436 Commits

Author SHA1 Message Date
Wilman Yesid Farfan Diaz
3277d41227 feat: implement core backend-api, events-relay, telemetry-ingestor, and build-orchestrator services with prometheus monitoring infrastructure 2026-07-07 22:45:04 -05:00
9ea2f48088 refactor: remove unused WiX utility namespace and service auto-restart configuration 2026-07-07 21:38:26 -05:00
36cd78b870 refactor: encapsulate Windows named pipe creation in a synchronous function to avoid Send bound issues in async tasks 2026-07-07 21:38:26 -05:00
7a38146744 fix: define SECURITY_DESCRIPTOR_REVISION constant to support newer windows-sys versions 2026-07-07 21:38:26 -05:00
af2fc3c6f4 chore: update tray-icon and windows-sys dependencies in edge-agent 2026-07-07 21:38:26 -05:00
94fd169e20 refactor: improve edge-tray UI responsiveness, modernize IPC pipe creation, and update Windows dependencies 2026-07-07 21:38:26 -05:00
cf2448b439 chore: update Dockerfile to copy all icon files instead of a single specific one 2026-07-07 21:38:25 -05:00
henryor
9db196eea2 feat(admin): add trends detail modal 2026-07-07 21:38:25 -05:00
henryor
e5547e0134 feat(admin): paginate trends variables 2026-07-07 21:38:25 -05:00
henryor
368d9f4928 feat(admin): add trends operational summary 2026-07-07 21:38:25 -05:00
henryor
5d2cdd8ff5 fix(admin): improve trends data integrity 2026-07-07 21:38:25 -05:00
henryor
76c8ad109f fix(admin): remove variables trend column 2026-07-07 21:38:25 -05:00
7e9c317a5f feat: add application icon and implement automatic process termination for edge-tray during installation 2026-07-07 21:38:24 -05:00
588a0aab09 feat: implement real-time OTA update progress reporting and tray icon status indicators 2026-07-07 21:38:24 -05:00
0d91b49dec feat: implement granular RLS policies for operations_downtime table instead of generic helper call 2026-07-07 21:38:24 -05:00
52fc377755 refactor: migrate asset-dependent tables to custom RLS policies based on asset_id mapping 2026-07-07 21:38:24 -05:00
867ebceb4f refactor: remove RLS configuration for telemetry_alarms table in multi-tenancy migration 2026-07-07 21:38:24 -05:00
99f73f7e2a chore: remove row level security policies from telemetry_raw table 2026-07-07 21:38:24 -05:00
e5a67cab90 refactor: move ipc_protocol module declaration and optimize frontend Docker multi-stage build cache 2026-07-07 21:38:24 -05:00
1f6743590e feat: add crash reporting via MQTT, implement periodic data store-and-forward drainage, and enforce periodic signal publication in deadband filter to comply with ANH 0651 regulations. 2026-07-07 21:38:24 -05:00
henryor
bc01767106 feat(pwa): improve field measurement capture UX 2026-07-07 21:38:23 -05:00
henryor
342bd08bdd fix(pwa): limit login credential length 2026-07-07 21:38:23 -05:00
henryor
6f6ed2fa36 feat(pwa): clarify field navigation 2026-07-07 21:38:23 -05:00
henryor
2a18ada082 fix(pwa): pass asset into measurement fields 2026-07-07 21:38:23 -05:00
henryor
0ce9d2d550 feat(pwa): add field home dashboard 2026-07-07 21:38:23 -05:00
henryor
fb017396f0 fix(pwa): restore measurement fields and well copy 2026-07-07 21:38:23 -05:00
henryor
f16b112978 fix(admin): clean operator well labels 2026-07-07 21:38:23 -05:00
henryor
3e6e3c559d feat(pwa): add well-scoped operator access 2026-07-07 21:38:22 -05:00
henryor
fae8173199 feat(pwa): rebalance field layout navigation 2026-07-07 21:38:22 -05:00
henryor
5948998f3d feat(pwa): add feedback primitives 2026-07-07 21:38:22 -05:00
henryor
3815268b8a feat(pwa): add accessible form primitives 2026-07-07 21:38:22 -05:00
henryor
50bee181f6 fix(pwa): improve primary token contrast 2026-07-07 21:38:21 -05:00
henryor
da35b72f8f chore(pwa): point shadcn config at consolidated globals.css 2026-07-07 21:38:21 -05:00
henryor
2908ac0c66 fix(admin): stabilize well detail variable grid 2026-07-07 21:38:21 -05:00
henryor
43b8349447 feat(pwa): re-skin measurements feature with semantic design tokens
Replace hardcoded hex colors and raw Tailwind palette classes
(amber-*, red-*, emerald-*, slate-*) in MeasurementPage, PozoFields,
TanqueFields, DynamicManualFields, AssetSelector, MeasurementForm and
SyncStatusBadge with the semantic tokens consolidated in globals.css
(primary, status-*, card, border, muted-foreground, destructive).

SyncStatusBadge's offline state now uses the dedicated status-offline
(gray) token instead of reusing status-error (red), matching the
design's 5-state status palette. No logic or behavior changes — visual
only, matching the movements feature's existing token usage.

Adds a content-based Vitest guard (measurements-tokens.test.ts) that
fails if any hardcoded hex or raw palette class reappears in this
feature.
2026-07-07 21:38:21 -05:00
henryor
9b6b488c8a chore(pwa): delete dead config and orphaned stylesheets
tailwind.config.js is dead under Tailwind v4 (no @config directive
references it, postcss.config.js loads @tailwindcss/postcss directly,
and globals.css already defines OKLCH tokens the legacy HSL palette
never matched).

src/index.css and src/styles/index.css are orphaned duplicates of
globals.css with no imports anywhere in the app. src/App.tsx and
src/App.css are a dead mock prototype superseded by the real router.

Pre-verified zero importers for every deleted file before removal;
Vitest suite stays green.
2026-07-07 21:38:21 -05:00
henryor
319ceaae30 feat(pwa): consolidate design tokens to OmniOil Orange + status palette
Re-key --color-primary/accent/ring in globals.css to OmniOil Orange
(oklch(0.70 0.19 41) = #f97316) in both the @theme inline block and the
:root back-compat block, keeping them in lockstep.

Add 5 named status tokens (synced/pending/syncing/error/offline) with
foreground pairs, a type scale with a 16px body floor, and a 48px
touch-target spacing token. These are the foundation for the
field-design-system StatusBadge and form primitives landing in
follow-up PRs.
2026-07-07 21:38:21 -05:00
henryor
8e0a2b78bb feat(admin): link panorama well cards 2026-07-07 21:38:20 -05:00
henryor
50674b3078 fix(admin): clarify well critical alerts 2026-07-07 21:38:20 -05:00
henryor
d47360f86c fix(admin): stabilize panorama variable metrics 2026-07-07 21:38:20 -05:00
henryor
6cc6b4cc1a fix(billing): align commercial classification contract 2026-07-07 21:38:20 -05:00
henryor
7121ca9f47 feat(admin): show commercial billing breakdown 2026-07-07 21:38:20 -05:00
henryor
6a8cc55978 feat(console): classify commercial customers 2026-07-07 21:38:19 -05:00
henryor
d1adbe436a feat(billing): add commercial profile backend 2026-07-07 21:38:19 -05:00
henryor
895abef058 feat(billing): add commercial profile rules foundation 2026-07-07 21:38:19 -05:00
henryor
10e5993d2b style(admin): restyle billing page 2026-07-07 21:38:19 -05:00
henryor
ed6312f3a8 fix: stabilize post-merge validation 2026-07-07 21:38:19 -05:00
henryor
f5cea71798 feat(billing): show hf lf usage in admin 2026-07-07 21:38:19 -05:00
henryor
74517e2612 feat(billing): add hf lf usage backend 2026-07-07 21:38:18 -05:00
henryor
b084cf4f93 fix(admin): reserve well hf grid height 2026-07-07 21:38:18 -05:00
henryor
21a43f189e fix(admin): stabilize well lf readings 2026-07-07 21:38:18 -05:00
henryor
2a166396a0 fix(auth): restore sessions from refresh cookie 2026-07-07 21:38:18 -05:00
henryor
df718085cf fix(admin): match agent logs by well context 2026-07-07 21:38:18 -05:00
henryor
ef069b2ab9 fix(admin): stabilize well history charts 2026-07-07 21:38:18 -05:00
henryor
f55a022eb7 fix(admin): derive well lf captures from raw telemetry 2026-07-07 21:38:17 -05:00
henryor
b2bf8d21c8 fix(admin): show well lf captures from history 2026-07-07 21:38:17 -05:00
henryor
a18f059af1 feat(admin): optimize variables explorer 2026-07-07 21:38:17 -05:00
henryor
f0ed6166a2 feat(admin): show lf capture fields 2026-07-07 21:38:17 -05:00
henryor
1a704b5197 docs(telemetry): document origin contract 2026-07-07 21:38:16 -05:00
henryor
1aa40c9067 feat(admin): show dashboard telemetry by origin 2026-07-07 21:38:16 -05:00
henryor
39b83dbbe8 feat(pwa): sync lf writes with idempotency keys 2026-07-07 21:38:16 -05:00
henryor
db3304ed43 fix(api): dedupe movement retries by client id 2026-07-07 21:38:16 -05:00
henryor
91c7f12e25 fix(telemetry): handle compressed chunks in migration 2026-07-07 21:38:15 -05:00
henryor
240f8ac87a feat(telemetry): split silence alarms by origin 2026-07-07 21:38:15 -05:00
henryor
744e43c93f feat(telemetry): persist scada mqtt by resolved device 2026-07-07 21:38:15 -05:00
henryor
eb46661d3f fix(telemetry): persist lf source with project access 2026-07-07 21:38:15 -05:00
henryor
87d32acb30 feat(telemetry): add source contract schema 2026-07-07 21:38:14 -05:00
Wilman Yesid Farfan Diaz
3ce893f24b merge: sync dev with updated main 2026-06-26 21:58:59 -05:00
Wilman Yesid Farfan Diaz
959c89b5d3 fix(edge): resolve 0xc0000409 crash and configure auto-restart 2026-06-26 21:58:48 -05:00
Wilman Yesid Farfan Diaz
f38ca951f7 merge: sync dev with main overwriting conflicts 2026-06-26 21:58:22 -05:00
Wilman Yesid Farfan Diaz
4ccc1bbf90 fix(edge): resolve 0xc0000409 crash and configure auto-restart 2026-06-26 21:57:58 -05:00
henryor
df31ea1041 perf(admin): progressively render high-frequency variables 2026-06-26 21:24:45 -05:00
Wilman Yesid Farfan Diaz
e661caa514 feat: add multi-stage Dockerfiles for centralized service building and deployment 2026-06-26 21:16:16 -05:00
Wilman Yesid Farfan Diaz
d9daa6e6e4 feat: implement multi-stage Docker builds using cargo-chef for all backend services and optimize frontend build caching 2026-06-26 21:03:55 -05:00
henryor
5b68e2c80e fix(admin): keep variables explorer data stable 2026-06-26 20:42:48 -05:00
henryor
5b58da0f18 fix(admin): hide inactive high-frequency variables 2026-06-26 19:06:53 -05:00
henryor
70d1922906 fix(admin): sum panorama variables by well 2026-06-26 18:41:14 -05:00
henryor
d97fc76f16 docs(landing): add Spanish OmniOil Personal docs 2026-06-26 15:51:30 -05:00
henryor
eaaf368053 test(landing): enforce docs content policy 2026-06-26 15:01:12 -05:00
henryor
40cd3ece33 test(landing): cover OmniOil Personal docs 2026-06-26 14:46:17 -05:00
henryor
333c3b3b21 docs(landing): link OmniOil Personal documentation 2026-06-26 14:25:49 -05:00
henryor
5cd46f75a0 docs(landing): add OmniOil Personal docs routes 2026-06-26 09:14:36 -05:00
henryor
f250ad11de fix(admin): scope dashboard by selected well 2026-06-26 07:43:36 -05:00
c8a8089243 Merge pull request 'feat(admin): agregar panorama operacional del dashboard' (#17) from feat/admin-panorama-operations-dashboard into main
Reviewed-on: #17
2026-06-26 00:30:51 +00:00
henryor
565b0c0a61 feat(admin): add panorama operations dashboard 2026-06-25 19:27:09 -05:00
9b806d3fec Merge pull request 'feat/admin-well-dashboard-history' (#15) from feat/admin-well-dashboard-history into main
## Resumen
- Agrega la vista histórica de dashboard por pozo.
- Oculta el selector cuando no hay pozos reales configurados.
- Distingue el estado “Seleccioná un proyecto” del estado “Sin pozos configurados”.
- Preserva la ruta `/realtime` y suma `/wells/:wellId`.

## Verificación
- [x] `pnpm test -- DashboardPage WellDashboardPage`
- [x] `pnpm lint`
- [x] `git diff --check origin/main...HEAD`
2026-06-26 00:21:47 +00:00
henryor
64c42a02e4 test(admin): remove stale dashboard alarm assertion 2026-06-25 19:17:34 -05:00
henryor
338967b949 fix(admin): distinguish unresolved dashboard project 2026-06-25 19:17:34 -05:00
henryor
b331b05d55 feat(admin): add well dashboard history view 2026-06-25 19:17:34 -05:00
henryor
6dd7754206 fix(admin): hide empty well selector 2026-06-25 19:14:50 -05:00
30a16e5b6c Merge pull request 'style(admin): restyle ANH reports console' (#16) from feat/admin-anh-reports-polish into main
Reviewed-on: #16
2026-06-26 00:14:06 +00:00
e229f0ae50 Merge pull request 'feat/admin-project-config-deploy-toggle' (#14) from feat/admin-project-config-deploy-toggle into main
Reviewed-on: #14
2026-06-26 00:12:28 +00:00
47d31837e9 Merge pull request 'feat(simulator): add scalable Modbus load simulator' (#13) from feat/simulator-scalable-modbus-load into main
Reviewed-on: #13
2026-06-26 00:11:52 +00:00
04ef63537c Merge pull request 'feat/admin-realtime-telemetry' (#12) from feat/admin-realtime-telemetry into main
Reviewed-on: #12
2026-06-26 00:11:06 +00:00
henryor
ce9ad1f8f0 style(admin): restyle ANH reports console 2026-06-25 19:04:08 -05:00
henryor
d9c01647ef feat(admin): wire realtime telemetry route 2026-06-25 18:56:56 -05:00
henryor
e615b06f7b feat(admin): add realtime telemetry views 2026-06-25 18:54:40 -05:00
henryor
9559eb4e7d feat(admin): add realtime telemetry view models 2026-06-25 18:54:39 -05:00
henryor
311a5422a9 feat(admin): toggle well collector deployment 2026-06-25 18:54:38 -05:00
henryor
132be581f5 feat(admin): add wells sidebar navigation 2026-06-25 18:54:38 -05:00
henryor
c43642218e feat(admin): add full project config client 2026-06-25 18:54:37 -05:00
henryor
91181431aa feat(simulator): add scalable Modbus load simulator 2026-06-25 18:54:23 -05:00
ac411b1893 Merge pull request 'fix(mqtt): soportar payloads grandes de deploy' (#11) from fix/mqtt-large-deploy-payloads into main
Reviewed-on: #11
2026-06-25 23:18:13 +00:00
henryor
d70efd6b5e fix(mqtt): support larger deploy payloads 2026-06-25 18:08:56 -05:00
49431911fc Merge pull request 'fix(edge): recuperar builds de instalador detenidos' (#10) from fix/edge-installer-recovery into main
Reviewed-on: #10
2026-06-25 23:08:01 +00:00
henryor
f28cb4dad7 fix(edge): recover stalled installer builds 2026-06-25 18:00:22 -05:00
baa906001e Merge pull request 'feat(admin): rediseñar workspace de detalle de proyecto' (#9) from feat/admin-project-detail-workspace into main
Reviewed-on: #9
2026-06-25 22:58:58 +00:00
henryor
f9151dd9ba test(admin): normalize router navigation paths 2026-06-25 17:48:15 -05:00
henryor
f009c43bd2 fix(admin): refresh sidebar after project creation 2026-06-25 17:48:15 -05:00
henryor
113be509ee fix(admin): refresh sidebar after project changes 2026-06-25 17:48:14 -05:00
henryor
439b80dca8 feat(admin): restyle project detail workspace 2026-06-25 17:48:14 -05:00
51e27e49f4 Merge pull request 'feat(admin): renovar vista de proyectos y shell visual' (#8) from feat/admin-projects-overview-visuals into main
Reviewed-on: #8
2026-06-25 22:46:53 +00:00
henryor
948b372f40 style(admin): polish projects overview cards 2026-06-25 17:35:18 -05:00
henryor
3c6cc42216 feat(admin): compact projects overview cards 2026-06-25 17:35:18 -05:00
henryor
675b3033a4 feat(admin): align shell visual system 2026-06-25 17:35:18 -05:00
henryor
b5357a3d0c feat(admin): restyle projects list 2026-06-25 17:35:18 -05:00
761a4ab5a4 Merge pull request 'feat(admin): rediseñar panel SCADA de proyecto' (#7) from feat/admin-project-dashboard-shell into main
Reviewed-on: #7
2026-06-25 22:27:15 +00:00
henryor
89907fbb58 test(admin): use typed project dashboard fixtures 2026-06-25 17:19:45 -05:00
henryor
3dfe4c5659 feat(admin): restyle project agent logs 2026-06-25 17:19:45 -05:00
henryor
741034ac39 feat(admin): restyle project action console 2026-06-25 17:19:44 -05:00
henryor
8cfd7a085b feat(admin): restyle project agent health 2026-06-25 17:19:44 -05:00
henryor
88dc61512f feat(admin): restyle project well cards 2026-06-25 17:19:44 -05:00
henryor
18d8ffa375 feat(admin): add project scada dashboard shell 2026-06-25 17:19:44 -05:00
henryor
c1254566c1 refactor(admin): extract project dashboard layout 2026-06-25 17:19:44 -05:00
d5c768d9c3 Merge pull request 'feat(admin): add SCADA dashboard foundation' (#6) from feat/admin-dashboard-scada-foundation into main
Reviewed-on: #6
2026-06-25 22:16:06 +00:00
b7f1873504 Merge pull request 'chore(repo): ignore local generated artifacts' (#5) from chore/repo-ignore-local-generated-artifacts into main
Reviewed-on: #5
2026-06-25 20:42:55 +00:00
henryor
fac9ab3fe0 feat(admin): add dashboard scada empty states 2026-06-25 15:30:48 -05:00
henryor
3417d8afaf feat(admin): restyle telemetry chart panels 2026-06-25 15:30:48 -05:00
henryor
e6f5412ca2 feat(admin): restyle dashboard kpi cards 2026-06-25 15:30:48 -05:00
henryor
29e89a0a41 feat(admin): add scada dashboard header 2026-06-25 15:30:48 -05:00
henryor
3caf9ba42b refactor(admin): extract dashboard foundation components 2026-06-25 15:30:48 -05:00
henryor
acbf3cc56a chore(repo): ignore local generated artifacts 2026-06-25 15:29:52 -05:00
Wilman Yesid Farfan Diaz
e2c97bce74 feat: implement session tracking to display active users count in dashboard 2026-06-23 09:39:42 -05:00
Wilman Yesid Farfan Diaz
8bb323b2aa refactor: replace individual simulators with a unified multiprotocol simulator and add central documentation. 2026-06-23 09:33:58 -05:00
henryor
9ab9579cad feat(admin): filter agent logs by well 2026-06-22 22:08:37 -05:00
henryor
82777aa132 fix(admin): resolve telemetry wells without active project 2026-06-22 20:11:10 -05:00
henryor
45e945f82b fix(admin): keep telemetry selector above cards 2026-06-22 17:00:10 -05:00
henryor
5d190f00d2 feat(admin): filter dashboard telemetry by well 2026-06-22 10:41:27 -05:00
henryor
64914f98cd fix(admin): normalize well asset aliases 2026-06-22 10:04:10 -05:00
henryor
967e306e44 fix(admin): simplify well card actions 2026-06-22 09:44:15 -05:00
henryor
4b9fc196e5 fix(anh): use Colombia timezone in report JSON 2026-06-22 08:06:22 -05:00
henryor
f679bceca1 feat(admin): improve Edge agent log viewer 2026-06-22 06:25:36 -05:00
henryor
9070b5cacd feat(admin): improve Edge project detail UX 2026-06-22 06:25:36 -05:00
Wilman Yesid Farfan Diaz
a3401cfd95 feat: implement Telegram notifications and add rate-limited system error alerts to watchdog checks 2026-06-22 02:11:07 -05:00
henryor
9cccb8ba0e fix(security): replace telemetry websocket JWT query 2026-06-20 20:19:45 -05:00
henryor
28301e68fa feat(anh): split daily generation and regeneration 2026-06-20 19:47:42 -05:00
henryor
1511f13970 fix(admin): avoid duplicate input focus outline 2026-06-20 19:14:41 -05:00
henryor
139dbaa9a9 feat(admin): clarify Edge project status 2026-06-20 18:58:03 -05:00
henryor
0e66f4a04c feat(admin): show ANH report creation time 2026-06-20 18:46:42 -05:00
henryor
1448f6f530 fix(admin): gate layout alarm fetches 2026-06-20 18:28:06 -05:00
henryor
9bdb804dfd fix(api): limit offline agent noise 2026-06-20 17:56:51 -05:00
henryor
b923e75103 fix(admin): avoid global alert fetches 2026-06-20 17:46:01 -05:00
henryor
a02a10556e fix(api): scope alarms to tenant access 2026-06-20 17:36:32 -05:00
henryor
5c2ddac702 fix(anh): stabilize scheduled report generation 2026-06-20 09:11:13 -05:00
Wilman Yesid Farfan Diaz
55f189f000 chore: update INVITATION_BASE_URL, realign backend ports, and remove redundant VITE_API_URL build arguments 2026-06-19 15:59:11 -05:00
Wilman Yesid Farfan Diaz
5ebd31b049 fix(dev): port mappings to 3030 and 8080 to avoid conflicts 2026-06-19 14:33:48 -05:00
Wilman Yesid Farfan Diaz
bc021bd80f fix(seed): insert telemetry with associated asset_id 2026-06-19 14:10:44 -05:00
Wilman Yesid Farfan Diaz
7f717dd84d feat(telemetry): normalize variables in dashboard and fix ingestor thresholds 2026-06-19 14:01:56 -05:00
Wilman Yesid Farfan Diaz
7cbae7d3cc refactor: optimize Docker build caching and update error logging in credential store 2026-06-19 11:20:04 -05:00
henryor
00d130d776 fix(landing): update operational plan pricing 2026-06-19 11:08:35 -05:00
henryor
60764cce50 fix(admin): route user QR to PWA movements 2026-06-19 11:04:33 -05:00
henryor
0e6ed3b5db fix(admin): restrict customer user roles 2026-06-19 09:14:29 -05:00
henryor
1617c89f8f fix(api): support alarm acknowledgement migration on hypertables 2026-06-19 09:14:21 -05:00
Wilman Yesid Farfan Diaz
875a5a1106 feat: consolidate frontend services into a unified pnpm workspace and build pipeline with optimized Docker configuration 2026-06-19 08:47:21 -05:00
henryor
b077adf0f8 fix(billing): avoid compile-time database checks 2026-06-19 08:16:17 -05:00
henryor
ea1cd59f33 feat(admin): acknowledge grouped alerts 2026-06-19 08:16:07 -05:00
henryor
161d330801 fix(admin): handle nullable alerts 2026-06-19 06:09:22 -05:00
Wilman Yesid Farfan Diaz
9635ec379b refactor: consolidate Docker build stages and optimize cross-compilation pipeline for Windows/Linux targets 2026-06-19 01:38:01 -05:00
Wilman Yesid Farfan Diaz
739ee9d413 chore: add build script and config file to docker context for edge-agent 2026-06-19 00:35:27 -05:00
Wilman Yesid Farfan Diaz
43307fea12 chore: enable locked sharing for Docker cargo registry and git cache mounts 2026-06-19 00:17:48 -05:00
Wilman Yesid Farfan Diaz
3046823bb4 feat: implement automatic Linux binary upload to MinIO in build-orchestrator and optimize Docker build caching and ignore patterns. 2026-06-19 00:14:32 -05:00
Wilman Yesid Farfan Diaz
f6ea571613 perf: add target cache mounts to Docker build stages and optimize dockerignore build context filtering 2026-06-18 23:42:34 -05:00
henryor
11f29f9248 chore(rust): normalize workspace formatting 2026-06-18 23:03:50 -05:00
henryor
b2eb75d824 fix(admin): clean frontend typecheck debt 2026-06-18 23:03:49 -05:00
henryor
bb27ad14aa fix(admin): remove demo audit page 2026-06-18 23:03:49 -05:00
Wilman Yesid Farfan Diaz
88fe56b11e CAmbio de Dockerfiles para mejorar velocidad. 2026-06-18 22:27:06 -05:00
henryor
8f1fd2d098 docs(anh): document correction disposition flow 2026-06-18 21:26:09 -05:00
henryor
192b22b247 feat(admin): add anh correction review flow 2026-06-18 21:26:09 -05:00
henryor
0bae416111 fix(anh): protect scheduled report versions 2026-06-18 21:26:09 -05:00
henryor
42cda1ce70 feat(anh): add report correction workflow 2026-06-18 21:26:08 -05:00
henryor
d709629e66 feat(admin): improve anh report review ui 2026-06-18 06:11:14 -05:00
henryor
b799cb3109 fix(anh): exclude unconfirmed report scopes 2026-06-18 06:11:14 -05:00
0f6305e404 Merge pull request 'OptimizacionesDocker + permisos en local carpeta mapeadas' (#4) from OptimizacionesDocker into main
Reviewed-on: #4
2026-06-17 16:34:53 +00:00
Wilman Yesid Farfan Diaz
1571d2fa69 chore: update entrypoint and build scripts to use environment variables for configuration management 2026-06-17 11:32:36 -05:00
henryor
0179e593e1 fix(admin): hide API docs navigation 2026-06-17 09:13:50 -05:00
Wilman Yesid Farfan Diaz
3013b8ca66 feat: optimize Dockerfile by introducing a runtime base image for all services to reduce redundancy in package installations 2026-06-17 09:00:58 -05:00
henryor
8d2b5ef72a feat(admin): improve ANH reports dashboard 2026-06-17 08:52:34 -05:00
Wilman Yesid Farfan Diaz
09c5451876 chore: optimize Docker build caching by adding per-service Dockerfiles, normalizing build contexts, and documenting optimization strategies. 2026-06-17 08:13:16 -05:00
Wilman Yesid Farfan Diaz
7bb5cd658a feat: implement cargo-chef multi-stage builds for edge-agent and update Docker ignore patterns 2026-06-17 08:05:17 -05:00
Wilman Yesid Farfan Diaz
87a4d0427e feat: add WiX installer configuration and Linux build script for Windows MSI generation 2026-06-16 22:48:47 -05:00
Wilman Yesid Farfan Diaz
99e8dec729 feat: implement json-generator service and edge-agent, and optimize Docker unified build process 2026-06-16 21:41:50 -05:00
Wilman Yesid Farfan Diaz
d3ec5f8645 feat: implement multi-stage Dockerfiles for individual services using cargo-chef and update unified build configuration 2026-06-16 20:57:47 -05:00
henryor
a166e8ea0e feat(anh): auto provision schedules for confirmed contracts 2026-06-16 20:29:29 -05:00
henryor
9953a49eb3 fix(access): restore invitation resend test helpers 2026-06-16 20:29:28 -05:00
Wilman Yesid Farfan Diaz
f2ed516748 feat: add multi-stage Dockerfile for cross-compiling and MSI generation of edge-agent 2026-06-16 20:24:56 -05:00
Wilman Yesid Farfan Diaz
b1d60a5f9a feat: implement multi-stage Docker builder for Windows/Linux and add provisioning configuration loader 2026-06-16 20:22:41 -05:00
Wilman Yesid Farfan Diaz
99ed6a88a1 feat: implement multi-stage Docker builder for cross-compilation of edge-agent binaries and MSI generation 2026-06-16 19:30:34 -05:00
Wilman Yesid Farfan Diaz
257976167a feat: add multi-stage Dockerfile for cross-compiling and building edge-agent MSI installer 2026-06-16 19:04:55 -05:00
Wilman Yesid Farfan Diaz
74e7832aef feat: implement core edge-agent services, backend handlers for protocol imports and reports, and frontend admin modules 2026-06-16 18:35:11 -05:00
Wilman Yesid Farfan Diaz
80b0457396 feat: include json-generator service in the unified Docker build process 2026-06-16 17:24:36 -05:00
Wilman Yesid Farfan Diaz
3721fa7d12 feat: add SMTP, 2FA, and URL configurations to environment files and include json-generator in unified Docker build 2026-06-16 17:24:01 -05:00
Wilman Yesid Farfan Diaz
d486c07f87 feat: implement optimized multi-stage MSI builder for edge-agent and simplify unified build pipeline 2026-06-16 16:22:09 -05:00
Wilman Yesid Farfan Diaz
e6e091895e feat: add multi-stage Docker builder for unified workspace services and Windows edge-agent cross-compilation 2026-06-16 16:22:09 -05:00
Wilman Yesid Farfan Diaz
9ee1e11608 build: configure cross-compilation environment for Windows with static linking and OPC-UA support 2026-06-16 16:22:08 -05:00
henryor
4125ec627b fix(anh): clarify report approval messaging 2026-06-16 16:22:08 -05:00
henryor
0d6c006297 feat(admin): configure anh report schedule 2026-06-16 16:22:08 -05:00
henryor
6c234f60fd feat(anh): run scheduled reports from database 2026-06-16 16:22:08 -05:00
henryor
3f850db0ae feat(anh): add report schedule configuration 2026-06-16 16:22:07 -05:00
Wilman Yesid Farfan Diaz
14b0a03b14 feat: implement asymmetric provisioning flow for edge agent and add installer worker for orchestration 2026-06-16 16:22:07 -05:00
henryor
f18bfe65c3 fix(admin): download anh reports with auth 2026-06-16 16:22:07 -05:00
henryor
9fdf049ea8 test(anh): harden sealed download coverage 2026-06-16 16:22:07 -05:00
henryor
13562d3298 feat(anh): seal generated report downloads 2026-06-16 16:22:06 -05:00
henryor
c71c58c5aa feat(anh): add sealed report metadata foundation 2026-06-16 16:22:06 -05:00
henryor
9f13d4f274 fix(admin): repair settings profile security 2026-06-16 16:22:06 -05:00
henryor
9e65a0b447 fix(admin): remove inactive header search 2026-06-16 16:22:06 -05:00
henryor
edcd02e469 fix(admin): scope user management by project 2026-06-16 16:22:06 -05:00
henryor
88fb707a66 fix(console): show trial and invitation expiry 2026-06-16 16:22:06 -05:00
Wilman Yesid Farfan Diaz
ea035b4b60 feat: add agent download token management and implement edge project CRUD handlers 2026-06-16 16:22:06 -05:00
henryor
be3fcf1f28 feat(landing): add trial pricing option 2026-06-16 16:22:05 -05:00
henryor
a97f968be2 feat(auth): brand trial activation email 2026-06-16 16:22:05 -05:00
henryor
0ada878809 fix(auth): dedupe access requests before unique index 2026-06-16 16:22:05 -05:00
henryor
fd4220c98d feat(auth): automate trial access approval 2026-06-16 16:22:05 -05:00
Wilman Yesid Farfan Diaz
0329296ea5 feat: implement modular multi-protocol data collector agent with support for Modbus, S7, OPC-UA, MQTT Sparkplug B, and SQL drivers. 2026-06-16 16:22:05 -05:00
henryor
bfda385620 feat(landing): mejora privacidad SEO y accesibilidad
Alinea OmniOil como servicio operado por Kyrbot Innovation S.A.S., agrega la política de privacidad y refuerza la landing con SEO, accesibilidad, reduced motion y pruebas runtime.
2026-06-16 16:22:04 -05:00
henryor
690f7ff48d fix(landing): ajusta textos técnicos de arquitectura 2026-06-16 16:22:04 -05:00
henryor
e7c142f4cb feat(landing): simplifica precios por tags 2026-06-16 16:22:04 -05:00
henryor
5734abe176 fix(landing): ajusta texto de alertas por correo 2026-06-16 16:22:04 -05:00
henryor
6e755e2b56 fix(landing): retira botón de agendar demo del hero 2026-06-16 16:22:04 -05:00
Wilman Yesid Farfan Diaz
4c7e092ffa feat: add terms and conditions page with multi-language support 2026-06-16 16:22:03 -05:00
Wilman Yesid Farfan Diaz
095ec2ff25 feat: implement ANH report management handlers and include json-generator in Docker build 2026-06-16 16:22:03 -05:00
Wilman Yesid Farfan Diaz
66229ab050 Generaci[on del json mejorado 2026-06-16 16:22:03 -05:00
henryor
83e067b334 refactor(admin): migrate user form modal to UI kit 2026-06-16 16:22:03 -05:00
henryor
c12deda9f9 refactor(admin): migrate users page to UI kit 2026-06-16 16:22:03 -05:00
henryor
c45862f49d feat(admin): add admin UI compositions 2026-06-16 16:22:03 -05:00
henryor
eec83ea63d feat(admin): add reusable UI primitives 2026-06-16 16:22:02 -05:00
henryor
5ce407d5b1 retiro imagenes antiguas 2026-06-16 16:22:02 -05:00
henryor
e1f8d968a8 refactor(admin): extract admin child routes for reuse 2026-06-16 16:22:02 -05:00
henryor
940f58ecdd test(admin): add Select component tests and update existing test coverage 2026-06-16 16:22:02 -05:00
henryor
4126ea9aa0 refactor(admin): align visual language across dashboard, users, login and UI components 2026-06-16 16:22:02 -05:00
henryor
b7c348b655 feat(admin): add desktop collapsible sidebar with user identity topbar 2026-06-16 16:22:01 -05:00
henryor
509c91321f fix(api): pass production SMTP env 2026-06-16 16:22:01 -05:00
henryor
2928da900f feat(console): manage platform clients and operators 2026-06-16 16:22:01 -05:00
henryor
0698ca2471 fix(auth): restrict access approvals 2026-06-16 16:22:01 -05:00
henryor
686704b658 fix(edge): restore MSI builder build 2026-06-16 16:22:01 -05:00
henryor
a777a99367 test(auth): verify access provisioning flows 2026-06-16 16:22:01 -05:00
henryor
13aea702f3 test(auth): wire DB integration to handlers 2026-06-16 16:22:00 -05:00
henryor
b43c41d2ae test(auth): add DB integration harness 2026-06-16 16:22:00 -05:00
henryor
0e43805154 docs(sdd): verify remaining access blockers 2026-06-16 16:22:00 -05:00
henryor
2ecca522cf fix(auth): align provisioned project defaults 2026-06-16 16:22:00 -05:00
henryor
37780092e3 docs(sdd): mark fmt blocker external 2026-06-16 16:22:00 -05:00
henryor
7e6993fc72 docs(sdd): document DB test blocker 2026-06-16 16:22:00 -05:00
henryor
8035f5005e docs(sdd): verify access-ready resend 2026-06-16 16:22:00 -05:00
henryor
7fb03039bd fix(auth): resend access-ready notifications 2026-06-16 16:21:59 -05:00
henryor
b90b91f7db docs(sdd): refresh access provisioning verification 2026-06-16 16:21:59 -05:00
henryor
fb0764bf9a docs(auth): document invitation deployment 2026-06-16 16:21:59 -05:00
henryor
86c85c4110 docs(sdd): update access provisioning verification 2026-06-16 16:21:59 -05:00
henryor
8b572457e0 test(auth): cover invitation edge cases 2026-06-16 16:21:59 -05:00
henryor
bd473719bb test(admin): cover invitation activation 2026-06-16 16:21:59 -05:00
henryor
b4dcae280a feat(console): show access provisioning status 2026-06-16 16:21:59 -05:00
henryor
0c510e120b fix(auth): handle invitation email failures 2026-06-16 16:21:58 -05:00
henryor
92675d9616 feat(auth): provision approved access requests 2026-06-16 16:21:58 -05:00
Wilman Yesid Farfan Diaz
932932e9e6 chore: update MinIO hostnames, add TOTP key, include Turnstile credentials, and configure OTA agent environment variables 2026-06-16 16:21:58 -05:00
Wilman Yesid Farfan Diaz
c9d7f82b2d fix(docker): add .dockerignore to frontend-console and landing
Build context is the service subdirectory, so Docker does not use
the root .dockerignore. Without a local .dockerignore, the local
node_modules (Windows binaries) was copied into the Alpine container,
overwriting the pnpm-installed packages and causing vite to disappear.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-06-16 16:21:58 -05:00
Wilman Yesid Farfan Diaz
5e385667e8 fix(docker): add perl + cmake for vendored OpenSSL build
OpenSSL's Configure script is written in Perl — without it vendored
openssl-sys fails with 'perl: not found' in debian:bookworm-slim images.

Affected images: build-orchestrator, edge-agent/Dockerfile.builder (cacher + builder stages)

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-06-16 16:21:58 -05:00
Wilman Yesid Farfan Diaz
1652669aad fix(edge): vendored OpenSSL for opcua-support — no external OpenSSL required
- openssl = { version = '0.10', features = ['vendored'] } compiles OpenSSL
  from source as part of the Rust build; no OPENSSL_DIR or vcpkg needed
- opcua-support feature now activates both dep:opcua + dep:openssl (vendored)
- Dockerfile.builder: add cmake to cacher + builder stages (required for vendored build)
- Dockerfile.builder: set OPENSSL_NO_ASM=1 to avoid nasm dependency in cross-compile
- Dockerfile.builder: add --features edge-agent/opcua-support to production cargo builds
- build-orchestrator/Dockerfile: add cmake for runtime agent builds

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-06-16 16:21:58 -05:00
Wilman Yesid Farfan Diaz
10fce1119f refactor(edge): MQTT-only agent — remove HTTP polling, embed OTA payload in command
- Remove run_updater() periodic HTTP polling loop entirely
- Remove check_and_perform_update() and ServerSettings (api_url/api_token)
- AgentCommand::OtaUpdate now carries download_url + sha256 + asset_type
- Backend trigger_ota_update generates download URL + fetches SHA-256 before publishing MQTT
- Agent makes ONE outbound HTTPS GET only when OTA is commanded (no stored credentials)
- LogReporter::new cleaned up (removed unused api_url/api_token params)
- UpdaterSettings simplified: only prefer_msi + verify_signature remain
- Existing agent.toml with [server] or [updater] enabled keys ignored safely (serde)

Security model: MQTT is the only persistent channel. Binary download is a
single outbound HTTPS request using a temporary URL embedded in the MQTT command.
All traffic flows: Agent → MQTT broker (persistent) + Agent → Frontend/Nginx → Backend → MinIO (one-shot on OTA).

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-06-16 16:21:58 -05:00
Wilman Yesid Farfan Diaz
1d1b13b478 feat(edge): OPC UA node scanner (v0.3.0)
- shared-lib: Add OpcUaScannedNode and OpcUaScanResult types to mqtt_messages
- edge-agent: Bump version 0.2.0 → 0.3.0
- edge-agent: Add optional opcua dep + opcua-support feature flag
- edge-agent/drivers/opc_ua: Add browse_nodes() with cfg-gated real impl
- edge-agent/mqtt_listener: Add ScanOpcUa command variant and MQTT handler
- edge-agent/main: Handle AgentCommand::ScanOpcUa, publish results to MQTT
- backend-api: Migration 20260511000000_opcua_scan_results.sql
- backend-api/handlers/edge_devices: trigger_opcua_scan + get_opcua_scan_result
- backend-api/main: MQTT handler for omnioil/opcua-scan/+, add API routes
- build-orchestrator: Enable opcua-support feature when building agent
- frontend-admin/variables-api: OPC UA scanner API types and functions
- frontend-admin/DeviceConfigModal: OPC UA node browser UI with polling
- .env.example.*: AGENT_LATEST_VERSION 0.2.0 → 0.3.0

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-06-16 16:21:57 -05:00
Wilman Yesid Farfan Diaz
d7dc59b7f3 feat: add admin dashboard modules, alerts management, and infrastructure entrypoints 2026-06-16 16:21:57 -05:00
henryor
9cfddf0be6 docs(sdd): define provisionamiento de solicitudes aprobadas 2026-06-16 16:21:57 -05:00
henryor
4f91382b5e fix(console): usa logo de footer en consola 2026-06-16 16:21:56 -05:00
henryor
e29210b16c fix(landing): corrige envio de solicitudes 2026-06-16 16:21:56 -05:00
Wilman Yesid Farfan Diaz
b8ab916c8d feat: add compliance section, pricing calculator, and supporting layout components for landing page 2026-06-16 16:21:56 -05:00
henryor
d2a3143c85 feat(console): alinea identidad visual 2026-06-16 16:21:56 -05:00
henryor
473631d329 feat(landing): simplifica contenido comercial 2026-06-16 16:21:55 -05:00
henryor
70a2282716 feat(landing): actualiza logos de marca 2026-06-16 16:21:55 -05:00
henryor
fe0176da4a feat(console): implementa 2FA TOTP 2026-06-16 16:21:55 -05:00
henryor
2bc9eb3120 docs(openspec): archiva el change console-tech-debt-cleanup
Persiste proposal, specs delta (auth, api-client, access-requests,
platform-shell, testing), design con 8 ADRs, tasks (27 items en 10 fases),
verify-report (28 tests, 0 críticos, 0 warnings tras el cierre) y
archive-report final.

Cierra el ciclo SDD del cleanup de deuda técnica del console.
2026-06-16 16:21:54 -05:00
henryor
0bb57a0749 feat(console): mejora UX de errores y consolida isActionable en solicitudes
Antes, si la API de solicitudes fallaba, el operador veía una tabla vacía
sin distinguir 'no hay solicitudes' de 'la API se cayó'. Se agrega una card
de error con botón Reintentar que invoca refetch(); las stat cards usan
fallbacks suaves cuando allRequestsQuery está en error.

RequestDetail repetía el chequeo ['pending','in_review'].includes(status)
inline en tres lugares. Ahora usa el helper isActionable que ya existía,
quedando una sola fuente de verdad para 'la solicitud todavía se puede
accionar'.

Tests cubren error UI, click de Reintentar con refetch, y el comportamiento
de los botones Aprobar/Rechazar/Revisar para los cuatro estados posibles.
También quedan pinned los flujos de éxito y error de approve, reject y
review (toast + invalidación de cache).
2026-06-16 16:21:54 -05:00
henryor
3ca1d31ede feat(console): agrega ErrorBoundary en el shell de plataforma
Un componente hijo que tira durante el render dejaba toda la consola en
blanco, sin sidebar ni botón de salir. Se agrega un ErrorBoundary nativo
(class component, sin dependencias externas) que envuelve el Outlet dentro
de PlatformLayout. El fallback queda dentro del chrome del layout para que
el operador pueda navegar o salir aunque la página interna falle.

Login no necesita boundary (es un único formulario controlado).

Tests verifican que un hijo que tira muestra el fallback, que el fallback
expone un botón funcional, y que el render normal pasa derecho cuando no
hay error.
2026-06-16 16:21:54 -05:00
henryor
3be117cb29 fix(console): hidrata ProtectedRoute antes de redirigir al login
ProtectedRoute mostraba 'Restaurando sesión...' siempre en el primer render,
incluso cuando el usuario nunca tuvo sesión, porque el state inicial siempre
arrancaba sin token. La heurística ahora consulta hasHydrated() de Zustand y
usa el user persistido (que sí sobrevive al reload) para decidir si vale la
pena intentar refresh: si nunca hubo user, redirige directo al login sin
flash.

Tests cubren el cold visit (sin user, no se llama a refresh) y el warm visit
(con user persistido, muestra el restoring y resuelve).
2026-06-16 16:21:54 -05:00
henryor
2da824f1a6 refactor(console): separa apiClient y apiClientVoid para respuestas 204
apiClient devolvía null casteado a T en respuestas 204, lo que silenciaba un
error de tipos en tiempo de compilación. La auditoría confirmó que ningún
caller actual pega a un endpoint 204, por lo que el cast era código muerto.

Se separa en dos funciones:
- apiClient<T>: devuelve T y lanza si el server responde 204 sin body.
- apiClientVoid: devuelve void en 204, para endpoints que no retornan body.

Tests cubren la deduplicación del refresh ante 401 concurrentes, el path
200-con-body, el throw en 204 y el path void de apiClientVoid.
2026-06-16 16:21:54 -05:00
henryor
89590121a6 fix(console): unifica accessToken y alinea LoginResponse con el backend
El store de auth duplicaba el token en dos campos (token y accessToken) que
quedaban siempre iguales pero con consumers divididos. Se elimina token: el
accessToken queda como única fuente de verdad.

LoginResponse refleja el contrato real del backend (services/backend-api auth
handler emite access_token primario y token como legacy compat). LoginPage lee
response.access_token directo, sin fallback. refresh_token sale del body porque
viaja en cookie HttpOnly, no en el JSON.

Tests pinean el contrato: setSession y logout no escriben token, y los campos
de LoginResponse mantienen los tipos esperados.
2026-06-16 16:21:54 -05:00
henryor
83f4c54d2c chore(console): integra vitest y testing library
Configura el runner de tests inline en vite.config.ts replicando el setup de
frontend-admin (jsdom, setupFiles, restoreMocks, clearMocks). Agrega el archivo
src/test/setup.ts con el mock de storage y la flag IS_REACT_ACT_ENVIRONMENT.

Esto habilita escribir tests RED-GREEN para los cambios de auth, api-client,
ProtectedRoute y access-requests que vienen en los siguientes commits.
2026-06-16 16:21:54 -05:00
henryor
e45b05d288 fix(deploy): fija version de pnpm en frontends 2026-06-16 16:21:54 -05:00
henryor
e6a9a40ff8 fix(db): corrige historial de migraciones 2026-06-16 16:21:53 -05:00
henryor
14aa7c252a fix(landing): optimiza memoria y marca visual 2026-06-16 16:21:53 -05:00
henryor
2e42230f54 fix(landing): simplify hero actions 2026-06-16 16:21:53 -05:00
henryor
4217e8ec39 fix(deploy): check backend health with curl 2026-06-16 16:21:53 -05:00
henryor
9cdafcd262 feat(landing): move access requests to landing 2026-06-16 16:21:52 -05:00
henryor
1c08e44f45 feat(platform): add provider control plane 2026-06-16 16:21:52 -05:00
henryor
08e097d640 docs: agrega guion de presentación 2026-06-16 16:21:51 -05:00
henryor
330fb7cb30 feat(admin): add B2B access request flow 2026-06-16 16:21:51 -05:00
henryor
5301f72e28 docs: update presentation html 2026-06-16 16:21:51 -05:00
henryor
06c81822d0 docs: add presentation html 2026-06-16 16:21:51 -05:00
Wilman Yesid Farfan Diaz
073d80dc79 feat: implement optimized multi-stage MSI builder for edge-agent and simplify unified build pipeline 2026-06-16 13:45:22 -05:00
Wilman Yesid Farfan Diaz
cc5b308dfb feat: add multi-stage Docker builder for unified workspace services and Windows edge-agent cross-compilation 2026-06-16 13:44:40 -05:00
Wilman Yesid Farfan Diaz
8ce5904cf7 build: configure cross-compilation environment for Windows with static linking and OPC-UA support 2026-06-16 12:50:19 -05:00
henryor
5bce7cb5fc fix(anh): clarify report approval messaging 2026-06-16 10:48:26 -05:00
henryor
22962fdab8 feat(admin): configure anh report schedule 2026-06-16 10:48:25 -05:00
henryor
41c4a2cdfb feat(anh): run scheduled reports from database 2026-06-16 10:48:25 -05:00
henryor
a6fc71bcc9 feat(anh): add report schedule configuration 2026-06-16 10:48:25 -05:00
Wilman Yesid Farfan Diaz
4a7aa94cb9 feat: implement asymmetric provisioning flow for edge agent and add installer worker for orchestration 2026-06-16 10:11:38 -05:00
henryor
f95575a832 fix(admin): download anh reports with auth 2026-06-16 08:17:19 -05:00
henryor
12adbc2e8c test(anh): harden sealed download coverage 2026-06-16 07:37:20 -05:00
henryor
b3fe40637d feat(anh): seal generated report downloads 2026-06-16 07:22:54 -05:00
henryor
fceb8fe1a5 feat(anh): add sealed report metadata foundation 2026-06-16 05:39:12 -05:00
henryor
e1c10f58f8 fix(admin): repair settings profile security 2026-06-15 21:22:12 -05:00
henryor
1d1d32c4d4 fix(admin): remove inactive header search 2026-06-15 20:48:27 -05:00
henryor
a4627c2b4c fix(admin): scope user management by project 2026-06-15 20:32:39 -05:00
henryor
a92b731110 fix(console): show trial and invitation expiry 2026-06-15 20:32:39 -05:00
Wilman Yesid Farfan Diaz
4801101d4f feat: add agent download token management and implement edge project CRUD handlers 2026-06-15 20:07:40 -05:00
henryor
1b739e2b14 feat(landing): add trial pricing option 2026-06-15 08:51:20 -05:00
henryor
95b9d76f3f feat(auth): brand trial activation email 2026-06-15 08:22:51 -05:00
henryor
7ca59a43cc fix(auth): dedupe access requests before unique index 2026-06-15 07:00:45 -05:00
henryor
0093839b5e feat(auth): automate trial access approval 2026-06-14 22:19:19 -05:00
Wilman Yesid Farfan Diaz
be7f0a8da4 feat: implement modular multi-protocol data collector agent with support for Modbus, S7, OPC-UA, MQTT Sparkplug B, and SQL drivers. 2026-06-14 19:37:42 -05:00
henryor
44acb87203 feat(landing): mejora privacidad SEO y accesibilidad
Alinea OmniOil como servicio operado por Kyrbot Innovation S.A.S., agrega la política de privacidad y refuerza la landing con SEO, accesibilidad, reduced motion y pruebas runtime.
2026-06-14 15:03:50 -05:00
henryor
e9e8d85092 fix(landing): ajusta textos técnicos de arquitectura 2026-06-13 20:24:53 -05:00
henryor
930461e7ce feat(landing): simplifica precios por tags 2026-06-13 20:19:55 -05:00
henryor
de661f42e5 fix(landing): ajusta texto de alertas por correo 2026-06-13 20:09:03 -05:00
henryor
983c800583 fix(landing): retira botón de agendar demo del hero 2026-06-13 19:54:24 -05:00
Wilman Yesid Farfan Diaz
92ec74dd92 feat: add terms and conditions page with multi-language support 2026-06-13 19:15:52 -05:00
Wilman Yesid Farfan Diaz
abb95e1501 feat: implement ANH report management handlers and include json-generator in Docker build 2026-06-13 16:49:24 -05:00
Wilman Yesid Farfan Diaz
aa7cbbce7d Generaci[on del json mejorado 2026-06-13 13:35:51 -05:00
henryor
910c2ab964 refactor(admin): migrate user form modal to UI kit 2026-05-15 21:44:35 -05:00
henryor
0beebb04c0 refactor(admin): migrate users page to UI kit 2026-05-15 20:11:59 -05:00
henryor
44d3b2cf26 feat(admin): add admin UI compositions 2026-05-15 20:11:59 -05:00
henryor
21bb2bbea8 feat(admin): add reusable UI primitives 2026-05-15 20:11:58 -05:00
henryor
bec2580e2f retiro imagenes antiguas 2026-05-15 10:03:24 -05:00
henryor
351fd7dddd refactor(admin): extract admin child routes for reuse 2026-05-15 09:57:10 -05:00
henryor
e05f808e61 test(admin): add Select component tests and update existing test coverage 2026-05-15 09:56:01 -05:00
henryor
0119902556 refactor(admin): align visual language across dashboard, users, login and UI components 2026-05-15 09:55:51 -05:00
henryor
a7b262c053 feat(admin): add desktop collapsible sidebar with user identity topbar 2026-05-15 09:55:03 -05:00
Wilman Yesid Farfan Diaz
02f24f27ce docs: add technical manual and strategic roadmap for infrastructure scaling 2026-05-04 18:59:16 -05:00
Wilman Yesid Farfan Diaz
28d1a7d930 feat: implement edge project CRUD handlers and update S3 installers bucket configuration 2026-05-04 18:59:14 -05:00
Wilman Yesid Farfan Diaz
1c57abd97a feat: implement OTA update service with authenticated backend proxy for agent binaries 2026-05-04 18:59:13 -05:00
Wilman Yesid Farfan Diaz
fb0c172bb5 build: include shared-lib and SQLX offline data in backend-api build stage 2026-05-04 18:59:10 -05:00
Wilman Yesid Farfan Diaz
6f486a038b chore: remove unused configuration file 2026-05-04 18:59:05 -05:00
Wilman Yesid Farfan Diaz
78e8ecb55a feat: integrate sqlx offline mode with new database management scripts and dependencies 2026-05-04 18:59:05 -05:00
Wilman Yesid Farfan Diaz
9f3d7212ee feat: implement full-stack platform architecture including backend services, edge agents, frontend dashboard, and monitoring infrastructure 2026-05-04 18:59:00 -05:00
Wilman Yesid Farfan Diaz
c81ed907f7 feat(api): Phase 6 — OpenAPI docs, version header, enhanced health endpoint
- Add static OpenAPI 3.0.3 spec (services/backend-api/static/openapi.yaml)
  covering all ~70 endpoints with schemas, security, tags and responses
- Serve Swagger UI at GET /docs (embedded HTML, dark OmniOil theme)
- Serve raw YAML at GET /docs/openapi.yaml (include_str! at compile time)
- Add Tower middleware (api_version.rs) injecting X-API-Version: 1 and
  X-Powered-By: OmniOil/1.0 on every response
- Replace static /health handler with enhanced health_check that probes
  DB latency, tracks uptime via OnceLock, returns structured HealthResponse
- Expand API Explorer frontend: 70+ endpoints in 10 groups, PATCH color
  coding (purple), group filter pills, count badge, Swagger UI links

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-05-04 18:58:48 -05:00
Wilman Yesid Farfan Diaz
f90fb552fc feat: add multi-stage Dockerfile to cross-compile and build edge-agent MSI installer 2026-05-04 18:58:47 -05:00
Wilman Yesid Farfan Diaz
83988cfcde feat: implement individual service Dockerfiles and update unified build configuration 2026-05-04 18:58:47 -05:00
Wilman Yesid Farfan Diaz
22b39aadd3 feat: implement edge agent, build orchestrator, and backend telemetry services with multi-region MQTT support 2026-05-04 18:58:46 -05:00
Wilman Yesid Farfan Diaz
9c878a310b docs: add initial ISO 27001 compliance documentation including Statement of Applicability and reporting templates 2026-05-04 18:58:45 -05:00
Wilman Yesid Farfan Diaz
2d9ef90b09 feat: implement edge-agent service with MQTT listener for deployment signals and Windows service management 2026-05-04 18:58:44 -05:00
Wilman Yesid Farfan Diaz
e6d884ae95 feat: implement provisioning infrastructure with P256-based ECIES encryption and binary overlay support 2026-05-04 18:58:44 -05:00
Wilman Yesid Farfan Diaz
2dd1975b8d feat: introduce EdgeDevice model and update database schema to support additional industrial protocols including S7 2026-05-04 18:58:44 -05:00
Wilman Yesid Farfan Diaz
fa1be83665 feat: implement native Edge Agent service with MQTT, SQLite storage, and remote configuration management 2026-05-04 18:58:43 -05:00
Wilman Yesid Farfan Diaz
f19a13def1 feat(edge-agent): limpieza automática de logs y cola SQLite
- log_cleaner.rs: elimina archivos edge-agent.log.YYYY-MM-DD con
  más de 30 días al arrancar y cada 24h (evita llenado de disco 24/7)
- persistence_manager: cleanup_old_queue_entries() borra telemetría
  no enviada con más de 7 días (protege ante outages prolongados)
- main.rs + service_manager.rs: spawns de ambas tareas de limpieza

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-05-04 18:58:43 -05:00
Wilman Yesid Farfan Diaz
2c3dd8e8bd security: HIGH-5/6/8, MED-1/5 — rate limiting, HSTS, HttpOnly cookies, Argon2id
HIGH-5: Rate limiting por IP con governor (Docker-compatible via X-Real-IP)
  - Login/refresh: 10 req/min; Register: 5 req/min
  - services/backend-api/src/rate_limiter.rs (nuevo módulo)

HIGH-6: HSTS + X-Forwarded-Proto en Nginx; TLS terminado por Dokploy
  - infrastructure/nginx/gateway.conf

HIGH-8: Refresh token movido a cookie HttpOnly; access token solo en memoria
  - Backend: login emite cookie, refresh rota cookie, logout la borra
  - Frontend: Zustand v3 sin tokens en localStorage; credentials: include

MED-1: Eliminado PBKDF2 legacy; Argon2id m=65536 t=3 p=1 en todos los hashes
  - services/shared-lib/src/overlay.rs (imports limpiados)
  - services/backend-api/src/handlers/auth.rs (hash_password helper)

MED-5: Rotación atómica de refresh tokens con detección de replay

SECURITY_AUDIT_REPORT.md: riesgo reducido a 🟡 MEDIO

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-05-04 18:58:43 -05:00
Wilman Yesid Farfan Diaz
47d6a4e48d feat: implement edge agent service core, industrial simulators, and build orchestration tools 2026-05-04 18:58:42 -05:00
Wilman Yesid Farfan Diaz
7ccadb0272 feat: implement edge agent, build orchestrator, ANH reporting service, and Node-RED E2E test infrastructure 2026-05-04 18:58:41 -05:00
Wilman Yesid Farfan Diaz
154f183341 feat: implement base microservice architecture with relay, generator, and edge-agent services 2026-05-04 18:58:40 -05:00
d1647ddbcf feat(frontend-pwa): mejora flujo de sincronizacion y UX de campo 2026-05-04 18:58:39 -05:00
Wilman Yesid Farfan Diaz
d130ee578f feat: add project-wide Docker support including build orchestrator and frontend service containers 2026-05-04 18:58:39 -05:00
Wilman Yesid Farfan Diaz
6afacdc7ce feat: implement telemetry ingestor service and build-orchestrator image registry push functionality 2026-05-04 18:58:38 -05:00
Wilman Yesid Farfan Diaz
4566a57e94 feat: implement periodic container health checker with auto-restart and rollback capabilities 2026-05-04 18:58:38 -05:00
Wilman Yesid Farfan Diaz
79dd41ba6f feat: add DashboardPage and ProjectDetailsPage to frontend-admin feature modules 2026-05-04 18:58:38 -05:00
Wilman Yesid Farfan Diaz
77bcde08ef chore: update MinIO configuration, adjust service dependencies, and pass S3 bucket environment variable to MQTT service 2026-05-04 18:58:38 -05:00
Wilman Yesid Farfan Diaz
56bb76fdb2 feat: implement real-time dashboard page with WebSocket telemetry integration and historical data visualization 2026-05-04 18:58:37 -05:00
Wilman Yesid Farfan Diaz
7a774a70fc feat: implement frontend PWA and Admin services with Vite, Docker configurations, and routing logic. 2026-05-04 18:58:37 -05:00
Wilman Yesid Farfan Diaz
2a53ae7e9e feat: bootstrap initial project architecture, shared libraries, and core service modules for edge, backend, and frontend applications. 2026-05-04 18:58:37 -05:00
Wilman Yesid Farfan Diaz
c97ab61512 feat: add Dockerfile for frontend-admin and configure VITE_ADMIN_APP_ORIGIN in docker-compose 2026-05-04 18:58:37 -05:00
Wilman Yesid Farfan Diaz
d6bbc66676 feat: implement dashboard page with real-time monitoring and operational stats overview 2026-05-04 18:58:37 -05:00
Wilman Yesid Farfan Diaz
997b6b0da8 feat: add dashboard and project details pages with domain-based routing configuration 2026-05-04 18:58:36 -05:00
Wilman Yesid Farfan Diaz
555aef226e feat: implement admin dashboard and project details pages for monitoring and asset management 2026-05-04 18:58:36 -05:00
Wilman Yesid Farfan Diaz
f9af6a552e feat: implement cross-domain routing logic and initial admin dashboard page architecture 2026-05-04 18:58:36 -05:00
Wilman Yesid Farfan Diaz
60ad7ca44f feat: initialize core services, MQTT infrastructure, and backend API with telemetry processing and watchdog monitoring 2026-05-04 18:58:35 -05:00
Wilman Yesid Farfan Diaz
de213a22f0 Reconstrucci[on validada. 2026-05-04 18:58:35 -05:00
Wilman Yesid Farfan Diaz
93307c3b8f Cargar valores por defecto.
Actualización de nuevas variables de entorno y comunicación.
2026-05-04 18:58:35 -05:00
Wilman Yesid Farfan Diaz
edc773b367 feat: implement backend API service with MQTT telemetry handling and edge system management routes 2026-05-04 18:58:34 -05:00
Wilman Yesid Farfan Diaz
f43a3f202d feat: implement dynamic MQTT configuration and automated edge-agent binary injection in build orchestrator 2026-05-04 18:58:34 -05:00
Wilman Yesid Farfan Diaz
a379c8dd59 Eliminacion de basura 2026-05-04 18:58:34 -05:00
Wilman Yesid Farfan Diaz
02151e56dd feat: add Mosquitto entrypoint script and configuration template for dynamic PostgreSQL authentication setup 2026-05-04 18:58:34 -05:00
Wilman Yesid Farfan Diaz
db259b5dbe feat: initialize core microservices architecture including telemetry, backend API, build orchestrator, edge-agent, and admin dashboard. 2026-05-04 18:58:33 -05:00
Wilman Yesid Farfan Diaz
8359ae9a06 feat: implement JWT-based authentication service and initialize core infrastructure across backend, edge-agent, and dashboard services 2026-05-04 18:58:33 -05:00
Wilman Yesid Farfan Diaz
2430419257 refactor: replace .env.example with environment-specific .env.example.docker and .env.example.local templates and update .gitignore 2026-05-04 18:58:33 -05:00
Wilman Yesid Farfan Diaz
ba0b0c633f Actualización de variables de entorno para pruebas locales 2026-05-04 18:58:32 -05:00
Wilman Yesid Farfan Diaz
7d2013cedf Actualizaciones locales. 2026-05-04 18:58:32 -05:00
Wilman Yesid Farfan Diaz
660092a8ff Actualizaci[on de agente con la configuraci[on para despliegue. 2026-05-04 18:58:32 -05:00
Wilman Yesid Farfan Diaz
0092d20e35 Cambio de usuarios, inscripci[on activada. 2026-05-04 18:58:32 -05:00
Wilman Yesid Farfan Diaz
1c9059a0ea Despliegue completo.
Funcionalidad de despliegue por agente funcionando.
Hay que validar URL.
2026-05-04 18:58:32 -05:00
Wilman Yesid Farfan Diaz
a0cb973078 Validaci[on de reconstrucci[on hecho. 2026-05-04 18:58:32 -05:00
Wilman Yesid Farfan Diaz
c92e0b5c06 Actualizaci[on de los logs del sistema.
Validaci[on de los puntos de base de datos.
2026-05-04 18:58:32 -05:00
Wilman Yesid Farfan Diaz
0949f19c9d Inicializaci[on de datos 2026-05-04 18:58:31 -05:00
Wilman Yesid Farfan Diaz
3bde9096ab Actualizado sistema a inicializaci[on. 2026-05-04 18:58:31 -05:00
63ef77d1ce feat(admin): gestión de usuarios con CRUD, roles, proyectos y QR
- Backend: 7 endpoints admin (CRUD usuarios, roles, proyectos por usuario)
- AdminClaims extractor con validación de rol admin (403)
- Frontend admin: página de usuarios con filtros, modal crear/editar
- QR con deep link a PWA para compartir credenciales en campo
- PWA: prefill de email desde query params del QR
- Fix: Redis restart loop (command como array en docker-compose)
- Fix: crash ProjectDetailsPage por stage undefined
- Fix: migración audit_trail compatible con schema existente
- Cleanup: eliminado RegisterPage y ruta /register del admin
2026-05-04 18:58:31 -05:00
dae5e8bf00 fix(tests): corregir tests del flow-generator tras nuevos campos en modelos
Los structs EdgeProject y EdgeAsset fueron ampliados con campos ANH
(operador, contrato, localización, calibración) pero los tests no se
actualizaron. También se corrige la aserción de nodos modbus-read
que esperaba 2 en vez de 1 por variable.
2026-05-04 18:58:31 -05:00
Wilman Yesid Farfan Diaz
a524e52a19 Actualizaci[on de todos los elementos para completar el flujo 2026-05-04 18:58:30 -05:00
Wilman Yesid Farfan Diaz
6f780cf22e Listo para pruebas de flujo. 2026-05-04 18:58:30 -05:00
Wilman Yesid Farfan Diaz
0ad8fc05c1 Cambias actualizando el sistema 2026-05-04 18:58:30 -05:00
Wilman Yesid Farfan Diaz
977e500bd6 Ultimo cambio 2026-05-04 18:58:30 -05:00
Wilman Yesid Farfan Diaz
7efef41179 Revert "Actualizaci[on de flujo concurrente en el instalador"
This reverts commit 8f239c6ff4.
2026-05-04 18:58:30 -05:00
Wilman Yesid Farfan Diaz
accefcd375 Revert "Actualizaci[on de los archivos para el flujo"
This reverts commit c62c9991d6.
2026-05-04 18:58:30 -05:00
Wilman Yesid Farfan Diaz
f83d530c18 Actualizaci[on de los archivos para el flujo 2026-05-04 18:58:30 -05:00
Wilman Yesid Farfan Diaz
58de3dc1f9 Actualizaci[on de flujo concurrente en el instalador 2026-05-04 18:58:30 -05:00
Wilman Yesid Farfan Diaz
80403eeb20 Actualizada configuración de contenedores para visualizarse entre ellos 2026-05-04 18:58:30 -05:00
Wilman Yesid Farfan Diaz
dcf72cef79 Actualizaci[on del instalador 2026-05-04 18:58:29 -05:00
Wilman Yesid Farfan Diaz
58ebb1adf0 Correcciones en el json generator 2026-05-04 18:58:29 -05:00
Wilman Yesid Farfan Diaz
e56dac1269 Actualizaci[on del metodo de sqlx 2026-05-04 18:58:29 -05:00
Wilman Yesid Farfan Diaz
98a09289a6 Actualizaci[on del contrato y nombre del contrato con el operador 2026-05-04 18:58:29 -05:00
Wilman Yesid Farfan Diaz
b34213c9c8 Arreglando bucle infinito 2026-05-04 18:58:29 -05:00
Wilman Yesid Farfan Diaz
6e9fdc0f17 feat: implement auto-update mechanism and optimize Nginx cache-control policies for frontend services 2026-05-04 18:58:29 -05:00
a36b06bbf8 docs: agregar reporte de correccion auth mobile 2026-05-04 18:58:29 -05:00
84f5c42e5b fix(auth): deshabilitar registro publico en mobile 2026-05-04 18:58:29 -05:00
4f1f530ce1 fix(auth): completar separacion de superficies admin y campo 2026-05-04 18:58:28 -05:00
Wilman Yesid Farfan Diaz
dd4511cdfc Subir cambio de red, para ver si se soluciona ailsmiento de contenedores 2026-05-04 18:58:28 -05:00
Wilman Yesid Farfan Diaz
55a3c9895d feat: implement CRUD handlers and full configuration retrieval for edge projects 2026-05-04 18:58:28 -05:00
Wilman Yesid Farfan Diaz
ee0ed8800a Modificaci[on en la variable de aws para evitar el panic en el contenedor 2026-05-04 18:58:28 -05:00
Wilman Yesid Farfan Diaz
801c78fd57 Actualizaci[on de informacion adicional 2026-05-04 18:58:28 -05:00
Wilman Yesid Farfan Diaz
9e5b69f3a2 Listo el depliegue 2026-05-04 18:58:27 -05:00
Wilman Yesid Farfan Diaz
11f4757fe0 Actualizacion del agente y los reportes 2026-05-04 18:58:27 -05:00
Wilman Yesid Farfan Diaz
980fbcc3b3 Actualizaci[on del telemetry para ingesta de datos 2026-05-04 18:58:27 -05:00
Wilman Yesid Farfan Diaz
f3fd71e286 Actualizaci[on completa sobre seguridad y comunicaci[on segura. 2026-05-04 18:58:27 -05:00
Wilman Yesid Farfan Diaz
f0ce0a67cc Cargando el doccjer cpmpse mpdificado 2026-05-04 18:58:27 -05:00
7aec3b67fc docs: add repair.md (registro usuarios) 2026-05-04 18:58:27 -05:00
0551d41475 feat(api): support comma-separated ALLOWED_ORIGINS for proper CORS in prod 2026-05-04 18:58:27 -05:00
dade0608a8 fix(gateway+pwa): map mobile.omnioil.app to PWA and set VITE_API_BASE=/api for frontend-pwa 2026-05-04 18:58:26 -05:00
ebb165bbff fix(api): re-enable public user registration endpoint (/api/auth/register) 2026-05-04 18:58:26 -05:00
Wilman Yesid Farfan Diaz
6d0c432043 Actualizado todo el sistema con las mejoras en seguridad encontradas, despliegue sin migraciones, solo la inicial. 2026-05-04 18:58:26 -05:00
Wilman Yesid Farfan Diaz
c72868f65b Soluci[on de seguridad de los puntos realizada 2026-05-04 18:58:26 -05:00
Wilman Yesid Farfan Diaz
39eea9d6a4 Funcionalidades de seguridad implementadas. 2026-05-04 18:58:26 -05:00
Wilman Yesid Farfan Diaz
d00db12ec0 Cambios para despliegue con mosquito 2026-05-04 18:58:26 -05:00
e83d88c599 docs: agregar PRD de estabilización y preparación para producción (v1.0) 2026-05-04 18:58:26 -05:00
Wilman Yesid Farfan Diaz
e26af8ef37 Actualizado entrypoint.sh a versión 1.0.2 2026-05-04 18:58:26 -05:00
Wilman Yesid Farfan Diaz
0b73ef5c49 Archivo mosquito cambiado. 2026-05-04 18:58:26 -05:00
Wilman Yesid Farfan Diaz
f5ba0df791 Actualizaci[on archivo mosquito para la validaci[on de la comunicaci[on encriptada y aceptada entre contenedores 2026-05-04 18:58:25 -05:00
Wilman Yesid Farfan Diaz
12c3564e01 Ajustes finales de comunicaci[on segura 2026-05-04 18:58:25 -05:00
Wilman Yesid Farfan Diaz
a4f1fa137d Actualizaci[on de seguridad fuerte.
Actualizaci[on en el readme
2026-05-04 18:58:25 -05:00
Wilman Yesid Farfan Diaz
952514784e Base de datos para la validación de las credenciales 2026-05-04 18:58:25 -05:00
Wilman Yesid Farfan Diaz
ab000b29a9 Adicion de mosquito.config 2026-05-04 18:58:25 -05:00
Wilman Yesid Farfan Diaz
487b075493 Actualización. del docker 2026-05-04 18:58:25 -05:00
Wilman Yesid Farfan Diaz
671a5339a9 Cambios en la seguridad y nuevo campo 2026-05-04 18:58:25 -05:00
f910768719 Merge remote-tracking branch 'origin/main' into dev 2026-04-06 08:41:51 -05:00
30eff47bec Merge pull request 'Update and syc dev branch' (#3) from main into dev
Reviewed-on: #3
2026-04-05 18:55:49 +00:00
406 changed files with 57297 additions and 24636 deletions

14
.clippy.toml Normal file
View File

@@ -0,0 +1,14 @@
# Clippy configuration for OmniOil workspace
# https://doc.rust-lang.org/clippy/configuration.html
# Disallow unwrap() in production code (allowed in tests)
disallowed-methods = [
{ path = "std::option::Option::unwrap", reason = "Use ? operator or pattern matching instead" },
{ path = "std::result::Result::unwrap", reason = "Use ? operator or pattern matching instead" },
]
# Warn on complexity
cognitive-complexity-threshold = 25
# Allow certain patterns that are intentional
allow-unwrap-in-tests = true

View File

@@ -1,30 +1,55 @@
# Rust build artifacts # === Ignorar casi todo por defecto ===
target/ **
services/**/target/
# Node.js # === Archivos y carpetas que SÍ queremos incluir ===
node_modules/ !Dockerfile*
**/node_modules/ !docker-compose.yml
# Tests & Data
tests/
.docker/
infrastructure/postgres/data/
# Git
.git/
.gitignore
# Environment
.env
.env.*
!.env.example !.env.example
!Cargo.toml
!Cargo.lock
!package.json
!pnpm-lock.yaml
!pnpm-workspace.yaml
!.sqlx/
# Servicios Rust esenciales
!services/shared-lib/
!services/backend-api/
!services/data-collector/
!services/json-generator/
!services/events-relay/
!services/build-orchestrator/
!services/telemetry-ingestor/
!services/edge-agent/Cargo.toml
!services/edge-agent/src/
!services/edge-agent/Cargo.lock
!services/edge-agent/build.rs
!services/edge-agent/embedded_config.toml
!services/edge-agent/Dockerfile.builder
!services/edge-agent/wix/
!services/edge-agent/*.ico
!services/edge-agent/*.sh
!services/edge-agent/msi_worker.sh
# IDE !infrastructure/
.vscode/
.idea/
# Docs & Meta # Excepciones dentro de frontend (Node.js)
docs/ !services/landing/
LICENSE !services/frontend-pwa/
!README.md !services/frontend-admin/
!services/frontend-console/
# Ignorar dentro de las carpetas permitidas
**/target
**/node_modules
**/.git
**/.github
**/.vscode
**/.idea
**/*.log
**/.DS_Store
.env*
**/__pycache__
**/dist
**/build
*.tar
*.tar.gz

View File

@@ -2,6 +2,7 @@
# OMNIOIL SCADA - CONFIGURACIÓN PARA AMBIENTE LOCAL (DOCUMENTADA) # OMNIOIL SCADA - CONFIGURACIÓN PARA AMBIENTE LOCAL (DOCUMENTADA)
# ============================================================================= # =============================================================================
AGENT_LATEST_VERSION=0.3.1
# --- ORQUESTACIÓN DE COMPOSE --- # --- ORQUESTACIÓN DE COMPOSE ---
COMPOSE_PATH_SEPARATOR=; # Separador de archivos compose para Windows COMPOSE_PATH_SEPARATOR=; # Separador de archivos compose para Windows
COMPOSE_FILE=docker-compose.yml;docker-compose.dev.yml # Archivos compose activos por defecto COMPOSE_FILE=docker-compose.yml;docker-compose.dev.yml # Archivos compose activos por defecto
@@ -57,6 +58,7 @@ MOSQUITTO_GO_AUTH=true # Habilita autenticaci
# --- SEGURIDAD Y TÚNELES --- # --- SEGURIDAD Y TÚNELES ---
JWT_SECRET=llave_maestra_para_tokens_api # Secreto para firmar tokens de sesión JWT_SECRET=llave_maestra_para_tokens_api # Secreto para firmar tokens de sesión
ACCESS_REQUEST_REVIEWER_EMAILS=w.farfan@omnioil.app,h.ortegon@omnioil.app # Revisores de solicitudes de acceso
TOTP_SECRET_ENCRYPTION_KEY=zOTp9/NjvLR9XX7NU0+/00M7AVvomLHhf5kQSnj0Z7s= # Base64 de 32 bytes para cifrar secretos TOTP TOTP_SECRET_ENCRYPTION_KEY=zOTp9/NjvLR9XX7NU0+/00M7AVvomLHhf5kQSnj0Z7s= # Base64 de 32 bytes para cifrar secretos TOTP
OMNIOIL_MASTER_SECRET=redisoilsecret456 # Secreto maestro para encriptación de datos sensibles OMNIOIL_MASTER_SECRET=redisoilsecret456 # Secreto maestro para encriptación de datos sensibles
ALLOWED_ORIGINS=http://localhost:3000,http://localhost:3001,http://localhost:3002,http://localhost:3004,https://console.omnioil.app # Orígenes permitidos para CORS ALLOWED_ORIGINS=http://localhost:3000,http://localhost:3001,http://localhost:3002,http://localhost:3004,https://console.omnioil.app # Orígenes permitidos para CORS
@@ -65,6 +67,10 @@ VITE_LANDING_APP_ORIGIN=http://localhost:3004/
NEXT_PUBLIC_TURNSTILE_SITE_KEY=1x00000000000000000000AA NEXT_PUBLIC_TURNSTILE_SITE_KEY=1x00000000000000000000AA
TUNNEL_SECRET_TOKEN=default_tunnel_secret_123 # Token de validación para Nginx Gatekeeper TUNNEL_SECRET_TOKEN=default_tunnel_secret_123 # Token de validación para Nginx Gatekeeper
# --- SEGURIDAD DE DESCARGA DE AGENTES (OTA) ---
AGENT_DOWNLOAD_SECRET=clave_secreta_para_firmar_descargas # HMAC secret para firmar URLs de descarga de agentes
# Si no se configura, las descargas operan sin autenticación
# --- ORQUESTACIÓN Y REGISTRY --- # --- ORQUESTACIÓN Y REGISTRY ---
DOCKER_REGISTRY_URL=localhost:5000 # Registro local accesible desde el host DOCKER_REGISTRY_URL=localhost:5000 # Registro local accesible desde el host
EXTERNAL_REGISTRY_URL=localhost:5000 # Registro accesible para agentes en la misma PC EXTERNAL_REGISTRY_URL=localhost:5000 # Registro accesible para agentes en la misma PC

63
.github/workflows/ci.yml vendored Normal file
View File

@@ -0,0 +1,63 @@
name: CI
on:
push:
branches: [main, develop]
pull_request:
branches: [main]
env:
CARGO_TERM_COLOR: always
RUSTFLAGS: -D warnings
jobs:
rust-check:
name: Rust Check
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions-rust-lang/setup-rust-toolchain@v1
with:
toolchain: stable
components: rustfmt, clippy
- name: Check formatting
run: cargo fmt -- --check
- name: Run clippy
run: cargo clippy --workspace -- -D warnings
- name: Run cargo check
run: cargo check --workspace
- name: Run tests
run: cargo test --workspace
frontend-lint:
name: Frontend Lint
runs-on: ubuntu-latest
strategy:
matrix:
app: [frontend-admin, frontend-pwa, landing]
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 22
cache: npm
cache-dependency-path: services/${{ matrix.app }}/package-lock.json
- name: Install dependencies
run: npm ci
working-directory: services/${{ matrix.app }}
- name: Lint
run: npm run lint
working-directory: services/${{ matrix.app }}
- name: TypeScript check
run: npx tsc --noEmit
working-directory: services/${{ matrix.app }}
continue-on-error: true
docker-build:
name: Docker Build
runs-on: ubuntu-latest
needs: [rust-check]
steps:
- uses: actions/checkout@v4
- name: Build Docker images
run: docker compose build

8
.gitignore vendored
View File

@@ -47,3 +47,11 @@ CLAUDE.md
__pycache__ __pycache__
build_test/ build_test/
venv venv
# Local generated artifacts
.venv/
**/.venv/
/services/edge-agent/logs/
/services/edge-agent/edge-agent-revision-local*.exe
/tests/*.log
/tests/simulators/variables_import.csv

16
.pre-commit-config.yaml Normal file
View File

@@ -0,0 +1,16 @@
repos:
- repo: https://github.com/pre-commit/pre-commit-hooks
rev: v5.0.0
hooks:
- id: trailing-whitespace
- id: end-of-file-fixer
- id: check-yaml
- id: check-added-large-files
- id: check-merge-conflict
- id: detect-private-key
- repo: https://github.com/doublify/pre-commit-rust
rev: v1.0
hooks:
- id: fmt
- id: cargo-check

1464
Cargo.lock generated

File diff suppressed because it is too large Load Diff

View File

@@ -20,10 +20,11 @@ tokio = { version = "1.50.0", features = ["full"] }
serde = { version = "1.0.228", features = ["derive"] } serde = { version = "1.0.228", features = ["derive"] }
serde_json = "1.0.149" serde_json = "1.0.149"
chrono = { version = "0.4.44", features = ["serde"] } chrono = { version = "0.4.44", features = ["serde"] }
chrono-tz = "0.10.4"
uuid = { version = "1.23.0", features = ["v4", "serde"] } uuid = { version = "1.23.0", features = ["v4", "serde"] }
anyhow = "1.0.102" anyhow = "1.0.102"
tracing = "0.1.44" tracing = "0.1.44"
tracing-subscriber = { version = "0.3.23", features = ["env-filter"] } tracing-subscriber = { version = "0.3.23", features = ["env-filter", "json"] }
sqlx = { version = "0.8.6", features = ["runtime-tokio-rustls", "postgres", "uuid", "chrono", "json", "bigdecimal"] } sqlx = { version = "0.8.6", features = ["runtime-tokio-rustls", "postgres", "uuid", "chrono", "json", "bigdecimal"] }
rumqttc = { version = "0.25.1", features = ["use-rustls"] } rumqttc = { version = "0.25.1", features = ["use-rustls"] }
zstd = "0.13" zstd = "0.13"
@@ -41,6 +42,7 @@ tokio-modbus = { version = "0.17.0", default-features = false, features = ["tcp"
s7 = "0.1.2" s7 = "0.1.2"
semver = "1.0" semver = "1.0"
shared-lib = { path = "services/shared-lib" } shared-lib = { path = "services/shared-lib" }
prometheus = "0.13"
# ── Disk-space optimized profiles ────────────────────────────────────────────── # ── Disk-space optimized profiles ──────────────────────────────────────────────
# These reduce the target/ directory from ~37 GB to ~5-10 GB in normal use. # These reduce the target/ directory from ~37 GB to ~5-10 GB in normal use.
@@ -63,3 +65,14 @@ lto = "thin"
codegen-units = 1 codegen-units = 1
# Abort on panic — removes unwinding tables (~5-10% smaller binary). # Abort on panic — removes unwinding tables (~5-10% smaller binary).
panic = "abort" panic = "abort"
# ── CI/Docker profile: fast builds, still production-grade ─────────────────────
# Use with `cargo build --profile ci`. Output goes to target/ci/.
# Skips LTO (~3-5 min saved) and uses parallel codegen units.
# Binaries are ~5-10% larger but functionally identical.
[profile.ci]
inherits = "release"
lto = false
strip = true
codegen-units = 8

113
Dockerfile.frontend Normal file
View File

@@ -0,0 +1,113 @@
# syntax=docker/dockerfile:1
# =============================================================================
# OmniOil Workspace - Unified Frontend Build Pipeline
# =============================================================================
# --- Stage 1: Install workspace dependencies ---
FROM node:22-alpine AS deps
RUN corepack enable && corepack prepare pnpm@9.0.0 --activate
WORKDIR /app
# Copy workspace configurations and manifests
COPY package.json pnpm-lock.yaml pnpm-workspace.yaml ./
COPY services/landing/package.json ./services/landing/
COPY services/frontend-admin/package.json ./services/frontend-admin/
COPY services/frontend-console/package.json ./services/frontend-console/
COPY services/frontend-pwa/package.json ./services/frontend-pwa/
# Run installation utilizing pnpm cache mount
RUN --mount=type=cache,id=pnpm,target=/pnpm/store \
pnpm install --frozen-lockfile --store-dir /pnpm/store
# --- Stage 2: Base builder ---
FROM deps AS builder_base
# --- Stage 3: Landing build ---
FROM builder_base AS builder-landing
COPY services/landing services/landing
ARG NEXT_PUBLIC_TURNSTILE_SITE_KEY
ENV NEXT_TELEMETRY_DISABLED=1
ENV NEXT_PUBLIC_TURNSTILE_SITE_KEY=$NEXT_PUBLIC_TURNSTILE_SITE_KEY
RUN pnpm --filter landing build
# --- Stage 4: Admin build ---
FROM builder_base AS builder-admin
COPY services/frontend-admin services/frontend-admin
ARG VITE_API_BASE
ARG VITE_ADMIN_APP_ORIGIN
ARG VITE_MOBILE_APP_ORIGIN
ARG VITE_API_URL
ARG VITE_TURNSTILE_SITE_KEY
ARG VITE_LANDING_APP_ORIGIN
ENV VITE_API_BASE=$VITE_API_BASE
ENV VITE_ADMIN_APP_ORIGIN=$VITE_ADMIN_APP_ORIGIN
ENV VITE_MOBILE_APP_ORIGIN=$VITE_MOBILE_APP_ORIGIN
ENV VITE_API_URL=$VITE_API_URL
ENV VITE_TURNSTILE_SITE_KEY=$VITE_TURNSTILE_SITE_KEY
ENV VITE_LANDING_APP_ORIGIN=$VITE_LANDING_APP_ORIGIN
RUN pnpm --filter frontend-admin build
# --- Stage 5: Console build ---
FROM builder_base AS builder-console
COPY services/frontend-console services/frontend-console
ARG VITE_API_BASE
ARG VITE_CONSOLE_APP_ORIGIN
ARG VITE_ACCESS_REQUEST_REVIEWER_EMAILS
ENV VITE_API_BASE=$VITE_API_BASE
ENV VITE_CONSOLE_APP_ORIGIN=$VITE_CONSOLE_APP_ORIGIN
ENV VITE_ACCESS_REQUEST_REVIEWER_EMAILS=$VITE_ACCESS_REQUEST_REVIEWER_EMAILS
RUN pnpm --filter frontend-console build
# --- Stage 6: PWA build ---
FROM builder_base AS builder-pwa
COPY services/frontend-pwa services/frontend-pwa
ARG VITE_API_BASE
ARG VITE_ADMIN_APP_ORIGIN
ARG VITE_MOBILE_APP_ORIGIN
ARG VITE_API_URL
ENV VITE_API_BASE=$VITE_API_BASE
ENV VITE_ADMIN_APP_ORIGIN=$VITE_ADMIN_APP_ORIGIN
ENV VITE_MOBILE_APP_ORIGIN=$VITE_MOBILE_APP_ORIGIN
ENV VITE_API_URL=$VITE_API_URL
RUN pnpm --filter frontend-pwa build
# =============================================================================
# RUNTIME IMAGES
# =============================================================================
# --- LANDING RUNTIME (Next.js Standalone Mode in Monorepo) ---
FROM node:22-alpine AS landing
WORKDIR /app
ENV NODE_ENV=production
ENV NEXT_TELEMETRY_DISABLED=1
ENV HOSTNAME=0.0.0.0
ENV PORT=80
ENV LANDING_API_PROXY_TARGET=http://backend-api:8000
COPY --from=builder-landing /app/services/landing/public ./services/landing/public
COPY --from=builder-landing /app/services/landing/.next/standalone ./
COPY --from=builder-landing /app/services/landing/.next/static ./services/landing/.next/static
EXPOSE 80
CMD ["node", "services/landing/server.js"]
# --- ADMIN RUNTIME (Nginx SPA) ---
FROM nginx:alpine AS frontend-admin
COPY --from=builder-admin /app/services/frontend-admin/dist /usr/share/nginx/html
COPY services/frontend-admin/nginx.conf /etc/nginx/conf.d/default.conf
EXPOSE 80
CMD ["nginx", "-g", "daemon off;"]
# --- CONSOLE RUNTIME (Nginx SPA) ---
FROM nginx:alpine AS frontend-console
COPY --from=builder-console /app/services/frontend-console/dist /usr/share/nginx/html
COPY services/frontend-console/nginx.conf /etc/nginx/conf.d/default.conf
EXPOSE 80
CMD ["nginx", "-g", "daemon off;"]
# --- PWA RUNTIME (Nginx SPA) ---
FROM nginx:alpine AS frontend-pwa
COPY --from=builder-pwa /app/services/frontend-pwa/dist /usr/share/nginx/html
COPY services/frontend-pwa/nginx.conf /etc/nginx/conf.d/default.conf
EXPOSE 80
CMD ["nginx", "-g", "daemon off;"]

View File

@@ -7,16 +7,47 @@ FROM lukemathwalker/cargo-chef:latest-rust-1.93-slim-bookworm AS base
WORKDIR /app WORKDIR /app
RUN apt-get update && apt-get install -y \ RUN apt-get update && apt-get install -y \
cmake nasm pkg-config libssl-dev libglib2.0-dev libatk1.0-dev libgtk-3-dev build-essential ca-certificates lld \ pkg-config libssl-dev build-essential ca-certificates lld \
gcc-mingw-w64-x86-64 musl-tools \
&& rm -rf /var/lib/apt/lists/* && rm -rf /var/lib/apt/lists/*
RUN rustup target add x86_64-pc-windows-gnu && \
rustup target add x86_64-unknown-linux-musl
# Variables de entorno para cross-compilation
ENV CC_x86_64_pc_windows_gnu=x86_64-w64-mingw32-gcc
ENV CXX_x86_64_pc_windows_gnu=x86_64-w64-mingw32-g++
ENV CARGO_TARGET_X86_64_PC_WINDOWS_GNU_LINKER=x86_64-w64-mingw32-gcc
ENV OPENSSL_NO_ASM=1
ENV RUSTFLAGS="-C link-arg=-fuse-ld=lld" ENV RUSTFLAGS="-C link-arg=-fuse-ld=lld"
ENV CARGO_INCREMENTAL=0 ENV CARGO_INCREMENTAL=0
# 2. PLANNER: Solo analiza dependencias (cambia poco) # 2. PLANNER: Solo analiza dependencias (cambia poco)
# Copiamos solo los Cargo.toml y Cargo.lock para máxima reutilización de caché
FROM base AS planner FROM base AS planner
COPY Cargo.toml Cargo.lock ./ COPY Cargo.toml Cargo.lock ./
COPY services/ services/ COPY services/shared-lib/Cargo.toml services/shared-lib/Cargo.toml
COPY services/backend-api/Cargo.toml services/backend-api/Cargo.toml
COPY services/data-collector/Cargo.toml services/data-collector/Cargo.toml
COPY services/json-generator/Cargo.toml services/json-generator/Cargo.toml
COPY services/events-relay/Cargo.toml services/events-relay/Cargo.toml
COPY services/build-orchestrator/Cargo.toml services/build-orchestrator/Cargo.toml
COPY services/edge-agent/Cargo.toml services/edge-agent/Cargo.toml
COPY services/telemetry-ingestor/Cargo.toml services/telemetry-ingestor/Cargo.toml
# Creamos archivos stub para cada crate (evita "file not found" en cargo metadata)
RUN mkdir -p services/shared-lib/src && echo "// stub" > services/shared-lib/src/lib.rs && \
mkdir -p services/backend-api/src && echo "// stub" > services/backend-api/src/lib.rs && echo "fn main() {}" > services/backend-api/src/main.rs && \
mkdir -p services/data-collector/src && echo "fn main() {}" > services/data-collector/src/main.rs && \
mkdir -p services/json-generator/src && echo "// stub" > services/json-generator/src/lib.rs && echo "fn main() {}" > services/json-generator/src/main.rs && \
mkdir -p services/events-relay/src && echo "fn main() {}" > services/events-relay/src/main.rs && \
mkdir -p services/build-orchestrator/src && echo "fn main() {}" > services/build-orchestrator/src/main.rs && \
mkdir -p services/edge-agent/src/bin && echo "fn main() {}" > services/edge-agent/src/bin/edge-tray.rs && echo "fn main() {}" > services/edge-agent/src/main.rs && \
mkdir -p services/telemetry-ingestor/src && echo "fn main() {}" > services/telemetry-ingestor/src/main.rs
# Generamos recipe.json para TODAS las dependencias del workspace
# Esto permite que todas las dependencias comunes se cacheen juntas
RUN cargo chef prepare --recipe-path recipe.json RUN cargo chef prepare --recipe-path recipe.json
# 3. CACHER: Compila TODAS las dependencias del workspace UNA vez # 3. CACHER: Compila TODAS las dependencias del workspace UNA vez
@@ -27,74 +58,65 @@ RUN --mount=type=cache,target=/usr/local/cargo/registry \
--mount=type=cache,target=/app/target \ --mount=type=cache,target=/app/target \
cargo chef cook --release --recipe-path recipe.json cargo chef cook --release --recipe-path recipe.json
# 4. BUILDER-LINUX: Base para servicios Linux (rápida) # 3a. Cook default workspace dependencies (Linux GNU)
RUN --mount=type=cache,sharing=locked,target=/usr/local/cargo/registry \
--mount=type=cache,sharing=locked,target=/usr/local/cargo/git \
cargo chef cook --release --recipe-path recipe.json
# 3b. Cook edge-agent dependencies for Windows GNU (con RUSTFLAGS idénticos)
RUN --mount=type=cache,sharing=locked,target=/usr/local/cargo/registry \
--mount=type=cache,sharing=locked,target=/usr/local/cargo/git \
RUSTFLAGS="-C target-feature=+crt-static -C link-args=-static" \
cargo chef cook --release --target x86_64-pc-windows-gnu --features edge-agent/opcua-support --recipe-path recipe.json
# 3c. Cook edge-agent dependencies for Linux musl
RUN --mount=type=cache,sharing=locked,target=/usr/local/cargo/registry \
--mount=type=cache,sharing=locked,target=/usr/local/cargo/git \
cargo chef cook --release --target x86_64-unknown-linux-musl --features edge-agent/opcua-support --recipe-path recipe.json
# 4. BUILDER-LINUX: Base para servicios Linux (reutiliza caché de deps)
FROM base AS builder-linux FROM base AS builder-linux
COPY Cargo.toml Cargo.lock ./ COPY --from=cacher /app /app
# Crear estructura de carpetas y archivos dummy COPY --from=cacher /usr/local/cargo /usr/local/cargo
RUN mkdir -p services/backend-api/src && echo "fn main() {}" > services/backend-api/src/main.rs && \ # Ahora copiar el código real de shared-lib (debe estar DESPUÉS de usar cacher)
mkdir -p services/data-collector/src && echo "fn main() {}" > services/data-collector/src/main.rs && \
mkdir -p services/json-generator/src && echo "fn main() {}" > services/json-generator/src/main.rs && \
mkdir -p services/shared-lib/src && touch services/shared-lib/src/lib.rs && \
mkdir -p services/build-orchestrator/src && echo "fn main() {}" > services/build-orchestrator/src/main.rs && \
mkdir -p services/edge-agent/src && echo "fn main() {}" > services/edge-agent/src/main.rs && \
mkdir -p services/events-relay/src && echo "fn main() {}" > services/events-relay/src/main.rs && \
mkdir -p services/telemetry-ingestor/src && echo "fn main() {}" > services/telemetry-ingestor/src/main.rs
COPY services/backend-api/Cargo.toml services/backend-api/Cargo.toml
COPY services/data-collector/Cargo.toml services/data-collector/Cargo.toml
COPY services/json-generator/Cargo.toml services/json-generator/Cargo.toml
COPY services/shared-lib/Cargo.toml services/shared-lib/Cargo.toml
COPY services/build-orchestrator/Cargo.toml services/build-orchestrator/Cargo.toml
COPY services/edge-agent/Cargo.toml services/edge-agent/Cargo.toml
COPY services/events-relay/Cargo.toml services/events-relay/Cargo.toml
COPY services/telemetry-ingestor/Cargo.toml services/telemetry-ingestor/Cargo.toml
# Pre-descargar todas las dependencias de forma secuencial para evitar colisiones
RUN --mount=type=cache,sharing=locked,target=/usr/local/cargo/registry \
--mount=type=cache,sharing=locked,target=/usr/local/cargo/git \
cargo fetch
# Compilar shared-lib primero
COPY services/shared-lib services/shared-lib COPY services/shared-lib services/shared-lib
# Compilar shared-lib (será rápido debido al caché de deps)
RUN --mount=type=cache,sharing=locked,target=/usr/local/cargo/registry \ RUN --mount=type=cache,sharing=locked,target=/usr/local/cargo/registry \
--mount=type=cache,sharing=locked,target=/usr/local/cargo/git \ --mount=type=cache,sharing=locked,target=/usr/local/cargo/git \
--mount=type=cache,sharing=locked,target=/app/target \ --mount=type=cache,target=/app/target \
cargo build --release -p shared-lib cargo build --release -p shared-lib
# 5. BUILDER-WINDOWS: Especializada en el agente # 5. BUILDER-WINDOWS: Especializada en el agente
FROM base AS builder-windows FROM base AS builder-windows
RUN apt-get update && apt-get install -y gcc-mingw-w64-x86-64 && rm -rf /var/lib/apt/lists/* COPY --from=cacher /app /app
RUN rustup target add x86_64-pc-windows-gnu COPY --from=cacher /usr/local/cargo /usr/local/cargo
COPY Cargo.toml Cargo.lock ./
RUN mkdir -p services/backend-api/src && echo "fn main() {}" > services/backend-api/src/main.rs && \
mkdir -p services/data-collector/src && echo "fn main() {}" > services/data-collector/src/main.rs && \
mkdir -p services/json-generator/src && echo "fn main() {}" > services/json-generator/src/main.rs && \
mkdir -p services/shared-lib/src && touch services/shared-lib/src/lib.rs && \
mkdir -p services/build-orchestrator/src && echo "fn main() {}" > services/build-orchestrator/src/main.rs && \
mkdir -p services/edge-agent/src && echo "fn main() {}" > services/edge-agent/src/main.rs && \
mkdir -p services/events-relay/src && echo "fn main() {}" > services/events-relay/src/main.rs && \
mkdir -p services/telemetry-ingestor/src && echo "fn main() {}" > services/telemetry-ingestor/src/main.rs
COPY services/backend-api/Cargo.toml services/backend-api/Cargo.toml
COPY services/data-collector/Cargo.toml services/data-collector/Cargo.toml
COPY services/json-generator/Cargo.toml services/json-generator/Cargo.toml
COPY services/shared-lib/Cargo.toml services/shared-lib/Cargo.toml
COPY services/build-orchestrator/Cargo.toml services/build-orchestrator/Cargo.toml
COPY services/edge-agent/Cargo.toml services/edge-agent/Cargo.toml
COPY services/events-relay/Cargo.toml services/events-relay/Cargo.toml
COPY services/telemetry-ingestor/Cargo.toml services/telemetry-ingestor/Cargo.toml
RUN --mount=type=cache,sharing=locked,target=/usr/local/cargo/registry \
--mount=type=cache,sharing=locked,target=/usr/local/cargo/git \
cargo fetch
COPY services/shared-lib services/shared-lib COPY services/shared-lib services/shared-lib
COPY services/edge-agent services/edge-agent COPY services/edge-agent services/edge-agent
RUN --mount=type=cache,sharing=locked,target=/usr/local/cargo/registry \
# Parchear la versión del agente en Cargo.toml antes de compilar
ARG BUILD_VERSION=0.3.0
RUN CURRENT_VERSION=$(grep -m 1 "^version =" services/edge-agent/Cargo.toml | cut -d '"' -f 2) && \
if [ "$CURRENT_VERSION" != "$BUILD_VERSION" ]; then \
sed -i "0,/^version = /s/^version = \"[^\"]*\"/version = \"${BUILD_VERSION}\"/" services/edge-agent/Cargo.toml; \
fi
RUN --mount=type=cache,target=/app/target \
--mount=type=cache,sharing=locked,target=/usr/local/cargo/registry \
--mount=type=cache,sharing=locked,target=/usr/local/cargo/git \ --mount=type=cache,sharing=locked,target=/usr/local/cargo/git \
--mount=type=cache,sharing=locked,target=/app/target \ RUSTFLAGS="-C target-feature=+crt-static -C link-args=-static" \
cargo build --release -p edge-agent --target x86_64-pc-windows-gnu && \ cargo build --release -p edge-agent --target x86_64-pc-windows-gnu && \
mkdir -p /app/bin/windows && cp target/x86_64-pc-windows-gnu/release/edge-agent.exe /app/bin/windows/ cargo build --release -p edge-agent --bin edge-agent --target x86_64-unknown-linux-musl && \
mkdir -p /app/bin/windows && cp target/x86_64-pc-windows-gnu/release/edge-agent.exe /app/bin/windows/ && \
mkdir -p /app/bin/linux && cp target/x86_64-unknown-linux-musl/release/edge-agent /app/bin/linux/
# =============================================================================
# RUNTIME BASE: Imagen base optimizada para todos los servicios
# =============================================================================
# Un ÚNICO apt-get update para todos los paquetes de runtime
FROM debian:bookworm-slim AS runtime-base
RUN apt-get update && apt-get install -y \
libssl3 ca-certificates curl mosquitto-clients \
&& rm -rf /var/lib/apt/lists/*
# ============================================================================= # =============================================================================
# INDIVIDUAL SERVICE TARGETS # INDIVIDUAL SERVICE TARGETS
@@ -103,18 +125,17 @@ RUN --mount=type=cache,sharing=locked,target=/usr/local/cargo/registry \
# --- BACKEND-API --- # --- BACKEND-API ---
FROM builder-linux AS builder-backend-api FROM builder-linux AS builder-backend-api
COPY services/backend-api services/backend-api COPY services/backend-api services/backend-api
COPY services/shared-lib services/shared-lib COPY services/json-generator services/json-generator
COPY .sqlx .sqlx COPY .sqlx .sqlx
ENV SQLX_OFFLINE=true ENV SQLX_OFFLINE=true
RUN --mount=type=cache,sharing=locked,target=/usr/local/cargo/registry \ RUN --mount=type=cache,sharing=locked,target=/usr/local/cargo/registry \
--mount=type=cache,sharing=locked,target=/usr/local/cargo/git \ --mount=type=cache,sharing=locked,target=/usr/local/cargo/git \
--mount=type=cache,sharing=locked,target=/app/target \ --mount=type=cache,target=/app/target \
cargo build --release -p backend-api && \ cargo build --release -p backend-api && \
cp target/release/backend-api /app/backend-api cp target/release/backend-api /app/backend-api
FROM debian:bookworm-slim AS backend-api FROM runtime-base AS backend-api
WORKDIR /app WORKDIR /app
RUN apt-get update && apt-get install -y libssl3 ca-certificates curl && rm -rf /var/lib/apt/lists/*
COPY --from=builder-backend-api /app/backend-api . COPY --from=builder-backend-api /app/backend-api .
ENV RUST_LOG=info ENV RUST_LOG=info
EXPOSE 8000 EXPOSE 8000
@@ -125,13 +146,12 @@ FROM builder-linux AS builder-data-collector
COPY services/data-collector services/data-collector COPY services/data-collector services/data-collector
RUN --mount=type=cache,sharing=locked,target=/usr/local/cargo/registry \ RUN --mount=type=cache,sharing=locked,target=/usr/local/cargo/registry \
--mount=type=cache,sharing=locked,target=/usr/local/cargo/git \ --mount=type=cache,sharing=locked,target=/usr/local/cargo/git \
--mount=type=cache,sharing=locked,target=/app/target \ --mount=type=cache,target=/app/target \
cargo build --release -p data-collector && \ cargo build --release -p data-collector && \
cp target/release/data-collector /app/data-collector cp target/release/data-collector /app/data-collector
FROM debian:bookworm-slim AS data-collector FROM runtime-base AS data-collector
WORKDIR /app WORKDIR /app
RUN apt-get update && apt-get install -y libssl3 ca-certificates && rm -rf /var/lib/apt/lists/*
COPY --from=builder-data-collector /app/data-collector . COPY --from=builder-data-collector /app/data-collector .
ENV RUST_LOG=info ENV RUST_LOG=info
CMD ["./data-collector"] CMD ["./data-collector"]
@@ -141,13 +161,12 @@ FROM builder-linux AS builder-json-generator
COPY services/json-generator services/json-generator COPY services/json-generator services/json-generator
RUN --mount=type=cache,sharing=locked,target=/usr/local/cargo/registry \ RUN --mount=type=cache,sharing=locked,target=/usr/local/cargo/registry \
--mount=type=cache,sharing=locked,target=/usr/local/cargo/git \ --mount=type=cache,sharing=locked,target=/usr/local/cargo/git \
--mount=type=cache,sharing=locked,target=/app/target \ --mount=type=cache,target=/app/target \
cargo build --release -p json-generator && \ cargo build --release -p json-generator && \
cp target/release/json-generator /app/json-generator cp target/release/json-generator /app/json-generator
FROM debian:bookworm-slim AS json-generator FROM runtime-base AS json-generator
WORKDIR /app WORKDIR /app
RUN apt-get update && apt-get install -y libssl3 ca-certificates && rm -rf /var/lib/apt/lists/*
COPY --from=builder-json-generator /app/json-generator . COPY --from=builder-json-generator /app/json-generator .
ENV RUST_LOG=info ENV RUST_LOG=info
CMD ["./json-generator"] CMD ["./json-generator"]
@@ -157,13 +176,12 @@ FROM builder-linux AS builder-telemetry-ingestor
COPY services/telemetry-ingestor services/telemetry-ingestor COPY services/telemetry-ingestor services/telemetry-ingestor
RUN --mount=type=cache,sharing=locked,target=/usr/local/cargo/registry \ RUN --mount=type=cache,sharing=locked,target=/usr/local/cargo/registry \
--mount=type=cache,sharing=locked,target=/usr/local/cargo/git \ --mount=type=cache,sharing=locked,target=/usr/local/cargo/git \
--mount=type=cache,sharing=locked,target=/app/target \ --mount=type=cache,target=/app/target \
cargo build --release -p telemetry-ingestor && \ cargo build --release -p telemetry-ingestor && \
cp target/release/telemetry-ingestor /app/telemetry-ingestor cp target/release/telemetry-ingestor /app/telemetry-ingestor
FROM debian:bookworm-slim AS telemetry-ingestor FROM runtime-base AS telemetry-ingestor
WORKDIR /app WORKDIR /app
RUN apt-get update && apt-get install -y libssl3 ca-certificates && rm -rf /var/lib/apt/lists/*
COPY --from=builder-telemetry-ingestor /app/telemetry-ingestor . COPY --from=builder-telemetry-ingestor /app/telemetry-ingestor .
ENV RUST_LOG=info ENV RUST_LOG=info
CMD ["./telemetry-ingestor"] CMD ["./telemetry-ingestor"]
@@ -173,13 +191,12 @@ FROM builder-linux AS builder-events-relay
COPY services/events-relay services/events-relay COPY services/events-relay services/events-relay
RUN --mount=type=cache,sharing=locked,target=/usr/local/cargo/registry \ RUN --mount=type=cache,sharing=locked,target=/usr/local/cargo/registry \
--mount=type=cache,sharing=locked,target=/usr/local/cargo/git \ --mount=type=cache,sharing=locked,target=/usr/local/cargo/git \
--mount=type=cache,sharing=locked,target=/app/target \ --mount=type=cache,target=/app/target \
cargo build --release -p events-relay && \ cargo build --release -p events-relay && \
cp target/release/events-relay /app/events-relay cp target/release/events-relay /app/events-relay
FROM debian:bookworm-slim AS events-relay FROM runtime-base AS events-relay
WORKDIR /app WORKDIR /app
RUN apt-get update && apt-get install -y libssl3 ca-certificates && rm -rf /var/lib/apt/lists/*
COPY --from=builder-events-relay /app/events-relay . COPY --from=builder-events-relay /app/events-relay .
ENV RUST_LOG=info ENV RUST_LOG=info
CMD ["./events-relay"] CMD ["./events-relay"]
@@ -189,21 +206,21 @@ FROM builder-linux AS builder-build-orchestrator
COPY services/build-orchestrator services/build-orchestrator COPY services/build-orchestrator services/build-orchestrator
RUN --mount=type=cache,sharing=locked,target=/usr/local/cargo/registry \ RUN --mount=type=cache,sharing=locked,target=/usr/local/cargo/registry \
--mount=type=cache,sharing=locked,target=/usr/local/cargo/git \ --mount=type=cache,sharing=locked,target=/usr/local/cargo/git \
--mount=type=cache,sharing=locked,target=/app/target \ --mount=type=cache,target=/app/target \
cargo build --release -p build-orchestrator && \ cargo build --release -p build-orchestrator && \
cp target/release/build-orchestrator /app/build-orchestrator cp target/release/build-orchestrator /app/build-orchestrator
FROM debian:bookworm-slim AS build-orchestrator FROM runtime-base AS build-orchestrator
WORKDIR /app WORKDIR /app
RUN apt-get update && apt-get install -y libssl3 ca-certificates mosquitto-clients && rm -rf /var/lib/apt/lists/*
# Binario del orquestador
COPY --from=builder-build-orchestrator /app/build-orchestrator . COPY --from=builder-build-orchestrator /app/build-orchestrator .
# Template del agente (Desde la etapa builder-windows) # Template del agente (Desde la etapa builder-windows)
RUN mkdir -p target/x86_64-pc-windows-gnu/release RUN mkdir -p target/x86_64-pc-windows-gnu/release
COPY --from=builder-windows /app/bin/windows/edge-agent.exe target/x86_64-pc-windows-gnu/release/ COPY --from=builder-windows /app/bin/windows/edge-agent.exe target/x86_64-pc-windows-gnu/release/
RUN mkdir -p target/x86_64-unknown-linux-musl/release
COPY --from=builder-windows /app/bin/linux/edge-agent target/x86_64-unknown-linux-musl/release/
# Cargo.toml del agente # Cargo.toml del agente
RUN mkdir -p services/edge-agent RUN mkdir -p services/edge-agent
COPY services/edge-agent/Cargo.toml services/edge-agent/Cargo.toml COPY services/edge-agent/Cargo.toml services/edge-agent/Cargo.toml

76
Makefile Normal file
View File

@@ -0,0 +1,76 @@
.PHONY: all build test lint format check dev up down clean help
all: help
help:
@echo "Usage: make <target>"
@echo ""
@echo "Targets:"
@echo " build Build all Rust services (release)"
@echo " build-dev Build all Rust services (debug)"
@echo " test Run all cargo tests"
@echo " test-pkg Run tests for a specific package: make test-pkg PKG=shared-lib"
@echo " lint Run all linters (cargo fmt --check + npm run lint)"
@echo " fmt Format all Rust code (cargo fmt)"
@echo " check Run cargo check on all packages"
@echo " dev Start full dev stack (docker compose up)"
@echo " up Start production stack"
@echo " down Stop all containers"
@echo " clean Remove build artifacts"
@echo " logs Tail logs from a service: make logs SVC=backend-api"
@echo " db-migrate Run database migrations"
@echo " npm-install Install npm dependencies for all frontends"
@echo " ci Full CI pipeline (check + test + lint)"
build:
cargo build --release
build-dev:
cargo build
test:
cargo test
test-pkg:
cargo test -p $(PKG)
lint:
cargo fmt -- --check
cd services/frontend-admin && npm run lint 2>/dev/null || true
cd services/frontend-pwa && npm run lint 2>/dev/null || true
fmt:
cargo fmt
check:
cargo check --workspace
dev:
docker compose -f docker-compose.yml -f docker-compose.dev.yml up -d
dev-build:
docker compose -f docker-compose.yml -f docker-compose.dev.yml up -d --build
up:
docker compose up -d
down:
docker compose down
clean:
cargo clean
rm -rf services/frontend-*/node_modules 2>/dev/null || true
logs:
docker compose logs -f $(SVC)
db-migrate:
cargo run -p backend-api --bin migrate
npm-install:
cd services/frontend-admin && npm install
cd services/frontend-pwa && npm install
cd services/frontend-console && npm install
cd services/landing && npm install
ci: check test lint

View File

@@ -26,7 +26,7 @@ services:
- SMTP_TLS_MODE=none - SMTP_TLS_MODE=none
- SMTP_USER= - SMTP_USER=
- SMTP_PASS= - SMTP_PASS=
- SMTP_FROM=notificaciones-local@omnioil.app - SMTP_FROM=OmniOil <soporte@omnioil.app>
- INVITATION_BASE_URL=http://localhost:3000 - INVITATION_BASE_URL=http://localhost:3000
- PLATFORM_CONSOLE_URL=http://localhost:3002/login - PLATFORM_CONSOLE_URL=http://localhost:3002/login
depends_on: depends_on:

View File

@@ -66,7 +66,7 @@ services:
- MQTT_USER=${MQTT_USER} - MQTT_USER=${MQTT_USER}
- MQTT_PASSWORD=${MQTT_PASSWORD} - MQTT_PASSWORD=${MQTT_PASSWORD}
- REDIS_URL=redis://:${REDIS_PASSWORD}@redis:6379 - REDIS_URL=redis://:${REDIS_PASSWORD}@redis:6379
- MINIO_ENDPOINT_URL=${MINIO_ENDPOINT_URL} - MINIO_ENDPOINT_URL=http://minio:9000
- AWS_ACCESS_KEY_ID=${MINIO_USER} - AWS_ACCESS_KEY_ID=${MINIO_USER}
- AWS_SECRET_ACCESS_KEY=${MINIO_PASSWORD} - AWS_SECRET_ACCESS_KEY=${MINIO_PASSWORD}
- ALLOWED_ORIGINS=${ALLOWED_ORIGINS} - ALLOWED_ORIGINS=${ALLOWED_ORIGINS}
@@ -84,6 +84,7 @@ services:
- MQTT_PUBLIC_PORT=${MQTT_PUBLIC_PORT:-1883} - MQTT_PUBLIC_PORT=${MQTT_PUBLIC_PORT:-1883}
- MQTT_USE_WS=${MQTT_USE_WS:-false} - MQTT_USE_WS=${MQTT_USE_WS:-false}
- APP_PUBLIC_URL=${APP_PUBLIC_URL} - APP_PUBLIC_URL=${APP_PUBLIC_URL}
- AGENT_LATEST_VERSION=${AGENT_LATEST_VERSION:-0.3.0}
expose: expose:
- "8000" - "8000"
depends_on: depends_on:
@@ -112,6 +113,8 @@ services:
environment: environment:
- DATABASE_URL=postgres://${DB_USER}:${DB_PASSWORD}@timescaledb:5432/${DB_NAME} - DATABASE_URL=postgres://${DB_USER}:${DB_PASSWORD}@timescaledb:5432/${DB_NAME}
- REDIS_URL=redis://:${REDIS_PASSWORD}@redis:6379 - REDIS_URL=redis://:${REDIS_PASSWORD}@redis:6379
expose:
- "9092"
depends_on: depends_on:
backend-api: backend-api:
condition: service_healthy condition: service_healthy
@@ -133,6 +136,8 @@ services:
- MQTT_PORT=1883 - MQTT_PORT=1883
- MQTT_USER=${MQTT_USER} - MQTT_USER=${MQTT_USER}
- MQTT_PASSWORD=${MQTT_PASSWORD} - MQTT_PASSWORD=${MQTT_PASSWORD}
expose:
- "9091"
depends_on: depends_on:
timescaledb: timescaledb:
condition: service_healthy condition: service_healthy
@@ -150,9 +155,14 @@ services:
target: json-generator target: json-generator
environment: environment:
- DATABASE_URL=postgres://${DB_USER}:${DB_PASSWORD}@timescaledb:5432/${DB_NAME} - DATABASE_URL=postgres://${DB_USER}:${DB_PASSWORD}@timescaledb:5432/${DB_NAME}
- MINIO_ENDPOINT=${MINIO_ENDPOINT} - MINIO_ENDPOINT_URL=http://minio:9000
- MINIO_ENDPOINT=http://minio:9000
- MINIO_BUCKET=${MINIO_BUCKET:-anh-reports}
- AWS_ACCESS_KEY_ID=${MINIO_USER} - AWS_ACCESS_KEY_ID=${MINIO_USER}
- AWS_SECRET_ACCESS_KEY=${MINIO_PASSWORD} - AWS_SECRET_ACCESS_KEY=${MINIO_PASSWORD}
- AWS_REGION=${AWS_REGION:-us-east-1}
expose:
- "9093"
depends_on: depends_on:
backend-api: backend-api:
condition: service_healthy condition: service_healthy
@@ -165,7 +175,8 @@ services:
container_name: anh_landing container_name: anh_landing
build: build:
context: . context: .
dockerfile: services/landing/Dockerfile dockerfile: Dockerfile.frontend
target: landing
args: args:
- NEXT_PUBLIC_TURNSTILE_SITE_KEY=${NEXT_PUBLIC_TURNSTILE_SITE_KEY:-${VITE_TURNSTILE_SITE_KEY}} - NEXT_PUBLIC_TURNSTILE_SITE_KEY=${NEXT_PUBLIC_TURNSTILE_SITE_KEY:-${VITE_TURNSTILE_SITE_KEY}}
environment: environment:
@@ -180,7 +191,8 @@ services:
restart: always restart: always
build: build:
context: . context: .
dockerfile: services/frontend-pwa/Dockerfile dockerfile: Dockerfile.frontend
target: frontend-pwa
args: args:
- VITE_API_BASE=/api - VITE_API_BASE=/api
- VITE_ADMIN_APP_ORIGIN=${VITE_ADMIN_APP_ORIGIN} - VITE_ADMIN_APP_ORIGIN=${VITE_ADMIN_APP_ORIGIN}
@@ -195,8 +207,9 @@ services:
container_name: anh_frontend_admin container_name: anh_frontend_admin
restart: always restart: always
build: build:
context: ./services/frontend-admin context: .
dockerfile: Dockerfile dockerfile: Dockerfile.frontend
target: frontend-admin
args: args:
- VITE_API_BASE=/api - VITE_API_BASE=/api
- VITE_ADMIN_APP_ORIGIN=${VITE_ADMIN_APP_ORIGIN} - VITE_ADMIN_APP_ORIGIN=${VITE_ADMIN_APP_ORIGIN}
@@ -213,11 +226,13 @@ services:
container_name: anh_frontend_console container_name: anh_frontend_console
restart: always restart: always
build: build:
context: ./services/frontend-console context: .
dockerfile: Dockerfile dockerfile: Dockerfile.frontend
target: frontend-console
args: args:
- VITE_API_BASE=/api - VITE_API_BASE=/api
- VITE_CONSOLE_APP_ORIGIN=${VITE_CONSOLE_APP_ORIGIN:-https://console.omnioil.app} - VITE_CONSOLE_APP_ORIGIN=${VITE_CONSOLE_APP_ORIGIN:-https://console.omnioil.app}
- VITE_ACCESS_REQUEST_REVIEWER_EMAILS=${ACCESS_REQUEST_REVIEWER_EMAILS:-w.farfan@omnioil.app,h.ortegon@omnioil.app}
expose: expose:
- "80" - "80"
depends_on: depends_on:
@@ -252,13 +267,15 @@ services:
context: . context: .
dockerfile: Dockerfile.unified dockerfile: Dockerfile.unified
target: build-orchestrator target: build-orchestrator
args:
- BUILD_VERSION=${AGENT_LATEST_VERSION:-0.3.0}
environment: environment:
- DATABASE_URL=postgres://${DB_USER}:${DB_PASSWORD}@timescaledb:5432/${DB_NAME} - DATABASE_URL=postgres://${DB_USER}:${DB_PASSWORD}@timescaledb:5432/${DB_NAME}
- MQTT_HOST=mosquitto - MQTT_HOST=mosquitto
- MQTT_PORT=1883 - MQTT_PORT=1883
- MQTT_USER=${MQTT_USER} - MQTT_USER=${MQTT_USER}
- MQTT_PASSWORD=${MQTT_PASSWORD} - MQTT_PASSWORD=${MQTT_PASSWORD}
- MINIO_ENDPOINT_URL=${MINIO_ENDPOINT_URL} - MINIO_ENDPOINT_URL=http://minio:9000
- AWS_ACCESS_KEY_ID=${MINIO_USER} - AWS_ACCESS_KEY_ID=${MINIO_USER}
- AWS_SECRET_ACCESS_KEY=${MINIO_PASSWORD} - AWS_SECRET_ACCESS_KEY=${MINIO_PASSWORD}
- AWS_REGION=${AWS_REGION} - AWS_REGION=${AWS_REGION}
@@ -269,6 +286,9 @@ services:
- MQTT_USE_WS=${MQTT_USE_WS:-true} - MQTT_USE_WS=${MQTT_USE_WS:-true}
- OMNIOIL_MASTER_SECRET=${OMNIOIL_MASTER_SECRET} - OMNIOIL_MASTER_SECRET=${OMNIOIL_MASTER_SECRET}
- S3_INSTALLERS_BUCKET=${S3_INSTALLERS_BUCKET:-omnioil-releases} - S3_INSTALLERS_BUCKET=${S3_INSTALLERS_BUCKET:-omnioil-releases}
- AGENT_LATEST_VERSION=${AGENT_LATEST_VERSION:-0.3.0}
expose:
- "9094"
volumes: volumes:
- ./infrastructure/mosquitto:/app/infrastructure/mosquitto - ./infrastructure/mosquitto:/app/infrastructure/mosquitto
- ${INSTALLER_INCOMING:-installer_incoming}:/app/services/edge-agent/incoming - ${INSTALLER_INCOMING:-installer_incoming}:/app/services/edge-agent/incoming
@@ -286,6 +306,8 @@ services:
build: build:
context: . context: .
dockerfile: services/edge-agent/Dockerfile.builder dockerfile: services/edge-agent/Dockerfile.builder
args:
- BUILD_VERSION=${AGENT_LATEST_VERSION:-0.3.0}
volumes: volumes:
- ${INSTALLER_INCOMING:-installer_incoming}:/app/services/edge-agent/incoming - ${INSTALLER_INCOMING:-installer_incoming}:/app/services/edge-agent/incoming
- ${INSTALLER_OUTPUT:-installer_output}:/app/services/edge-agent/output - ${INSTALLER_OUTPUT:-installer_output}:/app/services/edge-agent/output
@@ -303,6 +325,7 @@ services:
- '--web.enable-lifecycle' - '--web.enable-lifecycle'
volumes: volumes:
- ./infrastructure/monitoring/prometheus.yml:/etc/prometheus/prometheus.yml:ro - ./infrastructure/monitoring/prometheus.yml:/etc/prometheus/prometheus.yml:ro
- ./infrastructure/monitoring/prometheus-rules.yml:/etc/prometheus/prometheus-rules.yml:ro
- prometheus_data:/prometheus - prometheus_data:/prometheus
networks: networks:
- anh_network - anh_network
@@ -331,6 +354,44 @@ services:
depends_on: depends_on:
- prometheus - prometheus
loki:
image: grafana/loki:3.6.0
container_name: anh_loki
restart: always
command: -config.file=/etc/loki/loki-config.yml
volumes:
- ./infrastructure/monitoring/loki-config.yml:/etc/loki/loki-config.yml:ro
- loki_data:/loki
networks:
- anh_network
promtail:
image: grafana/promtail:3.6.0
container_name: anh_promtail
restart: always
command: -config.file=/etc/promtail/promtail-config.yml
volumes:
- ./infrastructure/monitoring/promtail-config.yml:/etc/promtail/promtail-config.yml:ro
- /var/run/docker.sock:/var/run/docker.sock:ro
- /var/lib/docker/containers:/var/lib/docker/containers:ro
networks:
- anh_network
depends_on:
- loki
alertmanager:
image: prom/alertmanager:v0.28.1
container_name: anh_alertmanager
restart: always
command:
- '--config.file=/etc/alertmanager/alertmanager.yml'
- '--storage.path=/alertmanager'
volumes:
- ./infrastructure/monitoring/alertmanager.yml:/etc/alertmanager/alertmanager.yml:ro
- alertmanager_data:/alertmanager
networks:
- anh_network
node-exporter: node-exporter:
image: prom/node-exporter:v1.9.1 image: prom/node-exporter:v1.9.1
container_name: anh_node_exporter container_name: anh_node_exporter
@@ -362,3 +423,5 @@ volumes:
installer_output: installer_output:
prometheus_data: prometheus_data:
grafana_data: grafana_data:
loki_data:
alertmanager_data:

View File

@@ -12,7 +12,7 @@ La **Resolución 0651 de 2025** de la Agencia Nacional de Hidrocarburos (ANH) es
- Instalación de sistemas de telemetría certificados en todos los campos de producción activos - Instalación de sistemas de telemetría certificados en todos los campos de producción activos
- Reporte diario automático en formato JSON con identificador único de recurso - Reporte diario automático en formato JSON con identificador único de recurso
- Almacenamiento de datos con frecuencia mínima de 5 minutos - Almacenamiento de datos con frecuencia mínima de 5 minutos
- Mecanismos de integridad de datos (hash SHA-256) en cada reporte - Mecanismos de integridad, inalterabilidad y trazabilidad del dato reportado
- Trazabilidad completa de ingresos y movimientos de crudo - Trazabilidad completa de ingresos y movimientos de crudo
- Registro de paradas planificadas y no planificadas con justificación - Registro de paradas planificadas y no planificadas con justificación
@@ -32,7 +32,7 @@ El incumplimiento de la Resolución puede resultar en:
| **Art. 7** | Sistema de medición continua (mínimo cada 5 min) | Telemetría cada 10 segundos vía MQTT, almacenada en TimescaleDB | ✅ | | **Art. 7** | Sistema de medición continua (mínimo cada 5 min) | Telemetría cada 10 segundos vía MQTT, almacenada en TimescaleDB | ✅ |
| **Art. 17** | Almacenamiento periódico mínimo 5 min | Hypertable con registros cada ≤5 minutos. Resolución real: 10 segundos | ✅ | | **Art. 17** | Almacenamiento periódico mínimo 5 min | Hypertable con registros cada ≤5 minutos. Resolución real: 10 segundos | ✅ |
| **Art. 22** | Redundancia ante fallos de comunicación | Store & Forward en Edge Agent (SQLite AES-256 local) | ✅ | | **Art. 22** | Redundancia ante fallos de comunicación | Store & Forward en Edge Agent (SQLite AES-256 local) | ✅ |
| **Art. 26** | Integridad e inalterabilidad de datos | TimescaleDB append-only + hash SHA-256 en cada reporte | ✅ | | **Art. 26** | Integridad e inalterabilidad de datos | TimescaleDB append-only + artefactos JSON sellados en MinIO; cada corrección crea una nueva versión inmutable con hash SHA-384 propio | ✅ |
| **Art. 27** | Timestamps UTC obligatorios | `timestamptz` en todas las tablas, NTP sincronizado | ✅ | | **Art. 27** | Timestamps UTC obligatorios | `timestamptz` en todas las tablas, NTP sincronizado | ✅ |
| **Art. 34** | Nomenclatura del archivo de reporte | `OPERADOR_CONTRATO_DD-MMM-YYYY.json` implementado | ✅ | | **Art. 34** | Nomenclatura del archivo de reporte | `OPERADOR_CONTRATO_DD-MMM-YYYY.json` implementado | ✅ |
| **Art. 35** | ID de recurso coincidente con Forma 4CR | Campo `id_recurso` mapeado al código ANH del pozo | ✅ | | **Art. 35** | ID de recurso coincidente con Forma 4CR | Campo `id_recurso` mapeado al código ANH del pozo | ✅ |
@@ -41,27 +41,19 @@ El incumplimiento de la Resolución puede resultar en:
| **Art. 52** | Registro de paradas y excepciones | Sección `exceptions` en el reporte JSON con alarmas detectadas | ✅ | | **Art. 52** | Registro de paradas y excepciones | Sección `exceptions` en el reporte JSON con alarmas detectadas | ✅ |
| **Art. 55** | Auditoría de datos ingresados manualmente | `audit_log` con usuario, IP, timestamp, valor anterior/nuevo | ✅ | | **Art. 55** | Auditoría de datos ingresados manualmente | `audit_log` con usuario, IP, timestamp, valor anterior/nuevo | ✅ |
| **Art. 60** | Metrología y calibración | Campos de calibración en activos: última fecha, próxima fecha, certificado | ✅ | | **Art. 60** | Metrología y calibración | Campos de calibración en activos: última fecha, próxima fecha, certificado | ✅ |
| **Art. 65** | Acceso del ente regulador | Rol `anh_reader` con acceso de solo lectura a reportes y datos históricos | ✅ | | **Art. 65** | Acceso del ente regulador | Rol `anh_reader` con acceso de solo lectura a reportes, versiones corregidas y datos históricos dispuestos para consulta ANH | ✅ |
--- ---
## 3. Integridad de Datos ## 3. Integridad de Datos
### 3.1 Hash SHA-256 ### 3.1 Artefacto JSON sellado e integridad interna
Cada reporte diario generado incluye un hash de integridad en el encabezado: El JSON entregado a la ANH mantiene únicamente la estructura normativa del Anexo 4. OmniOil no agrega campos propios como `Hash Sello`, `INTEGRITY_HASH` o un `hash_sha256` en el cuerpo del JSON entregable.
```json Para auditoría interna, cada reporte se sella como bytes finales en MinIO y el sistema calcula `hash_sha384` sobre esos bytes exactos. Ese hash se conserva como metadato de evidencia, separado del contenido normativo del archivo.
{
"header": {
"hash_sha256": "a3f5c8d9e2b1f4a7c6d8e9f0a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6e7f8a9b0",
"algoritmo": "SHA-256",
"fecha_generacion": "2026-05-04T06:30:00Z"
}
}
```
Cualquier alteración del contenido del archivo después de su generación invalidará el hash, permitiendo detectar manipulaciones. Cualquier alteración del objeto sellado cambia el SHA-384 esperado y permite detectar manipulaciones sin modificar la forma del JSON que recibe la ANH.
### 3.2 Timestamps UTC ### 3.2 Timestamps UTC
@@ -208,18 +200,44 @@ CREATE TABLE audit_log (
### 8.1 Proceso de Generación ### 8.1 Proceso de Generación
El microservicio `json-generator` ejecuta diariamente a las **06:30 AM UTC**: El microservicio `json-generator` genera reportes ANH con una programación administrable desde el panel Admin. La configuración por defecto es **06:00** en zona horaria **America/Bogota** y puede activarse/desactivarse por unidad de generación ANH.
1. Consulta todos los proyectos activos con contrato ANH configurado 1. Consulta todos los proyectos activos con contrato ANH configurado
2. Para cada proyecto, agrega: 2. Para cada proyecto, agrega:
- Telemetría de las últimas 24h (con promedio, mínimo y máximo por variable) - Telemetría de las últimas 24h (con promedio, mínimo y máximo por variable)
- Movimientos de producción del período - Movimientos de producción del período
- Alarmas y excepciones del período - Alarmas y excepciones del período
3. Calcula el hash SHA-256 del contenido 3. Serializa el JSON normativo final, sin campos de hash agregados por OmniOil
4. Guarda el archivo en MinIO (bucket `anh-reports`) 4. Guarda los bytes finales en MinIO (bucket `anh-reports`) y registra `hash_sha384` como metadato interno de auditoría
5. Envía notificación de reporte generado a los supervisores 5. Deja el reporte en estado generado/disponible para revisión; no lo aprueba automáticamente
### 8.2 Generación Manual ### 8.2 Estados del ciclo de vida
- `GENERATED`: reporte sellado y disponible para revisión manual.
- `APPROVED`: reporte aprobado manualmente por un administrador.
- `SENT` / `DISPOSED`: estados históricos legibles por compatibilidad; en la interfaz se interpretan como **disponible para ANH** o **dispuesto para consulta ANH**.
- `REJECTED`: ANH registró rechazo o inconsistencia sobre una versión disponible.
- `CORRECTION_DRAFT`: nueva versión de corrección en preparación; no modifica el artefacto sellado anterior.
- `CORRECTED_APPROVED`: versión corregida aprobada y sellada con hash propio.
- `AVAILABLE_FOR_ANH`: artefacto sellado disponible para consulta ANH.
- `FAILED`: generación fallida o error operativo.
### 8.3 Correcciones y re-disposición para consulta ANH
OmniOil **no envía reportes a la ANH** ni representa una retransmisión saliente por API. El sistema conserva artefactos sellados y los deja **disponibles para ANH** / **dispuestos para consulta ANH** cuando el regulador decida accederlos.
Flujo de corrección:
1. Un administrador autorizado registra el rechazo o inconsistencia con motivo obligatorio y fecha/hora del rechazo.
2. Desde esa fecha/hora corre un SLA de **1 hora** para preparar la corrección.
3. La corrección se crea como una nueva versión inmutable (`correction_version`) enlazada a la versión previa y a la raíz del reporte.
4. El artefacto anterior permanece sellado: no se sobrescriben `object_key`, `hash_sha384` ni `sealed_at`.
5. La versión corregida se aprueba y se sella con su propio `object_key`, `hash_sha384` y `sealed_at`.
6. El administrador marca la versión corregida como **disponible para ANH**; esto solo registra la disposición para consulta y no dispara integración saliente.
Cada evento queda en auditoría con actor, motivo, timestamp, versión previa, versión raíz y metadatos del artefacto sellado. La historia permite reconstruir la cadena completa: original → rechazo/inconsistencia → versión corregida → artefacto corregido sellado → disponible para ANH.
### 8.4 Generación Manual
Para generar el reporte de un día específico: Para generar el reporte de un día específico:
``` ```
@@ -244,7 +262,11 @@ Antes de la certificación ante la ANH, verificar:
- [ ] Reglas de alerta configuradas para variables críticas - [ ] Reglas de alerta configuradas para variables críticas
- [ ] Fechas de calibración actualizadas en todos los activos - [ ] Fechas de calibración actualizadas en todos los activos
- [ ] Primer reporte ANH generado y verificado manualmente - [ ] Primer reporte ANH generado y verificado manualmente
- [ ] Hash SHA-256 verificado correctamente - [ ] Hash interno SHA-384 verificado contra los bytes sellados descargados desde MinIO
- [ ] Correcciones ANH verificadas como versiones inmutables, sin sobrescribir artefactos sellados previos
- [ ] Rechazos/inconsistencias registran motivo obligatorio y SLA de 1 hora desde el timestamp de rechazo
- [ ] Versiones corregidas aprobadas quedan disponibles para ANH / dispuestas para consulta ANH, sin flujo de envío saliente
- [ ] Audit trail muestra actor, motivo, timestamp, versión previa, versión raíz, hash y objeto sellado
- [ ] Nomenclatura del archivo cumple con el Art. 34 - [ ] Nomenclatura del archivo cumple con el Art. 34
- [ ] Rol `anh_reader` creado para el auditor de la ANH - [ ] Rol `anh_reader` creado para el auditor de la ANH
- [ ] MinIO accesible para descarga de reportes - [ ] MinIO accesible para descarga de reportes

View File

@@ -102,14 +102,14 @@ COMPOSE_PATH_SEPARATOR=:
INVITATION_BASE_URL=https://app.omnioil.app INVITATION_BASE_URL=https://app.omnioil.app
SMTP_HOST=smtp.tu-proveedor.com SMTP_HOST=smtp.tu-proveedor.com
SMTP_PORT=587 SMTP_PORT=587
SMTP_USER=notificaciones@omnioil.app SMTP_USER=soporte@omnioil.app
SMTP_PASS=<gestionar-en-secrets-manager> SMTP_PASS=<gestionar-en-secrets-manager>
SMTP_FROM=notificaciones@omnioil.app SMTP_FROM=OmniOil <soporte@omnioil.app>
``` ```
`INVITATION_BASE_URL` debe apuntar al Admin App público porque los clientes activan su cuenta en `https://app.omnioil.app/activate?token=...`, no en la Console interna. Las variables `SMTP_*` son requeridas para los correos de aprobación/activación y reutilizan la configuración SMTP del backend; `SMTP_PASS` debe cargarse desde el gestor de secretos de Dokploy o equivalente, nunca desde el repositorio. `INVITATION_BASE_URL` debe apuntar al Admin App público porque los clientes activan su cuenta en `https://app.omnioil.app/activate?token=...`, no en la Console interna. Las variables `SMTP_*` son requeridas para acuses de recibo, rechazos, aprobación/activación y reutilizan la configuración SMTP del backend; `SMTP_PASS` debe cargarse desde el gestor de secretos de Dokploy o equivalente, nunca desde el repositorio. Los únicos correos autorizados para aprobar, rechazar, marcar en revisión o reenviar invitaciones son `w.farfan@omnioil.app` y `h.ortegon@omnioil.app`; esta allowlist se aplica en backend.
Cuando se aprueba una solicitud, el backend crea el proyecto, el usuario administrador cliente, el acceso al proyecto y una suscripción inicial `trial` de 7 días antes de intentar enviar el correo. Si SMTP falla, la aprobación NO se revierte: el operador debe revisar la solicitud en Console y usar `Reenviar invitación` después de corregir la configuración o incidente SMTP. Cuando se aprueba una solicitud, el backend crea el proyecto, el usuario administrador cliente, el acceso al proyecto y una suscripción inicial `trial` de 30 días antes de intentar enviar el correo. Si SMTP falla, la aprobación NO se revierte: el operador debe revisar la solicitud en Console y usar `Reenviar invitación` después de corregir la configuración o incidente SMTP.
### 2.3 Configuración MQTT sobre WSS ### 2.3 Configuración MQTT sobre WSS
@@ -136,11 +136,13 @@ wss://mqtt.omnioil.app:443
- [ ] `TURNSTILE_SECRET_KEY` configurado en backend para validar solicitudes públicas de acceso - [ ] `TURNSTILE_SECRET_KEY` configurado en backend para validar solicitudes públicas de acceso
- [ ] Puertos 5432 (TimescaleDB) y 6379 (Redis) NO expuestos públicamente - [ ] Puertos 5432 (TimescaleDB) y 6379 (Redis) NO expuestos públicamente
- [ ] `VITE_API_BASE` apunta al subdominio correcto de la API - [ ] `VITE_API_BASE` apunta al subdominio correcto de la API
- [ ] `VITE_ADMIN_APP_ORIGIN=https://app.omnioil.app` y `VITE_MOBILE_APP_ORIGIN=https://mobile.omnioil.app` configurados antes de rebuildar frontends
- [ ] `VITE_CONSOLE_APP_ORIGIN` apunta a `https://console.omnioil.app` - [ ] `VITE_CONSOLE_APP_ORIGIN` apunta a `https://console.omnioil.app`
- [ ] `ALLOWED_ORIGINS` incluye `https://app.omnioil.app`, `https://mobile.omnioil.app` y `https://console.omnioil.app` - [ ] `ALLOWED_ORIGINS` incluye `https://app.omnioil.app`, `https://mobile.omnioil.app` y `https://console.omnioil.app`
- [ ] `VITE_TURNSTILE_SITE_KEY` configurado en el frontend admin para el formulario público de registro - [ ] `VITE_TURNSTILE_SITE_KEY` configurado en el frontend admin para el formulario público de registro
- [ ] `INVITATION_BASE_URL=https://app.omnioil.app` configurado para links de activación de solicitudes aprobadas - [ ] `INVITATION_BASE_URL=https://app.omnioil.app` configurado para links de activación de solicitudes aprobadas
- [ ] `SMTP_HOST`, `SMTP_PORT`, `SMTP_USER`, `SMTP_PASS` y `SMTP_FROM` configurados para alertas ANH y correos de aprobación/activación - [ ] `SMTP_HOST`, `SMTP_PORT`, `SMTP_USER=soporte@omnioil.app`, `SMTP_PASS` y `SMTP_FROM=OmniOil <soporte@omnioil.app>` configurados para alertas ANH y correos de acceso
- [ ] Console recibe `VITE_ACCESS_REQUEST_REVIEWER_EMAILS=w.farfan@omnioil.app,h.ortegon@omnioil.app` como ayuda visual; la autorización real se valida en backend
- [ ] Operadores informados: un fallo SMTP no revierte la aprobación; corregir SMTP y usar `Reenviar invitación` desde Console - [ ] Operadores informados: un fallo SMTP no revierte la aprobación; corregir SMTP y usar `Reenviar invitación` desde Console
- [ ] `TELEGRAM_*` configurado para alertas ANH cuando aplique - [ ] `TELEGRAM_*` configurado para alertas ANH cuando aplique
- [ ] Backup inicial de la base de datos configurado - [ ] Backup inicial de la base de datos configurado

View File

@@ -0,0 +1,200 @@
# Docker Build Cache Optimization Guide
## Changes Made
### 1. **Dockerfile.unified** (Rust Services)
#### Problem Solved
- Previously, `planner` and `cacher` stages only generated/compiled dependencies for `backend-api`
- This caused other services to recompile shared dependencies independently
- Each service rebuild invalidated previous builds' caches
#### Improvements
- **Planner stage**: Now copies only `Cargo.toml` files and creates stub source files
- Generates recipe for **ALL workspace dependencies**, not just backend-api
- Maximizes cache reuse across all services
- **Cacher stage**: Now compiles **all dependencies** for the entire workspace
- Single cache layer for all Rust services
- Dependencies are compiled once and reused by all builders
- **Builder stages**: Each builder (linux/windows) now:
- Copies from cacher (pre-compiled deps)
- Uses `--mount=type=cache` for incremental builds
- Only rebuilds when source code changes
#### Expected Improvement
- **First build**: ~1-2 hours (initializes dependencies)
- **Code changes only**: 30-60 seconds (skips dependency compilation)
- **Dependency changes**: ~5-10 minutes (only affected services rebuild)
### 2. **Frontend Dockerfiles** (Node.js SPAs)
#### Problem Solved
- Previously, copied `package.json` and `pnpm-lock.yaml` together
- Any source code change invalidated the entire deps cache
- Rebuild would reinstall `node_modules` even if dependencies unchanged
#### Improvements
**Standard pattern for all frontends** (admin, console, pwa, landing):
```dockerfile
# Stage 1: Dependencies (cached separately)
FROM node:22-alpine AS deps
COPY package.json pnpm-lock.yaml ./
RUN pnpm install (with --mount=type=cache)
# Stage 2: Build (reuses cached node_modules)
FROM node:22-alpine AS builder
COPY --from=deps /app/node_modules ./node_modules
COPY . .
RUN pnpm build
# Stage 3: Runtime (Nginx)
FROM nginx:alpine
COPY --from=builder /app/dist /usr/share/nginx/html
```
#### Expected Improvement
- **Dependency changes only**: 2-5 minutes (installs new deps)
- **Source code changes**: 10-30 seconds (build only)
- **Rebuild with no changes**: <5 seconds (pulls from cache)
### 3. **docker-compose.yml** (Context Normalization)
#### Problem Solved
- Inconsistent `context` values across services
- Some used `context: .` with full dockerfile paths
- Others used `context: ./services/...` with relative paths
- This made builds unpredictable and paths ambiguous
#### Improvements
- **All services now use consistent context**:
- Rust services: `context: .` with `dockerfile: Dockerfile.unified` (workspace build)
- Frontend services: `context: ./services/frontend-xxx` with `dockerfile: Dockerfile`
- Infrastructure: `context: ./infrastructure/mosquitto` (local builds)
### 4. **.dockerignore** (Build Context Optimization)
Already configured to exclude:
- Build artifacts (`target/`, `dist/`, `.next/`, `node_modules/`)
- Git and IDE files
- Documentation (not needed in images)
- Test files and CI configs
## Docker Build Commands
### Full Stack Build (with caching)
```bash
# Build all services (leverages existing cache)
docker compose -f docker-compose.yml build
# Build specific service
docker compose -f docker-compose.yml build backend-api
docker compose -f docker-compose.yml build frontend-admin
```
### Development Stack
```bash
# Start with local cache
docker compose -f docker-compose.yml -f docker-compose.dev.yml up -d
# Rebuild after code changes (fast with cache)
docker compose -f docker-compose.yml build --no-cache # Clear all cache
docker compose -f docker-compose.yml build # Use cache
```
### CI/CD Recommendations
For **GitHub Actions** or **GitLab CI**:
```yaml
# Use --cache-from and --cache-to for BuildKit cache export
docker buildx build --cache-from=type=gha \
--cache-to=type=gha,mode=max \
-t myimage:latest .
```
For **Local Development**:
```bash
# Enable BuildKit (faster, better caching)
export DOCKER_BUILDKIT=1
# Build with progress output
docker compose build --progress plain
```
## Cache Invalidation Behavior
| Change Type | Cache Invalidated | Rebuild Time |
|-------------|------------------|--------------|
| No changes | No | <5s (pull from cache) |
| Source code only | Deps cache remains | 10-60s |
| `package.json` / `pnpm-lock.yaml` only | Node_modules reused if hash matches | 2-5m |
| `Cargo.toml` / `Cargo.lock` only | Deps recompiled, binaries reused | 5-15m |
| All of above changed | Full rebuild | 1-2h |
## Best Practices
### 1. **Use BuildKit for faster builds**
```bash
export DOCKER_BUILDKIT=1
export COMPOSE_DOCKER_CLI_BUILD=1
export BUILDKIT_PROGRESS=plain
```
### 2. **Keep Dockerfiles stable**
- Don't modify Dockerfile frequently (invalidates base layer cache)
- Update `.dockerignore` to exclude unnecessary files
### 3. **Dependency management**
- Update `Cargo.toml` / `pnpm-lock.yaml` separately when possible
- Commit lock files to version control
- Don't run `cargo update` or `pnpm update` in builds
### 4. **Multi-stage builds**
- Each stage is independently cacheable
- Earlier stages (deps) change less frequently
- Later stages (source) are rebuilt more often
### 5. **Monitor cache efficiency**
```bash
# View cache layers
docker buildx du
# Clear unused cache
docker buildx prune
```
## Troubleshooting
### Cache not working
```bash
# Check if BuildKit is enabled
docker buildx version
# Force rebuild without cache
docker compose build --no-cache
# Clear all local cache
docker buildx prune -a
```
### Slow builds after "fix"
- Verify `.dockerignore` isn't excluding needed files
- Check if `Cargo.lock` and `pnpm-lock.yaml` are stable
- Ensure CI systems use persistent cache volumes
### Container size optimization
```bash
# Check layer sizes
docker history myimage:tag
# Use slim base images (alpine, distroless)
# Already configured in this project
```
## References
- [Docker BuildKit Documentation](https://docs.docker.com/develop/build/build_context/)
- [Dockerfile Best Practices](https://docs.docker.com/develop/dev-best-practices/)
- [cargo-chef for Rust](https://github.com/LukeMathWalker/cargo-chef)

View File

@@ -523,9 +523,9 @@ DOCKER_REGISTRY_URL=docker-registry:5000
# --- NOTIFICACIONES --- # --- NOTIFICACIONES ---
SMTP_HOST=smtp.gmail.com SMTP_HOST=smtp.gmail.com
SMTP_PORT=465 SMTP_PORT=465
SMTP_USER=alertas@omnioil.app SMTP_USER=soporte@omnioil.app
SMTP_PASS=clave_de_aplicacion_smtp SMTP_PASS=clave_de_aplicacion_smtp
SMTP_FROM=alertas@omnioil.app SMTP_FROM=OmniOil <soporte@omnioil.app>
TELEGRAM_BOT_TOKEN=bot_token_telegram TELEGRAM_BOT_TOKEN=bot_token_telegram
TELEGRAM_CHAT_ID=-100123456789 TELEGRAM_CHAT_ID=-100123456789
@@ -588,10 +588,9 @@ El backend expone métricas en `GET /api/metrics` en formato Prometheus:
| Ruta | Descripción | | Ruta | Descripción |
|------|-------------| |------|-------------|
| `/login` | Login de operador | | `/login` | Login de operador |
| `/projects` | Lista de proyectos asignados | | `/app/field` | Inicio de la aplicación de campo |
| `/projects/:id/assets` | Activos del proyecto | | `/app/field/measurements` | Registrar medición manual |
| `/assets/:id/readings` | Registrar medición manual | | `/app/field/movements` | Registrar movimiento de producción |
| `/assets/:id/movements` | Registrar movimiento de producción |
| `/offline` | Estado offline / sincronización pendiente | | `/offline` | Estado offline / sincronización pendiente |
--- ---

115
docs/SIMULADORES.md Normal file
View File

@@ -0,0 +1,115 @@
# Simulador de Carga Modbus TCP
`tests/simulators/unified_simulator.py` es el simulador soportado para pruebas locales de carga Modbus TCP en OmniOil. Genera variables petroleras sintéticas para varios pozos, expone un servidor Modbus TCP, publica un dashboard/API HTTP y crea un CSV compatible con la importación del backend.
> Este script es **Modbus TCP-only**. No levanta Siemens S7 ni un simulador multiprotocolo. La restauración de S7 queda fuera del alcance de esta herramienta.
## Servicios expuestos
| Servicio | Protocolo | Endpoint | Descripción |
|---|---|---|---|
| Modbus TCP | Modbus TCP | `127.0.0.1:5020` | Un `unit_id` por pozo, con Holding/Input Registers base 0. |
| Dashboard | HTTP | `http://127.0.0.1:8085` | Panel web para observar estado y muestras en vivo. |
| API | HTTP JSON | `http://127.0.0.1:8085/api/data` | Snapshot de estado para smoke tests y desarrollo. |
## Instalación
Usá Python 3.8+ y fijá la versión de `pymodbus`:
```bash
python -m venv venv
source venv/bin/activate # Linux/WSL/macOS
# o: venv\Scripts\activate # Windows
pip install "pymodbus==3.6.9"
```
La versión `3.6.9` es intencional: el simulador usa `ModbusSlaveContext.setValues` para actualizar registros en vivo. Versiones más nuevas de `pymodbus` cambiaron esa API y pueden romper la actualización dinámica.
## Ejecución
Desde la raíz del repositorio:
```bash
python tests/simulators/unified_simulator.py
```
Al iniciar, el script:
1. Construye 5 pozos con 200 variables por pozo.
2. Genera `tests/simulators/variables_import.csv`.
3. Levanta el dashboard/API HTTP en el puerto `8085`.
4. Si `pymodbus==3.6.9` está instalado, levanta Modbus TCP en el puerto `5020`.
5. Actualiza variables de alta frecuencia cada 1 segundo y baja frecuencia cada 30 segundos.
Si `pymodbus` no está instalado o no coincide con `3.6.9`, el script muestra una advertencia clara. El CSV y el dashboard pueden seguir funcionando, pero el servidor Modbus TCP queda desactivado.
## Contrato Modbus
- Host recomendado para clientes locales: `127.0.0.1`.
- Puerto: `5020`.
- Unit IDs: `1..N`, uno por pozo. Con la configuración por defecto: `1..5`.
- Direcciones: base 0, usando el formato `HR:<address>` o `IR:<address>`.
- Ejemplos válidos: `HR:0`, `HR:1`, `IR:0`, `IR:1`.
- Tipos de dato generados para importación: `uint16`.
Este contrato coincide con el flujo de lectura del edge-agent: el `UnitId` identifica el pozo/PLC dentro del mismo endpoint TCP y `Address` indica el banco (`HR` o `IR`) más la dirección raw base 0.
## API HTTP
`GET http://127.0.0.1:8085/api/data` devuelve un snapshot con esta forma:
```json
{
"timestamp": "2026-06-25 15:00:00",
"summary": {
"total": 1000,
"hf": 400,
"lf": 600,
"wells": 5,
"changes_last_cycle": 400,
"modbus_running": true
},
"wells": [
{ "code": "POZO-01", "unit_id": 1, "tags": 200, "hf": 80, "lf": 120 }
],
"sample": [
{ "well": "POZO-01", "alias": "Presion_Cabezal_01", "address": "HR:0", "value": 1042, "unit": "psi" }
]
}
```
## CSV de importación
El simulador genera `tests/simulators/variables_import.csv` al arrancar. Ese archivo es salida generada y está ignorado por Git; no debe commitearse.
Headers esperados:
```text
WellCode,DeviceName,Host,Port,UnitId,VariableAlias,Address,DataType,LastCalibrationDate,NextCalibrationDate,CalibrationCertificateUrl
```
Con la carga por defecto se generan 1000 filas: 5 pozos × 200 variables. Cada fila apunta a `127.0.0.1:5020`, usa `UnitId` `1..5` y direcciones `HR:`/`IR:` base 0.
## Verificación segura
Comandos útiles para validar el contrato sin depender del backend completo:
```bash
python -m py_compile tests/simulators/unified_simulator.py
python -m unittest tests.simulators.test_unified_simulator_contract
```
Smoke manual de runtime:
```bash
pip install "pymodbus==3.6.9"
python tests/simulators/unified_simulator.py
curl http://127.0.0.1:8085/api/data
```
Después de una ejecución real, revisá que el CSV generado siga fuera del control de versiones:
```bash
git status --short --ignored tests/simulators/variables_import.csv
```

View File

@@ -0,0 +1,98 @@
# Telemetry Origin Contract
OmniOil distinguishes telemetry by origin, not by UI labels or polling heuristics. The backend persists the origin, downstream services derive frequency from it, and the dashboard consumes the same contract.
## Decision
| Concept | Contract |
| --- | --- |
| High frequency | `source = SCADA``frequency_class = HF` |
| Low frequency | `source = PWA` or `source = MANUAL``frequency_class = LF` |
| Movements | Operational events, not telemetry signal |
| Unknown origin | Do not count as HF or LF |
`frequency_class` is derived from `source`; it is not an independent persisted database column.
## Write Paths
| Path | Source | Persistence rule |
| --- | --- | --- |
| Edge MQTT telemetry | `SCADA` | Resolve `(project_id, device_name)` to exactly one asset before insert. Drop unresolved or ambiguous samples with warning/metric. |
| PWA `/api/telemetry` | `PWA` | Derive `project_id` from `asset_id`, validate user project access, persist `client_id`. Reject `SCADA` over HTTP. |
| Manual HTTP telemetry | `MANUAL` | Same LF freshness semantics as PWA. |
| PWA `/api/movements` | not telemetry | Deduplicate retries by movement `client_id`; movements do not feed silence checks or HF/LF counts. |
## Freshness And Silence
| Alarm type | Origin | Default window |
| --- | --- | --- |
| `TELEMETRY_SILENCE_HF` | `SCADA` | 10 minutes |
| `TELEMETRY_SILENCE_LF` | `PWA`, `MANUAL` | 24 hours |
Legacy `TELEMETRY_SILENCE` is treated as HF/deprecated for compatibility.
## Dashboard Rules
- Panorama HF/LF cards count normalized telemetry aliases by authoritative origin.
- The dashboard does not infer HF/LF from polling interval.
- LF samples must not make the global SCADA/HF status look healthy.
- Unknown origin remains visible as telemetry but does not enter HF/LF buckets.
## Post-Deploy Checklist
Run these checks after applying migrations in an environment with TimescaleDB.
### 1. Confirm source backfill and chunks
```sql
SELECT
(SELECT count(*) FROM telemetry_raw) AS rows,
count(*) FILTER (WHERE is_compressed) AS compressed_chunks,
count(*) AS total_chunks
FROM timescaledb_information.chunks
WHERE hypertable_name = 'telemetry_raw';
SELECT source, count(*)
FROM telemetry_raw
GROUP BY source
ORDER BY source;
```
Expected: every `telemetry_raw.source` is non-null and existing rows are backfilled to `SCADA` unless written later as `PWA` or `MANUAL`.
### 2. Confirm telemetry idempotency
```sql
SELECT time, asset_id, count(*)
FROM telemetry_raw
GROUP BY time, asset_id
HAVING count(*) > 1;
```
Expected: zero rows.
### 3. Confirm movement idempotency
```sql
SELECT asset_id, details->>'client_id' AS client_id, count(*)
FROM operation_movements
WHERE details->>'client_id' IS NOT NULL
AND details->>'client_id' <> ''
GROUP BY asset_id, details->>'client_id'
HAVING count(*) > 1;
```
Expected: zero rows.
### 4. Watch MQTT resolution metrics
- `omnioil_telemetry_unresolved_device_total`
- `omnioil_telemetry_ambiguous_device_total`
If `ambiguous` increases, audit duplicate device names within a project before adding any structural uniqueness constraint.
## Follow-Ups
- Fix baseline TypeScript and Rust formatting gates separately if CI requires full workspace checks.
- Add structural uniqueness for `(project_id, device_name)` only after auditing current device data.
- The LF simulator is intentionally out of scope for this contract document.

View File

@@ -0,0 +1,15 @@
global:
resolve_timeout: 5m
route:
receiver: 'default'
group_wait: 30s
group_interval: 5m
repeat_interval: 4h
receivers:
- name: 'default'
slack_configs:
- send_resolved: true
title: '{{ .GroupLabels.alertname }}'
text: '{{ .CommonAnnotations.description }}'

View File

@@ -6,3 +6,10 @@ datasources:
url: http://prometheus:9090 url: http://prometheus:9090
isDefault: true isDefault: true
editable: false editable: false
- name: Loki
type: loki
access: proxy
url: http://loki:3100
isDefault: false
editable: false

View File

@@ -0,0 +1,39 @@
auth_enabled: false
server:
http_listen_port: 3100
grpc_listen_port: 9096
common:
instance_addr: 0.0.0.0
path_prefix: /loki
storage:
filesystem:
chunks_directory: /loki/chunks
rules_directory: /loki/rules
replication_factor: 1
ring:
kvstore:
store: inmemory
schema_config:
configs:
- from: 2020-10-24
store: tsdb
object_store: filesystem
schema: v13
index:
prefix: index_
period: 24h
compactor:
working_directory: /loki/compactor
retention_enabled: true
limits_config:
reject_old_samples: true
reject_old_samples_max_age: 168h
ruler:
alertmanager_url: http://alertmanager:9093
enable_alertmanager_v2: true

View File

@@ -0,0 +1,30 @@
groups:
- name: omnioil_alerts
interval: 30s
rules:
- alert: BackendAPIDown
expr: up{job="omnioil-api"} == 0
for: 1m
labels:
severity: critical
annotations:
summary: "Backend API is down"
description: "Backend API has been unreachable for more than 1 minute."
- alert: HighMQTTConnectionErrors
expr: rate(omnioil_mqtt_connections_total[5m]) > 0.1
for: 2m
labels:
severity: warning
annotations:
summary: "High MQTT reconnection rate"
description: "MQTT is reconnecting more than once per 10 seconds."
- alert: ActiveAgentsLow
expr: omnioil_active_agents < 1
for: 5m
labels:
severity: critical
annotations:
summary: "No active agents"
description: "There are no active edge agents reporting health."

View File

@@ -2,6 +2,14 @@ global:
scrape_interval: 15s scrape_interval: 15s
evaluation_interval: 15s evaluation_interval: 15s
alerting:
alertmanagers:
- static_configs:
- targets: ['alertmanager:9093']
rule_files:
- /etc/prometheus/prometheus-rules.yml
scrape_configs: scrape_configs:
- job_name: 'omnioil-api' - job_name: 'omnioil-api'
static_configs: static_configs:
@@ -9,6 +17,26 @@ scrape_configs:
metrics_path: '/metrics' metrics_path: '/metrics'
scrape_interval: 15s scrape_interval: 15s
- job_name: 'telemetry-ingestor'
static_configs:
- targets: ['telemetry-ingestor:9091']
scrape_interval: 15s
- job_name: 'events-relay'
static_configs:
- targets: ['events-relay:9092']
scrape_interval: 15s
- job_name: 'json-generator'
static_configs:
- targets: ['json-generator:9093']
scrape_interval: 15s
- job_name: 'build-orchestrator'
static_configs:
- targets: ['build-orchestrator:9094']
scrape_interval: 15s
- job_name: 'node-exporter' - job_name: 'node-exporter'
static_configs: static_configs:
- targets: ['node-exporter:9100'] - targets: ['node-exporter:9100']

View File

@@ -0,0 +1,21 @@
server:
http_listen_port: 9080
grpc_listen_port: 0
positions:
filename: /tmp/positions.yaml
clients:
- url: http://loki:3100/loki/api/v1/push
scrape_configs:
- job_name: docker
docker_sd_configs:
- host: unix:///var/run/docker.sock
refresh_interval: 5s
relabel_configs:
- source_labels: ['__meta_docker_container_name']
regex: '/(.*)'
target_label: 'container'
- source_labels: ['__meta_docker_container_log_stream']
target_label: 'stream'

View File

@@ -44,6 +44,9 @@ persistence true
persistence_location /mosquitto/data persistence_location /mosquitto/data
# Límite de tamaño de paquetes MQTT # Límite de tamaño de paquetes MQTT
# 256KB: suficiente para payloads de provisioning comprimidos y telemetría # 2MB: suficiente para ProjectConfig sin comprimir durante deploys locales/grandes
# (rumqttc también está configurado con 256KB) # y payloads de telemetría de simulador con cientos de variables por dispositivo.
message_size_limit 262144 # Mosquitto 2.x anuncia `max_packet_size` a clientes MQTT v5; mantener ambos
# evita que clientes rumqttc sigan viendo el límite default de 256KB.
max_packet_size 2097152
message_size_limit 2097152

0
infrastructure/nginx/entrypoint.sh Normal file → Executable file
View File

View File

@@ -4,6 +4,14 @@ map $sent_http_content_type $csp_header {
default "default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data:; connect-src 'self' wss:; frame-ancestors 'none';"; default "default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data:; connect-src 'self' wss:; frame-ancestors 'none';";
} }
# Access logs must not include query strings: URLs can carry short-lived tickets,
# invitation tokens, or other sensitive one-time values. Use $uri instead of
# $request to avoid persisting secrets in Nginx/Docker logs.
log_format main_no_query '$remote_addr - $remote_user [$time_local] '
'"$request_method $uri $server_protocol" $status $body_bytes_sent '
'"$http_referer" "$http_user_agent" "$http_x_forwarded_for"';
access_log /var/log/nginx/access.log main_no_query;
server { server {
listen 80; listen 80;
server_name omnioil.app www.omnioil.app localhost; server_name omnioil.app www.omnioil.app localhost;

View File

@@ -128,9 +128,9 @@ async function main() {
} }
await client.query( await client.query(
`INSERT INTO telemetry_raw (time, project_id, data) `INSERT INTO telemetry_raw (time, project_id, asset_id, data)
SELECT $1::timestamptz, id, $2::jsonb FROM edge_projects LIMIT 1`, VALUES ($1::timestamptz, $2::uuid, $3::uuid, $4::jsonb)`,
[time.toISOString(), JSON.stringify(data)] [time.toISOString(), projectId, assetId, JSON.stringify(data)]
) )
insertCount++ insertCount++
} }

View File

@@ -46,11 +46,11 @@ If any app diff grows beyond review comfort, keep each app in a separate PR/comm
## Fase 2: `frontend-admin` ## Fase 2: `frontend-admin`
- [ ] 2.1 Update `services/frontend-admin/src/styles/globals.css` primary/accent/ring/glass/scrollbar tokens from emerald to orange. - [x] 2.1 Update `services/frontend-admin/src/styles/globals.css` primary/accent/ring/glass/scrollbar tokens from emerald to orange.
- [ ] 2.2 Align `services/frontend-admin/src/components/ui/{button,card,badge}.tsx` cva variants and dark card surfaces. - [x] 2.2 Align `services/frontend-admin/src/components/ui/{button,card,badge}.tsx` cva variants and dark card surfaces.
- [ ] 2.3 Restyle `services/frontend-admin/src/app/layouts/AdminLayout.tsx` sidebar/header/project accents without permission or route changes. - [x] 2.3 Restyle `services/frontend-admin/src/app/layouts/AdminLayout.tsx` sidebar/header/project accents without permission or route changes.
- [ ] 2.4 Restyle `services/frontend-admin/src/features/auth/pages/{LoginPage,SetupPage}.tsx` without submit/redirect/setup behavior changes. - [x] 2.4 Restyle `services/frontend-admin/src/features/auth/pages/{LoginPage,SetupPage}.tsx` without submit/redirect/setup behavior changes.
- [ ] 2.5 Run Admin verification from `services/frontend-admin`: `npm run lint`; run `npm run test` if available. - [x] 2.5 Run Admin verification from `services/frontend-admin`: `npm run lint`; run `npm run test` if available.
## Fase 3: `frontend-pwa` ## Fase 3: `frontend-pwa`

10537
pnpm-lock.yaml generated Normal file

File diff suppressed because it is too large Load Diff

5
pnpm-workspace.yaml Normal file
View File

@@ -0,0 +1,5 @@
packages:
- "services/landing"
- "services/frontend-admin"
- "services/frontend-console"
- "services/frontend-pwa"

3
rust-toolchain.toml Normal file
View File

@@ -0,0 +1,3 @@
[toolchain]
channel = "stable"
components = ["rustfmt", "clippy"]

25
scripts/dev-watch.ps1 Normal file
View File

@@ -0,0 +1,25 @@
#!/usr/bin/env pwsh
<#
.SYNOPSIS
Run a Rust service with hot-reload (cargo-watch).
.EXAMPLE
.\scripts\dev-watch.ps1 -Service backend-api
.\scripts\dev-watch.ps1 -Service telemetry-ingestor
#>
param(
[Parameter(Mandatory)]
[ValidateSet('backend-api','telemetry-ingestor','events-relay','json-generator','build-orchestrator','edge-agent','data-collector')]
[string]$Service
)
$ErrorActionPreference = 'Stop'
# Check if cargo-watch is installed
if (-not (Get-Command 'cargo-watch' -ErrorAction SilentlyContinue)) {
Write-Host "Installing cargo-watch..." -ForegroundColor Yellow
cargo install cargo-watch
}
Write-Host "Starting $Service with hot-reload..." -ForegroundColor Green
cargo watch -x "run -p $Service"

View File

@@ -5,6 +5,7 @@ edition = "2024"
[dependencies] [dependencies]
shared-lib = { workspace = true } shared-lib = { workspace = true }
json-generator = { path = "../json-generator" }
axum = { workspace = true, features = ["macros", "ws"] } axum = { workspace = true, features = ["macros", "ws"] }
tokio = { workspace = true } tokio = { workspace = true }
sqlx = { workspace = true } sqlx = { workspace = true }
@@ -18,6 +19,7 @@ dotenvy = { workspace = true }
jsonwebtoken = { version = "10.3.0", features = ["aws_lc_rs"] } jsonwebtoken = { version = "10.3.0", features = ["aws_lc_rs"] }
argon2 = { version = "0.5.3", features = ["password-hash", "rand"] } argon2 = { version = "0.5.3", features = ["password-hash", "rand"] }
chrono = { workspace = true } chrono = { workspace = true }
chrono-tz = { workspace = true }
axum-extra = { version = "0.12.5", features = ["typed-header"] } axum-extra = { version = "0.12.5", features = ["typed-header"] }
uuid = { workspace = true } uuid = { workspace = true }
anyhow = { workspace = true } anyhow = { workspace = true }
@@ -41,3 +43,6 @@ reqwest = { workspace = true, features = ["json", "rustls"] }
semver = { workspace = true } semver = { workspace = true }
axum-prometheus = "0.8" axum-prometheus = "0.8"
metrics = "0.24" metrics = "0.24"
csv = "1.4.0"
hmac = "0.12"
hex = "0.4"

View File

@@ -6,7 +6,7 @@ WORKDIR /app
# Instala dependencias del sistema necesarias para compilar crates de C y linkeo rápido # Instala dependencias del sistema necesarias para compilar crates de C y linkeo rápido
# Nota: libatk1.0-dev y libgtk-3-dev se añaden para satisfacer dependencias transitivas de GUI si el workspace las tiene # Nota: libatk1.0-dev y libgtk-3-dev se añaden para satisfacer dependencias transitivas de GUI si el workspace las tiene
RUN apt-get update && apt-get install -y \ RUN apt-get update && apt-get install -y \
cmake nasm pkg-config libssl-dev libglib2.0-dev libatk1.0-dev libgtk-3-dev build-essential ca-certificates lld \ pkg-config libssl-dev build-essential ca-certificates lld \
&& rm -rf /var/lib/apt/lists/* && rm -rf /var/lib/apt/lists/*
# Linker LLD para reducir el tiempo de linkeado significativamente # Linker LLD para reducir el tiempo de linkeado significativamente
ENV RUSTFLAGS="-C link-arg=-fuse-ld=lld" ENV RUSTFLAGS="-C link-arg=-fuse-ld=lld"
@@ -52,10 +52,15 @@ RUN cargo chef cook --release --recipe-path recipe.json --bin backend-api
# 3. BUILDER # 3. BUILDER
# ============================================================================= # =============================================================================
FROM base AS builder FROM base AS builder
COPY . . # Copiar el esqueleto dummy y las dependencias pre-compiladas desde el cacher
# Copiar caché del cacher COPY --from=cacher /app /app
COPY --from=cacher /app/target target
COPY --from=cacher /usr/local/cargo /usr/local/cargo COPY --from=cacher /usr/local/cargo /usr/local/cargo
# Copiar el código real de los crates que vamos a compilar (sobreescribiendo los stubs)
COPY services/shared-lib services/shared-lib
COPY services/json-generator services/json-generator
COPY services/backend-api services/backend-api
COPY .sqlx .sqlx
RUN cargo build --release --bin backend-api RUN cargo build --release --bin backend-api
# ============================================================================= # =============================================================================

View File

@@ -0,0 +1,35 @@
-- Enforce one active access request per email, case-insensitively.
-- Rejected requests remain historical and allow a future retry.
-- Production may already contain duplicate active requests created before this
-- invariant existed. Keep the most advanced/latest request and close the rest
-- before creating the partial unique index, otherwise the deploy migration fails.
WITH ranked_active_requests AS (
SELECT
id,
ROW_NUMBER() OVER (
PARTITION BY lower(email)
ORDER BY
CASE status
WHEN 'approved' THEN 1
WHEN 'in_review' THEN 2
WHEN 'pending' THEN 3
ELSE 4
END,
created_at DESC,
id DESC
) AS duplicate_rank
FROM access_requests
WHERE status IN ('pending', 'in_review', 'approved')
)
UPDATE access_requests ar
SET status = 'rejected',
rejection_reason = 'duplicate_active_request_before_unique_index',
updated_at = NOW()
FROM ranked_active_requests ranked
WHERE ar.id = ranked.id
AND ranked.duplicate_rank > 1;
CREATE UNIQUE INDEX IF NOT EXISTS idx_access_requests_active_email_unique
ON access_requests (lower(email))
WHERE status IN ('pending', 'in_review', 'approved');

View File

@@ -0,0 +1,4 @@
-- Alter edge_devices check constraint to include new SCADA protocols
ALTER TABLE edge_devices DROP CONSTRAINT IF EXISTS edge_devices_protocol_check;
ALTER TABLE edge_devices ADD CONSTRAINT edge_devices_protocol_check
CHECK (protocol IN ('MODBUS_TCP', 'MODBUS_RTU', 'OPC_UA', 'MQTT', 'ETHERNET_IP', 'PROFINET', 'S7', 'MQTT_SPARKPLUG_B', 'SQL_DB'));

View File

@@ -0,0 +1,12 @@
-- =============================================================================
-- Migration: Add agent_download_tokens table for one-time script downloads
-- =============================================================================
CREATE TABLE IF NOT EXISTS agent_download_tokens (
token VARCHAR(100) PRIMARY KEY,
project_id UUID NOT NULL REFERENCES edge_projects(id) ON DELETE CASCADE,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
expires_at TIMESTAMPTZ NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_agent_download_tokens_project ON agent_download_tokens(project_id);

View File

@@ -0,0 +1,8 @@
ALTER TABLE anh_reports
ADD COLUMN IF NOT EXISTS object_key TEXT,
ADD COLUMN IF NOT EXISTS sealed_at TIMESTAMPTZ,
ADD COLUMN IF NOT EXISTS content_type TEXT;
CREATE UNIQUE INDEX IF NOT EXISTS idx_anh_reports_object_key_unique
ON anh_reports (object_key)
WHERE object_key IS NOT NULL;

View File

@@ -0,0 +1,61 @@
CREATE TABLE IF NOT EXISTS anh_report_schedules (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
operator_name TEXT NOT NULL,
contract_number TEXT NOT NULL,
local_time TIME NOT NULL DEFAULT '06:00',
timezone TEXT NOT NULL DEFAULT 'America/Bogota',
is_active BOOLEAN NOT NULL DEFAULT true,
next_run_at TIMESTAMPTZ NOT NULL,
last_run_at TIMESTAMPTZ,
last_status TEXT,
last_error TEXT,
claim_token UUID,
claimed_at TIMESTAMPTZ,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
created_by UUID REFERENCES users(id) ON DELETE SET NULL,
updated_by UUID REFERENCES users(id) ON DELETE SET NULL,
CONSTRAINT anh_report_schedules_unit_key UNIQUE (operator_name, contract_number),
CONSTRAINT anh_report_schedules_time_minute CHECK (date_trunc('minute', local_time) = local_time)
);
CREATE INDEX IF NOT EXISTS idx_anh_report_schedules_due
ON anh_report_schedules (next_run_at)
WHERE is_active = true;
CREATE INDEX IF NOT EXISTS idx_anh_report_schedules_claimed_at
ON anh_report_schedules (claimed_at)
WHERE claimed_at IS NOT NULL;
INSERT INTO anh_report_schedules (operator_name, contract_number, local_time, timezone, is_active, next_run_at)
SELECT DISTINCT
operator_name,
contract_number,
TIME '06:00',
'America/Bogota',
true,
CASE
WHEN (((NOW() AT TIME ZONE 'America/Bogota')::date + TIME '06:00') AT TIME ZONE 'America/Bogota') > NOW()
THEN (((NOW() AT TIME ZONE 'America/Bogota')::date + TIME '06:00') AT TIME ZONE 'America/Bogota')
ELSE ((((NOW() AT TIME ZONE 'America/Bogota')::date + 1) + TIME '06:00') AT TIME ZONE 'America/Bogota')
END
FROM edge_projects
WHERE is_active = true
AND operator_name IS NOT NULL
AND contract_number IS NOT NULL
ON CONFLICT (operator_name, contract_number) DO NOTHING;
ALTER TABLE anh_reports
ADD COLUMN IF NOT EXISTS schedule_id UUID REFERENCES anh_report_schedules(id) ON DELETE SET NULL,
ADD COLUMN IF NOT EXISTS operator_name TEXT,
ADD COLUMN IF NOT EXISTS contract_number TEXT,
ADD COLUMN IF NOT EXISTS generation_source TEXT NOT NULL DEFAULT 'manual',
ADD COLUMN IF NOT EXISTS approved_at TIMESTAMPTZ,
ADD COLUMN IF NOT EXISTS approved_by UUID REFERENCES users(id) ON DELETE SET NULL;
CREATE UNIQUE INDEX IF NOT EXISTS idx_anh_reports_generation_scope_unique
ON anh_reports (operator_name, contract_number, period_start, period_end)
WHERE operator_name IS NOT NULL
AND contract_number IS NOT NULL
AND period_start IS NOT NULL
AND period_end IS NOT NULL;

View File

@@ -0,0 +1,10 @@
-- Remove trial/placeholder ANH artifacts. These were never confirmed contracts
-- and must not appear as regulatory reports.
DELETE FROM anh_reports
WHERE upper(btrim(COALESCE(contract_number, content ->> 'CONTRATO', ''))) LIKE 'PENDING-%'
OR upper(btrim(COALESCE(contract_number, content ->> 'CONTRATO', ''))) LIKE 'TRIAL-%';
DELETE FROM anh_report_schedules
WHERE upper(btrim(contract_number)) LIKE 'PENDING-%'
OR upper(btrim(contract_number)) LIKE 'TRIAL-%';

View File

@@ -0,0 +1,48 @@
ALTER TABLE anh_reports
ADD COLUMN IF NOT EXISTS root_report_id UUID REFERENCES anh_reports(id) ON DELETE RESTRICT,
ADD COLUMN IF NOT EXISTS previous_report_id UUID REFERENCES anh_reports(id) ON DELETE RESTRICT,
ADD COLUMN IF NOT EXISTS superseded_by_report_id UUID REFERENCES anh_reports(id) ON DELETE SET NULL,
ADD COLUMN IF NOT EXISTS correction_version INT NOT NULL DEFAULT 0,
ADD COLUMN IF NOT EXISTS rejection_reason TEXT,
ADD COLUMN IF NOT EXISTS rejected_at TIMESTAMPTZ,
ADD COLUMN IF NOT EXISTS rejected_by UUID REFERENCES users(id) ON DELETE SET NULL,
ADD COLUMN IF NOT EXISTS correction_reason TEXT,
ADD COLUMN IF NOT EXISTS correction_deadline_at TIMESTAMPTZ,
ADD COLUMN IF NOT EXISTS correction_created_at TIMESTAMPTZ,
ADD COLUMN IF NOT EXISTS correction_created_by UUID REFERENCES users(id) ON DELETE SET NULL,
ADD COLUMN IF NOT EXISTS available_for_anh_at TIMESTAMPTZ,
ADD COLUMN IF NOT EXISTS available_for_anh_by UUID REFERENCES users(id) ON DELETE SET NULL,
ADD COLUMN IF NOT EXISTS availability_note TEXT,
ADD COLUMN IF NOT EXISTS superseded_at TIMESTAMPTZ;
UPDATE anh_reports
SET root_report_id = id
WHERE root_report_id IS NULL;
ALTER TABLE anh_reports
ADD CONSTRAINT anh_reports_correction_version_non_negative
CHECK (correction_version >= 0),
ADD CONSTRAINT anh_reports_correction_reason_required
CHECK (correction_version = 0 OR NULLIF(BTRIM(correction_reason), '') IS NOT NULL),
ADD CONSTRAINT anh_reports_rejection_reason_required
CHECK ((rejected_at IS NULL AND rejection_reason IS NULL) OR (rejected_at IS NOT NULL AND NULLIF(BTRIM(rejection_reason), '') IS NOT NULL));
DROP INDEX IF EXISTS idx_anh_reports_generation_scope_unique;
CREATE UNIQUE INDEX IF NOT EXISTS idx_anh_reports_generation_scope_version_unique
ON anh_reports (operator_name, contract_number, period_start, period_end, correction_version)
WHERE operator_name IS NOT NULL
AND contract_number IS NOT NULL
AND period_start IS NOT NULL
AND period_end IS NOT NULL;
CREATE UNIQUE INDEX IF NOT EXISTS idx_anh_reports_active_draft_unique
ON anh_reports (operator_name, contract_number, period_start, period_end)
WHERE status IN ('GENERATED', 'CORRECTION_DRAFT')
AND operator_name IS NOT NULL
AND contract_number IS NOT NULL
AND period_start IS NOT NULL
AND period_end IS NOT NULL;
CREATE INDEX IF NOT EXISTS idx_anh_reports_root_version
ON anh_reports (root_report_id, correction_version);

View File

@@ -0,0 +1,10 @@
ALTER TABLE telemetry_alarms
ADD COLUMN IF NOT EXISTS acknowledged BOOLEAN,
ADD COLUMN IF NOT EXISTS acknowledged_at TIMESTAMPTZ,
ADD COLUMN IF NOT EXISTS acknowledged_by UUID;
ALTER TABLE telemetry_alarms
ALTER COLUMN acknowledged SET DEFAULT FALSE;
CREATE INDEX IF NOT EXISTS idx_telemetry_alarms_acknowledged
ON telemetry_alarms(project_id, acknowledged, timestamp DESC);

View File

@@ -0,0 +1,15 @@
-- =============================================================================
-- Migration: Short-lived one-time WebSocket telemetry tickets
-- =============================================================================
CREATE TABLE IF NOT EXISTS websocket_tickets (
token_hash TEXT PRIMARY KEY,
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
project_id UUID NOT NULL REFERENCES edge_projects(id) ON DELETE CASCADE,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
expires_at TIMESTAMPTZ NOT NULL,
used_at TIMESTAMPTZ NULL
);
CREATE INDEX IF NOT EXISTS idx_websocket_tickets_project ON websocket_tickets(project_id);
CREATE INDEX IF NOT EXISTS idx_websocket_tickets_expires_at ON websocket_tickets(expires_at);

View File

@@ -0,0 +1,71 @@
-- Timescale compressed chunks have restrictions around DML and index changes.
-- Capture the compressed chunks up front, decompress them for this migration,
-- and recompress only the chunks that were compressed before the migration.
CREATE TEMP TABLE _telemetry_raw_compressed_chunks_before AS
SELECT format('%I.%I', chunk_schema, chunk_name)::regclass AS chunk
FROM timescaledb_information.chunks
WHERE hypertable_name = 'telemetry_raw'
AND is_compressed;
SELECT decompress_chunk(chunk, true)
FROM _telemetry_raw_compressed_chunks_before;
ALTER TABLE telemetry_raw
ADD COLUMN IF NOT EXISTS source VARCHAR(20) DEFAULT 'SCADA',
ADD COLUMN IF NOT EXISTS client_id TEXT;
ALTER TABLE telemetry_raw
ALTER COLUMN source SET DEFAULT 'SCADA';
ALTER TABLE telemetry_raw
DROP CONSTRAINT IF EXISTS telemetry_raw_source_check;
ALTER TABLE telemetry_raw
ADD CONSTRAINT telemetry_raw_source_check
CHECK (source IN ('SCADA', 'PWA', 'MANUAL'));
UPDATE telemetry_raw
SET source = 'SCADA'
WHERE source IS NULL;
ALTER TABLE telemetry_raw
ALTER COLUMN source SET NOT NULL;
-- Make the idempotency index migration-safe for databases that already contain
-- exact duplicate telemetry samples. Keep one row per existing (time, asset_id)
-- pair and delete only the extra rows in those duplicate pairs. `asset_id` was
-- nullable in legacy telemetry_raw, so `IS NOT DISTINCT FROM` preserves exact
-- duplicate-pair semantics for NULL asset_id without broad cleanup. Hypertables
-- can span multiple chunks, so identify physical rows by (tableoid, ctid), not
-- ctid alone.
WITH duplicate_pairs AS (
SELECT time, asset_id
FROM telemetry_raw
GROUP BY time, asset_id
HAVING COUNT(*) > 1
), ranked_duplicates AS (
SELECT
t.tableoid,
t.ctid,
ROW_NUMBER() OVER (PARTITION BY t.time, t.asset_id ORDER BY t.tableoid, t.ctid) AS duplicate_rank
FROM telemetry_raw t
JOIN duplicate_pairs d
ON t.time IS NOT DISTINCT FROM d.time
AND t.asset_id IS NOT DISTINCT FROM d.asset_id
)
DELETE FROM telemetry_raw t
USING ranked_duplicates d
WHERE t.tableoid = d.tableoid
AND t.ctid = d.ctid
AND d.duplicate_rank > 1;
CREATE UNIQUE INDEX IF NOT EXISTS idx_telemetry_raw_time_asset_unique
ON telemetry_raw (time, asset_id);
CREATE INDEX IF NOT EXISTS idx_telemetry_raw_project_source_time
ON telemetry_raw (project_id, source, time DESC);
SELECT compress_chunk(chunk, true)
FROM _telemetry_raw_compressed_chunks_before;
DROP TABLE _telemetry_raw_compressed_chunks_before;

View File

@@ -0,0 +1,23 @@
-- Existing deployments may already contain rows inserted by offline clients before
-- the idempotency constraint existed. Those rows can share the same per-asset
-- client id and would make the unique index fail. Only reclaim that narrow class
-- of duplicate idempotency keys: keep the earliest row deterministically and
-- delete later rows for the same non-empty (asset_id, details->>'client_id').
WITH duplicate_movement_client_ids AS (
SELECT
ctid,
ROW_NUMBER() OVER (
PARTITION BY asset_id, details->>'client_id'
ORDER BY created_at NULLS LAST, id
) AS duplicate_position
FROM operation_movements
WHERE NULLIF(BTRIM(details->>'client_id'), '') IS NOT NULL
)
DELETE FROM operation_movements AS movement
USING duplicate_movement_client_ids AS duplicate
WHERE movement.ctid = duplicate.ctid
AND duplicate.duplicate_position > 1;
CREATE UNIQUE INDEX IF NOT EXISTS idx_operation_movements_asset_client_id_unique
ON operation_movements (asset_id, (details->>'client_id'))
WHERE NULLIF(BTRIM(details->>'client_id'), '') IS NOT NULL;

View File

@@ -0,0 +1,98 @@
-- Customer commercial profile foundation.
-- Rollback boundary: later slices can ignore these additive tables and continue
-- reading legacy subscriptions. Existing subscription rows are not modified.
ALTER TABLE access_requests
DROP CONSTRAINT IF EXISTS access_requests_status_check;
ALTER TABLE access_requests
ADD CONSTRAINT access_requests_status_check
CHECK (status IN ('pending', 'pending_classification', 'in_review', 'approved', 'rejected'));
CREATE TABLE IF NOT EXISTS customer_commercial_profiles (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
access_request_id UUID REFERENCES access_requests(id) ON DELETE SET NULL,
project_id UUID REFERENCES edge_projects(id) ON DELETE CASCADE,
status VARCHAR(30) NOT NULL DEFAULT 'draft'
CHECK (status IN ('draft', 'in_review', 'approved', 'active', 'suspended', 'archived')),
plan VARCHAR(30) NOT NULL DEFAULT 'free'
CHECK (plan IN ('free', 'operative', 'enterprise')),
payment_method VARCHAR(40)
CHECK (payment_method IS NULL OR payment_method IN ('none', 'bank_transfer', 'manual_invoice', 'enterprise_contract')),
payment_terms_days INT CHECK (payment_terms_days IS NULL OR payment_terms_days >= 0),
credit_limit_cop BIGINT CHECK (credit_limit_cop IS NULL OR credit_limit_cop >= 0),
tg_limit NUMERIC(12, 2) CHECK (tg_limit IS NULL OR tg_limit >= 0),
pbase_cop BIGINT NOT NULL DEFAULT 110000 CHECK (pbase_cop > 0),
discount_k NUMERIC(6, 4) NOT NULL DEFAULT 0.1000 CHECK (discount_k >= 0),
currency CHAR(3) NOT NULL DEFAULT 'COP',
effective_from DATE NOT NULL DEFAULT CURRENT_DATE,
effective_to DATE CHECK (effective_to IS NULL OR effective_to >= effective_from),
approved_by UUID REFERENCES users(id) ON DELETE SET NULL,
approved_at TIMESTAMPTZ,
enterprise_annual_cop BIGINT CHECK (enterprise_annual_cop IS NULL OR enterprise_annual_cop >= 0),
enterprise_monthly_cop BIGINT CHECK (enterprise_monthly_cop IS NULL OR enterprise_monthly_cop >= 0),
enterprise_override_reason TEXT,
notes TEXT,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
CHECK (access_request_id IS NOT NULL OR project_id IS NOT NULL)
);
CREATE UNIQUE INDEX IF NOT EXISTS idx_customer_commercial_profiles_access_request
ON customer_commercial_profiles(access_request_id)
WHERE access_request_id IS NOT NULL;
CREATE UNIQUE INDEX IF NOT EXISTS idx_customer_commercial_profiles_project
ON customer_commercial_profiles(project_id)
WHERE project_id IS NOT NULL;
CREATE INDEX IF NOT EXISTS idx_customer_commercial_profiles_status
ON customer_commercial_profiles(status, effective_from DESC);
CREATE INDEX IF NOT EXISTS idx_customer_commercial_profiles_plan
ON customer_commercial_profiles(plan);
CREATE TABLE IF NOT EXISTS customer_commercial_profile_versions (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
commercial_profile_id UUID NOT NULL REFERENCES customer_commercial_profiles(id) ON DELETE CASCADE,
actor_user_id UUID REFERENCES users(id) ON DELETE SET NULL,
changed_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
change_reason TEXT NOT NULL,
changed_fields TEXT[] NOT NULL DEFAULT ARRAY[]::TEXT[],
before_json JSONB NOT NULL DEFAULT '{}'::JSONB,
after_json JSONB NOT NULL DEFAULT '{}'::JSONB
);
CREATE INDEX IF NOT EXISTS idx_customer_commercial_profile_versions_profile
ON customer_commercial_profile_versions(commercial_profile_id, changed_at DESC);
CREATE INDEX IF NOT EXISTS idx_customer_commercial_profile_versions_actor
ON customer_commercial_profile_versions(actor_user_id, changed_at DESC)
WHERE actor_user_id IS NOT NULL;
INSERT INTO customer_commercial_profiles (
project_id,
status,
plan,
payment_method,
payment_terms_days,
tg_limit,
pbase_cop,
discount_k,
effective_from,
notes
)
SELECT
s.project_id,
CASE WHEN s.status = 'suspended' THEN 'suspended' ELSE 'active' END,
CASE WHEN s.tier = 'enterprise' THEN 'enterprise' ELSE 'operative' END,
'manual_invoice',
30,
s.tag_limit::NUMERIC,
COALESCE(NULLIF(s.price_per_tag, 90000), 110000),
0.1000,
s.current_period_start::DATE,
'Backfilled from legacy subscription during commercial profile rollout.'
FROM subscriptions s
WHERE s.status IN ('active', 'trial', 'suspended')
ON CONFLICT DO NOTHING;

View File

@@ -0,0 +1,41 @@
CREATE TABLE IF NOT EXISTS user_well_access (
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
project_id UUID NOT NULL REFERENCES edge_projects(id) ON DELETE CASCADE,
well_asset_id UUID NOT NULL REFERENCES edge_assets(id) ON DELETE CASCADE,
is_active BOOLEAN DEFAULT true,
created_at TIMESTAMPTZ DEFAULT NOW(),
updated_at TIMESTAMPTZ DEFAULT NOW(),
PRIMARY KEY (user_id, well_asset_id),
CONSTRAINT user_well_access_project_well_unique UNIQUE (user_id, project_id, well_asset_id)
);
CREATE INDEX IF NOT EXISTS idx_user_well_access_user_project
ON user_well_access (user_id, project_id)
WHERE is_active = true;
CREATE INDEX IF NOT EXISTS idx_user_well_access_well
ON user_well_access (well_asset_id)
WHERE is_active = true;
CREATE OR REPLACE FUNCTION validate_user_well_access_well()
RETURNS TRIGGER AS $$
BEGIN
IF NOT EXISTS (
SELECT 1
FROM edge_assets ea
WHERE ea.id = NEW.well_asset_id
AND ea.project_id = NEW.project_id
AND ea.asset_type = 'POZO'
) THEN
RAISE EXCEPTION 'user_well_access.well_asset_id must reference a POZO in the same project';
END IF;
RETURN NEW;
END;
$$ LANGUAGE plpgsql;
DROP TRIGGER IF EXISTS trg_validate_user_well_access_well ON user_well_access;
CREATE TRIGGER trg_validate_user_well_access_well
BEFORE INSERT OR UPDATE ON user_well_access
FOR EACH ROW
EXECUTE FUNCTION validate_user_well_access_well();

View File

@@ -0,0 +1,423 @@
-- Migration to implement Row Level Security (RLS) and multi-tenancy constraints
-- at the database layer.
-- 1. Insert the 'superadmin' platform role with read-only statistics permissions
-- Note: 'superadmin' already exists in the system database with standard permissions,
-- this ensures it is initialized if it's a blank setup.
INSERT INTO roles (name, permissions) VALUES
('superadmin', '{"PLATFORM_ACCESS": true, "SUPER_STATS_ONLY": true}'::jsonb)
ON CONFLICT (name) DO NOTHING;
-- 2. Create helper functions to extract session context
CREATE OR REPLACE FUNCTION get_current_user_id() RETURNS UUID AS $$
DECLARE
user_id_str TEXT;
BEGIN
user_id_str := current_setting('app.current_user_id', true);
IF user_id_str IS NULL OR user_id_str = '' THEN
RETURN NULL;
END IF;
RETURN user_id_str::uuid;
EXCEPTION
WHEN OTHERS THEN
RETURN NULL;
END;
$$ LANGUAGE plpgsql SECURITY DEFINER;
CREATE OR REPLACE FUNCTION is_superadmin() RETURNS BOOLEAN AS $$
DECLARE
current_uid UUID;
is_sa BOOLEAN;
BEGIN
current_uid := get_current_user_id();
IF current_uid IS NULL THEN
RETURN FALSE;
END IF;
SELECT EXISTS (
SELECT 1
FROM users u
JOIN roles r ON u.role_id = r.id
WHERE u.id = current_uid
AND r.name = 'superadmin'
) INTO is_sa;
RETURN is_sa;
END;
$$ LANGUAGE plpgsql SECURITY DEFINER;
-- 3. Enable RLS on multi-tenant tables
ALTER TABLE edge_projects ENABLE ROW LEVEL SECURITY;
ALTER TABLE edge_assets ENABLE ROW LEVEL SECURITY;
ALTER TABLE edge_devices ENABLE ROW LEVEL SECURITY;
ALTER TABLE edge_variables ENABLE ROW LEVEL SECURITY;
ALTER TABLE operation_movements ENABLE ROW LEVEL SECURITY;
ALTER TABLE operations_downtime ENABLE ROW LEVEL SECURITY;
ALTER TABLE well_tests ENABLE ROW LEVEL SECURITY;
ALTER TABLE lab_results ENABLE ROW LEVEL SECURITY;
ALTER TABLE meter_readings ENABLE ROW LEVEL SECURITY;
ALTER TABLE alert_rules ENABLE ROW LEVEL SECURITY;
-- 4. Create RLS policies for edge_projects
DROP POLICY IF EXISTS select_edge_projects ON edge_projects;
CREATE POLICY select_edge_projects ON edge_projects
FOR SELECT
USING (
is_superadmin() OR
EXISTS (
SELECT 1 FROM user_project_access upa
WHERE upa.project_id = edge_projects.id
AND upa.user_id = get_current_user_id()
AND upa.is_active = true
)
);
DROP POLICY IF EXISTS modify_edge_projects ON edge_projects;
CREATE POLICY modify_edge_projects ON edge_projects
FOR ALL
USING (
NOT is_superadmin() AND
EXISTS (
SELECT 1 FROM user_project_access upa
WHERE upa.project_id = edge_projects.id
AND upa.user_id = get_current_user_id()
AND upa.is_active = true
)
)
WITH CHECK (
NOT is_superadmin() AND
EXISTS (
SELECT 1 FROM user_project_access upa
WHERE upa.project_id = edge_projects.id
AND upa.user_id = get_current_user_id()
AND upa.is_active = true
)
);
-- 5. Helper procedure to create standard policies for child tables with 'project_id' column
CREATE OR REPLACE PROCEDURE create_child_table_rls_policies(table_name TEXT) AS $$
BEGIN
EXECUTE format('
DROP POLICY IF EXISTS select_%I ON %I;
CREATE POLICY select_%I ON %I
FOR SELECT
USING (
is_superadmin() OR
EXISTS (
SELECT 1 FROM user_project_access upa
WHERE upa.project_id = %I.project_id
AND upa.user_id = get_current_user_id()
AND upa.is_active = true
)
);
', table_name, table_name, table_name, table_name, table_name);
EXECUTE format('
DROP POLICY IF EXISTS modify_%I ON %I;
CREATE POLICY modify_%I ON %I
FOR ALL
USING (
NOT is_superadmin() AND
EXISTS (
SELECT 1 FROM user_project_access upa
WHERE upa.project_id = %I.project_id
AND upa.user_id = get_current_user_id()
AND upa.is_active = true
)
)
WITH CHECK (
NOT is_superadmin() AND
EXISTS (
SELECT 1 FROM user_project_access upa
WHERE upa.project_id = %I.project_id
AND upa.user_id = get_current_user_id()
AND upa.is_active = true
)
);
', table_name, table_name, table_name, table_name, table_name, table_name);
END;
$$ LANGUAGE plpgsql;
-- 6. Apply standard RLS policies to child tables with direct 'project_id'
CALL create_child_table_rls_policies('edge_assets');
CALL create_child_table_rls_policies('alert_rules');
-- Clean up helper procedure
DROP PROCEDURE create_child_table_rls_policies(TEXT);
-- 7. Custom RLS policies for tables referencing asset_id (no direct project_id)
-- edge_devices
DROP POLICY IF EXISTS select_edge_devices ON edge_devices;
CREATE POLICY select_edge_devices ON edge_devices
FOR SELECT
USING (
is_superadmin() OR
EXISTS (
SELECT 1 FROM edge_assets ea
JOIN user_project_access upa ON upa.project_id = ea.project_id
WHERE ea.id = edge_devices.asset_id
AND upa.user_id = get_current_user_id()
AND upa.is_active = true
)
);
DROP POLICY IF EXISTS modify_edge_devices ON edge_devices;
CREATE POLICY modify_edge_devices ON edge_devices
FOR ALL
USING (
NOT is_superadmin() AND
EXISTS (
SELECT 1 FROM edge_assets ea
JOIN user_project_access upa ON upa.project_id = ea.project_id
WHERE ea.id = edge_devices.asset_id
AND upa.user_id = get_current_user_id()
AND upa.is_active = true
)
)
WITH CHECK (
NOT is_superadmin() AND
EXISTS (
SELECT 1 FROM edge_assets ea
JOIN user_project_access upa ON upa.project_id = ea.project_id
WHERE ea.id = edge_devices.asset_id
AND upa.user_id = get_current_user_id()
AND upa.is_active = true
)
);
-- operation_movements
DROP POLICY IF EXISTS select_operation_movements ON operation_movements;
CREATE POLICY select_operation_movements ON operation_movements
FOR SELECT
USING (
is_superadmin() OR
EXISTS (
SELECT 1 FROM edge_assets ea
JOIN user_project_access upa ON upa.project_id = ea.project_id
WHERE ea.id = operation_movements.asset_id
AND upa.user_id = get_current_user_id()
AND upa.is_active = true
)
);
DROP POLICY IF EXISTS modify_operation_movements ON operation_movements;
CREATE POLICY modify_operation_movements ON operation_movements
FOR ALL
USING (
NOT is_superadmin() AND
EXISTS (
SELECT 1 FROM edge_assets ea
JOIN user_project_access upa ON upa.project_id = ea.project_id
WHERE ea.id = operation_movements.asset_id
AND upa.user_id = get_current_user_id()
AND upa.is_active = true
)
)
WITH CHECK (
NOT is_superadmin() AND
EXISTS (
SELECT 1 FROM edge_assets ea
JOIN user_project_access upa ON upa.project_id = ea.project_id
WHERE ea.id = operation_movements.asset_id
AND upa.user_id = get_current_user_id()
AND upa.is_active = true
)
);
-- operations_downtime
DROP POLICY IF EXISTS select_operations_downtime ON operations_downtime;
CREATE POLICY select_operations_downtime ON operations_downtime
FOR SELECT
USING (
is_superadmin() OR
EXISTS (
SELECT 1 FROM edge_assets ea
JOIN user_project_access upa ON upa.project_id = ea.project_id
WHERE ea.id = operations_downtime.asset_id
AND upa.user_id = get_current_user_id()
AND upa.is_active = true
)
);
DROP POLICY IF EXISTS modify_operations_downtime ON operations_downtime;
CREATE POLICY modify_operations_downtime ON operations_downtime
FOR ALL
USING (
NOT is_superadmin() AND
EXISTS (
SELECT 1 FROM edge_assets ea
JOIN user_project_access upa ON upa.project_id = ea.project_id
WHERE ea.id = operations_downtime.asset_id
AND upa.user_id = get_current_user_id()
AND upa.is_active = true
)
)
WITH CHECK (
NOT is_superadmin() AND
EXISTS (
SELECT 1 FROM edge_assets ea
JOIN user_project_access upa ON upa.project_id = ea.project_id
WHERE ea.id = operations_downtime.asset_id
AND upa.user_id = get_current_user_id()
AND upa.is_active = true
)
);
-- well_tests
DROP POLICY IF EXISTS select_well_tests ON well_tests;
CREATE POLICY select_well_tests ON well_tests
FOR SELECT
USING (
is_superadmin() OR
EXISTS (
SELECT 1 FROM edge_assets ea
JOIN user_project_access upa ON upa.project_id = ea.project_id
WHERE ea.id = well_tests.asset_id
AND upa.user_id = get_current_user_id()
AND upa.is_active = true
)
);
DROP POLICY IF EXISTS modify_well_tests ON well_tests;
CREATE POLICY modify_well_tests ON well_tests
FOR ALL
USING (
NOT is_superadmin() AND
EXISTS (
SELECT 1 FROM edge_assets ea
JOIN user_project_access upa ON upa.project_id = ea.project_id
WHERE ea.id = well_tests.asset_id
AND upa.user_id = get_current_user_id()
AND upa.is_active = true
)
)
WITH CHECK (
NOT is_superadmin() AND
EXISTS (
SELECT 1 FROM edge_assets ea
JOIN user_project_access upa ON upa.project_id = ea.project_id
WHERE ea.id = well_tests.asset_id
AND upa.user_id = get_current_user_id()
AND upa.is_active = true
)
);
-- lab_results
DROP POLICY IF EXISTS select_lab_results ON lab_results;
CREATE POLICY select_lab_results ON lab_results
FOR SELECT
USING (
is_superadmin() OR
EXISTS (
SELECT 1 FROM edge_assets ea
JOIN user_project_access upa ON upa.project_id = ea.project_id
WHERE ea.id = lab_results.asset_id
AND upa.user_id = get_current_user_id()
AND upa.is_active = true
)
);
DROP POLICY IF EXISTS modify_lab_results ON lab_results;
CREATE POLICY modify_lab_results ON lab_results
FOR ALL
USING (
NOT is_superadmin() AND
EXISTS (
SELECT 1 FROM edge_assets ea
JOIN user_project_access upa ON upa.project_id = ea.project_id
WHERE ea.id = lab_results.asset_id
AND upa.user_id = get_current_user_id()
AND upa.is_active = true
)
)
WITH CHECK (
NOT is_superadmin() AND
EXISTS (
SELECT 1 FROM edge_assets ea
JOIN user_project_access upa ON upa.project_id = ea.project_id
WHERE ea.id = lab_results.asset_id
AND upa.user_id = get_current_user_id()
AND upa.is_active = true
)
);
-- meter_readings
DROP POLICY IF EXISTS select_meter_readings ON meter_readings;
CREATE POLICY select_meter_readings ON meter_readings
FOR SELECT
USING (
is_superadmin() OR
EXISTS (
SELECT 1 FROM edge_assets ea
JOIN user_project_access upa ON upa.project_id = ea.project_id
WHERE ea.id = meter_readings.asset_id
AND upa.user_id = get_current_user_id()
AND upa.is_active = true
)
);
DROP POLICY IF EXISTS modify_meter_readings ON meter_readings;
CREATE POLICY modify_meter_readings ON meter_readings
FOR ALL
USING (
NOT is_superadmin() AND
EXISTS (
SELECT 1 FROM edge_assets ea
JOIN user_project_access upa ON upa.project_id = ea.project_id
WHERE ea.id = meter_readings.asset_id
AND upa.user_id = get_current_user_id()
AND upa.is_active = true
)
)
WITH CHECK (
NOT is_superadmin() AND
EXISTS (
SELECT 1 FROM edge_assets ea
JOIN user_project_access upa ON upa.project_id = ea.project_id
WHERE ea.id = meter_readings.asset_id
AND upa.user_id = get_current_user_id()
AND upa.is_active = true
)
);
-- 8. Custom RLS policies for edge_variables (referencing device_id -> asset_id)
DROP POLICY IF EXISTS select_edge_variables ON edge_variables;
CREATE POLICY select_edge_variables ON edge_variables
FOR SELECT
USING (
is_superadmin() OR
EXISTS (
SELECT 1 FROM edge_devices ed
JOIN edge_assets ea ON ea.id = ed.asset_id
JOIN user_project_access upa ON upa.project_id = ea.project_id
WHERE ed.id = edge_variables.device_id
AND upa.user_id = get_current_user_id()
AND upa.is_active = true
)
);
DROP POLICY IF EXISTS modify_edge_variables ON edge_variables;
CREATE POLICY modify_edge_variables ON edge_variables
FOR ALL
USING (
NOT is_superadmin() AND
EXISTS (
SELECT 1 FROM edge_devices ed
JOIN edge_assets ea ON ea.id = ed.asset_id
JOIN user_project_access upa ON upa.project_id = ea.project_id
WHERE ed.id = edge_variables.device_id
AND upa.user_id = get_current_user_id()
AND upa.is_active = true
)
)
WITH CHECK (
NOT is_superadmin() AND
EXISTS (
SELECT 1 FROM edge_devices ed
JOIN edge_assets ea ON ea.id = ed.asset_id
JOIN user_project_access upa ON upa.project_id = ea.project_id
WHERE ed.id = edge_variables.device_id
AND upa.user_id = get_current_user_id()
AND upa.is_active = true
)
);

View File

@@ -0,0 +1,39 @@
-- =============================================================================
-- OMNIOIL — PERFORMANCE INDEXES (v2026-07-07)
-- =============================================================================
-- Índices compuestos faltantes identificados mediante análisis de queries.
-- =============================================================================
-- 1. Outbox polling: events-relay consulta cada segundo por eventos PENDING
-- ordenados por created_at (LIMIT 50). Sin índice, escanea toda la tabla.
CREATE INDEX IF NOT EXISTS idx_domain_outbox_pending
ON domain_outbox (status, created_at)
WHERE status = 'PENDING';
-- 2. Watchdog unhealthy agents: busca alarmas AGENT_OFFLINE recientes.
-- El índice existente idx_telemetry_alarms_project no es óptimo porque
-- filtra por alarm_type y timestamp, no por project_id.
CREATE INDEX IF NOT EXISTS idx_telemetry_alarms_type_time
ON telemetry_alarms (alarm_type, timestamp DESC);
-- 3. Cooldown de alarmas: telemetry-ingestor verifica duplicados por
-- (asset_id, variable_alias, alarm_type) con timestamp > NOW() - 15min.
CREATE INDEX IF NOT EXISTS idx_telemetry_alarms_cooldown
ON telemetry_alarms (asset_id, variable_alias, alarm_type, timestamp DESC);
-- 4. Cleanup agent_logs: DELETE masivo por timestamp.
-- El índice existente idx_agent_logs_project no cubre timestamp sola.
CREATE INDEX IF NOT EXISTS idx_agent_logs_timestamp
ON agent_logs (timestamp DESC);
-- 5. Watchdog unhealthy agents: filtro compuesto sobre edge_projects.
CREATE INDEX IF NOT EXISTS idx_edge_projects_watchdog
ON edge_projects (is_active, installer_status, contract_number)
WHERE is_active = true
AND installer_status = 'COMPLETED'
AND btrim(contract_number) <> '';
-- 6. Watchdog calibration: busca assets activos próximos a vencer.
CREATE INDEX IF NOT EXISTS idx_edge_assets_calibration
ON edge_assets (project_id, is_active, next_calibration_date)
WHERE is_active = true;

View File

@@ -1,8 +1,11 @@
pub struct BuiltEmail { pub struct BuiltEmail {
pub subject: String, pub subject: String,
pub body: String, pub body: String,
pub html_body: Option<String>,
} }
const OMNIOIL_LOGO_URL: &str = "https://www.omnioil.app/omnioil-logo-transparente.svg";
pub fn activation_link(token: &str) -> String { pub fn activation_link(token: &str) -> String {
let base_url = std::env::var("INVITATION_BASE_URL") let base_url = std::env::var("INVITATION_BASE_URL")
.unwrap_or_else(|_| "https://app.omnioil.app".to_string()); .unwrap_or_else(|_| "https://app.omnioil.app".to_string());
@@ -13,18 +16,49 @@ pub fn activation_link(token: &str) -> String {
) )
} }
pub fn activation_email(full_name: &str, company: &str, token: &str) -> BuiltEmail { pub fn activation_email(full_name: &str, _company: &str, token: &str) -> BuiltEmail {
let url = activation_link(token); let url = activation_link(token);
BuiltEmail { BuiltEmail {
subject: "Activa tu cuenta OmniOil".to_string(), subject: "Tu acceso de prueba a OmniOil ya está disponible".to_string(),
body: format!( body: format!(
"Hola {full_name},\n\n\ "Hola {full_name},\n\n\
Aprobamos la solicitud de acceso para {company}.\n\n\ Gracias por tu interés en OmniOil. Tu acceso de prueba por 30 días ya fue aprobado.\n\n\
Activa tu cuenta y crea tu contraseña desde este enlace:\n\ Para comenzar, activa tu cuenta y crea una contraseña segura desde el siguiente enlace:\n\
{url}\n\n\ {url}\n\n\
El enlace vence en 7 días. Si no reconocés esta solicitud, podés ignorar este correo.\n\n\ Una vez activada la cuenta, podrás ingresar a la plataforma desde:\n\
https://app.omnioil.app/login\n\n\
Por seguridad, este enlace vence en 7 días. Si no solicitaste este acceso, puedes ignorar este mensaje o escribirnos a soporte@omnioil.app.\n\n\
Equipo OmniOil" Equipo OmniOil"
), ),
html_body: Some(format!(
r#"<!doctype html>
<html lang="es">
<body style="margin:0;background:#080808;padding:32px 16px;font-family:Arial,Helvetica,sans-serif;color:#f8fafc;">
<table role="presentation" width="100%" cellspacing="0" cellpadding="0" style="max-width:640px;margin:0 auto;background:#0d1117;border:1px solid rgba(255,255,255,0.10);border-radius:24px;overflow:hidden;">
<tr>
<td style="padding:32px 32px 20px;text-align:left;background:linear-gradient(135deg,rgba(249,115,22,0.18),rgba(13,17,23,0));">
<img src="{OMNIOIL_LOGO_URL}" alt="OmniOil" width="132" style="display:block;max-width:132px;height:auto;margin-bottom:28px;" />
<p style="margin:0 0 10px;color:#fb923c;font-size:12px;font-weight:700;letter-spacing:0.18em;text-transform:uppercase;">Prueba gratuita aprobada</p>
<h1 style="margin:0;color:#ffffff;font-size:28px;line-height:1.2;font-weight:800;">Tu acceso a OmniOil ya está disponible</h1>
</td>
</tr>
<tr>
<td style="padding:8px 32px 32px;color:#cbd5e1;font-size:16px;line-height:1.7;">
<p style="margin:0 0 18px;">Hola {full_name},</p>
<p style="margin:0 0 18px;">Gracias por tu interés en OmniOil. Tu acceso de prueba por 30 días fue aprobado y ya podés activar tu cuenta.</p>
<p style="margin:0 0 28px;">Creá una contraseña segura desde el siguiente enlace:</p>
<p style="margin:0 0 28px;">
<a href="{url}" style="display:inline-block;background:#f97316;color:#111827;text-decoration:none;font-weight:800;padding:14px 22px;border-radius:14px;">Activar mi cuenta</a>
</p>
<p style="margin:0 0 18px;">Después de activar tu cuenta, ingresá a la plataforma desde <a href="https://app.omnioil.app/login" style="color:#fb923c;text-decoration:none;font-weight:700;">app.omnioil.app/login</a>.</p>
<p style="margin:0 0 18px;color:#94a3b8;font-size:14px;">Por seguridad, este enlace vence en 7 días. Si no solicitaste este acceso, podés ignorar este mensaje o escribirnos a <a href="mailto:soporte@omnioil.app" style="color:#fb923c;text-decoration:none;">soporte@omnioil.app</a>.</p>
<p style="margin:28px 0 0;color:#e5e7eb;font-weight:700;">Equipo OmniOil</p>
</td>
</tr>
</table>
</body>
</html>"#
)),
} }
} }
@@ -38,6 +72,33 @@ pub fn access_ready_email(full_name: &str, company: &str) -> BuiltEmail {
https://app.omnioil.app/login\n\n\ https://app.omnioil.app/login\n\n\
Equipo OmniOil" Equipo OmniOil"
), ),
html_body: None,
}
}
pub fn access_request_acknowledgement_email(full_name: &str) -> BuiltEmail {
BuiltEmail {
subject: "Recibimos tu solicitud de acceso OmniOil".to_string(),
body: format!(
"Hola {full_name},\n\n\
Recibimos tu solicitud de acceso a OmniOil. Nuestro equipo revisará la información y te responderá con la aceptación o negación en menos de 24 horas.\n\n\
Si necesitás agregar información, escribinos a soporte@omnioil.app.\n\n\
Equipo OmniOil"
),
html_body: None,
}
}
pub fn access_request_rejection_email(full_name: &str) -> BuiltEmail {
BuiltEmail {
subject: "Respuesta a tu solicitud de acceso OmniOil".to_string(),
body: format!(
"Hola {full_name},\n\n\
Revisamos tu solicitud de acceso a OmniOil y no podemos aprobarla en este momento.\n\n\
Si necesitás más información o querés actualizar los datos enviados, escribinos a soporte@omnioil.app.\n\n\
Equipo OmniOil"
),
html_body: None,
} }
} }
@@ -58,12 +119,16 @@ pub fn platform_operator_activation_email(full_name: &str, token: &str) -> Built
El enlace vence en 7 días. Si no reconocés esta invitación, podés ignorar este correo.\n\n\ El enlace vence en 7 días. Si no reconocés esta invitación, podés ignorar este correo.\n\n\
Equipo OmniOil" Equipo OmniOil"
), ),
html_body: None,
} }
} }
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::{activation_email, activation_link, platform_operator_activation_email}; use super::{
OMNIOIL_LOGO_URL, access_request_acknowledgement_email, access_request_rejection_email,
activation_email, activation_link, platform_operator_activation_email,
};
use std::sync::{Mutex, OnceLock}; use std::sync::{Mutex, OnceLock};
fn env_lock() -> std::sync::MutexGuard<'static, ()> { fn env_lock() -> std::sync::MutexGuard<'static, ()> {
@@ -100,6 +165,15 @@ mod tests {
let email = activation_email("Ada Lovelace", "Analytical Engines", "prod-token-456"); let email = activation_email("Ada Lovelace", "Analytical Engines", "prod-token-456");
assert!(email.body.contains("acceso de prueba por 30 días"));
assert!(email.body.contains("soporte@omnioil.app"));
assert!(
email
.html_body
.as_deref()
.is_some_and(|html| html.contains(OMNIOIL_LOGO_URL))
);
assert!(email.body.contains("https://app.omnioil.app/login"));
assert!( assert!(
email email
.body .body
@@ -125,4 +199,24 @@ mod tests {
); );
assert!(email.body.contains("http://localhost:3002/login")); assert!(email.body.contains("http://localhost:3002/login"));
} }
#[test]
fn acknowledgement_email_sets_customer_expectation_under_24_hours() {
let email = access_request_acknowledgement_email("Ana Lopez");
assert_eq!(email.subject, "Recibimos tu solicitud de acceso OmniOil");
assert!(email.body.contains("menos de 24 horas"));
assert!(email.body.contains("soporte@omnioil.app"));
}
#[test]
fn rejection_email_is_generic_and_omits_internal_reason() {
let email = access_request_rejection_email("Ana Lopez");
assert_eq!(email.subject, "Respuesta a tu solicitud de acceso OmniOil");
assert!(email.body.contains("no podemos aprobarla"));
assert!(!email.body.contains("fraude"));
assert!(!email.body.contains("riesgo"));
assert!(email.body.contains("soporte@omnioil.app"));
}
} }

View File

@@ -6,10 +6,9 @@ use axum::{
pub async fn add_version_header(request: Request<Body>, next: Next) -> Response<Body> { pub async fn add_version_header(request: Request<Body>, next: Next) -> Response<Body> {
let mut response = next.run(request).await; let mut response = next.run(request).await;
response.headers_mut().insert( response
"X-API-Version", .headers_mut()
axum::http::HeaderValue::from_static("1"), .insert("X-API-Version", axum::http::HeaderValue::from_static("1"));
);
response.headers_mut().insert( response.headers_mut().insert(
"X-Powered-By", "X-Powered-By",
axum::http::HeaderValue::from_static("OmniOil/1.0"), axum::http::HeaderValue::from_static("OmniOil/1.0"),

View File

@@ -99,6 +99,19 @@ pub async fn log(pool: &PgPool, entry: AuditEntry<'_>) {
} }
} }
pub fn commercial_profile_change_metadata(
before_json: serde_json::Value,
after_json: serde_json::Value,
reason: &str,
) -> serde_json::Value {
serde_json::json!({
"domain": "customer_commercial_profiles",
"reason": reason,
"before": before_json,
"after": after_json,
})
}
/// Extrae la IP del cliente de los headers de la request. /// Extrae la IP del cliente de los headers de la request.
pub fn extract_ip<B>(req: &Request<B>) -> Option<String> { pub fn extract_ip<B>(req: &Request<B>) -> Option<String> {
req.headers() req.headers()
@@ -113,3 +126,37 @@ pub fn extract_ip<B>(req: &Request<B>) -> Option<String> {
.map(|s| s.trim().to_string()) .map(|s| s.trim().to_string())
}) })
} }
#[cfg(test)]
mod tests {
#[test]
fn commercial_profile_audit_metadata_preserves_before_after_and_reason() {
let before = serde_json::json!({
"status": "in_review",
"payment_terms_days": 15,
"effective_from": "2026-06-01"
});
let after = serde_json::json!({
"status": "approved",
"payment_terms_days": 30,
"effective_from": "2026-07-01",
"approved_by": "44444444-4444-4444-4444-444444444444"
});
let metadata = super::commercial_profile_change_metadata(
before.clone(),
after.clone(),
"Terms approved for July rollout",
);
assert_eq!(metadata["domain"], "customer_commercial_profiles");
assert_eq!(metadata["reason"], "Terms approved for July rollout");
assert_eq!(metadata["before"], before);
assert_eq!(metadata["after"], after);
assert_eq!(metadata["after"]["effective_from"], "2026-07-01");
assert_eq!(
metadata["after"]["approved_by"],
"44444444-4444-4444-4444-444444444444"
);
}
}

View File

@@ -388,6 +388,26 @@ pub fn role_satisfies_platform_admin_claims(role: &str) -> bool {
role == "platform_admin" role == "platform_admin"
} }
pub fn require_access_request_reviewer(claims: &Claims) -> Result<(), crate::errors::AppError> {
let email = claims.email.trim().to_ascii_lowercase();
let reviewers = std::env::var("ACCESS_REQUEST_REVIEWER_EMAILS")
.unwrap_or_default();
let reviewer_list: Vec<String> = reviewers
.split(',')
.map(|s| s.trim().to_ascii_lowercase())
.filter(|s| !s.is_empty())
.collect();
if reviewer_list.contains(&email.to_ascii_lowercase()) {
return Ok(());
}
Err(crate::errors::AppError::Forbidden(
"No tienes permisos para gestionar solicitudes de acceso".to_string(),
))
}
impl<S> FromRequestParts<S> for PlatformAdminClaims impl<S> FromRequestParts<S> for PlatformAdminClaims
where where
sqlx::PgPool: axum::extract::FromRef<S>, sqlx::PgPool: axum::extract::FromRef<S>,

View File

@@ -0,0 +1,108 @@
use serde::{Deserialize, Serialize};
pub const DEFAULT_OPERATIVE_K: f64 = 0.10;
pub const DEFAULT_OPERATIVE_PBASE_COP: i64 = 110_000;
pub const OPERATIVE_LF_EQUIVALENT_TG: f64 = 0.4;
#[derive(Clone, Copy, Debug, Deserialize, PartialEq, Serialize)]
pub struct OperativeBillingInput {
pub hf_tags: u32,
pub lf_tags: u32,
pub pbase_cop: i64,
pub k: f64,
}
#[derive(Clone, Debug, PartialEq, Serialize)]
pub struct OperativeBillingBreakdown {
pub hf_tags: u32,
pub lf_tags: u32,
pub equivalent_tg: f64,
pub pbase_cop: i64,
pub k: f64,
pub annual_cop: i64,
pub monthly_cop: i64,
pub currency: &'static str,
pub rounding_policy: &'static str,
pub effective_hf_price_cop: i64,
pub effective_lf_price_cop: i64,
}
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub enum BillingRulesError {
NonPositivePbase,
NegativeDiscountExponent,
}
pub fn calculate_operative_billing_with_defaults(
hf_tags: u32,
lf_tags: u32,
) -> Result<OperativeBillingBreakdown, BillingRulesError> {
calculate_operative_billing(OperativeBillingInput {
hf_tags,
lf_tags,
pbase_cop: DEFAULT_OPERATIVE_PBASE_COP,
k: DEFAULT_OPERATIVE_K,
})
}
pub fn calculate_operative_billing(
input: OperativeBillingInput,
) -> Result<OperativeBillingBreakdown, BillingRulesError> {
if input.pbase_cop <= 0 {
return Err(BillingRulesError::NonPositivePbase);
}
if input.k < 0.0 {
return Err(BillingRulesError::NegativeDiscountExponent);
}
let equivalent_tg = input.hf_tags as f64 + (input.lf_tags as f64 * OPERATIVE_LF_EQUIVALENT_TG);
if equivalent_tg == 0.0 {
return Ok(OperativeBillingBreakdown {
hf_tags: input.hf_tags,
lf_tags: input.lf_tags,
equivalent_tg,
pbase_cop: input.pbase_cop,
k: input.k,
annual_cop: 0,
monthly_cop: 0,
currency: "COP",
rounding_policy: "nearest peso",
effective_hf_price_cop: 0,
effective_lf_price_cop: 0,
});
}
let annual_cop =
round_cop(equivalent_tg * input.pbase_cop as f64 * equivalent_tg.powf(-input.k));
let monthly_cop = round_cop(annual_cop as f64 / 12.0);
let effective_hf_price_cop = if input.hf_tags > 0 {
round_cop(annual_cop as f64 / equivalent_tg)
} else {
0
};
let effective_lf_price_cop = if input.lf_tags > 0 {
round_cop((annual_cop as f64 / equivalent_tg) * OPERATIVE_LF_EQUIVALENT_TG)
} else {
0
};
Ok(OperativeBillingBreakdown {
hf_tags: input.hf_tags,
lf_tags: input.lf_tags,
equivalent_tg,
pbase_cop: input.pbase_cop,
k: input.k,
annual_cop,
monthly_cop,
currency: "COP",
rounding_policy: "nearest peso",
effective_hf_price_cop,
effective_lf_price_cop,
})
}
fn round_cop(value: f64) -> i64 {
value.round() as i64
}

View File

@@ -1,8 +1,43 @@
use sqlx::postgres::{PgPool, PgPoolOptions}; use sqlx::postgres::{PgPool, PgPoolOptions};
use std::env; use std::env;
use std::time::Duration;
pub type DbPool = PgPool; pub type DbPool = PgPool;
pub fn default_pool_options() -> PgPoolOptions {
PgPoolOptions::new()
.max_connections(
env::var("DB_MAX_CONNECTIONS")
.ok()
.and_then(|v| v.parse().ok())
.unwrap_or(10),
)
.min_connections(
env::var("DB_MIN_CONNECTIONS")
.ok()
.and_then(|v| v.parse().ok())
.unwrap_or(1),
)
.idle_timeout(Duration::from_secs(
env::var("DB_IDLE_TIMEOUT_SECS")
.ok()
.and_then(|v| v.parse().ok())
.unwrap_or(600),
))
.max_lifetime(Duration::from_secs(
env::var("DB_MAX_LIFETIME_SECS")
.ok()
.and_then(|v| v.parse().ok())
.unwrap_or(1800),
))
.acquire_timeout(Duration::from_secs(
env::var("DB_ACQUIRE_TIMEOUT_SECS")
.ok()
.and_then(|v| v.parse().ok())
.unwrap_or(10),
))
}
pub async fn establish_connection() -> Result<DbPool, sqlx::Error> { pub async fn establish_connection() -> Result<DbPool, sqlx::Error> {
let database_url = env::var("DATABASE_URL").expect("DATABASE_URL must be set"); let database_url = env::var("DATABASE_URL").expect("DATABASE_URL must be set");
@@ -10,9 +45,7 @@ pub async fn establish_connection() -> Result<DbPool, sqlx::Error> {
let max_retries = 10; let max_retries = 10;
loop { loop {
match PgPoolOptions::new() match default_pool_options()
.max_connections(10)
.acquire_timeout(std::time::Duration::from_secs(3))
.connect(&database_url) .connect(&database_url)
.await .await
{ {
@@ -22,7 +55,12 @@ pub async fn establish_connection() -> Result<DbPool, sqlx::Error> {
if retries >= max_retries { if retries >= max_retries {
return Err(e); return Err(e);
} }
tracing::warn!("⏳ Fallo conexión a DB (intento {}/{}): {}. Reintentando en 5s...", retries, max_retries, e); tracing::warn!(
"⏳ Fallo conexión a DB (intento {}/{}): {}. Reintentando en 5s...",
retries,
max_retries,
e
);
tokio::time::sleep(std::time::Duration::from_secs(5)).await; tokio::time::sleep(std::time::Duration::from_secs(5)).await;
} }
} }

View File

@@ -1,11 +1,12 @@
use axum::{ use axum::{
http::StatusCode, http::StatusCode,
response::{IntoResponse, Response, Json}, response::{IntoResponse, Json, Response},
}; };
use serde_json::json; use serde_json::json;
use tracing::error; use tracing::error;
use uuid::Uuid; use uuid::Uuid;
#[derive(Debug)]
pub enum AppError { pub enum AppError {
Database(sqlx::Error), Database(sqlx::Error),
Internal(anyhow::Error), Internal(anyhow::Error),
@@ -21,21 +22,23 @@ impl IntoResponse for AppError {
let (status, message) = match self { let (status, message) = match self {
AppError::Database(ref e) => { AppError::Database(ref e) => {
error!(correlation_id = %correlation_id, "Database Error: {:?}", e); error!(correlation_id = %correlation_id, "Database Error: {:?}", e);
(StatusCode::INTERNAL_SERVER_ERROR, "Error interno de base de datos".to_string()) (
StatusCode::INTERNAL_SERVER_ERROR,
"Error interno de base de datos".to_string(),
)
} }
AppError::Internal(ref e) => { AppError::Internal(ref e) => {
error!(correlation_id = %correlation_id, "Internal Error: {:?}", e); error!(correlation_id = %correlation_id, "Internal Error: {:?}", e);
(StatusCode::INTERNAL_SERVER_ERROR, "Error interno del servidor".to_string()) (
StatusCode::INTERNAL_SERVER_ERROR,
"Error interno del servidor".to_string(),
)
} }
AppError::NotFound(entity) => { AppError::NotFound(entity) => {
(StatusCode::NOT_FOUND, format!("{} no encontrado", entity)) (StatusCode::NOT_FOUND, format!("{} no encontrado", entity))
} }
AppError::Forbidden(msg) => { AppError::Forbidden(msg) => (StatusCode::FORBIDDEN, msg),
(StatusCode::FORBIDDEN, msg) AppError::BadRequest(msg) => (StatusCode::BAD_REQUEST, msg),
}
AppError::BadRequest(msg) => {
(StatusCode::BAD_REQUEST, msg)
}
}; };
( (

File diff suppressed because it is too large Load Diff

View File

@@ -1,9 +1,109 @@
use axum::{Json, extract::{Path, State}, http::StatusCode}; use crate::auth::{AdminClaims, Claims, role_satisfies_platform_claims};
use crate::errors::AppError;
use axum::{
Json,
extract::{Path, State},
http::StatusCode,
};
use serde::Deserialize;
use shared_lib::models::{AlertRule, CreateAlertRuleRequest};
use sqlx::PgPool; use sqlx::PgPool;
use uuid::Uuid; use uuid::Uuid;
use shared_lib::models::{AlertRule, CreateAlertRuleRequest};
use crate::auth::{Claims, AdminClaims}; #[derive(Deserialize)]
use crate::errors::AppError; pub struct AcknowledgeAlarmsRequest {
pub ids: Vec<Uuid>,
}
fn is_platform_global(claims: &Claims) -> bool {
role_satisfies_platform_claims(&claims.role)
}
fn alarm_select_sql(has_project: bool, platform_global: bool) -> &'static str {
match (has_project, platform_global) {
(true, true) => {
r#"SELECT row_to_json(t) FROM (
SELECT ta.id, ta.project_id, ta.asset_id, ta.variable_alias, ta.current_value,
ta.alarm_min, ta.alarm_max, ta.alarm_type, ta.message, COALESCE(ta.acknowledged, FALSE) AS acknowledged,
ta.acknowledged_at, ta.acknowledged_by, ta.timestamp
FROM telemetry_alarms ta
WHERE ta.project_id = $1
ORDER BY ta.timestamp DESC LIMIT $2
) t"#
}
(true, false) => {
r#"SELECT row_to_json(t) FROM (
SELECT ta.id, ta.project_id, ta.asset_id, ta.variable_alias, ta.current_value,
ta.alarm_min, ta.alarm_max, ta.alarm_type, ta.message, COALESCE(ta.acknowledged, FALSE) AS acknowledged,
ta.acknowledged_at, ta.acknowledged_by, ta.timestamp
FROM telemetry_alarms ta
JOIN user_project_access upa ON upa.project_id = ta.project_id
WHERE ta.project_id = $1
AND upa.user_id = $2
AND upa.is_active = TRUE
ORDER BY ta.timestamp DESC LIMIT $3
) t"#
}
(false, true) => {
r#"SELECT row_to_json(t) FROM (
SELECT ta.id, ta.project_id, ta.asset_id, ta.variable_alias, ta.current_value,
ta.alarm_min, ta.alarm_max, ta.alarm_type, ta.message, COALESCE(ta.acknowledged, FALSE) AS acknowledged,
ta.acknowledged_at, ta.acknowledged_by, ta.timestamp
FROM telemetry_alarms ta
ORDER BY ta.timestamp DESC LIMIT $1
) t"#
}
(false, false) => {
r#"SELECT row_to_json(t) FROM (
SELECT ta.id, ta.project_id, ta.asset_id, ta.variable_alias, ta.current_value,
ta.alarm_min, ta.alarm_max, ta.alarm_type, ta.message, COALESCE(ta.acknowledged, FALSE) AS acknowledged,
ta.acknowledged_at, ta.acknowledged_by, ta.timestamp
FROM telemetry_alarms ta
JOIN user_project_access upa ON upa.project_id = ta.project_id
WHERE upa.user_id = $1
AND upa.is_active = TRUE
ORDER BY ta.timestamp DESC LIMIT $2
) t"#
}
}
}
fn alarm_ack_scope_clause(platform_global: bool) -> &'static str {
if platform_global {
""
} else {
r#"AND EXISTS (
SELECT 1
FROM user_project_access upa
WHERE upa.user_id = $2
AND upa.project_id = ta.project_id
AND upa.is_active = TRUE
)"#
}
}
fn acknowledge_alarm_sql(platform_global: bool, bulk: bool) -> String {
let id_predicate = if bulk {
"ta.id = ANY($1)"
} else {
"ta.id = $1"
};
format!(
r#"WITH updated AS (
UPDATE telemetry_alarms ta
SET acknowledged = TRUE,
acknowledged_at = COALESCE(ta.acknowledged_at, NOW()),
acknowledged_by = COALESCE(ta.acknowledged_by, $2)
WHERE {id_predicate}
{scope_clause}
RETURNING ta.id, ta.project_id, ta.asset_id, ta.variable_alias, ta.current_value,
ta.alarm_min, ta.alarm_max, ta.alarm_type, ta.message, ta.acknowledged,
ta.acknowledged_at, ta.acknowledged_by, ta.timestamp
)
SELECT row_to_json(updated) FROM updated"#,
scope_clause = alarm_ack_scope_clause(platform_global)
)
}
/// GET /api/alert-rules?project_id=xxx /// GET /api/alert-rules?project_id=xxx
pub async fn list_alert_rules( pub async fn list_alert_rules(
@@ -13,25 +113,36 @@ pub async fn list_alert_rules(
) -> Result<Json<Vec<AlertRule>>, AppError> { ) -> Result<Json<Vec<AlertRule>>, AppError> {
let user_id = Uuid::parse_str(&claims.sub) let user_id = Uuid::parse_str(&claims.sub)
.map_err(|_| AppError::BadRequest("Invalid user ID in token".to_string()))?; .map_err(|_| AppError::BadRequest("Invalid user ID in token".to_string()))?;
let project_id = params.get("project_id") let project_id = params
.get("project_id")
.and_then(|s| Uuid::parse_str(s).ok()); .and_then(|s| Uuid::parse_str(s).ok());
let rules = if claims.role == "admin" || claims.role == "superadmin" { let rules = if claims.role == "admin" || claims.role == "superadmin" {
match project_id { match project_id {
Some(pid) => sqlx::query_as::<_, AlertRule>( Some(pid) => {
"SELECT * FROM alert_rules WHERE project_id = $1 ORDER BY created_at DESC" sqlx::query_as::<_, AlertRule>(
).bind(pid).fetch_all(&pool).await?, "SELECT * FROM alert_rules WHERE project_id = $1 ORDER BY created_at DESC",
None => sqlx::query_as::<_, AlertRule>( )
"SELECT * FROM alert_rules ORDER BY created_at DESC" .bind(pid)
).fetch_all(&pool).await?, .fetch_all(&pool)
.await?
}
None => {
sqlx::query_as::<_, AlertRule>("SELECT * FROM alert_rules ORDER BY created_at DESC")
.fetch_all(&pool)
.await?
}
} }
} else { } else {
sqlx::query_as::<_, AlertRule>( sqlx::query_as::<_, AlertRule>(
r#"SELECT ar.* FROM alert_rules ar r#"SELECT ar.* FROM alert_rules ar
JOIN user_project_access upa ON ar.project_id = upa.project_id JOIN user_project_access upa ON ar.project_id = upa.project_id
WHERE upa.user_id = $1 AND upa.is_active = true WHERE upa.user_id = $1 AND upa.is_active = true
ORDER BY ar.created_at DESC"# ORDER BY ar.created_at DESC"#,
).bind(user_id).fetch_all(&pool).await? )
.bind(user_id)
.fetch_all(&pool)
.await?
}; };
Ok(Json(rules)) Ok(Json(rules))
@@ -130,46 +241,194 @@ pub async fn list_alarms(
claims: Claims, claims: Claims,
axum::extract::Query(params): axum::extract::Query<std::collections::HashMap<String, String>>, axum::extract::Query(params): axum::extract::Query<std::collections::HashMap<String, String>>,
) -> Result<Json<Vec<serde_json::Value>>, AppError> { ) -> Result<Json<Vec<serde_json::Value>>, AppError> {
let limit: i64 = params.get("limit").and_then(|s| s.parse().ok()).unwrap_or(50).min(500); let limit: i64 = params
let project_id = params.get("project_id").and_then(|s| Uuid::parse_str(s).ok()); .get("limit")
.and_then(|s| s.parse().ok())
let alarms = match project_id { .unwrap_or(50)
Some(pid) => { .min(500);
sqlx::query_scalar::<_, serde_json::Value>( let project_id = params
r#"SELECT row_to_json(t) FROM ( .get("project_id")
SELECT id, project_id, asset_id, variable_alias, current_value, .and_then(|s| Uuid::parse_str(s).ok());
alarm_min, alarm_max, alarm_type, message, timestamp
FROM telemetry_alarms
WHERE project_id = $1
ORDER BY timestamp DESC LIMIT $2
) t"#
).bind(pid).bind(limit).fetch_all(&pool).await?
},
None if claims.role == "admin" || claims.role == "superadmin" => {
sqlx::query_scalar::<_, serde_json::Value>(
r#"SELECT row_to_json(t) FROM (
SELECT id, project_id, asset_id, variable_alias, current_value,
alarm_min, alarm_max, alarm_type, message, timestamp
FROM telemetry_alarms
ORDER BY timestamp DESC LIMIT $1
) t"#
).bind(limit).fetch_all(&pool).await?
},
None => {
let user_id = Uuid::parse_str(&claims.sub) let user_id = Uuid::parse_str(&claims.sub)
.map_err(|_| AppError::BadRequest("Invalid user ID in token".to_string()))?; .map_err(|_| AppError::BadRequest("Invalid user ID in token".to_string()))?;
sqlx::query_scalar::<_, serde_json::Value>(
r#"SELECT row_to_json(t) FROM ( let platform_global = is_platform_global(&claims);
SELECT ta.id, ta.project_id, ta.asset_id, ta.variable_alias, ta.current_value, let alarms = match project_id {
ta.alarm_min, ta.alarm_max, ta.alarm_type, ta.message, ta.timestamp Some(pid) if platform_global => {
FROM telemetry_alarms ta sqlx::query_scalar::<_, serde_json::Value>(alarm_select_sql(true, platform_global))
JOIN user_project_access upa ON ta.project_id = upa.project_id .bind(pid)
WHERE upa.user_id = $1 AND upa.is_active = true .bind(limit)
ORDER BY ta.timestamp DESC LIMIT $2 .fetch_all(&pool)
) t"# .await?
).bind(user_id).bind(limit).fetch_all(&pool).await? }
Some(pid) => {
sqlx::query_scalar::<_, serde_json::Value>(alarm_select_sql(true, platform_global))
.bind(pid)
.bind(user_id)
.bind(limit)
.fetch_all(&pool)
.await?
}
None if platform_global => {
sqlx::query_scalar::<_, serde_json::Value>(alarm_select_sql(false, platform_global))
.bind(limit)
.fetch_all(&pool)
.await?
}
None => {
sqlx::query_scalar::<_, serde_json::Value>(alarm_select_sql(false, platform_global))
.bind(user_id)
.bind(limit)
.fetch_all(&pool)
.await?
} }
}; };
Ok(Json(alarms)) Ok(Json(alarms))
} }
/// PATCH /api/alarms/:id/acknowledge — marcar una alarma como reconocida
pub async fn acknowledge_alarm(
State(pool): State<PgPool>,
claims: Claims,
Path(id): Path<Uuid>,
) -> Result<Json<serde_json::Value>, AppError> {
let user_id = Uuid::parse_str(&claims.sub)
.map_err(|_| AppError::BadRequest("Invalid user ID in token".to_string()))?;
let platform_global = is_platform_global(&claims);
let alarm =
sqlx::query_scalar::<_, serde_json::Value>(&acknowledge_alarm_sql(platform_global, false))
.bind(id)
.bind(user_id)
.fetch_optional(&pool)
.await?
.ok_or(AppError::NotFound("Alarma".to_string()))?;
Ok(Json(alarm))
}
/// PATCH /api/alarms/acknowledge — marcar múltiples alarmas como reconocidas
pub async fn acknowledge_alarms(
State(pool): State<PgPool>,
claims: Claims,
Json(req): Json<AcknowledgeAlarmsRequest>,
) -> Result<Json<Vec<serde_json::Value>>, AppError> {
if req.ids.is_empty() {
return Err(AppError::BadRequest(
"Debe enviar al menos una alarma".to_string(),
));
}
let user_id = Uuid::parse_str(&claims.sub)
.map_err(|_| AppError::BadRequest("Invalid user ID in token".to_string()))?;
let platform_global = is_platform_global(&claims);
let alarms =
sqlx::query_scalar::<_, serde_json::Value>(&acknowledge_alarm_sql(platform_global, true))
.bind(&req.ids)
.bind(user_id)
.fetch_all(&pool)
.await?;
if alarms.is_empty() {
return Err(AppError::NotFound("Alarmas".to_string()));
}
Ok(Json(alarms))
}
#[cfg(test)]
mod tests {
use super::{
acknowledge_alarm_sql, alarm_ack_scope_clause, alarm_select_sql, is_platform_global,
};
use crate::auth::Claims;
use uuid::Uuid;
fn claims(role: &str) -> Claims {
Claims {
sub: Uuid::new_v4().to_string(),
role: role.to_string(),
email: format!("{role}@example.com"),
projects: Vec::new(),
exp: 0,
}
}
#[test]
fn customer_alarm_list_without_project_is_scoped_by_project_access() {
let sql = alarm_select_sql(false, false);
assert!(sql.contains("JOIN user_project_access upa"));
assert!(sql.contains("upa.user_id = $1"));
assert!(sql.contains("upa.project_id = ta.project_id"));
assert!(sql.contains("upa.is_active = TRUE"));
}
#[test]
fn platform_alarm_list_without_project_omits_project_access_scope() {
let sql = alarm_select_sql(false, true);
assert!(!sql.contains("user_project_access"));
assert!(sql.contains("FROM telemetry_alarms"));
assert!(sql.contains("LIMIT $1"));
}
#[test]
fn customer_alarm_list_for_project_is_scoped_by_project_access() {
let sql = alarm_select_sql(true, false);
assert!(sql.contains("JOIN user_project_access upa"));
assert!(sql.contains("ta.project_id = $1"));
assert!(sql.contains("upa.user_id = $2"));
assert!(sql.contains("LIMIT $3"));
}
#[test]
fn platform_alarm_list_for_project_omits_project_access_scope() {
let sql = alarm_select_sql(true, true);
assert!(!sql.contains("user_project_access"));
assert!(sql.contains("ta.project_id = $1"));
assert!(sql.contains("LIMIT $2"));
}
#[test]
fn customer_ack_scope_requires_project_access() {
let clause = alarm_ack_scope_clause(false);
assert!(clause.contains("EXISTS"));
assert!(clause.contains("FROM user_project_access upa"));
assert!(clause.contains("upa.user_id = $2"));
assert!(clause.contains("upa.project_id = ta.project_id"));
assert!(clause.contains("upa.is_active = TRUE"));
}
#[test]
fn platform_ack_scope_does_not_require_project_access() {
let clause = alarm_ack_scope_clause(true);
assert!(!clause.contains("user_project_access"));
assert!(clause.trim().is_empty());
}
#[test]
fn customer_single_and_bulk_ack_sql_include_project_access_scope() {
let single_sql = acknowledge_alarm_sql(false, false);
let bulk_sql = acknowledge_alarm_sql(false, true);
assert!(single_sql.contains("WHERE ta.id = $1"));
assert!(single_sql.contains("FROM user_project_access upa"));
assert!(bulk_sql.contains("WHERE ta.id = ANY($1)"));
assert!(bulk_sql.contains("FROM user_project_access upa"));
}
#[test]
fn only_platform_roles_are_platform_global_for_alarms() {
assert!(is_platform_global(&claims("platform_admin")));
assert!(is_platform_global(&claims("platform_operator")));
assert!(!is_platform_global(&claims("admin")));
assert!(!is_platform_global(&claims("superadmin")));
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -1,6 +1,6 @@
use serde_json::Value;
use sqlx::PgPool; use sqlx::PgPool;
use uuid::Uuid; use uuid::Uuid;
use serde_json::Value;
pub async fn log_audit( pub async fn log_audit(
pool: &PgPool, pool: &PgPool,

View File

@@ -44,6 +44,23 @@ fn extract_refresh_cookie(headers: &HeaderMap) -> Option<String> {
}) })
} }
fn is_active_access_request_unique_violation(error: &sqlx::Error) -> bool {
error
.as_database_error()
.and_then(|database_error| database_error.constraint())
== Some("idx_access_requests_active_email_unique")
}
fn duplicate_access_request_response() -> axum::response::Response {
(
StatusCode::CONFLICT,
Json(json!({
"error": "Ya existe una solicitud activa para este email"
})),
)
.into_response()
}
#[derive(Deserialize, validator::Validate)] #[derive(Deserialize, validator::Validate)]
pub struct LoginPayload { pub struct LoginPayload {
#[validate(email(message = "Email inválido"))] #[validate(email(message = "Email inválido"))]
@@ -60,16 +77,8 @@ pub struct RequestAccessPayload {
full_name: String, full_name: String,
#[validate(email)] #[validate(email)]
email: String, email: String,
#[validate(length(min = 2, max = 200))] #[validate(length(max = 50))]
company: String, whatsapp: Option<String>,
#[validate(length(min = 3, max = 30))]
nit: String,
#[validate(length(min = 2, max = 150))]
position: String,
#[validate(length(min = 7, max = 50))]
phone: String,
#[validate(length(max = 2000))]
message: Option<String>,
#[validate(length(min = 1))] #[validate(length(min = 1))]
turnstile_token: String, turnstile_token: String,
} }
@@ -96,6 +105,14 @@ struct AccessRequestResponse {
message: &'static str, message: &'static str,
} }
const REQUEST_ACCESS_DRAFT_PROFILE_SQL: &str = r#"INSERT INTO customer_commercial_profiles (access_request_id, status, plan, payment_method, effective_from, notes)
VALUES ($1, 'draft', 'free', 'none', CURRENT_DATE, 'Created from public access request; pending commercial classification.')
ON CONFLICT (access_request_id) WHERE access_request_id IS NOT NULL DO NOTHING"#;
const REQUEST_ACCESS_PENDING_CLASSIFICATION_SQL: &str = r#"UPDATE access_requests
SET status = 'pending_classification', updated_at = NOW()
WHERE id = $1 AND status = 'pending'"#;
#[derive(sqlx::FromRow)] #[derive(sqlx::FromRow)]
struct UserRow { struct UserRow {
id: uuid::Uuid, id: uuid::Uuid,
@@ -359,21 +376,6 @@ pub async fn login(
Err(AuthError::InvalidCredentials) Err(AuthError::InvalidCredentials)
} }
fn is_valid_nit(nit: &str) -> bool {
let mut parts = nit.split('-');
let Some(number) = parts.next() else {
return false;
};
let Some(check_digit) = parts.next() else {
return false;
};
parts.next().is_none()
&& (9..=10).contains(&number.len())
&& number.chars().all(|c| c.is_ascii_digit())
&& check_digit.len() == 1
&& check_digit.chars().all(|c| c.is_ascii_digit())
}
async fn verify_turnstile(token: &str, headers: &HeaderMap) -> Result<(), AuthError> { async fn verify_turnstile(token: &str, headers: &HeaderMap) -> Result<(), AuthError> {
let secret = std::env::var("TURNSTILE_SECRET_KEY") let secret = std::env::var("TURNSTILE_SECRET_KEY")
.map_err(|_| AuthError::Internal(anyhow::anyhow!("TURNSTILE_SECRET_KEY must be set")))?; .map_err(|_| AuthError::Internal(anyhow::anyhow!("TURNSTILE_SECRET_KEY must be set")))?;
@@ -412,6 +414,21 @@ async fn verify_turnstile(token: &str, headers: &HeaderMap) -> Result<(), AuthEr
} }
} }
fn is_valid_nit(nit: &str) -> bool {
let mut parts = nit.split('-');
let Some(number) = parts.next() else {
return false;
};
let Some(check_digit) = parts.next() else {
return false;
};
parts.next().is_none()
&& (9..=10).contains(&number.len())
&& number.chars().all(|c| c.is_ascii_digit())
&& check_digit.len() == 1
&& check_digit.chars().all(|c| c.is_ascii_digit())
}
pub async fn request_access( pub async fn request_access(
State(pool): State<DbPool>, State(pool): State<DbPool>,
headers: HeaderMap, headers: HeaderMap,
@@ -419,14 +436,6 @@ pub async fn request_access(
RequestAccessPayload, RequestAccessPayload,
>, >,
) -> Result<impl IntoResponse, AuthError> { ) -> Result<impl IntoResponse, AuthError> {
if !is_valid_nit(&payload.nit) {
return Ok((
StatusCode::UNPROCESSABLE_ENTITY,
Json(json!({ "error": "Formato NIT inválido" })),
)
.into_response());
}
verify_turnstile(&payload.turnstile_token, &headers).await?; verify_turnstile(&payload.turnstile_token, &headers).await?;
let request_ip = headers let request_ip = headers
@@ -444,51 +453,101 @@ pub async fn request_access(
.get("User-Agent") .get("User-Agent")
.and_then(|v| v.to_str().ok()) .and_then(|v| v.to_str().ok())
.map(str::to_string); .map(str::to_string);
let email = payload.email.trim().to_ascii_lowercase();
sqlx::query( let has_active_request: bool = sqlx::query_scalar(
r#"INSERT INTO access_requests r#"SELECT EXISTS(
(full_name, email, company, nit, position, phone, message, request_ip, user_agent) SELECT 1
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)"#, FROM access_requests
WHERE lower(email) = $1
AND status IN ('pending', 'pending_classification', 'in_review', 'approved')
)"#,
) )
.bind(payload.full_name.trim()) .bind(&email)
.bind(payload.email.trim().to_lowercase()) .fetch_one(&pool)
.bind(payload.company.trim())
.bind(payload.nit.trim())
.bind(payload.position.trim())
.bind(payload.phone.trim())
.bind(
payload
.message
.as_deref()
.map(str::trim)
.filter(|s| !s.is_empty()),
)
.bind(&request_ip)
.bind(&user_agent)
.execute(&pool)
.await .await
.map_err(|e| { .map_err(|e| {
tracing::error!("Access request insert error: {}", e); tracing::error!("Access request duplicate lookup error: {}", e);
AuthError::Internal(anyhow::anyhow!("Error al registrar solicitud")) AuthError::Internal(anyhow::anyhow!("Error al validar solicitud"))
})?; })?;
if has_active_request {
return Ok(duplicate_access_request_response());
}
let request_id_result: Result<uuid::Uuid, sqlx::Error> = sqlx::query_scalar(
r#"INSERT INTO access_requests
(full_name, email, company, nit, position, phone, message, request_ip, user_agent)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)
RETURNING id"#,
)
.bind(payload.full_name.trim())
.bind(&email)
.bind(payload.full_name.trim())
.bind("PENDING")
.bind("Trial lead")
.bind(
payload
.whatsapp
.as_deref()
.map(str::trim)
.filter(|s| !s.is_empty())
.unwrap_or(""),
)
.bind(Option::<&str>::None)
.bind(&request_ip)
.bind(&user_agent)
.fetch_one(&pool)
.await;
let request_id = match request_id_result {
Ok(request_id) => request_id,
Err(error) => {
if is_active_access_request_unique_violation(&error) {
return Ok(duplicate_access_request_response());
}
tracing::error!("Access request insert error: {}", error);
return Err(AuthError::Internal(anyhow::anyhow!(
"Error al registrar solicitud"
)));
}
};
crate::audit::log( crate::audit::log(
&pool, &pool,
crate::audit::AuditEntry::new("access_request.create") crate::audit::AuditEntry::new("access_request.create")
.with_headers(&headers) .with_headers(&headers)
.with_metadata(json!({ .with_metadata(json!({
"email": payload.email, "email": &payload.email,
"company": payload.company, "whatsapp_provided": payload.whatsapp.as_deref().is_some_and(|value| !value.trim().is_empty()),
"nit": payload.nit,
})), })),
) )
.await; .await;
sqlx::query(REQUEST_ACCESS_DRAFT_PROFILE_SQL)
.bind(request_id)
.execute(&pool)
.await
.map_err(|error| {
tracing::error!(request_id = %request_id, error = %error, "Commercial profile creation failed");
AuthError::Internal(anyhow::anyhow!("Error al registrar perfil comercial"))
})?;
sqlx::query(REQUEST_ACCESS_PENDING_CLASSIFICATION_SQL)
.bind(request_id)
.execute(&pool)
.await
.map_err(|error| {
tracing::error!(request_id = %request_id, error = %error, "Access request classification status update failed");
AuthError::Internal(anyhow::anyhow!("Error al registrar solicitud"))
})?;
Ok(( Ok((
StatusCode::ACCEPTED, StatusCode::ACCEPTED,
Json(AccessRequestResponse { Json(AccessRequestResponse {
status: "pending", status: "pending_classification",
message: "Solicitud recibida. Nuestro equipo revisará la información.", message: "Recibimos tu solicitud. El equipo comercial la revisará antes de activar el acceso.",
}), }),
) )
.into_response()) .into_response())
@@ -660,11 +719,242 @@ fn hash_password(password: &str) -> Result<String, AuthError> {
.map_err(|e| AuthError::Internal(anyhow::anyhow!("Hash error: {}", e))) .map_err(|e| AuthError::Internal(anyhow::anyhow!("Hash error: {}", e)))
} }
#[derive(Deserialize, Validate)]
pub struct PublicRegisterPayload {
#[validate(email(message = "Email inválido"))]
pub email: String,
#[validate(length(min = 6, message = "Contraseña debe tener al menos 6 caracteres"))]
pub password: String,
#[validate(length(min = 3, message = "Nombre completo inválido"))]
pub full_name: String,
#[validate(length(min = 2, message = "Nombre de la empresa inválido"))]
pub company: String,
#[validate(length(min = 3, max = 30))]
pub nit: String,
}
pub async fn register( pub async fn register(
State(_pool): State<DbPool>, State(state): State<crate::AppState>,
Json(_payload): Json<SetupRegisterPayload>, headers: HeaderMap,
) -> Result<Json<serde_json::Value>, AuthError> { crate::validation::ValidatedJson(payload): crate::validation::ValidatedJson<
Err(AuthError::RegistrationDisabled) PublicRegisterPayload,
>,
) -> Result<impl IntoResponse, AuthError> {
if !is_valid_nit(&payload.nit) {
return Ok((
StatusCode::UNPROCESSABLE_ENTITY,
Json(json!({ "error": "Formato NIT inválido" })),
)
.into_response());
}
let password_hash = hash_password(&payload.password)?;
let mut tx =
state.pool.begin().await.map_err(|e| {
AuthError::Internal(anyhow::anyhow!("Error al iniciar transacción: {}", e))
})?;
let user_exists: bool =
sqlx::query_scalar("SELECT EXISTS(SELECT 1 FROM users WHERE email = $1)")
.bind(payload.email.trim().to_lowercase())
.fetch_one(&mut *tx)
.await
.map_err(|e| {
AuthError::Internal(anyhow::anyhow!(
"Error al verificar existencia de usuario: {}",
e
))
})?;
if user_exists {
return Ok((
StatusCode::CONFLICT,
Json(json!({ "error": "El correo electrónico ya está registrado" })),
)
.into_response());
}
let admin_role_id: i32 = sqlx::query_scalar("SELECT id FROM roles WHERE name = 'admin'")
.fetch_optional(&mut *tx)
.await
.map_err(|e| AuthError::Internal(anyhow::anyhow!("Error al buscar rol: {}", e)))?
.ok_or_else(|| AuthError::Internal(anyhow::anyhow!("Rol admin no encontrado")))?;
let user_id: uuid::Uuid = sqlx::query_scalar(
"INSERT INTO users (email, password_hash, full_name, role_id, is_active) VALUES ($1, $2, $3, $4, true) RETURNING id",
)
.bind(payload.email.trim().to_lowercase())
.bind(&password_hash)
.bind(payload.full_name.trim())
.bind(admin_role_id)
.fetch_one(&mut *tx)
.await
.map_err(|e| AuthError::Internal(anyhow::anyhow!("Error al crear usuario: {}", e)))?;
let base_project_name = {
let sanitized: String = payload
.company
.chars()
.map(|ch| {
if ch.is_ascii_alphanumeric() || ch == ' ' || ch == '-' || ch == '_' {
ch
} else {
' '
}
})
.collect();
let collapsed = sanitized.split_whitespace().collect::<Vec<_>>().join(" ");
let base = if collapsed.is_empty() {
"Cliente OmniOil"
} else {
collapsed.as_str()
};
base.chars().take(80).collect::<String>()
};
let project_name_exists: bool =
sqlx::query_scalar("SELECT EXISTS(SELECT 1 FROM edge_projects WHERE name = $1)")
.bind(&base_project_name)
.fetch_one(&mut *tx)
.await
.map_err(|e| {
AuthError::Internal(anyhow::anyhow!(
"Error al verificar existencia de proyecto: {}",
e
))
})?;
let project_name = if project_name_exists {
format!(
"{}-{}",
base_project_name,
&uuid::Uuid::new_v4().to_string()[..8]
)
} else {
base_project_name
};
let project_id: uuid::Uuid = sqlx::query_scalar(
r#"INSERT INTO edge_projects
(name, client, operator_name, contract_number, description, metadata)
VALUES ($1, $2, $3, $4, $5, $6)
RETURNING id"#,
)
.bind(&project_name)
.bind(&payload.company)
.bind(&payload.company)
.bind(format!("TRIAL-{}", payload.nit.trim()))
.bind(format!(
"Proyecto trial para {} (NIT {})",
payload.company, payload.nit
))
.bind(serde_json::json!({ "auto_onboarded": true, "company_nit": payload.nit }))
.fetch_one(&mut *tx)
.await
.map_err(|e| AuthError::Internal(anyhow::anyhow!("Error al insertar proyecto: {}", e)))?;
sqlx::query(
r#"INSERT INTO user_project_access (user_id, project_id, project_role, is_active)
VALUES ($1, $2, 'owner', true)"#,
)
.bind(user_id)
.bind(project_id)
.execute(&mut *tx)
.await
.map_err(|e| {
AuthError::Internal(anyhow::anyhow!(
"Error al asociar usuario a proyecto: {}",
e
))
})?;
sqlx::query(
r#"INSERT INTO subscriptions (project_id, status, trial_ends_at, notes)
VALUES ($1, 'trial', NOW() + INTERVAL '30 days', $2)"#,
)
.bind(project_id)
.bind("Trial de 30 días creado automáticamente al registrarse")
.execute(&mut *tx)
.await
.map_err(|e| AuthError::Internal(anyhow::anyhow!("Error al crear suscripción: {}", e)))?;
tx.commit()
.await
.map_err(|e| AuthError::Internal(anyhow::anyhow!("Error al realizar commit: {}", e)))?;
let event = shared_lib::mqtt_messages::ProjectCreatedEvent {
project_id,
deployment_id: Some(project_id),
name: project_name.clone(),
client: payload.company.clone(),
timestamp: chrono::Utc::now(),
};
if let Ok(payload_str) = serde_json::to_string(&event) {
let _ = state
.mqtt_client
.publish(
"omnioil/events/project_created",
rumqttc::QoS::AtLeastOnce,
false,
payload_str,
)
.await;
}
crate::audit::log(
&state.pool,
crate::audit::AuditEntry::new("user.register")
.with_user(user_id, &payload.email)
.with_headers(&headers),
)
.await;
let tokens = create_jwt_tokens(
&user_id.to_string(),
"admin",
&payload.email,
vec![project_id],
)
.map_err(|e| AuthError::Internal(anyhow::anyhow!("Error al crear tokens: {}", e)))?;
use sha2::{Digest, Sha256};
let mut hasher = Sha256::new();
hasher.update(tokens.refresh_token.as_bytes());
let refresh_hash = format!("{:x}", hasher.finalize());
sqlx::query("INSERT INTO refresh_tokens (user_id, token_hash, expires_at) VALUES ($1, $2, $3)")
.bind(user_id)
.bind(refresh_hash)
.bind(chrono::Utc::now() + chrono::Duration::days(7))
.execute(&state.pool)
.await
.map_err(|e| {
tracing::error!("Error storing refresh token: {}", e);
AuthError::Internal(anyhow::anyhow!("Error de sesión"))
})?;
let cookie = build_refresh_cookie(&tokens.refresh_token, 7 * 24 * 3600);
let mut response_headers = HeaderMap::new();
response_headers.insert(SET_COOKIE, cookie.parse().unwrap());
Ok((
response_headers,
Json(json!({
"access_token": tokens.access_token,
"token": tokens.access_token,
"role": "admin",
"email": payload.email,
"projects": [
{
"id": project_id,
"name": project_name
}
]
})),
)
.into_response())
} }
#[derive(serde::Serialize)] #[derive(serde::Serialize)]
@@ -686,6 +976,34 @@ pub async fn get_setup_status(State(pool): State<DbPool>) -> Result<Json<SetupSt
})) }))
} }
#[cfg(test)]
mod tests {
use super::{REQUEST_ACCESS_DRAFT_PROFILE_SQL, REQUEST_ACCESS_PENDING_CLASSIFICATION_SQL};
#[test]
fn commercial_request_access_persists_draft_profile_before_pending_classification_response() {
assert!(
REQUEST_ACCESS_DRAFT_PROFILE_SQL.contains("INSERT INTO customer_commercial_profiles")
);
assert!(REQUEST_ACCESS_DRAFT_PROFILE_SQL.contains("access_request_id"));
assert!(REQUEST_ACCESS_DRAFT_PROFILE_SQL.contains("'draft'"));
assert!(REQUEST_ACCESS_DRAFT_PROFILE_SQL.contains("'free'"));
assert!(REQUEST_ACCESS_DRAFT_PROFILE_SQL.contains("'none'"));
assert!(REQUEST_ACCESS_DRAFT_PROFILE_SQL.contains("CURRENT_DATE"));
assert!(REQUEST_ACCESS_DRAFT_PROFILE_SQL.contains(
"ON CONFLICT (access_request_id) WHERE access_request_id IS NOT NULL DO NOTHING"
));
assert!(
REQUEST_ACCESS_PENDING_CLASSIFICATION_SQL
.contains("SET status = 'pending_classification'")
);
assert!(
REQUEST_ACCESS_PENDING_CLASSIFICATION_SQL
.contains("WHERE id = $1 AND status = 'pending'")
);
}
}
#[derive(Deserialize)] #[derive(Deserialize)]
pub struct SetupRegisterPayload { pub struct SetupRegisterPayload {
pub email: String, pub email: String,

View File

@@ -11,6 +11,10 @@ use std::collections::HashMap;
use uuid::Uuid; use uuid::Uuid;
use crate::auth::{Claims, PlatformAdminClaims}; use crate::auth::{Claims, PlatformAdminClaims};
use crate::billing_rules::{self, OperativeBillingInput};
const LEGACY_DEFAULT_PRICE_PER_TAG_COP: i64 = 90_000;
const OFFICIAL_PRICE_PER_TAG_COP: i64 = 110_000;
// ─── Error helper ─────────────────────────────────────────────────────────── // ─── Error helper ───────────────────────────────────────────────────────────
@@ -62,6 +66,8 @@ pub struct BillingUsage {
pub project_name: String, pub project_name: String,
pub tier: String, pub tier: String,
pub subscription_status: String, pub subscription_status: String,
pub hf_variables: i64,
pub lf_variables: i64,
pub active_tags: i64, pub active_tags: i64,
pub tag_limit: i32, pub tag_limit: i32,
pub price_per_tag: i64, pub price_per_tag: i64,
@@ -73,6 +79,47 @@ pub struct BillingUsage {
pub period_start: DateTime<Utc>, pub period_start: DateTime<Utc>,
pub period_end: DateTime<Utc>, pub period_end: DateTime<Utc>,
pub last_snapshot: Option<NaiveDate>, pub last_snapshot: Option<NaiveDate>,
pub commercial_profile: Option<CommercialProfileBillingSummary>,
pub formula: Option<OperativeFormulaSummary>,
pub calculation_lines: Vec<BillingCalculationLine>,
}
#[derive(Clone, Debug, Serialize)]
pub struct CommercialProfileBillingSummary {
pub id: Uuid,
pub status: String,
pub plan: String,
pub payment_method: Option<String>,
pub payment_terms_days: Option<i32>,
pub credit_limit_cop: Option<i64>,
pub tg_limit: Option<f64>,
pub effective_from: NaiveDate,
pub profile_complete: bool,
pub missing_fields: Vec<String>,
}
#[derive(Clone, Debug, Serialize)]
pub struct OperativeFormulaSummary {
pub hf_tags: u32,
pub lf_tags: u32,
pub equivalent_tg: f64,
pub pbase_cop: i64,
pub k: f64,
pub annual_cop: i64,
pub monthly_cop: i64,
pub currency: String,
pub rounding_policy: String,
pub effective_from: NaiveDate,
pub effective_hf_price_cop: i64,
pub effective_lf_price_cop: i64,
}
#[derive(Clone, Debug, Serialize)]
pub struct BillingCalculationLine {
pub kind: String,
pub label: String,
pub amount_cop: i64,
pub source: Option<String>,
} }
#[derive(Serialize)] #[derive(Serialize)]
@@ -98,6 +145,55 @@ pub struct ProjectBillingRow {
pub period_end: DateTime<Utc>, pub period_end: DateTime<Utc>,
} }
#[derive(sqlx::FromRow)]
struct ProjectLookup {
pub name: String,
}
#[derive(Clone, Copy)]
struct BillingVariableCounts {
hf_variables: i64,
lf_variables: i64,
}
struct CurrentBillingCalculation {
active_tags: i64,
price_per_tag: i64,
subtotal_annual: i64,
minimum_applied: bool,
total_annual_cop: i64,
}
#[derive(Clone, Debug, sqlx::FromRow)]
struct CommercialProfileForBilling {
id: Uuid,
status: String,
plan: String,
payment_method: Option<String>,
payment_terms_days: Option<i32>,
credit_limit_cop: Option<i64>,
tg_limit: Option<f64>,
pbase_cop: i64,
discount_k: f64,
currency: String,
effective_from: NaiveDate,
enterprise_annual_cop: Option<i64>,
enterprise_monthly_cop: Option<i64>,
enterprise_override_reason: Option<String>,
}
struct ProfileBillingResponse {
active_tags: i64,
price_per_tag: i64,
subtotal_annual: i64,
minimum_applied: bool,
total_annual_cop: i64,
total_monthly_cop: i64,
commercial_profile: CommercialProfileBillingSummary,
formula: Option<OperativeFormulaSummary>,
calculation_lines: Vec<BillingCalculationLine>,
}
#[derive(Deserialize)] #[derive(Deserialize)]
pub struct UpdateSubscriptionRequest { pub struct UpdateSubscriptionRequest {
pub tier: Option<String>, pub tier: Option<String>,
@@ -132,7 +228,7 @@ async fn get_or_create_subscription(
// Auto-create starter subscription // Auto-create starter subscription
sqlx::query_as::<_, Subscription>( sqlx::query_as::<_, Subscription>(
r#"INSERT INTO subscriptions (project_id, tier, tag_limit, price_per_tag, min_annual_fee, status) r#"INSERT INTO subscriptions (project_id, tier, tag_limit, price_per_tag, min_annual_fee, status)
VALUES ($1, 'starter', 50, 90000, 4000000, 'active') VALUES ($1, 'starter', 50, 110000, 4000000, 'active')
ON CONFLICT (project_id) DO UPDATE SET updated_at = NOW() ON CONFLICT (project_id) DO UPDATE SET updated_at = NOW()
RETURNING *"#, RETURNING *"#,
) )
@@ -156,6 +252,380 @@ fn calculate_billing(
(subtotal, minimum_applied, total) (subtotal, minimum_applied, total)
} }
fn effective_price_per_tag(stored_price_per_tag: i64) -> i64 {
if stored_price_per_tag == LEGACY_DEFAULT_PRICE_PER_TAG_COP {
OFFICIAL_PRICE_PER_TAG_COP
} else {
stored_price_per_tag
}
}
fn calculate_current_billing(
counts: BillingVariableCounts,
stored_price_per_tag: i64,
min_annual_fee: i64,
) -> CurrentBillingCalculation {
let active_tags = counts.hf_variables + counts.lf_variables;
let price_per_tag = effective_price_per_tag(stored_price_per_tag);
let (subtotal_annual, minimum_applied, total_annual_cop) =
calculate_billing(active_tags, price_per_tag, min_annual_fee);
CurrentBillingCalculation {
active_tags,
price_per_tag,
subtotal_annual,
minimum_applied,
total_annual_cop,
}
}
fn build_billing_usage_response(
project_id: Uuid,
project_name: String,
sub: &Subscription,
counts: BillingVariableCounts,
billing: CurrentBillingCalculation,
last_snapshot: Option<NaiveDate>,
) -> BillingUsage {
BillingUsage {
project_id,
project_name,
tier: sub.tier.clone(),
subscription_status: sub.status.clone(),
hf_variables: counts.hf_variables,
lf_variables: counts.lf_variables,
active_tags: billing.active_tags,
tag_limit: sub.tag_limit,
price_per_tag: billing.price_per_tag,
min_annual_fee: sub.min_annual_fee,
subtotal_annual: billing.subtotal_annual,
minimum_applied: billing.minimum_applied,
total_annual_cop: billing.total_annual_cop,
total_monthly_cop: billing.total_annual_cop / 12,
period_start: sub.current_period_start,
period_end: sub.current_period_end,
last_snapshot,
commercial_profile: None,
formula: None,
calculation_lines: vec![BillingCalculationLine {
kind: "legacy_subscription".to_string(),
label: "Legacy subscription tag billing".to_string(),
amount_cop: billing.total_annual_cop,
source: Some("subscriptions".to_string()),
}],
}
}
fn profile_missing_fields(profile: &CommercialProfileForBilling) -> Vec<String> {
let mut missing = Vec::new();
if profile.payment_method.is_none() {
missing.push("payment_method".to_string());
}
if profile.plan != "free" {
if profile.payment_terms_days.is_none() {
missing.push("payment_terms_days".to_string());
}
if profile.tg_limit.is_none() {
missing.push("tg_limit".to_string());
}
}
if profile.plan == "enterprise" {
if profile.enterprise_annual_cop.is_none() {
missing.push("enterprise_annual_cop".to_string());
}
if profile.enterprise_monthly_cop.is_none() {
missing.push("enterprise_monthly_cop".to_string());
}
if profile
.enterprise_override_reason
.as_deref()
.is_none_or(|reason| reason.trim().is_empty())
{
missing.push("enterprise_override_reason".to_string());
}
}
missing
}
fn commercial_profile_summary(
profile: &CommercialProfileForBilling,
missing_fields: Vec<String>,
) -> CommercialProfileBillingSummary {
CommercialProfileBillingSummary {
id: profile.id,
status: profile.status.clone(),
plan: profile.plan.clone(),
payment_method: profile.payment_method.clone(),
payment_terms_days: profile.payment_terms_days,
credit_limit_cop: profile.credit_limit_cop,
tg_limit: profile.tg_limit,
effective_from: profile.effective_from,
profile_complete: missing_fields.is_empty()
&& matches!(profile.status.as_str(), "approved" | "active"),
missing_fields,
}
}
fn build_profile_billing_response(
profile: &CommercialProfileForBilling,
counts: BillingVariableCounts,
) -> Result<ProfileBillingResponse, billing_rules::BillingRulesError> {
let missing_fields = profile_missing_fields(profile);
let commercial_profile = commercial_profile_summary(profile, missing_fields);
let active_tags = counts.hf_variables + counts.lf_variables;
if !commercial_profile.profile_complete {
return Ok(ProfileBillingResponse {
active_tags,
price_per_tag: 0,
subtotal_annual: 0,
minimum_applied: false,
total_annual_cop: 0,
total_monthly_cop: 0,
commercial_profile,
formula: None,
calculation_lines: vec![BillingCalculationLine {
kind: "incomplete_profile".to_string(),
label: "Incomplete commercial profile is non-billable".to_string(),
amount_cop: 0,
source: Some("customer_commercial_profiles".to_string()),
}],
});
}
match profile.plan.as_str() {
"free" => Ok(ProfileBillingResponse {
active_tags,
price_per_tag: 0,
subtotal_annual: 0,
minimum_applied: false,
total_annual_cop: 0,
total_monthly_cop: 0,
commercial_profile,
formula: None,
calculation_lines: vec![BillingCalculationLine {
kind: "free_non_collecting".to_string(),
label: "Free plan does not collect payment".to_string(),
amount_cop: 0,
source: Some("customer_commercial_profiles".to_string()),
}],
}),
"enterprise" => {
let annual = profile.enterprise_annual_cop.unwrap_or(0);
let monthly = profile.enterprise_monthly_cop.unwrap_or(0);
Ok(ProfileBillingResponse {
active_tags,
price_per_tag: 0,
subtotal_annual: annual,
minimum_applied: false,
total_annual_cop: annual,
total_monthly_cop: monthly,
commercial_profile,
formula: None,
calculation_lines: vec![BillingCalculationLine {
kind: "enterprise_override".to_string(),
label: "Enterprise override pricing".to_string(),
amount_cop: annual,
source: profile.enterprise_override_reason.clone(),
}],
})
}
_ => {
let breakdown = billing_rules::calculate_operative_billing(OperativeBillingInput {
hf_tags: counts.hf_variables.max(0) as u32,
lf_tags: counts.lf_variables.max(0) as u32,
pbase_cop: profile.pbase_cop,
k: profile.discount_k,
})?;
Ok(ProfileBillingResponse {
active_tags,
price_per_tag: breakdown.effective_hf_price_cop,
subtotal_annual: breakdown.annual_cop,
minimum_applied: false,
total_annual_cop: breakdown.annual_cop,
total_monthly_cop: breakdown.monthly_cop,
commercial_profile,
formula: Some(OperativeFormulaSummary {
hf_tags: breakdown.hf_tags,
lf_tags: breakdown.lf_tags,
equivalent_tg: breakdown.equivalent_tg,
pbase_cop: breakdown.pbase_cop,
k: breakdown.k,
annual_cop: breakdown.annual_cop,
monthly_cop: breakdown.monthly_cop,
currency: profile.currency.clone(),
rounding_policy: breakdown.rounding_policy.to_string(),
effective_from: profile.effective_from,
effective_hf_price_cop: breakdown.effective_hf_price_cop,
effective_lf_price_cop: breakdown.effective_lf_price_cop,
}),
calculation_lines: vec![BillingCalculationLine {
kind: "operative_formula".to_string(),
label: "V = HF + (LF * 0.4); annual = V * Pbase * V^-k".to_string(),
amount_cop: breakdown.annual_cop,
source: Some("billing_rules".to_string()),
}],
})
}
}
}
fn build_profile_billing_usage_response(
project_id: Uuid,
project_name: String,
sub: &Subscription,
counts: BillingVariableCounts,
billing: ProfileBillingResponse,
last_snapshot: Option<NaiveDate>,
) -> BillingUsage {
BillingUsage {
project_id,
project_name,
tier: billing.commercial_profile.plan.clone(),
subscription_status: sub.status.clone(),
hf_variables: counts.hf_variables,
lf_variables: counts.lf_variables,
active_tags: billing.active_tags,
tag_limit: sub.tag_limit,
price_per_tag: billing.price_per_tag,
min_annual_fee: 0,
subtotal_annual: billing.subtotal_annual,
minimum_applied: billing.minimum_applied,
total_annual_cop: billing.total_annual_cop,
total_monthly_cop: billing.total_monthly_cop,
period_start: sub.current_period_start,
period_end: sub.current_period_end,
last_snapshot,
commercial_profile: Some(billing.commercial_profile),
formula: billing.formula,
calculation_lines: billing.calculation_lines,
}
}
fn hf_variables_count_sql() -> &'static str {
r#"SELECT COUNT(*)
FROM edge_variables ev
JOIN edge_devices ed ON ev.device_id = ed.id
JOIN edge_assets ea ON ed.asset_id = ea.id
WHERE ea.project_id = $1
AND ev.is_enabled = true
AND ed.is_enabled = true
AND ea.is_active = true"#
}
fn lf_variables_count_sql() -> &'static str {
r#"SELECT COUNT(DISTINCT lower(field_name))
FROM telemetry_raw tr
CROSS JOIN LATERAL (
SELECT field_name, field_value
FROM jsonb_each(tr.data) AS top_level(field_name, field_value)
UNION ALL
SELECT field_name, field_value
-- Expands values telemetry shape: jsonb_each(tr.data -> 'values')
FROM jsonb_each(
CASE
WHEN jsonb_typeof(tr.data -> 'values') = 'object' THEN tr.data -> 'values'
ELSE '{}'::jsonb
END
) AS values_fields(field_name, field_value)
UNION ALL
SELECT field_name, field_value
-- Expands manual capture shape: jsonb_each(tr.data -> 'manual_fields')
FROM jsonb_each(
CASE
WHEN jsonb_typeof(tr.data -> 'manual_fields') = 'object' THEN tr.data -> 'manual_fields'
ELSE '{}'::jsonb
END
) AS manual_fields(field_name, field_value)
) fields
WHERE tr.project_id = $1
AND tr.source IN ('PWA', 'MANUAL')
AND jsonb_typeof(field_value) = 'number'
AND lower(field_name) NOT IN (
'project_id', 'device_name', 'timestamp', 'asset_id', 'asset',
'last_updated', 'telemetry', 'time', 'data', 'id', 'source',
'frequency_class', 'created_at', 'updated_at', 'tipo',
'observaciones', 'values', 'variables', 'manual_fields', 'client_id'
)
AND NOT EXISTS (
SELECT 1
FROM edge_variables ev
JOIN edge_devices ed ON ev.device_id = ed.id
JOIN edge_assets ea ON ed.asset_id = ea.id
WHERE ea.project_id = tr.project_id
AND ev.is_enabled = true
AND ed.is_enabled = true
AND ea.is_active = true
AND lower(ev.alias) = lower(field_name)
)"#
}
async fn count_hf_variables(pool: &PgPool, project_id: Uuid) -> Result<i64, sqlx::Error> {
sqlx::query_scalar::<_, i64>(hf_variables_count_sql())
.bind(project_id)
.fetch_one(pool)
.await
}
async fn count_lf_variables(pool: &PgPool, project_id: Uuid) -> Result<i64, sqlx::Error> {
sqlx::query_scalar::<_, i64>(lf_variables_count_sql())
.bind(project_id)
.fetch_one(pool)
.await
}
async fn count_billing_variables(
pool: &PgPool,
project_id: Uuid,
) -> Result<BillingVariableCounts, sqlx::Error> {
let hf_variables = count_hf_variables(pool, project_id).await?;
let lf_variables = count_lf_variables(pool, project_id).await?;
Ok(BillingVariableCounts {
hf_variables,
lf_variables,
})
}
async fn fetch_commercial_profile_for_billing(
pool: &PgPool,
project_id: Uuid,
) -> Result<Option<CommercialProfileForBilling>, sqlx::Error> {
sqlx::query_as::<_, CommercialProfileForBilling>(
r#"SELECT id,
status,
plan,
payment_method,
payment_terms_days,
credit_limit_cop,
tg_limit::float8 AS tg_limit,
pbase_cop,
discount_k::float8 AS discount_k,
currency,
effective_from,
enterprise_annual_cop,
enterprise_monthly_cop,
enterprise_override_reason
FROM customer_commercial_profiles
WHERE project_id = $1
AND status IN ('approved', 'active', 'in_review', 'draft', 'suspended')
ORDER BY CASE status
WHEN 'active' THEN 0
WHEN 'approved' THEN 1
ELSE 2
END,
effective_from DESC,
updated_at DESC
LIMIT 1"#,
)
.bind(project_id)
.fetch_optional(pool)
.await
}
// ─── Handlers ─────────────────────────────────────────────────────────────── // ─── Handlers ───────────────────────────────────────────────────────────────
/// GET /api/billing/usage?project_id=UUID /// GET /api/billing/usage?project_id=UUID
@@ -173,10 +643,9 @@ pub async fn get_billing_usage(
}; };
// Get project name // Get project name
let project = sqlx::query!( let project =
"SELECT id, name FROM edge_projects WHERE id = $1", sqlx::query_as::<_, ProjectLookup>("SELECT name FROM edge_projects WHERE id = $1")
project_id .bind(project_id)
)
.fetch_optional(&pool) .fetch_optional(&pool)
.await; .await;
@@ -189,21 +658,11 @@ pub async fn get_billing_usage(
} }
}; };
// Count active tags // Count current billable variables from real persisted HF/LF sources.
let tag_count: i64 = match sqlx::query_scalar( let counts = match count_billing_variables(&pool, project_id).await {
r#"SELECT COUNT(*) Ok(counts) => counts,
FROM edge_variables ev
JOIN edge_devices ed ON ev.device_id = ed.id
JOIN edge_assets ea ON ed.asset_id = ea.id
WHERE ea.project_id = $1 AND ev.is_enabled = true"#,
)
.bind(project_id)
.fetch_one(&pool)
.await
{
Ok(c) => c,
Err(e) => { Err(e) => {
tracing::error!("DB error counting tags: {}", e); tracing::error!("DB error counting billing variables: {}", e);
return err(StatusCode::INTERNAL_SERVER_ERROR, "Database error"); return err(StatusCode::INTERNAL_SERVER_ERROR, "Database error");
} }
}; };
@@ -226,26 +685,47 @@ pub async fn get_billing_usage(
.await .await
.unwrap_or(None); .unwrap_or(None);
let (subtotal, minimum_applied, total) = let profile = match fetch_commercial_profile_for_billing(&pool, project_id).await {
calculate_billing(tag_count, sub.price_per_tag, sub.min_annual_fee); Ok(profile) => profile,
Err(e) => {
tracing::error!("DB error fetching commercial profile: {}", e);
return err(StatusCode::INTERNAL_SERVER_ERROR, "Database error");
}
};
Json(BillingUsage { if let Some(profile) = profile {
let billing = match build_profile_billing_response(&profile, counts) {
Ok(billing) => billing,
Err(error) => {
tracing::error!(?error, "Commercial billing calculation failed");
return err(
StatusCode::INTERNAL_SERVER_ERROR,
"Billing calculation error",
);
}
};
return Json(build_profile_billing_usage_response(
project_id, project_id,
project_name: project.name, project.name,
tier: sub.tier, &sub,
subscription_status: sub.status, counts,
active_tags: tag_count, billing,
tag_limit: sub.tag_limit,
price_per_tag: sub.price_per_tag,
min_annual_fee: sub.min_annual_fee,
subtotal_annual: subtotal,
minimum_applied,
total_annual_cop: total,
total_monthly_cop: total / 12,
period_start: sub.current_period_start,
period_end: sub.current_period_end,
last_snapshot, last_snapshot,
}) ))
.into_response();
}
let billing = calculate_current_billing(counts, sub.price_per_tag, sub.min_annual_fee);
Json(build_billing_usage_response(
project_id,
project.name,
&sub,
counts,
billing,
last_snapshot,
))
.into_response() .into_response()
} }
@@ -448,14 +928,13 @@ pub async fn take_usage_snapshot(
/// Idempotent daily snapshot for all active projects. Called by watchdog. /// Idempotent daily snapshot for all active projects. Called by watchdog.
pub async fn take_daily_snapshots(pool: &PgPool) -> anyhow::Result<usize> { pub async fn take_daily_snapshots(pool: &PgPool) -> anyhow::Result<usize> {
let projects = sqlx::query!("SELECT id FROM edge_projects WHERE is_active = true") let projects: Vec<Uuid> =
sqlx::query_scalar("SELECT id FROM edge_projects WHERE is_active = true")
.fetch_all(pool) .fetch_all(pool)
.await?; .await?;
let mut count = 0usize; let mut count = 0usize;
for row in &projects { for project_id in &projects {
let project_id = row.id;
let active_tags: i64 = sqlx::query_scalar( let active_tags: i64 = sqlx::query_scalar(
r#"SELECT COUNT(*) r#"SELECT COUNT(*)
FROM edge_variables ev FROM edge_variables ev
@@ -513,3 +992,289 @@ pub async fn take_daily_snapshots(pool: &PgPool) -> anyhow::Result<usize> {
tracing::info!("📊 Usage snapshots taken for {} projects", count); tracing::info!("📊 Usage snapshots taken for {} projects", count);
Ok(count) Ok(count)
} }
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn current_billing_uses_split_counts_and_official_legacy_price() {
let counts = BillingVariableCounts {
hf_variables: 7,
lf_variables: 5,
};
let billing = calculate_current_billing(counts, 90_000, 4_000_000);
assert_eq!(OFFICIAL_PRICE_PER_TAG_COP, 110_000);
assert_eq!(billing.active_tags, 12);
assert_eq!(billing.price_per_tag, 110_000);
assert_eq!(billing.subtotal_annual, 1_320_000);
assert!(billing.minimum_applied);
assert_eq!(billing.total_annual_cop, 4_000_000);
}
#[test]
fn current_billing_preserves_custom_non_legacy_price() {
let counts = BillingVariableCounts {
hf_variables: 20,
lf_variables: 3,
};
let billing = calculate_current_billing(counts, 125_000, 1_000_000);
assert_eq!(billing.active_tags, 23);
assert_eq!(billing.price_per_tag, 125_000);
assert_eq!(billing.subtotal_annual, 2_875_000);
assert!(!billing.minimum_applied);
assert_eq!(billing.total_annual_cop, 2_875_000);
}
#[test]
fn billing_usage_response_serializes_hf_lf_and_active_tag_total() {
let project_id = Uuid::parse_str("11111111-1111-1111-1111-111111111111").unwrap();
let now = Utc::now();
let subscription = Subscription {
id: Uuid::parse_str("22222222-2222-2222-2222-222222222222").unwrap(),
project_id,
tier: "starter".to_string(),
tag_limit: 50,
price_per_tag: 90_000,
min_annual_fee: 4_000_000,
status: "active".to_string(),
trial_ends_at: None,
current_period_start: now,
current_period_end: now,
notes: None,
created_at: now,
updated_at: now,
};
let counts = BillingVariableCounts {
hf_variables: 8,
lf_variables: 4,
};
let billing = calculate_current_billing(
counts,
subscription.price_per_tag,
subscription.min_annual_fee,
);
let usage = build_billing_usage_response(
project_id,
"Demo Project".to_string(),
&subscription,
counts,
billing,
None,
);
let serialized = serde_json::to_value(usage).unwrap();
assert_eq!(serialized["hf_variables"], 8);
assert_eq!(serialized["lf_variables"], 4);
assert_eq!(serialized["active_tags"], 12);
assert_eq!(
serialized["active_tags"].as_i64().unwrap(),
serialized["hf_variables"].as_i64().unwrap()
+ serialized["lf_variables"].as_i64().unwrap()
);
assert_eq!(serialized["price_per_tag"], OFFICIAL_PRICE_PER_TAG_COP);
}
#[test]
fn hf_variable_count_sql_scopes_enabled_variables_to_active_project_parents() {
let sql = hf_variables_count_sql();
assert!(sql.contains("FROM edge_variables ev"));
assert!(sql.contains("JOIN edge_devices ed ON ev.device_id = ed.id"));
assert!(sql.contains("JOIN edge_assets ea ON ed.asset_id = ea.id"));
assert!(sql.contains("ea.project_id = $1"));
assert!(sql.contains("ev.is_enabled = true"));
assert!(sql.contains("ed.is_enabled = true"));
assert!(sql.contains("ea.is_active = true"));
assert!(!sql.contains("ev.project_id"));
}
#[test]
fn lf_variable_count_sql_uses_real_manual_pwa_numeric_fields_only() {
let sql = lf_variables_count_sql();
assert!(sql.contains("FROM telemetry_raw tr"));
assert!(sql.contains("tr.project_id = $1"));
assert!(sql.contains("tr.source IN ('PWA', 'MANUAL')"));
assert!(sql.contains("jsonb_typeof(field_value) = 'number'"));
assert!(sql.contains("jsonb_each(tr.data)"));
assert!(sql.contains("jsonb_each(tr.data -> 'values')"));
assert!(sql.contains("jsonb_each(tr.data -> 'manual_fields')"));
assert!(sql.contains("COUNT(DISTINCT lower(field_name))"));
assert!(sql.contains("NOT IN"));
assert!(sql.contains("'timestamp'"));
assert!(sql.contains("'manual_fields'"));
assert!(sql.contains("lower(ev.alias) = lower(field_name)"));
assert!(!sql.contains("source = 'SCADA'"));
}
#[test]
fn free_commercial_profile_returns_non_collecting_zero_billing() {
let profile = CommercialProfileForBilling {
id: Uuid::parse_str("33333333-3333-3333-3333-333333333333").unwrap(),
status: "approved".to_string(),
plan: "free".to_string(),
payment_method: Some("none".to_string()),
payment_terms_days: Some(0),
credit_limit_cop: None,
tg_limit: Some(0.0),
pbase_cop: 110_000,
discount_k: 0.1,
currency: "COP".to_string(),
effective_from: chrono::NaiveDate::from_ymd_opt(2026, 6, 29).unwrap(),
enterprise_annual_cop: None,
enterprise_monthly_cop: None,
enterprise_override_reason: None,
};
let response = build_profile_billing_response(
&profile,
BillingVariableCounts {
hf_variables: 20,
lf_variables: 10,
},
)
.unwrap();
assert_eq!(response.total_annual_cop, 0);
assert_eq!(response.total_monthly_cop, 0);
assert_eq!(response.commercial_profile.plan, "free");
assert_eq!(response.commercial_profile.profile_complete, true);
assert_eq!(response.calculation_lines[0].amount_cop, 0);
assert_eq!(response.calculation_lines[0].kind, "free_non_collecting");
}
#[test]
fn operative_commercial_profile_uses_formula_not_flat_active_tags() {
let profile = CommercialProfileForBilling {
id: Uuid::parse_str("33333333-3333-3333-3333-333333333333").unwrap(),
status: "active".to_string(),
plan: "operative".to_string(),
payment_method: Some("manual_invoice".to_string()),
payment_terms_days: Some(30),
credit_limit_cop: Some(10_000_000),
tg_limit: Some(50.0),
pbase_cop: 110_000,
discount_k: 0.1,
currency: "COP".to_string(),
effective_from: chrono::NaiveDate::from_ymd_opt(2026, 6, 29).unwrap(),
enterprise_annual_cop: None,
enterprise_monthly_cop: None,
enterprise_override_reason: None,
};
let response = build_profile_billing_response(
&profile,
BillingVariableCounts {
hf_variables: 2,
lf_variables: 5,
},
)
.unwrap();
assert_eq!(response.active_tags, 7);
assert_eq!(response.commercial_profile.plan, "operative");
assert_eq!(response.formula.as_ref().unwrap().equivalent_tg, 4.0);
assert_ne!(response.total_annual_cop, 7 * 110_000);
assert_eq!(response.total_annual_cop, 383_042);
assert_eq!(response.calculation_lines[0].kind, "operative_formula");
}
#[test]
fn profile_backed_operative_billing_usage_serializes_effective_formula_prices() {
let project_id = Uuid::parse_str("11111111-1111-1111-1111-111111111111").unwrap();
let now = Utc::now();
let subscription = Subscription {
id: Uuid::parse_str("22222222-2222-2222-2222-222222222222").unwrap(),
project_id,
tier: "starter".to_string(),
tag_limit: 50,
price_per_tag: 90_000,
min_annual_fee: 4_000_000,
status: "active".to_string(),
trial_ends_at: None,
current_period_start: now,
current_period_end: now,
notes: None,
created_at: now,
updated_at: now,
};
let profile = CommercialProfileForBilling {
id: Uuid::parse_str("33333333-3333-3333-3333-333333333333").unwrap(),
status: "active".to_string(),
plan: "operative".to_string(),
payment_method: Some("manual_invoice".to_string()),
payment_terms_days: Some(30),
credit_limit_cop: Some(10_000_000),
tg_limit: Some(50.0),
pbase_cop: 110_000,
discount_k: 0.1,
currency: "COP".to_string(),
effective_from: chrono::NaiveDate::from_ymd_opt(2026, 6, 29).unwrap(),
enterprise_annual_cop: None,
enterprise_monthly_cop: None,
enterprise_override_reason: None,
};
let counts = BillingVariableCounts {
hf_variables: 2,
lf_variables: 5,
};
let billing = build_profile_billing_response(&profile, counts).unwrap();
let usage = build_profile_billing_usage_response(
project_id,
"Demo Project".to_string(),
&subscription,
counts,
billing,
None,
);
let serialized = serde_json::to_value(usage).unwrap();
assert_eq!(serialized["commercial_profile"]["plan"], "operative");
assert_eq!(serialized["formula"]["effective_hf_price_cop"], 95_761);
assert_eq!(serialized["formula"]["effective_lf_price_cop"], 38_304);
}
#[test]
fn enterprise_commercial_profile_uses_configured_override_source() {
let profile = CommercialProfileForBilling {
id: Uuid::parse_str("33333333-3333-3333-3333-333333333333").unwrap(),
status: "approved".to_string(),
plan: "enterprise".to_string(),
payment_method: Some("enterprise_contract".to_string()),
payment_terms_days: Some(45),
credit_limit_cop: Some(50_000_000),
tg_limit: Some(100.0),
pbase_cop: 110_000,
discount_k: 0.1,
currency: "COP".to_string(),
effective_from: chrono::NaiveDate::from_ymd_opt(2026, 6, 29).unwrap(),
enterprise_annual_cop: Some(24_000_000),
enterprise_monthly_cop: Some(2_000_000),
enterprise_override_reason: Some("Signed MSA".to_string()),
};
let response = build_profile_billing_response(
&profile,
BillingVariableCounts {
hf_variables: 8,
lf_variables: 4,
},
)
.unwrap();
assert_eq!(response.total_annual_cop, 24_000_000);
assert_eq!(response.total_monthly_cop, 2_000_000);
assert_eq!(response.calculation_lines[0].kind, "enterprise_override");
assert_eq!(
response.calculation_lines[0].source.as_deref(),
Some("Signed MSA")
);
}
}

View File

@@ -0,0 +1,112 @@
use crate::AppState;
use axum::{Json, extract::State, http::StatusCode, response::IntoResponse};
use serde::Deserialize;
use serde_json::Value;
#[derive(Deserialize, Debug)]
pub struct StripeEvent {
#[serde(rename = "type")]
pub event_type: String,
pub data: StripeData,
}
#[derive(Deserialize, Debug)]
pub struct StripeData {
pub object: StripeObject,
}
#[derive(Deserialize, Debug)]
pub struct StripeObject {
pub client_reference_id: Option<String>,
pub metadata: Option<Value>,
}
pub async fn stripe_webhook(
State(state): State<AppState>,
Json(payload): Json<StripeEvent>,
) -> impl IntoResponse {
tracing::info!("📩 Received Stripe Webhook event: {}", payload.event_type);
let event_type = payload.event_type.as_str();
if event_type == "checkout.session.completed"
|| event_type == "customer.subscription.updated"
|| event_type == "customer.subscription.created"
{
// Try to get project ID from client_reference_id or metadata
let project_id_str = payload.data.object.client_reference_id.clone().or_else(|| {
payload
.data
.object
.metadata
.as_ref()
.and_then(|m| m.get("project_id"))
.and_then(|v| v.as_str().map(|s| s.to_string()))
});
if let Some(ref id_str) = project_id_str {
if let Ok(project_id) = uuid::Uuid::parse_str(id_str) {
// Update subscription status to 'active'
// Upgrade tier to 'standard' and set limit to 200 variables
let update_result = sqlx::query(
r#"UPDATE subscriptions
SET status = 'active', tier = 'standard', tag_limit = 200, updated_at = NOW()
WHERE project_id = $1"#,
)
.bind(project_id)
.execute(&state.pool)
.await;
match update_result {
Ok(res) => {
if res.rows_affected() > 0 {
tracing::info!(
"✅ Successfully activated subscription for project: {}",
project_id
);
} else {
// If no subscription exists for this project, let's insert one!
let insert_result = sqlx::query(
r#"INSERT INTO subscriptions (project_id, tier, tag_limit, status, notes)
VALUES ($1, 'standard', 200, 'active', 'Creado vía Stripe Webhook')"#,
)
.bind(project_id)
.execute(&state.pool)
.await;
if insert_result.is_ok() {
tracing::info!(
"✅ Created standard active subscription for project: {}",
project_id
);
} else {
tracing::error!(
"❌ Failed to insert active subscription for project: {}",
project_id
);
}
}
}
Err(e) => {
tracing::error!(
"❌ Failed to update subscription for project {}: {:?}",
project_id,
e
);
return (StatusCode::INTERNAL_SERVER_ERROR, "Database error")
.into_response();
}
}
} else {
tracing::warn!(
"⚠️ Stripe event contained invalid UUID project ID: {}",
id_str
);
}
} else {
tracing::warn!("⚠️ Stripe event did not contain project_id / client_reference_id");
}
}
(StatusCode::OK, "Webhook processed successfully").into_response()
}

View File

@@ -1,8 +1,8 @@
use axum::{extract::State, response::IntoResponse, Json};
use chrono::{DateTime, Utc};
use serde_json::{json, Value};
use crate::db::DbPool;
use crate::auth::Claims; use crate::auth::Claims;
use crate::db::DbPool;
use axum::{Json, extract::State, response::IntoResponse};
use chrono::{DateTime, Utc};
use serde_json::{Value, json};
use uuid::Uuid; use uuid::Uuid;
#[derive(sqlx::FromRow)] #[derive(sqlx::FromRow)]
@@ -11,12 +11,10 @@ struct TelemetryRow {
asset_code: String, asset_code: String,
time: DateTime<Utc>, time: DateTime<Utc>,
data: Value, data: Value,
source: String,
} }
pub async fn get_summary( pub async fn get_summary(State(pool): State<DbPool>, claims: Claims) -> impl IntoResponse {
State(pool): State<DbPool>,
claims: Claims,
) -> impl IntoResponse {
let user_id = Uuid::parse_str(&claims.sub).unwrap(); let user_id = Uuid::parse_str(&claims.sub).unwrap();
let records = sqlx::query_as::<_, TelemetryRow>( let records = sqlx::query_as::<_, TelemetryRow>(
@@ -25,7 +23,8 @@ pub async fn get_summary(
t.asset_id, t.asset_id,
a.code as asset_code, a.code as asset_code,
t.time, t.time,
t.data t.data,
t.source
FROM telemetry_raw t FROM telemetry_raw t
JOIN edge_assets a ON t.asset_id = a.id JOIN edge_assets a ON t.asset_id = a.id
JOIN user_project_access upa ON a.project_id = upa.project_id JOIN user_project_access upa ON a.project_id = upa.project_id
@@ -46,6 +45,7 @@ pub async fn get_summary(
"asset_id": row.asset_id, "asset_id": row.asset_id,
"asset": row.asset_code, "asset": row.asset_code,
"last_updated": row.time, "last_updated": row.time,
"source": row.source,
"telemetry": row.data "telemetry": row.data
}) })
}) })

View File

@@ -1,7 +1,8 @@
use axum::response::Html; use axum::response::Html;
pub async fn swagger_ui() -> Html<String> { pub async fn swagger_ui() -> Html<String> {
Html(format!(r#"<!DOCTYPE html> Html(format!(
r#"<!DOCTYPE html>
<html lang="es"> <html lang="es">
<head> <head>
<meta charset="UTF-8"> <meta charset="UTF-8">
@@ -36,7 +37,8 @@ pub async fn swagger_ui() -> Html<String> {
}}); }});
</script> </script>
</body> </body>
</html>"#)) </html>"#
))
} }
pub async fn openapi_yaml() -> axum::response::Response { pub async fn openapi_yaml() -> axum::response::Response {

View File

@@ -1,5 +1,6 @@
use crate::auth::Claims; use crate::auth::Claims;
use crate::errors::AppError; use crate::errors::AppError;
use crate::handlers::well_access;
use axum::{ use axum::{
Json, Json,
extract::{Query, State}, extract::{Query, State},
@@ -34,20 +35,14 @@ pub async fn list_downtime(
let user_id = Uuid::parse_str(&claims.sub) let user_id = Uuid::parse_str(&claims.sub)
.map_err(|_| AppError::BadRequest("User ID inválido".to_string()))?; .map_err(|_| AppError::BadRequest("User ID inválido".to_string()))?;
let access = sqlx::query( well_access::ensure_asset_access(
r#"SELECT 1 FROM edge_assets ea &pool,
JOIN user_project_access upa ON ea.project_id = upa.project_id user_id,
WHERE ea.id = $1 AND upa.user_id = $2 AND upa.is_active = true"#, params.asset_id,
"Sin permiso para ver paradas de este activo",
) )
.bind(params.asset_id)
.bind(user_id)
.fetch_optional(&pool)
.await?; .await?;
if access.is_none() {
return Err(AppError::Forbidden("Sin permiso para ver paradas de este activo".to_string()));
}
let downtime: Vec<OperationDowntime> = sqlx::query_as::<Postgres, OperationDowntime>( let downtime: Vec<OperationDowntime> = sqlx::query_as::<Postgres, OperationDowntime>(
"SELECT * FROM operations_downtime WHERE asset_id = $1 ORDER BY start_time DESC", "SELECT * FROM operations_downtime WHERE asset_id = $1 ORDER BY start_time DESC",
) )
@@ -67,20 +62,14 @@ pub async fn create_downtime(
let user_id = Uuid::parse_str(&claims.sub) let user_id = Uuid::parse_str(&claims.sub)
.map_err(|_| AppError::BadRequest("User ID inválido".to_string()))?; .map_err(|_| AppError::BadRequest("User ID inválido".to_string()))?;
let access = sqlx::query( well_access::ensure_asset_access(
r#"SELECT 1 FROM edge_assets ea &pool,
JOIN user_project_access upa ON ea.project_id = upa.project_id user_id,
WHERE ea.id = $1 AND upa.user_id = $2 AND upa.is_active = true"#, req.asset_id,
"Sin permiso para registrar paradas en este activo",
) )
.bind(req.asset_id)
.bind(user_id)
.fetch_optional(&pool)
.await?; .await?;
if access.is_none() {
return Err(AppError::Forbidden("Sin permiso para registrar paradas en este activo".to_string()));
}
let downtime: OperationDowntime = sqlx::query_as::<Postgres, OperationDowntime>( let downtime: OperationDowntime = sqlx::query_as::<Postgres, OperationDowntime>(
r#"INSERT INTO operations_downtime ( r#"INSERT INTO operations_downtime (
asset_id, start_time, end_time, is_planned, reason, comments asset_id, start_time, end_time, is_planned, reason, comments

View File

@@ -2,18 +2,18 @@
//! //!
//! Este módulo centraliza la telemetría enviada por los agentes instalados en las plantas. //! Este módulo centraliza la telemetría enviada por los agentes instalados en las plantas.
use crate::AppState;
use crate::auth::Claims;
use axum::{ use axum::{
Json,
extract::{Path, Query, State}, extract::{Path, Query, State},
http::StatusCode, http::StatusCode,
Json,
}; };
use rumqttc::QoS;
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use shared_lib::edge_models::*; use shared_lib::edge_models::*;
use sqlx::PgPool; use sqlx::PgPool;
use uuid::Uuid; use uuid::Uuid;
use crate::auth::Claims;
use crate::AppState;
use rumqttc::QoS;
use crate::handlers::update_info::fetch_sha256_internal; use crate::handlers::update_info::fetch_sha256_internal;
@@ -120,6 +120,8 @@ pub async fn trigger_ota_update(
)) ))
} }
use crate::errors::AppError;
#[derive(Debug, Deserialize)] #[derive(Debug, Deserialize)]
pub struct LogQueryParams { pub struct LogQueryParams {
pub level: Option<String>, pub level: Option<String>,
@@ -128,8 +130,6 @@ pub struct LogQueryParams {
pub limit: Option<i64>, pub limit: Option<i64>,
} }
use crate::errors::AppError;
/// GET /api/edge/agents/logs /// GET /api/edge/agents/logs
pub async fn list_agent_logs( pub async fn list_agent_logs(
State(pool): State<PgPool>, State(pool): State<PgPool>,
@@ -224,7 +224,9 @@ pub async fn get_agent_health(
.await?; .await?;
if access.is_none() { if access.is_none() {
return Err(AppError::Forbidden("Sin acceso al estado de este proyecto".to_string())); return Err(AppError::Forbidden(
"Sin acceso al estado de este proyecto".to_string(),
));
} }
} }
// Obtener último log de health para saber si sigue comunicando // Obtener último log de health para saber si sigue comunicando

View File

@@ -1,22 +1,26 @@
//! Handlers para activos (Pozos, Tanques, etc.) //! Handlers para activos (Pozos, Tanques, etc.)
use crate::auth::Claims;
use crate::handlers::well_access;
use axum::{ use axum::{
Json,
extract::{Path, Query, State}, extract::{Path, Query, State},
http::StatusCode, http::StatusCode,
Json,
}; };
use sqlx::PgPool;
use uuid::Uuid;
use serde::Deserialize; use serde::Deserialize;
use serde_json::Value; use serde_json::Value;
use shared_lib::models::{EdgeAsset, CreateEdgeAssetRequest}; use shared_lib::models::{CreateEdgeAssetRequest, EdgeAsset};
use crate::auth::Claims; use sqlx::PgPool;
use uuid::Uuid;
#[derive(Deserialize)] #[derive(Deserialize)]
pub struct UpdateAssetRequest { pub struct UpdateAssetRequest {
pub name: String, pub name: String,
pub is_active: bool, pub is_active: bool,
pub metadata: Option<Value>, pub metadata: Option<Value>,
pub last_calibration_date: Option<chrono::NaiveDate>,
pub next_calibration_date: Option<chrono::NaiveDate>,
pub calibration_certificate_url: Option<String>,
pub external_api_url: Option<String>, pub external_api_url: Option<String>,
pub external_api_key: Option<String>, pub external_api_key: Option<String>,
pub is_collector_enabled: Option<bool>, pub is_collector_enabled: Option<bool>,
@@ -30,24 +34,12 @@ pub async fn list_assets_by_project(
claims: Claims, claims: Claims,
Path(project_id): Path<Uuid>, Path(project_id): Path<Uuid>,
) -> Result<Json<Vec<EdgeAsset>>, AppError> { ) -> Result<Json<Vec<EdgeAsset>>, AppError> {
// Validar acceso al proyecto
let user_id = Uuid::parse_str(&claims.sub) let user_id = Uuid::parse_str(&claims.sub)
.map_err(|_| AppError::BadRequest("User ID inválido".to_string()))?; .map_err(|_| AppError::BadRequest("User ID inválido".to_string()))?;
let access = sqlx::query("SELECT 1 FROM user_project_access WHERE user_id = $1 AND project_id = $2 AND is_active = true") let assets = sqlx::query_as::<_, EdgeAsset>(well_access::project_assets_sql())
.bind(project_id)
.bind(user_id) .bind(user_id)
.bind(project_id)
.fetch_optional(&pool)
.await?;
if access.is_none() {
return Err(AppError::Forbidden("Sin acceso a los activos de este proyecto".to_string()));
}
let assets = sqlx::query_as::<_, EdgeAsset>(
"SELECT * FROM edge_assets WHERE project_id = $1 ORDER BY created_at DESC"
)
.bind(project_id)
.fetch_all(&pool) .fetch_all(&pool)
.await?; .await?;
@@ -71,13 +63,19 @@ pub async fn create_asset(
.await?; .await?;
if access.is_none() { if access.is_none() {
return Err(AppError::Forbidden("Sin permiso para crear activos en este proyecto".to_string())); return Err(AppError::Forbidden(
"Sin permiso para crear activos en este proyecto".to_string(),
));
} }
let asset = sqlx::query_as::<_, EdgeAsset>( let asset = sqlx::query_as::<_, EdgeAsset>(
r#"INSERT INTO edge_assets (project_id, code, name, asset_type, metadata, external_api_url, external_api_key, is_collector_enabled) r#"INSERT INTO edge_assets (
VALUES ($1, $2, $3, $4, $5, $6, $7, $8) project_id, code, name, asset_type, metadata,
RETURNING *"# external_api_url, external_api_key, is_collector_enabled,
last_calibration_date, next_calibration_date, calibration_certificate_url
)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11)
RETURNING *"#,
) )
.bind(req.project_id) .bind(req.project_id)
.bind(&req.code) .bind(&req.code)
@@ -87,6 +85,9 @@ pub async fn create_asset(
.bind(req.external_api_url) .bind(req.external_api_url)
.bind(req.external_api_key) .bind(req.external_api_key)
.bind(req.is_collector_enabled.unwrap_or(false)) .bind(req.is_collector_enabled.unwrap_or(false))
.bind(req.last_calibration_date)
.bind(req.next_calibration_date)
.bind(&req.calibration_certificate_url)
.fetch_one(&pool) .fetch_one(&pool)
.await?; .await?;
@@ -147,15 +148,18 @@ pub async fn update_asset(
.await?; .await?;
if access.is_none() { if access.is_none() {
return Err(AppError::Forbidden("Sin permiso para actualizar este activo".to_string())); return Err(AppError::Forbidden(
"Sin permiso para actualizar este activo".to_string(),
));
} }
let updated = sqlx::query_as::<_, EdgeAsset>( let updated = sqlx::query_as::<_, EdgeAsset>(
r#"UPDATE edge_assets r#"UPDATE edge_assets
SET name = $1, is_active = $2, metadata = $3, SET name = $1, is_active = $2, metadata = $3,
external_api_url = $4, external_api_key = $5, is_collector_enabled = $6, external_api_url = $4, external_api_key = $5, is_collector_enabled = $6,
last_calibration_date = $7, next_calibration_date = $8, calibration_certificate_url = $9,
updated_at = NOW() updated_at = NOW()
WHERE id = $7 WHERE id = $10
RETURNING *"# RETURNING *"#
) )
.bind(&req.name) .bind(&req.name)
@@ -164,6 +168,9 @@ pub async fn update_asset(
.bind(req.external_api_url) .bind(req.external_api_url)
.bind(req.external_api_key) .bind(req.external_api_key)
.bind(req.is_collector_enabled.unwrap_or(false)) .bind(req.is_collector_enabled.unwrap_or(false))
.bind(req.last_calibration_date)
.bind(req.next_calibration_date)
.bind(&req.calibration_certificate_url)
.bind(id) .bind(id)
.fetch_optional(&pool) .fetch_optional(&pool)
.await? .await?
@@ -183,8 +190,9 @@ pub async fn list_tanks_by_project(
claims: Claims, claims: Claims,
Query(params): Query<TankQueryParams>, Query(params): Query<TankQueryParams>,
) -> Result<Json<Vec<EdgeAsset>>, AppError> { ) -> Result<Json<Vec<EdgeAsset>>, AppError> {
let project_id = params.project_id let project_id = params.project_id.ok_or(AppError::BadRequest(
.ok_or(AppError::BadRequest("project_id es obligatorio".to_string()))?; "project_id es obligatorio".to_string(),
))?;
// Validar acceso al proyecto // Validar acceso al proyecto
let user_id = Uuid::parse_str(&claims.sub) let user_id = Uuid::parse_str(&claims.sub)
@@ -197,7 +205,9 @@ pub async fn list_tanks_by_project(
.await?; .await?;
if access.is_none() { if access.is_none() {
return Err(AppError::Forbidden("Sin acceso a los tanques de este proyecto".to_string())); return Err(AppError::Forbidden(
"Sin acceso a los tanques de este proyecto".to_string(),
));
} }
let tanks = sqlx::query_as::<_, EdgeAsset>( let tanks = sqlx::query_as::<_, EdgeAsset>(
@@ -234,7 +244,9 @@ pub async fn delete_asset(
.await?; .await?;
if access.is_none() { if access.is_none() {
return Err(AppError::Forbidden("Sin permiso para eliminar este activo".to_string())); return Err(AppError::Forbidden(
"Sin permiso para eliminar este activo".to_string(),
));
} }
sqlx::query("DELETE FROM edge_assets WHERE id = $1") sqlx::query("DELETE FROM edge_assets WHERE id = $1")

View File

@@ -14,6 +14,43 @@ use sqlx::PgPool;
use uuid::Uuid; use uuid::Uuid;
use chrono; use chrono;
async fn mark_build_dispatch_failed(
pool: &PgPool,
project_id: Uuid,
deployment_id: Uuid,
error_message: &str,
) {
if let Err(e) = sqlx::query(
r#"UPDATE edge_deployments
SET status = 'FAILED', error_message = $1
WHERE id = $2"#,
)
.bind(error_message)
.bind(deployment_id)
.execute(pool)
.await
{
tracing::error!(
"Failed to mark deployment {} as FAILED after build dispatch error: {}",
deployment_id,
e
);
}
if let Err(e) =
sqlx::query("UPDATE edge_projects SET installer_status = 'FAILED' WHERE id = $1")
.bind(project_id)
.execute(pool)
.await
{
tracing::error!(
"Failed to mark project {} installer as FAILED after build dispatch error: {}",
project_id,
e
);
}
}
/// GET /api/edge/projects/:project_id/deployments /// GET /api/edge/projects/:project_id/deployments
/// ///
/// Retorna los últimos 50 registros de despliegue para un proyecto específico. /// Retorna los últimos 50 registros de despliegue para un proyecto específico.
@@ -113,10 +150,11 @@ pub async fn trigger_build(
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?; .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
// 4. Sincronizar estado en la tabla de proyectos para visibilidad inmediata en UI // 4. Sincronizar estado en la tabla de proyectos para visibilidad inmediata en UI
let _ = sqlx::query("UPDATE edge_projects SET installer_status = 'BUILDING' WHERE id = $1") sqlx::query("UPDATE edge_projects SET installer_status = 'BUILDING' WHERE id = $1")
.bind(project_id) .bind(project_id)
.execute(&pool) .execute(&pool)
.await; .await
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
// ─── Dispatch Build Request via MQTT ────────────────────────────── // ─── Dispatch Build Request via MQTT ──────────────────────────────
let event = shared_lib::mqtt_messages::ProjectCreatedEvent { let event = shared_lib::mqtt_messages::ProjectCreatedEvent {
@@ -127,19 +165,34 @@ pub async fn trigger_build(
timestamp: chrono::Utc::now(), timestamp: chrono::Utc::now(),
}; };
if let Ok(payload) = serde_json::to_string(&event) { let payload = serde_json::to_string(&event).map_err(|e| {
let mqtt_clone = mqtt.clone(); let message = format!("Failed to serialize ProjectCreatedEvent: {}", e);
tracing::info!("📡 Enviando señal de construcción para despliegue {}: {}", deployment.id, payload); (StatusCode::INTERNAL_SERVER_ERROR, message)
tokio::spawn(async move { })?;
let _ = mqtt_clone
tracing::info!(
"📡 Enviando señal de construcción para despliegue {}: {}",
deployment.id,
payload
);
if let Err(e) = mqtt
.publish( .publish(
"omnioil/events/project_created", "omnioil/events/project_created",
rumqttc::QoS::AtLeastOnce, rumqttc::QoS::AtLeastOnce,
false, false,
payload, payload,
) )
.await; .await
}); {
let message = format!("MQTT publish failed for installer build request: {}", e);
tracing::error!(
"{} (project={}, deployment={})",
message,
project_id,
deployment.id
);
mark_build_dispatch_failed(&pool, project_id, deployment.id, &message).await;
return Err((StatusCode::INTERNAL_SERVER_ERROR, message));
} }
Ok((StatusCode::ACCEPTED, Json(deployment))) Ok((StatusCode::ACCEPTED, Json(deployment)))

View File

@@ -1,14 +1,14 @@
//! Handlers para CRUD de dispositivos Edge. //! Handlers para CRUD de dispositivos Edge.
use crate::auth::Claims;
use axum::{ use axum::{
Json,
extract::{Path, State}, extract::{Path, State},
http::StatusCode, http::StatusCode,
Json,
}; };
use shared_lib::edge_models::*;
use sqlx::PgPool; use sqlx::PgPool;
use uuid::Uuid; use uuid::Uuid;
use shared_lib::edge_models::*;
use crate::auth::Claims;
/// GET /api/edge/assets/:asset_id/devices — Listar dispositivos de un activo. /// GET /api/edge/assets/:asset_id/devices — Listar dispositivos de un activo.
pub async fn list_devices( pub async fn list_devices(
@@ -21,7 +21,7 @@ pub async fn list_devices(
let access = sqlx::query( let access = sqlx::query(
r#"SELECT 1 FROM edge_assets ea r#"SELECT 1 FROM edge_assets ea
JOIN user_project_access upa ON ea.project_id = upa.project_id JOIN user_project_access upa ON ea.project_id = upa.project_id
WHERE ea.id = $1 AND upa.user_id = $2 AND upa.is_active = true"# WHERE ea.id = $1 AND upa.user_id = $2 AND upa.is_active = true"#,
) )
.bind(asset_id) .bind(asset_id)
.bind(Uuid::parse_str(&claims.sub).unwrap()) .bind(Uuid::parse_str(&claims.sub).unwrap())
@@ -30,12 +30,15 @@ pub async fn list_devices(
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?; .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
if access.is_none() { if access.is_none() {
return Err((StatusCode::FORBIDDEN, "Access denied to this asset's devices".to_string())); return Err((
StatusCode::FORBIDDEN,
"Access denied to this asset's devices".to_string(),
));
} }
} }
let devices = sqlx::query_as::<_, EdgeDevice>( let devices = sqlx::query_as::<_, EdgeDevice>(
"SELECT * FROM edge_devices WHERE asset_id = $1 ORDER BY name" "SELECT * FROM edge_devices WHERE asset_id = $1 ORDER BY name",
) )
.bind(asset_id) .bind(asset_id)
.fetch_all(&pool) .fetch_all(&pool)
@@ -57,7 +60,7 @@ pub async fn create_device(
let access = sqlx::query( let access = sqlx::query(
r#"SELECT 1 FROM edge_assets ea r#"SELECT 1 FROM edge_assets ea
JOIN user_project_access upa ON ea.project_id = upa.project_id JOIN user_project_access upa ON ea.project_id = upa.project_id
WHERE ea.id = $1 AND upa.user_id = $2 AND upa.is_active = true"# WHERE ea.id = $1 AND upa.user_id = $2 AND upa.is_active = true"#,
) )
.bind(asset_id) .bind(asset_id)
.bind(Uuid::parse_str(&claims.sub).unwrap()) .bind(Uuid::parse_str(&claims.sub).unwrap())
@@ -66,14 +69,17 @@ pub async fn create_device(
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?; .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
if access.is_none() { if access.is_none() {
return Err((StatusCode::FORBIDDEN, "Access denied to create devices for this asset".to_string())); return Err((
StatusCode::FORBIDDEN,
"Access denied to create devices for this asset".to_string(),
));
} }
} }
let device = sqlx::query_as::<_, EdgeDevice>( let device = sqlx::query_as::<_, EdgeDevice>(
r#"INSERT INTO edge_devices (asset_id, name, protocol, host, port, protocol_config) r#"INSERT INTO edge_devices (asset_id, name, protocol, host, port, protocol_config)
VALUES ($1, $2, $3, $4, $5, $6) VALUES ($1, $2, $3, $4, $5, $6)
RETURNING *"# RETURNING *"#,
) )
.bind(asset_id) .bind(asset_id)
.bind(&req.name) .bind(&req.name)
@@ -101,7 +107,7 @@ pub async fn update_device(
r#"SELECT 1 FROM edge_devices ed r#"SELECT 1 FROM edge_devices ed
JOIN edge_assets ea ON ed.asset_id = ea.id JOIN edge_assets ea ON ed.asset_id = ea.id
JOIN user_project_access upa ON ea.project_id = upa.project_id JOIN user_project_access upa ON ea.project_id = upa.project_id
WHERE ed.id = $1 AND upa.user_id = $2 AND upa.is_active = true"# WHERE ed.id = $1 AND upa.user_id = $2 AND upa.is_active = true"#,
) )
.bind(id) .bind(id)
.bind(Uuid::parse_str(&claims.sub).unwrap()) .bind(Uuid::parse_str(&claims.sub).unwrap())
@@ -110,7 +116,10 @@ pub async fn update_device(
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?; .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
if access.is_none() { if access.is_none() {
return Err((StatusCode::FORBIDDEN, "Access denied to this device".to_string())); return Err((
StatusCode::FORBIDDEN,
"Access denied to this device".to_string(),
));
} }
} }
@@ -123,7 +132,7 @@ pub async fn update_device(
protocol_config = COALESCE($6, protocol_config), protocol_config = COALESCE($6, protocol_config),
is_enabled = COALESCE($7, is_enabled) is_enabled = COALESCE($7, is_enabled)
WHERE id = $1 WHERE id = $1
RETURNING *"# RETURNING *"#,
) )
.bind(id) .bind(id)
.bind(&req.name) .bind(&req.name)
@@ -152,7 +161,7 @@ pub async fn delete_device(
r#"SELECT 1 FROM edge_devices ed r#"SELECT 1 FROM edge_devices ed
JOIN edge_assets ea ON ed.asset_id = ea.id JOIN edge_assets ea ON ed.asset_id = ea.id
JOIN user_project_access upa ON ea.project_id = upa.project_id JOIN user_project_access upa ON ea.project_id = upa.project_id
WHERE ed.id = $1 AND upa.user_id = $2 AND upa.is_active = true"# WHERE ed.id = $1 AND upa.user_id = $2 AND upa.is_active = true"#,
) )
.bind(id) .bind(id)
.bind(Uuid::parse_str(&claims.sub).unwrap()) .bind(Uuid::parse_str(&claims.sub).unwrap())
@@ -161,7 +170,10 @@ pub async fn delete_device(
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?; .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
if access.is_none() { if access.is_none() {
return Err((StatusCode::FORBIDDEN, "Access denied to delete this device".to_string())); return Err((
StatusCode::FORBIDDEN,
"Access denied to delete this device".to_string(),
));
} }
} }
@@ -221,18 +233,25 @@ pub async fn trigger_opcua_scan(
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))? .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?
.ok_or_else(|| (StatusCode::NOT_FOUND, "Device not found".to_string()))?; .ok_or_else(|| (StatusCode::NOT_FOUND, "Device not found".to_string()))?;
let protocol: String = row.try_get("protocol") let protocol: String = row
.try_get("protocol")
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?; .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
let host: String = row.try_get("host") let host: String = row
.try_get("host")
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?; .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
let port: i32 = row.try_get("port") let port: i32 = row
.try_get("port")
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?; .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
let project_id: Uuid = row.try_get("project_id") let project_id: Uuid = row
.try_get("project_id")
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?; .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
// Verify this is an OPC UA device // Verify this is an OPC UA device
if protocol != "OPC_UA" { if protocol != "OPC_UA" {
return Err((StatusCode::BAD_REQUEST, "Device protocol is not OPC_UA".to_string())); return Err((
StatusCode::BAD_REQUEST,
"Device protocol is not OPC_UA".to_string(),
));
} }
// Check access // Check access
@@ -281,17 +300,25 @@ pub async fn trigger_opcua_scan(
"issued_at": chrono::Utc::now().to_rfc3339(), "issued_at": chrono::Utc::now().to_rfc3339(),
}); });
let payload = serde_json::to_vec(&cmd) let payload =
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?; serde_json::to_vec(&cmd).map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
state.mqtt_client state
.mqtt_client
.publish(&topic, QoS::AtLeastOnce, false, payload) .publish(&topic, QoS::AtLeastOnce, false, payload)
.await .await
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("MQTT publish failed: {}", e)))?; .map_err(|e| {
(
StatusCode::INTERNAL_SERVER_ERROR,
format!("MQTT publish failed: {}", e),
)
})?;
tracing::info!( tracing::info!(
"🔍 OPC UA scan triggered for device {} (project {}). Scan ID: {}", "🔍 OPC UA scan triggered for device {} (project {}). Scan ID: {}",
device_id, project_id, scan_id device_id,
project_id,
scan_id
); );
Ok(( Ok((
@@ -323,7 +350,7 @@ pub async fn get_opcua_scan_result(
r#"SELECT 1 FROM edge_devices ed r#"SELECT 1 FROM edge_devices ed
JOIN edge_assets ea ON ed.asset_id = ea.id JOIN edge_assets ea ON ed.asset_id = ea.id
JOIN user_project_access upa ON ea.project_id = upa.project_id JOIN user_project_access upa ON ea.project_id = upa.project_id
WHERE ed.id = $1 AND upa.user_id = $2 AND upa.is_active = true"# WHERE ed.id = $1 AND upa.user_id = $2 AND upa.is_active = true"#,
) )
.bind(device_id) .bind(device_id)
.bind(user_id) .bind(user_id)
@@ -345,15 +372,20 @@ pub async fn get_opcua_scan_result(
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))? .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?
.ok_or_else(|| (StatusCode::NOT_FOUND, "Scan not found".to_string()))?; .ok_or_else(|| (StatusCode::NOT_FOUND, "Scan not found".to_string()))?;
let status: String = row.try_get("status") let status: String = row
.try_get("status")
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?; .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
let result: Option<serde_json::Value> = row.try_get("result") let result: Option<serde_json::Value> = row
.try_get("result")
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?; .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
let error_message: Option<String> = row.try_get("error_message") let error_message: Option<String> = row
.try_get("error_message")
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?; .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
let created_at: chrono::DateTime<chrono::Utc> = row.try_get("created_at") let created_at: chrono::DateTime<chrono::Utc> = row
.try_get("created_at")
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?; .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
let expires_at: chrono::DateTime<chrono::Utc> = row.try_get("expires_at") let expires_at: chrono::DateTime<chrono::Utc> = row
.try_get("expires_at")
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?; .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
Ok(Json(serde_json::json!({ Ok(Json(serde_json::json!({

View File

@@ -1,15 +1,15 @@
//! Handlers para CRUD de proyectos Edge. //! Handlers para CRUD de proyectos Edge.
use crate::auth::Claims;
use crate::handlers::anh_reports::ensure_default_anh_schedule_for_confirmed_contract;
use axum::{ use axum::{
Json, Json,
extract::{Path, State}, extract::{Path, Query, State},
http::StatusCode, http::StatusCode,
}; };
use shared_lib::edge_models::*; use shared_lib::edge_models::*;
use crate::auth::Claims; use sqlx::{PgPool, Row};
use sqlx::PgPool;
use uuid::Uuid; use uuid::Uuid;
use chrono;
/// GET /api/edge/projects — Listar todos los proyectos. /// GET /api/edge/projects — Listar todos los proyectos.
pub async fn list_projects( pub async fn list_projects(
@@ -22,7 +22,10 @@ pub async fn list_projects(
WHERE upa.user_id = $1 AND upa.is_active = true WHERE upa.user_id = $1 AND upa.is_active = true
ORDER BY p.created_at DESC"#, ORDER BY p.created_at DESC"#,
) )
.bind(Uuid::parse_str(&claims.sub).map_err(|_| (StatusCode::UNAUTHORIZED, "Invalid user ID".to_string()))?) .bind(
Uuid::parse_str(&claims.sub)
.map_err(|_| (StatusCode::UNAUTHORIZED, "Invalid user ID".to_string()))?,
)
.fetch_all(&pool) .fetch_all(&pool)
.await .await
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?; .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
@@ -39,9 +42,15 @@ pub async fn create_project(
) -> Result<(StatusCode, Json<EdgeProject>), (StatusCode, String)> { ) -> Result<(StatusCode, Json<EdgeProject>), (StatusCode, String)> {
// Solo admins pueden crear proyectos globales // Solo admins pueden crear proyectos globales
if claims.role != "admin" { if claims.role != "admin" {
return Err((StatusCode::FORBIDDEN, "Only admins can create projects".to_string())); return Err((
StatusCode::FORBIDDEN,
"Only admins can create projects".to_string(),
));
} }
let mut tx = pool.begin().await.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?; let mut tx = pool
.begin()
.await
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
let project = sqlx::query_as::<_, EdgeProject>( let project = sqlx::query_as::<_, EdgeProject>(
r#"INSERT INTO edge_projects (name, client, operator_name, contract_number, description, metadata) r#"INSERT INTO edge_projects (name, client, operator_name, contract_number, description, metadata)
@@ -59,7 +68,21 @@ pub async fn create_project(
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?; .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
// Autolink al creador as owner // Autolink al creador as owner
let user_id = Uuid::parse_str(&claims.sub).map_err(|_| (StatusCode::UNAUTHORIZED, "Invalid user ID in token".to_string()))?; let user_id = Uuid::parse_str(&claims.sub).map_err(|_| {
(
StatusCode::UNAUTHORIZED,
"Invalid user ID in token".to_string(),
)
})?;
ensure_default_anh_schedule_for_confirmed_contract(
&mut *tx,
&req.operator_name,
&req.contract_number,
Some(user_id),
)
.await
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("{:?}", e)))?;
sqlx::query( sqlx::query(
"INSERT INTO user_project_access (user_id, project_id, project_role) VALUES ($1, $2, 'owner')" "INSERT INTO user_project_access (user_id, project_id, project_role) VALUES ($1, $2, 'owner')"
@@ -70,7 +93,9 @@ pub async fn create_project(
.await .await
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?; .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
tx.commit().await.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?; tx.commit()
.await
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
// ─── Dispatch Domain Event via MQTT (Internal Messaging) ────────────────────────────── // ─── Dispatch Domain Event via MQTT (Internal Messaging) ──────────────────────────────
let event = shared_lib::mqtt_messages::ProjectCreatedEvent { let event = shared_lib::mqtt_messages::ProjectCreatedEvent {
@@ -102,14 +127,41 @@ pub async fn create_project(
}); });
} }
crate::audit::log(&pool, crate::audit::AuditEntry::new("project.create") crate::audit::log(
&pool,
crate::audit::AuditEntry::new("project.create")
.with_user(user_id, &claims.email) .with_user(user_id, &claims.email)
.with_resource(format!("project:{}", project.id)) .with_resource(format!("project:{}", project.id)),
).await; )
.await;
Ok((StatusCode::CREATED, Json(project))) Ok((StatusCode::CREATED, Json(project)))
} }
#[cfg(test)]
mod tests {
#[test]
fn create_project_wires_default_anh_schedule_before_commit() {
let source = include_str!("edge_projects.rs");
let schedule_call = source
.find("ensure_default_anh_schedule_for_confirmed_contract(\n &mut *tx")
.expect("create_project should call ANH schedule provisioning helper");
let user_id_parse = source
.find("let user_id = Uuid::parse_str(&claims.sub)")
.expect("create_project should parse the authenticated user id");
let access_insert = source
.find("INSERT INTO user_project_access")
.expect("create_project should create owner access");
let commit = source
.find("tx.commit()")
.expect("create_project should commit transaction");
assert!(user_id_parse < schedule_call);
assert!(schedule_call < commit);
assert!(access_insert < commit);
}
}
/// GET /api/edge/projects/:id — Detalle de un proyecto. /// GET /api/edge/projects/:id — Detalle de un proyecto.
pub async fn get_project( pub async fn get_project(
State(pool): State<PgPool>, State(pool): State<PgPool>,
@@ -125,7 +177,10 @@ pub async fn get_project(
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?; .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
if access.is_none() { if access.is_none() {
return Err((StatusCode::FORBIDDEN, "Access denied to this project".to_string())); return Err((
StatusCode::FORBIDDEN,
"Access denied to this project".to_string(),
));
} }
let project = sqlx::query_as::<_, EdgeProject>("SELECT * FROM edge_projects WHERE id = $1") let project = sqlx::query_as::<_, EdgeProject>("SELECT * FROM edge_projects WHERE id = $1")
@@ -154,7 +209,10 @@ pub async fn update_project(
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?; .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
if access.is_none() { if access.is_none() {
return Err((StatusCode::FORBIDDEN, "Only project owners or authorized admins can update project details".to_string())); return Err((
StatusCode::FORBIDDEN,
"Only project owners or authorized admins can update project details".to_string(),
));
} }
let project = sqlx::query_as::<_, EdgeProject>( let project = sqlx::query_as::<_, EdgeProject>(
@@ -196,7 +254,10 @@ pub async fn delete_project(
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?; .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
if access.is_none() { if access.is_none() {
return Err((StatusCode::FORBIDDEN, "Only project owners can delete projects".to_string())); return Err((
StatusCode::FORBIDDEN,
"Only project owners can delete projects".to_string(),
));
} }
let result = sqlx::query("DELETE FROM edge_projects WHERE id = $1") let result = sqlx::query("DELETE FROM edge_projects WHERE id = $1")
@@ -227,7 +288,10 @@ pub async fn get_project_full_config(
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?; .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
if access.is_none() { if access.is_none() {
return Err((StatusCode::FORBIDDEN, "Access denied to this project".to_string())); return Err((
StatusCode::FORBIDDEN,
"Access denied to this project".to_string(),
));
} }
use shared_lib::models::EdgeAsset; use shared_lib::models::EdgeAsset;
@@ -300,7 +364,10 @@ pub async fn download_installer(
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?; .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
if access.is_none() { if access.is_none() {
return Err((StatusCode::FORBIDDEN, "Access denied to this project installer".to_string())); return Err((
StatusCode::FORBIDDEN,
"Access denied to this project installer".to_string(),
));
} }
let project = sqlx::query_as::<_, EdgeProject>("SELECT * FROM edge_projects WHERE id = $1") let project = sqlx::query_as::<_, EdgeProject>("SELECT * FROM edge_projects WHERE id = $1")
.bind(id) .bind(id)
@@ -323,13 +390,12 @@ pub async fn download_installer(
return Err(( return Err((
StatusCode::INTERNAL_SERVER_ERROR, StatusCode::INTERNAL_SERVER_ERROR,
format!("Invalid installer key format: '{}'", installer_key), format!("Invalid installer key format: '{}'", installer_key),
)) ));
} }
}; };
// URL interna de MinIO (solo accesible desde la red Docker) // URL interna de MinIO (solo accesible desde la red Docker)
let endpoint_url = let endpoint_url = std::env::var("MINIO_ENDPOINT_URL").expect("MINIO_ENDPOINT_URL must be set");
std::env::var("MINIO_ENDPOINT_URL").expect("MINIO_ENDPOINT_URL must be set");
// Configurar credenciales explícitas // Configurar credenciales explícitas
let access_key = std::env::var("AWS_ACCESS_KEY_ID") let access_key = std::env::var("AWS_ACCESS_KEY_ID")
@@ -359,7 +425,12 @@ pub async fn download_installer(
.build(); .build();
let client = aws_sdk_s3::Client::from_conf(s3_config); let client = aws_sdk_s3::Client::from_conf(s3_config);
tracing::info!("📥 Intentando descargar desde S3 bucket: {}, key: {} en {}", bucket_name, key_name, endpoint_url); tracing::info!(
"📥 Intentando descargar desde S3 bucket: {}, key: {} en {}",
bucket_name,
key_name,
endpoint_url
);
let get_res = client let get_res = client
.get_object() .get_object()
@@ -371,7 +442,12 @@ pub async fn download_installer(
let get_req = match get_res { let get_req = match get_res {
Ok(res) => res, Ok(res) => res,
Err(e) => { Err(e) => {
tracing::error!("❌ Error de S3 al recuperar archivo (bucket: {}, key: {}): {:?}", bucket_name, key_name, e); tracing::error!(
"❌ Error de S3 al recuperar archivo (bucket: {}, key: {}): {:?}",
bucket_name,
key_name,
e
);
return Err(( return Err((
StatusCode::INTERNAL_SERVER_ERROR, StatusCode::INTERNAL_SERVER_ERROR,
format!("Failed to retrieve file from S3 ({}): {}", key_name, e), format!("Failed to retrieve file from S3 ({}): {}", key_name, e),
@@ -415,7 +491,10 @@ pub async fn deploy_project(
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?; .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
if access.is_none() { if access.is_none() {
return Err((StatusCode::FORBIDDEN, "Access denied to deploy this project".to_string())); return Err((
StatusCode::FORBIDDEN,
"Access denied to deploy this project".to_string(),
));
} }
// 2. Obtener config FULL (Project + Assets + Devices + Variables) // 2. Obtener config FULL (Project + Assets + Devices + Variables)
@@ -457,7 +536,10 @@ pub async fn deploy_project(
device_configs.push(DeviceConfig { device, variables }); device_configs.push(DeviceConfig { device, variables });
} }
asset_configs.push(AssetConfig { asset, devices: device_configs }); asset_configs.push(AssetConfig {
asset,
devices: device_configs,
});
} }
let full_config = ProjectConfig { let full_config = ProjectConfig {
@@ -467,16 +549,28 @@ pub async fn deploy_project(
// 3. Publicar en MQTT para que el sistema de despliegue lo procese // 3. Publicar en MQTT para que el sistema de despliegue lo procese
// Tópico: omnioil/control/deploy/{project_id} // Tópico: omnioil/control/deploy/{project_id}
let payload = serde_json::to_string(&full_config) let payload = serde_json::to_string(&full_config).map_err(|e| {
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("Failed to serialize config: {}", e)))?; (
StatusCode::INTERNAL_SERVER_ERROR,
format!("Failed to serialize config: {}", e),
)
})?;
let topic = format!("omnioil/control/deploy/{}", id); let topic = format!("omnioil/control/deploy/{}", id);
mqtt.publish(topic, rumqttc::QoS::AtLeastOnce, false, payload) mqtt.publish(topic, rumqttc::QoS::AtLeastOnce, false, payload)
.await .await
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("MQTT Publish failed: {}", e)))?; .map_err(|e| {
(
StatusCode::INTERNAL_SERVER_ERROR,
format!("MQTT Publish failed: {}", e),
)
})?;
tracing::info!("🚀 Despliegue solicitado para proyecto: {}. Configuración publicada en MQTT.", id); tracing::info!(
"🚀 Despliegue solicitado para proyecto: {}. Configuración publicada en MQTT.",
id
);
Ok(Json(serde_json::json!({ Ok(Json(serde_json::json!({
"message": "Despliegue iniciado correctamente", "message": "Despliegue iniciado correctamente",
@@ -485,15 +579,12 @@ pub async fn deploy_project(
}))) })))
} }
/// GET /api/edge/projects/:id/linux-install — Genera script de instalación Linux. /// POST /api/edge/projects/:id/linux-install-token — Genera un token de descarga temporal de un solo uso.
/// pub async fn generate_download_token(
/// Crea un nuevo token de provisioning y devuelve un script bash listo para
/// ejecutar en el dispositivo de campo: curl -sSL <url> | sudo bash
pub async fn linux_install_script(
State(pool): State<PgPool>, State(pool): State<PgPool>,
claims: Claims, claims: Claims,
Path(id): Path<Uuid>, Path(id): Path<Uuid>,
) -> Result<Response, (StatusCode, String)> { ) -> Result<Json<serde_json::Value>, (StatusCode, String)> {
// Verificar acceso al proyecto // Verificar acceso al proyecto
let access = sqlx::query( let access = sqlx::query(
"SELECT 1 FROM user_project_access WHERE user_id = $1 AND project_id = $2 AND is_active = true" "SELECT 1 FROM user_project_access WHERE user_id = $1 AND project_id = $2 AND is_active = true"
@@ -505,12 +596,135 @@ pub async fn linux_install_script(
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?; .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
if access.is_none() { if access.is_none() {
return Err((StatusCode::FORBIDDEN, "Acceso denegado a este proyecto".to_string())); return Err((
StatusCode::FORBIDDEN,
"Acceso denegado a este proyecto".to_string(),
));
} }
let download_token = uuid::Uuid::new_v4().to_string();
let expires_at = chrono::Utc::now() + chrono::Duration::minutes(10);
sqlx::query(
"INSERT INTO agent_download_tokens (token, project_id, expires_at) VALUES ($1, $2, $3)",
)
.bind(&download_token)
.bind(id)
.bind(expires_at)
.execute(&pool)
.await
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
Ok(Json(serde_json::json!({ "token": download_token })))
}
#[derive(serde::Deserialize)]
pub struct InstallScriptQuery {
token: Option<String>,
}
/// GET /api/edge/projects/:id/linux-install — Genera script de instalación Linux.
///
/// Crea un nuevo token de provisioning y devuelve un script bash listo para
/// ejecutar en el dispositivo de campo. Puede autenticarse con sesión activa (JWT)
/// o mediante un token de descarga temporal de un solo uso en la query param: ?token=...
pub async fn linux_install_script(
State(pool): State<PgPool>,
headers: axum::http::HeaderMap,
Query(query): Query<InstallScriptQuery>,
Path(id): Path<Uuid>,
) -> Result<Response, (StatusCode, String)> {
// Intentar extraer y verificar claims desde los headers de forma manual
let claims_opt = if let Some(auth_header) = headers
.get(axum::http::header::AUTHORIZATION)
.and_then(|value| value.to_str().ok())
{
if auth_header.starts_with("Bearer ") {
let token = &auth_header[7..];
if let Ok(token_data) = crate::auth::verify_jwt(token) {
let claims = token_data.claims;
if let Ok(user_id) = uuid::Uuid::parse_str(&claims.sub) {
let user_active: Option<bool> =
sqlx::query_scalar("SELECT is_active FROM users WHERE id = $1")
.bind(user_id)
.fetch_optional(&pool)
.await
.unwrap_or(None);
if user_active == Some(true) {
Some(claims)
} else {
None
}
} else {
None
}
} else {
None
}
} else {
None
}
} else {
None
};
let project_id = if let Some(claims) = claims_opt {
// Verificar acceso al proyecto
let access = sqlx::query(
"SELECT 1 FROM user_project_access WHERE user_id = $1 AND project_id = $2 AND is_active = true"
)
.bind(Uuid::parse_str(&claims.sub).unwrap())
.bind(id)
.fetch_optional(&pool)
.await
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
if access.is_none() {
return Err((
StatusCode::FORBIDDEN,
"Acceso denegado a este proyecto".to_string(),
));
}
id
} else {
// Validar por token de descarga temporal
let token_str = query.token.ok_or((
StatusCode::UNAUTHORIZED,
"Token de descarga o sesión JWT faltante".to_string(),
))?;
let token_row = sqlx::query(
"SELECT project_id FROM agent_download_tokens WHERE token = $1 AND expires_at > NOW()",
)
.bind(&token_str)
.fetch_optional(&pool)
.await
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?
.ok_or((
StatusCode::UNAUTHORIZED,
"Token de descarga inválido o expirado".to_string(),
))?;
let token_project_id: Uuid = token_row.get(0);
if token_project_id != id {
return Err((
StatusCode::FORBIDDEN,
"El token no corresponde a este proyecto".to_string(),
));
}
// Eliminar el token para que sea de un solo uso
let _ = sqlx::query("DELETE FROM agent_download_tokens WHERE token = $1")
.bind(&token_str)
.execute(&pool)
.await;
id
};
// Verificar que el proyecto existe // Verificar que el proyecto existe
let project = sqlx::query_as::<_, EdgeProject>("SELECT * FROM edge_projects WHERE id = $1") let project = sqlx::query_as::<_, EdgeProject>("SELECT * FROM edge_projects WHERE id = $1")
.bind(id) .bind(project_id)
.fetch_optional(&pool) .fetch_optional(&pool)
.await .await
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))? .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?
@@ -525,7 +739,7 @@ pub async fn linux_install_script(
r#"INSERT INTO agent_provisioning_tokens (project_id, token, expires_at) r#"INSERT INTO agent_provisioning_tokens (project_id, token, expires_at)
VALUES ($1, $2, $3)"#, VALUES ($1, $2, $3)"#,
) )
.bind(id) .bind(project_id)
.bind(&provisioning_token) .bind(&provisioning_token)
.bind(token_expires_at) .bind(token_expires_at)
.execute(&pool) .execute(&pool)
@@ -533,7 +747,7 @@ pub async fn linux_install_script(
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?; .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
// Crear/renovar usuario MQTT de provisioning temporal // Crear/renovar usuario MQTT de provisioning temporal
let prov_mqtt_user = format!("provision:{}", id); let prov_mqtt_user = format!("provision:{}", project_id);
sqlx::query( sqlx::query(
"INSERT INTO mqtt_users (username, password_hash) "INSERT INTO mqtt_users (username, password_hash)
VALUES ($1, crypt($2, gen_salt('bf', 10))) VALUES ($1, crypt($2, gen_salt('bf', 10)))
@@ -547,8 +761,8 @@ pub async fn linux_install_script(
// ACLs de provisioning // ACLs de provisioning
let prov_acls = vec![ let prov_acls = vec![
(format!("provision/request/{}", id), 2i32), (format!("provision/request/{}", project_id), 2i32),
(format!("provision/response/{}", id), 4i32), (format!("provision/response/{}", project_id), 4i32),
]; ];
for (topic, rw) in prov_acls { for (topic, rw) in prov_acls {
sqlx::query( sqlx::query(
@@ -571,7 +785,7 @@ pub async fn linux_install_script(
.unwrap_or_else(|_| "https://tu-servidor.omnioil.io".to_string()); .unwrap_or_else(|_| "https://tu-servidor.omnioil.io".to_string());
let script = generate_install_script( let script = generate_install_script(
&id.to_string(), &project_id.to_string(),
&provisioning_token, &provisioning_token,
&mqtt_host, &mqtt_host,
&mqtt_port, &mqtt_port,
@@ -585,7 +799,10 @@ pub async fn linux_install_script(
(header::CONTENT_TYPE, "text/x-shellscript; charset=utf-8"), (header::CONTENT_TYPE, "text/x-shellscript; charset=utf-8"),
( (
header::CONTENT_DISPOSITION, header::CONTENT_DISPOSITION,
&format!("attachment; filename=\"install-omnioil-{}.sh\"", &id.to_string()[..8]), &format!(
"attachment; filename=\"install-omnioil-{}.sh\"",
&project_id.to_string()[..8]
),
), ),
], ],
script, script,
@@ -602,7 +819,8 @@ fn generate_install_script(
server_url: &str, server_url: &str,
project_name: &str, project_name: &str,
) -> String { ) -> String {
format!(r#"#!/bin/bash format!(
r#"#!/bin/bash
# ============================================================================= # =============================================================================
# OmniOil Edge Agent — Instalador Linux # OmniOil Edge Agent — Instalador Linux
# Proyecto: {project_name} # Proyecto: {project_name}
@@ -744,11 +962,15 @@ pub async fn download_linux_binary(
let arch_tag = match arch.as_str() { let arch_tag = match arch.as_str() {
"x86_64" | "amd64" => "x86_64", "x86_64" | "amd64" => "x86_64",
"aarch64" | "arm64" => "aarch64", "aarch64" | "arm64" => "aarch64",
_ => return Err((StatusCode::BAD_REQUEST, format!("Arquitectura no soportada: {}", arch))), _ => {
return Err((
StatusCode::BAD_REQUEST,
format!("Arquitectura no soportada: {}", arch),
));
}
}; };
let endpoint_url = std::env::var("MINIO_ENDPOINT_URL") let endpoint_url = std::env::var("MINIO_ENDPOINT_URL").expect("MINIO_ENDPOINT_URL must be set");
.expect("MINIO_ENDPOINT_URL must be set");
let access_key = std::env::var("AWS_ACCESS_KEY_ID") let access_key = std::env::var("AWS_ACCESS_KEY_ID")
.or_else(|_| std::env::var("MINIO_USER")) .or_else(|_| std::env::var("MINIO_USER"))
.expect("MinIO credentials must be set"); .expect("MinIO credentials must be set");
@@ -756,9 +978,8 @@ pub async fn download_linux_binary(
.or_else(|_| std::env::var("MINIO_PASSWORD")) .or_else(|_| std::env::var("MINIO_PASSWORD"))
.expect("MinIO credentials must be set"); .expect("MinIO credentials must be set");
let credentials = aws_sdk_s3::config::Credentials::new( let credentials =
access_key, secret_key, None, None, "minio", aws_sdk_s3::config::Credentials::new(access_key, secret_key, None, None, "minio");
);
let config = aws_config::defaults(aws_config::BehaviorVersion::latest()) let config = aws_config::defaults(aws_config::BehaviorVersion::latest())
.region(aws_sdk_s3::config::Region::new("us-east-1")) .region(aws_sdk_s3::config::Region::new("us-east-1"))
.endpoint_url(&endpoint_url) .endpoint_url(&endpoint_url)
@@ -771,25 +992,38 @@ pub async fn download_linux_binary(
let client = aws_sdk_s3::Client::from_conf(s3_config); let client = aws_sdk_s3::Client::from_conf(s3_config);
let key = format!("agents/linux/{}/edge-agent", arch_tag); let key = format!("agents/linux/{}/edge-agent", arch_tag);
let bucket = std::env::var("S3_INSTALLERS_BUCKET") let bucket =
.unwrap_or_else(|_| "omnioil-releases".to_string()); std::env::var("S3_INSTALLERS_BUCKET").unwrap_or_else(|_| "omnioil-releases".to_string());
let get_res = client.get_object() let get_res = client
.get_object()
.bucket(&bucket) .bucket(&bucket)
.key(&key) .key(&key)
.send() .send()
.await .await
.map_err(|e| (StatusCode::NOT_FOUND, format!("Binario Linux no disponible aún: {}", e)))?; .map_err(|e| {
(
StatusCode::NOT_FOUND,
format!("Binario Linux no disponible aún: {}", e),
)
})?;
let bytes = get_res.body.collect().await let bytes = get_res
.body
.collect()
.await
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?; .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
Ok(( Ok((
[ [
(header::CONTENT_TYPE, "application/octet-stream"), (header::CONTENT_TYPE, "application/octet-stream"),
(header::CONTENT_DISPOSITION, "attachment; filename=\"edge-agent\""), (
header::CONTENT_DISPOSITION,
"attachment; filename=\"edge-agent\"",
),
], ],
bytes.into_bytes(), bytes.into_bytes(),
).into_response()) )
.into_response())
} }
/// POST /api/edge/projects/:id/rebuild — Forzar la reconstrucción del instalador (MSI). /// POST /api/edge/projects/:id/rebuild — Forzar la reconstrucción del instalador (MSI).
@@ -808,13 +1042,19 @@ pub async fn rebuild_installer(
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?; .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
if access.is_none() && claims.role != "admin" { if access.is_none() && claims.role != "admin" {
return Err((StatusCode::FORBIDDEN, "Only admins or authorized users can trigger installer rebuilds".to_string())); return Err((
StatusCode::FORBIDDEN,
"Only admins or authorized users can trigger installer rebuilds".to_string(),
));
} }
// Si no tiene acceso directo y es admin, tal vez queramos permitirlo en el futuro, // Si no tiene acceso directo y es admin, tal vez queramos permitirlo en el futuro,
// pero el requerimiento es separacion TOTAL. // pero el requerimiento es separacion TOTAL.
// Así que exigimos acceso directo. // Así que exigimos acceso directo.
if access.is_none() { if access.is_none() {
return Err((StatusCode::FORBIDDEN, "Access denied to this project".to_string())); return Err((
StatusCode::FORBIDDEN,
"Access denied to this project".to_string(),
));
} }
// 2. Obtener datos básicos del proyecto // 2. Obtener datos básicos del proyecto
@@ -842,14 +1082,34 @@ pub async fn rebuild_installer(
timestamp: chrono::Utc::now(), timestamp: chrono::Utc::now(),
}; };
let payload = serde_json::to_string(&event) let payload = serde_json::to_string(&event).map_err(|e| {
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("Failed to serialize event: {}", e)))?; (
StatusCode::INTERNAL_SERVER_ERROR,
format!("Failed to serialize event: {}", e),
)
})?;
mqtt.publish("omnioil/events/project_created", rumqttc::QoS::AtLeastOnce, false, payload) if let Err(e) = mqtt
.publish(
"omnioil/events/project_created",
rumqttc::QoS::AtLeastOnce,
false,
payload,
)
.await .await
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("MQTT Publish failed: {}", e)))?; {
let message = format!("MQTT Publish failed: {}", e);
let _ = sqlx::query("UPDATE edge_projects SET installer_status = 'FAILED' WHERE id = $1")
.bind(id)
.execute(&pool)
.await;
return Err((StatusCode::INTERNAL_SERVER_ERROR, message));
}
tracing::info!("🛠️ Reconstrucción de instalador solicitada para proyecto: {}", id); tracing::info!(
"🛠️ Reconstrucción de instalador solicitada para proyecto: {}",
id
);
Ok(Json(serde_json::json!({ Ok(Json(serde_json::json!({
"message": "Reconstrucción de instalador iniciada", "message": "Reconstrucción de instalador iniciada",

View File

@@ -1,14 +1,14 @@
//! Handlers para CRUD de variables Edge. //! Handlers para CRUD de variables Edge.
use crate::auth::Claims;
use axum::{ use axum::{
Json,
extract::{Path, State}, extract::{Path, State},
http::StatusCode, http::StatusCode,
Json,
}; };
use shared_lib::edge_models::*;
use sqlx::PgPool; use sqlx::PgPool;
use uuid::Uuid; use uuid::Uuid;
use shared_lib::edge_models::*;
use crate::auth::Claims;
use crate::errors::AppError; use crate::errors::AppError;
@@ -27,7 +27,7 @@ pub async fn list_variables(
r#"SELECT 1 FROM edge_devices ed r#"SELECT 1 FROM edge_devices ed
JOIN edge_assets ea ON ed.asset_id = ea.id JOIN edge_assets ea ON ed.asset_id = ea.id
JOIN user_project_access upa ON ea.project_id = upa.project_id JOIN user_project_access upa ON ea.project_id = upa.project_id
WHERE ed.id = $1 AND upa.user_id = $2 AND upa.is_active = true"# WHERE ed.id = $1 AND upa.user_id = $2 AND upa.is_active = true"#,
) )
.bind(device_id) .bind(device_id)
.bind(user_id) .bind(user_id)
@@ -35,12 +35,14 @@ pub async fn list_variables(
.await?; .await?;
if access.is_none() { if access.is_none() {
return Err(AppError::Forbidden("Sin acceso a las variables de este dispositivo".to_string())); return Err(AppError::Forbidden(
"Sin acceso a las variables de este dispositivo".to_string(),
));
} }
} }
let variables = sqlx::query_as::<_, EdgeVariable>( let variables = sqlx::query_as::<_, EdgeVariable>(
"SELECT * FROM edge_variables WHERE device_id = $1 ORDER BY alias" "SELECT * FROM edge_variables WHERE device_id = $1 ORDER BY alias",
) )
.bind(device_id) .bind(device_id)
.fetch_all(&pool) .fetch_all(&pool)
@@ -65,7 +67,7 @@ pub async fn create_variable(
r#"SELECT 1 FROM edge_devices ed r#"SELECT 1 FROM edge_devices ed
JOIN edge_assets ea ON ed.asset_id = ea.id JOIN edge_assets ea ON ed.asset_id = ea.id
JOIN user_project_access upa ON ea.project_id = upa.project_id JOIN user_project_access upa ON ea.project_id = upa.project_id
WHERE ed.id = $1 AND upa.user_id = $2 AND upa.is_active = true"# WHERE ed.id = $1 AND upa.user_id = $2 AND upa.is_active = true"#,
) )
.bind(device_id) .bind(device_id)
.bind(user_id) .bind(user_id)
@@ -73,7 +75,9 @@ pub async fn create_variable(
.await?; .await?;
if access.is_none() { if access.is_none() {
return Err(AppError::Forbidden("Sin permiso para crear variables en este dispositivo".to_string())); return Err(AppError::Forbidden(
"Sin permiso para crear variables en este dispositivo".to_string(),
));
} }
} }
@@ -84,7 +88,7 @@ pub async fn create_variable(
byte_order, metadata byte_order, metadata
) )
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11)
RETURNING *"# RETURNING *"#,
) )
.bind(device_id) .bind(device_id)
.bind(&req.address) .bind(&req.address)
@@ -120,7 +124,7 @@ pub async fn update_variable(
JOIN edge_devices ed ON ev.device_id = ed.id JOIN edge_devices ed ON ev.device_id = ed.id
JOIN edge_assets ea ON ed.asset_id = ea.id JOIN edge_assets ea ON ed.asset_id = ea.id
JOIN user_project_access upa ON ea.project_id = upa.project_id JOIN user_project_access upa ON ea.project_id = upa.project_id
WHERE ev.id = $1 AND upa.user_id = $2 AND upa.is_active = true"# WHERE ev.id = $1 AND upa.user_id = $2 AND upa.is_active = true"#,
) )
.bind(id) .bind(id)
.bind(user_id) .bind(user_id)
@@ -128,7 +132,9 @@ pub async fn update_variable(
.await?; .await?;
if access.is_none() { if access.is_none() {
return Err(AppError::Forbidden("Sin acceso a esta variable".to_string())); return Err(AppError::Forbidden(
"Sin acceso a esta variable".to_string(),
));
} }
} }
@@ -146,7 +152,7 @@ pub async fn update_variable(
metadata = COALESCE($11, metadata), metadata = COALESCE($11, metadata),
is_enabled = COALESCE($12, is_enabled) is_enabled = COALESCE($12, is_enabled)
WHERE id = $1 WHERE id = $1
RETURNING *"# RETURNING *"#,
) )
.bind(id) .bind(id)
.bind(&req.address) .bind(&req.address)
@@ -183,7 +189,7 @@ pub async fn delete_variable(
JOIN edge_devices ed ON ev.device_id = ed.id JOIN edge_devices ed ON ev.device_id = ed.id
JOIN edge_assets ea ON ed.asset_id = ea.id JOIN edge_assets ea ON ed.asset_id = ea.id
JOIN user_project_access upa ON ea.project_id = upa.project_id JOIN user_project_access upa ON ea.project_id = upa.project_id
WHERE ev.id = $1 AND upa.user_id = $2 AND upa.is_active = true"# WHERE ev.id = $1 AND upa.user_id = $2 AND upa.is_active = true"#,
) )
.bind(id) .bind(id)
.bind(user_id) .bind(user_id)
@@ -191,7 +197,9 @@ pub async fn delete_variable(
.await?; .await?;
if access.is_none() { if access.is_none() {
return Err(AppError::Forbidden("Sin permiso para eliminar esta variable".to_string())); return Err(AppError::Forbidden(
"Sin permiso para eliminar esta variable".to_string(),
));
} }
} }

View File

@@ -48,7 +48,11 @@ pub async fn health_check(State(pool): State<PgPool>) -> Json<HealthResponse> {
}, },
}; };
let overall = if db_status.status == "ok" { "ok" } else { "degraded" }; let overall = if db_status.status == "ok" {
"ok"
} else {
"degraded"
};
Json(HealthResponse { Json(HealthResponse {
status: overall, status: overall,

View File

@@ -0,0 +1,844 @@
use crate::{AppState, auth::Claims};
use axum::{
Json,
extract::{Path, State},
http::StatusCode,
response::IntoResponse,
};
use csv::ReaderBuilder;
use serde::Deserialize;
use serde_json::{Value, json};
use sqlx::Row;
use std::collections::HashMap;
use uuid::Uuid;
#[derive(Deserialize, Debug)]
pub struct ModbusTcpRecord {
#[serde(rename = "WellCode")]
pub well_code: String,
#[serde(rename = "DeviceName")]
pub device_name: String,
#[serde(rename = "Host")]
pub host: String,
#[serde(rename = "Port")]
pub port: i32,
#[serde(rename = "UnitId")]
pub unit_id: u8,
#[serde(rename = "VariableAlias")]
pub variable_alias: String,
#[serde(rename = "Address")]
pub address: String,
#[serde(rename = "DataType")]
pub data_type: String,
#[serde(rename = "LastCalibrationDate")]
pub last_calibration_date: Option<chrono::NaiveDate>,
#[serde(rename = "NextCalibrationDate")]
pub next_calibration_date: Option<chrono::NaiveDate>,
#[serde(rename = "CalibrationCertificateUrl")]
pub calibration_certificate_url: Option<String>,
}
#[derive(Deserialize, Debug)]
pub struct S7Record {
#[serde(rename = "WellCode")]
pub well_code: String,
#[serde(rename = "DeviceName")]
pub device_name: String,
#[serde(rename = "Host")]
pub host: String,
#[serde(rename = "Port")]
pub port: i32,
#[serde(rename = "Rack")]
pub rack: u16,
#[serde(rename = "Slot")]
pub slot: u16,
#[serde(rename = "VariableAlias")]
pub variable_alias: String,
#[serde(rename = "Address")]
pub address: String,
#[serde(rename = "DataType")]
pub data_type: String,
#[serde(rename = "LastCalibrationDate")]
pub last_calibration_date: Option<chrono::NaiveDate>,
#[serde(rename = "NextCalibrationDate")]
pub next_calibration_date: Option<chrono::NaiveDate>,
#[serde(rename = "CalibrationCertificateUrl")]
pub calibration_certificate_url: Option<String>,
}
#[derive(Deserialize, Debug)]
pub struct OpcUaRecord {
#[serde(rename = "WellCode")]
pub well_code: String,
#[serde(rename = "DeviceName")]
pub device_name: String,
#[serde(rename = "Endpoint")]
pub endpoint: String,
#[serde(rename = "VariableAlias")]
pub variable_alias: String,
#[serde(rename = "NodeId")]
pub node_id: String,
#[serde(rename = "LastCalibrationDate")]
pub last_calibration_date: Option<chrono::NaiveDate>,
#[serde(rename = "NextCalibrationDate")]
pub next_calibration_date: Option<chrono::NaiveDate>,
#[serde(rename = "CalibrationCertificateUrl")]
pub calibration_certificate_url: Option<String>,
}
#[derive(Deserialize, Debug)]
pub struct MqttSparkplugBRecord {
#[serde(rename = "WellCode")]
pub well_code: String,
#[serde(rename = "DeviceName")]
pub device_name: String,
#[serde(rename = "BrokerHost")]
pub broker_host: String,
#[serde(rename = "BrokerPort")]
pub broker_port: i32,
#[serde(rename = "GroupId")]
pub group_id: String,
#[serde(rename = "EdgeNodeId")]
pub edge_node_id: String,
#[serde(rename = "DeviceId")]
pub device_id: String,
#[serde(rename = "VariableAlias")]
pub variable_alias: String,
#[serde(rename = "SparkplugMetric")]
pub sparkplug_metric: String,
#[serde(rename = "LastCalibrationDate")]
pub last_calibration_date: Option<chrono::NaiveDate>,
#[serde(rename = "NextCalibrationDate")]
pub next_calibration_date: Option<chrono::NaiveDate>,
#[serde(rename = "CalibrationCertificateUrl")]
pub calibration_certificate_url: Option<String>,
}
#[derive(Deserialize, Debug)]
pub struct SqlDbRecord {
#[serde(rename = "WellCode")]
pub well_code: String,
#[serde(rename = "DeviceName")]
pub device_name: String,
#[serde(rename = "ConnectionString")]
pub connection_string: String,
#[serde(rename = "Query")]
pub query: String,
#[serde(rename = "VariableAlias")]
pub variable_alias: String,
#[serde(rename = "ColumnName")]
pub column_name: String,
#[serde(rename = "LastCalibrationDate")]
pub last_calibration_date: Option<chrono::NaiveDate>,
#[serde(rename = "NextCalibrationDate")]
pub next_calibration_date: Option<chrono::NaiveDate>,
#[serde(rename = "CalibrationCertificateUrl")]
pub calibration_certificate_url: Option<String>,
}
fn parse_data_type(
s: &str,
) -> Result<shared_lib::edge_models::variables::VariableDataType, String> {
match s.to_lowercase().as_str() {
"bool" | "boolean" => Ok(shared_lib::edge_models::variables::VariableDataType::Bool),
"int16" => Ok(shared_lib::edge_models::variables::VariableDataType::Int16),
"uint16" => Ok(shared_lib::edge_models::variables::VariableDataType::Uint16),
"int32" => Ok(shared_lib::edge_models::variables::VariableDataType::Int32),
"uint32" => Ok(shared_lib::edge_models::variables::VariableDataType::Uint32),
"float32" | "float" => Ok(shared_lib::edge_models::variables::VariableDataType::Float32),
"float64" | "double" => Ok(shared_lib::edge_models::variables::VariableDataType::Float64),
"string" | "text" => Ok(shared_lib::edge_models::variables::VariableDataType::StringType),
_ => Err(format!("Tipo de dato no soportado: {}", s)),
}
}
fn parse_endpoint_host_port(endpoint: &str) -> (String, i32) {
if let Some(stripped) = endpoint.strip_prefix("opc.tcp://") {
let parts: Vec<&str> = stripped
.split('/')
.next()
.unwrap_or("")
.split(':')
.collect();
if parts.len() == 2 {
let host = parts[0].to_string();
let port = parts[1].parse::<i32>().unwrap_or(4840);
return (host, port);
} else if parts.len() == 1 {
return (parts[0].to_string(), 4840);
}
}
(endpoint.to_string(), 4840)
}
// Endpoint for template download
pub async fn download_import_template(
Path((_project_id, protocol)): Path<(Uuid, String)>,
) -> impl IntoResponse {
let (filename, content) = match protocol.to_uppercase().as_str() {
"MODBUS_TCP" => (
"template_modbus_tcp.csv",
"WellCode,DeviceName,Host,Port,UnitId,VariableAlias,Address,DataType,LastCalibrationDate,NextCalibrationDate,CalibrationCertificateUrl\nPOZO-X1,FlowMeter1,192.168.1.100,502,1,Temperature,HR:40001,float32,2026-01-15,2027-01-15,http://example.com/cert.pdf\n",
),
"S7" => (
"template_s7.csv",
"WellCode,DeviceName,Host,Port,Rack,Slot,VariableAlias,Address,DataType,LastCalibrationDate,NextCalibrationDate,CalibrationCertificateUrl\nPOZO-X1,PLC_S7,192.168.1.50,102,0,1,Level,DB1.DBW0,int16,2026-01-15,2027-01-15,http://example.com/cert.pdf\n",
),
"OPC_UA" => (
"template_opc_ua.csv",
"WellCode,DeviceName,Endpoint,VariableAlias,NodeId,LastCalibrationDate,NextCalibrationDate,CalibrationCertificateUrl\nPOZO-X1,SCADA_OPC,opc.tcp://192.168.1.10:4840,Pressure,ns=2;s=Well1.Pressure,2026-01-15,2027-01-15,http://example.com/cert.pdf\n",
),
"MQTT_SPARKPLUG_B" => (
"template_mqtt_sparkplug_b.csv",
"WellCode,DeviceName,BrokerHost,BrokerPort,GroupId,EdgeNodeId,DeviceId,VariableAlias,SparkplugMetric,LastCalibrationDate,NextCalibrationDate,CalibrationCertificateUrl\nPOZO-X1,SparkDevice,192.168.1.80,1883,FieldGroup,Node1,Meter1,FlowRate,Outputs/FlowRate,2026-01-15,2027-01-15,http://example.com/cert.pdf\n",
),
"SQL_DB" => (
"template_sql_db.csv",
"WellCode,DeviceName,ConnectionString,Query,VariableAlias,ColumnName,LastCalibrationDate,NextCalibrationDate,CalibrationCertificateUrl\nPOZO-X1,HistDB,postgresql://user:pass@192.168.1.90/db,SELECT temp FROM logs LIMIT 1,Temperature,temp,2026-01-15,2027-01-15,http://example.com/cert.pdf\n",
),
_ => return (StatusCode::BAD_REQUEST, "Protocolo no soportado").into_response(),
};
(
StatusCode::OK,
[
("Content-Type", "text/csv"),
(
"Content-Disposition",
&format!("attachment; filename=\"{}\"", filename),
),
],
content,
)
.into_response()
}
async fn get_or_create_well(
tx: &mut sqlx::Transaction<'_, sqlx::Postgres>,
project_id: Uuid,
well_code: &str,
last_calibration_date: Option<chrono::NaiveDate>,
next_calibration_date: Option<chrono::NaiveDate>,
calibration_certificate_url: Option<String>,
cache: &mut HashMap<String, Uuid>,
imported_count: &mut i32,
) -> Result<Uuid, (StatusCode, String)> {
let trimmed = well_code.trim();
if let Some(&id) = cache.get(trimmed) {
return Ok(id);
}
let row = sqlx::query("SELECT id FROM edge_assets WHERE code = $1 AND project_id = $2")
.bind(trimmed)
.bind(project_id)
.fetch_optional(&mut **tx)
.await
.map_err(|e| {
(
StatusCode::INTERNAL_SERVER_ERROR,
format!("Error al buscar pozo: {}", e),
)
})?;
if let Some(r) = row {
let id: Uuid = r
.try_get("id")
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
cache.insert(trimmed.to_string(), id);
// Actualizar calibración si se provee en el CSV para pozo existente
if last_calibration_date.is_some()
|| next_calibration_date.is_some()
|| calibration_certificate_url.is_some()
{
sqlx::query(
r#"UPDATE edge_assets
SET last_calibration_date = COALESCE($1, last_calibration_date),
next_calibration_date = COALESCE($2, next_calibration_date),
calibration_certificate_url = COALESCE($3, calibration_certificate_url),
updated_at = NOW()
WHERE id = $4"#,
)
.bind(last_calibration_date)
.bind(next_calibration_date)
.bind(calibration_certificate_url)
.bind(id)
.execute(&mut **tx)
.await
.map_err(|e| {
(
StatusCode::INTERNAL_SERVER_ERROR,
format!("Error al actualizar calibración de pozo: {}", e),
)
})?;
}
Ok(id)
} else {
let id = Uuid::new_v4();
let well_name = format!("Pozo {}", trimmed);
sqlx::query(
r#"INSERT INTO edge_assets (id, project_id, code, name, asset_type, is_active, last_calibration_date, next_calibration_date, calibration_certificate_url)
VALUES ($1, $2, $3, $4, 'POZO', true, $5, $6, $7)"#,
)
.bind(id)
.bind(project_id)
.bind(trimmed)
.bind(&well_name)
.bind(last_calibration_date)
.bind(next_calibration_date)
.bind(calibration_certificate_url)
.execute(&mut **tx)
.await
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("Error al insertar pozo: {}", e)))?;
*imported_count += 1;
cache.insert(trimmed.to_string(), id);
Ok(id)
}
}
async fn get_or_create_device(
tx: &mut sqlx::Transaction<'_, sqlx::Postgres>,
asset_id: Uuid,
device_name: &str,
protocol: &str,
host: &str,
port: i32,
protocol_config: Value,
cache: &mut HashMap<(Uuid, String), Uuid>,
imported_count: &mut i32,
) -> Result<Uuid, (StatusCode, String)> {
let key = (asset_id, device_name.trim().to_string());
if let Some(&id) = cache.get(&key) {
return Ok(id);
}
let row = sqlx::query("SELECT id FROM edge_devices WHERE asset_id = $1 AND name = $2")
.bind(asset_id)
.bind(&key.1)
.fetch_optional(&mut **tx)
.await
.map_err(|e| {
(
StatusCode::INTERNAL_SERVER_ERROR,
format!("Error al buscar dispositivo: {}", e),
)
})?;
if let Some(r) = row {
let id: Uuid = r
.try_get("id")
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
cache.insert(key, id);
Ok(id)
} else {
let id = Uuid::new_v4();
sqlx::query(
r#"INSERT INTO edge_devices (id, asset_id, name, protocol, host, port, protocol_config, is_enabled)
VALUES ($1, $2, $3, $4, $5, $6, $7, true)"#,
)
.bind(id)
.bind(asset_id)
.bind(&key.1)
.bind(protocol)
.bind(host)
.bind(port)
.bind(protocol_config)
.execute(&mut **tx)
.await
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("Error al insertar dispositivo: {}", e)))?;
*imported_count += 1;
cache.insert(key, id);
Ok(id)
}
}
async fn create_variable(
tx: &mut sqlx::Transaction<'_, sqlx::Postgres>,
device_id: Uuid,
address: &str,
data_type: shared_lib::edge_models::variables::VariableDataType,
alias: &str,
unit: Option<&str>,
metadata: Value,
) -> Result<(), (StatusCode, String)> {
let dt_str = match data_type {
shared_lib::edge_models::variables::VariableDataType::Bool => "bool",
shared_lib::edge_models::variables::VariableDataType::Int16 => "int16",
shared_lib::edge_models::variables::VariableDataType::Uint16 => "uint16",
shared_lib::edge_models::variables::VariableDataType::Int32 => "int32",
shared_lib::edge_models::variables::VariableDataType::Uint32 => "uint32",
shared_lib::edge_models::variables::VariableDataType::Float32 => "float32",
shared_lib::edge_models::variables::VariableDataType::Float64 => "float64",
shared_lib::edge_models::variables::VariableDataType::StringType => "string",
};
let exists: bool = sqlx::query_scalar(
"SELECT EXISTS(SELECT 1 FROM edge_variables WHERE device_id = $1 AND alias = $2)",
)
.bind(device_id)
.bind(alias.trim())
.fetch_one(&mut **tx)
.await
.map_err(|e| {
(
StatusCode::INTERNAL_SERVER_ERROR,
format!("Error al verificar existencia de variable: {}", e),
)
})?;
if exists {
sqlx::query(
r#"UPDATE edge_variables
SET address = $3, data_type = $4, unit = $5, metadata = $6, updated_at = NOW()
WHERE device_id = $1 AND alias = $2"#,
)
.bind(device_id)
.bind(alias.trim())
.bind(address.trim())
.bind(dt_str)
.bind(unit)
.bind(metadata)
.execute(&mut **tx)
.await
.map_err(|e| {
(
StatusCode::INTERNAL_SERVER_ERROR,
format!("Error al actualizar variable: {}", e),
)
})?;
} else {
sqlx::query(
r#"INSERT INTO edge_variables (device_id, address, data_type, alias, unit, metadata, is_enabled)
VALUES ($1, $2, $3, $4, $5, $6, true)"#,
)
.bind(device_id)
.bind(address.trim())
.bind(dt_str)
.bind(alias.trim())
.bind(unit)
.bind(metadata)
.execute(&mut **tx)
.await
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("Error al insertar variable: {}", e)))?;
}
Ok(())
}
// POST /api/edge/projects/{project_id}/import-csv/{protocol}
pub async fn import_csv_by_protocol(
State(state): State<AppState>,
claims: Claims,
Path((project_id, protocol)): Path<(Uuid, String)>,
body_csv: String,
) -> Result<Json<serde_json::Value>, (StatusCode, String)> {
if claims.role != "admin" {
let user_id = Uuid::parse_str(&claims.sub)
.map_err(|_| (StatusCode::BAD_REQUEST, "Invalid user ID".to_string()))?;
let access = sqlx::query(
"SELECT 1 FROM user_project_access WHERE user_id = $1 AND project_id = $2 AND is_active = true"
)
.bind(user_id)
.bind(project_id)
.fetch_optional(&state.pool)
.await
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
if access.is_none() {
return Err((
StatusCode::FORBIDDEN,
"Access denied to this project".to_string(),
));
}
}
let mut tx = state.pool.begin().await.map_err(|e| {
(
StatusCode::INTERNAL_SERVER_ERROR,
format!("Failed to start transaction: {}", e),
)
})?;
let mut rdr = ReaderBuilder::new()
.has_headers(true)
.flexible(false)
.from_reader(body_csv.as_bytes());
let mut line_num = 1;
let protocol_upper = protocol.to_uppercase();
let mut wells_cache: HashMap<String, Uuid> = HashMap::new();
let mut devices_cache: HashMap<(Uuid, String), Uuid> = HashMap::new();
let mut imported_wells = 0;
let mut imported_devices = 0;
let mut imported_variables = 0;
let headers = rdr
.headers()
.map_err(|e| {
(
StatusCode::BAD_REQUEST,
format!("Fila de cabecera CSV inválida: {}", e),
)
})?
.clone();
for result in rdr.records() {
line_num += 1;
let record = result.map_err(|e| {
(
StatusCode::BAD_REQUEST,
format!("Error en línea {}: CSV mal formateado ({})", line_num, e),
)
})?;
match protocol_upper.as_str() {
"MODBUS_TCP" => {
let rec: ModbusTcpRecord = record.deserialize(Some(&headers)).map_err(|e| {
(
StatusCode::BAD_REQUEST,
format!(
"Error en línea {}: no coincide con el formato Modbus TCP ({})",
line_num, e
),
)
})?;
if rec.well_code.trim().is_empty()
|| rec.device_name.trim().is_empty()
|| rec.variable_alias.trim().is_empty()
{
return Err((
StatusCode::BAD_REQUEST,
format!(
"Error en línea {}: WellCode, DeviceName y VariableAlias son campos requeridos",
line_num
),
));
}
let asset_id = get_or_create_well(
&mut tx,
project_id,
&rec.well_code,
rec.last_calibration_date,
rec.next_calibration_date,
rec.calibration_certificate_url.clone(),
&mut wells_cache,
&mut imported_wells,
)
.await?;
let device_config = json!({ "unit_id": rec.unit_id });
let device_id = get_or_create_device(
&mut tx,
asset_id,
&rec.device_name,
"MODBUS_TCP",
&rec.host,
rec.port,
device_config,
&mut devices_cache,
&mut imported_devices,
)
.await?;
let dt = parse_data_type(&rec.data_type).map_err(|e| {
(
StatusCode::BAD_REQUEST,
format!("Error en línea {}: {}", line_num, e),
)
})?;
create_variable(
&mut tx,
device_id,
&rec.address,
dt,
&rec.variable_alias,
None,
json!({}),
)
.await?;
imported_variables += 1;
}
"S7" => {
let rec: S7Record = record.deserialize(Some(&headers)).map_err(|e| {
(
StatusCode::BAD_REQUEST,
format!(
"Error en línea {}: no coincide con el formato Siemens S7 ({})",
line_num, e
),
)
})?;
if rec.well_code.trim().is_empty()
|| rec.device_name.trim().is_empty()
|| rec.variable_alias.trim().is_empty()
{
return Err((
StatusCode::BAD_REQUEST,
format!(
"Error en línea {}: WellCode, DeviceName y VariableAlias son campos requeridos",
line_num
),
));
}
let asset_id = get_or_create_well(
&mut tx,
project_id,
&rec.well_code,
rec.last_calibration_date,
rec.next_calibration_date,
rec.calibration_certificate_url.clone(),
&mut wells_cache,
&mut imported_wells,
)
.await?;
let device_config = json!({ "rack": rec.rack, "slot": rec.slot });
let device_id = get_or_create_device(
&mut tx,
asset_id,
&rec.device_name,
"S7",
&rec.host,
rec.port,
device_config,
&mut devices_cache,
&mut imported_devices,
)
.await?;
let dt = parse_data_type(&rec.data_type).map_err(|e| {
(
StatusCode::BAD_REQUEST,
format!("Error en línea {}: {}", line_num, e),
)
})?;
create_variable(
&mut tx,
device_id,
&rec.address,
dt,
&rec.variable_alias,
None,
json!({}),
)
.await?;
imported_variables += 1;
}
"OPC_UA" => {
let rec: OpcUaRecord = record.deserialize(Some(&headers)).map_err(|e| {
(
StatusCode::BAD_REQUEST,
format!(
"Error en línea {}: no coincide con el formato OPC UA ({})",
line_num, e
),
)
})?;
if rec.well_code.trim().is_empty()
|| rec.device_name.trim().is_empty()
|| rec.variable_alias.trim().is_empty()
{
return Err((
StatusCode::BAD_REQUEST,
format!(
"Error en línea {}: WellCode, DeviceName y VariableAlias son campos requeridos",
line_num
),
));
}
let asset_id = get_or_create_well(
&mut tx,
project_id,
&rec.well_code,
rec.last_calibration_date,
rec.next_calibration_date,
rec.calibration_certificate_url.clone(),
&mut wells_cache,
&mut imported_wells,
)
.await?;
let device_config = json!({ "endpoint": rec.endpoint });
let (host, port) = parse_endpoint_host_port(&rec.endpoint);
let device_id = get_or_create_device(
&mut tx,
asset_id,
&rec.device_name,
"OPC_UA",
&host,
port,
device_config,
&mut devices_cache,
&mut imported_devices,
)
.await?;
create_variable(
&mut tx,
device_id,
&rec.node_id,
shared_lib::edge_models::variables::VariableDataType::Float32,
&rec.variable_alias,
None,
json!({}),
)
.await?;
imported_variables += 1;
}
"MQTT_SPARKPLUG_B" => {
let rec: MqttSparkplugBRecord = record.deserialize(Some(&headers)).map_err(|e| {
(StatusCode::BAD_REQUEST, format!("Error en línea {}: no coincide con el formato MQTT Sparkplug B ({})", line_num, e))
})?;
if rec.well_code.trim().is_empty()
|| rec.device_name.trim().is_empty()
|| rec.variable_alias.trim().is_empty()
{
return Err((
StatusCode::BAD_REQUEST,
format!(
"Error en línea {}: WellCode, DeviceName y VariableAlias son campos requeridos",
line_num
),
));
}
let asset_id = get_or_create_well(
&mut tx,
project_id,
&rec.well_code,
rec.last_calibration_date,
rec.next_calibration_date,
rec.calibration_certificate_url.clone(),
&mut wells_cache,
&mut imported_wells,
)
.await?;
let device_config = json!({
"group_id": rec.group_id,
"edge_node_id": rec.edge_node_id,
"device_id": rec.device_id
});
let device_id = get_or_create_device(
&mut tx,
asset_id,
&rec.device_name,
"MQTT_SPARKPLUG_B",
&rec.broker_host,
rec.broker_port,
device_config,
&mut devices_cache,
&mut imported_devices,
)
.await?;
create_variable(
&mut tx,
device_id,
&rec.sparkplug_metric,
shared_lib::edge_models::variables::VariableDataType::Float32,
&rec.variable_alias,
None,
json!({}),
)
.await?;
imported_variables += 1;
}
"SQL_DB" => {
let rec: SqlDbRecord = record.deserialize(Some(&headers)).map_err(|e| {
(
StatusCode::BAD_REQUEST,
format!(
"Error en línea {}: no coincide con el formato SQL Database ({})",
line_num, e
),
)
})?;
if rec.well_code.trim().is_empty()
|| rec.device_name.trim().is_empty()
|| rec.variable_alias.trim().is_empty()
{
return Err((
StatusCode::BAD_REQUEST,
format!(
"Error en línea {}: WellCode, DeviceName y VariableAlias son campos requeridos",
line_num
),
));
}
let asset_id = get_or_create_well(
&mut tx,
project_id,
&rec.well_code,
rec.last_calibration_date,
rec.next_calibration_date,
rec.calibration_certificate_url.clone(),
&mut wells_cache,
&mut imported_wells,
)
.await?;
let device_config = json!({ "connection_string": rec.connection_string });
let device_id = get_or_create_device(
&mut tx,
asset_id,
&rec.device_name,
"SQL_DB",
&rec.connection_string,
0,
device_config,
&mut devices_cache,
&mut imported_devices,
)
.await?;
let metadata = json!({ "query": rec.query });
create_variable(
&mut tx,
device_id,
&rec.column_name,
shared_lib::edge_models::variables::VariableDataType::Float32,
&rec.variable_alias,
None,
metadata,
)
.await?;
imported_variables += 1;
}
_ => {
return Err((
StatusCode::BAD_REQUEST,
"Protocolo no soportado para importación".to_string(),
));
}
}
}
tx.commit().await.map_err(|e| {
(
StatusCode::INTERNAL_SERVER_ERROR,
format!("Failed to commit transaction: {}", e),
)
})?;
Ok(Json(json!({
"status": "success",
"wells_imported": imported_wells,
"devices_imported": imported_devices,
"variables_imported": imported_variables,
})))
}

View File

@@ -43,6 +43,7 @@ struct PlatformInvitationActivationRow {
user_email: String, user_email: String,
} }
#[allow(dead_code)]
fn invitation_can_activate( fn invitation_can_activate(
expires_at: DateTime<Utc>, expires_at: DateTime<Utc>,
consumed_at: Option<DateTime<Utc>>, consumed_at: Option<DateTime<Utc>>,

View File

@@ -1,6 +1,6 @@
use crate::auth::Claims; use crate::auth::Claims;
use crate::handlers::audit_helper;
use crate::errors::AppError; use crate::errors::AppError;
use crate::handlers::audit_helper;
use axum::{ use axum::{
Json, Json,
extract::{Query, State}, extract::{Query, State},
@@ -44,7 +44,9 @@ pub async fn list_lab_results(
.await?; .await?;
if access.is_none() { if access.is_none() {
return Err(AppError::Forbidden("Sin permiso para ver resultados de este activo".to_string())); return Err(AppError::Forbidden(
"Sin permiso para ver resultados de este activo".to_string(),
));
} }
let records: Vec<LabResult> = sqlx::query_as::<Postgres, LabResult>( let records: Vec<LabResult> = sqlx::query_as::<Postgres, LabResult>(
@@ -78,7 +80,11 @@ pub async fn create_lab_result(
let project_id: Uuid = match project_row { let project_id: Uuid = match project_row {
Some(row) => row.get("project_id"), Some(row) => row.get("project_id"),
None => return Err(AppError::Forbidden("Sin permiso para registrar resultados en este activo".to_string())), None => {
return Err(AppError::Forbidden(
"Sin permiso para registrar resultados en este activo".to_string(),
));
}
}; };
let record: LabResult = sqlx::query_as::<Postgres, LabResult>( let record: LabResult = sqlx::query_as::<Postgres, LabResult>(
@@ -107,7 +113,8 @@ pub async fn create_lab_result(
None, None,
Some(serde_json::to_value(&record).unwrap_or_default()), Some(serde_json::to_value(&record).unwrap_or_default()),
None, None,
).await; )
.await;
Ok((StatusCode::CREATED, Json(record))) Ok((StatusCode::CREATED, Json(record)))
} }

View File

@@ -46,7 +46,9 @@ pub async fn list_meter_readings(
.await?; .await?;
if access.is_none() { if access.is_none() {
return Err(AppError::Forbidden("Sin permiso para ver lecturas de este activo".to_string())); return Err(AppError::Forbidden(
"Sin permiso para ver lecturas de este activo".to_string(),
));
} }
let records: Vec<MeterReading> = sqlx::query_as::<Postgres, MeterReading>( let records: Vec<MeterReading> = sqlx::query_as::<Postgres, MeterReading>(
@@ -79,7 +81,9 @@ pub async fn create_meter_reading(
.await?; .await?;
if access.is_none() { if access.is_none() {
return Err(AppError::Forbidden("Sin permiso para registrar lecturas en este activo".to_string())); return Err(AppError::Forbidden(
"Sin permiso para registrar lecturas en este activo".to_string(),
));
} }
let record: MeterReading = sqlx::query_as::<Postgres, MeterReading>( let record: MeterReading = sqlx::query_as::<Postgres, MeterReading>(

View File

@@ -4,6 +4,7 @@ pub mod anh_reports;
pub mod audit_helper; pub mod audit_helper;
pub mod auth; pub mod auth;
pub mod billing; pub mod billing;
pub mod billing_webhook;
pub mod dashboard; pub mod dashboard;
pub mod docs; pub mod docs;
pub mod downtime; pub mod downtime;
@@ -14,6 +15,7 @@ pub mod edge_devices;
pub mod edge_projects; pub mod edge_projects;
pub mod edge_variables; pub mod edge_variables;
pub mod health; pub mod health;
pub mod import;
pub mod invitations; pub mod invitations;
pub mod lab_results; pub mod lab_results;
pub mod meters; pub mod meters;
@@ -24,4 +26,5 @@ pub mod telemetry;
pub mod totp; pub mod totp;
pub mod update_info; pub mod update_info;
pub mod users; pub mod users;
pub mod well_access;
pub mod well_tests; pub mod well_tests;

View File

@@ -1,6 +1,6 @@
use crate::auth::Claims; use crate::auth::Claims;
use crate::handlers::audit_helper;
use crate::errors::AppError; use crate::errors::AppError;
use crate::handlers::{audit_helper, well_access};
use axum::{ use axum::{
Json, Json,
extract::{Query, State}, extract::{Query, State},
@@ -9,7 +9,7 @@ use axum::{
use bigdecimal::BigDecimal; use bigdecimal::BigDecimal;
use serde::Deserialize; use serde::Deserialize;
use shared_lib::models::{MovementType, OperationMovement}; use shared_lib::models::{MovementType, OperationMovement};
use sqlx::{PgPool, Postgres, Row}; use sqlx::{PgPool, Postgres};
use uuid::Uuid; use uuid::Uuid;
#[derive(Debug, Deserialize)] #[derive(Debug, Deserialize)]
@@ -19,6 +19,7 @@ pub struct CreateMovementRequest {
pub start_time: chrono::DateTime<chrono::Utc>, pub start_time: chrono::DateTime<chrono::Utc>,
pub end_time: chrono::DateTime<chrono::Utc>, pub end_time: chrono::DateTime<chrono::Utc>,
pub volumen_neto: Option<BigDecimal>, pub volumen_neto: Option<BigDecimal>,
pub client_id: Option<String>,
pub observaciones: Option<String>, pub observaciones: Option<String>,
} }
@@ -27,6 +28,115 @@ pub struct MovementQueryParams {
pub asset_id: Uuid, pub asset_id: Uuid,
} }
fn movement_details(
observaciones: Option<String>,
client_id: Option<String>,
created_by: &str,
) -> serde_json::Value {
serde_json::json!({
"observaciones": observaciones,
"client_id": client_id,
"created_by": created_by
})
}
fn normalize_client_id(client_id: Option<String>) -> Result<Option<String>, AppError> {
let Some(client_id) = client_id else {
return Ok(None);
};
let trimmed = client_id.trim();
if trimmed.is_empty() {
return Ok(None);
}
let parsed = Uuid::parse_str(trimmed)
.map_err(|_| AppError::BadRequest("client_id debe ser un UUID válido".to_string()))?;
Ok(Some(parsed.to_string()))
}
fn find_existing_movement_by_client_id_sql() -> &'static str {
r#"SELECT *
FROM operation_movements
WHERE details->>'client_id' = $1
AND asset_id = $2
LIMIT 1"#
}
fn insert_movement_sql() -> &'static str {
r#"INSERT INTO operation_movements (
asset_id, movement_type, start_time, end_time, volumen_neto, details
)
VALUES ($1, $2, $3, $4, $5, $6)
ON CONFLICT (asset_id, (details->>'client_id'))
WHERE NULLIF(BTRIM(details->>'client_id'), '') IS NOT NULL
DO NOTHING
RETURNING *"#
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn movement_details_preserve_client_id_for_pwa_idempotency() {
let details = movement_details(
Some("nota".to_string()),
Some("client-1".to_string()),
"user-1",
);
assert_eq!(details["observaciones"], "nota");
assert_eq!(details["client_id"], "client-1");
assert_eq!(details["created_by"], "user-1");
}
#[test]
fn normalize_client_id_ignores_blank_values() {
assert_eq!(normalize_client_id(None).unwrap(), None);
assert_eq!(normalize_client_id(Some(" ".to_string())).unwrap(), None);
}
#[test]
fn normalize_client_id_rejects_invalid_non_empty_values() {
let result = normalize_client_id(Some("not-a-uuid".to_string()));
assert!(matches!(result, Err(AppError::BadRequest(message)) if message.contains("UUID")));
}
#[test]
fn normalize_client_id_canonicalizes_valid_uuid_values() {
let result =
normalize_client_id(Some(" 67E55044-10B1-426F-9247-BB680E5FE0C8 ".to_string()))
.unwrap();
assert_eq!(
result,
Some("67e55044-10b1-426f-9247-bb680e5fe0c8".to_string())
);
}
#[test]
fn find_existing_movement_by_client_id_filters_inside_details() {
let sql = find_existing_movement_by_client_id_sql();
assert!(sql.contains("FROM operation_movements"));
assert!(sql.contains("details->>'client_id' = $1"));
assert!(sql.contains("asset_id = $2"));
assert!(sql.contains("LIMIT 1"));
}
#[test]
fn insert_movement_sql_is_conflict_safe_for_client_id() {
let sql = insert_movement_sql();
assert!(sql.contains("ON CONFLICT (asset_id, (details->>'client_id'))"));
assert!(sql.contains("WHERE NULLIF(BTRIM(details->>'client_id'), '') IS NOT NULL"));
assert!(sql.contains("DO NOTHING"));
}
}
/// GET /api/movements — Listar movimientos de un activo. /// GET /api/movements — Listar movimientos de un activo.
pub async fn list_movements( pub async fn list_movements(
State(pool): State<PgPool>, State(pool): State<PgPool>,
@@ -37,20 +147,14 @@ pub async fn list_movements(
let user_id = Uuid::parse_str(&claims.sub) let user_id = Uuid::parse_str(&claims.sub)
.map_err(|_| AppError::BadRequest("User ID inválido".to_string()))?; .map_err(|_| AppError::BadRequest("User ID inválido".to_string()))?;
let access = sqlx::query( well_access::ensure_asset_access(
r#"SELECT 1 FROM edge_assets ea &pool,
JOIN user_project_access upa ON ea.project_id = upa.project_id user_id,
WHERE ea.id = $1 AND upa.user_id = $2 AND upa.is_active = true"#, params.asset_id,
"Sin permiso para ver movimientos de este activo",
) )
.bind(params.asset_id)
.bind(user_id)
.fetch_optional(&pool)
.await?; .await?;
if access.is_none() {
return Err(AppError::Forbidden("Sin permiso para ver movimientos de este activo".to_string()));
}
let movements: Vec<OperationMovement> = sqlx::query_as::<Postgres, OperationMovement>( let movements: Vec<OperationMovement> = sqlx::query_as::<Postgres, OperationMovement>(
"SELECT * FROM operation_movements WHERE asset_id = $1 ORDER BY start_time DESC", "SELECT * FROM operation_movements WHERE asset_id = $1 ORDER BY start_time DESC",
) )
@@ -71,42 +175,58 @@ pub async fn create_movement(
let user_id = Uuid::parse_str(&claims.sub) let user_id = Uuid::parse_str(&claims.sub)
.map_err(|_| AppError::BadRequest("User ID inválido".to_string()))?; .map_err(|_| AppError::BadRequest("User ID inválido".to_string()))?;
let project_row = sqlx::query( let project_id = well_access::ensure_asset_access(
r#"SELECT ea.project_id FROM edge_assets ea &pool,
JOIN user_project_access upa ON ea.project_id = upa.project_id user_id,
WHERE ea.id = $1 AND upa.user_id = $2 AND upa.is_active = true"#, req.asset_id,
"Sin permiso para registrar movimientos en este activo",
) )
.await?;
let client_id = normalize_client_id(req.client_id)?;
if let Some(client_id) = client_id.as_deref() {
let existing = sqlx::query_as::<Postgres, OperationMovement>(
find_existing_movement_by_client_id_sql(),
)
.bind(client_id)
.bind(req.asset_id) .bind(req.asset_id)
.bind(user_id)
.fetch_optional(&pool) .fetch_optional(&pool)
.await?; .await?;
let project_id: Uuid = match project_row { if let Some(movement) = existing {
Some(row) => row.get("project_id"), return Ok((StatusCode::OK, Json(movement)));
None => return Err(AppError::Forbidden("Sin permiso para registrar movimientos en este activo".to_string())), }
}; }
let details = serde_json::json!({ let details = movement_details(req.observaciones, client_id.clone(), &claims.sub);
"observaciones": req.observaciones,
"created_by": claims.sub
});
let movement: OperationMovement = sqlx::query_as::<Postgres, OperationMovement>( let inserted = sqlx::query_as::<Postgres, OperationMovement>(insert_movement_sql())
r#"INSERT INTO operation_movements (
asset_id, movement_type, start_time, end_time, volumen_neto, details
)
VALUES ($1, $2, $3, $4, $5, $6)
RETURNING *"#,
)
.bind(req.asset_id) .bind(req.asset_id)
.bind(req.movement_type) .bind(req.movement_type)
.bind(req.start_time) .bind(req.start_time)
.bind(req.end_time) .bind(req.end_time)
.bind(req.volumen_neto) .bind(req.volumen_neto)
.bind(details) .bind(details)
.fetch_optional(&pool)
.await?;
let Some(movement) = inserted else {
let Some(client_id) = client_id.as_deref() else {
return Err(AppError::Database(sqlx::Error::RowNotFound));
};
let movement = sqlx::query_as::<Postgres, OperationMovement>(
find_existing_movement_by_client_id_sql(),
)
.bind(client_id)
.bind(req.asset_id)
.fetch_one(&pool) .fetch_one(&pool)
.await?; .await?;
return Ok((StatusCode::OK, Json(movement)));
};
// ─── Auditoría ──────────────────────────────────────────────────────────── // ─── Auditoría ────────────────────────────────────────────────────────────
audit_helper::log_audit( audit_helper::log_audit(
&pool, &pool,
@@ -119,8 +239,8 @@ pub async fn create_movement(
None, None,
Some(serde_json::to_value(&movement).unwrap_or_default()), Some(serde_json::to_value(&movement).unwrap_or_default()),
None, // Podríamos extraer IP del extractor ConnectInfo de axum None, // Podríamos extraer IP del extractor ConnectInfo de axum
).await; )
.await;
Ok((StatusCode::CREATED, Json(movement))) Ok((StatusCode::CREATED, Json(movement)))
} }

View File

@@ -130,6 +130,7 @@ pub async fn create_platform_operator(
to: &email, to: &email,
subject: &email_body.subject, subject: &email_body.subject,
body: &email_body.body, body: &email_body.body,
html_body: email_body.html_body.as_deref(),
}) })
.await; .await;

View File

@@ -12,11 +12,11 @@
//! 5. Limpia el usuario MQTT temporal. //! 5. Limpia el usuario MQTT temporal.
//! 6. Solo el agente puede descifrar la respuesta con su clave privada. //! 6. Solo el agente puede descifrar la respuesta con su clave privada.
use axum::{Json, extract::State, http::StatusCode};
use chrono::Utc; use chrono::Utc;
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use sqlx::PgPool; use sqlx::PgPool;
use uuid::Uuid; use uuid::Uuid;
use axum::{Json, extract::State, http::StatusCode};
// ─── Mensajes MQTT ──────────────────────────────────────────────────────────── // ─── Mensajes MQTT ────────────────────────────────────────────────────────────
@@ -63,7 +63,12 @@ pub async fn handle_mqtt_provisioning(
.bind(project_id) .bind(project_id)
.fetch_optional(pool) .fetch_optional(pool)
.await? .await?
.ok_or_else(|| anyhow::anyhow!("No hay token de provisioning activo para proyecto {}", project_id))?; .ok_or_else(|| {
anyhow::anyhow!(
"No hay token de provisioning activo para proyecto {}",
project_id
)
})?;
let token_id: Uuid = sqlx::Row::try_get(&token_row, "id")?; let token_id: Uuid = sqlx::Row::try_get(&token_row, "id")?;
@@ -86,9 +91,8 @@ pub async fn handle_mqtt_provisioning(
.await?; .await?;
// 5. Obtener config TOML del proyecto // 5. Obtener config TOML del proyecto
let config_toml: Option<String> = sqlx::query_scalar( let config_toml: Option<String> =
"SELECT agent_config_toml FROM edge_projects WHERE id = $1", sqlx::query_scalar("SELECT agent_config_toml FROM edge_projects WHERE id = $1")
)
.bind(project_id) .bind(project_id)
.fetch_optional(pool) .fetch_optional(pool)
.await? .await?
@@ -167,9 +171,8 @@ pub async fn rotate_provisioning_token(
let _ = claims; let _ = claims;
let db_err = |e: sqlx::Error| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()); let db_err = |e: sqlx::Error| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string());
let exists: Option<bool> = sqlx::query_scalar( let exists: Option<bool> =
"SELECT EXISTS(SELECT 1 FROM edge_projects WHERE id = $1)", sqlx::query_scalar("SELECT EXISTS(SELECT 1 FROM edge_projects WHERE id = $1)")
)
.bind(project_id) .bind(project_id)
.fetch_one(&pool) .fetch_one(&pool)
.await .await

View File

@@ -4,46 +4,89 @@
//! - `telemetry_raw` → Datos crudos, retención 31 días. Usado para gráfica en tiempo real. //! - `telemetry_raw` → Datos crudos, retención 31 días. Usado para gráfica en tiempo real.
//! - `telemetry_5min` → Agregado cada 5 min, retención 5 años. Usado para historial ANH. //! - `telemetry_5min` → Agregado cada 5 min, retención 5 años. Usado para historial ANH.
use crate::auth::Claims;
use crate::errors::AppError;
use crate::handlers::well_access;
use axum::{ use axum::{
Json,
extract::{Path, Query, State}, extract::{Path, Query, State},
http::StatusCode, http::StatusCode,
Json,
}; };
use chrono::{Duration, Utc}; use chrono::{Duration, Utc};
use serde::Deserialize; use serde::Deserialize;
use shared_lib::models::{
PostTelemetryRequest, TelemetryRecord, TelemetryResponse, TelemetrySource,
};
use sqlx::PgPool; use sqlx::PgPool;
use uuid::Uuid; use uuid::Uuid;
use shared_lib::models::{PostTelemetryRequest, TelemetryRecord, TelemetryResponse};
use crate::auth::Claims;
/// POST /api/telemetry — Ingresar una medición de campo (solo frontend/admin con JWT). fn app_error_to_tuple(error: AppError) -> (StatusCode, String) {
match error {
AppError::NotFound(entity) => (StatusCode::NOT_FOUND, format!("{} no encontrado", entity)),
AppError::Forbidden(message) => (StatusCode::FORBIDDEN, message),
AppError::BadRequest(message) => (StatusCode::BAD_REQUEST, message),
AppError::Database(_) => (
StatusCode::INTERNAL_SERVER_ERROR,
"Error interno de base de datos".to_string(),
),
AppError::Internal(_) => (
StatusCode::INTERNAL_SERVER_ERROR,
"Error interno del servidor".to_string(),
),
}
}
/// POST /api/telemetry — Official LF telemetry channel for PWA/manual clients.
/// ///
/// Los agentes de campo envían telemetría exclusivamente por MQTT (`omnioil/telemetry/{id}`), /// SCADA/HF agents send telemetry by MQTT (`omnioil/telemetry/{project_id}`), never by HTTP.
/// nunca por HTTP. Este endpoint es únicamente para el frontend administrativo.
/// ///
/// Idempotente: ON CONFLICT (time, asset_id) DO NOTHING permite reintentos sin duplicados. /// Idempotente: ON CONFLICT (time, asset_id) DO NOTHING permite reintentos sin duplicados.
pub async fn post_telemetry( pub async fn post_telemetry(
State(pool): State<PgPool>, State(pool): State<PgPool>,
_claims: Claims, claims: Claims,
Json(req): Json<PostTelemetryRequest>, Json(req): Json<PostTelemetryRequest>,
) -> Result<(StatusCode, Json<TelemetryResponse>), (StatusCode, String)> { ) -> Result<(StatusCode, Json<TelemetryResponse>), (StatusCode, String)> {
let time = req.time.unwrap_or_else(Utc::now); let time = req.time.unwrap_or_else(Utc::now);
let source = resolve_http_telemetry_source(req.source)?;
sqlx::query( let user_id = Uuid::parse_str(&claims.sub).map_err(|_| {
r#"INSERT INTO telemetry_raw (time, asset_id, data) (
VALUES ($1, $2, $3) StatusCode::UNAUTHORIZED,
ON CONFLICT (time, asset_id) DO NOTHING"#, "User ID inválido en token".to_string(),
) )
})?;
well_access::ensure_asset_access(
&pool,
user_id,
req.asset_id,
"Access denied to this asset's telemetry",
)
.await
.map_err(app_error_to_tuple)?;
let result = sqlx::query(post_telemetry_insert_sql())
.bind(time) .bind(time)
.bind(req.asset_id) .bind(req.asset_id)
.bind(&req.data) .bind(&req.data)
.bind(source)
.bind(&req.client_id)
.bind(user_id)
.execute(&pool) .execute(&pool)
.await .await
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?; .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
if result.rows_affected() == 0 {
tracing::debug!(
asset_id = %req.asset_id,
time = %time,
"Telemetry record already exists; treating POST as idempotent"
);
}
tracing::info!( tracing::info!(
asset_id = %req.asset_id, asset_id = %req.asset_id,
time = %time, time = %time,
source = ?source,
"Telemetry record inserted" "Telemetry record inserted"
); );
@@ -51,6 +94,8 @@ pub async fn post_telemetry(
time, time,
asset_id: req.asset_id, asset_id: req.asset_id,
data: req.data, data: req.data,
source,
client_id: req.client_id,
}; };
Ok((StatusCode::CREATED, Json(response))) Ok((StatusCode::CREATED, Json(response)))
@@ -83,16 +128,24 @@ pub async fn get_asset_telemetry(
let access = sqlx::query( let access = sqlx::query(
r#"SELECT 1 FROM edge_assets ea r#"SELECT 1 FROM edge_assets ea
JOIN user_project_access upa ON ea.project_id = upa.project_id JOIN user_project_access upa ON ea.project_id = upa.project_id
WHERE ea.id = $1 AND upa.user_id = $2 AND upa.is_active = true"# WHERE ea.id = $1 AND upa.user_id = $2 AND upa.is_active = true"#,
) )
.bind(asset_id) .bind(asset_id)
.bind(Uuid::parse_str(&claims.sub).map_err(|_| (StatusCode::UNAUTHORIZED, "User ID inválido en token".to_string()))?) .bind(Uuid::parse_str(&claims.sub).map_err(|_| {
(
StatusCode::UNAUTHORIZED,
"User ID inválido en token".to_string(),
)
})?)
.fetch_optional(&pool) .fetch_optional(&pool)
.await .await
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?; .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
if access.is_none() { if access.is_none() {
return Err((StatusCode::FORBIDDEN, "Access denied to this asset's telemetry".to_string())); return Err((
StatusCode::FORBIDDEN,
"Access denied to this asset's telemetry".to_string(),
));
} }
let limit = params.limit.unwrap_or(10).min(100); let limit = params.limit.unwrap_or(10).min(100);
@@ -100,7 +153,7 @@ pub async fn get_asset_telemetry(
let since = Utc::now() - Duration::days(31); let since = Utc::now() - Duration::days(31);
let records = sqlx::query_as::<_, TelemetryRecord>( let records = sqlx::query_as::<_, TelemetryRecord>(
r#"SELECT time, asset_id, data r#"SELECT time, asset_id, data, source, client_id
FROM telemetry_raw FROM telemetry_raw
WHERE asset_id = $1 WHERE asset_id = $1
AND time >= $2 AND time >= $2
@@ -117,6 +170,80 @@ pub async fn get_asset_telemetry(
Ok(Json(records)) Ok(Json(records))
} }
pub(crate) fn post_telemetry_insert_sql() -> &'static str {
r#"INSERT INTO telemetry_raw (time, project_id, asset_id, data, source, client_id)
SELECT $1, ea.project_id, ea.id, $3, $4, $5
FROM edge_assets ea
JOIN user_project_access upa
ON upa.project_id = ea.project_id
AND upa.user_id = $6
AND upa.is_active = true
WHERE ea.id = $2
ON CONFLICT (time, asset_id) DO NOTHING"#
}
pub(crate) fn resolve_http_telemetry_source(
source: Option<TelemetrySource>,
) -> Result<TelemetrySource, (StatusCode, String)> {
match source.unwrap_or(TelemetrySource::Pwa) {
TelemetrySource::Scada => Err((
StatusCode::BAD_REQUEST,
"SCADA telemetry must be ingested via MQTT, not HTTP".to_string(),
)),
source @ (TelemetrySource::Pwa | TelemetrySource::Manual) => Ok(source),
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn pwa_insert_derives_project_id_from_asset_and_persists_source_client() {
let sql = post_telemetry_insert_sql();
assert!(sql.contains(
"INSERT INTO telemetry_raw (time, project_id, asset_id, data, source, client_id)"
));
assert!(sql.contains("SELECT $1, ea.project_id, ea.id, $3, $4, $5"));
assert!(sql.contains("FROM edge_assets ea"));
assert!(sql.contains("JOIN user_project_access upa"));
assert!(sql.contains("upa.project_id = ea.project_id"));
assert!(sql.contains("upa.user_id = $6"));
assert!(sql.contains("upa.is_active = true"));
assert!(sql.contains("WHERE ea.id = $2"));
assert!(sql.contains("ON CONFLICT (time, asset_id) DO NOTHING"));
}
#[test]
fn http_source_defaults_to_pwa_and_allows_manual() {
assert_eq!(
resolve_http_telemetry_source(None).unwrap(),
TelemetrySource::Pwa
);
assert_eq!(
resolve_http_telemetry_source(Some(TelemetrySource::Manual)).unwrap(),
TelemetrySource::Manual
);
}
#[test]
fn http_source_rejects_scada() {
let err = resolve_http_telemetry_source(Some(TelemetrySource::Scada)).unwrap_err();
assert_eq!(err.0, StatusCode::BAD_REQUEST);
assert!(err.1.contains("SCADA telemetry must be ingested via MQTT"));
}
#[test]
fn post_telemetry_uses_well_scoped_asset_access() {
let source = include_str!("telemetry.rs");
assert!(source.contains("well_access::ensure_asset_access"));
assert!(source.contains("Access denied to this asset's telemetry"));
}
}
/// GET /api/telemetry/:asset_id/history — Historial largo (agregado de 5 minutos). /// GET /api/telemetry/:asset_id/history — Historial largo (agregado de 5 minutos).
/// ///
/// Usa `telemetry_5min` (vista continua de TimescaleDB), que retiene datos por 5 años. /// Usa `telemetry_5min` (vista continua de TimescaleDB), que retiene datos por 5 años.
@@ -128,14 +255,18 @@ pub async fn get_asset_telemetry_history(
Path(asset_id): Path<Uuid>, Path(asset_id): Path<Uuid>,
Query(params): Query<TelemetryHistoryParams>, Query(params): Query<TelemetryHistoryParams>,
) -> Result<Json<serde_json::Value>, (StatusCode, String)> { ) -> Result<Json<serde_json::Value>, (StatusCode, String)> {
let user_id = Uuid::parse_str(&claims.sub) let user_id = Uuid::parse_str(&claims.sub).map_err(|_| {
.map_err(|_| (StatusCode::UNAUTHORIZED, "User ID inválido en token".to_string()))?; (
StatusCode::UNAUTHORIZED,
"User ID inválido en token".to_string(),
)
})?;
// Validar acceso // Validar acceso
let access = sqlx::query( let access = sqlx::query(
r#"SELECT 1 FROM edge_assets ea r#"SELECT 1 FROM edge_assets ea
JOIN user_project_access upa ON ea.project_id = upa.project_id JOIN user_project_access upa ON ea.project_id = upa.project_id
WHERE ea.id = $1 AND upa.user_id = $2 AND upa.is_active = true"# WHERE ea.id = $1 AND upa.user_id = $2 AND upa.is_active = true"#,
) )
.bind(asset_id) .bind(asset_id)
.bind(user_id) .bind(user_id)
@@ -144,7 +275,10 @@ pub async fn get_asset_telemetry_history(
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?; .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
if access.is_none() { if access.is_none() {
return Err((StatusCode::FORBIDDEN, "Access denied to this asset's telemetry".to_string())); return Err((
StatusCode::FORBIDDEN,
"Access denied to this asset's telemetry".to_string(),
));
} }
// Calcular ventana de tiempo: máximo 5 años (retención del agregado) // Calcular ventana de tiempo: máximo 5 años (retención del agregado)
@@ -195,4 +329,3 @@ pub async fn get_asset_telemetry_history(
"count": records.len() "count": records.len()
}))) })))
} }

View File

@@ -272,7 +272,8 @@ pub async fn status(
PlatformClaims(claims): PlatformClaims, PlatformClaims(claims): PlatformClaims,
) -> Result<Json<TotpStatusResponse>, AuthError> { ) -> Result<Json<TotpStatusResponse>, AuthError> {
let user_id = uuid::Uuid::parse_str(&claims.sub).map_err(|_| AuthError::InvalidCredentials)?; let user_id = uuid::Uuid::parse_str(&claims.sub).map_err(|_| AuthError::InvalidCredentials)?;
let two_factor_enabled: bool = sqlx::query_scalar("SELECT two_factor_enabled FROM users WHERE id = $1") let two_factor_enabled: bool =
sqlx::query_scalar("SELECT two_factor_enabled FROM users WHERE id = $1")
.bind(user_id) .bind(user_id)
.fetch_one(&pool) .fetch_one(&pool)
.await .await

View File

@@ -7,27 +7,113 @@
//! //!
//! Edge Agent ──► GET /api/edge/update-info (solo metadatos) //! Edge Agent ──► GET /api/edge/update-info (solo metadatos)
//! Edge Agent ──► GET /api/edge/download-agent ──► MinIO (sin puerto expuesto) //! Edge Agent ──► GET /api/edge/download-agent ──► MinIO (sin puerto expuesto)
//! │ verifica Bearer token //! │ verifica firma HMAC (ts+sig)
//! │ hace streaming desde MinIO interno //! │ hace streaming desde MinIO interno
//! └──► Edge Agent recibe el binario //! └──► Edge Agent recibe el binario
//! ``` //! ```
//! //!
//! MinIO NUNCA tiene puerto expuesto al exterior (no hay mapeo en docker-compose.yml). //! MinIO NUNCA tiene puerto expuesto al exterior (no hay mapeo en docker-compose.yml).
//! Todo el tráfico pasa por el backend API que valida autenticación y puede auditar. //! Todo el tráfico pasa por el backend API que valida la firma HMAC de la URL.
//! La firma usa AGENT_DOWNLOAD_SECRET y expira a los 5 minutos.
use axum::{ use axum::{
Json,
body::Body, body::Body,
extract::{Query, State}, extract::{Query, State},
http::{HeaderMap, HeaderValue, StatusCode, header}, http::{HeaderMap, HeaderValue, StatusCode, header},
response::{IntoResponse, Response}, response::{IntoResponse, Response},
Json,
}; };
use hmac::{Hmac, Mac};
use semver::Version; use semver::Version;
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use sha2::Sha256;
use std::time::{SystemTime, UNIX_EPOCH};
use tracing::{error, info, warn}; use tracing::{error, info, warn};
use crate::AppState; use crate::AppState;
type HmacSha256 = Hmac<Sha256>;
/// Verifica que la URL firmada sea válida y no haya expirado.
/// Si AGENT_DOWNLOAD_SECRET no está configurado, opera en modo compatibilidad (sin firma).
fn verify_signed_url(message: &str, timestamp: &str, signature: &str, max_age_secs: u64) -> Result<(), Response> {
let secret = match std::env::var("AGENT_DOWNLOAD_SECRET") {
Ok(s) => s,
Err(_) => {
warn!("AGENT_DOWNLOAD_SECRET not set — download authentication disabled");
return Ok(());
}
};
let now = SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap_or_default()
.as_secs();
let ts: u64 = timestamp.parse().map_err(|_| {
(StatusCode::BAD_REQUEST, Json(serde_json::json!({ "error": "Invalid timestamp" }))).into_response()
})?;
if now.saturating_sub(ts) > max_age_secs {
return Err(
(StatusCode::BAD_REQUEST, Json(serde_json::json!({ "error": "Signature expired" }))).into_response()
);
}
let mut mac = HmacSha256::new_from_slice(secret.as_bytes())
.map_err(|_| {
(StatusCode::INTERNAL_SERVER_ERROR, Json(serde_json::json!({ "error": "HMAC error" }))).into_response()
})?;
mac.update(message.as_bytes());
let expected = hex::encode(mac.finalize().into_bytes());
// Comparación en tiempo constante
if expected.as_bytes() != signature.as_bytes() {
return Err(
(StatusCode::FORBIDDEN, Json(serde_json::json!({ "error": "Invalid signature" }))).into_response()
);
}
Ok(())
}
/// Genera una URL firmada para descarga del binario.
/// Si AGENT_DOWNLOAD_SECRET no está configurado, devuelve URL sin firma (modo compatibilidad).
fn generate_signed_url(api_base: &str, version: &str, asset_type: &str) -> String {
let secret = match std::env::var("AGENT_DOWNLOAD_SECRET") {
Ok(s) => s,
Err(_) => {
let url = format!(
"{}/api/edge/download-agent?version={}&asset_type={}",
api_base.trim_end_matches('/'),
version,
asset_type,
);
return url;
}
};
let now = SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap_or_default()
.as_secs();
let ts = now.to_string();
let message = format!("{}:{}", version, ts);
let mut mac = HmacSha256::new_from_slice(secret.as_bytes())
.expect("HMAC key init");
mac.update(message.as_bytes());
let sig = hex::encode(mac.finalize().into_bytes());
format!(
"{}/api/edge/download-agent?version={}&asset_type={}&ts={}&sig={}",
api_base.trim_end_matches('/'),
version,
asset_type,
ts,
sig,
)
}
// ── DTOs ───────────────────────────────────────────────────────────────────── // ── DTOs ─────────────────────────────────────────────────────────────────────
#[derive(Debug, Deserialize)] #[derive(Debug, Deserialize)]
@@ -36,6 +122,10 @@ pub struct UpdateQuery {
pub version: String, pub version: String,
/// Versión específica para rollback/pin (ej. "0.1.9"). Opcional. /// Versión específica para rollback/pin (ej. "0.1.9"). Opcional.
pub target: Option<String>, pub target: Option<String>,
/// Timestamp UNIX para firma HMAC.
pub ts: Option<String>,
/// HMAC-SHA256 signature: hex(version:timestamp).
pub sig: Option<String>,
} }
#[derive(Debug, Deserialize)] #[derive(Debug, Deserialize)]
@@ -44,6 +134,10 @@ pub struct DownloadQuery {
pub version: String, pub version: String,
/// Tipo de asset: "msi" | "exe" (default: "msi"). /// Tipo de asset: "msi" | "exe" (default: "msi").
pub asset_type: Option<String>, pub asset_type: Option<String>,
/// Timestamp UNIX para firma HMAC.
pub ts: Option<String>,
/// HMAC-SHA256 signature: hex(version:timestamp).
pub sig: Option<String>,
} }
#[derive(Debug, Serialize)] #[derive(Debug, Serialize)]
@@ -67,7 +161,7 @@ fn err(status: StatusCode, msg: &str) -> Response {
// ── Handlers ────────────────────────────────────────────────────────────────── // ── Handlers ──────────────────────────────────────────────────────────────────
/// GET /api/edge/update-info?version=<current>[&target=<pin>] /// GET /api/edge/update-info?version=<current>[&target=<pin>][&ts=<unix>&sig=<hex>]
/// ///
/// Devuelve metadatos de la actualización disponible. La `download_url` apunta /// Devuelve metadatos de la actualización disponible. La `download_url` apunta
/// al endpoint `/api/edge/download-agent` del propio backend, no a MinIO. /// al endpoint `/api/edge/download-agent` del propio backend, no a MinIO.
@@ -75,6 +169,19 @@ pub async fn get_update_info(
State(_state): State<AppState>, State(_state): State<AppState>,
Query(params): Query<UpdateQuery>, Query(params): Query<UpdateQuery>,
) -> Response { ) -> Response {
// 0. Verificar firma HMAC (si AGENT_DOWNLOAD_SECRET está configurado)
if let (Some(ts), Some(sig)) = (&params.ts, &params.sig) {
let msg = format!("{}:{}", params.version, ts);
if let Err(resp) = verify_signed_url(&msg, ts, sig, 300) {
return resp;
}
} else if std::env::var("AGENT_DOWNLOAD_SECRET").is_ok() {
return (
StatusCode::BAD_REQUEST,
Json(serde_json::json!({ "error": "Missing signature (ts and sig params required)" })),
).into_response();
}
// 1. Parsear versión actual // 1. Parsear versión actual
let current = match Version::parse(&params.version) { let current = match Version::parse(&params.version) {
Ok(v) => v, Ok(v) => v,
@@ -82,7 +189,7 @@ pub async fn get_update_info(
return err( return err(
StatusCode::BAD_REQUEST, StatusCode::BAD_REQUEST,
&format!("Invalid version format: '{}'", params.version), &format!("Invalid version format: '{}'", params.version),
) );
} }
}; };
@@ -128,14 +235,10 @@ pub async fn get_update_info(
// 4. SHA-256: leerlo desde MinIO internamente (el agente no necesita acceder a MinIO) // 4. SHA-256: leerlo desde MinIO internamente (el agente no necesita acceder a MinIO)
let sha256 = fetch_sha256_internal(&latest.to_string(), "msi").await; let sha256 = fetch_sha256_internal(&latest.to_string(), "msi").await;
// 5. download_url apunta al endpoint del BACKEND, no a MinIO directamente // 5. download_url apunta al endpoint del BACKEND con firma HMAC, no a MinIO directamente
let api_base = std::env::var("API_BASE_URL") let api_base =
.unwrap_or_else(|_| "https://api.omnioil.com".to_string()); std::env::var("API_BASE_URL").unwrap_or_else(|_| "https://api.omnioil.com".to_string());
let download_url = format!( let download_url = generate_signed_url(&api_base, &latest.to_string(), "msi");
"{}/api/edge/download-agent?version={}&asset_type=msi",
api_base.trim_end_matches('/'),
latest
);
info!("Agent v{} → update available: v{}", current, latest); info!("Agent v{} → update available: v{}", current, latest);
@@ -149,7 +252,7 @@ pub async fn get_update_info(
.into_response() .into_response()
} }
/// GET /api/edge/download-agent?version=<v>&asset_type=<msi|exe> /// GET /api/edge/download-agent?version=<v>&asset_type=<msi|exe>&ts=<unix>&sig=<hex>
/// ///
/// Proxy autenticado: descarga el binario desde MinIO (red interna) y lo /// Proxy autenticado: descarga el binario desde MinIO (red interna) y lo
/// envía al agente en streaming. El agente nunca ve la URL de MinIO. /// envía al agente en streaming. El agente nunca ve la URL de MinIO.
@@ -157,13 +260,27 @@ pub async fn download_agent(
State(_state): State<AppState>, State(_state): State<AppState>,
Query(params): Query<DownloadQuery>, Query(params): Query<DownloadQuery>,
) -> Response { ) -> Response {
// 0. Verificar firma HMAC (si AGENT_DOWNLOAD_SECRET está configurado)
let asset_type = params.asset_type.as_deref().unwrap_or("msi"); let asset_type = params.asset_type.as_deref().unwrap_or("msi");
let version = params.version.trim_start_matches('v'); let version_clean = params.version.trim_start_matches('v');
let minio_internal = std::env::var("MINIO_INTERNAL_URL") if let (Some(ts), Some(sig)) = (&params.ts, &params.sig) {
.unwrap_or_else(|_| "http://anh_minio:9000".to_string()); let msg = format!("{}:{}", version_clean, ts);
let bucket = std::env::var("MINIO_BUCKET") if let Err(resp) = verify_signed_url(&msg, ts, sig, 300) {
.unwrap_or_else(|_| "omnioil-releases".to_string()); return resp;
}
} else if std::env::var("AGENT_DOWNLOAD_SECRET").is_ok() {
return (
StatusCode::BAD_REQUEST,
Json(serde_json::json!({ "error": "Missing signature (ts and sig params required)" })),
).into_response();
}
let version = version_clean;
let minio_internal =
std::env::var("MINIO_INTERNAL_URL").unwrap_or_else(|_| "http://anh_minio:9000".to_string());
let bucket = std::env::var("MINIO_BUCKET").unwrap_or_else(|_| "omnioil-releases".to_string());
// Convención de paths en MinIO: // Convención de paths en MinIO:
// releases/edge-agent/v0.2.1/omnioil-edge-agent-windows-amd64.msi // releases/edge-agent/v0.2.1/omnioil-edge-agent-windows-amd64.msi
@@ -172,9 +289,17 @@ pub async fn download_agent(
_ => format!("omnioil-edge-agent-windows-amd64.msi"), _ => format!("omnioil-edge-agent-windows-amd64.msi"),
}; };
let object_key = format!("releases/edge-agent/v{}/{}", version, filename); let object_key = format!("releases/edge-agent/v{}/{}", version, filename);
let minio_url = format!("{}/{}/{}", minio_internal.trim_end_matches('/'), bucket, object_key); let minio_url = format!(
"{}/{}/{}",
minio_internal.trim_end_matches('/'),
bucket,
object_key
);
info!("📦 Proxying agent download: {} → {}", params.version, object_key); info!(
"📦 Proxying agent download: {} → {}",
params.version, object_key
);
// Descargar desde MinIO (red interna — sin autenticación pública) // Descargar desde MinIO (red interna — sin autenticación pública)
let minio_token = std::env::var("MINIO_ACCESS_KEY").ok(); let minio_token = std::env::var("MINIO_ACCESS_KEY").ok();
@@ -214,7 +339,10 @@ pub async fn download_agent(
return if status.as_u16() == 404 { return if status.as_u16() == 404 {
err( err(
StatusCode::NOT_FOUND, StatusCode::NOT_FOUND,
&format!("Agent binary v{} ({}) not found in storage", version, asset_type), &format!(
"Agent binary v{} ({}) not found in storage",
version, asset_type
),
) )
} else { } else {
err(StatusCode::BAD_GATEWAY, "Asset storage error") err(StatusCode::BAD_GATEWAY, "Asset storage error")
@@ -243,27 +371,26 @@ pub async fn download_agent(
} }
} }
( (StatusCode::OK, headers, Body::from_stream(byte_stream)).into_response()
StatusCode::OK,
headers,
Body::from_stream(byte_stream),
)
.into_response()
} }
/// Obtiene el SHA-256 del asset desde MinIO internamente. /// Obtiene el SHA-256 del asset desde MinIO internamente.
pub async fn fetch_sha256_internal(version: &str, asset_type: &str) -> Option<String> { pub async fn fetch_sha256_internal(version: &str, asset_type: &str) -> Option<String> {
let minio_internal = std::env::var("MINIO_INTERNAL_URL") let minio_internal =
.unwrap_or_else(|_| "http://anh_minio:9000".to_string()); std::env::var("MINIO_INTERNAL_URL").unwrap_or_else(|_| "http://anh_minio:9000".to_string());
let bucket = std::env::var("MINIO_BUCKET") let bucket = std::env::var("MINIO_BUCKET").unwrap_or_else(|_| "omnioil-releases".to_string());
.unwrap_or_else(|_| "omnioil-releases".to_string());
let ext = if asset_type == "exe" { "exe" } else { "msi" }; let ext = if asset_type == "exe" { "exe" } else { "msi" };
let sha_key = format!( let sha_key = format!(
"releases/edge-agent/v{}/omnioil-edge-agent-windows-amd64.{}.sha256", "releases/edge-agent/v{}/omnioil-edge-agent-windows-amd64.{}.sha256",
version, ext version, ext
); );
let url = format!("{}/{}/{}", minio_internal.trim_end_matches('/'), bucket, sha_key); let url = format!(
"{}/{}/{}",
minio_internal.trim_end_matches('/'),
bucket,
sha_key
);
let client = reqwest::Client::builder() let client = reqwest::Client::builder()
.timeout(std::time::Duration::from_secs(10)) .timeout(std::time::Duration::from_secs(10))
@@ -280,11 +407,13 @@ pub async fn fetch_sha256_internal(version: &str, asset_type: &str) -> Option<St
let resp = req.send().await.ok()?; let resp = req.send().await.ok()?;
if !resp.status().is_success() { if !resp.status().is_success() {
warn!("SHA-256 not found in MinIO for agent v{} ({})", version, asset_type); warn!(
"SHA-256 not found in MinIO for agent v{} ({})",
version, asset_type
);
return None; return None;
} }
let text = resp.text().await.ok()?; let text = resp.text().await.ok()?;
Some(text.split_whitespace().next()?.to_lowercase()) Some(text.split_whitespace().next()?.to_lowercase())
} }

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,337 @@
use crate::auth::{AdminClaims, Claims};
use crate::errors::AppError;
use axum::{
Json,
extract::{Path, State},
};
use serde::{Deserialize, Serialize};
use shared_lib::models::EdgeAsset;
use sqlx::{PgPool, Row};
use uuid::Uuid;
#[derive(Debug, Deserialize)]
pub struct SetUserWellsRequest {
pub well_ids: Vec<Uuid>,
}
#[derive(Debug, Serialize)]
pub struct SetUserWellsResponse {
pub message: String,
pub count: usize,
}
pub fn project_access_sql() -> &'static str {
r#"SELECT 1
FROM user_project_access
WHERE user_id = $1
AND project_id = $2
AND is_active = true"#
}
pub fn active_well_assignments_exist_sql() -> &'static str {
r#"SELECT 1
FROM user_well_access
WHERE user_id = $1
AND project_id = $2
AND is_active = true
LIMIT 1"#
}
pub fn asset_access_sql() -> &'static str {
r#"SELECT 1
FROM edge_assets ea
JOIN user_project_access upa
ON upa.project_id = ea.project_id
AND upa.user_id = $2
AND upa.is_active = true
WHERE ea.id = $1
AND (
NOT EXISTS (
SELECT 1
FROM user_well_access uwa_any
WHERE uwa_any.user_id = $2
AND uwa_any.project_id = ea.project_id
AND uwa_any.is_active = true
)
OR ea.asset_type <> 'POZO'
OR EXISTS (
SELECT 1
FROM user_well_access uwa
WHERE uwa.user_id = $2
AND uwa.project_id = ea.project_id
AND uwa.well_asset_id = ea.id
AND uwa.is_active = true
)
)"#
}
pub fn project_assets_sql() -> &'static str {
r#"SELECT ea.*
FROM edge_assets ea
JOIN user_project_access upa
ON upa.project_id = ea.project_id
AND upa.user_id = $2
AND upa.is_active = true
WHERE ea.project_id = $1
AND (
NOT EXISTS (
SELECT 1
FROM user_well_access uwa_any
WHERE uwa_any.user_id = $2
AND uwa_any.project_id = ea.project_id
AND uwa_any.is_active = true
)
OR ea.asset_type <> 'POZO'
OR EXISTS (
SELECT 1
FROM user_well_access uwa
WHERE uwa.user_id = $2
AND uwa.project_id = ea.project_id
AND uwa.well_asset_id = ea.id
AND uwa.is_active = true
)
)
ORDER BY ea.created_at DESC"#
}
pub async fn ensure_project_access(
pool: &PgPool,
user_id: Uuid,
project_id: Uuid,
) -> Result<(), AppError> {
let access = sqlx::query(project_access_sql())
.bind(user_id)
.bind(project_id)
.fetch_optional(pool)
.await?;
if access.is_none() {
return Err(AppError::Forbidden(
"Sin acceso a este proyecto".to_string(),
));
}
Ok(())
}
pub async fn ensure_asset_access(
pool: &PgPool,
user_id: Uuid,
asset_id: Uuid,
forbidden_message: &str,
) -> Result<Uuid, AppError> {
let row = sqlx::query(
r#"SELECT ea.project_id
FROM edge_assets ea
WHERE ea.id = $1"#,
)
.bind(asset_id)
.fetch_optional(pool)
.await?
.ok_or(AppError::NotFound("Activo".to_string()))?;
let project_id: Uuid = row.get("project_id");
let access = sqlx::query(asset_access_sql())
.bind(asset_id)
.bind(user_id)
.fetch_optional(pool)
.await?;
if access.is_none() {
return Err(AppError::Forbidden(forbidden_message.to_string()));
}
Ok(project_id)
}
fn parse_claim_user_id(claims: &Claims) -> Result<Uuid, AppError> {
Uuid::parse_str(&claims.sub).map_err(|_| AppError::BadRequest("User ID inválido".to_string()))
}
fn is_superadmin(claims: &Claims) -> bool {
claims.role == "superadmin"
}
async fn ensure_admin_project_access(
pool: &PgPool,
admin: &AdminClaims,
project_id: Uuid,
) -> Result<(), AppError> {
if is_superadmin(&admin.0) {
return Ok(());
}
ensure_project_access(pool, parse_claim_user_id(&admin.0)?, project_id).await
}
pub async fn list_project_wells(
State(pool): State<PgPool>,
admin: AdminClaims,
Path(project_id): Path<Uuid>,
) -> Result<Json<Vec<EdgeAsset>>, AppError> {
ensure_admin_project_access(&pool, &admin, project_id).await?;
let wells = sqlx::query_as::<_, EdgeAsset>(
r#"SELECT *
FROM edge_assets
WHERE project_id = $1
AND asset_type = 'POZO'
AND is_active = true
ORDER BY name ASC"#,
)
.bind(project_id)
.fetch_all(&pool)
.await?;
Ok(Json(wells))
}
pub async fn get_user_wells(
State(pool): State<PgPool>,
admin: AdminClaims,
Path(user_id): Path<Uuid>,
) -> Result<Json<Vec<EdgeAsset>>, AppError> {
if is_superadmin(&admin.0) {
let wells = sqlx::query_as::<_, EdgeAsset>(
r#"SELECT ea.*
FROM edge_assets ea
JOIN user_well_access uwa ON uwa.well_asset_id = ea.id
WHERE uwa.user_id = $1
AND uwa.is_active = true
ORDER BY ea.name ASC"#,
)
.bind(user_id)
.fetch_all(&pool)
.await?;
return Ok(Json(wells));
}
let admin_id = parse_claim_user_id(&admin.0)?;
let wells = sqlx::query_as::<_, EdgeAsset>(
r#"SELECT ea.*
FROM edge_assets ea
JOIN user_well_access uwa ON uwa.well_asset_id = ea.id
JOIN user_project_access admin_upa ON admin_upa.project_id = ea.project_id
WHERE uwa.user_id = $1
AND uwa.is_active = true
AND admin_upa.user_id = $2
AND admin_upa.is_active = true
ORDER BY ea.name ASC"#,
)
.bind(user_id)
.bind(admin_id)
.fetch_all(&pool)
.await?;
Ok(Json(wells))
}
pub async fn set_user_wells(
State(pool): State<PgPool>,
admin: AdminClaims,
Path(user_id): Path<Uuid>,
Json(req): Json<SetUserWellsRequest>,
) -> Result<Json<SetUserWellsResponse>, AppError> {
let mut tx = pool.begin().await?;
if is_superadmin(&admin.0) {
sqlx::query("DELETE FROM user_well_access WHERE user_id = $1")
.bind(user_id)
.execute(&mut *tx)
.await?;
} else {
let admin_id = parse_claim_user_id(&admin.0)?;
sqlx::query(
r#"DELETE FROM user_well_access uwa
USING user_project_access admin_upa
WHERE uwa.user_id = $1
AND admin_upa.user_id = $2
AND admin_upa.project_id = uwa.project_id
AND admin_upa.is_active = true"#,
)
.bind(user_id)
.bind(admin_id)
.execute(&mut *tx)
.await?;
}
for well_id in &req.well_ids {
let well = sqlx::query(
r#"SELECT id, project_id
FROM edge_assets
WHERE id = $1
AND asset_type = 'POZO'
AND is_active = true"#,
)
.bind(well_id)
.fetch_optional(&mut *tx)
.await?
.ok_or(AppError::BadRequest(
"Solo se pueden asignar pozos activos".to_string(),
))?;
let project_id: Uuid = well.get("project_id");
ensure_admin_project_access(&pool, &admin, project_id).await?;
ensure_project_access(&pool, user_id, project_id).await?;
sqlx::query(
r#"INSERT INTO user_well_access (user_id, project_id, well_asset_id, is_active)
VALUES ($1, $2, $3, true)
ON CONFLICT (user_id, well_asset_id)
DO UPDATE SET project_id = EXCLUDED.project_id,
is_active = true,
updated_at = NOW()"#,
)
.bind(user_id)
.bind(project_id)
.bind(well_id)
.execute(&mut *tx)
.await?;
}
tx.commit().await?;
Ok(Json(SetUserWellsResponse {
message: "Pozos asignados exitosamente".to_string(),
count: req.well_ids.len(),
}))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn asset_access_is_project_access_with_optional_well_scope() {
let sql = asset_access_sql();
assert!(sql.contains("JOIN user_project_access upa"));
assert!(sql.contains("NOT EXISTS"));
assert!(sql.contains("FROM user_well_access uwa_any"));
assert!(sql.contains("ea.asset_type <> 'POZO'"));
assert!(sql.contains("uwa.well_asset_id = ea.id"));
}
#[test]
fn project_assets_are_filtered_by_well_assignments_when_present() {
let sql = project_assets_sql();
assert!(sql.contains("SELECT ea.*"));
assert!(sql.contains("ea.project_id = $1"));
assert!(sql.contains("upa.user_id = $2"));
assert!(sql.contains("ea.asset_type <> 'POZO'"));
assert!(sql.contains("uwa.well_asset_id = ea.id"));
}
#[test]
fn non_superadmin_replacement_deletes_only_visible_scope() {
let source = include_str!("well_access.rs");
assert!(source.contains("DELETE FROM user_well_access uwa"));
assert!(source.contains("USING user_project_access admin_upa"));
assert!(source.contains("admin_upa.project_id = uwa.project_id"));
}
}

View File

@@ -1,6 +1,6 @@
use crate::auth::Claims; use crate::auth::Claims;
use crate::handlers::audit_helper;
use crate::errors::AppError; use crate::errors::AppError;
use crate::handlers::audit_helper;
use axum::{ use axum::{
Json, Json,
extract::{Query, State}, extract::{Query, State},
@@ -52,7 +52,9 @@ pub async fn list_well_tests(
.await?; .await?;
if access.is_none() { if access.is_none() {
return Err(AppError::Forbidden("Sin permiso para ver pruebas de este activo".to_string())); return Err(AppError::Forbidden(
"Sin permiso para ver pruebas de este activo".to_string(),
));
} }
let records: Vec<WellTest> = sqlx::query_as::<Postgres, WellTest>( let records: Vec<WellTest> = sqlx::query_as::<Postgres, WellTest>(
@@ -86,7 +88,11 @@ pub async fn create_well_test(
let project_id: Uuid = match project_row { let project_id: Uuid = match project_row {
Some(row) => row.get("project_id"), Some(row) => row.get("project_id"),
None => return Err(AppError::Forbidden("Sin permiso para registrar pruebas en este activo".to_string())), None => {
return Err(AppError::Forbidden(
"Sin permiso para registrar pruebas en este activo".to_string(),
));
}
}; };
let record: WellTest = sqlx::query_as::<Postgres, WellTest>( let record: WellTest = sqlx::query_as::<Postgres, WellTest>(
@@ -122,7 +128,8 @@ pub async fn create_well_test(
None, None,
Some(serde_json::to_value(&record).unwrap_or_default()), Some(serde_json::to_value(&record).unwrap_or_default()),
None, None,
).await; )
.await;
Ok((StatusCode::CREATED, Json(record))) Ok((StatusCode::CREATED, Json(record)))
} }

View File

@@ -2,13 +2,16 @@ pub mod access_request_emails;
pub mod api_version; pub mod api_version;
pub mod audit; pub mod audit;
pub mod auth; pub mod auth;
pub mod billing_rules;
pub mod crypto; pub mod crypto;
pub mod db; pub mod db;
pub mod errors; pub mod errors;
pub mod handlers; pub mod handlers;
pub mod invitation_tokens; pub mod invitation_tokens;
pub mod metrics; pub mod metrics;
pub mod mqtt_loop;
pub mod notifier; pub mod notifier;
pub mod ws;
pub mod passwords; pub mod passwords;
pub mod rate_limiter; pub mod rate_limiter;
pub mod smtp_mailer; pub mod smtp_mailer;

View File

@@ -1,24 +1,16 @@
use axum::{ use axum::{
Router, Router,
extract::{
Query, State,
ws::{Message, WebSocket, WebSocketUpgrade},
},
response::IntoResponse,
routing::{get, patch, post, put}, routing::{get, patch, post, put},
}; };
use backend_api::{ use backend_api::{
AppState, TelemetryEvent, api_version, auth, crypto, db, handlers, metrics, notifier, AppState, TelemetryEvent, api_version, crypto, db, handlers, metrics, mqtt_loop,
rate_limiter, watchdog, rate_limiter, watchdog, ws,
}; };
use dotenvy::dotenv; use dotenvy::dotenv;
use serde::Deserialize;
use std::net::SocketAddr; use std::net::SocketAddr;
use tokio::sync::broadcast; use tokio::sync::broadcast;
use tracing_subscriber::{layer::SubscriberExt, util::SubscriberInitExt}; use tracing_subscriber::{layer::SubscriberExt, util::SubscriberInitExt};
use std::sync::Arc;
#[tokio::main] #[tokio::main]
async fn main() { async fn main() {
dotenv().ok(); dotenv().ok();
@@ -28,7 +20,7 @@ async fn main() {
.with( .with(
tracing_subscriber::EnvFilter::try_from_default_env().unwrap_or_else(|_| "info".into()), tracing_subscriber::EnvFilter::try_from_default_env().unwrap_or_else(|_| "info".into()),
) )
.with(tracing_subscriber::fmt::layer()) .with(tracing_subscriber::fmt::layer().json().flatten_event(false))
.init(); .init();
tracing::info!("Starting Backend API..."); tracing::info!("Starting Backend API...");
@@ -116,15 +108,15 @@ async fn main() {
let client_id = format!("omnioil-backend-api-{}", uuid::Uuid::new_v4()); let client_id = format!("omnioil-backend-api-{}", uuid::Uuid::new_v4());
let mut mqtt_options = rumqttc::MqttOptions::new(client_id, &mqtt_host, mqtt_port); let mut mqtt_options = rumqttc::MqttOptions::new(client_id, &mqtt_host, mqtt_port);
mqtt_options.set_keep_alive(std::time::Duration::from_secs(30)); mqtt_options.set_keep_alive(std::time::Duration::from_secs(30));
// Aumentar el límite de paquetes: por defecto rumqttc usa 10KB, insuficiente // Large simulator imports can produce ProjectConfig deploy requests above
// para payloads de provisioning y telemetría comprimida (256KB es seguro). // 400KB before the build-orchestrator compresses the agent deploy signal.
mqtt_options.set_max_packet_size(256 * 1024, 256 * 1024); mqtt_options.set_max_packet_size(2 * 1024 * 1024, 2 * 1024 * 1024);
if let (Ok(u), Ok(p)) = (std::env::var("MQTT_USER"), std::env::var("MQTT_PASSWORD")) { if let (Ok(u), Ok(p)) = (std::env::var("MQTT_USER"), std::env::var("MQTT_PASSWORD")) {
mqtt_options.set_credentials(u, p); mqtt_options.set_credentials(u, p);
} }
let (mqtt_client, mut eventloop) = rumqttc::AsyncClient::new(mqtt_options, 10); let (mqtt_client, eventloop) = rumqttc::AsyncClient::new(mqtt_options, 10);
let (tx, _) = broadcast::channel::<TelemetryEvent>(1000); let (tx, _) = broadcast::channel::<TelemetryEvent>(1000);
let state = AppState { let state = AppState {
@@ -133,244 +125,18 @@ async fn main() {
tx: tx.clone(), tx: tx.clone(),
}; };
// Spawn MQTT event loop para procesar mensajes // Spawn MQTT event loop (extraído a mqtt_loop.rs)
let tx_relay = tx.clone(); let mqtt_client_for_loop = mqtt_client.clone();
let prov_pool = pool.clone(); let mqtt_pool = pool.clone();
let prov_mqtt_client = mqtt_client.clone(); let mqtt_tx = tx.clone();
tokio::spawn(async move { tokio::spawn(async move {
loop { mqtt_loop::run_event_loop(
match eventloop.poll().await { eventloop,
Ok(notification) => { mqtt_client_for_loop,
if let rumqttc::Event::Incoming(rumqttc::Packet::Publish(publish)) = mqtt_pool,
notification mqtt_tx,
{
let topic = publish.topic.as_str();
if topic.starts_with("omnioil/status/") {
// ── Estado inmediato del agente (LWT o reconexión) ─────────────
// Permite que la UI muestre "offline" en segundos, no minutos.
let project_id_str = topic
.strip_prefix("omnioil/status/")
.unwrap_or("")
.to_string();
let pool_ref = prov_pool.clone();
let tx_ref = tx_relay.clone();
let payload_bytes = publish.payload.to_vec();
tokio::spawn(async move {
if let Ok(msg) =
serde_json::from_slice::<serde_json::Value>(&payload_bytes)
{
let status =
msg["status"].as_str().unwrap_or("offline").to_string();
if let Ok(project_uuid) = uuid::Uuid::parse_str(&project_id_str)
{
let _ = sqlx::query(
r#"UPDATE edge_projects
SET last_health_report = COALESCE(last_health_report, '{}'::jsonb)
|| jsonb_build_object('status', $1, 'timestamp', NOW()::text)
WHERE id = $2"#,
) )
.bind(&status)
.bind(project_uuid)
.execute(&pool_ref)
.await; .await;
}
// Notificar UI en tiempo real
let event = TelemetryEvent {
project_id: Arc::from(project_id_str.to_lowercase()),
payload: Arc::from(
serde_json::json!({
"type": "agent_status",
"status": status,
"timestamp": msg["timestamp"]
})
.to_string(),
),
};
let _ = tx_ref.send(event);
}
});
} else if topic.starts_with("omnioil/health/") {
// ── Health report periódico del agente ─────────────────────────
let pool_ref = prov_pool.clone();
let tx_ref = tx_relay.clone();
let project_id_str = topic
.strip_prefix("omnioil/health/")
.unwrap_or("")
.to_string();
let payload_bytes = publish.payload.to_vec();
tokio::spawn(async move {
if let Ok(report) = serde_json::from_slice::<
shared_lib::mqtt_messages::HealthReport,
>(&payload_bytes)
{
let _ = sqlx::query(
"UPDATE edge_projects SET last_health_report = $1 WHERE id = $2",
)
.bind(serde_json::to_value(&report).unwrap_or_default())
.bind(report.project_id)
.execute(&pool_ref)
.await;
// Relay a UI
let event = TelemetryEvent {
project_id: Arc::from(project_id_str.to_lowercase()),
payload: Arc::from(
serde_json::to_string(&report).unwrap_or_default(),
),
};
let _ = tx_ref.send(event);
}
});
} else if topic.starts_with("omnioil/logs/") {
// ── Logs del agente → insertar en DB ──────────────────────────
let pool_ref = prov_pool.clone();
let payload_bytes = publish.payload.to_vec();
tokio::spawn(async move {
if let Ok(entry) = serde_json::from_slice::<
shared_lib::mqtt_messages::AgentLogEntry,
>(&payload_bytes)
{
let _ = sqlx::query(
r#"INSERT INTO agent_logs
(project_id, level, category, message, context, agent_hostname, timestamp)
VALUES ($1, $2, $3, $4, $5, $6, $7)"#,
)
.bind(entry.project_id)
.bind(&entry.level)
.bind(&entry.category)
.bind(&entry.message)
.bind(entry.context.unwrap_or(serde_json::json!({})))
.bind(&entry.agent_hostname)
.bind(entry.timestamp)
.execute(&pool_ref)
.await;
}
});
} else if topic.starts_with("omnioil/alarms/") {
// ── Alarma MQTT → dispatch notificaciones ─────────────────────
let pool_ref = prov_pool.clone();
let tx_ref = tx_relay.clone();
let payload_bytes = publish.payload.to_vec();
let project_id_str = topic
.strip_prefix("omnioil/alarms/")
.unwrap_or("")
.to_string();
tokio::spawn(async move {
if let Ok(event) = serde_json::from_slice::<
shared_lib::mqtt_messages::AlarmEvent,
>(&payload_bytes)
{
let alarm = notifier::AlarmNotification {
project_id: event.project_id,
asset_id: None,
variable: event
.variable_alias
.unwrap_or_else(|| "unknown".to_string()),
value: event.current_value.unwrap_or(0.0),
alarm_type: format!("{:?}", event.alarm_type),
message: event.message.unwrap_or_default(),
timestamp: event.timestamp,
};
notifier::dispatch(&pool_ref, &alarm, &tx_ref).await;
} else {
tracing::warn!(
"⚠️ Failed to parse AlarmEvent on topic omnioil/alarms/{}",
project_id_str
);
}
});
} else if topic.starts_with("omnioil/opcua-scan/") {
// ── OPC UA scan result from agent → store in DB ───────────────
let pool_ref = prov_pool.clone();
let payload_bytes = publish.payload.to_vec();
tokio::spawn(async move {
if let Ok(result) = serde_json::from_slice::<serde_json::Value>(&payload_bytes) {
let scan_id_str = result.get("scan_id").and_then(|v| v.as_str()).unwrap_or("");
let status = result.get("status").and_then(|v| v.as_str()).unwrap_or("failed");
let error_msg = result.get("error").and_then(|v| v.as_str());
if let Ok(scan_id) = uuid::Uuid::parse_str(scan_id_str) {
let _ = sqlx::query(
r#"UPDATE opcua_scan_results
SET status = $1, result = $2, error_message = $3
WHERE id = $4"#,
)
.bind(status)
.bind(&result)
.bind(error_msg)
.bind(scan_id)
.execute(&pool_ref)
.await;
tracing::info!("✅ OPC UA scan result stored: {} ({})", scan_id, status);
}
}
});
} else if topic.starts_with("omnioil/") {
// ── Relay genérico: telemetría, alarmas, deploys → WebSocket ──
let topic_parts: Vec<&str> = topic.split('/').collect();
let project_id = topic_parts.get(2).unwrap_or(&"unknown").to_string();
metrics::record_telemetry_received();
let decompressed =
shared_lib::compression::decompress_if_needed(&publish.payload);
if let Ok(payload_str) = String::from_utf8(decompressed.to_vec()) {
let event = TelemetryEvent {
project_id: Arc::from(project_id.to_lowercase()),
payload: Arc::from(payload_str),
};
match tx_relay.send(event) {
Ok(receivers) => {
tracing::debug!(
"📥 MQTT relayed to {} WS listeners",
receivers
);
}
Err(e) => {
tracing::warn!("⚠️ No WS listeners: {:?}", e);
}
}
}
} else if topic.starts_with("provision/request/") {
// ── Provisioning MQTT ──────────────────────────────────────────
let project_id_str = topic
.strip_prefix("provision/request/")
.unwrap_or("")
.to_string();
let pool_ref = prov_pool.clone();
let client_ref = prov_mqtt_client.clone();
let payload_bytes = publish.payload.to_vec();
tokio::spawn(async move {
if let Err(e) = handlers::provisioning::handle_mqtt_provisioning(
&pool_ref,
&client_ref,
&project_id_str,
&payload_bytes,
)
.await
{
tracing::error!(
"❌ Error en provisioning MQTT para proyecto {}: {}",
project_id_str,
e
);
}
});
}
}
}
Err(e) => {
tracing::error!("MQTT connection error: {:?}", e);
tokio::time::sleep(std::time::Duration::from_secs(5)).await;
}
}
}
}); });
// Suscribirse a todos los topics de omnioil y de provisioning // Suscribirse a todos los topics de omnioil y de provisioning
@@ -424,10 +190,22 @@ async fn main() {
"/projects/{id}/rebuild", "/projects/{id}/rebuild",
post(handlers::edge_projects::rebuild_installer), post(handlers::edge_projects::rebuild_installer),
) )
.route(
"/projects/{id}/import-template/{protocol}",
get(handlers::import::download_import_template),
)
.route(
"/projects/{id}/import-csv/{protocol}",
post(handlers::import::import_csv_by_protocol),
)
.route( .route(
"/projects/{id}/linux-install", "/projects/{id}/linux-install",
get(handlers::edge_projects::linux_install_script), get(handlers::edge_projects::linux_install_script),
) )
.route(
"/projects/{id}/linux-install-token",
post(handlers::edge_projects::generate_download_token),
)
.route( .route(
"/agent/linux-binary/{arch}", "/agent/linux-binary/{arch}",
get(handlers::edge_projects::download_linux_binary), get(handlers::edge_projects::download_linux_binary),
@@ -559,6 +337,7 @@ async fn main() {
let auth_rate_limiter = rate_limiter::new_rate_limiter(10); let auth_rate_limiter = rate_limiter::new_rate_limiter(10);
let register_rate_limiter = rate_limiter::new_rate_limiter(5); let register_rate_limiter = rate_limiter::new_rate_limiter(5);
let request_access_rate_limiter = rate_limiter::new_rate_limiter(5); let request_access_rate_limiter = rate_limiter::new_rate_limiter(5);
let setup_rate_limiter = rate_limiter::new_rate_limiter(5);
// Sub-router de auth con rate limiting por endpoint // Sub-router de auth con rate limiting por endpoint
let auth_routes = Router::new() let auth_routes = Router::new()
@@ -608,7 +387,13 @@ async fn main() {
get(handlers::invitations::validate_invitation), get(handlers::invitations::validate_invitation),
) )
.route("/setup/status", get(handlers::auth::get_setup_status)) .route("/setup/status", get(handlers::auth::get_setup_status))
.route("/setup/register", post(handlers::auth::setup_register)); .route(
"/setup/register",
post(handlers::auth::setup_register).layer(axum::middleware::from_fn_with_state(
setup_rate_limiter,
rate_limiter::rate_limit_by_ip,
)),
);
// Rutas de gestión de tokens de provisioning (solo admin) // Rutas de gestión de tokens de provisioning (solo admin)
let provisioning_routes = Router::new().route( let provisioning_routes = Router::new().route(
@@ -622,6 +407,14 @@ async fn main() {
"/users", "/users",
get(handlers::users::list_users).post(handlers::users::create_user), get(handlers::users::list_users).post(handlers::users::create_user),
) )
.route(
"/users/me",
get(handlers::users::get_current_user).put(handlers::users::update_current_user),
)
.route(
"/users/me/reset-password",
post(handlers::users::reset_current_user_password),
)
.route( .route(
"/users/{id}", "/users/{id}",
get(handlers::users::get_user).put(handlers::users::update_user), get(handlers::users::get_user).put(handlers::users::update_user),
@@ -634,6 +427,14 @@ async fn main() {
"/users/{id}/projects", "/users/{id}/projects",
put(handlers::users::set_user_projects), put(handlers::users::set_user_projects),
) )
.route(
"/users/{id}/wells",
get(handlers::well_access::get_user_wells).put(handlers::well_access::set_user_wells),
)
.route(
"/projects/{project_id}/wells",
get(handlers::well_access::list_project_wells),
)
.route("/roles", get(handlers::users::list_roles)) .route("/roles", get(handlers::users::list_roles))
// Deprecated aliases: provider-only routes live under /api/platform. // Deprecated aliases: provider-only routes live under /api/platform.
.route( .route(
@@ -648,6 +449,14 @@ async fn main() {
"/access-requests/{id}/review", "/access-requests/{id}/review",
post(handlers::access_requests::mark_access_request_in_review), post(handlers::access_requests::mark_access_request_in_review),
) )
.route(
"/access-requests/{id}/classification",
put(handlers::access_requests::classify_access_request),
)
.route(
"/commercial-profiles/{id}",
get(handlers::access_requests::get_commercial_profile),
)
.route( .route(
"/access-requests/{id}/approve", "/access-requests/{id}/approve",
post(handlers::access_requests::approve_access_request), post(handlers::access_requests::approve_access_request),
@@ -675,6 +484,14 @@ async fn main() {
"/access-requests/{id}/review", "/access-requests/{id}/review",
post(handlers::access_requests::mark_access_request_in_review), post(handlers::access_requests::mark_access_request_in_review),
) )
.route(
"/access-requests/{id}/classification",
put(handlers::access_requests::classify_access_request),
)
.route(
"/commercial-profiles/{id}",
get(handlers::access_requests::get_commercial_profile),
)
.route( .route(
"/access-requests/{id}/approve", "/access-requests/{id}/approve",
post(handlers::access_requests::approve_access_request), post(handlers::access_requests::approve_access_request),
@@ -724,6 +541,10 @@ async fn main() {
// Swagger UI y spec OpenAPI (públicos, sin JWT) // Swagger UI y spec OpenAPI (públicos, sin JWT)
.route("/docs", get(handlers::docs::swagger_ui)) .route("/docs", get(handlers::docs::swagger_ui))
.route("/docs/openapi.yaml", get(handlers::docs::openapi_yaml)) .route("/docs/openapi.yaml", get(handlers::docs::openapi_yaml))
.route(
"/api/billing/webhook",
post(handlers::billing_webhook::stripe_webhook),
)
// Metrics endpoint (internal only — Prometheus scrapes this; not JWT-protected) // Metrics endpoint (internal only — Prometheus scrapes this; not JWT-protected)
.route( .route(
"/metrics", "/metrics",
@@ -749,10 +570,37 @@ async fn main() {
get(handlers::telemetry::get_asset_telemetry_history), get(handlers::telemetry::get_asset_telemetry_history),
) )
// Reportes ANH (Resolución 0651) // Reportes ANH (Resolución 0651)
.route("/api/anh/reports", get(handlers::anh_reports::list_reports)) .route(
"/api/anh/reports",
get(handlers::anh_reports::list_reports).post(handlers::anh_reports::generate_report),
)
.route( .route(
"/api/anh/reports/{id}", "/api/anh/reports/{id}",
get(handlers::anh_reports::get_report), get(handlers::anh_reports::get_report).put(handlers::anh_reports::update_report),
)
.route(
"/api/anh/reports/{id}/approve",
post(handlers::anh_reports::approve_report),
)
.route(
"/api/anh/reports/{id}/versions",
get(handlers::anh_reports::get_report_versions),
)
.route(
"/api/anh/reports/{id}/rejection",
post(handlers::anh_reports::register_report_rejection),
)
.route(
"/api/anh/reports/{id}/corrections",
post(handlers::anh_reports::create_report_correction),
)
.route(
"/api/anh/reports/{id}/available-for-anh",
post(handlers::anh_reports::mark_report_available_for_anh),
)
.route(
"/api/anh/reports/{id}/audit-logs",
get(handlers::anh_reports::get_report_audit_logs),
) )
.route( .route(
"/api/anh/reports/{id}/download", "/api/anh/reports/{id}/download",
@@ -762,6 +610,14 @@ async fn main() {
"/api/anh/reports/{id}/hash", "/api/anh/reports/{id}/hash",
get(handlers::anh_reports::download_report_hash), get(handlers::anh_reports::download_report_hash),
) )
.route(
"/api/anh/report-schedules",
get(handlers::anh_reports::list_report_schedules),
)
.route(
"/api/anh/report-schedules/{id}",
put(handlers::anh_reports::update_report_schedule),
)
// Alert Rules & Alarm history // Alert Rules & Alarm history
.route( .route(
"/api/alert-rules", "/api/alert-rules",
@@ -778,6 +634,14 @@ async fn main() {
patch(handlers::alert_rules::toggle_alert_rule), patch(handlers::alert_rules::toggle_alert_rule),
) )
.route("/api/alarms", get(handlers::alert_rules::list_alarms)) .route("/api/alarms", get(handlers::alert_rules::list_alarms))
.route(
"/api/alarms/acknowledge",
patch(handlers::alert_rules::acknowledge_alarms),
)
.route(
"/api/alarms/{id}/acknowledge",
patch(handlers::alert_rules::acknowledge_alarm),
)
// Billing & Usage Metering // Billing & Usage Metering
.route( .route(
"/api/billing/usage", "/api/billing/usage",
@@ -833,8 +697,8 @@ async fn main() {
) )
// Edge System API // Edge System API
.nest("/api/edge", edge_routes) .nest("/api/edge", edge_routes)
// WebSocket Stream (Relay de Telemetría) // WebSocket Stream (Relay de Telemetría) — extraído a ws.rs
.route("/api/ws/telemetry", get(ws_telemetry_handler)) .merge(ws::routes())
// Global rate limiter: 60 req/min per IP // Global rate limiter: 60 req/min per IP
.layer(axum::middleware::from_fn_with_state( .layer(axum::middleware::from_fn_with_state(
api_rate_limiter, api_rate_limiter,
@@ -857,113 +721,3 @@ async fn main() {
async fn root() -> axum::Json<serde_json::Value> { async fn root() -> axum::Json<serde_json::Value> {
axum::Json(serde_json::json!({"status": "ok"})) axum::Json(serde_json::json!({"status": "ok"}))
} }
#[derive(Deserialize)]
struct WsParams {
project_id: Option<String>,
/// JWT bearer token — los browsers no pueden enviar Authorization header en WS,
/// por eso se acepta como query param.
token: Option<String>,
}
async fn ws_telemetry_handler(
ws: WebSocketUpgrade,
query: Query<WsParams>,
State(state): State<AppState>,
) -> impl IntoResponse {
// Validar token JWT antes de upgrading la conexión WebSocket (HIGH-3)
let token = match &query.token {
Some(t) => t.as_str(),
None => {
return (
axum::http::StatusCode::UNAUTHORIZED,
axum::Json(serde_json::json!({"error": "Token requerido para conexión WebSocket"})),
)
.into_response();
}
};
if auth::verify_jwt(token).is_err() {
return (
axum::http::StatusCode::UNAUTHORIZED,
axum::Json(serde_json::json!({"error": "Token inválido o expirado"})),
)
.into_response();
}
let project_id = query.project_id.clone();
tracing::debug!(
"New WebSocket upgrade request for telemetry. Filter: {:?}",
project_id
);
ws.on_upgrade(move |socket| handle_socket(socket, state, project_id))
}
async fn handle_socket(mut socket: WebSocket, state: AppState, filter_project_id: Option<String>) {
tracing::info!(
"🔌 WebSocket connection upgraded and active for project: {:?}",
filter_project_id
);
let mut rx = state.tx.subscribe();
// Ping interval para detectar conexiones zombi
let mut ping_interval = tokio::time::interval(std::time::Duration::from_secs(15));
ping_interval.tick().await; // consume el tick inmediato
loop {
tokio::select! {
// Branch 1: Recibir evento del broadcast y enviarlo al cliente
result = rx.recv() => {
match result {
Ok(event) => {
if let Some(ref target_id) = filter_project_id {
// target_id debe normalizarse una vez fuera del loop para máxima velocidad
if &*event.project_id != target_id.to_lowercase() {
continue;
}
}
if socket.send(Message::Text(event.payload.to_string().into())).await.is_err() {
tracing::debug!("WebSocket client disconnected (send failed)");
break;
}
}
Err(tokio::sync::broadcast::error::RecvError::Lagged(count)) => {
tracing::warn!("WebSocket receiver lagged by {} messages", count);
continue;
}
Err(tokio::sync::broadcast::error::RecvError::Closed) => {
tracing::error!("Broadcast channel closed");
break;
}
}
}
// Branch 2: Leer mensajes del cliente (detectar Close/Pong/desconexión)
msg = socket.recv() => {
match msg {
Some(Ok(Message::Close(_))) | None => {
tracing::debug!("WebSocket client closed connection");
break;
}
Some(Ok(Message::Pong(_))) => {
// Cliente respondió al ping, la conexión está viva
}
Some(Err(_)) => {
tracing::debug!("WebSocket read error, dropping connection");
break;
}
_ => {} // Ignorar otros mensajes (Text, Binary del cliente)
}
}
// Branch 3: Enviar ping periódico para detectar zombis
_ = ping_interval.tick() => {
if socket.send(Message::Ping(vec![1, 2, 3].into())).await.is_err() {
tracing::debug!("WebSocket ping failed, client is dead");
break;
}
}
}
}
tracing::info!(
"🔌 WebSocket connection closed for project: {:?}",
filter_project_id
);
}

View File

@@ -1,13 +1,36 @@
use metrics::{counter, describe_counter, describe_gauge, gauge}; use metrics::{counter, describe_counter, describe_gauge, gauge};
pub fn register_metrics() { pub fn register_metrics() {
describe_gauge!("omnioil_active_agents", "Number of MQTT-connected edge agents"); describe_gauge!(
"omnioil_active_agents",
"Number of MQTT-connected edge agents"
);
describe_gauge!("omnioil_active_projects", "Number of active edge projects"); describe_gauge!("omnioil_active_projects", "Number of active edge projects");
describe_gauge!("omnioil_active_tags", "Total active edge variables (tags)"); describe_gauge!("omnioil_active_tags", "Total active edge variables (tags)");
describe_gauge!(
"omnioil_db_pool_size",
"Current number of database connections in the pool"
);
describe_gauge!(
"omnioil_db_pool_idle",
"Current number of idle database connections"
);
describe_counter!( describe_counter!(
"omnioil_telemetry_messages_total", "omnioil_telemetry_messages_total",
"Total telemetry MQTT messages processed" "Total telemetry MQTT messages processed"
); );
describe_counter!(
"omnioil_telemetry_unresolved_device_total",
"MQTT telemetry messages dropped because the device could not be resolved"
);
describe_counter!(
"omnioil_telemetry_ambiguous_device_total",
"MQTT telemetry messages dropped because device resolution matched multiple assets"
);
describe_counter!(
"omnioil_telemetry_project_mismatch_total",
"MQTT telemetry messages dropped because payload project_id mismatched the topic project_id"
);
describe_counter!( describe_counter!(
"omnioil_alarms_triggered_total", "omnioil_alarms_triggered_total",
"Total threshold alarms triggered" "Total threshold alarms triggered"
@@ -22,10 +45,30 @@ pub fn record_telemetry_received() {
counter!("omnioil_telemetry_messages_total").increment(1); counter!("omnioil_telemetry_messages_total").increment(1);
} }
pub fn record_active_agents(count: f64) {
gauge!("omnioil_active_agents").set(count);
}
pub fn record_mqtt_connection() {
counter!("omnioil_mqtt_connections_total").increment(1);
}
pub fn record_alarm_triggered() { pub fn record_alarm_triggered() {
counter!("omnioil_alarms_triggered_total").increment(1); counter!("omnioil_alarms_triggered_total").increment(1);
} }
pub fn record_telemetry_unresolved_device() {
counter!("omnioil_telemetry_unresolved_device_total").increment(1);
}
pub fn record_telemetry_ambiguous_device() {
counter!("omnioil_telemetry_ambiguous_device_total").increment(1);
}
pub fn record_telemetry_project_mismatch() {
counter!("omnioil_telemetry_project_mismatch_total").increment(1);
}
pub async fn update_system_gauges(pool: &sqlx::PgPool) { pub async fn update_system_gauges(pool: &sqlx::PgPool) {
if let Ok(count) = sqlx::query_scalar::<_, i64>("SELECT COUNT(*) FROM edge_projects") if let Ok(count) = sqlx::query_scalar::<_, i64>("SELECT COUNT(*) FROM edge_projects")
.fetch_one(pool) .fetch_one(pool)
@@ -33,12 +76,27 @@ pub async fn update_system_gauges(pool: &sqlx::PgPool) {
{ {
gauge!("omnioil_active_projects").set(count as f64); gauge!("omnioil_active_projects").set(count as f64);
} }
if let Ok(count) = sqlx::query_scalar::<_, i64>( if let Ok(count) =
"SELECT COUNT(*) FROM edge_variables WHERE is_enabled = true", sqlx::query_scalar::<_, i64>("SELECT COUNT(*) FROM edge_variables WHERE is_enabled = true")
)
.fetch_one(pool) .fetch_one(pool)
.await .await
{ {
gauge!("omnioil_active_tags").set(count as f64); gauge!("omnioil_active_tags").set(count as f64);
} }
if let Ok(count) = sqlx::query_scalar::<_, i64>(
r#"SELECT COUNT(*) FROM edge_projects p
WHERE p.is_active = true
AND p.installer_status = 'COMPLETED'
AND p.last_health_report ? 'timestamp'
AND (p.last_health_report->>'timestamp')::TIMESTAMPTZ > NOW() - INTERVAL '5 minutes'"#,
)
.fetch_one(pool)
.await
{
gauge!("omnioil_active_agents").set(count as f64);
}
// Pool metrics
gauge!("omnioil_db_pool_size").set(pool.size() as f64);
gauge!("omnioil_db_pool_idle").set(pool.num_idle() as f64);
} }

View File

@@ -0,0 +1,215 @@
use crate::{
TelemetryEvent, metrics, notifier,
};
use rumqttc::{AsyncClient, Event, Packet};
use sqlx::PgPool;
use std::sync::Arc;
use tokio::sync::broadcast;
/// Procesa mensajes MQTT de `omnioil/#` y `provision/request/+`.
///
/// Responsabilidades únicas de backend-api (no delegadas a telemetry-ingestor):
/// - `omnioil/status/`: estado LWT/reconexión → DB + WS
/// - `omnioil/alarms/`: dispatch de notificaciones (email/SMS) + WS relay
/// - `omnioil/opcua-scan/`: resultados de scan OPC UA → DB
/// - `omnioil/*` catch-all: relay genérico a WebSocket (real-time UI)
/// - `provision/request/+`: provisioning de agentes
///
/// Topics duplicados con telemetry-ingestor (solo WS relay, sin DB):
/// - `omnioil/health/`, `omnioil/logs/`, `omnioil/telemetry/`
pub async fn run_event_loop(
mut eventloop: rumqttc::EventLoop,
mqtt_client: AsyncClient,
pool: PgPool,
tx: broadcast::Sender<TelemetryEvent>,
) {
let tx_relay = tx.clone();
let prov_pool = pool.clone();
let prov_mqtt_client = mqtt_client.clone();
loop {
match eventloop.poll().await {
Ok(notification) => {
match notification {
Event::Incoming(Packet::Publish(publish)) => {
let topic = publish.topic.as_str();
if topic.starts_with("omnioil/status/") {
let project_id_str = topic
.strip_prefix("omnioil/status/")
.unwrap_or("")
.to_string();
let pool_ref = prov_pool.clone();
let tx_ref = tx_relay.clone();
let payload_bytes = publish.payload.to_vec();
tokio::spawn(async move {
if let Ok(msg) =
serde_json::from_slice::<serde_json::Value>(&payload_bytes)
{
let status =
msg["status"].as_str().unwrap_or("offline").to_string();
if let Ok(project_uuid) = uuid::Uuid::parse_str(&project_id_str)
{
let _ = sqlx::query(
r#"UPDATE edge_projects
SET last_health_report = COALESCE(last_health_report, '{}'::jsonb)
|| jsonb_build_object('status', $1, 'timestamp', NOW()::text)
WHERE id = $2"#,
)
.bind(&status)
.bind(project_uuid)
.execute(&pool_ref)
.await;
}
let event = TelemetryEvent {
project_id: Arc::from(project_id_str.to_lowercase()),
payload: Arc::from(
serde_json::json!({
"type": "agent_status",
"status": status,
"timestamp": msg["timestamp"]
})
.to_string(),
),
};
let _ = tx_ref.send(event);
}
});
} else if topic.starts_with("omnioil/alarms/") {
let pool_ref = prov_pool.clone();
let tx_ref = tx_relay.clone();
let payload_bytes = publish.payload.to_vec();
let project_id_str = topic
.strip_prefix("omnioil/alarms/")
.unwrap_or("")
.to_string();
tokio::spawn(async move {
if let Ok(event) = serde_json::from_slice::<
shared_lib::mqtt_messages::AlarmEvent,
>(&payload_bytes)
{
let alarm = notifier::AlarmNotification {
project_id: event.project_id,
asset_id: None,
variable: event
.variable_alias
.unwrap_or_else(|| "unknown".to_string()),
value: event.current_value.unwrap_or(0.0),
alarm_type: format!("{:?}", event.alarm_type),
message: event.message.unwrap_or_default(),
timestamp: event.timestamp,
};
// Dispatch notificaciones (email/SMS) — única responsabilidad de backend-api
notifier::dispatch(&pool_ref, &alarm, &tx_ref).await;
} else {
tracing::warn!(
"⚠️ Failed to parse AlarmEvent on topic omnioil/alarms/{}",
project_id_str
);
}
});
} else if topic.starts_with("omnioil/opcua-scan/") {
let pool_ref = prov_pool.clone();
let payload_bytes = publish.payload.to_vec();
tokio::spawn(async move {
if let Ok(result) =
serde_json::from_slice::<serde_json::Value>(&payload_bytes)
{
let scan_id_str = result
.get("scan_id")
.and_then(|v| v.as_str())
.unwrap_or("");
let status = result
.get("status")
.and_then(|v| v.as_str())
.unwrap_or("failed");
let error_msg = result.get("error").and_then(|v| v.as_str());
if let Ok(scan_id) = uuid::Uuid::parse_str(scan_id_str) {
let _ = sqlx::query(
r#"UPDATE opcua_scan_results
SET status = $1, result = $2, error_message = $3
WHERE id = $4"#,
)
.bind(status)
.bind(&result)
.bind(error_msg)
.bind(scan_id)
.execute(&pool_ref)
.await;
tracing::info!(
"✅ OPC UA scan result stored: {} ({})",
scan_id,
status
);
}
}
});
} else if topic.starts_with("omnioil/") {
// Relay genérico: telemetría, alarmas, deploys, health, logs → WebSocket
let topic_parts: Vec<&str> = topic.split('/').collect();
let project_id = topic_parts.get(2).unwrap_or(&"unknown").to_string();
metrics::record_telemetry_received();
let decompressed =
shared_lib::compression::decompress_if_needed(&publish.payload);
if let Ok(payload_str) = String::from_utf8(decompressed.to_vec()) {
let event = TelemetryEvent {
project_id: Arc::from(project_id.to_lowercase()),
payload: Arc::from(payload_str),
};
match tx_relay.send(event) {
Ok(receivers) => {
tracing::debug!(
"📥 MQTT relayed to {} WS listeners",
receivers
);
}
Err(e) => {
tracing::warn!("⚠️ No WS listeners: {:?}", e);
}
}
}
} else if topic.starts_with("provision/request/") {
let project_id_str = topic
.strip_prefix("provision/request/")
.unwrap_or("")
.to_string();
let pool_ref = prov_pool.clone();
let client_ref = prov_mqtt_client.clone();
let payload_bytes = publish.payload.to_vec();
tokio::spawn(async move {
if let Err(e) = crate::handlers::provisioning::handle_mqtt_provisioning(
&pool_ref,
&client_ref,
&project_id_str,
&payload_bytes,
)
.await
{
tracing::error!(
"❌ Error en provisioning MQTT para proyecto {}: {}",
project_id_str,
e
);
}
});
}
}
Event::Incoming(Packet::ConnAck(_)) => {
metrics::record_mqtt_connection();
}
_ => {}
}
}
Err(e) => {
tracing::error!("MQTT connection error: {:?}", e);
tokio::time::sleep(std::time::Duration::from_secs(5)).await;
}
}
}
}

View File

@@ -2,7 +2,9 @@
//! Soporta: Email (SMTP), Webhook (HTTP POST), WebSocket (in-app). //! Soporta: Email (SMTP), Webhook (HTTP POST), WebSocket (in-app).
use sqlx::PgPool; use sqlx::PgPool;
use std::sync::Arc; use std::collections::HashMap;
use std::sync::{Arc, Mutex, OnceLock};
use std::time::Instant;
use tokio::sync::broadcast; use tokio::sync::broadcast;
use uuid::Uuid; use uuid::Uuid;
@@ -61,6 +63,7 @@ pub async fn dispatch(
let result = match cfg.channel_type.as_str() { let result = match cfg.channel_type.as_str() {
"email" => send_email(&cfg.destination, alarm).await, "email" => send_email(&cfg.destination, alarm).await,
"webhook" => send_webhook(&cfg.destination, alarm).await, "webhook" => send_webhook(&cfg.destination, alarm).await,
"telegram" => send_telegram_alarm(&cfg.destination, alarm).await,
other => { other => {
tracing::warn!("Canal de notificación no soportado: {}", other); tracing::warn!("Canal de notificación no soportado: {}", other);
Ok(()) Ok(())
@@ -91,7 +94,10 @@ pub async fn dispatch(
} }
} }
async fn send_email(to: &str, alarm: &AlarmNotification) -> Result<(), Box<dyn std::error::Error + Send + Sync>> { async fn send_email(
to: &str,
alarm: &AlarmNotification,
) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
use lettre::{Message, SmtpTransport, Transport, transport::smtp::authentication::Credentials}; use lettre::{Message, SmtpTransport, Transport, transport::smtp::authentication::Credentials};
let smtp_host = std::env::var("SMTP_HOST").unwrap_or_default(); let smtp_host = std::env::var("SMTP_HOST").unwrap_or_default();
@@ -134,7 +140,10 @@ async fn send_email(to: &str, alarm: &AlarmNotification) -> Result<(), Box<dyn s
Ok(()) Ok(())
} }
async fn send_webhook(url: &str, alarm: &AlarmNotification) -> Result<(), Box<dyn std::error::Error + Send + Sync>> { async fn send_webhook(
url: &str,
alarm: &AlarmNotification,
) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
let client = reqwest::Client::builder() let client = reqwest::Client::builder()
.timeout(std::time::Duration::from_secs(10)) .timeout(std::time::Duration::from_secs(10))
.build()?; .build()?;
@@ -159,3 +168,207 @@ async fn send_webhook(url: &str, alarm: &AlarmNotification) -> Result<(), Box<dy
} }
Ok(()) Ok(())
} }
async fn send_telegram_alarm(
destination: &str,
alarm: &AlarmNotification,
) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
let token = std::env::var("TELEGRAM_BOT_TOKEN").unwrap_or_default();
let chat_id = if destination.trim().is_empty() {
std::env::var("TELEGRAM_CHAT_ID").unwrap_or_default()
} else {
destination.to_string()
};
if token.is_empty() || chat_id.is_empty() {
tracing::warn!("Telegram no configurado para alarma");
return Ok(());
}
let url = format!("https://api.telegram.org/bot{}/sendMessage", token);
let message = format!(
"🚨 *Alarma OmniOil — {}*\n\n\
• *Variable:* {}\n\
• *Valor:* {:.2}\n\
• *Tipo:* {}\n\
• *Mensaje:* {}\n\
• *Timestamp:* {}",
alarm.variable,
alarm.variable,
alarm.value,
alarm.alarm_type,
alarm.message,
alarm.timestamp
);
let payload = serde_json::json!({
"chat_id": chat_id,
"text": message,
"parse_mode": "Markdown"
});
let client = reqwest::Client::builder()
.timeout(std::time::Duration::from_secs(10))
.build()?;
let resp = client.post(&url).json(&payload).send().await?;
if !resp.status().is_success() {
let body = resp.text().await.unwrap_or_default();
return Err(format!("Telegram error: {}", body).into());
}
Ok(())
}
static LAST_SYSTEM_ALERTS: OnceLock<Mutex<HashMap<String, Instant>>> = OnceLock::new();
/// Envía una alerta inmediata por Correo Electrónico y Telegram si ocurre una falla en el servidor.
/// Limita la frecuencia a una vez cada 10 minutos por contexto para evitar spam.
pub async fn send_system_alert(context: &str, error: &anyhow::Error) {
let now = Instant::now();
let cooldown = std::time::Duration::from_secs(600); // 10 minutos
{
let map_mutex = LAST_SYSTEM_ALERTS.get_or_init(|| Mutex::new(HashMap::new()));
let mut map = map_mutex.lock().unwrap();
if let Some(&last_sent) = map.get(context) {
if now.duration_since(last_sent) < cooldown {
// Cooldown activo, no enviar
return;
}
}
map.insert(context.to_string(), now);
}
let subject = format!("🚨 ERROR CRÍTICO OMNIOIL: {}", context);
let message = format!(
"Se ha detectado un problema crítico en el servidor de OmniOil.\n\n\
Contexto: {}\n\
Error: {:?}\n\
Timestamp: {}",
context,
error,
chrono::Utc::now()
);
// 1. Enviar a Telegram
if let Err(e) = send_telegram_direct(&message).await {
tracing::error!("Fallo al enviar alerta de sistema a Telegram: {}", e);
}
// 2. Enviar por Correo Electrónico
let admin_email = std::env::var("ADMIN_EMAIL")
.or_else(|_| std::env::var("SMTP_FROM"))
.or_else(|_| std::env::var("SMTP_USER"))
.unwrap_or_default();
if !admin_email.is_empty() {
let msg = crate::smtp_mailer::EmailMessage {
to: &admin_email,
subject: &subject,
body: &message,
html_body: None,
};
if let Err(e) = crate::smtp_mailer::send_email(msg).await {
tracing::error!("Fallo al enviar alerta de sistema por correo: {}", e);
}
} else {
tracing::warn!(
"No se pudo enviar alerta de sistema por correo: ADMIN_EMAIL, SMTP_FROM y SMTP_USER no están configurados"
);
}
}
async fn send_telegram_direct(message: &str) -> anyhow::Result<()> {
let token = std::env::var("TELEGRAM_BOT_TOKEN").unwrap_or_default();
let chat_id = std::env::var("TELEGRAM_CHAT_ID").unwrap_or_default();
if token.is_empty() || chat_id.is_empty() {
tracing::warn!("Telegram no configurado: TOKEN o CHAT_ID faltantes");
return Ok(());
}
let url = format!("https://api.telegram.org/bot{}/sendMessage", token);
let payload = serde_json::json!({
"chat_id": chat_id,
"text": message
});
let client = reqwest::Client::builder()
.timeout(std::time::Duration::from_secs(10))
.build()?;
let resp = client.post(&url).json(&payload).send().await?;
let status = resp.status();
if !status.is_success() {
let body = resp.text().await.unwrap_or_default();
anyhow::bail!("Telegram HTTP error {}: {}", status, body);
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
use std::time::Duration;
#[tokio::test]
async fn test_send_system_alert_rate_limiting() {
let context = "test-rate-limit";
let error = anyhow::anyhow!("Test error message");
// Asegurarse de que el mapa está inicializado y limpio para el test
let map_mutex = LAST_SYSTEM_ALERTS.get_or_init(|| Mutex::new(HashMap::new()));
{
let mut map = map_mutex.lock().unwrap();
map.clear();
}
// Primer envío (debería registrarse)
send_system_alert(context, &error).await;
let first_instant = {
let map = map_mutex.lock().unwrap();
let inst = map.get(context).cloned();
assert!(
inst.is_some(),
"Debería haberse registrado el envío de la alerta"
);
inst.unwrap()
};
// Segundo envío inmediato (debería ser ignorado por el cooldown, manteniendo el instante original)
send_system_alert(context, &error).await;
{
let map = map_mutex.lock().unwrap();
let second_instant = map.get(context).cloned().unwrap();
assert_eq!(
first_instant, second_instant,
"El segundo envío inmediato no debería actualizar el timestamp debido al cooldown"
);
}
// Simular que el cooldown ya pasó (retrocediendo el tiempo de envío en el mapa)
{
let mut map = map_mutex.lock().unwrap();
map.insert(
context.to_string(),
Instant::now() - Duration::from_secs(650),
);
}
// Tercer envío después del cooldown (debería registrarse y actualizar el timestamp)
send_system_alert(context, &error).await;
{
let map = map_mutex.lock().unwrap();
let third_instant = map.get(context).cloned().unwrap();
assert!(
third_instant > first_instant,
"El envío después del cooldown debería actualizar el timestamp"
);
}
}
}

View File

@@ -1,4 +1,8 @@
use lettre::{Message, SmtpTransport, Transport, transport::smtp::authentication::Credentials}; use lettre::{
Message, SmtpTransport, Transport,
message::{Mailbox, MultiPart, SinglePart, header::ContentType},
transport::smtp::authentication::Credentials,
};
#[derive(Clone, Copy, Debug, Eq, PartialEq)] #[derive(Clone, Copy, Debug, Eq, PartialEq)]
enum SmtpTlsMode { enum SmtpTlsMode {
@@ -10,6 +14,7 @@ pub struct EmailMessage<'a> {
pub to: &'a str, pub to: &'a str,
pub subject: &'a str, pub subject: &'a str,
pub body: &'a str, pub body: &'a str,
pub html_body: Option<&'a str>,
} }
fn parse_tls_mode(value: Option<&str>) -> anyhow::Result<SmtpTlsMode> { fn parse_tls_mode(value: Option<&str>) -> anyhow::Result<SmtpTlsMode> {
@@ -28,6 +33,15 @@ fn should_apply_credentials(user: &str, pass: &str) -> bool {
!user.trim().is_empty() || !pass.trim().is_empty() !user.trim().is_empty() || !pass.trim().is_empty()
} }
fn smtp_from_mailbox(value: &str) -> anyhow::Result<Mailbox> {
let trimmed = value.trim();
if trimmed.contains('<') {
return Ok(trimmed.parse()?);
}
Ok(format!("OmniOil <{trimmed}>").parse()?)
}
pub async fn send_email(message: EmailMessage<'_>) -> anyhow::Result<()> { pub async fn send_email(message: EmailMessage<'_>) -> anyhow::Result<()> {
let smtp_host = std::env::var("SMTP_HOST").unwrap_or_default(); let smtp_host = std::env::var("SMTP_HOST").unwrap_or_default();
let smtp_host = smtp_host.trim().to_string(); let smtp_host = smtp_host.trim().to_string();
@@ -49,11 +63,28 @@ pub async fn send_email(message: EmailMessage<'_>) -> anyhow::Result<()> {
let smtp_from = std::env::var("SMTP_FROM").unwrap_or_else(|_| smtp_user.clone()); let smtp_from = std::env::var("SMTP_FROM").unwrap_or_else(|_| smtp_user.clone());
let tls_mode = parse_tls_mode(std::env::var("SMTP_TLS_MODE").ok().as_deref())?; let tls_mode = parse_tls_mode(std::env::var("SMTP_TLS_MODE").ok().as_deref())?;
let email = Message::builder() let email_builder = Message::builder()
.from(format!("OmniOil <{}>", smtp_from).parse()?) .from(smtp_from_mailbox(&smtp_from)?)
.to(message.to.parse()?) .to(message.to.parse()?)
.subject(message.subject) .subject(message.subject);
.body(message.body.to_string())?;
let email = if let Some(html_body) = message.html_body {
email_builder.multipart(
MultiPart::alternative()
.singlepart(
SinglePart::builder()
.header(ContentType::TEXT_PLAIN)
.body(message.body.to_string()),
)
.singlepart(
SinglePart::builder()
.header(ContentType::TEXT_HTML)
.body(html_body.to_string()),
),
)?
} else {
email_builder.body(message.body.to_string())?
};
let mut mailer_builder = match tls_mode { let mut mailer_builder = match tls_mode {
SmtpTlsMode::StartTls => SmtpTransport::starttls_relay(&smtp_host)?, SmtpTlsMode::StartTls => SmtpTransport::starttls_relay(&smtp_host)?,
@@ -74,7 +105,10 @@ pub async fn send_email(message: EmailMessage<'_>) -> anyhow::Result<()> {
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::{EmailMessage, SmtpTlsMode, parse_tls_mode, send_email, should_apply_credentials}; use super::{
EmailMessage, SmtpTlsMode, parse_tls_mode, send_email, should_apply_credentials,
smtp_from_mailbox,
};
use std::sync::{Mutex, OnceLock}; use std::sync::{Mutex, OnceLock};
fn env_lock() -> &'static Mutex<()> { fn env_lock() -> &'static Mutex<()> {
@@ -93,6 +127,7 @@ mod tests {
to: "customer@example.com", to: "customer@example.com",
subject: "Tu acceso a OmniOil fue aprobado", subject: "Tu acceso a OmniOil fue aprobado",
body: "Activation instructions", body: "Activation instructions",
html_body: None,
}) })
.await; .await;
@@ -114,6 +149,7 @@ mod tests {
to: "customer@example.com", to: "customer@example.com",
subject: "Nuevo proyecto habilitado en OmniOil", subject: "Nuevo proyecto habilitado en OmniOil",
body: "Access is ready", body: "Access is ready",
html_body: None,
}) })
.await; .await;
@@ -153,4 +189,16 @@ mod tests {
assert!(should_apply_credentials("user", "")); assert!(should_apply_credentials("user", ""));
assert!(should_apply_credentials("", "password")); assert!(should_apply_credentials("", "password"));
} }
#[test]
fn smtp_from_accepts_raw_email_or_full_mailbox() {
let raw = smtp_from_mailbox("soporte@omnioil.app").expect("raw sender should parse");
let full =
smtp_from_mailbox("OmniOil <soporte@omnioil.app>").expect("full sender should parse");
assert_eq!(raw.email.to_string(), "soporte@omnioil.app");
assert_eq!(raw.name.as_deref(), Some("OmniOil"));
assert_eq!(full.email.to_string(), "soporte@omnioil.app");
assert_eq!(full.name.as_deref(), Some("OmniOil"));
}
} }

View File

@@ -19,13 +19,8 @@ where
{ {
type Rejection = Response; type Rejection = Response;
async fn from_request( async fn from_request(req: axum::extract::Request, state: &S) -> Result<Self, Self::Rejection> {
req: axum::extract::Request, let Json(value) = Json::<T>::from_request(req, state).await.map_err(|e| {
state: &S,
) -> Result<Self, Self::Rejection> {
let Json(value) = Json::<T>::from_request(req, state)
.await
.map_err(|e| {
( (
StatusCode::BAD_REQUEST, StatusCode::BAD_REQUEST,
Json(serde_json::json!({ "error": e.to_string() })), Json(serde_json::json!({ "error": e.to_string() })),

View File

@@ -1,11 +1,22 @@
use crate::notifier::{self, AlarmNotification};
use crate::handlers::billing::take_daily_snapshots; use crate::handlers::billing::take_daily_snapshots;
use crate::notifier::{self, AlarmNotification};
use chrono::{TimeZone, Utc};
use sqlx::{PgPool, Row}; use sqlx::{PgPool, Row};
use std::time::Duration; use std::time::Duration;
use chrono::Utc;
use tokio::sync::broadcast; use tokio::sync::broadcast;
use uuid::Uuid; use uuid::Uuid;
const TELEMETRY_SILENCE_HF: &str = "TELEMETRY_SILENCE_HF";
const TELEMETRY_SILENCE_LF: &str = "TELEMETRY_SILENCE_LF";
#[derive(Clone, Copy)]
struct SilenceSpec {
source: &'static str,
frequency_class: &'static str,
alarm_type: &'static str,
window: chrono::Duration,
}
pub async fn run_watchdog(pool: PgPool, tx: broadcast::Sender<crate::TelemetryEvent>) { pub async fn run_watchdog(pool: PgPool, tx: broadcast::Sender<crate::TelemetryEvent>) {
tracing::info!("🐕 Watchdog started: Monitoring telemetry silence & health..."); tracing::info!("🐕 Watchdog started: Monitoring telemetry silence & health...");
@@ -18,72 +29,80 @@ pub async fn run_watchdog(pool: PgPool, tx: broadcast::Sender<crate::TelemetryEv
if let Err(e) = check_telemetry_silence(&pool, &tx).await { if let Err(e) = check_telemetry_silence(&pool, &tx).await {
tracing::error!("Watchdog error (silence check): {}", e); tracing::error!("Watchdog error (silence check): {}", e);
notifier::send_system_alert("silence check", &e).await;
} }
if let Err(e) = check_unhealthy_agents(&pool, &tx).await { if let Err(e) = check_unhealthy_agents(&pool, &tx).await {
tracing::error!("Watchdog error (health check): {}", e); tracing::error!("Watchdog error (health check): {}", e);
notifier::send_system_alert("health check", &e).await;
}
if let Err(e) = check_report_review_deadlines(&pool).await {
tracing::error!("Watchdog error (report review deadline check): {}", e);
notifier::send_system_alert("report review deadline check", &e).await;
} }
crate::metrics::update_system_gauges(&pool).await; crate::metrics::update_system_gauges(&pool).await;
// Limpieza diaria: borrar logs de agente con más de 6 meses (tick cada 60s → 1440 = 24h) // Limpieza diaria: borrar logs de agente con más de 6 meses (tick cada 60s → 1440 = 24h)
if tick_count % 1440 == 0 { if tick_count % 1440 == 1 {
if let Err(e) = cleanup_old_agent_logs(&pool).await { if let Err(e) = cleanup_old_agent_logs(&pool).await {
tracing::error!("Watchdog error (log cleanup): {}", e); tracing::error!("Watchdog error (log cleanup): {}", e);
notifier::send_system_alert("log cleanup", &anyhow::Error::new(e)).await;
} }
if let Err(e) = take_daily_snapshots(&pool).await { if let Err(e) = take_daily_snapshots(&pool).await {
tracing::error!("Watchdog error (usage snapshots): {}", e); tracing::error!("Watchdog error (usage snapshots): {}", e);
notifier::send_system_alert("usage snapshots", &e).await;
}
if let Err(e) = check_calibration_expirations(&pool, &tx).await {
tracing::error!("Watchdog error (calibration check): {}", e);
notifier::send_system_alert("calibration check", &e).await;
} }
} }
} }
} }
async fn check_telemetry_silence(pool: &PgPool, tx: &broadcast::Sender<crate::TelemetryEvent>) -> anyhow::Result<()> { async fn check_telemetry_silence(
// Buscar proyectos activos que NO hayan tenido telemetría en los últimos 10 minutos pool: &PgPool,
let rows = sqlx::query( tx: &broadcast::Sender<crate::TelemetryEvent>,
r#" ) -> anyhow::Result<()> {
SELECT p.id, p.name for spec in telemetry_silence_specs() {
FROM edge_projects p let window = format_window_interval(spec.window);
WHERE p.is_active = true let rows = sqlx::query(telemetry_silence_select_sql())
AND NOT EXISTS ( .bind(spec.frequency_class)
SELECT 1 FROM telemetry_raw t .bind(&window)
WHERE t.project_id = p.id
AND t.time > NOW() - INTERVAL '10 minutes'
)
AND EXISTS (
SELECT 1 FROM edge_assets a WHERE a.project_id = p.id
)
"#
)
.fetch_all(pool) .fetch_all(pool)
.await?; .await?;
for row in rows { for row in rows {
let proj_id: Uuid = row.get("id"); let proj_id: Uuid = row.get("id");
let proj_name: String = row.get("name"); let proj_name: String = row.get("name");
let msg = format!("ALERTA ANH: No se recibe telemetría del proyecto {} hace más de 10 min.", proj_name); let msg = format!(
"ALERTA ANH: No se recibe telemetría {frequency_class} del proyecto {proj_name} en la ventana configurada ({window}). source={source} frequency_class={frequency_class}",
source = spec.source,
frequency_class = spec.frequency_class,
);
let already_alerted = sqlx::query( let already_alerted = sqlx::query(telemetry_silence_recent_alarm_sql())
r#"
SELECT 1 FROM telemetry_alarms
WHERE project_id = $1
AND alarm_type = 'TELEMETRY_SILENCE'
AND timestamp > NOW() - INTERVAL '1 hour'
LIMIT 1
"#
)
.bind(proj_id) .bind(proj_id)
.bind(spec.alarm_type)
.fetch_optional(pool) .fetch_optional(pool)
.await?; .await?;
if already_alerted.is_none() { if already_alerted.is_none() {
tracing::warn!("⚠️ Detectado silencio prolongado en project: {}", proj_name); tracing::warn!(
source = spec.source,
frequency_class = spec.frequency_class,
"⚠️ Detectado silencio prolongado en project: {}",
proj_name
);
sqlx::query( sqlx::query(
"INSERT INTO telemetry_alarms (project_id, device_name, alarm_type, message, timestamp) "INSERT INTO telemetry_alarms (project_id, device_name, alarm_type, message, timestamp)
VALUES ($1, 'SYSTEM_WATCHDOG', 'TELEMETRY_SILENCE', $2, $3)" VALUES ($1, 'SYSTEM_WATCHDOG', $2, $3, $4)"
) )
.bind(proj_id) .bind(proj_id)
.bind(spec.alarm_type)
.bind(&msg) .bind(&msg)
.bind(Utc::now()) .bind(Utc::now())
.execute(pool) .execute(pool)
@@ -92,40 +111,109 @@ async fn check_telemetry_silence(pool: &PgPool, tx: &broadcast::Sender<crate::Te
let alarm = AlarmNotification { let alarm = AlarmNotification {
project_id: proj_id, project_id: proj_id,
asset_id: None, asset_id: None,
variable: "TELEMETRY_SILENCE".to_string(), variable: spec.alarm_type.to_string(),
value: 0.0, value: 0.0,
alarm_type: "TELEMETRY_SILENCE".to_string(), alarm_type: spec.alarm_type.to_string(),
message: msg, message: msg,
timestamp: Utc::now(), timestamp: Utc::now(),
}; };
notifier::dispatch(pool, &alarm, tx).await; notifier::dispatch(pool, &alarm, tx).await;
} }
} }
}
Ok(()) Ok(())
} }
async fn check_unhealthy_agents(pool: &PgPool, tx: &broadcast::Sender<crate::TelemetryEvent>) -> anyhow::Result<()> { fn telemetry_silence_specs() -> [SilenceSpec; 2] {
// Proyectos con heartbeats perdidos (> 5 minutos) let hf_minutes = std::env::var("OMNIOIL_TELEMETRY_SILENCE_HF_MINUTES")
let lost_agents = sqlx::query( .ok()
.and_then(|value| value.parse::<i64>().ok())
.filter(|value| *value > 0);
let lf_hours = std::env::var("OMNIOIL_TELEMETRY_SILENCE_LF_HOURS")
.ok()
.and_then(|value| value.parse::<i64>().ok())
.filter(|value| *value > 0);
telemetry_silence_specs_from_values(hf_minutes, lf_hours)
}
fn telemetry_silence_specs_from_values(
hf_minutes: Option<i64>,
lf_hours: Option<i64>,
) -> [SilenceSpec; 2] {
let hf_minutes = hf_minutes.unwrap_or(10);
let lf_hours = lf_hours.unwrap_or(24);
[
SilenceSpec {
source: "SCADA",
frequency_class: "HF",
alarm_type: TELEMETRY_SILENCE_HF,
window: chrono::Duration::minutes(hf_minutes),
},
SilenceSpec {
source: "PWA|MANUAL",
frequency_class: "LF",
alarm_type: TELEMETRY_SILENCE_LF,
window: chrono::Duration::hours(lf_hours),
},
]
}
fn format_window_interval(window: chrono::Duration) -> String {
format!("{} seconds", window.num_seconds())
}
pub(crate) fn telemetry_silence_select_sql() -> &'static str {
r#" r#"
SELECT id, name SELECT p.id, p.name
FROM edge_projects FROM edge_projects p
WHERE is_active = true WHERE p.is_active = true
AND (last_health_report->>'timestamp')::TIMESTAMPTZ < NOW() - INTERVAL '5 minutes' AND NOT EXISTS (
"# SELECT 1 FROM telemetry_raw t
WHERE t.project_id = p.id
AND CASE t.source WHEN 'SCADA' THEN 'HF' ELSE 'LF' END = $1
AND t.time > NOW() - ($2::text)::interval
) )
AND EXISTS (
SELECT 1 FROM edge_assets a WHERE a.project_id = p.id
)
"#
}
pub(crate) fn telemetry_silence_recent_alarm_sql() -> &'static str {
r#"
SELECT 1 FROM telemetry_alarms
WHERE project_id = $1
AND (
alarm_type = $2
OR ($2 = 'TELEMETRY_SILENCE_HF' AND alarm_type = 'TELEMETRY_SILENCE')
)
AND timestamp > NOW() - INTERVAL '1 hour'
LIMIT 1
"#
}
async fn check_unhealthy_agents(
pool: &PgPool,
tx: &broadcast::Sender<crate::TelemetryEvent>,
) -> anyhow::Result<()> {
let lost_agents = sqlx::query(unhealthy_agents_select_sql())
.fetch_all(pool) .fetch_all(pool)
.await?; .await?;
for row in lost_agents { for row in lost_agents {
let agent_id: Uuid = row.get("id"); let agent_id: Uuid = row.get("id");
let agent_name: String = row.get("name"); let agent_name: String = row.get("name");
let msg = format!("Falla de Comunicación: El agente del proyecto {} no responde.", agent_name); let msg = format!(
"Falla de Comunicación: El agente del proyecto {} no responde.",
agent_name
);
sqlx::query( sqlx::query(
"INSERT INTO telemetry_alarms (project_id, device_name, alarm_type, message, timestamp) "INSERT INTO telemetry_alarms (project_id, device_name, alarm_type, message, timestamp)
VALUES ($1, 'SYSTEM_WATCHDOG', 'AGENT_OFFLINE', $2, $3)" VALUES ($1, 'SYSTEM_WATCHDOG', 'AGENT_OFFLINE', $2, $3)",
) )
.bind(agent_id) .bind(agent_id)
.bind(&msg) .bind(&msg)
@@ -148,11 +236,37 @@ async fn check_unhealthy_agents(pool: &PgPool, tx: &broadcast::Sender<crate::Tel
Ok(()) Ok(())
} }
pub(crate) fn unhealthy_agents_select_sql() -> &'static str {
r#"
SELECT p.id, p.name
FROM edge_projects p
WHERE p.is_active = true
AND p.installer_status = 'COMPLETED'
AND btrim(p.contract_number) <> ''
AND upper(btrim(p.contract_number)) NOT LIKE 'PENDING-%'
AND upper(btrim(p.contract_number)) NOT LIKE 'TRIAL-%'
AND p.last_health_report ? 'timestamp'
AND jsonb_typeof(p.last_health_report->'timestamp') = 'string'
AND (p.last_health_report->>'timestamp') ~ '^[0-9]{4}-[0-9]{2}-[0-9]{2}T'
AND (p.last_health_report->>'timestamp')::TIMESTAMPTZ < NOW() - INTERVAL '5 minutes'
AND EXISTS (
SELECT 1 FROM edge_assets a
WHERE a.project_id = p.id
AND a.is_active = true
)
AND NOT EXISTS (
SELECT 1 FROM telemetry_alarms ta
WHERE ta.project_id = p.id
AND ta.alarm_type = 'AGENT_OFFLINE'
AND ta.timestamp > NOW() - INTERVAL '1 hour'
)
"#
}
/// Elimina logs de agente más antiguos de 6 meses (retención máxima para visualización en tiempo real). /// Elimina logs de agente más antiguos de 6 meses (retención máxima para visualización en tiempo real).
async fn cleanup_old_agent_logs(pool: &PgPool) -> Result<(), sqlx::Error> { async fn cleanup_old_agent_logs(pool: &PgPool) -> Result<(), sqlx::Error> {
let result = sqlx::query( let result =
"DELETE FROM agent_logs WHERE timestamp < NOW() - INTERVAL '6 months'" sqlx::query("DELETE FROM agent_logs WHERE timestamp < NOW() - INTERVAL '6 months'")
)
.execute(pool) .execute(pool)
.await?; .await?;
@@ -165,3 +279,351 @@ async fn cleanup_old_agent_logs(pool: &PgPool) -> Result<(), sqlx::Error> {
Ok(()) Ok(())
} }
async fn check_report_review_deadlines(pool: &PgPool) -> anyhow::Result<()> {
use shared_lib::models::ANHReport;
// Buscar reportes generados que siguen pendientes de revisión manual.
let pending_reports = sqlx::query_as::<_, ANHReport>(report_review_deadline_select_sql())
.fetch_all(pool)
.await?;
for report in pending_reports {
if let Some(start) = report.period_start {
// Default: 06:00 AM COT (Bogota), matching official daily ANH generation.
let mut local_time = default_review_cutoff_time().format("%H:%M").to_string();
let mut timezone = "America/Bogota".to_string();
// Si el reporte tiene una programación asignada, cargamos su hora y zona horaria
if let Some(sched_id) = report.schedule_id {
if let Ok(Some(sched)) = sqlx::query_as::<_, shared_lib::models::ANHReportSchedule>(
"SELECT id, operator_name, contract_number, to_char(local_time, 'HH24:MI') as local_time, timezone, is_active, next_run_at, last_run_at, last_status, last_error, claim_token, claimed_at, created_at, updated_at, created_by, updated_by FROM anh_report_schedules WHERE id = $1"
)
.bind(sched_id)
.fetch_optional(pool)
.await {
local_time = sched.local_time;
timezone = sched.timezone;
}
}
// Parsear zona horaria
let tz = timezone
.parse::<chrono_tz::Tz>()
.unwrap_or(chrono_tz::America::Bogota);
let local_start = start.with_timezone(&tz);
let local_date = local_start.date_naive();
// Límite de revisión (cutoff) es al día siguiente (D+1) a la hora configurada (local_time)
let cutoff_date = local_date.succ_opt().unwrap_or(local_date);
let time = parse_review_cutoff_time(&local_time);
let local_cutoff = cutoff_date.and_time(time);
let deadline = match tz.from_local_datetime(&local_cutoff) {
chrono::LocalResult::Single(dt) => dt.with_timezone(&chrono::Utc),
chrono::LocalResult::Ambiguous(earliest, _) => earliest.with_timezone(&chrono::Utc),
chrono::LocalResult::None => {
start + chrono::Duration::days(1) + chrono::Duration::hours(12)
}
};
if chrono::Utc::now() >= deadline {
tracing::warn!(
"{}",
review_deadline_reached_message(&report.filename, report.id, deadline)
);
// Auto-sellado
let sealed = if report.object_key.is_some() {
report
} else {
let final_json_bytes =
crate::handlers::anh_reports::final_json_bytes_for_sealing(&report.content)
.map_err(|e| anyhow::anyhow!("Error de formato JSON: {:?}", e))?;
crate::handlers::anh_reports::seal_report_artifact(
pool,
report.id,
&report.filename,
&final_json_bytes,
)
.await
.map_err(|e| anyhow::anyhow!("Error al sellar reporte: {:?}", e))?
};
// Auto-aprobación (status = 'APPROVED')
let updated = sqlx::query_as::<_, ANHReport>(
r#"UPDATE anh_reports
SET status = 'APPROVED',
approved_at = NOW(),
approved_by = NULL
WHERE id = $1
RETURNING *"#,
)
.bind(sealed.id)
.fetch_one(pool)
.await?;
// Registrar en la auditoría
crate::audit::log(
pool,
crate::audit::AuditEntry::new("anh_report.auto_approve")
.with_resource(updated.id.to_string())
.with_metadata(serde_json::json!({
"report_id": updated.id,
"filename": updated.filename,
"hash_sha384": updated.hash_sha384,
"reason": automatic_approval_audit_reason(deadline),
})),
)
.await;
tracing::info!(
"✅ Reporte {} (ID: {}) aprobado automáticamente por el sistema.",
updated.filename,
updated.id
);
}
}
}
Ok(())
}
pub(crate) fn report_review_deadline_select_sql() -> &'static str {
"SELECT * FROM anh_reports WHERE status = 'GENERATED' AND correction_version = 0"
}
fn review_deadline_reached_message(
filename: &str,
report_id: Uuid,
deadline: chrono::DateTime<Utc>,
) -> String {
format!(
"⏰ Límite de revisión alcanzado (cutoff {}) para el reporte de {} (ID: {}). Se procederá con el sellado y aprobación preventiva.",
deadline, filename, report_id
)
}
fn automatic_approval_audit_reason(deadline: chrono::DateTime<Utc>) -> String {
format!(
"Aprobación preventiva por límite de tiempo de corte ({}) alcanzado",
deadline
)
}
fn default_review_cutoff_time() -> chrono::NaiveTime {
chrono::NaiveTime::from_hms_opt(6, 0, 0).unwrap()
}
fn parse_review_cutoff_time(local_time: &str) -> chrono::NaiveTime {
chrono::NaiveTime::parse_from_str(local_time, "%H:%M")
.unwrap_or_else(|_| default_review_cutoff_time())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn report_review_deadline_select_only_targets_base_generated_drafts() {
let sql = report_review_deadline_select_sql();
assert!(sql.contains("status = 'GENERATED'"));
assert!(sql.contains("correction_version = 0"));
assert!(!sql.contains("CORRECTION_DRAFT"));
assert!(!sql.contains("CORRECTED_APPROVED"));
assert!(!sql.contains("AVAILABLE_FOR_ANH"));
assert!(!sql.contains("SENT"));
assert!(!sql.contains("DISPOSED"));
}
#[test]
fn watchdog_review_deadline_copy_uses_approval_not_outbound_send_terms() {
let deadline = chrono::DateTime::parse_from_rfc3339("2026-06-16T12:00:00Z")
.unwrap()
.with_timezone(&Utc);
let message = review_deadline_reached_message(
"report.json",
Uuid::parse_str("aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa").unwrap(),
deadline,
);
let reason = automatic_approval_audit_reason(deadline);
let combined = format!("{} {}", message, reason).to_lowercase();
assert!(combined.contains("aprob"));
assert!(combined.contains("sell"));
assert!(!combined.contains("auto-env"));
assert!(!combined.contains("envío"));
assert!(!combined.contains("enviar"));
assert!(!combined.contains("send"));
assert!(!combined.contains("sent"));
}
#[test]
fn watchdog_default_review_cutoff_aligns_with_six_am_bogota() {
assert_eq!(
default_review_cutoff_time(),
chrono::NaiveTime::from_hms_opt(6, 0, 0).unwrap()
);
assert_eq!(
parse_review_cutoff_time("invalid"),
chrono::NaiveTime::from_hms_opt(6, 0, 0).unwrap()
);
}
#[test]
fn unhealthy_agents_select_requires_operational_projects_with_prior_health() {
let sql = unhealthy_agents_select_sql();
assert!(sql.contains("p.is_active = true"));
assert!(sql.contains("p.installer_status = 'COMPLETED'"));
assert!(sql.contains("last_health_report ? 'timestamp'"));
assert!(sql.contains("jsonb_typeof(p.last_health_report->'timestamp') = 'string'"));
assert!(sql.contains("~ '^[0-9]{4}-[0-9]{2}-[0-9]{2}T'"));
assert!(sql.contains(
"(p.last_health_report->>'timestamp')::TIMESTAMPTZ < NOW() - INTERVAL '5 minutes'"
));
assert!(sql.contains("EXISTS ("));
assert!(sql.contains("FROM edge_assets a"));
assert!(sql.contains("a.project_id = p.id"));
assert!(sql.contains("a.is_active = true"));
}
#[test]
fn unhealthy_agents_select_skips_pending_trial_and_recent_offline_noise() {
let sql = unhealthy_agents_select_sql();
assert!(sql.contains("upper(btrim(p.contract_number)) NOT LIKE 'PENDING-%'"));
assert!(sql.contains("upper(btrim(p.contract_number)) NOT LIKE 'TRIAL-%'"));
assert!(sql.contains("NOT EXISTS ("));
assert!(sql.contains("FROM telemetry_alarms ta"));
assert!(sql.contains("ta.project_id = p.id"));
assert!(sql.contains("ta.alarm_type = 'AGENT_OFFLINE'"));
assert!(sql.contains("ta.timestamp > NOW() - INTERVAL '1 hour'"));
}
#[test]
fn telemetry_silence_select_uses_source_derived_frequency_classes() {
let sql = telemetry_silence_select_sql();
assert!(sql.contains("FROM telemetry_raw t"));
assert!(sql.contains("CASE t.source WHEN 'SCADA' THEN 'HF' ELSE 'LF' END = $1"));
assert!(sql.contains("t.time > NOW() - ($2::text)::interval"));
}
#[test]
fn telemetry_silence_recent_alarm_maps_legacy_silence_to_hf() {
let sql = telemetry_silence_recent_alarm_sql();
assert!(sql.contains("alarm_type = $2"));
assert!(sql.contains("$2 = 'TELEMETRY_SILENCE_HF'"));
assert!(sql.contains("alarm_type = 'TELEMETRY_SILENCE'"));
}
#[test]
fn telemetry_silence_defaults_are_hf_ten_minutes_and_lf_twenty_four_hours() {
let specs = telemetry_silence_specs_from_values(None, None);
assert_eq!(specs[0].frequency_class, "HF");
assert_eq!(specs[0].alarm_type, TELEMETRY_SILENCE_HF);
assert_eq!(format_window_interval(specs[0].window), "600 seconds");
assert_eq!(specs[1].frequency_class, "LF");
assert_eq!(specs[1].alarm_type, TELEMETRY_SILENCE_LF);
assert_eq!(format_window_interval(specs[1].window), "86400 seconds");
}
}
async fn check_calibration_expirations(
pool: &PgPool,
tx: &broadcast::Sender<crate::TelemetryEvent>,
) -> anyhow::Result<()> {
let rows = sqlx::query(
r#"
SELECT id, project_id, name, code, next_calibration_date
FROM edge_assets
WHERE is_active = true
AND next_calibration_date <= NOW() + INTERVAL '30 days'
"#,
)
.fetch_all(pool)
.await?;
for row in rows {
let asset_id: Uuid = row.get("id");
let project_id: Uuid = row.get("project_id");
let name: String = row.get("name");
let code: String = row.get("code");
let next_cal: chrono::NaiveDate = row.get("next_calibration_date");
let today = Utc::now().date_naive();
let days_left = (next_cal - today).num_days();
let alarm_type = if days_left < 0 {
"CALIBRATION_EXPIRED"
} else {
"CALIBRATION_WARNING"
};
let msg = if days_left < 0 {
format!(
"ALERTA METROLOGÍA: La calibración para el activo {} ({}) venció hace {} días el {}.",
name, code, -days_left, next_cal
)
} else {
format!(
"ALERTA METROLOGÍA: La calibración para el activo {} ({}) vencerá en {} días el {}.",
name, code, days_left, next_cal
)
};
// Evitar duplicar alertas recientes del mismo tipo en las últimas 24 horas para este activo
let already_alerted = sqlx::query(
r#"
SELECT 1 FROM telemetry_alarms
WHERE project_id = $1
AND device_name = $2
AND alarm_type = $3
AND timestamp > NOW() - INTERVAL '24 hours'
LIMIT 1
"#,
)
.bind(project_id)
.bind(&code)
.bind(alarm_type)
.fetch_optional(pool)
.await?;
if already_alerted.is_none() {
tracing::warn!(
"⚠️ Calibración vencida/por vencer en activo {}: {}",
code,
msg
);
sqlx::query(
"INSERT INTO telemetry_alarms (project_id, device_name, alarm_type, message, timestamp)
VALUES ($1, $2, $3, $4, $5)"
)
.bind(project_id)
.bind(&code)
.bind(alarm_type)
.bind(&msg)
.bind(Utc::now())
.execute(pool)
.await?;
let alarm = AlarmNotification {
project_id,
asset_id: Some(asset_id),
variable: "next_calibration_date".to_string(),
value: days_left as f64,
alarm_type: alarm_type.to_string(),
message: msg,
timestamp: Utc::now(),
};
notifier::dispatch(pool, &alarm, tx).await;
}
}
Ok(())
}

View File

@@ -0,0 +1,303 @@
use axum::{
extract::{Query, State, ws::{Message, WebSocket, WebSocketUpgrade}},
response::IntoResponse,
routing::{get, post},
Json, Router,
};
use crate::{
auth, AppState,
};
use serde::{Deserialize, Serialize};
use sha2::{Digest, Sha256};
use sqlx::Row;
#[derive(Deserialize)]
pub(crate) struct WsParams {
project_id: Option<String>,
ticket: Option<String>,
}
#[derive(Deserialize)]
pub(crate) struct CreateWsTicketRequest {
project_id: String,
}
#[derive(Serialize)]
pub(crate) struct CreateWsTicketResponse {
ticket: String,
expires_in: u64,
}
const WS_TICKET_TTL_SECONDS: u64 = 60;
pub fn routes() -> Router<AppState> {
Router::new()
.route("/api/ws/telemetry/ticket", post(create_ws_telemetry_ticket))
.route("/api/ws/telemetry", get(ws_telemetry_handler))
}
async fn create_ws_telemetry_ticket(
State(state): State<AppState>,
claims: auth::Claims,
Json(req): Json<CreateWsTicketRequest>,
) -> impl IntoResponse {
let user_id = match uuid::Uuid::parse_str(&claims.sub) {
Ok(user_id) => user_id,
Err(_) => {
return (
axum::http::StatusCode::UNAUTHORIZED,
Json(serde_json::json!({"error": "Usuario inválido"})),
)
.into_response();
}
};
let project_id = match uuid::Uuid::parse_str(&req.project_id) {
Ok(project_id) => project_id,
Err(_) => {
return (
axum::http::StatusCode::BAD_REQUEST,
Json(serde_json::json!({"error": "project_id inválido"})),
)
.into_response();
}
};
match user_can_access_project(&state.pool, user_id, project_id).await {
Ok(true) => {}
Ok(false) => {
return (
axum::http::StatusCode::FORBIDDEN,
Json(serde_json::json!({"error": "Access denied to this project"})),
)
.into_response();
}
Err(error) => {
tracing::error!(error = %error, "Failed to check WebSocket project access");
return (
axum::http::StatusCode::INTERNAL_SERVER_ERROR,
Json(serde_json::json!({"error": "Ticket creation failed"})),
)
.into_response();
}
}
let ticket = format!("wst_{}", uuid::Uuid::new_v4().simple());
let token_hash = hash_ws_ticket(&ticket);
let expires_in = WS_TICKET_TTL_SECONDS as i64;
if let Err(error) = sqlx::query(
r#"INSERT INTO websocket_tickets (token_hash, user_id, project_id, expires_at)
VALUES ($1, $2, $3, NOW() + ($4::text)::interval)"#,
)
.bind(token_hash)
.bind(user_id)
.bind(project_id)
.bind(format!("{} seconds", expires_in))
.execute(&state.pool)
.await
{
tracing::error!(error = %error, "Failed to persist WebSocket ticket");
return (
axum::http::StatusCode::INTERNAL_SERVER_ERROR,
Json(serde_json::json!({"error": "Ticket creation failed"})),
)
.into_response();
}
Json(CreateWsTicketResponse {
ticket,
expires_in: WS_TICKET_TTL_SECONDS,
})
.into_response()
}
async fn ws_telemetry_handler(
ws: WebSocketUpgrade,
query: Query<WsParams>,
State(state): State<AppState>,
) -> impl IntoResponse {
let ticket = match &query.ticket {
Some(ticket) => ticket.as_str(),
None => {
return (
axum::http::StatusCode::UNAUTHORIZED,
Json(serde_json::json!({"error": "Ticket requerido para conexión WebSocket"})),
)
.into_response();
}
};
let project_id = match consume_ws_ticket(&state.pool, ticket, query.project_id.as_deref()).await
{
Ok(project_id) => project_id,
Err(status) => {
let message = if status == axum::http::StatusCode::FORBIDDEN {
"Ticket no corresponde al proyecto solicitado"
} else {
"Ticket inválido o expirado"
};
return (status, Json(serde_json::json!({"error": message}))).into_response();
}
};
let project_id = Some(project_id.to_string());
tracing::debug!(
"New WebSocket upgrade request for telemetry. Filter: {:?}",
project_id
);
ws.on_upgrade(move |socket| handle_socket(socket, state, project_id))
}
fn hash_ws_ticket(ticket: &str) -> String {
let mut hasher = Sha256::new();
hasher.update(ticket.as_bytes());
hasher
.finalize()
.iter()
.map(|byte| format!("{byte:02x}"))
.collect()
}
async fn user_can_access_project(
pool: &sqlx::PgPool,
user_id: uuid::Uuid,
project_id: uuid::Uuid,
) -> Result<bool, sqlx::Error> {
let access = sqlx::query(
r#"SELECT 1
FROM user_project_access
WHERE user_id = $1
AND project_id = $2
AND is_active = true"#,
)
.bind(user_id)
.bind(project_id)
.fetch_optional(pool)
.await?;
Ok(access.is_some())
}
async fn consume_ws_ticket(
pool: &sqlx::PgPool,
ticket: &str,
requested_project_id: Option<&str>,
) -> Result<uuid::Uuid, axum::http::StatusCode> {
let token_hash = hash_ws_ticket(ticket);
let row = sqlx::query(
r#"UPDATE websocket_tickets
SET used_at = NOW()
WHERE token_hash = $1
AND used_at IS NULL
AND expires_at > NOW()
RETURNING project_id"#,
)
.bind(token_hash)
.fetch_optional(pool)
.await
.map_err(|error| {
tracing::error!(error = %error, "Failed to consume WebSocket ticket");
axum::http::StatusCode::INTERNAL_SERVER_ERROR
})?
.ok_or(axum::http::StatusCode::UNAUTHORIZED)?;
let project_id: uuid::Uuid = row.get("project_id");
if let Some(requested_project_id) = requested_project_id {
let requested = uuid::Uuid::parse_str(requested_project_id)
.map_err(|_| axum::http::StatusCode::BAD_REQUEST)?;
if requested != project_id {
return Err(axum::http::StatusCode::FORBIDDEN);
}
}
Ok(project_id)
}
async fn handle_socket(mut socket: WebSocket, state: AppState, filter_project_id: Option<String>) {
tracing::info!(
"🔌 WebSocket connection upgraded and active for project: {:?}",
filter_project_id
);
let mut rx = state.tx.subscribe();
let mut ping_interval = tokio::time::interval(std::time::Duration::from_secs(15));
ping_interval.tick().await;
loop {
tokio::select! {
result = rx.recv() => {
match result {
Ok(event) => {
if let Some(ref target_id) = filter_project_id {
if &*event.project_id != target_id.to_lowercase() {
continue;
}
}
if socket.send(Message::Text(event.payload.to_string().into())).await.is_err() {
tracing::debug!("WebSocket client disconnected (send failed)");
break;
}
}
Err(tokio::sync::broadcast::error::RecvError::Lagged(count)) => {
tracing::warn!("WebSocket receiver lagged by {} messages", count);
continue;
}
Err(tokio::sync::broadcast::error::RecvError::Closed) => {
tracing::error!("Broadcast channel closed");
break;
}
}
}
msg = socket.recv() => {
match msg {
Some(Ok(Message::Close(_))) | None => {
tracing::debug!("WebSocket client closed connection");
break;
}
Some(Ok(Message::Pong(_))) => {}
Some(Err(_)) => {
tracing::debug!("WebSocket read error, dropping connection");
break;
}
_ => {}
}
}
_ = ping_interval.tick() => {
if socket.send(Message::Ping(vec![1, 2, 3].into())).await.is_err() {
tracing::debug!("WebSocket ping failed, client is dead");
break;
}
}
}
}
tracing::info!(
"🔌 WebSocket connection closed for project: {:?}",
filter_project_id
);
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn websocket_ticket_hash_never_stores_raw_ticket() {
let ticket = "wst_example-ticket";
let hash = hash_ws_ticket(ticket);
assert_ne!(hash, ticket);
assert_eq!(hash.len(), 64);
assert!(hash.chars().all(|ch| ch.is_ascii_hexdigit()));
}
#[test]
fn websocket_ticket_migration_enforces_expiry_and_single_use_state() {
let migration = include_str!("../migrations/20260621000000_websocket_tickets.sql");
assert!(migration.contains("token_hash TEXT PRIMARY KEY"));
assert!(migration.contains("expires_at TIMESTAMPTZ NOT NULL"));
assert!(migration.contains("used_at TIMESTAMPTZ NULL"));
assert!(migration.contains("REFERENCES edge_projects(id) ON DELETE CASCADE"));
}
}

View File

@@ -283,6 +283,10 @@ async fn approve_pending_and_in_review_requests_provision_customer_access() {
assert!(row.provisioned_user_id.is_some()); assert!(row.provisioned_user_id.is_some());
assert!(row.provisioned_project_id.is_some()); assert!(row.provisioned_project_id.is_some());
assert!(row.provisioned_at.is_some()); assert!(row.provisioned_at.is_some());
assert!(
row.trial_ends_at.is_some(),
"approved access request response should expose subscription trial expiry"
);
assert_eq!(row.invitation_email_status.as_deref(), Some("failed")); assert_eq!(row.invitation_email_status.as_deref(), Some("failed"));
let counts = sqlx::query( let counts = sqlx::query(
@@ -337,6 +341,10 @@ async fn approve_pending_and_in_review_requests_provision_customer_access() {
let active_request_id = seed_access_request(&pool, "pending", Some(active_email.clone())).await; let active_request_id = seed_access_request(&pool, "pending", Some(active_email.clone())).await;
let active_row = approve_request(&pool, operator_id, active_request_id).await; let active_row = approve_request(&pool, operator_id, active_request_id).await;
assert_eq!(active_row.provisioned_user_id, Some(active_user_id)); assert_eq!(active_row.provisioned_user_id, Some(active_user_id));
assert!(
active_row.trial_ends_at.is_some(),
"active existing users still receive a provisioned project trial expiry"
);
let active_user_state = sqlx::query("SELECT password_hash, is_active FROM users WHERE id = $1") let active_user_state = sqlx::query("SELECT password_hash, is_active FROM users WHERE id = $1")
.bind(active_user_id) .bind(active_user_id)
.fetch_one(&pool) .fetch_one(&pool)

View File

@@ -0,0 +1,99 @@
use backend_api::billing_rules::{
DEFAULT_OPERATIVE_K, DEFAULT_OPERATIVE_PBASE_COP, OperativeBillingInput,
calculate_operative_billing, calculate_operative_billing_with_defaults,
};
fn expected_annual_cop(equivalent_tg: f64, pbase_cop: i64, k: f64) -> i64 {
(equivalent_tg * pbase_cop as f64 * equivalent_tg.powf(-k)).round() as i64
}
#[test]
fn hf_counts_as_one_equivalent_tg_with_defaults() {
let result = calculate_operative_billing_with_defaults(1, 0).expect("valid usage");
assert_eq!(result.hf_tags, 1);
assert_eq!(result.lf_tags, 0);
assert_eq!(result.equivalent_tg, 1.0);
assert_eq!(result.pbase_cop, DEFAULT_OPERATIVE_PBASE_COP);
assert_eq!(result.k, DEFAULT_OPERATIVE_K);
assert_eq!(result.annual_cop, 110_000);
assert_eq!(result.monthly_cop, 9_167);
assert_eq!(result.effective_hf_price_cop, 110_000);
assert_eq!(result.effective_lf_price_cop, 0);
}
#[test]
fn lf_counts_as_zero_point_four_equivalent_tg() {
let result = calculate_operative_billing_with_defaults(0, 1).expect("valid usage");
assert_eq!(result.hf_tags, 0);
assert_eq!(result.lf_tags, 1);
assert_eq!(result.equivalent_tg, 0.4);
assert_eq!(
result.annual_cop,
expected_annual_cop(0.4, DEFAULT_OPERATIVE_PBASE_COP, DEFAULT_OPERATIVE_K)
);
assert_eq!(result.effective_hf_price_cop, 0);
assert_eq!(result.effective_lf_price_cop, result.annual_cop);
}
#[test]
fn mixed_usage_exposes_equivalent_tg_and_not_flat_total_tags_pricing() {
let result = calculate_operative_billing_with_defaults(1, 1).expect("valid usage");
assert_eq!(result.equivalent_tg, 1.4);
assert_eq!(
result.annual_cop,
expected_annual_cop(1.4, DEFAULT_OPERATIVE_PBASE_COP, DEFAULT_OPERATIVE_K)
);
assert_ne!(result.annual_cop, 2 * DEFAULT_OPERATIVE_PBASE_COP);
}
#[test]
fn volume_discount_uses_v_to_the_negative_k_factor() {
let one_tg = calculate_operative_billing_with_defaults(1, 0).expect("valid usage");
let ten_tg = calculate_operative_billing_with_defaults(10, 0).expect("valid usage");
assert_eq!(ten_tg.equivalent_tg, 10.0);
assert_eq!(
ten_tg.annual_cop,
expected_annual_cop(10.0, DEFAULT_OPERATIVE_PBASE_COP, DEFAULT_OPERATIVE_K)
);
assert!(ten_tg.effective_hf_price_cop < one_tg.effective_hf_price_cop);
assert_ne!(ten_tg.annual_cop, 10 * DEFAULT_OPERATIVE_PBASE_COP);
}
#[test]
fn zero_usage_returns_zero_without_applying_power_formula() {
let result = calculate_operative_billing_with_defaults(0, 0).expect("valid usage");
assert_eq!(result.equivalent_tg, 0.0);
assert_eq!(result.annual_cop, 0);
assert_eq!(result.monthly_cop, 0);
assert_eq!(result.effective_hf_price_cop, 0);
assert_eq!(result.effective_lf_price_cop, 0);
}
#[test]
fn custom_defaults_drive_effective_prices_until_profile_values_are_stored() {
let result = calculate_operative_billing(OperativeBillingInput {
hf_tags: 2,
lf_tags: 5,
pbase_cop: 200_000,
k: 0.20,
})
.expect("valid usage");
assert_eq!(result.equivalent_tg, 4.0);
assert_eq!(result.annual_cop, expected_annual_cop(4.0, 200_000, 0.20));
assert_eq!(
result.effective_hf_price_cop,
(result.annual_cop as f64 / result.equivalent_tg).round() as i64
);
assert_eq!(
result.effective_lf_price_cop,
((result.annual_cop as f64 / result.equivalent_tg) * 0.4).round() as i64
);
assert_eq!(result.currency, "COP");
assert_eq!(result.rounding_policy, "nearest peso");
}

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