diff --git a/.env.example b/.env.example index f6daa1f4..9eca50e0 100644 --- a/.env.example +++ b/.env.example @@ -145,3 +145,12 @@ JWT_ISSUER=reqsai # Optional: Override display amounts in cents (must match Stripe Price object) # BILLING_PRO_AMOUNT_CENTS=2900 # BILLING_ENTERPRISE_AMOUNT_CENTS=9900 + +# ── Integrations — Jira (gateway) ───────────────────────────────────────────── +# INTEGRATIONS_ENCRYPTION_KEY=your-base64-32-byte-key-here +# JIRA_OAUTH_CALLBACK_URL must match the app's Authorization → Callback URL exactly. +# Defaults to {FRONTEND_URL}/settings/integrations/jira/callback — only set this to override. +# JIRA_OAUTH_CLIENT_ID=your-atlassian-oauth-client-id +# JIRA_OAUTH_CLIENT_SECRET=your-atlassian-oauth-client-secret +# JIRA_OAUTH_CALLBACK_URL=http://localhost:4200/settings/integrations/jira/callback +# JIRA_OAUTH_STATE_SECRET=your-hex-64-char-state-secret diff --git a/CHANGELOG.md b/CHANGELOG.md index 9d542c35..f802ac83 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,160 @@ follows [Semantic Versioning](https://semver.org/). _Bounded-context implementation (iam, billing, workspace, discovery, gateway) in progress._ +### Added (Billing / Stripe subscriptions — `feature/billing-subscription-payments-quota`) + +- **Paid subscription lifecycle** — the `Subscription` aggregate gains upgrade/cancel/reactivate/downgrade + transitions plus AI token-quota accounting with automatic period rollover, driven by new lifecycle + domain events. `PlanCatalog` defines PRO/ENTERPRISE limits; pricing is config-driven via + `BillingProperties` (`reqsai.billing.*`, display amounts in minor units + currency) — currently PRO + USD 49.00/mo and ENTERPRISE USD 149.00/mo, matching the marketing landing page. + New REST endpoints on the subscription controller: `PUT .../upgrade`, `PUT .../cancel`, + `PUT .../reactivate`, `GET .../usage`. +- **AI token metering** — `BillingModuleApi.recordTokenConsumption`/`hasTokenQuotaAvailable`/`planLimits` + let Discovery capture provider-reported token usage from each LLM response and meter it against the + tenant's subscription quota (best-effort — a metering failure never breaks generation). +- **Stripe as a real payment gateway** (`PaymentGatewayPort`, swapped in by + `reqsai.billing.payment-provider=stripe`; `fake` — synchronous plan activation, no charge — stays the + default) implemented over Spring `RestClient` + a hand-rolled HMAC verifier, no Stripe SDK, to keep the + dependency surface minimal: + - `StripePaymentGatewayAdapter` creates a hosted Checkout Session (subscription mode) and returns its + URL; the plan is only activated from the webhook, honoring the same `PlanChangeResult` contract as + the fake gateway so domain/handlers are unchanged. Checkout return URLs derive from `WEB_APP_URL` + (`.../billing/success`, `.../billing/cancel`) rather than the API host. + - `StripeWebhookParser` verifies the `Stripe-Signature` header (HMAC-SHA256 over + `.`) and maps events to a gateway-agnostic `PaymentWebhookEvent`. + - `ProcessPaymentWebhookCommandHandler` de-duplicates by event id (new `public.billing_processed_events`, + common migration `V13__billing_processed_events.sql`) and applies plan activation / downgrade / + past-due. New endpoint `POST /api/billing/webhooks/stripe` (signature-verified, JWT-exempt in + `SecurityConfiguration`). +- **Cross-module relay** — on upgrade/downgrade, Billing publishes a + `SubscriptionPlanChangedIntegrationEvent` on `billing::api`; Workspace mirrors the new plan limits onto + its `Organization` aggregate. +- **Docs** — [docs/BILLING.md](docs/BILLING.md) covers Stripe test-mode setup, product/price + configuration, local webhook forwarding via the Stripe CLI, and deployed-environment webhooks; + `.env.example` documents every billing/Stripe variable (provider flag, API key, per-plan Price ids, + webhook secret, return-URL overrides). + +### Added (Live session presence — `feature/discovery-presence`) + +- **Real-time presence for live discovery sessions** — the users currently viewing a live session are + now tracked and broadcast so every participant sees who else is present. A new `PRESENCE_STATE` + event on the existing per-session topic (`/topic/sessions/{id}`) carries a `SessionPresenceMessage` + snapshot: the full participant list (`userId`, `displayName`, `avatarUrl`) plus a distinct `count`. + Presence is driven entirely by STOMP lifecycle events — subscribing to the session topic **is** the + presence signal; unsubscribe/disconnect removes the user; the same user across two tabs counts once. +- **`WorkspaceModuleApi.findMemberDisplayName(orgId, userId)`** — new read on the `workspace::api` ACL + so discovery labels participants from the member roster (`public.members`) without reaching into + workspace internals; the result is Caffeine-cached per tenant+user. +- **No Redis / no schema change** — the presence roster is ephemeral in-process state (a + `SessionPresenceRegistry` alongside the in-memory broker). Like the `SIMPLE` broker (ADR-0007) it is + per-JVM; a fully global roster across multiple instances would require the shared `RELAY` broker's + state. The authenticated `orgId` is stashed in the STOMP session attributes on CONNECT so the + broker-thread listeners can resolve the tenant. + +### Added (Integrations / Jira — `feature/integrations-jira`) + +- **Jira Cloud integration in the reserved `gateway` bounded context** (ADR-0023) — the feature reuses + the `com.kntro.reqsai.gateway` module reserved for external integrations. Extensible + provider model (`IntegrationProvider` port + `JiraProvider`) whose credentials live at the + **organization** level and whose push target lives at the **project** level. + - **Org connection endpoints** (org owner/admin gated): `GET /organizations/{orgId}/integrations`, + `POST /organizations/{orgId}/integrations/jira` (`{siteUrl,email,apiToken}` — verifies against Jira + then stores the token **encrypted**; `409 INTEGRATION_ALREADY_CONNECTED` when one already exists), + `POST /organizations/{orgId}/integrations/{connectionId}/test` (`{ok, accountName?}`), + `DELETE /organizations/{orgId}/integrations/{connectionId}` (`204`), + `GET /organizations/{orgId}/integrations/{connectionId}/jira/projects`, + `GET /organizations/{orgId}/integrations/{connectionId}/jira/issue-types?projectKey=`. + The API token is **never** returned by any response. + - **Project target + push endpoints** (project `INTEGRATION_*` gated): + `GET/PUT/DELETE /projects/{projectId}/integration/jira/target` (single target per project; + `404` when none), `POST /projects/{projectId}/integration/jira/stories/{storyId}/push` + (`{storyId,jiraIssueKey,jiraIssueUrl}`; `409 INTEGRATION_TARGET_NOT_CONFIGURED` when no target), + `POST /projects/{projectId}/integration/jira/stories/push-all` (now an **async job** — see the + "async sync jobs" entry below). All endpoints use header `Api-Version: 1`. + - **Encryption at rest** — AES-256-GCM `AttributeConverter` (random 12-byte IV prepended to the + ciphertext) keyed from `INTEGRATIONS_ENCRYPTION_KEY` (base64 32 bytes); a documented default key is + provided for dev/test so the suite runs without a `.env`. + - **RBAC** — new project permissions `INTEGRATION_READ`, `INTEGRATION_WRITE`, `INTEGRATION_DELETE`, + `INTEGRATION_SYNC` added to the workspace `Permission` catalog. IAM identity/auth is unchanged. + - **Cross-module read** — discovery now publishes a `discovery::api` named interface + (`DiscoveryStoryReadPort` returning value-only `StoryView`s) so the `gateway` module can read user stories + (title/role/action/benefit/priority/story points + Given/When/Then) to render the Jira issue + description as ADF. The story is pushed with the `EXPORTED`-style export flow. + - Migration `V20260708055818__integration_connections.sql` (tenant schema): `integration_connections` + (org-scoped, one active per org+provider) and `project_integration_targets` (project-scoped, one + per project). New error codes: `INTEGRATION_CONNECTION_NOT_FOUND`, `INTEGRATION_ALREADY_CONNECTED`, + `INTEGRATION_TARGET_NOT_CONFIGURED`, `JIRA_PROJECT_NOT_FOUND`, `JIRA_AUTH_FAILED`, + `JIRA_UNREACHABLE`, `JIRA_PUSH_FAILED`, `INTEGRATION_ENCRYPTION_ERROR`. + - **Jira OAuth 2.0 (3LO) as a second credential type** (ADR-0023) — added alongside the API-token + flow, which is unchanged. A `credentialType` (`API_TOKEN` | `OAUTH2`) selects the auth: OAuth uses + bearer auth against `https://api.atlassian.com/ex/jira/{cloudId}/rest/api/3`, API tokens keep basic + auth against `https://{site}/rest/api/3`. **`IntegrationConnectionResponse` now carries + `credentialType`, and `email` is `null` for `OAUTH2` connections** (frontend contract change). No + token or ciphertext is ever returned. + - **New org-admin-gated endpoints** (header `Api-Version: 1`): + `GET /organizations/{orgId}/integrations/jira/oauth/authorize-url` → `{url, state}` (the `state` + is a stateless HMAC-signed token over org+user+expiry+nonce; `501 JIRA_OAUTH_NOT_CONFIGURED` when + OAuth is unconfigured), and + `POST /organizations/{orgId}/integrations/jira/oauth/callback` `{code, state, cloudId?}` — validates + the state, exchanges the code, and either saves an encrypted `OAUTH2` connection (cloudId given or + exactly one accessible site) or returns `200 {sites:[…]}` to choose from (multiple sites), enforcing + the one-active-connection rule (`409 INTEGRATION_ALREADY_CONNECTED`). Authorization codes are + single-use, so the exchanged tokens are cached under the `state` (short TTL) and the site-selection + re-POST completes from the cache without re-exchanging the code. + - **Token handling** — OAuth refresh + access tokens are encrypted at rest with the same AES-256-GCM + `SecretCipher`; before an OAuth call the access token is refreshed if near expiry and the **rotated** + refresh token is persisted (`JIRA_AUTH_FAILED` on refresh failure). + - **Config** (all optional; the app boots when unset): `reqsai.integrations.jira.oauth.client-id`, + `client-secret`, `redirect-uri` (from `JIRA_OAUTH_CLIENT_ID` / `JIRA_OAUTH_CLIENT_SECRET` / + `JIRA_OAUTH_CALLBACK_URL`, defaulting to `${FRONTEND_URL}/settings/integrations/jira/callback` when + unset — matching the `WEB_APP_URL`-derived pattern used by Stripe's checkout return URLs) and a + dedicated `state-secret` (`JIRA_OAUTH_STATE_SECRET`; generate with + `scripts/generate-oauth-state-secret.sh`). + - Migration `V20260708055820__integration_connections_oauth.sql` (tenant, additive): adds `credential_type` + (default `API_TOKEN`), `cloud_id`, `oauth_refresh_ciphertext`, `oauth_access_ciphertext`, + `oauth_access_expires_at`, and relaxes `email` + `secret_ciphertext` to nullable. New error codes: + `JIRA_OAUTH_NOT_CONFIGURED` (501), `JIRA_OAUTH_STATE_INVALID` (400), + `JIRA_OAUTH_EXCHANGE_FAILED` (502). + +### Added (Integrations / Jira — async sync jobs on Spring Batch, `feature/integrations-jira`) + +- **Jira import and push-all now run as background jobs** (ADR-0023). The blocking requests (minutes of + LLM transformations / issue creation) are gone: + - `POST /projects/{projectId}/integration/jira/import` (`{issueKeys?}`) and + `POST /projects/{projectId}/integration/jira/stories/push-all` now answer **`202 Accepted`** with an + `IntegrationJobResponse` snapshot `{id, projectId, jobType: IMPORT|PUSH_ALL, status: + RUNNING|COMPLETED|FAILED, total, processed, succeeded, failed, message, createdAt, finishedAt}`. + `409 INTEGRATION_JOB_ALREADY_RUNNING` when a job of the same type is already RUNNING for the project + (at most one, enforced by a partial unique index). The single-story push and the import preview stay + synchronous. + - **Live progress over STOMP** — every per-item update and the terminal state are broadcast as the full + `IntegrationJobResponse` JSON on **`/topic/projects/{projectId}/integration-jobs`** (JWT-authenticated + CONNECT, as with the other project topics). + - **Reload recovery** — `GET /projects/{projectId}/integration/jira/jobs?active=true` returns the + RUNNING jobs (re-attach the progress banner after a reload); without the flag the most recent ~10 of + any status. `GET .../jobs/{jobId}` returns one job (`404 INTEGRATION_JOB_NOT_FOUND`). Job reads are + gated by `INTEGRATION_READ`; job starts keep `INTEGRATION_SYNC`. + - **Durable state** — new tenant migration `V20260709100000__integration_sync_jobs.sql` + (`integration_sync_jobs`, the domain-facing projection and source of truth for the UI). Import + duplicates count toward `processed` only; the terminal message summarizes them + ("N duplicados omitidos"). A fatal error (e.g. Jira unreachable) marks the job `FAILED` with a message. + - **Engine: Spring Batch 6** — two chunk-oriented jobs (`jiraImportJob`, `jiraPushAllJob`; chunk size 5, + per-item skip policy so one bad item never aborts the run) behind the application-layer + `IntegrationJobLauncher` port. Batch metadata (`BATCH_JOB_INSTANCE`, `BATCH_JOB_EXECUTION`, ...) lives + in the **global `public` schema** (common migration `V20260709100001__spring_batch_metadata.sql`) with + the JobRepository schema-qualified via table prefix `public.BATCH_` — immune to the per-tenant + `search_path` routing. The caller's tenant is captured into job parameters and restored around the + execution. `spring.batch.job.enabled=false` (jobs run only on API demand). + +### Changed (Workspace — `feature/integrations-jira`) + +- **Org admins may now view organization general settings** — `GET /organizations/{orgId}` is now gated + by `@authz.orgOwnerOrAdmin` instead of `@authz.orgOwner`, so an organization **admin** receives `200` + when reading the org (previously `403`). Editing stays owner-only: `PATCH /organizations/{orgId}`, + `POST /organizations/{orgId}/transfer-ownership`, and `DELETE /organizations/{orgId}` remain + `@authz.orgOwner`. Non-members still receive `403` on the GET. + ### Added (Backlog / Glossary / Constraints listing — `feature/discovery-session-control`) - **User-story backlog list filters + search** — `GET /projects/{projectId}/stories` now accepts five diff --git a/README.md b/README.md index de43aff5..5357aae9 100644 --- a/README.md +++ b/README.md @@ -167,6 +167,7 @@ Detalle en [`docs/DEPLOYMENT.md`](./docs/DEPLOYMENT.md). | [`docs/LOCAL_AI.md`](./docs/LOCAL_AI.md) | IA local↔nube (LLM, embeddings, STT) — Mac/Win/Linux | | [`docs/DEPLOYMENT.md`](./docs/DEPLOYMENT.md) | Despliegue (Docker, AWS ECS Fargate, CI/CD) | | [`docs/MIGRATIONS.md`](./docs/MIGRATIONS.md) | Cómo crear migraciones Flyway (`scripts/new-migration.sh`) | +| [`docs/JIRA_INTEGRATION.md`](./docs/JIRA_INTEGRATION.md) | Integración Jira: permisos, callback, secretos, uso | | [`.github/CONTRIBUTING.md`](./.github/CONTRIBUTING.md) | Flujo de trabajo, build, tests, ramas, commits | | [`CHANGELOG.md`](./CHANGELOG.md) | Historial de cambios (Keep a Changelog) | | [`AUTHORS.md`](./AUTHORS.md) · [`CONTRIBUTORS.md`](./CONTRIBUTORS.md) · [`ACKNOWLEDGMENTS.md`](./ACKNOWLEDGMENTS.md) | Equipo y créditos | diff --git a/build.gradle.kts b/build.gradle.kts index 33b4c714..8a5e8b2d 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -63,6 +63,7 @@ dependencies { // DATA + DB // ================================== implementation("org.springframework.boot:spring-boot-starter-data-jpa") + implementation("org.springframework.boot:spring-boot-starter-batch") implementation("org.springframework.boot:spring-boot-starter-flyway") implementation("org.flywaydb:flyway-database-postgresql") implementation("org.hibernate.orm:hibernate-vector") diff --git a/docs/JIRA_INTEGRATION.md b/docs/JIRA_INTEGRATION.md new file mode 100644 index 00000000..4cfbeb13 --- /dev/null +++ b/docs/JIRA_INTEGRATION.md @@ -0,0 +1,166 @@ +# Jira integration — setup guide + +Reqs-AI can push **user stories to Jira Cloud** as issues. The integration lives in the **`gateway`** +bounded context and is designed to be provider-extensible (Jira is the first provider). See +[ADR-0023](adr/0023-third-party-integrations-jira.md) for the design rationale. + +- **Connection is organization-level** — credentials are stored once per org, encrypted at rest. +- **Push target is project-level** — each project picks which Jira project + issue type its stories go to. +- **Two ways to connect**, pick either (both can coexist): + +| Method | User experience | What you must set up | Best for | +|---------------------|-----------------------------------------------|---------------------------------------------------------|------------------------------------------------| +| **OAuth 2.0 (3LO)** | One click "Connect with Atlassian", no typing | Register an Atlassian app (Client ID/Secret + callback) | The recommended, smoothest flow | +| **API token** | Paste site URL + email + API token | Nothing server-side; each user creates a token | Quick start, or when you can't register an app | + +> The Jira API token / OAuth tokens are **never** stored in the browser and never returned by the API. +> They are encrypted at rest with AES-256-GCM. + +--- + +## 1. Generate the backend secrets + +Two secrets are needed. They are **different values in different formats** — do not mix them up. + +| Env var | Purpose | Format | Script | +|-------------------------------|------------------------------------------|------------------------------------------------------------|------------------------------------------| +| `INTEGRATIONS_ENCRYPTION_KEY` | Encrypt stored credentials (AES-256-GCM) | **base64**, decodes to 32 bytes (~44 chars, ends with `=`) | `scripts/generate-encryption-key.sh` | +| `JIRA_OAUTH_STATE_SECRET` | Sign the OAuth `state` (HMAC-SHA256) | **hex**, 64 chars (`0-9 a-f`) | `scripts/generate-oauth-state-secret.sh` | + +Run them in **Git Bash** (they use `openssl`, bundled with Git for Windows): + +```bash +bash scripts/generate-encryption-key.sh # prints INTEGRATIONS_ENCRYPTION_KEY=... +bash scripts/generate-oauth-state-secret.sh # prints JIRA_OAUTH_STATE_SECRET=... +``` + +Each prints one line to the terminal — copy it into your `.env`. Nothing is written to the repo. + +> **Common mistake:** using the hex value for `INTEGRATIONS_ENCRYPTION_KEY`. A 64-char hex string +> base64-decodes to 48 bytes, and the app fails at startup with +> `INTEGRATIONS_ENCRYPTION_KEY must decode to 32 bytes (AES-256), got 48`. The encryption key **must** +> come from `generate-encryption-key.sh` (base64). Verify with: +> `echo -n "" | base64 -d | wc -c` → must print `32`. + +`INTEGRATIONS_ENCRYPTION_KEY` is **required** to run the integration (dev/test carry a default in +`application-dev.yml` / `application-test.yml`). The OAuth vars below are **optional** — without them the +API-token flow still works and the "Connect with Atlassian" button reports *not configured*. + +--- + +## 2. Register the Atlassian OAuth app (only for the OAuth method) + +1. Go to **https://developer.atlassian.com/console/myapps** → **Create → OAuth 2.0 integration**. +2. **Name:** e.g. `ReqsAI`. **Access type:** **Resource-level** (least privilege — only the site the + user selects during authorization). Accept the terms → **Create**. +3. **Permissions → Add → Jira API**, and add these scopes (must match what the backend requests): + + ``` + read:jira-work write:jira-work read:jira-user read:me + ``` + + > `offline_access` is **not** added here — the backend requests it at login time to obtain a refresh + > token. The full scope string the backend sends is + > `read:jira-work write:jira-work read:jira-user offline_access read:me`. If the console has *fewer* + > scopes than this, Atlassian rejects authorization with *invalid scope*. + +4. **Authorization → OAuth 2.0 (3LO) → Configure** → set the **Callback URL** (see §3). Save. +5. **Settings → copy the Client ID and Secret** → these become `JIRA_OAUTH_CLIENT_ID` / + `JIRA_OAUTH_CLIENT_SECRET`. +6. Leave **Distribution = private** for development (only your Atlassian account can authorize it). + Switch to *Sharing* only when other organizations must connect their own Jira (production) — that + step asks for vendor name, privacy policy and a personal-data declaration. + +### The Callback URL + +It is **not** something Atlassian gives you — **you define it**, and it must match, character for +character, both the Atlassian app's *Authorization → Callback URL* and the backend's +`JIRA_OAUTH_CALLBACK_URL`. It is the frontend route that receives the OAuth redirect: + +| Environment | Callback URL | +|-------------|--------------------------------------------------------------------| +| Local dev | `http://localhost:4200/settings/integrations/jira/callback` | +| Production | `https://YOUR-FRONTEND-DOMAIN/settings/integrations/jira/callback` | + +You may register **multiple** callback URLs (one per line, up to 30). Order does not matter — the +backend sends the exact `redirect_uri` for its environment, and Atlassian only requires it to be in the +list. If it doesn't match, Atlassian errors with `redirect_uri mismatch`. + +--- + +## 3. Configure `.env` + +Add these to the backend `.env` (see `.env.example` for the annotated block): + +```dotenv +# Required for the integration (base64, 32 bytes) +INTEGRATIONS_ENCRYPTION_KEY= + +# Optional — only for the OAuth "Connect with Atlassian" flow +JIRA_OAUTH_CLIENT_ID= +JIRA_OAUTH_CLIENT_SECRET= +JIRA_OAUTH_CALLBACK_URL=http://localhost:4200/settings/integrations/jira/callback +JIRA_OAUTH_STATE_SECRET= +``` + +These map to `reqsai.integrations.encryption-key` and `reqsai.integrations.jira.oauth.*`. Restart the +backend after editing `.env`. + +--- + +## 4. Alternative: the API-token method (no app registration) + +Each user creates a personal API token: + +1. Go to **https://id.atlassian.com/manage-profile/security/api-tokens** → **Create API token**. +2. In Reqs-AI → **Org Settings → Integrations → Jira**, expand *"use an API token"* and enter: + - **Site URL** — `https://your-space.atlassian.net` + - **Email** — the Atlassian account email that owns the token + - **API token** — the value you just created (sent encrypted, never shown again) + +--- + +## 5. Use it + +1. **Connect** (once per org): Org Settings → **Integrations** → *Connect with Atlassian* (OAuth) or the + API-token form. On multi-site Atlassian accounts you pick the site. +2. **Map** (per project): Project Settings → **Integrations** → choose the Jira project + issue type. +3. **Push**: from a story's detail (*Push to Jira*, synchronous) or the backlog (*Push all to Jira*). + The story title becomes the issue summary; the description carries role/action/benefit + acceptance + criteria (Given/When/Then). +4. **Import**: from the backlog, preview the eligible Jira issues (duplicates flagged) and import them + as user stories (LLM mapping + dedup). + +**Push-all and import run as background jobs** (Spring Batch): the POST returns `202` with a job +snapshot, progress streams on `/topic/projects/{projectId}/integration-jobs`, and after a reload the +client recovers via `GET .../integration/jira/jobs?active=true` (or `.../jobs/{jobId}`). At most one +running job per type and project (`409 INTEGRATION_JOB_ALREADY_RUNNING`). See ADR-0023, "Async sync +jobs on Spring Batch". + +Actions are RBAC-gated by new permissions: `INTEGRATION_READ`, `INTEGRATION_WRITE`, `INTEGRATION_DELETE`, +`INTEGRATION_SYNC`. Org-level connection management requires org owner/admin. + +--- + +## Troubleshooting + +| Symptom | Cause & fix | +|---------------------------------------------------------|-----------------------------------------------------------------------------------------------------------------------------| +| Startup: `...must decode to 32 bytes (AES-256), got 48` | `INTEGRATIONS_ENCRYPTION_KEY` holds a hex value. Regenerate with `scripts/generate-encryption-key.sh` (base64). | +| Button disabled / `JIRA_OAUTH_NOT_CONFIGURED` | OAuth env vars are missing/blank. Set `JIRA_OAUTH_CLIENT_ID/SECRET/CALLBACK_URL` and restart. | +| Atlassian: `redirect_uri mismatch` | The app's Callback URL ≠ `JIRA_OAUTH_CALLBACK_URL`. Make them identical. | +| Atlassian: `invalid scope` | The console has fewer scopes than the backend requests. Add all of `read:jira-work write:jira-work read:jira-user read:me`. | +| `JIRA_AUTH_FAILED` on connect/push | Bad API token/email, expired OAuth grant, or the token owner lacks access to the Jira project. | +| `INTEGRATION_TARGET_NOT_CONFIGURED` on push | No Jira target set for the project — configure it in Project Settings → Integrations. | + +--- + +## Reference + +- **Design:** [ADR-0023](adr/0023-third-party-integrations-jira.md) +- **Module:** `com.kntro.reqsai.gateway` +- **Migrations:** tenant — connections, targets, OAuth columns, `integration_sync_jobs`; common — + Spring Batch metadata in `public` (`V20260709100001__spring_batch_metadata.sql`) +- **Config keys:** `reqsai.integrations.encryption-key`, `reqsai.integrations.jira.oauth.{client-id,client-secret,redirect-uri,state-secret}`, `spring.batch.job.enabled=false` +- **Endpoints:** `/api/organizations/{orgId}/integrations*` (connection, OAuth authorize-url/callback), `/api/projects/{projectId}/integration/jira*` (target, story push, import preview/import, sync jobs) +- **Realtime:** `/topic/projects/{projectId}/integration-jobs` (job progress snapshots) diff --git a/docs/REALTIME.md b/docs/REALTIME.md index a25905f7..bd076d28 100644 --- a/docs/REALTIME.md +++ b/docs/REALTIME.md @@ -73,9 +73,20 @@ client.activate(); 1. The HTTP handshake to `/ws/**` is **permitted** in `SecurityConfiguration` (no token yet). 2. The client sends `Authorization: Bearer ` as a native STOMP header on **CONNECT**. 3. `StompAuthChannelInterceptor` verifies it with the same `TokenVerifier` as the HTTP filter and binds - the user `Principal` to the session — so `/user/**` queues and per-message security work. + the user `Principal` to the CONNECT frame's accessor, and — separately — stashes `userId`/`orgId` in + the STOMP **session attributes**. 4. CONNECT with no token → anonymous; with an invalid token → rejected (the verifier throws). +> **The `Principal` does not persist past CONNECT.** Empirically (verified against a running instance), +> a later frame's `accessor.getUser()` on the same STOMP session comes back `null` — only the session +> attributes carry over to every subsequent frame. Anything that needs the caller's identity outside the +> CONNECT handler itself (a session-lifecycle listener, a future `@MessageMapping` handler) must read +> `StompAuthChannelInterceptor.USER_ID_ATTRIBUTE`/`ORG_ID_ATTRIBUTE` from `accessor.getSessionAttributes()`, +> not `accessor.getUser()`. This is why presence resolves identity this way (see below). `sendToUser` +> is expected to be unaffected — Spring resolves it via a username registry populated from the CONNECT +> frame itself, not by re-reading `accessor.getUser()` on later frames — but it has no caller in this +> codebase yet, so that has not been directly exercised. + ## Destination prefixes | Prefix | Direction | Use | @@ -84,6 +95,27 @@ client.activate(); | `/topic` | server → clients | broadcast (many subscribers) | | `/user` | server → one user | per-user queue (`sendToUser`, resolved per principal) | +## Live presence (who is viewing a session) + +Discovery tracks who is currently viewing a **live** session and broadcasts the roster so every +participant sees the others. It is built entirely on the STOMP lifecycle — there is **no** extra +subscription and **no** client→server message: + +- **Signal:** a client's SUBSCRIBE to `/topic/sessions/{id}` *is* "I am present". `SessionPresenceTracker` + listens to `SessionSubscribeEvent` / `SessionUnsubscribeEvent` / `SessionDisconnectEvent`. +- **State:** `SessionPresenceRegistry` holds, per session, which connections are present (a user across + two tabs counts once). It is in-process — **not** Redis — and, like the `SIMPLE` broker, per-JVM. +- **Broadcast:** on any real roster change the tracker sends a `PRESENCE_STATE` message + (`SessionPresenceMessage`: full participant snapshot + `count`) back on the same `sessions/{id}` topic. + The client keeps its one subscription and switches on `type` (see the single-topic pattern below). +- **Identity:** the CONNECT interceptor stashes `userId` and `orgId` in the STOMP session attributes + (see the callout above — not the frame `Principal`); the tracker resolves each `userId` to a display + name via `WorkspaceModuleApi.findMemberDisplayName` (Caffeine-cached) and a deterministic `avatarUrl` + (`/api/users/{userId}/avatar`). + +> Multi-instance caveat: because the registry is per-JVM (same as the `SIMPLE` broker), a global roster +> across several ECS tasks needs the shared `RELAY` broker's state — acceptable at single-instance scale. + ## Scaling: SIMPLE vs. RELAY (important for ECS) The default **`SIMPLE`** broker is in-memory and only knows connections on the **local JVM**. With more diff --git a/docs/adr/0023-third-party-integrations-jira.md b/docs/adr/0023-third-party-integrations-jira.md new file mode 100644 index 00000000..b21b80df --- /dev/null +++ b/docs/adr/0023-third-party-integrations-jira.md @@ -0,0 +1,238 @@ +# 0023. Extensible third-party integrations, first provider Jira Cloud + +- Status: Accepted +- Date: 2026-07-06 +- Deciders: Kntro-Soft team + +## Context + +Teams that run discovery in Reqs-AI keep their delivery backlog in an external tracker. The first and +most requested target is **Jira Cloud**: an analyst approves user stories in Reqs-AI and wants to push +them into a Jira project as issues without re-typing them. The `UserStory` review lifecycle already +has an `EXPORTED` terminal state described as _"Pushed to an external tracker (e.g. Jira)"_, so the +domain anticipated this. + +We want the design to generalize beyond Jira (Azure DevOps, Linear, GitHub Issues, …) rather than +bolt a one-off Jira client onto an existing context, and we want to keep the credential and the +push concern out of `discovery` and `workspace` — they are a distinct capability with their own +lifecycle, error surface and RBAC. + +Forces: + +- **Where do credentials live vs. where does a push target live?** A Jira site + API token is an + organization-wide asset (billed once, administered by an org admin). A Jira *project key* and + *issue type* are per Reqs-AI-project routing decisions any project writer makes. These have + different owners, different RBAC and different lifecycles. +- **Auth will evolve.** Jira Cloud supports both an **API token / basic auth** (simplest, works today, + no app registration) and **OAuth 2.0 (3LO)**. We ship the token flow now but must not paint + ourselves into a corner that blocks OAuth later. +- **Secrets at rest.** A Jira API token is a bearer credential. It must be encrypted in the tenant + database, never logged, and never returned by any endpoint. +- **Module boundaries must hold.** The backend is a Spring Modulith modular monolith (ADR-0002) with + schema-per-tenant multitenancy (ADR-0003); ArchUnit + `verifyModularity` (ADR-0019) enforce that + cross-module talk only happens through named interfaces. Reading user stories to push them must go + through a published discovery interface, not into discovery internals. +- **Identity/auth is out of scope.** This feature must not touch IAM's account/authn model. It only + adds project-scoped RBAC permissions to the existing workspace `Permission` catalog. + +## Decision + +### Housed in the reserved `gateway` bounded context + +The integration lives in `com.kntro.reqsai.gateway`, the Spring Modulith application module reserved +for external integrations (`@ApplicationModule(allowedDependencies = {"shared", "workspace::api", +"discovery::api"})`), with the usual hexagonal layers (`domain`, `application`, `infrastructure`, +`interfaces`). It depends on `workspace::api` for org/project authorization context and on a new +`discovery::api` named interface for reading the stories it pushes. The Jira feature keeps its own +domain vocabulary (`IntegrationConnection`, `ProjectIntegrationTarget`, etc.) — those name the +concept, while `gateway` names the module. + +### Org-level connection, project-level target (the split) + +Two aggregates, two scopes: + +- **`IntegrationConnection` (organization-scoped)** — one row in `integration_connections` per + `(organization_id, provider)`. Holds the provider (`JIRA`), the Jira `site_url`, the account + `email`, the **encrypted** API token (`secret_ciphertext BYTEA`), a `status` + (`CONNECTED`/`DISCONNECTED`) and `last_verified_at`. A **partial unique index** + (`WHERE status <> 'DISCONNECTED'`) enforces **at most one active connection per org per provider**. + Managed by org admins. +- **`ProjectIntegrationTarget` (project-scoped)** — one row in `project_integration_targets` per + project (see the uniqueness decision below). References a `connection_id`, plus the Jira + `jira_project_key` and `issue_type_name` chosen for that Reqs-AI project. Managed by project + writers. + +This mirrors the real ownership: credentials are administered once at the top; routing is decided +per project by the people who own that project. + +**Uniqueness choice — one target per project.** `project_integration_targets` is uniquely indexed on +`project_id` alone (`uq_project_integration_targets_project`), *not* on +`(project_id, connection_id)`. A Reqs-AI project pushes to exactly one Jira destination at a time; the +`PUT .../target` endpoint is an upsert that replaces the single target. This keeps the push path +unambiguous (no "which target?" question) and matches the locked REST contract, which exposes a +singular `/integration/jira/target` resource. Re-pointing a project at a different connection or Jira +project is a `PUT` overwrite. + +### Provider/adapter pattern (`IntegrationProvider` port + `JiraProvider`) + +The push/verify capability is expressed as an `IntegrationProvider` port in the application layer. +`JiraProvider` is the first (and currently only) implementation; it delegates the raw HTTP to a +`JiraClient` RestClient adapter in `infrastructure/jira`. Adding Azure DevOps later means adding an +`AzureDevOpsProvider` selected by the connection's `provider` value — no change to the handlers, the +endpoints or the aggregates. The `JiraClient` mirrors the existing `AssemblyAiAdapter` RestClient +style (per-call `RestClient`, typed response records, status→exception mapping). + +### API token and OAuth 2.0 (3LO), side by side + +Authentication started as Jira **basic auth with an API token**: +`Authorization: Basic base64(email:token)`, base URL `https://{site}/rest/api/3/...`. The credential +abstraction (`IntegrationConnection` carrying an encrypted secret + the `IntegrationProvider` seam) +is deliberately auth-mechanism-agnostic, and we have now added **OAuth 2.0 (3LO)** alongside it — the +API-token flow is unchanged. + +**Update (OAuth 2.0 3LO shipped).** A `credentialType` discriminator (`API_TOKEN` | `OAUTH2`) selects +the credential shape on the same `integration_connections` table (migration `V23`, additive): OAuth +rows carry the Atlassian `cloud_id` and the **encrypted** refresh/access tokens (`oauth_refresh_ciphertext` +/ `oauth_access_ciphertext`, same `SecretCipher`/`AesGcmCipher` as the API token) plus +`oauth_access_expires_at`, while `email` + `secret_ciphertext` are relaxed to nullable and left empty. +Exactly one shape is populated per `credentialType` (app-enforced). The **same** `JiraProvider`/`JiraClient` +serve both modes: the base URL + `Authorization` header are chosen per call — `https://{site}/rest/api/3` ++ `Basic` for API tokens, `https://api.atlassian.com/ex/jira/{cloudId}/rest/api/3` + `Bearer` for OAuth. +Before an OAuth call, `JiraOAuthTokenService` refreshes the access token if it is expired/near-expiry and +persists the **rotated** tokens (a refresh failure surfaces as `JIRA_AUTH_FAILED`). + +Two new org-admin-gated endpoints drive the flow, keyed off a **stateless HMAC-signed `state`** (over +org + user + short expiry + nonce, using a dedicated `JIRA_OAUTH_STATE_SECRET`) so nothing is stored to +survive the browser redirect: +`GET /organizations/{orgId}/integrations/jira/oauth/authorize-url` → `{url, state}`, and +`POST /organizations/{orgId}/integrations/jira/oauth/callback` `{code, state, cloudId?}` which validates +the state, exchanges the code, and either saves an `OAUTH2` connection (cloudId given or exactly one +accessible site) or returns the site list to choose from (multiple sites). Because authorization codes +are **single-use**, the callback caches the exchanged tokens + discovered sites under the `state` (short +in-memory TTL) so the follow-up site-selection POST completes from the cache without re-exchanging the +already-consumed code. OAuth config is **optional**: when the client id/secret/redirect are absent the +app still boots and the endpoints answer `JIRA_OAUTH_NOT_CONFIGURED` (501) so the UI disables the button. +New error codes: `JIRA_OAUTH_NOT_CONFIGURED` (501), `JIRA_OAUTH_STATE_INVALID` (400), +`JIRA_OAUTH_EXCHANGE_FAILED` (502). `IntegrationConnectionResponse` now carries `credentialType`, and +`email` is `null` for OAuth connections; no token or ciphertext is ever returned. + +### Encryption at rest (AES-256-GCM) + +There is no existing encryption utility, so we add one: a JPA `AttributeConverter` +(`EncryptedStringConverter`) backed by an `AesGcmCipher`. It encrypts the token with **AES-256-GCM**, +a random **12-byte IV per value prepended to the ciphertext** (`IV || ciphertext+tag`), keyed from +`INTEGRATIONS_ENCRYPTION_KEY` (base64-encoded 32 bytes) bound via `application.yml` +`${INTEGRATIONS_ENCRYPTION_KEY:}`. For local/dev and tests a documented default 32-byte test key is +provided through `application-test.yml` so the suite runs without a `.env`. The token is decrypted +only inside `JiraProvider` when building the auth header; it is never logged and never leaves the +backend in any response. Cipher failures raise `INTEGRATION_ENCRYPTION_ERROR` (500). + +### Reading discovery stories through a published interface + +Integrations needs the story's title/role/action/benefit/priority/story-points and its Given/When/Then +acceptance criteria to build the Jira issue. Discovery exposes a new **`discovery::api`** named +interface with a `DiscoveryStoryReadPort` returning value-only `StoryView` / `AcceptanceCriterionView` +records (no JPA entities cross the boundary), implemented inside discovery over its existing +`UserStoryRepository`. Integrations consumes only that interface; `verifyModularity` and the ArchUnit +fitness functions stay green. + +### RBAC — new project permissions only + +The workspace `Permission` catalog gains `INTEGRATION_READ`, `INTEGRATION_WRITE`, `INTEGRATION_DELETE` +and `INTEGRATION_SYNC`, wired into the default role permission sets exactly like the existing +resources. **Project** endpoints (target read/write/delete, story push) are gated with +`@authz.projectPermission(...)`. **Org connection** endpoints are gated with the existing org-admin +path (`@authz.orgOwnerOrAdmin(#orgId, authentication)`) — administering an org-wide credential is an +org-admin action, not a project permission. IAM identity/authn is untouched. + +### Async sync jobs on Spring Batch (import / push-all) + +Import (dozens of issues × one LLM transformation each) and push-all take minutes; a blocking HTTP +request locks the UI and a page reload loses everything. Both operations therefore run as +**background jobs**: `POST .../import` and `POST .../stories/push-all` answer **202 Accepted** +immediately with an `IntegrationJobResponse` snapshot, progress streams over STOMP, and the state +survives reloads. The execution engine is **Spring Batch**, chosen over a hand-rolled `@Async` +worker because it gives us, out of the box, a persistent execution ledger, chunked transactions, +a declarative per-item skip policy, and an operational vocabulary (job/step/execution) that any +Spring developer can read. + +**Spring Batch in one paragraph.** A *Job* is a named, parameterized unit of work. Launching a job +with a set of *identifying* `JobParameters` creates a *JobInstance* (the logical run: "import for +domain job X"); every attempt at an instance is a *JobExecution* (rows in the `BATCH_*` metadata +tables, written by the *JobRepository*). A job is a sequence of *Steps*; our jobs have exactly one +**chunk-oriented step**, which reads items one at a time (*ItemReader*), transforms them +(*ItemProcessor*), and commits a transaction every N items (chunk size 5) — bounding transaction +size and, in restartable designs, lost work. `faultTolerant().skip(Exception)` makes a throwing +item a *skip* (counted, logged, execution continues) instead of a failure — exactly the +per-item-failure semantics the old synchronous endpoints had. We deliberately do **not** use Batch +restartability (each API launch is a fresh JobInstance keyed by the domain job UUID): a half-done +import is re-run safely because the discovery dedup skips already-imported stories. + +The topology (all in `gateway.infrastructure.batch` — the engine is an infrastructure detail behind +the application's `IntegrationJobLauncher` port; handlers and REST contract never see Batch types): + +- `jiraImportJob` / `jiraPushAllJob`, one chunk step each. Step-scoped readers resolve the work list + up front (Jira fetch / story list) and fix the projection's `total`; processors delegate one item + to the *existing* `JiraImportService` / `StoryPushService`; the writer is a no-op (services own + their side effects). +- **Tenant propagation**: the launcher captures `TenantContext` (tenant id + schema) on the request + thread into *non-identifying job parameters*; a `JobExecutionListener` restores it in `beforeJob` + and clears it in `afterJob` — the same snapshot-then-restore pattern as + `TenantAwareModuleListener`. The whole execution runs on one executor thread, so every Hibernate + session in the job resolves the caller's schema. +- **Batch metadata lives in `public`** (`V20260709100001__spring_batch_metadata.sql`, common + migration) with the JobRepository configured with table prefix **`public.BATCH_`** + (`shared/.../BatchConfiguration extends JdbcDefaultBatchConfiguration`). Rationale: the single + DataSource rewrites `search_path` per tenant, so unqualified `BATCH_*` SQL could land in an + arbitrary tenant schema; qualifying every metadata query makes it immune to whatever + `search_path` a pooled connection carries. Batch metadata is operational, org-agnostic data — + global like `public.organizations`. (Boot 4 / Batch 6 default to an in-memory "resourceless" + JobRepository; subclassing `JdbcDefaultBatchConfiguration` is the opt-in to durable JDBC metadata, + and `spring.batch.job.enabled=false` stops Boot replaying jobs at startup.) +- **Domain projection, not `BATCH_*` exposure**: the API reads/writes the per-tenant + `integration_sync_jobs` row (`{id, projectId, jobType, status, total, processed, succeeded, + failed, message, createdAt, finishedAt}`), updated per item by step listeners and finalized in + `afterJob`. The projection is tenant-scoped, queryable per project, and keeps the REST/STOMP + contract stable even if the engine changes; the `BATCH_*` tables stay an internal ledger + (1:1 linked via the identifying `domainJobId` parameter). +- **Realtime + recovery**: every counter update is broadcast as the full job snapshot on + `/topic/projects/{projectId}/integration-jobs` (same JSON as `IntegrationJobResponse`); a reloaded + client re-attaches via `GET .../jobs?active=true` and `GET .../jobs/{jobId}` — the durable row is + the source of truth, STOMP is only the push channel. +- **Concurrency**: at most one RUNNING job per (project, type), enforced in the application layer + (pre-check + partial unique index backstop → 409 `INTEGRATION_JOB_ALREADY_RUNNING`) — Batch's + JobInstance uniqueness is not used for this rule. +- Import duplicates count toward `processed` only (neither `succeeded` nor `failed`); the terminal + message summarizes them ("N duplicados omitidos"). A fatal error (e.g. Jira unreachable in the + reader) fails the step, and `afterJob` marks the projection FAILED with the first failure message. +- The single-story push and the import preview remain synchronous (fast, no job). + +### Error surface + +- Domain: `IntegrationsError` — `INTEGRATION_CONNECTION_NOT_FOUND` (404), + `INTEGRATION_ALREADY_CONNECTED` (409), `INTEGRATION_TARGET_NOT_CONFIGURED` (409), + `INTEGRATION_JOB_ALREADY_RUNNING` (409), `INTEGRATION_JOB_NOT_FOUND` (404), + `JIRA_PROJECT_NOT_FOUND` (404), `JIRA_OAUTH_NOT_CONFIGURED` (501), `JIRA_OAUTH_STATE_INVALID` (400). +- Infrastructure: `IntegrationsInfrastructureError` — `JIRA_AUTH_FAILED` (401), + `JIRA_UNREACHABLE` (502), `JIRA_PUSH_FAILED` (502), `INTEGRATION_ENCRYPTION_ERROR` (500), + `JIRA_OAUTH_EXCHANGE_FAILED` (502). + +Both are `ErrorCatalog` enums auto-mapped by the shared `GlobalExceptionHandler`; infrastructure +errors never leak the token or the internal cause to the client. + +## Consequences + +- Positive: credentials and routing sit at their natural owners; the provider seam and the + auth-mechanism-agnostic credential make Azure DevOps / OAuth additive; the token is encrypted at + rest and never exposed; discovery is read through a boundary-checked interface; the existing + `EXPORTED` story state is finally reachable. +- Trade-off: one active connection per org per provider and one target per project are intentional + simplifications for the first release; multi-connection / multi-target routing can be revisited by + relaxing the two unique indexes without a shape change to the endpoints. +- Trade-off: a symmetric AES-GCM key in config means key rotation is a manual re-encrypt for now; a + KMS-backed key can replace the converter's key source later without touching the model. +- The push-all and import jobs capture per-item failures (skip policy) and continue, so one bad + story/issue never aborts the rest; a stuck modal is gone — the UI follows the job row. +- Trade-off: Spring Batch adds metadata tables and a learning curve, but buys a durable execution + ledger, chunked transactions and skip/retry semantics we would otherwise reimplement; the engine + stays swappable behind the `IntegrationJobLauncher` port. diff --git a/docs/adr/README.md b/docs/adr/README.md index 24cc142d..62f500d5 100644 --- a/docs/adr/README.md +++ b/docs/adr/README.md @@ -32,6 +32,7 @@ one (and update the old one's status). | [0020](./0020-global-search-postgres-trigram.md) | Global search with Postgres trigram lexical matching | Accepted | | [0021](./0021-organization-invitations.md) | Organization invitations with tokenized email trust | Accepted | | [0022](./0022-flyway-timestamp-based-migration-versions.md) | Timestamp-based Flyway migration versions | Accepted | +| [0023](./0023-third-party-integrations-jira.md) | Extensible third-party integrations, first Jira Cloud | Accepted | ## Template diff --git a/ecs/task-definition.json b/ecs/task-definition.json index eb905f72..a7a76e23 100644 --- a/ecs/task-definition.json +++ b/ecs/task-definition.json @@ -17,6 +17,26 @@ "name": "CORS_ALLOWED_ORIGINS", "value": "https://app.tamci.app" }, + { + "name": "WEB_APP_URL", + "value": "https://app.tamci.app" + }, + { + "name": "BILLING_PAYMENT_PROVIDER", + "value": "stripe" + }, + { + "name": "BILLING_CURRENCY", + "value": "USD" + }, + { + "name": "BILLING_PRO_STRIPE_PRICE_ID", + "value": "price_1TrSiGDRW48WB7COzoE13Ius" + }, + { + "name": "BILLING_ENTERPRISE_STRIPE_PRICE_ID", + "value": "price_1TrSikDRW48WB7COSEMwH3xf" + }, { "name": "SPRINGDOC_API_DOCS_ENABLED", "value": "true" @@ -124,6 +144,30 @@ { "name": "OPENAI_API_KEY", "valueFrom": "arn:aws:secretsmanager:us-east-1:418272789689:secret:reqsai/production/ai-GTSPn8:openai_api_key::" + }, + { + "name": "INTEGRATIONS_ENCRYPTION_KEY", + "valueFrom": "arn:aws:secretsmanager:us-east-1:418272789689:secret:reqsai/production/jira-JsDzh5:encryption_key::" + }, + { + "name": "JIRA_OAUTH_CLIENT_ID", + "valueFrom": "arn:aws:secretsmanager:us-east-1:418272789689:secret:reqsai/production/jira-JsDzh5:oauth_client_id::" + }, + { + "name": "JIRA_OAUTH_CLIENT_SECRET", + "valueFrom": "arn:aws:secretsmanager:us-east-1:418272789689:secret:reqsai/production/jira-JsDzh5:oauth_client_secret::" + }, + { + "name": "JIRA_OAUTH_STATE_SECRET", + "valueFrom": "arn:aws:secretsmanager:us-east-1:418272789689:secret:reqsai/production/jira-JsDzh5:oauth_state_secret::" + }, + { + "name": "STRIPE_API_KEY", + "valueFrom": "arn:aws:secretsmanager:us-east-1:418272789689:secret:reqsai/production/stripe-GUmpLb:api_key::" + }, + { + "name": "STRIPE_WEBHOOK_SECRET", + "valueFrom": "arn:aws:secretsmanager:us-east-1:418272789689:secret:reqsai/production/stripe-GUmpLb:webhook_secret::" } ], "logConfiguration": { diff --git a/gradlew.bat b/gradlew.bat index 8508ef68..a51ec4f5 100644 --- a/gradlew.bat +++ b/gradlew.bat @@ -1,82 +1,82 @@ -@rem -@rem Copyright 2015 the original author or authors. -@rem -@rem Licensed under the Apache License, Version 2.0 (the "License"); -@rem you may not use this file except in compliance with the License. -@rem You may obtain a copy of the License at -@rem -@rem https://www.apache.org/licenses/LICENSE-2.0 -@rem -@rem Unless required by applicable law or agreed to in writing, software -@rem distributed under the License is distributed on an "AS IS" BASIS, -@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -@rem See the License for the specific language governing permissions and -@rem limitations under the License. -@rem -@rem SPDX-License-Identifier: Apache-2.0 -@rem - -@if "%DEBUG%"=="" @echo off -@rem ########################################################################## -@rem -@rem gradlew startup script for Windows -@rem -@rem ########################################################################## - -@rem Set local scope for the variables, and ensure extensions are enabled -setlocal EnableExtensions - -set DIRNAME=%~dp0 -if "%DIRNAME%"=="" set DIRNAME=. -@rem This is normally unused -set APP_BASE_NAME=%~n0 -set APP_HOME=%DIRNAME% - -@rem Resolve any "." and ".." in APP_HOME to make it shorter. -for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi - -@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. -set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m" - -@rem Find java.exe -if defined JAVA_HOME goto findJavaFromJavaHome - -set JAVA_EXE=java.exe -%JAVA_EXE% -version >NUL 2>&1 -if %ERRORLEVEL% equ 0 goto execute - -echo. 1>&2 -echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2 -echo. 1>&2 -echo Please set the JAVA_HOME variable in your environment to match the 1>&2 -echo location of your Java installation. 1>&2 - -"%COMSPEC%" /c exit 1 - -:findJavaFromJavaHome -set JAVA_HOME=%JAVA_HOME:"=% -set JAVA_EXE=%JAVA_HOME%/bin/java.exe - -if exist "%JAVA_EXE%" goto execute - -echo. 1>&2 -echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2 -echo. 1>&2 -echo Please set the JAVA_HOME variable in your environment to match the 1>&2 -echo location of your Java installation. 1>&2 - -"%COMSPEC%" /c exit 1 - -:execute -@rem Setup the command line - - - -@rem Execute gradlew -@rem endlocal doesn't take effect until after the line is parsed and variables are expanded -@rem which allows us to clear the local environment before executing the java command -endlocal & "%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -jar "%APP_HOME%\gradle\wrapper\gradle-wrapper.jar" %* & call :exitWithErrorLevel - -:exitWithErrorLevel -@rem Use "%COMSPEC%" /c exit to allow operators to work properly in scripts -"%COMSPEC%" /c exit %ERRORLEVEL% +@rem +@rem Copyright 2015 the original author or authors. +@rem +@rem Licensed under the Apache License, Version 2.0 (the "License"); +@rem you may not use this file except in compliance with the License. +@rem You may obtain a copy of the License at +@rem +@rem https://www.apache.org/licenses/LICENSE-2.0 +@rem +@rem Unless required by applicable law or agreed to in writing, software +@rem distributed under the License is distributed on an "AS IS" BASIS, +@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +@rem See the License for the specific language governing permissions and +@rem limitations under the License. +@rem +@rem SPDX-License-Identifier: Apache-2.0 +@rem + +@if "%DEBUG%"=="" @echo off +@rem ########################################################################## +@rem +@rem gradlew startup script for Windows +@rem +@rem ########################################################################## + +@rem Set local scope for the variables, and ensure extensions are enabled +setlocal EnableExtensions + +set DIRNAME=%~dp0 +if "%DIRNAME%"=="" set DIRNAME=. +@rem This is normally unused +set APP_BASE_NAME=%~n0 +set APP_HOME=%DIRNAME% + +@rem Resolve any "." and ".." in APP_HOME to make it shorter. +for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi + +@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m" + +@rem Find java.exe +if defined JAVA_HOME goto findJavaFromJavaHome + +set JAVA_EXE=java.exe +%JAVA_EXE% -version >NUL 2>&1 +if %ERRORLEVEL% equ 0 goto execute + +echo. 1>&2 +echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2 +echo. 1>&2 +echo Please set the JAVA_HOME variable in your environment to match the 1>&2 +echo location of your Java installation. 1>&2 + +"%COMSPEC%" /c exit 1 + +:findJavaFromJavaHome +set JAVA_HOME=%JAVA_HOME:"=% +set JAVA_EXE=%JAVA_HOME%/bin/java.exe + +if exist "%JAVA_EXE%" goto execute + +echo. 1>&2 +echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2 +echo. 1>&2 +echo Please set the JAVA_HOME variable in your environment to match the 1>&2 +echo location of your Java installation. 1>&2 + +"%COMSPEC%" /c exit 1 + +:execute +@rem Setup the command line + + + +@rem Execute gradlew +@rem endlocal doesn't take effect until after the line is parsed and variables are expanded +@rem which allows us to clear the local environment before executing the java command +endlocal & "%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -jar "%APP_HOME%\gradle\wrapper\gradle-wrapper.jar" %* & call :exitWithErrorLevel + +:exitWithErrorLevel +@rem Use "%COMSPEC%" /c exit to allow operators to work properly in scripts +"%COMSPEC%" /c exit %ERRORLEVEL% diff --git a/scripts/generate-encryption-key.sh b/scripts/generate-encryption-key.sh new file mode 100644 index 00000000..0e0ba421 --- /dev/null +++ b/scripts/generate-encryption-key.sh @@ -0,0 +1,19 @@ +#!/usr/bin/env bash +# Generates a random AES-256 key for encrypting integration secrets at rest (ADR-0022). +# - 32 random bytes, base64-encoded (~44 chars), used as the AES-256-GCM key. +# +# NOTE: this is base64 (NOT hex) — the backend base64-decodes it and requires exactly +# 32 bytes, so a hex value would fail at startup. The Jira OAuth state secret is a +# DIFFERENT value in a DIFFERENT format (hex) — see scripts/generate-oauth-state-secret.sh. +# +# The key is NOT stored in the repo. Paste the printed value into your .env as +# INTEGRATIONS_ENCRYPTION_KEY; in production mount it as a secret (see the deploy workflow). +set -euo pipefail + +KEY="$(openssl rand -base64 32)" + +echo "Generated integrations encryption key (AES-256):" +echo +echo "INTEGRATIONS_ENCRYPTION_KEY=$KEY" +echo +echo "Paste the line above into your .env (do not commit it)." diff --git a/scripts/generate-oauth-state-secret.sh b/scripts/generate-oauth-state-secret.sh new file mode 100755 index 00000000..a5f89164 --- /dev/null +++ b/scripts/generate-oauth-state-secret.sh @@ -0,0 +1,15 @@ +#!/usr/bin/env bash +# Generates a random secret for signing the Jira OAuth 2.0 (3LO) `state` token (ADR-0022). +# - 32 random bytes, hex-encoded (64 hex chars), used as the raw HMAC-SHA256 key. +# +# The secret is NOT stored in the repo. Paste the printed value into your .env as +# JIRA_OAUTH_STATE_SECRET; in production mount it as a secret (see the deploy workflow). +set -euo pipefail + +SECRET="$(openssl rand -hex 32)" + +echo "Generated Jira OAuth state secret:" +echo +echo "JIRA_OAUTH_STATE_SECRET=$SECRET" +echo +echo "Paste the line above into your .env (do not commit it)." diff --git a/src/main/java/com/kntro/reqsai/discovery/api/AcceptanceCriterionView.java b/src/main/java/com/kntro/reqsai/discovery/api/AcceptanceCriterionView.java new file mode 100644 index 00000000..c4579097 --- /dev/null +++ b/src/main/java/com/kntro/reqsai/discovery/api/AcceptanceCriterionView.java @@ -0,0 +1,14 @@ +package com.kntro.reqsai.discovery.api; + +import org.jspecify.annotations.Nullable; + +/** + * Read-only projection of a user story's acceptance criterion exposed by Discovery via + * {@link DiscoveryStoryReadPort}. Given/When/Then plus an optional scenario label. No JPA entity. + */ +public record AcceptanceCriterionView( + @Nullable String scenario, + String given, + String when, + String then +) {} diff --git a/src/main/java/com/kntro/reqsai/discovery/api/DiscoveryStoryReadPort.java b/src/main/java/com/kntro/reqsai/discovery/api/DiscoveryStoryReadPort.java new file mode 100644 index 00000000..6636d837 --- /dev/null +++ b/src/main/java/com/kntro/reqsai/discovery/api/DiscoveryStoryReadPort.java @@ -0,0 +1,26 @@ +package com.kntro.reqsai.discovery.api; + +import java.util.List; +import java.util.Optional; +import java.util.UUID; + +/** + * Public ACL interface of the Discovery bounded context for reading user stories, accessible to other + * Spring Modulith modules. Returns plain-value {@link StoryView} snapshots — no JPA entities escape + * this boundary. All reads are tenant-scoped (schema resolved from the JWT {@code orgId}). + * + *

Implementations are package-private and registered as Spring beans; callers depend only on this + * interface (anti-corruption layer). Consumed by {@code integrations} to render stories into external + * tracker issues. + */ +public interface DiscoveryStoryReadPort { + + /** + * Returns a read-only projection of one story scoped to the given project, or + * {@link Optional#empty()} when the story does not exist or belongs to a different project/tenant. + */ + Optional findStory(UUID projectId, UUID storyId); + + /** Returns read-only projections of every story in the project (empty when the project has none). */ + List listStories(UUID projectId); +} diff --git a/src/main/java/com/kntro/reqsai/discovery/api/DiscoveryStoryWritePort.java b/src/main/java/com/kntro/reqsai/discovery/api/DiscoveryStoryWritePort.java new file mode 100644 index 00000000..5e50eaa8 --- /dev/null +++ b/src/main/java/com/kntro/reqsai/discovery/api/DiscoveryStoryWritePort.java @@ -0,0 +1,33 @@ +package com.kntro.reqsai.discovery.api; + +/** + * Public ACL interface of the Discovery bounded context for creating user stories from + * external tracker issues, accessible to other Spring Modulith modules (the {@code gateway} Jira import). + * The counterpart of {@link DiscoveryStoryReadPort}. + * + *

Discovery owns the transformation: an external issue (summary + plain-text description) is turned into + * a well-formed story (role/action/benefit + acceptance criteria) by Discovery's existing LLM generation + * when configured, and by a deterministic safe mapping otherwise — so the LLM never crosses the module + * boundary. Creation reuses the existing {@code CreateUserStoryCommandHandler}, keeping the + * similarity/duplicate detection identical to manual and AI-generated stories: an import that collides with + * an existing story is reported as a {@link ImportedStory.Status#DUPLICATE} rather than created. + * + *

Implementations are package-private Spring beans; callers depend only on this interface. All writes + * are tenant-scoped (schema resolved from the JWT {@code orgId}). + */ +public interface DiscoveryStoryWritePort { + + /** + * Transforms the external issue into a story and creates it, reusing the standard dedup gate. Returns + * {@link ImportedStory#created(java.util.UUID)} on success or {@link ImportedStory#duplicate} when the + * transformed story is a near-duplicate of an existing project story (nothing is created in that case). + */ + ImportedStory importFromExternalIssue(ExternalIssueInput input); + + /** + * Checks — without creating — whether the external issue would map to a near-duplicate of an existing + * project story, so the import preview can flag it. Returns {@link StoryDuplicateCheck#notDuplicate()} + * when the embedding model is unavailable (no similarity signal to report). + */ + StoryDuplicateCheck checkDuplicate(ExternalIssueInput input); +} diff --git a/src/main/java/com/kntro/reqsai/discovery/api/ExternalIssueInput.java b/src/main/java/com/kntro/reqsai/discovery/api/ExternalIssueInput.java new file mode 100644 index 00000000..339366ff --- /dev/null +++ b/src/main/java/com/kntro/reqsai/discovery/api/ExternalIssueInput.java @@ -0,0 +1,26 @@ +package com.kntro.reqsai.discovery.api; + +import org.jspecify.annotations.Nullable; + +import java.util.UUID; + +/** + * Raw material for importing an external tracker issue (e.g. a Jira issue) into the Discovery backlog as a + * user story, passed by another module (the {@code gateway}) to {@link DiscoveryStoryWritePort}. + * + *

Deliberately provider-neutral and unstructured: it carries the issue's {@code summary} (title-ish + * line) and its {@code description} already flattened to plain text. Discovery owns the transformation + * into a well-formed story (role/action/benefit + acceptance criteria) — via its LLM generation when + * available, otherwise a deterministic safe mapping — so the AI stays inside the Discovery boundary. + * + * @param projectId project the story will belong to (tenant-scoped) + * @param summary the external issue summary (never blank; used as the story title / generation seed) + * @param description the external issue description flattened to plain text ({@code null}/blank allowed) + * @param language BCP-47 language tag to guide the LLM (e.g. {@code "es-PE"}); {@code null} = default + */ +public record ExternalIssueInput( + UUID projectId, + String summary, + @Nullable String description, + @Nullable String language +) {} diff --git a/src/main/java/com/kntro/reqsai/discovery/api/ImportedStory.java b/src/main/java/com/kntro/reqsai/discovery/api/ImportedStory.java new file mode 100644 index 00000000..711369fa --- /dev/null +++ b/src/main/java/com/kntro/reqsai/discovery/api/ImportedStory.java @@ -0,0 +1,39 @@ +package com.kntro.reqsai.discovery.api; + +import org.jspecify.annotations.Nullable; + +import java.util.UUID; + +/** + * Outcome of importing one external issue through {@link DiscoveryStoryWritePort}: either a new story was + * created, or the transformed story was detected as a near-duplicate of an existing one (reusing the same + * similarity/deduplication gate as manual and AI-generated creation) and therefore not + * created. + * + * @param status {@link Status#CREATED} or {@link Status#DUPLICATE} + * @param storyId the created story id when {@code CREATED}, else {@code null} + * @param existingStoryId the near-duplicate's story id when {@code DUPLICATE} and it could be resolved, + * else {@code null} + * @param similarity cosine similarity to the existing story when {@code DUPLICATE} (0 otherwise) + */ +public record ImportedStory( + Status status, + @Nullable UUID storyId, + @Nullable UUID existingStoryId, + double similarity +) { + + public enum Status { CREATED, DUPLICATE } + + public static ImportedStory created(UUID storyId) { + return new ImportedStory(Status.CREATED, storyId, null, 0.0); + } + + public static ImportedStory duplicate(@Nullable UUID existingStoryId, double similarity) { + return new ImportedStory(Status.DUPLICATE, null, existingStoryId, similarity); + } + + public boolean isDuplicate() { + return status == Status.DUPLICATE; + } +} diff --git a/src/main/java/com/kntro/reqsai/discovery/api/StoryDuplicateCheck.java b/src/main/java/com/kntro/reqsai/discovery/api/StoryDuplicateCheck.java new file mode 100644 index 00000000..755d869e --- /dev/null +++ b/src/main/java/com/kntro/reqsai/discovery/api/StoryDuplicateCheck.java @@ -0,0 +1,26 @@ +package com.kntro.reqsai.discovery.api; + +import org.jspecify.annotations.Nullable; + +import java.util.UUID; + +/** + * Result of checking whether a candidate story (built from an external issue) would be a near-duplicate of + * an existing project story without creating anything. Used by the import preview so the + * caller can flag likely duplicates before the user commits to importing. + * + * @param duplicate true when the candidate's similarity to an existing story is at/above the same + * deduplication threshold that creation enforces + * @param existingStoryId the most-similar existing story id when one was found, else {@code null} + * @param similarity cosine similarity to that story (0 when none / embedding unavailable) + */ +public record StoryDuplicateCheck( + boolean duplicate, + @Nullable UUID existingStoryId, + double similarity +) { + + public static StoryDuplicateCheck notDuplicate() { + return new StoryDuplicateCheck(false, null, 0.0); + } +} diff --git a/src/main/java/com/kntro/reqsai/discovery/api/StoryView.java b/src/main/java/com/kntro/reqsai/discovery/api/StoryView.java new file mode 100644 index 00000000..b56f035a --- /dev/null +++ b/src/main/java/com/kntro/reqsai/discovery/api/StoryView.java @@ -0,0 +1,26 @@ +package com.kntro.reqsai.discovery.api; + +import org.jspecify.annotations.Nullable; + +import java.util.List; +import java.util.UUID; + +/** + * Read-only projection of a {@code UserStory} exposed by Discovery via {@link DiscoveryStoryReadPort}. + * Carries only the text fields another module needs to render the story into an external tracker issue + * (title, role/action/benefit, priority, story points and the Given/When/Then acceptance criteria). + * + *

{@code priority} is the {@code Priority} enum name; no JPA entities, no embeddings cross this + * boundary. + */ +public record StoryView( + UUID storyId, + UUID projectId, + String title, + String role, + String action, + String benefit, + String priority, + @Nullable Integer storyPoints, + List acceptanceCriteria +) {} diff --git a/src/main/java/com/kntro/reqsai/discovery/api/package-info.java b/src/main/java/com/kntro/reqsai/discovery/api/package-info.java new file mode 100644 index 00000000..f890bec8 --- /dev/null +++ b/src/main/java/com/kntro/reqsai/discovery/api/package-info.java @@ -0,0 +1,19 @@ +/** + * Named interface of the Discovery module — the only types other modules may import. + *

+ * Exposes {@link com.kntro.reqsai.discovery.api.DiscoveryStoryReadPort} and its read-only value + * records ({@link com.kntro.reqsai.discovery.api.StoryView}, + * {@link com.kntro.reqsai.discovery.api.AcceptanceCriterionView}) so other modules (e.g. the + * {@code gateway}) can read user stories to push them to external trackers, and + * {@link com.kntro.reqsai.discovery.api.DiscoveryStoryWritePort} (with + * {@link com.kntro.reqsai.discovery.api.ExternalIssueInput}, + * {@link com.kntro.reqsai.discovery.api.ImportedStory} and + * {@link com.kntro.reqsai.discovery.api.StoryDuplicateCheck}) so the {@code gateway} can import external + * issues as stories — Discovery owns the LLM transformation and reuses its create/dedup use case behind + * the port. No JPA entities cross this boundary. + *

+ * Declare {@code allowedDependencies = "discovery::api"} in the consuming module's + * {@code @ApplicationModule} annotation to make Spring Modulith enforce the boundary. + */ +@org.springframework.modulith.NamedInterface("api") +package com.kntro.reqsai.discovery.api; diff --git a/src/main/java/com/kntro/reqsai/discovery/application/command/BatchDeleteUserStoriesCommand.java b/src/main/java/com/kntro/reqsai/discovery/application/command/BatchDeleteUserStoriesCommand.java new file mode 100644 index 00000000..cb8034c9 --- /dev/null +++ b/src/main/java/com/kntro/reqsai/discovery/application/command/BatchDeleteUserStoriesCommand.java @@ -0,0 +1,15 @@ +package com.kntro.reqsai.discovery.application.command; + +import java.util.List; +import java.util.UUID; + +/** + * Intent to permanently delete several user stories of a project in one call. Ids that do not belong + * to {@code projectId} (unknown, or in another project/tenant) are silently skipped — the operation is + * best-effort and reports how many rows were actually deleted, never an error for a missing id. Each + * deleted story's acceptance criteria are removed with it (JPA cascade / orphan removal). + * + * @param projectId project the stories must belong to + * @param storyIds candidate stories to delete (order preserved; ids not in the project are skipped) + */ +public record BatchDeleteUserStoriesCommand(UUID projectId, List storyIds) {} diff --git a/src/main/java/com/kntro/reqsai/discovery/application/command/DeleteUserStoryCommand.java b/src/main/java/com/kntro/reqsai/discovery/application/command/DeleteUserStoryCommand.java new file mode 100644 index 00000000..5ccb64c6 --- /dev/null +++ b/src/main/java/com/kntro/reqsai/discovery/application/command/DeleteUserStoryCommand.java @@ -0,0 +1,13 @@ +package com.kntro.reqsai.discovery.application.command; + +import java.util.UUID; + +/** + * Intent to permanently delete a single user story. Scoped to a project: the story must belong to + * {@code projectId} or the delete is rejected with a 404. The story's acceptance criteria are removed + * with it (JPA cascade / orphan removal on the aggregate). + * + * @param projectId project the story must belong to + * @param storyId story to delete + */ +public record DeleteUserStoryCommand(UUID projectId, UUID storyId) {} diff --git a/src/main/java/com/kntro/reqsai/discovery/application/handler/BatchDeleteUserStoriesCommandHandler.java b/src/main/java/com/kntro/reqsai/discovery/application/handler/BatchDeleteUserStoriesCommandHandler.java new file mode 100644 index 00000000..2abb42a5 --- /dev/null +++ b/src/main/java/com/kntro/reqsai/discovery/application/handler/BatchDeleteUserStoriesCommandHandler.java @@ -0,0 +1,38 @@ +package com.kntro.reqsai.discovery.application.handler; + +import com.kntro.reqsai.discovery.application.command.BatchDeleteUserStoriesCommand; +import com.kntro.reqsai.discovery.application.port.UserStoryRepository; +import com.kntro.reqsai.discovery.domain.model.UserStory; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Component; +import org.springframework.transaction.annotation.Transactional; + +import java.util.List; + +/** + * Permanently deletes several {@link UserStory} aggregates of a project in one transaction. Only the + * candidate ids that actually belong to the project are resolved and deleted; ids that are unknown or + * live in another project/tenant are silently skipped (best-effort, never an error), so the returned + * count is the number of stories actually deleted. Each deletion cascades to the story's acceptance + * criteria via {@code orphanRemoval}. + *

+ * Local delete only: it does NOT touch any external tracker (e.g. Jira) issue a story was exported to. + */ +@Component +@RequiredArgsConstructor +@Slf4j +public class BatchDeleteUserStoriesCommandHandler { + + private final UserStoryRepository stories; + + /** @return the number of stories actually deleted (candidate ids not in the project are skipped). */ + @Transactional + public int handle(BatchDeleteUserStoriesCommand command) { + List found = stories.findAllByProjectIdAndIdIn(command.projectId(), command.storyIds()); + found.forEach(stories::delete); + log.info("Batch-deleted {} of {} requested user stories for project {}", + found.size(), command.storyIds().size(), command.projectId()); + return found.size(); + } +} diff --git a/src/main/java/com/kntro/reqsai/discovery/application/handler/DeleteUserStoryCommandHandler.java b/src/main/java/com/kntro/reqsai/discovery/application/handler/DeleteUserStoryCommandHandler.java new file mode 100644 index 00000000..7a620540 --- /dev/null +++ b/src/main/java/com/kntro/reqsai/discovery/application/handler/DeleteUserStoryCommandHandler.java @@ -0,0 +1,36 @@ +package com.kntro.reqsai.discovery.application.handler; + +import com.kntro.reqsai.discovery.application.command.DeleteUserStoryCommand; +import com.kntro.reqsai.discovery.application.port.UserStoryRepository; +import com.kntro.reqsai.discovery.domain.exception.DiscoveryExceptions; +import com.kntro.reqsai.discovery.domain.model.UserStory; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Component; +import org.springframework.transaction.annotation.Transactional; + +/** + * Permanently deletes a single {@link UserStory} of the current tenant. The story is scope-checked + * against the project in a single lookup (404 when it does not exist in the project), mirroring the + * update path. Deletion is a hard delete (consistent with document deletion): removing the aggregate + * cascades to its acceptance criteria via {@code orphanRemoval}. + *

+ * This is a Reqs-AI-local delete only. It does NOT touch any external tracker (e.g. Jira) issue the + * story was previously exported to — the remote issue, if any, is left untouched. + */ +@Component +@RequiredArgsConstructor +@Slf4j +public class DeleteUserStoryCommandHandler { + + private final UserStoryRepository stories; + + @Transactional + public void handle(DeleteUserStoryCommand command) { + UserStory story = stories.findByIdAndProjectId(command.storyId(), command.projectId()) + .orElseThrow(() -> DiscoveryExceptions.userStoryNotFound(command.storyId())); + + stories.delete(story); + log.info("User story {} deleted for project {}", command.storyId(), command.projectId()); + } +} diff --git a/src/main/java/com/kntro/reqsai/discovery/application/notification/SessionTopics.java b/src/main/java/com/kntro/reqsai/discovery/application/notification/SessionTopics.java index c5282775..27c78058 100644 --- a/src/main/java/com/kntro/reqsai/discovery/application/notification/SessionTopics.java +++ b/src/main/java/com/kntro/reqsai/discovery/application/notification/SessionTopics.java @@ -19,6 +19,15 @@ public final class SessionTopics { private SessionTopics() { } + /** + * The logical topic prefix for per-session destinations (no broker prefix). Presence tracking + * matches subscribe destinations against {@code /topic/} + this value to recognize which session + * a client is viewing. + */ + public static String sessionsPrefix() { + return SESSIONS_PREFIX; + } + /** * Logical topic carrying every realtime update for one discovery session. * diff --git a/src/main/java/com/kntro/reqsai/discovery/application/port/UserStoryRepository.java b/src/main/java/com/kntro/reqsai/discovery/application/port/UserStoryRepository.java index 77f8afb2..d8e8cb58 100644 --- a/src/main/java/com/kntro/reqsai/discovery/application/port/UserStoryRepository.java +++ b/src/main/java/com/kntro/reqsai/discovery/application/port/UserStoryRepository.java @@ -37,6 +37,20 @@ public interface UserStoryRepository { Page findAllBySessionId(UUID sessionId, Pageable pageable); + /** + * Returns the stories of {@code projectId} whose id is in {@code storyIds}, in an arbitrary order. + * Used by the batch delete to resolve the candidate ids to managed aggregates: ids not belonging to + * the project simply do not appear in the result (silently skipped). + */ + List findAllByProjectIdAndIdIn(UUID projectId, List storyIds); + + /** + * Permanently deletes the story (hard delete, mirroring document deletion). Removing the aggregate + * cascades to its acceptance criteria via {@code orphanRemoval}. This is a local delete only: it does + * not touch any external tracker (e.g. Jira) issue the story was exported to. + */ + void delete(UserStory story); + void deleteAllBySessionId(UUID sessionId); /** diff --git a/src/main/java/com/kntro/reqsai/discovery/application/service/DiscoveryStoryReadPortImpl.java b/src/main/java/com/kntro/reqsai/discovery/application/service/DiscoveryStoryReadPortImpl.java new file mode 100644 index 00000000..742f8d87 --- /dev/null +++ b/src/main/java/com/kntro/reqsai/discovery/application/service/DiscoveryStoryReadPortImpl.java @@ -0,0 +1,62 @@ +package com.kntro.reqsai.discovery.application.service; + +import com.kntro.reqsai.discovery.api.AcceptanceCriterionView; +import com.kntro.reqsai.discovery.api.DiscoveryStoryReadPort; +import com.kntro.reqsai.discovery.api.StoryView; +import com.kntro.reqsai.discovery.application.port.UserStoryRepository; +import com.kntro.reqsai.discovery.domain.model.AcceptanceCriterion; +import com.kntro.reqsai.discovery.domain.model.UserStory; +import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.Pageable; +import org.springframework.stereotype.Component; +import org.springframework.transaction.annotation.Transactional; + +import java.util.List; +import java.util.Optional; +import java.util.UUID; + +/** + * Package-private cross-context implementation of {@link DiscoveryStoryReadPort}. Reads {@link UserStory} + * aggregates through the existing {@link UserStoryRepository} and maps them to boundary value records, + * so no JPA entity crosses the module boundary. + */ +@Component +@RequiredArgsConstructor +class DiscoveryStoryReadPortImpl implements DiscoveryStoryReadPort { + + private final UserStoryRepository stories; + + @Override + @Transactional(readOnly = true) + public Optional findStory(UUID projectId, UUID storyId) { + return stories.findByIdAndProjectId(storyId, projectId).map(DiscoveryStoryReadPortImpl::toView); + } + + @Override + @Transactional(readOnly = true) + public List listStories(UUID projectId) { + return stories.findAllByProjectId(projectId, Pageable.unpaged()) + .map(DiscoveryStoryReadPortImpl::toView) + .getContent(); + } + + private static StoryView toView(UserStory story) { + List criteria = story.getAcceptanceCriteria().stream() + .map(DiscoveryStoryReadPortImpl::toView) + .toList(); + return new StoryView( + story.getId(), + story.getProjectId(), + story.getTitle(), + story.getRole(), + story.getAction(), + story.getBenefit(), + story.getPriority().name(), + story.getStoryPoints(), + criteria); + } + + private static AcceptanceCriterionView toView(AcceptanceCriterion c) { + return new AcceptanceCriterionView(c.getScenario(), c.getGiven(), c.getWhen(), c.getThen()); + } +} diff --git a/src/main/java/com/kntro/reqsai/discovery/application/service/DiscoveryStoryWritePortImpl.java b/src/main/java/com/kntro/reqsai/discovery/application/service/DiscoveryStoryWritePortImpl.java new file mode 100644 index 00000000..482dee04 --- /dev/null +++ b/src/main/java/com/kntro/reqsai/discovery/application/service/DiscoveryStoryWritePortImpl.java @@ -0,0 +1,198 @@ +package com.kntro.reqsai.discovery.application.service; + +import com.kntro.reqsai.discovery.api.DiscoveryStoryWritePort; +import com.kntro.reqsai.discovery.api.ExternalIssueInput; +import com.kntro.reqsai.discovery.api.ImportedStory; +import com.kntro.reqsai.discovery.api.StoryDuplicateCheck; +import com.kntro.reqsai.discovery.application.command.CreateUserStoryCommand; +import com.kntro.reqsai.discovery.application.handler.CreateUserStoryCommandHandler; +import com.kntro.reqsai.discovery.application.port.GenerationResult; +import com.kntro.reqsai.discovery.application.port.GenerationResult.GeneratedStory; +import com.kntro.reqsai.discovery.application.port.RequirementGenerationPort; +import com.kntro.reqsai.discovery.application.port.UserStoryRepository; +import com.kntro.reqsai.discovery.domain.exception.DiscoveryError; +import com.kntro.reqsai.discovery.domain.model.Priority; +import com.kntro.reqsai.discovery.domain.model.UserStory; +import com.kntro.reqsai.shared.application.port.EmbeddingPort; +import com.kntro.reqsai.shared.domain.exception.DomainException; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Component; +import org.springframework.transaction.annotation.Propagation; +import org.springframework.transaction.annotation.Transactional; + +import java.util.List; +import java.util.Optional; + +/** + * Package-private cross-context implementation of {@link DiscoveryStoryWritePort}. Transforms an external + * tracker issue into a well-formed {@link UserStory} and creates it through the existing + * {@link CreateUserStoryCommandHandler}, so the similarity/deduplication gate is identical to manual and + * AI-generated creation. + * + *

Transformation (two paths, documented): + *

    + *
  1. LLM path — when {@link RequirementGenerationPort#isAvailable()} the issue's summary + + * plain-text description are fed to Discovery's existing generation as a short transcript; the first + * generated story (already role/action/benefit + acceptance criteria) is used. This is the requested + * behaviour: no regex parsing of Jira text.
  2. + *
  3. Deterministic fallback — when the model is unconfigured or generation fails/returns + * nothing, a safe mapping is used: {@code title = summary}, a minimal valid role/action, and the + * description (or a default) as the benefit. This guarantees the required story fields are always + * satisfied so import works without an LLM.
  4. + *
+ * Either way the resulting story goes through the standard create handler; a near-duplicate is reported as + * {@link ImportedStory.Status#DUPLICATE} (nothing created), not propagated as an error. + */ +@Component +@RequiredArgsConstructor +@Slf4j +class DiscoveryStoryWritePortImpl implements DiscoveryStoryWritePort { + + private static final int TITLE_MAX = 200; + private static final int FIELD_MAX = 500; + + private final RequirementGenerationPort generationPort; + private final CreateUserStoryCommandHandler createUserStory; + private final UserStoryRepository stories; + private final EmbeddingPort embeddingPort; + + /** + * Imports one issue in its OWN transaction ({@link Propagation#REQUIRES_NEW}) so a per-issue rollback + * (e.g. a rare check→create duplicate race) never poisons the caller's batch — matching how + * {@code StoryExtractionService} isolates each AI-generated story. Must be invoked from another bean + * (the {@code gateway} handler) for the proxy to apply the new transaction. + * + *

A near-duplicate is detected BEFORE invoking the {@link CreateUserStoryCommandHandler}: were the + * handler to throw the duplicate exception, that throw would cross its {@code @Transactional} boundary + * and mark this transaction rollback-only even though we catch it. The pre-check uses the same canonical + * text + embedding + threshold as the create-time gate, so behaviour is identical; the handler still + * guards as a backstop. + */ + @Override + @Transactional(propagation = Propagation.REQUIRES_NEW) + public ImportedStory importFromExternalIssue(ExternalIssueInput input) { + GeneratedStory gen = transform(input); + + StoryDuplicateCheck dup = resolveDuplicate(input.projectId(), gen); + if (dup.duplicate()) { + return ImportedStory.duplicate(dup.existingStoryId(), dup.similarity()); + } + + CreateUserStoryCommand command = new CreateUserStoryCommand( + input.projectId(), gen.title(), gen.role(), gen.action(), gen.benefit(), + gen.priority(), gen.storyPoints()); + UserStory saved = createUserStory.handle(command); + if (gen.acceptanceCriteria() != null && !gen.acceptanceCriteria().isEmpty()) { + gen.acceptanceCriteria().forEach(c -> + saved.addAcceptanceCriterion(c.scenario(), c.given(), c.when(), c.then())); + stories.save(saved); + } + return ImportedStory.created(saved.getId()); + } + + @Override + @Transactional(readOnly = true) + public StoryDuplicateCheck checkDuplicate(ExternalIssueInput input) { + if (!embeddingPort.isAvailable()) { + return StoryDuplicateCheck.notDuplicate(); + } + // Deliberately uses the deterministic mapping (NOT the LLM): preview calls this once per remote + // issue, and an LLM generation per issue makes previews take minutes. The similarity gate re-runs + // at import time against the final (possibly LLM-transformed) story, so the badge stays a preview. + return resolveDuplicate(input.projectId(), fallback(input)); + } + + /** + * Finds the most similar existing story to the candidate and flags it as a duplicate when the score is + * at/above the shared threshold. Uses the same canonical text + embedding as the create-time gate. + */ + private StoryDuplicateCheck resolveDuplicate(java.util.UUID projectId, GeneratedStory gen) { + if (!embeddingPort.isAvailable()) { + return StoryDuplicateCheck.notDuplicate(); + } + UserStory candidate = new UserStory(projectId, gen.title(), gen.role(), gen.action(), + gen.benefit(), gen.priority(), gen.storyPoints()); + Optional match = + stories.findMostSimilar(projectId, embeddingPort.embed(candidate.toCanonicalText())); + return match + .map(s -> new StoryDuplicateCheck(s.similarity() >= UserStory.DUPLICATE_THRESHOLD, + s.storyId(), s.similarity())) + .orElseGet(StoryDuplicateCheck::notDuplicate); + } + + /** LLM transformation with a deterministic fallback that always yields a valid story. */ + private GeneratedStory transform(ExternalIssueInput input) { + if (generationPort.isAvailable()) { + try { + GenerationResult result = generationPort.generate(seedTranscript(input), language(input)); + if (result != null && result.stories() != null && !result.stories().isEmpty()) { + return sanitize(result.stories().getFirst(), input); + } + log.info("Generation returned no story for imported issue '{}'; using safe fallback mapping", + input.summary()); + } catch (RuntimeException e) { + log.warn("Generation failed for imported issue '{}'; using safe fallback mapping: {}", + input.summary(), e.getMessage()); + } + } + return fallback(input); + } + + /** Feeds the LLM the issue as a tiny transcript so it produces one structured story. */ + private static String seedTranscript(ExternalIssueInput input) { + String description = input.description() == null ? "" : input.description(); + return ("Convert the following tracker issue into a single user story.\n" + + "Title: " + input.summary() + "\n" + + "Description: " + description).strip(); + } + + private static String language(ExternalIssueInput input) { + return input.language() == null || input.language().isBlank() ? "en-US" : input.language(); + } + + /** + * Ensures an LLM-generated story satisfies the {@link UserStory} invariants (non-blank, bounded fields) + * regardless of what the model returned, so a sparse generation never fails the create. + */ + private static GeneratedStory sanitize(GeneratedStory gen, ExternalIssueInput input) { + String title = clamp(nonBlank(gen.title(), input.summary()), TITLE_MAX); + String role = clamp(nonBlank(gen.role(), "stakeholder"), FIELD_MAX); + String action = clamp(nonBlank(gen.action(), deriveAction(input.summary())), FIELD_MAX); + String benefit = clamp(nonBlank(gen.benefit(), deriveBenefit(input)), FIELD_MAX); + Priority priority = gen.priority() == null ? Priority.MEDIUM : gen.priority(); + List criteria = + gen.acceptanceCriteria() == null ? List.of() : gen.acceptanceCriteria(); + return new GeneratedStory(title, role, action, benefit, priority, gen.storyPoints(), criteria); + } + + /** Deterministic safe mapping: title = summary, minimal valid role/action, description as benefit. */ + private static GeneratedStory fallback(ExternalIssueInput input) { + String title = clamp(nonBlank(input.summary(), "Imported issue"), TITLE_MAX); + return new GeneratedStory( + title, + "stakeholder", + clamp(deriveAction(title), FIELD_MAX), + clamp(deriveBenefit(input), FIELD_MAX), + Priority.MEDIUM, + null, + List.of()); + } + + private static String deriveAction(String summary) { + return "achieve: " + summary; + } + + private static String deriveBenefit(ExternalIssueInput input) { + String description = input.description() == null ? "" : input.description().strip(); + return description.isBlank() ? "the imported requirement is captured in the backlog" : description; + } + + private static String nonBlank(String value, String fallback) { + return value == null || value.isBlank() ? fallback : value.strip(); + } + + private static String clamp(String value, int max) { + return value.length() <= max ? value : value.substring(0, max); + } +} diff --git a/src/main/java/com/kntro/reqsai/discovery/infrastructure/persistence/adapters/UserStoryRepositoryAdapter.java b/src/main/java/com/kntro/reqsai/discovery/infrastructure/persistence/adapters/UserStoryRepositoryAdapter.java index d9134a6b..d4154400 100644 --- a/src/main/java/com/kntro/reqsai/discovery/infrastructure/persistence/adapters/UserStoryRepositoryAdapter.java +++ b/src/main/java/com/kntro/reqsai/discovery/infrastructure/persistence/adapters/UserStoryRepositoryAdapter.java @@ -54,6 +54,16 @@ public Page findAllBySessionId(UUID sessionId, Pageable pageable) { return jpa.findAllBySessionId(sessionId, pageable); } + @Override + public List findAllByProjectIdAndIdIn(UUID projectId, List storyIds) { + return jpa.findAllByProjectIdAndIdIn(projectId, storyIds); + } + + @Override + public void delete(UserStory story) { + jpa.delete(story); + } + @Override public void deleteAllBySessionId(UUID sessionId) { jpa.deleteAllBySessionId(sessionId); diff --git a/src/main/java/com/kntro/reqsai/discovery/infrastructure/persistence/repositories/UserStoryJpaRepository.java b/src/main/java/com/kntro/reqsai/discovery/infrastructure/persistence/repositories/UserStoryJpaRepository.java index 518aa183..f535858e 100644 --- a/src/main/java/com/kntro/reqsai/discovery/infrastructure/persistence/repositories/UserStoryJpaRepository.java +++ b/src/main/java/com/kntro/reqsai/discovery/infrastructure/persistence/repositories/UserStoryJpaRepository.java @@ -26,6 +26,9 @@ public interface UserStoryJpaRepository extends JpaRepository, Optional findByIdAndProjectId(UUID id, UUID projectId); + /** Stories of the project whose id is in the given collection (ids in other projects are excluded). */ + List findAllByProjectIdAndIdIn(UUID projectId, List ids); + /** Stories persisted without an embedding (provider down/failed at write time), oldest first. */ List findAllByProjectIdAndEmbeddingIsNull(UUID projectId, Pageable pageable); diff --git a/src/main/java/com/kntro/reqsai/discovery/interfaces/notification/SessionEventType.java b/src/main/java/com/kntro/reqsai/discovery/interfaces/notification/SessionEventType.java index 39c7ea61..6be5eb0b 100644 --- a/src/main/java/com/kntro/reqsai/discovery/interfaces/notification/SessionEventType.java +++ b/src/main/java/com/kntro/reqsai/discovery/interfaces/notification/SessionEventType.java @@ -61,5 +61,13 @@ public enum SessionEventType { SUGGESTION_ACCEPTED, /** The analyst dismissed a suggestion (no backlog change). */ - SUGGESTION_DISMISSED + SUGGESTION_DISMISSED, + + // Live presence + + /** + * The roster of users currently viewing the live session changed (someone joined or left). + * Carries the full participant list so the client can render it idempotently. + */ + PRESENCE_STATE } diff --git a/src/main/java/com/kntro/reqsai/discovery/interfaces/notification/messages/SessionParticipant.java b/src/main/java/com/kntro/reqsai/discovery/interfaces/notification/messages/SessionParticipant.java new file mode 100644 index 00000000..a129bef2 --- /dev/null +++ b/src/main/java/com/kntro/reqsai/discovery/interfaces/notification/messages/SessionParticipant.java @@ -0,0 +1,14 @@ +package com.kntro.reqsai.discovery.interfaces.notification.messages; + +import java.util.UUID; + +/** + * One user currently present in a live discovery session, as carried by {@link SessionPresenceMessage}. + * + * @param userId the participant's user id (JWT {@code sub}) + * @param displayName the member display name resolved from the workspace roster; may fall back to a + * generic label when the membership cannot be resolved + * @param avatarUrl the public avatar serve path for the user (loadable directly by an {@code }) + */ +public record SessionParticipant(UUID userId, String displayName, String avatarUrl) { +} diff --git a/src/main/java/com/kntro/reqsai/discovery/interfaces/notification/messages/SessionPresenceMessage.java b/src/main/java/com/kntro/reqsai/discovery/interfaces/notification/messages/SessionPresenceMessage.java new file mode 100644 index 00000000..f7160888 --- /dev/null +++ b/src/main/java/com/kntro/reqsai/discovery/interfaces/notification/messages/SessionPresenceMessage.java @@ -0,0 +1,43 @@ +package com.kntro.reqsai.discovery.interfaces.notification.messages; + +import com.fasterxml.jackson.annotation.JsonProperty; +import com.kntro.reqsai.discovery.interfaces.notification.SessionEventType; + +import java.time.Instant; +import java.util.List; +import java.util.UUID; + +/** + * WebSocket payload for {@link SessionEventType#PRESENCE_STATE}: the full roster of users currently + * viewing a live discovery session. + *

+ * The message is a snapshot (the complete list, not a delta) so the client renders it + * idempotently and never drifts if it misses an intermediate join/leave. {@code count} is the number + * of distinct participants — the same user viewing from two tabs appears once. + */ +public record SessionPresenceMessage( + UUID sessionId, + List participants, + int count, + Instant occurredAt +) implements SessionRealtimeMessage { + + /** Builds a presence snapshot, deriving {@code count} from the participant list. */ + public static SessionPresenceMessage of(UUID sessionId, List participants, Instant occurredAt) { + return new SessionPresenceMessage(sessionId, List.copyOf(participants), participants.size(), occurredAt); + } + + /** + * {@code type} is a fixed constant, not a canonical record component — the same pattern used by + * {@code SessionProcessingFailedMessage}/{@code SessionStoryGeneratedMessage}/ + * {@code SessionTranscriptSegmentMessage}. The explicit {@code @JsonProperty} is required: Jackson's + * record serializer only emits canonical components, so without it this override is silently + * dropped from the JSON and the client never sees a discriminator to switch on (the bug this + * annotation fixes — verified missing from the wire payload). + */ + @Override + @JsonProperty("type") + public SessionEventType type() { + return SessionEventType.PRESENCE_STATE; + } +} diff --git a/src/main/java/com/kntro/reqsai/discovery/interfaces/notification/messages/SessionRealtimeMessage.java b/src/main/java/com/kntro/reqsai/discovery/interfaces/notification/messages/SessionRealtimeMessage.java index 72ccdc01..3c16ec5e 100644 --- a/src/main/java/com/kntro/reqsai/discovery/interfaces/notification/messages/SessionRealtimeMessage.java +++ b/src/main/java/com/kntro/reqsai/discovery/interfaces/notification/messages/SessionRealtimeMessage.java @@ -12,7 +12,7 @@ * correct UI component. The {@code sealed} hierarchy makes the full set of messages explicit and * lets serializers/consumers reason about it exhaustively at compile time. */ -public sealed interface SessionRealtimeMessage permits SessionStatusChangedMessage, SessionProcessingFailedMessage, SessionStoryGeneratedMessage, SessionTranscriptSegmentMessage, SessionSuggestionMessage, SessionLifecycleMessage { +public sealed interface SessionRealtimeMessage permits SessionStatusChangedMessage, SessionProcessingFailedMessage, SessionStoryGeneratedMessage, SessionTranscriptSegmentMessage, SessionSuggestionMessage, SessionLifecycleMessage, SessionPresenceMessage { /** Session this update belongs to (matches the subscribed topic). */ UUID sessionId(); diff --git a/src/main/java/com/kntro/reqsai/discovery/interfaces/rest/controllers/ProjectStoryControllerImpl.java b/src/main/java/com/kntro/reqsai/discovery/interfaces/rest/controllers/ProjectStoryControllerImpl.java index c82c9546..dbd40d7b 100644 --- a/src/main/java/com/kntro/reqsai/discovery/interfaces/rest/controllers/ProjectStoryControllerImpl.java +++ b/src/main/java/com/kntro/reqsai/discovery/interfaces/rest/controllers/ProjectStoryControllerImpl.java @@ -1,6 +1,8 @@ package com.kntro.reqsai.discovery.interfaces.rest.controllers; +import com.kntro.reqsai.discovery.application.handler.BatchDeleteUserStoriesCommandHandler; import com.kntro.reqsai.discovery.application.handler.CreateUserStoryCommandHandler; +import com.kntro.reqsai.discovery.application.handler.DeleteUserStoryCommandHandler; import com.kntro.reqsai.discovery.application.handler.GetProjectStoryQueryHandler; import com.kntro.reqsai.discovery.application.handler.ListProjectStoriesQueryHandler; import com.kntro.reqsai.discovery.application.handler.UpdateUserStoryCommandHandler; @@ -10,8 +12,10 @@ import com.kntro.reqsai.discovery.domain.model.Priority; import com.kntro.reqsai.discovery.domain.model.StoryStatus; import com.kntro.reqsai.discovery.domain.model.UserStory; +import com.kntro.reqsai.discovery.interfaces.rest.dto.request.BatchDeleteUserStoriesRequest; import com.kntro.reqsai.discovery.interfaces.rest.dto.request.CreateUserStoryRequest; import com.kntro.reqsai.discovery.interfaces.rest.dto.request.UpdateUserStoryRequest; +import com.kntro.reqsai.discovery.interfaces.rest.dto.response.BatchDeleteUserStoriesResponse; import com.kntro.reqsai.discovery.interfaces.rest.dto.response.UserStoryResponse; import com.kntro.reqsai.discovery.interfaces.rest.mappers.request.UserStoryRequestMapper; import com.kntro.reqsai.discovery.interfaces.rest.mappers.response.UserStoryResponseMapper; @@ -37,6 +41,8 @@ public class ProjectStoryControllerImpl implements ProjectStoryController { private final GetProjectStoryQueryHandler getUserStory; private final ListProjectStoriesQueryHandler listUserStories; private final UpdateUserStoryCommandHandler updateUserStory; + private final DeleteUserStoryCommandHandler deleteUserStory; + private final BatchDeleteUserStoriesCommandHandler batchDeleteUserStories; @Override @PreAuthorize("@authz.projectPermission(#projectId, 'STORY_WRITE', authentication)") @@ -82,6 +88,20 @@ public ResponseEntity update(UUID projectId, UUID storyId, Up return ResponseEntity.ok(UserStoryResponseMapper.toResponse(story)); } + @Override + @PreAuthorize("@authz.projectPermission(#projectId, 'STORY_DELETE', authentication)") + public ResponseEntity delete(UUID projectId, UUID storyId) { + deleteUserStory.handle(UserStoryRequestMapper.toDeleteCommand(projectId, storyId)); + return ResponseEntity.noContent().build(); + } + + @Override + @PreAuthorize("@authz.projectPermission(#projectId, 'STORY_DELETE', authentication)") + public ResponseEntity batchDelete(UUID projectId, BatchDeleteUserStoriesRequest request) { + int deleted = batchDeleteUserStories.handle(UserStoryRequestMapper.toBatchDeleteCommand(projectId, request)); + return ResponseEntity.ok(new BatchDeleteUserStoriesResponse(deleted)); + } + /** * Parses an optional enum query param, treating {@code null}/blank as "no filter". An unrecognized * value is a client error → 400, rather than being silently dropped (which would return an diff --git a/src/main/java/com/kntro/reqsai/discovery/interfaces/rest/dto/request/BatchDeleteUserStoriesRequest.java b/src/main/java/com/kntro/reqsai/discovery/interfaces/rest/dto/request/BatchDeleteUserStoriesRequest.java new file mode 100644 index 00000000..1276f314 --- /dev/null +++ b/src/main/java/com/kntro/reqsai/discovery/interfaces/rest/dto/request/BatchDeleteUserStoriesRequest.java @@ -0,0 +1,19 @@ +package com.kntro.reqsai.discovery.interfaces.rest.dto.request; + +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotEmpty; +import jakarta.validation.constraints.Size; + +import java.util.List; +import java.util.UUID; + +/** + * Request body to delete several user stories of a project in one call. Ids not belonging to the + * project are silently skipped; the response reports how many were actually deleted. + */ +@Schema(description = "Request body to delete several user stories in one call") +public record BatchDeleteUserStoriesRequest( + @Schema(description = "Ids of the stories to delete (ids not in the project are skipped)", + example = "[\"019756a0-1234-7abc-8def-000000000010\",\"019756a0-1234-7abc-8def-000000000011\"]") + @NotEmpty @Size(max = 200) List storyIds +) {} diff --git a/src/main/java/com/kntro/reqsai/discovery/interfaces/rest/dto/response/BatchDeleteUserStoriesResponse.java b/src/main/java/com/kntro/reqsai/discovery/interfaces/rest/dto/response/BatchDeleteUserStoriesResponse.java new file mode 100644 index 00000000..fe9a0400 --- /dev/null +++ b/src/main/java/com/kntro/reqsai/discovery/interfaces/rest/dto/response/BatchDeleteUserStoriesResponse.java @@ -0,0 +1,13 @@ +package com.kntro.reqsai.discovery.interfaces.rest.dto.response; + +import io.swagger.v3.oas.annotations.media.Schema; + +/** + * Result of a batch user-story delete: how many of the requested stories were actually deleted + * (candidate ids not found in the project are skipped and not counted). + */ +@Schema(description = "Result of a batch user-story delete") +public record BatchDeleteUserStoriesResponse( + @Schema(description = "Number of stories actually deleted", example = "3") + int deleted +) {} diff --git a/src/main/java/com/kntro/reqsai/discovery/interfaces/rest/mappers/request/UserStoryRequestMapper.java b/src/main/java/com/kntro/reqsai/discovery/interfaces/rest/mappers/request/UserStoryRequestMapper.java index daa4b27c..68185bef 100644 --- a/src/main/java/com/kntro/reqsai/discovery/interfaces/rest/mappers/request/UserStoryRequestMapper.java +++ b/src/main/java/com/kntro/reqsai/discovery/interfaces/rest/mappers/request/UserStoryRequestMapper.java @@ -1,7 +1,10 @@ package com.kntro.reqsai.discovery.interfaces.rest.mappers.request; +import com.kntro.reqsai.discovery.application.command.BatchDeleteUserStoriesCommand; import com.kntro.reqsai.discovery.application.command.CreateUserStoryCommand; +import com.kntro.reqsai.discovery.application.command.DeleteUserStoryCommand; import com.kntro.reqsai.discovery.application.command.UpdateUserStoryCommand; +import com.kntro.reqsai.discovery.interfaces.rest.dto.request.BatchDeleteUserStoriesRequest; import com.kntro.reqsai.discovery.interfaces.rest.dto.request.CreateUserStoryRequest; import com.kntro.reqsai.discovery.interfaces.rest.dto.request.UpdateUserStoryRequest; @@ -21,4 +24,12 @@ public static CreateUserStoryCommand toCommand(UUID projectId, CreateUserStoryRe public static UpdateUserStoryCommand toUpdateCommand(UUID projectId, UUID storyId, UpdateUserStoryRequest request) { return new UpdateUserStoryCommand(projectId, storyId, request.title(), request.role(), request.action(), request.benefit(), request.priority(), request.storyPoints()); } + + public static DeleteUserStoryCommand toDeleteCommand(UUID projectId, UUID storyId) { + return new DeleteUserStoryCommand(projectId, storyId); + } + + public static BatchDeleteUserStoriesCommand toBatchDeleteCommand(UUID projectId, BatchDeleteUserStoriesRequest request) { + return new BatchDeleteUserStoriesCommand(projectId, request.storyIds()); + } } diff --git a/src/main/java/com/kntro/reqsai/discovery/interfaces/rest/swagger/ProjectStoryController.java b/src/main/java/com/kntro/reqsai/discovery/interfaces/rest/swagger/ProjectStoryController.java index dd48a24b..e8f5a758 100644 --- a/src/main/java/com/kntro/reqsai/discovery/interfaces/rest/swagger/ProjectStoryController.java +++ b/src/main/java/com/kntro/reqsai/discovery/interfaces/rest/swagger/ProjectStoryController.java @@ -1,7 +1,9 @@ package com.kntro.reqsai.discovery.interfaces.rest.swagger; +import com.kntro.reqsai.discovery.interfaces.rest.dto.request.BatchDeleteUserStoriesRequest; import com.kntro.reqsai.discovery.interfaces.rest.dto.request.CreateUserStoryRequest; import com.kntro.reqsai.discovery.interfaces.rest.dto.request.UpdateUserStoryRequest; +import com.kntro.reqsai.discovery.interfaces.rest.dto.response.BatchDeleteUserStoriesResponse; import com.kntro.reqsai.discovery.interfaces.rest.dto.response.UserStoryResponse; import com.kntro.reqsai.shared.interfaces.pagination.PageResponse; import com.kntro.reqsai.shared.infrastructure.configuration.ApiVersioning; @@ -21,6 +23,7 @@ import org.springframework.format.annotation.DateTimeFormat; import org.springframework.http.MediaType; import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.DeleteMapping; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.PathVariable; import org.springframework.web.bind.annotation.PostMapping; @@ -157,4 +160,45 @@ ResponseEntity update( @Parameter(description = "Story to update", required = true) @PathVariable UUID storyId, @Valid @RequestBody UpdateUserStoryRequest request); + + @Operation( + summary = "Delete a user story", + description = """ + Permanently deletes a single user story of the given project, scoped to the \ + authenticated tenant. The story's acceptance criteria are removed with it. This is a \ + Reqs-AI-local delete only: it does NOT touch any external tracker (e.g. Jira) issue \ + the story was previously exported to.""") + @ApiResponse(responseCode = "204", description = "Story deleted") + @ApiResponseNotFound + @ApiStandardErrorResponses + @SecurityRequirement(name = OpenApiConfiguration.BEARER_SCHEME) + @DeleteMapping(path = "/{storyId}", version = ApiVersioning.V1) + ResponseEntity delete( + @Parameter(description = "Project the story belongs to", required = true) + @PathVariable UUID projectId, + @Parameter(description = "Story to delete", required = true) + @PathVariable UUID storyId); + + @Operation( + summary = "Delete several user stories in one call", + description = """ + Permanently deletes the given stories of the project, scoped to the authenticated \ + tenant, and returns how many were actually deleted. Ids not found in the project are \ + silently skipped (never an error). Each deleted story's acceptance criteria are \ + removed with it. Local delete only: it does NOT touch any external tracker (e.g. \ + Jira) issue a story was exported to.""") + @ApiResponse( + responseCode = "200", + description = "Stories deleted; body reports the deleted count", + content = @Content(mediaType = MediaType.APPLICATION_JSON_VALUE, + schema = @Schema(implementation = BatchDeleteUserStoriesResponse.class), + examples = @ExampleObject(value = "{ \"deleted\": 3 }"))) + @ApiResponseBadRequest + @ApiStandardErrorResponses + @SecurityRequirement(name = OpenApiConfiguration.BEARER_SCHEME) + @PostMapping(path = "/batch-delete", version = ApiVersioning.V1) + ResponseEntity batchDelete( + @Parameter(description = "Project the stories belong to", required = true) + @PathVariable UUID projectId, + @Valid @RequestBody BatchDeleteUserStoriesRequest request); } diff --git a/src/main/java/com/kntro/reqsai/discovery/interfaces/websocket/presence/SessionParticipantResolver.java b/src/main/java/com/kntro/reqsai/discovery/interfaces/websocket/presence/SessionParticipantResolver.java new file mode 100644 index 00000000..461c94ec --- /dev/null +++ b/src/main/java/com/kntro/reqsai/discovery/interfaces/websocket/presence/SessionParticipantResolver.java @@ -0,0 +1,50 @@ +package com.kntro.reqsai.discovery.interfaces.websocket.presence; + +import com.github.benmanes.caffeine.cache.Cache; +import com.github.benmanes.caffeine.cache.Caffeine; +import com.kntro.reqsai.discovery.interfaces.notification.messages.SessionParticipant; +import com.kntro.reqsai.shared.application.avatar.AvatarPaths; +import com.kntro.reqsai.workspace.api.WorkspaceModuleApi; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Component; + +import java.time.Duration; +import java.util.UUID; + +/** + * Turns a bare {@code userId} (plus the connection's tenant) into a display-ready + * {@link SessionParticipant} for the presence roster. + *

+ * The display name comes from the workspace member roster through the {@code workspace::api} ACL and + * is Caffeine-cached (keyed by tenant + user) so a chatty stream of join/leave + * events does not hit the database on every transition. The avatar URL is deterministic + * ({@link AvatarPaths#user(UUID)}) and needs no lookup. + */ +@Component +@RequiredArgsConstructor +public class SessionParticipantResolver { + + /** Shown when a user id cannot be matched to an active membership (e.g. removed mid-session). */ + static final String UNKNOWN_DISPLAY_NAME = "Participant"; + + private final WorkspaceModuleApi workspace; + + private final Cache displayNames = Caffeine.newBuilder() + .maximumSize(10_000) + .expireAfterWrite(Duration.ofMinutes(30)) + .build(); + + /** + * Resolves the participant view for {@code userId} in {@code orgId}. Never returns {@code null}: + * an unresolved membership falls back to a generic label so a present user is still shown. + */ + public SessionParticipant resolve(UUID orgId, UUID userId) { + String displayName = displayNames.get(cacheKey(orgId, userId), key -> + workspace.findMemberDisplayName(orgId, userId).orElse(UNKNOWN_DISPLAY_NAME)); + return new SessionParticipant(userId, displayName, AvatarPaths.user(userId)); + } + + private static String cacheKey(UUID orgId, UUID userId) { + return orgId + ":" + userId; + } +} diff --git a/src/main/java/com/kntro/reqsai/discovery/interfaces/websocket/presence/SessionPresenceRegistry.java b/src/main/java/com/kntro/reqsai/discovery/interfaces/websocket/presence/SessionPresenceRegistry.java new file mode 100644 index 00000000..7d39b918 --- /dev/null +++ b/src/main/java/com/kntro/reqsai/discovery/interfaces/websocket/presence/SessionPresenceRegistry.java @@ -0,0 +1,126 @@ +package com.kntro.reqsai.discovery.interfaces.websocket.presence; + +import org.springframework.stereotype.Component; + +import java.util.LinkedHashMap; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.Set; +import java.util.UUID; +import java.util.concurrent.ConcurrentHashMap; + +/** + * In-process registry of who is currently viewing each live discovery session, driven by STOMP + * subscribe/unsubscribe/disconnect events. Deliberately not Redis: presence is + * ephemeral, per-connection state, so it lives in the JVM alongside the in-memory broker. + * + *

Scaling note: like the {@code SIMPLE} broker (ADR-0007), this registry only + * sees connections on the local JVM. With multiple instances behind a {@code RELAY} broker, each + * instance tracks its own slice; a fully global roster would need the broker's shared state. This is + * acceptable at single-instance scale and is the same trade-off already accepted for broadcasts. + * + *

Presence is keyed by discovery {@code sessionId}. A single user viewing from two browser tabs + * (two STOMP sessions) counts once — {@link #roster(UUID)} returns distinct user ids. All mutating + * methods return whether the visible roster for a session actually changed, so the caller only + * re-broadcasts on real transitions. + */ +@Component +public class SessionPresenceRegistry { + + /** + * discovery sessionId → (stompSessionId → userId) of everyone currently subscribed. The inner map + * is a {@link LinkedHashMap} so the roster keeps a stable join order (avatars don't reshuffle); + * safe because every access below is {@code synchronized}. + */ + private final Map> presenceBySession = new ConcurrentHashMap<>(); + + /** stompSessionId → (subscriptionId → discovery sessionId), to resolve unsubscribe/disconnect. */ + private final Map> subscriptionsByStomp = new ConcurrentHashMap<>(); + + /** + * Records that {@code userId} subscribed to {@code sessionId} on a STOMP connection. + * + * @return {@code true} when this made the user newly present in the session (roster grew) + */ + public synchronized boolean join(UUID sessionId, String stompSessionId, String subscriptionId, UUID userId) { + subscriptionsByStomp + .computeIfAbsent(stompSessionId, k -> new ConcurrentHashMap<>()) + .put(subscriptionId, sessionId); + boolean userWasPresent = isUserPresent(sessionId, userId); + presenceBySession + .computeIfAbsent(sessionId, k -> new LinkedHashMap<>()) + .put(stompSessionId, userId); + return !userWasPresent; + } + + /** + * Removes a single subscription (STOMP UNSUBSCRIBE). If it was the connection's last subscription + * to that session, the connection stops being present. + * + * @return the affected session id when the visible roster changed, otherwise empty + */ + public synchronized Optional leaveSubscription(String stompSessionId, String subscriptionId) { + Map subs = subscriptionsByStomp.get(stompSessionId); + if (subs == null) { + return Optional.empty(); + } + UUID sessionId = subs.remove(subscriptionId); + if (subs.isEmpty()) { + subscriptionsByStomp.remove(stompSessionId); + } + if (sessionId == null || subs.containsValue(sessionId)) { + // Unknown subscription, or the connection still views this session via another subscription. + return Optional.empty(); + } + return removeConnectionFromSession(sessionId, stompSessionId); + } + + /** + * Removes a whole STOMP connection (DISCONNECT), dropping it from every session it viewed. + * + * @return the set of sessions whose visible roster changed + */ + public synchronized Set disconnect(String stompSessionId) { + Map subs = subscriptionsByStomp.remove(stompSessionId); + if (subs == null) { + return Set.of(); + } + Set changed = new LinkedHashSet<>(); + for (UUID sessionId : Set.copyOf(subs.values())) { + removeConnectionFromSession(sessionId, stompSessionId).ifPresent(changed::add); + } + return changed; + } + + /** Distinct user ids currently present in {@code sessionId}, insertion-ordered. */ + public synchronized List roster(UUID sessionId) { + Map present = presenceBySession.get(sessionId); + if (present == null) { + return List.of(); + } + return List.copyOf(new LinkedHashSet<>(present.values())); + } + + private Optional removeConnectionFromSession(UUID sessionId, String stompSessionId) { + Map present = presenceBySession.get(sessionId); + if (present == null) { + return Optional.empty(); + } + UUID removedUser = present.remove(stompSessionId); + if (present.isEmpty()) { + presenceBySession.remove(sessionId); + } + if (removedUser == null) { + return Optional.empty(); + } + // Roster only changed if that user is no longer present via another connection (another tab). + return present.containsValue(removedUser) ? Optional.empty() : Optional.of(sessionId); + } + + private boolean isUserPresent(UUID sessionId, UUID userId) { + Map present = presenceBySession.get(sessionId); + return present != null && present.containsValue(userId); + } +} diff --git a/src/main/java/com/kntro/reqsai/discovery/interfaces/websocket/presence/SessionPresenceTracker.java b/src/main/java/com/kntro/reqsai/discovery/interfaces/websocket/presence/SessionPresenceTracker.java new file mode 100644 index 00000000..4a46609a --- /dev/null +++ b/src/main/java/com/kntro/reqsai/discovery/interfaces/websocket/presence/SessionPresenceTracker.java @@ -0,0 +1,141 @@ +package com.kntro.reqsai.discovery.interfaces.websocket.presence; + +import com.kntro.reqsai.discovery.application.notification.SessionTopics; +import com.kntro.reqsai.discovery.interfaces.notification.messages.SessionParticipant; +import com.kntro.reqsai.discovery.interfaces.notification.messages.SessionPresenceMessage; +import com.kntro.reqsai.shared.application.avatar.AvatarPaths; +import com.kntro.reqsai.shared.application.notification.RealtimeNotifier; +import com.kntro.reqsai.shared.infrastructure.web.websocket.StompAuthChannelInterceptor; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.context.event.EventListener; +import org.springframework.messaging.simp.stomp.StompHeaderAccessor; +import org.springframework.stereotype.Component; +import org.springframework.web.socket.messaging.SessionDisconnectEvent; +import org.springframework.web.socket.messaging.SessionSubscribeEvent; +import org.springframework.web.socket.messaging.SessionUnsubscribeEvent; + +import java.time.Instant; +import java.util.List; +import java.util.Map; +import java.util.UUID; + +/** + * Live-presence tracker for discovery sessions. Listens to STOMP lifecycle events and keeps + * {@link SessionPresenceRegistry} in sync: a SUBSCRIBE to {@code /topic/sessions/{id}} marks the user + * present, an UNSUBSCRIBE or DISCONNECT removes them. On every real roster change it rebroadcasts the + * full {@link SessionPresenceMessage} snapshot on that session's topic, so all viewers converge. + *

+ * Presence rides the same per-session topic the client already subscribes to — no extra subscription, + * and the subscription itself is the presence signal (viewing the live session = present). + */ +@Component +@RequiredArgsConstructor +@Slf4j +public class SessionPresenceTracker { + + private static final String SESSION_DESTINATION_PREFIX = "/topic/" + SessionTopics.sessionsPrefix(); + + private final SessionPresenceRegistry registry; + private final SessionParticipantResolver resolver; + private final RealtimeNotifier notifier; + + @EventListener + void onSubscribe(SessionSubscribeEvent event) { + StompHeaderAccessor accessor = StompHeaderAccessor.wrap(event.getMessage()); + UUID sessionId = parseSessionId(accessor.getDestination()); + Map attributes = accessor.getSessionAttributes(); + UUID userId = attributeUuid(attributes, StompAuthChannelInterceptor.USER_ID_ATTRIBUTE); + UUID orgId = attributeUuid(attributes, StompAuthChannelInterceptor.ORG_ID_ATTRIBUTE); + String stompSessionId = accessor.getSessionId(); + String subscriptionId = accessor.getSubscriptionId(); + if (sessionId == null || userId == null || orgId == null + || stompSessionId == null || subscriptionId == null) { + return; + } + if (registry.join(sessionId, stompSessionId, subscriptionId, userId)) { + broadcast(sessionId, orgId); + } + } + + @EventListener + void onUnsubscribe(SessionUnsubscribeEvent event) { + StompHeaderAccessor accessor = StompHeaderAccessor.wrap(event.getMessage()); + String stompSessionId = accessor.getSessionId(); + String subscriptionId = accessor.getSubscriptionId(); + if (stompSessionId == null || subscriptionId == null) { + return; + } + UUID orgId = attributeUuid(accessor.getSessionAttributes(), StompAuthChannelInterceptor.ORG_ID_ATTRIBUTE); + registry.leaveSubscription(stompSessionId, subscriptionId) + .ifPresent(sessionId -> broadcast(sessionId, orgId)); + } + + @EventListener + void onDisconnect(SessionDisconnectEvent event) { + StompHeaderAccessor accessor = StompHeaderAccessor.wrap(event.getMessage()); + String stompSessionId = event.getSessionId(); + if (stompSessionId == null) { + return; + } + UUID orgId = attributeUuid(accessor.getSessionAttributes(), StompAuthChannelInterceptor.ORG_ID_ATTRIBUTE); + for (UUID sessionId : registry.disconnect(stompSessionId)) { + broadcast(sessionId, orgId); + } + } + + /** + * Rebroadcasts the current roster for a session. {@code orgId} comes from the connection that + * triggered the change; since a discovery session is single-tenant, every participant resolves + * under the same organization. A missing {@code orgId} still broadcasts an anonymous roster so + * the count stays correct. + */ + private void broadcast(UUID sessionId, UUID orgId) { + List participants = registry.roster(sessionId).stream() + .map(userId -> resolveParticipant(orgId, userId)) + .toList(); + notifier.broadcast(SessionTopics.of(sessionId), + SessionPresenceMessage.of(sessionId, participants, Instant.now())); + log.debug("Presence for session {}: {} participant(s)", sessionId, participants.size()); + } + + private SessionParticipant resolveParticipant(UUID orgId, UUID userId) { + if (orgId == null) { + return new SessionParticipant(userId, SessionParticipantResolver.UNKNOWN_DISPLAY_NAME, + AvatarPaths.user(userId)); + } + return resolver.resolve(orgId, userId); + } + + private static UUID parseSessionId(String destination) { + if (destination == null || !destination.startsWith(SESSION_DESTINATION_PREFIX)) { + return null; + } + String raw = destination.substring(SESSION_DESTINATION_PREFIX.length()); + try { + return UUID.fromString(raw); + } catch (IllegalArgumentException ex) { + return null; + } + } + + /** + * Reads a UUID-valued STOMP session attribute (see {@link StompAuthChannelInterceptor}). + * Session attributes — unlike the frame's {@code Principal} — persist across every frame of a + * STOMP session, which is why identity is read from here rather than {@code accessor.getUser()}. + */ + private static UUID attributeUuid(Map attributes, String key) { + if (attributes == null) { + return null; + } + Object value = attributes.get(key); + if (!(value instanceof String raw)) { + return null; + } + try { + return UUID.fromString(raw); + } catch (IllegalArgumentException ex) { + return null; + } + } +} diff --git a/src/main/java/com/kntro/reqsai/gateway/application/command/ConnectJiraCommand.java b/src/main/java/com/kntro/reqsai/gateway/application/command/ConnectJiraCommand.java new file mode 100644 index 00000000..c5786c0d --- /dev/null +++ b/src/main/java/com/kntro/reqsai/gateway/application/command/ConnectJiraCommand.java @@ -0,0 +1,12 @@ +package com.kntro.reqsai.gateway.application.command; + +import java.util.UUID; + +/** Connect a Jira integration at the organization level (verifies the credential, then persists it). */ +public record ConnectJiraCommand( + UUID organizationId, + String siteUrl, + String email, + String apiToken, + UUID requestedBy +) {} diff --git a/src/main/java/com/kntro/reqsai/gateway/application/command/DeleteConnectionCommand.java b/src/main/java/com/kntro/reqsai/gateway/application/command/DeleteConnectionCommand.java new file mode 100644 index 00000000..51a2e435 --- /dev/null +++ b/src/main/java/com/kntro/reqsai/gateway/application/command/DeleteConnectionCommand.java @@ -0,0 +1,6 @@ +package com.kntro.reqsai.gateway.application.command; + +import java.util.UUID; + +/** Delete an organization integration connection. */ +public record DeleteConnectionCommand(UUID organizationId, UUID connectionId, UUID requestedBy) {} diff --git a/src/main/java/com/kntro/reqsai/gateway/application/command/DeleteProjectTargetCommand.java b/src/main/java/com/kntro/reqsai/gateway/application/command/DeleteProjectTargetCommand.java new file mode 100644 index 00000000..7b265298 --- /dev/null +++ b/src/main/java/com/kntro/reqsai/gateway/application/command/DeleteProjectTargetCommand.java @@ -0,0 +1,6 @@ +package com.kntro.reqsai.gateway.application.command; + +import java.util.UUID; + +/** Delete a project's Jira push target. */ +public record DeleteProjectTargetCommand(UUID projectId, UUID requestedBy) {} diff --git a/src/main/java/com/kntro/reqsai/gateway/application/command/ImportJiraStoriesCommand.java b/src/main/java/com/kntro/reqsai/gateway/application/command/ImportJiraStoriesCommand.java new file mode 100644 index 00000000..c6ca81a8 --- /dev/null +++ b/src/main/java/com/kntro/reqsai/gateway/application/command/ImportJiraStoriesCommand.java @@ -0,0 +1,15 @@ +package com.kntro.reqsai.gateway.application.command; + +import org.jspecify.annotations.Nullable; + +import java.util.List; +import java.util.UUID; + +/** + * Intent to import Jira issues from the project's configured target into the backlog as user stories. + * + * @param projectId the project (its {@code project_integration_targets} row says WHERE to pull from) + * @param issueKeys the specific Jira issue keys to import; {@code null}/empty means all eligible issues + * @param requestedBy caller id (authorization already enforced at the controller) + */ +public record ImportJiraStoriesCommand(UUID projectId, @Nullable List issueKeys, UUID requestedBy) {} diff --git a/src/main/java/com/kntro/reqsai/gateway/application/command/JiraOAuthCallbackCommand.java b/src/main/java/com/kntro/reqsai/gateway/application/command/JiraOAuthCallbackCommand.java new file mode 100644 index 00000000..b75aeee2 --- /dev/null +++ b/src/main/java/com/kntro/reqsai/gateway/application/command/JiraOAuthCallbackCommand.java @@ -0,0 +1,19 @@ +package com.kntro.reqsai.gateway.application.command; + +import org.jspecify.annotations.Nullable; + +import java.util.UUID; + +/** + * Completes the Jira OAuth 2.0 (3LO) flow at the organization level (ADR-0023): validates {@code state}, + * exchanges {@code code}, discovers accessible sites and — if a site is chosen ({@code cloudId} given or + * exactly one available) — persists an OAUTH2 connection. When multiple sites exist and {@code cloudId} + * is null the handler returns the site list WITHOUT saving. + */ +public record JiraOAuthCallbackCommand( + UUID organizationId, + String code, + String state, + @Nullable String cloudId, + UUID requestedBy +) {} diff --git a/src/main/java/com/kntro/reqsai/gateway/application/command/PushAllStoriesCommand.java b/src/main/java/com/kntro/reqsai/gateway/application/command/PushAllStoriesCommand.java new file mode 100644 index 00000000..a65e3621 --- /dev/null +++ b/src/main/java/com/kntro/reqsai/gateway/application/command/PushAllStoriesCommand.java @@ -0,0 +1,17 @@ +package com.kntro.reqsai.gateway.application.command; + +import org.jspecify.annotations.Nullable; + +import java.util.List; +import java.util.UUID; + +/** + * Push stories of a project to the project's configured Jira target (per-story failures captured). + * {@code storyIds} optionally restricts the push to the given stories; {@code null}/empty means every + * eligible story (the unrestricted, original behaviour). Ids not in the project are ignored. + * + * @param projectId project whose stories are pushed + * @param storyIds the specific stories to push; {@code null}/empty means all eligible stories + * @param requestedBy caller id (authorization already enforced at the controller) + */ +public record PushAllStoriesCommand(UUID projectId, @Nullable List storyIds, UUID requestedBy) {} diff --git a/src/main/java/com/kntro/reqsai/gateway/application/command/PushStoryCommand.java b/src/main/java/com/kntro/reqsai/gateway/application/command/PushStoryCommand.java new file mode 100644 index 00000000..146bbf41 --- /dev/null +++ b/src/main/java/com/kntro/reqsai/gateway/application/command/PushStoryCommand.java @@ -0,0 +1,6 @@ +package com.kntro.reqsai.gateway.application.command; + +import java.util.UUID; + +/** Push a single project story to the project's configured Jira target. */ +public record PushStoryCommand(UUID projectId, UUID storyId, UUID requestedBy) {} diff --git a/src/main/java/com/kntro/reqsai/gateway/application/command/SaveProjectTargetCommand.java b/src/main/java/com/kntro/reqsai/gateway/application/command/SaveProjectTargetCommand.java new file mode 100644 index 00000000..b6c6eb9c --- /dev/null +++ b/src/main/java/com/kntro/reqsai/gateway/application/command/SaveProjectTargetCommand.java @@ -0,0 +1,12 @@ +package com.kntro.reqsai.gateway.application.command; + +import java.util.UUID; + +/** Create or replace the single Jira push target of a project. */ +public record SaveProjectTargetCommand( + UUID projectId, + UUID connectionId, + String jiraProjectKey, + String issueTypeName, + UUID requestedBy +) {} diff --git a/src/main/java/com/kntro/reqsai/gateway/application/config/JiraOAuthProperties.java b/src/main/java/com/kntro/reqsai/gateway/application/config/JiraOAuthProperties.java new file mode 100644 index 00000000..bdc8ed7f --- /dev/null +++ b/src/main/java/com/kntro/reqsai/gateway/application/config/JiraOAuthProperties.java @@ -0,0 +1,41 @@ +package com.kntro.reqsai.gateway.application.config; + +import org.springframework.boot.context.properties.ConfigurationProperties; +import org.jspecify.annotations.Nullable; + +/** + * Jira OAuth 2.0 (3LO) app configuration bound from {@code reqsai.integrations.jira.oauth.*} (ADR-0023). + *

+ * All fields are OPTIONAL: when {@link #clientId}, {@link #clientSecret} or {@link #redirectUri} is + * blank the feature is considered not configured ({@link #configured()} is false) and the OAuth + * endpoints answer {@code JIRA_OAUTH_NOT_CONFIGURED} — the app still boots (unlike the required + * encryption key). Secrets are read from the environment and never logged. + * + * @param clientId the OAuth app client id (blank ⇒ not configured) + * @param clientSecret the OAuth app client secret (blank ⇒ not configured) + * @param redirectUri the registered callback URL (blank ⇒ not configured) + * @param stateSecret dedicated HMAC secret ({@code JIRA_OAUTH_STATE_SECRET}, a random hex string) for + * signing the stateless {@code state} token + */ +@ConfigurationProperties(prefix = "reqsai.integrations.jira.oauth") +public record JiraOAuthProperties( + @Nullable String clientId, + @Nullable String clientSecret, + @Nullable String redirectUri, + @Nullable String stateSecret +) { + + /** True only when the three app credentials required to run the flow are all present. */ + public boolean configured() { + return notBlank(clientId) && notBlank(clientSecret) && notBlank(redirectUri); + } + + /** The dedicated HMAC signing secret (raw UTF-8 key material); empty when unset. */ + public String effectiveStateSecret() { + return notBlank(stateSecret) ? stateSecret : ""; + } + + private static boolean notBlank(@Nullable String value) { + return value != null && !value.isBlank(); + } +} diff --git a/src/main/java/com/kntro/reqsai/gateway/application/handler/ConnectJiraCommandHandler.java b/src/main/java/com/kntro/reqsai/gateway/application/handler/ConnectJiraCommandHandler.java new file mode 100644 index 00000000..92352cdf --- /dev/null +++ b/src/main/java/com/kntro/reqsai/gateway/application/handler/ConnectJiraCommandHandler.java @@ -0,0 +1,46 @@ +package com.kntro.reqsai.gateway.application.handler; + +import com.kntro.reqsai.gateway.application.command.ConnectJiraCommand; +import com.kntro.reqsai.gateway.application.port.IntegrationConnectionRepository; +import com.kntro.reqsai.gateway.application.port.IntegrationProvider; +import com.kntro.reqsai.gateway.application.service.ProviderRegistry; +import com.kntro.reqsai.gateway.domain.exception.IntegrationsExceptions; +import com.kntro.reqsai.gateway.domain.model.ConnectionStatus; +import com.kntro.reqsai.gateway.domain.model.IntegrationConnection; +import com.kntro.reqsai.gateway.domain.model.IntegrationProviderType; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Component; +import org.springframework.transaction.annotation.Transactional; + +import java.time.Instant; + +/** + * Connects a Jira integration at the organization level: verifies the credential against Jira + * (fail → {@code JIRA_AUTH_FAILED}/{@code JIRA_UNREACHABLE}) and, on success, persists an encrypted + * connection. Rejects a second active connection with {@code INTEGRATION_ALREADY_CONNECTED}. + */ +@Component +@RequiredArgsConstructor +public class ConnectJiraCommandHandler { + + private final IntegrationConnectionRepository connections; + private final ProviderRegistry providers; + + @Transactional + public IntegrationConnection handle(ConnectJiraCommand command) { + if (connections.existsByOrganizationIdAndProviderAndStatusNot( + command.organizationId(), IntegrationProviderType.JIRA, ConnectionStatus.DISCONNECTED)) { + throw IntegrationsExceptions.alreadyConnected(command.organizationId(), IntegrationProviderType.JIRA.name()); + } + + String siteUrl = IntegrationConnection.normalizeSiteUrl(command.siteUrl()); + IntegrationProvider provider = providers.get(IntegrationProviderType.JIRA); + // Verify the credential BEFORE persisting anything. Throws on auth/reachability failure. + provider.verify(IntegrationProvider.ProviderCredentials.apiToken(siteUrl, command.email(), command.apiToken())); + + IntegrationConnection connection = new IntegrationConnection( + command.organizationId(), IntegrationProviderType.JIRA, + siteUrl, command.email(), command.apiToken(), Instant.now()); + return connections.save(connection); + } +} diff --git a/src/main/java/com/kntro/reqsai/gateway/application/handler/DeleteConnectionCommandHandler.java b/src/main/java/com/kntro/reqsai/gateway/application/handler/DeleteConnectionCommandHandler.java new file mode 100644 index 00000000..90acdbb4 --- /dev/null +++ b/src/main/java/com/kntro/reqsai/gateway/application/handler/DeleteConnectionCommandHandler.java @@ -0,0 +1,28 @@ +package com.kntro.reqsai.gateway.application.handler; + +import com.kntro.reqsai.gateway.application.command.DeleteConnectionCommand; +import com.kntro.reqsai.gateway.application.port.IntegrationConnectionRepository; +import com.kntro.reqsai.gateway.domain.exception.IntegrationsExceptions; +import com.kntro.reqsai.gateway.domain.model.IntegrationConnection; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Component; +import org.springframework.transaction.annotation.Transactional; + +/** + * Deletes an organization connection. Project targets referencing it are removed by the FK + * {@code ON DELETE CASCADE}. + */ +@Component +@RequiredArgsConstructor +public class DeleteConnectionCommandHandler { + + private final IntegrationConnectionRepository connections; + + @Transactional + public void handle(DeleteConnectionCommand command) { + IntegrationConnection connection = connections + .findByIdAndOrganizationId(command.connectionId(), command.organizationId()) + .orElseThrow(() -> IntegrationsExceptions.connectionNotFound(command.connectionId())); + connections.delete(connection); + } +} diff --git a/src/main/java/com/kntro/reqsai/gateway/application/handler/DeleteProjectTargetCommandHandler.java b/src/main/java/com/kntro/reqsai/gateway/application/handler/DeleteProjectTargetCommandHandler.java new file mode 100644 index 00000000..96d523d2 --- /dev/null +++ b/src/main/java/com/kntro/reqsai/gateway/application/handler/DeleteProjectTargetCommandHandler.java @@ -0,0 +1,24 @@ +package com.kntro.reqsai.gateway.application.handler; + +import com.kntro.reqsai.gateway.application.command.DeleteProjectTargetCommand; +import com.kntro.reqsai.gateway.application.port.ProjectIntegrationTargetRepository; +import com.kntro.reqsai.gateway.domain.exception.IntegrationsExceptions; +import com.kntro.reqsai.gateway.domain.model.ProjectIntegrationTarget; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Component; +import org.springframework.transaction.annotation.Transactional; + +/** Deletes a project's Jira target (404 when none is configured). */ +@Component +@RequiredArgsConstructor +public class DeleteProjectTargetCommandHandler { + + private final ProjectIntegrationTargetRepository targets; + + @Transactional + public void handle(DeleteProjectTargetCommand command) { + ProjectIntegrationTarget target = targets.findByProjectId(command.projectId()) + .orElseThrow(() -> IntegrationsExceptions.targetNotFound(command.projectId())); + targets.delete(target); + } +} diff --git a/src/main/java/com/kntro/reqsai/gateway/application/handler/GetIntegrationJobQueryHandler.java b/src/main/java/com/kntro/reqsai/gateway/application/handler/GetIntegrationJobQueryHandler.java new file mode 100644 index 00000000..a098c338 --- /dev/null +++ b/src/main/java/com/kntro/reqsai/gateway/application/handler/GetIntegrationJobQueryHandler.java @@ -0,0 +1,27 @@ +package com.kntro.reqsai.gateway.application.handler; + +import com.kntro.reqsai.gateway.application.port.IntegrationSyncJobRepository; +import com.kntro.reqsai.gateway.application.query.GetIntegrationJobQuery; +import com.kntro.reqsai.gateway.domain.exception.IntegrationsExceptions; +import com.kntro.reqsai.gateway.domain.model.IntegrationSyncJob; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Component; +import org.springframework.transaction.annotation.Transactional; + +/** + * Fetches one integration sync job, scoped to the project in the path: a job id belonging to a + * different project 404s ({@code INTEGRATION_JOB_NOT_FOUND}) just like a nonexistent one. + */ +@Component +@RequiredArgsConstructor +public class GetIntegrationJobQueryHandler { + + private final IntegrationSyncJobRepository jobs; + + @Transactional(readOnly = true) + public IntegrationSyncJob handle(GetIntegrationJobQuery query) { + return jobs.findById(query.jobId()) + .filter(job -> job.getProjectId().equals(query.projectId())) + .orElseThrow(() -> IntegrationsExceptions.jobNotFound(query.jobId())); + } +} diff --git a/src/main/java/com/kntro/reqsai/gateway/application/handler/GetProjectTargetQueryHandler.java b/src/main/java/com/kntro/reqsai/gateway/application/handler/GetProjectTargetQueryHandler.java new file mode 100644 index 00000000..eb8232cb --- /dev/null +++ b/src/main/java/com/kntro/reqsai/gateway/application/handler/GetProjectTargetQueryHandler.java @@ -0,0 +1,23 @@ +package com.kntro.reqsai.gateway.application.handler; + +import com.kntro.reqsai.gateway.application.port.ProjectIntegrationTargetRepository; +import com.kntro.reqsai.gateway.application.query.GetProjectTargetQuery; +import com.kntro.reqsai.gateway.domain.exception.IntegrationsExceptions; +import com.kntro.reqsai.gateway.domain.model.ProjectIntegrationTarget; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Component; +import org.springframework.transaction.annotation.Transactional; + +/** Reads a project's Jira target, 404 ({@code INTEGRATION_CONNECTION_NOT_FOUND}) when none is set. */ +@Component +@RequiredArgsConstructor +public class GetProjectTargetQueryHandler { + + private final ProjectIntegrationTargetRepository targets; + + @Transactional(readOnly = true) + public ProjectIntegrationTarget handle(GetProjectTargetQuery query) { + return targets.findByProjectId(query.projectId()) + .orElseThrow(() -> IntegrationsExceptions.targetNotFound(query.projectId())); + } +} diff --git a/src/main/java/com/kntro/reqsai/gateway/application/handler/ImportJiraStoriesCommandHandler.java b/src/main/java/com/kntro/reqsai/gateway/application/handler/ImportJiraStoriesCommandHandler.java new file mode 100644 index 00000000..99590011 --- /dev/null +++ b/src/main/java/com/kntro/reqsai/gateway/application/handler/ImportJiraStoriesCommandHandler.java @@ -0,0 +1,41 @@ +package com.kntro.reqsai.gateway.application.handler; + +import com.kntro.reqsai.gateway.application.command.ImportJiraStoriesCommand; +import com.kntro.reqsai.gateway.application.port.IntegrationJobLauncher; +import com.kntro.reqsai.gateway.application.port.ProjectIntegrationTargetRepository; +import com.kntro.reqsai.gateway.application.service.IntegrationSyncJobStarter; +import com.kntro.reqsai.gateway.domain.exception.IntegrationsExceptions; +import com.kntro.reqsai.gateway.domain.model.IntegrationSyncJob; +import com.kntro.reqsai.gateway.domain.model.IntegrationSyncJobType; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Component; + +/** + * Accepts a Jira import request as an asynchronous background job: validates the + * target exists (409 {@code INTEGRATION_TARGET_NOT_CONFIGURED}), persists a RUNNING + * {@code integration_sync_jobs} row (409 {@code INTEGRATION_JOB_ALREADY_RUNNING} when one is + * already running), hands execution to the {@link IntegrationJobLauncher} and returns the job + * snapshot for the 202 response. Progress streams on + * {@code /topic/projects/{projectId}/integration-jobs} and is queryable via the job endpoints. + * Deliberately not {@code @Transactional}: the job row must be committed (and visible to the async + * worker and to reload queries) before the launch. + */ +@Component +@RequiredArgsConstructor +public class ImportJiraStoriesCommandHandler { + + private final ProjectIntegrationTargetRepository targets; + private final IntegrationSyncJobStarter starter; + private final IntegrationJobLauncher launcher; + + public IntegrationSyncJob handle(ImportJiraStoriesCommand command) { + targets.findByProjectId(command.projectId()) + .orElseThrow(() -> IntegrationsExceptions.targetNotConfigured(command.projectId())); + + int knownTotal = command.issueKeys() == null ? 0 : command.issueKeys().size(); + IntegrationSyncJob job = starter.start( + command.projectId(), IntegrationSyncJobType.IMPORT, knownTotal, command.requestedBy()); + launcher.launchImport(job.getId(), command.projectId(), command.issueKeys()); + return job; + } +} diff --git a/src/main/java/com/kntro/reqsai/gateway/application/handler/JiraOAuthCallbackCommandHandler.java b/src/main/java/com/kntro/reqsai/gateway/application/handler/JiraOAuthCallbackCommandHandler.java new file mode 100644 index 00000000..de87ea7b --- /dev/null +++ b/src/main/java/com/kntro/reqsai/gateway/application/handler/JiraOAuthCallbackCommandHandler.java @@ -0,0 +1,113 @@ +package com.kntro.reqsai.gateway.application.handler; + +import com.kntro.reqsai.gateway.application.command.JiraOAuthCallbackCommand; +import com.kntro.reqsai.gateway.application.port.IntegrationConnectionRepository; +import com.kntro.reqsai.gateway.application.port.JiraOAuthPort; +import com.kntro.reqsai.gateway.application.port.JiraOAuthPort.OAuthTokens; +import com.kntro.reqsai.gateway.application.port.JiraOAuthPort.Site; +import com.kntro.reqsai.gateway.application.result.JiraOAuthCallbackResult; +import com.kntro.reqsai.gateway.application.service.JiraOAuthPendingTokenCache; +import com.kntro.reqsai.gateway.application.service.JiraOAuthPendingTokenCache.Pending; +import com.kntro.reqsai.gateway.application.service.JiraOAuthStateService; +import com.kntro.reqsai.gateway.domain.exception.IntegrationsExceptions; +import com.kntro.reqsai.gateway.domain.model.ConnectionStatus; +import com.kntro.reqsai.gateway.domain.model.IntegrationConnection; +import com.kntro.reqsai.gateway.domain.model.IntegrationProviderType; +import com.kntro.reqsai.gateway.application.config.JiraOAuthProperties; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Component; +import org.springframework.transaction.annotation.Transactional; + +import java.time.Instant; +import java.util.List; + +/** + * Completes the Jira OAuth 2.0 (3LO) org-level flow (ADR-0023): + *

    + *
  1. reject if OAuth is not configured ({@code JIRA_OAUTH_NOT_CONFIGURED});
  2. + *
  3. validate the signed {@code state} against this org+user ({@code JIRA_OAUTH_STATE_INVALID});
  4. + *
  5. exchange the authorization {@code code} for tokens and discover accessible sites — but only ONCE: + * authorization codes are single-use, so the exchanged tokens + sites are cached under the signed + * {@code state}. The second (site-selection) callback reuses the cache and never re-exchanges the + * already-consumed code;
  6. + *
  7. if a {@code cloudId} is given use it, else if exactly one site auto-select it, else return the + * site list WITHOUT saving (the frontend re-POSTs with a chosen {@code cloudId});
  8. + *
  9. on selection, enforce one active connection per org ({@code INTEGRATION_ALREADY_CONNECTED}), + * persist an encrypted OAUTH2 connection, and evict the cached tokens.
  10. + *
+ */ +@Component +@RequiredArgsConstructor +public class JiraOAuthCallbackCommandHandler { + + private final JiraOAuthProperties props; + private final JiraOAuthStateService stateService; + private final JiraOAuthPort oauth; + private final IntegrationConnectionRepository connections; + private final JiraOAuthPendingTokenCache pendingTokens; + + @Transactional + public JiraOAuthCallbackResult handle(JiraOAuthCallbackCommand command) { + if (!props.configured()) { + throw IntegrationsExceptions.oauthNotConfigured(); + } + stateService.verify(command.state(), command.organizationId(), command.requestedBy()); + + // Exchange the single-use code at most once per state: on the second (site-selection) callback the + // code is already consumed, so reuse the cached tokens + sites instead of re-exchanging. + Pending pending = pendingTokens.get(command.state()); + OAuthTokens tokens; + List sites; + if (pending != null) { + tokens = pending.tokens(); + sites = pending.sites(); + } else { + tokens = oauth.exchangeCode(command.code()); + sites = oauth.accessibleResources(tokens.accessToken()); + if (sites.isEmpty()) { + // No Jira site is reachable with the granted consent — treat as an auth failure. + throw IntegrationsExceptions.oauthStateInvalid("no accessible Jira sites for the granted consent"); + } + } + + Site chosen = selectSite(sites, command.cloudId()); + if (chosen == null) { + // Multiple sites and no cloudId yet: cache the exchanged tokens + sites under the state so the + // follow-up callback (with a chosen cloudId) completes WITHOUT re-exchanging the used code. + pendingTokens.put(command.state(), tokens, sites); + return JiraOAuthCallbackResult.needsSiteSelection(sites); + } + + if (connections.existsByOrganizationIdAndProviderAndStatusNot( + command.organizationId(), IntegrationProviderType.JIRA, ConnectionStatus.DISCONNECTED)) { + throw IntegrationsExceptions.alreadyConnected( + command.organizationId(), IntegrationProviderType.JIRA.name()); + } + + Instant now = Instant.now(); + Instant accessExpiresAt = now.plusSeconds(tokens.expiresInSeconds()); + IntegrationConnection connection = IntegrationConnection.oauth( + command.organizationId(), IntegrationProviderType.JIRA, + chosen.url(), chosen.cloudId(), tokens.refreshToken(), + tokens.accessToken(), accessExpiresAt, now); + IntegrationConnection saved = connections.save(connection); + pendingTokens.evict(command.state()); + return JiraOAuthCallbackResult.saved(saved); + } + + /** + * Chooses the site to connect: the one matching {@code requestedCloudId} if given (or throws if it is + * not among the accessible sites), otherwise the sole site when exactly one exists, otherwise null to + * signal that the caller must pick. + */ + private static Site selectSite(List sites, String requestedCloudId) { + if (requestedCloudId != null && !requestedCloudId.isBlank()) { + return sites.stream() + .filter(s -> s.cloudId().equals(requestedCloudId)) + .findFirst() + .orElseThrow(() -> IntegrationsExceptions.oauthStateInvalid( + "chosen cloudId is not among the accessible sites")); + } + return sites.size() == 1 ? sites.get(0) : null; + } +} diff --git a/src/main/java/com/kntro/reqsai/gateway/application/handler/ListConnectionsQueryHandler.java b/src/main/java/com/kntro/reqsai/gateway/application/handler/ListConnectionsQueryHandler.java new file mode 100644 index 00000000..9c3e2f6b --- /dev/null +++ b/src/main/java/com/kntro/reqsai/gateway/application/handler/ListConnectionsQueryHandler.java @@ -0,0 +1,23 @@ +package com.kntro.reqsai.gateway.application.handler; + +import com.kntro.reqsai.gateway.application.port.IntegrationConnectionRepository; +import com.kntro.reqsai.gateway.application.query.ListConnectionsQuery; +import com.kntro.reqsai.gateway.domain.model.IntegrationConnection; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Component; +import org.springframework.transaction.annotation.Transactional; + +import java.util.List; + +/** Lists an organization's integration connections (never exposing the token). */ +@Component +@RequiredArgsConstructor +public class ListConnectionsQueryHandler { + + private final IntegrationConnectionRepository connections; + + @Transactional(readOnly = true) + public List handle(ListConnectionsQuery query) { + return connections.findAllByOrganizationId(query.organizationId()); + } +} diff --git a/src/main/java/com/kntro/reqsai/gateway/application/handler/ListIntegrationJobsQueryHandler.java b/src/main/java/com/kntro/reqsai/gateway/application/handler/ListIntegrationJobsQueryHandler.java new file mode 100644 index 00000000..a0e422f6 --- /dev/null +++ b/src/main/java/com/kntro/reqsai/gateway/application/handler/ListIntegrationJobsQueryHandler.java @@ -0,0 +1,26 @@ +package com.kntro.reqsai.gateway.application.handler; + +import com.kntro.reqsai.gateway.application.port.IntegrationSyncJobRepository; +import com.kntro.reqsai.gateway.application.query.ListIntegrationJobsQuery; +import com.kntro.reqsai.gateway.domain.model.IntegrationSyncJob; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Component; +import org.springframework.transaction.annotation.Transactional; + +import java.util.List; + +/** + * Lists a project's integration sync jobs: RUNNING only when {@code activeOnly} (what a reloaded + * page asks first to re-attach its progress banner), else the most recent ~10 of any status. + */ +@Component +@RequiredArgsConstructor +public class ListIntegrationJobsQueryHandler { + + private final IntegrationSyncJobRepository jobs; + + @Transactional(readOnly = true) + public List handle(ListIntegrationJobsQuery query) { + return query.activeOnly() ? jobs.findRunning(query.projectId()) : jobs.findRecent(query.projectId()); + } +} diff --git a/src/main/java/com/kntro/reqsai/gateway/application/handler/ListJiraIssueTypesQueryHandler.java b/src/main/java/com/kntro/reqsai/gateway/application/handler/ListJiraIssueTypesQueryHandler.java new file mode 100644 index 00000000..7adf8c6a --- /dev/null +++ b/src/main/java/com/kntro/reqsai/gateway/application/handler/ListJiraIssueTypesQueryHandler.java @@ -0,0 +1,34 @@ +package com.kntro.reqsai.gateway.application.handler; + +import com.kntro.reqsai.gateway.application.port.IntegrationConnectionRepository; +import com.kntro.reqsai.gateway.application.port.IntegrationProvider; +import com.kntro.reqsai.gateway.application.port.IntegrationProvider.RemoteIssueType; +import com.kntro.reqsai.gateway.application.query.ListJiraIssueTypesQuery; +import com.kntro.reqsai.gateway.application.service.ProviderCredentialsFactory; +import com.kntro.reqsai.gateway.application.service.ProviderRegistry; +import com.kntro.reqsai.gateway.domain.exception.IntegrationsExceptions; +import com.kntro.reqsai.gateway.domain.model.IntegrationConnection; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Component; +import org.springframework.transaction.annotation.Transactional; + +import java.util.List; + +/** Lists the Jira issue types for a project key visible to a connection (live provider call). */ +@Component +@RequiredArgsConstructor +public class ListJiraIssueTypesQueryHandler { + + private final IntegrationConnectionRepository connections; + private final ProviderRegistry providers; + private final ProviderCredentialsFactory credentials; + + @Transactional(readOnly = true) + public List handle(ListJiraIssueTypesQuery query) { + IntegrationConnection connection = connections + .findByIdAndOrganizationId(query.connectionId(), query.organizationId()) + .orElseThrow(() -> IntegrationsExceptions.connectionNotFound(query.connectionId())); + IntegrationProvider provider = providers.get(connection.getProvider()); + return provider.listIssueTypes(credentials.from(connection), query.projectKey()); + } +} diff --git a/src/main/java/com/kntro/reqsai/gateway/application/handler/ListJiraProjectsQueryHandler.java b/src/main/java/com/kntro/reqsai/gateway/application/handler/ListJiraProjectsQueryHandler.java new file mode 100644 index 00000000..4caabf5d --- /dev/null +++ b/src/main/java/com/kntro/reqsai/gateway/application/handler/ListJiraProjectsQueryHandler.java @@ -0,0 +1,34 @@ +package com.kntro.reqsai.gateway.application.handler; + +import com.kntro.reqsai.gateway.application.port.IntegrationConnectionRepository; +import com.kntro.reqsai.gateway.application.port.IntegrationProvider; +import com.kntro.reqsai.gateway.application.port.IntegrationProvider.RemoteProject; +import com.kntro.reqsai.gateway.application.query.ListJiraProjectsQuery; +import com.kntro.reqsai.gateway.application.service.ProviderCredentialsFactory; +import com.kntro.reqsai.gateway.application.service.ProviderRegistry; +import com.kntro.reqsai.gateway.domain.exception.IntegrationsExceptions; +import com.kntro.reqsai.gateway.domain.model.IntegrationConnection; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Component; +import org.springframework.transaction.annotation.Transactional; + +import java.util.List; + +/** Lists the Jira projects visible to a connection (live provider call). */ +@Component +@RequiredArgsConstructor +public class ListJiraProjectsQueryHandler { + + private final IntegrationConnectionRepository connections; + private final ProviderRegistry providers; + private final ProviderCredentialsFactory credentials; + + @Transactional(readOnly = true) + public List handle(ListJiraProjectsQuery query) { + IntegrationConnection connection = connections + .findByIdAndOrganizationId(query.connectionId(), query.organizationId()) + .orElseThrow(() -> IntegrationsExceptions.connectionNotFound(query.connectionId())); + IntegrationProvider provider = providers.get(connection.getProvider()); + return provider.listProjects(credentials.from(connection)); + } +} diff --git a/src/main/java/com/kntro/reqsai/gateway/application/handler/PreviewJiraImportQueryHandler.java b/src/main/java/com/kntro/reqsai/gateway/application/handler/PreviewJiraImportQueryHandler.java new file mode 100644 index 00000000..a9452f13 --- /dev/null +++ b/src/main/java/com/kntro/reqsai/gateway/application/handler/PreviewJiraImportQueryHandler.java @@ -0,0 +1,47 @@ +package com.kntro.reqsai.gateway.application.handler; + +import com.kntro.reqsai.discovery.api.StoryDuplicateCheck; +import com.kntro.reqsai.gateway.application.port.IntegrationProvider.RemoteIssue; +import com.kntro.reqsai.gateway.application.port.ProjectIntegrationTargetRepository; +import com.kntro.reqsai.gateway.application.query.PreviewJiraImportQuery; +import com.kntro.reqsai.gateway.application.result.ImportPreview; +import com.kntro.reqsai.gateway.application.result.ImportPreview.Candidate; +import com.kntro.reqsai.gateway.application.service.JiraImportService; +import com.kntro.reqsai.gateway.application.service.StoryPushService.PushContext; +import com.kntro.reqsai.gateway.domain.exception.IntegrationsExceptions; +import com.kntro.reqsai.gateway.domain.model.ProjectIntegrationTarget; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Component; +import org.springframework.transaction.annotation.Transactional; + +import java.util.List; + +/** + * Lists the candidate Jira issues eligible for import from the project's target and flags likely duplicates + * via the discovery similarity path — WITHOUT creating anything. 409 + * ({@code INTEGRATION_TARGET_NOT_CONFIGURED}) when no target exists. + */ +@Component +@RequiredArgsConstructor +public class PreviewJiraImportQueryHandler { + + private final ProjectIntegrationTargetRepository targets; + private final JiraImportService importService; + + @Transactional(readOnly = true) + public ImportPreview handle(PreviewJiraImportQuery query) { + ProjectIntegrationTarget target = targets.findByProjectId(query.projectId()) + .orElseThrow(() -> IntegrationsExceptions.targetNotConfigured(query.projectId())); + + PushContext ctx = importService.contextFor(target); + List issues = importService.fetchIssues(ctx); + + List candidates = issues.stream().map(issue -> { + StoryDuplicateCheck dup = importService.checkDuplicate(query.projectId(), issue); + return new Candidate(issue.issueKey(), issue.summary(), issue.issueType(), + dup.duplicate(), dup.existingStoryId()); + }).toList(); + + return ImportPreview.of(candidates); + } +} diff --git a/src/main/java/com/kntro/reqsai/gateway/application/handler/PushAllStoriesCommandHandler.java b/src/main/java/com/kntro/reqsai/gateway/application/handler/PushAllStoriesCommandHandler.java new file mode 100644 index 00000000..95344463 --- /dev/null +++ b/src/main/java/com/kntro/reqsai/gateway/application/handler/PushAllStoriesCommandHandler.java @@ -0,0 +1,62 @@ +package com.kntro.reqsai.gateway.application.handler; + +import com.kntro.reqsai.discovery.api.DiscoveryStoryReadPort; +import com.kntro.reqsai.discovery.api.StoryView; +import com.kntro.reqsai.gateway.application.command.PushAllStoriesCommand; +import com.kntro.reqsai.gateway.application.port.IntegrationJobLauncher; +import com.kntro.reqsai.gateway.application.port.ProjectIntegrationTargetRepository; +import com.kntro.reqsai.gateway.application.service.IntegrationSyncJobStarter; +import com.kntro.reqsai.gateway.domain.exception.IntegrationsExceptions; +import com.kntro.reqsai.gateway.domain.model.IntegrationSyncJob; +import com.kntro.reqsai.gateway.domain.model.IntegrationSyncJobType; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Component; + +import java.util.LinkedHashSet; +import java.util.Set; +import java.util.UUID; + +/** + * Accepts a push-all request as an asynchronous background job: validates the + * target exists (409 {@code INTEGRATION_TARGET_NOT_CONFIGURED}), persists a RUNNING + * {@code integration_sync_jobs} row (409 {@code INTEGRATION_JOB_ALREADY_RUNNING} when one is + * already running), hands execution to the {@link IntegrationJobLauncher} and returns the job + * snapshot for the 202 response. The known story count seeds {@code total} immediately so the + * progress banner can render a meaningful bar from the first frame — when the command carries a + * story-id selection the seed is the count of selected stories that actually exist in the project + * (ids not in the project are ignored, matching the reader's filter). Deliberately not + * {@code @Transactional}: the job row must be committed before the launch. + */ +@Component +@RequiredArgsConstructor +public class PushAllStoriesCommandHandler { + + private final ProjectIntegrationTargetRepository targets; + private final DiscoveryStoryReadPort stories; + private final IntegrationSyncJobStarter starter; + private final IntegrationJobLauncher launcher; + + public IntegrationSyncJob handle(PushAllStoriesCommand command) { + targets.findByProjectId(command.projectId()) + .orElseThrow(() -> IntegrationsExceptions.targetNotConfigured(command.projectId())); + + int knownTotal = countEligible(command); + IntegrationSyncJob job = starter.start( + command.projectId(), IntegrationSyncJobType.PUSH_ALL, knownTotal, command.requestedBy()); + launcher.launchPushAll(job.getId(), command.projectId(), command.storyIds()); + return job; + } + + /** + * Number of stories the run will actually push: every project story when unrestricted, otherwise + * the selected ids that exist in the project (unknown ids are ignored — same rule as the reader). + */ + private int countEligible(PushAllStoriesCommand command) { + java.util.List all = stories.listStories(command.projectId()); + if (command.storyIds() == null || command.storyIds().isEmpty()) { + return all.size(); + } + Set selected = new LinkedHashSet<>(command.storyIds()); + return (int) all.stream().filter(story -> selected.contains(story.storyId())).count(); + } +} diff --git a/src/main/java/com/kntro/reqsai/gateway/application/handler/PushStoryCommandHandler.java b/src/main/java/com/kntro/reqsai/gateway/application/handler/PushStoryCommandHandler.java new file mode 100644 index 00000000..2062890e --- /dev/null +++ b/src/main/java/com/kntro/reqsai/gateway/application/handler/PushStoryCommandHandler.java @@ -0,0 +1,42 @@ +package com.kntro.reqsai.gateway.application.handler; + +import com.kntro.reqsai.discovery.api.DiscoveryStoryReadPort; +import com.kntro.reqsai.discovery.api.StoryView; +import com.kntro.reqsai.gateway.application.command.PushStoryCommand; +import com.kntro.reqsai.gateway.application.port.IntegrationProvider.PushedIssue; +import com.kntro.reqsai.gateway.application.port.ProjectIntegrationTargetRepository; +import com.kntro.reqsai.gateway.application.result.StoryPushResult; +import com.kntro.reqsai.gateway.application.service.StoryPushService; +import com.kntro.reqsai.gateway.application.service.StoryPushService.PushContext; +import com.kntro.reqsai.gateway.domain.exception.IntegrationsExceptions; +import com.kntro.reqsai.gateway.domain.model.ProjectIntegrationTarget; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Component; +import org.springframework.transaction.annotation.Transactional; + +/** + * Pushes a single project story to the project's configured Jira target. 409 + * ({@code INTEGRATION_TARGET_NOT_CONFIGURED}) when no target exists; 404 when the story is not in the + * project; provider failures ({@code JIRA_*}) surface as infrastructure exceptions. + */ +@Component +@RequiredArgsConstructor +public class PushStoryCommandHandler { + + private final ProjectIntegrationTargetRepository targets; + private final DiscoveryStoryReadPort stories; + private final StoryPushService pushService; + + @Transactional(readOnly = true) + public StoryPushResult handle(PushStoryCommand command) { + ProjectIntegrationTarget target = targets.findByProjectId(command.projectId()) + .orElseThrow(() -> IntegrationsExceptions.targetNotConfigured(command.projectId())); + + StoryView story = stories.findStory(command.projectId(), command.storyId()) + .orElseThrow(() -> IntegrationsExceptions.storyNotFound(command.storyId())); + + PushContext ctx = pushService.contextFor(target); + PushedIssue issue = pushService.push(ctx, story); + return StoryPushResult.success(command.storyId(), issue.issueKey(), issue.issueUrl()); + } +} diff --git a/src/main/java/com/kntro/reqsai/gateway/application/handler/SaveProjectTargetCommandHandler.java b/src/main/java/com/kntro/reqsai/gateway/application/handler/SaveProjectTargetCommandHandler.java new file mode 100644 index 00000000..eb2b61eb --- /dev/null +++ b/src/main/java/com/kntro/reqsai/gateway/application/handler/SaveProjectTargetCommandHandler.java @@ -0,0 +1,38 @@ +package com.kntro.reqsai.gateway.application.handler; + +import com.kntro.reqsai.gateway.application.command.SaveProjectTargetCommand; +import com.kntro.reqsai.gateway.application.port.IntegrationConnectionRepository; +import com.kntro.reqsai.gateway.application.port.ProjectIntegrationTargetRepository; +import com.kntro.reqsai.gateway.domain.exception.IntegrationsExceptions; +import com.kntro.reqsai.gateway.domain.model.ProjectIntegrationTarget; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Component; +import org.springframework.transaction.annotation.Transactional; + +/** + * Creates or replaces the single Jira push target of a project (upsert). Validates the referenced + * connection exists in the tenant ({@code INTEGRATION_CONNECTION_NOT_FOUND} otherwise). + */ +@Component +@RequiredArgsConstructor +public class SaveProjectTargetCommandHandler { + + private final ProjectIntegrationTargetRepository targets; + private final IntegrationConnectionRepository connections; + + @Transactional + public ProjectIntegrationTarget handle(SaveProjectTargetCommand command) { + connections.findById(command.connectionId()) + .orElseThrow(() -> IntegrationsExceptions.connectionNotFound(command.connectionId())); + + ProjectIntegrationTarget target = targets.findByProjectId(command.projectId()) + .map(existing -> { + existing.update(command.connectionId(), command.jiraProjectKey(), command.issueTypeName()); + return existing; + }) + .orElseGet(() -> new ProjectIntegrationTarget( + command.projectId(), command.connectionId(), + command.jiraProjectKey(), command.issueTypeName())); + return targets.save(target); + } +} diff --git a/src/main/java/com/kntro/reqsai/gateway/application/handler/TestConnectionQueryHandler.java b/src/main/java/com/kntro/reqsai/gateway/application/handler/TestConnectionQueryHandler.java new file mode 100644 index 00000000..124d85be --- /dev/null +++ b/src/main/java/com/kntro/reqsai/gateway/application/handler/TestConnectionQueryHandler.java @@ -0,0 +1,52 @@ +package com.kntro.reqsai.gateway.application.handler; + +import com.kntro.reqsai.gateway.application.port.IntegrationConnectionRepository; +import com.kntro.reqsai.gateway.application.port.IntegrationProvider; +import com.kntro.reqsai.gateway.application.query.TestConnectionQuery; +import com.kntro.reqsai.gateway.application.result.ConnectionTestResult; +import com.kntro.reqsai.gateway.application.service.ProviderCredentialsFactory; +import com.kntro.reqsai.gateway.application.service.ProviderRegistry; +import com.kntro.reqsai.gateway.domain.exception.IntegrationsExceptions; +import com.kntro.reqsai.gateway.domain.model.IntegrationConnection; +import com.kntro.reqsai.shared.domain.exception.InfrastructureException; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Component; +import org.springframework.transaction.annotation.Transactional; + +import java.time.Instant; + +/** + * Re-verifies a connection's stored credential against the provider. Returns {@code ok=true} + the + * account name and marks the connection verified on success; on an auth/reachability failure it marks + * the connection {@code DEGRADED} and returns {@code ok=false} (a test never fails the request). + */ +@Component +@RequiredArgsConstructor +@Slf4j +public class TestConnectionQueryHandler { + + private final IntegrationConnectionRepository connections; + private final ProviderRegistry providers; + private final ProviderCredentialsFactory credentials; + + @Transactional + public ConnectionTestResult handle(TestConnectionQuery query) { + IntegrationConnection connection = connections + .findByIdAndOrganizationId(query.connectionId(), query.organizationId()) + .orElseThrow(() -> IntegrationsExceptions.connectionNotFound(query.connectionId())); + + IntegrationProvider provider = providers.get(connection.getProvider()); + try { + String accountName = provider.verify(credentials.from(connection)); + connection.markVerified(Instant.now()); + connections.save(connection); + return new ConnectionTestResult(true, accountName); + } catch (InfrastructureException e) { + log.warn("Connection {} verification failed [{}]", connection.getId(), e.error().code()); + connection.markDegraded(); + connections.save(connection); + return new ConnectionTestResult(false, null); + } + } +} diff --git a/src/main/java/com/kntro/reqsai/gateway/application/notification/IntegrationJobProgressNotifier.java b/src/main/java/com/kntro/reqsai/gateway/application/notification/IntegrationJobProgressNotifier.java new file mode 100644 index 00000000..5df4762e --- /dev/null +++ b/src/main/java/com/kntro/reqsai/gateway/application/notification/IntegrationJobProgressNotifier.java @@ -0,0 +1,25 @@ +package com.kntro.reqsai.gateway.application.notification; + +import com.kntro.reqsai.gateway.domain.model.IntegrationSyncJob; +import com.kntro.reqsai.gateway.interfaces.notification.mappers.IntegrationJobNotificationMapper; +import com.kntro.reqsai.shared.application.notification.RealtimeNotifier; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Component; + +/** + * Publishes a job's current state to its project topic ({@link IntegrationJobTopics#jobsOf}) after + * every persisted update. The worker publishes per item — at the current batch scale (tens of + * issues) no throttling is needed; the durable row remains the source of truth if a frame is lost + * (the shared {@link RealtimeNotifier} never propagates send failures). + */ +@Component +@RequiredArgsConstructor +public class IntegrationJobProgressNotifier { + + private final RealtimeNotifier notifier; + + public void publish(IntegrationSyncJob job) { + notifier.broadcast(IntegrationJobTopics.jobsOf(job.getProjectId()), + IntegrationJobNotificationMapper.toMessage(job)); + } +} diff --git a/src/main/java/com/kntro/reqsai/gateway/application/notification/IntegrationJobTopics.java b/src/main/java/com/kntro/reqsai/gateway/application/notification/IntegrationJobTopics.java new file mode 100644 index 00000000..525ace79 --- /dev/null +++ b/src/main/java/com/kntro/reqsai/gateway/application/notification/IntegrationJobTopics.java @@ -0,0 +1,37 @@ +package com.kntro.reqsai.gateway.application.notification; + +import com.kntro.reqsai.shared.application.notification.RealtimeNotifier; + +import java.util.Objects; +import java.util.UUID; + +/** + * Single source of truth for the integration-job realtime destination. Like the + * discovery {@code ProjectTopics}, this is a logical topic name (no broker prefix) — the + * shared {@link RealtimeNotifier#broadcast(String, Object)} prepends {@code /topic}, so + * {@code jobsOf(id)} reaches subscribers on {@code /topic/projects/{id}/integration-jobs}. + * + *

A viewer on any page of the project subscribes here to render the global progress banner for + * background Jira import / push-all jobs. Subscription auth follows the same model as the other + * {@code /topic/projects/{id}/...} topics: the STOMP CONNECT frame is JWT-authenticated by + * {@code StompAuthChannelInterceptor}; no per-destination gate exists, and none is added here. + */ +public final class IntegrationJobTopics { + + static final String PROJECTS_PREFIX = "projects/"; + static final String JOBS_SUFFIX = "/integration-jobs"; + + private IntegrationJobTopics() { + } + + /** + * Logical topic carrying every sync-job progress update of one project. + * + * @param projectId the project aggregate id (required) + * @return {@code "projects/{projectId}/integration-jobs"} — the notifier adds the {@code /topic} prefix + */ + public static String jobsOf(UUID projectId) { + Objects.requireNonNull(projectId, "projectId"); + return PROJECTS_PREFIX + projectId + JOBS_SUFFIX; + } +} diff --git a/src/main/java/com/kntro/reqsai/gateway/application/port/IntegrationConnectionRepository.java b/src/main/java/com/kntro/reqsai/gateway/application/port/IntegrationConnectionRepository.java new file mode 100644 index 00000000..070ebcdd --- /dev/null +++ b/src/main/java/com/kntro/reqsai/gateway/application/port/IntegrationConnectionRepository.java @@ -0,0 +1,26 @@ +package com.kntro.reqsai.gateway.application.port; + +import com.kntro.reqsai.gateway.domain.model.ConnectionStatus; +import com.kntro.reqsai.gateway.domain.model.IntegrationConnection; +import com.kntro.reqsai.gateway.domain.model.IntegrationProviderType; + +import java.util.List; +import java.util.Optional; +import java.util.UUID; + +/** Persistence port for the {@link IntegrationConnection} aggregate. Tenant-scoped. */ +public interface IntegrationConnectionRepository { + + IntegrationConnection save(IntegrationConnection connection); + + Optional findById(UUID id); + + Optional findByIdAndOrganizationId(UUID id, UUID organizationId); + + List findAllByOrganizationId(UUID organizationId); + + boolean existsByOrganizationIdAndProviderAndStatusNot( + UUID organizationId, IntegrationProviderType provider, ConnectionStatus status); + + void delete(IntegrationConnection connection); +} diff --git a/src/main/java/com/kntro/reqsai/gateway/application/port/IntegrationJobLauncher.java b/src/main/java/com/kntro/reqsai/gateway/application/port/IntegrationJobLauncher.java new file mode 100644 index 00000000..27bb5da3 --- /dev/null +++ b/src/main/java/com/kntro/reqsai/gateway/application/port/IntegrationJobLauncher.java @@ -0,0 +1,25 @@ +package com.kntro.reqsai.gateway.application.port; + +import org.jspecify.annotations.Nullable; + +import java.util.List; +import java.util.UUID; + +/** + * Port for launching the asynchronous execution of an integration sync job. The handlers create the + * durable {@code integration_sync_jobs} row first (the API's source of truth) and then hand the run + * to this port; the engine behind it is an infrastructure detail (currently Spring Batch — see + * {@code gateway.infrastructure.batch}). Implementations must return immediately (the endpoints + * answer {@code 202 Accepted}) and must propagate the caller's tenant to the execution. + */ +public interface IntegrationJobLauncher { + + /** Starts the Jira import run for an already-persisted RUNNING job row. */ + void launchImport(UUID jobId, UUID projectId, @Nullable List issueKeys); + + /** + * Starts the push-all run for an already-persisted RUNNING job row. {@code storyIds} optionally + * restricts the push to the given stories; {@code null}/empty pushes every eligible story. + */ + void launchPushAll(UUID jobId, UUID projectId, @Nullable List storyIds); +} diff --git a/src/main/java/com/kntro/reqsai/gateway/application/port/IntegrationProvider.java b/src/main/java/com/kntro/reqsai/gateway/application/port/IntegrationProvider.java new file mode 100644 index 00000000..d2e71c20 --- /dev/null +++ b/src/main/java/com/kntro/reqsai/gateway/application/port/IntegrationProvider.java @@ -0,0 +1,83 @@ +package com.kntro.reqsai.gateway.application.port; + +import com.kntro.reqsai.discovery.api.StoryView; +import com.kntro.reqsai.gateway.domain.model.CredentialType; +import com.kntro.reqsai.gateway.domain.model.IntegrationProviderType; +import org.jspecify.annotations.Nullable; + +import java.util.List; + +/** + * Provider seam (ADR-0023): the capability of talking to a third-party tracker. Jira is the first + * implementation ({@code JiraProvider}); adding another provider means adding an implementation keyed + * by its {@link IntegrationProviderType}, with no change to the handlers or endpoints. + * + *

Credentials are passed explicitly (decrypted by the caller) so the provider never touches + * persistence. Failures surface as infrastructure exceptions + * ({@code JIRA_AUTH_FAILED} / {@code JIRA_UNREACHABLE} / {@code JIRA_PUSH_FAILED}). + */ +public interface IntegrationProvider { + + /** The provider this implementation serves. */ + IntegrationProviderType type(); + + /** Verifies credentials, returning the authenticated account's display name. */ + String verify(ProviderCredentials credentials); + + /** Lists the projects visible to the credentials. */ + List listProjects(ProviderCredentials credentials); + + /** Lists issue types available for the given project key. */ + List listIssueTypes(ProviderCredentials credentials, String projectKey); + + /** Creates a tracker issue from a Reqs-AI story and returns its key + browse URL. */ + PushedIssue pushStory(ProviderCredentials credentials, String projectKey, String issueTypeName, StoryView story); + + /** + * Fetches the tracker issues eligible for import from {@code projectKey} of type {@code issueTypeName} + * (all pages), each flattened to a provider-neutral {@link RemoteIssue} (summary + plain-text + * description + mapped priority). The reverse of {@link #pushStory}. + */ + List searchImportableIssues(ProviderCredentials credentials, String projectKey, String issueTypeName); + + /** + * Decrypted credentials for a single provider call (never persisted, never logged). Carries both + * credential shapes; {@link #credentialType} selects which is populated: + *

    + *
  • {@link CredentialType#API_TOKEN} — {@code siteUrl} + {@code email} + {@code apiToken}.
  • + *
  • {@link CredentialType#OAUTH2} — {@code siteUrl} (for browse URLs) + {@code cloudId} + + * {@code accessToken}. The access token is already fresh (refreshed by the caller if needed).
  • + *
+ */ + record ProviderCredentials(CredentialType credentialType, String siteUrl, + @Nullable String email, @Nullable String apiToken, + @Nullable String cloudId, @Nullable String accessToken) { + + /** API-token credentials (basic auth). */ + public static ProviderCredentials apiToken(String siteUrl, String email, String apiToken) { + return new ProviderCredentials(CredentialType.API_TOKEN, siteUrl, email, apiToken, null, null); + } + + /** OAuth 2.0 credentials (bearer auth); {@code accessToken} must already be valid. */ + public static ProviderCredentials oauth(String siteUrl, String cloudId, String accessToken) { + return new ProviderCredentials(CredentialType.OAUTH2, siteUrl, null, null, cloudId, accessToken); + } + } + + /** A remote project ({key,name}). */ + record RemoteProject(String key, String name) {} + + /** A remote issue type ({id,name}). */ + record RemoteIssueType(String id, String name) {} + + /** The result of a successful push ({issueKey, issueUrl}). */ + record PushedIssue(String issueKey, String issueUrl) {} + + /** + * A tracker issue eligible for import, flattened to provider-neutral fields. {@code priority} is a + * Reqs-AI {@code Priority} name (the provider maps the tracker's priority scale); {@code description} + * is plain text (ADF flattened for Jira). {@code issueType} is the tracker's type label. + */ + record RemoteIssue(String issueKey, String summary, @Nullable String issueType, + @Nullable String description, String priority) {} +} diff --git a/src/main/java/com/kntro/reqsai/gateway/application/port/IntegrationSyncJobRepository.java b/src/main/java/com/kntro/reqsai/gateway/application/port/IntegrationSyncJobRepository.java new file mode 100644 index 00000000..4ddfed68 --- /dev/null +++ b/src/main/java/com/kntro/reqsai/gateway/application/port/IntegrationSyncJobRepository.java @@ -0,0 +1,28 @@ +package com.kntro.reqsai.gateway.application.port; + +import com.kntro.reqsai.gateway.domain.model.IntegrationSyncJob; +import com.kntro.reqsai.gateway.domain.model.IntegrationSyncJobType; + +import java.util.List; +import java.util.Optional; +import java.util.UUID; + +/** + * Persistence port for {@link IntegrationSyncJob} rows — the durable state behind the async Jira + * import / push-all endpoints. Implemented by {@code IntegrationSyncJobRepositoryAdapter}. + */ +public interface IntegrationSyncJobRepository { + + IntegrationSyncJob save(IntegrationSyncJob job); + + Optional findById(UUID id); + + /** Whether a {@code RUNNING} job of {@code type} already exists for the project (409 guard). */ + boolean existsRunning(UUID projectId, IntegrationSyncJobType type); + + /** The project's {@code RUNNING} jobs, newest first (reload recovery). */ + List findRunning(UUID projectId); + + /** The project's most recent jobs (any status, newest first, bounded to ~10). */ + List findRecent(UUID projectId); +} diff --git a/src/main/java/com/kntro/reqsai/gateway/application/port/JiraOAuthPort.java b/src/main/java/com/kntro/reqsai/gateway/application/port/JiraOAuthPort.java new file mode 100644 index 00000000..a1b68c3e --- /dev/null +++ b/src/main/java/com/kntro/reqsai/gateway/application/port/JiraOAuthPort.java @@ -0,0 +1,33 @@ +package com.kntro.reqsai.gateway.application.port; + +import org.jspecify.annotations.Nullable; + +import java.util.List; + +/** + * Application seam for the Atlassian OAuth 2.0 (3LO) endpoints (ADR-0023): authorization-code exchange, + * refresh-token rotation, and accessible-resources discovery. The concrete HTTP lives in an + * infrastructure adapter over {@code JiraOAuthClient}; application code programs against this port so it + * never touches {@code infrastructure}. Tokens are opaque strings and are never logged by callers. + */ +public interface JiraOAuthPort { + + /** Exchanges an authorization {@code code} for the initial token set. */ + OAuthTokens exchangeCode(String code); + + /** Exchanges a {@code refreshToken} for a new (possibly rotated) token set. */ + OAuthTokens refresh(String refreshToken); + + /** Lists the Atlassian sites the {@code accessToken} can reach. */ + List accessibleResources(String accessToken); + + /** + * A token set from Atlassian. {@code refreshToken} may be null on a refresh if the app does not rotate + * refresh tokens; callers keep the prior refresh token in that case. {@code expiresInSeconds} is the + * access-token lifetime. + */ + record OAuthTokens(String accessToken, @Nullable String refreshToken, long expiresInSeconds, @Nullable String scope) {} + + /** An accessible Atlassian site: {@code cloudId} is used to build the OAuth Jira API base URL. */ + record Site(String cloudId, String url, String name) {} +} diff --git a/src/main/java/com/kntro/reqsai/gateway/application/port/ProjectIntegrationTargetRepository.java b/src/main/java/com/kntro/reqsai/gateway/application/port/ProjectIntegrationTargetRepository.java new file mode 100644 index 00000000..1402c664 --- /dev/null +++ b/src/main/java/com/kntro/reqsai/gateway/application/port/ProjectIntegrationTargetRepository.java @@ -0,0 +1,16 @@ +package com.kntro.reqsai.gateway.application.port; + +import com.kntro.reqsai.gateway.domain.model.ProjectIntegrationTarget; + +import java.util.Optional; +import java.util.UUID; + +/** Persistence port for the {@link ProjectIntegrationTarget} aggregate. Tenant-scoped. */ +public interface ProjectIntegrationTargetRepository { + + ProjectIntegrationTarget save(ProjectIntegrationTarget target); + + Optional findByProjectId(UUID projectId); + + void delete(ProjectIntegrationTarget target); +} diff --git a/src/main/java/com/kntro/reqsai/gateway/application/port/SecretCipher.java b/src/main/java/com/kntro/reqsai/gateway/application/port/SecretCipher.java new file mode 100644 index 00000000..65d8803f --- /dev/null +++ b/src/main/java/com/kntro/reqsai/gateway/application/port/SecretCipher.java @@ -0,0 +1,29 @@ +package com.kntro.reqsai.gateway.application.port; + +/** + * Port for symmetric encryption of integration secrets at rest (ADR-0023). + *

+ * Abstracts the cipher used to protect sensitive credentials (e.g. the Jira API token) before they + * are persisted, and to recover them on load. Callers program against this port; the concrete + * algorithm lives in an infrastructure adapter. The stored form is opaque to callers and is + * self-describing to the adapter that produced it. Implementations must never log plaintext or key + * material. + */ +public interface SecretCipher { + + /** + * Encrypts {@code plaintext} into an opaque stored representation. + * + * @param plaintext the secret bytes to protect + * @return the encrypted, self-describing bytes to persist + */ + byte[] encrypt(byte[] plaintext); + + /** + * Decrypts bytes previously produced by {@link #encrypt(byte[])} back into plaintext. + * + * @param stored the stored, encrypted bytes + * @return the recovered plaintext bytes + */ + byte[] decrypt(byte[] stored); +} diff --git a/src/main/java/com/kntro/reqsai/gateway/application/query/GetIntegrationJobQuery.java b/src/main/java/com/kntro/reqsai/gateway/application/query/GetIntegrationJobQuery.java new file mode 100644 index 00000000..cfccc4e5 --- /dev/null +++ b/src/main/java/com/kntro/reqsai/gateway/application/query/GetIntegrationJobQuery.java @@ -0,0 +1,6 @@ +package com.kntro.reqsai.gateway.application.query; + +import java.util.UUID; + +/** Fetches one integration sync job of a project by id (404 when absent or in another project). */ +public record GetIntegrationJobQuery(UUID projectId, UUID jobId, UUID requestedBy) {} diff --git a/src/main/java/com/kntro/reqsai/gateway/application/query/GetProjectTargetQuery.java b/src/main/java/com/kntro/reqsai/gateway/application/query/GetProjectTargetQuery.java new file mode 100644 index 00000000..dda24d1f --- /dev/null +++ b/src/main/java/com/kntro/reqsai/gateway/application/query/GetProjectTargetQuery.java @@ -0,0 +1,6 @@ +package com.kntro.reqsai.gateway.application.query; + +import java.util.UUID; + +/** Read a project's Jira push target. */ +public record GetProjectTargetQuery(UUID projectId, UUID requestedBy) {} diff --git a/src/main/java/com/kntro/reqsai/gateway/application/query/ListConnectionsQuery.java b/src/main/java/com/kntro/reqsai/gateway/application/query/ListConnectionsQuery.java new file mode 100644 index 00000000..378493ee --- /dev/null +++ b/src/main/java/com/kntro/reqsai/gateway/application/query/ListConnectionsQuery.java @@ -0,0 +1,6 @@ +package com.kntro.reqsai.gateway.application.query; + +import java.util.UUID; + +/** List an organization's integration connections. */ +public record ListConnectionsQuery(UUID organizationId, UUID requestedBy) {} diff --git a/src/main/java/com/kntro/reqsai/gateway/application/query/ListIntegrationJobsQuery.java b/src/main/java/com/kntro/reqsai/gateway/application/query/ListIntegrationJobsQuery.java new file mode 100644 index 00000000..3849da3f --- /dev/null +++ b/src/main/java/com/kntro/reqsai/gateway/application/query/ListIntegrationJobsQuery.java @@ -0,0 +1,10 @@ +package com.kntro.reqsai.gateway.application.query; + +import java.util.UUID; + +/** + * Lists a project's integration sync jobs. {@code activeOnly} limits the result to RUNNING jobs + * (the reload-recovery path for the global progress banner); otherwise the most recent ~10 jobs of + * any status are returned, newest first. + */ +public record ListIntegrationJobsQuery(UUID projectId, boolean activeOnly, UUID requestedBy) {} diff --git a/src/main/java/com/kntro/reqsai/gateway/application/query/ListJiraIssueTypesQuery.java b/src/main/java/com/kntro/reqsai/gateway/application/query/ListJiraIssueTypesQuery.java new file mode 100644 index 00000000..fda6f8a9 --- /dev/null +++ b/src/main/java/com/kntro/reqsai/gateway/application/query/ListJiraIssueTypesQuery.java @@ -0,0 +1,6 @@ +package com.kntro.reqsai.gateway.application.query; + +import java.util.UUID; + +/** List the Jira issue types for a project key visible to an organization connection. */ +public record ListJiraIssueTypesQuery(UUID organizationId, UUID connectionId, String projectKey, UUID requestedBy) {} diff --git a/src/main/java/com/kntro/reqsai/gateway/application/query/ListJiraProjectsQuery.java b/src/main/java/com/kntro/reqsai/gateway/application/query/ListJiraProjectsQuery.java new file mode 100644 index 00000000..dbc0a1e8 --- /dev/null +++ b/src/main/java/com/kntro/reqsai/gateway/application/query/ListJiraProjectsQuery.java @@ -0,0 +1,6 @@ +package com.kntro.reqsai.gateway.application.query; + +import java.util.UUID; + +/** List the Jira projects visible to an organization connection. */ +public record ListJiraProjectsQuery(UUID organizationId, UUID connectionId, UUID requestedBy) {} diff --git a/src/main/java/com/kntro/reqsai/gateway/application/query/PreviewJiraImportQuery.java b/src/main/java/com/kntro/reqsai/gateway/application/query/PreviewJiraImportQuery.java new file mode 100644 index 00000000..57d50f94 --- /dev/null +++ b/src/main/java/com/kntro/reqsai/gateway/application/query/PreviewJiraImportQuery.java @@ -0,0 +1,12 @@ +package com.kntro.reqsai.gateway.application.query; + +import java.util.UUID; + +/** + * Query for the Jira import preview: lists the candidate issues of the project's configured target and + * flags likely duplicates, without creating anything. + * + * @param projectId the project whose target defines WHERE to pull from + * @param requestedBy caller id (authorization already enforced at the controller) + */ +public record PreviewJiraImportQuery(UUID projectId, UUID requestedBy) {} diff --git a/src/main/java/com/kntro/reqsai/gateway/application/query/TestConnectionQuery.java b/src/main/java/com/kntro/reqsai/gateway/application/query/TestConnectionQuery.java new file mode 100644 index 00000000..f80ab43d --- /dev/null +++ b/src/main/java/com/kntro/reqsai/gateway/application/query/TestConnectionQuery.java @@ -0,0 +1,6 @@ +package com.kntro.reqsai.gateway.application.query; + +import java.util.UUID; + +/** Re-verify an organization connection's credential against the provider. */ +public record TestConnectionQuery(UUID organizationId, UUID connectionId, UUID requestedBy) {} diff --git a/src/main/java/com/kntro/reqsai/gateway/application/result/ConnectionTestResult.java b/src/main/java/com/kntro/reqsai/gateway/application/result/ConnectionTestResult.java new file mode 100644 index 00000000..1e92bf1b --- /dev/null +++ b/src/main/java/com/kntro/reqsai/gateway/application/result/ConnectionTestResult.java @@ -0,0 +1,6 @@ +package com.kntro.reqsai.gateway.application.result; + +import org.jspecify.annotations.Nullable; + +/** Outcome of re-verifying a connection: {@code ok} plus the provider account name when successful. */ +public record ConnectionTestResult(boolean ok, @Nullable String accountName) {} diff --git a/src/main/java/com/kntro/reqsai/gateway/application/result/ImportPreview.java b/src/main/java/com/kntro/reqsai/gateway/application/result/ImportPreview.java new file mode 100644 index 00000000..b5f4d005 --- /dev/null +++ b/src/main/java/com/kntro/reqsai/gateway/application/result/ImportPreview.java @@ -0,0 +1,29 @@ +package com.kntro.reqsai.gateway.application.result; + +import org.jspecify.annotations.Nullable; + +import java.util.List; +import java.util.UUID; + +/** + * Preview of a Jira import: the candidate issues eligible for import, each flagged as a likely duplicate + * (detected via the discovery similarity path WITHOUT creating anything). + */ +public record ImportPreview(int total, List issues) { + + public static ImportPreview of(List issues) { + return new ImportPreview(issues.size(), issues); + } + + /** + * One candidate issue. {@code duplicate} is true when its mapped story would collide with an existing + * story; {@code existingStoryId} carries that story's id when resolved. + */ + public record Candidate( + String jiraIssueKey, + String summary, + @Nullable String issueType, + boolean duplicate, + @Nullable UUID existingStoryId + ) {} +} diff --git a/src/main/java/com/kntro/reqsai/gateway/application/result/ImportStoryResult.java b/src/main/java/com/kntro/reqsai/gateway/application/result/ImportStoryResult.java new file mode 100644 index 00000000..a6178d12 --- /dev/null +++ b/src/main/java/com/kntro/reqsai/gateway/application/result/ImportStoryResult.java @@ -0,0 +1,52 @@ +package com.kntro.reqsai.gateway.application.result; + +import org.jspecify.annotations.Nullable; + +import java.util.UUID; + +/** + * Result of importing one Jira issue. {@code status} is one of {@code imported} / {@code duplicate} / + * {@code failed}: + *

    + *
  • {@code imported} — a story was created; {@code storyId} is set.
  • + *
  • {@code duplicate} — the issue mapped to a near-duplicate of an existing story and was skipped + * (nothing created); {@code storyId} is null.
  • + *
  • {@code failed} — the issue could not be imported; {@code message} carries a token-free reason.
  • + *
+ */ +public record ImportStoryResult( + String jiraIssueKey, + @Nullable UUID storyId, + Status status, + @Nullable String message +) { + + public enum Status { + IMPORTED("imported"), + DUPLICATE("duplicate"), + FAILED("failed"); + + private final String wire; + + Status(String wire) { + this.wire = wire; + } + + /** Lowercase wire value used in the locked API contract. */ + public String wire() { + return wire; + } + } + + public static ImportStoryResult imported(String jiraIssueKey, UUID storyId) { + return new ImportStoryResult(jiraIssueKey, storyId, Status.IMPORTED, null); + } + + public static ImportStoryResult duplicate(String jiraIssueKey) { + return new ImportStoryResult(jiraIssueKey, null, Status.DUPLICATE, null); + } + + public static ImportStoryResult failed(String jiraIssueKey, String message) { + return new ImportStoryResult(jiraIssueKey, null, Status.FAILED, message); + } +} diff --git a/src/main/java/com/kntro/reqsai/gateway/application/result/JiraOAuthCallbackResult.java b/src/main/java/com/kntro/reqsai/gateway/application/result/JiraOAuthCallbackResult.java new file mode 100644 index 00000000..74038b72 --- /dev/null +++ b/src/main/java/com/kntro/reqsai/gateway/application/result/JiraOAuthCallbackResult.java @@ -0,0 +1,32 @@ +package com.kntro.reqsai.gateway.application.result; + +import com.kntro.reqsai.gateway.application.port.JiraOAuthPort.Site; +import com.kntro.reqsai.gateway.domain.model.IntegrationConnection; +import org.jspecify.annotations.Nullable; + +import java.util.List; + +/** + * Outcome of the Jira OAuth callback (ADR-0023): either a saved {@link #connection} (a site was chosen or + * auto-selected), or a non-empty list of {@link #sites} to choose from (multiple sites, no {@code cloudId} + * yet) — in which case nothing was persisted and the frontend re-POSTs with a chosen {@code cloudId}. + * Exactly one of the two is non-null. + */ +public record JiraOAuthCallbackResult( + @Nullable IntegrationConnection connection, + @Nullable List sites +) { + + public static JiraOAuthCallbackResult saved(IntegrationConnection connection) { + return new JiraOAuthCallbackResult(connection, null); + } + + public static JiraOAuthCallbackResult needsSiteSelection(List sites) { + return new JiraOAuthCallbackResult(null, sites); + } + + /** True when a connection was saved; false when the caller must pick a site. */ + public boolean isSaved() { + return connection != null; + } +} diff --git a/src/main/java/com/kntro/reqsai/gateway/application/result/StoryPushResult.java b/src/main/java/com/kntro/reqsai/gateway/application/result/StoryPushResult.java new file mode 100644 index 00000000..21dbd20f --- /dev/null +++ b/src/main/java/com/kntro/reqsai/gateway/application/result/StoryPushResult.java @@ -0,0 +1,29 @@ +package com.kntro.reqsai.gateway.application.result; + +import org.jspecify.annotations.Nullable; + +import java.util.UUID; + +/** + * Result of pushing a single story. On success {@code jiraIssueKey}/{@code jiraIssueUrl} are set and + * {@code error} is null; on failure (in a batch push) {@code error} carries the error code and the Jira + * fields are null. + */ +public record StoryPushResult( + UUID storyId, + @Nullable String jiraIssueKey, + @Nullable String jiraIssueUrl, + @Nullable String error +) { + public static StoryPushResult success(UUID storyId, String key, String url) { + return new StoryPushResult(storyId, key, url, null); + } + + public static StoryPushResult failure(UUID storyId, String errorCode) { + return new StoryPushResult(storyId, null, null, errorCode); + } + + public boolean isSuccess() { + return error == null; + } +} diff --git a/src/main/java/com/kntro/reqsai/gateway/application/service/IntegrationSyncJobStarter.java b/src/main/java/com/kntro/reqsai/gateway/application/service/IntegrationSyncJobStarter.java new file mode 100644 index 00000000..8cd3895e --- /dev/null +++ b/src/main/java/com/kntro/reqsai/gateway/application/service/IntegrationSyncJobStarter.java @@ -0,0 +1,46 @@ +package com.kntro.reqsai.gateway.application.service; + +import com.kntro.reqsai.gateway.application.port.IntegrationSyncJobRepository; +import com.kntro.reqsai.gateway.domain.exception.IntegrationsExceptions; +import com.kntro.reqsai.gateway.domain.model.IntegrationSyncJob; +import com.kntro.reqsai.gateway.domain.model.IntegrationSyncJobType; +import lombok.RequiredArgsConstructor; +import org.jspecify.annotations.Nullable; +import org.springframework.dao.DataIntegrityViolationException; +import org.springframework.stereotype.Component; + +import java.util.UUID; + +/** + * Creates the durable {@code RUNNING} job row shared by both async endpoints, enforcing the + * single-running-job rule twice: a cheap pre-check (the common 409 path) and the partial unique + * index {@code uq_integration_sync_jobs_running} as the race-proof backstop (a concurrent insert + * surfaces as the same 409). Deliberately not wrapped in a caller transaction: + * the save commits on its own, so the row is visible to the async worker (and to job queries) + * before the worker is dispatched. + */ +@Component +@RequiredArgsConstructor +public class IntegrationSyncJobStarter { + + private final IntegrationSyncJobRepository jobs; + + /** + * Persists a new {@code RUNNING} job of {@code type} for the project. + * + * @param total the item count if already known (selected issue keys / story count), else 0 + * @throws com.kntro.reqsai.shared.domain.exception.DomainException 409 + * {@code INTEGRATION_JOB_ALREADY_RUNNING} when a job of the same type is running + */ + public IntegrationSyncJob start(UUID projectId, IntegrationSyncJobType type, int total, @Nullable UUID requestedBy) { + if (jobs.existsRunning(projectId, type)) { + throw IntegrationsExceptions.jobAlreadyRunning(projectId, type.name()); + } + try { + return jobs.save(new IntegrationSyncJob(projectId, type, total, requestedBy)); + } catch (DataIntegrityViolationException e) { + // Two requests raced past the pre-check; the partial unique index kept exactly one. + throw IntegrationsExceptions.jobAlreadyRunning(projectId, type.name()); + } + } +} diff --git a/src/main/java/com/kntro/reqsai/gateway/application/service/JiraImportService.java b/src/main/java/com/kntro/reqsai/gateway/application/service/JiraImportService.java new file mode 100644 index 00000000..ffe6d818 --- /dev/null +++ b/src/main/java/com/kntro/reqsai/gateway/application/service/JiraImportService.java @@ -0,0 +1,65 @@ +package com.kntro.reqsai.gateway.application.service; + +import com.kntro.reqsai.discovery.api.DiscoveryStoryWritePort; +import com.kntro.reqsai.discovery.api.ExternalIssueInput; +import com.kntro.reqsai.discovery.api.ImportedStory; +import com.kntro.reqsai.gateway.application.port.IntegrationProvider.RemoteIssue; +import com.kntro.reqsai.gateway.application.result.ImportStoryResult; +import com.kntro.reqsai.gateway.application.service.StoryPushService.PushContext; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Component; + +import java.util.UUID; + +/** + * Shared Jira-import mechanics used by both the import command handler and the preview query handler: + * resolves the target's provider/credentials once (reusing {@link StoryPushService#contextFor}), fetches + * the eligible issues from Jira, and — for the import path — maps each issue to a story via the discovery + * {@link DiscoveryStoryWritePort} (which owns the LLM transformation + dedup). The connection/target model + * is unchanged: import pulls from the same {@code project_integration_targets} row push writes to. + */ +@Component +@RequiredArgsConstructor +@Slf4j +public class JiraImportService { + + private final StoryPushService pushService; + private final DiscoveryStoryWritePort discoveryStories; + + /** Resolves the provider context (connection + credentials + project/issue-type) for the target. */ + public PushContext contextFor(com.kntro.reqsai.gateway.domain.model.ProjectIntegrationTarget target) { + return pushService.contextFor(target); + } + + /** Fetches every eligible Jira issue for the resolved target (all pages). */ + public java.util.List fetchIssues(PushContext ctx) { + return ctx.provider().searchImportableIssues(ctx.credentials(), ctx.projectKey(), ctx.issueTypeName()); + } + + /** + * Imports one Jira issue into the project as a story via the discovery write port. A near-duplicate is + * reported as {@link ImportStoryResult.Status#DUPLICATE} (skipped, nothing created). Any failure is + * captured as {@link ImportStoryResult.Status#FAILED} with a token-free message so the batch continues. + */ + public ImportStoryResult importIssue(UUID projectId, RemoteIssue issue) { + try { + ExternalIssueInput input = new ExternalIssueInput( + projectId, issue.summary(), issue.description(), null); + ImportedStory outcome = discoveryStories.importFromExternalIssue(input); + if (outcome.isDuplicate()) { + return ImportStoryResult.duplicate(issue.issueKey()); + } + return ImportStoryResult.imported(issue.issueKey(), outcome.storyId()); + } catch (RuntimeException e) { + log.warn("Import failed for Jira issue {}: {}", issue.issueKey(), e.getMessage()); + return ImportStoryResult.failed(issue.issueKey(), e.getMessage()); + } + } + + /** Checks whether a Jira issue would map to a near-duplicate, without creating anything (preview). */ + public com.kntro.reqsai.discovery.api.StoryDuplicateCheck checkDuplicate(UUID projectId, RemoteIssue issue) { + return discoveryStories.checkDuplicate(new ExternalIssueInput( + projectId, issue.summary(), issue.description(), null)); + } +} diff --git a/src/main/java/com/kntro/reqsai/gateway/application/service/JiraOAuthAuthorizeService.java b/src/main/java/com/kntro/reqsai/gateway/application/service/JiraOAuthAuthorizeService.java new file mode 100644 index 00000000..c4d1e6da --- /dev/null +++ b/src/main/java/com/kntro/reqsai/gateway/application/service/JiraOAuthAuthorizeService.java @@ -0,0 +1,53 @@ +package com.kntro.reqsai.gateway.application.service; + +import com.kntro.reqsai.gateway.domain.exception.IntegrationsExceptions; +import com.kntro.reqsai.gateway.application.config.JiraOAuthProperties; +import org.springframework.stereotype.Component; +import org.springframework.web.util.UriComponentsBuilder; + +import java.util.UUID; + +/** + * Builds the Atlassian authorize URL for the OAuth 2.0 (3LO) flow (ADR-0023): + * {@code https://auth.atlassian.com/authorize?audience=api.atlassian.com&client_id=...&scope=...& + * redirect_uri=...&state=...&response_type=code&prompt=consent}. The {@code state} is a stateless signed + * token from {@link JiraOAuthStateService}. When OAuth is not configured it raises + * {@code JIRA_OAUTH_NOT_CONFIGURED} so the UI can disable the button. + */ +@Component +public class JiraOAuthAuthorizeService { + + private static final String AUTHORIZE_URL = "https://auth.atlassian.com/authorize"; + /** offline_access yields a refresh token; read:me + jira scopes cover verify/list/create. */ + private static final String SCOPES = "read:jira-work write:jira-work read:jira-user offline_access read:me"; + + private final JiraOAuthProperties props; + private final JiraOAuthStateService stateService; + + public JiraOAuthAuthorizeService(JiraOAuthProperties props, JiraOAuthStateService stateService) { + this.props = props; + this.stateService = stateService; + } + + /** Builds the authorize URL + signed state for {@code orgId}/{@code userId}. */ + public AuthorizeUrl build(UUID orgId, UUID userId) { + if (!props.configured()) { + throw IntegrationsExceptions.oauthNotConfigured(); + } + String state = stateService.issue(orgId, userId); + String url = UriComponentsBuilder.fromUriString(AUTHORIZE_URL) + .queryParam("audience", "api.atlassian.com") + .queryParam("client_id", props.clientId()) + .queryParam("scope", SCOPES) + .queryParam("redirect_uri", props.redirectUri()) + .queryParam("state", state) + .queryParam("response_type", "code") + .queryParam("prompt", "consent") + .encode() + .toUriString(); + return new AuthorizeUrl(url, state); + } + + /** The built authorize URL and the signed state embedded in it. */ + public record AuthorizeUrl(String url, String state) {} +} diff --git a/src/main/java/com/kntro/reqsai/gateway/application/service/JiraOAuthPendingTokenCache.java b/src/main/java/com/kntro/reqsai/gateway/application/service/JiraOAuthPendingTokenCache.java new file mode 100644 index 00000000..81694d81 --- /dev/null +++ b/src/main/java/com/kntro/reqsai/gateway/application/service/JiraOAuthPendingTokenCache.java @@ -0,0 +1,66 @@ +package com.kntro.reqsai.gateway.application.service; + +import com.kntro.reqsai.gateway.application.port.JiraOAuthPort.OAuthTokens; +import com.kntro.reqsai.gateway.application.port.JiraOAuthPort.Site; +import org.jspecify.annotations.Nullable; +import org.springframework.stereotype.Component; + +import java.time.Duration; +import java.time.Instant; +import java.util.List; +import java.util.Map; +import java.util.concurrent.ConcurrentHashMap; + +/** + * Short-lived in-memory cache of a completed OAuth code exchange, keyed by the signed {@code state} + * (ADR-0023). + *

+ * Atlassian authorization codes are SINGLE-USE: the multi-site callback exchanges the code once (to call + * accessible-resources) and, when the user must still pick a site, cannot exchange it again on the second + * callback. The already-exchanged tokens + discovered sites are cached here so the second callback (with + * the chosen {@code cloudId}) completes from the cache without re-consuming the code. Entries expire after + * {@link #TTL} (a few minutes — long enough to pick a site, short enough to bound retention) and are + * removed on use. Tokens live only in memory and are never logged. + */ +@Component +public class JiraOAuthPendingTokenCache { + + /** How long an unfinished multi-site selection is retained before the user must restart the flow. */ + static final Duration TTL = Duration.ofMinutes(5); + + private final Map byState = new ConcurrentHashMap<>(); + + /** Caches the exchanged tokens + discovered sites under {@code state}. */ + public void put(String state, OAuthTokens tokens, List sites) { + byState.put(state, new Entry(tokens, sites, Instant.now().plus(TTL))); + } + + /** Returns the cached exchange for {@code state}, or null if absent/expired (expired entries are purged). */ + public @Nullable Pending get(String state) { + purgeExpired(); + Entry entry = byState.get(state); + if (entry == null) { + return null; + } + if (entry.expiresAt.isBefore(Instant.now())) { + byState.remove(state); + return null; + } + return new Pending(entry.tokens, entry.sites); + } + + /** Removes the cached exchange for {@code state} (called once the connection is saved). */ + public void evict(String state) { + byState.remove(state); + } + + private void purgeExpired() { + Instant now = Instant.now(); + byState.entrySet().removeIf(e -> e.getValue().expiresAt.isBefore(now)); + } + + /** A cached, already-exchanged token set + the sites it can reach. */ + public record Pending(OAuthTokens tokens, List sites) {} + + private record Entry(OAuthTokens tokens, List sites, Instant expiresAt) {} +} diff --git a/src/main/java/com/kntro/reqsai/gateway/application/service/JiraOAuthStateService.java b/src/main/java/com/kntro/reqsai/gateway/application/service/JiraOAuthStateService.java new file mode 100644 index 00000000..a444a21a --- /dev/null +++ b/src/main/java/com/kntro/reqsai/gateway/application/service/JiraOAuthStateService.java @@ -0,0 +1,112 @@ +package com.kntro.reqsai.gateway.application.service; + +import com.kntro.reqsai.gateway.domain.exception.IntegrationsExceptions; +import com.kntro.reqsai.gateway.application.config.JiraOAuthProperties; +import org.springframework.stereotype.Component; + +import javax.crypto.Mac; +import javax.crypto.spec.SecretKeySpec; +import java.nio.charset.StandardCharsets; +import java.security.MessageDigest; +import java.security.SecureRandom; +import java.time.Duration; +import java.time.Instant; +import java.util.Base64; +import java.util.UUID; + +/** + * Signs and verifies the STATELESS OAuth {@code state} token (ADR-0023). The token binds the CSRF state + * to the initiating {@code orgId} + {@code userId} with a short expiry and a random nonce, so nothing has + * to be stored server-side and it survives the browser redirect. Format: + *

{@code base64url(orgId|userId|expiryEpochSeconds|nonce) + "." + base64url(HMAC-SHA256(payload))}
+ * The HMAC key is the dedicated {@code reqsai.integrations.jira.oauth.state-secret} ({@code JIRA_OAUTH_STATE_SECRET}). + * Verification checks the signature (constant-time), the expiry, and that the org/user match the caller; + * any failure raises {@code JIRA_OAUTH_STATE_INVALID}. + */ +@Component +public class JiraOAuthStateService { + + /** How long an issued state token stays valid — long enough to complete consent, short enough to bound replay. */ + static final Duration TTL = Duration.ofMinutes(15); + + private static final String HMAC_ALG = "HmacSHA256"; + private static final Base64.Encoder B64 = Base64.getUrlEncoder().withoutPadding(); + private static final Base64.Decoder B64D = Base64.getUrlDecoder(); + + private final JiraOAuthProperties props; + private final SecureRandom random = new SecureRandom(); + + public JiraOAuthStateService(JiraOAuthProperties props) { + this.props = props; + } + + /** Issues a signed state token for {@code orgId} + {@code userId}, valid for {@link #TTL}. */ + public String issue(UUID orgId, UUID userId) { + long expiry = Instant.now().plus(TTL).getEpochSecond(); + String nonce = UUID.randomUUID().toString().replace("-", ""); + String payload = "%s|%s|%d|%s".formatted(orgId, userId, expiry, nonce); + String encodedPayload = B64.encodeToString(payload.getBytes(StandardCharsets.UTF_8)); + return encodedPayload + "." + B64.encodeToString(hmac(encodedPayload)); + } + + /** + * Verifies {@code state} was issued for this {@code orgId} + {@code userId}, is unexpired, and has a + * valid signature. Throws {@code JIRA_OAUTH_STATE_INVALID} on any failure. + */ + public void verify(String state, UUID orgId, UUID userId) { + if (state == null || state.isBlank()) { + throw IntegrationsExceptions.oauthStateInvalid("missing state"); + } + int dot = state.indexOf('.'); + if (dot <= 0 || dot == state.length() - 1) { + throw IntegrationsExceptions.oauthStateInvalid("malformed state"); + } + String encodedPayload = state.substring(0, dot); + String signature = state.substring(dot + 1); + + byte[] expected = hmac(encodedPayload); + byte[] provided; + try { + provided = B64D.decode(signature); + } catch (IllegalArgumentException e) { + throw IntegrationsExceptions.oauthStateInvalid("bad signature encoding"); + } + if (!MessageDigest.isEqual(expected, provided)) { + throw IntegrationsExceptions.oauthStateInvalid("signature mismatch"); + } + + String payload; + try { + payload = new String(B64D.decode(encodedPayload), StandardCharsets.UTF_8); + } catch (IllegalArgumentException e) { + throw IntegrationsExceptions.oauthStateInvalid("bad payload encoding"); + } + String[] parts = payload.split("\\|"); + if (parts.length != 4) { + throw IntegrationsExceptions.oauthStateInvalid("malformed payload"); + } + if (!parts[0].equals(orgId.toString()) || !parts[1].equals(userId.toString())) { + throw IntegrationsExceptions.oauthStateInvalid("org/user mismatch"); + } + long expiry; + try { + expiry = Long.parseLong(parts[2]); + } catch (NumberFormatException e) { + throw IntegrationsExceptions.oauthStateInvalid("bad expiry"); + } + if (Instant.now().getEpochSecond() > expiry) { + throw IntegrationsExceptions.oauthStateInvalid("expired"); + } + } + + private byte[] hmac(String data) { + try { + Mac mac = Mac.getInstance(HMAC_ALG); + mac.init(new SecretKeySpec(props.effectiveStateSecret().getBytes(StandardCharsets.UTF_8), HMAC_ALG)); + return mac.doFinal(data.getBytes(StandardCharsets.UTF_8)); + } catch (Exception e) { + // A misconfigured/empty secret is a server config problem, not a client one. + throw new IllegalStateException("OAuth state HMAC failed", e); + } + } +} diff --git a/src/main/java/com/kntro/reqsai/gateway/application/service/JiraOAuthTokenService.java b/src/main/java/com/kntro/reqsai/gateway/application/service/JiraOAuthTokenService.java new file mode 100644 index 00000000..c77d587c --- /dev/null +++ b/src/main/java/com/kntro/reqsai/gateway/application/service/JiraOAuthTokenService.java @@ -0,0 +1,58 @@ +package com.kntro.reqsai.gateway.application.service; + +import com.kntro.reqsai.gateway.application.port.IntegrationConnectionRepository; +import com.kntro.reqsai.gateway.application.port.JiraOAuthPort; +import com.kntro.reqsai.gateway.application.port.JiraOAuthPort.OAuthTokens; +import com.kntro.reqsai.gateway.domain.model.IntegrationConnection; +import com.kntro.reqsai.gateway.infrastructure.exception.IntegrationsInfrastructureExceptions; +import com.kntro.reqsai.shared.domain.exception.InfrastructureException; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Component; + +import java.time.Duration; +import java.time.Instant; + +/** + * Ensures an OAuth 2.0 (3LO) {@link IntegrationConnection} has a usable, non-expired access token before a + * provider call (ADR-0023). If the cached access token is missing, expired, or within {@link #SKEW} of + * expiring, it refreshes via {@link JiraOAuthPort}, persists the rotated tokens (encrypted) + new expiry, + * and returns the fresh access token. A refresh failure surfaces as {@code JIRA_AUTH_FAILED}. Tokens are + * never logged. + */ +@Component +@RequiredArgsConstructor +@Slf4j +public class JiraOAuthTokenService { + + /** Refresh a little before the token actually expires to avoid mid-call expiry races. */ + static final Duration SKEW = Duration.ofSeconds(60); + + private final JiraOAuthPort oauth; + private final IntegrationConnectionRepository connections; + + /** + * Returns a valid access token for {@code connection}, refreshing and persisting rotated tokens first + * if the cached one is stale. Assumes {@code connection} is an OAUTH2 connection. + */ + public String freshAccessToken(IntegrationConnection connection) { + Instant now = Instant.now(); + if (!connection.oauthAccessExpiredWithin(SKEW, now)) { + return connection.getOauthAccessToken(); + } + String refreshToken = connection.getOauthRefreshToken(); + if (refreshToken == null || refreshToken.isBlank()) { + throw IntegrationsInfrastructureExceptions.jiraAuthFailed(); + } + try { + OAuthTokens tokens = oauth.refresh(refreshToken); + Instant expiresAt = now.plusSeconds(tokens.expiresInSeconds()); + connection.applyRefreshedTokens(tokens.refreshToken(), tokens.accessToken(), expiresAt); + connections.save(connection); + return tokens.accessToken(); + } catch (InfrastructureException e) { + log.warn("OAuth refresh failed for connection {} [{}]", connection.getId(), e.error().code()); + throw IntegrationsInfrastructureExceptions.jiraAuthFailed(); + } + } +} diff --git a/src/main/java/com/kntro/reqsai/gateway/application/service/ProviderCredentialsFactory.java b/src/main/java/com/kntro/reqsai/gateway/application/service/ProviderCredentialsFactory.java new file mode 100644 index 00000000..4c565422 --- /dev/null +++ b/src/main/java/com/kntro/reqsai/gateway/application/service/ProviderCredentialsFactory.java @@ -0,0 +1,32 @@ +package com.kntro.reqsai.gateway.application.service; + +import com.kntro.reqsai.gateway.application.port.IntegrationProvider.ProviderCredentials; +import com.kntro.reqsai.gateway.domain.model.CredentialType; +import com.kntro.reqsai.gateway.domain.model.IntegrationConnection; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Component; + +/** + * Builds provider {@link ProviderCredentials} from a persisted {@link IntegrationConnection}, decrypting + * the secret (the getters return decrypted values via the JPA converter). Isolated so the decryption + * point is single and obvious; the result is short-lived and never logged. + *

+ * For {@link CredentialType#OAUTH2} connections it first ensures a fresh access token via + * {@link JiraOAuthTokenService} (refreshing + persisting rotated tokens if the cached one is stale), so + * the provider always receives a usable bearer token. + */ +@Component +@RequiredArgsConstructor +public class ProviderCredentialsFactory { + + private final JiraOAuthTokenService oauthTokens; + + public ProviderCredentials from(IntegrationConnection connection) { + if (connection.getCredentialType() == CredentialType.OAUTH2) { + String accessToken = oauthTokens.freshAccessToken(connection); + return ProviderCredentials.oauth(connection.getSiteUrl(), connection.getCloudId(), accessToken); + } + return ProviderCredentials.apiToken( + connection.getSiteUrl(), connection.getEmail(), connection.getApiToken()); + } +} diff --git a/src/main/java/com/kntro/reqsai/gateway/application/service/ProviderRegistry.java b/src/main/java/com/kntro/reqsai/gateway/application/service/ProviderRegistry.java new file mode 100644 index 00000000..39bb6fb9 --- /dev/null +++ b/src/main/java/com/kntro/reqsai/gateway/application/service/ProviderRegistry.java @@ -0,0 +1,33 @@ +package com.kntro.reqsai.gateway.application.service; + +import com.kntro.reqsai.gateway.application.port.IntegrationProvider; +import com.kntro.reqsai.gateway.domain.model.IntegrationProviderType; +import org.springframework.stereotype.Component; + +import java.util.EnumMap; +import java.util.List; +import java.util.Map; + +/** + * Resolves the {@link IntegrationProvider} for a given {@link IntegrationProviderType} (ADR-0023 provider + * seam). Indexes every provider bean by its {@code type()}; adding a provider is purely additive. + */ +@Component +public class ProviderRegistry { + + private final Map byType = + new EnumMap<>(IntegrationProviderType.class); + + public ProviderRegistry(List providers) { + providers.forEach(p -> byType.put(p.type(), p)); + } + + /** Returns the provider for {@code type}, or throws if none is registered. */ + public IntegrationProvider get(IntegrationProviderType type) { + IntegrationProvider provider = byType.get(type); + if (provider == null) { + throw new IllegalStateException("No integration provider registered for " + type); + } + return provider; + } +} diff --git a/src/main/java/com/kntro/reqsai/gateway/application/service/StoryPushService.java b/src/main/java/com/kntro/reqsai/gateway/application/service/StoryPushService.java new file mode 100644 index 00000000..86290b4a --- /dev/null +++ b/src/main/java/com/kntro/reqsai/gateway/application/service/StoryPushService.java @@ -0,0 +1,44 @@ +package com.kntro.reqsai.gateway.application.service; + +import com.kntro.reqsai.discovery.api.StoryView; +import com.kntro.reqsai.gateway.application.port.IntegrationConnectionRepository; +import com.kntro.reqsai.gateway.application.port.IntegrationProvider; +import com.kntro.reqsai.gateway.application.port.IntegrationProvider.ProviderCredentials; +import com.kntro.reqsai.gateway.application.port.IntegrationProvider.PushedIssue; +import com.kntro.reqsai.gateway.domain.exception.IntegrationsExceptions; +import com.kntro.reqsai.gateway.domain.model.IntegrationConnection; +import com.kntro.reqsai.gateway.domain.model.ProjectIntegrationTarget; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Component; + +/** + * Shared push mechanics used by both the single-story and push-all handlers: resolves the target's + * connection + provider once, then pushes a story through the provider. Keeps the two handlers thin and + * their credential/provider resolution identical. + */ +@Component +@RequiredArgsConstructor +public class StoryPushService { + + private final IntegrationConnectionRepository connections; + private final ProviderRegistry providers; + private final ProviderCredentialsFactory credentialsFactory; + + /** Resolves the connection + provider for a target, or throws if the connection has gone missing. */ + public PushContext contextFor(ProjectIntegrationTarget target) { + IntegrationConnection connection = connections.findById(target.getConnectionId()) + .orElseThrow(() -> IntegrationsExceptions.connectionNotFound(target.getConnectionId())); + IntegrationProvider provider = providers.get(connection.getProvider()); + return new PushContext(provider, credentialsFactory.from(connection), + target.getJiraProjectKey(), target.getIssueTypeName()); + } + + /** Pushes one story within a resolved context. Throws an infrastructure exception on provider failure. */ + public PushedIssue push(PushContext ctx, StoryView story) { + return ctx.provider().pushStory(ctx.credentials(), ctx.projectKey(), ctx.issueTypeName(), story); + } + + /** Resolved-once push context for a project's target. */ + public record PushContext(IntegrationProvider provider, ProviderCredentials credentials, + String projectKey, String issueTypeName) {} +} diff --git a/src/main/java/com/kntro/reqsai/gateway/domain/exception/IntegrationsError.java b/src/main/java/com/kntro/reqsai/gateway/domain/exception/IntegrationsError.java new file mode 100644 index 00000000..e2d54c22 --- /dev/null +++ b/src/main/java/com/kntro/reqsai/gateway/domain/exception/IntegrationsError.java @@ -0,0 +1,45 @@ +package com.kntro.reqsai.gateway.domain.exception; + +import com.kntro.reqsai.shared.domain.exception.ErrorCatalog; +import org.springframework.http.HttpStatus; + +/** + * Domain (business-rule) error codes owned by the Integrations bounded context (ADR-0023). Mapped to + * RFC 9457 {@code ProblemDetail} by the shared {@code GlobalExceptionHandler}. External-service + * failures live in {@link com.kntro.reqsai.gateway.infrastructure.exception.IntegrationsInfrastructureError}. + */ +public enum IntegrationsError implements ErrorCatalog { + + INTEGRATION_CONNECTION_NOT_FOUND(HttpStatus.NOT_FOUND), + INTEGRATION_ALREADY_CONNECTED(HttpStatus.CONFLICT), + INTEGRATION_TARGET_NOT_CONFIGURED(HttpStatus.CONFLICT), + + /** A background sync job (import / push-all) of the same type is already RUNNING for the project. */ + INTEGRATION_JOB_ALREADY_RUNNING(HttpStatus.CONFLICT), + + /** No sync job with the requested id exists for the project. */ + INTEGRATION_JOB_NOT_FOUND(HttpStatus.NOT_FOUND), + JIRA_PROJECT_NOT_FOUND(HttpStatus.NOT_FOUND), + + /** Jira OAuth 2.0 (3LO) is not configured on this deployment (client id/secret/redirect absent). */ + JIRA_OAUTH_NOT_CONFIGURED(HttpStatus.NOT_IMPLEMENTED), + + /** The OAuth {@code state} token failed validation (bad signature, expired, or wrong org/user). */ + JIRA_OAUTH_STATE_INVALID(HttpStatus.BAD_REQUEST); + + private final HttpStatus status; + + IntegrationsError(HttpStatus status) { + this.status = status; + } + + @Override + public String code() { + return name(); + } + + @Override + public HttpStatus status() { + return status; + } +} diff --git a/src/main/java/com/kntro/reqsai/gateway/domain/exception/IntegrationsExceptions.java b/src/main/java/com/kntro/reqsai/gateway/domain/exception/IntegrationsExceptions.java new file mode 100644 index 00000000..99715c58 --- /dev/null +++ b/src/main/java/com/kntro/reqsai/gateway/domain/exception/IntegrationsExceptions.java @@ -0,0 +1,74 @@ +package com.kntro.reqsai.gateway.domain.exception; + +import com.kntro.reqsai.shared.domain.exception.DomainException; +import com.kntro.reqsai.shared.domain.exception.EntityNotFoundException; + +import java.util.UUID; + +/** + * Factory for Integrations domain exceptions — the context-specific counterpart of the shared + * {@code Exceptions}. Not-found cases return {@link EntityNotFoundException}; the rest a + * {@link DomainException} carrying an {@link IntegrationsError}. + */ +public final class IntegrationsExceptions { + + private IntegrationsExceptions() { + throw new UnsupportedOperationException("Utility class - do not instantiate"); + } + + public static EntityNotFoundException connectionNotFound(UUID connectionId) { + return new EntityNotFoundException(IntegrationsError.INTEGRATION_CONNECTION_NOT_FOUND, + "Integration connection not found: " + connectionId); + } + + /** A project has no Jira target configured — 404 for the GET target endpoint. */ + public static EntityNotFoundException targetNotFound(UUID projectId) { + return new EntityNotFoundException(IntegrationsError.INTEGRATION_CONNECTION_NOT_FOUND, + "No integration target configured for project " + projectId); + } + + public static DomainException alreadyConnected(UUID organizationId, String provider) { + return new DomainException(IntegrationsError.INTEGRATION_ALREADY_CONNECTED, + "An active %s integration already exists for organization %s".formatted(provider, organizationId)); + } + + public static DomainException targetNotConfigured(UUID projectId) { + return new DomainException(IntegrationsError.INTEGRATION_TARGET_NOT_CONFIGURED, + "No integration target configured for project " + projectId); + } + + /** A RUNNING sync job of the same type already exists for the project — 409 for the job-start endpoints. */ + public static DomainException jobAlreadyRunning(UUID projectId, String jobType) { + return new DomainException(IntegrationsError.INTEGRATION_JOB_ALREADY_RUNNING, + "A %s job is already running for project %s".formatted(jobType, projectId)); + } + + /** No sync job with the given id exists for the project — 404 for the job query endpoint. */ + public static EntityNotFoundException jobNotFound(UUID jobId) { + return new EntityNotFoundException(IntegrationsError.INTEGRATION_JOB_NOT_FOUND, + "Integration sync job not found: " + jobId); + } + + public static EntityNotFoundException jiraProjectNotFound(String jiraProjectKey) { + return new EntityNotFoundException(IntegrationsError.JIRA_PROJECT_NOT_FOUND, + "Jira project not found: " + jiraProjectKey); + } + + /** A story to push was not found in the project — 404 for the push endpoints. */ + public static EntityNotFoundException storyNotFound(UUID storyId) { + return new EntityNotFoundException(IntegrationsError.INTEGRATION_CONNECTION_NOT_FOUND, + "Story not found in project: " + storyId); + } + + /** Jira OAuth is not configured on this deployment — the authorize/callback endpoints are unavailable. */ + public static DomainException oauthNotConfigured() { + return new DomainException(IntegrationsError.JIRA_OAUTH_NOT_CONFIGURED, + "Jira OAuth 2.0 (3LO) is not configured on this deployment"); + } + + /** The OAuth {@code state} token failed validation (signature/expiry/org-user mismatch). */ + public static DomainException oauthStateInvalid(String reason) { + return new DomainException(IntegrationsError.JIRA_OAUTH_STATE_INVALID, + "Invalid OAuth state: " + reason); + } +} diff --git a/src/main/java/com/kntro/reqsai/gateway/domain/model/ConnectionStatus.java b/src/main/java/com/kntro/reqsai/gateway/domain/model/ConnectionStatus.java new file mode 100644 index 00000000..4db82562 --- /dev/null +++ b/src/main/java/com/kntro/reqsai/gateway/domain/model/ConnectionStatus.java @@ -0,0 +1,19 @@ +package com.kntro.reqsai.gateway.domain.model; + +/** + * Lifecycle of an {@link IntegrationConnection}. A connection is {@code CONNECTED} while its stored + * credential last verified successfully; a failed verification flips it to {@code DEGRADED} without + * losing the credential; deleting it removes the row. The partial unique index treats anything other + * than {@code DISCONNECTED} as "active", so at most one active connection exists per org per provider. + */ +public enum ConnectionStatus { + + /** Credential present and last verification succeeded. */ + CONNECTED, + + /** Credential present but the last verification failed (auth or reachability). */ + DEGRADED, + + /** Retired connection (not counted by the single-active-connection unique index). */ + DISCONNECTED +} diff --git a/src/main/java/com/kntro/reqsai/gateway/domain/model/CredentialType.java b/src/main/java/com/kntro/reqsai/gateway/domain/model/CredentialType.java new file mode 100644 index 00000000..57520e74 --- /dev/null +++ b/src/main/java/com/kntro/reqsai/gateway/domain/model/CredentialType.java @@ -0,0 +1,22 @@ +package com.kntro.reqsai.gateway.domain.model; + +/** + * How an {@link IntegrationConnection} authenticates against its provider (ADR-0023). + *

    + *
  • {@code API_TOKEN} — Jira basic auth: {@code Authorization: Basic base64(email:token)} against + * {@code https://{site}/rest/api/3}. The {@code email} + encrypted {@code secret_ciphertext} are + * populated; the OAuth columns are null.
  • + *
  • {@code OAUTH2} — Jira OAuth 2.0 (3LO): {@code Authorization: Bearer {access}} against + * {@code https://api.atlassian.com/ex/jira/{cloudId}/rest/api/3}. The {@code cloud_id} + encrypted + * OAuth refresh/access tokens are populated; {@code email} + {@code secret_ciphertext} are null.
  • + *
+ * Exactly one credential shape is populated per value (application-enforced by the domain factory). + */ +public enum CredentialType { + + /** Jira API token + account email over basic auth (the original flow). */ + API_TOKEN, + + /** Jira OAuth 2.0 (3LO) refresh/access tokens over bearer auth. */ + OAUTH2 +} diff --git a/src/main/java/com/kntro/reqsai/gateway/domain/model/IntegrationConnection.java b/src/main/java/com/kntro/reqsai/gateway/domain/model/IntegrationConnection.java new file mode 100644 index 00000000..bac1653d --- /dev/null +++ b/src/main/java/com/kntro/reqsai/gateway/domain/model/IntegrationConnection.java @@ -0,0 +1,175 @@ +package com.kntro.reqsai.gateway.domain.model; + +import com.kntro.reqsai.gateway.infrastructure.persistence.converters.EncryptedStringConverter; +import com.kntro.reqsai.shared.domain.model.AggregateRoot; +import com.kntro.reqsai.shared.domain.support.Assert; +import jakarta.persistence.Column; +import jakarta.persistence.Convert; +import jakarta.persistence.Entity; +import jakarta.persistence.EnumType; +import jakarta.persistence.Enumerated; +import jakarta.persistence.Table; +import lombok.Getter; +import org.jspecify.annotations.Nullable; + +import java.time.Instant; +import java.util.UUID; + +/** + * Organization-scoped third-party integration connection (ADR-0023). Holds the provider, the Jira site + * URL, and one of two credential shapes selected by {@link #credentialType}: + *
    + *
  • {@link CredentialType#API_TOKEN} — account {@code email} + the API token + * encrypted at rest ({@code apiToken} → {@code secret_ciphertext} BYTEA).
  • + *
  • {@link CredentialType#OAUTH2} — the Atlassian {@code cloudId} + the OAuth refresh/access tokens + * encrypted at rest ({@code oauth_refresh_ciphertext} / {@code oauth_access_ciphertext}) + * plus the access-token expiry.
  • + *
+ * All secrets are transparently encrypted/decrypted by {@link EncryptedStringConverter} and are never + * exposed by any response mapper. + */ +@Entity +@Table(name = "integration_connections") +@Getter +public class IntegrationConnection extends AggregateRoot { + + private static final int SITE_URL_MAX = 500; + private static final int EMAIL_MAX = 320; + private static final int CLOUD_ID_MAX = 64; + + @Column(name = "organization_id", columnDefinition = "uuid", nullable = false, updatable = false) + private UUID organizationId; + + @Enumerated(EnumType.STRING) + @Column(name = "provider", nullable = false, length = 32, updatable = false) + private IntegrationProviderType provider; + + @Enumerated(EnumType.STRING) + @Column(name = "credential_type", nullable = false, length = 32, updatable = false) + private CredentialType credentialType; + + @Column(name = "site_url", nullable = false, length = SITE_URL_MAX) + private String siteUrl; + + /** Populated for {@link CredentialType#API_TOKEN}; null for OAuth. */ + @Column(name = "email", length = EMAIL_MAX) + private @Nullable String email; + + /** Plaintext in memory only; persisted encrypted via {@link EncryptedStringConverter}. API_TOKEN only. */ + @Convert(converter = EncryptedStringConverter.class) + @Column(name = "secret_ciphertext") + private @Nullable String apiToken; + + /** Atlassian cloud id (site id) for OAuth calls. Populated for {@link CredentialType#OAUTH2}. */ + @Column(name = "cloud_id", length = CLOUD_ID_MAX) + private @Nullable String cloudId; + + /** OAuth refresh token (plaintext in memory only; persisted encrypted). OAUTH2 only. */ + @Convert(converter = EncryptedStringConverter.class) + @Column(name = "oauth_refresh_ciphertext") + private @Nullable String oauthRefreshToken; + + /** OAuth access token (plaintext in memory only; persisted encrypted). OAUTH2 only. */ + @Convert(converter = EncryptedStringConverter.class) + @Column(name = "oauth_access_ciphertext") + private @Nullable String oauthAccessToken; + + /** When the current OAuth access token expires; used to decide when to refresh. OAUTH2 only. */ + @Column(name = "oauth_access_expires_at") + private @Nullable Instant oauthAccessExpiresAt; + + @Enumerated(EnumType.STRING) + @Column(name = "status", nullable = false, length = 32) + private ConnectionStatus status; + + @Column(name = "last_verified_at") + private @Nullable Instant lastVerifiedAt; + + protected IntegrationConnection() { + super(); + } + + /** + * Creates an API-token ({@link CredentialType#API_TOKEN}) Jira connection. The caller is expected to + * have verified the credential (via the provider) before persisting; {@code verifiedAt} records that + * success. + */ + public IntegrationConnection(UUID organizationId, IntegrationProviderType provider, + String siteUrl, String email, String apiToken, Instant verifiedAt) { + super(); + this.organizationId = Assert.notNull(organizationId, "organizationId"); + this.provider = Assert.notNull(provider, "provider"); + this.credentialType = CredentialType.API_TOKEN; + this.siteUrl = normalizeSiteUrl(siteUrl); + this.email = Assert.maxLength(Assert.notBlank(email, "email"), "email", EMAIL_MAX); + this.apiToken = Assert.notBlank(apiToken, "apiToken"); + this.status = ConnectionStatus.CONNECTED; + this.lastVerifiedAt = Assert.notNull(verifiedAt, "verifiedAt"); + } + + /** + * Creates an OAuth 2.0 (3LO) ({@link CredentialType#OAUTH2}) Jira connection from a completed token + * exchange. {@code siteUrl} is the discovered accessible-resource URL, {@code cloudId} its site id. + * The refresh token is required (obtained via the {@code offline_access} scope); the access token + + * its expiry are cached so the first call need not refresh. + */ + public static IntegrationConnection oauth(UUID organizationId, IntegrationProviderType provider, + String siteUrl, String cloudId, String refreshToken, + String accessToken, Instant accessExpiresAt, Instant verifiedAt) { + IntegrationConnection c = new IntegrationConnection(); + c.organizationId = Assert.notNull(organizationId, "organizationId"); + c.provider = Assert.notNull(provider, "provider"); + c.credentialType = CredentialType.OAUTH2; + c.siteUrl = normalizeSiteUrl(siteUrl); + c.cloudId = Assert.maxLength(Assert.notBlank(cloudId, "cloudId"), "cloudId", CLOUD_ID_MAX); + c.oauthRefreshToken = Assert.notBlank(refreshToken, "refreshToken"); + c.oauthAccessToken = Assert.notBlank(accessToken, "accessToken"); + c.oauthAccessExpiresAt = Assert.notNull(accessExpiresAt, "accessExpiresAt"); + c.status = ConnectionStatus.CONNECTED; + c.lastVerifiedAt = Assert.notNull(verifiedAt, "verifiedAt"); + return c; + } + + /** + * Applies rotated OAuth tokens after a refresh. Atlassian may return a new (rotated) refresh token; if + * so it is persisted, otherwise the existing refresh token is kept. The fresh access token + expiry + * replace the cached pair. No-op semantics for API-token connections is prevented by the caller. + */ + public void applyRefreshedTokens(@Nullable String rotatedRefreshToken, String accessToken, + Instant accessExpiresAt) { + Assert.isTrue(credentialType == CredentialType.OAUTH2, "credentialType", + "applyRefreshedTokens requires an OAUTH2 credential"); + if (rotatedRefreshToken != null && !rotatedRefreshToken.isBlank()) { + this.oauthRefreshToken = rotatedRefreshToken; + } + this.oauthAccessToken = Assert.notBlank(accessToken, "accessToken"); + this.oauthAccessExpiresAt = Assert.notNull(accessExpiresAt, "accessExpiresAt"); + this.status = ConnectionStatus.CONNECTED; + } + + /** True when the OAuth access token is missing, expired, or within {@code skew} of expiring. */ + public boolean oauthAccessExpiredWithin(java.time.Duration skew, Instant now) { + if (credentialType != CredentialType.OAUTH2) { + return false; + } + return oauthAccessToken == null || oauthAccessExpiresAt == null + || !oauthAccessExpiresAt.isAfter(now.plus(skew)); + } + + /** Normalizes the Jira base site URL, trimming a trailing slash so path concatenation is clean. */ + public static String normalizeSiteUrl(String siteUrl) { + String trimmed = Assert.maxLength(Assert.notBlank(siteUrl, "siteUrl"), "siteUrl", SITE_URL_MAX); + return trimmed.endsWith("/") ? trimmed.substring(0, trimmed.length() - 1) : trimmed; + } + + /** Marks a successful credential verification. */ + public void markVerified(Instant when) { + this.status = ConnectionStatus.CONNECTED; + this.lastVerifiedAt = Assert.notNull(when, "when"); + } + + /** Marks a failed credential verification without discarding the stored credential. */ + public void markDegraded() { + this.status = ConnectionStatus.DEGRADED; + } +} diff --git a/src/main/java/com/kntro/reqsai/gateway/domain/model/IntegrationProviderType.java b/src/main/java/com/kntro/reqsai/gateway/domain/model/IntegrationProviderType.java new file mode 100644 index 00000000..88be028c --- /dev/null +++ b/src/main/java/com/kntro/reqsai/gateway/domain/model/IntegrationProviderType.java @@ -0,0 +1,10 @@ +package com.kntro.reqsai.gateway.domain.model; + +/** + * Supported third-party integration providers. Only {@code JIRA} exists today; the value is stored on + * {@code IntegrationConnection} and drives provider-adapter selection (ADR-0023), so adding a provider + * (e.g. Azure DevOps) is additive. + */ +public enum IntegrationProviderType { + JIRA +} diff --git a/src/main/java/com/kntro/reqsai/gateway/domain/model/IntegrationSyncJob.java b/src/main/java/com/kntro/reqsai/gateway/domain/model/IntegrationSyncJob.java new file mode 100644 index 00000000..c8b4c3e8 --- /dev/null +++ b/src/main/java/com/kntro/reqsai/gateway/domain/model/IntegrationSyncJob.java @@ -0,0 +1,142 @@ +package com.kntro.reqsai.gateway.domain.model; + +import com.kntro.reqsai.shared.domain.model.AggregateRoot; +import com.kntro.reqsai.shared.domain.support.Assert; +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.EnumType; +import jakarta.persistence.Enumerated; +import jakarta.persistence.Table; +import lombok.Getter; +import org.jspecify.annotations.Nullable; + +import java.time.Instant; +import java.util.UUID; + +/** + * Durable background sync job (ADR-0023): one row per async Jira {@code IMPORT} / {@code PUSH_ALL} + * run. The row is the source of truth for progress — the async worker updates the + * counters per item and mirrors every update to STOMP, so a page reload recovers the live state by + * querying the job endpoints. + * + *

Counting rules: {@code processed} counts every handled item; {@code succeeded} the created/pushed + * ones; {@code failed} the per-item failures (which never abort the run). An import duplicate counts + * toward {@code processed} only (skipped — neither succeeded nor failed). Terminal transitions set + * {@code finishedAt} and an optional bounded {@code message} (fatal-error summary or skip note). + */ +@Entity +@Table(name = "integration_sync_jobs") +@Getter +public class IntegrationSyncJob extends AggregateRoot { + + private static final int MESSAGE_MAX = 1000; + + @Column(name = "project_id", columnDefinition = "uuid", nullable = false, updatable = false) + private UUID projectId; + + @Enumerated(EnumType.STRING) + @Column(name = "job_type", nullable = false, length = 16, updatable = false) + private IntegrationSyncJobType jobType; + + @Enumerated(EnumType.STRING) + @Column(name = "status", nullable = false, length = 16) + private IntegrationSyncJobStatus status; + + @Column(name = "total", nullable = false) + private int total; + + @Column(name = "processed", nullable = false) + private int processed; + + @Column(name = "succeeded", nullable = false) + private int succeeded; + + @Column(name = "failed", nullable = false) + private int failed; + + @Column(name = "message", length = MESSAGE_MAX) + @Nullable + private String message; + + @Column(name = "requested_by", columnDefinition = "uuid", updatable = false) + @Nullable + private UUID requestedBy; + + @Column(name = "finished_at") + @Nullable + private Instant finishedAt; + + protected IntegrationSyncJob() { + super(); + } + + /** Starts a new job in {@code RUNNING} state. {@code total} may be 0 until the worker resolves it. */ + public IntegrationSyncJob(UUID projectId, IntegrationSyncJobType jobType, int total, @Nullable UUID requestedBy) { + super(); + this.projectId = Assert.notNull(projectId, "projectId"); + this.jobType = Assert.notNull(jobType, "jobType"); + this.status = IntegrationSyncJobStatus.RUNNING; + this.total = Math.max(0, total); + this.requestedBy = requestedBy; + } + + public boolean isRunning() { + return status == IntegrationSyncJobStatus.RUNNING; + } + + /** Fixes the item count once the worker knows how many items it will process. */ + public void planTotal(int total) { + assertRunning(); + this.total = Math.max(0, total); + } + + /** One item created/pushed successfully. */ + public void recordSuccess() { + assertRunning(); + processed++; + succeeded++; + } + + /** One item failed (the run continues). */ + public void recordFailure() { + assertRunning(); + processed++; + failed++; + } + + /** One item skipped (e.g. an import duplicate): processed, but neither succeeded nor failed. */ + public void recordSkipped() { + assertRunning(); + processed++; + } + + /** Terminal success (per-item failures allowed); {@code message} is an optional summary note. */ + public void complete(@Nullable String message) { + assertRunning(); + this.status = IntegrationSyncJobStatus.COMPLETED; + this.message = truncate(message); + this.finishedAt = Instant.now(); + } + + /** Terminal fatal failure (e.g. the tracker was unreachable before/while iterating). */ + public void fail(@Nullable String message) { + assertRunning(); + this.status = IntegrationSyncJobStatus.FAILED; + this.message = truncate(message); + this.finishedAt = Instant.now(); + } + + private void assertRunning() { + if (!isRunning()) { + throw new IllegalStateException("Job " + getId() + " is terminal (" + status + ")"); + } + } + + @Nullable + private static String truncate(@Nullable String message) { + if (message == null || message.length() <= MESSAGE_MAX) { + return message; + } + return message.substring(0, MESSAGE_MAX); + } +} diff --git a/src/main/java/com/kntro/reqsai/gateway/domain/model/IntegrationSyncJobStatus.java b/src/main/java/com/kntro/reqsai/gateway/domain/model/IntegrationSyncJobStatus.java new file mode 100644 index 00000000..542a2f4c --- /dev/null +++ b/src/main/java/com/kntro/reqsai/gateway/domain/model/IntegrationSyncJobStatus.java @@ -0,0 +1,12 @@ +package com.kntro.reqsai.gateway.domain.model; + +/** + * Lifecycle of an {@link IntegrationSyncJob}: born {@code RUNNING}, ends {@code COMPLETED} (per-item + * failures allowed) or {@code FAILED} (fatal error, e.g. the tracker was unreachable). Persisted as + * the wire value ({@code VARCHAR(16)}), so names are part of the API contract. + */ +public enum IntegrationSyncJobStatus { + RUNNING, + COMPLETED, + FAILED +} diff --git a/src/main/java/com/kntro/reqsai/gateway/domain/model/IntegrationSyncJobType.java b/src/main/java/com/kntro/reqsai/gateway/domain/model/IntegrationSyncJobType.java new file mode 100644 index 00000000..18eb09e8 --- /dev/null +++ b/src/main/java/com/kntro/reqsai/gateway/domain/model/IntegrationSyncJobType.java @@ -0,0 +1,11 @@ +package com.kntro.reqsai.gateway.domain.model; + +/** + * What an {@link IntegrationSyncJob} does: {@code IMPORT} pulls tracker issues into the backlog as + * user stories; {@code PUSH_ALL} exports every project story to the tracker. Persisted as the wire + * value ({@code VARCHAR(16)}), so names are part of the API contract. + */ +public enum IntegrationSyncJobType { + IMPORT, + PUSH_ALL +} diff --git a/src/main/java/com/kntro/reqsai/gateway/domain/model/ProjectIntegrationTarget.java b/src/main/java/com/kntro/reqsai/gateway/domain/model/ProjectIntegrationTarget.java new file mode 100644 index 00000000..a54efe3c --- /dev/null +++ b/src/main/java/com/kntro/reqsai/gateway/domain/model/ProjectIntegrationTarget.java @@ -0,0 +1,63 @@ +package com.kntro.reqsai.gateway.domain.model; + +import com.kntro.reqsai.shared.domain.model.AggregateRoot; +import com.kntro.reqsai.shared.domain.support.Assert; +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.Table; +import lombok.Getter; + +import java.util.UUID; + +/** + * Project-scoped push target (ADR-0023): the Jira project key + issue type a Reqs-AI project's stories + * are pushed to, referencing the org-level {@link IntegrationConnection}. Exactly one per project (the + * {@code PUT .../target} endpoint upserts this single row). + */ +@Entity +@Table(name = "project_integration_targets") +@Getter +public class ProjectIntegrationTarget extends AggregateRoot { + + private static final int KEY_MAX = 100; + private static final int TYPE_MAX = 100; + + @Column(name = "project_id", columnDefinition = "uuid", nullable = false, updatable = false) + private UUID projectId; + + @Column(name = "connection_id", columnDefinition = "uuid", nullable = false) + private UUID connectionId; + + @Column(name = "jira_project_key", nullable = false, length = KEY_MAX) + private String jiraProjectKey; + + @Column(name = "issue_type_name", nullable = false, length = TYPE_MAX) + private String issueTypeName; + + protected ProjectIntegrationTarget() { + super(); + } + + public ProjectIntegrationTarget(UUID projectId, UUID connectionId, String jiraProjectKey, String issueTypeName) { + super(); + this.projectId = Assert.notNull(projectId, "projectId"); + this.connectionId = Assert.notNull(connectionId, "connectionId"); + this.jiraProjectKey = normalizeKey(jiraProjectKey); + this.issueTypeName = normalizeType(issueTypeName); + } + + public static String normalizeKey(String key) { + return Assert.maxLength(Assert.notBlank(key, "jiraProjectKey"), "jiraProjectKey", KEY_MAX); + } + + public static String normalizeType(String type) { + return Assert.maxLength(Assert.notBlank(type, "issueTypeName"), "issueTypeName", TYPE_MAX); + } + + /** Re-points this target at a (possibly different) connection, Jira project and issue type. */ + public void update(UUID connectionId, String jiraProjectKey, String issueTypeName) { + this.connectionId = Assert.notNull(connectionId, "connectionId"); + this.jiraProjectKey = normalizeKey(jiraProjectKey); + this.issueTypeName = normalizeType(issueTypeName); + } +} diff --git a/src/main/java/com/kntro/reqsai/gateway/infrastructure/batch/IntegrationBatchJobsConfiguration.java b/src/main/java/com/kntro/reqsai/gateway/infrastructure/batch/IntegrationBatchJobsConfiguration.java new file mode 100644 index 00000000..7f351d45 --- /dev/null +++ b/src/main/java/com/kntro/reqsai/gateway/infrastructure/batch/IntegrationBatchJobsConfiguration.java @@ -0,0 +1,227 @@ +package com.kntro.reqsai.gateway.infrastructure.batch; + +import com.kntro.reqsai.discovery.api.DiscoveryStoryReadPort; +import com.kntro.reqsai.discovery.api.StoryView; +import com.kntro.reqsai.gateway.application.notification.IntegrationJobProgressNotifier; +import com.kntro.reqsai.gateway.application.port.IntegrationProvider.RemoteIssue; +import com.kntro.reqsai.gateway.application.port.IntegrationSyncJobRepository; +import com.kntro.reqsai.gateway.application.port.ProjectIntegrationTargetRepository; +import com.kntro.reqsai.gateway.application.service.JiraImportService; +import com.kntro.reqsai.gateway.application.service.StoryPushService; +import com.kntro.reqsai.gateway.application.service.StoryPushService.PushContext; +import com.kntro.reqsai.gateway.domain.exception.IntegrationsExceptions; +import com.kntro.reqsai.gateway.domain.model.IntegrationSyncJob; +import com.kntro.reqsai.gateway.domain.model.ProjectIntegrationTarget; +import org.springframework.batch.core.configuration.annotation.StepScope; +import org.springframework.batch.core.job.Job; +import org.springframework.batch.core.job.builder.JobBuilder; +import org.springframework.batch.core.listener.ItemProcessListener; +import org.springframework.batch.core.repository.JobRepository; +import org.springframework.batch.core.step.Step; +import org.springframework.batch.core.step.builder.StepBuilder; +import org.springframework.batch.infrastructure.item.support.ListItemReader; +import org.springframework.beans.factory.annotation.Qualifier; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.transaction.PlatformTransactionManager; + +import java.util.List; +import java.util.Set; +import java.util.UUID; + +/** + * The two integration batch jobs (ADR-0023): {@code jiraImportJob} and {@code jiraPushAllJob}. Both + * follow the same topology — a single chunk-oriented step whose reader resolves the + * work list up front (planning the projection's {@code total}), whose processor delegates one item + * at a time to the existing application services, and whose writer is a no-op (the services persist + * their own side effects; the step only orchestrates). + * + *

Key Spring Batch concepts as used here: + *

    + *
  • Chunk-oriented processing — items are read/processed one by one and the + * chunk transaction commits every {@value #CHUNK_SIZE} items, bounding both transaction size + * and lost progress on a crash.
  • + *
  • Fault tolerance — {@code faultTolerant().skip(Exception).skipLimit(MAX)} + * means one bad item is skipped (counted by the SkipListener), never fatal; that + * mirrors the per-item semantics of the old synchronous endpoints. A failure outside + * item processing (e.g. Jira unreachable in the reader) still fails the step and the job.
  • + *
  • {@code @StepScope} — readers/processors/progress listener are created per + * step execution and parameterized from {@code JobParameters} (late binding), because + * singleton step components could not carry per-run state like the project or job id.
  • + *
+ * + *

Placement: this is infrastructure. The step components only drive the application + * layer ({@link JiraImportService}, {@link StoryPushService}) — swapping the engine again would + * touch this package and nothing else. + */ +@Configuration +public class IntegrationBatchJobsConfiguration { + + public static final String IMPORT_JOB_NAME = "jiraImportJob"; + public static final String PUSH_ALL_JOB_NAME = "jiraPushAllJob"; + + private static final int CHUNK_SIZE = 5; + + // ================================== + // IMPORT JOB + // ================================== + + @Bean + public Job jiraImportJob(JobRepository jobRepository, + @Qualifier("jiraImportStep") Step jiraImportStep, + IntegrationJobExecutionListener integrationJobExecutionListener) { + return new JobBuilder(IMPORT_JOB_NAME, jobRepository) + .listener(integrationJobExecutionListener) + .start(jiraImportStep) + .build(); + } + + @Bean + public Step jiraImportStep(JobRepository jobRepository, + PlatformTransactionManager transactionManager, + @Qualifier("jiraImportReader") ListItemReader jiraImportReader, + JiraImportItemProcessor jiraImportProcessor, + IntegrationJobProgressListener integrationJobProgressListener) { + return new StepBuilder("jiraImportStep", jobRepository) + .chunk(CHUNK_SIZE) + .reader(jiraImportReader) + .processor(jiraImportProcessor) + .writer(chunk -> { }) + .transactionManager(transactionManager) + .listener((ItemProcessListener) integrationJobProgressListener) + .faultTolerant() + .skip(Exception.class) + .skipLimit(Long.MAX_VALUE) + .skipListener(integrationJobProgressListener) + .build(); + } + + /** + * Resolves the import work list at step start: target → provider context → eligible Jira issues, + * optionally restricted to the requested keys. Also fixes the projection's {@code total} now that + * the real item count is known. A Jira failure here is fatal by design (nothing was processed + * yet) and fails the job. + */ + @Bean + @StepScope + public ListItemReader jiraImportReader( + @Value("#{jobParameters['" + IntegrationJobParameters.DOMAIN_JOB_ID + "']}") String domainJobId, + @Value("#{jobParameters['" + IntegrationJobParameters.PROJECT_ID + "']}") String projectId, + @Value("#{jobParameters['" + IntegrationJobParameters.ISSUE_KEYS + "']}") String issueKeysCsv, + ProjectIntegrationTargetRepository targets, + JiraImportService importService, + IntegrationSyncJobRepository jobs, + IntegrationJobProgressNotifier progress) { + UUID project = UUID.fromString(projectId); + PushContext ctx = importService.contextFor(requireTarget(targets, project)); + Set requested = IntegrationJobParameters.parseIssueKeys(issueKeysCsv); + List selected = importService.fetchIssues(ctx).stream() + .filter(issue -> requested.isEmpty() || requested.contains(issue.issueKey())) + .toList(); + planTotal(jobs, progress, domainJobId, selected.size()); + return new ListItemReader<>(selected); + } + + @Bean + @StepScope + public JiraImportItemProcessor jiraImportProcessor( + @Value("#{jobParameters['" + IntegrationJobParameters.PROJECT_ID + "']}") String projectId, + JiraImportService importService) { + return new JiraImportItemProcessor(importService, UUID.fromString(projectId)); + } + + // ================================== + // PUSH-ALL JOB + // ================================== + + @Bean + public Job jiraPushAllJob(JobRepository jobRepository, + @Qualifier("jiraPushAllStep") Step jiraPushAllStep, + IntegrationJobExecutionListener integrationJobExecutionListener) { + return new JobBuilder(PUSH_ALL_JOB_NAME, jobRepository) + .listener(integrationJobExecutionListener) + .start(jiraPushAllStep) + .build(); + } + + @Bean + public Step jiraPushAllStep(JobRepository jobRepository, + PlatformTransactionManager transactionManager, + @Qualifier("jiraPushAllReader") ListItemReader jiraPushAllReader, + JiraStoryPushItemProcessor jiraStoryPushProcessor, + IntegrationJobProgressListener integrationJobProgressListener) { + return new StepBuilder("jiraPushAllStep", jobRepository) + .chunk(CHUNK_SIZE) + .reader(jiraPushAllReader) + .processor(jiraStoryPushProcessor) + .writer(chunk -> { }) + .transactionManager(transactionManager) + .listener((ItemProcessListener) integrationJobProgressListener) + .faultTolerant() + .skip(Exception.class) + .skipLimit(Long.MAX_VALUE) + .skipListener(integrationJobProgressListener) + .build(); + } + + /** + * Resolves the push work list and fixes the projection's {@code total}. Pushes every project story + * unless a story-id selection was carried on the job, in which case the list is filtered to the + * selected ids (original ordering preserved; ids not in the project are ignored). The {@code total} + * reflects the filtered count. + */ + @Bean + @StepScope + public ListItemReader jiraPushAllReader( + @Value("#{jobParameters['" + IntegrationJobParameters.DOMAIN_JOB_ID + "']}") String domainJobId, + @Value("#{jobParameters['" + IntegrationJobParameters.PROJECT_ID + "']}") String projectId, + @Value("#{jobParameters['" + IntegrationJobParameters.STORY_IDS + "']}") String storyIdsCsv, + DiscoveryStoryReadPort stories, + IntegrationSyncJobRepository jobs, + IntegrationJobProgressNotifier progress) { + Set requested = IntegrationJobParameters.parseStoryIds(storyIdsCsv); + List selected = stories.listStories(UUID.fromString(projectId)).stream() + .filter(story -> requested.isEmpty() || requested.contains(story.storyId())) + .toList(); + planTotal(jobs, progress, domainJobId, selected.size()); + return new ListItemReader<>(selected); + } + + @Bean + @StepScope + public JiraStoryPushItemProcessor jiraStoryPushProcessor( + @Value("#{jobParameters['" + IntegrationJobParameters.PROJECT_ID + "']}") String projectId, + ProjectIntegrationTargetRepository targets, + StoryPushService pushService) { + UUID project = UUID.fromString(projectId); + return new JiraStoryPushItemProcessor(pushService, + pushService.contextFor(requireTarget(targets, project))); + } + + // ================================== + // SHARED STEP COMPONENTS + // ================================== + + @Bean + @StepScope + public IntegrationJobProgressListener integrationJobProgressListener( + @Value("#{jobParameters['" + IntegrationJobParameters.DOMAIN_JOB_ID + "']}") String domainJobId, + IntegrationSyncJobRepository jobs, + IntegrationJobProgressNotifier progress) { + return new IntegrationJobProgressListener(UUID.fromString(domainJobId), jobs, progress); + } + + private static ProjectIntegrationTarget requireTarget(ProjectIntegrationTargetRepository targets, UUID projectId) { + return targets.findByProjectId(projectId) + .orElseThrow(() -> IntegrationsExceptions.targetNotConfigured(projectId)); + } + + private static void planTotal(IntegrationSyncJobRepository jobs, IntegrationJobProgressNotifier progress, + String domainJobId, int total) { + jobs.findById(UUID.fromString(domainJobId)).filter(IntegrationSyncJob::isRunning).ifPresent(job -> { + job.planTotal(total); + progress.publish(jobs.save(job)); + }); + } +} diff --git a/src/main/java/com/kntro/reqsai/gateway/infrastructure/batch/IntegrationJobExecutionListener.java b/src/main/java/com/kntro/reqsai/gateway/infrastructure/batch/IntegrationJobExecutionListener.java new file mode 100644 index 00000000..fe06a632 --- /dev/null +++ b/src/main/java/com/kntro/reqsai/gateway/infrastructure/batch/IntegrationJobExecutionListener.java @@ -0,0 +1,92 @@ +package com.kntro.reqsai.gateway.infrastructure.batch; + +import com.kntro.reqsai.gateway.application.notification.IntegrationJobProgressNotifier; +import com.kntro.reqsai.gateway.application.port.IntegrationSyncJobRepository; +import com.kntro.reqsai.gateway.domain.model.IntegrationSyncJob; +import com.kntro.reqsai.gateway.domain.model.IntegrationSyncJobType; +import com.kntro.reqsai.shared.infrastructure.persistence.multitenancy.TenantContext; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.batch.core.BatchStatus; +import org.springframework.batch.core.job.JobExecution; +import org.springframework.batch.core.listener.JobExecutionListener; +import org.springframework.stereotype.Component; + +import java.util.UUID; + +/** + * Frames every integration batch execution with the two cross-cutting concerns the engine cannot + * know about: + * + *

    + *
  1. Tenant restoration — the batch job runs on an executor thread with no + * filter-managed {@link TenantContext}. {@code beforeJob} restores the tenant/schema captured + * into the job parameters at launch time (the same snapshot-then-restore pattern used by + * {@code TenantAwareModuleListener} for async event consumers); {@code afterJob} clears it in a + * {@code finally} so the pooled thread never leaks a schema. The whole execution — listeners, + * step, readers, processors — runs on this one thread, so every Hibernate session it opens + * resolves the caller's tenant schema.
  2. + *
  3. Terminal projection state — {@code afterJob} runs whether the execution + * COMPLETED or FAILED, and is where the domain-facing {@code integration_sync_jobs} row gets + * its terminal status, {@code finished_at} and message (fatal-error summary, or the + * "N duplicados omitidos" note for imports), followed by the final STOMP publish.
  4. + *
+ */ +@Component +@RequiredArgsConstructor +@Slf4j +public class IntegrationJobExecutionListener implements JobExecutionListener { + + private final IntegrationSyncJobRepository jobs; + private final IntegrationJobProgressNotifier progress; + + @Override + public void beforeJob(JobExecution jobExecution) { + String tenantId = jobExecution.getJobParameters().getString(IntegrationJobParameters.TENANT_ID); + String tenantSchema = jobExecution.getJobParameters().getString(IntegrationJobParameters.TENANT_SCHEMA); + TenantContext.setCurrentTenant(tenantId != null ? tenantId : TenantContext.DEFAULT_SCHEMA); + TenantContext.setCurrentSchema(tenantSchema != null ? tenantSchema : TenantContext.DEFAULT_SCHEMA); + log.debug("Integration batch job {} running for tenant schema {}", + jobExecution.getJobInstance().getJobName(), tenantSchema); + } + + @Override + public void afterJob(JobExecution jobExecution) { + try { + String domainJobId = jobExecution.getJobParameters().getString(IntegrationJobParameters.DOMAIN_JOB_ID); + IntegrationSyncJob job = domainJobId == null + ? null + : jobs.findById(UUID.fromString(domainJobId)).orElse(null); + if (job == null || !job.isRunning()) { + log.warn("No RUNNING projection row to finalize for batch execution {}", jobExecution.getId()); + return; + } + if (jobExecution.getStatus() == BatchStatus.COMPLETED) { + job.complete(completionMessage(job)); + } else { + job.fail(failureMessage(jobExecution)); + } + progress.publish(jobs.save(job)); + } finally { + TenantContext.clear(); + } + } + + /** Imports report skipped duplicates; other jobs complete silently. */ + private static String completionMessage(IntegrationSyncJob job) { + int duplicates = job.getProcessed() - job.getSucceeded() - job.getFailed(); + if (job.getJobType() == IntegrationSyncJobType.IMPORT && duplicates > 0) { + return duplicates + " duplicados omitidos"; + } + return null; + } + + /** First failure message of the execution (e.g. Jira unreachable while fetching), token-free. */ + private static String failureMessage(JobExecution jobExecution) { + return jobExecution.getAllFailureExceptions().stream() + .map(Throwable::getMessage) + .filter(m -> m != null && !m.isBlank()) + .findFirst() + .orElse("Job failed with status " + jobExecution.getStatus()); + } +} diff --git a/src/main/java/com/kntro/reqsai/gateway/infrastructure/batch/IntegrationJobLauncherAdapter.java b/src/main/java/com/kntro/reqsai/gateway/infrastructure/batch/IntegrationJobLauncherAdapter.java new file mode 100644 index 00000000..4b3ee03b --- /dev/null +++ b/src/main/java/com/kntro/reqsai/gateway/infrastructure/batch/IntegrationJobLauncherAdapter.java @@ -0,0 +1,93 @@ +package com.kntro.reqsai.gateway.infrastructure.batch; + +import com.kntro.reqsai.gateway.application.notification.IntegrationJobProgressNotifier; +import com.kntro.reqsai.gateway.application.port.IntegrationJobLauncher; +import com.kntro.reqsai.gateway.application.port.IntegrationSyncJobRepository; +import com.kntro.reqsai.gateway.domain.model.IntegrationSyncJob; +import com.kntro.reqsai.shared.infrastructure.persistence.multitenancy.TenantContext; +import com.kntro.reqsai.shared.infrastructure.persistence.multitenancy.TenantContext.TenantSnapshot; +import lombok.extern.slf4j.Slf4j; +import org.jspecify.annotations.Nullable; +import org.springframework.batch.core.job.Job; +import org.springframework.batch.core.job.parameters.JobParametersBuilder; +import org.springframework.batch.core.launch.JobOperator; +import org.springframework.beans.factory.annotation.Qualifier; +import org.springframework.stereotype.Component; + +import java.util.List; +import java.util.UUID; + +/** + * Spring Batch adapter for the {@link IntegrationJobLauncher} port. Captures the caller's tenant on + * the request thread ({@link TenantContext#capture()}) into job parameters — the batch + * equivalent of the snapshot-then-restore pattern used by the app's other async paths — and starts + * the job through the {@code JobOperator}, whose task executor makes {@code start} return as soon as + * the execution is registered (that is what backs the {@code 202 Accepted} contract). + * + *

The {@code domainJobId} is the only identifying parameter, so each API launch + * is a brand-new JobInstance. If the launch itself fails (no executor thread, metadata store down), + * the projection row is failed immediately so no client is left watching a phantom RUNNING job. + */ +@Component +@Slf4j +public class IntegrationJobLauncherAdapter implements IntegrationJobLauncher { + + private final JobOperator jobOperator; + private final Job jiraImportJob; + private final Job jiraPushAllJob; + private final IntegrationSyncJobRepository jobs; + private final IntegrationJobProgressNotifier progress; + + public IntegrationJobLauncherAdapter( + JobOperator jobOperator, + @Qualifier("jiraImportJob") Job jiraImportJob, + @Qualifier("jiraPushAllJob") Job jiraPushAllJob, + IntegrationSyncJobRepository jobs, + IntegrationJobProgressNotifier progress) { + this.jobOperator = jobOperator; + this.jiraImportJob = jiraImportJob; + this.jiraPushAllJob = jiraPushAllJob; + this.jobs = jobs; + this.progress = progress; + } + + @Override + public void launchImport(UUID jobId, UUID projectId, @Nullable List issueKeys) { + launch(jiraImportJob, jobId, projectId, + IntegrationJobParameters.ISSUE_KEYS, IntegrationJobParameters.joinIssueKeys(issueKeys)); + } + + @Override + public void launchPushAll(UUID jobId, UUID projectId, @Nullable List storyIds) { + launch(jiraPushAllJob, jobId, projectId, + IntegrationJobParameters.STORY_IDS, IntegrationJobParameters.joinStoryIds(storyIds)); + } + + private void launch(Job batchJob, UUID jobId, UUID projectId, + String selectionKey, @Nullable String selectionCsv) { + TenantSnapshot tenant = TenantContext.capture(); + JobParametersBuilder params = new JobParametersBuilder() + .addString(IntegrationJobParameters.DOMAIN_JOB_ID, jobId.toString(), true) + .addString(IntegrationJobParameters.PROJECT_ID, projectId.toString(), false) + .addString(IntegrationJobParameters.TENANT_ID, tenant.tenantId(), false) + .addString(IntegrationJobParameters.TENANT_SCHEMA, tenant.tenantSchema(), false); + if (selectionCsv != null) { + params.addString(selectionKey, selectionCsv, false); + } + try { + jobOperator.start(batchJob, params.toJobParameters()); + } catch (Exception e) { + failUnlaunched(jobId, e); + } + } + + /** The execution never started: fail the projection so the UI is not stuck on RUNNING. */ + private void failUnlaunched(UUID jobId, Exception cause) { + log.error("Could not launch integration batch job {}", jobId, cause); + jobs.findById(jobId).filter(IntegrationSyncJob::isRunning).ifPresent(job -> { + job.fail("The background job could not be launched"); + progress.publish(jobs.save(job)); + }); + throw new IllegalStateException("Could not launch integration batch job " + jobId, cause); + } +} diff --git a/src/main/java/com/kntro/reqsai/gateway/infrastructure/batch/IntegrationJobParameters.java b/src/main/java/com/kntro/reqsai/gateway/infrastructure/batch/IntegrationJobParameters.java new file mode 100644 index 00000000..25bc5c90 --- /dev/null +++ b/src/main/java/com/kntro/reqsai/gateway/infrastructure/batch/IntegrationJobParameters.java @@ -0,0 +1,86 @@ +package com.kntro.reqsai.gateway.infrastructure.batch; + +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Set; +import java.util.StringJoiner; +import java.util.UUID; + +/** + * Job-parameter keys shared by the integration batch jobs. Spring Batch derives the + * JobInstance identity from the job name plus the identifying parameters — + * here only {@link #DOMAIN_JOB_ID} (the {@code integration_sync_jobs} UUID) is identifying, so every + * API-triggered run is a fresh JobInstance with exactly one JobExecution, and the batch metadata + * links 1:1 back to the domain row. The remaining keys are non-identifying context the execution + * needs: the tenant coordinates to restore ({@link #TENANT_ID}/{@link #TENANT_SCHEMA}), the project, + * the optional issue-key selection (import) and the optional story-id selection (push-all). + */ +public final class IntegrationJobParameters { + + /** Identifying: the {@code integration_sync_jobs} row this execution reports into. */ + public static final String DOMAIN_JOB_ID = "domainJobId"; + + public static final String PROJECT_ID = "projectId"; + public static final String TENANT_ID = "tenantId"; + public static final String TENANT_SCHEMA = "tenantSchema"; + + /** Comma-joined Jira issue keys to import; absent/blank means all eligible issues. */ + public static final String ISSUE_KEYS = "issueKeys"; + + /** Comma-joined story ids to push; absent/blank means all eligible stories. */ + public static final String STORY_IDS = "storyIds"; + + private IntegrationJobParameters() { + throw new UnsupportedOperationException("Utility class - do not instantiate"); + } + + /** Parses the comma-joined {@link #ISSUE_KEYS} value; empty set means "no restriction". */ + public static Set parseIssueKeys(String issueKeysCsv) { + Set keys = new LinkedHashSet<>(); + if (issueKeysCsv != null && !issueKeysCsv.isBlank()) { + for (String key : issueKeysCsv.split(",")) { + if (!key.isBlank()) { + keys.add(key.trim()); + } + } + } + return keys; + } + + /** Joins issue keys for the {@link #ISSUE_KEYS} parameter; {@code null} when unrestricted. */ + public static String joinIssueKeys(java.util.List issueKeys) { + if (issueKeys == null || issueKeys.isEmpty()) { + return null; + } + return String.join(",", issueKeys); + } + + /** + * Parses the comma-joined {@link #STORY_IDS} value into an ordered set of {@link UUID}s; an empty + * set means "no restriction" (push every eligible story). Blank or malformed tokens are ignored. + */ + public static Set parseStoryIds(String storyIdsCsv) { + Set ids = new LinkedHashSet<>(); + if (storyIdsCsv != null && !storyIdsCsv.isBlank()) { + for (String token : storyIdsCsv.split(",")) { + String trimmed = token.trim(); + if (!trimmed.isBlank()) { + ids.add(UUID.fromString(trimmed)); + } + } + } + return ids; + } + + /** Joins story ids for the {@link #STORY_IDS} parameter; {@code null} when unrestricted. */ + public static String joinStoryIds(List storyIds) { + if (storyIds == null || storyIds.isEmpty()) { + return null; + } + StringJoiner joiner = new StringJoiner(","); + for (UUID id : storyIds) { + joiner.add(id.toString()); + } + return joiner.toString(); + } +} diff --git a/src/main/java/com/kntro/reqsai/gateway/infrastructure/batch/IntegrationJobProgressListener.java b/src/main/java/com/kntro/reqsai/gateway/infrastructure/batch/IntegrationJobProgressListener.java new file mode 100644 index 00000000..88660506 --- /dev/null +++ b/src/main/java/com/kntro/reqsai/gateway/infrastructure/batch/IntegrationJobProgressListener.java @@ -0,0 +1,68 @@ +package com.kntro.reqsai.gateway.infrastructure.batch; + +import com.kntro.reqsai.gateway.application.notification.IntegrationJobProgressNotifier; +import com.kntro.reqsai.gateway.application.port.IntegrationSyncJobRepository; +import com.kntro.reqsai.gateway.domain.model.IntegrationSyncJob; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.batch.core.listener.ItemProcessListener; +import org.springframework.batch.core.listener.SkipListener; + +import java.util.UUID; +import java.util.function.Consumer; + +/** + * Per-item progress bridge from the batch step to the domain projection: after each processed item + * it updates the {@code integration_sync_jobs} counters and publishes the fresh snapshot to the + * project's STOMP topic. Registered twice on the step: + * + *

    + *
  • {@link ItemProcessListener#afterProcess} — normal path; the processor reports the outcome + * ({@code SUCCEEDED}/{@code SKIPPED}/{@code FAILED}) without throwing.
  • + *
  • {@link SkipListener#onSkipInProcess} — fault-tolerant path; the processor threw, the step's + * skip policy swallowed the exception, and the item counts as a failure.
  • + *
+ * + *

Instantiated {@code @StepScope} (one instance per step execution) because the target row id + * comes from the {@code domainJobId} job parameter. Counter writes join the surrounding chunk + * transaction — durable at each chunk boundary — while STOMP frames go out immediately; if a chunk + * rolls back for item-by-item skip rescanning, the counters are re-derived from the reverted row, so + * the projection stays consistent (a transient duplicate STOMP frame is harmless for a progress + * banner). + */ +@RequiredArgsConstructor +@Slf4j +public class IntegrationJobProgressListener + implements ItemProcessListener, SkipListener { + + private final UUID domainJobId; + private final IntegrationSyncJobRepository jobs; + private final IntegrationJobProgressNotifier progress; + + @Override + public void afterProcess(Object item, SyncItemOutcome outcome) { + if (outcome == null) { + return; // item filtered out by the processor; nothing to count + } + record(job -> { + switch (outcome) { + case SUCCEEDED -> job.recordSuccess(); + case SKIPPED -> job.recordSkipped(); + case FAILED -> job.recordFailure(); + } + }); + } + + @Override + public void onSkipInProcess(Object item, Throwable t) { + log.warn("Integration job {} skipped one item: {}", domainJobId, t.getMessage()); + record(IntegrationSyncJob::recordFailure); + } + + private void record(Consumer update) { + jobs.findById(domainJobId).filter(IntegrationSyncJob::isRunning).ifPresent(job -> { + update.accept(job); + progress.publish(jobs.save(job)); + }); + } +} diff --git a/src/main/java/com/kntro/reqsai/gateway/infrastructure/batch/JiraImportItemProcessor.java b/src/main/java/com/kntro/reqsai/gateway/infrastructure/batch/JiraImportItemProcessor.java new file mode 100644 index 00000000..a78c27e4 --- /dev/null +++ b/src/main/java/com/kntro/reqsai/gateway/infrastructure/batch/JiraImportItemProcessor.java @@ -0,0 +1,33 @@ +package com.kntro.reqsai.gateway.infrastructure.batch; + +import com.kntro.reqsai.gateway.application.port.IntegrationProvider.RemoteIssue; +import com.kntro.reqsai.gateway.application.result.ImportStoryResult; +import com.kntro.reqsai.gateway.application.service.JiraImportService; +import lombok.RequiredArgsConstructor; +import org.springframework.batch.infrastructure.item.ItemProcessor; + +import java.util.UUID; + +/** + * Chunk-step processor for the import job: one Jira issue in, one {@link SyncItemOutcome} out, + * delegating to the existing {@link JiraImportService#importIssue} (LLM mapping + dedup, owned by + * the application layer — the batch step is only the driver). The service captures per-issue + * failures itself and reports them as a {@code FAILED} result, so this processor normally never + * throws; anything unexpected that does escape is handled by the step's skip policy. + */ +@RequiredArgsConstructor +public class JiraImportItemProcessor implements ItemProcessor { + + private final JiraImportService importService; + private final UUID projectId; + + @Override + public SyncItemOutcome process(RemoteIssue issue) { + ImportStoryResult result = importService.importIssue(projectId, issue); + return switch (result.status()) { + case IMPORTED -> SyncItemOutcome.SUCCEEDED; + case DUPLICATE -> SyncItemOutcome.SKIPPED; + case FAILED -> SyncItemOutcome.FAILED; + }; + } +} diff --git a/src/main/java/com/kntro/reqsai/gateway/infrastructure/batch/JiraStoryPushItemProcessor.java b/src/main/java/com/kntro/reqsai/gateway/infrastructure/batch/JiraStoryPushItemProcessor.java new file mode 100644 index 00000000..27acd9dc --- /dev/null +++ b/src/main/java/com/kntro/reqsai/gateway/infrastructure/batch/JiraStoryPushItemProcessor.java @@ -0,0 +1,27 @@ +package com.kntro.reqsai.gateway.infrastructure.batch; + +import com.kntro.reqsai.discovery.api.StoryView; +import com.kntro.reqsai.gateway.application.service.StoryPushService; +import com.kntro.reqsai.gateway.application.service.StoryPushService.PushContext; +import lombok.RequiredArgsConstructor; +import org.springframework.batch.infrastructure.item.ItemProcessor; + +/** + * Chunk-step processor for the push-all job: one story in, pushed to the tracker via the existing + * {@link StoryPushService} with the context resolved once per step execution. A provider failure + * throws on purpose: the step's fault-tolerant skip policy swallows it, the SkipListener + * counts it as a failed item, and the batch moves on — the same per-item semantics the old + * synchronous push-all endpoint had. + */ +@RequiredArgsConstructor +public class JiraStoryPushItemProcessor implements ItemProcessor { + + private final StoryPushService pushService; + private final PushContext context; + + @Override + public SyncItemOutcome process(StoryView story) { + pushService.push(context, story); + return SyncItemOutcome.SUCCEEDED; + } +} diff --git a/src/main/java/com/kntro/reqsai/gateway/infrastructure/batch/SyncItemOutcome.java b/src/main/java/com/kntro/reqsai/gateway/infrastructure/batch/SyncItemOutcome.java new file mode 100644 index 00000000..3ade74c5 --- /dev/null +++ b/src/main/java/com/kntro/reqsai/gateway/infrastructure/batch/SyncItemOutcome.java @@ -0,0 +1,13 @@ +package com.kntro.reqsai.gateway.infrastructure.batch; + +/** + * Per-item result flowing from the batch item processors to the progress listener. {@code SKIPPED} + * is an import duplicate (processed but neither succeeded nor failed); {@code FAILED} is a per-item + * failure the processor captured itself (the run continues). Items whose processor throws + * instead are routed through the step's skip policy and land in the SkipListener, not here. + */ +public enum SyncItemOutcome { + SUCCEEDED, + SKIPPED, + FAILED +} diff --git a/src/main/java/com/kntro/reqsai/gateway/infrastructure/crypto/AesGcmCipher.java b/src/main/java/com/kntro/reqsai/gateway/infrastructure/crypto/AesGcmCipher.java new file mode 100644 index 00000000..7a715af2 --- /dev/null +++ b/src/main/java/com/kntro/reqsai/gateway/infrastructure/crypto/AesGcmCipher.java @@ -0,0 +1,83 @@ +package com.kntro.reqsai.gateway.infrastructure.crypto; + +import com.kntro.reqsai.gateway.application.port.SecretCipher; +import com.kntro.reqsai.gateway.infrastructure.exception.IntegrationsInfrastructureExceptions; + +import javax.crypto.Cipher; +import javax.crypto.spec.GCMParameterSpec; +import javax.crypto.spec.SecretKeySpec; +import java.nio.ByteBuffer; +import java.security.SecureRandom; +import java.util.Base64; + +/** + * AES-256-GCM symmetric encryption for integration secrets at rest (ADR-0023). + *

+ * Each value gets a fresh random 12-byte IV, prepended to the ciphertext+tag so decryption is + * self-describing: the stored bytes are {@code IV(12) || ciphertext||tag}. The key is a base64-encoded + * 32-byte value supplied at construction (from {@code INTEGRATIONS_ENCRYPTION_KEY}). Never logs + * plaintext or key material. + *

+ * Infrastructure adapter for the {@link SecretCipher} application port. + */ +public final class AesGcmCipher implements SecretCipher { + + private static final String TRANSFORMATION = "AES/GCM/NoPadding"; + private static final int IV_LENGTH = 12; + private static final int TAG_LENGTH_BITS = 128; + private static final int KEY_LENGTH_BYTES = 32; + + private final SecretKeySpec key; + private final SecureRandom random = new SecureRandom(); + + /** @param base64Key base64-encoded 32-byte (AES-256) key */ + public AesGcmCipher(String base64Key) { + byte[] raw; + try { + raw = Base64.getDecoder().decode(base64Key.strip()); + } catch (IllegalArgumentException e) { + throw IntegrationsInfrastructureExceptions.encryptionError( + "INTEGRATIONS_ENCRYPTION_KEY is not valid base64", e); + } + if (raw.length != KEY_LENGTH_BYTES) { + throw IntegrationsInfrastructureExceptions.encryptionError( + "INTEGRATIONS_ENCRYPTION_KEY must decode to 32 bytes (AES-256), got " + raw.length, null); + } + this.key = new SecretKeySpec(raw, "AES"); + } + + /** Encrypts {@code plaintext} → {@code IV || ciphertext+tag}. */ + @Override + public byte[] encrypt(byte[] plaintext) { + try { + byte[] iv = new byte[IV_LENGTH]; + random.nextBytes(iv); + Cipher cipher = Cipher.getInstance(TRANSFORMATION); + cipher.init(Cipher.ENCRYPT_MODE, key, new GCMParameterSpec(TAG_LENGTH_BITS, iv)); + byte[] ciphertext = cipher.doFinal(plaintext); + return ByteBuffer.allocate(iv.length + ciphertext.length).put(iv).put(ciphertext).array(); + } catch (Exception e) { + throw IntegrationsInfrastructureExceptions.encryptionError("encrypt", e); + } + } + + /** Decrypts {@code IV || ciphertext+tag} produced by {@link #encrypt(byte[])}. */ + @Override + public byte[] decrypt(byte[] stored) { + try { + if (stored.length <= IV_LENGTH) { + throw new IllegalArgumentException("ciphertext too short"); + } + ByteBuffer buffer = ByteBuffer.wrap(stored); + byte[] iv = new byte[IV_LENGTH]; + buffer.get(iv); + byte[] ciphertext = new byte[buffer.remaining()]; + buffer.get(ciphertext); + Cipher cipher = Cipher.getInstance(TRANSFORMATION); + cipher.init(Cipher.DECRYPT_MODE, key, new GCMParameterSpec(TAG_LENGTH_BITS, iv)); + return cipher.doFinal(ciphertext); + } catch (Exception e) { + throw IntegrationsInfrastructureExceptions.encryptionError("decrypt", e); + } + } +} diff --git a/src/main/java/com/kntro/reqsai/gateway/infrastructure/crypto/IntegrationsCryptoConfiguration.java b/src/main/java/com/kntro/reqsai/gateway/infrastructure/crypto/IntegrationsCryptoConfiguration.java new file mode 100644 index 00000000..ce908ea1 --- /dev/null +++ b/src/main/java/com/kntro/reqsai/gateway/infrastructure/crypto/IntegrationsCryptoConfiguration.java @@ -0,0 +1,44 @@ +package com.kntro.reqsai.gateway.infrastructure.crypto; + +import com.kntro.reqsai.gateway.application.port.SecretCipher; +import com.kntro.reqsai.gateway.infrastructure.exception.IntegrationsInfrastructureExceptions; +import com.kntro.reqsai.gateway.infrastructure.persistence.converters.EncryptedStringConverter; +import jakarta.annotation.PostConstruct; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +/** + * Wires the AES-256-GCM cipher used to encrypt integration secrets at rest (ADR-0023) and injects it + * into the Hibernate-instantiated {@link EncryptedStringConverter} via its static holder. + *

+ * The key comes from {@code INTEGRATIONS_ENCRYPTION_KEY} (base64, 32 bytes). It is required for the + * integrations feature; if absent the context fails fast at startup with a clear message rather than + * only when a token is first persisted. + */ +@Configuration +@Slf4j +public class IntegrationsCryptoConfiguration { + + private final SecretCipher cipher; + + public IntegrationsCryptoConfiguration(@Value("${reqsai.integrations.encryption-key:}") String base64Key) { + if (base64Key == null || base64Key.isBlank()) { + throw IntegrationsInfrastructureExceptions.encryptionError( + "INTEGRATIONS_ENCRYPTION_KEY is not configured", null); + } + this.cipher = new AesGcmCipher(base64Key); + } + + @Bean + SecretCipher integrationsCipher() { + return cipher; + } + + @PostConstruct + void wireConverter() { + EncryptedStringConverter.setCipher(cipher); + log.info("Integrations secret encryption initialized (AES-256-GCM)"); + } +} diff --git a/src/main/java/com/kntro/reqsai/gateway/infrastructure/exception/IntegrationsInfrastructureError.java b/src/main/java/com/kntro/reqsai/gateway/infrastructure/exception/IntegrationsInfrastructureError.java new file mode 100644 index 00000000..e46192e8 --- /dev/null +++ b/src/main/java/com/kntro/reqsai/gateway/infrastructure/exception/IntegrationsInfrastructureError.java @@ -0,0 +1,37 @@ +package com.kntro.reqsai.gateway.infrastructure.exception; + +import com.kntro.reqsai.shared.domain.exception.ErrorCatalog; +import org.springframework.http.HttpStatus; + +/** + * Error codes for external-service and crypto failures in the Integrations bounded context (ADR-0023). + * These are infrastructure concerns (Jira reachability/auth, encryption) and must NOT live in + * {@link com.kntro.reqsai.gateway.domain.exception.IntegrationsError}. + */ +public enum IntegrationsInfrastructureError implements ErrorCatalog { + + JIRA_AUTH_FAILED(HttpStatus.UNAUTHORIZED), + JIRA_UNREACHABLE(HttpStatus.BAD_GATEWAY), + JIRA_PUSH_FAILED(HttpStatus.BAD_GATEWAY), + JIRA_IMPORT_FAILED(HttpStatus.BAD_GATEWAY), + INTEGRATION_ENCRYPTION_ERROR(HttpStatus.INTERNAL_SERVER_ERROR), + + /** The Jira OAuth authorization-code / refresh-token exchange with Atlassian failed. */ + JIRA_OAUTH_EXCHANGE_FAILED(HttpStatus.BAD_GATEWAY); + + private final HttpStatus status; + + IntegrationsInfrastructureError(HttpStatus status) { + this.status = status; + } + + @Override + public String code() { + return name(); + } + + @Override + public HttpStatus status() { + return status; + } +} diff --git a/src/main/java/com/kntro/reqsai/gateway/infrastructure/exception/IntegrationsInfrastructureExceptions.java b/src/main/java/com/kntro/reqsai/gateway/infrastructure/exception/IntegrationsInfrastructureExceptions.java new file mode 100644 index 00000000..257ac354 --- /dev/null +++ b/src/main/java/com/kntro/reqsai/gateway/infrastructure/exception/IntegrationsInfrastructureExceptions.java @@ -0,0 +1,47 @@ +package com.kntro.reqsai.gateway.infrastructure.exception; + +import com.kntro.reqsai.shared.domain.exception.InfrastructureException; + +/** + * Factory for Integrations infrastructure exceptions — the infrastructure counterpart of + * {@link com.kntro.reqsai.gateway.domain.exception.IntegrationsExceptions}. Adapters use this + * factory instead of constructing {@link InfrastructureException} inline. Messages never include the + * Jira token. + */ +public final class IntegrationsInfrastructureExceptions { + + private IntegrationsInfrastructureExceptions() { + throw new UnsupportedOperationException("Utility class - do not instantiate"); + } + + public static InfrastructureException jiraAuthFailed() { + return new InfrastructureException(IntegrationsInfrastructureError.JIRA_AUTH_FAILED, + "Jira rejected the credentials (401/403)", null); + } + + public static InfrastructureException jiraUnreachable(String reason, Throwable cause) { + return new InfrastructureException(IntegrationsInfrastructureError.JIRA_UNREACHABLE, + "Jira is unreachable: " + reason, cause); + } + + public static InfrastructureException jiraPushFailed(String reason) { + return new InfrastructureException(IntegrationsInfrastructureError.JIRA_PUSH_FAILED, + "Jira rejected the issue creation: " + reason, null); + } + + public static InfrastructureException jiraImportFailed(String reason, Throwable cause) { + return new InfrastructureException(IntegrationsInfrastructureError.JIRA_IMPORT_FAILED, + "Jira import failed: " + reason, cause); + } + + public static InfrastructureException encryptionError(String reason, Throwable cause) { + return new InfrastructureException(IntegrationsInfrastructureError.INTEGRATION_ENCRYPTION_ERROR, + "Integration secret encryption failed: " + reason, cause); + } + + /** The Jira OAuth token/refresh exchange with Atlassian failed. Never includes any token. */ + public static InfrastructureException jiraOauthExchangeFailed(String reason, Throwable cause) { + return new InfrastructureException(IntegrationsInfrastructureError.JIRA_OAUTH_EXCHANGE_FAILED, + "Jira OAuth token exchange failed: " + reason, cause); + } +} diff --git a/src/main/java/com/kntro/reqsai/gateway/infrastructure/jira/JiraAdfBuilder.java b/src/main/java/com/kntro/reqsai/gateway/infrastructure/jira/JiraAdfBuilder.java new file mode 100644 index 00000000..492ee7ee --- /dev/null +++ b/src/main/java/com/kntro/reqsai/gateway/infrastructure/jira/JiraAdfBuilder.java @@ -0,0 +1,71 @@ +package com.kntro.reqsai.gateway.infrastructure.jira; + +import com.kntro.reqsai.discovery.api.AcceptanceCriterionView; +import com.kntro.reqsai.discovery.api.StoryView; + +import java.util.ArrayList; +import java.util.List; +import java.util.Map; + +/** + * Builds a Jira issue description as an Atlassian Document Format (ADF) document + * (Jira Cloud REST v3 requires ADF, not wiki markup) from a Reqs-AI {@link StoryView}. + *

+ * Layout: a "As {role}, I want to {action}, so that {benefit}." paragraph, the priority/story-point + * metadata, then an "Acceptance Criteria" heading with one bullet per criterion rendered as + * {@code Given … When … Then …}. The returned map is the {@code description} field value. + */ +public final class JiraAdfBuilder { + + private JiraAdfBuilder() { + throw new UnsupportedOperationException("Utility class"); + } + + /** Builds the ADF {@code doc} node for the given story. */ + public static Map buildDescription(StoryView story) { + List content = new ArrayList<>(); + + content.add(paragraph("As %s, I want to %s, so that %s.".formatted( + story.role(), story.action(), story.benefit()))); + + String meta = "Priority: " + story.priority() + + (story.storyPoints() != null ? " • Story points: " + story.storyPoints() : ""); + content.add(paragraph(meta)); + + List criteria = story.acceptanceCriteria(); + if (criteria != null && !criteria.isEmpty()) { + content.add(heading("Acceptance Criteria")); + content.add(bulletList(criteria)); + } + + return Map.of("type", "doc", "version", 1, "content", content); + } + + private static Map paragraph(String text) { + return Map.of("type", "paragraph", "content", List.of(textNode(text))); + } + + private static Map heading(String text) { + return Map.of("type", "heading", "attrs", Map.of("level", 3), + "content", List.of(textNode(text))); + } + + private static Map bulletList(List criteria) { + List items = new ArrayList<>(); + for (AcceptanceCriterionView c : criteria) { + StringBuilder line = new StringBuilder(); + if (c.scenario() != null && !c.scenario().isBlank()) { + line.append(c.scenario()).append(": "); + } + line.append("Given ").append(c.given()) + .append(", When ").append(c.when()) + .append(", Then ").append(c.then()).append('.'); + items.add(Map.of("type", "listItem", "content", List.of(paragraph(line.toString())))); + } + return Map.of("type", "bulletList", "content", items); + } + + private static Map textNode(String text) { + return Map.of("type", "text", "text", text); + } +} diff --git a/src/main/java/com/kntro/reqsai/gateway/infrastructure/jira/JiraAdfReader.java b/src/main/java/com/kntro/reqsai/gateway/infrastructure/jira/JiraAdfReader.java new file mode 100644 index 00000000..4042df49 --- /dev/null +++ b/src/main/java/com/kntro/reqsai/gateway/infrastructure/jira/JiraAdfReader.java @@ -0,0 +1,71 @@ +package com.kntro.reqsai.gateway.infrastructure.jira; + +import org.jspecify.annotations.Nullable; + +import java.util.List; +import java.util.Map; + +/** + * Minimal inverse of {@link JiraAdfBuilder}: flattens an Atlassian Document Format (ADF) description node + * (Jira Cloud REST v3 returns descriptions as ADF, not plain text) into plain text so the import mapping + * can feed it to the LLM / fallback parser. + * + *

Walks the {@code content} tree collecting every {@code text} leaf, inserting a newline after each + * block node ({@code paragraph}, {@code heading}, {@code listItem}) and a {@code "- "} bullet marker before + * list items. Unknown node types are traversed for their text children. Returns {@code ""} for a null or + * empty document — never throws, so a malformed description never aborts an import. + */ +public final class JiraAdfReader { + + private JiraAdfReader() { + throw new UnsupportedOperationException("Utility class"); + } + + /** Flattens the ADF {@code doc} map to plain text (empty string when null/blank). */ + public static String toPlainText(@Nullable Map adf) { + if (adf == null || adf.isEmpty()) { + return ""; + } + StringBuilder out = new StringBuilder(); + appendNode(adf, out); + return out.toString().strip(); + } + + @SuppressWarnings("unchecked") + private static void appendNode(Object node, StringBuilder out) { + if (!(node instanceof Map map)) { + return; + } + String type = String.valueOf(map.get("type")); + if ("text".equals(type)) { + Object text = map.get("text"); + if (text != null) { + out.append(text); + } + return; + } + if ("hardBreak".equals(type)) { + out.append('\n'); + return; + } + if ("listItem".equals(type)) { + out.append("- "); + } + Object content = map.get("content"); + if (content instanceof List children) { + for (Object child : children) { + appendNode(child, out); + } + } + if (isBlock(type)) { + out.append('\n'); + } + } + + private static boolean isBlock(String type) { + return switch (type) { + case "paragraph", "heading", "listItem", "blockquote", "codeBlock" -> true; + default -> false; + }; + } +} diff --git a/src/main/java/com/kntro/reqsai/gateway/infrastructure/jira/JiraClient.java b/src/main/java/com/kntro/reqsai/gateway/infrastructure/jira/JiraClient.java new file mode 100644 index 00000000..70aaffda --- /dev/null +++ b/src/main/java/com/kntro/reqsai/gateway/infrastructure/jira/JiraClient.java @@ -0,0 +1,410 @@ +package com.kntro.reqsai.gateway.infrastructure.jira; + +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.kntro.reqsai.gateway.infrastructure.exception.IntegrationsInfrastructureExceptions; +import lombok.extern.slf4j.Slf4j; +import org.springframework.http.HttpStatusCode; +import org.springframework.http.MediaType; +import org.springframework.stereotype.Component; +import org.springframework.web.client.RestClient; + +import java.io.IOException; +import java.net.URLEncoder; +import java.nio.charset.StandardCharsets; +import java.util.ArrayList; +import java.util.Base64; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; + +/** + * Outbound Jira Cloud REST v3 client (ADR-0023), dual-mode across the two credential types: + *

    + *
  • API_TOKEN — base {@code https://{site}/rest/api/3} with basic auth + * ({@code Authorization: Basic base64(email:token)}).
  • + *
  • OAUTH2 — base {@code https://api.atlassian.com/ex/jira/{cloudId}/rest/api/3} + * with bearer auth ({@code Authorization: Bearer {access}}).
  • + *
+ * The base URL + {@code Authorization} header are supplied per call via a {@link JiraApiContext} built by + * {@link JiraProvider}, so the same call code serves both modes. Neither the token nor the header is ever + * logged or placed in exceptions. + *
    + *
  • 401/403 → {@code JIRA_AUTH_FAILED}
  • + *
  • connect/timeout/5xx → {@code JIRA_UNREACHABLE}
  • + *
  • 400 on create → {@code JIRA_PUSH_FAILED} (with Jira's {@code errorMessages}/field {@code errors})
  • + *
+ * + *

Issue types are resolved project-scoped via {@code createmeta/{projectKey}/issuetypes} + * (the new endpoint; the legacy global {@code /issuetype} returns duplicate names across projects and its + * ids are not always accepted by team-managed projects). Issue creation always sends the issue type by + * id (resolved for the specific project), which team-managed / localized projects + * (e.g. Spanish "Historia") require. + */ +@Component +@Slf4j +public class JiraClient { + + private static final String OAUTH_API_BASE = "https://api.atlassian.com/ex/jira/"; + private static final ObjectMapper ERROR_MAPPER = new ObjectMapper(); + + private final RestClient restClient; + + public JiraClient() { + this(RestClient.builder()); + } + + /** Builder-based constructor so tests can bind a {@code MockRestServiceServer} at the HTTP boundary. */ + public JiraClient(RestClient.Builder builder) { + this.restClient = builder.build(); + } + + /** + * The per-call base URL + {@code Authorization} header for a Jira REST v3 call. The {@code browseBase} + * is the human site URL used to build a {@code /browse/{key}} link (same for both modes). + */ + public record JiraApiContext(String apiBase, String authHeader, String browseBase) { + + /** API-token context: {@code https://{site}/rest/api/3} + basic auth. */ + public static JiraApiContext apiToken(String siteUrl, String email, String token) { + return new JiraApiContext(siteUrl + "/rest/api/3", basic(email, token), siteUrl); + } + + /** OAuth context: {@code https://api.atlassian.com/ex/jira/{cloudId}/rest/api/3} + bearer auth. */ + public static JiraApiContext oauth(String cloudId, String accessToken, String browseBase) { + return new JiraApiContext(OAUTH_API_BASE + cloudId + "/rest/api/3", "Bearer " + accessToken, browseBase); + } + + private static String basic(String email, String token) { + String raw = email + ":" + token; + return "Basic " + Base64.getEncoder().encodeToString(raw.getBytes(StandardCharsets.UTF_8)); + } + } + + /** GET /myself → the authenticated account's display name. */ + public String verify(JiraApiContext ctx) { + Myself me = exchange(() -> restClient.get() + .uri(ctx.apiBase() + "/myself") + .header("Authorization", ctx.authHeader()) + .accept(MediaType.APPLICATION_JSON) + .retrieve() + .onStatus(HttpStatusCode::isError, (req, res) -> { throw mapError(res, false); }) + .body(Myself.class), "verify"); + return me != null ? me.displayName() : ""; + } + + /** GET /project/search → visible projects. */ + public List listProjects(JiraApiContext ctx) { + ProjectSearch search = exchange(() -> restClient.get() + .uri(ctx.apiBase() + "/project/search?maxResults=100") + .header("Authorization", ctx.authHeader()) + .accept(MediaType.APPLICATION_JSON) + .retrieve() + .onStatus(HttpStatusCode::isError, (req, res) -> { throw mapError(res, false); }) + .body(ProjectSearch.class), "listProjects"); + return search == null || search.values() == null ? List.of() : search.values(); + } + + /** + * GET {@code /issue/createmeta/{projectKey}/issuetypes} → the issue types valid for that specific + * project, each with its project-scoped id (deduped by id). Fixes the previous behaviour of returning + * the GLOBAL {@code /issuetype} list, which repeated names ("Historia", "Tarea", …) across projects + * and produced ids that team-managed projects reject on create. + */ + public List listIssueTypes(JiraApiContext ctx, String projectKey) { + CreateMetaIssueTypes meta = exchange(() -> restClient.get() + .uri(ctx.apiBase() + "/issue/createmeta/" + enc(projectKey) + "/issuetypes?maxResults=200") + .header("Authorization", ctx.authHeader()) + .accept(MediaType.APPLICATION_JSON) + .retrieve() + .onStatus(HttpStatusCode::isError, (req, res) -> { throw mapError(res, false); }) + .body(CreateMetaIssueTypes.class), "listIssueTypes"); + if (meta == null || meta.issueTypes() == null) { + return List.of(); + } + Map byId = new LinkedHashMap<>(); + for (JiraIssueType t : meta.issueTypes()) { + if (t.id() != null) { + byId.putIfAbsent(t.id(), t); + } + } + return List.copyOf(byId.values()); + } + + /** + * Resolves the issue type id for {@code issueTypeName} within {@code projectKey}. The + * mapped target stores a human name ("Story" / "Historia"); create requires the project-scoped id. + * Matches by exact name (case-insensitive). Throws {@code JIRA_PUSH_FAILED} listing the available types + * when the name is not valid for the project. + */ + public String resolveIssueTypeId(JiraApiContext ctx, String projectKey, String issueTypeName) { + List types = listIssueTypes(ctx, projectKey); + return types.stream() + .filter(t -> t.name() != null && t.name().equalsIgnoreCase(issueTypeName)) + .map(JiraIssueType::id) + .findFirst() + .orElseThrow(() -> IntegrationsInfrastructureExceptions.jiraPushFailed( + "issue type '" + issueTypeName + "' is not available in project '" + projectKey + + "' (available: " + types.stream().map(JiraIssueType::name).toList() + ")")); + } + + /** + * GET {@code /issue/createmeta/{projectKey}/issuetypes/{issueTypeId}} → the create-screen fields for + * that project + issue type (required flag, default flag and schema type). Used to satisfy + * project-specific REQUIRED custom fields (e.g. a mandatory "Criterios de aceptación" text field) that + * would otherwise fail the create with a 400. + */ + public List listCreateFields(JiraApiContext ctx, String projectKey, String issueTypeId) { + CreateMetaFields meta = exchange(() -> restClient.get() + .uri(ctx.apiBase() + "/issue/createmeta/" + enc(projectKey) + "/issuetypes/" + + enc(issueTypeId) + "?maxResults=200") + .header("Authorization", ctx.authHeader()) + .accept(MediaType.APPLICATION_JSON) + .retrieve() + .onStatus(HttpStatusCode::isError, (req, res) -> { throw mapError(res, false); }) + .body(CreateMetaFields.class), "listCreateFields"); + return meta == null || meta.fields() == null ? List.of() : meta.fields(); + } + + /** The base create fields Reqs-AI always sends; anything else required must be filled generically. */ + private static final java.util.Set BASE_FIELDS = + java.util.Set.of("project", "issuetype", "summary", "description", "reporter"); + + /** + * POST /issue → the created issue's id/key/self. Sends {@code issuetype:{id:…}} (resolved for the + * project) so team-managed and localized projects accept the create. Any OTHER field the project marks + * required without a default (custom fields like "Criterios de aceptación") is filled generically from + * {@code requiredFieldFallbackText}: plain text for {@code string} fields, an ADF doc for {@code doc} + * fields (unfillable types are left for Jira to report). Reads Jira's error body on failure and + * surfaces its {@code errorMessages}/field {@code errors} (token-free) for diagnosability. + */ + public CreatedIssue createIssue(JiraApiContext ctx, String projectKey, String issueTypeName, + String summary, Map descriptionAdf, + String requiredFieldFallbackText) { + String issueTypeId = resolveIssueTypeId(ctx, projectKey, issueTypeName); + Map fields = new LinkedHashMap<>(); + fields.put("project", Map.of("key", projectKey)); + fields.put("issuetype", Map.of("id", issueTypeId)); + fields.put("summary", summary); + fields.put("description", descriptionAdf); + fillRequiredCustomFields(ctx, projectKey, issueTypeId, fields, requiredFieldFallbackText); + CreatedIssue created = exchange(() -> restClient.post() + .uri(ctx.apiBase() + "/issue") + .header("Authorization", ctx.authHeader()) + .contentType(MediaType.APPLICATION_JSON) + .accept(MediaType.APPLICATION_JSON) + .body(Map.of("fields", fields)) + .retrieve() + .onStatus(HttpStatusCode::isError, (req, res) -> { throw mapError(res, true); }) + .body(CreatedIssue.class), "createIssue"); + if (created == null || created.key() == null) { + throw IntegrationsInfrastructureExceptions.jiraPushFailed( + "Jira accepted the request but returned no issue key (project '" + projectKey + + "', issue type id '" + issueTypeId + "')"); + } + return created; + } + + /** + * GET {@code /search/jql} → one page of issues matching {@code jql}, requesting only the + * {@code summary}, {@code description}, {@code issuetype} and {@code priority} fields. Token-paginated + * (the current Jira Cloud model: {@code nextPageToken} + {@code isLast}, no {@code total}). Returns the + * raw {@code issues} nodes plus the next-page token; the caller loops until {@code isLast}. + */ + public IssueSearchPage searchIssues(JiraApiContext ctx, String jql, int maxResults, String nextPageToken) { + StringBuilder uri = new StringBuilder(ctx.apiBase()) + .append("/search/jql?jql=").append(enc(jql)) + .append("&fields=").append(enc("summary,description,issuetype,priority")) + .append("&maxResults=").append(maxResults); + if (nextPageToken != null && !nextPageToken.isBlank()) { + uri.append("&nextPageToken=").append(enc(nextPageToken)); + } + // The JQL is already URL-encoded via enc(); pass a java.net.URI so RestClient does NOT treat the + // string as a template and re-encode it (double-encoding turned %22 into %2522 → Jira 400). + IssueSearchResponse res = exchange(() -> restClient.get() + .uri(java.net.URI.create(uri.toString())) + .header("Authorization", ctx.authHeader()) + .accept(MediaType.APPLICATION_JSON) + .retrieve() + .onStatus(HttpStatusCode::isError, (req, r) -> { throw mapError(r, false); }) + .body(IssueSearchResponse.class), "searchIssues"); + if (res == null) { + return new IssueSearchPage(List.of(), true, null); + } + List issues = res.issues() == null ? List.of() : res.issues(); + return new IssueSearchPage(issues, res.isLast() == null || res.isLast(), res.nextPageToken()); + } + + /** Fetches every issue matching {@code jql} across all pages (created ASC ordering is the caller's). */ + public List searchAllIssues(JiraApiContext ctx, String jql) { + List all = new ArrayList<>(); + String token = null; + do { + IssueSearchPage page = searchIssues(ctx, jql, 100, token); + all.addAll(page.issues()); + token = page.isLast() ? null : page.nextPageToken(); + } while (token != null); + return all; + } + + /** Browse URL for a created issue (uses the human site URL, not the OAuth API base). */ + public String browseUrl(String browseBase, String issueKey) { + return browseBase + "/browse/" + issueKey; + } + + /** + * Fills every create-screen field the project marks {@code required} without a default and that the + * base payload does not already cover, so project-specific mandatory custom fields (e.g. a required + * "Criterios de aceptación") don't 400 the create. {@code string} fields get {@code fallbackText}; + * rich-text {@code doc} fields get a single-paragraph ADF doc. Other types (options, numbers, users…) + * cannot be guessed generically and are left unset — Jira's diagnosable 400 then names them. + */ + private void fillRequiredCustomFields(JiraApiContext ctx, String projectKey, String issueTypeId, + Map fields, String fallbackText) { + String text = fallbackText == null || fallbackText.isBlank() ? "See description." : fallbackText; + for (CreateField field : listCreateFields(ctx, projectKey, issueTypeId)) { + String id = field.fieldId(); + if (id == null || fields.containsKey(id) || BASE_FIELDS.contains(id) + || !Boolean.TRUE.equals(field.required()) + || Boolean.TRUE.equals(field.hasDefaultValue())) { + continue; + } + String type = field.schema() == null ? null : field.schema().type(); + String custom = field.schema() == null ? null : field.schema().custom(); + // Rich-text fields need an ADF doc even when the schema type says "string": the v3 API + // requires ADF for textarea/paragraph custom fields (team-managed projects report them as + // string+custom:…textarea, and a plain string is rejected as "not valid ADF"). + boolean richText = "doc".equals(type) + || (custom != null && (custom.contains("textarea") || custom.contains("paragraph"))); + if (richText) { + fields.put(id, Map.of("type", "doc", "version", 1, "content", + List.of(Map.of("type", "paragraph", "content", + List.of(Map.of("type", "text", "text", text)))))); + } else if ("string".equals(type)) { + fields.put(id, text); + } + } + } + + // Helpers + + private static String enc(String raw) { + return URLEncoder.encode(raw, StandardCharsets.UTF_8); + } + + /** + * Maps an error response inside the RestClient exchange, reading Jira's error body so the thrown + * exception is DIAGNOSABLE. {@code onCreate} selects the 400/404 → push-failed mapping. The body is + * parsed for Jira's {@code errorMessages} array and field {@code errors} map (token-free — bodies never + * carry credentials). Throwing here aborts the call with a mapped exception. + */ + private static RuntimeException mapError(org.springframework.http.client.ClientHttpResponse res, + boolean onCreate) throws IOException { + int status = res.getStatusCode().value(); + String detail = readJiraError(res); + if (status == 401 || status == 403) { + return IntegrationsInfrastructureExceptions.jiraAuthFailed(); + } + if (onCreate && (status == 400 || status == 404)) { + return IntegrationsInfrastructureExceptions.jiraPushFailed( + "Jira rejected the request (" + status + ")" + (detail.isBlank() ? "" : ": " + detail)); + } + return IntegrationsInfrastructureExceptions.jiraUnreachable( + "HTTP " + status + (detail.isBlank() ? "" : ": " + detail), null); + } + + /** + * Extracts Jira's {@code errorMessages} (array) and {@code errors} (field → message map) from an error + * response body into a compact, token-free string. Returns "" when the body is empty or unparseable. + */ + private static String readJiraError(org.springframework.http.client.ClientHttpResponse res) { + try { + byte[] raw = res.getBody().readAllBytes(); + if (raw.length == 0) { + return ""; + } + JsonNode body = ERROR_MAPPER.readTree(raw); + List parts = new ArrayList<>(); + JsonNode messages = body.get("errorMessages"); + if (messages != null && messages.isArray()) { + messages.forEach(m -> parts.add(m.asText())); + } + JsonNode errors = body.get("errors"); + if (errors != null && errors.isObject()) { + errors.fields().forEachRemaining(e -> parts.add(e.getKey() + ": " + e.getValue().asText())); + } + return String.join("; ", parts); + } catch (Exception e) { + return ""; + } + } + + /** Runs a RestClient call, translating transport-level failures (connect/timeout) to JIRA_UNREACHABLE. */ + private T exchange(java.util.function.Supplier call, String op) { + try { + return call.get(); + } catch (com.kntro.reqsai.shared.domain.exception.InfrastructureException mapped) { + throw mapped; // already mapped by onStatus + } catch (Exception e) { + log.warn("Jira {} failed: {}", op, e.getMessage()); + throw IntegrationsInfrastructureExceptions.jiraUnreachable(op, e); + } + } + + // Jackson-bound response records + + @JsonIgnoreProperties(ignoreUnknown = true) + private record Myself(String displayName) {} + + @JsonIgnoreProperties(ignoreUnknown = true) + public record JiraProject(String key, String name) {} + + @JsonIgnoreProperties(ignoreUnknown = true) + private record ProjectSearch(List values) {} + + @JsonIgnoreProperties(ignoreUnknown = true) + public record JiraIssueType(String id, String name) {} + + /** Response of {@code /issue/createmeta/{projectKey}/issuetypes}. */ + @JsonIgnoreProperties(ignoreUnknown = true) + private record CreateMetaIssueTypes(List issueTypes) {} + + /** One create-screen field from {@code createmeta/{project}/issuetypes/{typeId}}. */ + public record CreateField(String fieldId, String name, Boolean required, Boolean hasDefaultValue, + FieldSchema schema) {} + + /** + * The schema of a create-screen field. {@code type} is the value type ({@code string}, {@code doc}, + * {@code array}, …); {@code custom} names the custom-field kind (e.g. {@code …:textarea}) — needed + * because rich-text fields report {@code type=string} but the v3 API requires ADF values for them. + */ + public record FieldSchema(String type, String custom) {} + + private record CreateMetaFields(List fields) {} + + @JsonIgnoreProperties(ignoreUnknown = true) + public record CreatedIssue(String id, String key, String self) {} + + /** One issue returned by {@code /search/jql} with the subset of fields requested above. */ + @JsonIgnoreProperties(ignoreUnknown = true) + public record JiraIssue(String key, IssueFields fields) {} + + /** + * The requested field subset of a search hit. {@code description} is an ADF document (nested object) + * bound as a {@code Map} — {@link JiraAdfReader} flattens it to plain text for parsing. + */ + @JsonIgnoreProperties(ignoreUnknown = true) + public record IssueFields(String summary, Map description, + NamedRef issuetype, NamedRef priority) {} + + /** A Jira {name,id} reference (issue type, priority). */ + @JsonIgnoreProperties(ignoreUnknown = true) + public record NamedRef(String id, String name) {} + + @JsonIgnoreProperties(ignoreUnknown = true) + private record IssueSearchResponse(List issues, Boolean isLast, String nextPageToken) {} + + /** One page of a token-paginated JQL search. */ + public record IssueSearchPage(List issues, boolean isLast, String nextPageToken) {} +} diff --git a/src/main/java/com/kntro/reqsai/gateway/infrastructure/jira/JiraOAuthAdapter.java b/src/main/java/com/kntro/reqsai/gateway/infrastructure/jira/JiraOAuthAdapter.java new file mode 100644 index 00000000..a9cfa28c --- /dev/null +++ b/src/main/java/com/kntro/reqsai/gateway/infrastructure/jira/JiraOAuthAdapter.java @@ -0,0 +1,40 @@ +package com.kntro.reqsai.gateway.infrastructure.jira; + +import com.kntro.reqsai.gateway.application.port.JiraOAuthPort; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Component; + +import java.util.List; + +/** + * Adapts the {@link JiraOAuthPort} application port to the {@link JiraOAuthClient} HTTP client (ADR-0023), + * translating the client's Jackson records into the port's value records. Keeps application code off + * infrastructure. + */ +@Component +@RequiredArgsConstructor +public class JiraOAuthAdapter implements JiraOAuthPort { + + private final JiraOAuthClient client; + + @Override + public OAuthTokens exchangeCode(String code) { + return toTokens(client.exchangeCode(code)); + } + + @Override + public OAuthTokens refresh(String refreshToken) { + return toTokens(client.refresh(refreshToken)); + } + + @Override + public List accessibleResources(String accessToken) { + return client.accessibleResources(accessToken).stream() + .map(r -> new Site(r.id(), r.url(), r.name())) + .toList(); + } + + private static OAuthTokens toTokens(JiraOAuthClient.OAuthTokens t) { + return new OAuthTokens(t.accessToken(), t.refreshToken(), t.expiresIn(), t.scope()); + } +} diff --git a/src/main/java/com/kntro/reqsai/gateway/infrastructure/jira/JiraOAuthClient.java b/src/main/java/com/kntro/reqsai/gateway/infrastructure/jira/JiraOAuthClient.java new file mode 100644 index 00000000..e1c1e559 --- /dev/null +++ b/src/main/java/com/kntro/reqsai/gateway/infrastructure/jira/JiraOAuthClient.java @@ -0,0 +1,133 @@ +package com.kntro.reqsai.gateway.infrastructure.jira; + +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import com.fasterxml.jackson.annotation.JsonProperty; +import com.kntro.reqsai.gateway.application.config.JiraOAuthProperties; +import com.kntro.reqsai.gateway.infrastructure.exception.IntegrationsInfrastructureExceptions; +import lombok.extern.slf4j.Slf4j; +import org.springframework.http.HttpStatusCode; +import org.springframework.http.MediaType; +import org.springframework.stereotype.Component; +import org.springframework.web.client.RestClient; + +import java.util.List; +import java.util.Map; + +/** + * Outbound Atlassian OAuth 2.0 (3LO) client (ADR-0023): authorization-code exchange, refresh-token + * rotation, and accessible-resources discovery. Mirrors the {@link JiraClient} RestClient style (per-call + * client, typed records, status → infrastructure exception). + *

    + *
  • Token exchange / refresh: {@code POST https://auth.atlassian.com/oauth/token} (JSON).
  • + *
  • Sites: {@code GET https://api.atlassian.com/oauth/token/accessible-resources} (Bearer access).
  • + *
+ * Any non-2xx on token exchange/refresh maps to {@code JIRA_OAUTH_EXCHANGE_FAILED}; a 401/403 on + * accessible-resources maps to {@code JIRA_AUTH_FAILED}. Tokens are never logged nor put in exceptions. + */ +@Component +@Slf4j +public class JiraOAuthClient { + + private static final String TOKEN_URL = "https://auth.atlassian.com/oauth/token"; + private static final String RESOURCES_URL = "https://api.atlassian.com/oauth/token/accessible-resources"; + + private final RestClient restClient = RestClient.create(); + private final JiraOAuthProperties props; + + public JiraOAuthClient(JiraOAuthProperties props) { + this.props = props; + } + + /** Exchanges an authorization {@code code} for the initial token set. */ + public OAuthTokens exchangeCode(String code) { + Map body = Map.of( + "grant_type", "authorization_code", + "client_id", nn(props.clientId()), + "client_secret", nn(props.clientSecret()), + "code", code, + "redirect_uri", nn(props.redirectUri())); + return postToken(body, "exchangeCode"); + } + + /** Exchanges a {@code refreshToken} for a new (rotated) token set. */ + public OAuthTokens refresh(String refreshToken) { + Map body = Map.of( + "grant_type", "refresh_token", + "client_id", nn(props.clientId()), + "client_secret", nn(props.clientSecret()), + "refresh_token", refreshToken); + return postToken(body, "refresh"); + } + + /** Lists the Atlassian sites the access token can reach ({cloudId, url, name}). */ + public List accessibleResources(String accessToken) { + try { + List sites = restClient.get() + .uri(RESOURCES_URL) + .header("Authorization", "Bearer " + accessToken) + .accept(MediaType.APPLICATION_JSON) + .retrieve() + .onStatus(HttpStatusCode::isError, (req, res) -> { + if (res.getStatusCode().value() == 401 || res.getStatusCode().value() == 403) { + throw IntegrationsInfrastructureExceptions.jiraAuthFailed(); + } + throw IntegrationsInfrastructureExceptions.jiraOauthExchangeFailed( + "accessible-resources HTTP " + res.getStatusCode().value(), null); + }) + .body(RESOURCE_LIST); + return sites == null ? List.of() : sites; + } catch (com.kntro.reqsai.shared.domain.exception.InfrastructureException mapped) { + throw mapped; + } catch (Exception e) { + log.warn("Jira accessible-resources failed: {}", e.getMessage()); + throw IntegrationsInfrastructureExceptions.jiraOauthExchangeFailed("accessible-resources", e); + } + } + + private OAuthTokens postToken(Map body, String op) { + try { + OAuthTokens tokens = restClient.post() + .uri(TOKEN_URL) + .contentType(MediaType.APPLICATION_JSON) + .accept(MediaType.APPLICATION_JSON) + .body(body) + .retrieve() + .onStatus(HttpStatusCode::isError, (req, res) -> { + throw IntegrationsInfrastructureExceptions.jiraOauthExchangeFailed( + op + " HTTP " + res.getStatusCode().value(), null); + }) + .body(OAuthTokens.class); + if (tokens == null || tokens.accessToken() == null) { + throw IntegrationsInfrastructureExceptions.jiraOauthExchangeFailed(op + ": empty token response", null); + } + return tokens; + } catch (com.kntro.reqsai.shared.domain.exception.InfrastructureException mapped) { + throw mapped; + } catch (Exception e) { + log.warn("Jira OAuth {} failed: {}", op, e.getMessage()); + throw IntegrationsInfrastructureExceptions.jiraOauthExchangeFailed(op, e); + } + } + + private static String nn(String value) { + return value == null ? "" : value; + } + + private static final org.springframework.core.ParameterizedTypeReference> RESOURCE_LIST = + new org.springframework.core.ParameterizedTypeReference<>() {}; + + /** + * Atlassian token response. {@code refreshToken} is present when {@code offline_access} was requested; + * on a rotating-refresh-token app it is a NEW value on every refresh (persist it). + */ + @JsonIgnoreProperties(ignoreUnknown = true) + public record OAuthTokens( + @JsonProperty("access_token") String accessToken, + @JsonProperty("refresh_token") String refreshToken, + @JsonProperty("expires_in") long expiresIn, + @JsonProperty("scope") String scope) {} + + /** One accessible Atlassian site: {@code id} is the cloud id used in the OAuth API base URL. */ + @JsonIgnoreProperties(ignoreUnknown = true) + public record AccessibleResource(String id, String url, String name) {} +} diff --git a/src/main/java/com/kntro/reqsai/gateway/infrastructure/jira/JiraProvider.java b/src/main/java/com/kntro/reqsai/gateway/infrastructure/jira/JiraProvider.java new file mode 100644 index 00000000..3f6d5d8b --- /dev/null +++ b/src/main/java/com/kntro/reqsai/gateway/infrastructure/jira/JiraProvider.java @@ -0,0 +1,118 @@ +package com.kntro.reqsai.gateway.infrastructure.jira; + +import com.kntro.reqsai.discovery.api.StoryView; +import com.kntro.reqsai.gateway.application.port.IntegrationProvider; +import com.kntro.reqsai.gateway.domain.model.CredentialType; +import com.kntro.reqsai.gateway.domain.model.IntegrationProviderType; +import com.kntro.reqsai.gateway.infrastructure.jira.JiraClient.JiraApiContext; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Component; + +import java.util.List; +import java.util.Map; + +/** + * Jira Cloud implementation of {@link IntegrationProvider} (ADR-0023). Translates provider-neutral calls + * into {@link JiraClient} REST calls and renders the story description as ADF via {@link JiraAdfBuilder}. + *

+ * Dual-mode: {@link #contextFor(ProviderCredentials)} picks the base URL + {@code Authorization} header + * from the credential type — basic auth against {@code https://{site}/rest/api/3} for API tokens, bearer + * auth against {@code https://api.atlassian.com/ex/jira/{cloudId}/rest/api/3} for OAuth. OAuth access + * tokens arrive already fresh (refreshed upstream by {@code ProviderCredentialsFactory}). + */ +@Component +@RequiredArgsConstructor +public class JiraProvider implements IntegrationProvider { + + private final JiraClient jira; + + @Override + public IntegrationProviderType type() { + return IntegrationProviderType.JIRA; + } + + @Override + public String verify(ProviderCredentials c) { + return jira.verify(contextFor(c)); + } + + @Override + public List listProjects(ProviderCredentials c) { + return jira.listProjects(contextFor(c)).stream() + .map(p -> new RemoteProject(p.key(), p.name())) + .toList(); + } + + @Override + public List listIssueTypes(ProviderCredentials c, String projectKey) { + return jira.listIssueTypes(contextFor(c), projectKey).stream() + .map(t -> new RemoteIssueType(t.id(), t.name())) + .toList(); + } + + @Override + public PushedIssue pushStory(ProviderCredentials c, String projectKey, String issueTypeName, StoryView story) { + JiraApiContext ctx = contextFor(c); + Map description = JiraAdfBuilder.buildDescription(story); + JiraClient.CreatedIssue created = jira.createIssue( + ctx, projectKey, issueTypeName, story.title(), description, acceptanceCriteriaText(story)); + return new PushedIssue(created.key(), jira.browseUrl(ctx.browseBase(), created.key())); + } + + /** + * Renders the story's acceptance criteria as plain {@code Given … When … Then …} lines — the generic + * value used to satisfy project-specific REQUIRED custom fields (e.g. a mandatory + * "Criterios de aceptación"). Empty when the story has none (the client then sends a neutral note). + */ + private static String acceptanceCriteriaText(StoryView story) { + if (story.acceptanceCriteria() == null || story.acceptanceCriteria().isEmpty()) { + return ""; + } + return story.acceptanceCriteria().stream() + .map(c -> (c.scenario() != null && !c.scenario().isBlank() ? c.scenario() + ": " : "") + + "Given " + c.given() + ", When " + c.when() + ", Then " + c.then() + ".") + .collect(java.util.stream.Collectors.joining("\n")); + } + + @Override + public List searchImportableIssues(ProviderCredentials c, String projectKey, String issueTypeName) { + JiraApiContext ctx = contextFor(c); + String jql = "project = \"" + projectKey + "\" AND issuetype = \"" + issueTypeName + + "\" ORDER BY created ASC"; + return jira.searchAllIssues(ctx, jql).stream() + .map(JiraProvider::toRemoteIssue) + .toList(); + } + + private static RemoteIssue toRemoteIssue(JiraClient.JiraIssue issue) { + JiraClient.IssueFields f = issue.fields(); + String summary = f != null && f.summary() != null ? f.summary() : issue.key(); + String description = f != null ? JiraAdfReader.toPlainText(f.description()) : ""; + String issueType = f != null && f.issuetype() != null ? f.issuetype().name() : null; + String priority = mapPriority(f != null && f.priority() != null ? f.priority().name() : null); + return new RemoteIssue(issue.key(), summary, issueType, description, priority); + } + + /** + * Maps a Jira priority name to a Reqs-AI {@code Priority} name: Highest/High → HIGH, + * Medium → MEDIUM, Low/Lowest → LOW; anything else (including {@code null}) → MEDIUM. + */ + private static String mapPriority(String jiraPriority) { + if (jiraPriority == null) { + return "MEDIUM"; + } + return switch (jiraPriority.trim().toLowerCase(java.util.Locale.ROOT)) { + case "highest", "high" -> "HIGH"; + case "low", "lowest" -> "LOW"; + default -> "MEDIUM"; + }; + } + + /** Builds the base-URL + auth context for the credential's mode. */ + private static JiraApiContext contextFor(ProviderCredentials c) { + if (c.credentialType() == CredentialType.OAUTH2) { + return JiraApiContext.oauth(c.cloudId(), c.accessToken(), c.siteUrl()); + } + return JiraApiContext.apiToken(c.siteUrl(), c.email(), c.apiToken()); + } +} diff --git a/src/main/java/com/kntro/reqsai/gateway/infrastructure/persistence/adapters/IntegrationConnectionRepositoryAdapter.java b/src/main/java/com/kntro/reqsai/gateway/infrastructure/persistence/adapters/IntegrationConnectionRepositoryAdapter.java new file mode 100644 index 00000000..dd2c1a9b --- /dev/null +++ b/src/main/java/com/kntro/reqsai/gateway/infrastructure/persistence/adapters/IntegrationConnectionRepositoryAdapter.java @@ -0,0 +1,52 @@ +package com.kntro.reqsai.gateway.infrastructure.persistence.adapters; + +import com.kntro.reqsai.gateway.application.port.IntegrationConnectionRepository; +import com.kntro.reqsai.gateway.domain.model.ConnectionStatus; +import com.kntro.reqsai.gateway.domain.model.IntegrationConnection; +import com.kntro.reqsai.gateway.domain.model.IntegrationProviderType; +import com.kntro.reqsai.gateway.infrastructure.persistence.repositories.IntegrationConnectionJpaRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Component; + +import java.util.List; +import java.util.Optional; +import java.util.UUID; + +/** Adapts the {@link IntegrationConnectionRepository} port to Spring Data JPA. */ +@Component +@RequiredArgsConstructor +public class IntegrationConnectionRepositoryAdapter implements IntegrationConnectionRepository { + + private final IntegrationConnectionJpaRepository jpa; + + @Override + public IntegrationConnection save(IntegrationConnection connection) { + return jpa.save(connection); + } + + @Override + public Optional findById(UUID id) { + return jpa.findById(id); + } + + @Override + public Optional findByIdAndOrganizationId(UUID id, UUID organizationId) { + return jpa.findByIdAndOrganizationId(id, organizationId); + } + + @Override + public List findAllByOrganizationId(UUID organizationId) { + return jpa.findAllByOrganizationIdOrderByCreatedAtDesc(organizationId); + } + + @Override + public boolean existsByOrganizationIdAndProviderAndStatusNot( + UUID organizationId, IntegrationProviderType provider, ConnectionStatus status) { + return jpa.existsByOrganizationIdAndProviderAndStatusNot(organizationId, provider, status); + } + + @Override + public void delete(IntegrationConnection connection) { + jpa.delete(connection); + } +} diff --git a/src/main/java/com/kntro/reqsai/gateway/infrastructure/persistence/adapters/IntegrationSyncJobRepositoryAdapter.java b/src/main/java/com/kntro/reqsai/gateway/infrastructure/persistence/adapters/IntegrationSyncJobRepositoryAdapter.java new file mode 100644 index 00000000..183a3ef3 --- /dev/null +++ b/src/main/java/com/kntro/reqsai/gateway/infrastructure/persistence/adapters/IntegrationSyncJobRepositoryAdapter.java @@ -0,0 +1,46 @@ +package com.kntro.reqsai.gateway.infrastructure.persistence.adapters; + +import com.kntro.reqsai.gateway.application.port.IntegrationSyncJobRepository; +import com.kntro.reqsai.gateway.domain.model.IntegrationSyncJob; +import com.kntro.reqsai.gateway.domain.model.IntegrationSyncJobStatus; +import com.kntro.reqsai.gateway.domain.model.IntegrationSyncJobType; +import com.kntro.reqsai.gateway.infrastructure.persistence.repositories.IntegrationSyncJobJpaRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Component; + +import java.util.List; +import java.util.Optional; +import java.util.UUID; + +/** Adapts the {@link IntegrationSyncJobRepository} port to Spring Data JPA. */ +@Component +@RequiredArgsConstructor +public class IntegrationSyncJobRepositoryAdapter implements IntegrationSyncJobRepository { + + private final IntegrationSyncJobJpaRepository jpa; + + @Override + public IntegrationSyncJob save(IntegrationSyncJob job) { + return jpa.save(job); + } + + @Override + public Optional findById(UUID id) { + return jpa.findById(id); + } + + @Override + public boolean existsRunning(UUID projectId, IntegrationSyncJobType type) { + return jpa.existsByProjectIdAndJobTypeAndStatus(projectId, type, IntegrationSyncJobStatus.RUNNING); + } + + @Override + public List findRunning(UUID projectId) { + return jpa.findByProjectIdAndStatusOrderByCreatedAtDesc(projectId, IntegrationSyncJobStatus.RUNNING); + } + + @Override + public List findRecent(UUID projectId) { + return jpa.findTop10ByProjectIdOrderByCreatedAtDesc(projectId); + } +} diff --git a/src/main/java/com/kntro/reqsai/gateway/infrastructure/persistence/adapters/ProjectIntegrationTargetRepositoryAdapter.java b/src/main/java/com/kntro/reqsai/gateway/infrastructure/persistence/adapters/ProjectIntegrationTargetRepositoryAdapter.java new file mode 100644 index 00000000..f6e7f827 --- /dev/null +++ b/src/main/java/com/kntro/reqsai/gateway/infrastructure/persistence/adapters/ProjectIntegrationTargetRepositoryAdapter.java @@ -0,0 +1,33 @@ +package com.kntro.reqsai.gateway.infrastructure.persistence.adapters; + +import com.kntro.reqsai.gateway.application.port.ProjectIntegrationTargetRepository; +import com.kntro.reqsai.gateway.domain.model.ProjectIntegrationTarget; +import com.kntro.reqsai.gateway.infrastructure.persistence.repositories.ProjectIntegrationTargetJpaRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Component; + +import java.util.Optional; +import java.util.UUID; + +/** Adapts the {@link ProjectIntegrationTargetRepository} port to Spring Data JPA. */ +@Component +@RequiredArgsConstructor +public class ProjectIntegrationTargetRepositoryAdapter implements ProjectIntegrationTargetRepository { + + private final ProjectIntegrationTargetJpaRepository jpa; + + @Override + public ProjectIntegrationTarget save(ProjectIntegrationTarget target) { + return jpa.save(target); + } + + @Override + public Optional findByProjectId(UUID projectId) { + return jpa.findByProjectId(projectId); + } + + @Override + public void delete(ProjectIntegrationTarget target) { + jpa.delete(target); + } +} diff --git a/src/main/java/com/kntro/reqsai/gateway/infrastructure/persistence/converters/EncryptedStringConverter.java b/src/main/java/com/kntro/reqsai/gateway/infrastructure/persistence/converters/EncryptedStringConverter.java new file mode 100644 index 00000000..43c3c390 --- /dev/null +++ b/src/main/java/com/kntro/reqsai/gateway/infrastructure/persistence/converters/EncryptedStringConverter.java @@ -0,0 +1,53 @@ +package com.kntro.reqsai.gateway.infrastructure.persistence.converters; + +import com.kntro.reqsai.gateway.application.port.SecretCipher; +import com.kntro.reqsai.gateway.infrastructure.exception.IntegrationsInfrastructureExceptions; +import jakarta.persistence.AttributeConverter; +import jakarta.persistence.Converter; +import org.jspecify.annotations.Nullable; + +import java.nio.charset.StandardCharsets; + +/** + * JPA converter that encrypts a {@code String} attribute (the Jira API token) to a {@code byte[]} + * ({@code secret_ciphertext} BYTEA) with AES-256-GCM and decrypts it on load (ADR-0023). + *

+ * JPA converters are instantiated by Hibernate, not Spring, so the {@link SecretCipher} is supplied + * through a static holder set once at startup by {@code IntegrationsCryptoConfiguration}. A missing + * cipher (no key configured) surfaces as {@code INTEGRATION_ENCRYPTION_ERROR} rather than a null token. + */ +@Converter +public class EncryptedStringConverter implements AttributeConverter { + + private static volatile @Nullable SecretCipher cipher; + + /** Wired once at startup by the crypto configuration. */ + public static void setCipher(SecretCipher secretCipher) { + cipher = secretCipher; + } + + @Override + public byte @Nullable [] convertToDatabaseColumn(@Nullable String attribute) { + if (attribute == null) { + return null; + } + return cipher().encrypt(attribute.getBytes(StandardCharsets.UTF_8)); + } + + @Override + public @Nullable String convertToEntityAttribute(byte @Nullable [] dbData) { + if (dbData == null) { + return null; + } + return new String(cipher().decrypt(dbData), StandardCharsets.UTF_8); + } + + private static SecretCipher cipher() { + SecretCipher c = cipher; + if (c == null) { + throw IntegrationsInfrastructureExceptions.encryptionError( + "encryption cipher is not configured (INTEGRATIONS_ENCRYPTION_KEY missing)", null); + } + return c; + } +} diff --git a/src/main/java/com/kntro/reqsai/gateway/infrastructure/persistence/repositories/IntegrationConnectionJpaRepository.java b/src/main/java/com/kntro/reqsai/gateway/infrastructure/persistence/repositories/IntegrationConnectionJpaRepository.java new file mode 100644 index 00000000..adb18495 --- /dev/null +++ b/src/main/java/com/kntro/reqsai/gateway/infrastructure/persistence/repositories/IntegrationConnectionJpaRepository.java @@ -0,0 +1,20 @@ +package com.kntro.reqsai.gateway.infrastructure.persistence.repositories; + +import com.kntro.reqsai.gateway.domain.model.ConnectionStatus; +import com.kntro.reqsai.gateway.domain.model.IntegrationConnection; +import com.kntro.reqsai.gateway.domain.model.IntegrationProviderType; +import org.springframework.data.jpa.repository.JpaRepository; + +import java.util.List; +import java.util.Optional; +import java.util.UUID; + +public interface IntegrationConnectionJpaRepository extends JpaRepository { + + Optional findByIdAndOrganizationId(UUID id, UUID organizationId); + + List findAllByOrganizationIdOrderByCreatedAtDesc(UUID organizationId); + + boolean existsByOrganizationIdAndProviderAndStatusNot( + UUID organizationId, IntegrationProviderType provider, ConnectionStatus status); +} diff --git a/src/main/java/com/kntro/reqsai/gateway/infrastructure/persistence/repositories/IntegrationSyncJobJpaRepository.java b/src/main/java/com/kntro/reqsai/gateway/infrastructure/persistence/repositories/IntegrationSyncJobJpaRepository.java new file mode 100644 index 00000000..792d11f8 --- /dev/null +++ b/src/main/java/com/kntro/reqsai/gateway/infrastructure/persistence/repositories/IntegrationSyncJobJpaRepository.java @@ -0,0 +1,21 @@ +package com.kntro.reqsai.gateway.infrastructure.persistence.repositories; + +import com.kntro.reqsai.gateway.domain.model.IntegrationSyncJob; +import com.kntro.reqsai.gateway.domain.model.IntegrationSyncJobStatus; +import com.kntro.reqsai.gateway.domain.model.IntegrationSyncJobType; +import org.springframework.data.jpa.repository.JpaRepository; + +import java.util.List; +import java.util.UUID; + +/** Spring Data repository for {@link IntegrationSyncJob}; internal to the persistence adapter. */ +public interface IntegrationSyncJobJpaRepository extends JpaRepository { + + boolean existsByProjectIdAndJobTypeAndStatus( + UUID projectId, IntegrationSyncJobType jobType, IntegrationSyncJobStatus status); + + List findByProjectIdAndStatusOrderByCreatedAtDesc( + UUID projectId, IntegrationSyncJobStatus status); + + List findTop10ByProjectIdOrderByCreatedAtDesc(UUID projectId); +} diff --git a/src/main/java/com/kntro/reqsai/gateway/infrastructure/persistence/repositories/ProjectIntegrationTargetJpaRepository.java b/src/main/java/com/kntro/reqsai/gateway/infrastructure/persistence/repositories/ProjectIntegrationTargetJpaRepository.java new file mode 100644 index 00000000..79dbecc3 --- /dev/null +++ b/src/main/java/com/kntro/reqsai/gateway/infrastructure/persistence/repositories/ProjectIntegrationTargetJpaRepository.java @@ -0,0 +1,12 @@ +package com.kntro.reqsai.gateway.infrastructure.persistence.repositories; + +import com.kntro.reqsai.gateway.domain.model.ProjectIntegrationTarget; +import org.springframework.data.jpa.repository.JpaRepository; + +import java.util.Optional; +import java.util.UUID; + +public interface ProjectIntegrationTargetJpaRepository extends JpaRepository { + + Optional findByProjectId(UUID projectId); +} diff --git a/src/main/java/com/kntro/reqsai/gateway/interfaces/notification/mappers/IntegrationJobNotificationMapper.java b/src/main/java/com/kntro/reqsai/gateway/interfaces/notification/mappers/IntegrationJobNotificationMapper.java new file mode 100644 index 00000000..f709d085 --- /dev/null +++ b/src/main/java/com/kntro/reqsai/gateway/interfaces/notification/mappers/IntegrationJobNotificationMapper.java @@ -0,0 +1,27 @@ +package com.kntro.reqsai.gateway.interfaces.notification.mappers; + +import com.kntro.reqsai.gateway.domain.model.IntegrationSyncJob; +import com.kntro.reqsai.gateway.interfaces.notification.messages.IntegrationJobMessage; + +/** Maps an {@link IntegrationSyncJob} snapshot to its realtime {@link IntegrationJobMessage}. */ +public final class IntegrationJobNotificationMapper { + + private IntegrationJobNotificationMapper() { + throw new UnsupportedOperationException("Utility class - do not instantiate"); + } + + public static IntegrationJobMessage toMessage(IntegrationSyncJob job) { + return new IntegrationJobMessage( + job.getId(), + job.getProjectId(), + job.getJobType(), + job.getStatus(), + job.getTotal(), + job.getProcessed(), + job.getSucceeded(), + job.getFailed(), + job.getMessage(), + job.getCreatedAt(), + job.getFinishedAt()); + } +} diff --git a/src/main/java/com/kntro/reqsai/gateway/interfaces/notification/messages/IntegrationJobMessage.java b/src/main/java/com/kntro/reqsai/gateway/interfaces/notification/messages/IntegrationJobMessage.java new file mode 100644 index 00000000..354d0f0d --- /dev/null +++ b/src/main/java/com/kntro/reqsai/gateway/interfaces/notification/messages/IntegrationJobMessage.java @@ -0,0 +1,29 @@ +package com.kntro.reqsai.gateway.interfaces.notification.messages; + +import com.kntro.reqsai.gateway.domain.model.IntegrationSyncJobStatus; +import com.kntro.reqsai.gateway.domain.model.IntegrationSyncJobType; +import org.jspecify.annotations.Nullable; + +import java.time.Instant; +import java.util.UUID; + +/** + * WebSocket payload broadcast on {@code /topic/projects/{projectId}/integration-jobs} for every + * sync-job progress update (per item) and terminal transition. The JSON shape is deliberately + * identical to the REST {@code IntegrationJobResponse}, so the frontend renders the + * same object whether it arrives live over STOMP or from the reload-recovery job query endpoints. + */ +public record IntegrationJobMessage( + UUID id, + UUID projectId, + IntegrationSyncJobType jobType, + IntegrationSyncJobStatus status, + int total, + int processed, + int succeeded, + int failed, + @Nullable String message, + Instant createdAt, + @Nullable Instant finishedAt +) { +} diff --git a/src/main/java/com/kntro/reqsai/gateway/interfaces/rest/controllers/OrganizationIntegrationControllerImpl.java b/src/main/java/com/kntro/reqsai/gateway/interfaces/rest/controllers/OrganizationIntegrationControllerImpl.java new file mode 100644 index 00000000..678593f1 --- /dev/null +++ b/src/main/java/com/kntro/reqsai/gateway/interfaces/rest/controllers/OrganizationIntegrationControllerImpl.java @@ -0,0 +1,145 @@ +package com.kntro.reqsai.gateway.interfaces.rest.controllers; + +import com.kntro.reqsai.gateway.application.handler.ConnectJiraCommandHandler; +import com.kntro.reqsai.gateway.application.handler.DeleteConnectionCommandHandler; +import com.kntro.reqsai.gateway.application.handler.JiraOAuthCallbackCommandHandler; +import com.kntro.reqsai.gateway.application.handler.ListConnectionsQueryHandler; +import com.kntro.reqsai.gateway.application.handler.ListJiraIssueTypesQueryHandler; +import com.kntro.reqsai.gateway.application.handler.ListJiraProjectsQueryHandler; +import com.kntro.reqsai.gateway.application.handler.TestConnectionQueryHandler; +import com.kntro.reqsai.gateway.application.command.DeleteConnectionCommand; +import com.kntro.reqsai.gateway.application.command.JiraOAuthCallbackCommand; +import com.kntro.reqsai.gateway.application.query.ListConnectionsQuery; +import com.kntro.reqsai.gateway.application.query.ListJiraIssueTypesQuery; +import com.kntro.reqsai.gateway.application.query.ListJiraProjectsQuery; +import com.kntro.reqsai.gateway.application.query.TestConnectionQuery; +import com.kntro.reqsai.gateway.application.result.JiraOAuthCallbackResult; +import com.kntro.reqsai.gateway.application.service.JiraOAuthAuthorizeService; +import com.kntro.reqsai.gateway.application.service.JiraOAuthAuthorizeService.AuthorizeUrl; +import com.kntro.reqsai.gateway.domain.model.IntegrationConnection; +import com.kntro.reqsai.gateway.interfaces.rest.dto.request.ConnectJiraRequest; +import com.kntro.reqsai.gateway.interfaces.rest.dto.request.JiraOAuthCallbackRequest; +import com.kntro.reqsai.gateway.interfaces.rest.dto.response.ConnectionTestResponse; +import com.kntro.reqsai.gateway.interfaces.rest.dto.response.IntegrationConnectionResponse; +import com.kntro.reqsai.gateway.interfaces.rest.dto.response.JiraIssueTypeResponse; +import com.kntro.reqsai.gateway.interfaces.rest.dto.response.JiraOAuthAuthorizeUrlResponse; +import com.kntro.reqsai.gateway.interfaces.rest.dto.response.JiraOAuthSitesResponse; +import com.kntro.reqsai.gateway.interfaces.rest.dto.response.JiraProjectResponse; +import com.kntro.reqsai.gateway.interfaces.rest.mappers.request.IntegrationRequestMapper; +import com.kntro.reqsai.gateway.interfaces.rest.mappers.response.IntegrationResponseMapper; +import com.kntro.reqsai.gateway.interfaces.rest.swagger.OrganizationIntegrationController; +import lombok.RequiredArgsConstructor; +import org.springframework.http.ResponseEntity; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.security.core.Authentication; +import org.springframework.web.bind.annotation.RestController; +import org.springframework.web.servlet.support.ServletUriComponentsBuilder; + +import java.net.URI; +import java.util.List; +import java.util.UUID; + +/** + * Organization-level integration endpoints. Administering an org-wide credential is an org-admin action, + * so every method is gated by {@code @authz.orgOwnerOrAdmin} (ADR-0023). + */ +@RestController +@RequiredArgsConstructor +public class OrganizationIntegrationControllerImpl implements OrganizationIntegrationController { + + private final ListConnectionsQueryHandler listConnections; + private final ConnectJiraCommandHandler connectJira; + private final TestConnectionQueryHandler testConnection; + private final DeleteConnectionCommandHandler deleteConnection; + private final ListJiraProjectsQueryHandler listJiraProjects; + private final ListJiraIssueTypesQueryHandler listJiraIssueTypes; + private final JiraOAuthAuthorizeService jiraOAuthAuthorize; + private final JiraOAuthCallbackCommandHandler jiraOAuthCallback; + + @Override + @PreAuthorize("@authz.orgOwnerOrAdmin(#orgId, authentication)") + public ResponseEntity> listConnections(UUID orgId, Authentication authentication) { + UUID requestedBy = UUID.fromString(authentication.getName()); + List body = listConnections.handle(new ListConnectionsQuery(orgId, requestedBy)) + .stream().map(IntegrationResponseMapper::toResponse).toList(); + return ResponseEntity.ok(body); + } + + @Override + @PreAuthorize("@authz.orgOwnerOrAdmin(#orgId, authentication)") + public ResponseEntity connectJira( + UUID orgId, ConnectJiraRequest request, Authentication authentication) { + UUID requestedBy = UUID.fromString(authentication.getName()); + IntegrationConnection connection = connectJira.handle( + IntegrationRequestMapper.toCommand(orgId, request, requestedBy)); + URI location = ServletUriComponentsBuilder.fromCurrentRequest() + .replacePath("/api/organizations/{orgId}/integrations/{id}") + .buildAndExpand(orgId, connection.getId()) + .toUri(); + return ResponseEntity.created(location).body(IntegrationResponseMapper.toResponse(connection)); + } + + @Override + @PreAuthorize("@authz.orgOwnerOrAdmin(#orgId, authentication)") + public ResponseEntity jiraOAuthAuthorizeUrl(UUID orgId, Authentication authentication) { + UUID requestedBy = UUID.fromString(authentication.getName()); + AuthorizeUrl authorizeUrl = jiraOAuthAuthorize.build(orgId, requestedBy); + return ResponseEntity.ok(new JiraOAuthAuthorizeUrlResponse(authorizeUrl.url(), authorizeUrl.state())); + } + + @Override + @PreAuthorize("@authz.orgOwnerOrAdmin(#orgId, authentication)") + public ResponseEntity jiraOAuthCallback( + UUID orgId, JiraOAuthCallbackRequest request, Authentication authentication) { + UUID requestedBy = UUID.fromString(authentication.getName()); + JiraOAuthCallbackResult result = jiraOAuthCallback.handle(new JiraOAuthCallbackCommand( + orgId, request.code(), request.state(), request.cloudId(), requestedBy)); + if (result.isSaved()) { + IntegrationConnection connection = result.connection(); + URI location = ServletUriComponentsBuilder.fromCurrentRequest() + .replacePath("/api/organizations/{orgId}/integrations/{id}") + .buildAndExpand(orgId, connection.getId()) + .toUri(); + return ResponseEntity.created(location).body(IntegrationResponseMapper.toResponse(connection)); + } + JiraOAuthSitesResponse sites = new JiraOAuthSitesResponse( + result.sites().stream().map(IntegrationResponseMapper::toResponse).toList()); + return ResponseEntity.ok(sites); + } + + @Override + @PreAuthorize("@authz.orgOwnerOrAdmin(#orgId, authentication)") + public ResponseEntity testConnection(UUID orgId, UUID connectionId, Authentication authentication) { + UUID requestedBy = UUID.fromString(authentication.getName()); + return ResponseEntity.ok(IntegrationResponseMapper.toResponse( + testConnection.handle(new TestConnectionQuery(orgId, connectionId, requestedBy)))); + } + + @Override + @PreAuthorize("@authz.orgOwnerOrAdmin(#orgId, authentication)") + public ResponseEntity deleteConnection(UUID orgId, UUID connectionId, Authentication authentication) { + UUID requestedBy = UUID.fromString(authentication.getName()); + deleteConnection.handle(new DeleteConnectionCommand(orgId, connectionId, requestedBy)); + return ResponseEntity.noContent().build(); + } + + @Override + @PreAuthorize("@authz.orgOwnerOrAdmin(#orgId, authentication)") + public ResponseEntity> listJiraProjects(UUID orgId, UUID connectionId, Authentication authentication) { + UUID requestedBy = UUID.fromString(authentication.getName()); + List body = listJiraProjects.handle(new ListJiraProjectsQuery(orgId, connectionId, requestedBy)) + .stream().map(IntegrationResponseMapper::toResponse).toList(); + return ResponseEntity.ok(body); + } + + @Override + @PreAuthorize("@authz.orgOwnerOrAdmin(#orgId, authentication)") + public ResponseEntity> listJiraIssueTypes( + UUID orgId, UUID connectionId, String projectKey, Authentication authentication) { + UUID requestedBy = UUID.fromString(authentication.getName()); + List body = listJiraIssueTypes + .handle(new ListJiraIssueTypesQuery(orgId, connectionId, projectKey, requestedBy)) + .stream().map(IntegrationResponseMapper::toResponse).toList(); + return ResponseEntity.ok(body); + } +} diff --git a/src/main/java/com/kntro/reqsai/gateway/interfaces/rest/controllers/ProjectIntegrationControllerImpl.java b/src/main/java/com/kntro/reqsai/gateway/interfaces/rest/controllers/ProjectIntegrationControllerImpl.java new file mode 100644 index 00000000..218665a8 --- /dev/null +++ b/src/main/java/com/kntro/reqsai/gateway/interfaces/rest/controllers/ProjectIntegrationControllerImpl.java @@ -0,0 +1,142 @@ +package com.kntro.reqsai.gateway.interfaces.rest.controllers; + +import com.kntro.reqsai.gateway.application.command.DeleteProjectTargetCommand; +import com.kntro.reqsai.gateway.application.command.ImportJiraStoriesCommand; +import com.kntro.reqsai.gateway.application.command.PushAllStoriesCommand; +import com.kntro.reqsai.gateway.application.command.PushStoryCommand; +import com.kntro.reqsai.gateway.application.handler.DeleteProjectTargetCommandHandler; +import com.kntro.reqsai.gateway.application.handler.GetIntegrationJobQueryHandler; +import com.kntro.reqsai.gateway.application.handler.GetProjectTargetQueryHandler; +import com.kntro.reqsai.gateway.application.handler.ImportJiraStoriesCommandHandler; +import com.kntro.reqsai.gateway.application.handler.ListIntegrationJobsQueryHandler; +import com.kntro.reqsai.gateway.application.handler.PreviewJiraImportQueryHandler; +import com.kntro.reqsai.gateway.application.handler.PushAllStoriesCommandHandler; +import com.kntro.reqsai.gateway.application.handler.PushStoryCommandHandler; +import com.kntro.reqsai.gateway.application.handler.SaveProjectTargetCommandHandler; +import com.kntro.reqsai.gateway.application.query.GetIntegrationJobQuery; +import com.kntro.reqsai.gateway.application.query.GetProjectTargetQuery; +import com.kntro.reqsai.gateway.application.query.ListIntegrationJobsQuery; +import com.kntro.reqsai.gateway.application.query.PreviewJiraImportQuery; +import com.kntro.reqsai.gateway.interfaces.rest.dto.request.ImportJiraStoriesRequest; +import com.kntro.reqsai.gateway.interfaces.rest.dto.request.PushAllStoriesRequest; +import com.kntro.reqsai.gateway.interfaces.rest.dto.request.SaveProjectTargetRequest; +import com.kntro.reqsai.gateway.interfaces.rest.dto.response.IntegrationJobResponse; +import com.kntro.reqsai.gateway.interfaces.rest.dto.response.JiraImportPreviewResponse; +import com.kntro.reqsai.gateway.interfaces.rest.dto.response.JiraPushResultResponse; +import com.kntro.reqsai.gateway.interfaces.rest.dto.response.ProjectJiraTargetResponse; +import com.kntro.reqsai.gateway.interfaces.rest.mappers.request.IntegrationRequestMapper; +import com.kntro.reqsai.gateway.interfaces.rest.mappers.response.IntegrationResponseMapper; +import com.kntro.reqsai.gateway.interfaces.rest.swagger.ProjectIntegrationController; +import lombok.RequiredArgsConstructor; +import org.springframework.http.ResponseEntity; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.security.core.Authentication; +import org.springframework.web.bind.annotation.RestController; + +import java.util.List; +import java.util.UUID; + +/** + * Project-level integration endpoints. Target read/write/delete are gated by project + * {@code INTEGRATION_WRITE}; story pushes/imports by {@code INTEGRATION_SYNC}, via the tenant-bound + * {@code @authz.projectPermission} variant (these routes carry no {@code orgId}). + * + *

Import and push-all are asynchronous: they answer {@code 202 Accepted} with an + * {@link IntegrationJobResponse} snapshot; live progress streams on + * {@code /topic/projects/{projectId}/integration-jobs} and the job endpoints serve reload recovery. + * The single-story push and the import preview stay synchronous. Job reads are gated by + * {@code INTEGRATION_READ} (they expose progress state, not sync capability — consistent with the + * target GET). + */ +@RestController +@RequiredArgsConstructor +public class ProjectIntegrationControllerImpl implements ProjectIntegrationController { + + private final GetProjectTargetQueryHandler getTarget; + private final SaveProjectTargetCommandHandler saveTarget; + private final DeleteProjectTargetCommandHandler deleteTarget; + private final PushStoryCommandHandler pushStory; + private final PushAllStoriesCommandHandler pushAllStories; + private final PreviewJiraImportQueryHandler previewImport; + private final ImportJiraStoriesCommandHandler importStories; + private final ListIntegrationJobsQueryHandler listJobs; + private final GetIntegrationJobQueryHandler getJob; + + @Override + @PreAuthorize("@authz.projectPermission(#projectId, 'INTEGRATION_READ', authentication)") + public ResponseEntity getTarget(UUID projectId, Authentication authentication) { + UUID requestedBy = UUID.fromString(authentication.getName()); + return ResponseEntity.ok(IntegrationResponseMapper.toResponse( + getTarget.handle(new GetProjectTargetQuery(projectId, requestedBy)))); + } + + @Override + @PreAuthorize("@authz.projectPermission(#projectId, 'INTEGRATION_WRITE', authentication)") + public ResponseEntity saveTarget( + UUID projectId, SaveProjectTargetRequest request, Authentication authentication) { + UUID requestedBy = UUID.fromString(authentication.getName()); + return ResponseEntity.ok(IntegrationResponseMapper.toResponse( + saveTarget.handle(IntegrationRequestMapper.toCommand(projectId, request, requestedBy)))); + } + + @Override + @PreAuthorize("@authz.projectPermission(#projectId, 'INTEGRATION_DELETE', authentication)") + public ResponseEntity deleteTarget(UUID projectId, Authentication authentication) { + UUID requestedBy = UUID.fromString(authentication.getName()); + deleteTarget.handle(new DeleteProjectTargetCommand(projectId, requestedBy)); + return ResponseEntity.noContent().build(); + } + + @Override + @PreAuthorize("@authz.projectPermission(#projectId, 'INTEGRATION_SYNC', authentication)") + public ResponseEntity pushStory(UUID projectId, UUID storyId, Authentication authentication) { + UUID requestedBy = UUID.fromString(authentication.getName()); + return ResponseEntity.ok(IntegrationResponseMapper.toResponse( + pushStory.handle(new PushStoryCommand(projectId, storyId, requestedBy)))); + } + + @Override + @PreAuthorize("@authz.projectPermission(#projectId, 'INTEGRATION_SYNC', authentication)") + public ResponseEntity pushAllStories( + UUID projectId, PushAllStoriesRequest request, Authentication authentication) { + UUID requestedBy = UUID.fromString(authentication.getName()); + List storyIds = request == null ? null : request.storyIds(); + return ResponseEntity.accepted().body(IntegrationResponseMapper.toResponse( + pushAllStories.handle(new PushAllStoriesCommand(projectId, storyIds, requestedBy)))); + } + + @Override + @PreAuthorize("@authz.projectPermission(#projectId, 'INTEGRATION_SYNC', authentication)") + public ResponseEntity previewImport(UUID projectId, Authentication authentication) { + UUID requestedBy = UUID.fromString(authentication.getName()); + return ResponseEntity.ok(IntegrationResponseMapper.toResponse( + previewImport.handle(new PreviewJiraImportQuery(projectId, requestedBy)))); + } + + @Override + @PreAuthorize("@authz.projectPermission(#projectId, 'INTEGRATION_SYNC', authentication)") + public ResponseEntity importStories( + UUID projectId, ImportJiraStoriesRequest request, Authentication authentication) { + UUID requestedBy = UUID.fromString(authentication.getName()); + List issueKeys = request == null ? null : request.issueKeys(); + return ResponseEntity.accepted().body(IntegrationResponseMapper.toResponse( + importStories.handle(new ImportJiraStoriesCommand(projectId, issueKeys, requestedBy)))); + } + + @Override + @PreAuthorize("@authz.projectPermission(#projectId, 'INTEGRATION_READ', authentication)") + public ResponseEntity> listJobs( + UUID projectId, boolean active, Authentication authentication) { + UUID requestedBy = UUID.fromString(authentication.getName()); + return ResponseEntity.ok(listJobs.handle(new ListIntegrationJobsQuery(projectId, active, requestedBy)) + .stream().map(IntegrationResponseMapper::toResponse).toList()); + } + + @Override + @PreAuthorize("@authz.projectPermission(#projectId, 'INTEGRATION_READ', authentication)") + public ResponseEntity getJob(UUID projectId, UUID jobId, Authentication authentication) { + UUID requestedBy = UUID.fromString(authentication.getName()); + return ResponseEntity.ok(IntegrationResponseMapper.toResponse( + getJob.handle(new GetIntegrationJobQuery(projectId, jobId, requestedBy)))); + } +} diff --git a/src/main/java/com/kntro/reqsai/gateway/interfaces/rest/dto/request/ConnectJiraRequest.java b/src/main/java/com/kntro/reqsai/gateway/interfaces/rest/dto/request/ConnectJiraRequest.java new file mode 100644 index 00000000..ed5b4836 --- /dev/null +++ b/src/main/java/com/kntro/reqsai/gateway/interfaces/rest/dto/request/ConnectJiraRequest.java @@ -0,0 +1,21 @@ +package com.kntro.reqsai.gateway.interfaces.rest.dto.request; + +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.Email; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.Size; + +@Schema(description = "Request body to connect a Jira Cloud integration at the organization level") +public record ConnectJiraRequest( + @Schema(description = "Jira site base URL", example = "https://acme.atlassian.net", maxLength = 500) + @NotBlank @Size(max = 500) + String siteUrl, + + @Schema(description = "Jira account email", example = "pm@acme.com", maxLength = 320) + @NotBlank @Email @Size(max = 320) + String email, + + @Schema(description = "Jira API token (stored encrypted, never returned)") + @NotBlank + String apiToken +) {} diff --git a/src/main/java/com/kntro/reqsai/gateway/interfaces/rest/dto/request/ImportJiraStoriesRequest.java b/src/main/java/com/kntro/reqsai/gateway/interfaces/rest/dto/request/ImportJiraStoriesRequest.java new file mode 100644 index 00000000..14371c6d --- /dev/null +++ b/src/main/java/com/kntro/reqsai/gateway/interfaces/rest/dto/request/ImportJiraStoriesRequest.java @@ -0,0 +1,16 @@ +package com.kntro.reqsai.gateway.interfaces.rest.dto.request; + +import io.swagger.v3.oas.annotations.media.Schema; +import org.jspecify.annotations.Nullable; + +import java.util.List; + +/** + * Request body for the Jira import. {@code issueKeys} restricts the import to the given issue keys; omit or + * leave empty to import every eligible issue of the project's target. + */ +@Schema(description = "Request body to import Jira issues as user stories") +public record ImportJiraStoriesRequest( + @Schema(description = "Specific Jira issue keys to import; omit/empty = all eligible", example = "[\"PAY-1\",\"PAY-2\"]") + @Nullable List issueKeys +) {} diff --git a/src/main/java/com/kntro/reqsai/gateway/interfaces/rest/dto/request/JiraOAuthCallbackRequest.java b/src/main/java/com/kntro/reqsai/gateway/interfaces/rest/dto/request/JiraOAuthCallbackRequest.java new file mode 100644 index 00000000..971f2f08 --- /dev/null +++ b/src/main/java/com/kntro/reqsai/gateway/interfaces/rest/dto/request/JiraOAuthCallbackRequest.java @@ -0,0 +1,22 @@ +package com.kntro.reqsai.gateway.interfaces.rest.dto.request; + +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotBlank; +import org.jspecify.annotations.Nullable; + +/** + * Request body for the Jira OAuth 2.0 (3LO) callback. {@code code} + {@code state} come from the + * Atlassian redirect; {@code cloudId} is optional and only supplied on the second POST when the user has + * chosen among multiple accessible sites. + */ +@Schema(description = "Jira OAuth callback: authorization code + signed state, optional chosen site") +public record JiraOAuthCallbackRequest( + @Schema(description = "Authorization code from the Atlassian redirect") + @NotBlank String code, + + @Schema(description = "Signed state token issued by the authorize-url endpoint") + @NotBlank String state, + + @Schema(description = "Chosen Atlassian cloud id (only when selecting among multiple sites)") + @Nullable String cloudId +) {} diff --git a/src/main/java/com/kntro/reqsai/gateway/interfaces/rest/dto/request/PushAllStoriesRequest.java b/src/main/java/com/kntro/reqsai/gateway/interfaces/rest/dto/request/PushAllStoriesRequest.java new file mode 100644 index 00000000..ec871a43 --- /dev/null +++ b/src/main/java/com/kntro/reqsai/gateway/interfaces/rest/dto/request/PushAllStoriesRequest.java @@ -0,0 +1,19 @@ +package com.kntro.reqsai.gateway.interfaces.rest.dto.request; + +import io.swagger.v3.oas.annotations.media.Schema; +import org.jspecify.annotations.Nullable; + +import java.util.List; +import java.util.UUID; + +/** + * Optional request body for the Jira push-all. {@code storyIds} restricts the push to the given stories; + * omit the body or leave it empty to push every eligible story of the project (the original behaviour). + * Ids not belonging to the project are ignored. + */ +@Schema(description = "Optional request body to push a selection of stories to Jira") +public record PushAllStoriesRequest( + @Schema(description = "Specific story ids to push; omit/empty = all eligible stories", + example = "[\"019756a0-1234-7abc-8def-000000000010\"]") + @Nullable List storyIds +) {} diff --git a/src/main/java/com/kntro/reqsai/gateway/interfaces/rest/dto/request/SaveProjectTargetRequest.java b/src/main/java/com/kntro/reqsai/gateway/interfaces/rest/dto/request/SaveProjectTargetRequest.java new file mode 100644 index 00000000..d2069709 --- /dev/null +++ b/src/main/java/com/kntro/reqsai/gateway/interfaces/rest/dto/request/SaveProjectTargetRequest.java @@ -0,0 +1,23 @@ +package com.kntro.reqsai.gateway.interfaces.rest.dto.request; + +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.Size; + +import java.util.UUID; + +@Schema(description = "Request body to set a project's Jira push target") +public record SaveProjectTargetRequest( + @Schema(description = "Organization integration connection id") + @NotNull + UUID connectionId, + + @Schema(description = "Jira project key", example = "PAY", maxLength = 100) + @NotBlank @Size(max = 100) + String jiraProjectKey, + + @Schema(description = "Jira issue type name", example = "Story", maxLength = 100) + @NotBlank @Size(max = 100) + String issueTypeName +) {} diff --git a/src/main/java/com/kntro/reqsai/gateway/interfaces/rest/dto/response/ConnectionTestResponse.java b/src/main/java/com/kntro/reqsai/gateway/interfaces/rest/dto/response/ConnectionTestResponse.java new file mode 100644 index 00000000..52f29b61 --- /dev/null +++ b/src/main/java/com/kntro/reqsai/gateway/interfaces/rest/dto/response/ConnectionTestResponse.java @@ -0,0 +1,8 @@ +package com.kntro.reqsai.gateway.interfaces.rest.dto.response; + +import io.swagger.v3.oas.annotations.media.Schema; +import org.jspecify.annotations.Nullable; + +/** Result of testing a connection: {@code ok} plus the account display name when successful. */ +@Schema(description = "Connection test result") +public record ConnectionTestResponse(boolean ok, @Nullable String accountName) {} diff --git a/src/main/java/com/kntro/reqsai/gateway/interfaces/rest/dto/response/IntegrationConnectionResponse.java b/src/main/java/com/kntro/reqsai/gateway/interfaces/rest/dto/response/IntegrationConnectionResponse.java new file mode 100644 index 00000000..eb12baec --- /dev/null +++ b/src/main/java/com/kntro/reqsai/gateway/interfaces/rest/dto/response/IntegrationConnectionResponse.java @@ -0,0 +1,29 @@ +package com.kntro.reqsai.gateway.interfaces.rest.dto.response; + +import io.swagger.v3.oas.annotations.media.Schema; +import org.jspecify.annotations.Nullable; + +import java.time.Instant; +import java.util.UUID; + +/** + * Organization integration connection resource. NEVER carries the API token or any OAuth token. + *

+ * {@code credentialType} is {@code "API_TOKEN"} or {@code "OAUTH2"}; {@code email} is populated only for + * {@code API_TOKEN} connections and is {@code null} for {@code OAUTH2}. + */ +@Schema(description = "Organization integration connection (no API/OAuth token is ever returned)") +public record IntegrationConnectionResponse( + UUID id, + UUID organizationId, + String provider, + @Schema(description = "Credential type", allowableValues = {"API_TOKEN", "OAUTH2"}) + String credentialType, + String siteUrl, + @Schema(description = "Jira account email; null for OAUTH2 connections") + @Nullable String email, + String status, + @Nullable Instant lastVerifiedAt, + Instant createdAt, + Instant updatedAt +) {} diff --git a/src/main/java/com/kntro/reqsai/gateway/interfaces/rest/dto/response/IntegrationJobResponse.java b/src/main/java/com/kntro/reqsai/gateway/interfaces/rest/dto/response/IntegrationJobResponse.java new file mode 100644 index 00000000..534b9ed0 --- /dev/null +++ b/src/main/java/com/kntro/reqsai/gateway/interfaces/rest/dto/response/IntegrationJobResponse.java @@ -0,0 +1,28 @@ +package com.kntro.reqsai.gateway.interfaces.rest.dto.response; + +import io.swagger.v3.oas.annotations.media.Schema; +import org.jspecify.annotations.Nullable; + +import java.time.Instant; +import java.util.UUID; + +/** + * Snapshot of a background integration sync job. Returned by the 202 job-start endpoints and the + * job query endpoints, and broadcast with the same JSON shape on + * {@code /topic/projects/{projectId}/integration-jobs} — live frames and reload-recovery reads are + * interchangeable for the client. + */ +@Schema(description = "Background integration sync job (import / push-all) with live progress counters") +public record IntegrationJobResponse( + UUID id, + UUID projectId, + @Schema(description = "IMPORT | PUSH_ALL") String jobType, + @Schema(description = "RUNNING | COMPLETED | FAILED") String status, + int total, + int processed, + int succeeded, + int failed, + @Schema(description = "Terminal summary (fatal error or skipped-duplicates note); null otherwise") + @Nullable String message, + Instant createdAt, + @Nullable Instant finishedAt) {} diff --git a/src/main/java/com/kntro/reqsai/gateway/interfaces/rest/dto/response/JiraImportPreviewResponse.java b/src/main/java/com/kntro/reqsai/gateway/interfaces/rest/dto/response/JiraImportPreviewResponse.java new file mode 100644 index 00000000..5e8c9519 --- /dev/null +++ b/src/main/java/com/kntro/reqsai/gateway/interfaces/rest/dto/response/JiraImportPreviewResponse.java @@ -0,0 +1,20 @@ +package com.kntro.reqsai.gateway.interfaces.rest.dto.response; + +import io.swagger.v3.oas.annotations.media.Schema; +import org.jspecify.annotations.Nullable; + +import java.util.List; +import java.util.UUID; + +/** Preview of a Jira import: candidate issues with likely-duplicate flags. */ +@Schema(description = "Candidate Jira issues eligible for import, with likely-duplicate flags") +public record JiraImportPreviewResponse(int total, List issues) { + + @Schema(description = "One candidate Jira issue") + public record Candidate( + String jiraIssueKey, + String summary, + @Nullable String issueType, + boolean duplicate, + @Nullable UUID existingStoryId) {} +} diff --git a/src/main/java/com/kntro/reqsai/gateway/interfaces/rest/dto/response/JiraIssueTypeResponse.java b/src/main/java/com/kntro/reqsai/gateway/interfaces/rest/dto/response/JiraIssueTypeResponse.java new file mode 100644 index 00000000..1bf347e7 --- /dev/null +++ b/src/main/java/com/kntro/reqsai/gateway/interfaces/rest/dto/response/JiraIssueTypeResponse.java @@ -0,0 +1,7 @@ +package com.kntro.reqsai.gateway.interfaces.rest.dto.response; + +import io.swagger.v3.oas.annotations.media.Schema; + +/** A selectable Jira issue type. */ +@Schema(description = "A Jira issue type available for a project") +public record JiraIssueTypeResponse(String id, String name) {} diff --git a/src/main/java/com/kntro/reqsai/gateway/interfaces/rest/dto/response/JiraOAuthAuthorizeUrlResponse.java b/src/main/java/com/kntro/reqsai/gateway/interfaces/rest/dto/response/JiraOAuthAuthorizeUrlResponse.java new file mode 100644 index 00000000..4b51debe --- /dev/null +++ b/src/main/java/com/kntro/reqsai/gateway/interfaces/rest/dto/response/JiraOAuthAuthorizeUrlResponse.java @@ -0,0 +1,10 @@ +package com.kntro.reqsai.gateway.interfaces.rest.dto.response; + +import io.swagger.v3.oas.annotations.media.Schema; + +/** The Atlassian authorize URL to redirect the user to, plus the signed state embedded in it. */ +@Schema(description = "Jira OAuth authorize URL + signed state") +public record JiraOAuthAuthorizeUrlResponse( + @Schema(description = "Full Atlassian authorize URL") String url, + @Schema(description = "Signed, stateless CSRF state token embedded in the URL") String state +) {} diff --git a/src/main/java/com/kntro/reqsai/gateway/interfaces/rest/dto/response/JiraOAuthSiteResponse.java b/src/main/java/com/kntro/reqsai/gateway/interfaces/rest/dto/response/JiraOAuthSiteResponse.java new file mode 100644 index 00000000..29e242c9 --- /dev/null +++ b/src/main/java/com/kntro/reqsai/gateway/interfaces/rest/dto/response/JiraOAuthSiteResponse.java @@ -0,0 +1,11 @@ +package com.kntro.reqsai.gateway.interfaces.rest.dto.response; + +import io.swagger.v3.oas.annotations.media.Schema; + +/** One accessible Atlassian site offered for selection during the OAuth callback. */ +@Schema(description = "An accessible Atlassian Jira site") +public record JiraOAuthSiteResponse( + @Schema(description = "Atlassian cloud id") String cloudId, + @Schema(description = "Site base URL", example = "https://acme.atlassian.net") String url, + @Schema(description = "Site display name") String name +) {} diff --git a/src/main/java/com/kntro/reqsai/gateway/interfaces/rest/dto/response/JiraOAuthSitesResponse.java b/src/main/java/com/kntro/reqsai/gateway/interfaces/rest/dto/response/JiraOAuthSitesResponse.java new file mode 100644 index 00000000..7f8ad9af --- /dev/null +++ b/src/main/java/com/kntro/reqsai/gateway/interfaces/rest/dto/response/JiraOAuthSitesResponse.java @@ -0,0 +1,15 @@ +package com.kntro.reqsai.gateway.interfaces.rest.dto.response; + +import io.swagger.v3.oas.annotations.media.Schema; + +import java.util.List; + +/** + * Returned by the OAuth callback (HTTP 200) when the user has access to multiple Atlassian sites and has + * not yet chosen one. Nothing was persisted; the frontend re-POSTs the callback with a chosen + * {@code cloudId}. + */ +@Schema(description = "Multiple accessible Jira sites to choose from (no connection saved yet)") +public record JiraOAuthSitesResponse( + List sites +) {} diff --git a/src/main/java/com/kntro/reqsai/gateway/interfaces/rest/dto/response/JiraProjectResponse.java b/src/main/java/com/kntro/reqsai/gateway/interfaces/rest/dto/response/JiraProjectResponse.java new file mode 100644 index 00000000..18190f4a --- /dev/null +++ b/src/main/java/com/kntro/reqsai/gateway/interfaces/rest/dto/response/JiraProjectResponse.java @@ -0,0 +1,7 @@ +package com.kntro.reqsai.gateway.interfaces.rest.dto.response; + +import io.swagger.v3.oas.annotations.media.Schema; + +/** A selectable Jira project. */ +@Schema(description = "A Jira project visible to the connection") +public record JiraProjectResponse(String key, String name) {} diff --git a/src/main/java/com/kntro/reqsai/gateway/interfaces/rest/dto/response/JiraPushResultResponse.java b/src/main/java/com/kntro/reqsai/gateway/interfaces/rest/dto/response/JiraPushResultResponse.java new file mode 100644 index 00000000..d2f96399 --- /dev/null +++ b/src/main/java/com/kntro/reqsai/gateway/interfaces/rest/dto/response/JiraPushResultResponse.java @@ -0,0 +1,15 @@ +package com.kntro.reqsai.gateway.interfaces.rest.dto.response; + +import io.swagger.v3.oas.annotations.media.Schema; +import org.jspecify.annotations.Nullable; + +import java.util.UUID; + +/** Result of pushing one story to Jira. {@code error} is set (and the Jira fields null) on failure. */ +@Schema(description = "Result of pushing a single story to Jira") +public record JiraPushResultResponse( + UUID storyId, + @Nullable String jiraIssueKey, + @Nullable String jiraIssueUrl, + @Nullable String error +) {} diff --git a/src/main/java/com/kntro/reqsai/gateway/interfaces/rest/dto/response/ProjectJiraTargetResponse.java b/src/main/java/com/kntro/reqsai/gateway/interfaces/rest/dto/response/ProjectJiraTargetResponse.java new file mode 100644 index 00000000..ef31ca4e --- /dev/null +++ b/src/main/java/com/kntro/reqsai/gateway/interfaces/rest/dto/response/ProjectJiraTargetResponse.java @@ -0,0 +1,18 @@ +package com.kntro.reqsai.gateway.interfaces.rest.dto.response; + +import io.swagger.v3.oas.annotations.media.Schema; + +import java.time.Instant; +import java.util.UUID; + +/** A project's Jira push target. */ +@Schema(description = "The Jira push target configured for a project") +public record ProjectJiraTargetResponse( + UUID id, + UUID projectId, + UUID connectionId, + String jiraProjectKey, + String issueTypeName, + Instant createdAt, + Instant updatedAt +) {} diff --git a/src/main/java/com/kntro/reqsai/gateway/interfaces/rest/mappers/request/IntegrationRequestMapper.java b/src/main/java/com/kntro/reqsai/gateway/interfaces/rest/mappers/request/IntegrationRequestMapper.java new file mode 100644 index 00000000..4ae96482 --- /dev/null +++ b/src/main/java/com/kntro/reqsai/gateway/interfaces/rest/mappers/request/IntegrationRequestMapper.java @@ -0,0 +1,25 @@ +package com.kntro.reqsai.gateway.interfaces.rest.mappers.request; + +import com.kntro.reqsai.gateway.application.command.ConnectJiraCommand; +import com.kntro.reqsai.gateway.application.command.SaveProjectTargetCommand; +import com.kntro.reqsai.gateway.interfaces.rest.dto.request.ConnectJiraRequest; +import com.kntro.reqsai.gateway.interfaces.rest.dto.request.SaveProjectTargetRequest; + +import java.util.UUID; + +/** Maps integration REST requests to application commands. */ +public final class IntegrationRequestMapper { + + private IntegrationRequestMapper() { + throw new UnsupportedOperationException("Utility class - do not instantiate"); + } + + public static ConnectJiraCommand toCommand(UUID orgId, ConnectJiraRequest request, UUID requestedBy) { + return new ConnectJiraCommand(orgId, request.siteUrl(), request.email(), request.apiToken(), requestedBy); + } + + public static SaveProjectTargetCommand toCommand(UUID projectId, SaveProjectTargetRequest request, UUID requestedBy) { + return new SaveProjectTargetCommand( + projectId, request.connectionId(), request.jiraProjectKey(), request.issueTypeName(), requestedBy); + } +} diff --git a/src/main/java/com/kntro/reqsai/gateway/interfaces/rest/mappers/response/IntegrationResponseMapper.java b/src/main/java/com/kntro/reqsai/gateway/interfaces/rest/mappers/response/IntegrationResponseMapper.java new file mode 100644 index 00000000..94f2fa54 --- /dev/null +++ b/src/main/java/com/kntro/reqsai/gateway/interfaces/rest/mappers/response/IntegrationResponseMapper.java @@ -0,0 +1,99 @@ +package com.kntro.reqsai.gateway.interfaces.rest.mappers.response; + +import com.kntro.reqsai.gateway.application.port.IntegrationProvider.RemoteIssueType; +import com.kntro.reqsai.gateway.application.port.IntegrationProvider.RemoteProject; +import com.kntro.reqsai.gateway.application.port.JiraOAuthPort.Site; +import com.kntro.reqsai.gateway.application.result.ConnectionTestResult; +import com.kntro.reqsai.gateway.application.result.ImportPreview; +import com.kntro.reqsai.gateway.application.result.StoryPushResult; +import com.kntro.reqsai.gateway.domain.model.IntegrationConnection; +import com.kntro.reqsai.gateway.domain.model.IntegrationSyncJob; +import com.kntro.reqsai.gateway.domain.model.ProjectIntegrationTarget; +import com.kntro.reqsai.gateway.interfaces.rest.dto.response.ConnectionTestResponse; +import com.kntro.reqsai.gateway.interfaces.rest.dto.response.IntegrationConnectionResponse; +import com.kntro.reqsai.gateway.interfaces.rest.dto.response.IntegrationJobResponse; +import com.kntro.reqsai.gateway.interfaces.rest.dto.response.JiraIssueTypeResponse; +import com.kntro.reqsai.gateway.interfaces.rest.dto.response.JiraOAuthSiteResponse; +import com.kntro.reqsai.gateway.interfaces.rest.dto.response.JiraImportPreviewResponse; +import com.kntro.reqsai.gateway.interfaces.rest.dto.response.JiraProjectResponse; +import com.kntro.reqsai.gateway.interfaces.rest.dto.response.JiraPushResultResponse; +import com.kntro.reqsai.gateway.interfaces.rest.dto.response.ProjectJiraTargetResponse; + +/** Maps integration domain/results to REST responses. Never emits the API token. */ +public final class IntegrationResponseMapper { + + private IntegrationResponseMapper() { + throw new UnsupportedOperationException("Utility class - do not instantiate"); + } + + public static IntegrationConnectionResponse toResponse(IntegrationConnection c) { + return new IntegrationConnectionResponse( + c.getId(), + c.getOrganizationId(), + c.getProvider().name(), + c.getCredentialType().name(), + c.getSiteUrl(), + c.getEmail(), + c.getStatus().name(), + c.getLastVerifiedAt(), + c.getCreatedAt(), + c.getUpdatedAt()); + } + + public static ConnectionTestResponse toResponse(ConnectionTestResult r) { + return new ConnectionTestResponse(r.ok(), r.accountName()); + } + + public static JiraProjectResponse toResponse(RemoteProject p) { + return new JiraProjectResponse(p.key(), p.name()); + } + + public static JiraIssueTypeResponse toResponse(RemoteIssueType t) { + return new JiraIssueTypeResponse(t.id(), t.name()); + } + + public static JiraOAuthSiteResponse toResponse(Site s) { + return new JiraOAuthSiteResponse(s.cloudId(), s.url(), s.name()); + } + + public static ProjectJiraTargetResponse toResponse(ProjectIntegrationTarget t) { + return new ProjectJiraTargetResponse( + t.getId(), + t.getProjectId(), + t.getConnectionId(), + t.getJiraProjectKey(), + t.getIssueTypeName(), + t.getCreatedAt(), + t.getUpdatedAt()); + } + + public static JiraPushResultResponse toResponse(StoryPushResult r) { + return new JiraPushResultResponse(r.storyId(), r.jiraIssueKey(), r.jiraIssueUrl(), r.error()); + } + + /** Same field-by-field shape as the {@code IntegrationJobMessage} broadcast over STOMP. */ + public static IntegrationJobResponse toResponse(IntegrationSyncJob job) { + return new IntegrationJobResponse( + job.getId(), + job.getProjectId(), + job.getJobType().name(), + job.getStatus().name(), + job.getTotal(), + job.getProcessed(), + job.getSucceeded(), + job.getFailed(), + job.getMessage(), + job.getCreatedAt(), + job.getFinishedAt()); + } + + public static JiraImportPreviewResponse toResponse(ImportPreview p) { + return new JiraImportPreviewResponse( + p.total(), + p.issues().stream() + .map(c -> new JiraImportPreviewResponse.Candidate( + c.jiraIssueKey(), c.summary(), c.issueType(), c.duplicate(), c.existingStoryId())) + .toList()); + } + +} diff --git a/src/main/java/com/kntro/reqsai/gateway/interfaces/rest/swagger/OrganizationIntegrationController.java b/src/main/java/com/kntro/reqsai/gateway/interfaces/rest/swagger/OrganizationIntegrationController.java new file mode 100644 index 00000000..0b027970 --- /dev/null +++ b/src/main/java/com/kntro/reqsai/gateway/interfaces/rest/swagger/OrganizationIntegrationController.java @@ -0,0 +1,162 @@ +package com.kntro.reqsai.gateway.interfaces.rest.swagger; + +import com.kntro.reqsai.gateway.interfaces.rest.dto.request.ConnectJiraRequest; +import com.kntro.reqsai.gateway.interfaces.rest.dto.request.JiraOAuthCallbackRequest; +import com.kntro.reqsai.gateway.interfaces.rest.dto.response.ConnectionTestResponse; +import com.kntro.reqsai.gateway.interfaces.rest.dto.response.IntegrationConnectionResponse; +import com.kntro.reqsai.gateway.interfaces.rest.dto.response.JiraIssueTypeResponse; +import com.kntro.reqsai.gateway.interfaces.rest.dto.response.JiraOAuthAuthorizeUrlResponse; +import com.kntro.reqsai.gateway.interfaces.rest.dto.response.JiraProjectResponse; +import com.kntro.reqsai.shared.infrastructure.configuration.ApiVersioning; +import com.kntro.reqsai.shared.infrastructure.documentation.openapi.OpenApiConfiguration; +import com.kntro.reqsai.shared.infrastructure.documentation.openapi.annotations.ApiResponseBadRequest; +import com.kntro.reqsai.shared.infrastructure.documentation.openapi.annotations.ApiResponseConflict; +import com.kntro.reqsai.shared.infrastructure.documentation.openapi.annotations.ApiResponseNotFound; +import com.kntro.reqsai.shared.infrastructure.documentation.openapi.annotations.ApiStandardErrorResponses; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.media.Content; +import io.swagger.v3.oas.annotations.media.Schema; +import io.swagger.v3.oas.annotations.responses.ApiResponse; +import io.swagger.v3.oas.annotations.security.SecurityRequirement; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.validation.Valid; +import org.springframework.http.MediaType; +import org.springframework.http.ResponseEntity; +import org.springframework.security.core.Authentication; +import org.springframework.web.bind.annotation.DeleteMapping; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; + +import java.util.List; +import java.util.UUID; + +@RequestMapping( + path = ApiVersioning.BASE + "/organizations/{orgId}/integrations", + produces = MediaType.APPLICATION_JSON_VALUE) +@Tag(name = "Organization Integrations", description = "Org-level third-party integration connections (Jira)") +public interface OrganizationIntegrationController { + + @Operation(summary = "List organization integration connections", + description = "Returns the organization's integration connections. The API token is never returned.") + @ApiResponse(responseCode = "200", description = "Connections listed", + content = @Content(mediaType = MediaType.APPLICATION_JSON_VALUE)) + @ApiStandardErrorResponses + @SecurityRequirement(name = OpenApiConfiguration.BEARER_SCHEME) + @GetMapping(version = ApiVersioning.V1) + ResponseEntity> listConnections( + @Parameter(description = "Organization UUID") @PathVariable UUID orgId, + Authentication authentication); + + @Operation(summary = "Connect a Jira integration", + description = """ + Verifies the supplied Jira credentials against Jira Cloud and, on success, stores an + encrypted connection. Returns 409 when an active connection already exists, and + 401/502 when Jira rejects or is unreachable.""") + @ApiResponse(responseCode = "201", description = "Jira connection created", + content = @Content(mediaType = MediaType.APPLICATION_JSON_VALUE, + schema = @Schema(implementation = IntegrationConnectionResponse.class))) + @ApiResponseBadRequest + @ApiResponseConflict + @ApiStandardErrorResponses + @SecurityRequirement(name = OpenApiConfiguration.BEARER_SCHEME) + @PostMapping(value = "/jira", version = ApiVersioning.V1) + ResponseEntity connectJira( + @Parameter(description = "Organization UUID") @PathVariable UUID orgId, + @Valid @RequestBody ConnectJiraRequest request, + Authentication authentication); + + @Operation(summary = "Get the Jira OAuth authorize URL", + description = """ + Returns the Atlassian authorize URL (with a stateless signed `state`) to redirect the + user into the OAuth 2.0 (3LO) consent flow. Responds `501 JIRA_OAUTH_NOT_CONFIGURED` + when the deployment has no Jira OAuth app configured (the UI disables the button).""") + @ApiResponse(responseCode = "200", description = "Authorize URL + state", + content = @Content(mediaType = MediaType.APPLICATION_JSON_VALUE, + schema = @Schema(implementation = JiraOAuthAuthorizeUrlResponse.class))) + @ApiStandardErrorResponses + @SecurityRequirement(name = OpenApiConfiguration.BEARER_SCHEME) + @GetMapping(value = "/jira/oauth/authorize-url", version = ApiVersioning.V1) + ResponseEntity jiraOAuthAuthorizeUrl( + @Parameter(description = "Organization UUID") @PathVariable UUID orgId, + Authentication authentication); + + @Operation(summary = "Complete the Jira OAuth callback", + description = """ + Validates the signed `state`, exchanges the authorization `code`, and discovers the + accessible Atlassian sites. If a `cloudId` is supplied (or exactly one site exists) an + encrypted OAUTH2 connection is saved and returned (`IntegrationConnectionResponse`). + If multiple sites exist and no `cloudId` is given, returns `200 {sites:[...]}` WITHOUT + saving, for the frontend to re-POST with a chosen `cloudId`. `409` when a connection + already exists; `400 JIRA_OAUTH_STATE_INVALID` on a bad state; + `501 JIRA_OAUTH_NOT_CONFIGURED` when OAuth is unconfigured.""") + @ApiResponse(responseCode = "200", + description = "OAUTH2 connection saved, or the list of sites to choose from", + content = @Content(mediaType = MediaType.APPLICATION_JSON_VALUE)) + @ApiResponseBadRequest + @ApiResponseConflict + @ApiStandardErrorResponses + @SecurityRequirement(name = OpenApiConfiguration.BEARER_SCHEME) + @PostMapping(value = "/jira/oauth/callback", version = ApiVersioning.V1) + ResponseEntity jiraOAuthCallback( + @Parameter(description = "Organization UUID") @PathVariable UUID orgId, + @Valid @RequestBody JiraOAuthCallbackRequest request, + Authentication authentication); + + @Operation(summary = "Test an integration connection", + description = "Re-verifies the stored credential against the provider. Never fails the request.") + @ApiResponse(responseCode = "200", description = "Test result", + content = @Content(mediaType = MediaType.APPLICATION_JSON_VALUE, + schema = @Schema(implementation = ConnectionTestResponse.class))) + @ApiResponseNotFound + @ApiStandardErrorResponses + @SecurityRequirement(name = OpenApiConfiguration.BEARER_SCHEME) + @PostMapping(value = "/{connectionId}/test", version = ApiVersioning.V1) + ResponseEntity testConnection( + @Parameter(description = "Organization UUID") @PathVariable UUID orgId, + @Parameter(description = "Connection UUID") @PathVariable UUID connectionId, + Authentication authentication); + + @Operation(summary = "Delete an integration connection", + description = "Removes the connection; project targets referencing it are cascaded away.") + @ApiResponse(responseCode = "204", description = "Connection deleted") + @ApiResponseNotFound + @ApiStandardErrorResponses + @SecurityRequirement(name = OpenApiConfiguration.BEARER_SCHEME) + @DeleteMapping(value = "/{connectionId}", version = ApiVersioning.V1) + ResponseEntity deleteConnection( + @Parameter(description = "Organization UUID") @PathVariable UUID orgId, + @Parameter(description = "Connection UUID") @PathVariable UUID connectionId, + Authentication authentication); + + @Operation(summary = "List Jira projects for a connection", + description = "Lists the Jira projects visible to the connection's credentials.") + @ApiResponse(responseCode = "200", description = "Jira projects", + content = @Content(mediaType = MediaType.APPLICATION_JSON_VALUE)) + @ApiResponseNotFound + @ApiStandardErrorResponses + @SecurityRequirement(name = OpenApiConfiguration.BEARER_SCHEME) + @GetMapping(value = "/{connectionId}/jira/projects", version = ApiVersioning.V1) + ResponseEntity> listJiraProjects( + @Parameter(description = "Organization UUID") @PathVariable UUID orgId, + @Parameter(description = "Connection UUID") @PathVariable UUID connectionId, + Authentication authentication); + + @Operation(summary = "List Jira issue types", + description = "Lists the Jira issue types available to the connection for the given project key.") + @ApiResponse(responseCode = "200", description = "Jira issue types", + content = @Content(mediaType = MediaType.APPLICATION_JSON_VALUE)) + @ApiResponseNotFound + @ApiStandardErrorResponses + @SecurityRequirement(name = OpenApiConfiguration.BEARER_SCHEME) + @GetMapping(value = "/{connectionId}/jira/issue-types", version = ApiVersioning.V1) + ResponseEntity> listJiraIssueTypes( + @Parameter(description = "Organization UUID") @PathVariable UUID orgId, + @Parameter(description = "Connection UUID") @PathVariable UUID connectionId, + @Parameter(description = "Jira project key", example = "PAY") @RequestParam String projectKey, + Authentication authentication); +} diff --git a/src/main/java/com/kntro/reqsai/gateway/interfaces/rest/swagger/ProjectIntegrationController.java b/src/main/java/com/kntro/reqsai/gateway/interfaces/rest/swagger/ProjectIntegrationController.java new file mode 100644 index 00000000..8c7b2805 --- /dev/null +++ b/src/main/java/com/kntro/reqsai/gateway/interfaces/rest/swagger/ProjectIntegrationController.java @@ -0,0 +1,188 @@ +package com.kntro.reqsai.gateway.interfaces.rest.swagger; + +import com.kntro.reqsai.gateway.interfaces.rest.dto.request.ImportJiraStoriesRequest; +import com.kntro.reqsai.gateway.interfaces.rest.dto.request.PushAllStoriesRequest; +import com.kntro.reqsai.gateway.interfaces.rest.dto.request.SaveProjectTargetRequest; +import com.kntro.reqsai.gateway.interfaces.rest.dto.response.IntegrationJobResponse; +import com.kntro.reqsai.gateway.interfaces.rest.dto.response.JiraImportPreviewResponse; +import com.kntro.reqsai.gateway.interfaces.rest.dto.response.JiraPushResultResponse; +import com.kntro.reqsai.gateway.interfaces.rest.dto.response.ProjectJiraTargetResponse; +import com.kntro.reqsai.shared.infrastructure.configuration.ApiVersioning; +import com.kntro.reqsai.shared.infrastructure.documentation.openapi.OpenApiConfiguration; +import com.kntro.reqsai.shared.infrastructure.documentation.openapi.annotations.ApiResponseBadRequest; +import com.kntro.reqsai.shared.infrastructure.documentation.openapi.annotations.ApiResponseConflict; +import com.kntro.reqsai.shared.infrastructure.documentation.openapi.annotations.ApiResponseNotFound; +import com.kntro.reqsai.shared.infrastructure.documentation.openapi.annotations.ApiStandardErrorResponses; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.media.Content; +import io.swagger.v3.oas.annotations.media.Schema; +import io.swagger.v3.oas.annotations.responses.ApiResponse; +import io.swagger.v3.oas.annotations.security.SecurityRequirement; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.validation.Valid; +import org.springframework.http.MediaType; +import org.springframework.http.ResponseEntity; +import org.springframework.security.core.Authentication; +import org.springframework.web.bind.annotation.DeleteMapping; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.PutMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; + +import java.util.List; +import java.util.UUID; + +@RequestMapping( + path = ApiVersioning.BASE + "/projects/{projectId}/integration/jira", + produces = MediaType.APPLICATION_JSON_VALUE) +@Tag(name = "Project Integration", description = "Project-level Jira push target and story export") +public interface ProjectIntegrationController { + + @Operation(summary = "Get the project's Jira target", + description = "Returns the project's configured Jira push target, 404 when none is set.") + @ApiResponse(responseCode = "200", description = "Jira target", + content = @Content(mediaType = MediaType.APPLICATION_JSON_VALUE, + schema = @Schema(implementation = ProjectJiraTargetResponse.class))) + @ApiResponseNotFound + @ApiStandardErrorResponses + @SecurityRequirement(name = OpenApiConfiguration.BEARER_SCHEME) + @GetMapping(value = "/target", version = ApiVersioning.V1) + ResponseEntity getTarget( + @Parameter(description = "Project UUID") @PathVariable UUID projectId, + Authentication authentication); + + @Operation(summary = "Set the project's Jira target", + description = "Creates or replaces the single Jira push target for the project (upsert).") + @ApiResponse(responseCode = "200", description = "Jira target saved", + content = @Content(mediaType = MediaType.APPLICATION_JSON_VALUE, + schema = @Schema(implementation = ProjectJiraTargetResponse.class))) + @ApiResponseBadRequest + @ApiResponseNotFound + @ApiStandardErrorResponses + @SecurityRequirement(name = OpenApiConfiguration.BEARER_SCHEME) + @PutMapping(value = "/target", version = ApiVersioning.V1) + ResponseEntity saveTarget( + @Parameter(description = "Project UUID") @PathVariable UUID projectId, + @Valid @RequestBody SaveProjectTargetRequest request, + Authentication authentication); + + @Operation(summary = "Delete the project's Jira target", + description = "Removes the project's Jira push target.") + @ApiResponse(responseCode = "204", description = "Jira target deleted") + @ApiResponseNotFound + @ApiStandardErrorResponses + @SecurityRequirement(name = OpenApiConfiguration.BEARER_SCHEME) + @DeleteMapping(value = "/target", version = ApiVersioning.V1) + ResponseEntity deleteTarget( + @Parameter(description = "Project UUID") @PathVariable UUID projectId, + Authentication authentication); + + @Operation(summary = "Push one story to Jira", + description = "Pushes a single story to the project's Jira target. 409 when no target is configured.") + @ApiResponse(responseCode = "200", description = "Story pushed", + content = @Content(mediaType = MediaType.APPLICATION_JSON_VALUE, + schema = @Schema(implementation = JiraPushResultResponse.class))) + @ApiResponseConflict + @ApiResponseNotFound + @ApiStandardErrorResponses + @SecurityRequirement(name = OpenApiConfiguration.BEARER_SCHEME) + @PostMapping(value = "/stories/{storyId}/push", version = ApiVersioning.V1) + ResponseEntity pushStory( + @Parameter(description = "Project UUID") @PathVariable UUID projectId, + @Parameter(description = "Story UUID") @PathVariable UUID storyId, + Authentication authentication); + + @Operation(summary = "Push all stories to Jira (async job)", + description = """ + Starts a background job that pushes project stories to the Jira target and returns + 202 immediately with the job snapshot. Body {storyIds?} restricts the push to the + given stories; omit/empty pushes every eligible story (ids not in the project are + ignored). Progress is broadcast on /topic/projects/{projectId}/integration-jobs and + queryable via the jobs endpoints. Per-story failures are counted without aborting the + job. 409 when no target is configured (INTEGRATION_TARGET_NOT_CONFIGURED) or a + push-all job is already running (INTEGRATION_JOB_ALREADY_RUNNING).""") + @ApiResponse(responseCode = "202", description = "Job accepted and running", + content = @Content(mediaType = MediaType.APPLICATION_JSON_VALUE, + schema = @Schema(implementation = IntegrationJobResponse.class))) + @ApiResponseConflict + @ApiStandardErrorResponses + @SecurityRequirement(name = OpenApiConfiguration.BEARER_SCHEME) + @PostMapping(value = "/stories/push-all", version = ApiVersioning.V1) + ResponseEntity pushAllStories( + @Parameter(description = "Project UUID") @PathVariable UUID projectId, + @RequestBody(required = false) PushAllStoriesRequest request, + Authentication authentication); + + @Operation(summary = "Preview a Jira import", + description = """ + Lists the Jira issues eligible for import from the project's target and flags likely + duplicates (detected via the discovery similarity path, without creating anything). + 409 when no target is configured.""") + @ApiResponse(responseCode = "200", description = "Import preview", + content = @Content(mediaType = MediaType.APPLICATION_JSON_VALUE, + schema = @Schema(implementation = JiraImportPreviewResponse.class))) + @ApiResponseConflict + @ApiStandardErrorResponses + @SecurityRequirement(name = OpenApiConfiguration.BEARER_SCHEME) + @GetMapping(value = "/import/preview", version = ApiVersioning.V1) + ResponseEntity previewImport( + @Parameter(description = "Project UUID") @PathVariable UUID projectId, + Authentication authentication); + + @Operation(summary = "Import Jira issues as stories (async job)", + description = """ + Starts a background job that pulls Jira issues from the project's target and creates + them as user stories (LLM mapping + duplicate detection reused from discovery), and + returns 202 immediately with the job snapshot. Body {issueKeys?} restricts the import; + omit/empty imports all eligible issues. Progress is broadcast on + /topic/projects/{projectId}/integration-jobs and queryable via the jobs endpoints. + Per-issue failures are counted without aborting the job; duplicates count as processed + only. 409 when no target is configured (INTEGRATION_TARGET_NOT_CONFIGURED) or an import + job is already running (INTEGRATION_JOB_ALREADY_RUNNING).""") + @ApiResponse(responseCode = "202", description = "Job accepted and running", + content = @Content(mediaType = MediaType.APPLICATION_JSON_VALUE, + schema = @Schema(implementation = IntegrationJobResponse.class))) + @ApiResponseConflict + @ApiStandardErrorResponses + @SecurityRequirement(name = OpenApiConfiguration.BEARER_SCHEME) + @PostMapping(value = "/import", version = ApiVersioning.V1) + ResponseEntity importStories( + @Parameter(description = "Project UUID") @PathVariable UUID projectId, + @RequestBody(required = false) ImportJiraStoriesRequest request, + Authentication authentication); + + @Operation(summary = "List integration sync jobs", + description = """ + Lists the project's background sync jobs. active=true returns only RUNNING jobs (what a + reloaded page asks first to re-attach its progress banner); otherwise the most recent + ~10 jobs of any status are returned, newest first.""") + @ApiResponse(responseCode = "200", description = "Sync jobs", + content = @Content(mediaType = MediaType.APPLICATION_JSON_VALUE, + schema = @Schema(implementation = IntegrationJobResponse[].class))) + @ApiStandardErrorResponses + @SecurityRequirement(name = OpenApiConfiguration.BEARER_SCHEME) + @GetMapping(value = "/jobs", version = ApiVersioning.V1) + ResponseEntity> listJobs( + @Parameter(description = "Project UUID") @PathVariable UUID projectId, + @Parameter(description = "Return only RUNNING jobs") + @RequestParam(name = "active", required = false, defaultValue = "false") boolean active, + Authentication authentication); + + @Operation(summary = "Get one integration sync job", + description = "Returns one background sync job of the project; 404 when unknown to this project.") + @ApiResponse(responseCode = "200", description = "Sync job", + content = @Content(mediaType = MediaType.APPLICATION_JSON_VALUE, + schema = @Schema(implementation = IntegrationJobResponse.class))) + @ApiResponseNotFound + @ApiStandardErrorResponses + @SecurityRequirement(name = OpenApiConfiguration.BEARER_SCHEME) + @GetMapping(value = "/jobs/{jobId}", version = ApiVersioning.V1) + ResponseEntity getJob( + @Parameter(description = "Project UUID") @PathVariable UUID projectId, + @Parameter(description = "Job UUID") @PathVariable UUID jobId, + Authentication authentication); +} diff --git a/src/main/java/com/kntro/reqsai/gateway/package-info.java b/src/main/java/com/kntro/reqsai/gateway/package-info.java index 2f03d352..192252ff 100644 --- a/src/main/java/com/kntro/reqsai/gateway/package-info.java +++ b/src/main/java/com/kntro/reqsai/gateway/package-info.java @@ -1,10 +1,17 @@ /** - * Gateway — external integrations bounded context. + * Gateway — external integrations bounded context (ADR-0023). *

- * Jira integration (OAuth connection + export of user stories). Owner: Marcelo. + * Third-party tracker connections and story push, whose first provider is Jira Cloud. Extensible + * provider model: credentials live at the organization level + * ({@code IntegrationConnection}, encrypted API token); the push target (Jira project key + issue + * type) lives at the project level ({@code ProjectIntegrationTarget}). + * Owner: Marcelo. *

- * Layers: {@code api}, {@code domain}, {@code application}, {@code infrastructure}, {@code interfaces}. - * Depends only on the OPEN {@code shared} module. + * Layers: {@code domain}, {@code application}, {@code infrastructure}, {@code interfaces}. + * Depends on the OPEN {@code shared} module, the {@code workspace::api} named interface (org/project + * authorization context — {@code @authz} + {@code Permission}) and the {@code discovery::api} named + * interface ({@code DiscoveryStoryReadPort}, reading user stories to push). */ -@org.springframework.modulith.ApplicationModule +@org.springframework.modulith.ApplicationModule( + allowedDependencies = {"shared", "workspace::api", "discovery::api"}) package com.kntro.reqsai.gateway; diff --git a/src/main/java/com/kntro/reqsai/shared/infrastructure/configuration/BatchConfiguration.java b/src/main/java/com/kntro/reqsai/shared/infrastructure/configuration/BatchConfiguration.java new file mode 100644 index 00000000..21d330ac --- /dev/null +++ b/src/main/java/com/kntro/reqsai/shared/infrastructure/configuration/BatchConfiguration.java @@ -0,0 +1,49 @@ +package com.kntro.reqsai.shared.infrastructure.configuration; + +import org.springframework.batch.core.configuration.support.JdbcDefaultBatchConfiguration; +import org.springframework.beans.factory.annotation.Qualifier; +import org.springframework.context.annotation.Configuration; +import org.springframework.core.task.TaskExecutor; + +/** + * Spring Batch runtime for the async integration sync jobs (ADR-0023). Extending + * {@link JdbcDefaultBatchConfiguration} opts into durable batch metadata (Spring + * Batch 6 / Boot 4 default to an in-memory "resourceless" {@code JobRepository}) and makes Boot's + * batch auto-configuration back off, so this class is the single source of truth: + * + *

    + *
  • Metadata lives in the {@code public} schema. The app's single DataSource is + * routed per tenant by rewriting {@code search_path}, so unqualified {@code BATCH_*} SQL could + * hit an arbitrary tenant schema. The {@code public.BATCH_} table prefix schema-qualifies every + * {@code JobRepository} query (tables and sequences alike), and the matching DDL is owned by + * the common Flyway migration {@code V20260709100001__spring_batch_metadata.sql}. Batch + * metadata is operational — global like {@code public.organizations} — while the domain-facing + * job state stays in the per-tenant {@code integration_sync_jobs} projection.
  • + *
  • Asynchronous launches. The {@code JobOperator} built by this configuration is + * a {@code TaskExecutorJobOperator} running on the shared {@code taskExecutor} (virtual + * threads), so {@code start(job, parameters)} registers the execution and returns immediately — + * that is what lets the REST endpoints answer {@code 202 Accepted} while the job runs.
  • + *
+ * + *

{@code spring.batch.job.enabled=false} keeps Boot from replaying registered jobs at startup; + * jobs run only when the API launches them with explicit parameters. + */ +@Configuration +public class BatchConfiguration extends JdbcDefaultBatchConfiguration { + + private final TaskExecutor taskExecutor; + + public BatchConfiguration(@Qualifier("taskExecutor") TaskExecutor taskExecutor) { + this.taskExecutor = taskExecutor; + } + + @Override + protected String getTablePrefix() { + return "public.BATCH_"; + } + + @Override + protected TaskExecutor getTaskExecutor() { + return taskExecutor; + } +} diff --git a/src/main/java/com/kntro/reqsai/shared/infrastructure/security/SecurityConfiguration.java b/src/main/java/com/kntro/reqsai/shared/infrastructure/security/SecurityConfiguration.java index de0f8daf..72912f7a 100644 --- a/src/main/java/com/kntro/reqsai/shared/infrastructure/security/SecurityConfiguration.java +++ b/src/main/java/com/kntro/reqsai/shared/infrastructure/security/SecurityConfiguration.java @@ -7,6 +7,8 @@ import lombok.RequiredArgsConstructor; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; +import org.springframework.core.env.Environment; +import org.springframework.core.env.Profiles; import org.springframework.http.HttpMethod; import org.springframework.http.MediaType; import org.springframework.http.ProblemDetail; @@ -39,6 +41,9 @@ public class SecurityConfiguration { private static final String[] PUBLIC_ENDPOINTS = { + // NOTE: this wildcard also matches /api/auth/dev-token (DevTokenController, mints a JWT + // for any user/org/role, no login required). A dedicated, profile-aware rule for that one + // path is registered before this wildcard in securityFilterChain() — see the comment there. "/api/auth/**", "/api-docs/**", "/swagger-ui/**", @@ -69,22 +74,34 @@ public class SecurityConfiguration { private final TokenVerifier tokenVerifier; private final TenantSchemaResolver tenantSchemaResolver; private final CorsProperties corsProperties; + private final Environment environment; @Bean public SecurityFilterChain securityFilterChain(HttpSecurity http) { CorrelationFilter correlationFilter = new CorrelationFilter(); JwtAuthenticationFilter jwtAuthenticationFilter = new JwtAuthenticationFilter(tokenVerifier, tenantSchemaResolver); + boolean devProfileActive = environment.acceptsProfiles(Profiles.of("dev")); http .csrf(AbstractHttpConfigurer::disable) .cors(cors -> cors.configurationSource(corsConfigurationSource())) .sessionManagement(sm -> sm.sessionCreationPolicy(SessionCreationPolicy.STATELESS)) - .authorizeHttpRequests(auth -> auth - .requestMatchers(HttpMethod.OPTIONS, "/**").permitAll() - .requestMatchers(HttpMethod.GET, PUBLIC_GET_ENDPOINTS).permitAll() - .requestMatchers(PUBLIC_ENDPOINTS).permitAll() - .anyRequest().authenticated()) + .authorizeHttpRequests(auth -> { + auth.requestMatchers(HttpMethod.OPTIONS, "/**").permitAll(); + // Evaluated before the /api/auth/** wildcard below, so this specific rule wins. + // DevTokenController is @Profile("dev") (its bean never registers otherwise), but + // that annotation is invisible to Spring Security's filter chain — this rule is + // the actual enforcement if a profile misconfiguration ever activates it outside dev. + if (devProfileActive) { + auth.requestMatchers("/api/auth/dev-token").permitAll(); + } else { + auth.requestMatchers("/api/auth/dev-token").denyAll(); + } + auth.requestMatchers(HttpMethod.GET, PUBLIC_GET_ENDPOINTS).permitAll(); + auth.requestMatchers(PUBLIC_ENDPOINTS).permitAll(); + auth.anyRequest().authenticated(); + }) .exceptionHandling(ex -> ex.authenticationEntryPoint(unauthenticatedEntryPoint())) .addFilterBefore(correlationFilter, UsernamePasswordAuthenticationFilter.class) .addFilterAfter(jwtAuthenticationFilter, CorrelationFilter.class); diff --git a/src/main/java/com/kntro/reqsai/shared/infrastructure/web/websocket/StompAuthChannelInterceptor.java b/src/main/java/com/kntro/reqsai/shared/infrastructure/web/websocket/StompAuthChannelInterceptor.java index 64861403..09932bc0 100644 --- a/src/main/java/com/kntro/reqsai/shared/infrastructure/web/websocket/StompAuthChannelInterceptor.java +++ b/src/main/java/com/kntro/reqsai/shared/infrastructure/web/websocket/StompAuthChannelInterceptor.java @@ -16,6 +16,7 @@ import org.springframework.util.StringUtils; import java.util.List; +import java.util.Map; /** * Authenticates STOMP CONNECT frames using the same {@link TokenVerifier} as the HTTP filter. @@ -24,12 +25,27 @@ * interceptor verifies it and binds the user {@code Principal} to the session (so per-user queues and * {@code @MessageMapping} security work). A CONNECT with no token is left anonymous; a CONNECT with an * invalid token is rejected (the verifier throws). + *

+ * The verified {@code userId} and {@code orgId} are also stashed in the STOMP session attributes + * ({@link #USER_ID_ATTRIBUTE}, {@link #ORG_ID_ATTRIBUTE}). This is deliberate, not redundant with + * {@link StompHeaderAccessor#setUser}: empirically, the {@code Principal} set on the CONNECT frame's + * accessor does not carry over to later frames on the same STOMP session (a later + * SUBSCRIBE/UNSUBSCRIBE frame's {@code accessor.getUser()} is {@code null}), whereas session + * attributes are the underlying {@code WebSocketSession}'s own attribute map and do persist across + * every frame. Session-lifecycle listeners (e.g. discovery presence) that need the caller's identity + * outside the CONNECT frame must read it from here, not from {@code accessor.getUser()}. */ @Component @RequiredArgsConstructor @Slf4j public class StompAuthChannelInterceptor implements ChannelInterceptor { + /** STOMP session-attribute key holding the authenticated user id (a {@code String}). */ + public static final String USER_ID_ATTRIBUTE = "reqsai.userId"; + + /** STOMP session-attribute key holding the authenticated tenant/organization id (a {@code String}). */ + public static final String ORG_ID_ATTRIBUTE = "reqsai.orgId"; + private final TokenVerifier tokenVerifier; @Override @@ -43,6 +59,13 @@ public Message preSend(@NonNull Message message, @NonNull MessageChannel c token.userId(), null, token.role() != null ? List.of(new SimpleGrantedAuthority(token.role())) : List.of()); accessor.setUser(authentication); + Map attributes = accessor.getSessionAttributes(); + if (attributes != null) { + attributes.put(USER_ID_ATTRIBUTE, token.userId()); + if (token.orgId() != null) { + attributes.put(ORG_ID_ATTRIBUTE, token.orgId()); + } + } log.debug("WebSocket CONNECT authenticated for user {} (tenant {})", token.userId(), token.orgId()); } diff --git a/src/main/java/com/kntro/reqsai/workspace/api/WorkspaceModuleApi.java b/src/main/java/com/kntro/reqsai/workspace/api/WorkspaceModuleApi.java index a75970df..a6b61c4c 100644 --- a/src/main/java/com/kntro/reqsai/workspace/api/WorkspaceModuleApi.java +++ b/src/main/java/com/kntro/reqsai/workspace/api/WorkspaceModuleApi.java @@ -40,4 +40,17 @@ public interface WorkspaceModuleApi { * {@code orgId} path variable (e.g. discovery's {@code /api/projects/{projectId}/...}). */ boolean callerHasProjectPermission(UUID projectId, UUID userId, String permission); + + /** + * Resolves the roster display name of an active member by organization and user id. Used by + * discovery's live-session presence to label participants without reaching into the workspace + * member internals. Reads the {@code public.members} registry, so it does not require a tenant + * schema to be bound. Returns {@link Optional#empty()} when the user is not an active member of + * the organization. + * + * @param organizationId the tenant/organization id (the JWT {@code orgId}) + * @param userId the authenticated user id (the JWT {@code sub}) + * @return the member's display name, or empty when there is no active membership + */ + Optional findMemberDisplayName(UUID organizationId, UUID userId); } diff --git a/src/main/java/com/kntro/reqsai/workspace/application/service/WorkspaceModuleApiImpl.java b/src/main/java/com/kntro/reqsai/workspace/application/service/WorkspaceModuleApiImpl.java index 265a1e07..9ae7de38 100644 --- a/src/main/java/com/kntro/reqsai/workspace/application/service/WorkspaceModuleApiImpl.java +++ b/src/main/java/com/kntro/reqsai/workspace/application/service/WorkspaceModuleApiImpl.java @@ -5,11 +5,14 @@ import com.kntro.reqsai.workspace.api.WorkspaceModuleApi; import com.kntro.reqsai.shared.infrastructure.persistence.multitenancy.TenantContext; import com.kntro.reqsai.workspace.application.port.GlossaryRepository; +import com.kntro.reqsai.workspace.application.port.MemberRepository; import com.kntro.reqsai.workspace.application.port.OrganizationRepository; import com.kntro.reqsai.workspace.application.port.ProjectRepository; import com.kntro.reqsai.workspace.application.port.WorkspaceSearchRepository; import com.kntro.reqsai.workspace.domain.model.Glossary; import com.kntro.reqsai.workspace.domain.model.GlossaryTerm; +import com.kntro.reqsai.workspace.domain.model.Member; +import com.kntro.reqsai.workspace.domain.model.MemberStatus; import com.kntro.reqsai.workspace.domain.model.Permission; import com.kntro.reqsai.workspace.domain.model.Project; import com.kntro.reqsai.workspace.domain.model.ProjectConstraint; @@ -30,6 +33,7 @@ class WorkspaceModuleApiImpl implements WorkspaceModuleApi { private final WorkspaceSearchRepository searchRepository; private final OrganizationRepository organizations; private final ProjectPermissionService projectPermissions; + private final MemberRepository members; @Override @Transactional(readOnly = true) @@ -86,6 +90,13 @@ public boolean callerHasProjectPermission(UUID projectId, UUID userId, String pe .orElse(false); } + @Override + @Transactional(readOnly = true) + public Optional findMemberDisplayName(UUID organizationId, UUID userId) { + return members.findByOrganizationIdAndUserIdAndStatus(organizationId, userId, MemberStatus.ACTIVE) + .map(Member::getDisplayName); + } + /** The organization bound to the current request/callback thread, or {@code null} when none is. */ private static UUID currentTenantOrgId() { String tenant = TenantContext.getCurrentTenant(); diff --git a/src/main/java/com/kntro/reqsai/workspace/domain/model/Permission.java b/src/main/java/com/kntro/reqsai/workspace/domain/model/Permission.java index 5b70d4a5..b47c8b24 100644 --- a/src/main/java/com/kntro/reqsai/workspace/domain/model/Permission.java +++ b/src/main/java/com/kntro/reqsai/workspace/domain/model/Permission.java @@ -48,5 +48,13 @@ public enum Permission { // User stories (backlog) STORY_READ, - STORY_WRITE + STORY_WRITE, + STORY_DELETE, + + // Third-party integrations (e.g. Jira). Org-level connection administration is gated by the + // org owner/admin check; these project-scoped permissions gate the per-project target + push. + INTEGRATION_READ, + INTEGRATION_WRITE, + INTEGRATION_DELETE, + INTEGRATION_SYNC } diff --git a/src/main/java/com/kntro/reqsai/workspace/interfaces/rest/controllers/OrganizationControllerImpl.java b/src/main/java/com/kntro/reqsai/workspace/interfaces/rest/controllers/OrganizationControllerImpl.java index cb96f9e2..7aa5128f 100644 --- a/src/main/java/com/kntro/reqsai/workspace/interfaces/rest/controllers/OrganizationControllerImpl.java +++ b/src/main/java/com/kntro/reqsai/workspace/interfaces/rest/controllers/OrganizationControllerImpl.java @@ -50,7 +50,7 @@ public ResponseEntity> list(Authentication authentica } @Override - @PreAuthorize("@authz.orgOwner(#orgId, authentication)") + @PreAuthorize("@authz.orgOwnerOrAdmin(#orgId, authentication)") public ResponseEntity getById(UUID orgId, Authentication authentication) { UUID requestedBy = UUID.fromString(authentication.getName()); Organization organization = getOrganization.handle(new GetOrganizationQuery(orgId, requestedBy)); diff --git a/src/main/resources/application-dev.yml b/src/main/resources/application-dev.yml index 66c524ee..fd4c0398 100644 --- a/src/main/resources/application-dev.yml +++ b/src/main/resources/application-dev.yml @@ -28,6 +28,16 @@ spring: pgvector: initialize-schema: true +reqsai: + integrations: + encryption-key: ${INTEGRATIONS_ENCRYPTION_KEY:AAECAwQFBgcICQoLDA0ODxAREhMUFRYXGBkaGxwdHh8=} + jira: + oauth: + client-id: ${JIRA_OAUTH_CLIENT_ID:dev-client-id} + client-secret: ${JIRA_OAUTH_CLIENT_SECRET:dev-client-secret} + redirect-uri: ${JIRA_OAUTH_CALLBACK_URL:http://localhost:4200/integrations/jira/callback} + state-secret: ${JIRA_OAUTH_STATE_SECRET:0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef} + logging: level: com.kntro.reqsai: DEBUG diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index 7f7f2eb6..f5b51bda 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -48,6 +48,13 @@ spring: validate-on-migrate: true out-of-order: true + # Spring Batch runs the async integration sync jobs (Jira import / push-all). Jobs are launched + # on demand by the API — never at startup, hence enabled: false. Metadata DDL is owned by the + # common Flyway migrations (public.batch_*); Boot 4 does not auto-initialize a batch schema. + batch: + job: + enabled: false + mail: host: ${MAIL_HOST:smtp.gmail.com} port: ${MAIL_PORT:587} @@ -154,6 +161,16 @@ reqsai: webhook-secret: ${STRIPE_WEBHOOK_SECRET:} success-url: ${STRIPE_SUCCESS_URL:${WEB_APP_URL:http://localhost:4200}/billing/success} cancel-url: ${STRIPE_CANCEL_URL:${WEB_APP_URL:http://localhost:4200}/billing/cancel} + integrations: + # Base64-encoded 32-byte (AES-256) key used to encrypt third-party integration secrets at rest + # (ADR-0023). Required for the integrations feature; keep it out of source control (.env / secret). + encryption-key: ${INTEGRATIONS_ENCRYPTION_KEY:} + jira: + oauth: + client-id: ${JIRA_OAUTH_CLIENT_ID:} + client-secret: ${JIRA_OAUTH_CLIENT_SECRET:} + redirect-uri: ${JIRA_OAUTH_CALLBACK_URL:${FRONTEND_URL:http://localhost:4200}/settings/integrations/jira/callback} + state-secret: ${JIRA_OAUTH_STATE_SECRET:} jwt: private-key-path: ${JWT_PRIVATE_KEY_PATH:classpath:certs/private_key.pem} private-key-pem: ${JWT_PRIVATE_KEY_PEM:} @@ -181,14 +198,9 @@ reqsai: discovery: realtime: - # Char threshold: generate once this many NEW (past-watermark) transcript chars have accrued. min-transcript-chars: ${DISCOVERY_REALTIME_MIN_TRANSCRIPT_CHARS:180} - # Time fallback: generate when this many seconds have elapsed since the last pass with new - # transcript waiting, even if the char threshold has not been reached (short exchanges stream). max-transcript-age-seconds: ${DISCOVERY_REALTIME_MAX_TRANSCRIPT_AGE_SECONDS:22} context-top-k: ${DISCOVERY_REALTIME_CONTEXT_TOP_K:5} - # Cross-pass near-duplicate cosine threshold: drop a draft this similar to a PENDING suggestion - # or existing story before persisting (below the 0.85 duplicate-story threshold to catch paraphrases). dedup-similarity-threshold: ${DISCOVERY_REALTIME_DEDUP_SIMILARITY_THRESHOLD:0.84} server: @@ -208,7 +220,14 @@ management: endpoints: web: exposure: - include: health,info,metrics,modulith + # Only health is exposed over HTTP. metrics/modulith reveal internal + # architecture and operational data with no legitimate consumer today + # (no Prometheus/scraper — logs go to CloudWatch only); info returns + # nothing useful without management.info.* contributors configured. + # There's no platform-wide "ops" role in this codebase to gate them + # behind (authorization here is entirely tenant/org-scoped) — not + # exposing them at all is simpler and safer than inventing one. + include: health endpoint: health: show-details: when-authorized diff --git a/src/main/resources/db/migration/common/V20260709100001__spring_batch_metadata.sql b/src/main/resources/db/migration/common/V20260709100001__spring_batch_metadata.sql new file mode 100644 index 00000000..f030ceb5 --- /dev/null +++ b/src/main/resources/db/migration/common/V20260709100001__spring_batch_metadata.sql @@ -0,0 +1,86 @@ +-- Spring Batch job-repository metadata (ADR-0023, async integration sync jobs). +-- +-- Copied verbatim from spring-batch-core 6.0.x `org/springframework/batch/core/schema-postgresql.sql`. +-- These tables live in the GLOBAL `public` schema on purpose: the app's DataSource routes +-- connections per tenant by rewriting `search_path`, so unqualified batch DDL/DML could land in +-- whatever tenant schema happens to be active. This migration runs with Flyway's `schemas: public` +-- (objects created in public) and the runtime JobRepository/JobOperator are configured with +-- tablePrefix `public.BATCH_` so every metadata query is schema-qualified regardless of search_path. +-- Domain-facing job state remains the per-tenant `integration_sync_jobs` projection. + +CREATE TABLE BATCH_JOB_INSTANCE ( + JOB_INSTANCE_ID BIGINT NOT NULL PRIMARY KEY, + VERSION BIGINT, + JOB_NAME VARCHAR(100) NOT NULL, + JOB_KEY VARCHAR(32) NOT NULL, + constraint JOB_INST_UN unique (JOB_NAME, JOB_KEY) +); + +CREATE TABLE BATCH_JOB_EXECUTION ( + JOB_EXECUTION_ID BIGINT NOT NULL PRIMARY KEY, + VERSION BIGINT, + JOB_INSTANCE_ID BIGINT NOT NULL, + CREATE_TIME TIMESTAMP NOT NULL, + START_TIME TIMESTAMP DEFAULT NULL, + END_TIME TIMESTAMP DEFAULT NULL, + STATUS VARCHAR(10), + EXIT_CODE VARCHAR(2500), + EXIT_MESSAGE VARCHAR(2500), + LAST_UPDATED TIMESTAMP, + constraint JOB_INST_EXEC_FK foreign key (JOB_INSTANCE_ID) + references BATCH_JOB_INSTANCE(JOB_INSTANCE_ID) +); + +CREATE TABLE BATCH_JOB_EXECUTION_PARAMS ( + JOB_EXECUTION_ID BIGINT NOT NULL, + PARAMETER_NAME VARCHAR(100) NOT NULL, + PARAMETER_TYPE VARCHAR(100) NOT NULL, + PARAMETER_VALUE VARCHAR(2500), + IDENTIFYING CHAR(1) NOT NULL, + constraint JOB_EXEC_PARAMS_FK foreign key (JOB_EXECUTION_ID) + references BATCH_JOB_EXECUTION(JOB_EXECUTION_ID) +); + +CREATE TABLE BATCH_STEP_EXECUTION ( + STEP_EXECUTION_ID BIGINT NOT NULL PRIMARY KEY, + VERSION BIGINT NOT NULL, + STEP_NAME VARCHAR(100) NOT NULL, + JOB_EXECUTION_ID BIGINT NOT NULL, + CREATE_TIME TIMESTAMP NOT NULL, + START_TIME TIMESTAMP DEFAULT NULL, + END_TIME TIMESTAMP DEFAULT NULL, + STATUS VARCHAR(10), + COMMIT_COUNT BIGINT, + READ_COUNT BIGINT, + FILTER_COUNT BIGINT, + WRITE_COUNT BIGINT, + READ_SKIP_COUNT BIGINT, + WRITE_SKIP_COUNT BIGINT, + PROCESS_SKIP_COUNT BIGINT, + ROLLBACK_COUNT BIGINT, + EXIT_CODE VARCHAR(2500), + EXIT_MESSAGE VARCHAR(2500), + LAST_UPDATED TIMESTAMP, + constraint JOB_EXEC_STEP_FK foreign key (JOB_EXECUTION_ID) + references BATCH_JOB_EXECUTION(JOB_EXECUTION_ID) +); + +CREATE TABLE BATCH_STEP_EXECUTION_CONTEXT ( + STEP_EXECUTION_ID BIGINT NOT NULL PRIMARY KEY, + SHORT_CONTEXT VARCHAR(2500) NOT NULL, + SERIALIZED_CONTEXT TEXT, + constraint STEP_EXEC_CTX_FK foreign key (STEP_EXECUTION_ID) + references BATCH_STEP_EXECUTION(STEP_EXECUTION_ID) +); + +CREATE TABLE BATCH_JOB_EXECUTION_CONTEXT ( + JOB_EXECUTION_ID BIGINT NOT NULL PRIMARY KEY, + SHORT_CONTEXT VARCHAR(2500) NOT NULL, + SERIALIZED_CONTEXT TEXT, + constraint JOB_EXEC_CTX_FK foreign key (JOB_EXECUTION_ID) + references BATCH_JOB_EXECUTION(JOB_EXECUTION_ID) +); + +CREATE SEQUENCE BATCH_STEP_EXECUTION_SEQ MAXVALUE 9223372036854775807 NO CYCLE; +CREATE SEQUENCE BATCH_JOB_EXECUTION_SEQ MAXVALUE 9223372036854775807 NO CYCLE; +CREATE SEQUENCE BATCH_JOB_INSTANCE_SEQ MAXVALUE 9223372036854775807 NO CYCLE; diff --git a/src/main/resources/db/migration/tenant/V20260708055818__integration_connections.sql b/src/main/resources/db/migration/tenant/V20260708055818__integration_connections.sql new file mode 100644 index 00000000..b21a8846 --- /dev/null +++ b/src/main/resources/db/migration/tenant/V20260708055818__integration_connections.sql @@ -0,0 +1,23 @@ +-- Third-party integration connections (ADR-0022): ORG-scoped credentials, encrypted at rest. + +CREATE TABLE integration_connections ( + id UUID NOT NULL PRIMARY KEY, + organization_id UUID NOT NULL, + provider VARCHAR(32) NOT NULL, + site_url VARCHAR(500) NOT NULL, + email VARCHAR(320) NOT NULL, + secret_ciphertext BYTEA NOT NULL, + status VARCHAR(32) NOT NULL, + last_verified_at TIMESTAMPTZ, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT now(), + created_by UUID, + updated_by UUID +); + +CREATE INDEX idx_integration_connections_org ON integration_connections (organization_id); + +-- At most one active (non-disconnected) connection per org per provider. +CREATE UNIQUE INDEX uq_integration_connections_active_org_provider + ON integration_connections (organization_id, provider) + WHERE status <> 'DISCONNECTED'; diff --git a/src/main/resources/db/migration/tenant/V20260708055819__project_integration_targets.sql b/src/main/resources/db/migration/tenant/V20260708055819__project_integration_targets.sql new file mode 100644 index 00000000..cc69d4da --- /dev/null +++ b/src/main/resources/db/migration/tenant/V20260708055819__project_integration_targets.sql @@ -0,0 +1,19 @@ +-- Project-scoped Jira push routing (ADR-0022): one target per project, pointing at an org connection. + +CREATE TABLE project_integration_targets ( + id UUID NOT NULL PRIMARY KEY, + project_id UUID NOT NULL REFERENCES projects(id) ON DELETE CASCADE, + connection_id UUID NOT NULL REFERENCES integration_connections(id) ON DELETE CASCADE, + jira_project_key VARCHAR(100) NOT NULL, + issue_type_name VARCHAR(100) NOT NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT now(), + created_by UUID, + updated_by UUID +); + +-- One integration target per project (the PUT .../target endpoint upserts this single row). +CREATE UNIQUE INDEX uq_project_integration_targets_project + ON project_integration_targets (project_id); + +CREATE INDEX idx_project_integration_targets_connection ON project_integration_targets (connection_id); diff --git a/src/main/resources/db/migration/tenant/V20260708055820__integration_connections_oauth.sql b/src/main/resources/db/migration/tenant/V20260708055820__integration_connections_oauth.sql new file mode 100644 index 00000000..93a23d33 --- /dev/null +++ b/src/main/resources/db/migration/tenant/V20260708055820__integration_connections_oauth.sql @@ -0,0 +1,28 @@ +-- Jira Cloud OAuth 2.0 (3LO) support alongside the existing API-token flow (ADR-0022). +-- Additive & backward-compatible: existing rows default to credential_type = 'API_TOKEN' and keep their +-- email + secret_ciphertext. OAuth rows carry cloud_id + encrypted refresh/access tokens instead. +-- +-- Invariant (application-enforced): exactly one credential shape is populated per credential_type — +-- API_TOKEN -> (email, secret_ciphertext) NOT NULL, oauth_* NULL +-- OAUTH2 -> (cloud_id, oauth_refresh_ciphertext) NOT NULL, email + secret_ciphertext NULL +-- site_url stays NOT NULL for both (OAuth sets it from the discovered accessible-resource site URL). + +ALTER TABLE integration_connections + ADD COLUMN credential_type VARCHAR(32) NOT NULL DEFAULT 'API_TOKEN'; + +ALTER TABLE integration_connections + ADD COLUMN cloud_id VARCHAR(64); + +ALTER TABLE integration_connections + ADD COLUMN oauth_refresh_ciphertext BYTEA; + +ALTER TABLE integration_connections + ADD COLUMN oauth_access_ciphertext BYTEA; + +ALTER TABLE integration_connections + ADD COLUMN oauth_access_expires_at TIMESTAMPTZ; + +-- Relax the API-token-only NOT NULL constraints so OAuth connections (no basic-auth email/token) fit the +-- shared table. The one-populated-shape-per-credential_type invariant is enforced in the domain factory. +ALTER TABLE integration_connections ALTER COLUMN email DROP NOT NULL; +ALTER TABLE integration_connections ALTER COLUMN secret_ciphertext DROP NOT NULL; diff --git a/src/main/resources/db/migration/tenant/V20260709100000__integration_sync_jobs.sql b/src/main/resources/db/migration/tenant/V20260709100000__integration_sync_jobs.sql new file mode 100644 index 00000000..71ce14d9 --- /dev/null +++ b/src/main/resources/db/migration/tenant/V20260709100000__integration_sync_jobs.sql @@ -0,0 +1,27 @@ +-- Durable integration sync jobs (ADR-0023): async Jira import / push-all runs persisted per project. +-- The job row is the source of truth for progress; STOMP pushes mirror it and a reload recovers from it. + +CREATE TABLE integration_sync_jobs ( + id UUID NOT NULL PRIMARY KEY, + project_id UUID NOT NULL REFERENCES projects(id) ON DELETE CASCADE, + job_type VARCHAR(16) NOT NULL, -- IMPORT | PUSH_ALL + status VARCHAR(16) NOT NULL, -- RUNNING | COMPLETED | FAILED + total INT NOT NULL DEFAULT 0, + processed INT NOT NULL DEFAULT 0, + succeeded INT NOT NULL DEFAULT 0, + failed INT NOT NULL DEFAULT 0, + message VARCHAR(1000), + requested_by UUID, + finished_at TIMESTAMPTZ, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT now(), + created_by UUID, + updated_by UUID +); + +CREATE INDEX idx_integration_sync_jobs_project_status ON integration_sync_jobs (project_id, status); + +-- At most one RUNNING job per (project, type): a concurrent start gets 409 INTEGRATION_JOB_ALREADY_RUNNING. +CREATE UNIQUE INDEX uq_integration_sync_jobs_running + ON integration_sync_jobs (project_id, job_type) + WHERE status = 'RUNNING'; diff --git a/src/test/java/com/kntro/reqsai/architecture/ArchitectureTests.java b/src/test/java/com/kntro/reqsai/architecture/ArchitectureTests.java index 9f6c4859..0853547f 100644 --- a/src/test/java/com/kntro/reqsai/architecture/ArchitectureTests.java +++ b/src/test/java/com/kntro/reqsai/architecture/ArchitectureTests.java @@ -22,7 +22,8 @@ class ArchitectureTests { "..discovery.domain.exception..", "..workspace.domain.exception..", "..iam.domain.exception..", - "..billing.domain.exception..") + "..billing.domain.exception..", + "..gateway.domain.exception..") .should().dependOnClassesThat().resideInAPackage("org.springframework..") .because("domain layer must be framework-agnostic; " + "shared.domain.model uses Spring Data auditing intentionally, " @@ -39,7 +40,8 @@ class ArchitectureTests { "..workspace.domain.valueobjects..", "..iam.domain.model..", "..billing.domain.model..", - "..billing.domain.model.valueobjects..") + "..billing.domain.model.valueobjects..", + "..gateway.domain.model..") .should().dependOnClassesThat().resideInAPackage("jakarta.persistence..") .because("domain must not depend on JPA — use ports; " + "Active Record pattern exempts model and value-object packages"); diff --git a/src/test/java/com/kntro/reqsai/discovery/application/handler/BatchDeleteUserStoriesCommandHandlerTest.java b/src/test/java/com/kntro/reqsai/discovery/application/handler/BatchDeleteUserStoriesCommandHandlerTest.java new file mode 100644 index 00000000..19dd4c77 --- /dev/null +++ b/src/test/java/com/kntro/reqsai/discovery/application/handler/BatchDeleteUserStoriesCommandHandlerTest.java @@ -0,0 +1,71 @@ +package com.kntro.reqsai.discovery.application.handler; + +import com.kntro.reqsai.discovery.application.command.BatchDeleteUserStoriesCommand; +import com.kntro.reqsai.discovery.application.port.UserStoryRepository; +import com.kntro.reqsai.discovery.domain.model.UserStory; +import com.kntro.reqsai.discovery.mothers.UserStoryMother; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Tag; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import java.util.List; +import java.util.UUID; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +/** + * Unit tests for {@link BatchDeleteUserStoriesCommandHandler}: it deletes only the candidate ids that + * belong to the project, silently skips the rest, and returns the number actually deleted. + */ +@Tag("unit") +@DisplayName("Application: Batch Delete User Stories") +@ExtendWith(MockitoExtension.class) +class BatchDeleteUserStoriesCommandHandlerTest { + + @Mock + private UserStoryRepository stories; + @InjectMocks + private BatchDeleteUserStoriesCommandHandler handler; + + @Test + @DisplayName("deletes only the stories found in the project and returns the deleted count") + void deletes_found_and_skips_missing() { + UUID projectId = UUID.randomUUID(); + UserStory a = UserStoryMother.draft().withProjectId(projectId).build(); + UserStory b = UserStoryMother.draft().withProjectId(projectId).build(); + UUID missing = UUID.randomUUID(); + List requested = List.of(a.getId(), b.getId(), missing); + + // the repository only returns the two ids that belong to the project; the missing id is skipped + when(stories.findAllByProjectIdAndIdIn(projectId, requested)).thenReturn(List.of(a, b)); + + int deleted = handler.handle(new BatchDeleteUserStoriesCommand(projectId, requested)); + + assertThat(deleted).isEqualTo(2); + verify(stories).delete(a); + verify(stories).delete(b); + verify(stories, times(2)).delete(any()); + } + + @Test + @DisplayName("returns zero and deletes nothing when none of the ids are in the project") + void deletes_nothing_when_all_missing() { + UUID projectId = UUID.randomUUID(); + List requested = List.of(UUID.randomUUID(), UUID.randomUUID()); + when(stories.findAllByProjectIdAndIdIn(projectId, requested)).thenReturn(List.of()); + + int deleted = handler.handle(new BatchDeleteUserStoriesCommand(projectId, requested)); + + assertThat(deleted).isZero(); + verify(stories, never()).delete(any()); + } +} diff --git a/src/test/java/com/kntro/reqsai/discovery/application/handler/DeleteUserStoryCommandHandlerTest.java b/src/test/java/com/kntro/reqsai/discovery/application/handler/DeleteUserStoryCommandHandlerTest.java new file mode 100644 index 00000000..f359afef --- /dev/null +++ b/src/test/java/com/kntro/reqsai/discovery/application/handler/DeleteUserStoryCommandHandlerTest.java @@ -0,0 +1,70 @@ +package com.kntro.reqsai.discovery.application.handler; + +import com.kntro.reqsai.discovery.application.command.DeleteUserStoryCommand; +import com.kntro.reqsai.discovery.application.port.UserStoryRepository; +import com.kntro.reqsai.discovery.domain.exception.DiscoveryError; +import com.kntro.reqsai.discovery.domain.model.UserStory; +import com.kntro.reqsai.discovery.mothers.UserStoryMother; +import com.kntro.reqsai.shared.domain.exception.EntityNotFoundException; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Tag; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import java.util.Optional; +import java.util.UUID; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +/** + * Unit tests for {@link DeleteUserStoryCommandHandler} with a mocked repository. Deleting a story is a + * hard delete of the aggregate (its acceptance criteria go with it via cascade); a story that is not in + * the project surfaces as a 404. + */ +@Tag("unit") +@DisplayName("Application: Delete User Story") +@ExtendWith(MockitoExtension.class) +class DeleteUserStoryCommandHandlerTest { + + @Mock + private UserStoryRepository stories; + @InjectMocks + private DeleteUserStoryCommandHandler handler; + + @Test + @DisplayName("deletes the scoped story (acceptance criteria cascade with the aggregate)") + void deletes_scoped_story() { + UUID projectId = UUID.randomUUID(); + UserStory story = UserStoryMother.draft().withProjectId(projectId).build(); + story.addAcceptanceCriterion("scenario", "given", "when", "then"); + when(stories.findByIdAndProjectId(story.getId(), projectId)).thenReturn(Optional.of(story)); + + handler.handle(new DeleteUserStoryCommand(projectId, story.getId())); + + // deleting the aggregate cascades to its acceptance criteria (orphanRemoval on the collection) + assertThat(story.getAcceptanceCriteria()).hasSize(1); + verify(stories).delete(story); + } + + @Test + @DisplayName("throws 404 when the story does not exist in the project; nothing is deleted") + void throws_not_found_when_missing_in_project() { + UUID projectId = UUID.randomUUID(); + UUID storyId = UUID.randomUUID(); + when(stories.findByIdAndProjectId(storyId, projectId)).thenReturn(Optional.empty()); + + assertThatThrownBy(() -> handler.handle(new DeleteUserStoryCommand(projectId, storyId))) + .isInstanceOf(EntityNotFoundException.class) + .satisfies(ex -> assertThat(((EntityNotFoundException) ex).error()) + .isEqualTo(DiscoveryError.USER_STORY_NOT_FOUND)); + verify(stories, never()).delete(any()); + } +} diff --git a/src/test/java/com/kntro/reqsai/discovery/application/notification/RealtimeNotificationIntegrationTest.java b/src/test/java/com/kntro/reqsai/discovery/application/notification/RealtimeNotificationIntegrationTest.java index 699c17da..59ab21da 100644 --- a/src/test/java/com/kntro/reqsai/discovery/application/notification/RealtimeNotificationIntegrationTest.java +++ b/src/test/java/com/kntro/reqsai/discovery/application/notification/RealtimeNotificationIntegrationTest.java @@ -6,8 +6,11 @@ import com.kntro.reqsai.discovery.domain.model.Priority; import com.kntro.reqsai.discovery.interfaces.notification.SessionEventType; import com.kntro.reqsai.discovery.interfaces.notification.messages.SessionProcessingFailedMessage; +import com.kntro.reqsai.discovery.interfaces.notification.messages.SessionRealtimeMessage; import com.kntro.reqsai.discovery.interfaces.notification.messages.SessionStatusChangedMessage; import com.kntro.reqsai.discovery.interfaces.notification.messages.SessionStoryGeneratedMessage; +import com.fasterxml.jackson.databind.DeserializationFeature; +import com.fasterxml.jackson.databind.ObjectMapper; import com.kntro.reqsai.testsupport.AbstractIntegrationTest; import com.kntro.reqsai.testsupport.TestJwtFactory; import org.junit.jupiter.api.AfterEach; @@ -63,6 +66,15 @@ class RealtimeNotificationIntegrationTest extends AbstractIntegrationTest { private static final String USER_ID = "00000000-0000-0000-0000-000000000001"; private static final String ORG_ID = "00000000-0000-0000-0000-0000000000aa"; + /** + * Converts a raw wire frame (Map) into the typed message once its wire {@code type} is confirmed. + * Ignores unknown properties: the wire carries a {@code type} discriminator (and presence frames + * carry {@code participants}/{@code count}) that are not canonical record components. + */ + private static final ObjectMapper WIRE_MAPPER = new ObjectMapper() + .findAndRegisterModules() + .configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false); + @LocalServerPort private int port; @@ -87,7 +99,8 @@ void tearDown() { void should_deliver_status_change() throws Exception { // Arrange UUID sessionId = UUID.randomUUID(); - var received = subscribe(connectAuthenticated(), sessionId, SessionStatusChangedMessage.class); + var received = subscribe(connectAuthenticated(), sessionId, SessionStatusChangedMessage.class, + SessionEventType.RECORDING_STARTED); // Act & Assert SessionStatusChangedMessage msg = awaitFirst(received, @@ -101,7 +114,8 @@ void should_deliver_status_change() throws Exception { void should_deliver_failure_reason() throws Exception { // Arrange UUID sessionId = UUID.randomUUID(); - var received = subscribe(connectAuthenticated(), sessionId, SessionProcessingFailedMessage.class); + var received = subscribe(connectAuthenticated(), sessionId, SessionProcessingFailedMessage.class, + SessionEventType.FAILED); // Act & Assert SessionProcessingFailedMessage msg = awaitFirst(received, @@ -116,7 +130,8 @@ void should_deliver_story_generated() throws Exception { // Arrange UUID sessionId = UUID.randomUUID(); UUID storyId = UUID.randomUUID(); - var received = subscribe(connectAuthenticated(), sessionId, SessionStoryGeneratedMessage.class); + var received = subscribe(connectAuthenticated(), sessionId, SessionStoryGeneratedMessage.class, + SessionEventType.STORY_GENERATED); // Act & Assert SessionStoryGeneratedMessage msg = awaitFirst(received, @@ -161,18 +176,36 @@ private StompSession connect(String authorization) throws Exception { .get(5, TimeUnit.SECONDS); } - private BlockingQueue subscribe(StompSession session, UUID sessionId, Class payloadType) { + /** + * Subscribes and filters incoming frames to {@code expectedType} before enqueuing. A subscriber + * to a session topic can now also receive an automatic {@code PRESENCE_STATE} broadcast (see + * {@code SessionPresenceTracker}) the instant it subscribes — the JSON still decodes cleanly into + * whatever {@code payloadType} the caller asked for (a record's extra unmapped fields are just + * ignored), so without this filter the spurious presence frame would race the real one under test. + */ + private BlockingQueue subscribe( + StompSession session, UUID sessionId, Class payloadType, SessionEventType expectedType) { BlockingQueue queue = new LinkedBlockingQueue<>(); + // Read each frame as a raw Map first: every concrete message hardcodes type() as a fixed + // constant (not a JSON-mapped record component), so force-casting a frame into payloadType makes + // type() lie. In particular the automatic PRESENCE_STATE broadcast (sent the instant a client + // subscribes) would deserialize into e.g. SessionProcessingFailedMessage with type()==FAILED and + // a null reason, defeating a type()-based filter. Discriminate on the WIRE type instead, then + // convert only genuine matches into the typed record. session.subscribe("/topic/" + SessionTopics.of(sessionId), new StompFrameHandler() { @Override @NonNull public Type getPayloadType(@NonNull StompHeaders headers) { - return payloadType; + return java.util.Map.class; } @Override public void handleFrame(@NonNull StompHeaders headers, Object payload) { - queue.add(payloadType.cast(payload)); + @SuppressWarnings("unchecked") + java.util.Map frame = (java.util.Map) payload; + if (expectedType.name().equals(String.valueOf(frame.get("type")))) { + queue.add(WIRE_MAPPER.convertValue(frame, payloadType)); + } } }); return queue; diff --git a/src/test/java/com/kntro/reqsai/discovery/application/notification/SuggestionBroadcastIntegrationTest.java b/src/test/java/com/kntro/reqsai/discovery/application/notification/SuggestionBroadcastIntegrationTest.java index 1caafecb..b94a6f84 100644 --- a/src/test/java/com/kntro/reqsai/discovery/application/notification/SuggestionBroadcastIntegrationTest.java +++ b/src/test/java/com/kntro/reqsai/discovery/application/notification/SuggestionBroadcastIntegrationTest.java @@ -4,6 +4,7 @@ import com.kntro.reqsai.discovery.domain.model.Priority; import com.kntro.reqsai.discovery.domain.model.SuggestionType; import com.kntro.reqsai.discovery.interfaces.notification.SessionEventType; +import com.kntro.reqsai.discovery.interfaces.notification.messages.SessionRealtimeMessage; import com.kntro.reqsai.discovery.interfaces.notification.messages.SessionSuggestionMessage; import com.kntro.reqsai.testsupport.AbstractIntegrationTest; import com.kntro.reqsai.testsupport.TestJwtFactory; @@ -81,7 +82,8 @@ void should_broadcast_suggestion_generated() throws Exception { UUID projectId = UUID.randomUUID(); BlockingQueue received = - subscribe(connectAuthenticated(), sessionId, SessionSuggestionMessage.class); + subscribe(connectAuthenticated(), sessionId, SessionSuggestionMessage.class, + SessionEventType.SUGGESTION_GENERATED); SuggestionCreatedEvent event = new SuggestionCreatedEvent( suggestionId, sessionId, projectId, SuggestionType.NEW_STORY, @@ -111,7 +113,15 @@ private StompSession connectAuthenticated() throws Exception { .get(5, TimeUnit.SECONDS); } - private BlockingQueue subscribe(StompSession session, UUID sessionId, Class payloadType) { + /** + * Subscribes and filters incoming frames to {@code expectedType} before enqueuing. A subscriber + * to a session topic can now also receive an automatic {@code PRESENCE_STATE} broadcast (see + * {@code SessionPresenceTracker}) the instant it subscribes — the JSON still decodes cleanly into + * whatever {@code payloadType} the caller asked for (a record's extra unmapped fields are just + * ignored), so without this filter the spurious presence frame would race the real one under test. + */ + private BlockingQueue subscribe( + StompSession session, UUID sessionId, Class payloadType, SessionEventType expectedType) { BlockingQueue queue = new LinkedBlockingQueue<>(); session.subscribe("/topic/" + SessionTopics.of(sessionId), new StompFrameHandler() { @Override @@ -122,7 +132,10 @@ public Type getPayloadType(@NonNull StompHeaders headers) { @Override public void handleFrame(@NonNull StompHeaders headers, Object payload) { - queue.add(payloadType.cast(payload)); + T message = payloadType.cast(payload); + if (message.type() == expectedType) { + queue.add(message); + } } }); return queue; diff --git a/src/test/java/com/kntro/reqsai/discovery/application/service/DiscoveryStoryWritePortImplTest.java b/src/test/java/com/kntro/reqsai/discovery/application/service/DiscoveryStoryWritePortImplTest.java new file mode 100644 index 00000000..2c9fd76a --- /dev/null +++ b/src/test/java/com/kntro/reqsai/discovery/application/service/DiscoveryStoryWritePortImplTest.java @@ -0,0 +1,163 @@ +package com.kntro.reqsai.discovery.application.service; + +import com.kntro.reqsai.discovery.api.ExternalIssueInput; +import com.kntro.reqsai.discovery.api.ImportedStory; +import com.kntro.reqsai.discovery.api.StoryDuplicateCheck; +import com.kntro.reqsai.discovery.application.handler.CreateUserStoryCommandHandler; +import com.kntro.reqsai.discovery.application.port.GenerationResult; +import com.kntro.reqsai.discovery.application.port.GenerationResult.GeneratedCriterion; +import com.kntro.reqsai.discovery.application.port.GenerationResult.GeneratedStory; +import com.kntro.reqsai.discovery.application.port.RequirementGenerationPort; +import com.kntro.reqsai.discovery.application.port.UserStoryRepository; +import com.kntro.reqsai.discovery.domain.model.Priority; +import com.kntro.reqsai.discovery.domain.model.UserStory; +import com.kntro.reqsai.shared.application.port.EmbeddingPort; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Tag; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.ArgumentCaptor; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import java.util.List; +import java.util.Optional; +import java.util.UUID; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +@Tag("unit") +@ExtendWith(MockitoExtension.class) +@DisplayName("Application: Discovery story write port (external issue import)") +class DiscoveryStoryWritePortImplTest { + + private static final UUID PROJECT = UUID.randomUUID(); + + @Mock + private RequirementGenerationPort generationPort; + @Mock + private UserStoryRepository stories; + @Mock + private EmbeddingPort embeddingPort; + + private DiscoveryStoryWritePortImpl port; + + @BeforeEach + void setUp() { + UserStoryDeduplicationService dedup = new UserStoryDeduplicationService(stories, embeddingPort); + CreateUserStoryCommandHandler createHandler = new CreateUserStoryCommandHandler(stories, dedup); + port = new DiscoveryStoryWritePortImpl(generationPort, createHandler, stories, embeddingPort); + } + + @Test + @DisplayName("LLM path: uses the generated role/action/benefit + criteria and creates the story") + void llm_path_creates_structured_story() { + when(generationPort.isAvailable()).thenReturn(true); + when(generationPort.generate(any(), any())).thenReturn(new GenerationResult(List.of( + new GeneratedStory("Login con Google", "usuario registrado", + "iniciar sesión con Google", "no recordar otra contraseña", + Priority.HIGH, 3, + List.of(new GeneratedCriterion("ok", "en login", "click Google", "redirige a OAuth")))))); + when(stories.save(any())).thenAnswer(i -> i.getArgument(0)); + // embeddingPort.isAvailable() defaults to false -> dedup skipped + + ImportedStory result = port.importFromExternalIssue(new ExternalIssueInput( + PROJECT, "Login con Google", "Como usuario quiero entrar con Google", "es-PE")); + + assertThat(result.status()).isEqualTo(ImportedStory.Status.CREATED); + ArgumentCaptor saved = ArgumentCaptor.forClass(UserStory.class); + verify(stories, org.mockito.Mockito.atLeastOnce()).save(saved.capture()); + UserStory story = saved.getValue(); + assertThat(story.getTitle()).isEqualTo("Login con Google"); + assertThat(story.getRole()).isEqualTo("usuario registrado"); + assertThat(story.getAction()).isEqualTo("iniciar sesión con Google"); + assertThat(story.getAcceptanceCriteria()).hasSize(1); + } + + @Test + @DisplayName("fallback path: no LLM configured -> safe deterministic mapping still satisfies validation") + void fallback_path_when_llm_unavailable() { + when(generationPort.isAvailable()).thenReturn(false); + when(stories.save(any())).thenAnswer(i -> i.getArgument(0)); + + ImportedStory result = port.importFromExternalIssue(new ExternalIssueInput( + PROJECT, "Bulk CSV import", "Upload a CSV to seed the backlog", null)); + + assertThat(result.status()).isEqualTo(ImportedStory.Status.CREATED); + ArgumentCaptor saved = ArgumentCaptor.forClass(UserStory.class); + verify(stories).save(saved.capture()); + UserStory story = saved.getValue(); + assertThat(story.getTitle()).isEqualTo("Bulk CSV import"); + assertThat(story.getRole()).isNotBlank(); + assertThat(story.getAction()).isNotBlank(); + assertThat(story.getBenefit()).contains("Upload a CSV"); + } + + @Test + @DisplayName("fallback path: generation failure falls back rather than aborting the import") + void fallback_when_generation_throws() { + when(generationPort.isAvailable()).thenReturn(true); + when(generationPort.generate(any(), any())).thenThrow(new RuntimeException("model timeout")); + when(stories.save(any())).thenAnswer(i -> i.getArgument(0)); + + ImportedStory result = port.importFromExternalIssue(new ExternalIssueInput( + PROJECT, "Password reset", "As a user I want to reset my password", "en-US")); + + assertThat(result.status()).isEqualTo(ImportedStory.Status.CREATED); + verify(stories).save(any()); + } + + @Test + @DisplayName("duplicate: create hits the dedup gate -> reported as DUPLICATE with the existing story id") + void duplicate_is_reported_not_created() { + UUID existing = UUID.randomUUID(); + when(generationPort.isAvailable()).thenReturn(false); + when(embeddingPort.isAvailable()).thenReturn(true); + when(embeddingPort.embed(any())).thenReturn(new float[EmbeddingPort.DIMENSIONS]); + when(stories.findMostSimilar(any(), any())) + .thenReturn(Optional.of(new UserStoryRepository.SimilarStory(existing, 0.93))); + + ImportedStory result = port.importFromExternalIssue(new ExternalIssueInput( + PROJECT, "Duplicate story", "same as an existing one", null)); + + assertThat(result.status()).isEqualTo(ImportedStory.Status.DUPLICATE); + assertThat(result.existingStoryId()).isEqualTo(existing); + assertThat(result.similarity()).isEqualTo(0.93); + verify(stories, never()).save(any()); + } + + @Test + @DisplayName("checkDuplicate: flags a near-duplicate without creating anything") + void check_duplicate_flags_without_creating() { + UUID existing = UUID.randomUUID(); + // checkDuplicate deliberately never consults the LLM (deterministic mapping only) — no generation stub. + when(embeddingPort.isAvailable()).thenReturn(true); + when(embeddingPort.embed(any())).thenReturn(new float[EmbeddingPort.DIMENSIONS]); + when(stories.findMostSimilar(any(), any())) + .thenReturn(Optional.of(new UserStoryRepository.SimilarStory(existing, 0.88))); + + StoryDuplicateCheck check = port.checkDuplicate(new ExternalIssueInput( + PROJECT, "Maybe duplicate", "similar", null)); + + assertThat(check.duplicate()).isTrue(); + assertThat(check.existingStoryId()).isEqualTo(existing); + verify(stories, never()).save(any()); + } + + @Test + @DisplayName("checkDuplicate: returns not-duplicate when the embedding model is unavailable") + void check_duplicate_no_embedding() { + when(embeddingPort.isAvailable()).thenReturn(false); + + StoryDuplicateCheck check = port.checkDuplicate(new ExternalIssueInput( + PROJECT, "Anything", "x", null)); + + assertThat(check.duplicate()).isFalse(); + assertThat(check.existingStoryId()).isNull(); + } +} diff --git a/src/test/java/com/kntro/reqsai/discovery/interfaces/notification/messages/SessionRealtimeMessageSerializationTest.java b/src/test/java/com/kntro/reqsai/discovery/interfaces/notification/messages/SessionRealtimeMessageSerializationTest.java new file mode 100644 index 00000000..6559394c --- /dev/null +++ b/src/test/java/com/kntro/reqsai/discovery/interfaces/notification/messages/SessionRealtimeMessageSerializationTest.java @@ -0,0 +1,87 @@ +package com.kntro.reqsai.discovery.interfaces.notification.messages; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule; +import com.kntro.reqsai.discovery.domain.model.Priority; +import com.kntro.reqsai.discovery.domain.model.SessionStatus; +import com.kntro.reqsai.discovery.domain.model.SuggestionStatus; +import com.kntro.reqsai.discovery.domain.model.SuggestionType; +import com.kntro.reqsai.discovery.interfaces.notification.SessionEventType; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.MethodSource; + +import java.time.Instant; +import java.util.List; +import java.util.UUID; +import java.util.stream.Stream; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * Guards the wire contract every {@link SessionRealtimeMessage} implementation must honor: the + * serialized JSON must carry a {@code "type"} field matching {@link SessionRealtimeMessage#type()}. + *

+ * This is not redundant with the per-message unit tests: those inspect the Java object directly and + * would pass even if {@code type} were silently dropped from the JSON. That exact bug shipped once — + * {@code SessionPresenceMessage} overrode {@code type()} without a canonical record component or a + * {@code @JsonProperty("type")} annotation, so Jackson's record serializer (which only emits canonical + * components) omitted it entirely. The client's {@code message.type === 'PRESENCE_STATE'} switch then + * silently never matched, even though the message otherwise arrived correctly. Only a real + * {@link ObjectMapper} pass over the actual JSON output can catch this class of bug. + */ +class SessionRealtimeMessageSerializationTest { + + private final ObjectMapper mapper = new ObjectMapper().registerModule(new JavaTimeModule()); + + @ParameterizedTest(name = "{0} serializes type={1}") + @MethodSource("messages") + @DisplayName("every SessionRealtimeMessage serializes a matching \"type\" field") + void serializesTypeField(SessionRealtimeMessage message, SessionEventType expectedType) throws Exception { + String json = mapper.writeValueAsString(message); + + assertThat(json).contains("\"type\":\"" + expectedType.name() + "\""); + var node = mapper.readTree(json); + assertThat(node.get("type").asText()).isEqualTo(expectedType.name()); + } + + static Stream messages() { + UUID sessionId = UUID.randomUUID(); + Instant now = Instant.now(); + return Stream.of( + org.junit.jupiter.params.provider.Arguments.of( + SessionPresenceMessage.of(sessionId, List.of(), now), SessionEventType.PRESENCE_STATE), + org.junit.jupiter.params.provider.Arguments.of( + SessionStatusChangedMessage.of(sessionId, SessionEventType.RECORDING_STARTED, now), + SessionEventType.RECORDING_STARTED), + org.junit.jupiter.params.provider.Arguments.of( + new SessionProcessingFailedMessage(sessionId, "boom", now), SessionEventType.FAILED), + org.junit.jupiter.params.provider.Arguments.of( + new SessionStoryGeneratedMessage(sessionId, UUID.randomUUID(), "Title", "role", "action", + "benefit", Priority.MEDIUM, null, now), + SessionEventType.STORY_GENERATED), + org.junit.jupiter.params.provider.Arguments.of( + new SessionTranscriptSegmentMessage(sessionId, 0, null, "text", 0L, 100L, true, now), + SessionEventType.TRANSCRIPT_SEGMENT), + org.junit.jupiter.params.provider.Arguments.of( + new SessionSuggestionMessage(sessionId, UUID.randomUUID(), SessionEventType.SUGGESTION_GENERATED, + SuggestionType.NEW_STORY, SuggestionStatus.PENDING, null, null, null, null, null, null, + null, null, null, List.of(), null, now), + SessionEventType.SUGGESTION_GENERATED), + org.junit.jupiter.params.provider.Arguments.of( + new SessionLifecycleMessage(sessionId, UUID.randomUUID(), SessionEventType.SESSION_CREATED, + SessionStatus.DRAFT, "Title", "es-PE", null, now), + SessionEventType.SESSION_CREATED) + ); + } + + @Test + @DisplayName("regression: SessionPresenceMessage.type() carries the required @JsonProperty (else Jackson drops it)") + void presenceMessageTypeAccessorIsAnnotated() throws Exception { + var method = SessionPresenceMessage.class.getMethod("type"); + assertThat(method.isAnnotationPresent(com.fasterxml.jackson.annotation.JsonProperty.class)) + .as("type() must be @JsonProperty(\"type\") annotated since it is not a canonical record component") + .isTrue(); + } +} diff --git a/src/test/java/com/kntro/reqsai/discovery/interfaces/rest/DiscoveryAccessControlIntegrationTest.java b/src/test/java/com/kntro/reqsai/discovery/interfaces/rest/DiscoveryAccessControlIntegrationTest.java index 0ef5d480..57a3d4d1 100644 --- a/src/test/java/com/kntro/reqsai/discovery/interfaces/rest/DiscoveryAccessControlIntegrationTest.java +++ b/src/test/java/com/kntro/reqsai/discovery/interfaces/rest/DiscoveryAccessControlIntegrationTest.java @@ -106,8 +106,74 @@ void discovery_endpoints_enforce_project_permissions() { .isEqualTo(HttpStatus.NOT_FOUND); } + @Test + @DisplayName("story delete + batch-delete require STORY_DELETE: a writer without it gets 403; with it 204/200") + void story_delete_endpoints_enforce_story_delete_permission() { + String suffix = UUID.randomUUID().toString().substring(0, 8); + String slug = "acme-" + suffix; + String schema = "tenant_" + slug; + UUID orgId = createOrganizationAndReturnId(suffix, slug); + + createMember(orgId, Map.of( + "userId", READER_USER_ID, "email", "writer@example.com", "displayName", "Writer", "role", "MEMBER")); + UUID writerId = memberId(orgId, "writer@example.com"); + + UUID projectId = createProjectAndReturnId(orgId, slug); + // A writer role that can create/edit but NOT delete stories. + String writerRoleId = createRoleAndReturnId(orgId, projectId, "Story Writer", + List.of("STORY_READ", "STORY_WRITE"), schema); + assignMember(orgId, projectId, writerId.toString(), writerRoleId); + + // Owner seeds two distinct stories (owner bypasses the gates). + UUID story1 = createStory(orgId, projectId, "Bulk import suppliers via CSV upload"); + UUID story2 = createStory(orgId, projectId, "Export the monthly compliance audit report"); + + // Writer lacks STORY_DELETE -> single delete and batch-delete are both forbidden. + assertThat(delete(READER_USER_ID, orgId, "/api/projects/" + projectId + "/stories/" + story1).getStatusCode()) + .isEqualTo(HttpStatus.FORBIDDEN); + assertThat(post(READER_USER_ID, orgId, "/api/projects/" + projectId + "/stories/batch-delete", + Map.of("storyIds", List.of(story1, story2))).getStatusCode()) + .isEqualTo(HttpStatus.FORBIDDEN); + + // Grant STORY_DELETE by updating the role. + put(OWNER_USER_ID, orgId, + "/api/organizations/" + orgId + "/projects/" + projectId + "/roles/" + writerRoleId, + Map.of("name", "Story Writer", "permissions", List.of("STORY_READ", "STORY_WRITE", "STORY_DELETE"))); + + // Now the single delete succeeds (204) and the batch-delete of the remaining story returns 200 {deleted:1}. + assertThat(delete(READER_USER_ID, orgId, "/api/projects/" + projectId + "/stories/" + story1).getStatusCode()) + .isEqualTo(HttpStatus.NO_CONTENT); + ResponseEntity batch = post(READER_USER_ID, orgId, + "/api/projects/" + projectId + "/stories/batch-delete", + Map.of("storyIds", List.of(story2, UUID.randomUUID()))); + assertThat(batch.getStatusCode()).isEqualTo(HttpStatus.OK); + assertThat(batch.getBody()).contains("\"deleted\":1"); + } + // ----- helpers ----- + private UUID createStory(UUID orgId, UUID projectId, String action) { + ResponseEntity res = post(OWNER_USER_ID, orgId, "/api/projects/" + projectId + "/stories", + Map.of("title", action, "role", "user", "action", action, "benefit", "access", "priority", "HIGH")); + assertThat(res.getStatusCode()).isEqualTo(HttpStatus.CREATED); + return UUID.fromString(res.getBody().split("\"id\":\"")[1].split("\"")[0]); + } + + private ResponseEntity delete(String userId, UUID orgId, String uri) { + return client().method(org.springframework.http.HttpMethod.DELETE).uri(uri) + .header("Authorization", TestJwtFactory.bearer(userId, orgId.toString(), "ROLE_USER")) + .header("Api-Version", "1") + .exchange((req, res) -> ResponseEntity.status(res.getStatusCode()).body(res.bodyTo(String.class))); + } + + private ResponseEntity put(String userId, UUID orgId, String uri, Map body) { + return client().put().uri(uri) + .header("Authorization", TestJwtFactory.bearer(userId, orgId.toString(), "ROLE_USER")) + .header("Api-Version", "1") + .contentType(MediaType.APPLICATION_JSON).body(body) + .exchange((req, res) -> ResponseEntity.status(res.getStatusCode()).body(res.bodyTo(String.class))); + } + private ResponseEntity get(String userId, UUID orgId, String uri) { return client().get().uri(uri) .header("Authorization", TestJwtFactory.bearer(userId, orgId.toString(), "ROLE_USER")) diff --git a/src/test/java/com/kntro/reqsai/discovery/interfaces/websocket/presence/SessionParticipantResolverTest.java b/src/test/java/com/kntro/reqsai/discovery/interfaces/websocket/presence/SessionParticipantResolverTest.java new file mode 100644 index 00000000..95c42fa6 --- /dev/null +++ b/src/test/java/com/kntro/reqsai/discovery/interfaces/websocket/presence/SessionParticipantResolverTest.java @@ -0,0 +1,66 @@ +package com.kntro.reqsai.discovery.interfaces.websocket.presence; + +import com.kntro.reqsai.discovery.interfaces.notification.messages.SessionParticipant; +import com.kntro.reqsai.workspace.api.WorkspaceModuleApi; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import java.util.Optional; +import java.util.UUID; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +@DisplayName("Presence: participant resolver") +@ExtendWith(MockitoExtension.class) +class SessionParticipantResolverTest { + + @Mock + private WorkspaceModuleApi workspace; + + private final UUID orgId = UUID.randomUUID(); + private final UUID userId = UUID.randomUUID(); + + @Test + @DisplayName("resolves the member name and a deterministic avatar url") + void resolvesNameAndAvatar() { + var resolver = new SessionParticipantResolver(workspace); + when(workspace.findMemberDisplayName(orgId, userId)).thenReturn(Optional.of("Ana Torres")); + + SessionParticipant participant = resolver.resolve(orgId, userId); + + assertThat(participant.userId()).isEqualTo(userId); + assertThat(participant.displayName()).isEqualTo("Ana Torres"); + assertThat(participant.avatarUrl()).isEqualTo("/api/users/" + userId + "/avatar"); + } + + @Test + @DisplayName("caches the display name so repeated resolves hit the workspace once") + void cachesDisplayName() { + var resolver = new SessionParticipantResolver(workspace); + when(workspace.findMemberDisplayName(orgId, userId)).thenReturn(Optional.of("Ana Torres")); + + resolver.resolve(orgId, userId); + resolver.resolve(orgId, userId); + resolver.resolve(orgId, userId); + + verify(workspace, times(1)).findMemberDisplayName(orgId, userId); + } + + @Test + @DisplayName("falls back to a generic label when the membership cannot be resolved") + void fallsBackWhenUnknown() { + var resolver = new SessionParticipantResolver(workspace); + when(workspace.findMemberDisplayName(orgId, userId)).thenReturn(Optional.empty()); + + SessionParticipant participant = resolver.resolve(orgId, userId); + + assertThat(participant.displayName()).isEqualTo(SessionParticipantResolver.UNKNOWN_DISPLAY_NAME); + assertThat(participant.avatarUrl()).isEqualTo("/api/users/" + userId + "/avatar"); + } +} diff --git a/src/test/java/com/kntro/reqsai/discovery/interfaces/websocket/presence/SessionPresenceRegistryTest.java b/src/test/java/com/kntro/reqsai/discovery/interfaces/websocket/presence/SessionPresenceRegistryTest.java new file mode 100644 index 00000000..77cc6532 --- /dev/null +++ b/src/test/java/com/kntro/reqsai/discovery/interfaces/websocket/presence/SessionPresenceRegistryTest.java @@ -0,0 +1,95 @@ +package com.kntro.reqsai.discovery.interfaces.websocket.presence; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import java.util.UUID; + +import static org.assertj.core.api.Assertions.assertThat; + +/** Unit tests for the presence bookkeeping: join/leave transitions, multi-tab dedup and disconnect. */ +class SessionPresenceRegistryTest { + + private final SessionPresenceRegistry registry = new SessionPresenceRegistry(); + + private final UUID session = UUID.randomUUID(); + private final UUID alice = UUID.randomUUID(); + private final UUID bob = UUID.randomUUID(); + + @Test + @DisplayName("first subscribe makes the user present and reports a roster change") + void firstSubscribeAddsUser() { + boolean changed = registry.join(session, "stomp-1", "sub-1", alice); + + assertThat(changed).isTrue(); + assertThat(registry.roster(session)).containsExactly(alice); + } + + @Test + @DisplayName("distinct users accumulate in the roster") + void distinctUsersAccumulate() { + registry.join(session, "stomp-1", "sub-1", alice); + boolean changed = registry.join(session, "stomp-2", "sub-1", bob); + + assertThat(changed).isTrue(); + assertThat(registry.roster(session)).containsExactly(alice, bob); + } + + @Test + @DisplayName("same user on a second tab does not change the visible roster") + void secondTabIsDeduped() { + registry.join(session, "stomp-1", "sub-1", alice); + boolean changed = registry.join(session, "stomp-2", "sub-1", alice); + + assertThat(changed).isFalse(); + assertThat(registry.roster(session)).containsExactly(alice); + } + + @Test + @DisplayName("unsubscribing the last subscription removes the user and reports the change") + void unsubscribeRemovesUser() { + registry.join(session, "stomp-1", "sub-1", alice); + + var affected = registry.leaveSubscription("stomp-1", "sub-1"); + + assertThat(affected).contains(session); + assertThat(registry.roster(session)).isEmpty(); + } + + @Test + @DisplayName("a user stays present until their last tab leaves") + void userStaysUntilLastTabLeaves() { + registry.join(session, "stomp-1", "sub-1", alice); + registry.join(session, "stomp-2", "sub-1", alice); + + var firstLeave = registry.leaveSubscription("stomp-1", "sub-1"); + assertThat(firstLeave).isEmpty(); + assertThat(registry.roster(session)).containsExactly(alice); + + var lastLeave = registry.leaveSubscription("stomp-2", "sub-1"); + assertThat(lastLeave).contains(session); + assertThat(registry.roster(session)).isEmpty(); + } + + @Test + @DisplayName("disconnect drops the connection from every session it viewed") + void disconnectDropsFromAllSessions() { + UUID otherSession = UUID.randomUUID(); + registry.join(session, "stomp-1", "sub-1", alice); + registry.join(otherSession, "stomp-1", "sub-2", alice); + registry.join(session, "stomp-2", "sub-1", bob); + + var affected = registry.disconnect("stomp-1"); + + assertThat(affected).containsExactlyInAnyOrder(session, otherSession); + assertThat(registry.roster(session)).containsExactly(bob); + assertThat(registry.roster(otherSession)).isEmpty(); + } + + @Test + @DisplayName("unknown subscription/connection is a no-op") + void unknownIsNoOp() { + assertThat(registry.leaveSubscription("ghost", "sub-1")).isEmpty(); + assertThat(registry.disconnect("ghost")).isEmpty(); + } +} diff --git a/src/test/java/com/kntro/reqsai/discovery/interfaces/websocket/presence/SessionPresenceTrackerTest.java b/src/test/java/com/kntro/reqsai/discovery/interfaces/websocket/presence/SessionPresenceTrackerTest.java new file mode 100644 index 00000000..a9de69e3 --- /dev/null +++ b/src/test/java/com/kntro/reqsai/discovery/interfaces/websocket/presence/SessionPresenceTrackerTest.java @@ -0,0 +1,170 @@ +package com.kntro.reqsai.discovery.interfaces.websocket.presence; + +import com.kntro.reqsai.discovery.application.notification.SessionTopics; +import com.kntro.reqsai.discovery.interfaces.notification.messages.SessionParticipant; +import com.kntro.reqsai.discovery.interfaces.notification.messages.SessionPresenceMessage; +import com.kntro.reqsai.shared.application.notification.RealtimeNotifier; +import com.kntro.reqsai.shared.infrastructure.web.websocket.StompAuthChannelInterceptor; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.ArgumentCaptor; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.messaging.Message; +import org.springframework.messaging.simp.stomp.StompCommand; +import org.springframework.messaging.simp.stomp.StompHeaderAccessor; +import org.springframework.messaging.support.MessageBuilder; +import org.springframework.web.socket.messaging.SessionDisconnectEvent; +import org.springframework.web.socket.messaging.SessionSubscribeEvent; +import org.springframework.web.socket.messaging.SessionUnsubscribeEvent; + +import java.util.HashMap; +import java.util.Map; +import java.util.UUID; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +@DisplayName("Presence: STOMP tracker") +@ExtendWith(MockitoExtension.class) +class SessionPresenceTrackerTest { + + @Mock + private SessionParticipantResolver resolver; + + @Mock + private RealtimeNotifier notifier; + + private SessionPresenceTracker tracker; + + private final UUID sessionId = UUID.randomUUID(); + private final UUID orgId = UUID.randomUUID(); + private final UUID alice = UUID.randomUUID(); + + @BeforeEach + void setUp() { + tracker = new SessionPresenceTracker(new SessionPresenceRegistry(), resolver, notifier); + } + + @Test + @DisplayName("a subscribe to a session topic broadcasts the roster") + void subscribeBroadcastsRoster() { + when(resolver.resolve(orgId, alice)) + .thenReturn(new SessionParticipant(alice, "Ana", "/api/users/" + alice + "/avatar")); + + tracker.onSubscribe(subscribe("stomp-1", "sub-1", "/topic/" + SessionTopics.of(sessionId))); + + SessionPresenceMessage message = captureBroadcast(); + assertThat(message.sessionId()).isEqualTo(sessionId); + assertThat(message.count()).isEqualTo(1); + assertThat(message.participants()).singleElement() + .satisfies(p -> assertThat(p.displayName()).isEqualTo("Ana")); + } + + @Test + @DisplayName("a subscribe to an unrelated destination is ignored") + void ignoresUnrelatedDestination() { + tracker.onSubscribe(subscribe("stomp-1", "sub-1", "/topic/projects/" + UUID.randomUUID() + "/sessions")); + + verify(notifier, never()).broadcast(any(), any()); + } + + @Test + @DisplayName("a subscribe with no user-id session attribute is ignored, even with a Principal on the frame") + void ignoresSubscribeMissingUserIdAttribute() { + // Regression test: the STOMP Principal set on CONNECT does not carry over to later frames in + // practice (verified against a real Spring STOMP session), so the tracker must not depend on + // accessor.getUser() — only on the session-attribute identity stashed by the auth interceptor. + StompHeaderAccessor accessor = StompHeaderAccessor.create(StompCommand.SUBSCRIBE); + accessor.setSessionId("stomp-1"); + accessor.setSubscriptionId("sub-1"); + accessor.setDestination("/topic/" + SessionTopics.of(sessionId)); + Map attributesWithoutUserId = new HashMap<>(); + attributesWithoutUserId.put(StompAuthChannelInterceptor.ORG_ID_ATTRIBUTE, orgId.toString()); + accessor.setSessionAttributes(attributesWithoutUserId); + accessor.setUser(new org.springframework.security.authentication.UsernamePasswordAuthenticationToken(alice.toString(), null)); + Message message = MessageBuilder.createMessage(new byte[0], accessor.getMessageHeaders()); + + tracker.onSubscribe(new SessionSubscribeEvent(this, message, null)); + + verify(notifier, never()).broadcast(any(), any()); + } + + @Test + @DisplayName("unsubscribing the last subscription rebroadcasts an empty roster") + void unsubscribeBroadcastsEmptyRoster() { + when(resolver.resolve(orgId, alice)) + .thenReturn(new SessionParticipant(alice, "Ana", "/api/users/" + alice + "/avatar")); + tracker.onSubscribe(subscribe("stomp-1", "sub-1", "/topic/" + SessionTopics.of(sessionId))); + + tracker.onUnsubscribe(unsubscribe("stomp-1", "sub-1")); + + ArgumentCaptor payload = ArgumentCaptor.forClass(Object.class); + verify(notifier, org.mockito.Mockito.atLeast(2)).broadcast(eq(SessionTopics.of(sessionId)), payload.capture()); + SessionPresenceMessage last = (SessionPresenceMessage) payload.getValue(); + assertThat(last.count()).isZero(); + } + + @Test + @DisplayName("disconnect rebroadcasts an empty roster") + void disconnectBroadcastsEmptyRoster() { + when(resolver.resolve(orgId, alice)) + .thenReturn(new SessionParticipant(alice, "Ana", "/api/users/" + alice + "/avatar")); + tracker.onSubscribe(subscribe("stomp-1", "sub-1", "/topic/" + SessionTopics.of(sessionId))); + + tracker.onDisconnect(disconnect("stomp-1")); + + // Last captured broadcast is the empty roster after the disconnect. + ArgumentCaptor payload = ArgumentCaptor.forClass(Object.class); + verify(notifier, org.mockito.Mockito.atLeast(2)).broadcast(eq(SessionTopics.of(sessionId)), payload.capture()); + SessionPresenceMessage last = (SessionPresenceMessage) payload.getValue(); + assertThat(last.count()).isZero(); + assertThat(last.participants()).isEmpty(); + } + + private SessionPresenceMessage captureBroadcast() { + ArgumentCaptor payload = ArgumentCaptor.forClass(Object.class); + verify(notifier).broadcast(eq(SessionTopics.of(sessionId)), payload.capture()); + return (SessionPresenceMessage) payload.getValue(); + } + + private SessionSubscribeEvent subscribe(String stompSessionId, String subscriptionId, String destination) { + StompHeaderAccessor accessor = StompHeaderAccessor.create(StompCommand.SUBSCRIBE); + accessor.setSessionId(stompSessionId); + accessor.setSubscriptionId(subscriptionId); + accessor.setDestination(destination); + accessor.setSessionAttributes(sessionAttributes()); + Message message = MessageBuilder.createMessage(new byte[0], accessor.getMessageHeaders()); + return new SessionSubscribeEvent(this, message, null); + } + + private SessionUnsubscribeEvent unsubscribe(String stompSessionId, String subscriptionId) { + StompHeaderAccessor accessor = StompHeaderAccessor.create(StompCommand.UNSUBSCRIBE); + accessor.setSessionId(stompSessionId); + accessor.setSubscriptionId(subscriptionId); + accessor.setSessionAttributes(sessionAttributes()); + Message message = MessageBuilder.createMessage(new byte[0], accessor.getMessageHeaders()); + return new SessionUnsubscribeEvent(this, message, null); + } + + private SessionDisconnectEvent disconnect(String stompSessionId) { + StompHeaderAccessor accessor = StompHeaderAccessor.create(StompCommand.DISCONNECT); + accessor.setSessionId(stompSessionId); + accessor.setSessionAttributes(sessionAttributes()); + Message message = MessageBuilder.createMessage(new byte[0], accessor.getMessageHeaders()); + return new SessionDisconnectEvent(this, message, stompSessionId, null); + } + + private Map sessionAttributes() { + Map attributes = new HashMap<>(); + attributes.put(StompAuthChannelInterceptor.USER_ID_ATTRIBUTE, alice.toString()); + attributes.put(StompAuthChannelInterceptor.ORG_ID_ATTRIBUTE, orgId.toString()); + return attributes; + } +} diff --git a/src/test/java/com/kntro/reqsai/gateway/StubJiraOAuthConfig.java b/src/test/java/com/kntro/reqsai/gateway/StubJiraOAuthConfig.java new file mode 100644 index 00000000..65d50b14 --- /dev/null +++ b/src/test/java/com/kntro/reqsai/gateway/StubJiraOAuthConfig.java @@ -0,0 +1,87 @@ +package com.kntro.reqsai.gateway; + +import com.kntro.reqsai.gateway.application.port.JiraOAuthPort; +import com.kntro.reqsai.gateway.infrastructure.jira.JiraClient; +import org.springframework.boot.test.context.TestConfiguration; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Primary; + +import java.util.List; +import java.util.Map; +import java.util.concurrent.CopyOnWriteArrayList; + +/** + * Stubs the Atlassian/Jira HTTP boundary for the OAuth integration test WITHOUT touching the network: + *
    + *
  • {@link JiraOAuthPort} — canned code exchange (fixed access/refresh + short expiry) and a single + * accessible site {@code cloud-1 / https://acme.atlassian.net}.
  • + *
  • {@link JiraClient} — a recording subclass that returns canned verify/project/issue-type/create + * results and CAPTURES the {@code apiBase} of every call, so the test can assert an OAUTH2 push + * routes to the {@code https://api.atlassian.com/ex/jira/{cloudId}/rest/api/3} base.
  • + *
+ * The real {@code JiraProvider}, {@code ProviderCredentialsFactory}, {@code JiraOAuthTokenService} and + * the callback handler run end-to-end so encryption + persistence + dual-mode routing are exercised. + */ +@TestConfiguration +public class StubJiraOAuthConfig { + + /** Records every API base URL the client was called with (for routing assertions). */ + public static final class RecordingJiraClient extends JiraClient { + public final List apiBases = new CopyOnWriteArrayList<>(); + + @Override + public String verify(JiraApiContext ctx) { + apiBases.add(ctx.apiBase()); + return "Stub OAuth Admin"; + } + + @Override + public List listProjects(JiraApiContext ctx) { + apiBases.add(ctx.apiBase()); + return List.of(new JiraProject("PAY", "Payments")); + } + + @Override + public List listIssueTypes(JiraApiContext ctx, String projectKey) { + apiBases.add(ctx.apiBase()); + return List.of(new JiraIssueType("10001", "Story")); + } + + @Override + public CreatedIssue createIssue(JiraApiContext ctx, String projectKey, String issueTypeName, + String summary, Map descriptionAdf, + String requiredFieldFallbackText) { + apiBases.add(ctx.apiBase()); + String key = projectKey + "-42"; + return new CreatedIssue("42", key, ctx.apiBase() + "/issue/" + key); + } + } + + @Bean + @Primary + public JiraClient recordingJiraClient() { + return new RecordingJiraClient(); + } + + @Bean + @Primary + public JiraOAuthPort stubJiraOAuthPort() { + return new JiraOAuthPort() { + @Override + public OAuthTokens exchangeCode(String code) { + // Short-lived access token so a subsequent push exercises the refresh-before-call path too. + return new OAuthTokens("access-token-1", "refresh-token-1", 3600, "read:jira-work offline_access"); + } + + @Override + public OAuthTokens refresh(String refreshToken) { + return new OAuthTokens("access-token-refreshed", "refresh-token-rotated", 3600, "read:jira-work"); + } + + @Override + public List accessibleResources(String accessToken) { + return List.of(new Site("cloud-1", "https://acme.atlassian.net", "Acme")); + } + }; + } +} diff --git a/src/test/java/com/kntro/reqsai/gateway/StubJiraProviderConfig.java b/src/test/java/com/kntro/reqsai/gateway/StubJiraProviderConfig.java new file mode 100644 index 00000000..c3fe8a40 --- /dev/null +++ b/src/test/java/com/kntro/reqsai/gateway/StubJiraProviderConfig.java @@ -0,0 +1,64 @@ +package com.kntro.reqsai.gateway; + +import com.kntro.reqsai.discovery.api.StoryView; +import com.kntro.reqsai.gateway.application.port.IntegrationProvider; +import com.kntro.reqsai.gateway.domain.model.IntegrationProviderType; +import org.springframework.boot.test.context.TestConfiguration; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Primary; + +import java.util.List; + +/** + * Deterministic stand-in for {@code JiraProvider} used by integration tests: it stubs the RestClient + * boundary so the tests exercise the full connect → target → push flow against a real tenant schema + * WITHOUT hitting Jira. Verification always succeeds; a push returns a synthetic issue key/url derived + * from the story id so assertions are stable. + */ +@TestConfiguration +public class StubJiraProviderConfig { + + public static final String ACCOUNT_NAME = "Stub Jira Admin"; + + @Bean + @Primary + public IntegrationProvider stubJiraProvider() { + return new IntegrationProvider() { + @Override + public IntegrationProviderType type() { + return IntegrationProviderType.JIRA; + } + + @Override + public String verify(ProviderCredentials credentials) { + return ACCOUNT_NAME; + } + + @Override + public List listProjects(ProviderCredentials credentials) { + return List.of(new RemoteProject("PAY", "Payments")); + } + + @Override + public List listIssueTypes(ProviderCredentials credentials, String projectKey) { + return List.of(new RemoteIssueType("10001", "Story")); + } + + @Override + public PushedIssue pushStory(ProviderCredentials c, String projectKey, String issueTypeName, StoryView story) { + String key = projectKey + "-" + Math.abs(story.storyId().hashCode() % 1000); + return new PushedIssue(key, c.siteUrl() + "/browse/" + key); + } + + @Override + public List searchImportableIssues(ProviderCredentials c, String projectKey, String issueTypeName) { + return List.of( + new RemoteIssue(projectKey + "-101", "Password reset via email", + issueTypeName, "As a user I want to reset my password so that I can regain access.", + "HIGH"), + new RemoteIssue(projectKey + "-102", "Export backlog to CSV", + issueTypeName, "Allow exporting the backlog as a CSV file.", "MEDIUM")); + } + }; + } +} diff --git a/src/test/java/com/kntro/reqsai/gateway/application/handler/ConnectJiraCommandHandlerTest.java b/src/test/java/com/kntro/reqsai/gateway/application/handler/ConnectJiraCommandHandlerTest.java new file mode 100644 index 00000000..4949763b --- /dev/null +++ b/src/test/java/com/kntro/reqsai/gateway/application/handler/ConnectJiraCommandHandlerTest.java @@ -0,0 +1,96 @@ +package com.kntro.reqsai.gateway.application.handler; + +import com.kntro.reqsai.gateway.application.command.ConnectJiraCommand; +import com.kntro.reqsai.gateway.application.port.IntegrationConnectionRepository; +import com.kntro.reqsai.gateway.application.port.IntegrationProvider; +import com.kntro.reqsai.gateway.application.service.ProviderRegistry; +import com.kntro.reqsai.gateway.domain.model.ConnectionStatus; +import com.kntro.reqsai.gateway.domain.model.IntegrationConnection; +import com.kntro.reqsai.gateway.domain.model.IntegrationProviderType; +import com.kntro.reqsai.gateway.infrastructure.exception.IntegrationsInfrastructureExceptions; +import com.kntro.reqsai.shared.domain.exception.DomainException; +import com.kntro.reqsai.shared.domain.exception.InfrastructureException; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import java.util.List; +import java.util.UUID; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +@DisplayName("Application: Connect Jira") +@ExtendWith(MockitoExtension.class) +class ConnectJiraCommandHandlerTest { + + @Mock + private IntegrationConnectionRepository connections; + @Mock + private IntegrationProvider jiraProvider; + + private ConnectJiraCommandHandler handler; + + @BeforeEach + void setUp() { + when(jiraProvider.type()).thenReturn(IntegrationProviderType.JIRA); + handler = new ConnectJiraCommandHandler(connections, new ProviderRegistry(List.of(jiraProvider))); + } + + @Test + @DisplayName("verifies the credential then persists an encrypted connection") + void connects_after_verifying() { + UUID orgId = UUID.randomUUID(); + when(connections.existsByOrganizationIdAndProviderAndStatusNot( + eq(orgId), eq(IntegrationProviderType.JIRA), eq(ConnectionStatus.DISCONNECTED))).thenReturn(false); + when(jiraProvider.verify(any())).thenReturn("Jane Admin"); + when(connections.save(any(IntegrationConnection.class))).thenAnswer(i -> i.getArgument(0)); + + IntegrationConnection saved = handler.handle(new ConnectJiraCommand( + orgId, "https://acme.atlassian.net/", "pm@acme.com", "tok", UUID.randomUUID())); + + verify(jiraProvider).verify(any()); + verify(connections).save(any(IntegrationConnection.class)); + assertThat(saved.getProvider()).isEqualTo(IntegrationProviderType.JIRA); + assertThat(saved.getSiteUrl()).isEqualTo("https://acme.atlassian.net"); // trailing slash trimmed + assertThat(saved.getStatus()).isEqualTo(ConnectionStatus.CONNECTED); + } + + @Test + @DisplayName("rejects a second active connection with a 409 domain error") + void rejects_duplicate() { + UUID orgId = UUID.randomUUID(); + when(connections.existsByOrganizationIdAndProviderAndStatusNot( + eq(orgId), eq(IntegrationProviderType.JIRA), eq(ConnectionStatus.DISCONNECTED))).thenReturn(true); + + assertThatThrownBy(() -> handler.handle(new ConnectJiraCommand( + orgId, "https://acme.atlassian.net", "pm@acme.com", "tok", UUID.randomUUID()))) + .isInstanceOf(DomainException.class); + + verify(jiraProvider, never()).verify(any()); + verify(connections, never()).save(any()); + } + + @Test + @DisplayName("propagates a verification failure and persists nothing") + void propagates_verify_failure() { + UUID orgId = UUID.randomUUID(); + when(connections.existsByOrganizationIdAndProviderAndStatusNot( + eq(orgId), eq(IntegrationProviderType.JIRA), eq(ConnectionStatus.DISCONNECTED))).thenReturn(false); + when(jiraProvider.verify(any())).thenThrow(IntegrationsInfrastructureExceptions.jiraAuthFailed()); + + assertThatThrownBy(() -> handler.handle(new ConnectJiraCommand( + orgId, "https://acme.atlassian.net", "pm@acme.com", "bad", UUID.randomUUID()))) + .isInstanceOf(InfrastructureException.class); + + verify(connections, never()).save(any()); + } +} diff --git a/src/test/java/com/kntro/reqsai/gateway/application/handler/ImportJiraStoriesCommandHandlerTest.java b/src/test/java/com/kntro/reqsai/gateway/application/handler/ImportJiraStoriesCommandHandlerTest.java new file mode 100644 index 00000000..a53621ee --- /dev/null +++ b/src/test/java/com/kntro/reqsai/gateway/application/handler/ImportJiraStoriesCommandHandlerTest.java @@ -0,0 +1,107 @@ +package com.kntro.reqsai.gateway.application.handler; + +import com.kntro.reqsai.gateway.application.command.ImportJiraStoriesCommand; +import com.kntro.reqsai.gateway.application.port.IntegrationJobLauncher; +import com.kntro.reqsai.gateway.application.port.ProjectIntegrationTargetRepository; +import com.kntro.reqsai.gateway.application.service.IntegrationSyncJobStarter; +import com.kntro.reqsai.gateway.domain.model.IntegrationSyncJob; +import com.kntro.reqsai.gateway.domain.model.IntegrationSyncJobStatus; +import com.kntro.reqsai.gateway.domain.model.IntegrationSyncJobType; +import com.kntro.reqsai.gateway.domain.model.ProjectIntegrationTarget; +import com.kntro.reqsai.shared.domain.exception.DomainException; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Tag; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import java.util.List; +import java.util.Optional; +import java.util.UUID; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +@Tag("unit") +@ExtendWith(MockitoExtension.class) +@DisplayName("Application: Import Jira stories command handler (async job)") +class ImportJiraStoriesCommandHandlerTest { + + private static final UUID PROJECT = UUID.randomUUID(); + private static final UUID USER = UUID.randomUUID(); + + @Mock + private ProjectIntegrationTargetRepository targets; + @Mock + private IntegrationSyncJobStarter starter; + @Mock + private IntegrationJobLauncher launcher; + @InjectMocks + private ImportJiraStoriesCommandHandler handler; + + @Test + @DisplayName("creates a RUNNING job and dispatches the async import") + void starts_job_and_launches() { + stubTarget(); + IntegrationSyncJob job = new IntegrationSyncJob(PROJECT, IntegrationSyncJobType.IMPORT, 2, USER); + when(starter.start(PROJECT, IntegrationSyncJobType.IMPORT, 2, USER)).thenReturn(job); + + IntegrationSyncJob result = handler.handle( + new ImportJiraStoriesCommand(PROJECT, List.of("PAY-1", "PAY-2"), USER)); + + assertThat(result.getStatus()).isEqualTo(IntegrationSyncJobStatus.RUNNING); + assertThat(result.getTotal()).isEqualTo(2); + verify(launcher).launchImport(job.getId(), PROJECT, List.of("PAY-1", "PAY-2")); + } + + @Test + @DisplayName("a full import (no issueKeys) starts with total 0 until the worker resolves it") + void full_import_starts_with_unknown_total() { + stubTarget(); + IntegrationSyncJob job = new IntegrationSyncJob(PROJECT, IntegrationSyncJobType.IMPORT, 0, USER); + when(starter.start(PROJECT, IntegrationSyncJobType.IMPORT, 0, USER)).thenReturn(job); + + IntegrationSyncJob result = handler.handle(new ImportJiraStoriesCommand(PROJECT, null, USER)); + + assertThat(result.getTotal()).isZero(); + verify(launcher).launchImport(job.getId(), PROJECT, null); + } + + @Test + @DisplayName("409 INTEGRATION_TARGET_NOT_CONFIGURED when no target exists; nothing is launched") + void no_target_conflicts() { + when(targets.findByProjectId(PROJECT)).thenReturn(Optional.empty()); + + assertThatThrownBy(() -> handler.handle(new ImportJiraStoriesCommand(PROJECT, null, USER))) + .isInstanceOf(DomainException.class) + .satisfies(e -> assertThat(((DomainException) e).error().code()) + .isEqualTo("INTEGRATION_TARGET_NOT_CONFIGURED")); + verify(launcher, never()).launchImport(any(), any(), any()); + } + + @Test + @DisplayName("propagates the starter's 409 INTEGRATION_JOB_ALREADY_RUNNING without launching") + void running_job_conflicts() { + stubTarget(); + when(starter.start(PROJECT, IntegrationSyncJobType.IMPORT, 0, USER)) + .thenThrow(com.kntro.reqsai.gateway.domain.exception.IntegrationsExceptions + .jobAlreadyRunning(PROJECT, IntegrationSyncJobType.IMPORT.name())); + + assertThatThrownBy(() -> handler.handle(new ImportJiraStoriesCommand(PROJECT, null, USER))) + .isInstanceOf(DomainException.class) + .satisfies(e -> assertThat(((DomainException) e).error().code()) + .isEqualTo("INTEGRATION_JOB_ALREADY_RUNNING")); + verify(launcher, never()).launchImport(any(), any(), any()); + } + + private void stubTarget() { + when(targets.findByProjectId(PROJECT)).thenReturn(Optional.of(mock(ProjectIntegrationTarget.class))); + } +} diff --git a/src/test/java/com/kntro/reqsai/gateway/application/handler/JiraOAuthCallbackCommandHandlerTest.java b/src/test/java/com/kntro/reqsai/gateway/application/handler/JiraOAuthCallbackCommandHandlerTest.java new file mode 100644 index 00000000..362bc5d7 --- /dev/null +++ b/src/test/java/com/kntro/reqsai/gateway/application/handler/JiraOAuthCallbackCommandHandlerTest.java @@ -0,0 +1,188 @@ +package com.kntro.reqsai.gateway.application.handler; + +import com.kntro.reqsai.gateway.application.command.JiraOAuthCallbackCommand; +import com.kntro.reqsai.gateway.application.config.JiraOAuthProperties; +import com.kntro.reqsai.gateway.application.port.IntegrationConnectionRepository; +import com.kntro.reqsai.gateway.application.port.JiraOAuthPort; +import com.kntro.reqsai.gateway.application.port.JiraOAuthPort.OAuthTokens; +import com.kntro.reqsai.gateway.application.port.JiraOAuthPort.Site; +import com.kntro.reqsai.gateway.application.result.JiraOAuthCallbackResult; +import com.kntro.reqsai.gateway.application.service.JiraOAuthPendingTokenCache; +import com.kntro.reqsai.gateway.application.service.JiraOAuthStateService; +import com.kntro.reqsai.gateway.domain.model.ConnectionStatus; +import com.kntro.reqsai.gateway.domain.model.CredentialType; +import com.kntro.reqsai.gateway.domain.model.IntegrationConnection; +import com.kntro.reqsai.gateway.domain.model.IntegrationProviderType; +import com.kntro.reqsai.shared.domain.exception.DomainException; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import java.util.List; +import java.util.UUID; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +@DisplayName("Application: Jira OAuth callback") +@ExtendWith(MockitoExtension.class) +class JiraOAuthCallbackCommandHandlerTest { + + private static final JiraOAuthProperties PROPS = new JiraOAuthProperties( + "client-id", "client-secret", "https://cb", "state-secret-material-0123456789"); + + @Mock + private JiraOAuthPort oauth; + @Mock + private IntegrationConnectionRepository connections; + + private JiraOAuthStateService stateService; + private JiraOAuthPendingTokenCache pendingTokens; + private JiraOAuthCallbackCommandHandler handler; + + private final UUID orgId = UUID.randomUUID(); + private final UUID userId = UUID.randomUUID(); + + @BeforeEach + void setUp() { + stateService = new JiraOAuthStateService(PROPS); + pendingTokens = new JiraOAuthPendingTokenCache(); // real cache to exercise the two-step flow + handler = new JiraOAuthCallbackCommandHandler(PROPS, stateService, oauth, connections, pendingTokens); + } + + private JiraOAuthCallbackCommand command(String cloudId) { + return new JiraOAuthCallbackCommand(orgId, "auth-code", stateService.issue(orgId, userId), cloudId, userId); + } + + private JiraOAuthCallbackCommand command(String state, String cloudId) { + return new JiraOAuthCallbackCommand(orgId, "auth-code", state, cloudId, userId); + } + + private OAuthTokens tokens() { + return new OAuthTokens("access-abc", "refresh-xyz", 3600, "read:jira-work offline_access"); + } + + @Test + @DisplayName("single accessible site auto-selects and persists an encrypted OAUTH2 connection") + void single_site_auto_selects() { + when(oauth.exchangeCode("auth-code")).thenReturn(tokens()); + when(oauth.accessibleResources("access-abc")) + .thenReturn(List.of(new Site("cloud-1", "https://acme.atlassian.net", "Acme"))); + when(connections.existsByOrganizationIdAndProviderAndStatusNot( + any(), any(), any())).thenReturn(false); + when(connections.save(any(IntegrationConnection.class))).thenAnswer(i -> i.getArgument(0)); + + JiraOAuthCallbackResult result = handler.handle(command(null)); + + assertThat(result.isSaved()).isTrue(); + IntegrationConnection saved = result.connection(); + assertThat(saved.getCredentialType()).isEqualTo(CredentialType.OAUTH2); + assertThat(saved.getCloudId()).isEqualTo("cloud-1"); + assertThat(saved.getSiteUrl()).isEqualTo("https://acme.atlassian.net"); + assertThat(saved.getEmail()).isNull(); + assertThat(saved.getOauthRefreshToken()).isEqualTo("refresh-xyz"); + assertThat(saved.getOauthAccessToken()).isEqualTo("access-abc"); + assertThat(saved.getStatus()).isEqualTo(ConnectionStatus.CONNECTED); + } + + @Test + @DisplayName("multiple sites without a cloudId returns the site list and saves nothing") + void multi_site_returns_sites() { + when(oauth.exchangeCode("auth-code")).thenReturn(tokens()); + when(oauth.accessibleResources("access-abc")).thenReturn(List.of( + new Site("cloud-1", "https://acme.atlassian.net", "Acme"), + new Site("cloud-2", "https://beta.atlassian.net", "Beta"))); + + JiraOAuthCallbackResult result = handler.handle(command(null)); + + assertThat(result.isSaved()).isFalse(); + assertThat(result.sites()).extracting(Site::cloudId).containsExactly("cloud-1", "cloud-2"); + verify(connections, never()).save(any()); + } + + @Test + @DisplayName("two-step multi-site flow exchanges the single-use code exactly once and reuses the cache") + void two_step_multi_site_exchanges_code_once() { + when(oauth.exchangeCode("auth-code")).thenReturn(tokens()); + when(oauth.accessibleResources("access-abc")).thenReturn(List.of( + new Site("cloud-1", "https://acme.atlassian.net", "Acme"), + new Site("cloud-2", "https://beta.atlassian.net", "Beta"))); + when(connections.existsByOrganizationIdAndProviderAndStatusNot(any(), any(), any())).thenReturn(false); + when(connections.save(any(IntegrationConnection.class))).thenAnswer(i -> i.getArgument(0)); + + // Step 1: no cloudId -> returns sites, caches tokens under the state (nothing saved yet). + String state = stateService.issue(orgId, userId); + JiraOAuthCallbackResult first = handler.handle(command(state, null)); + assertThat(first.isSaved()).isFalse(); + verify(connections, never()).save(any()); + + // Step 2: same state + chosen cloudId -> completes from the cache, saves, does NOT re-exchange. + JiraOAuthCallbackResult second = handler.handle(command(state, "cloud-2")); + assertThat(second.isSaved()).isTrue(); + assertThat(second.connection().getCloudId()).isEqualTo("cloud-2"); + + // The single-use code was exchanged exactly once and accessible-resources called exactly once. + verify(oauth, times(1)).exchangeCode("auth-code"); + verify(oauth, times(1)).accessibleResources("access-abc"); + } + + @Test + @DisplayName("a chosen cloudId among multiple sites persists that site") + void chosen_cloud_id_persists() { + when(oauth.exchangeCode("auth-code")).thenReturn(tokens()); + when(oauth.accessibleResources("access-abc")).thenReturn(List.of( + new Site("cloud-1", "https://acme.atlassian.net", "Acme"), + new Site("cloud-2", "https://beta.atlassian.net", "Beta"))); + when(connections.existsByOrganizationIdAndProviderAndStatusNot(any(), any(), any())).thenReturn(false); + when(connections.save(any(IntegrationConnection.class))).thenAnswer(i -> i.getArgument(0)); + + JiraOAuthCallbackResult result = handler.handle(command("cloud-2")); + + assertThat(result.isSaved()).isTrue(); + assertThat(result.connection().getCloudId()).isEqualTo("cloud-2"); + assertThat(result.connection().getSiteUrl()).isEqualTo("https://beta.atlassian.net"); + } + + @Test + @DisplayName("rejects a second active connection with a 409 domain error") + void rejects_already_connected() { + when(oauth.exchangeCode("auth-code")).thenReturn(tokens()); + when(oauth.accessibleResources("access-abc")) + .thenReturn(List.of(new Site("cloud-1", "https://acme.atlassian.net", "Acme"))); + when(connections.existsByOrganizationIdAndProviderAndStatusNot( + any(), any(), any())).thenReturn(true); + + assertThatThrownBy(() -> handler.handle(command(null))) + .isInstanceOf(DomainException.class); + verify(connections, never()).save(any()); + } + + @Test + @DisplayName("a tampered state is rejected before any token exchange") + void rejects_invalid_state() { + JiraOAuthCallbackCommand bad = new JiraOAuthCallbackCommand( + orgId, "auth-code", "bogus.state", null, userId); + + assertThatThrownBy(() -> handler.handle(bad)).isInstanceOf(DomainException.class); + verify(oauth, never()).exchangeCode(any()); + } + + @Test + @DisplayName("unconfigured oauth is rejected with JIRA_OAUTH_NOT_CONFIGURED") + void rejects_unconfigured() { + JiraOAuthProperties unconfigured = new JiraOAuthProperties(null, null, null, null); + JiraOAuthCallbackCommandHandler h = new JiraOAuthCallbackCommandHandler( + unconfigured, stateService, oauth, connections, pendingTokens); + + assertThatThrownBy(() -> h.handle(command(null))).isInstanceOf(DomainException.class); + verify(oauth, never()).exchangeCode(any()); + } +} diff --git a/src/test/java/com/kntro/reqsai/gateway/application/handler/PreviewJiraImportQueryHandlerTest.java b/src/test/java/com/kntro/reqsai/gateway/application/handler/PreviewJiraImportQueryHandlerTest.java new file mode 100644 index 00000000..4b56bf0f --- /dev/null +++ b/src/test/java/com/kntro/reqsai/gateway/application/handler/PreviewJiraImportQueryHandlerTest.java @@ -0,0 +1,74 @@ +package com.kntro.reqsai.gateway.application.handler; + +import com.kntro.reqsai.discovery.api.StoryDuplicateCheck; +import com.kntro.reqsai.gateway.application.port.IntegrationProvider.RemoteIssue; +import com.kntro.reqsai.gateway.application.port.ProjectIntegrationTargetRepository; +import com.kntro.reqsai.gateway.application.query.PreviewJiraImportQuery; +import com.kntro.reqsai.gateway.application.result.ImportPreview; +import com.kntro.reqsai.gateway.application.service.JiraImportService; +import com.kntro.reqsai.gateway.application.service.StoryPushService.PushContext; +import com.kntro.reqsai.gateway.domain.model.ProjectIntegrationTarget; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Tag; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import java.util.List; +import java.util.Optional; +import java.util.UUID; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +@Tag("unit") +@ExtendWith(MockitoExtension.class) +@DisplayName("Application: Preview Jira import query handler") +class PreviewJiraImportQueryHandlerTest { + + private static final UUID PROJECT = UUID.randomUUID(); + private static final UUID USER = UUID.randomUUID(); + + @Mock + private ProjectIntegrationTargetRepository targets; + @Mock + private JiraImportService importService; + @InjectMocks + private PreviewJiraImportQueryHandler handler; + + @Test + @DisplayName("flags likely duplicates without importing, and reports the total") + void flags_duplicates() { + ProjectIntegrationTarget target = mock(ProjectIntegrationTarget.class); + when(targets.findByProjectId(PROJECT)).thenReturn(Optional.of(target)); + PushContext ctx = mock(PushContext.class); + when(importService.contextFor(target)).thenReturn(ctx); + RemoteIssue a = new RemoteIssue("PAY-1", "Login", "Story", "desc", "HIGH"); + RemoteIssue b = new RemoteIssue("PAY-2", "Logout", "Story", "desc", "LOW"); + when(importService.fetchIssues(ctx)).thenReturn(List.of(a, b)); + UUID existing = UUID.randomUUID(); + when(importService.checkDuplicate(eq(PROJECT), argKey("PAY-1"))) + .thenReturn(new StoryDuplicateCheck(true, existing, 0.9)); + when(importService.checkDuplicate(eq(PROJECT), argKey("PAY-2"))) + .thenReturn(StoryDuplicateCheck.notDuplicate()); + + ImportPreview preview = handler.handle(new PreviewJiraImportQuery(PROJECT, USER)); + + assertThat(preview.total()).isEqualTo(2); + assertThat(preview.issues()).hasSize(2); + ImportPreview.Candidate first = preview.issues().getFirst(); + assertThat(first.jiraIssueKey()).isEqualTo("PAY-1"); + assertThat(first.duplicate()).isTrue(); + assertThat(first.existingStoryId()).isEqualTo(existing); + assertThat(preview.issues().get(1).duplicate()).isFalse(); + } + + private static RemoteIssue argKey(String key) { + return org.mockito.ArgumentMatchers.argThat(i -> i != null && key.equals(i.issueKey())); + } +} diff --git a/src/test/java/com/kntro/reqsai/gateway/application/handler/PushAllStoriesCommandHandlerTest.java b/src/test/java/com/kntro/reqsai/gateway/application/handler/PushAllStoriesCommandHandlerTest.java new file mode 100644 index 00000000..98e5d628 --- /dev/null +++ b/src/test/java/com/kntro/reqsai/gateway/application/handler/PushAllStoriesCommandHandlerTest.java @@ -0,0 +1,105 @@ +package com.kntro.reqsai.gateway.application.handler; + +import com.kntro.reqsai.discovery.api.DiscoveryStoryReadPort; +import com.kntro.reqsai.discovery.api.StoryView; +import com.kntro.reqsai.gateway.application.command.PushAllStoriesCommand; +import com.kntro.reqsai.gateway.application.port.IntegrationJobLauncher; +import com.kntro.reqsai.gateway.application.port.ProjectIntegrationTargetRepository; +import com.kntro.reqsai.gateway.application.service.IntegrationSyncJobStarter; +import com.kntro.reqsai.gateway.domain.model.IntegrationSyncJob; +import com.kntro.reqsai.gateway.domain.model.IntegrationSyncJobStatus; +import com.kntro.reqsai.gateway.domain.model.IntegrationSyncJobType; +import com.kntro.reqsai.gateway.domain.model.ProjectIntegrationTarget; +import com.kntro.reqsai.shared.domain.exception.DomainException; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Tag; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import java.util.List; +import java.util.Optional; +import java.util.UUID; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +@Tag("unit") +@ExtendWith(MockitoExtension.class) +@DisplayName("Application: Push all stories command handler (async job)") +class PushAllStoriesCommandHandlerTest { + + private static final UUID PROJECT = UUID.randomUUID(); + private static final UUID USER = UUID.randomUUID(); + + @Mock + private ProjectIntegrationTargetRepository targets; + @Mock + private DiscoveryStoryReadPort stories; + @Mock + private IntegrationSyncJobStarter starter; + @Mock + private IntegrationJobLauncher launcher; + @InjectMocks + private PushAllStoriesCommandHandler handler; + + @Test + @DisplayName("creates a RUNNING job with the story count as total and dispatches the async push") + void starts_job_and_launches() { + when(targets.findByProjectId(PROJECT)).thenReturn(Optional.of(mock(ProjectIntegrationTarget.class))); + when(stories.listStories(PROJECT)).thenReturn(List.of(story(), story(), story())); + IntegrationSyncJob job = new IntegrationSyncJob(PROJECT, IntegrationSyncJobType.PUSH_ALL, 3, USER); + when(starter.start(PROJECT, IntegrationSyncJobType.PUSH_ALL, 3, USER)).thenReturn(job); + + IntegrationSyncJob result = handler.handle(new PushAllStoriesCommand(PROJECT, null, USER)); + + assertThat(result.getStatus()).isEqualTo(IntegrationSyncJobStatus.RUNNING); + assertThat(result.getTotal()).isEqualTo(3); + assertThat(result.getProcessed()).isZero(); + verify(launcher).launchPushAll(job.getId(), PROJECT, null); + } + + @Test + @DisplayName("with a story-id selection, total counts only selected stories present in the project") + void starts_job_with_selection() { + StoryView a = story(); + StoryView b = story(); + StoryView c = story(); + UUID missing = UUID.randomUUID(); + List selection = List.of(a.storyId(), c.storyId(), missing); + + when(targets.findByProjectId(PROJECT)).thenReturn(Optional.of(mock(ProjectIntegrationTarget.class))); + when(stories.listStories(PROJECT)).thenReturn(List.of(a, b, c)); + IntegrationSyncJob job = new IntegrationSyncJob(PROJECT, IntegrationSyncJobType.PUSH_ALL, 2, USER); + when(starter.start(PROJECT, IntegrationSyncJobType.PUSH_ALL, 2, USER)).thenReturn(job); + + IntegrationSyncJob result = handler.handle(new PushAllStoriesCommand(PROJECT, selection, USER)); + + // only a and c exist in the project; the missing id is ignored + assertThat(result.getTotal()).isEqualTo(2); + verify(launcher).launchPushAll(job.getId(), PROJECT, selection); + } + + @Test + @DisplayName("409 INTEGRATION_TARGET_NOT_CONFIGURED when no target exists; nothing is launched") + void no_target_conflicts() { + when(targets.findByProjectId(PROJECT)).thenReturn(Optional.empty()); + + assertThatThrownBy(() -> handler.handle(new PushAllStoriesCommand(PROJECT, null, USER))) + .isInstanceOf(DomainException.class) + .satisfies(e -> assertThat(((DomainException) e).error().code()) + .isEqualTo("INTEGRATION_TARGET_NOT_CONFIGURED")); + verify(launcher, never()).launchPushAll(any(), any(), any()); + } + + private static StoryView story() { + return new StoryView(UUID.randomUUID(), PROJECT, "Title", "user", "do", "benefit", "MEDIUM", null, List.of()); + } +} diff --git a/src/test/java/com/kntro/reqsai/gateway/application/handler/PushStoryCommandHandlerTest.java b/src/test/java/com/kntro/reqsai/gateway/application/handler/PushStoryCommandHandlerTest.java new file mode 100644 index 00000000..f177b857 --- /dev/null +++ b/src/test/java/com/kntro/reqsai/gateway/application/handler/PushStoryCommandHandlerTest.java @@ -0,0 +1,93 @@ +package com.kntro.reqsai.gateway.application.handler; + +import com.kntro.reqsai.discovery.api.DiscoveryStoryReadPort; +import com.kntro.reqsai.discovery.api.StoryView; +import com.kntro.reqsai.gateway.application.command.PushStoryCommand; +import com.kntro.reqsai.gateway.application.port.IntegrationConnectionRepository; +import com.kntro.reqsai.gateway.application.port.IntegrationProvider; +import com.kntro.reqsai.gateway.application.port.IntegrationProvider.PushedIssue; +import com.kntro.reqsai.gateway.application.port.ProjectIntegrationTargetRepository; +import com.kntro.reqsai.gateway.application.result.StoryPushResult; +import com.kntro.reqsai.gateway.application.service.ProviderCredentialsFactory; +import com.kntro.reqsai.gateway.application.service.ProviderRegistry; +import com.kntro.reqsai.gateway.application.service.StoryPushService; +import com.kntro.reqsai.gateway.domain.model.IntegrationConnection; +import com.kntro.reqsai.gateway.domain.model.IntegrationProviderType; +import com.kntro.reqsai.gateway.domain.model.ProjectIntegrationTarget; +import com.kntro.reqsai.shared.domain.exception.DomainException; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import java.time.Instant; +import java.util.List; +import java.util.Optional; +import java.util.UUID; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.when; + +@DisplayName("Application: Push single story") +@ExtendWith(MockitoExtension.class) +class PushStoryCommandHandlerTest { + + @Mock private ProjectIntegrationTargetRepository targets; + @Mock private DiscoveryStoryReadPort stories; + @Mock private IntegrationConnectionRepository connections; + @Mock private IntegrationProvider jiraProvider; + @Mock private ProviderCredentialsFactory credentialsFactory; + + private PushStoryCommandHandler handler; + + @BeforeEach + void setUp() { + when(jiraProvider.type()).thenReturn(IntegrationProviderType.JIRA); + StoryPushService pushService = new StoryPushService( + connections, new ProviderRegistry(List.of(jiraProvider)), credentialsFactory); + handler = new PushStoryCommandHandler(targets, stories, pushService); + } + + @Test + @DisplayName("pushes the story and returns the issue key + url") + void pushes_story() { + UUID projectId = UUID.randomUUID(); + UUID storyId = UUID.randomUUID(); + UUID connectionId = UUID.randomUUID(); + ProjectIntegrationTarget target = new ProjectIntegrationTarget(projectId, connectionId, "PAY", "Story"); + IntegrationConnection connection = new IntegrationConnection( + UUID.randomUUID(), IntegrationProviderType.JIRA, "https://acme.atlassian.net", + "pm@acme.com", "tok", Instant.now()); + StoryView story = new StoryView(storyId, projectId, "T", "user", "do", "benefit", "HIGH", 2, List.of()); + + when(targets.findByProjectId(projectId)).thenReturn(Optional.of(target)); + when(stories.findStory(projectId, storyId)).thenReturn(Optional.of(story)); + when(connections.findById(connectionId)).thenReturn(Optional.of(connection)); + when(credentialsFactory.from(connection)).thenReturn( + IntegrationProvider.ProviderCredentials.apiToken("https://acme.atlassian.net", "pm@acme.com", "tok")); + when(jiraProvider.pushStory(any(), any(), any(), any())) + .thenReturn(new PushedIssue("PAY-7", "https://acme.atlassian.net/browse/PAY-7")); + + StoryPushResult result = handler.handle(new PushStoryCommand(projectId, storyId, UUID.randomUUID())); + + assertThat(result.jiraIssueKey()).isEqualTo("PAY-7"); + assertThat(result.jiraIssueUrl()).isEqualTo("https://acme.atlassian.net/browse/PAY-7"); + assertThat(result.isSuccess()).isTrue(); + } + + @Test + @DisplayName("returns 409 when no target is configured") + void no_target_configured() { + UUID projectId = UUID.randomUUID(); + when(targets.findByProjectId(projectId)).thenReturn(Optional.empty()); + + assertThatThrownBy(() -> handler.handle(new PushStoryCommand(projectId, UUID.randomUUID(), UUID.randomUUID()))) + .isInstanceOf(DomainException.class) + .extracting(e -> ((DomainException) e).error().code()) + .isEqualTo("INTEGRATION_TARGET_NOT_CONFIGURED"); + } +} diff --git a/src/test/java/com/kntro/reqsai/gateway/application/handler/TestConnectionQueryHandlerTest.java b/src/test/java/com/kntro/reqsai/gateway/application/handler/TestConnectionQueryHandlerTest.java new file mode 100644 index 00000000..06e52b29 --- /dev/null +++ b/src/test/java/com/kntro/reqsai/gateway/application/handler/TestConnectionQueryHandlerTest.java @@ -0,0 +1,86 @@ +package com.kntro.reqsai.gateway.application.handler; + +import com.kntro.reqsai.gateway.application.port.IntegrationConnectionRepository; +import com.kntro.reqsai.gateway.application.port.IntegrationProvider; +import com.kntro.reqsai.gateway.application.query.TestConnectionQuery; +import com.kntro.reqsai.gateway.application.result.ConnectionTestResult; +import com.kntro.reqsai.gateway.application.service.ProviderCredentialsFactory; +import com.kntro.reqsai.gateway.application.service.ProviderRegistry; +import com.kntro.reqsai.gateway.domain.model.ConnectionStatus; +import com.kntro.reqsai.gateway.domain.model.IntegrationConnection; +import com.kntro.reqsai.gateway.domain.model.IntegrationProviderType; +import com.kntro.reqsai.gateway.infrastructure.exception.IntegrationsInfrastructureExceptions; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import java.time.Instant; +import java.util.List; +import java.util.Optional; +import java.util.UUID; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.when; + +@DisplayName("Application: Test connection") +@ExtendWith(MockitoExtension.class) +class TestConnectionQueryHandlerTest { + + @Mock private IntegrationConnectionRepository connections; + @Mock private IntegrationProvider jiraProvider; + @Mock private ProviderCredentialsFactory credentialsFactory; + + private TestConnectionQueryHandler handler; + + @BeforeEach + void setUp() { + when(jiraProvider.type()).thenReturn(IntegrationProviderType.JIRA); + handler = new TestConnectionQueryHandler( + connections, new ProviderRegistry(List.of(jiraProvider)), credentialsFactory); + } + + @Test + @DisplayName("returns ok + account name and marks the connection verified on success") + void ok_on_success() { + UUID orgId = UUID.randomUUID(); + UUID connectionId = UUID.randomUUID(); + IntegrationConnection connection = connection(orgId); + when(connections.findByIdAndOrganizationId(connectionId, orgId)).thenReturn(Optional.of(connection)); + when(credentialsFactory.from(connection)).thenReturn( + IntegrationProvider.ProviderCredentials.apiToken("https://acme.atlassian.net", "pm@acme.com", "tok")); + when(jiraProvider.verify(any())).thenReturn("Jane Admin"); + + ConnectionTestResult result = handler.handle(new TestConnectionQuery(orgId, connectionId, UUID.randomUUID())); + + assertThat(result.ok()).isTrue(); + assertThat(result.accountName()).isEqualTo("Jane Admin"); + assertThat(connection.getStatus()).isEqualTo(ConnectionStatus.CONNECTED); + } + + @Test + @DisplayName("returns ok=false and marks DEGRADED on verification failure (never fails the request)") + void degraded_on_failure() { + UUID orgId = UUID.randomUUID(); + UUID connectionId = UUID.randomUUID(); + IntegrationConnection connection = connection(orgId); + when(connections.findByIdAndOrganizationId(connectionId, orgId)).thenReturn(Optional.of(connection)); + when(credentialsFactory.from(connection)).thenReturn( + IntegrationProvider.ProviderCredentials.apiToken("https://acme.atlassian.net", "pm@acme.com", "tok")); + when(jiraProvider.verify(any())).thenThrow(IntegrationsInfrastructureExceptions.jiraAuthFailed()); + + ConnectionTestResult result = handler.handle(new TestConnectionQuery(orgId, connectionId, UUID.randomUUID())); + + assertThat(result.ok()).isFalse(); + assertThat(result.accountName()).isNull(); + assertThat(connection.getStatus()).isEqualTo(ConnectionStatus.DEGRADED); + } + + private static IntegrationConnection connection(UUID orgId) { + return new IntegrationConnection(orgId, IntegrationProviderType.JIRA, + "https://acme.atlassian.net", "pm@acme.com", "tok", Instant.now()); + } +} diff --git a/src/test/java/com/kntro/reqsai/gateway/application/service/IntegrationSyncJobStarterTest.java b/src/test/java/com/kntro/reqsai/gateway/application/service/IntegrationSyncJobStarterTest.java new file mode 100644 index 00000000..be68146a --- /dev/null +++ b/src/test/java/com/kntro/reqsai/gateway/application/service/IntegrationSyncJobStarterTest.java @@ -0,0 +1,77 @@ +package com.kntro.reqsai.gateway.application.service; + +import com.kntro.reqsai.gateway.application.port.IntegrationSyncJobRepository; +import com.kntro.reqsai.gateway.domain.model.IntegrationSyncJob; +import com.kntro.reqsai.gateway.domain.model.IntegrationSyncJobStatus; +import com.kntro.reqsai.gateway.domain.model.IntegrationSyncJobType; +import com.kntro.reqsai.shared.domain.exception.DomainException; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Tag; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.dao.DataIntegrityViolationException; + +import java.util.UUID; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +@Tag("unit") +@ExtendWith(MockitoExtension.class) +@DisplayName("Application: Integration sync job starter (single-running-job rule)") +class IntegrationSyncJobStarterTest { + + private static final UUID PROJECT = UUID.randomUUID(); + private static final UUID USER = UUID.randomUUID(); + + @Mock + private IntegrationSyncJobRepository jobs; + @InjectMocks + private IntegrationSyncJobStarter starter; + + @Test + @DisplayName("persists a RUNNING job when none of the same type is running") + void starts_when_free() { + when(jobs.existsRunning(PROJECT, IntegrationSyncJobType.IMPORT)).thenReturn(false); + when(jobs.save(any())).thenAnswer(inv -> inv.getArgument(0)); + + IntegrationSyncJob job = starter.start(PROJECT, IntegrationSyncJobType.IMPORT, 3, USER); + + assertThat(job.getStatus()).isEqualTo(IntegrationSyncJobStatus.RUNNING); + assertThat(job.getProjectId()).isEqualTo(PROJECT); + assertThat(job.getJobType()).isEqualTo(IntegrationSyncJobType.IMPORT); + assertThat(job.getTotal()).isEqualTo(3); + assertThat(job.getRequestedBy()).isEqualTo(USER); + } + + @Test + @DisplayName("409 INTEGRATION_JOB_ALREADY_RUNNING when a job of the same type is running") + void conflicts_on_running_job() { + when(jobs.existsRunning(PROJECT, IntegrationSyncJobType.PUSH_ALL)).thenReturn(true); + + assertThatThrownBy(() -> starter.start(PROJECT, IntegrationSyncJobType.PUSH_ALL, 0, USER)) + .isInstanceOf(DomainException.class) + .satisfies(e -> assertThat(((DomainException) e).error().code()) + .isEqualTo("INTEGRATION_JOB_ALREADY_RUNNING")); + verify(jobs, never()).save(any()); + } + + @Test + @DisplayName("maps the unique-index race (partial index backstop) to the same 409") + void conflicts_on_racing_insert() { + when(jobs.existsRunning(PROJECT, IntegrationSyncJobType.IMPORT)).thenReturn(false); + when(jobs.save(any())).thenThrow(new DataIntegrityViolationException("uq_integration_sync_jobs_running")); + + assertThatThrownBy(() -> starter.start(PROJECT, IntegrationSyncJobType.IMPORT, 0, USER)) + .isInstanceOf(DomainException.class) + .satisfies(e -> assertThat(((DomainException) e).error().code()) + .isEqualTo("INTEGRATION_JOB_ALREADY_RUNNING")); + } +} diff --git a/src/test/java/com/kntro/reqsai/gateway/application/service/JiraOAuthStateServiceTest.java b/src/test/java/com/kntro/reqsai/gateway/application/service/JiraOAuthStateServiceTest.java new file mode 100644 index 00000000..2573935b --- /dev/null +++ b/src/test/java/com/kntro/reqsai/gateway/application/service/JiraOAuthStateServiceTest.java @@ -0,0 +1,113 @@ +package com.kntro.reqsai.gateway.application.service; + +import com.kntro.reqsai.gateway.application.config.JiraOAuthProperties; +import com.kntro.reqsai.gateway.domain.exception.IntegrationsError; +import com.kntro.reqsai.shared.domain.exception.DomainException; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import java.util.UUID; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +@DisplayName("Application: Jira OAuth state sign/verify") +class JiraOAuthStateServiceTest { + + private static final JiraOAuthProperties PROPS = new JiraOAuthProperties( + "client-id", "client-secret", "https://cb", "state-secret-material-0123456789"); + + private final JiraOAuthStateService service = new JiraOAuthStateService(PROPS); + + @Test + @DisplayName("a freshly issued state verifies for its org+user") + void round_trips() { + UUID org = UUID.randomUUID(); + UUID user = UUID.randomUUID(); + + String state = service.issue(org, user); + + assertThat(state).contains("."); + service.verify(state, org, user); // does not throw + } + + @Test + @DisplayName("a tampered payload fails signature verification") + void rejects_tampered() { + UUID org = UUID.randomUUID(); + UUID user = UUID.randomUUID(); + String state = service.issue(org, user); + // Flip the last character of the payload segment. + int dot = state.indexOf('.'); + char[] chars = state.toCharArray(); + chars[dot - 1] = chars[dot - 1] == 'A' ? 'B' : 'A'; + String tampered = new String(chars); + + assertThatThrownBy(() -> service.verify(tampered, org, user)) + .isInstanceOf(DomainException.class) + .extracting(e -> ((DomainException) e).error()) + .isEqualTo(IntegrationsError.JIRA_OAUTH_STATE_INVALID); + } + + @Test + @DisplayName("a state issued for a different org is rejected") + void rejects_wrong_org() { + UUID user = UUID.randomUUID(); + String state = service.issue(UUID.randomUUID(), user); + + assertThatThrownBy(() -> service.verify(state, UUID.randomUUID(), user)) + .isInstanceOf(DomainException.class) + .extracting(e -> ((DomainException) e).error()) + .isEqualTo(IntegrationsError.JIRA_OAUTH_STATE_INVALID); + } + + @Test + @DisplayName("a state issued for a different user is rejected") + void rejects_wrong_user() { + UUID org = UUID.randomUUID(); + String state = service.issue(org, UUID.randomUUID()); + + assertThatThrownBy(() -> service.verify(state, org, UUID.randomUUID())) + .isInstanceOf(DomainException.class); + } + + @Test + @DisplayName("an expired state is rejected") + void rejects_expired() { + UUID org = UUID.randomUUID(); + UUID user = UUID.randomUUID(); + // Build a state whose payload expiry is in the past but signed with the real secret, so only the + // expiry check fails (not the signature). Reuses the service's own signing via reflection-free + // reconstruction: issue then rewrite the expiry is not possible (would break the signature), so we + // sign a hand-built expired payload the same way the service does. + String expired = signExpired(org, user); + + assertThatThrownBy(() -> service.verify(expired, org, user)) + .isInstanceOf(DomainException.class) + .extracting(e -> ((DomainException) e).error()) + .isEqualTo(IntegrationsError.JIRA_OAUTH_STATE_INVALID); + } + + @Test + @DisplayName("a malformed state (no signature separator) is rejected") + void rejects_malformed() { + assertThatThrownBy(() -> service.verify("not-a-valid-state", UUID.randomUUID(), UUID.randomUUID())) + .isInstanceOf(DomainException.class); + } + + /** Signs an already-expired payload with the same HMAC the service uses, to exercise the expiry branch. */ + private static String signExpired(UUID org, UUID user) { + try { + String payload = "%s|%s|%d|%s".formatted(org, user, 1L, "noncevalue"); + var b64 = java.util.Base64.getUrlEncoder().withoutPadding(); + String encodedPayload = b64.encodeToString(payload.getBytes(java.nio.charset.StandardCharsets.UTF_8)); + javax.crypto.Mac mac = javax.crypto.Mac.getInstance("HmacSHA256"); + mac.init(new javax.crypto.spec.SecretKeySpec( + PROPS.effectiveStateSecret().getBytes(java.nio.charset.StandardCharsets.UTF_8), "HmacSHA256")); + byte[] sig = mac.doFinal(encodedPayload.getBytes(java.nio.charset.StandardCharsets.UTF_8)); + return encodedPayload + "." + b64.encodeToString(sig); + } catch (Exception e) { + throw new IllegalStateException(e); + } + } +} diff --git a/src/test/java/com/kntro/reqsai/gateway/application/service/JiraOAuthTokenServiceTest.java b/src/test/java/com/kntro/reqsai/gateway/application/service/JiraOAuthTokenServiceTest.java new file mode 100644 index 00000000..48ecc23a --- /dev/null +++ b/src/test/java/com/kntro/reqsai/gateway/application/service/JiraOAuthTokenServiceTest.java @@ -0,0 +1,103 @@ +package com.kntro.reqsai.gateway.application.service; + +import com.kntro.reqsai.gateway.application.port.IntegrationConnectionRepository; +import com.kntro.reqsai.gateway.application.port.JiraOAuthPort; +import com.kntro.reqsai.gateway.application.port.JiraOAuthPort.OAuthTokens; +import com.kntro.reqsai.gateway.domain.model.IntegrationConnection; +import com.kntro.reqsai.gateway.domain.model.IntegrationProviderType; +import com.kntro.reqsai.shared.domain.exception.InfrastructureException; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import java.time.Instant; +import java.time.temporal.ChronoUnit; +import java.util.UUID; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +@DisplayName("Application: Jira OAuth token refresh-before-call") +@ExtendWith(MockitoExtension.class) +class JiraOAuthTokenServiceTest { + + @Mock + private JiraOAuthPort oauth; + @Mock + private IntegrationConnectionRepository connections; + + private JiraOAuthTokenService service() { + return new JiraOAuthTokenService(oauth, connections); + } + + private IntegrationConnection oauthConnection(Instant accessExpiry) { + return IntegrationConnection.oauth( + UUID.randomUUID(), IntegrationProviderType.JIRA, + "https://acme.atlassian.net", "cloud-1", + "refresh-old", "access-old", accessExpiry, Instant.now()); + } + + @Test + @DisplayName("returns the cached access token when it is still valid") + void uses_cached_when_valid() { + IntegrationConnection connection = oauthConnection(Instant.now().plus(30, ChronoUnit.MINUTES)); + + String token = service().freshAccessToken(connection); + + assertThat(token).isEqualTo("access-old"); + verify(oauth, never()).refresh(any()); + verify(connections, never()).save(any()); + } + + @Test + @DisplayName("refreshes and persists rotated tokens when the access token has expired") + void refreshes_when_expired() { + IntegrationConnection connection = oauthConnection(Instant.now().minus(1, ChronoUnit.MINUTES)); + when(oauth.refresh("refresh-old")) + .thenReturn(new OAuthTokens("access-new", "refresh-rotated", 3600, "scope")); + when(connections.save(any(IntegrationConnection.class))).thenAnswer(i -> i.getArgument(0)); + + String token = service().freshAccessToken(connection); + + assertThat(token).isEqualTo("access-new"); + // Rotated refresh token is persisted; the cached access token + expiry are updated. + assertThat(connection.getOauthRefreshToken()).isEqualTo("refresh-rotated"); + assertThat(connection.getOauthAccessToken()).isEqualTo("access-new"); + assertThat(connection.getOauthAccessExpiresAt()).isAfter(Instant.now()); + verify(connections).save(connection); + } + + @Test + @DisplayName("keeps the existing refresh token when the refresh response omits a new one") + void keeps_refresh_when_not_rotated() { + IntegrationConnection connection = oauthConnection(Instant.now().minus(1, ChronoUnit.MINUTES)); + when(oauth.refresh("refresh-old")) + .thenReturn(new OAuthTokens("access-new", null, 3600, "scope")); + when(connections.save(any(IntegrationConnection.class))).thenAnswer(i -> i.getArgument(0)); + + service().freshAccessToken(connection); + + assertThat(connection.getOauthRefreshToken()).isEqualTo("refresh-old"); + assertThat(connection.getOauthAccessToken()).isEqualTo("access-new"); + } + + @Test + @DisplayName("a refresh failure surfaces as JIRA_AUTH_FAILED") + void refresh_failure_maps_to_auth_failed() { + IntegrationConnection connection = oauthConnection(Instant.now().minus(1, ChronoUnit.MINUTES)); + when(oauth.refresh("refresh-old")) + .thenThrow(com.kntro.reqsai.gateway.infrastructure.exception.IntegrationsInfrastructureExceptions + .jiraOauthExchangeFailed("boom", null)); + + assertThatThrownBy(() -> service().freshAccessToken(connection)) + .isInstanceOf(InfrastructureException.class) + .extracting(e -> ((InfrastructureException) e).error().code()) + .isEqualTo("JIRA_AUTH_FAILED"); + } +} diff --git a/src/test/java/com/kntro/reqsai/gateway/application/service/ProviderCredentialsFactoryTest.java b/src/test/java/com/kntro/reqsai/gateway/application/service/ProviderCredentialsFactoryTest.java new file mode 100644 index 00000000..adfd0062 --- /dev/null +++ b/src/test/java/com/kntro/reqsai/gateway/application/service/ProviderCredentialsFactoryTest.java @@ -0,0 +1,58 @@ +package com.kntro.reqsai.gateway.application.service; + +import com.kntro.reqsai.gateway.application.port.IntegrationProvider.ProviderCredentials; +import com.kntro.reqsai.gateway.domain.model.CredentialType; +import com.kntro.reqsai.gateway.domain.model.IntegrationConnection; +import com.kntro.reqsai.gateway.domain.model.IntegrationProviderType; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import java.time.Instant; +import java.time.temporal.ChronoUnit; +import java.util.UUID; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.when; + +@DisplayName("Application: ProviderCredentialsFactory dual-mode routing") +@ExtendWith(MockitoExtension.class) +class ProviderCredentialsFactoryTest { + + @Mock + private JiraOAuthTokenService oauthTokens; + + @Test + @DisplayName("API_TOKEN connection yields basic-auth credentials from email + token") + void api_token_routing() { + IntegrationConnection connection = new IntegrationConnection( + UUID.randomUUID(), IntegrationProviderType.JIRA, + "https://acme.atlassian.net", "pm@acme.com", "tok", Instant.now()); + + ProviderCredentials creds = new ProviderCredentialsFactory(oauthTokens).from(connection); + + assertThat(creds.credentialType()).isEqualTo(CredentialType.API_TOKEN); + assertThat(creds.email()).isEqualTo("pm@acme.com"); + assertThat(creds.apiToken()).isEqualTo("tok"); + assertThat(creds.accessToken()).isNull(); + } + + @Test + @DisplayName("OAUTH2 connection yields bearer credentials with a freshly resolved access token") + void oauth_routing_uses_fresh_token() { + IntegrationConnection connection = IntegrationConnection.oauth( + UUID.randomUUID(), IntegrationProviderType.JIRA, + "https://acme.atlassian.net", "cloud-1", "refresh", "access-stale", + Instant.now().minus(1, ChronoUnit.MINUTES), Instant.now()); + when(oauthTokens.freshAccessToken(connection)).thenReturn("access-fresh"); + + ProviderCredentials creds = new ProviderCredentialsFactory(oauthTokens).from(connection); + + assertThat(creds.credentialType()).isEqualTo(CredentialType.OAUTH2); + assertThat(creds.cloudId()).isEqualTo("cloud-1"); + assertThat(creds.accessToken()).isEqualTo("access-fresh"); + assertThat(creds.email()).isNull(); + } +} diff --git a/src/test/java/com/kntro/reqsai/gateway/domain/model/IntegrationSyncJobTest.java b/src/test/java/com/kntro/reqsai/gateway/domain/model/IntegrationSyncJobTest.java new file mode 100644 index 00000000..acff3cb8 --- /dev/null +++ b/src/test/java/com/kntro/reqsai/gateway/domain/model/IntegrationSyncJobTest.java @@ -0,0 +1,73 @@ +package com.kntro.reqsai.gateway.domain.model; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Tag; +import org.junit.jupiter.api.Test; + +import java.util.UUID; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +@Tag("unit") +@DisplayName("Domain: Integration sync job") +class IntegrationSyncJobTest { + + private static final UUID PROJECT = UUID.randomUUID(); + + @Test + @DisplayName("starts RUNNING with zeroed counters and the known total") + void starts_running() { + IntegrationSyncJob job = new IntegrationSyncJob(PROJECT, IntegrationSyncJobType.IMPORT, 5, UUID.randomUUID()); + + assertThat(job.isRunning()).isTrue(); + assertThat(job.getStatus()).isEqualTo(IntegrationSyncJobStatus.RUNNING); + assertThat(job.getTotal()).isEqualTo(5); + assertThat(job.getProcessed()).isZero(); + assertThat(job.getSucceeded()).isZero(); + assertThat(job.getFailed()).isZero(); + assertThat(job.getFinishedAt()).isNull(); + } + + @Test + @DisplayName("counts items: success and failure both process; skipped only processes") + void counts_items() { + IntegrationSyncJob job = new IntegrationSyncJob(PROJECT, IntegrationSyncJobType.IMPORT, 0, null); + job.planTotal(3); + + job.recordSuccess(); + job.recordSkipped(); + job.recordFailure(); + + assertThat(job.getTotal()).isEqualTo(3); + assertThat(job.getProcessed()).isEqualTo(3); + assertThat(job.getSucceeded()).isEqualTo(1); + assertThat(job.getFailed()).isEqualTo(1); + } + + @Test + @DisplayName("complete() and fail() are terminal: they stamp finishedAt and freeze the job") + void terminal_transitions() { + IntegrationSyncJob completed = new IntegrationSyncJob(PROJECT, IntegrationSyncJobType.IMPORT, 1, null); + completed.complete("1 duplicados omitidos"); + assertThat(completed.getStatus()).isEqualTo(IntegrationSyncJobStatus.COMPLETED); + assertThat(completed.getMessage()).isEqualTo("1 duplicados omitidos"); + assertThat(completed.getFinishedAt()).isNotNull(); + assertThatThrownBy(completed::recordSuccess).isInstanceOf(IllegalStateException.class); + + IntegrationSyncJob failed = new IntegrationSyncJob(PROJECT, IntegrationSyncJobType.PUSH_ALL, 1, null); + failed.fail("Jira unreachable"); + assertThat(failed.getStatus()).isEqualTo(IntegrationSyncJobStatus.FAILED); + assertThat(failed.getMessage()).isEqualTo("Jira unreachable"); + assertThatThrownBy(() -> failed.complete(null)).isInstanceOf(IllegalStateException.class); + } + + @Test + @DisplayName("bounds the terminal message to the column size") + void truncates_message() { + IntegrationSyncJob job = new IntegrationSyncJob(PROJECT, IntegrationSyncJobType.IMPORT, 0, null); + job.fail("x".repeat(2000)); + + assertThat(job.getMessage()).hasSize(1000); + } +} diff --git a/src/test/java/com/kntro/reqsai/gateway/infrastructure/batch/IntegrationJobExecutionListenerTest.java b/src/test/java/com/kntro/reqsai/gateway/infrastructure/batch/IntegrationJobExecutionListenerTest.java new file mode 100644 index 00000000..2bf1b858 --- /dev/null +++ b/src/test/java/com/kntro/reqsai/gateway/infrastructure/batch/IntegrationJobExecutionListenerTest.java @@ -0,0 +1,145 @@ +package com.kntro.reqsai.gateway.infrastructure.batch; + +import com.kntro.reqsai.gateway.application.notification.IntegrationJobProgressNotifier; +import com.kntro.reqsai.gateway.application.port.IntegrationSyncJobRepository; +import com.kntro.reqsai.gateway.domain.model.IntegrationSyncJob; +import com.kntro.reqsai.gateway.domain.model.IntegrationSyncJobStatus; +import com.kntro.reqsai.gateway.domain.model.IntegrationSyncJobType; +import com.kntro.reqsai.shared.infrastructure.persistence.multitenancy.TenantContext; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Tag; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.batch.core.BatchStatus; +import org.springframework.batch.core.job.JobExecution; +import org.springframework.batch.core.job.JobInstance; +import org.springframework.batch.core.job.parameters.JobParametersBuilder; + +import java.util.Optional; +import java.util.UUID; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +@Tag("unit") +@ExtendWith(MockitoExtension.class) +@DisplayName("Batch: integration job execution listener (tenant framing + terminal projection)") +class IntegrationJobExecutionListenerTest { + + private static final UUID PROJECT = UUID.randomUUID(); + private static final UUID JOB_ID = UUID.randomUUID(); + + @Mock + private IntegrationSyncJobRepository jobs; + @Mock + private IntegrationJobProgressNotifier progress; + @InjectMocks + private IntegrationJobExecutionListener listener; + + @AfterEach + void clearTenant() { + TenantContext.clear(); + } + + @Test + @DisplayName("beforeJob restores the tenant captured into the job parameters") + void restores_tenant() { + listener.beforeJob(execution(IntegrationSyncJobType.IMPORT)); + + assertThat(TenantContext.getCurrentTenant()).isEqualTo("org-1"); + assertThat(TenantContext.getCurrentSchema()).isEqualTo("tenant_acme"); + } + + @Test + @DisplayName("afterJob COMPLETED completes the projection, notes skipped duplicates and publishes") + void completes_with_duplicates_note() { + IntegrationSyncJob job = runningJob(IntegrationSyncJobType.IMPORT); + job.planTotal(3); + job.recordSuccess(); + job.recordSkipped(); + job.recordSkipped(); + when(jobs.findById(JOB_ID)).thenReturn(Optional.of(job)); + when(jobs.save(job)).thenReturn(job); + JobExecution execution = execution(IntegrationSyncJobType.IMPORT); + execution.setStatus(BatchStatus.COMPLETED); + + listener.afterJob(execution); + + assertThat(job.getStatus()).isEqualTo(IntegrationSyncJobStatus.COMPLETED); + assertThat(job.getMessage()).isEqualTo("2 duplicados omitidos"); + assertThat(job.getFinishedAt()).isNotNull(); + verify(progress).publish(job); + assertThat(TenantContext.getCurrentSchema()).isNull(); + } + + @Test + @DisplayName("afterJob COMPLETED without duplicates leaves the message null") + void completes_silently() { + IntegrationSyncJob job = runningJob(IntegrationSyncJobType.PUSH_ALL); + job.planTotal(1); + job.recordSuccess(); + when(jobs.findById(JOB_ID)).thenReturn(Optional.of(job)); + when(jobs.save(job)).thenReturn(job); + JobExecution execution = execution(IntegrationSyncJobType.PUSH_ALL); + execution.setStatus(BatchStatus.COMPLETED); + + listener.afterJob(execution); + + assertThat(job.getStatus()).isEqualTo(IntegrationSyncJobStatus.COMPLETED); + assertThat(job.getMessage()).isNull(); + } + + @Test + @DisplayName("afterJob FAILED fails the projection with the execution's failure message") + void fails_with_message() { + IntegrationSyncJob job = runningJob(IntegrationSyncJobType.IMPORT); + when(jobs.findById(JOB_ID)).thenReturn(Optional.of(job)); + when(jobs.save(job)).thenReturn(job); + JobExecution execution = execution(IntegrationSyncJobType.IMPORT); + execution.setStatus(BatchStatus.FAILED); + execution.addFailureException(new IllegalStateException("Jira unreachable")); + + listener.afterJob(execution); + + assertThat(job.getStatus()).isEqualTo(IntegrationSyncJobStatus.FAILED); + assertThat(job.getMessage()).isEqualTo("Jira unreachable"); + verify(progress).publish(job); + assertThat(TenantContext.getCurrentSchema()).isNull(); + } + + @Test + @DisplayName("afterJob clears the tenant even when no RUNNING projection row exists") + void clears_tenant_without_row() { + when(jobs.findById(JOB_ID)).thenReturn(Optional.empty()); + JobExecution execution = execution(IntegrationSyncJobType.IMPORT); + execution.setStatus(BatchStatus.COMPLETED); + listener.beforeJob(execution); + + listener.afterJob(execution); + + verify(jobs, never()).save(any()); + assertThat(TenantContext.getCurrentSchema()).isNull(); + assertThat(TenantContext.getCurrentTenant()).isNull(); + } + + private static IntegrationSyncJob runningJob(IntegrationSyncJobType type) { + return new IntegrationSyncJob(PROJECT, type, 0, UUID.randomUUID()); + } + + private static JobExecution execution(IntegrationSyncJobType type) { + String jobName = type == IntegrationSyncJobType.IMPORT ? "jiraImportJob" : "jiraPushAllJob"; + return new JobExecution(1L, new JobInstance(1L, jobName), new JobParametersBuilder() + .addString(IntegrationJobParameters.DOMAIN_JOB_ID, JOB_ID.toString(), true) + .addString(IntegrationJobParameters.PROJECT_ID, PROJECT.toString(), false) + .addString(IntegrationJobParameters.TENANT_ID, "org-1", false) + .addString(IntegrationJobParameters.TENANT_SCHEMA, "tenant_acme", false) + .toJobParameters()); + } +} diff --git a/src/test/java/com/kntro/reqsai/gateway/infrastructure/batch/IntegrationJobLauncherAdapterTest.java b/src/test/java/com/kntro/reqsai/gateway/infrastructure/batch/IntegrationJobLauncherAdapterTest.java new file mode 100644 index 00000000..39ad1b6e --- /dev/null +++ b/src/test/java/com/kntro/reqsai/gateway/infrastructure/batch/IntegrationJobLauncherAdapterTest.java @@ -0,0 +1,125 @@ +package com.kntro.reqsai.gateway.infrastructure.batch; + +import com.kntro.reqsai.gateway.application.notification.IntegrationJobProgressNotifier; +import com.kntro.reqsai.gateway.application.port.IntegrationSyncJobRepository; +import com.kntro.reqsai.gateway.domain.model.IntegrationSyncJob; +import com.kntro.reqsai.gateway.domain.model.IntegrationSyncJobStatus; +import com.kntro.reqsai.gateway.domain.model.IntegrationSyncJobType; +import com.kntro.reqsai.shared.infrastructure.persistence.multitenancy.TenantContext; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Tag; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.ArgumentCaptor; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.batch.core.job.Job; +import org.springframework.batch.core.job.parameters.JobParameters; +import org.springframework.batch.core.launch.JobOperator; + +import java.util.List; +import java.util.Optional; +import java.util.UUID; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +@Tag("unit") +@ExtendWith(MockitoExtension.class) +@DisplayName("Batch: launcher adapter captures the tenant into job parameters") +class IntegrationJobLauncherAdapterTest { + + private static final UUID JOB_ID = UUID.randomUUID(); + private static final UUID PROJECT = UUID.randomUUID(); + + @Mock + private JobOperator jobOperator; + @Mock + private Job jiraImportJob; + @Mock + private Job jiraPushAllJob; + @Mock + private IntegrationSyncJobRepository jobs; + @Mock + private IntegrationJobProgressNotifier progress; + + @AfterEach + void clearTenant() { + TenantContext.clear(); + } + + private IntegrationJobLauncherAdapter adapter() { + return new IntegrationJobLauncherAdapter(jobOperator, jiraImportJob, jiraPushAllJob, jobs, progress); + } + + @Test + @DisplayName("launchImport snapshots the caller's tenant and passes it as job parameters") + void captures_tenant_snapshot() throws Exception { + TenantContext.setCurrentTenant("org-1"); + TenantContext.setCurrentSchema("tenant_acme"); + + adapter().launchImport(JOB_ID, PROJECT, List.of("PAY-1", "PAY-2")); + + ArgumentCaptor params = ArgumentCaptor.forClass(JobParameters.class); + verify(jobOperator).start(eq(jiraImportJob), params.capture()); + JobParameters captured = params.getValue(); + assertThat(captured.getString(IntegrationJobParameters.TENANT_ID)).isEqualTo("org-1"); + assertThat(captured.getString(IntegrationJobParameters.TENANT_SCHEMA)).isEqualTo("tenant_acme"); + assertThat(captured.getString(IntegrationJobParameters.PROJECT_ID)).isEqualTo(PROJECT.toString()); + assertThat(captured.getString(IntegrationJobParameters.ISSUE_KEYS)).isEqualTo("PAY-1,PAY-2"); + // Only the domain job id identifies the JobInstance: one API launch == one fresh instance. + assertThat(captured.getParameter(IntegrationJobParameters.DOMAIN_JOB_ID).identifying()).isTrue(); + assertThat(captured.getParameter(IntegrationJobParameters.TENANT_SCHEMA).identifying()).isFalse(); + } + + @Test + @DisplayName("launchPushAll without a selection omits story ids and starts the push-all job") + void launches_push_all() throws Exception { + TenantContext.setCurrentTenant("org-1"); + TenantContext.setCurrentSchema("tenant_acme"); + + adapter().launchPushAll(JOB_ID, PROJECT, null); + + ArgumentCaptor params = ArgumentCaptor.forClass(JobParameters.class); + verify(jobOperator).start(eq(jiraPushAllJob), params.capture()); + assertThat(params.getValue().getParameter(IntegrationJobParameters.STORY_IDS)).isNull(); + assertThat(params.getValue().getParameter(IntegrationJobParameters.ISSUE_KEYS)).isNull(); + } + + @Test + @DisplayName("launchPushAll with a selection passes the story ids as a comma-joined job parameter") + void launches_push_all_with_selection() throws Exception { + TenantContext.setCurrentTenant("org-1"); + TenantContext.setCurrentSchema("tenant_acme"); + UUID s1 = UUID.randomUUID(); + UUID s2 = UUID.randomUUID(); + + adapter().launchPushAll(JOB_ID, PROJECT, List.of(s1, s2)); + + ArgumentCaptor params = ArgumentCaptor.forClass(JobParameters.class); + verify(jobOperator).start(eq(jiraPushAllJob), params.capture()); + assertThat(params.getValue().getString(IntegrationJobParameters.STORY_IDS)) + .isEqualTo(s1 + "," + s2); + } + + @Test + @DisplayName("a failed launch fails the projection row so no client watches a phantom RUNNING job") + void fails_projection_when_launch_fails() throws Exception { + IntegrationSyncJob job = new IntegrationSyncJob(PROJECT, IntegrationSyncJobType.IMPORT, 0, null); + when(jobOperator.start(any(Job.class), any(JobParameters.class))) + .thenThrow(new IllegalStateException("no executor")); + when(jobs.findById(JOB_ID)).thenReturn(Optional.of(job)); + when(jobs.save(job)).thenReturn(job); + + assertThatThrownBy(() -> adapter().launchImport(JOB_ID, PROJECT, null)) + .isInstanceOf(IllegalStateException.class); + + assertThat(job.getStatus()).isEqualTo(IntegrationSyncJobStatus.FAILED); + verify(progress).publish(job); + } +} diff --git a/src/test/java/com/kntro/reqsai/gateway/infrastructure/batch/IntegrationJobProgressListenerTest.java b/src/test/java/com/kntro/reqsai/gateway/infrastructure/batch/IntegrationJobProgressListenerTest.java new file mode 100644 index 00000000..042ee540 --- /dev/null +++ b/src/test/java/com/kntro/reqsai/gateway/infrastructure/batch/IntegrationJobProgressListenerTest.java @@ -0,0 +1,90 @@ +package com.kntro.reqsai.gateway.infrastructure.batch; + +import com.kntro.reqsai.gateway.application.notification.IntegrationJobProgressNotifier; +import com.kntro.reqsai.gateway.application.port.IntegrationSyncJobRepository; +import com.kntro.reqsai.gateway.domain.model.IntegrationSyncJob; +import com.kntro.reqsai.gateway.domain.model.IntegrationSyncJobType; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Tag; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import java.util.Optional; +import java.util.UUID; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.lenient; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; + +@Tag("unit") +@ExtendWith(MockitoExtension.class) +@DisplayName("Batch: per-item progress listener updates the projection and publishes each snapshot") +class IntegrationJobProgressListenerTest { + + private static final UUID JOB_ID = UUID.randomUUID(); + + @Mock + private IntegrationSyncJobRepository jobs; + @Mock + private IntegrationJobProgressNotifier progress; + + private IntegrationSyncJob job; + private IntegrationJobProgressListener listener; + + @BeforeEach + void setUp() { + job = new IntegrationSyncJob(UUID.randomUUID(), IntegrationSyncJobType.IMPORT, 3, UUID.randomUUID()); + lenient().when(jobs.findById(JOB_ID)).thenReturn(Optional.of(job)); + lenient().when(jobs.save(job)).thenReturn(job); + listener = new IntegrationJobProgressListener(JOB_ID, jobs, progress); + } + + @Test + @DisplayName("afterProcess maps each outcome onto the counters and publishes per item") + void counts_outcomes() { + listener.afterProcess("item-1", SyncItemOutcome.SUCCEEDED); + listener.afterProcess("item-2", SyncItemOutcome.SKIPPED); + listener.afterProcess("item-3", SyncItemOutcome.FAILED); + + assertThat(job.getProcessed()).isEqualTo(3); + assertThat(job.getSucceeded()).isEqualTo(1); + assertThat(job.getFailed()).isEqualTo(1); + verify(progress, times(3)).publish(job); + } + + @Test + @DisplayName("a skipped (thrown-and-swallowed) item counts as failed and publishes") + void counts_skip_as_failure() { + listener.onSkipInProcess("item-1", new IllegalStateException("boom")); + + assertThat(job.getProcessed()).isEqualTo(1); + assertThat(job.getFailed()).isEqualTo(1); + assertThat(job.getSucceeded()).isZero(); + verify(progress).publish(job); + } + + @Test + @DisplayName("a null outcome (filtered item) is not counted") + void ignores_filtered_items() { + listener.afterProcess("item-1", null); + + assertThat(job.getProcessed()).isZero(); + verify(progress, never()).publish(any()); + } + + @Test + @DisplayName("a terminal projection row is left untouched") + void ignores_terminal_row() { + job.complete(null); + + listener.afterProcess("item-1", SyncItemOutcome.SUCCEEDED); + + verify(progress, never()).publish(any()); + } +} diff --git a/src/test/java/com/kntro/reqsai/gateway/infrastructure/batch/JiraImportItemProcessorTest.java b/src/test/java/com/kntro/reqsai/gateway/infrastructure/batch/JiraImportItemProcessorTest.java new file mode 100644 index 00000000..574f74d2 --- /dev/null +++ b/src/test/java/com/kntro/reqsai/gateway/infrastructure/batch/JiraImportItemProcessorTest.java @@ -0,0 +1,43 @@ +package com.kntro.reqsai.gateway.infrastructure.batch; + +import com.kntro.reqsai.gateway.application.port.IntegrationProvider.RemoteIssue; +import com.kntro.reqsai.gateway.application.result.ImportStoryResult; +import com.kntro.reqsai.gateway.application.service.JiraImportService; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Tag; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import java.util.UUID; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.when; + +@Tag("unit") +@ExtendWith(MockitoExtension.class) +@DisplayName("Batch: import item processor maps service results to item outcomes") +class JiraImportItemProcessorTest { + + private static final UUID PROJECT = UUID.randomUUID(); + private static final RemoteIssue ISSUE = new RemoteIssue("PAY-1", "Summary", "Story", "desc", "MEDIUM"); + + @Mock + private JiraImportService importService; + + @Test + @DisplayName("imported -> SUCCEEDED, duplicate -> SKIPPED, failed -> FAILED") + void maps_outcomes() { + JiraImportItemProcessor processor = new JiraImportItemProcessor(importService, PROJECT); + + when(importService.importIssue(PROJECT, ISSUE)) + .thenReturn(ImportStoryResult.imported("PAY-1", UUID.randomUUID())) + .thenReturn(ImportStoryResult.duplicate("PAY-1")) + .thenReturn(ImportStoryResult.failed("PAY-1", "boom")); + + assertThat(processor.process(ISSUE)).isEqualTo(SyncItemOutcome.SUCCEEDED); + assertThat(processor.process(ISSUE)).isEqualTo(SyncItemOutcome.SKIPPED); + assertThat(processor.process(ISSUE)).isEqualTo(SyncItemOutcome.FAILED); + } +} diff --git a/src/test/java/com/kntro/reqsai/gateway/infrastructure/batch/JiraPushAllReaderTest.java b/src/test/java/com/kntro/reqsai/gateway/infrastructure/batch/JiraPushAllReaderTest.java new file mode 100644 index 00000000..1766935e --- /dev/null +++ b/src/test/java/com/kntro/reqsai/gateway/infrastructure/batch/JiraPushAllReaderTest.java @@ -0,0 +1,99 @@ +package com.kntro.reqsai.gateway.infrastructure.batch; + +import com.kntro.reqsai.discovery.api.DiscoveryStoryReadPort; +import com.kntro.reqsai.discovery.api.StoryView; +import com.kntro.reqsai.gateway.application.notification.IntegrationJobProgressNotifier; +import com.kntro.reqsai.gateway.application.port.IntegrationSyncJobRepository; +import com.kntro.reqsai.gateway.domain.model.IntegrationSyncJob; +import com.kntro.reqsai.gateway.domain.model.IntegrationSyncJobType; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Tag; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.batch.infrastructure.item.support.ListItemReader; + +import java.util.ArrayList; +import java.util.List; +import java.util.Optional; +import java.util.UUID; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.lenient; +import static org.mockito.Mockito.when; + +/** + * Unit tests for the push-all reader in {@link IntegrationBatchJobsConfiguration}: it filters the + * project backlog to the requested story ids (preserving order, ignoring ids not in the project) and + * plans the projection {@code total} to the filtered count. An absent/blank selection pushes all. + */ +@Tag("unit") +@ExtendWith(MockitoExtension.class) +@DisplayName("Batch: push-all reader filters the backlog to the selected story ids") +class JiraPushAllReaderTest { + + private static final UUID PROJECT = UUID.randomUUID(); + private static final UUID JOB_ID = UUID.randomUUID(); + + @Mock + private DiscoveryStoryReadPort stories; + @Mock + private IntegrationSyncJobRepository jobs; + @Mock + private IntegrationJobProgressNotifier progress; + + private final IntegrationBatchJobsConfiguration config = new IntegrationBatchJobsConfiguration(); + + private List readAll(ListItemReader reader) { + List out = new ArrayList<>(); + StoryView next; + while ((next = reader.read()) != null) { + out.add(next); + } + return out; + } + + private StoryView story(UUID id) { + return new StoryView(id, PROJECT, "Title", "user", "do", "benefit", "MEDIUM", null, List.of()); + } + + private void planningJobIsRunning() { + IntegrationSyncJob job = new IntegrationSyncJob(PROJECT, IntegrationSyncJobType.PUSH_ALL, 0, null); + lenient().when(jobs.findById(JOB_ID)).thenReturn(Optional.of(job)); + lenient().when(jobs.save(job)).thenReturn(job); + } + + @Test + @DisplayName("filters to the selected ids (order preserved), ignores unknown ids, and totals the filtered count") + void filters_to_selection() { + StoryView a = story(UUID.randomUUID()); + StoryView b = story(UUID.randomUUID()); + StoryView c = story(UUID.randomUUID()); + UUID missing = UUID.randomUUID(); + when(stories.listStories(PROJECT)).thenReturn(List.of(a, b, c)); + planningJobIsRunning(); + + // request c and a and a missing id; result must follow the backlog order (a, c) + String csv = c.storyId() + "," + a.storyId() + "," + missing; + ListItemReader reader = config.jiraPushAllReader( + JOB_ID.toString(), PROJECT.toString(), csv, stories, jobs, progress); + + List read = readAll(reader); + assertThat(read).extracting(StoryView::storyId).containsExactly(a.storyId(), c.storyId()); + } + + @Test + @DisplayName("an absent/blank selection pushes every story") + void no_selection_pushes_all() { + StoryView a = story(UUID.randomUUID()); + StoryView b = story(UUID.randomUUID()); + when(stories.listStories(PROJECT)).thenReturn(List.of(a, b)); + planningJobIsRunning(); + + ListItemReader reader = config.jiraPushAllReader( + JOB_ID.toString(), PROJECT.toString(), null, stories, jobs, progress); + + assertThat(readAll(reader)).extracting(StoryView::storyId).containsExactly(a.storyId(), b.storyId()); + } +} diff --git a/src/test/java/com/kntro/reqsai/gateway/infrastructure/crypto/AesGcmCipherTest.java b/src/test/java/com/kntro/reqsai/gateway/infrastructure/crypto/AesGcmCipherTest.java new file mode 100644 index 00000000..d7b07008 --- /dev/null +++ b/src/test/java/com/kntro/reqsai/gateway/infrastructure/crypto/AesGcmCipherTest.java @@ -0,0 +1,60 @@ +package com.kntro.reqsai.gateway.infrastructure.crypto; + +import com.kntro.reqsai.shared.domain.exception.InfrastructureException; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import java.nio.charset.StandardCharsets; +import java.util.Base64; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +@DisplayName("Infrastructure: AES-256-GCM cipher") +class AesGcmCipherTest { + + private static final String KEY = Base64.getEncoder().encodeToString(new byte[32]); + + @Test + @DisplayName("encrypt then decrypt round-trips the plaintext") + void round_trips() { + AesGcmCipher cipher = new AesGcmCipher(KEY); + byte[] plaintext = "super-secret-jira-token".getBytes(StandardCharsets.UTF_8); + + byte[] encrypted = cipher.encrypt(plaintext); + byte[] decrypted = cipher.decrypt(encrypted); + + assertThat(new String(decrypted, StandardCharsets.UTF_8)).isEqualTo("super-secret-jira-token"); + assertThat(encrypted).isNotEqualTo(plaintext); + // IV(12) is prepended, so ciphertext is longer than plaintext. + assertThat(encrypted.length).isGreaterThan(plaintext.length + 12); + } + + @Test + @DisplayName("uses a fresh IV per value (same input yields different ciphertext)") + void fresh_iv_per_value() { + AesGcmCipher cipher = new AesGcmCipher(KEY); + byte[] plaintext = "token".getBytes(StandardCharsets.UTF_8); + + assertThat(cipher.encrypt(plaintext)).isNotEqualTo(cipher.encrypt(plaintext)); + } + + @Test + @DisplayName("rejects a key that does not decode to 32 bytes") + void rejects_wrong_key_length() { + String shortKey = Base64.getEncoder().encodeToString(new byte[16]); + assertThatThrownBy(() -> new AesGcmCipher(shortKey)) + .isInstanceOf(InfrastructureException.class); + } + + @Test + @DisplayName("fails to decrypt tampered ciphertext (GCM auth tag)") + void detects_tampering() { + AesGcmCipher cipher = new AesGcmCipher(KEY); + byte[] encrypted = cipher.encrypt("token".getBytes(StandardCharsets.UTF_8)); + encrypted[encrypted.length - 1] ^= 0x01; // flip a bit in the tag + + assertThatThrownBy(() -> cipher.decrypt(encrypted)) + .isInstanceOf(InfrastructureException.class); + } +} diff --git a/src/test/java/com/kntro/reqsai/gateway/infrastructure/jira/JiraAdfBuilderTest.java b/src/test/java/com/kntro/reqsai/gateway/infrastructure/jira/JiraAdfBuilderTest.java new file mode 100644 index 00000000..f51a8a90 --- /dev/null +++ b/src/test/java/com/kntro/reqsai/gateway/infrastructure/jira/JiraAdfBuilderTest.java @@ -0,0 +1,57 @@ +package com.kntro.reqsai.gateway.infrastructure.jira; + +import com.kntro.reqsai.discovery.api.AcceptanceCriterionView; +import com.kntro.reqsai.discovery.api.StoryView; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import java.util.List; +import java.util.Map; +import java.util.UUID; + +import static org.assertj.core.api.Assertions.assertThat; + +@DisplayName("Infrastructure: Jira ADF builder") +class JiraAdfBuilderTest { + + @Test + @DisplayName("builds a valid ADF doc with the story statement, metadata and criteria bullets") + void builds_adf() { + StoryView story = new StoryView( + UUID.randomUUID(), UUID.randomUUID(), + "Login with Google", "user", "sign in with my Google account", "I don't manage another password", + "HIGH", 3, + List.of(new AcceptanceCriterionView("Happy path", "I have a Google account", "I click sign in", "I am logged in"))); + + Map doc = JiraAdfBuilder.buildDescription(story); + + assertThat(doc).containsEntry("type", "doc").containsEntry("version", 1); + @SuppressWarnings("unchecked") + List content = (List) doc.get("content"); + // paragraph (story) + paragraph (meta) + heading + bulletList + assertThat(content).hasSize(4); + String json = doc.toString(); + assertThat(json).contains("As user, I want to sign in with my Google account, so that I don't manage another password."); + assertThat(json).contains("Priority: HIGH"); + assertThat(json).contains("Story points: 3"); + assertThat(json).contains("Acceptance Criteria"); + assertThat(json).contains("Given I have a Google account, When I click sign in, Then I am logged in."); + } + + @Test + @DisplayName("omits the acceptance-criteria section when there are none") + void omits_criteria_when_empty() { + StoryView story = new StoryView( + UUID.randomUUID(), UUID.randomUUID(), + "Title", "user", "do", "benefit", "LOW", null, List.of()); + + Map doc = JiraAdfBuilder.buildDescription(story); + + @SuppressWarnings("unchecked") + List content = (List) doc.get("content"); + // just the two paragraphs, no heading/list + assertThat(content).hasSize(2); + assertThat(doc.toString()).doesNotContain("Acceptance Criteria"); + assertThat(doc.toString()).doesNotContain("Story points"); + } +} diff --git a/src/test/java/com/kntro/reqsai/gateway/infrastructure/jira/JiraApiContextTest.java b/src/test/java/com/kntro/reqsai/gateway/infrastructure/jira/JiraApiContextTest.java new file mode 100644 index 00000000..a2951282 --- /dev/null +++ b/src/test/java/com/kntro/reqsai/gateway/infrastructure/jira/JiraApiContextTest.java @@ -0,0 +1,37 @@ +package com.kntro.reqsai.gateway.infrastructure.jira; + +import com.kntro.reqsai.gateway.infrastructure.jira.JiraClient.JiraApiContext; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import java.nio.charset.StandardCharsets; +import java.util.Base64; + +import static org.assertj.core.api.Assertions.assertThat; + +@DisplayName("Infrastructure: dual-mode Jira API base URL + auth selection") +class JiraApiContextTest { + + @Test + @DisplayName("API_TOKEN mode uses the site base URL and Basic auth") + void api_token_context() { + JiraApiContext ctx = JiraApiContext.apiToken("https://acme.atlassian.net", "pm@acme.com", "tok"); + + assertThat(ctx.apiBase()).isEqualTo("https://acme.atlassian.net/rest/api/3"); + assertThat(ctx.browseBase()).isEqualTo("https://acme.atlassian.net"); + assertThat(ctx.authHeader()).startsWith("Basic "); + String decoded = new String(Base64.getDecoder().decode(ctx.authHeader().substring("Basic ".length())), + StandardCharsets.UTF_8); + assertThat(decoded).isEqualTo("pm@acme.com:tok"); + } + + @Test + @DisplayName("OAUTH2 mode uses the api.atlassian.com/ex/jira/{cloudId} base and Bearer auth") + void oauth_context() { + JiraApiContext ctx = JiraApiContext.oauth("cloud-1", "access-abc", "https://acme.atlassian.net"); + + assertThat(ctx.apiBase()).isEqualTo("https://api.atlassian.com/ex/jira/cloud-1/rest/api/3"); + assertThat(ctx.browseBase()).isEqualTo("https://acme.atlassian.net"); // browse links use the human site + assertThat(ctx.authHeader()).isEqualTo("Bearer access-abc"); + } +} diff --git a/src/test/java/com/kntro/reqsai/gateway/infrastructure/jira/JiraClientTest.java b/src/test/java/com/kntro/reqsai/gateway/infrastructure/jira/JiraClientTest.java new file mode 100644 index 00000000..80725d01 --- /dev/null +++ b/src/test/java/com/kntro/reqsai/gateway/infrastructure/jira/JiraClientTest.java @@ -0,0 +1,171 @@ +package com.kntro.reqsai.gateway.infrastructure.jira; + +import com.kntro.reqsai.gateway.infrastructure.jira.JiraClient.CreatedIssue; +import com.kntro.reqsai.gateway.infrastructure.jira.JiraClient.IssueSearchPage; +import com.kntro.reqsai.gateway.infrastructure.jira.JiraClient.JiraApiContext; +import com.kntro.reqsai.gateway.infrastructure.jira.JiraClient.JiraIssue; +import com.kntro.reqsai.gateway.infrastructure.jira.JiraClient.JiraIssueType; +import com.kntro.reqsai.shared.domain.exception.InfrastructureException; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Tag; +import org.junit.jupiter.api.Test; +import org.springframework.http.HttpStatus; +import org.springframework.http.MediaType; +import org.springframework.test.web.client.MockRestServiceServer; +import org.springframework.web.client.RestClient; + +import java.util.List; +import java.util.Map; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.springframework.test.web.client.match.MockRestRequestMatchers.jsonPath; +import static org.springframework.test.web.client.match.MockRestRequestMatchers.method; +import static org.springframework.test.web.client.match.MockRestRequestMatchers.requestTo; +import static org.springframework.test.web.client.response.MockRestResponseCreators.withStatus; +import static org.springframework.test.web.client.response.MockRestResponseCreators.withSuccess; + +@Tag("unit") +@DisplayName("Infrastructure: JiraClient (dual-mode REST, project-scoped types, diagnosable errors, JQL search)") +class JiraClientTest { + + private static final JiraApiContext CTX = + JiraApiContext.apiToken("https://acme.atlassian.net", "pm@acme.com", "tok"); + private static final String BASE = "https://acme.atlassian.net/rest/api/3"; + + private RestClient.Builder builder; + private MockRestServiceServer server; + private JiraClient client; + + @BeforeEach + void setUp() { + builder = RestClient.builder(); + server = MockRestServiceServer.bindTo(builder).build(); + client = new JiraClient(builder); + } + + @Test + @DisplayName("listIssueTypes reads the PROJECT-SCOPED createmeta list and dedupes by id") + void project_scoped_issue_types_deduped() { + server.expect(requestTo(BASE + "/issue/createmeta/PAY/issuetypes?maxResults=200")) + .andExpect(method(org.springframework.http.HttpMethod.GET)) + .andRespond(withSuccess(""" + {"issueTypes":[ + {"id":"10001","name":"Historia"}, + {"id":"10001","name":"Historia"}, + {"id":"10002","name":"Tarea"} + ]}""", MediaType.APPLICATION_JSON)); + + List types = client.listIssueTypes(CTX, "PAY"); + + assertThat(types).extracting(JiraIssueType::id).containsExactly("10001", "10002"); + assertThat(types).extracting(JiraIssueType::name).containsExactly("Historia", "Tarea"); + server.verify(); + } + + @Test + @DisplayName("createIssue resolves the issue type NAME to its project id and sends issuetype:{id}") + void create_sends_issue_type_by_id() { + server.expect(requestTo(BASE + "/issue/createmeta/PAY/issuetypes?maxResults=200")) + .andRespond(withSuccess("{\"issueTypes\":[{\"id\":\"10001\",\"name\":\"Historia\"}]}", + MediaType.APPLICATION_JSON)); + // The create-screen meta declares a REQUIRED rich-text custom field (the real-world + // "Criterios de aceptación" case) — the client must fill it generically or Jira 400s. + server.expect(requestTo(BASE + "/issue/createmeta/PAY/issuetypes/10001?maxResults=200")) + .andRespond(withSuccess("{\"fields\":[{\"fieldId\":\"customfield_10037\"," + + "\"name\":\"Criterios de aceptación\",\"required\":true," + + "\"hasDefaultValue\":false,\"schema\":{\"type\":\"doc\"}}]}", + MediaType.APPLICATION_JSON)); + server.expect(requestTo(BASE + "/issue")) + .andExpect(method(org.springframework.http.HttpMethod.POST)) + .andExpect(jsonPath("$.fields.issuetype.id").value("10001")) + .andExpect(jsonPath("$.fields.project.key").value("PAY")) + .andExpect(jsonPath("$.fields.customfield_10037.type").value("doc")) + .andExpect(jsonPath("$.fields.customfield_10037.content[0].content[0].text") + .value("Given ok, When login, Then home.")) + .andRespond(withStatus(HttpStatus.CREATED) + .body("{\"id\":\"42\",\"key\":\"PAY-42\",\"self\":\"https://acme.atlassian.net/rest/api/3/issue/42\"}") + .contentType(MediaType.APPLICATION_JSON)); + + CreatedIssue created = client.createIssue(CTX, "PAY", "Historia", "Login con Google", + Map.of("type", "doc", "version", 1, "content", List.of()), + "Given ok, When login, Then home."); + + assertThat(created.key()).isEqualTo("PAY-42"); + assertThat(created.id()).isEqualTo("42"); + server.verify(); + } + + @Test + @DisplayName("createIssue surfaces Jira errorMessages + field errors on a 400 (diagnosable, token-free)") + void create_surfaces_jira_error_body() { + server.expect(requestTo(BASE + "/issue/createmeta/PAY/issuetypes?maxResults=200")) + .andRespond(withSuccess("{\"issueTypes\":[{\"id\":\"10001\",\"name\":\"Historia\"}]}", + MediaType.APPLICATION_JSON)); + server.expect(requestTo(BASE + "/issue/createmeta/PAY/issuetypes/10001?maxResults=200")) + .andRespond(withSuccess("{\"fields\":[]}", MediaType.APPLICATION_JSON)); + server.expect(requestTo(BASE + "/issue")) + .andRespond(withStatus(HttpStatus.BAD_REQUEST) + .body("{\"errorMessages\":[\"Field 'customfield_10011' is required\"]," + + "\"errors\":{\"summary\":\"Summary must be provided.\"}}") + .contentType(MediaType.APPLICATION_JSON)); + + assertThatThrownBy(() -> client.createIssue(CTX, "PAY", "Historia", "x", + Map.of("type", "doc", "version", 1, "content", List.of()), "")) + .isInstanceOf(InfrastructureException.class) + .hasMessageContaining("Field 'customfield_10011' is required") + .hasMessageContaining("summary: Summary must be provided."); + server.verify(); + } + + @Test + @DisplayName("resolveIssueTypeId throws a diagnosable error listing available types when the name is invalid") + void resolve_unknown_issue_type_lists_available() { + server.expect(requestTo(BASE + "/issue/createmeta/PAY/issuetypes?maxResults=200")) + .andRespond(withSuccess("{\"issueTypes\":[{\"id\":\"10001\",\"name\":\"Historia\"}]}", + MediaType.APPLICATION_JSON)); + + assertThatThrownBy(() -> client.resolveIssueTypeId(CTX, "PAY", "Bug")) + .isInstanceOf(InfrastructureException.class) + .hasMessageContaining("issue type 'Bug' is not available") + .hasMessageContaining("Historia"); + server.verify(); + } + + @Test + @DisplayName("searchAllIssues follows nextPageToken until isLast and concatenates issues") + void search_paginates_by_token() { + server.expect(requestTo(org.hamcrest.Matchers.containsString("/search/jql?jql="))) + .andRespond(withSuccess(""" + {"issues":[{"key":"PAY-1","fields":{"summary":"One"}}], + "isLast":false,"nextPageToken":"tok-2"}""", MediaType.APPLICATION_JSON)); + server.expect(requestTo(org.hamcrest.Matchers.containsString("nextPageToken=tok-2"))) + .andRespond(withSuccess(""" + {"issues":[{"key":"PAY-2","fields":{"summary":"Two"}}], + "isLast":true}""", MediaType.APPLICATION_JSON)); + + List issues = client.searchAllIssues(CTX, + "project = \"PAY\" AND issuetype = \"Historia\" ORDER BY created ASC"); + + assertThat(issues).extracting(JiraIssue::key).containsExactly("PAY-1", "PAY-2"); + server.verify(); + } + + @Test + @DisplayName("a single search page reports isLast and exposes the raw issue fields node") + void single_search_page() { + server.expect(requestTo(org.hamcrest.Matchers.containsString("/search/jql?jql="))) + .andRespond(withSuccess(""" + {"issues":[{"key":"PAY-9","fields":{"summary":"Nine","priority":{"name":"High"}}}], + "isLast":true}""", MediaType.APPLICATION_JSON)); + + IssueSearchPage page = client.searchIssues(CTX, "project = \"PAY\"", 100, null); + + assertThat(page.isLast()).isTrue(); + assertThat(page.issues()).hasSize(1); + assertThat(page.issues().getFirst().fields().summary()).isEqualTo("Nine"); + assertThat(page.issues().getFirst().fields().priority().name()).isEqualTo("High"); + server.verify(); + } +} diff --git a/src/test/java/com/kntro/reqsai/gateway/infrastructure/jira/JiraProviderImportMappingTest.java b/src/test/java/com/kntro/reqsai/gateway/infrastructure/jira/JiraProviderImportMappingTest.java new file mode 100644 index 00000000..5a7b8506 --- /dev/null +++ b/src/test/java/com/kntro/reqsai/gateway/infrastructure/jira/JiraProviderImportMappingTest.java @@ -0,0 +1,67 @@ +package com.kntro.reqsai.gateway.infrastructure.jira; + +import com.kntro.reqsai.gateway.application.port.IntegrationProvider.ProviderCredentials; +import com.kntro.reqsai.gateway.application.port.IntegrationProvider.RemoteIssue; +import com.kntro.reqsai.gateway.infrastructure.jira.JiraClient.IssueFields; +import com.kntro.reqsai.gateway.infrastructure.jira.JiraClient.JiraApiContext; +import com.kntro.reqsai.gateway.infrastructure.jira.JiraClient.JiraIssue; +import com.kntro.reqsai.gateway.infrastructure.jira.JiraClient.NamedRef; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Tag; +import org.junit.jupiter.api.Test; + +import java.util.List; +import java.util.Map; + +import static org.assertj.core.api.Assertions.assertThat; + +@Tag("unit") +@DisplayName("Infrastructure: Jira issue -> RemoteIssue import mapping (priority + ADF flatten)") +class JiraProviderImportMappingTest { + + private static final ProviderCredentials CREDS = + ProviderCredentials.apiToken("https://acme.atlassian.net", "pm@acme.com", "tok"); + + @Test + @DisplayName("maps summary, ADF description (flattened), issue type and priority scale") + void maps_issue_fields() { + Map adf = Map.of( + "type", "doc", "version", 1, + "content", List.of( + Map.of("type", "paragraph", "content", + List.of(Map.of("type", "text", "text", "As a user I want to reset my password."))))); + JiraIssue high = new JiraIssue("PAY-1", + new IssueFields("Password reset", adf, new NamedRef("10001", "Historia"), new NamedRef("2", "High"))); + JiraIssue lowest = new JiraIssue("PAY-2", + new IssueFields("Tidy up", null, new NamedRef("10002", "Tarea"), new NamedRef("5", "Lowest"))); + JiraIssue noPriority = new JiraIssue("PAY-3", + new IssueFields("No priority", null, new NamedRef("10001", "Historia"), null)); + + JiraProvider provider = new JiraProvider(new StubSearchClient(List.of(high, lowest, noPriority))); + List issues = provider.searchImportableIssues(CREDS, "PAY", "Historia"); + + assertThat(issues).hasSize(3); + assertThat(issues.get(0).issueKey()).isEqualTo("PAY-1"); + assertThat(issues.get(0).summary()).isEqualTo("Password reset"); + assertThat(issues.get(0).description()).contains("reset my password"); + assertThat(issues.get(0).issueType()).isEqualTo("Historia"); + assertThat(issues.get(0).priority()).isEqualTo("HIGH"); + assertThat(issues.get(1).priority()).isEqualTo("LOW"); // Lowest -> LOW + assertThat(issues.get(1).description()).isEmpty(); // null ADF -> "" + assertThat(issues.get(2).priority()).isEqualTo("MEDIUM"); // no priority -> MEDIUM + } + + /** Minimal JiraClient stand-in that returns a fixed search result. */ + private static final class StubSearchClient extends JiraClient { + private final List result; + + private StubSearchClient(List result) { + this.result = result; + } + + @Override + public List searchAllIssues(JiraApiContext ctx, String jql) { + return result; + } + } +} diff --git a/src/test/java/com/kntro/reqsai/gateway/interfaces/rest/JiraImportIntegrationTest.java b/src/test/java/com/kntro/reqsai/gateway/interfaces/rest/JiraImportIntegrationTest.java new file mode 100644 index 00000000..5e02b077 --- /dev/null +++ b/src/test/java/com/kntro/reqsai/gateway/interfaces/rest/JiraImportIntegrationTest.java @@ -0,0 +1,213 @@ +package com.kntro.reqsai.gateway.interfaces.rest; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.kntro.reqsai.gateway.StubJiraProviderConfig; +import com.kntro.reqsai.testsupport.AbstractIntegrationTest; +import com.kntro.reqsai.testsupport.StubEmbeddingConfig; +import com.kntro.reqsai.testsupport.StubRequirementGenerationConfig; +import com.kntro.reqsai.testsupport.TestJwtFactory; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Tag; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.context.annotation.Import; +import org.springframework.http.HttpStatus; +import org.springframework.http.MediaType; +import org.springframework.http.ResponseEntity; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.test.context.ActiveProfiles; + +import java.util.Map; +import java.util.UUID; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * End-to-end test of the ASYNC Jira IMPORT slice: connects Jira at the org level, sets a project target, + * then starts the import job (202 + RUNNING snapshot), polls the job endpoint until the Spring Batch + * execution COMPLETEs in the right tenant schema, and asserts stories were created with a near-duplicate + * counted as processed-but-skipped (both stubbed issues map to the same stubbed generation output). Also + * asserts the batch metadata landed in the global {@code public.batch_*} tables, not a tenant schema. + * + *

The Jira boundary is stubbed via {@link StubJiraProviderConfig} (no real network) and the LLM via + * {@link StubRequirementGenerationConfig} (no real model — this test is NOT tagged {@code llm}). + */ +@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT) +@ActiveProfiles("test") +@Import({StubJiraProviderConfig.class, StubEmbeddingConfig.class, StubRequirementGenerationConfig.class}) +@Tag("integration") +@DisplayName("Integration: Jira import (preview + import)") +class JiraImportIntegrationTest extends AbstractIntegrationTest { + + private static final String USER_ID = "00000000-0000-0000-0000-000000000001"; + private static final ObjectMapper JSON = new ObjectMapper(); + + @Autowired + private JdbcTemplate jdbcTemplate; + + @Test + @DisplayName("previews candidates then imports: creates stories and skips a duplicate") + void previews_then_imports() throws Exception { + String suffix = UUID.randomUUID().toString().substring(0, 8); + String slug = "acme-" + suffix; + String schema = "tenant_" + slug; + String orgId = createOrg(suffix, slug); + UUID projectId = createProject(orgId, schema, "Payment Platform"); + + String connectionId = connectAndReadId(orgId, schema); + setTarget(orgId, projectId, connectionId); + + // Preview lists both stubbed issues; neither is a duplicate yet (empty backlog). + ResponseEntity previewRes = client().get() + .uri("/api/projects/{p}/integration/jira/import/preview", projectId) + .header("Authorization", TestJwtFactory.bearer(USER_ID, orgId, "ROLE_USER")) + .header("Api-Version", "1") + .exchange((req, res) -> ResponseEntity.status(res.getStatusCode()).body(res.bodyTo(String.class))); + assertThat(previewRes.getStatusCode()).isEqualTo(HttpStatus.OK); + JsonNode preview = JSON.readTree(previewRes.getBody()); + assertThat(preview.get("total").asInt()).isEqualTo(2); + assertThat(preview.get("issues")).hasSize(2); + + // Start the import job: 202 Accepted with a RUNNING snapshot. + ResponseEntity importRes = client().post() + .uri("/api/projects/{p}/integration/jira/import", projectId) + .header("Authorization", TestJwtFactory.bearer(USER_ID, orgId, "ROLE_USER")) + .header("Api-Version", "1") + .contentType(MediaType.APPLICATION_JSON) + .body(Map.of()) + .exchange((req, res) -> ResponseEntity.status(res.getStatusCode()).body(res.bodyTo(String.class))); + assertThat(importRes.getStatusCode()).isEqualTo(HttpStatus.ACCEPTED); + JsonNode accepted = JSON.readTree(importRes.getBody()); + assertThat(accepted.get("jobType").asText()).isEqualTo("IMPORT"); + assertThat(accepted.get("status").asText()).isEqualTo("RUNNING"); + String jobId = accepted.get("id").asText(); + + // The RUNNING job is visible to the reload-recovery query while it lasts, and the terminal + // state is reached by polling the job endpoint (the batch runs on the async executor). + JsonNode result = awaitJobCompletion(projectId, jobId, orgId); + + // Both stubbed issues map (via the stubbed generation) to the same story, so the second is a + // duplicate: processed but neither succeeded nor failed, summarized in the message. + assertThat(result.get("status").asText()).isEqualTo("COMPLETED"); + assertThat(result.get("total").asInt()).isEqualTo(2); + assertThat(result.get("processed").asInt()).isEqualTo(2); + assertThat(result.get("succeeded").asInt()).isEqualTo(1); + assertThat(result.get("failed").asInt()).isZero(); + assertThat(result.get("message").asText()).isEqualTo("1 duplicados omitidos"); + assertThat(result.hasNonNull("finishedAt")).isTrue(); + + // Exactly one story persisted in the tenant backlog — written from the batch thread, proving + // the tenant context captured at launch was restored by the job listener. + Integer storyCount = jdbcTemplate.queryForObject( + "SELECT count(*) FROM \"" + schema + "\".user_stories WHERE project_id = ?::uuid", + Integer.class, projectId.toString()); + assertThat(storyCount).isEqualTo(1); + + // Spring Batch metadata lands in the global public schema (schema-qualified table prefix). + Integer batchInstances = jdbcTemplate.queryForObject( + "SELECT count(*) FROM public.batch_job_instance WHERE job_name = 'jiraImportJob'", + Integer.class); + assertThat(batchInstances).isGreaterThanOrEqualTo(1); + + // The jobs listing returns the finished job (most recent first). + ResponseEntity listRes = client().get() + .uri("/api/projects/{p}/integration/jira/jobs", projectId) + .header("Authorization", TestJwtFactory.bearer(USER_ID, orgId, "ROLE_USER")) + .header("Api-Version", "1") + .exchange((req, res) -> ResponseEntity.status(res.getStatusCode()).body(res.bodyTo(String.class))); + assertThat(listRes.getStatusCode()).isEqualTo(HttpStatus.OK); + JsonNode jobsList = JSON.readTree(listRes.getBody()); + assertThat(jobsList.isArray()).isTrue(); + assertThat(jobsList.get(0).get("id").asText()).isEqualTo(jobId); + } + + /** Polls {@code GET .../jobs/{jobId}} until the job leaves RUNNING (max ~60s). */ + private JsonNode awaitJobCompletion(UUID projectId, String jobId, String orgId) throws Exception { + JsonNode job = null; + for (int i = 0; i < 200; i++) { + ResponseEntity res = client().get() + .uri("/api/projects/{p}/integration/jira/jobs/{j}", projectId, jobId) + .header("Authorization", TestJwtFactory.bearer(USER_ID, orgId, "ROLE_USER")) + .header("Api-Version", "1") + .exchange((req, response) -> ResponseEntity.status(response.getStatusCode()) + .body(response.bodyTo(String.class))); + assertThat(res.getStatusCode()).isEqualTo(HttpStatus.OK); + job = JSON.readTree(res.getBody()); + if (!"RUNNING".equals(job.get("status").asText())) { + return job; + } + Thread.sleep(300); + } + throw new AssertionError("Job " + jobId + " did not finish in time: " + job); + } + + @Test + @DisplayName("returns 409 when importing with no target configured") + void import_without_target() { + String suffix = UUID.randomUUID().toString().substring(0, 8); + String orgId = createOrg(suffix, "acme-" + suffix); + UUID projectId = UUID.randomUUID(); + + ResponseEntity res = client().post() + .uri("/api/projects/{p}/integration/jira/import", projectId) + .header("Authorization", TestJwtFactory.bearer(USER_ID, orgId, "ROLE_USER")) + .header("Api-Version", "1") + .contentType(MediaType.APPLICATION_JSON) + .body(Map.of()) + .exchange((req, response) -> ResponseEntity.status(response.getStatusCode()) + .body(response.bodyTo(String.class))); + + assertThat(res.getStatusCode()).isEqualTo(HttpStatus.CONFLICT); + assertThat(res.getBody()).contains("INTEGRATION_TARGET_NOT_CONFIGURED"); + } + + private String connectAndReadId(String orgId, String schema) throws Exception { + ResponseEntity connectRes = client().post() + .uri("/api/organizations/{orgId}/integrations/jira", orgId) + .header("Authorization", TestJwtFactory.bearer(USER_ID, orgId, "ROLE_USER")) + .header("Api-Version", "1") + .contentType(MediaType.APPLICATION_JSON) + .body(Map.of("siteUrl", "https://acme.atlassian.net", "email", "pm@acme.com", "apiToken", "tok")) + .exchange((req, res) -> ResponseEntity.status(res.getStatusCode()).body(res.bodyTo(String.class))); + assertThat(connectRes.getStatusCode()).isEqualTo(HttpStatus.CREATED); + return JSON.readTree(connectRes.getBody()).get("id").asText(); + } + + private void setTarget(String orgId, UUID projectId, String connectionId) { + ResponseEntity targetRes = client().put() + .uri("/api/projects/{p}/integration/jira/target", projectId) + .header("Authorization", TestJwtFactory.bearer(USER_ID, orgId, "ROLE_USER")) + .header("Api-Version", "1") + .contentType(MediaType.APPLICATION_JSON) + .body(Map.of("connectionId", connectionId, "jiraProjectKey", "PAY", "issueTypeName", "Story")) + .exchange((req, res) -> ResponseEntity.status(res.getStatusCode()).body(res.bodyTo(String.class))); + assertThat(targetRes.getStatusCode()).isEqualTo(HttpStatus.OK); + } + + private UUID createProject(String orgId, String schema, String name) { + client().post().uri("/api/organizations/{orgId}/projects", orgId) + .header("Authorization", TestJwtFactory.bearer(USER_ID, orgId, "ROLE_USER")) + .header("Api-Version", "1") + .contentType(MediaType.APPLICATION_JSON) + .body(Map.of("name", name, "programmingLanguages", java.util.List.of("Java"), + "frameworks", java.util.List.of("Spring Boot"), "clientPlatforms", java.util.List.of("Web"), + "databases", java.util.List.of("PostgreSQL"), "architecture", "Hexagonal", "domain", "Fintech")) + .exchange((req, res) -> ResponseEntity.status(res.getStatusCode()).body(res.bodyTo(String.class))); + return UUID.fromString(jdbcTemplate.queryForObject( + "SELECT id::text FROM \"" + schema + "\".projects WHERE name = ?", String.class, name)); + } + + private String createOrg(String suffix, String expectedSlug) { + ResponseEntity orgRes = client().post().uri("/api/organizations") + .header("Authorization", TestJwtFactory.bearer(USER_ID, UUID.randomUUID().toString(), "ROLE_USER")) + .header("Api-Version", "1") + .contentType(MediaType.APPLICATION_JSON) + .body(Map.of("name", "Acme " + suffix)) + .exchange((req, res) -> ResponseEntity.status(res.getStatusCode()).body(res.bodyTo(String.class))); + assertThat(orgRes.getStatusCode()).isEqualTo(HttpStatus.CREATED); + return jdbcTemplate.queryForObject( + "SELECT id FROM public.organizations WHERE slug = ?", String.class, expectedSlug); + } +} diff --git a/src/test/java/com/kntro/reqsai/gateway/interfaces/rest/JiraIntegrationPushIntegrationTest.java b/src/test/java/com/kntro/reqsai/gateway/interfaces/rest/JiraIntegrationPushIntegrationTest.java new file mode 100644 index 00000000..4c0e8fb8 --- /dev/null +++ b/src/test/java/com/kntro/reqsai/gateway/interfaces/rest/JiraIntegrationPushIntegrationTest.java @@ -0,0 +1,297 @@ +package com.kntro.reqsai.gateway.interfaces.rest; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.kntro.reqsai.gateway.StubJiraProviderConfig; +import com.kntro.reqsai.testsupport.AbstractIntegrationTest; +import com.kntro.reqsai.testsupport.StubEmbeddingConfig; +import com.kntro.reqsai.testsupport.TestJwtFactory; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Tag; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.context.annotation.Import; +import org.springframework.http.HttpStatus; +import org.springframework.http.MediaType; +import org.springframework.http.ResponseEntity; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.test.context.ActiveProfiles; + +import java.util.Map; +import java.util.UUID; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * End-to-end test of the Jira integration slice across the full multitenant flow: creates an org + * (provisioning its tenant schema with the V21 integration tables), connects Jira at the org level + * (persisting an ENCRYPTED token), sets a project target, seeds a story, and pushes it — asserting the + * connection/target rows persist (token encrypted, never echoed) and the push maps to a Jira issue. + *

+ * The Jira RestClient boundary is stubbed via {@link StubJiraProviderConfig} so nothing hits real Jira. + */ +@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT) +@ActiveProfiles("test") +@Import({StubJiraProviderConfig.class, StubEmbeddingConfig.class}) +@Tag("integration") +@DisplayName("Integration: Jira connection, target and push") +class JiraIntegrationPushIntegrationTest extends AbstractIntegrationTest { + + private static final String USER_ID = "00000000-0000-0000-0000-000000000001"; + private static final ObjectMapper JSON = new ObjectMapper(); + + @Autowired + private JdbcTemplate jdbcTemplate; + + @Test + @DisplayName("connects Jira, sets a target and pushes a story end-to-end") + void connects_targets_and_pushes() throws Exception { + String suffix = UUID.randomUUID().toString().substring(0, 8); + String slug = "acme-" + suffix; + String schema = "tenant_" + slug; + String orgId = createOrg(suffix, slug); + UUID projectId = createProject(orgId, schema, "Payment Platform"); + + // Connect Jira at the org level (verify is stubbed to succeed) -> 201, token NOT echoed + ResponseEntity connectRes = client().post() + .uri("/api/organizations/{orgId}/integrations/jira", orgId) + .header("Authorization", TestJwtFactory.bearer(USER_ID, orgId, "ROLE_USER")) + .header("Api-Version", "1") + .contentType(MediaType.APPLICATION_JSON) + .body(Map.of("siteUrl", "https://acme.atlassian.net", + "email", "pm@acme.com", "apiToken", "super-secret-token")) + .exchange((req, res) -> ResponseEntity.status(res.getStatusCode()).body(res.bodyTo(String.class))); + + assertThat(connectRes.getStatusCode()).isEqualTo(HttpStatus.CREATED); + assertThat(connectRes.getBody()).contains("\"provider\":\"JIRA\""); + assertThat(connectRes.getBody()).doesNotContain("super-secret-token"); + String connectionId = JSON.readTree(connectRes.getBody()).get("id").asText(); + + // The stored secret is ciphertext (BYTEA), not the plaintext token. + String storedHex = jdbcTemplate.queryForObject( + "SELECT encode(secret_ciphertext, 'escape') FROM \"" + schema + "\".integration_connections WHERE id = ?::uuid", + String.class, connectionId); + assertThat(storedHex).doesNotContain("super-secret-token"); + + // Seed a story in the tenant. + ResponseEntity storyRes = client().post().uri("/api/projects/{p}/stories", projectId) + .header("Authorization", TestJwtFactory.bearer(USER_ID, orgId, "ROLE_USER")) + .header("Api-Version", "1") + .contentType(MediaType.APPLICATION_JSON) + .body(Map.of("title", "Bulk import", "role", "analyst", + "action", "upload a CSV", "benefit", "save time", "priority", "HIGH")) + .exchange((req, res) -> ResponseEntity.status(res.getStatusCode()).body(res.bodyTo(String.class))); + assertThat(storyRes.getStatusCode()).isEqualTo(HttpStatus.CREATED); + String storyId = JSON.readTree(storyRes.getBody()).get("id").asText(); + + // Set the project target. + ResponseEntity targetRes = client().put() + .uri("/api/projects/{p}/integration/jira/target", projectId) + .header("Authorization", TestJwtFactory.bearer(USER_ID, orgId, "ROLE_USER")) + .header("Api-Version", "1") + .contentType(MediaType.APPLICATION_JSON) + .body(Map.of("connectionId", connectionId, "jiraProjectKey", "PAY", "issueTypeName", "Story")) + .exchange((req, res) -> ResponseEntity.status(res.getStatusCode()).body(res.bodyTo(String.class))); + assertThat(targetRes.getStatusCode()).isEqualTo(HttpStatus.OK); + assertThat(targetRes.getBody()).contains("\"jiraProjectKey\":\"PAY\""); + + Integer targetCount = jdbcTemplate.queryForObject( + "SELECT count(*) FROM \"" + schema + "\".project_integration_targets WHERE project_id = ?::uuid", + Integer.class, projectId.toString()); + assertThat(targetCount).isEqualTo(1); + + // Push the story -> mapped to a Jira issue key/url by the stub provider. + ResponseEntity pushRes = client().post() + .uri("/api/projects/{p}/integration/jira/stories/{s}/push", projectId, storyId) + .header("Authorization", TestJwtFactory.bearer(USER_ID, orgId, "ROLE_USER")) + .header("Api-Version", "1") + .exchange((req, res) -> ResponseEntity.status(res.getStatusCode()).body(res.bodyTo(String.class))); + + assertThat(pushRes.getStatusCode()).isEqualTo(HttpStatus.OK); + JsonNode push = JSON.readTree(pushRes.getBody()); + assertThat(push.get("storyId").asText()).isEqualTo(storyId); + assertThat(push.get("jiraIssueKey").asText()).startsWith("PAY-"); + assertThat(push.get("jiraIssueUrl").asText()).startsWith("https://acme.atlassian.net/browse/PAY-"); + assertThat(push.hasNonNull("error")).isFalse(); + + // push-all is now an async job: 202 with a RUNNING snapshot, then poll to COMPLETED. + // An empty body {} selects all eligible stories (the unrestricted default). + ResponseEntity pushAllRes = client().post() + .uri("/api/projects/{p}/integration/jira/stories/push-all", projectId) + .header("Authorization", TestJwtFactory.bearer(USER_ID, orgId, "ROLE_USER")) + .header("Api-Version", "1") + .contentType(MediaType.APPLICATION_JSON) + .body(Map.of()) + .exchange((req, res) -> ResponseEntity.status(res.getStatusCode()).body(res.bodyTo(String.class))); + assertThat(pushAllRes.getStatusCode()).isEqualTo(HttpStatus.ACCEPTED); + JsonNode accepted = JSON.readTree(pushAllRes.getBody()); + assertThat(accepted.get("jobType").asText()).isEqualTo("PUSH_ALL"); + assertThat(accepted.get("status").asText()).isEqualTo("RUNNING"); + assertThat(accepted.get("total").asInt()).isEqualTo(1); + + JsonNode all = awaitJobCompletion(projectId, accepted.get("id").asText(), orgId); + assertThat(all.get("status").asText()).isEqualTo("COMPLETED"); + assertThat(all.get("total").asInt()).isEqualTo(1); + assertThat(all.get("processed").asInt()).isEqualTo(1); + assertThat(all.get("succeeded").asInt()).isEqualTo(1); + assertThat(all.get("failed").asInt()).isZero(); + assertThat(all.hasNonNull("finishedAt")).isTrue(); + + // Batch metadata for the push-all job lands in the global public schema. + Integer batchInstances = jdbcTemplate.queryForObject( + "SELECT count(*) FROM public.batch_job_instance WHERE job_name = 'jiraPushAllJob'", + Integer.class); + assertThat(batchInstances).isGreaterThanOrEqualTo(1); + } + + @Test + @DisplayName("push-all with a storyIds selection pushes only the selected stories") + void push_all_with_story_selection() throws Exception { + String suffix = UUID.randomUUID().toString().substring(0, 8); + String slug = "acme-" + suffix; + String schema = "tenant_" + slug; + String orgId = createOrg(suffix, slug); + UUID projectId = createProject(orgId, schema, "Selective Push"); + + ResponseEntity connectRes = connectJira(orgId); + assertThat(connectRes.getStatusCode()).isEqualTo(HttpStatus.CREATED); + String connectionId = JSON.readTree(connectRes.getBody()).get("id").asText(); + + // Seed three stories; only two will be selected for the push-all. + String story1 = createStoryReturningId(orgId, projectId, "First"); + createStoryReturningId(orgId, projectId, "Second"); + String story3 = createStoryReturningId(orgId, projectId, "Third"); + + setTarget(orgId, projectId, connectionId); + + // Push-all with a selection of two of the three stories (order preserved by the reader). + ResponseEntity pushAllRes = client().post() + .uri("/api/projects/{p}/integration/jira/stories/push-all", projectId) + .header("Authorization", TestJwtFactory.bearer(USER_ID, orgId, "ROLE_USER")) + .header("Api-Version", "1") + .contentType(MediaType.APPLICATION_JSON) + .body(Map.of("storyIds", java.util.List.of(story1, story3))) + .exchange((req, res) -> ResponseEntity.status(res.getStatusCode()).body(res.bodyTo(String.class))); + assertThat(pushAllRes.getStatusCode()).isEqualTo(HttpStatus.ACCEPTED); + JsonNode accepted = JSON.readTree(pushAllRes.getBody()); + assertThat(accepted.get("jobType").asText()).isEqualTo("PUSH_ALL"); + // total reflects the filtered selection, not the full backlog of three. + assertThat(accepted.get("total").asInt()).isEqualTo(2); + + JsonNode all = awaitJobCompletion(projectId, accepted.get("id").asText(), orgId); + assertThat(all.get("status").asText()).isEqualTo("COMPLETED"); + assertThat(all.get("total").asInt()).isEqualTo(2); + assertThat(all.get("processed").asInt()).isEqualTo(2); + assertThat(all.get("succeeded").asInt()).isEqualTo(2); + assertThat(all.get("failed").asInt()).isZero(); + } + + private String createStoryReturningId(String orgId, UUID projectId, String title) throws Exception { + ResponseEntity res = client().post().uri("/api/projects/{p}/stories", projectId) + .header("Authorization", TestJwtFactory.bearer(USER_ID, orgId, "ROLE_USER")) + .header("Api-Version", "1") + .contentType(MediaType.APPLICATION_JSON) + .body(Map.of("title", title, "role", "analyst", + "action", "do " + title, "benefit", "save time", "priority", "HIGH")) + .exchange((req, r) -> ResponseEntity.status(r.getStatusCode()).body(r.bodyTo(String.class))); + assertThat(res.getStatusCode()).isEqualTo(HttpStatus.CREATED); + return JSON.readTree(res.getBody()).get("id").asText(); + } + + private void setTarget(String orgId, UUID projectId, String connectionId) { + ResponseEntity res = client().put() + .uri("/api/projects/{p}/integration/jira/target", projectId) + .header("Authorization", TestJwtFactory.bearer(USER_ID, orgId, "ROLE_USER")) + .header("Api-Version", "1") + .contentType(MediaType.APPLICATION_JSON) + .body(Map.of("connectionId", connectionId, "jiraProjectKey", "PAY", "issueTypeName", "Story")) + .exchange((req, r) -> ResponseEntity.status(r.getStatusCode()).body(r.bodyTo(String.class))); + assertThat(res.getStatusCode()).isEqualTo(HttpStatus.OK); + } + + /** Polls {@code GET .../jobs/{jobId}} until the job leaves RUNNING (max ~60s). */ + private JsonNode awaitJobCompletion(UUID projectId, String jobId, String orgId) throws Exception { + JsonNode job = null; + for (int i = 0; i < 200; i++) { + ResponseEntity res = client().get() + .uri("/api/projects/{p}/integration/jira/jobs/{j}", projectId, jobId) + .header("Authorization", TestJwtFactory.bearer(USER_ID, orgId, "ROLE_USER")) + .header("Api-Version", "1") + .exchange((req, response) -> ResponseEntity.status(response.getStatusCode()) + .body(response.bodyTo(String.class))); + assertThat(res.getStatusCode()).isEqualTo(HttpStatus.OK); + job = JSON.readTree(res.getBody()); + if (!"RUNNING".equals(job.get("status").asText())) { + return job; + } + Thread.sleep(300); + } + throw new AssertionError("Job " + jobId + " did not finish in time: " + job); + } + + @Test + @DisplayName("rejects a second active Jira connection with 409") + void rejects_duplicate_connection() { + String suffix = UUID.randomUUID().toString().substring(0, 8); + String orgId = createOrg(suffix, "acme-" + suffix); + + connectJira(orgId); + ResponseEntity second = connectJira(orgId); + + assertThat(second.getStatusCode()).isEqualTo(HttpStatus.CONFLICT); + assertThat(second.getBody()).contains("INTEGRATION_ALREADY_CONNECTED"); + } + + @Test + @DisplayName("returns 409 when pushing with no target configured") + void push_without_target() { + String suffix = UUID.randomUUID().toString().substring(0, 8); + String orgId = createOrg(suffix, "acme-" + suffix); + UUID projectId = UUID.randomUUID(); + + ResponseEntity res = client().post() + .uri("/api/projects/{p}/integration/jira/stories/{s}/push", projectId, UUID.randomUUID()) + .header("Authorization", TestJwtFactory.bearer(USER_ID, orgId, "ROLE_USER")) + .header("Api-Version", "1") + .exchange((req, response) -> ResponseEntity.status(response.getStatusCode()) + .body(response.bodyTo(String.class))); + + assertThat(res.getStatusCode()).isEqualTo(HttpStatus.CONFLICT); + assertThat(res.getBody()).contains("INTEGRATION_TARGET_NOT_CONFIGURED"); + } + + private ResponseEntity connectJira(String orgId) { + return client().post().uri("/api/organizations/{orgId}/integrations/jira", orgId) + .header("Authorization", TestJwtFactory.bearer(USER_ID, orgId, "ROLE_USER")) + .header("Api-Version", "1") + .contentType(MediaType.APPLICATION_JSON) + .body(Map.of("siteUrl", "https://acme.atlassian.net", "email", "pm@acme.com", "apiToken", "tok")) + .exchange((req, res) -> ResponseEntity.status(res.getStatusCode()).body(res.bodyTo(String.class))); + } + + private UUID createProject(String orgId, String schema, String name) { + client().post().uri("/api/organizations/{orgId}/projects", orgId) + .header("Authorization", TestJwtFactory.bearer(USER_ID, orgId, "ROLE_USER")) + .header("Api-Version", "1") + .contentType(MediaType.APPLICATION_JSON) + .body(Map.of("name", name, "programmingLanguages", java.util.List.of("Java"), + "frameworks", java.util.List.of("Spring Boot"), "clientPlatforms", java.util.List.of("Web"), + "databases", java.util.List.of("PostgreSQL"), "architecture", "Hexagonal", "domain", "Fintech")) + .exchange((req, res) -> ResponseEntity.status(res.getStatusCode()).body(res.bodyTo(String.class))); + return UUID.fromString(jdbcTemplate.queryForObject( + "SELECT id::text FROM \"" + schema + "\".projects WHERE name = ?", String.class, name)); + } + + private String createOrg(String suffix, String expectedSlug) { + ResponseEntity orgRes = client().post().uri("/api/organizations") + .header("Authorization", TestJwtFactory.bearer(USER_ID, UUID.randomUUID().toString(), "ROLE_USER")) + .header("Api-Version", "1") + .contentType(MediaType.APPLICATION_JSON) + .body(Map.of("name", "Acme " + suffix)) + .exchange((req, res) -> ResponseEntity.status(res.getStatusCode()).body(res.bodyTo(String.class))); + assertThat(orgRes.getStatusCode()).isEqualTo(HttpStatus.CREATED); + return jdbcTemplate.queryForObject( + "SELECT id FROM public.organizations WHERE slug = ?", String.class, expectedSlug); + } +} diff --git a/src/test/java/com/kntro/reqsai/gateway/interfaces/rest/JiraOAuthIntegrationTest.java b/src/test/java/com/kntro/reqsai/gateway/interfaces/rest/JiraOAuthIntegrationTest.java new file mode 100644 index 00000000..a032ba3e --- /dev/null +++ b/src/test/java/com/kntro/reqsai/gateway/interfaces/rest/JiraOAuthIntegrationTest.java @@ -0,0 +1,169 @@ +package com.kntro.reqsai.gateway.interfaces.rest; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.kntro.reqsai.gateway.StubJiraOAuthConfig; +import com.kntro.reqsai.testsupport.AbstractIntegrationTest; +import com.kntro.reqsai.testsupport.StubEmbeddingConfig; +import com.kntro.reqsai.testsupport.TestJwtFactory; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Tag; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.context.annotation.Import; +import org.springframework.http.HttpStatus; +import org.springframework.http.MediaType; +import org.springframework.http.ResponseEntity; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.test.context.ActiveProfiles; + +import java.util.List; +import java.util.Map; +import java.util.UUID; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * End-to-end test of the Jira OAuth 2.0 (3LO) slice: creates an org, fetches the authorize URL (obtaining + * a real signed state), completes the callback (single accessible site auto-selects), then sets a target, + * seeds a story and pushes it. Asserts an OAUTH2 connection persists with ENCRYPTED tokens (never echoed) + * and that the push routes to the {@code api.atlassian.com/ex/jira/{cloudId}} OAuth base. + *

+ * The Atlassian/Jira HTTP boundary is stubbed via {@link StubJiraOAuthConfig} — no real network. + */ +@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT) +@ActiveProfiles("test") +@Import({StubJiraOAuthConfig.class, StubEmbeddingConfig.class}) +@Tag("integration") +@DisplayName("Integration: Jira OAuth connect, target and push") +class JiraOAuthIntegrationTest extends AbstractIntegrationTest { + + private static final String USER_ID = "00000000-0000-0000-0000-000000000001"; + private static final ObjectMapper JSON = new ObjectMapper(); + + @Autowired + private JdbcTemplate jdbcTemplate; + + @Autowired + private StubJiraOAuthConfig.RecordingJiraClient recordingJiraClient; + + @Test + @DisplayName("completes the OAuth callback, persists encrypted tokens and pushes via the OAuth base") + void oauth_connect_target_and_push() throws Exception { + String suffix = UUID.randomUUID().toString().substring(0, 8); + String slug = "oauth-" + suffix; + String schema = "tenant_" + slug; + String orgId = createOrg(suffix, slug); + UUID projectId = createProject(orgId, schema, "OAuth Platform " + suffix); + + // 1. Get the authorize URL -> yields a real signed state bound to this org+user. + ResponseEntity authUrlRes = client().get() + .uri("/api/organizations/{orgId}/integrations/jira/oauth/authorize-url", orgId) + .header("Authorization", TestJwtFactory.bearer(USER_ID, orgId, "ROLE_USER")) + .header("Api-Version", "1") + .exchange((req, res) -> ResponseEntity.status(res.getStatusCode()).body(res.bodyTo(String.class))); + assertThat(authUrlRes.getStatusCode()).isEqualTo(HttpStatus.OK); + JsonNode authUrl = JSON.readTree(authUrlRes.getBody()); + assertThat(authUrl.get("url").asText()).startsWith("https://auth.atlassian.com/authorize"); + String state = authUrl.get("state").asText(); + + // 2. Complete the callback (single site auto-selects) -> 201 OAUTH2 connection, tokens NOT echoed. + ResponseEntity callbackRes = client().post() + .uri("/api/organizations/{orgId}/integrations/jira/oauth/callback", orgId) + .header("Authorization", TestJwtFactory.bearer(USER_ID, orgId, "ROLE_USER")) + .header("Api-Version", "1") + .contentType(MediaType.APPLICATION_JSON) + .body(Map.of("code", "auth-code", "state", state)) + .exchange((req, res) -> ResponseEntity.status(res.getStatusCode()).body(res.bodyTo(String.class))); + + assertThat(callbackRes.getStatusCode()).isEqualTo(HttpStatus.CREATED); + JsonNode conn = JSON.readTree(callbackRes.getBody()); + assertThat(conn.get("provider").asText()).isEqualTo("JIRA"); + assertThat(conn.get("credentialType").asText()).isEqualTo("OAUTH2"); + assertThat(conn.get("siteUrl").asText()).isEqualTo("https://acme.atlassian.net"); + assertThat(conn.hasNonNull("email")).isFalse(); // email is null for OAUTH2 + assertThat(callbackRes.getBody()).doesNotContain("access-token-1"); + assertThat(callbackRes.getBody()).doesNotContain("refresh-token-1"); + String connectionId = conn.get("id").asText(); + + // The stored OAuth tokens are ciphertext (BYTEA), not the plaintext values; cloud_id + type persist. + Map row = jdbcTemplate.queryForMap( + "SELECT credential_type, cloud_id, email, secret_ciphertext, " + + "encode(oauth_refresh_ciphertext, 'escape') AS refresh_txt, " + + "encode(oauth_access_ciphertext, 'escape') AS access_txt " + + "FROM \"" + schema + "\".integration_connections WHERE id = ?::uuid", connectionId); + assertThat(row.get("credential_type")).isEqualTo("OAUTH2"); + assertThat(row.get("cloud_id")).isEqualTo("cloud-1"); + assertThat(row.get("email")).isNull(); + assertThat(row.get("secret_ciphertext")).isNull(); + assertThat((String) row.get("refresh_txt")).doesNotContain("refresh-token-1"); + assertThat((String) row.get("access_txt")).doesNotContain("access-token-1"); + + // 3. Seed a story, set the target, push -> routes to the OAuth API base. + String storyId = seedStory(projectId, orgId); + setTarget(projectId, orgId, connectionId); + recordingJiraClient.apiBases.clear(); + + ResponseEntity pushRes = client().post() + .uri("/api/projects/{p}/integration/jira/stories/{s}/push", projectId, storyId) + .header("Authorization", TestJwtFactory.bearer(USER_ID, orgId, "ROLE_USER")) + .header("Api-Version", "1") + .exchange((req, res) -> ResponseEntity.status(res.getStatusCode()).body(res.bodyTo(String.class))); + + assertThat(pushRes.getStatusCode()).isEqualTo(HttpStatus.OK); + JsonNode push = JSON.readTree(pushRes.getBody()); + assertThat(push.get("jiraIssueKey").asText()).isEqualTo("PAY-42"); + // The push routed through the OAuth base, not the API-token site base. + assertThat(recordingJiraClient.apiBases) + .allSatisfy(base -> assertThat(base).isEqualTo("https://api.atlassian.com/ex/jira/cloud-1/rest/api/3")); + } + + private String seedStory(UUID projectId, String orgId) throws Exception { + ResponseEntity storyRes = client().post().uri("/api/projects/{p}/stories", projectId) + .header("Authorization", TestJwtFactory.bearer(USER_ID, orgId, "ROLE_USER")) + .header("Api-Version", "1") + .contentType(MediaType.APPLICATION_JSON) + .body(Map.of("title", "OAuth push", "role", "analyst", + "action", "push via oauth", "benefit", "no api token", "priority", "HIGH")) + .exchange((req, res) -> ResponseEntity.status(res.getStatusCode()).body(res.bodyTo(String.class))); + assertThat(storyRes.getStatusCode()).isEqualTo(HttpStatus.CREATED); + return JSON.readTree(storyRes.getBody()).get("id").asText(); + } + + private void setTarget(UUID projectId, String orgId, String connectionId) { + ResponseEntity targetRes = client().put() + .uri("/api/projects/{p}/integration/jira/target", projectId) + .header("Authorization", TestJwtFactory.bearer(USER_ID, orgId, "ROLE_USER")) + .header("Api-Version", "1") + .contentType(MediaType.APPLICATION_JSON) + .body(Map.of("connectionId", connectionId, "jiraProjectKey", "PAY", "issueTypeName", "Story")) + .exchange((req, res) -> ResponseEntity.status(res.getStatusCode()).body(res.bodyTo(String.class))); + assertThat(targetRes.getStatusCode()).isEqualTo(HttpStatus.OK); + } + + private UUID createProject(String orgId, String schema, String name) { + client().post().uri("/api/organizations/{orgId}/projects", orgId) + .header("Authorization", TestJwtFactory.bearer(USER_ID, orgId, "ROLE_USER")) + .header("Api-Version", "1") + .contentType(MediaType.APPLICATION_JSON) + .body(Map.of("name", name, "programmingLanguages", List.of("Java"), + "frameworks", List.of("Spring Boot"), "clientPlatforms", List.of("Web"), + "databases", List.of("PostgreSQL"), "architecture", "Hexagonal", "domain", "Fintech")) + .exchange((req, res) -> ResponseEntity.status(res.getStatusCode()).body(res.bodyTo(String.class))); + return UUID.fromString(jdbcTemplate.queryForObject( + "SELECT id::text FROM \"" + schema + "\".projects WHERE name = ?", String.class, name)); + } + + private String createOrg(String suffix, String expectedSlug) { + ResponseEntity orgRes = client().post().uri("/api/organizations") + .header("Authorization", TestJwtFactory.bearer(USER_ID, UUID.randomUUID().toString(), "ROLE_USER")) + .header("Api-Version", "1") + .contentType(MediaType.APPLICATION_JSON) + .body(Map.of("name", "Oauth " + suffix)) + .exchange((req, res) -> ResponseEntity.status(res.getStatusCode()).body(res.bodyTo(String.class))); + assertThat(orgRes.getStatusCode()).isEqualTo(HttpStatus.CREATED); + return jdbcTemplate.queryForObject( + "SELECT id FROM public.organizations WHERE slug = ?", String.class, expectedSlug); + } +} diff --git a/src/test/java/com/kntro/reqsai/workspace/interfaces/rest/GetOrganizationIntegrationTest.java b/src/test/java/com/kntro/reqsai/workspace/interfaces/rest/GetOrganizationIntegrationTest.java index 5bfa5bcb..f43f5058 100644 --- a/src/test/java/com/kntro/reqsai/workspace/interfaces/rest/GetOrganizationIntegrationTest.java +++ b/src/test/java/com/kntro/reqsai/workspace/interfaces/rest/GetOrganizationIntegrationTest.java @@ -25,6 +25,7 @@ class GetOrganizationIntegrationTest extends AbstractIntegrationTest { private static final String USER_ID = "00000000-0000-0000-0000-000000000001"; + private static final String ADMIN_USER_ID = "00000000-0000-0000-0000-000000000003"; private static final String ORG_ID = "00000000-0000-0000-0000-000000000009"; @Autowired @@ -55,7 +56,31 @@ void should_return_the_organization_for_its_owner() { } @Test - @DisplayName("should reject get from a non-owner") + @DisplayName("should return the organization for an org admin") + void should_return_the_organization_for_an_admin() { + String suffix = UUID.randomUUID().toString().substring(0, 8); + String expectedSlug = "acme-" + suffix; + + ResponseEntity createResponse = post( + Map.of("name", "Acme " + suffix, "meetingLanguage", "en-US"), + TestJwtFactory.bearer(USER_ID, ORG_ID, "ROLE_USER")); + assertThat(createResponse.getStatusCode()).isEqualTo(HttpStatus.CREATED); + + String organizationId = extractOrganizationId(expectedSlug); + createMember(organizationId, USER_ID, Map.of( + "userId", ADMIN_USER_ID, "email", "admin@example.com", "displayName", "Admin", "role", "ADMIN")); + + ResponseEntity getResponse = get( + organizationId, + TestJwtFactory.bearer(ADMIN_USER_ID, organizationId, "ROLE_USER")); + + assertThat(getResponse.getStatusCode()).isEqualTo(HttpStatus.OK); + assertThat(getResponse.getBody()).contains("\"slug\":\"" + expectedSlug + "\""); + assertThat(getResponse.getBody()).contains("\"ownerId\":\"" + USER_ID + "\""); + } + + @Test + @DisplayName("should reject get from a non-member") void should_reject_get_from_non_owner() { String suffix = UUID.randomUUID().toString().substring(0, 8); String expectedSlug = "acme-" + suffix; @@ -121,4 +146,15 @@ private ResponseEntity getAnonymously(String organizationId) { .exchange((request, response) -> ResponseEntity.status(response.getStatusCode()) .body(response.bodyTo(String.class)), false); } + + private void createMember(String organizationId, String ownerUserId, Map body) { + ResponseEntity res = client().post().uri("/api/organizations/{orgId}/members", organizationId) + .header("Authorization", TestJwtFactory.bearer(ownerUserId, organizationId, "ROLE_USER")) + .header("Api-Version", "1") + .contentType(MediaType.APPLICATION_JSON) + .body(body) + .exchange((request, response) -> ResponseEntity.status(response.getStatusCode()) + .body(response.bodyTo(String.class))); + assertThat(res.getStatusCode()).isEqualTo(HttpStatus.CREATED); + } } diff --git a/src/test/java/com/kntro/reqsai/workspace/interfaces/rest/UpdateOrganizationIntegrationTest.java b/src/test/java/com/kntro/reqsai/workspace/interfaces/rest/UpdateOrganizationIntegrationTest.java index a99f8eab..b5c16e91 100644 --- a/src/test/java/com/kntro/reqsai/workspace/interfaces/rest/UpdateOrganizationIntegrationTest.java +++ b/src/test/java/com/kntro/reqsai/workspace/interfaces/rest/UpdateOrganizationIntegrationTest.java @@ -227,6 +227,49 @@ void should_reject_unauthenticated_update_request() { assertThat(updateResponse.getStatusCode()).isIn(HttpStatus.UNAUTHORIZED, HttpStatus.FORBIDDEN); } + @Test + @DisplayName("should reject update from an org admin (owner-only edit)") + void should_reject_update_from_an_admin() { + String suffix = UUID.randomUUID().toString().substring(0, 8); + String originalName = "Acme " + suffix; + String expectedSlug = "acme-" + suffix; + String adminUserId = "00000000-0000-0000-0000-000000000003"; + + ResponseEntity createResponse = post( + Map.of("name", originalName, "meetingLanguage", "en-US"), + TestJwtFactory.bearer(USER_ID, ORG_ID, "ROLE_USER")); + assertThat(createResponse.getStatusCode()).isEqualTo(HttpStatus.CREATED); + + String organizationId = jdbcTemplate.queryForObject( + "SELECT id::text FROM public.organizations WHERE slug = ?", String.class, expectedSlug); + createMember(organizationId, USER_ID, Map.of( + "userId", adminUserId, "email", "admin@example.com", "displayName", "Admin", "role", "ADMIN")); + + ResponseEntity updateResponse = patch( + organizationId, + Map.of("name", "Admin Update " + suffix, "meetingLanguage", "pt-BR", "audioRetentionDays", 7), + TestJwtFactory.bearer(adminUserId, organizationId, "ROLE_USER")); + + assertThat(updateResponse.getStatusCode()).isEqualTo(HttpStatus.FORBIDDEN); + + Map row = jdbcTemplate.queryForMap( + "SELECT name, meeting_language FROM public.organizations WHERE id = ?::uuid", + organizationId); + assertThat(row.get("name")).isEqualTo(originalName); + assertThat(row.get("meeting_language")).isEqualTo("en-US"); + } + + private void createMember(String organizationId, String ownerUserId, Map body) { + ResponseEntity res = client().post().uri("/api/organizations/{orgId}/members", organizationId) + .header("Authorization", TestJwtFactory.bearer(ownerUserId, organizationId, "ROLE_USER")) + .header("Api-Version", "1") + .contentType(MediaType.APPLICATION_JSON) + .body(body) + .exchange((request, response) -> ResponseEntity.status(response.getStatusCode()) + .body(response.bodyTo(String.class))); + assertThat(res.getStatusCode()).isEqualTo(HttpStatus.CREATED); + } + private ResponseEntity post(Map body, String bearer) { return client().post().uri("/api/organizations") .header("Authorization", bearer) diff --git a/src/test/resources/application-test.yml b/src/test/resources/application-test.yml index 4eef284a..b071b718 100644 --- a/src/test/resources/application-test.yml +++ b/src/test/resources/application-test.yml @@ -21,6 +21,16 @@ reqsai: jwt: public-key-path: classpath:certs/public_key.pem issuer: reqsai-test + integrations: + # Deterministic non-secret AES-256 key (base64 of bytes 0..31) so tests encrypt/decrypt integration + # secrets without a .env. NEVER use this key outside tests/local dev. + encryption-key: AAECAwQFBgcICQoLDA0ODxAREhMUFRYXGBkaGxwdHh8= + jira: + oauth: + client-id: test-client-id + client-secret: test-client-secret + redirect-uri: http://localhost/integrations/jira/callback + state-secret: 0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef cors: allowed-origins: http://localhost:4200 allow-credentials: true