diff --git a/docs/architecture/adrs/0055-graphql-rest-hybrid-api.es.md b/docs/architecture/adrs/0055-graphql-rest-hybrid-api.es.md index e48ffdf8..28afd79d 100644 --- a/docs/architecture/adrs/0055-graphql-rest-hybrid-api.es.md +++ b/docs/architecture/adrs/0055-graphql-rest-hybrid-api.es.md @@ -2,7 +2,7 @@ | Campo | Valor | |---|---| -| **Estado** | Aceptado | +| **Estado** | **Reemplazado por [ADR-0166](./0166-retire-graphql-single-rest-surface.es.md)** (2026-08-10) | | **Fecha** | 2026-05-21 | | **Contexto** | UMS Web App — Estrategia de Comunicación de API | | **Decisores** | Equipo de Arquitectura | @@ -96,6 +96,11 @@ La separación de niveles permanece como el camino de escalada correcto si estos --- +> **Reemplazado.** GraphQL se retiró de UMS el 2026-08-10: el esquema, el endpoint +> `/graphql` y los 17 tipos de consulta ya no existen. La superficie REST cubría el 100% de +> las lecturas y era la única que los clientes usaban. Ver ADR-0166 para la medición que lo +> sustenta. Este documento se conserva como registro de lo que se decidió y por qué. + ### Alternativa 2: GraphQL para Queries y Mutaciones Usar GraphQL exclusivamente — queries y mutaciones — eliminando la capa REST. diff --git a/docs/architecture/adrs/0055-graphql-rest-hybrid-api.md b/docs/architecture/adrs/0055-graphql-rest-hybrid-api.md index 6bdb177e..afb72676 100644 --- a/docs/architecture/adrs/0055-graphql-rest-hybrid-api.md +++ b/docs/architecture/adrs/0055-graphql-rest-hybrid-api.md @@ -4,7 +4,7 @@ | Field | Value | |---|---| -| **Status** | Accepted | +| **Status** | **Superseded by [ADR-0166](./0166-retire-graphql-single-rest-surface.md)** (2026-08-10) | | **Date** | 2026-05-21 | | **Context** | UMS Web App — API Communication Strategy | | **Deciders** | Architecture Team | @@ -98,6 +98,11 @@ Tier separation remains the correct escalation path if these controls prove insu --- +> **Reemplazado.** GraphQL se retiró de UMS el 2026-08-10: el esquema, el endpoint +> `/graphql` y los 17 tipos de consulta ya no existen. La superficie REST cubría el 100% de +> las lecturas y era la única que los clientes usaban. Ver ADR-0166 para la medición que lo +> sustenta. Este documento se conserva como registro de lo que se decidió y por qué. + ### Alternative 2: GraphQL for Both Queries and Mutations Use GraphQL exclusively — queries and mutations — eliminating the REST layer. diff --git a/docs/architecture/adrs/0059-single-api-tier-decision.md b/docs/architecture/adrs/0059-single-api-tier-decision.md index 8dd3b53a..f1e05aa5 100644 --- a/docs/architecture/adrs/0059-single-api-tier-decision.md +++ b/docs/architecture/adrs/0059-single-api-tier-decision.md @@ -7,7 +7,7 @@ | Field | Value | |---|---| | **ID** | ADR-0059 | -| **Status** | ACCEPTED | +| **Status** | ACCEPTED — **amended by [ADR-0166](./0166-retire-graphql-single-rest-surface.md)** (2026-08-10) | | **Date** | 2026-05-15 | | **Deciders** | Architecture Team | | **Evolith Relationship** | Override — UMS diverges from Evolith multi-tier API baseline | @@ -20,9 +20,15 @@ Evolith permits splitting query and command surfaces into separate API tiers whe ## Decision -UMS will co-locate GraphQL (query) and REST (command) surfaces in a **single `ums.api` deployment unit** using HotChocolate Minimal APIs on .NET 10. +UMS will serve its API from a **single `ums.api` deployment unit** on .NET 10. -CQRS separation is maintained at the **protocol level** (GraphQL for reads, REST for writes) but not at the deployment/infrastructure level. +> **Amended by ADR-0166 (2026-08-10).** As written, this decision co-located a GraphQL query +> surface and a REST command surface. GraphQL has since been retired: there is one surface, REST, +> and nothing to co-locate. What survives — and is the reason this ADR stands — is the rejection of +> splitting reads and writes into **separately deployed tiers**, which ADR-0166 does not revisit. + +CQRS separation is maintained, but no longer at the protocol level: it lives in code organisation +(`Endpoints/**/Queries/` for reads, `Endpoints/**/` for commands) over MediatR's `IQuery`/`ICommand`. ## Rationale diff --git a/docs/architecture/adrs/0066-actionable-user-error-contract.es.md b/docs/architecture/adrs/0066-actionable-user-error-contract.es.md index e47a67bb..88b03895 100644 --- a/docs/architecture/adrs/0066-actionable-user-error-contract.es.md +++ b/docs/architecture/adrs/0066-actionable-user-error-contract.es.md @@ -6,7 +6,7 @@ **Disposicion Evolith:** Propuesto para adopcion como estandar universal de Evolith **Relacionados:** - [ADR-0053: Observabilidad con OpenTelemetry](./0053-opentelemetry-observability.md) -- [ADR-0055: Patron Hibrido GraphQL/REST](./0055-graphql-rest-hybrid-api.md) +- [ADR-0055: Patron Hibrido GraphQL/REST](./0055-graphql-rest-hybrid-api.es.md) — *reemplazado por [ADR-0166](./0166-retire-graphql-single-rest-surface.es.md)* - [ADR-0057: Estado con Zustand y TanStack Query](./0057-zustand-tanstack-query-state.md) - [ADR-0061: Execution Context Accessor](./0061-execution-context-accessor.es.md) - [ADR-0062: Configuracion Serilog Segura de PII](./0062-pii-safe-serilog-configuration.es.md) @@ -73,6 +73,12 @@ La implementacion actual de UMS produce `userMessage` mediante recursos de local ### 3. Limite GraphQL +> **Ya no aplica (2026-08-10).** GraphQL se retiró de UMS por +> [ADR-0166](./0166-retire-graphql-single-rest-surface.es.md): no hay esquema, ni endpoint +> `/graphql`, ni resolvers. Esta cláusula se conserva como registro de lo que el contrato exigía +> mientras esa superficie existió, y como especificación a respetar si algún día se reintroduce. +> El resto del ADR sigue vigente: trata del contrato de error REST, que ahora es el único. + GraphQL se usa para lecturas segun ADR-0055. Los errores GraphQL expuestos al cliente usan mensajes genericos seguros localizados y transportan `errorId`. Las excepciones de resolvers se registran mediante Serilog con el mismo `errorId`, pero no se serializan al navegador. ### 4. Observabilidad y Logging diff --git a/docs/architecture/adrs/0066-actionable-user-error-contract.md b/docs/architecture/adrs/0066-actionable-user-error-contract.md index 93489bb2..615466f3 100644 --- a/docs/architecture/adrs/0066-actionable-user-error-contract.md +++ b/docs/architecture/adrs/0066-actionable-user-error-contract.md @@ -8,7 +8,7 @@ **Evolith disposition:** Proposed for adoption as a universal Evolith standard **Related:** - [ADR-0053: OpenTelemetry Observability](./0053-opentelemetry-observability.md) -- [ADR-0055: GraphQL/REST Hybrid API Pattern](./0055-graphql-rest-hybrid-api.md) +- [ADR-0055: GraphQL/REST Hybrid API Pattern](./0055-graphql-rest-hybrid-api.md) — *superseded by [ADR-0166](./0166-retire-graphql-single-rest-surface.md)* - [ADR-0057: Zustand and TanStack Query State Management](./0057-zustand-tanstack-query-state.md) - [ADR-0061: Execution Context Accessor](./0061-execution-context-accessor.md) - [ADR-0062: PII-Safe Serilog Configuration](./0062-pii-safe-serilog-configuration.md) @@ -75,6 +75,12 @@ The current UMS implementation produces `userMessage` through API localization r ### 3. GraphQL Boundary +> **No longer applicable (2026-08-10).** GraphQL was retired from UMS by +> [ADR-0166](./0166-retire-graphql-single-rest-surface.md); there is no schema, no `/graphql` +> endpoint and no resolvers. This clause is kept as the record of what the contract required while +> that surface existed — and as the specification to honour if it is ever reintroduced. Everything +> else in this ADR stands: it is about the REST error contract, which is now the only one. + GraphQL is used for reads according to ADR-0055. GraphQL errors exposed to clients use safe localized generic messages and carry `errorId`. Resolver exceptions are logged through Serilog with that same `errorId`, but are not serialized to the browser. ### 4. Observability and Logging diff --git a/docs/architecture/adrs/0166-retire-graphql-single-rest-surface.es.md b/docs/architecture/adrs/0166-retire-graphql-single-rest-surface.es.md new file mode 100644 index 00000000..0a7bcf56 --- /dev/null +++ b/docs/architecture/adrs/0166-retire-graphql-single-rest-surface.es.md @@ -0,0 +1,108 @@ +# ADR-0166: Retirar GraphQL — REST Como Única Superficie + +**Estado:** Aceptado +**Fecha:** 2026-08-10 +**Responsable de Decisión:** Arquitectura +**Reemplaza:** [ADR-0055](./0055-graphql-rest-hybrid-api.es.md) — patrón híbrido GraphQL/REST +**Modifica:** [ADR-0059](./0059-single-api-tier-decision.md) — su decisión era co-ubicar ambas superficies; queda una sola +**Cierra:** TD-002 + +--- + +## Contexto + +ADR-0055 estableció el patrón híbrido —GraphQL para lecturas, REST para escrituras— y ADR-0059 +decidió co-ubicar ambas superficies en un solo despliegue. Los dos siguen en estado Aceptado. + +El código dice otra cosa desde hace meses: + +| Superficie | Estado medido (2026-08-10) | +| --- | --- | +| Servidor | 17 clases de consulta registradas, esquema vivo con 39 campos | +| Aplicación web — módulos GraphQL | 9, de los cuales **uno** tenía consumidor | +| Aplicación web — los otros ocho | Código muerto: existían con pruebas y nadie los llamaba | +| SDK (.NET y TypeScript) | No usa GraphQL en absoluto | +| Pruebas E2E | Tampoco | + +El único llamante vivo era `branding.service.getBranding`. Todo lo demás pasó a REST en la +resincronización de 2026-08-09, que se llevó el parámetro `FRONTEND_CONFIG_TRANSPORT` y el +`query-transport.service` que decidía el transporte: la capa de acceso a datos importada llama a +REST directamente. + +### La decisión ya estaba tomada: D-007 + +Esto **no es una decisión nueva**. El 2026-07-14, D-007 estableció «API REST-only con CQRS en la +capa de aplicación», revisando expresamente ADR-0055 y ADR-0059. La documentación de referencia lo +recogió entonces y sigue diciéndolo hoy: la arquitectura de solución afirma que «GraphQL fue +retirado», los objetivos de calidad cambiaron su SLO de lectura de GraphQL a `GET`, el modelo de +amenazas retiró el vector de DoS por GraphQL, y el PRD declara la API REST-only. + +Lo que nunca ocurrió fue el cambio en el código. Un mes después seguían en pie el esquema, el +endpoint `/graphql`, el paquete y las 17 clases; y ADR-0055 y ADR-0059 seguían en estado Aceptado, +contradiciendo a D-007 sin que nada lo señalara. + +> **Por qué se pasó por alto.** El registro de decisiones vive en un `DECISIONS.md` que **no está +> en este repositorio**: los 117 enlaces a `../../DECISIONS.md` y `../../GAPS.md` que hay en +> `docs/` apuntan a la nada. Quien busque «¿se decidió algo sobre GraphQL?» mirando los ADRs +> —donde 0055 y 0059 dicen Aceptado— concluye que no. Eso es exactamente lo que ocurrió al abrir +> este trabajo, y lo corrigió el propio texto de `solution-architecture.es.md`. La deuda de tener +> el registro fuera del repositorio queda anotada aparte. + +Así que este ADR hace dos cosas: **implementa D-007** en el código, y **escribe el registro ADR +que D-007 necesitaba** para que ADR-0055 y ADR-0059 dejen de decir lo contrario. + +Mantener un tier de consulta completo para un solo llamante, además, tiene coste y no rinde: una +dependencia más (HotChocolate), una superficie más que asegurar y versionar, un filtro de errores +propio, un interceptor de contexto de inquilino, y 17 clases que hay que mantener alineadas con sus +queries cuando cambia un DTO. + +## Decisión + +### §2.1 — Se retira GraphQL por completo + +Desaparecen el esquema, el endpoint `/graphql` (raíz y versionado), el paquete +`HotChocolate.AspNetCore`, las 17 clases de consulta, `SafeGraphQlErrorFilter`, +`TenantContextGraphQlInterceptor` y los helpers `UnwrapGraphQl`/`UnwrapGraphQlOrNull`. En el +cliente desaparecen `graphqlClient` y los nueve módulos `*.graphql.ts`. + +### §2.2 — REST ya cubría el 100%, y eso es lo que hace la retirada segura + +No se ha escrito ni un endpoint nuevo. Existe una carpeta `Endpoints/**/Queries/` paralela con +**22 grupos REST de consulta** frente a las 17 clases GraphQL, y cada consulta del esquema tiene +gemela que envía **la misma query de MediatR**. La lectura de identidad visual, por ejemplo, ya +estaba en `Queries/BrandingQueryEndpoints` desde antes; el resolver y el endpoint enviaban ambos +`GetBrandingByTenantIdQuery`. + +> Esa carpeta paralela es también el motivo por el que un primer sondeo de este trabajo concluyó +> —mal— que había que añadir un GET: se miró el fichero de comandos y no el de consultas. La +> comprobación buena es enumerar las dos superficies y casarlas una a una, y se hizo. + +### §2.3 — La separación CQRS se mantiene; deja de ser por protocolo + +ADR-0059 mantenía la separación lectura/escritura **a nivel de protocolo** (GraphQL vs REST). Esa +frontera se mueve a la organización del código: `Endpoints/**/Queries/` para lecturas, +`Endpoints/**/` para comandos, sobre `IQuery`/`ICommand` de MediatR. La separación que importa +—qué handler puede escribir— nunca dependió del transporte. + +## Consecuencias + +- Una dependencia menos y una superficie de ataque menos. El endpoint `/graphql` era anónimo por + construcción salvo por el interceptor de inquilino; deja de existir. +- `branding.service.getBranding` colapsa a `null` tanto el 200 con cuerpo vacío (el inquilino + existe y no tiene identidad visual) como el 404 (el inquilino no existe), que es exactamente lo + que hacía el camino GraphQL. Cualquier otro error se propaga. +- TD-002 —«la consulta `appConfigurations` devuelve vacío»— se cierra por desaparición del sujeto. + Nunca se encontró la causa raíz y llevaba meses sin síntoma porque nada enrutaba por ahí. +- El blueprint TE-07 del gateway YARP pierde su ruta `/graphql`. +- **Si en el futuro se quiere GraphQL**, este ADR no lo prohíbe: dice que hoy no lo paga nadie. + Reintroducirlo sería una decisión nueva, con un consumidor real que la justifique. + +## Alternativas descartadas + +- **Volver a usarlo de verdad**, como decían los ADRs. Implicaría reconectar la capa de datos + importada, que es REST-only, para ganar qué: la superficie REST ya cubre el 100% de las lecturas + y el producto funciona. Sería trabajo para volver al punto de partida. +- **Dejarlo como estaba.** Es lo más barato hoy y lo más caro después: dos ADRs Aceptados + describiendo una arquitectura que no existe, y 17 clases que alguien seguirá manteniendo por + disciplina sin que nadie las llame. La documentación que miente cuesta más que el código que + sobra. diff --git a/docs/architecture/adrs/0166-retire-graphql-single-rest-surface.md b/docs/architecture/adrs/0166-retire-graphql-single-rest-surface.md new file mode 100644 index 00000000..6ccfed5a --- /dev/null +++ b/docs/architecture/adrs/0166-retire-graphql-single-rest-surface.md @@ -0,0 +1,105 @@ +# ADR-0166: Retire GraphQL — REST as the Only Surface + +**Status:** Accepted +**Date:** 2026-08-10 +**Decision Owner:** Architecture +**Supersedes:** [ADR-0055](./0055-graphql-rest-hybrid-api.md) — GraphQL/REST hybrid pattern +**Amends:** [ADR-0059](./0059-single-api-tier-decision.md) — its decision was to co-locate both surfaces; one remains +**Closes:** TD-002 + +--- + +## Context + +ADR-0055 established the hybrid pattern — GraphQL for reads, REST for writes — and ADR-0059 decided +to co-locate both surfaces in a single deployment. Both are still Accepted. + +The code has said otherwise for months: + +| Surface | Measured state (2026-08-10) | +| --- | --- | +| Server | 17 query classes registered, live schema with 39 fields | +| Web app — GraphQL modules | 9, of which **one** had a consumer | +| Web app — the other eight | Dead code: they existed with tests and nothing called them | +| SDK (.NET and TypeScript) | Does not use GraphQL at all | +| E2E tests | Neither | + +The only live caller was `branding.service.getBranding`. Everything else moved to REST in the +2026-08-09 resync, which removed the `FRONTEND_CONFIG_TRANSPORT` parameter and the +`query-transport.service` that chose the transport: the imported data-access layer calls REST +directly. + +### The decision was already made: D-007 + +This is **not a new decision**. On 2026-07-14, D-007 established "REST-only API with CQRS in the +application layer", expressly revising ADR-0055 and ADR-0059. The reference documentation recorded +it then and still says so today: the solution architecture states that "GraphQL was retired", the +quality objectives moved their read SLO from GraphQL to `GET`, the threat model dropped the +GraphQL DoS vector, and the PRD declares the API REST-only. + +What never happened was the code change. A month later the schema, the `/graphql` endpoint, the +package and the 17 classes were all still standing; and ADR-0055 and ADR-0059 were still Accepted, +contradicting D-007 with nothing to flag it. + +> **Why it was missed.** The decision register lives in a `DECISIONS.md` that is **not in this +> repository**: the 117 links to `../../DECISIONS.md` and `../../GAPS.md` across `docs/` point at +> nothing. Anyone asking "was anything decided about GraphQL?" by reading the ADRs — where 0055 and +> 0059 say Accepted — concludes no. That is exactly what happened when this work opened, and what +> corrected it was the prose of `solution-architecture.es.md`. The debt of keeping the register +> outside the repository is recorded separately. + +So this ADR does two things: it **implements D-007** in code, and it **writes the ADR record D-007 +needed** so that ADR-0055 and ADR-0059 stop saying the opposite. + +Keeping a whole query tier for one caller also costs and returns nothing: one more dependency +(HotChocolate), one more surface to secure and version, a bespoke error filter, a tenant-context +interceptor, and 17 classes that must be kept aligned with their queries whenever a DTO changes. + +## Decision + +### §2.1 — GraphQL is retired in full + +The schema, the `/graphql` endpoint (root and versioned), the `HotChocolate.AspNetCore` package, +the 17 query classes, `SafeGraphQlErrorFilter`, `TenantContextGraphQlInterceptor` and the +`UnwrapGraphQl`/`UnwrapGraphQlOrNull` helpers all go. On the client, `graphqlClient` and the nine +`*.graphql.ts` modules go with them. + +### §2.2 — REST already covered 100%, and that is what makes the removal safe + +Not one new endpoint was written. A parallel `Endpoints/**/Queries/` folder holds **22 REST query +groups** against GraphQL's 17 query classes, and every schema query has a twin dispatching **the +same MediatR query**. Branding reads, for instance, already lived in +`Queries/BrandingQueryEndpoints`; resolver and endpoint both sent `GetBrandingByTenantIdQuery`. + +> That parallel folder is also why a first survey of this work concluded — wrongly — that a GET had +> to be added: it looked at the commands file and not the queries one. The correct check is to +> enumerate both surfaces and match them one to one, and that was done. + +### §2.3 — CQRS separation stays; it stops being protocol-level + +ADR-0059 kept the read/write split **at the protocol level** (GraphQL vs REST). That boundary moves +into code organisation: `Endpoints/**/Queries/` for reads, `Endpoints/**/` for commands, over +MediatR's `IQuery`/`ICommand`. The separation that matters — which handler may write — never +depended on the transport. + +## Consequences + +- One dependency fewer and one attack surface fewer. The `/graphql` endpoint was anonymous by + construction apart from the tenant interceptor; it ceases to exist. +- `branding.service.getBranding` collapses to `null` both the 200 with an empty body (the tenant + exists and has no branding) and the 404 (the tenant does not exist), which is exactly what the + GraphQL path did. Any other error propagates. +- TD-002 — "the `appConfigurations` query returns empty" — closes because its subject is gone. The + root cause was never found and it had been symptomless for months because nothing routed there. +- The TE-07 YARP gateway blueprint loses its `/graphql` route. +- **If GraphQL is wanted again in future**, this ADR does not forbid it: it says nobody is paying + for it today. Reintroducing it would be a new decision, with a real consumer to justify it. + +## Alternatives rejected + +- **Actually start using it**, as the ADRs said. That would mean rewiring the imported data-access + layer, which is REST-only, to gain what: the REST surface already covers 100% of reads and the + product works. Work to get back to the starting point. +- **Leave it as it was.** Cheapest today and dearest later: two Accepted ADRs describing an + architecture that does not exist, and 17 classes someone keeps maintaining out of discipline + while nothing calls them. Documentation that lies costs more than code that is surplus. diff --git a/docs/architecture/adrs/index.es.md b/docs/architecture/adrs/index.es.md index 86927d22..4bf3bef3 100644 --- a/docs/architecture/adrs/index.es.md +++ b/docs/architecture/adrs/index.es.md @@ -15,7 +15,7 @@ UMS es un repositorio satelite de `evolith_arch32`. El repositorio padre define | [ADR-0052](./0052-immutable-audit-trail-enforcement.es.md) | Enforcement de audit trail inmutable | Aceptado | | [ADR-0053](./0053-opentelemetry-observability.es.md) | Estrategia de observabilidad OpenTelemetry | Aceptado | | [ADR-0054](./0054-shell-library-isolation.es.md) | Aislamiento de shell libraries | Aceptado | -| [ADR-0055](./0055-graphql-rest-hybrid-api.es.md) | Patron API hibrido GraphQL/REST | Aceptado | +| [ADR-0055](./0055-graphql-rest-hybrid-api.es.md) | Patron API hibrido GraphQL/REST | Reemplazado | | [ADR-0056](./0056-clean-architecture-frontend.es.md) | Limites de Clean Architecture en frontend | Aceptado | | [ADR-0057](./0057-zustand-tanstack-query-state.es.md) | Estado con Zustand y TanStack Query | Aceptado | | [ADR-0058](./0058-api-gateway-yarp-evolution.es.md) | Evolucion API Gateway con YARP | Propuesto | @@ -45,6 +45,7 @@ UMS es un repositorio satelite de `evolith_arch32`. El repositorio padre define | [ADR-0090](./0090-recursive-menu-node-tree.es.md) | El Árbol Recursivo MenuNode Sustituye a Menu/SubMenu/Option | Aceptado | | [ADR-0164](./0164-branch-closure-is-terminal.es.md) | Cerrar una Sucursal Es Terminal y Lógico | Aceptado | | [ADR-0165](./0165-login-never-answers-404.es.md) | El Login Nunca Responde 404 | Aceptado | +| [ADR-0166](./0166-retire-graphql-single-rest-surface.es.md) | Retirar GraphQL — REST Como Única Superficie | Aceptado | --- diff --git a/docs/architecture/adrs/index.md b/docs/architecture/adrs/index.md index 0d18fe07..71669560 100644 --- a/docs/architecture/adrs/index.md +++ b/docs/architecture/adrs/index.md @@ -32,7 +32,7 @@ UMS is a satellite repository of `evolith_arch32`. The parent repository defines | [ADR-0052](./0052-immutable-audit-trail-enforcement.md) | Immutable Audit Trail — SQL Server Enforcement Strategy | Accepted | | [ADR-0053](./0053-opentelemetry-observability.md) | OpenTelemetry Observability Strategy | Accepted | | [ADR-0054](./0054-shell-library-isolation.md) | Shell Library Isolation — DDD, Factory, AOP, Bootstrapper | Accepted · Amended 2026-05-24 | -| [ADR-0055](./0055-graphql-rest-hybrid-api.md) | GraphQL/REST Hybrid API Pattern | Accepted | +| [ADR-0055](./0055-graphql-rest-hybrid-api.md) | GraphQL/REST Hybrid API Pattern | Superseded | | [ADR-0056](./0056-clean-architecture-frontend.md) | Clean Architecture Layer Boundaries (Frontend) | Accepted | | [ADR-0057](./0057-zustand-tanstack-query-state.md) | Zustand + TanStack Query State Management | Accepted | | [ADR-0058](./0058-api-gateway-yarp-evolution.md) | API Gateway Evolution — YARP for Multi-Client SaaS | Proposed | @@ -62,12 +62,13 @@ UMS is a satellite repository of `evolith_arch32`. The parent repository defines | [ADR-0090](./0090-recursive-menu-node-tree.md) | Recursive MenuNode Tree Replaces Menu/SubMenu/Option | Accepted | | [ADR-0164](./0164-branch-closure-is-terminal.md) | Branch Closure Is Terminal and Logical | Accepted | | [ADR-0165](./0165-login-never-answers-404.md) | Login Never Answers 404 | Accepted | +| [ADR-0166](./0166-retire-graphql-single-rest-surface.md) | Retire GraphQL — REST as the Only Surface | Accepted | --- ## Bilingual Coverage (R-01 Compliance) -All ADRs (0050-0082) have Spanish translations, as do ADR-0090, ADR-0164 and ADR-0165: +All ADRs (0050-0082) have Spanish translations, as do ADR-0090 and ADR-0164 through ADR-0166: | ADR | Spanish | ADR | Spanish | |-----|---------|-----|---------| diff --git a/docs/architecture/blueprints-es/technical-enablers/te-07-yarp-api-gateway.md b/docs/architecture/blueprints-es/technical-enablers/te-07-yarp-api-gateway.md index c04b9e2a..c29c5073 100644 --- a/docs/architecture/blueprints-es/technical-enablers/te-07-yarp-api-gateway.md +++ b/docs/architecture/blueprints-es/technical-enablers/te-07-yarp-api-gateway.md @@ -86,10 +86,6 @@ ums/src/apps/ums.gateway/ "ClusterId": "ums-api", "Match": { "Path": "/api/{**catch-all}" } }, - "graphql-route": { - "ClusterId": "ums-api", - "Match": { "Path": "/graphql" } - }, "web-route": { "ClusterId": "ums-web", "Match": { "Path": "/{**catch-all}" } @@ -225,7 +221,7 @@ ums-gateway: ## Criterios de Aceptación -- [ ] Todas las solicitudes a `/api/**` y `/graphql` se enrutan a través del gateway hacia `ums-api`. +- [ ] Todas las solicitudes a `/api/**` se enrutan a través del gateway hacia `ums-api`. - [ ] Todas las solicitudes a `/**` (no-API) se enrutan a través del gateway hacia `ums-web`. - [ ] Las cabeceras de seguridad están presentes en cada respuesta independientemente del tipo de cliente. - [ ] La limitación de tasa se aplica por claim `tenant_id`; las solicitudes anónimas usan una partición compartida. diff --git a/docs/architecture/blueprints/technical-enablers/te-07-yarp-api-gateway.md b/docs/architecture/blueprints/technical-enablers/te-07-yarp-api-gateway.md index 83afae12..8f932bef 100644 --- a/docs/architecture/blueprints/technical-enablers/te-07-yarp-api-gateway.md +++ b/docs/architecture/blueprints/technical-enablers/te-07-yarp-api-gateway.md @@ -86,10 +86,6 @@ ums/src/apps/ums.gateway/ "ClusterId": "ums-api", "Match": { "Path": "/api/{**catch-all}" } }, - "graphql-route": { - "ClusterId": "ums-api", - "Match": { "Path": "/graphql" } - }, "web-route": { "ClusterId": "ums-web", "Match": { "Path": "/{**catch-all}" } @@ -225,7 +221,7 @@ Do **not** implement until the mobile client (`ums.mobile-app`) project is initi ## Acceptance Criteria -- [ ] All requests to `/api/**` and `/graphql` are routed through the gateway to `ums-api`. +- [ ] All requests to `/api/**` are routed through the gateway to `ums-api`. - [ ] All requests to `/**` (non-API) are routed through the gateway to `ums-web`. - [ ] Security headers are present on every response regardless of client type. - [ ] Rate limiting applies per `tenant_id` claim; anonymous requests use a shared partition. diff --git a/docs/architecture/overview.es.md b/docs/architecture/overview.es.md index 86cbfca3..2fb3d90e 100644 --- a/docs/architecture/overview.es.md +++ b/docs/architecture/overview.es.md @@ -15,7 +15,7 @@ UMS actúa como una pasarela de autorización e identidad que puede funcionar de ``` ┌─────────────────────────────────────────────────────────┐ │ Capa de Presentación │ - │ React 18 + Vite (SPA) / Web API / GraphQL / REST │ + │ React 19 + Vite (SPA) / Web API REST │ └────────────┬───────────────────────────────▲────────────┘ │ Comandos │ Consultas / DTOs ▼ │ @@ -41,7 +41,7 @@ UMS actúa como una pasarela de autorización e identidad que puede funcionar de 1. **Pureza del Dominio**: La capa de dominio (`{BoundedContext}.Domain`) consta de objetos C# puros (POCOs) con cero referencias a bibliotecas externas, asegurando que la lógica de negocio permanezca incontaminada. 2. **Límites Explícitos**: Las interacciones entre contextos están estrictamente desacopladas utilizando comunicación basada en eventos (Transactional Outbox) o Capas Anticorrupción (ACL) explícitas en la capa de Aplicación. Las uniones de bases de datos directas entre contextos están estrictamente prohibidas. 3. **Aislamiento de Inquilinos (Tenancy)**: El aislamiento de inquilinos de alta seguridad se aplica de forma nativa en la capa de Aplicacion, utilizando PostgreSQL row-level security y politicas de base de datos como mecanismos secundarios a nivel de infraestructura (R-10). -4. **Segregación de Responsabilidades de Consulta y Comando (CQRS)**: Los modelos de lectura están altamente optimizados y separados de los modelos de escritura. Las escrituras son estrictamente transaccionales, mientras que las lecturas aprovechan proyecciones planas eficientes o la ejecución directa de GraphQL. +4. **Segregación de Responsabilidades de Consulta y Comando (CQRS)**: Los modelos de lectura están altamente optimizados y separados de los modelos de escritura. Las escrituras son estrictamente transaccionales, mientras que las lecturas aprovechan proyecciones planas eficientes servidas por `GET` REST (ADR-0166). --- @@ -51,7 +51,7 @@ UMS conecta a múltiples actores y suites externas para proporcionar una gestió ```mermaid graph TD - User["Usuario Final (Navegador/Cliente)"] -->|HTTPS GraphQL/REST| Gate["API Gateway / BFF"] + User["Usuario Final (Navegador/Cliente)"] -->|HTTPS REST| Gate["API Gateway / BFF"] Admin["Administrador de Inquilino"] -->|HTTPS Config| Gate SysAdmin["Admin de Sistema de Plataforma"] -->|Mantenimiento Global| Gate diff --git a/docs/architecture/overview.md b/docs/architecture/overview.md index 1ca7af20..36fe80c9 100644 --- a/docs/architecture/overview.md +++ b/docs/architecture/overview.md @@ -15,7 +15,7 @@ UMS serves as an authorization and identity gateway that can either function as ``` ┌─────────────────────────────────────────────────────────┐ │ Presentation Layer │ - │ React 18 + Vite (SPA) / Web API / GraphQL / REST │ + │ React 19 + Vite (SPA) / Web API REST │ └────────────┬───────────────────────────────▲────────────┘ │ Commands │ Queries / DTOs ▼ │ @@ -42,7 +42,7 @@ UMS serves as an authorization and identity gateway that can either function as 1. **Domain Purity**: The Domain layer (`{BoundedContext}.Domain`) contains the pure DDD model — Aggregate Roots, Entities, Value Objects, Domain Events, invariants, and domain services — with zero external framework references. 2. **Explicit Boundaries**: Cross-context interactions are strictly decoupled using event-driven communication (Transactional Outbox) or explicit Application-layer Anti-Corruption Layers (ACLs). Direct cross-context database joins are strictly prohibited. 3. **Tenant Isolation**: High-security multi-tenancy is enforced natively in the Application layer, with PostgreSQL row-level security and database policies serving as infrastructure-level secondary failsafes (R-10). -4. **Command-Query Responsibility Segregation (CQRS)**: Read models are highly optimized and separated from write models. Writes are strictly transaction-safe, while reads leverage efficient flat projections or direct GraphQL execution. +4. **Command-Query Responsibility Segregation (CQRS)**: Read models are highly optimized and separated from write models. Writes are strictly transaction-safe, while reads leverage efficient flat projections served over REST `GET` (ADR-0166). --- @@ -52,7 +52,7 @@ UMS connects multiple actors and external suites to provide unified access manag ```mermaid graph TD - User["End User (Browser/Client)"] -->|HTTPS GraphQL/REST| Gate["API Gateway / BFF"] + User["End User (Browser/Client)"] -->|HTTPS REST| Gate["API Gateway / BFF"] Admin["Tenant Admin"] -->|HTTPS Config| Gate SysAdmin["Platform System Admin"] -->|Global Maintenance| Gate diff --git a/docs/architecture/technical-debt.md b/docs/architecture/technical-debt.md index 19463dce..12eac2d0 100644 --- a/docs/architecture/technical-debt.md +++ b/docs/architecture/technical-debt.md @@ -27,17 +27,17 @@ ## [TD-002] GraphQL Queries Return Empty Data for AppConfigurations -- **Status**: Acknowledged — **workaround withdrawn, needs re-measuring** -- **Severity**: Medium -- **Component**: `Ums.Presentation/GraphQL/Configuration/AppConfigurationQueries.cs`, `Ums.Application/Configuration/AppConfiguration/Queries/GetAllAppConfigurationsQueryHandler.cs` -- **Description**: The `appConfigurations` GraphQL query returned an empty `items` array with `totalItems: 0` while the REST endpoint returned the same data correctly. The handler completed in ~25 ms without error; the result was simply empty. Other GraphQL queries (e.g. `tenants`) behaved normally. -- **Rationale**: The investigation never found a root cause. Same handler, same repository — data for REST, nothing for GraphQL. Suspected a HotChocolate DataLoader or resolver-scope issue. -- **What changed (2026-08-09 resync)**: the escape hatch this entry relied on **no longer exists**. The `FRONTEND_CONFIG_TRANSPORT` parameter and the `query-transport.service` that read it were withdrawn along with the web app's REST/GraphQL switch; the imported data-access layer calls REST directly and `branding.service` is the only consumer left on the GraphQL client. So there is no flag to set, and no fallback to fall back *from* — but also no live symptom, because nothing routes AppConfiguration reads through GraphQL any more. -- **Impact today**: latent, not observed. The GraphQL query still exists and is still registered in the schema; if a client starts using it, the original defect — whatever it is — is presumably still there. -- **Next step is measurement, not investigation**: query `appConfigurations` directly against a seeded environment and see whether it still returns empty. If it does, the entry stands and the DataLoader/resolver-scope hypothesis is the place to start. If it does not, close it — something along the way fixed it, and carrying a phantom is worse than carrying nothing. -- **Related Files**: -- `src/apps/ums.api/Ums.Presentation/GraphQL/Configuration/AppConfigurationQueries.cs` -- `src/apps/ums.api/Ums.Application/Configuration/AppConfiguration/Queries/GetAllAppConfigurationsQueryHandler.cs` +- **Status**: **Closed — subject removed** (2026-08-10) +- **Severity**: was Medium +- **Description**: The `appConfigurations` GraphQL query returned an empty `items` array with `totalItems: 0` while the REST endpoint returned the same data correctly. The handler completed in ~25 ms without error; the result was simply empty. Other GraphQL queries (e.g. `tenants`) behaved normally. The investigation never found a root cause — same handler, same repository, data for REST and nothing for GraphQL. A HotChocolate DataLoader or resolver-scope issue was suspected and never confirmed. + +### Why it closes without a root cause + +GraphQL was retired from UMS on 2026-08-10 ([ADR-0166](./adrs/0166-retire-graphql-single-rest-surface.md)). The query that returned empty no longer exists, along with the other sixteen and the `/graphql` endpoint itself. + +This is a **closure by removal, not a fix**. If GraphQL is ever reintroduced, this entry is worth re-reading first: the defect was never explained, so nothing guarantees it would not come back with it. The one lead on record — DataLoader or resolver scope — is the place to start. + +The entry had already been symptomless for months: the 2026-08-09 resync withdrew the `FRONTEND_CONFIG_TRANSPORT` parameter and the `query-transport.service` that read it, leaving the imported data-access layer on REST. Nothing routed AppConfiguration reads through GraphQL any more, which is precisely how a live defect became invisible without being solved. --- @@ -208,3 +208,30 @@ Fixed here rather than filed separately, because otherwise TD-008's verification - **Verified**: `Ums.ContractTest` 38/38. - **Still owed**: report upstream — `unimar-ums` has the same `MapAuthError`. +--- + +## [TD-010] The Decision Register D-007 Cites Is Not in This Repository + +- **Status**: Confirmed +- **Severity**: High +- **Component**: `docs/**` — 117 links to `../../DECISIONS.md` and `../../GAPS.md` +- **Description**: Reference documentation across `docs/` cites decisions as `D-NNN` and gaps as `G-NNN`, linking to `../../DECISIONS.md` and `../../GAPS.md` at the repository root. **Neither file exists here, and neither ever has** (`git log --all` finds nothing). All 117 links are dead. +- **What it cost, concretely**: asked whether GraphQL had been dropped, the ADRs were the natural place to look — and ADR-0055 and ADR-0059 both said *Accepted*, so the answer given was "no, nothing decided it." That was wrong. D-007 had decided exactly that on 2026-07-14, a month earlier. It surfaced only because a sentence of prose in `solution-architecture.es.md` happened to mention it. Meanwhile the code kept a full GraphQL tier that a decision had already retired. +- **Why it is worse than a broken link**: the registers are cited as *authority* — quality objectives, the threat model and the PRD all defer to `D-007`/`D-008` for what the architecture is. A reader who cannot open them cannot check whether the ADRs, the prose and the code agree. In this case they did not, for a month, and nothing surfaced it. +- **Suggested resolution**: decide where the register belongs and make the links resolve. Either (a) bring `DECISIONS.md` and `GAPS.md` into this repository, so a decision and the code it governs move together, or (b) point the links at wherever they really live and make that reachable. Option (a) is preferable: a decision register that lives outside the repository it governs will drift again — this entry is the evidence. +- **Related**: [ADR-0166](./adrs/0166-retire-graphql-single-rest-surface.md) implements D-007 a month after it was taken, and records why the delay went unnoticed. + +--- + +## [TD-011] The Integration Suite Shares One Host Across 31 Test Classes + +- **Status**: Confirmed +- **Severity**: High +- **Component**: `src/apps/ums.api/Ums.Presentation.IntegrationTest/Infrastructure/UmsApiWebApplicationFactory.cs` +- **Description**: 31 test classes take `IClassFixture` over a shared seeded store. Any test that **writes** mutates state every other class reads. There is no per-test isolation and no cleanup. +- **How it surfaced (2026-08-10)**: a single new test that wrote branding to the first seeded tenant and read it back took **133 of 316 tests down in CI**. It passed locally, on both Debug and Release — execution order happened to hide it. One added write, 133 failures, and a green local run: that is the failure mode this design produces. +- **Not the first time**: the factory's own comments record an earlier incident of roughly 80 failures caused by a static constructor leaking `Persistence__Provider` between hosts. The mechanism differs; the shape does not. +- **Impact**: the suite cannot cover any write path end to end without risking the rest of it, so write coverage is pushed down to handler tests. That is a reasonable place for it, but it should be a choice, not a constraint. It also means a genuine regression can hide behind an unrelated test's pollution, and that failures do not reproduce locally — the most expensive kind. +- **Suggested resolution**: give each test class its own store (a per-class database name is the cheapest step), or add a reset between classes. The e2e suite already solved the equivalent problem for the shared cluster with `tests/helpers/limpieza.ts` and a per-run marker; the same reasoning applies here. +- **Interim measure**: `TenantBrandingRestQueryTests` covers only the read path. Read-after-write for branding is deliberately absent, with the reason written in the file so nobody re-adds it and repeats the 133. + diff --git a/src/.editorconfig b/src/.editorconfig index ae6a7eaa..cd219ef5 100644 --- a/src/.editorconfig +++ b/src/.editorconfig @@ -115,14 +115,3 @@ dotnet_diagnostic.S3011.severity = error # bypass de accesibilidad por reflexi # configuración de la lista de marcadores, así que se queda como aviso no bloqueante y los # TODO reales se gobiernan por convención: TODO(D-016) y TODO(G-NNN) trazan a DECISIONS.md # y GAPS.md. Un TODO sin identificador de registro es la deuda invisible que SD-07 prohíbe. - -# ── Resolvers de GraphQL ───────────────────────────────────────────────────── -# S2325 («este método podría ser static») NO se gatea bajo Ums.Presentation/GraphQL. -# HotChocolate descubre los campos de un [ExtendObjectType] por reflexión sobre los métodos de -# INSTANCIA del tipo. Convertirlos en static los hace invisibles para esa reflexión: el tipo -# `Query` se queda sin un solo campo, el esquema no compila —«The object type `Query` has to at -# least define one field in order to be valid»— y la aplicación NO ARRANCA. Se comprobó del modo -# más caro posible: hacerlos static dejó 229 pruebas de integración en rojo porque el host no -# levantaba. El analizador tiene razón sobre el lenguaje y se equivoca sobre el framework. -[**/Ums.Presentation/GraphQL/**.cs] -dotnet_diagnostic.S2325.severity = suggestion diff --git a/src/apps/ums.api/Ums.Presentation.IntegrationTest/Identity/IdentityAndAuthorizationGraphQlTests.cs b/src/apps/ums.api/Ums.Presentation.IntegrationTest/Identity/IdentityAndAuthorizationGraphQlTests.cs deleted file mode 100644 index 2cbd400f..00000000 --- a/src/apps/ums.api/Ums.Presentation.IntegrationTest/Identity/IdentityAndAuthorizationGraphQlTests.cs +++ /dev/null @@ -1,37 +0,0 @@ -using Ums.Presentation.IntegrationTest.Infrastructure; - -namespace Ums.Presentation.IntegrationTest.Identity; - -public sealed class IdentityAndAuthorizationRestTests : IClassFixture -{ - private readonly HttpClient _client; - - public IdentityAndAuthorizationRestTests(UmsApiWebApplicationFactory factory) - { - _client = factory.CreateClient(new WebApplicationFactoryClientOptions - { - BaseAddress = new Uri("https://localhost"), - AllowAutoRedirect = false, - }); - } - - [Fact] - public async Task GetUserAccounts_ShouldReturnItems() - { - var response = await _client.GetAsync("/api/v1/user-accounts?page=1&pageSize=10", TestContext.Current.CancellationToken); - response.StatusCode.Should().Be(HttpStatusCode.OK); - - using var payload = JsonDocument.Parse(await response.Content.ReadAsStringAsync(TestContext.Current.CancellationToken)); - payload.RootElement.GetProperty("items").GetArrayLength().Should().BeGreaterThan(0); - } - - [Fact] - public async Task GetProfiles_ShouldReturnPayload() - { - var response = await _client.GetAsync("/api/v1/profiles?page=1&pageSize=10", TestContext.Current.CancellationToken); - response.StatusCode.Should().Be(HttpStatusCode.OK); - - using var payload = JsonDocument.Parse(await response.Content.ReadAsStringAsync(TestContext.Current.CancellationToken)); - payload.RootElement.GetProperty("items").ValueKind.Should().Be(JsonValueKind.Array); - } -} diff --git a/src/apps/ums.api/Ums.Presentation.IntegrationTest/Identity/TenantBrandingRestQueryTests.cs b/src/apps/ums.api/Ums.Presentation.IntegrationTest/Identity/TenantBrandingRestQueryTests.cs new file mode 100644 index 00000000..3bac5009 --- /dev/null +++ b/src/apps/ums.api/Ums.Presentation.IntegrationTest/Identity/TenantBrandingRestQueryTests.cs @@ -0,0 +1,70 @@ +using Ums.Presentation.IntegrationTest.Infrastructure; + +namespace Ums.Presentation.IntegrationTest.Identity; + +/// +/// GET /tenants/{tenantId}/branding (`Queries/BrandingQueryEndpoints`). +/// +/// La ruta existía desde antes y no tenía ninguna prueba: se descubrió al retirar GraphQL +/// (ADR-0166), comprobando que cada consulta del esquema tuviera gemela REST. Como pasa a ser el +/// único camino de lectura de la identidad visual, se cubre aquí. +/// +/// Se fijan los dos desenlaces que el handler distingue en LECTURA, porque son los que el +/// panel de identidad visual necesita separar y los que un 404 indiscriminado borraría. +/// +/// NO se prueba aquí «leer lo que el POST acaba de escribir». Se intentó y hundió 133 +/// pruebas en CI: las 31 clases de esta suite comparten `UmsApiWebApplicationFactory`, así que +/// escribir sobre el inquilino sembrado contamina a todas las demás. En local pasaba —el orden de +/// ejecución lo escondía—, que es la peor forma de que un fallo así se manifieste. El camino de +/// escritura ya está cubierto en `Ums.Application.Test/Tenants/Branding`, a nivel de handler y sin +/// estado compartido, que es donde corresponde mientras esta suite no aísle. +/// +public sealed class TenantBrandingRestQueryTests : IClassFixture +{ + private readonly HttpClient _client; + + public TenantBrandingRestQueryTests(UmsApiWebApplicationFactory factory) + { + _client = factory.CreateClient(new WebApplicationFactoryClientOptions + { + BaseAddress = new Uri("https://localhost"), + AllowAutoRedirect = false, + }); + } + + private async Task PrimerInquilinoSembradoAsync() + { + var response = await _client.GetAsync( + "/api/v1/tenants?page=1&pageSize=1", TestContext.Current.CancellationToken); + response.StatusCode.Should().Be(HttpStatusCode.OK); + + using var payload = JsonDocument.Parse( + await response.Content.ReadAsStringAsync(TestContext.Current.CancellationToken)); + return payload.RootElement.GetProperty("items")[0].GetProperty("tenantId").GetGuid(); + } + + [Fact] + public async Task GetBranding_InquilinoExistente_Responde200() + { + var tenantId = await PrimerInquilinoSembradoAsync(); + + var response = await _client.GetAsync( + $"/api/v1/tenants/{tenantId}/branding", TestContext.Current.CancellationToken); + + // 200 tanto si tiene identidad visual como si no: el recurso «la identidad visual de este + // inquilino» existe y puede estar vacío. Lo que NO puede es responder 404, porque el panel + // usa ese código para decidir que el inquilino no existe. + response.StatusCode.Should().Be(HttpStatusCode.OK); + } + + [Fact] + public async Task GetBranding_InquilinoInexistente_Responde404() + { + var inexistente = Guid.NewGuid(); + + var response = await _client.GetAsync( + $"/api/v1/tenants/{inexistente}/branding", TestContext.Current.CancellationToken); + + response.StatusCode.Should().Be(HttpStatusCode.NotFound); + } +} diff --git a/src/apps/ums.api/Ums.Presentation.IntegrationTest/Platform/SafeGraphQlErrorFilterTests.cs b/src/apps/ums.api/Ums.Presentation.IntegrationTest/Platform/SafeGraphQlErrorFilterTests.cs deleted file mode 100644 index 04a1d8f8..00000000 --- a/src/apps/ums.api/Ums.Presentation.IntegrationTest/Platform/SafeGraphQlErrorFilterTests.cs +++ /dev/null @@ -1,32 +0,0 @@ -using HotChocolate; -using Ums.Globalization.Access; -using Ums.Presentation.GraphQL; - -namespace Ums.Presentation.IntegrationTest.Platform; - -public sealed class SafeGraphQlErrorFilterTests -{ - [Fact] - public void OnError_ShouldNotExposeTechnicalMessageOrExtensions() - { - var sourceError = ErrorBuilder.New() - .SetMessage("System.InvalidOperationException: private resolver detail") - .SetException(new InvalidOperationException("private resolver stack")) - .SetExtension("stackTrace", "private stack") - .SetCode("UMS_INTERNAL_ERROR") - .Build(); - - IError responseError; - using (CultureContext.Set("es")) - { - responseError = new SafeGraphQlErrorFilter().OnError(sourceError); - } - - responseError.Message.Should().Be( - "No se pudo completar la operación debido a un error inesperado. Intente nuevamente más tarde."); - responseError.Code.Should().Be("UMS_INTERNAL_ERROR"); - var extensions = responseError.Extensions!; - extensions.Should().NotContainKey("stackTrace"); - Guid.TryParse(extensions["errorId"]?.ToString(), out _).Should().BeTrue(); - } -} diff --git a/src/apps/ums.api/Ums.Presentation/Bootstrapping/UmsApiServiceBootstrappers.cs b/src/apps/ums.api/Ums.Presentation/Bootstrapping/UmsApiServiceBootstrappers.cs index e7c4ff56..c6c4d00b 100644 --- a/src/apps/ums.api/Ums.Presentation/Bootstrapping/UmsApiServiceBootstrappers.cs +++ b/src/apps/ums.api/Ums.Presentation/Bootstrapping/UmsApiServiceBootstrappers.cs @@ -15,7 +15,6 @@ using Ums.Infrastructure.Persistence.Options; using Ums.Infrastructure.Persistence.Seeders; using Ums.Presentation.Endpoints; -using Ums.Presentation.GraphQL; using Ums.Presentation.Services; using Ums.Presentation.Endpoints.Approvals.AccessEnforcementPolicy; using Ums.Presentation.Endpoints.Approvals.AccessEnforcementPolicy.Queries; @@ -109,8 +108,6 @@ public void Run() Result.AddApplication(); Result.AddInfrastructure(_configuration, _environment); Result.AddScoped(); - // GraphQL: transporte de consulta propio del satélite (ausente en la plataforma de origen). - Result.AddUmsGraphQl(_environment); Result.AddMemoryCache(); // required by IdempotencyMiddleware (FIX-06) // HARDENING-02: JWT Bearer authentication. Disabled in dev (DevAuthMiddleware handles it). @@ -322,27 +319,9 @@ public static WebApplication MapUmsApiSurface(this WebApplication app) versionedGroup.MapUmsCommandEndpoints(); versionedGroup.MapUmsQueryEndpoints(); - // Transporte GraphQL de solo lectura: superficie propia del satélite. - app.MapGraphQlSurface(versionedGroup); - return app; } - /// - /// GraphQL es el transporte de consulta propio del satélite (no existe en la plataforma de - /// origen). Se expone en la raíz y bajo el grupo versionado para que un cliente pueda fijar - /// la versión igual que hace con REST. - /// - internal static IEndpointRouteBuilder MapGraphQlSurface( - this IEndpointRouteBuilder endpoints, - RouteGroupBuilder versionedGroup) - { - endpoints.MapGraphQL("/graphql").WithTags("GraphQL - Queries"); - versionedGroup.MapGraphQL("/graphql").WithTags("GraphQL - Queries"); - - return endpoints; - } - internal static RouteGroupBuilder CreateVersionedApiGroup(this WebApplication app) { var versionSet = app.NewApiVersionSet() diff --git a/src/apps/ums.api/Ums.Presentation/Endpoints/Identity/Tenant/TenantBrandingEndpoints.cs b/src/apps/ums.api/Ums.Presentation/Endpoints/Identity/Tenant/TenantBrandingEndpoints.cs index 068d4b6a..f8e1c6c5 100644 --- a/src/apps/ums.api/Ums.Presentation/Endpoints/Identity/Tenant/TenantBrandingEndpoints.cs +++ b/src/apps/ums.api/Ums.Presentation/Endpoints/Identity/Tenant/TenantBrandingEndpoints.cs @@ -10,6 +10,8 @@ public static IEndpointRouteBuilder MapTenantBrandingEndpoints(this IEndpointRou var group = app.MapGroup("/tenants/{tenantId:guid}/branding") .WithTags("Tenant Branding"); + // La LECTURA no está aquí: vive en Queries/BrandingQueryEndpoints, junto al resto de la + // superficie REST de consulta. Este fichero solo tiene comandos. group.MapPost("/", async ( Guid tenantId, [FromBody] SetBrandingRequest request, diff --git a/src/apps/ums.api/Ums.Presentation/Extensions/QueryResultExtensions.cs b/src/apps/ums.api/Ums.Presentation/Extensions/QueryResultExtensions.cs deleted file mode 100644 index 8a42000c..00000000 --- a/src/apps/ums.api/Ums.Presentation/Extensions/QueryResultExtensions.cs +++ /dev/null @@ -1,31 +0,0 @@ -namespace Ums.Presentation.Extensions; - -using HotChocolate; -using HotChocolate.Execution; -using Ums.Domain.Kernel; - -internal static class QueryResultExtensions -{ - public static T UnwrapGraphQl(this Result result, string code = "UMS_QUERY_ERROR") - { - if (result.IsSuccess) - { - return result.Value; - } - - throw CreateGraphQlException(code); - } - - public static T? UnwrapGraphQlOrNull(this Result result) - where T : class - => result.IsSuccess ? result.Value : null; - - public static GraphQLException ToGraphQlException(this string message, string code = "UMS_QUERY_ERROR") - => CreateGraphQlException(code); - - private static GraphQLException CreateGraphQlException(string code) - => new(ErrorBuilder.New() - .SetMessage("The request could not be completed.") - .SetCode(code) - .Build()); -} diff --git a/src/apps/ums.api/Ums.Presentation/GraphQL/Approvals/AccessEnforcementPolicyQueries.cs b/src/apps/ums.api/Ums.Presentation/GraphQL/Approvals/AccessEnforcementPolicyQueries.cs deleted file mode 100644 index 30bc5433..00000000 --- a/src/apps/ums.api/Ums.Presentation/GraphQL/Approvals/AccessEnforcementPolicyQueries.cs +++ /dev/null @@ -1,32 +0,0 @@ -namespace Ums.Presentation.GraphQL.Approvals; - -using HotChocolate; -using HotChocolate.Types; -using Ums.Application.Common; -using Ums.Application.Approvals.AccessEnforcementPolicy.DTOs; -using Ums.Application.Approvals.AccessEnforcementPolicy.Queries; -using Ums.Presentation.Extensions; -using static Ums.Application.Common.QueryRequestNormalizer; - -[ExtendObjectType("Query")] -public sealed class AccessEnforcementPolicyQueries -{ - public async Task> GetAccessEnforcementPoliciesAsync( - int page, int pageSize, string? search, string? criteria, string? status, string? sortBy, string? sortOrder, - Guid? tenantId, [Service] IMediator mediator, CancellationToken cancellationToken) - { - var result = await mediator.Send(new GetAllAccessEnforcementPoliciesQuery( - NormalizePage(page), NormalizePageSize(pageSize), search, - NormalizeText(criteria, "enforcementAction"), - NormalizeText(status, "all"), - NormalizeText(sortBy, "enforcementAction"), - NormalizeText(sortOrder, "asc"), tenantId), cancellationToken); - return result.UnwrapGraphQl(); - } - - public async Task GetAccessEnforcementPolicyByIdAsync(Guid id, [Service] IMediator mediator, CancellationToken cancellationToken) - { - var result = await mediator.Send(new GetAccessEnforcementPolicyByIdQuery(id), cancellationToken); - return result.UnwrapGraphQlOrNull(); - } -} diff --git a/src/apps/ums.api/Ums.Presentation/GraphQL/Approvals/ApprovalRequestQueries.cs b/src/apps/ums.api/Ums.Presentation/GraphQL/Approvals/ApprovalRequestQueries.cs deleted file mode 100644 index 4a54e20e..00000000 --- a/src/apps/ums.api/Ums.Presentation/GraphQL/Approvals/ApprovalRequestQueries.cs +++ /dev/null @@ -1,32 +0,0 @@ -namespace Ums.Presentation.GraphQL.Approvals; - -using HotChocolate; -using HotChocolate.Types; -using Ums.Application.Common; -using Ums.Application.Approvals.ApprovalRequest.DTOs; -using Ums.Application.Approvals.ApprovalRequest.Queries; -using Ums.Presentation.Extensions; -using static Ums.Application.Common.QueryRequestNormalizer; - -[ExtendObjectType("Query")] -public sealed class ApprovalRequestQueries -{ - public async Task> GetApprovalRequestsAsync( - int page, int pageSize, string? search, string? criteria, string? status, string? sortBy, string? sortOrder, - Guid? tenantId, Guid? userId, [Service] IMediator mediator, CancellationToken cancellationToken) - { - var result = await mediator.Send(new GetAllApprovalRequestsQuery( - NormalizePage(page), NormalizePageSize(pageSize), search, - NormalizeText(criteria, "status"), - NormalizeText(status, "all"), - NormalizeText(sortBy, "status"), - NormalizeText(sortOrder, "asc"), tenantId, userId), cancellationToken); - return result.UnwrapGraphQl(); - } - - public async Task GetApprovalRequestByIdAsync(Guid id, [Service] IMediator mediator, CancellationToken cancellationToken) - { - var result = await mediator.Send(new GetApprovalRequestByIdQuery(id), cancellationToken); - return result.UnwrapGraphQlOrNull(); - } -} diff --git a/src/apps/ums.api/Ums.Presentation/GraphQL/Approvals/ApprovalWorkflowQueries.cs b/src/apps/ums.api/Ums.Presentation/GraphQL/Approvals/ApprovalWorkflowQueries.cs deleted file mode 100644 index 36f785f5..00000000 --- a/src/apps/ums.api/Ums.Presentation/GraphQL/Approvals/ApprovalWorkflowQueries.cs +++ /dev/null @@ -1,31 +0,0 @@ -namespace Ums.Presentation.GraphQL.Approvals; - -using HotChocolate; -using HotChocolate.Types; -using Ums.Application.Common; -using Ums.Application.Approvals.ApprovalWorkflow.DTOs; -using Ums.Application.Approvals.ApprovalWorkflow.Queries; -using Ums.Presentation.Extensions; -using static Ums.Application.Common.QueryRequestNormalizer; - -[ExtendObjectType("Query")] -public sealed class ApprovalWorkflowQueries -{ - public async Task> GetApprovalWorkflowsAsync( - int page, int pageSize, string? search, string? criteria, string? sortBy, string? sortOrder, - Guid? tenantId, [Service] IMediator mediator, CancellationToken cancellationToken) - { - var result = await mediator.Send(new GetAllApprovalWorkflowsQuery( - NormalizePage(page), NormalizePageSize(pageSize), search, - NormalizeText(criteria, "name"), - NormalizeText(sortBy, "name"), - NormalizeText(sortOrder, "asc"), tenantId), cancellationToken); - return result.UnwrapGraphQl(); - } - - public async Task GetApprovalWorkflowByIdAsync(Guid id, [Service] IMediator mediator, CancellationToken cancellationToken) - { - var result = await mediator.Send(new GetApprovalWorkflowByIdQuery(id), cancellationToken); - return result.UnwrapGraphQlOrNull(); - } -} diff --git a/src/apps/ums.api/Ums.Presentation/GraphQL/Approvals/DocumentTypeQueries.cs b/src/apps/ums.api/Ums.Presentation/GraphQL/Approvals/DocumentTypeQueries.cs deleted file mode 100644 index ff703cbb..00000000 --- a/src/apps/ums.api/Ums.Presentation/GraphQL/Approvals/DocumentTypeQueries.cs +++ /dev/null @@ -1,31 +0,0 @@ -namespace Ums.Presentation.GraphQL.Approvals; - -using HotChocolate; -using HotChocolate.Types; -using Ums.Application.Common; -using Ums.Application.Approvals.DocumentType.DTOs; -using Ums.Application.Approvals.DocumentType.Queries; -using Ums.Presentation.Extensions; -using static Ums.Application.Common.QueryRequestNormalizer; - -[ExtendObjectType("Query")] -public sealed class DocumentTypeQueries -{ - public async Task> GetDocumentTypesAsync( - int page, int pageSize, string? search, string? criteria, string? sortBy, string? sortOrder, - Guid? tenantId, [Service] IMediator mediator, CancellationToken cancellationToken) - { - var result = await mediator.Send(new GetAllDocumentTypesQuery( - NormalizePage(page), NormalizePageSize(pageSize), search, - NormalizeText(criteria, "name"), - NormalizeText(sortBy, "name"), - NormalizeText(sortOrder, "asc"), tenantId), cancellationToken); - return result.UnwrapGraphQl(); - } - - public async Task GetDocumentTypeByIdAsync(Guid id, [Service] IMediator mediator, CancellationToken cancellationToken) - { - var result = await mediator.Send(new GetDocumentTypeByIdQuery(id), cancellationToken); - return result.UnwrapGraphQlOrNull(); - } -} diff --git a/src/apps/ums.api/Ums.Presentation/GraphQL/Approvals/NotificationRuleQueries.cs b/src/apps/ums.api/Ums.Presentation/GraphQL/Approvals/NotificationRuleQueries.cs deleted file mode 100644 index b49769f1..00000000 --- a/src/apps/ums.api/Ums.Presentation/GraphQL/Approvals/NotificationRuleQueries.cs +++ /dev/null @@ -1,32 +0,0 @@ -namespace Ums.Presentation.GraphQL.Approvals; - -using HotChocolate; -using HotChocolate.Types; -using Ums.Application.Common; -using Ums.Application.Approvals.NotificationRule.DTOs; -using Ums.Application.Approvals.NotificationRule.Queries; -using Ums.Presentation.Extensions; -using static Ums.Application.Common.QueryRequestNormalizer; - -[ExtendObjectType("Query")] -public sealed class NotificationRuleQueries -{ - public async Task> GetNotificationRulesAsync( - int page, int pageSize, string? search, string? criteria, string? status, string? sortBy, string? sortOrder, - Guid? tenantId, [Service] IMediator mediator, CancellationToken cancellationToken) - { - var result = await mediator.Send(new GetAllNotificationRulesQuery( - NormalizePage(page), NormalizePageSize(pageSize), search, - NormalizeText(criteria, "channel"), - NormalizeText(status, "all"), - NormalizeText(sortBy, "channel"), - NormalizeText(sortOrder, "asc"), tenantId), cancellationToken); - return result.UnwrapGraphQl(); - } - - public async Task GetNotificationRuleByIdAsync(Guid id, [Service] IMediator mediator, CancellationToken cancellationToken) - { - var result = await mediator.Send(new GetNotificationRuleByIdQuery(id), cancellationToken); - return result.UnwrapGraphQlOrNull(); - } -} diff --git a/src/apps/ums.api/Ums.Presentation/GraphQL/Approvals/UserDocumentQueries.cs b/src/apps/ums.api/Ums.Presentation/GraphQL/Approvals/UserDocumentQueries.cs deleted file mode 100644 index 91bf08a0..00000000 --- a/src/apps/ums.api/Ums.Presentation/GraphQL/Approvals/UserDocumentQueries.cs +++ /dev/null @@ -1,32 +0,0 @@ -namespace Ums.Presentation.GraphQL.Approvals; - -using HotChocolate; -using HotChocolate.Types; -using Ums.Application.Common; -using Ums.Application.Approvals.UserDocument.DTOs; -using Ums.Application.Approvals.UserDocument.Queries; -using Ums.Presentation.Extensions; -using static Ums.Application.Common.QueryRequestNormalizer; - -[ExtendObjectType("Query")] -public sealed class UserDocumentQueries -{ - public async Task> GetUserDocumentsAsync( - int page, int pageSize, string? search, string? criteria, string? status, string? sortBy, string? sortOrder, - Guid? userId, Guid? tenantId, [Service] IMediator mediator, CancellationToken cancellationToken) - { - var result = await mediator.Send(new GetAllUserDocumentsQuery( - NormalizePage(page), NormalizePageSize(pageSize), search, - NormalizeText(criteria, "status"), - NormalizeText(status, "all"), - NormalizeText(sortBy, "status"), - NormalizeText(sortOrder, "asc"), userId, tenantId), cancellationToken); - return result.UnwrapGraphQl(); - } - - public async Task GetUserDocumentByIdAsync(Guid id, [Service] IMediator mediator, CancellationToken cancellationToken) - { - var result = await mediator.Send(new GetUserDocumentByIdQuery(id), cancellationToken); - return result.UnwrapGraphQlOrNull(); - } -} diff --git a/src/apps/ums.api/Ums.Presentation/GraphQL/Audit/AuditRecordQueries.cs b/src/apps/ums.api/Ums.Presentation/GraphQL/Audit/AuditRecordQueries.cs deleted file mode 100644 index fdb6c1fc..00000000 --- a/src/apps/ums.api/Ums.Presentation/GraphQL/Audit/AuditRecordQueries.cs +++ /dev/null @@ -1,44 +0,0 @@ -namespace Ums.Presentation.GraphQL.Audit; - -using HotChocolate; -using HotChocolate.Types; -using Ums.Application.Common; -using Ums.Application.Audit.AuditRecord.DTOs; -using Ums.Application.Audit.AuditRecord.Queries; -using Ums.Presentation.Extensions; -using static Ums.Application.Common.QueryRequestNormalizer; - -[ExtendObjectType("Query")] -public sealed class AuditRecordQueries -{ - public async Task> GetAuditRecordsAsync( - int page, - int pageSize, - string? eventType, - Guid? actorId, - Guid? entityId, - string? entityType, - Guid? tenantId, - DateTime? from, - DateTime? to, - [Service] IMediator mediator, - CancellationToken cancellationToken) - { - var result = await mediator.Send(new GetAllAuditRecordsQuery( - NormalizePage(page), - NormalizePageSize(pageSize), - eventType, actorId, entityId, entityType, tenantId, from, to), cancellationToken); - - return result.UnwrapGraphQl(); - } - - public async Task GetAuditRecordByIdAsync( - Guid auditRecordId, - [Service] IMediator mediator, - CancellationToken cancellationToken) - { - var result = await mediator.Send(new GetAuditRecordByIdQuery(auditRecordId), cancellationToken); - - return result.UnwrapGraphQlOrNull(); - } -} diff --git a/src/apps/ums.api/Ums.Presentation/GraphQL/Authorization/PermissionTemplateQueries.cs b/src/apps/ums.api/Ums.Presentation/GraphQL/Authorization/PermissionTemplateQueries.cs deleted file mode 100644 index 2e72c035..00000000 --- a/src/apps/ums.api/Ums.Presentation/GraphQL/Authorization/PermissionTemplateQueries.cs +++ /dev/null @@ -1,52 +0,0 @@ -namespace Ums.Presentation.GraphQL.Authorization; - -using HotChocolate; -using HotChocolate.Types; -using Ums.Application.Common; -using Ums.Application.Authorization.Template.DTOs; -using Ums.Application.Authorization.Template.Queries; -using Ums.Presentation.Extensions; -using static Ums.Application.Common.QueryRequestNormalizer; - -[ExtendObjectType("Query")] -public sealed class PermissionTemplateQueries -{ - public async Task> GetPermissionTemplatesAsync( - int page, - int pageSize, - string? search, - string? criteria, - string? status, - string? sortBy, - string? sortOrder, - Guid? tenantId, - Guid? systemSuiteId, - Guid? roleId, - [Service] IMediator mediator, - CancellationToken cancellationToken) - { - var result = await mediator.Send(new GetAllPermissionTemplatesQuery( - NormalizePage(page), - NormalizePageSize(pageSize), - search, - NormalizeText(criteria, "version"), - NormalizeText(status, "all"), - NormalizeText(sortBy, "version"), - NormalizeText(sortOrder, "asc"), - tenantId, - systemSuiteId, - roleId), cancellationToken); - - return result.UnwrapGraphQl(); - } - - public async Task GetPermissionTemplateByIdAsync( - Guid templateId, - [Service] IMediator mediator, - CancellationToken cancellationToken) - { - var result = await mediator.Send(new GetPermissionTemplateByIdQuery(templateId), cancellationToken); - - return result.UnwrapGraphQlOrNull(); - } -} diff --git a/src/apps/ums.api/Ums.Presentation/GraphQL/Authorization/ProfileQueries.cs b/src/apps/ums.api/Ums.Presentation/GraphQL/Authorization/ProfileQueries.cs deleted file mode 100644 index 819ca47d..00000000 --- a/src/apps/ums.api/Ums.Presentation/GraphQL/Authorization/ProfileQueries.cs +++ /dev/null @@ -1,50 +0,0 @@ -namespace Ums.Presentation.GraphQL.Authorization; - -using HotChocolate; -using HotChocolate.Types; -using Ums.Application.Common; -using Ums.Application.Authorization.Profile.DTOs; -using Ums.Application.Authorization.Profile.Queries; -using Ums.Presentation.Extensions; -using static Ums.Application.Common.QueryRequestNormalizer; - -[ExtendObjectType("Query")] -public sealed class ProfileQueries -{ - public async Task> GetProfilesAsync( - int page, - int pageSize, - string? search, - string? criteria, - string? status, - string? sortBy, - string? sortOrder, - Guid? tenantId, - Guid? userId, - [Service] IMediator mediator, - CancellationToken cancellationToken) - { - var result = await mediator.Send(new GetAllProfilesQuery( - NormalizePage(page), - NormalizePageSize(pageSize), - search, - NormalizeText(criteria, "userId"), - NormalizeText(status, "all"), - NormalizeText(sortBy, "userId"), - NormalizeText(sortOrder, "asc"), - tenantId, - userId), cancellationToken); - - return result.UnwrapGraphQl(); - } - - public async Task GetProfileByIdAsync( - Guid profileId, - [Service] IMediator mediator, - CancellationToken cancellationToken) - { - var result = await mediator.Send(new GetProfileByIdQuery(profileId), cancellationToken); - - return result.UnwrapGraphQlOrNull(); - } -} diff --git a/src/apps/ums.api/Ums.Presentation/GraphQL/Authorization/RoleQueries.cs b/src/apps/ums.api/Ums.Presentation/GraphQL/Authorization/RoleQueries.cs deleted file mode 100644 index 47713cab..00000000 --- a/src/apps/ums.api/Ums.Presentation/GraphQL/Authorization/RoleQueries.cs +++ /dev/null @@ -1,20 +0,0 @@ -namespace Ums.Presentation.GraphQL.Authorization; - -using HotChocolate; -using HotChocolate.Types; -using Ums.Application.Authorization.Role.DTOs; -using Ums.Application.Authorization.Role.Queries; -using Ums.Presentation.Extensions; - -[ExtendObjectType("Query")] -public sealed class RoleQueries -{ - public async Task> GetRolesBySystemSuiteAsync( - Guid systemSuiteId, - [Service] IMediator mediator, - CancellationToken cancellationToken) - { - var result = await mediator.Send(new GetRolesBySystemSuiteQuery(systemSuiteId), cancellationToken); - return result.UnwrapGraphQl(); - } -} diff --git a/src/apps/ums.api/Ums.Presentation/GraphQL/Authorization/SystemSuiteQueries.cs b/src/apps/ums.api/Ums.Presentation/GraphQL/Authorization/SystemSuiteQueries.cs deleted file mode 100644 index 68aafb1c..00000000 --- a/src/apps/ums.api/Ums.Presentation/GraphQL/Authorization/SystemSuiteQueries.cs +++ /dev/null @@ -1,48 +0,0 @@ -namespace Ums.Presentation.GraphQL.Authorization; - -using HotChocolate; -using HotChocolate.Types; -using Ums.Application.Common; -using Ums.Application.Authorization.SystemSuite.DTOs; -using Ums.Application.Authorization.SystemSuite.Queries; -using Ums.Presentation.Extensions; -using static Ums.Application.Common.QueryRequestNormalizer; - -[ExtendObjectType("Query")] -public sealed class SystemSuiteQueries -{ - public async Task> GetSystemSuitesAsync( - int page, - int pageSize, - string? search, - string? criteria, - string? status, - string? sortBy, - string? sortOrder, - Guid? tenantId, - [Service] IMediator mediator, - CancellationToken cancellationToken) - { - var result = await mediator.Send(new GetAllSystemSuitesQuery( - NormalizePage(page), - NormalizePageSize(pageSize), - search, - NormalizeText(criteria, "name"), - NormalizeText(status, "all"), - NormalizeText(sortBy, "name"), - NormalizeText(sortOrder, "asc"), - tenantId), cancellationToken); - - return result.UnwrapGraphQl(); - } - - public async Task GetSystemSuiteByIdAsync( - Guid systemSuiteId, - [Service] IMediator mediator, - CancellationToken cancellationToken) - { - var result = await mediator.Send(new GetSystemSuiteByIdQuery(systemSuiteId), cancellationToken); - - return result.UnwrapGraphQlOrNull(); - } -} diff --git a/src/apps/ums.api/Ums.Presentation/GraphQL/Configuration/AppConfigurationQueries.cs b/src/apps/ums.api/Ums.Presentation/GraphQL/Configuration/AppConfigurationQueries.cs deleted file mode 100644 index b639d52d..00000000 --- a/src/apps/ums.api/Ums.Presentation/GraphQL/Configuration/AppConfigurationQueries.cs +++ /dev/null @@ -1,56 +0,0 @@ -namespace Ums.Presentation.GraphQL.Configuration; - -using HotChocolate; -using HotChocolate.Types; -using Ums.Application.Common; -using Ums.Application.Configuration.AppConfiguration.DTOs; -using Ums.Application.Configuration.AppConfiguration.Queries; -using Ums.Presentation.Extensions; -using static Ums.Application.Common.QueryRequestNormalizer; - -[ExtendObjectType("Query")] -public sealed class AppConfigurationQueries -{ - public async Task> GetAppConfigurationsAsync( - int page, - int pageSize, - string? search, - string? criteria, - string? status, - string? sortBy, - string? sortOrder, - string? scope, - Guid? tenantId, - Guid? systemSuiteId, - Guid? moduleId, - [Service] IMediator mediator, - CancellationToken cancellationToken) - { - // TODO(TD-002): Investigate why this resolver returns empty AppConfiguration pages - // while the REST-backed query path returns the expected data. - var result = await mediator.Send(new GetAllAppConfigurationsQuery( - NormalizePage(page), - NormalizePageSize(pageSize), - search, - NormalizeText(criteria, "code"), - NormalizeText(status, "all"), - NormalizeText(sortBy, "code"), - NormalizeText(sortOrder, "asc"), - NormalizeSearch(scope), - tenantId, - systemSuiteId, - moduleId), cancellationToken); - if (result.IsFailure) - { - throw new Exception(result.Error); - } - - return result.Value; - } - - public async Task GetAppConfigurationByIdAsync(Guid id, [Service] IMediator mediator, CancellationToken cancellationToken) - { - var result = await mediator.Send(new GetAppConfigurationByIdQuery(id), cancellationToken); - return result.UnwrapGraphQlOrNull(); - } -} diff --git a/src/apps/ums.api/Ums.Presentation/GraphQL/Configuration/FeatureFlagQueries.cs b/src/apps/ums.api/Ums.Presentation/GraphQL/Configuration/FeatureFlagQueries.cs deleted file mode 100644 index dbe48ac5..00000000 --- a/src/apps/ums.api/Ums.Presentation/GraphQL/Configuration/FeatureFlagQueries.cs +++ /dev/null @@ -1,45 +0,0 @@ -namespace Ums.Presentation.GraphQL.Configuration; - -using HotChocolate; -using HotChocolate.Types; -using Ums.Application.Common; -using Ums.Application.Configuration.FeatureFlag.DTOs; -using Ums.Application.Configuration.FeatureFlag.Queries; -using Ums.Presentation.Extensions; -using static Ums.Application.Common.QueryRequestNormalizer; - -[ExtendObjectType("Query")] -public sealed class FeatureFlagQueries -{ - public async Task> GetFeatureFlagsAsync( - int page, - int pageSize, - string? search, - string? criteria, - string? status, - string? sortBy, - string? sortOrder, - string? flagType, - string? linkedResourceType, - [Service] IMediator mediator, - CancellationToken cancellationToken) - { - var result = await mediator.Send(new GetAllFeatureFlagsQuery( - NormalizePage(page), - NormalizePageSize(pageSize), - search, - NormalizeText(criteria, "flagCode"), - NormalizeText(status, "all"), - NormalizeText(sortBy, "flagCode"), - NormalizeText(sortOrder, "asc"), - NormalizeSearch(flagType), - NormalizeSearch(linkedResourceType)), cancellationToken); - return result.UnwrapGraphQl(); - } - - public async Task GetFeatureFlagByIdAsync(Guid id, [Service] IMediator mediator, CancellationToken cancellationToken) - { - var result = await mediator.Send(new GetFeatureFlagByIdQuery(id), cancellationToken); - return result.UnwrapGraphQlOrNull(); - } -} diff --git a/src/apps/ums.api/Ums.Presentation/GraphQL/Configuration/IdpConfigurationQueries.cs b/src/apps/ums.api/Ums.Presentation/GraphQL/Configuration/IdpConfigurationQueries.cs deleted file mode 100644 index b5ac1a63..00000000 --- a/src/apps/ums.api/Ums.Presentation/GraphQL/Configuration/IdpConfigurationQueries.cs +++ /dev/null @@ -1,63 +0,0 @@ -namespace Ums.Presentation.GraphQL.Configuration; - -using HotChocolate; -using HotChocolate.Types; -using Ums.Application.Common; -using Ums.Application.Configuration.IdpConfiguration.DTOs; -using Ums.Application.Configuration.IdpConfiguration.Queries; -using Ums.Presentation.Extensions; -using static Ums.Application.Common.QueryRequestNormalizer; - -[ExtendObjectType("Query")] -public sealed class IdpConfigurationQueries -{ - public async Task> GetIdpConfigurationsAsync( - int page, - int pageSize, - string? search, - string? criteria, - string? status, - string? sortBy, - string? sortOrder, - Guid? tenantId, - Guid? systemSuiteId, - string? providerType, - [Service] IMediator mediator, - CancellationToken cancellationToken) - { - var result = await mediator.Send(new GetAllIdpConfigurationsQuery( - NormalizePage(page), - NormalizePageSize(pageSize), - search, - NormalizeText(criteria, "providerType"), - NormalizeText(status, "all"), - NormalizeText(sortBy, "resolutionPriority"), - NormalizeText(sortOrder, "asc"), - tenantId, - systemSuiteId, - NormalizeSearch(providerType)), cancellationToken); - return result.UnwrapGraphQl(); - } - - public async Task GetIdpConfigurationByIdAsync(Guid id, [Service] IMediator mediator, CancellationToken cancellationToken) - { - var result = await mediator.Send(new GetIdpConfigurationByIdQuery(id), cancellationToken); - return result.UnwrapGraphQlOrNull(); - } - - public async Task ResolveIdpConfigurationAsync( - Guid tenantId, - Guid? systemSuiteId, - string? emailDomain, - string? providerType, - [Service] IMediator mediator, - CancellationToken cancellationToken) - { - var result = await mediator.Send(new ResolveIdpConfigurationQuery( - tenantId, - systemSuiteId, - NormalizeSearch(emailDomain), - NormalizeSearch(providerType)), cancellationToken); - return result.UnwrapGraphQlOrNull(); - } -} diff --git a/src/apps/ums.api/Ums.Presentation/GraphQL/GraphQlServiceCollectionExtensions.cs b/src/apps/ums.api/Ums.Presentation/GraphQL/GraphQlServiceCollectionExtensions.cs deleted file mode 100644 index 645eeeb8..00000000 --- a/src/apps/ums.api/Ums.Presentation/GraphQL/GraphQlServiceCollectionExtensions.cs +++ /dev/null @@ -1,61 +0,0 @@ -namespace Ums.Presentation.GraphQL; - -using HotChocolate.Execution.Configuration; -using Ums.Presentation.GraphQL.Approvals; -using Ums.Presentation.GraphQL.Audit; -using Ums.Presentation.GraphQL.Authorization; -using Ums.Presentation.GraphQL.Configuration; -using Ums.Presentation.GraphQL.Identity; - -public static class GraphQlServiceCollectionExtensions -{ - /// - /// REC-11: GraphQL introspection is restricted to Development environment only. - /// In Staging/Production, introspection queries are rejected with 400 to prevent - /// schema enumeration by unauthorized clients via __schema or __type queries. - /// - public static IRequestExecutorBuilder AddUmsGraphQl( - this IServiceCollection services, - IHostEnvironment environment) - { - var builder = services - .AddGraphQLServer() - .AddHttpRequestInterceptor() - .AddQueryType(d => d.Name("Query")) - .AddTypeExtension() - .AddTypeExtension() - .AddTypeExtension() - .AddTypeExtension() - .AddTypeExtension() - .AddTypeExtension() - .AddTypeExtension() - .AddTypeExtension() - .AddTypeExtension() - .AddTypeExtension() - .AddTypeExtension() - .AddTypeExtension() - .AddTypeExtension() - .AddTypeExtension() - .AddTypeExtension() - .AddTypeExtension() - .AddTypeExtension() - .AddErrorFilter() - .AddMaxExecutionDepthRule(12) - .ModifyRequestOptions(options => - { - options.ExecutionTimeout = TimeSpan.FromSeconds(10); - options.IncludeExceptionDetails = false; - }); - - // REC-11: Disable introspection outside Development so attackers cannot - // enumerate the full schema surface via __schema or __type queries. - // DisableIntrospection() is an extension on IRequestExecutorBuilder - // from HotChocolate.Execution.Configuration.RequestExecutorBuilderExtensions. - if (!environment.IsDevelopment()) - { - builder.DisableIntrospection(); - } - - return builder; - } -} diff --git a/src/apps/ums.api/Ums.Presentation/GraphQL/Identity/DelegationQueries.cs b/src/apps/ums.api/Ums.Presentation/GraphQL/Identity/DelegationQueries.cs deleted file mode 100644 index cd91b6b1..00000000 --- a/src/apps/ums.api/Ums.Presentation/GraphQL/Identity/DelegationQueries.cs +++ /dev/null @@ -1,48 +0,0 @@ -namespace Ums.Presentation.GraphQL.Identity; - -using HotChocolate; -using HotChocolate.Types; -using Ums.Application.Identity.UserManagementDelegation.DTOs; -using Ums.Application.Identity.UserManagementDelegation.Queries; -using Ums.Presentation.Extensions; - -[ExtendObjectType("Query")] -public sealed class DelegationQueries -{ - public async Task GetDelegationByIdAsync( - Guid delegationId, - [Service] IMediator mediator, - CancellationToken cancellationToken) - { - var result = await mediator.Send(new GetDelegationByIdQuery(delegationId), cancellationToken); - return result.UnwrapGraphQlOrNull(); - } - - public async Task> GetDelegationsByDelegatedAdminAsync( - Guid delegatedAdminId, - Guid tenantId, - [Service] IMediator mediator, - CancellationToken cancellationToken) - { - var result = await mediator.Send(new GetDelegationsByDelegatedAdminQuery(delegatedAdminId, tenantId), cancellationToken); - return result.UnwrapGraphQl(); - } - - public async Task> GetDelegationsByDelegatingAdminAsync( - Guid delegatingAdminId, - Guid tenantId, - [Service] IMediator mediator, - CancellationToken cancellationToken) - { - var result = await mediator.Send(new GetDelegationsByDelegatingAdminQuery(delegatingAdminId, tenantId), cancellationToken); - return result.UnwrapGraphQl(); - } - - public async Task> GetAllDelegationsAsync( - [Service] IMediator mediator, - CancellationToken cancellationToken) - { - var result = await mediator.Send(new GetAllDelegationsQuery(), cancellationToken); - return result.UnwrapGraphQl(); - } -} diff --git a/src/apps/ums.api/Ums.Presentation/GraphQL/Identity/TenantQueries.cs b/src/apps/ums.api/Ums.Presentation/GraphQL/Identity/TenantQueries.cs deleted file mode 100644 index 37808853..00000000 --- a/src/apps/ums.api/Ums.Presentation/GraphQL/Identity/TenantQueries.cs +++ /dev/null @@ -1,82 +0,0 @@ -namespace Ums.Presentation.GraphQL.Identity; - -using HotChocolate; -using HotChocolate.Types; -using Ums.Application.Common; -using Ums.Application.Identity.Tenant.Branch.DTOs; -using Ums.Application.Identity.Tenant.Branch.Queries; -using Ums.Application.Identity.Tenant.Branding.DTOs; -using Ums.Application.Identity.Tenant.Branding.Queries; -using Ums.Application.Identity.Tenant.DTOs; -using Ums.Application.Identity.Tenant.IdentityProvider.DTOs; -using Ums.Application.Identity.Tenant.IdentityProvider.Queries; -using Ums.Application.Identity.Tenant.Queries; -using Ums.Presentation.Extensions; -using static Ums.Application.Common.QueryRequestNormalizer; - -[ExtendObjectType("Query")] -public sealed class TenantQueries -{ - public async Task> GetTenantsAsync( - int page, - int pageSize, - string? search, - string? criteria, - string? status, - string? sortBy, - string? sortOrder, - [Service] IMediator mediator, - CancellationToken cancellationToken) - { - var result = await mediator.Send(new GetAllTenantsQuery( - NormalizePage(page), - NormalizePageSize(pageSize), - search, - NormalizeText(criteria, "name"), - NormalizeText(status, "all"), - NormalizeText(sortBy, "name"), - NormalizeText(sortOrder, "asc")), cancellationToken); - - return result.UnwrapGraphQl(); - } - - public async Task GetTenantByIdAsync( - Guid tenantId, - [Service] IMediator mediator, - CancellationToken cancellationToken) - { - var result = await mediator.Send(new GetTenantByIdQuery(tenantId), cancellationToken); - - return result.UnwrapGraphQlOrNull(); - } - - public async Task> GetTenantBranchesAsync( - Guid tenantId, - [Service] IMediator mediator, - CancellationToken cancellationToken) - { - var result = await mediator.Send(new GetBranchesByTenantIdQuery(tenantId), cancellationToken); - - return result.UnwrapGraphQl(); - } - - public async Task GetTenantBrandingAsync( - Guid tenantId, - [Service] IMediator mediator, - CancellationToken cancellationToken) - { - var result = await mediator.Send(new GetBrandingByTenantIdQuery(tenantId), cancellationToken); - - return result.UnwrapGraphQlOrNull(); - } - - public async Task> GetTenantIdentityProvidersAsync( - Guid tenantId, - [Service] IMediator mediator, - CancellationToken cancellationToken) - { - var result = await mediator.Send(new GetIdentityProvidersByTenantIdQuery(tenantId), cancellationToken); - - return result.UnwrapGraphQl(); - } -} diff --git a/src/apps/ums.api/Ums.Presentation/GraphQL/Identity/UserAccountQueries.cs b/src/apps/ums.api/Ums.Presentation/GraphQL/Identity/UserAccountQueries.cs deleted file mode 100644 index 2a347231..00000000 --- a/src/apps/ums.api/Ums.Presentation/GraphQL/Identity/UserAccountQueries.cs +++ /dev/null @@ -1,48 +0,0 @@ -namespace Ums.Presentation.GraphQL.Identity; - -using HotChocolate; -using HotChocolate.Types; -using Ums.Application.Common; -using Ums.Application.Identity.UserAccount.DTOs; -using Ums.Application.Identity.UserAccount.Queries; -using Ums.Presentation.Extensions; -using static Ums.Application.Common.QueryRequestNormalizer; - -[ExtendObjectType("Query")] -public sealed class UserAccountQueries -{ - public async Task> GetUserAccountsAsync( - int page, - int pageSize, - string? search, - string? criteria, - string? status, - string? sortBy, - string? sortOrder, - Guid? tenantId, - [Service] IMediator mediator, - CancellationToken cancellationToken) - { - var result = await mediator.Send(new GetAllUserAccountsQuery( - NormalizePage(page), - NormalizePageSize(pageSize), - search, - NormalizeText(criteria, "email"), - NormalizeText(status, "all"), - NormalizeText(sortBy, "email"), - NormalizeText(sortOrder, "asc"), - tenantId), cancellationToken); - - return result.UnwrapGraphQl(); - } - - public async Task GetUserAccountByIdAsync( - Guid userAccountId, - [Service] IMediator mediator, - CancellationToken cancellationToken) - { - var result = await mediator.Send(new GetUserAccountByIdQuery(userAccountId), cancellationToken); - - return result.UnwrapGraphQlOrNull(); - } -} diff --git a/src/apps/ums.api/Ums.Presentation/GraphQL/SafeGraphQlErrorFilter.cs b/src/apps/ums.api/Ums.Presentation/GraphQL/SafeGraphQlErrorFilter.cs deleted file mode 100644 index b427bc98..00000000 --- a/src/apps/ums.api/Ums.Presentation/GraphQL/SafeGraphQlErrorFilter.cs +++ /dev/null @@ -1,38 +0,0 @@ -namespace Ums.Presentation.GraphQL; - -using HotChocolate; -using HotChocolate.Execution; -using Ums.Globalization.Access; -using Serilog; - -public sealed class SafeGraphQlErrorFilter : IErrorFilter -{ - public IError OnError(IError error) - { - var errorId = Guid.NewGuid().ToString("D"); - - if (error.Exception is not null) - { - Log.Error( - error.Exception, - "GraphQL request failed. ErrorId: {ErrorId}, Code: {ErrorCode}", - errorId, - error.Code ?? "UMS_GRAPHQL_ERROR"); - } - else - { - Log.Warning( - "GraphQL request returned a safe failure. ErrorId: {ErrorId}, Code: {ErrorCode}", - errorId, - error.Code ?? "UMS_GRAPHQL_ERROR"); - } - - var responseError = ErrorBuilder.FromError(error) - .SetMessage(StringLocalizer.T("error.unexpected")) - .ClearExtensions() - .SetCode(error.Code ?? "UMS_GRAPHQL_ERROR") - .SetExtension("errorId", errorId); - - return responseError.Build(); - } -} diff --git a/src/apps/ums.api/Ums.Presentation/GraphQL/TenantContextGraphQlInterceptor.cs b/src/apps/ums.api/Ums.Presentation/GraphQL/TenantContextGraphQlInterceptor.cs deleted file mode 100644 index f65209d6..00000000 --- a/src/apps/ums.api/Ums.Presentation/GraphQL/TenantContextGraphQlInterceptor.cs +++ /dev/null @@ -1,30 +0,0 @@ -namespace Ums.Presentation.GraphQL; - -using HotChocolate.AspNetCore; -using HotChocolate.Execution; - -/// -/// Propagates the HTTP request's IServiceProvider to HotChocolate's operation execution. -/// -/// HotChocolate creates its own DI scope for resolvers, separate from the ASP.NET Core -/// HTTP request scope. This means scoped services initialized by middleware (like ITenantContext, -/// which is populated by TenantContextMiddleware) would be uninitialized when injected into -/// MediatR handlers resolved from HotChocolate's scope. -/// -/// By setting the HTTP request's RequestServices as the operation's service provider, -/// all handlers resolved via MediatR share the same scoped instances (including the -/// already-initialized ITenantContext), ensuring tenant isolation applies to GraphQL queries. -/// -public sealed class TenantContextGraphQlInterceptor : DefaultHttpRequestInterceptor -{ - public override ValueTask OnCreateAsync( - HttpContext context, - IRequestExecutor requestExecutor, - OperationRequestBuilder requestBuilder, - CancellationToken cancellationToken) - { - requestBuilder.SetServices(context.RequestServices); - - return base.OnCreateAsync(context, requestExecutor, requestBuilder, cancellationToken); - } -} diff --git a/src/apps/ums.api/Ums.Presentation/Ums.Presentation.csproj b/src/apps/ums.api/Ums.Presentation/Ums.Presentation.csproj index 56b122ff..2b286e55 100644 --- a/src/apps/ums.api/Ums.Presentation/Ums.Presentation.csproj +++ b/src/apps/ums.api/Ums.Presentation/Ums.Presentation.csproj @@ -28,8 +28,6 @@ - - diff --git a/src/apps/ums.api/docs/graphql-query-side.md b/src/apps/ums.api/docs/graphql-query-side.md deleted file mode 100644 index a0648025..00000000 --- a/src/apps/ums.api/docs/graphql-query-side.md +++ /dev/null @@ -1,137 +0,0 @@ -# GraphQL Query Side - -UMS exposes GraphQL only as a CQRS query-side API. - -## Boundary - -GraphQL is for read models only: - -- tenant list -- tenant detail -- future dashboard/composite reads - -Commands remain on REST Minimal APIs: - -- `POST /api/v1/tenants` -- `POST /api/v1/tenants/{tenantId}/activate` -- `POST /api/v1/tenants/{tenantId}/suspend` -- branch, branding, and identity-provider commands - -## Endpoint - -```text -POST /graphql -``` - -The endpoint is intentionally outside `/api/v{version}` because the GraphQL schema is versioned as a contract, not as REST routes. - -## Initial Queries - -### Tenants - -```graphql -query Tenants { - tenants(page: 1, pageSize: 20, status: "all", sortBy: "name", sortOrder: "asc") { - items { - tenantId - code - name - type - status - parentTenantId - companyReference - } - page - pageSize - totalItems - totalPages - } -} -``` - -### Tenant By ID - -```graphql -query TenantById($tenantId: UUID!) { - tenantById(tenantId: $tenantId) { - tenantId - code - name - status - } -} -``` - -### Tenant Child Objects - -```graphql -query TenantMaintenance($tenantId: UUID!) { - tenantBranches(tenantId: $tenantId) { - branchId - code - name - isActive - geofencingMetadata - } - - tenantBranding(tenantId: $tenantId) { - logo - logoFormat - primaryColor - backgroundStyle - headlineText - customDomain - magicLinkFallbackEnabled - dnsVerificationStatus - } - - tenantIdentityProviders(tenantId: $tenantId) { - identityProviderId - code - name - description - strategy - isActive - } -} -``` - -## Current Coverage - -GraphQL query coverage now includes 100% of the Domain aggregates across all Bounded Contexts: - -- **Identity**: Tenant, UserAccount, UserManagementDelegation -- **Authorization**: Profile, SystemSuite, Template -- **IGA**: PromotionRequest, RoleMaturityStatus -- **Approvals**: AccessEnforcementPolicy, ApprovalRequest, ApprovalWorkflow, DocumentType, NotificationRule, UserDocument -- **Configuration**: AppConfiguration, FeatureFlag, IdpConfiguration -- **Audit**: AuditRecord - -All these aggregates currently expose their respective Application query handlers and are fully registered in the GraphQL schema via `GraphQlServiceCollectionExtensions.cs`. - -## Safety Rules - -- No GraphQL mutations for MVP. -- No direct domain aggregate exposure. -- No direct EF entity exposure. -- Resolvers call application query handlers. -- Query depth is limited. -- Execution timeout is limited. -- ASP.NET rate limiting applies to `/graphql`. -- Future nested resolvers must use DataLoader to avoid N+1 behavior. - -## Persistence Direction - -The long-term query side should use EF Core projections directly into DTO/read models. - -Recommended future shape: - -```text -GraphQL Resolver - -> Application Query Service - -> EF Core DbContext - -> AsNoTracking projection - -> DTO / read model -``` - -Tenant isolation must still be enforced through the primary application/EF layer, with database RLS as optional failsafe where infrastructure supports it. diff --git a/src/apps/ums.web-app/src/infrastructure/authorization/queries/permission-template.graphql.test.ts b/src/apps/ums.web-app/src/infrastructure/authorization/queries/permission-template.graphql.test.ts deleted file mode 100644 index fb6dc2da..00000000 --- a/src/apps/ums.web-app/src/infrastructure/authorization/queries/permission-template.graphql.test.ts +++ /dev/null @@ -1,67 +0,0 @@ -import { describe, it, expect, vi, beforeEach } from 'vitest'; -import { graphqlPermissionTemplateQueries } from './permission-template.graphql'; -import * as graphqlClientModule from '@infra/http/graphqlClient'; - -vi.mock('@infra/http/graphqlClient', () => ({ - graphqlClient: { - request: vi.fn(), - }, -})); - -describe('graphqlPermissionTemplateQueries', () => { - beforeEach(() => { - vi.mocked(graphqlClientModule.graphqlClient.request).mockClear(); - }); - - it('getPermissionTemplates calls graphqlClient.request', async () => { - const mockResponse = { - permissionTemplates: { - items: [], - page: 1, - pageSize: 20, - totalItems: 0, - totalPages: 0, - }, - }; - - vi.mocked(graphqlClientModule.graphqlClient.request).mockResolvedValue(mockResponse); - - const result = await graphqlPermissionTemplateQueries.getPermissionTemplates({ - page: 1, - pageSize: 20, - }); - - expect(result.permissionTemplates).toEqual(mockResponse.permissionTemplates); - expect(graphqlClientModule.graphqlClient.request).toHaveBeenCalledWith( - expect.any(String), - expect.objectContaining({ page: 1, pageSize: 20 }) - ); - }); - - it('getPermissionTemplateById calls graphqlClient.request', async () => { - const mockResponse = { - permissionTemplateById: { - templateId: '3fa85f64-5717-4562-b3fc-2c963f66afa6', - tenantId: '3fa85f64-5717-4562-b3fc-2c963f66afa7', - roleId: '3fa85f64-5717-4562-b3fc-2c963f66afa8', - roleName: 'Admin', - systemSuiteId: '3fa85f64-5717-4562-b3fc-2c963f66afa9', - systemSuiteName: 'Suite 1', - version: '1.0', - status: 'Draft', - items: [], - }, - }; - - vi.mocked(graphqlClientModule.graphqlClient.request).mockResolvedValue(mockResponse); - - const result = await graphqlPermissionTemplateQueries.getPermissionTemplateById( - '3fa85f64-5717-4562-b3fc-2c963f66afa6' - ); - - expect(result.permissionTemplateById?.templateId).toBe('3fa85f64-5717-4562-b3fc-2c963f66afa6'); - expect(graphqlClientModule.graphqlClient.request).toHaveBeenCalledWith(expect.any(String), { - templateId: '3fa85f64-5717-4562-b3fc-2c963f66afa6', - }); - }); -}); diff --git a/src/apps/ums.web-app/src/infrastructure/authorization/queries/permission-template.graphql.ts b/src/apps/ums.web-app/src/infrastructure/authorization/queries/permission-template.graphql.ts deleted file mode 100644 index 5ec197fb..00000000 --- a/src/apps/ums.web-app/src/infrastructure/authorization/queries/permission-template.graphql.ts +++ /dev/null @@ -1,135 +0,0 @@ -/** - * permission-template.graphql.ts — GraphQL queries for PermissionTemplate (read path). - * Commands use REST via permission-template.service.ts. - */ -import { graphqlClient } from '@infra/http/graphqlClient'; - -const GET_PERMISSION_TEMPLATES = ` - query PermissionTemplates( - $page: Int! - $pageSize: Int! - $search: String - $criteria: String - $status: String - $sortBy: String - $sortOrder: String - $tenantId: UUID - $systemSuiteId: UUID - $roleId: UUID - ) { - permissionTemplates( - page: $page - pageSize: $pageSize - search: $search - criteria: $criteria - status: $status - sortBy: $sortBy - sortOrder: $sortOrder - tenantId: $tenantId - systemSuiteId: $systemSuiteId - roleId: $roleId - ) { - items { - templateId - tenantId - roleId - roleName - systemSuiteId - systemSuiteName - version - status - } - page - pageSize - totalItems - totalPages - } - } -`; - -export interface GraphqlPermissionTemplateDto { - templateId: string; - tenantId: string; - roleId: string; - roleName: string; - systemSuiteId: string; - systemSuiteName: string; - version: string; - status: string; -} - -export interface GraphqlPermissionTemplatePage { - items: GraphqlPermissionTemplateDto[]; - page: number; - pageSize: number; - totalItems: number; - totalPages: number; -} - -export interface GetPermissionTemplatesResponse { - permissionTemplates: GraphqlPermissionTemplatePage; -} - -export interface GetPermissionTemplateByIdResponse { - permissionTemplateById: GraphqlPermissionTemplateDto | null; -} - -const GET_PERMISSION_TEMPLATE_BY_ID = ` - query PermissionTemplateById($templateId: UUID!) { - permissionTemplateById(templateId: $templateId) { - templateId - tenantId - roleId - roleName - systemSuiteId - systemSuiteName - version - status - items { - itemId - targetType - targetId - targetName - actionId - actionName - isAllowed - isDenied - isActive - } - } - } -`; - -export const graphqlPermissionTemplateQueries = { - getPermissionTemplates: async (params: { - page: number; - pageSize: number; - search?: string; - criteria?: string; - status?: string; - sortBy?: string; - sortOrder?: string; - tenantId?: string; - systemSuiteId?: string; - roleId?: string; - }) => { - return graphqlClient.request(GET_PERMISSION_TEMPLATES, { - page: params.page, - pageSize: params.pageSize, - search: params.search ?? null, - criteria: params.criteria ?? 'version', - status: params.status ?? 'all', - sortBy: params.sortBy ?? 'version', - sortOrder: params.sortOrder ?? 'asc', - tenantId: params.tenantId ?? null, - systemSuiteId: params.systemSuiteId ?? null, - roleId: params.roleId ?? null, - }); - }, - - getPermissionTemplateById: async (templateId: string) => { - return graphqlClient.request(GET_PERMISSION_TEMPLATE_BY_ID, { - templateId, - }); - }, -}; diff --git a/src/apps/ums.web-app/src/infrastructure/authorization/queries/profile.graphql.test.ts b/src/apps/ums.web-app/src/infrastructure/authorization/queries/profile.graphql.test.ts deleted file mode 100644 index 5a38ac4b..00000000 --- a/src/apps/ums.web-app/src/infrastructure/authorization/queries/profile.graphql.test.ts +++ /dev/null @@ -1,93 +0,0 @@ -import { describe, it, expect, vi, beforeEach } from 'vitest'; -import { graphqlProfileQueries } from './profile.graphql'; -import * as graphqlClientModule from '@infra/http/graphqlClient'; - -vi.mock('@infra/http/graphqlClient', () => ({ - graphqlClient: { - request: vi.fn(), - }, -})); - -describe('graphqlProfileQueries', () => { - beforeEach(() => { - vi.mocked(graphqlClientModule.graphqlClient.request).mockClear(); - }); - - describe('getProfiles', () => { - it('calls graphqlClient with default values', async () => { - vi.mocked(graphqlClientModule.graphqlClient.request).mockResolvedValue({ - getProfiles: { items: [], page: 1, pageSize: 10, totalItems: 0, totalPages: 0 }, - }); - - await graphqlProfileQueries.getProfiles({ page: 1, pageSize: 10 }); - - expect(graphqlClientModule.graphqlClient.request).toHaveBeenCalledWith( - expect.stringContaining('query GetProfiles'), - expect.objectContaining({ - page: 1, - pageSize: 10, - criteria: 'userId', - status: 'all', - sortBy: 'userId', - sortOrder: 'asc', - }) - ); - }); - - it('includes optional search parameter', async () => { - vi.mocked(graphqlClientModule.graphqlClient.request).mockResolvedValue({ - getProfiles: { items: [], page: 1, pageSize: 10, totalItems: 0, totalPages: 0 }, - }); - - await graphqlProfileQueries.getProfiles({ page: 1, pageSize: 10, search: 'test' }); - - const callArgs = vi.mocked(graphqlClientModule.graphqlClient.request).mock.calls[0]; - expect(callArgs[1]).toHaveProperty('search', 'test'); - }); - - it('includes optional tenantId parameter', async () => { - vi.mocked(graphqlClientModule.graphqlClient.request).mockResolvedValue({ - getProfiles: { items: [], page: 1, pageSize: 10, totalItems: 0, totalPages: 0 }, - }); - - await graphqlProfileQueries.getProfiles({ - page: 1, - pageSize: 10, - tenantId: '3fa85f64-5717-4562-b3fc-2c963f66afa6', - }); - - const callArgs = vi.mocked(graphqlClientModule.graphqlClient.request).mock.calls[0]; - expect(callArgs[1]).toHaveProperty('tenantId', '3fa85f64-5717-4562-b3fc-2c963f66afa6'); - }); - - it('includes optional userId parameter', async () => { - vi.mocked(graphqlClientModule.graphqlClient.request).mockResolvedValue({ - getProfiles: { items: [], page: 1, pageSize: 10, totalItems: 0, totalPages: 0 }, - }); - - await graphqlProfileQueries.getProfiles({ - page: 1, - pageSize: 10, - userId: '3fa85f64-5717-4562-b3fc-2c963f66afa7', - }); - - const callArgs = vi.mocked(graphqlClientModule.graphqlClient.request).mock.calls[0]; - expect(callArgs[1]).toHaveProperty('userId', '3fa85f64-5717-4562-b3fc-2c963f66afa7'); - }); - }); - - describe('getProfileById', () => { - it('calls graphqlClient with profileId', async () => { - vi.mocked(graphqlClientModule.graphqlClient.request).mockResolvedValue({ - getProfileById: null, - }); - - await graphqlProfileQueries.getProfileById('3fa85f64-5717-4562-b3fc-2c963f66afa6'); - - expect(graphqlClientModule.graphqlClient.request).toHaveBeenCalledWith( - expect.stringContaining('query GetProfileById'), - expect.objectContaining({ profileId: '3fa85f64-5717-4562-b3fc-2c963f66afa6' }) - ); - }); - }); -}); diff --git a/src/apps/ums.web-app/src/infrastructure/authorization/queries/profile.graphql.ts b/src/apps/ums.web-app/src/infrastructure/authorization/queries/profile.graphql.ts deleted file mode 100644 index f253be4c..00000000 --- a/src/apps/ums.web-app/src/infrastructure/authorization/queries/profile.graphql.ts +++ /dev/null @@ -1,132 +0,0 @@ -import { graphqlClient } from '@infra/http/graphqlClient'; - -const GET_PROFILES = ` - query GetProfiles( - $page: Int! - $pageSize: Int! - $search: String - $criteria: String - $status: String - $sortBy: String - $sortOrder: String - $tenantId: UUID - $userId: UUID - ) { - profiles( - page: $page - pageSize: $pageSize - search: $search - criteria: $criteria - status: $status - sortBy: $sortBy - sortOrder: $sortOrder - tenantId: $tenantId - userId: $userId - ) { - items { - profileId - tenantId - tenantCode - tenantName - userId - userEmail - roleId - roleCode - roleName - systemSuiteId - systemSuiteCode - systemSuiteName - branchId - branchName - scope - isActive - permissionCount - } - page - pageSize - totalItems - totalPages - } - } -`; - -const GET_PROFILE_BY_ID = ` - query GetProfileById($profileId: UUID!) { - profileById(profileId: $profileId) { - profileId - tenantId - tenantCode - tenantName - userId - userEmail - roleId - roleCode - roleName - systemSuiteId - systemSuiteCode - systemSuiteName - branchId - branchName - scope - isActive - permissionCount - permissions { - permissionId - profileId - templateId - targetType - targetId - targetName - actionId - actionName - isAllowed - isDenied - isActive - isOverride - originalFromTemplate { - itemId - targetType - targetId - targetName - actionId - actionName - isAllowed - isDenied - isActive - } - } - } - } -`; - -export const graphqlProfileQueries = { - getProfiles: async (params: { - page: number; - pageSize: number; - search?: string; - criteria?: string; - status?: string; - sortBy?: string; - sortOrder?: string; - tenantId?: string; - userId?: string; - }) => { - return graphqlClient.request<{ profiles: unknown }>(GET_PROFILES, { - page: params.page, - pageSize: params.pageSize, - search: params.search ?? null, - criteria: params.criteria ?? 'userId', - status: params.status ?? 'all', - sortBy: params.sortBy ?? 'userId', - sortOrder: params.sortOrder ?? 'asc', - tenantId: params.tenantId ?? null, - userId: params.userId ?? null, - }); - }, - - getProfileById: async (profileId: string) => { - return graphqlClient.request<{ profileById: unknown }>(GET_PROFILE_BY_ID, { - profileId, - }); - }, -}; diff --git a/src/apps/ums.web-app/src/infrastructure/authorization/queries/role.graphql.test.ts b/src/apps/ums.web-app/src/infrastructure/authorization/queries/role.graphql.test.ts deleted file mode 100644 index 9f8d475a..00000000 --- a/src/apps/ums.web-app/src/infrastructure/authorization/queries/role.graphql.test.ts +++ /dev/null @@ -1,79 +0,0 @@ -import { describe, it, expect, vi, beforeEach } from 'vitest'; -import { graphqlRoleQueries } from './role.graphql'; -import { GraphQlValidationError } from '@infra/http/graphqlClient'; -import * as graphqlClientModule from '@infra/http/graphqlClient'; - -vi.mock('@infra/http/graphqlClient', () => ({ - graphqlClient: { - request: vi.fn(), - }, - GraphQlValidationError: class GraphQlValidationError extends Error { - constructor( - message: string, - public details: string[] - ) { - super(message); - this.name = 'GraphQlValidationError'; - } - }, -})); - -describe('graphqlRoleQueries', () => { - beforeEach(() => { - vi.mocked(graphqlClientModule.graphqlClient.request).mockClear(); - }); - - describe('getRolesBySystemSuite', () => { - it('calls graphqlClient with systemSuiteId', async () => { - vi.mocked(graphqlClientModule.graphqlClient.request).mockResolvedValue({ - rolesBySystemSuite: [], - }); - - await graphqlRoleQueries.getRolesBySystemSuite('3fa85f64-5717-4562-b3fc-2c963f66afa6'); - - expect(graphqlClientModule.graphqlClient.request).toHaveBeenCalledWith( - expect.stringContaining('query RolesBySystemSuite'), - expect.objectContaining({ systemSuiteId: '3fa85f64-5717-4562-b3fc-2c963f66afa6' }) - ); - }); - - it('throws on empty systemSuiteId', async () => { - await expect(graphqlRoleQueries.getRolesBySystemSuite('')).rejects.toThrow( - GraphQlValidationError - ); - }); - - it('throws on whitespace systemSuiteId', async () => { - await expect(graphqlRoleQueries.getRolesBySystemSuite(' ')).rejects.toThrow( - GraphQlValidationError - ); - }); - - it('returns roles when successful', async () => { - const mockRoles = [ - { - roleId: '3fa85f64-5717-4562-b3fc-2c963f66afa6', - tenantId: '3fa85f64-5717-4562-b3fc-2c963f66afa7', - systemSuiteId: '3fa85f64-5717-4562-b3fc-2c963f66afa8', - parentRoleId: null, - code: 'ADMIN', - value: 'Administrator', - description: 'Admin role', - hierarchyLevel: 0, - promotionOrder: 1, - isActive: true, - }, - ]; - vi.mocked(graphqlClientModule.graphqlClient.request).mockResolvedValue({ - rolesBySystemSuite: mockRoles, - }); - - const result = await graphqlRoleQueries.getRolesBySystemSuite( - '3fa85f64-5717-4562-b3fc-2c963f66afa6' - ); - - expect(result.rolesBySystemSuite).toHaveLength(1); - expect(result.rolesBySystemSuite[0].code).toBe('ADMIN'); - }); - }); -}); diff --git a/src/apps/ums.web-app/src/infrastructure/authorization/queries/role.graphql.ts b/src/apps/ums.web-app/src/infrastructure/authorization/queries/role.graphql.ts deleted file mode 100644 index 60b43c4f..00000000 --- a/src/apps/ums.web-app/src/infrastructure/authorization/queries/role.graphql.ts +++ /dev/null @@ -1,37 +0,0 @@ -import { graphqlClient, GraphQlValidationError } from '@infra/http/graphqlClient'; -import type { Role } from '@domain/authorization/schemas/role.schema'; - -const GET_ROLES_BY_SYSTEM_SUITE = ` - query RolesBySystemSuite($systemSuiteId: UUID!) { - rolesBySystemSuite(systemSuiteId: $systemSuiteId) { - roleId - tenantId - systemSuiteId - parentRoleId - code - value - description - hierarchyLevel - promotionOrder - isActive - } - } -`; - -interface RolesBySystemSuiteResponse { - rolesBySystemSuite: Role[]; -} - -export const graphqlRoleQueries = { - getRolesBySystemSuite: async (systemSuiteId: string): Promise => { - if (!systemSuiteId.trim()) { - throw new GraphQlValidationError('Invalid systemSuiteId parameter', [ - 'systemSuiteId must be provided', - ]); - } - - return graphqlClient.request(GET_ROLES_BY_SYSTEM_SUITE, { - systemSuiteId, - }); - }, -}; diff --git a/src/apps/ums.web-app/src/infrastructure/configuration/queries/feature-flag.graphql.test.ts b/src/apps/ums.web-app/src/infrastructure/configuration/queries/feature-flag.graphql.test.ts deleted file mode 100644 index 2058abf2..00000000 --- a/src/apps/ums.web-app/src/infrastructure/configuration/queries/feature-flag.graphql.test.ts +++ /dev/null @@ -1,110 +0,0 @@ -import { describe, it, expect, vi, beforeEach } from 'vitest'; -import { graphqlFeatureFlagQueries } from './feature-flag.graphql'; -import * as graphqlClientModule from '@infra/http/graphqlClient'; - -vi.mock('@infra/http/graphqlClient', () => ({ - graphqlClient: { - request: vi.fn(), - }, - GraphQlValidationError: class GraphQlValidationError extends Error { - constructor( - message: string, - public errors: string[] - ) { - super(message); - this.name = 'GraphQlValidationError'; - } - }, -})); - -describe('graphqlFeatureFlagQueries', () => { - beforeEach(() => { - vi.mocked(graphqlClientModule.graphqlClient.request).mockClear(); - }); - - it('getFeatureFlags calls graphqlClient.request', async () => { - const mockResponse = { - featureFlags: { - items: [], - page: 1, - pageSize: 20, - totalItems: 0, - totalPages: 0, - }, - }; - - vi.mocked(graphqlClientModule.graphqlClient.request).mockResolvedValue(mockResponse); - - const result = await graphqlFeatureFlagQueries.getFeatureFlags({ page: 1, pageSize: 20 }); - - expect(result.featureFlags.items).toHaveLength(0); - expect(graphqlClientModule.graphqlClient.request).toHaveBeenCalledWith( - expect.any(String), - expect.objectContaining({ page: 1, pageSize: 20 }) - ); - }); - - it('getFeatureFlags passes optional params', async () => { - vi.mocked(graphqlClientModule.graphqlClient.request).mockResolvedValue({ - featureFlags: { items: [], page: 1, pageSize: 20, totalItems: 0, totalPages: 0 }, - }); - - await graphqlFeatureFlagQueries.getFeatureFlags({ - page: 1, - pageSize: 20, - search: 'test', - status: 'Active', - }); - - expect(graphqlClientModule.graphqlClient.request).toHaveBeenCalledWith( - expect.any(String), - expect.objectContaining({ search: 'test', status: 'Active' }) - ); - }); - - it('getFeatureFlags throws on invalid page', async () => { - await expect( - graphqlFeatureFlagQueries.getFeatureFlags({ page: 0, pageSize: 20 }) - ).rejects.toThrow('Invalid page parameter'); - }); - - it('getFeatureFlags throws on invalid pageSize', async () => { - await expect( - graphqlFeatureFlagQueries.getFeatureFlags({ page: 1, pageSize: 0 }) - ).rejects.toThrow('Invalid pageSize parameter'); - }); - - it('getFeatureFlagById calls graphqlClient.request', async () => { - const mockResponse = { - featureFlagById: { - featureFlagId: '3fa85f64-5717-4562-b3fc-2c963f66afa6', - systemSuiteId: '3fa85f64-5717-4562-b3fc-2c963f66afa7', - systemSuiteCode: 'CORE', - systemSuiteName: 'Core Suite', - flagCode: 'FLAG_1', - flagType: 'Boolean', - flagTargets: 'all', - status: 'Active', - rolloutPercentage: null, - criteria: [], - }, - }; - - vi.mocked(graphqlClientModule.graphqlClient.request).mockResolvedValue(mockResponse); - - const result = await graphqlFeatureFlagQueries.getFeatureFlagById( - '3fa85f64-5717-4562-b3fc-2c963f66afa6' - ); - - expect(result.featureFlagById?.flagCode).toBe('FLAG_1'); - expect(graphqlClientModule.graphqlClient.request).toHaveBeenCalledWith(expect.any(String), { - id: '3fa85f64-5717-4562-b3fc-2c963f66afa6', - }); - }); - - it('getFeatureFlagById throws on empty id', async () => { - await expect(graphqlFeatureFlagQueries.getFeatureFlagById('')).rejects.toThrow( - 'Invalid id parameter' - ); - }); -}); diff --git a/src/apps/ums.web-app/src/infrastructure/configuration/queries/feature-flag.graphql.ts b/src/apps/ums.web-app/src/infrastructure/configuration/queries/feature-flag.graphql.ts deleted file mode 100644 index ae560ef0..00000000 --- a/src/apps/ums.web-app/src/infrastructure/configuration/queries/feature-flag.graphql.ts +++ /dev/null @@ -1,159 +0,0 @@ -/** - * feature-flag.graphql.ts — GraphQL query definitions for FeatureFlag - * - * Read operations (paginated list, single flag) use GraphQL. - * The scoped list by SystemSuite uses REST (no dedicated GraphQL resolver). - */ -import { graphqlClient, GraphQlValidationError } from '@infra/http/graphqlClient'; - -const GET_FEATURE_FLAGS = ` - query FeatureFlags( - $page: Int! - $pageSize: Int! - $search: String - $criteria: String - $status: String - $sortBy: String - $sortOrder: String - $flagType: String - ) { - featureFlags( - page: $page - pageSize: $pageSize - search: $search - criteria: $criteria - status: $status - sortBy: $sortBy - sortOrder: $sortOrder - flagType: $flagType - ) { - items { - featureFlagId - systemSuiteId - systemSuiteCode - systemSuiteName - flagCode - flagType - flagTargets - status - rolloutPercentage - criteria { - criteriaId - criteriaType - operator - value - createdAtUtc - } - } - page - pageSize - totalItems - totalPages - } - } -`; - -const GET_FEATURE_FLAG_BY_ID = ` - query FeatureFlagById($id: UUID!) { - featureFlagById(id: $id) { - featureFlagId - systemSuiteId - systemSuiteCode - systemSuiteName - flagCode - flagType - flagTargets - status - rolloutPercentage - criteria { - criteriaId - criteriaType - operator - value - createdAtUtc - } - } - } -`; - -// ── DTOs for GraphQL responses ───────────────────────────────────────────────── - -export interface GraphqlFeatureFlagCriteriaDto { - criteriaId: string; - criteriaType: string; - operator: string; - value: string; - createdAtUtc: string; -} - -export interface GraphqlFeatureFlagDto { - featureFlagId: string; - systemSuiteId: string; - systemSuiteCode: string; - systemSuiteName: string; - flagCode: string; - flagType: string; - flagTargets: string; - status: string; - rolloutPercentage: number | null; - criteria: GraphqlFeatureFlagCriteriaDto[]; -} - -export interface GraphqlFeatureFlagPage { - items: GraphqlFeatureFlagDto[]; - page: number; - pageSize: number; - totalItems: number; - totalPages: number; -} - -export interface GetFeatureFlagsResponse { - featureFlags: GraphqlFeatureFlagPage; -} - -export interface GetFeatureFlagByIdResponse { - featureFlagById: GraphqlFeatureFlagDto | null; -} - -function validatePageParams(params: { page: number; pageSize: number }): void { - if (!Number.isInteger(params.page) || params.page < 1) - throw new GraphQlValidationError('Invalid page parameter', [ - `page must be a positive integer, got: ${params.page}`, - ]); - if (!Number.isInteger(params.pageSize) || params.pageSize < 1) - throw new GraphQlValidationError('Invalid pageSize parameter', [ - `pageSize must be a positive integer, got: ${params.pageSize}`, - ]); -} - -export const graphqlFeatureFlagQueries = { - getFeatureFlags: async (params: { - page: number; - pageSize: number; - search?: string; - criteria?: string; - status?: string; - sortBy?: string; - sortOrder?: string; - flagType?: string; - }): Promise => { - validatePageParams(params); - const variables: Record = { - page: params.page, - pageSize: params.pageSize, - }; - if (params.search !== undefined) variables.search = params.search; - if (params.criteria !== undefined) variables.criteria = params.criteria; - if (params.status !== undefined) variables.status = params.status; - if (params.sortBy !== undefined) variables.sortBy = params.sortBy; - if (params.sortOrder !== undefined) variables.sortOrder = params.sortOrder; - if (params.flagType !== undefined) variables.flagType = params.flagType; - return graphqlClient.request(GET_FEATURE_FLAGS, variables); - }, - - getFeatureFlagById: async (id: string): Promise => { - if (!id || id.trim() === '') - throw new GraphQlValidationError('Invalid id parameter', ['id must be a non-empty string']); - return graphqlClient.request(GET_FEATURE_FLAG_BY_ID, { id }); - }, -}; diff --git a/src/apps/ums.web-app/src/infrastructure/http/graphqlClient.test.ts b/src/apps/ums.web-app/src/infrastructure/http/graphqlClient.test.ts deleted file mode 100644 index 371b3757..00000000 --- a/src/apps/ums.web-app/src/infrastructure/http/graphqlClient.test.ts +++ /dev/null @@ -1,275 +0,0 @@ -import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; -import { - GraphQlError, - GraphQlUnavailableError, - GraphQlValidationError, - graphqlClient, -} from './graphqlClient'; - -describe('GraphQlError', () => { - it('creates an error with status', () => { - const error = new GraphQlError('Test error', 500); - expect(error.message).toBe('Test error'); - expect(error.status).toBe(500); - expect(error.name).toBe('GraphQlError'); - }); - - it('creates an error with response errors', () => { - const responseErrors = [{ message: 'Field required' }]; - const error = new GraphQlError('Validation failed', 400, responseErrors); - expect(error.responseErrors).toEqual(responseErrors); - }); - - it('creates an error with operation name', () => { - const error = new GraphQlError('Test error', 500, undefined, 'GetTenants'); - expect(error.operationName).toBe('GetTenants'); - }); - - it('creates an error with support reference ID', () => { - const error = new GraphQlError('Test error', 500, undefined, undefined, 'ERR-123'); - expect(error.supportReferenceId).toBe('ERR-123'); - }); -}); - -describe('GraphQlUnavailableError', () => { - it('creates error with network message for status 0', () => { - const error = new GraphQlUnavailableError(0); - expect(error.message).toContain('unable to reach the API'); - expect(error.name).toBe('GraphQlUnavailableError'); - }); - - it('creates error with HTTP status message', () => { - const error = new GraphQlUnavailableError(503); - expect(error.message).toContain('503'); - }); - - it('includes support reference ID', () => { - const error = new GraphQlUnavailableError(502, 'ERR-456'); - expect(error.supportReferenceId).toBe('ERR-456'); - }); -}); - -describe('GraphQlValidationError', () => { - it('creates error with details', () => { - const error = new GraphQlValidationError('Validation failed', [ - 'Field A is required', - 'Field B is invalid', - ]); - expect(error.message).toBe('Validation failed'); - expect(error.details).toEqual(['Field A is required', 'Field B is invalid']); - expect(error.name).toBe('GraphQlValidationError'); - }); - - it('includes support reference ID', () => { - const error = new GraphQlValidationError('Validation failed', ['Error'], 'ERR-789'); - expect(error.supportReferenceId).toBe('ERR-789'); - }); -}); - -describe('graphqlClient', () => { - beforeEach(() => { - vi.stubGlobal('fetch', vi.fn()); - }); - - afterEach(() => { - vi.unstubAllGlobals(); - vi.restoreAllMocks(); - }); - - it('exports a request method', () => { - expect(typeof graphqlClient.request).toBe('function'); - }); - - it('sends POST request with query', async () => { - const mockResponse = { - ok: true, - json: vi.fn().mockResolvedValue({ data: { tenants: [] } }), - headers: { get: vi.fn().mockReturnValue(null) }, - }; - vi.mocked(globalThis.fetch).mockResolvedValue(mockResponse as unknown as Response); - - await graphqlClient.request('{ tenants { tenantId } }'); - - expect(globalThis.fetch).toHaveBeenCalledWith( - expect.any(String), - expect.objectContaining({ - method: 'POST', - headers: expect.objectContaining({ - 'Content-Type': 'application/json', - 'X-Tenant-Id': expect.any(String), - }), - body: expect.stringContaining('tenants'), - }) - ); - }); - - it('includes variables in request body', async () => { - const mockResponse = { - ok: true, - json: vi.fn().mockResolvedValue({ data: { tenant: { tenantId: '123' } } }), - headers: { get: vi.fn().mockReturnValue(null) }, - }; - vi.mocked(globalThis.fetch).mockResolvedValue(mockResponse as unknown as Response); - - await graphqlClient.request('query GetTenant($id: ID!) { tenant(id: $id) { tenantId } }', { - id: '123', - }); - - const callArgs = vi.mocked(globalThis.fetch).mock.calls[0]; - expect(callArgs).toBeDefined(); - const body = JSON.parse(callArgs![1]!.body as string); - expect(body.variables).toEqual({ id: '123' }); - }); - - it('extracts operation name from query', async () => { - const mockResponse = { - ok: true, - json: vi.fn().mockResolvedValue({ data: { tenants: [] } }), - headers: { get: vi.fn().mockReturnValue(null) }, - }; - vi.mocked(globalThis.fetch).mockResolvedValue(mockResponse as unknown as Response); - - await graphqlClient.request('query GetTenants { tenants { tenantId } }'); - - const callArgs = vi.mocked(globalThis.fetch).mock.calls[0]; - expect(callArgs).toBeDefined(); - const body = JSON.parse(callArgs![1]!.body as string); - expect(body.operationName).toBe('GetTenants'); - }); - - it('extracts operation name from mutation', async () => { - const mockResponse = { - ok: true, - json: vi.fn().mockResolvedValue({ data: { createTenant: { tenantId: '123' } } }), - headers: { get: vi.fn().mockReturnValue(null) }, - }; - vi.mocked(globalThis.fetch).mockResolvedValue(mockResponse as unknown as Response); - - await graphqlClient.request( - 'mutation CreateTenant($code: String!) { createTenant(code: $code) { tenantId } }', - { code: 'TEST' } - ); - - const callArgs = vi.mocked(globalThis.fetch).mock.calls[0]; - expect(callArgs).toBeDefined(); - const body = JSON.parse(callArgs![1]!.body as string); - expect(body.operationName).toBe('CreateTenant'); - }); - - it('throws GraphQlUnavailableError on 502', async () => { - const mockResponse = { - ok: false, - status: 502, - headers: { get: vi.fn().mockReturnValue(null) }, - }; - vi.mocked(globalThis.fetch).mockResolvedValue(mockResponse as unknown as Response); - - await expect(graphqlClient.request('{ tenants { tenantId } }')).rejects.toThrow( - GraphQlUnavailableError - ); - }); - - it('throws GraphQlUnavailableError on 503', async () => { - const mockResponse = { - ok: false, - status: 503, - headers: { get: vi.fn().mockReturnValue(null) }, - }; - vi.mocked(globalThis.fetch).mockResolvedValue(mockResponse as unknown as Response); - - await expect(graphqlClient.request('{ tenants { tenantId } }')).rejects.toThrow( - GraphQlUnavailableError - ); - }); - - it('throws GraphQlValidationError on 400 with errors', async () => { - const mockResponse = { - ok: false, - status: 400, - json: vi.fn().mockResolvedValue({ - errors: [{ message: 'Field required' }], - data: null, - }), - headers: { get: vi.fn().mockReturnValue(null) }, - }; - vi.mocked(globalThis.fetch).mockResolvedValue(mockResponse as unknown as Response); - - await expect(graphqlClient.request('{ tenants { tenantId } }')).rejects.toThrow( - GraphQlValidationError - ); - }); - - it('throws GraphQlError on non-400 error status', async () => { - const mockResponse = { - ok: false, - status: 500, - json: vi.fn().mockResolvedValue({ - errors: [{ message: 'Internal error' }], - data: null, - }), - headers: { get: vi.fn().mockReturnValue(null) }, - }; - vi.mocked(globalThis.fetch).mockResolvedValue(mockResponse as unknown as Response); - - await expect(graphqlClient.request('{ tenants { tenantId } }')).rejects.toThrow(GraphQlError); - }); - - it('throws GraphQlError when response has no data', async () => { - const mockResponse = { - ok: true, - json: vi.fn().mockResolvedValue({ data: null }), - headers: { get: vi.fn().mockReturnValue(null) }, - }; - vi.mocked(globalThis.fetch).mockResolvedValue(mockResponse as unknown as Response); - - await expect(graphqlClient.request('{ tenants { tenantId } }')).rejects.toThrow(GraphQlError); - }); - - it('throws GraphQlValidationError when response has errors in data', async () => { - const mockResponse = { - ok: true, - json: vi.fn().mockResolvedValue({ - data: null, - errors: [{ message: 'Field not found' }], - }), - headers: { get: vi.fn().mockReturnValue(null) }, - }; - vi.mocked(globalThis.fetch).mockResolvedValue(mockResponse as unknown as Response); - - await expect(graphqlClient.request('{ tenants { tenantId } }')).rejects.toThrow( - GraphQlValidationError - ); - }); - - it('returns data on successful response', async () => { - const expectedData = { tenants: [{ tenantId: 'abc-123' }] }; - const mockResponse = { - ok: true, - json: vi.fn().mockResolvedValue({ data: expectedData }), - headers: { get: vi.fn().mockReturnValue(null) }, - }; - vi.mocked(globalThis.fetch).mockResolvedValue(mockResponse as unknown as Response); - - const result = await graphqlClient.request('{ tenants { tenantId } }'); - - expect(result).toEqual(expectedData); - }); - - it('includes X-Error-Id header in error', async () => { - const mockResponse = { - ok: false, - status: 500, - json: vi.fn().mockResolvedValue({ errors: [{ message: 'Error' }], data: null }), - headers: { - get: vi.fn().mockImplementation(name => (name === 'X-Error-Id' ? 'ERR-001' : null)), - }, - }; - vi.mocked(globalThis.fetch).mockResolvedValue(mockResponse as unknown as Response); - - try { - await graphqlClient.request('{ tenants { tenantId } }'); - } catch (error) { - expect((error as GraphQlError).supportReferenceId).toBe('ERR-001'); - } - }); -}); diff --git a/src/apps/ums.web-app/src/infrastructure/http/graphqlClient.ts b/src/apps/ums.web-app/src/infrastructure/http/graphqlClient.ts deleted file mode 100644 index fcd64b7f..00000000 --- a/src/apps/ums.web-app/src/infrastructure/http/graphqlClient.ts +++ /dev/null @@ -1,222 +0,0 @@ -/** - * graphqlClient.ts — Infrastructure GraphQL client - * - * Single configured GraphQL client for all read operations. - * Commands/transactions continue to use the REST httpClient. - * - * Uses native fetch with explicit body construction for full control - * over request format, ensuring compatibility with HotChocolate. - * - * In development mode, logs the outgoing payload for debugging. - */ -import { getRequestContext, DEFAULT_TENANT_ID } from './request-context'; -import { logger } from '@app/utils/logger'; -import { useAuthStore } from '@app/stores/auth.store'; - -function getGraphqlUrl(): string { - if (typeof window !== 'undefined') { - return `${window.location.origin}/graphql`; - } - return 'http://localhost:5173/graphql'; -} - -export interface GraphQlErrorResponse { - errors: Array<{ - message: string; - locations?: Array<{ line: number; column: number }>; - path?: string[]; - extensions?: { code?: string; errorId?: string; traceId?: string }; - }>; - data?: null; -} - -export class GraphQlError extends Error { - constructor( - message: string, - public readonly status: number, - public readonly responseErrors?: GraphQlErrorResponse['errors'], - public readonly operationName?: string, - public readonly supportReferenceId?: string - ) { - super(message); - this.name = 'GraphQlError'; - } -} - -export class GraphQlUnavailableError extends Error { - constructor( - public readonly status: number, - public readonly supportReferenceId?: string - ) { - super( - status === 0 ? 'Network error: unable to reach the API' : `API unavailable (HTTP ${status})` - ); - this.name = 'GraphQlUnavailableError'; - } -} - -export class GraphQlValidationError extends Error { - constructor( - message: string, - public readonly details: string[], - public readonly supportReferenceId?: string - ) { - super(message); - this.name = 'GraphQlValidationError'; - } -} - -function extractOperationName(query: string): string | undefined { - const match = query.match(/(?:query|mutation)\s+(\w+)/); - return match?.[1]; -} - -/** Cabeceras comunes a toda petición GraphQL. Solo se envía lo que existe. */ -function buildHeaders(ctx: { - tenantId?: string; - userId?: string; - language?: string; -}): Record { - const user = useAuthStore.getState().user; - const headers: Record = { - 'Content-Type': 'application/json', - 'X-Tenant-Id': ctx.tenantId || DEFAULT_TENANT_ID, - }; - if (ctx.userId) headers['X-User-Id'] = ctx.userId; - if (ctx.language) headers['X-Language'] = ctx.language; - // ADR-0076 D2: propagate browser-detected timezone for server-side date operations - if (user?.sessionParameters?.defaultTimezone) { - headers['X-Timezone'] = user.sessionParameters.defaultTimezone; - } - if (user?.token) headers['Authorization'] = `Bearer ${user.token}`; - if (user?.isInternalAdmin) headers['X-Is-Internal-Admin'] = 'true'; - return headers; -} - -/** - * Traduce una respuesta no-ok a la excepción que le corresponde. Devuelve `never`: siempre lanza. - * 502/503/0 se distinguen del resto porque significan «el backend no está», no «la petición está - * mal», y quien los captura arriba enseña otro mensaje. - */ -async function throwForResponse( - response: Response, - supportReferenceId: string | undefined, - ctx: { url: string; operationName?: string; requestBody: unknown; isDev: boolean } -): Promise { - if (response.status === 502 || response.status === 503 || response.status === 0) { - throw new GraphQlUnavailableError(response.status, supportReferenceId); - } - - let errorBody: GraphQlErrorResponse | null = null; - try { - errorBody = await response.json(); - } catch { - // Una respuesta de error que no es JSON no aporta desglose; se sigue con el genérico. - } - - if (ctx.isDev) { - logger.warn('GraphQL error response', { - url: ctx.url, - status: response.status, - operationName: ctx.operationName, - requestBody: ctx.requestBody, - responseBody: errorBody, - }); - } - - if (response.status === 400 && errorBody?.errors) { - const messages = errorBody.errors.map(e => e.message); - throw new GraphQlValidationError( - `GraphQL validation failed: ${messages.join('; ')}`, - messages, - supportReferenceId - ); - } - - throw new GraphQlError( - `GraphQL request failed with HTTP ${response.status}`, - response.status, - errorBody?.errors, - ctx.operationName, - supportReferenceId - ); -} - -async function executeGraphQl(query: string, variables?: Record): Promise { - const url = getGraphqlUrl(); - const { userId, language, tenantId } = getRequestContext(); - - const body: Record = { query }; - if (variables && Object.keys(variables).length > 0) { - body.variables = variables; - } - const operationName = extractOperationName(query); - if (operationName) { - body.operationName = operationName; - } - - const headers = buildHeaders({ tenantId, userId, language }); - - const isDev = typeof import.meta !== 'undefined' && import.meta.env?.DEV; - if (isDev) { - logger.debug('GraphQL request', { - url, - operationName, - variables: variables ?? {}, - headers: { - 'X-Tenant-Id': headers['X-Tenant-Id'], - 'X-User-Id': headers['X-User-Id'], - 'X-Language': headers['X-Language'], - }, - }); - } - - const response = await fetch(url, { - method: 'POST', - headers, - body: JSON.stringify(body), - credentials: 'include', - }); - const supportReferenceId = - response.headers.get('X-Error-Id') ?? response.headers.get('X-Correlation-Id') ?? undefined; - - if (!response.ok) { - await throwForResponse(response, supportReferenceId, { - url, - operationName, - requestBody: body, - isDev, - }); - } - - const result = (await response.json()) as { data?: T; errors?: GraphQlErrorResponse['errors'] }; - - if (result.errors && result.errors.length > 0) { - const messages = result.errors.map(e => e.message); - throw new GraphQlValidationError( - `GraphQL errors: ${messages.join('; ')}`, - messages, - supportReferenceId ?? - result.errors[0]?.extensions?.errorId ?? - result.errors[0]?.extensions?.traceId - ); - } - - if (!result.data) { - throw new GraphQlError( - 'GraphQL response contained no data', - 200, - undefined, - operationName, - supportReferenceId - ); - } - - return result.data; -} - -export const graphqlClient = { - request: async (query: string, variables?: Record): Promise => { - return executeGraphQl(query, variables); - }, -}; diff --git a/src/apps/ums.web-app/src/infrastructure/identity/queries/branding.graphql.test.ts b/src/apps/ums.web-app/src/infrastructure/identity/queries/branding.graphql.test.ts deleted file mode 100644 index 2871cfeb..00000000 --- a/src/apps/ums.web-app/src/infrastructure/identity/queries/branding.graphql.test.ts +++ /dev/null @@ -1,57 +0,0 @@ -import { describe, it, expect, vi, beforeEach } from 'vitest'; -import { graphqlBrandingQueries } from './branding.graphql'; -import * as graphqlClientModule from '@infra/http/graphqlClient'; - -vi.mock('@infra/http/graphqlClient', () => ({ - graphqlClient: { - request: vi.fn(), - }, - GraphQlValidationError: class GraphQlValidationError extends Error { - constructor( - message: string, - public errors: string[] - ) { - super(message); - this.name = 'GraphQlValidationError'; - } - }, -})); - -describe('graphqlBrandingQueries', () => { - beforeEach(() => { - vi.mocked(graphqlClientModule.graphqlClient.request).mockClear(); - }); - - it('getBranding calls graphqlClient.request', async () => { - const mockResponse = { - tenantBranding: { - logo: 'logo.png', - logoFormat: 'png', - primaryColor: '#3b5bdb', - backgroundStyle: 'solid', - headlineText: 'Welcome', - secondaryText: 'Sign in', - primaryButtonLabel: 'Login', - footerText: 'Footer', - customDomain: null, - magicLinkFallbackEnabled: false, - dnsVerificationStatus: 'Pending', - }, - }; - - vi.mocked(graphqlClientModule.graphqlClient.request).mockResolvedValue(mockResponse); - - const result = await graphqlBrandingQueries.getBranding('3fa85f64-5717-4562-b3fc-2c963f66afa7'); - - expect(result.tenantBranding?.headlineText).toBe('Welcome'); - expect(graphqlClientModule.graphqlClient.request).toHaveBeenCalledWith(expect.any(String), { - tenantId: '3fa85f64-5717-4562-b3fc-2c963f66afa7', - }); - }); - - it('getBranding throws on empty tenantId', async () => { - await expect(graphqlBrandingQueries.getBranding('')).rejects.toThrow( - 'Invalid tenantId parameter' - ); - }); -}); diff --git a/src/apps/ums.web-app/src/infrastructure/identity/queries/branding.graphql.ts b/src/apps/ums.web-app/src/infrastructure/identity/queries/branding.graphql.ts deleted file mode 100644 index e9940d2f..00000000 --- a/src/apps/ums.web-app/src/infrastructure/identity/queries/branding.graphql.ts +++ /dev/null @@ -1,54 +0,0 @@ -/** - * branding.graphql.ts — GraphQL query definitions for Tenant Branding bounded context - * - * All read operations use these GraphQL queries. - * Commands/transactions use REST endpoints via branding.service.ts. - */ -import { graphqlClient, GraphQlValidationError } from '@infra/http/graphqlClient'; - -const GET_BRANDING = ` - query Branding($tenantId: UUID!) { - tenantBranding(tenantId: $tenantId) { - logo - logoFormat - primaryColor - backgroundStyle - headlineText - secondaryText - primaryButtonLabel - footerText - customDomain - magicLinkFallbackEnabled - dnsVerificationStatus - } - } -`; - -export interface GraphqlBrandingDto { - logo: string; - logoFormat: string; - primaryColor: string; - backgroundStyle: string; - headlineText: string; - secondaryText: string; - primaryButtonLabel: string; - footerText: string; - customDomain: string | null; - magicLinkFallbackEnabled: boolean; - dnsVerificationStatus: string | null; -} - -export interface GetBrandingResponse { - tenantBranding: GraphqlBrandingDto | null; -} - -export const graphqlBrandingQueries = { - getBranding: async (tenantId: string): Promise => { - if (!tenantId || tenantId.trim() === '') { - throw new GraphQlValidationError('Invalid tenantId parameter', [ - 'tenantId must be a non-empty string', - ]); - } - return graphqlClient.request(GET_BRANDING, { tenantId }); - }, -}; diff --git a/src/apps/ums.web-app/src/infrastructure/identity/queries/delegation.graphql.test.ts b/src/apps/ums.web-app/src/infrastructure/identity/queries/delegation.graphql.test.ts deleted file mode 100644 index 36183b71..00000000 --- a/src/apps/ums.web-app/src/infrastructure/identity/queries/delegation.graphql.test.ts +++ /dev/null @@ -1,110 +0,0 @@ -import { describe, it, expect, vi, beforeEach } from 'vitest'; -import { graphqlDelegationQueries } from './delegation.graphql'; -import * as graphqlClientModule from '@infra/http/graphqlClient'; - -vi.mock('@infra/http/graphqlClient', () => ({ - graphqlClient: { - request: vi.fn(), - }, - GraphQlValidationError: class GraphQlValidationError extends Error { - constructor( - message: string, - public errors: string[] - ) { - super(message); - this.name = 'GraphQlValidationError'; - } - }, -})); - -describe('graphqlDelegationQueries', () => { - beforeEach(() => { - vi.mocked(graphqlClientModule.graphqlClient.request).mockClear(); - }); - - it('getDelegationById calls graphqlClient.request', async () => { - const mockResponse = { - delegationById: { - delegationId: '3fa85f64-5717-4562-b3fc-2c963f66afa6', - tenantId: '3fa85f64-5717-4562-b3fc-2c963f66afa7', - delegatingAdminId: '3fa85f64-5717-4562-b3fc-2c963f66afa8', - delegatedAdminId: '3fa85f64-5717-4562-b3fc-2c963f66afa9', - scopeType: 'Tenant', - scopeId: null, - allowedActions: ['read', 'write'], - validFrom: '2024-01-01', - validUntil: '2024-12-31', - maxDurationDays: null, - requiresApproval: false, - approvalRequestId: null, - status: 'Active', - revokedAt: null, - revokedBy: null, - revocationReason: null, - }, - }; - - vi.mocked(graphqlClientModule.graphqlClient.request).mockResolvedValue(mockResponse); - - const result = await graphqlDelegationQueries.getDelegationById( - '3fa85f64-5717-4562-b3fc-2c963f66afa6' - ); - - expect(result.delegationById?.delegationId).toBe('3fa85f64-5717-4562-b3fc-2c963f66afa6'); - expect(graphqlClientModule.graphqlClient.request).toHaveBeenCalledWith(expect.any(String), { - delegationId: '3fa85f64-5717-4562-b3fc-2c963f66afa6', - }); - }); - - it('getDelegationById throws on empty delegationId', async () => { - await expect(graphqlDelegationQueries.getDelegationById('')).rejects.toThrow( - 'Invalid delegationId parameter' - ); - }); - - it('getDelegationsByDelegatedAdmin calls graphqlClient.request', async () => { - vi.mocked(graphqlClientModule.graphqlClient.request).mockResolvedValue({ - delegationsByDelegatedAdmin: [], - }); - - const result = await graphqlDelegationQueries.getDelegationsByDelegatedAdmin( - 'admin1', - 'tenant1' - ); - - expect(result.delegationsByDelegatedAdmin).toEqual([]); - expect(graphqlClientModule.graphqlClient.request).toHaveBeenCalledWith(expect.any(String), { - delegatedAdminId: 'admin1', - tenantId: 'tenant1', - }); - }); - - it('getDelegationsByDelegatedAdmin throws on missing params', async () => { - await expect( - graphqlDelegationQueries.getDelegationsByDelegatedAdmin('', 'tenant1') - ).rejects.toThrow('Invalid parameters'); - }); - - it('getDelegationsByDelegatingAdmin calls graphqlClient.request', async () => { - vi.mocked(graphqlClientModule.graphqlClient.request).mockResolvedValue({ - delegationsByDelegatingAdmin: [], - }); - - const result = await graphqlDelegationQueries.getDelegationsByDelegatingAdmin( - 'admin1', - 'tenant1' - ); - - expect(result.delegationsByDelegatingAdmin).toEqual([]); - expect(graphqlClientModule.graphqlClient.request).toHaveBeenCalledWith(expect.any(String), { - delegatingAdminId: 'admin1', - tenantId: 'tenant1', - }); - }); - - it('getDelegationsByDelegatingAdmin throws on missing params', async () => { - await expect( - graphqlDelegationQueries.getDelegationsByDelegatingAdmin('', 'tenant1') - ).rejects.toThrow('Invalid parameters'); - }); -}); diff --git a/src/apps/ums.web-app/src/infrastructure/identity/queries/delegation.graphql.ts b/src/apps/ums.web-app/src/infrastructure/identity/queries/delegation.graphql.ts deleted file mode 100644 index 01cb4ab2..00000000 --- a/src/apps/ums.web-app/src/infrastructure/identity/queries/delegation.graphql.ts +++ /dev/null @@ -1,88 +0,0 @@ -/** - * delegation.graphql.ts — GraphQL query definitions for the UserManagementDelegation bounded context - * - * All read operations use these GraphQL queries. - * Commands/transactions use REST endpoints via delegation.service.ts. - */ -import { graphqlClient, GraphQlValidationError } from '@infra/http/graphqlClient'; - -const GET_DELEGATION_BY_ID = - 'query DelegationById($delegationId: UUID!) { delegationById(delegationId: $delegationId) { delegationId tenantId delegatingAdminId delegatedAdminId scopeType scopeId allowedActions validFrom validUntil maxDurationDays requiresApproval approvalRequestId status revokedAt revokedBy revocationReason } }'; - -const GET_DELEGATIONS_BY_DELEGATED_ADMIN = - 'query DelegationsByDelegatedAdmin($delegatedAdminId: UUID!, $tenantId: UUID!) { delegationsByDelegatedAdmin(delegatedAdminId: $delegatedAdminId, tenantId: $tenantId) { delegationId tenantId delegatingAdminId delegatedAdminId scopeType scopeId allowedActions validFrom validUntil maxDurationDays requiresApproval approvalRequestId status revokedAt revokedBy revocationReason } }'; - -const GET_DELEGATIONS_BY_DELEGATING_ADMIN = - 'query DelegationsByDelegatingAdmin($delegatingAdminId: UUID!, $tenantId: UUID!) { delegationsByDelegatingAdmin(delegatingAdminId: $delegatingAdminId, tenantId: $tenantId) { delegationId tenantId delegatingAdminId delegatedAdminId scopeType scopeId allowedActions validFrom validUntil maxDurationDays requiresApproval approvalRequestId status revokedAt revokedBy revocationReason } }'; - -export interface GraphqlDelegationDto { - delegationId: string; - tenantId: string; - delegatingAdminId: string; - delegatedAdminId: string; - scopeType: string; - scopeId: string | null; - allowedActions: string[]; - validFrom: string; - validUntil: string; - maxDurationDays: number | null; - requiresApproval: boolean; - approvalRequestId: string | null; - status: string; - revokedAt: string | null; - revokedBy: string | null; - revocationReason: string | null; -} - -export interface GetDelegationByIdResponse { - delegationById: GraphqlDelegationDto | null; -} - -export interface GetDelegationsByDelegatedAdminResponse { - delegationsByDelegatedAdmin: GraphqlDelegationDto[]; -} - -export interface GetDelegationsByDelegatingAdminResponse { - delegationsByDelegatingAdmin: GraphqlDelegationDto[]; -} - -export const graphqlDelegationQueries = { - getDelegationById: async (delegationId: string): Promise => { - if (!delegationId || delegationId.trim() === '') { - throw new GraphQlValidationError('Invalid delegationId parameter', [ - 'delegationId must be a non-empty string', - ]); - } - return graphqlClient.request(GET_DELEGATION_BY_ID, { delegationId }); - }, - - getDelegationsByDelegatedAdmin: async ( - delegatedAdminId: string, - tenantId: string - ): Promise => { - if (!delegatedAdminId || !tenantId) { - throw new GraphQlValidationError('Invalid parameters', [ - 'delegatedAdminId and tenantId are required', - ]); - } - return graphqlClient.request( - GET_DELEGATIONS_BY_DELEGATED_ADMIN, - { delegatedAdminId, tenantId } - ); - }, - - getDelegationsByDelegatingAdmin: async ( - delegatingAdminId: string, - tenantId: string - ): Promise => { - if (!delegatingAdminId || !tenantId) { - throw new GraphQlValidationError('Invalid parameters', [ - 'delegatingAdminId and tenantId are required', - ]); - } - return graphqlClient.request( - GET_DELEGATIONS_BY_DELEGATING_ADMIN, - { delegatingAdminId, tenantId } - ); - }, -}; diff --git a/src/apps/ums.web-app/src/infrastructure/identity/queries/idp.graphql.test.ts b/src/apps/ums.web-app/src/infrastructure/identity/queries/idp.graphql.test.ts deleted file mode 100644 index aa359a26..00000000 --- a/src/apps/ums.web-app/src/infrastructure/identity/queries/idp.graphql.test.ts +++ /dev/null @@ -1,63 +0,0 @@ -import { describe, it, expect, vi, beforeEach } from 'vitest'; -import { graphqlIdpQueries } from './idp.graphql'; -import * as graphqlClientModule from '@infra/http/graphqlClient'; - -vi.mock('@infra/http/graphqlClient', () => ({ - graphqlClient: { - request: vi.fn(), - }, - GraphQlValidationError: class GraphQlValidationError extends Error { - constructor( - message: string, - public errors: string[] - ) { - super(message); - this.name = 'GraphQlValidationError'; - } - }, -})); - -describe('graphqlIdpQueries', () => { - beforeEach(() => { - vi.mocked(graphqlClientModule.graphqlClient.request).mockClear(); - }); - - it('getIdentityProviders calls graphqlClient.request', async () => { - const mockResponse = { - tenantIdentityProviders: [ - { - identityProviderId: '3fa85f64-5717-4562-b3fc-2c963f66afa6', - code: 'AZURE', - name: 'Azure AD', - description: 'Azure Active Directory', - strategy: 'OIDC', - isActive: true, - }, - ], - }; - - vi.mocked(graphqlClientModule.graphqlClient.request).mockResolvedValue(mockResponse); - - const result = await graphqlIdpQueries.getIdentityProviders( - '3fa85f64-5717-4562-b3fc-2c963f66afa7' - ); - - expect(result.tenantIdentityProviders).toHaveLength(1); - expect(result.tenantIdentityProviders[0].name).toBe('Azure AD'); - expect(graphqlClientModule.graphqlClient.request).toHaveBeenCalledWith(expect.any(String), { - tenantId: '3fa85f64-5717-4562-b3fc-2c963f66afa7', - }); - }); - - it('getIdentityProviders throws on empty tenantId', async () => { - await expect(graphqlIdpQueries.getIdentityProviders('')).rejects.toThrow( - 'Invalid tenantId parameter' - ); - }); - - it('getIdentityProviders throws on whitespace-only tenantId', async () => { - await expect(graphqlIdpQueries.getIdentityProviders(' ')).rejects.toThrow( - 'Invalid tenantId parameter' - ); - }); -}); diff --git a/src/apps/ums.web-app/src/infrastructure/identity/queries/idp.graphql.ts b/src/apps/ums.web-app/src/infrastructure/identity/queries/idp.graphql.ts deleted file mode 100644 index 0141f2f8..00000000 --- a/src/apps/ums.web-app/src/infrastructure/identity/queries/idp.graphql.ts +++ /dev/null @@ -1,46 +0,0 @@ -/** - * idp.graphql.ts — GraphQL query definitions for Identity Provider bounded context - * - * All read operations use these GraphQL queries. - * Commands/transactions use REST endpoints via idp.service.ts. - */ -import { graphqlClient, GraphQlValidationError } from '@infra/http/graphqlClient'; - -const GET_IDENTITY_PROVIDERS = ` - query IdentityProviders($tenantId: UUID!) { - tenantIdentityProviders(tenantId: $tenantId) { - identityProviderId - code - name - description - strategy - isActive - } - } -`; - -export interface GraphqlIdentityProviderDto { - identityProviderId: string; - code: string; - name: string; - description: string; - strategy: string; - isActive: boolean; -} - -export interface GetIdentityProvidersResponse { - tenantIdentityProviders: GraphqlIdentityProviderDto[]; -} - -export const graphqlIdpQueries = { - getIdentityProviders: async (tenantId: string): Promise => { - if (!tenantId || tenantId.trim() === '') { - throw new GraphQlValidationError('Invalid tenantId parameter', [ - 'tenantId must be a non-empty string', - ]); - } - return graphqlClient.request(GET_IDENTITY_PROVIDERS, { - tenantId, - }); - }, -}; diff --git a/src/apps/ums.web-app/src/infrastructure/identity/queries/tenant.graphql.test.ts b/src/apps/ums.web-app/src/infrastructure/identity/queries/tenant.graphql.test.ts deleted file mode 100644 index 3f18627a..00000000 --- a/src/apps/ums.web-app/src/infrastructure/identity/queries/tenant.graphql.test.ts +++ /dev/null @@ -1,146 +0,0 @@ -import { describe, it, expect, vi, beforeEach } from 'vitest'; -import { graphqlQueries } from './tenant.graphql'; -import { GraphQlValidationError } from '@infra/http/graphqlClient'; -import * as graphqlClientModule from '@infra/http/graphqlClient'; - -vi.mock('@infra/http/graphqlClient', () => ({ - graphqlClient: { - request: vi.fn(), - }, - GraphQlValidationError: class GraphQlValidationError extends Error { - constructor( - message: string, - public details: string[] - ) { - super(message); - this.name = 'GraphQlValidationError'; - } - }, -})); - -describe('graphqlQueries', () => { - beforeEach(() => { - vi.mocked(graphqlClientModule.graphqlClient.request).mockClear(); - }); - - describe('getTenants', () => { - it('calls graphqlClient with correct query and variables', async () => { - vi.mocked(graphqlClientModule.graphqlClient.request).mockResolvedValue({ - getTenants: { items: [], page: 1, pageSize: 10, totalItems: 0, totalPages: 0 }, - }); - - await graphqlQueries.getTenants({ page: 1, pageSize: 10 }); - - expect(graphqlClientModule.graphqlClient.request).toHaveBeenCalledWith( - expect.stringContaining('query Tenants'), - expect.objectContaining({ page: 1, pageSize: 10 }) - ); - }); - - it('includes optional search parameter', async () => { - vi.mocked(graphqlClientModule.graphqlClient.request).mockResolvedValue({ - getTenants: { items: [], page: 1, pageSize: 10, totalItems: 0, totalPages: 0 }, - }); - - await graphqlQueries.getTenants({ page: 1, pageSize: 10, search: 'test' }); - - const callArgs = vi.mocked(graphqlClientModule.graphqlClient.request).mock.calls[0]; - expect(callArgs[1]).toHaveProperty('search', 'test'); - }); - - it('includes optional criteria parameter', async () => { - vi.mocked(graphqlClientModule.graphqlClient.request).mockResolvedValue({ - getTenants: { items: [], page: 1, pageSize: 10, totalItems: 0, totalPages: 0 }, - }); - - await graphqlQueries.getTenants({ page: 1, pageSize: 10, criteria: 'name' }); - - const callArgs = vi.mocked(graphqlClientModule.graphqlClient.request).mock.calls[0]; - expect(callArgs[1]).toHaveProperty('criteria', 'name'); - }); - - it('includes optional status parameter', async () => { - vi.mocked(graphqlClientModule.graphqlClient.request).mockResolvedValue({ - getTenants: { items: [], page: 1, pageSize: 10, totalItems: 0, totalPages: 0 }, - }); - - await graphqlQueries.getTenants({ page: 1, pageSize: 10, status: 'Active' }); - - const callArgs = vi.mocked(graphqlClientModule.graphqlClient.request).mock.calls[0]; - expect(callArgs[1]).toHaveProperty('status', 'Active'); - }); - - it('includes optional sortBy parameter', async () => { - vi.mocked(graphqlClientModule.graphqlClient.request).mockResolvedValue({ - getTenants: { items: [], page: 1, pageSize: 10, totalItems: 0, totalPages: 0 }, - }); - - await graphqlQueries.getTenants({ page: 1, pageSize: 10, sortBy: 'name' }); - - const callArgs = vi.mocked(graphqlClientModule.graphqlClient.request).mock.calls[0]; - expect(callArgs[1]).toHaveProperty('sortBy', 'name'); - }); - - it('throws on invalid page', async () => { - await expect(graphqlQueries.getTenants({ page: 0, pageSize: 10 })).rejects.toThrow( - GraphQlValidationError - ); - }); - - it('throws on invalid pageSize', async () => { - await expect(graphqlQueries.getTenants({ page: 1, pageSize: 0 })).rejects.toThrow( - GraphQlValidationError - ); - }); - }); - - describe('getTenantById', () => { - it('calls graphqlClient with tenantId', async () => { - vi.mocked(graphqlClientModule.graphqlClient.request).mockResolvedValue({ - getTenantById: { - tenantId: '123', - code: 'TEST', - name: 'Test', - type: 'INTERNAL', - status: 'Active', - parentTenantId: null, - companyReference: null, - }, - }); - - await graphqlQueries.getTenantById('3fa85f64-5717-4562-b3fc-2c963f66afa6'); - - expect(graphqlClientModule.graphqlClient.request).toHaveBeenCalledWith( - expect.stringContaining('query Tenant'), - expect.objectContaining({ tenantId: '3fa85f64-5717-4562-b3fc-2c963f66afa6' }) - ); - }); - - it('throws on empty tenantId', async () => { - await expect(graphqlQueries.getTenantById('')).rejects.toThrow(GraphQlValidationError); - }); - - it('throws on whitespace tenantId', async () => { - await expect(graphqlQueries.getTenantById(' ')).rejects.toThrow(GraphQlValidationError); - }); - }); - - describe('getTenantBranches', () => { - it('calls graphqlClient with tenantId', async () => { - vi.mocked(graphqlClientModule.graphqlClient.request).mockResolvedValue({ - getTenantBranches: [], - }); - - await graphqlQueries.getTenantBranches('3fa85f64-5717-4562-b3fc-2c963f66afa6'); - - expect(graphqlClientModule.graphqlClient.request).toHaveBeenCalledWith( - expect.stringContaining('query TenantBranches'), - expect.objectContaining({ tenantId: '3fa85f64-5717-4562-b3fc-2c963f66afa6' }) - ); - }); - - it('throws on empty tenantId', async () => { - await expect(graphqlQueries.getTenantBranches('')).rejects.toThrow(GraphQlValidationError); - }); - }); -}); diff --git a/src/apps/ums.web-app/src/infrastructure/identity/queries/tenant.graphql.ts b/src/apps/ums.web-app/src/infrastructure/identity/queries/tenant.graphql.ts deleted file mode 100644 index 70a6c4e1..00000000 --- a/src/apps/ums.web-app/src/infrastructure/identity/queries/tenant.graphql.ts +++ /dev/null @@ -1,167 +0,0 @@ -/** - * tenant.graphql.ts — GraphQL query definitions for the Tenant bounded context - * - * All read operations use these GraphQL queries. - * Commands/transactions use REST endpoints via tenant.service.ts. - */ -import { graphqlClient, GraphQlValidationError } from '@infra/http/graphqlClient'; - -const GET_TENANTS = ` - query Tenants( - $page: Int! - $pageSize: Int! - $search: String - $criteria: String - $status: String - $sortBy: String - $sortOrder: String - ) { - tenants( - page: $page - pageSize: $pageSize - search: $search - criteria: $criteria - status: $status - sortBy: $sortBy - sortOrder: $sortOrder - ) { - items { - tenantId - code - name - type - status - parentTenantId - companyReference - isManagementOwner - } - page - pageSize - totalItems - totalPages - } - } -`; - -const GET_TENANT_BY_ID = ` - query Tenant($tenantId: UUID!) { - tenantById(tenantId: $tenantId) { - tenantId - code - name - type - status - parentTenantId - companyReference - isManagementOwner - } - } -`; - -const GET_TENANT_BRANCHES = ` - query TenantBranches($tenantId: UUID!) { - tenantBranches(tenantId: $tenantId) { - branchId - code - name - isActive - geofencingMetadata - } - } -`; - -export interface GraphqlTenantDto { - tenantId: string; - code: string; - name: string; - type: string; - status: string; - parentTenantId: string | null; - companyReference: string | null; - isManagementOwner: boolean; -} - -export interface GraphqlTenantPage { - items: GraphqlTenantDto[]; - page: number; - pageSize: number; - totalItems: number; - totalPages: number; -} - -export interface GraphqlBranchDto { - branchId: string; - code: string; - name: string; - isActive: boolean; - geofencingMetadata: string | null; -} - -export interface GetTenantsResponse { - tenants: GraphqlTenantPage; -} - -export interface GetTenantByIdResponse { - tenantById: GraphqlTenantDto | null; -} - -export interface GetTenantBranchesResponse { - tenantBranches: GraphqlBranchDto[]; -} - -function validateGetTenantsParams(params: { page: number; pageSize: number }): void { - if (!Number.isInteger(params.page) || params.page < 1) { - throw new GraphQlValidationError('Invalid page parameter', [ - `page must be a positive integer, got: ${params.page}`, - ]); - } - if (!Number.isInteger(params.pageSize) || params.pageSize < 1) { - throw new GraphQlValidationError('Invalid pageSize parameter', [ - `pageSize must be a positive integer, got: ${params.pageSize}`, - ]); - } -} - -export const graphqlQueries = { - getTenants: async (params: { - page: number; - pageSize: number; - search?: string; - criteria?: string; - status?: string; - sortBy?: string; - sortOrder?: string; - }): Promise => { - validateGetTenantsParams(params); - - const variables: Record = { - page: params.page, - pageSize: params.pageSize, - }; - if (params.search !== undefined) variables.search = params.search; - if (params.criteria !== undefined) variables.criteria = params.criteria; - if (params.status !== undefined) variables.status = params.status; - if (params.sortBy !== undefined) variables.sortBy = params.sortBy; - if (params.sortOrder !== undefined) variables.sortOrder = params.sortOrder; - - return graphqlClient.request(GET_TENANTS, variables); - }, - - getTenantById: async (tenantId: string): Promise => { - if (!tenantId || tenantId.trim() === '') { - throw new GraphQlValidationError('Invalid tenantId parameter', [ - 'tenantId must be a non-empty string', - ]); - } - return graphqlClient.request(GET_TENANT_BY_ID, { tenantId }); - }, - - getTenantBranches: async (tenantId: string): Promise => { - if (!tenantId || tenantId.trim() === '') { - throw new GraphQlValidationError('Invalid tenantId parameter', [ - 'tenantId must be a non-empty string', - ]); - } - return graphqlClient.request(GET_TENANT_BRANCHES, { tenantId }); - }, -}; diff --git a/src/apps/ums.web-app/src/infrastructure/identity/queries/user-account.graphql.test.ts b/src/apps/ums.web-app/src/infrastructure/identity/queries/user-account.graphql.test.ts deleted file mode 100644 index 0b65460a..00000000 --- a/src/apps/ums.web-app/src/infrastructure/identity/queries/user-account.graphql.test.ts +++ /dev/null @@ -1,143 +0,0 @@ -import { describe, it, expect, vi, beforeEach } from 'vitest'; -import { graphqlQueries } from './user-account.graphql'; -import { GraphQlValidationError } from '@infra/http/graphqlClient'; -import * as graphqlClientModule from '@infra/http/graphqlClient'; - -vi.mock('@infra/http/graphqlClient', () => ({ - graphqlClient: { - request: vi.fn(), - }, - GraphQlValidationError: class GraphQlValidationError extends Error { - constructor( - message: string, - public details: string[] - ) { - super(message); - this.name = 'GraphQlValidationError'; - } - }, -})); - -describe('graphqlQueries (user-account)', () => { - beforeEach(() => { - vi.mocked(graphqlClientModule.graphqlClient.request).mockClear(); - }); - - describe('getUserAccounts', () => { - it('calls graphqlClient with correct query and variables', async () => { - vi.mocked(graphqlClientModule.graphqlClient.request).mockResolvedValue({ - getUserAccounts: { items: [], page: 1, pageSize: 10, totalItems: 0, totalPages: 0 }, - }); - - await graphqlQueries.getUserAccounts({ page: 1, pageSize: 10 }); - - expect(graphqlClientModule.graphqlClient.request).toHaveBeenCalledWith( - expect.stringContaining('query UserAccounts'), - expect.objectContaining({ page: 1, pageSize: 10 }) - ); - }); - - it('includes optional search parameter', async () => { - vi.mocked(graphqlClientModule.graphqlClient.request).mockResolvedValue({ - getUserAccounts: { items: [], page: 1, pageSize: 10, totalItems: 0, totalPages: 0 }, - }); - - await graphqlQueries.getUserAccounts({ page: 1, pageSize: 10, search: 'test' }); - - const callArgs = vi.mocked(graphqlClientModule.graphqlClient.request).mock.calls[0]; - expect(callArgs[1]).toHaveProperty('search', 'test'); - }); - - it('includes optional criteria parameter', async () => { - vi.mocked(graphqlClientModule.graphqlClient.request).mockResolvedValue({ - getUserAccounts: { items: [], page: 1, pageSize: 10, totalItems: 0, totalPages: 0 }, - }); - - await graphqlQueries.getUserAccounts({ page: 1, pageSize: 10, criteria: 'email' }); - - const callArgs = vi.mocked(graphqlClientModule.graphqlClient.request).mock.calls[0]; - expect(callArgs[1]).toHaveProperty('criteria', 'email'); - }); - - it('includes optional status parameter', async () => { - vi.mocked(graphqlClientModule.graphqlClient.request).mockResolvedValue({ - getUserAccounts: { items: [], page: 1, pageSize: 10, totalItems: 0, totalPages: 0 }, - }); - - await graphqlQueries.getUserAccounts({ page: 1, pageSize: 10, status: 'Active' }); - - const callArgs = vi.mocked(graphqlClientModule.graphqlClient.request).mock.calls[0]; - expect(callArgs[1]).toHaveProperty('status', 'Active'); - }); - - it('includes optional sortBy parameter', async () => { - vi.mocked(graphqlClientModule.graphqlClient.request).mockResolvedValue({ - getUserAccounts: { items: [], page: 1, pageSize: 10, totalItems: 0, totalPages: 0 }, - }); - - await graphqlQueries.getUserAccounts({ page: 1, pageSize: 10, sortBy: 'email' }); - - const callArgs = vi.mocked(graphqlClientModule.graphqlClient.request).mock.calls[0]; - expect(callArgs[1]).toHaveProperty('sortBy', 'email'); - }); - - it('includes optional tenantId parameter', async () => { - vi.mocked(graphqlClientModule.graphqlClient.request).mockResolvedValue({ - getUserAccounts: { items: [], page: 1, pageSize: 10, totalItems: 0, totalPages: 0 }, - }); - - await graphqlQueries.getUserAccounts({ page: 1, pageSize: 10, tenantId: 't-1' }); - - const callArgs = vi.mocked(graphqlClientModule.graphqlClient.request).mock.calls[0]; - expect(callArgs[1]).toHaveProperty('tenantId', 't-1'); - }); - - it('throws on invalid page', async () => { - await expect(graphqlQueries.getUserAccounts({ page: 0, pageSize: 10 })).rejects.toThrow( - GraphQlValidationError - ); - }); - - it('throws on invalid pageSize', async () => { - await expect(graphqlQueries.getUserAccounts({ page: 1, pageSize: 0 })).rejects.toThrow( - GraphQlValidationError - ); - }); - }); - - describe('getUserAccountById', () => { - it('calls graphqlClient with userAccountId', async () => { - vi.mocked(graphqlClientModule.graphqlClient.request).mockResolvedValue({ - getUserAccountById: { - userAccountId: 'u-1', - email: 'test@test.com', - category: 'Internal', - status: 'Active', - tenantId: 't-1', - branchId: null, - identityReference: null, - identityReferenceType: null, - hasActivePassword: true, - passwordUpdatedAtUtc: null, - }, - }); - - await graphqlQueries.getUserAccountById('3fa85f64-5717-4562-b3fc-2c963f66afa6'); - - expect(graphqlClientModule.graphqlClient.request).toHaveBeenCalledWith( - expect.stringContaining('query UserAccount'), - expect.objectContaining({ userAccountId: '3fa85f64-5717-4562-b3fc-2c963f66afa6' }) - ); - }); - - it('throws on empty userAccountId', async () => { - await expect(graphqlQueries.getUserAccountById('')).rejects.toThrow(GraphQlValidationError); - }); - - it('throws on whitespace userAccountId', async () => { - await expect(graphqlQueries.getUserAccountById(' ')).rejects.toThrow( - GraphQlValidationError - ); - }); - }); -}); diff --git a/src/apps/ums.web-app/src/infrastructure/identity/queries/user-account.graphql.ts b/src/apps/ums.web-app/src/infrastructure/identity/queries/user-account.graphql.ts deleted file mode 100644 index 8fb1d92e..00000000 --- a/src/apps/ums.web-app/src/infrastructure/identity/queries/user-account.graphql.ts +++ /dev/null @@ -1,96 +0,0 @@ -/** - * user-account.graphql.ts — GraphQL query definitions for the UserAccount bounded context - * - * All read operations use these GraphQL queries. - * Commands/transactions use REST endpoints via user-account.service.ts. - */ -import { graphqlClient, GraphQlValidationError } from '@infra/http/graphqlClient'; - -const GET_USER_ACCOUNTS = - 'query UserAccounts($page: Int!, $pageSize: Int!, $search: String, $criteria: String, $status: String, $sortBy: String, $sortOrder: String, $tenantId: UUID) { userAccounts(page: $page, pageSize: $pageSize, search: $search, criteria: $criteria, status: $status, sortBy: $sortBy, sortOrder: $sortOrder, tenantId: $tenantId) { items { userAccountId tenantId branchId email category status identityReference identityReferenceType hasActivePassword passwordUpdatedAtUtc } page pageSize totalItems totalPages } }'; - -const GET_USER_ACCOUNT_BY_ID = - 'query UserAccount($userAccountId: UUID!) { userAccountById(userAccountId: $userAccountId) { userAccountId tenantId branchId email category status identityReference identityReferenceType hasActivePassword passwordUpdatedAtUtc } }'; - -export interface GraphqlUserAccountDto { - userAccountId: string; - tenantId: string; - branchId: string | null; - email: string; - category: string; - status: string; - identityReference: string | null; - identityReferenceType: string | null; - hasActivePassword: boolean; - passwordUpdatedAtUtc: string | null; -} - -export interface GraphqlUserAccountPage { - items: GraphqlUserAccountDto[]; - page: number; - pageSize: number; - totalItems: number; - totalPages: number; -} - -export interface GetUserAccountsResponse { - userAccounts: GraphqlUserAccountPage; -} - -export interface GetUserAccountByIdResponse { - userAccountById: GraphqlUserAccountDto | null; -} - -export interface GetUserAccountsParams { - page: number; - pageSize: number; - search?: string; - criteria?: string; - status?: string; - sortBy?: string; - sortOrder?: string; - tenantId?: string; -} - -function validateUserAccountsParams(params: GetUserAccountsParams): void { - if (!Number.isInteger(params.page) || params.page < 1) { - throw new GraphQlValidationError('Invalid page parameter', [ - `page must be a positive integer, got: ${params.page}`, - ]); - } - if (!Number.isInteger(params.pageSize) || params.pageSize < 1) { - throw new GraphQlValidationError('Invalid pageSize parameter', [ - `pageSize must be a positive integer, got: ${params.pageSize}`, - ]); - } -} - -export const graphqlQueries = { - getUserAccounts: async (params: GetUserAccountsParams): Promise => { - validateUserAccountsParams(params); - - const variables: Record = { - page: params.page, - pageSize: params.pageSize, - }; - if (params.search !== undefined) variables.search = params.search; - if (params.criteria !== undefined) variables.criteria = params.criteria; - if (params.status !== undefined) variables.status = params.status; - if (params.sortBy !== undefined) variables.sortBy = params.sortBy; - if (params.sortOrder !== undefined) variables.sortOrder = params.sortOrder; - if (params.tenantId !== undefined) variables.tenantId = params.tenantId; - - return graphqlClient.request(GET_USER_ACCOUNTS, variables); - }, - - getUserAccountById: async (userAccountId: string): Promise => { - if (!userAccountId || userAccountId.trim() === '') { - throw new GraphQlValidationError('Invalid userAccountId parameter', [ - 'userAccountId must be a non-empty string', - ]); - } - return graphqlClient.request(GET_USER_ACCOUNT_BY_ID, { - userAccountId, - }); - }, -}; diff --git a/src/apps/ums.web-app/src/infrastructure/identity/services/branding.service.test.ts b/src/apps/ums.web-app/src/infrastructure/identity/services/branding.service.test.ts index eae70461..6149a978 100644 --- a/src/apps/ums.web-app/src/infrastructure/identity/services/branding.service.test.ts +++ b/src/apps/ums.web-app/src/infrastructure/identity/services/branding.service.test.ts @@ -1,44 +1,59 @@ import { describe, it, expect, vi, beforeEach } from 'vitest'; import { brandingService } from './branding.service'; import * as httpClientModule from '@infra/http/httpClient'; -import * as graphqlBrandingQueriesModule from '@infra/identity/queries/branding.graphql'; vi.mock('@infra/http/httpClient', () => ({ httpClient: { + get: vi.fn(), post: vi.fn(), put: vi.fn(), delete: vi.fn(), }, })); -vi.mock('@infra/identity/queries/branding.graphql', () => ({ - graphqlBrandingQueries: { - getBranding: vi.fn(), - }, -})); - describe('brandingService', () => { beforeEach(() => { vi.mocked(httpClientModule.httpClient.post).mockClear(); vi.mocked(httpClientModule.httpClient.put).mockClear(); vi.mocked(httpClientModule.httpClient.delete).mockClear(); - vi.mocked(graphqlBrandingQueriesModule.graphqlBrandingQueries.getBranding).mockClear(); + vi.mocked(httpClientModule.httpClient.get).mockClear(); }); describe('getBranding', () => { - it('returns null when no branding exists', async () => { - vi.mocked(graphqlBrandingQueriesModule.graphqlBrandingQueries.getBranding).mockResolvedValue({ - tenantBranding: null, - }); + it('devuelve null cuando el inquilino no tiene identidad visual (200 con cuerpo vacío)', async () => { + vi.mocked(httpClientModule.httpClient.get).mockResolvedValue({ data: null }); const result = await brandingService.getBranding('3fa85f64-5717-4562-b3fc-2c963f66afa6'); expect(result).toBeNull(); + expect(httpClientModule.httpClient.get).toHaveBeenCalledWith( + '/tenants/3fa85f64-5717-4562-b3fc-2c963f66afa6/branding' + ); + }); + + it('devuelve null cuando el inquilino no existe (404), sin propagar el error', async () => { + const error = new Error('Not Found') as Error & { response?: { status: number } }; + error.response = { status: 404 }; + vi.mocked(httpClientModule.httpClient.get).mockRejectedValue(error); + + const result = await brandingService.getBranding('3fa85f64-5717-4562-b3fc-2c963f66afa6'); + + expect(result).toBeNull(); + }); + + it('propaga cualquier otro error: un 500 no es «no hay identidad visual»', async () => { + const error = new Error('Server Error') as Error & { response?: { status: number } }; + error.response = { status: 500 }; + vi.mocked(httpClientModule.httpClient.get).mockRejectedValue(error); + + await expect( + brandingService.getBranding('3fa85f64-5717-4562-b3fc-2c963f66afa6') + ).rejects.toThrow(); }); it('returns parsed branding when available', async () => { - vi.mocked(graphqlBrandingQueriesModule.graphqlBrandingQueries.getBranding).mockResolvedValue({ - tenantBranding: { + vi.mocked(httpClientModule.httpClient.get).mockResolvedValue({ + data: { logo: 'data:image/png;base64,...', logoFormat: 'png', primaryColor: '#3b82f6', diff --git a/src/apps/ums.web-app/src/infrastructure/identity/services/branding.service.ts b/src/apps/ums.web-app/src/infrastructure/identity/services/branding.service.ts index 8e592da9..0dddaf53 100644 --- a/src/apps/ums.web-app/src/infrastructure/identity/services/branding.service.ts +++ b/src/apps/ums.web-app/src/infrastructure/identity/services/branding.service.ts @@ -2,12 +2,10 @@ * branding.service.ts * * Infrastructure service for Tenant Branding bounded context. - * Queries use GraphQL via graphqlClient. - * Commands/transactions use REST via httpClient. - * All responses are validated at runtime with Zod before returning. + * Lecturas y comandos van por REST (ADR-0166); las respuestas se validan con Zod. */ import { httpClient } from '@infra/http/httpClient'; -import { graphqlBrandingQueries } from '@infra/identity/queries/branding.graphql'; +import { getHttpStatus } from '@app/errors/http-error'; import { BrandingSchema, type Branding, @@ -16,12 +14,25 @@ import { } from '@domain/identity/schemas/branding.schema'; export const brandingService = { - // ── Queries (GraphQL) ───────────────────────────────────────────────────── - + // ── Queries (REST) ──────────────────────────────────────────────────────── + + /** + * Devuelve `null` cuando el inquilino no tiene identidad visual configurada, que es un estado + * normal y no un error: el panel lo pinta con los valores por defecto del producto. + * + * El servidor lo expresa de dos formas —200 con cuerpo vacío si el inquilino existe y no la ha + * configurado, 404 si el inquilino no existe— y aquí las dos colapsan a `null`, que es lo que + * hacía el camino GraphQL anterior. Cualquier otro error sí se propaga. + */ getBranding: async (tenantId: string): Promise => { - const response = await graphqlBrandingQueries.getBranding(tenantId); - if (!response.tenantBranding) return null; - return BrandingSchema.parse(response.tenantBranding); + try { + const { data } = await httpClient.get(`/tenants/${tenantId}/branding`); + if (!data) return null; + return BrandingSchema.parse(data); + } catch (error: unknown) { + if (getHttpStatus(error) === 404) return null; + throw error; + } }, // ── Commands (REST) ───────────────────────────────────────────────────────