Compare commits

..
Author SHA1 Message Date
admin be15f31073 feat(auth): expose audited mobile password change 2026-09-08 17:18:56 -03:00
admin 33584d545f refactor(android): share auth and clean uploaded signatures 2026-09-08 17:16:55 -03:00
admin 516445fea6 refactor(android): share auth and clean uploaded finding photos 2026-09-08 17:16:03 -03:00
admin 480cb6c691 fix(android): remove duplicate checklist model 2026-09-08 17:15:25 -03:00
admin 373cf8cccb refactor(android): share encrypted mobile session coordinator 2026-09-08 17:14:32 -03:00
admin 18238d0c3d test(android): prove rotating refresh is serialized 2026-09-08 17:13:07 -03:00
admin ad72f3505c feat(android): centralize mobile session refresh and password change 2026-09-08 17:11:54 -03:00
admin a976d40d45 feat(android): serialize rotating mobile refresh tokens 2026-09-08 17:11:30 -03:00
admin b387929023 fix(android): make signature GPS permission guard lint-visible 2026-09-08 17:06:10 -03:00
admin abffe12cc9 fix(android): make finding GPS permission guard lint-visible 2026-09-08 17:05:14 -03:00
admin 8a1940f002 fix(android): make field GPS permission guard lint-visible 2026-09-08 17:01:56 -03:00
admin c3fecc3e7a fix(android): make legacy GPS permission guard lint-visible 2026-09-08 17:00:43 -03:00
admin b5fe7835af fix(android): declare camera hardware optional 2026-09-08 16:59:21 -03:00
admin 6e063e701f test(android): accept documented absence as terminal outcome 2026-09-08 16:55:35 -03:00
admin 0f5d425fd1 fix(android): mirror documented absent company outcome 2026-09-08 16:55:12 -03:00
admin 8a6c182452 ci(android): print complete lint failures 2026-09-08 16:52:45 -03:00
admin 7b2a510018 ci(android): guard mobile security and app identity 2026-09-08 16:44:54 -03:00
admin 1d713fd771 test(android): pin RC identity and production API target 2026-09-08 16:44:30 -03:00
admin d3a404ec82 chore(android): ignore local builds and signing material 2026-09-08 16:43:47 -03:00
admin 6a34c86cfb docs(android): define F5 RC release contract 2026-09-08 16:39:59 -03:00
admin d406e40315 ci(android): promote F5 Android to release barrier 2026-09-08 16:39:44 -03:00
admin 5f5f376acb build(android): cut 0.13.0-rc1 and enforce lint 2026-09-08 16:39:08 -03:00
admin 26a48d3b4f test(android): cover F5 field workflow guards 2026-09-08 16:38:38 -03:00
admin 1d25207c76 feat(android): add testable F5 workflow rules 2026-09-08 16:38:19 -03:00
admin 1dc3282055 F4 hotfix · metadata de health
Corrige la fase publicada por el health de la API para reflejar F4 y agrega una prueba de regresión sin cambiar la versión funcional.
2026-09-08 16:18:58 -03:00
admin 7875fea3ca test(health): pin F4 phase metadata 2026-09-08 16:14:10 -03:00
admin 0d5bdd25ce fix(health): report F4 phase 2026-09-08 16:14:04 -03:00
admin a75e9bdcb0 F4 hotfix · paridad preflight deploy/CI
Alinea el preflight real del auto-deploy VPS con la barrera Docker de GitHub CI, montando contratos cross-tree en read-only y agregando una guardia de paridad.
2026-09-08 16:04:44 -03:00
admin 3027e9a8c8 ci: guard deploy preflight contract mounts 2026-09-08 15:59:00 -03:00
admin 09a190532c fix(deploy): mirror F4 contract mounts in VPS preflight 2026-09-08 15:58:46 -03:00
admin 8a8ab47453 F4 · Saneamiento backend y flujo documental
Integra el saneamiento F4 validado por CI completa: flujo Acta → INF → GEDO/IF, lifecycle documental, contratos WEB/Android, migraciones reversibles, seguimiento append-only y cierre de observaciones de revisión.
2026-09-08 15:53:41 -03:00
admin 9b86f49c9e ci: mount compose contract read-only in preflight 2026-09-08 15:27:33 -03:00
admin 8584249eb2 fix: sanitize GEDO download filename 2026-09-08 15:23:03 -03:00
github-actions[bot] 5dc7ea037d fix: close final F4 review gaps 2026-09-08 18:22:02 +00:00
admin 969ffddc76 chore: stage final F4 review fixes 2026-09-08 15:21:54 -03:00
admin 4c47916bd1 test: update multi-act company outcome semantics 2026-09-08 15:10:18 -03:00
admin 2121df0b55 test: align F4 sealing contract with documented absence 2026-09-08 15:10:01 -03:00
admin fcd64d5f43 chore: remove one-shot F4 patch workflow 2026-09-08 15:07:03 -03:00
github-actions[bot] 844c1a950f fix: close F4 pre-merge review gaps 2026-09-08 18:06:16 +00:00
admin e1e44e6352 chore: stage one-shot F4 pre-merge corrections 2026-09-08 15:06:07 -03:00
admin 94a8d5c92f ci: mount web and android contracts read-only in preflight 2026-09-08 14:51:19 -03:00
admin 3bfa3bce1b fix(f4): require current checklist before inspection execution 2026-09-08 14:43:02 -03:00
admin 4df8f40dd4 test(f4): align legacy contracts with lifecycle model 2026-09-08 14:39:55 -03:00
admin 7b6460dff2 fix(f4): name recurrence response contracts for api declarations 2026-09-08 13:55:11 -03:00
admin 30353c338b fix(f4): expose recurrence state and close ci type gaps 2026-09-08 13:52:24 -03:00
admin 95a41f39d7 fix(f4): align verification and web contracts with legacy cleanup 2026-09-08 13:44:51 -03:00
admin 100d457484 test(f4): guard Android inspection contract from legacy fields 2026-09-08 11:42:48 -03:00
admin 4d0ed8b046 test(f4): guard all active inspection consumers from legacy fields 2026-09-08 11:41:42 -03:00
admin a510d309bc test(f4): prove legacy field rollback preserves data 2026-09-08 10:30:53 -03:00
admin 9606168ac6 fix(f4): make inspection legacy field removal data reversible 2026-09-08 10:30:27 -03:00
admin 0442511065 test(f4): guard android lock seal lifecycle contract 2026-09-08 10:29:42 -03:00
admin 27d14d2d4e refactor(f4): remove legacy mobile act lifecycle aliases 2026-09-08 10:29:24 -03:00
admin fa64ba7727 fix(f4): align embedded INF status in act contract 2026-09-08 10:27:32 -03:00
admin d0b805b281 test(f4): guard locked sealed web act lifecycle 2026-09-08 10:26:51 -03:00
admin b3125b51ad fix(f4): use locked sealed lifecycle in act detail 2026-09-08 10:26:13 -03:00
admin b67db745e4 fix(f4): present locked and sealed act lifecycle 2026-09-08 10:25:46 -03:00
admin f0ffde975b refactor(f4): decouple findings panel from legacy act type 2026-09-08 10:25:10 -03:00
admin d8b02bfaf1 fix(f4): use act lifecycle contract inside inspections 2026-09-08 10:24:39 -03:00
admin 7a406df1ad fix(f4): show locked and sealed acts in document center 2026-09-08 10:24:19 -03:00
admin 8c89cd05c9 feat(f4): add explicit act lifecycle web contract 2026-09-08 10:23:49 -03:00
admin f7a4e7f228 fix(f4): align web inspection legacy contract 2026-09-08 10:18:42 -03:00
admin 7b9d2b7903 test(f4): guard inspection legacy fields across backend and web 2026-09-08 10:11:33 -03:00
admin 53db8b93bb fix(f4): preserve finding verification date contract 2026-09-08 10:10:45 -03:00
admin 61f77505df fix(f4): remove legacy inspection titles from findings 2026-09-08 10:08:39 -03:00
admin c28e5b060d test(f4): lock field finding and sequential multi-act contracts 2026-09-08 10:01:38 -03:00
admin 2c1fdf7fb2 test(f4): make lifecycle source boundaries deterministic 2026-09-08 10:00:24 -03:00
admin afb6feb4c1 test(f4): harden multi-act immutability gates 2026-09-08 09:58:12 -03:00
admin 7174d379e5 test: cover F4 inspector delivery database contract 2026-09-08 09:38:52 -03:00
admin ff7a99725e fix: align F4 document delivery recipient constraint 2026-09-08 09:38:18 -03:00
admin 6f100bf9ba test: align document center baseline with F4 report lifecycle 2026-09-08 09:35:57 -03:00
admin 2968f83b36 test: guard active Act queries from removed Inspection fields 2026-09-08 09:32:56 -03:00
admin cac3161cbb fix: restore area filter after Inspection title cleanup 2026-09-08 09:31:42 -03:00
admin 97296ba37b fix: remove active Inspection title reads from Acts service 2026-09-08 09:29:56 -03:00
admin 80275969c9 test: cover F4 Act code database constraint 2026-09-08 09:27:58 -03:00
admin 345ef76820 fix: align F4 Act code constraint with generated codes 2026-09-08 09:27:29 -03:00
admin 40e33f8312 test: lock F4 document lifecycle database contract 2026-09-08 09:25:14 -03:00
admin 57739dcb3e fix: make F4 lifecycle constraint rollback executable 2026-09-08 09:24:50 -03:00
admin 271290abe4 fix: align F4 document lifecycle database constraints 2026-09-08 09:23:55 -03:00
admin 5393a40e8b test: tighten F4 Director retirement and retained INF history 2026-09-08 09:20:33 -03:00
admin 3a1379d49a fix: fully retire Director report permissions in F4 2026-09-08 09:20:14 -03:00
admin ad44dc7ef6 test: assert Survey planning DTOs are retired in F4 2026-09-08 09:18:19 -03:00
admin 248fb6285b test: assert Survey planning authorization is retired in F4 2026-09-08 09:18:04 -03:00
admin 792e21e669 test: assert Survey execution DTOs are retired in F4 2026-09-08 09:17:48 -03:00
admin 9fab773090 test: assert Survey execution authorization is retired in F4 2026-09-08 09:17:30 -03:00
admin 38742bf140 test: align mobile multi-act lifecycle with F4 sealing 2026-09-08 09:16:23 -03:00
admin 2018e1aeba test: align document delivery contract with F4 recipients and SMTP 2026-09-08 09:15:44 -03:00
admin c11808f560 test: align automatic report contract with F4 sealing 2026-09-08 09:15:26 -03:00
admin 28ec002710 test: retire Director review contract under F4 2026-09-08 09:15:06 -03:00
admin 90851e6e7d test: preserve D5 guarantees under F4 document workflow 2026-09-08 09:11:40 -03:00
admin 0e2b524a26 test: lock F4 multi-act lifecycle invariants 2026-09-08 09:08:58 -03:00
admin 5bd392dcb4 test: move verification completion gate to inspection close 2026-09-08 09:08:42 -03:00
admin 232614ccf0 test: align multi-act contract with F4 lifecycle 2026-09-08 09:08:23 -03:00
admin b24c3787c9 fix: require verification results when closing inspection 2026-09-08 09:08:00 -03:00
admin 71d0884557 fix: decouple act locking from visit verification completion 2026-09-08 09:07:05 -03:00
admin 2506bece76 test(f4): guard retired inspection fields 2026-09-08 08:52:09 -03:00
admin a9fc9f3e8e refactor(f4): remove inspection legacy type shims 2026-09-08 08:50:42 -03:00
admin f70962a819 refactor(f4): remove inspection legacy persistence shim 2026-09-08 08:50:27 -03:00
admin e6ae2807bc refactor(f4): align inspection entity with retired columns 2026-09-08 08:46:15 -03:00
admin 5404409042 refactor(f4): retire legacy inspection fields 2026-09-08 08:45:54 -03:00
admin 49b321ca87 fix(f4): remove inspection title from asset dossier 2026-09-08 08:26:31 -03:00
admin 048b7b90af fix(f4): remove inspection title from Android contract 2026-09-08 08:16:29 -03:00
admin c53997a41c fix(f4): align finding worklist web contract 2026-09-08 08:11:46 -03:00
admin 459ad03326 fix(f4): decouple finding worklist from inspection title 2026-09-08 08:11:33 -03:00
admin a816f4cfdf fix(f4): keep legacy deadline storage compatible pending notification definition 2026-09-08 07:51:33 -03:00
admin 98545820ac refactor(f4): model nonurgent deadline on notification date 2026-09-08 07:50:39 -03:00
admin a9b2ed4b4b fix(f4): decouple GEDO officialization from notification deadline 2026-09-08 07:49:29 -03:00
admin 5386ccf9b5 refactor(f4): remove inspection title from verification contracts 2026-09-08 07:46:34 -03:00
admin 0df7935b54 refactor(f4): remove inspection title from reports UI 2026-09-08 07:43:52 -03:00
admin 2d2a268220 refactor(f4): remove inspection title from report web contract 2026-09-08 07:43:09 -03:00
admin b021189a94 refactor(f4): remove inspection title from report contracts 2026-09-08 07:42:32 -03:00
admin ce0bbf1d94 refactor(f4): align inspection wrapper with canonical contract 2026-09-08 07:41:24 -03:00
admin 3f0a1e5441 refactor(f4): remove legacy inspection lifecycle and public title/end date 2026-09-08 07:39:38 -03:00
admin bd767b7e32 fix(f4): hide SMTP controls outside system admin 2026-09-08 07:35:08 -03:00
admin d08162fca8 fix(f4): restrict SMTP administration to system admin 2026-09-08 07:32:39 -03:00
admin 2826960d71 db(f4): drop legacy director report workflow 2026-09-08 07:11:12 -03:00
admin 0718414fdb refactor(api): remove legacy director report fields 2026-09-08 07:10:53 -03:00
admin a4a57d3109 chore(web): remove legacy survey redirects 2026-09-08 07:05:23 -03:00
admin e3b24cba3d F4: remove obsolete Survey subsystem schema and permissions 2026-09-08 06:44:06 -03:00
admin cc8c5d9392 F4: remove Survey report media entity 2026-09-08 06:43:31 -03:00
admin 27f52a05c8 F4: remove Survey report version entity 2026-09-08 06:43:21 -03:00
admin b5b6942e39 F4: remove Survey target report entity 2026-09-08 06:43:07 -03:00
admin 0609b7faab F4: remove Survey campaign target entity 2026-09-08 06:42:54 -03:00
admin 0aa4e06d17 F4: remove Survey campaign entity 2026-09-08 06:42:44 -03:00
admin d2ae9f9bd4 F4: remove Survey entities from ORM registry 2026-09-08 06:42:35 -03:00
admin e3c347dbb8 F4: restore web API client exactly after accidental overwrite 2026-09-08 06:32:48 -03:00
admin 116dd24b7b F4: remove accidental api placeholder before exact restore 2026-09-08 06:28:19 -03:00
admin c2c935fd85 noop 2026-09-08 06:28:08 -03:00
admin 068cf8ae3a F4: remove legacy survey report DTO 2026-09-08 06:27:35 -03:00
admin fbf426ed90 F4: remove legacy survey review DTO 2026-09-08 06:27:24 -03:00
admin 46c2abb4d1 F4: remove legacy survey execution service 2026-09-08 06:27:10 -03:00
admin 50728e83ba F4: remove legacy survey execution module 2026-09-08 06:27:00 -03:00
admin cbf8f0c738 F4: remove obsolete survey execution controller 2026-09-07 23:16:42 -03:00
admin e2049b084f F4: remove survey planning DTOs 2026-09-07 23:16:33 -03:00
admin 73eb6fc192 F4: remove survey planning DTOs 2026-09-07 23:16:24 -03:00
admin 89f48dd82d F4: remove survey planning DTOs 2026-09-07 23:16:14 -03:00
admin 5b22dde1c6 F4: remove survey planning DTOs 2026-09-07 23:16:07 -03:00
admin 3d3d89a76a F4: remove survey planning DTOs 2026-09-07 23:15:59 -03:00
admin 29864fb2ae F4: remove survey planning DTOs 2026-09-07 23:15:51 -03:00
admin dea007581c F4: remove survey planning DTOs 2026-09-07 23:15:41 -03:00
admin de87952743 F4: remove survey planning DTOs 2026-09-07 23:15:29 -03:00
admin 0bab3e5b70 F4: remove obsolete survey planning service 2026-09-07 23:15:19 -03:00
admin 17e2e1902b F4: remove obsolete survey planning module 2026-09-07 23:15:10 -03:00
admin 3e2b021943 F4: remove obsolete survey campaigns controller 2026-09-07 23:15:02 -03:00
admin 830addb7b5 F4: make SMTP configurable from Superadmin and remove Director 2026-09-07 23:14:15 -03:00
admin 2319d06685 F4: add document delivery client without Director 2026-09-07 23:13:41 -03:00
admin 8d962e783d F4: add Superadmin SMTP settings client 2026-09-07 23:13:24 -03:00
admin b69b773094 F4: present locked and sealed Act states 2026-09-07 23:11:24 -03:00
admin 5996c3819a F4: remove obsolete generic-status inspection editor 2026-09-07 23:10:48 -03:00
admin 7ac42fa05d F4: route inspections through clean planning editor 2026-09-07 23:10:36 -03:00
admin 3180101ef7 F4: add clean inspection planning editor 2026-09-07 23:10:01 -03:00
admin 572c8a4af1 F4: add explicit inspection lifecycle client 2026-09-07 23:09:05 -03:00
admin 91480779dd F4: expose explicit inspection unplan action 2026-09-07 23:08:55 -03:00
admin 0a6500fbff F4: add explicit inspection unplan lifecycle action 2026-09-07 23:08:34 -03:00
admin 69bc720899 F4: activate inspection visit compatibility layer 2026-09-07 23:06:21 -03:00
admin ce33b32820 F4: isolate technical inspection checklist and public visit model 2026-09-07 23:06:12 -03:00
admin 212f4dcee5 F4: remove obsolete finding company-response workspace 2026-09-07 23:01:56 -03:00
admin 1775392eb1 F4: make findings worklist technical-only 2026-09-07 23:00:01 -03:00
admin 92cf0dee97 F4: add technical findings worklist client 2026-09-07 22:59:24 -03:00
admin 2e5c4c35aa F4: register technical findings worklist 2026-09-07 22:59:03 -03:00
admin 1192ed2cdd F4: route global findings to technical worklist 2026-09-07 22:58:49 -03:00
admin 4db3797afc F4: add technical finding worklist service 2026-09-07 22:58:32 -03:00
admin 7a910d7505 F4: align dashboard with technical findings and INF workflow 2026-09-07 22:57:00 -03:00
admin c999354645 F4: add dashboard contract without finding response states 2026-09-07 22:56:33 -03:00
admin bc7cd48436 F4: align dashboard counters with technical and INF workflows 2026-09-07 22:56:20 -03:00
admin 44eccaa36c F4: align verification planning UI with technical workflow 2026-09-07 22:55:02 -03:00
admin 82d8af2728 F4: decouple verification planning from company response 2026-09-07 22:53:41 -03:00
admin 408fd5990d F4: align report list with INF GEDO workflow 2026-09-07 22:48:18 -03:00
admin c715029dbc F4: add report list contracts to web client 2026-09-07 22:47:43 -03:00
admin f83ab824ff F4: replace director report review UI with INF workflow 2026-09-07 22:46:53 -03:00
admin 513fe6121b F4: add web client for report workflow 2026-09-07 22:45:57 -03:00
admin fb12ce3cff F4: add function catalog to administration navigation 2026-09-07 22:44:00 -03:00
admin 558fdbc85d F4: route inventory function catalog admin 2026-09-07 22:43:43 -03:00
admin 4a86dd15d9 F4: expose function changes in inventory history 2026-09-07 22:43:25 -03:00
admin 86e692c537 F4: add inventory function catalog admin 2026-09-07 22:41:59 -03:00
admin f8e866a140 F4: add inventory function change panel 2026-09-07 22:41:32 -03:00
admin 30f543ad08 F4 findings: center detail on immutable description and verification 2026-09-07 22:16:05 -03:00
admin 747ad21262 F4 inventory: version function changes in dossier history 2026-09-07 22:11:10 -03:00
admin a9a6b2fa1f F4 inventory: include function in temporal snapshots 2026-09-07 22:09:50 -03:00
admin 4caf6216de F4 WEB: add Inventory function API client 2026-09-07 21:45:09 -03:00
admin f20cc28045 F4: pass secure SMTP and remote signature settings to API 2026-09-07 21:43:38 -03:00
admin 3b4310b63a F4: document remote signature and SMTP secure settings 2026-09-07 21:43:15 -03:00
admin 335d327984 F4 WEB: expose public remote Act signature route 2026-09-07 21:42:56 -03:00
admin f4e5480ac8 F4 WEB: add public secure Act signature page 2026-09-07 21:42:26 -03:00
admin bdcf193a90 F4: wire remote company signature workflow 2026-09-07 21:39:05 -03:00
admin e577e4c976 F4: export SMTP delivery for remote signature invitations 2026-09-07 21:38:56 -03:00
admin 343dbe086f F4: expose secure remote company signature endpoints 2026-09-07 21:38:40 -03:00
admin a178f64763 F4: implement secure remote company signature workflow 2026-09-07 21:38:23 -03:00
admin badb79be5b F4: add remote company signature invitation DTOs 2026-09-07 21:37:13 -03:00
admin 161f4a9252 F4: add secure remote company signature invitations 2026-09-07 21:36:15 -03:00
admin 73c69c7a62 F4 Android: redesign Act screen for DRAFT LOCKED SEALED 2026-09-07 21:34:45 -03:00
admin ef084dd899 F4 Android: align ViewModel with urgency lock and seal 2026-09-07 21:33:53 -03:00
admin eeb35da02c F4: generate Act PDF only from SEALED Acts 2026-09-07 21:32:50 -03:00
admin 0db4567289 F4: include editable INF fields in generated Word 2026-09-07 21:31:57 -03:00
admin 53702009e3 F4: rebuild editable INF Word from sealed Act snapshot 2026-09-07 21:31:35 -03:00
admin f5ab6ce885 F4: rebuild Act PDF from locked and sealed snapshot 2026-09-07 21:30:43 -03:00
admin 1bed1cae98 F4 Android: keep branch compiling while UI migrates to lock/seal 2026-09-07 21:28:53 -03:00
admin d9698f11f0 F4: remove legacy per-Finding company follow-up endpoint 2026-09-07 21:26:55 -03:00
admin d0c55b8b39 F4 Android: align Act contracts with lock and seal 2026-09-07 21:25:53 -03:00
admin 009f4d10cf F4: keep function history in its temporal ledger 2026-09-07 21:22:53 -03:00
admin c30f167731 F4: audit recurrence through finding update ledger 2026-09-07 21:21:46 -03:00
admin 8b033dc297 F4: wire finding recurrence workflow 2026-09-07 21:21:14 -03:00
admin 236605a7d7 F4: expose finding recurrence endpoints 2026-09-07 21:21:04 -03:00
admin af6256ce0f F4: implement finding recurrence workflow 2026-09-07 21:20:56 -03:00
admin 0a8cb8f8e5 F4: add finding recurrence DTO 2026-09-07 21:20:31 -03:00
admin 5e14ffda17 F4: expose explicit inspection lifecycle endpoints 2026-09-07 21:19:42 -03:00
admin fcf52c60ff F4: wire explicit inspection lifecycle 2026-09-07 21:19:22 -03:00
admin 577e267f49 F4: implement explicit inspection lifecycle 2026-09-07 21:19:12 -03:00
admin 83ab2d4060 F4: add explicit inspection cancellation DTO 2026-09-07 21:18:43 -03:00
admin 2b63638c35 F4: remove legacy Act inference from finding queries 2026-09-07 21:17:56 -03:00
admin c3c798baed F4: require explicit Act for every field finding 2026-09-07 21:17:46 -03:00
admin a836c7535a F4: include technical function in merged inventory dossier 2026-09-07 21:17:33 -03:00
admin 88179eb67c F4: audit inventory function changes 2026-09-07 21:15:15 -03:00
admin 4d5c83732e F4: version inventory function changes 2026-09-07 21:14:42 -03:00
admin 0833746e1d F4: wire inventory function workflow 2026-09-07 21:14:27 -03:00
admin e02a3d9b58 F4: expose inventory function catalog and history 2026-09-07 21:14:09 -03:00
admin cbe69a8fff F4: implement temporal inventory function changes 2026-09-07 21:13:59 -03:00
admin 8b4cc99e53 F4: add inventory function DTOs 2026-09-07 21:12:57 -03:00
admin 5c1c046be6 F4: add temporal inventory function catalog 2026-09-07 21:12:43 -03:00
admin e25f72ac0a feat(f4): register deadline administration 2026-09-07 21:03:00 -03:00
admin 5e6631554a feat(f4): expose JEFE deadline policy endpoints 2026-09-07 21:02:54 -03:00
admin 9a79b7eb86 feat(f4): implement JEFE deadline policy and calendar administration 2026-09-07 21:02:44 -03:00
admin d071e6be74 feat(f4): add JEFE deadline administration DTOs 2026-09-07 21:02:27 -03:00
admin 2d97398c01 feat(f4): add Superadmin SMTP management endpoints 2026-09-07 21:01:44 -03:00
admin 489fb309b3 feat(f4): add superadmin SMTP settings DTO 2026-09-07 21:01:14 -03:00
admin a7f53f32f0 refactor(f4): remove obsolete Director delivery setting 2026-09-07 21:01:02 -03:00
admin ce3d3cf8b7 feat(f4): load SMTP from encrypted superadmin settings 2026-09-07 20:59:33 -03:00
admin 96093d9ed4 refactor(f4): deliver editable INF Word to inspector 2026-09-07 20:58:49 -03:00
admin a1d994b6d4 feat(f4): register INF workflow service 2026-09-07 20:57:47 -03:00
admin 39846c81a4 feat(f4): expose INF editing, GEDO and follow-up endpoints 2026-09-07 20:57:37 -03:00
admin 31256ea0b5 feat(f4): implement INF editing, GEDO officialization and follow-up 2026-09-07 20:57:22 -03:00
admin ad66bb3e61 feat(f4): add append-only INF follow-up DTO 2026-09-07 20:56:39 -03:00
admin 0c35e801eb feat(f4): add GEDO officialization DTO 2026-09-07 20:56:32 -03:00
admin b8fde4c32d feat(f4): add editable INF narrative DTO 2026-09-07 20:56:27 -03:00
admin 416aac335c refactor(f4): make INF editable and one-to-one with sealed Act 2026-09-07 20:56:11 -03:00
admin 97a6fb029d refactor(f4): expose lock and seal act lifecycle 2026-09-07 20:55:14 -03:00
admin f23a28e438 refactor(f4): replace READY/CLOSED lifecycle with LOCKED/SEALED 2026-09-07 20:54:53 -03:00
admin c348f3162c feat(f4): add lifecycle and document audit actions 2026-09-07 20:52:58 -03:00
admin 56f9c4234b feat(f4): add locked and sealed act version events 2026-09-07 20:52:29 -03:00
admin e7a9ff7dd9 feat(f4): align act numbering and urgency contract 2026-09-07 20:51:11 -03:00
admin 01efa2a8cc feat(f4): allow act urgency update while draft 2026-09-07 20:49:49 -03:00
admin bda4ce224d feat(f4): require explicit act urgency 2026-09-07 20:49:41 -03:00
admin 92240b21c2 feat(f4): adopt institutional INSP numbering 2026-09-07 20:47:33 -03:00
admin 6cc687d5e1 refactor(f4): remove survey runtime modules 2026-09-07 20:46:17 -03:00
admin c2d4f82a3a refactor(f4): remove Director report review service 2026-09-07 20:46:03 -03:00
admin f184c73c9e refactor(f4): remove Director report review endpoints 2026-09-07 20:45:58 -03:00
admin d834bc7b82 refactor(f4): remove Director report review circuit 2026-09-07 20:45:49 -03:00
admin c97e5ae33c feat(f4): add document workflow foundation migration 2026-09-07 20:45:29 -03:00
admin 0f1164fae8 feat(f4): register deadline, follow-up and SMTP entities 2026-09-07 20:45:04 -03:00
admin ab665e51b5 feat(f4): add superadmin SMTP settings entity 2026-09-07 20:44:42 -03:00
admin 13f2dcfa95 feat(f4): add append-only INF follow-up timeline 2026-09-07 20:44:34 -03:00
admin 2fb48ec41e feat(f4): add business-day calendar overrides 2026-09-07 20:44:27 -03:00
admin 52a068c817 feat(f4): add JEFE-managed act deadline policy 2026-09-07 20:44:21 -03:00
admin a9efd71367 feat(f4): extend INF with GEDO officialization 2026-09-07 20:44:12 -03:00
admin 7a41ce25ad feat(f4): add finding recurrence linkage 2026-09-07 20:43:55 -03:00
admin 48bbe293d9 feat(f4): extend act lifecycle and deadline snapshot 2026-09-07 20:43:40 -03:00
admin ce18f5d1a3 F3.2 · Android multi-Acta y cierre operativo
Integra selección/creación explícita de Actas, flujo móvil de firmas/cierre, soporte de Actas READY paralelas, cierre validado por servidor y Actas de verificación sin Hallazgos nuevos.
2026-09-07 17:30:53 -03:00
admin 03a3a66c51 F3.2 Android: permitir preparar Actas de verificación sin Hallazgos nuevos 2026-09-07 16:50:17 -03:00
admin 2d4e8ae2a7 F3.2 Android: permitir Actas READY paralelas y delegar cierre al servidor 2026-09-07 16:43:24 -03:00
admin 3e5dfb78a2 F3.2 Android: corregir Card clickeable de mensajes 2026-09-07 16:35:37 -03:00
admin ecf6f71c58 F3.2 Android: corregir API de PointerInput en firma 2026-09-07 16:34:33 -03:00
admin bd0f22969c F3.2 API: restaurar manifiesto exacto de producción 2026-09-07 16:26:48 -03:00
admin 5f3987747d F3.2 API: conservar dependencias y cambiar sólo versión 2026-09-07 16:26:32 -03:00
admin a208788488 F3.2: agregar CI de integración multi-Acta 2026-09-07 16:25:23 -03:00
admin 2e5183cc3c F3.2 API: actualizar versión de paquete 2026-09-07 16:24:57 -03:00
admin 80ce9ced0f F3.2 API: versionar integración multi-Acta móvil 2026-09-07 16:24:42 -03:00
admin d49fb81bcf F3.2 Android: activar CI y artefacto 0.12.0 2026-09-07 16:24:31 -03:00
admin 9e50967089 F3.2 Android: versionar APK 0.12.0 2026-09-07 16:24:16 -03:00
admin a1450da51e F3.2 Android: integrar Actas en la navegación de inspección 2026-09-07 16:23:10 -03:00
admin 97a774c264 F3.2 Android: implementar gestión y cierre secuencial de Actas 2026-09-07 16:20:08 -03:00
admin c9f1434462 F3.2 Android: agregar pad manuscrito PNG para firmas 2026-09-07 16:18:28 -03:00
admin d14cf737c0 F3.2 Android: incorporar estado multi-Acta y cierre 2026-09-07 16:16:09 -03:00
admin 9f60e671d2 F3.2 Android: agregar cliente de Actas y cierre 2026-09-07 16:14:43 -03:00
admin 28f6fb3684 F3.2 Android: enviar Acta explícita en Hallazgos 2026-09-07 16:13:52 -03:00
admin d7184ed537 F3.2: fijar contrato multi-Acta móvil y cierre secuencial 2026-09-07 16:13:22 -03:00
admin 203443d9c6 F3.2: resolver Hallazgos contra Acta explícita 2026-09-07 16:12:03 -03:00
admin 45ca6a783e F3.2: recibir Acta explícita en Hallazgos de campo 2026-09-07 16:11:20 -03:00
admin d3bfdeac67 F3.2: permitir Acta explícita al crear Hallazgo de campo 2026-09-07 16:11:05 -03:00
admin 9479ae6d1a F3.2: agregar selección explícita de Acta en Hallazgos de campo 2026-09-07 16:10:49 -03:00
admin 4ca65c6a89 F3.1 hotfix · aislar preflight API del WEB (#19)
Separa el contrato visual F3.1 del test API y agrega a CI el mismo preflight Docker aislado que ejecuta el VPS antes de producción.
2026-09-07 15:53:27 -03:00
195 changed files with 11943 additions and 6497 deletions
+11 -1
View File
@@ -21,10 +21,20 @@ ACCESS_COOKIE_NAME=dhv2_access
REFRESH_COOKIE_NAME=dhv2_refresh
CSRF_COOKIE_NAME=dhv2_csrf
INSPECTION_SIGNATURE_ROOT=/app/storage/asset-media/inspection-signatures
INSPECTION_REPORT_WORD_ROOT=/app/storage/asset-media/inspection-reports-word
INSPECTION_REPORT_REVISION_ROOT=/app/storage/asset-media/inspection-report-revisions
INSPECTION_ACT_PDF_ROOT=/app/storage/asset-media/inspection-acts-pdf
# Enlace público de un solo uso para firma/manifestación de empresa.
# En producción: https://dhv2.korexlabs.com/firma-acta
# En entorno de prueba: https://prueba.dhv2.korexlabs.com/firma-acta
COMPANY_SIGNATURE_PUBLIC_BASE_URL=https://dhv2.korexlabs.com/firma-acta
# Clave maestra de 32 bytes (64 hex o base64) para cifrar la contraseña SMTP guardada por Superadmin.
SMTP_SETTINGS_MASTER_KEY=CHANGE_ME_WITH_32_RANDOM_BYTES
# Fallback SMTP. Se usa sólo hasta que Superadmin guarde una configuración en la base de datos.
SMTP_HOST=
SMTP_PORT=587
SMTP_SECURE=false
+90 -17
View File
@@ -1,30 +1,32 @@
name: Android APK
# F3.1: este workflow genera la APK debug verificable de la rama antes de promoverla.
name: Android CI / RC
on:
push:
branches:
- 'feature/f2-2*'
- 'feature/f2-3*'
- 'feature/f2-4*'
- 'feature/f3-1*'
branches: [main]
paths:
- 'android-app/**'
- 'api-v3/src/**'
- '.github/workflows/android.yml'
pull_request:
branches: [main]
paths:
- 'android-app/**'
- 'api-v3/src/auth/**'
- 'api-v3/src/**'
- '.github/workflows/android.yml'
workflow_dispatch:
permissions:
contents: read
concurrency:
group: dhv2-android-${{ github.ref }}
cancel-in-progress: true
jobs:
build-debug-apk:
android:
name: Android · lint, tests, debug APK, release compile
runs-on: ubuntu-latest
timeout-minutes: 30
timeout-minutes: 35
steps:
- name: Checkout
uses: actions/checkout@v4
@@ -46,18 +48,89 @@ jobs:
with:
gradle-version: '8.13'
- name: Assemble debug
working-directory: android-app
run: gradle --no-daemon :app:assembleDebug
- name: Validate mobile security and identity contract
run: |
set -Eeuo pipefail
grep -Fq 'applicationId = "com.korexlabs.dhinspeccion"' android-app/app/build.gradle.kts
grep -Fq 'applicationIdSuffix = ".debug"' android-app/app/build.gradle.kts
grep -Fq 'buildConfigField("String", "API_BASE_URL", "\"https://dhv2.korexlabs.com/api/v3/\"")' android-app/app/build.gradle.kts
grep -Fq 'android:allowBackup="false"' android-app/app/src/main/AndroidManifest.xml
grep -Fq 'android:usesCleartextTraffic="false"' android-app/app/src/main/AndroidManifest.xml
- name: Unit tests
- name: Android lint
working-directory: android-app
run: gradle --no-daemon :app:lintDebug
- name: Print complete lint failures
if: failure()
run: |
report="android-app/app/build/intermediates/lint_intermediate_text_report/debug/lintReportDebug/lint-results-debug.txt"
if [ -f "$report" ]; then
echo '========== ANDROID LINT =========='
cat "$report"
fi
- name: Android unit tests
working-directory: android-app
run: gradle --no-daemon :app:testDebugUnitTest
- name: Upload APK
- name: Require real unit-test results
run: |
set -Eeuo pipefail
result="$(find android-app/app/build/test-results/testDebugUnitTest -type f -name 'TEST-*.xml' -print -quit)"
test -n "$result"
grep -Eq '<testsuite[^>]+tests="[1-9][0-9]*"' "$result"
- name: Assemble debug APK
working-directory: android-app
run: gradle --no-daemon :app:assembleDebug
- name: Compile unsigned release variant
working-directory: android-app
run: gradle --no-daemon :app:assembleRelease
- name: Package RC artifact and checksum
id: package
run: |
set -Eeuo pipefail
version="$(sed -n 's/^[[:space:]]*versionName = "\([^"]*\)"/\1/p' android-app/app/build.gradle.kts | head -n1)"
code="$(sed -n 's/^[[:space:]]*versionCode = \([0-9][0-9]*\)/\1/p' android-app/app/build.gradle.kts | head -n1)"
test -n "$version"
test -n "$code"
short_sha="${GITHUB_SHA::12}"
mkdir -p android-app/dist
apk="android-app/dist/DH-Inspeccion-${version}-vc${code}-${short_sha}-debug.apk"
cp android-app/app/build/outputs/apk/debug/app-debug.apk "$apk"
sha256sum "$apk" > "${apk}.sha256"
{
echo "version=$version"
echo "versionCode=$code"
echo "commit=$GITHUB_SHA"
echo "artifact=$(basename "$apk")"
echo "applicationId=com.korexlabs.dhinspeccion.debug"
echo "apiBaseUrl=https://dhv2.korexlabs.com/api/v3/"
echo "channel=DEBUG_RC"
} > android-app/dist/release-metadata.txt
echo "version=$version" >> "$GITHUB_OUTPUT"
echo "version_code=$code" >> "$GITHUB_OUTPUT"
echo "short_sha=$short_sha" >> "$GITHUB_OUTPUT"
- name: Upload debug RC
uses: actions/upload-artifact@v4
with:
name: DH-Inspeccion-F3.1-0.11.0-debug
path: android-app/app/build/outputs/apk/debug/app-debug.apk
name: DH-Inspeccion-${{ steps.package.outputs.version }}-vc${{ steps.package.outputs.version_code }}-${{ steps.package.outputs.short_sha }}-debug
path: android-app/dist/*
if-no-files-found: error
retention-days: 30
- name: Upload Android diagnostics
if: always()
uses: actions/upload-artifact@v4
with:
name: android-diagnostics-${{ github.sha }}
path: |
android-app/app/build/reports/lint-results-debug.html
android-app/app/build/reports/tests/testDebugUnitTest/**
android-app/app/build/test-results/testDebugUnitTest/**
if-no-files-found: ignore
retention-days: 14
+8
View File
@@ -62,6 +62,11 @@ jobs:
while IFS= read -r -d '' script; do
bash -n "$script"
done < <(find scripts -type f -name '*.sh' -print0)
- name: Validate deploy preflight parity
run: |
grep -Fq -- '$STAGE/docker-compose.yml:/docker-compose.yml:ro' scripts/deploy-github.sh
grep -Fq -- '$STAGE/web-v2:/web-v2:ro' scripts/deploy-github.sh
grep -Fq -- '$STAGE/android-app:/android-app:ro' scripts/deploy-github.sh
- name: Validate Compose
run: docker compose --env-file .env.example config >/dev/null
- name: VPS-equivalent isolated API preflight
@@ -72,6 +77,9 @@ jobs:
docker run --rm \
-v "$PWD/api-v3/test:/app/test:ro" \
-v "$PWD/api-v3/tsconfig.test.json:/app/tsconfig.test.json:ro" \
-v "$PWD/docker-compose.yml:/docker-compose.yml:ro" \
-v "$PWD/web-v2:/web-v2:ro" \
-v "$PWD/android-app:/android-app:ro" \
"$image" npm test
docker image rm "$image" >/dev/null 2>&1 || true
- name: Build production images
+80
View File
@@ -0,0 +1,80 @@
name: F3.2 Multi-Acta CI
on:
push:
branches:
- 'feature/f3-2*'
workflow_dispatch:
permissions:
contents: read
jobs:
api:
name: API · F3.2
runs-on: ubuntu-latest
timeout-minutes: 25
defaults:
run:
working-directory: api-v3
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: '24'
cache: npm
cache-dependency-path: api-v3/package-lock.json
- run: npm ci
- name: Typecheck
run: npm run typecheck
- name: Tests
run: npm test
- name: Build
run: npm run build
web:
name: WEB · regression
runs-on: ubuntu-latest
timeout-minutes: 20
defaults:
run:
working-directory: web-v2
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: '24'
cache: npm
cache-dependency-path: web-v2/package-lock.json
- run: npm ci
- run: npm run typecheck
- name: F3.1 structural WEB contract
run: bash ../scripts/check-f3-1-web-contract.sh
- run: npm run build
deploy-preflight:
name: VPS-equivalent preflight / Docker
runs-on: ubuntu-latest
timeout-minutes: 30
needs: [api, web]
steps:
- uses: actions/checkout@v4
- name: Validate shell scripts
run: |
while IFS= read -r -d '' script; do
bash -n "$script"
done < <(find scripts -type f -name '*.sh' -print0)
- name: Validate Compose
run: docker compose --env-file .env.example config >/dev/null
- name: VPS-equivalent isolated API tests
run: |
set -Eeuo pipefail
image="dhv2-api:f3-2-preflight-${GITHUB_SHA::12}"
docker build --target builder -t "$image" api-v3
docker run --rm \
-v "$PWD/api-v3/test:/app/test:ro" \
-v "$PWD/api-v3/tsconfig.test.json:/app/tsconfig.test.json:ro" \
"$image" npm test
docker image rm "$image" >/dev/null 2>&1 || true
- name: Build production images
run: docker compose --env-file .env.example build api migrate web
+7
View File
@@ -11,6 +11,11 @@
**/.vite/
**/coverage/
# Android / Gradle local state
android-app/.gradle/
android-app/**/build/
android-app/local.properties
# Backups / exports
*.zip
*.tar.gz
@@ -38,6 +43,8 @@ Thumbs.db
*.key
*.p12
*.pfx
*.jks
*.keystore
id_rsa
id_ed25519
*_github
+39
View File
@@ -0,0 +1,39 @@
# DH Inspección Android · contrato de release F5
## Estado de esta etapa
La primera candidata de F5 es `0.13.0-rc1` (`versionCode 20`). Su objetivo es convertir el cliente Android en una barrera verificable del repositorio antes del piloto de campo.
## Barrera obligatoria
Todo cambio Android o de API que pueda afectar al cliente móvil debe pasar el workflow `Android CI / RC`:
1. Android lint.
2. Unit tests Android reales (el job falla si no existe ningún XML de tests con al menos una prueba).
3. `assembleDebug`.
4. `assembleRelease` para verificar que la variante productiva compile.
5. Empaquetado del APK debug RC con SHA-256 y metadata de commit/versionado.
El APK debug usa `com.korexlabs.dhinspeccion.debug`; es deliberadamente independiente de la app productiva y sirve para QA/piloto técnico sin sobrescribir una instalación release histórica.
## Firma release
La clave histórica de firma NO se versiona ni se reemplaza. La variante release se compila en CI, pero el APK de distribución final deberá firmarse con la clave histórica y comprobarse antes de instalarlo como actualización de `com.korexlabs.dhinspeccion`.
No se debe generar una clave nueva para "resolver" una falta de acceso: eso rompería la continuidad de actualización de las tablets que ya tengan una versión firmada con la clave anterior.
## Evidencia mínima de cada candidata
El artefacto de CI contiene:
- APK debug RC;
- archivo `.sha256`;
- `release-metadata.txt` con versión, versionCode, commit, applicationId, API base y canal.
Para un release de campo definitivo se agregará además:
- APK release firmada;
- huella/certificado de firma comprobado contra la versión histórica;
- prueba de actualización sobre una tablet con versión anterior;
- smoke funcional contra producción;
- registro del SHA Git exacto que originó la APK.
+7 -2
View File
@@ -12,8 +12,8 @@ android {
applicationId = "com.korexlabs.dhinspeccion"
minSdk = 26
targetSdk = 36
versionCode = 18
versionName = "0.11.0"
versionCode = 20
versionName = "0.13.0-rc1"
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
vectorDrawables.useSupportLibrary = true
@@ -46,6 +46,11 @@ android {
}
kotlinOptions.jvmTarget = "17"
lint {
abortOnError = true
checkReleaseBuilds = true
}
packaging.resources.excludes += "/META-INF/{AL2.0,LGPL2.1}"
}
@@ -6,6 +6,10 @@
<uses-permission android:name="android.permission.ACCESS_FINE_LOCATION" />
<uses-permission android:name="android.permission.ACCESS_COARSE_LOCATION" />
<uses-feature
android:name="android.hardware.camera"
android:required="false" />
<application
android:allowBackup="false"
android:label="@string/app_name"
@@ -16,6 +16,11 @@ import com.korexlabs.dhinspeccion.data.FieldFindingOptionsResponse
import com.korexlabs.dhinspeccion.data.FieldFindingsRepository
import com.korexlabs.dhinspeccion.data.FieldInventoryItem
import com.korexlabs.dhinspeccion.data.FieldType
import com.korexlabs.dhinspeccion.data.MobileActClosure
import com.korexlabs.dhinspeccion.data.MobileActDetail
import com.korexlabs.dhinspeccion.data.MobileActSummary
import com.korexlabs.dhinspeccion.data.MobileActsRepository
import com.korexlabs.dhinspeccion.data.MobileResponsibleRequest
import com.korexlabs.dhinspeccion.data.StoredSession
import com.korexlabs.dhinspeccion.data.VisitDetail
import com.korexlabs.dhinspeccion.data.VisitSummary
@@ -26,6 +31,7 @@ import java.time.Instant
class MainViewModel(application: Application) : AndroidViewModel(application) {
private val repository = DhRepository(application)
private val findingsRepository = FieldFindingsRepository(application)
private val actsRepository = MobileActsRepository(application)
var session: StoredSession? by mutableStateOf(repository.currentSession())
private set
@@ -48,6 +54,13 @@ class MainViewModel(application: Application) : AndroidViewModel(application) {
var selectedFieldAsset: FieldAssetDetail? by mutableStateOf(null)
private set
var acts: List<MobileActSummary> by mutableStateOf(emptyList())
private set
var selectedAct: MobileActDetail? by mutableStateOf(null)
private set
var actClosure: MobileActClosure? by mutableStateOf(null)
private set
var fieldFindingOptions: FieldFindingOptionsResponse? by mutableStateOf(null)
private set
var lastCreatedFinding: FieldFindingItem? by mutableStateOf(null)
@@ -85,6 +98,7 @@ class MainViewModel(application: Application) : AndroidViewModel(application) {
inventory = emptyList()
fieldTypes = emptyList()
selectedFieldAsset = null
clearActState()
clearFindingState()
}
}
@@ -101,6 +115,7 @@ class MainViewModel(application: Application) : AndroidViewModel(application) {
fieldTypes = emptyList()
selectedFieldAsset = null
clearFindingState()
loadActsInternal(id, selectDraft = true)
}
fun closeVisitView() {
@@ -108,6 +123,7 @@ class MainViewModel(application: Application) : AndroidViewModel(application) {
inventory = emptyList()
fieldTypes = emptyList()
selectedFieldAsset = null
clearActState()
clearFindingState()
loadVisits()
}
@@ -117,10 +133,61 @@ class MainViewModel(application: Application) : AndroidViewModel(application) {
launchBusy {
visit = repository.startVisit(id)
notice = "Inspección iniciada."
loadActsInternal(id, selectDraft = true)
loadVisitsInternal()
}
}
fun reloadActs() {
val visitId = visit?.id ?: return
launchBusy { loadActsInternal(visitId, selectDraft = selectedAct == null) }
}
fun selectAct(actId: String) {
launchBusy {
selectedAct = actsRepository.get(actId)
actClosure = actsRepository.closure(actId)
clearFindingState()
}
}
fun createActForSelectedInventory(urgency: String = "NON_URGENT") {
val currentVisit = visit ?: return
val asset = selectedFieldAsset?.asset
if (currentVisit.status != "IN_PROGRESS") {
error = "La Inspección debe estar en curso para crear un Acta."
return
}
if (urgency !in setOf("URGENT", "NON_URGENT")) {
error = "Elegí si el Acta es urgente o no urgente."
return
}
if (asset == null) {
error = "Seleccioná primero una Instalación o Subinstalación para iniciar el Acta."
return
}
if (acts.any { it.status == "DRAFT" }) {
error = "Ya existe un Acta en borrador. Bloqueala o cancelala antes de crear la siguiente."
return
}
launchBusy {
val created = actsRepository.create(
currentVisit.id,
asset.id,
currentVisit.code,
urgency,
)
selectedAct = created
actClosure = actsRepository.closure(created.id)
loadActsInternal(currentVisit.id, selectDraft = false)
val urgencyLabel = if (urgency == "URGENT") "urgente" else "no urgente"
notice = "${created.code} creada como $urgencyLabel. Los Hallazgos nuevos quedarán vinculados explícitamente a esta Acta."
if (selectedFieldAsset?.capture?.readyForFinding == true) {
loadFindingOptionsInternal(currentVisit.id, asset.id, created.id)
}
}
}
fun searchInventory(search: String, parentId: String? = null) {
val id = visit?.id ?: return
launchBusy {
@@ -139,10 +206,15 @@ class MainViewModel(application: Application) : AndroidViewModel(application) {
val visitId = visit?.id ?: return
launchBusy {
selectedFieldAsset = repository.selectFieldAsset(visitId, item.id)
notice = "Inventario agregado a la inspección."
notice = "Inventario agregado a la Inspección."
inventory = repository.fieldInventory(visitId, null, null).data
if (selectedFieldAsset?.capture?.readyForFinding == true) {
loadFindingOptionsInternal(visitId, item.id)
val draft = selectedDraftAct()
if (selectedFieldAsset?.capture?.readyForFinding == true && draft != null) {
selectedAct = actsRepository.ensureAsset(draft.id, item.id)
loadActsInternal(visitId, selectDraft = false)
loadFindingOptionsInternal(visitId, item.id, draft.id)
} else if (selectedFieldAsset?.capture?.readyForFinding == true) {
notice = "Inventario listo. Creá o seleccioná un Acta antes de registrar Hallazgos."
}
}
}
@@ -160,7 +232,7 @@ class MainViewModel(application: Application) : AndroidViewModel(application) {
) {
val visitId = visit?.id ?: return
if (visit?.status != "IN_PROGRESS") {
error = "La inspección debe estar en curso para dar de alta Inventario."
error = "La Inspección debe estar en curso para dar de alta Inventario."
return
}
if (type.familyRequired && familyId == null) {
@@ -207,8 +279,11 @@ class MainViewModel(application: Application) : AndroidViewModel(application) {
selectedFieldAsset = repository.selectFieldAsset(visitId, result.canonical.id)
notice = "Fusión registrada. Se conserva ${result.canonical.code} y la historia de ${result.source.code} permanece trazable."
inventory = repository.fieldInventory(visitId, null, null).data
if (selectedFieldAsset?.capture?.readyForFinding == true) {
loadFindingOptionsInternal(visitId, result.canonical.id)
val draft = selectedDraftAct()
if (selectedFieldAsset?.capture?.readyForFinding == true && draft != null) {
selectedAct = actsRepository.ensureAsset(draft.id, result.canonical.id)
loadActsInternal(visitId, selectDraft = false)
loadFindingOptionsInternal(visitId, result.canonical.id, draft.id)
}
}
}
@@ -237,8 +312,13 @@ class MainViewModel(application: Application) : AndroidViewModel(application) {
"Fotografía registrada."
}
inventory = repository.fieldInventory(visitId, null, null).data
if (response.capture.readyForFinding) {
loadFindingOptionsInternal(visitId, asset.id)
val draft = selectedDraftAct()
if (response.capture.readyForFinding && draft != null) {
selectedAct = actsRepository.ensureAsset(draft.id, asset.id)
loadActsInternal(visitId, selectDraft = false)
loadFindingOptionsInternal(visitId, asset.id, draft.id)
} else if (response.capture.readyForFinding) {
notice = "Inventario listo. Creá o seleccioná un Acta antes de registrar Hallazgos."
}
}
}
@@ -246,7 +326,16 @@ class MainViewModel(application: Application) : AndroidViewModel(application) {
fun openFindingForSelected() {
val visitId = visit?.id ?: return
val assetId = selectedFieldAsset?.asset?.id ?: return
launchBusy { loadFindingOptionsInternal(visitId, assetId) }
val draft = selectedDraftAct()
if (draft == null) {
error = "Creá o seleccioná el Acta en borrador antes de registrar Hallazgos."
return
}
launchBusy {
selectedAct = actsRepository.ensureAsset(draft.id, assetId)
loadActsInternal(visitId, selectDraft = false)
loadFindingOptionsInternal(visitId, assetId, draft.id)
}
}
fun createFieldFinding(
@@ -259,6 +348,11 @@ class MainViewModel(application: Application) : AndroidViewModel(application) {
) {
val visitId = visit?.id ?: return
val assetId = selectedFieldAsset?.asset?.id ?: return
val actId = selectedDraftAct()?.id
if (actId == null) {
error = "No hay un Acta en borrador seleccionada."
return
}
if (description.isBlank()) {
error = "Describí el Hallazgo antes de guardarlo."
return
@@ -272,10 +366,12 @@ class MainViewModel(application: Application) : AndroidViewModel(application) {
return
}
launchBusy {
selectedAct = actsRepository.ensureAsset(actId, assetId)
val response = findingsRepository.create(
visitId,
assetId,
CreateFieldFindingRequest(
actId = actId,
catalogItemId = catalogItemId,
customTitle = customTitle?.trim()?.takeIf { it.isNotBlank() },
customLegalBasis = customLegalBasis?.trim()?.takeIf { it.isNotBlank() },
@@ -285,8 +381,9 @@ class MainViewModel(application: Application) : AndroidViewModel(application) {
),
)
lastCreatedFinding = response.finding
notice = "Hallazgo ${response.finding.code} registrado. Podés agregar evidencia fotográfica."
loadFindingOptionsInternal(visitId, assetId, keepLastCreated = true)
notice = "Hallazgo ${response.finding.code} registrado en ${response.act.code}. Podés agregar evidencia fotográfica."
loadFindingOptionsInternal(visitId, assetId, actId, keepLastCreated = true)
loadActsInternal(visitId, selectDraft = false)
}
}
@@ -318,6 +415,138 @@ class MainViewModel(application: Application) : AndroidViewModel(application) {
launchBusy { loadEvidenceInternal(findingId) }
}
fun setCompanyResponsiblePresent(
fullName: String,
documentType: String,
documentNumber: String,
position: String,
email: String?,
phone: String?,
) {
val actId = selectedAct?.id ?: return
if (fullName.isBlank() || documentNumber.isBlank() || position.isBlank()) {
error = "Completá nombre, documento y cargo del responsable de la empresa."
return
}
launchBusy {
actClosure = actsRepository.setResponsible(
actId,
MobileResponsibleRequest(
attendanceStatus = "PRESENT",
fullName = fullName.trim(),
documentType = documentType,
documentNumber = documentNumber.trim(),
position = position.trim(),
email = email?.trim()?.takeIf { it.isNotBlank() },
phone = phone?.trim()?.takeIf { it.isNotBlank() },
),
)
notice = "Responsable de empresa registrado para el Acta."
}
}
fun setCompanyResponsibleAbsent(reason: String) {
val actId = selectedAct?.id ?: return
if (reason.trim().length < 10) {
error = "Indicá un motivo de ausencia de al menos 10 caracteres."
return
}
launchBusy {
actClosure = actsRepository.setResponsible(
actId,
MobileResponsibleRequest(
attendanceStatus = "ABSENT",
absenceReason = reason.trim(),
),
)
notice = "Ausencia del responsable registrada. La manifestación de empresa quedará pendiente y deberá resolverse antes de sellar el Acta."
}
}
fun prepareSelectedAct() {
val actId = selectedAct?.id ?: return
launchBusy {
actClosure = actsRepository.lock(actId)
refreshSelectedActInternal(actId)
notice = "Acta bloqueada. Su contenido quedó inmutable; ahora deben resolverse las firmas y manifestaciones."
}
}
fun reopenSelectedAct() {
error = "Un Acta bloqueada es inmutable y no puede volver a borrador."
}
fun signSelectedActAsInspector(
png: File,
latitude: Double?,
longitude: Double?,
accuracyM: Double?,
) {
val actId = selectedAct?.id ?: return
launchBusy {
actClosure = actsRepository.signInspector(actId, png, latitude, longitude, accuracyM)
notice = "Firma del inspector incorporada al Acta."
}
}
fun signSelectedActAsCompany(
png: File,
latitude: Double?,
longitude: Double?,
accuracyM: Double?,
manifestation: String,
statement: String?,
) {
val actId = selectedAct?.id ?: return
launchBusy {
actClosure = actsRepository.signCompany(
actId, png, latitude, longitude, accuracyM, manifestation, statement,
)
notice = if (manifestation == "DISSENT") {
"Firma de empresa registrada con disidencia."
} else {
"Firma de empresa registrada."
}
}
}
fun recordCompanyOutcome(status: String, reason: String) {
val actId = selectedAct?.id ?: return
if (status != "REFUSED") {
error = "La ausencia no resuelve la manifestación de la empresa. Registrá una firma o una negativa."
return
}
if (reason.trim().length < 10) {
error = "Indicá un motivo de al menos 10 caracteres."
return
}
launchBusy {
actClosure = actsRepository.companyOutcome(actId, status, reason)
notice = "Negativa a firmar asentada."
}
}
fun closeSelectedAct() {
val currentVisit = visit ?: return
val actId = selectedAct?.id ?: return
launchBusy {
actClosure = actsRepository.seal(actId)
refreshSelectedActInternal(actId)
loadActsInternal(currentVisit.id, selectDraft = false)
clearFindingState()
notice = "${selectedAct?.code ?: "Acta"} SELLADA e inmutable. Podés crear otra Acta o continuar hacia el cierre de la Inspección."
}
}
fun closeInspection() {
val visitId = visit?.id ?: return
launchBusy {
visit = actsRepository.closeVisit(visitId)
loadVisitsInternal()
notice = "Inspección cerrada. Todas sus Actas quedaron SELLADAS y disponibles para el circuito de oficina."
}
}
fun clearFindingFlow() {
fieldFindingOptions = null
lastCreatedFinding = null
@@ -330,12 +559,43 @@ class MainViewModel(application: Application) : AndroidViewModel(application) {
clearFindingState()
}
private fun selectedDraftAct(): MobileActDetail? =
selectedAct?.takeIf { it.status == "DRAFT" }
?: acts.firstOrNull { it.status == "DRAFT" }?.let { summary ->
selectedAct?.takeIf { it.id == summary.id && it.status == "DRAFT" }
}
private suspend fun loadActsInternal(visitId: String, selectDraft: Boolean) {
acts = actsRepository.list(visitId).data
val currentId = selectedAct?.id
val current = currentId?.let { id -> acts.firstOrNull { it.id == id } }
val target = when {
current != null -> current.id
selectDraft -> acts.firstOrNull { it.status == "DRAFT" }?.id
else -> null
}
if (target != null) {
selectedAct = actsRepository.get(target)
actClosure = actsRepository.closure(target)
} else if (current == null) {
selectedAct = null
actClosure = null
}
}
private suspend fun refreshSelectedActInternal(actId: String) {
selectedAct = actsRepository.get(actId)
actClosure = actsRepository.closure(actId)
visit?.id?.let { loadActsInternal(it, selectDraft = false) }
}
private suspend fun loadFindingOptionsInternal(
visitId: String,
assetId: String,
actId: String,
keepLastCreated: Boolean = false,
) {
val options = findingsRepository.options(visitId, assetId)
val options = findingsRepository.options(visitId, assetId, actId)
fieldFindingOptions = options
if (!keepLastCreated) lastCreatedFinding = null
val loaded = linkedMapOf<String, List<FieldFindingEvidence>>()
@@ -351,6 +611,12 @@ class MainViewModel(application: Application) : AndroidViewModel(application) {
)
}
private fun clearActState() {
acts = emptyList()
selectedAct = null
actClosure = null
}
private fun clearFindingState() {
fieldFindingOptions = null
lastCreatedFinding = null
@@ -7,8 +7,6 @@ import android.util.Base64
import com.korexlabs.dhinspeccion.BuildConfig
import com.squareup.moshi.Moshi
import com.squareup.moshi.kotlin.reflect.KotlinJsonAdapterFactory
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import okhttp3.MediaType.Companion.toMediaType
import okhttp3.MultipartBody
import okhttp3.OkHttpClient
@@ -69,6 +67,7 @@ data class StoredSession(
val displayName: String,
val accessToken: String,
val refreshToken: String,
val mustChangePassword: Boolean = false,
)
// ---------- Inspections ----------
@@ -90,7 +89,6 @@ data class PersonSummary(
data class VisitSummary(
val id: String,
val code: String,
val title: String? = null,
val objective: String? = null,
val status: String,
val scopeAsset: AssetSummary? = null,
@@ -153,7 +151,6 @@ data class ChecklistSummary(
data class VisitDetail(
val id: String,
val code: String,
val title: String? = null,
val objective: String? = null,
val status: String,
val operationalArea: AssetSummary? = null,
@@ -294,9 +291,6 @@ interface DhApi {
@POST("auth/mobile/login")
suspend fun login(@Body request: LoginRequest): MobileSessionResponse
@POST("auth/mobile/refresh")
suspend fun refresh(@Body request: RefreshRequest): MobileSessionResponse
@POST("auth/mobile/logout")
suspend fun logout(@Header("Authorization") authorization: String): Map<String, Any?>
@@ -395,6 +389,7 @@ class SecureSessionStore(context: Context) {
displayName = json.optString("displayName", json.getString("username")),
accessToken = json.getString("accessToken"),
refreshToken = json.getString("refreshToken"),
mustChangePassword = json.optBoolean("mustChangePassword", false),
)
}.getOrElse {
clear()
@@ -405,19 +400,26 @@ class SecureSessionStore(context: Context) {
fun save(response: MobileSessionResponse): StoredSession {
val displayName = listOfNotNull(response.user.firstName, response.user.lastName)
.joinToString(" ").trim().ifBlank { response.user.username }
val stored = StoredSession(
userId = response.user.id,
username = response.user.username,
displayName = displayName,
accessToken = response.accessToken,
refreshToken = response.refreshToken,
return save(
StoredSession(
userId = response.user.id,
username = response.user.username,
displayName = displayName,
accessToken = response.accessToken,
refreshToken = response.refreshToken,
mustChangePassword = response.user.mustChangePassword,
),
)
}
fun save(stored: StoredSession): StoredSession {
val json = JSONObject()
.put("userId", stored.userId)
.put("username", stored.username)
.put("displayName", stored.displayName)
.put("accessToken", stored.accessToken)
.put("refreshToken", stored.refreshToken)
.put("mustChangePassword", stored.mustChangePassword)
.toString()
val cipher = Cipher.getInstance("AES/GCM/NoPadding")
cipher.init(Cipher.ENCRYPT_MODE, key())
@@ -451,8 +453,7 @@ class SecureSessionStore(context: Context) {
}
class DhRepository(context: Context) {
private val store = SecureSessionStore(context.applicationContext)
private val refreshMutex = Mutex()
private val sessions = MobileSessionCoordinator.get(context.applicationContext)
private val moshi = Moshi.Builder().addLast(KotlinJsonAdapterFactory()).build()
private val api: DhApi = Retrofit.Builder()
.baseUrl(BuildConfig.API_BASE_URL)
@@ -461,42 +462,45 @@ class DhRepository(context: Context) {
.build()
.create(DhApi::class.java)
fun currentSession(): StoredSession? = store.load()
fun currentSession(): StoredSession? = sessions.currentSession()
suspend fun login(identifier: String, password: String): StoredSession =
store.save(api.login(LoginRequest(identifier.trim(), password)))
sessions.save(api.login(LoginRequest(identifier.trim(), password)))
suspend fun changePassword(currentPassword: String, newPassword: String): StoredSession =
sessions.changePassword(currentPassword, newPassword)
suspend fun logout() {
val session = store.load()
val session = sessions.currentSession()
if (session != null) runCatching { api.logout("Bearer ${session.accessToken}") }
store.clear()
sessions.clear()
}
suspend fun visits(): VisitListResponse = authorized { session ->
suspend fun visits(): VisitListResponse = sessions.authorized { session ->
api.visits("Bearer ${session.accessToken}", session.userId)
}
suspend fun visit(id: String): VisitDetail = authorized { session ->
suspend fun visit(id: String): VisitDetail = sessions.authorized { session ->
api.visit("Bearer ${session.accessToken}", id)
}
suspend fun startVisit(id: String): VisitDetail = authorized { session ->
suspend fun startVisit(id: String): VisitDetail = sessions.authorized { session ->
api.startVisit("Bearer ${session.accessToken}", id)
}
suspend fun fieldInventory(visitId: String, search: String?, parentId: String? = null) = authorized { session ->
suspend fun fieldInventory(visitId: String, search: String?, parentId: String? = null) = sessions.authorized { session ->
api.fieldInventory("Bearer ${session.accessToken}", visitId, search?.takeIf { it.isNotBlank() }, parentId)
}
suspend fun fieldTypes(visitId: String, parentId: String?) = authorized { session ->
suspend fun fieldTypes(visitId: String, parentId: String?) = sessions.authorized { session ->
api.fieldTypes("Bearer ${session.accessToken}", visitId, parentId)
}
suspend fun selectFieldAsset(visitId: String, assetId: String) = authorized { session ->
suspend fun selectFieldAsset(visitId: String, assetId: String) = sessions.authorized { session ->
api.selectFieldAsset("Bearer ${session.accessToken}", visitId, assetId)
}
suspend fun createFieldAsset(visitId: String, request: CreateFieldInventoryRequest) = authorized { session ->
suspend fun createFieldAsset(visitId: String, request: CreateFieldInventoryRequest) = sessions.authorized { session ->
api.createFieldAsset("Bearer ${session.accessToken}", visitId, request)
}
@@ -505,7 +509,7 @@ class DhRepository(context: Context) {
assetId: String,
canonicalAssetId: String,
reason: String,
): FieldInventoryMergeResult = authorized { session ->
): FieldInventoryMergeResult = sessions.authorized { session ->
api.mergeFieldAsset(
"Bearer ${session.accessToken}",
visitId,
@@ -522,46 +526,28 @@ class DhRepository(context: Context) {
longitude: Double,
accuracyM: Double?,
capturedAt: String = Instant.now().toString(),
): FieldPhotoResponse = authorized { session ->
val text = "text/plain".toMediaType()
val body = file.asRequestBody("image/jpeg".toMediaType())
val part = MultipartBody.Part.createFormData("file", file.name, body)
api.uploadFieldPhoto(
authorization = "Bearer ${session.accessToken}",
visitId = visitId,
assetId = assetId,
file = part,
latitude = latitude.toString().toRequestBody(text),
longitude = longitude.toString().toRequestBody(text),
accuracy = accuracyM?.toString()?.toRequestBody(text),
capturedAt = capturedAt.toRequestBody(text),
deviceLabel = "DH Android".toRequestBody(text),
exifLatitude = latitude.toString().toRequestBody(text),
exifLongitude = longitude.toString().toRequestBody(text),
exifCapturedAt = capturedAt.toRequestBody(text),
)
}
private suspend fun <T> authorized(block: suspend (StoredSession) -> T): T {
var session = store.load() ?: throw IllegalStateException("Sesión no iniciada")
try {
return block(session)
} catch (error: HttpException) {
if (error.code() != 401) throw error
}
session = refresh(session.refreshToken)
return block(session)
}
private suspend fun refresh(previousRefreshToken: String): StoredSession = refreshMutex.withLock {
val latest = store.load() ?: throw IllegalStateException("Sesión no iniciada")
if (latest.refreshToken != previousRefreshToken) return@withLock latest
try {
store.save(api.refresh(RefreshRequest(previousRefreshToken)))
} catch (error: Throwable) {
store.clear()
throw error
): FieldPhotoResponse {
val response = sessions.authorized { session ->
val text = "text/plain".toMediaType()
val body = file.asRequestBody("image/jpeg".toMediaType())
val part = MultipartBody.Part.createFormData("file", file.name, body)
api.uploadFieldPhoto(
authorization = "Bearer ${session.accessToken}",
visitId = visitId,
assetId = assetId,
file = part,
latitude = latitude.toString().toRequestBody(text),
longitude = longitude.toString().toRequestBody(text),
accuracy = accuracyM?.toString()?.toRequestBody(text),
capturedAt = capturedAt.toRequestBody(text),
deviceLabel = "DH Android".toRequestBody(text),
exifLatitude = latitude.toString().toRequestBody(text),
exifLongitude = longitude.toString().toRequestBody(text),
exifCapturedAt = capturedAt.toRequestBody(text),
)
}
file.delete()
return response
}
companion object {
@@ -4,15 +4,12 @@ import android.content.Context
import com.korexlabs.dhinspeccion.BuildConfig
import com.squareup.moshi.Moshi
import com.squareup.moshi.kotlin.reflect.KotlinJsonAdapterFactory
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import okhttp3.MediaType.Companion.toMediaType
import okhttp3.MultipartBody
import okhttp3.OkHttpClient
import okhttp3.RequestBody
import okhttp3.RequestBody.Companion.asRequestBody
import okhttp3.RequestBody.Companion.toRequestBody
import retrofit2.HttpException
import retrofit2.Retrofit
import retrofit2.converter.moshi.MoshiConverterFactory
import retrofit2.http.Body
@@ -22,6 +19,7 @@ import retrofit2.http.Multipart
import retrofit2.http.POST
import retrofit2.http.Part
import retrofit2.http.Path
import retrofit2.http.Query
import java.io.File
import java.time.Instant
@@ -100,12 +98,15 @@ data class FieldFindingEvidenceListResponse(
data class FieldFindingOptionsResponse(
val act: FieldFindingAct,
val capture: CaptureStatus = CaptureStatus(),
val assetIncludedInAct: Boolean = true,
val catalog: FieldFindingCatalog,
val findings: List<FieldFindingItem> = emptyList(),
val canAddAnother: Boolean = true,
val actSelectionMode: String = "EXPLICIT",
)
data class CreateFieldFindingRequest(
val actId: String,
val catalogItemId: String? = null,
val customTitle: String? = null,
val customLegalBasis: String? = null,
@@ -119,6 +120,7 @@ data class FieldFindingCreateResponse(
val capture: CaptureStatus = CaptureStatus(),
val finding: FieldFindingItem,
val canAddAnother: Boolean = true,
val actSelectionMode: String = "EXPLICIT",
)
private interface FieldFindingsApi {
@@ -127,6 +129,7 @@ private interface FieldFindingsApi {
@Header("Authorization") authorization: String,
@Path("visitId") visitId: String,
@Path("assetId") assetId: String,
@Query("actId") actId: String,
): FieldFindingOptionsResponse
@POST("inspection-visits/{visitId}/field-findings/{assetId}")
@@ -159,18 +162,14 @@ private interface FieldFindingsApi {
@Part("accuracyM") accuracyM: RequestBody?,
@Part("deviceLabel") deviceLabel: RequestBody,
): FieldFindingEvidence
@POST("auth/mobile/refresh")
suspend fun refresh(@Body request: RefreshRequest): MobileSessionResponse
}
/**
* Cliente de campo para Hallazgos y sus evidencias append-only.
* Comparte el almacén cifrado de sesión y nunca persiste la contraseña.
* F5 comparte autenticación con Inventario y Actas para serializar refresh tokens rotativos.
*/
class FieldFindingsRepository(context: Context) {
private val store = SecureSessionStore(context.applicationContext)
private val refreshMutex = Mutex()
private val sessions = MobileSessionCoordinator.get(context.applicationContext)
private val moshi = Moshi.Builder().addLast(KotlinJsonAdapterFactory()).build()
private val api: FieldFindingsApi = Retrofit.Builder()
.baseUrl(BuildConfig.API_BASE_URL)
@@ -179,20 +178,20 @@ class FieldFindingsRepository(context: Context) {
.build()
.create(FieldFindingsApi::class.java)
suspend fun options(visitId: String, assetId: String): FieldFindingOptionsResponse =
authorized { session ->
api.options("Bearer ${session.accessToken}", visitId, assetId)
suspend fun options(visitId: String, assetId: String, actId: String): FieldFindingOptionsResponse =
sessions.authorized { session ->
api.options("Bearer ${session.accessToken}", visitId, assetId, actId)
}
suspend fun create(
visitId: String,
assetId: String,
request: CreateFieldFindingRequest,
): FieldFindingCreateResponse = authorized { session ->
): FieldFindingCreateResponse = sessions.authorized { session ->
api.create("Bearer ${session.accessToken}", visitId, assetId, request)
}
suspend fun evidence(findingId: String): FieldFindingEvidenceListResponse = authorized { session ->
suspend fun evidence(findingId: String): FieldFindingEvidenceListResponse = sessions.authorized { session ->
api.evidence("Bearer ${session.accessToken}", findingId)
}
@@ -205,48 +204,30 @@ class FieldFindingsRepository(context: Context) {
title: String? = null,
description: String? = null,
capturedAt: String = Instant.now().toString(),
): FieldFindingEvidence = authorized { session ->
val text = "text/plain".toMediaType()
val part = MultipartBody.Part.createFormData(
"file",
file.name,
file.asRequestBody("image/jpeg".toMediaType()),
)
api.uploadEvidence(
authorization = "Bearer ${session.accessToken}",
findingId = findingId,
file = part,
kind = "PHOTO".toRequestBody(text),
purpose = "OBSERVATION".toRequestBody(text),
title = title?.trim()?.takeIf { it.isNotBlank() }?.toRequestBody(text),
description = description?.trim()?.takeIf { it.isNotBlank() }?.toRequestBody(text),
capturedAt = capturedAt.toRequestBody(text),
latitude = latitude.toString().toRequestBody(text),
longitude = longitude.toString().toRequestBody(text),
accuracyM = accuracyM?.toString()?.toRequestBody(text),
deviceLabel = "DH Android".toRequestBody(text),
)
}
private suspend fun <T> authorized(block: suspend (StoredSession) -> T): T {
var session = store.load() ?: throw IllegalStateException("Sesión no iniciada")
try {
return block(session)
} catch (error: HttpException) {
if (error.code() != 401) throw error
}
session = refresh(session.refreshToken)
return block(session)
}
private suspend fun refresh(previousRefreshToken: String): StoredSession = refreshMutex.withLock {
val latest = store.load() ?: throw IllegalStateException("Sesión no iniciada")
if (latest.refreshToken != previousRefreshToken) return@withLock latest
try {
store.save(api.refresh(RefreshRequest(previousRefreshToken)))
} catch (error: Throwable) {
store.clear()
throw error
): FieldFindingEvidence {
val response = sessions.authorized { session ->
val text = "text/plain".toMediaType()
val part = MultipartBody.Part.createFormData(
"file",
file.name,
file.asRequestBody("image/jpeg".toMediaType()),
)
api.uploadEvidence(
authorization = "Bearer ${session.accessToken}",
findingId = findingId,
file = part,
kind = "PHOTO".toRequestBody(text),
purpose = "OBSERVATION".toRequestBody(text),
title = title?.trim()?.takeIf { it.isNotBlank() }?.toRequestBody(text),
description = description?.trim()?.takeIf { it.isNotBlank() }?.toRequestBody(text),
capturedAt = capturedAt.toRequestBody(text),
latitude = latitude.toString().toRequestBody(text),
longitude = longitude.toString().toRequestBody(text),
accuracyM = accuracyM?.toString()?.toRequestBody(text),
deviceLabel = "DH Android".toRequestBody(text),
)
}
file.delete()
return response
}
}
@@ -0,0 +1,446 @@
package com.korexlabs.dhinspeccion.data
import android.content.Context
import com.korexlabs.dhinspeccion.BuildConfig
import com.squareup.moshi.Moshi
import com.squareup.moshi.kotlin.reflect.KotlinJsonAdapterFactory
import okhttp3.MediaType.Companion.toMediaType
import okhttp3.MultipartBody
import okhttp3.OkHttpClient
import okhttp3.RequestBody
import okhttp3.RequestBody.Companion.asRequestBody
import okhttp3.RequestBody.Companion.toRequestBody
import retrofit2.Retrofit
import retrofit2.converter.moshi.MoshiConverterFactory
import retrofit2.http.Body
import retrofit2.http.GET
import retrofit2.http.Header
import retrofit2.http.Multipart
import retrofit2.http.PATCH
import retrofit2.http.POST
import retrofit2.http.PUT
import retrofit2.http.Part
import retrofit2.http.Path
import retrofit2.http.Query
import java.io.File
import java.time.Instant
data class MobileActSummary(
val id: String,
val visitId: String,
val code: String,
val status: String,
val occurredAt: String,
val title: String,
val summary: String,
val observations: String? = null,
val urgency: String = "NON_URGENT",
val deadlineDays: Int? = null,
val deadlineDayType: String? = null,
val deadlineBasis: String? = null,
val deadlineBaseAt: String? = null,
val deadlineAt: String? = null,
val lockedAt: String? = null,
val lockedSha256: String? = null,
val sealedAt: String? = null,
val currentVersion: Int = 0,
val closedAt: String? = null,
val closureSha256: String? = null,
val assetCount: Int = 0,
val findingCount: Int = 0,
)
data class MobileActDetail(
val id: String,
val visitId: String,
val code: String,
val status: String,
val occurredAt: String,
val title: String,
val summary: String,
val observations: String? = null,
val urgency: String = "NON_URGENT",
val deadlineDays: Int? = null,
val deadlineDayType: String? = null,
val deadlineBasis: String? = null,
val deadlineBaseAt: String? = null,
val deadlineAt: String? = null,
val lockedAt: String? = null,
val lockedSha256: String? = null,
val sealedAt: String? = null,
val currentVersion: Int = 0,
val closedAt: String? = null,
val closureSha256: String? = null,
val assetCount: Int = 0,
val findingCount: Int = 0,
val assets: List<AssetSummary> = emptyList(),
)
data class MobileActListMeta(
val page: Int = 1,
val pageSize: Int = 100,
val total: Int = 0,
val totalPages: Int = 0,
)
data class MobileActListResponse(
val data: List<MobileActSummary> = emptyList(),
val meta: MobileActListMeta = MobileActListMeta(),
)
data class CreateMobileActRequest(
val occurredAt: String,
val urgency: String,
val title: String,
val summary: String,
val observations: String? = null,
val assetIds: List<String>,
)
data class UpdateMobileActRequest(
val assetIds: List<String>,
)
data class MobileResponsibleRequest(
val attendanceStatus: String,
val fullName: String? = null,
val documentType: String? = null,
val documentNumber: String? = null,
val position: String? = null,
val email: String? = null,
val phone: String? = null,
val absenceReason: String? = null,
)
data class MobileResponsible(
val actId: String,
val attendanceStatus: String,
val fullName: String? = null,
val documentType: String? = null,
val documentNumber: String? = null,
val position: String? = null,
val email: String? = null,
val phone: String? = null,
val absenceReason: String? = null,
)
data class MobileActClosureHeader(
val id: String,
val code: String,
val status: String,
val visitId: String,
val urgency: String = "NON_URGENT",
val deadlineDays: Int? = null,
val deadlineDayType: String? = null,
val deadlineBasis: String? = null,
val deadlineBaseAt: String? = null,
val deadlineAt: String? = null,
val lockedAt: String? = null,
val lockedSha256: String? = null,
val sealedAt: String? = null,
val currentVersion: Int = 0,
val closedAt: String? = null,
val closureSha256: String? = null,
)
data class MobileVisitClosureHeader(
val id: String,
val code: String,
val status: String,
val actualClosedAt: String? = null,
)
data class MobileSignature(
val id: String,
val signerType: String,
val signerUserId: String? = null,
val signerName: String,
val status: String,
val reason: String? = null,
val companyManifestation: String? = null,
val companyStatement: String? = null,
val signedAt: String? = null,
val imageSha256: String? = null,
)
data class MobileClosureRecord(
val schemaVersion: String,
val preparedSha256: String,
val preparedAt: String,
val finalSha256: String? = null,
val deviceClosedAt: String? = null,
val serverClosedAt: String? = null,
val uploadMode: String? = null,
val isCurrent: Boolean = true,
)
data class MobileClosureConsents(
val version: String = "",
val inspector: String = "",
val company: String = "",
)
data class MobileActClosure(
val act: MobileActClosureHeader,
val visit: MobileVisitClosureHeader,
val responsible: MobileResponsible? = null,
val closure: MobileClosureRecord? = null,
val signatures: List<MobileSignature> = emptyList(),
val consents: MobileClosureConsents = MobileClosureConsents(),
)
data class MobileCompanyOutcomeRequest(
val status: String,
val reason: String,
)
data class MobileSealActRequest(
val clientClosedAt: String = Instant.now().toString(),
val uploadMode: String = "ONLINE",
)
data class MobileCloseVisitRequest(
val clientClosedAt: String = Instant.now().toString(),
)
private interface MobileActsApi {
@GET("inspection-visits/{visitId}/acts")
suspend fun listActs(
@Header("Authorization") authorization: String,
@Path("visitId") visitId: String,
@Query("pageSize") pageSize: Int = 100,
): MobileActListResponse
@GET("inspection-acts/{actId}")
suspend fun act(
@Header("Authorization") authorization: String,
@Path("actId") actId: String,
): MobileActDetail
@POST("inspection-visits/{visitId}/acts")
suspend fun createAct(
@Header("Authorization") authorization: String,
@Path("visitId") visitId: String,
@Body request: CreateMobileActRequest,
): MobileActDetail
@PATCH("inspection-acts/{actId}")
suspend fun updateAct(
@Header("Authorization") authorization: String,
@Path("actId") actId: String,
@Body request: UpdateMobileActRequest,
): MobileActDetail
@GET("inspection-acts/{actId}/closure")
suspend fun closure(
@Header("Authorization") authorization: String,
@Path("actId") actId: String,
): MobileActClosure
@PUT("inspection-acts/{actId}/responsible")
suspend fun responsible(
@Header("Authorization") authorization: String,
@Path("actId") actId: String,
@Body request: MobileResponsibleRequest,
): MobileActClosure
@POST("inspection-acts/{actId}/lock")
suspend fun lock(
@Header("Authorization") authorization: String,
@Path("actId") actId: String,
): MobileActClosure
@Multipart
@POST("inspection-acts/{actId}/signatures/inspector")
suspend fun signInspector(
@Header("Authorization") authorization: String,
@Path("actId") actId: String,
@Part file: MultipartBody.Part,
@Part("consentAccepted") consentAccepted: RequestBody,
@Part("clientSignedAt") clientSignedAt: RequestBody,
@Part("latitude") latitude: RequestBody?,
@Part("longitude") longitude: RequestBody?,
@Part("accuracyM") accuracyM: RequestBody?,
@Part("deviceLabel") deviceLabel: RequestBody,
): MobileActClosure
@Multipart
@POST("inspection-acts/{actId}/signatures/company")
suspend fun signCompany(
@Header("Authorization") authorization: String,
@Path("actId") actId: String,
@Part file: MultipartBody.Part,
@Part("consentAccepted") consentAccepted: RequestBody,
@Part("clientSignedAt") clientSignedAt: RequestBody,
@Part("latitude") latitude: RequestBody?,
@Part("longitude") longitude: RequestBody?,
@Part("accuracyM") accuracyM: RequestBody?,
@Part("deviceLabel") deviceLabel: RequestBody,
@Part("manifestation") manifestation: RequestBody?,
@Part("statement") statement: RequestBody?,
): MobileActClosure
@POST("inspection-acts/{actId}/company-outcome")
suspend fun companyOutcome(
@Header("Authorization") authorization: String,
@Path("actId") actId: String,
@Body request: MobileCompanyOutcomeRequest,
): MobileActClosure
@POST("inspection-acts/{actId}/seal")
suspend fun sealAct(
@Header("Authorization") authorization: String,
@Path("actId") actId: String,
@Body request: MobileSealActRequest,
): MobileActClosure
@POST("inspection-visits/{visitId}/close")
suspend fun closeVisit(
@Header("Authorization") authorization: String,
@Path("visitId") visitId: String,
@Body request: MobileCloseVisitRequest,
): VisitDetail
}
class MobileActsRepository(context: Context) {
private val sessions = MobileSessionCoordinator.get(context.applicationContext)
private val moshi = Moshi.Builder().addLast(KotlinJsonAdapterFactory()).build()
private val api: MobileActsApi = Retrofit.Builder()
.baseUrl(BuildConfig.API_BASE_URL)
.client(OkHttpClient.Builder().build())
.addConverterFactory(MoshiConverterFactory.create(moshi))
.build()
.create(MobileActsApi::class.java)
suspend fun list(visitId: String): MobileActListResponse = sessions.authorized { session ->
api.listActs("Bearer ${session.accessToken}", visitId)
}
suspend fun get(actId: String): MobileActDetail = sessions.authorized { session ->
api.act("Bearer ${session.accessToken}", actId)
}
suspend fun create(
visitId: String,
assetId: String,
visitCode: String,
urgency: String = "NON_URGENT",
): MobileActDetail = sessions.authorized { session ->
api.createAct(
"Bearer ${session.accessToken}",
visitId,
CreateMobileActRequest(
occurredAt = Instant.now().toString(),
urgency = urgency,
title = "Acta de inspección $visitCode",
summary = "Acta de inspección en curso. Los Hallazgos y observaciones se incorporan de forma trazable durante la inspección.",
assetIds = listOf(assetId),
),
)
}
suspend fun ensureAsset(actId: String, assetId: String): MobileActDetail {
val detail = get(actId)
if (detail.assets.any { it.id == assetId }) return detail
val ids = (detail.assets.map { it.id } + assetId).distinct()
return sessions.authorized { session ->
api.updateAct("Bearer ${session.accessToken}", actId, UpdateMobileActRequest(ids))
}
}
suspend fun closure(actId: String): MobileActClosure = sessions.authorized { session ->
api.closure("Bearer ${session.accessToken}", actId)
}
suspend fun setResponsible(actId: String, request: MobileResponsibleRequest): MobileActClosure = sessions.authorized { session ->
api.responsible("Bearer ${session.accessToken}", actId, request)
}
suspend fun lock(actId: String): MobileActClosure = sessions.authorized { session ->
api.lock("Bearer ${session.accessToken}", actId)
}
suspend fun signInspector(
actId: String,
png: File,
latitude: Double?,
longitude: Double?,
accuracyM: Double?,
): MobileActClosure = signature(actId, png, latitude, longitude, accuracyM, company = false)
suspend fun signCompany(
actId: String,
png: File,
latitude: Double?,
longitude: Double?,
accuracyM: Double?,
manifestation: String = "CONFORMITY",
statement: String? = null,
): MobileActClosure = signature(
actId = actId,
png = png,
latitude = latitude,
longitude = longitude,
accuracyM = accuracyM,
company = true,
manifestation = manifestation,
statement = statement,
)
suspend fun companyOutcome(actId: String, status: String, reason: String): MobileActClosure = sessions.authorized { session ->
api.companyOutcome(
"Bearer ${session.accessToken}",
actId,
MobileCompanyOutcomeRequest(status, reason.trim()),
)
}
suspend fun seal(actId: String): MobileActClosure = sessions.authorized { session ->
api.sealAct("Bearer ${session.accessToken}", actId, MobileSealActRequest())
}
suspend fun closeVisit(visitId: String): VisitDetail = sessions.authorized { session ->
api.closeVisit("Bearer ${session.accessToken}", visitId, MobileCloseVisitRequest())
}
private suspend fun signature(
actId: String,
png: File,
latitude: Double?,
longitude: Double?,
accuracyM: Double?,
company: Boolean,
manifestation: String? = null,
statement: String? = null,
): MobileActClosure {
val response = sessions.authorized { session ->
val text = "text/plain".toMediaType()
val filePart = MultipartBody.Part.createFormData(
"file",
png.name,
png.asRequestBody("image/png".toMediaType()),
)
val consent = "true".toRequestBody(text)
val signedAt = Instant.now().toString().toRequestBody(text)
val device = "DH Android".toRequestBody(text)
val lat = latitude?.toString()?.toRequestBody(text)
val lon = longitude?.toString()?.toRequestBody(text)
val accuracy = accuracyM?.toString()?.toRequestBody(text)
if (company) {
api.signCompany(
"Bearer ${session.accessToken}", actId, filePart, consent, signedAt,
lat, lon, accuracy, device,
manifestation?.toRequestBody(text),
statement?.trim()?.takeIf { it.isNotBlank() }?.toRequestBody(text),
)
} else {
api.signInspector(
"Bearer ${session.accessToken}", actId, filePart, consent, signedAt,
lat, lon, accuracy, device,
)
}
}
png.delete()
return response
}
}
@@ -0,0 +1,98 @@
package com.korexlabs.dhinspeccion.data
import android.content.Context
import com.korexlabs.dhinspeccion.BuildConfig
import com.squareup.moshi.Moshi
import com.squareup.moshi.kotlin.reflect.KotlinJsonAdapterFactory
import okhttp3.OkHttpClient
import retrofit2.HttpException
import retrofit2.Retrofit
import retrofit2.converter.moshi.MoshiConverterFactory
import retrofit2.http.Body
import retrofit2.http.Header
import retrofit2.http.POST
data class ChangeMobilePasswordRequest(
val currentPassword: String,
val newPassword: String,
)
data class ChangeMobilePasswordResponse(
val status: String,
val mustChangePassword: Boolean,
)
private interface MobileSessionApi {
@POST("auth/mobile/refresh")
suspend fun refresh(@Body request: RefreshRequest): MobileSessionResponse
@POST("auth/change-password")
suspend fun changePassword(
@Header("Authorization") authorization: String,
@Body request: ChangeMobilePasswordRequest,
): ChangeMobilePasswordResponse
}
/**
* Único coordinador de sesión del proceso Android.
*
* Todos los repositorios comparten el mismo gate de refresh para que un token rotativo no sea
* consumido en paralelo por Inventario, Hallazgos y Actas.
*/
class MobileSessionCoordinator private constructor(context: Context) {
private val store = SecureSessionStore(context.applicationContext)
private val gate = SessionRefreshGate()
private val moshi = Moshi.Builder().addLast(KotlinJsonAdapterFactory()).build()
private val api: MobileSessionApi = Retrofit.Builder()
.baseUrl(BuildConfig.API_BASE_URL)
.client(OkHttpClient.Builder().build())
.addConverterFactory(MoshiConverterFactory.create(moshi))
.build()
.create(MobileSessionApi::class.java)
fun currentSession(): StoredSession? = store.load()
fun save(response: MobileSessionResponse): StoredSession = store.save(response)
fun clear() = store.clear()
suspend fun <T> authorized(block: suspend (StoredSession) -> T): T {
var session = store.load() ?: throw IllegalStateException("Sesión no iniciada")
try {
return block(session)
} catch (error: HttpException) {
if (error.code() != 401) throw error
}
session = gate.refreshIfNeeded(
previousRefreshToken = session.refreshToken,
load = store::load,
save = store::save,
clear = store::clear,
refresh = { token -> api.refresh(RefreshRequest(token)) },
)
return block(session)
}
suspend fun changePassword(currentPassword: String, newPassword: String): StoredSession {
val sessionUsed = authorized { session ->
api.changePassword(
authorization = "Bearer ${session.accessToken}",
request = ChangeMobilePasswordRequest(currentPassword, newPassword),
)
session
}
val latest = store.load() ?: sessionUsed
return store.save(latest.copy(mustChangePassword = false))
}
companion object {
@Volatile
private var instance: MobileSessionCoordinator? = null
fun get(context: Context): MobileSessionCoordinator =
instance ?: synchronized(this) {
instance ?: MobileSessionCoordinator(context.applicationContext).also { instance = it }
}
}
}
@@ -0,0 +1,32 @@
package com.korexlabs.dhinspeccion.data
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
/**
* Serializa la rotación del refresh token a nivel de proceso.
*
* El backend rota el token en cada refresh. Si dos repositorios reciben 401 al mismo tiempo,
* sólo el primero debe consumir el token anterior; el segundo reutiliza la sesión ya renovada.
*/
class SessionRefreshGate {
private val mutex = Mutex()
suspend fun refreshIfNeeded(
previousRefreshToken: String,
load: () -> StoredSession?,
save: (MobileSessionResponse) -> StoredSession,
clear: () -> Unit,
refresh: suspend (String) -> MobileSessionResponse,
): StoredSession = mutex.withLock {
val latest = load() ?: throw IllegalStateException("Sesión no iniciada")
if (latest.refreshToken != previousRefreshToken) return@withLock latest
try {
save(refresh(previousRefreshToken))
} catch (error: Throwable) {
clear()
throw error
}
}
}
@@ -0,0 +1,54 @@
package com.korexlabs.dhinspeccion.domain
/**
* UX-side mirrors of server invariants used to prevent invalid field actions before a request is sent.
* The API remains authoritative; these rules must never be used to weaken backend validation.
*/
object MobileWorkflowRules {
private val validUrgencies = setOf("URGENT", "NON_URGENT")
private val terminalCompanyOutcomes = setOf("SIGNED", "REFUSED", "ABSENT")
fun canStartInspection(status: String): Boolean = status == "PLANNED"
fun hasDraftAct(statuses: Iterable<String>): Boolean = statuses.any { it == "DRAFT" }
fun canCreateAct(
visitStatus: String,
actStatuses: Iterable<String>,
hasSelectedInventory: Boolean,
urgency: String,
): Boolean =
visitStatus == "IN_PROGRESS" &&
!hasDraftAct(actStatuses) &&
hasSelectedInventory &&
urgency in validUrgencies
fun canCreateFieldInventory(visitStatus: String): Boolean = visitStatus == "IN_PROGRESS"
fun canRegisterFinding(
visitStatus: String,
selectedActStatus: String?,
inventoryReadyForFinding: Boolean,
): Boolean =
visitStatus == "IN_PROGRESS" &&
selectedActStatus == "DRAFT" &&
inventoryReadyForFinding
fun canLockAct(actStatus: String?, responsibleDefined: Boolean): Boolean =
actStatus == "DRAFT" && responsibleDefined
fun canSealAct(
actStatus: String?,
inspectorSigned: Boolean,
companyOutcomeStatus: String?,
): Boolean =
actStatus == "LOCKED" &&
inspectorSigned &&
companyOutcomeStatus in terminalCompanyOutcomes
fun canCloseInspection(visitStatus: String, actStatuses: Iterable<String>): Boolean {
if (visitStatus != "IN_PROGRESS") return false
val active = actStatuses.filter { it != "CANCELLED" }
return active.isNotEmpty() && active.all { it == "SEALED" }
}
}
@@ -541,7 +541,9 @@ private fun hasLocation(context: Context): Boolean =
hasPermission(context, Manifest.permission.ACCESS_FINE_LOCATION) || hasPermission(context, Manifest.permission.ACCESS_COARSE_LOCATION)
private suspend fun currentGeo(context: Context): GeoSnapshot = suspendCancellableCoroutine { continuation ->
if (!hasLocation(context)) {
val fineGranted = ContextCompat.checkSelfPermission(context, Manifest.permission.ACCESS_FINE_LOCATION) == PackageManager.PERMISSION_GRANTED
val coarseGranted = ContextCompat.checkSelfPermission(context, Manifest.permission.ACCESS_COARSE_LOCATION) == PackageManager.PERMISSION_GRANTED
if (!fineGranted && !coarseGranted) {
continuation.resumeWithException(SecurityException("Se necesita permiso de ubicación."))
return@suspendCancellableCoroutine
}
@@ -76,15 +76,31 @@ private data class F3GeoSnapshot(
@Composable
fun F3VisitRoot(model: MainViewModel) {
var inventoryMode by rememberSaveable(model.visit?.id) { mutableStateOf(false) }
if (inventoryMode) {
F3FieldInventoryScreen(model, onBack = { inventoryMode = false })
} else {
F3VisitOverview(model, onInventory = { inventoryMode = true })
var actsMode by rememberSaveable(model.visit?.id) { mutableStateOf(false) }
when {
actsMode -> MobileActsScreen(
model = model,
onBack = { actsMode = false },
onGoInventory = {
actsMode = false
inventoryMode = true
},
)
inventoryMode -> F3FieldInventoryScreen(model, onBack = { inventoryMode = false })
else -> F3VisitOverview(
model = model,
onInventory = { inventoryMode = true },
onActs = { actsMode = true },
)
}
}
@Composable
private fun F3VisitOverview(model: MainViewModel, onInventory: () -> Unit) {
private fun F3VisitOverview(
model: MainViewModel,
onInventory: () -> Unit,
onActs: () -> Unit,
) {
val visit = model.visit ?: return
Column(
Modifier
@@ -137,9 +153,17 @@ private fun F3VisitOverview(model: MainViewModel, onInventory: () -> Unit) {
Button(onClick = onInventory, modifier = Modifier.fillMaxWidth()) {
Text("Abrir Inventario de campo")
}
OutlinedButton(onClick = onActs, modifier = Modifier.fillMaxWidth()) {
val open = model.acts.count { it.status == "DRAFT" || it.status == "READY" }
Text("Actas de la inspección · ${model.acts.size}${if (open > 0) " · $open abiertas" else ""}")
}
} else if (visit.status == "CLOSED") {
OutlinedButton(onClick = onActs, modifier = Modifier.fillMaxWidth()) {
Text("Ver Actas · ${model.acts.size}")
}
} else if (visit.status == "PLANNED") {
Text(
"Primero iniciá la inspección para habilitar altas, fotografías y Hallazgos.",
"Primero iniciá la inspección para habilitar altas, fotografías, Actas y Hallazgos.",
style = MaterialTheme.typography.bodySmall,
)
}
@@ -697,7 +721,9 @@ private fun f3HasLocation(context: Context): Boolean =
f3HasPermission(context, Manifest.permission.ACCESS_COARSE_LOCATION)
private suspend fun currentF3Geo(context: Context): F3GeoSnapshot = suspendCancellableCoroutine { continuation ->
if (!f3HasLocation(context)) {
val fineGranted = ContextCompat.checkSelfPermission(context, Manifest.permission.ACCESS_FINE_LOCATION) == PackageManager.PERMISSION_GRANTED
val coarseGranted = ContextCompat.checkSelfPermission(context, Manifest.permission.ACCESS_COARSE_LOCATION) == PackageManager.PERMISSION_GRANTED
if (!fineGranted && !coarseGranted) {
continuation.resumeWithException(SecurityException("Se necesita permiso de ubicación."))
return@suspendCancellableCoroutine
}
@@ -383,7 +383,9 @@ private fun findingHasLocation(context: Context): Boolean =
findingHasPermission(context, Manifest.permission.ACCESS_COARSE_LOCATION)
private suspend fun currentFindingGeo(context: Context): FindingGeoSnapshot = suspendCancellableCoroutine { continuation ->
if (!findingHasLocation(context)) {
val fineGranted = ContextCompat.checkSelfPermission(context, Manifest.permission.ACCESS_FINE_LOCATION) == PackageManager.PERMISSION_GRANTED
val coarseGranted = ContextCompat.checkSelfPermission(context, Manifest.permission.ACCESS_COARSE_LOCATION) == PackageManager.PERMISSION_GRANTED
if (!fineGranted && !coarseGranted) {
continuation.resumeWithException(SecurityException("Se necesita permiso de ubicación."))
return@suspendCancellableCoroutine
}
@@ -0,0 +1,445 @@
package com.korexlabs.dhinspeccion.ui
import android.Manifest
import android.content.Context
import android.content.pm.PackageManager
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.AssistChip
import androidx.compose.material3.Button
import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.core.content.ContextCompat
import com.google.android.gms.location.LocationServices
import com.google.android.gms.location.Priority
import com.google.android.gms.tasks.CancellationTokenSource
import com.korexlabs.dhinspeccion.MainViewModel
import kotlinx.coroutines.launch
import kotlinx.coroutines.suspendCancellableCoroutine
import java.io.File
import kotlin.coroutines.resume
import kotlin.coroutines.resumeWithException
private data class ActSignatureGeo(
val latitude: Double,
val longitude: Double,
val accuracyM: Double?,
)
@Composable
fun MobileActsScreen(
model: MainViewModel,
onBack: () -> Unit,
onGoInventory: () -> Unit,
) {
val visit = model.visit ?: return
val selected = model.selectedAct
val closure = model.actClosure
val context = LocalContext.current
val scope = rememberCoroutineScope()
var newActUrgency by rememberSaveable(visit.id) { mutableStateOf("NON_URGENT") }
var attendance by rememberSaveable(selected?.id) {
mutableStateOf(closure?.responsible?.attendanceStatus ?: "PRESENT")
}
var fullName by rememberSaveable(selected?.id) {
mutableStateOf(closure?.responsible?.fullName.orEmpty())
}
var documentType by rememberSaveable(selected?.id) {
mutableStateOf(closure?.responsible?.documentType ?: "DNI")
}
var documentNumber by rememberSaveable(selected?.id) {
mutableStateOf(closure?.responsible?.documentNumber.orEmpty())
}
var position by rememberSaveable(selected?.id) {
mutableStateOf(closure?.responsible?.position.orEmpty())
}
var email by rememberSaveable(selected?.id) {
mutableStateOf(closure?.responsible?.email.orEmpty())
}
var phone by rememberSaveable(selected?.id) {
mutableStateOf(closure?.responsible?.phone.orEmpty())
}
var absenceReason by rememberSaveable(selected?.id) {
mutableStateOf(closure?.responsible?.absenceReason.orEmpty())
}
var refusalReason by rememberSaveable(selected?.id) { mutableStateOf("") }
var manifestation by rememberSaveable(selected?.id) { mutableStateOf("CONFORMITY") }
var dissentStatement by rememberSaveable(selected?.id) { mutableStateOf("") }
LaunchedEffect(visit.id) { model.reloadActs() }
fun signWithGeo(file: File, company: Boolean) {
scope.launch {
val geo = runCatching { currentActSignatureGeo(context) }.getOrNull()
if (company) {
model.signSelectedActAsCompany(
png = file,
latitude = geo?.latitude,
longitude = geo?.longitude,
accuracyM = geo?.accuracyM,
manifestation = manifestation,
statement = dissentStatement.takeIf { manifestation == "DISSENT" },
)
} else {
model.signSelectedActAsInspector(
png = file,
latitude = geo?.latitude,
longitude = geo?.longitude,
accuracyM = geo?.accuracyM,
)
}
}
}
Column(
Modifier
.fillMaxSize()
.verticalScroll(rememberScrollState())
.padding(top = 28.dp, start = 16.dp, end = 16.dp, bottom = 36.dp),
verticalArrangement = Arrangement.spacedBy(12.dp),
) {
Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween) {
OutlinedButton(onClick = onBack, enabled = !model.busy) { Text("Volver") }
Text("Actas", style = MaterialTheme.typography.titleLarge, fontWeight = FontWeight.Bold)
}
Text("${visit.code} · ${visit.operatorCompany?.name.orEmpty()}")
F32ActMessage(model)
Card(Modifier.fillMaxWidth()) {
Column(Modifier.padding(12.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) {
Text("Actas de esta Inspección", fontWeight = FontWeight.Bold)
if (model.acts.isEmpty()) {
Text("Todavía no hay Actas. La primera se inicia sobre una Instalación/Subinstalación seleccionada.")
}
model.acts.forEach { act ->
val active = selected?.id == act.id
val urgency = if (act.urgency == "URGENT") "URGENTE" else "No urgente"
OutlinedButton(
onClick = { model.selectAct(act.id) },
modifier = Modifier.fillMaxWidth(),
) {
Text(
(if (active) "" else "") +
"${act.code} · ${actStatusLabel(act.status)} · $urgency · ${act.findingCount} Hallazgo${if (act.findingCount == 1) "" else "s"}",
)
}
}
}
}
val hasDraftAct = model.acts.any { it.status == "DRAFT" }
if (visit.status == "IN_PROGRESS" && !hasDraftAct) {
Card(
Modifier.fillMaxWidth(),
colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.primaryContainer),
) {
Column(Modifier.padding(12.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) {
Text("Nueva Acta", fontWeight = FontWeight.Bold)
if (model.acts.any { it.status == "LOCKED" }) {
Text(
"Podés abrir una nueva Acta aunque otra esté BLOQUEADA esperando firmas. Sólo se permite un borrador a la vez.",
style = MaterialTheme.typography.bodySmall,
)
}
Text("Urgencia del Acta", fontWeight = FontWeight.Bold)
Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(8.dp)) {
AssistChip(
onClick = { newActUrgency = "NON_URGENT" },
label = { Text(if (newActUrgency == "NON_URGENT") "✓ No urgente" else "No urgente") },
)
AssistChip(
onClick = { newActUrgency = "URGENT" },
label = { Text(if (newActUrgency == "URGENT") "✓ Urgente" else "Urgente") },
)
}
Text(
if (newActUrgency == "URGENT") {
"El plazo urgente se computará desde el Acta según la política institucional vigente al bloquearla."
} else {
"El plazo no urgente se computará desde la oficialización GEDO según la política institucional vigente al bloquearla."
},
style = MaterialTheme.typography.bodySmall,
)
val selectedInventory = model.selectedFieldAsset?.asset
if (selectedInventory == null) {
Text("Primero elegí una Instalación o Subinstalación desde Inventario de campo. Ese registro será el primer elemento del Acta.")
Button(onClick = onGoInventory, modifier = Modifier.fillMaxWidth()) {
Text("Ir a Inventario y elegir")
}
} else {
Text("Inventario inicial: ${selectedInventory.name} · ${selectedInventory.code}")
Button(
onClick = { model.createActForSelectedInventory(newActUrgency) },
enabled = !model.busy,
modifier = Modifier.fillMaxWidth(),
) { Text("Crear nueva Acta") }
}
}
}
}
if (selected != null) {
HorizontalDivider()
Card(Modifier.fillMaxWidth()) {
Column(Modifier.padding(12.dp), verticalArrangement = Arrangement.spacedBy(5.dp)) {
Text(selected.code, style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.Bold)
Text(actStatusLabel(selected.status))
Text(if (selected.urgency == "URGENT") "Urgente" else "No urgente")
Text("${selected.findingCount} Hallazgo${if (selected.findingCount == 1) "" else "s"} · ${selected.assetCount} elemento${if (selected.assetCount == 1) "" else "s"} de Inventario")
selected.deadlineAt?.let { Text("Vencimiento calculado: $it", style = MaterialTheme.typography.bodySmall) }
if (selected.deadlineAt == null && selected.deadlineBasis == "GEDO_DATE") {
Text("Vencimiento pendiente de fecha GEDO.", style = MaterialTheme.typography.bodySmall)
}
selected.lockedSha256?.let { Text("Hash bloqueado: $it", style = MaterialTheme.typography.bodySmall) }
selected.closureSha256?.let { Text("Hash sellado: $it", style = MaterialTheme.typography.bodySmall) }
}
}
when (selected.status) {
"DRAFT" -> {
Text("1. Responsable de la empresa", style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.Bold)
Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(8.dp)) {
AssistChip(onClick = { attendance = "PRESENT" }, label = { Text(if (attendance == "PRESENT") "✓ Presente" else "Presente") })
AssistChip(onClick = { attendance = "ABSENT" }, label = { Text(if (attendance == "ABSENT") "✓ Ausente" else "Ausente") })
}
if (attendance == "PRESENT") {
OutlinedTextField(fullName, { fullName = it }, label = { Text("Nombre y apellido *") }, modifier = Modifier.fillMaxWidth())
Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(6.dp)) {
listOf("DNI", "CUIL", "PASSPORT", "OTHER").forEach { kind ->
AssistChip(onClick = { documentType = kind }, label = { Text(if (documentType == kind) "$kind" else kind) })
}
}
OutlinedTextField(documentNumber, { documentNumber = it }, label = { Text("Documento *") }, modifier = Modifier.fillMaxWidth())
OutlinedTextField(position, { position = it }, label = { Text("Cargo *") }, modifier = Modifier.fillMaxWidth())
OutlinedTextField(email, { email = it }, label = { Text("Email") }, modifier = Modifier.fillMaxWidth())
OutlinedTextField(phone, { phone = it }, label = { Text("Teléfono") }, modifier = Modifier.fillMaxWidth())
Button(
onClick = {
model.setCompanyResponsiblePresent(fullName, documentType, documentNumber, position, email, phone)
},
enabled = !model.busy && fullName.isNotBlank() && documentNumber.isNotBlank() && position.isNotBlank(),
modifier = Modifier.fillMaxWidth(),
) { Text("Guardar responsable") }
} else {
OutlinedTextField(
absenceReason,
{ absenceReason = it },
label = { Text("Motivo de ausencia *") },
minLines = 2,
modifier = Modifier.fillMaxWidth(),
)
Button(
onClick = { model.setCompanyResponsibleAbsent(absenceReason) },
enabled = !model.busy && absenceReason.trim().length >= 10,
modifier = Modifier.fillMaxWidth(),
) { Text("Guardar ausencia") }
}
HorizontalDivider()
Text("2. Hallazgos / verificaciones", style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.Bold)
Text("Los Hallazgos se cargan desde Inventario y quedan vinculados explícitamente a ${selected.code}. El Acta también puede finalizar sin Hallazgos cuando corresponde dejar constancia de una inspección o verificación sin nuevos incumplimientos.")
Button(onClick = onGoInventory, modifier = Modifier.fillMaxWidth()) { Text("Ir a Inventario / Hallazgos") }
HorizontalDivider()
Text("3. Finalizar contenido", style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.Bold)
Text("Al BLOQUEAR el Acta, el contenido y los Hallazgos quedan inmutables. Esta acción no se puede deshacer.")
Button(
onClick = { model.prepareSelectedAct() },
enabled = !model.busy && closure?.responsible != null,
modifier = Modifier.fillMaxWidth(),
) { Text("Finalizar y BLOQUEAR Acta") }
}
"LOCKED" -> {
val signatures = closure?.signatures.orEmpty()
val inspectorSigned = signatures.any { it.signerType == "INSPECTOR" && it.status == "SIGNED" }
val companyOutcome = signatures.firstOrNull { it.signerType == "COMPANY_RESPONSIBLE" }
val companyResolved = companyOutcome?.status == "SIGNED" || companyOutcome?.status == "REFUSED"
Text("Acta BLOQUEADA", style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.Bold)
Text("El contenido ya es inmutable. Sólo resta resolver firmas y manifestaciones para poder SELLARLA.")
closure?.closure?.preparedSha256?.let { Text("Hash bloqueado: $it", style = MaterialTheme.typography.bodySmall) }
HorizontalDivider()
Text("Firma del inspector", fontWeight = FontWeight.Bold)
if (inspectorSigned) {
Text("✓ Firma del inspector registrada", color = MaterialTheme.colorScheme.primary)
} else {
Text(closure?.consents?.inspector.orEmpty(), style = MaterialTheme.typography.bodySmall)
SignaturePad(
label = "Firmá como inspector/a",
enabled = !model.busy,
onCaptured = { file -> signWithGeo(file, company = false) },
)
}
HorizontalDivider()
Text("Manifestación de la empresa", fontWeight = FontWeight.Bold)
if (companyOutcome != null && companyResolved) {
val detail = when (companyOutcome.status) {
"SIGNED" -> if (companyOutcome.companyManifestation == "DISSENT") "Firma en disidencia" else "Firma en conformidad"
"REFUSED" -> "Negativa a firmar"
else -> companyOutcome.status
}
Text("$detail", color = MaterialTheme.colorScheme.primary)
companyOutcome.reason?.let { Text(it, style = MaterialTheme.typography.bodySmall) }
companyOutcome.companyStatement?.let { Text(it, style = MaterialTheme.typography.bodySmall) }
} else if (closure?.responsible?.attendanceStatus == "ABSENT") {
Text(
"El responsable fue registrado como ausente. La ausencia NO resuelve la manifestación: deberá obtenerse firma o negativa posteriormente antes de SELLAR el Acta.",
style = MaterialTheme.typography.bodyMedium,
)
} else {
Text(closure?.consents?.company.orEmpty(), style = MaterialTheme.typography.bodySmall)
Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(8.dp)) {
AssistChip(onClick = { manifestation = "CONFORMITY" }, label = { Text(if (manifestation == "CONFORMITY") "✓ Conforme" else "Conforme") })
AssistChip(onClick = { manifestation = "DISSENT" }, label = { Text(if (manifestation == "DISSENT") "✓ En disidencia" else "En disidencia") })
}
if (manifestation == "DISSENT") {
OutlinedTextField(
dissentStatement,
{ dissentStatement = it },
label = { Text("Manifestación de disidencia *") },
minLines = 2,
modifier = Modifier.fillMaxWidth(),
)
}
SignaturePad(
label = "Firma del responsable de empresa",
enabled = !model.busy && inspectorSigned && (manifestation != "DISSENT" || dissentStatement.trim().length >= 10),
onCaptured = { file -> signWithGeo(file, company = true) },
)
Text("Si la persona presente se niega a firmar, asentá el motivo en lugar de dibujar una firma.", style = MaterialTheme.typography.bodySmall)
OutlinedTextField(
refusalReason,
{ refusalReason = it },
label = { Text("Motivo de negativa") },
minLines = 2,
modifier = Modifier.fillMaxWidth(),
)
OutlinedButton(
onClick = { model.recordCompanyOutcome("REFUSED", refusalReason) },
enabled = !model.busy && inspectorSigned && refusalReason.trim().length >= 10,
modifier = Modifier.fillMaxWidth(),
) { Text("Registrar negativa a firmar") }
}
HorizontalDivider()
if (inspectorSigned && companyResolved) {
Button(
onClick = { model.closeSelectedAct() },
enabled = !model.busy,
modifier = Modifier.fillMaxWidth(),
) { Text("SELLAR Acta definitivamente") }
} else {
Text(
"Esta Acta bloquea el cierre de la Inspección hasta tener firma de inspector y firma o negativa válida de la empresa.",
style = MaterialTheme.typography.bodySmall,
)
}
}
"SEALED" -> {
Card(
Modifier.fillMaxWidth(),
colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.primaryContainer),
) {
Column(Modifier.padding(12.dp), verticalArrangement = Arrangement.spacedBy(5.dp)) {
Text("Acta SELLADA e inmutable", fontWeight = FontWeight.Bold)
Text("Desde este sellado se genera el PDF del Acta y el INF Word editable para el Inspector.")
closure?.closure?.finalSha256?.let { Text("SHA-256: $it", style = MaterialTheme.typography.bodySmall) }
}
}
}
"CANCELLED" -> Text("Esta Acta fue cancelada y permanece sólo como antecedente.")
}
}
if (visit.status == "IN_PROGRESS" && model.acts.isNotEmpty()) {
HorizontalDivider()
val activeActs = model.acts.filter { it.status != "CANCELLED" }
val pendingActs = activeActs.filter { it.status != "SEALED" }
Text("Finalizar Inspección", style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.Bold)
if (pendingActs.isEmpty()) {
Text("Todas las Actas están SELLADAS. La Inspección ya puede cerrarse.")
} else {
Text("No puede cerrarse todavía. Falta SELLAR: ${pendingActs.joinToString { it.code }}")
}
Button(
onClick = { model.closeInspection() },
enabled = !model.busy && activeActs.isNotEmpty() && pendingActs.isEmpty(),
modifier = Modifier.fillMaxWidth(),
) { Text("Cerrar Inspección y salir de la empresa") }
}
}
}
@Composable
private fun F32ActMessage(model: MainViewModel) {
val text = model.error ?: model.notice ?: return
Card(
modifier = Modifier.fillMaxWidth(),
colors = CardDefaults.cardColors(
containerColor = if (model.error != null) MaterialTheme.colorScheme.errorContainer
else MaterialTheme.colorScheme.secondaryContainer,
),
onClick = { model.clearMessages() },
) {
Text(text, Modifier.padding(12.dp))
}
}
private fun actStatusLabel(status: String): String = when (status) {
"DRAFT" -> "Borrador"
"LOCKED" -> "BLOQUEADA"
"SEALED" -> "SELLADA"
"CANCELLED" -> "Cancelada"
else -> status
}
private fun hasActLocation(context: Context): Boolean =
ContextCompat.checkSelfPermission(context, Manifest.permission.ACCESS_FINE_LOCATION) == PackageManager.PERMISSION_GRANTED ||
ContextCompat.checkSelfPermission(context, Manifest.permission.ACCESS_COARSE_LOCATION) == PackageManager.PERMISSION_GRANTED
private suspend fun currentActSignatureGeo(context: Context): ActSignatureGeo = suspendCancellableCoroutine { continuation ->
val fineGranted = ContextCompat.checkSelfPermission(context, Manifest.permission.ACCESS_FINE_LOCATION) == PackageManager.PERMISSION_GRANTED
val coarseGranted = ContextCompat.checkSelfPermission(context, Manifest.permission.ACCESS_COARSE_LOCATION) == PackageManager.PERMISSION_GRANTED
if (!fineGranted && !coarseGranted) {
continuation.resumeWithException(SecurityException("Ubicación no autorizada"))
return@suspendCancellableCoroutine
}
val source = CancellationTokenSource()
val client = LocationServices.getFusedLocationProviderClient(context)
client.getCurrentLocation(Priority.PRIORITY_HIGH_ACCURACY, source.token)
.addOnSuccessListener { location ->
if (!continuation.isActive) return@addOnSuccessListener
if (location == null) continuation.resumeWithException(IllegalStateException("Ubicación no disponible"))
else continuation.resume(ActSignatureGeo(location.latitude, location.longitude, location.accuracy.toDouble()))
}
.addOnFailureListener { if (continuation.isActive) continuation.resumeWithException(it) }
continuation.invokeOnCancellation { source.cancel() }
}
@@ -0,0 +1,137 @@
package com.korexlabs.dhinspeccion.ui
import android.graphics.Bitmap
import android.graphics.Canvas as AndroidCanvas
import android.graphics.Color as AndroidColor
import android.graphics.Paint
import androidx.compose.foundation.Canvas
import androidx.compose.foundation.background
import androidx.compose.foundation.gestures.detectDragGestures
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.Button
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateListOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.StrokeCap
import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.layout.onSizeChanged
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.unit.IntSize
import androidx.compose.ui.unit.dp
import java.io.File
import java.io.FileOutputStream
@Composable
fun SignaturePad(
label: String,
enabled: Boolean = true,
onCaptured: (File) -> Unit,
) {
val context = LocalContext.current
val strokes = remember { mutableStateListOf<List<Offset>>() }
var currentStroke by remember { mutableStateOf<List<Offset>>(emptyList()) }
var canvasSize by remember { mutableStateOf(IntSize.Zero) }
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
Text(label, fontWeight = androidx.compose.ui.text.font.FontWeight.SemiBold)
Text(
"Firmá dentro del recuadro. La imagen se guarda como PNG y se incorpora al hash del Acta.",
style = MaterialTheme.typography.bodySmall,
)
Canvas(
modifier = Modifier
.fillMaxWidth()
.height(190.dp)
.background(Color.White)
.onSizeChanged { canvasSize = it }
.pointerInput(enabled) {
if (!enabled) return@pointerInput
detectDragGestures(
onDragStart = { position -> currentStroke = listOf(position) },
onDrag = { change, _ ->
change.consume()
currentStroke = currentStroke + change.position
},
onDragEnd = {
if (currentStroke.size > 1) strokes.add(currentStroke)
currentStroke = emptyList()
},
onDragCancel = { currentStroke = emptyList() },
)
},
) {
val all = strokes + listOf(currentStroke)
all.forEach { stroke ->
stroke.zipWithNext().forEach { (start, end) ->
drawLine(
color = Color.Black,
start = start,
end = end,
strokeWidth = 4.dp.toPx(),
cap = StrokeCap.Round,
)
}
}
}
Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(8.dp)) {
OutlinedButton(
onClick = { strokes.clear(); currentStroke = emptyList() },
enabled = enabled && (strokes.isNotEmpty() || currentStroke.isNotEmpty()),
modifier = Modifier.weight(1f),
) { Text("Limpiar") }
Button(
onClick = {
val width = canvasSize.width.coerceAtLeast(1)
val height = canvasSize.height.coerceAtLeast(1)
val targetWidth = 1000
val targetHeight = 400
val scaleX = targetWidth.toFloat() / width.toFloat()
val scaleY = targetHeight.toFloat() / height.toFloat()
val bitmap = Bitmap.createBitmap(targetWidth, targetHeight, Bitmap.Config.ARGB_8888)
val native = AndroidCanvas(bitmap)
native.drawColor(AndroidColor.WHITE)
val paint = Paint(Paint.ANTI_ALIAS_FLAG).apply {
color = AndroidColor.BLACK
strokeWidth = 7f
strokeCap = Paint.Cap.ROUND
strokeJoin = Paint.Join.ROUND
style = Paint.Style.STROKE
}
strokes.forEach { stroke ->
stroke.zipWithNext().forEach { (start, end) ->
native.drawLine(
start.x * scaleX,
start.y * scaleY,
end.x * scaleX,
end.y * scaleY,
paint,
)
}
}
val file = File.createTempFile("DH_FIRMA_", ".png", context.cacheDir)
FileOutputStream(file).use { stream ->
check(bitmap.compress(Bitmap.CompressFormat.PNG, 100, stream))
}
bitmap.recycle()
onCaptured(file)
},
enabled = enabled && strokes.isNotEmpty(),
modifier = Modifier.weight(1f),
) { Text("Usar firma") }
}
}
}
@@ -0,0 +1,20 @@
package com.korexlabs.dhinspeccion
import org.junit.Assert.assertEquals
import org.junit.Assert.assertTrue
import org.junit.Test
class ReleaseMetadataTest {
@Test
fun debugRcKeepsSeparateApplicationIdentity() {
assertEquals("com.korexlabs.dhinspeccion.debug", BuildConfig.APPLICATION_ID)
assertEquals(20, BuildConfig.VERSION_CODE)
assertEquals("0.13.0-rc1-debug", BuildConfig.VERSION_NAME)
}
@Test
fun rcTargetsOnlyTheHttpsProductionApi() {
assertEquals("https://dhv2.korexlabs.com/api/v3/", BuildConfig.API_BASE_URL)
assertTrue(BuildConfig.API_BASE_URL.startsWith("https://"))
}
}
@@ -0,0 +1,62 @@
package com.korexlabs.dhinspeccion.data
import java.util.concurrent.atomic.AtomicInteger
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.async
import kotlinx.coroutines.awaitAll
import kotlinx.coroutines.delay
import kotlinx.coroutines.runBlocking
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNotNull
import org.junit.Test
class SessionRefreshGateTest {
@Test
fun simultaneous401sConsumeTheRotatingRefreshTokenOnlyOnce() = runBlocking {
val gate = SessionRefreshGate()
val calls = AtomicInteger(0)
var stored: StoredSession? = StoredSession(
userId = "u-1",
username = "inspector",
displayName = "Inspector",
accessToken = "access-old",
refreshToken = "refresh-old",
)
fun response() = MobileSessionResponse(
user = MobileUser(id = "u-1", username = "inspector"),
accessToken = "access-new",
refreshToken = "refresh-new",
accessExpiresInSeconds = 900,
)
val results = listOf(1, 2).map {
async(Dispatchers.Default) {
gate.refreshIfNeeded(
previousRefreshToken = "refresh-old",
load = { stored },
save = { refreshed ->
StoredSession(
userId = refreshed.user.id,
username = refreshed.user.username,
displayName = refreshed.user.username,
accessToken = refreshed.accessToken,
refreshToken = refreshed.refreshToken,
).also { stored = it }
},
clear = { stored = null },
refresh = {
calls.incrementAndGet()
delay(40)
response()
},
)
}
}.awaitAll()
assertEquals(1, calls.get())
assertEquals(listOf("refresh-new", "refresh-new"), results.map { it.refreshToken })
assertNotNull(stored)
assertEquals("access-new", stored?.accessToken)
}
}
@@ -0,0 +1,90 @@
package com.korexlabs.dhinspeccion.domain
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
import org.junit.Test
class MobileWorkflowRulesTest {
@Test
fun plannedInspectionCanStartButRunningOrClosedCannot() {
assertTrue(MobileWorkflowRules.canStartInspection("PLANNED"))
assertFalse(MobileWorkflowRules.canStartInspection("IN_PROGRESS"))
assertFalse(MobileWorkflowRules.canStartInspection("CLOSED"))
}
@Test
fun onlyOneDraftActIsAllowedPerInspection() {
assertFalse(MobileWorkflowRules.hasDraftAct(listOf("SEALED", "LOCKED")))
assertTrue(MobileWorkflowRules.hasDraftAct(listOf("SEALED", "DRAFT", "LOCKED")))
}
@Test
fun creatingAnActRequiresRunningInspectionInventoryValidUrgencyAndNoDraft() {
assertTrue(
MobileWorkflowRules.canCreateAct(
visitStatus = "IN_PROGRESS",
actStatuses = listOf("SEALED", "LOCKED"),
hasSelectedInventory = true,
urgency = "NON_URGENT",
),
)
assertTrue(
MobileWorkflowRules.canCreateAct(
visitStatus = "IN_PROGRESS",
actStatuses = emptyList(),
hasSelectedInventory = true,
urgency = "URGENT",
),
)
assertFalse(MobileWorkflowRules.canCreateAct("PLANNED", emptyList(), true, "URGENT"))
assertFalse(MobileWorkflowRules.canCreateAct("IN_PROGRESS", listOf("DRAFT"), true, "URGENT"))
assertFalse(MobileWorkflowRules.canCreateAct("IN_PROGRESS", emptyList(), false, "URGENT"))
assertFalse(MobileWorkflowRules.canCreateAct("IN_PROGRESS", emptyList(), true, "UNKNOWN"))
}
@Test
fun fieldInventoryCanOnlyBeCreatedWhileInspectionIsRunning() {
assertTrue(MobileWorkflowRules.canCreateFieldInventory("IN_PROGRESS"))
assertFalse(MobileWorkflowRules.canCreateFieldInventory("PLANNED"))
assertFalse(MobileWorkflowRules.canCreateFieldInventory("CLOSED"))
}
@Test
fun findingsRequireRunningInspectionDraftActAndReadyInventory() {
assertTrue(MobileWorkflowRules.canRegisterFinding("IN_PROGRESS", "DRAFT", true))
assertFalse(MobileWorkflowRules.canRegisterFinding("PLANNED", "DRAFT", true))
assertFalse(MobileWorkflowRules.canRegisterFinding("IN_PROGRESS", "LOCKED", true))
assertFalse(MobileWorkflowRules.canRegisterFinding("IN_PROGRESS", "DRAFT", false))
assertFalse(MobileWorkflowRules.canRegisterFinding("IN_PROGRESS", null, true))
}
@Test
fun actCanOnlyLockFromDraftAfterResponsibleWasResolved() {
assertTrue(MobileWorkflowRules.canLockAct("DRAFT", true))
assertFalse(MobileWorkflowRules.canLockAct("DRAFT", false))
assertFalse(MobileWorkflowRules.canLockAct("LOCKED", true))
assertFalse(MobileWorkflowRules.canLockAct("SEALED", true))
}
@Test
fun actCanOnlySealAfterInspectorAndCompanyOutcomeAreResolved() {
assertTrue(MobileWorkflowRules.canSealAct("LOCKED", true, "SIGNED"))
assertTrue(MobileWorkflowRules.canSealAct("LOCKED", true, "REFUSED"))
assertTrue(MobileWorkflowRules.canSealAct("LOCKED", true, "ABSENT"))
assertFalse(MobileWorkflowRules.canSealAct("DRAFT", true, "SIGNED"))
assertFalse(MobileWorkflowRules.canSealAct("LOCKED", false, "SIGNED"))
assertFalse(MobileWorkflowRules.canSealAct("LOCKED", true, null))
assertFalse(MobileWorkflowRules.canSealAct("LOCKED", true, "PENDING"))
}
@Test
fun inspectionClosesOnlyWhenEveryNonCancelledActIsSealed() {
assertTrue(MobileWorkflowRules.canCloseInspection("IN_PROGRESS", listOf("SEALED")))
assertTrue(MobileWorkflowRules.canCloseInspection("IN_PROGRESS", listOf("SEALED", "CANCELLED", "SEALED")))
assertFalse(MobileWorkflowRules.canCloseInspection("IN_PROGRESS", emptyList()))
assertFalse(MobileWorkflowRules.canCloseInspection("IN_PROGRESS", listOf("CANCELLED")))
assertFalse(MobileWorkflowRules.canCloseInspection("IN_PROGRESS", listOf("SEALED", "LOCKED")))
assertFalse(MobileWorkflowRules.canCloseInspection("IN_PROGRESS", listOf("SEALED", "DRAFT")))
assertFalse(MobileWorkflowRules.canCloseInspection("CLOSED", listOf("SEALED")))
}
}
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "dhv2-api",
"version": "0.24.0-1",
"version": "0.25.0-1",
"private": true,
"license": "UNLICENSED",
"scripts": {
@@ -49,7 +49,7 @@ export class ActAdministrationService {
COUNT(*) FILTER (WHERE f.status = 'OPEN' AND f.next_control_on IS NOT NULL) AS scheduled_control_count
FROM inspection_findings f WHERE f.act_id = ia.id
) fc ON true
WHERE ia.status IN ('CLOSED', 'RECTIFIED')
WHERE ia.status IN ('SEALED', 'CLOSED', 'RECTIFIED')
), classified AS (
SELECT *, CASE
WHEN "findingCount" > 0 AND "openFindingCount" = 0 THEN 'REGULARIZED'
@@ -110,7 +110,7 @@ export class ActAdministrationService {
const rows = await this.dataSource.query(`SELECT id, code, status FROM inspection_acts WHERE id = $1`, [actId]);
const act = rows[0];
if (!act) throw new NotFoundException({ code: 'ACT_NOT_FOUND', message: 'Acta inexistente.' });
if (!['CLOSED', 'RECTIFIED'].includes(act.status)) throw new BadRequestException({ code: 'ACT_ADMIN_REQUIRES_CLOSED', message: 'El seguimiento administrativo comienza cuando el Acta está cerrada.' });
if (!['SEALED', 'CLOSED', 'RECTIFIED'].includes(act.status)) throw new BadRequestException({ code: 'ACT_ADMIN_REQUIRES_SEALED', message: 'El seguimiento administrativo comienza cuando el Acta está sellada.' });
return act;
}
@@ -129,7 +129,7 @@ export class FieldBriefingService {
ORDER BY company_response.received_on DESC, company_response.created_at DESC, company_response.id DESC
LIMIT 1
) response ON true
WHERE act.status IN ('CLOSED', 'RECTIFIED')
WHERE act.status IN ('SEALED', 'CLOSED', 'RECTIFIED')
AND source_visit.id <> $1
AND source_visit.operational_area_id = $2::uuid
AND source_visit.operator_company_id = $3::uuid
-4
View File
@@ -15,8 +15,6 @@ import { HealthController } from './health.controller';
import { HealthService } from './health.service';
import { DashboardModule } from './dashboard/dashboard.module';
import { AssetMasterModule } from './asset-master/asset-master.module';
import { SurveyPlanningModule } from './survey-planning/survey-planning.module';
import { SurveyExecutionModule } from './survey-execution/survey-execution.module';
import { InspectionVisitsModule } from './inspection-visits/inspection-visits.module';
import { InspectionActsModule } from './inspection-acts/inspection-acts.module';
import { InspectionFindingsModule } from './inspection-findings/inspection-findings.module';
@@ -69,8 +67,6 @@ function required(config: ConfigService, key: string): string {
AdministrationModule,
DashboardModule,
AssetMasterModule,
SurveyPlanningModule,
SurveyExecutionModule,
InspectionVisitsModule,
InspectionActsModule,
InspectionFindingsModule,
+55 -158
View File
@@ -41,14 +41,14 @@ export interface AssetVersionDetail extends AssetVersionSummary {
function assetNotFound(): NotFoundException {
return new NotFoundException({
code: 'ASSET_NOT_FOUND',
message: 'Activo no encontrado',
message: 'Inventario no encontrado',
});
}
function versionNotFound(): NotFoundException {
return new NotFoundException({
code: 'ASSET_VERSION_NOT_FOUND',
message: 'Versión de activo no encontrada',
message: 'Versión de Inventario no encontrada',
});
}
@@ -79,7 +79,7 @@ export class AssetHistoryService {
const versionNumber = Number(versionRow.current_version);
if (!Number.isInteger(versionNumber) || versionNumber < 1) {
throw new Error(`Versión de activo inválida después de incrementar: ${versionRow.current_version}`);
throw new Error(`Versión de Inventario inválida después de incrementar: ${versionRow.current_version}`);
}
const snapshot = await this.loadCurrentSnapshot(manager, assetId);
@@ -101,17 +101,8 @@ export class AssetHistoryService {
asset_id, version_number, change_type, changed_fields, snapshot,
actor_user_id, actor_username, source, request_id
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)`,
[
assetId,
versionNumber,
changeType,
changedFields,
snapshot,
principal.userId,
principal.username,
source,
request.requestId,
],
[assetId, versionNumber, changeType, changedFields, snapshot,
principal.userId, principal.username, source, request.requestId],
);
return versionNumber;
}
@@ -123,38 +114,21 @@ export class AssetHistoryService {
parameters.push(value);
return `$${parameters.length}`;
};
if (query.search?.trim()) {
const search = add(`%${query.search.trim()}%`);
conditions.push(`(
version.snapshot->>'code' ILIKE ${search}
OR version.snapshot->>'name' ILIKE ${search}
OR version.actor_username ILIKE ${search}
)`);
}
if (query.typeId) {
conditions.push(`version.snapshot #>> '{type,id}' = ${add(query.typeId)}`);
}
if (query.status) {
conditions.push(`version.snapshot->>'informationStatus' = ${add(query.status)}`);
}
if (query.changeType) {
conditions.push(`version.change_type = ${add(query.changeType)}`);
conditions.push(`(version.snapshot->>'code' ILIKE ${search} OR version.snapshot->>'name' ILIKE ${search} OR version.actor_username ILIKE ${search})`);
}
if (query.typeId) conditions.push(`version.snapshot #>> '{type,id}' = ${add(query.typeId)}`);
if (query.status) conditions.push(`version.snapshot->>'informationStatus' = ${add(query.status)}`);
if (query.changeType) conditions.push(`version.change_type = ${add(query.changeType)}`);
if (query.from) conditions.push(`version.occurred_at >= ${add(new Date(query.from))}`);
if (query.to) conditions.push(`version.occurred_at <= ${add(new Date(query.to))}`);
return this.listWithConditions(query.page, query.pageSize, conditions, parameters);
}
async listForAsset(assetId: string, query: AssetVersionPageQueryDto) {
await this.requireAsset(assetId);
return this.listWithConditions(
query.page,
query.pageSize,
['version.asset_id = $1'],
[assetId],
);
return this.listWithConditions(query.page, query.pageSize, ['version.asset_id = $1'], [assetId]);
}
async getVersion(assetId: string, versionNumber: number): Promise<AssetVersionDetail> {
@@ -170,17 +144,9 @@ export class AssetHistoryService {
return row;
}
private async listWithConditions(
page: number,
pageSize: number,
conditions: string[],
parameters: unknown[],
) {
private async listWithConditions(page: number, pageSize: number, conditions: string[], parameters: unknown[]) {
const where = conditions.length ? `WHERE ${conditions.join(' AND ')}` : '';
const [countRow] = (await this.dataSource.query(
`SELECT COUNT(*)::integer AS total FROM asset_versions version ${where}`,
parameters,
)) as Array<{ total: number }>;
const [countRow] = (await this.dataSource.query(`SELECT COUNT(*)::integer AS total FROM asset_versions version ${where}`, parameters)) as Array<{ total: number }>;
const total = Number(countRow?.total ?? 0);
const paginated = [...parameters, pageSize, (page - 1) * pageSize];
const limit = `$${parameters.length + 1}`;
@@ -194,51 +160,29 @@ export class AssetHistoryService {
LIMIT ${limit} OFFSET ${offset}`,
paginated,
)) as AssetVersionSummary[];
return {
data,
meta: {
page,
pageSize,
total,
totalPages: total === 0 ? 0 : Math.ceil(total / pageSize),
},
};
return { data, meta: { page, pageSize, total, totalPages: total === 0 ? 0 : Math.ceil(total / pageSize) } };
}
private selectSummary(): string {
return `SELECT
version.id,
version.asset_id AS "assetId",
version.snapshot->>'code' AS "assetCode",
version.snapshot->>'name' AS "assetName",
version.snapshot #>> '{type,id}' AS "typeId",
version.snapshot #>> '{type,name}' AS "typeName",
version.id, version.asset_id AS "assetId",
version.snapshot->>'code' AS "assetCode", version.snapshot->>'name' AS "assetName",
version.snapshot #>> '{type,id}' AS "typeId", version.snapshot #>> '{type,name}' AS "typeName",
version.snapshot->>'informationStatus' AS "informationStatus",
version.snapshot->>'operationalStatus' AS "operationalStatus",
version.version_number AS "versionNumber",
version.change_type AS "changeType",
version.changed_fields AS "changedFields",
version.occurred_at AS "occurredAt",
version.actor_user_id AS "actorUserId",
version.actor_username AS "actorUsername",
version.source,
version.request_id AS "requestId",
version.version_number AS "versionNumber", version.change_type AS "changeType",
version.changed_fields AS "changedFields", version.occurred_at AS "occurredAt",
version.actor_user_id AS "actorUserId", version.actor_username AS "actorUsername",
version.source, version.request_id AS "requestId",
(version.version_number = current_asset.current_version) AS "isCurrent"`;
}
private async requireAsset(assetId: string): Promise<void> {
const [row] = (await this.dataSource.query(
'SELECT 1 FROM assets WHERE id = $1',
[assetId],
)) as unknown[];
const [row] = (await this.dataSource.query('SELECT 1 FROM assets WHERE id = $1', [assetId])) as unknown[];
if (!row) throw assetNotFound();
}
private async loadCurrentSnapshot(
manager: EntityManager,
assetId: string,
): Promise<Record<string, unknown>> {
private async loadCurrentSnapshot(manager: EntityManager, assetId: string): Promise<Record<string, unknown>> {
const [row] = (await manager.query(
`SELECT JSONB_BUILD_OBJECT(
'id', asset.id,
@@ -246,82 +190,49 @@ export class AssetHistoryService {
'name', asset.name,
'commonName', asset.common_name,
'description', asset.description,
'type', JSONB_BUILD_OBJECT(
'id', asset_type.id,
'code', asset_type.code,
'name', asset_type.name,
'operationalRole', asset_type.operational_role
),
'parent', CASE WHEN parent.id IS NULL THEN NULL ELSE JSONB_BUILD_OBJECT(
'id', parent.id,
'code', parent.code,
'name', parent.name
) END,
'operationalArea', CASE WHEN operational_area.id IS NULL THEN NULL ELSE JSONB_BUILD_OBJECT(
'id', operational_area.id,
'code', operational_area.code,
'name', operational_area.name
) END,
'operatorCompany', CASE WHEN operator_company.id IS NULL THEN NULL ELSE JSONB_BUILD_OBJECT(
'id', operator_company.id,
'code', operator_company.code,
'name', operator_company.name
) END,
'type', JSONB_BUILD_OBJECT('id', asset_type.id,'code', asset_type.code,'name', asset_type.name,'operationalRole', asset_type.operational_role),
'parent', CASE WHEN parent.id IS NULL THEN NULL ELSE JSONB_BUILD_OBJECT('id', parent.id,'code', parent.code,'name', parent.name) END,
'operationalArea', CASE WHEN operational_area.id IS NULL THEN NULL ELSE JSONB_BUILD_OBJECT('id', operational_area.id,'code', operational_area.code,'name', operational_area.name) END,
'operatorCompany', CASE WHEN operator_company.id IS NULL THEN NULL ELSE JSONB_BUILD_OBJECT('id', operator_company.id,'code', operator_company.code,'name', operator_company.name) END,
'informationStatus', asset.information_status,
'operationalStatus', asset.operational_status,
'currentFunction', (
SELECT JSONB_BUILD_OBJECT(
'assignmentId', assignment.id,
'id', fn.id,
'code', fn.code,
'name', fn.name,
'validFrom', assignment.valid_from,
'reason', assignment.change_reason
)
FROM inventory_function_assignments assignment
JOIN inventory_functions fn ON fn.id=assignment.function_id
WHERE assignment.asset_id=asset.id AND assignment.valid_until IS NULL
ORDER BY assignment.valid_from DESC LIMIT 1
),
'attributes', COALESCE((
SELECT JSONB_AGG(JSONB_BUILD_OBJECT(
'definitionId', definition.id,
'code', definition.code,
'name', definition.name,
'dataType', definition.data_type,
'isRequired', definition.is_required,
'unit', definition.unit,
'options', definition.options,
'sortOrder', definition.sort_order,
'value', value.value
'definitionId', definition.id,'code', definition.code,'name', definition.name,
'dataType', definition.data_type,'isRequired', definition.is_required,
'unit', definition.unit,'options', definition.options,'sortOrder', definition.sort_order,'value', value.value
) ORDER BY definition.sort_order, definition.name)
FROM asset_attribute_definitions definition
LEFT JOIN asset_attribute_values value
ON value.definition_id = definition.id
AND value.asset_id = asset.id
WHERE definition.asset_type_id = asset.asset_type_id
AND definition.is_active = true
LEFT JOIN asset_attribute_values value ON value.definition_id = definition.id AND value.asset_id = asset.id
WHERE definition.asset_type_id = asset.asset_type_id AND definition.is_active = true
), '[]'::jsonb),
'geometry', CASE WHEN geometry.asset_id IS NULL THEN NULL ELSE JSONB_BUILD_OBJECT(
'assetId', geometry.asset_id,
'geometry', ST_AsGeoJSON(geometry.geometry)::jsonb,
'geometryType', geometry.geometry_type,
'source', geometry.source,
'accuracyM', geometry.accuracy_m::double precision,
'capturedAt', geometry.captured_at,
'deviceLabel', geometry.device_label,
'createdAt', geometry.created_at,
'updatedAt', geometry.updated_at,
'updatedBy', geometry.updated_by
'assetId', geometry.asset_id,'geometry', ST_AsGeoJSON(geometry.geometry)::jsonb,'geometryType', geometry.geometry_type,
'source', geometry.source,'accuracyM', geometry.accuracy_m::double precision,'capturedAt', geometry.captured_at,
'deviceLabel', geometry.device_label,'createdAt', geometry.created_at,'updatedAt', geometry.updated_at,'updatedBy', geometry.updated_by
) END,
'media', COALESCE((
SELECT JSONB_AGG(JSONB_BUILD_OBJECT(
'id', media.id,
'kind', media.kind,
'originalName', media.original_name,
'mimeType', media.mime_type,
'sizeBytes', media.size_bytes,
'sha256', media.sha256,
'title', media.title,
'description', media.description,
'capturedAt', media.captured_at,
'latitude', media.latitude,
'longitude', media.longitude,
'accuracyM', media.accuracy_m,
'source', media.source,
'uploadedBy', media.uploaded_by,
'createdAt', media.created_at,
'updatedAt', media.updated_at
'id', media.id,'kind', media.kind,'originalName', media.original_name,'mimeType', media.mime_type,
'sizeBytes', media.size_bytes,'sha256', media.sha256,'title', media.title,'description', media.description,
'capturedAt', media.captured_at,'latitude', media.latitude,'longitude', media.longitude,'accuracyM', media.accuracy_m,
'source', media.source,'uploadedBy', media.uploaded_by,'createdAt', media.created_at,'updatedAt', media.updated_at
) ORDER BY media.created_at, media.id)
FROM asset_media media
WHERE media.asset_id = asset.id
AND media.deleted_at IS NULL
FROM asset_media media WHERE media.asset_id = asset.id AND media.deleted_at IS NULL
), '[]'::jsonb),
'organizationProfile', (SELECT TO_JSONB(profile) - 'created_at' - 'updated_at' FROM organization_profiles profile WHERE profile.asset_id=asset.id),
'organizationMemberships', COALESCE((
@@ -337,22 +248,8 @@ export class AssetHistoryService {
'legalRights', COALESCE((
SELECT JSONB_AGG(JSONB_BUILD_OBJECT('id',r.id,'rightType',r.right_type,'name',r.name,'instrumentNumber',r.instrument_number,'validFrom',r.valid_from,'validUntil',r.valid_until,'status',r.status,'sourceDocumentId',r.source_document_id,'notes',r.notes,'organizations',COALESCE((SELECT JSONB_AGG(JSONB_BUILD_OBJECT('id',o.id,'organizationId',o.organization_id,'role',o.role,'participationPercent',o.participation_percent::double precision,'validFrom',o.valid_from,'validUntil',o.valid_until,'notes',o.notes,'endReason',o.end_reason) ORDER BY o.valid_until NULLS FIRST,o.valid_from DESC) FROM area_legal_right_organizations o WHERE o.right_id=r.id),'[]'::jsonb)) ORDER BY r.valid_until DESC NULLS FIRST,r.valid_from DESC NULLS LAST) FROM area_legal_rights r WHERE r.area_id=asset.id
),'[]'::jsonb),
'provenance', JSONB_BUILD_OBJECT(
'origin', asset.data_origin,
'sourceName', asset.source_name,
'sourceReference', asset.source_reference,
'observedAt', asset.source_observed_at,
'notes', asset.source_notes,
'verifiedAt', asset.provenance_verified_at,
'verifiedBy', asset.provenance_verified_by,
'updatedAt', asset.provenance_updated_at,
'updatedBy', asset.provenance_updated_by
),
'createdAt', asset.created_at,
'updatedAt', asset.updated_at,
'createdBy', asset.created_by,
'updatedBy', asset.updated_by,
'currentVersion', asset.current_version
'provenance', JSONB_BUILD_OBJECT('origin', asset.data_origin,'sourceName', asset.source_name,'sourceReference', asset.source_reference,'observedAt', asset.source_observed_at,'notes', asset.source_notes,'verifiedAt', asset.provenance_verified_at,'verifiedBy', asset.provenance_verified_by,'updatedAt', asset.provenance_updated_at,'updatedBy', asset.provenance_updated_by),
'createdAt', asset.created_at,'updatedAt', asset.updated_at,'createdBy', asset.created_by,'updatedBy', asset.updated_by,'currentVersion', asset.current_version
) AS snapshot
FROM assets asset
INNER JOIN asset_types asset_type ON asset_type.id = asset.asset_type_id
@@ -29,6 +29,8 @@ import { InventoryFamilyCatalogService } from './inventory-family-catalog.servic
import { FieldInventoryMergeController, InventoryMergeController } from './inventory-merge.controller';
import { InventoryMergeService } from './inventory-merge.service';
import { MergedInventoryDossierService } from './merged-inventory-dossier.service';
import { InventoryFunctionController } from './inventory-function.controller';
import { InventoryFunctionService } from './inventory-function.service';
@Module({
imports: [AuditModule],
@@ -37,6 +39,7 @@ import { MergedInventoryDossierService } from './merged-inventory-dossier.servic
AssetsController,
InventoryStructureController,
InventoryFamilyCatalogController,
InventoryFunctionController,
InventoryMergeController,
FieldInventoryMergeController,
AssetGeometriesController,
@@ -53,6 +56,7 @@ import { MergedInventoryDossierService } from './merged-inventory-dossier.servic
AssetsService,
InventoryStructureService,
InventoryFamilyCatalogService,
InventoryFunctionService,
InventoryMergeService,
MergedInventoryDossierService,
AssetGeometriesService,
@@ -67,6 +71,7 @@ import { MergedInventoryDossierService } from './merged-inventory-dossier.servic
exports: [
AssetHistoryService,
AssetsService,
InventoryFunctionService,
InventoryMergeService,
MergedInventoryDossierService,
AssetGeometriesService,
+6 -6
View File
@@ -298,7 +298,7 @@ export class AssetsService {
const [visits, acts, findings, evidence, communications, verificationResults, documents, inspectionReports, media, versions] = await Promise.all([
this.dataSource.query(
`SELECT DISTINCT visit.id, visit.code, visit.title, visit.status,
`SELECT DISTINCT visit.id, visit.code, visit.status,
visit.planned_start_at AS "plannedStartAt",
visit.actual_started_at AS "actualStartedAt",
visit.actual_closed_at AS "actualClosedAt",
@@ -322,7 +322,7 @@ export class AssetsService {
`SELECT DISTINCT act.id, act.visit_id AS "visitId", act.code, act.status,
act.occurred_at AS "occurredAt", act.title, act.summary,
act.closed_at AS "closedAt", act.current_version AS "currentVersion",
visit.code AS "visitCode", visit.title AS "visitTitle"
visit.code AS "visitCode"
FROM inspection_acts act
JOIN inspection_visits visit ON visit.id = act.visit_id
LEFT JOIN inspection_act_assets act_asset
@@ -342,7 +342,7 @@ export class AssetsService {
finding.closed_at AS "closedAt", finding.closure_notes AS "closureNotes",
finding.created_at AS "createdAt", finding.updated_at AS "updatedAt",
act.code AS "actCode", act.occurred_at AS "actOccurredAt",
visit.id AS "visitId", visit.code AS "visitCode", visit.title AS "visitTitle"
visit.id AS "visitId", visit.code AS "visitCode"
FROM inspection_findings finding
JOIN inspection_acts act ON act.id = finding.act_id
JOIN inspection_visits visit ON visit.id = act.visit_id
@@ -389,7 +389,7 @@ export class AssetsService {
verification_link.result_recorded_at AS "resultRecordedAt",
verification_link.rescheduled_control_on AS "rescheduledControlOn",
finding.code AS "findingCode", finding.title AS "findingTitle",
visit.code AS "visitCode", visit.title AS "visitTitle", visit.status AS "visitStatus",
visit.code AS "visitCode", visit.status AS "visitStatus",
(SELECT COUNT(*)::integer
FROM inspection_finding_evidence verification_evidence
WHERE verification_evidence.finding_id = finding.id
@@ -484,7 +484,7 @@ export class AssetsService {
kind: 'INSPECTION',
occurredAt: visit.actualStartedAt ?? visit.plannedStartAt ?? visit.createdAt,
title: `Inspección ${String(visit.code)}`,
description: visit.title,
description: null,
href: `/inspecciones/${String(visit.id)}`,
meta: { status: visit.status },
}));
@@ -867,7 +867,7 @@ export class AssetsService {
private fieldDiscoverySelect(): string {
return `SELECT discovery.id, discovery.status, discovery.discovery_notes AS "discoveryNotes", discovery.observed_at AS "observedAt", discovery.reviewed_at AS "reviewedAt", discovery.review_notes AS "reviewNotes", discovery.created_at AS "createdAt",
JSONB_BUILD_OBJECT('id',asset.id,'code',asset.code,'name',asset.name,'commonName',asset.common_name,'informationStatus',asset.information_status,'typeId',asset.asset_type_id,'typeName',asset_type.name,'parentId',asset.parent_id,'operationalAreaId',asset.operational_area_id,'operatorCompanyId',asset.operator_company_id) AS asset,
JSONB_BUILD_OBJECT('id',visit.id,'code',visit.code,'title',visit.title,'status',visit.status) AS visit,
JSONB_BUILD_OBJECT('id',visit.id,'code',visit.code,'status',visit.status) AS visit,
JSONB_BUILD_OBJECT('id',creator.id,'username',creator.username,'firstName',creator.first_name,'lastName',creator.last_name) AS creator,
CASE WHEN reviewer.id IS NULL THEN NULL ELSE JSONB_BUILD_OBJECT('id',reviewer.id,'username',reviewer.username,'firstName',reviewer.first_name,'lastName',reviewer.last_name) END AS reviewer,
CASE WHEN matched.id IS NULL THEN NULL ELSE JSONB_BUILD_OBJECT('id',matched.id,'code',matched.code,'name',matched.name,'commonName',matched.common_name) END AS "matchedAsset"
@@ -0,0 +1,79 @@
import { Transform } from 'class-transformer';
import {
IsBoolean,
IsISO8601,
IsInt,
IsOptional,
IsString,
IsUUID,
Max,
MaxLength,
Min,
MinLength,
} from 'class-validator';
export class CreateInventoryFunctionDto {
@Transform(({ value }) => typeof value === 'string' ? value.trim().toUpperCase().replace(/[^A-Z0-9]+/g, '_').replace(/^_+|_+$/g, '') : value)
@IsString()
@MinLength(2)
@MaxLength(120)
code!: string;
@Transform(({ value }) => typeof value === 'string' ? value.trim() : value)
@IsString()
@MinLength(2)
@MaxLength(240)
name!: string;
@IsOptional()
@Transform(({ value }) => typeof value === 'string' && value.trim() ? value.trim() : null)
@IsString()
@MaxLength(4000)
description?: string | null;
@IsOptional()
@IsInt()
@Min(0)
@Max(100000)
sortOrder?: number;
}
export class UpdateInventoryFunctionDto {
@IsOptional()
@Transform(({ value }) => typeof value === 'string' ? value.trim() : value)
@IsString()
@MinLength(2)
@MaxLength(240)
name?: string;
@IsOptional()
@Transform(({ value }) => typeof value === 'string' && value.trim() ? value.trim() : null)
@IsString()
@MaxLength(4000)
description?: string | null;
@IsOptional()
@IsBoolean()
isActive?: boolean;
@IsOptional()
@IsInt()
@Min(0)
@Max(100000)
sortOrder?: number;
}
export class ChangeInventoryFunctionDto {
@IsUUID('4')
functionId!: string;
@IsOptional()
@IsISO8601({ strict: true })
effectiveAt?: string;
@IsOptional()
@Transform(({ value }) => typeof value === 'string' && value.trim() ? value.trim() : null)
@IsString()
@MaxLength(4000)
reason?: string | null;
}
@@ -0,0 +1,69 @@
import {
Body,
Controller,
Get,
Param,
ParseUUIDPipe,
Patch,
Post,
Query,
Req,
} from '@nestjs/common';
import { CurrentAuth } from '../auth/decorators/current-auth.decorator';
import { RequirePermissions } from '../authorization/decorators/require-permissions.decorator';
import type { AuthPrincipal, RequestWithContext } from '../common/http/request-context';
import {
ChangeInventoryFunctionDto,
CreateInventoryFunctionDto,
UpdateInventoryFunctionDto,
} from './dto/inventory-function.dto';
import { InventoryFunctionService } from './inventory-function.service';
@Controller()
export class InventoryFunctionController {
constructor(private readonly functions: InventoryFunctionService) {}
@Get('inventory-functions')
@RequirePermissions('assets.read')
list(@Query('includeInactive') includeInactive?: string) {
return this.functions.list(includeInactive === 'true');
}
@Post('inventory-functions')
@RequirePermissions('asset_types.manage')
create(
@Body() dto: CreateInventoryFunctionDto,
@CurrentAuth() principal: AuthPrincipal,
@Req() request: RequestWithContext,
) {
return this.functions.create(dto, principal, request);
}
@Patch('inventory-functions/:id')
@RequirePermissions('asset_types.manage')
update(
@Param('id', new ParseUUIDPipe({ version: '4' })) id: string,
@Body() dto: UpdateInventoryFunctionDto,
@CurrentAuth() principal: AuthPrincipal,
@Req() request: RequestWithContext,
) {
return this.functions.update(id, dto, principal, request);
}
@Get('assets/:id/function-history')
@RequirePermissions('assets.read_history')
history(@Param('id', new ParseUUIDPipe({ version: '4' })) id: string) {
return this.functions.getForAsset(id);
}
@Post('assets/:id/function')
@RequirePermissions('assets.update')
change(
@Param('id', new ParseUUIDPipe({ version: '4' })) id: string,
@Body() dto: ChangeInventoryFunctionDto,
@CurrentAuth() principal: AuthPrincipal,
@Req() request: RequestWithContext,
) {
return this.functions.changeForAsset(id, dto, principal, request);
}
}
@@ -0,0 +1,186 @@
import {
BadRequestException,
ConflictException,
Injectable,
NotFoundException,
} from '@nestjs/common';
import { DataSource, EntityManager } from 'typeorm';
import { administrationAuditContext } from '../administration/common/administration-audit';
import { AuditService } from '../audit/audit.service';
import type { AuthPrincipal, RequestWithContext } from '../common/http/request-context';
import { AssetVersionChangeType, AuditAction } from '../database/entities';
import type {
ChangeInventoryFunctionDto,
CreateInventoryFunctionDto,
UpdateInventoryFunctionDto,
} from './dto/inventory-function.dto';
import { AssetHistoryService } from './asset-history.service';
export interface InventoryFunctionRow {
id: string; code: string; name: string; description: string | null;
isActive: boolean; sortOrder: number; createdAt: Date; updatedAt: Date;
}
export interface FunctionAssignmentRow {
id: string; assetId: string; functionId: string; functionCode: string; functionName: string;
validFrom: Date; validUntil: Date | null; reason: string | null;
changedBy: string | null; changedByUsername: string | null; createdAt: Date;
}
interface FunctionEligibleAsset {
id: string; code: string; name: string; typeCode: string; typeName: string;
familyCode: string | null; familyName: string | null;
}
function normalized(value: string | null): string {
return (value ?? '').normalize('NFD').replace(/[\u0300-\u036f]/g, '').toLowerCase().replace(/[^a-z0-9]+/g, ' ').trim();
}
@Injectable()
export class InventoryFunctionService {
constructor(
private readonly dataSource: DataSource,
private readonly audit: AuditService,
private readonly history: AssetHistoryService,
) {}
async list(includeInactive = false): Promise<{ data: InventoryFunctionRow[] }> {
const data = await this.dataSource.query(`
SELECT id,code,name,description,is_active AS "isActive",sort_order AS "sortOrder",
created_at AS "createdAt",updated_at AS "updatedAt"
FROM inventory_functions
${includeInactive ? '' : 'WHERE is_active=true'}
ORDER BY sort_order,name,code
`) as InventoryFunctionRow[];
return { data };
}
async create(dto: CreateInventoryFunctionDto, principal: AuthPrincipal, request: RequestWithContext): Promise<InventoryFunctionRow> {
return this.dataSource.transaction(async (manager) => {
const [duplicate] = await manager.query('SELECT 1 FROM inventory_functions WHERE lower(code)=lower($1) LIMIT 1', [dto.code]) as unknown[];
if (duplicate) throw new ConflictException({ code: 'INVENTORY_FUNCTION_CODE_EXISTS', message: 'Ya existe una función con ese código' });
const [created] = await manager.query(`
INSERT INTO inventory_functions(code,name,description,is_active,sort_order,created_by,updated_by)
VALUES($1,$2,$3,true,$4,$5,$5)
RETURNING id,code,name,description,is_active AS "isActive",sort_order AS "sortOrder",
created_at AS "createdAt",updated_at AS "updatedAt"
`, [dto.code, dto.name, dto.description ?? null, dto.sortOrder ?? 0, principal.userId]) as InventoryFunctionRow[];
await this.audit.record({ ...administrationAuditContext(principal, request), action: AuditAction.INVENTORY_FUNCTION_CREATED,
entityType: 'inventory_function', entityId: created.id, afterData: created as unknown as Record<string, unknown> }, manager);
return created;
});
}
async update(id: string, dto: UpdateInventoryFunctionDto, principal: AuthPrincipal, request: RequestWithContext): Promise<InventoryFunctionRow> {
if (Object.keys(dto).length === 0) throw new BadRequestException({ code: 'NO_CHANGES', message: 'No se recibieron cambios' });
return this.dataSource.transaction(async (manager) => {
const before = await this.requireFunction(manager, id, false);
const [updated] = await manager.query(`
UPDATE inventory_functions SET
name=COALESCE($2,name), description=CASE WHEN $3::boolean THEN $4 ELSE description END,
is_active=COALESCE($5,is_active), sort_order=COALESCE($6,sort_order),
updated_by=$7,updated_at=CURRENT_TIMESTAMP
WHERE id=$1
RETURNING id,code,name,description,is_active AS "isActive",sort_order AS "sortOrder",
created_at AS "createdAt",updated_at AS "updatedAt"
`, [id, dto.name ?? null, dto.description !== undefined, dto.description ?? null, dto.isActive ?? null, dto.sortOrder ?? null, principal.userId]) as InventoryFunctionRow[];
await this.audit.record({ ...administrationAuditContext(principal, request), action: AuditAction.INVENTORY_FUNCTION_UPDATED,
entityType: 'inventory_function', entityId: id, beforeData: before as unknown as Record<string, unknown>,
afterData: updated as unknown as Record<string, unknown> }, manager);
return updated;
});
}
async getForAsset(assetId: string) {
return this.dataSource.transaction((manager) => this.getForAssetWithManager(manager, assetId));
}
async changeForAsset(assetId: string, dto: ChangeInventoryFunctionDto, principal: AuthPrincipal, request: RequestWithContext) {
return this.dataSource.transaction(async (manager) => {
const asset = await this.requireEligibleAsset(manager, assetId, true);
const nextFunction = await this.requireFunction(manager, dto.functionId, true);
const effectiveAt = dto.effectiveAt ? new Date(dto.effectiveAt) : new Date();
if (!Number.isFinite(effectiveAt.getTime())) throw new BadRequestException({ code: 'INVENTORY_FUNCTION_EFFECTIVE_DATE_INVALID', message: 'La fecha efectiva del cambio de función no es válida' });
if (effectiveAt.getTime() > Date.now() + 60_000) throw new BadRequestException({ code: 'INVENTORY_FUNCTION_FUTURE_DATE_NOT_ALLOWED', message: 'El cambio de función no puede registrarse con fecha futura' });
const current = await this.currentAssignment(manager, assetId, true);
if (current?.functionId === nextFunction.id) return this.getForAssetWithManager(manager, assetId);
if (current && effectiveAt.getTime() <= new Date(current.validFrom).getTime()) {
throw new ConflictException({ code: 'INVENTORY_FUNCTION_EFFECTIVE_DATE_OVERLAP', message: 'La fecha efectiva debe ser posterior al inicio de la función vigente' });
}
if (current) await manager.query(`UPDATE inventory_function_assignments SET valid_until=$2 WHERE id=$1 AND valid_until IS NULL`, [current.id, effectiveAt]);
await manager.query(`INSERT INTO inventory_function_assignments(asset_id,function_id,valid_from,change_reason,changed_by) VALUES($1,$2,$3,$4,$5)`,
[assetId, nextFunction.id, effectiveAt, dto.reason ?? null, principal.userId]);
await manager.query(`UPDATE assets SET updated_by=$2,updated_at=CURRENT_TIMESTAMP WHERE id=$1`, [assetId, principal.userId]);
const versionNumber = await this.history.capture(manager, assetId, AssetVersionChangeType.FUNCTION_CHANGED, principal, request);
const after = await this.getForAssetWithManager(manager, assetId);
await this.audit.record({
...administrationAuditContext(principal, request), action: AuditAction.ASSET_FUNCTION_CHANGED,
entityType: 'asset', entityId: assetId,
beforeData: current ? { functionId: current.functionId, functionCode: current.functionCode, functionName: current.functionName, validFrom: current.validFrom } : { functionId: null },
afterData: { functionId: nextFunction.id, functionCode: nextFunction.code, functionName: nextFunction.name, effectiveAt, reason: dto.reason ?? null, versionNumber },
metadata: { inventoryCode: asset.code, inventoryName: asset.name, temporal: true, historySource: 'inventory_function_assignments', assetVersionChangeType: AssetVersionChangeType.FUNCTION_CHANGED },
}, manager);
return after;
});
}
private async getForAssetWithManager(manager: EntityManager, assetId: string) {
const asset = await this.requireEligibleAsset(manager, assetId, false);
const currentFunction = await this.currentAssignment(manager, assetId, false);
const history = await manager.query(`
SELECT assignment.id,assignment.asset_id AS "assetId",assignment.function_id AS "functionId",
fn.code AS "functionCode",fn.name AS "functionName",
assignment.valid_from AS "validFrom",assignment.valid_until AS "validUntil",
assignment.change_reason AS reason,assignment.changed_by AS "changedBy",
actor.username AS "changedByUsername",assignment.created_at AS "createdAt"
FROM inventory_function_assignments assignment
JOIN inventory_functions fn ON fn.id=assignment.function_id
LEFT JOIN users actor ON actor.id=assignment.changed_by
WHERE assignment.asset_id=$1
ORDER BY assignment.valid_from DESC,assignment.created_at DESC
`, [assetId]) as FunctionAssignmentRow[];
return { asset: { id: asset.id, code: asset.code, name: asset.name, typeCode: asset.typeCode, typeName: asset.typeName, familyCode: asset.familyCode, familyName: asset.familyName }, currentFunction, history };
}
private async currentAssignment(manager: EntityManager, assetId: string, lock: boolean): Promise<FunctionAssignmentRow | null> {
const [row] = await manager.query(`
SELECT assignment.id,assignment.asset_id AS "assetId",assignment.function_id AS "functionId",
fn.code AS "functionCode",fn.name AS "functionName",
assignment.valid_from AS "validFrom",assignment.valid_until AS "validUntil",
assignment.change_reason AS reason,assignment.changed_by AS "changedBy",
actor.username AS "changedByUsername",assignment.created_at AS "createdAt"
FROM inventory_function_assignments assignment
JOIN inventory_functions fn ON fn.id=assignment.function_id
LEFT JOIN users actor ON actor.id=assignment.changed_by
WHERE assignment.asset_id=$1 AND assignment.valid_until IS NULL
${lock ? 'FOR UPDATE OF assignment' : ''}
`, [assetId]) as FunctionAssignmentRow[];
return row ?? null;
}
private async requireFunction(manager: EntityManager, id: string, active: boolean): Promise<InventoryFunctionRow> {
const [row] = await manager.query(`
SELECT id,code,name,description,is_active AS "isActive",sort_order AS "sortOrder",created_at AS "createdAt",updated_at AS "updatedAt"
FROM inventory_functions WHERE id=$1 ${active ? 'AND is_active=true' : ''}
`, [id]) as InventoryFunctionRow[];
if (!row) throw new NotFoundException({ code: 'INVENTORY_FUNCTION_NOT_FOUND', message: active ? 'La función seleccionada no existe o está inactiva' : 'Función no encontrada' });
return row;
}
private async requireEligibleAsset(manager: EntityManager, assetId: string, lock: boolean): Promise<FunctionEligibleAsset> {
const [asset] = await manager.query(`
SELECT asset.id,asset.code,asset.name,asset_type.code AS "typeCode",asset_type.name AS "typeName",
family.code AS "familyCode",family.name AS "familyName"
FROM assets asset JOIN asset_types asset_type ON asset_type.id=asset.asset_type_id
LEFT JOIN inventory_families family ON family.id=asset.inventory_family_id
WHERE asset.id=$1 ${lock ? 'FOR UPDATE OF asset' : ''}
`, [assetId]) as FunctionEligibleAsset[];
if (!asset) throw new NotFoundException({ code: 'ASSET_NOT_FOUND', message: 'Inventario no encontrado' });
const values = [asset.typeCode, asset.typeName, asset.familyCode, asset.familyName].map(normalized);
const eligible = values.some((value) => value === 'estacion' || value === 'subestacion' || value.includes('estacion ') || value.includes('subestacion ') || value.endsWith(' estacion') || value.endsWith(' subestacion'));
if (!eligible) throw new ConflictException({ code: 'INVENTORY_FUNCTION_CHANGE_NOT_ALLOWED', message: 'El cambio de función sólo está habilitado para Inventarios de Estación o Subestación' });
return asset;
}
}
@@ -1,5 +1,6 @@
import { Injectable } from '@nestjs/common';
import { AssetsService } from './assets.service';
import { InventoryFunctionService } from './inventory-function.service';
import { InventoryMergeService } from './inventory-merge.service';
type LooseRecord = Record<string, any>;
@@ -29,6 +30,7 @@ export class MergedInventoryDossierService {
constructor(
private readonly assets: AssetsService,
private readonly merges: InventoryMergeService,
private readonly functions: InventoryFunctionService,
) {}
async dossier(requestedAssetId: string): Promise<Record<string, unknown>> {
@@ -44,7 +46,8 @@ export class MergedInventoryDossierService {
const dossiers = await Promise.all(inventoryIds.map(async (assetId) => {
const dossier = await this.assets.dossier(assetId) as LooseRecord;
const identity = dossier.asset as LooseRecord;
return { assetId, identity, dossier };
const functionDossier = await this.functions.getForAsset(assetId).catch(() => null) as LooseRecord | null;
return { assetId, identity, dossier, functionDossier };
}));
const enrich = (entry: LooseRecord, identity: LooseRecord): LooseRecord => ({
@@ -75,6 +78,15 @@ export class MergedInventoryDossierService {
const media = sortDesc(collect('media'), ['capturedAt', 'createdAt']);
const versions = sortDesc(collect('versions'), ['occurredAt']);
const functionHistory = sortDesc(
dossiers.flatMap(({ identity, functionDossier }) =>
(((functionDossier?.history ?? []) as LooseRecord[]).map((entry) => enrich(entry, identity))),
),
['validFrom', 'createdAt'],
);
const canonicalFunctionDossier = dossiers.find((item) => item.assetId === canonical.id)?.functionDossier ?? null;
const currentFunction = canonicalFunctionDossier?.currentFunction ?? null;
const timelineSource: LooseRecord[] = dossiers.flatMap(({ identity, dossier }) =>
((dossier.timeline ?? []) as LooseRecord[]).map((event): LooseRecord => ({
...event,
@@ -91,6 +103,25 @@ export class MergedInventoryDossierService {
);
const timeline: LooseRecord[] = dedupeById<LooseRecord>(timelineSource);
for (const assignment of functionHistory) {
timeline.push({
id: `function:${String(assignment.id)}`,
kind: 'FUNCTION_CHANGED',
occurredAt: assignment.validFrom,
title: `Cambio de función · ${String(assignment.functionName)}`,
description: assignment.reason ?? null,
meta: {
functionId: assignment.functionId,
functionCode: assignment.functionCode,
functionName: assignment.functionName,
validFrom: assignment.validFrom,
validUntil: assignment.validUntil,
changedByUsername: assignment.changedByUsername,
historicalInventory: assignment.historicalInventory,
},
});
}
for (const alias of aliases) {
timeline.push({
id: `merge:${String(alias.id)}:${String(alias.mergedAt)}`,
@@ -121,6 +152,7 @@ export class MergedInventoryDossierService {
code: canonical.code,
name: canonical.name,
commonName: dossiers.find((item) => item.assetId === canonical.id)?.identity?.commonName ?? null,
currentFunction,
},
requestedAsset: {
id: requested.id,
@@ -146,7 +178,10 @@ export class MergedInventoryDossierService {
documents: documents.length + media.filter((item) => item.kind === 'DOCUMENT').length,
photos: evidence.filter((item) => item.kind === 'PHOTO').length + media.filter((item) => item.kind === 'PHOTO').length,
reports: reports.length + inspectionReports.length,
functionChanges: functionHistory.length,
},
currentFunction,
functionHistory,
visits,
acts,
findings,
+12
View File
@@ -13,6 +13,7 @@ import type {
import { CurrentAuth } from './decorators/current-auth.decorator';
import { Public } from './decorators/public.decorator';
import { SkipCsrf } from './decorators/skip-csrf.decorator';
import { ChangePasswordDto } from './dto/change-password.dto';
import { LoginDto } from './dto/login.dto';
import { MobileRefreshDto } from './dto/mobile-refresh.dto';
import { MobileAuthService } from './mobile-auth.service';
@@ -53,4 +54,15 @@ export class MobileAuthController {
) {
return this.mobileAuth.logout(principal, request);
}
@Post('change-password')
@HttpCode(200)
@Throttle({ default: { limit: 10, ttl: 60_000 } })
changePassword(
@CurrentAuth() principal: AuthPrincipal,
@Body() dto: ChangePasswordDto,
@Req() request: RequestWithContext,
) {
return this.mobileAuth.changePassword(principal, dto, request);
}
}
+58 -41
View File
@@ -19,12 +19,12 @@ interface DashboardCountsRow {
inactiveUsers: number | string;
activeSessions: number | string;
openFindings: number | string;
awaitingCompanyResponse: number | string;
overdueCompanyResponses: number | string;
companyResponsesDueNext7Days: number | string;
awaitingVerificationSchedule: number | string;
findingsWithoutControlDate: number | string;
overdueControls: number | string;
controlsNext30Days: number | string;
reportsWorking: number | string;
reportsOfficialized: number | string;
sealedActsWithoutReport: number | string;
}
export interface DashboardUpcomingControl {
@@ -65,43 +65,54 @@ export class DashboardService {
SELECT COUNT(*) FROM inspection_findings WHERE status = 'OPEN'
) AS "openFindings",
(
SELECT COUNT(*) FROM inspection_findings
WHERE status = 'OPEN' AND company_response_received_on IS NULL
) AS "awaitingCompanyResponse",
SELECT COUNT(*)
FROM inspection_findings finding
WHERE finding.status = 'OPEN'
AND finding.next_control_on IS NULL
AND COALESCE((
SELECT verification.outcome
FROM inspection_finding_verification_visits verification
WHERE verification.finding_id=finding.id AND verification.outcome IS NOT NULL
ORDER BY verification.result_recorded_at DESC NULLS LAST, verification.created_at DESC, verification.id DESC
LIMIT 1
), '') <> 'RESOLVED'
) AS "findingsWithoutControlDate",
(
SELECT COUNT(*) FROM inspection_findings
WHERE status = 'OPEN'
AND company_response_received_on IS NULL
AND correction_due_on IS NOT NULL
AND correction_due_on < (CURRENT_TIMESTAMP AT TIME ZONE 'America/Argentina/Mendoza')::date
) AS "overdueCompanyResponses",
(
SELECT COUNT(*) FROM inspection_findings
WHERE status = 'OPEN'
AND company_response_received_on IS NULL
AND correction_due_on BETWEEN
(CURRENT_TIMESTAMP AT TIME ZONE 'America/Argentina/Mendoza')::date
AND (CURRENT_TIMESTAMP AT TIME ZONE 'America/Argentina/Mendoza')::date + 7
) AS "companyResponsesDueNext7Days",
(
SELECT COUNT(*) FROM inspection_findings
WHERE status = 'OPEN'
AND company_response_received_on IS NOT NULL
AND next_control_on IS NULL
) AS "awaitingVerificationSchedule",
(
SELECT COUNT(*) FROM inspection_findings
WHERE status = 'OPEN'
AND company_response_received_on IS NOT NULL
AND next_control_on < (CURRENT_TIMESTAMP AT TIME ZONE 'America/Argentina/Mendoza')::date
SELECT COUNT(*)
FROM inspection_findings finding
WHERE finding.status = 'OPEN'
AND finding.next_control_on < (CURRENT_TIMESTAMP AT TIME ZONE 'America/Argentina/Mendoza')::date
AND COALESCE((
SELECT verification.outcome
FROM inspection_finding_verification_visits verification
WHERE verification.finding_id=finding.id AND verification.outcome IS NOT NULL
ORDER BY verification.result_recorded_at DESC NULLS LAST, verification.created_at DESC, verification.id DESC
LIMIT 1
), '') <> 'RESOLVED'
) AS "overdueControls",
(
SELECT COUNT(*) FROM inspection_findings
WHERE status = 'OPEN'
AND next_control_on BETWEEN
SELECT COUNT(*)
FROM inspection_findings finding
WHERE finding.status = 'OPEN'
AND finding.next_control_on BETWEEN
(CURRENT_TIMESTAMP AT TIME ZONE 'America/Argentina/Mendoza')::date
AND (CURRENT_TIMESTAMP AT TIME ZONE 'America/Argentina/Mendoza')::date + 30
) AS "controlsNext30Days"
AND COALESCE((
SELECT verification.outcome
FROM inspection_finding_verification_visits verification
WHERE verification.finding_id=finding.id AND verification.outcome IS NOT NULL
ORDER BY verification.result_recorded_at DESC NULLS LAST, verification.created_at DESC, verification.id DESC
LIMIT 1
), '') <> 'RESOLVED'
) AS "controlsNext30Days",
(SELECT COUNT(*) FROM inspection_reports WHERE status='WORKING') AS "reportsWorking",
(SELECT COUNT(*) FROM inspection_reports WHERE status='OFFICIALIZED') AS "reportsOfficialized",
(
SELECT COUNT(*)
FROM inspection_acts act
WHERE act.status='SEALED'
AND NOT EXISTS (SELECT 1 FROM inspection_reports report WHERE report.act_id=act.id)
) AS "sealedActsWithoutReport"
`)) as DashboardCountsRow[];
const recentAudit = (await manager.query(`
@@ -135,8 +146,14 @@ export class DashboardService {
INNER JOIN inspection_visits visit ON visit.id = act.visit_id
INNER JOIN assets asset ON asset.id = finding.asset_id
WHERE finding.status = 'OPEN'
AND finding.company_response_received_on IS NOT NULL
AND finding.next_control_on IS NOT NULL
AND COALESCE((
SELECT verification.outcome
FROM inspection_finding_verification_visits verification
WHERE verification.finding_id=finding.id AND verification.outcome IS NOT NULL
ORDER BY verification.result_recorded_at DESC NULLS LAST, verification.created_at DESC, verification.id DESC
LIMIT 1
), '') <> 'RESOLVED'
ORDER BY finding.next_control_on, finding.code
LIMIT 8
`)) as DashboardUpcomingControl[];
@@ -151,12 +168,12 @@ export class DashboardService {
inactiveUsers: Number(counts?.inactiveUsers ?? 0),
activeSessions: Number(counts?.activeSessions ?? 0),
openFindings: Number(counts?.openFindings ?? 0),
awaitingCompanyResponse: Number(counts?.awaitingCompanyResponse ?? 0),
overdueCompanyResponses: Number(counts?.overdueCompanyResponses ?? 0),
companyResponsesDueNext7Days: Number(counts?.companyResponsesDueNext7Days ?? 0),
awaitingVerificationSchedule: Number(counts?.awaitingVerificationSchedule ?? 0),
findingsWithoutControlDate: Number(counts?.findingsWithoutControlDate ?? 0),
overdueControls: Number(counts?.overdueControls ?? 0),
controlsNext30Days: Number(counts?.controlsNext30Days ?? 0),
reportsWorking: Number(counts?.reportsWorking ?? 0),
reportsOfficialized: Number(counts?.reportsOfficialized ?? 0),
sealedActsWithoutReport: Number(counts?.sealedActsWithoutReport ?? 0),
},
recentAudit,
upcomingControls,
@@ -5,6 +5,7 @@ export enum AssetVersionChangeType {
CREATED = 'CREATED',
UPDATED = 'UPDATED',
CONTEXT_CHANGED = 'CONTEXT_CHANGED',
FUNCTION_CHANGED = 'FUNCTION_CHANGED',
STATUS_CHANGED = 'STATUS_CHANGED',
OPERATIONAL_STATUS_CHANGED = 'OPERATIONAL_STATUS_CHANGED',
REGISTRY_UPDATED = 'REGISTRY_UPDATED',
@@ -35,6 +35,9 @@ export enum AuditAction {
ASSET_FIELD_DISCOVERY_REJECTED = 'ASSET_FIELD_DISCOVERY_REJECTED',
ASSET_UPDATED = 'ASSET_UPDATED',
ASSET_CONTEXT_CHANGED = 'ASSET_CONTEXT_CHANGED',
ASSET_FUNCTION_CHANGED = 'ASSET_FUNCTION_CHANGED',
INVENTORY_FUNCTION_CREATED = 'INVENTORY_FUNCTION_CREATED',
INVENTORY_FUNCTION_UPDATED = 'INVENTORY_FUNCTION_UPDATED',
ASSET_INFORMATION_STATUS_CHANGED = 'ASSET_INFORMATION_STATUS_CHANGED',
ASSET_OPERATIONAL_STATUS_CHANGED = 'ASSET_OPERATIONAL_STATUS_CHANGED',
ASSET_REGISTRY_UPDATED = 'ASSET_REGISTRY_UPDATED',
@@ -82,15 +85,24 @@ export enum AuditAction {
INSPECTION_ACT_UPDATED = 'INSPECTION_ACT_UPDATED',
INSPECTION_ACT_CANCELLED = 'INSPECTION_ACT_CANCELLED',
INSPECTION_ACT_RESPONSIBLE_UPDATED = 'INSPECTION_ACT_RESPONSIBLE_UPDATED',
INSPECTION_ACT_LOCKED = 'INSPECTION_ACT_LOCKED',
INSPECTION_ACT_SEALED = 'INSPECTION_ACT_SEALED',
INSPECTION_ACT_READY = 'INSPECTION_ACT_READY',
INSPECTION_ACT_REOPENED = 'INSPECTION_ACT_REOPENED',
INSPECTION_ACT_SIGNATURE_RECORDED = 'INSPECTION_ACT_SIGNATURE_RECORDED',
INSPECTION_ACT_COMPANY_OUTCOME_RECORDED = 'INSPECTION_ACT_COMPANY_OUTCOME_RECORDED',
INSPECTION_ACT_CLOSED = 'INSPECTION_ACT_CLOSED',
INSPECTION_REPORT_GENERATED = 'INSPECTION_REPORT_GENERATED',
INSPECTION_REPORT_UPDATED = 'INSPECTION_REPORT_UPDATED',
INSPECTION_REPORT_REVISION_ADDED = 'INSPECTION_REPORT_REVISION_ADDED',
INSPECTION_REPORT_OFFICIALIZED = 'INSPECTION_REPORT_OFFICIALIZED',
INSPECTION_REPORT_FOLLOW_UP_ADDED = 'INSPECTION_REPORT_FOLLOW_UP_ADDED',
INSPECTION_REPORT_APPROVED = 'INSPECTION_REPORT_APPROVED',
INSPECTION_REPORT_SIGNED = 'INSPECTION_REPORT_SIGNED',
INSPECTION_DEADLINE_POLICY_UPDATED = 'INSPECTION_DEADLINE_POLICY_UPDATED',
INSPECTION_BUSINESS_CALENDAR_UPDATED = 'INSPECTION_BUSINESS_CALENDAR_UPDATED',
SMTP_SETTINGS_UPDATED = 'SMTP_SETTINGS_UPDATED',
SMTP_TEST_SENT = 'SMTP_TEST_SENT',
DOCUMENT_DELIVERY_SETTINGS_UPDATED = 'DOCUMENT_DELIVERY_SETTINGS_UPDATED',
DOCUMENT_DELIVERY_RETRY_REQUESTED = 'DOCUMENT_DELIVERY_RETRY_REQUESTED',
DOCUMENT_DELIVERY_SENT = 'DOCUMENT_DELIVERY_SENT',
+27 -80
View File
@@ -6,14 +6,8 @@ export { Role } from './role.entity';
export { UserRole } from './user-role.entity';
export { User, UserStatus } from './user.entity';
export { Asset, AssetDataOrigin, AssetInformationStatus, AssetOperationalStatus } from './asset.entity';
export {
AssetVersion,
AssetVersionChangeType,
} from './asset-version.entity';
export {
AssetAttributeDataType,
AssetAttributeDefinition,
} from './asset-attribute-definition.entity';
export { AssetVersion, AssetVersionChangeType } from './asset-version.entity';
export { AssetAttributeDataType, AssetAttributeDefinition } from './asset-attribute-definition.entity';
export { AssetAttributeValue } from './asset-attribute-value.entity';
export { AreaCompanyRelation, AreaOrganizationRole } from './area-company-relation.entity';
export { OrganizationProfile, OrganizationKind } from './organization-profile.entity';
@@ -25,69 +19,33 @@ export { AreaLegalRight, AreaLegalRightType, AreaLegalRightStatus } from './area
export { AreaLegalRightOrganization, AreaLegalRightOrganizationRole } from './area-legal-right-organization.entity';
export { AssetTypeParentRule } from './asset-type-parent-rule.entity';
export { AssetType, AssetTypeOperationalRole } from './asset-type.entity';
export {
AssetGeometry,
AssetGeometrySource,
AssetGeometryType,
} from './asset-geometry.entity';
export {
AssetMedia,
AssetMediaKind,
AssetMediaSource,
} from './asset-media.entity';
export {
SurveyCampaign,
SurveyCampaignStatus,
} from './survey-campaign.entity';
export {
SurveyCampaignTarget,
SurveyTargetStatus,
} from './survey-campaign-target.entity';
export {
SurveyReportOutcome,
SurveyReportStatus,
SurveyTargetReport,
} from './survey-target-report.entity';
export { SurveyTargetReportMedia } from './survey-target-report-media.entity';
export {
SurveyReportVersionEvent,
SurveyTargetReportVersion,
} from './survey-target-report-version.entity';
export {
InspectionVisit,
InspectionVisitStatus,
} from './inspection-visit.entity';
export { AssetGeometry, AssetGeometrySource, AssetGeometryType } from './asset-geometry.entity';
export { AssetMedia, AssetMediaKind, AssetMediaSource } from './asset-media.entity';
export { InspectionVisit, InspectionVisitStatus } from './inspection-visit.entity';
export { InspectionVisitAsset, InspectionVisitAssetPlanningSource } from './inspection-visit-asset.entity';
export { InspectionVisitMember } from './inspection-visit-member.entity';
export {
DocumentAnnualSequence,
DocumentSequenceType,
} from './document-annual-sequence.entity';
export { DocumentAnnualSequence, DocumentSequenceType } from './document-annual-sequence.entity';
export {
InspectionAct,
InspectionActStatus,
InspectionActUrgency,
InspectionDeadlineBasis,
InspectionDeadlineDayType,
} from './inspection-act.entity';
export { InspectionActAsset } from './inspection-act-asset.entity';
export {
InspectionReport,
InspectionReportPdfStatus,
InspectionReportReviewStatus,
InspectionReportStatus,
InspectionReportWordStatus,
} from './inspection-report.entity';
export {
InspectionActVersion,
InspectionActVersionEvent,
} from './inspection-act-version.entity';
export { InspectionActVersion, InspectionActVersionEvent } from './inspection-act-version.entity';
export {
InspectionActResponsible,
InspectionResponsibleAttendanceStatus,
InspectionResponsibleDocumentType,
} from './inspection-act-responsible.entity';
export {
InspectionActClosure,
InspectionActUploadMode,
} from './inspection-act-closure.entity';
export { InspectionActClosure, InspectionActUploadMode } from './inspection-act-closure.entity';
export {
InspectionActSignature,
InspectionActSignatureSource,
@@ -95,21 +53,18 @@ export {
InspectionActSignerType,
InspectionCompanySignatureManifestation,
} from './inspection-act-signature.entity';
export { InspectionDeadlinePolicy } from './inspection-deadline-policy.entity';
export { InspectionBusinessCalendarDay } from './inspection-business-calendar-day.entity';
export { InspectionReportFollowUp, InspectionReportFollowUpType } from './inspection-report-follow-up.entity';
export { SystemSmtpSettings, SmtpSecurityMode } from './system-smtp-settings.entity';
export { FindingCategory } from './finding-category.entity';
export { FindingCatalogItem } from './finding-catalog-item.entity';
export { FindingCatalogItemAssetType } from './finding-catalog-item-asset-type.entity';
export { FindingCatalogAssetTypeProfile } from './finding-catalog-asset-type-profile.entity';
export { FindingCatalogAssetOverride } from './finding-catalog-asset-override.entity';
export { FindingCatalogProposal, FindingCatalogProposalStatus } from './finding-catalog-proposal.entity';
export {
InspectionFinding,
InspectionFindingResponseDueBasis,
InspectionFindingStatus,
} from './inspection-finding.entity';
export {
InspectionFindingVersion,
InspectionFindingVersionEvent,
} from './inspection-finding-version.entity';
export { InspectionFinding, InspectionFindingResponseDueBasis, InspectionFindingStatus } from './inspection-finding.entity';
export { InspectionFindingVersion, InspectionFindingVersionEvent } from './inspection-finding-version.entity';
export {
InspectionCommunicationChannel,
InspectionCommunicationDirection,
@@ -122,14 +77,8 @@ export {
InspectionEvidenceSource,
InspectionFindingEvidence,
} from './inspection-finding-evidence.entity';
export {
InspectionFindingVerificationVisit,
InspectionVerificationOutcome,
} from './inspection-finding-verification-visit.entity';
export {
InspectionFindingVerificationEvent,
InspectionFindingVerificationEventType,
} from './inspection-finding-verification-event.entity';
export { InspectionFindingVerificationVisit, InspectionVerificationOutcome } from './inspection-finding-verification-visit.entity';
export { InspectionFindingVerificationEvent, InspectionFindingVerificationEventType } from './inspection-finding-verification-event.entity';
import { AuditEvent } from './audit-event.entity';
import { AuthSession } from './auth-session.entity';
@@ -154,11 +103,6 @@ import { AreaLegalRightOrganization } from './area-legal-right-organization.enti
import { AssetGeometry } from './asset-geometry.entity';
import { AssetVersion } from './asset-version.entity';
import { AssetMedia } from './asset-media.entity';
import { SurveyCampaign } from './survey-campaign.entity';
import { SurveyCampaignTarget } from './survey-campaign-target.entity';
import { SurveyTargetReport } from './survey-target-report.entity';
import { SurveyTargetReportMedia } from './survey-target-report-media.entity';
import { SurveyTargetReportVersion } from './survey-target-report-version.entity';
import { InspectionVisit } from './inspection-visit.entity';
import { InspectionVisitAsset } from './inspection-visit-asset.entity';
import { InspectionVisitMember } from './inspection-visit-member.entity';
@@ -170,6 +114,10 @@ import { InspectionActVersion } from './inspection-act-version.entity';
import { InspectionActResponsible } from './inspection-act-responsible.entity';
import { InspectionActClosure } from './inspection-act-closure.entity';
import { InspectionActSignature } from './inspection-act-signature.entity';
import { InspectionDeadlinePolicy } from './inspection-deadline-policy.entity';
import { InspectionBusinessCalendarDay } from './inspection-business-calendar-day.entity';
import { InspectionReportFollowUp } from './inspection-report-follow-up.entity';
import { SystemSmtpSettings } from './system-smtp-settings.entity';
import { FindingCategory } from './finding-category.entity';
import { FindingCatalogItem } from './finding-catalog-item.entity';
import { FindingCatalogItemAssetType } from './finding-catalog-item-asset-type.entity';
@@ -207,11 +155,6 @@ export const PHASE_A_ENTITIES = [
AssetGeometry,
AssetVersion,
AssetMedia,
SurveyCampaign,
SurveyCampaignTarget,
SurveyTargetReport,
SurveyTargetReportMedia,
SurveyTargetReportVersion,
InspectionVisit,
InspectionVisitAsset,
InspectionVisitMember,
@@ -223,6 +166,10 @@ export const PHASE_A_ENTITIES = [
InspectionActResponsible,
InspectionActClosure,
InspectionActSignature,
InspectionDeadlinePolicy,
InspectionBusinessCalendarDay,
InspectionReportFollowUp,
SystemSmtpSettings,
FindingCategory,
FindingCatalogItem,
FindingCatalogItemAssetType,
@@ -3,6 +3,8 @@ import { Column, Entity, Index, PrimaryGeneratedColumn } from 'typeorm';
export enum InspectionActVersionEvent {
CREATED = 'CREATED',
UPDATED = 'UPDATED',
LOCKED = 'LOCKED',
SEALED = 'SEALED',
READY = 'READY',
REOPENED = 'REOPENED',
CLOSED = 'CLOSED',
@@ -3,12 +3,33 @@ import { TimestampedEntity } from './timestamped.entity';
export enum InspectionActStatus {
DRAFT = 'DRAFT',
LOCKED = 'LOCKED',
SEALED = 'SEALED',
READY = 'READY',
CLOSED = 'CLOSED',
CANCELLED = 'CANCELLED',
RECTIFIED = 'RECTIFIED',
}
export enum InspectionActUrgency {
URGENT = 'URGENT',
NON_URGENT = 'NON_URGENT',
}
export enum InspectionDeadlineDayType {
BUSINESS = 'BUSINESS',
CALENDAR = 'CALENDAR',
}
export enum InspectionDeadlineBasis {
ACT_DATE = 'ACT_DATE',
// La columna F4 temprana usó GEDO_DATE. Se conserva el valor físico por
// compatibilidad de migración, pero funcionalmente significa "pendiente de
// fecha de notificación" hasta que el procedimiento defina el evento válido.
NOTIFICATION_DATE = 'GEDO_DATE',
GEDO_DATE = 'GEDO_DATE',
}
@Entity({ name: 'inspection_acts' })
@Index('uq_inspection_acts_one_draft_per_visit', ['visitId'], { unique: true, where: "status = 'DRAFT'" })
@Index('uq_inspection_acts_year_number', ['actYear', 'actNumber'], { unique: true })
@@ -16,6 +37,7 @@ export enum InspectionActStatus {
@Index('idx_inspection_acts_visit_status', ['visitId', 'status'])
@Index('idx_inspection_acts_occurred_at', ['occurredAt'])
@Index('idx_inspection_acts_created_by', ['createdBy'])
@Index('idx_inspection_acts_deadline', ['deadlineAt'])
export class InspectionAct extends TimestampedEntity {
@PrimaryGeneratedColumn('uuid')
id!: string;
@@ -29,7 +51,7 @@ export class InspectionAct extends TimestampedEntity {
@Column({ name: 'act_number', type: 'integer' })
actNumber!: number;
@Column({ type: 'varchar', length: 24 })
@Column({ type: 'varchar', length: 40 })
code!: string;
@Column({ type: 'varchar', length: 24, default: InspectionActStatus.DRAFT })
@@ -47,6 +69,39 @@ export class InspectionAct extends TimestampedEntity {
@Column({ type: 'text', nullable: true })
observations!: string | null;
@Column({ name: 'urgency', type: 'varchar', length: 24, default: InspectionActUrgency.NON_URGENT })
urgency!: InspectionActUrgency;
@Column({ name: 'deadline_days', type: 'integer', nullable: true })
deadlineDays!: number | null;
@Column({ name: 'deadline_day_type', type: 'varchar', length: 24, nullable: true })
deadlineDayType!: InspectionDeadlineDayType | null;
@Column({ name: 'deadline_basis', type: 'varchar', length: 24, nullable: true })
deadlineBasis!: InspectionDeadlineBasis | null;
@Column({ name: 'deadline_base_at', type: 'timestamptz', nullable: true })
deadlineBaseAt!: Date | null;
@Column({ name: 'deadline_at', type: 'timestamptz', nullable: true })
deadlineAt!: Date | null;
@Column({ name: 'locked_at', type: 'timestamptz', nullable: true })
lockedAt!: Date | null;
@Column({ name: 'locked_by', type: 'uuid', nullable: true })
lockedBy!: string | null;
@Column({ name: 'locked_sha256', type: 'char', length: 64, nullable: true })
lockedSha256!: string | null;
@Column({ name: 'sealed_at', type: 'timestamptz', nullable: true })
sealedAt!: Date | null;
@Column({ name: 'sealed_by', type: 'uuid', nullable: true })
sealedBy!: string | null;
@Column({ name: 'current_version', type: 'integer', default: 0 })
currentVersion!: number;
@@ -0,0 +1,21 @@
import { Column, Entity, Index, PrimaryGeneratedColumn } from 'typeorm';
import { TimestampedEntity } from './timestamped.entity';
@Entity({ name: 'inspection_business_calendar_days' })
@Index('uq_inspection_business_calendar_day', ['date'], { unique: true })
export class InspectionBusinessCalendarDay extends TimestampedEntity {
@PrimaryGeneratedColumn('uuid')
id!: string;
@Column({ type: 'date' })
date!: string;
@Column({ name: 'is_business_day', type: 'boolean', default: false })
isBusinessDay!: boolean;
@Column({ type: 'varchar', length: 200 })
label!: string;
@Column({ name: 'updated_by', type: 'uuid', nullable: true })
updatedBy!: string | null;
}
@@ -0,0 +1,24 @@
import { Column, Entity, PrimaryGeneratedColumn } from 'typeorm';
import { InspectionDeadlineDayType } from './inspection-act.entity';
import { TimestampedEntity } from './timestamped.entity';
@Entity({ name: 'inspection_deadline_policies' })
export class InspectionDeadlinePolicy extends TimestampedEntity {
@PrimaryGeneratedColumn('uuid')
id!: string;
@Column({ name: 'urgent_days', type: 'integer', default: 5 })
urgentDays!: number;
@Column({ name: 'urgent_day_type', type: 'varchar', length: 24, default: InspectionDeadlineDayType.BUSINESS })
urgentDayType!: InspectionDeadlineDayType;
@Column({ name: 'non_urgent_days', type: 'integer', default: 10 })
nonUrgentDays!: number;
@Column({ name: 'non_urgent_day_type', type: 'varchar', length: 24, default: InspectionDeadlineDayType.BUSINESS })
nonUrgentDayType!: InspectionDeadlineDayType;
@Column({ name: 'updated_by', type: 'uuid', nullable: true })
updatedBy!: string | null;
}
@@ -1,6 +1,7 @@
import { Column, Entity, Index, PrimaryGeneratedColumn } from 'typeorm';
import { TimestampedEntity } from './timestamped.entity';
/** @deprecated Los plazos de respuesta pasan al nivel Acta/Informe en F4. */
export enum InspectionFindingResponseDueBasis {
FINDING_DATE = 'FINDING_DATE',
REPORT_NOTIFICATION = 'REPORT_NOTIFICATION',
@@ -18,6 +19,7 @@ export enum InspectionFindingStatus {
@Index('idx_inspection_findings_status_control', ['status', 'nextControlOn'])
@Index('idx_inspection_findings_asset_status', ['assetId', 'status'])
@Index('idx_inspection_findings_catalog_item', ['catalogItemId'])
@Index('idx_inspection_findings_recurrence_of', ['recurrenceOfFindingId'])
export class InspectionFinding extends TimestampedEntity {
@PrimaryGeneratedColumn('uuid')
id!: string;
@@ -61,9 +63,16 @@ export class InspectionFinding extends TimestampedEntity {
@Column({ type: 'smallint', nullable: true })
severity!: number | null;
@Column({ name: 'is_recurrence', type: 'boolean', default: false })
isRecurrence!: boolean;
@Column({ name: 'recurrence_of_finding_id', type: 'uuid', nullable: true })
recurrenceOfFindingId!: string | null;
@Column({ name: 'correction_due_on', type: 'date', nullable: true })
correctionDueOn!: string | null;
// Campos legacy conservados temporalmente para migrar datos sin pérdida.
@Column({ name: 'response_due_basis', type: 'varchar', length: 32, nullable: true })
responseDueBasis!: InspectionFindingResponseDueBasis | null;
@@ -0,0 +1,50 @@
import { Column, Entity, Index, PrimaryGeneratedColumn } from 'typeorm';
import { TimestampedEntity } from './timestamped.entity';
export enum InspectionReportFollowUpType {
COMPANY_NOTE = 'COMPANY_NOTE',
COMPANY_DOCUMENT = 'COMPANY_DOCUMENT',
INTERNAL_NOTE = 'INTERNAL_NOTE',
VERIFICATION = 'VERIFICATION',
OTHER = 'OTHER',
}
@Entity({ name: 'inspection_report_follow_ups' })
@Index('idx_inspection_report_follow_ups_report_created', ['reportId', 'createdAt'])
export class InspectionReportFollowUp extends TimestampedEntity {
@PrimaryGeneratedColumn('uuid')
id!: string;
@Column({ name: 'report_id', type: 'uuid' })
reportId!: string;
@Column({ type: 'varchar', length: 40 })
type!: InspectionReportFollowUpType;
@Column({ name: 'external_reference', type: 'varchar', length: 255, nullable: true })
externalReference!: string | null;
@Column({ name: 'occurred_at', type: 'timestamptz' })
occurredAt!: Date;
@Column({ type: 'text', nullable: true })
description!: string | null;
@Column({ name: 'original_name', type: 'varchar', length: 255, nullable: true })
originalName!: string | null;
@Column({ name: 'stored_name', type: 'varchar', length: 255, nullable: true })
storedName!: string | null;
@Column({ name: 'mime_type', type: 'varchar', length: 120, nullable: true })
mimeType!: string | null;
@Column({ name: 'size_bytes', type: 'integer', nullable: true })
sizeBytes!: number | null;
@Column({ name: 'sha256', type: 'char', length: 64, nullable: true })
sha256!: string | null;
@Column({ name: 'created_by', type: 'uuid', nullable: true })
createdBy!: string | null;
}
@@ -2,6 +2,8 @@ import { Column, Entity, Index, PrimaryGeneratedColumn } from 'typeorm';
import { TimestampedEntity } from './timestamped.entity';
export enum InspectionReportStatus {
WORKING = 'WORKING',
OFFICIALIZED = 'OFFICIALIZED',
FROZEN = 'FROZEN',
CANCELLED = 'CANCELLED',
}
@@ -18,12 +20,6 @@ export enum InspectionReportWordStatus {
FAILED = 'FAILED',
}
export enum InspectionReportReviewStatus {
PENDING_REVIEW = 'PENDING_REVIEW',
APPROVED = 'APPROVED',
SIGNED = 'SIGNED',
}
@Entity({ name: 'inspection_reports' })
@Index('idx_inspection_reports_visit_id', ['visitId'])
@Index('uq_inspection_reports_act', ['actId'], { unique: true })
@@ -31,6 +27,7 @@ export enum InspectionReportReviewStatus {
@Index('uq_inspection_reports_code', ['code'], { unique: true })
@Index('idx_inspection_reports_generated_at', ['generatedAt'])
@Index('idx_inspection_reports_status', ['status', 'pdfStatus'])
@Index('idx_inspection_reports_gedo_officialized_at', ['gedoOfficializedAt'])
export class InspectionReport extends TimestampedEntity {
@PrimaryGeneratedColumn('uuid')
id!: string;
@@ -47,12 +44,39 @@ export class InspectionReport extends TimestampedEntity {
@Column({ name: 'report_number', type: 'integer' })
reportNumber!: number;
@Column({ type: 'varchar', length: 24 })
@Column({ type: 'varchar', length: 40 })
code!: string;
@Column({ type: 'varchar', length: 24, default: InspectionReportStatus.FROZEN })
@Column({ type: 'varchar', length: 24, default: InspectionReportStatus.WORKING })
status!: InspectionReportStatus;
@Column({ name: 'executive_summary', type: 'text', nullable: true })
executiveSummary!: string | null;
@Column({ name: 'report_description', type: 'text', nullable: true })
reportDescription!: string | null;
@Column({ name: 'gedo_if_identifier', type: 'varchar', length: 255, nullable: true })
gedoIfIdentifier!: string | null;
@Column({ name: 'gedo_officialized_at', type: 'timestamptz', nullable: true })
gedoOfficializedAt!: Date | null;
@Column({ name: 'gedo_pdf_original_name', type: 'varchar', length: 255, nullable: true })
gedoPdfOriginalName!: string | null;
@Column({ name: 'gedo_pdf_stored_name', type: 'varchar', length: 255, nullable: true })
gedoPdfStoredName!: string | null;
@Column({ name: 'gedo_pdf_mime_type', type: 'varchar', length: 120, nullable: true })
gedoPdfMimeType!: string | null;
@Column({ name: 'gedo_pdf_size_bytes', type: 'integer', nullable: true })
gedoPdfSizeBytes!: number | null;
@Column({ name: 'gedo_pdf_sha256', type: 'char', length: 64, nullable: true })
gedoPdfSha256!: string | null;
@Column({ name: 'pdf_status', type: 'varchar', length: 24, default: InspectionReportPdfStatus.PENDING })
pdfStatus!: InspectionReportPdfStatus;
@@ -80,27 +104,9 @@ export class InspectionReport extends TimestampedEntity {
@Column({ name: 'word_error', type: 'varchar', length: 500, nullable: true })
wordError!: string | null;
@Column({ name: 'review_status', type: 'varchar', length: 32, default: InspectionReportReviewStatus.PENDING_REVIEW })
reviewStatus!: InspectionReportReviewStatus;
@Column({ name: 'current_revision_number', type: 'integer', default: 0 })
currentRevisionNumber!: number;
@Column({ name: 'approved_revision_id', type: 'uuid', nullable: true })
approvedRevisionId!: string | null;
@Column({ name: 'approved_by', type: 'uuid', nullable: true })
approvedBy!: string | null;
@Column({ name: 'approved_at', type: 'timestamptz', nullable: true })
approvedAt!: Date | null;
@Column({ name: 'review_note', type: 'varchar', length: 1000, nullable: true })
reviewNote!: string | null;
@Column({ name: 'signed_at', type: 'timestamptz', nullable: true })
signedAt!: Date | null;
@Column({ type: 'varchar', length: 220 })
title!: string;
@@ -14,7 +14,7 @@ export enum InspectionVisitStatus {
@Index('idx_inspection_visits_status', ['status'])
@Index('idx_inspection_visits_scope_asset_id', ['scopeAssetId'])
@Index('idx_inspection_visits_lead_inspector_user_id', ['leadInspectorUserId'])
@Index('idx_inspection_visits_planned_dates', ['plannedStartAt', 'plannedEndAt'])
@Index('idx_inspection_visits_planned_start_at', ['plannedStartAt'])
@Index('idx_inspection_visits_operational_context', ['operationalAreaId', 'operatorCompanyId'])
export class InspectionVisit extends TimestampedEntity {
@PrimaryGeneratedColumn('uuid')
@@ -23,9 +23,6 @@ export class InspectionVisit extends TimestampedEntity {
@Column({ type: 'varchar', length: 80 })
code!: string;
@Column({ type: 'varchar', length: 200 })
title!: string;
@Column({ type: 'text', nullable: true })
objective!: string | null;
@@ -47,9 +44,6 @@ export class InspectionVisit extends TimestampedEntity {
@Column({ name: 'planned_start_at', type: 'timestamptz', nullable: true })
plannedStartAt!: Date | null;
@Column({ name: 'planned_end_at', type: 'timestamptz', nullable: true })
plannedEndAt!: Date | null;
@Column({ name: 'actual_started_at', type: 'timestamptz', nullable: true })
actualStartedAt!: Date | null;
@@ -1,44 +0,0 @@
import { Column, Entity, Index, PrimaryGeneratedColumn } from 'typeorm';
import { TimestampedEntity } from './timestamped.entity';
export enum SurveyTargetStatus {
PENDING = 'PENDING',
IN_PROGRESS = 'IN_PROGRESS',
SUBMITTED = 'SUBMITTED',
COMPLETED = 'COMPLETED',
SKIPPED = 'SKIPPED',
}
@Entity({ name: 'survey_campaign_targets' })
@Index('uq_survey_campaign_targets_campaign_asset', ['campaignId', 'assetId'], { unique: true })
@Index('idx_survey_campaign_targets_campaign_status', ['campaignId', 'status'])
@Index('idx_survey_campaign_targets_asset_id', ['assetId'])
@Index('idx_survey_campaign_targets_assigned_user_id', ['assignedUserId'])
export class SurveyCampaignTarget extends TimestampedEntity {
@PrimaryGeneratedColumn('uuid')
id!: string;
@Column({ name: 'campaign_id', type: 'uuid' })
campaignId!: string;
@Column({ name: 'asset_id', type: 'uuid' })
assetId!: string;
@Column({ name: 'assigned_user_id', type: 'uuid', nullable: true })
assignedUserId!: string | null;
@Column({ type: 'varchar', length: 24, default: SurveyTargetStatus.PENDING })
status!: SurveyTargetStatus;
@Column({ name: 'due_at', type: 'timestamptz', nullable: true })
dueAt!: Date | null;
@Column({ type: 'text', nullable: true })
instructions!: string | null;
@Column({ name: 'created_by', type: 'uuid', nullable: true })
createdBy!: string | null;
@Column({ name: 'updated_by', type: 'uuid', nullable: true })
updatedBy!: string | null;
}
@@ -1,51 +0,0 @@
import { Column, Entity, Index, PrimaryGeneratedColumn } from 'typeorm';
import { TimestampedEntity } from './timestamped.entity';
export enum SurveyCampaignStatus {
DRAFT = 'DRAFT',
PLANNED = 'PLANNED',
IN_PROGRESS = 'IN_PROGRESS',
COMPLETED = 'COMPLETED',
CANCELLED = 'CANCELLED',
}
@Entity({ name: 'survey_campaigns' })
@Index('uq_survey_campaigns_code', ['code'], { unique: true })
@Index('idx_survey_campaigns_status', ['status'])
@Index('idx_survey_campaigns_scope_asset_id', ['scopeAssetId'])
@Index('idx_survey_campaigns_coordinator_user_id', ['coordinatorUserId'])
@Index('idx_survey_campaigns_planned_dates', ['plannedStartAt', 'plannedEndAt'])
export class SurveyCampaign extends TimestampedEntity {
@PrimaryGeneratedColumn('uuid')
id!: string;
@Column({ type: 'varchar', length: 80 })
code!: string;
@Column({ type: 'varchar', length: 200 })
name!: string;
@Column({ type: 'text', nullable: true })
description!: string | null;
@Column({ type: 'varchar', length: 24, default: SurveyCampaignStatus.DRAFT })
status!: SurveyCampaignStatus;
@Column({ name: 'planned_start_at', type: 'timestamptz', nullable: true })
plannedStartAt!: Date | null;
@Column({ name: 'planned_end_at', type: 'timestamptz', nullable: true })
plannedEndAt!: Date | null;
@Column({ name: 'scope_asset_id', type: 'uuid', nullable: true })
scopeAssetId!: string | null;
@Column({ name: 'coordinator_user_id', type: 'uuid', nullable: true })
coordinatorUserId!: string | null;
@Column({ name: 'created_by', type: 'uuid', nullable: true })
createdBy!: string | null;
@Column({ name: 'updated_by', type: 'uuid', nullable: true })
updatedBy!: string | null;
}
@@ -1,19 +0,0 @@
import { Column, Entity, Index, PrimaryColumn } from 'typeorm';
import { TimestampedEntity } from './timestamped.entity';
@Entity({ name: 'survey_target_report_media' })
@Index('idx_survey_target_report_media_media_id', ['mediaId'])
@Index('idx_survey_target_report_media_included', ['reportId', 'included'])
export class SurveyTargetReportMedia extends TimestampedEntity {
@PrimaryColumn({ name: 'report_id', type: 'uuid' })
reportId!: string;
@PrimaryColumn({ name: 'media_id', type: 'uuid' })
mediaId!: string;
@Column({ type: 'boolean', default: true })
included!: boolean;
@Column({ name: 'added_by', type: 'uuid', nullable: true })
addedBy!: string | null;
}
@@ -1,36 +0,0 @@
import { Column, Entity, Index, PrimaryGeneratedColumn } from 'typeorm';
export enum SurveyReportVersionEvent {
SUBMITTED = 'SUBMITTED',
APPROVED = 'APPROVED',
REJECTED = 'REJECTED',
}
@Entity({ name: 'survey_target_report_versions' })
@Index('uq_survey_target_report_versions_number', ['reportId', 'versionNumber'], { unique: true })
@Index('idx_survey_target_report_versions_created_at', ['createdAt'])
export class SurveyTargetReportVersion {
@PrimaryGeneratedColumn('uuid')
id!: string;
@Column({ name: 'report_id', type: 'uuid' })
reportId!: string;
@Column({ name: 'version_number', type: 'integer' })
versionNumber!: number;
@Column({ type: 'varchar', length: 24 })
event!: SurveyReportVersionEvent;
@Column({ type: 'jsonb' })
snapshot!: Record<string, unknown>;
@Column({ name: 'actor_user_id', type: 'uuid', nullable: true })
actorUserId!: string | null;
@Column({ name: 'actor_username', type: 'varchar', length: 80, nullable: true })
actorUsername!: string | null;
@Column({ name: 'created_at', type: 'timestamptz', default: () => 'CURRENT_TIMESTAMP' })
createdAt!: Date;
}
@@ -1,73 +0,0 @@
import { Column, Entity, Index, PrimaryGeneratedColumn } from 'typeorm';
import { TimestampedEntity } from './timestamped.entity';
export enum SurveyReportOutcome {
CONFIRMED = 'CONFIRMED',
CHANGES_RECORDED = 'CHANGES_RECORDED',
NOT_LOCATED = 'NOT_LOCATED',
}
export enum SurveyReportStatus {
DRAFT = 'DRAFT',
SUBMITTED = 'SUBMITTED',
APPROVED = 'APPROVED',
REJECTED = 'REJECTED',
}
@Entity({ name: 'survey_target_reports' })
@Index('uq_survey_target_reports_target_id', ['targetId'], { unique: true })
@Index('idx_survey_target_reports_status', ['status'])
@Index('idx_survey_target_reports_submitted_by', ['submittedBy'])
@Index('idx_survey_target_reports_reviewed_by', ['reviewedBy'])
export class SurveyTargetReport extends TimestampedEntity {
@PrimaryGeneratedColumn('uuid')
id!: string;
@Column({ name: 'target_id', type: 'uuid' })
targetId!: string;
@Column({ type: 'varchar', length: 32, nullable: true })
outcome!: SurveyReportOutcome | null;
@Column({ type: 'varchar', length: 24, default: SurveyReportStatus.DRAFT })
status!: SurveyReportStatus;
@Column({ name: 'observed_at', type: 'timestamptz', nullable: true })
observedAt!: Date | null;
@Column({ type: 'numeric', precision: 9, scale: 6, nullable: true })
latitude!: string | null;
@Column({ type: 'numeric', precision: 9, scale: 6, nullable: true })
longitude!: string | null;
@Column({ name: 'accuracy_m', type: 'numeric', precision: 12, scale: 3, nullable: true })
accuracyM!: string | null;
@Column({ type: 'text', nullable: true })
notes!: string | null;
@Column({ name: 'asset_version_at_submission', type: 'integer', nullable: true })
assetVersionAtSubmission!: number | null;
@Column({ name: 'submitted_at', type: 'timestamptz', nullable: true })
submittedAt!: Date | null;
@Column({ name: 'submitted_by', type: 'uuid', nullable: true })
submittedBy!: string | null;
@Column({ name: 'reviewed_at', type: 'timestamptz', nullable: true })
reviewedAt!: Date | null;
@Column({ name: 'reviewed_by', type: 'uuid', nullable: true })
reviewedBy!: string | null;
@Column({ name: 'review_notes', type: 'text', nullable: true })
reviewNotes!: string | null;
@Column({ name: 'created_by', type: 'uuid', nullable: true })
createdBy!: string | null;
@Column({ name: 'updated_by', type: 'uuid', nullable: true })
updatedBy!: string | null;
}
@@ -0,0 +1,44 @@
import { Column, Entity, PrimaryGeneratedColumn } from 'typeorm';
import { TimestampedEntity } from './timestamped.entity';
export enum SmtpSecurityMode {
NONE = 'NONE',
STARTTLS = 'STARTTLS',
TLS = 'TLS',
}
@Entity({ name: 'system_smtp_settings' })
export class SystemSmtpSettings extends TimestampedEntity {
@PrimaryGeneratedColumn('uuid')
id!: string;
@Column({ type: 'varchar', length: 255 })
host!: string;
@Column({ type: 'integer' })
port!: number;
@Column({ name: 'security_mode', type: 'varchar', length: 24, default: SmtpSecurityMode.STARTTLS })
securityMode!: SmtpSecurityMode;
@Column({ type: 'varchar', length: 255, nullable: true })
username!: string | null;
@Column({ name: 'password_enc', type: 'text', nullable: true })
passwordEnc!: string | null;
@Column({ name: 'from_name', type: 'varchar', length: 200 })
fromName!: string;
@Column({ name: 'from_email', type: 'varchar', length: 320 })
fromEmail!: string;
@Column({ name: 'reply_to', type: 'varchar', length: 320, nullable: true })
replyTo!: string | null;
@Column({ type: 'boolean', default: true })
enabled!: boolean;
@Column({ name: 'updated_by', type: 'uuid', nullable: true })
updatedBy!: string | null;
}
@@ -0,0 +1,161 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
export class F4DocumentWorkflowFoundation1790000000000 implements MigrationInterface {
name = 'F4DocumentWorkflowFoundation1790000000000';
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`ALTER TABLE inspection_acts ALTER COLUMN code TYPE varchar(40)`);
await queryRunner.query(`ALTER TABLE inspection_reports ALTER COLUMN code TYPE varchar(40)`);
await queryRunner.query(`ALTER TABLE inspection_acts ADD COLUMN IF NOT EXISTS urgency varchar(24) NOT NULL DEFAULT 'NON_URGENT'`);
await queryRunner.query(`ALTER TABLE inspection_acts ADD COLUMN IF NOT EXISTS deadline_days integer`);
await queryRunner.query(`ALTER TABLE inspection_acts ADD COLUMN IF NOT EXISTS deadline_day_type varchar(24)`);
await queryRunner.query(`ALTER TABLE inspection_acts ADD COLUMN IF NOT EXISTS deadline_basis varchar(24)`);
await queryRunner.query(`ALTER TABLE inspection_acts ADD COLUMN IF NOT EXISTS deadline_base_at timestamptz`);
await queryRunner.query(`ALTER TABLE inspection_acts ADD COLUMN IF NOT EXISTS deadline_at timestamptz`);
await queryRunner.query(`ALTER TABLE inspection_acts ADD COLUMN IF NOT EXISTS locked_at timestamptz`);
await queryRunner.query(`ALTER TABLE inspection_acts ADD COLUMN IF NOT EXISTS locked_by uuid`);
await queryRunner.query(`ALTER TABLE inspection_acts ADD COLUMN IF NOT EXISTS locked_sha256 char(64)`);
await queryRunner.query(`ALTER TABLE inspection_acts ADD COLUMN IF NOT EXISTS sealed_at timestamptz`);
await queryRunner.query(`ALTER TABLE inspection_acts ADD COLUMN IF NOT EXISTS sealed_by uuid`);
await queryRunner.query(`CREATE INDEX IF NOT EXISTS idx_inspection_acts_deadline ON inspection_acts(deadline_at)`);
await queryRunner.query(`ALTER TABLE inspection_findings ADD COLUMN IF NOT EXISTS is_recurrence boolean NOT NULL DEFAULT false`);
await queryRunner.query(`ALTER TABLE inspection_findings ADD COLUMN IF NOT EXISTS recurrence_of_finding_id uuid`);
await queryRunner.query(`CREATE INDEX IF NOT EXISTS idx_inspection_findings_recurrence_of ON inspection_findings(recurrence_of_finding_id)`);
await queryRunner.query(`
DO $$ BEGIN
ALTER TABLE inspection_findings
ADD CONSTRAINT fk_inspection_findings_recurrence_of
FOREIGN KEY (recurrence_of_finding_id) REFERENCES inspection_findings(id) ON DELETE RESTRICT;
EXCEPTION WHEN duplicate_object THEN NULL; END $$;
`);
await queryRunner.query(`ALTER TABLE inspection_reports ADD COLUMN IF NOT EXISTS executive_summary text`);
await queryRunner.query(`ALTER TABLE inspection_reports ADD COLUMN IF NOT EXISTS report_description text`);
await queryRunner.query(`ALTER TABLE inspection_reports ADD COLUMN IF NOT EXISTS gedo_if_identifier varchar(255)`);
await queryRunner.query(`ALTER TABLE inspection_reports ADD COLUMN IF NOT EXISTS gedo_officialized_at timestamptz`);
await queryRunner.query(`ALTER TABLE inspection_reports ADD COLUMN IF NOT EXISTS gedo_pdf_original_name varchar(255)`);
await queryRunner.query(`ALTER TABLE inspection_reports ADD COLUMN IF NOT EXISTS gedo_pdf_stored_name varchar(255)`);
await queryRunner.query(`ALTER TABLE inspection_reports ADD COLUMN IF NOT EXISTS gedo_pdf_mime_type varchar(120)`);
await queryRunner.query(`ALTER TABLE inspection_reports ADD COLUMN IF NOT EXISTS gedo_pdf_size_bytes integer`);
await queryRunner.query(`ALTER TABLE inspection_reports ADD COLUMN IF NOT EXISTS gedo_pdf_sha256 char(64)`);
await queryRunner.query(`CREATE INDEX IF NOT EXISTS idx_inspection_reports_gedo_officialized_at ON inspection_reports(gedo_officialized_at)`);
await queryRunner.query(`
CREATE TABLE IF NOT EXISTS inspection_deadline_policies (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
urgent_days integer NOT NULL DEFAULT 5 CHECK (urgent_days > 0),
urgent_day_type varchar(24) NOT NULL DEFAULT 'BUSINESS',
non_urgent_days integer NOT NULL DEFAULT 10 CHECK (non_urgent_days > 0),
non_urgent_day_type varchar(24) NOT NULL DEFAULT 'BUSINESS',
updated_by uuid,
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now(),
CONSTRAINT ck_inspection_deadline_policy_urgent_type CHECK (urgent_day_type IN ('BUSINESS','CALENDAR')),
CONSTRAINT ck_inspection_deadline_policy_non_urgent_type CHECK (non_urgent_day_type IN ('BUSINESS','CALENDAR'))
)
`);
await queryRunner.query(`
INSERT INTO inspection_deadline_policies (urgent_days, urgent_day_type, non_urgent_days, non_urgent_day_type)
SELECT 5, 'BUSINESS', 10, 'BUSINESS'
WHERE NOT EXISTS (SELECT 1 FROM inspection_deadline_policies)
`);
await queryRunner.query(`
CREATE TABLE IF NOT EXISTS inspection_business_calendar_days (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
date date NOT NULL,
is_business_day boolean NOT NULL DEFAULT false,
label varchar(200) NOT NULL,
updated_by uuid,
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now(),
CONSTRAINT uq_inspection_business_calendar_day UNIQUE(date)
)
`);
await queryRunner.query(`
CREATE TABLE IF NOT EXISTS inspection_report_follow_ups (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
report_id uuid NOT NULL,
type varchar(40) NOT NULL,
external_reference varchar(255),
occurred_at timestamptz NOT NULL,
description text,
original_name varchar(255),
stored_name varchar(255),
mime_type varchar(120),
size_bytes integer,
sha256 char(64),
created_by uuid,
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now(),
CONSTRAINT fk_inspection_report_follow_ups_report FOREIGN KEY(report_id) REFERENCES inspection_reports(id) ON DELETE CASCADE,
CONSTRAINT ck_inspection_report_follow_up_type CHECK (type IN ('COMPANY_NOTE','COMPANY_DOCUMENT','INTERNAL_NOTE','VERIFICATION','OTHER'))
)
`);
await queryRunner.query(`CREATE INDEX IF NOT EXISTS idx_inspection_report_follow_ups_report_created ON inspection_report_follow_ups(report_id, created_at)`);
await queryRunner.query(`
CREATE TABLE IF NOT EXISTS system_smtp_settings (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
host varchar(255) NOT NULL,
port integer NOT NULL CHECK (port > 0 AND port <= 65535),
security_mode varchar(24) NOT NULL DEFAULT 'STARTTLS',
username varchar(255),
password_enc text,
from_name varchar(200) NOT NULL,
from_email varchar(320) NOT NULL,
reply_to varchar(320),
enabled boolean NOT NULL DEFAULT true,
updated_by uuid,
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now(),
CONSTRAINT ck_system_smtp_security_mode CHECK (security_mode IN ('NONE','STARTTLS','TLS'))
)
`);
await queryRunner.query(`
UPDATE inspection_acts
SET
deadline_days = COALESCE(deadline_days, 10),
deadline_day_type = COALESCE(deadline_day_type, 'BUSINESS'),
deadline_basis = COALESCE(deadline_basis, 'GEDO_DATE')
WHERE deadline_days IS NULL OR deadline_day_type IS NULL OR deadline_basis IS NULL
`);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`DROP TABLE IF EXISTS system_smtp_settings`);
await queryRunner.query(`DROP TABLE IF EXISTS inspection_report_follow_ups`);
await queryRunner.query(`DROP TABLE IF EXISTS inspection_business_calendar_days`);
await queryRunner.query(`DROP TABLE IF EXISTS inspection_deadline_policies`);
await queryRunner.query(`ALTER TABLE inspection_reports DROP COLUMN IF EXISTS gedo_pdf_sha256`);
await queryRunner.query(`ALTER TABLE inspection_reports DROP COLUMN IF EXISTS gedo_pdf_size_bytes`);
await queryRunner.query(`ALTER TABLE inspection_reports DROP COLUMN IF EXISTS gedo_pdf_mime_type`);
await queryRunner.query(`ALTER TABLE inspection_reports DROP COLUMN IF EXISTS gedo_pdf_stored_name`);
await queryRunner.query(`ALTER TABLE inspection_reports DROP COLUMN IF EXISTS gedo_pdf_original_name`);
await queryRunner.query(`ALTER TABLE inspection_reports DROP COLUMN IF EXISTS gedo_officialized_at`);
await queryRunner.query(`ALTER TABLE inspection_reports DROP COLUMN IF EXISTS gedo_if_identifier`);
await queryRunner.query(`ALTER TABLE inspection_reports DROP COLUMN IF EXISTS report_description`);
await queryRunner.query(`ALTER TABLE inspection_reports DROP COLUMN IF EXISTS executive_summary`);
await queryRunner.query(`ALTER TABLE inspection_findings DROP CONSTRAINT IF EXISTS fk_inspection_findings_recurrence_of`);
await queryRunner.query(`ALTER TABLE inspection_findings DROP COLUMN IF EXISTS recurrence_of_finding_id`);
await queryRunner.query(`ALTER TABLE inspection_findings DROP COLUMN IF EXISTS is_recurrence`);
await queryRunner.query(`ALTER TABLE inspection_acts DROP COLUMN IF EXISTS sealed_by`);
await queryRunner.query(`ALTER TABLE inspection_acts DROP COLUMN IF EXISTS sealed_at`);
await queryRunner.query(`ALTER TABLE inspection_acts DROP COLUMN IF EXISTS locked_sha256`);
await queryRunner.query(`ALTER TABLE inspection_acts DROP COLUMN IF EXISTS locked_by`);
await queryRunner.query(`ALTER TABLE inspection_acts DROP COLUMN IF EXISTS locked_at`);
await queryRunner.query(`ALTER TABLE inspection_acts DROP COLUMN IF EXISTS deadline_at`);
await queryRunner.query(`ALTER TABLE inspection_acts DROP COLUMN IF EXISTS deadline_base_at`);
await queryRunner.query(`ALTER TABLE inspection_acts DROP COLUMN IF EXISTS deadline_basis`);
await queryRunner.query(`ALTER TABLE inspection_acts DROP COLUMN IF EXISTS deadline_day_type`);
await queryRunner.query(`ALTER TABLE inspection_acts DROP COLUMN IF EXISTS deadline_days`);
await queryRunner.query(`ALTER TABLE inspection_acts DROP COLUMN IF EXISTS urgency`);
}
}
@@ -0,0 +1,80 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
export class F4InventoryFunctionHistory1790000100000 implements MigrationInterface {
name = 'F4InventoryFunctionHistory1790000100000';
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
CREATE TABLE inventory_functions (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
code varchar(120) NOT NULL UNIQUE,
name varchar(240) NOT NULL,
description text,
is_active boolean NOT NULL DEFAULT true,
sort_order integer NOT NULL DEFAULT 0,
created_by uuid,
updated_by uuid,
created_at timestamptz NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at timestamptz NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT fk_inventory_functions_created_by FOREIGN KEY (created_by)
REFERENCES users(id) ON DELETE SET NULL,
CONSTRAINT fk_inventory_functions_updated_by FOREIGN KEY (updated_by)
REFERENCES users(id) ON DELETE SET NULL
)
`);
await queryRunner.query(`
CREATE INDEX idx_inventory_functions_active_sort
ON inventory_functions(is_active,sort_order,name)
`);
await queryRunner.query(`
CREATE TABLE inventory_function_assignments (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
asset_id uuid NOT NULL,
function_id uuid NOT NULL,
valid_from timestamptz NOT NULL,
valid_until timestamptz,
change_reason text,
changed_by uuid,
created_at timestamptz NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT fk_inventory_function_assignment_asset FOREIGN KEY (asset_id)
REFERENCES assets(id) ON DELETE CASCADE,
CONSTRAINT fk_inventory_function_assignment_function FOREIGN KEY (function_id)
REFERENCES inventory_functions(id) ON DELETE RESTRICT,
CONSTRAINT fk_inventory_function_assignment_user FOREIGN KEY (changed_by)
REFERENCES users(id) ON DELETE SET NULL,
CONSTRAINT chk_inventory_function_assignment_dates CHECK (
valid_until IS NULL OR valid_until > valid_from
)
)
`);
await queryRunner.query(`
CREATE UNIQUE INDEX uq_inventory_function_assignment_current
ON inventory_function_assignments(asset_id)
WHERE valid_until IS NULL
`);
await queryRunner.query(`
CREATE INDEX idx_inventory_function_assignment_history
ON inventory_function_assignments(asset_id,valid_from DESC,created_at DESC)
`);
await queryRunner.query(`
CREATE INDEX idx_inventory_function_assignment_function
ON inventory_function_assignments(function_id,valid_from DESC)
`);
await queryRunner.query(`
INSERT INTO inventory_functions(code,name,description,sort_order)
VALUES
('BOMBEO_MECANICO_AIB','Bombeo mecánico AIB',
'Función técnica de bombeo mecánico mediante aparato individual de bombeo (AIB).',10),
('BOMBEO_MECANICO_ROTAFLEX','Bombeo mecánico Rotaflex',
'Función técnica de bombeo mecánico mediante sistema Rotaflex.',20)
ON CONFLICT (code) DO NOTHING
`);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query('DROP TABLE IF EXISTS inventory_function_assignments');
await queryRunner.query('DROP TABLE IF EXISTS inventory_functions');
}
}
@@ -0,0 +1,51 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
export class F4CompanySignatureInvites1790000200000 implements MigrationInterface {
name = 'F4CompanySignatureInvites1790000200000';
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
CREATE TABLE inspection_act_company_signature_invites (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
act_id uuid NOT NULL,
token_sha256 char(64) NOT NULL UNIQUE,
recipient_email varchar(320) NOT NULL,
recipient_name varchar(200),
recipient_document_type varchar(20),
recipient_document_number varchar(40),
recipient_position varchar(200),
status varchar(20) NOT NULL DEFAULT 'PENDING',
expires_at timestamptz NOT NULL,
sent_at timestamptz,
used_at timestamptz,
revoked_at timestamptz,
created_by uuid NOT NULL,
revoked_by uuid,
created_at timestamptz NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at timestamptz NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT fk_company_signature_invite_act FOREIGN KEY (act_id)
REFERENCES inspection_acts(id) ON DELETE CASCADE,
CONSTRAINT fk_company_signature_invite_created_by FOREIGN KEY (created_by)
REFERENCES users(id) ON DELETE RESTRICT,
CONSTRAINT fk_company_signature_invite_revoked_by FOREIGN KEY (revoked_by)
REFERENCES users(id) ON DELETE SET NULL,
CONSTRAINT chk_company_signature_invite_status CHECK (
status IN ('PENDING','USED','REVOKED','EXPIRED')
)
)
`);
await queryRunner.query(`
CREATE INDEX idx_company_signature_invites_act_created
ON inspection_act_company_signature_invites(act_id,created_at DESC)
`);
await queryRunner.query(`
CREATE INDEX idx_company_signature_invites_pending_expiry
ON inspection_act_company_signature_invites(status,expires_at)
WHERE status='PENDING'
`);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query('DROP TABLE IF EXISTS inspection_act_company_signature_invites');
}
}
@@ -0,0 +1,220 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
const surveyPermissions = [
'surveys.read',
'surveys.manage',
'surveys.assign',
'surveys.execute',
'surveys.read_reports',
'surveys.capture',
'surveys.review',
] as const;
const rolePermissionValues = `
('admin', 'surveys.read'),
('admin', 'surveys.manage'),
('admin', 'surveys.assign'),
('admin', 'surveys.execute'),
('admin', 'surveys.read_reports'),
('admin', 'surveys.capture'),
('admin', 'surveys.review'),
('director', 'surveys.read'),
('director', 'surveys.read_reports'),
('director', 'surveys.review'),
('supervisor', 'surveys.read'),
('supervisor', 'surveys.manage'),
('supervisor', 'surveys.assign'),
('supervisor', 'surveys.execute'),
('supervisor', 'surveys.read_reports'),
('supervisor', 'surveys.capture'),
('supervisor', 'surveys.review'),
('inspector', 'surveys.read'),
('inspector', 'surveys.execute'),
('inspector', 'surveys.read_reports'),
('inspector', 'surveys.capture'),
('auditor', 'surveys.read'),
('auditor', 'surveys.read_reports')
`;
function quoteIdentifier(identifier: string): string {
return `"${identifier.replaceAll('"', '""')}"`;
}
export class F4RemoveSurveySubsystem1790000300000 implements MigrationInterface {
name = 'F4RemoveSurveySubsystem1790000300000';
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
DELETE FROM role_permissions
WHERE permission_id IN (
SELECT id FROM permissions WHERE code = ANY($1::varchar[])
)
`, [surveyPermissions]);
await queryRunner.query(
`DELETE FROM permissions WHERE code = ANY($1::varchar[])`,
[surveyPermissions],
);
// Deliberadamente sin CASCADE: una dependencia externa debe detener la migración.
await queryRunner.query(`DROP TABLE IF EXISTS survey_target_report_versions`);
await queryRunner.query(`DROP TABLE IF EXISTS survey_target_report_media`);
await queryRunner.query(`DROP TABLE IF EXISTS survey_target_reports`);
await queryRunner.query(`DROP TABLE IF EXISTS survey_campaign_targets`);
await queryRunner.query(`DROP TABLE IF EXISTS survey_campaigns`);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
CREATE TABLE IF NOT EXISTS survey_campaigns (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
code varchar(80) NOT NULL UNIQUE,
name varchar(200) NOT NULL,
description text,
status varchar(24) NOT NULL DEFAULT 'DRAFT',
planned_start_at timestamptz,
planned_end_at timestamptz,
scope_asset_id uuid REFERENCES assets(id) ON DELETE RESTRICT,
coordinator_user_id uuid REFERENCES users(id) ON DELETE SET NULL,
created_by uuid REFERENCES users(id) ON DELETE SET NULL,
updated_by uuid REFERENCES users(id) ON DELETE SET NULL,
created_at timestamptz NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at timestamptz NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT chk_survey_campaigns_status CHECK (
status IN ('DRAFT','PLANNED','IN_PROGRESS','COMPLETED','CANCELLED')
),
CONSTRAINT chk_survey_campaigns_dates CHECK (
planned_start_at IS NULL OR planned_end_at IS NULL OR planned_end_at >= planned_start_at
)
)
`);
await queryRunner.query(`CREATE INDEX IF NOT EXISTS idx_survey_campaigns_status ON survey_campaigns(status)`);
await queryRunner.query(`CREATE INDEX IF NOT EXISTS idx_survey_campaigns_scope_asset_id ON survey_campaigns(scope_asset_id)`);
await queryRunner.query(`CREATE INDEX IF NOT EXISTS idx_survey_campaigns_coordinator_user_id ON survey_campaigns(coordinator_user_id)`);
await queryRunner.query(`CREATE INDEX IF NOT EXISTS idx_survey_campaigns_planned_dates ON survey_campaigns(planned_start_at,planned_end_at)`);
await queryRunner.query(`
CREATE TABLE IF NOT EXISTS survey_campaign_targets (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
campaign_id uuid NOT NULL REFERENCES survey_campaigns(id) ON DELETE RESTRICT,
asset_id uuid NOT NULL REFERENCES assets(id) ON DELETE RESTRICT,
assigned_user_id uuid REFERENCES users(id) ON DELETE SET NULL,
status varchar(24) NOT NULL DEFAULT 'PENDING',
due_at timestamptz,
instructions text,
created_by uuid REFERENCES users(id) ON DELETE SET NULL,
updated_by uuid REFERENCES users(id) ON DELETE SET NULL,
created_at timestamptz NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at timestamptz NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT uq_survey_campaign_targets_campaign_asset UNIQUE(campaign_id,asset_id),
CONSTRAINT chk_survey_campaign_targets_status CHECK (
status IN ('PENDING','IN_PROGRESS','SUBMITTED','COMPLETED','SKIPPED')
)
)
`);
await queryRunner.query(`CREATE INDEX IF NOT EXISTS idx_survey_campaign_targets_campaign_status ON survey_campaign_targets(campaign_id,status)`);
await queryRunner.query(`CREATE INDEX IF NOT EXISTS idx_survey_campaign_targets_asset_id ON survey_campaign_targets(asset_id)`);
await queryRunner.query(`CREATE INDEX IF NOT EXISTS idx_survey_campaign_targets_assigned_user_id ON survey_campaign_targets(assigned_user_id)`);
await queryRunner.query(`
CREATE TABLE IF NOT EXISTS survey_target_reports (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
target_id uuid NOT NULL UNIQUE REFERENCES survey_campaign_targets(id) ON DELETE RESTRICT,
outcome varchar(32),
status varchar(24) NOT NULL DEFAULT 'DRAFT',
observed_at timestamptz,
latitude numeric(9,6),
longitude numeric(9,6),
accuracy_m numeric(12,3),
notes text,
asset_version_at_submission integer,
submitted_at timestamptz,
submitted_by uuid REFERENCES users(id) ON DELETE SET NULL,
reviewed_at timestamptz,
reviewed_by uuid REFERENCES users(id) ON DELETE SET NULL,
review_notes text,
created_by uuid REFERENCES users(id) ON DELETE SET NULL,
updated_by uuid REFERENCES users(id) ON DELETE SET NULL,
created_at timestamptz NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at timestamptz NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT chk_survey_target_reports_outcome CHECK (
outcome IS NULL OR outcome IN ('CONFIRMED','CHANGES_RECORDED','NOT_LOCATED')
),
CONSTRAINT chk_survey_target_reports_status CHECK (
status IN ('DRAFT','SUBMITTED','APPROVED','REJECTED')
),
CONSTRAINT chk_survey_target_reports_coordinates CHECK (
(latitude IS NULL AND longitude IS NULL)
OR (latitude IS NOT NULL AND longitude IS NOT NULL
AND latitude BETWEEN -90 AND 90 AND longitude BETWEEN -180 AND 180)
),
CONSTRAINT chk_survey_target_reports_accuracy CHECK (
accuracy_m IS NULL OR (accuracy_m >= 0 AND latitude IS NOT NULL AND longitude IS NOT NULL)
)
)
`);
await queryRunner.query(`CREATE INDEX IF NOT EXISTS idx_survey_target_reports_status ON survey_target_reports(status)`);
await queryRunner.query(`CREATE INDEX IF NOT EXISTS idx_survey_target_reports_submitted_by ON survey_target_reports(submitted_by)`);
await queryRunner.query(`CREATE INDEX IF NOT EXISTS idx_survey_target_reports_reviewed_by ON survey_target_reports(reviewed_by)`);
await queryRunner.query(`
CREATE TABLE IF NOT EXISTS survey_target_report_media (
report_id uuid NOT NULL REFERENCES survey_target_reports(id) ON DELETE RESTRICT,
media_id uuid NOT NULL REFERENCES asset_media(id) ON DELETE RESTRICT,
included boolean NOT NULL DEFAULT true,
added_by uuid REFERENCES users(id) ON DELETE SET NULL,
created_at timestamptz NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at timestamptz NOT NULL DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY(report_id,media_id)
)
`);
await queryRunner.query(`CREATE INDEX IF NOT EXISTS idx_survey_target_report_media_media_id ON survey_target_report_media(media_id)`);
await queryRunner.query(`CREATE INDEX IF NOT EXISTS idx_survey_target_report_media_included ON survey_target_report_media(report_id,included)`);
await queryRunner.query(`
CREATE TABLE IF NOT EXISTS survey_target_report_versions (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
report_id uuid NOT NULL REFERENCES survey_target_reports(id) ON DELETE RESTRICT,
version_number integer NOT NULL,
event varchar(24) NOT NULL,
snapshot jsonb NOT NULL,
actor_user_id uuid REFERENCES users(id) ON DELETE SET NULL,
actor_username varchar(80),
created_at timestamptz NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT uq_survey_target_report_versions_number UNIQUE(report_id,version_number),
CONSTRAINT chk_survey_target_report_versions_event CHECK (
event IN ('SUBMITTED','APPROVED','REJECTED')
)
)
`);
await queryRunner.query(`CREATE INDEX IF NOT EXISTS idx_survey_target_report_versions_created_at ON survey_target_report_versions(created_at)`);
await queryRunner.query(`
INSERT INTO permissions(code,description) VALUES
('surveys.read','Consultar planificación de relevamientos'),
('surveys.manage','Crear y actualizar campañas de relevamiento'),
('surveys.assign','Asignar objetivos de relevamiento'),
('surveys.execute','Actualizar el avance de objetivos asignados'),
('surveys.read_reports','Consultar capturas y versiones de relevamientos'),
('surveys.capture','Capturar y enviar relevamientos de campo'),
('surveys.review','Aprobar o rechazar relevamientos enviados')
ON CONFLICT(code) DO UPDATE SET description=EXCLUDED.description
`);
await queryRunner.query(`
WITH mapping(role_code,permission_code) AS (VALUES ${rolePermissionValues})
INSERT INTO role_permissions(role_id,permission_id)
SELECT role.id,permission.id
FROM mapping
JOIN roles role ON role.code=mapping.role_code
JOIN permissions permission ON permission.code=mapping.permission_code
ON CONFLICT(role_id,permission_id) DO NOTHING
`);
const appRole = process.env.DB_APP_USER;
if (!appRole) throw new Error('Missing required environment variable: DB_APP_USER');
const applicationRole = quoteIdentifier(appRole);
await queryRunner.query(`GRANT SELECT,INSERT,UPDATE ON TABLE survey_campaigns,survey_campaign_targets,survey_target_reports,survey_target_report_media TO ${applicationRole}`);
await queryRunner.query(`GRANT SELECT,INSERT ON TABLE survey_target_report_versions TO ${applicationRole}`);
await queryRunner.query(`REVOKE DELETE ON TABLE survey_campaigns,survey_campaign_targets,survey_target_reports,survey_target_report_media,survey_target_report_versions FROM ${applicationRole}`);
await queryRunner.query(`REVOKE UPDATE ON TABLE survey_target_report_versions FROM ${applicationRole}`);
}
}
@@ -0,0 +1,113 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
const legacyDirectorPermissions = [
['inspection_reports.revise', 'Cargar versiones corregidas del informe para revisión directiva'],
['inspection_reports.review', 'Aprobar la versión vigente del informe'],
['inspection_reports.sign_final', 'Firmar electrónicamente el informe final aprobado'],
] as const;
/**
* Retira definitivamente el circuito activo legacy Director -> revisión -> aprobación -> firma.
*
* current_revision_number e inspection_report_revisions se conservan porque forman parte del
* versionado útil del INF. inspection_report_signatures también se conserva como archivo
* histórico: F4 quita sus endpoints/permisos pero no destruye evidencia documental ya emitida.
*/
export class F4RemoveDirectorReportLegacy1790000400000 implements MigrationInterface {
name = 'F4RemoveDirectorReportLegacy1790000400000';
public async up(queryRunner: QueryRunner): Promise<void> {
// Las entregas al Director pertenecían al flujo retirado y no deben seguir
// apareciendo como pendientes/reintentables en F4.
await queryRunner.query(`
DELETE FROM inspection_document_deliveries
WHERE recipient_kind = 'DIRECTOR'
`);
// Retira permisos huérfanos del circuito anterior para que tampoco aparezcan
// como capacidades administrables en RBAC una vez eliminados sus endpoints.
await queryRunner.query(`
DELETE FROM role_permissions
WHERE permission_id IN (
SELECT id FROM permissions
WHERE code IN (
'inspection_reports.revise',
'inspection_reports.review',
'inspection_reports.sign_final'
)
)
`);
await queryRunner.query(`
DELETE FROM permissions
WHERE code IN (
'inspection_reports.revise',
'inspection_reports.review',
'inspection_reports.sign_final'
)
`);
await queryRunner.query(`ALTER TABLE inspection_reports DROP COLUMN IF EXISTS approved_revision_id`);
await queryRunner.query(`ALTER TABLE inspection_reports DROP COLUMN IF EXISTS approved_by`);
await queryRunner.query(`ALTER TABLE inspection_reports DROP COLUMN IF EXISTS approved_at`);
await queryRunner.query(`ALTER TABLE inspection_reports DROP COLUMN IF EXISTS review_note`);
await queryRunner.query(`ALTER TABLE inspection_reports DROP COLUMN IF EXISTS signed_at`);
await queryRunner.query(`ALTER TABLE inspection_reports DROP COLUMN IF EXISTS review_status`);
}
public async down(queryRunner: QueryRunner): Promise<void> {
// El rollback reconstruye el contrato técnico legacy, pero no recrea entregas Director
// ni valores de aprobación eliminados. El backup PRE-F4 sigue siendo la fuente de
// recuperación de esos datos históricos si alguna vez fuera necesario volver al circuito.
await queryRunner.query(`
ALTER TABLE inspection_reports
ADD COLUMN IF NOT EXISTS review_status varchar(32) NOT NULL DEFAULT 'PENDING_REVIEW'
`);
await queryRunner.query(`ALTER TABLE inspection_reports ADD COLUMN IF NOT EXISTS approved_revision_id uuid`);
await queryRunner.query(`ALTER TABLE inspection_reports ADD COLUMN IF NOT EXISTS approved_by uuid`);
await queryRunner.query(`ALTER TABLE inspection_reports ADD COLUMN IF NOT EXISTS approved_at timestamptz`);
await queryRunner.query(`ALTER TABLE inspection_reports ADD COLUMN IF NOT EXISTS review_note varchar(1000)`);
await queryRunner.query(`ALTER TABLE inspection_reports ADD COLUMN IF NOT EXISTS signed_at timestamptz`);
await queryRunner.query(`
DO $$ BEGIN
ALTER TABLE inspection_reports
ADD CONSTRAINT chk_inspection_reports_review_status
CHECK (review_status IN ('PENDING_REVIEW','APPROVED','SIGNED'));
EXCEPTION WHEN duplicate_object THEN NULL; END $$;
`);
await queryRunner.query(`
DO $$ BEGIN
ALTER TABLE inspection_reports
ADD CONSTRAINT fk_inspection_reports_approved_by
FOREIGN KEY (approved_by) REFERENCES users(id) ON DELETE SET NULL;
EXCEPTION WHEN duplicate_object THEN NULL; END $$;
`);
await queryRunner.query(`
DO $$ BEGIN
ALTER TABLE inspection_reports
ADD CONSTRAINT fk_inspection_reports_approved_revision
FOREIGN KEY (approved_revision_id) REFERENCES inspection_report_revisions(id) ON DELETE RESTRICT;
EXCEPTION WHEN duplicate_object THEN NULL; END $$;
`);
for (const [code, description] of legacyDirectorPermissions) {
await queryRunner.query(
`INSERT INTO permissions (code, description) VALUES ($1, $2) ON CONFLICT (code) DO UPDATE SET description = EXCLUDED.description`,
[code, description],
);
}
await queryRunner.query(`
WITH mapping(role_code, permission_code) AS (VALUES
('director', 'inspection_reports.revise'),
('director', 'inspection_reports.review'),
('director', 'inspection_reports.sign_final')
)
INSERT INTO role_permissions (role_id, permission_id)
SELECT role.id, permission.id
FROM mapping
JOIN roles role ON role.code = mapping.role_code
JOIN permissions permission ON permission.code = mapping.permission_code
ON CONFLICT (role_id, permission_id) DO NOTHING
`);
}
}
@@ -0,0 +1,118 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
/**
* F4 elimina dos campos heredados de la planificación original de Inspecciones:
* - title: la Inspección se identifica por su código autogenerado.
* - planned_end_at: la planificación sólo define la fecha/hora prevista de inicio.
*
* La migración conserva una copia técnica de los valores eliminados para que un
* rollback restaure los datos exactos existentes antes del cambio. Inspecciones
* creadas después del up() reciben code como title y planned_end_at NULL al bajar.
*
* Se conserva un índice sobre planned_start_at porque sigue siendo un criterio de
* orden y filtro operativo. Ninguna migración histórica se modifica.
*/
export class F4RemoveInspectionLegacyFields1790000500000 implements MigrationInterface {
name = 'F4RemoveInspectionLegacyFields1790000500000';
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
CREATE TABLE IF NOT EXISTS f4_inspection_visit_legacy_fields_backup (
visit_id uuid PRIMARY KEY,
title varchar(200) NOT NULL,
planned_end_at timestamptz NULL,
backed_up_at timestamptz NOT NULL DEFAULT CURRENT_TIMESTAMP
)
`);
await queryRunner.query(`
INSERT INTO f4_inspection_visit_legacy_fields_backup (
visit_id,
title,
planned_end_at,
backed_up_at
)
SELECT
id,
title,
planned_end_at,
CURRENT_TIMESTAMP
FROM inspection_visits
ON CONFLICT (visit_id) DO UPDATE SET
title = EXCLUDED.title,
planned_end_at = EXCLUDED.planned_end_at,
backed_up_at = EXCLUDED.backed_up_at
`);
await queryRunner.query(`DROP INDEX IF EXISTS idx_inspection_visits_planned_dates`);
await queryRunner.query(`
ALTER TABLE inspection_visits
DROP CONSTRAINT IF EXISTS chk_inspection_visits_planned_dates
`);
await queryRunner.query(`ALTER TABLE inspection_visits DROP COLUMN IF EXISTS planned_end_at`);
await queryRunner.query(`ALTER TABLE inspection_visits DROP COLUMN IF EXISTS title`);
await queryRunner.query(`
CREATE INDEX IF NOT EXISTS idx_inspection_visits_planned_start_at
ON inspection_visits (planned_start_at)
`);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`DROP INDEX IF EXISTS idx_inspection_visits_planned_start_at`);
await queryRunner.query(`
ALTER TABLE inspection_visits
ADD COLUMN IF NOT EXISTS title varchar(200)
`);
await queryRunner.query(`
ALTER TABLE inspection_visits
ADD COLUMN IF NOT EXISTS planned_end_at timestamptz
`);
await queryRunner.query(`
UPDATE inspection_visits visit
SET
title = backup.title,
planned_end_at = backup.planned_end_at
FROM f4_inspection_visit_legacy_fields_backup backup
WHERE backup.visit_id = visit.id
`);
await queryRunner.query(`
UPDATE inspection_visits
SET title = code
WHERE title IS NULL
`);
await queryRunner.query(`
ALTER TABLE inspection_visits
ALTER COLUMN title SET NOT NULL
`);
await queryRunner.query(`
DO $$
BEGIN
IF NOT EXISTS (
SELECT 1
FROM pg_constraint
WHERE conname = 'chk_inspection_visits_planned_dates'
AND conrelid = 'inspection_visits'::regclass
) THEN
ALTER TABLE inspection_visits
ADD CONSTRAINT chk_inspection_visits_planned_dates CHECK (
planned_start_at IS NULL
OR planned_end_at IS NULL
OR planned_end_at >= planned_start_at
);
END IF;
END
$$
`);
await queryRunner.query(`
CREATE INDEX IF NOT EXISTS idx_inspection_visits_planned_dates
ON inspection_visits (planned_start_at, planned_end_at)
`);
await queryRunner.query(`DROP TABLE IF EXISTS f4_inspection_visit_legacy_fields_backup`);
}
}
@@ -0,0 +1,274 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
/**
* Alinea las restricciones físicas heredadas de D5 con el ciclo documental F4.
*
* F4 incorpora estados LOCKED/SEALED para Actas y WORKING/OFFICIALIZED para INF.
* También usa el código visible ACT-NNNNN-DD-MM-YY y entrega copia al INSPECTOR.
* Las migraciones históricas conservaban CHECKs, triggers y contratos de D5, por lo
* que el servicio podía compilar pero PostgreSQL rechazaba las transiciones nuevas.
*/
export class F4AlignDocumentLifecycleConstraints1790000600000 implements MigrationInterface {
name = 'F4AlignDocumentLifecycleConstraints1790000600000';
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
ALTER TABLE inspection_reports
DROP CONSTRAINT IF EXISTS chk_inspection_reports_status
`);
await queryRunner.query(`
ALTER TABLE inspection_reports
ADD CONSTRAINT chk_inspection_reports_status CHECK (
status IN ('WORKING','OFFICIALIZED','FROZEN','CANCELLED')
)
`);
await queryRunner.query(`
ALTER TABLE inspection_reports
ALTER COLUMN status SET DEFAULT 'WORKING'
`);
await queryRunner.query(`
ALTER TABLE inspection_acts
DROP CONSTRAINT IF EXISTS chk_inspection_acts_status
`);
await queryRunner.query(`
ALTER TABLE inspection_acts
ADD CONSTRAINT chk_inspection_acts_status CHECK (
status IN ('DRAFT','LOCKED','SEALED','READY','CLOSED','CANCELLED','RECTIFIED')
)
`);
// Conservamos los códigos históricos ACTA-* ya emitidos y habilitamos el
// formato F4 realmente generado por InspectionActsService.
await queryRunner.query(`
ALTER TABLE inspection_acts
DROP CONSTRAINT IF EXISTS chk_inspection_acts_code
`);
await queryRunner.query(`
ALTER TABLE inspection_acts
ADD CONSTRAINT chk_inspection_acts_code CHECK (
code ~ '^(ACTA-[0-9]{4}-[0-9]{6}|ACT-[0-9]{5}-[0-9]{2}-[0-9]{2}-[0-9]{2})$'
)
`);
await queryRunner.query(`
ALTER TABLE inspection_act_versions
DROP CONSTRAINT IF EXISTS chk_inspection_act_versions_event
`);
await queryRunner.query(`
ALTER TABLE inspection_act_versions
ADD CONSTRAINT chk_inspection_act_versions_event CHECK (
event IN ('CREATED','UPDATED','LOCKED','SEALED','READY','REOPENED','CLOSED','CANCELLED')
)
`);
// El cierre físico F4 se representa como SEALED. Conservamos CLOSED como
// compatibilidad histórica y exigimos la misma integridad en ambos estados.
await queryRunner.query(`
ALTER TABLE inspection_acts
DROP CONSTRAINT IF EXISTS chk_inspection_acts_closure
`);
await queryRunner.query(`
ALTER TABLE inspection_acts
ADD CONSTRAINT chk_inspection_acts_closure CHECK (
status NOT IN ('SEALED','CLOSED')
OR (
closed_at IS NOT NULL
AND closed_by IS NOT NULL
AND closure_sha256 ~ '^[0-9a-f]{64}$'
)
)
`);
// F4 retira Director como destinatario activo y agrega copia al inspector.
// La migración 040 ya eliminó previamente las filas DIRECTOR existentes.
await queryRunner.query(`
ALTER TABLE inspection_document_deliveries
DROP CONSTRAINT IF EXISTS chk_inspection_document_deliveries_recipient_kind
`);
await queryRunner.query(`
ALTER TABLE inspection_document_deliveries
ADD CONSTRAINT chk_inspection_document_deliveries_recipient_kind CHECK (
recipient_kind IN ('COMPANY','OFFICE','INSPECTOR')
)
`);
// Las firmas F4 pertenecen al snapshot LOCKED. El trigger D5 exigía READY.
await queryRunner.query(`
CREATE OR REPLACE FUNCTION dhv2_guard_act_signature_ready()
RETURNS trigger LANGUAGE plpgsql AS $$
DECLARE current_status varchar(24);
DECLARE current_sha char(64);
BEGIN
SELECT act.status, closure.prepared_sha256
INTO current_status, current_sha
FROM inspection_acts act
INNER JOIN inspection_act_closures closure ON closure.act_id = act.id
WHERE act.id = NEW.act_id;
IF current_status <> 'LOCKED' OR current_sha IS DISTINCT FROM NEW.prepared_sha256 THEN
RAISE EXCEPTION 'La firma no corresponde a un acta bloqueada vigente';
END IF;
RETURN NEW;
END
$$
`);
// SEALED es tan inmutable como CLOSED/RECTIFIED en el contrato histórico.
await queryRunner.query(`
CREATE OR REPLACE FUNCTION dhv2_guard_closed_act_immutable()
RETURNS trigger LANGUAGE plpgsql AS $$
BEGIN
IF OLD.status IN ('SEALED','CLOSED','RECTIFIED') THEN
RAISE EXCEPTION 'El acta sellada es inmutable';
END IF;
RETURN NEW;
END
$$
`);
}
public async down(queryRunner: QueryRunner): Promise<void> {
// Primero restauramos la guarda D5. De otro modo el propio trigger F4 impediría
// traducir una fila SEALED a CLOSED durante el rollback.
await queryRunner.query(`
CREATE OR REPLACE FUNCTION dhv2_guard_closed_act_immutable()
RETURNS trigger LANGUAGE plpgsql AS $$
BEGIN
IF OLD.status IN ('CLOSED','RECTIFIED') THEN
RAISE EXCEPTION 'El acta cerrada es inmutable';
END IF;
RETURN NEW;
END
$$
`);
// Los códigos F4 se traducen antes de convertir SEALED a CLOSED, porque una vez
// restaurada la guarda D5 una fila CLOSED ya no puede modificarse.
await queryRunner.query(`
UPDATE inspection_acts
SET code='ACTA-' || act_year::text || '-' || LPAD(act_number::text, 6, '0')
WHERE code ~ '^ACT-[0-9]{5}-[0-9]{2}-[0-9]{2}-[0-9]{2}$'
`);
await queryRunner.query(`
ALTER TABLE inspection_acts
DROP CONSTRAINT IF EXISTS chk_inspection_acts_code
`);
await queryRunner.query(`
ALTER TABLE inspection_acts
ADD CONSTRAINT chk_inspection_acts_code CHECK (
code ~ '^ACTA-[0-9]{4}-[0-9]{6}$'
)
`);
// El contrato D5 no conoce los estados F4. Reducimos las representaciones
// nuevas a sus equivalentes históricos antes de restaurar sus CHECKs.
await queryRunner.query(`
UPDATE inspection_reports
SET status='FROZEN'
WHERE status IN ('WORKING','OFFICIALIZED')
`);
await queryRunner.query(`
ALTER TABLE inspection_reports
ALTER COLUMN status SET DEFAULT 'FROZEN'
`);
await queryRunner.query(`
ALTER TABLE inspection_reports
DROP CONSTRAINT IF EXISTS chk_inspection_reports_status
`);
await queryRunner.query(`
ALTER TABLE inspection_reports
ADD CONSTRAINT chk_inspection_reports_status CHECK (
status IN ('FROZEN','CANCELLED')
)
`);
await queryRunner.query(`
UPDATE inspection_acts
SET status=CASE
WHEN status='LOCKED' THEN 'READY'
WHEN status='SEALED' THEN 'CLOSED'
ELSE status
END
WHERE status IN ('LOCKED','SEALED')
`);
await queryRunner.query(`
ALTER TABLE inspection_acts
DROP CONSTRAINT IF EXISTS chk_inspection_acts_status
`);
await queryRunner.query(`
ALTER TABLE inspection_acts
ADD CONSTRAINT chk_inspection_acts_status CHECK (
status IN ('DRAFT','READY','CLOSED','CANCELLED','RECTIFIED')
)
`);
await queryRunner.query(`
UPDATE inspection_act_versions
SET event=CASE
WHEN event='LOCKED' THEN 'READY'
WHEN event='SEALED' THEN 'CLOSED'
ELSE event
END
WHERE event IN ('LOCKED','SEALED')
`);
await queryRunner.query(`
ALTER TABLE inspection_act_versions
DROP CONSTRAINT IF EXISTS chk_inspection_act_versions_event
`);
await queryRunner.query(`
ALTER TABLE inspection_act_versions
ADD CONSTRAINT chk_inspection_act_versions_event CHECK (
event IN ('CREATED','UPDATED','READY','REOPENED','CLOSED','CANCELLED')
)
`);
await queryRunner.query(`
ALTER TABLE inspection_acts
DROP CONSTRAINT IF EXISTS chk_inspection_acts_closure
`);
await queryRunner.query(`
ALTER TABLE inspection_acts
ADD CONSTRAINT chk_inspection_acts_closure CHECK (
status <> 'CLOSED'
OR (
closed_at IS NOT NULL
AND closed_by IS NOT NULL
AND closure_sha256 ~ '^[0-9a-f]{64}$'
)
)
`);
// Conservamos INSPECTOR en el CHECK de rollback para no destruir ni falsificar
// entregas F4 ya auditadas. D5 puede seguir operando con sus tres valores y el
// backup PRE-F4 sigue siendo la fuente de una restauración histórica exacta.
await queryRunner.query(`
ALTER TABLE inspection_document_deliveries
DROP CONSTRAINT IF EXISTS chk_inspection_document_deliveries_recipient_kind
`);
await queryRunner.query(`
ALTER TABLE inspection_document_deliveries
ADD CONSTRAINT chk_inspection_document_deliveries_recipient_kind CHECK (
recipient_kind IN ('COMPANY','OFFICE','DIRECTOR','INSPECTOR')
)
`);
await queryRunner.query(`
CREATE OR REPLACE FUNCTION dhv2_guard_act_signature_ready()
RETURNS trigger LANGUAGE plpgsql AS $$
DECLARE current_status varchar(24);
DECLARE current_sha char(64);
BEGIN
SELECT act.status, closure.prepared_sha256
INTO current_status, current_sha
FROM inspection_acts act
INNER JOIN inspection_act_closures closure ON closure.act_id = act.id
WHERE act.id = NEW.act_id;
IF current_status <> 'READY' OR current_sha IS DISTINCT FROM NEW.prepared_sha256 THEN
RAISE EXCEPTION 'La firma no corresponde a un acta preparada vigente';
END IF;
RETURN NEW;
END
$$
`);
}
}
@@ -0,0 +1,44 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
export class F4AlignAssetVersionFunctionChange1790000700000 implements MigrationInterface {
name = 'F4AlignAssetVersionFunctionChange1790000700000';
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
ALTER TABLE asset_versions
DROP CONSTRAINT IF EXISTS chk_asset_versions_change_type
`);
await queryRunner.query(`
ALTER TABLE asset_versions
ADD CONSTRAINT chk_asset_versions_change_type CHECK (change_type IN (
'BASELINE', 'CREATED', 'UPDATED', 'CONTEXT_CHANGED', 'FUNCTION_CHANGED',
'STATUS_CHANGED', 'OPERATIONAL_STATUS_CHANGED', 'REGISTRY_UPDATED',
'GEOMETRY_UPDATED', 'GEOMETRY_REMOVED',
'MEDIA_UPLOADED', 'MEDIA_UPDATED', 'MEDIA_REMOVED',
'PROVENANCE_BASELINE', 'PROVENANCE_UPDATED', 'PROVENANCE_VERIFIED'
))
`);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
UPDATE asset_versions
SET change_type='UPDATED'
WHERE change_type='FUNCTION_CHANGED'
`);
await queryRunner.query(`
ALTER TABLE asset_versions
DROP CONSTRAINT IF EXISTS chk_asset_versions_change_type
`);
await queryRunner.query(`
ALTER TABLE asset_versions
ADD CONSTRAINT chk_asset_versions_change_type CHECK (change_type IN (
'BASELINE', 'CREATED', 'UPDATED', 'CONTEXT_CHANGED',
'STATUS_CHANGED', 'OPERATIONAL_STATUS_CHANGED', 'REGISTRY_UPDATED',
'GEOMETRY_UPDATED', 'GEOMETRY_REMOVED',
'MEDIA_UPLOADED', 'MEDIA_UPDATED', 'MEDIA_REMOVED',
'PROVENANCE_BASELINE', 'PROVENANCE_UPDATED', 'PROVENANCE_VERIFIED'
))
`);
}
}
@@ -4,6 +4,7 @@ import {
ArrayMinSize,
ArrayUnique,
IsArray,
IsEnum,
IsISO8601,
IsOptional,
IsString,
@@ -11,11 +12,15 @@ import {
MaxLength,
MinLength,
} from 'class-validator';
import { InspectionActUrgency } from '../../database/entities';
export class CreateInspectionActDto {
@IsISO8601({ strict: true })
occurredAt!: string;
@IsEnum(InspectionActUrgency)
urgency!: InspectionActUrgency;
@Transform(({ value }) => (typeof value === 'string' ? value.trim() : value))
@IsString()
@MinLength(1)
@@ -4,6 +4,7 @@ import {
ArrayMinSize,
ArrayUnique,
IsArray,
IsEnum,
IsISO8601,
IsOptional,
IsString,
@@ -11,12 +12,17 @@ import {
MaxLength,
MinLength,
} from 'class-validator';
import { InspectionActUrgency } from '../../database/entities';
export class UpdateInspectionActDto {
@IsOptional()
@IsISO8601({ strict: true })
occurredAt?: string;
@IsOptional()
@IsEnum(InspectionActUrgency)
urgency?: InspectionActUrgency;
@IsOptional()
@Transform(({ value }) => (typeof value === 'string' ? value.trim() : value))
@IsString()
@@ -14,6 +14,9 @@ import {
DocumentSequenceType,
InspectionAct,
InspectionActStatus,
InspectionActUrgency,
InspectionDeadlineBasis,
InspectionDeadlineDayType,
InspectionActVersionEvent,
InspectionVisit,
InspectionVisitStatus,
@@ -41,7 +44,6 @@ interface ActAsset {
interface ActVisitSummary {
id: string;
code: string;
title: string;
status: InspectionVisitStatus;
actualStartedAt: Date | null;
}
@@ -72,6 +74,17 @@ export interface InspectionActListItem {
title: string;
summary: string;
observations: string | null;
urgency: InspectionActUrgency;
deadlineDays: number | null;
deadlineDayType: InspectionDeadlineDayType | null;
deadlineBasis: InspectionDeadlineBasis | null;
deadlineBaseAt: Date | null;
deadlineAt: Date | null;
lockedAt: Date | null;
lockedBy: string | null;
lockedSha256: string | null;
sealedAt: Date | null;
sealedBy: string | null;
currentVersion: number;
cancellationReason: string | null;
closedAt: Date | null;
@@ -137,7 +150,6 @@ export class InspectionActsService {
act.code ILIKE ${search}
OR act.title ILIKE ${search}
OR visit.code ILIKE ${search}
OR visit.title ILIKE ${search}
OR EXISTS (
SELECT 1
FROM inspection_act_assets search_link
@@ -313,13 +325,16 @@ export class InspectionActsService {
const occurredAt = new Date(dto.occurredAt);
const actYear = await this.yearAtProjectTimezone(manager, occurredAt);
const actNumber = await this.allocateNumber(manager, actYear);
if (actNumber > 999999) {
if (actNumber > 99999) {
throw new ConflictException({
code: 'INSPECTION_ACT_SEQUENCE_EXHAUSTED',
message: 'La numeración anual de actas agotó su rango disponible',
});
}
const code = `ACTA-${actYear}-${String(actNumber).padStart(6, '0')}`;
const [dateRow] = (await manager.query(`
SELECT TO_CHAR($1::timestamptz AT TIME ZONE 'America/Argentina/Mendoza', 'DD-MM-YY') AS date_part
`, [occurredAt])) as Array<{ date_part: string }>;
const code = `ACT-${String(actNumber).padStart(5, '0')}-${dateRow.date_part}`;
const act = manager.getRepository(InspectionAct).create({
visitId,
actYear,
@@ -330,6 +345,17 @@ export class InspectionActsService {
title: dto.title,
summary: dto.summary,
observations: dto.observations ?? null,
urgency: dto.urgency,
deadlineDays: null,
deadlineDayType: null,
deadlineBasis: null,
deadlineBaseAt: null,
deadlineAt: null,
lockedAt: null,
lockedBy: null,
lockedSha256: null,
sealedAt: null,
sealedBy: null,
currentVersion: 0,
cancellationReason: null,
createdBy: principal.userId,
@@ -384,6 +410,7 @@ export class InspectionActsService {
await this.assertVisitAssets(manager, visit.id, nextAssetIds);
const before = await this.loadView(manager, id);
if (dto.occurredAt !== undefined) act.occurredAt = nextOccurredAt;
if (dto.urgency !== undefined) act.urgency = dto.urgency;
if (dto.title !== undefined) act.title = dto.title;
if (dto.summary !== undefined) act.summary = dto.summary;
if (dto.observations !== undefined) act.observations = dto.observations;
@@ -462,7 +489,6 @@ export class InspectionActsService {
JSONB_BUILD_OBJECT(
'id', visit.id,
'code', visit.code,
'title', visit.title,
'status', visit.status,
'actualStartedAt', visit.actual_started_at
) AS visit,
@@ -474,6 +500,17 @@ export class InspectionActsService {
act.title,
act.summary,
act.observations,
act.urgency,
act.deadline_days AS "deadlineDays",
act.deadline_day_type AS "deadlineDayType",
act.deadline_basis AS "deadlineBasis",
act.deadline_base_at AS "deadlineBaseAt",
act.deadline_at AS "deadlineAt",
act.locked_at AS "lockedAt",
act.locked_by AS "lockedBy",
act.locked_sha256 AS "lockedSha256",
act.sealed_at AS "sealedAt",
act.sealed_by AS "sealedBy",
act.current_version AS "currentVersion",
act.cancellation_reason AS "cancellationReason",
act.closed_at AS "closedAt",
@@ -645,7 +682,7 @@ export class InspectionActsService {
if (rows.length > 0) {
throw new ConflictException({
code: 'INSPECTION_VISIT_DRAFT_ACT_ALREADY_EXISTS',
message: 'La inspección ya tiene un acta en borrador; preparala o cancelala antes de crear otra',
message: 'La inspección ya tiene un acta en borrador; finalizala o cancelala antes de crear otra',
});
}
}
@@ -665,7 +702,7 @@ export class InspectionActsService {
if (assetIds.length < 1 || Number(row?.count ?? 0) !== assetIds.length) {
throw new BadRequestException({
code: 'INSPECTION_ACT_ASSET_INVALID',
message: 'Cada activo del acta debe estar incluido en la visita',
message: 'Cada inventario del acta debe estar incluido en la inspección',
});
}
}
@@ -758,6 +795,15 @@ export class InspectionActsService {
'title', act.title,
'summary', act.summary,
'observations', act.observations,
'urgency', act.urgency,
'deadlineDays', act.deadline_days,
'deadlineDayType', act.deadline_day_type,
'deadlineBasis', act.deadline_basis,
'deadlineBaseAt', act.deadline_base_at,
'deadlineAt', act.deadline_at,
'lockedAt', act.locked_at,
'lockedSha256', act.locked_sha256,
'sealedAt', act.sealed_at,
'currentVersion', act.current_version,
'cancellationReason', act.cancellation_reason,
'createdBy', act.created_by,
@@ -765,7 +811,6 @@ export class InspectionActsService {
'visit', JSONB_BUILD_OBJECT(
'id', visit.id,
'code', visit.code,
'title', visit.title,
'status', visit.status,
'scopeAssetId', visit.scope_asset_id,
'actualStartedAt', visit.actual_started_at
@@ -804,6 +849,15 @@ export class InspectionActsService {
title: act.title,
summary: act.summary,
observations: act.observations,
urgency: act.urgency,
deadlineDays: act.deadlineDays,
deadlineDayType: act.deadlineDayType,
deadlineBasis: act.deadlineBasis,
deadlineBaseAt: act.deadlineBaseAt,
deadlineAt: act.deadlineAt,
lockedAt: act.lockedAt,
lockedSha256: act.lockedSha256,
sealedAt: act.sealedAt,
currentVersion: act.currentVersion,
cancellationReason: act.cancellationReason,
closedAt: act.closedAt,
@@ -0,0 +1,77 @@
import {
Body,
Controller,
Get,
Param,
ParseUUIDPipe,
Post,
Req,
UploadedFile,
UseInterceptors,
} from '@nestjs/common';
import { FileInterceptor } from '@nestjs/platform-express';
import { CurrentAuth } from '../auth/decorators/current-auth.decorator';
import { Public } from '../auth/decorators/public.decorator';
import { SkipCsrf } from '../auth/decorators/skip-csrf.decorator';
import { RequirePermissions } from '../authorization/decorators/require-permissions.decorator';
import type { AuthPrincipal, RequestWithContext } from '../common/http/request-context';
import { CompanySignatureInviteService } from './company-signature-invite.service';
import {
CreateCompanySignatureInviteDto,
PublicCompanyRefusalDto,
PublicCompanySignatureDto,
} from './dto/company-signature-invite.dto';
import {
MAX_INSPECTION_SIGNATURE_BYTES,
type UploadedInspectionSignatureFile,
} from './inspection-signature-file';
@Controller('inspection-acts/:actId/company-signature-invitations')
export class CompanySignatureInviteController {
constructor(private readonly invites: CompanySignatureInviteService) {}
@Post()
@RequirePermissions('inspection_closure.sign')
create(
@Param('actId', new ParseUUIDPipe({ version: '4' })) actId: string,
@Body() dto: CreateCompanySignatureInviteDto,
@CurrentAuth() principal: AuthPrincipal,
@Req() request: RequestWithContext,
) {
return this.invites.create(actId, dto, principal, request);
}
}
@Public()
@SkipCsrf()
@Controller('public/company-signatures')
export class PublicCompanySignatureController {
constructor(private readonly invites: CompanySignatureInviteService) {}
@Get(':token')
view(@Param('token') token: string) {
return this.invites.view(token);
}
@Post(':token/sign')
@UseInterceptors(FileInterceptor('file', {
limits: { fileSize: MAX_INSPECTION_SIGNATURE_BYTES, files: 1 },
}))
sign(
@Param('token') token: string,
@Body() dto: PublicCompanySignatureDto,
@UploadedFile() file: UploadedInspectionSignatureFile | undefined,
@Req() request: RequestWithContext,
) {
return this.invites.sign(token, dto, file, request);
}
@Post(':token/refuse')
refuse(
@Param('token') token: string,
@Body() dto: PublicCompanyRefusalDto,
@Req() request: RequestWithContext,
) {
return this.invites.refuse(token, dto, request);
}
}
@@ -0,0 +1,624 @@
import { createHash, randomBytes, randomUUID } from 'node:crypto';
import { mkdir, unlink, writeFile } from 'node:fs/promises';
import { isAbsolute, parse, resolve } from 'node:path';
import {
ConflictException,
GoneException,
Injectable,
InternalServerErrorException,
NotFoundException,
} from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { DataSource, EntityManager } from 'typeorm';
import { administrationAuditContext } from '../administration/common/administration-audit';
import { AuditService } from '../audit/audit.service';
import type { AuthPrincipal, RequestWithContext } from '../common/http/request-context';
import {
AuditSource,
InspectionActSignatureSource,
InspectionActSignatureStatus,
InspectionActSignerType,
InspectionCompanySignatureManifestation,
} from '../database/entities';
import { SmtpDeliveryService } from '../inspection-reports/smtp-delivery.service';
import { sha256CanonicalJson } from './canonical-json';
import type {
CreateCompanySignatureInviteDto,
PublicCompanyRefusalDto,
PublicCompanySignatureDto,
} from './dto/company-signature-invite.dto';
import {
inspectInspectionSignatureFile,
type UploadedInspectionSignatureFile,
} from './inspection-signature-file';
const REMOTE_CONSENT_VERSION = 'F4-1';
const REMOTE_COMPANY_CONSENT = 'Declaro haber leído o recibido explicación del contenido del Acta y que esta firma se incorpora como constancia de recepción, sin implicar aceptación de los Hallazgos.';
interface InviteContext {
id: string;
actId: string;
tokenSha256: string;
recipientEmail: string;
recipientName: string | null;
recipientDocumentType: string | null;
recipientDocumentNumber: string | null;
recipientPosition: string | null;
status: 'PENDING' | 'USED' | 'REVOKED' | 'EXPIRED';
expiresAt: Date;
sentAt: Date | null;
usedAt: Date | null;
createdBy: string;
actCode: string;
actStatus: string;
lockedSha256: string | null;
lockedAt: Date | null;
inspectionCode: string;
lockedSnapshot: Record<string, unknown> | null;
}
@Injectable()
export class CompanySignatureInviteService {
private readonly signatureRoot: string;
constructor(
private readonly dataSource: DataSource,
private readonly audit: AuditService,
private readonly smtp: SmtpDeliveryService,
private readonly config: ConfigService,
) {
const configured = config.get<string>('INSPECTION_SIGNATURE_ROOT')
?? '/app/storage/asset-media/inspection-signatures';
if (!isAbsolute(configured)) throw new Error('INSPECTION_SIGNATURE_ROOT must be an absolute path');
this.signatureRoot = resolve(configured);
if (this.signatureRoot === parse(this.signatureRoot).root) {
throw new Error('INSPECTION_SIGNATURE_ROOT cannot be the filesystem root');
}
}
async create(
actId: string,
dto: CreateCompanySignatureInviteDto,
principal: AuthPrincipal,
request: RequestWithContext,
) {
const token = randomBytes(32).toString('base64url');
const tokenSha256 = this.hashToken(token);
const expiresAt = new Date(Date.now() + (dto.expiresInDays ?? 7) * 24 * 60 * 60 * 1000);
const created = await this.dataSource.transaction(async (manager) => {
await this.assertActorAssigned(manager, actId, principal);
const [context] = await manager.query(`
SELECT
act.id,
act.code,
act.status,
act.locked_sha256 AS "lockedSha256",
responsible.full_name AS "fullName",
responsible.document_type AS "documentType",
responsible.document_number AS "documentNumber",
responsible.position,
responsible.email AS "responsibleEmail",
visit.code AS "inspectionCode",
(
SELECT profile.notification_email
FROM inspection_act_assets link
JOIN assets inventory ON inventory.id=link.asset_id
JOIN asset_types inventory_type ON inventory_type.id=inventory.asset_type_id
JOIN assets company ON company.id=COALESCE(
inventory.operator_company_id,
CASE WHEN inventory_type.operational_role='COMPANY' THEN inventory.id END
)
LEFT JOIN organization_profiles profile ON profile.asset_id=company.id
WHERE link.act_id=act.id AND link.included=true
AND profile.notification_email IS NOT NULL
ORDER BY company.id
LIMIT 1
) AS "companyEmail"
FROM inspection_acts act
JOIN inspection_visits visit ON visit.id=act.visit_id
LEFT JOIN inspection_act_responsibles responsible ON responsible.act_id=act.id
WHERE act.id=$1
FOR UPDATE OF act
`, [actId]) as Array<{
id: string;
code: string;
status: string;
lockedSha256: string | null;
fullName: string | null;
documentType: string | null;
documentNumber: string | null;
position: string | null;
responsibleEmail: string | null;
inspectionCode: string;
companyEmail: string | null;
}>;
if (!context) {
throw new NotFoundException({ code: 'INSPECTION_ACT_NOT_FOUND', message: 'Acta no encontrada' });
}
if (context.status !== 'LOCKED' || !context.lockedSha256) {
throw new ConflictException({
code: 'COMPANY_SIGNATURE_INVITE_ACT_NOT_LOCKED',
message: 'La invitación de firma sólo puede emitirse para un Acta BLOQUEADA',
});
}
if (await this.hasCompanyOutcome(manager, actId)) {
throw new ConflictException({
code: 'COMPANY_SIGNATURE_ALREADY_RESOLVED',
message: 'La manifestación de la empresa ya fue registrada',
});
}
const recipientEmail = dto.recipientEmail
?? context.responsibleEmail?.toLowerCase()
?? context.companyEmail?.toLowerCase()
?? null;
if (!recipientEmail) {
throw new ConflictException({
code: 'COMPANY_SIGNATURE_EMAIL_REQUIRED',
message: 'No hay email de responsable ni email institucional de empresa; indicá un destinatario',
});
}
await manager.query(`
UPDATE inspection_act_company_signature_invites
SET status='REVOKED',revoked_at=CURRENT_TIMESTAMP,revoked_by=$2,updated_at=CURRENT_TIMESTAMP
WHERE act_id=$1 AND status='PENDING'
`, [actId, principal.userId]);
const [invite] = await manager.query(`
INSERT INTO inspection_act_company_signature_invites(
act_id,token_sha256,recipient_email,recipient_name,recipient_document_type,
recipient_document_number,recipient_position,expires_at,created_by
) VALUES($1,$2,$3,$4,$5,$6,$7,$8,$9)
RETURNING id,act_id AS "actId",recipient_email AS "recipientEmail",
expires_at AS "expiresAt",created_at AS "createdAt"
`, [
actId,
tokenSha256,
recipientEmail,
context.fullName,
context.documentType,
context.documentNumber,
context.position,
expiresAt,
principal.userId,
]) as Array<{
id: string;
actId: string;
recipientEmail: string;
expiresAt: Date;
createdAt: Date;
}>;
await this.audit.record({
...administrationAuditContext(principal, request),
action: 'INSPECTION_ACT_COMPANY_SIGNATURE_INVITE_CREATED',
entityType: 'inspection_act_company_signature_invite',
entityId: invite.id,
afterData: {
actId,
actCode: context.code,
recipientEmail,
expiresAt,
},
metadata: { tokenStoredAsHashOnly: true },
}, manager);
return { ...invite, actCode: context.code, inspectionCode: context.inspectionCode };
});
const publicBase = this.config.get<string>('COMPANY_SIGNATURE_PUBLIC_BASE_URL')?.trim().replace(/\/$/, '') ?? null;
const publicUrl = publicBase ? `${publicBase}?token=${encodeURIComponent(token)}` : null;
let emailSent = false;
let deliveryError: string | null = null;
if (!publicUrl) {
deliveryError = 'COMPANY_SIGNATURE_PUBLIC_BASE_URL no configurada';
} else if (!(await this.smtp.configured())) {
deliveryError = 'SMTP no configurado';
} else {
const body = [
`Se solicita revisar y manifestarse sobre el Acta ${created.actCode}.`,
`Inspección: ${created.inspectionCode}.`,
'',
'El enlace permite firmar en conformidad, firmar en disidencia o registrar una negativa a firmar.',
'El contenido del Acta está bloqueado y no puede modificarse desde este enlace.',
'',
`Enlace seguro: ${publicUrl}`,
`Válido hasta: ${new Date(created.expiresAt).toLocaleString('es-AR')}`,
].join('\n');
try {
await this.smtp.send({
to: created.recipientEmail,
subject: `DH Inspección · Firma de ${created.actCode}`,
text: body,
attachment: {
filename: `${created.actCode}-instrucciones.txt`,
mimeType: 'text/plain',
content: Buffer.from(body, 'utf8'),
},
});
emailSent = true;
await this.dataSource.query(`
UPDATE inspection_act_company_signature_invites
SET sent_at=CURRENT_TIMESTAMP,updated_at=CURRENT_TIMESTAMP
WHERE id=$1
`, [created.id]);
} catch (error) {
deliveryError = error instanceof Error ? error.message.slice(0, 500) : 'No se pudo enviar el email';
}
}
return {
id: created.id,
actId: created.actId,
actCode: created.actCode,
recipientEmail: created.recipientEmail,
expiresAt: created.expiresAt,
emailSent,
deliveryError,
publicUrl,
};
}
async view(token: string) {
const invite = await this.resolveToken(this.dataSource.manager, token, false);
const locked = invite.lockedSnapshot ?? {};
const act = this.record(locked.act);
const inventories = this.records(locked.inventories);
const findings = this.records(locked.findings).map((finding) => ({
id: finding.id,
code: finding.code,
title: finding.title,
description: finding.description,
legalBasis: finding.legalBasis ?? null,
severity: finding.severity ?? null,
isRecurrence: finding.isRecurrence === true,
recurrenceOfFindingId: finding.recurrenceOfFindingId ?? null,
}));
return {
invitation: {
id: invite.id,
recipientEmail: invite.recipientEmail,
expiresAt: invite.expiresAt,
},
act: {
code: invite.actCode,
inspectionCode: invite.inspectionCode,
lockedAt: invite.lockedAt,
lockedSha256: invite.lockedSha256,
urgency: act.urgency ?? null,
summary: act.summary ?? null,
observations: act.observations ?? null,
},
responsibleDefaults: {
fullName: invite.recipientName,
documentType: invite.recipientDocumentType,
documentNumber: invite.recipientDocumentNumber,
position: invite.recipientPosition,
},
inventories: inventories.map((inventory) => ({
id: inventory.id,
code: inventory.code,
name: inventory.name,
typeName: inventory.typeName ?? inventory.typeCode ?? null,
})),
findings,
consent: REMOTE_COMPANY_CONSENT,
allowedActions: ['SIGN_CONFORMITY', 'SIGN_DISSENT', 'REFUSE'],
};
}
async sign(
token: string,
dto: PublicCompanySignatureDto,
file: UploadedInspectionSignatureFile | undefined,
request: RequestWithContext,
) {
if (!dto.consentAccepted) {
throw new ConflictException({
code: 'COMPANY_SIGNATURE_CONSENT_REQUIRED',
message: 'Debe aceptarse la constancia antes de firmar',
});
}
const inspected = inspectInspectionSignatureFile(file);
const id = randomUUID();
const storedName = `${id}.png`;
const filePath = resolve(this.signatureRoot, storedName);
const imageSha256 = createHash('sha256').update(file!.buffer).digest('hex');
await mkdir(this.signatureRoot, { recursive: true, mode: 0o700 });
await writeFile(filePath, file!.buffer, { flag: 'wx', mode: 0o600 });
try {
const result = await this.dataSource.transaction(async (manager) => {
const invite = await this.resolveToken(manager, token, true);
await this.assertNoCompanyOutcome(manager, invite.actId);
const signedAt = new Date();
const manifestation = dto.manifestation === 'DISSENT'
? InspectionCompanySignatureManifestation.DISSENT
: InspectionCompanySignatureManifestation.CONFORMITY;
const statement = manifestation === InspectionCompanySignatureManifestation.DISSENT
? dto.statement?.trim() ?? null
: null;
const payload = {
invitationId: invite.id,
actId: invite.actId,
lockedSha256: invite.lockedSha256,
signerType: InspectionActSignerType.COMPANY_RESPONSIBLE,
signerName: dto.fullName,
documentType: dto.documentType,
documentNumber: dto.documentNumber,
position: dto.position,
status: InspectionActSignatureStatus.SIGNED,
companyManifestation: manifestation,
companyStatement: statement,
imageSha256,
consentText: REMOTE_COMPANY_CONSENT,
consentVersion: REMOTE_CONSENT_VERSION,
signedAt: signedAt.toISOString(),
source: InspectionActSignatureSource.WEB,
};
const signaturePayloadSha256 = sha256CanonicalJson(payload);
await manager.query(`
INSERT INTO inspection_act_signatures(
id,act_id,signer_type,signer_user_id,signer_name,document_type,document_number,
position,status,company_manifestation,company_statement,original_name,stored_name,
mime_type,size_bytes,image_sha256,consent_text,consent_version,consent_accepted_at,
signed_at,device_label,source,prepared_sha256,signature_payload_sha256,uploaded_by,created_at
) VALUES(
$1,$2,'COMPANY_RESPONSIBLE',NULL,$3,$4,$5,$6,'SIGNED',$7,$8,$9,$10,$11,$12,$13,
$14,$15,$16,$16,'Firma remota por enlace seguro','WEB',$17,$18,$19,$16
)
`, [
id,
invite.actId,
dto.fullName,
dto.documentType,
dto.documentNumber,
dto.position,
manifestation,
statement,
inspected.originalName,
storedName,
inspected.mimeType,
file!.buffer.length,
imageSha256,
REMOTE_COMPANY_CONSENT,
REMOTE_CONSENT_VERSION,
signedAt,
invite.lockedSha256,
signaturePayloadSha256,
invite.createdBy,
]);
await manager.query(`
UPDATE inspection_act_company_signature_invites
SET status='USED',used_at=$2,updated_at=$2
WHERE id=$1
`, [invite.id, signedAt]);
await this.audit.record({
action: 'INSPECTION_ACT_COMPANY_REMOTE_SIGNATURE_RECORDED',
entityType: 'inspection_act_signature',
entityId: id,
source: AuditSource.WEB,
actorUserId: null,
actorUsername: null,
requestId: request.requestId,
ip: request.ip ?? null,
userAgent: request.get('user-agent') ?? null,
afterData: payload,
metadata: {
invitationId: invite.id,
recipientEmail: invite.recipientEmail,
immutable: true,
signaturePayloadSha256,
},
}, manager);
return { actCode: invite.actCode, signatureId: id, manifestation };
});
return { ok: true, ...result };
} catch (error) {
await unlink(filePath).catch(() => undefined);
throw error;
}
}
async refuse(
token: string,
dto: PublicCompanyRefusalDto,
request: RequestWithContext,
) {
return this.dataSource.transaction(async (manager) => {
const invite = await this.resolveToken(manager, token, true);
await this.assertNoCompanyOutcome(manager, invite.actId);
const createdAt = new Date();
const id = randomUUID();
const payload = {
invitationId: invite.id,
actId: invite.actId,
lockedSha256: invite.lockedSha256,
signerType: InspectionActSignerType.COMPANY_RESPONSIBLE,
signerName: dto.fullName,
documentType: dto.documentType,
documentNumber: dto.documentNumber,
position: dto.position,
status: InspectionActSignatureStatus.REFUSED,
reason: dto.reason,
source: InspectionActSignatureSource.WEB,
createdAt: createdAt.toISOString(),
};
const signaturePayloadSha256 = sha256CanonicalJson(payload);
await manager.query(`
INSERT INTO inspection_act_signatures(
id,act_id,signer_type,signer_user_id,signer_name,document_type,document_number,
position,status,reason,source,prepared_sha256,signature_payload_sha256,uploaded_by,created_at
) VALUES($1,$2,'COMPANY_RESPONSIBLE',NULL,$3,$4,$5,$6,'REFUSED',$7,'WEB',$8,$9,$10,$11)
`, [
id,
invite.actId,
dto.fullName,
dto.documentType,
dto.documentNumber,
dto.position,
dto.reason,
invite.lockedSha256,
signaturePayloadSha256,
invite.createdBy,
createdAt,
]);
await manager.query(`
UPDATE inspection_act_company_signature_invites
SET status='USED',used_at=$2,updated_at=$2
WHERE id=$1
`, [invite.id, createdAt]);
await this.audit.record({
action: 'INSPECTION_ACT_COMPANY_REMOTE_REFUSAL_RECORDED',
entityType: 'inspection_act_signature',
entityId: id,
source: AuditSource.WEB,
actorUserId: null,
actorUsername: null,
requestId: request.requestId,
ip: request.ip ?? null,
userAgent: request.get('user-agent') ?? null,
afterData: payload,
metadata: {
invitationId: invite.id,
recipientEmail: invite.recipientEmail,
immutable: true,
signaturePayloadSha256,
},
}, manager);
return { ok: true, actCode: invite.actCode, refusalId: id };
});
}
private async resolveToken(
manager: EntityManager,
token: string,
lock: boolean,
): Promise<InviteContext> {
if (!/^[A-Za-z0-9_-]{40,120}$/.test(token)) throw this.invalidInvite();
const tokenSha256 = this.hashToken(token);
const [invite] = await manager.query(`
SELECT
invite.id,
invite.act_id AS "actId",
invite.token_sha256 AS "tokenSha256",
invite.recipient_email AS "recipientEmail",
invite.recipient_name AS "recipientName",
invite.recipient_document_type AS "recipientDocumentType",
invite.recipient_document_number AS "recipientDocumentNumber",
invite.recipient_position AS "recipientPosition",
invite.status,
invite.expires_at AS "expiresAt",
invite.sent_at AS "sentAt",
invite.used_at AS "usedAt",
invite.created_by AS "createdBy",
act.code AS "actCode",
act.status AS "actStatus",
act.locked_sha256 AS "lockedSha256",
act.locked_at AS "lockedAt",
visit.code AS "inspectionCode",
closure.prepared_snapshot AS "lockedSnapshot"
FROM inspection_act_company_signature_invites invite
JOIN inspection_acts act ON act.id=invite.act_id
JOIN inspection_visits visit ON visit.id=act.visit_id
JOIN inspection_act_closures closure ON closure.act_id=act.id
WHERE invite.token_sha256=$1
${lock ? 'FOR UPDATE OF invite,act' : ''}
`, [tokenSha256]) as InviteContext[];
if (!invite) throw this.invalidInvite();
if (invite.status !== 'PENDING') {
throw new GoneException({
code: 'COMPANY_SIGNATURE_INVITE_NOT_ACTIVE',
message: invite.status === 'USED'
? 'Este enlace ya fue utilizado'
: 'Este enlace ya no está vigente',
});
}
if (new Date(invite.expiresAt).getTime() <= Date.now()) {
await manager.query(`
UPDATE inspection_act_company_signature_invites
SET status='EXPIRED',updated_at=CURRENT_TIMESTAMP
WHERE id=$1 AND status='PENDING'
`, [invite.id]);
throw new GoneException({
code: 'COMPANY_SIGNATURE_INVITE_EXPIRED',
message: 'El enlace de firma venció. Solicitá una nueva invitación.',
});
}
if (invite.actStatus !== 'LOCKED' || !invite.lockedSha256 || !invite.lockedSnapshot) {
throw new ConflictException({
code: 'COMPANY_SIGNATURE_ACT_NOT_AVAILABLE',
message: 'El Acta ya no está disponible para manifestación remota',
});
}
return invite;
}
private async assertActorAssigned(
manager: EntityManager,
actId: string,
principal: AuthPrincipal,
): Promise<void> {
if (principal.permissions.includes('inspections.manage')) return;
const [row] = await manager.query(`
SELECT 1
FROM inspection_acts act
JOIN inspection_visit_members member ON member.visit_id=act.visit_id
WHERE act.id=$1 AND member.user_id=$2 AND member.included=true
LIMIT 1
`, [actId, principal.userId]) as unknown[];
if (!row) {
throw new ConflictException({
code: 'COMPANY_SIGNATURE_INVITE_NOT_ASSIGNED',
message: 'Sólo un Inspector asignado puede emitir la invitación',
});
}
}
private async assertNoCompanyOutcome(manager: EntityManager, actId: string): Promise<void> {
if (await this.hasCompanyOutcome(manager, actId)) {
throw new ConflictException({
code: 'COMPANY_SIGNATURE_ALREADY_RESOLVED',
message: 'La manifestación de la empresa ya fue registrada',
});
}
}
private async hasCompanyOutcome(manager: EntityManager, actId: string): Promise<boolean> {
const rows = await manager.query(`
SELECT 1 FROM inspection_act_signatures
WHERE act_id=$1 AND signer_type='COMPANY_RESPONSIBLE'
LIMIT 1
`, [actId]) as unknown[];
return rows.length > 0;
}
private hashToken(token: string): string {
return createHash('sha256').update(token, 'utf8').digest('hex');
}
private invalidInvite(): NotFoundException {
return new NotFoundException({
code: 'COMPANY_SIGNATURE_INVITE_NOT_FOUND',
message: 'El enlace de firma no es válido',
});
}
private record(value: unknown): Record<string, unknown> {
return value && typeof value === 'object' && !Array.isArray(value)
? value as Record<string, unknown>
: {};
}
private records(value: unknown): Array<Record<string, unknown>> {
return Array.isArray(value) ? value.map((item) => this.record(item)) : [];
}
private storageError(): InternalServerErrorException {
return new InternalServerErrorException({
code: 'COMPANY_SIGNATURE_STORAGE_ERROR',
message: 'No se pudo almacenar la firma remota',
});
}
}
@@ -0,0 +1,101 @@
import { Transform, Type } from 'class-transformer';
import {
IsBoolean,
IsEmail,
IsIn,
IsInt,
IsOptional,
IsString,
Max,
MaxLength,
Min,
MinLength,
ValidateIf,
} from 'class-validator';
export class CreateCompanySignatureInviteDto {
@IsOptional()
@Transform(({ value }) => typeof value === 'string' && value.trim() ? value.trim().toLowerCase() : undefined)
@IsEmail()
@MaxLength(320)
recipientEmail?: string;
@IsOptional()
@Type(() => Number)
@IsInt()
@Min(1)
@Max(30)
expiresInDays?: number;
}
export class PublicCompanySignatureDto {
@Transform(({ value }) => typeof value === 'string' ? value.trim() : value)
@IsString()
@MinLength(2)
@MaxLength(200)
fullName!: string;
@Transform(({ value }) => typeof value === 'string' ? value.trim().toUpperCase() : value)
@IsString()
@IsIn(['DNI', 'CUIL', 'PASSPORT', 'OTHER'])
documentType!: string;
@Transform(({ value }) => typeof value === 'string' ? value.trim() : value)
@IsString()
@MinLength(3)
@MaxLength(40)
documentNumber!: string;
@Transform(({ value }) => typeof value === 'string' ? value.trim() : value)
@IsString()
@MinLength(2)
@MaxLength(200)
position!: string;
@Transform(({ value }) => typeof value === 'string' ? value.trim().toUpperCase() : value)
@IsString()
@IsIn(['CONFORMITY', 'DISSENT'])
manifestation!: 'CONFORMITY' | 'DISSENT';
@ValidateIf((value: PublicCompanySignatureDto) => value.manifestation === 'DISSENT')
@Transform(({ value }) => typeof value === 'string' ? value.trim() : value)
@IsString()
@MinLength(10)
@MaxLength(8000)
statement?: string;
@Transform(({ value }) => value === true || value === 'true')
@IsBoolean()
consentAccepted!: boolean;
}
export class PublicCompanyRefusalDto {
@Transform(({ value }) => typeof value === 'string' ? value.trim() : value)
@IsString()
@MinLength(2)
@MaxLength(200)
fullName!: string;
@Transform(({ value }) => typeof value === 'string' ? value.trim().toUpperCase() : value)
@IsString()
@IsIn(['DNI', 'CUIL', 'PASSPORT', 'OTHER'])
documentType!: string;
@Transform(({ value }) => typeof value === 'string' ? value.trim() : value)
@IsString()
@MinLength(3)
@MaxLength(40)
documentNumber!: string;
@Transform(({ value }) => typeof value === 'string' ? value.trim() : value)
@IsString()
@MinLength(2)
@MaxLength(200)
position!: string;
@Transform(({ value }) => typeof value === 'string' ? value.trim() : value)
@IsString()
@MinLength(10)
@MaxLength(8000)
reason!: string;
}
@@ -48,9 +48,9 @@ export class InspectionClosingController {
return this.closing.upsertResponsible(actId, dto, principal, request);
}
@Post('ready')
@Post('lock')
@RequirePermissions('inspection_closure.prepare')
ready(
lock(
@Param('actId', new ParseUUIDPipe({ version: '4' })) actId: string,
@CurrentAuth() principal: AuthPrincipal,
@Req() request: RequestWithContext,
@@ -58,16 +58,6 @@ export class InspectionClosingController {
return this.closing.prepare(actId, principal, request);
}
@Post('reopen')
@RequirePermissions('inspection_closure.prepare')
reopen(
@Param('actId', new ParseUUIDPipe({ version: '4' })) actId: string,
@CurrentAuth() principal: AuthPrincipal,
@Req() request: RequestWithContext,
) {
return this.closing.reopen(actId, principal, request);
}
@Post('signatures/inspector')
@RequirePermissions('inspection_closure.sign')
@UseInterceptors(FileInterceptor('file', {
@@ -109,9 +99,9 @@ export class InspectionClosingController {
return this.closing.recordCompanyOutcome(actId, dto, principal, request);
}
@Post('close')
@Post('seal')
@RequirePermissions('inspection_closure.close')
close(
seal(
@Param('actId', new ParseUUIDPipe({ version: '4' })) actId: string,
@Body() dto: CloseInspectionActDto,
@CurrentAuth() principal: AuthPrincipal,
@@ -1,6 +1,11 @@
import { Module } from '@nestjs/common';
import { AuditModule } from '../audit/audit.module';
import { InspectionReportsModule } from '../inspection-reports/inspection-reports.module';
import {
CompanySignatureInviteController,
PublicCompanySignatureController,
} from './company-signature-invite.controller';
import { CompanySignatureInviteService } from './company-signature-invite.service';
import {
InspectionClosingController,
InspectionSignatureContentController,
@@ -9,7 +14,12 @@ import { InspectionClosingService } from './inspection-closing.service';
@Module({
imports: [AuditModule, InspectionReportsModule],
controllers: [InspectionClosingController, InspectionSignatureContentController],
providers: [InspectionClosingService],
controllers: [
InspectionClosingController,
InspectionSignatureContentController,
CompanySignatureInviteController,
PublicCompanySignatureController,
],
providers: [InspectionClosingService, CompanySignatureInviteService],
})
export class InspectionClosingModule {}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,6 @@
import { IsUUID } from 'class-validator';
export class SetFindingRecurrenceDto {
@IsUUID('4')
recurrenceOfFindingId!: string;
}
@@ -0,0 +1,53 @@
import {
Body,
Controller,
Delete,
Get,
Param,
ParseUUIDPipe,
Post,
Req,
} from '@nestjs/common';
import { CurrentAuth } from '../auth/decorators/current-auth.decorator';
import { RequirePermissions } from '../authorization/decorators/require-permissions.decorator';
import type { AuthPrincipal, RequestWithContext } from '../common/http/request-context';
import { SetFindingRecurrenceDto } from './dto/set-finding-recurrence.dto';
import { FindingRecurrenceService } from './finding-recurrence.service';
@Controller('inspection-findings')
export class FindingRecurrenceController {
constructor(private readonly recurrence: FindingRecurrenceService) {}
@Get(':id/recurrence')
@RequirePermissions('inspection_findings.read')
state(@Param('id', new ParseUUIDPipe({ version: '4' })) id: string) {
return this.recurrence.state(id);
}
@Get(':id/recurrence-candidates')
@RequirePermissions('inspection_findings.read')
candidates(@Param('id', new ParseUUIDPipe({ version: '4' })) id: string) {
return this.recurrence.candidates(id);
}
@Post(':id/recurrence')
@RequirePermissions('inspection_findings.update')
link(
@Param('id', new ParseUUIDPipe({ version: '4' })) id: string,
@Body() dto: SetFindingRecurrenceDto,
@CurrentAuth() principal: AuthPrincipal,
@Req() request: RequestWithContext,
) {
return this.recurrence.link(id, dto, principal, request);
}
@Delete(':id/recurrence')
@RequirePermissions('inspection_findings.update')
clear(
@Param('id', new ParseUUIDPipe({ version: '4' })) id: string,
@CurrentAuth() principal: AuthPrincipal,
@Req() request: RequestWithContext,
) {
return this.recurrence.clear(id, principal, request);
}
}
@@ -0,0 +1,268 @@
import {
BadRequestException,
ConflictException,
ForbiddenException,
Injectable,
NotFoundException,
} from '@nestjs/common';
import { DataSource, EntityManager } from 'typeorm';
import { administrationAuditContext } from '../administration/common/administration-audit';
import { AuditService } from '../audit/audit.service';
import type { AuthPrincipal, RequestWithContext } from '../common/http/request-context';
import { AuditAction } from '../database/entities';
import { assertMobileInspector } from '../inspection-operations/mobile-inspector-policy';
import type { SetFindingRecurrenceDto } from './dto/set-finding-recurrence.dto';
import { InspectionFindingsService } from './inspection-findings.service';
import type { InspectionFindingView } from './inspection-findings.service';
interface FindingRecurrenceContext {
id: string;
assetId: string;
catalogItemId: string | null;
title: string;
createdAt: Date;
actId: string;
actStatus: string;
visitId: string;
visitStatus: string;
}
export interface FindingRecurrenceState {
isRecurrence: boolean;
recurrenceOfFindingId: string | null;
recurrenceOfFindingCode: string | null;
}
export type FindingWithRecurrenceView = InspectionFindingView & FindingRecurrenceState;
function comparable(value: string): string {
return value
.normalize('NFD')
.replace(/[\u0300-\u036f]/g, '')
.toLowerCase()
.replace(/[^a-z0-9]+/g, ' ')
.trim()
.replace(/\s+/g, ' ');
}
@Injectable()
export class FindingRecurrenceService {
constructor(
private readonly dataSource: DataSource,
private readonly audit: AuditService,
private readonly findings: InspectionFindingsService,
) {}
async state(id: string): Promise<FindingRecurrenceState> {
const [row] = await this.dataSource.query(`
SELECT
finding.is_recurrence AS "isRecurrence",
finding.recurrence_of_finding_id AS "recurrenceOfFindingId",
antecedent.code AS "recurrenceOfFindingCode"
FROM inspection_findings finding
LEFT JOIN inspection_findings antecedent ON antecedent.id = finding.recurrence_of_finding_id
WHERE finding.id = $1
`, [id]) as FindingRecurrenceState[];
if (!row) {
throw new NotFoundException({
code: 'INSPECTION_FINDING_NOT_FOUND',
message: 'Hallazgo no encontrado',
});
}
return row;
}
async candidates(id: string) {
const current = await this.requireFinding(this.dataSource.manager, id, false);
const rows = await this.dataSource.query(`
SELECT
finding.id,finding.code,finding.title,finding.description,finding.status,
finding.catalog_item_id AS "catalogItemId",finding.created_at AS "createdAt",
act.id AS "actId",act.code AS "actCode",act.occurred_at AS "actOccurredAt",
visit.id AS "visitId",visit.code AS "visitCode",visit.status AS "visitStatus"
FROM inspection_findings finding
JOIN inspection_acts act ON act.id=finding.act_id
JOIN inspection_visits visit ON visit.id=act.visit_id
WHERE finding.asset_id=$1
AND finding.id<>$2
AND finding.status<>'VOIDED'
AND finding.created_at<$3
AND (
($4::uuid IS NOT NULL AND finding.catalog_item_id=$4::uuid)
OR ($4::uuid IS NULL AND lower(btrim(finding.title))=lower(btrim($5)))
)
ORDER BY finding.created_at DESC,finding.id DESC
LIMIT 20
`, [current.assetId, current.id, current.createdAt, current.catalogItemId, current.title]);
return {
findingId: current.id,
assetId: current.assetId,
hasCandidates: rows.length > 0,
data: rows,
};
}
async link(
id: string,
dto: SetFindingRecurrenceDto,
principal: AuthPrincipal,
request: RequestWithContext,
): Promise<FindingWithRecurrenceView> {
assertMobileInspector(principal);
await this.dataSource.transaction(async (manager) => {
const current = await this.requireFinding(manager, id, true);
this.assertEditable(current);
await this.assertActorAssigned(manager, current.visitId, principal);
if (id === dto.recurrenceOfFindingId) {
throw new BadRequestException({
code: 'FINDING_CANNOT_RECUR_FROM_SELF',
message: 'Un Hallazgo no puede ser reincidencia de sí mismo',
});
}
const previous = await this.requireFinding(manager, dto.recurrenceOfFindingId, false);
if (previous.assetId !== current.assetId) {
throw new ConflictException({
code: 'FINDING_RECURRENCE_DIFFERENT_INVENTORY',
message: 'La reincidencia debe referir a un Hallazgo anterior del mismo Inventario',
});
}
if (new Date(previous.createdAt).getTime() >= new Date(current.createdAt).getTime()) {
throw new ConflictException({
code: 'FINDING_RECURRENCE_NOT_PREVIOUS',
message: 'El Hallazgo de referencia debe ser anterior al actual',
});
}
const sameCatalog = current.catalogItemId && previous.catalogItemId
? current.catalogItemId === previous.catalogItemId
: current.catalogItemId === previous.catalogItemId;
const sameTitle = comparable(current.title) === comparable(previous.title);
if (!sameCatalog && !sameTitle) {
throw new ConflictException({
code: 'FINDING_RECURRENCE_NOT_EQUIVALENT',
message: 'El antecedente seleccionado no corresponde al mismo tipo de Hallazgo',
});
}
const [before] = await manager.query(`
SELECT is_recurrence AS "isRecurrence",recurrence_of_finding_id AS "recurrenceOfFindingId"
FROM inspection_findings WHERE id=$1 FOR UPDATE
`, [id]) as Array<{ isRecurrence: boolean; recurrenceOfFindingId: string | null }>;
await manager.query(`
UPDATE inspection_findings
SET is_recurrence=true,recurrence_of_finding_id=$2,updated_by=$3,updated_at=CURRENT_TIMESTAMP
WHERE id=$1
`, [id, previous.id, principal.userId]);
await this.audit.record({
...administrationAuditContext(principal, request),
action: AuditAction.INSPECTION_FINDING_UPDATED,
entityType: 'inspection_finding',
entityId: id,
beforeData: before ?? { isRecurrence: false, recurrenceOfFindingId: null },
afterData: {
isRecurrence: true,
recurrenceOfFindingId: previous.id,
recurrenceOfFindingCode: await this.codeForFinding(manager, previous.id),
},
metadata: { actId: current.actId, visitId: current.visitId, recurrenceAction: 'LINKED' },
}, manager);
});
const [finding, recurrence] = await Promise.all([
this.findings.getById(id),
this.state(id),
]);
return { ...finding, ...recurrence };
}
async clear(
id: string,
principal: AuthPrincipal,
request: RequestWithContext,
): Promise<FindingWithRecurrenceView> {
assertMobileInspector(principal);
await this.dataSource.transaction(async (manager) => {
const current = await this.requireFinding(manager, id, true);
this.assertEditable(current);
await this.assertActorAssigned(manager, current.visitId, principal);
const [before] = await manager.query(`
SELECT is_recurrence AS "isRecurrence",recurrence_of_finding_id AS "recurrenceOfFindingId"
FROM inspection_findings WHERE id=$1 FOR UPDATE
`, [id]) as Array<{ isRecurrence: boolean; recurrenceOfFindingId: string | null }>;
if (!before?.isRecurrence && !before?.recurrenceOfFindingId) return;
await manager.query(`
UPDATE inspection_findings
SET is_recurrence=false,recurrence_of_finding_id=NULL,updated_by=$2,updated_at=CURRENT_TIMESTAMP
WHERE id=$1
`, [id, principal.userId]);
await this.audit.record({
...administrationAuditContext(principal, request),
action: AuditAction.INSPECTION_FINDING_UPDATED,
entityType: 'inspection_finding',
entityId: id,
beforeData: before,
afterData: { isRecurrence: false, recurrenceOfFindingId: null },
metadata: { actId: current.actId, visitId: current.visitId, recurrenceAction: 'CLEARED' },
}, manager);
});
const [finding, recurrence] = await Promise.all([
this.findings.getById(id),
this.state(id),
]);
return { ...finding, ...recurrence };
}
private async requireFinding(
manager: EntityManager,
id: string,
lock: boolean,
): Promise<FindingRecurrenceContext> {
const [row] = await manager.query(`
SELECT finding.id,finding.asset_id AS "assetId",finding.catalog_item_id AS "catalogItemId",
finding.title,finding.created_at AS "createdAt",finding.act_id AS "actId",
act.status AS "actStatus",visit.id AS "visitId",visit.status AS "visitStatus"
FROM inspection_findings finding
JOIN inspection_acts act ON act.id=finding.act_id
JOIN inspection_visits visit ON visit.id=act.visit_id
WHERE finding.id=$1
${lock ? 'FOR UPDATE OF finding,act,visit' : ''}
`, [id]) as FindingRecurrenceContext[];
if (!row) {
throw new NotFoundException({
code: 'INSPECTION_FINDING_NOT_FOUND',
message: 'Hallazgo no encontrado',
});
}
return row;
}
private assertEditable(finding: FindingRecurrenceContext): void {
if (finding.actStatus !== 'DRAFT' || finding.visitStatus !== 'IN_PROGRESS') {
throw new ConflictException({
code: 'FINDING_RECURRENCE_IMMUTABLE',
message: 'La reincidencia sólo puede definirse mientras el Acta está en BORRADOR y la Inspección en curso',
});
}
}
private async assertActorAssigned(
manager: EntityManager,
visitId: string,
principal: AuthPrincipal,
): Promise<void> {
if (principal.permissions.includes('inspections.manage')) return;
const [row] = await manager.query(`
SELECT 1 FROM inspection_visit_members
WHERE visit_id=$1 AND user_id=$2 AND included=true LIMIT 1
`, [visitId, principal.userId]) as unknown[];
if (!row) {
throw new ForbiddenException({
code: 'INSPECTION_NOT_ASSIGNED',
message: 'La Inspección no está asignada al usuario actual',
});
}
}
private async codeForFinding(manager: EntityManager, id: string): Promise<string | null> {
const [row] = await manager.query('SELECT code FROM inspection_findings WHERE id=$1', [id]) as Array<{ code: string }>;
return row?.code ?? null;
}
}
@@ -0,0 +1,331 @@
import { Injectable } from '@nestjs/common';
import { DataSource } from 'typeorm';
import type { ListInspectionFindingsQueryDto } from './dto/list-inspection-findings-query.dto';
export interface InspectionFindingWorklistCounters {
open: number;
withoutControlDate: number;
toVerify: number;
verificationOverdue: number;
verificationNext30Days: number;
readyToClose: number;
closed: number;
}
export interface InspectionFindingWorklistItem {
id: string;
actId: string;
assetId: string;
code: string;
status: 'OPEN' | 'CLOSED' | 'VOIDED';
title: string;
description: string;
severity: number | null;
nextControlOn: string | null;
closedAt: Date | null;
closureNotes: string | null;
asset: {
id: string;
code: string;
name: string;
commonName: string | null;
typeName: string;
operatorCompany: { id: string; code: string; name: string } | null;
operationalArea: { id: string; code: string; name: string } | null;
};
document: {
actId: string;
actCode: string;
actStatus: string;
visitId: string;
visitCode: string;
visitStatus: string;
};
verificationVisit: {
id: string;
code: string;
status: string;
plannedStartAt: Date | null;
} | null;
latestVerification: {
visitId: string;
visitCode: string;
visitStatus: string;
targetControlOn: string | null;
outcome: 'RESOLVED' | 'NOT_RESOLVED' | 'REQUIRES_NEW_DATE' | null;
resultNotes: string | null;
verifiedAt: Date | null;
resultRecordedAt: Date | null;
rescheduledControlOn: string | null;
evidenceCount: number;
} | null;
createdAt: Date;
updatedAt: Date;
}
export interface InspectionFindingWorklistPage {
data: InspectionFindingWorklistItem[];
meta: { page: number; pageSize: number; total: number; totalPages: number };
counters: InspectionFindingWorklistCounters;
}
@Injectable()
export class InspectionFindingWorklistService {
constructor(private readonly dataSource: DataSource) {}
async list(query: ListInspectionFindingsQueryDto): Promise<InspectionFindingWorklistPage> {
const page = query.page ?? 1;
const pageSize = query.pageSize ?? 25;
const workflow = query.workflow ?? 'OPEN';
const values: unknown[] = [];
const contextFilters: string[] = [];
const today = `(CURRENT_TIMESTAMP AT TIME ZONE 'America/Argentina/Mendoza')::date`;
const latestOutcome = `(SELECT latest_verification.outcome
FROM inspection_finding_verification_visits latest_verification
WHERE latest_verification.finding_id = finding.id
AND latest_verification.outcome IS NOT NULL
ORDER BY latest_verification.result_recorded_at DESC NULLS LAST,
latest_verification.created_at DESC,
latest_verification.id DESC
LIMIT 1)`;
const latestVisitStatus = `(SELECT latest_visit.status
FROM inspection_finding_verification_visits latest_verification
INNER JOIN inspection_visits latest_visit ON latest_visit.id = latest_verification.visit_id
WHERE latest_verification.finding_id = finding.id
AND latest_verification.outcome IS NOT NULL
ORDER BY latest_verification.result_recorded_at DESC NULLS LAST,
latest_verification.created_at DESC,
latest_verification.id DESC
LIMIT 1)`;
const add = (value: unknown): string => {
values.push(value);
return `$${values.length}`;
};
if (query.search) {
const parameter = add(`%${query.search}%`);
contextFilters.push(`(
finding.code ILIKE ${parameter}
OR finding.title ILIKE ${parameter}
OR finding.description ILIKE ${parameter}
OR asset.code ILIKE ${parameter}
OR asset.name ILIKE ${parameter}
OR company.name ILIKE ${parameter}
OR area.name ILIKE ${parameter}
OR act.code ILIKE ${parameter}
)`);
}
if (query.companyId) contextFilters.push(`company.id = ${add(query.companyId)}::uuid`);
if (query.areaId) contextFilters.push(`area.id = ${add(query.areaId)}::uuid`);
if (query.inspectorId) {
const inspector = add(query.inspectorId);
contextFilters.push(`(
visit.lead_inspector_user_id = ${inspector}::uuid
OR EXISTS (
SELECT 1 FROM inspection_visit_members member_filter
WHERE member_filter.visit_id = visit.id
AND member_filter.included = true
AND member_filter.user_id = ${inspector}::uuid
)
)`);
}
if (query.dateFrom) contextFilters.push(`act.occurred_at::date >= ${add(query.dateFrom)}::date`);
if (query.dateTo) contextFilters.push(`act.occurred_at::date <= ${add(query.dateTo)}::date`);
const workflowFilters: string[] = [];
if (workflow === 'OPEN') workflowFilters.push(`finding.status = 'OPEN'`);
if (workflow === 'TO_SCHEDULE_VERIFICATION') {
workflowFilters.push(`finding.status = 'OPEN' AND finding.next_control_on IS NULL AND COALESCE(${latestOutcome}, '') <> 'RESOLVED'`);
}
if (workflow === 'TO_VERIFY') {
workflowFilters.push(`finding.status = 'OPEN' AND finding.next_control_on IS NOT NULL AND COALESCE(${latestOutcome}, '') <> 'RESOLVED'`);
}
if (workflow === 'VERIFICATION_OVERDUE') {
workflowFilters.push(`finding.status = 'OPEN' AND finding.next_control_on IS NOT NULL AND finding.next_control_on < ${today} AND COALESCE(${latestOutcome}, '') <> 'RESOLVED'`);
}
if (workflow === 'READY_TO_CLOSE') {
workflowFilters.push(`finding.status = 'OPEN' AND ${latestOutcome} = 'RESOLVED' AND ${latestVisitStatus} = 'CLOSED'`);
}
if (workflow === 'CLOSED') workflowFilters.push(`finding.status = 'CLOSED'`);
if (workflow === 'WAITING_COMPANY' || workflow === 'COMPANY_OVERDUE') {
// Compatibilidad de URL histórica: estos estados dejaron de existir en F4.
workflowFilters.push(`finding.status = 'OPEN'`);
}
const joins = `
INNER JOIN inspection_acts act ON act.id = finding.act_id
INNER JOIN inspection_visits visit ON visit.id = act.visit_id
INNER JOIN assets asset ON asset.id = finding.asset_id
INNER JOIN asset_types asset_type ON asset_type.id = asset.asset_type_id
LEFT JOIN assets company ON company.id = asset.operator_company_id
LEFT JOIN assets area ON area.id = asset.operational_area_id
`;
const filters = [...contextFilters, ...workflowFilters];
const where = filters.length ? `WHERE ${filters.join(' AND ')}` : '';
const contextWhere = contextFilters.length ? `WHERE ${contextFilters.join(' AND ')}` : '';
const [countRow] = await this.dataSource.query(`
SELECT COUNT(*)::integer AS total
FROM inspection_findings finding
${joins}
${where}
`, values) as Array<{ total: number }>;
const total = Number(countRow?.total ?? 0);
const listValues = [...values, pageSize, (page - 1) * pageSize];
const limitParameter = `$${values.length + 1}`;
const offsetParameter = `$${values.length + 2}`;
const data = await this.dataSource.query(`
SELECT
finding.id,
finding.act_id AS "actId",
finding.asset_id AS "assetId",
finding.code,
finding.status,
finding.title,
finding.description,
finding.severity,
finding.next_control_on AS "nextControlOn",
finding.closed_at AS "closedAt",
finding.closure_notes AS "closureNotes",
JSONB_BUILD_OBJECT(
'id', asset.id,
'code', asset.code,
'name', asset.name,
'commonName', asset.common_name,
'typeName', asset_type.name,
'operatorCompany', CASE WHEN company.id IS NULL THEN NULL ELSE JSONB_BUILD_OBJECT(
'id', company.id, 'code', company.code, 'name', company.name
) END,
'operationalArea', CASE WHEN area.id IS NULL THEN NULL ELSE JSONB_BUILD_OBJECT(
'id', area.id, 'code', area.code, 'name', area.name
) END
) AS asset,
JSONB_BUILD_OBJECT(
'actId', act.id,
'actCode', act.code,
'actStatus', act.status,
'visitId', visit.id,
'visitCode', visit.code,
'visitStatus', visit.status
) AS document,
CASE WHEN verification_visit.id IS NULL THEN NULL ELSE JSONB_BUILD_OBJECT(
'id', verification_visit.id,
'code', verification_visit.code,
'status', verification_visit.status,
'plannedStartAt', verification_visit.planned_start_at
) END AS "verificationVisit",
CASE WHEN latest_verification.visit_id IS NULL THEN NULL ELSE JSONB_BUILD_OBJECT(
'visitId', latest_verification.visit_id,
'visitCode', latest_verification.visit_code,
'visitStatus', latest_verification.visit_status,
'targetControlOn', latest_verification.target_control_on,
'outcome', latest_verification.outcome,
'resultNotes', latest_verification.result_notes,
'verifiedAt', latest_verification.verified_at,
'resultRecordedAt', latest_verification.result_recorded_at,
'rescheduledControlOn', latest_verification.rescheduled_control_on,
'evidenceCount', latest_verification.evidence_count
) END AS "latestVerification",
finding.created_at AS "createdAt",
finding.updated_at AS "updatedAt"
FROM inspection_findings finding
${joins}
LEFT JOIN LATERAL (
SELECT verification_target.id, verification_target.code,
verification_target.status, verification_target.planned_start_at
FROM inspection_finding_verification_visits verification_link
INNER JOIN inspection_visits verification_target ON verification_target.id = verification_link.visit_id
WHERE verification_link.finding_id = finding.id
AND verification_target.status IN ('DRAFT', 'PLANNED', 'IN_PROGRESS')
ORDER BY verification_link.created_at DESC, verification_link.id DESC
LIMIT 1
) verification_visit ON true
LEFT JOIN LATERAL (
SELECT
verification_link.visit_id,
verification_target.code AS visit_code,
verification_target.status AS visit_status,
verification_link.target_control_on,
verification_link.outcome,
verification_link.result_notes,
verification_link.verified_at,
verification_link.result_recorded_at,
verification_link.rescheduled_control_on,
(SELECT COUNT(*)::integer
FROM inspection_finding_evidence evidence
WHERE evidence.finding_id = finding.id
AND evidence.verification_visit_id = verification_link.visit_id
AND evidence.purpose = 'VERIFICATION') AS evidence_count
FROM inspection_finding_verification_visits verification_link
INNER JOIN inspection_visits verification_target ON verification_target.id = verification_link.visit_id
WHERE verification_link.finding_id = finding.id
ORDER BY COALESCE(verification_link.result_recorded_at, verification_link.created_at) DESC,
verification_link.id DESC
LIMIT 1
) latest_verification ON true
${where}
ORDER BY
CASE WHEN finding.status = 'OPEN' THEN 0 ELSE 1 END,
COALESCE(finding.next_control_on, '9999-12-31'::date),
finding.updated_at DESC,
finding.code
LIMIT ${limitParameter} OFFSET ${offsetParameter}
`, listValues) as InspectionFindingWorklistItem[];
const [counterRow] = await this.dataSource.query(`
SELECT
COUNT(*) FILTER (WHERE finding.status='OPEN')::integer AS "open",
COUNT(*) FILTER (
WHERE finding.status='OPEN'
AND finding.next_control_on IS NULL
AND COALESCE(${latestOutcome}, '') <> 'RESOLVED'
)::integer AS "withoutControlDate",
COUNT(*) FILTER (
WHERE finding.status='OPEN'
AND finding.next_control_on IS NOT NULL
AND COALESCE(${latestOutcome}, '') <> 'RESOLVED'
)::integer AS "toVerify",
COUNT(*) FILTER (
WHERE finding.status='OPEN'
AND finding.next_control_on IS NOT NULL
AND finding.next_control_on < ${today}
AND COALESCE(${latestOutcome}, '') <> 'RESOLVED'
)::integer AS "verificationOverdue",
COUNT(*) FILTER (
WHERE finding.status='OPEN'
AND finding.next_control_on BETWEEN ${today} AND ${today} + 30
AND COALESCE(${latestOutcome}, '') <> 'RESOLVED'
)::integer AS "verificationNext30Days",
COUNT(*) FILTER (
WHERE finding.status='OPEN'
AND ${latestOutcome}='RESOLVED'
AND ${latestVisitStatus}='CLOSED'
)::integer AS "readyToClose",
COUNT(*) FILTER (WHERE finding.status='CLOSED')::integer AS "closed"
FROM inspection_findings finding
${joins}
${contextWhere}
`, values) as InspectionFindingWorklistCounters[];
return {
data,
meta: {
page,
pageSize,
total,
totalPages: total === 0 ? 0 : Math.ceil(total / pageSize),
},
counters: {
open: Number(counterRow?.open ?? 0),
withoutControlDate: Number(counterRow?.withoutControlDate ?? 0),
toVerify: Number(counterRow?.toVerify ?? 0),
verificationOverdue: Number(counterRow?.verificationOverdue ?? 0),
verificationNext30Days: Number(counterRow?.verificationNext30Days ?? 0),
readyToClose: Number(counterRow?.readyToClose ?? 0),
closed: Number(counterRow?.closed ?? 0),
},
};
}
}
@@ -15,9 +15,12 @@ import type { AuthPrincipal, RequestWithContext } from '../common/http/request-c
import { CloseInspectionFindingDto } from './dto/close-inspection-finding.dto';
import { CreateInspectionFindingDto } from './dto/create-inspection-finding.dto';
import { ListInspectionFindingsQueryDto } from './dto/list-inspection-findings-query.dto';
import { UpdateInspectionFindingFollowUpDto } from './dto/update-inspection-finding-follow-up.dto';
import { UpdateInspectionFindingDto } from './dto/update-inspection-finding.dto';
import { FindingRecurrenceService } from './finding-recurrence.service';
import type { FindingRecurrenceState } from './finding-recurrence.service';
import { InspectionFindingWorklistService } from './inspection-finding-worklist.service';
import { InspectionFindingsService } from './inspection-findings.service';
import type { InspectionFindingView } from './inspection-findings.service';
@Controller('inspection-acts/:actId/findings')
export class InspectionActFindingsController {
@@ -43,18 +46,28 @@ export class InspectionActFindingsController {
@Controller('inspection-findings')
export class InspectionFindingsController {
constructor(private readonly findings: InspectionFindingsService) {}
constructor(
private readonly findings: InspectionFindingsService,
private readonly worklist: InspectionFindingWorklistService,
private readonly recurrence: FindingRecurrenceService,
) {}
@Get()
@RequirePermissions('inspection_findings.read')
list(@Query() query: ListInspectionFindingsQueryDto) {
return this.findings.listGlobal(query);
return this.worklist.list(query);
}
@Get(':id')
@RequirePermissions('inspection_findings.read')
get(@Param('id', new ParseUUIDPipe({ version: '4' })) id: string) {
return this.findings.getById(id);
async get(
@Param('id', new ParseUUIDPipe({ version: '4' })) id: string,
): Promise<InspectionFindingView & FindingRecurrenceState> {
const [finding, recurrence] = await Promise.all([
this.findings.getById(id),
this.recurrence.state(id),
]);
return { ...finding, ...recurrence };
}
@Get(':id/verification-history')
@@ -84,15 +97,4 @@ export class InspectionFindingsController {
) {
return this.findings.close(id, dto, principal, request);
}
@Patch(':id/follow-up')
@RequirePermissions('inspection_findings.follow_up')
updateFollowUp(
@Param('id', new ParseUUIDPipe({ version: '4' })) id: string,
@Body() dto: UpdateInspectionFindingFollowUpDto,
@CurrentAuth() principal: AuthPrincipal,
@Req() request: RequestWithContext,
) {
return this.findings.updateFollowUp(id, dto, principal, request);
}
}
@@ -4,6 +4,9 @@ import { FindingCatalogController } from './finding-catalog.controller';
import { FindingCatalogMergeService } from './finding-catalog-merge.service';
import { FindingCatalogService } from './finding-catalog.service';
import { F3FindingCatalogResolverService } from './f3-finding-catalog-resolver.service';
import { FindingRecurrenceController } from './finding-recurrence.controller';
import { FindingRecurrenceService } from './finding-recurrence.service';
import { InspectionFindingWorklistService } from './inspection-finding-worklist.service';
import {
InspectionActFindingsController,
InspectionFindingsController,
@@ -22,6 +25,7 @@ import { InspectionEvidenceService } from './inspection-evidence.service';
FindingCatalogController,
InspectionActFindingsController,
InspectionFindingsController,
FindingRecurrenceController,
InspectionFindingEvidenceController,
InspectionFindingCommunicationsController,
InspectionEvidenceContentController,
@@ -31,6 +35,8 @@ import { InspectionEvidenceService } from './inspection-evidence.service';
F3FindingCatalogResolverService,
FindingCatalogMergeService,
InspectionFindingsService,
InspectionFindingWorklistService,
FindingRecurrenceService,
InspectionEvidenceService,
],
exports: [
@@ -38,6 +44,8 @@ import { InspectionEvidenceService } from './inspection-evidence.service';
F3FindingCatalogResolverService,
FindingCatalogMergeService,
InspectionFindingsService,
InspectionFindingWorklistService,
FindingRecurrenceService,
],
})
export class InspectionFindingsModule {}
@@ -62,7 +62,6 @@ interface FindingDocumentView {
actStatus: InspectionActStatus;
visitId: string;
visitCode: string;
visitTitle: string;
visitStatus: InspectionVisitStatus;
}
@@ -99,7 +98,6 @@ export interface InspectionFindingListItem {
verificationVisit: {
id: string;
code: string;
title: string;
status: InspectionVisitStatus;
plannedStartAt: Date | null;
} | null;
@@ -143,7 +141,6 @@ export interface FindingVerificationHistoryEvent {
verificationVisit: {
id: string;
code: string;
title: string;
status: InspectionVisitStatus;
plannedStartAt: Date | null;
} | null;
@@ -811,13 +808,11 @@ export class InspectionFindingsService {
'actStatus', act.status,
'visitId', visit.id,
'visitCode', visit.code,
'visitTitle', visit.title,
'visitStatus', visit.status
) AS document,
CASE WHEN verification_visit.id IS NULL THEN NULL ELSE JSONB_BUILD_OBJECT(
'id', verification_visit.id,
'code', verification_visit.code,
'title', verification_visit.title,
'status', verification_visit.status,
'plannedStartAt', verification_visit.planned_start_at
) END AS "verificationVisit",
@@ -845,7 +840,7 @@ export class InspectionFindingsService {
LEFT JOIN finding_catalog_items catalog ON catalog.id = finding.catalog_item_id
LEFT JOIN finding_categories category ON category.id = catalog.category_id
LEFT JOIN LATERAL (
SELECT verification_target.id, verification_target.code, verification_target.title,
SELECT verification_target.id, verification_target.code,
verification_target.status, verification_target.planned_start_at
FROM inspection_finding_verification_visits verification_link
INNER JOIN inspection_visits verification_target ON verification_target.id = verification_link.visit_id
@@ -923,7 +918,6 @@ export class InspectionFindingsService {
CASE WHEN visit.id IS NULL THEN NULL ELSE JSONB_BUILD_OBJECT(
'id', visit.id,
'code', visit.code,
'title', visit.title,
'status', visit.status,
'plannedStartAt', visit.planned_start_at
) END AS "verificationVisit",
@@ -1,17 +1,128 @@
import { Body, Controller, Get, Param, ParseUUIDPipe, Patch, Post, Req } from '@nestjs/common';
import { Body, Controller, ForbiddenException, Get, Param, ParseUUIDPipe, Patch, Post, Put, Req } from '@nestjs/common';
import { CurrentAuth } from '../auth/decorators/current-auth.decorator';
import { RequirePermissions } from '../authorization/decorators/require-permissions.decorator';
import type { AuthPrincipal, RequestWithContext } from '../common/http/request-context';
import { administrationAuditContext } from '../administration/common/administration-audit';
import { AuditService } from '../audit/audit.service';
import { AuditAction } from '../database/entities';
import { InspectionDocumentDeliveryService } from './inspection-document-delivery.service';
import type { DeliveryRow } from './inspection-document-delivery.service';
import { SmtpDeliveryService } from './smtp-delivery.service';
import { UpdateDocumentDeliverySettingsDto } from './dto/update-document-delivery-settings.dto';
import { TestSmtpSettingsDto, UpdateSmtpSettingsDto } from './dto/update-smtp-settings.dto';
@Controller('document-delivery')
export class DocumentDeliveryController {
constructor(private readonly delivery:InspectionDocumentDeliveryService){}
@Get('settings') @RequirePermissions('document_delivery.read') settings(){return this.delivery.settings();}
@Patch('settings') @RequirePermissions('document_delivery.manage') update(@Body() dto:UpdateDocumentDeliverySettingsDto,@CurrentAuth() principal:AuthPrincipal,@Req() request:RequestWithContext){return this.delivery.updateSettings(dto,principal,request);}
@Get('outbox') @RequirePermissions('document_delivery.read') outbox(){return this.delivery.list();}
@Post('outbox/:id/retry') @RequirePermissions('document_delivery.manage') retry(@Param('id',new ParseUUIDPipe({version:'4'})) id:string,@CurrentAuth() principal:AuthPrincipal,@Req() request:RequestWithContext):Promise<DeliveryRow>{return this.delivery.retry(id,principal,request);}
@Post('outbox/retry-pending') @RequirePermissions('document_delivery.manage') retryPending(@CurrentAuth() principal:AuthPrincipal,@Req() request:RequestWithContext){return this.delivery.retryPending(principal,request);}
constructor(
private readonly delivery: InspectionDocumentDeliveryService,
private readonly smtp: SmtpDeliveryService,
private readonly audit: AuditService,
) {}
@Get('settings')
@RequirePermissions('document_delivery.read')
settings() {
return this.delivery.settings();
}
@Patch('settings')
@RequirePermissions('document_delivery.manage')
update(
@Body() dto: UpdateDocumentDeliverySettingsDto,
@CurrentAuth() principal: AuthPrincipal,
@Req() request: RequestWithContext,
) {
return this.delivery.updateSettings(dto, principal, request);
}
@Get('smtp')
@RequirePermissions('document_delivery.manage')
smtpSettings(@CurrentAuth() principal: AuthPrincipal) {
this.assertSystemAdmin(principal);
return this.smtp.publicSettings();
}
@Put('smtp')
@RequirePermissions('document_delivery.manage')
async updateSmtp(
@Body() dto: UpdateSmtpSettingsDto,
@CurrentAuth() principal: AuthPrincipal,
@Req() request: RequestWithContext,
) {
this.assertSystemAdmin(principal);
const before = await this.smtp.publicSettings();
const after = await this.smtp.saveSettings(dto, principal.userId);
await this.audit.record({
...administrationAuditContext(principal, request),
action: AuditAction.SMTP_SETTINGS_UPDATED,
entityType: 'system_smtp_settings',
entityId: 'singleton',
beforeData: before as Record<string, unknown>,
afterData: after as Record<string, unknown>,
metadata: { passwordNeverReturned: true, restrictedToRole: 'admin' },
});
return after;
}
@Post('smtp/test')
@RequirePermissions('document_delivery.manage')
async testSmtp(
@Body() dto: TestSmtpSettingsDto,
@CurrentAuth() principal: AuthPrincipal,
@Req() request: RequestWithContext,
) {
this.assertSystemAdmin(principal);
const sent = await this.smtp.send({
to: dto.email,
subject: 'DH Inspección · Prueba SMTP',
text: 'Este correo confirma que la configuración SMTP de DH Inspección funciona correctamente.',
attachment: {
filename: 'dh-inspeccion-smtp-test.txt',
mimeType: 'text/plain',
content: Buffer.from('DH Inspección · Prueba SMTP OK\n', 'utf8'),
},
});
await this.audit.record({
...administrationAuditContext(principal, request),
action: AuditAction.SMTP_TEST_SENT,
entityType: 'system_smtp_settings',
entityId: 'singleton',
afterData: { recipient: dto.email, messageId: sent.messageId },
metadata: { restrictedToRole: 'admin' },
});
return { ok: true, recipient: dto.email, messageId: sent.messageId };
}
@Get('outbox')
@RequirePermissions('document_delivery.read')
outbox() {
return this.delivery.list();
}
@Post('outbox/:id/retry')
@RequirePermissions('document_delivery.manage')
retry(
@Param('id', new ParseUUIDPipe({ version: '4' })) id: string,
@CurrentAuth() principal: AuthPrincipal,
@Req() request: RequestWithContext,
): Promise<DeliveryRow> {
return this.delivery.retry(id, principal, request);
}
@Post('outbox/retry-pending')
@RequirePermissions('document_delivery.manage')
retryPending(
@CurrentAuth() principal: AuthPrincipal,
@Req() request: RequestWithContext,
) {
return this.delivery.retryPending(principal, request);
}
private assertSystemAdmin(principal: AuthPrincipal): void {
if (principal.roles.includes('admin')) return;
throw new ForbiddenException({
code: 'SMTP_SUPERADMIN_REQUIRED',
message: 'La configuración SMTP está reservada al Superadmin del sistema',
});
}
}
@@ -0,0 +1,23 @@
import { Transform } from 'class-transformer';
import { IsEnum, IsISO8601, IsOptional, IsString, MaxLength } from 'class-validator';
import { InspectionReportFollowUpType } from '../../database/entities';
export class CreateInspectionReportFollowUpDto {
@IsEnum(InspectionReportFollowUpType)
type!: InspectionReportFollowUpType;
@IsISO8601({ strict: true })
occurredAt!: string;
@IsOptional()
@Transform(({ value }) => typeof value === 'string' ? value.trim() || null : value)
@IsString()
@MaxLength(255)
externalReference?: string | null;
@IsOptional()
@Transform(({ value }) => typeof value === 'string' ? value.trim() || null : value)
@IsString()
@MaxLength(20000)
description?: string | null;
}
@@ -0,0 +1,13 @@
import { Transform } from 'class-transformer';
import { IsISO8601, IsString, MaxLength, MinLength } from 'class-validator';
export class OfficializeInspectionReportDto {
@Transform(({ value }) => typeof value === 'string' ? value.trim() : value)
@IsString()
@MinLength(2)
@MaxLength(255)
gedoIfIdentifier!: string;
@IsISO8601({ strict: true })
gedoOfficializedAt!: string;
}
@@ -0,0 +1,48 @@
import { Transform, Type } from 'class-transformer';
import {
IsBoolean,
IsDateString,
IsEnum,
IsInt,
IsString,
Max,
MaxLength,
Min,
MinLength,
} from 'class-validator';
import { InspectionDeadlineDayType } from '../../database/entities';
export class UpdateDeadlinePolicyDto {
@Type(() => Number)
@IsInt()
@Min(1)
@Max(365)
urgentDays!: number;
@IsEnum(InspectionDeadlineDayType)
urgentDayType!: InspectionDeadlineDayType;
@Type(() => Number)
@IsInt()
@Min(1)
@Max(365)
nonUrgentDays!: number;
@IsEnum(InspectionDeadlineDayType)
nonUrgentDayType!: InspectionDeadlineDayType;
}
export class UpsertBusinessCalendarDayDto {
@IsDateString()
date!: string;
@Transform(({ value }) => value === true || value === 'true')
@IsBoolean()
isBusinessDay!: boolean;
@Transform(({ value }) => typeof value === 'string' ? value.trim() : value)
@IsString()
@MinLength(1)
@MaxLength(200)
label!: string;
}
@@ -1,2 +1,12 @@
import { Transform } from 'class-transformer'; import { IsEmail,IsOptional,MaxLength } from 'class-validator';
export class UpdateDocumentDeliverySettingsDto { @IsOptional() @Transform(({value})=>typeof value==='string'&&value.trim()?value.trim().toLowerCase():null) @IsEmail() @MaxLength(320) officeEmail?:string|null; @IsOptional() @Transform(({value})=>typeof value==='string'&&value.trim()?value.trim().toLowerCase():null) @IsEmail() @MaxLength(320) directorEmail?:string|null; }
import { Transform } from 'class-transformer';
import { IsEmail, IsOptional, MaxLength } from 'class-validator';
export class UpdateDocumentDeliverySettingsDto {
@IsOptional()
@Transform(({ value }) =>
typeof value === 'string' && value.trim() ? value.trim().toLowerCase() : null,
)
@IsEmail()
@MaxLength(320)
officeEmail?: string | null;
}
@@ -0,0 +1,16 @@
import { Transform } from 'class-transformer';
import { IsOptional, IsString, MaxLength } from 'class-validator';
export class UpdateInspectionReportDto {
@IsOptional()
@Transform(({ value }) => typeof value === 'string' ? value.trim() || null : value)
@IsString()
@MaxLength(20000)
executiveSummary?: string | null;
@IsOptional()
@Transform(({ value }) => typeof value === 'string' ? value.trim() || null : value)
@IsString()
@MaxLength(40000)
description?: string | null;
}
@@ -0,0 +1,71 @@
import { Transform, Type } from 'class-transformer';
import {
IsBoolean,
IsEmail,
IsEnum,
IsInt,
IsOptional,
IsString,
Max,
MaxLength,
Min,
MinLength,
} from 'class-validator';
import { SmtpSecurityMode } from '../../database/entities';
export class UpdateSmtpSettingsDto {
@Transform(({ value }) => typeof value === 'string' ? value.trim() : value)
@IsString()
@MinLength(1)
@MaxLength(255)
host!: string;
@Type(() => Number)
@IsInt()
@Min(1)
@Max(65535)
port!: number;
@IsEnum(SmtpSecurityMode)
securityMode!: SmtpSecurityMode;
@IsOptional()
@Transform(({ value }) => typeof value === 'string' ? value.trim() || null : value)
@IsString()
@MaxLength(255)
username?: string | null;
/** Omitir para conservar la clave actual; enviar null/cadena vacía para quitarla. */
@IsOptional()
@IsString()
@MaxLength(1000)
password?: string | null;
@Transform(({ value }) => typeof value === 'string' ? value.trim() : value)
@IsString()
@MinLength(1)
@MaxLength(200)
fromName!: string;
@Transform(({ value }) => typeof value === 'string' ? value.trim().toLowerCase() : value)
@IsEmail()
@MaxLength(320)
fromEmail!: string;
@IsOptional()
@Transform(({ value }) => typeof value === 'string' && value.trim() ? value.trim().toLowerCase() : null)
@IsEmail()
@MaxLength(320)
replyTo?: string | null;
@Transform(({ value }) => value === true || value === 'true')
@IsBoolean()
enabled!: boolean;
}
export class TestSmtpSettingsDto {
@Transform(({ value }) => typeof value === 'string' ? value.trim().toLowerCase() : value)
@IsEmail()
@MaxLength(320)
email!: string;
}
@@ -1,88 +1,212 @@
import { createHash } from 'node:crypto';
function asRecord(value: unknown): Record<string, unknown> { return value && typeof value === 'object' && !Array.isArray(value) ? value as Record<string, unknown> : {}; }
function asArray(value: unknown): Array<Record<string, unknown>> { return Array.isArray(value) ? value.map(asRecord) : []; }
function text(value: unknown, fallback='-'): string { const out=String(value ?? '').trim(); return out || fallback; }
function date(value: unknown): string { const d=new Date(String(value ?? '')); return Number.isFinite(d.getTime()) ? d.toLocaleDateString('es-AR') : '-'; }
function clean(value: string): string { return value.normalize('NFKD').replace(/[\u0300-\u036f]/g,'').replace(/[–—]/g,'-').replace(/[“”]/g,'"').replace(/[‘’]/g,"'").replace(/[^\x20-\xFF]/g,'?'); }
function escapePdf(value: string): string { return clean(value).replaceAll('\\','\\\\').replaceAll('(','\\(').replaceAll(')','\\)'); }
function wrap(value: string, max=92): string[] { const words=clean(value).split(/\s+/).filter(Boolean); const out:string[]=[]; let line=''; for(const word of words){ const next=line?`${line} ${word}`:word; if(next.length>max&&line){out.push(line);line=word;}else line=next;} if(line)out.push(line); return out.length?out:['-']; }
function asRecord(value: unknown): Record<string, unknown> {
return value && typeof value === 'object' && !Array.isArray(value)
? value as Record<string, unknown>
: {};
}
function asArray(value: unknown): Array<Record<string, unknown>> {
return Array.isArray(value) ? value.map(asRecord) : [];
}
function text(value: unknown, fallback = '-'): string {
const out = String(value ?? '').trim();
return out || fallback;
}
function date(value: unknown): string {
const parsed = new Date(String(value ?? ''));
return Number.isFinite(parsed.getTime()) ? parsed.toLocaleDateString('es-AR') : '-';
}
function clean(value: string): string {
return value
.normalize('NFKD')
.replace(/[\u0300-\u036f]/g, '')
.replace(/[–—]/g, '-')
.replace(/[“”]/g, '"')
.replace(/[‘’]/g, "'")
.replace(/[^\x20-\xFF]/g, '?');
}
function escapePdf(value: string): string {
return clean(value).replaceAll('\\', '\\\\').replaceAll('(', '\\(').replaceAll(')', '\\)');
}
function wrap(value: string, max = 92): string[] {
const words = clean(value).split(/\s+/).filter(Boolean);
const out: string[] = [];
let line = '';
for (const word of words) {
const next = line ? `${line} ${word}` : word;
if (next.length > max && line) {
out.push(line);
line = word;
} else {
line = next;
}
}
if (line) out.push(line);
return out.length ? out : ['-'];
}
function lockedSnapshot(snapshot: Record<string, unknown>): {
locked: Record<string, unknown>;
signatures: Array<Record<string, unknown>>;
finalSha256: unknown;
} {
const sealed = asRecord(snapshot);
const locked = asRecord(sealed.lockedSnapshot ?? sealed.preparedSnapshot);
return {
locked,
signatures: asArray(sealed.signatures),
finalSha256: sealed.finalSha256 ?? sealed.lockedSha256 ?? sealed.preparedSha256,
};
}
function urgencyLabel(value: unknown): string {
return text(value, '') === 'URGENT' ? 'Urgente' : 'No urgente';
}
function dayTypeLabel(value: unknown): string {
return text(value, '') === 'CALENDAR' ? 'dias corridos' : 'dias habiles';
}
function lines(snapshot: Record<string, unknown>): string[] {
const prepared=asRecord(asRecord(snapshot).preparedSnapshot);
const act=asRecord(prepared.act);
const visit=asRecord(act.visit);
const responsible=asRecord(prepared.responsible);
const team=asArray(prepared.team);
const assets=asArray(prepared.assets);
const findings=asArray(prepared.findings);
const signatures=asArray(snapshot.signatures);
const companySignature=signatures.find(item=>text(item.signerType,'')==='COMPANY_RESPONSIBLE');
const companies=[...new Set(assets.map(x=>text(asRecord(x.operatorCompany).name,'')).filter(Boolean))];
const areas=[...new Set(assets.map(x=>text(asRecord(x.operationalArea).name,'')).filter(Boolean))];
const inspectors=team.map(x=>`${text(x.firstName,'')} ${text(x.lastName,'')}`.trim()).filter(Boolean);
const out:string[]=[
const source = lockedSnapshot(snapshot);
const locked = source.locked;
const act = asRecord(locked.act);
const inspection = asRecord(act.inspection ?? asRecord(act).visit);
const responsible = asRecord(locked.responsible);
const inventories = asArray(locked.inventories ?? locked.assets);
const findings = asArray(locked.findings);
const signatures = source.signatures;
const companySignature = signatures.find((item) => text(item.signerType, '') === 'COMPANY_RESPONSIBLE');
const inspectorSignatures = signatures.filter((item) => text(item.signerType, '') === 'INSPECTOR');
const deadlineText = act.deadlineAt
? `${date(act.deadlineAt)} (${text(act.deadlineDays)} ${dayTypeLabel(act.deadlineDayType)})`
: act.deadlineBasis === 'GEDO_DATE'
? `${text(act.deadlineDays)} ${dayTypeLabel(act.deadlineDayType)} desde fecha GEDO`
: '-';
const out: string[] = [
'ACTA DE INSPECCION',
'Documento automatico - diseno institucional pendiente de definicion',
'',
`Acta: ${text(act.code)}`,
`Inspeccion: ${text(visit.code)}`,
`Inspeccion: ${text(inspection.code)}`,
`Fecha: ${date(act.occurredAt)}`,
`Empresa: ${companies.join(' / ') || 'Segun alcance del acta'}`,
`Area: ${areas.join(' / ') || 'Segun alcance del acta'}`,
`Inspector/es: ${inspectors.join(' / ') || '-'}`,
`Urgencia: ${urgencyLabel(act.urgency)}`,
`Plazo: ${deadlineText}`,
`Responsable empresa: ${text(responsible.fullName)}`,
`Cargo: ${text(responsible.position)}`,
'',
'OBJETIVO', ...wrap(text(visit.objective)), '',
'RESUMEN', ...wrap(text(act.summary)), '',
'OBSERVACIONES', ...wrap(text(act.observations)), '',
'ELEMENTOS INSPECCIONADOS',
'RESUMEN',
...wrap(text(act.summary)),
'',
'OBSERVACIONES',
...wrap(text(act.observations)),
'',
'INVENTARIO INSPECCIONADO',
];
if(!assets.length) out.push('-');
for(const item of assets) out.push(...wrap(`${text(item.code)} | ${text(item.name)}${text(item.commonName,'') ? ` | ${text(item.commonName,'')}` : ''} | ${text(item.typeName)}`));
out.push('', 'HALLAZGOS');
if(!findings.length) out.push('Sin hallazgos registrados.');
for(const item of findings){ out.push(...wrap(`${text(item.code)} | ${text(item.title)} | Vencimiento: ${date(item.correctionDueOn)}`)); out.push(...wrap(text(item.description))); }
out.push('', 'CONSTANCIA DE LA EMPRESA');
if(!companySignature) out.push('Firma o constancia pendiente.');
else if(text(companySignature.status,'')==='SIGNED'){
const manifestation=text(companySignature.companyManifestation,'CONFORMITY');
out.push(manifestation==='DISSENT'?'Firma en disidencia':'Firma en conformidad');
if(manifestation==='DISSENT') out.push(...wrap(text(companySignature.companyStatement)));
} else {
out.push(...wrap(`${text(companySignature.status)}: ${text(companySignature.reason)}`));
if (!inventories.length) out.push('-');
for (const item of inventories) {
out.push(...wrap(`${text(item.code)} | ${text(item.name)} | ${text(item.typeName ?? item.typeCode)}`));
}
out.push('', 'INTEGRIDAD', `Hash de cierre: ${text(asRecord(snapshot).finalSha256 ?? asRecord(snapshot).preparedSha256)}`);
out.push('', 'HALLAZGOS');
if (!findings.length) out.push('Sin hallazgos registrados.');
for (const item of findings) {
const recurrence = item.isRecurrence
? ` | REINCIDENCIA${item.recurrenceOfFindingId ? ` de ${text(item.recurrenceOfFindingCode ?? item.recurrenceOfFindingId)}` : ''}`
: '';
out.push(...wrap(`${text(item.code)} | ${text(item.title)}${recurrence}`));
out.push(...wrap(`Descripcion: ${text(item.description)}`));
if (item.legalBasis) out.push(...wrap(`Base legal: ${text(item.legalBasis)}`));
}
out.push('', 'FIRMAS Y CONSTANCIAS');
if (!inspectorSignatures.length) out.push('Firma de inspector: pendiente.');
for (const signature of inspectorSignatures) {
out.push(...wrap(`Inspector: ${text(signature.signerName)} | ${text(signature.status)} | ${date(signature.signedAt ?? signature.createdAt)}`));
}
if (!companySignature) {
out.push('Manifestacion de empresa: pendiente.');
} else if (text(companySignature.status, '') === 'SIGNED') {
const manifestation = text(companySignature.companyManifestation, 'CONFORMITY');
out.push(manifestation === 'DISSENT' ? 'Empresa: firma en disidencia' : 'Empresa: firma en conformidad');
if (manifestation === 'DISSENT') out.push(...wrap(text(companySignature.companyStatement)));
} else {
out.push(...wrap(`Empresa: ${text(companySignature.status)} - ${text(companySignature.reason)}`));
}
out.push(
'',
'INTEGRIDAD',
`Hash del Acta sellada: ${text(source.finalSha256)}`,
`Hash del contenido bloqueado: ${text(snapshot.lockedSha256 ?? snapshot.preparedSha256)}`,
);
return out;
}
function objectBuffer(id: number, body: Buffer|string): Buffer { const data=Buffer.isBuffer(body)?body:Buffer.from(body,'latin1'); return Buffer.concat([Buffer.from(`${id} 0 obj\n`,'ascii'),data,Buffer.from('\nendobj\n','ascii')]); }
function objectBuffer(id: number, body: Buffer | string): Buffer {
const data = Buffer.isBuffer(body) ? body : Buffer.from(body, 'latin1');
return Buffer.concat([
Buffer.from(`${id} 0 obj\n`, 'ascii'),
data,
Buffer.from('\nendobj\n', 'ascii'),
]);
}
export function buildInspectionActPdf(snapshot: Record<string, unknown>): { buffer: Buffer; sha256: string } {
const all=lines(snapshot);
const chunks:Array<string[]>=[];
for(let i=0;i<all.length;i+=56) chunks.push(all.slice(i,i+56));
if(!chunks.length) chunks.push(['ACTA DE INSPECCION']);
const pageCount=chunks.length;
const pageIds=Array.from({length:pageCount},(_,i)=>4+i*2);
const contentIds=Array.from({length:pageCount},(_,i)=>5+i*2);
const objects:Buffer[]=[];
objects.push(objectBuffer(1,'<< /Type /Catalog /Pages 2 0 R >>'));
objects.push(objectBuffer(2,`<< /Type /Pages /Count ${pageCount} /Kids [${pageIds.map(id=>`${id} 0 R`).join(' ')}] >>`));
objects.push(objectBuffer(3,'<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >>'));
chunks.forEach((chunk,index)=>{
const content=chunk.map((line,i)=>`${i===0?'':'T* '}(${escapePdf(line)}) Tj`).join('\n');
const stream=Buffer.from(`BT\n/F1 9 Tf\n40 800 Td\n12 TL\n${content}\nET`,'latin1');
objects.push(objectBuffer(pageIds[index]!,`<< /Type /Page /Parent 2 0 R /MediaBox [0 0 595 842] /Resources << /Font << /F1 3 0 R >> >> /Contents ${contentIds[index]} 0 R >>`));
objects.push(objectBuffer(contentIds[index]!,Buffer.concat([Buffer.from(`<< /Length ${stream.length} >>\nstream\n`,'ascii'),stream,Buffer.from('\nendstream','ascii')])));
const all = lines(snapshot);
const chunks: Array<string[]> = [];
for (let index = 0; index < all.length; index += 56) chunks.push(all.slice(index, index + 56));
if (!chunks.length) chunks.push(['ACTA DE INSPECCION']);
const pageCount = chunks.length;
const pageIds = Array.from({ length: pageCount }, (_, index) => 4 + index * 2);
const contentIds = Array.from({ length: pageCount }, (_, index) => 5 + index * 2);
const objects: Buffer[] = [];
objects.push(objectBuffer(1, '<< /Type /Catalog /Pages 2 0 R >>'));
objects.push(objectBuffer(2, `<< /Type /Pages /Count ${pageCount} /Kids [${pageIds.map((id) => `${id} 0 R`).join(' ')}] >>`));
objects.push(objectBuffer(3, '<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >>'));
chunks.forEach((chunk, index) => {
const content = chunk
.map((line, lineIndex) => `${lineIndex === 0 ? '' : 'T* '}(${escapePdf(line)}) Tj`)
.join('\n');
const stream = Buffer.from(`BT\n/F1 9 Tf\n40 800 Td\n12 TL\n${content}\nET`, 'latin1');
objects.push(objectBuffer(
pageIds[index]!,
`<< /Type /Page /Parent 2 0 R /MediaBox [0 0 595 842] /Resources << /Font << /F1 3 0 R >> >> /Contents ${contentIds[index]} 0 R >>`,
));
objects.push(objectBuffer(
contentIds[index]!,
Buffer.concat([
Buffer.from(`<< /Length ${stream.length} >>\nstream\n`, 'ascii'),
stream,
Buffer.from('\nendstream', 'ascii'),
]),
));
});
const header=Buffer.from('%PDF-1.4\n%\xE2\xE3\xCF\xD3\n','binary');
const offsets:number[]=[0]; let pos=header.length;
for(const obj of objects){ offsets.push(pos); pos+=obj.length; }
const xrefOffset=pos;
const xref=[`xref\n0 ${objects.length+1}\n`,`0000000000 65535 f \n`,...objects.map((_,i)=>`${String(offsets[i+1]).padStart(10,'0')} 00000 n \n`)].join('');
const trailer=`trailer\n<< /Size ${objects.length+1} /Root 1 0 R >>\nstartxref\n${xrefOffset}\n%%EOF\n`;
const buffer=Buffer.concat([header,...objects,Buffer.from(xref+trailer,'ascii')]);
return { buffer, sha256:createHash('sha256').update(buffer).digest('hex') };
const header = Buffer.from('%PDF-1.4\n%\xE2\xE3\xCF\xD3\n', 'binary');
const offsets: number[] = [0];
let position = header.length;
for (const object of objects) {
offsets.push(position);
position += object.length;
}
const xrefOffset = position;
const xref = [
`xref\n0 ${objects.length + 1}\n`,
'0000000000 65535 f \n',
...objects.map((_, index) => `${String(offsets[index + 1]).padStart(10, '0')} 00000 n \n`),
].join('');
const trailer = `trailer\n<< /Size ${objects.length + 1} /Root 1 0 R >>\nstartxref\n${xrefOffset}\n%%EOF\n`;
const buffer = Buffer.concat([header, ...objects, Buffer.from(xref + trailer, 'ascii')]);
return { buffer, sha256: createHash('sha256').update(buffer).digest('hex') };
}
@@ -8,20 +8,112 @@ import { buildInspectionActPdf } from './inspection-act-pdf-builder';
@Injectable()
export class InspectionActPdfService {
private readonly root:string;
constructor(private readonly dataSource:DataSource, config:ConfigService){ const configured=config.get<string>('INSPECTION_ACT_PDF_ROOT')??'/app/storage/asset-media/inspection-acts-pdf'; if(!isAbsolute(configured))throw new Error('INSPECTION_ACT_PDF_ROOT must be an absolute path'); this.root=resolve(configured); if(this.root===parse(this.root).root)throw new Error('INSPECTION_ACT_PDF_ROOT cannot be the filesystem root'); }
private readonly root: string;
async ensure(actId:string):Promise<void>{
const [row]=await this.dataSource.query(`SELECT a.id,a.code,a.closure_sha256 AS "closureSha256",c.final_snapshot AS "finalSnapshot",p.status,p.stored_name AS "storedName" FROM inspection_acts a JOIN inspection_act_closures c ON c.act_id=a.id LEFT JOIN inspection_act_pdf_artifacts p ON p.act_id=a.id WHERE a.id=$1 AND a.status='CLOSED'`,[actId]) as Array<{id:string;code:string;closureSha256:string;finalSnapshot:Record<string,unknown>;status:string|null;storedName:string|null}>;
if(!row)throw new NotFoundException({code:'INSPECTION_ACT_NOT_CLOSED',message:'El acta cerrada no está disponible'});
if(row.status==='READY'&&row.storedName){ try{ await this.content(actId); return; }catch{} }
await this.dataSource.query(`INSERT INTO inspection_act_pdf_artifacts (act_id,status) VALUES ($1,'PENDING') ON CONFLICT (act_id) DO UPDATE SET status='PENDING',error=NULL,updated_at=CURRENT_TIMESTAMP`,[actId]);
try{
const snapshot={...row.finalSnapshot,finalSha256:row.closureSha256}; const built=buildInspectionActPdf(snapshot); await mkdir(this.root,{recursive:true,mode:0o700}); const storedName=`${actId}.pdf`; const originalName=`${row.code}.pdf`; const filePath=resolve(this.root,storedName); await writeFile(filePath,built.buffer,{mode:0o600});
await this.dataSource.query(`UPDATE inspection_act_pdf_artifacts SET status='READY',original_name=$2,stored_name=$3,mime_type='application/pdf',size_bytes=$4,sha256=$5,generated_at=CURRENT_TIMESTAMP,error=NULL,updated_at=CURRENT_TIMESTAMP WHERE act_id=$1`,[actId,originalName,storedName,built.buffer.length,built.sha256]);
}catch(error){ const message=error instanceof Error?error.message.slice(0,500):'Error desconocido'; await this.dataSource.query(`UPDATE inspection_act_pdf_artifacts SET status='FAILED',error=$2,updated_at=CURRENT_TIMESTAMP WHERE act_id=$1`,[actId,message]).catch(()=>undefined); }
constructor(private readonly dataSource: DataSource, config: ConfigService) {
const configured = config.get<string>('INSPECTION_ACT_PDF_ROOT')
?? '/app/storage/asset-media/inspection-acts-pdf';
if (!isAbsolute(configured)) throw new Error('INSPECTION_ACT_PDF_ROOT must be an absolute path');
this.root = resolve(configured);
if (this.root === parse(this.root).root) throw new Error('INSPECTION_ACT_PDF_ROOT cannot be the filesystem root');
}
async content(actId:string):Promise<{buffer:Buffer;originalName:string;mimeType:string}>{ const [row]=await this.dataSource.query(`SELECT original_name AS "originalName",stored_name AS "storedName",mime_type AS "mimeType",size_bytes::integer AS "sizeBytes",sha256 FROM inspection_act_pdf_artifacts WHERE act_id=$1 AND status='READY'`,[actId]) as Array<{originalName:string;storedName:string;mimeType:string;sizeBytes:number;sha256:string}>; if(!row)throw new NotFoundException({code:'INSPECTION_ACT_PDF_NOT_READY',message:'El PDF del acta todavía no está disponible'}); const filePath=resolve(this.root,row.storedName); if(!filePath.startsWith(`${this.root}/`))throw this.storageError(); const st=await stat(filePath).catch(()=>null); if(!st?.isFile()||st.size!==row.sizeBytes)throw this.storageError(); const buffer=await readFile(filePath); if(createHash('sha256').update(buffer).digest('hex')!==row.sha256)throw this.storageError(); return {buffer,originalName:row.originalName,mimeType:row.mimeType}; }
private storageError(){return new InternalServerErrorException({code:'INSPECTION_ACT_PDF_STORAGE_ERROR',message:'El PDF del acta no está disponible o no supera la validación de integridad'});}
async ensure(actId: string): Promise<void> {
const [row] = await this.dataSource.query(`
SELECT
act.id,
act.code,
act.closure_sha256 AS "closureSha256",
closure.final_snapshot AS "finalSnapshot",
artifact.status,
artifact.stored_name AS "storedName"
FROM inspection_acts act
JOIN inspection_act_closures closure ON closure.act_id=act.id
LEFT JOIN inspection_act_pdf_artifacts artifact ON artifact.act_id=act.id
WHERE act.id=$1 AND act.status='SEALED'
`, [actId]) as Array<{
id: string;
code: string;
closureSha256: string;
finalSnapshot: Record<string, unknown>;
status: string | null;
storedName: string | null;
}>;
if (!row) {
throw new NotFoundException({
code: 'INSPECTION_ACT_NOT_SEALED',
message: 'El PDF sólo puede generarse desde un Acta SELLADA',
});
}
if (row.status === 'READY' && row.storedName) {
try {
await this.content(actId);
return;
} catch {}
}
await this.dataSource.query(`
INSERT INTO inspection_act_pdf_artifacts(act_id,status)
VALUES($1,'PENDING')
ON CONFLICT (act_id) DO UPDATE SET
status='PENDING',error=NULL,updated_at=CURRENT_TIMESTAMP
`, [actId]);
try {
const snapshot = { ...row.finalSnapshot, finalSha256: row.closureSha256 };
const built = buildInspectionActPdf(snapshot);
await mkdir(this.root, { recursive: true, mode: 0o700 });
const storedName = `${actId}.pdf`;
const originalName = `${row.code}.pdf`;
const filePath = resolve(this.root, storedName);
await writeFile(filePath, built.buffer, { mode: 0o600 });
await this.dataSource.query(`
UPDATE inspection_act_pdf_artifacts
SET status='READY',original_name=$2,stored_name=$3,mime_type='application/pdf',
size_bytes=$4,sha256=$5,generated_at=CURRENT_TIMESTAMP,error=NULL,
updated_at=CURRENT_TIMESTAMP
WHERE act_id=$1
`, [actId, originalName, storedName, built.buffer.length, built.sha256]);
} catch (error) {
const message = error instanceof Error ? error.message.slice(0, 500) : 'Error desconocido';
await this.dataSource.query(`
UPDATE inspection_act_pdf_artifacts
SET status='FAILED',error=$2,updated_at=CURRENT_TIMESTAMP
WHERE act_id=$1
`, [actId, message]).catch(() => undefined);
}
}
async content(actId: string): Promise<{ buffer: Buffer; originalName: string; mimeType: string }> {
const [row] = await this.dataSource.query(`
SELECT original_name AS "originalName",stored_name AS "storedName",
mime_type AS "mimeType",size_bytes::integer AS "sizeBytes",sha256
FROM inspection_act_pdf_artifacts
WHERE act_id=$1 AND status='READY'
`, [actId]) as Array<{
originalName: string;
storedName: string;
mimeType: string;
sizeBytes: number;
sha256: string;
}>;
if (!row) {
throw new NotFoundException({
code: 'INSPECTION_ACT_PDF_NOT_READY',
message: 'El PDF del Acta todavía no está disponible',
});
}
const filePath = resolve(this.root, row.storedName);
if (!filePath.startsWith(`${this.root}/`)) throw this.storageError();
const fileStat = await stat(filePath).catch(() => null);
if (!fileStat?.isFile() || fileStat.size !== row.sizeBytes) throw this.storageError();
const buffer = await readFile(filePath);
if (createHash('sha256').update(buffer).digest('hex') !== row.sha256) throw this.storageError();
return { buffer, originalName: row.originalName, mimeType: row.mimeType };
}
private storageError(): InternalServerErrorException {
return new InternalServerErrorException({
code: 'INSPECTION_ACT_PDF_STORAGE_ERROR',
message: 'El PDF del Acta no está disponible o no supera la validación de integridad',
});
}
}
@@ -0,0 +1,53 @@
import { Body, Controller, Delete, Get, Param, Put, Req } from '@nestjs/common';
import { CurrentAuth } from '../auth/decorators/current-auth.decorator';
import { RequirePermissions } from '../authorization/decorators/require-permissions.decorator';
import type { AuthPrincipal, RequestWithContext } from '../common/http/request-context';
import { UpdateDeadlinePolicyDto, UpsertBusinessCalendarDayDto } from './dto/update-deadline-policy.dto';
import { InspectionDeadlineAdminService } from './inspection-deadline-admin.service';
@Controller('inspection-deadlines')
export class InspectionDeadlineAdminController {
constructor(private readonly deadlines: InspectionDeadlineAdminService) {}
@Get('policy')
@RequirePermissions('inspections.read')
policy() {
return this.deadlines.policy();
}
@Put('policy')
@RequirePermissions('inspections.manage')
updatePolicy(
@Body() dto: UpdateDeadlinePolicyDto,
@CurrentAuth() principal: AuthPrincipal,
@Req() request: RequestWithContext,
) {
return this.deadlines.updatePolicy(dto, principal, request);
}
@Get('calendar')
@RequirePermissions('inspections.read')
calendar() {
return this.deadlines.calendar();
}
@Put('calendar')
@RequirePermissions('inspections.manage')
upsertDay(
@Body() dto: UpsertBusinessCalendarDayDto,
@CurrentAuth() principal: AuthPrincipal,
@Req() request: RequestWithContext,
) {
return this.deadlines.upsertCalendarDay(dto, principal, request);
}
@Delete('calendar/:date')
@RequirePermissions('inspections.manage')
removeDay(
@Param('date') date: string,
@CurrentAuth() principal: AuthPrincipal,
@Req() request: RequestWithContext,
) {
return this.deadlines.removeCalendarDay(date, principal, request);
}
}
@@ -0,0 +1,160 @@
import { Injectable } from '@nestjs/common';
import { DataSource } from 'typeorm';
import { administrationAuditContext } from '../administration/common/administration-audit';
import { AuditService } from '../audit/audit.service';
import type { AuthPrincipal, RequestWithContext } from '../common/http/request-context';
import { AuditAction } from '../database/entities';
import type { UpdateDeadlinePolicyDto, UpsertBusinessCalendarDayDto } from './dto/update-deadline-policy.dto';
@Injectable()
export class InspectionDeadlineAdminService {
constructor(
private readonly dataSource: DataSource,
private readonly audit: AuditService,
) {}
async policy() {
const [row] = await this.dataSource.query(`
SELECT id,
urgent_days AS "urgentDays",
urgent_day_type AS "urgentDayType",
non_urgent_days AS "nonUrgentDays",
non_urgent_day_type AS "nonUrgentDayType",
updated_by AS "updatedBy",
updated_at AS "updatedAt"
FROM inspection_deadline_policies
ORDER BY created_at
LIMIT 1
`);
return row ?? {
urgentDays: 5,
urgentDayType: 'BUSINESS',
nonUrgentDays: 10,
nonUrgentDayType: 'BUSINESS',
};
}
async updatePolicy(
dto: UpdateDeadlinePolicyDto,
principal: AuthPrincipal,
request: RequestWithContext,
) {
const before = await this.policy();
const [existing] = await this.dataSource.query(`
SELECT id FROM inspection_deadline_policies ORDER BY created_at LIMIT 1
`) as Array<{ id: string }>;
if (existing) {
await this.dataSource.query(`
UPDATE inspection_deadline_policies
SET urgent_days=$2,
urgent_day_type=$3,
non_urgent_days=$4,
non_urgent_day_type=$5,
updated_by=$6,
updated_at=CURRENT_TIMESTAMP
WHERE id=$1
`, [
existing.id,
dto.urgentDays,
dto.urgentDayType,
dto.nonUrgentDays,
dto.nonUrgentDayType,
principal.userId,
]);
} else {
await this.dataSource.query(`
INSERT INTO inspection_deadline_policies(
urgent_days,urgent_day_type,non_urgent_days,non_urgent_day_type,updated_by
) VALUES($1,$2,$3,$4,$5)
`, [
dto.urgentDays,
dto.urgentDayType,
dto.nonUrgentDays,
dto.nonUrgentDayType,
principal.userId,
]);
}
const after = await this.policy();
await this.audit.record({
...administrationAuditContext(principal, request),
action: AuditAction.INSPECTION_DEADLINE_POLICY_UPDATED,
entityType: 'inspection_deadline_policy',
entityId: String((after as { id?: string }).id ?? 'singleton'),
beforeData: before as Record<string, unknown>,
afterData: after as Record<string, unknown>,
metadata: {
appliesOnlyToFutureLockedActs: true,
historicalActsKeepSnapshot: true,
},
});
return after;
}
async calendar() {
const data = await this.dataSource.query(`
SELECT id,date,is_business_day AS "isBusinessDay",label,
updated_by AS "updatedBy",created_at AS "createdAt",updated_at AS "updatedAt"
FROM inspection_business_calendar_days
ORDER BY date
`);
return { data };
}
async upsertCalendarDay(
dto: UpsertBusinessCalendarDayDto,
principal: AuthPrincipal,
request: RequestWithContext,
) {
const [before] = await this.dataSource.query(`
SELECT id,date,is_business_day AS "isBusinessDay",label
FROM inspection_business_calendar_days WHERE date=$1::date
`, [dto.date]);
await this.dataSource.query(`
INSERT INTO inspection_business_calendar_days(date,is_business_day,label,updated_by)
VALUES($1::date,$2,$3,$4)
ON CONFLICT(date) DO UPDATE SET
is_business_day=EXCLUDED.is_business_day,
label=EXCLUDED.label,
updated_by=EXCLUDED.updated_by,
updated_at=CURRENT_TIMESTAMP
`, [dto.date, dto.isBusinessDay, dto.label, principal.userId]);
const [after] = await this.dataSource.query(`
SELECT id,date,is_business_day AS "isBusinessDay",label,
updated_by AS "updatedBy",updated_at AS "updatedAt"
FROM inspection_business_calendar_days WHERE date=$1::date
`, [dto.date]);
await this.audit.record({
...administrationAuditContext(principal, request),
action: AuditAction.INSPECTION_BUSINESS_CALENDAR_UPDATED,
entityType: 'inspection_business_calendar_day',
entityId: String(after.id),
beforeData: before ?? null,
afterData: after,
});
return after;
}
async removeCalendarDay(
date: string,
principal: AuthPrincipal,
request: RequestWithContext,
) {
const [before] = await this.dataSource.query(`
SELECT id,date,is_business_day AS "isBusinessDay",label
FROM inspection_business_calendar_days WHERE date=$1::date
`, [date]);
if (before) {
await this.dataSource.query(`DELETE FROM inspection_business_calendar_days WHERE id=$1`, [before.id]);
await this.audit.record({
...administrationAuditContext(principal, request),
action: AuditAction.INSPECTION_BUSINESS_CALENDAR_UPDATED,
entityType: 'inspection_business_calendar_day',
entityId: String(before.id),
beforeData: before,
afterData: null,
metadata: { removed: true },
});
}
return { removed: Boolean(before), date };
}
}
@@ -10,7 +10,7 @@ import { InspectionActPdfService } from './inspection-act-pdf.service';
import { InspectionReportWordService } from './inspection-report-word.service';
import { SmtpDeliveryService } from './smtp-delivery.service';
export type DeliveryRecipientKind = 'COMPANY' | 'OFFICE' | 'DIRECTOR' | 'INSPECTOR';
export type DeliveryRecipientKind = 'COMPANY' | 'OFFICE' | 'INSPECTOR';
export interface DeliveryRow {
id: string;
@@ -40,21 +40,14 @@ export class InspectionDocumentDeliveryService {
async settings() {
const [row] = await this.dataSource.query(`
SELECT
office_email AS "officeEmail",
director_email AS "directorEmail",
updated_at AS "updatedAt"
SELECT office_email AS "officeEmail",updated_at AS "updatedAt"
FROM institutional_delivery_settings
WHERE id=1
`) as Array<{
officeEmail: string | null;
directorEmail: string | null;
updatedAt: Date;
}>;
`) as Array<{ officeEmail: string | null; updatedAt: Date }>;
return {
...row,
smtpConfigured: this.smtp.configured(),
mailFrom: this.config.get<string>('MAIL_FROM') ?? null,
smtpConfigured: await this.smtp.configured(),
mailFrom: await this.smtp.fromAddress() ?? this.config.get<string>('MAIL_FROM') ?? null,
};
}
@@ -66,9 +59,9 @@ export class InspectionDocumentDeliveryService {
const before = await this.settings();
await this.dataSource.query(`
UPDATE institutional_delivery_settings
SET office_email=$1,director_email=$2,updated_by=$3,updated_at=CURRENT_TIMESTAMP
SET office_email=$1,updated_by=$2,updated_at=CURRENT_TIMESTAMP
WHERE id=1
`, [dto.officeEmail ?? null, dto.directorEmail ?? null, principal.userId]);
`, [dto.officeEmail ?? null, principal.userId]);
const after = await this.settings();
await this.audit.record({
...administrationAuditContext(principal, request),
@@ -84,33 +77,23 @@ export class InspectionDocumentDeliveryService {
async list() {
const data = await this.dataSource.query(`
SELECT
d.id,
d.act_id AS "actId",
d.report_id AS "reportId",
d.document_kind AS "documentKind",
d.recipient_kind AS "recipientKind",
d.recipient_asset_id AS "recipientAssetId",
d.recipient_user_id AS "recipientUserId",
d.recipient_email AS "recipientEmail",
d.status,
d.attempts,
d.last_attempt_at AS "lastAttemptAt",
d.sent_at AS "sentAt",
d.provider_message_id AS "providerMessageId",
d.last_error AS "lastError",
d.created_at AS "createdAt",
a.code AS "actCode",
r.code AS "reportCode",
d.id,d.act_id AS "actId",d.report_id AS "reportId",
d.document_kind AS "documentKind",d.recipient_kind AS "recipientKind",
d.recipient_asset_id AS "recipientAssetId",d.recipient_user_id AS "recipientUserId",
d.recipient_email AS "recipientEmail",d.status,d.attempts,
d.last_attempt_at AS "lastAttemptAt",d.sent_at AS "sentAt",
d.provider_message_id AS "providerMessageId",d.last_error AS "lastError",
d.created_at AS "createdAt",a.code AS "actCode",r.code AS "reportCode",
recipient.name AS "recipientAssetName",
CASE
WHEN recipient_user.id IS NULL THEN NULL
ELSE btrim(concat_ws(' ', recipient_user.first_name, recipient_user.last_name))
CASE WHEN recipient_user.id IS NULL THEN NULL
ELSE btrim(concat_ws(' ',recipient_user.first_name,recipient_user.last_name))
END AS "recipientUserName"
FROM inspection_document_deliveries d
JOIN inspection_acts a ON a.id=d.act_id
LEFT JOIN inspection_reports r ON r.id=d.report_id
LEFT JOIN assets recipient ON recipient.id=d.recipient_asset_id
LEFT JOIN users recipient_user ON recipient_user.id=d.recipient_user_id
WHERE d.recipient_kind<>'DIRECTOR'
ORDER BY d.created_at DESC
LIMIT 200
`);
@@ -154,11 +137,9 @@ export class InspectionDocumentDeliveryService {
async retryPending(principal: AuthPrincipal, request: RequestWithContext) {
const rows = await this.dataSource.query(`
SELECT id
FROM inspection_document_deliveries
WHERE status<>'SENT'
ORDER BY created_at ASC
LIMIT 100
SELECT id FROM inspection_document_deliveries
WHERE status<>'SENT' AND recipient_kind<>'DIRECTOR'
ORDER BY created_at ASC LIMIT 100
`) as Array<{ id: string }>;
for (const item of rows) await this.retry(item.id, principal, request).catch(() => undefined);
return { processed: rows.length };
@@ -166,10 +147,9 @@ export class InspectionDocumentDeliveryService {
private async ensureRows(actId: string, reportId: string | null) {
const [settings] = await this.dataSource.query(`
SELECT office_email AS "officeEmail",director_email AS "directorEmail"
FROM institutional_delivery_settings
WHERE id=1
`) as Array<{ officeEmail: string | null; directorEmail: string | null }>;
SELECT office_email AS "officeEmail"
FROM institutional_delivery_settings WHERE id=1
`) as Array<{ officeEmail: string | null }>;
const companies = await this.dataSource.query(`
SELECT DISTINCT company.id,profile.notification_email AS email
@@ -185,9 +165,7 @@ export class InspectionDocumentDeliveryService {
`, [actId]) as Array<{ id: string; email: string | null }>;
const [inspector] = await this.dataSource.query(`
SELECT
inspector.id,
inspector.email
SELECT inspector.id,inspector.email
FROM inspection_acts act
JOIN inspection_visits visit ON visit.id=act.visit_id
JOIN users inspector ON inspector.id=visit.lead_inspector_user_id
@@ -196,52 +174,32 @@ export class InspectionDocumentDeliveryService {
for (const company of companies) {
await this.upsertRow({
actId,
reportId,
documentKind: 'ACT_PDF',
recipientKind: 'COMPANY',
recipientAssetId: company.id,
recipientUserId: null,
recipientKey: company.id,
recipientEmail: company.email,
actId,reportId,documentKind:'ACT_PDF',recipientKind:'COMPANY',
recipientAssetId:company.id,recipientUserId:null,recipientKey:company.id,
recipientEmail:company.email,
});
}
await this.upsertRow({
actId,
reportId,
documentKind: 'ACT_PDF',
recipientKind: 'OFFICE',
recipientAssetId: null,
recipientUserId: null,
recipientKey: '00000000-0000-0000-0000-000000000000',
recipientEmail: settings?.officeEmail ?? null,
actId,reportId,documentKind:'ACT_PDF',recipientKind:'OFFICE',
recipientAssetId:null,recipientUserId:null,
recipientKey:'00000000-0000-0000-0000-000000000000',
recipientEmail:settings?.officeEmail ?? null,
});
if (inspector) {
await this.upsertRow({
actId,
reportId,
documentKind: 'ACT_PDF',
recipientKind: 'INSPECTOR',
recipientAssetId: null,
recipientUserId: inspector.id,
recipientKey: inspector.id,
recipientEmail: inspector.email,
});
}
if (reportId) {
await this.upsertRow({
actId,
reportId,
documentKind: 'REPORT_WORD',
recipientKind: 'DIRECTOR',
recipientAssetId: null,
recipientUserId: null,
recipientKey: '00000000-0000-0000-0000-000000000000',
recipientEmail: settings?.directorEmail ?? null,
actId,reportId,documentKind:'ACT_PDF',recipientKind:'INSPECTOR',
recipientAssetId:null,recipientUserId:inspector.id,recipientKey:inspector.id,
recipientEmail:inspector.email,
});
if (reportId) {
await this.upsertRow({
actId,reportId,documentKind:'REPORT_WORD',recipientKind:'INSPECTOR',
recipientAssetId:null,recipientUserId:inspector.id,recipientKey:inspector.id,
recipientEmail:inspector.email,
});
}
}
}
@@ -267,31 +225,21 @@ export class InspectionDocumentDeliveryService {
recipient_user_id=COALESCE(EXCLUDED.recipient_user_id,inspection_document_deliveries.recipient_user_id),
recipient_email=CASE
WHEN inspection_document_deliveries.status='SENT' THEN inspection_document_deliveries.recipient_email
ELSE EXCLUDED.recipient_email
END,
ELSE EXCLUDED.recipient_email END,
status=CASE
WHEN inspection_document_deliveries.status='SENT' THEN 'SENT'
WHEN EXCLUDED.recipient_email IS NULL THEN 'WAITING_RECIPIENT'
ELSE inspection_document_deliveries.status
END,
ELSE inspection_document_deliveries.status END,
updated_at=CURRENT_TIMESTAMP
`, [
input.actId,
input.reportId,
input.documentKind,
input.recipientKind,
input.recipientAssetId,
input.recipientUserId,
input.recipientKey,
input.recipientEmail,
initialStatus,
input.actId,input.reportId,input.documentKind,input.recipientKind,
input.recipientAssetId,input.recipientUserId,input.recipientKey,input.recipientEmail,initialStatus,
]);
}
private async rowsForAct(actId: string): Promise<DeliveryRow[]> {
return this.dataSource.query(`
SELECT
d.id,d.act_id AS "actId",d.report_id AS "reportId",
SELECT d.id,d.act_id AS "actId",d.report_id AS "reportId",
d.document_kind AS "documentKind",d.recipient_kind AS "recipientKind",
d.recipient_asset_id AS "recipientAssetId",d.recipient_user_id AS "recipientUserId",
d.recipient_email AS "recipientEmail",d.status,d.attempts,
@@ -299,15 +247,14 @@ export class InspectionDocumentDeliveryService {
FROM inspection_document_deliveries d
JOIN inspection_acts a ON a.id=d.act_id
LEFT JOIN inspection_reports r ON r.id=d.report_id
WHERE d.act_id=$1
WHERE d.act_id=$1 AND d.recipient_kind<>'DIRECTOR'
ORDER BY d.created_at
`, [actId]) as Promise<DeliveryRow[]>;
}
private async load(id: string): Promise<DeliveryRow> {
const [row] = await this.dataSource.query(`
SELECT
d.id,d.act_id AS "actId",d.report_id AS "reportId",
SELECT d.id,d.act_id AS "actId",d.report_id AS "reportId",
d.document_kind AS "documentKind",d.recipient_kind AS "recipientKind",
d.recipient_asset_id AS "recipientAssetId",d.recipient_user_id AS "recipientUserId",
d.recipient_email AS "recipientEmail",d.status,d.attempts,
@@ -315,12 +262,11 @@ export class InspectionDocumentDeliveryService {
FROM inspection_document_deliveries d
JOIN inspection_acts a ON a.id=d.act_id
LEFT JOIN inspection_reports r ON r.id=d.report_id
WHERE d.id=$1
WHERE d.id=$1 AND d.recipient_kind<>'DIRECTOR'
`, [id]) as DeliveryRow[];
if (!row) {
throw new NotFoundException({
code: 'DOCUMENT_DELIVERY_NOT_FOUND',
message: 'Entrega documental no encontrada',
code:'DOCUMENT_DELIVERY_NOT_FOUND',message:'Entrega documental no encontrada',
});
}
return row;
@@ -330,47 +276,37 @@ export class InspectionDocumentDeliveryService {
let email: string | null = null;
if (row.recipientKind === 'COMPANY' && row.recipientAssetId) {
const [company] = await this.dataSource.query(`
SELECT notification_email AS email
FROM organization_profiles
WHERE asset_id=$1
SELECT notification_email AS email FROM organization_profiles WHERE asset_id=$1
`, [row.recipientAssetId]) as Array<{ email: string | null }>;
email = company?.email ?? null;
} else if (row.recipientKind === 'INSPECTOR' && row.recipientUserId) {
const [inspector] = await this.dataSource.query(`
SELECT email
FROM users
WHERE id=$1 AND is_active=true
SELECT email FROM users WHERE id=$1 AND is_active=true
`, [row.recipientUserId]) as Array<{ email: string | null }>;
email = inspector?.email ?? null;
} else {
const [settings] = await this.dataSource.query(`
SELECT office_email AS "officeEmail",director_email AS "directorEmail"
FROM institutional_delivery_settings
WHERE id=1
`) as Array<{ officeEmail: string | null; directorEmail: string | null }>;
email = row.recipientKind === 'OFFICE'
? settings?.officeEmail ?? null
: settings?.directorEmail ?? null;
SELECT office_email AS "officeEmail" FROM institutional_delivery_settings WHERE id=1
`) as Array<{ officeEmail: string | null }>;
email = settings?.officeEmail ?? null;
}
await this.dataSource.query(`
UPDATE inspection_document_deliveries
SET recipient_email=$2,
status=CASE WHEN $2::text IS NULL THEN 'WAITING_RECIPIENT' ELSE 'PENDING' END,
last_error=NULL,
updated_at=CURRENT_TIMESTAMP
last_error=NULL,updated_at=CURRENT_TIMESTAMP
WHERE id=$1 AND status<>'SENT'
`, [row.id, email]);
`, [row.id,email]);
}
private async attempt(row: DeliveryRow) {
if (row.status === 'SENT') return;
if (!row.recipientEmail) {
await this.setStatus(row.id, 'WAITING_RECIPIENT', 'Destinatario no configurado');
await this.setStatus(row.id,'WAITING_RECIPIENT','Destinatario no configurado');
return;
}
if (!this.smtp.configured()) {
await this.setStatus(row.id, 'WAITING_TRANSPORT', 'SMTP no configurado');
if (!await this.smtp.configured()) {
await this.setStatus(row.id,'WAITING_TRANSPORT','SMTP no configurado');
return;
}
@@ -379,28 +315,18 @@ export class InspectionDocumentDeliveryService {
if (row.documentKind === 'ACT_PDF') {
await this.pdf.ensure(row.actId);
const file = await this.pdf.content(row.actId);
attachment = {
filename: file.originalName,
mimeType: file.mimeType,
content: file.buffer,
};
attachment = { filename:file.originalName,mimeType:file.mimeType,content:file.buffer };
} else {
if (!row.reportId) throw new Error('Informe no vinculado');
await this.word.ensure(row.reportId);
const file = await this.word.content(row.reportId);
const { readFile } = await import('node:fs/promises');
attachment = {
filename: file.originalName,
mimeType: file.mimeType,
content: await readFile(file.filePath),
filename:file.originalName,mimeType:file.mimeType,content:await readFile(file.filePath),
};
}
} catch (error) {
await this.setStatus(
row.id,
'WAITING_ARTIFACT',
error instanceof Error ? error.message : 'Documento no disponible',
);
await this.setStatus(row.id,'WAITING_ARTIFACT',error instanceof Error ? error.message : 'Documento no disponible');
return;
}
@@ -409,62 +335,41 @@ export class InspectionDocumentDeliveryService {
SET attempts=attempts+1,last_attempt_at=CURRENT_TIMESTAMP,status='PENDING',
last_error=NULL,updated_at=CURRENT_TIMESTAMP
WHERE id=$1
`, [row.id]);
`,[row.id]);
try {
const label = row.documentKind === 'ACT_PDF'
? `Acta ${row.actCode}`
: `Informe ${row.reportCode ?? ''}`;
const text = row.documentKind === 'REPORT_WORD'
? `Se adjunta el informe Word automático ${row.reportCode ?? ''} para revisión del Director de Hidrocarburos.`
? `Se adjunta el INF editable ${row.reportCode ?? ''} para su revisión y preparación antes de cargarlo en GEDO.`
: row.recipientKind === 'INSPECTOR'
? `Se adjunta copia del acta cerrada e inmutable ${row.actCode} correspondiente a tu inspección.`
: `Se adjunta el acta cerrada e inmutable ${row.actCode}.`;
? `Se adjunta copia del acta sellada e inmutable ${row.actCode} correspondiente a tu inspección.`
: `Se adjunta el acta sellada e inmutable ${row.actCode}.`;
const sent = await this.smtp.send({
to: row.recipientEmail,
subject: `DH Inspección · ${label}`,
text,
attachment,
to:row.recipientEmail,subject:`DH Inspección · ${label}`,text,attachment,
});
await this.dataSource.query(`
UPDATE inspection_document_deliveries
SET status='SENT',sent_at=CURRENT_TIMESTAMP,provider_message_id=$2,
last_error=NULL,updated_at=CURRENT_TIMESTAMP
WHERE id=$1
`, [row.id, sent.messageId]);
last_error=NULL,updated_at=CURRENT_TIMESTAMP WHERE id=$1
`,[row.id,sent.messageId]);
await this.audit.record({
action: AuditAction.DOCUMENT_DELIVERY_SENT,
entityType: 'inspection_document_delivery',
entityId: row.id,
source: AuditSource.SYSTEM,
actorUserId: null,
actorUsername: null,
requestId: null,
ip: null,
userAgent: null,
beforeData: null,
afterData: {
recipientKind: row.recipientKind,
recipientUserId: row.recipientUserId,
documentKind: row.documentKind,
status: 'SENT',
},
metadata: { actId: row.actId, reportId: row.reportId },
action:AuditAction.DOCUMENT_DELIVERY_SENT,
entityType:'inspection_document_delivery',entityId:row.id,source:AuditSource.SYSTEM,
actorUserId:null,actorUsername:null,requestId:null,ip:null,userAgent:null,beforeData:null,
afterData:{recipientKind:row.recipientKind,recipientUserId:row.recipientUserId,documentKind:row.documentKind,status:'SENT'},
metadata:{actId:row.actId,reportId:row.reportId},
});
} catch (error) {
await this.setStatus(
row.id,
'FAILED',
error instanceof Error ? error.message : 'Error de entrega',
);
await this.setStatus(row.id,'FAILED',error instanceof Error ? error.message : 'Error de entrega');
}
}
private async setStatus(id: string, status: string, error: string) {
private async setStatus(id: string,status: string,error: string) {
await this.dataSource.query(`
UPDATE inspection_document_deliveries
SET status=$2,last_error=$3,updated_at=CURRENT_TIMESTAMP
WHERE id=$1
`, [id, status, error.slice(0, 500)]);
SET status=$2,last_error=$3,updated_at=CURRENT_TIMESTAMP WHERE id=$1
`,[id,status,error.slice(0,500)]);
}
}
@@ -1,87 +0,0 @@
import { Body, Controller, Get, Param, ParseUUIDPipe, Post, Req, Res, UploadedFile, UseInterceptors } from '@nestjs/common';
import { FileInterceptor } from '@nestjs/platform-express';
import type { Response } from 'express';
import { CurrentAuth } from '../auth/decorators/current-auth.decorator';
import { RequirePermissions } from '../authorization/decorators/require-permissions.decorator';
import type { AuthPrincipal, RequestWithContext } from '../common/http/request-context';
import { ApproveInspectionReportDto } from './dto/approve-inspection-report.dto';
import { CreateInspectionReportRevisionDto } from './dto/create-inspection-report-revision.dto';
import { SignFinalInspectionReportDto } from './dto/sign-final-inspection-report.dto';
import {
MAX_INSPECTION_REPORT_REVISION_BYTES,
type UploadedInspectionReportRevisionFile,
} from './inspection-report-revision-file';
import {
InspectionReportReviewService,
type InspectionReportReviewView,
} from './inspection-report-review.service';
@Controller('inspection-reports/:reportId/review')
export class InspectionReportReviewController {
constructor(private readonly review: InspectionReportReviewService) {}
@Get()
@RequirePermissions('inspection_reports.read')
get(@Param('reportId', new ParseUUIDPipe({ version: '4' })) reportId: string): Promise<InspectionReportReviewView> {
return this.review.get(reportId);
}
@Post('revisions')
@RequirePermissions('inspection_reports.revise')
@UseInterceptors(FileInterceptor('file', { limits: { fileSize: MAX_INSPECTION_REPORT_REVISION_BYTES, files: 1 } }))
revision(
@Param('reportId', new ParseUUIDPipe({ version: '4' })) reportId: string,
@Body() dto: CreateInspectionReportRevisionDto,
@UploadedFile() file: UploadedInspectionReportRevisionFile | undefined,
@CurrentAuth() principal: AuthPrincipal,
@Req() request: RequestWithContext,
): Promise<InspectionReportReviewView> {
return this.review.createRevision(reportId, dto, file, principal, request);
}
@Post('approve')
@RequirePermissions('inspection_reports.review')
approve(
@Param('reportId', new ParseUUIDPipe({ version: '4' })) reportId: string,
@Body() dto: ApproveInspectionReportDto,
@CurrentAuth() principal: AuthPrincipal,
@Req() request: RequestWithContext,
): Promise<InspectionReportReviewView> {
return this.review.approve(reportId, dto, principal, request);
}
@Post('sign-final')
@RequirePermissions('inspection_reports.sign_final')
signFinal(
@Param('reportId', new ParseUUIDPipe({ version: '4' })) reportId: string,
@Body() dto: SignFinalInspectionReportDto,
@CurrentAuth() principal: AuthPrincipal,
@Req() request: RequestWithContext,
): Promise<InspectionReportReviewView> {
return this.review.signFinal(reportId, dto, principal, request);
}
}
@Controller('inspection-report-revisions')
export class InspectionReportRevisionContentController {
constructor(private readonly review: InspectionReportReviewService) {}
@Get(':revisionId/content')
@RequirePermissions('inspection_reports.read')
async content(
@Param('revisionId', new ParseUUIDPipe({ version: '4' })) revisionId: string,
@Res() response: Response,
): Promise<void> {
const content = await this.review.revisionContent(revisionId);
response.setHeader('Content-Type', content.mimeType);
response.setHeader('Content-Disposition', `attachment; filename="${content.originalName.replaceAll('"', '')}"`);
response.setHeader('Cache-Control', 'private, no-store');
response.setHeader('X-Content-Type-Options', 'nosniff');
await new Promise<void>((resolveSend, rejectSend) => {
response.sendFile(content.filePath, (error) => {
if (error) rejectSend(error);
else resolveSend();
});
});
}
}
@@ -1,433 +0,0 @@
import { createHash, randomUUID } from 'node:crypto';
import { mkdir, readFile, rm, stat, writeFile } from 'node:fs/promises';
import { isAbsolute, parse, resolve } from 'node:path';
import { ConflictException, ForbiddenException, Injectable, InternalServerErrorException, NotFoundException } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { DataSource, EntityManager } from 'typeorm';
import { administrationAuditContext } from '../administration/common/administration-audit';
import { AuditService } from '../audit/audit.service';
import type { AuthPrincipal, RequestWithContext } from '../common/http/request-context';
import { AuditAction } from '../database/entities';
import { sha256CanonicalJson } from '../inspection-closing/canonical-json';
import type { ApproveInspectionReportDto } from './dto/approve-inspection-report.dto';
import type { CreateInspectionReportRevisionDto } from './dto/create-inspection-report-revision.dto';
import type { SignFinalInspectionReportDto } from './dto/sign-final-inspection-report.dto';
import {
INSPECTION_REPORT_WORD_MIME,
inspectInspectionReportRevisionUpload,
type UploadedInspectionReportRevisionFile,
validateInspectionReportRevisionContainer,
} from './inspection-report-revision-file';
import { InspectionReportWordService } from './inspection-report-word.service';
export type InspectionReportReviewStatus = 'PENDING_REVIEW' | 'APPROVED' | 'SIGNED';
export type InspectionReportRevisionSource = 'AUTO' | 'DIRECTOR_UPLOAD';
export interface InspectionReportReviewPerson {
id: string;
username: string;
firstName: string;
lastName: string;
}
export interface InspectionReportRevisionView {
id: string;
reportId: string;
revisionNumber: number;
source: InspectionReportRevisionSource;
originalName: string;
mimeType: string;
sizeBytes: number;
sha256: string;
changeSummary: string | null;
createdAt: Date;
createdBy: InspectionReportReviewPerson;
}
export interface InspectionReportSignatureView {
id: string;
revisionId: string;
signedAt: Date;
confirmationText: string;
signatureSha256: string;
signedBy: InspectionReportReviewPerson;
}
export interface InspectionReportReviewView {
reportId: string;
reportCode: string;
reviewStatus: InspectionReportReviewStatus;
currentRevisionNumber: number;
approvedRevisionId: string | null;
approvedAt: Date | null;
reviewNote: string | null;
approvedBy: InspectionReportReviewPerson | null;
signature: InspectionReportSignatureView | null;
revisions: InspectionReportRevisionView[];
}
interface LockedReport {
id: string;
code: string;
status: string;
frozenSha256: string;
reviewStatus: InspectionReportReviewStatus;
currentRevisionNumber: number;
approvedRevisionId: string | null;
approvedAt: Date | null;
reviewNote: string | null;
}
interface StoredRevisionRow {
id: string;
reportId: string;
revisionNumber: number;
source: InspectionReportRevisionSource;
originalName: string;
storedName: string;
mimeType: string;
sizeBytes: number;
sha256: string;
}
const FINAL_CONFIRMATION_TEXT = 'Confirmo que revisé la versión aprobada y firmo electrónicamente el informe final como Director de Hidrocarburos.';
@Injectable()
export class InspectionReportReviewService {
private readonly revisionRoot: string;
constructor(
private readonly dataSource: DataSource,
private readonly audit: AuditService,
private readonly word: InspectionReportWordService,
config: ConfigService,
) {
const configured = config.get<string>('INSPECTION_REPORT_REVISION_ROOT') ?? '/app/storage/asset-media/inspection-report-revisions';
if (!isAbsolute(configured)) throw new Error('INSPECTION_REPORT_REVISION_ROOT must be an absolute path');
this.revisionRoot = resolve(configured);
if (this.revisionRoot === parse(this.revisionRoot).root) throw new Error('INSPECTION_REPORT_REVISION_ROOT cannot be the filesystem root');
}
async get(reportId: string): Promise<InspectionReportReviewView> {
await this.word.ensure(reportId);
await this.ensureAutomaticRevision(reportId);
return this.loadView(reportId);
}
async createRevision(
reportId: string,
dto: CreateInspectionReportRevisionDto,
file: UploadedInspectionReportRevisionFile | undefined,
principal: AuthPrincipal,
request: RequestWithContext,
): Promise<InspectionReportReviewView> {
inspectInspectionReportRevisionUpload(file);
await this.assertDirector(principal.userId, this.dataSource.manager);
await mkdir(this.revisionRoot, { recursive: true, mode: 0o700 });
const storedName = `${reportId}-${randomUUID()}.docx`;
const filePath = resolve(this.revisionRoot, storedName);
if (!filePath.startsWith(`${this.revisionRoot}/`)) throw this.storageError();
await writeFile(filePath, file!.buffer, { mode: 0o600 });
try {
await validateInspectionReportRevisionContainer(filePath);
const sha256 = createHash('sha256').update(file!.buffer).digest('hex');
const created = await this.dataSource.transaction(async (manager) => {
const report = await this.lockReport(manager, reportId);
this.assertOpenForReview(report);
const nextRevision = report.currentRevisionNumber + 1;
const [row] = await manager.query(`
INSERT INTO inspection_report_revisions (
report_id, revision_number, source, original_name, stored_name, mime_type,
size_bytes, sha256, change_summary, created_by
) VALUES ($1,$2,'DIRECTOR_UPLOAD',$3,$4,$5,$6,$7,$8,$9)
RETURNING id
`, [
report.id,
nextRevision,
this.cleanOriginalName(file!.originalname),
storedName,
INSPECTION_REPORT_WORD_MIME,
file!.buffer.length,
sha256,
dto.changeSummary.trim(),
principal.userId,
]) as Array<{ id: string }>;
await manager.query(`
UPDATE inspection_reports
SET current_revision_number = $2, updated_at = CURRENT_TIMESTAMP
WHERE id = $1
`, [report.id, nextRevision]);
await this.audit.record({
...administrationAuditContext(principal, request),
action: AuditAction.INSPECTION_REPORT_REVISION_ADDED,
entityType: 'inspection_report_revision',
entityId: row.id,
afterData: {
reportId: report.id,
reportCode: report.code,
revisionNumber: nextRevision,
sha256,
source: 'DIRECTOR_UPLOAD',
},
}, manager);
return row.id;
});
if (!created) throw this.storageError();
} catch (error) {
await rm(filePath, { force: true }).catch(() => undefined);
throw error;
}
return this.loadView(reportId);
}
async approve(
reportId: string,
dto: ApproveInspectionReportDto,
principal: AuthPrincipal,
request: RequestWithContext,
): Promise<InspectionReportReviewView> {
await this.dataSource.transaction(async (manager) => {
await this.assertDirector(principal.userId, manager);
const report = await this.lockReport(manager, reportId);
this.assertOpenForReview(report);
const [revision] = await manager.query(`
SELECT id, revision_number AS "revisionNumber", sha256
FROM inspection_report_revisions
WHERE report_id = $1 AND revision_number = $2
`, [report.id, report.currentRevisionNumber]) as Array<{ id: string; revisionNumber: number; sha256: string }>;
if (!revision) throw new ConflictException({ code: 'INSPECTION_REPORT_REVISION_REQUIRED', message: 'El informe necesita una versión Word válida antes de aprobarse' });
const approvedAt = new Date();
await manager.query(`
UPDATE inspection_reports
SET review_status = 'APPROVED', approved_revision_id = $2, approved_by = $3,
approved_at = $4, review_note = $5, updated_at = CURRENT_TIMESTAMP
WHERE id = $1
`, [report.id, revision.id, principal.userId, approvedAt, dto.note?.trim() || null]);
await this.audit.record({
...administrationAuditContext(principal, request),
action: AuditAction.INSPECTION_REPORT_APPROVED,
entityType: 'inspection_report',
entityId: report.id,
afterData: {
reportCode: report.code,
revisionId: revision.id,
revisionNumber: revision.revisionNumber,
revisionSha256: revision.sha256,
approvedAt: approvedAt.toISOString(),
},
}, manager);
});
return this.loadView(reportId);
}
async signFinal(
reportId: string,
dto: SignFinalInspectionReportDto,
principal: AuthPrincipal,
request: RequestWithContext,
): Promise<InspectionReportReviewView> {
if (dto.confirmation !== true) throw new ConflictException({ code: 'INSPECTION_REPORT_SIGNATURE_CONFIRMATION_REQUIRED', message: 'Debés confirmar expresamente la firma final del informe' });
await this.dataSource.transaction(async (manager) => {
await this.assertDirector(principal.userId, manager);
const report = await this.lockReport(manager, reportId);
if (report.reviewStatus === 'SIGNED') throw new ConflictException({ code: 'INSPECTION_REPORT_ALREADY_SIGNED', message: 'El informe ya tiene una firma final inmutable' });
if (report.reviewStatus !== 'APPROVED' || !report.approvedRevisionId || !report.approvedAt) {
throw new ConflictException({ code: 'INSPECTION_REPORT_NOT_APPROVED', message: 'El Director debe aprobar una versión antes de firmar el informe final' });
}
const [revision] = await manager.query(`
SELECT id, revision_number AS "revisionNumber", sha256
FROM inspection_report_revisions
WHERE id = $1 AND report_id = $2
`, [report.approvedRevisionId, report.id]) as Array<{ id: string; revisionNumber: number; sha256: string }>;
if (!revision) throw new ConflictException({ code: 'INSPECTION_REPORT_APPROVED_REVISION_MISSING', message: 'La versión aprobada no está disponible para la firma final' });
const signedAt = new Date();
const payload = {
schemaVersion: 'DH-INSPECTION-REPORT-SIGNATURE-V1',
reportId: report.id,
reportCode: report.code,
reportFrozenSha256: report.frozenSha256,
revisionId: revision.id,
revisionNumber: revision.revisionNumber,
revisionSha256: revision.sha256,
signedBy: principal.userId,
signedByUsername: principal.username,
signedAt: signedAt.toISOString(),
confirmationText: FINAL_CONFIRMATION_TEXT,
};
const signatureSha256 = sha256CanonicalJson(payload);
const [signature] = await manager.query(`
INSERT INTO inspection_report_signatures (
report_id, revision_id, signed_by, signed_at, confirmation_text,
signature_payload, signature_sha256
) VALUES ($1,$2,$3,$4,$5,$6,$7)
RETURNING id
`, [report.id, revision.id, principal.userId, signedAt, FINAL_CONFIRMATION_TEXT, payload, signatureSha256]) as Array<{ id: string }>;
await manager.query(`
UPDATE inspection_reports
SET review_status = 'SIGNED', signed_at = $2, updated_at = CURRENT_TIMESTAMP
WHERE id = $1
`, [report.id, signedAt]);
await this.audit.record({
...administrationAuditContext(principal, request),
action: AuditAction.INSPECTION_REPORT_SIGNED,
entityType: 'inspection_report_signature',
entityId: signature.id,
afterData: {
reportId: report.id,
reportCode: report.code,
revisionId: revision.id,
revisionNumber: revision.revisionNumber,
revisionSha256: revision.sha256,
signatureSha256,
signedAt: signedAt.toISOString(),
},
}, manager);
});
return this.loadView(reportId);
}
async revisionContent(revisionId: string): Promise<{ filePath: string; originalName: string; mimeType: string }> {
const [revision] = await this.dataSource.query(`
SELECT id, report_id AS "reportId", revision_number AS "revisionNumber", source,
original_name AS "originalName", stored_name AS "storedName", mime_type AS "mimeType",
size_bytes::integer AS "sizeBytes", sha256
FROM inspection_report_revisions WHERE id = $1
`, [revisionId]) as StoredRevisionRow[];
if (!revision) throw new NotFoundException({ code: 'INSPECTION_REPORT_REVISION_NOT_FOUND', message: 'Versión del informe no encontrada' });
if (revision.source === 'AUTO') return this.word.content(revision.reportId);
const filePath = resolve(this.revisionRoot, revision.storedName);
if (!filePath.startsWith(`${this.revisionRoot}/`)) throw this.storageError();
const fileStat = await stat(filePath).catch(() => null);
if (!fileStat?.isFile() || fileStat.size !== revision.sizeBytes) throw this.storageError();
const buffer = await readFile(filePath);
const sha256 = createHash('sha256').update(buffer).digest('hex');
if (sha256 !== revision.sha256) throw this.storageError();
return { filePath, originalName: revision.originalName, mimeType: revision.mimeType };
}
private async ensureAutomaticRevision(reportId: string): Promise<void> {
await this.dataSource.query(`
INSERT INTO inspection_report_revisions (
report_id, revision_number, source, original_name, stored_name, mime_type,
size_bytes, sha256, change_summary, created_by, created_at
)
SELECT
id, 1, 'AUTO', word_original_name, word_stored_name, word_mime_type,
word_size_bytes, word_sha256, 'Versión automática inicial', generated_by,
COALESCE(word_generated_at, generated_at)
FROM inspection_reports
WHERE id = $1
AND word_status = 'READY'
AND word_original_name IS NOT NULL
AND word_stored_name IS NOT NULL
AND word_mime_type = $2
AND word_size_bytes > 0
AND word_sha256 ~ '^[0-9a-f]{64}$'
ON CONFLICT (report_id, revision_number) DO NOTHING
`, [reportId, INSPECTION_REPORT_WORD_MIME]);
await this.dataSource.query(`
UPDATE inspection_reports
SET current_revision_number = GREATEST(current_revision_number, 1), updated_at = CURRENT_TIMESTAMP
WHERE id = $1
AND EXISTS (SELECT 1 FROM inspection_report_revisions WHERE report_id = $1 AND revision_number = 1)
`, [reportId]);
}
private async loadView(reportId: string): Promise<InspectionReportReviewView> {
const [report] = await this.dataSource.query(`
SELECT
report.id AS "reportId",
report.code AS "reportCode",
report.review_status AS "reviewStatus",
report.current_revision_number AS "currentRevisionNumber",
report.approved_revision_id AS "approvedRevisionId",
report.approved_at AS "approvedAt",
report.review_note AS "reviewNote",
CASE WHEN approver.id IS NULL THEN NULL ELSE JSONB_BUILD_OBJECT(
'id', approver.id, 'username', approver.username, 'firstName', approver.first_name, 'lastName', approver.last_name
) END AS "approvedBy"
FROM inspection_reports report
LEFT JOIN users approver ON approver.id = report.approved_by
WHERE report.id = $1
`, [reportId]) as Array<Omit<InspectionReportReviewView, 'signature' | 'revisions'>>;
if (!report) throw new NotFoundException({ code: 'INSPECTION_REPORT_NOT_FOUND', message: 'Informe de inspección no encontrado' });
const revisions = await this.dataSource.query(`
SELECT
revision.id,
revision.report_id AS "reportId",
revision.revision_number AS "revisionNumber",
revision.source,
revision.original_name AS "originalName",
revision.mime_type AS "mimeType",
revision.size_bytes::integer AS "sizeBytes",
revision.sha256,
revision.change_summary AS "changeSummary",
revision.created_at AS "createdAt",
JSONB_BUILD_OBJECT(
'id', creator.id, 'username', creator.username, 'firstName', creator.first_name, 'lastName', creator.last_name
) AS "createdBy"
FROM inspection_report_revisions revision
JOIN users creator ON creator.id = revision.created_by
WHERE revision.report_id = $1
ORDER BY revision.revision_number DESC
`, [reportId]) as InspectionReportRevisionView[];
const [signature] = await this.dataSource.query(`
SELECT
signature.id,
signature.revision_id AS "revisionId",
signature.signed_at AS "signedAt",
signature.confirmation_text AS "confirmationText",
signature.signature_sha256 AS "signatureSha256",
JSONB_BUILD_OBJECT(
'id', signer.id, 'username', signer.username, 'firstName', signer.first_name, 'lastName', signer.last_name
) AS "signedBy"
FROM inspection_report_signatures signature
JOIN users signer ON signer.id = signature.signed_by
WHERE signature.report_id = $1
`, [reportId]) as InspectionReportSignatureView[];
return { ...report, signature: signature ?? null, revisions };
}
private async lockReport(manager: EntityManager, reportId: string): Promise<LockedReport> {
const [report] = await manager.query(`
SELECT
id, code, status, frozen_sha256 AS "frozenSha256",
review_status AS "reviewStatus",
current_revision_number AS "currentRevisionNumber",
approved_revision_id AS "approvedRevisionId",
approved_at AS "approvedAt",
review_note AS "reviewNote"
FROM inspection_reports
WHERE id = $1
FOR UPDATE
`, [reportId]) as LockedReport[];
if (!report) throw new NotFoundException({ code: 'INSPECTION_REPORT_NOT_FOUND', message: 'Informe de inspección no encontrado' });
if (report.status !== 'FROZEN') throw new ConflictException({ code: 'INSPECTION_REPORT_NOT_REVIEWABLE', message: 'Sólo los informes congelados pueden ingresar al circuito de revisión' });
return report;
}
private assertOpenForReview(report: LockedReport): void {
if (report.reviewStatus === 'SIGNED') throw new ConflictException({ code: 'INSPECTION_REPORT_ALREADY_SIGNED', message: 'El informe firmado es definitivo y no admite nuevas versiones' });
if (report.reviewStatus === 'APPROVED') throw new ConflictException({ code: 'INSPECTION_REPORT_ALREADY_APPROVED', message: 'La versión ya fue aprobada y sólo resta la firma final del Director' });
}
private async assertDirector(userId: string, manager: EntityManager): Promise<void> {
const [role] = await manager.query(`
SELECT role.code
FROM user_roles membership
JOIN roles role ON role.id = membership.role_id
WHERE membership.user_id = $1 AND role.code = 'director'
LIMIT 1
`, [userId]) as Array<{ code: string }>;
if (!role) throw new ForbiddenException({ code: 'INSPECTION_REPORT_DIRECTOR_REQUIRED', message: 'Esta operación está reservada al Director de Hidrocarburos' });
}
private cleanOriginalName(value: string): string {
const cleaned = value.replace(/[\\/\0\r\n]/g, '_').trim();
return (cleaned || 'informe-corregido.docx').slice(0, 255);
}
private storageError(): InternalServerErrorException {
return new InternalServerErrorException({ code: 'INSPECTION_REPORT_REVISION_STORAGE_ERROR', message: 'La versión del informe no está disponible o no supera la validación de integridad' });
}
}
@@ -11,6 +11,8 @@ interface ReportWordInput {
generatedAt: Date;
frozenSha256: string;
frozenSnapshot: Record<string, unknown>;
executiveSummary?: string | null;
reportDescription?: string | null;
}
const crcTable = (() => {
@@ -56,8 +58,8 @@ function text(value: unknown, fallback = '—'): string {
}
function isoDate(value: unknown): string {
const date = new Date(String(value ?? ''));
return Number.isFinite(date.getTime()) ? date.toLocaleDateString('es-AR') : '—';
const parsed = new Date(String(value ?? ''));
return Number.isFinite(parsed.getTime()) ? parsed.toLocaleDateString('es-AR') : '—';
}
function paragraph(value: string, style?: 'Title' | 'Heading1' | 'Heading2'): string {
@@ -76,52 +78,96 @@ function table(headers: string[], rows: string[][]): string {
return `<w:tbl><w:tblPr><w:tblW w:w="0" w:type="auto"/><w:tblBorders><w:top w:val="single" w:sz="4" w:color="B7C9D6"/><w:left w:val="single" w:sz="4" w:color="B7C9D6"/><w:bottom w:val="single" w:sz="4" w:color="B7C9D6"/><w:right w:val="single" w:sz="4" w:color="B7C9D6"/><w:insideH w:val="single" w:sz="4" w:color="D8E1E8"/><w:insideV w:val="single" w:sz="4" w:color="D8E1E8"/></w:tblBorders></w:tblPr>${header}${body}</w:tbl>`;
}
function f4Snapshot(input: ReportWordInput) {
const source = asRecord(input.frozenSnapshot.source);
const sealedAct = asRecord(input.frozenSnapshot.sealedAct ?? input.frozenSnapshot.actClosure);
const locked = asRecord(sealedAct.lockedSnapshot ?? sealedAct.preparedSnapshot);
const act = asRecord(locked.act);
const inspection = asRecord(act.inspection ?? act.visit);
const responsible = asRecord(locked.responsible);
const inventories = asArray(locked.inventories ?? locked.assets);
const findings = asArray(locked.findings);
const signatures = asArray(sealedAct.signatures);
return { source, sealedAct, locked, act, inspection, responsible, inventories, findings, signatures };
}
function urgency(value: unknown): string {
return text(value, '') === 'URGENT' ? 'Urgente' : 'No urgente';
}
function deadline(act: Record<string, unknown>): string {
const type = text(act.deadlineDayType, '') === 'CALENDAR' ? 'días corridos' : 'días hábiles';
if (act.deadlineAt) return `${isoDate(act.deadlineAt)} · ${text(act.deadlineDays)} ${type}`;
if (text(act.deadlineBasis, '') === 'GEDO_DATE') return `${text(act.deadlineDays)} ${type} desde la fecha de oficialización GEDO`;
return '—';
}
function documentXml(input: ReportWordInput): string {
const report = asRecord(input.frozenSnapshot.report);
const actClosure = asRecord(input.frozenSnapshot.actClosure);
const prepared = asRecord(actClosure.preparedSnapshot);
const act = asRecord(prepared.act);
const visit = asRecord(act.visit);
const responsible = asRecord(prepared.responsible);
const team = asArray(prepared.team);
const assets = asArray(prepared.assets);
const findings = asArray(prepared.findings);
const signatures = asArray(actClosure.signatures);
const companies = [...new Set(assets.map((item) => text(asRecord(item.operatorCompany).name, '')).filter(Boolean))];
const areas = [...new Set(assets.map((item) => text(asRecord(item.operationalArea).name, '')).filter(Boolean))];
const inspectorNames = team.map((member) => `${text(member.firstName, '')} ${text(member.lastName, '')}`.trim()).filter(Boolean);
const assetRows = assets.map((item) => [text(item.code), text(item.name), text(item.commonName, ''), text(item.typeName)]);
const findingRows = findings.map((item) => [text(item.code), text(item.title), text(item.description), item.severity == null ? '—' : `${text(item.severity)}/10`, isoDate(item.correctionDueOn)]);
const signatureRows = signatures.map((item) => [text(item.signerType), text(item.signerName), text(item.status), isoDate(item.signedAt ?? item.createdAt)]);
const snapshot = f4Snapshot(input);
const findingRows = snapshot.findings.map((item) => [
text(item.code),
text(item.title),
text(item.description),
item.isRecurrence ? `${item.recurrenceOfFindingCode ? ` · ${text(item.recurrenceOfFindingCode)}` : ''}` : 'No',
item.severity == null ? '—' : `${text(item.severity)}/10`,
]);
const inventoryRows = snapshot.inventories.map((item) => [
text(item.code),
text(item.name),
text(item.typeName ?? item.typeCode),
]);
const signatureRows = snapshot.signatures.map((item) => [
text(item.signerType),
text(item.signerName),
text(item.status),
text(item.companyManifestation, ''),
isoDate(item.signedAt ?? item.createdAt),
]);
const executive = input.executiveSummary?.trim()
|| '[EDITAR] Incorporar aquí el resumen ejecutivo del informe.';
const description = input.reportDescription?.trim()
|| '[EDITAR] Incorporar aquí la descripción técnica, análisis y consideraciones del inspector.';
const body = [
paragraph('INFORME TÉCNICO DE INSPECCIÓN', 'Title'),
paragraph('Borrador automático para revisión del Director de Hidrocarburos', 'Heading2'),
paragraph('Documento Word editable para revisión del Inspector antes de su incorporación a GEDO', 'Heading2'),
labelValue('Informe', input.code),
labelValue('Título', input.title),
labelValue('Acta fuente', text(snapshot.source.actCode ?? snapshot.act.code)),
labelValue('Inspección', text(snapshot.source.inspectionCode ?? snapshot.inspection.code)),
labelValue('Fecha de inspección', isoDate(snapshot.act.occurredAt)),
labelValue('Urgencia', urgency(snapshot.act.urgency)),
labelValue('Plazo', deadline(snapshot.act)),
labelValue('Fecha de generación', input.generatedAt.toLocaleString('es-AR')),
labelValue('Acta', text(report.actCode ?? act.code)),
labelValue('Inspección', text(report.visitCode ?? visit.code)),
labelValue('Empresa', companies.join(' · ') || 'Según alcance del acta'),
labelValue('Área', areas.join(' · ') || 'Según alcance del acta'),
labelValue('Inspector/es', inspectorNames.join(' · ') || '—'),
labelValue('Responsable de empresa', text(responsible.fullName)),
labelValue('Cargo', text(responsible.position)),
paragraph('Datos de la visita', 'Heading1'),
labelValue('Objetivo', text(visit.objective)),
labelValue('Fecha de inspección', isoDate(act.occurredAt)),
labelValue('Resumen', text(act.summary)),
labelValue('Observaciones', text(act.observations)),
paragraph('Elementos inspeccionados', 'Heading1'),
assetRows.length ? table(['Código', 'Nombre técnico', 'Nombre habitual', 'Tipo'], assetRows) : paragraph('No se registraron elementos en la instantánea.'),
paragraph('Resumen ejecutivo', 'Heading1'),
paragraph(executive),
paragraph('Descripción / análisis técnico', 'Heading1'),
paragraph(description),
paragraph('Acta fuente · contenido inmutable', 'Heading1'),
paragraph('El bloque siguiente reproduce información proveniente del Acta sellada. Debe conservarse sin alterar su sentido ni sustituir los Hallazgos originales.'),
labelValue('Resumen del Acta', text(snapshot.act.summary)),
labelValue('Observaciones del Acta', text(snapshot.act.observations)),
labelValue('Responsable de empresa', text(snapshot.responsible.fullName)),
labelValue('Cargo', text(snapshot.responsible.position)),
paragraph('Inventario inspeccionado', 'Heading1'),
inventoryRows.length
? table(['Código', 'Nombre', 'Tipo'], inventoryRows)
: paragraph('No se registraron elementos de Inventario en el Acta.'),
paragraph('Hallazgos', 'Heading1'),
findingRows.length ? table(['Código', 'Título', 'Descripción', 'Gravedad', 'Vencimiento'], findingRows) : paragraph('No se registraron hallazgos en la instantánea.'),
paragraph('Firmas y constancias', 'Heading1'),
signatureRows.length ? table(['Tipo', 'Firmante', 'Estado', 'Fecha'], signatureRows) : paragraph('No se registraron firmas en la instantánea.'),
paragraph('Integridad documental', 'Heading1'),
labelValue('Hash del informe', input.frozenSha256),
labelValue('Hash de cierre del acta', text(report.actClosureSha256)),
paragraph('El contenido de este archivo fue generado desde la instantánea congelada del acta. El diseño institucional y la firma final del Director se incorporarán en la etapa de revisión correspondiente.'),
findingRows.length
? table(['Código', 'Título', 'Descripción', 'Reincidencia', 'Gravedad'], findingRows)
: paragraph('El Acta no contiene Hallazgos.'),
paragraph('Firmas y manifestaciones', 'Heading1'),
signatureRows.length
? table(['Tipo', 'Firmante', 'Estado', 'Manifestación', 'Fecha'], signatureRows)
: paragraph('No se registraron firmas en la instantánea sellada.'),
paragraph('Integridad de la fuente', 'Heading1'),
labelValue('Hash de la fuente del INF', input.frozenSha256),
labelValue('Hash del Acta sellada', text(snapshot.source.actClosureSha256 ?? snapshot.sealedAct.finalSha256)),
labelValue('Hash del contenido bloqueado', text(snapshot.sealedAct.lockedSha256)),
paragraph('Este INF permanece editable mientras está en preparación. La edición del informe no modifica el Acta fuente ni los Hallazgos contenidos en ella. La versión oficial será la que se registre posteriormente en GEDO con su identificador IF y PDF oficial.'),
].join('');
return `<?xml version="1.0" encoding="UTF-8" standalone="yes"?><w:document xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main"><w:body>${body}<w:sectPr><w:pgSz w:w="11906" w:h="16838"/><w:pgMar w:top="1134" w:right="1134" w:bottom="1134" w:left="1134" w:header="708" w:footer="708" w:gutter="0"/></w:sectPr></w:body></w:document>`;
}
@@ -147,6 +193,7 @@ function buildZip(entries: ZipEntry[]): Buffer {
local.writeUInt16LE(name.length, 26);
local.writeUInt16LE(0, 28);
locals.push(local, name, entry.data);
const central = Buffer.alloc(46);
central.writeUInt32LE(0x02014b50, 0);
central.writeUInt16LE(20, 4);
@@ -168,6 +215,7 @@ function buildZip(entries: ZipEntry[]): Buffer {
centrals.push(central, name);
offset += local.length + name.length + entry.data.length;
}
const centralData = Buffer.concat(centrals);
const end = Buffer.alloc(22);
end.writeUInt32LE(0x06054b50, 0);
@@ -12,6 +12,8 @@ interface WordRow {
id: string;
code: string;
title: string;
executiveSummary: string | null;
reportDescription: string | null;
generatedAt: Date;
frozenSha256: string;
frozenSnapshot: Record<string, unknown>;
@@ -29,7 +31,8 @@ export class InspectionReportWordService {
private readonly root: string;
constructor(private readonly dataSource: DataSource, config: ConfigService) {
const configured = config.get<string>('INSPECTION_REPORT_WORD_ROOT') ?? '/app/storage/asset-media/inspection-reports-word';
const configured = config.get<string>('INSPECTION_REPORT_WORD_ROOT')
?? '/app/storage/asset-media/inspection-reports-word';
if (!isAbsolute(configured)) throw new Error('INSPECTION_REPORT_WORD_ROOT must be an absolute path');
this.root = resolve(configured);
if (this.root === parse(this.root).root) throw new Error('INSPECTION_REPORT_WORD_ROOT cannot be the filesystem root');
@@ -38,7 +41,11 @@ export class InspectionReportWordService {
async ensure(reportId: string): Promise<void> {
const row = await this.load(reportId);
if (row.wordStatus === 'READY' && row.wordStoredName) {
try { await this.content(row.id); await this.ensureInitialRevision(row); return; } catch {}
try {
await this.content(row.id);
await this.ensureInitialRevision(row);
return;
} catch {}
}
try {
const built = buildInspectionReportWord({
@@ -47,40 +54,51 @@ export class InspectionReportWordService {
generatedAt: new Date(row.generatedAt),
frozenSha256: row.frozenSha256,
frozenSnapshot: row.frozenSnapshot,
executiveSummary: row.executiveSummary,
reportDescription: row.reportDescription,
});
await mkdir(this.root, { recursive: true, mode: 0o700 });
const storedName = `${row.id}.docx`;
const storedName = `${row.id}-${built.sha256.slice(0, 16)}.docx`;
const originalName = `${row.code}.docx`;
const filePath = resolve(this.root, storedName);
await writeFile(filePath, built.buffer, { mode: 0o600 });
await this.dataSource.query(`
UPDATE inspection_reports
SET word_status = 'READY',
word_original_name = $2,
word_stored_name = $3,
word_mime_type = $4,
word_size_bytes = $5,
word_sha256 = $6,
word_generated_at = CURRENT_TIMESTAMP,
word_error = NULL,
updated_at = CURRENT_TIMESTAMP
WHERE id = $1
SET word_status='READY',
word_original_name=$2,
word_stored_name=$3,
word_mime_type=$4,
word_size_bytes=$5,
word_sha256=$6,
word_generated_at=CURRENT_TIMESTAMP,
word_error=NULL,
updated_at=CURRENT_TIMESTAMP
WHERE id=$1
`, [row.id, originalName, storedName, WORD_MIME, built.buffer.length, built.sha256]);
await this.ensureInitialRevision(await this.load(row.id));
} catch (error) {
const message = error instanceof Error ? error.message.slice(0, 500) : 'Error desconocido';
await this.dataSource.query(`
UPDATE inspection_reports
SET word_status = 'FAILED', word_error = $2, updated_at = CURRENT_TIMESTAMP
WHERE id = $1
SET word_status='FAILED',word_error=$2,updated_at=CURRENT_TIMESTAMP
WHERE id=$1
`, [row.id, message]).catch(() => undefined);
}
}
async content(reportId: string): Promise<{ filePath: string; originalName: string; mimeType: string }> {
const row = await this.load(reportId);
if (row.wordStatus !== 'READY' || !row.wordStoredName || !row.wordOriginalName || !row.wordSha256 || !row.wordSizeBytes) {
throw new NotFoundException({ code: 'INSPECTION_REPORT_WORD_NOT_READY', message: 'El archivo Word del informe todavía no está disponible' });
if (
row.wordStatus !== 'READY'
|| !row.wordStoredName
|| !row.wordOriginalName
|| !row.wordSha256
|| !row.wordSizeBytes
) {
throw new NotFoundException({
code: 'INSPECTION_REPORT_WORD_NOT_READY',
message: 'El archivo Word del informe todavía no está disponible',
});
}
const filePath = resolve(this.root, row.wordStoredName);
if (!filePath.startsWith(`${this.root}/`)) throw this.storageError();
@@ -89,40 +107,78 @@ export class InspectionReportWordService {
const buffer = await readFile(filePath);
const sha256 = createHash('sha256').update(buffer).digest('hex');
if (sha256 !== row.wordSha256) throw this.storageError();
return { filePath, originalName: row.wordOriginalName, mimeType: row.wordMimeType ?? WORD_MIME };
return {
filePath,
originalName: row.wordOriginalName,
mimeType: row.wordMimeType ?? WORD_MIME,
};
}
private async ensureInitialRevision(row: WordRow): Promise<void> {
if (row.wordStatus !== 'READY' || !row.wordOriginalName || !row.wordStoredName || !row.wordMimeType || !row.wordSizeBytes || !row.wordSha256) return;
if (
row.wordStatus !== 'READY'
|| !row.wordOriginalName
|| !row.wordStoredName
|| !row.wordMimeType
|| !row.wordSizeBytes
|| !row.wordSha256
) return;
await this.dataSource.query(`
INSERT INTO inspection_report_revisions (
report_id, revision_number, source, original_name, stored_name, mime_type,
size_bytes, sha256, change_summary, created_by, created_at
) VALUES ($1,1,'AUTO',$2,$3,$4,$5,$6,'Versión automática inicial',$7,COALESCE((SELECT word_generated_at FROM inspection_reports WHERE id=$1),CURRENT_TIMESTAMP))
ON CONFLICT (report_id, revision_number) DO NOTHING
`, [row.id, row.wordOriginalName, row.wordStoredName, row.wordMimeType, row.wordSizeBytes, row.wordSha256, row.generatedBy]);
report_id,revision_number,source,original_name,stored_name,mime_type,
size_bytes,sha256,change_summary,created_by,created_at
) VALUES (
$1,1,'AUTO',$2,$3,$4,$5,$6,'Versión automática inicial editable',$7,
COALESCE((SELECT word_generated_at FROM inspection_reports WHERE id=$1),CURRENT_TIMESTAMP)
)
ON CONFLICT (report_id,revision_number) DO NOTHING
`, [
row.id,
row.wordOriginalName,
row.wordStoredName,
row.wordMimeType,
row.wordSizeBytes,
row.wordSha256,
row.generatedBy,
]);
await this.dataSource.query(`
UPDATE inspection_reports
SET current_revision_number = GREATEST(current_revision_number, 1), updated_at = CURRENT_TIMESTAMP
WHERE id = $1
SET current_revision_number=GREATEST(current_revision_number,1),updated_at=CURRENT_TIMESTAMP
WHERE id=$1
`, [row.id]);
}
private async load(reportId: string): Promise<WordRow> {
const [row] = await this.dataSource.query(`
SELECT id, code, title, generated_at AS "generatedAt", frozen_sha256 AS "frozenSha256",
frozen_snapshot AS "frozenSnapshot", word_status AS "wordStatus",
word_original_name AS "wordOriginalName", word_stored_name AS "wordStoredName",
word_mime_type AS "wordMimeType", word_size_bytes::integer AS "wordSizeBytes",
word_sha256 AS "wordSha256", generated_by AS "generatedBy"
FROM inspection_reports WHERE id = $1
SELECT id,code,title,
executive_summary AS "executiveSummary",
report_description AS "reportDescription",
generated_at AS "generatedAt",
frozen_sha256 AS "frozenSha256",
frozen_snapshot AS "frozenSnapshot",
word_status AS "wordStatus",
word_original_name AS "wordOriginalName",
word_stored_name AS "wordStoredName",
word_mime_type AS "wordMimeType",
word_size_bytes::integer AS "wordSizeBytes",
word_sha256 AS "wordSha256",
generated_by AS "generatedBy"
FROM inspection_reports
WHERE id=$1
`, [reportId]) as WordRow[];
if (!row) throw new NotFoundException({ code: 'INSPECTION_REPORT_NOT_FOUND', message: 'Informe de inspección no encontrado' });
if (!row) {
throw new NotFoundException({
code: 'INSPECTION_REPORT_NOT_FOUND',
message: 'Informe de inspección no encontrado',
});
}
return row;
}
private storageError(): InternalServerErrorException {
return new InternalServerErrorException({ code: 'INSPECTION_REPORT_WORD_STORAGE_ERROR', message: 'El archivo Word del informe no está disponible o no supera la validación de integridad' });
return new InternalServerErrorException({
code: 'INSPECTION_REPORT_WORD_STORAGE_ERROR',
message: 'El archivo Word del informe no está disponible o no supera la validación de integridad',
});
}
}
@@ -0,0 +1,428 @@
import { createHash, randomUUID } from 'node:crypto';
import { mkdir, readFile, stat, unlink, writeFile } from 'node:fs/promises';
import { isAbsolute, parse, resolve } from 'node:path';
import {
BadRequestException,
ConflictException,
Injectable,
InternalServerErrorException,
NotFoundException,
} from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { DataSource, EntityManager } from 'typeorm';
import { administrationAuditContext } from '../administration/common/administration-audit';
import { AuditService } from '../audit/audit.service';
import type { AuthPrincipal, RequestWithContext } from '../common/http/request-context';
import {
AuditAction,
InspectionReportStatus,
} from '../database/entities';
import type { CreateInspectionReportFollowUpDto } from './dto/create-inspection-report-follow-up.dto';
import type { OfficializeInspectionReportDto } from './dto/officialize-inspection-report.dto';
import type { UpdateInspectionReportDto } from './dto/update-inspection-report.dto';
export const MAX_INSPECTION_REPORT_FILE_BYTES = 40 * 1024 * 1024;
export interface UploadedInspectionReportFile {
buffer: Buffer;
originalname: string;
mimetype: string;
size: number;
}
interface ReportRow {
id: string;
actId: string;
visitId: string;
code: string;
status: InspectionReportStatus;
executiveSummary: string | null;
reportDescription: string | null;
gedoIfIdentifier: string | null;
gedoOfficializedAt: Date | null;
}
function reportNotFound(): NotFoundException {
return new NotFoundException({
code: 'INSPECTION_REPORT_NOT_FOUND',
message: 'Informe de inspección no encontrado',
});
}
@Injectable()
export class InspectionReportWorkflowService {
private readonly root: string;
constructor(
private readonly dataSource: DataSource,
private readonly audit: AuditService,
config: ConfigService,
) {
const configured = config.get<string>('INSPECTION_REPORT_UPLOAD_ROOT')
?? '/app/storage/asset-media/inspection-reports';
if (!isAbsolute(configured)) throw new Error('INSPECTION_REPORT_UPLOAD_ROOT must be absolute');
this.root = resolve(configured);
if (this.root === parse(this.root).root) {
throw new Error('INSPECTION_REPORT_UPLOAD_ROOT cannot be filesystem root');
}
}
async updateNarrative(
reportId: string,
dto: UpdateInspectionReportDto,
principal: AuthPrincipal,
request: RequestWithContext,
) {
if (Object.keys(dto).length === 0) {
throw new BadRequestException({ code: 'NO_CHANGES', message: 'No se recibieron cambios' });
}
return this.dataSource.transaction(async (manager) => {
const report = await this.lockReport(manager, reportId);
if (report.status !== InspectionReportStatus.WORKING) {
throw new ConflictException({
code: 'INSPECTION_REPORT_ALREADY_OFFICIALIZED',
message: 'El contenido editable del INF queda cerrado cuando se registra su IF oficial de GEDO',
});
}
const before = {
executiveSummary: report.executiveSummary,
reportDescription: report.reportDescription,
};
const executiveSummary = dto.executiveSummary !== undefined
? dto.executiveSummary
: report.executiveSummary;
const reportDescription = dto.description !== undefined
? dto.description
: report.reportDescription;
await manager.query(`
UPDATE inspection_reports
SET executive_summary=$2,
report_description=$3,
word_status=CASE
WHEN executive_summary IS DISTINCT FROM $2 OR report_description IS DISTINCT FROM $3
THEN 'PENDING' ELSE word_status END,
word_original_name=CASE
WHEN executive_summary IS DISTINCT FROM $2 OR report_description IS DISTINCT FROM $3
THEN NULL ELSE word_original_name END,
word_stored_name=CASE
WHEN executive_summary IS DISTINCT FROM $2 OR report_description IS DISTINCT FROM $3
THEN NULL ELSE word_stored_name END,
word_mime_type=CASE
WHEN executive_summary IS DISTINCT FROM $2 OR report_description IS DISTINCT FROM $3
THEN NULL ELSE word_mime_type END,
word_size_bytes=CASE
WHEN executive_summary IS DISTINCT FROM $2 OR report_description IS DISTINCT FROM $3
THEN NULL ELSE word_size_bytes END,
word_sha256=CASE
WHEN executive_summary IS DISTINCT FROM $2 OR report_description IS DISTINCT FROM $3
THEN NULL ELSE word_sha256 END,
word_generated_at=CASE
WHEN executive_summary IS DISTINCT FROM $2 OR report_description IS DISTINCT FROM $3
THEN NULL ELSE word_generated_at END,
word_error=CASE
WHEN executive_summary IS DISTINCT FROM $2 OR report_description IS DISTINCT FROM $3
THEN NULL ELSE word_error END,
updated_at=CURRENT_TIMESTAMP
WHERE id=$1
`, [reportId, executiveSummary, reportDescription]);
await this.audit.record({
...administrationAuditContext(principal, request),
action: AuditAction.INSPECTION_REPORT_UPDATED,
entityType: 'inspection_report',
entityId: reportId,
beforeData: before,
afterData: { executiveSummary, reportDescription },
metadata: { reportCode: report.code, actId: report.actId },
}, manager);
return this.getWorkflowView(manager, reportId);
});
}
async officialize(
reportId: string,
dto: OfficializeInspectionReportDto,
file: UploadedInspectionReportFile | undefined,
principal: AuthPrincipal,
request: RequestWithContext,
) {
this.assertPdf(file);
const id = randomUUID();
const storedName = `gedo-${id}.pdf`;
const filePath = resolve(this.root, storedName);
const sha256 = createHash('sha256').update(file!.buffer).digest('hex');
await mkdir(this.root, { recursive: true, mode: 0o700 });
await writeFile(filePath, file!.buffer, { flag: 'wx', mode: 0o600 });
try {
return await this.dataSource.transaction(async (manager) => {
const report = await this.lockReport(manager, reportId);
if (report.status !== InspectionReportStatus.WORKING) {
throw new ConflictException({
code: 'INSPECTION_REPORT_ALREADY_OFFICIALIZED',
message: 'El IF oficial de GEDO ya fue registrado y es inmutable',
});
}
const officializedAt = new Date(dto.gedoOfficializedAt);
const now = new Date();
if (officializedAt.getTime() > now.getTime() + 24 * 60 * 60 * 1000) {
throw new BadRequestException({
code: 'INVALID_GEDO_DATE',
message: 'La fecha de GEDO no puede estar más de 24 horas en el futuro',
});
}
await manager.query(`
UPDATE inspection_reports
SET status='OFFICIALIZED',
gedo_if_identifier=$2,
gedo_officialized_at=$3,
gedo_pdf_original_name=$4,
gedo_pdf_stored_name=$5,
gedo_pdf_mime_type='application/pdf',
gedo_pdf_size_bytes=$6,
gedo_pdf_sha256=$7,
updated_at=CURRENT_TIMESTAMP
WHERE id=$1
`, [
reportId,
dto.gedoIfIdentifier,
officializedAt,
file!.originalname,
storedName,
file!.buffer.length,
sha256,
]);
// GEDO oficializa el INF, pero no equivale por sí solo a la notificación
// administrativa de un Acta no urgente. El vencimiento queda pendiente
// hasta que el procedimiento defina y registre el evento de notificación.
await this.audit.record({
...administrationAuditContext(principal, request),
action: AuditAction.INSPECTION_REPORT_OFFICIALIZED,
entityType: 'inspection_report',
entityId: reportId,
afterData: {
status: InspectionReportStatus.OFFICIALIZED,
gedoIfIdentifier: dto.gedoIfIdentifier,
gedoOfficializedAt: officializedAt,
gedoPdfOriginalName: file!.originalname,
gedoPdfSha256: sha256,
},
metadata: {
reportCode: report.code,
actId: report.actId,
immutable: true,
deadlineActivation: 'PENDING_NOTIFICATION_EVENT',
},
}, manager);
return this.getWorkflowView(manager, reportId);
});
} catch (error) {
await unlink(filePath).catch(() => undefined);
throw error;
}
}
async officialPdfContent(reportId: string): Promise<{ filePath: string; originalName: string; mimeType: string }> {
const [row] = await this.dataSource.query(`
SELECT
gedo_pdf_original_name AS "originalName",
gedo_pdf_stored_name AS "storedName",
gedo_pdf_mime_type AS "mimeType",
gedo_pdf_size_bytes::integer AS "sizeBytes",
gedo_pdf_sha256 AS sha256
FROM inspection_reports
WHERE id=$1
`, [reportId]) as Array<{
originalName: string | null;
storedName: string | null;
mimeType: string | null;
sizeBytes: number | null;
sha256: string | null;
}>;
if (!row) throw reportNotFound();
if (!row.originalName || !row.storedName || !row.sizeBytes || !row.sha256) {
throw new NotFoundException({
code: 'INSPECTION_REPORT_GEDO_PDF_NOT_AVAILABLE',
message: 'El PDF oficial de GEDO todavía no está disponible',
});
}
const filePath = resolve(this.root, row.storedName);
if (filePath === this.root || !filePath.startsWith(`${this.root}/`)) throw this.reportStorageError();
const fileStat = await stat(filePath).catch(() => null);
if (!fileStat?.isFile() || fileStat.size !== row.sizeBytes) throw this.reportStorageError();
const buffer = await readFile(filePath);
const sha256 = createHash('sha256').update(buffer).digest('hex');
if (sha256 !== row.sha256) throw this.reportStorageError();
return {
filePath,
originalName: row.originalName,
mimeType: row.mimeType ?? 'application/pdf',
};
}
async addFollowUp(
reportId: string,
dto: CreateInspectionReportFollowUpDto,
file: UploadedInspectionReportFile | undefined,
principal: AuthPrincipal,
request: RequestWithContext,
) {
if (file && file.buffer.length > MAX_INSPECTION_REPORT_FILE_BYTES) {
throw new BadRequestException({
code: 'INSPECTION_REPORT_FILE_TOO_LARGE',
message: 'El archivo supera el máximo permitido de 40 MB',
});
}
const followUpId = randomUUID();
const ext = this.safeExtension(file?.originalname);
const storedName = file ? `followup-${followUpId}${ext}` : null;
const filePath = storedName ? resolve(this.root, storedName) : null;
const sha256 = file ? createHash('sha256').update(file.buffer).digest('hex') : null;
if (filePath && file) {
await mkdir(this.root, { recursive: true, mode: 0o700 });
await writeFile(filePath, file.buffer, { flag: 'wx', mode: 0o600 });
}
try {
return await this.dataSource.transaction(async (manager) => {
const report = await this.lockReport(manager, reportId);
const occurredAt = new Date(dto.occurredAt);
await manager.query(`
INSERT INTO inspection_report_follow_ups (
id,report_id,type,external_reference,occurred_at,description,
original_name,stored_name,mime_type,size_bytes,sha256,created_by
) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12)
`, [
followUpId,
reportId,
dto.type,
dto.externalReference ?? null,
occurredAt,
dto.description ?? null,
file?.originalname ?? null,
storedName,
file?.mimetype ?? null,
file?.buffer.length ?? null,
sha256,
principal.userId,
]);
await this.audit.record({
...administrationAuditContext(principal, request),
action: AuditAction.INSPECTION_REPORT_FOLLOW_UP_ADDED,
entityType: 'inspection_report_follow_up',
entityId: followUpId,
afterData: {
reportId,
type: dto.type,
externalReference: dto.externalReference ?? null,
occurredAt,
description: dto.description ?? null,
originalName: file?.originalname ?? null,
sha256,
},
metadata: { reportCode: report.code, actId: report.actId, appendOnly: true },
}, manager);
return this.listFollowUpsWithManager(manager, reportId);
});
} catch (error) {
if (filePath) await unlink(filePath).catch(() => undefined);
throw error;
}
}
async listFollowUps(reportId: string) {
const report = await this.getReport(this.dataSource.manager, reportId);
if (!report) throw reportNotFound();
return this.listFollowUpsWithManager(this.dataSource.manager, reportId);
}
private async listFollowUpsWithManager(manager: EntityManager, reportId: string) {
return manager.query(`
SELECT
follow_up.id,
follow_up.report_id AS "reportId",
follow_up.type,
follow_up.external_reference AS "externalReference",
follow_up.occurred_at AS "occurredAt",
follow_up.description,
follow_up.original_name AS "originalName",
follow_up.mime_type AS "mimeType",
follow_up.size_bytes AS "sizeBytes",
follow_up.sha256,
follow_up.created_by AS "createdBy",
follow_up.created_at AS "createdAt"
FROM inspection_report_follow_ups follow_up
WHERE follow_up.report_id=$1
ORDER BY follow_up.occurred_at,follow_up.created_at,follow_up.id
`, [reportId]);
}
private reportStorageError(): InternalServerErrorException {
return new InternalServerErrorException({
code: 'INSPECTION_REPORT_GEDO_PDF_STORAGE_ERROR',
message: 'El PDF oficial de GEDO no está disponible o no supera la validación de integridad',
});
}
private assertPdf(file: UploadedInspectionReportFile | undefined): void {
if (!file?.buffer?.length) {
throw new BadRequestException({
code: 'GEDO_PDF_REQUIRED',
message: 'Debe adjuntarse el PDF oficial generado por GEDO',
});
}
if (file.buffer.length > MAX_INSPECTION_REPORT_FILE_BYTES) {
throw new BadRequestException({
code: 'GEDO_PDF_TOO_LARGE',
message: 'El PDF oficial supera el máximo permitido de 40 MB',
});
}
if (file.mimetype !== 'application/pdf' || file.buffer.subarray(0, 5).toString('ascii') !== '%PDF-') {
throw new BadRequestException({
code: 'INVALID_GEDO_PDF',
message: 'El documento oficial de GEDO debe ser un PDF válido',
});
}
}
private safeExtension(name: string | undefined): string {
if (!name) return '';
const dot = name.lastIndexOf('.');
if (dot < 0) return '';
const ext = name.slice(dot).toLowerCase();
return /^\.[a-z0-9]{1,10}$/.test(ext) ? ext : '';
}
private async lockReport(manager: EntityManager, id: string): Promise<ReportRow> {
const [row] = await manager.query(`
SELECT id,act_id AS "actId",visit_id AS "visitId",code,status,
executive_summary AS "executiveSummary",report_description AS "reportDescription",
gedo_if_identifier AS "gedoIfIdentifier",gedo_officialized_at AS "gedoOfficializedAt"
FROM inspection_reports WHERE id=$1 FOR UPDATE
`, [id]) as ReportRow[];
if (!row) throw reportNotFound();
return row;
}
private async getReport(manager: EntityManager, id: string): Promise<ReportRow | null> {
const [row] = await manager.query(`
SELECT id,act_id AS "actId",visit_id AS "visitId",code,status,
executive_summary AS "executiveSummary",report_description AS "reportDescription",
gedo_if_identifier AS "gedoIfIdentifier",gedo_officialized_at AS "gedoOfficializedAt"
FROM inspection_reports WHERE id=$1
`, [id]) as ReportRow[];
return row ?? null;
}
private async getWorkflowView(manager: EntityManager, id: string) {
const report = await this.getReport(manager, id);
if (!report) throw reportNotFound();
const [act] = await manager.query(`
SELECT code,urgency,deadline_days AS "deadlineDays",deadline_day_type AS "deadlineDayType",
deadline_basis AS "deadlineBasis",deadline_base_at AS "deadlineBaseAt",deadline_at AS "deadlineAt"
FROM inspection_acts WHERE id=$1
`, [report.actId]);
return {
...report,
act,
followUps: await this.listFollowUpsWithManager(manager, id),
};
}
}
@@ -1,15 +1,41 @@
import { Controller, Get, Param, ParseUUIDPipe, Post, Query, Req, Res } from '@nestjs/common';
import {
Body,
Controller,
Get,
Param,
ParseUUIDPipe,
Patch,
Post,
Query,
Req,
Res,
UploadedFile,
UseInterceptors,
} from '@nestjs/common';
import { FileInterceptor } from '@nestjs/platform-express';
import { CurrentAuth } from '../auth/decorators/current-auth.decorator';
import { RequirePermissions } from '../authorization/decorators/require-permissions.decorator';
import type { Response } from 'express';
import type { AuthPrincipal, RequestWithContext } from '../common/http/request-context';
import { CreateInspectionReportFollowUpDto } from './dto/create-inspection-report-follow-up.dto';
import { ListInspectionReportsQueryDto } from './dto/list-inspection-reports-query.dto';
import { OfficializeInspectionReportDto } from './dto/officialize-inspection-report.dto';
import { UpdateInspectionReportDto } from './dto/update-inspection-report.dto';
import {
InspectionReportWorkflowService,
MAX_INSPECTION_REPORT_FILE_BYTES,
type UploadedInspectionReportFile,
} from './inspection-report-workflow.service';
import { InspectionReportsService } from './inspection-reports.service';
import { InspectionReportWordService } from './inspection-report-word.service';
@Controller('inspection-reports')
export class InspectionReportsController {
constructor(private readonly reports: InspectionReportsService, private readonly word: InspectionReportWordService) {}
constructor(
private readonly reports: InspectionReportsService,
private readonly word: InspectionReportWordService,
private readonly workflow: InspectionReportWorkflowService,
) {}
@Get()
@RequirePermissions('inspection_reports.read')
@@ -42,6 +68,65 @@ export class InspectionReportsController {
return response.sendFile(content.filePath);
}
@Get(':id/gedo-pdf')
@RequirePermissions('inspection_reports.read')
async gedoPdfContent(
@Param('id', new ParseUUIDPipe({ version: '4' })) id: string,
@Res() response: Response,
) {
const content = await this.workflow.officialPdfContent(id);
response.setHeader('Content-Type', content.mimeType);
response.setHeader('Content-Disposition', `attachment; filename="${content.originalName.replaceAll('"', '')}"`);
return response.sendFile(content.filePath);
}
@Patch(':id')
@RequirePermissions('inspection_reports.generate')
updateNarrative(
@Param('id', new ParseUUIDPipe({ version: '4' })) id: string,
@Body() dto: UpdateInspectionReportDto,
@CurrentAuth() principal: AuthPrincipal,
@Req() request: RequestWithContext,
) {
return this.workflow.updateNarrative(id, dto, principal, request);
}
@Post(':id/officialize')
@RequirePermissions('inspection_reports.generate')
@UseInterceptors(FileInterceptor('file', {
limits: { fileSize: MAX_INSPECTION_REPORT_FILE_BYTES, files: 1 },
}))
officialize(
@Param('id', new ParseUUIDPipe({ version: '4' })) id: string,
@Body() dto: OfficializeInspectionReportDto,
@UploadedFile() file: UploadedInspectionReportFile | undefined,
@CurrentAuth() principal: AuthPrincipal,
@Req() request: RequestWithContext,
) {
return this.workflow.officialize(id, dto, file, principal, request);
}
@Get(':id/follow-ups')
@RequirePermissions('inspection_reports.read')
followUps(@Param('id', new ParseUUIDPipe({ version: '4' })) id: string) {
return this.workflow.listFollowUps(id);
}
@Post(':id/follow-ups')
@RequirePermissions('inspection_reports.generate')
@UseInterceptors(FileInterceptor('file', {
limits: { fileSize: MAX_INSPECTION_REPORT_FILE_BYTES, files: 1 },
}))
addFollowUp(
@Param('id', new ParseUUIDPipe({ version: '4' })) id: string,
@Body() dto: CreateInspectionReportFollowUpDto,
@UploadedFile() file: UploadedInspectionReportFile | undefined,
@CurrentAuth() principal: AuthPrincipal,
@Req() request: RequestWithContext,
) {
return this.workflow.addFollowUp(id, dto, file, principal, request);
}
@Get(':id')
@RequirePermissions('inspection_reports.read')
get(@Param('id', new ParseUUIDPipe({ version: '4' })) id: string) {
@@ -2,9 +2,10 @@ import { Module } from '@nestjs/common';
import { AuditModule } from '../audit/audit.module';
import { DocumentDeliveryController } from './document-delivery.controller';
import { InspectionActPdfService } from './inspection-act-pdf.service';
import { InspectionDeadlineAdminController } from './inspection-deadline-admin.controller';
import { InspectionDeadlineAdminService } from './inspection-deadline-admin.service';
import { InspectionDocumentDeliveryService } from './inspection-document-delivery.service';
import { InspectionReportReviewController, InspectionReportRevisionContentController } from './inspection-report-review.controller';
import { InspectionReportReviewService } from './inspection-report-review.service';
import { InspectionReportWorkflowService } from './inspection-report-workflow.service';
import { InspectionReportWordService } from './inspection-report-word.service';
import { InspectionActReportController, InspectionReportsController } from './inspection-reports.controller';
import { InspectionReportsService } from './inspection-reports.service';
@@ -15,18 +16,18 @@ import { SmtpDeliveryService } from './smtp-delivery.service';
controllers: [
InspectionReportsController,
InspectionActReportController,
InspectionDeadlineAdminController,
DocumentDeliveryController,
InspectionReportReviewController,
InspectionReportRevisionContentController,
],
providers: [
InspectionReportsService,
InspectionReportWorkflowService,
InspectionReportWordService,
InspectionActPdfService,
InspectionDeadlineAdminService,
InspectionDocumentDeliveryService,
SmtpDeliveryService,
InspectionReportReviewService,
],
exports: [InspectionReportsService],
exports: [InspectionReportsService, SmtpDeliveryService],
})
export class InspectionReportsModule {}
@@ -5,7 +5,6 @@ import { AuditService } from '../audit/audit.service';
import type { AuthPrincipal, RequestWithContext } from '../common/http/request-context';
import {
AuditAction,
DocumentSequenceType,
InspectionActStatus,
InspectionReportPdfStatus,
InspectionReportStatus,
@@ -16,7 +15,7 @@ import type { ListInspectionReportsQueryDto } from './dto/list-inspection-report
import { InspectionReportWordService } from './inspection-report-word.service';
import { InspectionDocumentDeliveryService } from './inspection-document-delivery.service';
const REPORT_SCHEMA_VERSION = 'DH-INSPECTION-REPORT-V1';
const REPORT_SCHEMA_VERSION = 'DH-INSPECTION-INF-V4';
interface ContextAsset {
id: string;
@@ -32,21 +31,32 @@ export interface InspectionReportListItem {
reportNumber: number;
code: string;
status: InspectionReportStatus;
executiveSummary: string | null;
reportDescription: string | null;
gedoIfIdentifier: string | null;
gedoOfficializedAt: Date | null;
gedoPdfOriginalName: string | null;
gedoPdfSha256: string | null;
pdfStatus: InspectionReportPdfStatus;
wordStatus: 'PENDING' | 'READY' | 'FAILED';
wordGeneratedAt: Date | null;
reviewStatus: 'PENDING_REVIEW' | 'APPROVED' | 'SIGNED';
currentRevisionNumber: number;
approvedAt: Date | null;
signedAt: Date | null;
title: string;
actVersion: number;
actClosureSha256: string;
frozenSha256: string;
generatedAt: Date;
generatedBy: { id: string; username: string; firstName: string; lastName: string };
act: { id: string; code: string; title: string; status: string; occurredAt: Date; closedAt: Date | null };
visit: { id: string; code: string; title: string; status: string };
act: {
id: string;
code: string;
title: string;
status: string;
occurredAt: Date;
sealedAt: Date | null;
urgency: string;
deadlineAt: Date | null;
};
visit: { id: string; code: string; status: string };
companies: ContextAsset[];
areas: ContextAsset[];
findingCount: number;
@@ -63,10 +73,9 @@ export interface PendingInspectionReportItem {
actTitle: string;
actYear: number;
occurredAt: Date;
closedAt: Date;
sealedAt: Date;
closureSha256: string;
visitCode: string;
visitTitle: string;
companies: ContextAsset[];
areas: ContextAsset[];
findingCount: number;
@@ -127,7 +136,7 @@ export class InspectionReportsService {
async listPending(query: ListInspectionReportsQueryDto) {
const conditions = [
`act.status = 'CLOSED'`,
`act.status = 'SEALED'`,
'report.id IS NULL',
'act.closure_sha256 IS NOT NULL',
];
@@ -142,22 +151,6 @@ export class InspectionReportsService {
act.code ILIKE ${search}
OR act.title ILIKE ${search}
OR visit.code ILIKE ${search}
OR visit.title ILIKE ${search}
OR EXISTS (
SELECT 1
FROM inspection_act_assets search_link
INNER JOIN assets search_asset ON search_asset.id = search_link.asset_id
LEFT JOIN assets search_company ON search_company.id = search_asset.operator_company_id
LEFT JOIN assets search_area ON search_area.id = search_asset.operational_area_id
WHERE search_link.act_id = act.id
AND search_link.included = true
AND (
search_asset.name ILIKE ${search}
OR search_asset.code ILIKE ${search}
OR search_company.name ILIKE ${search}
OR search_area.name ILIKE ${search}
)
)
)`);
}
if (query.year) conditions.push(`act.act_year = ${add(query.year)}`);
@@ -196,10 +189,9 @@ export class InspectionReportsService {
act.title AS "actTitle",
act.act_year AS "actYear",
act.occurred_at AS "occurredAt",
act.closed_at AS "closedAt",
act.sealed_at AS "sealedAt",
act.closure_sha256 AS "closureSha256",
visit.code AS "visitCode",
visit.title AS "visitTitle",
COALESCE(context.companies, '[]'::jsonb) AS companies,
COALESCE(context.areas, '[]'::jsonb) AS areas,
COALESCE(finding_count.total, 0)::integer AS "findingCount"
@@ -226,16 +218,7 @@ export class InspectionReportsService {
}
async get(id: string): Promise<InspectionReportView> {
const [report] = (await this.dataSource.query(
`${this.reportSelect('WHERE report.id = $1')}`,
[id],
)) as InspectionReportListItem[];
if (!report) throw reportNotFound();
const [snapshot] = (await this.dataSource.query(
'SELECT frozen_snapshot AS "frozenSnapshot" FROM inspection_reports WHERE id = $1',
[id],
)) as Array<{ frozenSnapshot: Record<string, unknown> }>;
return { ...report, frozenSnapshot: snapshot.frozenSnapshot };
return this.getWithManager(this.dataSource.manager, id);
}
async generate(
@@ -244,144 +227,144 @@ export class InspectionReportsService {
request: RequestWithContext,
): Promise<InspectionReportView> {
assertMobileInspector(principal);
const report = await this.dataSource.transaction(async (manager) => this.ensureFrozenReport(manager, actId, principal, request));
const report = await this.dataSource.transaction((manager) =>
this.ensureFrozenReport(manager, actId, principal, request),
);
await this.word.ensure(report.id);
return this.get(report.id);
}
/**
* Conserva una copia interna inmutable del Acta sellada como fuente documental.
* El INF que se genera desde esa fuente permanece editable y versionable.
*/
async ensureFrozenReport(
manager: EntityManager,
actId: string,
principal: AuthPrincipal,
request: RequestWithContext,
): Promise<InspectionReportView> {
const [act] = (await manager.query(`
SELECT
act.id,
act.visit_id AS "visitId",
act.act_year AS "actYear",
act.code,
act.title,
act.status,
act.current_version AS "currentVersion",
act.closure_sha256 AS "closureSha256",
visit.code AS "visitCode",
visit.title AS "visitTitle"
FROM inspection_acts act
INNER JOIN inspection_visits visit ON visit.id = act.visit_id
WHERE act.id = $1
FOR UPDATE OF act
`, [actId])) as Array<{
id: string;
visitId: string;
actYear: number;
code: string;
title: string;
status: InspectionActStatus;
currentVersion: number;
closureSha256: string | null;
visitCode: string;
visitTitle: string;
}>;
if (!act) throw actNotFound();
if (act.status !== InspectionActStatus.CLOSED || !act.closureSha256) {
throw new ConflictException({
code: 'INSPECTION_REPORT_ACT_NOT_CLOSED',
message: 'El informe sólo puede emitirse después del cierre definitivo del acta',
});
}
await this.assertActorAssigned(manager, act.visitId, principal.userId);
const [existing] = (await manager.query(
'SELECT id FROM inspection_reports WHERE act_id = $1',
[actId],
)) as Array<{ id: string }>;
if (existing) return this.getWithManager(manager, existing.id);
const [closure] = (await manager.query(`
SELECT final_snapshot AS "finalSnapshot", final_sha256 AS "finalSha256"
FROM inspection_act_closures
WHERE act_id = $1
`, [actId])) as Array<{
finalSnapshot: Record<string, unknown> | null;
finalSha256: string | null;
}>;
if (!closure?.finalSnapshot || closure.finalSha256 !== act.closureSha256) {
throw new ConflictException({
code: 'INSPECTION_REPORT_CLOSURE_NOT_FROZEN',
message: 'El cierre del acta no tiene una instantánea final válida para emitir el informe',
});
}
const reportNumber = await this.allocateNumber(manager, act.actYear);
if (reportNumber > 999999) {
throw new ConflictException({
code: 'INSPECTION_REPORT_SEQUENCE_EXHAUSTED',
message: 'La numeración anual de informes agotó su rango disponible',
});
}
const code = `INF-${act.actYear}-${String(reportNumber).padStart(6, '0')}`;
const generatedAt = new Date();
const title = `Informe técnico · ${act.title}`.slice(0, 220);
const frozenSnapshot = {
schemaVersion: REPORT_SCHEMA_VERSION,
report: {
code,
reportYear: act.actYear,
reportNumber,
generatedAt: generatedAt.toISOString(),
generatedBy: principal.userId,
generatedByUsername: principal.username,
visitId: act.visitId,
visitCode: act.visitCode,
visitTitle: act.visitTitle,
actId: act.id,
actCode: act.code,
actVersion: act.currentVersion,
actClosureSha256: act.closureSha256,
},
actClosure: closure.finalSnapshot,
};
const frozenSha256 = sha256CanonicalJson(frozenSnapshot);
const [created] = (await manager.query(`
INSERT INTO inspection_reports (
visit_id, act_id, report_year, report_number, code, status, pdf_status,
title, act_version, act_closure_sha256, frozen_sha256, frozen_snapshot,
generated_at, generated_by
) VALUES (
$1, $2, $3, $4, $5, 'FROZEN', 'PENDING', $6, $7, $8, $9, $10, $11, $12
)
RETURNING id
`, [
act.visitId,
const [act] = (await manager.query(`
SELECT
act.id,
act.actYear,
reportNumber,
act.visit_id AS "visitId",
act.act_year AS "actYear",
act.act_number AS "actNumber",
act.code,
act.title,
act.status,
act.current_version AS "currentVersion",
act.closure_sha256 AS "closureSha256",
act.occurred_at AS "occurredAt",
visit.code AS "visitCode"
FROM inspection_acts act
INNER JOIN inspection_visits visit ON visit.id = act.visit_id
WHERE act.id = $1
FOR UPDATE OF act
`, [actId])) as Array<{
id: string;
visitId: string;
actYear: number;
actNumber: number;
code: string;
title: string;
status: InspectionActStatus;
currentVersion: number;
closureSha256: string | null;
occurredAt: Date;
visitCode: string;
}>;
if (!act) throw actNotFound();
if (act.status !== InspectionActStatus.SEALED || !act.closureSha256) {
throw new ConflictException({
code: 'INSPECTION_REPORT_ACT_NOT_SEALED',
message: 'El INF sólo puede generarse después de sellar el acta',
});
}
await this.assertActorAssigned(manager, act.visitId, principal.userId);
const [existing] = (await manager.query(
'SELECT id FROM inspection_reports WHERE act_id = $1',
[actId],
)) as Array<{ id: string }>;
if (existing) return this.getWithManager(manager, existing.id);
const [closure] = (await manager.query(`
SELECT final_snapshot AS "finalSnapshot", final_sha256 AS "finalSha256"
FROM inspection_act_closures
WHERE act_id = $1
`, [actId])) as Array<{
finalSnapshot: Record<string, unknown> | null;
finalSha256: string | null;
}>;
if (!closure?.finalSnapshot || closure.finalSha256 !== act.closureSha256) {
throw new ConflictException({
code: 'INSPECTION_REPORT_ACT_SNAPSHOT_INVALID',
message: 'El acta sellada no tiene una instantánea final válida',
});
}
const reportNumber = act.actNumber;
const code = act.code.replace(/^ACT-/, 'INF-');
const generatedAt = new Date();
const title = `Informe de inspección · ${act.code}`.slice(0, 220);
const frozenSnapshot = {
schemaVersion: REPORT_SCHEMA_VERSION,
source: {
actId: act.id,
actCode: act.code,
actVersion: act.currentVersion,
actClosureSha256: act.closureSha256,
inspectionId: act.visitId,
inspectionCode: act.visitCode,
},
sealedAct: closure.finalSnapshot,
};
const frozenSha256 = sha256CanonicalJson(frozenSnapshot);
const [created] = (await manager.query(`
INSERT INTO inspection_reports (
visit_id,act_id,report_year,report_number,code,status,pdf_status,
executive_summary,report_description,title,act_version,act_closure_sha256,
frozen_sha256,frozen_snapshot,generated_at,generated_by
) VALUES (
$1,$2,$3,$4,$5,'WORKING','PENDING',NULL,NULL,$6,$7,$8,$9,$10,$11,$12
)
RETURNING id
`, [
act.visitId,
act.id,
act.actYear,
reportNumber,
code,
title,
act.currentVersion,
act.closureSha256,
frozenSha256,
frozenSnapshot,
generatedAt,
principal.userId,
])) as Array<{ id: string }>;
await this.audit.record({
...administrationAuditContext(principal, request),
action: AuditAction.INSPECTION_REPORT_GENERATED,
entityType: 'inspection_report',
entityId: created.id,
afterData: {
code,
title,
act.currentVersion,
act.closureSha256,
actId: act.id,
inspectionId: act.visitId,
actVersion: act.currentVersion,
frozenSha256,
frozenSnapshot,
generatedAt,
principal.userId,
])) as Array<{ id: string }>;
await this.audit.record({
...administrationAuditContext(principal, request),
action: AuditAction.INSPECTION_REPORT_GENERATED,
entityType: 'inspection_report',
entityId: created.id,
afterData: {
code,
actId: act.id,
visitId: act.visitId,
actVersion: act.currentVersion,
frozenSha256,
pdfStatus: InspectionReportPdfStatus.PENDING,
},
}, manager);
return this.getWithManager(manager, created.id);
status: InspectionReportStatus.WORKING,
},
}, manager);
return this.getWithManager(manager, created.id);
}
async ensureWordForAct(actId: string): Promise<void> {
const [row] = await this.dataSource.query('SELECT id FROM inspection_reports WHERE act_id = $1', [actId]) as Array<{ id: string }>;
const [row] = await this.dataSource.query(
'SELECT id FROM inspection_reports WHERE act_id = $1',
[actId],
) as Array<{ id: string }>;
if (row) await this.word.ensure(row.id);
await this.delivery.dispatchForAct(actId).catch(() => undefined);
}
@@ -398,25 +381,10 @@ export class InspectionReportsService {
conditions.push(`(
report.code ILIKE ${search}
OR report.title ILIKE ${search}
OR report.gedo_if_identifier ILIKE ${search}
OR act.code ILIKE ${search}
OR act.title ILIKE ${search}
OR visit.code ILIKE ${search}
OR visit.title ILIKE ${search}
OR EXISTS (
SELECT 1
FROM inspection_act_assets search_link
INNER JOIN assets search_asset ON search_asset.id = search_link.asset_id
LEFT JOIN assets search_company ON search_company.id = search_asset.operator_company_id
LEFT JOIN assets search_area ON search_area.id = search_asset.operational_area_id
WHERE search_link.act_id = act.id
AND search_link.included = true
AND (
search_asset.name ILIKE ${search}
OR search_asset.code ILIKE ${search}
OR search_company.name ILIKE ${search}
OR search_area.name ILIKE ${search}
)
)
)`);
}
if (query.year) conditions.push(`report.report_year = ${add(query.year)}`);
@@ -454,20 +422,16 @@ export class InspectionReportsService {
return `
SELECT
COALESCE(JSONB_AGG(DISTINCT JSONB_BUILD_OBJECT(
'id', company.id,
'code', company.code,
'name', company.name
)) FILTER (WHERE company.id IS NOT NULL), '[]'::jsonb) AS companies,
'id',company.id,'code',company.code,'name',company.name
)) FILTER (WHERE company.id IS NOT NULL),'[]'::jsonb) AS companies,
COALESCE(JSONB_AGG(DISTINCT JSONB_BUILD_OBJECT(
'id', area.id,
'code', area.code,
'name', area.name
)) FILTER (WHERE area.id IS NOT NULL), '[]'::jsonb) AS areas
'id',area.id,'code',area.code,'name',area.name
)) FILTER (WHERE area.id IS NOT NULL),'[]'::jsonb) AS areas
FROM inspection_act_assets context_link
INNER JOIN assets context_asset ON context_asset.id = context_link.asset_id
LEFT JOIN assets company ON company.id = context_asset.operator_company_id
LEFT JOIN assets area ON area.id = context_asset.operational_area_id
WHERE context_link.act_id = act.id AND context_link.included = true
INNER JOIN assets context_asset ON context_asset.id=context_link.asset_id
LEFT JOIN assets company ON company.id=context_asset.operator_company_id
LEFT JOIN assets area ON area.id=context_asset.operational_area_id
WHERE context_link.act_id=act.id AND context_link.included=true
`;
}
@@ -481,50 +445,43 @@ export class InspectionReportsService {
report.report_number AS "reportNumber",
report.code,
report.status,
report.executive_summary AS "executiveSummary",
report.report_description AS "reportDescription",
report.gedo_if_identifier AS "gedoIfIdentifier",
report.gedo_officialized_at AS "gedoOfficializedAt",
report.gedo_pdf_original_name AS "gedoPdfOriginalName",
report.gedo_pdf_sha256 AS "gedoPdfSha256",
report.pdf_status AS "pdfStatus",
report.word_status AS "wordStatus",
report.word_generated_at AS "wordGeneratedAt",
report.review_status AS "reviewStatus",
report.current_revision_number AS "currentRevisionNumber",
report.approved_at AS "approvedAt",
report.signed_at AS "signedAt",
report.title,
report.act_version AS "actVersion",
report.act_closure_sha256 AS "actClosureSha256",
report.frozen_sha256 AS "frozenSha256",
report.generated_at AS "generatedAt",
JSONB_BUILD_OBJECT(
'id', generator.id,
'username', generator.username,
'firstName', generator.first_name,
'lastName', generator.last_name
'id',generator.id,'username',generator.username,
'firstName',generator.first_name,'lastName',generator.last_name
) AS "generatedBy",
JSONB_BUILD_OBJECT(
'id', act.id,
'code', act.code,
'title', act.title,
'status', act.status,
'occurredAt', act.occurred_at,
'closedAt', act.closed_at
'id',act.id,'code',act.code,'title',act.title,'status',act.status,
'occurredAt',act.occurred_at,'sealedAt',act.sealed_at,
'urgency',act.urgency,'deadlineAt',act.deadline_at
) AS act,
JSONB_BUILD_OBJECT(
'id', visit.id,
'code', visit.code,
'title', visit.title,
'status', visit.status
'id',visit.id,'code',visit.code,'status',visit.status
) AS visit,
COALESCE(context.companies, '[]'::jsonb) AS companies,
COALESCE(context.areas, '[]'::jsonb) AS areas,
COALESCE(finding_count.total, 0)::integer AS "findingCount"
COALESCE(context.companies,'[]'::jsonb) AS companies,
COALESCE(context.areas,'[]'::jsonb) AS areas,
COALESCE(finding_count.total,0)::integer AS "findingCount"
FROM inspection_reports report
INNER JOIN inspection_acts act ON act.id = report.act_id
INNER JOIN inspection_visits visit ON visit.id = report.visit_id
INNER JOIN users generator ON generator.id = report.generated_by
INNER JOIN inspection_acts act ON act.id=report.act_id
INNER JOIN inspection_visits visit ON visit.id=report.visit_id
INNER JOIN users generator ON generator.id=report.generated_by
LEFT JOIN LATERAL (${this.contextSelect()}) context ON true
LEFT JOIN LATERAL (
SELECT COUNT(*) AS total
FROM inspection_findings finding
WHERE finding.act_id = act.id AND finding.status <> 'VOIDED'
SELECT COUNT(*) AS total FROM inspection_findings finding
WHERE finding.act_id=act.id AND finding.status<>'VOIDED'
) finding_count ON true
${where}
`;
@@ -543,29 +500,15 @@ export class InspectionReportsService {
return { ...report, frozenSnapshot: snapshot.frozenSnapshot };
}
private async allocateNumber(manager: EntityManager, year: number): Promise<number> {
const [row] = (await manager.query(`
INSERT INTO document_annual_sequences (document_type, year, last_number)
VALUES ($1, $2, 1)
ON CONFLICT (document_type, year) DO UPDATE SET
last_number = document_annual_sequences.last_number + 1,
updated_at = CURRENT_TIMESTAMP
RETURNING last_number AS number
`, [DocumentSequenceType.REPORT, year])) as Array<{ number: number }>;
return Number(row.number);
}
private async assertActorAssigned(manager: EntityManager, visitId: string, userId: string): Promise<void> {
const rows = (await manager.query(`
SELECT 1
FROM inspection_visit_members
WHERE visit_id = $1 AND user_id = $2 AND included = true
LIMIT 1
SELECT 1 FROM inspection_visit_members
WHERE visit_id=$1 AND user_id=$2 AND included=true LIMIT 1
`, [visitId, userId])) as unknown[];
if (rows.length === 0) {
if (!rows.length) {
throw new ForbiddenException({
code: 'INSPECTION_REPORT_ACTOR_NOT_ASSIGNED',
message: 'Sólo un inspector asignado a la visita puede solicitar el informe',
message: 'Sólo un inspector asignado a la inspección puede solicitar el informe',
});
}
}
@@ -1,13 +1,27 @@
import { createCipheriv, createDecipheriv, randomBytes, randomUUID } from 'node:crypto';
import { connect as connectNet, Socket } from 'node:net';
import { connect as connectTls, TLSSocket } from 'node:tls';
import { randomUUID } from 'node:crypto';
import { ConfigService } from '@nestjs/config';
import { Injectable } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { DataSource } from 'typeorm';
import { SmtpSecurityMode } from '../database/entities';
interface MailAttachment { filename:string; mimeType:string; content:Buffer; }
interface MailInput { to:string; subject:string; text:string; attachment:MailAttachment; }
interface Reply { code:number; text:string; }
export interface EffectiveSmtpSettings {
source: 'DATABASE' | 'ENVIRONMENT';
host: string;
port: number;
securityMode: SmtpSecurityMode;
username: string | null;
password: string;
fromName: string | null;
fromEmail: string;
replyTo: string | null;
}
class SmtpSession {
private buffer=''; private waiters:Array<(reply:Reply)=>void>=[]; private replyLines:string[]=[];
private readonly dataHandler=(chunk:Buffer|string)=>this.onData(typeof chunk==='string'?chunk:chunk.toString('utf8'));
@@ -20,18 +34,164 @@ class SmtpSession {
async command(command:string, expected:number|number[]){ this.socket.write(`${command}\r\n`); const reply=await this.reply(); const allowed=Array.isArray(expected)?expected:[expected]; if(!allowed.includes(reply.code))throw new Error(`SMTP ${reply.code}: ${reply.text}`); return reply; }
write(data:string){this.socket.write(data);}
end(){this.socket.end();}
current(){return this.socket;}
}
function subject(value:string){return `=?UTF-8?B?${Buffer.from(value,'utf8').toString('base64')}?=`;}
function envelope(value:string){const m=value.match(/<([^>]+)>/);return (m?.[1]??value).trim();}
function mime(input:MailInput,from:string){ const boundary=`dh-${randomUUID()}`; const body=[`From: ${from}`,`To: ${input.to}`,`Subject: ${subject(input.subject)}`,'MIME-Version: 1.0',`Content-Type: multipart/mixed; boundary="${boundary}"`,'',`--${boundary}`,'Content-Type: text/plain; charset=utf-8','Content-Transfer-Encoding: base64','',Buffer.from(input.text,'utf8').toString('base64'),`--${boundary}`,`Content-Type: ${input.attachment.mimeType}; name="${input.attachment.filename.replaceAll('"','')}"`,'Content-Transfer-Encoding: base64',`Content-Disposition: attachment; filename="${input.attachment.filename.replaceAll('"','')}"`,'',input.attachment.content.toString('base64').replace(/(.{76})/g,'$1\r\n'),`--${boundary}--`,''].join('\r\n'); return body.replace(/^\./gm,'..'); }
function mime(input:MailInput,from:string,replyTo:string|null){ const boundary=`dh-${randomUUID()}`; const body=[`From: ${from}`,`To: ${input.to}`,replyTo?`Reply-To: ${replyTo}`:null,`Subject: ${subject(input.subject)}`,'MIME-Version: 1.0',`Content-Type: multipart/mixed; boundary="${boundary}"`,'',`--${boundary}`,'Content-Type: text/plain; charset=utf-8','Content-Transfer-Encoding: base64','',Buffer.from(input.text,'utf8').toString('base64'),`--${boundary}`,`Content-Type: ${input.attachment.mimeType}; name="${input.attachment.filename.replaceAll('"','')}"`,'Content-Transfer-Encoding: base64',`Content-Disposition: attachment; filename="${input.attachment.filename.replaceAll('"','')}"`,'',input.attachment.content.toString('base64').replace(/(.{76})/g,'$1\r\n'),`--${boundary}--`,''].filter((line):line is string=>line!==null).join('\r\n'); return body.replace(/^\./gm,'..'); }
@Injectable()
export class SmtpDeliveryService {
constructor(private readonly config:ConfigService){}
configured(){return Boolean(this.config.get<string>('SMTP_HOST')&&this.config.get<string>('MAIL_FROM'));}
async send(input:MailInput):Promise<{messageId:string}>{ const host=this.config.get<string>('SMTP_HOST'); const from=this.config.get<string>('MAIL_FROM'); if(!host||!from)throw new Error('SMTP no configurado'); const port=Number(this.config.get<string>('SMTP_PORT')??587); const secure=String(this.config.get<string>('SMTP_SECURE')??'false').toLowerCase()==='true'; const user=this.config.get<string>('SMTP_USER')??''; const pass=this.config.get<string>('SMTP_PASS')??''; const base=await new Promise<Socket|TLSSocket>((resolve,reject)=>{ if(secure){ const tls=connectTls({host,port,servername:host,rejectUnauthorized:true},()=>resolve(tls)); tls.once('error',reject); } else { const raw=connectNet({host,port},()=>resolve(raw)); raw.once('error',reject); } }); const session=new SmtpSession(base); const welcome=await session.reply(); if(welcome.code!==220)throw new Error(`SMTP ${welcome.code}: ${welcome.text}`); let ehlo=await session.command(`EHLO dh-inspeccion`,250); if(!secure){ if(!/STARTTLS/i.test(ehlo.text))throw new Error('El servidor SMTP no ofrece STARTTLS'); await session.command('STARTTLS',220); const rawForTls=session.detachForUpgrade() as Socket; const upgraded=await new Promise<TLSSocket>((resolve,reject)=>{ const tls=connectTls({socket:rawForTls,servername:host,rejectUnauthorized:true},()=>resolve(tls)); tls.once('error',reject); }); session.replaceSocket(upgraded); ehlo=await session.command('EHLO dh-inspeccion',250); }
if(user){ if(/AUTH[^\n]*PLAIN/i.test(ehlo.text)){ const token=Buffer.from(`\u0000${user}\u0000${pass}`,'utf8').toString('base64'); await session.command(`AUTH PLAIN ${token}`,235); } else { await session.command('AUTH LOGIN',334); await session.command(Buffer.from(user).toString('base64'),334); await session.command(Buffer.from(pass).toString('base64'),235); } }
await session.command(`MAIL FROM:<${envelope(from)}>`,250); await session.command(`RCPT TO:<${input.to}>`,[250,251]); await session.command('DATA',354); session.write(`${mime(input,from)}\r\n.\r\n`); const sent=await session.reply(); if(sent.code!==250)throw new Error(`SMTP ${sent.code}: ${sent.text}`); await session.command('QUIT',221).catch(()=>undefined); session.end(); const match=sent.text.match(/(?:queued as|id=|message-id[=:]?)[\s<]*([^\s>]+)/i); return {messageId:match?.[1]??randomUUID()}; }
constructor(
private readonly dataSource:DataSource,
private readonly config:ConfigService,
){}
async configured():Promise<boolean>{return Boolean(await this.resolveSettings());}
async fromAddress():Promise<string|null>{
const settings=await this.resolveSettings();
if(!settings)return null;
return settings.fromName?`${settings.fromName} <${settings.fromEmail}>`:settings.fromEmail;
}
async publicSettings(){
const [row]=await this.dataSource.query(`
SELECT id,host,port,security_mode AS "securityMode",username,
(password_enc IS NOT NULL) AS "hasPassword",from_name AS "fromName",
from_email AS "fromEmail",reply_to AS "replyTo",enabled,
updated_at AS "updatedAt"
FROM system_smtp_settings ORDER BY created_at LIMIT 1
`) as Array<Record<string,unknown>>;
if(row)return {...row,source:'DATABASE'};
const fallback=await this.resolveEnvironment();
return fallback?{
source:'ENVIRONMENT',host:fallback.host,port:fallback.port,
securityMode:fallback.securityMode,username:fallback.username,
hasPassword:Boolean(fallback.password),fromName:fallback.fromName,
fromEmail:fallback.fromEmail,replyTo:fallback.replyTo,enabled:true,
}:{source:'NONE',enabled:false};
}
async saveSettings(input:{
host:string;port:number;securityMode:SmtpSecurityMode;username?:string|null;
password?:string|null;fromName:string;fromEmail:string;replyTo?:string|null;enabled:boolean;
},userId:string){
const [existing]=await this.dataSource.query(`SELECT id,password_enc AS "passwordEnc" FROM system_smtp_settings ORDER BY created_at LIMIT 1`) as Array<{id:string;passwordEnc:string|null}>;
const passwordEnc=input.password===undefined
? existing?.passwordEnc??null
: input.password?this.encryptSecret(input.password):null;
if(existing){
await this.dataSource.query(`
UPDATE system_smtp_settings SET host=$2,port=$3,security_mode=$4,username=$5,
password_enc=$6,from_name=$7,from_email=$8,reply_to=$9,enabled=$10,
updated_by=$11,updated_at=CURRENT_TIMESTAMP WHERE id=$1
`,[existing.id,input.host,input.port,input.securityMode,input.username??null,passwordEnc,input.fromName,input.fromEmail,input.replyTo??null,input.enabled,userId]);
}else{
await this.dataSource.query(`
INSERT INTO system_smtp_settings(host,port,security_mode,username,password_enc,from_name,from_email,reply_to,enabled,updated_by)
VALUES($1,$2,$3,$4,$5,$6,$7,$8,$9,$10)
`,[input.host,input.port,input.securityMode,input.username??null,passwordEnc,input.fromName,input.fromEmail,input.replyTo??null,input.enabled,userId]);
}
return this.publicSettings();
}
async send(input:MailInput):Promise<{messageId:string}>{
const settings=await this.resolveSettings();
if(!settings)throw new Error('SMTP no configurado');
const {host,port,securityMode,userName,password}= {
host:settings.host,port:settings.port,securityMode:settings.securityMode,
userName:settings.username??'',password:settings.password,
};
const implicitTls=securityMode===SmtpSecurityMode.TLS;
const base=await new Promise<Socket|TLSSocket>((resolve,reject)=>{
if(implicitTls){ const tls=connectTls({host,port,servername:host,rejectUnauthorized:true},()=>resolve(tls)); tls.once('error',reject); }
else { const raw=connectNet({host,port},()=>resolve(raw)); raw.once('error',reject); }
});
const session=new SmtpSession(base);
const welcome=await session.reply();
if(welcome.code!==220)throw new Error(`SMTP ${welcome.code}: ${welcome.text}`);
let ehlo=await session.command('EHLO dh-inspeccion',250);
if(securityMode===SmtpSecurityMode.STARTTLS){
if(!/STARTTLS/i.test(ehlo.text))throw new Error('El servidor SMTP no ofrece STARTTLS');
await session.command('STARTTLS',220);
const rawForTls=session.detachForUpgrade() as Socket;
const upgraded=await new Promise<TLSSocket>((resolve,reject)=>{ const tls=connectTls({socket:rawForTls,servername:host,rejectUnauthorized:true},()=>resolve(tls)); tls.once('error',reject); });
session.replaceSocket(upgraded);
ehlo=await session.command('EHLO dh-inspeccion',250);
}
if(userName){
if(/AUTH[^\n]*PLAIN/i.test(ehlo.text)){ const token=Buffer.from(`\u0000${userName}\u0000${password}`,'utf8').toString('base64'); await session.command(`AUTH PLAIN ${token}`,235); }
else { await session.command('AUTH LOGIN',334); await session.command(Buffer.from(userName).toString('base64'),334); await session.command(Buffer.from(password).toString('base64'),235); }
}
const from=settings.fromName?`${settings.fromName} <${settings.fromEmail}>`:settings.fromEmail;
await session.command(`MAIL FROM:<${envelope(from)}>`,250);
await session.command(`RCPT TO:<${input.to}>`,[250,251]);
await session.command('DATA',354);
session.write(`${mime(input,from,settings.replyTo)}\r\n.\r\n`);
const sent=await session.reply();
if(sent.code!==250)throw new Error(`SMTP ${sent.code}: ${sent.text}`);
await session.command('QUIT',221).catch(()=>undefined);
session.end();
const match=sent.text.match(/(?:queued as|id=|message-id[=:]?)[\s<]*([^\s>]+)/i);
return {messageId:match?.[1]??randomUUID()};
}
private async resolveSettings():Promise<EffectiveSmtpSettings|null>{
const [row]=await this.dataSource.query(`
SELECT host,port,security_mode AS "securityMode",username,password_enc AS "passwordEnc",
from_name AS "fromName",from_email AS "fromEmail",reply_to AS "replyTo",enabled
FROM system_smtp_settings ORDER BY created_at LIMIT 1
`) as Array<{
host:string;port:number;securityMode:SmtpSecurityMode;username:string|null;passwordEnc:string|null;
fromName:string;fromEmail:string;replyTo:string|null;enabled:boolean;
}>;
if(row){
if(!row.enabled)return null;
return {
source:'DATABASE',host:row.host,port:Number(row.port),securityMode:row.securityMode,
username:row.username,password:row.passwordEnc?this.decryptSecret(row.passwordEnc):'',
fromName:row.fromName,fromEmail:row.fromEmail,replyTo:row.replyTo,
};
}
return this.resolveEnvironment();
}
private async resolveEnvironment():Promise<EffectiveSmtpSettings|null>{
const host=this.config.get<string>('SMTP_HOST');
const rawFrom=this.config.get<string>('MAIL_FROM');
if(!host||!rawFrom)return null;
const fromEmail=envelope(rawFrom);
const fromName=rawFrom.includes('<')?rawFrom.slice(0,rawFrom.indexOf('<')).trim().replace(/^"|"$/g,'')||null:null;
const secure=String(this.config.get<string>('SMTP_SECURE')??'false').toLowerCase()==='true';
return {
source:'ENVIRONMENT',host,port:Number(this.config.get<string>('SMTP_PORT')??(secure?465:587)),
securityMode:secure?SmtpSecurityMode.TLS:SmtpSecurityMode.STARTTLS,
username:this.config.get<string>('SMTP_USER')??null,password:this.config.get<string>('SMTP_PASS')??'',
fromName,fromEmail,replyTo:null,
};
}
private encryptionKey():Buffer{
const raw=this.config.get<string>('SMTP_SETTINGS_MASTER_KEY');
if(!raw)throw new Error('SMTP_SETTINGS_MASTER_KEY no configurada');
const key=/^[0-9a-fA-F]{64}$/.test(raw)?Buffer.from(raw,'hex'):Buffer.from(raw,'base64');
if(key.length!==32)throw new Error('SMTP_SETTINGS_MASTER_KEY debe contener exactamente 32 bytes');
return key;
}
private encryptSecret(value:string):string{
const iv=randomBytes(12); const cipher=createCipheriv('aes-256-gcm',this.encryptionKey(),iv);
const encrypted=Buffer.concat([cipher.update(value,'utf8'),cipher.final()]);
const tag=cipher.getAuthTag();
return `v1:${iv.toString('base64')}:${tag.toString('base64')}:${encrypted.toString('base64')}`;
}
private decryptSecret(value:string):string{
const [version,ivB64,tagB64,cipherB64]=value.split(':');
if(version!=='v1'||!ivB64||!tagB64||!cipherB64)throw new Error('Credencial SMTP cifrada inválida');
const decipher=createDecipheriv('aes-256-gcm',this.encryptionKey(),Buffer.from(ivB64,'base64'));
decipher.setAuthTag(Buffer.from(tagB64,'base64'));
return Buffer.concat([decipher.update(Buffer.from(cipherB64,'base64')),decipher.final()]).toString('utf8');
}
}
@@ -27,10 +27,8 @@ interface VerificationContextView {
interface VerificationVisitView {
id: string;
code: string;
title: string;
status: InspectionVisitStatus;
plannedStartAt: Date | null;
plannedEndAt: Date | null;
}
export interface VerificationPlanningItem {
@@ -39,12 +37,11 @@ export interface VerificationPlanningItem {
title: string;
status: 'OPEN';
nextControlOn: string;
companyResponseReceivedOn: string;
asset: VerificationContextView & { typeName: string };
company: VerificationContextView | null;
area: VerificationContextView | null;
act: { id: string; code: string };
sourceVisit: { id: string; code: string; title: string };
sourceVisit: { id: string; code: string };
verificationVisit: VerificationVisitView | null;
}
@@ -75,7 +72,6 @@ interface LockedFindingRow {
areaId: string | null;
areaCode: string | null;
areaName: string | null;
companyResponseReceivedOn: string | null;
nextControlOn: string | null;
status: string;
}
@@ -93,7 +89,6 @@ export class InspectionVerificationsService {
const values: unknown[] = [];
const baseFilters = [
`finding.status = 'OPEN'`,
`finding.company_response_received_on IS NOT NULL`,
`finding.next_control_on IS NOT NULL`,
`COALESCE((
SELECT latest_verification.outcome
@@ -132,10 +127,8 @@ export class InspectionVerificationsService {
SELECT
visit.id,
visit.code,
visit.title,
visit.status,
visit.planned_start_at AS "plannedStartAt",
visit.planned_end_at AS "plannedEndAt"
visit.planned_start_at AS "plannedStartAt"
FROM inspection_finding_verification_visits verification_link
INNER JOIN inspection_visits visit ON visit.id = verification_link.visit_id
WHERE verification_link.finding_id = finding.id
@@ -181,7 +174,6 @@ export class InspectionVerificationsService {
finding.title,
finding.status,
finding.next_control_on AS "nextControlOn",
finding.company_response_received_on AS "companyResponseReceivedOn",
jsonb_build_object(
'id', asset.id,
'code', asset.code,
@@ -201,16 +193,13 @@ export class InspectionVerificationsService {
jsonb_build_object('id', act.id, 'code', act.code) AS act,
jsonb_build_object(
'id', source_visit.id,
'code', source_visit.code,
'title', source_visit.title
'code', source_visit.code
) AS "sourceVisit",
CASE WHEN verification_visit.id IS NULL THEN NULL ELSE jsonb_build_object(
'id', verification_visit.id,
'code', verification_visit.code,
'title', verification_visit.title,
'status', verification_visit.status,
'plannedStartAt', verification_visit."plannedStartAt",
'plannedEndAt', verification_visit."plannedEndAt"
'plannedStartAt', verification_visit."plannedStartAt"
) END AS "verificationVisit"
FROM inspection_findings finding
${joins}
@@ -503,7 +492,6 @@ export class InspectionVerificationsService {
finding.title,
finding.status,
finding.asset_id AS "assetId",
finding.company_response_received_on AS "companyResponseReceivedOn",
finding.next_control_on AS "nextControlOn",
asset.code AS "assetCode",
asset.name AS "assetName",
@@ -524,13 +512,11 @@ export class InspectionVerificationsService {
if (rows.length !== findingIds.length) {
throw new NotFoundException({ code: 'VERIFICATION_FINDING_NOT_FOUND', message: 'Uno o más hallazgos ya no están disponibles' });
}
const invalid = rows.find((row) =>
row.status !== 'OPEN' || !row.companyResponseReceivedOn || !row.nextControlOn,
);
const invalid = rows.find((row) => row.status !== 'OPEN' || !row.nextControlOn);
if (invalid) {
throw new ConflictException({
code: 'VERIFICATION_FINDING_NOT_READY',
message: `${invalid.code} no está listo para una verificación planificada`,
message: `${invalid.code} necesita estar abierto y tener una fecha de control para planificar su verificación`,
});
}
const missingContext = rows.find((row) => !row.companyId || !row.areaId);
@@ -569,7 +555,6 @@ export class InspectionVerificationsService {
const companyName = rows[0]?.companyName ?? 'Empresa';
const areaName = rows[0]?.areaName ?? 'Área';
const code = await nextInspectionVisitCode(manager, plannedStartAt);
const title = code;
const findingCodes = rows.map((row) => row.code).join(', ');
const notes = dto.notes?.trim() || null;
const instructions = [
@@ -579,7 +564,6 @@ export class InspectionVerificationsService {
const visit = manager.getRepository(InspectionVisit).create({
code,
title,
objective: 'Verificar en campo la resolución de hallazgos con fecha de control programada.',
status: InspectionVisitStatus.DRAFT,
scopeAssetId: areaId,
@@ -587,7 +571,6 @@ export class InspectionVerificationsService {
operatorCompanyId: companyId,
leadInspectorUserId: null,
plannedStartAt,
plannedEndAt: null,
actualStartedAt: null,
actualClosedAt: null,
instructions,
@@ -650,7 +633,6 @@ export class InspectionVerificationsService {
code: visit.code,
status: visit.status,
plannedStartAt: visit.plannedStartAt,
plannedEndAt: visit.plannedEndAt,
companyId,
areaId,
findingIds,
@@ -662,10 +644,8 @@ export class InspectionVerificationsService {
visit: {
id: visit.id,
code: visit.code,
title: visit.title,
status: visit.status,
plannedStartAt: visit.plannedStartAt,
plannedEndAt: visit.plannedEndAt,
},
company: { id: companyId, code: rows[0]?.companyCode ?? '', name: companyName },
area: { id: areaId, code: rows[0]?.areaCode ?? '', name: areaName },
@@ -719,9 +699,6 @@ export class InspectionVerificationsService {
'title', finding.title,
'description', finding.description,
'correctionDueOn', finding.correction_due_on,
'companyResponse', finding.company_response,
'companyResponseReceivedOn', finding.company_response_received_on,
'companyCommittedCorrectionOn', finding.company_committed_correction_on,
'nextControlOn', finding.next_control_on,
'currentVersion', finding.current_version,
'verification', JSONB_BUILD_OBJECT(
@@ -759,5 +736,4 @@ export class InspectionVerificationsService {
]);
return versionNumber;
}
}
@@ -0,0 +1,10 @@
import { Transform } from 'class-transformer';
import { IsString, MaxLength, MinLength } from 'class-validator';
export class CancelInspectionVisitDto {
@Transform(({ value }) => typeof value === 'string' ? value.trim() : value)
@IsString()
@MinLength(10)
@MaxLength(4000)
reason!: string;
}

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