Compare commits

...

367 Commits

Author SHA1 Message Date
5a231c206e Merge pull request 'feat(frontend): sidebar colapsable por secciones + fly-out en modo colapsado' (#62) from chore/sidebar-collapsible-sections into main 2026-04-21 17:09:07 +00:00
bcb0c94fc5 feat(frontend): sidebar secciones colapsables + fly-out en modo colapsado
Mejora UX post-refactor (PR #61): las 4 secciones del sidebar expandido son
ahora colapsables individualmente, y el modo colapsado reemplaza la lista
larga de iconos por un icono por grupo con fly-out panel on hover.

Expandido (240px):
- Click en header de sección (Seguridad/Maestros/Catálogo/Tasación) toggle
  collapse con chevron que rota.
- Default: todas colapsadas EXCEPTO la que contiene la ruta activa
  (auto-expand override).
- Sección activa tiene el header disabled + sin chevron (no se puede colapsar
  mientras estás ahí — evita esconder items de la ruta actual).
- Preferencia per-sección persistida en localStorage.

Colapsado (68px):
- Un icono por grupo en lugar de listar TODOS los items (evitando scroll
  largo en usuarios con muchos permisos).
- Hover sobre el grupo despliega un fly-out panel al lado derecho con el
  título del grupo + sus items clickeables.
- Grupo que contiene la ruta activa tiene un dot indicator.
- Icons de grupo: ShieldCheck (Seguridad), Building2 (Maestros),
  Package (Catálogo), Calculator (Tasación).

Accessibility:
- Headers expandidos: aria-expanded refleja estado.
- Fly-out: aria-haspopup='menu' + role='menu' + keyboard focus.

z-index management (pedido explícito del user):
- aside wrapper en ProtectedLayout: z-10 -> z-30 (sobre contenido).
- HoverCardContent del fly-out: z-[60] (sobre cualquier overlay app-level,
  excepto modal dialogs que siguen siendo z-50 por convención Radix).
- hover-card.tsx: envuelto en HoverCardPrimitive.Portal (faltaba en el
  shadcn generated) — previene que el fly-out quede cortado por overflow
  del aside.

Dependencies:
- shadcn hover-card agregado via 'npx shadcn@latest add' (+ @radix-ui/react-hover-card).

Tests:
- 16 tests (antes 10) — agregados 6 casos: default collapsed except active,
  click toggle expand/collapse, aria-expanded reflection, disabled header
  when active, root route collapses all, localStorage persistence.
2026-04-21 14:07:12 -03:00
2aae873a4b Merge pull request 'chore(frontend): reorganizar sidebar en secciones + quitar items disabled' (#61) from chore/sidebar-categorization into main 2026-04-21 16:38:17 +00:00
3a534f7ad3 chore(frontend): reorganize sidebar into grouped sections + remove disabled items
Problem: sidebar was growing unwieldy — 4 top-level disabled items marked
'Próx.' acted as visual noise, and 12 admin items sat in a flat list with
no grouping (hard to scan).

Changes:
- Remove the 4 disabled top-level items (Ventas, Tasación, Integraciones,
  Administración-as-link). Those features will surface via the admin
  subsections when actually implemented, not as placeholder ghosts.
- Group the 12 admin items into 4 domain-aligned sections:
  - Seguridad: Usuarios, Crear Usuario, Roles, Permisos, Auditoría
  - Maestros:  Medios, Secciones, Puntos de Venta
  - Catálogo:  Rubros, Tipos de Producto, Productos
  - Tasación:  Caracteres Tasables
- Sections auto-hide when no item passes the permission filter, preventing
  empty headers for users with limited roles.
- Dashboard remains as the single top-level nav item (always visible).

TDD: new AppSidebar.test.tsx covers 10 scenarios — section rendering,
permission filtering, section auto-hide, role gating, active-route marking,
and section ordering.
2026-04-21 13:37:53 -03:00
dfeb5fb7e1 Merge pull request 'chore(prc-001): followups #54 #55 #57 #58 + cierre #52 #53' (#60) from chore/prc-001-followups into main 2026-04-21 16:28:29 +00:00
3e7c4bfde9 chore(prc-001): followups #54 #55 #57 #58 — emoji validator + opt-in pricing + demo seed + tsconfig
Resuelve 4 de los followups creados post-archive de PRC-001:

#55 — Decisión de negocio (2026-04-21): emojis NO se permiten en Symbol config.
  - WordCounterService.ContainsEmoji(string): helper publico que reutiliza los
    rangos Unicode de IsEmojiRune (Emoticons, Pictographs, Dingbats, VS-16, ZWJ).
  - CreateChargeableCharConfigCommandValidator: regla .Must que rechaza emoji
    en Symbol con mensaje claro. Defensiva: cubre clientes directos al API
    (Postman, adversariales) mas alla del SymbolInput blocker del frontend.
  - Tests: 5 emojis positivos (smile/car/fire/heart VS-16/sun) + 8 plain symbols
    ($, %, !, ¡, @, €, ##, ABCD) + actualizacion del Api test E2E (Post_WithEmojiSymbol).

#57 — Alineacion FluentValidation con opt-in billing (CK_Price_NonNegative >= 0).
  - CreateChargeableCharConfigCommandValidator.PricePerUnit: GreaterThan(0)
    -> GreaterThanOrEqualTo(0). Mensaje explica el significado: 0 = no cobra.
  - Tests actualizados: PricePerUnit_Zero ahora Passes (era Fails). Negative
    sigue fallando. Api e2e usa -1 para el caso de rechazo.

#58 — tsconfig ignoreDeprecations + MSW handler (parte a).
  - src/web/tsconfig.json: agrega "ignoreDeprecations": "6.0" para silenciar
    el warning TS5101 del baseUrl deprecated en TS 6.x.
  - (El MSW handler de /api/v1/admin/product-types no aplica — los tests ya
    mockean ProductTypeSelect directamente; warning residual no existe.)

#54 — Seeder demo de overrides ficticios per-ProductType (V025).
  - database/migrations/V025__seed_chargeable_char_overrides_demo.sql:
    MERGE idempotente que crea overrides de ChargeableCharConfig para
    ProductTypes Clasificado/Notables/Fúnebres si existen en la DB.
    Precios ficticios ($ 5-8, % 3-5, ! 2-4, ¡ 2-4). No-op si los tipos no
    estan seedados (sera cuando PRD-008 haga seed de los 12 legacy).
  - V025_ROLLBACK.sql: elimina overrides demo preservando globales.
  - Aplicado en SIGCM2, SIGCM2_Test_App, SIGCM2_Test_Api.
  - database/README.md: V025 agregada al indice.

Tests: 1659 .NET (1310 Application + 349 Api) + 510 vitest — todos GREEN.

Closes #54, #55, #57, #58.
2026-04-21 13:27:54 -03:00
0eab947975 Merge pull request #59: PRC-001 WordCounter + ChargeableCharConfig (SPIKE + scope delta)
Closes PR #59. Retroactive PR documenting the full PRC-001 scope + final README commit.

Scope: WordCounterService (25 golden cases) + ChargeableCharConfig ABM per-ProductType + Reactivate (A+guard) + Delete (guard diferido FAC-001) + UI condicional. 14 commits total (8 core SPIKE + 6 refinement). 1634 .NET + 510 vitest tests green. Verify: PASS-WITH-FOLLOWUPS. Followups #52-#58.
2026-04-21 16:06:20 +00:00
ee36d86b5a docs(bd): V018..V024 entries in database/README.md (PRC-001 archive)
Archive follow-up: updates the migrations table in database/README.md with
V018 (PRD-002), V019 (PRD-003), and V020..V024 (PRC-001 + scope delta).
The table was stale since V017 (PRD-001). This entry keeps the onboarding
doc aligned with the actual migration chain applied on all three DBs.
2026-04-21 13:04:54 -03:00
0e2e4c9c94 Merge PRC-001: WordCounter + ChargeableCharConfig (SPIKE) + refinement
Full scope delivered:
- WordCounterService (25/25 golden cases, pure domain, anti-fraud algorithm)
- ChargeableCharConfig entity per ProductType + global fallback + forward-only history
- Admin CRUD + Reactivate (A+guard) + Delete endpoints
- Temporal Tables + IAuditLogger integration (fail-closed)
- Permission tasacion:caracteres_especiales:gestionar
- Frontend CMS feature (SymbolInput emoji blocker, conditional actions, ProductTypeSelect)
- V020+V021+V022+V023+V024 migrations
- 1634 .NET + 510 vitest tests green

Engram artifacts: sdd/prc-001-word-counter-spike/*
Verify report: sdd/prc-001-word-counter-spike/verify-report-v2 (PASS-WITH-FOLLOWUPS)
2026-04-21 12:58:07 -03:00
3a596080cb fix(frontend): generic delete warning without FAC-001 reference (PRC-001)
User feedback from smoke test: the FAC-001 reference is future-coupled — when
the invoicing module lands we would need to remember to update the dialog text.
Switched to a timeless message that describes the current behavior: 'La
eliminación es posible porque este carácter no está en uso.' It stays accurate
before and after the FAC-001 usage guard ships.
2026-04-21 12:47:07 -03:00
d7c6cbd4ff fix(backend+tests): reactivate endpoint 500 + test schema mismatches (PRC-001)
Three bugs surfaced while user smoke-testing Reactivate:

1. ReactivateAsync opened a SECOND connection for GetByIdAsync after the SP
   call, inside the ambient TransactionScope. This promoted the tx to DTC
   (distributed) which requires MSDTC — typically not enabled on dev/prod
   servers. The API returned an opaque 500. Fix: run the post-SP SELECT on
   the SAME connection (local tx stays lightweight / LTM).

2. Agent 1's V023 test refactor wrote 'INSERT INTO dbo.ProductType (Nombre,
   Codigo, Activo)' in 2 test files — but dbo.ProductType has no 'Codigo' or
   'Activo' columns (schema is Nombre + IsActive + flags + multimedia limits).
   Fix: use '(Nombre, HasDuration, RequiresText, RequiresCategory, IsBundle,
   AllowImages)' matching the other test files (ProductQueryRepositoryTests,
   ProductRepositoryTests, ProductPriceRepositoryIntegrationTests).

3. SqlTestFixture.EnsureV021SchemaAsync unconditionally ALTERed the V021-era
   SPs with '@MedioId' body. On second fixture run after V023 had already
   refactored the table, ALTER PROCEDURE body referenced a MedioId column
   that no longer existed — 'Invalid column name MedioId'. Fix: guard the
   V021 SP ALTERs + seedV022 behind 'MedioId column exists' check. If V023
   already dropped MedioId, skip V021 re-install; EnsureV023SchemaAsync
   still recreates SPs with @ProductTypeId.

4. PricingExceptionTests still used 'medioId:' named-arg + '.MedioId' — Agent 2
   renamed the exception property but not these 6 test references.

Tests: 1297/1297 Application.Tests green.
2026-04-21 11:32:23 -03:00
40b5f3904a fix(bd): V023 idempotent guard for SYSTEM_VERSIONING OFF (PRC-001)
Wrap 'ALTER TABLE ... SET SYSTEM_VERSIONING = OFF' in temporal_type=2 check.
Without this guard, re-running V023 on a DB where a previous partial run
already turned SYSTEM_VERSIONING OFF fails with:
  'SYSTEM_VERSIONING is not turned ON for table...'

Found while applying on SIGCM2 dev after a prior interrupted run. Now truly
idempotent: safe to re-run in any state (fully applied, never applied, or
partially applied with SYSTEM_VERSIONING already OFF).
2026-04-21 11:18:53 -03:00
3eecb05634 refactor+feat(frontend): chargeableChars por ProductType + Reactivate/Delete/UI condicional (PRC-001)
Part of feature/PRC-001 pre-merge refinement.

REFACTOR:
- types, API client, hooks, components renamed MedioId -> ProductTypeId
- CopyToAllMediaDialog -> CopyToAllProductTypesDialog
- ProductTypeSelect reused from features/product-types (or created minimal stub)
- Form validation + test mocks updated

FEATURES:
- Conditional action buttons per row.isActive:
  - Active: Desactivar + Eliminar
  - Inactive: Reactivar + Eliminar
- Reactivate: useReactivateChargeableCharConfig hook, 409 reason surfaces
  localized error message
- Delete: useDeleteChargeableCharConfig hook + DeleteChargeableCharConfigDialog
  with confirmation warning + FAC-001 disclaimer
- ProductType column in ChargeableCharsTable (fallback "Global" when null)

Tests:
- Conditional rendering tests (5 new)
- Reactivate/Delete hook tests (5 new)
- Updated mocks for all existing tests
- DeleteChargeableCharConfigDialog tests (3 new)
- CopyToAllProductTypesDialog tests (3 new)
2026-04-21 11:08:17 -03:00
f7fb76219a refactor+feat(backend): ChargeableCharConfig por ProductType + Reactivate + Delete endpoints (PRC-001)
Part A — MedioId → ProductTypeId rename across all C# layers:
  Domain, Application, Infrastructure, API, all test projects.
  Solution was non-compilable after BD refactor (5c1675e); now compiles clean (0 errors).

Part B — PATCH /api/v1/admin/chargeable-chars/{id}/reactivate:
  ReactivateChargeableCharConfigCommand/Handler, SP guard maps 50410/50411/50412
  → ChargeableCharConfigReactivationNotAllowedException(Reason) → HTTP 409.

Part C — DELETE /api/v1/admin/chargeable-chars/{id}:
  DeleteChargeableCharConfigCommand/Handler, physical DELETE on SYSTEM_VERSIONED table.
  KeyNotFoundException → 404 via ExceptionFilter.

Tests: +30 unit tests (TDD RED→GREEN). All 1266 unit tests pass.
2026-04-21 10:54:47 -03:00
5c1675e59a refactor(bd): V023+V024 ChargeableCharConfig por ProductType + SP ReactivateWithGuard (PRC-001)
BREAKING: schema refactor pre-merge. Backend+frontend do not compile yet;
subsequent commits in this PR restore compilation. Acceptable only because
feature/PRC-001 is not yet merged to main.

- V023: drop MedioId + FK_Medio, add ProductTypeId + FK_ProductType, rename
  indexes, drop+create SPs InsertWithClose (now @ProductTypeId) and
  GetActiveForProductType (renamed from GetActiveForMedio). NEW SP
  ReactivateWithGuard (A+guard pattern for feature 3 of scope delta).
  Drop CK_Price_Positive, add CK_Price_NonNegative (>= 0 for opt-in billing).
- V024: reseed global rows with PricePerUnit = 0.0000 (opt-in billing).
- V023_ROLLBACK + V024_ROLLBACK scripts.
- SqlTestFixture: EnsureV023SchemaAsync, EnsureV024SeedAsync, renamed seed
  method signature (ProductTypeId=NULL + PricePerUnit=0), history table
  TablesToIgnore preserved. HardeningTests seeds dbo.ProductType (not Medio).
- MigrationTests: updated SP existence + column + FK + price assertions.
- RepositoryIntegrationTests + HardeningTests: SQL-level assertions updated;
  C# method/property renames deferred to Agent 2 (backend refactor).
2026-04-21 10:35:38 -03:00
5175cc1ece test(integration): concurrency + SYSTEM_VERSIONING + e2e extra (PRC-001)
Batch 7 hardening tests:
- T7.1 Concurrency: SemaphoreSlim barrier + Task.WhenAll; exactly 1 winner,
  2 losers receive SqlException; post-race vigente count = 1.
- T7.2 SYSTEM_VERSIONING: exact 0-before / 1-after history row count on close;
  history captures pre-close state (ValidTo was NULL at snapshot).
- T7.3 FOR SYSTEM_TIME AS OF: temporal snapshot at T0 returns row as it existed
  before the close UPDATE (ValidTo=NULL, original price).
- T7.4 Per-medio + global fallback: ELDIA override for % wins over global;
  ELPLATA falls back to V022 global seed at 1.00; service-layer priority verified.
- T7.6 WordCounterService x ChargeableCharConfig integration contract (pure unit):
  documents PRC-002+ billing pattern; asserts charge computation for 6 scenarios.

Total .NET tests: 1603 (was 1591; +12 new).
2026-04-20 13:21:59 -03:00
c2a0612a70 feat(frontend): chargeableChars feature — table + dialog + copy-to-all (PRC-001)
- types.ts: ChargeableCharConfig, PagedResult, requests (validFrom/validTo as yyyy-MM-dd strings, UDT-011)
- categories.ts: CHARGEABLE_CHAR_CATEGORIES + CATEGORY_LABELS
- api/: 5 functions (list, getById, create, schedulePriceChange, deactivate) via axiosClient
- hooks/: 5 TanStack Query hooks; mutations invalidate ['chargeableChars','list'] + byId
- SymbolInput.tsx: emoji-blocking input (/\p{Extended_Pictographic}/u), max 4 chars
- ChargeableCharsTable.tsx: shadcn DataTable; medio filter + activeOnly toggle; Vigente/Cerrada badges; formatCivilDate (UDT-011)
- ChargeableCharFormDialog.tsx: dual-mode create/schedulePrice; Zod schema; todayArgentina() min date; 409 inline error
- CopyToAllMediaDialog.tsx: Promise.allSettled over active medios; preview symbol/price/date
- ChargeableCharsPage.tsx: orchestrates table + dialogs + state
- routes.tsx: path/permission constants
- router.tsx: route /admin/tasacion/chargeable-chars registered
- AppSidebar.tsx: nav item "Caracteres Tasables" with Hash icon
- Tests: 22 new RTL/vitest tests (5 test files) — strict TDD RED→GREEN→REFACTOR
2026-04-20 12:59:27 -03:00
8fc7b363d5 feat(api): ChargeableCharConfigController + DI + ExceptionFilter integration (PRC-001) 2026-04-20 12:46:07 -03:00
3b1edfd696 feat(infrastructure): ChargeableCharConfigRepository Dapper + SP invocation (PRC-001)
- ChargeableCharConfigRepository implements IChargeableCharConfigRepository via Dapper
- InsertWithCloseAsync calls usp_ChargeableCharConfig_InsertWithClose with OUTPUT params;
  maps SqlException 50409 → ChargeableCharConfigForwardOnlyException, 50404 → ChargeableCharConfigInvalidException
- GetActiveForMedioAsync calls usp_ChargeableCharConfig_GetActiveForMedio; returns all rows
  (global + per-medio) — Application service handles priority resolution
- ListAsync / CountAsync use parameterized SQL with OFFSET/FETCH and NULL-aware MedioId filter
- GetByIdAsync / DeactivateAsync cover single-entity read and idempotent deactivation
- DateOnly mapping: DateTime → DateOnly.FromDateTime() pattern, same as ProductPriceRepository
- Registered IChargeableCharConfigRepository → ChargeableCharConfigRepository in DI
- 14 integration tests against SIGCM2_Test_App (all GREEN); 1571/1571 total tests pass
2026-04-20 12:32:17 -03:00
f1b38cd9ce feat(application): commands/queries + IChargeableCharConfigService (PRC-001) 2026-04-20 12:24:06 -03:00
ded76fcdc7 feat(domain): WordCounterService + WordCountResult + ChargeableCharConfig entity + exceptions (PRC-001)
- WordCounterService: pure domain service, 7-step algorithm (null/empty fast path → length check → emoji detection via Rune.EnumerateRunes → count specials before replace → replace specials+hyphens → collapse whitespace → tokenize)
- WordCountResult: sealed record with TotalWords + IReadOnlyDictionary<string,int> SpecialCharCounts
- 4 domain exceptions extending DomainException: EmojiDetectedException, WordCountValidationException, ChargeableCharConfigInvalidException, ChargeableCharConfigForwardOnlyException
- ChargeableCharConfig: rich entity with Create factory (invariants), Rehydrate reconstructor, ScheduleNewPrice (forward-only, returns new entity), Deactivate (idempotent)
- ChargeableCharCategories: enum-as-string constants (Currency, Percentage, Exclamation, Question, Other)
- DomainTimeProviderExtensions: internal GetArgentinaToday helper (mirrors Application.Common without creating Domain→Application dependency)
- 60 new tests: 25 golden cases all GREEN, 12 entity invariant tests, 12 exception tests, 5 WordCountResult tests, 6 ChargeableCharConfig entity tests
2026-04-20 12:13:06 -03:00
8ac91a13aa feat(bd): V020 permiso + V022 seed ChargeableCharConfig (PRC-001) 2026-04-20 12:01:55 -03:00
9144c2e89e feat(bd): V021 crea dbo.ChargeableCharConfig + SPs + índices (PRC-001) 2026-04-20 12:01:49 -03:00
dd4d4a1673 Merge pull request 'feat: paginación en GET /api/v1/products/{id}/prices (closes #47)' (#51) from feature/prd-003-prices-pagination into main 2026-04-19 23:08:31 +00:00
e997409e95 test(integration): pagination edge cases (prd-003-prices-pagination) 2026-04-19 20:01:09 -03:00
34b07a1d55 feat(frontend): pagination UI on product prices history (refs #47) 2026-04-19 19:52:45 -03:00
0dce3ee4ac feat(api): pagination on GET product prices (closes #47)
- GET /api/v1/products/{id}/prices now returns PagedResult<ProductPriceDto>
  with OFFSET/FETCH + COUNT via Dapper (two queries on same connection)
- Query params: ?page (default 1) and ?pageSize (default 20, max 100)
- Clamping: Math.Max(1, page) + Math.Clamp(pageSize, 1, 100) in handler
- Auth upgraded from [Authorize] to [RequirePermission("catalogo:productos:gestionar")]
- IProductPriceRepository.GetByProductIdAsync signature updated to paginated form
- AddProductPriceCommandHandler adapted to read back via page=1, pageSize=2
- TDD cycle: RED (tests updated to PagedResult shape) -> GREEN (implementation) -> REFACTOR
- Tests: 1418 total (1106 Application + 312 Api), 0 failures

closes #47
2026-04-19 19:47:18 -03:00
da063ad677 Merge pull request 'refactor(frontend): unify dateFormat + numberFormat into formatters' (#50) from refactor/unify-formatters into main 2026-04-19 22:26:48 +00:00
7d06ac721b refactor(frontend): unify dateFormat + numberFormat into formatters (closes #46)
- Create src/web/src/lib/formatters.ts with all exports from both modules
- Migrate all 14 import sites to @/lib/formatters (Opción A — immediate migration)
- Replace dateFormat.test.ts with formatters.test.ts including 10 smoke tests + full suite
- Delete src/web/src/lib/dateFormat.ts and numberFormat.ts
- 464 tests green, tsc clean (TS5101 warning is pre-existing)
2026-04-19 19:26:24 -03:00
5a55fdaaae Merge pull request 'chore(infra): configure coverlet for backend C# coverage' (#49) from infra/coverlet-setup into main
chore(infra): configure coverlet for backend C# coverage (#49)
2026-04-19 22:22:05 +00:00
9f1a312bb9 chore(infra): configure coverlet for backend C# coverage
Add coverlet.runsettings with Cobertura format, exclusions for migrations,
DI wiring, Program.cs and auto-props. Document coverage commands in README.

coverlet.collector 6.0.4 was already present via Directory.Packages.props.

Coverage baseline (Application.Tests + Api.Tests combined):
- Application.Tests: line 80.9%, branch 65.3%
- Api.Tests: line 64.9%, branch 57.8%

Closes #48
2026-04-19 19:21:45 -03:00
dd0e5e4fe8 Merge pull request 'feat: PRD-003 ProductPrices históricos (ValidFrom/ValidTo)' (#45) from feature/PRD-003 into main 2026-04-19 22:07:21 +00:00
7cabb677f3 test(integration): concurrency + SYSTEM_VERSIONING + e2e extra (PRD-003) 2026-04-19 18:43:11 -03:00
6a9818b0ae feat(frontend): productPrices feature — history + dialog (PRD-003)
- API layer: getProductPrices + addProductPrice (axiosClient)
- Hooks: useProductPrices (useQuery, staleTime 30s, enabled productId>0)
         useAddProductPrice (useMutation, invalidates ['products', id, 'prices'])
- Components: ProductPriceHistory (shadcn Table + Badge Vigente, formatCivilDate, formatCurrency)
              AddProductPriceDialog (shadcn Dialog + Form, Zod schema con priceValidFrom>=todayArgentina())
- Integration: ProductsPage gets "Ver precios" per row opening prices dialog
- lib/numberFormat.ts: formatCurrency() con Intl.NumberFormat ARS
- types.ts extended: ProductPrice, AddProductPriceRequest, AddProductPriceResponse
- Tests (Vitest + RTL): 19 tests — RED→GREEN confirmed
  - ProductPriceHistory: loading/error/empty/data/Badge Vigente/dialog/permissions
  - AddProductPriceDialog: validation (fecha pasada, precio=0, precio negativo),
    happy path payload + close, server 409 inline error, vi.useFakeTimers ART
  - hooks: useProductPrices caching + disabled when productId=0,
           useAddProductPrice invalidateQueries + error 409
- 453 total tests, 0 rojos
2026-04-19 18:36:17 -03:00
f6f24bc4be feat(api): ProductPricesController + DI + ExceptionFilter integration (PRD-003)
- GET /api/v1/products/{id}/prices [Authorize] → 200 IReadOnlyList<ProductPriceDto>
- POST /api/v1/admin/products/{id}/prices [RequirePermission catalogo:productos:gestionar] → 201 AddProductPriceResponse + Location header
- ExceptionFilter: 3 new cases (ProductPriceForwardOnlyException→409, ProductPriceInvalidException→400, ProductSinPrecioActivoException→404)
- Fix AddProductPriceCommandHandler: move GetByProductIdAsync outside TransactionScope using block to avoid InvalidOperationException (scope already complete)
- 16 e2e tests in ProductPricesControllerTests: 401/403, 200 history ordered DESC, 404 not found, 201 first/second price, 400 validation, 409 forward-only, audit event, DateOnly yyyy-MM-dd roundtrip
- 305 Api.Tests + 1088 Application.Tests = 1393 total, 0 red
2026-04-19 18:26:24 -03:00
2d2e90fa3c feat(infrastructure): ProductPriceRepository Dapper + SP invocation (PRD-003) 2026-04-19 18:15:30 -03:00
4b0567d252 feat(application): commands/queries + IProductPricingService (PRD-003)
- IProductPriceRepository (AddAsync/GetByProductIdAsync/GetActiveAsync)
- ProductPriceDto, AddProductPriceCommand/Response, GetProductPricesQuery
- AddProductPriceCommandValidator (FluentValidation + TimeProvider, fecha >= hoy_AR)
- AddProductPriceCommandHandler (TransactionScope AsyncFlow, audit fail-closed)
- GetProductPricesQueryHandler (verifica producto existe, lista vacía válida)
- IProductPricingService + ProductPricingService (GetPriceAtAsync → decimal?)
- DI wiring en DependencyInjection.cs
- 29 tests NSubstitute + FakeTimeProvider, 1081 Application.Tests GREEN
2026-04-19 18:08:16 -03:00
54b0265994 feat(domain): ProductPrice entity + exceptions (PRD-003) 2026-04-19 17:59:43 -03:00
59f30cddfb feat(bd): V019 crea dbo.ProductPrices + SP + índices (PRD-003) 2026-04-19 17:53:58 -03:00
e735afb5b4 Merge pull request 'feat(domain): RubroConProductosActivosException + guard en DeactivateRubro (closes #41)' (#44) from fix/issue-41-rubro-deactivation-guard into main 2026-04-19 20:09:38 +00:00
50a5118a78 feat(api): ExceptionFilter + e2e 409 para RubroConProductosActivos (closes #41)
Mapea RubroConProductosActivosException → HTTP 409 con error code
rubro_con_productos_activos. Test e2e usa DI override (patrón issue #36)
para stub IProductQueryRepository sin sembrar Products reales en DB.
2026-04-19 17:08:42 -03:00
c974e824e0 feat(infrastructure): ProductQueryRepository.CountActiveByRubroAsync + integration test
Implementa SELECT COUNT(1) FROM dbo.Product WHERE RubroId = @RubroId AND IsActive = 1.
Tests de integración verifican: 0 sin productos, count correcto con mix
activos/inactivos/otro rubro, y solo inactivos retorna 0.
2026-04-19 17:08:35 -03:00
900fd5e975 feat(application): DeactivateRubroCommandHandler guard contra Products activos
Extiende IProductQueryRepository con CountActiveByRubroAsync, inyecta
el repositorio en el handler e intercala el chequeo después del guard
de hijos activos. Tests de unidad cubren: throw, success con 0 productos,
y estabilidad del orden de guardas (hijos primero).
2026-04-19 17:08:30 -03:00
e9d1e3237d feat(domain): RubroConProductosActivosException + test (closes #41)
Co-authored-by: fix/issue-41-rubro-deactivation-guard
2026-04-19 17:08:23 -03:00
e33e9f332e Merge pull request 'refactor(tests): TestWebAppFactory.CreateClientWithOverrides para DI override scoped (closes #36)' (#43) from fix/issue-36-rsa-singleton-override into main 2026-04-19 20:00:47 +00:00
0e363d1cfc refactor(tests): TestWebAppFactory.CreateClientWithOverrides para DI override por test (closes #36)
Agrega helper CreateClientWithOverrides en TestWebAppFactory que envuelve
WithWebHostBuilder+ConfigureTestServices para inyectar stubs por test sin
tocar la fábrica compartida. Usa el patrón para agregar 2 tests e2e:
Deactivate_WhenProductQueryReturnsInUse_Returns409WithErrorCode (PRD-001/PRD-002)
y CreateRubro_WhenParentHasAvisos_Returns409WithErrorCode (CAT-002).
Remueve el comentario TODO PRD-002. 287 Api tests verdes.
2026-04-19 16:59:53 -03:00
c5a8cd9edd Merge pull request 'fix: openEdit fetch ProductTypeDetail antes de abrir dialog (closes #37)' (#42) from fix/issue-37-openedit-fetch-detail into main 2026-04-19 19:53:47 +00:00
616f6432d1 fix(frontend): openEdit fetch ProductTypeDetail antes de abrir dialog (closes #37)
Reemplaza el stub con nulls por queryClient.fetchQuery con getProductTypeById,
deshabilitando el botón durante la carga y mostrando toast.error si falla.
2026-04-19 16:53:00 -03:00
1730b0623e Merge pull request 'feat: PRD-002 Product CRUD' (#40) from feature/PRD-002 into main 2026-04-19 16:49:58 +00:00
d7fb3105fa feat(bd): V018 crea dbo.Product + SqlTestFixture consolida V018 + permisos catalogo (PRD-002 W6) 2026-04-19 13:46:11 -03:00
b4f17d6961 refactor: eliminar NullProductQueryRepository dead code + EXISTS en ProductQueryRepository (PRD-002 S1 S2) 2026-04-19 13:37:10 -03:00
a7cfcdb683 test(frontend): ProductsPage pagination + filter tests (PRD-002 W5) 2026-04-19 13:36:48 -03:00
0f5455aba6 test(frontend): ProductFormDialog + DeactivateProductDialog tests (PRD-002 W3 W4) 2026-04-19 13:35:23 -03:00
2b79b6f769 feat(frontend): ProductForm reactivo a flags ProductType (PRD-002 W2) 2026-04-19 13:33:53 -03:00
d262454b28 fix(api): ExceptionFilter 409 para ProductTypeInactivo y RubroInactivo (PRD-002 W1) 2026-04-19 13:31:38 -03:00
08a4738daf feat(frontend): Products feature — CRUD page, form, dialogs, hooks (PRD-002)
Implements full frontend for PRD-002: 5 API fns, 5 hooks (useProducts,
useCreateProduct, useUpdateProduct, useDeactivateProduct), ProductForm,
ProductFormDialog, DeactivateProductDialog, ProductsPage with CanPerform
gating. Router entry at /admin/products and sidebar link added. 19 Vitest
tests GREEN (api, hooks, page).
2026-04-19 13:24:42 -03:00
a41a4ea341 test(api): guard proof — ProductType deactivation returns 409 when active Products exist (PRD-002) 2026-04-19 13:18:21 -03:00
165abc8245 feat(api): ProductsController + ExceptionFilter Product cases, fix permiso count to 27 (PRD-002) 2026-04-19 13:17:31 -03:00
733ca0e2e2 test(infrastructure): ProductRepository integration tests — roundtrip, update, deactivate history, UQ (PRD-002) 2026-04-19 13:11:21 -03:00
8c9a50504d feat(infrastructure): ProductRepository + ProductQueryRepository, DI swap activates guard (PRD-002) 2026-04-19 13:10:21 -03:00
bb455be745 feat(application): Product handlers + DI registration, fix permiso count to 27 (PRD-002) 2026-04-19 13:07:59 -03:00
8b555e1f8b feat(application): Product commands, DTOs, IProductRepository, validators (PRD-002) 2026-04-19 13:02:42 -03:00
16197cf242 feat(domain): Product entity + 5 domain exceptions (PRD-002) 2026-04-19 12:59:58 -03:00
0462970ea1 Merge pull request 'feat: PRD-001 ProductType (flags + multimedia)' (#38) from feature/PRD-001 into main 2026-04-19 15:18:53 +00:00
d6ec618ff2 docs(tests): TODO W1 PRD-002 en ProductTypesControllerTests + audit doc ya completo (PRD-001)
Agrega comentario TODO antes del bloque DELETE explicando el gap e2e
para 409 IsInUse (bloqueado por RSA singleton, issue #36, PRD-002).
Auditoría.md ya tenía las entradas producto_tipo.* desde apply anterior.
2026-04-19 12:10:35 -03:00
230405e056 feat(frontend): wire dialogs en ProductTypesPage (PRD-001 W3)
Conecta ProductTypeFormDialog (create/edit) y DeactivateProductTypeDialog
en ProductTypesPage: botón "Nuevo Tipo", acción Editar por fila, acción
Desactivar por fila, empty state CTA "Crear primer tipo".

9 tests nuevos de page integration. Total: 390.
2026-04-19 12:10:09 -03:00
9cb1e84ec0 feat(frontend): ProductTypeForm + Dialog + DeactivateDialog con TDD (PRD-001 W3)
Implementa los 3 componentes de UI faltantes con enfoque Red→Green:
- ProductTypeForm: zod schema con transforms para multimedia numérica,
  lógica condicional (multimedia deshabilitada cuando allowImages=false),
  normalización en submit.
- ProductTypeFormDialog: mode create/edit, inline error 409, aria-describedby (NFR8).
- DeactivateProductTypeDialog: AlertDialog confirmar soft-delete, inline error 409 EnUso.

18 tests nuevos (8 form + 6 dialog + 4 deactivate). Total: 381.
2026-04-19 12:08:36 -03:00
3db4dedb91 feat(frontend): feature product-types completa (PRD-001)
types, api, hooks, ProductTypesPage, router /admin/product-types,
sidebar Tipos de Producto; 11 vitest tests nuevos — suite total 363 GREEN.
2026-04-19 10:01:12 -03:00
170789886b feat(api): ProductTypesController + ExceptionFilter 4 casos PRD-001
CRUD endpoints con validación FluentValidation inline; 4 nuevas excepciones mapeadas
en ExceptionFilter; conteos de permisos 25→26 actualizados; 12 e2e tests nuevos.
2026-04-19 09:57:11 -03:00
936d1dc353 feat(infrastructure): ProductTypeRepository Dapper + DI wiring (PRD-001)
CRUD + paginado con filtros sobre dbo.ProductType; history temporal verificada en tests.
11 integration tests nuevos, suite total 935 GREEN.
2026-04-19 09:49:08 -03:00
5c8f19bf39 feat(application): CRUD handlers + validators + DI de ProductType (PRD-001)
Create/Update/Deactivate handlers con TransactionScope + audit; validators FluentValidation;
DI wiring NullProductQueryRepository + 5 handlers; SqlTestFixture V017 + permiso count 25→26.
2026-04-19 09:46:31 -03:00
3c9e852379 feat(application): IProductTypeRepository + IProductQueryRepository stub + queries (PRD-001) 2026-04-19 09:38:51 -03:00
132d17c99f feat(domain): ProductType entity + domain exceptions (PRD-001) 2026-04-19 09:36:29 -03:00
de70152d3e feat(bd): V017 crea dbo.ProductType con SYSTEM_VERSIONING + permiso catalogo:tipos:gestionar (PRD-001) 2026-04-19 09:34:23 -03:00
d8d1da8ea4 Merge pull request 'feat: CAT-002 Regla de Oro Rama vs Hoja + validaciones' (#35) from feature/CAT-002 into main 2026-04-19 11:56:32 +00:00
a0a1874ac2 test(frontend): apretar match exacto del title en CategoryTree (CAT-002 W2) 2026-04-19 08:52:34 -03:00
4f25233bab feat(frontend): tieneAvisos en RubroTreeNode + disable btn subrubro (CAT-002) 2026-04-19 08:35:42 -03:00
bb5dde6e24 feat(api): ExceptionFilter 409 para regla de oro + DTO delta (CAT-002) 2026-04-19 08:31:39 -03:00
f861dfa826 feat(application): RubroTreeBuilder + GetRubroTree con tieneAvisos (CAT-002) 2026-04-19 08:25:13 -03:00
c03aad8c5a feat(application): guard avisos en MoveRubroCommandHandler (CAT-002) 2026-04-19 08:24:07 -03:00
216983623a feat(application): guard avisos en CreateRubroCommandHandler (CAT-002) 2026-04-19 08:22:55 -03:00
9e50a929ae feat(application): RubroTreeBuilder + GetRubroTree con tieneAvisos (CAT-002) 2026-04-19 08:20:36 -03:00
673194e249 feat(application): IAvisoQueryRepository + NullAvisoQueryRepository (CAT-002) 2026-04-19 08:18:56 -03:00
ddd28ea4d5 feat(domain): excepciones regla de oro rama/hoja (CAT-002) 2026-04-19 08:17:45 -03:00
205f9c76ad Merge pull request 'feat: CAT-001 Árbol N-ario de Rubros' (#30) from feature/CAT-001 into main 2026-04-19 10:49:37 +00:00
389dda6e5e fix(tests): consolidar V016 en SqlTestFixture post issue #29
Rebase de CAT-001 sobre main (post #29) requiere:
- EnsureV016SchemaAsync en SqlTestFixture
- Rubro_History en TablesToIgnore central (el commit original b1be4a5 se skipeo por ser obsoleto post consolidacion)
- catalogo:rubros:gestionar en seed canonical de Permiso + RolPermiso admin
- RubroRepositoryTests refactorizado al patron [Collection] + SqlTestFixture
- RubrosControllerTests apunta a TestConnectionStrings.ApiTestDb
- Counts de permisos admin actualizados 24 -> 25 en 5 tests

Verify: App 819/819 + Api 251/251 + vitest 349/349 verde post-rebase.
2026-04-19 07:49:18 -03:00
bd2febf411 fix(frontend): MoveRubroDialog type cast para zodResolver output (CAT-001) 2026-04-19 07:42:56 -03:00
46ef3878de feat(frontend): MoveRubroDialog + wire en RubrosPage + aria-describedby (CAT-001)
Implementa MoveRubroDialog con flattenExcludingSubtree para prevenir ciclos en UI,
lo conecta en RubrosPage y agrega DialogDescription en RubroFormDialog.
2026-04-19 07:42:55 -03:00
022a36a90c test(application): GetRubroByIdQueryHandlerTests dedicado (CAT-001) 2026-04-19 07:42:55 -03:00
f07802f769 fix(frontend): corregir tipos zodResolver en RubroFormDialog (CAT-001)
- Reemplaza z.union([z.coerce.number(), z.literal('')]) por z.string().transform+pipe para evitar inferencia unknown en zodResolver
- Simplifica RubroFormValues a {nombre: string, tarifarioBaseId?: number | null}
- Actualiza RubrosPage: tarifarioId ya llega como number|null del schema transform
2026-04-19 07:42:55 -03:00
b22e9fe59a feat(frontend): rubros feature + CategoryTree + CRUD dialogs (CAT-001)
Co-Authored-By: none
2026-04-19 07:42:54 -03:00
5e2323e0bc feat(api): RubrosController + integration tests e2e + audit verification (CAT-001) 2026-04-19 07:42:54 -03:00
f8e9d18379 feat(infrastructure): RubroRepository Dapper + DI + integration tests (CAT-001) 2026-04-19 07:42:53 -03:00
d9fc9a2867 feat(application): Rubros commands/queries + RubroTreeBuilder + audit (CAT-001) 2026-04-19 07:42:26 -03:00
dcb2e5ada6 feat(domain): Rubro entity + domain exceptions (CAT-001) 2026-04-19 07:42:26 -03:00
9f78425a93 fix(bd): V016 COLLATE order — SQL Server requiere COLLATE antes de NOT NULL (CAT-001) 2026-04-19 07:42:25 -03:00
0d50d4f3cc feat(bd): V016 create Rubro table con SYSTEM_VERSIONING (CAT-001)
- dbo.Rubro: adjacency list, self-FK, soft-delete, temporal retention 10y
- Filtered unique index UQ_Rubro_ParentId_Nombre_Activo + covering IX_Rubro_ParentId_Activo
- Permission catalogo:rubros:gestionar seeded + assigned to admin role
- V016_ROLLBACK.sql: full reversal script
- RubrosOptions class (MaxDepth=10) + appsettings.json Rubros section
- services.Configure<RubrosOptions> registered in Infrastructure DI
- database/README.md updated with V013-V016 entries
2026-04-19 07:42:25 -03:00
9886524645 Merge pull request 'fix: issue #29 — integration tests flakiness (DB split + SqlTestFixture consolidado)' (#34) from fix/issue-29-flakiness into main 2026-04-19 10:41:27 +00:00
bcbba2c012 Merge pull request 'chore(frontend): limpiar lint errors pre-existentes' (#33) from chore/frontend-lint-preexisting into main 2026-04-19 10:41:16 +00:00
3cb89f80a3 Merge pull request 'chore(tests): dotnet format sobre archivos pre-existentes' (#32) from chore/dotnet-format-testfixtures into main 2026-04-19 10:41:14 +00:00
18ce4f6841 Merge pull request 'chore(frontend): DialogDescription en dialogs para a11y' (#31) from chore/dialog-aria-describedby into main 2026-04-19 10:41:09 +00:00
8daadc8a77 fix(tests): timestamp determinístico en QueryAsync_Limit_EmitsCursor
DATETIME2(3) + cursor roundtrip via O format perdía sub-ms de
DateTime.UtcNow causando ~37% flake rate. Timestamp fijo con sub-ms=0
elimina la ambigüedad.

Fixes residual flake del issue #29.
2026-04-19 07:40:32 -03:00
a0dcc7258b docs(database): actualiza README con V013-V015 y sección Test DBs
Agrega filas V013, V014, V015 a la tabla de migraciones. Actualiza
convención de "3 bases" (SIGCM2, SIGCM2_Test_App, SIGCM2_Test_Api).
Añade sección "Bases de datos de integration tests" con tabla de
propósito y referencia al script de creación.
2026-04-18 21:44:45 -03:00
e5b6c06f64 refactor(tests): Api.Tests apunta a SIGCM2_Test_Api via TestConnectionStrings
Todos los archivos de Api.Tests reemplazan la connection string hardcodeada
por TestConnectionStrings.ApiTestDb. Cada proyecto de tests ahora tiene su
propia base de datos aislada, eliminando la contención entre Application.Tests
y Api.Tests que causaba flakiness.
2026-04-18 21:44:40 -03:00
e0b9cba948 refactor(tests): Application.Tests elimina Respawner inline; usa SqlTestFixture compartido
6 clases que instanciaban Respawner directamente migran a recibir SqlTestFixture
vía ICollectionFixture. 8 clases restantes solo actualizan ConnectionString a
TestConnectionStrings.AppTestDb. Cada clase ahora es responsable únicamente de
sus seeds específicos; la limpieza de la base queda centralizada en el fixture.
2026-04-18 21:44:36 -03:00
03a695feb9 refactor(tests): DatabaseCollection centraliza ICollectionFixture<SqlTestFixture>
Registra la colección "Database" con SqlTestFixture como fixture compartido
para Application.Tests (elimina el ctor-con-string inline en cada test class).
Agrega Using global a ambos proyectos para evitar usings por archivo.
2026-04-18 21:44:24 -03:00
e987228f14 refactor(tests): SqlTestFixture usa TestConnectionStrings; ctor interno para Api.Tests
Agrega ctor parameterless que apunta a SIGCM2_Test_App (requerido por
xUnit ICollectionFixture<T>). El ctor con string se marca internal y
expone via InternalsVisibleTo a SIGCM2.Api.Tests. TestWebAppFactory
apunta a SIGCM2_Test_Api. Se agrega propiedad Connection pública para
que los tests que necesitan queries ad-hoc la usen.
2026-04-18 21:44:19 -03:00
d4a2b3bc3e feat(tests): añade TestConnectionStrings y script de creación de DBs de test
Introduce SIGCM2_Test_App y SIGCM2_Test_Api como bases aisladas para
Application.Tests y Api.Tests respectivamente. TestConnectionStrings.cs
centraliza las connection strings; create-test-api-db.sql documenta
el setup idempotente de ambas bases con COLLATE Modern_Spanish_CI_AS.
2026-04-18 21:44:12 -03:00
50a3c87b14 chore(frontend): limpiar lint errors pre-existentes
11 errores en archivos pre-existentes (0 en rubros/). Categorización:
2 bugs reales removidos, 1 FP con disable comentado, 8 FPs suprimidos con eslint-disable-next-line.

Files:
- src/web/src/components/ui/badge.tsx — react-refresh/only-export-components (FP: shadcn/ui co-ubica badgeVariants con el componente por diseño)
- src/web/src/components/ui/button.tsx — react-refresh/only-export-components (FP: ídem, buttonVariants)
- src/web/src/components/ui/form.tsx — react-refresh/only-export-components (FP: shadcn/ui co-ubica useFormField hook)
- src/web/src/pages/admin/audit/AuditFilters.tsx — react-refresh/only-export-components x2 (FP: EMPTY_FILTERS y toApiFilter co-ubicados con el componente que los consume)
- src/web/src/features/permisos/components/RolPermisosEditor.tsx — react-hooks/set-state-in-effect (FP: patrón válido de derived state desde prop externa asignados)
- src/web/src/features/users/components/PermisosEditor.tsx — react-hooks/set-state-in-effect (FP: ídem, permisoData → mapa local de overrides)
- src/web/src/pages/admin/audit/AuditPage.tsx — react-hooks/set-state-in-effect (FP: acumulación de páginas paginadas desde query externa)
- src/web/src/features/users/pages/CreateUserPage.tsx — @typescript-eslint/no-unused-vars (FP: _created existe por contrato de callback, no se necesita el valor)
- src/web/src/lib/dateFormat.ts — @typescript-eslint/no-unused-vars (FP: _opts reservado para extensibilidad futura; formato hardcodeado por compatibilidad Intl)
- src/web/src/tests/api/axiosClient.test.ts — @typescript-eslint/no-unused-vars (bug real: requestCount incrementado en mock handler pero nunca asercionado; variable eliminada)
2026-04-18 21:00:00 -03:00
9957724c40 chore(tests): dotnet format sobre archivos pre-existentes (surfaced durante CAT-001)
Fix mecánico de whitespace detectado por dotnet format --verify-no-changes durante la verify phase de CAT-001 (PR #30). Sin cambios funcionales.
2026-04-18 20:56:23 -03:00
1cb69cbaf3 chore(frontend): DialogDescription en dialogs para a11y (silencia Radix warning) 2026-04-18 20:55:36 -03:00
8353f73230 Merge pull request 'refactor(udt-011): Quartz jobs usan TimeProvider (closes #24)' (#28) from fix/UDT-011-quartz-jobs-timeprovider into main 2026-04-18 14:08:11 +00:00
01ad4cbfbc test(udt-011): Quartz jobs verifican TimeProvider injection 2026-04-18 11:07:47 -03:00
67da544bb4 refactor(udt-011): AuditRetentionEnforcerJob usa TimeProvider inyectado 2026-04-18 11:07:43 -03:00
b79dfb2f34 refactor(udt-011): AuditPartitionManagerJob usa TimeProvider inyectado 2026-04-18 11:07:40 -03:00
ff912cc6a9 refactor(udt-011): AuditIntegrityCheckJob usa TimeProvider inyectado 2026-04-18 11:07:36 -03:00
8d2618e6e5 Merge pull request 'UDT-011: Localización Temporal Argentina (infra transversal)' (#25) from feature/UDT-011 into main 2026-04-18 13:57:49 +00:00
a5fd3e90fb Merge branch 'main' into feature/UDT-011 2026-04-18 10:56:09 -03:00
50f713dc10 Merge pull request 'fix(web): cleanup 25 TS errors preexistentes en main (closes #26)' (#27) from fix/ADM-011-web-ts-cleanup into main
fix(web): cleanup 25 TS errors preexistentes en main (closes #26)
2026-04-18 13:55:33 +00:00
b5ec0c25a9 fix(web/tests): alinear updateUserPermisosOverrides mock con UsuarioPermisos shape (TS2339) 2026-04-18 10:54:44 -03:00
a39427865f fix(web/tests): eliminar imports no usados en tests (TS6133) 2026-04-18 10:54:40 -03:00
202d267e16 fix(web): migrar SeccionForm a sintaxis Zod v4 (errorMap → error, coerce.number<number>()) 2026-04-18 10:54:28 -03:00
8b369b69ee fix(web): migrar MedioForm a sintaxis Zod v4 (TS2322 — coerce.number<number>()) 2026-04-18 10:54:23 -03:00
d16da502f4 fix(web): corregir import type-only de ButtonProps en pagination.tsx (TS1484) 2026-04-18 10:54:19 -03:00
408c97559b chore(web/udt-011): grep final confirma 0 anti-patterns en src/web/src fuera de dateFormat.ts 2026-04-18 10:27:13 -03:00
ef4b02be3b fix(web/udt-011): AuditFilters datetime-local usa parseArgentinaDateTimeToUtc (fix BUG-FE-05) 2026-04-18 10:26:56 -03:00
03a02c63d5 refactor(web/udt-011): eliminar 4 funciones formatDate duplicadas y formatOccurredAt, usar dateFormat utility (fix BUG-FE-01, BUG-FE-02) 2026-04-18 10:26:29 -03:00
71d0928389 fix(web/udt-011): NuevaVigenciaModal preview usa prevCivilDate+formatCivilDate sin Date() (fix BUG-FE-04) 2026-04-18 10:24:15 -03:00
20b5863908 fix(web/udt-011): IngresosBrutosFormModal default vigenciaDesde usa todayArgentina 2026-04-18 10:22:47 -03:00
7e23a16062 fix(web/udt-011): TipoDeIvaFormModal default vigenciaDesde usa todayArgentina (fix BUG-FE-03) 2026-04-18 10:22:43 -03:00
2ea7678129 feat(web/udt-011): dateFormat.ts utility (formatInstant, formatCivilDate, todayArgentina, etc.) 2026-04-18 10:17:47 -03:00
bc3e5d99a1 test(web/udt-011): dateFormat.ts utility tests (Red — 6 funciones + edge cases) 2026-04-18 10:17:43 -03:00
9bc191c3ae test(udt-011): T400.40 — update tests for TimeProvider injection and explicit now params
Fix all test compilation errors caused by T400.10/T400.20/T400.30:
- Handler constructors: add TimeProvider.System as last argument
- Domain mutator calls: add DateTime.UtcNow as explicit 'now' argument
- AuditLogger/SecurityEventLogger Build() helpers: add TimeProvider.System
- JwtService test constructors: add TimeProvider.System
Cat2 coverage already present in TimeProviderArgentinaExtensionsTests.cs:
FakeTimeProvider proves GetArgentinaToday() returns ART civil date, not UTC.
2026-04-18 10:12:32 -03:00
a9838427a4 feat(udt-011): T400.30 — inject TimeProvider into Infrastructure critical services
AuditLogger, SecurityEventLogger: inject TimeProvider and use
_timeProvider.GetUtcNow().UtcDateTime for occurredAt timestamps.
JwtService: inject TimeProvider; use GetUtcNow() for token IssuedAt/Expires.
DI: update JwtService factory to pass sp.GetRequiredService<TimeProvider>().
Repositories: remove ?? DateTime.UtcNow fallback in UpdateAsync since callers
always provide FechaModificacion via domain mutators.
2026-04-18 10:12:24 -03:00
d69da5ff4c feat(udt-011): T400.10 — inject TimeProvider into all Application handlers
All command handlers that call domain mutators now inject TimeProvider
via constructor and use _timeProvider.GetUtcNow().UtcDateTime as the
explicit 'now' argument. Replaces previous direct DateTime.UtcNow usage.
2026-04-18 10:12:17 -03:00
4e1d8f69ab feat(udt-011): T400.20 — domain mutators accept explicit DateTime now param
Remove DateTime.UtcNow calls from all With*/Deactivate/Reactivate/
CerrarVigencia/NuevaVersion domain methods. Caller (Application layer)
is now responsible for passing the UTC timestamp obtained via
_timeProvider.GetUtcNow().UtcDateTime.
2026-04-18 10:12:03 -03:00
3c264aa7a1 chore(udt-011): register DateOnlyJsonConverter in Program.cs AddJsonOptions 2026-04-18 09:47:19 -03:00
a75d2f75a0 feat(udt-011): DateOnlyJsonConverter as yyyy-MM-dd 2026-04-18 09:47:16 -03:00
8dd668d5c5 test(udt-011): DateOnlyJsonConverter serialization tests (Red) 2026-04-18 09:47:13 -03:00
54d2340bb9 feat(udt-011): register TimeProvider.System in AddApplication DI 2026-04-18 09:44:21 -03:00
4e70b0f847 feat(udt-011): TimeProviderArgentinaExtensions.GetArgentinaToday cross-platform 2026-04-18 09:43:35 -03:00
03d51d4310 chore(udt-011): add Microsoft.Extensions.TimeProvider.Testing NuGet 2026-04-18 09:43:31 -03:00
7e4a096f24 test(udt-011): TimeProvider Argentina extension tests with FakeTimeProvider (Red) 2026-04-18 09:43:28 -03:00
cc4efe9ef2 chore(udt-011): SqlTestFixture.EnsureV015SchemaAsync for timezone views 2026-04-18 09:39:04 -03:00
7913dd8bb9 chore(udt-011): V015_ROLLBACK script for timezone views 2026-04-18 09:39:00 -03:00
a51a7bc07e feat(udt-011): V015 create v_AuditEvent_Local + v_SecurityEvent_Local views 2026-04-18 09:39:00 -03:00
be6f76d107 test(udt-011): V015 migration tests for timezone views (Red) 2026-04-18 09:38:55 -03:00
d4b2183628 Merge pull request 'fix(web): migrar PuntoDeVentaForm a sintaxis Zod v4 (closes #21)' (#23) from fix/ADM-008-zod-v4 into main 2026-04-18 11:47:39 +00:00
0863ed8682 fix(web/adm-008): migrar PuntoDeVentaForm a sintaxis Zod v4 (closes #21) 2026-04-18 08:47:20 -03:00
a804ef3c7b Merge pull request 'ADM-009: Tablas Fiscales (IVA + IIBB) — append-only versioned ref data' (#22) from feature/ADM-009 into main 2026-04-18 11:45:13 +00:00
30b55e60ea fix(web/adm-009): migrar componentes fiscales a sintaxis Zod v4 2026-04-18 08:37:10 -03:00
8c08a706f0 test(adm-009): V014MigrationTests con filtros especificos por seed (no count total) 2026-04-17 19:11:55 -03:00
600ff52dd2 refactor(infra): eliminar LegacySeedMap/NormalizeUpperSnakeToPascal de IngresosBrutosRepository 2026-04-17 19:11:51 -03:00
882f947765 chore(db): V014 seed Provincia en PascalCase (cleanup tech debt) 2026-04-17 19:11:47 -03:00
4739e5cd46 chore(web): routes /admin/fiscal/iva y /admin/fiscal/iibb con permiso
Ambas rutas protegidas con requiredPermissions=['administracion:fiscal:gestionar'].
Integradas en ProtectedPage con MustChangePasswordGate y ProtectedLayout.
2026-04-17 18:56:02 -03:00
a3a15a4118 test+feat(web/adm-009): iibb subfeature mirror de iva
Types (ProvinciaArgentina 24 valores + PROVINCIA_DISPLAY), iibbApi.ts,
useIngresosBrutos hooks, tabla con columna Provincia, FormModal sin Alicuota
en edit [REQ-UI-007], NuevaVigenciaIibbModal con preview, TiposDeIibbPage con
banner. 8 tests RTL pasan (iibb). Total fiscal: 47/47 tests.
2026-04-17 18:55:57 -03:00
fcd34081d2 test+feat(web/adm-009): TiposDeIvaPage con banner + tabla + modales
Banner advertencia visible al mount con tokens warning-bg/warning-border [REQ-UI-005].
Filtros por codigo y activo. Paginacion server-side. Modales create/edit/nueva-version
controlados por estado local. 12 tests RTL pasan.
2026-04-17 18:55:49 -03:00
88274a9f10 test+feat(web/adm-009): NuevaVigenciaModal con preview de fechas
Preview en tiempo real: nuevo porcentaje, fecha cierre = vigenciaDesde-1d.
Banner warning con tokens DS. Boton disabled si form invalido [REQ-UI-004].
7 tests RTL pasan incluyendo verificacion de fecha cierre correcta.
2026-04-17 18:55:44 -03:00
038a2ade70 test+feat(web/adm-009): TipoDeIvaFormModal sin campo Porcentaje
Modal de edicion solo cosmeticos (Codigo, Descripcion, AplicaIVA, Activo).
Campo Porcentaje ausente en modo edit — verificado con queryByLabelText null [REQ-UI-003].
Modo create incluye Porcentaje inicial + VigenciaDesde. 10 tests RTL pasan.
2026-04-17 18:55:38 -03:00
8ffee0dbe4 test+feat(web/adm-009): TipoDeIvaTable con acciones y paginacion
Columnas: Codigo, Descripcion, Porcentaje%, Vigencia (abierta si null),
Estado (badge), Version con HistorialCadenaTooltip lazy. Acciones: editar,
nueva vigencia, deactivate/reactivate toggle. 10 tests RTL pasan.
2026-04-17 18:55:33 -03:00
95432e843f feat(web/adm-009): hooks TanStack Query para fiscal IVA
useTiposDeIvaList, useTipoDeIva, useHistorialTipoDeIva (lazy enable),
mutations con invalidateQueries. staleTime: 15_000 en todas las queries.
Query keys estables: ['fiscal', 'iva', ...].
2026-04-17 18:55:25 -03:00
ea16d57646 feat(web/adm-009): types y api client para fiscal IVA
TipoDeIva types (UpdateRequest sin Porcentaje), ivaApi.ts con 8 endpoints,
ApiError contract { error, message } alineado con backend ADM-009.
2026-04-17 18:55:21 -03:00
9c05167788 chore(web): agregar tokens warning-bg y warning-border al Design System
Tokens usados en banner de advertencia fiscal (ADM-009). Incluye variante
light (amber claro) y dark (amber oscuro), mapeados en @theme inline de Tailwind.
2026-04-17 18:55:16 -03:00
3eda59f5aa feat(adm-009): ExceptionFilter mapping for fiscal exceptions ({error, message} unified) 2026-04-17 18:40:05 -03:00
b1a461b6cb feat(adm-009): FiscalController with raw-body Porcentaje/Alicuota defense 2026-04-17 18:40:02 -03:00
25407583eb feat(adm-009): Fiscal API DTOs (requests + responses + mapper) 2026-04-17 18:39:58 -03:00
4544a000ae test(adm-009): FiscalController integration tests with JWT auth (Red→Green) 2026-04-17 18:39:55 -03:00
83dd680fa3 feat(adm-009): TipoDeIvaRepository + IngresosBrutosRepository Dapper implementations + DI registration 2026-04-17 18:23:10 -03:00
8e2d6bfb14 test(adm-009): TipoDeIvaRepository + IngresosBrutosRepository integration tests (Red) 2026-04-17 18:18:17 -03:00
bd0c4deea7 feat(adm-009): TipoDeIva + IngresosBrutos handlers, DTOs, DI registration 2026-04-17 18:09:52 -03:00
2cd25e1036 test(adm-009): IngresosBrutos handler tests mirror (Red) 2026-04-17 18:09:44 -03:00
8db2b333c0 test(adm-009): TipoDeIva + IngresosBrutos handler tests (Red) 2026-04-17 18:09:40 -03:00
eead0a35cd feat(adm-009): ITipoDeIvaRepository + IIngresosBrutosRepository abstractions 2026-04-17 18:09:36 -03:00
1d051c93d6 feat(adm-009): Permiso.AdministracionFiscalGestionar constant 2026-04-17 17:53:17 -03:00
f267e4f427 feat(adm-009): domain exceptions for fiscal entities 2026-04-17 17:52:57 -03:00
4cb3eed21f test(adm-009): domain exceptions tests (Red) 2026-04-17 17:52:12 -03:00
088f2303c1 feat(adm-009): IngresosBrutos sealed entity mirror of TipoDeIva 2026-04-17 17:51:52 -03:00
87364ff8e6 test(adm-009): IngresosBrutos entity tests (Red) 2026-04-17 17:49:46 -03:00
f307306f91 feat(adm-009): TipoDeIva sealed entity with factories 2026-04-17 17:49:07 -03:00
b16dd313ed test(adm-009): TipoDeIva entity validation tests (Red) 2026-04-17 17:48:12 -03:00
98a4fea7c4 feat(adm-009): ProvinciaArgentina enum with display mapping 2026-04-17 17:47:22 -03:00
3ee0bf0724 test(adm-009): ProvinciaArgentina enum tests (Red) 2026-04-17 17:45:41 -03:00
c6c4eda269 chore(adm-009): actualizar Respawner TablesToIgnore + conteos de permisos en tests existentes 2026-04-17 17:41:30 -03:00
f4bd84c3f1 feat(adm-009): V014 seed 4 TipoDeIva + 24 IngresosBrutos + permiso fiscal:gestionar 2026-04-17 17:41:25 -03:00
58ff15a0c0 feat(adm-009): V014 create TipoDeIva + IngresosBrutos tables with SYSTEM_VERSIONING 2026-04-17 17:33:19 -03:00
93664612d5 test(adm-009): V014 migration integration tests (Red) 2026-04-17 17:32:02 -03:00
a82d51ff7a Merge pull request 'ADM-008: Puntos de Venta (CRUD fundacional)' (#19) from feature/ADM-008 into main 2026-04-17 17:31:21 +00:00
fc77576427 chore(adm-008): limpiar import huerfano + comentario stale post-ciruigia
- PuntoDeVentaTests.cs: quitar using SIGCM2.Domain.Enums (quedo huerfano tras
  eliminar TipoComprobante).
- SqlTestFixture.cs: actualizar comentario de EnsureV013SchemaAsync para
  reflejar scope recortado (solo PdV + permiso, drops idempotentes de
  SecuenciaComprobante + SP).
2026-04-17 14:24:58 -03:00
6458ee0106 revert(tests): eliminar tests de reserva/concurrencia/secuencialidad ADM-008
Eliminar SecuenciaComprobanteTests, ReservarNumeroCommandHandlerTests,
GetProximoNumeroQueryHandlerTests y 7 tests de integración en
PuntosDeVentaControllerTests (reserva/proximo/concurrencia/secuencialidad).
SqlTestFixture ahora limpia SecuenciaComprobante+SP si existen (drops idempotentes)
y solo crea PuntoDeVenta + temporal table.
2026-04-17 14:16:21 -03:00
6be637b4cf revert(web): eliminar feature de reserva de numero en UI ADM-008
Eliminar secuencias.api.ts, useReservarNumero.ts, SecuenciasPanel.tsx,
TipoComprobante enum y tipos ReservarNumeroResponse/ProximoNumeroResponse.
Quitar SecuenciasPanel del PuntoDeVentaDetailPage.
2026-04-17 14:16:14 -03:00
7d432a949a revert(backend): eliminar handlers/endpoints/excepciones de reserva de numero ADM-008
Eliminar SecuenciaComprobante entity, TipoComprobante enum, DeadlockTransientException,
PuntoDeVentaInactivoException, carpetas Reservar/ y ProximoNumero/ de Application,
métodos ReservarNumeroAsync/GetUltimoNumeroAsync del repositorio, endpoints
POST /secuencias/.../reservar y GET /secuencias/.../proximo del controller,
y mapping PuntoDeVentaInactivoException del ExceptionFilter.
2026-04-17 14:16:09 -03:00
40482caf7b revert(db): eliminar SecuenciaComprobante + SP de V013 — IMAC asigna numeros AFIP
SecuenciaComprobante, usp_ReservarNumeroComprobante y TipoComprobante no tienen
propósito de negocio: IMAC/Infogestión asigna NumeroFactura+CAI externamente.
V013 ahora solo gestiona PuntoDeVenta + temporal table + permiso AFIP.
Sección 0 aplica drops idempotentes para limpiar SIGCM2_Test y reinstalaciones.
2026-04-17 14:16:01 -03:00
9263d9a178 feat(web): panel de reserva de numeros en PdV detail (ADM-008)
Gap detectado durante smoke: la DetailPage tenia los hooks
useReservarNumero/useProximoNumero creados en Batch 6 pero faltaba
el componente que los consume.

SecuenciasPanel.tsx: tabla con los 6 tipos AFIP (FacturaA/B/C, NC A/B/C),
proximo numero por tipo, boton Reservar. Toast con el numero reservado.
Deshabilitado si PdV o Medio padre estan inactivos.

Integrado en PuntoDeVentaDetailPage bajo guard de permiso.
2026-04-17 13:38:21 -03:00
4368c42599 docs(adm-008): actualizar 2.5 Auditoría + cerrar OQ-ADM-008 + STATUS 2026-04-17 13:05:22 -03:00
65787db272 fix(adm-008): correcciones del verify loop
Seis ajustes post-verify detectados durante la corrida full de tests:

1. PuntoDeVentaRepository: UQ_PuntoDeVenta_Medio_AFIP (no _MedioId_NumeroAFIP)
   — el catch de unique violation no disparaba → 500 en race duplicado.

2. Application.DependencyInjection: registro de 8 handlers PuntosDeVenta
   — sin esto, dispatcher arrojaba "No service registered" → 500.

3. ReservarNumeroCommandHandler: backoff ampliado a 5 retries
   [25, 75, 200, 500, 1200]ms para soportar 50 threads concurrentes.

4. SecuenciaComprobante: SYSTEM_VERSIONING = OFF (AD8 revisitado).
   Under UPDATE concurrente sobre misma fila, el engine arroja
   "transaction time earlier than period start time" — limitación
   conocida de Temporal Tables con alta contención de UPDATEs.
   Decisión: secuencia es operacional, no configuración → sin history.
   V013 y SqlTestFixture actualizados para ser idempotentes.

5. SqlTestFixture: EnsureV013SchemaAsync idempotente + PuntoDeVenta_History
   en TablesToIgnore + permiso administracion:puntos_de_venta:gestionar
   en seed canónico + asignación a rol admin.

6. Tests: conteos 22→23 permisos (V013 agrega uno); repository fixtures
   ignoran PuntoDeVenta_History; test UpdatePdv_WhenPdvInactive eliminado
   (over-specified — spec no bloquea update en PdV inactivo, solo en Medio
   padre inactivo; alineado con frontend que permite editar PdV inactivo).

Resultado: 190/190 Api.Tests y tests específicos ADM-008 verdes
(Domain 13, Application 42, Api 21 = 76 tests nuevos). El único failure
residual (AuditEventRepositoryTests.QueryAsync_Limit_EmitsCursor) es
pre-existente y no relacionado a ADM-008.

Covers: verify report CRITICAL (UQ name mismatch) + WARNINGs descubiertos
durante la ejecución (DI registro, temporal tables concurrency, permiso
fixture, counts de tests pre-existentes).
2026-04-17 13:02:35 -03:00
4720f6772f test(web): component tests puntos-de-venta 2026-04-17 12:36:53 -03:00
056045232c feat(web): banners y routing puntos-de-venta 2026-04-17 12:36:48 -03:00
4b96cdefcc feat(web): tabla y form PuntosDeVenta 2026-04-17 12:36:44 -03:00
d61292afa4 feat(web): feature puntos-de-venta — types, api, hooks 2026-04-17 12:36:39 -03:00
48779543f9 test(api): integration tests CRUD + concurrencia + secuencialidad PuntosDeVenta
T5.3: 18 tests cubriendo 401/403, create, get, list, update, deactivate, reactivate, reservar, proximo.
T5.4: 50 tasks paralelas → 50 numeros distintos sin duplicados.
T5.5: 100 reservas en serie → {1..100} en orden.
2026-04-17 12:34:35 -03:00
39160bbb83 feat(api): PuntosDeVentaController + ExceptionFilter mappings ADM-008
8 endpoints en /api/v1/admin/puntos-de-venta con permiso administracion:puntos_de_venta:gestionar.
ExceptionFilter: +PuntoDeVentaNotFoundException (404), +PuntoDeVentaInactivoException (409), +NumeroAFIPDuplicadoException (409).
MedioInactivoException ya mapeado por ADM-001; no duplicado.
2026-04-17 12:34:30 -03:00
489359f0b8 feat(infrastructure): PuntoDeVentaRepository con Dapper + mapping SqlException + registro DI 2026-04-17 12:29:16 -03:00
50f6f2b67a feat(application): repository abstraction + DTOs + validators + handlers CRUD PuntosDeVenta con auditoría + retry deadlock 2026-04-17 12:28:11 -03:00
43877bd4a1 feat(domain): entidad PuntoDeVenta + SecuenciaComprobante + TipoComprobante + excepciones 2026-04-17 12:21:45 -03:00
bef8977c5c feat(db): migration V013 + SP usp_ReservarNumeroComprobante para ADM-008
- Tabla PuntoDeVenta con Temporal Tables + UNIQUE(MedioId, NumeroAFIP)
- Tabla SecuenciaComprobante con Temporal Tables + UNIQUE(PdvId, TipoComprobante)
- Permiso administracion:puntos_de_venta:gestionar (guion_bajo: CK_Permiso_Codigo_Format)
- SP usp_ReservarNumeroComprobante con SERIALIZABLE + THROW 50001/50002/50003
- V013_ROLLBACK.sql incluido

Smoke tests SIGCM2_Test:
- TEST 1: primera reserva devuelve 1 (lazy init) OK
- TEST 2: segunda reserva devuelve 2 OK
- TEST 3: PdV inactivo -> SqlException 50001 'punto_de_venta_inactivo' OK
- TEST 4: Medio inactivo -> SqlException 50002 'medio_inactivo' OK

Covers: REQ-PDV-001/003/009, REQ-SEC-CMB-001/002/003/004
2026-04-17 12:16:56 -03:00
b7ac9831f9 Merge pull request '#18 fix(adm-001): cascada de inactividad Medio→Seccion'
Closes #16. Medio inactivo bloquea update/deactivate/reactivate de sus Secciones con 409 medio_inactivo. Fail-closed (sin AuditEvent si el throw sucede antes). Banner + disabled buttons en UI.
2026-04-17 14:50:03 +00:00
3829c93af6 test(secciones): cobertura cascada de inactividad — issue #16 2026-04-17 11:46:14 -03:00
4fb25356a3 feat(web): banner y disabled de secciones de medio inactivo — issue #16 2026-04-17 11:46:09 -03:00
455954fa98 feat(api): mapping 409 medio_inactivo en ExceptionFilter — issue #16 2026-04-17 11:46:05 -03:00
870cbe91b3 feat(secciones): validar medio activo en update/deactivate/reactivate — issue #16 2026-04-17 11:46:01 -03:00
1ad6633cdd feat(domain): MedioInactivoException (issue #16) 2026-04-17 11:45:56 -03:00
91d353655d Merge pull request '#15 ADM-001: Medios y Secciones (fundacional)'
ADM-001 entrega el catálogo fundacional Medio + Seccion con Temporal Tables, auditoría y frontend CMS (9 commits, 831 tests / 0 fails). Desbloquea 20+ UDTs downstream.
2026-04-17 14:37:15 +00:00
740298a9e1 fix(web): reemplazar <select> nativos por shadcn Select (dark mode compat) — ADM-001
Reemplaza 13 <select>/<option> nativos en 8 archivos por el componente
shadcn Select (Radix UI). Los selects nativos ignoraban los tokens del
design system en dark mode, causando texto invisible. Se agrega mock de
pointer capture APIs en test setup para compatibilidad de Radix con jsdom.
2026-04-17 10:13:20 -03:00
6b946f6080 feat(web): Medios + Secciones admin UI + hooks + routing — ADM-001 B7+B8
- API clients + TanStack Query hooks for medios and secciones
- CRUD pages: List, Create, Edit, Detail for both entities
- Components: MediosTable, MedioForm, DeactivateMedioModal,
  SeccionesTable, SeccionForm, DeactivateSeccionModal, SeccionesFilters
- TipoMedio enum (int→label) and TipoSeccion display helpers
- CanPerform permission gates: administracion:medios:gestionar,
  administracion:secciones:gestionar
- Routes /admin/medios/** and /admin/secciones/** in router.tsx
- Sidebar items (Newspaper + Columns3 icons) with requiredPermission
- Vitest+RTL+MSW tests: 11 test files, 38 new cases — 207 pass total
2026-04-16 19:28:30 -03:00
13480ad8c2 feat(api): MediosController + SeccionesController + ExceptionFilter mappings — ADM-001 B6
- POST/GET/PUT + deactivate/reactivate endpoints for /api/v1/admin/medios
- POST/GET/PUT + deactivate/reactivate endpoints for /api/v1/admin/secciones
- ExceptionFilter: add Medio/Seccion 404+409 mappings after RolInUseException
- Integration tests: 19 scenarios covering 401/403/201/404/409/idempotency/AuditEvent
- All 166 Api.Tests + 458 Application.Tests passing
2026-04-16 19:16:33 -03:00
a6f4011806 fix(tests): resolve ADM-001 regressions in Api.Tests fixture
- Update hardcoded permiso count from 21 → 22 in AuthControllerTests and
  PermisosEndpointTests after V011 added 'administracion:secciones:gestionar'
- The TestSupport SqlTestFixture already had Medio_History/Seccion_History in
  TablesToIgnore; tests were failing due to stale binaries (needed rebuild)
2026-04-16 19:08:32 -03:00
2f0da2d720 feat(infra): MedioRepository + SeccionRepository + integration tests — ADM-001 B5 2026-04-16 19:04:09 -03:00
a1a8e6e0cb fix(tests): realign test expectations with V011 (ADM-001) seed — 22 permisos + Medios fixture 2026-04-16 19:04:06 -03:00
f672de78ce feat(medios,secciones): application layer + handlers TDD — ADM-001 B3+B4
- IMedioRepository, ISeccionRepository interfaces
- MediosQuery, SeccionesQuery common records
- TipoSeccion static AllowedTipos helper
- Medios: 6 use cases (Create/Update/Deactivate/Reactivate/List/GetById) with validators, handlers and DTOs
- Secciones: 6 use cases mirroring Medios; Create validates MedioId active via IMedioRepository
- 52 unit tests (xUnit + NSubstitute) all green; audit LogAsync asserted per mutating handler
- DI registrations for all 12 handlers and validators auto-scanned via AddValidatorsFromAssemblyContaining
2026-04-16 18:53:57 -03:00
bb98dbf217 feat(domain): Medio + Seccion entities + 4 exceptions — ADM-001 B2
Entities sealed immutable con factory ForCreation + copy-with methods
WithUpdatedProfile/WithActivo (Codigo inmutable en Medio; MedioId y
Codigo inmutables en Seccion — enforzado en Validators en B4).

Exceptions: MedioCodigoDuplicado (UQ global), SeccionCodigoDuplicadoEnMedio
(UQ compuesto), MedioNotFound, SeccionNotFound. Todas heredan de
DomainException.
2026-04-16 18:45:46 -03:00
ff7d8986fd feat(db): Medio + Seccion (temporal tables + seed) — ADM-001 B1
V011 crea dbo.Medio y dbo.Seccion con SYSTEM_VERSIONING ON (retention 10
anios) y PAGE compression en history; siembra el permiso
'administracion:secciones:gestionar' y lo asigna a rol admin. El permiso
'administracion:medios:gestionar' ya existia desde V005.

V012 siembra Medios fundacionales ELDIA y ELPLATA (MERGE idempotente).

Rollbacks V011/V012 validados estructuralmente; aplicacion y
reaplicacion verificadas en SIGCM2_Test y SIGCM2. Fixture de tests
actualizado: EnsureV011SchemaAsync, SeedMediosCanonicalAsync, ignora
Medio_History y Seccion_History en Respawner.
2026-04-16 18:13:54 -03:00
7c0646be0d Merge pull request 'UDT-010: Infraestructura de Auditoría y Trazabilidad — Closes #6' (#14) from feature/UDT-010 into main 2026-04-16 20:30:17 +00:00
9eac044752 feat(jobs): 3 audit maintenance jobs (Quartz.NET, UDT-010 B11)
Agrega Quartz.Extensions.Hosting 3.13.1 al catálogo central.

SIGCM2.Infrastructure/Audit/Jobs/:
- AuditPartitionManagerJob — mensual (cron '0 0 2 1 * ?', UTC). Extiende
  pf_AuditEvent_Monthly y pf_SecurityEvent_Monthly con SPLIT RANGE para el
  mes+2 (mantiene +1 de buffer). Idempotente: verifica existencia antes.
- AuditRetentionEnforcerJob — anual (cron '0 0 3 1 1 ?', UTC). DELETE rows
  > 10 años en AuditEvent y > 5 años en SecurityEvent. Temporal history se
  purga solo vía HISTORY_RETENTION_PERIOD del engine.
- AuditIntegrityCheckJob — semanal domingos (cron '0 0 1 ? * SUN', UTC).
  Valida SYSTEM_VERSIONING=ON + partitions próximos 3 meses. Emite
  SecurityEvent 'system.integrity_alert' failure via ISecurityEventLogger
  cuando detecta inconsistencias.

AuditMaintenanceRegistration.cs:
- services.AddAuditMaintenance(configuration) wraps AddQuartz + AddQuartzHostedService
  con los 3 triggers crónicos.

Program.cs:
- builder.Services.AddAuditMaintenance(configuration) wired ONLY en entornos
  productivos — skipeado en 'Testing' para que los integration tests no
  disparen los triggers cron durante el ciclo de vida del TestWebAppFactory.

Row-based DELETE en RetentionEnforcerJob es la opción conservadora para la
primera generación — cuando los volúmenes lo justifiquen (>200M filas), se
upgradea a SWITCH OUT + DROP para partition-level drop. Documentado en
comentario de la clase.

Tests (Strict TDD, integration):
- AuditJobsTests (3): PartitionManager crea target boundary + idempotencia,
  RetentionEnforcer purga > threshold (10y audit, 5y security), IntegrityCheck
  all-OK no emite alert.

Suite: 381/381 Application.Tests + 147/147 Api.Tests = 528/528 passing.

Refs: sdd/udt-010-auditoria-trazabilidad/{spec#REQ-AUD-6 #REQ-SEC-5, design, tasks#B11}
2026-04-16 17:10:43 -03:00
b526df2125 feat(web): /admin/audit page + filtros (UDT-010 B12)
Read-only audit timeline per design #D-9. Delegated to sub-agent, completed
before rate limit cutoff; verified with vitest 161/161 passing.

New files:
- src/web/src/api/audit.ts — axios client: listAuditEvents(filter)
- src/web/src/features/admin/audit/useAuditEvents.ts — TanStack Query hook
- src/web/src/pages/admin/audit/AuditPage.tsx — DataTable + 4 filters + cursor
  pagination 'Cargar más' button. Columns: OccurredAt (local time formatted),
  ActorUsername, Action (badge), TargetType + TargetId, IpAddress, CorrelationId
  (copy button with toast).
- src/web/src/pages/admin/audit/AuditFilters.tsx — 4 filters form.
- src/web/src/tests/features/admin/audit/useAuditEvents.test.ts — hook unit.
- src/web/src/tests/features/admin/audit/AuditPage.test.tsx — component test
  with MSW handler mock.

Modified:
- src/web/src/router.tsx — /admin/audit route, protected by auth + permission
  'administracion:auditoria:ver'.
- src/web/src/components/layout/AppSidebar.tsx — sidebar entry (icon, visible
  only with the required permission, uses existing permission-filtering pattern).

OUT of scope (deferred to ADM-004):
- Row drilldown modal with full metadata JSON formatted.
- CSV export.
- Timeline-per-entity visualization.

Design System v2.4 conventions respected: DataTable component from
@/components/ui/data-table (no raw <table>), tokens only (no hex inline),
density compact, Radix tooltip for copy button, sonner toast on copy.

Vitest run: 29 test files / 161 tests passing. No regressions in existing
frontend tests.

Refs: sdd/udt-010-auditoria-trazabilidad/{spec, design#D-9, tasks#B12}
2026-04-16 17:07:13 -03:00
2bb90118ab feat(api): GET /audit/events + /health/audit (UDT-010 B10)
AuditController:
- GET /api/v1/audit/events?actorUserId&targetType&targetId&from&to&cursor&limit
- Protected by [RequirePermission("administracion:auditoria:ver")] — reuses
  the existing permission (V005/V006 seed assigns it to admin).
- 400 on limit out of [1,100] or from > to.
- Cursor-based DESC pagination via AuditEventRepository.QueryAsync.

AuditHealthCheck (IHealthCheck):
- Validates SYSTEM_VERSIONING ON on Usuario/Rol/Permiso/RolPermiso.
- Validates partition boundaries exist for next 3 months (both AuditEvent and
  SecurityEvent functions).
- Reports last audit event age (lenient 24h to accommodate dev/test quiet envs).
- Validates HISTORY_RETENTION_PERIOD == 10 YEARS on all 4 tables.
  Key fix during impl: sys.tables.history_retention_period is stored in UNITS
  (1=INFINITE, 3=DAY, 4=WEEK, 5=MONTH, 6=YEAR), NOT seconds. Assertion: period=10
  AND unit=6 (10 YEARS).
- Mapped at /health/audit via app.MapHealthChecks with tag 'audit'.

Tests (Strict TDD, integration against SIGCM2_Test):
- AuditControllerTests (5): without-auth 401, without-permission 403 (cajero),
  admin with filter returns events, invalid limit 400, from>to 400.
- AuditHealthCheckTests (1): returns Healthy with V010 applied.

Suite: 378/378 Application.Tests + 147/147 Api.Tests = 525/525 passing.

Refs: sdd/udt-010-auditoria-trazabilidad/{spec#REQ-AUD-7/8, design, tasks#B10}
2026-04-16 17:05:40 -03:00
b619c05762 feat(audit): security events en Auth + authorization handlers (UDT-010 B9)
Instruments auth pipeline with ISecurityEventLogger per #REQ-AUTH-SEC:

LoginCommandHandler:
- login success → action=login result=success actorUserId=user.Id
- login failure disaggregated internally (client still sees 401 unified):
  user_not_found / user_inactive / invalid_password
  — attempts captured with attemptedUsername + FailureReason

LogoutCommandHandler:
- action=logout result=success actorUserId=cmd.UsuarioId

RefreshCommandHandler:
- refresh.issue success on successful rotation
- refresh.reuse_detected failure when revoked token is presented (chain
  revoke already happens; we add the security event with metadata.familyId)
- refresh.issue failure for: token_expired / sub_mismatch / user_not_found /
  user_inactive

PermissionAuthorizationHandler:
- permission.denied failure on require-permission rejection, with metadata
  { permissionRequired, endpoint, method }. ActorUserId from JWT sub.

DI: ISecurityEventLogger was already registered by B6 (AddInfrastructure).

Test updates: 4 test classes now inject ISecurityEventLogger mock:
- LoginCommandHandlerTests, LogoutCommandHandlerTests, RefreshCommandHandlerTests
- PermissionAuthorizationHandlerTests (Api.Tests)

Suite: 378/378 Application.Tests + 141/141 Api.Tests = 519/519 passing.

Refs: sdd/udt-010-auditoria-trazabilidad/{spec#REQ-SEC-2/3/4/5 #REQ-AUTH-SEC,
design, tasks#B9}
2026-04-16 13:59:27 -03:00
a3f01bc6c9 feat(audit): enchufar audit en handlers de Rol (UDT-010 B8)
4 command handlers del módulo Roles + Permisos ahora auditan:

| Handler                              | Action                 |
|--------------------------------------|------------------------|
| CreateRolCommandHandler              | rol.create             |
| UpdateRolCommandHandler              | rol.update             |
| DeactivateRolCommandHandler          | rol.deactivate         |
| AssignPermisosToRolCommandHandler    | rol.permisos_update    |

Mismo patrón que B7 (using block + post-commit reads outside scope).

Metadata:
- rol.create: after={Codigo, Nombre, Descripcion}
- rol.update: {before, after} diff
- rol.permisos_update: {before, after} con arrays de codigos ordenados

AssignPermisosToRolCommandHandler captura 'before' leyendo
GetByRolCodigoAsync antes del TransactionScope para poder emitir el diff.

4 test classes actualizados con mock de IAuditLogger.

Suite: 378/378 Application.Tests + 141/141 Api.Tests = 519/519 passing.

Refs: sdd/udt-010-auditoria-trazabilidad/{spec#REQ-RM-AUD, design, tasks#B8}
2026-04-16 13:54:47 -03:00
26efb74c22 feat(audit): enchufar audit en handlers de Usuario — Closes #6
7 command handlers del módulo Usuarios ahora auditan via IAuditLogger:

| Handler                                 | Action                  |
|-----------------------------------------|-------------------------|
| CreateUsuarioCommandHandler             | usuario.create          |
| UpdateUsuarioCommandHandler             | usuario.update          |
| DeactivateUsuarioCommandHandler         | usuario.deactivate      |
| ReactivateUsuarioCommandHandler         | usuario.reactivate      |
| ChangeMyPasswordCommandHandler          | usuario.password_change |
| ResetUsuarioPasswordCommandHandler      | usuario.password_reset  |
| UpdateUsuarioPermisosOverridesHandler   | usuario.permisos_update |

Patrón por handler (per design #D-1):
  using (var tx = new TransactionScope(Required, ReadCommitted, AsyncFlowEnabled))
  {
      await repo.UpdateAsync(...);
      await audit.LogAsync(...);
      tx.Complete();
  }
  // post-commit reads OUTSIDE the using block
  var updated = await repo.GetDetailAsync(...);

Metadata captured:
- usuario.create: after={username, nombre, apellido, email, rol} — NO password.
- usuario.update: {before, after} diff of editable fields.
- usuario.password_reset: {targetId} only — tempPassword is NEVER persisted to
  audit (returned to caller once, never stored).
- usuario.permisos_update: {before, after} of grant/deny override lists.

Key fix during implementation: initially used 'using var tx = ...' (bare
declaration). This kept the TransactionScope active for the rest of the method,
causing 'The current TransactionScope is already complete' when post-commit
reads (GetDetailAsync) tried to enlist. Solution: explicit 'using (var tx = ...)
{ ... }' block that disposes the scope before post-commit reads.

AuditContextMissingException surfaces from AuditLogger when IAuditContext
lacks ActorUserId — fail-closed per #REQ-AUD-4. In integration tests, the
middleware populates ActorUserId from the JWT sub of the authenticated admin.

Test updates: 6 existing unit test classes now inject IAuditLogger mock:
- CreateUsuarioCommandHandlerTests
- UpdateUsuarioCommandHandlerTests
- DeactivateUsuarioCommandHandlerTests
- ReactivateUsuarioCommandHandlerTests
- ChangeMyPasswordCommandHandlerTests
- ResetUsuarioPasswordCommandHandlerTests

Follow-up #6 ([Auditoría] Registrar admin creador en alta de usuarios) is
closed: CreateUsuarioCommandHandler now records ActorUserId = admin JWT sub
on every user creation. TODO comment removed.

Suite: 378/378 Application.Tests + 141/141 Api.Tests = 519/519 passing.

Closes #6
Refs: sdd/udt-010-auditoria-trazabilidad/{spec#REQ-UM-AUD, design, tasks#B7}
2026-04-16 13:49:44 -03:00
a3d6214d09 feat(infra): AuditLogger + SecurityEventLogger impl (UDT-010 B6)
Composes the audit emission layer per design #D-8:

SIGCM2.Infrastructure/Audit/AuditLogger.cs (IAuditLogger):
- Enriches from IAuditContext (ActorUserId/ActorRoleId/Ip/UserAgent/CorrelationId).
- Sanitizes metadata via JsonSanitizer + AuditOptions.SanitizedKeys.
- Persists via IAuditEventRepository.InsertAsync.
- Fail-closed: throws AuditContextMissingException when ActorUserId is null.
- Translates Guid.Empty correlation id to null (DB column is nullable; Empty
  indicates 'no middleware ran').
- Uses System.DateTime.UtcNow for occurredAt.

SIGCM2.Infrastructure/Audit/SecurityEventLogger.cs (ISecurityEventLogger):
- NOT fail-closed: null ActorUserId is valid (login failures, anonymous
  permission.denied events).
- Ip/UserAgent pulled from IAuditContext; metadata sanitized the same way.
- Persists via ISecurityEventRepository.

DI: AddScoped for both loggers in AddInfrastructure.

Tests (Strict TDD, mocks for IAuditContext/IAuditEventRepository/
ISecurityEventRepository):
- AuditLoggerTests (6): happy path with full context, fail-closed null actor,
  metadata sanitization, null metadata pass-through, repo-throws-bubbles-up
  (critical for TransactionScope rollback), custom SanitizedKeys from options.
- SecurityEventLoggerTests (4): login.success with context, login.failure
  with null actor + attemptedUsername, metadata sanitization,
  permission.denied with both actor and attemptedUsername null.

Two initial failures were fixed by replacing 'null' literal arguments in
NSubstitute Received(...) assertions with Arg.Is<T?>(x => x == null) —
NSubstitute does not always match null literals when mixed with Arg.Any<T>().

Suite: 378/378 Application.Tests + 141/141 Api.Tests = 519/519 passing.

Refs: sdd/udt-010-auditoria-trazabilidad/{spec#REQ-AUD-4 #REQ-SEC-2/3, design#D-8, tasks#B6}
2026-04-16 13:41:10 -03:00
300badda73 feat(infra): audit + security event repositories (UDT-010 B5)
Introduces persistence layer for audit and security events per design #D-6:

SIGCM2.Application/Audit/:
- IAuditEventRepository: InsertAsync + QueryAsync with cursor pagination
- ISecurityEventRepository: InsertAsync only (no query — SecurityEvent is
  queried only from an admin dashboard deferred to ADM-004)
- AuditEventQueryResult: (Items, NextCursor) record

SIGCM2.Infrastructure/Audit/:
- AuditEventCursor (public): base64(OccurredAt:O|Id) opaque cursor for
  DESC pagination. TryDecode is fail-open — malformed cursor returns null
  and the query starts from the top.
- AuditEventRepository: Dapper INSERT via OUTPUT INSERTED.Id + dynamic
  WHERE composition with parameterized filters (zero SQL injection risk).
  LEFT JOIN to dbo.Usuario to populate ActorUsername in AuditEventDto.
  Pagination fetches Limit+1 rows to detect "more pages"; emits cursor
  from the Nth row when overflow observed.
- SecurityEventRepository: straight INSERT for login/logout/refresh/
  permission.denied events.

DI: AddScoped for both repos in AddInfrastructure.

Integration tests (Strict TDD): 13 total, all against SIGCM2_Test.
- AuditEventRepositoryTests (10): insert-roundtrip, filter-by-actor,
  filter-by-target, filter-by-date-range, cursor pagination across 3 pages
  (no overlap/no gap), malformed-cursor fail-open, LEFT JOIN Usuario
  populates username, cursor encode/decode roundtrip, cursor malformed
  variants.
- SecurityEventRepositoryTests (3): insert success, insert failure with
  null ActorUserId + AttemptedUsername, CK_SecurityEvent_Result rejection.

Suite: 368/368 Application.Tests + 141/141 Api.Tests = 509/509 passing.

Refs: sdd/udt-010-auditoria-trazabilidad/{spec#REQ-AUD-2,7 #REQ-SEC-1,
design#D-6, tasks#B5}
2026-04-16 13:38:05 -03:00
0b4af4c332 feat(api): audit context middleware + scoped impl (UDT-010 B4)
Wires the request-scoped audit context per design #D-2:

Middleware pipeline in Program.cs:
  app.UseCors()
  app.UseMiddleware<CorrelationIdMiddleware>()  // PRE-AUTH
  app.UseAuthentication()
  app.UseMiddleware<AuditActorMiddleware>()     // POST-AUTH
  app.UseAuthorization()
  app.MapControllers()

SIGCM2.Api/Middleware/CorrelationIdMiddleware.cs:
- Preserves client-sent X-Correlation-Id header when a valid GUID, otherwise
  generates Guid.NewGuid(). Stores in HttpContext.Items (audit:correlationId).
- Captures Ip (Connection.RemoteIpAddress) + UserAgent header into Items.
- Echoes the correlation id back via response header (OnStarting + immediate
  set — immediate set makes unit testing against DefaultHttpContext reliable).

SIGCM2.Api/Middleware/AuditActorMiddleware.cs:
- Reads JWT 'sub' claim from authenticated HttpContext.User, parses to int,
  stores as audit:actorUserId. Anonymous / non-numeric sub leaves it unset.

SIGCM2.Infrastructure/Audit/AuditContext.cs (IAuditContext scoped impl):
- Reads Items entries via IHttpContextAccessor. Returns null / Guid.Empty
  when no HttpContext is available (jobs, tests without middleware).
- ActorRoleId intentionally null for now — rol code → id resolution is
  deferred; the logger may resolve it at persist time in a later batch.

DI registration (Infrastructure/DependencyInjection.cs):
- services.AddScoped<IAuditContext, AuditContext>()

Tests (Strict TDD):
- CorrelationIdMiddlewareTests (6): generates/preserves/handles-malformed
  correlation id, sets response header, captures ip/ua, calls next.
- AuditActorMiddlewareTests (5): authenticated/anonymous/no-sub/non-numeric/
  calls-next.
- AuditContextTests (7): reads from Items, null-http-context defaults,
  ActorRoleId currently null.

Suite: 355/355 Application.Tests + 141/141 Api.Tests = 496/496 passing.

Refs: sdd/udt-010-auditoria-trazabilidad/{spec#REQ-AUD-3/9, design#D-2, tasks#B4}
2026-04-16 13:32:13 -03:00
08d6622e43 feat(infra): JsonSanitizer + AuditOptions binding (UDT-010 B3)
Adds the metadata sanitization layer per #REQ-AUD-5:

SIGCM2.Infrastructure/Audit/JsonSanitizer.cs (static class):
- Sanitize(object?, IReadOnlyCollection<string>) -> string?
- Serializes via System.Text.Json + JsonNode recursive traversal.
- Strips blacklisted keys at every nesting level (objects + arrays).
- Case-insensitive match (ToLowerInvariant on both sides).
- Null input -> null output (never throws).
- Output is always valid JSON (ISJSON=1 compatible — satisfies AuditEvent CHECK).

SIGCM2.Application/Audit/AuditOptions.cs:
- Documented the IConfiguration array-binding quirk: config is ADDITIVE
  (append at higher indices), not REPLACE. Intentional for security — defaults
  like 'password'/'token'/'cvv' must not be silently dropped.

SIGCM2.Infrastructure/DependencyInjection.cs:
- services.Configure<AuditOptions>(configuration.GetSection(AuditOptions.SectionName))
  wired in AddInfrastructure().

Tests (Strict TDD, RED -> GREEN):
- JsonSanitizerTests (10): null/empty-blacklist/flat/nested/arrays/case-insensitive/
  primitives/round-trip-valid-json/string-as-value/default-keys-effective.
- AuditOptionsBindingTests (2): defaults when section absent + additive override.

One test needed adjustment during GREEN: 'AlreadySerializedJsonString' originally
asserted against an encoding-specific literal; rewrote to use JsonDocument
round-trip (validates behavior without coupling to encoder quirks).

Suite: 348/348 Application.Tests + 130/130 Api.Tests = 478/478 passing.

Refs: sdd/udt-010-auditoria-trazabilidad/{spec#REQ-AUD-5, design#D-5, tasks#B3}
2026-04-16 13:28:37 -03:00
68f96b90c7 feat(application): audit abstractions (UDT-010 B2)
Introduces the contract layer for audit logging per design #D-8:

SIGCM2.Application/Audit/:
- IAuditContext — request-scoped accessor for ActorUserId/ActorRoleId/
  Ip/UserAgent/CorrelationId. Populated by CorrelationIdMiddleware +
  AuditActorMiddleware (B4).
- IAuditLogger.LogAsync(action, targetType, targetId, metadata?, ct) —
  domain-level audit emitter. Enlists in ambient TransactionScope
  (fail-closed per #REQ-AUD-4).
- ISecurityEventLogger.LogAsync(action, result, actorUserId?, attemptedUsername?,
  sessionId?, failureReason?, metadata?, ct) — security-events emitter
  separate from IAuditLogger (different retention, no transaction scope,
  captures login/logout/refresh/permission.denied).
- AuditOptions — bindable POCO with SanitizedKeys[] defaults (used by
  JsonSanitizer in B3).
- AuditEventDto — read projection for GET /api/v1/audit/events (B10).
- AuditEventFilter — query filter record with default Limit=50.

SIGCM2.Domain/Exceptions/:
- AuditContextMissingException : DomainException — fail-closed sentinel
  thrown when IAuditLogger is called without ActorUserId in a user-scoped
  command (#REQ-AUD-4).

Tests (Strict TDD — shape contract, RED → GREEN):
- tests/SIGCM2.Application.Tests/Audit/AuditAbstractionsTests.cs: 11 tests
  covering nullability, signatures, default options, record equality.

Suite: 336/336 Application.Tests (prev 325 + 11 new). 130/130 Api.Tests.

Refs: sdd/udt-010-auditoria-trazabilidad/{spec#REQ-AUD-3/4/5, design#D-8, tasks#B2}
2026-04-16 13:23:11 -03:00
c95bc7fe01 fix(tests): extend Respawn + collection config for UDT-010 temporal tables
Follow-up of B1 (V010 migration). Issues found when running the full suite
cross-assembly:

1. Respawn 'Cannot delete rows from a temporal history table' error:
   4 per-class Respawner configs in SIGCM2.Application.Tests did not
   include the newly-created *_History tables introduced by V010
   (Usuario_History / Rol_History / Permiso_History / RolPermiso_History).
   The engine rejects direct DELETE on system-versioned history tables.
   Extended TablesToIgnore in all 4 configs.

2. FK_RefreshToken_Usuario violation in RolRepositoryTests.InitializeAsync:
   Manual 'DELETE FROM Usuario' failed when residual RefreshTokens from
   prior suites existed. Added 'DELETE FROM RefreshToken' before the
   Usuario cleanup to respect FK order. Latent bug surfaced by a new
   test-run ordering — not UDT-010 specific, but fixed in scope.

3. UQ_Usuario_Username duplicate admin race:
   TransactionScopeSpikeTests (B0) and V010MigrationTests (B1) were
   missing [Collection("ApiIntegration")], causing them to run in
   parallel with the rest of SIGCM2.Api.Tests and race on SeedAdmin.
   Serialized by adding the Collection attribute.

Suite now passes cross-assembly: 130/130 Api.Tests + 336/336 Application.Tests.

Refs: sdd/udt-010-auditoria-trazabilidad/apply-progress (B1 follow-up)
2026-04-16 13:22:56 -03:00
1c79dfa0a4 feat(db): V010 audit infrastructure + temporal tables
Applied to SIGCM2 (dev) and SIGCM2_Test.

V010__audit_infrastructure.sql (idempotent, ~280 LoC):
- Filegroups AUDIT_HOT + AUDIT_COLD with physical files (per-DB logical names
  via DB_NAME() prefix to avoid collision in dev/test).
- pf/ps_AuditEvent_Monthly + pf/ps_SecurityEvent_Monthly (RANGE RIGHT,
  DATETIME2(3), 14 boundaries 2026-01..2027-02 → 15 partitions). Job extends
  forward monthly in B11.
- dbo.AuditEvent (partitioned, clustered PK on OccurredAt+Id) + 4 indexes
  (Actor/Target/Action/Correlation) with PAGE compression.
- dbo.SecurityEvent (partitioned) + 3 indexes (Actor/Action_Result/Ip_Failure).
- CHECK constraints: Action LIKE '%.%', ISJSON(Metadata), Result IN (success|failure).
- SYSTEM_VERSIONING ON in Usuario/Rol/Permiso/RolPermiso with 10 YEARS retention +
  PAGE compression in history tables.
- No hard FK on ActorUserId → Usuario.Id (soft FK — audit must survive user deletion).

V010_ROLLBACK.sql: emergency reversal (WARNING: destroys all audit history).

database/README.md: migration order + V010 prod-apply notes.

tests/SIGCM2.TestSupport/SqlTestFixture.cs:
- EnsureV010SchemaAsync() validates audit infra is applied (fails fast with
  clear message if not — migration itself requires ALTER DATABASE privileges
  and is applied manually via sqlcmd).
- Respawn TablesToIgnore extended with *_History (engine rejects direct DELETE
  on system-versioned history tables).

tests/SIGCM2.Api.Tests/Audit/V010MigrationTests.cs — 5 smoke tests:
- AuditEvent insert+roundtrip with CorrelationId.
- CK_AuditEvent_Action rejects Action without '.'.
- CK_AuditEvent_Metadata rejects non-JSON.
- CK_SecurityEvent_Result rejects invalid Result.
- Usuario SYSTEM_VERSIONING: temporal query FOR SYSTEM_TIME AS OF returns
  pre-update state + Usuario_History populated.

Suite: 130/130 passing (previous 124 + spike B0 + 5 new B1). No regressions.

Refs: sdd/udt-010-auditoria-trazabilidad/{spec#REQ-AUD-1,2, #REQ-SEC-1,
design#D-4, tasks}
2026-04-16 13:10:04 -03:00
2d1d187f6e chore(udt-010): bootstrap rama + spike anti-MSDTC
Validates design decision #D-1 (TransactionScope ambient over IUnitOfWork):
TransactionScope with TransactionScopeAsyncFlowOption.Enabled does NOT
escalate to MSDTC when multiple SqlConnections share the same connection
string. Test passes (DistributedIdentifier == Guid.Empty).

Unblocks UDT-010 batches B1-B14.

Refs: sdd/udt-010-auditoria-trazabilidad/{design,tasks}
2026-04-16 12:56:17 -03:00
d201d9e08e Merge pull request 'Design System: bootstrap tokens + paleta brand El Día' (#13) from chore/design-system-tokens into main 2026-04-16 15:00:26 +00:00
fa76d0055a feat(web): infra UI completa pre-ADM-001 — DataTable + 8 shadcn + MCP global
shadcn MCP server registrado globalmente (claude mcp add -s user shadcn -- npx -y shadcn@latest mcp). Disponible en cualquier sesion/proyecto.

8 componentes shadcn nuevos via CLI (cd src/web && npx shadcn@latest add ...):
- table, select, popover, pagination, breadcrumb, alert-dialog
- command, dialog (deps de combobox y alert-dialog)
Total instalados ahora: 22

Fix gotcha shadcn CLI: agregado compilerOptions.paths al root tsconfig.json
(sino crea folder literal '@/' en lugar de resolver el alias). Antes solo
estaba en tsconfig.app.json que el CLI no lee.

@tanstack/react-table 8.21 instalado.

Nuevo componente <DataTable> generico (src/web/src/components/ui/data-table.tsx):
- Wrapper sobre TanStack Table
- Priority columns: meta { priority: 'high' | 'medium' | 'low' }
  → hidden md:table-cell / hidden lg:table-cell automatico
- Tap-to-expand row mobile (chevron auto-aparece cuando hay cols hidden,
  click despliega panel con hidden cells como dl/dt/dd)
- Loading state con DataTableSkeleton
- Empty state customizable
- onRowClick callback con stop-propagation correcto en chevron
- 14 tests cubriendo todas las features

Refactor UsersTable a DataTable como dogfood (mismo output visual,
columnas con priority alta/media/baja). 150 tests frontend totales verde.

Doc Obsidian 2.14 v2.4 actualizado con seccion DataTable completa,
componentes ampliados a 22, MCP global, y gotcha del tsconfig.

Engram sig-cm2/design-system actualizado a v2.4.

Skill registry actualizado con compact rules de DataTable y MCP.
2026-04-16 11:54:14 -03:00
5f7d9e6b89 docs(skill-registry): actualizar compact rules design system v2.3
Sincroniza con doc Obsidian 2.14 v2.3 y engram sig-cm2/design-system:
- Agrega violet accent y nueva guidance
- Density bumpeada a 40px (input h-10), radius 10px base
- Utilities CSS (.glass/.gradient-mesh/.grid-bg/.surface/.focus-glow)
- Card variants ampliados
- Tooltip Radix obligatorio (no CSS absolute en sidebars)
- Sidebar colapsable con useSidebar
- TooltipProvider y Toaster ya en App.tsx
- Browser autofill fix mencionado
2026-04-16 11:32:07 -03:00
83d76b95d4 feat(web): tooltips Radix + toggle sidebar al lado del brand
Cambios:
- Instalado @radix-ui/react-tooltip 1.2.8 (componente faltante de
  shadcn/ui que faltaba en el set inicial).
- Nuevo src/web/src/components/ui/tooltip.tsx (shadcn pattern):
  TooltipProvider, Tooltip, TooltipTrigger, TooltipContent con
  animaciones data-state (fade + zoom + slide direccional).
- App.tsx: TooltipProvider envuelve toda la app con delayDuration 150ms.
- AppSidebar refactorizado:
  - Toggle button MOVIDO al header (top), al lado izquierdo del nombre
    'SIG-CM 2.0'. Eliminado boton bottom (era redundante).
  - Cuando collapsed: solo el toggle visible centrado (68px width).
  - Cuando expanded: [Toggle] [SIG-CM 2.0] aligned left.
  - Quitado overflow-hidden del aside (era lo que impedia que los
    tooltips fueran visibles — los clipping containers padres tampoco
    importan ahora porque Radix portalea el tooltip a body).
  - Tooltips en TODOS los items collapsed (incluido el toggle) y en
    items disabled muestra 'Label · Próximamente'.
  - Eliminado el componente CSS-only SidebarTooltip (reemplazado por
    Radix que se renderiza fuera del DOM tree con Portal).

El bug original era que tanto el aside con overflow-hidden como el
ProtectedLayout con overflow-hidden clipean cualquier elemento que
intente escapar via absolute positioning. Radix Portal soluciona
eso renderizando el tooltip en document.body.

Tests 136/136 verde.
2026-04-16 11:26:41 -03:00
7b7ef1c137 feat(web): sidebar colapsable con tooltips + fix scroll horizontal
Cambios:
- Nuevo hook useSidebar() con persistencia en localStorage
  ('sidebar-collapsed' = '1'/'0').
- SidebarNav refactorizado:
  - Width controlled internamente (w-60 expanded, w-[68px] collapsed)
  - Toggle button al pie con PanelLeftClose/Open icon
  - Brand mark con gradient brand+violet (consistencia con login)
  - Active indicator: barra vertical sutil a la izquierda cuando expanded,
    bg accent cuando collapsed
  - SectionLabel se reemplaza por divider sutil cuando collapsed
- Custom SidebarTooltip puro CSS (sin radix dep nuevo) que aparece
  a la derecha del item con animacion fade + slide al hover. Funciona
  con group-hover/item y group-hover/toggle (Tailwind named groups).
- Items disabled muestran badge 'Próx.' chico (era 'Próximamente' largo)
  y en tooltip cuando collapsed: 'Label · Próximamente'.
- Fix scroll horizontal: overflow-x-hidden en nav, truncate en spans,
  shrink-0 en iconos y badges. Layout robusto a labels largos.
- ProtectedLayout deja de hardcodear lg:w-60 — sidebar controla su width.
- AppHeader Sheet (mobile) usa <SidebarNav forceExpanded /> para que
  en mobile siempre se vea expanded sin importar el state desktop.

Tests 136/136 verde.
2026-04-16 11:21:42 -03:00
41b6882b5c feat(web): mas contraste cards/tables sobre bg + utility .surface
Cambios de tokens:
- Light --background: 0.988 -> 0.962 (slate cool, hace pop el card white)
- Dark --background: 0.135 -> 0.130 (mas oscuro)
- Dark --card: 0.180 -> 0.220 (salto +0.090 vs bg, antes solo +0.045)
- Dark --popover: 0.200 -> 0.245 (popovers/dropdowns aun mas elevados)
- Dark --secondary/muted/accent/input: bumpeados al nivel correspondiente
  para que la jerarquia visual mantenga proporciones

Card variants:
- default: shadow-sm -> shadow-md (mas elevacion default)
- nuevo variant 'flat' (sin shadow) para cuando se necesite

Nueva utility .surface:
- Mismo treatment visual que <Card variant='default'> pero como clase
  CSS — para contenedores que no usan el componente Card (ej: tablas,
  listas custom). bg-card + border + radius + shadow-md.

UsersTable refactorizado para usar .surface en lugar de border manual.
Cualquier futura tabla/lista usa .surface por consistencia.

Tests 136/136 verde.
2026-04-16 11:15:36 -03:00
278e1cf378 fix(web): light mode profundidad + grid global + autofill fix
Cambios:
- index.css: fix de browser autofill (Chrome/Safari forzaba bg amarillo +
  texto blanco que rompia contraste). Override -webkit-text-fill-color
  + box-shadow inset para mantener tokens del DS. Esta era la causa real
  de las 'letras blancas en gris' que se veian en login.
- index.css: utility .grid-bg global (7% opacity light, 10% dark) — para
  usar como fondo cuadriculado en todos los layouts.
- PublicLayout: agrego .grid-bg layer + bg-background explicito + glow
  blobs mas intensos (25%/20% en light vs 10% antes). Light ahora
  tiene la misma profundidad visual que dark.
- ProtectedLayout: agrego .grid-bg + glow blobs sutiles en corners para
  dar profundidad al dashboard y todas las secciones internas. Resalta
  futuros componentes glass.

Tests: 136/136 verde.
2026-04-16 11:10:06 -03:00
3bc2625e21 fix(web): agregar ThemeToggle en PublicLayout (login)
El ThemeToggle solo vivia en AppHeader (ProtectedLayout), por lo que
desde /login era imposible cambiar el tema. Movido a esquina superior
derecha con z-index 20 sobre el gradient mesh.

useTheme defaultea a system preference, pero el usuario tiene que poder
override desde cualquier pantalla — incluido el login.
2026-04-16 11:05:39 -03:00
6e6c729bac feat(web): design system v2 — tech sophisticated con glass + gradient mesh
Cambios principales:
- Agregado violet accent (oklch 0.62 0.20 280) para combo tech con brand cyan
- Neutrals con shift sutil hacia hue 250 (slate-violet)
- Dark mode con bg oklch(0.135 0.018 252) — no pure black, feel mas tech
- Inputs con token --input propio (white en light, elevado en dark) y --input-border mas prominente. Fixea problema de input gris feo
- Card soporta variant glass/elevated/default
- Multi-layer shadows reales (shadow-sm/md/lg/xl/glow)
- Gradient mesh utility (.gradient-mesh + token --gradient-mesh)
- Clase .glass para glassmorphism (backdrop-blur 20px + saturate 180%)
- Border radius default 10px (era 8px) — mas moderno
- Headings con tracking-tight -0.015em

LoginPage redesigned:
- PublicLayout con gradient mesh + 2 glow blobs (brand+violet) + grid sutil
- Card variant glass para el form
- Logo mark con bg-gradient-to-br from-brand-500 to-violet-500
- Inputs con bg propio + ring brand glow al focus

Tests: 136/136 verde.
Doc Obsidian 2.14 actualizado v2.0. Engram sig-cm2/design-system actualizado.
2026-04-16 11:02:59 -03:00
c488e2430d feat(web): bootstrap design system con paleta brand El Dia
- Reemplazo de tokens HSL por OKLCH (Tailwind 4 native)
- Brand color #008fbe escalado a brand-50..950
- Neutral cool slate (complementa brand)
- Semantic: success/warning/destructive como tokens
- Background tinted off-white (no pure white) para warmth
- Dark mode usa neutral-950 (no pure black)
- Brand utilities expuestos via @theme inline (bg-brand-500, etc)
- Focus rings con ring brand color
- Selection con brand-200/800

Skill registry actualizado con compact rules de design system para auto-inyeccion en sub-agents.

Source of truth: Obsidian/02-ARQUITECTURA-y-TECH-STACK/2.14 Design System.md
Engram: sig-cm2/design-system
2026-04-16 10:46:07 -03:00
492705c076 Merge pull request 'UDT-009: Overrides de PermisosJson por usuario — cierre módulo Auth' (#12) from feature/UDT-009 into main 2026-04-16 13:12:23 +00:00
6822d56e11 fix(web): montar Toaster + feedback toast en PermisosEditor [UDT-009]
Sonner estaba como dependencia pero el componente Toaster nunca se monto
en el arbol de la app. ChangeMyPasswordPage ya usaba toast() pero no
mostraba nada visualmente. Agregado <Toaster richColors closeButton /> en
App.tsx (top-right) y toasts de exito/error en PermisosEditor.handleSave
para confirmar al usuario que el cambio se persistio.
2026-04-16 10:11:04 -03:00
a30b10ebff fix(web): UsuarioPermisos shape nested para matchear backend [UDT-009]
El backend devuelve { rolPermisos, overrides: {grant, deny}, effective }
(nested) segun spec, pero el frontend lo lee como {grant, deny} planos.
Causaba TypeError: permisoData.grant is not iterable al abrir tab Permisos.
Tests del frontend actualizados con el shape correcto.
2026-04-16 10:06:43 -03:00
b7882613a4 feat(web): UserEditPage con tabs Perfil+Permisos [UDT-009] 2026-04-15 21:50:55 -03:00
9dbf3e895d feat(web): tabs.tsx + PermisosEditor tri-state [UDT-009] 2026-04-15 21:49:48 -03:00
c1426b2257 feat(web): api + hooks permisos overrides [UDT-009] 2026-04-15 21:48:06 -03:00
7d4dc4d2bb feat(api): PUT /api/v1/users/{id}/permisos/overrides + excepciones domain + ExceptionFilter [UDT-009] 2026-04-15 21:43:38 -03:00
47323302cc feat(api): GET /api/v1/users/{id}/permisos con CQRS handler [UDT-009] 2026-04-15 21:43:08 -03:00
5fd88b5a9d feat(infra): IUsuarioRepository.UpdatePermisosJsonAsync + impl Dapper [UDT-009] 2026-04-15 21:33:39 -03:00
bf64ffb35e feat(api): PermissionAuthorizationHandler resuelve overrides desde DB por request [UDT-009] 2026-04-15 21:32:35 -03:00
fb07a1139a feat(application): LoginCommandHandler usa PermisoResolver para permisos efectivos [UDT-009] 2026-04-15 21:29:33 -03:00
86310de286 feat(security): remover claim permisos del JWT post-UDT-009 [UDT-009] 2026-04-15 21:28:26 -03:00
54955231bf feat(infra): V009 migration + Usuario.WithPermisosJson + SqlTestFixture V009 schema [UDT-009] 2026-04-15 21:27:29 -03:00
da1eb83ac1 feat(application): PermisosOverride record + PermisoResolver static helper [UDT-009] 2026-04-15 21:25:09 -03:00
be86c2fac9 chore(repo): bootstrap feature/UDT-009 [UDT-009] 2026-04-15 21:23:43 -03:00
68897f446b Merge pull request 'UDT-008: Gestión completa de usuarios' (#11) from feature/UDT-008 into main 2026-04-16 00:01:36 +00:00
06908263f6 fix(web): cablear ResetPasswordModal en UserEditPage [UDT-008]
El row click de UsersListPage navega directo a /usuarios/:id/editar,
por lo que el modal montado solo en UserDetailPage no era alcanzable
desde el flujo real. Ahora tambien esta en el header del EditPage,
al lado del boton Volver, oculto cuando el target es el user logueado.
2026-04-15 21:00:08 -03:00
9e93c70d8b refactor(web): mover Cambiar contraseña de sidebar a menu perfil [UDT-008]
La seccion Mi cuenta en el sidebar quedaba desprolija con un unico item.
Se movio Cambiar contraseña al dropdown del avatar en AppHeader donde
pertenece semanticamente.
2026-04-15 20:55:26 -03:00
851fed8692 fix(web): cablear ResetPasswordModal en UserDetailPage [UDT-008]
El componente ResetPasswordModal estaba implementado pero nunca montado en una pagina.
Ahora se renderiza en UserDetailPage, oculto cuando el target es el usuario logueado
(evita hit de cannot-self-reset en backend).
2026-04-15 20:54:25 -03:00
2e2d4543ad feat(web): router wiring completo + nav link usuarios + MustChangePasswordGate integration [UDT-008]
- Agrega ProtectedPage helper que combina ProtectedRoute + MustChangePasswordGate + ProtectedLayout
- Rutas nuevas: /usuarios, /usuarios/:id, /usuarios/:id/editar con permisos RBAC
- /perfil/contrasena sin MustChangePasswordGate (evita redirect loop)
- Sidebar: sección "Mi cuenta" con cambio de contraseña; link Usuarios en sección admin
2026-04-15 18:12:54 -03:00
25ed0f6452 feat(web): ChangeMyPasswordPage + ResetPasswordModal — hooks, pages, modal [UDT-008] 2026-04-15 18:09:59 -03:00
64e0a8b5fb feat(web): UserDetailPage + UserEditPage — get/update/deactivate/reactivate hooks y pages [UDT-008] 2026-04-15 18:06:54 -03:00
9512f4125d feat(web): UsersListPage — api client, hook, filters, table, pagination [UDT-008] 2026-04-15 18:05:07 -03:00
d998d215e0 feat(web): authStore username+mustChangePassword + MustChangePasswordGate [UDT-008] 2026-04-15 18:02:20 -03:00
7d96d5ff18 feat(api): ResetPassword admin — TempPasswordGenerator, handler, endpoint POST /{id}/password/reset [UDT-008]
Batch 7: POST /api/v1/users/{id}/password/reset (admin only).
- TempPasswordGenerator: RandomNumberGenerator.Fill, 12-char min, full charset diversity, never logs result
- ResetUsuarioPasswordCommandHandler: self-reset guard, 404, hash, mustChangePassword=true, revoke all tokens
- ExceptionFilter: CannotSelfResetException → 400 {error: cannot-self-reset}
- Unit tests: TempPasswordGeneratorTests (8), ResetUsuarioPasswordCommandHandlerTests (5)
- Integration tests: ResetPasswordEndpointTests (6) — 200/length/self-reset/404/401/403
2026-04-15 17:55:45 -03:00
a3bd066f7b feat(api): ChangeMyPassword — validator, handler, endpoint PUT /me/password [UDT-008] 2026-04-15 17:52:15 -03:00
473566f255 feat(api): Deactivate + Reactivate usuarios — idempotentes, anti-lockout, revoke tokens [UDT-008] 2026-04-15 17:50:54 -03:00
14c385fdb1 feat(api): UpdateUsuario — handler, validator, anti-lockout guard, revoke tokens [UDT-008] 2026-04-15 17:49:19 -03:00
2925336783 feat(api): List + GetById usuarios — handlers, repo, endpoints [UDT-008] 2026-04-15 17:46:23 -03:00
9dcd63543e feat(auth): extend LoginResponse with username + mustChangePassword + ultimoLogin [UDT-008] 2026-04-15 17:39:48 -03:00
d1f7b3805b feat(domain): V008 migration + Usuario with-methods + DomainException hierarchy [UDT-008] 2026-04-15 17:36:46 -03:00
5ddc5ddf02 chore(udt-008): bootstrap rama feature/UDT-008 [UDT-008] 2026-04-15 17:34:12 -03:00
c0d1ea4ac2 Merge pull request 'UDT-006: Middleware de Autorización (RBAC enforcement)' (#10) from feature/UDT-006 into main 2026-04-15 20:15:18 +00:00
8513e99554 test(api): assert count 21 permisos admin post-V007 [UDT-006] 2026-04-15 16:49:54 -03:00
96e7290fb7 refactor(web): eliminar guards inline rol admin en páginas de roles/permisos [UDT-006] 2026-04-15 16:49:21 -03:00
f6cdd7650b feat(web): ProtectedRoute extraído + router migrado + CreateUserPage cleanup [UDT-006] 2026-04-15 16:41:39 -03:00
8935115da9 feat(web): usePermission + CanPerform [UDT-006] 2026-04-15 16:40:23 -03:00
2efd5e2fdb feat(web): authStore + useLogin persisten permisos [UDT-006] 2026-04-15 16:39:18 -03:00
0218d8d371 feat(api): migrar controllers admin a RequirePermission [UDT-006] 2026-04-15 16:34:32 -03:00
4866c4f21f feat(api): ForbiddenProblemDetailsHandler 403 shape [UDT-006] 2026-04-15 16:27:36 -03:00
58d0df601f feat(api): RequirePermissionAttribute + PermissionAuthorizationHandler [UDT-006] 2026-04-15 16:26:30 -03:00
cdb8dcd03c feat(api): login response permisos desde RolPermiso [UDT-006] 2026-04-15 16:24:21 -03:00
2afac53fca Merge pull request 'UDT-005: Gestión de Permisos (RBAC) — catálogo + asignación rol↔permisos' (#9) from feature/UDT-005 into main 2026-04-15 19:02:02 +00:00
1a864e9f8b fix(app): validar formato codigo rol en GetRolPermisos [UDT-005]
Agrega GetRolPermisosQueryValidator con regex ^[a-z][a-z0-9_]*$ para
rechazar codigos invalidos con 400 en GET /api/v1/roles/{codigo}/permisos.
2026-04-15 15:56:49 -03:00
885a8cef17 feat(web): BATCH 6 - feature permisos con grid por modulo [UDT-005]
- api/types.ts: PermisoDto, AssignPermisosRequest
- api/listPermisos, getRolPermisos, assignPermisos
- hooks: usePermisos, useRolPermisos, useAssignPermisos (TanStack Query)
- components/RolPermisosEditor: checkbox-grid agrupado por modulo (codigo.split(':')[0])
- pages/RolPermisosPage: selector rol activo + guard admin + RolPermisosEditor
- router.tsx: ruta /admin/permisos
- AppSidebar.tsx: link Permisos (KeyRound icon) en seccion admin
- tests: 5 smoke tests RolPermisosEditor (render, prefill, toggle, save, 400)
2026-04-15 15:46:49 -03:00
4913a35d06 feat(api): BATCH 5 - PermisosController + tests HTTP [UDT-005] 2026-04-15 15:42:03 -03:00
be2257a9bf feat(infra): BATCH 4 - Permiso/RolPermiso repos Dapper + tests integracion [UDT-005] 2026-04-15 15:39:25 -03:00
704794a2e2 feat(app): BATCH 3 - handlers permisos con TDD [UDT-005] 2026-04-15 15:31:26 -03:00
7ddb71c24c feat(domain): BATCH 2 - Permiso entity + catalogo const [UDT-005] 2026-04-15 15:31:20 -03:00
7d2190c37e feat(db): BATCH 1 - V005/V006 Permiso y RolPermiso + seed [UDT-005] 2026-04-15 15:26:22 -03:00
f6ad371de4 chore(tests): BATCH 0 - agregar Permiso y RolPermiso a TablesToIgnore [UDT-005] 2026-04-15 15:26:19 -03:00
4d3e55c422 Actualizar README.md 2026-04-15 17:36:36 +00:00
e5ee8e673b Merge pull request 'UDT-004: Gestión de Roles (tabla maestra + CRUD admin + validator dinámico + UI)' (#8) from feature/UDT-004 into main 2026-04-15 16:19:58 +00:00
57e4cdac01 chore(tests): limpia warning xUnit2012 en CreateUsuario_WithInactiveRol_Returns400
Reemplaza Assert.True(enumerable.Any(...)) por Assert.Contains idiomatico.
2026-04-15 13:03:18 -03:00
fae06fb8b8 feat(web): UDT-004 gestion de roles + UserForm dinamico
- features/roles: API clients (list/get/create/update/deactivate), TanStack Query hooks, RolForm (create + edit variants), RolesList con acciones y guard 409, paginas RolesPage/NewRolPage/EditRolPage
- router.tsx: rutas /admin/roles, /admin/roles/nuevo, /admin/roles/:codigo/editar
- AppSidebar: nav Roles (admin-only)
- features/users: useRolesForSelect wrapper (filtra activo=true), UserForm fetchea roles async con loading/error states; elimina ROL_OPTIONS hardcoded
- tests: 47 vitest verdes (10 authStore + 5 auth api + 7 axios + 3 useCreateUser + 3 RolesList + 5 LoginPage + 7 UserForm + 7 RolForm). Typecheck limpio
2026-04-15 12:58:08 -03:00
6f999b8fcd feat(api): UDT-004 controller de roles + refactor validator UDT-003 a lookup dinamico
- RolesController /api/v1/roles CRUD admin-only: GET list, GET {codigo}, POST, PUT, DELETE (soft-delete con guard 409)
- ExceptionFilter: mapea RolNotFound (404), RolAlreadyExists (409), RolInUse (409)
- DI: registra 5 handlers de Roles (Application) y IRolRepository/RolRepository (Infrastructure)
- CreateUsuarioCommandValidator: reemplaza whitelist hardcoded por IRolRepository.ExistsActiveByCodigoAsync via MustAsync; constructor recibe (AuthOptions, IRolRepository)
- Tests: 202 verdes (173 application + 29 api). Nuevas: RolesEndpointTests (13 integration), CreateUsuarioCommandValidatorTests reescrito con NSubstitute mock, CreateUsuario_WithInactiveRol_Returns400 en Api.Tests
- Fix: ApiIntegration pasa de IClassFixture (N factories) a ICollectionFixture (1 factory shared) — evitaba ObjectDisposedException sobre RSABCrypt al compartir coleccion con multiples test classes
- tests/tests.runsettings: MaxCpuCount=1 para evitar race entre assemblies sobre SIGCM2_Test
2026-04-15 12:50:24 -03:00
34b714750a feat(api): UDT-004 dominio + repositorio + application roles (tdd)
- Migraciones V003 (tabla Rol + 8 seeds canonicos) y V004 (drop CK + FK Usuario.Rol)
- Dominio: Rol entity + 3 excepciones (RolNotFound/AlreadyExists/InUse)
- Infraestructura: RolRepository (Dapper) con List/Get/ExistsActive/Add/Update/HasActiveUsuarios
- Application: CRUD queries y commands (List, Get, Create, Update, Deactivate) + validators (codigo regex ^[a-z][a-z0-9_]*$)
- Validator UDT-003: whitelist alineada a codigos canonicos (full IRolRepository lookup diferido a Phase 5.1)
- Tests: 169 application + 15 api (todos verdes). Respawn configurado para re-seedear Rol canonical post-reset.
- Estricto TDD: RED/GREEN/TRIANGULATE en todos los handlers nuevos.
2026-04-15 12:31:29 -03:00
e0e9ec3b88 Actualizar README.md 2026-04-15 14:28:52 +00:00
c6352e1e39 Merge pull request 'docs: add minimal README' (#7) from chore/readme into main 2026-04-15 14:27:49 +00:00
ddc7c8d53d docs: add minimal README with stack, structure, run instructions 2026-04-15 11:27:20 -03:00
890da06f71 Merge pull request 'UDT-003: Registro de Usuarios (admin-only) + fix JWT claim mapping' (#4) from feature/UDT-003 into main 2026-04-15 14:23:53 +00:00
bce591e63c fix(auth): preserve JWT claim names in bearer middleware
JwtBearerOptions.MapInboundClaims defaulted to true, which mapped the
'sub' claim to ClaimTypes.NameIdentifier in HttpContext.User. Logout
endpoint read User.FindFirst("sub") and got null, returning 401 for
any authenticated caller.

Fix: set MapInboundClaims=false and pin NameClaimType="name" so the
JWT claims land in the principal with their original names, aligning
with how JwtService.GetPrincipalFromExpiredToken (used by refresh)
already consumes them.

Unblocks Login_Refresh_Logout_FullFlow integration test (15/15 green).
2026-04-15 11:03:15 -03:00
dd99e5cc69 feat(web): UDT-003 formulario de alta de usuarios (admin)
Agrega CreateUserPage con UserForm (react-hook-form + Zod), hook useCreateUser
(TanStack Query mutation), ruta /users/new protegida y entrada en AppSidebar.
Incluye tests Vitest: UserForm (9 casos) y useCreateUser (3 casos).
2026-04-15 10:57:11 -03:00
3d598faffc feat(api): UDT-003 registro de usuarios — backend completo (Phases 1-6)
- Domain: Usuario.ForCreation factory, UsernameAlreadyExistsException, IUsuarioRepository extendido
- Application: CreateUsuarioCommand/Validator/Handler, UsuarioCreatedDto, AuthOptions password policy
- Infrastructure: UsuarioRepository.ExistsByUsernameAsync + AddAsync (INSERT OUTPUT INSERTED.Id), RoleClaimType="rol" en TokenValidationParameters
- Api: UsuariosController POST api/v1/users [Authorize(Roles="admin")], ExceptionFilter mapea UsernameAlreadyExistsException + SqlException 2627 → 409
- Tests (unit): 43 tests — 33 validator + 10 handler (107 total, green)
- Tests (integration): 7 tests CreateUsuarioEndpoint — 401/403/400/201/409/race/e2e (green)
- Fix: TestWebAppFactory.ConfigureTestServices reemplaza SqlConnectionFactory singleton con CS de test correcto
2026-04-15 10:47:48 -03:00
023d30fce4 chore(repo): gitignore .claude/ local state and autogen src/src.sln 2026-04-14 14:39:38 -03:00
5b3797a81c Merge pull request 'UDT-002: Logout + Refresh Token con rotación y chain revocation' (#3) from feature/UDT-002 into main 2026-04-14 17:37:47 +00:00
96dbeecc0f fix(web): use endsWith for /auth path exclusion in refresh interceptor
Avoids substring-match false positives on future endpoints whose URL could
contain /auth/refresh or /auth/login as infix (W-01 from verify report).
2026-04-14 13:59:37 -03:00
7fadb88da0 docs(web): smoke test checklist UDT-002 — login, refresh, logout, reuse detection 2026-04-14 13:52:59 -03:00
dd4f4dbd5e test(web): LoginPage — verify setAuth receives expiresIn and calculates expiresAt 2026-04-14 13:51:41 -03:00
bdaaaffaf6 feat(web): axiosClient — request/response interceptors with singleton refresh queue 2026-04-14 13:50:49 -03:00
d40b7247fc feat(web): authApi — add refresh() and logout() with types and tests 2026-04-14 13:49:39 -03:00
f806e0a483 test(web): authStore TDD — refreshToken, expiresAt, clearAuth, updateAccess, logout async 2026-04-14 13:48:50 -03:00
f1d4ea0047 fix(test): RefreshTokenRepository tests use Respawn pattern instead of transaction isolation
Transaction-scoped tests conflicted with the repository opening its own connection,
blocking on FK locks for the uncommitted seeded user and causing timeouts.
Switched to the Respawn pattern used by UsuarioRepositoryTests ([Collection("Database")])
which commits seed data and resets between test classes.
2026-04-14 13:45:53 -03:00
fd2ff8a802 feat(api): map InvalidRefreshTokenException and TokenReuseDetectedException to generic 401 2026-04-14 13:28:45 -03:00
8768067fdd feat(api): add /refresh [AllowAnonymous] and /logout [Authorize] endpoints to AuthController 2026-04-14 13:28:45 -03:00
4e7b2690bd test(api): add Refresh and Logout endpoint integration tests RED 2026-04-14 13:28:44 -03:00
aed26e3de9 feat(infra): register RefreshTokenRepository, RefreshTokenGenerator, ClientContext and handlers in DI 2026-04-14 13:28:36 -03:00
cb4250f7b3 feat(infra): implement ClientContext for IP and UserAgent from IHttpContextAccessor 2026-04-14 13:28:35 -03:00
19ac807500 feat(infra): add RefreshTokenDays to JwtOptions and AuthOptions config 2026-04-14 13:28:35 -03:00
0c809da633 feat(infra): implement RefreshTokenRepository with Dapper and add GetByIdAsync to UsuarioRepository 2026-04-14 13:28:29 -03:00
e405c0453b test(infra): add RefreshTokenRepository integration tests RED 2026-04-14 13:28:28 -03:00
d326dd87e0 feat(infra): implement RefreshTokenGenerator with cryptographic random bytes 2026-04-14 13:28:24 -03:00
2806e8dfa6 test(infra): add RefreshTokenGenerator tests RED 2026-04-14 13:28:24 -03:00
c910ff2fc5 feat(infra): implement GetPrincipalFromExpiredToken in JwtService 2026-04-14 13:28:20 -03:00
a363e3658d test(infra): add GetPrincipalFromExpiredToken tests for JwtService RED 2026-04-14 13:28:20 -03:00
8bbd2b6f2a feat(app): update LoginCommandHandler to persist hashed refresh token on login 2026-04-14 13:28:16 -03:00
b79efc778a test(app): extend LoginCommandHandler tests with refresh token persistence cases RED 2026-04-14 13:28:15 -03:00
6c02197369 feat(app): implement LogoutCommand handler with idempotent revocation 2026-04-14 13:28:10 -03:00
15a7687e4c test(app): add LogoutCommandHandler tests RED 2026-04-14 13:28:10 -03:00
f5e67b78a5 feat(app): implement RefreshCommand handler with token rotation and chain revocation 2026-04-14 13:28:06 -03:00
25639398c2 test(app): add RefreshCommandHandler tests RED 2026-04-14 13:28:02 -03:00
971f6f572f feat(app): add IClientContext abstraction for IP and UserAgent 2026-04-14 13:17:12 -03:00
84006776b6 feat(app): add IRefreshTokenGenerator abstraction 2026-04-14 13:17:12 -03:00
802c89ffe5 feat(app): add IRefreshTokenRepository abstraction 2026-04-14 13:17:11 -03:00
ba6dffb137 feat(app): extend IJwtService with GetPrincipalFromExpiredToken 2026-04-14 13:17:11 -03:00
83c6a95ee2 feat(domain): add InvalidRefreshTokenException and TokenReuseDetectedException 2026-04-14 13:16:44 -03:00
aacfd29673 feat(domain): add TokenHasher SHA-256 base64url helper 2026-04-14 13:16:43 -03:00
22aff10330 test(domain): add TokenHasher tests RED 2026-04-14 13:16:43 -03:00
99bb3364c3 feat(domain): add RefreshToken entity with factory methods and IsActive logic 2026-04-14 13:16:38 -03:00
2efe4115c4 test(domain): add RefreshToken entity tests RED 2026-04-14 13:16:36 -03:00
ffb68db57e db(auth): add V002__create_refresh_token migration with chain revocation indexes 2026-04-14 13:14:47 -03:00
3b66415e17 fix(web): default API port to 5212 2026-04-14 12:54:36 -03:00
cc532ff319 Merge pull request 'UI Design System: shadcn/ui + Tailwind 4 + layout shell' (#2) from feature/UI-DESIGN-SYSTEM into main 2026-04-14 14:45:08 +00:00
b3d78ff56d Merge pull request 'UDT-001: Login (scaffolding + JWT RS256 end-to-end)' (#1) from feature/UDT-001 into main 2026-04-14 14:44:28 +00:00
5e1e979377 refactor(web): LoginPage con shadcn Form, zod validation y Alert destructive 2026-04-14 11:21:53 -03:00
7eea0fd17c feat(ui): app shell con Sidebar, Header, ThemeToggle y HomePage grid de modulos 2026-04-14 11:21:48 -03:00
8acd2975ba feat(ui): shadcn/ui setup con componentes base, fonts y design tokens 2026-04-14 11:21:43 -03:00
a15d8c166e chore(udt-001): vite scaffold default assets 2026-04-13 21:36:49 -03:00
4fa891f340 chore(udt-001): add skill registry 2026-04-13 21:36:41 -03:00
6c4d572111 docs(udt-001): smoke test checklist 2026-04-13 21:36:41 -03:00
f4f063f5f0 test(udt-001): frontend tests (authStore, authApi, LoginPage - 11 tests) 2026-04-13 21:36:40 -03:00
a692576bc3 feat(udt-001): frontend auth UI (Zustand store, TanStack Query, LoginPage, router) 2026-04-13 21:36:32 -03:00
5f6ebccb54 feat(udt-001): frontend scaffold (Vite 6 + React 19 + TS strict + Tailwind 4) 2026-04-13 21:36:17 -03:00
b657dc0d2a test(udt-001): backend unit and integration tests (30 tests) 2026-04-13 21:36:09 -03:00
9891f96618 feat(udt-001): api layer with AuthController, Program.cs and Serilog 2026-04-13 21:36:08 -03:00
ca57ce33b5 feat(udt-001): infrastructure (Dapper, BCrypt, JWT RS256, dispatcher) 2026-04-13 21:36:02 -03:00
8c26cd3ac5 feat(udt-001): application layer with LoginCommandHandler and ports 2026-04-13 21:36:01 -03:00
2111070c77 feat(udt-001): domain layer with Usuario entity 2026-04-13 21:36:00 -03:00
88ecaa2c7f chore(udt-001): RSA key generation script 2026-04-13 21:35:56 -03:00
1e5cac737b feat(udt-001): db schema for Usuario with admin seed 2026-04-13 21:35:55 -03:00
c666729685 chore(udt-001): repo scaffold with central package management 2026-04-13 21:35:51 -03:00
1066 changed files with 100677 additions and 1 deletions

83
.atl/skill-registry.md Normal file
View File

@@ -0,0 +1,83 @@
# Skill Registry — sig-cm2
Generated: 2026-04-13
## User Skills
| Skill | Trigger |
|-------|---------|
| `sdd-init` | User says "sdd init", "iniciar sdd", "openspec init" |
| `sdd-explore` | Orchestrator launches exploration of a feature or codebase area |
| `sdd-propose` | Orchestrator launches proposal for a change |
| `sdd-spec` | Orchestrator launches spec writing for a change |
| `sdd-design` | Orchestrator launches technical design for a change |
| `sdd-tasks` | Orchestrator launches task breakdown for a change |
| `sdd-apply` | Orchestrator launches implementation of tasks |
| `sdd-verify` | Orchestrator launches verification of a completed change |
| `sdd-archive` | Orchestrator launches archival of a completed change |
| `sdd-onboard` | User wants a guided SDD walkthrough |
| `judgment-day` | User says "judgment day", "review adversarial", "doble review", "juzgar" |
| `go-testing` | Writing Go tests, using teatest, Bubbletea TUI testing |
| `skill-creator` | Creating a new AI agent skill |
| `branch-pr` | Creating a pull request, preparing changes for review |
| `issue-creation` | Creating a GitHub issue, bug report, or feature request |
| `skill-registry` | Update skill registry, "actualizar skills" |
| `obsidian-cli` | Interact with Obsidian vault via CLI |
| `obsidian-markdown` | Creating/editing Obsidian Flavored Markdown (.md files in vault) |
| `gitea-workflow` | Agile workflow for Gitea repos, "run the workflow", "what's next" |
| `find-skills` | "Find a skill for X", "how do I do X", discover capabilities |
## Project Conventions
| File | Role |
|------|------|
| `Obsidian/SPEC.md` | Source of truth — visión, módulos, tech stack |
| `Obsidian/STATUS.md` | Estado de UDTs — ÚNICO lugar para marcar tareas `[x]` |
| `Obsidian/INSTRUCCIONES_IA.md` | SOP del agente: bucle de ejecución, reglas de lectura |
| `Obsidian/02-ARQUITECTURA-y-TECH-STACK/` | UDTs por módulo con CMV (Contexto Mínimo Viable) |
| `Obsidian/04-DOMINIO-y-REGLAS-de-NEGOCIO/` | Reglas de negocio — consultar ante dudas |
## Compact Rules
### SIG-CM2 Development Rules
- Orden de implementación SIEMPRE: BD → Backend → Frontend
- Rama por UDT: `feature/UDT-XXX` (o VTA-XXX, TAS-XXX, INT-XXX, ADM-XXX)
- Commits: `tipo(módulo): descripción` — feat/fix/docs/refactor/test/chore/security
- NUNCA leer `Obsidian/07-RELEVAMIENTOS/` sin instrucción humana explícita
- Para dudas de negocio: consultar `04-DOMINIO-y-REGLAS-de-NEGOCIO/` o `SPEC.md`
- Antes de cada UDT: leer STATUS.md → leer UDT en carpeta 02 → cargar solo el CMV indicado
### Architecture
- Clean Architecture: SIGCM2.Api / SIGCM2.Application / SIGCM2.Domain / SIGCM2.Infrastructure
- Backend ORM: Dapper 2.x (NO Entity Framework — decisión arquitectural)
- Lógica crítica de negocio: Stored Procedures en SQL Server
- Frontend state: Zustand (global) + TanStack Query (server state)
- Frontend estructura: src/api, src/components/{ui,features}, src/features/*, src/hooks, src/layouts, src/pages, src/stores, src/utils
### Strict TDD Mode (ACTIVE)
- Tests ANTES del código de producción (Red → Green → Refactor)
- Backend: xUnit + NSubstitute — comando: `dotnet test`
- Frontend: Vitest + React Testing Library — comando: `vitest`
- Coverage backend: `dotnet test --collect:"XPlat Code Coverage"`
- Coverage frontend: `vitest --coverage`
### Design System (frontend) — v2.3
- Source of truth: `Obsidian/02-ARQUITECTURA-y-TECH-STACK/2.14 🎨 Design System.md`. Engram topic_key: `sig-cm2/design-system`
- Personality: tech sophisticated (Vercel/Linear/Railway). Glass + gradient mesh + multi-layer shadows + glow blobs corners
- Brand `#008fbe` (logo) → escalado OKLCH `--brand-50..950`. **Violet accent** `oklch(0.62 0.20 280)` (`--accent-violet-*`) para combos tech. Neutral cool slate con shift hue 250-252 (`--neutral-50..950`)
- NO usar `gray-*`/`slate-*`/`blue-*` genéricos de Tailwind. Solo brand/neutral/violet/semantic
- Tokens semánticos: `bg-background`, `text-foreground`, `bg-primary`, `bg-card`, `text-muted-foreground`, `border-border`, `ring-ring`, `bg-input` (con `border-input-border`). NUNCA hardcodear `bg-white`/`text-black`/hex inline
- Density compact: button 32-40px, input 40px (`h-10`), table row 40px. `--radius` base 10px (sm/md/lg/xl = 6/8/10/14)
- Light + Dark con default = system preference (`useTheme()` hook). Dark NO es pure black (slate-violet). Smoke test ambos antes de mergear
- Forms: ≤4 campos single col, ≥5 campos `grid grid-cols-1 md:grid-cols-2 gap-4`
- Tablas mobile: priority columns + tap-to-expand (NO cards-on-mobile, NO pure horizontal scroll)
- **Utilities CSS** (`@layer components` en index.css): `.glass`, `.gradient-mesh`, `.grid-bg` (usar en root layouts), `.surface` (tablas), `.focus-glow`
- **Card variants**: `default` (shadow-md) / `elevated` / `glass` / `flat`
- **Tooltips**: usar SIEMPRE `<Tooltip>` de `@/components/ui/tooltip` (Radix Portal). NO CSS absolute en sidebars/modals — clipping issue
- **Sidebar**: colapsable con `useSidebar()` hook (persiste en localStorage). Toggle en top header al lado del brand
- **DataTable**: usar SIEMPRE `<DataTable>` de `@/components/ui/data-table` para tablas. NUNCA HTML `<table>` crudo. Soporta `meta: { priority: 'high'|'medium'|'low' }` para responsive + tap-to-expand row mobile automático
- **shadcn MCP**: registrado globalmente (user scope). Pedirle a Claude que instale componentes shadcn — lo hace via MCP sin que el dev toque CLI. 22 componentes ya instalados
- Toasts via `sonner` (`<Toaster richColors closeButton position="top-right" />` ya montado en `App.tsx`). `toast.success()` / `toast.error()`
- TooltipProvider ya envuelve App con `delayDuration={150}`
- Componentes shadcn: instalar via shadcn MCP server o `npx shadcn@latest add`. NUNCA copy-paste manual del website
- WCAG AA obligatorio: focus rings visibles (ya forzado en CSS base), contrast ≥ 4.5:1 texto normal, aria-label en botones icon-only
- Browser autofill fix ya aplicado en `@layer base` — respeta tokens del DS

45
.gitignore vendored
View File

@@ -29,7 +29,52 @@ yarn-error.log*
#.env.production.local
# ----------------------------------------------------------------------------
# ## .NET Build Artifacts ##
# ----------------------------------------------------------------------------
[Bb]in/
[Oo]bj/
*.user
*.suo
*.userosscache
*.sln.docstates
.vs/
TestResults/
*.trx
*.coverage
*.coveragexml
# ----------------------------------------------------------------------------
# ## JWT / Security Keys ##
# ----------------------------------------------------------------------------
src/api/SIGCM2.Api/keys/*.pem
# ----------------------------------------------------------------------------
# ## ASP.NET Core local secrets ##
# ----------------------------------------------------------------------------
src/api/SIGCM2.Api/appsettings.Development.json
src/api/SIGCM2.Api/appsettings.Test.json
tests/SIGCM2.Api.Tests/appsettings.Test.json
tests/SIGCM2.Application.Tests/appsettings.Test.json
# ----------------------------------------------------------------------------
# ## Frontend Build Artifacts ##
# ----------------------------------------------------------------------------
src/web/dist/
src/web/node_modules/
src/web/.vite/
# ----------------------------------------------------------------------------
# ## Documentación ##
# ----------------------------------------------------------------------------
/Obsidian
# ----------------------------------------------------------------------------
# ## Claude Code local state ##
# ----------------------------------------------------------------------------
.claude/
# ----------------------------------------------------------------------------
# ## Visual Studio auto-generated solution (usamos SIGCM2.slnx en la raíz) ##
# ----------------------------------------------------------------------------
src/src.sln

View File

@@ -0,0 +1 @@
{"version":"2.1.9","results":[[":src/web/src/tests/stores/authStore.test.ts",{"duration":19.427999999999997,"failed":true}],[":src/web/src/tests/features/auth/ProtectedRoute.test.tsx",{"duration":0,"failed":true}],[":src/web/src/tests/api/axiosClient.test.ts",{"duration":259.31550000000016,"failed":true}],[":src/web/src/tests/features/users/UserForm.test.tsx",{"duration":0,"failed":true}],[":src/web/src/tests/features/users/UsersListPage.test.tsx",{"duration":0,"failed":true}],[":src/web/src/tests/features/permisos/RolPermisosEditor.test.tsx",{"duration":0,"failed":true}],[":src/web/src/tests/features/roles/RolForm.test.tsx",{"duration":0,"failed":true}],[":src/web/src/tests/features/auth/LoginPage.test.tsx",{"duration":0,"failed":true}],[":src/web/src/tests/features/profile/ChangeMyPasswordPage.test.tsx",{"duration":0,"failed":true}],[":src/web/src/tests/features/auth/useLogin.test.ts",{"duration":0,"failed":true}],[":src/web/src/tests/features/users/UserEditPage.test.tsx",{"duration":0,"failed":true}],[":src/web/src/tests/features/users/ResetPasswordModal.test.tsx",{"duration":0,"failed":true}],[":src/web/src/tests/features/auth/authApi.test.ts",{"duration":0,"failed":true}],[":src/web/src/tests/features/roles/RolesList.test.tsx",{"duration":0,"failed":true}],[":src/web/src/tests/features/users/useCreateUser.test.ts",{"duration":0,"failed":true}],[":src/web/src/tests/components/routing/MustChangePasswordGate.test.tsx",{"duration":0,"failed":true}],[":src/web/src/tests/features/auth/CanPerform.test.tsx",{"duration":0,"failed":true}],[":src/web/src/tests/features/auth/usePermission.test.ts",{"duration":0,"failed":true}],[":src/web/src/tests/features/users/useUsersList.test.ts",{"duration":0,"failed":true}],[":src/web/src/tests/features/users/listUsers.test.ts",{"duration":0,"failed":true}],[":src/web/src/tests/features/users/updateUser.test.ts",{"duration":0,"failed":true}],[":src/web/src/tests/features/users/getUser.test.ts",{"duration":0,"failed":true}]]}

33
Directory.Packages.props Normal file
View File

@@ -0,0 +1,33 @@
<Project>
<PropertyGroup>
<ManagePackageVersionsCentrally>true</ManagePackageVersionsCentrally>
</PropertyGroup>
<!-- Production dependencies -->
<ItemGroup>
<PackageVersion Include="Dapper" Version="2.1.35" />
<PackageVersion Include="Microsoft.AspNetCore.OpenApi" Version="10.0.5" />
<PackageVersion Include="Microsoft.Data.SqlClient" Version="5.2.2" />
<PackageVersion Include="BCrypt.Net-Next" Version="4.0.3" />
<PackageVersion Include="FluentValidation.AspNetCore" Version="11.3.0" />
<PackageVersion Include="Serilog.AspNetCore" Version="8.0.3" />
<PackageVersion Include="Serilog.Sinks.Seq" Version="8.0.0" />
<PackageVersion Include="Microsoft.AspNetCore.Authentication.JwtBearer" Version="10.0.0-preview.3.25172.1" />
<PackageVersion Include="Scalar.AspNetCore" Version="2.5.6" />
<PackageVersion Include="System.IdentityModel.Tokens.Jwt" Version="8.9.0" />
<PackageVersion Include="Microsoft.IdentityModel.Tokens" Version="8.9.0" />
<PackageVersion Include="Quartz.Extensions.Hosting" Version="3.13.1" />
</ItemGroup>
<!-- Test dependencies -->
<ItemGroup>
<PackageVersion Include="Microsoft.Extensions.TimeProvider.Testing" Version="9.1.0" />
<PackageVersion Include="xunit" Version="2.9.3" />
<PackageVersion Include="xunit.runner.visualstudio" Version="3.1.4" />
<PackageVersion Include="Microsoft.NET.Test.Sdk" Version="17.14.1" />
<PackageVersion Include="NSubstitute" Version="5.3.0" />
<PackageVersion Include="FluentAssertions" Version="6.12.2" />
<PackageVersion Include="Microsoft.AspNetCore.Mvc.Testing" Version="10.0.0-preview.3.25172.1" />
<PackageVersion Include="Microsoft.AspNetCore.TestHost" Version="10.0.0-preview.3.25172.1" />
<PackageVersion Include="Respawn" Version="6.2.1" />
<PackageVersion Include="coverlet.collector" Version="6.0.4" />
</ItemGroup>
</Project>

103
README.md Normal file
View File

@@ -0,0 +1,103 @@
# SIG-CM 2.0
Sistema de gestión comercial — migración del sistema legacy (VB6) a una plataforma web moderna.
## Stack
- **Backend**: .NET 10 · C# 13 · ASP.NET Core · Clean Architecture · Dapper 2.x · SQL Server 2022 · JWT RS256 · Serilog · FluentValidation · xUnit + NSubstitute
- **Frontend**: React 19 · TypeScript 5 strict · Vite 6 · Tailwind 4 · Zustand · React Router 7 · TanStack Query · Axios · Vitest + RTL
- **Infra**: Docker · Gitea Actions · Obsidian (documentación interna) · SQL Server
## Estructura
```
src/api/ # Backend .NET (Clean Architecture)
SIGCM2.Api/ controllers, filters, Program.cs
SIGCM2.Application/ commands, handlers, validators, abstractions
SIGCM2.Domain/ entities, exceptions, domain security
SIGCM2.Infrastructure/ persistence (Dapper), security, DI
src/web/ # Frontend React 19 (Vite + TS strict)
src/features/ feature modules (auth, users, …)
src/components/ shared UI + layout
src/tests/ Vitest suites
database/
migrations/ .sql con orden Vxxx
seeds/ datos iniciales
schemas/ definiciones auxiliares
tests/
SIGCM2.Api.Tests/ integration (TestWebAppFactory + SQL Server)
SIGCM2.Application.Tests/ unit (handlers, validators)
SIGCM2.TestSupport/ fixtures compartidas
Obsidian/ # Source of truth funcional (IGNORADO por git)
STATUS.md roadmap y estado de UDTs
INSTRUCCIONES_IA.md SOP del agente de IA
02-ARQUITECTURA.../ specs por módulo
```
## Cómo correr
### Requisitos
- .NET 10 SDK
- Node 20+
- SQL Server 2019+ (local o remoto)
### Backend
```bash
cd src/api/SIGCM2.Api
dotnet run
```
Config en `appsettings.json` (DB: `SIGCM2`, usuario `desarrollo`, server `TECNICA3`). Para tests de integración se usa `SIGCM2_Test`.
### Frontend
```bash
cd src/web
npm install
npm run dev
```
### Tests
```bash
# Backend
dotnet test tests/SIGCM2.Application.Tests # unit
dotnet test tests/SIGCM2.Api.Tests # integration (requiere SIGCM2_Test)
# Frontend
cd src/web && npx vitest run
```
### Coverage (backend)
```bash
# Generar reporte de coverage en formato Cobertura
dotnet test --collect:"XPlat Code Coverage" --settings coverlet.runsettings --results-directory ./TestResults
```
El comando genera un `coverage.cobertura.xml` por cada proyecto de test en `./TestResults/`.
Para convertirlo a HTML:
```bash
# Instalar ReportGenerator (solo la primera vez)
dotnet tool install -g dotnet-reportgenerator-globaltool
# Generar reporte HTML
reportgenerator -reports:"./TestResults/**/coverage.cobertura.xml" -targetdir:"./coverage-report" -reporttypes:Html
```
Abrí `./coverage-report/index.html` en el browser para ver el detalle por archivo.
## Convenciones
- Ramas: `feature/UDT-XXX` desde `main`.
- Commits: `tipo(módulo): descripción``feat`, `fix`, `docs`, `refactor`, `test`, `chore`, `security`.
- Orden de trabajo por UDT: **BD → Backend → Frontend**.
- Desarrollo guiado por Spec-Driven Development (SDD) + Strict TDD.
- Follow-ups / deuda técnica se registran como issues de Gitea con label `followup`.

14
SIGCM2.slnx Normal file
View File

@@ -0,0 +1,14 @@
<Solution>
<Folder Name="/src/" />
<Folder Name="/src/api/">
<Project Path="src/api/SIGCM2.Api/SIGCM2.Api.csproj" />
<Project Path="src/api/SIGCM2.Application/SIGCM2.Application.csproj" />
<Project Path="src/api/SIGCM2.Domain/SIGCM2.Domain.csproj" />
<Project Path="src/api/SIGCM2.Infrastructure/SIGCM2.Infrastructure.csproj" />
</Folder>
<Folder Name="/tests/">
<Project Path="tests/SIGCM2.Api.Tests/SIGCM2.Api.Tests.csproj" />
<Project Path="tests/SIGCM2.Application.Tests/SIGCM2.Application.Tests.csproj" />
<Project Path="tests/SIGCM2.TestSupport/SIGCM2.TestSupport.csproj" />
</Folder>
</Solution>

48
coverlet.runsettings Normal file
View File

@@ -0,0 +1,48 @@
<?xml version="1.0" encoding="utf-8"?>
<RunSettings>
<!--
Configuracion de coverage con coverlet.collector.
Uso: dotnet test /collect:"XPlat Code Coverage" /settings:coverlet.runsettings /results-directory:./TestResults
-->
<RunConfiguration>
<!-- Mantener ejecución secuencial (hereda política de tests.runsettings) -->
<MaxCpuCount>1</MaxCpuCount>
</RunConfiguration>
<DataCollectionRunSettings>
<DataCollectors>
<DataCollector friendlyName="XPlat Code Coverage">
<Configuration>
<!-- Formato de salida: cobertura (compatible con ReportGenerator y CI/CD) -->
<Format>cobertura</Format>
<!-- Exclusiones por atributo generado -->
<ExcludeByAttribute>GeneratedCodeAttribute,ExcludeFromCodeCoverageAttribute</ExcludeByAttribute>
<!-- Exclusiones por tipo/namespace -->
<Exclude>
<!-- Migrations embebidas (SQL scripts, no lógica de negocio) -->
[*.Migrations]*,
<!-- Los proyectos de test no se miden a sí mismos -->
[*.Tests]*,
[SIGCM2.TestSupport]*,
<!-- Program.cs: host wiring, no testeable unitariamente -->
[SIGCM2.Api]Program,
<!-- Extension methods de DI: una línea por registro, ruido sin valor -->
[*]*.Extensions.*Extensions,
[*]*.DependencyInjection
</Exclude>
<!-- No medir las propiedades auto-implementadas -->
<SkipAutoProps>true</SkipAutoProps>
<!-- No incluir el assembly de tests en el reporte -->
<IncludeTestAssembly>false</IncludeTestAssembly>
<!-- Permitir timestamps reales en el reporte (no forzar determinismo) -->
<DeterministicReport>false</DeterministicReport>
</Configuration>
</DataCollector>
</DataCollectors>
</DataCollectionRunSettings>
</RunSettings>

127
database/README.md Normal file
View File

@@ -0,0 +1,127 @@
# `database/` — SIG-CM 2.0
Todo el DDL del sistema vive acá: migraciones versionadas, stored procedures, functions, seeds.
## Estructura
```
database/
├── migrations/ # Migraciones versionadas V0XX__<descripcion>.sql (SQL puro, idempotentes)
├── procedures/ # Stored procedures — creados/alterados por UDTs específicas
├── functions/ # User-defined functions
├── seeds/ # Data de referencia no-versionada
└── schemas/ # Extracciones de schema (referencia)
```
## Migraciones aplicadas (orden obligatorio)
| Versión | Archivo | UDT | Descripción |
|---|---|---|---|
| V001 | `V001__create_usuario.sql` | UDT-001 | Tabla Usuario + IX_Usuario_Username_Activo |
| V002 | `V002__create_refresh_token.sql` | UDT-002 | Tabla RefreshToken |
| V003 | `V003__create_rol.sql` | UDT-004 | Tabla Rol + 8 roles canónicos |
| V004 | `V004__alter_usuario_rol_fk.sql` | UDT-004 | FK Usuario.Rol → Rol.Codigo |
| V005 | `V005__create_permiso.sql` | UDT-005 | Tabla Permiso + 18 permisos canónicos |
| V006 | `V006__create_rol_permiso.sql` | UDT-005 | Tabla RolPermiso + seed 36 rows |
| V007 | `V007__add_admin_permissions_udt006.sql` | UDT-006 | 3 permisos administrativos RBAC |
| V008 | `V008__add_mustchangepassword_and_indexes.sql` | UDT-008 | Usuario.MustChangePassword + IX_Usuario_Activo_Rol |
| V009 | `V009__activate_permisos_overrides.sql` | UDT-009 | Migración shape `PermisosJson` `{grant, deny}` |
| **V010** | **`V010__audit_infrastructure.sql`** | **UDT-010** | **Infra de auditoría + Temporal Tables. Ver nota abajo.** |
| V011 | `V011__create_medio_seccion.sql` | ADM-001 | Medio + Seccion (temporal, retention 10y) + permiso `administracion:secciones:gestionar` |
| V012 | `V012__seed_medios.sql` | ADM-001 | Seed idempotente de Medios ELDIA y ELPLATA |
| V013 | `V013__create_puntos_de_venta.sql` | ADM-008 | PuntosDeVenta (temporal, retention 10y) + permiso `administracion:puntos_de_venta:gestionar` |
| V014 | `V014__create_tablas_fiscales.sql` | ADM-009 | TiposDeIva + IngresosBrutos (versioning por cadena) + permisos fiscales |
| V015 | `V015__create_local_timezone_views.sql` | UDT-011 | Vistas admin con OccurredAt convertido a hora Argentina |
| **V016** | **`V016__create_rubro.sql`** | **CAT-001** | **Rubro (adjacency list, temporal 10y) + permiso `catalogo:rubros:gestionar`** |
| **V017** | **`V017__create_product_type.sql`** | **PRD-001** | **ProductType (flags + multimedia limits, temporal 10y) + permiso `catalogo:tipos:gestionar`** |
| V018 | `V018__create_product.sql` | PRD-002 | Product (temporal 10y) + permiso `catalogo:productos:gestionar` + índices |
| V019 | `V019__create_product_prices.sql` | PRD-003 | ProductPrices (temporal 10y, forward-only) + SP `sp_ProductPrices_InsertWithClose` + permiso implícito |
| V020 | `V020__add_chargeable_chars_permission.sql` | PRC-001 | Permiso `tasacion:caracteres_especiales:gestionar` + asignación a admin |
| V021 | `V021__create_chargeable_char_config.sql` | PRC-001 | ChargeableCharConfig + ChargeableCharConfig_History (temporal 10y) + 2 SPs (`InsertWithClose`, `GetActiveForProductType`) + 2 índices |
| V022 | `V022__seed_chargeable_char_config.sql` | PRC-001 | Seed 4 filas globales (`$`, `%`, `!`, `¡`) con PricePerUnit=1.0000 |
| V023 | `V023__refactor_chargeable_char_config_to_product_type.sql` | PRC-001 (scope delta) | Refactor MedioId→ProductTypeId + nuevo SP `ReactivateWithGuard` + CK_Price_NonNegative (>= 0) |
| V024 | `V024__reseed_global_with_zero_price.sql` | PRC-001 (scope delta) | Reseed 4 globales a PricePerUnit=0.0000 (opt-in billing) |
| V025 | `V025__seed_chargeable_char_overrides_demo.sql` | PRC-001 (followup #54) | Seed demo de overrides ficticios per-ProductType (Clasificado/Notables/Fúnebres). Idempotente: no-op si los tipos no existen |
## Convenciones
- **SQL puro** ejecutado manualmente (no hay runner automático; decisión arquitectónica).
- **Idempotentes**: cada migración usa `IF NOT EXISTS` / `MERGE` / `IF NOT EXISTS (SELECT 1 FROM sys....)`. Re-ejecutarlas es seguro.
- **Boilerplate** inicial: `SET QUOTED_IDENTIFIER ON; SET ANSI_NULLS ON; SET NOCOUNT ON; GO`.
- **Naming**: `PK_*`, `UQ_*`, `FK_*`, `DF_*`, `CK_*`, `IX_*`.
- **Se aplican a TRES bases**: `SIGCM2` (dev), `SIGCM2_Test_App` (Application.Tests) y `SIGCM2_Test_Api` (Api.Tests). El orden debe ser idéntico en las tres.
## Cómo aplicar migraciones
### En dev (manual)
```bash
# Con sqlcmd (aplicar a las tres bases en orden):
sqlcmd -S TECNICA3 -d SIGCM2 -U desarrollo -P desarrollo2026 -i database/migrations/V010__audit_infrastructure.sql
sqlcmd -S TECNICA3 -d SIGCM2_Test_App -U desarrollo -P desarrollo2026 -i database/migrations/V010__audit_infrastructure.sql
sqlcmd -S TECNICA3 -d SIGCM2_Test_Api -U desarrollo -P desarrollo2026 -i database/migrations/V010__audit_infrastructure.sql
```
O desde SSMS: abrir el archivo, conectar a cada base, F5.
### En integration tests
`tests/SIGCM2.TestSupport/SqlTestFixture.cs` aplica automáticamente el schema necesario en `SIGCM2_Test_App` al inicializar el fixture (`EnsureV008SchemaAsync`, `EnsureV009SchemaAsync`, `EnsureV010SchemaAsync`…). `TestWebAppFactory` hace lo mismo contra `SIGCM2_Test_Api`. **NO** hace falta correr los scripts manualmente si el fixture ya lo cubre.
### En producción (roadmap futuro)
1. Backup completo de la base.
2. Revisar notas específicas de la migración (ver abajo).
3. Ventana de mantenimiento si la migración lo requiere.
4. `sqlcmd` + script + verify que los `PRINT` salieron esperados.
5. Smoke test post-migración.
## ⚠️ Notas especiales por migración
### V010 — Infraestructura de Auditoría
**Riesgos específicos**:
- Activa `SYSTEM_VERSIONING` en `Usuario`, `Rol`, `Permiso`, `RolPermiso`. Tablas con datos. `ALTER TABLE ADD PERIOD FOR SYSTEM_TIME` toma **Sch-M lock** (schema modification). En dev con pocos usuarios el lock es milisegundos; en prod con conexiones activas puede generar espera.
- Crea filegroups `AUDIT_HOT` y `AUDIT_COLD` con archivos físicos `<DB>_AUDIT_HOT.ndf` y `<DB>_AUDIT_COLD.ndf` en el default data path del server.
- Crea 2 partition functions + schemes mensuales (boundaries 2026-01..2027-02). El job `AuditPartitionManagerJob` (B11) extiende la ventana mes a mes.
**Para aplicar en prod**:
1. **Backup completo previo** (no negociable).
2. **Ventana de mantenimiento ≥ 10 min** (los ALTER de SYSTEM_VERSIONING son rápidos pero pueden caer en timeouts si hay transacciones largas).
3. Ejecutar el script + verificar todos los `PRINT` "created/applied".
4. Smoke test post: `SELECT TOP 1 * FROM dbo.AuditEvent` (vacío OK); `SELECT temporal_type FROM sys.tables WHERE name = 'Usuario'` (debe devolver `2` = system-versioned).
5. Si algo falla → `V010_ROLLBACK.sql` (pierde toda la historia) o restore de backup.
**Catálogo de entidades auditables** (source of truth): `Obsidian/02-ARQUITECTURA-y-TECH-STACK/2.5 📋 Auditoría.md`. Cada UDT nueva que introduzca entidades de negocio debe agregar esas tablas al catálogo y activar `SYSTEM_VERSIONING` en su migración.
### V011/V012 — ADM-001 Medios y Secciones
**Alcance**: crea `dbo.Medio` y `dbo.Seccion` con Temporal Tables (retention 10 años), el permiso `administracion:secciones:gestionar` (y lo asigna a rol `admin`), y siembra los dos Medios fundacionales `ELDIA` y `ELPLATA`.
**Notas**:
- `administracion:medios:gestionar` ya existía desde V005 — no se toca.
- `PlataformaEmpresaId` es `INT NULL` sin FK; la FK se agrega en INT-003 cuando se cree la tabla `PlataformaEmpresa`.
- `Seccion.Tipo` acepta `'clasificados' | 'notables' | 'suplementos'` (CHECK constraint). Config avanzada (par/impar, suplementos, cupos) se introduce en ADM-003.
- Rollback: `V012_ROLLBACK.sql` (falla si hay Secciones vivas) → `V011_ROLLBACK.sql`. Rollback en prod NO soportado si ADM-008/009 o PRC-* ya aplicados.
## Bases de datos de integration tests
| Base | Propósito | Usada por |
|---|---|---|
| `SIGCM2_Test_App` | Tests de repositorios y Application layer | `SIGCM2.Application.Tests` vía `SqlTestFixture` (parameterless ctor) |
| `SIGCM2_Test_Api` | Tests de endpoints HTTP / WebApplicationFactory | `SIGCM2.Api.Tests` vía `TestWebAppFactory` |
**Script de creación inicial** (idempotente): `database/init/create-test-api-db.sql`
Ambas bases deben tener **todas las migraciones V001V015** aplicadas en orden. Al crear una base nueva o al agregar un desarrollador:
1. Crear las bases con `create-test-api-db.sql`
2. Aplicar V001V015 en orden (ver tabla de arriba) contra cada base de test
3. Las `EnsureV0XX` del fixture validan presencia; no aplican migraciones pesadas
Fuente única de connection strings: `tests/SIGCM2.TestSupport/TestConnectionStrings.cs`
## Recursos
- Design autoritativo: `Obsidian/02-ARQUITECTURA-y-TECH-STACK/2.5 📋 Auditoría.md`
- Decisión persistida (engram): `sig-cm2/audit-architecture`
- SDD artifacts UDT-010 (engram): `sdd/udt-010-auditoria-trazabilidad/{explore,proposal,spec,design,tasks,apply-progress}`

View File

@@ -0,0 +1,30 @@
-- create-test-api-db.sql
-- Creates test databases for integration tests (idempotent).
-- Run once per environment on TECNICA3 before executing integration tests.
--
-- SIGCM2_Test_App -> used by SIGCM2.Application.Tests
-- SIGCM2_Test_Api -> used by SIGCM2.Api.Tests
-- SIGCM2_Test -> legacy (kept for old branches e.g. pre-merge CAT-001)
--
-- After creating the DBs, apply V010 to both new DBs:
-- See database/README.md > "Test DBs" section for the PowerShell runbook.
IF DB_ID(N'SIGCM2_Test_App') IS NULL
BEGIN
CREATE DATABASE [SIGCM2_Test_App]
COLLATE Modern_Spanish_CI_AS;
PRINT 'Database SIGCM2_Test_App created.';
END
ELSE
PRINT 'Database SIGCM2_Test_App already exists -- skip.';
GO
IF DB_ID(N'SIGCM2_Test_Api') IS NULL
BEGIN
CREATE DATABASE [SIGCM2_Test_Api]
COLLATE Modern_Spanish_CI_AS;
PRINT 'Database SIGCM2_Test_Api created.';
END
ELSE
PRINT 'Database SIGCM2_Test_Api already exists -- skip.';
GO

View File

@@ -0,0 +1,43 @@
-- V001__create_usuario.sql
-- Creates the core Usuario table for SIG-CM2
-- Run on: SIGCM2 (prod) and SIGCM2_Test (integration tests)
SET QUOTED_IDENTIFIER ON;
SET ANSI_NULLS ON;
GO
IF OBJECT_ID('dbo.Usuario', 'U') IS NULL
BEGIN
CREATE TABLE dbo.Usuario (
Id INT IDENTITY(1,1) NOT NULL CONSTRAINT PK_Usuario PRIMARY KEY,
Username NVARCHAR(50) NOT NULL,
PasswordHash NVARCHAR(255) NOT NULL,
Nombre NVARCHAR(100) NOT NULL,
Apellido NVARCHAR(100) NOT NULL,
Email NVARCHAR(150) NULL,
Rol VARCHAR(30) NOT NULL,
PermisosJson NVARCHAR(MAX) NOT NULL CONSTRAINT DF_Usuario_Permisos DEFAULT('[]'),
Activo BIT NOT NULL CONSTRAINT DF_Usuario_Activo DEFAULT(1),
FechaCreacion DATETIME2(3) NOT NULL CONSTRAINT DF_Usuario_FechaCreacion DEFAULT(SYSUTCDATETIME()),
FechaModificacion DATETIME2(3) NULL,
UltimoLogin DATETIME2(3) NULL,
CONSTRAINT UQ_Usuario_Username UNIQUE (Username),
CONSTRAINT CK_Usuario_Rol CHECK (Rol IN ('admin','vendedor','tasador','consulta'))
);
PRINT 'Table dbo.Usuario created successfully.';
END
ELSE
BEGIN
PRINT 'Table dbo.Usuario already exists — skipping.';
END
GO
IF NOT EXISTS (SELECT 1 FROM sys.indexes WHERE name = 'IX_Usuario_Username_Activo' AND object_id = OBJECT_ID('dbo.Usuario'))
BEGIN
CREATE INDEX IX_Usuario_Username_Activo
ON dbo.Usuario(Username)
WHERE Activo = 1;
PRINT 'Index IX_Usuario_Username_Activo created.';
END
GO

View File

@@ -0,0 +1,63 @@
-- V002__create_refresh_token.sql
-- Creates dbo.RefreshToken table for opaque token rotation with chain revocation
-- Run on: SIGCM2 (prod) and SIGCM2_Test (integration tests)
SET QUOTED_IDENTIFIER ON;
SET ANSI_NULLS ON;
GO
IF OBJECT_ID(N'dbo.RefreshToken', N'U') IS NOT NULL
BEGIN
PRINT 'Table dbo.RefreshToken already exists — skipping.';
RETURN;
END
GO
CREATE TABLE dbo.RefreshToken
(
Id INT IDENTITY(1,1) NOT NULL,
UsuarioId INT NOT NULL,
TokenHash NVARCHAR(88) NOT NULL, -- SHA-256 base64url = 43 chars sin padding; margen a 88
FamilyId UNIQUEIDENTIFIER NOT NULL, -- una familia = una sesion de login
IssuedAt DATETIME2(3) NOT NULL,
ExpiresAt DATETIME2(3) NOT NULL, -- absolute: heredado en cada rotacion
RevokedAt DATETIME2(3) NULL,
ReplacedById INT NULL,
CreatedByIp VARCHAR(45) NOT NULL, -- IPv4/IPv6 textual
UserAgent NVARCHAR(512) NULL,
CONSTRAINT PK_RefreshToken PRIMARY KEY CLUSTERED (Id),
CONSTRAINT FK_RefreshToken_Usuario
FOREIGN KEY (UsuarioId) REFERENCES dbo.Usuario(Id),
CONSTRAINT FK_RefreshToken_ReplacedBy
FOREIGN KEY (ReplacedById) REFERENCES dbo.RefreshToken(Id),
CONSTRAINT UQ_RefreshToken_TokenHash UNIQUE (TokenHash)
);
GO
-- Lookup por familia para chain revocation
CREATE INDEX IX_RefreshToken_UsuarioId_FamilyId
ON dbo.RefreshToken (UsuarioId, FamilyId);
GO
-- Indice filtrado para revocaciones masivas de activos
CREATE INDEX IX_RefreshToken_Active
ON dbo.RefreshToken (UsuarioId, FamilyId)
WHERE RevokedAt IS NULL;
GO
-- Housekeeping futuro
CREATE INDEX IX_RefreshToken_ExpiresAt
ON dbo.RefreshToken (ExpiresAt)
WHERE RevokedAt IS NULL;
GO
EXEC sys.sp_addextendedproperty
@name = N'MS_Description',
@value = N'Refresh tokens opacos (SHA-256 hash) con rotacion y chain revocation por familia',
@level0type = N'SCHEMA', @level0name = N'dbo',
@level1type = N'TABLE', @level1name = N'RefreshToken';
GO
PRINT 'Table dbo.RefreshToken created successfully.';
GO

View File

@@ -0,0 +1,58 @@
-- V003__create_rol.sql
-- Creates dbo.Rol master table (referenced by Usuario.Rol via FK in V004) and seeds
-- the 8 canonical business roles (RBAC doc §2.4.2).
-- Run on: SIGCM2 (prod) and SIGCM2_Test (integration tests)
SET QUOTED_IDENTIFIER ON;
SET ANSI_NULLS ON;
SET NOCOUNT ON;
GO
IF OBJECT_ID(N'dbo.Rol', N'U') IS NULL
BEGIN
CREATE TABLE dbo.Rol
(
Id INT IDENTITY(1,1) NOT NULL CONSTRAINT PK_Rol PRIMARY KEY,
Codigo VARCHAR(30) NOT NULL,
Nombre NVARCHAR(60) NOT NULL,
Descripcion NVARCHAR(250) NULL,
Activo BIT NOT NULL CONSTRAINT DF_Rol_Activo DEFAULT(1),
FechaCreacion DATETIME2(3) NOT NULL CONSTRAINT DF_Rol_FC DEFAULT(SYSUTCDATETIME()),
FechaModificacion DATETIME2(3) NULL,
CONSTRAINT UQ_Rol_Codigo UNIQUE (Codigo),
-- Codigo format: lowercase letter followed by lowercase letters, digits or underscore.
-- Using binary collation to enforce case-sensitivity (default DB collation is case-insensitive).
CONSTRAINT CK_Rol_Codigo_Format CHECK (
PATINDEX('[a-z]%', Codigo COLLATE Latin1_General_BIN2) = 1
AND PATINDEX('%[^a-z0-9_]%', Codigo COLLATE Latin1_General_BIN2) = 0
)
);
PRINT 'Table dbo.Rol created successfully.';
END
ELSE
BEGIN
PRINT 'Table dbo.Rol already exists — skipping create.';
END
GO
-- Seed 8 canonical roles (idempotent).
MERGE dbo.Rol AS target
USING (VALUES
('admin', N'Administrador', N'Supervisor total del sistema'),
('cajero', N'Cajero', N'Atención de mostrador, contado'),
('operador_ctacte', N'Operador Cta Cte', N'Gestión de cuenta corriente'),
('picadora', N'Picadora/Correctora', N'Edición de textos y corrección'),
('jefe_publicidad', N'Jefe de Publicidad', N'Supervisión de pauta y recursos'),
('productor', N'Productor', N'Consulta y carga restringida'),
('diagramacion', N'Diagramación/Taller', N'Solo lectura de pauta'),
('reportes', N'Reportes', N'Solo lectura de reportes y estadísticas')
) AS source (Codigo, Nombre, Descripcion)
ON target.Codigo = source.Codigo
WHEN NOT MATCHED BY TARGET THEN
INSERT (Codigo, Nombre, Descripcion, Activo)
VALUES (source.Codigo, source.Nombre, source.Descripcion, 1);
GO
PRINT 'Rol seeds applied (8 canonical roles).';
GO

View File

@@ -0,0 +1,44 @@
-- V004__alter_usuario_rol_fk.sql
-- Replaces the hardcoded CHECK constraint on Usuario.Rol with a FOREIGN KEY
-- against dbo.Rol(Codigo). Must run AFTER V003 (which creates dbo.Rol and seeds the
-- codes already in use, including 'admin').
-- Run on: SIGCM2 (prod) and SIGCM2_Test (integration tests)
SET QUOTED_IDENTIFIER ON;
SET ANSI_NULLS ON;
SET NOCOUNT ON;
GO
-- 1) Drop the old hardcoded whitelist CHECK constraint (if still present).
IF EXISTS (
SELECT 1 FROM sys.check_constraints
WHERE name = 'CK_Usuario_Rol'
AND parent_object_id = OBJECT_ID(N'dbo.Usuario')
)
BEGIN
ALTER TABLE dbo.Usuario DROP CONSTRAINT CK_Usuario_Rol;
PRINT 'Dropped CK_Usuario_Rol (hardcoded whitelist).';
END
ELSE
BEGIN
PRINT 'CK_Usuario_Rol not present — skipping drop.';
END
GO
-- 2) Add the FK Usuario.Rol -> Rol.Codigo (only if not already present).
IF NOT EXISTS (
SELECT 1 FROM sys.foreign_keys
WHERE name = 'FK_Usuario_Rol'
AND parent_object_id = OBJECT_ID(N'dbo.Usuario')
)
BEGIN
ALTER TABLE dbo.Usuario
ADD CONSTRAINT FK_Usuario_Rol
FOREIGN KEY (Rol) REFERENCES dbo.Rol(Codigo);
PRINT 'Added FK_Usuario_Rol -> dbo.Rol(Codigo).';
END
ELSE
BEGIN
PRINT 'FK_Usuario_Rol already present — skipping.';
END
GO

View File

@@ -0,0 +1,65 @@
-- V005__create_permiso.sql
-- Tabla catálogo de permisos atómicos RBAC (18 permisos iniciales §2.4.2).
-- Requerimiento: ejecutar ANTES de V006 (FK PermisoId).
-- Run on: SIGCM2 (prod) and SIGCM2_Test (integration tests)
SET QUOTED_IDENTIFIER ON;
SET ANSI_NULLS ON;
SET NOCOUNT ON;
GO
IF OBJECT_ID(N'dbo.Permiso', N'U') IS NULL
BEGIN
CREATE TABLE dbo.Permiso (
Id INT IDENTITY(1,1) NOT NULL CONSTRAINT PK_Permiso PRIMARY KEY,
Codigo VARCHAR(60) NOT NULL,
Nombre NVARCHAR(100) NOT NULL,
Descripcion NVARCHAR(500) NULL,
Modulo VARCHAR(30) NOT NULL,
Activo BIT NOT NULL CONSTRAINT DF_Permiso_Activo DEFAULT(1),
FechaCreacion DATETIME2(3) NOT NULL CONSTRAINT DF_Permiso_FC DEFAULT(SYSUTCDATETIME()),
CONSTRAINT UQ_Permiso_Codigo UNIQUE (Codigo),
-- Formato: segmentos en minúsculas separados por ':', p.ej. ventas:contado:crear
-- Usa collation binaria para forzar case-sensitivity (igual que CK_Rol_Codigo_Format).
CONSTRAINT CK_Permiso_Codigo_Format CHECK (
PATINDEX('[a-z]%', Codigo COLLATE Latin1_General_BIN2) = 1
AND PATINDEX('%[^a-z0-9_:]%', Codigo COLLATE Latin1_General_BIN2) = 0
)
);
PRINT 'Table dbo.Permiso created.';
END
ELSE
PRINT 'Table dbo.Permiso already exists — skip.';
GO
-- Seed 18 permisos canónicos (idempotente via MERGE).
-- Convención RBAC: cada permiso nuevo → asignar a admin en la misma migración (V006+).
MERGE dbo.Permiso AS t
USING (VALUES
('ventas:contado:crear', N'Cargar orden contado', NULL, 'ventas'),
('ventas:contado:modificar', N'Modificar orden contado', NULL, 'ventas'),
('ventas:contado:cobrar', N'Cobrar orden contado', NULL, 'ventas'),
('ventas:contado:facturar', N'Facturar orden contado', NULL, 'ventas'),
('ventas:ctacte:crear', N'Cargar orden cuenta corriente', NULL, 'ventas'),
('ventas:ctacte:facturar', N'Facturar lote cuenta corriente', NULL, 'ventas'),
('textos:editar', N'Editar textos', NULL, 'textos'),
('textos:reclamos:ver', N'Ver reclamos de textos', NULL, 'textos'),
('pauta:azanu:ver', N'Ver AZANU en pauta', NULL, 'pauta'),
('pauta:limpiar', N'Limpieza de pauta', NULL, 'pauta'),
('pauta:recursos:fueradehora', N'Recursos fuera de hora', NULL, 'pauta'),
('productores:deuda:ver', N'Ver deuda propia de productores', NULL, 'productores'),
('productores:pendientes:crear', N'Cargar pendientes de productores', NULL, 'productores'),
('productores:deuda:bypass', N'Bypass de deuda de productores', NULL, 'productores'),
('administracion:usuarios:gestionar', N'Gestionar usuarios del sistema', N'Crear, editar y desactivar usuarios', 'administracion'),
('administracion:tarifarios:gestionar', N'Gestionar tarifarios', N'Crear y modificar tarifarios de publicidad', 'administracion'),
('administracion:medios:gestionar', N'Gestionar medios publicitarios', N'Alta y configuración de medios', 'administracion'),
('administracion:auditoria:ver', N'Ver logs de auditoría', N'Acceso al dashboard de auditoría', 'administracion')
) AS s (Codigo, Nombre, Descripcion, Modulo)
ON t.Codigo = s.Codigo
WHEN NOT MATCHED BY TARGET THEN
INSERT (Codigo, Nombre, Descripcion, Modulo)
VALUES (s.Codigo, s.Nombre, s.Descripcion, s.Modulo);
GO
PRINT 'Permiso seeds applied (18 permisos).';
GO

View File

@@ -0,0 +1,96 @@
-- V006__create_rol_permiso.sql
-- Tabla M:N Rol ↔ Permiso + seed inicial según matriz §2.4.2.
-- Requiere: V003 (dbo.Rol), V005 (dbo.Permiso).
-- Convención RBAC: cada permiso nuevo → asignar explícitamente a admin en la misma migración.
-- Run on: SIGCM2 (prod) and SIGCM2_Test (integration tests)
SET QUOTED_IDENTIFIER ON;
SET ANSI_NULLS ON;
SET NOCOUNT ON;
GO
IF OBJECT_ID(N'dbo.RolPermiso', N'U') IS NULL
BEGIN
CREATE TABLE dbo.RolPermiso (
Id INT IDENTITY(1,1) NOT NULL CONSTRAINT PK_RolPermiso PRIMARY KEY,
RolId INT NOT NULL CONSTRAINT FK_RolPermiso_Rol REFERENCES dbo.Rol(Id) ON DELETE CASCADE,
PermisoId INT NOT NULL CONSTRAINT FK_RolPermiso_Permiso REFERENCES dbo.Permiso(Id) ON DELETE CASCADE,
FechaAsignacion DATETIME2(3) NOT NULL CONSTRAINT DF_RolPermiso_FA DEFAULT(SYSUTCDATETIME()),
CONSTRAINT UQ_RolPermiso UNIQUE (RolId, PermisoId)
);
CREATE INDEX IX_RolPermiso_RolId ON dbo.RolPermiso (RolId);
CREATE INDEX IX_RolPermiso_PermisoId ON dbo.RolPermiso (PermisoId);
PRINT 'Table dbo.RolPermiso created.';
END
ELSE
PRINT 'Table dbo.RolPermiso already exists — skip.';
GO
-- Seed: mapeo rol → permisos según matriz §2.4.2
-- admin: 18 permisos (explícito — sin wildcard, convención RBAC)
-- cajero: 4 permisos (ventas contado)
-- operador_ctacte: 2 permisos (ventas ctacte)
-- picadora: 2 permisos (textos)
-- jefe_publicidad: 7 permisos (textos + pauta + productores)
-- productor: 2 permisos (productores)
-- diagramacion: 1 permiso (pauta:azanu:ver)
-- reportes: 0 permisos (solo lectura reportes — sin permisos en este catálogo)
-- Total rows: 36
MERGE dbo.RolPermiso AS t
USING (
SELECT r.Id AS RolId, p.Id AS PermisoId
FROM (VALUES
-- admin (18 permisos)
('admin', 'ventas:contado:crear'),
('admin', 'ventas:contado:modificar'),
('admin', 'ventas:contado:cobrar'),
('admin', 'ventas:contado:facturar'),
('admin', 'ventas:ctacte:crear'),
('admin', 'ventas:ctacte:facturar'),
('admin', 'textos:editar'),
('admin', 'textos:reclamos:ver'),
('admin', 'pauta:azanu:ver'),
('admin', 'pauta:limpiar'),
('admin', 'pauta:recursos:fueradehora'),
('admin', 'productores:deuda:ver'),
('admin', 'productores:pendientes:crear'),
('admin', 'productores:deuda:bypass'),
('admin', 'administracion:usuarios:gestionar'),
('admin', 'administracion:tarifarios:gestionar'),
('admin', 'administracion:medios:gestionar'),
('admin', 'administracion:auditoria:ver'),
-- cajero (4 permisos)
('cajero', 'ventas:contado:crear'),
('cajero', 'ventas:contado:modificar'),
('cajero', 'ventas:contado:cobrar'),
('cajero', 'ventas:contado:facturar'),
-- operador_ctacte (2 permisos)
('operador_ctacte', 'ventas:ctacte:crear'),
('operador_ctacte', 'ventas:ctacte:facturar'),
-- picadora (2 permisos)
('picadora', 'textos:editar'),
('picadora', 'textos:reclamos:ver'),
-- jefe_publicidad (7 permisos)
('jefe_publicidad', 'textos:editar'),
('jefe_publicidad', 'textos:reclamos:ver'),
('jefe_publicidad', 'pauta:azanu:ver'),
('jefe_publicidad', 'pauta:limpiar'),
('jefe_publicidad', 'pauta:recursos:fueradehora'),
('jefe_publicidad', 'productores:deuda:ver'),
('jefe_publicidad', 'productores:deuda:bypass'),
-- productor (2 permisos)
('productor', 'productores:deuda:ver'),
('productor', 'productores:pendientes:crear'),
-- diagramacion (1 permiso)
('diagramacion', 'pauta:azanu:ver')
-- reportes: 0 permisos — no filas
) AS x (RolCodigo, PermisoCodigo)
JOIN dbo.Rol r ON r.Codigo = x.RolCodigo
JOIN dbo.Permiso p ON p.Codigo = x.PermisoCodigo
) AS s ON t.RolId = s.RolId AND t.PermisoId = s.PermisoId
WHEN NOT MATCHED BY TARGET THEN
INSERT (RolId, PermisoId) VALUES (s.RolId, s.PermisoId);
GO
PRINT 'RolPermiso seeds applied (36 rows: admin×18 + cajero×4 + operador_ctacte×2 + picadora×2 + jefe_publicidad×7 + productor×2 + diagramacion×1).';
GO

View File

@@ -0,0 +1,42 @@
-- V007__add_admin_permissions_udt006.sql
-- Agrega 3 permisos administrativos requeridos por UDT-006 (middleware de autorización RBAC).
-- Los 3 nuevos permisos se asignan al rol 'admin' inmediatamente.
-- Convención RBAC: cada permiso nuevo → asignar explícitamente a admin en la misma migración.
-- Run on: SIGCM2 (prod) and SIGCM2_Test (integration tests)
SET QUOTED_IDENTIFIER ON;
SET ANSI_NULLS ON;
SET NOCOUNT ON;
GO
-- Agregar los 3 permisos nuevos al catálogo (idempotente via MERGE)
MERGE dbo.Permiso AS t
USING (VALUES
('administracion:roles:gestionar', N'Gestionar roles del sistema', N'Crear, editar y desactivar roles RBAC', 'administracion'),
('administracion:roles_permisos:gestionar', N'Gestionar asignación de permisos', N'Asignar y revocar permisos por rol', 'administracion'),
('administracion:permisos:ver', N'Ver catálogo de permisos', N'Consultar el listado de permisos del sistema', 'administracion')
) AS s (Codigo, Nombre, Descripcion, Modulo)
ON t.Codigo = s.Codigo
WHEN NOT MATCHED BY TARGET THEN
INSERT (Codigo, Nombre, Descripcion, Modulo)
VALUES (s.Codigo, s.Nombre, s.Descripcion, s.Modulo);
GO
-- Asignar los 3 nuevos permisos al rol 'admin' (idempotente via MERGE)
MERGE dbo.RolPermiso AS t
USING (
SELECT r.Id AS RolId, p.Id AS PermisoId
FROM (VALUES
('admin', 'administracion:roles:gestionar'),
('admin', 'administracion:roles_permisos:gestionar'),
('admin', 'administracion:permisos:ver')
) AS x (RolCodigo, PermisoCodigo)
JOIN dbo.Rol r ON r.Codigo = x.RolCodigo
JOIN dbo.Permiso p ON p.Codigo = x.PermisoCodigo
) AS s ON t.RolId = s.RolId AND t.PermisoId = s.PermisoId
WHEN NOT MATCHED BY TARGET THEN
INSERT (RolId, PermisoId) VALUES (s.RolId, s.PermisoId);
GO
PRINT 'V007: 3 permisos administracion:roles|roles-permisos|permisos agregados al catalogo y asignados a admin.';
GO

View File

@@ -0,0 +1,34 @@
-- V008: Add MustChangePassword column + IX_Usuario_Activo_Rol index
-- Idempotent: re-runnable without errors.
SET QUOTED_IDENTIFIER ON;
SET ANSI_NULLS ON;
SET NOCOUNT ON;
GO
-- Add MustChangePassword column (idempotent via COL_LENGTH check)
IF COL_LENGTH('dbo.Usuario', 'MustChangePassword') IS NULL
BEGIN
ALTER TABLE dbo.Usuario
ADD MustChangePassword BIT NOT NULL
CONSTRAINT DF_Usuario_MustChangePassword DEFAULT(0);
PRINT 'Column MustChangePassword added to dbo.Usuario.';
END
ELSE
PRINT 'Column MustChangePassword already exists — skipping.';
GO
-- Compound index for listado filtrado (Activo + Rol) and anti-lockout guard
IF NOT EXISTS (
SELECT 1 FROM sys.indexes
WHERE name = 'IX_Usuario_Activo_Rol'
AND object_id = OBJECT_ID('dbo.Usuario')
)
BEGIN
CREATE INDEX IX_Usuario_Activo_Rol
ON dbo.Usuario(Activo, Rol)
INCLUDE (Id, Username, Email, UltimoLogin, FechaModificacion);
PRINT 'Index IX_Usuario_Activo_Rol created.';
END
ELSE
PRINT 'Index IX_Usuario_Activo_Rol already exists — skipping.';
GO

View File

@@ -0,0 +1,43 @@
-- V009__activate_permisos_overrides.sql
-- Activates Usuario.PermisosJson as explicit overrides {grant, deny} on top of role permissions.
-- Idempotent: safe to run multiple times.
SET QUOTED_IDENTIFIER ON;
SET ANSI_NULLS ON;
GO
-- 1. Drop old default constraint if it exists (handles any previous shape)
IF EXISTS (
SELECT 1 FROM sys.default_constraints
WHERE name = 'DF_Usuario_Permisos'
AND parent_object_id = OBJECT_ID('dbo.Usuario')
)
BEGIN
ALTER TABLE dbo.Usuario DROP CONSTRAINT DF_Usuario_Permisos;
PRINT 'Dropped DF_Usuario_Permisos.';
END
GO
-- 2. Re-add default constraint with canonical shape
IF NOT EXISTS (
SELECT 1 FROM sys.default_constraints
WHERE name = 'DF_Usuario_Permisos'
AND parent_object_id = OBJECT_ID('dbo.Usuario')
)
BEGIN
ALTER TABLE dbo.Usuario
ADD CONSTRAINT DF_Usuario_Permisos
DEFAULT('{"grant":[],"deny":[]}') FOR PermisosJson;
PRINT 'Added DF_Usuario_Permisos with new shape {"grant":[],"deny":[]}.';
END
GO
-- 3. Migrate legacy values to new canonical shape
UPDATE dbo.Usuario
SET PermisosJson = '{"grant":[],"deny":[]}'
WHERE PermisosJson IN ('[]', '["*"]', '')
OR PermisosJson IS NULL
OR LTRIM(RTRIM(PermisosJson)) = '';
PRINT 'Migrated legacy PermisosJson rows to canonical shape.';
GO

View File

@@ -0,0 +1,183 @@
-- V010_ROLLBACK.sql
-- Reversa de V010__audit_infrastructure.sql.
--
-- ⚠️ ADVERTENCIA: ejecutar este script ELIMINA toda la historia auditada.
-- - dbo.AuditEvent y dbo.SecurityEvent se dropean (junto con datos).
-- - History tables (Usuario_History, Rol_History, Permiso_History, RolPermiso_History) se dropean.
-- - Particionamiento, filegroups y archivos físicos se desmontan.
--
-- Uso intended: ROLLBACK de emergencia en entornos NO-productivos.
-- En prod futuro, este script NO se ejecuta: si hace falta revertir, se hace
-- restore de backup previo a V010.
SET QUOTED_IDENTIFIER ON;
SET ANSI_NULLS ON;
SET NOCOUNT ON;
GO
-- ═══════════════════════════════════════════════════════════════════════
-- 1. Apagar SYSTEM_VERSIONING + remover columnas PERIOD en las 4 tablas
-- ═══════════════════════════════════════════════════════════════════════
-- Usuario
IF EXISTS (SELECT 1 FROM sys.tables WHERE object_id = OBJECT_ID('dbo.Usuario') AND temporal_type = 2)
BEGIN
ALTER TABLE dbo.Usuario SET (SYSTEM_VERSIONING = OFF);
PRINT 'Usuario: SYSTEM_VERSIONING OFF.';
END
GO
IF EXISTS (SELECT 1 FROM sys.periods WHERE object_id = OBJECT_ID('dbo.Usuario'))
BEGIN
ALTER TABLE dbo.Usuario DROP PERIOD FOR SYSTEM_TIME;
PRINT 'Usuario: PERIOD FOR SYSTEM_TIME dropped.';
END
GO
IF COL_LENGTH('dbo.Usuario', 'ValidFrom') IS NOT NULL
BEGIN
ALTER TABLE dbo.Usuario DROP CONSTRAINT IF EXISTS DF_Usuario_ValidFrom;
ALTER TABLE dbo.Usuario DROP CONSTRAINT IF EXISTS DF_Usuario_ValidTo;
ALTER TABLE dbo.Usuario DROP COLUMN ValidFrom, ValidTo;
PRINT 'Usuario: ValidFrom/ValidTo dropped.';
END
GO
IF OBJECT_ID(N'dbo.Usuario_History', N'U') IS NOT NULL
BEGIN
DROP TABLE dbo.Usuario_History;
PRINT 'Usuario_History dropped.';
END
GO
-- Rol
IF EXISTS (SELECT 1 FROM sys.tables WHERE object_id = OBJECT_ID('dbo.Rol') AND temporal_type = 2)
ALTER TABLE dbo.Rol SET (SYSTEM_VERSIONING = OFF);
GO
IF EXISTS (SELECT 1 FROM sys.periods WHERE object_id = OBJECT_ID('dbo.Rol'))
ALTER TABLE dbo.Rol DROP PERIOD FOR SYSTEM_TIME;
GO
IF COL_LENGTH('dbo.Rol', 'ValidFrom') IS NOT NULL
BEGIN
ALTER TABLE dbo.Rol DROP CONSTRAINT IF EXISTS DF_Rol_ValidFrom;
ALTER TABLE dbo.Rol DROP CONSTRAINT IF EXISTS DF_Rol_ValidTo;
ALTER TABLE dbo.Rol DROP COLUMN ValidFrom, ValidTo;
END
GO
IF OBJECT_ID(N'dbo.Rol_History', N'U') IS NOT NULL DROP TABLE dbo.Rol_History;
GO
-- Permiso
IF EXISTS (SELECT 1 FROM sys.tables WHERE object_id = OBJECT_ID('dbo.Permiso') AND temporal_type = 2)
ALTER TABLE dbo.Permiso SET (SYSTEM_VERSIONING = OFF);
GO
IF EXISTS (SELECT 1 FROM sys.periods WHERE object_id = OBJECT_ID('dbo.Permiso'))
ALTER TABLE dbo.Permiso DROP PERIOD FOR SYSTEM_TIME;
GO
IF COL_LENGTH('dbo.Permiso', 'ValidFrom') IS NOT NULL
BEGIN
ALTER TABLE dbo.Permiso DROP CONSTRAINT IF EXISTS DF_Permiso_ValidFrom;
ALTER TABLE dbo.Permiso DROP CONSTRAINT IF EXISTS DF_Permiso_ValidTo;
ALTER TABLE dbo.Permiso DROP COLUMN ValidFrom, ValidTo;
END
GO
IF OBJECT_ID(N'dbo.Permiso_History', N'U') IS NOT NULL DROP TABLE dbo.Permiso_History;
GO
-- RolPermiso
IF EXISTS (SELECT 1 FROM sys.tables WHERE object_id = OBJECT_ID('dbo.RolPermiso') AND temporal_type = 2)
ALTER TABLE dbo.RolPermiso SET (SYSTEM_VERSIONING = OFF);
GO
IF EXISTS (SELECT 1 FROM sys.periods WHERE object_id = OBJECT_ID('dbo.RolPermiso'))
ALTER TABLE dbo.RolPermiso DROP PERIOD FOR SYSTEM_TIME;
GO
IF COL_LENGTH('dbo.RolPermiso', 'ValidFrom') IS NOT NULL
BEGIN
ALTER TABLE dbo.RolPermiso DROP CONSTRAINT IF EXISTS DF_RolPermiso_ValidFrom;
ALTER TABLE dbo.RolPermiso DROP CONSTRAINT IF EXISTS DF_RolPermiso_ValidTo;
ALTER TABLE dbo.RolPermiso DROP COLUMN ValidFrom, ValidTo;
END
GO
IF OBJECT_ID(N'dbo.RolPermiso_History', N'U') IS NOT NULL DROP TABLE dbo.RolPermiso_History;
GO
-- ═══════════════════════════════════════════════════════════════════════
-- 2. Drop AuditEvent + SecurityEvent
-- ═══════════════════════════════════════════════════════════════════════
IF OBJECT_ID(N'dbo.AuditEvent', N'U') IS NOT NULL
BEGIN
DROP TABLE dbo.AuditEvent;
PRINT 'Table dbo.AuditEvent dropped.';
END
GO
IF OBJECT_ID(N'dbo.SecurityEvent', N'U') IS NOT NULL
BEGIN
DROP TABLE dbo.SecurityEvent;
PRINT 'Table dbo.SecurityEvent dropped.';
END
GO
-- ═══════════════════════════════════════════════════════════════════════
-- 3. Drop partition schemes + functions
-- ═══════════════════════════════════════════════════════════════════════
IF EXISTS (SELECT 1 FROM sys.partition_schemes WHERE name = 'ps_AuditEvent_Monthly')
DROP PARTITION SCHEME ps_AuditEvent_Monthly;
GO
IF EXISTS (SELECT 1 FROM sys.partition_functions WHERE name = 'pf_AuditEvent_Monthly')
DROP PARTITION FUNCTION pf_AuditEvent_Monthly;
GO
IF EXISTS (SELECT 1 FROM sys.partition_schemes WHERE name = 'ps_SecurityEvent_Monthly')
DROP PARTITION SCHEME ps_SecurityEvent_Monthly;
GO
IF EXISTS (SELECT 1 FROM sys.partition_functions WHERE name = 'pf_SecurityEvent_Monthly')
DROP PARTITION FUNCTION pf_SecurityEvent_Monthly;
GO
-- ═══════════════════════════════════════════════════════════════════════
-- 4. Remover archivos físicos y filegroups
-- ═══════════════════════════════════════════════════════════════════════
DECLARE @dbName NVARCHAR(128) = DB_NAME();
DECLARE @hotLogical NVARCHAR(128) = @dbName + N'_AUDIT_HOT';
DECLARE @coldLogical NVARCHAR(128) = @dbName + N'_AUDIT_COLD';
DECLARE @sql NVARCHAR(MAX);
IF EXISTS (SELECT 1 FROM sys.database_files WHERE name = @hotLogical)
BEGIN
SET @sql = N'ALTER DATABASE CURRENT REMOVE FILE [' + @hotLogical + N'];';
EXEC sp_executesql @sql;
PRINT 'File ' + @hotLogical + ' removed.';
END
IF EXISTS (SELECT 1 FROM sys.database_files WHERE name = @coldLogical)
BEGIN
SET @sql = N'ALTER DATABASE CURRENT REMOVE FILE [' + @coldLogical + N'];';
EXEC sp_executesql @sql;
PRINT 'File ' + @coldLogical + ' removed.';
END
IF EXISTS (SELECT 1 FROM sys.filegroups WHERE name = 'AUDIT_HOT')
EXEC sp_executesql N'ALTER DATABASE CURRENT REMOVE FILEGROUP AUDIT_HOT;';
IF EXISTS (SELECT 1 FROM sys.filegroups WHERE name = 'AUDIT_COLD')
EXEC sp_executesql N'ALTER DATABASE CURRENT REMOVE FILEGROUP AUDIT_COLD;';
GO
PRINT '';
PRINT 'V010 rolled back. Audit infrastructure removed. All audit history is permanently LOST.';
GO

View File

@@ -0,0 +1,434 @@
-- V010__audit_infrastructure.sql
-- UDT-010: Infraestructura de Auditoría y Trazabilidad (Fase 0.5 — transversal).
--
-- Cambios:
-- 1. Filegroups AUDIT_HOT y AUDIT_COLD (archivos físicos en default data path).
-- 2. Partition functions + schemes mensuales (RANGE RIGHT) para AuditEvent y SecurityEvent.
-- 3. dbo.AuditEvent particionada + 4 índices + CHECK constraints.
-- 4. dbo.SecurityEvent particionada + 3 índices + CHECK constraints.
-- 5. SYSTEM_VERSIONING en dbo.Usuario, dbo.Rol, dbo.Permiso, dbo.RolPermiso
-- con HISTORY_RETENTION_PERIOD = 10 YEARS y PAGE compression en history tables.
--
-- Source of truth del diseño: Obsidian/02-ARQUITECTURA-y-TECH-STACK/2.5 📋 Auditoría.md
-- Idempotente: seguro para re-ejecutar.
-- Reversa: V010_ROLLBACK.sql (pierde TODA la historia auditada).
-- Run on: SIGCM2 (dev) y SIGCM2_Test (integration tests). Para producción futuro,
-- revisar database/README.md (ventana de mantenimiento + backup previo).
SET QUOTED_IDENTIFIER ON;
SET ANSI_NULLS ON;
SET NOCOUNT ON;
GO
-- ═══════════════════════════════════════════════════════════════════════
-- 1. FILEGROUPS + ARCHIVOS FÍSICOS
-- ═══════════════════════════════════════════════════════════════════════
-- Usamos el default data path del server + DB_NAME como prefijo lógico,
-- así SIGCM2 y SIGCM2_Test coexisten sin colisión de logical file names.
DECLARE @dbName NVARCHAR(128) = DB_NAME();
DECLARE @dataPath NVARCHAR(260) = CAST(SERVERPROPERTY('InstanceDefaultDataPath') AS NVARCHAR(260));
DECLARE @hotLogical NVARCHAR(128) = @dbName + N'_AUDIT_HOT';
DECLARE @coldLogical NVARCHAR(128) = @dbName + N'_AUDIT_COLD';
DECLARE @hotPhysical NVARCHAR(260) = @dataPath + @hotLogical + N'.ndf';
DECLARE @coldPhysical NVARCHAR(260) = @dataPath + @coldLogical + N'.ndf';
DECLARE @sql NVARCHAR(MAX);
-- Filegroup AUDIT_HOT
IF NOT EXISTS (SELECT 1 FROM sys.filegroups WHERE name = 'AUDIT_HOT')
BEGIN
EXEC sp_executesql N'ALTER DATABASE CURRENT ADD FILEGROUP AUDIT_HOT;';
PRINT 'Filegroup AUDIT_HOT created.';
END
ELSE
PRINT 'Filegroup AUDIT_HOT already exists — skip.';
IF NOT EXISTS (SELECT 1 FROM sys.database_files WHERE name = @hotLogical)
BEGIN
SET @sql = N'ALTER DATABASE CURRENT ADD FILE (
NAME = N''' + @hotLogical + N''',
FILENAME = N''' + @hotPhysical + N''',
SIZE = 64MB,
FILEGROWTH = 64MB
) TO FILEGROUP AUDIT_HOT;';
EXEC sp_executesql @sql;
PRINT 'File ' + @hotLogical + ' added to filegroup AUDIT_HOT.';
END
ELSE
PRINT 'File ' + @hotLogical + ' already exists — skip.';
-- Filegroup AUDIT_COLD
IF NOT EXISTS (SELECT 1 FROM sys.filegroups WHERE name = 'AUDIT_COLD')
BEGIN
EXEC sp_executesql N'ALTER DATABASE CURRENT ADD FILEGROUP AUDIT_COLD;';
PRINT 'Filegroup AUDIT_COLD created.';
END
ELSE
PRINT 'Filegroup AUDIT_COLD already exists — skip.';
IF NOT EXISTS (SELECT 1 FROM sys.database_files WHERE name = @coldLogical)
BEGIN
SET @sql = N'ALTER DATABASE CURRENT ADD FILE (
NAME = N''' + @coldLogical + N''',
FILENAME = N''' + @coldPhysical + N''',
SIZE = 64MB,
FILEGROWTH = 64MB
) TO FILEGROUP AUDIT_COLD;';
EXEC sp_executesql @sql;
PRINT 'File ' + @coldLogical + ' added to filegroup AUDIT_COLD.';
END
ELSE
PRINT 'File ' + @coldLogical + ' already exists — skip.';
GO
-- ═══════════════════════════════════════════════════════════════════════
-- 2. PARTITION FUNCTIONS + SCHEMES (mensuales, RANGE RIGHT)
-- ═══════════════════════════════════════════════════════════════════════
-- Boundaries iniciales: 14 valores de 2026-01-01 a 2027-02-01 → 15 particiones.
-- AuditPartitionManagerJob (B11) extiende mes a mes automáticamente.
IF NOT EXISTS (SELECT 1 FROM sys.partition_functions WHERE name = 'pf_AuditEvent_Monthly')
BEGIN
CREATE PARTITION FUNCTION pf_AuditEvent_Monthly (DATETIME2(3))
AS RANGE RIGHT FOR VALUES (
'2026-01-01T00:00:00.000', '2026-02-01T00:00:00.000', '2026-03-01T00:00:00.000',
'2026-04-01T00:00:00.000', '2026-05-01T00:00:00.000', '2026-06-01T00:00:00.000',
'2026-07-01T00:00:00.000', '2026-08-01T00:00:00.000', '2026-09-01T00:00:00.000',
'2026-10-01T00:00:00.000', '2026-11-01T00:00:00.000', '2026-12-01T00:00:00.000',
'2027-01-01T00:00:00.000', '2027-02-01T00:00:00.000'
);
PRINT 'Partition function pf_AuditEvent_Monthly created.';
END
ELSE
PRINT 'Partition function pf_AuditEvent_Monthly already exists — skip.';
GO
IF NOT EXISTS (SELECT 1 FROM sys.partition_schemes WHERE name = 'ps_AuditEvent_Monthly')
BEGIN
CREATE PARTITION SCHEME ps_AuditEvent_Monthly
AS PARTITION pf_AuditEvent_Monthly ALL TO ([AUDIT_HOT]);
PRINT 'Partition scheme ps_AuditEvent_Monthly created.';
END
ELSE
PRINT 'Partition scheme ps_AuditEvent_Monthly already exists — skip.';
GO
IF NOT EXISTS (SELECT 1 FROM sys.partition_functions WHERE name = 'pf_SecurityEvent_Monthly')
BEGIN
CREATE PARTITION FUNCTION pf_SecurityEvent_Monthly (DATETIME2(3))
AS RANGE RIGHT FOR VALUES (
'2026-01-01T00:00:00.000', '2026-02-01T00:00:00.000', '2026-03-01T00:00:00.000',
'2026-04-01T00:00:00.000', '2026-05-01T00:00:00.000', '2026-06-01T00:00:00.000',
'2026-07-01T00:00:00.000', '2026-08-01T00:00:00.000', '2026-09-01T00:00:00.000',
'2026-10-01T00:00:00.000', '2026-11-01T00:00:00.000', '2026-12-01T00:00:00.000',
'2027-01-01T00:00:00.000', '2027-02-01T00:00:00.000'
);
PRINT 'Partition function pf_SecurityEvent_Monthly created.';
END
ELSE
PRINT 'Partition function pf_SecurityEvent_Monthly already exists — skip.';
GO
IF NOT EXISTS (SELECT 1 FROM sys.partition_schemes WHERE name = 'ps_SecurityEvent_Monthly')
BEGIN
CREATE PARTITION SCHEME ps_SecurityEvent_Monthly
AS PARTITION pf_SecurityEvent_Monthly ALL TO ([AUDIT_HOT]);
PRINT 'Partition scheme ps_SecurityEvent_Monthly created.';
END
ELSE
PRINT 'Partition scheme ps_SecurityEvent_Monthly already exists — skip.';
GO
-- ═══════════════════════════════════════════════════════════════════════
-- 3. dbo.AuditEvent (eventos de dominio, retention 10 años)
-- ═══════════════════════════════════════════════════════════════════════
IF OBJECT_ID(N'dbo.AuditEvent', N'U') IS NULL
BEGIN
CREATE TABLE dbo.AuditEvent (
Id BIGINT IDENTITY(1,1) NOT NULL,
OccurredAt DATETIME2(3) NOT NULL CONSTRAINT DF_AuditEvent_OccurredAt DEFAULT(SYSUTCDATETIME()),
ActorUserId INT NULL, -- NULL solo para eventos del sistema (jobs)
ActorRoleId INT NULL, -- rol efectivo al momento del evento (denormalizado)
Action VARCHAR(100) NOT NULL, -- "usuario.create", "cliente.deactivate"
TargetType VARCHAR(50) NOT NULL, -- "Usuario", "Cliente", "Factura"
TargetId VARCHAR(100) NOT NULL, -- PK del target como string (soporta INT/GUID)
CorrelationId UNIQUEIDENTIFIER NULL, -- linkea eventos de una misma operación
IpAddress VARCHAR(45) NULL, -- IPv4/IPv6
UserAgent VARCHAR(500) NULL,
Metadata NVARCHAR(MAX) NULL, -- JSON libre (ya sanitizado por la app)
CONSTRAINT PK_AuditEvent PRIMARY KEY CLUSTERED (OccurredAt, Id)
ON ps_AuditEvent_Monthly(OccurredAt),
CONSTRAINT CK_AuditEvent_Action CHECK (Action LIKE '%.%'),
CONSTRAINT CK_AuditEvent_Metadata CHECK (Metadata IS NULL OR ISJSON(Metadata) = 1)
) ON ps_AuditEvent_Monthly(OccurredAt);
PRINT 'Table dbo.AuditEvent created (partitioned monthly on OccurredAt).';
END
ELSE
PRINT 'Table dbo.AuditEvent already exists — skip.';
GO
-- Índices (cubren 95% de queries: actor / target / action / correlation)
IF NOT EXISTS (SELECT 1 FROM sys.indexes WHERE name = 'IX_AuditEvent_Actor' AND object_id = OBJECT_ID('dbo.AuditEvent'))
BEGIN
CREATE NONCLUSTERED INDEX IX_AuditEvent_Actor
ON dbo.AuditEvent(ActorUserId, OccurredAt DESC)
INCLUDE (Action, TargetType, TargetId, CorrelationId)
WITH (DATA_COMPRESSION = PAGE)
ON ps_AuditEvent_Monthly(OccurredAt);
PRINT 'Index IX_AuditEvent_Actor created.';
END
GO
IF NOT EXISTS (SELECT 1 FROM sys.indexes WHERE name = 'IX_AuditEvent_Target' AND object_id = OBJECT_ID('dbo.AuditEvent'))
BEGIN
CREATE NONCLUSTERED INDEX IX_AuditEvent_Target
ON dbo.AuditEvent(TargetType, TargetId, OccurredAt DESC)
INCLUDE (ActorUserId, Action, CorrelationId)
WITH (DATA_COMPRESSION = PAGE)
ON ps_AuditEvent_Monthly(OccurredAt);
PRINT 'Index IX_AuditEvent_Target created.';
END
GO
IF NOT EXISTS (SELECT 1 FROM sys.indexes WHERE name = 'IX_AuditEvent_Action' AND object_id = OBJECT_ID('dbo.AuditEvent'))
BEGIN
CREATE NONCLUSTERED INDEX IX_AuditEvent_Action
ON dbo.AuditEvent(Action, OccurredAt DESC)
INCLUDE (ActorUserId, TargetType, TargetId)
WITH (DATA_COMPRESSION = PAGE)
ON ps_AuditEvent_Monthly(OccurredAt);
PRINT 'Index IX_AuditEvent_Action created.';
END
GO
IF NOT EXISTS (SELECT 1 FROM sys.indexes WHERE name = 'IX_AuditEvent_Correlation' AND object_id = OBJECT_ID('dbo.AuditEvent'))
BEGIN
CREATE NONCLUSTERED INDEX IX_AuditEvent_Correlation
ON dbo.AuditEvent(CorrelationId)
WHERE CorrelationId IS NOT NULL
ON ps_AuditEvent_Monthly(OccurredAt);
PRINT 'Index IX_AuditEvent_Correlation (filtered) created.';
END
GO
-- ═══════════════════════════════════════════════════════════════════════
-- 4. dbo.SecurityEvent (eventos de seguridad, retention 5 años)
-- ═══════════════════════════════════════════════════════════════════════
IF OBJECT_ID(N'dbo.SecurityEvent', N'U') IS NULL
BEGIN
CREATE TABLE dbo.SecurityEvent (
Id BIGINT IDENTITY(1,1) NOT NULL,
OccurredAt DATETIME2(3) NOT NULL CONSTRAINT DF_SecurityEvent_OccurredAt DEFAULT(SYSUTCDATETIME()),
ActorUserId INT NULL, -- NULL si login falló y user no existe
AttemptedUsername VARCHAR(256) NULL, -- para login failures
SessionId UNIQUEIDENTIFIER NULL,
Action VARCHAR(100) NOT NULL, -- "login", "logout", "refresh.reuse_detected", "permission.denied"
Result VARCHAR(20) NOT NULL, -- "success" | "failure"
FailureReason VARCHAR(200) NULL, -- "invalid_password", "account_locked"
IpAddress VARCHAR(45) NULL,
UserAgent VARCHAR(500) NULL,
Metadata NVARCHAR(MAX) NULL,
CONSTRAINT PK_SecurityEvent PRIMARY KEY CLUSTERED (OccurredAt, Id)
ON ps_SecurityEvent_Monthly(OccurredAt),
CONSTRAINT CK_SecurityEvent_Result CHECK (Result IN ('success','failure')),
CONSTRAINT CK_SecurityEvent_Metadata CHECK (Metadata IS NULL OR ISJSON(Metadata) = 1)
) ON ps_SecurityEvent_Monthly(OccurredAt);
PRINT 'Table dbo.SecurityEvent created (partitioned monthly on OccurredAt).';
END
ELSE
PRINT 'Table dbo.SecurityEvent already exists — skip.';
GO
IF NOT EXISTS (SELECT 1 FROM sys.indexes WHERE name = 'IX_SecurityEvent_Actor' AND object_id = OBJECT_ID('dbo.SecurityEvent'))
BEGIN
CREATE NONCLUSTERED INDEX IX_SecurityEvent_Actor
ON dbo.SecurityEvent(ActorUserId, OccurredAt DESC)
INCLUDE (Action, Result, SessionId)
WITH (DATA_COMPRESSION = PAGE)
ON ps_SecurityEvent_Monthly(OccurredAt);
PRINT 'Index IX_SecurityEvent_Actor created.';
END
GO
IF NOT EXISTS (SELECT 1 FROM sys.indexes WHERE name = 'IX_SecurityEvent_Action_Result' AND object_id = OBJECT_ID('dbo.SecurityEvent'))
BEGIN
CREATE NONCLUSTERED INDEX IX_SecurityEvent_Action_Result
ON dbo.SecurityEvent(Action, Result, OccurredAt DESC)
INCLUDE (ActorUserId, IpAddress)
WITH (DATA_COMPRESSION = PAGE)
ON ps_SecurityEvent_Monthly(OccurredAt);
PRINT 'Index IX_SecurityEvent_Action_Result created.';
END
GO
IF NOT EXISTS (SELECT 1 FROM sys.indexes WHERE name = 'IX_SecurityEvent_Ip_Failure' AND object_id = OBJECT_ID('dbo.SecurityEvent'))
BEGIN
CREATE NONCLUSTERED INDEX IX_SecurityEvent_Ip_Failure
ON dbo.SecurityEvent(IpAddress, OccurredAt DESC)
WHERE Result = 'failure'
ON ps_SecurityEvent_Monthly(OccurredAt);
PRINT 'Index IX_SecurityEvent_Ip_Failure (filtered) created.';
END
GO
-- ═══════════════════════════════════════════════════════════════════════
-- 5. SYSTEM_VERSIONING — Usuario, Rol, Permiso, RolPermiso
-- ═══════════════════════════════════════════════════════════════════════
-- Patrón: (a) agregar PERIOD FOR SYSTEM_TIME con columnas HIDDEN,
-- (b) activar SYSTEM_VERSIONING con HISTORY_RETENTION_PERIOD 10 YEARS,
-- (c) rebuild history con PAGE compression.
-- Tablas con datos: los registros existentes reciben ValidFrom = instante del ALTER.
-- ─── Usuario ───────────────────────────────────────────────────────────
IF COL_LENGTH('dbo.Usuario', 'ValidFrom') IS NULL
BEGIN
ALTER TABLE dbo.Usuario
ADD
ValidFrom DATETIME2(3) GENERATED ALWAYS AS ROW START HIDDEN NOT NULL
CONSTRAINT DF_Usuario_ValidFrom DEFAULT(SYSUTCDATETIME()),
ValidTo DATETIME2(3) GENERATED ALWAYS AS ROW END HIDDEN NOT NULL
CONSTRAINT DF_Usuario_ValidTo DEFAULT(CONVERT(DATETIME2(3), '9999-12-31 23:59:59.999')),
PERIOD FOR SYSTEM_TIME (ValidFrom, ValidTo);
PRINT 'Usuario: PERIOD FOR SYSTEM_TIME added.';
END
GO
IF NOT EXISTS (SELECT 1 FROM sys.tables WHERE object_id = OBJECT_ID('dbo.Usuario') AND temporal_type = 2)
BEGIN
ALTER TABLE dbo.Usuario
SET (SYSTEM_VERSIONING = ON (
HISTORY_TABLE = dbo.Usuario_History,
HISTORY_RETENTION_PERIOD = 10 YEARS
));
PRINT 'Usuario: SYSTEM_VERSIONING = ON (history: dbo.Usuario_History, retention: 10 years).';
END
ELSE
PRINT 'Usuario: SYSTEM_VERSIONING already ON — skip.';
GO
IF EXISTS (SELECT 1 FROM sys.tables WHERE name = 'Usuario_History' AND schema_id = SCHEMA_ID('dbo'))
AND NOT EXISTS (
SELECT 1 FROM sys.partitions p
JOIN sys.tables t ON t.object_id = p.object_id
WHERE t.name = 'Usuario_History' AND p.data_compression = 2
)
BEGIN
ALTER TABLE dbo.Usuario_History REBUILD WITH (DATA_COMPRESSION = PAGE);
PRINT 'Usuario_History: rebuilt with PAGE compression.';
END
GO
-- ─── Rol ───────────────────────────────────────────────────────────────
IF COL_LENGTH('dbo.Rol', 'ValidFrom') IS NULL
BEGIN
ALTER TABLE dbo.Rol
ADD
ValidFrom DATETIME2(3) GENERATED ALWAYS AS ROW START HIDDEN NOT NULL
CONSTRAINT DF_Rol_ValidFrom DEFAULT(SYSUTCDATETIME()),
ValidTo DATETIME2(3) GENERATED ALWAYS AS ROW END HIDDEN NOT NULL
CONSTRAINT DF_Rol_ValidTo DEFAULT(CONVERT(DATETIME2(3), '9999-12-31 23:59:59.999')),
PERIOD FOR SYSTEM_TIME (ValidFrom, ValidTo);
PRINT 'Rol: PERIOD FOR SYSTEM_TIME added.';
END
GO
IF NOT EXISTS (SELECT 1 FROM sys.tables WHERE object_id = OBJECT_ID('dbo.Rol') AND temporal_type = 2)
BEGIN
ALTER TABLE dbo.Rol
SET (SYSTEM_VERSIONING = ON (
HISTORY_TABLE = dbo.Rol_History,
HISTORY_RETENTION_PERIOD = 10 YEARS
));
PRINT 'Rol: SYSTEM_VERSIONING = ON.';
END
GO
IF EXISTS (SELECT 1 FROM sys.tables WHERE name = 'Rol_History' AND schema_id = SCHEMA_ID('dbo'))
AND NOT EXISTS (
SELECT 1 FROM sys.partitions p JOIN sys.tables t ON t.object_id = p.object_id
WHERE t.name = 'Rol_History' AND p.data_compression = 2
)
BEGIN
ALTER TABLE dbo.Rol_History REBUILD WITH (DATA_COMPRESSION = PAGE);
PRINT 'Rol_History: rebuilt with PAGE compression.';
END
GO
-- ─── Permiso ───────────────────────────────────────────────────────────
IF COL_LENGTH('dbo.Permiso', 'ValidFrom') IS NULL
BEGIN
ALTER TABLE dbo.Permiso
ADD
ValidFrom DATETIME2(3) GENERATED ALWAYS AS ROW START HIDDEN NOT NULL
CONSTRAINT DF_Permiso_ValidFrom DEFAULT(SYSUTCDATETIME()),
ValidTo DATETIME2(3) GENERATED ALWAYS AS ROW END HIDDEN NOT NULL
CONSTRAINT DF_Permiso_ValidTo DEFAULT(CONVERT(DATETIME2(3), '9999-12-31 23:59:59.999')),
PERIOD FOR SYSTEM_TIME (ValidFrom, ValidTo);
PRINT 'Permiso: PERIOD FOR SYSTEM_TIME added.';
END
GO
IF NOT EXISTS (SELECT 1 FROM sys.tables WHERE object_id = OBJECT_ID('dbo.Permiso') AND temporal_type = 2)
BEGIN
ALTER TABLE dbo.Permiso
SET (SYSTEM_VERSIONING = ON (
HISTORY_TABLE = dbo.Permiso_History,
HISTORY_RETENTION_PERIOD = 10 YEARS
));
PRINT 'Permiso: SYSTEM_VERSIONING = ON.';
END
GO
IF EXISTS (SELECT 1 FROM sys.tables WHERE name = 'Permiso_History' AND schema_id = SCHEMA_ID('dbo'))
AND NOT EXISTS (
SELECT 1 FROM sys.partitions p JOIN sys.tables t ON t.object_id = p.object_id
WHERE t.name = 'Permiso_History' AND p.data_compression = 2
)
BEGIN
ALTER TABLE dbo.Permiso_History REBUILD WITH (DATA_COMPRESSION = PAGE);
PRINT 'Permiso_History: rebuilt with PAGE compression.';
END
GO
-- ─── RolPermiso ────────────────────────────────────────────────────────
IF COL_LENGTH('dbo.RolPermiso', 'ValidFrom') IS NULL
BEGIN
ALTER TABLE dbo.RolPermiso
ADD
ValidFrom DATETIME2(3) GENERATED ALWAYS AS ROW START HIDDEN NOT NULL
CONSTRAINT DF_RolPermiso_ValidFrom DEFAULT(SYSUTCDATETIME()),
ValidTo DATETIME2(3) GENERATED ALWAYS AS ROW END HIDDEN NOT NULL
CONSTRAINT DF_RolPermiso_ValidTo DEFAULT(CONVERT(DATETIME2(3), '9999-12-31 23:59:59.999')),
PERIOD FOR SYSTEM_TIME (ValidFrom, ValidTo);
PRINT 'RolPermiso: PERIOD FOR SYSTEM_TIME added.';
END
GO
IF NOT EXISTS (SELECT 1 FROM sys.tables WHERE object_id = OBJECT_ID('dbo.RolPermiso') AND temporal_type = 2)
BEGIN
ALTER TABLE dbo.RolPermiso
SET (SYSTEM_VERSIONING = ON (
HISTORY_TABLE = dbo.RolPermiso_History,
HISTORY_RETENTION_PERIOD = 10 YEARS
));
PRINT 'RolPermiso: SYSTEM_VERSIONING = ON.';
END
GO
IF EXISTS (SELECT 1 FROM sys.tables WHERE name = 'RolPermiso_History' AND schema_id = SCHEMA_ID('dbo'))
AND NOT EXISTS (
SELECT 1 FROM sys.partitions p JOIN sys.tables t ON t.object_id = p.object_id
WHERE t.name = 'RolPermiso_History' AND p.data_compression = 2
)
BEGIN
ALTER TABLE dbo.RolPermiso_History REBUILD WITH (DATA_COMPRESSION = PAGE);
PRINT 'RolPermiso_History: rebuilt with PAGE compression.';
END
GO
PRINT '';
PRINT 'V010 applied successfully — audit infrastructure + temporal tables active.';
PRINT 'Next: runs in B2 onwards (Application.Audit abstractions).';
GO

View File

@@ -0,0 +1,118 @@
-- V011_ROLLBACK.sql
-- Reversa de V011__create_medio_seccion.sql.
--
-- ⚠️ ADVERTENCIA: ejecutar ELIMINA Medio, Seccion, su historia temporal,
-- el permiso 'administracion:secciones:gestionar' y sus asignaciones.
-- ('administracion:medios:gestionar' NO se toca — es pre-existente de V005.)
--
-- Uso intended: ROLLBACK en entornos NO-productivos.
-- Prerequisito: no deben existir FKs vivas apuntando a Medio (p.ej., Punto de Venta, Tarifario).
-- Si ADM-008, ADM-009 o PRC-* ya están aplicados, este rollback falla — usar backup.
SET QUOTED_IDENTIFIER ON;
SET ANSI_NULLS ON;
SET NOCOUNT ON;
GO
-- ═══════════════════════════════════════════════════════════════════════
-- 1. Apagar SYSTEM_VERSIONING + remover PERIOD en Seccion y Medio
-- ═══════════════════════════════════════════════════════════════════════
-- Seccion primero (FK al Medio)
IF EXISTS (SELECT 1 FROM sys.tables WHERE object_id = OBJECT_ID('dbo.Seccion') AND temporal_type = 2)
BEGIN
ALTER TABLE dbo.Seccion SET (SYSTEM_VERSIONING = OFF);
PRINT 'Seccion: SYSTEM_VERSIONING OFF.';
END
GO
IF EXISTS (SELECT 1 FROM sys.periods WHERE object_id = OBJECT_ID('dbo.Seccion'))
BEGIN
ALTER TABLE dbo.Seccion DROP PERIOD FOR SYSTEM_TIME;
PRINT 'Seccion: PERIOD FOR SYSTEM_TIME dropped.';
END
GO
IF COL_LENGTH('dbo.Seccion', 'ValidFrom') IS NOT NULL
BEGIN
ALTER TABLE dbo.Seccion DROP CONSTRAINT IF EXISTS DF_Seccion_ValidFrom;
ALTER TABLE dbo.Seccion DROP CONSTRAINT IF EXISTS DF_Seccion_ValidTo;
ALTER TABLE dbo.Seccion DROP COLUMN ValidFrom, ValidTo;
PRINT 'Seccion: ValidFrom/ValidTo dropped.';
END
GO
IF OBJECT_ID(N'dbo.Seccion_History', N'U') IS NOT NULL
BEGIN
DROP TABLE dbo.Seccion_History;
PRINT 'Seccion_History dropped.';
END
GO
-- Medio
IF EXISTS (SELECT 1 FROM sys.tables WHERE object_id = OBJECT_ID('dbo.Medio') AND temporal_type = 2)
BEGIN
ALTER TABLE dbo.Medio SET (SYSTEM_VERSIONING = OFF);
PRINT 'Medio: SYSTEM_VERSIONING OFF.';
END
GO
IF EXISTS (SELECT 1 FROM sys.periods WHERE object_id = OBJECT_ID('dbo.Medio'))
BEGIN
ALTER TABLE dbo.Medio DROP PERIOD FOR SYSTEM_TIME;
PRINT 'Medio: PERIOD FOR SYSTEM_TIME dropped.';
END
GO
IF COL_LENGTH('dbo.Medio', 'ValidFrom') IS NOT NULL
BEGIN
ALTER TABLE dbo.Medio DROP CONSTRAINT IF EXISTS DF_Medio_ValidFrom;
ALTER TABLE dbo.Medio DROP CONSTRAINT IF EXISTS DF_Medio_ValidTo;
ALTER TABLE dbo.Medio DROP COLUMN ValidFrom, ValidTo;
PRINT 'Medio: ValidFrom/ValidTo dropped.';
END
GO
IF OBJECT_ID(N'dbo.Medio_History', N'U') IS NOT NULL
BEGIN
DROP TABLE dbo.Medio_History;
PRINT 'Medio_History dropped.';
END
GO
-- ═══════════════════════════════════════════════════════════════════════
-- 2. Drop Seccion y Medio (Seccion primero por FK)
-- ═══════════════════════════════════════════════════════════════════════
IF OBJECT_ID(N'dbo.Seccion', N'U') IS NOT NULL
BEGIN
DROP TABLE dbo.Seccion;
PRINT 'Table dbo.Seccion dropped.';
END
GO
IF OBJECT_ID(N'dbo.Medio', N'U') IS NOT NULL
BEGIN
DROP TABLE dbo.Medio;
PRINT 'Table dbo.Medio dropped.';
END
GO
-- ═══════════════════════════════════════════════════════════════════════
-- 3. Remover permiso 'administracion:secciones:gestionar' + RolPermiso
-- ═══════════════════════════════════════════════════════════════════════
DELETE rp
FROM dbo.RolPermiso rp
JOIN dbo.Permiso p ON p.Id = rp.PermisoId
WHERE p.Codigo = 'administracion:secciones:gestionar';
GO
DELETE FROM dbo.Permiso
WHERE Codigo = 'administracion:secciones:gestionar';
GO
PRINT '';
PRINT 'V011 rolled back. dbo.Medio, dbo.Seccion and their history removed.';
PRINT 'administracion:medios:gestionar preserved (pre-existing from V005).';
GO

View File

@@ -0,0 +1,206 @@
-- V011__create_medio_seccion.sql
-- ADM-001 (Fase 1 CRITICAL PATH): Medios y Secciones — catálogo fundacional.
--
-- Cambios:
-- 1. dbo.Medio (Codigo UQ global, TipoMedio enum 1..4, PlataformaEmpresaId NULL, SYSTEM_VERSIONING ON).
-- 2. dbo.Seccion (FK MedioId, Codigo UQ por Medio, Tipo CHECK, SYSTEM_VERSIONING ON).
-- 3. Permiso 'administracion:secciones:gestionar' + asignación a rol 'admin'.
-- El permiso 'administracion:medios:gestionar' ya existía desde V005.
--
-- Patrón: V007 (permisos MERGE) + V010 (Temporal Tables con retention 10 años + PAGE compression en history).
-- Idempotente: seguro para re-ejecutar.
-- Reversa: V011_ROLLBACK.sql.
-- Run on: SIGCM2 (dev) y SIGCM2_Test (integration tests).
--
-- Source of truth: Obsidian/02-ARQUITECTURA-y-TECH-STACK/2.10 📋 UDTs Módulo Administración.md (ADM-001)
-- Entidades: Obsidian/03-MODELO-de-DATOS/3.2 Entidades Core/3.2.1 🏢 Medio.md
-- Auditoría: Obsidian/02-ARQUITECTURA-y-TECH-STACK/2.5 📋 Auditoría.md
SET QUOTED_IDENTIFIER ON;
SET ANSI_NULLS ON;
SET NOCOUNT ON;
GO
-- ═══════════════════════════════════════════════════════════════════════
-- 1. dbo.Medio
-- ═══════════════════════════════════════════════════════════════════════
IF OBJECT_ID(N'dbo.Medio', N'U') IS NULL
BEGIN
CREATE TABLE dbo.Medio (
Id INT IDENTITY(1,1) NOT NULL CONSTRAINT PK_Medio PRIMARY KEY,
Codigo VARCHAR(30) NOT NULL,
Nombre NVARCHAR(100) NOT NULL,
Tipo TINYINT NOT NULL, -- TipoMedio: 1=Diario, 2=Radio, 3=Web, 4=Poster
PlataformaEmpresaId INT NULL, -- FK futura a INT-003 (IMAC mapping)
Activo BIT NOT NULL CONSTRAINT DF_Medio_Activo DEFAULT(1),
FechaCreacion DATETIME2(3) NOT NULL CONSTRAINT DF_Medio_FechaCreacion DEFAULT(SYSUTCDATETIME()),
FechaModificacion DATETIME2(3) NULL,
CONSTRAINT UQ_Medio_Codigo UNIQUE (Codigo),
CONSTRAINT CK_Medio_Tipo CHECK (Tipo BETWEEN 1 AND 4)
);
PRINT 'Table dbo.Medio created.';
END
ELSE
PRINT 'Table dbo.Medio already exists — skip.';
GO
IF NOT EXISTS (SELECT 1 FROM sys.indexes WHERE name = 'IX_Medio_Activo_Tipo' AND object_id = OBJECT_ID('dbo.Medio'))
BEGIN
CREATE INDEX IX_Medio_Activo_Tipo
ON dbo.Medio(Activo, Tipo)
INCLUDE (Codigo, Nombre, PlataformaEmpresaId);
PRINT 'Index IX_Medio_Activo_Tipo created.';
END
GO
-- ═══════════════════════════════════════════════════════════════════════
-- 2. dbo.Seccion
-- ═══════════════════════════════════════════════════════════════════════
IF OBJECT_ID(N'dbo.Seccion', N'U') IS NULL
BEGIN
CREATE TABLE dbo.Seccion (
Id INT IDENTITY(1,1) NOT NULL CONSTRAINT PK_Seccion PRIMARY KEY,
MedioId INT NOT NULL,
Codigo VARCHAR(30) NOT NULL,
Nombre NVARCHAR(100) NOT NULL,
Tipo VARCHAR(20) NOT NULL, -- 'clasificados' | 'notables' | 'suplementos'
Activo BIT NOT NULL CONSTRAINT DF_Seccion_Activo DEFAULT(1),
FechaCreacion DATETIME2(3) NOT NULL CONSTRAINT DF_Seccion_FechaCreacion DEFAULT(SYSUTCDATETIME()),
FechaModificacion DATETIME2(3) NULL,
CONSTRAINT FK_Seccion_Medio FOREIGN KEY (MedioId) REFERENCES dbo.Medio(Id) ON DELETE NO ACTION,
CONSTRAINT UQ_Seccion_MedioId_Codigo UNIQUE (MedioId, Codigo),
CONSTRAINT CK_Seccion_Tipo CHECK (Tipo IN ('clasificados','notables','suplementos'))
);
PRINT 'Table dbo.Seccion created.';
END
ELSE
PRINT 'Table dbo.Seccion already exists — skip.';
GO
IF NOT EXISTS (SELECT 1 FROM sys.indexes WHERE name = 'IX_Seccion_MedioId_Activo' AND object_id = OBJECT_ID('dbo.Seccion'))
BEGIN
CREATE INDEX IX_Seccion_MedioId_Activo
ON dbo.Seccion(MedioId, Activo)
INCLUDE (Codigo, Nombre, Tipo);
PRINT 'Index IX_Seccion_MedioId_Activo created.';
END
GO
-- ═══════════════════════════════════════════════════════════════════════
-- 3. SYSTEM_VERSIONING — Medio
-- ═══════════════════════════════════════════════════════════════════════
IF COL_LENGTH('dbo.Medio', 'ValidFrom') IS NULL
BEGIN
ALTER TABLE dbo.Medio
ADD
ValidFrom DATETIME2(3) GENERATED ALWAYS AS ROW START HIDDEN NOT NULL
CONSTRAINT DF_Medio_ValidFrom DEFAULT(SYSUTCDATETIME()),
ValidTo DATETIME2(3) GENERATED ALWAYS AS ROW END HIDDEN NOT NULL
CONSTRAINT DF_Medio_ValidTo DEFAULT(CONVERT(DATETIME2(3), '9999-12-31 23:59:59.999')),
PERIOD FOR SYSTEM_TIME (ValidFrom, ValidTo);
PRINT 'Medio: PERIOD FOR SYSTEM_TIME added.';
END
GO
IF NOT EXISTS (SELECT 1 FROM sys.tables WHERE object_id = OBJECT_ID('dbo.Medio') AND temporal_type = 2)
BEGIN
ALTER TABLE dbo.Medio
SET (SYSTEM_VERSIONING = ON (
HISTORY_TABLE = dbo.Medio_History,
HISTORY_RETENTION_PERIOD = 10 YEARS
));
PRINT 'Medio: SYSTEM_VERSIONING = ON (history: dbo.Medio_History, retention: 10 years).';
END
ELSE
PRINT 'Medio: SYSTEM_VERSIONING already ON — skip.';
GO
IF EXISTS (SELECT 1 FROM sys.tables WHERE name = 'Medio_History' AND schema_id = SCHEMA_ID('dbo'))
AND NOT EXISTS (
SELECT 1 FROM sys.partitions p
JOIN sys.tables t ON t.object_id = p.object_id
WHERE t.name = 'Medio_History' AND p.data_compression = 2
)
BEGIN
ALTER TABLE dbo.Medio_History REBUILD WITH (DATA_COMPRESSION = PAGE);
PRINT 'Medio_History: rebuilt with PAGE compression.';
END
GO
-- ═══════════════════════════════════════════════════════════════════════
-- 4. SYSTEM_VERSIONING — Seccion
-- ═══════════════════════════════════════════════════════════════════════
IF COL_LENGTH('dbo.Seccion', 'ValidFrom') IS NULL
BEGIN
ALTER TABLE dbo.Seccion
ADD
ValidFrom DATETIME2(3) GENERATED ALWAYS AS ROW START HIDDEN NOT NULL
CONSTRAINT DF_Seccion_ValidFrom DEFAULT(SYSUTCDATETIME()),
ValidTo DATETIME2(3) GENERATED ALWAYS AS ROW END HIDDEN NOT NULL
CONSTRAINT DF_Seccion_ValidTo DEFAULT(CONVERT(DATETIME2(3), '9999-12-31 23:59:59.999')),
PERIOD FOR SYSTEM_TIME (ValidFrom, ValidTo);
PRINT 'Seccion: PERIOD FOR SYSTEM_TIME added.';
END
GO
IF NOT EXISTS (SELECT 1 FROM sys.tables WHERE object_id = OBJECT_ID('dbo.Seccion') AND temporal_type = 2)
BEGIN
ALTER TABLE dbo.Seccion
SET (SYSTEM_VERSIONING = ON (
HISTORY_TABLE = dbo.Seccion_History,
HISTORY_RETENTION_PERIOD = 10 YEARS
));
PRINT 'Seccion: SYSTEM_VERSIONING = ON.';
END
ELSE
PRINT 'Seccion: SYSTEM_VERSIONING already ON — skip.';
GO
IF EXISTS (SELECT 1 FROM sys.tables WHERE name = 'Seccion_History' AND schema_id = SCHEMA_ID('dbo'))
AND NOT EXISTS (
SELECT 1 FROM sys.partitions p
JOIN sys.tables t ON t.object_id = p.object_id
WHERE t.name = 'Seccion_History' AND p.data_compression = 2
)
BEGIN
ALTER TABLE dbo.Seccion_History REBUILD WITH (DATA_COMPRESSION = PAGE);
PRINT 'Seccion_History: rebuilt with PAGE compression.';
END
GO
-- ═══════════════════════════════════════════════════════════════════════
-- 5. Permiso nuevo: administracion:secciones:gestionar
-- ('administracion:medios:gestionar' ya fue sembrado en V005 — no se toca).
-- ═══════════════════════════════════════════════════════════════════════
MERGE dbo.Permiso AS t
USING (VALUES
('administracion:secciones:gestionar', N'Gestionar secciones por medio', N'Crear, editar y desactivar secciones de un medio', 'administracion')
) AS s (Codigo, Nombre, Descripcion, Modulo)
ON t.Codigo = s.Codigo
WHEN NOT MATCHED BY TARGET THEN
INSERT (Codigo, Nombre, Descripcion, Modulo)
VALUES (s.Codigo, s.Nombre, s.Descripcion, s.Modulo);
GO
MERGE dbo.RolPermiso AS t
USING (
SELECT r.Id AS RolId, p.Id AS PermisoId
FROM (VALUES
('admin', 'administracion:secciones:gestionar')
) AS x (RolCodigo, PermisoCodigo)
JOIN dbo.Rol r ON r.Codigo = x.RolCodigo
JOIN dbo.Permiso p ON p.Codigo = x.PermisoCodigo
) AS s ON t.RolId = s.RolId AND t.PermisoId = s.PermisoId
WHEN NOT MATCHED BY TARGET THEN
INSERT (RolId, PermisoId) VALUES (s.RolId, s.PermisoId);
GO
PRINT '';
PRINT 'V011 applied successfully — dbo.Medio + dbo.Seccion (temporal, retention 10y) + permiso secciones.';
PRINT 'Next: V012__seed_medios.sql (seed ELDIA, ELPLATA).';
GO

View File

@@ -0,0 +1,30 @@
-- V012_ROLLBACK.sql
-- Reversa de V012__seed_medios.sql.
--
-- Elimina los seed rows ELDIA y ELPLATA solo si NO tienen Secciones asociadas.
-- Si alguna sección depende de un seed Medio, el DELETE falla por FK ON DELETE NO ACTION.
SET QUOTED_IDENTIFIER ON;
SET ANSI_NULLS ON;
SET NOCOUNT ON;
GO
-- Falla temprano si hay secciones vivas apuntando a estos Medios.
IF EXISTS (
SELECT 1
FROM dbo.Seccion s
JOIN dbo.Medio m ON m.Id = s.MedioId
WHERE m.Codigo IN ('ELDIA', 'ELPLATA')
)
BEGIN
RAISERROR('Cannot rollback V012: existen Secciones vinculadas a ELDIA/ELPLATA. Rollback ADM-001 completo con V011_ROLLBACK.sql.', 16, 1);
RETURN;
END
GO
DELETE FROM dbo.Medio
WHERE Codigo IN ('ELDIA', 'ELPLATA');
GO
PRINT 'V012 rolled back — seed Medios ELDIA y ELPLATA removed.';
GO

View File

@@ -0,0 +1,27 @@
-- V012__seed_medios.sql
-- ADM-001: seed inicial de Medios ELDIA y ELPLATA.
--
-- Idempotente via MERGE por Codigo.
-- Tipo = 1 (Diario) per enum TipoMedio.
-- PlataformaEmpresaId = NULL (INT-003 lo poblará cuando exista el mapeo IMAC).
--
-- Run on: SIGCM2 (dev) y SIGCM2_Test (integration tests).
SET QUOTED_IDENTIFIER ON;
SET ANSI_NULLS ON;
SET NOCOUNT ON;
GO
MERGE dbo.Medio AS t
USING (VALUES
('ELDIA', N'El Día', 1),
('ELPLATA', N'El Plata', 1)
) AS s (Codigo, Nombre, Tipo)
ON t.Codigo = s.Codigo
WHEN NOT MATCHED BY TARGET THEN
INSERT (Codigo, Nombre, Tipo, PlataformaEmpresaId, Activo)
VALUES (s.Codigo, s.Nombre, s.Tipo, NULL, 1);
GO
PRINT 'V012 applied — Medios ELDIA y ELPLATA seeded (idempotent).';
GO

View File

@@ -0,0 +1,81 @@
-- V013_ROLLBACK.sql
-- Reversa de V013__create_puntos_de_venta.sql.
--
-- ADVERTENCIA: ejecutar ELIMINA PuntoDeVenta, su historia temporal,
-- el permiso 'administracion:puntos_de_venta:gestionar' y sus asignaciones.
--
-- Uso intended: ROLLBACK en entornos NO-productivos.
-- Prerequisito: no deben existir FKs vivas apuntando a PuntoDeVenta (p.ej., comprobantes FAC-001).
-- Si FAC-001 ya está aplicado, este rollback fallará — usar backup.
--
-- NOTA: SecuenciaComprobante y SP usp_ReservarNumeroComprobante ya no forman parte
-- de V013 (eliminados en cirugía post-smoke Batch 9). Este rollback solo maneja PuntoDeVenta.
SET QUOTED_IDENTIFIER ON;
SET ANSI_NULLS ON;
SET NOCOUNT ON;
GO
-- ═══════════════════════════════════════════════════════════════════════
-- 1. Apagar SYSTEM_VERSIONING + remover PERIOD — PuntoDeVenta
-- ═══════════════════════════════════════════════════════════════════════
IF EXISTS (SELECT 1 FROM sys.tables WHERE object_id = OBJECT_ID('dbo.PuntoDeVenta') AND temporal_type = 2)
BEGIN
ALTER TABLE dbo.PuntoDeVenta SET (SYSTEM_VERSIONING = OFF);
PRINT 'PuntoDeVenta: SYSTEM_VERSIONING OFF.';
END
GO
IF EXISTS (SELECT 1 FROM sys.periods WHERE object_id = OBJECT_ID('dbo.PuntoDeVenta'))
BEGIN
ALTER TABLE dbo.PuntoDeVenta DROP PERIOD FOR SYSTEM_TIME;
PRINT 'PuntoDeVenta: PERIOD FOR SYSTEM_TIME dropped.';
END
GO
IF COL_LENGTH('dbo.PuntoDeVenta', 'ValidFrom') IS NOT NULL
BEGIN
ALTER TABLE dbo.PuntoDeVenta DROP CONSTRAINT IF EXISTS DF_PuntoDeVenta_ValidFrom;
ALTER TABLE dbo.PuntoDeVenta DROP CONSTRAINT IF EXISTS DF_PuntoDeVenta_ValidTo;
ALTER TABLE dbo.PuntoDeVenta DROP COLUMN ValidFrom, ValidTo;
PRINT 'PuntoDeVenta: ValidFrom/ValidTo dropped.';
END
GO
IF OBJECT_ID(N'dbo.PuntoDeVenta_History', N'U') IS NOT NULL
BEGIN
DROP TABLE dbo.PuntoDeVenta_History;
PRINT 'PuntoDeVenta_History dropped.';
END
GO
-- ═══════════════════════════════════════════════════════════════════════
-- 2. Drop tabla PuntoDeVenta
-- ═══════════════════════════════════════════════════════════════════════
IF OBJECT_ID(N'dbo.PuntoDeVenta', N'U') IS NOT NULL
BEGIN
DROP TABLE dbo.PuntoDeVenta;
PRINT 'Table dbo.PuntoDeVenta dropped.';
END
GO
-- ═══════════════════════════════════════════════════════════════════════
-- 3. Remover permiso 'administracion:puntos_de_venta:gestionar' + RolPermiso
-- ═══════════════════════════════════════════════════════════════════════
DELETE rp
FROM dbo.RolPermiso rp
JOIN dbo.Permiso p ON p.Id = rp.PermisoId
WHERE p.Codigo = 'administracion:puntos_de_venta:gestionar';
GO
DELETE FROM dbo.Permiso
WHERE Codigo = 'administracion:puntos_de_venta:gestionar';
GO
PRINT '';
PRINT 'V013 rolled back. dbo.PuntoDeVenta and its history removed.';
PRINT 'Permiso administracion:puntos_de_venta:gestionar removed.';
GO

View File

@@ -0,0 +1,179 @@
-- V013__create_puntos_de_venta.sql
-- ADM-008 Puntos de Venta: DDL para dbo.PuntoDeVenta + permiso AFIP.
--
-- NOTA POST-SMOKE (Batch 9): SecuenciaComprobante, SP usp_ReservarNumeroComprobante
-- y TipoComprobante fueron eliminados. SIG-CM2.0 NO genera números AFIP — IMAC
-- (Plataforma Infogestión) los asigna externamente. Un worker futuro (INT-001)
-- polleará la vista de Infogestión para asociar NumeroOrdenInterno ↔ NumeroFacturaAFIP + CAI.
-- PuntoDeVenta.NumeroAFIP es config fija que se manda en el payload a IMAC.
--
-- Cambios:
-- 1. dbo.PuntoDeVenta (FK→Medio, UNIQUE(MedioId,NumeroAFIP), SYSTEM_VERSIONING ON, retention 10Y).
-- 2. Drops idempotentes de artefactos de versión previa (SecuenciaComprobante + SP).
-- 3. Permiso 'administracion:puntos_de_venta:gestionar' + asignación a rol 'admin'.
--
-- Patrón: V011 (Temporal Tables + Permiso MERGE + PAGE compression en history).
-- Idempotente: seguro para re-ejecutar.
-- Reversa: V013_ROLLBACK.sql.
-- Run on: SIGCM2 (dev) y SIGCM2_Test (integration tests).
--
-- NOTA: el código de permiso usa guion_bajo (_) según CK_Permiso_Codigo_Format del proyecto.
-- Código efectivo: 'administracion:puntos_de_venta:gestionar'
-- El spec menciona 'administracion:puntos-de-venta:gestionar' (guion) pero el CHECK constraint
-- solo permite [a-z0-9_:] — se usa guion_bajo para cumplir la constraint existente.
-- El backend y frontend deben usar el código con guion_bajo.
--
-- Covers: REQ-PDV-001, -003, -009
-- Source of truth: Obsidian/02-ARQUITECTURA-y-TECH-STACK/2.10 ADM-008
--
-- NOTA T1.3 — Seeds: NO se seedean PuntoDeVenta.
-- Cada instalación configura sus propios PdVs con el NumeroAFIP real asignado por AFIP/ARCA.
-- Seedear con valores ficticios generaría confusión operativa. El admin los crea manualmente.
SET QUOTED_IDENTIFIER ON;
SET ANSI_NULLS ON;
SET NOCOUNT ON;
GO
-- ═══════════════════════════════════════════════════════════════════════
-- 0. Drops idempotentes de artefactos de versión previa
-- (SecuenciaComprobante + SP — eliminados en cirugía post-smoke Batch 9)
-- ═══════════════════════════════════════════════════════════════════════
IF OBJECT_ID(N'dbo.usp_ReservarNumeroComprobante', N'P') IS NOT NULL
BEGIN
DROP PROCEDURE dbo.usp_ReservarNumeroComprobante;
PRINT 'SP dbo.usp_ReservarNumeroComprobante dropped (cleanup).';
END
GO
IF EXISTS (SELECT 1 FROM sys.tables WHERE object_id = OBJECT_ID('dbo.SecuenciaComprobante') AND temporal_type = 2)
BEGIN
ALTER TABLE dbo.SecuenciaComprobante SET (SYSTEM_VERSIONING = OFF);
PRINT 'SecuenciaComprobante: SYSTEM_VERSIONING = OFF (cleanup).';
END
GO
IF OBJECT_ID(N'dbo.SecuenciaComprobante_History', N'U') IS NOT NULL
BEGIN
DROP TABLE dbo.SecuenciaComprobante_History;
PRINT 'SecuenciaComprobante_History dropped (cleanup).';
END
GO
IF OBJECT_ID(N'dbo.SecuenciaComprobante', N'U') IS NOT NULL
BEGIN
DROP TABLE dbo.SecuenciaComprobante;
PRINT 'Table dbo.SecuenciaComprobante dropped (cleanup).';
END
GO
-- ═══════════════════════════════════════════════════════════════════════
-- 1. dbo.PuntoDeVenta
-- ═══════════════════════════════════════════════════════════════════════
IF OBJECT_ID(N'dbo.PuntoDeVenta', N'U') IS NULL
BEGIN
CREATE TABLE dbo.PuntoDeVenta (
Id INT IDENTITY(1,1) NOT NULL CONSTRAINT PK_PuntoDeVenta PRIMARY KEY,
MedioId INT NOT NULL,
NumeroAFIP SMALLINT NOT NULL,
Nombre NVARCHAR(100) NOT NULL,
Descripcion NVARCHAR(255) NULL,
Activo BIT NOT NULL CONSTRAINT DF_PuntoDeVenta_Activo DEFAULT(1),
FechaCreacion DATETIME2(3) NOT NULL CONSTRAINT DF_PuntoDeVenta_FechaCreacion DEFAULT(SYSUTCDATETIME()),
FechaModificacion DATETIME2(3) NULL,
CONSTRAINT FK_PuntoDeVenta_Medio FOREIGN KEY (MedioId) REFERENCES dbo.Medio(Id) ON DELETE NO ACTION,
CONSTRAINT UQ_PuntoDeVenta_Medio_AFIP UNIQUE (MedioId, NumeroAFIP),
CONSTRAINT CK_PuntoDeVenta_NumeroAFIP CHECK (NumeroAFIP >= 1)
);
PRINT 'Table dbo.PuntoDeVenta created.';
END
ELSE
PRINT 'Table dbo.PuntoDeVenta already exists — skip.';
GO
IF NOT EXISTS (SELECT 1 FROM sys.indexes WHERE name = 'IX_PuntoDeVenta_MedioId_Activo' AND object_id = OBJECT_ID('dbo.PuntoDeVenta'))
BEGIN
CREATE INDEX IX_PuntoDeVenta_MedioId_Activo
ON dbo.PuntoDeVenta(MedioId, Activo)
INCLUDE (NumeroAFIP, Nombre);
PRINT 'Index IX_PuntoDeVenta_MedioId_Activo created.';
END
GO
-- ═══════════════════════════════════════════════════════════════════════
-- 2. SYSTEM_VERSIONING — PuntoDeVenta
-- ═══════════════════════════════════════════════════════════════════════
IF COL_LENGTH('dbo.PuntoDeVenta', 'ValidFrom') IS NULL
BEGIN
ALTER TABLE dbo.PuntoDeVenta
ADD
ValidFrom DATETIME2(3) GENERATED ALWAYS AS ROW START HIDDEN NOT NULL
CONSTRAINT DF_PuntoDeVenta_ValidFrom DEFAULT(SYSUTCDATETIME()),
ValidTo DATETIME2(3) GENERATED ALWAYS AS ROW END HIDDEN NOT NULL
CONSTRAINT DF_PuntoDeVenta_ValidTo DEFAULT(CONVERT(DATETIME2(3), '9999-12-31 23:59:59.999')),
PERIOD FOR SYSTEM_TIME (ValidFrom, ValidTo);
PRINT 'PuntoDeVenta: PERIOD FOR SYSTEM_TIME added.';
END
GO
IF NOT EXISTS (SELECT 1 FROM sys.tables WHERE object_id = OBJECT_ID('dbo.PuntoDeVenta') AND temporal_type = 2)
BEGIN
ALTER TABLE dbo.PuntoDeVenta
SET (SYSTEM_VERSIONING = ON (
HISTORY_TABLE = dbo.PuntoDeVenta_History,
HISTORY_RETENTION_PERIOD = 10 YEARS
));
PRINT 'PuntoDeVenta: SYSTEM_VERSIONING = ON (history: dbo.PuntoDeVenta_History, retention: 10 years).';
END
ELSE
PRINT 'PuntoDeVenta: SYSTEM_VERSIONING already ON — skip.';
GO
IF EXISTS (SELECT 1 FROM sys.tables WHERE name = 'PuntoDeVenta_History' AND schema_id = SCHEMA_ID('dbo'))
AND NOT EXISTS (
SELECT 1 FROM sys.partitions p
JOIN sys.tables t ON t.object_id = p.object_id
WHERE t.name = 'PuntoDeVenta_History' AND p.data_compression = 2
)
BEGIN
ALTER TABLE dbo.PuntoDeVenta_History REBUILD WITH (DATA_COMPRESSION = PAGE);
PRINT 'PuntoDeVenta_History: rebuilt with PAGE compression.';
END
GO
-- ═══════════════════════════════════════════════════════════════════════
-- 3. Permiso: administracion:puntos_de_venta:gestionar
-- ═══════════════════════════════════════════════════════════════════════
MERGE dbo.Permiso AS t
USING (VALUES
('administracion:puntos_de_venta:gestionar', N'Gestionar puntos de venta', N'Crear, editar y desactivar puntos de venta AFIP', 'administracion')
) AS s (Codigo, Nombre, Descripcion, Modulo)
ON t.Codigo = s.Codigo
WHEN NOT MATCHED BY TARGET THEN
INSERT (Codigo, Nombre, Descripcion, Modulo)
VALUES (s.Codigo, s.Nombre, s.Descripcion, s.Modulo);
GO
MERGE dbo.RolPermiso AS t
USING (
SELECT r.Id AS RolId, p.Id AS PermisoId
FROM (VALUES
('admin', 'administracion:puntos_de_venta:gestionar')
) AS x (RolCodigo, PermisoCodigo)
JOIN dbo.Rol r ON r.Codigo = x.RolCodigo
JOIN dbo.Permiso p ON p.Codigo = x.PermisoCodigo
) AS s ON t.RolId = s.RolId AND t.PermisoId = s.PermisoId
WHEN NOT MATCHED BY TARGET THEN
INSERT (RolId, PermisoId) VALUES (s.RolId, s.PermisoId);
GO
PRINT '';
PRINT 'V013 applied successfully.';
PRINT ' - dbo.PuntoDeVenta (temporal, retention 10y, PAGE compression)';
PRINT ' - Permiso administracion:puntos_de_venta:gestionar (asignado a admin)';
PRINT ' - Artefactos de version previa (SecuenciaComprobante + SP) eliminados si existian';
GO

View File

@@ -0,0 +1,141 @@
-- V014_ROLLBACK.sql
-- Reversa de V014__create_tablas_fiscales.sql.
--
-- ADVERTENCIA: ejecutar ELIMINA TipoDeIva, IngresosBrutos, sus historiales temporales,
-- el permiso 'administracion:fiscal:gestionar' y sus asignaciones.
--
-- Uso intended: ROLLBACK en entornos NO-productivos.
-- Prerequisito: no deben existir FKs vivas apuntando a estas tablas (FAC-001, etc.).
-- Si FAC-001 ya esta aplicado, este rollback fallara — usar backup.
--
-- Idempotente: seguro para re-ejecutar (guards en cada paso).
SET QUOTED_IDENTIFIER ON;
SET ANSI_NULLS ON;
SET NOCOUNT ON;
GO
-- ═══════════════════════════════════════════════════════════════════════
-- 1. Apagar SYSTEM_VERSIONING — TipoDeIva
-- ═══════════════════════════════════════════════════════════════════════
IF EXISTS (SELECT 1 FROM sys.tables WHERE object_id = OBJECT_ID('dbo.TipoDeIva') AND temporal_type = 2)
BEGIN
ALTER TABLE dbo.TipoDeIva SET (SYSTEM_VERSIONING = OFF);
PRINT 'TipoDeIva: SYSTEM_VERSIONING OFF.';
END
GO
IF EXISTS (SELECT 1 FROM sys.periods WHERE object_id = OBJECT_ID('dbo.TipoDeIva'))
BEGIN
ALTER TABLE dbo.TipoDeIva DROP PERIOD FOR SYSTEM_TIME;
PRINT 'TipoDeIva: PERIOD FOR SYSTEM_TIME dropped.';
END
GO
IF COL_LENGTH('dbo.TipoDeIva', 'ValidFrom') IS NOT NULL
BEGIN
ALTER TABLE dbo.TipoDeIva DROP CONSTRAINT IF EXISTS DF_TipoDeIva_ValidFrom;
ALTER TABLE dbo.TipoDeIva DROP CONSTRAINT IF EXISTS DF_TipoDeIva_ValidTo;
ALTER TABLE dbo.TipoDeIva DROP COLUMN ValidFrom, ValidTo;
PRINT 'TipoDeIva: ValidFrom/ValidTo dropped.';
END
GO
-- ═══════════════════════════════════════════════════════════════════════
-- 2. Apagar SYSTEM_VERSIONING — IngresosBrutos
-- ═══════════════════════════════════════════════════════════════════════
IF EXISTS (SELECT 1 FROM sys.tables WHERE object_id = OBJECT_ID('dbo.IngresosBrutos') AND temporal_type = 2)
BEGIN
ALTER TABLE dbo.IngresosBrutos SET (SYSTEM_VERSIONING = OFF);
PRINT 'IngresosBrutos: SYSTEM_VERSIONING OFF.';
END
GO
IF EXISTS (SELECT 1 FROM sys.periods WHERE object_id = OBJECT_ID('dbo.IngresosBrutos'))
BEGIN
ALTER TABLE dbo.IngresosBrutos DROP PERIOD FOR SYSTEM_TIME;
PRINT 'IngresosBrutos: PERIOD FOR SYSTEM_TIME dropped.';
END
GO
IF COL_LENGTH('dbo.IngresosBrutos', 'ValidFrom') IS NOT NULL
BEGIN
ALTER TABLE dbo.IngresosBrutos DROP CONSTRAINT IF EXISTS DF_IIBB_ValidFrom;
ALTER TABLE dbo.IngresosBrutos DROP CONSTRAINT IF EXISTS DF_IIBB_ValidTo;
ALTER TABLE dbo.IngresosBrutos DROP COLUMN ValidFrom, ValidTo;
PRINT 'IngresosBrutos: ValidFrom/ValidTo dropped.';
END
GO
-- ═══════════════════════════════════════════════════════════════════════
-- 3. Drop FK self antes de DROP TABLE (para evitar constraint violation)
-- ═══════════════════════════════════════════════════════════════════════
IF OBJECT_ID('FK_TipoDeIva_Predecesor', 'F') IS NOT NULL
BEGIN
ALTER TABLE dbo.TipoDeIva DROP CONSTRAINT FK_TipoDeIva_Predecesor;
PRINT 'FK_TipoDeIva_Predecesor dropped.';
END
GO
IF OBJECT_ID('FK_IIBB_Predecesor', 'F') IS NOT NULL
BEGIN
ALTER TABLE dbo.IngresosBrutos DROP CONSTRAINT FK_IIBB_Predecesor;
PRINT 'FK_IIBB_Predecesor dropped.';
END
GO
-- ═══════════════════════════════════════════════════════════════════════
-- 4. Drop history tables → main tables
-- ═══════════════════════════════════════════════════════════════════════
IF OBJECT_ID(N'dbo.TipoDeIva_History', N'U') IS NOT NULL
BEGIN
DROP TABLE dbo.TipoDeIva_History;
PRINT 'TipoDeIva_History dropped.';
END
GO
IF OBJECT_ID(N'dbo.IngresosBrutos_History', N'U') IS NOT NULL
BEGIN
DROP TABLE dbo.IngresosBrutos_History;
PRINT 'IngresosBrutos_History dropped.';
END
GO
IF OBJECT_ID(N'dbo.TipoDeIva', N'U') IS NOT NULL
BEGIN
DROP TABLE dbo.TipoDeIva;
PRINT 'Table dbo.TipoDeIva dropped.';
END
GO
IF OBJECT_ID(N'dbo.IngresosBrutos', N'U') IS NOT NULL
BEGIN
DROP TABLE dbo.IngresosBrutos;
PRINT 'Table dbo.IngresosBrutos dropped.';
END
GO
-- ═══════════════════════════════════════════════════════════════════════
-- 5. Remover permiso 'administracion:fiscal:gestionar' + RolPermiso
-- ═══════════════════════════════════════════════════════════════════════
DELETE rp
FROM dbo.RolPermiso rp
JOIN dbo.Permiso p ON p.Id = rp.PermisoId
WHERE p.Codigo = 'administracion:fiscal:gestionar';
GO
DELETE FROM dbo.Permiso
WHERE Codigo = 'administracion:fiscal:gestionar';
GO
PRINT '';
PRINT 'V014 rolled back.';
PRINT ' - dbo.TipoDeIva and dbo.TipoDeIva_History removed.';
PRINT ' - dbo.IngresosBrutos and dbo.IngresosBrutos_History removed.';
PRINT ' - Permiso administracion:fiscal:gestionar removed.';
GO

View File

@@ -0,0 +1,293 @@
-- V014__create_tablas_fiscales.sql
-- ADM-009 Tablas Fiscales: DDL para dbo.TipoDeIva + dbo.IngresosBrutos + permisos.
--
-- Patron: append-only versioned ref data.
-- Porcentaje/Alicuota son INMUTABLES post-creacion; cambiar el valor = nueva fila + cierre de predecesora.
-- PredecesorId (FK self) establece la cadena de versiones (historial de negocio).
-- SYSTEM_VERSIONING ON para historial tecnico (auditoria temporal de SQL Server).
--
-- Idempotente: seguro para re-ejecutar.
-- Reversa: V014_ROLLBACK.sql.
-- Run on: SIGCM2 (dev) y SIGCM2_Test (integration tests).
--
-- Covers: REQ-SEED-001, REQ-SEED-002, REQ-SEED-003, REQ-TEMPORAL-001, REQ-FISCAL-AUTH-002
SET QUOTED_IDENTIFIER ON;
SET ANSI_NULLS ON;
SET NOCOUNT ON;
GO
-- ═══════════════════════════════════════════════════════════════════════
-- 1. dbo.TipoDeIva
-- ═══════════════════════════════════════════════════════════════════════
IF OBJECT_ID(N'dbo.TipoDeIva', N'U') IS NULL
BEGIN
CREATE TABLE dbo.TipoDeIva (
Id INT IDENTITY(1,1) NOT NULL CONSTRAINT PK_TipoDeIva PRIMARY KEY,
Codigo VARCHAR(32) NOT NULL,
Descripcion NVARCHAR(100) NOT NULL,
Porcentaje DECIMAL(5,2) NOT NULL,
AplicaIVA BIT NOT NULL,
Activo BIT NOT NULL CONSTRAINT DF_TipoDeIva_Activo DEFAULT(1),
VigenciaDesde DATE NOT NULL,
VigenciaHasta DATE NULL,
PredecesorId INT NULL,
FechaCreacion DATETIME2(3) NOT NULL CONSTRAINT DF_TipoDeIva_FechaCreacion DEFAULT(SYSUTCDATETIME()),
FechaModificacion DATETIME2(3) NULL,
CONSTRAINT CK_TipoDeIva_Porcentaje CHECK (Porcentaje >= 0 AND Porcentaje <= 100),
CONSTRAINT CK_TipoDeIva_Vigencia CHECK (VigenciaHasta IS NULL OR VigenciaHasta >= VigenciaDesde),
CONSTRAINT UQ_TipoDeIva_Codigo_Vigencia UNIQUE (Codigo, VigenciaDesde),
CONSTRAINT FK_TipoDeIva_Predecesor FOREIGN KEY (PredecesorId) REFERENCES dbo.TipoDeIva(Id)
);
PRINT 'Table dbo.TipoDeIva created.';
END
ELSE
PRINT 'Table dbo.TipoDeIva already exists — skip.';
GO
-- Indices TipoDeIva
IF NOT EXISTS (SELECT 1 FROM sys.indexes WHERE name = 'IX_TipoDeIva_Codigo_VigenciaDesde' AND object_id = OBJECT_ID('dbo.TipoDeIva'))
BEGIN
CREATE INDEX IX_TipoDeIva_Codigo_VigenciaDesde
ON dbo.TipoDeIva(Codigo, VigenciaDesde DESC);
PRINT 'Index IX_TipoDeIva_Codigo_VigenciaDesde created.';
END
GO
IF NOT EXISTS (SELECT 1 FROM sys.indexes WHERE name = 'IX_TipoDeIva_PredecesorId' AND object_id = OBJECT_ID('dbo.TipoDeIva'))
BEGIN
CREATE INDEX IX_TipoDeIva_PredecesorId
ON dbo.TipoDeIva(PredecesorId)
WHERE PredecesorId IS NOT NULL;
PRINT 'Index IX_TipoDeIva_PredecesorId created.';
END
GO
-- SYSTEM_VERSIONING — TipoDeIva
IF COL_LENGTH('dbo.TipoDeIva', 'ValidFrom') IS NULL
BEGIN
ALTER TABLE dbo.TipoDeIva
ADD
ValidFrom DATETIME2(3) GENERATED ALWAYS AS ROW START HIDDEN NOT NULL
CONSTRAINT DF_TipoDeIva_ValidFrom DEFAULT(SYSUTCDATETIME()),
ValidTo DATETIME2(3) GENERATED ALWAYS AS ROW END HIDDEN NOT NULL
CONSTRAINT DF_TipoDeIva_ValidTo DEFAULT(CONVERT(DATETIME2(3), '9999-12-31 23:59:59.999')),
PERIOD FOR SYSTEM_TIME (ValidFrom, ValidTo);
PRINT 'TipoDeIva: PERIOD FOR SYSTEM_TIME added.';
END
GO
IF NOT EXISTS (SELECT 1 FROM sys.tables WHERE object_id = OBJECT_ID('dbo.TipoDeIva') AND temporal_type = 2)
BEGIN
ALTER TABLE dbo.TipoDeIva
SET (SYSTEM_VERSIONING = ON (
HISTORY_TABLE = dbo.TipoDeIva_History,
HISTORY_RETENTION_PERIOD = 10 YEARS
));
PRINT 'TipoDeIva: SYSTEM_VERSIONING = ON (history: dbo.TipoDeIva_History, retention: 10 years).';
END
ELSE
PRINT 'TipoDeIva: SYSTEM_VERSIONING already ON — skip.';
GO
IF EXISTS (SELECT 1 FROM sys.tables WHERE name = 'TipoDeIva_History' AND schema_id = SCHEMA_ID('dbo'))
AND NOT EXISTS (
SELECT 1 FROM sys.partitions p
JOIN sys.tables t ON t.object_id = p.object_id
WHERE t.name = 'TipoDeIva_History' AND p.data_compression = 2
)
BEGIN
ALTER TABLE dbo.TipoDeIva_History REBUILD WITH (DATA_COMPRESSION = PAGE);
PRINT 'TipoDeIva_History: rebuilt with PAGE compression.';
END
GO
-- ═══════════════════════════════════════════════════════════════════════
-- 2. dbo.IngresosBrutos
-- ═══════════════════════════════════════════════════════════════════════
IF OBJECT_ID(N'dbo.IngresosBrutos', N'U') IS NULL
BEGIN
CREATE TABLE dbo.IngresosBrutos (
Id INT IDENTITY(1,1) NOT NULL CONSTRAINT PK_IngresosBrutos PRIMARY KEY,
Provincia VARCHAR(50) NOT NULL,
Descripcion NVARCHAR(100) NOT NULL,
Alicuota DECIMAL(5,2) NOT NULL,
Activo BIT NOT NULL CONSTRAINT DF_IIBB_Activo DEFAULT(1),
VigenciaDesde DATE NOT NULL,
VigenciaHasta DATE NULL,
PredecesorId INT NULL,
FechaCreacion DATETIME2(3) NOT NULL CONSTRAINT DF_IIBB_FechaCreacion DEFAULT(SYSUTCDATETIME()),
FechaModificacion DATETIME2(3) NULL,
CONSTRAINT CK_IIBB_Alicuota CHECK (Alicuota >= 0 AND Alicuota <= 100),
CONSTRAINT CK_IIBB_Vigencia CHECK (VigenciaHasta IS NULL OR VigenciaHasta >= VigenciaDesde),
CONSTRAINT UQ_IIBB_Provincia_Vigencia UNIQUE (Provincia, VigenciaDesde),
CONSTRAINT FK_IIBB_Predecesor FOREIGN KEY (PredecesorId) REFERENCES dbo.IngresosBrutos(Id)
);
PRINT 'Table dbo.IngresosBrutos created.';
END
ELSE
PRINT 'Table dbo.IngresosBrutos already exists — skip.';
GO
-- Indices IngresosBrutos
IF NOT EXISTS (SELECT 1 FROM sys.indexes WHERE name = 'IX_IIBB_Provincia_VigenciaDesde' AND object_id = OBJECT_ID('dbo.IngresosBrutos'))
BEGIN
CREATE INDEX IX_IIBB_Provincia_VigenciaDesde
ON dbo.IngresosBrutos(Provincia, VigenciaDesde DESC);
PRINT 'Index IX_IIBB_Provincia_VigenciaDesde created.';
END
GO
IF NOT EXISTS (SELECT 1 FROM sys.indexes WHERE name = 'IX_IIBB_PredecesorId' AND object_id = OBJECT_ID('dbo.IngresosBrutos'))
BEGIN
CREATE INDEX IX_IIBB_PredecesorId
ON dbo.IngresosBrutos(PredecesorId)
WHERE PredecesorId IS NOT NULL;
PRINT 'Index IX_IIBB_PredecesorId created.';
END
GO
-- SYSTEM_VERSIONING — IngresosBrutos
IF COL_LENGTH('dbo.IngresosBrutos', 'ValidFrom') IS NULL
BEGIN
ALTER TABLE dbo.IngresosBrutos
ADD
ValidFrom DATETIME2(3) GENERATED ALWAYS AS ROW START HIDDEN NOT NULL
CONSTRAINT DF_IIBB_ValidFrom DEFAULT(SYSUTCDATETIME()),
ValidTo DATETIME2(3) GENERATED ALWAYS AS ROW END HIDDEN NOT NULL
CONSTRAINT DF_IIBB_ValidTo DEFAULT(CONVERT(DATETIME2(3), '9999-12-31 23:59:59.999')),
PERIOD FOR SYSTEM_TIME (ValidFrom, ValidTo);
PRINT 'IngresosBrutos: PERIOD FOR SYSTEM_TIME added.';
END
GO
IF NOT EXISTS (SELECT 1 FROM sys.tables WHERE object_id = OBJECT_ID('dbo.IngresosBrutos') AND temporal_type = 2)
BEGIN
ALTER TABLE dbo.IngresosBrutos
SET (SYSTEM_VERSIONING = ON (
HISTORY_TABLE = dbo.IngresosBrutos_History,
HISTORY_RETENTION_PERIOD = 10 YEARS
));
PRINT 'IngresosBrutos: SYSTEM_VERSIONING = ON (history: dbo.IngresosBrutos_History, retention: 10 years).';
END
ELSE
PRINT 'IngresosBrutos: SYSTEM_VERSIONING already ON — skip.';
GO
IF EXISTS (SELECT 1 FROM sys.tables WHERE name = 'IngresosBrutos_History' AND schema_id = SCHEMA_ID('dbo'))
AND NOT EXISTS (
SELECT 1 FROM sys.partitions p
JOIN sys.tables t ON t.object_id = p.object_id
WHERE t.name = 'IngresosBrutos_History' AND p.data_compression = 2
)
BEGIN
ALTER TABLE dbo.IngresosBrutos_History REBUILD WITH (DATA_COMPRESSION = PAGE);
PRINT 'IngresosBrutos_History: rebuilt with PAGE compression.';
END
GO
-- ═══════════════════════════════════════════════════════════════════════
-- 3. Seed TipoDeIva — 4 filas canonicas (REQ-SEED-001)
-- MERGE garantiza idempotencia (REQ-SEED-003)
-- EXENTO y NO_GRAVADO no aplican IVA; IVA_105 e IVA_21 si aplican.
-- ═══════════════════════════════════════════════════════════════════════
MERGE dbo.TipoDeIva AS t
USING (VALUES
('EXENTO', N'Exento de IVA', CAST(0 AS DECIMAL(5,2)), CAST(0 AS BIT), CAST('2020-01-01' AS DATE)),
('NO_GRAVADO', N'No gravado', CAST(0 AS DECIMAL(5,2)), CAST(0 AS BIT), CAST('2020-01-01' AS DATE)),
('IVA_105', N'IVA alicuota diferencial 10.5%', CAST(10.5 AS DECIMAL(5,2)), CAST(1 AS BIT), CAST('2020-01-01' AS DATE)),
('IVA_21', N'IVA alicuota general 21%', CAST(21 AS DECIMAL(5,2)), CAST(1 AS BIT), CAST('2020-01-01' AS DATE))
) AS s (Codigo, Descripcion, Porcentaje, AplicaIVA, VigenciaDesde)
ON t.Codigo = s.Codigo AND t.PredecesorId IS NULL
WHEN NOT MATCHED BY TARGET THEN
INSERT (Codigo, Descripcion, Porcentaje, AplicaIVA, Activo, VigenciaDesde, VigenciaHasta, PredecesorId)
VALUES (s.Codigo, s.Descripcion, s.Porcentaje, s.AplicaIVA, 1, s.VigenciaDesde, NULL, NULL);
GO
PRINT 'TipoDeIva: 4 canonical rows seeded (EXENTO, NO_GRAVADO, IVA_105, IVA_21).';
GO
-- ═══════════════════════════════════════════════════════════════════════
-- 4. Seed IngresosBrutos — 24 filas (23 provincias INDEC + CABA) (REQ-SEED-002)
-- Alicuota=0 placeholder — el operador cargara las alicuotas reales via UI.
-- MERGE garantiza idempotencia (REQ-SEED-003).
-- Provincias almacenadas como nombre de enum ProvinciaArgentina PascalCase (VARCHAR(50)).
-- DISCOVERY: spec dice 25 filas pero lista canonica del design tiene 24 entradas
-- (23 provincias INDEC + CABA). Implementado con 24. Ver apply-progress.
-- T700 cleanup: valores cambiados de UPPER_SNAKE_CASE a PascalCase (matching enum.ToString()).
-- ═══════════════════════════════════════════════════════════════════════
MERGE dbo.IngresosBrutos AS t
USING (VALUES
('BuenosAires', N'Ingresos Brutos - Buenos Aires'),
('CiudadAutonomaDeBuenosAires', N'Ingresos Brutos - Ciudad Autonoma de Buenos Aires'),
('Catamarca', N'Ingresos Brutos - Catamarca'),
('Chaco', N'Ingresos Brutos - Chaco'),
('Chubut', N'Ingresos Brutos - Chubut'),
('Cordoba', N'Ingresos Brutos - Cordoba'),
('Corrientes', N'Ingresos Brutos - Corrientes'),
('EntreRios', N'Ingresos Brutos - Entre Rios'),
('Formosa', N'Ingresos Brutos - Formosa'),
('Jujuy', N'Ingresos Brutos - Jujuy'),
('LaPampa', N'Ingresos Brutos - La Pampa'),
('LaRioja', N'Ingresos Brutos - La Rioja'),
('Mendoza', N'Ingresos Brutos - Mendoza'),
('Misiones', N'Ingresos Brutos - Misiones'),
('Neuquen', N'Ingresos Brutos - Neuquen'),
('RioNegro', N'Ingresos Brutos - Rio Negro'),
('Salta', N'Ingresos Brutos - Salta'),
('SanJuan', N'Ingresos Brutos - San Juan'),
('SanLuis', N'Ingresos Brutos - San Luis'),
('SantaCruz', N'Ingresos Brutos - Santa Cruz'),
('SantaFe', N'Ingresos Brutos - Santa Fe'),
('SantiagoDelEstero', N'Ingresos Brutos - Santiago del Estero'),
('TierraDelFuego', N'Ingresos Brutos - Tierra del Fuego'),
('Tucuman', N'Ingresos Brutos - Tucuman')
) AS s (Provincia, Descripcion)
ON t.Provincia = s.Provincia AND t.PredecesorId IS NULL
WHEN NOT MATCHED BY TARGET THEN
INSERT (Provincia, Descripcion, Alicuota, Activo, VigenciaDesde, VigenciaHasta, PredecesorId)
VALUES (s.Provincia, s.Descripcion, CAST(0 AS DECIMAL(5,2)), 1, CAST('2020-01-01' AS DATE), NULL, NULL);
GO
PRINT 'IngresosBrutos: 24 canonical rows seeded (23 provincias INDEC + CABA, Alicuota=0 placeholder, PascalCase).';
GO
-- ═══════════════════════════════════════════════════════════════════════
-- 5. Permiso: administracion:fiscal:gestionar (REQ-FISCAL-AUTH-002)
-- ═══════════════════════════════════════════════════════════════════════
MERGE dbo.Permiso AS t
USING (VALUES
('administracion:fiscal:gestionar', N'Gestionar tablas fiscales', N'Gestionar tablas fiscales (IVA, IIBB)', 'administracion')
) AS s (Codigo, Nombre, Descripcion, Modulo)
ON t.Codigo = s.Codigo
WHEN NOT MATCHED BY TARGET THEN
INSERT (Codigo, Nombre, Descripcion, Modulo)
VALUES (s.Codigo, s.Nombre, s.Descripcion, s.Modulo);
GO
MERGE dbo.RolPermiso AS t
USING (
SELECT r.Id AS RolId, p.Id AS PermisoId
FROM (VALUES
('admin', 'administracion:fiscal:gestionar')
) AS x (RolCodigo, PermisoCodigo)
JOIN dbo.Rol r ON r.Codigo = x.RolCodigo
JOIN dbo.Permiso p ON p.Codigo = x.PermisoCodigo
) AS s ON t.RolId = s.RolId AND t.PermisoId = s.PermisoId
WHEN NOT MATCHED BY TARGET THEN
INSERT (RolId, PermisoId) VALUES (s.RolId, s.PermisoId);
GO
PRINT '';
PRINT 'V014 applied successfully.';
PRINT ' - dbo.TipoDeIva (temporal, retention 10y, PAGE compression)';
PRINT ' - dbo.IngresosBrutos (temporal, retention 10y, PAGE compression)';
PRINT ' - TipoDeIva: 4 canonical rows (EXENTO, NO_GRAVADO, IVA_105, IVA_21)';
PRINT ' - IngresosBrutos: 24 rows (23 provincias INDEC + CABA, Alicuota=0 placeholder)';
PRINT ' - Permiso administracion:fiscal:gestionar (asignado a admin)';
GO

View File

@@ -0,0 +1,37 @@
-- V015_ROLLBACK.sql
-- Reversa de V015__create_local_timezone_views.sql.
--
-- Elimina: dbo.v_AuditEvent_Local + dbo.v_SecurityEvent_Local
-- No toca datos: las tablas base AuditEvent y SecurityEvent no se modifican.
--
-- Idempotente: seguro para re-ejecutar.
-- Prerequisito: ningún objeto dependa de estas vistas (funciones, SPs, otras vistas).
SET QUOTED_IDENTIFIER ON;
SET ANSI_NULLS ON;
SET NOCOUNT ON;
GO
IF OBJECT_ID('dbo.v_AuditEvent_Local', 'V') IS NOT NULL
BEGIN
DROP VIEW dbo.v_AuditEvent_Local;
PRINT 'View dbo.v_AuditEvent_Local dropped.';
END
ELSE
PRINT 'View dbo.v_AuditEvent_Local does not exist — skip.';
GO
IF OBJECT_ID('dbo.v_SecurityEvent_Local', 'V') IS NOT NULL
BEGIN
DROP VIEW dbo.v_SecurityEvent_Local;
PRINT 'View dbo.v_SecurityEvent_Local dropped.';
END
ELSE
PRINT 'View dbo.v_SecurityEvent_Local does not exist — skip.';
GO
PRINT '';
PRINT 'V015 rolled back.';
PRINT ' - dbo.v_AuditEvent_Local removed.';
PRINT ' - dbo.v_SecurityEvent_Local removed.';
GO

View File

@@ -0,0 +1,88 @@
-- V015__create_local_timezone_views.sql
-- UDT-011: Vistas admin con OccurredAt convertido a hora Argentina.
--
-- Crea:
-- dbo.v_AuditEvent_Local — AuditEvent con OccurredAtLocal (offset -03:00)
-- dbo.v_SecurityEvent_Local — SecurityEvent con OccurredAtLocal (offset -03:00)
--
-- Conversión: OccurredAt AT TIME ZONE 'UTC' AT TIME ZONE 'Argentina Standard Time'
-- → offset fijo -03:00, sin DST (Argentina dejó el horario de verano en 2009).
-- → Nombre 'Argentina Standard Time' es portable: Windows + SQL Server Linux 2022+ (via ICU).
--
-- Idempotente: re-ejecutable. Guard IF OBJECT_ID IS NULL en cada vista.
-- No altera tablas base — rollback seguro sin pérdida de datos.
-- Reversa: V015_ROLLBACK.sql.
-- Run on: SIGCM2 (dev) y SIGCM2_Test (integration tests).
--
-- Covers: REQ-DB-VIEWS-001, REQ-DB-VIEWS-002, REQ-DB-VIEWS-003, REQ-DB-VIEWS-004
SET QUOTED_IDENTIFIER ON;
SET ANSI_NULLS ON;
SET NOCOUNT ON;
GO
-- ═══════════════════════════════════════════════════════════════════════
-- 1. dbo.v_AuditEvent_Local
-- ═══════════════════════════════════════════════════════════════════════
-- Nota: CREATE VIEW no permite IF...BEGIN...END directo — se usa EXEC('CREATE VIEW ...').
IF OBJECT_ID('dbo.v_AuditEvent_Local', 'V') IS NULL
BEGIN
EXEC('
CREATE VIEW dbo.v_AuditEvent_Local AS
SELECT
Id,
OccurredAt,
OccurredAt AT TIME ZONE ''UTC'' AT TIME ZONE ''Argentina Standard Time'' AS OccurredAtLocal,
ActorUserId,
ActorRoleId,
Action,
TargetType,
TargetId,
CorrelationId,
IpAddress,
UserAgent,
Metadata
FROM dbo.AuditEvent;
');
PRINT 'View dbo.v_AuditEvent_Local created.';
END
ELSE
PRINT 'View dbo.v_AuditEvent_Local already exists — skip.';
GO
-- ═══════════════════════════════════════════════════════════════════════
-- 2. dbo.v_SecurityEvent_Local
-- ═══════════════════════════════════════════════════════════════════════
IF OBJECT_ID('dbo.v_SecurityEvent_Local', 'V') IS NULL
BEGIN
EXEC('
CREATE VIEW dbo.v_SecurityEvent_Local AS
SELECT
Id,
OccurredAt,
OccurredAt AT TIME ZONE ''UTC'' AT TIME ZONE ''Argentina Standard Time'' AS OccurredAtLocal,
ActorUserId,
AttemptedUsername,
SessionId,
Action,
Result,
FailureReason,
IpAddress,
UserAgent,
Metadata
FROM dbo.SecurityEvent;
');
PRINT 'View dbo.v_SecurityEvent_Local created.';
END
ELSE
PRINT 'View dbo.v_SecurityEvent_Local already exists — skip.';
GO
PRINT '';
PRINT 'V015 applied successfully.';
PRINT ' - dbo.v_AuditEvent_Local (AuditEvent + OccurredAtLocal offset -03:00)';
PRINT ' - dbo.v_SecurityEvent_Local (SecurityEvent + OccurredAtLocal offset -03:00)';
PRINT ' - Argentina Standard Time = UTC-3 (fixed offset, no DST since 2009)';
GO

View File

@@ -0,0 +1,82 @@
-- V016_ROLLBACK.sql
-- Reversa de V016__create_rubro.sql.
--
-- ⚠️ ADVERTENCIA: ejecutar ELIMINA dbo.Rubro, dbo.Rubro_History,
-- el permiso 'catalogo:rubros:gestionar' y sus asignaciones.
--
-- Uso intended: ROLLBACK en entornos NO-productivos.
-- Prerequisito: no deben existir FKs vivas apuntando a Rubro (p.ej., Producto, Tarifario).
-- Si CAT-002..006 o PRC-001 ya están aplicados, agregar TarifarioBaseId FK,
-- este rollback fallará — usar backup.
SET QUOTED_IDENTIFIER ON;
SET ANSI_NULLS ON;
SET NOCOUNT ON;
GO
-- ═══════════════════════════════════════════════════════════════════════
-- 1. Apagar SYSTEM_VERSIONING + remover PERIOD en Rubro
-- ═══════════════════════════════════════════════════════════════════════
IF EXISTS (SELECT 1 FROM sys.tables WHERE object_id = OBJECT_ID('dbo.Rubro') AND temporal_type = 2)
BEGIN
ALTER TABLE dbo.Rubro SET (SYSTEM_VERSIONING = OFF);
PRINT 'Rubro: SYSTEM_VERSIONING OFF.';
END
GO
IF EXISTS (SELECT 1 FROM sys.periods WHERE object_id = OBJECT_ID('dbo.Rubro'))
BEGIN
ALTER TABLE dbo.Rubro DROP PERIOD FOR SYSTEM_TIME;
PRINT 'Rubro: PERIOD FOR SYSTEM_TIME dropped.';
END
GO
IF COL_LENGTH('dbo.Rubro', 'ValidFrom') IS NOT NULL
BEGIN
ALTER TABLE dbo.Rubro DROP CONSTRAINT IF EXISTS DF_Rubro_ValidFrom;
ALTER TABLE dbo.Rubro DROP CONSTRAINT IF EXISTS DF_Rubro_ValidTo;
ALTER TABLE dbo.Rubro DROP COLUMN ValidFrom, ValidTo;
PRINT 'Rubro: ValidFrom/ValidTo dropped.';
END
GO
IF OBJECT_ID(N'dbo.Rubro_History', N'U') IS NOT NULL
BEGIN
DROP TABLE dbo.Rubro_History;
PRINT 'Rubro_History dropped.';
END
GO
-- ═══════════════════════════════════════════════════════════════════════
-- 2. Drop índices + tabla Rubro
-- ═══════════════════════════════════════════════════════════════════════
-- Self-FK must be dropped before dropping the table (SQL Server handles it
-- automatically when the table is dropped, but explicit is safer).
IF OBJECT_ID(N'dbo.Rubro', N'U') IS NOT NULL
BEGIN
DROP TABLE dbo.Rubro;
PRINT 'Table dbo.Rubro dropped.';
END
GO
-- ═══════════════════════════════════════════════════════════════════════
-- 3. Remover permiso 'catalogo:rubros:gestionar' + RolPermiso
-- ═══════════════════════════════════════════════════════════════════════
DELETE rp
FROM dbo.RolPermiso rp
JOIN dbo.Permiso p ON p.Id = rp.PermisoId
WHERE p.Codigo = 'catalogo:rubros:gestionar';
GO
DELETE FROM dbo.Permiso
WHERE Codigo = 'catalogo:rubros:gestionar';
GO
PRINT '';
PRINT 'V016 rolled back. dbo.Rubro and dbo.Rubro_History removed.';
PRINT 'catalogo:rubros:gestionar permission and role assignment removed.';
GO

View File

@@ -0,0 +1,152 @@
-- V016__create_rubro.sql
-- CAT-001: Árbol N-ario de Rubros — tabla fundacional del catálogo comercial.
--
-- Cambios:
-- 1. dbo.Rubro (adjacency list, self-FK, soft-delete, SYSTEM_VERSIONING ON, retention 10 años).
-- 2. Índice filtrado unique UQ_Rubro_ParentId_Nombre_Activo (unicidad CI por padre en activos).
-- 3. Índice cubriente IX_Rubro_ParentId_Activo (child lookups ordenados).
-- 4. Permiso 'catalogo:rubros:gestionar' + asignación a rol 'admin'.
--
-- Patrón: V011 (dbo.Medio con SYSTEM_VERSIONING + PAGE compression + MERGE permisos).
-- Idempotente: seguro para re-ejecutar.
-- Reversa: V016_ROLLBACK.sql.
-- Run on: SIGCM2 (dev) y SIGCM2_Test (integration tests).
--
-- Notas:
-- - TarifarioBaseId es INT NULL SIN FK — la FK se agrega en PRC-001.
-- - UQ_Rubro_ParentId_Nombre_Activo cubre solo ParentId IS NOT NULL;
-- para roots (ParentId IS NULL) la unicidad CI la garantiza Application
-- via ExistsByNombreUnderParentAsync(null, ...) — SQL Server trata NULLs
-- como distintos en índices únicos. Ver Design §9 Risk 1.
-- - FechaCreacion / FechaModificacion: DATETIME2(3) alineado con Medio/Seccion.
-- - ValidFrom / ValidTo: DATETIME2(3) GENERATED ALWAYS HIDDEN (idéntico a V011).
--
-- Source of truth: Obsidian/02-ARQUITECTURA-y-TECH-STACK/2.5 📋 Auditoría.md
-- SDD Design: engram sdd/cat-001-arbol-nario-rubros/design
SET QUOTED_IDENTIFIER ON;
SET ANSI_NULLS ON;
SET NOCOUNT ON;
GO
-- ═══════════════════════════════════════════════════════════════════════
-- 1. dbo.Rubro
-- ═══════════════════════════════════════════════════════════════════════
IF OBJECT_ID(N'dbo.Rubro', N'U') IS NULL
BEGIN
CREATE TABLE dbo.Rubro (
Id INT IDENTITY(1,1) NOT NULL CONSTRAINT PK_Rubro PRIMARY KEY,
ParentId INT NULL,
Nombre NVARCHAR(200) COLLATE SQL_Latin1_General_CP1_CI_AI NOT NULL,
Orden INT NOT NULL CONSTRAINT DF_Rubro_Orden DEFAULT(0),
Activo BIT NOT NULL CONSTRAINT DF_Rubro_Activo DEFAULT(1),
TarifarioBaseId INT NULL, -- FK reservada para PRC-001 (sin constraint por ahora)
FechaCreacion DATETIME2(3) NOT NULL CONSTRAINT DF_Rubro_FechaCreacion DEFAULT(SYSUTCDATETIME()),
FechaModificacion DATETIME2(3) NULL,
CONSTRAINT FK_Rubro_Parent FOREIGN KEY (ParentId) REFERENCES dbo.Rubro(Id) ON DELETE NO ACTION
);
PRINT 'Table dbo.Rubro created.';
END
ELSE
PRINT 'Table dbo.Rubro already exists — skip.';
GO
-- ═══════════════════════════════════════════════════════════════════════
-- 2. SYSTEM_VERSIONING — Rubro
-- ═══════════════════════════════════════════════════════════════════════
IF COL_LENGTH('dbo.Rubro', 'ValidFrom') IS NULL
BEGIN
ALTER TABLE dbo.Rubro
ADD
ValidFrom DATETIME2(3) GENERATED ALWAYS AS ROW START HIDDEN NOT NULL
CONSTRAINT DF_Rubro_ValidFrom DEFAULT(SYSUTCDATETIME()),
ValidTo DATETIME2(3) GENERATED ALWAYS AS ROW END HIDDEN NOT NULL
CONSTRAINT DF_Rubro_ValidTo DEFAULT(CONVERT(DATETIME2(3), '9999-12-31 23:59:59.999')),
PERIOD FOR SYSTEM_TIME (ValidFrom, ValidTo);
PRINT 'Rubro: PERIOD FOR SYSTEM_TIME added.';
END
GO
IF NOT EXISTS (SELECT 1 FROM sys.tables WHERE object_id = OBJECT_ID('dbo.Rubro') AND temporal_type = 2)
BEGIN
ALTER TABLE dbo.Rubro
SET (SYSTEM_VERSIONING = ON (
HISTORY_TABLE = dbo.Rubro_History,
HISTORY_RETENTION_PERIOD = 10 YEARS
));
PRINT 'Rubro: SYSTEM_VERSIONING = ON (history: dbo.Rubro_History, retention: 10 years).';
END
ELSE
PRINT 'Rubro: SYSTEM_VERSIONING already ON — skip.';
GO
IF EXISTS (SELECT 1 FROM sys.tables WHERE name = 'Rubro_History' AND schema_id = SCHEMA_ID('dbo'))
AND NOT EXISTS (
SELECT 1 FROM sys.partitions p
JOIN sys.tables t ON t.object_id = p.object_id
WHERE t.name = 'Rubro_History' AND p.data_compression = 2
)
BEGIN
ALTER TABLE dbo.Rubro_History REBUILD WITH (DATA_COMPRESSION = PAGE);
PRINT 'Rubro_History: rebuilt with PAGE compression.';
END
GO
-- ═══════════════════════════════════════════════════════════════════════
-- 3. Índices
-- ═══════════════════════════════════════════════════════════════════════
-- Unicidad CI por nombre bajo el mismo padre (solo filas activas + ParentId NOT NULL).
-- Para roots (ParentId IS NULL) la unicidad la garantiza Application layer.
IF NOT EXISTS (SELECT 1 FROM sys.indexes WHERE name = 'UQ_Rubro_ParentId_Nombre_Activo' AND object_id = OBJECT_ID('dbo.Rubro'))
BEGIN
CREATE UNIQUE INDEX UQ_Rubro_ParentId_Nombre_Activo
ON dbo.Rubro(ParentId, Nombre)
WHERE Activo = 1 AND ParentId IS NOT NULL;
PRINT 'Index UQ_Rubro_ParentId_Nombre_Activo created.';
END
GO
-- Cubriente para child lookups ordenados por Orden.
IF NOT EXISTS (SELECT 1 FROM sys.indexes WHERE name = 'IX_Rubro_ParentId_Activo' AND object_id = OBJECT_ID('dbo.Rubro'))
BEGIN
CREATE INDEX IX_Rubro_ParentId_Activo
ON dbo.Rubro(ParentId, Activo)
INCLUDE (Nombre, Orden);
PRINT 'Index IX_Rubro_ParentId_Activo created.';
END
GO
-- ═══════════════════════════════════════════════════════════════════════
-- 4. Permiso: catalogo:rubros:gestionar + asignación a rol 'admin'
-- ═══════════════════════════════════════════════════════════════════════
MERGE dbo.Permiso AS t
USING (VALUES
('catalogo:rubros:gestionar', N'Gestionar rubros del catálogo', N'Crear, editar, mover y desactivar rubros del árbol de catálogo comercial', 'catalogo')
) AS s (Codigo, Nombre, Descripcion, Modulo)
ON t.Codigo = s.Codigo
WHEN NOT MATCHED BY TARGET THEN
INSERT (Codigo, Nombre, Descripcion, Modulo)
VALUES (s.Codigo, s.Nombre, s.Descripcion, s.Modulo);
GO
MERGE dbo.RolPermiso AS t
USING (
SELECT r.Id AS RolId, p.Id AS PermisoId
FROM (VALUES
('admin', 'catalogo:rubros:gestionar')
) AS x (RolCodigo, PermisoCodigo)
JOIN dbo.Rol r ON r.Codigo = x.RolCodigo
JOIN dbo.Permiso p ON p.Codigo = x.PermisoCodigo
) AS s ON t.RolId = s.RolId AND t.PermisoId = s.PermisoId
WHEN NOT MATCHED BY TARGET THEN
INSERT (RolId, PermisoId) VALUES (s.RolId, s.PermisoId);
GO
PRINT '';
PRINT 'V016 applied successfully — dbo.Rubro (temporal, retention 10y) + permiso catalogo:rubros:gestionar.';
PRINT 'Next: V017 (future — TBD by next UDT).';
GO

View File

@@ -0,0 +1,71 @@
-- V017_ROLLBACK.sql
-- Reversa de V017__create_product_type.sql.
-- PRD-001: ProductType rollback.
--
-- ADVERTENCIA: Si PRD-002 ya fue mergeado (IProductQueryRepository real), hacer rollback
-- de PRD-002 primero (la interfaz es removida por esta rollback).
--
-- Idempotente: cada paso usa IF EXISTS guards.
SET QUOTED_IDENTIFIER ON;
SET ANSI_NULLS ON;
SET NOCOUNT ON;
GO
-- 1. Desactivar SYSTEM_VERSIONING
IF EXISTS (SELECT 1 FROM sys.tables WHERE object_id = OBJECT_ID('dbo.ProductType') AND temporal_type = 2)
BEGIN
ALTER TABLE dbo.ProductType SET (SYSTEM_VERSIONING = OFF);
PRINT 'ProductType: SYSTEM_VERSIONING = OFF.';
END
GO
-- 2. Remover PERIOD FOR SYSTEM_TIME
IF EXISTS (SELECT 1 FROM sys.periods WHERE object_id = OBJECT_ID('dbo.ProductType'))
BEGIN
ALTER TABLE dbo.ProductType DROP PERIOD FOR SYSTEM_TIME;
PRINT 'ProductType: PERIOD FOR SYSTEM_TIME dropped.';
END
GO
-- 3. Remover columnas HIDDEN + default constraints
IF COL_LENGTH('dbo.ProductType', 'ValidFrom') IS NOT NULL
BEGIN
ALTER TABLE dbo.ProductType DROP CONSTRAINT IF EXISTS DF_ProductType_ValidFrom;
ALTER TABLE dbo.ProductType DROP CONSTRAINT IF EXISTS DF_ProductType_ValidTo;
ALTER TABLE dbo.ProductType DROP COLUMN ValidFrom, ValidTo;
PRINT 'ProductType: ValidFrom/ValidTo columns dropped.';
END
GO
-- 4. Drop history table
IF OBJECT_ID(N'dbo.ProductType_History', N'U') IS NOT NULL
BEGIN
DROP TABLE dbo.ProductType_History;
PRINT 'Table dbo.ProductType_History dropped.';
END
GO
-- 5. Drop main table
IF OBJECT_ID(N'dbo.ProductType', N'U') IS NOT NULL
BEGIN
DROP TABLE dbo.ProductType;
PRINT 'Table dbo.ProductType dropped.';
END
GO
-- 6. Remover RolPermiso para catalogo:tipos:gestionar
DELETE rp FROM dbo.RolPermiso rp
JOIN dbo.Permiso p ON p.Id = rp.PermisoId
WHERE p.Codigo = 'catalogo:tipos:gestionar';
PRINT 'RolPermiso rows for catalogo:tipos:gestionar deleted.';
GO
-- 7. Remover Permiso
DELETE FROM dbo.Permiso WHERE Codigo = 'catalogo:tipos:gestionar';
PRINT 'Permiso catalogo:tipos:gestionar deleted.';
GO
PRINT '';
PRINT 'V017 rolled back successfully.';
GO

View File

@@ -0,0 +1,158 @@
-- V017__create_product_type.sql
-- PRD-001: ProductType — tipología dinámica de productos con flags de comportamiento + límites multimedia.
--
-- Cambios:
-- 1. dbo.ProductType (flags + multimedia limits, SYSTEM_VERSIONING ON, retention 10 años).
-- 2. Índice filtrado unique UQ_ProductType_Nombre_Activo (unicidad CI entre activos).
-- 3. Índice cubriente IX_ProductType_IsActive_Cover.
-- 4. Permiso 'catalogo:tipos:gestionar' + asignación a rol 'admin'.
--
-- Patrón: V016 (dbo.Rubro con SYSTEM_VERSIONING + PAGE compression + MERGE permisos).
-- Idempotente: seguro para re-ejecutar.
-- Reversa: V017_ROLLBACK.sql.
-- Run on: SIGCM2 (dev) y SIGCM2_Test (integration tests).
--
-- Notas:
-- - SIN seed de datos — PRD-008 (V018) seedea los 12 tipos legacy.
-- - SIN FK desde dbo.Product — PRD-002 agrega ALTER TABLE con FK.
-- - Invariante aplicada en Application: si AllowImages=0, los 4 campos multimedia son NULL (handler normaliza).
-- - MaxImages/MaxImageSizeMB/MaxImageWidth/MaxImageHeight: NULL = sin límite; >=1 = tope (validator rechaza <=0).
-- - Desviación del UDT: "0 = ilimitado" → usamos NULL (convención canónica). Ver PRD-001 archive-report.
--
-- SDD Design: engram sdd/prd-001-product-type-flags-multimedia/design
SET QUOTED_IDENTIFIER ON;
SET ANSI_NULLS ON;
SET NOCOUNT ON;
GO
-- ═══════════════════════════════════════════════════════════════════════
-- 1. dbo.ProductType
-- ═══════════════════════════════════════════════════════════════════════
IF OBJECT_ID(N'dbo.ProductType', N'U') IS NULL
BEGIN
CREATE TABLE dbo.ProductType (
Id INT IDENTITY(1,1) NOT NULL CONSTRAINT PK_ProductType PRIMARY KEY,
Nombre NVARCHAR(200) COLLATE SQL_Latin1_General_CP1_CI_AI NOT NULL,
-- Flags de comportamiento
HasDuration BIT NOT NULL CONSTRAINT DF_ProductType_HasDuration DEFAULT(0),
RequiresText BIT NOT NULL CONSTRAINT DF_ProductType_RequiresText DEFAULT(0),
RequiresCategory BIT NOT NULL CONSTRAINT DF_ProductType_RequiresCategory DEFAULT(0),
IsBundle BIT NOT NULL CONSTRAINT DF_ProductType_IsBundle DEFAULT(0),
-- Multimedia (AllowImages=0 => handler normaliza los 4 siguientes a NULL)
AllowImages BIT NOT NULL CONSTRAINT DF_ProductType_AllowImages DEFAULT(0),
MaxImages INT NULL, -- NULL = sin límite; >=1 tope (validator rechaza <=0)
MaxImageSizeMB DECIMAL(10,2) NULL, -- NULL = sin límite; DECIMAL(10,2) permite 0.5 MB, 2.75 MB
MaxImageWidth INT NULL, -- NULL = sin límite; >=1 px
MaxImageHeight INT NULL, -- NULL = sin límite; >=1 px
-- Lifecycle
IsActive BIT NOT NULL CONSTRAINT DF_ProductType_IsActive DEFAULT(1),
FechaCreacion DATETIME2(3) NOT NULL CONSTRAINT DF_ProductType_FechaCreacion DEFAULT(SYSUTCDATETIME()),
FechaModificacion DATETIME2(3) NULL
);
PRINT 'Table dbo.ProductType created.';
END
ELSE
PRINT 'Table dbo.ProductType already exists — skip.';
GO
-- ═══════════════════════════════════════════════════════════════════════
-- 2. SYSTEM_VERSIONING — ProductType
-- ═══════════════════════════════════════════════════════════════════════
IF COL_LENGTH('dbo.ProductType', 'ValidFrom') IS NULL
BEGIN
ALTER TABLE dbo.ProductType
ADD
ValidFrom DATETIME2(3) GENERATED ALWAYS AS ROW START HIDDEN NOT NULL
CONSTRAINT DF_ProductType_ValidFrom DEFAULT(SYSUTCDATETIME()),
ValidTo DATETIME2(3) GENERATED ALWAYS AS ROW END HIDDEN NOT NULL
CONSTRAINT DF_ProductType_ValidTo DEFAULT(CONVERT(DATETIME2(3), '9999-12-31 23:59:59.999')),
PERIOD FOR SYSTEM_TIME (ValidFrom, ValidTo);
PRINT 'ProductType: PERIOD FOR SYSTEM_TIME added.';
END
GO
IF NOT EXISTS (SELECT 1 FROM sys.tables WHERE object_id = OBJECT_ID('dbo.ProductType') AND temporal_type = 2)
BEGIN
ALTER TABLE dbo.ProductType
SET (SYSTEM_VERSIONING = ON (
HISTORY_TABLE = dbo.ProductType_History,
HISTORY_RETENTION_PERIOD = 10 YEARS
));
PRINT 'ProductType: SYSTEM_VERSIONING = ON (history: dbo.ProductType_History, retention: 10 years).';
END
ELSE
PRINT 'ProductType: SYSTEM_VERSIONING already ON — skip.';
GO
IF EXISTS (SELECT 1 FROM sys.tables WHERE name = 'ProductType_History' AND schema_id = SCHEMA_ID('dbo'))
AND NOT EXISTS (
SELECT 1 FROM sys.partitions p
JOIN sys.tables t ON t.object_id = p.object_id
WHERE t.name = 'ProductType_History' AND p.data_compression = 2
)
BEGIN
ALTER TABLE dbo.ProductType_History REBUILD WITH (DATA_COMPRESSION = PAGE);
PRINT 'ProductType_History: rebuilt with PAGE compression.';
END
GO
-- ═══════════════════════════════════════════════════════════════════════
-- 3. Índices
-- ═══════════════════════════════════════════════════════════════════════
IF NOT EXISTS (SELECT 1 FROM sys.indexes WHERE name = 'UQ_ProductType_Nombre_Activo' AND object_id = OBJECT_ID('dbo.ProductType'))
BEGIN
CREATE UNIQUE INDEX UQ_ProductType_Nombre_Activo
ON dbo.ProductType(Nombre)
WHERE IsActive = 1;
PRINT 'Index UQ_ProductType_Nombre_Activo created.';
END
GO
IF NOT EXISTS (SELECT 1 FROM sys.indexes WHERE name = 'IX_ProductType_IsActive_Cover' AND object_id = OBJECT_ID('dbo.ProductType'))
BEGIN
CREATE INDEX IX_ProductType_IsActive_Cover
ON dbo.ProductType(IsActive)
INCLUDE (Nombre, HasDuration, RequiresText, RequiresCategory, IsBundle, AllowImages);
PRINT 'Index IX_ProductType_IsActive_Cover created.';
END
GO
-- ═══════════════════════════════════════════════════════════════════════
-- 4. Permiso: catalogo:tipos:gestionar + asignación a rol 'admin'
-- ═══════════════════════════════════════════════════════════════════════
MERGE dbo.Permiso AS t
USING (VALUES
('catalogo:tipos:gestionar',
N'Gestionar tipos de producto',
N'Crear, editar y desactivar ProductTypes del catálogo (flags + límites multimedia)',
'catalogo')
) AS s (Codigo, Nombre, Descripcion, Modulo)
ON t.Codigo = s.Codigo
WHEN NOT MATCHED BY TARGET THEN
INSERT (Codigo, Nombre, Descripcion, Modulo)
VALUES (s.Codigo, s.Nombre, s.Descripcion, s.Modulo);
GO
MERGE dbo.RolPermiso AS t
USING (
SELECT r.Id AS RolId, p.Id AS PermisoId
FROM (VALUES ('admin', 'catalogo:tipos:gestionar')) AS x (RolCodigo, PermisoCodigo)
JOIN dbo.Rol r ON r.Codigo = x.RolCodigo
JOIN dbo.Permiso p ON p.Codigo = x.PermisoCodigo
) AS s ON t.RolId = s.RolId AND t.PermisoId = s.PermisoId
WHEN NOT MATCHED BY TARGET THEN
INSERT (RolId, PermisoId) VALUES (s.RolId, s.PermisoId);
GO
PRINT '';
PRINT 'V017 applied — dbo.ProductType (temporal, retention 10y) + permiso catalogo:tipos:gestionar.';
PRINT 'Next: V018 (PRD-008 — seed 12 tipos legacy).';
GO

View File

@@ -0,0 +1,67 @@
-- V018_ROLLBACK.sql
-- Reversa de V018__create_product.sql — PRD-002.
--
-- Idempotente: cada paso usa IF EXISTS guards.
-- ADVERTENCIA: Ejecutar antes de V017_ROLLBACK (FK desde Product hacia ProductType).
SET QUOTED_IDENTIFIER ON;
SET ANSI_NULLS ON;
SET NOCOUNT ON;
GO
-- 1. SYSTEM_VERSIONING OFF
IF EXISTS (SELECT 1 FROM sys.tables WHERE object_id = OBJECT_ID('dbo.Product') AND temporal_type = 2)
BEGIN
ALTER TABLE dbo.Product SET (SYSTEM_VERSIONING = OFF);
PRINT 'Product: SYSTEM_VERSIONING = OFF.';
END
GO
-- 2. DROP PERIOD
IF EXISTS (SELECT 1 FROM sys.periods WHERE object_id = OBJECT_ID('dbo.Product'))
BEGIN
ALTER TABLE dbo.Product DROP PERIOD FOR SYSTEM_TIME;
PRINT 'Product: PERIOD FOR SYSTEM_TIME dropped.';
END
GO
-- 3. Drop HIDDEN columns + default constraints
IF COL_LENGTH('dbo.Product', 'ValidFrom') IS NOT NULL
BEGIN
ALTER TABLE dbo.Product DROP CONSTRAINT IF EXISTS DF_Product_ValidFrom;
ALTER TABLE dbo.Product DROP CONSTRAINT IF EXISTS DF_Product_ValidTo;
ALTER TABLE dbo.Product DROP COLUMN ValidFrom, ValidTo;
PRINT 'Product: ValidFrom/ValidTo columns dropped.';
END
GO
-- 4. Drop history
IF OBJECT_ID(N'dbo.Product_History', N'U') IS NOT NULL
BEGIN
DROP TABLE dbo.Product_History;
PRINT 'Table dbo.Product_History dropped.';
END
GO
-- 5. Drop main
IF OBJECT_ID(N'dbo.Product', N'U') IS NOT NULL
BEGIN
DROP TABLE dbo.Product;
PRINT 'Table dbo.Product dropped.';
END
GO
-- 6. Remove RolPermiso / Permiso
DELETE rp FROM dbo.RolPermiso rp
JOIN dbo.Permiso p ON p.Id = rp.PermisoId
WHERE p.Codigo = 'catalogo:productos:gestionar';
PRINT 'RolPermiso rows for catalogo:productos:gestionar deleted.';
GO
DELETE FROM dbo.Permiso WHERE Codigo = 'catalogo:productos:gestionar';
PRINT 'Permiso catalogo:productos:gestionar deleted.';
GO
PRINT '';
PRINT 'V018 rolled back successfully.';
GO

View File

@@ -0,0 +1,172 @@
-- V018__create_product.sql
-- PRD-002: Product — entidad vendible concreta del catálogo comercial.
--
-- Cambios:
-- 1. dbo.Product (FK Medio/ProductType/Rubro, SYSTEM_VERSIONING ON, retention 10 años).
-- 2. Índices: filtered UQ por (MedioId, ProductTypeId, Nombre) activos; cover por ProductTypeId
-- (para IProductQueryRepository); cover por MedioId; cover filtrado por RubroId.
-- 3. Permiso 'catalogo:productos:gestionar' + asignación a rol 'admin'.
--
-- Patrón: V017 (dbo.ProductType con SYSTEM_VERSIONING + PAGE compression + MERGE permisos).
-- Idempotente: seguro para re-ejecutar.
-- Reversa: V018_ROLLBACK.sql.
-- Run on: SIGCM2 (dev) y SIGCM2_Test (integration tests).
--
-- Notas:
-- - SIN seed de datos — PRD-008 (V019) seedea los 12 productos legacy.
-- - Validación de flags (RequiresCategory, HasDuration) vive en Application layer:
-- un ProductType puede cambiar flags; la Product queda en estado snapshot.
-- - UQ filtered WHERE IsActive=1: permite reusar nombres tras soft-delete.
--
-- SDD Design: engram sdd/prd-002-product-crud/design
SET QUOTED_IDENTIFIER ON;
SET ANSI_NULLS ON;
SET NOCOUNT ON;
GO
-- ═══════════════════════════════════════════════════════════════════════
-- 1. dbo.Product
-- ═══════════════════════════════════════════════════════════════════════
IF OBJECT_ID(N'dbo.Product', N'U') IS NULL
BEGIN
CREATE TABLE dbo.Product (
Id INT IDENTITY(1,1) NOT NULL CONSTRAINT PK_Product PRIMARY KEY,
Nombre NVARCHAR(300) COLLATE SQL_Latin1_General_CP1_CI_AI NOT NULL,
MedioId INT NOT NULL,
ProductTypeId INT NOT NULL,
RubroId INT NULL,
BasePrice DECIMAL(18,4) NOT NULL,
PriceDurationDays INT NULL,
IsActive BIT NOT NULL CONSTRAINT DF_Product_IsActive DEFAULT(1),
FechaCreacion DATETIME2(3) NOT NULL CONSTRAINT DF_Product_FechaCreacion DEFAULT(SYSUTCDATETIME()),
FechaModificacion DATETIME2(3) NULL,
CONSTRAINT FK_Product_Medio FOREIGN KEY (MedioId) REFERENCES dbo.Medio(Id) ON DELETE NO ACTION,
CONSTRAINT FK_Product_ProductType FOREIGN KEY (ProductTypeId) REFERENCES dbo.ProductType(Id) ON DELETE NO ACTION,
CONSTRAINT FK_Product_Rubro FOREIGN KEY (RubroId) REFERENCES dbo.Rubro(Id) ON DELETE NO ACTION,
CONSTRAINT CK_Product_BasePrice_NonNegative CHECK (BasePrice >= 0),
CONSTRAINT CK_Product_PriceDurationDays_Positive CHECK (PriceDurationDays IS NULL OR PriceDurationDays >= 1)
);
PRINT 'Table dbo.Product created.';
END
ELSE
PRINT 'Table dbo.Product already exists — skip.';
GO
-- ═══════════════════════════════════════════════════════════════════════
-- 2. SYSTEM_VERSIONING — Product
-- ═══════════════════════════════════════════════════════════════════════
IF COL_LENGTH('dbo.Product', 'ValidFrom') IS NULL
BEGIN
ALTER TABLE dbo.Product
ADD
ValidFrom DATETIME2(3) GENERATED ALWAYS AS ROW START HIDDEN NOT NULL
CONSTRAINT DF_Product_ValidFrom DEFAULT(SYSUTCDATETIME()),
ValidTo DATETIME2(3) GENERATED ALWAYS AS ROW END HIDDEN NOT NULL
CONSTRAINT DF_Product_ValidTo DEFAULT(CONVERT(DATETIME2(3), '9999-12-31 23:59:59.999')),
PERIOD FOR SYSTEM_TIME (ValidFrom, ValidTo);
PRINT 'Product: PERIOD FOR SYSTEM_TIME added.';
END
GO
IF NOT EXISTS (SELECT 1 FROM sys.tables WHERE object_id = OBJECT_ID('dbo.Product') AND temporal_type = 2)
BEGIN
ALTER TABLE dbo.Product
SET (SYSTEM_VERSIONING = ON (
HISTORY_TABLE = dbo.Product_History,
HISTORY_RETENTION_PERIOD = 10 YEARS
));
PRINT 'Product: SYSTEM_VERSIONING = ON (history: dbo.Product_History, retention: 10 years).';
END
ELSE
PRINT 'Product: SYSTEM_VERSIONING already ON — skip.';
GO
IF EXISTS (SELECT 1 FROM sys.tables WHERE name = 'Product_History' AND schema_id = SCHEMA_ID('dbo'))
AND NOT EXISTS (
SELECT 1 FROM sys.partitions p
JOIN sys.tables t ON t.object_id = p.object_id
WHERE t.name = 'Product_History' AND p.data_compression = 2
)
BEGIN
ALTER TABLE dbo.Product_History REBUILD WITH (DATA_COMPRESSION = PAGE);
PRINT 'Product_History: rebuilt with PAGE compression.';
END
GO
-- ═══════════════════════════════════════════════════════════════════════
-- 3. Índices
-- ═══════════════════════════════════════════════════════════════════════
-- Filtered UQ: unicidad activa por (Medio, Tipo, Nombre). Permite reusar nombres tras soft-delete.
IF NOT EXISTS (SELECT 1 FROM sys.indexes WHERE name = 'UQ_Product_MedioId_ProductTypeId_Nombre_Active' AND object_id = OBJECT_ID('dbo.Product'))
BEGIN
CREATE UNIQUE INDEX UQ_Product_MedioId_ProductTypeId_Nombre_Active
ON dbo.Product (MedioId, ProductTypeId, Nombre)
WHERE IsActive = 1;
PRINT 'Index UQ_Product_MedioId_ProductTypeId_Nombre_Active created.';
END
GO
-- Cover para IProductQueryRepository.ExistsActiveByProductTypeAsync
IF NOT EXISTS (SELECT 1 FROM sys.indexes WHERE name = 'IX_Product_ProductTypeId_IsActive' AND object_id = OBJECT_ID('dbo.Product'))
BEGIN
CREATE INDEX IX_Product_ProductTypeId_IsActive
ON dbo.Product (ProductTypeId, IsActive);
PRINT 'Index IX_Product_ProductTypeId_IsActive created.';
END
GO
-- Cover para list filtered by MedioId
IF NOT EXISTS (SELECT 1 FROM sys.indexes WHERE name = 'IX_Product_MedioId_IsActive' AND object_id = OBJECT_ID('dbo.Product'))
BEGIN
CREATE INDEX IX_Product_MedioId_IsActive
ON dbo.Product (MedioId, IsActive);
PRINT 'Index IX_Product_MedioId_IsActive created.';
END
GO
-- Cover para list filtered by RubroId
IF NOT EXISTS (SELECT 1 FROM sys.indexes WHERE name = 'IX_Product_RubroId_IsActive' AND object_id = OBJECT_ID('dbo.Product'))
BEGIN
CREATE INDEX IX_Product_RubroId_IsActive
ON dbo.Product (RubroId, IsActive)
WHERE RubroId IS NOT NULL;
PRINT 'Index IX_Product_RubroId_IsActive created.';
END
GO
-- ═══════════════════════════════════════════════════════════════════════
-- 4. Permiso: catalogo:productos:gestionar + asignación a rol 'admin'
-- ═══════════════════════════════════════════════════════════════════════
MERGE dbo.Permiso AS t
USING (VALUES
('catalogo:productos:gestionar',
N'Gestionar productos del catálogo',
N'Crear, editar y desactivar productos del catálogo comercial',
'catalogo')
) AS s (Codigo, Nombre, Descripcion, Modulo)
ON t.Codigo = s.Codigo
WHEN NOT MATCHED BY TARGET THEN
INSERT (Codigo, Nombre, Descripcion, Modulo)
VALUES (s.Codigo, s.Nombre, s.Descripcion, s.Modulo);
GO
MERGE dbo.RolPermiso AS t
USING (
SELECT r.Id AS RolId, p.Id AS PermisoId
FROM (VALUES ('admin', 'catalogo:productos:gestionar')) AS x (RolCodigo, PermisoCodigo)
JOIN dbo.Rol r ON r.Codigo = x.RolCodigo
JOIN dbo.Permiso p ON p.Codigo = x.PermisoCodigo
) AS s ON t.RolId = s.RolId AND t.PermisoId = s.PermisoId
WHEN NOT MATCHED BY TARGET THEN
INSERT (RolId, PermisoId) VALUES (s.RolId, s.PermisoId);
GO
PRINT '';
PRINT 'V018 applied — dbo.Product (temporal, retention 10y) + permiso catalogo:productos:gestionar.';
PRINT 'Next: V019 (PRD-008 — seed 12 productos legacy).';
GO

View File

@@ -0,0 +1,71 @@
-- V019_ROLLBACK.sql
-- PRD-003: Reversa de V019__create_product_prices.sql.
--
-- Pasos:
-- 1. Deshabilita SYSTEM_VERSIONING en dbo.ProductPrices (requerido antes de DROP TABLE).
-- 2. Elimina el PERIOD FOR SYSTEM_TIME y las columnas hidden SysStartTime/SysEndTime.
-- 3. Drop de dbo.ProductPrices_History.
-- 4. Drop de dbo.ProductPrices (y sus constraints + índices en cascada).
-- 5. Drop de dbo.usp_AddProductPrice.
--
-- ADVERTENCIA: destruye todo el historial de precios. Ejecutar sólo en DEV o TEST.
-- Run on: SIGCM2 (dev), SIGCM2_Test_App, SIGCM2_Test_Api.
SET QUOTED_IDENTIFIER ON;
SET ANSI_NULLS ON;
SET NOCOUNT ON;
GO
-- 1. Deshabilita SYSTEM_VERSIONING (imprescindible antes de DROP TABLE temporal).
IF EXISTS (SELECT 1 FROM sys.tables WHERE object_id = OBJECT_ID('dbo.ProductPrices') AND temporal_type = 2)
BEGIN
ALTER TABLE dbo.ProductPrices SET (SYSTEM_VERSIONING = OFF);
PRINT 'ProductPrices: SYSTEM_VERSIONING = OFF.';
END
GO
-- 2. Elimina el PERIOD y las hidden cols (si existen, independientemente del versioning).
IF COL_LENGTH('dbo.ProductPrices', 'SysStartTime') IS NOT NULL
BEGIN
ALTER TABLE dbo.ProductPrices
DROP PERIOD FOR SYSTEM_TIME;
-- Drop default constraints antes de drop de columnas.
IF EXISTS (SELECT 1 FROM sys.default_constraints WHERE name = 'DF_ProductPrices_SysStartTime')
ALTER TABLE dbo.ProductPrices DROP CONSTRAINT DF_ProductPrices_SysStartTime;
IF EXISTS (SELECT 1 FROM sys.default_constraints WHERE name = 'DF_ProductPrices_SysEndTime')
ALTER TABLE dbo.ProductPrices DROP CONSTRAINT DF_ProductPrices_SysEndTime;
ALTER TABLE dbo.ProductPrices DROP COLUMN SysStartTime;
ALTER TABLE dbo.ProductPrices DROP COLUMN SysEndTime;
PRINT 'ProductPrices: PERIOD + hidden cols dropped.';
END
GO
-- 3. Drop de la history table.
IF OBJECT_ID(N'dbo.ProductPrices_History', N'U') IS NOT NULL
BEGIN
DROP TABLE dbo.ProductPrices_History;
PRINT 'Table dbo.ProductPrices_History dropped.';
END
GO
-- 4. Drop de la tabla principal (constraints + índices en cascada).
IF OBJECT_ID(N'dbo.ProductPrices', N'U') IS NOT NULL
BEGIN
DROP TABLE dbo.ProductPrices;
PRINT 'Table dbo.ProductPrices dropped.';
END
GO
-- 5. Drop del SP.
IF OBJECT_ID(N'dbo.usp_AddProductPrice', N'P') IS NOT NULL
BEGIN
DROP PROCEDURE dbo.usp_AddProductPrice;
PRINT 'Procedure dbo.usp_AddProductPrice dropped.';
END
GO
PRINT '';
PRINT 'V019 rollback complete — dbo.ProductPrices, dbo.ProductPrices_History, dbo.usp_AddProductPrice removed.';
GO

View File

@@ -0,0 +1,196 @@
-- V019__create_product_prices.sql
-- PRD-003: ProductPrices — historial de precios por Producto con vigencia civil (Cat2).
--
-- Cambios:
-- 1. dbo.ProductPrices (FK Product, SYSTEM_VERSIONING ON, retention 10 años).
-- 2. Índices: filtered UQ un único activo; cover compuesto para GetPriceAt.
-- 3. SP dbo.usp_AddProductPrice (SERIALIZABLE + UPDLOCK, cierre atómico forward-only).
--
-- Patrón: V018 (SYSTEM_VERSIONING + PAGE compression).
-- Idempotente: seguro para re-ejecutar.
-- Reversa: V019_ROLLBACK.sql.
-- Run on: SIGCM2 (dev), SIGCM2_Test_App, SIGCM2_Test_Api.
--
-- Notas:
-- - SysStartTime/SysEndTime como nombres de cols HIDDEN (no ValidFrom/ValidTo):
-- evita colisión con las business cols PriceValidFrom/PriceValidTo (D1).
-- - DECIMAL(12,2) para Price (distinto de Product.BasePrice DECIMAL(18,4)) — precios retail
-- en pesos con 2 decimales; la diferencia es intencional (D6).
-- - Sin seed inicial — Product.BasePrice queda ortogonal como fallback (OQ-B, D8).
-- - Forward-only estricto en SP: THROW 50409 si new PVF <= active PVF (no solo <).
--
-- SDD Design: engram sdd/prd-003-product-prices-historicos/design
SET QUOTED_IDENTIFIER ON;
SET ANSI_NULLS ON;
SET NOCOUNT ON;
GO
-- ═══════════════════════════════════════════════════════════════════════
-- 1. dbo.ProductPrices
-- ═══════════════════════════════════════════════════════════════════════
IF OBJECT_ID(N'dbo.ProductPrices', N'U') IS NULL
BEGIN
CREATE TABLE dbo.ProductPrices (
Id BIGINT IDENTITY(1,1) NOT NULL
CONSTRAINT PK_ProductPrices PRIMARY KEY,
ProductId INT NOT NULL,
Price DECIMAL(12,2) NOT NULL,
PriceValidFrom DATE NOT NULL,
PriceValidTo DATE NULL,
FechaCreacion DATETIME2(3) NOT NULL
CONSTRAINT DF_ProductPrices_FechaCreacion DEFAULT(SYSUTCDATETIME()),
CONSTRAINT FK_ProductPrices_Product
FOREIGN KEY (ProductId) REFERENCES dbo.Product(Id) ON DELETE NO ACTION,
CONSTRAINT CK_ProductPrices_Price_Positive
CHECK (Price > 0),
CONSTRAINT CK_ProductPrices_ValidRange
CHECK (PriceValidTo IS NULL OR PriceValidTo >= PriceValidFrom)
);
PRINT 'Table dbo.ProductPrices created.';
END
ELSE
PRINT 'Table dbo.ProductPrices already exists — skip.';
GO
-- ═══════════════════════════════════════════════════════════════════════
-- 2. SYSTEM_VERSIONING — ProductPrices
-- Las hidden cols se llaman SysStartTime/SysEndTime para evitar
-- colisión con las business cols PriceValidFrom/PriceValidTo (D1).
-- ═══════════════════════════════════════════════════════════════════════
IF COL_LENGTH('dbo.ProductPrices', 'SysStartTime') IS NULL
BEGIN
ALTER TABLE dbo.ProductPrices
ADD
SysStartTime DATETIME2(3) GENERATED ALWAYS AS ROW START HIDDEN NOT NULL
CONSTRAINT DF_ProductPrices_SysStartTime DEFAULT(SYSUTCDATETIME()),
SysEndTime DATETIME2(3) GENERATED ALWAYS AS ROW END HIDDEN NOT NULL
CONSTRAINT DF_ProductPrices_SysEndTime DEFAULT(CONVERT(DATETIME2(3),'9999-12-31 23:59:59.999')),
PERIOD FOR SYSTEM_TIME (SysStartTime, SysEndTime);
PRINT 'ProductPrices: PERIOD FOR SYSTEM_TIME added (SysStartTime/SysEndTime).';
END
GO
IF NOT EXISTS (SELECT 1 FROM sys.tables WHERE object_id = OBJECT_ID('dbo.ProductPrices') AND temporal_type = 2)
BEGIN
ALTER TABLE dbo.ProductPrices
SET (SYSTEM_VERSIONING = ON (
HISTORY_TABLE = dbo.ProductPrices_History,
HISTORY_RETENTION_PERIOD = 10 YEARS
));
PRINT 'ProductPrices: SYSTEM_VERSIONING = ON (history: dbo.ProductPrices_History, retention: 10 years).';
END
ELSE
PRINT 'ProductPrices: SYSTEM_VERSIONING already ON — skip.';
GO
IF EXISTS (SELECT 1 FROM sys.tables WHERE name = 'ProductPrices_History' AND schema_id = SCHEMA_ID('dbo'))
AND NOT EXISTS (
SELECT 1 FROM sys.partitions p
JOIN sys.tables t ON t.object_id = p.object_id
WHERE t.name = 'ProductPrices_History' AND p.data_compression = 2
)
BEGIN
ALTER TABLE dbo.ProductPrices_History REBUILD WITH (DATA_COMPRESSION = PAGE);
PRINT 'ProductPrices_History: rebuilt with PAGE compression.';
END
GO
-- ═══════════════════════════════════════════════════════════════════════
-- 3. Índices
-- ═══════════════════════════════════════════════════════════════════════
-- Un único activo por producto (imposibilita violar a nivel BD).
IF NOT EXISTS (SELECT 1 FROM sys.indexes WHERE name = 'UX_ProductPrices_Active' AND object_id = OBJECT_ID('dbo.ProductPrices'))
BEGIN
CREATE UNIQUE INDEX UX_ProductPrices_Active
ON dbo.ProductPrices (ProductId)
WHERE PriceValidTo IS NULL;
PRINT 'Index UX_ProductPrices_Active created.';
END
GO
-- Cover para GetPriceAt / GetByProductIdAsync (ProductId + PriceValidFrom con INCLUDEs).
IF NOT EXISTS (SELECT 1 FROM sys.indexes WHERE name = 'IX_ProductPrices_Lookup' AND object_id = OBJECT_ID('dbo.ProductPrices'))
BEGIN
CREATE INDEX IX_ProductPrices_Lookup
ON dbo.ProductPrices (ProductId, PriceValidFrom DESC)
INCLUDE (Price, PriceValidTo);
PRINT 'Index IX_ProductPrices_Lookup created.';
END
GO
-- ═══════════════════════════════════════════════════════════════════════
-- 4. SP — dbo.usp_AddProductPrice
-- Cierre atómico forward-only: SERIALIZABLE + UPDLOCK + HOLDLOCK.
-- Params de salida: @NewId (BIGINT), @ClosedId (BIGINT — NULL si primer precio).
-- ═══════════════════════════════════════════════════════════════════════
GO
CREATE OR ALTER PROCEDURE dbo.usp_AddProductPrice
@ProductId INT,
@Price DECIMAL(12,2),
@PriceValidFrom DATE,
@NewId BIGINT OUTPUT,
@ClosedId BIGINT OUTPUT
AS
BEGIN
SET NOCOUNT ON;
SET XACT_ABORT ON;
SET TRANSACTION ISOLATION LEVEL SERIALIZABLE;
BEGIN TRY
BEGIN TRANSACTION;
-- Validación: producto debe existir y estar activo.
IF NOT EXISTS (SELECT 1 FROM dbo.Product WITH (NOLOCK) WHERE Id = @ProductId AND IsActive = 1)
BEGIN
ROLLBACK;
THROW 50404, 'Product not found or inactive', 1;
END
-- Lee activo con UPDLOCK + HOLDLOCK — bloquea el range key del filtered index.
DECLARE @ActiveId BIGINT, @ActivePVF DATE;
SELECT TOP 1
@ActiveId = Id,
@ActivePVF = PriceValidFrom
FROM dbo.ProductPrices WITH (UPDLOCK, HOLDLOCK, ROWLOCK)
WHERE ProductId = @ProductId AND PriceValidTo IS NULL;
-- Forward-only estricto: el nuevo PVF debe ser ESTRICTAMENTE mayor al activo.
IF @ActiveId IS NOT NULL AND @PriceValidFrom <= @ActivePVF
BEGIN
ROLLBACK;
THROW 50409, 'ProductPriceForwardOnly: new PriceValidFrom must be > active.PriceValidFrom', 1;
END
-- Cierra el activo previo: PVT = PVF(nuevo) - 1 día.
IF @ActiveId IS NOT NULL
BEGIN
UPDATE dbo.ProductPrices
SET PriceValidTo = DATEADD(DAY, -1, @PriceValidFrom)
WHERE Id = @ActiveId;
SET @ClosedId = @ActiveId;
END
ELSE
SET @ClosedId = NULL;
-- Inserta el nuevo activo.
INSERT INTO dbo.ProductPrices (ProductId, Price, PriceValidFrom, PriceValidTo)
VALUES (@ProductId, @Price, @PriceValidFrom, NULL);
SET @NewId = SCOPE_IDENTITY();
COMMIT TRANSACTION;
END TRY
BEGIN CATCH
IF XACT_STATE() <> 0 ROLLBACK TRANSACTION;
THROW;
END CATCH
END
GO
PRINT '';
PRINT 'V019 applied — dbo.ProductPrices (temporal, retention 10y) + UX_ProductPrices_Active + IX_ProductPrices_Lookup + usp_AddProductPrice.';
PRINT 'Next migration: V020 (TBD).';
GO

View File

@@ -0,0 +1,33 @@
-- V020_ROLLBACK.sql
-- PRC-001: Reversa de V020__add_chargeable_chars_permission.sql.
--
-- Pasos:
-- 1. Elimina la asignación del permiso al rol 'admin'.
-- 2. Elimina el permiso del catálogo.
--
-- ADVERTENCIA: si algún usuario o rol tiene este permiso asignado explícitamente,
-- la FK de RolPermiso causará error. Limpiar RolPermiso primero.
-- Run on: SIGCM2 (dev), SIGCM2_Test_App, SIGCM2_Test_Api.
SET QUOTED_IDENTIFIER ON;
SET ANSI_NULLS ON;
SET NOCOUNT ON;
GO
-- 1. Eliminar asignaciones del permiso a cualquier rol.
DELETE rp
FROM dbo.RolPermiso rp
JOIN dbo.Permiso p ON p.Id = rp.PermisoId
WHERE p.Codigo = 'tasacion:caracteres_especiales:gestionar';
PRINT 'V020 rollback: RolPermiso entries for tasacion:caracteres_especiales:gestionar removed.';
GO
-- 2. Eliminar el permiso del catálogo.
DELETE FROM dbo.Permiso
WHERE Codigo = 'tasacion:caracteres_especiales:gestionar';
PRINT 'V020 rollback: Permiso tasacion:caracteres_especiales:gestionar removed.';
GO
PRINT '';
PRINT 'V020 rollback complete.';
GO

View File

@@ -0,0 +1,54 @@
-- V020__add_chargeable_chars_permission.sql
-- PRC-001: permiso RBAC para ABM de caracteres tasables.
--
-- Cambios:
-- 1. Agrega permiso 'tasacion:caracteres_especiales:gestionar' al catálogo.
-- 2. Asigna el permiso al rol 'admin'.
--
-- Convención RBAC: modulo:recurso:accion.
-- Patrón: V007 (MERGE idempotente).
-- Idempotente: seguro para re-ejecutar.
-- Reversa: V020_ROLLBACK.sql.
-- Run on: SIGCM2 (dev), SIGCM2_Test_App, SIGCM2_Test_Api.
--
-- NOTA: V020 se ejecuta ANTES de V021 (tabla) porque el permiso debe existir
-- antes de que la API arranque con [RequirePermission(...)].
-- V021 crea la tabla dbo.ChargeableCharConfig.
-- V022 siembra las 4 filas globales por defecto.
--
-- SDD Design: engram sdd/prc-001-word-counter-spike/design (D16/D17)
SET QUOTED_IDENTIFIER ON;
SET ANSI_NULLS ON;
SET NOCOUNT ON;
GO
-- Agregar permiso al catálogo (idempotente via MERGE).
MERGE dbo.Permiso AS t
USING (VALUES
('tasacion:caracteres_especiales:gestionar',
N'Gestionar caracteres tasables',
N'Crear, editar precio y desactivar la configuración de caracteres especiales para tasación.',
'tasacion')
) AS s (Codigo, Nombre, Descripcion, Modulo)
ON t.Codigo = s.Codigo
WHEN NOT MATCHED BY TARGET THEN
INSERT (Codigo, Nombre, Descripcion, Modulo)
VALUES (s.Codigo, s.Nombre, s.Descripcion, s.Modulo);
GO
-- Asignar a rol 'admin' (idempotente via MERGE).
MERGE dbo.RolPermiso AS t
USING (
SELECT r.Id AS RolId, p.Id AS PermisoId
FROM dbo.Rol r
CROSS JOIN dbo.Permiso p
WHERE r.Codigo = 'admin'
AND p.Codigo = 'tasacion:caracteres_especiales:gestionar'
) AS s ON t.RolId = s.RolId AND t.PermisoId = s.PermisoId
WHEN NOT MATCHED BY TARGET THEN
INSERT (RolId, PermisoId) VALUES (s.RolId, s.PermisoId);
GO
PRINT 'V020 applied — tasacion:caracteres_especiales:gestionar added to catalog and assigned to admin.';
GO

View File

@@ -0,0 +1,79 @@
-- V021_ROLLBACK.sql
-- PRC-001: Reversa de V021__create_chargeable_char_config.sql.
--
-- Pasos:
-- 1. Deshabilita SYSTEM_VERSIONING en dbo.ChargeableCharConfig (requerido antes de DROP TABLE).
-- 2. Elimina el PERIOD FOR SYSTEM_TIME y las columnas hidden SysStartTime/SysEndTime.
-- 3. Drop de dbo.ChargeableCharConfig_History.
-- 4. Drop de dbo.ChargeableCharConfig (constraints + índices en cascada).
-- 5. Drop de dbo.usp_ChargeableCharConfig_InsertWithClose.
-- 6. Drop de dbo.usp_ChargeableCharConfig_GetActiveForMedio.
--
-- ADVERTENCIA: destruye toda la configuración de caracteres tasables. Solo DEV/TEST.
-- Run on: SIGCM2 (dev), SIGCM2_Test_App, SIGCM2_Test_Api.
SET QUOTED_IDENTIFIER ON;
SET ANSI_NULLS ON;
SET NOCOUNT ON;
GO
-- 1. Deshabilita SYSTEM_VERSIONING (imprescindible antes de DROP TABLE temporal).
IF EXISTS (SELECT 1 FROM sys.tables WHERE object_id = OBJECT_ID('dbo.ChargeableCharConfig') AND temporal_type = 2)
BEGIN
ALTER TABLE dbo.ChargeableCharConfig SET (SYSTEM_VERSIONING = OFF);
PRINT 'ChargeableCharConfig: SYSTEM_VERSIONING = OFF.';
END
GO
-- 2. Elimina el PERIOD y las hidden cols.
IF COL_LENGTH('dbo.ChargeableCharConfig', 'SysStartTime') IS NOT NULL
BEGIN
ALTER TABLE dbo.ChargeableCharConfig
DROP PERIOD FOR SYSTEM_TIME;
IF EXISTS (SELECT 1 FROM sys.default_constraints WHERE name = 'DF_ChargeableCharConfig_SysStartTime')
ALTER TABLE dbo.ChargeableCharConfig DROP CONSTRAINT DF_ChargeableCharConfig_SysStartTime;
IF EXISTS (SELECT 1 FROM sys.default_constraints WHERE name = 'DF_ChargeableCharConfig_SysEndTime')
ALTER TABLE dbo.ChargeableCharConfig DROP CONSTRAINT DF_ChargeableCharConfig_SysEndTime;
ALTER TABLE dbo.ChargeableCharConfig DROP COLUMN SysStartTime;
ALTER TABLE dbo.ChargeableCharConfig DROP COLUMN SysEndTime;
PRINT 'ChargeableCharConfig: PERIOD + hidden cols dropped.';
END
GO
-- 3. Drop de la history table.
IF OBJECT_ID(N'dbo.ChargeableCharConfig_History', N'U') IS NOT NULL
BEGIN
DROP TABLE dbo.ChargeableCharConfig_History;
PRINT 'Table dbo.ChargeableCharConfig_History dropped.';
END
GO
-- 4. Drop de la tabla principal.
IF OBJECT_ID(N'dbo.ChargeableCharConfig', N'U') IS NOT NULL
BEGIN
DROP TABLE dbo.ChargeableCharConfig;
PRINT 'Table dbo.ChargeableCharConfig dropped.';
END
GO
-- 5. Drop del SP InsertWithClose.
IF OBJECT_ID(N'dbo.usp_ChargeableCharConfig_InsertWithClose', N'P') IS NOT NULL
BEGIN
DROP PROCEDURE dbo.usp_ChargeableCharConfig_InsertWithClose;
PRINT 'Procedure dbo.usp_ChargeableCharConfig_InsertWithClose dropped.';
END
GO
-- 6. Drop del SP GetActiveForMedio.
IF OBJECT_ID(N'dbo.usp_ChargeableCharConfig_GetActiveForMedio', N'P') IS NOT NULL
BEGIN
DROP PROCEDURE dbo.usp_ChargeableCharConfig_GetActiveForMedio;
PRINT 'Procedure dbo.usp_ChargeableCharConfig_GetActiveForMedio dropped.';
END
GO
PRINT '';
PRINT 'V021 rollback complete — dbo.ChargeableCharConfig, dbo.ChargeableCharConfig_History, usp_ChargeableCharConfig_InsertWithClose, usp_ChargeableCharConfig_GetActiveForMedio removed.';
GO

View File

@@ -0,0 +1,256 @@
-- V021__create_chargeable_char_config.sql
-- PRC-001: ChargeableCharConfig — configuración de caracteres especiales tasables con vigencia civil.
--
-- Cambios:
-- 1. dbo.ChargeableCharConfig (FK Medios NULL=global, SYSTEM_VERSIONING ON, retention 10 años).
-- 2. Índices: filtered UX vigente por (MedioId,Symbol); cover IX para GetActiveForMedio.
-- 3. SP dbo.usp_ChargeableCharConfig_InsertWithClose (SERIALIZABLE + UPDLOCK, forward-only).
-- 4. SP dbo.usp_ChargeableCharConfig_GetActiveForMedio (CTE + ROW_NUMBER per-medio/global).
--
-- Patrón: V019 (SYSTEM_VERSIONING + PAGE compression + SERIALIZABLE SP).
-- Idempotente: seguro para re-ejecutar.
-- Reversa: V021_ROLLBACK.sql.
-- Run on: SIGCM2 (dev), SIGCM2_Test_App, SIGCM2_Test_Api.
--
-- Notas:
-- - SysStartTime/SysEndTime como hidden cols: evita colisión con business cols ValidFrom/ValidTo (D4).
-- - DECIMAL(18,4) para PricePerUnit (mayor granularidad que ProductPrices) (D8).
-- - MedioId NULL = global fallback; per-medio overrides global in GetActiveForMedio (D2/D6).
-- - Forward-only estricto: THROW 50409 si new ValidFrom <= activo.ValidFrom (D9).
-- - UX filtered WHERE ValidTo IS NULL: SQL Server trata (NULL,'$') como valor igual → enforza 1 vigente global (D7).
-- - dbo.ChargeableCharConfig_History debe agregarse a TablesToIgnore en SqlTestFixture.cs (Respawn).
--
-- SDD Design: engram sdd/prc-001-word-counter-spike/design
SET QUOTED_IDENTIFIER ON;
SET ANSI_NULLS ON;
SET NOCOUNT ON;
GO
-- ═══════════════════════════════════════════════════════════════════════
-- 1. dbo.ChargeableCharConfig
-- ═══════════════════════════════════════════════════════════════════════
IF OBJECT_ID(N'dbo.ChargeableCharConfig', N'U') IS NULL
BEGIN
CREATE TABLE dbo.ChargeableCharConfig (
Id BIGINT IDENTITY(1,1) NOT NULL
CONSTRAINT PK_ChargeableCharConfig PRIMARY KEY,
MedioId INT NULL, -- NULL = global fallback
Symbol NVARCHAR(4) NOT NULL,
Category NVARCHAR(32) NOT NULL, -- enum-as-string: Currency/Percentage/Exclamation/Question/Other
PricePerUnit DECIMAL(18,4) NOT NULL,
ValidFrom DATE NOT NULL,
ValidTo DATE NULL,
IsActive BIT NOT NULL
CONSTRAINT DF_ChargeableCharConfig_IsActive DEFAULT(1),
FechaCreacion DATETIME2(3) NOT NULL
CONSTRAINT DF_ChargeableCharConfig_FechaCreacion DEFAULT(SYSUTCDATETIME()),
CONSTRAINT FK_ChargeableCharConfig_Medio
FOREIGN KEY (MedioId) REFERENCES dbo.Medio(Id) ON DELETE NO ACTION,
CONSTRAINT CK_ChargeableCharConfig_Price_Positive
CHECK (PricePerUnit > 0),
CONSTRAINT CK_ChargeableCharConfig_Symbol_NotEmpty
CHECK (LEN(Symbol) > 0),
CONSTRAINT CK_ChargeableCharConfig_ValidRange
CHECK (ValidTo IS NULL OR ValidTo >= ValidFrom)
);
PRINT 'Table dbo.ChargeableCharConfig created.';
END
ELSE
PRINT 'Table dbo.ChargeableCharConfig already exists — skip.';
GO
-- ═══════════════════════════════════════════════════════════════════════
-- 2. SYSTEM_VERSIONING — ChargeableCharConfig
-- SysStartTime/SysEndTime para no colisionar con business cols ValidFrom/ValidTo (D4).
-- ═══════════════════════════════════════════════════════════════════════
IF COL_LENGTH('dbo.ChargeableCharConfig', 'SysStartTime') IS NULL
BEGIN
ALTER TABLE dbo.ChargeableCharConfig
ADD
SysStartTime DATETIME2(3) GENERATED ALWAYS AS ROW START HIDDEN NOT NULL
CONSTRAINT DF_ChargeableCharConfig_SysStartTime DEFAULT(SYSUTCDATETIME()),
SysEndTime DATETIME2(3) GENERATED ALWAYS AS ROW END HIDDEN NOT NULL
CONSTRAINT DF_ChargeableCharConfig_SysEndTime DEFAULT(CONVERT(DATETIME2(3),'9999-12-31 23:59:59.999')),
PERIOD FOR SYSTEM_TIME (SysStartTime, SysEndTime);
PRINT 'ChargeableCharConfig: PERIOD FOR SYSTEM_TIME added (SysStartTime/SysEndTime).';
END
GO
IF NOT EXISTS (SELECT 1 FROM sys.tables WHERE object_id = OBJECT_ID('dbo.ChargeableCharConfig') AND temporal_type = 2)
BEGIN
ALTER TABLE dbo.ChargeableCharConfig
SET (SYSTEM_VERSIONING = ON (
HISTORY_TABLE = dbo.ChargeableCharConfig_History,
HISTORY_RETENTION_PERIOD = 10 YEARS
));
PRINT 'ChargeableCharConfig: SYSTEM_VERSIONING = ON (history: dbo.ChargeableCharConfig_History, retention: 10 years).';
END
ELSE
PRINT 'ChargeableCharConfig: SYSTEM_VERSIONING already ON — skip.';
GO
IF EXISTS (SELECT 1 FROM sys.tables WHERE name = 'ChargeableCharConfig_History' AND schema_id = SCHEMA_ID('dbo'))
AND NOT EXISTS (
SELECT 1 FROM sys.partitions p
JOIN sys.tables t ON t.object_id = p.object_id
WHERE t.name = 'ChargeableCharConfig_History' AND p.data_compression = 2
)
BEGIN
ALTER TABLE dbo.ChargeableCharConfig_History REBUILD WITH (DATA_COMPRESSION = PAGE);
PRINT 'ChargeableCharConfig_History: rebuilt with PAGE compression.';
END
GO
-- ═══════════════════════════════════════════════════════════════════════
-- 3. Índices
-- ═══════════════════════════════════════════════════════════════════════
-- Un único vigente por (MedioId, Symbol).
-- SQL Server trata NULL como "distinto" en índices únicos: (NULL,'$') colisiona consigo mismo
-- → enforza exactamente 1 vigente global por símbolo (D7).
IF NOT EXISTS (
SELECT 1 FROM sys.indexes
WHERE name = 'UX_ChargeableCharConfig_Vigente'
AND object_id = OBJECT_ID('dbo.ChargeableCharConfig')
)
BEGIN
CREATE UNIQUE INDEX UX_ChargeableCharConfig_Vigente
ON dbo.ChargeableCharConfig (MedioId, Symbol)
WHERE ValidTo IS NULL;
PRINT 'Index UX_ChargeableCharConfig_Vigente created.';
END
GO
-- Cover para GetActiveForMedio y List.
IF NOT EXISTS (
SELECT 1 FROM sys.indexes
WHERE name = 'IX_ChargeableCharConfig_Query'
AND object_id = OBJECT_ID('dbo.ChargeableCharConfig')
)
BEGIN
CREATE INDEX IX_ChargeableCharConfig_Query
ON dbo.ChargeableCharConfig (MedioId, Symbol, ValidFrom, ValidTo)
INCLUDE (PricePerUnit, IsActive, Category);
PRINT 'Index IX_ChargeableCharConfig_Query created.';
END
GO
-- ═══════════════════════════════════════════════════════════════════════
-- 4. SP — dbo.usp_ChargeableCharConfig_InsertWithClose
-- Cierre atómico forward-only: SERIALIZABLE + UPDLOCK + HOLDLOCK.
-- @MedioId NULL = global; existencia validada sólo cuando NOT NULL.
-- THROW 50404: Medio not found.
-- THROW 50409: ForwardOnly — new ValidFrom must be > active.ValidFrom.
-- Params de salida: @NewId (BIGINT), @ClosedId (BIGINT — NULL si primer precio).
-- ═══════════════════════════════════════════════════════════════════════
GO
CREATE OR ALTER PROCEDURE dbo.usp_ChargeableCharConfig_InsertWithClose
@MedioId INT = NULL,
@Symbol NVARCHAR(4),
@Category NVARCHAR(32),
@PricePerUnit DECIMAL(18,4),
@ValidFrom DATE,
@NewId BIGINT OUTPUT,
@ClosedId BIGINT OUTPUT
AS
BEGIN
SET NOCOUNT ON;
SET XACT_ABORT ON;
SET TRANSACTION ISOLATION LEVEL SERIALIZABLE;
BEGIN TRY
BEGIN TRANSACTION;
-- Validar MedioId sólo cuando se proporciona (NULL = global fallback siempre válido).
IF @MedioId IS NOT NULL
AND NOT EXISTS (SELECT 1 FROM dbo.Medio WITH (NOLOCK) WHERE Id = @MedioId)
BEGIN
ROLLBACK;
THROW 50404, 'Medio not found', 1;
END
-- Lee el vigente actual con bloqueo de rango para serialización.
DECLARE @ActiveId BIGINT, @ActiveValidFrom DATE;
SELECT TOP 1
@ActiveId = Id,
@ActiveValidFrom = ValidFrom
FROM dbo.ChargeableCharConfig WITH (UPDLOCK, HOLDLOCK, ROWLOCK)
WHERE ((@MedioId IS NULL AND MedioId IS NULL)
OR (@MedioId IS NOT NULL AND MedioId = @MedioId))
AND Symbol = @Symbol
AND ValidTo IS NULL;
-- Forward-only estricto: new ValidFrom debe ser ESTRICTAMENTE mayor al activo.
IF @ActiveId IS NOT NULL AND @ValidFrom <= @ActiveValidFrom
BEGIN
ROLLBACK;
THROW 50409, 'ChargeableCharConfigForwardOnly: new ValidFrom must be > active.ValidFrom', 1;
END
-- Cierra el vigente previo: ValidTo = ValidFrom(nuevo) - 1 día.
IF @ActiveId IS NOT NULL
BEGIN
UPDATE dbo.ChargeableCharConfig
SET ValidTo = DATEADD(DAY, -1, @ValidFrom)
WHERE Id = @ActiveId;
SET @ClosedId = @ActiveId;
END
ELSE
SET @ClosedId = NULL;
-- Inserta el nuevo vigente.
INSERT INTO dbo.ChargeableCharConfig
(MedioId, Symbol, Category, PricePerUnit, ValidFrom, ValidTo, IsActive)
VALUES
(@MedioId, @Symbol, @Category, @PricePerUnit, @ValidFrom, NULL, 1);
SET @NewId = SCOPE_IDENTITY();
COMMIT TRANSACTION;
END TRY
BEGIN CATCH
IF XACT_STATE() <> 0 ROLLBACK TRANSACTION;
THROW;
END CATCH
END
GO
-- ═══════════════════════════════════════════════════════════════════════
-- 5. SP — dbo.usp_ChargeableCharConfig_GetActiveForMedio
-- Resolución per-medio + global fallback: 1 fila por Symbol.
-- CTE + ROW_NUMBER PARTITION BY Symbol ORDER BY per-medio(0) vs global(1).
-- ═══════════════════════════════════════════════════════════════════════
GO
CREATE OR ALTER PROCEDURE dbo.usp_ChargeableCharConfig_GetActiveForMedio
@MedioId INT,
@AsOfDate DATE
AS
BEGIN
SET NOCOUNT ON;
WITH Candidates AS (
SELECT
Id, MedioId, Symbol, Category, PricePerUnit, ValidFrom, ValidTo, IsActive,
ROW_NUMBER() OVER (
PARTITION BY Symbol
ORDER BY
CASE WHEN MedioId = @MedioId THEN 0 ELSE 1 END, -- prefer specific over global
ValidFrom DESC
) AS rn
FROM dbo.ChargeableCharConfig
WHERE IsActive = 1
AND ValidFrom <= @AsOfDate
AND (ValidTo IS NULL OR ValidTo >= @AsOfDate)
AND (MedioId = @MedioId OR MedioId IS NULL)
)
SELECT Id, MedioId, Symbol, Category, PricePerUnit, ValidFrom, ValidTo, IsActive
FROM Candidates
WHERE rn = 1;
END
GO
PRINT '';
PRINT 'V021 applied — dbo.ChargeableCharConfig (temporal, retention 10y) + UX_ChargeableCharConfig_Vigente + IX_ChargeableCharConfig_Query + usp_ChargeableCharConfig_InsertWithClose + usp_ChargeableCharConfig_GetActiveForMedio.';
PRINT 'Next migration: V022 (seed ChargeableCharConfig).';
GO

View File

@@ -0,0 +1,23 @@
-- V022_ROLLBACK.sql
-- PRC-001: Reversa de V022__seed_chargeable_char_config.sql.
--
-- Elimina las 4 filas globales de seed (MedioId NULL, símbolos $/%/!/¡, ValidTo NULL).
-- Solo elimina las filas vigentes (ValidTo IS NULL) para no romper el historial temporal.
--
-- ADVERTENCIA: si alguna de estas filas fue cerrada (ValidTo SET), el rollback las ignora
-- (ya no son vigentes). La historia temporal queda intacta.
-- Run on: SIGCM2 (dev), SIGCM2_Test_App, SIGCM2_Test_Api.
SET QUOTED_IDENTIFIER ON;
SET ANSI_NULLS ON;
SET NOCOUNT ON;
GO
DELETE FROM dbo.ChargeableCharConfig
WHERE MedioId IS NULL
AND Symbol IN (N'$', N'%', N'!', N'¡')
AND ValidTo IS NULL;
GO
PRINT 'V022 rollback complete — global seed rows ($, %, !, ¡) removed.';
GO

View File

@@ -0,0 +1,44 @@
-- V022__seed_chargeable_char_config.sql
-- PRC-001: seed de las 4 configuraciones globales de caracteres tasables por defecto.
--
-- Cambios:
-- 1. Inserta 4 filas globales (MedioId NULL): $, %, !, ¡ — precios placeholder 1.0000.
-- El equipo de negocio seteará los valores reales desde el CMS.
--
-- Patrón: MERGE idempotente ON (MedioId IS NULL AND Symbol AND ValidTo IS NULL).
-- Idempotente: seguro para re-ejecutar.
-- Reversa: V022_ROLLBACK.sql.
-- Run on: SIGCM2 (dev), SIGCM2_Test_App, SIGCM2_Test_Api.
--
-- Depends on: V021 (dbo.ChargeableCharConfig must exist).
--
-- Notas:
-- - MedioId NULL = global fallback; aplica a todos los medios a menos que exista
-- una fila per-medio más específica (resolución en usp_ChargeableCharConfig_GetActiveForMedio).
-- - ValidFrom = 2026-01-01: retroactivo al inicio del año fiscal 2026.
-- - ValidTo NULL = vigente (sin fecha de cierre).
-- - PricePerUnit 1.0000 son placeholders — CONFIRMAR con el área de tasación.
--
-- SDD Design: engram sdd/prc-001-word-counter-spike/design (§3.3)
SET QUOTED_IDENTIFIER ON;
SET ANSI_NULLS ON;
SET NOCOUNT ON;
GO
MERGE dbo.ChargeableCharConfig AS t
USING (VALUES
(NULL, N'$', N'Currency', CAST(1.0000 AS DECIMAL(18,4)), CAST('2026-01-01' AS DATE)),
(NULL, N'%', N'Percentage', CAST(1.0000 AS DECIMAL(18,4)), CAST('2026-01-01' AS DATE)),
(NULL, N'!', N'Exclamation', CAST(1.0000 AS DECIMAL(18,4)), CAST('2026-01-01' AS DATE)),
(NULL, N'¡', N'Exclamation', CAST(1.0000 AS DECIMAL(18,4)), CAST('2026-01-01' AS DATE))
) AS s (MedioId, Symbol, Category, PricePerUnit, ValidFrom)
ON (t.MedioId IS NULL AND s.MedioId IS NULL AND t.Symbol = s.Symbol AND t.ValidTo IS NULL)
WHEN NOT MATCHED THEN
INSERT (MedioId, Symbol, Category, PricePerUnit, ValidFrom, ValidTo, IsActive)
VALUES (s.MedioId, s.Symbol, s.Category, s.PricePerUnit, s.ValidFrom, NULL, 1);
GO
PRINT 'V022 applied — 4 global ChargeableCharConfig defaults seeded ($, %, !, ¡).';
PRINT 'NOTE: PricePerUnit values are placeholders (1.0000). Update via CMS before going live.';
GO

View File

@@ -0,0 +1,246 @@
-- V023_ROLLBACK.sql
-- PRC-001: Reversa de V023__refactor_chargeable_char_config_to_product_type.sql.
--
-- ADVERTENCIA: rollback destructivo — elimina ProductTypeId y restaura MedioId.
-- - Todos los datos de ProductTypeId se pierden.
-- - Las filas globales (ProductTypeId NULL) se preservan como globales (MedioId NULL).
-- - El historial temporal puede quedar inconsistente si la tabla fue modificada después.
--
-- Solo para uso en DEV/TEST. No ejecutar en producción si hay datos de ProductTypeId.
-- Run on: SIGCM2 (dev), SIGCM2_Test_App, SIGCM2_Test_Api.
SET QUOTED_IDENTIFIER ON;
SET ANSI_NULLS ON;
SET NOCOUNT ON;
GO
-- ─── 1. Drop new SPs ────────────────────────────────────────────────────────
IF OBJECT_ID('dbo.usp_ChargeableCharConfig_ReactivateWithGuard', 'P') IS NOT NULL
BEGIN
DROP PROCEDURE dbo.usp_ChargeableCharConfig_ReactivateWithGuard;
PRINT 'V023 ROLLBACK: usp_ChargeableCharConfig_ReactivateWithGuard dropped.';
END
GO
IF OBJECT_ID('dbo.usp_ChargeableCharConfig_GetActiveForProductType', 'P') IS NOT NULL
BEGIN
DROP PROCEDURE dbo.usp_ChargeableCharConfig_GetActiveForProductType;
PRINT 'V023 ROLLBACK: usp_ChargeableCharConfig_GetActiveForProductType dropped.';
END
GO
IF OBJECT_ID('dbo.usp_ChargeableCharConfig_InsertWithClose', 'P') IS NOT NULL
BEGIN
DROP PROCEDURE dbo.usp_ChargeableCharConfig_InsertWithClose;
PRINT 'V023 ROLLBACK: usp_ChargeableCharConfig_InsertWithClose dropped.';
END
GO
-- ─── 2. Reverse table alterations if ProductTypeId column exists ─────────────
IF EXISTS (SELECT 1 FROM sys.tables WHERE name = 'ChargeableCharConfig' AND schema_id = SCHEMA_ID('dbo'))
AND EXISTS (SELECT 1 FROM sys.columns
WHERE object_id = OBJECT_ID('dbo.ChargeableCharConfig')
AND name = 'ProductTypeId')
BEGIN
-- 2a. Turn off SYSTEM_VERSIONING
ALTER TABLE dbo.ChargeableCharConfig SET (SYSTEM_VERSIONING = OFF);
PRINT 'V023 ROLLBACK: SYSTEM_VERSIONING = OFF.';
-- 2b. Drop indexes on ProductTypeId
IF EXISTS (SELECT 1 FROM sys.indexes WHERE name = 'UX_ChargeableCharConfig_Vigente'
AND object_id = OBJECT_ID('dbo.ChargeableCharConfig'))
BEGIN
DROP INDEX UX_ChargeableCharConfig_Vigente ON dbo.ChargeableCharConfig;
PRINT 'V023 ROLLBACK: UX_ChargeableCharConfig_Vigente dropped.';
END
IF EXISTS (SELECT 1 FROM sys.indexes WHERE name = 'IX_ChargeableCharConfig_Query'
AND object_id = OBJECT_ID('dbo.ChargeableCharConfig'))
BEGIN
DROP INDEX IX_ChargeableCharConfig_Query ON dbo.ChargeableCharConfig;
PRINT 'V023 ROLLBACK: IX_ChargeableCharConfig_Query dropped.';
END
-- 2c. Drop FK to ProductType
DECLARE @fk_pt sysname;
SELECT @fk_pt = name
FROM sys.foreign_keys
WHERE parent_object_id = OBJECT_ID('dbo.ChargeableCharConfig')
AND referenced_object_id = OBJECT_ID('dbo.ProductType');
IF @fk_pt IS NOT NULL
BEGIN
EXEC('ALTER TABLE dbo.ChargeableCharConfig DROP CONSTRAINT ' + @fk_pt);
PRINT 'V023 ROLLBACK: FK_ChargeableCharConfig_ProductType dropped.';
END
-- 2d. Drop NonNegative price check; restore Positive check
IF EXISTS (SELECT 1 FROM sys.check_constraints
WHERE name = 'CK_ChargeableCharConfig_Price_NonNegative'
AND parent_object_id = OBJECT_ID('dbo.ChargeableCharConfig'))
BEGIN
ALTER TABLE dbo.ChargeableCharConfig DROP CONSTRAINT CK_ChargeableCharConfig_Price_NonNegative;
PRINT 'V023 ROLLBACK: CK_ChargeableCharConfig_Price_NonNegative dropped.';
END
IF NOT EXISTS (SELECT 1 FROM sys.check_constraints
WHERE name = 'CK_ChargeableCharConfig_Price_Positive'
AND parent_object_id = OBJECT_ID('dbo.ChargeableCharConfig'))
BEGIN
ALTER TABLE dbo.ChargeableCharConfig
ADD CONSTRAINT CK_ChargeableCharConfig_Price_Positive CHECK (PricePerUnit > 0);
PRINT 'V023 ROLLBACK: CK_ChargeableCharConfig_Price_Positive restored.';
END
-- 2e. Drop ProductTypeId column from main + history
ALTER TABLE dbo.ChargeableCharConfig DROP COLUMN ProductTypeId;
PRINT 'V023 ROLLBACK: ProductTypeId dropped from ChargeableCharConfig.';
IF EXISTS (SELECT 1 FROM sys.columns
WHERE object_id = OBJECT_ID('dbo.ChargeableCharConfig_History')
AND name = 'ProductTypeId')
BEGIN
ALTER TABLE dbo.ChargeableCharConfig_History DROP COLUMN ProductTypeId;
PRINT 'V023 ROLLBACK: ProductTypeId dropped from ChargeableCharConfig_History.';
END
-- 2f. Restore MedioId column
ALTER TABLE dbo.ChargeableCharConfig ADD MedioId INT NULL;
ALTER TABLE dbo.ChargeableCharConfig_History ADD MedioId INT NULL;
PRINT 'V023 ROLLBACK: MedioId restored.';
-- 2g. Restore FK to Medio
ALTER TABLE dbo.ChargeableCharConfig
ADD CONSTRAINT FK_ChargeableCharConfig_Medio
FOREIGN KEY (MedioId) REFERENCES dbo.Medio(Id) ON DELETE NO ACTION;
PRINT 'V023 ROLLBACK: FK_ChargeableCharConfig_Medio restored.';
-- 2h. Restore indexes on MedioId
CREATE UNIQUE NONCLUSTERED INDEX UX_ChargeableCharConfig_Vigente
ON dbo.ChargeableCharConfig (MedioId, Symbol)
WHERE ValidTo IS NULL;
PRINT 'V023 ROLLBACK: UX_ChargeableCharConfig_Vigente restored (MedioId).';
CREATE NONCLUSTERED INDEX IX_ChargeableCharConfig_Query
ON dbo.ChargeableCharConfig (MedioId, Symbol, ValidFrom, ValidTo)
INCLUDE (PricePerUnit, IsActive, Category);
PRINT 'V023 ROLLBACK: IX_ChargeableCharConfig_Query restored (MedioId).';
-- 2i. Restore SYSTEM_VERSIONING
ALTER TABLE dbo.ChargeableCharConfig
SET (SYSTEM_VERSIONING = ON (
HISTORY_TABLE = dbo.ChargeableCharConfig_History,
HISTORY_RETENTION_PERIOD = 10 YEARS
));
PRINT 'V023 ROLLBACK: SYSTEM_VERSIONING = ON restored.';
END
ELSE
PRINT 'V023 ROLLBACK: ProductTypeId column not found — table already in MedioId state or missing, skipping.';
GO
-- ─── 3. Restore original SPs ────────────────────────────────────────────────
IF OBJECT_ID('dbo.usp_ChargeableCharConfig_InsertWithClose', 'P') IS NULL
EXEC('CREATE PROCEDURE dbo.usp_ChargeableCharConfig_InsertWithClose AS RETURN 0');
GO
ALTER PROCEDURE dbo.usp_ChargeableCharConfig_InsertWithClose
@MedioId INT = NULL,
@Symbol NVARCHAR(4),
@Category NVARCHAR(32),
@PricePerUnit DECIMAL(18,4),
@ValidFrom DATE,
@NewId BIGINT OUTPUT,
@ClosedId BIGINT OUTPUT
AS
BEGIN
SET NOCOUNT ON;
SET XACT_ABORT ON;
SET TRANSACTION ISOLATION LEVEL SERIALIZABLE;
BEGIN TRY
BEGIN TRANSACTION;
IF @MedioId IS NOT NULL
AND NOT EXISTS (SELECT 1 FROM dbo.Medio WITH (NOLOCK) WHERE Id = @MedioId)
BEGIN
ROLLBACK;
THROW 50404, 'Medio not found', 1;
END
DECLARE @ActiveId BIGINT, @ActiveValidFrom DATE;
SELECT TOP 1
@ActiveId = Id,
@ActiveValidFrom = ValidFrom
FROM dbo.ChargeableCharConfig WITH (UPDLOCK, HOLDLOCK, ROWLOCK)
WHERE ((@MedioId IS NULL AND MedioId IS NULL)
OR (@MedioId IS NOT NULL AND MedioId = @MedioId))
AND Symbol = @Symbol
AND ValidTo IS NULL;
IF @ActiveId IS NOT NULL AND @ValidFrom <= @ActiveValidFrom
BEGIN
ROLLBACK;
THROW 50409, 'ChargeableCharConfigForwardOnly: new ValidFrom must be > active.ValidFrom', 1;
END
IF @ActiveId IS NOT NULL
BEGIN
UPDATE dbo.ChargeableCharConfig
SET ValidTo = DATEADD(DAY, -1, @ValidFrom)
WHERE Id = @ActiveId;
SET @ClosedId = @ActiveId;
END
ELSE
SET @ClosedId = NULL;
INSERT INTO dbo.ChargeableCharConfig
(MedioId, Symbol, Category, PricePerUnit, ValidFrom, ValidTo, IsActive)
VALUES
(@MedioId, @Symbol, @Category, @PricePerUnit, @ValidFrom, NULL, 1);
SET @NewId = SCOPE_IDENTITY();
COMMIT TRANSACTION;
END TRY
BEGIN CATCH
IF XACT_STATE() <> 0 ROLLBACK TRANSACTION;
THROW;
END CATCH
END
GO
IF OBJECT_ID('dbo.usp_ChargeableCharConfig_GetActiveForMedio', 'P') IS NULL
EXEC('CREATE PROCEDURE dbo.usp_ChargeableCharConfig_GetActiveForMedio AS RETURN 0');
GO
ALTER PROCEDURE dbo.usp_ChargeableCharConfig_GetActiveForMedio
@MedioId INT,
@AsOfDate DATE
AS
BEGIN
SET NOCOUNT ON;
WITH Candidates AS (
SELECT
Id, MedioId, Symbol, Category, PricePerUnit, ValidFrom, ValidTo, IsActive,
ROW_NUMBER() OVER (
PARTITION BY Symbol
ORDER BY
CASE WHEN MedioId = @MedioId THEN 0 ELSE 1 END,
ValidFrom DESC
) AS rn
FROM dbo.ChargeableCharConfig
WHERE IsActive = 1
AND ValidFrom <= @AsOfDate
AND (ValidTo IS NULL OR ValidTo >= @AsOfDate)
AND (MedioId = @MedioId OR MedioId IS NULL)
)
SELECT Id, MedioId, Symbol, Category, PricePerUnit, ValidFrom, ValidTo, IsActive
FROM Candidates
WHERE rn = 1;
END
GO
PRINT '';
PRINT 'V023 ROLLBACK complete — ChargeableCharConfig restored to MedioId model.';
GO

View File

@@ -0,0 +1,414 @@
-- V023__refactor_chargeable_char_config_to_product_type.sql
-- PRC-001 scope delta: ChargeableCharConfig per ProductType (reemplaza per-Medio).
--
-- Cambios:
-- 1. DROP MedioId + FK_ChargeableCharConfig_Medio + índices que lo referencian.
-- 2. ADD ProductTypeId (nullable = global fallback) + FK_ChargeableCharConfig_ProductType.
-- 3. Recrea índices con ProductTypeId (UX_Vigente + IX_Query).
-- 4. DROP+CREATE usp_ChargeableCharConfig_InsertWithClose (@MedioId → @ProductTypeId).
-- 5. DROP usp_ChargeableCharConfig_GetActiveForMedio + CREATE usp_ChargeableCharConfig_GetActiveForProductType.
-- 6. NEW SP usp_ChargeableCharConfig_ReactivateWithGuard (opción A+guard para feature 3).
-- 7. DROP CK_ChargeableCharConfig_Price_Positive (se permite 0.0000 para opt-in billing).
-- Reemplaza con CK_ChargeableCharConfig_Price_NonNegative (>= 0).
--
-- Patrón: idempotente con IF EXISTS guards. Bloque principal protegido por la presencia
-- de la columna MedioId — si no existe ya fue refactorizada, el bloque no ejecuta.
-- SYSTEM_VERSIONING: OFF al inicio del ALTER block, ON al final (con history table + retention).
-- Depende de: V017 (dbo.ProductType debe existir).
-- Reversa: V023_ROLLBACK.sql.
-- Run on: SIGCM2 (dev), SIGCM2_Test_App, SIGCM2_Test_Api.
--
-- SDD Design: engram sdd/prc-001-word-counter-spike/design
-- Scope delta: engram sdd/prc-001-word-counter-spike/scope-delta-1
SET QUOTED_IDENTIFIER ON;
SET ANSI_NULLS ON;
SET NOCOUNT ON;
GO
-- ═══════════════════════════════════════════════════════════════════════
-- Bloque principal: solo ejecuta si la tabla existe Y todavía tiene MedioId
-- (guard idempotente: si ya fue refactorizada, el bloque se saltea completo).
-- ═══════════════════════════════════════════════════════════════════════
IF EXISTS (SELECT 1 FROM sys.tables WHERE name = 'ChargeableCharConfig' AND schema_id = SCHEMA_ID('dbo'))
AND EXISTS (SELECT 1 FROM sys.columns
WHERE object_id = OBJECT_ID('dbo.ChargeableCharConfig')
AND name = 'MedioId')
BEGIN
PRINT 'V023: MedioId column found — proceeding with refactor.';
-- ─── 1. Turn OFF SYSTEM_VERSIONING (idempotent — skip if already OFF) ───
IF EXISTS (SELECT 1 FROM sys.tables
WHERE name = 'ChargeableCharConfig'
AND schema_id = SCHEMA_ID('dbo')
AND temporal_type = 2) -- 2 = SYSTEM_VERSIONED_TEMPORAL_TABLE
BEGIN
ALTER TABLE dbo.ChargeableCharConfig SET (SYSTEM_VERSIONING = OFF);
PRINT 'V023: SYSTEM_VERSIONING = OFF.';
END
ELSE
PRINT 'V023: SYSTEM_VERSIONING already OFF — skipping.';
-- ─── 2. Drop indexes that reference MedioId ────────────────────────
IF EXISTS (SELECT 1 FROM sys.indexes WHERE name = 'UX_ChargeableCharConfig_Vigente'
AND object_id = OBJECT_ID('dbo.ChargeableCharConfig'))
BEGIN
DROP INDEX UX_ChargeableCharConfig_Vigente ON dbo.ChargeableCharConfig;
PRINT 'V023: UX_ChargeableCharConfig_Vigente dropped.';
END
IF EXISTS (SELECT 1 FROM sys.indexes WHERE name = 'IX_ChargeableCharConfig_Query'
AND object_id = OBJECT_ID('dbo.ChargeableCharConfig'))
BEGIN
DROP INDEX IX_ChargeableCharConfig_Query ON dbo.ChargeableCharConfig;
PRINT 'V023: IX_ChargeableCharConfig_Query dropped.';
END
-- ─── 3. Drop FK to Medio ────────────────────────────────────────────
DECLARE @fk_name sysname;
SELECT @fk_name = name
FROM sys.foreign_keys
WHERE parent_object_id = OBJECT_ID('dbo.ChargeableCharConfig')
AND referenced_object_id = OBJECT_ID('dbo.Medio');
IF @fk_name IS NOT NULL
BEGIN
EXEC('ALTER TABLE dbo.ChargeableCharConfig DROP CONSTRAINT ' + @fk_name);
PRINT 'V023: FK_ChargeableCharConfig_Medio dropped.';
END
-- ─── 4. Drop MedioId column (drop DF constraint first if present) ───
DECLARE @df_medio sysname;
SELECT @df_medio = dc.name
FROM sys.default_constraints dc
JOIN sys.columns c ON c.default_object_id = dc.object_id
WHERE c.object_id = OBJECT_ID('dbo.ChargeableCharConfig')
AND c.name = 'MedioId';
IF @df_medio IS NOT NULL
BEGIN
EXEC('ALTER TABLE dbo.ChargeableCharConfig DROP CONSTRAINT ' + @df_medio);
PRINT 'V023: Default constraint on MedioId dropped.';
END
ALTER TABLE dbo.ChargeableCharConfig DROP COLUMN MedioId;
PRINT 'V023: MedioId column dropped from ChargeableCharConfig.';
-- Drop MedioId from history table if present
IF EXISTS (SELECT 1 FROM sys.columns
WHERE object_id = OBJECT_ID('dbo.ChargeableCharConfig_History')
AND name = 'MedioId')
BEGIN
-- Drop default constraint on history MedioId if any
DECLARE @df_hist_medio sysname;
SELECT @df_hist_medio = dc.name
FROM sys.default_constraints dc
JOIN sys.columns c ON c.default_object_id = dc.object_id
WHERE c.object_id = OBJECT_ID('dbo.ChargeableCharConfig_History')
AND c.name = 'MedioId';
IF @df_hist_medio IS NOT NULL
EXEC('ALTER TABLE dbo.ChargeableCharConfig_History DROP CONSTRAINT ' + @df_hist_medio);
ALTER TABLE dbo.ChargeableCharConfig_History DROP COLUMN MedioId;
PRINT 'V023: MedioId column dropped from ChargeableCharConfig_History.';
END
-- ─── 5. Drop CK_Price_Positive, replace with CK_Price_NonNegative ──
-- V024 seeds PricePerUnit = 0.0000 (opt-in billing). Old check (> 0) would block it.
IF EXISTS (SELECT 1 FROM sys.check_constraints
WHERE name = 'CK_ChargeableCharConfig_Price_Positive'
AND parent_object_id = OBJECT_ID('dbo.ChargeableCharConfig'))
BEGIN
ALTER TABLE dbo.ChargeableCharConfig DROP CONSTRAINT CK_ChargeableCharConfig_Price_Positive;
PRINT 'V023: CK_ChargeableCharConfig_Price_Positive dropped.';
END
IF NOT EXISTS (SELECT 1 FROM sys.check_constraints
WHERE name = 'CK_ChargeableCharConfig_Price_NonNegative'
AND parent_object_id = OBJECT_ID('dbo.ChargeableCharConfig'))
BEGIN
ALTER TABLE dbo.ChargeableCharConfig
ADD CONSTRAINT CK_ChargeableCharConfig_Price_NonNegative
CHECK (PricePerUnit >= 0);
PRINT 'V023: CK_ChargeableCharConfig_Price_NonNegative added (>= 0, opt-in billing).';
END
-- ─── 6. Add ProductTypeId column ────────────────────────────────────
ALTER TABLE dbo.ChargeableCharConfig
ADD ProductTypeId INT NULL; -- NULL = global fallback
PRINT 'V023: ProductTypeId column added to ChargeableCharConfig.';
ALTER TABLE dbo.ChargeableCharConfig_History
ADD ProductTypeId INT NULL;
PRINT 'V023: ProductTypeId column added to ChargeableCharConfig_History.';
-- ─── 7. Add FK to ProductType ────────────────────────────────────────
ALTER TABLE dbo.ChargeableCharConfig
ADD CONSTRAINT FK_ChargeableCharConfig_ProductType
FOREIGN KEY (ProductTypeId) REFERENCES dbo.ProductType(Id) ON DELETE NO ACTION;
PRINT 'V023: FK_ChargeableCharConfig_ProductType added.';
-- ─── 8. Recreate filtered unique index with ProductTypeId ────────────
-- 1 vigente per (ProductTypeId, Symbol). NULL ProductTypeId = global fallback.
-- SQL Server trata NULL como "distinto" en unique indexes → enforza 1 vigente global.
CREATE UNIQUE NONCLUSTERED INDEX UX_ChargeableCharConfig_Vigente
ON dbo.ChargeableCharConfig (ProductTypeId, Symbol)
WHERE ValidTo IS NULL;
PRINT 'V023: UX_ChargeableCharConfig_Vigente recreated (ProductTypeId, Symbol).';
-- ─── 9. Recreate cover index with ProductTypeId ──────────────────────
CREATE NONCLUSTERED INDEX IX_ChargeableCharConfig_Query
ON dbo.ChargeableCharConfig (ProductTypeId, Symbol, ValidFrom, ValidTo)
INCLUDE (PricePerUnit, IsActive, Category);
PRINT 'V023: IX_ChargeableCharConfig_Query recreated (ProductTypeId).';
-- ─── 10. Turn SYSTEM_VERSIONING back ON ──────────────────────────────
ALTER TABLE dbo.ChargeableCharConfig
SET (SYSTEM_VERSIONING = ON (
HISTORY_TABLE = dbo.ChargeableCharConfig_History,
HISTORY_RETENTION_PERIOD = 10 YEARS
));
PRINT 'V023: SYSTEM_VERSIONING = ON (history: dbo.ChargeableCharConfig_History, retention: 10 years).';
END
ELSE
BEGIN
IF NOT EXISTS (SELECT 1 FROM sys.tables WHERE name = 'ChargeableCharConfig' AND schema_id = SCHEMA_ID('dbo'))
PRINT 'V023: dbo.ChargeableCharConfig does not exist — skipping table refactor.';
ELSE
PRINT 'V023: MedioId column not found — table already refactored, skipping.';
END
GO
-- ═══════════════════════════════════════════════════════════════════════
-- SP: usp_ChargeableCharConfig_InsertWithClose (@ProductTypeId replaces @MedioId)
-- Cierre atómico forward-only: SERIALIZABLE + UPDLOCK + HOLDLOCK.
-- @ProductTypeId NULL = global; FK validada solo cuando NOT NULL (via referential integrity).
-- THROW 50404: ProductType not found.
-- THROW 50409: ForwardOnly — new ValidFrom must be > active.ValidFrom.
-- Output: @NewId (BIGINT), @ClosedId (BIGINT — NULL if first price for symbol).
-- ═══════════════════════════════════════════════════════════════════════
IF OBJECT_ID('dbo.usp_ChargeableCharConfig_InsertWithClose', 'P') IS NOT NULL
DROP PROCEDURE dbo.usp_ChargeableCharConfig_InsertWithClose;
GO
CREATE PROCEDURE dbo.usp_ChargeableCharConfig_InsertWithClose
@ProductTypeId INT = NULL,
@Symbol NVARCHAR(4),
@Category NVARCHAR(32),
@PricePerUnit DECIMAL(18,4),
@ValidFrom DATE,
@NewId BIGINT OUTPUT,
@ClosedId BIGINT OUTPUT
AS
BEGIN
SET NOCOUNT ON;
SET XACT_ABORT ON;
SET TRANSACTION ISOLATION LEVEL SERIALIZABLE;
BEGIN TRY
BEGIN TRANSACTION;
-- Validate ProductTypeId only when provided (NULL = global fallback, always valid).
-- FK constraint handles referential integrity; we throw 50404 explicitly for better UX.
IF @ProductTypeId IS NOT NULL
AND NOT EXISTS (SELECT 1 FROM dbo.ProductType WITH (NOLOCK) WHERE Id = @ProductTypeId)
BEGIN
ROLLBACK;
THROW 50404, 'ProductType not found', 1;
END
-- Read current vigente with range lock for serialization.
DECLARE @ActiveId BIGINT, @ActiveValidFrom DATE;
SELECT TOP 1
@ActiveId = Id,
@ActiveValidFrom = ValidFrom
FROM dbo.ChargeableCharConfig WITH (UPDLOCK, HOLDLOCK, ROWLOCK)
WHERE ((@ProductTypeId IS NULL AND ProductTypeId IS NULL)
OR (@ProductTypeId IS NOT NULL AND ProductTypeId = @ProductTypeId))
AND Symbol = @Symbol
AND ValidTo IS NULL;
-- Forward-only strict: new ValidFrom must be STRICTLY greater than active.ValidFrom.
IF @ActiveId IS NOT NULL AND @ValidFrom <= @ActiveValidFrom
BEGIN
ROLLBACK;
THROW 50409, 'ChargeableCharConfigForwardOnly: new ValidFrom must be > active.ValidFrom', 1;
END
-- Close the current vigente: ValidTo = new ValidFrom - 1 day.
IF @ActiveId IS NOT NULL
BEGIN
UPDATE dbo.ChargeableCharConfig
SET ValidTo = DATEADD(DAY, -1, @ValidFrom)
WHERE Id = @ActiveId;
SET @ClosedId = @ActiveId;
END
ELSE
SET @ClosedId = NULL;
-- Insert the new vigente.
INSERT INTO dbo.ChargeableCharConfig
(ProductTypeId, Symbol, Category, PricePerUnit, ValidFrom, ValidTo, IsActive)
VALUES
(@ProductTypeId, @Symbol, @Category, @PricePerUnit, @ValidFrom, NULL, 1);
SET @NewId = SCOPE_IDENTITY();
COMMIT TRANSACTION;
END TRY
BEGIN CATCH
IF XACT_STATE() <> 0 ROLLBACK TRANSACTION;
THROW;
END CATCH
END
GO
-- ═══════════════════════════════════════════════════════════════════════
-- SP: drop old GetActiveForMedio (renamed to GetActiveForProductType)
-- ═══════════════════════════════════════════════════════════════════════
IF OBJECT_ID('dbo.usp_ChargeableCharConfig_GetActiveForMedio', 'P') IS NOT NULL
BEGIN
DROP PROCEDURE dbo.usp_ChargeableCharConfig_GetActiveForMedio;
PRINT 'V023: usp_ChargeableCharConfig_GetActiveForMedio dropped.';
END
GO
-- ═══════════════════════════════════════════════════════════════════════
-- SP: usp_ChargeableCharConfig_GetActiveForProductType
-- Resolución per-ProductType + global fallback: 1 fila por Symbol.
-- CTE + ROW_NUMBER PARTITION BY Symbol ORDER BY per-PT(0) vs global(1).
-- @ProductTypeId: the specific product type to resolve for.
-- @AsOfDate: resolve active rows as of this date (for pricing snapshot).
-- ═══════════════════════════════════════════════════════════════════════
CREATE PROCEDURE dbo.usp_ChargeableCharConfig_GetActiveForProductType
@ProductTypeId INT,
@AsOfDate DATE
AS
BEGIN
SET NOCOUNT ON;
WITH Candidates AS (
SELECT
Id, ProductTypeId, Symbol, Category, PricePerUnit, ValidFrom, ValidTo, IsActive,
ROW_NUMBER() OVER (
PARTITION BY Symbol
ORDER BY
CASE WHEN ProductTypeId = @ProductTypeId THEN 0 ELSE 1 END, -- prefer specific over global
ValidFrom DESC
) AS rn
FROM dbo.ChargeableCharConfig
WHERE IsActive = 1
AND ValidFrom <= @AsOfDate
AND (ValidTo IS NULL OR ValidTo >= @AsOfDate)
AND (ProductTypeId = @ProductTypeId OR ProductTypeId IS NULL)
)
SELECT Id, ProductTypeId, Symbol, Category, PricePerUnit, ValidFrom, ValidTo, IsActive
FROM Candidates
WHERE rn = 1;
END
GO
-- ═══════════════════════════════════════════════════════════════════════
-- SP: usp_ChargeableCharConfig_ReactivateWithGuard (NEW — feature 3 of scope delta)
-- Opción A+guard: literal undo of the last close for (ProductTypeId, Symbol).
-- Guards:
-- - Row must exist → THROW 50404
-- - Row must be closed (ValidTo IS NOT NULL, IsActive = 0) → THROW 50410 if already active
-- - No vigente currently exists for (ProductTypeId, Symbol) → THROW 50411
-- - No posterior rows exist for (ProductTypeId, Symbol) → THROW 50412
-- On success: UPDATE IsActive = 1, ValidTo = NULL (literal undo).
-- Preserves forward-only invariant and maintains clean history.
-- ═══════════════════════════════════════════════════════════════════════
IF OBJECT_ID('dbo.usp_ChargeableCharConfig_ReactivateWithGuard', 'P') IS NOT NULL
DROP PROCEDURE dbo.usp_ChargeableCharConfig_ReactivateWithGuard;
GO
CREATE PROCEDURE dbo.usp_ChargeableCharConfig_ReactivateWithGuard
@Id BIGINT
AS
BEGIN
SET NOCOUNT ON;
SET XACT_ABORT ON;
SET TRANSACTION ISOLATION LEVEL SERIALIZABLE;
BEGIN TRY
BEGIN TRANSACTION;
-- Step 1: Lock + load target row.
DECLARE @ProductTypeId INT, @Symbol NVARCHAR(4), @ValidTo DATE, @IsActive BIT;
SELECT @ProductTypeId = ProductTypeId,
@Symbol = Symbol,
@ValidTo = ValidTo,
@IsActive = IsActive
FROM dbo.ChargeableCharConfig WITH (UPDLOCK, HOLDLOCK)
WHERE Id = @Id;
IF @@ROWCOUNT = 0
BEGIN
ROLLBACK TRANSACTION;
THROW 50404, 'ChargeableCharConfig row not found', 1;
END
-- Step 2: Row must be closed (ValidTo IS NOT NULL and IsActive = 0).
-- If it is currently active (ValidTo IS NULL), reactivation is nonsensical.
IF @ValidTo IS NULL
BEGIN
ROLLBACK TRANSACTION;
THROW 50410, 'Row is already active — reactivation not needed', 1;
END
-- Step 3: GUARD — no vigente currently for (ProductTypeId, Symbol).
-- Prevents re-opening a row while another is already vigente.
IF EXISTS (
SELECT 1 FROM dbo.ChargeableCharConfig
WHERE ((ProductTypeId = @ProductTypeId) OR (ProductTypeId IS NULL AND @ProductTypeId IS NULL))
AND Symbol = @Symbol
AND ValidTo IS NULL
)
BEGIN
ROLLBACK TRANSACTION;
THROW 50411, 'A current active row already exists for this ProductType/Symbol — cannot reactivate', 1;
END
-- Step 4: GUARD — no posterior rows exist for (ProductTypeId, Symbol) after @ValidTo.
-- Ensures this is the LAST closed row; reactivating an older row would violate
-- forward-only ordering of the temporal chain.
IF EXISTS (
SELECT 1 FROM dbo.ChargeableCharConfig
WHERE ((ProductTypeId = @ProductTypeId) OR (ProductTypeId IS NULL AND @ProductTypeId IS NULL))
AND Symbol = @Symbol
AND ValidFrom > @ValidTo
AND Id <> @Id
)
BEGIN
ROLLBACK TRANSACTION;
THROW 50412, 'Posterior rows exist for this ProductType/Symbol — reactivation not allowed', 1;
END
-- Step 5: Literal undo — re-open the row.
UPDATE dbo.ChargeableCharConfig
SET IsActive = 1,
ValidTo = NULL
WHERE Id = @Id;
COMMIT TRANSACTION;
END TRY
BEGIN CATCH
IF XACT_STATE() <> 0 ROLLBACK TRANSACTION;
THROW;
END CATCH
END
GO
PRINT '';
PRINT 'V023 applied — ChargeableCharConfig refactored to ProductType model:';
PRINT ' - MedioId dropped, ProductTypeId added (FK to dbo.ProductType)';
PRINT ' - UX_ChargeableCharConfig_Vigente + IX_ChargeableCharConfig_Query recreated';
PRINT ' - usp_ChargeableCharConfig_InsertWithClose: @MedioId → @ProductTypeId';
PRINT ' - usp_ChargeableCharConfig_GetActiveForMedio dropped';
PRINT ' - usp_ChargeableCharConfig_GetActiveForProductType created';
PRINT ' - usp_ChargeableCharConfig_ReactivateWithGuard created (NEW)';
PRINT ' - CK_Price_Positive replaced by CK_Price_NonNegative (>= 0 for opt-in billing)';
PRINT 'Next migration: V024 (reseed global rows with PricePerUnit = 0.0000).';
GO

View File

@@ -0,0 +1,22 @@
-- V024_ROLLBACK.sql
-- PRC-001: Reversa de V024__reseed_global_with_zero_price.sql.
--
-- Restaura las 4 filas globales de seed a PricePerUnit = 1.0000 (valor original de V022).
-- Solo ejecutar si V024 fue aplicado y se desea volver al estado previo.
--
-- Run on: SIGCM2 (dev), SIGCM2_Test_App, SIGCM2_Test_Api.
SET QUOTED_IDENTIFIER ON;
SET ANSI_NULLS ON;
SET NOCOUNT ON;
GO
UPDATE dbo.ChargeableCharConfig
SET PricePerUnit = CAST(1.0000 AS DECIMAL(18,4))
WHERE ProductTypeId IS NULL
AND Symbol IN (N'$', N'%', N'!', N'¡')
AND ValidTo IS NULL;
PRINT 'V024 ROLLBACK complete — global ChargeableCharConfig prices restored to 1.0000.';
PRINT 'Rows updated: ' + CAST(@@ROWCOUNT AS NVARCHAR(10));
GO

View File

@@ -0,0 +1,34 @@
-- V024__reseed_global_with_zero_price.sql
-- PRC-001 scope delta: actualiza las 4 filas globales de seed a PricePerUnit = 0.0000.
--
-- Cambios:
-- 1. UPDATE directo de las 4 filas globales vigentes ($, %, !, ¡) a PricePerUnit = 0.0000.
--
-- Decisión: UPDATE directo (no forward-only close+insert) porque:
-- - V022 seed price 1.0000 era siempre un placeholder nunca usado en lógica de negocio.
-- - No existe historial de facturación con el valor 1.0000.
-- - La semántica correcta es "opt-in billing": por defecto ningún tipo cobra especiales.
-- - La forward-only invariante aplica a cambios de precio en producción; este es un fix
-- de seed pre-go-live dentro de la misma branch feature (no mergeada a main aún).
-- See: scope-delta-1 en engram sdd/prc-001-word-counter-spike/scope-delta-1.
--
-- Patrón: UPDATE simple WHERE ProductTypeId IS NULL AND Symbol IN (...) AND ValidTo IS NULL.
-- Idempotente: UPDATE idempotente (re-ejecutar no cambia el resultado).
-- Reversa: V024_ROLLBACK.sql.
-- Depends on: V023 (ProductTypeId column must exist; CK_Price_NonNegative >= 0 required).
-- Run on: SIGCM2 (dev), SIGCM2_Test_App, SIGCM2_Test_Api.
SET QUOTED_IDENTIFIER ON;
SET ANSI_NULLS ON;
SET NOCOUNT ON;
GO
UPDATE dbo.ChargeableCharConfig
SET PricePerUnit = CAST(0.0000 AS DECIMAL(18,4))
WHERE ProductTypeId IS NULL
AND Symbol IN (N'$', N'%', N'!', N'¡')
AND ValidTo IS NULL;
PRINT 'V024 applied — global ChargeableCharConfig prices reset to 0.0000 (opt-in billing).';
PRINT 'Rows updated: ' + CAST(@@ROWCOUNT AS NVARCHAR(10));
GO

View File

@@ -0,0 +1,20 @@
-- V025_ROLLBACK.sql
-- Reversa de V025 — elimina los overrides demo de ChargeableCharConfig.
-- Los globales V022/V024 (ProductTypeId IS NULL) NO se tocan.
SET QUOTED_IDENTIFIER ON;
SET ANSI_NULLS ON;
SET NOCOUNT ON;
GO
DELETE FROM dbo.ChargeableCharConfig
WHERE ProductTypeId IS NOT NULL
AND ValidTo IS NULL
AND Symbol IN (N'$', N'%', N'!', N'¡')
AND ProductTypeId IN (
SELECT Id FROM dbo.ProductType
WHERE Nombre IN ('Clasificado', 'Notables', 'Fúnebres', 'Funebres')
);
PRINT 'V025 rolled back — demo overrides eliminated. Globales V022/V024 preservados.';
GO

View File

@@ -0,0 +1,101 @@
-- V025__seed_chargeable_char_overrides_demo.sql
-- PRC-001 followup #54: seeders de demo con valores ficticios per-ProductType.
--
-- Estrategia:
-- 1. Los 4 globales de V022+V024 quedan en 0.0000 (opt-in billing baseline).
-- 2. Para ProductTypes conocidos del roadmap (Clasificado, Notables, Fúnebres),
-- inserta overrides con precios ficticios coherentes con datos de demo del resto
-- del proyecto. Si el ProductType no existe, el bloque correspondiente no hace nada.
-- 3. Cuando PRD-008 seede los 12 tipos legacy, V025 puede re-aplicarse y creará
-- los overrides que falten (MERGE idempotente).
--
-- Precios ficticios (placeholders de demo — NO son tarifas reales):
-- Clasificado: $ = 5.0000, % = 3.0000, ! = 2.0000, ¡ = 2.0000
-- Notables: $ = 8.0000, % = 5.0000, ! = 4.0000, ¡ = 4.0000
-- Fúnebres: $ = 6.0000, % = 4.0000, ! = 3.5000, ¡ = 3.5000
--
-- Reversa: V025_ROLLBACK.sql (elimina los overrides demo dejando solo los globales V022/V024).
-- Run on: SIGCM2 (dev), SIGCM2_Test_App, SIGCM2_Test_Api.
-- Idempotente: usa MERGE por (ProductTypeId, Symbol, ValidTo IS NULL).
SET QUOTED_IDENTIFIER ON;
SET ANSI_NULLS ON;
SET NOCOUNT ON;
GO
DECLARE @ClasificadoId INT = (SELECT TOP 1 Id FROM dbo.ProductType WHERE Nombre = 'Clasificado' AND IsActive = 1);
DECLARE @NotablesId INT = (SELECT TOP 1 Id FROM dbo.ProductType WHERE Nombre = 'Notables' AND IsActive = 1);
DECLARE @FunebresId INT = (SELECT TOP 1 Id FROM dbo.ProductType WHERE Nombre IN ('Fúnebres','Funebres') AND IsActive = 1);
DECLARE @DemoValidFrom DATE = '2026-01-01';
-- Clasificado overrides
IF @ClasificadoId IS NOT NULL
BEGIN
MERGE dbo.ChargeableCharConfig AS t
USING (VALUES
(@ClasificadoId, N'$', N'Currency', CAST(5.0000 AS DECIMAL(18,4)), @DemoValidFrom),
(@ClasificadoId, N'%', N'Percentage', CAST(3.0000 AS DECIMAL(18,4)), @DemoValidFrom),
(@ClasificadoId, N'!', N'Exclamation', CAST(2.0000 AS DECIMAL(18,4)), @DemoValidFrom),
(@ClasificadoId, N'¡', N'Exclamation', CAST(2.0000 AS DECIMAL(18,4)), @DemoValidFrom)
) AS s (ProductTypeId, Symbol, Category, PricePerUnit, ValidFrom)
ON (t.ProductTypeId = s.ProductTypeId AND t.Symbol = s.Symbol AND t.ValidTo IS NULL)
WHEN NOT MATCHED THEN
INSERT (ProductTypeId, Symbol, Category, PricePerUnit, ValidFrom, ValidTo, IsActive)
VALUES (s.ProductTypeId, s.Symbol, s.Category, s.PricePerUnit, s.ValidFrom, NULL, 1);
PRINT 'V025: Clasificado overrides seeded (ProductTypeId=' + CAST(@ClasificadoId AS NVARCHAR(10)) + ').';
END
ELSE
PRINT 'V025: ProductType "Clasificado" not found — skipping Clasificado overrides.';
GO
DECLARE @NotablesId INT = (SELECT TOP 1 Id FROM dbo.ProductType WHERE Nombre = 'Notables' AND IsActive = 1);
DECLARE @DemoValidFrom DATE = '2026-01-01';
-- Notables overrides
IF @NotablesId IS NOT NULL
BEGIN
MERGE dbo.ChargeableCharConfig AS t
USING (VALUES
(@NotablesId, N'$', N'Currency', CAST(8.0000 AS DECIMAL(18,4)), @DemoValidFrom),
(@NotablesId, N'%', N'Percentage', CAST(5.0000 AS DECIMAL(18,4)), @DemoValidFrom),
(@NotablesId, N'!', N'Exclamation', CAST(4.0000 AS DECIMAL(18,4)), @DemoValidFrom),
(@NotablesId, N'¡', N'Exclamation', CAST(4.0000 AS DECIMAL(18,4)), @DemoValidFrom)
) AS s (ProductTypeId, Symbol, Category, PricePerUnit, ValidFrom)
ON (t.ProductTypeId = s.ProductTypeId AND t.Symbol = s.Symbol AND t.ValidTo IS NULL)
WHEN NOT MATCHED THEN
INSERT (ProductTypeId, Symbol, Category, PricePerUnit, ValidFrom, ValidTo, IsActive)
VALUES (s.ProductTypeId, s.Symbol, s.Category, s.PricePerUnit, s.ValidFrom, NULL, 1);
PRINT 'V025: Notables overrides seeded (ProductTypeId=' + CAST(@NotablesId AS NVARCHAR(10)) + ').';
END
ELSE
PRINT 'V025: ProductType "Notables" not found — skipping Notables overrides.';
GO
DECLARE @FunebresId INT = (SELECT TOP 1 Id FROM dbo.ProductType WHERE Nombre IN ('Fúnebres','Funebres') AND IsActive = 1);
DECLARE @DemoValidFrom DATE = '2026-01-01';
-- Fúnebres overrides
IF @FunebresId IS NOT NULL
BEGIN
MERGE dbo.ChargeableCharConfig AS t
USING (VALUES
(@FunebresId, N'$', N'Currency', CAST(6.0000 AS DECIMAL(18,4)), @DemoValidFrom),
(@FunebresId, N'%', N'Percentage', CAST(4.0000 AS DECIMAL(18,4)), @DemoValidFrom),
(@FunebresId, N'!', N'Exclamation', CAST(3.5000 AS DECIMAL(18,4)), @DemoValidFrom),
(@FunebresId, N'¡', N'Exclamation', CAST(3.5000 AS DECIMAL(18,4)), @DemoValidFrom)
) AS s (ProductTypeId, Symbol, Category, PricePerUnit, ValidFrom)
ON (t.ProductTypeId = s.ProductTypeId AND t.Symbol = s.Symbol AND t.ValidTo IS NULL)
WHEN NOT MATCHED THEN
INSERT (ProductTypeId, Symbol, Category, PricePerUnit, ValidFrom, ValidTo, IsActive)
VALUES (s.ProductTypeId, s.Symbol, s.Category, s.PricePerUnit, s.ValidFrom, NULL, 1);
PRINT 'V025: Fúnebres overrides seeded (ProductTypeId=' + CAST(@FunebresId AS NVARCHAR(10)) + ').';
END
ELSE
PRINT 'V025: ProductType "Fúnebres/Funebres" not found — skipping Fúnebres overrides.';
GO
PRINT '';
PRINT 'V025 applied — demo overrides (fictitious prices) seeded for ProductTypes: Clasificado, Notables, Fúnebres (only where they exist).';
PRINT 'NOTE: Los 4 globales (V022/V024) quedan intactos en 0.0000. Estos overrides son PLACEHOLDERS DE DEMO — reemplazar antes de go-live.';
GO

View File

@@ -0,0 +1,30 @@
-- S001__seed_admin.sql
-- Seeds the default admin user for SIG-CM2
-- BCrypt hash of '@Diego550@' at cost 12
-- Generated: 2026-04-13
SET QUOTED_IDENTIFIER ON;
SET ANSI_NULLS ON;
SET NOCOUNT ON;
GO
IF NOT EXISTS (SELECT 1 FROM dbo.Usuario WHERE Username = 'admin')
BEGIN
INSERT INTO dbo.Usuario (Username, PasswordHash, Nombre, Apellido, Email, Rol, PermisosJson, Activo)
VALUES (
'admin',
'$2a$12$rmq6tlSAQ8WXhR2CwLCSeuwCJKz/.8Eab95UQCUNfwe4dokeOqMcW',
'Administrador',
'Sistema',
NULL,
'admin',
'["*"]',
1
);
PRINT 'Admin user seeded successfully.';
END
ELSE
BEGIN
PRINT 'Admin user already exists — skipping.';
END
GO

134
docs/smoke-test-udt-001.md Normal file
View File

@@ -0,0 +1,134 @@
# Smoke Test — UDT-001 Login
Manual checklist para verificar la integración completa del flujo de login.
## Pre-requisitos
- SQL Server TECNICA3 con base `SIGCM2` y seed admin ejecutado (`database/seeds/S001__seed_admin.sql`)
- Claves RSA generadas: `scripts/generate-keys.ps1` ya corrido
- `src/api/SIGCM2.Api/appsettings.Development.json` configurado con connection string y rutas de claves
- Node.js 18+ instalado
- .NET 10 SDK instalado
## Pasos
### 1. Arrancar el backend
Abrir Terminal 1 en la raíz del repositorio:
```bash
dotnet run --project src/api/SIGCM2.Api
```
Verificar que la consola muestre algo similar a:
```
Now listening on: http://localhost:5000
Application started. Press Ctrl+C to shut down.
```
### 2. Arrancar el frontend
Abrir Terminal 2:
```bash
cd src/web
npm run dev
```
Verificar que la consola muestre:
```
VITE v8.x.x ready in Xms
➜ Local: http://localhost:5173/
```
### 3. Verificar redirect a /login
- Abrir `http://localhost:5173` en el navegador
- Debe redirigir automáticamente a `http://localhost:5173/login`
- Debe mostrar el formulario de login con campos **Usuario** y **Contraseña**
**Esperado**: Formulario visible, sin errores en consola del navegador.
### 4. Login con credenciales válidas
- Ingresar `admin` en el campo **Usuario**
- Ingresar `@Diego550@` en el campo **Contraseña**
- Hacer click en **Ingresar**
**Esperado**: Botón se deshabilita brevemente mientras carga.
### 5. Verificar Network tab — POST /api/v1/auth/login
- Abrir DevTools → pestaña **Network**
- Buscar la request `POST /api/v1/auth/login`
- Verificar:
- Status: `200 OK`
- Response body contiene: `accessToken`, `refreshToken`, `expiresIn`, `usuario`
- `usuario.username` = `"admin"`, `usuario.rol` = `"admin"`
**Esperado**: Respuesta 200 con JWT válido.
### 6. Verificar LocalStorage — auth-storage
- DevTools → pestaña **Application** → Storage → Local Storage → `http://localhost:5173`
- Buscar clave `auth-storage`
- Verificar que el JSON contenga:
```json
{
"state": {
"user": { "username": "admin", "rol": "admin", ... },
"accessToken": "eyJ..."
}
}
```
**Esperado**: Token y datos de usuario persistidos correctamente.
### 7. Verificar redirect a Dashboard
- Luego del login exitoso, la URL debe cambiar a `http://localhost:5173/`
- Debe mostrarse: **"SIG-CM2 — Dashboard — Bienvenido al SIG-CM2."**
**Esperado**: Placeholder de Dashboard visible.
### 8. Verificar firma JWT en jwt.io
- Copiar el valor de `accessToken` del LocalStorage
- Abrir [https://jwt.io](https://jwt.io)
- Pegar el token en el campo "Encoded"
- En "VERIFY SIGNATURE" → sección "Public Key or Certificate": pegar el contenido de `src/api/SIGCM2.Api/keys/public.pem`
- Verificar:
- Header: `"alg": "RS256"`
- Payload contiene: `sub`, `name` (= `"admin"`), `rol` (= `"admin"`), `permisos` (= `["*"]`), `iss`, `aud`, `exp`
- Footer muestra: **"Signature Verified"** (fondo azul)
**Esperado**: Firma RS256 válida, claims correctos.
### 9. Probar login fallido
- Volver a `http://localhost:5173/login` (o hacer logout si hubiera botón)
- Ingresar `admin` / `wrongpass`
- Hacer click en **Ingresar**
- Verificar en **Network**: `POST /api/v1/auth/login` → Status `401`
- Verificar en la UI: mensaje de error visible con texto **"Credenciales inválidas"** (sin stack trace)
**Esperado**: Error visible en UI, sin exposición de detalles internos.
---
## Resultado esperado global
| Paso | Resultado |
|------|-----------|
| 1. Backend arranca en :5000 | ✅ / ❌ |
| 2. Frontend arranca en :5173 | ✅ / ❌ |
| 3. Redirect a /login | ✅ / ❌ |
| 4. Login con admin/@Diego550@ | ✅ / ❌ |
| 5. Network: POST 200 + JWT | ✅ / ❌ |
| 6. LocalStorage: auth-storage con token | ✅ / ❌ |
| 7. Redirect a / Dashboard | ✅ / ❌ |
| 8. JWT verificado en jwt.io (RS256) | ✅ / ❌ |
| 9. Login fallido: error en UI, 401 en Network | ✅ / ❌ |

108
docs/smoke-test-udt-002.md Normal file
View File

@@ -0,0 +1,108 @@
# Smoke Test — UDT-002: Logout + Refresh Token
**Branch**: feature/UDT-002
**Fecha**: 2026-04-14
**Prerequisito**: backend corriendo en `http://localhost:5212`, BD `SIGCM2` con migración V002 aplicada.
---
## Escenario 1 — Login y persistencia de tokens
- [ ] Abrir la app en `http://localhost:5173`
- [ ] Ingresar con credenciales válidas (admin / password)
- [ ] Verificar que el login redirige al home
- [ ] Abrir DevTools → Application → Local Storage → `auth-storage`
- [ ] Confirmar que el objeto contiene: `accessToken`, `refreshToken`, `expiresAt`, `user`
- [ ] Verificar que `expiresAt` es aproximadamente `Date.now() + 3600000` (1 hora)
---
## Escenario 2 — Refresh transparente en 401
**Opción A (esperar expiración natural — requiere token con TTL corto):**
- [ ] Modificar `Jwt:AccessTokenMinutes` a `1` en `appsettings.Development.json` y reiniciar el backend
- [ ] Hacer login
- [ ] Esperar 1 minuto para que el access token expire
- [ ] Realizar cualquier request autenticado (ej: navegar a una sección que llame a la API)
- [ ] Verificar que el request se completa sin error visible para el usuario
- [ ] Verificar en DevTools → Network que hubo una llamada a `POST /api/v1/auth/refresh` seguida del request original reenviado con un nuevo Bearer
**Opción B (manipulación manual del token):**
- [ ] Después del login, abrir DevTools → Application → Local Storage → `auth-storage`
- [ ] Editar el JSON y reemplazar `accessToken` con un valor inválido (ej: `"expired"`)
- [ ] Realizar cualquier request autenticado
- [ ] El interceptor de axiosClient recibe 401, llama a `/refresh` con el `refreshToken` real
- [ ] El request original se reintenta automáticamente con el nuevo `accessToken`
- [ ] El usuario no ve ningún error
---
## Escenario 3 — Refresh de 3 requests paralelos (singleton promise)
- [ ] Con el access token vencido (opción B del escenario 2)
- [ ] Abrir una página que dispare múltiples llamadas API simultáneas
- [ ] Verificar en DevTools → Network que hay exactamente **1** llamada a `POST /api/v1/auth/refresh`
- [ ] Verificar que todos los requests subsiguientes retornan con éxito
---
## Escenario 4 — Logout
- [ ] Con sesión activa, hacer click en el botón de logout
- [ ] Verificar que redirige a `/login`
- [ ] Verificar en DevTools → Network que se llamó a `POST /api/v1/auth/logout`
- [ ] Verificar en Local Storage que `auth-storage` tiene `user: null`, `accessToken: null`, `refreshToken: null`
- [ ] Intentar navegar a una ruta protegida — debería redirigir a login
---
## Escenario 5 — Reuso de refresh token después del logout (reuse detection)
- [ ] Hacer login y copiar el valor de `refreshToken` del Local Storage
- [ ] Hacer logout
- [ ] Intentar llamar manualmente al endpoint de refresh con el token anterior:
```bash
curl -X POST http://localhost:5212/api/v1/auth/refresh \
-H "Content-Type: application/json" \
-d '{"accessToken": "<access-anterior>", "refreshToken": "<refresh-anterior>"}'
```
- [ ] Verificar que el backend responde `401` con `{ "error": "invalid_token" }`
- [ ] Verificar en la BD que todos los tokens de la familia fueron revocados:
```sql
SELECT * FROM dbo.RefreshToken WHERE RevokedAt IS NOT NULL ORDER BY Id DESC;
```
---
## Escenario 6 — Refresh token expirado (7 días)
- [ ] Modificar `ExpiresAt` de un token en la BD `SIGCM2_Test` a una fecha pasada
- [ ] Intentar refresh con ese token — debería responder `401`
- [ ] Verificar que el frontend redirige a `/login` y limpia el Local Storage
---
## Escenario 7 — Refresh con access token de otro usuario (mismatch)
- [ ] Crear dos usuarios en la BD (o usar admin + otro)
- [ ] Hacer login con usuario A, guardar el `accessToken`
- [ ] Hacer login con usuario B, guardar el `refreshToken`
- [ ] Intentar refresh con accessToken de A + refreshToken de B:
```bash
curl -X POST http://localhost:5212/api/v1/auth/refresh \
-H "Content-Type: application/json" \
-d '{"accessToken": "<access-usuario-A>", "refreshToken": "<refresh-usuario-B>"}'
```
- [ ] Verificar que el backend responde `401`
---
## Notas de verificación
| Check | Comando |
|-------|---------|
| Tokens en BD | `SELECT Id, UsuarioId, FamilyId, IssuedAt, ExpiresAt, RevokedAt FROM dbo.RefreshToken ORDER BY Id DESC` |
| Familias revocadas | `SELECT FamilyId, COUNT(*) as Total, SUM(CASE WHEN RevokedAt IS NOT NULL THEN 1 ELSE 0 END) as Revoked FROM dbo.RefreshToken GROUP BY FamilyId` |
| Usuario activo | `SELECT Id, Username, Activo FROM dbo.Usuario` |

30
scripts/generate-keys.ps1 Normal file
View File

@@ -0,0 +1,30 @@
# generate-keys.ps1
# Generates RSA 2048 key pair for JWT RS256 signing
# Requires: PowerShell 7+ (pwsh)
# Usage: pwsh -File scripts/generate-keys.ps1
# Keys are written to src/api/SIGCM2.Api/keys/ (gitignored)
$keysDir = Join-Path $PSScriptRoot "..\src\api\SIGCM2.Api\keys"
$keysDir = [System.IO.Path]::GetFullPath($keysDir)
if (-not (Test-Path $keysDir)) {
New-Item -ItemType Directory -Path $keysDir | Out-Null
}
$privatePath = Join-Path $keysDir "private.pem"
$publicPath = Join-Path $keysDir "public.pem"
$rsa = [System.Security.Cryptography.RSA]::Create(2048)
$priv = $rsa.ExportRSAPrivateKeyPem()
$pub = $rsa.ExportRSAPublicKeyPem()
$rsa.Dispose()
Set-Content -Path $privatePath -Value $priv -Encoding UTF8 -NoNewline
Set-Content -Path $publicPath -Value $pub -Encoding UTF8 -NoNewline
Write-Host "RSA 2048 key pair generated:"
Write-Host " Private: $privatePath"
Write-Host " Public: $publicPath"
Write-Host ""
Write-Host "IMPORTANT: These files are gitignored. Regenerate on each dev machine."
Write-Host "For production: set env vars JWT__PrivateKey and JWT__PublicKey (PEM content)."

View File

@@ -0,0 +1,58 @@
using System.Text.Json;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Authorization.Policy;
namespace SIGCM2.Api.Authorization;
/// <summary>
/// Custom IAuthorizationMiddlewareResultHandler that emits a structured ProblemDetails
/// response for 403 Forbidden outcomes (authenticated user, missing permission).
///
/// For 401 Unauthorized and successful outcomes, delegates to the default handler
/// so the existing JWT Bearer challenge flow is unaffected (REQ-B-07).
///
/// Registered as singleton in Program.cs — depends only on framework services.
/// </summary>
public sealed class ForbiddenProblemDetailsHandler : IAuthorizationMiddlewareResultHandler
{
private static readonly AuthorizationMiddlewareResultHandler DefaultHandler = new();
private static readonly JsonSerializerOptions SerializerOptions = new()
{
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
};
public async Task HandleAsync(
RequestDelegate next,
HttpContext context,
AuthorizationPolicy policy,
PolicyAuthorizationResult authorizeResult)
{
// Only intercept 403s for authenticated users.
// If the user is not authenticated, the 401 challenge is handled by JwtBearer (REQ-B-07).
if (authorizeResult.Forbidden && context.User.Identity?.IsAuthenticated == true)
{
var requiredPermission = context.Items["RequiredPermission"] as string;
context.Response.StatusCode = StatusCodes.Status403Forbidden;
context.Response.ContentType = "application/problem+json; charset=utf-8";
var problem = new
{
type = "https://sigcm2.local/errors/forbidden",
title = "Acceso denegado",
status = 403,
detail = "No tenés el permiso requerido para ejecutar esta acción.",
permisoRequerido = requiredPermission,
};
await context.Response.WriteAsync(
JsonSerializer.Serialize(problem, SerializerOptions));
return;
}
// Delegate 401 challenges and successful outcomes to the default handler
await DefaultHandler.HandleAsync(next, context, policy, authorizeResult);
}
}

View File

@@ -0,0 +1,106 @@
using System.IdentityModel.Tokens.Jwt;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Http;
using SIGCM2.Application.Abstractions.Persistence;
using SIGCM2.Application.Audit;
using SIGCM2.Application.Common;
namespace SIGCM2.Api.Authorization;
/// <summary>
/// Authorization handler for <see cref="RequirePermissionAttribute"/>.
/// UDT-009: Reads "rol" + "sub" claims, queries both IRolPermisoRepository
/// and IUsuarioRepository, resolves effective permissions via PermisoResolver,
/// and succeeds if at least one required permission matches (OR semantics).
/// No caching — always authoritative from DB (UDT-006 D1, UDT-009 D3).
/// UDT-010: emits SecurityEvent 'permission.denied' on rejection.
/// </summary>
public sealed class PermissionAuthorizationHandler
: AuthorizationHandler<RequirePermissionAttribute>
{
private readonly IRolPermisoRepository _rolPermisoRepo;
private readonly IUsuarioRepository _usuarioRepo;
private readonly ISecurityEventLogger _security;
private readonly ILogger<PermissionAuthorizationHandler> _logger;
public PermissionAuthorizationHandler(
IRolPermisoRepository rolPermisoRepo,
IUsuarioRepository usuarioRepo,
ISecurityEventLogger security,
ILogger<PermissionAuthorizationHandler> logger)
{
_rolPermisoRepo = rolPermisoRepo;
_usuarioRepo = usuarioRepo;
_security = security;
_logger = logger;
}
protected override async Task HandleRequirementAsync(
AuthorizationHandlerContext context,
RequirePermissionAttribute requirement)
{
// 1. Must be authenticated — defense-in-depth
if (context.User?.Identity?.IsAuthenticated != true)
return; // implicit Fail
// 2. Extract "rol" claim
var rolCodigo = context.User.FindFirst("rol")?.Value;
if (string.IsNullOrWhiteSpace(rolCodigo))
{
_logger.LogWarning(
"Authorization failed — token missing 'rol' claim for user {User}",
context.User.Identity?.Name);
context.Fail(new AuthorizationFailureReason(this, "missing_rol_claim"));
return;
}
// 3. Extract "sub" claim — MapInboundClaims=false so it stays as "sub" (NOT NameIdentifier)
var subClaim = context.User.FindFirst(JwtRegisteredClaimNames.Sub)?.Value
?? context.User.FindFirst("sub")?.Value;
if (string.IsNullOrWhiteSpace(subClaim) || !int.TryParse(subClaim, out var userId))
{
_logger.LogWarning(
"Authorization failed — token missing or non-numeric 'sub' claim for user {User}",
context.User.Identity?.Name);
context.Fail(new AuthorizationFailureReason(this, "missing_sub_claim"));
return;
}
// 4. Load role permissions — no cache (UDT-006 D1)
var rolPermisoEntities = await _rolPermisoRepo.GetByRolCodigoAsync(rolCodigo);
var rolPermisos = rolPermisoEntities.Select(p => p.Codigo);
// 5. Load user overrides — no cache (UDT-009 D3); null usuario → no overrides
var usuario = await _usuarioRepo.GetByIdAsync(userId);
var overrides = PermisosOverride.FromJson(usuario?.PermisosJson);
// 6. Resolve effective permissions
var effective = PermisoResolver.Resolve(rolPermisos, overrides);
// 7. OR semantics — any single match is enough
var matched = requirement.PermissionCodes.FirstOrDefault(effective.Contains);
if (matched is not null)
{
context.Succeed(requirement);
return;
}
// 8. Stash required permission for ForbiddenProblemDetailsHandler
var requiredPermission = requirement.PermissionCodes[0];
if (context.Resource is HttpContext httpContext)
httpContext.Items["RequiredPermission"] = requiredPermission;
// 9. Emit SecurityEvent for the denial
var endpoint = (context.Resource as HttpContext)?.Request?.Path.Value;
var method = (context.Resource as HttpContext)?.Request?.Method;
await _security.LogAsync("permission.denied", "failure",
actorUserId: userId,
failureReason: $"missing_permission:{requiredPermission}",
metadata: new { permissionRequired = requiredPermission, endpoint, method });
context.Fail(new AuthorizationFailureReason(this,
$"missing_permission:{string.Join('|', requirement.PermissionCodes)}"));
}
}

View File

@@ -0,0 +1,35 @@
using Microsoft.AspNetCore.Authorization;
namespace SIGCM2.Api.Authorization;
/// <summary>
/// Authorization attribute that requires the authenticated user to have at least ONE
/// of the declared permission codes assigned to their role (OR semantics).
/// Implements IAuthorizationRequirementData (.NET 8+) so ASP.NET Core builds the policy
/// on-the-fly from GetRequirements() — no AddPolicy() registration needed.
/// </summary>
/// <example>
/// // Single permission
/// [RequirePermission("administracion:usuarios:gestionar")]
///
/// // Multiple — OR semantics: any single match grants access
/// [RequirePermission("ventas:contado:crear", "ventas:ctacte:crear")]
/// </example>
[AttributeUsage(AttributeTargets.Class | AttributeTargets.Method, AllowMultiple = false)]
public sealed class RequirePermissionAttribute
: AuthorizeAttribute, IAuthorizationRequirement, IAuthorizationRequirementData
{
/// <summary>Permission codes required (OR semantics — at least one must match).</summary>
public string[] PermissionCodes { get; }
public RequirePermissionAttribute(params string[] permissionCodes)
{
if (permissionCodes is null || permissionCodes.Length == 0)
throw new ArgumentException("At least one permission code is required.", nameof(permissionCodes));
PermissionCodes = permissionCodes;
}
/// <inheritdoc/>
public IEnumerable<IAuthorizationRequirement> GetRequirements() => new[] { this };
}

View File

@@ -0,0 +1,123 @@
using SIGCM2.Application.IngresosBrutos.Dtos;
using SIGCM2.Application.TiposDeIva.Dtos;
using SIGCM2.Domain.Fiscal;
namespace SIGCM2.Api.Contracts.Fiscal;
// ── IVA Request records ───────────────────────────────────────────────────────
/// <summary>ADM-009: Create TipoDeIva request body.</summary>
public sealed record CreateTipoDeIvaRequest(
string? Codigo,
string? Descripcion,
decimal? Porcentaje,
bool? AplicaIVA,
string? VigenciaDesde,
string? VigenciaHasta = null);
/// <summary>
/// ADM-009: Update TipoDeIva request body — only cosmetic fields.
/// Porcentaje is intentionally absent; any attempt to pass it in the body
/// is detected via raw JSON inspection and returns 409.
/// </summary>
public sealed record UpdateTipoDeIvaRequest(
string? Codigo,
string? Descripcion,
bool? AplicaIVA,
bool? Activo);
/// <summary>ADM-009: Create new TipoDeIva version request body.</summary>
public sealed record NuevaVersionTipoDeIvaRequest(
decimal? Porcentaje,
string? VigenciaDesde);
// ── IIBB Request records ──────────────────────────────────────────────────────
/// <summary>ADM-009: Create IngresosBrutos request body.</summary>
public sealed record CreateIngresosBrutosRequest(
string? Provincia,
string? Descripcion,
decimal? Alicuota,
string? VigenciaDesde,
string? VigenciaHasta = null);
/// <summary>
/// ADM-009: Update IngresosBrutos request body — only cosmetic fields.
/// Alicuota and Provincia are intentionally absent.
/// </summary>
public sealed record UpdateIngresosBrutosRequest(
string? Descripcion,
bool? Activo);
/// <summary>ADM-009: Create new IngresosBrutos version request body.</summary>
public sealed record NuevaVersionIngresosBrutosRequest(
decimal? Alicuota,
string? VigenciaDesde);
// ── Shared Response records ───────────────────────────────────────────────────
/// <summary>ADM-009: Response for nueva-version operations.</summary>
public sealed record NuevaVersionResponse(
int PredecesoraId,
int NuevaVersionId);
// ── Mapper ────────────────────────────────────────────────────────────────────
/// <summary>
/// Maps Application-layer DTOs to API response shapes.
/// Application DTOs are already well-formed for most cases;
/// IIBB Provincia is mapped to its display string for the API.
/// </summary>
public static class FiscalContractMapper
{
public static object ToIvaResponse(TipoDeIvaDto dto) => new
{
dto.Id,
dto.Codigo,
dto.Descripcion,
dto.Porcentaje,
dto.AplicaIVA,
dto.Activo,
dto.VigenciaDesde,
dto.VigenciaHasta,
dto.PredecesorId,
dto.FechaCreacion,
dto.FechaModificacion
};
public static object ToIibbResponse(IngresosBrutosDto dto) => new
{
dto.Id,
Provincia = dto.Provincia.ToDisplayString(),
dto.Descripcion,
dto.Alicuota,
dto.Activo,
dto.VigenciaDesde,
dto.VigenciaHasta,
dto.PredecesorId,
dto.FechaCreacion,
dto.FechaModificacion
};
public static object ToHistorialIvaResponse(HistorialCadenaDto dto) => new
{
dto.Id,
dto.Codigo,
dto.Porcentaje,
dto.VigenciaDesde,
dto.VigenciaHasta,
dto.PredecesorId,
dto.Version
};
public static object ToHistorialIibbResponse(HistorialCadenaIibbDto dto) => new
{
dto.Id,
Provincia = dto.Provincia.ToDisplayString(),
dto.Alicuota,
dto.VigenciaDesde,
dto.VigenciaHasta,
dto.PredecesorId,
dto.Version
};
}

View File

@@ -0,0 +1,63 @@
using Microsoft.AspNetCore.Mvc;
using SIGCM2.Api.Authorization;
using SIGCM2.Application.Audit;
namespace SIGCM2.Api.Controllers;
/// <summary>
/// UDT-010: Read-only endpoint for audit events. Requires administracion:auditoria:ver.
/// Cursor-based DESC pagination with 4 filter axes (actor/target/from/to).
/// Rich UI (drilldown, export CSV, timeline) is deferred to ADM-004.
/// </summary>
[ApiController]
[Route("api/v1/audit")]
public sealed class AuditController : ControllerBase
{
private readonly IAuditEventRepository _repo;
public AuditController(IAuditEventRepository repo)
{
_repo = repo;
}
/// <summary>Lists audit events with optional filters. Cursor-based DESC pagination.</summary>
[HttpGet("events")]
[RequirePermission("administracion:auditoria:ver")]
[ProducesResponseType(typeof(AuditEventPageResponse), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status400BadRequest)]
[ProducesResponseType(StatusCodes.Status401Unauthorized)]
[ProducesResponseType(StatusCodes.Status403Forbidden)]
public async Task<IActionResult> GetEvents(
[FromQuery] int? actorUserId = null,
[FromQuery] string? targetType = null,
[FromQuery] string? targetId = null,
[FromQuery] DateTime? from = null,
[FromQuery] DateTime? to = null,
[FromQuery] string? cursor = null,
[FromQuery] int limit = 50,
CancellationToken ct = default)
{
if (limit < 1 || limit > 100)
return BadRequest(new { error = "limit must be between 1 and 100" });
if (from is not null && to is not null && from > to)
return BadRequest(new { error = "from must be <= to" });
var filter = new AuditEventFilter(
ActorUserId: actorUserId,
TargetType: targetType,
TargetId: targetId,
From: from,
To: to,
Cursor: cursor,
Limit: limit);
var result = await _repo.QueryAsync(filter, ct);
return Ok(new AuditEventPageResponse(result.Items, result.NextCursor));
}
}
/// <summary>UDT-010: Paginated response wrapper for GET /api/v1/audit/events.</summary>
public sealed record AuditEventPageResponse(
IReadOnlyList<AuditEventDto> Items,
string? NextCursor);

View File

@@ -0,0 +1,104 @@
using System.IdentityModel.Tokens.Jwt;
using FluentValidation;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using SIGCM2.Application.Abstractions;
using SIGCM2.Application.Auth.Login;
using SIGCM2.Application.Auth.Logout;
using SIGCM2.Application.Auth.Refresh;
namespace SIGCM2.Api.Controllers;
[ApiController]
[Route("api/v1/auth")]
public sealed class AuthController : ControllerBase
{
private readonly IDispatcher _dispatcher;
private readonly IValidator<LoginCommand> _loginValidator;
private readonly IValidator<RefreshCommand> _refreshValidator;
public AuthController(
IDispatcher dispatcher,
IValidator<LoginCommand> loginValidator,
IValidator<RefreshCommand> refreshValidator)
{
_dispatcher = dispatcher;
_loginValidator = loginValidator;
_refreshValidator = refreshValidator;
}
/// <summary>Authenticates a user and returns a JWT access token + refresh token.</summary>
[HttpPost("login")]
[AllowAnonymous]
[ProducesResponseType(typeof(LoginResponseDto), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status400BadRequest)]
[ProducesResponseType(StatusCodes.Status401Unauthorized)]
public async Task<IActionResult> Login([FromBody] LoginRequest request)
{
var command = new LoginCommand(request.Username ?? string.Empty, request.Password ?? string.Empty);
var validation = await _loginValidator.ValidateAsync(command);
if (!validation.IsValid)
{
var errors = validation.Errors
.GroupBy(e => e.PropertyName)
.ToDictionary(g => g.Key, g => g.Select(e => e.ErrorMessage).ToArray());
return BadRequest(new { errors });
}
var result = await _dispatcher.Send<LoginCommand, LoginResponseDto>(command);
return Ok(result);
}
/// <summary>
/// Rotates a refresh token pair. Accepts an expired access token to extract the user identity.
/// Returns a new access + refresh token pair. Does NOT require Authorization header.
/// </summary>
[HttpPost("refresh")]
[AllowAnonymous]
[ProducesResponseType(typeof(RefreshResponseDto), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status400BadRequest)]
[ProducesResponseType(StatusCodes.Status401Unauthorized)]
public async Task<IActionResult> Refresh([FromBody] RefreshRequest request)
{
var command = new RefreshCommand(
request.AccessToken ?? string.Empty,
request.RefreshToken ?? string.Empty);
var validation = await _refreshValidator.ValidateAsync(command);
if (!validation.IsValid)
{
var errors = validation.Errors
.GroupBy(e => e.PropertyName)
.ToDictionary(g => g.Key, g => g.Select(e => e.ErrorMessage).ToArray());
return BadRequest(new { errors });
}
var result = await _dispatcher.Send<RefreshCommand, RefreshResponseDto>(command);
return Ok(result);
}
/// <summary>
/// Revokes all active refresh tokens for the authenticated user.
/// Requires a valid Bearer access token. Client must discard local tokens after this call.
/// </summary>
[HttpPost("logout")]
[Authorize]
[ProducesResponseType(typeof(LogoutResponseDto), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status401Unauthorized)]
public async Task<IActionResult> Logout()
{
var sub = User.FindFirst(JwtRegisteredClaimNames.Sub)?.Value;
if (!int.TryParse(sub, out var userId))
return Unauthorized();
var result = await _dispatcher.Send<LogoutCommand, LogoutResponseDto>(new LogoutCommand(userId));
return Ok(result);
}
}
/// <summary>Login request body — nullable to catch missing field scenarios.</summary>
public sealed record LoginRequest(string? Username, string? Password);
/// <summary>Refresh request body.</summary>
public sealed record RefreshRequest(string? AccessToken, string? RefreshToken);

View File

@@ -0,0 +1,247 @@
using FluentValidation;
using Microsoft.AspNetCore.Mvc;
using SIGCM2.Api.Authorization;
using SIGCM2.Application.Abstractions;
using SIGCM2.Application.Common;
using SIGCM2.Application.Pricing.ChargeableChars;
using SIGCM2.Application.Pricing.ChargeableChars.Create;
using SIGCM2.Application.Pricing.ChargeableChars.Deactivate;
using SIGCM2.Application.Pricing.ChargeableChars.Delete;
using SIGCM2.Application.Pricing.ChargeableChars.GetById;
using SIGCM2.Application.Pricing.ChargeableChars.List;
using SIGCM2.Application.Pricing.ChargeableChars.Reactivate;
using SIGCM2.Application.Pricing.ChargeableChars.SchedulePrice;
namespace SIGCM2.Api.Controllers;
/// <summary>
/// PRC-001: Admin endpoints for ChargeableCharConfig management.
/// All endpoints require 'tasacion:caracteres_especiales:gestionar'.
/// Route base: api/v1/admin/chargeable-chars
/// </summary>
[ApiController]
[Route("api/v1/admin/chargeable-chars")]
public sealed class ChargeableCharConfigController : ControllerBase
{
private readonly IDispatcher _dispatcher;
private readonly IValidator<CreateChargeableCharConfigCommand> _createValidator;
private readonly IValidator<SchedulePriceChangeCommand> _scheduleValidator;
public ChargeableCharConfigController(
IDispatcher dispatcher,
IValidator<CreateChargeableCharConfigCommand> createValidator,
IValidator<SchedulePriceChangeCommand> scheduleValidator)
{
_dispatcher = dispatcher;
_createValidator = createValidator;
_scheduleValidator = scheduleValidator;
}
// ── GET /api/v1/admin/chargeable-chars ────────────────────────────────────
/// <summary>
/// Returns a paginated list of ChargeableCharConfig rows.
/// Filters: productTypeId (optional, long?), activeOnly (bool, default true).
/// Pagination: skip/take model mapped to page/pageSize — or use page/pageSize directly.
/// Defaults: page=1, pageSize=20. Clamped: pageSize max 200.
/// </summary>
[HttpGet]
[RequirePermission("tasacion:caracteres_especiales:gestionar")]
[ProducesResponseType(typeof(PagedResult<ChargeableCharConfigDto>), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status401Unauthorized)]
[ProducesResponseType(StatusCodes.Status403Forbidden)]
public async Task<IActionResult> List(
[FromQuery] long? productTypeId,
[FromQuery] bool activeOnly = true,
[FromQuery] int? page = null,
[FromQuery] int? pageSize = null,
[FromQuery] int? skip = null,
[FromQuery] int? take = null)
{
// Support both page/pageSize and skip/take query patterns
int resolvedPage;
int resolvedPageSize;
if (skip is not null || take is not null)
{
// Convert skip/take to page/pageSize
resolvedPageSize = Math.Min(take ?? 50, 200);
resolvedPage = resolvedPageSize > 0
? ((skip ?? 0) / resolvedPageSize) + 1
: 1;
}
else
{
resolvedPage = page ?? 1;
resolvedPageSize = Math.Min(pageSize ?? 20, 200);
}
var query = new ListChargeableCharConfigQuery(productTypeId, activeOnly, resolvedPage, resolvedPageSize);
var result = await _dispatcher.Send<ListChargeableCharConfigQuery, PagedResult<ChargeableCharConfigDto>>(query);
return Ok(result);
}
// ── GET /api/v1/admin/chargeable-chars/{id} ───────────────────────────────
/// <summary>
/// Returns a single ChargeableCharConfig by Id. Returns 404 if not found.
/// </summary>
[HttpGet("{id:long}")]
[RequirePermission("tasacion:caracteres_especiales:gestionar")]
[ProducesResponseType(typeof(ChargeableCharConfigDto), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status401Unauthorized)]
[ProducesResponseType(StatusCodes.Status403Forbidden)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<IActionResult> GetById([FromRoute] long id)
{
var result = await _dispatcher.Send<GetChargeableCharConfigByIdQuery, ChargeableCharConfigDto?>(
new GetChargeableCharConfigByIdQuery(id));
return result is null ? NotFound() : Ok(result);
}
// ── POST /api/v1/admin/chargeable-chars ───────────────────────────────────
/// <summary>
/// Creates a new ChargeableCharConfig row. Closes the current active row for (ProductTypeId, Symbol) if one exists.
/// Returns 201 Created with Location header pointing to GET /{id}.
/// </summary>
[HttpPost]
[RequirePermission("tasacion:caracteres_especiales:gestionar")]
[ProducesResponseType(typeof(CreateChargeableCharConfigResponse), StatusCodes.Status201Created)]
[ProducesResponseType(StatusCodes.Status400BadRequest)]
[ProducesResponseType(StatusCodes.Status401Unauthorized)]
[ProducesResponseType(StatusCodes.Status403Forbidden)]
[ProducesResponseType(StatusCodes.Status409Conflict)]
public async Task<IActionResult> Create([FromBody] CreateChargeableCharConfigRequest request)
{
var command = new CreateChargeableCharConfigCommand(
request.ProductTypeId,
request.Symbol,
request.Category,
request.PricePerUnit,
request.ValidFrom);
var validation = await _createValidator.ValidateAsync(command);
if (!validation.IsValid)
{
var errors = validation.Errors
.GroupBy(e => e.PropertyName)
.ToDictionary(g => g.Key, g => g.Select(e => e.ErrorMessage).ToArray());
return BadRequest(new { errors });
}
var result = await _dispatcher.Send<CreateChargeableCharConfigCommand, CreateChargeableCharConfigResponse>(command);
return CreatedAtAction(nameof(GetById), new { id = result.Id }, result);
}
// ── PUT /api/v1/admin/chargeable-chars/{id}/price ────────────────────────
/// <summary>
/// Schedules a price change for an existing ChargeableCharConfig.
/// Closes the current active row and opens a new one with the new price + ValidFrom.
/// ValidFrom must be strictly greater than the existing row's ValidFrom (forward-only).
/// </summary>
[HttpPut("{id:long}/price")]
[RequirePermission("tasacion:caracteres_especiales:gestionar")]
[ProducesResponseType(typeof(SchedulePriceChangeResponse), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status400BadRequest)]
[ProducesResponseType(StatusCodes.Status401Unauthorized)]
[ProducesResponseType(StatusCodes.Status403Forbidden)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
[ProducesResponseType(StatusCodes.Status409Conflict)]
public async Task<IActionResult> SchedulePriceChange(
[FromRoute] long id,
[FromBody] SchedulePriceChangeRequest request)
{
var command = new SchedulePriceChangeCommand(id, request.PricePerUnit, request.ValidFrom);
var validation = await _scheduleValidator.ValidateAsync(command);
if (!validation.IsValid)
{
var errors = validation.Errors
.GroupBy(e => e.PropertyName)
.ToDictionary(g => g.Key, g => g.Select(e => e.ErrorMessage).ToArray());
return BadRequest(new { errors });
}
var result = await _dispatcher.Send<SchedulePriceChangeCommand, SchedulePriceChangeResponse>(command);
return Ok(result);
}
// ── PATCH /api/v1/admin/chargeable-chars/{id}/deactivate ─────────────────
/// <summary>
/// Deactivates a ChargeableCharConfig row (sets IsActive=false, ValidTo=today_AR).
/// Idempotent: calling on an already-inactive row is a no-op.
/// </summary>
[HttpPatch("{id:long}/deactivate")]
[RequirePermission("tasacion:caracteres_especiales:gestionar")]
[ProducesResponseType(typeof(DeactivateChargeableCharConfigResponse), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status401Unauthorized)]
[ProducesResponseType(StatusCodes.Status403Forbidden)]
public async Task<IActionResult> Deactivate([FromRoute] long id)
{
var result = await _dispatcher.Send<DeactivateChargeableCharConfigCommand, DeactivateChargeableCharConfigResponse>(
new DeactivateChargeableCharConfigCommand(id));
return Ok(result);
}
// ── PATCH /api/v1/admin/chargeable-chars/{id}/reactivate ─────────────────
/// <summary>
/// Reactivates a previously closed ChargeableCharConfig row (undo last deactivation).
/// Guard rules (enforced by SP):
/// - ALREADY_ACTIVE: target row is already active → 409
/// - VIGENTE_EXISTS: a different active row exists for (ProductTypeId, Symbol) → 409
/// - POSTERIOR_ROWS_EXIST: rows with higher ValidFrom exist after the target → 409
/// </summary>
[HttpPatch("{id:long}/reactivate")]
[RequirePermission("tasacion:caracteres_especiales:gestionar")]
[ProducesResponseType(typeof(ReactivateChargeableCharConfigResponse), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status401Unauthorized)]
[ProducesResponseType(StatusCodes.Status403Forbidden)]
[ProducesResponseType(StatusCodes.Status409Conflict)]
public async Task<IActionResult> Reactivate([FromRoute] long id)
{
var result = await _dispatcher.Send<ReactivateChargeableCharConfigCommand, ReactivateChargeableCharConfigResponse>(
new ReactivateChargeableCharConfigCommand(id));
return Ok(result);
}
// ── DELETE /api/v1/admin/chargeable-chars/{id} ───────────────────────────
/// <summary>
/// Deletes a ChargeableCharConfig row.
/// NOTE: With SYSTEM_VERSIONING ON, the row is moved to the history table (temporal audit preserved).
/// The row disappears from all current-state queries.
/// Guard for "used in invoicing" is deferred to FAC-001 followup issue.
/// Returns 200 + { id } consistent with the Deactivate pattern.
/// </summary>
[HttpDelete("{id:long}")]
[RequirePermission("tasacion:caracteres_especiales:gestionar")]
[ProducesResponseType(typeof(DeleteChargeableCharConfigResponse), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status401Unauthorized)]
[ProducesResponseType(StatusCodes.Status403Forbidden)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<IActionResult> Delete([FromRoute] long id)
{
var result = await _dispatcher.Send<DeleteChargeableCharConfigCommand, DeleteChargeableCharConfigResponse>(
new DeleteChargeableCharConfigCommand(id));
return Ok(result);
}
}
// ── Request body records ──────────────────────────────────────────────────────
/// <summary>PRC-001: Create ChargeableCharConfig request body.</summary>
public sealed record CreateChargeableCharConfigRequest(
long? ProductTypeId,
string Symbol,
string Category,
decimal PricePerUnit,
DateOnly ValidFrom);
/// <summary>PRC-001: Schedule price change request body.</summary>
public sealed record SchedulePriceChangeRequest(
decimal PricePerUnit,
DateOnly ValidFrom);

View File

@@ -0,0 +1,576 @@
using System.Text.Json;
using FluentValidation;
using Microsoft.AspNetCore.Mvc;
using SIGCM2.Api.Authorization;
using SIGCM2.Api.Contracts.Fiscal;
using SIGCM2.Application.Abstractions;
using SIGCM2.Application.Common;
using SIGCM2.Application.IngresosBrutos.Create;
using SIGCM2.Application.IngresosBrutos.Deactivate;
using SIGCM2.Application.IngresosBrutos.Dtos;
using SIGCM2.Application.IngresosBrutos.GetById;
using SIGCM2.Application.IngresosBrutos.GetHistorial;
using SIGCM2.Application.IngresosBrutos.List;
using SIGCM2.Application.IngresosBrutos.NuevaVersion;
using SIGCM2.Application.IngresosBrutos.Reactivate;
using SIGCM2.Application.IngresosBrutos.Update;
using SIGCM2.Application.TiposDeIva.Create;
using SIGCM2.Application.TiposDeIva.Deactivate;
using SIGCM2.Application.TiposDeIva.Dtos;
using SIGCM2.Application.TiposDeIva.GetById;
using SIGCM2.Application.TiposDeIva.GetHistorial;
using SIGCM2.Application.TiposDeIva.List;
using SIGCM2.Application.TiposDeIva.NuevaVersion;
using SIGCM2.Application.TiposDeIva.Reactivate;
using SIGCM2.Application.TiposDeIva.Update;
using SIGCM2.Domain.Exceptions;
using SIGCM2.Domain.Fiscal;
namespace SIGCM2.Api.Controllers;
/// <summary>
/// ADM-009: Tablas Fiscales — IVA + IngresosBrutos endpoints at /api/v1/admin/fiscal.
/// All endpoints require permission 'administracion:fiscal:gestionar'.
/// </summary>
[ApiController]
[Route("api/v1/admin/fiscal")]
public sealed class FiscalController : ControllerBase
{
private readonly IDispatcher _dispatcher;
private readonly IValidator<CreateTipoDeIvaCommand> _createIvaValidator;
private readonly IValidator<UpdateTipoDeIvaCommand> _updateIvaValidator;
private readonly IValidator<NuevaVersionTipoDeIvaCommand> _nuevaVersionIvaValidator;
private readonly IValidator<CreateIngresosBrutosCommand> _createIibbValidator;
private readonly IValidator<UpdateIngresosBrutosCommand> _updateIibbValidator;
private readonly IValidator<NuevaVersionIngresosBrutosCommand> _nuevaVersionIibbValidator;
public FiscalController(
IDispatcher dispatcher,
IValidator<CreateTipoDeIvaCommand> createIvaValidator,
IValidator<UpdateTipoDeIvaCommand> updateIvaValidator,
IValidator<NuevaVersionTipoDeIvaCommand> nuevaVersionIvaValidator,
IValidator<CreateIngresosBrutosCommand> createIibbValidator,
IValidator<UpdateIngresosBrutosCommand> updateIibbValidator,
IValidator<NuevaVersionIngresosBrutosCommand> nuevaVersionIibbValidator)
{
_dispatcher = dispatcher;
_createIvaValidator = createIvaValidator;
_updateIvaValidator = updateIvaValidator;
_nuevaVersionIvaValidator = nuevaVersionIvaValidator;
_createIibbValidator = createIibbValidator;
_updateIibbValidator = updateIibbValidator;
_nuevaVersionIibbValidator = nuevaVersionIibbValidator;
}
// ══════════════════════════════════════════════════════════════════════════
// IVA endpoints
// ══════════════════════════════════════════════════════════════════════════
/// <summary>Lists TiposDeIva with optional filters. Requires administracion:fiscal:gestionar.</summary>
[HttpGet("iva")]
[RequirePermission("administracion:fiscal:gestionar")]
[ProducesResponseType(StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status401Unauthorized)]
[ProducesResponseType(StatusCodes.Status403Forbidden)]
public async Task<IActionResult> ListIva(
[FromQuery] int page = 1,
[FromQuery] int pageSize = 20,
[FromQuery] bool? activo = null,
[FromQuery] string? codigo = null)
{
if (page < 1) return BadRequest(new { error = "page must be >= 1" });
if (pageSize < 1) return BadRequest(new { error = "pageSize must be >= 1" });
var query = new ListTiposDeIvaQuery(page, pageSize, activo, codigo);
var result = await _dispatcher.Send<ListTiposDeIvaQuery, PagedResult<TipoDeIvaDto>>(query);
return Ok(new
{
Items = result.Items.Select(FiscalContractMapper.ToIvaResponse).ToList(),
result.Page,
result.PageSize,
result.Total
});
}
/// <summary>Gets a single TipoDeIva by id.</summary>
[HttpGet("iva/{id:int}")]
[RequirePermission("administracion:fiscal:gestionar")]
[ProducesResponseType(StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status401Unauthorized)]
[ProducesResponseType(StatusCodes.Status403Forbidden)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<IActionResult> GetIvaById([FromRoute] int id)
{
var query = new GetTipoDeIvaByIdQuery(id);
var result = await _dispatcher.Send<GetTipoDeIvaByIdQuery, TipoDeIvaDto>(query);
return Ok(FiscalContractMapper.ToIvaResponse(result));
}
/// <summary>Gets the full version chain for a TipoDeIva.</summary>
[HttpGet("iva/{id:int}/historial")]
[RequirePermission("administracion:fiscal:gestionar")]
[ProducesResponseType(StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status401Unauthorized)]
[ProducesResponseType(StatusCodes.Status403Forbidden)]
public async Task<IActionResult> GetHistorialIva([FromRoute] int id)
{
var query = new GetHistorialTipoDeIvaQuery(id);
var result = await _dispatcher.Send<GetHistorialTipoDeIvaQuery, IReadOnlyList<HistorialCadenaDto>>(query);
return Ok(result.Select(FiscalContractMapper.ToHistorialIvaResponse).ToList());
}
/// <summary>Creates a new TipoDeIva. Returns 201 on success.</summary>
[HttpPost("iva")]
[RequirePermission("administracion:fiscal:gestionar")]
[ProducesResponseType(StatusCodes.Status201Created)]
[ProducesResponseType(StatusCodes.Status400BadRequest)]
[ProducesResponseType(StatusCodes.Status401Unauthorized)]
[ProducesResponseType(StatusCodes.Status403Forbidden)]
[ProducesResponseType(StatusCodes.Status409Conflict)]
public async Task<IActionResult> CreateIva([FromBody] CreateTipoDeIvaRequest request)
{
var vigenciaDesde = ParseDateOnly(request.VigenciaDesde, "vigenciaDesde");
if (vigenciaDesde is null)
return BadRequest(new { error = "vigenciaDesde must be a valid date (yyyy-MM-dd)" });
DateOnly? vigenciaHasta = null;
if (request.VigenciaHasta is not null)
{
vigenciaHasta = ParseDateOnly(request.VigenciaHasta, "vigenciaHasta");
if (vigenciaHasta is null)
return BadRequest(new { error = "vigenciaHasta must be a valid date (yyyy-MM-dd)" });
}
var command = new CreateTipoDeIvaCommand(
Codigo: request.Codigo ?? string.Empty,
Descripcion: request.Descripcion ?? string.Empty,
Porcentaje: request.Porcentaje ?? 0m,
AplicaIVA: request.AplicaIVA ?? false,
VigenciaDesde: vigenciaDesde.Value,
VigenciaHasta: vigenciaHasta);
var validation = await _createIvaValidator.ValidateAsync(command);
if (!validation.IsValid)
{
var errors = validation.Errors
.GroupBy(e => e.PropertyName)
.ToDictionary(g => g.Key, g => g.Select(e => e.ErrorMessage).ToArray());
return BadRequest(new { errors });
}
var result = await _dispatcher.Send<CreateTipoDeIvaCommand, TipoDeIvaDto>(command);
return CreatedAtAction(nameof(GetIvaById), new { id = result.Id }, FiscalContractMapper.ToIvaResponse(result));
}
/// <summary>
/// Updates cosmetic fields of a TipoDeIva (Codigo, Descripcion, AplicaIVA, Activo).
/// IMPORTANT: if the raw body contains "porcentaje" (case-insensitive) → 409 inmutable_usar_nueva_version.
/// </summary>
[HttpPatch("iva/{id:int}")]
[RequirePermission("administracion:fiscal:gestionar")]
[ProducesResponseType(StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status400BadRequest)]
[ProducesResponseType(StatusCodes.Status401Unauthorized)]
[ProducesResponseType(StatusCodes.Status403Forbidden)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
[ProducesResponseType(StatusCodes.Status409Conflict)]
public async Task<IActionResult> UpdateIva([FromRoute] int id)
{
// Read raw body to detect immutable-field tampering before deserialization
Request.EnableBuffering();
using var reader = new StreamReader(Request.Body, leaveOpen: true);
var rawBody = await reader.ReadToEndAsync();
Request.Body.Position = 0;
// Defend against porcentaje in body — must return 409 before dispatch
if (ContainsImmutableField(rawBody, "porcentaje"))
throw new PorcentajeInmutableException();
UpdateTipoDeIvaRequest? request;
try
{
request = JsonSerializer.Deserialize<UpdateTipoDeIvaRequest>(rawBody,
new JsonSerializerOptions { PropertyNameCaseInsensitive = true });
}
catch (JsonException)
{
return BadRequest(new { error = "Invalid JSON body" });
}
if (request is null)
return BadRequest(new { error = "Request body is required" });
var command = new UpdateTipoDeIvaCommand(
Id: id,
Codigo: request.Codigo ?? string.Empty,
Descripcion: request.Descripcion ?? string.Empty,
AplicaIVA: request.AplicaIVA ?? false,
Activo: request.Activo ?? true);
var validation = await _updateIvaValidator.ValidateAsync(command);
if (!validation.IsValid)
{
var errors = validation.Errors
.GroupBy(e => e.PropertyName)
.ToDictionary(g => g.Key, g => g.Select(e => e.ErrorMessage).ToArray());
return BadRequest(new { errors });
}
var result = await _dispatcher.Send<UpdateTipoDeIvaCommand, TipoDeIvaDto>(command);
return Ok(FiscalContractMapper.ToIvaResponse(result));
}
/// <summary>Creates a new version of a TipoDeIva (closes the predecessor). Returns 201.</summary>
[HttpPost("iva/{id:int}/nueva-version")]
[RequirePermission("administracion:fiscal:gestionar")]
[ProducesResponseType(StatusCodes.Status201Created)]
[ProducesResponseType(StatusCodes.Status400BadRequest)]
[ProducesResponseType(StatusCodes.Status401Unauthorized)]
[ProducesResponseType(StatusCodes.Status403Forbidden)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
[ProducesResponseType(StatusCodes.Status409Conflict)]
public async Task<IActionResult> NuevaVersionIva(
[FromRoute] int id,
[FromBody] NuevaVersionTipoDeIvaRequest request)
{
var vigenciaDesde = ParseDateOnly(request.VigenciaDesde, "vigenciaDesde");
if (vigenciaDesde is null)
return BadRequest(new { error = "vigenciaDesde must be a valid date (yyyy-MM-dd)" });
var command = new NuevaVersionTipoDeIvaCommand(
PredecesoraId: id,
NuevoPorcentaje: request.Porcentaje ?? 0m,
VigenciaDesde: vigenciaDesde.Value);
var validation = await _nuevaVersionIvaValidator.ValidateAsync(command);
if (!validation.IsValid)
{
var errors = validation.Errors
.GroupBy(e => e.PropertyName)
.ToDictionary(g => g.Key, g => g.Select(e => e.ErrorMessage).ToArray());
return BadRequest(new { errors });
}
var result = await _dispatcher.Send<NuevaVersionTipoDeIvaCommand, SIGCM2.Application.TiposDeIva.Dtos.NuevaVersionResultDto>(command);
return CreatedAtAction(
nameof(GetIvaById),
new { id = result.NuevaVersionId },
new NuevaVersionResponse(result.PredecesoraId, result.NuevaVersionId));
}
/// <summary>Deactivates a TipoDeIva. Idempotent.</summary>
[HttpPost("iva/{id:int}/deactivate")]
[RequirePermission("administracion:fiscal:gestionar")]
[ProducesResponseType(StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status401Unauthorized)]
[ProducesResponseType(StatusCodes.Status403Forbidden)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<IActionResult> DeactivateIva([FromRoute] int id)
{
var command = new DeactivateTipoDeIvaCommand(id);
var result = await _dispatcher.Send<DeactivateTipoDeIvaCommand, TipoDeIvaDto>(command);
return Ok(FiscalContractMapper.ToIvaResponse(result));
}
/// <summary>Reactivates a TipoDeIva. Idempotent.</summary>
[HttpPost("iva/{id:int}/reactivate")]
[RequirePermission("administracion:fiscal:gestionar")]
[ProducesResponseType(StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status401Unauthorized)]
[ProducesResponseType(StatusCodes.Status403Forbidden)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<IActionResult> ReactivateIva([FromRoute] int id)
{
var command = new ReactivateTipoDeIvaCommand(id);
var result = await _dispatcher.Send<ReactivateTipoDeIvaCommand, TipoDeIvaDto>(command);
return Ok(FiscalContractMapper.ToIvaResponse(result));
}
// ══════════════════════════════════════════════════════════════════════════
// IngresosBrutos endpoints
// ══════════════════════════════════════════════════════════════════════════
/// <summary>Lists IngresosBrutos with optional filters.</summary>
[HttpGet("iibb")]
[RequirePermission("administracion:fiscal:gestionar")]
[ProducesResponseType(StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status401Unauthorized)]
[ProducesResponseType(StatusCodes.Status403Forbidden)]
public async Task<IActionResult> ListIibb(
[FromQuery] int page = 1,
[FromQuery] int pageSize = 20,
[FromQuery] bool? activo = null,
[FromQuery] string? provincia = null)
{
if (page < 1) return BadRequest(new { error = "page must be >= 1" });
if (pageSize < 1) return BadRequest(new { error = "pageSize must be >= 1" });
ProvinciaArgentina? provinciaEnum = null;
if (provincia is not null)
{
if (!Enum.TryParse<ProvinciaArgentina>(provincia, ignoreCase: true, out var parsed))
return BadRequest(new { error = $"'{provincia}' is not a valid ProvinciaArgentina value." });
provinciaEnum = parsed;
}
var query = new ListIngresosBrutosQuery(page, pageSize, activo, provinciaEnum);
var result = await _dispatcher.Send<ListIngresosBrutosQuery, PagedResult<IngresosBrutosDto>>(query);
return Ok(new
{
Items = result.Items.Select(FiscalContractMapper.ToIibbResponse).ToList(),
result.Page,
result.PageSize,
result.Total
});
}
/// <summary>Gets a single IngresosBrutos by id.</summary>
[HttpGet("iibb/{id:int}")]
[RequirePermission("administracion:fiscal:gestionar")]
[ProducesResponseType(StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status401Unauthorized)]
[ProducesResponseType(StatusCodes.Status403Forbidden)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<IActionResult> GetIibbById([FromRoute] int id)
{
var query = new GetIngresosBrutosByIdQuery(id);
var result = await _dispatcher.Send<GetIngresosBrutosByIdQuery, IngresosBrutosDto>(query);
return Ok(FiscalContractMapper.ToIibbResponse(result));
}
/// <summary>Gets the full version chain for an IngresosBrutos entry.</summary>
[HttpGet("iibb/{id:int}/historial")]
[RequirePermission("administracion:fiscal:gestionar")]
[ProducesResponseType(StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status401Unauthorized)]
[ProducesResponseType(StatusCodes.Status403Forbidden)]
public async Task<IActionResult> GetHistorialIibb([FromRoute] int id)
{
var query = new GetHistorialIngresosBrutosQuery(id);
var result = await _dispatcher.Send<GetHistorialIngresosBrutosQuery, IReadOnlyList<HistorialCadenaIibbDto>>(query);
return Ok(result.Select(FiscalContractMapper.ToHistorialIibbResponse).ToList());
}
/// <summary>Creates a new IngresosBrutos entry. Returns 201 on success.</summary>
[HttpPost("iibb")]
[RequirePermission("administracion:fiscal:gestionar")]
[ProducesResponseType(StatusCodes.Status201Created)]
[ProducesResponseType(StatusCodes.Status400BadRequest)]
[ProducesResponseType(StatusCodes.Status401Unauthorized)]
[ProducesResponseType(StatusCodes.Status403Forbidden)]
[ProducesResponseType(StatusCodes.Status409Conflict)]
public async Task<IActionResult> CreateIibb([FromBody] CreateIngresosBrutosRequest request)
{
if (request.Provincia is null)
return BadRequest(new { error = "provincia is required" });
// Accept enum name (PascalCase) or display string
ProvinciaArgentina provinciaEnum;
if (Enum.TryParse<ProvinciaArgentina>(request.Provincia, ignoreCase: true, out var parsedEnum))
{
provinciaEnum = parsedEnum;
}
else
{
try
{
provinciaEnum = ProvinciaArgentinaExtensions.FromDisplayString(request.Provincia);
}
catch (ArgumentException)
{
return BadRequest(new { error = $"'{request.Provincia}' is not a valid provincia. Use enum name or display string." });
}
}
var vigenciaDesde = ParseDateOnly(request.VigenciaDesde, "vigenciaDesde");
if (vigenciaDesde is null)
return BadRequest(new { error = "vigenciaDesde must be a valid date (yyyy-MM-dd)" });
DateOnly? vigenciaHasta = null;
if (request.VigenciaHasta is not null)
{
vigenciaHasta = ParseDateOnly(request.VigenciaHasta, "vigenciaHasta");
if (vigenciaHasta is null)
return BadRequest(new { error = "vigenciaHasta must be a valid date (yyyy-MM-dd)" });
}
var command = new CreateIngresosBrutosCommand(
Provincia: provinciaEnum,
Descripcion: request.Descripcion ?? string.Empty,
Alicuota: request.Alicuota ?? 0m,
VigenciaDesde: vigenciaDesde.Value,
VigenciaHasta: vigenciaHasta);
var validation = await _createIibbValidator.ValidateAsync(command);
if (!validation.IsValid)
{
var errors = validation.Errors
.GroupBy(e => e.PropertyName)
.ToDictionary(g => g.Key, g => g.Select(e => e.ErrorMessage).ToArray());
return BadRequest(new { errors });
}
var result = await _dispatcher.Send<CreateIngresosBrutosCommand, IngresosBrutosDto>(command);
return CreatedAtAction(nameof(GetIibbById), new { id = result.Id }, FiscalContractMapper.ToIibbResponse(result));
}
/// <summary>
/// Updates cosmetic fields of IngresosBrutos (Descripcion, Activo).
/// IMPORTANT: if the raw body contains "alicuota" (case-insensitive) → 409 inmutable_usar_nueva_version.
/// </summary>
[HttpPatch("iibb/{id:int}")]
[RequirePermission("administracion:fiscal:gestionar")]
[ProducesResponseType(StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status400BadRequest)]
[ProducesResponseType(StatusCodes.Status401Unauthorized)]
[ProducesResponseType(StatusCodes.Status403Forbidden)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
[ProducesResponseType(StatusCodes.Status409Conflict)]
public async Task<IActionResult> UpdateIibb([FromRoute] int id)
{
Request.EnableBuffering();
using var reader = new StreamReader(Request.Body, leaveOpen: true);
var rawBody = await reader.ReadToEndAsync();
Request.Body.Position = 0;
if (ContainsImmutableField(rawBody, "alicuota"))
throw new AlicuotaInmutableException();
UpdateIngresosBrutosRequest? request;
try
{
request = JsonSerializer.Deserialize<UpdateIngresosBrutosRequest>(rawBody,
new JsonSerializerOptions { PropertyNameCaseInsensitive = true });
}
catch (JsonException)
{
return BadRequest(new { error = "Invalid JSON body" });
}
if (request is null)
return BadRequest(new { error = "Request body is required" });
var command = new UpdateIngresosBrutosCommand(
Id: id,
Descripcion: request.Descripcion ?? string.Empty,
Activo: request.Activo ?? true);
var validation = await _updateIibbValidator.ValidateAsync(command);
if (!validation.IsValid)
{
var errors = validation.Errors
.GroupBy(e => e.PropertyName)
.ToDictionary(g => g.Key, g => g.Select(e => e.ErrorMessage).ToArray());
return BadRequest(new { errors });
}
var result = await _dispatcher.Send<UpdateIngresosBrutosCommand, IngresosBrutosDto>(command);
return Ok(FiscalContractMapper.ToIibbResponse(result));
}
/// <summary>Creates a new version of IngresosBrutos (closes the predecessor). Returns 201.</summary>
[HttpPost("iibb/{id:int}/nueva-version")]
[RequirePermission("administracion:fiscal:gestionar")]
[ProducesResponseType(StatusCodes.Status201Created)]
[ProducesResponseType(StatusCodes.Status400BadRequest)]
[ProducesResponseType(StatusCodes.Status401Unauthorized)]
[ProducesResponseType(StatusCodes.Status403Forbidden)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
[ProducesResponseType(StatusCodes.Status409Conflict)]
public async Task<IActionResult> NuevaVersionIibb(
[FromRoute] int id,
[FromBody] NuevaVersionIngresosBrutosRequest request)
{
var vigenciaDesde = ParseDateOnly(request.VigenciaDesde, "vigenciaDesde");
if (vigenciaDesde is null)
return BadRequest(new { error = "vigenciaDesde must be a valid date (yyyy-MM-dd)" });
var command = new NuevaVersionIngresosBrutosCommand(
PredecesoraId: id,
NuevaAlicuota: request.Alicuota ?? 0m,
VigenciaDesde: vigenciaDesde.Value);
var validation = await _nuevaVersionIibbValidator.ValidateAsync(command);
if (!validation.IsValid)
{
var errors = validation.Errors
.GroupBy(e => e.PropertyName)
.ToDictionary(g => g.Key, g => g.Select(e => e.ErrorMessage).ToArray());
return BadRequest(new { errors });
}
var result = await _dispatcher.Send<NuevaVersionIngresosBrutosCommand, SIGCM2.Application.IngresosBrutos.Dtos.NuevaVersionIibbResultDto>(command);
return CreatedAtAction(
nameof(GetIibbById),
new { id = result.NuevaVersionId },
new NuevaVersionResponse(result.PredecesoraId, result.NuevaVersionId));
}
/// <summary>Deactivates an IngresosBrutos entry. Idempotent.</summary>
[HttpPost("iibb/{id:int}/deactivate")]
[RequirePermission("administracion:fiscal:gestionar")]
[ProducesResponseType(StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status401Unauthorized)]
[ProducesResponseType(StatusCodes.Status403Forbidden)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<IActionResult> DeactivateIibb([FromRoute] int id)
{
var command = new DeactivateIngresosBrutosCommand(id);
var result = await _dispatcher.Send<DeactivateIngresosBrutosCommand, IngresosBrutosDto>(command);
return Ok(FiscalContractMapper.ToIibbResponse(result));
}
/// <summary>Reactivates an IngresosBrutos entry. Idempotent.</summary>
[HttpPost("iibb/{id:int}/reactivate")]
[RequirePermission("administracion:fiscal:gestionar")]
[ProducesResponseType(StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status401Unauthorized)]
[ProducesResponseType(StatusCodes.Status403Forbidden)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<IActionResult> ReactivateIibb([FromRoute] int id)
{
var command = new ReactivateIngresosBrutosCommand(id);
var result = await _dispatcher.Send<ReactivateIngresosBrutosCommand, IngresosBrutosDto>(command);
return Ok(FiscalContractMapper.ToIibbResponse(result));
}
// ══════════════════════════════════════════════════════════════════════════
// Private helpers
// ══════════════════════════════════════════════════════════════════════════
/// <summary>
/// Parses a date string "yyyy-MM-dd" to DateOnly. Returns null if invalid.
/// </summary>
private static DateOnly? ParseDateOnly(string? value, string fieldName)
{
if (value is null) return null;
return DateOnly.TryParseExact(value, "yyyy-MM-dd",
System.Globalization.CultureInfo.InvariantCulture,
System.Globalization.DateTimeStyles.None,
out var result)
? result
: null;
}
/// <summary>
/// Checks if a raw JSON string contains a given field name (case-insensitive).
/// Used to detect immutable-field tampering before deserialization silently drops the field.
/// </summary>
private static bool ContainsImmutableField(string rawJson, string fieldName)
{
if (string.IsNullOrWhiteSpace(rawJson)) return false;
try
{
using var doc = JsonDocument.Parse(rawJson);
return doc.RootElement.ValueKind == JsonValueKind.Object &&
doc.RootElement.EnumerateObject()
.Any(p => string.Equals(p.Name, fieldName, StringComparison.OrdinalIgnoreCase));
}
catch (JsonException)
{
return false;
}
}
}

View File

@@ -0,0 +1,173 @@
using FluentValidation;
using Microsoft.AspNetCore.Mvc;
using SIGCM2.Api.Authorization;
using SIGCM2.Application.Abstractions;
using SIGCM2.Application.Common;
using SIGCM2.Application.Medios.Create;
using SIGCM2.Application.Medios.Deactivate;
using SIGCM2.Application.Medios.GetById;
using SIGCM2.Application.Medios.List;
using SIGCM2.Application.Medios.Reactivate;
using SIGCM2.Application.Medios.Update;
using SIGCM2.Domain.Entities;
namespace SIGCM2.Api.Controllers;
/// <summary>
/// ADM-001: Medio management endpoints at /api/v1/admin/medios.
/// All endpoints require permission 'administracion:medios:gestionar'.
/// </summary>
[ApiController]
[Route("api/v1/admin/medios")]
public sealed class MediosController : ControllerBase
{
private readonly IDispatcher _dispatcher;
private readonly IValidator<CreateMedioCommand> _createValidator;
private readonly IValidator<UpdateMedioCommand> _updateValidator;
public MediosController(
IDispatcher dispatcher,
IValidator<CreateMedioCommand> createValidator,
IValidator<UpdateMedioCommand> updateValidator)
{
_dispatcher = dispatcher;
_createValidator = createValidator;
_updateValidator = updateValidator;
}
/// <summary>Creates a new medio. Requires administracion:medios:gestionar.</summary>
[HttpPost]
[RequirePermission("administracion:medios:gestionar")]
[ProducesResponseType(typeof(MedioCreatedDto), StatusCodes.Status201Created)]
[ProducesResponseType(StatusCodes.Status400BadRequest)]
[ProducesResponseType(StatusCodes.Status401Unauthorized)]
[ProducesResponseType(StatusCodes.Status403Forbidden)]
[ProducesResponseType(StatusCodes.Status409Conflict)]
public async Task<IActionResult> CreateMedio([FromBody] CreateMedioRequest request)
{
var command = new CreateMedioCommand(
Codigo: request.Codigo ?? string.Empty,
Nombre: request.Nombre ?? string.Empty,
Tipo: request.Tipo ?? TipoMedio.Diario,
PlataformaEmpresaId: request.PlataformaEmpresaId);
var validation = await _createValidator.ValidateAsync(command);
if (!validation.IsValid)
{
var errors = validation.Errors
.GroupBy(e => e.PropertyName)
.ToDictionary(g => g.Key, g => g.Select(e => e.ErrorMessage).ToArray());
return BadRequest(new { errors });
}
var result = await _dispatcher.Send<CreateMedioCommand, MedioCreatedDto>(command);
return CreatedAtAction(nameof(GetMedioById), new { id = result.Id }, result);
}
/// <summary>Lists medios with optional filters and pagination.</summary>
[HttpGet]
[RequirePermission("administracion:medios:gestionar")]
[ProducesResponseType(typeof(PagedResult<MedioListItemDto>), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status401Unauthorized)]
[ProducesResponseType(StatusCodes.Status403Forbidden)]
public async Task<IActionResult> ListMedios(
[FromQuery] int page = 1,
[FromQuery] int pageSize = 20,
[FromQuery] bool? activo = null,
[FromQuery] TipoMedio? tipo = null,
[FromQuery] string? q = null)
{
if (page < 1) return BadRequest(new { error = "page must be >= 1" });
if (pageSize < 1) return BadRequest(new { error = "pageSize must be >= 1" });
var query = new ListMediosQuery(page, pageSize, activo, tipo, q);
var result = await _dispatcher.Send<ListMediosQuery, PagedResult<MedioListItemDto>>(query);
return Ok(result);
}
/// <summary>Gets a single medio by id.</summary>
[HttpGet("{id:int}")]
[RequirePermission("administracion:medios:gestionar")]
[ProducesResponseType(typeof(MedioDetailDto), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status401Unauthorized)]
[ProducesResponseType(StatusCodes.Status403Forbidden)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<IActionResult> GetMedioById([FromRoute] int id)
{
var query = new GetMedioByIdQuery(id);
var result = await _dispatcher.Send<GetMedioByIdQuery, MedioDetailDto>(query);
return Ok(result);
}
/// <summary>Updates a medio's editable fields.</summary>
[HttpPut("{id:int}")]
[RequirePermission("administracion:medios:gestionar")]
[ProducesResponseType(typeof(MedioUpdatedDto), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status400BadRequest)]
[ProducesResponseType(StatusCodes.Status401Unauthorized)]
[ProducesResponseType(StatusCodes.Status403Forbidden)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<IActionResult> UpdateMedio([FromRoute] int id, [FromBody] UpdateMedioRequest request)
{
var command = new UpdateMedioCommand(
Id: id,
Nombre: request.Nombre ?? string.Empty,
Tipo: request.Tipo ?? TipoMedio.Diario,
PlataformaEmpresaId: request.PlataformaEmpresaId);
var validation = await _updateValidator.ValidateAsync(command);
if (!validation.IsValid)
{
var errors = validation.Errors
.GroupBy(e => e.PropertyName)
.ToDictionary(g => g.Key, g => g.Select(e => e.ErrorMessage).ToArray());
return BadRequest(new { errors });
}
var result = await _dispatcher.Send<UpdateMedioCommand, MedioUpdatedDto>(command);
return Ok(result);
}
/// <summary>Deactivates a medio (idempotent).</summary>
[HttpPost("{id:int}/deactivate")]
[RequirePermission("administracion:medios:gestionar")]
[ProducesResponseType(StatusCodes.Status204NoContent)]
[ProducesResponseType(StatusCodes.Status401Unauthorized)]
[ProducesResponseType(StatusCodes.Status403Forbidden)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<IActionResult> DeactivateMedio([FromRoute] int id)
{
var command = new DeactivateMedioCommand(id);
await _dispatcher.Send<DeactivateMedioCommand, MedioStatusDto>(command);
return NoContent();
}
/// <summary>Reactivates a medio (idempotent).</summary>
[HttpPost("{id:int}/reactivate")]
[RequirePermission("administracion:medios:gestionar")]
[ProducesResponseType(StatusCodes.Status204NoContent)]
[ProducesResponseType(StatusCodes.Status401Unauthorized)]
[ProducesResponseType(StatusCodes.Status403Forbidden)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<IActionResult> ReactivateMedio([FromRoute] int id)
{
var command = new ReactivateMedioCommand(id);
await _dispatcher.Send<ReactivateMedioCommand, MedioStatusDto>(command);
return NoContent();
}
}
// ── Request body records ──────────────────────────────────────────────────────
/// <summary>ADM-001: Create medio request body.</summary>
public sealed record CreateMedioRequest(
string? Codigo,
string? Nombre,
TipoMedio? Tipo,
int? PlataformaEmpresaId);
/// <summary>ADM-001: Update medio request body.</summary>
public sealed record UpdateMedioRequest(
string? Nombre,
TipoMedio? Tipo,
int? PlataformaEmpresaId);

View File

@@ -0,0 +1,104 @@
using FluentValidation;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using SIGCM2.Api.Authorization;
using SIGCM2.Application.Abstractions;
using SIGCM2.Application.Permisos.Assign;
using SIGCM2.Application.Permisos.Dtos;
using SIGCM2.Application.Permisos.GetByRol;
using SIGCM2.Application.Permisos.List;
namespace SIGCM2.Api.Controllers;
/// <summary>
/// Permisos controller — granular permission per method (UDT-006).
/// [Authorize] at class level requires a valid JWT; each method declares its specific permission.
/// </summary>
[ApiController]
[Route("api/v1")]
[Authorize] // JWT required on all methods; per-method [RequirePermission] handles authz
public sealed class PermisosController : ControllerBase
{
private readonly IDispatcher _dispatcher;
private readonly IValidator<AssignPermisosToRolCommand> _assignValidator;
private readonly IValidator<GetRolPermisosQuery> _getRolPermisosValidator;
public PermisosController(
IDispatcher dispatcher,
IValidator<AssignPermisosToRolCommand> assignValidator,
IValidator<GetRolPermisosQuery> getRolPermisosValidator)
{
_dispatcher = dispatcher;
_assignValidator = assignValidator;
_getRolPermisosValidator = getRolPermisosValidator;
}
/// <summary>Lists all permisos in the canonical catalog.</summary>
[HttpGet("permisos")]
[RequirePermission("administracion:permisos:ver")]
[ProducesResponseType(typeof(IReadOnlyList<PermisoDto>), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status401Unauthorized)]
[ProducesResponseType(StatusCodes.Status403Forbidden)]
public async Task<IActionResult> ListPermisos()
{
var result = await _dispatcher.Send<ListPermisosQuery, IReadOnlyList<PermisoDto>>(new ListPermisosQuery());
return Ok(result);
}
/// <summary>Gets all permisos assigned to a rol.</summary>
[HttpGet("roles/{codigo}/permisos")]
[RequirePermission("administracion:roles_permisos:gestionar", "administracion:permisos:ver")]
[ProducesResponseType(typeof(IReadOnlyList<PermisoDto>), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status400BadRequest)]
[ProducesResponseType(StatusCodes.Status401Unauthorized)]
[ProducesResponseType(StatusCodes.Status403Forbidden)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<IActionResult> GetRolPermisos(string codigo)
{
var query = new GetRolPermisosQuery(codigo);
var validation = await _getRolPermisosValidator.ValidateAsync(query);
if (!validation.IsValid)
{
var errors = validation.Errors
.GroupBy(e => e.PropertyName)
.ToDictionary(g => g.Key, g => g.Select(e => e.ErrorMessage).ToArray());
return BadRequest(new { errors });
}
var result = await _dispatcher.Send<GetRolPermisosQuery, IReadOnlyList<PermisoDto>>(query);
return Ok(result);
}
/// <summary>
/// Replace-set: replaces the full permiso assignment for a rol.
/// Returns the updated permiso set (200).
/// </summary>
[HttpPut("roles/{codigo}/permisos")]
[RequirePermission("administracion:roles_permisos:gestionar")]
[ProducesResponseType(typeof(IReadOnlyList<PermisoDto>), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status400BadRequest)]
[ProducesResponseType(StatusCodes.Status401Unauthorized)]
[ProducesResponseType(StatusCodes.Status403Forbidden)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<IActionResult> AssignPermisos(string codigo, [FromBody] AssignPermisosRequest request)
{
var codigos = request.Codigos ?? [];
var command = new AssignPermisosToRolCommand(
RolCodigo: codigo,
Codigos: codigos);
var validation = await _assignValidator.ValidateAsync(command);
if (!validation.IsValid)
{
var errors = validation.Errors
.GroupBy(e => e.PropertyName)
.ToDictionary(g => g.Key, g => g.Select(e => e.ErrorMessage).ToArray());
return BadRequest(new { errors });
}
var result = await _dispatcher.Send<AssignPermisosToRolCommand, IReadOnlyList<PermisoDto>>(command);
return Ok(result);
}
}
public sealed record AssignPermisosRequest(IReadOnlyList<string>? Codigos);

View File

@@ -0,0 +1,102 @@
using FluentValidation;
using Microsoft.AspNetCore.Mvc;
using SIGCM2.Api.Authorization;
using SIGCM2.Application.Abstractions;
using SIGCM2.Application.Common;
using SIGCM2.Application.Products.Prices;
using SIGCM2.Application.Products.Prices.AddPrice;
using SIGCM2.Application.Products.Prices.GetHistory;
namespace SIGCM2.Api.Controllers;
/// <summary>
/// PRD-003: ProductPrices historic pricing management.
/// Read endpoint at GET /api/v1/products/{id}/prices — requires 'catalogo:productos:gestionar'.
/// Write endpoint at POST /api/v1/admin/products/{id}/prices — requires 'catalogo:productos:gestionar'.
/// </summary>
[ApiController]
public sealed class ProductPricesController : ControllerBase
{
private readonly IDispatcher _dispatcher;
private readonly IValidator<AddProductPriceCommand> _addValidator;
public ProductPricesController(
IDispatcher dispatcher,
IValidator<AddProductPriceCommand> addValidator)
{
_dispatcher = dispatcher;
_addValidator = addValidator;
}
// ── READ endpoint ──────────────────────────────────────────────────────────
/// <summary>
/// Returns a paginated page of price history for a Product, ordered descending by PriceValidFrom.
/// Defaults: page=1, pageSize=20. Clamping: page ≥ 1, pageSize ∈ [1, 100].
/// Returns 200 with empty items if the product has no prices yet or page is beyond total.
/// Returns 404 if the product does not exist.
/// Returns 401 if not authenticated, 403 if missing 'catalogo:productos:gestionar' permission.
/// </summary>
[HttpGet("api/v1/products/{id:int}/prices")]
[RequirePermission("catalogo:productos:gestionar")]
[ProducesResponseType(typeof(PagedResult<ProductPriceDto>), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status401Unauthorized)]
[ProducesResponseType(StatusCodes.Status403Forbidden)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<IActionResult> GetProductPrices(
[FromRoute] int id,
[FromQuery] int? page,
[FromQuery] int? pageSize)
{
var query = new GetProductPricesQuery(
ProductId: id,
Page: page ?? 1,
PageSize: pageSize ?? 20);
var result = await _dispatcher.Send<GetProductPricesQuery, PagedResult<ProductPriceDto>>(query);
return Ok(result);
}
// ── WRITE endpoint ─────────────────────────────────────────────────────────
/// <summary>
/// Adds a new price to a Product. Closes the current active price if one exists.
/// PriceValidFrom must be >= today_AR and strictly greater than the active price's PriceValidFrom.
/// Returns 201 Created with Location header pointing to GET /api/v1/products/{id}/prices.
/// </summary>
[HttpPost("api/v1/admin/products/{id:int}/prices")]
[RequirePermission("catalogo:productos:gestionar")]
[ProducesResponseType(typeof(AddProductPriceResponse), StatusCodes.Status201Created)]
[ProducesResponseType(StatusCodes.Status400BadRequest)]
[ProducesResponseType(StatusCodes.Status401Unauthorized)]
[ProducesResponseType(StatusCodes.Status403Forbidden)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
[ProducesResponseType(StatusCodes.Status409Conflict)]
public async Task<IActionResult> AddProductPrice(
[FromRoute] int id,
[FromBody] AddProductPriceRequest request)
{
var command = new AddProductPriceCommand(
ProductId: id,
Price: request.Price,
PriceValidFrom: request.PriceValidFrom);
var validation = await _addValidator.ValidateAsync(command);
if (!validation.IsValid)
{
var errors = validation.Errors
.GroupBy(e => e.PropertyName)
.ToDictionary(g => g.Key, g => g.Select(e => e.ErrorMessage).ToArray());
return BadRequest(new { errors });
}
var result = await _dispatcher.Send<AddProductPriceCommand, AddProductPriceResponse>(command);
return CreatedAtAction(nameof(GetProductPrices), new { id }, result);
}
}
// ── Request body record ───────────────────────────────────────────────────────
/// <summary>PRD-003: Add ProductPrice request body.</summary>
public sealed record AddProductPriceRequest(
decimal Price,
DateOnly PriceValidFrom);

View File

@@ -0,0 +1,184 @@
using FluentValidation;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using SIGCM2.Api.Authorization;
using SIGCM2.Application.Abstractions;
using SIGCM2.Application.Common;
using SIGCM2.Application.ProductTypes.Create;
using SIGCM2.Application.ProductTypes.Deactivate;
using SIGCM2.Application.ProductTypes.GetById;
using SIGCM2.Application.ProductTypes.List;
using SIGCM2.Application.ProductTypes.Update;
namespace SIGCM2.Api.Controllers;
/// <summary>
/// PRD-001: ProductType catalog management.
/// Read endpoints at /api/v1/product-types — require authentication (any role).
/// Write endpoints at /api/v1/admin/product-types — require 'catalogo:tipos:gestionar'.
/// </summary>
[ApiController]
public sealed class ProductTypesController : ControllerBase
{
private readonly IDispatcher _dispatcher;
private readonly IValidator<CreateProductTypeCommand> _createValidator;
private readonly IValidator<UpdateProductTypeCommand> _updateValidator;
public ProductTypesController(
IDispatcher dispatcher,
IValidator<CreateProductTypeCommand> createValidator,
IValidator<UpdateProductTypeCommand> updateValidator)
{
_dispatcher = dispatcher;
_createValidator = createValidator;
_updateValidator = updateValidator;
}
// ── READ endpoints ─────────────────────────────────────────────────────────
/// <summary>Returns a paginated list of ProductTypes. Requires authentication.</summary>
[HttpGet("api/v1/product-types")]
[Authorize]
[ProducesResponseType(typeof(PagedResult<ProductTypeListItemDto>), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status401Unauthorized)]
public async Task<IActionResult> ListProductTypes(
[FromQuery] int page = 1,
[FromQuery] int pageSize = 20,
[FromQuery] bool? activo = true,
[FromQuery] string? search = null)
{
var query = new ListProductTypesQuery(page, pageSize, activo, search);
var result = await _dispatcher.Send<ListProductTypesQuery, PagedResult<ProductTypeListItemDto>>(query);
return Ok(result);
}
/// <summary>Returns a single ProductType by id. Requires authentication.</summary>
[HttpGet("api/v1/product-types/{id:int}")]
[Authorize]
[ProducesResponseType(typeof(ProductTypeDetailDto), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status401Unauthorized)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<IActionResult> GetProductTypeById([FromRoute] int id)
{
var query = new GetProductTypeByIdQuery(id);
var result = await _dispatcher.Send<GetProductTypeByIdQuery, ProductTypeDetailDto>(query);
return Ok(result);
}
// ── WRITE endpoints ────────────────────────────────────────────────────────
/// <summary>Creates a new ProductType. Requires catalogo:tipos:gestionar.</summary>
[HttpPost("api/v1/admin/product-types")]
[RequirePermission("catalogo:tipos:gestionar")]
[ProducesResponseType(typeof(ProductTypeCreatedDto), StatusCodes.Status201Created)]
[ProducesResponseType(StatusCodes.Status400BadRequest)]
[ProducesResponseType(StatusCodes.Status401Unauthorized)]
[ProducesResponseType(StatusCodes.Status403Forbidden)]
[ProducesResponseType(StatusCodes.Status409Conflict)]
public async Task<IActionResult> CreateProductType([FromBody] CreateProductTypeRequest request)
{
var command = new CreateProductTypeCommand(
Nombre: request.Nombre ?? string.Empty,
HasDuration: request.HasDuration,
RequiresText: request.RequiresText,
RequiresCategory: request.RequiresCategory,
IsBundle: request.IsBundle,
AllowImages: request.AllowImages,
MaxImages: request.MaxImages,
MaxImageSizeMB: request.MaxImageSizeMB,
MaxImageWidth: request.MaxImageWidth,
MaxImageHeight: request.MaxImageHeight);
var validation = await _createValidator.ValidateAsync(command);
if (!validation.IsValid)
{
var errors = validation.Errors
.GroupBy(e => e.PropertyName)
.ToDictionary(g => g.Key, g => g.Select(e => e.ErrorMessage).ToArray());
return BadRequest(new { errors });
}
var result = await _dispatcher.Send<CreateProductTypeCommand, ProductTypeCreatedDto>(command);
return CreatedAtAction(nameof(GetProductTypeById), new { id = result.Id }, result);
}
/// <summary>Updates a ProductType. Requires catalogo:tipos:gestionar.</summary>
[HttpPut("api/v1/admin/product-types/{id:int}")]
[RequirePermission("catalogo:tipos:gestionar")]
[ProducesResponseType(typeof(ProductTypeUpdatedDto), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status400BadRequest)]
[ProducesResponseType(StatusCodes.Status401Unauthorized)]
[ProducesResponseType(StatusCodes.Status403Forbidden)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
[ProducesResponseType(StatusCodes.Status409Conflict)]
public async Task<IActionResult> UpdateProductType([FromRoute] int id, [FromBody] UpdateProductTypeRequest request)
{
var command = new UpdateProductTypeCommand(
Id: id,
Nombre: request.Nombre ?? string.Empty,
HasDuration: request.HasDuration,
RequiresText: request.RequiresText,
RequiresCategory: request.RequiresCategory,
IsBundle: request.IsBundle,
AllowImages: request.AllowImages,
MaxImages: request.MaxImages,
MaxImageSizeMB: request.MaxImageSizeMB,
MaxImageWidth: request.MaxImageWidth,
MaxImageHeight: request.MaxImageHeight);
var validation = await _updateValidator.ValidateAsync(command);
if (!validation.IsValid)
{
var errors = validation.Errors
.GroupBy(e => e.PropertyName)
.ToDictionary(g => g.Key, g => g.Select(e => e.ErrorMessage).ToArray());
return BadRequest(new { errors });
}
var result = await _dispatcher.Send<UpdateProductTypeCommand, ProductTypeUpdatedDto>(command);
return Ok(result);
}
/// <summary>Soft-deletes (deactivates) a ProductType. Requires catalogo:tipos:gestionar.</summary>
[HttpDelete("api/v1/admin/product-types/{id:int}")]
[RequirePermission("catalogo:tipos:gestionar")]
[ProducesResponseType(StatusCodes.Status204NoContent)]
[ProducesResponseType(StatusCodes.Status401Unauthorized)]
[ProducesResponseType(StatusCodes.Status403Forbidden)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
[ProducesResponseType(StatusCodes.Status409Conflict)]
public async Task<IActionResult> DeactivateProductType([FromRoute] int id)
{
var command = new DeactivateProductTypeCommand(id);
await _dispatcher.Send<DeactivateProductTypeCommand, ProductTypeStatusDto>(command);
return NoContent();
}
}
// ── Request body records ──────────────────────────────────────────────────────
/// <summary>PRD-001: Create ProductType request body.</summary>
public sealed record CreateProductTypeRequest(
string? Nombre,
bool HasDuration = false,
bool RequiresText = false,
bool RequiresCategory = false,
bool IsBundle = false,
bool AllowImages = false,
int? MaxImages = null,
decimal? MaxImageSizeMB = null,
int? MaxImageWidth = null,
int? MaxImageHeight = null);
/// <summary>PRD-001: Update ProductType request body.</summary>
public sealed record UpdateProductTypeRequest(
string? Nombre,
bool HasDuration = false,
bool RequiresText = false,
bool RequiresCategory = false,
bool IsBundle = false,
bool AllowImages = false,
int? MaxImages = null,
decimal? MaxImageSizeMB = null,
int? MaxImageWidth = null,
int? MaxImageHeight = null);

View File

@@ -0,0 +1,169 @@
using FluentValidation;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using SIGCM2.Api.Authorization;
using SIGCM2.Application.Abstractions;
using SIGCM2.Application.Common;
using SIGCM2.Application.Products.Create;
using SIGCM2.Application.Products.Deactivate;
using SIGCM2.Application.Products.GetById;
using SIGCM2.Application.Products.List;
using SIGCM2.Application.Products.Update;
namespace SIGCM2.Api.Controllers;
/// <summary>
/// PRD-002: Product catalog management.
/// Read endpoints at /api/v1/products — require authentication (any role).
/// Write endpoints at /api/v1/admin/products — require 'catalogo:productos:gestionar'.
/// </summary>
[ApiController]
public sealed class ProductsController : ControllerBase
{
private readonly IDispatcher _dispatcher;
private readonly IValidator<CreateProductCommand> _createValidator;
private readonly IValidator<UpdateProductCommand> _updateValidator;
public ProductsController(
IDispatcher dispatcher,
IValidator<CreateProductCommand> createValidator,
IValidator<UpdateProductCommand> updateValidator)
{
_dispatcher = dispatcher;
_createValidator = createValidator;
_updateValidator = updateValidator;
}
// ── READ endpoints ─────────────────────────────────────────────────────────
/// <summary>Returns a paginated list of Products. Requires authentication.</summary>
[HttpGet("api/v1/products")]
[Authorize]
[ProducesResponseType(typeof(PagedResult<ProductListItemDto>), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status401Unauthorized)]
public async Task<IActionResult> ListProducts(
[FromQuery] int page = 1,
[FromQuery] int pageSize = 20,
[FromQuery] bool? activo = true,
[FromQuery] string? search = null,
[FromQuery] int? medioId = null,
[FromQuery] int? productTypeId = null,
[FromQuery] int? rubroId = null)
{
var query = new ListProductsQuery(page, pageSize, activo, search, medioId, productTypeId, rubroId);
var result = await _dispatcher.Send<ListProductsQuery, PagedResult<ProductListItemDto>>(query);
return Ok(result);
}
/// <summary>Returns a single Product by id. Requires authentication.</summary>
[HttpGet("api/v1/products/{id:int}")]
[Authorize]
[ProducesResponseType(typeof(ProductDetailDto), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status401Unauthorized)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<IActionResult> GetProductById([FromRoute] int id)
{
var query = new GetProductByIdQuery(id);
var result = await _dispatcher.Send<GetProductByIdQuery, ProductDetailDto>(query);
return Ok(result);
}
// ── WRITE endpoints ────────────────────────────────────────────────────────
/// <summary>Creates a new Product. Requires catalogo:productos:gestionar.</summary>
[HttpPost("api/v1/admin/products")]
[RequirePermission("catalogo:productos:gestionar")]
[ProducesResponseType(typeof(ProductCreatedDto), StatusCodes.Status201Created)]
[ProducesResponseType(StatusCodes.Status400BadRequest)]
[ProducesResponseType(StatusCodes.Status401Unauthorized)]
[ProducesResponseType(StatusCodes.Status403Forbidden)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
[ProducesResponseType(StatusCodes.Status409Conflict)]
[ProducesResponseType(StatusCodes.Status422UnprocessableEntity)]
public async Task<IActionResult> CreateProduct([FromBody] CreateProductRequest request)
{
var command = new CreateProductCommand(
Nombre: request.Nombre ?? string.Empty,
MedioId: request.MedioId,
ProductTypeId: request.ProductTypeId,
RubroId: request.RubroId,
BasePrice: request.BasePrice,
PriceDurationDays: request.PriceDurationDays);
var validation = await _createValidator.ValidateAsync(command);
if (!validation.IsValid)
{
var errors = validation.Errors
.GroupBy(e => e.PropertyName)
.ToDictionary(g => g.Key, g => g.Select(e => e.ErrorMessage).ToArray());
return BadRequest(new { errors });
}
var result = await _dispatcher.Send<CreateProductCommand, ProductCreatedDto>(command);
return CreatedAtAction(nameof(GetProductById), new { id = result.Id }, result);
}
/// <summary>Updates a Product. Requires catalogo:productos:gestionar.</summary>
[HttpPut("api/v1/admin/products/{id:int}")]
[RequirePermission("catalogo:productos:gestionar")]
[ProducesResponseType(typeof(ProductUpdatedDto), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status400BadRequest)]
[ProducesResponseType(StatusCodes.Status401Unauthorized)]
[ProducesResponseType(StatusCodes.Status403Forbidden)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
[ProducesResponseType(StatusCodes.Status409Conflict)]
[ProducesResponseType(StatusCodes.Status422UnprocessableEntity)]
public async Task<IActionResult> UpdateProduct([FromRoute] int id, [FromBody] UpdateProductRequest request)
{
var command = new UpdateProductCommand(
Id: id,
Nombre: request.Nombre ?? string.Empty,
RubroId: request.RubroId,
BasePrice: request.BasePrice,
PriceDurationDays: request.PriceDurationDays);
var validation = await _updateValidator.ValidateAsync(command);
if (!validation.IsValid)
{
var errors = validation.Errors
.GroupBy(e => e.PropertyName)
.ToDictionary(g => g.Key, g => g.Select(e => e.ErrorMessage).ToArray());
return BadRequest(new { errors });
}
var result = await _dispatcher.Send<UpdateProductCommand, ProductUpdatedDto>(command);
return Ok(result);
}
/// <summary>Soft-deletes (deactivates) a Product. Requires catalogo:productos:gestionar.</summary>
[HttpDelete("api/v1/admin/products/{id:int}")]
[RequirePermission("catalogo:productos:gestionar")]
[ProducesResponseType(StatusCodes.Status204NoContent)]
[ProducesResponseType(StatusCodes.Status401Unauthorized)]
[ProducesResponseType(StatusCodes.Status403Forbidden)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<IActionResult> DeactivateProduct([FromRoute] int id)
{
var command = new DeactivateProductCommand(id);
await _dispatcher.Send<DeactivateProductCommand, ProductStatusDto>(command);
return NoContent();
}
}
// ── Request body records ──────────────────────────────────────────────────────
/// <summary>PRD-002: Create Product request body.</summary>
public sealed record CreateProductRequest(
string? Nombre,
int MedioId = 0,
int ProductTypeId = 0,
int? RubroId = null,
decimal BasePrice = 0m,
int? PriceDurationDays = null);
/// <summary>PRD-002: Update Product request body.</summary>
public sealed record UpdateProductRequest(
string? Nombre,
int? RubroId = null,
decimal BasePrice = 0m,
int? PriceDurationDays = null);

View File

@@ -0,0 +1,175 @@
using FluentValidation;
using Microsoft.AspNetCore.Mvc;
using SIGCM2.Api.Authorization;
using SIGCM2.Application.Abstractions;
using SIGCM2.Application.Common;
using SIGCM2.Application.PuntosDeVenta.Create;
using SIGCM2.Application.PuntosDeVenta.Deactivate;
using SIGCM2.Application.PuntosDeVenta.GetById;
using SIGCM2.Application.PuntosDeVenta.List;
using SIGCM2.Application.PuntosDeVenta.Reactivate;
using SIGCM2.Application.PuntosDeVenta.Update;
namespace SIGCM2.Api.Controllers;
/// <summary>
/// ADM-008: PuntoDeVenta management endpoints at /api/v1/admin/puntos-de-venta.
/// All endpoints require permission 'administracion:puntos_de_venta:gestionar'.
/// </summary>
[ApiController]
[Route("api/v1/admin/puntos-de-venta")]
public sealed class PuntosDeVentaController : ControllerBase
{
private readonly IDispatcher _dispatcher;
private readonly IValidator<CreatePuntoDeVentaCommand> _createValidator;
private readonly IValidator<UpdatePuntoDeVentaCommand> _updateValidator;
public PuntosDeVentaController(
IDispatcher dispatcher,
IValidator<CreatePuntoDeVentaCommand> createValidator,
IValidator<UpdatePuntoDeVentaCommand> updateValidator)
{
_dispatcher = dispatcher;
_createValidator = createValidator;
_updateValidator = updateValidator;
}
/// <summary>Creates a new punto de venta. Requires administracion:puntos_de_venta:gestionar.</summary>
[HttpPost]
[RequirePermission("administracion:puntos_de_venta:gestionar")]
[ProducesResponseType(typeof(PuntoDeVentaCreatedDto), StatusCodes.Status201Created)]
[ProducesResponseType(StatusCodes.Status400BadRequest)]
[ProducesResponseType(StatusCodes.Status401Unauthorized)]
[ProducesResponseType(StatusCodes.Status403Forbidden)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
[ProducesResponseType(StatusCodes.Status409Conflict)]
public async Task<IActionResult> CreatePuntoDeVenta([FromBody] CreatePuntoDeVentaRequest request)
{
var command = new CreatePuntoDeVentaCommand(
MedioId: request.MedioId ?? 0,
NumeroAFIP: request.NumeroAFIP ?? 0,
Nombre: request.Nombre ?? string.Empty,
Descripcion: request.Descripcion);
var validation = await _createValidator.ValidateAsync(command);
if (!validation.IsValid)
{
var errors = validation.Errors
.GroupBy(e => e.PropertyName)
.ToDictionary(g => g.Key, g => g.Select(e => e.ErrorMessage).ToArray());
return BadRequest(new { errors });
}
var result = await _dispatcher.Send<CreatePuntoDeVentaCommand, PuntoDeVentaCreatedDto>(command);
return CreatedAtAction(nameof(GetPuntoDeVentaById), new { id = result.Id }, result);
}
/// <summary>Lists puntos de venta with optional filters.</summary>
[HttpGet]
[RequirePermission("administracion:puntos_de_venta:gestionar")]
[ProducesResponseType(typeof(PagedResult<PuntoDeVentaListItemDto>), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status401Unauthorized)]
[ProducesResponseType(StatusCodes.Status403Forbidden)]
public async Task<IActionResult> ListPuntosDeVenta(
[FromQuery] int page = 1,
[FromQuery] int pageSize = 20,
[FromQuery] int? medioId = null,
[FromQuery] bool? activo = null)
{
if (page < 1) return BadRequest(new { error = "page must be >= 1" });
if (pageSize < 1) return BadRequest(new { error = "pageSize must be >= 1" });
var query = new ListPuntosDeVentaQuery(page, pageSize, medioId, activo);
var result = await _dispatcher.Send<ListPuntosDeVentaQuery, PagedResult<PuntoDeVentaListItemDto>>(query);
return Ok(result);
}
/// <summary>Gets a single punto de venta by id.</summary>
[HttpGet("{id:int}")]
[RequirePermission("administracion:puntos_de_venta:gestionar")]
[ProducesResponseType(typeof(PuntoDeVentaDetailDto), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status401Unauthorized)]
[ProducesResponseType(StatusCodes.Status403Forbidden)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<IActionResult> GetPuntoDeVentaById([FromRoute] int id)
{
var query = new GetPuntoDeVentaByIdQuery(id);
var result = await _dispatcher.Send<GetPuntoDeVentaByIdQuery, PuntoDeVentaDetailDto>(query);
return Ok(result);
}
/// <summary>Updates a punto de venta's editable fields.</summary>
[HttpPut("{id:int}")]
[RequirePermission("administracion:puntos_de_venta:gestionar")]
[ProducesResponseType(typeof(PuntoDeVentaUpdatedDto), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status400BadRequest)]
[ProducesResponseType(StatusCodes.Status401Unauthorized)]
[ProducesResponseType(StatusCodes.Status403Forbidden)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
[ProducesResponseType(StatusCodes.Status409Conflict)]
public async Task<IActionResult> UpdatePuntoDeVenta([FromRoute] int id, [FromBody] UpdatePuntoDeVentaRequest request)
{
var command = new UpdatePuntoDeVentaCommand(
Id: id,
Nombre: request.Nombre ?? string.Empty,
NumeroAFIP: request.NumeroAFIP ?? 0,
Descripcion: request.Descripcion);
var validation = await _updateValidator.ValidateAsync(command);
if (!validation.IsValid)
{
var errors = validation.Errors
.GroupBy(e => e.PropertyName)
.ToDictionary(g => g.Key, g => g.Select(e => e.ErrorMessage).ToArray());
return BadRequest(new { errors });
}
var result = await _dispatcher.Send<UpdatePuntoDeVentaCommand, PuntoDeVentaUpdatedDto>(command);
return Ok(result);
}
/// <summary>Deactivates a punto de venta.</summary>
[HttpPost("{id:int}/deactivate")]
[RequirePermission("administracion:puntos_de_venta:gestionar")]
[ProducesResponseType(StatusCodes.Status204NoContent)]
[ProducesResponseType(StatusCodes.Status401Unauthorized)]
[ProducesResponseType(StatusCodes.Status403Forbidden)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<IActionResult> DeactivatePuntoDeVenta([FromRoute] int id)
{
var command = new DeactivatePuntoDeVentaCommand(id);
await _dispatcher.Send<DeactivatePuntoDeVentaCommand, PuntoDeVentaStatusDto>(command);
return NoContent();
}
/// <summary>Reactivates a punto de venta (only if parent Medio is active).</summary>
[HttpPost("{id:int}/reactivate")]
[RequirePermission("administracion:puntos_de_venta:gestionar")]
[ProducesResponseType(StatusCodes.Status204NoContent)]
[ProducesResponseType(StatusCodes.Status401Unauthorized)]
[ProducesResponseType(StatusCodes.Status403Forbidden)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
[ProducesResponseType(StatusCodes.Status409Conflict)]
public async Task<IActionResult> ReactivatePuntoDeVenta([FromRoute] int id)
{
var command = new ReactivatePuntoDeVentaCommand(id);
await _dispatcher.Send<ReactivatePuntoDeVentaCommand, PuntoDeVentaStatusDto>(command);
return NoContent();
}
}
// ── Request body records ──────────────────────────────────────────────────────
/// <summary>ADM-008: Create punto de venta request body.</summary>
public sealed record CreatePuntoDeVentaRequest(
int? MedioId,
short? NumeroAFIP,
string? Nombre,
string? Descripcion);
/// <summary>ADM-008: Update punto de venta request body.</summary>
public sealed record UpdatePuntoDeVentaRequest(
string? Nombre,
short? NumeroAFIP,
string? Descripcion);

View File

@@ -0,0 +1,128 @@
using FluentValidation;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using SIGCM2.Api.Authorization;
using SIGCM2.Application.Abstractions;
using SIGCM2.Application.Roles.Create;
using SIGCM2.Application.Roles.Deactivate;
using SIGCM2.Application.Roles.Dtos;
using SIGCM2.Application.Roles.Get;
using SIGCM2.Application.Roles.List;
using SIGCM2.Application.Roles.Update;
namespace SIGCM2.Api.Controllers;
[ApiController]
[Route("api/v1/roles")]
[RequirePermission("administracion:roles:gestionar")]
public sealed class RolesController : ControllerBase
{
private readonly IDispatcher _dispatcher;
private readonly IValidator<CreateRolCommand> _createValidator;
private readonly IValidator<UpdateRolCommand> _updateValidator;
public RolesController(
IDispatcher dispatcher,
IValidator<CreateRolCommand> createValidator,
IValidator<UpdateRolCommand> updateValidator)
{
_dispatcher = dispatcher;
_createValidator = createValidator;
_updateValidator = updateValidator;
}
/// <summary>Lists all roles (including inactive). Requires admin role.</summary>
[HttpGet]
[ProducesResponseType(typeof(IReadOnlyList<RolDto>), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status401Unauthorized)]
[ProducesResponseType(StatusCodes.Status403Forbidden)]
public async Task<IActionResult> List()
{
var result = await _dispatcher.Send<ListRolesQuery, IReadOnlyList<RolDto>>(new ListRolesQuery());
return Ok(result);
}
/// <summary>Gets a role by its code. Requires admin role.</summary>
[HttpGet("{codigo}")]
[ProducesResponseType(typeof(RolDto), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status401Unauthorized)]
[ProducesResponseType(StatusCodes.Status403Forbidden)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<IActionResult> GetByCodigo(string codigo)
{
var result = await _dispatcher.Send<GetRolByCodigoQuery, RolDto>(new GetRolByCodigoQuery(codigo));
return Ok(result);
}
/// <summary>Creates a new role. Requires admin role.</summary>
[HttpPost]
[ProducesResponseType(typeof(RolCreatedDto), StatusCodes.Status201Created)]
[ProducesResponseType(StatusCodes.Status400BadRequest)]
[ProducesResponseType(StatusCodes.Status401Unauthorized)]
[ProducesResponseType(StatusCodes.Status403Forbidden)]
[ProducesResponseType(StatusCodes.Status409Conflict)]
public async Task<IActionResult> Create([FromBody] CreateRolRequest request)
{
var command = new CreateRolCommand(
Codigo: request.Codigo ?? string.Empty,
Nombre: request.Nombre ?? string.Empty,
Descripcion: request.Descripcion);
var validation = await _createValidator.ValidateAsync(command);
if (!validation.IsValid)
{
var errors = validation.Errors
.GroupBy(e => e.PropertyName)
.ToDictionary(g => g.Key, g => g.Select(e => e.ErrorMessage).ToArray());
return BadRequest(new { errors });
}
var result = await _dispatcher.Send<CreateRolCommand, RolCreatedDto>(command);
return CreatedAtAction(nameof(GetByCodigo), new { codigo = result.Codigo }, result);
}
/// <summary>Updates a role (codigo is immutable; route wins over body). Requires admin role.</summary>
[HttpPut("{codigo}")]
[ProducesResponseType(typeof(RolDto), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status400BadRequest)]
[ProducesResponseType(StatusCodes.Status401Unauthorized)]
[ProducesResponseType(StatusCodes.Status403Forbidden)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<IActionResult> Update(string codigo, [FromBody] UpdateRolRequest request)
{
// Codigo comes from the route — body.codigo (if present) is ignored by design.
var command = new UpdateRolCommand(
Codigo: codigo,
Nombre: request.Nombre ?? string.Empty,
Descripcion: request.Descripcion,
Activo: request.Activo);
var validation = await _updateValidator.ValidateAsync(command);
if (!validation.IsValid)
{
var errors = validation.Errors
.GroupBy(e => e.PropertyName)
.ToDictionary(g => g.Key, g => g.Select(e => e.ErrorMessage).ToArray());
return BadRequest(new { errors });
}
var result = await _dispatcher.Send<UpdateRolCommand, RolDto>(command);
return Ok(result);
}
/// <summary>Soft-deletes (deactivates) a role. 409 if active usuarios reference it. Requires admin role.</summary>
[HttpDelete("{codigo}")]
[ProducesResponseType(StatusCodes.Status204NoContent)]
[ProducesResponseType(StatusCodes.Status401Unauthorized)]
[ProducesResponseType(StatusCodes.Status403Forbidden)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
[ProducesResponseType(StatusCodes.Status409Conflict)]
public async Task<IActionResult> Deactivate(string codigo)
{
await _dispatcher.Send<DeactivateRolCommand, RolDto>(new DeactivateRolCommand(codigo));
return NoContent();
}
}
public sealed record CreateRolRequest(string? Codigo, string? Nombre, string? Descripcion);
public sealed record UpdateRolRequest(string? Nombre, string? Descripcion, bool Activo);

View File

@@ -0,0 +1,151 @@
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using SIGCM2.Api.Authorization;
using SIGCM2.Application.Abstractions;
using SIGCM2.Application.Rubros.Create;
using SIGCM2.Application.Rubros.Deactivate;
using SIGCM2.Application.Rubros.Dtos;
using SIGCM2.Application.Rubros.GetById;
using SIGCM2.Application.Rubros.GetTree;
using SIGCM2.Application.Rubros.Move;
using SIGCM2.Application.Rubros.Update;
namespace SIGCM2.Api.Controllers;
/// <summary>
/// CAT-001: Rubro N-ary tree management.
/// Read endpoints at /api/v1/rubros — require authentication (any role).
/// Write endpoints at /api/v1/admin/rubros — require 'catalogo:rubros:gestionar'.
/// </summary>
[ApiController]
public sealed class RubrosController : ControllerBase
{
private readonly IDispatcher _dispatcher;
public RubrosController(IDispatcher dispatcher)
{
_dispatcher = dispatcher;
}
// ── READ endpoints ─────────────────────────────────────────────────────────
/// <summary>Returns the full Rubro tree. Requires authentication.</summary>
[HttpGet("api/v1/rubros/tree")]
[Authorize]
[ProducesResponseType(typeof(IReadOnlyList<RubroTreeNodeDto>), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status401Unauthorized)]
public async Task<IActionResult> GetRubroTree([FromQuery] bool incluirInactivos = false)
{
var query = new GetRubroTreeQuery(incluirInactivos);
var result = await _dispatcher.Send<GetRubroTreeQuery, IReadOnlyList<RubroTreeNodeDto>>(query);
return Ok(result);
}
/// <summary>Returns a single Rubro by id. Requires authentication.</summary>
[HttpGet("api/v1/rubros/{id:int}")]
[Authorize]
[ProducesResponseType(typeof(RubroDetailDto), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status401Unauthorized)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<IActionResult> GetRubroById([FromRoute] int id)
{
var query = new GetRubroByIdQuery(id);
var result = await _dispatcher.Send<GetRubroByIdQuery, RubroDetailDto>(query);
return Ok(result);
}
// ── WRITE endpoints ────────────────────────────────────────────────────────
/// <summary>Creates a new Rubro. Requires catalogo:rubros:gestionar.</summary>
[HttpPost("api/v1/admin/rubros")]
[RequirePermission("catalogo:rubros:gestionar")]
[ProducesResponseType(typeof(RubroCreatedDto), StatusCodes.Status201Created)]
[ProducesResponseType(StatusCodes.Status400BadRequest)]
[ProducesResponseType(StatusCodes.Status401Unauthorized)]
[ProducesResponseType(StatusCodes.Status403Forbidden)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
[ProducesResponseType(StatusCodes.Status409Conflict)]
[ProducesResponseType(StatusCodes.Status422UnprocessableEntity)]
public async Task<IActionResult> CreateRubro([FromBody] CreateRubroRequest request)
{
var command = new CreateRubroCommand(
Nombre: request.Nombre ?? string.Empty,
ParentId: request.ParentId,
TarifarioBaseId: request.TarifarioBaseId);
var result = await _dispatcher.Send<CreateRubroCommand, RubroCreatedDto>(command);
return CreatedAtAction(nameof(GetRubroById), new { id = result.Id }, result);
}
/// <summary>Updates a Rubro's nombre. Requires catalogo:rubros:gestionar.</summary>
[HttpPut("api/v1/admin/rubros/{id:int}")]
[RequirePermission("catalogo:rubros:gestionar")]
[ProducesResponseType(typeof(RubroUpdatedDto), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status400BadRequest)]
[ProducesResponseType(StatusCodes.Status401Unauthorized)]
[ProducesResponseType(StatusCodes.Status403Forbidden)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
[ProducesResponseType(StatusCodes.Status409Conflict)]
public async Task<IActionResult> UpdateRubro([FromRoute] int id, [FromBody] UpdateRubroRequest request)
{
var command = new UpdateRubroCommand(
Id: id,
Nombre: request.Nombre ?? string.Empty);
var result = await _dispatcher.Send<UpdateRubroCommand, RubroUpdatedDto>(command);
return Ok(result);
}
/// <summary>Soft-deletes (deactivates) a Rubro. Requires catalogo:rubros:gestionar.</summary>
[HttpDelete("api/v1/admin/rubros/{id:int}")]
[RequirePermission("catalogo:rubros:gestionar")]
[ProducesResponseType(StatusCodes.Status204NoContent)]
[ProducesResponseType(StatusCodes.Status401Unauthorized)]
[ProducesResponseType(StatusCodes.Status403Forbidden)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
[ProducesResponseType(StatusCodes.Status409Conflict)]
public async Task<IActionResult> DeactivateRubro([FromRoute] int id)
{
var command = new DeactivateRubroCommand(id);
await _dispatcher.Send<DeactivateRubroCommand, RubroStatusDto>(command);
return NoContent();
}
/// <summary>Moves a Rubro to a new parent. Requires catalogo:rubros:gestionar.</summary>
[HttpPatch("api/v1/admin/rubros/{id:int}/mover")]
[RequirePermission("catalogo:rubros:gestionar")]
[ProducesResponseType(typeof(RubroMovedDto), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status400BadRequest)]
[ProducesResponseType(StatusCodes.Status401Unauthorized)]
[ProducesResponseType(StatusCodes.Status403Forbidden)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
[ProducesResponseType(StatusCodes.Status409Conflict)]
[ProducesResponseType(StatusCodes.Status422UnprocessableEntity)]
public async Task<IActionResult> MoveRubro([FromRoute] int id, [FromBody] MoveRubroRequest request)
{
var command = new MoveRubroCommand(
Id: id,
NuevoParentId: request.NuevoParentId,
NuevoOrden: request.NuevoOrden);
var result = await _dispatcher.Send<MoveRubroCommand, RubroMovedDto>(command);
return Ok(result);
}
}
// ── Request body records ──────────────────────────────────────────────────────
/// <summary>CAT-001: Create rubro request body.</summary>
public sealed record CreateRubroRequest(
string? Nombre,
int? ParentId,
int? TarifarioBaseId);
/// <summary>CAT-001: Update rubro request body.</summary>
public sealed record UpdateRubroRequest(
string? Nombre);
/// <summary>CAT-001: Move rubro request body.</summary>
public sealed record MoveRubroRequest(
int? NuevoParentId,
int NuevoOrden);

View File

@@ -0,0 +1,172 @@
using FluentValidation;
using Microsoft.AspNetCore.Mvc;
using SIGCM2.Api.Authorization;
using SIGCM2.Application.Abstractions;
using SIGCM2.Application.Common;
using SIGCM2.Application.Secciones.Create;
using SIGCM2.Application.Secciones.Deactivate;
using SIGCM2.Application.Secciones.GetById;
using SIGCM2.Application.Secciones.List;
using SIGCM2.Application.Secciones.Reactivate;
using SIGCM2.Application.Secciones.Update;
namespace SIGCM2.Api.Controllers;
/// <summary>
/// ADM-001: Seccion management endpoints at /api/v1/admin/secciones.
/// All endpoints require permission 'administracion:secciones:gestionar'.
/// </summary>
[ApiController]
[Route("api/v1/admin/secciones")]
public sealed class SeccionesController : ControllerBase
{
private readonly IDispatcher _dispatcher;
private readonly IValidator<CreateSeccionCommand> _createValidator;
private readonly IValidator<UpdateSeccionCommand> _updateValidator;
public SeccionesController(
IDispatcher dispatcher,
IValidator<CreateSeccionCommand> createValidator,
IValidator<UpdateSeccionCommand> updateValidator)
{
_dispatcher = dispatcher;
_createValidator = createValidator;
_updateValidator = updateValidator;
}
/// <summary>Creates a new seccion. Requires administracion:secciones:gestionar.</summary>
[HttpPost]
[RequirePermission("administracion:secciones:gestionar")]
[ProducesResponseType(typeof(SeccionCreatedDto), StatusCodes.Status201Created)]
[ProducesResponseType(StatusCodes.Status400BadRequest)]
[ProducesResponseType(StatusCodes.Status401Unauthorized)]
[ProducesResponseType(StatusCodes.Status403Forbidden)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
[ProducesResponseType(StatusCodes.Status409Conflict)]
public async Task<IActionResult> CreateSeccion([FromBody] CreateSeccionRequest request)
{
var command = new CreateSeccionCommand(
MedioId: request.MedioId ?? 0,
Codigo: request.Codigo ?? string.Empty,
Nombre: request.Nombre ?? string.Empty,
Tipo: request.Tipo ?? string.Empty);
var validation = await _createValidator.ValidateAsync(command);
if (!validation.IsValid)
{
var errors = validation.Errors
.GroupBy(e => e.PropertyName)
.ToDictionary(g => g.Key, g => g.Select(e => e.ErrorMessage).ToArray());
return BadRequest(new { errors });
}
var result = await _dispatcher.Send<CreateSeccionCommand, SeccionCreatedDto>(command);
return CreatedAtAction(nameof(GetSeccionById), new { id = result.Id }, result);
}
/// <summary>Lists secciones with optional filters and pagination.</summary>
[HttpGet]
[RequirePermission("administracion:secciones:gestionar")]
[ProducesResponseType(typeof(PagedResult<SeccionListItemDto>), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status401Unauthorized)]
[ProducesResponseType(StatusCodes.Status403Forbidden)]
public async Task<IActionResult> ListSecciones(
[FromQuery] int page = 1,
[FromQuery] int pageSize = 20,
[FromQuery] int? medioId = null,
[FromQuery] string? tipo = null,
[FromQuery] bool? activo = null,
[FromQuery] string? q = null)
{
if (page < 1) return BadRequest(new { error = "page must be >= 1" });
if (pageSize < 1) return BadRequest(new { error = "pageSize must be >= 1" });
var query = new ListSeccionesQuery(page, pageSize, medioId, tipo, activo, q);
var result = await _dispatcher.Send<ListSeccionesQuery, PagedResult<SeccionListItemDto>>(query);
return Ok(result);
}
/// <summary>Gets a single seccion by id.</summary>
[HttpGet("{id:int}")]
[RequirePermission("administracion:secciones:gestionar")]
[ProducesResponseType(typeof(SeccionDetailDto), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status401Unauthorized)]
[ProducesResponseType(StatusCodes.Status403Forbidden)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<IActionResult> GetSeccionById([FromRoute] int id)
{
var query = new GetSeccionByIdQuery(id);
var result = await _dispatcher.Send<GetSeccionByIdQuery, SeccionDetailDto>(query);
return Ok(result);
}
/// <summary>Updates a seccion's editable fields.</summary>
[HttpPut("{id:int}")]
[RequirePermission("administracion:secciones:gestionar")]
[ProducesResponseType(typeof(SeccionUpdatedDto), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status400BadRequest)]
[ProducesResponseType(StatusCodes.Status401Unauthorized)]
[ProducesResponseType(StatusCodes.Status403Forbidden)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<IActionResult> UpdateSeccion([FromRoute] int id, [FromBody] UpdateSeccionRequest request)
{
var command = new UpdateSeccionCommand(
Id: id,
Nombre: request.Nombre ?? string.Empty,
Tipo: request.Tipo ?? string.Empty);
var validation = await _updateValidator.ValidateAsync(command);
if (!validation.IsValid)
{
var errors = validation.Errors
.GroupBy(e => e.PropertyName)
.ToDictionary(g => g.Key, g => g.Select(e => e.ErrorMessage).ToArray());
return BadRequest(new { errors });
}
var result = await _dispatcher.Send<UpdateSeccionCommand, SeccionUpdatedDto>(command);
return Ok(result);
}
/// <summary>Deactivates a seccion (idempotent).</summary>
[HttpPost("{id:int}/deactivate")]
[RequirePermission("administracion:secciones:gestionar")]
[ProducesResponseType(StatusCodes.Status204NoContent)]
[ProducesResponseType(StatusCodes.Status401Unauthorized)]
[ProducesResponseType(StatusCodes.Status403Forbidden)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<IActionResult> DeactivateSeccion([FromRoute] int id)
{
var command = new DeactivateSeccionCommand(id);
await _dispatcher.Send<DeactivateSeccionCommand, SeccionStatusDto>(command);
return NoContent();
}
/// <summary>Reactivates a seccion (idempotent).</summary>
[HttpPost("{id:int}/reactivate")]
[RequirePermission("administracion:secciones:gestionar")]
[ProducesResponseType(StatusCodes.Status204NoContent)]
[ProducesResponseType(StatusCodes.Status401Unauthorized)]
[ProducesResponseType(StatusCodes.Status403Forbidden)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<IActionResult> ReactivateSeccion([FromRoute] int id)
{
var command = new ReactivateSeccionCommand(id);
await _dispatcher.Send<ReactivateSeccionCommand, SeccionStatusDto>(command);
return NoContent();
}
}
// ── Request body records ──────────────────────────────────────────────────────
/// <summary>ADM-001: Create seccion request body.</summary>
public sealed record CreateSeccionRequest(
int? MedioId,
string? Codigo,
string? Nombre,
string? Tipo);
/// <summary>ADM-001: Update seccion request body.</summary>
public sealed record UpdateSeccionRequest(
string? Nombre,
string? Tipo);

View File

@@ -0,0 +1,316 @@
using FluentValidation;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using SIGCM2.Api.Authorization;
using SIGCM2.Application.Abstractions;
using SIGCM2.Application.Common;
using SIGCM2.Application.Usuarios.ChangeMyPassword;
using SIGCM2.Application.Usuarios.Create;
using SIGCM2.Application.Usuarios.Deactivate;
using SIGCM2.Application.Usuarios.GetById;
using SIGCM2.Application.Usuarios.List;
using SIGCM2.Application.Usuarios.Reactivate;
using SIGCM2.Application.Usuarios.Permisos;
using SIGCM2.Application.Usuarios.ResetPassword;
using SIGCM2.Application.Usuarios.Update;
using System.IdentityModel.Tokens.Jwt;
using System.Security.Claims;
namespace SIGCM2.Api.Controllers;
/// <summary>
/// UDT-001/UDT-008: Usuario management endpoints.
/// RequirePermission moved to method level to allow /me/password with [Authorize] only.
/// </summary>
[ApiController]
[Route("api/v1/users")]
public sealed class UsuariosController : ControllerBase
{
private readonly IDispatcher _dispatcher;
private readonly IValidator<CreateUsuarioCommand> _createValidator;
private readonly IValidator<UpdateUsuarioCommand> _updateValidator;
private readonly IValidator<ChangeMyPasswordCommand> _changePasswordValidator;
public UsuariosController(
IDispatcher dispatcher,
IValidator<CreateUsuarioCommand> createValidator,
IValidator<UpdateUsuarioCommand> updateValidator,
IValidator<ChangeMyPasswordCommand> changePasswordValidator)
{
_dispatcher = dispatcher;
_createValidator = createValidator;
_updateValidator = updateValidator;
_changePasswordValidator = changePasswordValidator;
}
/// <summary>Creates a new user. Requires administracion:usuarios:gestionar.</summary>
[HttpPost]
[RequirePermission("administracion:usuarios:gestionar")]
[ProducesResponseType(typeof(UsuarioCreatedDto), StatusCodes.Status201Created)]
[ProducesResponseType(StatusCodes.Status400BadRequest)]
[ProducesResponseType(StatusCodes.Status401Unauthorized)]
[ProducesResponseType(StatusCodes.Status403Forbidden)]
[ProducesResponseType(StatusCodes.Status409Conflict)]
public async Task<IActionResult> CreateUsuario([FromBody] CreateUsuarioRequest request)
{
var command = new CreateUsuarioCommand(
Username: request.Username ?? string.Empty,
Password: request.Password ?? string.Empty,
Nombre: request.Nombre ?? string.Empty,
Apellido: request.Apellido ?? string.Empty,
Email: request.Email,
Rol: request.Rol ?? string.Empty);
var validation = await _createValidator.ValidateAsync(command);
if (!validation.IsValid)
{
var errors = validation.Errors
.GroupBy(e => e.PropertyName)
.ToDictionary(g => g.Key, g => g.Select(e => e.ErrorMessage).ToArray());
return BadRequest(new { errors });
}
var result = await _dispatcher.Send<CreateUsuarioCommand, UsuarioCreatedDto>(command);
return CreatedAtAction(nameof(CreateUsuario), new { id = result.Id }, result);
}
/// <summary>Lists usuarios with optional filters and pagination.</summary>
[HttpGet]
[RequirePermission("administracion:usuarios:gestionar")]
[ProducesResponseType(typeof(PagedResult<UsuarioListItemDto>), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status400BadRequest)]
[ProducesResponseType(StatusCodes.Status401Unauthorized)]
[ProducesResponseType(StatusCodes.Status403Forbidden)]
public async Task<IActionResult> ListUsuarios(
[FromQuery] int page = 1,
[FromQuery] int pageSize = 20,
[FromQuery] string? rol = null,
[FromQuery] bool? activo = null,
[FromQuery] string? search = null)
{
if (page < 1) return BadRequest(new { error = "page must be >= 1" });
if (pageSize < 1) return BadRequest(new { error = "pageSize must be >= 1" });
var query = new ListUsuariosQuery(page, pageSize, rol, activo, search);
var result = await _dispatcher.Send<ListUsuariosQuery, PagedResult<UsuarioListItemDto>>(query);
return Ok(result);
}
/// <summary>Gets a single usuario by id.</summary>
[HttpGet("{id:int}")]
[RequirePermission("administracion:usuarios:gestionar")]
[ProducesResponseType(typeof(UsuarioDetailDto), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status401Unauthorized)]
[ProducesResponseType(StatusCodes.Status403Forbidden)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<IActionResult> GetUsuarioById([FromRoute] int id)
{
var query = new GetUsuarioByIdQuery(id);
var result = await _dispatcher.Send<GetUsuarioByIdQuery, UsuarioDetailDto>(query);
return Ok(result);
}
/// <summary>Updates a usuario's editable fields.</summary>
[HttpPut("{id:int}")]
[RequirePermission("administracion:usuarios:gestionar")]
[ProducesResponseType(typeof(UsuarioDetailDto), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status400BadRequest)]
[ProducesResponseType(StatusCodes.Status401Unauthorized)]
[ProducesResponseType(StatusCodes.Status403Forbidden)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<IActionResult> UpdateUsuario([FromRoute] int id, [FromBody] UpdateUsuarioRequest request)
{
var command = new UpdateUsuarioCommand(
Id: id,
Nombre: request.Nombre ?? string.Empty,
Apellido: request.Apellido ?? string.Empty,
Email: request.Email,
Rol: request.Rol ?? string.Empty,
Activo: request.Activo ?? true);
var validation = await _updateValidator.ValidateAsync(command);
if (!validation.IsValid)
{
var errors = validation.Errors
.GroupBy(e => e.PropertyName)
.ToDictionary(g => g.Key, g => g.Select(e => e.ErrorMessage).ToArray());
return BadRequest(new { errors });
}
var result = await _dispatcher.Send<UpdateUsuarioCommand, UsuarioDetailDto>(command);
return Ok(result);
}
/// <summary>Deactivates a usuario (idempotent).</summary>
[HttpPatch("{id:int}/deactivate")]
[RequirePermission("administracion:usuarios:gestionar")]
[ProducesResponseType(typeof(UsuarioDetailDto), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status400BadRequest)]
[ProducesResponseType(StatusCodes.Status401Unauthorized)]
[ProducesResponseType(StatusCodes.Status403Forbidden)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<IActionResult> DeactivateUsuario([FromRoute] int id)
{
var command = new DeactivateUsuarioCommand(id);
var result = await _dispatcher.Send<DeactivateUsuarioCommand, UsuarioDetailDto>(command);
return Ok(result);
}
/// <summary>Reactivates a usuario (idempotent).</summary>
[HttpPatch("{id:int}/reactivate")]
[RequirePermission("administracion:usuarios:gestionar")]
[ProducesResponseType(typeof(UsuarioDetailDto), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status401Unauthorized)]
[ProducesResponseType(StatusCodes.Status403Forbidden)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<IActionResult> ReactivateUsuario([FromRoute] int id)
{
var command = new ReactivateUsuarioCommand(id);
var result = await _dispatcher.Send<ReactivateUsuarioCommand, UsuarioDetailDto>(command);
return Ok(result);
}
/// <summary>
/// Changes the authenticated user's own password.
/// Declared BEFORE /{id:int} route to avoid routing ambiguity (though :int constraint handles it).
/// Requires only authentication (no specific permission).
/// </summary>
[HttpPut("me/password")]
[Authorize]
[ProducesResponseType(StatusCodes.Status204NoContent)]
[ProducesResponseType(StatusCodes.Status400BadRequest)]
[ProducesResponseType(StatusCodes.Status401Unauthorized)]
public async Task<IActionResult> ChangeMyPassword([FromBody] ChangeMyPasswordRequest request)
{
var sub = User.FindFirstValue(JwtRegisteredClaimNames.Sub)
?? User.FindFirstValue(ClaimTypes.NameIdentifier)
?? throw new UnauthorizedAccessException();
var command = new ChangeMyPasswordCommand(
UsuarioId: int.Parse(sub),
OldPassword: request.OldPassword ?? string.Empty,
NewPassword: request.NewPassword ?? string.Empty);
var validation = await _changePasswordValidator.ValidateAsync(command);
if (!validation.IsValid)
{
var errors = validation.Errors
.GroupBy(e => e.PropertyName)
.ToDictionary(g => g.Key, g => g.Select(e => e.ErrorMessage).ToArray());
return BadRequest(new { errors });
}
await _dispatcher.Send<ChangeMyPasswordCommand, Unit>(command);
return NoContent();
}
/// <summary>Resets a usuario's password (admin only). Returns a one-time temp password.</summary>
[HttpPost("{id:int}/password/reset")]
[RequirePermission("administracion:usuarios:gestionar")]
[ProducesResponseType(typeof(ResetUsuarioPasswordResponse), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status400BadRequest)]
[ProducesResponseType(StatusCodes.Status401Unauthorized)]
[ProducesResponseType(StatusCodes.Status403Forbidden)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<IActionResult> ResetUsuarioPassword([FromRoute] int id)
{
var sub = User.FindFirstValue(JwtRegisteredClaimNames.Sub)
?? User.FindFirstValue(ClaimTypes.NameIdentifier)
?? throw new UnauthorizedAccessException();
var command = new ResetUsuarioPasswordCommand(
TargetId: id,
CallerId: int.Parse(sub));
var result = await _dispatcher.Send<ResetUsuarioPasswordCommand, ResetUsuarioPasswordResponse>(command);
return Ok(result);
}
// ── UDT-009: Permisos endpoints ───────────────────────────────────────────
/// <summary>
/// Gets a usuario's role permissions, explicit grant/deny overrides, and computed effective set.
/// Requires administracion:usuarios:gestionar.
/// </summary>
[HttpGet("{id:int}/permisos")]
[RequirePermission("administracion:usuarios:gestionar")]
[ProducesResponseType(typeof(UsuarioPermisosResponse), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status401Unauthorized)]
[ProducesResponseType(StatusCodes.Status403Forbidden)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<IActionResult> GetPermisos([FromRoute] int id)
{
var result = await _dispatcher.Send<GetUsuarioPermisosQuery, UsuarioPermisosDto>(
new GetUsuarioPermisosQuery(id));
return Ok(MapToPermisosResponse(result));
}
/// <summary>
/// Replaces the grant/deny override sets for a usuario.
/// Requires administracion:usuarios:gestionar.
/// </summary>
[HttpPut("{id:int}/permisos/overrides")]
[RequirePermission("administracion:usuarios:gestionar")]
[ProducesResponseType(typeof(UsuarioPermisosResponse), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status400BadRequest)]
[ProducesResponseType(StatusCodes.Status401Unauthorized)]
[ProducesResponseType(StatusCodes.Status403Forbidden)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<IActionResult> UpdatePermisosOverrides(
[FromRoute] int id,
[FromBody] UpdatePermisosOverridesRequest request)
{
var command = new UpdateUsuarioPermisosOverridesCommand(
Id: id,
Grant: request.Grant ?? [],
Deny: request.Deny ?? []);
var result = await _dispatcher.Send<UpdateUsuarioPermisosOverridesCommand, UsuarioPermisosDto>(command);
return Ok(MapToPermisosResponse(result));
}
private static UsuarioPermisosResponse MapToPermisosResponse(UsuarioPermisosDto dto)
=> new(
RolPermisos: dto.RolPermisos,
Overrides: new PermisosOverridesShape(dto.Grant, dto.Deny),
Effective: dto.Effective);
}
// ── request body records ──────────────────────────────────────────────────────
/// <summary>UDT-009: Response shape for permisos endpoints.</summary>
public sealed record UsuarioPermisosResponse(
IReadOnlyList<string> RolPermisos,
PermisosOverridesShape Overrides,
IReadOnlyList<string> Effective);
/// <summary>UDT-009: The grant/deny override shape nested in UsuarioPermisosResponse.</summary>
public sealed record PermisosOverridesShape(
IReadOnlyList<string> Grant,
IReadOnlyList<string> Deny);
/// <summary>UDT-009: PUT permisos/overrides request body.</summary>
public sealed record UpdatePermisosOverridesRequest(
IReadOnlyList<string>? Grant,
IReadOnlyList<string>? Deny);
/// <summary>Create user request body — nullable to catch missing field scenarios.</summary>
public sealed record CreateUsuarioRequest(
string? Username,
string? Password,
string? Nombre,
string? Apellido,
string? Email,
string? Rol);
public sealed record UpdateUsuarioRequest(
string? Nombre,
string? Apellido,
string? Email,
string? Rol,
bool? Activo);
public sealed record ChangeMyPasswordRequest(
string? OldPassword,
string? NewPassword);

View File

@@ -0,0 +1,758 @@
using FluentValidation;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc.Filters;
using Microsoft.Data.SqlClient;
using SIGCM2.Domain.Exceptions;
using SIGCM2.Domain.Pricing.Exceptions;
namespace SIGCM2.Api.Filters;
public sealed class ExceptionFilter : IExceptionFilter
{
private readonly ILogger<ExceptionFilter> _logger;
public ExceptionFilter(ILogger<ExceptionFilter> logger)
{
_logger = logger;
}
public void OnException(ExceptionContext context)
{
switch (context.Exception)
{
case UsuarioNotFoundException usuarioNotFoundEx:
context.Result = new ObjectResult(new
{
error = "usuario_not_found",
message = usuarioNotFoundEx.Message
})
{
StatusCode = StatusCodes.Status404NotFound
};
context.ExceptionHandled = true;
break;
case LastAdminLockoutException:
context.Result = new ObjectResult(new Microsoft.AspNetCore.Mvc.ProblemDetails
{
Type = "about:blank",
Title = "last-admin-lockout",
Status = 400,
Detail = "No se puede desactivar o cambiar el rol del último administrador activo."
})
{
StatusCode = StatusCodes.Status400BadRequest
};
context.ExceptionHandled = true;
break;
case CannotSelfResetException:
context.Result = new ObjectResult(new
{
error = "cannot-self-reset",
message = "Un administrador no puede resetear su propia contraseña. Use el endpoint de cambio de contraseña propio."
})
{
StatusCode = StatusCodes.Status400BadRequest
};
context.ExceptionHandled = true;
break;
case InvalidOldPasswordException:
context.Result = new ObjectResult(new
{
error = "invalid-old-password",
message = "La contraseña actual es incorrecta."
})
{
StatusCode = StatusCodes.Status400BadRequest
};
context.ExceptionHandled = true;
break;
case UsernameAlreadyExistsException usernameEx:
context.Result = new ObjectResult(new
{
error = "username_taken",
message = usernameEx.Message
})
{
StatusCode = StatusCodes.Status409Conflict
};
context.ExceptionHandled = true;
break;
case SqlException sqlEx when sqlEx.Number == 2627:
// Safety net: UQ constraint violation from a race condition
context.Result = new ObjectResult(new
{
error = "username_taken",
message = "El nombre de usuario ya está en uso."
})
{
StatusCode = StatusCodes.Status409Conflict
};
context.ExceptionHandled = true;
break;
case InvalidCredentialsException:
context.Result = new ObjectResult(new { error = "Credenciales inválidas" })
{
StatusCode = StatusCodes.Status401Unauthorized
};
context.ExceptionHandled = true;
break;
case TokenReuseDetectedException reuseEx:
// Log with detail on the backend but return generic 401 to client
_logger.LogWarning("Token reuse detected — possible session compromise: {Message}", reuseEx.Message);
context.Result = new ObjectResult(new { error = "Token inválido" })
{
StatusCode = StatusCodes.Status401Unauthorized
};
context.ExceptionHandled = true;
break;
case InvalidRefreshTokenException:
// Generic 401 — do NOT reveal if token was expired, not found, or mismatched
context.Result = new ObjectResult(new { error = "Token inválido" })
{
StatusCode = StatusCodes.Status401Unauthorized
};
context.ExceptionHandled = true;
break;
case RolNotFoundException rolNotFoundEx:
context.Result = new ObjectResult(new
{
error = "rol_not_found",
message = rolNotFoundEx.Message
})
{
StatusCode = StatusCodes.Status404NotFound
};
context.ExceptionHandled = true;
break;
case PermisoNotFoundException permisoNotFoundEx:
context.Result = new ObjectResult(new
{
error = "permiso_not_found",
message = permisoNotFoundEx.Message
})
{
StatusCode = StatusCodes.Status404NotFound
};
context.ExceptionHandled = true;
break;
case RolAlreadyExistsException rolExistsEx:
context.Result = new ObjectResult(new
{
error = "rol_already_exists",
message = rolExistsEx.Message
})
{
StatusCode = StatusCodes.Status409Conflict
};
context.ExceptionHandled = true;
break;
case RolInUseException rolInUseEx:
context.Result = new ObjectResult(new
{
error = "rol_in_use",
message = rolInUseEx.Message
})
{
StatusCode = StatusCodes.Status409Conflict
};
context.ExceptionHandled = true;
break;
// CAT-001: Rubro exceptions
case RubroNotFoundException rubroNotFoundEx:
context.Result = new ObjectResult(new
{
error = "rubro_not_found",
message = rubroNotFoundEx.Message
})
{
StatusCode = StatusCodes.Status404NotFound
};
context.ExceptionHandled = true;
break;
case RubroNombreDuplicadoEnPadreException rubroDupEx:
context.Result = new ObjectResult(new
{
error = "rubro_nombre_duplicado",
message = rubroDupEx.Message
})
{
StatusCode = StatusCodes.Status409Conflict
};
context.ExceptionHandled = true;
break;
case RubroTieneHijosActivosException rubroHijosEx:
context.Result = new ObjectResult(new
{
error = "rubro_tiene_hijos_activos",
message = rubroHijosEx.Message
})
{
StatusCode = StatusCodes.Status409Conflict
};
context.ExceptionHandled = true;
break;
case RubroPadreInactivoException rubroPadreEx:
context.Result = new ObjectResult(new
{
error = "rubro_padre_inactivo",
message = rubroPadreEx.Message
})
{
StatusCode = StatusCodes.Status400BadRequest
};
context.ExceptionHandled = true;
break;
case RubroMaxDepthExceededException rubroDepthEx:
context.Result = new ObjectResult(new
{
error = "rubro_max_depth_exceeded",
message = rubroDepthEx.Message
})
{
StatusCode = StatusCodes.Status422UnprocessableEntity
};
context.ExceptionHandled = true;
break;
case RubroCycleDetectedException rubroCycleEx:
context.Result = new ObjectResult(new
{
error = "rubro_cycle_detected",
message = rubroCycleEx.Message
})
{
StatusCode = StatusCodes.Status400BadRequest
};
context.ExceptionHandled = true;
break;
// CAT-002: Rubro Regla de Oro (rama vs hoja)
case RubroPadreEsHojaConAvisosException rubroPadreHojaEx:
context.Result = new ObjectResult(new
{
error = "rubro_padre_es_hoja_con_avisos",
message = rubroPadreHojaEx.Message
})
{
StatusCode = StatusCodes.Status409Conflict
};
context.ExceptionHandled = true;
break;
case RubroEsRamaConHijosActivosException rubroRamaHijosEx:
context.Result = new ObjectResult(new
{
error = "rubro_es_rama_con_hijos_activos",
message = rubroRamaHijosEx.Message
})
{
StatusCode = StatusCodes.Status409Conflict
};
context.ExceptionHandled = true;
break;
case RubroConProductosActivosException rubroProductosEx:
context.Result = new ObjectResult(new
{
error = "rubro_con_productos_activos",
message = rubroProductosEx.Message
})
{
StatusCode = StatusCodes.Status409Conflict
};
context.ExceptionHandled = true;
break;
// ADM-001: Medio exceptions
case MedioCodigoDuplicadoException medioCodDupEx:
context.Result = new ObjectResult(new
{
error = "medio_codigo_duplicado",
message = medioCodDupEx.Message
})
{
StatusCode = StatusCodes.Status409Conflict
};
context.ExceptionHandled = true;
break;
case MedioNotFoundException medioNotFoundEx:
context.Result = new ObjectResult(new
{
error = "medio_not_found",
message = medioNotFoundEx.Message
})
{
StatusCode = StatusCodes.Status404NotFound
};
context.ExceptionHandled = true;
break;
case MedioInactivoException medioInactivoEx:
context.Result = new ObjectResult(new
{
error = "medio_inactivo",
message = medioInactivoEx.Message
})
{
StatusCode = StatusCodes.Status409Conflict
};
context.ExceptionHandled = true;
break;
// ADM-001: Seccion exceptions
case SeccionCodigoDuplicadoEnMedioException seccionCodDupEx:
context.Result = new ObjectResult(new
{
error = "seccion_codigo_duplicado_en_medio",
message = seccionCodDupEx.Message
})
{
StatusCode = StatusCodes.Status409Conflict
};
context.ExceptionHandled = true;
break;
case SeccionNotFoundException seccionNotFoundEx:
context.Result = new ObjectResult(new
{
error = "seccion_not_found",
message = seccionNotFoundEx.Message
})
{
StatusCode = StatusCodes.Status404NotFound
};
context.ExceptionHandled = true;
break;
// ADM-009: TipoDeIva fiscal exceptions
case PorcentajeInmutableException:
context.Result = new ObjectResult(new
{
error = "inmutable_usar_nueva_version",
message = "El porcentaje de un TipoDeIva es inmutable. Creá una nueva versión vía POST /iva/{id}/nueva-version."
})
{
StatusCode = StatusCodes.Status409Conflict
};
context.ExceptionHandled = true;
break;
case AlicuotaInmutableException:
context.Result = new ObjectResult(new
{
error = "inmutable_usar_nueva_version",
message = "La alícuota de IngresosBrutos es inmutable. Creá una nueva versión vía POST /iibb/{id}/nueva-version."
})
{
StatusCode = StatusCodes.Status409Conflict
};
context.ExceptionHandled = true;
break;
case PredecesorYaCerradoException predecesorYaCerradoEx:
context.Result = new ObjectResult(new
{
error = "predecesora_ya_cerrada",
message = predecesorYaCerradoEx.Message
})
{
StatusCode = StatusCodes.Status409Conflict
};
context.ExceptionHandled = true;
break;
case DuplicateCodigoException duplicateCodigoEx:
context.Result = new ObjectResult(new
{
error = "duplicate_codigo",
message = duplicateCodigoEx.Message
})
{
StatusCode = StatusCodes.Status409Conflict
};
context.ExceptionHandled = true;
break;
case DuplicateProvinciaException duplicateProvinciaEx:
context.Result = new ObjectResult(new
{
error = "duplicate_provincia",
message = duplicateProvinciaEx.Message
})
{
StatusCode = StatusCodes.Status409Conflict
};
context.ExceptionHandled = true;
break;
case TipoDeIvaNotFoundException tipoDeIvaNotFoundEx:
context.Result = new ObjectResult(new
{
error = "tipo_iva_not_found",
message = tipoDeIvaNotFoundEx.Message
})
{
StatusCode = StatusCodes.Status404NotFound
};
context.ExceptionHandled = true;
break;
case IngresosBrutosNotFoundException ingresosBrutosNotFoundEx:
context.Result = new ObjectResult(new
{
error = "ingresos_brutos_not_found",
message = ingresosBrutosNotFoundEx.Message
})
{
StatusCode = StatusCodes.Status404NotFound
};
context.ExceptionHandled = true;
break;
// PRD-001: ProductType exceptions
case ProductTypeNotFoundException productTypeNotFoundEx:
context.Result = new ObjectResult(new
{
error = "product_type_not_found",
message = productTypeNotFoundEx.Message
})
{
StatusCode = StatusCodes.Status404NotFound
};
context.ExceptionHandled = true;
break;
case ProductTypeNombreDuplicadoException productTypeDupEx:
context.Result = new ObjectResult(new
{
error = "product_type_nombre_duplicado",
message = productTypeDupEx.Message
})
{
StatusCode = StatusCodes.Status409Conflict
};
context.ExceptionHandled = true;
break;
case ProductTypeEnUsoException productTypeEnUsoEx:
context.Result = new ObjectResult(new
{
error = "product_type_en_uso",
message = productTypeEnUsoEx.Message
})
{
StatusCode = StatusCodes.Status409Conflict
};
context.ExceptionHandled = true;
break;
case ProductTypeFlagsIncoherentesException productTypeFlagsEx:
context.Result = new ObjectResult(new
{
error = "product_type_flags_incoherentes",
message = productTypeFlagsEx.Message
})
{
StatusCode = StatusCodes.Status422UnprocessableEntity
};
context.ExceptionHandled = true;
break;
// PRD-003: ProductPrices exceptions
case ProductPriceForwardOnlyException forwardOnlyEx:
context.Result = new ObjectResult(new
{
error = "product_price_forward_only",
message = forwardOnlyEx.Message,
productId = forwardOnlyEx.ProductId
})
{
StatusCode = StatusCodes.Status409Conflict
};
context.ExceptionHandled = true;
break;
case ProductPriceInvalidException priceInvalidEx:
context.Result = new ObjectResult(new
{
error = "product_price_invalid",
message = priceInvalidEx.Message,
field = priceInvalidEx.Field
})
{
StatusCode = StatusCodes.Status400BadRequest
};
context.ExceptionHandled = true;
break;
case ProductSinPrecioActivoException sinPrecioEx:
context.Result = new ObjectResult(new
{
error = "product_sin_precio_activo",
message = sinPrecioEx.Message
})
{
StatusCode = StatusCodes.Status404NotFound
};
context.ExceptionHandled = true;
break;
// PRD-002: Product exceptions
case ProductNotFoundException productNotFoundEx:
context.Result = new ObjectResult(new
{
error = "product_not_found",
message = productNotFoundEx.Message
})
{
StatusCode = StatusCodes.Status404NotFound
};
context.ExceptionHandled = true;
break;
case ProductNombreDuplicadoEnMedioTipoException productDupEx:
context.Result = new ObjectResult(new
{
error = "product_nombre_duplicado",
message = productDupEx.Message
})
{
StatusCode = StatusCodes.Status409Conflict
};
context.ExceptionHandled = true;
break;
case ProductTipoFlagsIncoherentesException productFlagsEx:
context.Result = new ObjectResult(new
{
error = "product_flags_incoherentes",
field = productFlagsEx.Field,
message = productFlagsEx.Message
})
{
StatusCode = StatusCodes.Status422UnprocessableEntity
};
context.ExceptionHandled = true;
break;
case ProductTypeInactivoException productTypeInactivoEx:
context.Result = new ObjectResult(new
{
error = "product_type_inactivo",
message = productTypeInactivoEx.Message
})
{
StatusCode = StatusCodes.Status409Conflict
};
context.ExceptionHandled = true;
break;
case RubroInactivoException rubroInactivoEx:
context.Result = new ObjectResult(new
{
error = "rubro_inactivo",
message = rubroInactivoEx.Message
})
{
StatusCode = StatusCodes.Status409Conflict
};
context.ExceptionHandled = true;
break;
// ADM-008: PuntoDeVenta exceptions
case PuntoDeVentaNotFoundException puntoDeVentaNotFoundEx:
context.Result = new ObjectResult(new
{
error = "punto_de_venta_not_found",
message = puntoDeVentaNotFoundEx.Message
})
{
StatusCode = StatusCodes.Status404NotFound
};
context.ExceptionHandled = true;
break;
case NumeroAFIPDuplicadoException numeroAFIPDupEx:
context.Result = new ObjectResult(new
{
error = "numero_afip_duplicado",
message = numeroAFIPDupEx.Message
})
{
StatusCode = StatusCodes.Status409Conflict
};
context.ExceptionHandled = true;
break;
// UDT-009: permiso override validation errors
case InvalidPermisoCodesException ipce:
context.Result = new ObjectResult(new Microsoft.AspNetCore.Mvc.ProblemDetails
{
Type = "about:blank",
Title = "invalid-permiso-codes",
Status = 400,
Extensions = { ["invalidCodes"] = ipce.InvalidCodes }
})
{
StatusCode = StatusCodes.Status400BadRequest
};
context.ExceptionHandled = true;
break;
case GrantDenyOverlapException gdoe:
context.Result = new ObjectResult(new Microsoft.AspNetCore.Mvc.ProblemDetails
{
Type = "about:blank",
Title = "grant-deny-overlap",
Status = 400,
Extensions = { ["overlap"] = gdoe.Overlap }
})
{
StatusCode = StatusCodes.Status400BadRequest
};
context.ExceptionHandled = true;
break;
// ADM-009: vigencia_desde_invalida — domain throws ArgumentException for invalid vigencia range
case ArgumentException argEx when argEx.Message.Contains("vigencia_desde_invalida") ||
argEx.ParamName == "vigenciaDesde" ||
argEx.Message.Contains("debe ser posterior"):
context.Result = new ObjectResult(new
{
error = "vigencia_desde_invalida",
message = argEx.Message
})
{
StatusCode = StatusCodes.Status400BadRequest
};
context.ExceptionHandled = true;
break;
// PRC-001: WordCounter + ChargeableCharConfig exceptions
case EmojiDetectedException emojiEx:
context.Result = new ObjectResult(new
{
error = "emoji_not_allowed",
code = "EMOJI_NOT_ALLOWED",
message = emojiEx.Message
})
{
StatusCode = StatusCodes.Status400BadRequest
};
context.ExceptionHandled = true;
break;
case WordCountValidationException wordEx:
context.Result = new ObjectResult(new
{
error = "word_count_validation",
code = "WORD_COUNT_VALIDATION",
field = wordEx.Field,
reason = wordEx.Reason,
message = wordEx.Message
})
{
StatusCode = StatusCodes.Status400BadRequest
};
context.ExceptionHandled = true;
break;
case ChargeableCharConfigInvalidException configInvalidEx:
context.Result = new ObjectResult(new
{
error = "chargeable_char_invalid",
code = "CHARGEABLE_CHAR_INVALID",
field = configInvalidEx.Field,
reason = configInvalidEx.Reason,
message = configInvalidEx.Message
})
{
StatusCode = StatusCodes.Status400BadRequest
};
context.ExceptionHandled = true;
break;
case ChargeableCharConfigForwardOnlyException forwardOnlyCharEx:
context.Result = new ObjectResult(new
{
error = "chargeable_char_forward_only",
code = "CHARGEABLE_CHAR_FORWARD_ONLY",
productTypeId = forwardOnlyCharEx.ProductTypeId,
symbol = forwardOnlyCharEx.Symbol,
newValidFrom = forwardOnlyCharEx.NewValidFrom,
activeValidFrom = forwardOnlyCharEx.ActiveValidFrom,
message = forwardOnlyCharEx.Message
})
{
StatusCode = StatusCodes.Status409Conflict
};
context.ExceptionHandled = true;
break;
case ChargeableCharConfigReactivationNotAllowedException reactivationEx:
context.Result = new ObjectResult(new
{
error = "chargeable_char_reactivation_not_allowed",
code = "CHARGEABLE_CHAR_REACTIVATION_NOT_ALLOWED",
id = reactivationEx.Id,
reason = reactivationEx.Reason,
message = reactivationEx.Message
})
{
StatusCode = StatusCodes.Status409Conflict
};
context.ExceptionHandled = true;
break;
case KeyNotFoundException keyNotFoundEx:
context.Result = new ObjectResult(new
{
error = "not_found",
message = keyNotFoundEx.Message
})
{
StatusCode = StatusCodes.Status404NotFound
};
context.ExceptionHandled = true;
break;
case ValidationException validationEx:
var errors = validationEx.Errors
.GroupBy(e => e.PropertyName)
.ToDictionary(
g => g.Key,
g => g.Select(e => e.ErrorMessage).ToArray());
context.Result = new BadRequestObjectResult(new { errors });
context.ExceptionHandled = true;
break;
default:
_logger.LogError(context.Exception, "Unhandled exception");
context.Result = new ObjectResult(new { error = "Internal server error" })
{
StatusCode = StatusCodes.Status500InternalServerError
};
context.ExceptionHandled = true;
break;
}
}
}

View File

@@ -0,0 +1,126 @@
using Dapper;
using Microsoft.Extensions.Diagnostics.HealthChecks;
using SIGCM2.Infrastructure.Persistence;
namespace SIGCM2.Api.HealthChecks;
/// <summary>
/// UDT-010 (#REQ-AUD-8): health check for audit infrastructure.
/// Validates:
/// - SYSTEM_VERSIONING is ON for Usuario/Rol/Permiso/RolPermiso.
/// - Monthly partitions exist for the next 3 months on AuditEvent + SecurityEvent.
/// - Last AuditEvent is recent enough (< 24h) — relaxed from 1h spec to accommodate
/// quiet dev/test environments; prod deployments should tighten to 1h via config.
/// - HISTORY_RETENTION_PERIOD matches 10 years for the 4 versioned catalog tables.
/// Returns Unhealthy with details when any check fails.
/// </summary>
public sealed class AuditHealthCheck : IHealthCheck
{
private readonly SqlConnectionFactory _factory;
public AuditHealthCheck(SqlConnectionFactory factory)
{
_factory = factory;
}
public async Task<HealthCheckResult> CheckHealthAsync(
HealthCheckContext context,
CancellationToken cancellationToken = default)
{
try
{
await using var conn = _factory.CreateConnection();
await conn.OpenAsync(cancellationToken);
// 1. SYSTEM_VERSIONING checks
var versionedMissing = (await conn.QueryAsync<string>("""
SELECT t.name
FROM sys.tables t
WHERE t.name IN ('Usuario','Rol','Permiso','RolPermiso')
AND t.temporal_type <> 2;
""")).ToList();
if (versionedMissing.Any())
{
return HealthCheckResult.Unhealthy(
$"SYSTEM_VERSIONING missing on: {string.Join(",", versionedMissing)}");
}
// 2. Partitions for next 3 months in both event tables
var now = DateTime.UtcNow;
var requiredBoundaries = new[]
{
new DateTime(now.Year, now.Month, 1).AddMonths(1),
new DateTime(now.Year, now.Month, 1).AddMonths(2),
new DateTime(now.Year, now.Month, 1).AddMonths(3),
};
foreach (var pfName in new[] { "pf_AuditEvent_Monthly", "pf_SecurityEvent_Monthly" })
{
var values = (await conn.QueryAsync<DateTime>("""
SELECT CAST(prv.value AS DATETIME2(3))
FROM sys.partition_functions pf
JOIN sys.partition_range_values prv ON prv.function_id = pf.function_id
WHERE pf.name = @Name;
""", new { Name = pfName })).ToHashSet();
foreach (var req in requiredBoundaries)
{
if (!values.Contains(req))
{
return HealthCheckResult.Unhealthy(
$"Partition boundary missing in {pfName}: {req:yyyy-MM-dd}");
}
}
}
// 3. Recent audit activity — lenient 24h to avoid false positives in quiet envs
var lastEventAt = await conn.ExecuteScalarAsync<DateTime?>(
"SELECT MAX(OccurredAt) FROM dbo.AuditEvent;");
var recentMessage = lastEventAt is null
? "no audit events yet (acceptable on fresh DB)"
: (now - lastEventAt.Value).TotalHours < 24
? "recent"
: $"stale: last event {(now - lastEventAt.Value).TotalHours:F1}h ago";
// 4. Retention period check.
// sys.tables.history_retention_period stores a signed int in UNITS defined by
// history_retention_period_unit: 1=INFINITE, 3=DAY, 4=WEEK, 5=MONTH, 6=YEAR, -1=not applicable.
// V010 sets HISTORY_RETENTION_PERIOD = 10 YEARS → period=10, unit=6.
var retentionRows = (await conn.QueryAsync<(string TableName, int? Period, int? Unit)>("""
SELECT t.name AS TableName,
t.history_retention_period AS Period,
t.history_retention_period_unit AS Unit
FROM sys.tables t
WHERE t.name IN ('Usuario','Rol','Permiso','RolPermiso')
AND t.temporal_type = 2;
""")).ToList();
var badRetention = retentionRows
.Where(r => !(r.Period == 10 && r.Unit == 6)) // not 10 YEARS
.Select(r => r.TableName)
.ToList();
var data = new Dictionary<string, object>
{
["versionedTables"] = "Usuario, Rol, Permiso, RolPermiso",
["lastAuditEvent"] = (object?)lastEventAt ?? "none",
["lastAuditEventStatus"] = recentMessage,
["retentionOk"] = badRetention.Count == 0,
};
if (badRetention.Any())
{
return HealthCheckResult.Degraded(
$"Retention != 10 YEARS for: {string.Join(",", badRetention)}",
data: data);
}
return HealthCheckResult.Healthy("audit infrastructure OK", data);
}
catch (Exception ex)
{
return HealthCheckResult.Unhealthy("audit health check threw", ex);
}
}
}

View File

@@ -0,0 +1,31 @@
using System.Globalization;
using System.Text.Json;
using System.Text.Json.Serialization;
namespace SIGCM2.Api.Json;
/// <summary>
/// JSON converter for <see cref="DateOnly"/> that uses the "yyyy-MM-dd" ISO format.
///
/// UDT-011: Ensures Cat2 date fields (VigenciaDesde, etc.) never serialize as
/// "2026-05-01T00:00:00" or with a UTC suffix "Z", which would mislead consumers
/// into treating civil Argentine dates as absolute UTC instants.
/// </summary>
public sealed class DateOnlyJsonConverter : JsonConverter<DateOnly>
{
private const string DateFormat = "yyyy-MM-dd";
public override DateOnly Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options)
{
var str = reader.GetString();
if (str is null)
throw new JsonException("DateOnly value cannot be null.");
return DateOnly.ParseExact(str, DateFormat, CultureInfo.InvariantCulture);
}
public override void Write(Utf8JsonWriter writer, DateOnly value, JsonSerializerOptions options)
{
writer.WriteStringValue(value.ToString(DateFormat, CultureInfo.InvariantCulture));
}
}

View File

@@ -0,0 +1,32 @@
using Microsoft.AspNetCore.Http;
namespace SIGCM2.Api.Middleware;
/// UDT-010 — post-auth middleware that reads the JWT "sub" claim and stores the
/// resolved ActorUserId in HttpContext.Items. Anonymous requests leave it unset.
/// ActorRoleId is reserved for a future batch (rol code → id resolution).
public sealed class AuditActorMiddleware
{
public const string ItemActorUserId = "audit:actorUserId";
private readonly RequestDelegate _next;
public AuditActorMiddleware(RequestDelegate next)
{
_next = next;
}
public async Task InvokeAsync(HttpContext ctx)
{
if (ctx.User.Identity?.IsAuthenticated == true)
{
var sub = ctx.User.FindFirst("sub")?.Value;
if (int.TryParse(sub, out var userId))
{
ctx.Items[ItemActorUserId] = userId;
}
}
await _next(ctx);
}
}

View File

@@ -0,0 +1,51 @@
using Microsoft.AspNetCore.Http;
namespace SIGCM2.Api.Middleware;
/// UDT-010 — pre-auth middleware that stamps every request with a correlation ID,
/// preserves one sent by the client via X-Correlation-Id, and exposes it on the response.
/// Also captures Ip + UserAgent for downstream IAuditContext consumers.
public sealed class CorrelationIdMiddleware
{
public const string HeaderName = "X-Correlation-Id";
public const string ItemCorrelationId = "audit:correlationId";
public const string ItemIp = "audit:ip";
public const string ItemUserAgent = "audit:userAgent";
private readonly RequestDelegate _next;
public CorrelationIdMiddleware(RequestDelegate next)
{
_next = next;
}
public async Task InvokeAsync(HttpContext ctx)
{
Guid correlationId;
if (ctx.Request.Headers.TryGetValue(HeaderName, out var incoming)
&& Guid.TryParse(incoming.ToString(), out var parsed))
{
correlationId = parsed;
}
else
{
correlationId = Guid.NewGuid();
}
ctx.Items[ItemCorrelationId] = correlationId;
ctx.Items[ItemIp] = ctx.Connection.RemoteIpAddress?.ToString();
ctx.Items[ItemUserAgent] = ctx.Request.Headers.UserAgent.ToString();
ctx.Response.OnStarting(() =>
{
ctx.Response.Headers[HeaderName] = correlationId.ToString("D");
return Task.CompletedTask;
});
// Also set immediately for testability — DefaultHttpContext does not trigger OnStarting
// in unit tests because no body is written through the pipeline.
ctx.Response.Headers[HeaderName] = correlationId.ToString("D");
await _next(ctx);
}
}

View File

@@ -0,0 +1,105 @@
using Microsoft.AspNetCore.Authorization;
using Serilog;
using Scalar.AspNetCore;
using SIGCM2.Api.Authorization;
using SIGCM2.Api.Filters;
using SIGCM2.Api.HealthChecks;
using SIGCM2.Api.Json;
using SIGCM2.Api.Middleware;
using SIGCM2.Application;
using SIGCM2.Infrastructure;
using SIGCM2.Infrastructure.Audit.Jobs;
// Bootstrap logger — before DI is built
Log.Logger = new LoggerConfiguration()
.WriteTo.Console()
.CreateBootstrapLogger();
Log.Information("Starting SIGCM2 API");
var builder = WebApplication.CreateBuilder(args);
// Serilog — reads from appsettings.json "Serilog" section
builder.Host.UseSerilog((ctx, lc) => lc
.ReadFrom.Configuration(ctx.Configuration));
// Application + Infrastructure DI
builder.Services.AddApplication();
builder.Services.AddInfrastructure(builder.Configuration);
// UDT-010: Quartz.NET + 3 audit maintenance jobs (partition, retention, integrity).
// Disabled in Testing environment to keep integration tests deterministic.
if (!builder.Environment.IsEnvironment("Testing"))
builder.Services.AddAuditMaintenance(builder.Configuration);
// Authorization — handler lives in Api layer; DO NOT move to Infrastructure DI
builder.Services.AddAuthorization();
builder.Services.AddScoped<IAuthorizationHandler, PermissionAuthorizationHandler>();
builder.Services.AddSingleton<IAuthorizationMiddlewareResultHandler, ForbiddenProblemDetailsHandler>();
// Controllers with exception filter + JSON options
// UDT-011: DateOnlyJsonConverter ensures Cat2 date fields serialize as "yyyy-MM-dd"
// and never as "2026-05-01T00:00:00" or with a UTC "Z" suffix.
builder.Services.AddControllers(opts =>
{
opts.Filters.Add<ExceptionFilter>();
}).AddJsonOptions(jsonOpts =>
{
jsonOpts.JsonSerializerOptions.Converters.Add(new DateOnlyJsonConverter());
});
// OpenAPI / Scalar
builder.Services.AddOpenApi();
// UDT-010: Audit infrastructure health check
builder.Services.AddHealthChecks()
.AddCheck<AuditHealthCheck>("audit", tags: new[] { "audit" });
// CORS
var allowedOrigins = builder.Configuration
.GetSection("Cors:AllowedOrigins")
.Get<string[]>() ?? [];
builder.Services.AddCors(opts =>
{
opts.AddDefaultPolicy(policy =>
policy.WithOrigins(allowedOrigins)
.AllowAnyHeader()
.AllowAnyMethod());
});
var app = builder.Build();
// Middleware pipeline
app.UseSerilogRequestLogging();
if (app.Environment.IsDevelopment() || app.Environment.IsEnvironment("Testing"))
{
app.MapOpenApi();
app.MapScalarApiReference(opts =>
{
opts.Title = "SIGCM2 API";
});
}
app.UseHttpsRedirection();
app.UseCors();
// UDT-010: correlation id + ip/ua capture runs BEFORE auth so anonymous requests
// still get a correlation id and so logs can tie pre-auth events to the request.
app.UseMiddleware<CorrelationIdMiddleware>();
app.UseAuthentication();
// UDT-010: actor extraction runs AFTER auth to read the JWT sub claim.
app.UseMiddleware<AuditActorMiddleware>();
app.UseAuthorization();
app.MapControllers();
// UDT-010: /health/audit returns the audit check status (public but sparse data).
app.MapHealthChecks("/health/audit", new Microsoft.AspNetCore.Diagnostics.HealthChecks.HealthCheckOptions
{
Predicate = r => r.Tags.Contains("audit"),
});
app.Run();
// Exposed for WebApplicationFactory in integration tests
public partial class Program { }

View File

@@ -0,0 +1,23 @@
{
"$schema": "https://json.schemastore.org/launchsettings.json",
"profiles": {
"http": {
"commandName": "Project",
"dotnetRunMessages": true,
"launchBrowser": false,
"applicationUrl": "http://localhost:5212",
"environmentVariables": {
"ASPNETCORE_ENVIRONMENT": "Development"
}
},
"https": {
"commandName": "Project",
"dotnetRunMessages": true,
"launchBrowser": false,
"applicationUrl": "https://localhost:7280;http://localhost:5212",
"environmentVariables": {
"ASPNETCORE_ENVIRONMENT": "Development"
}
}
}
}

View File

@@ -0,0 +1,25 @@
<Project Sdk="Microsoft.NET.Sdk.Web">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<RootNamespace>SIGCM2.Api</RootNamespace>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.AspNetCore.OpenApi" />
<PackageReference Include="Serilog.AspNetCore" />
<PackageReference Include="Serilog.Sinks.Seq" />
<PackageReference Include="Microsoft.AspNetCore.Authentication.JwtBearer" />
<PackageReference Include="Scalar.AspNetCore" />
<PackageReference Include="FluentValidation.AspNetCore" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\SIGCM2.Application\SIGCM2.Application.csproj" />
<ProjectReference Include="..\SIGCM2.Infrastructure\SIGCM2.Infrastructure.csproj" />
<ProjectReference Include="..\SIGCM2.Domain\SIGCM2.Domain.csproj" />
</ItemGroup>
</Project>

View File

@@ -0,0 +1,6 @@
@SIGCM2.Api_HostAddress = http://localhost:5212
GET {{SIGCM2.Api_HostAddress}}/weatherforecast/
Accept: application/json
###

View File

@@ -0,0 +1,13 @@
{
"ConnectionStrings": {
"SqlServer": "Server=<YOUR_SERVER>;Database=SIGCM2;User Id=<YOUR_USER>;Password=<YOUR_PASSWORD>;TrustServerCertificate=True;"
},
"Serilog": {
"MinimumLevel": {
"Default": "Debug",
"Override": {
"Microsoft.AspNetCore": "Information"
}
}
}
}

View File

@@ -0,0 +1,39 @@
{
"ConnectionStrings": {
"SqlServer": "Server=__SET_IN_DEV_OR_ENV__;Database=SIGCM2;User Id=__SET_IN_DEV_OR_ENV__;Password=__SET_IN_DEV_OR_ENV__;TrustServerCertificate=True;"
},
"Jwt": {
"Issuer": "sigcm2.api",
"Audience": "sigcm2.web",
"AccessTokenMinutes": 60,
"RefreshTokenDays": 7,
"PrivateKeyPath": "keys/private.pem",
"PublicKeyPath": "keys/public.pem",
"PrivateKey": null,
"PublicKey": null
},
"Cors": {
"AllowedOrigins": [ "http://localhost:5173" ]
},
"Serilog": {
"MinimumLevel": {
"Default": "Information",
"Override": {
"Microsoft.AspNetCore": "Warning",
"Microsoft.EntityFrameworkCore": "Warning"
}
},
"WriteTo": [
{ "Name": "Console" },
{
"Name": "Seq",
"Args": { "serverUrl": "http://localhost:5341" }
}
],
"Enrich": [ "FromLogContext", "WithMachineName", "WithThreadId" ]
},
"Rubros": {
"MaxDepth": 10
},
"AllowedHosts": "*"
}

View File

View File

@@ -0,0 +1,28 @@
# JWT RSA Keys
This directory holds the RSA 2048 key pair used for JWT RS256 signing.
## Files (gitignored)
- `private.pem` — RSA private key (NEVER commit this)
- `public.pem` — RSA public key (NEVER commit this)
- `.gitkeep` — keeps this directory tracked in git
## Regenerate keys
Run from the repo root (requires PowerShell 7 / pwsh):
```powershell
pwsh -File scripts/generate-keys.ps1
```
## Production
In production, set these environment variables instead of files:
```
JWT__PrivateKey=<base64-encoded PEM content>
JWT__PublicKey=<base64-encoded PEM content>
```
The API's `RsaKeyLoader` checks environment variables first, falls back to files.

View File

@@ -0,0 +1,12 @@
namespace SIGCM2.Application.Abstractions;
/// <summary>
/// Provides HTTP client metadata (IP address and User-Agent) from the current request context.
/// Implemented in Infrastructure via IHttpContextAccessor.
/// Mockable in tests without HTTP stack.
/// </summary>
public interface IClientContext
{
string Ip { get; }
string? UserAgent { get; }
}

View File

@@ -0,0 +1,6 @@
namespace SIGCM2.Application.Abstractions;
public interface ICommandHandler<TCommand, TResult>
{
Task<TResult> Handle(TCommand command);
}

View File

@@ -0,0 +1,6 @@
namespace SIGCM2.Application.Abstractions;
public interface IDispatcher
{
Task<TResult> Send<TCommand, TResult>(TCommand command);
}

View File

@@ -0,0 +1,6 @@
namespace SIGCM2.Application.Abstractions;
public interface IQueryHandler<TQuery, TResult>
{
Task<TResult> Handle(TQuery query);
}

View File

@@ -0,0 +1,24 @@
namespace SIGCM2.Application.Abstractions.Persistence;
/// <summary>
/// Query-only access to Aviso counts by Rubro.
/// CAT-002 introduces the contract. The real Dapper-based impl lands in PRD-002
/// (when dbo.Aviso exists). Until then, NullAvisoQueryRepository is the binding.
/// </summary>
public interface IAvisoQueryRepository
{
/// <summary>
/// Returns the count of avisos (active, non-archived) assigned to the given rubro.
/// </summary>
Task<int> CountAvisosEnRubroAsync(int rubroId, CancellationToken ct = default);
/// <summary>
/// Returns a dictionary of { rubroId → count } for the provided ids.
/// Used by GetRubroTreeQueryHandler to avoid N+1 when populating TieneAvisos per node.
/// The implementation MUST do a single query; the stub returns an empty dictionary
/// (every rubro gets 0 via dictionary.GetValueOrDefault).
/// </summary>
Task<IReadOnlyDictionary<int, int>> CountAvisosBatchAsync(
IReadOnlyCollection<int> rubroIds,
CancellationToken ct = default);
}

View File

@@ -0,0 +1,104 @@
using SIGCM2.Domain.Pricing.ChargeableChars;
namespace SIGCM2.Application.Abstractions.Persistence;
/// <summary>
/// PRC-001 — Write + query access to dbo.ChargeableCharConfig.
/// Implemented by ChargeableCharConfigRepository (Dapper) in Infrastructure.
///
/// InsertWithCloseAsync: invokes usp_ChargeableCharConfig_InsertWithClose which atomically
/// closes any active row for (ProductTypeId, Symbol) and inserts the new row.
///
/// GetActiveForProductTypeAsync: invokes usp_ChargeableCharConfig_GetActiveForProductType which
/// returns both per-ProductType rows AND global (ProductTypeId IS NULL) rows for the given asOfDate.
/// The Application service applies the per-ProductType > global priority rule.
/// </summary>
public interface IChargeableCharConfigRepository
{
/// <summary>
/// Invokes usp_ChargeableCharConfig_InsertWithClose inside the ambient TransactionScope.
/// Closes any active row matching (ProductTypeId, Symbol) and inserts a new one.
/// Returns the Id of the newly inserted row.
/// Throws:
/// - ChargeableCharConfigForwardOnlyException on SQL THROW 50409
/// - ChargeableCharConfigInvalidException on SQL THROW 50404 (not-found guard)
/// </summary>
Task<long> InsertWithCloseAsync(
long? productTypeId,
string symbol,
string category,
decimal price,
DateOnly validFrom,
CancellationToken ct = default);
/// <summary>
/// Returns all active rows whose [ValidFrom, ValidTo] window covers the given asOfDate
/// for the specified ProductType, including global rows (ProductTypeId IS NULL).
/// The SP returns both per-ProductType AND global rows — callers apply priority.
/// </summary>
Task<IReadOnlyList<ChargeableCharConfig>> GetActiveForProductTypeAsync(
long productTypeId,
DateOnly asOfDate,
CancellationToken ct = default);
/// <summary>
/// Returns paginated rows filtered by ProductTypeId and IsActive.
/// Skip = (page - 1) * pageSize computed by the caller.
/// </summary>
Task<IReadOnlyList<ChargeableCharConfig>> ListAsync(
long? productTypeId,
bool activeOnly,
int skip,
int take,
CancellationToken ct = default);
/// <summary>
/// Returns total row count for the given filters (used for pagination metadata).
/// </summary>
Task<int> CountAsync(
long? productTypeId,
bool activeOnly,
CancellationToken ct = default);
/// <summary>
/// Returns the row with the given Id, or null if not found.
/// </summary>
Task<ChargeableCharConfig?> GetByIdAsync(
long id,
CancellationToken ct = default);
/// <summary>
/// Deactivates the row with the given Id by setting IsActive = false and ValidTo = today.
/// Idempotent: no-op if already inactive.
/// Called inside the ambient TransactionScope of the handler.
/// </summary>
Task DeactivateAsync(
long id,
DateOnly today,
CancellationToken ct = default);
/// <summary>
/// Invokes usp_ChargeableCharConfig_ReactivateWithGuard.
/// Guard rules (enforced by SP):
/// 50410 → target row is already active → ChargeableCharConfigReactivationNotAllowedException(ALREADY_ACTIVE)
/// 50411 → a vigente active row exists for (ProductTypeId, Symbol) → ChargeableCharConfigReactivationNotAllowedException(VIGENTE_EXISTS)
/// 50412 → posterior rows exist after target row → ChargeableCharConfigReactivationNotAllowedException(POSTERIOR_ROWS_EXIST)
/// 50404 → row not found → ChargeableCharConfigInvalidException
/// On success: re-opens the row (IsActive=true, ValidTo=NULL) and returns the reactivated entity.
/// </summary>
Task<ChargeableCharConfig> ReactivateAsync(
long id,
CancellationToken ct = default);
/// <summary>
/// Physically deletes the row with the given Id from dbo.ChargeableCharConfig (current state).
/// NOTE: Since SYSTEM_VERSIONING is ON, SQL Server moves the row to the history table with
/// SysEndTime set to the delete time. The row disappears from all current-state queries but
/// remains queryable via FOR SYSTEM_TIME. Temporal audit trail is preserved.
/// Future guard for "used in invoicing" is deferred to FAC-001 followup issue.
/// Throws KeyNotFoundException if the row does not exist.
/// </summary>
Task DeleteAsync(
long id,
CancellationToken ct = default);
}

View File

@@ -0,0 +1,43 @@
using SIGCM2.Application.Common;
using SIGCM2.Domain.Fiscal;
using IibbEntity = SIGCM2.Domain.Entities.IngresosBrutos;
namespace SIGCM2.Application.Abstractions.Persistence;
/// <summary>
/// Persistence contract for IngresosBrutos. Implemented by Dapper repo in Infrastructure.
/// </summary>
public interface IIngresosBrutosRepository
{
/// <summary>Inserts a new IngresosBrutos record and returns the generated identity Id.</summary>
Task<int> InsertAsync(IibbEntity entity, CancellationToken ct = default);
/// <summary>Returns the IngresosBrutos with the given Id, or null if not found.</summary>
Task<IibbEntity?> GetByIdAsync(int id, CancellationToken ct = default);
/// <summary>
/// Updates cosmetic fields only (Descripcion, Activo).
/// Never touches Alicuota, Provincia, or vigencia dates.
/// </summary>
Task<bool> UpdateCosmeticoAsync(int id, string descripcion, bool activo, CancellationToken ct = default);
/// <summary>
/// Closes the vigencia of the predecessor: UPDATE SET VigenciaHasta = @vigenciaHasta
/// WHERE Id = @id AND VigenciaHasta IS NULL (optimistic guard for race conditions).
/// Returns true if one row was affected, false if the row was already closed (race detected).
/// </summary>
Task<bool> UpdateCierreVigenciaAsync(int id, DateOnly vigenciaHasta, CancellationToken ct = default);
/// <summary>Sets Activo to the given value. Returns true if one row was affected.</summary>
Task<bool> SetActivoAsync(int id, bool activo, CancellationToken ct = default);
/// <summary>Returns a paged list applying optional Activo and Provincia filters.</summary>
Task<PagedResult<IibbEntity>> ListAsync(IngresosBrutosQuery query, CancellationToken ct = default);
/// <summary>
/// Returns the full version chain for the record identified by <paramref name="id"/>,
/// ordered from root (no PredecesorId) to the requested Id (inclusive).
/// Implemented via a recursive CTE in the concrete repository.
/// </summary>
Task<IReadOnlyList<IibbEntity>> GetHistorialAsync(int id, CancellationToken ct = default);
}

View File

@@ -0,0 +1,13 @@
using SIGCM2.Application.Common;
using SIGCM2.Domain.Entities;
namespace SIGCM2.Application.Abstractions.Persistence;
public interface IMedioRepository
{
Task<int> AddAsync(Medio m, CancellationToken ct = default);
Task<Medio?> GetByIdAsync(int id, CancellationToken ct = default);
Task<bool> ExistsByCodigoAsync(string codigo, CancellationToken ct = default);
Task UpdateAsync(Medio m, CancellationToken ct = default);
Task<PagedResult<Medio>> GetPagedAsync(MediosQuery q, CancellationToken ct = default);
}

View File

@@ -0,0 +1,10 @@
using SIGCM2.Domain.Entities;
namespace SIGCM2.Application.Abstractions.Persistence;
public interface IPermisoRepository
{
Task<IReadOnlyList<Permiso>> ListAsync(CancellationToken ct = default);
Task<Permiso?> GetByCodigoAsync(string codigo, CancellationToken ct = default);
Task<IReadOnlyList<Permiso>> GetByCodigosAsync(IEnumerable<string> codigos, CancellationToken ct = default);
}

View File

@@ -0,0 +1,44 @@
using SIGCM2.Application.Common;
using SIGCM2.Domain.Entities;
namespace SIGCM2.Application.Abstractions.Persistence;
/// <summary>
/// PRD-003 — Write + query access to dbo.ProductPrices.
/// Implemented by ProductPriceRepository (Dapper) in Infrastructure.
/// </summary>
public interface IProductPriceRepository
{
/// <summary>
/// Invokes dbo.usp_AddProductPrice inside the ambient TransactionScope.
/// Returns (newId, closedId?). Throws:
/// - ProductPriceForwardOnlyException on SQL THROW 50409 or unique index violation (2601/2627).
/// - ProductNotFoundException on SQL THROW 50404.
/// </summary>
Task<(long NewId, long? ClosedId)> AddAsync(
int productId,
decimal price,
DateOnly priceValidFrom,
CancellationToken ct = default);
/// <summary>
/// Returns a paginated page of price rows for the product, ordered descending by PriceValidFrom.
/// Caller is responsible for clamping page (≥ 1) and pageSize (1100) before calling.
/// Returns PagedResult with empty Items when the product has no price history or page is beyond total.
/// </summary>
Task<PagedResult<ProductPrice>> GetByProductIdAsync(
int productId,
int page,
int pageSize,
CancellationToken ct = default);
/// <summary>
/// Returns the ProductPrice row whose window [PriceValidFrom, PriceValidTo] covers the given
/// civil date, or null if no row matches (no history, or date is before any recorded price).
/// Used by ProductPricingService.GetPriceAtAsync.
/// </summary>
Task<ProductPrice?> GetActiveAsync(
int productId,
DateOnly date,
CancellationToken ct = default);
}

View File

@@ -0,0 +1,21 @@
namespace SIGCM2.Application.Abstractions.Persistence;
/// <summary>
/// PRD-002 handoff contract — query-only access to Product data needed by ProductType handlers.
/// PRD-001 binds to NullProductQueryRepository (always returns false).
/// PRD-002 binds to Dapper impl against dbo.Product (when that table exists).
/// </summary>
public interface IProductQueryRepository
{
/// <summary>
/// Returns true if at least one active Product with the given ProductTypeId exists.
/// Used by DeactivateProductTypeCommandHandler to guard against orphaning active products.
/// </summary>
Task<bool> ExistsActiveByProductTypeAsync(int productTypeId, CancellationToken ct = default);
/// <summary>
/// Returns the count of active Products where RubroId = rubroId.
/// Used by DeactivateRubroCommandHandler to guard against orphaning active products. (issue #41)
/// </summary>
Task<int> CountActiveByRubroAsync(int rubroId, CancellationToken ct = default);
}

View File

@@ -0,0 +1,29 @@
using SIGCM2.Application.Common;
using SIGCM2.Domain.Entities;
namespace SIGCM2.Application.Abstractions.Persistence;
/// <summary>
/// Write-side repository for Product.
/// All reads needed by write handlers are included here.
/// </summary>
public interface IProductRepository
{
/// <summary>Inserts a new Product and returns the DB-assigned Id.</summary>
Task<int> AddAsync(Product product, CancellationToken ct = default);
/// <summary>Returns the Product with the given Id, or null if not found.</summary>
Task<Product?> GetByIdAsync(int id, CancellationToken ct = default);
/// <summary>Returns a paged result of Products matching the query.</summary>
Task<PagedResult<Product>> GetPagedAsync(ProductsQuery query, CancellationToken ct = default);
/// <summary>Persists all changes to an existing Product row.</summary>
Task UpdateAsync(Product product, CancellationToken ct = default);
/// <summary>
/// Returns true if an active Product with the same Nombre exists for the given MedioId+ProductTypeId combination.
/// Pass excludeId to skip the self-comparison during rename (update scenario).
/// </summary>
Task<bool> ExistsByNombreAsync(string nombre, int medioId, int productTypeId, int? excludeId = null, CancellationToken ct = default);
}

View File

@@ -0,0 +1,31 @@
using SIGCM2.Application.Common;
using SIGCM2.Domain.Entities;
namespace SIGCM2.Application.Abstractions.Persistence;
/// <summary>
/// Write-side repository for ProductType.
/// All reads needed by write handlers are included here.
/// Query-side (for listing, filtering) uses GetPagedAsync with ProductTypesQuery.
/// </summary>
public interface IProductTypeRepository
{
/// <summary>Inserts a new ProductType and returns the DB-assigned Id.</summary>
Task<int> AddAsync(ProductType productType, CancellationToken ct = default);
/// <summary>Returns the ProductType with the given Id, or null if not found.</summary>
Task<ProductType?> GetByIdAsync(int id, CancellationToken ct = default);
/// <summary>Returns a paged result of ProductTypes matching the query.</summary>
Task<PagedResult<ProductType>> GetPagedAsync(ProductTypesQuery query, CancellationToken ct = default);
/// <summary>Persists all changes to an existing ProductType row.</summary>
Task UpdateAsync(ProductType productType, CancellationToken ct = default);
/// <summary>
/// Returns true if an active ProductType with the given nombre exists.
/// Pass excludeId to skip the self-comparison during rename (update scenario).
/// Case-insensitive — delegates to DB collation (SQL_Latin1_General_CP1_CI_AI).
/// </summary>
Task<bool> ExistsByNombreAsync(string nombre, int? excludeId = null, CancellationToken ct = default);
}

View File

@@ -0,0 +1,13 @@
using SIGCM2.Application.Common;
using SIGCM2.Domain.Entities;
namespace SIGCM2.Application.Abstractions.Persistence;
public interface IPuntoDeVentaRepository
{
Task<int> AddAsync(PuntoDeVenta pdv, CancellationToken ct = default);
Task<PuntoDeVenta?> GetByIdAsync(int id, CancellationToken ct = default);
Task<bool> ExistsByNumeroAFIPInMedioAsync(int medioId, short numeroAFIP, int? excludeId = null, CancellationToken ct = default);
Task UpdateAsync(PuntoDeVenta pdv, CancellationToken ct = default);
Task<PagedResult<PuntoDeVenta>> GetPagedAsync(PuntosDeVentaQuery q, CancellationToken ct = default);
}

View File

@@ -0,0 +1,33 @@
using SIGCM2.Domain.Entities;
namespace SIGCM2.Application.Abstractions.Persistence;
public interface IRefreshTokenRepository
{
/// <summary>
/// Finds a refresh token record by its SHA-256 hash.
/// Returns the record even if it is revoked or expired — callers decide what to do.
/// Returns null if no record matches the hash.
/// </summary>
Task<RefreshToken?> GetByHashAsync(string tokenHash, CancellationToken ct = default);
/// <summary>Persists a new refresh token and returns its generated Id.</summary>
Task<int> AddAsync(RefreshToken token, CancellationToken ct = default);
/// <summary>Marks a single token as revoked and optionally records its successor.</summary>
Task RevokeAsync(int id, int? replacedById, DateTime revokedAt, CancellationToken ct = default);
/// <summary>
/// Revokes all active (RevokedAt IS NULL) tokens in a family.
/// Used for chain revocation on reuse detection.
/// Returns the count of rows affected.
/// </summary>
Task<int> RevokeFamilyAsync(Guid familyId, DateTime revokedAt, CancellationToken ct = default);
/// <summary>
/// Revokes all active tokens for a user across all families.
/// Used for logout.
/// Returns the count of rows affected.
/// </summary>
Task<int> RevokeAllActiveForUserAsync(int usuarioId, DateTime revokedAt, CancellationToken ct = default);
}

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