Compare commits
436 Commits
17f8257936
...
Actualizac
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3277d41227 | ||
| 9ea2f48088 | |||
| 36cd78b870 | |||
| 7a38146744 | |||
| af2fc3c6f4 | |||
| 94fd169e20 | |||
| cf2448b439 | |||
|
|
9db196eea2 | ||
|
|
e5547e0134 | ||
|
|
368d9f4928 | ||
|
|
5d2cdd8ff5 | ||
|
|
76c8ad109f | ||
| 7e9c317a5f | |||
| 588a0aab09 | |||
| 0d91b49dec | |||
| 52fc377755 | |||
| 867ebceb4f | |||
| 99f73f7e2a | |||
| e5a67cab90 | |||
| 1f6743590e | |||
|
|
bc01767106 | ||
|
|
342bd08bdd | ||
|
|
6f6ed2fa36 | ||
|
|
2a18ada082 | ||
|
|
0ce9d2d550 | ||
|
|
fb017396f0 | ||
|
|
f16b112978 | ||
|
|
3e6e3c559d | ||
|
|
fae8173199 | ||
|
|
5948998f3d | ||
|
|
3815268b8a | ||
|
|
50bee181f6 | ||
|
|
da35b72f8f | ||
|
|
2908ac0c66 | ||
|
|
43b8349447 | ||
|
|
9b6b488c8a | ||
|
|
319ceaae30 | ||
|
|
8e0a2b78bb | ||
|
|
50674b3078 | ||
|
|
d47360f86c | ||
|
|
6cc6b4cc1a | ||
|
|
7121ca9f47 | ||
|
|
6a8cc55978 | ||
|
|
d1adbe436a | ||
|
|
895abef058 | ||
|
|
10e5993d2b | ||
|
|
ed6312f3a8 | ||
|
|
f5cea71798 | ||
|
|
74517e2612 | ||
|
|
b084cf4f93 | ||
|
|
21a43f189e | ||
|
|
2a166396a0 | ||
|
|
df718085cf | ||
|
|
ef069b2ab9 | ||
|
|
f55a022eb7 | ||
|
|
b2bf8d21c8 | ||
|
|
a18f059af1 | ||
|
|
f0ed6166a2 | ||
|
|
1a704b5197 | ||
|
|
1aa40c9067 | ||
|
|
39b83dbbe8 | ||
|
|
db3304ed43 | ||
|
|
91c7f12e25 | ||
|
|
240f8ac87a | ||
|
|
744e43c93f | ||
|
|
eb46661d3f | ||
|
|
87d32acb30 | ||
|
|
3ce893f24b | ||
|
|
959c89b5d3 | ||
|
|
f38ca951f7 | ||
|
|
4ccc1bbf90 | ||
|
|
df31ea1041 | ||
|
|
e661caa514 | ||
|
|
d9daa6e6e4 | ||
|
|
5b68e2c80e | ||
|
|
5b58da0f18 | ||
|
|
70d1922906 | ||
|
|
d97fc76f16 | ||
|
|
eaaf368053 | ||
|
|
40cd3ece33 | ||
|
|
333c3b3b21 | ||
|
|
5cd46f75a0 | ||
|
|
f250ad11de | ||
| c8a8089243 | |||
|
|
565b0c0a61 | ||
| 9b806d3fec | |||
|
|
64c42a02e4 | ||
|
|
338967b949 | ||
|
|
b331b05d55 | ||
|
|
6dd7754206 | ||
| 30a16e5b6c | |||
| e229f0ae50 | |||
| 47d31837e9 | |||
| 04ef63537c | |||
|
|
ce9ad1f8f0 | ||
|
|
d9c01647ef | ||
|
|
e615b06f7b | ||
|
|
9559eb4e7d | ||
|
|
311a5422a9 | ||
|
|
132be581f5 | ||
|
|
c43642218e | ||
|
|
91181431aa | ||
| ac411b1893 | |||
|
|
d70efd6b5e | ||
| 49431911fc | |||
|
|
f28cb4dad7 | ||
| baa906001e | |||
|
|
f9151dd9ba | ||
|
|
f009c43bd2 | ||
|
|
113be509ee | ||
|
|
439b80dca8 | ||
| 51e27e49f4 | |||
|
|
948b372f40 | ||
|
|
3c6cc42216 | ||
|
|
675b3033a4 | ||
|
|
b5357a3d0c | ||
| 761a4ab5a4 | |||
|
|
89907fbb58 | ||
|
|
3dfe4c5659 | ||
|
|
741034ac39 | ||
|
|
8cfd7a085b | ||
|
|
88dc61512f | ||
|
|
18d8ffa375 | ||
|
|
c1254566c1 | ||
| d5c768d9c3 | |||
| b7f1873504 | |||
|
|
fac9ab3fe0 | ||
|
|
3417d8afaf | ||
|
|
e6f5412ca2 | ||
|
|
29e89a0a41 | ||
|
|
3caf9ba42b | ||
|
|
acbf3cc56a | ||
|
|
e2c97bce74 | ||
|
|
8bb323b2aa | ||
|
|
9ab9579cad | ||
|
|
82777aa132 | ||
|
|
45e945f82b | ||
|
|
5d190f00d2 | ||
|
|
64914f98cd | ||
|
|
967e306e44 | ||
|
|
4b9fc196e5 | ||
|
|
f679bceca1 | ||
|
|
9070b5cacd | ||
|
|
a3401cfd95 | ||
|
|
9cccb8ba0e | ||
|
|
28301e68fa | ||
|
|
1511f13970 | ||
|
|
139dbaa9a9 | ||
|
|
0e66f4a04c | ||
|
|
1448f6f530 | ||
|
|
9bdb804dfd | ||
|
|
b923e75103 | ||
|
|
a02a10556e | ||
|
|
5c2ddac702 | ||
|
|
55f189f000 | ||
|
|
5ebd31b049 | ||
|
|
bc021bd80f | ||
|
|
7f717dd84d | ||
|
|
7cbae7d3cc | ||
|
|
00d130d776 | ||
|
|
60764cce50 | ||
|
|
0e6ed3b5db | ||
|
|
1617c89f8f | ||
|
|
875a5a1106 | ||
|
|
b077adf0f8 | ||
|
|
ea1cd59f33 | ||
|
|
161d330801 | ||
|
|
9635ec379b | ||
|
|
739ee9d413 | ||
|
|
43307fea12 | ||
|
|
3046823bb4 | ||
|
|
f6ea571613 | ||
|
|
11f29f9248 | ||
|
|
b2eb75d824 | ||
|
|
bb27ad14aa | ||
|
|
88fe56b11e | ||
|
|
8f1fd2d098 | ||
|
|
192b22b247 | ||
|
|
0bae416111 | ||
|
|
42cda1ce70 | ||
|
|
d709629e66 | ||
|
|
b799cb3109 | ||
| 0f6305e404 | |||
|
|
1571d2fa69 | ||
|
|
0179e593e1 | ||
|
|
3013b8ca66 | ||
|
|
8d2b5ef72a | ||
|
|
09c5451876 | ||
|
|
7bb5cd658a | ||
|
|
87a4d0427e | ||
|
|
99e8dec729 | ||
|
|
d3ec5f8645 | ||
|
|
a166e8ea0e | ||
|
|
9953a49eb3 | ||
|
|
f2ed516748 | ||
|
|
b1d60a5f9a | ||
|
|
99ed6a88a1 | ||
|
|
257976167a | ||
|
|
74e7832aef | ||
|
|
80b0457396 | ||
|
|
3721fa7d12 | ||
|
|
d486c07f87 | ||
|
|
e6e091895e | ||
|
|
9ee1e11608 | ||
|
|
4125ec627b | ||
|
|
0d6c006297 | ||
|
|
6c234f60fd | ||
|
|
3f850db0ae | ||
|
|
14b0a03b14 | ||
|
|
f18bfe65c3 | ||
|
|
9fdf049ea8 | ||
|
|
13562d3298 | ||
|
|
c71c58c5aa | ||
|
|
9f13d4f274 | ||
|
|
9e65a0b447 | ||
|
|
edcd02e469 | ||
|
|
88fb707a66 | ||
|
|
ea035b4b60 | ||
|
|
be3fcf1f28 | ||
|
|
a97f968be2 | ||
|
|
0ada878809 | ||
|
|
fd4220c98d | ||
|
|
0329296ea5 | ||
|
|
bfda385620 | ||
|
|
690f7ff48d | ||
|
|
e7c142f4cb | ||
|
|
5734abe176 | ||
|
|
6e755e2b56 | ||
|
|
4c7e092ffa | ||
|
|
095ec2ff25 | ||
|
|
66229ab050 | ||
|
|
83e067b334 | ||
|
|
c12deda9f9 | ||
|
|
c45862f49d | ||
|
|
eec83ea63d | ||
|
|
5ce407d5b1 | ||
|
|
e1f8d968a8 | ||
|
|
940f58ecdd | ||
|
|
4126ea9aa0 | ||
|
|
b7c348b655 | ||
|
|
509c91321f | ||
|
|
2928da900f | ||
|
|
0698ca2471 | ||
|
|
686704b658 | ||
|
|
a777a99367 | ||
|
|
13aea702f3 | ||
|
|
b43c41d2ae | ||
|
|
0e43805154 | ||
|
|
2ecca522cf | ||
|
|
37780092e3 | ||
|
|
7e6993fc72 | ||
|
|
8035f5005e | ||
|
|
7fb03039bd | ||
|
|
b90b91f7db | ||
|
|
fb0764bf9a | ||
|
|
86c85c4110 | ||
|
|
8b572457e0 | ||
|
|
bd473719bb | ||
|
|
b4dcae280a | ||
|
|
0c510e120b | ||
|
|
92675d9616 | ||
|
|
932932e9e6 | ||
|
|
c9d7f82b2d | ||
|
|
5e385667e8 | ||
|
|
1652669aad | ||
|
|
10fce1119f | ||
|
|
1d1b13b478 | ||
|
|
d7dc59b7f3 | ||
|
|
9cfddf0be6 | ||
|
|
4f91382b5e | ||
|
|
e29210b16c | ||
|
|
b8ab916c8d | ||
|
|
d2a3143c85 | ||
|
|
473631d329 | ||
|
|
70a2282716 | ||
|
|
fe0176da4a | ||
|
|
2bc9eb3120 | ||
|
|
0bb57a0749 | ||
|
|
3ca1d31ede | ||
|
|
3be117cb29 | ||
|
|
2da824f1a6 | ||
|
|
89590121a6 | ||
|
|
83f4c54d2c | ||
|
|
e45b05d288 | ||
|
|
e6a9a40ff8 | ||
|
|
14aa7c252a | ||
|
|
2e42230f54 | ||
|
|
4217e8ec39 | ||
|
|
9cdafcd262 | ||
|
|
1c08e44f45 | ||
|
|
08e097d640 | ||
|
|
330fb7cb30 | ||
|
|
5301f72e28 | ||
|
|
06c81822d0 | ||
|
|
073d80dc79 | ||
|
|
cc5b308dfb | ||
|
|
8ce5904cf7 | ||
|
|
5bce7cb5fc | ||
|
|
22962fdab8 | ||
|
|
41c4a2cdfb | ||
|
|
a6fc71bcc9 | ||
|
|
4a7aa94cb9 | ||
|
|
f95575a832 | ||
|
|
12adbc2e8c | ||
|
|
b3fe40637d | ||
|
|
fceb8fe1a5 | ||
|
|
e1c10f58f8 | ||
|
|
1d1d32c4d4 | ||
|
|
a4627c2b4c | ||
|
|
a92b731110 | ||
|
|
4801101d4f | ||
|
|
1b739e2b14 | ||
|
|
95b9d76f3f | ||
|
|
7ca59a43cc | ||
|
|
0093839b5e | ||
|
|
be7f0a8da4 | ||
|
|
44acb87203 | ||
|
|
e9e8d85092 | ||
|
|
930461e7ce | ||
|
|
de661f42e5 | ||
|
|
983c800583 | ||
|
|
92ec74dd92 | ||
|
|
abb95e1501 | ||
|
|
aa7cbbce7d | ||
|
|
910c2ab964 | ||
|
|
0beebb04c0 | ||
|
|
44d3b2cf26 | ||
|
|
21bb2bbea8 | ||
|
|
bec2580e2f | ||
|
|
351fd7dddd | ||
|
|
e05f808e61 | ||
|
|
0119902556 | ||
|
|
a7b262c053 | ||
|
|
02f24f27ce | ||
|
|
28d1a7d930 | ||
|
|
1c57abd97a | ||
|
|
fb0c172bb5 | ||
|
|
6f486a038b | ||
|
|
78e8ecb55a | ||
|
|
9f3d7212ee | ||
|
|
c81ed907f7 | ||
|
|
f90fb552fc | ||
|
|
83988cfcde | ||
|
|
22b39aadd3 | ||
|
|
9c878a310b | ||
|
|
2d9ef90b09 | ||
|
|
e6d884ae95 | ||
|
|
2dd1975b8d | ||
|
|
fa1be83665 | ||
|
|
f19a13def1 | ||
|
|
2c3dd8e8bd | ||
|
|
47d6a4e48d | ||
|
|
7ccadb0272 | ||
|
|
154f183341 | ||
| d1647ddbcf | |||
|
|
d130ee578f | ||
|
|
6afacdc7ce | ||
|
|
4566a57e94 | ||
|
|
79dd41ba6f | ||
|
|
77bcde08ef | ||
|
|
56bb76fdb2 | ||
|
|
7a774a70fc | ||
|
|
2a53ae7e9e | ||
|
|
c97ab61512 | ||
|
|
d6bbc66676 | ||
|
|
997b6b0da8 | ||
|
|
555aef226e | ||
|
|
f9af6a552e | ||
|
|
60ad7ca44f | ||
|
|
de213a22f0 | ||
|
|
93307c3b8f | ||
|
|
edc773b367 | ||
|
|
f43a3f202d | ||
|
|
a379c8dd59 | ||
|
|
02151e56dd | ||
|
|
db259b5dbe | ||
|
|
8359ae9a06 | ||
|
|
2430419257 | ||
|
|
ba0b0c633f | ||
|
|
7d2013cedf | ||
|
|
660092a8ff | ||
|
|
0092d20e35 | ||
|
|
1c9059a0ea | ||
|
|
a0cb973078 | ||
|
|
c92e0b5c06 | ||
|
|
0949f19c9d | ||
|
|
3bde9096ab | ||
| 63ef77d1ce | |||
| dae5e8bf00 | |||
|
|
a524e52a19 | ||
|
|
6f780cf22e | ||
|
|
0ad8fc05c1 | ||
|
|
977e500bd6 | ||
|
|
7efef41179 | ||
|
|
accefcd375 | ||
|
|
f83d530c18 | ||
|
|
58de3dc1f9 | ||
|
|
80403eeb20 | ||
|
|
dcf72cef79 | ||
|
|
58ebb1adf0 | ||
|
|
e56dac1269 | ||
|
|
98a09289a6 | ||
|
|
b34213c9c8 | ||
|
|
6e9fdc0f17 | ||
| a36b06bbf8 | |||
| 84f5c42e5b | |||
| 4f1f530ce1 | |||
|
|
dd4511cdfc | ||
|
|
55a3c9895d | ||
|
|
ee0ed8800a | ||
|
|
801c78fd57 | ||
|
|
9e5b69f3a2 | ||
|
|
11f4757fe0 | ||
|
|
980fbcc3b3 | ||
|
|
f3fd71e286 | ||
|
|
f0ce0a67cc | ||
| 7aec3b67fc | |||
| 0551d41475 | |||
| dade0608a8 | |||
| ebb165bbff | |||
|
|
6d0c432043 | ||
|
|
c72868f65b | ||
|
|
39eea9d6a4 | ||
|
|
d00db12ec0 | ||
| e83d88c599 | |||
|
|
e26af8ef37 | ||
|
|
0b73ef5c49 | ||
|
|
f5ba0df791 | ||
|
|
12c3564e01 | ||
|
|
a4f1fa137d | ||
|
|
952514784e | ||
|
|
ab000b29a9 | ||
|
|
487b075493 | ||
|
|
671a5339a9 | ||
| f910768719 | |||
| 30eff47bec |
14
.clippy.toml
Normal file
14
.clippy.toml
Normal 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
|
||||||
@@ -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
|
||||||
@@ -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
63
.github/workflows/ci.yml
vendored
Normal 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
8
.gitignore
vendored
@@ -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
16
.pre-commit-config.yaml
Normal 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
1464
Cargo.lock
generated
File diff suppressed because it is too large
Load Diff
15
Cargo.toml
15
Cargo.toml
@@ -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
113
Dockerfile.frontend
Normal 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;"]
|
||||||
@@ -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
76
Makefile
Normal 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
|
||||||
@@ -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:
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
200
docs/DOCKER_CACHE_OPTIMIZATION.md
Normal file
200
docs/DOCKER_CACHE_OPTIMIZATION.md
Normal 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)
|
||||||
@@ -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
115
docs/SIMULADORES.md
Normal 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
|
||||||
|
```
|
||||||
98
docs/architecture/telemetry-origin-contract.md
Normal file
98
docs/architecture/telemetry-origin-contract.md
Normal 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.
|
||||||
15
infrastructure/monitoring/alertmanager.yml
Normal file
15
infrastructure/monitoring/alertmanager.yml
Normal 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 }}'
|
||||||
@@ -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
|
||||||
|
|||||||
39
infrastructure/monitoring/loki-config.yml
Normal file
39
infrastructure/monitoring/loki-config.yml
Normal 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
|
||||||
30
infrastructure/monitoring/prometheus-rules.yml
Normal file
30
infrastructure/monitoring/prometheus-rules.yml
Normal 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."
|
||||||
@@ -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']
|
||||||
|
|||||||
21
infrastructure/monitoring/promtail-config.yml
Normal file
21
infrastructure/monitoring/promtail-config.yml
Normal 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'
|
||||||
@@ -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
0
infrastructure/nginx/entrypoint.sh
Normal file → Executable 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;
|
||||||
|
|||||||
@@ -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++
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
10537
pnpm-lock.yaml
generated
Normal file
File diff suppressed because it is too large
Load Diff
5
pnpm-workspace.yaml
Normal file
5
pnpm-workspace.yaml
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
packages:
|
||||||
|
- "services/landing"
|
||||||
|
- "services/frontend-admin"
|
||||||
|
- "services/frontend-console"
|
||||||
|
- "services/frontend-pwa"
|
||||||
3
rust-toolchain.toml
Normal file
3
rust-toolchain.toml
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
[toolchain]
|
||||||
|
channel = "stable"
|
||||||
|
components = ["rustfmt", "clippy"]
|
||||||
25
scripts/dev-watch.ps1
Normal file
25
scripts/dev-watch.ps1
Normal 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"
|
||||||
@@ -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"
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
|
|||||||
@@ -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');
|
||||||
@@ -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'));
|
||||||
@@ -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);
|
||||||
@@ -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;
|
||||||
@@ -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;
|
||||||
@@ -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-%';
|
||||||
@@ -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);
|
||||||
@@ -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);
|
||||||
@@ -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);
|
||||||
@@ -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;
|
||||||
@@ -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;
|
||||||
@@ -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;
|
||||||
@@ -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();
|
||||||
@@ -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
|
||||||
|
)
|
||||||
|
);
|
||||||
@@ -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;
|
||||||
@@ -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"));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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"),
|
||||||
|
|||||||
@@ -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"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -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>,
|
||||||
|
|||||||
108
services/backend-api/src/billing_rules.rs
Normal file
108
services/backend-api/src/billing_rules.rs
Normal 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
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
@@ -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())
|
||||||
|
.unwrap_or(50)
|
||||||
|
.min(500);
|
||||||
|
let project_id = params
|
||||||
|
.get("project_id")
|
||||||
|
.and_then(|s| Uuid::parse_str(s).ok());
|
||||||
|
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 = match project_id {
|
let alarms = match project_id {
|
||||||
|
Some(pid) if platform_global => {
|
||||||
|
sqlx::query_scalar::<_, serde_json::Value>(alarm_select_sql(true, platform_global))
|
||||||
|
.bind(pid)
|
||||||
|
.bind(limit)
|
||||||
|
.fetch_all(&pool)
|
||||||
|
.await?
|
||||||
|
}
|
||||||
Some(pid) => {
|
Some(pid) => {
|
||||||
sqlx::query_scalar::<_, serde_json::Value>(
|
sqlx::query_scalar::<_, serde_json::Value>(alarm_select_sql(true, platform_global))
|
||||||
r#"SELECT row_to_json(t) FROM (
|
.bind(pid)
|
||||||
SELECT id, project_id, asset_id, variable_alias, current_value,
|
.bind(user_id)
|
||||||
alarm_min, alarm_max, alarm_type, message, timestamp
|
.bind(limit)
|
||||||
FROM telemetry_alarms
|
.fetch_all(&pool)
|
||||||
WHERE project_id = $1
|
.await?
|
||||||
ORDER BY timestamp DESC LIMIT $2
|
}
|
||||||
) t"#
|
None if platform_global => {
|
||||||
).bind(pid).bind(limit).fetch_all(&pool).await?
|
sqlx::query_scalar::<_, serde_json::Value>(alarm_select_sql(false, platform_global))
|
||||||
},
|
.bind(limit)
|
||||||
None if claims.role == "admin" || claims.role == "superadmin" => {
|
.fetch_all(&pool)
|
||||||
sqlx::query_scalar::<_, serde_json::Value>(
|
.await?
|
||||||
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 => {
|
None => {
|
||||||
let user_id = Uuid::parse_str(&claims.sub)
|
sqlx::query_scalar::<_, serde_json::Value>(alarm_select_sql(false, platform_global))
|
||||||
.map_err(|_| AppError::BadRequest("Invalid user ID in token".to_string()))?;
|
.bind(user_id)
|
||||||
sqlx::query_scalar::<_, serde_json::Value>(
|
.bind(limit)
|
||||||
r#"SELECT row_to_json(t) FROM (
|
.fetch_all(&pool)
|
||||||
SELECT ta.id, ta.project_id, ta.asset_id, ta.variable_alias, ta.current_value,
|
.await?
|
||||||
ta.alarm_min, ta.alarm_max, ta.alarm_type, ta.message, ta.timestamp
|
|
||||||
FROM telemetry_alarms ta
|
|
||||||
JOIN user_project_access upa ON ta.project_id = upa.project_id
|
|
||||||
WHERE upa.user_id = $1 AND upa.is_active = true
|
|
||||||
ORDER BY ta.timestamp DESC LIMIT $2
|
|
||||||
) t"#
|
|
||||||
).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
@@ -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,
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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,12 +643,11 @@ 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;
|
|
||||||
|
|
||||||
let project = match project {
|
let project = match project {
|
||||||
Ok(Some(p)) => p,
|
Ok(Some(p)) => p,
|
||||||
@@ -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.name,
|
||||||
|
&sub,
|
||||||
|
counts,
|
||||||
|
billing,
|
||||||
|
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_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()
|
.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> =
|
||||||
.fetch_all(pool)
|
sqlx::query_scalar("SELECT id FROM edge_projects WHERE is_active = true")
|
||||||
.await?;
|
.fetch_all(pool)
|
||||||
|
.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")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
112
services/backend-api/src/handlers/billing_webhook.rs
Normal file
112
services/backend-api/src/handlers/billing_webhook.rs
Normal 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()
|
||||||
|
}
|
||||||
@@ -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
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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,27 +34,15 @@ 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(user_id)
|
|
||||||
.bind(project_id)
|
.bind(project_id)
|
||||||
.fetch_optional(&pool)
|
.bind(user_id)
|
||||||
|
.fetch_all(&pool)
|
||||||
.await?;
|
.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)
|
|
||||||
.await?;
|
|
||||||
|
|
||||||
Ok(Json(assets))
|
Ok(Json(assets))
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -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?;
|
||||||
|
|
||||||
@@ -116,7 +117,7 @@ pub async fn get_asset(
|
|||||||
.await?;
|
.await?;
|
||||||
|
|
||||||
if access.is_none() {
|
if access.is_none() {
|
||||||
return Err(AppError::Forbidden("Sin acceso a este activo".to_string()));
|
return Err(AppError::Forbidden("Sin acceso a este activo".to_string()));
|
||||||
}
|
}
|
||||||
|
|
||||||
Ok(Json(asset))
|
Ok(Json(asset))
|
||||||
@@ -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")
|
||||||
|
|||||||
@@ -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
|
|
||||||
.publish(
|
tracing::info!(
|
||||||
"omnioil/events/project_created",
|
"📡 Enviando señal de construcción para despliegue {}: {}",
|
||||||
rumqttc::QoS::AtLeastOnce,
|
deployment.id,
|
||||||
false,
|
payload
|
||||||
payload,
|
);
|
||||||
)
|
if let Err(e) = mqtt
|
||||||
.await;
|
.publish(
|
||||||
});
|
"omnioil/events/project_created",
|
||||||
|
rumqttc::QoS::AtLeastOnce,
|
||||||
|
false,
|
||||||
|
payload,
|
||||||
|
)
|
||||||
|
.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)))
|
||||||
|
|||||||
@@ -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!({
|
||||||
|
|||||||
@@ -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(
|
||||||
.with_user(user_id, &claims.email)
|
&pool,
|
||||||
.with_resource(format!("project:{}", project.id))
|
crate::audit::AuditEntry::new("project.create")
|
||||||
).await;
|
.with_user(user_id, &claims.email)
|
||||||
|
.with_resource(format!("project:{}", project.id)),
|
||||||
|
)
|
||||||
|
.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",
|
||||||
|
|||||||
@@ -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)
|
||||||
@@ -115,12 +119,12 @@ pub async fn update_variable(
|
|||||||
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(
|
let access = sqlx::query(
|
||||||
r#"SELECT 1 FROM edge_variables ev
|
r#"SELECT 1 FROM edge_variables ev
|
||||||
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)
|
||||||
@@ -178,12 +184,12 @@ pub async fn delete_variable(
|
|||||||
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(
|
let access = sqlx::query(
|
||||||
r#"SELECT 1 FROM edge_variables ev
|
r#"SELECT 1 FROM edge_variables ev
|
||||||
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(),
|
||||||
|
));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
844
services/backend-api/src/handlers/import.rs
Normal file
844
services/backend-api/src/handlers/import.rs
Normal 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,
|
||||||
|
})))
|
||||||
|
}
|
||||||
@@ -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>>,
|
||||||
|
|||||||
@@ -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)))
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>(
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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,41 +175,57 @@ 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",
|
||||||
)
|
)
|
||||||
.bind(req.asset_id)
|
|
||||||
.bind(user_id)
|
|
||||||
.fetch_optional(&pool)
|
|
||||||
.await?;
|
.await?;
|
||||||
|
|
||||||
let project_id: Uuid = match project_row {
|
let client_id = normalize_client_id(req.client_id)?;
|
||||||
Some(row) => row.get("project_id"),
|
|
||||||
None => return Err(AppError::Forbidden("Sin permiso para registrar movimientos en este activo".to_string())),
|
|
||||||
};
|
|
||||||
|
|
||||||
let details = serde_json::json!({
|
if let Some(client_id) = client_id.as_deref() {
|
||||||
"observaciones": req.observaciones,
|
let existing = sqlx::query_as::<Postgres, OperationMovement>(
|
||||||
"created_by": claims.sub
|
find_existing_movement_by_client_id_sql(),
|
||||||
});
|
|
||||||
|
|
||||||
let movement: OperationMovement = sqlx::query_as::<Postgres, OperationMovement>(
|
|
||||||
r#"INSERT INTO operation_movements (
|
|
||||||
asset_id, movement_type, start_time, end_time, volumen_neto, details
|
|
||||||
)
|
)
|
||||||
VALUES ($1, $2, $3, $4, $5, $6)
|
.bind(client_id)
|
||||||
RETURNING *"#,
|
.bind(req.asset_id)
|
||||||
)
|
.fetch_optional(&pool)
|
||||||
.bind(req.asset_id)
|
.await?;
|
||||||
.bind(req.movement_type)
|
|
||||||
.bind(req.start_time)
|
if let Some(movement) = existing {
|
||||||
.bind(req.end_time)
|
return Ok((StatusCode::OK, Json(movement)));
|
||||||
.bind(req.volumen_neto)
|
}
|
||||||
.bind(details)
|
}
|
||||||
.fetch_one(&pool)
|
|
||||||
.await?;
|
let details = movement_details(req.observaciones, client_id.clone(), &claims.sub);
|
||||||
|
|
||||||
|
let inserted = sqlx::query_as::<Postgres, OperationMovement>(insert_movement_sql())
|
||||||
|
.bind(req.asset_id)
|
||||||
|
.bind(req.movement_type)
|
||||||
|
.bind(req.start_time)
|
||||||
|
.bind(req.end_time)
|
||||||
|
.bind(req.volumen_neto)
|
||||||
|
.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)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
return Ok((StatusCode::OK, Json(movement)));
|
||||||
|
};
|
||||||
|
|
||||||
// ─── Auditoría ────────────────────────────────────────────────────────────
|
// ─── Auditoría ────────────────────────────────────────────────────────────
|
||||||
audit_helper::log_audit(
|
audit_helper::log_audit(
|
||||||
@@ -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)))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|
||||||
|
|||||||
@@ -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,13 +91,12 @@ 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?
|
.flatten();
|
||||||
.flatten();
|
|
||||||
|
|
||||||
let config_toml = config_toml.ok_or_else(|| {
|
let config_toml = config_toml.ok_or_else(|| {
|
||||||
anyhow::anyhow!(
|
anyhow::anyhow!(
|
||||||
@@ -118,7 +122,7 @@ pub async fn handle_mqtt_provisioning(
|
|||||||
.publish(
|
.publish(
|
||||||
&response_topic,
|
&response_topic,
|
||||||
rumqttc::QoS::AtLeastOnce,
|
rumqttc::QoS::AtLeastOnce,
|
||||||
true, // retain=true para que la entrega no dependa del timing
|
true, // retain=true para que la entrega no dependa del timing
|
||||||
response_payload,
|
response_payload,
|
||||||
)
|
)
|
||||||
.await
|
.await
|
||||||
@@ -167,13 +171,12 @@ 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
|
.map_err(db_err)?;
|
||||||
.map_err(db_err)?;
|
|
||||||
|
|
||||||
if !exists.unwrap_or(false) {
|
if !exists.unwrap_or(false) {
|
||||||
return Err((StatusCode::NOT_FOUND, "Proyecto no encontrado".to_string()));
|
return Err((StatusCode::NOT_FOUND, "Proyecto no encontrado".to_string()));
|
||||||
|
|||||||
@@ -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)?;
|
||||||
|
let user_id = Uuid::parse_str(&claims.sub).map_err(|_| {
|
||||||
|
(
|
||||||
|
StatusCode::UNAUTHORIZED,
|
||||||
|
"User ID inválido en token".to_string(),
|
||||||
|
)
|
||||||
|
})?;
|
||||||
|
|
||||||
sqlx::query(
|
well_access::ensure_asset_access(
|
||||||
r#"INSERT INTO telemetry_raw (time, asset_id, data)
|
&pool,
|
||||||
VALUES ($1, $2, $3)
|
user_id,
|
||||||
ON CONFLICT (time, asset_id) DO NOTHING"#,
|
req.asset_id,
|
||||||
|
"Access denied to this asset's telemetry",
|
||||||
)
|
)
|
||||||
.bind(time)
|
|
||||||
.bind(req.asset_id)
|
|
||||||
.bind(&req.data)
|
|
||||||
.execute(&pool)
|
|
||||||
.await
|
.await
|
||||||
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
.map_err(app_error_to_tuple)?;
|
||||||
|
|
||||||
|
let result = sqlx::query(post_telemetry_insert_sql())
|
||||||
|
.bind(time)
|
||||||
|
.bind(req.asset_id)
|
||||||
|
.bind(&req.data)
|
||||||
|
.bind(source)
|
||||||
|
.bind(&req.client_id)
|
||||||
|
.bind(user_id)
|
||||||
|
.execute(&pool)
|
||||||
|
.await
|
||||||
|
.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()
|
||||||
})))
|
})))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -272,11 +272,12 @@ 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 =
|
||||||
.bind(user_id)
|
sqlx::query_scalar("SELECT two_factor_enabled FROM users WHERE id = $1")
|
||||||
.fetch_one(&pool)
|
.bind(user_id)
|
||||||
.await
|
.fetch_one(&pool)
|
||||||
.map_err(|_| AuthError::InvalidCredentials)?;
|
.await
|
||||||
|
.map_err(|_| AuthError::InvalidCredentials)?;
|
||||||
|
|
||||||
Ok(Json(TotpStatusResponse { two_factor_enabled }))
|
Ok(Json(TotpStatusResponse { two_factor_enabled }))
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)) = (¶ms.ts, ¶ms.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(¶ms.version) {
|
let current = match Version::parse(¶ms.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),
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -110,7 +217,7 @@ pub async fn get_update_info(
|
|||||||
let available = if params.target.is_some() {
|
let available = if params.target.is_some() {
|
||||||
latest != current // rollback/pin: cualquier versión distinta
|
latest != current // rollback/pin: cualquier versión distinta
|
||||||
} else {
|
} else {
|
||||||
latest > current // ciclo periódico: solo avanzar
|
latest > current // ciclo periódico: solo avanzar
|
||||||
};
|
};
|
||||||
|
|
||||||
if !available {
|
if !available {
|
||||||
@@ -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,24 +260,46 @@ 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)) = (¶ms.ts, ¶ms.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
|
||||||
let filename = match asset_type {
|
let filename = match asset_type {
|
||||||
"exe" => format!("omnioil-edge-agent-windows-amd64.exe"),
|
"exe" => format!("omnioil-edge-agent-windows-amd64.exe"),
|
||||||
_ => 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")
|
||||||
@@ -227,7 +355,7 @@ pub async fn download_agent(
|
|||||||
// Stream de respuesta al agente
|
// Stream de respuesta al agente
|
||||||
let content_type = match asset_type {
|
let content_type = match asset_type {
|
||||||
"exe" => "application/vnd.microsoft.portable-executable",
|
"exe" => "application/vnd.microsoft.portable-executable",
|
||||||
_ => "application/x-msi",
|
_ => "application/x-msi",
|
||||||
};
|
};
|
||||||
|
|
||||||
let mut headers = HeaderMap::new();
|
let mut headers = HeaderMap::new();
|
||||||
@@ -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
337
services/backend-api/src/handlers/well_access.rs
Normal file
337
services/backend-api/src/handlers/well_access.rs
Normal 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"));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)))
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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();
|
.await;
|
||||||
|
|
||||||
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;
|
|
||||||
}
|
|
||||||
// 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
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -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>("SELECT COUNT(*) FROM edge_variables WHERE is_enabled = true")
|
||||||
|
.fetch_one(pool)
|
||||||
|
.await
|
||||||
|
{
|
||||||
|
gauge!("omnioil_active_tags").set(count as f64);
|
||||||
|
}
|
||||||
if let Ok(count) = sqlx::query_scalar::<_, i64>(
|
if let Ok(count) = sqlx::query_scalar::<_, i64>(
|
||||||
"SELECT COUNT(*) FROM edge_variables WHERE is_enabled = true",
|
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)
|
.fetch_one(pool)
|
||||||
.await
|
.await
|
||||||
{
|
{
|
||||||
gauge!("omnioil_active_tags").set(count as f64);
|
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);
|
||||||
}
|
}
|
||||||
|
|||||||
215
services/backend-api/src/mqtt_loop.rs
Normal file
215
services/backend-api/src/mqtt_loop.rs
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2,19 +2,21 @@
|
|||||||
//! 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;
|
||||||
|
|
||||||
#[derive(Debug, Clone, serde::Serialize)]
|
#[derive(Debug, Clone, serde::Serialize)]
|
||||||
pub struct AlarmNotification {
|
pub struct AlarmNotification {
|
||||||
pub project_id: Uuid,
|
pub project_id: Uuid,
|
||||||
pub asset_id: Option<Uuid>,
|
pub asset_id: Option<Uuid>,
|
||||||
pub variable: String,
|
pub variable: String,
|
||||||
pub value: f64,
|
pub value: f64,
|
||||||
pub alarm_type: String,
|
pub alarm_type: String,
|
||||||
pub message: String,
|
pub message: String,
|
||||||
pub timestamp: chrono::DateTime<chrono::Utc>,
|
pub timestamp: chrono::DateTime<chrono::Utc>,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Despacha notificaciones para una alarma disparada.
|
/// Despacha notificaciones para una alarma disparada.
|
||||||
@@ -59,16 +61,17 @@ pub async fn dispatch(
|
|||||||
|
|
||||||
for cfg in &configs {
|
for cfg in &configs {
|
||||||
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,
|
||||||
other => {
|
"telegram" => send_telegram_alarm(&cfg.destination, alarm).await,
|
||||||
|
other => {
|
||||||
tracing::warn!("Canal de notificación no soportado: {}", other);
|
tracing::warn!("Canal de notificación no soportado: {}", other);
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
let (success, error_message) = match &result {
|
let (success, error_message) = match &result {
|
||||||
Ok(_) => (true, None),
|
Ok(_) => (true, None),
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
tracing::error!("Error enviando notificación {}: {}", cfg.channel_type, e);
|
tracing::error!("Error enviando notificación {}: {}", cfg.channel_type, e);
|
||||||
(false, Some(e.to_string()))
|
(false, Some(e.to_string()))
|
||||||
@@ -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"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -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"));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -19,19 +19,14 @@ 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> {
|
StatusCode::BAD_REQUEST,
|
||||||
let Json(value) = Json::<T>::from_request(req, state)
|
Json(serde_json::json!({ "error": e.to_string() })),
|
||||||
.await
|
)
|
||||||
.map_err(|e| {
|
.into_response()
|
||||||
(
|
})?;
|
||||||
StatusCode::BAD_REQUEST,
|
|
||||||
Json(serde_json::json!({ "error": e.to_string() })),
|
|
||||||
)
|
|
||||||
.into_response()
|
|
||||||
})?;
|
|
||||||
|
|
||||||
value.validate().map_err(|e| {
|
value.validate().map_err(|e| {
|
||||||
(
|
(
|
||||||
|
|||||||
@@ -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,114 +29,191 @@ 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
|
.fetch_all(pool)
|
||||||
AND t.time > NOW() - INTERVAL '10 minutes'
|
|
||||||
)
|
|
||||||
AND EXISTS (
|
|
||||||
SELECT 1 FROM edge_assets a WHERE a.project_id = p.id
|
|
||||||
)
|
|
||||||
"#
|
|
||||||
)
|
|
||||||
.fetch_all(pool)
|
|
||||||
.await?;
|
|
||||||
|
|
||||||
for row in rows {
|
|
||||||
let proj_id: Uuid = row.get("id");
|
|
||||||
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 already_alerted = sqlx::query(
|
|
||||||
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)
|
|
||||||
.fetch_optional(pool)
|
|
||||||
.await?;
|
|
||||||
|
|
||||||
if already_alerted.is_none() {
|
|
||||||
tracing::warn!("⚠️ Detectado silencio prolongado en project: {}", proj_name);
|
|
||||||
|
|
||||||
sqlx::query(
|
|
||||||
"INSERT INTO telemetry_alarms (project_id, device_name, alarm_type, message, timestamp)
|
|
||||||
VALUES ($1, 'SYSTEM_WATCHDOG', 'TELEMETRY_SILENCE', $2, $3)"
|
|
||||||
)
|
|
||||||
.bind(proj_id)
|
|
||||||
.bind(&msg)
|
|
||||||
.bind(Utc::now())
|
|
||||||
.execute(pool)
|
|
||||||
.await?;
|
.await?;
|
||||||
|
|
||||||
let alarm = AlarmNotification {
|
for row in rows {
|
||||||
project_id: proj_id,
|
let proj_id: Uuid = row.get("id");
|
||||||
asset_id: None,
|
let proj_name: String = row.get("name");
|
||||||
variable: "TELEMETRY_SILENCE".to_string(),
|
let msg = format!(
|
||||||
value: 0.0,
|
"ALERTA ANH: No se recibe telemetría {frequency_class} del proyecto {proj_name} en la ventana configurada ({window}). source={source} frequency_class={frequency_class}",
|
||||||
alarm_type: "TELEMETRY_SILENCE".to_string(),
|
source = spec.source,
|
||||||
message: msg,
|
frequency_class = spec.frequency_class,
|
||||||
timestamp: Utc::now(),
|
);
|
||||||
};
|
|
||||||
notifier::dispatch(pool, &alarm, tx).await;
|
let already_alerted = sqlx::query(telemetry_silence_recent_alarm_sql())
|
||||||
|
.bind(proj_id)
|
||||||
|
.bind(spec.alarm_type)
|
||||||
|
.fetch_optional(pool)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
if already_alerted.is_none() {
|
||||||
|
tracing::warn!(
|
||||||
|
source = spec.source,
|
||||||
|
frequency_class = spec.frequency_class,
|
||||||
|
"⚠️ Detectado silencio prolongado en project: {}",
|
||||||
|
proj_name
|
||||||
|
);
|
||||||
|
|
||||||
|
sqlx::query(
|
||||||
|
"INSERT INTO telemetry_alarms (project_id, device_name, alarm_type, message, timestamp)
|
||||||
|
VALUES ($1, 'SYSTEM_WATCHDOG', $2, $3, $4)"
|
||||||
|
)
|
||||||
|
.bind(proj_id)
|
||||||
|
.bind(spec.alarm_type)
|
||||||
|
.bind(&msg)
|
||||||
|
.bind(Utc::now())
|
||||||
|
.execute(pool)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
let alarm = AlarmNotification {
|
||||||
|
project_id: proj_id,
|
||||||
|
asset_id: None,
|
||||||
|
variable: spec.alarm_type.to_string(),
|
||||||
|
value: 0.0,
|
||||||
|
alarm_type: spec.alarm_type.to_string(),
|
||||||
|
message: msg,
|
||||||
|
timestamp: Utc::now(),
|
||||||
|
};
|
||||||
|
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()
|
||||||
r#"
|
.and_then(|value| value.parse::<i64>().ok())
|
||||||
SELECT id, name
|
.filter(|value| *value > 0);
|
||||||
FROM edge_projects
|
let lf_hours = std::env::var("OMNIOIL_TELEMETRY_SILENCE_LF_HOURS")
|
||||||
WHERE is_active = true
|
.ok()
|
||||||
AND (last_health_report->>'timestamp')::TIMESTAMPTZ < NOW() - INTERVAL '5 minutes'
|
.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#"
|
||||||
|
SELECT p.id, p.name
|
||||||
|
FROM edge_projects p
|
||||||
|
WHERE p.is_active = true
|
||||||
|
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
|
||||||
|
)
|
||||||
"#
|
"#
|
||||||
)
|
}
|
||||||
.fetch_all(pool)
|
|
||||||
.await?;
|
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)
|
||||||
|
.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,13 +236,39 @@ 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?;
|
|
||||||
|
|
||||||
if result.rows_affected() > 0 {
|
if result.rows_affected() > 0 {
|
||||||
tracing::info!(
|
tracing::info!(
|
||||||
@@ -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(())
|
||||||
|
}
|
||||||
|
|||||||
303
services/backend-api/src/ws.rs
Normal file
303
services/backend-api/src/ws.rs
Normal 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"));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
|||||||
99
services/backend-api/tests/billing_rules.rs
Normal file
99
services/backend-api/tests/billing_rules.rs
Normal 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
Reference in New Issue
Block a user