diff --git a/CLAUDE.md b/CLAUDE.md index e16a7060ce..482ff89a9c 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -342,23 +342,21 @@ Per-endpoint integration test cost stays roughly constant as endpoints move Lift ## TODO / Phase Progress -### Phase 1 — Simple GETs (98 remaining in v6.0.0) -GET + no body. Purely mechanical — 1:1 copy of `NewStyle.function.*` calls, pick helper from Rule 4 matrix, 3 test scenarios per endpoint (401 / 403 / 200). +### Per-version completeness (from `comm -23 lift http4s` on each version's `lazy val ... : OBPEndpoint` declarations) -| Batch | Endpoints | Status | -|---|---|---| -| Batches 1–3 | 9 endpoints | ✓ done | -| Batch 4 | getCacheConfig, getCacheInfo, getDatabasePoolInfo, getStoredProcedureConnectorHealth, getMigrations, getCacheNamespaces | ✓ done | -| Remaining | 98 GETs | todo | +| Version | Genuine Lift handlers still on the bridge | +|---|---| +| v1.2.1, v1.3.0, v2.0.0, v2.1.0, v2.2.0, v3.0.0, v4.0.0, v5.0.0, v5.1.0, v6.0.0 | 0 — fully on http4s | +| v1.4.0 | `testResourceDoc` (dev-mode-only stub; tracked in "Per-version Lift leftovers") | +| v3.1.0 | `getMessageDocsSwagger`, `getObpConnectorLoopback` (both tracked as leftovers; retire via Resource-docs / bridge-removal workstreams) | -### Phase 2 — Account/View/Counterparty GETs (subset of the 98 above) -`withBankAccount` / `withView` / `withCounterparty` helpers ready. Same mechanical pattern. +### v6.0.0 migration — done (243 / 243) +Phase 1 (35 overrides) and Phase 2 (208 originals) both complete. All v6 routes live in `Http4s600.scala`, wired into `Http4sApp.baseServices` ahead of the Lift bridge. -### Phase 3 — POST / PUT / DELETE (57 + 33 + 26 = 116 endpoints in v6.0.0) -Body helpers and DELETE 204 helpers ready. Velocity: 6–8 endpoints/day. +Architectural note from the v6 migration: around the 140-endpoint mark `Implementations6_0_0`'s `` hit the JVM 64KB bytecode-per-method limit. The fix that ships in `Http4s600.scala` — and that future per-version files should adopt — is two-part: -### Phase 4 — Complex endpoints (~50 endpoints) -Dynamic entities, ABAC rules, mandate workflows, polymorphic bodies. ~45–60 min each. +1. Declare endpoints as `lazy val xxx: HttpRoutes[IO] = HttpRoutes.of[IO] { ... }` instead of `val`. Lambda materialisation moves out of `` into per-field `lzycompute` methods (each with its own 64KB budget). +2. Group `resourceDocs += ResourceDoc(...)` calls into `private def initXxxResourceDocs(): Unit` blocks of ~10–15 endpoints, called once each from the object body. Each helper def gets its own 64KB. ### Other TODOs - **OBP-Trading**: trading endpoints (createTradingOffer, getTradingOffer, getTradingOffers, cancelTradingOffer, createMarketOrder, getMarketOrder, cancelMarketOrder, createMarketMatch, getMarketTrade, requestSettlement, requestWithdrawal) are now in `Http4s700.scala`. 5 payment-auth endpoints remain commented out (notifyDeposit, createPaymentAuth, capturePaymentAuth, releasePaymentAuth, getPaymentAuth) — see `ideas/CAPTURE_RELEASE_TRANSACTION_REQUEST_TYPES.md`. diff --git a/LIFT_HTTP4S_MIGRATION.md b/LIFT_HTTP4S_MIGRATION.md index 063621f443..5167da65df 100644 --- a/LIFT_HTTP4S_MIGRATION.md +++ b/LIFT_HTTP4S_MIGRATION.md @@ -29,7 +29,7 @@ New API versions are implemented as native http4s routes and do not pass through ### Priority routing -Routes are tried in order: `corsHandler` (OPTIONS) → `AppsPage` → `StatusPage` → `Http4s500` → `Http4s700` → `Http4sBGv2` → `Http4s400` → `Http4s310` → `Http4s300` → `Http4s220` → `Http4s210` → `Http4s200` → `Http4s140` → `Http4s130` → `Http4s121` → `Http4sLiftWebBridge` (Lift fallback). Unhandled `/obp/v7.0.0/*` paths fall through silently to Lift — they do not 404. +Routes are tried in order: `corsHandler` (OPTIONS) → `AppsPage` → `StatusPage` → `Http4s510` → `Http4s600` → `Http4s500` → `Http4s700` → `Http4sBGv2` → `Http4s400` → `Http4s310` → `Http4s300` → `Http4s220` → `Http4s210` → `Http4s200` → `Http4s140` → `Http4s130` → `Http4s121` → `Http4sLiftWebBridge` (Lift fallback). Unhandled `/obp/vX.Y.Z/*` paths fall through silently to Lift — they do not 404. The non-numeric ordering (v510 before v600, v500 after v600 etc.) doesn't affect correctness because each per-version service gates on its own version prefix; the ordering only matters when two services overlap on the same URL pattern. ``` HTTP Request @@ -38,17 +38,16 @@ HTTP Request Http4sServer (IOApp / Ember) │ ▼ -corsHandler → AppsPage → StatusPage → Http4s500 → Http4s700 → Http4sBGv2 - │ - Http4s300 → Http4s220 → Http4s210 → Http4s200 → Http4s140 → Http4s130 → Http4s121 → Http4sLiftWebBridge - │ │ │ │ │ │ │ - v3.0.0 v2.2.0 v2.1.0 v2.0.0 v1.4.0 v1.3.0 v1.2.1 routes - own routes own routes own routes own routes own routes own routes (all 323 scenarios) - + v2.2.0 + v2.1.0 + v2.0.0 + v1.4.0 + v1.3.0 + v1.2.1 - bridge bridge bridge bridge bridge - │ - LiftRules.statelessDispatch - LiftRules.dispatch (REST API) +corsHandler → AppsPage → StatusPage → Http4s510 → Http4s600 → Http4s500 → Http4s700 → Http4sBGv2 + │ + Http4s400 → Http4s310 → Http4s300 → Http4s220 → Http4s210 → Http4s200 → Http4s140 → Http4s130 → Http4s121 → Http4sLiftWebBridge + │ │ │ │ │ │ │ │ │ + v4.0.0 v3.1.0 v3.0.0 v2.2.0 v2.1.0 v2.0.0 v1.4.0 v1.3.0 v1.2.1 routes + own routes own routes own routes own routes own routes own routes own routes own routes (all 323 scenarios) + bridge bridge bridge bridge bridge bridge bridge + │ + LiftRules.statelessDispatch + LiftRules.dispatch (REST API) │ ▼ HTTP Response (with standard headers) @@ -120,10 +119,10 @@ Bottom-up — each version depends on the one below it being done. | 6 | `APIMethods220` | 19 | **Done** — `Http4s220.scala`: 18 own endpoints + path-rewriting bridge to `Http4s210`; all 27 v2.2.0 tests pass | | 7 | `APIMethods300` | 47 | **Done** — `Http4s300.scala`: 47 own endpoints + path-rewriting bridge to `Http4s220`; all 86 v3.0.0 tests pass | | 8 | `APIMethods310` | 102 | **Done** — `Http4s310.scala` has all 100 functional endpoints (42 GET, 10 DELETE, 19 POST, 25 PUT, 1 GET-shaped revoke, 3 SCA aliases) + path-rewriting bridge to `Http4s300`; 181 v3.1.0 tests pass. Two endpoints tracked separately in "Per-version Lift leftovers" (`getMessageDocsSwagger`, `getObpConnectorLoopback`) — they retire via the Resource-docs workstream / bridge-removal PR, not as v3.1.0 follow-up. | -| 9 | `APIMethods400` | ~258 total | **In progress (47/258 endpoints)** — `Http4s400.scala` scaffolded with `staticResourceDocs`/`resourceDocs` split + bridge to `Http4s310`. **Dynamic-entity family complete** (11/11), **dynamic-endpoint family complete** (12/12), **mainstream batch 1** (`getMapperDatabaseInfo`, `getLogoutLink`, `getBanks`, `getBank`, `ibanChecker`, `callsLimit`, `createBank`, `root`). **Override audit started** (13/35 v4-over-older overrides migrated: `getBanks`, `getBank`, `createBank`, `root`, `getAtms`, `getAtm`, `createAtm`, `getProducts`, `getProduct`, `createProduct`, `createProductAttribute`, `updateProductAttribute`, `callsLimit`). Tests passing: BankTests, BankAttributeTests, MapperDatabaseInfoTest, RateLimitingTest, AtmsTest, ProductTest, DynamicEntityTest, DynamicEndpointsTest et al. **Bridge-cascade hijack gotcha** (see CLAUDE.md): v4 endpoints that *override* a same-URL endpoint from an earlier version must be migrated to `Http4s400` own-routes *before* relying on the bridge — otherwise the bridge cascade rewrites the path down to the older version's handler (which has different behaviour). 22 overrides remain to migrate. | -| 10 | `APIMethods500` | 37 | | -| 11 | `APIMethods510` | 111 | | -| 12 | `APIMethods600` | ~244 total | Final Lift endpoint file | +| 9 | `APIMethods400` | 258 | **Done — 258 / 258 (100%)**. `Http4s400.scala` covers all 253 unique handlers (`lazy val NAME: HttpRoutes[IO]`) plus 8 ResourceDoc aliases for the transaction-request-type variants (ACCOUNT, ACCOUNT_OTP, SEPA, COUNTERPARTY, REFUND, FREE_FORM, SIMPLE, AGENT_CASH_WITHDRAWAL — handled by the shared `createTransactionRequest` wildcard handler; the `literalAllCapsSegments` set in `Http4sSupport.scala` dispatches the matcher to the per-type doc for swagger purposes). Adopts the **lazy val + helper-def init pattern** (Batches 1–19) introduced in v6 to dodge the JVM 64KB `` method-size limit. **Bridge-cascade hijack** historically threatened v4's overrides; resolved by migrating all 35/35 v4-over-older URL+verb overrides. | +| 10 | `APIMethods500` | 10 | **Done** — `Http4s500.scala` (all v5.0.0 originals migrated) | +| 11 | `APIMethods510` | 111 | **Done** — `Http4s510.scala`. v5.1.0's `createConsent` Lift handler is exposed in Http4s510 under the alias name `createConsentImplicit` (a single handler with `if scaMethod == "EMAIL" \|\| scaMethod == "SMS" \|\| scaMethod == "IMPLICIT"` guard covers all three SCA-method URLs). | +| 12 | `APIMethods600` | 243 (35 overrides + 208 originals) | **Done — 243 / 243 (100%)**. `Http4s600.scala` covers all v6 originals and overrides. Wired into `Http4sApp.baseServices` ahead of the Lift bridge. Architecturally introduced the **lazy val + helper-def init pattern** to dodge the JVM 64KB `` method-size limit (`val xxx: HttpRoutes[IO]` ⇒ `lazy val xxx`; `resourceDocs += ResourceDoc(...)` calls grouped into `private def initXxxResourceDocs(): Unit` blocks). Future per-version files should adopt the same pattern from the start. | --- @@ -188,11 +187,110 @@ An `APIMethods{version}` file is marked **done** in the progress table when ever |---|---|---|---| | `getMessageDocsSwagger` (`GET /message-docs/CONNECTOR/swagger2.0`) | `APIMethods310` | Same shape as `getResourceDocsObpV700` / `openapi.yaml` — runtime Swagger generation with shared caching | The **Http4sResourceDocs** workstream (step 4) | | `getObpConnectorLoopback` (`GET /connector/loopback`) | `APIMethods310` | Deprecated stub that unconditionally throws `IllegalStateException(NotImplemented)`; no functional behaviour | Either a 3-line native http4s route that throws the same exception or outright deletion, decided when the Lift bridge is removed | +| `testResourceDoc` (`GET /dummy`) | `APIMethods140` | Dev-only stub gated behind `if (Props.devMode) { ... }`. Returns a dummy `APIInfoJSON` payload for testing the resource-doc renderer. Has no production behaviour worth porting. | Deleted in the bridge-removal PR (no native equivalent needed). | Track new leftovers here when later version files are migrated — the bridge-removal milestone in "Done Criteria" only requires the per-version files to be **done** in this table's sense (functional endpoints migrated, tests green). Leftovers folded into the Resource-docs or Auth-stack workstreams retire via those workstreams. --- +## Migration leftovers (full landscape, beyond per-version files) + +Things still on Lift that block the `Http4sLiftWebBridge` from being removed. Use this section as the master TODO for the "remove Lift Web" milestone. + +### Auth stack — every handler is its own `RestHelper` + +| Handler | File | Routes | Status | +|---|---|---|---| +| `DirectLogin` | `code/api/directlogin.scala` | `POST /my/logins/direct` | http4s version inside `Http4s600.scala`; Lift dispatch **still registered** as fallback. Earlier-version paths still hit Lift. The key gotcha: `createTokenFuture(allParameters)` ignores its argument and re-reads from Lift's `S.request` via `getAllParameters`. Use `validatorFutureWithParams(...)` + `createTokenCommonPart(...)` instead — this is the http4s-friendly entry point. | +| `GatewayLogin` | `code/api/GatewayLogin.scala` | Gateway JWT exchange | Lift only | +| `DAuth` | `code/api/dauth.scala` | dAuth JWT exchange | Lift only | +| `OAuth 1.0a` | OAuth files | OAuth 1.0a token endpoints | Lift only | +| `OAuth2` | `code/api/OAuth2.scala` | OAuth 2.0 token & callback | Lift only | +| `OpenIdConnect` | `code/api/openidconnect.scala` | OIDC callback — registered via `LiftRules.dispatch.append` | Lift only | + +These four (DirectLogin/GatewayLogin/DAuth/OAuth) are the most-complex remaining dependencies on Lift `S.request` and they collectively block bridge removal. + +### Resource-docs workstream + +Already partly described in the next major section, but counted here for completeness: + +- `ResourceDocs140` … `ResourceDocs600` — six separate Lift files, each registered via `LiftRules.statelessDispatch.append` in `Boot.scala`. +- `getResourceDocsObpV700` aggregation bug fix — landed (`V7ResourceDocsAggregationTest` passes). +- `openapi.yaml` route — raw `Lift serve { ... }` block, no native http4s handler. +- `getMessageDocsSwagger` (v3.1.0) — folds into the centralised `Http4sResourceDocs` service when it ships. +- One-PR opportunity: build `Http4sResourceDocs` above the Lift bridge in `Http4sApp`, intercept all `/obp/*/resource-docs/*` traffic, retire six Lift dispatch entries in a single change. + +### Small singleton Lift endpoints + +| Endpoint | File | Notes | +|---|---|---| +| `aliveCheck` | `code/api/aliveCheck.scala` | One-line liveness probe. Trivial port. | +| `ImporterAPI` | `code/api/ImporterAPI.scala` (gated by props) | Sandbox data-import endpoint. Single Lift file. | +| `OpenIdConnect` | (auth-stack table above) | OIDC callback, registered separately from OAuth2. | + +### Open-banking standards (large, deferred indefinitely) + +Lift implementations of 3rd-party regulatory standards. All currently pass through `Http4sLiftWebBridge` and continue to work; they are *not* OBP API per se but optional regulatory shims. Migrating them is out of scope for the "remove Lift Web" milestone if you accept keeping the bridge for these stacks only. If total Lift removal is the goal, each needs its own workstream. + +| Standard | Files / location | Status | +|---|---|---| +| Berlin Group v1.3 | `code/api/berlin/group/v1_3/*` — 7 files (AIS / PIS / PIIS / signing baskets / common) | Lift | +| **Berlin Group v2** | `code/api/berlin/group/v2/Http4sBGv2.scala` | ✅ already on http4s | +| UK Open Banking v2.0.0 + v3.1.0 | `code/api/UKOpenBanking/*` — ~20 files | Lift | +| Bahrain OBF v1.0.0 | `code/api/BahrainOBF/*` — ~20 files | Lift | +| AU OpenBanking v1.0.0 | `code/api/AUOpenBanking/*` — ~10 files | Lift | +| STET v1.4 | `code/api/STET/v1_4/*` — 4 files | Lift | +| MxOF v1.0.0 | `code/api/MxOF/*` — 2 files | Lift | +| Polish v2.1.1.1 | `code/api/Polish/v2_1_1_1/*` — 4 files | Lift | +| Sandbox / `SandboxApiCalls.scala` | `code/api/sandbox/*` | Lift | + +### `Boot.scala` scaffolding + +Currently runs on startup and goes away once the Lift bridge is removable: + +1. `LiftRules.statelessDispatch.append(...)` registrations: `DirectLogin`, `ImporterAPI`, `ResourceDocs140`–`ResourceDocs600`, `aliveCheck`. +2. `LiftRules.dispatch.append(OpenIdConnect)`. +3. `LiftRules.addToPackages("code")` — Lift package scanner. +4. `LiftRules.exceptionHandler.prepend { ... }` — global exception handler. +5. `LiftRules.uriNotFound.prepend { ... }` — 404 handler. +6. `LiftRules.early`, `LiftRules.supplementalHeaders`, `LiftRules.localeCalculator`, etc. — request-path hooks. +7. `LiftRules.unloadHooks.append(...)` — shutdown hooks (DB pool, Redis). +8. **Mapper schemifier** — DB schema init. Belongs to the long-term `lift-mapper` removal effort, not the bridge milestone. + +Everything in lines 1–7 is request-path-related and will go in the bridge-removal PR. Line 8 stays until lift-mapper is replaced. + +### Tests + +| Item | Status | +|---|---| +| `Http4s500RoutesTest`, `RootAndBanksTest`, `V500ContractParityTest` | `@Ignore`. | +| `CardTest` | Commented out. | +| v5.0.0: 13 skipped tests | Setup cost paid, no value. | +| `V7ResourceDocsAggregationTest` | Was intentionally failing; aggregation bug fix landed → now passes. | +| `AbacRuleTests` (6 local fails) | Environment-dependent — too few users in local DB triggers `isStatisticallyTooPermissive`. Not a regression. | + +### Reusable lessons from v6.0.0 + +1. **JVM 64KB `` limit** — see CLAUDE.md. Adopt `lazy val xxx: HttpRoutes[IO] = ...` plus `private def initXxxResourceDocs(): Unit` blocks in every per-version file from the start; don't wait until you hit the wall. +2. **DirectLogin pattern** — `S.request`-bound Lift handlers need an http4s-friendly entry point that accepts pre-parsed parameters. `validatorFutureWithParams` is the model; replicate this for `GatewayLogin` / `OAuth` when their migration starts. +3. **`Future.failed(new Exception)` produces 500** — use `unboxFullOrFail(Empty, ..., 400)` or `NewStyle.function.tryons(msg, 400, ...)` to return the intended 4xx. Pattern showed up in WebUiProps and RetailCustomer fixes. +4. **`isStatisticallyTooPermissive` is sample-pool-dependent** — locally, a fresh test DB with a single user causes spurious rejections. Tests built against this check must seed enough users. +5. **Reserved ALL_CAPS placeholders** in middleware (`BANK_ID`, `ACCOUNT_ID`, `VIEW_ID`, `COUNTERPARTY_ID`) — when an endpoint needs a same-shape var without middleware lookup, rename to a non-reserved variant (e.g. `COUNTERPARTY_ID_PARAM`) in both the http4s and Lift ResourceDocs. + +### Suggested ordering for the remaining work + +1. ~~**v4.0.0 bulk port**~~ — done (258/258, 100%). +2. **`aliveCheck`, `ImporterAPI`** — easiest wins, retire two `LiftRules.statelessDispatch` entries. +3. **`Http4sResourceDocs` centralised service** — single PR removes 6 dispatch entries + the `openapi.yaml` raw-serve block. +4. **Auth stack: OAuth2 / OpenIdConnect** — smaller and fewer call sites than the others. +5. **DirectLogin** — already half done in v6; needs to cover earlier versions and retire the `LiftRules.statelessDispatch.append(DirectLogin)` entry. +6. **GatewayLogin + DAuth + OAuth 1.0a** — biggest remaining auth work. +7. **Bridge-removal PR** — delete `Http4sLiftWebBridge` + the request-path entries from `Boot.scala` (lines 1–7 above). +8. **Open-banking standards** — decide whether to migrate or keep a thin Lift remnant. Weeks of work if migrating. +9. **`lift-mapper`** — separate long-term effort, out of scope here. + +--- + ## Server Chain After Full Migration ``` @@ -260,11 +358,11 @@ Binds to `hostname` / `dev.port` from your props file (defaults: `127.0.0.1:8080 | `APIMethods210` | done — `Http4s210.scala` (25 own endpoints; path-rewriting bridge to Http4s200) | | `APIMethods220` | done — `Http4s220.scala` (18 own endpoints; path-rewriting bridge to Http4s210) | | `APIMethods300` | done — `Http4s300.scala` (47 own endpoints; path-rewriting bridge to Http4s220; all 86 v3.0.0 tests pass) | -| `APIMethods310` | done — `Http4s310.scala` (100 own endpoints; path-rewriting bridge to Http4s300; 2 endpoints intentionally left on Lift: `getMessageDocsSwagger`, `getObpConnectorLoopback`) | -| `APIMethods400` | todo | -| `APIMethods500` | todo | -| `APIMethods510` | todo | -| `APIMethods600` | todo | +| `APIMethods310` | done — `Http4s310.scala` (100 own endpoints + `updateCustomerAddress`; path-rewriting bridge to Http4s300; 2 endpoints intentionally left on Lift: `getMessageDocsSwagger`, `getObpConnectorLoopback`) | +| `APIMethods400` | **done — 258 / 258 (100%)**. `Http4s400.scala` covers all 253 unique handlers + 8 ResourceDoc aliases for transaction-request-type variants (served by the shared wildcard handler). | +| `APIMethods500` | done — `Http4s500.scala` (all 10 v5.0.0 originals on http4s) | +| `APIMethods510` | done — `Http4s510.scala` (all 111 v5.1.0 originals on http4s; `createConsent` exposed as `createConsentImplicit` with a guard covering EMAIL/SMS/IMPLICIT SCA methods) | +| `APIMethods600` | **done — 243 / 243 (100%)**. `Http4s600.scala` covers all 35 overrides + 208 originals. | | Auth: DirectLogin | todo | | Auth: GatewayLogin | todo | | Auth: DAuth | todo | diff --git a/LIFT_HTTP4S_MIGRATION_V6_AUDIT.md b/LIFT_HTTP4S_MIGRATION_V6_AUDIT.md new file mode 100644 index 0000000000..610f6896f3 --- /dev/null +++ b/LIFT_HTTP4S_MIGRATION_V6_AUDIT.md @@ -0,0 +1,650 @@ +# v6.0.0 Lift → http4s Migration: Override Audit & Batch Roadmap + +Companion to `LIFT_HTTP4S_MIGRATION.md`. Generated by static analysis of +`APIMethods*.scala` ResourceDoc declarations across every prior version. + +## Summary + +- **Total v6.0.0 endpoints**: 243 +- **Overrides** (same VERB + URL as an earlier version): **35** + - These MUST be migrated before `Http4s600` is wired into `Http4sApp.baseServices`. + - Reason: the bridge cascade would otherwise route v6 requests to v5/v4/etc handlers + silently. See CLAUDE.md → "Bridge-cascade hijack". +- **Originals** (new to v6, no earlier definition): **208** + - Safe to migrate in any order before OR after `Http4s600` is wired in. + +### Verb distribution + +| Verb | Overrides | Originals | Total | +|---|---|---|---| +| GET | 23 | 100 | 123 | +| POST | 8 | 51 | 59 | +| PUT | 4 | 30 | 34 | +| DELETE | 0 | 27 | 27 | +| **Total** | **35** | **208** | **243** | + +--- + +## The 35 override endpoints — must migrate before wire-in + +Sorted by verb then URL. + +| # | v6 endpoint | Verb | URL | Earlier versions defining same (verb, URL) | +|---|---|---|---|---| +| 1 | `getScannedApiVersions` | GET | `/api/versions` | v4_0_0 | +| 2 | `getBanks` | GET | `/banks` | v1_2_1, v3_0_0, v4_0_0 | +| 3 | `getBank` | GET | `/banks/BANK_ID` | v1_2_1, v3_0_0, v4_0_0, v5_0_0 | +| 4 | `getAccountsAtBank` | GET | `/banks/BANK_ID/accounts` | v1_2_1, v2_0_0, v4_0_0 | +| 5 | `getPrivateAccountByIdFull` | GET | `/banks/BANK_ID/accounts/ACCOUNT_ID/VIEW_ID/account` | v1_2_1, v2_0_0, v3_0_0, v3_1_0, v4_0_0 | +| 6 | `getTransactionsForBankAccount` | GET | `/banks/BANK_ID/accounts/ACCOUNT_ID/VIEW_ID/transactions` | v1_2_1, v3_0_0 | +| 7 | `getCustomersAtOneBank` | GET | `/banks/BANK_ID/customers` | v2_1_0, v4_0_0, v5_0_0 | +| 8 | `getCustomerByCustomerId` | GET | `/banks/BANK_ID/customers/CUSTOMER_ID` | v3_1_0 | +| 9 | `getProductsV600` | GET | `/banks/BANK_ID/products` | v1_4_0, v2_1_0, v3_1_0, v4_0_0 | +| 10 | `getCustomersAtAllBanks` | GET | `/customers` | v4_0_0 | +| 11 | `getAggregateMetrics` | GET | `/management/aggregate-metrics` | v3_0_0, v5_1_0 | +| 12 | `getBankLevelDynamicEntities` | GET | `/management/banks/BANK_ID/dynamic-entities` | v4_0_0 | +| 13 | `getConsumer` | GET | `/management/consumers/CONSUMER_ID` | v2_1_0, v3_1_0, v5_1_0 | +| 14 | `getMetrics` | GET | `/management/metrics` | v2_1_0, v5_1_0 | +| 15 | `getTopAPIs` | GET | `/management/metrics/top-apis` | v3_1_0 | +| 16 | `getSystemDynamicEntities` | GET | `/management/system-dynamic-entities` | v4_0_0 | +| 17 | `getCoreAccountByIdV600` | GET | `/my/banks/BANK_ID/accounts/ACCOUNT_ID/account` | v2_0_0, v3_0_0, v4_0_0 | +| 18 | `getMyDynamicEntities` | GET | `/my/dynamic-entities` | v4_0_0 | +| 19 | `root` | GET | `/root` | v1_2_1, v1_3_0, v1_4_0, v2_0_0, v2_1_0, v2_2_0, v3_0_0, v3_1_0, v4_0_0, v5_0_0, v5_1_0 | +| 20 | `getUsers` | GET | `/users` | v2_1_0, v3_0_0, v4_0_0 | +| 21 | `getUserAttributes` | GET | `/users/USER_ID/attributes` | v4_0_0 | +| 22 | `getCurrentUser` | GET | `/users/current` | v2_0_0, v3_0_0 | +| 23 | `getWebUiProps` | GET | `/webui-props` | v5_1_0 | +| 24 | `createBank` | POST | `/banks` | v2_2_0, v4_0_0, v5_0_0 | +| 25 | `createCustomer` | POST | `/banks/BANK_ID/customers` | v2_0_0, v2_1_0, v3_1_0, v4_0_0, v5_0_0 | +| 26 | `getCustomerByCustomerNumber` | POST | `/banks/BANK_ID/customers/customer-number` | v3_1_0 | +| 27 | `getCustomersByLegalName` | POST | `/banks/BANK_ID/customers/legal-name` | v5_1_0 | +| 28 | `createBankLevelDynamicEntity` | POST | `/management/banks/BANK_ID/dynamic-entities` | v4_0_0 | +| 29 | `createSystemDynamicEntity` | POST | `/management/system-dynamic-entities` | v4_0_0 | +| 30 | `resetPasswordUrl` | POST | `/management/user/reset-password-url` | v4_0_0 | +| 31 | `createUser` | POST | `/users` | v2_0_0 | +| 32 | `updateBankLevelDynamicEntity` | PUT | `/management/banks/BANK_ID/dynamic-entities/DYNAMIC_ENTITY_ID` | v4_0_0 | +| 33 | `updateSystemDynamicEntity` | PUT | `/management/system-dynamic-entities/DYNAMIC_ENTITY_ID` | v4_0_0 | +| 34 | `updateMyDynamicEntity` | PUT | `/my/dynamic-entities/DYNAMIC_ENTITY_ID` | v4_0_0 | +| 35 | `updateSystemView` | PUT | `/system-views/VIEW_ID` | v3_1_0, v5_0_0 | + +--- + +## The 208 original endpoints — grouped by domain + +Each domain is a natural batch boundary. Recommended PR size: 5–10 endpoints. +Buckets are sorted by size (largest domain first). + +### Bucket summary + +| Bucket | Count | Verbs | +|---|---|---| +| `chat-rooms` | 26 | DELETE:4 GET:9 POST:6 PUT:7 | +| `banks/.../chat-rooms` | 24 | DELETE:4 GET:8 POST:5 PUT:7 | +| `banks/.../accounts` | 22 | DELETE:2 GET:9 POST:9 PUT:2 | +| `users` | 16 | DELETE:2 GET:6 POST:6 PUT:2 | +| `banks/.../mandates` | 10 | DELETE:2 GET:4 POST:2 PUT:2 | +| `banks/.../api-products` | 9 | DELETE:2 GET:3 POST:2 PUT:2 | +| `system` | 8 | GET:8 | +| `management/abac-rules` | 8 | DELETE:1 GET:3 POST:3 PUT:1 | +| `management/consumers` | 6 | DELETE:1 GET:3 POST:1 PUT:1 | +| `management/groups` | 6 | DELETE:1 GET:3 POST:1 PUT:1 | +| `signal` | 6 | DELETE:1 GET:4 POST:1 | +| `my/personal-data-fields` | 5 | DELETE:1 GET:2 POST:1 PUT:1 | +| `banks/.../customer-links` | 5 | DELETE:1 GET:2 POST:1 PUT:1 | +| `banks/.../corporate-customers` | 4 | GET:3 POST:1 | +| `management/api-collections` | 4 | DELETE:1 GET:1 POST:1 PUT:1 | +| `banks/.../customers` | 3 | GET:3 | +| `banks/.../retail-customers` | 3 | GET:2 POST:1 | +| `management/banks` | 3 | GET:1 POST:2 | +| `management/diagnostics` | 2 | DELETE:1 GET:1 | +| `management/system-views` | 2 | GET:2 | +| `management/webui_props` | 2 | DELETE:1 PUT:1 | +| `management/system-dynamic-entities` | 2 | DELETE:1 POST:1 | +| `management/abac-policies` | 2 | GET:1 POST:1 | +| `oidc` | 2 | GET:1 POST:1 | +| `management/connector` | 2 | GET:2 | +| `banks/.../products` | 2 | GET:1 PUT:1 | +| `features` | 1 | GET:1 | +| `consumers` | 1 | GET:1 | +| `management/cache` | 1 | POST:1 | +| `management/dynamic-entities` | 1 | GET:1 | +| `providers` | 1 | GET:1 | +| `my/logins` | 1 | POST:1 | +| `entitlements` | 1 | DELETE:1 | +| `management/roles-with-entitlement-counts` | 1 | GET:1 | +| `management/view-permissions` | 1 | GET:1 | +| `management/custom-views` | 1 | GET:1 | +| `webui-props` | 1 | GET:1 | +| `management/abac-rules-schema` | 1 | GET:1 | +| `management/dynamic-resource-docs` | 1 | POST:1 | +| `message-docs` | 1 | GET:1 | +| `personal-dynamic-entities` | 1 | GET:1 | +| `api` | 1 | GET:1 | +| `api-products` | 1 | GET:1 | +| `products` | 1 | GET:1 | +| `management/config-props` | 1 | GET:1 | +| `app-directory` | 1 | GET:1 | +| `my/account-access-requests` | 1 | GET:1 | +| `banks/.../account-directory` | 1 | GET:1 | +| `banks/.../chat-room-participants` | 1 | POST:1 | +| `chat-room-participants` | 1 | POST:1 | + +### Full breakdown by bucket + +#### `chat-rooms` — 26 endpoints + +| Endpoint | Verb | URL | +|---|---|---| +| `deleteSystemChatRoom` | DELETE | `/chat-rooms/CHAT_ROOM_ID` | +| `deleteSystemChatMessage` | DELETE | `/chat-rooms/CHAT_ROOM_ID/messages/CHAT_MESSAGE_ID` | +| `removeSystemReaction` | DELETE | `/chat-rooms/CHAT_ROOM_ID/messages/CHAT_MESSAGE_ID/reactions/EMOJI` | +| `removeSystemChatRoomParticipant` | DELETE | `/chat-rooms/CHAT_ROOM_ID/participants/USER_ID` | +| `getSystemChatRooms` | GET | `/chat-rooms` | +| `getSystemChatRoom` | GET | `/chat-rooms/CHAT_ROOM_ID` | +| `getSystemChatMessages` | GET | `/chat-rooms/CHAT_ROOM_ID/messages` | +| `getSystemChatMessage` | GET | `/chat-rooms/CHAT_ROOM_ID/messages/CHAT_MESSAGE_ID` | +| `getSystemReactions` | GET | `/chat-rooms/CHAT_ROOM_ID/messages/CHAT_MESSAGE_ID/reactions` | +| `getSystemThreadReplies` | GET | `/chat-rooms/CHAT_ROOM_ID/messages/CHAT_MESSAGE_ID/thread` | +| `getBulkReactions` | GET | `/chat-rooms/CHAT_ROOM_ID/messages/reactions` | +| `getSystemChatRoomParticipants` | GET | `/chat-rooms/CHAT_ROOM_ID/participants` | +| `getSystemTypingUsers` | GET | `/chat-rooms/CHAT_ROOM_ID/typing-indicators` | +| `createSystemChatRoom` | POST | `/chat-rooms` | +| `sendSystemChatMessage` | POST | `/chat-rooms/CHAT_ROOM_ID/messages` | +| `addSystemReaction` | POST | `/chat-rooms/CHAT_ROOM_ID/messages/CHAT_MESSAGE_ID/reactions` | +| `replyInSystemThread` | POST | `/chat-rooms/CHAT_ROOM_ID/messages/CHAT_MESSAGE_ID/thread` | +| `addSystemChatRoomParticipant` | POST | `/chat-rooms/CHAT_ROOM_ID/participants` | +| `searchChatRooms` | POST | `/chat-rooms/search` | +| `updateSystemChatRoom` | PUT | `/chat-rooms/CHAT_ROOM_ID` | +| `archiveSystemChatRoom` | PUT | `/chat-rooms/CHAT_ROOM_ID/archive-status` | +| `refreshSystemJoiningKey` | PUT | `/chat-rooms/CHAT_ROOM_ID/joining-key` | +| `editSystemChatMessage` | PUT | `/chat-rooms/CHAT_ROOM_ID/messages/CHAT_MESSAGE_ID` | +| `setSystemChatRoomOpenRoom` | PUT | `/chat-rooms/CHAT_ROOM_ID/open-room` | +| `updateSystemParticipantPermissions` | PUT | `/chat-rooms/CHAT_ROOM_ID/participants/USER_ID` | +| `signalSystemTyping` | PUT | `/chat-rooms/CHAT_ROOM_ID/typing-indicators` | + +#### `banks/.../chat-rooms` — 24 endpoints + +| Endpoint | Verb | URL | +|---|---|---| +| `deleteBankChatRoom` | DELETE | `/banks/BANK_ID/chat-rooms/CHAT_ROOM_ID` | +| `deleteBankChatMessage` | DELETE | `/banks/BANK_ID/chat-rooms/CHAT_ROOM_ID/messages/CHAT_MESSAGE_ID` | +| `removeBankReaction` | DELETE | `/banks/BANK_ID/chat-rooms/CHAT_ROOM_ID/messages/CHAT_MESSAGE_ID/reactions/EMOJI` | +| `removeBankChatRoomParticipant` | DELETE | `/banks/BANK_ID/chat-rooms/CHAT_ROOM_ID/participants/USER_ID` | +| `getBankChatRooms` | GET | `/banks/BANK_ID/chat-rooms` | +| `getBankChatRoom` | GET | `/banks/BANK_ID/chat-rooms/CHAT_ROOM_ID` | +| `getBankChatMessages` | GET | `/banks/BANK_ID/chat-rooms/CHAT_ROOM_ID/messages` | +| `getBankChatMessage` | GET | `/banks/BANK_ID/chat-rooms/CHAT_ROOM_ID/messages/CHAT_MESSAGE_ID` | +| `getBankReactions` | GET | `/banks/BANK_ID/chat-rooms/CHAT_ROOM_ID/messages/CHAT_MESSAGE_ID/reactions` | +| `getBankThreadReplies` | GET | `/banks/BANK_ID/chat-rooms/CHAT_ROOM_ID/messages/CHAT_MESSAGE_ID/thread` | +| `getBankChatRoomParticipants` | GET | `/banks/BANK_ID/chat-rooms/CHAT_ROOM_ID/participants` | +| `getBankTypingUsers` | GET | `/banks/BANK_ID/chat-rooms/CHAT_ROOM_ID/typing-indicators` | +| `createBankChatRoom` | POST | `/banks/BANK_ID/chat-rooms` | +| `sendBankChatMessage` | POST | `/banks/BANK_ID/chat-rooms/CHAT_ROOM_ID/messages` | +| `addBankReaction` | POST | `/banks/BANK_ID/chat-rooms/CHAT_ROOM_ID/messages/CHAT_MESSAGE_ID/reactions` | +| `replyInBankThread` | POST | `/banks/BANK_ID/chat-rooms/CHAT_ROOM_ID/messages/CHAT_MESSAGE_ID/thread` | +| `addBankChatRoomParticipant` | POST | `/banks/BANK_ID/chat-rooms/CHAT_ROOM_ID/participants` | +| `updateBankChatRoom` | PUT | `/banks/BANK_ID/chat-rooms/CHAT_ROOM_ID` | +| `archiveBankChatRoom` | PUT | `/banks/BANK_ID/chat-rooms/CHAT_ROOM_ID/archive-status` | +| `refreshBankJoiningKey` | PUT | `/banks/BANK_ID/chat-rooms/CHAT_ROOM_ID/joining-key` | +| `editBankChatMessage` | PUT | `/banks/BANK_ID/chat-rooms/CHAT_ROOM_ID/messages/CHAT_MESSAGE_ID` | +| `setBankChatRoomOpenRoom` | PUT | `/banks/BANK_ID/chat-rooms/CHAT_ROOM_ID/open-room` | +| `updateBankParticipantPermissions` | PUT | `/banks/BANK_ID/chat-rooms/CHAT_ROOM_ID/participants/USER_ID` | +| `signalBankTyping` | PUT | `/banks/BANK_ID/chat-rooms/CHAT_ROOM_ID/typing-indicators` | + +#### `banks/.../accounts` — 22 endpoints + +| Endpoint | Verb | URL | +|---|---|---| +| `deleteCounterpartyAttribute` | DELETE | `/banks/BANK_ID/accounts/ACCOUNT_ID/counterparties/COUNTERPARTY_ID/attributes/COUNTERPARTY_ATTRIBUTE_ID` | +| `deleteMandate` | DELETE | `/banks/BANK_ID/accounts/ACCOUNT_ID/mandates/MANDATE_ID` | +| `getHoldingAccountByReleaser` | GET | `/banks/BANK_ID/accounts/ACCOUNT_ID/VIEW_ID/holding-accounts` | +| `getAccountAccessRequestsForAccount` | GET | `/banks/BANK_ID/accounts/ACCOUNT_ID/account-access-requests` | +| `getAccountAccessRequestById` | GET | `/banks/BANK_ID/accounts/ACCOUNT_ID/account-access-requests/ACCOUNT_ACCESS_REQUEST_ID` | +| `getAllCounterpartyAttributes` | GET | `/banks/BANK_ID/accounts/ACCOUNT_ID/counterparties/COUNTERPARTY_ID/attributes` | +| `getCounterpartyAttributeById` | GET | `/banks/BANK_ID/accounts/ACCOUNT_ID/counterparties/COUNTERPARTY_ID/attributes/COUNTERPARTY_ATTRIBUTE_ID` | +| `getMandates` | GET | `/banks/BANK_ID/accounts/ACCOUNT_ID/mandates` | +| `getMandate` | GET | `/banks/BANK_ID/accounts/ACCOUNT_ID/mandates/MANDATE_ID` | +| `hasAccountAccess` | GET | `/banks/BANK_ID/accounts/ACCOUNT_ID/views/VIEW_ID/has-account-access` | +| `getUsersWithAccountAccess` | GET | `/banks/BANK_ID/accounts/ACCOUNT_ID/views/VIEW_ID/users-with-access` | +| `createAccountAccessRequest` | POST | `/banks/BANK_ID/accounts/ACCOUNT_ID/account-access-requests` | +| `approveAccountAccessRequest` | POST | `/banks/BANK_ID/accounts/ACCOUNT_ID/account-access-requests/ACCOUNT_ACCESS_REQUEST_ID/approval` | +| `rejectAccountAccessRequest` | POST | `/banks/BANK_ID/accounts/ACCOUNT_ID/account-access-requests/ACCOUNT_ACCESS_REQUEST_ID/rejection` | +| `createCounterpartyAttribute` | POST | `/banks/BANK_ID/accounts/ACCOUNT_ID/counterparties/COUNTERPARTY_ID/attributes` | +| `createMandate` | POST | `/banks/BANK_ID/accounts/ACCOUNT_ID/mandates` | +| `createTransactionRequestCardano` | POST | `/banks/BANK_ID/accounts/ACCOUNT_ID/owner/transaction-request-types/CARDANO/transaction-requests` | +| `createTransactionRequestEthSendRawTransaction` | POST | `/banks/BANK_ID/accounts/ACCOUNT_ID/owner/transaction-request-types/ETH_SEND_RAW_TRANSACTION/transaction-requests` | +| `createTransactionRequestEthereumeSendTransaction` | POST | `/banks/BANK_ID/accounts/ACCOUNT_ID/owner/transaction-request-types/ETH_SEND_TRANSACTION/transaction-requests` | +| `createTransactionRequestHold` | POST | `/banks/BANK_ID/accounts/ACCOUNT_ID/owner/transaction-request-types/HOLD/transaction-requests` | +| `updateCounterpartyAttribute` | PUT | `/banks/BANK_ID/accounts/ACCOUNT_ID/counterparties/COUNTERPARTY_ID/attributes/COUNTERPARTY_ATTRIBUTE_ID` | +| `updateMandate` | PUT | `/banks/BANK_ID/accounts/ACCOUNT_ID/mandates/MANDATE_ID` | + +#### `users` — 16 endpoints + +| Endpoint | Verb | URL | +|---|---|---| +| `deleteUserAttribute` | DELETE | `/users/USER_ID/attributes/USER_ATTRIBUTE_ID` | +| `removeUserFromGroup` | DELETE | `/users/USER_ID/group-entitlements/GROUP_ID` | +| `getUserAttributeById` | GET | `/users/USER_ID/attributes/USER_ATTRIBUTE_ID` | +| `getUserGroupMemberships` | GET | `/users/USER_ID/group-entitlements` | +| `getMyChatRooms` | GET | `/users/current/chat-rooms` | +| `getMyUnreadCounts` | GET | `/users/current/chat-rooms/unread` | +| `getMyMentions` | GET | `/users/current/mentions` | +| `getUserByUserId` | GET | `/users/user-id/USER_ID` | +| `createUserAttribute` | POST | `/users/USER_ID/attributes` | +| `addUserToGroup` | POST | `/users/USER_ID/group-entitlements` | +| `validateUserEmail` | POST | `/users/email-validation` | +| `resetPasswordComplete` | POST | `/users/password` | +| `resetPasswordUrlAnonymous` | POST | `/users/password-reset-url` | +| `verifyUserCredentials` | POST | `/users/verify-credentials` | +| `updateUserAttribute` | PUT | `/users/USER_ID/attributes/USER_ATTRIBUTE_ID` | +| `markChatRoomRead` | PUT | `/users/current/chat-rooms/CHAT_ROOM_ID/read-marker` | + +#### `banks/.../mandates` — 10 endpoints + +| Endpoint | Verb | URL | +|---|---|---| +| `deleteMandateProvision` | DELETE | `/banks/BANK_ID/mandates/MANDATE_ID/provisions/PROVISION_ID` | +| `deleteSignatoryPanel` | DELETE | `/banks/BANK_ID/mandates/MANDATE_ID/signatory-panels/PANEL_ID` | +| `getMandateProvisions` | GET | `/banks/BANK_ID/mandates/MANDATE_ID/provisions` | +| `getMandateProvision` | GET | `/banks/BANK_ID/mandates/MANDATE_ID/provisions/PROVISION_ID` | +| `getSignatoryPanels` | GET | `/banks/BANK_ID/mandates/MANDATE_ID/signatory-panels` | +| `getSignatoryPanel` | GET | `/banks/BANK_ID/mandates/MANDATE_ID/signatory-panels/PANEL_ID` | +| `createMandateProvision` | POST | `/banks/BANK_ID/mandates/MANDATE_ID/provisions` | +| `createSignatoryPanel` | POST | `/banks/BANK_ID/mandates/MANDATE_ID/signatory-panels` | +| `updateMandateProvision` | PUT | `/banks/BANK_ID/mandates/MANDATE_ID/provisions/PROVISION_ID` | +| `updateSignatoryPanel` | PUT | `/banks/BANK_ID/mandates/MANDATE_ID/signatory-panels/PANEL_ID` | + +#### `banks/.../api-products` — 9 endpoints + +| Endpoint | Verb | URL | +|---|---|---| +| `deleteApiProduct` | DELETE | `/banks/BANK_ID/api-products/API_PRODUCT_CODE` | +| `deleteApiProductAttribute` | DELETE | `/banks/BANK_ID/api-products/API_PRODUCT_CODE/attributes/API_PRODUCT_ATTRIBUTE_ID` | +| `getApiProducts` | GET | `/banks/BANK_ID/api-products` | +| `getApiProduct` | GET | `/banks/BANK_ID/api-products/API_PRODUCT_CODE` | +| `getApiProductAttribute` | GET | `/banks/BANK_ID/api-products/API_PRODUCT_CODE/attributes/API_PRODUCT_ATTRIBUTE_ID` | +| `createApiProduct` | POST | `/banks/BANK_ID/api-products/API_PRODUCT_CODE` | +| `createApiProductAttribute` | POST | `/banks/BANK_ID/api-products/API_PRODUCT_CODE/attribute` | +| `createOrUpdateApiProduct` | PUT | `/banks/BANK_ID/api-products/API_PRODUCT_CODE` | +| `updateApiProductAttribute` | PUT | `/banks/BANK_ID/api-products/API_PRODUCT_CODE/attributes/API_PRODUCT_ATTRIBUTE_ID` | + +#### `system` — 8 endpoints + +| Endpoint | Verb | URL | +|---|---|---| +| `getCacheConfig` | GET | `/system/cache/config` | +| `getCacheInfo` | GET | `/system/cache/info` | +| `getCacheNamespaces` | GET | `/system/cache/namespaces` | +| `getConnectorMethodNames` | GET | `/system/connector-method-names` | +| `getConnectors` | GET | `/system/connectors` | +| `getStoredProcedureConnectorHealth` | GET | `/system/connectors/stored_procedure_vDec2019/health` | +| `getDatabasePoolInfo` | GET | `/system/database/pool` | +| `getMigrations` | GET | `/system/migrations` | + +#### `management/abac-rules` — 8 endpoints + +| Endpoint | Verb | URL | +|---|---|---| +| `deleteAbacRule` | DELETE | `/management/abac-rules/ABAC_RULE_ID` | +| `getAbacRules` | GET | `/management/abac-rules` | +| `getAbacRule` | GET | `/management/abac-rules/ABAC_RULE_ID` | +| `getAbacRulesByPolicy` | GET | `/management/abac-rules/policy/POLICY` | +| `createAbacRule` | POST | `/management/abac-rules` | +| `executeAbacRule` | POST | `/management/abac-rules/ABAC_RULE_ID/execute` | +| `validateAbacRule` | POST | `/management/abac-rules/validate` | +| `updateAbacRule` | PUT | `/management/abac-rules/ABAC_RULE_ID` | + +#### `management/consumers` — 6 endpoints + +| Endpoint | Verb | URL | +|---|---|---| +| `deleteCallLimits` | DELETE | `/management/consumers/CONSUMER_ID/consumer/rate-limits/RATE_LIMITING_ID` | +| `getActiveRateLimitsNow` | GET | `/management/consumers/CONSUMER_ID/active-rate-limits` | +| `getActiveRateLimitsAtDate` | GET | `/management/consumers/CONSUMER_ID/active-rate-limits/DATE_WITH_HOUR` | +| `getConsumerCallCounters` | GET | `/management/consumers/CONSUMER_ID/call-counters` | +| `createCallLimits` | POST | `/management/consumers/CONSUMER_ID/consumer/rate-limits` | +| `updateRateLimits` | PUT | `/management/consumers/CONSUMER_ID/consumer/rate-limits/RATE_LIMITING_ID` | + +#### `management/groups` — 6 endpoints + +| Endpoint | Verb | URL | +|---|---|---| +| `deleteGroup` | DELETE | `/management/groups/GROUP_ID` | +| `getGroups` | GET | `/management/groups` | +| `getGroup` | GET | `/management/groups/GROUP_ID` | +| `getGroupEntitlements` | GET | `/management/groups/GROUP_ID/entitlements` | +| `createGroup` | POST | `/management/groups` | +| `updateGroup` | PUT | `/management/groups/GROUP_ID` | + +#### `signal` — 6 endpoints + +| Endpoint | Verb | URL | +|---|---|---| +| `deleteSignalChannel` | DELETE | `/signal/channels/CHANNEL_NAME` | +| `getSignalChannels` | GET | `/signal/channels` | +| `getSignalChannelInfo` | GET | `/signal/channels/CHANNEL_NAME/info` | +| `getSignalMessages` | GET | `/signal/channels/CHANNEL_NAME/messages` | +| `getSignalStats` | GET | `/signal/channels/stats` | +| `publishSignalMessage` | POST | `/signal/channels/CHANNEL_NAME/messages` | + +#### `my/personal-data-fields` — 5 endpoints + +| Endpoint | Verb | URL | +|---|---|---| +| `deletePersonalDataField` | DELETE | `/my/personal-data-fields/USER_ATTRIBUTE_ID` | +| `getPersonalDataFields` | GET | `/my/personal-data-fields` | +| `getPersonalDataFieldById` | GET | `/my/personal-data-fields/USER_ATTRIBUTE_ID` | +| `createPersonalDataField` | POST | `/my/personal-data-fields` | +| `updatePersonalDataField` | PUT | `/my/personal-data-fields/USER_ATTRIBUTE_ID` | + +#### `banks/.../customer-links` — 5 endpoints + +| Endpoint | Verb | URL | +|---|---|---| +| `deleteCustomerLink` | DELETE | `/banks/BANK_ID/customer-links/CUSTOMER_LINK_ID` | +| `getCustomerLinksByBankId` | GET | `/banks/BANK_ID/customer-links` | +| `getCustomerLinkById` | GET | `/banks/BANK_ID/customer-links/CUSTOMER_LINK_ID` | +| `createCustomerLink` | POST | `/banks/BANK_ID/customer-links` | +| `updateCustomerLink` | PUT | `/banks/BANK_ID/customer-links/CUSTOMER_LINK_ID` | + +#### `banks/.../corporate-customers` — 4 endpoints + +| Endpoint | Verb | URL | +|---|---|---| +| `getCorporateCustomersAtOneBank` | GET | `/banks/BANK_ID/corporate-customers` | +| `getCorporateCustomerByCustomerId` | GET | `/banks/BANK_ID/corporate-customers/CUSTOMER_ID` | +| `getCorporateCustomerSubsidiaries` | GET | `/banks/BANK_ID/corporate-customers/CUSTOMER_ID/subsidiaries` | +| `createCorporateCustomer` | POST | `/banks/BANK_ID/corporate-customers` | + +#### `management/api-collections` — 4 endpoints + +| Endpoint | Verb | URL | +|---|---|---| +| `deleteFeaturedApiCollection` | DELETE | `/management/api-collections/featured/API_COLLECTION_ID` | +| `getFeaturedApiCollectionsAdmin` | GET | `/management/api-collections/featured` | +| `createFeaturedApiCollection` | POST | `/management/api-collections/featured` | +| `updateFeaturedApiCollection` | PUT | `/management/api-collections/featured/API_COLLECTION_ID` | + +#### `banks/.../customers` — 3 endpoints + +| Endpoint | Verb | URL | +|---|---|---| +| `getCustomerChildren` | GET | `/banks/BANK_ID/customers/CUSTOMER_ID/children` | +| `getCustomerLinksByCustomerId` | GET | `/banks/BANK_ID/customers/CUSTOMER_ID/customer-links` | +| `getCustomerInvestigationReport` | GET | `/banks/BANK_ID/customers/CUSTOMER_ID/investigation-report` | + +#### `banks/.../retail-customers` — 3 endpoints + +| Endpoint | Verb | URL | +|---|---|---| +| `getRetailCustomersAtOneBank` | GET | `/banks/BANK_ID/retail-customers` | +| `getRetailCustomerByCustomerId` | GET | `/banks/BANK_ID/retail-customers/CUSTOMER_ID` | +| `createRetailCustomer` | POST | `/banks/BANK_ID/retail-customers` | + +#### `management/banks` — 3 endpoints + +| Endpoint | Verb | URL | +|---|---|---| +| `getCustomViewById` | GET | `/management/banks/BANK_ID/accounts/ACCOUNT_ID/views/VIEW_ID` | +| `createCustomViewManagement` | POST | `/management/banks/BANK_ID/accounts/ACCOUNT_ID/views` | +| `backupBankLevelDynamicEntity` | POST | `/management/banks/BANK_ID/dynamic-entities/DYNAMIC_ENTITY_ID/backup` | + +#### `management/diagnostics` — 2 endpoints + +| Endpoint | Verb | URL | +|---|---|---| +| `cleanupOrphanedDynamicEntityRecords` | DELETE | `/management/diagnostics/dynamic-entities/orphaned-records` | +| `getDynamicEntityDiagnostics` | GET | `/management/diagnostics/dynamic-entities` | + +#### `management/system-views` — 2 endpoints + +| Endpoint | Verb | URL | +|---|---|---| +| `getSystemViews` | GET | `/management/system-views` | +| `getSystemViewById` | GET | `/management/system-views/VIEW_ID` | + +#### `management/webui_props` — 2 endpoints + +| Endpoint | Verb | URL | +|---|---|---| +| `deleteWebUiProps` | DELETE | `/management/webui_props/WEBUI_PROP_NAME` | +| `createOrUpdateWebUiProps` | PUT | `/management/webui_props/WEBUI_PROP_NAME` | + +#### `management/system-dynamic-entities` — 2 endpoints + +| Endpoint | Verb | URL | +|---|---|---| +| `deleteSystemDynamicEntityCascade` | DELETE | `/management/system-dynamic-entities/cascade/DYNAMIC_ENTITY_ID` | +| `backupSystemDynamicEntity` | POST | `/management/system-dynamic-entities/DYNAMIC_ENTITY_ID/backup` | + +#### `management/abac-policies` — 2 endpoints + +| Endpoint | Verb | URL | +|---|---|---| +| `getAbacPolicies` | GET | `/management/abac-policies` | +| `executeAbacPolicy` | POST | `/management/abac-policies/POLICY/execute` | + +#### `oidc` — 2 endpoints + +| Endpoint | Verb | URL | +|---|---|---| +| `getOidcClient` | GET | `/oidc/clients/CLIENT_ID` | +| `verifyOidcClient` | POST | `/oidc/clients/verify` | + +#### `management/connector` — 2 endpoints + +| Endpoint | Verb | URL | +|---|---|---| +| `getConnectorCallCounts` | GET | `/management/connector/metrics/counts` | +| `getConnectorTraces` | GET | `/management/connector/traces` | + +#### `banks/.../products` — 2 endpoints + +| Endpoint | Verb | URL | +|---|---|---| +| `getProductTagsV600` | GET | `/banks/BANK_ID/products/PRODUCT_CODE/tags` | +| `updateProductTagsV600` | PUT | `/banks/BANK_ID/products/PRODUCT_CODE/tags` | + +#### `features` — 1 endpoints + +| Endpoint | Verb | URL | +|---|---|---| +| `getFeatures` | GET | `/features` | + +#### `consumers` — 1 endpoints + +| Endpoint | Verb | URL | +|---|---|---| +| `getCurrentConsumer` | GET | `/consumers/current` | + +#### `management/cache` — 1 endpoints + +| Endpoint | Verb | URL | +|---|---|---| +| `invalidateCacheNamespace` | POST | `/management/cache/namespaces/invalidate` | + +#### `management/dynamic-entities` — 1 endpoints + +| Endpoint | Verb | URL | +|---|---|---| +| `getReferenceTypes` | GET | `/management/dynamic-entities/reference-types` | + +#### `providers` — 1 endpoints + +| Endpoint | Verb | URL | +|---|---|---| +| `getProviders` | GET | `/providers` | + +#### `my/logins` — 1 endpoints + +| Endpoint | Verb | URL | +|---|---|---| +| `directLoginEndpoint` | POST | `/my/logins/direct` | + +#### `entitlements` — 1 endpoints + +| Endpoint | Verb | URL | +|---|---|---| +| `deleteEntitlement` | DELETE | `/entitlements/ENTITLEMENT_ID` | + +#### `management/roles-with-entitlement-counts` — 1 endpoints + +| Endpoint | Verb | URL | +|---|---|---| +| `getRolesWithEntitlementCountsAtAllBanks` | GET | `/management/roles-with-entitlement-counts` | + +#### `management/view-permissions` — 1 endpoints + +| Endpoint | Verb | URL | +|---|---|---| +| `getViewPermissions` | GET | `/management/view-permissions` | + +#### `management/custom-views` — 1 endpoints + +| Endpoint | Verb | URL | +|---|---|---| +| `getCustomViews` | GET | `/management/custom-views` | + +#### `webui-props` — 1 endpoints + +| Endpoint | Verb | URL | +|---|---|---| +| `getWebUiProp` | GET | `/webui-props/WEBUI_PROP_NAME` | + +#### `management/abac-rules-schema` — 1 endpoints + +| Endpoint | Verb | URL | +|---|---|---| +| `getAbacRuleSchema` | GET | `/management/abac-rules-schema` | + +#### `management/dynamic-resource-docs` — 1 endpoints + +| Endpoint | Verb | URL | +|---|---|---| +| `validateDynamicResourceDoc` | POST | `/management/dynamic-resource-docs/validate` | + +#### `message-docs` — 1 endpoints + +| Endpoint | Verb | URL | +|---|---|---| +| `getMessageDocsJsonSchema` | GET | `/message-docs/CONNECTOR/json-schema` | + +#### `personal-dynamic-entities` — 1 endpoints + +| Endpoint | Verb | URL | +|---|---|---| +| `getAvailablePersonalDynamicEntities` | GET | `/personal-dynamic-entities/available` | + +#### `api` — 1 endpoints + +| Endpoint | Verb | URL | +|---|---|---| +| `getPopularApis` | GET | `/api/popular-endpoints` | + +#### `api-products` — 1 endpoints + +| Endpoint | Verb | URL | +|---|---|---| +| `getAllApiProductsV600` | GET | `/api-products` | + +#### `products` — 1 endpoints + +| Endpoint | Verb | URL | +|---|---|---| +| `getAllProductsV600` | GET | `/products` | + +#### `management/config-props` — 1 endpoints + +| Endpoint | Verb | URL | +|---|---|---| +| `getConfigProps` | GET | `/management/config-props` | + +#### `app-directory` — 1 endpoints + +| Endpoint | Verb | URL | +|---|---|---| +| `getAppDirectory` | GET | `/app-directory` | + +#### `my/account-access-requests` — 1 endpoints + +| Endpoint | Verb | URL | +|---|---|---| +| `getMyAccountAccessRequests` | GET | `/my/account-access-requests` | + +#### `banks/.../account-directory` — 1 endpoints + +| Endpoint | Verb | URL | +|---|---|---| +| `getAccountDirectory` | GET | `/banks/BANK_ID/account-directory` | + +#### `banks/.../chat-room-participants` — 1 endpoints + +| Endpoint | Verb | URL | +|---|---|---| +| `joinBankChatRoom` | POST | `/banks/BANK_ID/chat-room-participants` | + +#### `chat-room-participants` — 1 endpoints + +| Endpoint | Verb | URL | +|---|---|---| +| `joinSystemChatRoom` | POST | `/chat-room-participants` | + +--- + +## Recommended migration order + +**Phase 0 — Foundation (1 PR, ~1 day)** + +1. Create `obp-api/src/main/scala/code/api/v6_0_0/Http4s600.scala` skeleton: + `prefixPath = Root / "obp" / "v6.0.0"`, empty `allRoutes`, `v600ToV510Bridge` + (path-rewrite to v5.1.0, then through the existing cascade). +2. Do NOT add to `Http4sApp.baseServices` yet — it remains inert. +3. Add `Http4s600.scala` registration to `OBPAPI6_0_0.allResourceDocs` chain + for resource-docs aggregation parity, but keep `resourceDocs` empty. + +**Phase 1 — Override batch (3–5 PRs, ~2–3 weeks)** + +Migrate the 35 overrides in 4 PRs by verb cluster, in this order: + +- PR 1: All 23 GET overrides (mechanical, lowest risk) +- PR 2: 4 PUT overrides +- PR 3: 8 POST overrides +- PR 4: **Wire `Http4s600` into `Http4sApp.baseServices`** + full regression run + +After PR 4, the chain is `… → Http4s600 → v600ToV510Bridge → Http4s510 → …` +and overrides are protected. + +**Phase 2 — Originals by domain (~20 PRs, ~3–5 weeks)** + +Migrate originals one bucket per PR (or split large buckets): + +- PR 5–6: `chat-rooms` (26) + `banks/.../chat-rooms` (24) — biggest domain (50 endpoints) +- PR 7: `banks/.../accounts` (22) +- PR 8: `users` (16) +- PR 9: `banks/.../mandates` (10) +- PR 10: `banks/.../api-products` (9) + `management/abac-rules` (8) +- PR 11: `system` (8) — note these are 8 GETs on `/system-*` paths +- PR 12–13: remaining management/* buckets +- PR 14+: smaller buckets in batches of 5–10 endpoints + +**Phase 3 — Cleanup** + +- Audit `disableAutoValidateRoles()` calls in v6 Lift sources for any inline-role-check + patterns (CLAUDE.md "Bypass roles vs required roles"). +- Verify `excludeEndpoints` list in `OBPAPI6_0_0` matches the migrated set. +- Remove unused Lift implementations as they become dead. + +--- + +## Estimated effort + +Using the CLAUDE.md velocity figures (6–8 endpoints/day mutations, faster for GETs): + +| Phase | Endpoints | PRs | Estimated days | +|---|---|---|---| +| 0 — Foundation | 0 | 1 | ~1 | +| 1 — Override batch | 35 | 3–5 | ~7–10 | +| 2 — Originals | 208 | ~20 | ~30–40 | +| **Total** | **243** | **~25** | **~38–51** | + +Roughly **8–10 weeks** of focused work. diff --git a/obp-api/src/main/scala/code/api/util/ErrorMessages.scala b/obp-api/src/main/scala/code/api/util/ErrorMessages.scala index 9fc2d195f6..c8a64e59e4 100644 --- a/obp-api/src/main/scala/code/api/util/ErrorMessages.scala +++ b/obp-api/src/main/scala/code/api/util/ErrorMessages.scala @@ -1029,6 +1029,24 @@ object ErrorMessages { */ def getCode(errorMsg: String): Int = errorToCode.get(errorMsg).getOrElse(400) + /** + * Resolve HTTP status code for an OBP-prefixed error message that may have a + * runtime suffix appended (e.g. "OBP-20020: User does not have access to the view. + * Current ViewId is owner"). Extracts the OBP-XXXXX prefix and returns the + * canonical status code from errorToCode, but ONLY when the canonical code is + * one of the auth-overrides Lift's `errorJsonResponse` performs (401, 403, 408, + * 429). For other codes (e.g. 404 BankNotFound) the default failCode the caller + * supplied wins — callers that want 404 must request it explicitly via + * `unboxFullOrFail(..., emptyBoxErrorCode = 404)` or similar, matching Lift's + * intent that 400 is a deliberate "validation failure" code. + */ + def getCodeByOBPPrefix(errorMsg: String): Int = { + val prefixOpt = "OBP-\\d{5}".r.findFirstIn(errorMsg) + prefixOpt.flatMap { prefix => + errorToCode.find { case (key, _) => key.startsWith(prefix + ":") }.map(_._2) + }.filter(Set(401, 403, 408, 429).contains).getOrElse(400) + } + /****** special error message, start with $, mark as do validation according ResourceDoc errorResponseBodies *****/ /** * validate method: APIUtil.authorizedAccess diff --git a/obp-api/src/main/scala/code/api/util/http4s/ErrorResponseConverter.scala b/obp-api/src/main/scala/code/api/util/http4s/ErrorResponseConverter.scala index b23065c2bc..c228d4ce2c 100644 --- a/obp-api/src/main/scala/code/api/util/http4s/ErrorResponseConverter.scala +++ b/obp-api/src/main/scala/code/api/util/http4s/ErrorResponseConverter.scala @@ -34,6 +34,8 @@ object ErrorResponseConverter { implicit val formats: Formats = CustomJsonFormats.formats private val jsonContentType: `Content-Type` = `Content-Type`(MediaType.application.json) + private val obpErrorCodePrefix = "^OBP-\\d{5}: ".r + private def tryExtractApiFailureFromExceptionMessage(error: Throwable): Option[APIFailureNewStyle] = { val msg = Option(error.getMessage).getOrElse("").trim if (msg.startsWith("{") && msg.contains("\"failCode\"") && msg.contains("\"failMsg\"")) { @@ -45,6 +47,9 @@ object ErrorResponseConverter { } catch { case _: Throwable => None } + } else if (obpErrorCodePrefix.findFirstIn(msg).isDefined) { + // Plain Exception("OBP-XXXXX: ...") thrown by fullBoxOrException(Failure(msg)) — Lift treats these as 400. + Some(APIFailureNewStyle(msg, 400)) } else { None } @@ -80,13 +85,31 @@ object ErrorResponseConverter { } } + /** Old-style versions keep raw 400 codes — they never promote to 403/401/etc. + * Mirrors the same set used in ResourceDocMiddleware.authenticate. + */ + private val oldStyleShortVersions = Set("v1.2.1", "v1.3.0", "v1.4.0", "v2.0.0") + + /** + * Translate a 400 default with an OBP-prefixed message to the canonical status + * Lift assigns (403 for role/view-access codes, 401 for auth codes, etc.) via + * ErrorMessages.getCodeByOBPPrefix. Leaves non-400 failCodes (caller set + * status explicitly) untouched. Old-style versions (v1.x, v2.0.0) keep the + * 400 — they return 400 for every error per the long-standing OBP convention. + */ + private def resolveStatusCode(failCode: Int, failMsg: String, callContext: CallContext): Int = + if (failCode == 400 && !oldStyleShortVersions.contains(callContext.implementedInVersion)) + code.api.util.ErrorMessages.getCodeByOBPPrefix(failMsg) + else failCode + /** * Convert APIFailureNewStyle to http4s Response. * Uses failCode as HTTP status and failMsg as error message. */ def apiFailureToResponse(failure: APIFailureNewStyle, callContext: CallContext): IO[Response[IO]] = { - val errorJson = OBPErrorResponse(failure.failCode, failure.failMsg) - val status = org.http4s.Status.fromInt(failure.failCode).getOrElse(org.http4s.Status.BadRequest) + val resolvedCode = resolveStatusCode(failure.failCode, failure.failMsg, callContext) + val errorJson = OBPErrorResponse(resolvedCode, failure.failMsg) + val status = org.http4s.Status.fromInt(resolvedCode).getOrElse(org.http4s.Status.BadRequest) IO.pure( Response[IO](status) .withEntity(toJsonString(errorJson)) diff --git a/obp-api/src/main/scala/code/api/util/http4s/Http4sApp.scala b/obp-api/src/main/scala/code/api/util/http4s/Http4sApp.scala index 4d2deddaf9..373ab006ed 100644 --- a/obp-api/src/main/scala/code/api/util/http4s/Http4sApp.scala +++ b/obp-api/src/main/scala/code/api/util/http4s/Http4sApp.scala @@ -72,6 +72,7 @@ object Http4sApp { private val v400Routes: HttpRoutes[IO] = gate(ApiVersion.v4_0_0, code.api.v4_0_0.Http4s400.wrappedRoutesV400Services) private val v500Routes: HttpRoutes[IO] = gate(ApiVersion.v5_0_0, code.api.v5_0_0.Http4s500.wrappedRoutesV500Services) private val v510Routes: HttpRoutes[IO] = gate(ApiVersion.v5_1_0, code.api.v5_1_0.Http4s510.wrappedRoutesV510Services) + private val v600Routes: HttpRoutes[IO] = gate(ApiVersion.v6_0_0, code.api.v6_0_0.Http4s600.wrappedRoutesV600Services) private val v700Routes: HttpRoutes[IO] = gate(ApiVersion.v7_0_0, code.api.v7_0_0.Http4s700.wrappedRoutesV700Services) /** @@ -106,6 +107,7 @@ object Http4sApp { .orElse(AppsPage.routes.run(req)) .orElse(StatusPage.routes.run(req)) .orElse(v510Routes.run(req)) + .orElse(v600Routes.run(req)) .orElse(v500Routes.run(req)) .orElse(v700Routes.run(req)) .orElse(code.api.berlin.group.v2.Http4sBGv2.wrappedRoutes.run(req)) diff --git a/obp-api/src/main/scala/code/api/util/http4s/ResourceDocMiddleware.scala b/obp-api/src/main/scala/code/api/util/http4s/ResourceDocMiddleware.scala index 4350c5c17e..c9de2d16c1 100644 --- a/obp-api/src/main/scala/code/api/util/http4s/ResourceDocMiddleware.scala +++ b/obp-api/src/main/scala/code/api/util/http4s/ResourceDocMiddleware.scala @@ -376,7 +376,11 @@ object ResourceDocMiddleware extends MdcLoggable { case Full(user) => val bankId = pathParams.getOrElse("BANK_ID", "") val consumerId = APIUtil.getConsumerPrimaryKey(Some(ctx.callContext)) - val ok = APIUtil.handleAccessControlRegardingEntitlementsAndScopes(bankId, user.userId, consumerId, roles) + // Use handleAccessControlWithAuthMode so authMode = UserOrApplication + // accepts consumer-scope-only requests (the auth-only handleAccess... + // function checks user-entitlements + scopes ANDed and rejects pure + // consumer-scope requests with 403 even under UserOrApplication). + val ok = APIUtil.handleAccessControlWithAuthMode(bankId, user.userId, consumerId, roles, resourceDoc.authMode) if (ok) success(ctx) else EitherT[IO, Response[IO], ValidationContext]( ErrorResponseConverter.createErrorResponse(403, UserHasMissingRoles + roles.mkString(" or "), ctx.callContext) diff --git a/obp-api/src/main/scala/code/api/v3_1_0/Http4s310.scala b/obp-api/src/main/scala/code/api/v3_1_0/Http4s310.scala index e8a4e90132..329fe58cf5 100644 --- a/obp-api/src/main/scala/code/api/v3_1_0/Http4s310.scala +++ b/obp-api/src/main/scala/code/api/v3_1_0/Http4s310.scala @@ -1706,6 +1706,39 @@ object Http4s310 { List(apiTagCustomer), Some(List(canCreateCustomerAddress)), http4sPartialFunction = Some(createCustomerAddress)) + // ─── updateCustomerAddress (PUT) ───────────────────────────────────────── + + val updateCustomerAddress: HttpRoutes[IO] = HttpRoutes.of[IO] { + case req @ PUT -> `prefixPath` / "banks" / _ / "customers" / customerIdStr / "addresses" / customerAddressIdStr => + EndpointHelpers.withUserAndBankAndBody[PostCustomerAddressJsonV310, Any](req) { (user, bank, postedData, cc) => + for { + _ <- NewStyle.function.hasEntitlement(bank.bankId.value, user.userId, canCreateCustomer, Some(cc)) + (_, _) <- NewStyle.function.getCustomerByCustomerId(customerIdStr, Some(cc)) + (address, _) <- NewStyle.function.updateCustomerAddress( + customerAddressIdStr, + postedData.line_1, postedData.line_2, postedData.line_3, + postedData.city, postedData.county, postedData.state, + postedData.postcode, postedData.country_code, + postedData.state, + postedData.tags.mkString(","), + Some(cc)) + } yield JSONFactory310.createAddress(address) + } + } + + resourceDocs += ResourceDoc( + null, implementedInApiVersion, nameOf(updateCustomerAddress), "PUT", + "/banks/BANK_ID/customers/CUSTOMER_ID/addresses/CUSTOMER_ADDRESS_ID", + "Update the Address of a Customer", + s"""Update an Address of the Customer specified by CUSTOMER_ADDRESS_ID. + | + |${userAuthenticationMessage(true)} + |""", + postCustomerAddressJsonV310, customerAddressJsonV310, + List(AuthenticatedUserIsRequired, UserHasMissingRoles, InvalidJsonFormat, UnknownError), + List(apiTagCustomer), Some(List(canCreateCustomer)), + http4sPartialFunction = Some(updateCustomerAddress)) + // ─── createUserAuthContext (POST) ──────────────────────────────────────── val createUserAuthContext: HttpRoutes[IO] = HttpRoutes.of[IO] { @@ -3581,6 +3614,7 @@ object Http4s310 { .orElse(revokeConsent.run(req)) .orElse(createTaxResidence.run(req)) .orElse(createCustomerAddress.run(req)) + .orElse(updateCustomerAddress.run(req)) .orElse(createUserAuthContext.run(req)) .orElse(createProductAttribute.run(req)) .orElse(createAccountWebhook.run(req)) diff --git a/obp-api/src/main/scala/code/api/v4_0_0/Http4s400.scala b/obp-api/src/main/scala/code/api/v4_0_0/Http4s400.scala index 6a9701cae3..979a5e253e 100644 --- a/obp-api/src/main/scala/code/api/v4_0_0/Http4s400.scala +++ b/obp-api/src/main/scala/code/api/v4_0_0/Http4s400.scala @@ -26,9 +26,39 @@ import code.api.v4_0_0.JSONFactory400._ import code.DynamicData.DynamicData import code.api.util.migration.Migration import code.dynamicEntity.DynamicEntityCommons -import code.bankconnectors.Connector +import code.bankconnectors.{Connector, DynamicConnector, InternalConnector} +import code.authtypevalidation.JsonAuthTypeValidation +import code.endpointMapping.EndpointMappingCommons import code.entitlement.Entitlement import code.model.BankX +import code.api.JsonResponseException +import code.api.util.AuthenticationType +import code.api.util.CommonsEmailWrapper.{EmailContent, sendHtmlEmail} +import code.api.util.DynamicUtil.Validation +import code.api.dynamic.endpoint.helper.CompiledObjects +import code.api.dynamic.endpoint.helper.practise.DynamicEndpointCodeGenerator +import code.model.dataAccess.BankAccountCreation +import code.connectormethod.{JsonConnectorMethod, JsonConnectorMethodMethodBody} +import code.dynamicMessageDoc.JsonDynamicMessageDoc +import code.dynamicResourceDoc.JsonDynamicResourceDoc +import code.userlocks.UserLocksProvider +import code.util.JsonSchemaUtil +import code.validation.JsonValidation +import code.api.util.ApiTrigger +import code.api.util.newstyle.Consumer.createConsumerNewStyle +import code.api.v2_0_0.CreateEntitlementJSON +import code.metadata.counterparties.MappedCounterparty +import code.model.AppType +import code.webuiprops.MappedWebUiPropsProvider.getWebUiPropsValue +import net.liftweb.json.JsonAST.{JNothing, JString} +import net.liftweb.json.Extraction +import net.liftweb.util.{Helpers => LiftHelpers, StringHelpers} +import java.net.URLEncoder +import java.text.SimpleDateFormat +import java.util +import com.networknt.schema.ValidationMessage + +import scala.collection.JavaConverters._ import code.model._ // implicit BankAccountExtended → moderatedBankAccount import code.model.dataAccess.AuthUser import code.ratelimiting.RateLimitingDI @@ -42,7 +72,7 @@ import net.liftweb.common.{Box, Failure, Full} import net.liftweb.json.Formats import net.liftweb.json.JsonAST.{JArray, JObject, JValue} import net.liftweb.json.JsonDSL._ -import net.liftweb.json.{compactRender, parse} +import net.liftweb.json.{compactRender, parse, prettyRender} import org.apache.commons.lang3.StringUtils import org.http4s._ import org.http4s.dsl.io._ @@ -69,7 +99,7 @@ object Http4s400 { // ─── getMapperDatabaseInfo ──────────────────────────────────────────────── - val getMapperDatabaseInfo: HttpRoutes[IO] = HttpRoutes.of[IO] { + lazy val getMapperDatabaseInfo: HttpRoutes[IO] = HttpRoutes.of[IO] { case req @ GET -> `prefixPath` / "database" / "info" => EndpointHelpers.withUser(req) { (user, cc) => for { @@ -92,7 +122,7 @@ object Http4s400 { // ─── getLogoutLink ──────────────────────────────────────────────────────── - val getLogoutLink: HttpRoutes[IO] = HttpRoutes.of[IO] { + lazy val getLogoutLink: HttpRoutes[IO] = HttpRoutes.of[IO] { case req @ GET -> `prefixPath` / "users" / "current" / "logout-link" => EndpointHelpers.withUser(req) { (_, _) => Future { @@ -117,7 +147,7 @@ object Http4s400 { // ─── getBanks ───────────────────────────────────────────────────────────── // v4.0.0 overrides v3.x getBanks — v4 uses createBanksJson which adds attributes. - val getBanks: HttpRoutes[IO] = HttpRoutes.of[IO] { + lazy val getBanks: HttpRoutes[IO] = HttpRoutes.of[IO] { case req @ GET -> `prefixPath` / "banks" => EndpointHelpers.executeAndRespond(req) { cc => for { @@ -140,7 +170,7 @@ object Http4s400 { // ─── getBank ────────────────────────────────────────────────────────────── // v4.0.0 overrides v3.x getBank — v4 includes bank attributes. - val getBank: HttpRoutes[IO] = HttpRoutes.of[IO] { + lazy val getBank: HttpRoutes[IO] = HttpRoutes.of[IO] { case req @ GET -> `prefixPath` / "banks" / _ => EndpointHelpers.withBank(req) { (bank, cc) => for { @@ -161,7 +191,7 @@ object Http4s400 { // ─── ibanChecker (POST → 200) ───────────────────────────────────────────── - val ibanChecker: HttpRoutes[IO] = HttpRoutes.of[IO] { + lazy val ibanChecker: HttpRoutes[IO] = HttpRoutes.of[IO] { case req @ POST -> `prefixPath` / "account" / "check" / "scheme" / "iban" => EndpointHelpers.executeFutureWithBody[IbanAddress, Any](req) { (ibanJson, cc) => for { @@ -184,7 +214,7 @@ object Http4s400 { // v4.0.0 overrides v3.1.0 — v4 takes additional api_version / api_name / bank_id fields // in the request body for finer-grained rate limiting. - val callsLimit: HttpRoutes[IO] = HttpRoutes.of[IO] { + lazy val callsLimit: HttpRoutes[IO] = HttpRoutes.of[IO] { case req @ PUT -> `prefixPath` / "management" / "consumers" / consumerIdStr / "consumer" / "call-limits" => EndpointHelpers.withUserAndBody[CallLimitPostJsonV400, Any](req) { (user, postJson, cc) => for { @@ -226,7 +256,7 @@ object Http4s400 { // Must live in Http4s400's own routes so the bridge cascade can't hijack POST /banks // down to Http4s220 (which has its own v2.2.0 createBank — different behavior). - val createBank: HttpRoutes[IO] = HttpRoutes.of[IO] { + lazy val createBank: HttpRoutes[IO] = HttpRoutes.of[IO] { case req @ POST -> `prefixPath` / "banks" => EndpointHelpers.executeFutureCreated(req) { implicit val cc: CallContext = req.callContext @@ -292,7 +322,7 @@ object Http4s400 { // ─── root (GET) — v4 override of v3.1.0's /root ────────────────────────── - val root: HttpRoutes[IO] = HttpRoutes.of[IO] { + lazy val root: HttpRoutes[IO] = HttpRoutes.of[IO] { case req @ GET -> `prefixPath` => EndpointHelpers.executeAndRespond(req) { _ => Future.successful(JSONFactory400.getApiInfoJSON( @@ -319,7 +349,7 @@ object Http4s400 { // ─── getAtms (GET) — v4 override; conditional auth via getAtmsIsPublic ─── - val getAtms: HttpRoutes[IO] = HttpRoutes.of[IO] { + lazy val getAtms: HttpRoutes[IO] = HttpRoutes.of[IO] { case req @ GET -> `prefixPath` / "banks" / _ / "atms" => EndpointHelpers.withBank(req) { (bank, cc) => val limit = req.uri.query.params.get("limit").map(Full(_)).getOrElse(net.liftweb.common.Empty) @@ -357,7 +387,7 @@ object Http4s400 { // ─── getAtm (GET) — v4 override; conditional auth ──────────────────────── - val getAtm: HttpRoutes[IO] = HttpRoutes.of[IO] { + lazy val getAtm: HttpRoutes[IO] = HttpRoutes.of[IO] { case req @ GET -> `prefixPath` / "banks" / _ / "atms" / atmIdStr => EndpointHelpers.withBank(req) { (bank, cc) => for { @@ -380,7 +410,7 @@ object Http4s400 { // ─── getProducts (GET) — v4 override; conditional auth ─────────────────── - val getProducts: HttpRoutes[IO] = HttpRoutes.of[IO] { + lazy val getProducts: HttpRoutes[IO] = HttpRoutes.of[IO] { case req @ GET -> `prefixPath` / "banks" / bankIdStr / "products" => EndpointHelpers.executeAndRespond(req) { cc => val params = req.uri.query.multiParams.toList.flatMap { @@ -407,7 +437,7 @@ object Http4s400 { // ─── getProduct (GET) — v4 override; loads attributes + fees ───────────── - val getProduct: HttpRoutes[IO] = HttpRoutes.of[IO] { + lazy val getProduct: HttpRoutes[IO] = HttpRoutes.of[IO] { case req @ GET -> `prefixPath` / "banks" / bankIdStr / "products" / productCodeStr => EndpointHelpers.executeAndRespond(req) { cc => for { @@ -434,7 +464,7 @@ object Http4s400 { // ─── createAtm (POST → 201) — v4 override ───────────────────────────────── - val createAtm: HttpRoutes[IO] = HttpRoutes.of[IO] { + lazy val createAtm: HttpRoutes[IO] = HttpRoutes.of[IO] { case req @ POST -> `prefixPath` / "banks" / _ / "atms" => EndpointHelpers.executeFutureCreated(req) { implicit val cc: CallContext = req.callContext @@ -475,7 +505,7 @@ object Http4s400 { // ─── createProduct (PUT → 201) — v4 override ────────────────────────────── - val createProduct: HttpRoutes[IO] = HttpRoutes.of[IO] { + lazy val createProduct: HttpRoutes[IO] = HttpRoutes.of[IO] { case req @ PUT -> `prefixPath` / "banks" / bankIdStr / "products" / productCodeStr => EndpointHelpers.executeFutureCreated(req) { implicit val cc: CallContext = req.callContext @@ -528,7 +558,7 @@ object Http4s400 { // ─── createProductAttribute (POST → 201) — v4 override ──────────────────── - val createProductAttribute: HttpRoutes[IO] = HttpRoutes.of[IO] { + lazy val createProductAttribute: HttpRoutes[IO] = HttpRoutes.of[IO] { case req @ POST -> `prefixPath` / "banks" / bankIdStr / "products" / productCodeStr / "attribute" => EndpointHelpers.withUserAndBodyCreated[ProductAttributeJsonV400, Any](req) { (user, postedData, cc) => for { @@ -561,7 +591,7 @@ object Http4s400 { // ─── updateProductAttribute (PUT → 200) — v4 override ───────────────────── - val updateProductAttribute: HttpRoutes[IO] = HttpRoutes.of[IO] { + lazy val updateProductAttribute: HttpRoutes[IO] = HttpRoutes.of[IO] { case req @ PUT -> `prefixPath` / "banks" / bankIdStr / "products" / productCodeStr / "attributes" / productAttributeIdStr => EndpointHelpers.withUserAndBody[ProductAttributeJsonV400, Any](req) { (user, postedData, cc) => for { @@ -594,7 +624,7 @@ object Http4s400 { // ─── getEntitlements (GET /users/USER_ID/entitlements) — v4 override ──── - val getEntitlements: HttpRoutes[IO] = HttpRoutes.of[IO] { + lazy val getEntitlements: HttpRoutes[IO] = HttpRoutes.of[IO] { case req @ GET -> `prefixPath` / "users" / userIdStr / "entitlements" => EndpointHelpers.withUser(req) { (_, cc) => for { @@ -626,7 +656,7 @@ object Http4s400 { // ─── getUserByUserId (GET /users/user_id/USER_ID) — v4 override ───────── - val getUserByUserId: HttpRoutes[IO] = HttpRoutes.of[IO] { + lazy val getUserByUserId: HttpRoutes[IO] = HttpRoutes.of[IO] { case req @ GET -> `prefixPath` / "users" / "user_id" / userIdStr => EndpointHelpers.withUser(req) { (_, cc) => for { @@ -662,7 +692,7 @@ object Http4s400 { // ─── getUserByUsername (GET /users/username/USERNAME) — v4 override ───── - val getUserByUsername: HttpRoutes[IO] = HttpRoutes.of[IO] { + lazy val getUserByUsername: HttpRoutes[IO] = HttpRoutes.of[IO] { case req @ GET -> `prefixPath` / "users" / "username" / username => EndpointHelpers.withUser(req) { (_, cc) => for { @@ -694,7 +724,7 @@ object Http4s400 { // ─── getUsersByEmail (GET /users/email/EMAIL/terminator) — v4 override ── - val getUsersByEmail: HttpRoutes[IO] = HttpRoutes.of[IO] { + lazy val getUsersByEmail: HttpRoutes[IO] = HttpRoutes.of[IO] { case req @ GET -> `prefixPath` / "users" / "email" / email / "terminator" => EndpointHelpers.withUser(req) { (_, _) => for { @@ -719,7 +749,7 @@ object Http4s400 { // ─── getUsers (GET /users) — v4 override ───────────────────────────────── - val getUsers: HttpRoutes[IO] = HttpRoutes.of[IO] { + lazy val getUsers: HttpRoutes[IO] = HttpRoutes.of[IO] { case req @ GET -> `prefixPath` / "users" => EndpointHelpers.withUser(req) { (_, cc) => val httpParams = req.headers.headers.toList.map(h => @@ -751,7 +781,7 @@ object Http4s400 { // ─── getCustomersByAttributes (GET /banks/BANK_ID/customers) — v4 override - val getCustomersByAttributes: HttpRoutes[IO] = HttpRoutes.of[IO] { + lazy val getCustomersByAttributes: HttpRoutes[IO] = HttpRoutes.of[IO] { case req @ GET -> `prefixPath` / "banks" / _ / "customers" => EndpointHelpers.withUserAndBank(req) { (_, bank, cc) => val params = req.uri.query.multiParams.map { case (k, vs) => k -> vs.toList } @@ -785,7 +815,7 @@ object Http4s400 { // ─── createCustomer (POST /banks/BANK_ID/customers → 201) — v4 override ── - val createCustomer: HttpRoutes[IO] = HttpRoutes.of[IO] { + lazy val createCustomer: HttpRoutes[IO] = HttpRoutes.of[IO] { case req @ POST -> `prefixPath` / "banks" / _ / "customers" => EndpointHelpers.withUserAndBankAndBodyCreated[code.api.v3_1_0.PostCustomerJsonV310, Any](req) { (_, bank, postedData, cc) => for { @@ -828,7 +858,7 @@ object Http4s400 { // ─── getBankAccountsBalancesForCurrentUser (GET /banks/BANK_ID/balances) — v4 - val getBankAccountsBalancesForCurrentUser: HttpRoutes[IO] = HttpRoutes.of[IO] { + lazy val getBankAccountsBalancesForCurrentUser: HttpRoutes[IO] = HttpRoutes.of[IO] { case req @ GET -> `prefixPath` / "banks" / _ / "balances" => EndpointHelpers.withUserAndBank(req) { (user, bank, cc) => for { @@ -850,7 +880,7 @@ object Http4s400 { // ─── getCoreAccountById (GET /my/banks/BANK_ID/accounts/ACCOUNT_ID/account) - val getCoreAccountById: HttpRoutes[IO] = HttpRoutes.of[IO] { + lazy val getCoreAccountById: HttpRoutes[IO] = HttpRoutes.of[IO] { case req @ GET -> `prefixPath` / "my" / "banks" / bankIdStr / "accounts" / accountIdStr / "account" => EndpointHelpers.withUser(req) { (user, cc) => for { @@ -882,7 +912,7 @@ object Http4s400 { // ─── getPrivateAccountByIdFull (GET /banks/BANK_ID/.../VIEW_ID/account) ── - val getPrivateAccountByIdFull: HttpRoutes[IO] = HttpRoutes.of[IO] { + lazy val getPrivateAccountByIdFull: HttpRoutes[IO] = HttpRoutes.of[IO] { case req @ GET -> `prefixPath` / "banks" / bankIdStr / "accounts" / _ / _ / "account" => EndpointHelpers.withView(req) { (user, account, view, cc) => for { @@ -913,7 +943,7 @@ object Http4s400 { // ─── getPrivateAccountsAtOneBank (GET /banks/BANK_ID/accounts) — v4 override - val getPrivateAccountsAtOneBank: HttpRoutes[IO] = HttpRoutes.of[IO] { + lazy val getPrivateAccountsAtOneBank: HttpRoutes[IO] = HttpRoutes.of[IO] { case req @ GET -> `prefixPath` / "banks" / _ / "accounts" => EndpointHelpers.withUserAndBank(req) { (user, bank, cc) => val params: Map[String, String] = req.uri.query.params @@ -953,7 +983,7 @@ object Http4s400 { // ─── createUserCustomerLinks (POST → 201) — v4 override ───────────────── - val createUserCustomerLinks: HttpRoutes[IO] = HttpRoutes.of[IO] { + lazy val createUserCustomerLinks: HttpRoutes[IO] = HttpRoutes.of[IO] { case req @ POST -> `prefixPath` / "banks" / bankIdStr / "user_customer_links" => EndpointHelpers.withUserAndBankAndBodyCreated[code.api.v2_0_0.CreateUserCustomerLinkJson, Any](req) { (_, bank, postedData, cc) => for { @@ -1003,7 +1033,7 @@ object Http4s400 { // ─── getSystemDynamicEntities ───────────────────────────────────────────── - val getSystemDynamicEntities: HttpRoutes[IO] = HttpRoutes.of[IO] { + lazy val getSystemDynamicEntities: HttpRoutes[IO] = HttpRoutes.of[IO] { case req @ GET -> `prefixPath` / "management" / "system-dynamic-entities" => EndpointHelpers.withUser(req) { (user, cc) => for { @@ -1032,7 +1062,7 @@ object Http4s400 { // ─── getBankLevelDynamicEntities ────────────────────────────────────────── - val getBankLevelDynamicEntities: HttpRoutes[IO] = HttpRoutes.of[IO] { + lazy val getBankLevelDynamicEntities: HttpRoutes[IO] = HttpRoutes.of[IO] { case req @ GET -> `prefixPath` / "management" / "banks" / _ / "dynamic-entities" => EndpointHelpers.withUserAndBank(req) { (user, bank, cc) => for { @@ -1062,7 +1092,7 @@ object Http4s400 { // ─── getMyDynamicEntities ───────────────────────────────────────────────── - val getMyDynamicEntities: HttpRoutes[IO] = HttpRoutes.of[IO] { + lazy val getMyDynamicEntities: HttpRoutes[IO] = HttpRoutes.of[IO] { case req @ GET -> `prefixPath` / "my" / "dynamic-entities" => EndpointHelpers.withUser(req) { (user, cc) => for { @@ -1168,7 +1198,7 @@ object Http4s400 { // ─── createSystemDynamicEntity ──────────────────────────────────────────── - val createSystemDynamicEntity: HttpRoutes[IO] = HttpRoutes.of[IO] { + lazy val createSystemDynamicEntity: HttpRoutes[IO] = HttpRoutes.of[IO] { case req @ POST -> `prefixPath` / "management" / "system-dynamic-entities" => EndpointHelpers.executeFutureCreated(req) { implicit val cc: CallContext = req.callContext @@ -1203,7 +1233,7 @@ object Http4s400 { // ─── createBankLevelDynamicEntity ───────────────────────────────────────── - val createBankLevelDynamicEntity: HttpRoutes[IO] = HttpRoutes.of[IO] { + lazy val createBankLevelDynamicEntity: HttpRoutes[IO] = HttpRoutes.of[IO] { case req @ POST -> `prefixPath` / "management" / "banks" / _ / "dynamic-entities" => EndpointHelpers.executeFutureCreated(req) { implicit val cc: CallContext = req.callContext @@ -1239,7 +1269,7 @@ object Http4s400 { // ─── updateSystemDynamicEntity ──────────────────────────────────────────── - val updateSystemDynamicEntity: HttpRoutes[IO] = HttpRoutes.of[IO] { + lazy val updateSystemDynamicEntity: HttpRoutes[IO] = HttpRoutes.of[IO] { case req @ PUT -> `prefixPath` / "management" / "system-dynamic-entities" / dynamicEntityId => EndpointHelpers.executeAndRespond(req) { cc => val rawBody = cc.httpBody.getOrElse("") @@ -1268,7 +1298,7 @@ object Http4s400 { // ─── updateBankLevelDynamicEntity ───────────────────────────────────────── - val updateBankLevelDynamicEntity: HttpRoutes[IO] = HttpRoutes.of[IO] { + lazy val updateBankLevelDynamicEntity: HttpRoutes[IO] = HttpRoutes.of[IO] { case req @ PUT -> `prefixPath` / "management" / "banks" / bankIdStr / "dynamic-entities" / dynamicEntityId => EndpointHelpers.withUserAndBank(req) { (_, bank, cc) => val rawBody = cc.httpBody.getOrElse("") @@ -1297,7 +1327,7 @@ object Http4s400 { // ─── deleteSystemDynamicEntity (200) ───────────────────────────────────── - val deleteSystemDynamicEntity: HttpRoutes[IO] = HttpRoutes.of[IO] { + lazy val deleteSystemDynamicEntity: HttpRoutes[IO] = HttpRoutes.of[IO] { case req @ DELETE -> `prefixPath` / "management" / "system-dynamic-entities" / dynamicEntityId => EndpointHelpers.withUser(req) { (_, cc) => deleteDynamicEntityImpl(None, dynamicEntityId, cc).map(Full(_)) @@ -1319,7 +1349,7 @@ object Http4s400 { // ─── deleteBankLevelDynamicEntity (200) ────────────────────────────────── - val deleteBankLevelDynamicEntity: HttpRoutes[IO] = HttpRoutes.of[IO] { + lazy val deleteBankLevelDynamicEntity: HttpRoutes[IO] = HttpRoutes.of[IO] { case req @ DELETE -> `prefixPath` / "management" / "banks" / _ / "dynamic-entities" / dynamicEntityId => EndpointHelpers.withUserAndBank(req) { (_, bank, cc) => deleteDynamicEntityImpl(Some(bank.bankId.value), dynamicEntityId, cc).map(Full(_)) @@ -1341,7 +1371,7 @@ object Http4s400 { // ─── updateMyDynamicEntity ──────────────────────────────────────────────── - val updateMyDynamicEntity: HttpRoutes[IO] = HttpRoutes.of[IO] { + lazy val updateMyDynamicEntity: HttpRoutes[IO] = HttpRoutes.of[IO] { case req @ PUT -> `prefixPath` / "my" / "dynamic-entities" / dynamicEntityId => EndpointHelpers.withUser(req) { (user, cc) => val rawBody = cc.httpBody.getOrElse("") @@ -1387,7 +1417,7 @@ object Http4s400 { // ─── deleteMyDynamicEntity (200) ───────────────────────────────────────── - val deleteMyDynamicEntity: HttpRoutes[IO] = HttpRoutes.of[IO] { + lazy val deleteMyDynamicEntity: HttpRoutes[IO] = HttpRoutes.of[IO] { case req @ DELETE -> `prefixPath` / "my" / "dynamic-entities" / dynamicEntityId => EndpointHelpers.withUser(req) { (user, cc) => for { @@ -1494,7 +1524,7 @@ object Http4s400 { // ─── createDynamicEndpoint (POST → 201) ────────────────────────────────── - val createDynamicEndpoint: HttpRoutes[IO] = HttpRoutes.of[IO] { + lazy val createDynamicEndpoint: HttpRoutes[IO] = HttpRoutes.of[IO] { case req @ POST -> `prefixPath` / "management" / "dynamic-endpoints" => EndpointHelpers.executeFutureCreated(req) { implicit val cc: CallContext = req.callContext @@ -1524,7 +1554,7 @@ object Http4s400 { // ─── createBankLevelDynamicEndpoint (POST → 201) ───────────────────────── - val createBankLevelDynamicEndpoint: HttpRoutes[IO] = HttpRoutes.of[IO] { + lazy val createBankLevelDynamicEndpoint: HttpRoutes[IO] = HttpRoutes.of[IO] { case req @ POST -> `prefixPath` / "management" / "banks" / _ / "dynamic-endpoints" => EndpointHelpers.executeFutureCreated(req) { implicit val cc: CallContext = req.callContext @@ -1555,7 +1585,7 @@ object Http4s400 { // ─── updateDynamicEndpointHost (PUT → 201) ─────────────────────────────── - val updateDynamicEndpointHost: HttpRoutes[IO] = HttpRoutes.of[IO] { + lazy val updateDynamicEndpointHost: HttpRoutes[IO] = HttpRoutes.of[IO] { case req @ PUT -> `prefixPath` / "management" / "dynamic-endpoints" / dynamicEndpointId / "host" => EndpointHelpers.executeFutureCreated(req) { implicit val cc: CallContext = req.callContext @@ -1582,7 +1612,7 @@ object Http4s400 { // ─── updateBankLevelDynamicEndpointHost (PUT → 201) ────────────────────── - val updateBankLevelDynamicEndpointHost: HttpRoutes[IO] = HttpRoutes.of[IO] { + lazy val updateBankLevelDynamicEndpointHost: HttpRoutes[IO] = HttpRoutes.of[IO] { case req @ PUT -> `prefixPath` / "management" / "banks" / _ / "dynamic-endpoints" / dynamicEndpointId / "host" => EndpointHelpers.executeFutureCreated(req) { implicit val cc: CallContext = req.callContext @@ -1609,7 +1639,7 @@ object Http4s400 { // ─── getDynamicEndpoint (GET → 200) ────────────────────────────────────── - val getDynamicEndpoint: HttpRoutes[IO] = HttpRoutes.of[IO] { + lazy val getDynamicEndpoint: HttpRoutes[IO] = HttpRoutes.of[IO] { case req @ GET -> `prefixPath` / "management" / "dynamic-endpoints" / dynamicEndpointId => EndpointHelpers.executeAndRespond(req) { cc => getDynamicEndpointImpl(None, dynamicEndpointId, cc) @@ -1630,7 +1660,7 @@ object Http4s400 { // ─── getDynamicEndpoints (GET → 200) ───────────────────────────────────── - val getDynamicEndpoints: HttpRoutes[IO] = HttpRoutes.of[IO] { + lazy val getDynamicEndpoints: HttpRoutes[IO] = HttpRoutes.of[IO] { case req @ GET -> `prefixPath` / "management" / "dynamic-endpoints" => EndpointHelpers.executeAndRespond(req) { cc => getDynamicEndpointsImpl(None, cc) @@ -1650,7 +1680,7 @@ object Http4s400 { // ─── getBankLevelDynamicEndpoint (GET → 200) ───────────────────────────── - val getBankLevelDynamicEndpoint: HttpRoutes[IO] = HttpRoutes.of[IO] { + lazy val getBankLevelDynamicEndpoint: HttpRoutes[IO] = HttpRoutes.of[IO] { case req @ GET -> `prefixPath` / "management" / "banks" / _ / "dynamic-endpoints" / dynamicEndpointId => EndpointHelpers.withUserAndBank(req) { (_, bank, cc) => getDynamicEndpointImpl(Some(bank.bankId.value), dynamicEndpointId, cc) @@ -1671,7 +1701,7 @@ object Http4s400 { // ─── getBankLevelDynamicEndpoints (GET → 200) ──────────────────────────── - val getBankLevelDynamicEndpoints: HttpRoutes[IO] = HttpRoutes.of[IO] { + lazy val getBankLevelDynamicEndpoints: HttpRoutes[IO] = HttpRoutes.of[IO] { case req @ GET -> `prefixPath` / "management" / "banks" / _ / "dynamic-endpoints" => EndpointHelpers.withUserAndBank(req) { (_, bank, cc) => getDynamicEndpointsImpl(Some(bank.bankId.value), cc) @@ -1692,7 +1722,7 @@ object Http4s400 { // ─── deleteDynamicEndpoint (DELETE → 204) ──────────────────────────────── - val deleteDynamicEndpoint: HttpRoutes[IO] = HttpRoutes.of[IO] { + lazy val deleteDynamicEndpoint: HttpRoutes[IO] = HttpRoutes.of[IO] { case req @ DELETE -> `prefixPath` / "management" / "dynamic-endpoints" / dynamicEndpointId => EndpointHelpers.withUserDelete(req) { (_, cc) => NewStyle.function.deleteDynamicEndpoint(None, dynamicEndpointId, Some(cc)) @@ -1712,7 +1742,7 @@ object Http4s400 { // ─── deleteBankLevelDynamicEndpoint (DELETE → 204) ─────────────────────── - val deleteBankLevelDynamicEndpoint: HttpRoutes[IO] = HttpRoutes.of[IO] { + lazy val deleteBankLevelDynamicEndpoint: HttpRoutes[IO] = HttpRoutes.of[IO] { case req @ DELETE -> `prefixPath` / "management" / "banks" / _ / "dynamic-endpoints" / dynamicEndpointId => EndpointHelpers.withUserAndBankDelete(req) { (_, bank, cc) => NewStyle.function.deleteDynamicEndpoint(Some(bank.bankId.value), dynamicEndpointId, Some(cc)) @@ -1733,7 +1763,7 @@ object Http4s400 { // ─── getMyDynamicEndpoints (GET → 200) ─────────────────────────────────── - val getMyDynamicEndpoints: HttpRoutes[IO] = HttpRoutes.of[IO] { + lazy val getMyDynamicEndpoints: HttpRoutes[IO] = HttpRoutes.of[IO] { case req @ GET -> `prefixPath` / "my" / "dynamic-endpoints" => EndpointHelpers.withUser(req) { (user, cc) => for { @@ -1761,7 +1791,7 @@ object Http4s400 { // ─── deleteMyDynamicEndpoint (DELETE → 204) ────────────────────────────── - val deleteMyDynamicEndpoint: HttpRoutes[IO] = HttpRoutes.of[IO] { + lazy val deleteMyDynamicEndpoint: HttpRoutes[IO] = HttpRoutes.of[IO] { case req @ DELETE -> `prefixPath` / "my" / "dynamic-endpoints" / dynamicEndpointId => EndpointHelpers.withUserDelete(req) { (user, cc) => for { @@ -1786,7 +1816,7 @@ object Http4s400 { // ─── getProductAttribute (v4 override of Http4s310 — Lift declared role mismatch fixed) ─ - val getProductAttribute: HttpRoutes[IO] = HttpRoutes.of[IO] { + lazy val getProductAttribute: HttpRoutes[IO] = HttpRoutes.of[IO] { case req @ GET -> `prefixPath` / "banks" / bankIdStr / "products" / _ / "attributes" / productAttributeIdStr => EndpointHelpers.withUser(req) { (user, cc) => for { @@ -1812,7 +1842,7 @@ object Http4s400 { // ─── getScopes (GET /consumers/CONSUMER_ID/scopes) — v4 override of Http4s300 ─ - val getScopes: HttpRoutes[IO] = HttpRoutes.of[IO] { + lazy val getScopes: HttpRoutes[IO] = HttpRoutes.of[IO] { case req @ GET -> `prefixPath` / "consumers" / uuidOfConsumer / "scopes" => EndpointHelpers.withUser(req) { (user, cc) => for { @@ -1846,7 +1876,7 @@ object Http4s400 { // ─── addScope (POST /consumers/CONSUMER_ID/scopes → 201) — v4 override ──── - val addScope: HttpRoutes[IO] = HttpRoutes.of[IO] { + lazy val addScope: HttpRoutes[IO] = HttpRoutes.of[IO] { case req @ POST -> `prefixPath` / "consumers" / consumerId / "scopes" => EndpointHelpers.withUserAndBodyCreated[code.api.v3_0_0.CreateScopeJson, Any](req) { (user, postedData, cc) => for { @@ -1894,7 +1924,7 @@ object Http4s400 { // ─── getConsents (GET /banks/BANK_ID/my/consents) — v4 override of Http4s310 ─ - val getConsents: HttpRoutes[IO] = HttpRoutes.of[IO] { + lazy val getConsents: HttpRoutes[IO] = HttpRoutes.of[IO] { case req @ GET -> `prefixPath` / "banks" / _ / "my" / "consents" => EndpointHelpers.withUserAndBank(req) { (user, bank, _) => val params = req.uri.query.params @@ -1934,7 +1964,7 @@ object Http4s400 { // ─── updateAccountLabel (POST /banks/BANK_ID/accounts/ACCOUNT_ID → 200) — v4 override of Http4s121 ─ - val updateAccountLabel: HttpRoutes[IO] = HttpRoutes.of[IO] { + lazy val updateAccountLabel: HttpRoutes[IO] = HttpRoutes.of[IO] { case req @ POST -> `prefixPath` / "banks" / bankIdStr / "accounts" / accountIdStr => EndpointHelpers.withUserAndBody[UpdateAccountJsonV400, Any](req) { (user, postedData, cc) => for { @@ -1975,7 +2005,7 @@ object Http4s400 { // ─── getExplicitCounterpartiesForAccount (GET .../counterparties) — v4 override ─ - val getExplicitCounterpartiesForAccount: HttpRoutes[IO] = HttpRoutes.of[IO] { + lazy val getExplicitCounterpartiesForAccount: HttpRoutes[IO] = HttpRoutes.of[IO] { case req @ GET -> `prefixPath` / "banks" / _ / "accounts" / _ / _ / "counterparties" => EndpointHelpers.withView(req) { (user, account, view, cc) => for { @@ -2011,7 +2041,7 @@ object Http4s400 { // ─── getExplicitCounterpartyById (GET .../counterparties/COUNTERPARTY_ID) — v4 override ─ - val getExplicitCounterpartyById: HttpRoutes[IO] = HttpRoutes.of[IO] { + lazy val getExplicitCounterpartyById: HttpRoutes[IO] = HttpRoutes.of[IO] { case req @ GET -> `prefixPath` / "banks" / _ / "accounts" / _ / _ / "counterparties" / counterpartyIdStr => EndpointHelpers.withView(req) { (_, account, view, cc) => for { @@ -2042,7 +2072,7 @@ object Http4s400 { // ─── createExplicitCounterparty (POST .../counterparties → 201) — v4 override ─ - val createExplicitCounterparty: HttpRoutes[IO] = HttpRoutes.of[IO] { + lazy val createExplicitCounterparty: HttpRoutes[IO] = HttpRoutes.of[IO] { case req @ POST -> `prefixPath` / "banks" / bankIdStr / "accounts" / accountIdStr / _ / "counterparties" => EndpointHelpers.withViewCreated(req) { (user, account, view, cc) => val bodyStr = cc.httpBody.getOrElse("") @@ -2139,7 +2169,7 @@ object Http4s400 { // ModeratedFirehoseAccountsJsonV400 (with `accounts`/`product_code` etc.) instead // of v3.0.0's ModeratedCoreAccountsJsonV300 shape that FirehoseTest can't parse. - val getFirehoseAccountsAtOneBank: HttpRoutes[IO] = HttpRoutes.of[IO] { + lazy val getFirehoseAccountsAtOneBank: HttpRoutes[IO] = HttpRoutes.of[IO] { case req @ GET -> `prefixPath` / "banks" / bankIdStr / "firehose" / "accounts" / "views" / viewIdStr => EndpointHelpers.withUser(req) { (user, cc) => val roles = ApiRoleObj.canUseAccountFirehose :: canUseAccountFirehoseAtAnyBank :: Nil @@ -2216,7 +2246,7 @@ object Http4s400 { // first synchronous read of `SS.user` captures the cc.user, then the Future chain // runs normally on any thread. - val createTransactionRequest: HttpRoutes[IO] = HttpRoutes.of[IO] { + lazy val createTransactionRequest: HttpRoutes[IO] = HttpRoutes.of[IO] { // GRANT_VIEW_ID in the ResourceDoc URL → middleware skips view validation. // Lift's v4 endpoint does no view-access check upfront; it lets // `checkAuthorisationToCreateTransactionRequest` inside the connector decide @@ -2285,6 +2315,165 @@ object Http4s400 { List(apiTagTransactionRequest, apiTagPSD2PIS, apiTagPsd2), None, http4sPartialFunction = Some(createTransactionRequest)) + // ─── per-type transaction-request alias ResourceDocs ─────────────────────── + // These 9 Lift `lazy val`s (createTransactionRequestAccountOtp/Sepa/Counterparty + // /Refund/FreeForm/Simple/AgentCashWithDrawal/Card and the previously-registered + // createTransactionRequestAccount) all share the same body — call + // `LocalMappedConnectorInternal.createTransactionRequest`. The already-migrated + // `createTransactionRequest` http4s route uses a wildcard segment, so adding a + // ResourceDoc per type (with the literal type segment — recognised in + // `literalAllCapsSegments`) is enough; no new `lazy val` needed. + private def initBatch9AliasResourceDocs(): Unit = { + staticResourceDocs += ResourceDoc( + null, implementedInApiVersion, "createTransactionRequestAccountOtp", "POST", + "/banks/BANK_ID/accounts/ACCOUNT_ID/GRANT_VIEW_ID/transaction-request-types/ACCOUNT_OTP/transaction-requests", + "Create Transaction Request (ACCOUNT_OTP)", + s"""Create Transaction Request (ACCOUNT_OTP). + | + |${userAuthenticationMessage(true)}""", + transactionRequestBodyJsonV200, transactionRequestWithChargeJSON400, + List($AuthenticatedUserIsRequired, InvalidJsonFormat, InvalidNumber, NotPositiveAmount, + InvalidTransactionRequestType, InvalidISOCurrencyCode, + InsufficientAuthorisationToCreateTransactionRequest, + InvalidAccountIdFormat, InvalidBankIdFormat, TransactionDisabled, UnknownError), + List(apiTagTransactionRequest, apiTagPSD2PIS, apiTagPsd2), None, + http4sPartialFunction = Some(createTransactionRequest)) + + staticResourceDocs += ResourceDoc( + null, implementedInApiVersion, "createTransactionRequestSepa", "POST", + "/banks/BANK_ID/accounts/ACCOUNT_ID/GRANT_VIEW_ID/transaction-request-types/SEPA/transaction-requests", + "Create Transaction Request (SEPA)", + s"""Create Transaction Request (SEPA). + | + |${userAuthenticationMessage(true)}""", + transactionRequestBodySEPAJsonV400, transactionRequestWithChargeJSON400, + List($AuthenticatedUserIsRequired, InvalidJsonFormat, InvalidNumber, NotPositiveAmount, + InvalidTransactionRequestType, InvalidISOCurrencyCode, + InsufficientAuthorisationToCreateTransactionRequest, + InvalidAccountIdFormat, InvalidBankIdFormat, TransactionDisabled, UnknownError), + List(apiTagTransactionRequest, apiTagPSD2PIS, apiTagPsd2), None, + http4sPartialFunction = Some(createTransactionRequest)) + + staticResourceDocs += ResourceDoc( + null, implementedInApiVersion, "createTransactionRequestCounterparty", "POST", + "/banks/BANK_ID/accounts/ACCOUNT_ID/GRANT_VIEW_ID/transaction-request-types/COUNTERPARTY/transaction-requests", + "Create Transaction Request (COUNTERPARTY)", + s"""Create Transaction Request (COUNTERPARTY). + | + |${userAuthenticationMessage(true)}""", + transactionRequestBodyCounterpartyJSON, transactionRequestWithChargeJSON400, + List($AuthenticatedUserIsRequired, InvalidJsonFormat, InvalidNumber, NotPositiveAmount, + InvalidTransactionRequestType, InvalidISOCurrencyCode, + InsufficientAuthorisationToCreateTransactionRequest, + InvalidAccountIdFormat, InvalidBankIdFormat, TransactionDisabled, UnknownError), + List(apiTagTransactionRequest, apiTagPSD2PIS, apiTagPsd2), None, + http4sPartialFunction = Some(createTransactionRequest)) + + staticResourceDocs += ResourceDoc( + null, implementedInApiVersion, "createTransactionRequestRefund", "POST", + "/banks/BANK_ID/accounts/ACCOUNT_ID/GRANT_VIEW_ID/transaction-request-types/REFUND/transaction-requests", + "Create Transaction Request (REFUND)", + s"""Create Transaction Request (REFUND). + | + |${userAuthenticationMessage(true)}""", + transactionRequestBodyRefundJsonV400, transactionRequestWithChargeJSON400, + List($AuthenticatedUserIsRequired, InvalidJsonFormat, InvalidNumber, NotPositiveAmount, + InvalidTransactionRequestType, InvalidISOCurrencyCode, + InsufficientAuthorisationToCreateTransactionRequest, + InvalidAccountIdFormat, InvalidBankIdFormat, TransactionDisabled, UnknownError), + List(apiTagTransactionRequest, apiTagPSD2PIS, apiTagPsd2), None, + http4sPartialFunction = Some(createTransactionRequest)) + + staticResourceDocs += ResourceDoc( + null, implementedInApiVersion, "createTransactionRequestFreeForm", "POST", + "/banks/BANK_ID/accounts/ACCOUNT_ID/GRANT_VIEW_ID/transaction-request-types/FREE_FORM/transaction-requests", + "Create Transaction Request (FREE_FORM)", + s"""Create Transaction Request (FREE_FORM). + | + |${userAuthenticationMessage(true)}""", + transactionRequestBodyFreeFormJSON, transactionRequestWithChargeJSON400, + List($AuthenticatedUserIsRequired, InvalidJsonFormat, InvalidNumber, NotPositiveAmount, + InvalidTransactionRequestType, InvalidISOCurrencyCode, + InsufficientAuthorisationToCreateTransactionRequest, + InvalidAccountIdFormat, InvalidBankIdFormat, TransactionDisabled, UnknownError), + List(apiTagTransactionRequest, apiTagPSD2PIS), + Some(List(canCreateAnyTransactionRequest)), + http4sPartialFunction = Some(createTransactionRequest)) + + staticResourceDocs += ResourceDoc( + null, implementedInApiVersion, "createTransactionRequestSimple", "POST", + "/banks/BANK_ID/accounts/ACCOUNT_ID/GRANT_VIEW_ID/transaction-request-types/SIMPLE/transaction-requests", + "Create Transaction Request (SIMPLE)", + s"""Create Transaction Request (SIMPLE). + | + |${userAuthenticationMessage(true)}""", + transactionRequestBodySimpleJsonV400, transactionRequestWithChargeJSON400, + List($AuthenticatedUserIsRequired, InvalidJsonFormat, InvalidNumber, NotPositiveAmount, + InvalidTransactionRequestType, InvalidISOCurrencyCode, + InsufficientAuthorisationToCreateTransactionRequest, + InvalidAccountIdFormat, InvalidBankIdFormat, TransactionDisabled, UnknownError), + List(apiTagTransactionRequest, apiTagPSD2PIS, apiTagPsd2), None, + http4sPartialFunction = Some(createTransactionRequest)) + + staticResourceDocs += ResourceDoc( + null, implementedInApiVersion, "createTransactionRequestAgentCashWithDrawal", "POST", + "/banks/BANK_ID/accounts/ACCOUNT_ID/GRANT_VIEW_ID/transaction-request-types/AGENT_CASH_WITHDRAWAL/transaction-requests", + "Create Transaction Request (AGENT_CASH_WITHDRAWAL)", + s"""Create Transaction Request (AGENT_CASH_WITHDRAWAL). + | + |${userAuthenticationMessage(true)}""", + transactionRequestBodyAgentJsonV400, transactionRequestWithChargeJSON400, + List($AuthenticatedUserIsRequired, InvalidJsonFormat, InvalidNumber, NotPositiveAmount, + InvalidTransactionRequestType, InvalidISOCurrencyCode, + InsufficientAuthorisationToCreateTransactionRequest, + InvalidAccountIdFormat, InvalidBankIdFormat, TransactionDisabled, UnknownError), + List(apiTagTransactionRequest, apiTagPSD2PIS, apiTagPsd2), None, + http4sPartialFunction = Some(createTransactionRequest)) + } + initBatch9AliasResourceDocs() + + // createTransactionRequestCard uses a different URL pattern (no bank/account/view) + // and the Lift body calls the connector with empty BankId/AccountId. Add as its own + // route + ResourceDoc. The connector reads the user via SS.user, so prime SS with + // the user only; bank/account/view are connector-resolved from card details. + lazy val createTransactionRequestCard: HttpRoutes[IO] = HttpRoutes.of[IO] { + case req @ POST -> `prefixPath` / "transaction-request-types" / "CARD" / "transaction-requests" => + implicit val cc: CallContext = req.callContext + EndpointHelpers.executeFutureCreated(req) { + val bodyStr = cc.httpBody.getOrElse("") + for { + user <- Future { cc.user.openOrThrowException(AuthenticatedUserIsRequired) } + json <- NewStyle.function.tryons( + s"$InvalidJsonFormat Empty or invalid request body.", 400, Some(cc)) { + net.liftweb.json.parse(bodyStr) + } + transactionRequestType = TransactionRequestType("CARD") + innerResult <- APIUtil.SS.init(Full(user), + null.asInstanceOf[Bank], null.asInstanceOf[BankAccount], + null.asInstanceOf[View], Some(cc)) { + code.bankconnectors.LocalMappedConnectorInternal.createTransactionRequest( + BankId(""), AccountId(""), ViewId(Constant.SYSTEM_OWNER_VIEW_ID), + transactionRequestType, json) + } + } yield innerResult._1 + } + } + + staticResourceDocs += ResourceDoc( + null, implementedInApiVersion, "createTransactionRequestCard", "POST", + "/transaction-request-types/CARD/transaction-requests", + "Create Transaction Request (CARD)", + s"""Create Transaction Request (CARD). + | + |${userAuthenticationMessage(true)}""", + transactionRequestBodyCardJsonV400, transactionRequestWithChargeJSON400, + List($AuthenticatedUserIsRequired, InvalidJsonFormat, InvalidNumber, NotPositiveAmount, + InvalidTransactionRequestType, InvalidISOCurrencyCode, + InsufficientAuthorisationToCreateTransactionRequest, + InvalidAccountIdFormat, InvalidBankIdFormat, TransactionDisabled, UnknownError), + List(apiTagTransactionRequest, apiTagPSD2PIS, apiTagPsd2), None, + http4sPartialFunction = Some(createTransactionRequestCard)) + // ─── answerTransactionRequestChallenge (POST .../trans-requests/{id}/challenge → 202) ─ // // v4 needs its own handling for this endpoint because the v2.1.0 catch-all (one @@ -2301,7 +2490,7 @@ object Http4s400 { // the http4s layer so the bridge cascade can't intercept it. The difference is we // delegate the body to Lift unchanged. - val answerTransactionRequestChallenge: HttpRoutes[IO] = HttpRoutes.of[IO] { + lazy val answerTransactionRequestChallenge: HttpRoutes[IO] = HttpRoutes.of[IO] { case req @ POST -> `prefixPath` / "banks" / _ / "accounts" / _ / _ / "transaction-request-types" / _ / "transaction-requests" / _ / "challenge" => code.api.util.http4s.Http4sLiftWebBridge.dispatch(req) } @@ -2321,6 +2510,5563 @@ object Http4s400 { List(apiTagTransactionRequest, apiTagPSD2PIS, apiTagPsd2), None, http4sPartialFunction = Some(answerTransactionRequestChallenge)) + // ═══════════════════════════════════════════════════════════════════════════ + // Batch 1 — simple GETs (mostly mechanical) + // ═══════════════════════════════════════════════════════════════════════════ + + lazy val getCallContext: HttpRoutes[IO] = HttpRoutes.of[IO] { + case req @ GET -> `prefixPath` / "development" / "call_context" => + EndpointHelpers.withUser(req) { (_, cc) => + Future(cc) + } + } + + lazy val verifyRequestSignResponse: HttpRoutes[IO] = HttpRoutes.of[IO] { + case req @ GET -> `prefixPath` / "development" / "echo" / "jws-verified-request-jws-signed-response" => + EndpointHelpers.withUser(req) { (_, cc) => + Future(cc) + } + } + + lazy val getCurrentUserId: HttpRoutes[IO] = HttpRoutes.of[IO] { + case req @ GET -> `prefixPath` / "users" / "current" / "user_id" => + EndpointHelpers.withUser(req) { (user, _) => + Future(JSONFactory400.createUserIdInfoJson(user)) + } + } + + lazy val getScannedApiVersions: HttpRoutes[IO] = HttpRoutes.of[IO] { + case req @ GET -> `prefixPath` / "api" / "versions" => + EndpointHelpers.executeAndRespond(req) { _ => + Future { + val versions: List[ScannedApiVersion] = + ApiVersion.allScannedApiVersion.asScala.toList.filter { v => + v.urlPrefix.trim.nonEmpty && APIUtil.versionIsAllowed(v) + } + com.openbankproject.commons.model.ListResult("scanned_api_versions", versions) + } + } + } + + lazy val getMySpaces: HttpRoutes[IO] = HttpRoutes.of[IO] { + case req @ GET -> `prefixPath` / "my" / "spaces" => + EndpointHelpers.withUser(req) { (user, cc) => + for { + entitlements <- NewStyle.function.getEntitlementsByUserId(user.userId, Some(cc)) + } yield MySpaces( + entitlements + .filter(_.roleName == canReadDynamicResourceDocsAtOneBank.toString()) + .map(_.bankId) + ) + } + } + + lazy val getBankAttributes: HttpRoutes[IO] = HttpRoutes.of[IO] { + case req @ GET -> `prefixPath` / "banks" / _ / "attributes" => + EndpointHelpers.withBank(req) { (bank, cc) => + for { + (attributes, _) <- NewStyle.function.getBankAttributesByBank(bank.bankId, Some(cc)) + } yield JSONFactory400.createBankAttributesJson(attributes) + } + } + + lazy val getBankAttribute: HttpRoutes[IO] = HttpRoutes.of[IO] { + case req @ GET -> `prefixPath` / "banks" / _ / "attributes" / bankAttributeId => + EndpointHelpers.withBank(req) { (_, cc) => + for { + (attribute, _) <- NewStyle.function.getBankAttributeById(bankAttributeId, Some(cc)) + } yield JSONFactory400.createBankAttributeJson(attribute) + } + } + + lazy val getSystemLevelEndpointTags: HttpRoutes[IO] = HttpRoutes.of[IO] { + case req @ GET -> `prefixPath` / "management" / "endpoints" / operationId / "tags" => + EndpointHelpers.withUser(req) { (_, cc) => + for { + (endpointTags, _) <- NewStyle.function.getSystemLevelEndpointTags(operationId, Some(cc)) + } yield endpointTags.map(e => + SystemLevelEndpointTagResponseJson400( + e.endpointTagId.getOrElse(""), e.operationId, e.tagName)) + } + } + + lazy val getBankLevelEndpointTags: HttpRoutes[IO] = HttpRoutes.of[IO] { + case req @ GET -> `prefixPath` / "management" / "banks" / bankIdStr / "endpoints" / operationId / "tags" => + EndpointHelpers.withUserAndBank(req) { (_, _, cc) => + for { + (endpointTags, _) <- NewStyle.function.getBankLevelEndpointTags(bankIdStr, operationId, Some(cc)) + } yield endpointTags.map(e => + BankLevelEndpointTagResponseJson400( + e.bankId.getOrElse(""), e.endpointTagId.getOrElse(""), e.operationId, e.tagName)) + } + } + + private def getEndpointMappingsMethodHttp4s(bankId: Option[String], cc: CallContext): Future[com.openbankproject.commons.model.ListResult[List[JValue]]] = + for { + (endpointMappings, _) <- NewStyle.function.getEndpointMappings(bankId, Some(cc)) + } yield { + val listCommons: List[EndpointMappingCommons] = endpointMappings + com.openbankproject.commons.model.ListResult("endpoint-mappings", listCommons.map(_.toJson)) + } + + private def getEndpointMappingMethodHttp4s(bankId: Option[String], endpointMappingId: String, cc: CallContext): Future[JValue] = + for { + (endpointMapping, _) <- NewStyle.function.getEndpointMappingById(bankId, endpointMappingId, Some(cc)) + } yield { + val commonsData: EndpointMappingCommons = endpointMapping + commonsData.toJson + } + + lazy val getEndpointMapping: HttpRoutes[IO] = HttpRoutes.of[IO] { + case req @ GET -> `prefixPath` / "management" / "endpoint-mappings" / endpointMappingId => + EndpointHelpers.withUser(req) { (_, cc) => + getEndpointMappingMethodHttp4s(None, endpointMappingId, cc) + } + } + + lazy val getBankLevelEndpointMapping: HttpRoutes[IO] = HttpRoutes.of[IO] { + case req @ GET -> `prefixPath` / "management" / "banks" / _ / "endpoint-mappings" / endpointMappingId => + EndpointHelpers.withUserAndBank(req) { (_, bank, cc) => + getEndpointMappingMethodHttp4s(Some(bank.bankId.value), endpointMappingId, cc) + } + } + + lazy val getAllEndpointMappings: HttpRoutes[IO] = HttpRoutes.of[IO] { + case req @ GET -> `prefixPath` / "management" / "endpoint-mappings" => + EndpointHelpers.withUser(req) { (_, cc) => + getEndpointMappingsMethodHttp4s(None, cc) + } + } + + lazy val getAllBankLevelEndpointMappings: HttpRoutes[IO] = HttpRoutes.of[IO] { + case req @ GET -> `prefixPath` / "management" / "banks" / _ / "endpoint-mappings" => + EndpointHelpers.withUserAndBank(req) { (_, bank, cc) => + getEndpointMappingsMethodHttp4s(Some(bank.bankId.value), cc) + } + } + + // ═══════════════════════════════════════════════════════════════════════════ + // Batch 8 — Counterparty management endpoints + // ═══════════════════════════════════════════════════════════════════════════ + + lazy val getCounterpartiesForAnyAccount: HttpRoutes[IO] = HttpRoutes.of[IO] { + case req @ GET -> `prefixPath` / "management" / "banks" / _ / "accounts" / _ / viewIdStr / "counterparties" => + EndpointHelpers.withBankAccount(req) { (_, account, cc) => + for { + (counterparties, _) <- NewStyle.function.getCounterparties( + account.bankId, account.accountId, ViewId(viewIdStr), Some(cc)) + _ <- code.util.Helper.booleanToFuture( + CreateOrUpdateCounterpartyMetadataError, failCode = 400, cc = Some(cc)) { + counterparties.forall { c => + code.metadata.counterparties.Counterparties.counterparties.vend.getOrCreateMetadata( + account.bankId, account.accountId, c.counterpartyId, c.name) match { + case Full(_) => true + case _ => false + } + } + } + } yield JSONFactory400.createCounterpartiesJson400(counterparties) + } + } + + lazy val getCounterpartyByIdForAnyAccount: HttpRoutes[IO] = HttpRoutes.of[IO] { + case req @ GET -> `prefixPath` / "management" / "banks" / _ / "accounts" / _ / _ / "counterparties" / counterpartyIdStr => + EndpointHelpers.withBankAccount(req) { (_, account, cc) => + for { + (counterparty, _) <- NewStyle.function.getCounterpartyByCounterpartyId( + CounterpartyId(counterpartyIdStr), Some(cc)) + counterpartyMetadata <- NewStyle.function.getMetadata( + account.bankId, account.accountId, counterpartyIdStr, Some(cc)) + } yield JSONFactory400.createCounterpartyWithMetadataJson400(counterparty, counterpartyMetadata) + } + } + + lazy val getCounterpartyByNameForAnyAccount: HttpRoutes[IO] = HttpRoutes.of[IO] { + case req @ GET -> `prefixPath` / "management" / "banks" / _ / "accounts" / _ / viewIdStr / "counterparty-names" / counterpartyName => + EndpointHelpers.withBankAccount(req) { (_, account, cc) => + for { + (counterpartyList, _) <- code.bankconnectors.Connector.connector.vend + .checkCounterpartyExists(counterpartyName, account.bankId.value, + account.accountId.value, viewIdStr, Some(cc)) + counterparty <- NewStyle.function.tryons( + CounterpartyNotFound.replace( + "The BANK_ID / ACCOUNT_ID specified does not exist on this server.", + s"COUNTERPARTY_NAME($counterpartyName) for the BANK_ID(${account.bankId.value}) and ACCOUNT_ID(${account.accountId.value}) and VIEW_ID($viewIdStr)"), + 400, Some(cc)) { counterpartyList.head } + (counterpartyMetadata, _) <- NewStyle.function.getOrCreateMetadata( + account.bankId, account.accountId, counterparty.counterpartyId, counterparty.name, Some(cc)) + } yield JSONFactory400.createCounterpartyWithMetadataJson400(counterparty, counterpartyMetadata) + } + } + + private def initBatch8ResourceDocs(): Unit = { + staticResourceDocs += ResourceDoc( + null, implementedInApiVersion, nameOf(getCounterpartiesForAnyAccount), "GET", + "/management/banks/BANK_ID/accounts/ACCOUNT_ID/VIEW_ID/counterparties", + "Get Counterparties for any account (Explicit)", + s"""Get Counterparties for any account.""".stripMargin, + EmptyBody, counterpartiesJson400, + List($AuthenticatedUserIsRequired, $BankNotFound, $BankAccountNotFound, + ViewNotFound, CreateOrUpdateCounterpartyMetadataError, UnknownError), + List(apiTagCounterparty, apiTagAccount), + Some(List(canGetCounterpartiesAtAnyBank, canGetCounterparties)), + http4sPartialFunction = Some(getCounterpartiesForAnyAccount)) + + staticResourceDocs += ResourceDoc( + null, implementedInApiVersion, nameOf(getCounterpartyByIdForAnyAccount), "GET", + "/management/banks/BANK_ID/accounts/ACCOUNT_ID/VIEW_ID/counterparties/COUNTERPARTY_ID_PARAM", + "Get Counterparty by Id for any account (Explicit)", + s"""Get Counterparty by COUNTERPARTY_ID.""".stripMargin, + EmptyBody, counterpartyWithMetadataJson400, + List($AuthenticatedUserIsRequired, InvalidAccountIdFormat, InvalidBankIdFormat, + $BankNotFound, $BankAccountNotFound, InvalidJsonFormat, ViewNotFound, UnknownError), + List(apiTagCounterparty, apiTagAccount), + Some(List(canGetCounterpartyAtAnyBank, canGetCounterparty)), + http4sPartialFunction = Some(getCounterpartyByIdForAnyAccount)) + + staticResourceDocs += ResourceDoc( + null, implementedInApiVersion, nameOf(getCounterpartyByNameForAnyAccount), "GET", + "/management/banks/BANK_ID/accounts/ACCOUNT_ID/VIEW_ID/counterparty-names/COUNTERPARTY_NAME", + "Get Counterparty by name for any account (Explicit)", + s"""Get Counterparty by COUNTERPARTY_NAME.""".stripMargin, + EmptyBody, counterpartyWithMetadataJson400, + List($AuthenticatedUserIsRequired, InvalidAccountIdFormat, InvalidBankIdFormat, + $BankNotFound, $BankAccountNotFound, InvalidJsonFormat, ViewNotFound, + CounterpartyNotFound, UnknownError), + List(apiTagCounterparty, apiTagAccount), + Some(List(canGetCounterpartyAtAnyBank, canGetCounterparty)), + http4sPartialFunction = Some(getCounterpartyByNameForAnyAccount)) + } + initBatch8ResourceDocs() + + // ═══════════════════════════════════════════════════════════════════════════ + // Batch 9 — Remaining v4 migrations + // ═══════════════════════════════════════════════════════════════════════════ + + // ─── DELETE family ──────────────────────────────────────────────────────── + // Most v4 DELETEs return 200 with body (Lift `HttpCode.\`200\``); use the + // non-Delete helpers. + + lazy val deleteExplicitCounterparty: HttpRoutes[IO] = HttpRoutes.of[IO] { + case req @ DELETE -> `prefixPath` / "banks" / bankIdStr / "accounts" / accountIdStr / viewIdStr / "counterparties" / counterpartyIdStr => + EndpointHelpers.withView(req) { (_, account, view, cc) => + for { + _ <- code.util.Helper.booleanToFuture(InvalidAccountIdFormat, cc = Some(cc)) { isValidID(accountIdStr) } + _ <- code.util.Helper.booleanToFuture(InvalidBankIdFormat, cc = Some(cc)) { isValidID(bankIdStr) } + _ <- code.util.Helper.booleanToFuture( + s"$NoViewPermission can_delete_counterparty. Please use a view with that permission or add the permission to this view.", + failCode = 403, cc = Some(cc)) { + view.allowed_actions.exists(_ == code.api.Constant.CAN_DELETE_COUNTERPARTY) + } + (counterparty, _) <- NewStyle.function.deleteCounterpartyByCounterpartyId( + CounterpartyId(counterpartyIdStr), Some(cc)) + _ <- NewStyle.function.deleteMetadata( + account.bankId, account.accountId, counterpartyIdStr, Some(cc)) + } yield counterparty + } + } + + lazy val deleteCounterpartyForAnyAccount: HttpRoutes[IO] = HttpRoutes.of[IO] { + case req @ DELETE -> `prefixPath` / "management" / "banks" / bankIdStr / "accounts" / accountIdStr / _ / "counterparties" / counterpartyIdStr => + EndpointHelpers.withBankAccount(req) { (_, account, cc) => + for { + _ <- code.util.Helper.booleanToFuture(InvalidAccountIdFormat, cc = Some(cc)) { isValidID(accountIdStr) } + _ <- code.util.Helper.booleanToFuture(InvalidBankIdFormat, cc = Some(cc)) { isValidID(bankIdStr) } + (counterparty, _) <- NewStyle.function.deleteCounterpartyByCounterpartyId( + CounterpartyId(counterpartyIdStr), Some(cc)) + _ <- NewStyle.function.deleteMetadata( + account.bankId, account.accountId, counterpartyIdStr, Some(cc)) + } yield counterparty + } + } + + lazy val deleteTagForViewOnAccount: HttpRoutes[IO] = HttpRoutes.of[IO] { + case req @ DELETE -> `prefixPath` / "banks" / _ / "accounts" / _ / viewIdStr / "metadata" / "tags" / tagId => + EndpointHelpers.withView(req) { (_, _, view, cc) => + for { + _ <- code.util.Helper.booleanToFuture( + s"$NoViewPermission can_delete_tag. Current ViewId($viewIdStr)", + cc = Some(cc)) { + view.allowed_actions.exists(_ == code.api.Constant.CAN_DELETE_TAG) + } + account = cc.bankAccount.getOrElse(throw new RuntimeException(BankAccountNotFound)) + deleted <- Future( + code.metadata.tags.Tags.tags.vend.deleteTagOnAccount(account.bankId, account.accountId)(tagId)) + } yield deleted + } + } + + lazy val getTagsForViewOnAccount: HttpRoutes[IO] = HttpRoutes.of[IO] { + case req @ GET -> `prefixPath` / "banks" / _ / "accounts" / _ / viewIdStr / "metadata" / "tags" => + EndpointHelpers.withView(req) { (_, account, view, cc) => + for { + _ <- code.util.Helper.booleanToFuture( + s"$NoViewPermission can_see_tags. Current ViewId($viewIdStr)", + cc = Some(cc)) { + view.allowed_actions.exists(_ == code.api.Constant.CAN_SEE_TAGS) + } + tags <- Future( + code.metadata.tags.Tags.tags.vend.getTagsOnAccount(account.bankId, account.accountId)(ViewId(viewIdStr))) + } yield JSONFactory400.createAccountTagsJSON(tags) + } + } + + lazy val addTagForViewOnAccount: HttpRoutes[IO] = HttpRoutes.of[IO] { + case req @ POST -> `prefixPath` / "banks" / _ / "accounts" / _ / viewIdStr / "metadata" / "tags" => + implicit val cc: CallContext = req.callContext + EndpointHelpers.executeFutureCreated(req) { + val bodyStr = cc.httpBody.getOrElse("") + for { + user <- Future { cc.user.openOrThrowException(AuthenticatedUserIsRequired) } + account <- Future { cc.bankAccount.getOrElse(throw new RuntimeException(BankAccountNotFound)) } + view <- Future { cc.view.getOrElse(throw new RuntimeException(s"$ViewNotFound Current ViewId($viewIdStr)")) } + _ <- code.util.Helper.booleanToFuture( + s"$NoViewPermission can_add_tag. Current ViewId($viewIdStr)", + cc = Some(cc)) { + view.allowed_actions.exists(_ == code.api.Constant.CAN_ADD_TAG) + } + tagJson <- NewStyle.function.tryons( + s"$InvalidJsonFormat The Json body should be the ${classOf[code.api.v1_2_1.PostTransactionTagJSON].getSimpleName} ", + 400, Some(cc)) { + net.liftweb.json.parse(bodyStr).extract[code.api.v1_2_1.PostTransactionTagJSON] + } + postedTag <- Future( + code.metadata.tags.Tags.tags.vend.addTagOnAccount(account.bankId, account.accountId)( + user.userPrimaryKey, ViewId(viewIdStr), tagJson.value, new java.util.Date()) + ) map { box => unboxFullOrFail(box, Some(cc), "OBP-50000: Unknown Error.", 400) } + } yield JSONFactory400.createAccountTagJSON(postedTag) + } + } + + // ─── simpler GETs ──────────────────────────────────────────────────────── + + lazy val getDoubleEntryTransaction: HttpRoutes[IO] = HttpRoutes.of[IO] { + case req @ GET -> `prefixPath` / "banks" / bankIdStr / "accounts" / accountIdStr / _ / "transactions" / transactionIdStr / "double-entry-transaction" => + EndpointHelpers.withView(req) { (_, _, _, cc) => + for { + (_, _) <- NewStyle.function.getTransaction( + BankId(bankIdStr), AccountId(accountIdStr), TransactionId(transactionIdStr), Some(cc)) + (doubleEntryTransaction, _) <- NewStyle.function.getDoubleEntryBookTransaction( + BankId(bankIdStr), AccountId(accountIdStr), TransactionId(transactionIdStr), Some(cc)) + } yield JSONFactory400.createDoubleEntryTransactionJson(doubleEntryTransaction) + } + } + + lazy val getBalancingTransaction: HttpRoutes[IO] = HttpRoutes.of[IO] { + case req @ GET -> `prefixPath` / "transactions" / transactionIdStr / "balancing-transaction" => + EndpointHelpers.withUser(req) { (user, cc) => + for { + (doubleEntryTransaction, _) <- NewStyle.function.getBalancingTransaction( + TransactionId(transactionIdStr), Some(cc)) + _ <- ViewNewStyle.checkBalancingTransactionAccountAccessAndReturnView( + doubleEntryTransaction, Full(user), Some(cc)) + } yield JSONFactory400.createDoubleEntryTransactionJson(doubleEntryTransaction) + } + } + + lazy val getBankAccountBalancesForCurrentUser: HttpRoutes[IO] = HttpRoutes.of[IO] { + case req @ GET -> `prefixPath` / "banks" / _ / "accounts" / accountIdStr / "balances" => + EndpointHelpers.withUserAndBank(req) { (user, bank, cc) => + for { + (allowedAccounts, _) <- code.api.util.newstyle.BalanceNewStyle.getAccountAccessAtBank( + user, bank.bankId, Some(cc)) + msg = s"$CannotFindAccountAccess AccountId(${accountIdStr})" + bankIdAccountId <- NewStyle.function.tryons(msg, 400, Some(cc)) { + allowedAccounts.find(_.accountId == AccountId(accountIdStr)).get + } + (accountBalances, _) <- code.api.util.newstyle.BalanceNewStyle.getBankAccountBalances( + bankIdAccountId, Some(cc)) + } yield JSONFactory400.createAccountBalancesJson(accountBalances) + } + } + + lazy val getAccountByAccountRouting: HttpRoutes[IO] = HttpRoutes.of[IO] { + case req @ POST -> `prefixPath` / "management" / "accounts" / "account-routing-query" => + EndpointHelpers.withUserAndBody[BankAccountRoutingJson, Any](req) { (user, postJson, cc) => + for { + (account, _) <- NewStyle.function.getBankAccountByRouting( + postJson.bank_id.map(BankId(_)), + postJson.account_routing.scheme, + postJson.account_routing.address, + Some(cc)) + view <- ViewNewStyle.checkOwnerViewAccessAndReturnOwnerView( + user, BankIdAccountId(account.bankId, account.accountId), Some(cc)) + moderatedAccount <- NewStyle.function.moderatedBankAccountCore( + account, view, Full(user), Some(cc)) + (accountAttributes, _) <- NewStyle.function.getAccountAttributesByAccount( + account.bankId, account.accountId, Some(cc)) + } yield { + val availableViews = Views.views.vend.privateViewsUserCanAccessForAccount( + user, BankIdAccountId(account.bankId, account.accountId)) + val viewsAvailable = availableViews + .map(code.api.v1_2_1.JSONFactory.createViewJSON).sortBy(_.short_name) + val tags = code.metadata.tags.Tags.tags.vend + .getTagsOnAccount(account.bankId, account.accountId)(view.viewId) + createBankAccountJSON(moderatedAccount, viewsAvailable, accountAttributes, tags) + } + } + } + + lazy val getAccountsByAccountRoutingRegex: HttpRoutes[IO] = HttpRoutes.of[IO] { + case req @ POST -> `prefixPath` / "management" / "accounts" / "account-routing-regex-query" => + EndpointHelpers.withUserAndBody[BankAccountRoutingJson, Any](req) { (user, postJson, cc) => + for { + (accountRoutings, _) <- NewStyle.function.getAccountRoutingsByScheme( + postJson.bank_id.map(BankId(_)), + postJson.account_routing.scheme, + Some(cc)) + accountRoutingAddressRegex = postJson.account_routing.address.r + filteredAccountRoutings = accountRoutings.filter(accountRouting => + accountRoutingAddressRegex.findFirstIn(accountRouting.accountRouting.address).isDefined) + accountsJson <- Future.sequence( + filteredAccountRoutings.map(accountRouting => + for { + (account, _) <- NewStyle.function.getBankAccount( + accountRouting.bankId, accountRouting.accountId, Some(cc)) + view <- ViewNewStyle.checkOwnerViewAccessAndReturnOwnerView( + user, BankIdAccountId(account.bankId, account.accountId), Some(cc)) + moderatedAccount <- NewStyle.function.moderatedBankAccountCore( + account, view, Full(user), Some(cc)) + (accountAttributes, _) <- NewStyle.function.getAccountAttributesByAccount( + account.bankId, account.accountId, Some(cc)) + availableViews = Views.views.vend.privateViewsUserCanAccessForAccount( + user, BankIdAccountId(account.bankId, account.accountId)) + viewsAvailable = availableViews + .map(code.api.v1_2_1.JSONFactory.createViewJSON).sortBy(_.short_name) + tags = code.metadata.tags.Tags.tags.vend + .getTagsOnAccount(account.bankId, account.accountId)(view.viewId) + } yield createBankAccountJSON( + moderatedAccount, viewsAvailable, accountAttributes, tags))) + } yield ModeratedAccountsJSON400(accountsJson) + } + } + + // ─── lockUser / resetPasswordUrl ───────────────────────────────────────── + + lazy val lockUser: HttpRoutes[IO] = HttpRoutes.of[IO] { + case req @ POST -> `prefixPath` / "users" / username / "locks" => + EndpointHelpers.withUser(req) { (_, cc) => + for { + userLocks <- Future { + code.userlocks.UserLocksProvider.lockUser( + Constant.localIdentityProvider, username) + } map { box => + unboxFullOrFail(box, Some(cc), + s"$UserNotFoundByProviderAndUsername($username)", 404) + } + } yield JSONFactory400.createUserLockStatusJson(userLocks) + } + } + + lazy val resetPasswordUrl: HttpRoutes[IO] = HttpRoutes.of[IO] { + case req @ POST -> `prefixPath` / "management" / "user" / "reset-password-url" => + EndpointHelpers.withUserAndBodyCreated[PostResetPasswordUrlJsonV400, ResetPasswordUrlJsonV400](req) { + (_, postedData, cc) => + for { + _ <- code.util.Helper.booleanToFuture( + failMsg = NotAllowedEndpoint, cc = Some(cc)) { + APIUtil.getPropsAsBoolValue("ResetPasswordUrlEnabled", false) + } + resetLink = AuthUser.passwordResetUrl( + postedData.username, postedData.email, postedData.user_id) + } yield ResetPasswordUrlJsonV400(resetLink) + } + } + + // ─── settlement-accounts ──────────────────────────────────────────────── + + lazy val getSettlementAccounts: HttpRoutes[IO] = HttpRoutes.of[IO] { + case req @ GET -> `prefixPath` / "banks" / bankIdStr / "settlement-accounts" => + EndpointHelpers.withUserAndBank(req) { (user, bank, cc) => + for { + _ <- NewStyle.function.hasEntitlement( + bankIdStr, user.userId, canGetSettlementAccountAtOneBank, Some(cc)) + (accounts, _) <- NewStyle.function.getBankSettlementAccounts(bank.bankId, Some(cc)) + settlementAccounts <- Future.sequence(accounts.map { account => + NewStyle.function.getAccountAttributesByAccount( + bank.bankId, account.accountId, Some(cc) + ).map { case (accountAttributes, _) => + JSONFactory400.getSettlementAccountJson(account, accountAttributes) + } + }) + } yield SettlementAccountsJson(settlementAccounts) + } + } + + private def initBatch9ResourceDocs(): Unit = { + staticResourceDocs += ResourceDoc( + null, implementedInApiVersion, nameOf(deleteExplicitCounterparty), "DELETE", + "/banks/BANK_ID/accounts/ACCOUNT_ID/VIEW_ID/counterparties/COUNTERPARTY_ID_PARAM", + "Delete Counterparty (Explicit)", + s"""Delete Counterparty (Explicit). + | + |${userAuthenticationMessage(true)}""", + EmptyBody, EmptyBody, + List($AuthenticatedUserIsRequired, $BankAccountNotFound, $BankNotFound, + InvalidAccountIdFormat, InvalidBankIdFormat, NoViewPermission, UnknownError), + List(apiTagCounterparty, apiTagAccount), None, + http4sPartialFunction = Some(deleteExplicitCounterparty)) + + staticResourceDocs += ResourceDoc( + null, implementedInApiVersion, nameOf(deleteCounterpartyForAnyAccount), "DELETE", + "/management/banks/BANK_ID/accounts/ACCOUNT_ID/VIEW_ID/counterparties/COUNTERPARTY_ID", + "Delete Counterparty for any account (Explicit)", + s"""Delete Counterparty for any account. + | + |${userAuthenticationMessage(true)}""", + EmptyBody, EmptyBody, + List($AuthenticatedUserIsRequired, $BankAccountNotFound, $BankNotFound, + InvalidAccountIdFormat, InvalidBankIdFormat, UserHasMissingRoles, UnknownError), + List(apiTagCounterparty, apiTagAccount), + Some(List(canDeleteCounterparty, canDeleteCounterpartyAtAnyBank)), + http4sPartialFunction = Some(deleteCounterpartyForAnyAccount)) + + staticResourceDocs += ResourceDoc( + null, implementedInApiVersion, "deleteTagForViewOnAccount", "DELETE", + "/banks/BANK_ID/accounts/ACCOUNT_ID/VIEW_ID/metadata/tags/TAG_ID", + "Delete a tag on account", + s"""Delete a tag on account. + | + |${userAuthenticationMessage(true)}""", + EmptyBody, EmptyBody, + List(NoViewPermission, ViewNotFound, $AuthenticatedUserIsRequired, + $BankNotFound, $BankAccountNotFound, $UserNoPermissionAccessView, UnknownError), + List(apiTagAccountMetadata, apiTagAccount), None, + http4sPartialFunction = Some(deleteTagForViewOnAccount)) + + staticResourceDocs += ResourceDoc( + null, implementedInApiVersion, "getTagsForViewOnAccount", "GET", + "/banks/BANK_ID/accounts/ACCOUNT_ID/VIEW_ID/metadata/tags", + "Get tags on account", + s"""Get tags on account. + | + |${userAuthenticationMessage(true)}""", + EmptyBody, accountTagsJSON, + List($AuthenticatedUserIsRequired, $BankNotFound, $BankAccountNotFound, + NoViewPermission, $UserNoPermissionAccessView, UnknownError), + List(apiTagAccountMetadata, apiTagAccount), None, + http4sPartialFunction = Some(getTagsForViewOnAccount)) + + staticResourceDocs += ResourceDoc( + null, implementedInApiVersion, "addTagForViewOnAccount", "POST", + "/banks/BANK_ID/accounts/ACCOUNT_ID/VIEW_ID/metadata/tags", + "Create a tag on account", + s"""Create a tag on account. + | + |${userAuthenticationMessage(true)}""", + code.api.v1_2_1.PostTransactionTagJSON("tag-value-example"), + accountTagJSON, + List($AuthenticatedUserIsRequired, $BankNotFound, $BankAccountNotFound, + InvalidJsonFormat, NoViewPermission, $UserNoPermissionAccessView, UnknownError), + List(apiTagAccountMetadata, apiTagAccount), None, + http4sPartialFunction = Some(addTagForViewOnAccount)) + + staticResourceDocs += ResourceDoc( + null, implementedInApiVersion, nameOf(getDoubleEntryTransaction), "GET", + "/banks/BANK_ID/accounts/ACCOUNT_ID/VIEW_ID/transactions/TRANSACTION_ID/double-entry-transaction", + "Get Double Entry Transaction", + s"""Get Double Entry Transaction. + | + |${userAuthenticationMessage(true)}""", + EmptyBody, doubleEntryTransactionJson, + List($AuthenticatedUserIsRequired, $BankNotFound, $BankAccountNotFound, + $UserNoPermissionAccessView, InvalidJsonFormat, UnknownError), + List(apiTagTransaction), + Some(List(canGetDoubleEntryTransactionAtAnyBank, canGetDoubleEntryTransactionAtOneBank)), + http4sPartialFunction = Some(getDoubleEntryTransaction)) + + staticResourceDocs += ResourceDoc( + null, implementedInApiVersion, nameOf(getBalancingTransaction), "GET", + "/transactions/TRANSACTION_ID/balancing-transaction", + "Get Balancing Transaction", + s"""Get Balancing Transaction. + | + |${userAuthenticationMessage(true)}""", + EmptyBody, doubleEntryTransactionJson, + List($AuthenticatedUserIsRequired, InvalidJsonFormat, UnknownError), + List(apiTagTransaction), Some(List()), + http4sPartialFunction = Some(getBalancingTransaction)) + + staticResourceDocs += ResourceDoc( + null, implementedInApiVersion, nameOf(getBankAccountBalancesForCurrentUser), "GET", + "/banks/BANK_ID/accounts/ACCOUNT_ID/balances", + "Get Account Balances", + """Get the Balances for one Account of the current User at one bank.""", + EmptyBody, accountBalanceV400, + List($AuthenticatedUserIsRequired, $BankNotFound, CannotFindAccountAccess, UnknownError), + apiTagAccount :: apiTagPSD2AIS :: apiTagPsd2 :: Nil, None, + http4sPartialFunction = Some(getBankAccountBalancesForCurrentUser)) + + staticResourceDocs += ResourceDoc( + null, implementedInApiVersion, nameOf(getAccountByAccountRouting), "POST", + "/management/accounts/account-routing-query", + "Get Account by Account Routing", + """Get Account by Account Routing.""", + bankAccountRoutingJson, moderatedAccountJSON400, + List($AuthenticatedUserIsRequired, $BankNotFound, $BankAccountNotFound, + $UserNoPermissionAccessView, UnknownError), + List(apiTagAccount), None, + http4sPartialFunction = Some(getAccountByAccountRouting)) + + staticResourceDocs += ResourceDoc( + null, implementedInApiVersion, nameOf(getAccountsByAccountRoutingRegex), "POST", + "/management/accounts/account-routing-regex-query", + "Get Accounts by Account Routing Regex", + """Get Accounts by Account Routing Regex.""", + bankAccountRoutingJson, moderatedAccountsJSON400, + List($AuthenticatedUserIsRequired, $BankNotFound, $BankAccountNotFound, + $UserNoPermissionAccessView, UnknownError), + List(apiTagAccount), None, + http4sPartialFunction = Some(getAccountsByAccountRoutingRegex)) + + staticResourceDocs += ResourceDoc( + null, implementedInApiVersion, nameOf(lockUser), "POST", + "/users/USERNAME/locks", + "Lock the user", + s"""Lock a User. + | + |${userAuthenticationMessage(true)}""", + EmptyBody, userLockStatusJson, + List($AuthenticatedUserIsRequired, UserNotFoundByProviderAndUsername, + UserHasMissingRoles, UnknownError), + List(apiTagUser), + Some(List(canLockUser)), + http4sPartialFunction = Some(lockUser)) + + staticResourceDocs += ResourceDoc( + null, implementedInApiVersion, nameOf(resetPasswordUrl), "POST", + "/management/user/reset-password-url", + "Create password reset url", + s"""Create password reset url.""", + PostResetPasswordUrlJsonV400("jobloggs", "jo@gmail.com", "74a8ebcc-10e4-4036-bef3-9835922246bf"), + ResetPasswordUrlJsonV400("https://apisandbox.openbankproject.com/user_mgt/reset_password/QOL1CPNJPCZ4BRMPX3Z01DPOX1HMGU3L"), + List($AuthenticatedUserIsRequired, UserHasMissingRoles, InvalidJsonFormat, UnknownError), + List(apiTagUser), + Some(List(canCreateResetPasswordUrl)), + http4sPartialFunction = Some(resetPasswordUrl)) + + staticResourceDocs += ResourceDoc( + null, implementedInApiVersion, nameOf(getSettlementAccounts), "GET", + "/banks/BANK_ID/settlement-accounts", + "Get Settlement accounts at Bank", + """Get Settlement accounts at Bank.""", + EmptyBody, settlementAccountsJson, + List($AuthenticatedUserIsRequired, UserHasMissingRoles, $BankNotFound, UnknownError), + List(apiTagBank, apiTagPsd2), + Some(List(canGetSettlementAccountAtOneBank)), + http4sPartialFunction = Some(getSettlementAccounts)) + } + initBatch9ResourceDocs() + + // ═══════════════════════════════════════════════════════════════════════════ + // Batch 10 — Attribute endpoints (Bank/Customer/Transaction/TransactionRequest/ProductFee) + // ═══════════════════════════════════════════════════════════════════════════ + + // ─── Bank Attribute ───────────────────────────────────────────────────── + + lazy val createBankAttribute: HttpRoutes[IO] = HttpRoutes.of[IO] { + case req @ POST -> `prefixPath` / "banks" / bankIdStr / "attribute" => + EndpointHelpers.withUserAndBankAndBodyCreated[BankAttributeJsonV400, Any](req) { (_, _, postedData, cc) => + for { + attrType <- NewStyle.function.tryons( + s"$InvalidJsonFormat The `Type` field can only accept the following field: " + + s"${com.openbankproject.commons.model.enums.BankAttributeType.DOUBLE}(12.1234), ${com.openbankproject.commons.model.enums.BankAttributeType.STRING}(TAX_NUMBER), ${com.openbankproject.commons.model.enums.BankAttributeType.INTEGER}(123) and ${com.openbankproject.commons.model.enums.BankAttributeType.DATE_WITH_DAY}(2012-04-23)", + 400, Some(cc)) { + com.openbankproject.commons.model.enums.BankAttributeType.withName(postedData.`type`) + } + (bankAttribute, _) <- NewStyle.function.createOrUpdateBankAttribute( + BankId(bankIdStr), None, postedData.name, attrType, postedData.value, + postedData.is_active, Some(cc)) + } yield createBankAttributeJson(bankAttribute) + } + } + + lazy val updateBankAttribute: HttpRoutes[IO] = HttpRoutes.of[IO] { + case req @ PUT -> `prefixPath` / "banks" / bankIdStr / "attributes" / bankAttributeId => + EndpointHelpers.withUserAndBankAndBody[BankAttributeJsonV400, Any](req) { (user, _, postedData, cc) => + for { + _ <- NewStyle.function.hasEntitlement( + bankIdStr, user.userId, canUpdateBankAttribute, Some(cc)) + attrType <- NewStyle.function.tryons( + s"$InvalidJsonFormat The `Type` field can only accept the following field: " + + s"${com.openbankproject.commons.model.enums.BankAttributeType.DOUBLE}(12.1234), ${com.openbankproject.commons.model.enums.BankAttributeType.STRING}(TAX_NUMBER), ${com.openbankproject.commons.model.enums.BankAttributeType.INTEGER}(123) and ${com.openbankproject.commons.model.enums.BankAttributeType.DATE_WITH_DAY}(2012-04-23)", + 400, Some(cc)) { + com.openbankproject.commons.model.enums.BankAttributeType.withName(postedData.`type`) + } + (_, _) <- NewStyle.function.getBankAttributeById(bankAttributeId, Some(cc)) + (bankAttribute, _) <- NewStyle.function.createOrUpdateBankAttribute( + BankId(bankIdStr), Some(bankAttributeId), postedData.name, attrType, + postedData.value, postedData.is_active, Some(cc)) + } yield createBankAttributeJson(bankAttribute) + } + } + + // ─── Customer Attribute ────────────────────────────────────────────────── + + private def checkCustomerBank(customer: com.openbankproject.commons.model.Customer, + bankId: String, customerId: String, cc: CallContext): Future[Box[Unit]] = + code.util.Helper.booleanToFuture( + InvalidCustomerBankId + .replaceAll("Bank Id.", s"Bank Id ($bankId).") + .replaceAll("The Customer", s"The Customer($customerId)"), + cc = Some(cc)) { customer.bankId == bankId } + + lazy val createCustomerAttribute: HttpRoutes[IO] = HttpRoutes.of[IO] { + case req @ POST -> `prefixPath` / "banks" / bankIdStr / "customers" / customerIdStr / "attribute" => + EndpointHelpers.withUserAndBodyCreated[CustomerAttributeJsonV400, Any](req) { (_, postedData, cc) => + for { + attrType <- NewStyle.function.tryons( + s"$InvalidJsonFormat The `Type` field can only accept the following field: " + + s"${com.openbankproject.commons.model.enums.CustomerAttributeType.DOUBLE}(12.1234), ${com.openbankproject.commons.model.enums.CustomerAttributeType.STRING}(TAX_NUMBER), ${com.openbankproject.commons.model.enums.CustomerAttributeType.INTEGER}(123) and ${com.openbankproject.commons.model.enums.CustomerAttributeType.DATE_WITH_DAY}(2012-04-23)", + 400, Some(cc)) { + com.openbankproject.commons.model.enums.CustomerAttributeType.withName(postedData.`type`) + } + (customer, _) <- NewStyle.function.getCustomerByCustomerId(customerIdStr, Some(cc)) + _ <- checkCustomerBank(customer, bankIdStr, customerIdStr, cc) + (attr, _) <- NewStyle.function.createOrUpdateCustomerAttribute( + BankId(bankIdStr), CustomerId(customerIdStr), None, postedData.name, + attrType, postedData.value, Some(cc)) + } yield JSONFactory400.createCustomerAttributeJson(attr) + } + } + + lazy val updateCustomerAttribute: HttpRoutes[IO] = HttpRoutes.of[IO] { + case req @ PUT -> `prefixPath` / "banks" / bankIdStr / "customers" / customerIdStr / "attributes" / customerAttributeId => + EndpointHelpers.withUserAndBody[CustomerAttributeJsonV400, Any](req) { (_, postedData, cc) => + for { + attrType <- NewStyle.function.tryons( + s"$InvalidJsonFormat The `Type` field can only accept the following field: " + + s"${com.openbankproject.commons.model.enums.CustomerAttributeType.DOUBLE}(12.1234), ${com.openbankproject.commons.model.enums.CustomerAttributeType.STRING}(TAX_NUMBER), ${com.openbankproject.commons.model.enums.CustomerAttributeType.INTEGER}(123) and ${com.openbankproject.commons.model.enums.CustomerAttributeType.DATE_WITH_DAY}(2012-04-23)", + 400, Some(cc)) { + com.openbankproject.commons.model.enums.CustomerAttributeType.withName(postedData.`type`) + } + (customer, _) <- NewStyle.function.getCustomerByCustomerId(customerIdStr, Some(cc)) + _ <- checkCustomerBank(customer, bankIdStr, customerIdStr, cc) + (_, _) <- NewStyle.function.getCustomerAttributeById(customerAttributeId, Some(cc)) + (attr, _) <- NewStyle.function.createOrUpdateCustomerAttribute( + BankId(bankIdStr), CustomerId(customerIdStr), Some(customerAttributeId), + postedData.name, attrType, postedData.value, Some(cc)) + } yield JSONFactory400.createCustomerAttributeJson(attr) + } + } + + // ─── Transaction Attribute ─────────────────────────────────────────────── + + lazy val createTransactionAttribute: HttpRoutes[IO] = HttpRoutes.of[IO] { + case req @ POST -> `prefixPath` / "banks" / bankIdStr / "accounts" / accountIdStr / "transactions" / transactionIdStr / "attribute" => + EndpointHelpers.withUserAndBankAndBodyCreated[TransactionAttributeJsonV400, Any](req) { (_, bank, postedData, cc) => + for { + (_, _) <- NewStyle.function.getTransaction( + bank.bankId, AccountId(accountIdStr), TransactionId(transactionIdStr), Some(cc)) + attrType <- NewStyle.function.tryons( + s"$InvalidJsonFormat The `Type` field can only accept the following field: " + + s"${com.openbankproject.commons.model.enums.TransactionAttributeType.DOUBLE}(12.1234), ${com.openbankproject.commons.model.enums.TransactionAttributeType.STRING}(TAX_NUMBER), ${com.openbankproject.commons.model.enums.TransactionAttributeType.INTEGER} (123)and ${com.openbankproject.commons.model.enums.TransactionAttributeType.DATE_WITH_DAY}(2012-04-23)", + 400, Some(cc)) { + com.openbankproject.commons.model.enums.TransactionAttributeType.withName(postedData.`type`) + } + (attr, _) <- NewStyle.function.createOrUpdateTransactionAttribute( + bank.bankId, TransactionId(transactionIdStr), None, postedData.name, + attrType, postedData.value, Some(cc)) + } yield JSONFactory400.createTransactionAttributeJson(attr) + } + } + + lazy val updateTransactionAttribute: HttpRoutes[IO] = HttpRoutes.of[IO] { + case req @ PUT -> `prefixPath` / "banks" / bankIdStr / "accounts" / accountIdStr / "transactions" / transactionIdStr / "attributes" / transactionAttributeId => + EndpointHelpers.withUserAndBankAndBody[TransactionAttributeJsonV400, Any](req) { (_, bank, postedData, cc) => + for { + (_, _) <- NewStyle.function.getTransaction( + bank.bankId, AccountId(accountIdStr), TransactionId(transactionIdStr), Some(cc)) + attrType <- NewStyle.function.tryons( + s"$InvalidJsonFormat The `Type` field can only accept the following field: " + + s"${com.openbankproject.commons.model.enums.TransactionAttributeType.DOUBLE}(12.1234), ${com.openbankproject.commons.model.enums.TransactionAttributeType.STRING}(TAX_NUMBER), ${com.openbankproject.commons.model.enums.TransactionAttributeType.INTEGER} (123)and ${com.openbankproject.commons.model.enums.TransactionAttributeType.DATE_WITH_DAY}(2012-04-23)", + 400, Some(cc)) { + com.openbankproject.commons.model.enums.TransactionAttributeType.withName(postedData.`type`) + } + (_, _) <- NewStyle.function.getTransactionAttributeById(transactionAttributeId, Some(cc)) + (attr, _) <- NewStyle.function.createOrUpdateTransactionAttribute( + bank.bankId, TransactionId(transactionIdStr), Some(transactionAttributeId), + postedData.name, attrType, postedData.value, Some(cc)) + } yield JSONFactory400.createTransactionAttributeJson(attr) + } + } + + // ─── Transaction Request Attribute ─────────────────────────────────────── + + lazy val createTransactionRequestAttribute: HttpRoutes[IO] = HttpRoutes.of[IO] { + case req @ POST -> `prefixPath` / "banks" / bankIdStr / "accounts" / _ / "transaction-requests" / transactionRequestIdStr / "attribute" => + EndpointHelpers.withUserAndBankAndBodyCreated[TransactionRequestAttributeJsonV400, Any](req) { (_, bank, postedData, cc) => + for { + (_, _) <- NewStyle.function.getTransactionRequestImpl( + TransactionRequestId(transactionRequestIdStr), Some(cc)) + attrType <- NewStyle.function.tryons( + s"$InvalidJsonFormat The `Type` field can only accept the following field: " + + s"${com.openbankproject.commons.model.enums.TransactionRequestAttributeType.DOUBLE}(12.1234), ${com.openbankproject.commons.model.enums.TransactionRequestAttributeType.STRING}(TAX_NUMBER), ${com.openbankproject.commons.model.enums.TransactionRequestAttributeType.INTEGER}(123) and ${com.openbankproject.commons.model.enums.TransactionRequestAttributeType.DATE_WITH_DAY}(2012-04-23)", + 400, Some(cc)) { + com.openbankproject.commons.model.enums.TransactionRequestAttributeType.withName(postedData.attribute_type) + } + (attr, _) <- NewStyle.function.createOrUpdateTransactionRequestAttribute( + bank.bankId, TransactionRequestId(transactionRequestIdStr), None, + postedData.name, attrType, postedData.value, Some(cc)) + } yield JSONFactory400.createTransactionRequestAttributeJson(attr) + } + } + + lazy val updateTransactionRequestAttribute: HttpRoutes[IO] = HttpRoutes.of[IO] { + case req @ PUT -> `prefixPath` / "banks" / bankIdStr / "accounts" / _ / "transaction-requests" / transactionRequestIdStr / "attributes" / transactionRequestAttributeId => + EndpointHelpers.withUserAndBankAndBody[TransactionRequestAttributeJsonV400, Any](req) { (_, bank, postedData, cc) => + for { + (_, _) <- NewStyle.function.getTransactionRequestImpl( + TransactionRequestId(transactionRequestIdStr), Some(cc)) + attrType <- NewStyle.function.tryons( + s"$InvalidJsonFormat The `Type` field can only accept the following field: " + + s"${com.openbankproject.commons.model.enums.TransactionRequestAttributeType.DOUBLE}(12.1234), ${com.openbankproject.commons.model.enums.TransactionRequestAttributeType.STRING}(TAX_NUMBER), ${com.openbankproject.commons.model.enums.TransactionRequestAttributeType.INTEGER}(123) and ${com.openbankproject.commons.model.enums.TransactionRequestAttributeType.DATE_WITH_DAY}(2012-04-23)", + 400, Some(cc)) { + com.openbankproject.commons.model.enums.TransactionRequestAttributeType.withName(postedData.attribute_type) + } + (_, _) <- NewStyle.function.getTransactionRequestAttributeById( + transactionRequestAttributeId, Some(cc)) + (attr, _) <- NewStyle.function.createOrUpdateTransactionRequestAttribute( + bank.bankId, TransactionRequestId(transactionRequestIdStr), + Some(transactionRequestAttributeId), postedData.name, attrType, + postedData.value, Some(cc)) + } yield JSONFactory400.createTransactionRequestAttributeJson(attr) + } + } + + // ─── Product Fee ───────────────────────────────────────────────────────── + + lazy val createProductFee: HttpRoutes[IO] = HttpRoutes.of[IO] { + case req @ POST -> `prefixPath` / "banks" / bankIdStr / "products" / productCode / "fee" => + EndpointHelpers.withUserAndBodyCreated[ProductFeeJsonV400, Any](req) { (_, postedData, cc) => + for { + (_, _) <- NewStyle.function.getProduct(BankId(bankIdStr), ProductCode(productCode), Some(cc)) + (productFee, _) <- NewStyle.function.createOrUpdateProductFee( + BankId(bankIdStr), ProductCode(productCode), None, + postedData.name, postedData.is_active, postedData.more_info, + postedData.value.currency, postedData.value.amount, + postedData.value.frequency, postedData.value.`type`, Some(cc)) + } yield createProductFeeJson(productFee) + } + } + + lazy val updateProductFee: HttpRoutes[IO] = HttpRoutes.of[IO] { + case req @ PUT -> `prefixPath` / "banks" / bankIdStr / "products" / productCode / "fees" / productFeeId => + EndpointHelpers.withUserAndBodyCreated[ProductFeeJsonV400, Any](req) { (_, postedData, cc) => + for { + (_, _) <- NewStyle.function.getProduct(BankId(bankIdStr), ProductCode(productCode), Some(cc)) + (_, _) <- NewStyle.function.getProductFeeById(productFeeId, Some(cc)) + (productFee, _) <- NewStyle.function.createOrUpdateProductFee( + BankId(bankIdStr), ProductCode(productCode), Some(productFeeId), + postedData.name, postedData.is_active, postedData.more_info, + postedData.value.currency, postedData.value.amount, + postedData.value.frequency, postedData.value.`type`, Some(cc)) + } yield createProductFeeJson(productFee) + } + } + + // ─── My Personal User Attribute ────────────────────────────────────────── + + lazy val createMyPersonalUserAttribute: HttpRoutes[IO] = HttpRoutes.of[IO] { + case req @ POST -> `prefixPath` / "my" / "user" / "attributes" => + EndpointHelpers.withUserAndBodyCreated[UserAttributeJsonV400, Any](req) { (user, postedData, cc) => + for { + attrType <- NewStyle.function.tryons( + s"$InvalidJsonFormat The `Type` field can only accept the following field: " + + s"${com.openbankproject.commons.model.enums.UserAttributeType.DOUBLE}(12.1234), ${com.openbankproject.commons.model.enums.UserAttributeType.STRING}(TAX_NUMBER), ${com.openbankproject.commons.model.enums.UserAttributeType.INTEGER} (123)and ${com.openbankproject.commons.model.enums.UserAttributeType.DATE_WITH_DAY}(2012-04-23)", + 400, Some(cc)) { + com.openbankproject.commons.model.enums.UserAttributeType.withName(postedData.`type`) + } + (userAttribute, _) <- NewStyle.function.createOrUpdateUserAttribute( + user.userId, None, postedData.name, attrType, postedData.value, + true, Some(cc)) + } yield JSONFactory400.createUserAttributeJson(userAttribute) + } + } + + lazy val updateMyPersonalUserAttribute: HttpRoutes[IO] = HttpRoutes.of[IO] { + case req @ PUT -> `prefixPath` / "my" / "user" / "attributes" / userAttributeId => + EndpointHelpers.withUserAndBody[UserAttributeJsonV400, Any](req) { (user, postedData, cc) => + for { + (attributes, _) <- NewStyle.function.getPersonalUserAttributes(user.userId, Some(cc)) + _ <- NewStyle.function.tryons(UserAttributeNotFound, 400, Some(cc)) { + attributes.exists(_.userAttributeId == userAttributeId) + } + attrType <- NewStyle.function.tryons( + s"$InvalidJsonFormat The `Type` field can only accept the following field: " + + s"${com.openbankproject.commons.model.enums.UserAttributeType.DOUBLE}(12.1234), ${com.openbankproject.commons.model.enums.UserAttributeType.STRING}(TAX_NUMBER), ${com.openbankproject.commons.model.enums.UserAttributeType.INTEGER} (123)and ${com.openbankproject.commons.model.enums.UserAttributeType.DATE_WITH_DAY}(2012-04-23)", + 400, Some(cc)) { + com.openbankproject.commons.model.enums.UserAttributeType.withName(postedData.`type`) + } + (userAttribute, _) <- NewStyle.function.createOrUpdateUserAttribute( + user.userId, Some(userAttributeId), postedData.name, attrType, + postedData.value, true, Some(cc)) + } yield JSONFactory400.createUserAttributeJson(userAttribute) + } + } + + private def initBatch10ResourceDocs(): Unit = { + staticResourceDocs += ResourceDoc( + null, implementedInApiVersion, nameOf(createBankAttribute), "POST", + "/banks/BANK_ID/attribute", + "Create Bank Attribute", + s"""Create Bank Attribute. + | + |${userAuthenticationMessage(true)}""", + bankAttributeJsonV400, bankAttributeResponseJsonV400, + List(InvalidJsonFormat, UnknownError), + List(apiTagBank, apiTagBankAttribute, apiTagAttribute), + Some(List(canCreateBankAttribute)), + http4sPartialFunction = Some(createBankAttribute)) + + staticResourceDocs += ResourceDoc( + null, implementedInApiVersion, nameOf(updateBankAttribute), "PUT", + "/banks/BANK_ID/attributes/BANK_ATTRIBUTE_ID", + "Update Bank Attribute", + s"""Update Bank Attribute. + | + |${userAuthenticationMessage(true)}""", + bankAttributeJsonV400, bankAttributeDefinitionJsonV400, + List(UserHasMissingRoles, UnknownError), + List(apiTagBank, apiTagBankAttribute, apiTagAttribute), + Some(List(canUpdateBankAttribute)), + http4sPartialFunction = Some(updateBankAttribute)) + + staticResourceDocs += ResourceDoc( + null, implementedInApiVersion, nameOf(createCustomerAttribute), "POST", + "/banks/BANK_ID/customers/CUSTOMER_ID/attribute", + "Create Customer Attribute", + s"""Create Customer Attribute. + | + |${userAuthenticationMessage(true)}""", + customerAttributeJsonV400, customerAttributeResponseJson, + List($AuthenticatedUserIsRequired, $BankNotFound, InvalidJsonFormat, UnknownError), + List(apiTagCustomer, apiTagCustomerAttribute, apiTagAttribute), + Some(List(canCreateCustomerAttributeAtOneBank, canCreateCustomerAttributeAtAnyBank)), + http4sPartialFunction = Some(createCustomerAttribute)) + + staticResourceDocs += ResourceDoc( + null, implementedInApiVersion, nameOf(updateCustomerAttribute), "PUT", + "/banks/BANK_ID/customers/CUSTOMER_ID/attributes/CUSTOMER_ATTRIBUTE_ID", + "Update Customer Attribute", + s"""Update Customer Attribute. + | + |${userAuthenticationMessage(true)}""", + customerAttributeJsonV400, customerAttributeResponseJson, + List($AuthenticatedUserIsRequired, $BankNotFound, InvalidJsonFormat, UnknownError), + List(apiTagCustomer, apiTagCustomerAttribute, apiTagAttribute), + Some(List(canUpdateCustomerAttributeAtOneBank, canUpdateCustomerAttributeAtAnyBank)), + http4sPartialFunction = Some(updateCustomerAttribute)) + + staticResourceDocs += ResourceDoc( + null, implementedInApiVersion, nameOf(createTransactionAttribute), "POST", + "/banks/BANK_ID/accounts/ACCOUNT_ID/transactions/TRANSACTION_ID/attribute", + "Create Transaction Attribute", + s"""Create Transaction Attribute. + | + |${userAuthenticationMessage(true)}""", + transactionAttributeJsonV400, transactionAttributeResponseJson, + List($AuthenticatedUserIsRequired, $BankNotFound, $BankAccountNotFound, + InvalidJsonFormat, UnknownError), + List(apiTagTransaction, apiTagTransactionAttribute, apiTagAttribute), + Some(List(canCreateTransactionAttributeAtOneBank)), + http4sPartialFunction = Some(createTransactionAttribute)) + + staticResourceDocs += ResourceDoc( + null, implementedInApiVersion, nameOf(updateTransactionAttribute), "PUT", + "/banks/BANK_ID/accounts/ACCOUNT_ID/transactions/TRANSACTION_ID/attributes/ACCOUNT_ATTRIBUTE_ID", + "Update Transaction Attribute", + s"""Update Transaction Attribute. + | + |${userAuthenticationMessage(true)}""", + transactionAttributeJsonV400, transactionAttributeResponseJson, + List($AuthenticatedUserIsRequired, $BankNotFound, $BankAccountNotFound, + InvalidJsonFormat, UnknownError), + List(apiTagTransaction, apiTagTransactionAttribute, apiTagAttribute), + Some(List(canUpdateTransactionAttributeAtOneBank)), + http4sPartialFunction = Some(updateTransactionAttribute)) + + staticResourceDocs += ResourceDoc( + null, implementedInApiVersion, nameOf(createTransactionRequestAttribute), "POST", + "/banks/BANK_ID/accounts/ACCOUNT_ID/transaction-requests/TRANSACTION_REQUEST_ID/attribute", + "Create Transaction Request Attribute", + s"""Create Transaction Request Attribute. + | + |${userAuthenticationMessage(true)}""", + transactionRequestAttributeJsonV400, transactionRequestAttributeResponseJson, + List($AuthenticatedUserIsRequired, $BankNotFound, $BankAccountNotFound, + InvalidJsonFormat, UnknownError), + List(apiTagTransactionRequest, apiTagTransactionRequestAttribute, apiTagAttribute), + Some(List(canCreateTransactionRequestAttributeAtOneBank)), + http4sPartialFunction = Some(createTransactionRequestAttribute)) + + staticResourceDocs += ResourceDoc( + null, implementedInApiVersion, nameOf(updateTransactionRequestAttribute), "PUT", + "/banks/BANK_ID/accounts/ACCOUNT_ID/transaction-requests/TRANSACTION_REQUEST_ID/attributes/ATTRIBUTE_ID", + "Update Transaction Request Attribute", + s"""Update Transaction Request Attribute. + | + |${userAuthenticationMessage(true)}""", + transactionRequestAttributeJsonV400, transactionRequestAttributeResponseJson, + List($AuthenticatedUserIsRequired, $BankNotFound, $BankAccountNotFound, + InvalidJsonFormat, UnknownError), + List(apiTagTransactionRequest, apiTagTransactionRequestAttribute, apiTagAttribute), + Some(List(canUpdateTransactionRequestAttributeAtOneBank)), + http4sPartialFunction = Some(updateTransactionRequestAttribute)) + + staticResourceDocs += ResourceDoc( + null, implementedInApiVersion, nameOf(createProductFee), "POST", + "/banks/BANK_ID/products/PRODUCT_CODE/fee", + "Create Product Fee", + s"""Create Product Fee. + | + |${userAuthenticationMessage(true)}""", + productFeeJsonV400.copy(product_fee_id = None), productFeeResponseJsonV400, + List($AuthenticatedUserIsRequired, $BankNotFound, InvalidJsonFormat, UnknownError), + List(apiTagProduct), + Some(List(canCreateProductFee)), + http4sPartialFunction = Some(createProductFee)) + + staticResourceDocs += ResourceDoc( + null, implementedInApiVersion, nameOf(updateProductFee), "PUT", + "/banks/BANK_ID/products/PRODUCT_CODE/fees/PRODUCT_FEE_ID", + "Update Product Fee", + s"""Update Product Fee. + | + |${userAuthenticationMessage(true)}""", + productFeeJsonV400.copy(product_fee_id = None), productFeeResponseJsonV400, + List($AuthenticatedUserIsRequired, $BankNotFound, UserHasMissingRoles, UnknownError), + List(apiTagProduct), + Some(List(canUpdateProductFee)), + http4sPartialFunction = Some(updateProductFee)) + + staticResourceDocs += ResourceDoc( + null, implementedInApiVersion, nameOf(createMyPersonalUserAttribute), "POST", + "/my/user/attributes", + "Create My Personal User Attribute", + s"""Create My Personal User Attribute. + | + |${userAuthenticationMessage(true)}""", + userAttributeJsonV400, userAttributeResponseJson, + List($AuthenticatedUserIsRequired, InvalidJsonFormat, UnknownError), + List(apiTagUser), Some(List()), + http4sPartialFunction = Some(createMyPersonalUserAttribute)) + + staticResourceDocs += ResourceDoc( + null, implementedInApiVersion, nameOf(updateMyPersonalUserAttribute), "PUT", + "/my/user/attributes/USER_ATTRIBUTE_ID", + "Update My Personal User Attribute", + s"""Update My Personal User Attribute. + | + |${userAuthenticationMessage(true)}""", + userAttributeJsonV400, userAttributeResponseJson, + List($AuthenticatedUserIsRequired, InvalidJsonFormat, UnknownError), + List(apiTagUser), Some(List()), + http4sPartialFunction = Some(updateMyPersonalUserAttribute)) + } + initBatch10ResourceDocs() + + // ═══════════════════════════════════════════════════════════════════════════ + // Batch 11 — Account access, user invitations, consents, api collections + // ═══════════════════════════════════════════════════════════════════════════ + + lazy val getUserInvitationAnonymous: HttpRoutes[IO] = HttpRoutes.of[IO] { + case req @ POST -> `prefixPath` / "banks" / bankIdStr / "user-invitations" => + EndpointHelpers.executeFutureWithBodyCreated[PostUserInvitationAnonymousJsonV400, Any](req) { (postedData, cc) => + for { + (invitation, _) <- NewStyle.function.getUserInvitation( + BankId(bankIdStr), postedData.secret_key, Some(cc)) + _ <- code.util.Helper.booleanToFuture(CannotFindUserInvitation, 404, Some(cc)) { + invitation.status == "CREATED" + } + _ <- code.util.Helper.booleanToFuture(CannotFindUserInvitation, 404, Some(cc)) { + val validUntil = java.util.Calendar.getInstance + validUntil.setTime(invitation.createdAt.get) + validUntil.add(java.util.Calendar.HOUR, 24) + validUntil.getTime.after(new java.util.Date()) + } + } yield JSONFactory400.createUserInvitationJson(invitation) + } + } + + lazy val grantUserAccessToView: HttpRoutes[IO] = HttpRoutes.of[IO] { + case req @ POST -> `prefixPath` / "banks" / bankIdStr / "accounts" / accountIdStr / "account-access" / "grant" => + EndpointHelpers.withUserAndBodyCreated[PostAccountAccessJsonV400, Any](req) { (loggedInUser, postJson, cc) => + val bankId = BankId(bankIdStr) + val accountId = AccountId(accountIdStr) + for { + _ <- code.util.Helper.booleanToFuture( + UserLacksPermissionCanGrantAccessToViewForTargetAccount + + s"Current ViewId(${postJson.view.view_id}) and current UserId(${loggedInUser.userId})", + cc = Some(cc)) { + APIUtil.canGrantAccessToView(bankId, accountId, ViewId(postJson.view.view_id), loggedInUser, Some(cc)) + } + (targetUser, _) <- NewStyle.function.findByUserId(postJson.user_id, Some(cc)) + view <- JSONFactory400.getView(bankId, accountId, postJson.view, Some(cc)) + addedView <- JSONFactory400.grantAccountAccessToUser(bankId, accountId, targetUser, view, Some(cc)) + } yield code.api.v3_0_0.JSONFactory300.createViewJSON(addedView) + } + } + + lazy val revokeUserAccessToView: HttpRoutes[IO] = HttpRoutes.of[IO] { + case req @ POST -> `prefixPath` / "banks" / bankIdStr / "accounts" / accountIdStr / "account-access" / "revoke" => + EndpointHelpers.withUserAndBodyCreated[PostAccountAccessJsonV400, Any](req) { (loggedInUser, postJson, cc) => + val bankId = BankId(bankIdStr) + val accountId = AccountId(accountIdStr) + val viewId = ViewId(postJson.view.view_id) + for { + _ <- code.util.Helper.booleanToFuture( + UserLacksPermissionCanGrantAccessToViewForTargetAccount + + s"Current ViewId($viewId) and current UserId(${loggedInUser.userId})", + cc = Some(cc)) { + APIUtil.canRevokeAccessToView(bankId, accountId, viewId, loggedInUser, Some(cc)) + } + (targetUser, _) <- NewStyle.function.findByUserId(postJson.user_id, Some(cc)) + view <- if (postJson.view.is_system) + ViewNewStyle.systemView(viewId, Some(cc)) + else + ViewNewStyle.customView(viewId, BankIdAccountId(bankId, accountId), Some(cc)) + revoked <- if (postJson.view.is_system) + ViewNewStyle.revokeAccessToSystemView(bankId, accountId, view, targetUser, Some(cc)) + else + ViewNewStyle.revokeAccessToCustomView(view, targetUser, Some(cc)) + } yield RevokedJsonV400(revoked) + } + } + + lazy val revokeGrantUserAccessToViews: HttpRoutes[IO] = HttpRoutes.of[IO] { + case req @ PUT -> `prefixPath` / "banks" / bankIdStr / "accounts" / accountIdStr / "account-access" => + EndpointHelpers.withUserAndBodyCreated[PostRevokeGrantAccountAccessJsonV400, Any](req) { (loggedInUser, postJson, cc) => + val bankId = BankId(bankIdStr) + val accountId = AccountId(accountIdStr) + for { + _ <- code.util.Helper.booleanToFuture( + UserLacksPermissionCanGrantAccessToViewForTargetAccount + + s"Current ViewIds(${postJson.views.mkString}) and current UserId(${loggedInUser.userId})", + cc = Some(cc)) { + APIUtil.canRevokeAccessToAllViews(bankId, accountId, loggedInUser, Some(cc)) + } + _ <- Future( + Views.views.vend.revokeAccountAccessByUser(bankId, accountId, loggedInUser, Some(cc)) + ) map { box => unboxFullOrFail(box, Some(cc), "Cannot revoke") } + grantViews = postJson.views.map(viewIdStr => + BankIdAccountIdViewId(bankId, accountId, ViewId(viewIdStr))) + _ <- Future( + Views.views.vend.grantAccessToMultipleViews(grantViews, loggedInUser, Some(cc)) + ) map { box => + unboxFullOrFail(box, Some(cc), + s"Cannot grant the views: ${postJson.views.mkString(",")}") + } + } yield RevokedJsonV400(true) + } + } + + lazy val createMyApiCollection: HttpRoutes[IO] = HttpRoutes.of[IO] { + case req @ POST -> `prefixPath` / "my" / "api-collections" => + EndpointHelpers.withUserAndBodyCreated[PostApiCollectionJson400, Any](req) { (user, postJson, cc) => + for { + apiCollection <- Future { + code.apicollection.MappedApiCollectionsProvider + .getApiCollectionByUserIdAndCollectionName(user.userId, postJson.api_collection_name) + } + _ <- code.util.Helper.booleanToFuture( + s"$ApiCollectionAlreadyExists Current api_collection_name(${postJson.api_collection_name}) is already existing for the log in user.", + cc = Some(cc)) { + apiCollection.isEmpty + } + (created, _) <- NewStyle.function.createApiCollection( + user.userId, postJson.api_collection_name, postJson.is_sharable, + postJson.description.getOrElse(""), Some(cc)) + } yield JSONFactory400.createApiCollectionJsonV400(created) + } + } + + lazy val createMyApiCollectionEndpoint: HttpRoutes[IO] = HttpRoutes.of[IO] { + case req @ POST -> `prefixPath` / "my" / "api-collections" / apiCollectionName / "api-collection-endpoints" => + EndpointHelpers.withUserAndBodyCreated[PostApiCollectionEndpointJson400, Any](req) { (user, postJson, cc) => + for { + _ <- code.util.Helper.booleanToFuture( + s"$InvalidOperationId Current OPERATION_ID(${postJson.operation_id})", + cc = Some(cc)) { + getAllResourceDocs.find(_.operationId == postJson.operation_id.trim).isDefined + } + (apiCollection, _) <- NewStyle.function.getApiCollectionByUserIdAndCollectionName( + user.userId, apiCollectionName, Some(cc)) + existing <- Future { + code.apicollectionendpoint.MappedApiCollectionEndpointsProvider + .getApiCollectionEndpointByApiCollectionIdAndOperationId( + apiCollection.apiCollectionId, postJson.operation_id) + } + _ <- code.util.Helper.booleanToFuture( + s"$ApiCollectionEndpointAlreadyExists Current OPERATION_ID(${postJson.operation_id}) is already in API_COLLECTION_NAME($apiCollectionName) ", + cc = Some(cc)) { + existing.isEmpty + } + (apiCollectionEndpoint, _) <- NewStyle.function.createApiCollectionEndpoint( + apiCollection.apiCollectionId, postJson.operation_id, Some(cc)) + } yield JSONFactory400.createApiCollectionEndpointJsonV400(apiCollectionEndpoint) + } + } + + lazy val createMyApiCollectionEndpointById: HttpRoutes[IO] = HttpRoutes.of[IO] { + case req @ POST -> `prefixPath` / "my" / "api-collection-ids" / apiCollectionIdStr / "api-collection-endpoints" => + EndpointHelpers.withUserAndBodyCreated[PostApiCollectionEndpointJson400, Any](req) { (_, postJson, cc) => + for { + _ <- code.util.Helper.booleanToFuture( + s"$InvalidOperationId Current OPERATION_ID(${postJson.operation_id})", + cc = Some(cc)) { + getAllResourceDocs.find(_.operationId == postJson.operation_id.trim).isDefined + } + (apiCollection, _) <- NewStyle.function.getApiCollectionById(apiCollectionIdStr, Some(cc)) + existing <- Future { + code.apicollectionendpoint.MappedApiCollectionEndpointsProvider + .getApiCollectionEndpointByApiCollectionIdAndOperationId( + apiCollection.apiCollectionId, postJson.operation_id) + } + _ <- code.util.Helper.booleanToFuture( + s"$ApiCollectionEndpointAlreadyExists Current OPERATION_ID(${postJson.operation_id}) is already in API_COLLECTION_ID($apiCollectionIdStr) ", + cc = Some(cc)) { + existing.isEmpty + } + (apiCollectionEndpoint, _) <- NewStyle.function.createApiCollectionEndpoint( + apiCollection.apiCollectionId, postJson.operation_id, Some(cc)) + } yield JSONFactory400.createApiCollectionEndpointJsonV400(apiCollectionEndpoint) + } + } + + lazy val updateConsentStatus: HttpRoutes[IO] = HttpRoutes.of[IO] { + case req @ PUT -> `prefixPath` / "banks" / _ / "consents" / consentId => + EndpointHelpers.withUserAndBankAndBody[PutConsentStatusJsonV400, Any](req) { (_, _, consentJson, cc) => + for { + consent <- Future( + code.consent.Consents.consentProvider.vend.getConsentByConsentId(consentId) + ) map { box => connectorEmptyResponse(box, Some(cc)) } + status = code.consent.ConsentStatus.withName(consentJson.status) + updated <- APIUtil.getPropsAsBoolValue("consents.sca.enabled", true) match { + case true => + Future.successful(consent) + case false => + Future( + code.consent.Consents.consentProvider.vend.updateConsentStatus(consentId, status) + ) map { box => connectorEmptyResponse(box, Some(cc)) } + } + } yield code.api.v3_1_0.ConsentJsonV310(updated.consentId, updated.jsonWebToken, updated.status) + } + } + + lazy val addConsentUser: HttpRoutes[IO] = HttpRoutes.of[IO] { + case req @ PUT -> `prefixPath` / "banks" / _ / "consents" / consentId / "user-update-request" => + EndpointHelpers.withUserAndBankAndBody[PutConsentUserJsonV400, Any](req) { (_, _, putJson, cc) => + for { + user <- code.users.Users.users.vend.getUserByUserIdFuture(putJson.user_id) map { box => + unboxFullOrFail(box, Some(cc), + s"$UserNotFoundByUserId Current UserId(${putJson.user_id})") + } + consent <- Future( + code.consent.Consents.consentProvider.vend.getConsentByConsentId(consentId) + ) map { box => connectorEmptyResponse(box, Some(cc)) } + _ <- code.util.Helper.booleanToFuture(ConsentUserAlreadyAdded, cc = Some(cc)) { + Option(consent.userId).forall(_.isBlank) + } + updated <- Future( + code.consent.Consents.consentProvider.vend.updateConsentUser(consentId, user) + ) map { box => connectorEmptyResponse(box, Some(cc)) } + } yield code.api.v3_1_0.ConsentJsonV310(updated.consentId, updated.jsonWebToken, updated.status) + } + } + + private def initBatch11ResourceDocs(): Unit = { + staticResourceDocs += ResourceDoc( + null, implementedInApiVersion, nameOf(getUserInvitationAnonymous), "POST", + "/banks/BANK_ID/user-invitations", + "Get User Invitation (Anonymous)", + s"""Get User Invitation.""", + PostUserInvitationAnonymousJsonV400(1L), userInvitationJsonV400, + List($BankNotFound, InvalidJsonFormat, UnknownError), + List(apiTagUserInvitation), None, + http4sPartialFunction = Some(getUserInvitationAnonymous)) + + staticResourceDocs += ResourceDoc( + null, implementedInApiVersion, "grantUserAccessToView", "POST", + "/banks/BANK_ID/accounts/ACCOUNT_ID/account-access/grant", + "Grant User access to View", + s"""Grant User access to View. + | + |${userAuthenticationMessage(true)} and the user needs to be account holder.""", + postAccountAccessJsonV400, viewJsonV300, + List($AuthenticatedUserIsRequired, + UserLacksPermissionCanGrantAccessToViewForTargetAccount, + InvalidJsonFormat, UserNotFoundById, SystemViewNotFound, ViewNotFound, + CannotGrantAccountAccess, UnknownError), + List(apiTagAccountAccess, apiTagView, apiTagAccount, apiTagUser, apiTagOwnerRequired), None, + http4sPartialFunction = Some(grantUserAccessToView)) + + staticResourceDocs += ResourceDoc( + null, implementedInApiVersion, "revokeUserAccessToView", "POST", + "/banks/BANK_ID/accounts/ACCOUNT_ID/account-access/revoke", + "Revoke User access to View", + s"""Revoke User access to View. + | + |${userAuthenticationMessage(true)} and the user needs to be account holder.""", + postAccountAccessJsonV400, revokedJsonV400, + List($AuthenticatedUserIsRequired, + UserLacksPermissionCanRevokeAccessToViewForTargetAccount, + InvalidJsonFormat, UserNotFoundById, SystemViewNotFound, ViewNotFound, + CannotRevokeAccountAccess, CannotFindAccountAccess, UnknownError), + List(apiTagAccountAccess, apiTagView, apiTagAccount, apiTagUser, apiTagOwnerRequired), None, + http4sPartialFunction = Some(revokeUserAccessToView)) + + staticResourceDocs += ResourceDoc( + null, implementedInApiVersion, "revokeGrantUserAccessToViews", "PUT", + "/banks/BANK_ID/accounts/ACCOUNT_ID/account-access", + "Revoke/Grant User access to View", + s"""Revoke/Grant User access to View. + | + |${userAuthenticationMessage(true)} and the user needs to be an account holder or has owner view access.""", + postRevokeGrantAccountAccessJsonV400, revokedJsonV400, + List($AuthenticatedUserIsRequired, + UserLacksPermissionCanGrantAccessToViewForTargetAccount, + InvalidJsonFormat, UserNotFoundById, SystemViewNotFound, ViewNotFound, + CannotRevokeAccountAccess, CannotFindAccountAccess, UnknownError), + List(apiTagAccountAccess, apiTagView, apiTagAccount, apiTagUser, apiTagOwnerRequired), None, + http4sPartialFunction = Some(revokeGrantUserAccessToViews)) + + staticResourceDocs += ResourceDoc( + null, implementedInApiVersion, nameOf(createMyApiCollection), "POST", + "/my/api-collections", + "Create My Api Collection", + s"""Create My Api Collection. + | + |${userAuthenticationMessage(true)}""", + postApiCollectionJson400, apiCollectionJson400, + List($AuthenticatedUserIsRequired, InvalidJsonFormat, UserNotFoundByUserId, UnknownError), + List(apiTagApiCollection), None, + http4sPartialFunction = Some(createMyApiCollection)) + + staticResourceDocs += ResourceDoc( + null, implementedInApiVersion, nameOf(createMyApiCollectionEndpoint), "POST", + "/my/api-collections/API_COLLECTION_NAME/api-collection-endpoints", + "Create My Api Collection Endpoint", + s"""Create My Api Collection Endpoint. + | + |${userAuthenticationMessage(true)}""", + postApiCollectionEndpointJson400, apiCollectionEndpointJson400, + List($AuthenticatedUserIsRequired, InvalidJsonFormat, UnknownError), + List(apiTagApiCollection), None, + http4sPartialFunction = Some(createMyApiCollectionEndpoint)) + + staticResourceDocs += ResourceDoc( + null, implementedInApiVersion, nameOf(createMyApiCollectionEndpointById), "POST", + "/my/api-collection-ids/API_COLLECTION_ID/api-collection-endpoints", + "Create My Api Collection Endpoint By Id", + s"""Create My Api Collection Endpoint By Id. + | + |${userAuthenticationMessage(true)}""", + postApiCollectionEndpointJson400, apiCollectionEndpointJson400, + List($AuthenticatedUserIsRequired, InvalidJsonFormat, UnknownError), + List(apiTagApiCollection), None, + http4sPartialFunction = Some(createMyApiCollectionEndpointById)) + + staticResourceDocs += ResourceDoc( + null, implementedInApiVersion, nameOf(updateConsentStatus), "PUT", + "/banks/BANK_ID/consents/CONSENT_ID", + "Update Consent Status", + s"""Update Consent Status. + | + |${userAuthenticationMessage(true)}""", + PutConsentStatusJsonV400(status = "AUTHORISED"), + code.api.v3_1_0.ConsentChallengeJsonV310( + "9d429899-24f5-42c8-8565-943ffa6a7945", "...", "AUTHORISED"), + List($AuthenticatedUserIsRequired, $BankNotFound, InvalidJsonFormat, + InvalidConnectorResponse, UnknownError), + apiTagConsent :: apiTagPSD2AIS :: Nil, None, + http4sPartialFunction = Some(updateConsentStatus)) + + staticResourceDocs += ResourceDoc( + null, implementedInApiVersion, nameOf(addConsentUser), "PUT", + "/banks/BANK_ID/consents/CONSENT_ID/user-update-request", + "Add User to a Consent", + s"""Add User to a Consent. + | + |${userAuthenticationMessage(true)}""", + PutConsentUserJsonV400("uuid-user"), + code.api.v3_1_0.ConsentJsonV310("9d429899-24f5-42c8-8565-943ffa6a7945", "...", "AUTHORISED"), + List($AuthenticatedUserIsRequired, $BankNotFound, UserNotFoundByUserId, + ConsentUserAlreadyAdded, InvalidJsonFormat, ConsentNotFound, UnknownError), + apiTagConsent :: apiTagPSD2AIS :: Nil, None, + http4sPartialFunction = Some(addConsentUser)) + } + initBatch11ResourceDocs() + + // ═══════════════════════════════════════════════════════════════════════════ + // Batch 12 — direct debits, standing orders, webhooks, settlement account + // ═══════════════════════════════════════════════════════════════════════════ + + private def directDebitImpl(bankIdStr: String, accountIdStr: String, + postJson: PostDirectDebitJsonV400, cc: CallContext): Future[DirectDebitJsonV400] = { + for { + (_, _) <- NewStyle.function.getCustomerByCustomerId(postJson.customer_id, Some(cc)) + _ <- code.users.Users.users.vend.getUserByUserIdFuture(postJson.user_id) map { box => + unboxFullOrFail(box, Some(cc), s"$UserNotFoundByUserId Current UserId(${postJson.user_id})") + } + (_, _) <- NewStyle.function.getCounterpartyByCounterpartyId( + CounterpartyId(postJson.counterparty_id), Some(cc)) + (directDebit, _) <- NewStyle.function.createDirectDebit( + bankIdStr, accountIdStr, postJson.customer_id, postJson.user_id, + postJson.counterparty_id, + postJson.date_signed.getOrElse(new java.util.Date()), + postJson.date_starts, postJson.date_expires, Some(cc)) + } yield JSONFactory400.createDirectDebitJSON(directDebit) + } + + lazy val createDirectDebit: HttpRoutes[IO] = HttpRoutes.of[IO] { + case req @ POST -> `prefixPath` / "banks" / bankIdStr / "accounts" / accountIdStr / viewIdStr / "direct-debit" => + EndpointHelpers.withViewCreated[DirectDebitJsonV400](req) { (_, _, view, cc) => + implicit val ccx: CallContext = cc + val bodyStr = cc.httpBody.getOrElse("") + for { + _ <- code.util.Helper.booleanToFuture( + s"$NoViewPermission can_create_direct_debit. Current ViewId($viewIdStr)", + cc = Some(cc)) { + view.allowed_actions.exists(_ == code.api.Constant.CAN_CREATE_DIRECT_DEBIT) + } + postJson <- NewStyle.function.tryons( + s"$InvalidJsonFormat The Json body should be the ${classOf[PostDirectDebitJsonV400].getSimpleName} ", + 400, Some(cc)) { + net.liftweb.json.parse(bodyStr).extract[PostDirectDebitJsonV400] + } + result <- directDebitImpl(bankIdStr, accountIdStr, postJson, cc) + } yield result + } + } + + lazy val createDirectDebitManagement: HttpRoutes[IO] = HttpRoutes.of[IO] { + case req @ POST -> `prefixPath` / "management" / "banks" / bankIdStr / "accounts" / accountIdStr / "direct-debit" => + EndpointHelpers.withUserAndBankAndBodyCreated[PostDirectDebitJsonV400, Any](req) { (_, _, postJson, cc) => + directDebitImpl(bankIdStr, accountIdStr, postJson, cc) + } + } + + private def standingOrderImpl(bankIdStr: String, accountIdStr: String, + postJson: PostStandingOrderJsonV400, cc: CallContext): Future[StandingOrderJsonV400] = { + for { + amountValue <- NewStyle.function.tryons( + s"$InvalidNumber Current input is ${postJson.amount.amount} ", 400, Some(cc)) { + BigDecimal(postJson.amount.amount) + } + _ <- code.util.Helper.booleanToFuture( + s"${InvalidISOCurrencyCode} Current input is: '${postJson.amount.currency}'", + cc = Some(cc)) { + APIUtil.isValidCurrencyISOCode(postJson.amount.currency) + } + (_, _) <- NewStyle.function.getCustomerByCustomerId(postJson.customer_id, Some(cc)) + _ <- code.users.Users.users.vend.getUserByUserIdFuture(postJson.user_id) map { box => + unboxFullOrFail(box, Some(cc), s"$UserNotFoundByUserId Current UserId(${postJson.user_id})") + } + (_, _) <- NewStyle.function.getCounterpartyByCounterpartyId( + CounterpartyId(postJson.counterparty_id), Some(cc)) + (order, _) <- NewStyle.function.createStandingOrder( + bankIdStr, accountIdStr, postJson.customer_id, postJson.user_id, + postJson.counterparty_id, amountValue, postJson.amount.currency, + postJson.when.frequency, postJson.when.detail, + postJson.date_signed.getOrElse(new java.util.Date()), + postJson.date_starts, postJson.date_expires, Some(cc)) + } yield JSONFactory400.createStandingOrderJSON(order) + } + + lazy val createStandingOrder: HttpRoutes[IO] = HttpRoutes.of[IO] { + case req @ POST -> `prefixPath` / "banks" / bankIdStr / "accounts" / accountIdStr / viewIdStr / "standing-order" => + EndpointHelpers.withViewCreated[StandingOrderJsonV400](req) { (_, _, view, cc) => + val bodyStr = cc.httpBody.getOrElse("") + for { + _ <- code.util.Helper.booleanToFuture( + s"$NoViewPermission can_create_standing_order. Current ViewId($viewIdStr)", + cc = Some(cc)) { + view.allowed_actions.exists(_ == code.api.Constant.CAN_CREATE_STANDING_ORDER) + } + postJson <- NewStyle.function.tryons( + s"$InvalidJsonFormat The Json body should be the ${classOf[PostStandingOrderJsonV400].getSimpleName} ", + 400, Some(cc)) { + net.liftweb.json.parse(bodyStr).extract[PostStandingOrderJsonV400] + } + result <- standingOrderImpl(bankIdStr, accountIdStr, postJson, cc) + } yield result + } + } + + lazy val createStandingOrderManagement: HttpRoutes[IO] = HttpRoutes.of[IO] { + case req @ POST -> `prefixPath` / "management" / "banks" / bankIdStr / "accounts" / accountIdStr / "standing-order" => + EndpointHelpers.withUserAndBankAndBodyCreated[PostStandingOrderJsonV400, Any](req) { (_, _, postJson, cc) => + standingOrderImpl(bankIdStr, accountIdStr, postJson, cc) + } + } + + lazy val createSystemAccountNotificationWebhook: HttpRoutes[IO] = HttpRoutes.of[IO] { + case req @ POST -> `prefixPath` / "web-hooks" / "account" / "notifications" / "on-create-transaction" => + EndpointHelpers.withUserAndBodyCreated[AccountNotificationWebhookPostJson, Any](req) { (user, postJson, cc) => + for { + _ <- code.util.Helper.booleanToFuture( + s"$InvalidHttpMethod Only Support `POST` currently. Current value is (${postJson.http_method})", + cc = Some(cc)) { postJson.http_method.equals("POST") } + _ <- code.util.Helper.booleanToFuture( + s"$InvalidHttpProtocol Only Support `HTTP/1.1` currently. Current value is (${postJson.http_protocol})", + cc = Some(cc)) { postJson.http_protocol.equals("HTTP/1.1") } + onCreateTransaction = code.api.util.ApiTrigger.onCreateTransaction.toString() + wh <- code.webhook.SystemAccountNotificationWebhookTrait + .systemAccountNotificationWebhook.vend + .createSystemAccountNotificationWebhookFuture( + userId = user.userId, triggerName = onCreateTransaction, + url = postJson.url, httpMethod = postJson.http_method, + httpProtocol = postJson.http_protocol) map { + unboxFullOrFail(_, Some(cc), CreateWebhookError) + } + } yield createSystemLevelAccountWebhookJsonV400(wh) + } + } + + lazy val createBankAccountNotificationWebhook: HttpRoutes[IO] = HttpRoutes.of[IO] { + case req @ POST -> `prefixPath` / "banks" / bankIdStr / "web-hooks" / "account" / "notifications" / "on-create-transaction" => + EndpointHelpers.withUserAndBankAndBodyCreated[AccountNotificationWebhookPostJson, Any](req) { (user, bank, postJson, cc) => + for { + _ <- code.util.Helper.booleanToFuture( + s"$InvalidHttpMethod Only Support `POST` currently. Current value is (${postJson.http_method})", + cc = Some(cc)) { postJson.http_method.equals("POST") } + _ <- code.util.Helper.booleanToFuture( + s"$InvalidHttpProtocol Only Support `HTTP/1.1` currently. Current value is (${postJson.http_protocol})", + cc = Some(cc)) { postJson.http_protocol.equals("HTTP/1.1") } + onCreateTransaction = code.api.util.ApiTrigger.onCreateTransaction.toString() + wh <- code.webhook.BankAccountNotificationWebhookTrait + .bankAccountNotificationWebhook.vend + .createBankAccountNotificationWebhookFuture( + bankId = bank.bankId.value, userId = user.userId, + triggerName = onCreateTransaction, url = postJson.url, + httpMethod = postJson.http_method, + httpProtocol = postJson.http_protocol) map { + unboxFullOrFail(_, Some(cc), CreateWebhookError) + } + } yield createBankLevelAccountWebhookJsonV400(wh) + } + } + + lazy val getFastFirehoseAccountsAtOneBank: HttpRoutes[IO] = HttpRoutes.of[IO] { + case req @ GET -> `prefixPath` / "management" / "banks" / bankIdStr / "fast-firehose" / "accounts" => + EndpointHelpers.withUserAndBank(req) { (_, _, cc) => + for { + _ <- code.util.Helper.booleanToFuture( + AccountFirehoseNotAllowedOnThisInstance, cc = Some(cc)) { + allowAccountFirehose + } + allowedParams = List("limit", "offset", "sort_direction") + httpParams <- NewStyle.function.extractHttpParamsFromUrl(req.uri.renderString) + (obpQueryParams, _) <- NewStyle.function.createObpParams( + httpParams, allowedParams, Some(cc)) + (firehoseAccounts, _) <- NewStyle.function.getBankAccountsWithAttributes( + BankId(bankIdStr), obpQueryParams, Some(cc)) + } yield JSONFactory400.createFirehoseBankAccountJSON(firehoseAccounts) + } + } + + private def initBatch12ResourceDocs(): Unit = { + staticResourceDocs += ResourceDoc( + null, implementedInApiVersion, nameOf(createDirectDebit), "POST", + "/banks/BANK_ID/accounts/ACCOUNT_ID/VIEW_ID/direct-debit", + "Create Direct Debit", + s"""Create direct debit for an account.""", + postDirectDebitJsonV400, directDebitJsonV400, + List($AuthenticatedUserIsRequired, $BankNotFound, $BankAccountNotFound, + NoViewPermission, InvalidJsonFormat, CustomerNotFoundByCustomerId, + UserNotFoundByUserId, CounterpartyNotFoundByCounterpartyId, UnknownError), + List(apiTagDirectDebit, apiTagAccount), None, + http4sPartialFunction = Some(createDirectDebit)) + + staticResourceDocs += ResourceDoc( + null, implementedInApiVersion, nameOf(createDirectDebitManagement), "POST", + "/management/banks/BANK_ID/accounts/ACCOUNT_ID/direct-debit", + "Create Direct Debit (management)", + s"""Create direct debit for an account.""", + postDirectDebitJsonV400, directDebitJsonV400, + List($AuthenticatedUserIsRequired, $BankNotFound, $BankAccountNotFound, + NoViewPermission, InvalidJsonFormat, CustomerNotFoundByCustomerId, + UserNotFoundByUserId, CounterpartyNotFoundByCounterpartyId, UnknownError), + List(apiTagDirectDebit, apiTagAccount), + Some(List(canCreateDirectDebitAtOneBank)), + http4sPartialFunction = Some(createDirectDebitManagement)) + + staticResourceDocs += ResourceDoc( + null, implementedInApiVersion, nameOf(createStandingOrder), "POST", + "/banks/BANK_ID/accounts/ACCOUNT_ID/VIEW_ID/standing-order", + "Create Standing Order", + s"""Create standing order for an account.""", + postStandingOrderJsonV400, standingOrderJsonV400, + List($AuthenticatedUserIsRequired, $BankNotFound, $BankAccountNotFound, + NoViewPermission, InvalidJsonFormat, InvalidNumber, InvalidISOCurrencyCode, + CustomerNotFoundByCustomerId, UserNotFoundByUserId, UnknownError), + List(apiTagStandingOrder, apiTagAccount), None, + http4sPartialFunction = Some(createStandingOrder)) + + staticResourceDocs += ResourceDoc( + null, implementedInApiVersion, nameOf(createStandingOrderManagement), "POST", + "/management/banks/BANK_ID/accounts/ACCOUNT_ID/standing-order", + "Create Standing Order (management)", + s"""Create standing order for an account.""", + postStandingOrderJsonV400, standingOrderJsonV400, + List($AuthenticatedUserIsRequired, $BankNotFound, $BankAccountNotFound, + NoViewPermission, InvalidJsonFormat, InvalidNumber, InvalidISOCurrencyCode, + CustomerNotFoundByCustomerId, UserNotFoundByUserId, UnknownError), + List(apiTagStandingOrder, apiTagAccount), + Some(List(canCreateStandingOrderAtOneBank)), + http4sPartialFunction = Some(createStandingOrderManagement)) + + staticResourceDocs += ResourceDoc( + null, implementedInApiVersion, nameOf(createSystemAccountNotificationWebhook), "POST", + "/web-hooks/account/notifications/on-create-transaction", + "Create System Level Account Notification Webhook", + s"""Create System Level Account Notification Webhook.""", + accountNotificationWebhookPostJson, systemAccountNotificationWebhookJson, + List(UnknownError), + apiTagWebhook :: apiTagBank :: Nil, + Some(List(canCreateSystemAccountNotificationWebhook)), + http4sPartialFunction = Some(createSystemAccountNotificationWebhook)) + + staticResourceDocs += ResourceDoc( + null, implementedInApiVersion, nameOf(createBankAccountNotificationWebhook), "POST", + "/banks/BANK_ID/web-hooks/account/notifications/on-create-transaction", + "Create bank level Account Notification Webhook", + s"""Create bank level Account Notification Webhook.""", + accountNotificationWebhookPostJson, bankAccountNotificationWebhookJson, + List(AuthenticatedUserIsRequired, $BankNotFound, UnknownError), + apiTagWebhook :: apiTagBank :: Nil, + Some(List(canCreateAccountNotificationWebhookAtOneBank)), + http4sPartialFunction = Some(createBankAccountNotificationWebhook)) + + staticResourceDocs += ResourceDoc( + null, implementedInApiVersion, nameOf(getFastFirehoseAccountsAtOneBank), "GET", + "/management/banks/BANK_ID/fast-firehose/accounts", + "Get Fast Firehose Accounts at Bank", + s"""Get Fast Firehose Accounts at Bank.""", + EmptyBody, fastFirehoseAccountsJsonV400, + List($BankNotFound), + List(apiTagAccount, apiTagAccountFirehose, apiTagFirehoseData), + Some(List(canUseAccountFirehoseAtAnyBank, code.api.util.ApiRole.canUseAccountFirehose)), + http4sPartialFunction = Some(getFastFirehoseAccountsAtOneBank)) + } + initBatch12ResourceDocs() + + // ═══════════════════════════════════════════════════════════════════════════ + // Batch 7 — createOrUpdate Attribute Definitions + // ═══════════════════════════════════════════════════════════════════════════ + + private def createOrUpdateAttributeDefinitionImpl( + bankIdStr: String, + expectedCategory: com.openbankproject.commons.model.enums.AttributeCategory.Value, + postedData: AttributeDefinitionJsonV400, + cc: CallContext): Future[code.api.v4_0_0.AttributeDefinitionResponseJsonV400] = { + for { + attributeType <- NewStyle.function.tryons( + s"$InvalidJsonFormat The `Type` field can only accept the following field: " + + s"${com.openbankproject.commons.model.enums.AttributeType.DOUBLE}(12.1234), ${com.openbankproject.commons.model.enums.AttributeType.STRING}(TAX_NUMBER), ${com.openbankproject.commons.model.enums.AttributeType.INTEGER} (123)and ${com.openbankproject.commons.model.enums.AttributeType.DATE_WITH_DAY}(2012-04-23)", + 400, Some(cc)) { + com.openbankproject.commons.model.enums.AttributeType.withName(postedData.`type`) + } + category <- NewStyle.function.tryons( + s"$InvalidJsonFormat The `Category` field can only accept the following field: $expectedCategory", + 400, Some(cc)) { + val c = com.openbankproject.commons.model.enums.AttributeCategory.withName(postedData.category) + if (c != expectedCategory) throw new IllegalArgumentException(s"Expected category $expectedCategory") + c + } + (attributeDefinition, _) <- code.api.util.newstyle.AttributeDefinition.createOrUpdateAttributeDefinition( + BankId(bankIdStr), postedData.name, category, attributeType, + postedData.description, postedData.alias, postedData.can_be_seen_on_views, + postedData.is_active, Some(cc)) + } yield JSONFactory400.createAttributeDefinitionJson(attributeDefinition) + } + + lazy val createOrUpdateCustomerAttributeAttributeDefinition: HttpRoutes[IO] = HttpRoutes.of[IO] { + case req @ PUT -> `prefixPath` / "banks" / bankIdStr / "attribute-definitions" / "customer" => + EndpointHelpers.withUserAndBankAndBodyCreated[AttributeDefinitionJsonV400, Any](req) { (_, _, postedData, cc) => + createOrUpdateAttributeDefinitionImpl(bankIdStr, + com.openbankproject.commons.model.enums.AttributeCategory.Customer, postedData, cc) + } + } + + lazy val createOrUpdateAccountAttributeDefinition: HttpRoutes[IO] = HttpRoutes.of[IO] { + case req @ PUT -> `prefixPath` / "banks" / bankIdStr / "attribute-definitions" / "account" => + EndpointHelpers.withUserAndBankAndBodyCreated[AttributeDefinitionJsonV400, Any](req) { (_, _, postedData, cc) => + createOrUpdateAttributeDefinitionImpl(bankIdStr, + com.openbankproject.commons.model.enums.AttributeCategory.Account, postedData, cc) + } + } + + lazy val createOrUpdateProductAttributeDefinition: HttpRoutes[IO] = HttpRoutes.of[IO] { + case req @ PUT -> `prefixPath` / "banks" / bankIdStr / "attribute-definitions" / "product" => + EndpointHelpers.withUserAndBankAndBodyCreated[AttributeDefinitionJsonV400, Any](req) { (_, _, postedData, cc) => + createOrUpdateAttributeDefinitionImpl(bankIdStr, + com.openbankproject.commons.model.enums.AttributeCategory.Product, postedData, cc) + } + } + + lazy val createOrUpdateTransactionAttributeDefinition: HttpRoutes[IO] = HttpRoutes.of[IO] { + case req @ PUT -> `prefixPath` / "banks" / bankIdStr / "attribute-definitions" / "transaction" => + EndpointHelpers.withUserAndBankAndBodyCreated[AttributeDefinitionJsonV400, Any](req) { (_, _, postedData, cc) => + createOrUpdateAttributeDefinitionImpl(bankIdStr, + com.openbankproject.commons.model.enums.AttributeCategory.Transaction, postedData, cc) + } + } + + lazy val createOrUpdateCardAttributeDefinition: HttpRoutes[IO] = HttpRoutes.of[IO] { + case req @ PUT -> `prefixPath` / "banks" / bankIdStr / "attribute-definitions" / "card" => + EndpointHelpers.withUserAndBankAndBodyCreated[AttributeDefinitionJsonV400, Any](req) { (_, _, postedData, cc) => + createOrUpdateAttributeDefinitionImpl(bankIdStr, + com.openbankproject.commons.model.enums.AttributeCategory.Card, postedData, cc) + } + } + + lazy val createOrUpdateBankAttributeDefinition: HttpRoutes[IO] = HttpRoutes.of[IO] { + case req @ PUT -> `prefixPath` / "banks" / bankIdStr / "attribute-definitions" / "bank" => + EndpointHelpers.withUserAndBankAndBodyCreated[AttributeDefinitionJsonV400, Any](req) { (user, _, postedData, cc) => + createOrUpdateAttributeDefinitionImpl(bankIdStr, + com.openbankproject.commons.model.enums.AttributeCategory.Bank, postedData, cc) + } + } + + private def initBatch7ResourceDocs(): Unit = { + staticResourceDocs += ResourceDoc( + null, implementedInApiVersion, nameOf(createOrUpdateCustomerAttributeAttributeDefinition), "PUT", + "/banks/BANK_ID/attribute-definitions/customer", + "Create or Update Customer Attribute Definition", + s"""Create or Update Customer Attribute Definition.""".stripMargin, + customerAttributeDefinitionJsonV400, customerAttributeDefinitionResponseJsonV400, + List($AuthenticatedUserIsRequired, $BankNotFound, InvalidJsonFormat, UnknownError), + List(apiTagCustomer, apiTagCustomerAttribute, apiTagAttribute), + Some(List(canCreateCustomerAttributeDefinitionAtOneBank)), + http4sPartialFunction = Some(createOrUpdateCustomerAttributeAttributeDefinition)) + + staticResourceDocs += ResourceDoc( + null, implementedInApiVersion, nameOf(createOrUpdateAccountAttributeDefinition), "PUT", + "/banks/BANK_ID/attribute-definitions/account", + "Create or Update Account Attribute Definition", + s"""Create or Update Account Attribute Definition.""".stripMargin, + accountAttributeDefinitionJsonV400, accountAttributeDefinitionResponseJsonV400, + List($AuthenticatedUserIsRequired, $BankNotFound, InvalidJsonFormat, UnknownError), + List(apiTagAccount, apiTagAccountAttribute, apiTagAttribute), + Some(List(canCreateAccountAttributeDefinitionAtOneBank)), + http4sPartialFunction = Some(createOrUpdateAccountAttributeDefinition)) + + staticResourceDocs += ResourceDoc( + null, implementedInApiVersion, nameOf(createOrUpdateProductAttributeDefinition), "PUT", + "/banks/BANK_ID/attribute-definitions/product", + "Create or Update Product Attribute Definition", + s"""Create or Update Product Attribute Definition.""".stripMargin, + productAttributeDefinitionJsonV400, productAttributeDefinitionResponseJsonV400, + List($AuthenticatedUserIsRequired, $BankNotFound, InvalidJsonFormat, UnknownError), + List(apiTagProduct, apiTagProductAttribute, apiTagAttribute), + Some(List(canCreateProductAttributeDefinitionAtOneBank)), + http4sPartialFunction = Some(createOrUpdateProductAttributeDefinition)) + + staticResourceDocs += ResourceDoc( + null, implementedInApiVersion, nameOf(createOrUpdateTransactionAttributeDefinition), "PUT", + "/banks/BANK_ID/attribute-definitions/transaction", + "Create or Update Transaction Attribute Definition", + s"""Create or Update Transaction Attribute Definition.""".stripMargin, + transactionAttributeDefinitionJsonV400, transactionAttributeDefinitionResponseJsonV400, + List($AuthenticatedUserIsRequired, $BankNotFound, InvalidJsonFormat, UnknownError), + List(apiTagTransaction, apiTagTransactionAttribute, apiTagAttribute), + Some(List(canCreateTransactionAttributeDefinitionAtOneBank)), + http4sPartialFunction = Some(createOrUpdateTransactionAttributeDefinition)) + + staticResourceDocs += ResourceDoc( + null, implementedInApiVersion, nameOf(createOrUpdateCardAttributeDefinition), "PUT", + "/banks/BANK_ID/attribute-definitions/card", + "Create or Update Card Attribute Definition", + s"""Create or Update Card Attribute Definition.""".stripMargin, + cardAttributeDefinitionJsonV400, cardAttributeDefinitionResponseJsonV400, + List($AuthenticatedUserIsRequired, $BankNotFound, InvalidJsonFormat, UnknownError), + List(apiTagCard, apiTagCardAttribute, apiTagAttribute), + Some(List(canCreateCardAttributeDefinitionAtOneBank)), + http4sPartialFunction = Some(createOrUpdateCardAttributeDefinition)) + + staticResourceDocs += ResourceDoc( + null, implementedInApiVersion, nameOf(createOrUpdateBankAttributeDefinition), "PUT", + "/banks/BANK_ID/attribute-definitions/bank", + "Create or Update Bank Attribute Definition", + s"""Create or Update Bank Attribute Definition.""".stripMargin, + bankAttributeDefinitionJsonV400, bankAttributeDefinitionResponseJsonV400, + List($AuthenticatedUserIsRequired, $BankNotFound, InvalidJsonFormat, UnknownError), + List(apiTagBank, apiTagBankAttribute, apiTagAttribute), + Some(List(canCreateBankAttributeDefinitionAtOneBank)), + http4sPartialFunction = Some(createOrUpdateBankAttributeDefinition)) + } + initBatch7ResourceDocs() + + // ═══════════════════════════════════════════════════════════════════════════ + // Batch 6 — ATM updates (PUT) and other simple PUTs + // ═══════════════════════════════════════════════════════════════════════════ + + lazy val updateAtmSupportedCurrencies: HttpRoutes[IO] = HttpRoutes.of[IO] { + case req @ PUT -> `prefixPath` / "banks" / _ / "atms" / atmIdStr / "supported-currencies" => + EndpointHelpers.withUserAndBankAndBodyCreated[SupportedCurrenciesJson, Any](req) { (_, bank, postedData, cc) => + for { + (_, _) <- NewStyle.function.getAtm(bank.bankId, AtmId(atmIdStr), Some(cc)) + (atm, _) <- NewStyle.function.updateAtmSupportedCurrencies( + bank.bankId, AtmId(atmIdStr), postedData.supported_currencies, Some(cc)) + } yield AtmSupportedCurrenciesJson(atm.atmId.value, atm.supportedCurrencies.getOrElse(Nil)) + } + } + + lazy val updateAtmSupportedLanguages: HttpRoutes[IO] = HttpRoutes.of[IO] { + case req @ PUT -> `prefixPath` / "banks" / _ / "atms" / atmIdStr / "supported-languages" => + EndpointHelpers.withUserAndBankAndBodyCreated[SupportedLanguagesJson, Any](req) { (_, bank, postedData, cc) => + for { + (_, _) <- NewStyle.function.getAtm(bank.bankId, AtmId(atmIdStr), Some(cc)) + (atm, _) <- NewStyle.function.updateAtmSupportedLanguages( + bank.bankId, AtmId(atmIdStr), postedData.supported_languages, Some(cc)) + } yield AtmSupportedLanguagesJson(atm.atmId.value, atm.supportedLanguages.getOrElse(Nil)) + } + } + + lazy val updateAtmAccessibilityFeatures: HttpRoutes[IO] = HttpRoutes.of[IO] { + case req @ PUT -> `prefixPath` / "banks" / _ / "atms" / atmIdStr / "accessibility-features" => + EndpointHelpers.withUserAndBankAndBodyCreated[AccessibilityFeaturesJson, Any](req) { (_, bank, postedData, cc) => + for { + (_, _) <- NewStyle.function.getAtm(bank.bankId, AtmId(atmIdStr), Some(cc)) + (atm, _) <- NewStyle.function.updateAtmAccessibilityFeatures( + bank.bankId, AtmId(atmIdStr), postedData.accessibility_features, Some(cc)) + } yield AtmAccessibilityFeaturesJson(atm.atmId.value, atm.accessibilityFeatures.getOrElse(Nil)) + } + } + + lazy val updateAtmServices: HttpRoutes[IO] = HttpRoutes.of[IO] { + case req @ PUT -> `prefixPath` / "banks" / _ / "atms" / atmIdStr / "services" => + EndpointHelpers.withUserAndBankAndBodyCreated[AtmServicesJsonV400, Any](req) { (_, bank, postedData, cc) => + for { + (_, _) <- NewStyle.function.getAtm(bank.bankId, AtmId(atmIdStr), Some(cc)) + (atm, _) <- NewStyle.function.updateAtmServices( + bank.bankId, AtmId(atmIdStr), postedData.services, Some(cc)) + } yield AtmServicesResponseJsonV400(atm.atmId.value, atm.services.getOrElse(Nil)) + } + } + + lazy val updateAtmNotes: HttpRoutes[IO] = HttpRoutes.of[IO] { + case req @ PUT -> `prefixPath` / "banks" / _ / "atms" / atmIdStr / "notes" => + EndpointHelpers.withUserAndBankAndBodyCreated[AtmNotesJsonV400, Any](req) { (_, bank, postedData, cc) => + for { + (_, _) <- NewStyle.function.getAtm(bank.bankId, AtmId(atmIdStr), Some(cc)) + (atm, _) <- NewStyle.function.updateAtmNotes( + bank.bankId, AtmId(atmIdStr), postedData.notes, Some(cc)) + } yield AtmServicesResponseJsonV400(atm.atmId.value, atm.notes.getOrElse(Nil)) + } + } + + lazy val updateAtmLocationCategories: HttpRoutes[IO] = HttpRoutes.of[IO] { + case req @ PUT -> `prefixPath` / "banks" / _ / "atms" / atmIdStr / "location-categories" => + EndpointHelpers.withUserAndBankAndBodyCreated[AtmLocationCategoriesJsonV400, Any](req) { (_, bank, postedData, cc) => + for { + (_, _) <- NewStyle.function.getAtm(bank.bankId, AtmId(atmIdStr), Some(cc)) + (atm, _) <- NewStyle.function.updateAtmLocationCategories( + bank.bankId, AtmId(atmIdStr), postedData.location_categories, Some(cc)) + } yield AtmLocationCategoriesResponseJsonV400(atm.atmId.value, atm.locationCategories.getOrElse(Nil)) + } + } + + lazy val updateAtm: HttpRoutes[IO] = HttpRoutes.of[IO] { + case req @ PUT -> `prefixPath` / "banks" / _ / "atms" / atmIdStr => + EndpointHelpers.withUserAndBankAndBodyCreated[AtmJsonV400, Any](req) { (_, bank, atmJsonV400, cc) => + for { + (_, _) <- NewStyle.function.getAtm(bank.bankId, AtmId(atmIdStr), Some(cc)) + _ <- code.util.Helper.booleanToFuture( + s"$InvalidJsonValue BANK_ID has to be the same in the URL and Body", + failCode = 400, cc = Some(cc)) { atmJsonV400.bank_id == bank.bankId.value } + atm <- NewStyle.function.tryons( + CouldNotTransformJsonToInternalModel + " Atm", 400, Some(cc)) { + JSONFactory400.transformToAtmFromV400(atmJsonV400.copy(id = Some(atmIdStr))) + } + (created, _) <- NewStyle.function.createOrUpdateAtm(atm, Some(cc)) + } yield JSONFactory400.createAtmJsonV400(created) + } + } + + private def initBatch6ResourceDocs(): Unit = { + staticResourceDocs += ResourceDoc( + null, implementedInApiVersion, nameOf(updateAtmSupportedCurrencies), "PUT", + "/banks/BANK_ID/atms/ATM_ID/supported-currencies", + "Update ATM Supported Currencies", + s"""Update ATM Supported Currencies.""".stripMargin, + supportedCurrenciesJson, atmSupportedCurrenciesJson, + List($AuthenticatedUserIsRequired, InvalidJsonFormat, UnknownError), + List(apiTagATM), None, + http4sPartialFunction = Some(updateAtmSupportedCurrencies)) + + staticResourceDocs += ResourceDoc( + null, implementedInApiVersion, nameOf(updateAtmSupportedLanguages), "PUT", + "/banks/BANK_ID/atms/ATM_ID/supported-languages", + "Update ATM Supported Languages", + s"""Update ATM Supported Languages.""".stripMargin, + supportedLanguagesJson, atmSupportedLanguagesJson, + List($AuthenticatedUserIsRequired, InvalidJsonFormat, UnknownError), + List(apiTagATM), None, + http4sPartialFunction = Some(updateAtmSupportedLanguages)) + + staticResourceDocs += ResourceDoc( + null, implementedInApiVersion, nameOf(updateAtmAccessibilityFeatures), "PUT", + "/banks/BANK_ID/atms/ATM_ID/accessibility-features", + "Update ATM Accessibility Features", + s"""Update ATM Accessibility Features.""".stripMargin, + accessibilityFeaturesJson, atmAccessibilityFeaturesJson, + List($AuthenticatedUserIsRequired, InvalidJsonFormat, UnknownError), + List(apiTagATM), None, + http4sPartialFunction = Some(updateAtmAccessibilityFeatures)) + + staticResourceDocs += ResourceDoc( + null, implementedInApiVersion, nameOf(updateAtmServices), "PUT", + "/banks/BANK_ID/atms/ATM_ID/services", + "Update ATM Services", + s"""Update ATM Services.""".stripMargin, + atmServicesJson, atmServicesResponseJson, + List($AuthenticatedUserIsRequired, InvalidJsonFormat, UnknownError), + List(apiTagATM), None, + http4sPartialFunction = Some(updateAtmServices)) + + staticResourceDocs += ResourceDoc( + null, implementedInApiVersion, nameOf(updateAtmNotes), "PUT", + "/banks/BANK_ID/atms/ATM_ID/notes", + "Update ATM Notes", + s"""Update ATM Notes.""".stripMargin, + atmNotesJson, atmNotesResponseJson, + List($AuthenticatedUserIsRequired, InvalidJsonFormat, UnknownError), + List(apiTagATM), None, + http4sPartialFunction = Some(updateAtmNotes)) + + staticResourceDocs += ResourceDoc( + null, implementedInApiVersion, nameOf(updateAtmLocationCategories), "PUT", + "/banks/BANK_ID/atms/ATM_ID/location-categories", + "Update ATM Location Categories", + s"""Update ATM Location Categories.""".stripMargin, + atmLocationCategoriesJsonV400, atmLocationCategoriesResponseJsonV400, + List($AuthenticatedUserIsRequired, InvalidJsonFormat, UnknownError), + List(apiTagATM), None, + http4sPartialFunction = Some(updateAtmLocationCategories)) + + staticResourceDocs += ResourceDoc( + null, implementedInApiVersion, nameOf(updateAtm), "PUT", + "/banks/BANK_ID/atms/ATM_ID", + "Update ATM", + s"""Update ATM.""".stripMargin, + atmJsonV400, atmJsonV400, + List($AuthenticatedUserIsRequired, InvalidJsonFormat, UnknownError), + List(apiTagATM), + Some(List(canUpdateAtm, canCreateAtmAtAnyBank)), + http4sPartialFunction = Some(updateAtm)) + } + initBatch6ResourceDocs() + + // ═══════════════════════════════════════════════════════════════════════════ + // Batch 5 — More simple endpoints + // ═══════════════════════════════════════════════════════════════════════════ + + lazy val getProductFee: HttpRoutes[IO] = HttpRoutes.of[IO] { + case req @ GET -> `prefixPath` / "banks" / _ / "products" / _ / "fees" / productFeeId => + EndpointHelpers.executeAndRespond(req) { cc => + for { + (productFee, _) <- NewStyle.function.getProductFeeById(productFeeId, Some(cc)) + } yield JSONFactory400.createProductFeeJson(productFee) + } + } + + lazy val getProductFees: HttpRoutes[IO] = HttpRoutes.of[IO] { + case req @ GET -> `prefixPath` / "banks" / bankIdStr / "products" / productCodeStr / "fees" => + EndpointHelpers.executeAndRespond(req) { cc => + for { + (productFees, _) <- NewStyle.function.getProductFeesFromProvider( + BankId(bankIdStr), ProductCode(productCodeStr), Some(cc)) + } yield JSONFactory400.createProductFeesJson(productFees) + } + } + + lazy val getTransactionAttributes: HttpRoutes[IO] = HttpRoutes.of[IO] { + case req @ GET -> `prefixPath` / "banks" / _ / "accounts" / _ / "transactions" / transactionIdStr / "attributes" => + EndpointHelpers.withBankAccount(req) { (_, account, cc) => + for { + (_, _) <- NewStyle.function.getTransaction( + account.bankId, account.accountId, TransactionId(transactionIdStr), Some(cc)) + (attrs, _) <- NewStyle.function.getTransactionAttributes( + account.bankId, TransactionId(transactionIdStr), Some(cc)) + } yield JSONFactory400.createTransactionAttributesJson(attrs) + } + } + + lazy val getTransactionAttributeById: HttpRoutes[IO] = HttpRoutes.of[IO] { + case req @ GET -> `prefixPath` / "banks" / _ / "accounts" / _ / "transactions" / transactionIdStr / "attributes" / transactionAttributeId => + EndpointHelpers.withBankAccount(req) { (_, account, cc) => + for { + (_, _) <- NewStyle.function.getTransaction( + account.bankId, account.accountId, TransactionId(transactionIdStr), Some(cc)) + (attr, _) <- NewStyle.function.getTransactionAttributeById(transactionAttributeId, Some(cc)) + } yield JSONFactory400.createTransactionAttributeJson(attr) + } + } + + lazy val getTransactionRequestAttributes: HttpRoutes[IO] = HttpRoutes.of[IO] { + case req @ GET -> `prefixPath` / "banks" / _ / "accounts" / _ / "transaction-requests" / transactionRequestIdStr / "attributes" => + EndpointHelpers.withBankAccount(req) { (_, account, cc) => + for { + (_, _) <- NewStyle.function.getTransactionRequestImpl( + TransactionRequestId(transactionRequestIdStr), Some(cc)) + (attrs, _) <- NewStyle.function.getTransactionRequestAttributes( + account.bankId, TransactionRequestId(transactionRequestIdStr), Some(cc)) + } yield JSONFactory400.createTransactionRequestAttributesJson(attrs) + } + } + + lazy val getTransactionRequestAttributeById: HttpRoutes[IO] = HttpRoutes.of[IO] { + case req @ GET -> `prefixPath` / "banks" / _ / "accounts" / _ / "transaction-requests" / transactionRequestIdStr / "attributes" / transactionRequestAttributeId => + EndpointHelpers.withBankAccount(req) { (_, _, cc) => + for { + (_, _) <- NewStyle.function.getTransactionRequestImpl( + TransactionRequestId(transactionRequestIdStr), Some(cc)) + (attr, _) <- NewStyle.function.getTransactionRequestAttributeById( + transactionRequestAttributeId, Some(cc)) + } yield JSONFactory400.createTransactionRequestAttributeJson(attr) + } + } + + lazy val getTransactionRequestAttributeDefinition: HttpRoutes[IO] = HttpRoutes.of[IO] { + case req @ GET -> `prefixPath` / "banks" / _ / "attribute-definitions" / "transaction-request" => + EndpointHelpers.withBank(req) { (_, cc) => + getAttributeDefinitionImpl( + com.openbankproject.commons.model.enums.AttributeCategory.TransactionRequest, cc) + } + } + + lazy val getTransactionRequest: HttpRoutes[IO] = HttpRoutes.of[IO] { + case req @ GET -> `prefixPath` / "banks" / _ / "accounts" / _ / _ / "transaction-requests" / transactionRequestIdStr => + EndpointHelpers.withView(req) { (user, account, view, cc) => + for { + (transactionRequest, _) <- NewStyle.function.getTransactionRequestImpl( + TransactionRequestId(transactionRequestIdStr), Some(cc)) + } yield code.api.v2_1_0.JSONFactory210.createTransactionRequestWithChargeJSON(transactionRequest) + } + } + + lazy val getMyCorrelatedEntities: HttpRoutes[IO] = HttpRoutes.of[IO] { + case req @ GET -> `prefixPath` / "my" / "correlated-entities" => + EndpointHelpers.withUser(req) { (user, cc) => + for { + (userCustomerLinks, _) <- code.api.util.newstyle.UserCustomerLinkNewStyle + .getUserCustomerLinksByUserId(user.userId, Some(cc)) + correlatedInfo <- Future.sequence(userCustomerLinks.map { link => + for { + (customer, _) <- NewStyle.function.getCustomerByCustomerId(link.customerId, Some(cc)) + (ucls, _) <- code.api.util.newstyle.UserCustomerLinkNewStyle + .getUserCustomerLinks(link.customerId, Some(cc)) + (users, _) <- NewStyle.function.getUsersByUserIds(ucls.map(_.userId), Some(cc)) + (attributes, _) <- NewStyle.function.getUserAttributesByUsers(ucls.map(_.userId), Some(cc)) + } yield (customer, users, attributes) + }) + } yield CorrelatedEntities( + correlatedInfo.map { case (c, us, attrs) => + JSONFactory400.createCustomerAdUsersWithAttributesJson(c, us, attrs) + }) + } + } + + lazy val getCorrelatedUsersInfoByCustomerId: HttpRoutes[IO] = HttpRoutes.of[IO] { + case req @ GET -> `prefixPath` / "banks" / _ / "customers" / customerIdStr / "correlated-users" => + EndpointHelpers.withUserAndBank(req) { (_, _, cc) => + for { + (customer, _) <- NewStyle.function.getCustomerByCustomerId(customerIdStr, Some(cc)) + (links, _) <- code.api.util.newstyle.UserCustomerLinkNewStyle + .getUserCustomerLinks(customerIdStr, Some(cc)) + (users, _) <- NewStyle.function.getUsersByUserIds(links.map(_.userId), Some(cc)) + (attributes, _) <- NewStyle.function.getUserAttributesByUsers(links.map(_.userId), Some(cc)) + } yield JSONFactory400.createCustomerAdUsersWithAttributesJson(customer, users, attributes) + } + } + + lazy val getAccountsMinimalByCustomerId: HttpRoutes[IO] = HttpRoutes.of[IO] { + case req @ GET -> `prefixPath` / "customers" / customerIdStr / "accounts-minimal" => + EndpointHelpers.withUser(req) { (user, cc) => + for { + (customer, _) <- NewStyle.function.getCustomerByCustomerId(customerIdStr, Some(cc)) + _ <- NewStyle.function.hasAtLeastOneEntitlement( + customer.bankId, user.userId, + canGetAccountsMinimalForCustomerAtOneBank :: canGetAccountsMinimalForCustomerAtAnyBank :: Nil, Some(cc)) + (links, _) <- code.api.util.newstyle.UserCustomerLinkNewStyle + .getUserCustomerLinks(customerIdStr, Some(cc)) + (users, _) <- NewStyle.function.getUsersByUserIds(links.map(_.userId), Some(cc)) + } yield { + val accountAccess = for (u <- users) + yield Views.views.vend.privateViewsUserCanAccess(u)._2 + JSONFactory400.createAccountsMinimalJson400(accountAccess.flatten) + } + } + } + + lazy val getCustomersByCustomerPhoneNumber: HttpRoutes[IO] = HttpRoutes.of[IO] { + case req @ POST -> `prefixPath` / "banks" / _ / "search" / "customers" / "mobile-phone-number" => + EndpointHelpers.withUserAndBankAndBody[PostCustomerPhoneNumberJsonV400, Any](req) { (_, bank, postedData, cc) => + for { + (customers, _) <- NewStyle.function.getCustomersByCustomerPhoneNumber( + bank.bankId, postedData.mobile_phone_number, Some(cc)) + } yield code.api.v3_0_0.JSONFactory300.createCustomersJson(customers) + } + } + + lazy val getCustomersAtAnyBank: HttpRoutes[IO] = HttpRoutes.of[IO] { + case req @ GET -> `prefixPath` / "customers" => + EndpointHelpers.withUser(req) { (_, cc) => + val httpParams = req.headers.headers.toList.map(h => + net.liftweb.http.provider.HTTPParam(h.name.toString, h.value)) + for { + (requestParams, _) <- NewStyle.function.extractQueryParams( + req.uri.renderString, List("limit", "offset", "sort_direction"), Some(cc)) + (customers, _) <- NewStyle.function.getCustomersAtAllBanks(Some(cc), requestParams) + } yield code.api.v3_0_0.JSONFactory300.createCustomersJson(customers.sortBy(_.bankId)) + } + } + + lazy val getCustomersMinimalAtAnyBank: HttpRoutes[IO] = HttpRoutes.of[IO] { + case req @ GET -> `prefixPath` / "customers-minimal" => + EndpointHelpers.withUser(req) { (_, cc) => + for { + (requestParams, _) <- NewStyle.function.extractQueryParams( + req.uri.renderString, List("limit", "offset", "sort_direction"), Some(cc)) + (customers, _) <- NewStyle.function.getCustomersAtAllBanks(Some(cc), requestParams) + } yield JSONFactory400.createCustomersMinimalJson(customers.sortBy(_.bankId)) + } + } + + lazy val getUserInvitation: HttpRoutes[IO] = HttpRoutes.of[IO] { + case req @ GET -> `prefixPath` / "banks" / _ / "user-invitations" / secretLink => + EndpointHelpers.withUserAndBank(req) { (_, bank, cc) => + for { + (invitation, _) <- NewStyle.function.getUserInvitation(bank.bankId, secretLink.toLong, Some(cc)) + } yield JSONFactory400.createUserInvitationJson(invitation) + } + } + + lazy val getUserInvitations: HttpRoutes[IO] = HttpRoutes.of[IO] { + case req @ GET -> `prefixPath` / "banks" / _ / "user-invitations" => + EndpointHelpers.withUserAndBank(req) { (_, bank, cc) => + for { + (invitations, _) <- NewStyle.function.getUserInvitations(bank.bankId, Some(cc)) + } yield JSONFactory400.createUserInvitationJson(invitations) + } + } + + private def initBatch5ResourceDocs(): Unit = { + staticResourceDocs += ResourceDoc( + null, implementedInApiVersion, nameOf(getProductFee), "GET", + "/banks/BANK_ID/products/PRODUCT_CODE/fees/PRODUCT_FEE_ID", + "Get Product Fee", + s"""Get Product Fee""".stripMargin, + EmptyBody, productFeeResponseJsonV400, + List($BankNotFound, UnknownError), + List(apiTagProduct), None, + http4sPartialFunction = Some(getProductFee)) + + staticResourceDocs += ResourceDoc( + null, implementedInApiVersion, nameOf(getProductFees), "GET", + "/banks/BANK_ID/products/PRODUCT_CODE/fees", + "Get Product Fees", + s"""Get Product Fees""".stripMargin, + EmptyBody, productFeesResponseJsonV400, + List($BankNotFound, UnknownError), + List(apiTagProduct), None, + http4sPartialFunction = Some(getProductFees)) + + staticResourceDocs += ResourceDoc( + null, implementedInApiVersion, nameOf(getTransactionAttributes), "GET", + "/banks/BANK_ID/accounts/ACCOUNT_ID/transactions/TRANSACTION_ID/attributes", + "Get Transaction Attributes", + s"""Get Transaction Attributes + | + |${userAuthenticationMessage(true)}""".stripMargin, + EmptyBody, transactionAttributesResponseJson, + List($AuthenticatedUserIsRequired, $BankNotFound, $BankAccountNotFound, InvalidJsonFormat, UnknownError), + List(apiTagTransaction, apiTagTransactionAttribute, apiTagAttribute), + Some(List(canGetTransactionAttributesAtOneBank)), + http4sPartialFunction = Some(getTransactionAttributes)) + + staticResourceDocs += ResourceDoc( + null, implementedInApiVersion, nameOf(getTransactionAttributeById), "GET", + "/banks/BANK_ID/accounts/ACCOUNT_ID/transactions/TRANSACTION_ID/attributes/ATTRIBUTE_ID", + "Get Transaction Attribute By Id", + s"""Get Transaction Attribute By Id + | + |${userAuthenticationMessage(true)}""".stripMargin, + EmptyBody, transactionAttributeResponseJson, + List($AuthenticatedUserIsRequired, $BankNotFound, $BankAccountNotFound, InvalidJsonFormat, UnknownError), + List(apiTagTransaction, apiTagTransactionAttribute, apiTagAttribute), + Some(List(canGetTransactionAttributeAtOneBank)), + http4sPartialFunction = Some(getTransactionAttributeById)) + + staticResourceDocs += ResourceDoc( + null, implementedInApiVersion, nameOf(getTransactionRequestAttributes), "GET", + "/banks/BANK_ID/accounts/ACCOUNT_ID/transaction-requests/TRANSACTION_REQUEST_ID/attributes", + "Get Transaction Request Attributes", + s"""Get Transaction Request Attributes + | + |${userAuthenticationMessage(true)}""".stripMargin, + EmptyBody, transactionRequestAttributesResponseJson, + List($AuthenticatedUserIsRequired, $BankNotFound, $BankAccountNotFound, InvalidJsonFormat, UnknownError), + List(apiTagTransactionRequest, apiTagTransactionRequestAttribute, apiTagAttribute), + Some(List(canGetTransactionRequestAttributesAtOneBank)), + http4sPartialFunction = Some(getTransactionRequestAttributes)) + + staticResourceDocs += ResourceDoc( + null, implementedInApiVersion, nameOf(getTransactionRequestAttributeById), "GET", + "/banks/BANK_ID/accounts/ACCOUNT_ID/transaction-requests/TRANSACTION_REQUEST_ID/attributes/ATTRIBUTE_ID", + "Get Transaction Request Attribute By Id", + s"""Get Transaction Request Attribute By Id + | + |${userAuthenticationMessage(true)}""".stripMargin, + EmptyBody, transactionRequestAttributeResponseJson, + List($AuthenticatedUserIsRequired, $BankNotFound, $BankAccountNotFound, InvalidJsonFormat, UnknownError), + List(apiTagTransactionRequest, apiTagTransactionRequestAttribute, apiTagAttribute), + Some(List(canGetTransactionRequestAttributeAtOneBank)), + http4sPartialFunction = Some(getTransactionRequestAttributeById)) + + staticResourceDocs += ResourceDoc( + null, implementedInApiVersion, nameOf(getTransactionRequestAttributeDefinition), "GET", + "/banks/BANK_ID/attribute-definitions/transaction-request", + "Get Transaction Request Attribute Definition", + s"""Get Transaction Request Attribute Definition + | + |${userAuthenticationMessage(true)}""".stripMargin, + EmptyBody, transactionRequestAttributeDefinitionsResponseJsonV400, + List($AuthenticatedUserIsRequired, $BankNotFound, UnknownError), + List(apiTagTransactionRequest, apiTagTransactionRequestAttribute, apiTagAttribute), + Some(List(canGetTransactionRequestAttributeDefinitionAtOneBank)), + http4sPartialFunction = Some(getTransactionRequestAttributeDefinition)) + + staticResourceDocs += ResourceDoc( + null, implementedInApiVersion, nameOf(getTransactionRequest), "GET", + "/banks/BANK_ID/accounts/ACCOUNT_ID/VIEW_ID/transaction-requests/TRANSACTION_REQUEST_ID", + "Get Transaction Request.", + s"""Returns the transaction request specified by TRANSACTION_REQUEST_ID at BANK_ID. + | + |${userAuthenticationMessage(true)}""".stripMargin, + EmptyBody, transactionRequestWithChargeJSON400, + List($AuthenticatedUserIsRequired, $BankNotFound, $BankAccountNotFound, $UserNoPermissionAccessView, UnknownError), + List(apiTagTransactionRequest), None, + http4sPartialFunction = Some(getTransactionRequest)) + + staticResourceDocs += ResourceDoc( + null, implementedInApiVersion, nameOf(getMyCorrelatedEntities), "GET", + "/my/correlated-entities", + "Get Correlated Entities for the current User", + s"""Correlated Entities are users and customers linked to the currently authenticated user via User-Customer-Links + | + |${userAuthenticationMessage(true)}""".stripMargin, + EmptyBody, correlatedUsersResponseJson, + List($AuthenticatedUserIsRequired, $BankNotFound, UnknownError), + List(apiTagCustomer), None, + http4sPartialFunction = Some(getMyCorrelatedEntities)) + + staticResourceDocs += ResourceDoc( + null, implementedInApiVersion, nameOf(getCorrelatedUsersInfoByCustomerId), "GET", + "/banks/BANK_ID/customers/CUSTOMER_ID/correlated-users", + "Get Correlated User Info by Customer", + s"""Get Correlated User Info by CUSTOMER_ID + | + |${userAuthenticationMessage(true)}""".stripMargin, + EmptyBody, customerAndUsersWithAttributesResponseJson, + List($AuthenticatedUserIsRequired, $BankNotFound, UnknownError), + List(apiTagCustomer), + Some(List(canGetCorrelatedUsersInfoAtAnyBank, canGetCorrelatedUsersInfo)), + http4sPartialFunction = Some(getCorrelatedUsersInfoByCustomerId)) + + staticResourceDocs += ResourceDoc( + null, implementedInApiVersion, nameOf(getAccountsMinimalByCustomerId), "GET", + "/customers/CUSTOMER_ID/accounts-minimal", + "Get Accounts Minimal for a Customer", + s"""Get Accounts Minimal that are owned by a Customer. + | + |${userAuthenticationMessage(true)}""".stripMargin, + EmptyBody, accountsMinimalJson400, + List($AuthenticatedUserIsRequired, CustomerNotFoundByCustomerId, UnknownError), + List(apiTagCustomer), + Some(List(canGetAccountsMinimalForCustomerAtOneBank, canGetAccountsMinimalForCustomerAtAnyBank)), + http4sPartialFunction = Some(getAccountsMinimalByCustomerId)) + + staticResourceDocs += ResourceDoc( + null, implementedInApiVersion, nameOf(getCustomersByCustomerPhoneNumber), "POST", + "/banks/BANK_ID/search/customers/mobile-phone-number", + "Get Customers by MOBILE_PHONE_NUMBER", + s"""Gets the Customers specified by MOBILE_PHONE_NUMBER.""".stripMargin, + postCustomerPhoneNumberJsonV400, customerJsonV310, + List($AuthenticatedUserIsRequired, $BankNotFound, InvalidJsonFormat, UnknownError), + List(apiTagCustomer), + Some(List(canGetCustomersAtOneBank)), + http4sPartialFunction = Some(getCustomersByCustomerPhoneNumber)) + + staticResourceDocs += ResourceDoc( + null, implementedInApiVersion, nameOf(getCustomersAtAnyBank), "GET", + "/customers", + "Get Customers at Any Bank", + s"""Get Customers at Any Bank.""".stripMargin, + EmptyBody, customersJsonV300, + List(AuthenticatedUserIsRequired, UserCustomerLinksNotFoundForUser, UnknownError), + List(apiTagCustomer, apiTagUser), + Some(List(canGetCustomersAtAllBanks)), + http4sPartialFunction = Some(getCustomersAtAnyBank)) + + staticResourceDocs += ResourceDoc( + null, implementedInApiVersion, nameOf(getCustomersMinimalAtAnyBank), "GET", + "/customers-minimal", + "Get Customers Minimal at Any Bank", + s"""Get Customers Minimal at Any Bank.""".stripMargin, + EmptyBody, customersMinimalJsonV300, + List(AuthenticatedUserIsRequired, UserCustomerLinksNotFoundForUser, UnknownError), + List(apiTagCustomer, apiTagUser), + Some(List(canGetCustomersMinimalAtAllBanks)), + http4sPartialFunction = Some(getCustomersMinimalAtAnyBank)) + + staticResourceDocs += ResourceDoc( + null, implementedInApiVersion, nameOf(getUserInvitation), "GET", + "/banks/BANK_ID/user-invitations/SECRET_LINK", + "Get User Invitation", + s"""Get User Invitation + | + |${userAuthenticationMessage(true)}""".stripMargin, + EmptyBody, userInvitationJsonV400, + List($AuthenticatedUserIsRequired, $BankNotFound, InvalidJsonFormat, UnknownError), + List(apiTagUserInvitation), + Some(List(canGetUserInvitation)), + http4sPartialFunction = Some(getUserInvitation)) + + staticResourceDocs += ResourceDoc( + null, implementedInApiVersion, nameOf(getUserInvitations), "GET", + "/banks/BANK_ID/user-invitations", + "Get User Invitations", + s"""Get User Invitations + | + |${userAuthenticationMessage(true)}""".stripMargin, + EmptyBody, userInvitationJsonV400, + List($AuthenticatedUserIsRequired, $BankNotFound, InvalidJsonFormat, UnknownError), + List(apiTagUserInvitation), + Some(List(canGetUserInvitation)), + http4sPartialFunction = Some(getUserInvitations)) + } + initBatch5ResourceDocs() + + // ═══════════════════════════════════════════════════════════════════════════ + // Batch 4 — Consents, ApiCollections, and other simple endpoints + // ═══════════════════════════════════════════════════════════════════════════ + + lazy val getConsentInfosByBank: HttpRoutes[IO] = HttpRoutes.of[IO] { + case req @ GET -> `prefixPath` / "banks" / _ / "my" / "consent-infos" => + EndpointHelpers.withUserAndBank(req) { (user, bank, _) => + for { + consents <- Future { + code.consent.Consents.consentProvider.vend.getConsentsByUser(user.userId) + .sortBy(i => (i.creationDateTime, i.apiStandard)).reverse + } + } yield { + val consentsOfBank = code.api.util.Consent.filterByBankId(consents, bank.bankId) + JSONFactory400.createConsentInfosJsonV400(consentsOfBank) + } + } + } + + lazy val getConsentInfos: HttpRoutes[IO] = HttpRoutes.of[IO] { + case req @ GET -> `prefixPath` / "my" / "consent-infos" => + EndpointHelpers.withUser(req) { (user, _) => + for { + consents <- Future { + code.consent.Consents.consentProvider.vend.getConsentsByUser(user.userId) + .sortBy(i => (i.creationDateTime, i.apiStandard)).reverse + } + } yield JSONFactory400.createConsentInfosJsonV400(consents) + } + } + + lazy val getMyApiCollectionByName: HttpRoutes[IO] = HttpRoutes.of[IO] { + case req @ GET -> `prefixPath` / "my" / "api-collections" / "name" / apiCollectionName => + EndpointHelpers.withUser(req) { (user, cc) => + for { + (ac, _) <- NewStyle.function.getApiCollectionByUserIdAndCollectionName( + user.userId, apiCollectionName, Some(cc)) + } yield JSONFactory400.createApiCollectionJsonV400(ac) + } + } + + lazy val getMyApiCollectionById: HttpRoutes[IO] = HttpRoutes.of[IO] { + case req @ GET -> `prefixPath` / "my" / "api-collections" / apiCollectionId => + EndpointHelpers.withUser(req) { (_, cc) => + for { + (ac, _) <- NewStyle.function.getApiCollectionById(apiCollectionId, Some(cc)) + } yield JSONFactory400.createApiCollectionJsonV400(ac) + } + } + + lazy val getSharableApiCollectionById: HttpRoutes[IO] = HttpRoutes.of[IO] { + case req @ GET -> `prefixPath` / "api-collections" / "sharable" / apiCollectionId => + EndpointHelpers.executeAndRespond(req) { cc => + for { + (ac, _) <- NewStyle.function.getApiCollectionById(apiCollectionId, Some(cc)) + _ <- code.util.Helper.booleanToFuture( + s"$ApiCollectionEndpointNotFound Current api_collection_id($apiCollectionId) is not sharable.", + cc = Some(cc)) { ac.isSharable } + } yield JSONFactory400.createApiCollectionJsonV400(ac) + } + } + + lazy val getApiCollectionsForUser: HttpRoutes[IO] = HttpRoutes.of[IO] { + case req @ GET -> `prefixPath` / "users" / userIdStr / "api-collections" => + EndpointHelpers.withUser(req) { (_, cc) => + for { + (_, _) <- NewStyle.function.findByUserId(userIdStr, Some(cc)) + (acs, _) <- NewStyle.function.getApiCollectionsByUserId(userIdStr, Some(cc)) + } yield JSONFactory400.createApiCollectionsJsonV400(acs) + } + } + + lazy val getFeaturedApiCollections: HttpRoutes[IO] = HttpRoutes.of[IO] { + case req @ GET -> `prefixPath` / "api-collections" / "featured" => + EndpointHelpers.executeAndRespond(req) { cc => + for { + (acs, _) <- NewStyle.function.getFeaturedApiCollections(Some(cc)) + } yield JSONFactory400.createApiCollectionsJsonV400(acs) + } + } + + lazy val getMyApiCollections: HttpRoutes[IO] = HttpRoutes.of[IO] { + case req @ GET -> `prefixPath` / "my" / "api-collections" => + EndpointHelpers.withUser(req) { (user, cc) => + val params = req.uri.query.params + val limitParam = params.get("limit").flatMap(s => scala.util.Try(s.toInt).toOption).getOrElse(50) + val offsetParam = params.get("offset").flatMap(s => scala.util.Try(s.toInt).toOption).getOrElse(0) + for { + (acs, _) <- NewStyle.function.getApiCollectionsByUserId(user.userId, Some(cc)) + } yield JSONFactory400.createApiCollectionsJsonV400(acs.drop(offsetParam).take(limitParam)) + } + } + + lazy val getMyApiCollectionEndpoint: HttpRoutes[IO] = HttpRoutes.of[IO] { + case req @ GET -> `prefixPath` / "my" / "api-collections" / apiCollectionName / "api-collection-endpoints" / operationId => + EndpointHelpers.withUser(req) { (user, cc) => + for { + (ac, _) <- NewStyle.function.getApiCollectionByUserIdAndCollectionName( + user.userId, apiCollectionName, Some(cc)) + (ace, _) <- NewStyle.function.getApiCollectionEndpointByApiCollectionIdAndOperationId( + ac.apiCollectionId, operationId, Some(cc)) + } yield JSONFactory400.createApiCollectionEndpointJsonV400(ace) + } + } + + lazy val getApiCollectionEndpoints: HttpRoutes[IO] = HttpRoutes.of[IO] { + case req @ GET -> `prefixPath` / "api-collections" / apiCollectionId / "api-collection-endpoints" => + EndpointHelpers.withUser(req) { (_, cc) => + for { + (aces, _) <- NewStyle.function.getApiCollectionEndpoints(apiCollectionId, Some(cc)) + } yield JSONFactory400.createApiCollectionEndpointsJsonV400(aces) + } + } + + lazy val getMyApiCollectionEndpoints: HttpRoutes[IO] = HttpRoutes.of[IO] { + case req @ GET -> `prefixPath` / "my" / "api-collections" / apiCollectionName / "api-collection-endpoints" => + EndpointHelpers.withUser(req) { (user, cc) => + for { + (ac, _) <- NewStyle.function.getApiCollectionByUserIdAndCollectionName( + user.userId, apiCollectionName, Some(cc)) + (aces, _) <- NewStyle.function.getApiCollectionEndpoints(ac.apiCollectionId, Some(cc)) + } yield JSONFactory400.createApiCollectionEndpointsJsonV400(aces) + } + } + + lazy val getMyApiCollectionEndpointsById: HttpRoutes[IO] = HttpRoutes.of[IO] { + case req @ GET -> `prefixPath` / "my" / "api-collection-ids" / apiCollectionId / "api-collection-endpoints" => + EndpointHelpers.withUser(req) { (_, cc) => + for { + (ac, _) <- NewStyle.function.getApiCollectionById(apiCollectionId, Some(cc)) + (aces, _) <- NewStyle.function.getApiCollectionEndpoints(ac.apiCollectionId, Some(cc)) + } yield JSONFactory400.createApiCollectionEndpointsJsonV400(aces) + } + } + + lazy val deleteMyApiCollection: HttpRoutes[IO] = HttpRoutes.of[IO] { + case req @ DELETE -> `prefixPath` / "my" / "api-collections" / apiCollectionId => + EndpointHelpers.withUserDelete(req) { (_, cc) => + for { + (_, _) <- NewStyle.function.getApiCollectionById(apiCollectionId, Some(cc)) + (deleted, _) <- NewStyle.function.deleteApiCollectionById(apiCollectionId, Some(cc)) + } yield deleted + } + } + + lazy val deleteMyApiCollectionEndpoint: HttpRoutes[IO] = HttpRoutes.of[IO] { + case req @ DELETE -> `prefixPath` / "my" / "api-collections" / apiCollectionName / "api-collection-endpoints" / operationId => + EndpointHelpers.withUserDelete(req) { (user, cc) => + for { + (ac, _) <- NewStyle.function.getApiCollectionByUserIdAndCollectionName( + user.userId, apiCollectionName, Some(cc)) + (ace, _) <- NewStyle.function.getApiCollectionEndpointByApiCollectionIdAndOperationId( + ac.apiCollectionId, operationId, Some(cc)) + (deleted, _) <- NewStyle.function.deleteApiCollectionEndpointById( + ace.apiCollectionEndpointId, Some(cc)) + } yield deleted + } + } + + lazy val deleteMyApiCollectionEndpointByOperationId: HttpRoutes[IO] = HttpRoutes.of[IO] { + case req @ DELETE -> `prefixPath` / "my" / "api-collection-ids" / apiCollectionId / "api-collection-endpoints" / operationId => + EndpointHelpers.withUserDelete(req) { (_, cc) => + for { + (ac, _) <- NewStyle.function.getApiCollectionById(apiCollectionId, Some(cc)) + (ace, _) <- NewStyle.function.getApiCollectionEndpointByApiCollectionIdAndOperationId( + ac.apiCollectionId, operationId, Some(cc)) + (deleted, _) <- NewStyle.function.deleteApiCollectionEndpointById( + ace.apiCollectionEndpointId, Some(cc)) + } yield deleted + } + } + + lazy val deleteMyApiCollectionEndpointById: HttpRoutes[IO] = HttpRoutes.of[IO] { + case req @ DELETE -> `prefixPath` / "my" / "api-collection-ids" / _ / "api-collection-endpoint-ids" / apiCollectionEndpointId => + EndpointHelpers.withUserDelete(req) { (_, cc) => + for { + (deleted, _) <- NewStyle.function.deleteApiCollectionEndpointById(apiCollectionEndpointId, Some(cc)) + } yield deleted + } + } + + private def initBatch4ResourceDocs(): Unit = { + staticResourceDocs += ResourceDoc( + null, implementedInApiVersion, nameOf(getConsentInfosByBank), "GET", + "/banks/BANK_ID/my/consent-infos", + "Get My Consents Info At Bank", + s"""This endpoint gets the Consents that the current User created at bank.""".stripMargin, + EmptyBody, consentInfosJsonV400, + List($AuthenticatedUserIsRequired, $BankNotFound, UnknownError), + List(apiTagConsent, apiTagPSD2AIS, apiTagPsd2), None, + http4sPartialFunction = Some(getConsentInfosByBank)) + + staticResourceDocs += ResourceDoc( + null, implementedInApiVersion, nameOf(getConsentInfos), "GET", + "/my/consent-infos", + "Get My Consents Info", + s"""This endpoint gets the Consents that the current User created.""".stripMargin, + EmptyBody, consentInfosJsonV400, + List($AuthenticatedUserIsRequired, $BankNotFound, UnknownError), + List(apiTagConsent, apiTagPSD2AIS, apiTagPsd2), None, + http4sPartialFunction = Some(getConsentInfos)) + + staticResourceDocs += ResourceDoc( + null, implementedInApiVersion, nameOf(getMyApiCollectionByName), "GET", + "/my/api-collections/name/API_COLLECTION_NAME", + "Get My Api Collection By Name", + s"""Get Api Collection By API_COLLECTION_NAME.""".stripMargin, + EmptyBody, apiCollectionJson400, + List($AuthenticatedUserIsRequired, UserNotFoundByUserId, UnknownError), + List(apiTagApiCollection), None, + http4sPartialFunction = Some(getMyApiCollectionByName)) + + staticResourceDocs += ResourceDoc( + null, implementedInApiVersion, nameOf(getMyApiCollectionById), "GET", + "/my/api-collections/API_COLLECTION_ID", + "Get My Api Collection By Id", + s"""Get Api Collection By API_COLLECTION_ID.""".stripMargin, + EmptyBody, apiCollectionJson400, + List($AuthenticatedUserIsRequired, UserNotFoundByUserId, UnknownError), + List(apiTagApiCollection), None, + http4sPartialFunction = Some(getMyApiCollectionById)) + + staticResourceDocs += ResourceDoc( + null, implementedInApiVersion, nameOf(getSharableApiCollectionById), "GET", + "/api-collections/sharable/API_COLLECTION_ID", + "Get Sharable Api Collection By Id", + s"""Get Sharable Api Collection By Id.""".stripMargin, + EmptyBody, apiCollectionJson400, + List(UnknownError), + List(apiTagApiCollection), None, + http4sPartialFunction = Some(getSharableApiCollectionById)) + + staticResourceDocs += ResourceDoc( + null, implementedInApiVersion, nameOf(getApiCollectionsForUser), "GET", + "/users/USER_ID/api-collections", + "Get Api Collections for User", + s"""Get Api Collections for User.""".stripMargin, + EmptyBody, apiCollectionsJson400, + List(UserNotFoundByUserId, UnknownError), + List(apiTagApiCollection), + Some(canGetApiCollectionsForUser :: Nil), + http4sPartialFunction = Some(getApiCollectionsForUser)) + + staticResourceDocs += ResourceDoc( + null, implementedInApiVersion, nameOf(getFeaturedApiCollections), "GET", + "/api-collections/featured", + "Get Featured Api Collections", + s"""Get Featured Api Collections.""".stripMargin, + EmptyBody, apiCollectionsJson400, + List(UnknownError), + List(apiTagApiCollection), None, + http4sPartialFunction = Some(getFeaturedApiCollections)) + + staticResourceDocs += ResourceDoc( + null, implementedInApiVersion, nameOf(getMyApiCollections), "GET", + "/my/api-collections", + "Get My Api Collections", + s"""Get all the apiCollections for logged in user.""".stripMargin, + EmptyBody, apiCollectionsJson400, + List($AuthenticatedUserIsRequired, UnknownError), + List(apiTagApiCollection), None, + http4sPartialFunction = Some(getMyApiCollections)) + + staticResourceDocs += ResourceDoc( + null, implementedInApiVersion, nameOf(getMyApiCollectionEndpoint), "GET", + "/my/api-collections/API_COLLECTION_NAME/api-collection-endpoints/OPERATION_ID", + "Get My Api Collection Endpoint", + s"""Get Api Collection Endpoint By OPERATION_ID.""".stripMargin, + EmptyBody, apiCollectionEndpointJson400, + List($AuthenticatedUserIsRequired, UserNotFoundByUserId, UnknownError), + List(apiTagApiCollection), None, + http4sPartialFunction = Some(getMyApiCollectionEndpoint)) + + staticResourceDocs += ResourceDoc( + null, implementedInApiVersion, nameOf(getApiCollectionEndpoints), "GET", + "/api-collections/API_COLLECTION_ID/api-collection-endpoints", + "Get Api Collection Endpoints", + s"""Get Api Collection Endpoints By API_COLLECTION_ID.""".stripMargin, + EmptyBody, apiCollectionEndpointsJson400, + List($AuthenticatedUserIsRequired, UnknownError), + List(apiTagApiCollection), None, + http4sPartialFunction = Some(getApiCollectionEndpoints)) + + staticResourceDocs += ResourceDoc( + null, implementedInApiVersion, nameOf(getMyApiCollectionEndpoints), "GET", + "/my/api-collections/API_COLLECTION_NAME/api-collection-endpoints", + "Get My Api Collection Endpoints", + s"""Get Api Collection Endpoints By API_COLLECTION_NAME.""".stripMargin, + EmptyBody, apiCollectionEndpointsJson400, + List($AuthenticatedUserIsRequired, UserNotFoundByUserId, UnknownError), + List(apiTagApiCollection), None, + http4sPartialFunction = Some(getMyApiCollectionEndpoints)) + + staticResourceDocs += ResourceDoc( + null, implementedInApiVersion, nameOf(getMyApiCollectionEndpointsById), "GET", + "/my/api-collection-ids/API_COLLECTION_ID/api-collection-endpoints", + "Get My Api Collection Endpoints By Id", + s"""Get Api Collection Endpoints By API_COLLECTION_ID.""".stripMargin, + EmptyBody, apiCollectionEndpointsJson400, + List($AuthenticatedUserIsRequired, UserNotFoundByUserId, UnknownError), + List(apiTagApiCollection), None, + http4sPartialFunction = Some(getMyApiCollectionEndpointsById)) + + staticResourceDocs += ResourceDoc( + null, implementedInApiVersion, nameOf(deleteMyApiCollection), "DELETE", + "/my/api-collections/API_COLLECTION_ID", + "Delete My Api Collection", + s"""Delete Api Collection By API_COLLECTION_ID.""".stripMargin, + EmptyBody, Full(true), + List($AuthenticatedUserIsRequired, UserNotFoundByUserId, UnknownError), + List(apiTagApiCollection), None, + http4sPartialFunction = Some(deleteMyApiCollection)) + + staticResourceDocs += ResourceDoc( + null, implementedInApiVersion, nameOf(deleteMyApiCollectionEndpoint), "DELETE", + "/my/api-collections/API_COLLECTION_NAME/api-collection-endpoints/OPERATION_ID", + "Delete My Api Collection Endpoint", + s"""Delete Api Collection Endpoint By OPERATION_ID.""".stripMargin, + EmptyBody, Full(true), + List($AuthenticatedUserIsRequired, UserNotFoundByUserId, UnknownError), + List(apiTagApiCollection), None, + http4sPartialFunction = Some(deleteMyApiCollectionEndpoint)) + + staticResourceDocs += ResourceDoc( + null, implementedInApiVersion, nameOf(deleteMyApiCollectionEndpointByOperationId), "DELETE", + "/my/api-collection-ids/API_COLLECTION_ID/api-collection-endpoints/OPERATION_ID", + "Delete My Api Collection Endpoint By Id", + s"""Delete Api Collection Endpoint By OPERATION_ID.""".stripMargin, + EmptyBody, Full(true), + List($AuthenticatedUserIsRequired, UserNotFoundByUserId, UnknownError), + List(apiTagApiCollection), None, + http4sPartialFunction = Some(deleteMyApiCollectionEndpointByOperationId)) + + staticResourceDocs += ResourceDoc( + null, implementedInApiVersion, nameOf(deleteMyApiCollectionEndpointById), "DELETE", + "/my/api-collection-ids/API_COLLECTION_ID/api-collection-endpoint-ids/API_COLLECTION_ENDPOINT_ID", + "Delete My Api Collection Endpoint By Id", + s"""Delete Api Collection Endpoint By Id.""".stripMargin, + EmptyBody, Full(true), + List($AuthenticatedUserIsRequired, UserNotFoundByUserId, UnknownError), + List(apiTagApiCollection), None, + http4sPartialFunction = Some(deleteMyApiCollectionEndpointById)) + } + initBatch4ResourceDocs() + + // ═══════════════════════════════════════════════════════════════════════════ + // Batch 3 — simple DELETEs (mostly return 200 with body, some return 204) + // ═══════════════════════════════════════════════════════════════════════════ + + private def deleteAttributeDefinitionImpl( + attributeDefinitionId: String, + category: com.openbankproject.commons.model.enums.AttributeCategory.Value, + cc: CallContext): Future[Box[Boolean]] = { + for { + (deleted, _) <- code.api.util.newstyle.AttributeDefinition.deleteAttributeDefinition( + attributeDefinitionId, category, Some(cc)) + } yield Full(deleted) + } + + lazy val deleteTransactionAttributeDefinition: HttpRoutes[IO] = HttpRoutes.of[IO] { + case req @ DELETE -> `prefixPath` / "banks" / _ / "attribute-definitions" / attributeDefinitionId / "transaction" => + EndpointHelpers.withBank(req) { (_, cc) => + deleteAttributeDefinitionImpl( + attributeDefinitionId, + com.openbankproject.commons.model.enums.AttributeCategory.Transaction, cc) + } + } + + lazy val deleteCustomerAttributeDefinition: HttpRoutes[IO] = HttpRoutes.of[IO] { + case req @ DELETE -> `prefixPath` / "banks" / _ / "attribute-definitions" / attributeDefinitionId / "customer" => + EndpointHelpers.withBank(req) { (_, cc) => + deleteAttributeDefinitionImpl( + attributeDefinitionId, + com.openbankproject.commons.model.enums.AttributeCategory.Customer, cc) + } + } + + lazy val deleteAccountAttributeDefinition: HttpRoutes[IO] = HttpRoutes.of[IO] { + case req @ DELETE -> `prefixPath` / "banks" / _ / "attribute-definitions" / attributeDefinitionId / "account" => + EndpointHelpers.withBank(req) { (_, cc) => + deleteAttributeDefinitionImpl( + attributeDefinitionId, + com.openbankproject.commons.model.enums.AttributeCategory.Account, cc) + } + } + + lazy val deleteProductAttributeDefinition: HttpRoutes[IO] = HttpRoutes.of[IO] { + case req @ DELETE -> `prefixPath` / "banks" / _ / "attribute-definitions" / attributeDefinitionId / "product" => + EndpointHelpers.withBank(req) { (_, cc) => + deleteAttributeDefinitionImpl( + attributeDefinitionId, + com.openbankproject.commons.model.enums.AttributeCategory.Product, cc) + } + } + + lazy val deleteCardAttributeDefinition: HttpRoutes[IO] = HttpRoutes.of[IO] { + case req @ DELETE -> `prefixPath` / "banks" / _ / "attribute-definitions" / attributeDefinitionId / "card" => + EndpointHelpers.withBank(req) { (_, cc) => + deleteAttributeDefinitionImpl( + attributeDefinitionId, + com.openbankproject.commons.model.enums.AttributeCategory.Card, cc) + } + } + + lazy val deleteTransactionRequestAttributeDefinition: HttpRoutes[IO] = HttpRoutes.of[IO] { + case req @ DELETE -> `prefixPath` / "banks" / _ / "attribute-definitions" / attributeDefinitionId / "transaction-request" => + EndpointHelpers.withBank(req) { (_, cc) => + deleteAttributeDefinitionImpl( + attributeDefinitionId, + com.openbankproject.commons.model.enums.AttributeCategory.TransactionRequest, cc) + } + } + + lazy val deleteUser: HttpRoutes[IO] = HttpRoutes.of[IO] { + case req @ DELETE -> `prefixPath` / "users" / userId => + EndpointHelpers.withUser(req) { (_, cc) => + for { + (user, _) <- NewStyle.function.findByUserId(userId, Some(cc)) + (userDeleted, _) <- NewStyle.function.deleteUser(user.userPrimaryKey, Some(cc)) + } yield Full(userDeleted) + } + } + + lazy val deleteUserCustomerLink: HttpRoutes[IO] = HttpRoutes.of[IO] { + case req @ DELETE -> `prefixPath` / "banks" / _ / "user_customer_links" / userCustomerLinkId => + EndpointHelpers.withUserAndBank(req) { (_, _, cc) => + for { + (deleted, _) <- code.api.util.newstyle.UserCustomerLinkNewStyle + .deleteUserCustomerLink(userCustomerLinkId, Some(cc)) + } yield Full(deleted) + } + } + + lazy val deleteTransactionCascade: HttpRoutes[IO] = HttpRoutes.of[IO] { + case req @ DELETE -> `prefixPath` / "management" / "cascading" / "banks" / bankIdStr / "accounts" / accountIdStr / "transactions" / transactionIdStr => + EndpointHelpers.withUser(req) { (_, cc) => + for { + (_, _) <- NewStyle.function.getTransaction( + BankId(bankIdStr), AccountId(accountIdStr), TransactionId(transactionIdStr), Some(cc)) + _ <- Future(deletion.DeleteTransactionCascade.atomicDelete( + BankId(bankIdStr), AccountId(accountIdStr), TransactionId(transactionIdStr))) + } yield Full(true) + } + } + + lazy val deleteAccountCascade: HttpRoutes[IO] = HttpRoutes.of[IO] { + case req @ DELETE -> `prefixPath` / "management" / "cascading" / "banks" / _ / "accounts" / _ => + EndpointHelpers.withBankAccount(req) { (_, account, cc) => + for { + result <- Future(deletion.DeleteAccountCascade.atomicDelete(account.bankId, account.accountId)) + } yield Full(result.getOrElse(false)) + } + } + + lazy val deleteBankCascade: HttpRoutes[IO] = HttpRoutes.of[IO] { + case req @ DELETE -> `prefixPath` / "management" / "cascading" / "banks" / _ => + EndpointHelpers.withUserAndBank(req) { (_, bank, _) => + for { + _ <- Future(deletion.DeleteBankCascade.atomicDelete(bank.bankId)) + } yield Full(true) + } + } + + lazy val deleteProductCascade: HttpRoutes[IO] = HttpRoutes.of[IO] { + case req @ DELETE -> `prefixPath` / "management" / "cascading" / "banks" / _ / "products" / productCodeStr => + EndpointHelpers.withUserAndBank(req) { (_, bank, cc) => + for { + (_, _) <- NewStyle.function.getProduct(bank.bankId, ProductCode(productCodeStr), Some(cc)) + _ <- Future(deletion.DeleteProductCascade.atomicDelete(bank.bankId, ProductCode(productCodeStr))) + } yield Full(true) + } + } + + lazy val deleteCustomerCascade: HttpRoutes[IO] = HttpRoutes.of[IO] { + case req @ DELETE -> `prefixPath` / "management" / "cascading" / "banks" / _ / "customers" / customerIdStr => + EndpointHelpers.withUserAndBank(req) { (_, _, cc) => + for { + (_, _) <- NewStyle.function.getCustomerByCustomerId(customerIdStr, Some(cc)) + _ <- Future(deletion.DeleteCustomerCascade.atomicDelete(CustomerId(customerIdStr))) + } yield Full(true) + } + } + + lazy val deleteSystemLevelEndpointTag: HttpRoutes[IO] = HttpRoutes.of[IO] { + case req @ DELETE -> `prefixPath` / "management" / "endpoints" / _ / "tags" / endpointTagId => + EndpointHelpers.withUserDelete(req) { (_, cc) => + for { + (_, _) <- NewStyle.function.getEndpointTag(endpointTagId, Some(cc)) + (deleted, _) <- NewStyle.function.deleteEndpointTag(endpointTagId, Some(cc)) + } yield deleted + } + } + + lazy val deleteBankLevelEndpointTag: HttpRoutes[IO] = HttpRoutes.of[IO] { + case req @ DELETE -> `prefixPath` / "management" / "banks" / _ / "endpoints" / _ / "tags" / endpointTagId => + EndpointHelpers.withUserAndBankDelete(req) { (_, _, cc) => + for { + (_, _) <- NewStyle.function.getEndpointTag(endpointTagId, Some(cc)) + (deleted, _) <- NewStyle.function.deleteEndpointTag(endpointTagId, Some(cc)) + } yield deleted + } + } + + lazy val deleteAuthenticationTypeValidation: HttpRoutes[IO] = HttpRoutes.of[IO] { + case req @ DELETE -> `prefixPath` / "management" / "authentication-type-validations" / operationId => + EndpointHelpers.withUser(req) { (_, cc) => + for { + (isExists, _) <- NewStyle.function.isAuthenticationTypeValidationExists(operationId, Some(cc)) + _ <- code.util.Helper.booleanToFuture(AuthenticationTypeValidationNotFound, cc = Some(cc)) { isExists } + (deleteResult, _) <- NewStyle.function.deleteAuthenticationTypeValidation(operationId, Some(cc)) + } yield deleteResult + } + } + + lazy val deleteJsonSchemaValidation: HttpRoutes[IO] = HttpRoutes.of[IO] { + case req @ DELETE -> `prefixPath` / "management" / "json-schema-validations" / operationId => + EndpointHelpers.withUser(req) { (_, cc) => + for { + (isExists, _) <- NewStyle.function.isJsonSchemaValidationExists(operationId, Some(cc)) + _ <- code.util.Helper.booleanToFuture(JsonSchemaValidationNotFound, cc = Some(cc)) { isExists } + (deleteResult, _) <- NewStyle.function.deleteJsonSchemaValidation(operationId, Some(cc)) + } yield deleteResult + } + } + + lazy val deleteCustomerAttribute: HttpRoutes[IO] = HttpRoutes.of[IO] { + case req @ DELETE -> `prefixPath` / "banks" / _ / "customers" / "attributes" / customerAttributeId => + EndpointHelpers.withUserAndBankDelete(req) { (_, _, cc) => + for { + (deleted, _) <- NewStyle.function.deleteCustomerAttribute(customerAttributeId, Some(cc)) + } yield deleted + } + } + + lazy val deleteBankAttribute: HttpRoutes[IO] = HttpRoutes.of[IO] { + case req @ DELETE -> `prefixPath` / "banks" / _ / "attributes" / bankAttributeId => + EndpointHelpers.withUserAndBankDelete(req) { (_, _, cc) => + for { + (_, _) <- NewStyle.function.deleteBankAttribute(bankAttributeId, Some(cc)) + } yield () + } + } + + lazy val deleteAtm: HttpRoutes[IO] = HttpRoutes.of[IO] { + case req @ DELETE -> `prefixPath` / "banks" / _ / "atms" / atmIdStr => + EndpointHelpers.withUserAndBankDelete(req) { (_, bank, cc) => + for { + (atm, _) <- NewStyle.function.getAtm(bank.bankId, AtmId(atmIdStr), Some(cc)) + (deleted, _) <- NewStyle.function.deleteAtm(atm, Some(cc)) + } yield deleted + } + } + + lazy val deleteProductFee: HttpRoutes[IO] = HttpRoutes.of[IO] { + case req @ DELETE -> `prefixPath` / "banks" / _ / "products" / _ / "fees" / productFeeId => + EndpointHelpers.withUserAndBankDelete(req) { (_, _, cc) => + for { + (_, _) <- NewStyle.function.getProductFeeById(productFeeId, Some(cc)) + (productFee, _) <- NewStyle.function.deleteProductFee(productFeeId, Some(cc)) + } yield productFee + } + } + + lazy val deleteEndpointMapping: HttpRoutes[IO] = HttpRoutes.of[IO] { + case req @ DELETE -> `prefixPath` / "management" / "endpoint-mappings" / endpointMappingId => + EndpointHelpers.withUser(req) { (_, cc) => + for { + (deleted, _) <- NewStyle.function.deleteEndpointMapping(None, endpointMappingId, Some(cc)) + } yield deleted + } + } + + lazy val deleteBankLevelEndpointMapping: HttpRoutes[IO] = HttpRoutes.of[IO] { + case req @ DELETE -> `prefixPath` / "management" / "banks" / _ / "endpoint-mappings" / endpointMappingId => + EndpointHelpers.withUserAndBank(req) { (_, bank, cc) => + for { + (deleted, _) <- NewStyle.function.deleteEndpointMapping(Some(bank.bankId.value), endpointMappingId, Some(cc)) + } yield deleted + } + } + + private def initBatch3ResourceDocs(): Unit = { + staticResourceDocs += ResourceDoc( + null, implementedInApiVersion, nameOf(deleteTransactionAttributeDefinition), "DELETE", + "/banks/BANK_ID/attribute-definitions/ATTRIBUTE_DEFINITION_ID/transaction", + "Delete Transaction Attribute Definition", + s"""Delete Transaction Attribute Definition by ATTRIBUTE_DEFINITION_ID + | + |${userAuthenticationMessage(true)}""".stripMargin, + EmptyBody, EmptyBody, + List($AuthenticatedUserIsRequired, $BankNotFound, UnknownError), + List(apiTagTransaction, apiTagTransactionAttribute, apiTagAttribute), + Some(List(canDeleteTransactionAttributeDefinitionAtOneBank)), + http4sPartialFunction = Some(deleteTransactionAttributeDefinition)) + + staticResourceDocs += ResourceDoc( + null, implementedInApiVersion, nameOf(deleteCustomerAttributeDefinition), "DELETE", + "/banks/BANK_ID/attribute-definitions/ATTRIBUTE_DEFINITION_ID/customer", + "Delete Customer Attribute Definition", + s"""Delete Customer Attribute Definition by ATTRIBUTE_DEFINITION_ID + | + |${userAuthenticationMessage(true)}""".stripMargin, + EmptyBody, EmptyBody, + List($AuthenticatedUserIsRequired, $BankNotFound, UnknownError), + List(apiTagCustomer, apiTagCustomerAttribute, apiTagAttribute), + Some(List(canDeleteCustomerAttributeDefinitionAtOneBank)), + http4sPartialFunction = Some(deleteCustomerAttributeDefinition)) + + staticResourceDocs += ResourceDoc( + null, implementedInApiVersion, nameOf(deleteAccountAttributeDefinition), "DELETE", + "/banks/BANK_ID/attribute-definitions/ATTRIBUTE_DEFINITION_ID/account", + "Delete Account Attribute Definition", + s"""Delete Account Attribute Definition by ATTRIBUTE_DEFINITION_ID + | + |${userAuthenticationMessage(true)}""".stripMargin, + EmptyBody, EmptyBody, + List($AuthenticatedUserIsRequired, $BankNotFound, UnknownError), + List(apiTagAccount, apiTagAccountAttribute, apiTagAttribute), + Some(List(canDeleteAccountAttributeDefinitionAtOneBank)), + http4sPartialFunction = Some(deleteAccountAttributeDefinition)) + + staticResourceDocs += ResourceDoc( + null, implementedInApiVersion, nameOf(deleteProductAttributeDefinition), "DELETE", + "/banks/BANK_ID/attribute-definitions/ATTRIBUTE_DEFINITION_ID/product", + "Delete Product Attribute Definition", + s"""Delete Product Attribute Definition by ATTRIBUTE_DEFINITION_ID + | + |${userAuthenticationMessage(true)}""".stripMargin, + EmptyBody, EmptyBody, + List($AuthenticatedUserIsRequired, $BankNotFound, UnknownError), + List(apiTagProduct, apiTagProductAttribute, apiTagAttribute), + Some(List(canDeleteProductAttributeDefinitionAtOneBank)), + http4sPartialFunction = Some(deleteProductAttributeDefinition)) + + staticResourceDocs += ResourceDoc( + null, implementedInApiVersion, nameOf(deleteCardAttributeDefinition), "DELETE", + "/banks/BANK_ID/attribute-definitions/ATTRIBUTE_DEFINITION_ID/card", + "Delete Card Attribute Definition", + s"""Delete Card Attribute Definition by ATTRIBUTE_DEFINITION_ID + | + |${userAuthenticationMessage(true)}""".stripMargin, + EmptyBody, EmptyBody, + List($AuthenticatedUserIsRequired, $BankNotFound, UnknownError), + List(apiTagCard, apiTagCardAttribute, apiTagAttribute), + Some(List(canDeleteCardAttributeDefinitionAtOneBank)), + http4sPartialFunction = Some(deleteCardAttributeDefinition)) + + staticResourceDocs += ResourceDoc( + null, implementedInApiVersion, nameOf(deleteTransactionRequestAttributeDefinition), "DELETE", + "/banks/BANK_ID/attribute-definitions/ATTRIBUTE_DEFINITION_ID/transaction-request", + "Delete Transaction Request Attribute Definition", + s"""Delete Transaction Request Attribute Definition by ATTRIBUTE_DEFINITION_ID + | + |${userAuthenticationMessage(true)}""".stripMargin, + EmptyBody, Full(true), + List($AuthenticatedUserIsRequired, $BankNotFound, UnknownError), + List(apiTagTransactionRequest, apiTagTransactionRequestAttribute, apiTagAttribute), + Some(List(canDeleteTransactionRequestAttributeDefinitionAtOneBank)), + http4sPartialFunction = Some(deleteTransactionRequestAttributeDefinition)) + + staticResourceDocs += ResourceDoc( + null, implementedInApiVersion, nameOf(deleteUser), "DELETE", + "/users/USER_ID", + "Delete a User", + s"""Delete a User. + | + |${userAuthenticationMessage(true)}""".stripMargin, + EmptyBody, EmptyBody, + List($AuthenticatedUserIsRequired, UserHasMissingRoles, UnknownError), + List(apiTagUser), + Some(List(canDeleteUser)), + http4sPartialFunction = Some(deleteUser)) + + staticResourceDocs += ResourceDoc( + null, implementedInApiVersion, nameOf(deleteUserCustomerLink), "DELETE", + "/banks/BANK_ID/user_customer_links/USER_CUSTOMER_LINK_ID", + "Delete User Customer Link", + s"""Delete User Customer Link by USER_CUSTOMER_LINK_ID + | + |${userAuthenticationMessage(true)}""".stripMargin, + EmptyBody, EmptyBody, + List($AuthenticatedUserIsRequired, $BankNotFound, UserHasMissingRoles, UnknownError), + List(apiTagCustomer), + Some(List(canDeleteUserCustomerLink)), + http4sPartialFunction = Some(deleteUserCustomerLink)) + + staticResourceDocs += ResourceDoc( + null, implementedInApiVersion, nameOf(deleteTransactionCascade), "DELETE", + "/management/cascading/banks/BANK_ID/accounts/ACCOUNT_ID/transactions/TRANSACTION_ID", + "Delete Transaction Cascade", + s"""Delete a Transaction Cascade specified by TRANSACTION_ID. + | + |${userAuthenticationMessage(true)}""".stripMargin, + EmptyBody, EmptyBody, + List($AuthenticatedUserIsRequired, $BankNotFound, $BankAccountNotFound, UserHasMissingRoles, UnknownError), + List(apiTagTransaction), + Some(List(canDeleteTransactionCascade)), + http4sPartialFunction = Some(deleteTransactionCascade)) + + staticResourceDocs += ResourceDoc( + null, implementedInApiVersion, nameOf(deleteAccountCascade), "DELETE", + "/management/cascading/banks/BANK_ID/accounts/ACCOUNT_ID", + "Delete Account Cascade", + s"""Delete an Account Cascade specified by ACCOUNT_ID. + | + |${userAuthenticationMessage(true)}""".stripMargin, + EmptyBody, EmptyBody, + List($AuthenticatedUserIsRequired, $BankNotFound, $BankAccountNotFound, UserHasMissingRoles, UnknownError), + List(apiTagAccount), + Some(List(canDeleteAccountCascade)), + http4sPartialFunction = Some(deleteAccountCascade)) + + staticResourceDocs += ResourceDoc( + null, implementedInApiVersion, nameOf(deleteBankCascade), "DELETE", + "/management/cascading/banks/BANK_ID", + "Delete Bank Cascade", + s"""Delete a Bank Cascade specified by BANK_ID. + | + |${userAuthenticationMessage(true)}""".stripMargin, + EmptyBody, EmptyBody, + List($AuthenticatedUserIsRequired, $BankNotFound, UserHasMissingRoles, UnknownError), + List(apiTagBank), + Some(List(canDeleteBankCascade)), + http4sPartialFunction = Some(deleteBankCascade)) + + staticResourceDocs += ResourceDoc( + null, implementedInApiVersion, nameOf(deleteProductCascade), "DELETE", + "/management/cascading/banks/BANK_ID/products/PRODUCT_CODE", + "Delete Product Cascade", + s"""Delete a Product Cascade specified by PRODUCT_CODE. + | + |${userAuthenticationMessage(true)}""".stripMargin, + EmptyBody, EmptyBody, + List($AuthenticatedUserIsRequired, $BankNotFound, $BankAccountNotFound, UserHasMissingRoles, UnknownError), + List(apiTagProduct), + Some(List(canDeleteProductCascade)), + http4sPartialFunction = Some(deleteProductCascade)) + + staticResourceDocs += ResourceDoc( + null, implementedInApiVersion, nameOf(deleteCustomerCascade), "DELETE", + "/management/cascading/banks/BANK_ID/customers/CUSTOMER_ID", + "Delete Customer Cascade", + s"""Delete a Customer Cascade specified by CUSTOMER_ID. + | + |${userAuthenticationMessage(true)}""".stripMargin, + EmptyBody, EmptyBody, + List($AuthenticatedUserIsRequired, $BankNotFound, CustomerNotFoundByCustomerId, UserHasMissingRoles, UnknownError), + List(apiTagCustomer), + Some(List(canDeleteCustomerCascade)), + http4sPartialFunction = Some(deleteCustomerCascade)) + + staticResourceDocs += ResourceDoc( + null, implementedInApiVersion, nameOf(deleteSystemLevelEndpointTag), "DELETE", + "/management/endpoints/OPERATION_ID/tags/ENDPOINT_TAG_ID", + "Delete System Level Endpoint Tag", + s"""Delete System Level Endpoint Tag.""", + EmptyBody, Full(true), + List($AuthenticatedUserIsRequired, UserHasMissingRoles, UnknownError), + List(apiTagApi), + Some(List(canDeleteSystemLevelEndpointTag)), + http4sPartialFunction = Some(deleteSystemLevelEndpointTag)) + + staticResourceDocs += ResourceDoc( + null, implementedInApiVersion, nameOf(deleteBankLevelEndpointTag), "DELETE", + "/management/banks/BANK_ID/endpoints/OPERATION_ID/tags/ENDPOINT_TAG_ID", + "Delete Bank Level Endpoint Tag", + s"""Delete Bank Level Endpoint Tag.""", + EmptyBody, Full(true), + List($AuthenticatedUserIsRequired, $BankNotFound, UserHasMissingRoles, UnknownError), + List(apiTagApi), + Some(List(canDeleteBankLevelEndpointTag)), + http4sPartialFunction = Some(deleteBankLevelEndpointTag)) + + staticResourceDocs += ResourceDoc( + null, implementedInApiVersion, nameOf(deleteAuthenticationTypeValidation), "DELETE", + "/management/authentication-type-validations/OPERATION_ID", + "Delete an Authentication Type Validation", + s"""Delete an Authentication Type Validation by operation_id.""", + EmptyBody, BooleanBody(true), + List($AuthenticatedUserIsRequired, UserHasMissingRoles, InvalidJsonFormat, UnknownError), + List(apiTagAuthenticationTypeValidation), + Some(List(canDeleteAuthenticationValidation)), + http4sPartialFunction = Some(deleteAuthenticationTypeValidation)) + + staticResourceDocs += ResourceDoc( + null, implementedInApiVersion, nameOf(deleteJsonSchemaValidation), "DELETE", + "/management/json-schema-validations/OPERATION_ID", + "Delete a JSON Schema Validation", + s"""Delete a JSON Schema Validation by operation_id.""", + EmptyBody, BooleanBody(true), + List($AuthenticatedUserIsRequired, UserHasMissingRoles, InvalidJsonFormat, UnknownError), + List(apiTagJsonSchemaValidation), + Some(List(canDeleteJsonSchemaValidation)), + http4sPartialFunction = Some(deleteJsonSchemaValidation)) + + staticResourceDocs += ResourceDoc( + null, implementedInApiVersion, nameOf(deleteCustomerAttribute), "DELETE", + "/banks/BANK_ID/customers/attributes/CUSTOMER_ATTRIBUTE_ID", + "Delete Customer Attribute", + s"""Delete Customer Attribute. + | + |${userAuthenticationMessage(true)}""".stripMargin, + EmptyBody, EmptyBody, + List(UserHasMissingRoles, UnknownError), + List(apiTagCustomer, apiTagCustomerAttribute, apiTagAttribute), + Some(List(canDeleteCustomerAttributeAtOneBank, canDeleteCustomerAttributeAtAnyBank)), + http4sPartialFunction = Some(deleteCustomerAttribute)) + + staticResourceDocs += ResourceDoc( + null, implementedInApiVersion, nameOf(deleteBankAttribute), "DELETE", + "/banks/BANK_ID/attributes/BANK_ATTRIBUTE_ID", + "Delete Bank Attribute", + s"""Delete Bank Attribute. + | + |${userAuthenticationMessage(true)}""".stripMargin, + EmptyBody, EmptyBody, + List(UserHasMissingRoles, BankNotFound, UnknownError), + List(apiTagBank, apiTagBankAttribute, apiTagAttribute), + Some(List(canDeleteBankAttribute)), + http4sPartialFunction = Some(deleteBankAttribute)) + + staticResourceDocs += ResourceDoc( + null, implementedInApiVersion, nameOf(deleteAtm), "DELETE", + "/banks/BANK_ID/atms/ATM_ID", + "Delete ATM", + s"""Delete ATM.""", + EmptyBody, EmptyBody, + List(UserHasMissingRoles, UnknownError), + List(apiTagATM), + Some(List(canDeleteAtm, canDeleteAtmAtAnyBank)), + http4sPartialFunction = Some(deleteAtm)) + + staticResourceDocs += ResourceDoc( + null, implementedInApiVersion, nameOf(deleteProductFee), "DELETE", + "/banks/BANK_ID/products/PRODUCT_CODE/fees/PRODUCT_FEE_ID", + "Delete Product Fee", + s"""Delete Product Fee.""", + EmptyBody, EmptyBody, + List(UserHasMissingRoles, UnknownError), + List(apiTagProduct), + Some(List(canDeleteProductFee)), + http4sPartialFunction = Some(deleteProductFee)) + + staticResourceDocs += ResourceDoc( + null, implementedInApiVersion, nameOf(deleteEndpointMapping), "DELETE", + "/management/endpoint-mappings/ENDPOINT_MAPPING_ID", + "Delete Endpoint Mapping", + s"""Delete a Endpoint Mapping.""", + EmptyBody, BooleanBody(true), + List($AuthenticatedUserIsRequired, UserHasMissingRoles, InvalidJsonFormat, UnknownError), + List(apiTagEndpointMapping), + Some(List(canDeleteEndpointMapping)), + http4sPartialFunction = Some(deleteEndpointMapping)) + + staticResourceDocs += ResourceDoc( + null, implementedInApiVersion, nameOf(deleteBankLevelEndpointMapping), "DELETE", + "/management/banks/BANK_ID/endpoint-mappings/ENDPOINT_MAPPING_ID", + "Delete Bank Level Endpoint Mapping", + s"""Delete a Bank Level Endpoint Mapping.""", + EmptyBody, BooleanBody(true), + List($BankNotFound, $AuthenticatedUserIsRequired, UserHasMissingRoles, InvalidJsonFormat, UnknownError), + List(apiTagEndpointMapping), + Some(List(canDeleteBankLevelEndpointMapping, canDeleteEndpointMapping)), + http4sPartialFunction = Some(deleteBankLevelEndpointMapping)) + } + initBatch3ResourceDocs() + + // ═══════════════════════════════════════════════════════════════════════════ + // Batch 2 — more simple GETs + // ═══════════════════════════════════════════════════════════════════════════ + + lazy val getEntitlementsForBank: HttpRoutes[IO] = HttpRoutes.of[IO] { + case req @ GET -> `prefixPath` / "banks" / bankIdStr / "entitlements" => + EndpointHelpers.withUser(req) { (_, cc) => + for { + entitlements <- NewStyle.function.getEntitlementsByBankId(bankIdStr, Some(cc)) + } yield JSONFactory400.createEntitlementJSONs(entitlements) + } + } + + lazy val getMyPersonalUserAttributes: HttpRoutes[IO] = HttpRoutes.of[IO] { + case req @ GET -> `prefixPath` / "my" / "user" / "attributes" => + EndpointHelpers.withUser(req) { (user, cc) => + for { + (attributes, _) <- NewStyle.function.getPersonalUserAttributes(user.userId, Some(cc)) + } yield JSONFactory400.createUserAttributesJson(attributes) + } + } + + lazy val getUserWithAttributes: HttpRoutes[IO] = HttpRoutes.of[IO] { + case req @ GET -> `prefixPath` / "users" / userId / "attributes" => + EndpointHelpers.withUser(req) { (_, cc) => + for { + (user, _) <- NewStyle.function.getUserByUserId(userId, Some(cc)) + (attributes, _) <- NewStyle.function.getUserAttributes(user.userId, Some(cc)) + } yield JSONFactory400.createUserWithAttributesJson(user, attributes) + } + } + + lazy val getCustomerAttributes: HttpRoutes[IO] = HttpRoutes.of[IO] { + case req @ GET -> `prefixPath` / "banks" / bankIdStr / "customers" / customerId / "attributes" => + EndpointHelpers.withUser(req) { (_, cc) => + for { + (customer, _) <- NewStyle.function.getCustomerByCustomerId(customerId, Some(cc)) + _ <- code.util.Helper.booleanToFuture( + InvalidCustomerBankId.replaceAll("Bank Id.", s"Bank Id ($bankIdStr).") + .replaceAll("The Customer", s"The Customer($customerId)"), + cc = Some(cc)) { customer.bankId == bankIdStr } + (accountAttribute, _) <- NewStyle.function.getCustomerAttributes( + BankId(bankIdStr), CustomerId(customerId), Some(cc)) + } yield JSONFactory400.createCustomerAttributesJson(accountAttribute) + } + } + + lazy val getCustomerAttributeById: HttpRoutes[IO] = HttpRoutes.of[IO] { + case req @ GET -> `prefixPath` / "banks" / bankIdStr / "customers" / customerId / "attributes" / customerAttributeId => + EndpointHelpers.withUser(req) { (_, cc) => + for { + (customer, _) <- NewStyle.function.getCustomerByCustomerId(customerId, Some(cc)) + _ <- code.util.Helper.booleanToFuture( + InvalidCustomerBankId.replaceAll("Bank Id.", s"Bank Id ($bankIdStr).") + .replaceAll("The Customer", s"The Customer($customerId)"), + cc = Some(cc)) { customer.bankId == bankIdStr } + (accountAttribute, _) <- NewStyle.function.getCustomerAttributeById(customerAttributeId, Some(cc)) + } yield JSONFactory400.createCustomerAttributeJson(accountAttribute) + } + } + + private def getAttributeDefinitionImpl( + category: com.openbankproject.commons.model.enums.AttributeCategory.Value, + cc: CallContext): Future[JValue] = { + for { + (defs, _) <- code.api.util.newstyle.AttributeDefinition.getAttributeDefinition(category, Some(cc)) + } yield net.liftweb.json.Extraction.decompose(JSONFactory400.createAttributeDefinitionsJson(defs)) + } + + lazy val getProductAttributeDefinition: HttpRoutes[IO] = HttpRoutes.of[IO] { + case req @ GET -> `prefixPath` / "banks" / _ / "attribute-definitions" / "product" => + EndpointHelpers.withBank(req) { (_, cc) => + getAttributeDefinitionImpl( + com.openbankproject.commons.model.enums.AttributeCategory.Product, cc) + } + } + + lazy val getCustomerAttributeDefinition: HttpRoutes[IO] = HttpRoutes.of[IO] { + case req @ GET -> `prefixPath` / "banks" / _ / "attribute-definitions" / "customer" => + EndpointHelpers.withBank(req) { (_, cc) => + getAttributeDefinitionImpl( + com.openbankproject.commons.model.enums.AttributeCategory.Customer, cc) + } + } + + lazy val getAccountAttributeDefinition: HttpRoutes[IO] = HttpRoutes.of[IO] { + case req @ GET -> `prefixPath` / "banks" / _ / "attribute-definitions" / "account" => + EndpointHelpers.withBank(req) { (_, cc) => + getAttributeDefinitionImpl( + com.openbankproject.commons.model.enums.AttributeCategory.Account, cc) + } + } + + lazy val getTransactionAttributeDefinition: HttpRoutes[IO] = HttpRoutes.of[IO] { + case req @ GET -> `prefixPath` / "banks" / _ / "attribute-definitions" / "transaction" => + EndpointHelpers.withBank(req) { (_, cc) => + getAttributeDefinitionImpl( + com.openbankproject.commons.model.enums.AttributeCategory.Transaction, cc) + } + } + + lazy val getCardAttributeDefinition: HttpRoutes[IO] = HttpRoutes.of[IO] { + case req @ GET -> `prefixPath` / "banks" / _ / "attribute-definitions" / "card" => + EndpointHelpers.withBank(req) { (_, cc) => + getAttributeDefinitionImpl( + com.openbankproject.commons.model.enums.AttributeCategory.Card, cc) + } + } + + lazy val getJsonSchemaValidation: HttpRoutes[IO] = HttpRoutes.of[IO] { + case req @ GET -> `prefixPath` / "management" / "json-schema-validations" / operationId => + EndpointHelpers.withUser(req) { (_, cc) => + for { + (validation, _) <- NewStyle.function.getJsonSchemaValidationByOperationId(operationId, Some(cc)) + } yield validation + } + } + + lazy val getAllJsonSchemaValidations: HttpRoutes[IO] = HttpRoutes.of[IO] { + case req @ GET -> `prefixPath` / "management" / "json-schema-validations" => + EndpointHelpers.withUser(req) { (_, cc) => + for { + (validations, _) <- NewStyle.function.getJsonSchemaValidations(Some(cc)) + } yield com.openbankproject.commons.model.ListResult("json_schema_validations", validations) + } + case req @ GET -> `prefixPath` / "endpoints" / "json-schema-validations" => + EndpointHelpers.executeAndRespond(req) { cc => + for { + (validations, _) <- NewStyle.function.getJsonSchemaValidations(Some(cc)) + } yield com.openbankproject.commons.model.ListResult("json_schema_validations", validations) + } + } + + lazy val getAuthenticationTypeValidation: HttpRoutes[IO] = HttpRoutes.of[IO] { + case req @ GET -> `prefixPath` / "management" / "authentication-type-validations" / operationId => + EndpointHelpers.withUser(req) { (_, cc) => + for { + (atv, _) <- NewStyle.function.getAuthenticationTypeValidationByOperationId(operationId, Some(cc)) + } yield atv + } + } + + lazy val getAllAuthenticationTypeValidations: HttpRoutes[IO] = HttpRoutes.of[IO] { + case req @ GET -> `prefixPath` / "management" / "authentication-type-validations" => + EndpointHelpers.withUser(req) { (_, cc) => + for { + (atvs, _) <- NewStyle.function.getAuthenticationTypeValidations(Some(cc)) + } yield com.openbankproject.commons.model.ListResult("authentication_types_validations", atvs) + } + case req @ GET -> `prefixPath` / "endpoints" / "authentication-type-validations" => + EndpointHelpers.executeAndRespond(req) { cc => + for { + (atvs, _) <- NewStyle.function.getAuthenticationTypeValidations(Some(cc)) + } yield com.openbankproject.commons.model.ListResult("authentication_types_validations", atvs) + } + } + + lazy val getConnectorMethod: HttpRoutes[IO] = HttpRoutes.of[IO] { + case req @ GET -> `prefixPath` / "management" / "connector-methods" / connectorMethodId => + EndpointHelpers.withUser(req) { (_, cc) => + for { + (cm, _) <- NewStyle.function.getJsonConnectorMethodById(connectorMethodId, Some(cc)) + } yield cm + } + } + + lazy val getAllConnectorMethods: HttpRoutes[IO] = HttpRoutes.of[IO] { + case req @ GET -> `prefixPath` / "management" / "connector-methods" => + EndpointHelpers.withUser(req) { (_, cc) => + for { + (methods, _) <- NewStyle.function.getJsonConnectorMethods(Some(cc)) + } yield com.openbankproject.commons.model.ListResult("connector_methods", methods) + } + } + + lazy val getUserCustomerLinksByUserId: HttpRoutes[IO] = HttpRoutes.of[IO] { + case req @ GET -> `prefixPath` / "banks" / _ / "user_customer_links" / "users" / userId => + EndpointHelpers.withUserAndBank(req) { (_, _, cc) => + for { + (links, _) <- code.api.util.newstyle.UserCustomerLinkNewStyle + .getUserCustomerLinksByUserId(userId, Some(cc)) + } yield code.api.v2_0_0.JSONFactory200.createUserCustomerLinkJSONs(links) + } + } + + lazy val getUserCustomerLinksByCustomerId: HttpRoutes[IO] = HttpRoutes.of[IO] { + case req @ GET -> `prefixPath` / "banks" / _ / "user_customer_links" / "customers" / customerId => + EndpointHelpers.withUserAndBank(req) { (_, _, cc) => + for { + (links, _) <- code.api.util.newstyle.UserCustomerLinkNewStyle + .getUserCustomerLinks(customerId, Some(cc)) + } yield code.api.v2_0_0.JSONFactory200.createUserCustomerLinkJSONs(links) + } + } + + lazy val getCustomerMessages: HttpRoutes[IO] = HttpRoutes.of[IO] { + case req @ GET -> `prefixPath` / "banks" / _ / "customers" / customerId / "messages" => + EndpointHelpers.withUserAndBank(req) { (_, bank, cc) => + for { + (customer, _) <- NewStyle.function.getCustomerByCustomerId(customerId, Some(cc)) + (messages, _) <- NewStyle.function.getCustomerMessages(customer, bank.bankId, Some(cc)) + } yield JSONFactory400.createCustomerMessagesJson(messages) + } + } + + lazy val createCustomerMessage: HttpRoutes[IO] = HttpRoutes.of[IO] { + case req @ POST -> `prefixPath` / "banks" / _ / "customers" / customerId / "messages" => + EndpointHelpers.withUserAndBankAndBodyCreated[CreateMessageJsonV400, code.api.v1_2_1.SuccessMessage](req) { + (_, bank, postedData, cc) => + for { + (customer, _) <- NewStyle.function.getCustomerByCustomerId(customerId, Some(cc)) + (_, _) <- NewStyle.function.createCustomerMessage( + customer, bank.bankId, postedData.transport, postedData.message, + postedData.from_department, postedData.from_person, Some(cc)) + } yield successMessage + } + } + + private def initBatch2ResourceDocs(): Unit = { + staticResourceDocs += ResourceDoc( + null, implementedInApiVersion, nameOf(getEntitlementsForBank), "GET", + "/banks/BANK_ID/entitlements", + "Get Entitlements for One Bank", + s"""${userAuthenticationMessage(true)}""".stripMargin, + EmptyBody, entitlementsJsonV400, + List($AuthenticatedUserIsRequired, BankNotFound, UserHasMissingRoles, UnknownError), + List(apiTagRole, apiTagEntitlement, apiTagUser, apiTagBank), + Some(List(canGetEntitlementsForOneBank, canGetEntitlementsForAnyBank)), + http4sPartialFunction = Some(getEntitlementsForBank)) + + staticResourceDocs += ResourceDoc( + null, implementedInApiVersion, nameOf(getMyPersonalUserAttributes), "GET", + "/my/user/attributes", + "Get my personal User Attributes", + s"""Get my personal User Attributes + | + |${userAuthenticationMessage(true)}""".stripMargin, + EmptyBody, userAttributesResponseJson, + List($AuthenticatedUserIsRequired, UnknownError), + List(apiTagUser), None, + http4sPartialFunction = Some(getMyPersonalUserAttributes)) + + staticResourceDocs += ResourceDoc( + null, implementedInApiVersion, nameOf(getUserWithAttributes), "GET", + "/users/USER_ID/attributes", + "Get User with Attributes by USER_ID", + s"""Get User Attributes for the user defined via USER_ID. + | + |${userAuthenticationMessage(true)}""".stripMargin, + EmptyBody, userWithAttributesResponseJson, + List($AuthenticatedUserIsRequired, UnknownError), + List(apiTagUser), + Some(canGetUsersWithAttributes :: Nil), + http4sPartialFunction = Some(getUserWithAttributes)) + + staticResourceDocs += ResourceDoc( + null, implementedInApiVersion, nameOf(getCustomerAttributes), "GET", + "/banks/BANK_ID/customers/CUSTOMER_ID/attributes", + "Get Customer Attributes", + s"""Get Customer Attributes + | + |${userAuthenticationMessage(true)}""".stripMargin, + EmptyBody, customerAttributesResponseJson, + List($AuthenticatedUserIsRequired, $BankNotFound, InvalidJsonFormat, UnknownError), + List(apiTagCustomer, apiTagCustomerAttribute, apiTagAttribute), + Some(List(canGetCustomerAttributesAtOneBank, canGetCustomerAttributesAtAnyBank)), + http4sPartialFunction = Some(getCustomerAttributes)) + + staticResourceDocs += ResourceDoc( + null, implementedInApiVersion, nameOf(getCustomerAttributeById), "GET", + "/banks/BANK_ID/customers/CUSTOMER_ID/attributes/ATTRIBUTE_ID", + "Get Customer Attribute By Id", + s"""Get Customer Attribute By Id + | + |${userAuthenticationMessage(true)}""".stripMargin, + EmptyBody, customerAttributeResponseJson, + List($AuthenticatedUserIsRequired, $BankNotFound, InvalidJsonFormat, UnknownError), + List(apiTagCustomer, apiTagCustomerAttribute, apiTagAttribute), + Some(List(canGetCustomerAttributeAtOneBank, canGetCustomerAttributeAtAnyBank)), + http4sPartialFunction = Some(getCustomerAttributeById)) + + staticResourceDocs += ResourceDoc( + null, implementedInApiVersion, nameOf(getProductAttributeDefinition), "GET", + "/banks/BANK_ID/attribute-definitions/product", + "Get Product Attribute Definition", + s"""Get Product Attribute Definition + | + |${userAuthenticationMessage(true)}""".stripMargin, + EmptyBody, productAttributeDefinitionsResponseJsonV400, + List($AuthenticatedUserIsRequired, $BankNotFound, UnknownError), + List(apiTagProduct, apiTagProductAttribute, apiTagAttribute), + Some(List(canGetProductAttributeDefinitionAtOneBank)), + http4sPartialFunction = Some(getProductAttributeDefinition)) + + staticResourceDocs += ResourceDoc( + null, implementedInApiVersion, nameOf(getCustomerAttributeDefinition), "GET", + "/banks/BANK_ID/attribute-definitions/customer", + "Get Customer Attribute Definition", + s"""Get Customer Attribute Definition + | + |${userAuthenticationMessage(true)}""".stripMargin, + EmptyBody, customerAttributeDefinitionsResponseJsonV400, + List($AuthenticatedUserIsRequired, $BankNotFound, UnknownError), + List(apiTagCustomer, apiTagCustomerAttribute, apiTagAttribute), + Some(List(canGetCustomerAttributeDefinitionAtOneBank)), + http4sPartialFunction = Some(getCustomerAttributeDefinition)) + + staticResourceDocs += ResourceDoc( + null, implementedInApiVersion, nameOf(getAccountAttributeDefinition), "GET", + "/banks/BANK_ID/attribute-definitions/account", + "Get Account Attribute Definition", + s"""Get Account Attribute Definition + | + |${userAuthenticationMessage(true)}""".stripMargin, + EmptyBody, accountAttributeDefinitionsResponseJsonV400, + List($AuthenticatedUserIsRequired, $BankNotFound, UnknownError), + List(apiTagAccount, apiTagAccountAttribute, apiTagAttribute), + Some(List(canGetAccountAttributeDefinitionAtOneBank)), + http4sPartialFunction = Some(getAccountAttributeDefinition)) + + staticResourceDocs += ResourceDoc( + null, implementedInApiVersion, nameOf(getTransactionAttributeDefinition), "GET", + "/banks/BANK_ID/attribute-definitions/transaction", + "Get Transaction Attribute Definition", + s"""Get Transaction Attribute Definition + | + |${userAuthenticationMessage(true)}""".stripMargin, + EmptyBody, transactionAttributeDefinitionsResponseJsonV400, + List($AuthenticatedUserIsRequired, $BankNotFound, UnknownError), + List(apiTagTransaction, apiTagTransactionAttribute, apiTagAttribute), + Some(List(canGetTransactionAttributeDefinitionAtOneBank)), + http4sPartialFunction = Some(getTransactionAttributeDefinition)) + + staticResourceDocs += ResourceDoc( + null, implementedInApiVersion, nameOf(getCardAttributeDefinition), "GET", + "/banks/BANK_ID/attribute-definitions/card", + "Get Card Attribute Definition", + s"""Get Card Attribute Definition + | + |${userAuthenticationMessage(true)}""".stripMargin, + EmptyBody, cardAttributeDefinitionsResponseJsonV400, + List($AuthenticatedUserIsRequired, $BankNotFound, UnknownError), + List(apiTagCard, apiTagCardAttribute, apiTagAttribute), + Some(List(canGetCardAttributeDefinitionAtOneBank)), + http4sPartialFunction = Some(getCardAttributeDefinition)) + + staticResourceDocs += ResourceDoc( + null, implementedInApiVersion, nameOf(getJsonSchemaValidation), "GET", + "/management/json-schema-validations/OPERATION_ID", + "Get a JSON Schema Validation", + s"""Get a JSON Schema Validation by operation_id.""", + EmptyBody, responseJsonSchema, + List(InvalidJsonFormat, UnknownError), + List(apiTagJsonSchemaValidation), + Some(List(canGetJsonSchemaValidation)), + http4sPartialFunction = Some(getJsonSchemaValidation)) + + staticResourceDocs += ResourceDoc( + null, implementedInApiVersion, nameOf(getAllJsonSchemaValidations), "GET", + "/management/json-schema-validations", + "Get all JSON Schema Validations", + s"""Get all JSON Schema Validations.""", + EmptyBody, + com.openbankproject.commons.model.ListResult("json_schema_validations", responseJsonSchema :: Nil), + List($AuthenticatedUserIsRequired, UserHasMissingRoles, InvalidJsonFormat, UnknownError), + List(apiTagJsonSchemaValidation), + Some(List(canGetJsonSchemaValidation)), + http4sPartialFunction = Some(getAllJsonSchemaValidations)) + + staticResourceDocs += ResourceDoc( + null, implementedInApiVersion, nameOf(getAuthenticationTypeValidation), "GET", + "/management/authentication-type-validations/OPERATION_ID", + "Get an Authentication Type Validation", + s"""Get an Authentication Type Validation by operation_id.""", + EmptyBody, JsonAuthTypeValidation("OBPv4.0.0-updateXxx", List.empty), + List(InvalidJsonFormat, UnknownError), + List(apiTagAuthenticationTypeValidation), + Some(List(canGetAuthenticationTypeValidation)), + http4sPartialFunction = Some(getAuthenticationTypeValidation)) + + staticResourceDocs += ResourceDoc( + null, implementedInApiVersion, nameOf(getAllAuthenticationTypeValidations), "GET", + "/management/authentication-type-validations", + "Get all Authentication Type Validations", + s"""Get all Authentication Type Validations.""", + EmptyBody, + com.openbankproject.commons.model.ListResult( + "authentication_types_validations", + List(JsonAuthTypeValidation("OBPv4.0.0-updateXxx", List.empty))), + List($AuthenticatedUserIsRequired, UserHasMissingRoles, InvalidJsonFormat, UnknownError), + List(apiTagAuthenticationTypeValidation), + Some(List(canGetAuthenticationTypeValidation)), + http4sPartialFunction = Some(getAllAuthenticationTypeValidations)) + + staticResourceDocs += ResourceDoc( + null, implementedInApiVersion, nameOf(getConnectorMethod), "GET", + "/management/connector-methods/CONNECTOR_METHOD_ID", + "Get Connector Method by Id", + s"""Get an internal connector by CONNECTOR_METHOD_ID.""", + EmptyBody, jsonScalaConnectorMethod, + List($AuthenticatedUserIsRequired, UserHasMissingRoles, UnknownError), + List(apiTagConnectorMethod), + Some(List(canGetConnectorMethod)), + http4sPartialFunction = Some(getConnectorMethod)) + + staticResourceDocs += ResourceDoc( + null, implementedInApiVersion, nameOf(getAllConnectorMethods), "GET", + "/management/connector-methods", + "Get all Connector Methods", + s"""Get all Connector Methods.""", + EmptyBody, + com.openbankproject.commons.model.ListResult("connectors_methods", jsonScalaConnectorMethod :: Nil), + List($AuthenticatedUserIsRequired, UserHasMissingRoles, UnknownError), + List(apiTagConnectorMethod), + Some(List(canGetAllConnectorMethods)), + http4sPartialFunction = Some(getAllConnectorMethods)) + + staticResourceDocs += ResourceDoc( + null, implementedInApiVersion, nameOf(getUserCustomerLinksByUserId), "GET", + "/banks/BANK_ID/user_customer_links/users/USER_ID", + "Get User Customer Links by User", + s"""Get User Customer Links by USER_ID + | + |${userAuthenticationMessage(true)}""".stripMargin, + EmptyBody, userCustomerLinksJson, + List($AuthenticatedUserIsRequired, $BankNotFound, UnknownError), + List(apiTagCustomer), + Some(List(canGetUserCustomerLink)), + http4sPartialFunction = Some(getUserCustomerLinksByUserId)) + + staticResourceDocs += ResourceDoc( + null, implementedInApiVersion, nameOf(getUserCustomerLinksByCustomerId), "GET", + "/banks/BANK_ID/user_customer_links/customers/CUSTOMER_ID", + "Get User Customer Links by Customer", + s"""Get User Customer Links by CUSTOMER_ID + | + |${userAuthenticationMessage(true)}""".stripMargin, + EmptyBody, userCustomerLinksJson, + List($AuthenticatedUserIsRequired, $BankNotFound, UnknownError), + List(apiTagCustomer), + Some(List(canGetUserCustomerLink)), + http4sPartialFunction = Some(getUserCustomerLinksByCustomerId)) + + staticResourceDocs += ResourceDoc( + null, implementedInApiVersion, nameOf(getCustomerMessages), "GET", + "/banks/BANK_ID/customers/CUSTOMER_ID/messages", + "Get Messages for Customer", + s"""Get messages for the logged in customer + | + |${userAuthenticationMessage(true)}""".stripMargin, + EmptyBody, customerMessagesJson, + List($AuthenticatedUserIsRequired, $BankNotFound, UnknownError), + List(apiTagMessage, apiTagCustomer), + Some(List(canGetCustomerMessages)), + http4sPartialFunction = Some(getCustomerMessages)) + + staticResourceDocs += ResourceDoc( + null, implementedInApiVersion, nameOf(createCustomerMessage), "POST", + "/banks/BANK_ID/customers/CUSTOMER_ID/messages", + "Create Customer Message", + s"""Create a message for the customer specified by CUSTOMER_ID + |${userAuthenticationMessage(true)}""".stripMargin, + createMessageJsonV400, successMessage, + List($AuthenticatedUserIsRequired, $BankNotFound), + List(apiTagMessage, apiTagCustomer, apiTagPerson), + Some(List(canCreateCustomerMessage)), + http4sPartialFunction = Some(createCustomerMessage)) + } + initBatch2ResourceDocs() + + private def initBatch1ResourceDocs(): Unit = { + staticResourceDocs += ResourceDoc( + null, implementedInApiVersion, nameOf(getCallContext), "GET", + "/development/call_context", + "Get the Call Context of a current call", + s"""Get the Call Context of the current call.""", + EmptyBody, EmptyBody, + List($AuthenticatedUserIsRequired, UnknownError), + List(apiTagApi), + Some(List(canGetCallContext)), + http4sPartialFunction = Some(getCallContext)) + + staticResourceDocs += ResourceDoc( + null, implementedInApiVersion, nameOf(verifyRequestSignResponse), "GET", + "/development/echo/jws-verified-request-jws-signed-response", + "Verify Request and Sign Response of a current call", + s"""Verify Request and Sign Response of a current call.""", + EmptyBody, EmptyBody, + List($AuthenticatedUserIsRequired, UnknownError), + List(apiTagApi), + Some(Nil), + http4sPartialFunction = Some(verifyRequestSignResponse)) + + staticResourceDocs += ResourceDoc( + null, implementedInApiVersion, nameOf(getCurrentUserId), "GET", + "/users/current/user_id", + "Get User Id (Current)", + s"""Get the USER_ID of the logged in user + | + |${userAuthenticationMessage(true)}""".stripMargin, + EmptyBody, userIdJsonV400, + List(AuthenticatedUserIsRequired, UnknownError), + List(apiTagUser), None, + http4sPartialFunction = Some(getCurrentUserId)) + + staticResourceDocs += ResourceDoc( + null, implementedInApiVersion, nameOf(getScannedApiVersions), "GET", + "/api/versions", + "Get scanned API Versions", + s"""Get all the scanned API Versions.""", + EmptyBody, + com.openbankproject.commons.model.ListResult("scanned_api_versions", + List(net.liftweb.json.Extraction.decompose(ApiVersion.v3_1_0))), + List(UnknownError), + List(apiTagDocumentation, apiTagApi), + Some(Nil), + http4sPartialFunction = Some(getScannedApiVersions)) + + staticResourceDocs += ResourceDoc( + null, implementedInApiVersion, nameOf(getMySpaces), "GET", + "/my/spaces", + "Get My Spaces", + s"""Get My Spaces.""", + EmptyBody, code.api.ResourceDocs1_4_0.SwaggerDefinitionsJSON.mySpaces, + List($AuthenticatedUserIsRequired, UnknownError), + List(apiTagUser), None, + http4sPartialFunction = Some(getMySpaces)) + + staticResourceDocs += ResourceDoc( + null, implementedInApiVersion, nameOf(getBankAttributes), "GET", + "/banks/BANK_ID/attributes", + "Get Bank Attributes", + s"""Get Bank Attributes + | + |${userAuthenticationMessage(true)}""".stripMargin, + EmptyBody, bankAttributesResponseJsonV400, + List($AuthenticatedUserIsRequired, $BankNotFound, InvalidJsonFormat, UnknownError), + List(apiTagBank, apiTagBankAttribute, apiTagAttribute), + Some(List(canGetBankAttribute)), + http4sPartialFunction = Some(getBankAttributes)) + + staticResourceDocs += ResourceDoc( + null, implementedInApiVersion, nameOf(getBankAttribute), "GET", + "/banks/BANK_ID/attributes/BANK_ATTRIBUTE_ID", + "Get Bank Attribute By BANK_ATTRIBUTE_ID", + s"""Get Bank Attribute By BANK_ATTRIBUTE_ID + | + |${userAuthenticationMessage(true)}""".stripMargin, + EmptyBody, bankAttributeResponseJsonV400, + List($AuthenticatedUserIsRequired, $BankNotFound, InvalidJsonFormat, UnknownError), + List(apiTagBank, apiTagBankAttribute, apiTagAttribute), + Some(List(canGetBankAttribute)), + http4sPartialFunction = Some(getBankAttribute)) + + staticResourceDocs += ResourceDoc( + null, implementedInApiVersion, nameOf(getSystemLevelEndpointTags), "GET", + "/management/endpoints/OPERATION_ID/tags", + "Get System Level Endpoint Tags", + s"""Get System Level Endpoint Tags.""", + EmptyBody, bankLevelEndpointTagResponseJson400 :: Nil, + List($AuthenticatedUserIsRequired, UserHasMissingRoles, UnknownError), + List(apiTagApi), + Some(List(canGetSystemLevelEndpointTag)), + http4sPartialFunction = Some(getSystemLevelEndpointTags)) + + staticResourceDocs += ResourceDoc( + null, implementedInApiVersion, nameOf(getBankLevelEndpointTags), "GET", + "/management/banks/BANK_ID/endpoints/OPERATION_ID/tags", + "Get Bank Level Endpoint Tags", + s"""Get Bank Level Endpoint Tags.""", + EmptyBody, bankLevelEndpointTagResponseJson400 :: Nil, + List($AuthenticatedUserIsRequired, $BankNotFound, UserHasMissingRoles, UnknownError), + List(apiTagApi), + Some(List(canGetBankLevelEndpointTag)), + http4sPartialFunction = Some(getBankLevelEndpointTags)) + + staticResourceDocs += ResourceDoc( + null, implementedInApiVersion, nameOf(getEndpointMapping), "GET", + "/management/endpoint-mappings/ENDPOINT_MAPPING_ID", + "Get Endpoint Mapping by Id", + s"""Get an Endpoint Mapping by ENDPOINT_MAPPING_ID.""", + EmptyBody, endpointMappingResponseBodyExample, + List($AuthenticatedUserIsRequired, UserHasMissingRoles, UnknownError), + List(apiTagEndpointMapping), + Some(List(canGetEndpointMapping)), + http4sPartialFunction = Some(getEndpointMapping)) + + staticResourceDocs += ResourceDoc( + null, implementedInApiVersion, nameOf(getBankLevelEndpointMapping), "GET", + "/management/banks/BANK_ID/endpoint-mappings/ENDPOINT_MAPPING_ID", + "Get Bank Level Endpoint Mapping", + s"""Get an Bank Level Endpoint Mapping by ENDPOINT_MAPPING_ID.""", + EmptyBody, endpointMappingResponseBodyExample, + List($BankNotFound, $AuthenticatedUserIsRequired, UserHasMissingRoles, UnknownError), + List(apiTagEndpointMapping), + Some(List(canGetBankLevelEndpointMapping, canGetEndpointMapping)), + http4sPartialFunction = Some(getBankLevelEndpointMapping)) + + staticResourceDocs += ResourceDoc( + null, implementedInApiVersion, nameOf(getAllEndpointMappings), "GET", + "/management/endpoint-mappings", + "Get all Endpoint Mappings", + s"""Get all Endpoint Mappings.""", + EmptyBody, + com.openbankproject.commons.model.ListResult("endpoint-mappings", endpointMappingResponseBodyExample :: Nil), + List($AuthenticatedUserIsRequired, UserHasMissingRoles, UnknownError), + List(apiTagEndpointMapping), + Some(List(canGetAllEndpointMappings)), + http4sPartialFunction = Some(getAllEndpointMappings)) + + staticResourceDocs += ResourceDoc( + null, implementedInApiVersion, nameOf(getAllBankLevelEndpointMappings), "GET", + "/management/banks/BANK_ID/endpoint-mappings", + "Get all Bank Level Endpoint Mappings", + s"""Get all Bank Level Endpoint Mappings.""", + EmptyBody, + com.openbankproject.commons.model.ListResult("endpoint-mappings", endpointMappingResponseBodyExample :: Nil), + List($BankNotFound, $AuthenticatedUserIsRequired, UserHasMissingRoles, UnknownError), + List(apiTagEndpointMapping), + Some(List(canGetAllBankLevelEndpointMappings, canGetAllEndpointMappings)), + http4sPartialFunction = Some(getAllBankLevelEndpointMappings)) + } + initBatch1ResourceDocs() + + // ═══════════════════════════════════════════════════════════════════════════ + // Batch 13 — Endpoint Mappings (create/update + bank-level variants) + // ═══════════════════════════════════════════════════════════════════════════ + + private def createEndpointMappingImpl(bankId: Option[String], rawBody: String, cc: CallContext): Future[JValue] = { + for { + endpointMapping <- NewStyle.function.tryons( + s"$InvalidJsonFormat The Json body should be the ${classOf[EndpointMappingCommons]}", + 400, Some(cc)) { + net.liftweb.json.parse(rawBody).extract[EndpointMappingCommons].copy(bankId = bankId) + } + (created, _) <- NewStyle.function.createOrUpdateEndpointMapping( + bankId, + endpointMapping.copy(endpointMappingId = None, bankId = bankId), + Some(cc)) + } yield { + val commons: EndpointMappingCommons = created + commons.toJson + } + } + + private def updateEndpointMappingImpl(bankId: Option[String], endpointMappingId: String, rawBody: String, cc: CallContext): Future[JValue] = { + for { + endpointMappingBody <- NewStyle.function.tryons( + s"$InvalidJsonFormat The Json body should be the ${classOf[EndpointMappingCommons]}", + 400, Some(cc)) { + net.liftweb.json.parse(rawBody).extract[EndpointMappingCommons].copy(bankId = bankId) + } + (existing, callContext) <- NewStyle.function.getEndpointMappingById(bankId, endpointMappingId, Some(cc)) + _ <- code.util.Helper.booleanToFuture( + s"$InvalidJsonFormat operation_id has to be the same in the URL (${existing.operationId}) and Body (${endpointMappingBody.operationId}). ", + 400, callContext) { + existing.operationId == endpointMappingBody.operationId + } + (updated, _) <- NewStyle.function.createOrUpdateEndpointMapping( + bankId, + endpointMappingBody.copy(endpointMappingId = Some(endpointMappingId), bankId = bankId), + callContext) + } yield { + val commons: EndpointMappingCommons = updated + commons.toJson + } + } + + lazy val createEndpointMapping: HttpRoutes[IO] = HttpRoutes.of[IO] { + case req @ POST -> `prefixPath` / "management" / "endpoint-mappings" => + EndpointHelpers.executeFutureCreated(req) { + val cc = req.callContext + createEndpointMappingImpl(None, cc.httpBody.getOrElse(""), cc) + } + } + + lazy val updateEndpointMapping: HttpRoutes[IO] = HttpRoutes.of[IO] { + case req @ PUT -> `prefixPath` / "management" / "endpoint-mappings" / endpointMappingId => + EndpointHelpers.executeFutureCreated(req) { + val cc = req.callContext + updateEndpointMappingImpl(None, endpointMappingId, cc.httpBody.getOrElse(""), cc) + } + } + + lazy val createBankLevelEndpointMapping: HttpRoutes[IO] = HttpRoutes.of[IO] { + case req @ POST -> `prefixPath` / "management" / "banks" / bankIdStr / "endpoint-mappings" => + EndpointHelpers.executeFutureCreated(req) { + val cc = req.callContext + createEndpointMappingImpl(Some(bankIdStr), cc.httpBody.getOrElse(""), cc) + } + } + + lazy val updateBankLevelEndpointMapping: HttpRoutes[IO] = HttpRoutes.of[IO] { + case req @ PUT -> `prefixPath` / "management" / "banks" / bankIdStr / "endpoint-mappings" / endpointMappingId => + EndpointHelpers.executeFutureCreated(req) { + val cc = req.callContext + updateEndpointMappingImpl(Some(bankIdStr), endpointMappingId, cc.httpBody.getOrElse(""), cc) + } + } + + private def initBatch13ResourceDocs(): Unit = { + staticResourceDocs += ResourceDoc( + null, implementedInApiVersion, nameOf(createEndpointMapping), "POST", + "/management/endpoint-mappings", + "Create Endpoint Mapping", + s"""Create an Endpoint Mapping. + | + |Note: at moment only support the dynamic endpoints + |""", + endpointMappingRequestBodyExample, endpointMappingResponseBodyExample, + List($AuthenticatedUserIsRequired, UserHasMissingRoles, InvalidJsonFormat, UnknownError), + List(apiTagEndpointMapping), + Some(List(canCreateEndpointMapping)), + http4sPartialFunction = Some(createEndpointMapping)) + + staticResourceDocs += ResourceDoc( + null, implementedInApiVersion, nameOf(updateEndpointMapping), "PUT", + "/management/endpoint-mappings/ENDPOINT_MAPPING_ID", + "Update Endpoint Mapping", + s"""Update an Endpoint Mapping.""", + endpointMappingRequestBodyExample, endpointMappingResponseBodyExample, + List($AuthenticatedUserIsRequired, UserHasMissingRoles, InvalidJsonFormat, UnknownError), + List(apiTagEndpointMapping), + Some(List(canUpdateEndpointMapping)), + http4sPartialFunction = Some(updateEndpointMapping)) + + staticResourceDocs += ResourceDoc( + null, implementedInApiVersion, nameOf(createBankLevelEndpointMapping), "POST", + "/management/banks/BANK_ID/endpoint-mappings", + "Create Bank Level Endpoint Mapping", + s"""Create an Bank Level Endpoint Mapping. + | + |Note: at moment only support the dynamic endpoints + |""", + endpointMappingRequestBodyExample, endpointMappingResponseBodyExample, + List($BankNotFound, $AuthenticatedUserIsRequired, UserHasMissingRoles, InvalidJsonFormat, UnknownError), + List(apiTagEndpointMapping), + Some(List(canCreateBankLevelEndpointMapping, canCreateEndpointMapping)), + http4sPartialFunction = Some(createBankLevelEndpointMapping)) + + staticResourceDocs += ResourceDoc( + null, implementedInApiVersion, nameOf(updateBankLevelEndpointMapping), "PUT", + "/management/banks/BANK_ID/endpoint-mappings/ENDPOINT_MAPPING_ID", + "Update Bank Level Endpoint Mapping", + s"""Update an Bank Level Endpoint Mapping.""", + endpointMappingRequestBodyExample, endpointMappingResponseBodyExample, + List($BankNotFound, $AuthenticatedUserIsRequired, UserHasMissingRoles, InvalidJsonFormat, UnknownError), + List(apiTagEndpointMapping), + Some(List(canUpdateBankLevelEndpointMapping, canUpdateEndpointMapping)), + http4sPartialFunction = Some(updateBankLevelEndpointMapping)) + } + initBatch13ResourceDocs() + + // ═══════════════════════════════════════════════════════════════════════════ + // Batch 14 — Endpoint Tags CRUD (create/update — system + bank level) + // ═══════════════════════════════════════════════════════════════════════════ + + lazy val createSystemLevelEndpointTag: HttpRoutes[IO] = HttpRoutes.of[IO] { + case req @ POST -> `prefixPath` / "management" / "endpoints" / operationId / "tags" => + EndpointHelpers.executeFutureCreated(req) { + val cc = req.callContext + val rawBody = cc.httpBody.getOrElse("") + for { + endpointTag <- NewStyle.function.tryons( + s"$InvalidJsonFormat The Json body should be the ${classOf[EndpointTagJson400].getSimpleName}", + 400, Some(cc)) { + net.liftweb.json.parse(rawBody).extract[EndpointTagJson400] + } + (exists, callContext) <- NewStyle.function.checkSystemLevelEndpointTagExists( + operationId, endpointTag.tag_name, Some(cc)) + _ <- code.util.Helper.booleanToFuture( + s"$EndpointTagAlreadyExists OPERATION_ID ($operationId) and tag_name(${endpointTag.tag_name})", + cc = callContext) { + !exists + } + (created, _) <- NewStyle.function.createSystemLevelEndpointTag( + operationId, endpointTag.tag_name, callContext) + } yield SystemLevelEndpointTagResponseJson400( + created.endpointTagId.getOrElse(""), + created.operationId, + created.tagName) + } + } + + lazy val updateSystemLevelEndpointTag: HttpRoutes[IO] = HttpRoutes.of[IO] { + case req @ PUT -> `prefixPath` / "management" / "endpoints" / operationId / "tags" / endpointTagId => + EndpointHelpers.executeFutureCreated(req) { + val cc = req.callContext + val rawBody = cc.httpBody.getOrElse("") + for { + endpointTag <- NewStyle.function.tryons( + s"$InvalidJsonFormat The Json body should be the ${classOf[EndpointTagJson400].getSimpleName}", + 400, Some(cc)) { + net.liftweb.json.parse(rawBody).extract[EndpointTagJson400] + } + (_, callContext) <- NewStyle.function.getEndpointTag(endpointTagId, Some(cc)) + (exists, callContext2) <- NewStyle.function.checkSystemLevelEndpointTagExists( + operationId, endpointTag.tag_name, callContext) + _ <- code.util.Helper.booleanToFuture( + s"$EndpointTagAlreadyExists OPERATION_ID ($operationId) and tag_name(${endpointTag.tag_name}), please choose another tag_name", + cc = callContext2) { + !exists + } + (updated, _) <- NewStyle.function.updateSystemLevelEndpointTag( + endpointTagId, operationId, endpointTag.tag_name, callContext2) + } yield SystemLevelEndpointTagResponseJson400( + updated.endpointTagId.getOrElse(""), + updated.operationId, + updated.tagName) + } + } + + lazy val createBankLevelEndpointTag: HttpRoutes[IO] = HttpRoutes.of[IO] { + case req @ POST -> `prefixPath` / "management" / "banks" / bankIdStr / "endpoints" / operationId / "tags" => + EndpointHelpers.executeFutureCreated(req) { + val cc = req.callContext + val rawBody = cc.httpBody.getOrElse("") + for { + endpointTag <- NewStyle.function.tryons( + s"$InvalidJsonFormat The Json body should be the ${classOf[EndpointTagJson400].getSimpleName}", + 400, Some(cc)) { + net.liftweb.json.parse(rawBody).extract[EndpointTagJson400] + } + (exists, callContext) <- NewStyle.function.checkBankLevelEndpointTagExists( + bankIdStr, operationId, endpointTag.tag_name, Some(cc)) + _ <- code.util.Helper.booleanToFuture( + s"$EndpointTagAlreadyExists OPERATION_ID ($operationId) and tag_name(${endpointTag.tag_name})", + cc = callContext) { + !exists + } + (created, _) <- NewStyle.function.createBankLevelEndpointTag( + bankIdStr, operationId, endpointTag.tag_name, callContext) + } yield BankLevelEndpointTagResponseJson400( + created.bankId.getOrElse(""), + created.endpointTagId.getOrElse(""), + created.operationId, + created.tagName) + } + } + + lazy val updateBankLevelEndpointTag: HttpRoutes[IO] = HttpRoutes.of[IO] { + case req @ PUT -> `prefixPath` / "management" / "banks" / bankIdStr / "endpoints" / operationId / "tags" / endpointTagId => + EndpointHelpers.executeFutureCreated(req) { + val cc = req.callContext + val rawBody = cc.httpBody.getOrElse("") + for { + endpointTag <- NewStyle.function.tryons( + s"$InvalidJsonFormat The Json body should be the ${classOf[EndpointTagJson400].getSimpleName}", + 400, Some(cc)) { + net.liftweb.json.parse(rawBody).extract[EndpointTagJson400] + } + (_, callContext) <- NewStyle.function.getEndpointTag(endpointTagId, Some(cc)) + (exists, callContext2) <- NewStyle.function.checkBankLevelEndpointTagExists( + bankIdStr, operationId, endpointTag.tag_name, callContext) + _ <- code.util.Helper.booleanToFuture( + s"$EndpointTagAlreadyExists BANK_ID($bankIdStr), OPERATION_ID ($operationId) and tag_name(${endpointTag.tag_name}), please choose another tag_name", + cc = callContext2) { + !exists + } + (updated, _) <- NewStyle.function.updateBankLevelEndpointTag( + bankIdStr, endpointTagId, operationId, endpointTag.tag_name, callContext2) + } yield BankLevelEndpointTagResponseJson400( + updated.bankId.getOrElse(""), + updated.endpointTagId.getOrElse(""), + updated.operationId, + updated.tagName) + } + } + + private def initBatch14ResourceDocs(): Unit = { + staticResourceDocs += ResourceDoc( + null, implementedInApiVersion, nameOf(createSystemLevelEndpointTag), "POST", + "/management/endpoints/OPERATION_ID/tags", + "Create System Level Endpoint Tag", + s"""Create System Level Endpoint Tag.""", + endpointTagJson400, bankLevelEndpointTagResponseJson400, + List($AuthenticatedUserIsRequired, UserHasMissingRoles, EndpointTagAlreadyExists, InvalidJsonFormat, UnknownError), + List(apiTagApi), + Some(List(canCreateSystemLevelEndpointTag)), + http4sPartialFunction = Some(createSystemLevelEndpointTag)) + + staticResourceDocs += ResourceDoc( + null, implementedInApiVersion, nameOf(updateSystemLevelEndpointTag), "PUT", + "/management/endpoints/OPERATION_ID/tags/ENDPOINT_TAG_ID", + "Update System Level Endpoint Tag", + s"""Update System Level Endpoint Tag, you can only update the tag_name here, operation_id can not be updated.""", + endpointTagJson400, bankLevelEndpointTagResponseJson400, + List($AuthenticatedUserIsRequired, UserHasMissingRoles, EndpointTagNotFoundByEndpointTagId, InvalidJsonFormat, UnknownError), + List(apiTagApi), + Some(List(canUpdateSystemLevelEndpointTag)), + http4sPartialFunction = Some(updateSystemLevelEndpointTag)) + + staticResourceDocs += ResourceDoc( + null, implementedInApiVersion, nameOf(createBankLevelEndpointTag), "POST", + "/management/banks/BANK_ID/endpoints/OPERATION_ID/tags", + "Create Bank Level Endpoint Tag", + s"""Create Bank Level Endpoint Tag""", + endpointTagJson400, bankLevelEndpointTagResponseJson400, + List($AuthenticatedUserIsRequired, $BankNotFound, UserHasMissingRoles, InvalidJsonFormat, UnknownError), + List(apiTagApi), + Some(List(canCreateBankLevelEndpointTag)), + http4sPartialFunction = Some(createBankLevelEndpointTag)) + + staticResourceDocs += ResourceDoc( + null, implementedInApiVersion, nameOf(updateBankLevelEndpointTag), "PUT", + "/management/banks/BANK_ID/endpoints/OPERATION_ID/tags/ENDPOINT_TAG_ID", + "Update Bank Level Endpoint Tag", + s"""Update Endpoint Tag, you can only update the tag_name here, operation_id can not be updated.""", + endpointTagJson400, bankLevelEndpointTagResponseJson400, + List($AuthenticatedUserIsRequired, $BankNotFound, UserHasMissingRoles, EndpointTagNotFoundByEndpointTagId, InvalidJsonFormat, UnknownError), + List(apiTagApi), + Some(List(canUpdateBankLevelEndpointTag)), + http4sPartialFunction = Some(updateBankLevelEndpointTag)) + } + initBatch14ResourceDocs() + + // ═══════════════════════════════════════════════════════════════════════════ + // Batch 15 — JSON Schema + Auth Type Validation + Connector Method + // ═══════════════════════════════════════════════════════════════════════════ + + lazy val createJsonSchemaValidation: HttpRoutes[IO] = HttpRoutes.of[IO] { + case req @ POST -> `prefixPath` / "management" / "json-schema-validations" / operationId => + EndpointHelpers.executeFutureCreated(req) { + val cc = req.callContext + val httpBody = cc.httpBody.getOrElse("") + for { + schemaErrors <- Future { JsonSchemaUtil.validateSchema(httpBody) } + _ <- code.util.Helper.booleanToFuture( + s"$JsonSchemaIllegal${StringUtils.join(schemaErrors, "; ")}", + cc = Some(cc)) { + schemaErrors.isEmpty + } + (isExists, callContext) <- NewStyle.function.isJsonSchemaValidationExists(operationId, Some(cc)) + _ <- code.util.Helper.booleanToFuture(OperationIdExistsError, cc = callContext) { !isExists } + (validation, _) <- NewStyle.function.createJsonSchemaValidation( + JsonValidation(operationId, httpBody), callContext) + } yield validation + } + } + + lazy val updateJsonSchemaValidation: HttpRoutes[IO] = HttpRoutes.of[IO] { + case req @ PUT -> `prefixPath` / "management" / "json-schema-validations" / operationId => + EndpointHelpers.executeAndRespond(req) { cc => + val httpBody = cc.httpBody.getOrElse("") + for { + schemaErrors <- Future { JsonSchemaUtil.validateSchema(httpBody) } + _ <- code.util.Helper.booleanToFuture( + s"$JsonSchemaIllegal${StringUtils.join(schemaErrors, "; ")}", + cc = Some(cc)) { + schemaErrors.isEmpty + } + (isExists, callContext) <- NewStyle.function.isJsonSchemaValidationExists(operationId, Some(cc)) + _ <- code.util.Helper.booleanToFuture(JsonSchemaValidationNotFound, cc = callContext) { isExists } + (validation, _) <- NewStyle.function.updateJsonSchemaValidation(operationId, httpBody, callContext) + } yield validation + } + } + + private lazy val allowedAuthTypes = + AuthenticationType.values.filterNot(AuthenticationType.Anonymous.==) + + lazy val createAuthenticationTypeValidation: HttpRoutes[IO] = HttpRoutes.of[IO] { + case req @ POST -> `prefixPath` / "management" / "authentication-type-validations" / operationId => + EndpointHelpers.executeFutureCreated(req) { + val cc = req.callContext + val rawBody = cc.httpBody.getOrElse("") + for { + authTypes <- NewStyle.function.tryons( + s"$AuthenticationTypeNameIllegal Allowed Authentication Type names: ${allowedAuthTypes.mkString("[", ", ", "]")}", + 400, Some(cc)) { + net.liftweb.json.parse(rawBody).extract[List[AuthenticationType]] + } + (isExists, callContext) <- NewStyle.function.isAuthenticationTypeValidationExists(operationId, Some(cc)) + _ <- code.util.Helper.booleanToFuture(OperationIdExistsError, cc = callContext) { !isExists } + (validation, _) <- NewStyle.function.createAuthenticationTypeValidation( + JsonAuthTypeValidation(operationId, authTypes), callContext) + } yield validation + } + } + + lazy val updateAuthenticationTypeValidation: HttpRoutes[IO] = HttpRoutes.of[IO] { + case req @ PUT -> `prefixPath` / "management" / "authentication-type-validations" / operationId => + EndpointHelpers.executeAndRespond(req) { cc => + val rawBody = cc.httpBody.getOrElse("") + for { + authTypes <- NewStyle.function.tryons( + s"$AuthenticationTypeNameIllegal Allowed AuthenticationType names: ${allowedAuthTypes.mkString("[", ", ", "]")}", + 400, Some(cc)) { + net.liftweb.json.parse(rawBody).extract[List[AuthenticationType]] + } + (isExists, callContext) <- NewStyle.function.isAuthenticationTypeValidationExists(operationId, Some(cc)) + _ <- code.util.Helper.booleanToFuture(AuthenticationTypeValidationNotFound, cc = callContext) { isExists } + (validation, _) <- NewStyle.function.updateAuthenticationTypeValidation( + operationId, authTypes, callContext) + } yield validation + } + } + + lazy val createConnectorMethod: HttpRoutes[IO] = HttpRoutes.of[IO] { + case req @ POST -> `prefixPath` / "management" / "connector-methods" => + EndpointHelpers.executeFutureCreated(req) { + val cc = req.callContext + val rawBody = cc.httpBody.getOrElse("") + for { + jsonConnectorMethod <- NewStyle.function.tryons( + s"$InvalidJsonFormat The Json body should be the ${classOf[JsonConnectorMethod].getSimpleName}", + 400, Some(cc)) { + net.liftweb.json.parse(rawBody).extract[JsonConnectorMethod] + } + (isExists, callContext) <- NewStyle.function.connectorMethodNameExists(jsonConnectorMethod.methodName, Some(cc)) + _ <- code.util.Helper.booleanToFuture( + s"$ConnectorMethodAlreadyExists Please use a different method_name(${jsonConnectorMethod.methodName})", + cc = callContext) { !isExists } + connectorMethod = InternalConnector.createFunction( + jsonConnectorMethod.methodName, + jsonConnectorMethod.decodedMethodBody, + jsonConnectorMethod.programmingLang) + errorMsg = + if (connectorMethod.isEmpty) + s"$ConnectorMethodBodyCompileFail ${connectorMethod.asInstanceOf[Failure].msg}" + else "" + _ <- code.util.Helper.booleanToFuture(errorMsg, cc = callContext) { connectorMethod.isDefined } + _ = Validation.validateDependency(connectorMethod.head) + (created, _) <- NewStyle.function.createJsonConnectorMethod(jsonConnectorMethod, callContext) + } yield created + } + } + + lazy val updateConnectorMethod: HttpRoutes[IO] = HttpRoutes.of[IO] { + case req @ PUT -> `prefixPath` / "management" / "connector-methods" / connectorMethodId => + EndpointHelpers.executeAndRespond(req) { cc => + val rawBody = cc.httpBody.getOrElse("") + for { + connectorMethodBody <- NewStyle.function.tryons( + s"$InvalidJsonFormat The Json body should be the ${classOf[JsonConnectorMethod].getSimpleName}", + 400, Some(cc)) { + net.liftweb.json.parse(rawBody).extract[JsonConnectorMethodMethodBody] + } + (cm, callContext) <- NewStyle.function.getJsonConnectorMethodById(connectorMethodId, Some(cc)) + connectorMethod = InternalConnector.createFunction( + cm.methodName, + connectorMethodBody.decodedMethodBody, + connectorMethodBody.programmingLang) + errorMsg = + if (connectorMethod.isEmpty) + s"$ConnectorMethodBodyCompileFail ${connectorMethod.asInstanceOf[Failure].msg}" + else "" + _ <- code.util.Helper.booleanToFuture(errorMsg, cc = callContext) { connectorMethod.isDefined } + _ = Validation.validateDependency(connectorMethod.head) + (updated, _) <- NewStyle.function.updateJsonConnectorMethod( + connectorMethodId, connectorMethodBody.methodBody, connectorMethodBody.programmingLang, callContext) + } yield updated + } + } + + private def initBatch15ResourceDocs(): Unit = { + staticResourceDocs += ResourceDoc( + null, implementedInApiVersion, nameOf(createJsonSchemaValidation), "POST", + "/management/json-schema-validations/OPERATION_ID", + "Create a JSON Schema Validation", + s"""Create a JSON Schema Validation. + | + |Introduction: + |${Glossary.getGlossaryItemSimple("JSON Schema Validation")} + | + |To use this endpoint, please supply a valid json-schema in the request body. + |""", + postOrPutJsonSchemaV400, responseJsonSchema, + List($AuthenticatedUserIsRequired, UserHasMissingRoles, InvalidJsonFormat, UnknownError), + List(apiTagJsonSchemaValidation), + Some(List(canCreateJsonSchemaValidation)), + http4sPartialFunction = Some(createJsonSchemaValidation)) + + staticResourceDocs += ResourceDoc( + null, implementedInApiVersion, nameOf(updateJsonSchemaValidation), "PUT", + "/management/json-schema-validations/OPERATION_ID", + "Update a JSON Schema Validation", + s"""Update a JSON Schema Validation. + | + |Introduction: + |${Glossary.getGlossaryItemSimple("JSON Schema Validation")} + | + |To use this endpoint, please supply a valid json-schema in the request body. + |""", + postOrPutJsonSchemaV400, responseJsonSchema, + List($AuthenticatedUserIsRequired, UserHasMissingRoles, InvalidJsonFormat, UnknownError), + List(apiTagJsonSchemaValidation), + Some(List(canUpdateJsonSchemaValidation)), + http4sPartialFunction = Some(updateJsonSchemaValidation)) + + staticResourceDocs += ResourceDoc( + null, implementedInApiVersion, nameOf(createAuthenticationTypeValidation), "POST", + "/management/authentication-type-validations/OPERATION_ID", + "Create an Authentication Type Validation", + s"""Create an Authentication Type Validation. + | + |Please supply allowed authentication types.""", + allowedAuthTypes, + JsonAuthTypeValidation("OBPv4.0.0-updateXxx", allowedAuthTypes), + List($AuthenticatedUserIsRequired, UserHasMissingRoles, InvalidJsonFormat, UnknownError), + List(apiTagAuthenticationTypeValidation), + Some(List(canCreateAuthenticationTypeValidation)), + http4sPartialFunction = Some(createAuthenticationTypeValidation)) + + staticResourceDocs += ResourceDoc( + null, implementedInApiVersion, nameOf(updateAuthenticationTypeValidation), "PUT", + "/management/authentication-type-validations/OPERATION_ID", + "Update an Authentication Type Validation", + s"""Update an Authentication Type Validation. + | + |Please supply allowed authentication types.""", + allowedAuthTypes, + JsonAuthTypeValidation("OBPv4.0.0-updateXxx", allowedAuthTypes), + List($AuthenticatedUserIsRequired, UserHasMissingRoles, InvalidJsonFormat, UnknownError), + List(apiTagAuthenticationTypeValidation), + Some(List(canUpdateAuthenticationTypeValidation)), + http4sPartialFunction = Some(updateAuthenticationTypeValidation)) + + staticResourceDocs += ResourceDoc( + null, implementedInApiVersion, nameOf(createConnectorMethod), "POST", + "/management/connector-methods", + "Create Connector Method", + s"""Create an internal connector. + | + |The method_body is URL-encoded format String""", + jsonScalaConnectorMethod.copy(connectorMethodId = None), jsonScalaConnectorMethod, + List($AuthenticatedUserIsRequired, UserHasMissingRoles, InvalidJsonFormat, UnknownError), + List(apiTagConnectorMethod), + Some(List(canCreateConnectorMethod)), + http4sPartialFunction = Some(createConnectorMethod)) + + staticResourceDocs += ResourceDoc( + null, implementedInApiVersion, nameOf(updateConnectorMethod), "PUT", + "/management/connector-methods/CONNECTOR_METHOD_ID", + "Update Connector Method", + s"""Update an internal connector. + | + |The method_body is URL-encoded format String""", + jsonScalaConnectorMethodMethodBody, jsonScalaConnectorMethod, + List($AuthenticatedUserIsRequired, UserHasMissingRoles, InvalidJsonFormat, UnknownError), + List(apiTagConnectorMethod), + Some(List(canUpdateConnectorMethod)), + http4sPartialFunction = Some(updateConnectorMethod)) + } + initBatch15ResourceDocs() + + // ═══════════════════════════════════════════════════════════════════════════ + // Batch 16 — Dynamic Resource Doc CRUD (system + bank level) + // ═══════════════════════════════════════════════════════════════════════════ + + private def validateDynamicResourceDocBody( + body: JsonDynamicResourceDoc, + cc: CallContext): Future[Unit] = { + for { + _ <- code.util.Helper.booleanToFuture( + s"""$InvalidJsonFormat The request_verb must be one of ["POST", "PUT", "GET", "DELETE"]""", + cc = Some(cc)) { + Set("POST", "PUT", "GET", "DELETE").contains(body.requestVerb) + } + _ <- code.util.Helper.booleanToFuture( + s"""$InvalidJsonFormat When request_verb is "GET" or "DELETE", the example_request_body must be a blank String "" or just totally omit the field""", + cc = Some(cc)) { + (body.requestVerb, body.exampleRequestBody) match { + case ("GET" | "DELETE", Some(JString(s))) => StringUtils.isBlank(s) + case ("GET" | "DELETE", Some(requestBody)) => requestBody == JNothing + case _ => true + } + } + } yield () + } + + private def compileDynamicResourceDoc(body: JsonDynamicResourceDoc, cc: CallContext): Unit = { + try { + CompiledObjects(body.exampleRequestBody, body.successResponseBody, body.methodBody).validateDependency() + } catch { + case e: JsonResponseException => throw e + case e: Exception => + val jsonResponse = createErrorJsonResponse( + s"$DynamicCodeCompileFail ${e.getMessage}", 400, cc.correlationId) + throw JsonResponseException(jsonResponse) + } + } + + private def createDynamicResourceDocImpl(bankId: Option[String], rawBody: String, cc: CallContext): Future[JsonDynamicResourceDoc] = { + for { + body <- NewStyle.function.tryons( + s"$InvalidJsonFormat The Json body should be the ${classOf[JsonDynamicResourceDoc].getSimpleName}", + 400, Some(cc)) { + net.liftweb.json.parse(rawBody).extract[JsonDynamicResourceDoc] + } + _ <- validateDynamicResourceDocBody(body, cc) + _ = compileDynamicResourceDoc(body, cc) + (isExists, callContext) <- NewStyle.function.isJsonDynamicResourceDocExists( + bankId, body.requestVerb, body.requestUrl, Some(cc)) + _ <- code.util.Helper.booleanToFuture( + s"$DynamicResourceDocAlreadyExists The combination of request_url(${body.requestUrl}) and request_verb(${body.requestVerb}) must be unique", + cc = callContext) { !isExists } + (created, _) <- NewStyle.function.createJsonDynamicResourceDoc(bankId, body, callContext) + } yield created + } + + private def updateDynamicResourceDocImpl(bankId: Option[String], dynamicResourceDocId: String, rawBody: String, cc: CallContext): Future[JsonDynamicResourceDoc] = { + for { + body <- NewStyle.function.tryons( + s"$InvalidJsonFormat The Json body should be the ${classOf[JsonDynamicResourceDoc].getSimpleName}", + 400, Some(cc)) { + net.liftweb.json.parse(rawBody).extract[JsonDynamicResourceDoc] + } + _ <- validateDynamicResourceDocBody(body, cc) + _ = compileDynamicResourceDoc(body, cc) + (_, callContext) <- NewStyle.function.getJsonDynamicResourceDocById(bankId, dynamicResourceDocId, Some(cc)) + (updated, _) <- NewStyle.function.updateJsonDynamicResourceDoc( + bankId, body.copy(dynamicResourceDocId = Some(dynamicResourceDocId)), callContext) + } yield updated + } + + lazy val createDynamicResourceDoc: HttpRoutes[IO] = HttpRoutes.of[IO] { + case req @ POST -> `prefixPath` / "management" / "dynamic-resource-docs" => + EndpointHelpers.executeFutureCreated(req) { + val cc = req.callContext + createDynamicResourceDocImpl(None, cc.httpBody.getOrElse(""), cc) + } + } + + lazy val updateDynamicResourceDoc: HttpRoutes[IO] = HttpRoutes.of[IO] { + case req @ PUT -> `prefixPath` / "management" / "dynamic-resource-docs" / dynamicResourceDocId => + EndpointHelpers.executeAndRespond(req) { cc => + updateDynamicResourceDocImpl(None, dynamicResourceDocId, cc.httpBody.getOrElse(""), cc) + } + } + + lazy val deleteDynamicResourceDoc: HttpRoutes[IO] = HttpRoutes.of[IO] { + case req @ DELETE -> `prefixPath` / "management" / "dynamic-resource-docs" / dynamicResourceDocId => + EndpointHelpers.withUserDelete(req) { (_, cc) => + for { + (_, callContext) <- NewStyle.function.getJsonDynamicResourceDocById(None, dynamicResourceDocId, Some(cc)) + (deleted, _) <- NewStyle.function.deleteJsonDynamicResourceDocById(None, dynamicResourceDocId, callContext) + } yield deleted + } + } + + lazy val getDynamicResourceDoc: HttpRoutes[IO] = HttpRoutes.of[IO] { + case req @ GET -> `prefixPath` / "management" / "dynamic-resource-docs" / dynamicResourceDocId => + EndpointHelpers.withUser(req) { (_, cc) => + for { + (doc, _) <- NewStyle.function.getJsonDynamicResourceDocById(None, dynamicResourceDocId, Some(cc)) + } yield doc + } + } + + lazy val getAllDynamicResourceDocs: HttpRoutes[IO] = HttpRoutes.of[IO] { + case req @ GET -> `prefixPath` / "management" / "dynamic-resource-docs" => + EndpointHelpers.withUser(req) { (_, cc) => + for { + (docs, _) <- NewStyle.function.getJsonDynamicResourceDocs(None, Some(cc)) + } yield com.openbankproject.commons.model.ListResult("dynamic-resource-docs", docs) + } + } + + lazy val createBankLevelDynamicResourceDoc: HttpRoutes[IO] = HttpRoutes.of[IO] { + case req @ POST -> `prefixPath` / "management" / "banks" / bankIdStr / "dynamic-resource-docs" => + EndpointHelpers.executeFutureCreated(req) { + val cc = req.callContext + createDynamicResourceDocImpl(Some(bankIdStr), cc.httpBody.getOrElse(""), cc) + } + } + + lazy val updateBankLevelDynamicResourceDoc: HttpRoutes[IO] = HttpRoutes.of[IO] { + case req @ PUT -> `prefixPath` / "management" / "banks" / bankIdStr / "dynamic-resource-docs" / dynamicResourceDocId => + EndpointHelpers.executeAndRespond(req) { cc => + updateDynamicResourceDocImpl(Some(bankIdStr), dynamicResourceDocId, cc.httpBody.getOrElse(""), cc) + } + } + + lazy val deleteBankLevelDynamicResourceDoc: HttpRoutes[IO] = HttpRoutes.of[IO] { + case req @ DELETE -> `prefixPath` / "management" / "banks" / bankIdStr / "dynamic-resource-docs" / dynamicResourceDocId => + EndpointHelpers.withUserDelete(req) { (_, cc) => + for { + (_, callContext) <- NewStyle.function.getJsonDynamicResourceDocById(Some(bankIdStr), dynamicResourceDocId, Some(cc)) + (deleted, _) <- NewStyle.function.deleteJsonDynamicResourceDocById(Some(bankIdStr), dynamicResourceDocId, callContext) + } yield deleted + } + } + + lazy val getBankLevelDynamicResourceDoc: HttpRoutes[IO] = HttpRoutes.of[IO] { + case req @ GET -> `prefixPath` / "management" / "banks" / bankIdStr / "dynamic-resource-docs" / dynamicResourceDocId => + EndpointHelpers.withUser(req) { (_, cc) => + for { + (doc, _) <- NewStyle.function.getJsonDynamicResourceDocById(Some(bankIdStr), dynamicResourceDocId, Some(cc)) + } yield doc + } + } + + lazy val getAllBankLevelDynamicResourceDocs: HttpRoutes[IO] = HttpRoutes.of[IO] { + case req @ GET -> `prefixPath` / "management" / "banks" / bankIdStr / "dynamic-resource-docs" => + EndpointHelpers.withUser(req) { (_, cc) => + for { + (docs, _) <- NewStyle.function.getJsonDynamicResourceDocs(Some(bankIdStr), Some(cc)) + } yield com.openbankproject.commons.model.ListResult("dynamic-resource-docs", docs) + } + } + + private def initBatch16ResourceDocs(): Unit = { + staticResourceDocs += ResourceDoc( + null, implementedInApiVersion, nameOf(createDynamicResourceDoc), "POST", + "/management/dynamic-resource-docs", + "Create Dynamic Resource Doc", + s"""Create a Dynamic Resource Doc. + | + |The connector_method_body is URL-encoded format String""", + jsonDynamicResourceDoc.copy(dynamicResourceDocId = None), jsonDynamicResourceDoc, + List($AuthenticatedUserIsRequired, UserHasMissingRoles, InvalidJsonFormat, UnknownError), + List(apiTagDynamicResourceDoc), + Some(List(canCreateDynamicResourceDoc)), + http4sPartialFunction = Some(createDynamicResourceDoc)) + + staticResourceDocs += ResourceDoc( + null, implementedInApiVersion, nameOf(updateDynamicResourceDoc), "PUT", + "/management/dynamic-resource-docs/DYNAMIC_RESOURCE_DOC_ID", + "Update Dynamic Resource Doc", + s"""Update a Dynamic Resource Doc. + | + |The connector_method_body is URL-encoded format String""", + jsonDynamicResourceDoc.copy(dynamicResourceDocId = None), jsonDynamicResourceDoc, + List($AuthenticatedUserIsRequired, UserHasMissingRoles, InvalidJsonFormat, UnknownError), + List(apiTagDynamicResourceDoc), + Some(List(canUpdateDynamicResourceDoc)), + http4sPartialFunction = Some(updateDynamicResourceDoc)) + + staticResourceDocs += ResourceDoc( + null, implementedInApiVersion, nameOf(deleteDynamicResourceDoc), "DELETE", + "/management/dynamic-resource-docs/DYNAMIC_RESOURCE_DOC_ID", + "Delete Dynamic Resource Doc", + s"""Delete a Dynamic Resource Doc.""", + EmptyBody, BooleanBody(true), + List($AuthenticatedUserIsRequired, UserHasMissingRoles, InvalidJsonFormat, UnknownError), + List(apiTagDynamicResourceDoc), + Some(List(canDeleteDynamicResourceDoc)), + http4sPartialFunction = Some(deleteDynamicResourceDoc)) + + staticResourceDocs += ResourceDoc( + null, implementedInApiVersion, nameOf(getDynamicResourceDoc), "GET", + "/management/dynamic-resource-docs/DYNAMIC_RESOURCE_DOC_ID", + "Get Dynamic Resource Doc by Id", + s"""Get a Dynamic Resource Doc by DYNAMIC_RESOURCE_DOC_ID.""", + EmptyBody, jsonDynamicResourceDoc, + List($AuthenticatedUserIsRequired, UserHasMissingRoles, UnknownError), + List(apiTagDynamicResourceDoc), + Some(List(canGetDynamicResourceDoc)), + http4sPartialFunction = Some(getDynamicResourceDoc)) + + staticResourceDocs += ResourceDoc( + null, implementedInApiVersion, nameOf(getAllDynamicResourceDocs), "GET", + "/management/dynamic-resource-docs", + "Get all Dynamic Resource Docs", + s"""Get all Dynamic Resource Docs.""", + EmptyBody, + com.openbankproject.commons.model.ListResult("dynamic-resource-docs", jsonDynamicResourceDoc :: Nil), + List($AuthenticatedUserIsRequired, UserHasMissingRoles, UnknownError), + List(apiTagDynamicResourceDoc), + Some(List(canGetAllDynamicResourceDocs)), + http4sPartialFunction = Some(getAllDynamicResourceDocs)) + + staticResourceDocs += ResourceDoc( + null, implementedInApiVersion, nameOf(createBankLevelDynamicResourceDoc), "POST", + "/management/banks/BANK_ID/dynamic-resource-docs", + "Create Bank Level Dynamic Resource Doc", + s"""Create a Bank Level Dynamic Resource Doc. + | + |The connector_method_body is URL-encoded format String""", + jsonDynamicResourceDoc.copy(dynamicResourceDocId = None), jsonDynamicResourceDoc, + List($BankNotFound, $AuthenticatedUserIsRequired, UserHasMissingRoles, InvalidJsonFormat, UnknownError), + List(apiTagDynamicResourceDoc), + Some(List(canCreateBankLevelDynamicResourceDoc)), + http4sPartialFunction = Some(createBankLevelDynamicResourceDoc)) + + staticResourceDocs += ResourceDoc( + null, implementedInApiVersion, nameOf(updateBankLevelDynamicResourceDoc), "PUT", + "/management/banks/BANK_ID/dynamic-resource-docs/DYNAMIC_RESOURCE_DOC_ID", + "Update Bank Level Dynamic Resource Doc", + s"""Update a Bank Level Dynamic Resource Doc. + | + |The connector_method_body is URL-encoded format String""", + jsonDynamicResourceDoc.copy(dynamicResourceDocId = None), jsonDynamicResourceDoc, + List($BankNotFound, $AuthenticatedUserIsRequired, UserHasMissingRoles, InvalidJsonFormat, UnknownError), + List(apiTagDynamicResourceDoc), + Some(List(canUpdateBankLevelDynamicResourceDoc)), + http4sPartialFunction = Some(updateBankLevelDynamicResourceDoc)) + + staticResourceDocs += ResourceDoc( + null, implementedInApiVersion, nameOf(deleteBankLevelDynamicResourceDoc), "DELETE", + "/management/banks/BANK_ID/dynamic-resource-docs/DYNAMIC_RESOURCE_DOC_ID", + "Delete Bank Level Dynamic Resource Doc", + s"""Delete a Bank Level Dynamic Resource Doc.""", + EmptyBody, BooleanBody(true), + List($BankNotFound, $AuthenticatedUserIsRequired, UserHasMissingRoles, InvalidJsonFormat, UnknownError), + List(apiTagDynamicResourceDoc), + Some(List(canDeleteBankLevelDynamicResourceDoc)), + http4sPartialFunction = Some(deleteBankLevelDynamicResourceDoc)) + + staticResourceDocs += ResourceDoc( + null, implementedInApiVersion, nameOf(getBankLevelDynamicResourceDoc), "GET", + "/management/banks/BANK_ID/dynamic-resource-docs/DYNAMIC_RESOURCE_DOC_ID", + "Get Bank Level Dynamic Resource Doc by Id", + s"""Get a Bank Level Dynamic Resource Doc by DYNAMIC_RESOURCE_DOC_ID.""", + EmptyBody, jsonDynamicResourceDoc, + List($BankNotFound, $AuthenticatedUserIsRequired, UserHasMissingRoles, UnknownError), + List(apiTagDynamicResourceDoc), + Some(List(canGetBankLevelDynamicResourceDoc)), + http4sPartialFunction = Some(getBankLevelDynamicResourceDoc)) + + staticResourceDocs += ResourceDoc( + null, implementedInApiVersion, nameOf(getAllBankLevelDynamicResourceDocs), "GET", + "/management/banks/BANK_ID/dynamic-resource-docs", + "Get all Bank Level Dynamic Resource Docs", + s"""Get all Bank Level Dynamic Resource Docs.""", + EmptyBody, + com.openbankproject.commons.model.ListResult("dynamic-resource-docs", jsonDynamicResourceDoc :: Nil), + List($BankNotFound, $AuthenticatedUserIsRequired, UserHasMissingRoles, UnknownError), + List(apiTagDynamicResourceDoc), + Some(List(canGetAllBankLevelDynamicResourceDocs)), + http4sPartialFunction = Some(getAllBankLevelDynamicResourceDocs)) + } + initBatch16ResourceDocs() + + // ═══════════════════════════════════════════════════════════════════════════ + // Batch 17 — Dynamic Message Doc CRUD (system + bank level) + // ═══════════════════════════════════════════════════════════════════════════ + + private def createDynamicMessageDocImpl(bankId: Option[String], rawBody: String, cc: CallContext): Future[JsonDynamicMessageDoc] = { + for { + body <- NewStyle.function.tryons( + s"$InvalidJsonFormat The Json body should be the ${classOf[JsonDynamicMessageDoc].getSimpleName}", + 400, Some(cc)) { + net.liftweb.json.parse(rawBody).extract[JsonDynamicMessageDoc] + } + (exists, callContext) <- NewStyle.function.isJsonDynamicMessageDocExists(bankId, body.process, Some(cc)) + _ <- code.util.Helper.booleanToFuture( + s"$DynamicMessageDocAlreadyExists The json body process(${body.process}) already exists", + cc = callContext) { !exists } + connectorMethod = DynamicConnector.createFunction(body.programmingLang, body.decodedMethodBody) + errorMsg = + if (connectorMethod.isEmpty) + s"$ConnectorMethodBodyCompileFail ${connectorMethod.asInstanceOf[Failure].msg}" + else "" + _ <- code.util.Helper.booleanToFuture(errorMsg, cc = callContext) { connectorMethod.isDefined } + _ = Validation.validateDependency(connectorMethod.orNull) + (created, _) <- NewStyle.function.createJsonDynamicMessageDoc(bankId, body, callContext) + } yield created + } + + private def updateDynamicMessageDocImpl(bankId: Option[String], dynamicMessageDocId: String, rawBody: String, cc: CallContext): Future[JsonDynamicMessageDoc] = { + for { + body <- NewStyle.function.tryons( + s"$InvalidJsonFormat The Json body should be the ${classOf[JsonDynamicMessageDoc].getSimpleName}", + 400, Some(cc)) { + net.liftweb.json.parse(rawBody).extract[JsonDynamicMessageDoc] + } + connectorMethod = DynamicConnector.createFunction(body.programmingLang, body.decodedMethodBody) + errorMsg = + if (connectorMethod.isEmpty) + s"$ConnectorMethodBodyCompileFail ${connectorMethod.asInstanceOf[Failure].msg}" + else "" + _ <- code.util.Helper.booleanToFuture(errorMsg, cc = Some(cc)) { connectorMethod.isDefined } + _ = Validation.validateDependency(connectorMethod.orNull) + (_, callContext) <- NewStyle.function.getJsonDynamicMessageDocById(bankId, dynamicMessageDocId, Some(cc)) + (updated, _) <- NewStyle.function.updateJsonDynamicMessageDoc( + bankId, body.copy(dynamicMessageDocId = Some(dynamicMessageDocId)), callContext) + } yield updated + } + + lazy val createDynamicMessageDoc: HttpRoutes[IO] = HttpRoutes.of[IO] { + case req @ POST -> `prefixPath` / "management" / "dynamic-message-docs" => + EndpointHelpers.executeFutureCreated(req) { + val cc = req.callContext + createDynamicMessageDocImpl(None, cc.httpBody.getOrElse(""), cc) + } + } + + lazy val updateDynamicMessageDoc: HttpRoutes[IO] = HttpRoutes.of[IO] { + case req @ PUT -> `prefixPath` / "management" / "dynamic-message-docs" / dynamicMessageDocId => + EndpointHelpers.executeAndRespond(req) { cc => + updateDynamicMessageDocImpl(None, dynamicMessageDocId, cc.httpBody.getOrElse(""), cc) + } + } + + lazy val deleteDynamicMessageDoc: HttpRoutes[IO] = HttpRoutes.of[IO] { + case req @ DELETE -> `prefixPath` / "management" / "dynamic-message-docs" / dynamicMessageDocId => + EndpointHelpers.withUserDelete(req) { (_, cc) => + for { + (_, callContext) <- NewStyle.function.getJsonDynamicMessageDocById(None, dynamicMessageDocId, Some(cc)) + (deleted, _) <- NewStyle.function.deleteJsonDynamicMessageDocById(None, dynamicMessageDocId, callContext) + } yield deleted + } + } + + lazy val getDynamicMessageDoc: HttpRoutes[IO] = HttpRoutes.of[IO] { + case req @ GET -> `prefixPath` / "management" / "dynamic-message-docs" / dynamicMessageDocId => + EndpointHelpers.withUser(req) { (_, cc) => + for { + (doc, _) <- NewStyle.function.getJsonDynamicMessageDocById(None, dynamicMessageDocId, Some(cc)) + } yield doc + } + } + + lazy val getAllDynamicMessageDocs: HttpRoutes[IO] = HttpRoutes.of[IO] { + case req @ GET -> `prefixPath` / "management" / "dynamic-message-docs" => + EndpointHelpers.withUser(req) { (_, cc) => + for { + (docs, _) <- NewStyle.function.getJsonDynamicMessageDocs(None, Some(cc)) + } yield com.openbankproject.commons.model.ListResult("dynamic-message-docs", docs) + } + } + + lazy val createBankLevelDynamicMessageDoc: HttpRoutes[IO] = HttpRoutes.of[IO] { + case req @ POST -> `prefixPath` / "management" / "banks" / bankIdStr / "dynamic-message-docs" => + EndpointHelpers.executeFutureCreated(req) { + val cc = req.callContext + createDynamicMessageDocImpl(Some(bankIdStr), cc.httpBody.getOrElse(""), cc) + } + } + + lazy val updateBankLevelDynamicMessageDoc: HttpRoutes[IO] = HttpRoutes.of[IO] { + case req @ PUT -> `prefixPath` / "management" / "banks" / bankIdStr / "dynamic-message-docs" / dynamicMessageDocId => + EndpointHelpers.executeAndRespond(req) { cc => + updateDynamicMessageDocImpl(Some(bankIdStr), dynamicMessageDocId, cc.httpBody.getOrElse(""), cc) + } + } + + lazy val deleteBankLevelDynamicMessageDoc: HttpRoutes[IO] = HttpRoutes.of[IO] { + case req @ DELETE -> `prefixPath` / "management" / "banks" / bankIdStr / "dynamic-message-docs" / dynamicMessageDocId => + EndpointHelpers.withUserDelete(req) { (_, cc) => + for { + (_, callContext) <- NewStyle.function.getJsonDynamicMessageDocById(Some(bankIdStr), dynamicMessageDocId, Some(cc)) + (deleted, _) <- NewStyle.function.deleteJsonDynamicMessageDocById(Some(bankIdStr), dynamicMessageDocId, callContext) + } yield deleted + } + } + + lazy val getBankLevelDynamicMessageDoc: HttpRoutes[IO] = HttpRoutes.of[IO] { + case req @ GET -> `prefixPath` / "management" / "banks" / bankIdStr / "dynamic-message-docs" / dynamicMessageDocId => + EndpointHelpers.withUser(req) { (_, cc) => + // Lift bug-compat: passes None for bankId; preserved verbatim. + for { + (doc, _) <- NewStyle.function.getJsonDynamicMessageDocById(None, dynamicMessageDocId, Some(cc)) + } yield doc + } + } + + lazy val getAllBankLevelDynamicMessageDocs: HttpRoutes[IO] = HttpRoutes.of[IO] { + case req @ GET -> `prefixPath` / "management" / "banks" / bankIdStr / "dynamic-message-docs" => + EndpointHelpers.withUser(req) { (_, cc) => + for { + (docs, _) <- NewStyle.function.getJsonDynamicMessageDocs(Some(bankIdStr), Some(cc)) + } yield com.openbankproject.commons.model.ListResult("dynamic-message-docs", docs) + } + } + + private def initBatch17ResourceDocs(): Unit = { + staticResourceDocs += ResourceDoc( + null, implementedInApiVersion, nameOf(createDynamicMessageDoc), "POST", + "/management/dynamic-message-docs", + "Create Dynamic Message Doc", + s"""Create a Dynamic Message Doc.""", + jsonDynamicMessageDoc.copy(dynamicMessageDocId = None), jsonDynamicMessageDoc, + List($AuthenticatedUserIsRequired, UserHasMissingRoles, InvalidJsonFormat, UnknownError), + List(apiTagDynamicMessageDoc), + Some(List(canCreateDynamicMessageDoc)), + http4sPartialFunction = Some(createDynamicMessageDoc)) + + staticResourceDocs += ResourceDoc( + null, implementedInApiVersion, nameOf(updateDynamicMessageDoc), "PUT", + "/management/dynamic-message-docs/DYNAMIC_MESSAGE_DOC_ID", + "Update Dynamic Message Doc", + s"""Update a Dynamic Message Doc.""", + jsonDynamicMessageDoc.copy(dynamicMessageDocId = None), jsonDynamicMessageDoc, + List($AuthenticatedUserIsRequired, UserHasMissingRoles, InvalidJsonFormat, UnknownError), + List(apiTagDynamicMessageDoc), + Some(List(canUpdateDynamicMessageDoc)), + http4sPartialFunction = Some(updateDynamicMessageDoc)) + + staticResourceDocs += ResourceDoc( + null, implementedInApiVersion, nameOf(deleteDynamicMessageDoc), "DELETE", + "/management/dynamic-message-docs/DYNAMIC_MESSAGE_DOC_ID", + "Delete Dynamic Message Doc", + s"""Delete a Dynamic Message Doc.""", + EmptyBody, BooleanBody(true), + List($AuthenticatedUserIsRequired, UserHasMissingRoles, InvalidJsonFormat, UnknownError), + List(apiTagDynamicMessageDoc), + Some(List(canDeleteDynamicMessageDoc)), + http4sPartialFunction = Some(deleteDynamicMessageDoc)) + + staticResourceDocs += ResourceDoc( + null, implementedInApiVersion, nameOf(getDynamicMessageDoc), "GET", + "/management/dynamic-message-docs/DYNAMIC_MESSAGE_DOC_ID", + "Get Dynamic Message Doc", + s"""Get a Dynamic Message Doc by DYNAMIC_MESSAGE_DOC_ID.""", + EmptyBody, jsonDynamicMessageDoc, + List($AuthenticatedUserIsRequired, UserHasMissingRoles, UnknownError), + List(apiTagDynamicMessageDoc), + Some(List(canGetDynamicMessageDoc)), + http4sPartialFunction = Some(getDynamicMessageDoc)) + + staticResourceDocs += ResourceDoc( + null, implementedInApiVersion, nameOf(getAllDynamicMessageDocs), "GET", + "/management/dynamic-message-docs", + "Get all Dynamic Message Docs", + s"""Get all Dynamic Message Docs.""", + EmptyBody, + com.openbankproject.commons.model.ListResult("dynamic-message-docs", jsonDynamicMessageDoc :: Nil), + List($AuthenticatedUserIsRequired, UserHasMissingRoles, UnknownError), + List(apiTagDynamicMessageDoc), + Some(List(canGetAllDynamicMessageDocs)), + http4sPartialFunction = Some(getAllDynamicMessageDocs)) + + staticResourceDocs += ResourceDoc( + null, implementedInApiVersion, nameOf(createBankLevelDynamicMessageDoc), "POST", + "/management/banks/BANK_ID/dynamic-message-docs", + "Create Bank Level Dynamic Message Doc", + s"""Create a Bank Level Dynamic Message Doc.""", + jsonDynamicMessageDoc.copy(dynamicMessageDocId = None), jsonDynamicMessageDoc, + List($BankNotFound, $AuthenticatedUserIsRequired, UserHasMissingRoles, InvalidJsonFormat, UnknownError), + List(apiTagDynamicMessageDoc), + Some(List(canCreateBankLevelDynamicMessageDoc)), + http4sPartialFunction = Some(createBankLevelDynamicMessageDoc)) + + staticResourceDocs += ResourceDoc( + null, implementedInApiVersion, nameOf(updateBankLevelDynamicMessageDoc), "PUT", + "/management/banks/BANK_ID/dynamic-message-docs/DYNAMIC_MESSAGE_DOC_ID", + "Update Bank Level Dynamic Message Doc", + s"""Update a Bank Level Dynamic Message Doc.""", + jsonDynamicMessageDoc.copy(dynamicMessageDocId = None), jsonDynamicMessageDoc, + List($BankNotFound, $AuthenticatedUserIsRequired, UserHasMissingRoles, InvalidJsonFormat, UnknownError), + List(apiTagDynamicMessageDoc), + Some(List(canUpdateDynamicMessageDoc)), + http4sPartialFunction = Some(updateBankLevelDynamicMessageDoc)) + + staticResourceDocs += ResourceDoc( + null, implementedInApiVersion, nameOf(deleteBankLevelDynamicMessageDoc), "DELETE", + "/management/banks/BANK_ID/dynamic-message-docs/DYNAMIC_MESSAGE_DOC_ID", + "Delete Bank Level Dynamic Message Doc", + s"""Delete a Bank Level Dynamic Message Doc.""", + EmptyBody, BooleanBody(true), + List($BankNotFound, $AuthenticatedUserIsRequired, UserHasMissingRoles, InvalidJsonFormat, UnknownError), + List(apiTagDynamicMessageDoc), + Some(List(canDeleteBankLevelDynamicMessageDoc)), + http4sPartialFunction = Some(deleteBankLevelDynamicMessageDoc)) + + staticResourceDocs += ResourceDoc( + null, implementedInApiVersion, nameOf(getBankLevelDynamicMessageDoc), "GET", + "/management/banks/BANK_ID/dynamic-message-docs/DYNAMIC_MESSAGE_DOC_ID", + "Get Bank Level Dynamic Message Doc", + s"""Get a Bank Level Dynamic Message Doc by DYNAMIC_MESSAGE_DOC_ID.""", + EmptyBody, jsonDynamicMessageDoc, + List($BankNotFound, $AuthenticatedUserIsRequired, UserHasMissingRoles, UnknownError), + List(apiTagDynamicMessageDoc), + Some(List(canGetBankLevelDynamicMessageDoc)), + http4sPartialFunction = Some(getBankLevelDynamicMessageDoc)) + + staticResourceDocs += ResourceDoc( + null, implementedInApiVersion, nameOf(getAllBankLevelDynamicMessageDocs), "GET", + "/management/banks/BANK_ID/dynamic-message-docs", + "Get all Bank Level Dynamic Message Docs", + s"""Get all Bank Level Dynamic Message Docs.""", + EmptyBody, + com.openbankproject.commons.model.ListResult("dynamic-message-docs", jsonDynamicMessageDoc :: Nil), + List($BankNotFound, $AuthenticatedUserIsRequired, UserHasMissingRoles, UnknownError), + List(apiTagDynamicMessageDoc), + Some(List(canGetAllDynamicMessageDocs)), + http4sPartialFunction = Some(getAllBankLevelDynamicMessageDocs)) + } + initBatch17ResourceDocs() + + // ═══════════════════════════════════════════════════════════════════════════ + // Batch 18 — buildDynamicEndpointTemplate + // ═══════════════════════════════════════════════════════════════════════════ + + lazy val buildDynamicEndpointTemplate: HttpRoutes[IO] = HttpRoutes.of[IO] { + case req @ POST -> `prefixPath` / "management" / "dynamic-resource-docs" / "endpoint-code" => + EndpointHelpers.executeFutureCreated(req) { + val cc = req.callContext + val rawBody = cc.httpBody.getOrElse("") + for { + fragment <- NewStyle.function.tryons( + s"$InvalidJsonFormat The Json body should be the ${classOf[code.api.v4_0_0.ResourceDocFragment].getSimpleName}", + 400, Some(cc)) { + net.liftweb.json.parse(rawBody).extract[code.api.v4_0_0.ResourceDocFragment] + } + _ <- code.util.Helper.booleanToFuture( + s"""$InvalidJsonFormat The request_verb must be one of ["POST", "PUT", "GET", "DELETE"]""", + cc = Some(cc)) { + Set("POST", "PUT", "GET", "DELETE").contains(fragment.requestVerb) + } + _ <- code.util.Helper.booleanToFuture( + s"""$InvalidJsonFormat When request_verb is "GET" or "DELETE", the example_request_body must be a blank String""", + cc = Some(cc)) { + (fragment.requestVerb, fragment.exampleRequestBody) match { + case ("GET" | "DELETE", Some(JString(s))) => StringUtils.isBlank(s) + case ("GET" | "DELETE", Some(requestBody)) => requestBody == JNothing + case _ => true + } + } + generatedCode = DynamicEndpointCodeGenerator.buildTemplate(fragment) + } yield code.api.v4_0_0.JsonCodeTemplateJson(URLEncoder.encode(generatedCode, "UTF-8")) + } + } + + private def initBatch18ResourceDocs(): Unit = { + staticResourceDocs += ResourceDoc( + null, implementedInApiVersion, nameOf(buildDynamicEndpointTemplate), "POST", + "/management/dynamic-resource-docs/endpoint-code", + "Create Dynamic Resource Doc endpoint code", + s"""Create a Dynamic Resource Doc endpoint code.""", + jsonResourceDocFragment, jsonCodeTemplateJson, + List($AuthenticatedUserIsRequired, InvalidJsonFormat, UnknownError), + List(apiTagDynamicResourceDoc), None, + http4sPartialFunction = Some(buildDynamicEndpointTemplate)) + } + initBatch18ResourceDocs() + + // ═══════════════════════════════════════════════════════════════════════════ + // Batch 19 — Complex authn endpoints (8 endpoints) + // ═══════════════════════════════════════════════════════════════════════════ + + // ─── Local helpers (inlined from the private APIMethods400 trait helpers) ── + + private def checkRoleBankIdMapping(cc: CallContext, entitlement: CreateEntitlementJSON): Future[Box[Unit]] = { + code.util.Helper.booleanToFuture( + failMsg = + if (code.api.util.ApiRole.valueOf(entitlement.role_name).requiresBankId) EntitlementIsBankRole + else EntitlementIsSystemRole, + cc = Some(cc)) { + code.api.util.ApiRole.valueOf(entitlement.role_name).requiresBankId == entitlement.bank_id.nonEmpty + } + } + + private def checkRoleBankIdMappings(cc: CallContext, postedData: PostCreateUserWithRolesJsonV400) = + Future.sequence(postedData.roles.map(checkRoleBankIdMapping(cc, _))) + + private def checkRoleBankIdExsiting(cc: CallContext, entitlement: CreateEntitlementJSON): Future[Box[Unit]] = { + code.util.Helper.booleanToFuture( + failMsg = s"$BankNotFound Current BANK_ID (${entitlement.bank_id})", + cc = Some(cc)) { + entitlement.bank_id.nonEmpty == false || + BankX(BankId(entitlement.bank_id), Some(cc)).map(_._1).isEmpty == false + } + } + + private def checkRolesBankIdExsiting(cc: CallContext, postedData: PostCreateUserWithRolesJsonV400) = + Future.sequence(postedData.roles.map(checkRoleBankIdExsiting(cc, _))) + + private def checkRoleName(cc: CallContext, entitlement: CreateEntitlementJSON): Future[code.api.util.ApiRole] = { + Future { LiftHelpers.tryo { code.api.util.ApiRole.valueOf(entitlement.role_name) } } map { + val msg = IncorrectRoleName + entitlement.role_name + ". Possible roles are " + + code.api.util.ApiRole.availableRoles.sorted.mkString(", ") + x => unboxFullOrFail(x, Some(cc), msg) + } + } + + private def checkRolesName(cc: CallContext, postJsonBody: PostCreateUserWithRolesJsonV400) = + Future.sequence(postJsonBody.roles.map(checkRoleName(cc, _))) + + private def addEntitlementToUser(userId: String, entitlement: CreateEntitlementJSON) = { + Future { Entitlement.entitlement.vend.addEntitlement(entitlement.bank_id, userId, entitlement.role_name) } map { unboxFull(_) } + } + + private def addEntitlementsToUser(userId: String, postedData: PostCreateUserWithRolesJsonV400) = + Future.sequence(postedData.roles.distinct.map(addEntitlementToUser(userId, _))) + + private def assertTargetUserLacksRoles(userId: String, requestedEntitlements: List[CreateEntitlementJSON], cc: CallContext): Future[Box[Unit]] = { + val userEntitlements = Entitlement.entitlement.vend.getEntitlementsByUserId(userId) + val userRoles = userEntitlements + .map(_.map(e => (e.roleName, e.bankId))) + .getOrElse(List.empty[(String, String)]) + .toSet + val targetRoles = requestedEntitlements.map(e => (e.role_name, e.bank_id)).toSet + val duplicatedRoles = userRoles.filter(targetRoles) + if (duplicatedRoles.nonEmpty) { + val errorMessages = s"$EntitlementAlreadyExists user_id($userId) ${duplicatedRoles.mkString(",")}" + code.util.Helper.booleanToFuture(errorMessages, cc = Some(cc)) { false } + } else Future.successful(Full(())) + } + + private def assertUserCanGrantRoles(userId: String, requestedEntitlements: List[CreateEntitlementJSON], cc: CallContext): Future[Box[Unit]] = { + val userEntitlements = Entitlement.entitlement.vend.getEntitlementsByUserId(userId) + val userRoles = userEntitlements + .map(_.map(e => (e.roleName, e.bankId))) + .getOrElse(List.empty[(String, String)]) + .toSet + val targetRoles = requestedEntitlements.map(e => (e.role_name, e.bank_id)).toSet + val roleLacking = targetRoles.filterNot(userRoles) + if (roleLacking.nonEmpty) { + val errorMessages = s"$EntitlementCannotBeGranted user_id($userId). The login user does not have the following roles: ${roleLacking.mkString(",")}" + code.util.Helper.booleanToFuture(errorMessages, cc = Some(cc)) { false } + } else Future.successful(Full(())) + } + + // ─── addAccount ────────────────────────────────────────────────────────── + + lazy val addAccount: HttpRoutes[IO] = HttpRoutes.of[IO] { + case req @ POST -> `prefixPath` / "banks" / bankIdStr / "accounts" => + EndpointHelpers.executeFutureCreated(req) { + val cc = req.callContext + val rawBody = cc.httpBody.getOrElse("") + val bankId = BankId(bankIdStr) + val failMsg = + s"$InvalidJsonFormat The Json body should be the ${prettyRender(Extraction.decompose(createAccountRequestJsonV310))} " + for { + createAccountJson <- NewStyle.function.tryons(failMsg, 400, Some(cc)) { + net.liftweb.json.parse(rawBody).extract[code.api.v3_1_0.CreateAccountRequestJsonV310] + } + loggedInUserId = cc.userId + userIdAccountOwner = + if (createAccountJson.user_id.nonEmpty) createAccountJson.user_id + else loggedInUserId + (postedOrLoggedInUser, callContext) <- NewStyle.function.findByUserId(userIdAccountOwner, Some(cc)) + _ <- if (userIdAccountOwner == loggedInUserId) Future.successful(Full(())) + else NewStyle.function.hasEntitlement( + bankId.value, loggedInUserId, canCreateAccount, callContext, + s"$UserHasMissingRoles $canCreateAccount or create account for self") + initialBalanceAsString = createAccountJson.balance.amount + accountType = createAccountJson.product_code + accountLabel = createAccountJson.label + initialBalanceAsNumber <- NewStyle.function.tryons(InvalidAccountInitialBalance, 400, callContext) { + BigDecimal(initialBalanceAsString) + } + _ <- code.util.Helper.booleanToFuture(InitialBalanceMustBeZero, cc = callContext) { 0 == initialBalanceAsNumber } + _ <- code.util.Helper.booleanToFuture(InvalidISOCurrencyCode, cc = callContext) { + APIUtil.isValidCurrencyISOCode(createAccountJson.balance.currency) + } + currency = createAccountJson.balance.currency + (_, callContext2) <- NewStyle.function.getBank(bankId, callContext) + _ <- code.util.Helper.booleanToFuture( + s"$InvalidAccountRoutings Duplication detected in account routings, please specify only one value per routing scheme", + cc = callContext2) { + createAccountJson.account_routings.map(_.scheme).distinct.size == createAccountJson.account_routings.size + } + alreadyExistAccountRoutings <- Future.sequence( + createAccountJson.account_routings.map(accountRouting => + NewStyle.function.getAccountRouting(Some(bankId), accountRouting.scheme, accountRouting.address, callContext2) + .map(_ => Some(accountRouting)) + .fallbackTo(Future.successful(None)))) + alreadyExistingAccountRouting = alreadyExistAccountRoutings.collect { + case Some(ar) => s"bankId: $bankId, scheme: ${ar.scheme}, address: ${ar.address}" + } + _ <- code.util.Helper.booleanToFuture( + s"$AccountRoutingAlreadyExist (${alreadyExistingAccountRouting.mkString("; ")})", + cc = callContext2) { + alreadyExistingAccountRouting.isEmpty + } + (bankAccount, callContext3) <- NewStyle.function.createBankAccount( + bankId, AccountId(APIUtil.generateUUID()), accountType, accountLabel, + currency, initialBalanceAsNumber, postedOrLoggedInUser.name, + createAccountJson.branch_id, + createAccountJson.account_routings.map(r => AccountRouting(r.scheme, r.address)), + callContext2) + accountId = bankAccount.accountId + (productAttributes, callContext4) <- NewStyle.function.getProductAttributesByBankAndCode( + bankId, ProductCode(accountType), callContext3) + (accountAttributes, callContext5) <- NewStyle.function.createAccountAttributes( + bankId, accountId, ProductCode(accountType), productAttributes, None, callContext4) + _ <- BankAccountCreation.setAccountHolderAndRefreshUserAccountAccess( + bankId, accountId, postedOrLoggedInUser, callContext5) + } yield code.api.v3_1_0.JSONFactory310.createAccountJSON(userIdAccountOwner, bankAccount, accountAttributes) + } + } + + // ─── createSettlementAccount ────────────────────────────────────────────── + + lazy val createSettlementAccount: HttpRoutes[IO] = HttpRoutes.of[IO] { + case req @ POST -> `prefixPath` / "banks" / bankIdStr / "settlement-accounts" => + EndpointHelpers.executeFutureCreated(req) { + val cc = req.callContext + val rawBody = cc.httpBody.getOrElse("") + val bankId = BankId(bankIdStr) + val failMsg = + s"$InvalidJsonFormat The Json body should be the ${prettyRender(Extraction.decompose(settlementAccountRequestJson))}" + for { + createAccountJson <- NewStyle.function.tryons(failMsg, 400, Some(cc)) { + net.liftweb.json.parse(rawBody).extract[SettlementAccountRequestJson] + } + loggedInUserId = cc.userId + userIdAccountOwner = + if (createAccountJson.user_id.nonEmpty) createAccountJson.user_id + else loggedInUserId + (postedOrLoggedInUser, callContext) <- NewStyle.function.findByUserId(userIdAccountOwner, Some(cc)) + _ <- if (userIdAccountOwner == loggedInUserId) Future.successful(Full(())) + else NewStyle.function.hasEntitlement(bankId.value, loggedInUserId, canCreateSettlementAccountAtOneBank, callContext) + initialBalanceAsString = createAccountJson.balance.amount + accountLabel = createAccountJson.label + initialBalanceAsNumber <- NewStyle.function.tryons(InvalidAccountInitialBalance, 400, callContext) { + BigDecimal(initialBalanceAsString) + } + _ <- code.util.Helper.booleanToFuture(InitialBalanceMustBeZero, cc = callContext) { 0 == initialBalanceAsNumber } + currency = createAccountJson.balance.currency + _ <- code.util.Helper.booleanToFuture(InvalidISOCurrencyCode, cc = callContext) { + APIUtil.isValidCurrencyISOCode(currency) + } + (_, callContext2) <- NewStyle.function.getBank(bankId, callContext) + _ <- code.util.Helper.booleanToFuture( + s"$InvalidAccountRoutings Duplication detected in account routings, please specify only one value per routing scheme", + cc = callContext2) { + createAccountJson.account_routings.map(_.scheme).distinct.size == createAccountJson.account_routings.size + } + alreadyExistAccountRoutings <- Future.sequence( + createAccountJson.account_routings.map(accountRouting => + NewStyle.function.getAccountRouting(Some(bankId), accountRouting.scheme, accountRouting.address, callContext2) + .map(_ => Some(accountRouting)) + .fallbackTo(Future.successful(None)))) + alreadyExistingAccountRouting = alreadyExistAccountRoutings.collect { + case Some(ar) => s"bankId: $bankId, scheme: ${ar.scheme}, address: ${ar.address}" + } + _ <- code.util.Helper.booleanToFuture( + s"$AccountRoutingAlreadyExist (${alreadyExistingAccountRouting.mkString("; ")})", + cc = callContext2) { + alreadyExistingAccountRouting.isEmpty + } + _ <- code.util.Helper.booleanToFuture( + s"$InvalidPaymentSystemName Space characters are not allowed.", + cc = callContext2) { + !createAccountJson.payment_system.contains(" ") + } + accountId = AccountId( + createAccountJson.payment_system.toUpperCase + "_SETTLEMENT_ACCOUNT_" + currency.toUpperCase) + (bankAccount, callContext3) <- NewStyle.function.createBankAccount( + bankId, accountId, "SETTLEMENT", accountLabel, currency, initialBalanceAsNumber, + postedOrLoggedInUser.name, createAccountJson.branch_id, + createAccountJson.account_routings.map(r => AccountRouting(r.scheme, r.address)), + callContext2) + (productAttributes, callContext4) <- NewStyle.function.getProductAttributesByBankAndCode( + bankId, ProductCode("SETTLEMENT"), callContext3) + (accountAttributes, callContext5) <- NewStyle.function.createAccountAttributes( + bankId, bankAccount.accountId, ProductCode("SETTLEMENT"), productAttributes, None, callContext4) + _ <- BankAccountCreation.setAccountHolderAndRefreshUserAccountAccess( + bankId, bankAccount.accountId, postedOrLoggedInUser, callContext5) + } yield JSONFactory400.createSettlementAccountJson(userIdAccountOwner, bankAccount, accountAttributes) + } + } + + // ─── createConsumer ────────────────────────────────────────────────────── + + lazy val createConsumer: HttpRoutes[IO] = HttpRoutes.of[IO] { + case req @ POST -> `prefixPath` / "management" / "consumers" => + EndpointHelpers.withUser(req) { (u, cc) => + val rawBody = cc.httpBody.getOrElse("") + for { + postedJsonAndAppType <- NewStyle.function.tryons(InvalidJsonFormat, 400, Some(cc)) { + val consumerPostJSON = net.liftweb.json.parse(rawBody).extract[code.api.v2_1_0.ConsumerPostJSON] + val appType = + if (consumerPostJSON.app_type.equals("Confidential")) AppType.valueOf("Confidential") + else AppType.valueOf("Public") + (consumerPostJSON, appType) + } + (postedJson, appType) = postedJsonAndAppType + _ <- NewStyle.function.hasEntitlement("", u.userId, code.api.util.ApiRole.canCreateConsumer, Some(cc)) + (consumer, callContext) <- createConsumerNewStyle( + key = Some(LiftHelpers.randomString(40).toLowerCase), + secret = Some(LiftHelpers.randomString(40).toLowerCase), + isActive = Some(postedJson.enabled), + name = Some(postedJson.app_name), + appType = Some(appType), + description = Some(postedJson.description), + developerEmail = Some(postedJson.developer_email), + company = None, + redirectURL = Some(postedJson.redirect_url), + createdByUserId = Some(u.userId), + clientCertificate = Some(postedJson.clientCertificate), + logoURL = None, + Some(cc)) + user <- Users.users.vend.getUserByUserIdFuture(u.userId) + } yield JSONFactory400.createConsumerJSON(consumer, user) + } + } + + // ─── createCounterpartyForAnyAccount ────────────────────────────────────── + + lazy val createCounterpartyForAnyAccount: HttpRoutes[IO] = HttpRoutes.of[IO] { + case req @ POST -> `prefixPath` / "management" / "banks" / bankIdStr / "accounts" / accountIdStr / viewIdStr / "counterparties" => + EndpointHelpers.withViewCreated[code.api.v4_0_0.CounterpartyWithMetadataJson400](req) { (user, _, _, cc) => + val u = user + val bankId = BankId(bankIdStr) + val accountId = AccountId(accountIdStr) + val rawBody = cc.httpBody.getOrElse("") + for { + postJson <- NewStyle.function.tryons(InvalidJsonFormat, 400, Some(cc)) { + net.liftweb.json.parse(rawBody).extract[PostCounterpartyJson400] + } + _ <- code.util.Helper.booleanToFuture( + s"$InvalidValueLength. The maximum length of `description` field is ${MappedCounterparty.mDescription.maxLen}", + cc = Some(cc)) { postJson.description.length <= 36 } + (counterparty, callContext) <- Connector.connector.vend.checkCounterpartyExists( + postJson.name, bankId.value, accountId.value, viewIdStr, Some(cc)) + _ <- code.util.Helper.booleanToFuture( + CounterpartyAlreadyExists.replace( + "value for BANK_ID or ACCOUNT_ID or VIEW_ID or NAME.", + s"COUNTERPARTY_NAME(${postJson.name}) for the BANK_ID(${bankId.value}) and ACCOUNT_ID(${accountId.value}) and VIEW_ID($viewIdStr)"), + cc = callContext) { counterparty.isEmpty } + _ <- code.util.Helper.booleanToFuture( + s"$InvalidISOCurrencyCode Current input is: '${postJson.currency}'", + cc = callContext) { APIUtil.isValidCurrencyISOCode(postJson.currency) } + (_, callContext2) <- + if (postJson.other_bank_routing_scheme.equalsIgnoreCase("OBP") + && postJson.other_account_routing_scheme.equalsIgnoreCase("OBP")) { + for { + (_, ctx) <- NewStyle.function.getBank(BankId(postJson.other_bank_routing_address), Some(cc)) + (account, ctx2) <- NewStyle.function.checkBankAccountExists( + BankId(postJson.other_bank_routing_address), + AccountId(postJson.other_account_routing_address), ctx) + } yield (account, ctx2) + } else if (postJson.other_bank_routing_scheme.equalsIgnoreCase("OBP") + && postJson.other_account_secondary_routing_scheme.equalsIgnoreCase("OBP")) { + for { + (_, ctx) <- NewStyle.function.getBank(BankId(postJson.other_bank_routing_address), Some(cc)) + (account, ctx2) <- NewStyle.function.checkBankAccountExists( + BankId(postJson.other_bank_routing_address), + AccountId(postJson.other_account_secondary_routing_address), ctx) + } yield (account, ctx2) + } else if (postJson.other_bank_routing_scheme.equalsIgnoreCase("ACCOUNT_NUMBER") + || postJson.other_bank_routing_scheme.equalsIgnoreCase("ACCOUNT_NO")) { + for { + bankIdOption <- Future.successful( + if (postJson.other_bank_routing_address.isEmpty) None + else Some(postJson.other_bank_routing_address)) + (account, ctx) <- NewStyle.function.getBankAccountByNumber( + bankIdOption.map(BankId(_)), postJson.other_bank_routing_address, callContext) + } yield (account, ctx) + } else Future { (Full(()), Some(cc)) } + otherAccountRoutingSchemeOBPFormat = + if (postJson.other_account_routing_scheme.equalsIgnoreCase("AccountNo")) "ACCOUNT_NUMBER" + else StringHelpers.snakify(postJson.other_account_routing_scheme).toUpperCase + (createdCounterparty, callContext3) <- NewStyle.function.createCounterparty( + name = postJson.name, + description = postJson.description, + currency = postJson.currency, + createdByUserId = u.userId, + thisBankId = bankId.value, + thisAccountId = accountId.value, + thisViewId = Constant.SYSTEM_OWNER_VIEW_ID, + otherAccountRoutingScheme = otherAccountRoutingSchemeOBPFormat, + otherAccountRoutingAddress = postJson.other_account_routing_address, + otherAccountSecondaryRoutingScheme = StringHelpers.snakify(postJson.other_account_secondary_routing_scheme).toUpperCase, + otherAccountSecondaryRoutingAddress = postJson.other_account_secondary_routing_address, + otherBankRoutingScheme = StringHelpers.snakify(postJson.other_bank_routing_scheme).toUpperCase, + otherBankRoutingAddress = postJson.other_bank_routing_address, + otherBranchRoutingScheme = StringHelpers.snakify(postJson.other_branch_routing_scheme).toUpperCase, + otherBranchRoutingAddress = postJson.other_branch_routing_address, + isBeneficiary = postJson.is_beneficiary, + bespoke = postJson.bespoke.map(b => CounterpartyBespoke(b.key, b.value)), + callContext2) + (counterpartyMetadata, _) <- NewStyle.function.getOrCreateMetadata( + bankId, accountId, createdCounterparty.counterpartyId, postJson.name, callContext3) + } yield JSONFactory400.createCounterpartyWithMetadataJson400(createdCounterparty, counterpartyMetadata) + } + } + + // ─── createHistoricalTransactionAtBank ──────────────────────────────────── + + lazy val createHistoricalTransactionAtBank: HttpRoutes[IO] = HttpRoutes.of[IO] { + case req @ POST -> `prefixPath` / "banks" / bankIdStr / "management" / "historical" / "transactions" => + EndpointHelpers.executeFutureCreated(req) { + val cc = req.callContext + val rawBody = cc.httpBody.getOrElse("") + val bankId = BankId(bankIdStr) + for { + _ <- NewStyle.function.hasEntitlement( + bankId.value, cc.userId, code.api.util.ApiRole.canCreateHistoricalTransactionAtBank, Some(cc)) + transDetailsJson <- NewStyle.function.tryons( + s"$InvalidJsonFormat The Json body should be the ${classOf[PostHistoricalTransactionAtBankJson].getSimpleName} ", + 400, Some(cc)) { + net.liftweb.json.parse(rawBody).extract[PostHistoricalTransactionAtBankJson] + } + (fromAccount, callContext) <- NewStyle.function.checkBankAccountExists( + bankId, AccountId(transDetailsJson.from_account_id), Some(cc)) + (toAccount, callContext2) <- NewStyle.function.checkBankAccountExists( + bankId, AccountId(transDetailsJson.to_account_id), callContext) + amountNumber <- NewStyle.function.tryons( + s"$InvalidNumber Current input is ${transDetailsJson.value.amount} ", + 400, callContext2) { BigDecimal(transDetailsJson.value.amount) } + _ <- code.util.Helper.booleanToFuture( + s"$NotPositiveAmount Current input is: '$amountNumber'", + cc = callContext2) { amountNumber > BigDecimal("0") } + posted <- NewStyle.function.tryons( + s"$InvalidDateFormat Current `posted` field is ${transDetailsJson.posted}. Please use this format ${DateWithSecondsFormat.toPattern}! ", + 400, callContext2) { + new SimpleDateFormat(DateWithSeconds).parse(transDetailsJson.posted) + } + completed <- NewStyle.function.tryons( + s"$InvalidDateFormat Current `completed` field is ${transDetailsJson.completed}. Please use this format ${DateWithSecondsFormat.toPattern}! ", + 400, callContext2) { + new SimpleDateFormat(DateWithSeconds).parse(transDetailsJson.completed) + } + _ <- code.util.Helper.booleanToFuture( + s"$InvalidISOCurrencyCode Current input is: '${transDetailsJson.value.currency}'", + cc = callContext2) { + APIUtil.isValidCurrencyISOCode(transDetailsJson.value.currency) + } + amountOfMoneyJson = com.openbankproject.commons.model.AmountOfMoneyJsonV121(transDetailsJson.value.currency, transDetailsJson.value.amount) + chargePolicy = transDetailsJson.charge_policy + transactionType = transDetailsJson.`type` + (transactionId, _) <- NewStyle.function.makeHistoricalPayment( + fromAccount, toAccount, posted, completed, amountNumber, + transDetailsJson.value.currency, transDetailsJson.description, + transactionType, chargePolicy, callContext2) + } yield JSONFactory400.createPostHistoricalTransactionResponseJson( + bankId, transactionId, fromAccount.accountId, toAccount.accountId, + value = amountOfMoneyJson, description = transDetailsJson.description, + posted, completed, transactionRequestType = transactionType, + chargePolicy = transDetailsJson.charge_policy) + } + } + + // ─── createUserWithRoles ────────────────────────────────────────────────── + + lazy val createUserWithRoles: HttpRoutes[IO] = HttpRoutes.of[IO] { + case req @ POST -> `prefixPath` / "user-entitlements" => + EndpointHelpers.executeFutureCreated(req) { + implicit val cc: CallContext = req.callContext + val loggedInUser = cc.user.openOrThrowException(AuthenticatedUserIsRequired) + val rawBody = cc.httpBody.getOrElse("") + val failMsg = s"$InvalidJsonFormat The Json body should be the ${classOf[PostCreateUserWithRolesJsonV400].getSimpleName} " + for { + postedData <- NewStyle.function.tryons(failMsg, 400, Some(cc)) { + net.liftweb.json.parse(rawBody).extract[PostCreateUserWithRolesJsonV400] + } + _ <- code.util.Helper.booleanToFuture( + s"$InvalidUserProvider The user.provider must be start with 'dauth.'", + cc = Some(cc)) { postedData.provider.startsWith("dauth.") } + _ <- checkRoleBankIdMappings(cc, postedData) + _ <- checkRolesBankIdExsiting(cc, postedData) + _ <- checkRolesName(cc, postedData) + canCreateEntitlementAtAnyBankRole = Entitlement.entitlement.vend + .getEntitlement("", loggedInUser.userId, canCreateEntitlementAtAnyBank.toString()) + (targetUser, callContext) <- NewStyle.function.getOrCreateResourceUser( + postedData.provider, postedData.username, Some(cc)) + _ <- if (canCreateEntitlementAtAnyBankRole.isDefined) + assertTargetUserLacksRoles(targetUser.userId, postedData.roles, cc) + else assertUserCanGrantRoles(loggedInUser.userId, postedData.roles, cc) + addedEntitlements <- addEntitlementsToUser(targetUser.userId, postedData) + } yield JSONFactory400.createEntitlementJSONs(addedEntitlements) + } + } + + // ─── createUserWithAccountAccess ────────────────────────────────────────── + + lazy val createUserWithAccountAccess: HttpRoutes[IO] = HttpRoutes.of[IO] { + case req @ POST -> `prefixPath` / "banks" / bankIdStr / "accounts" / accountIdStr / "user-account-access" => + EndpointHelpers.executeFutureCreated(req) { + implicit val cc: CallContext = req.callContext + val u = cc.user.openOrThrowException(AuthenticatedUserIsRequired) + val bankId = BankId(bankIdStr) + val accountId = AccountId(accountIdStr) + val rawBody = cc.httpBody.getOrElse("") + val failMsg = s"$InvalidJsonFormat The Json body should be the ${classOf[PostCreateUserAccountAccessJsonV400].getSimpleName} " + for { + postJson <- NewStyle.function.tryons(failMsg, 400, Some(cc)) { + net.liftweb.json.parse(rawBody).extract[PostCreateUserAccountAccessJsonV400] + } + _ <- code.util.Helper.booleanToFuture( + s"$InvalidUserProvider The user.provider must be start with 'dauth.'", + cc = Some(cc)) { postJson.provider.startsWith("dauth.") } + viewIdList = postJson.views.map(view => ViewId(view.view_id)) + msg = UserLacksPermissionCanGrantAccessToViewForTargetAccount + + s"Current ViewIds(${viewIdList.mkString}) and current UserId(${u.userId})" + _ <- code.util.Helper.booleanToFuture(msg, 403, cc = Some(cc)) { + APIUtil.canGrantAccessToMultipleViews(bankId, accountId, viewIdList, u, Some(cc)) + } + (targetUser, callContext) <- NewStyle.function.getOrCreateResourceUser( + postJson.provider, postJson.username, Some(cc)) + views <- Future.sequence(postJson.views.map(view => + JSONFactory400.getView(bankId, accountId, view, callContext))) + addedView <- Future.sequence(views.map(view => + JSONFactory400.grantAccountAccessToUser(bankId, accountId, targetUser, view, callContext))) + } yield addedView.map(code.api.v3_0_0.JSONFactory300.createViewJSON(_)) + } + } + + // ─── createUserInvitation ───────────────────────────────────────────────── + + private val INVITATION_EMAIL_RECIPIENT_PLACEHOLDER = "{{email_recipient}}" + private val INVITATION_ACTIVATE_ACCOUNT_PLACEHOLDER = "{{activate_your_account}}" + private val INVITATION_DEFAULT_EMAIL_TEXT = s"Dear $INVITATION_EMAIL_RECIPIENT_PLACEHOLDER, please activate your account: $INVITATION_ACTIVATE_ACCOUNT_PLACEHOLDER" + private val INVITATION_DEFAULT_EMAIL_HTML = s"

Dear $INVITATION_EMAIL_RECIPIENT_PLACEHOLDER,

Please activate your account: Activate

" + + lazy val createUserInvitation: HttpRoutes[IO] = HttpRoutes.of[IO] { + case req @ POST -> `prefixPath` / "banks" / bankIdStr / "user-invitation" => + EndpointHelpers.executeFutureCreated(req) { + val cc = req.callContext + val rawBody = cc.httpBody.getOrElse("") + val bankId = BankId(bankIdStr) + val failMsg = s"$InvalidJsonFormat The Json body should be the ${classOf[PostUserInvitationJsonV400].getSimpleName} " + for { + postedData <- NewStyle.function.tryons(failMsg, 400, Some(cc)) { + net.liftweb.json.parse(rawBody).extract[PostUserInvitationJsonV400] + } + _ <- NewStyle.function.tryons( + s"$InvalidJsonValue postedData.purpose only support ${com.openbankproject.commons.model.enums.UserInvitationPurpose.values.toString()}", + 400, Some(cc)) { + com.openbankproject.commons.model.enums.UserInvitationPurpose.withName(postedData.purpose) + } + (invitation, callContext) <- NewStyle.function.createUserInvitation( + bankId, postedData.first_name, postedData.last_name, + postedData.email, postedData.company, postedData.country, postedData.purpose, Some(cc)) + _ = { + val link = s"${APIUtil.getPropsValue("user_invitation_link_base_URL", APIUtil.getPropsValue("portal_hostname", Constant.HostName))}/user-invitation?id=${invitation.secretKey}" + if (postedData.purpose == com.openbankproject.commons.model.enums.UserInvitationPurpose.DEVELOPER.toString) { + val subject = getWebUiPropsValue("webui_developer_user_invitation_email_subject", "Welcome to the API Playground") + val from = getWebUiPropsValue("webui_developer_user_invitation_email_from", "do-not-reply@openbankproject.com") + val customText = getWebUiPropsValue("webui_developer_user_invitation_email_text", INVITATION_DEFAULT_EMAIL_TEXT) + val customHtmlText = getWebUiPropsValue("webui_developer_user_invitation_email_html_text", INVITATION_DEFAULT_EMAIL_HTML) + .replace(INVITATION_EMAIL_RECIPIENT_PLACEHOLDER, invitation.firstName) + .replace(INVITATION_ACTIVATE_ACCOUNT_PLACEHOLDER, link) + val emailContent = EmailContent( + from = from, to = List(invitation.email), subject = subject, + textContent = Some(customText), htmlContent = Some(customHtmlText)) + sendHtmlEmail(emailContent) + } else { + val subject = getWebUiPropsValue("webui_customer_user_invitation_email_subject", "Welcome to the API Playground") + val from = getWebUiPropsValue("webui_customer_user_invitation_email_from", "do-not-reply@openbankproject.com") + val customText = getWebUiPropsValue("webui_customer_user_invitation_email_text", INVITATION_DEFAULT_EMAIL_TEXT) + val customHtmlText = getWebUiPropsValue("webui_customer_user_invitation_email_html_text", INVITATION_DEFAULT_EMAIL_HTML) + .replace(INVITATION_EMAIL_RECIPIENT_PLACEHOLDER, invitation.firstName) + .replace(INVITATION_ACTIVATE_ACCOUNT_PLACEHOLDER, link) + val emailContent = EmailContent( + from = from, to = List(invitation.email), subject = subject, + textContent = Some(customText), htmlContent = Some(customHtmlText)) + sendHtmlEmail(emailContent) + } + } + } yield JSONFactory400.createUserInvitationJson(invitation) + } + } + + private def initBatch19ResourceDocs(): Unit = { + staticResourceDocs += ResourceDoc( + null, implementedInApiVersion, nameOf(addAccount), "POST", + "/banks/BANK_ID/accounts", + "Create Account (POST)", + """Create Account at bank specified by BANK_ID. + | + |The User can create an Account for themself - or - the User that has the USER_ID specified in the POST body. + | + |If the POST body USER_ID *is* specified, the logged in user must have the Role CanCreateAccount. Once created, the Account will be owned by the User specified by USER_ID. + | + |If the POST body USER_ID is *not* specified, the account will be owned by the logged in User. + | + |The 'product_code' field SHOULD be a product_code from Product. + |If the product_code matches a product_code from Product, account attributes will be created that match the Product Attributes. + | + |Note: The Amount MUST be zero.""".stripMargin, + createAccountRequestJsonV310, createAccountResponseJsonV310, + List(InvalidJsonFormat, $AuthenticatedUserIsRequired, UserHasMissingRoles, + InvalidAccountBalanceAmount, InvalidAccountInitialBalance, InitialBalanceMustBeZero, + InvalidAccountBalanceCurrency, UnknownError), + List(apiTagAccount), + Some(List(canCreateAccount)), + http4sPartialFunction = Some(addAccount)) + + staticResourceDocs += ResourceDoc( + null, implementedInApiVersion, nameOf(createSettlementAccount), "POST", + "/banks/BANK_ID/settlement-accounts", + "Create Settlement Account", + s"""Create a new settlement account at a bank.""", + settlementAccountRequestJson, settlementAccountResponseJson, + List(InvalidJsonFormat, $AuthenticatedUserIsRequired, UserHasMissingRoles, + $BankNotFound, InvalidAccountInitialBalance, InitialBalanceMustBeZero, + InvalidISOCurrencyCode, UnknownError), + List(apiTagBank), + Some(List(canCreateSettlementAccountAtOneBank)), + http4sPartialFunction = Some(createSettlementAccount)) + + staticResourceDocs += ResourceDoc( + null, implementedInApiVersion, "createConsumer", "POST", + "/management/consumers", + "Post a Consumer", + s"""Create a Consumer (Authenticated access).""", + code.api.v2_1_0.ConsumerPostJSON( + "Test", "Web", "Description", "some@email.com", "redirecturl", "createdby", true, new java.util.Date(), + """-----BEGIN CERTIFICATE----- + |client_certificate_content + |-----END CERTIFICATE-----""".stripMargin), + consumerJsonV400, + List(AuthenticatedUserIsRequired, UserHasMissingRoles, InvalidJsonFormat, UnknownError), + List(apiTagConsumer), + Some(List(canCreateConsumer)), + http4sPartialFunction = Some(createConsumer)) + + staticResourceDocs += ResourceDoc( + null, implementedInApiVersion, "createCounterpartyForAnyAccount", "POST", + "/management/banks/BANK_ID/accounts/ACCOUNT_ID/VIEW_ID/counterparties", + "Create Counterparty for any account (Explicit)", + s"""This is a management endpoint that allows the creation of a Counterparty on any Account.""", + postCounterpartyJson400, counterpartyWithMetadataJson400, + List($AuthenticatedUserIsRequired, InvalidAccountIdFormat, InvalidBankIdFormat, + $BankNotFound, $BankAccountNotFound, AccountNotFound, InvalidJsonFormat, + InvalidISOCurrencyCode, ViewNotFound, CounterpartyAlreadyExists, UnknownError), + List(apiTagCounterparty, apiTagAccount), + Some(List(canCreateCounterparty, canCreateCounterpartyAtAnyBank)), + http4sPartialFunction = Some(createCounterpartyForAnyAccount)) + + staticResourceDocs += ResourceDoc( + null, implementedInApiVersion, nameOf(createHistoricalTransactionAtBank), "POST", + "/banks/BANK_ID/management/historical/transactions", + "Create Historical Transactions ", + s"""Create historical transactions at one Bank.""", + postHistoricalTransactionAtBankJson, postHistoricalTransactionResponseJson, + List(InvalidJsonFormat, BankNotFound, AccountNotFound, CounterpartyNotFoundByCounterpartyId, + InvalidNumber, NotPositiveAmount, InvalidTransactionRequestCurrency, UnknownError), + List(apiTagTransactionRequest), + Some(List(canCreateHistoricalTransactionAtBank)), + http4sPartialFunction = Some(createHistoricalTransactionAtBank)) + + staticResourceDocs += ResourceDoc( + null, implementedInApiVersion, nameOf(createUserWithRoles), "POST", + "/user-entitlements", + "Create (DAuth) User with Roles", + s"""Create (DAuth) User with Roles.""", + postCreateUserWithRolesJsonV400, entitlementsJsonV400, + List(AuthenticatedUserIsRequired, InvalidJsonFormat, IncorrectRoleName, + EntitlementIsBankRole, EntitlementIsSystemRole, EntitlementAlreadyExists, + InvalidUserProvider, UnknownError), + List(apiTagRole, apiTagEntitlement, apiTagUser, apiTagDAuth), + None, + http4sPartialFunction = Some(createUserWithRoles)) + + staticResourceDocs += ResourceDoc( + null, implementedInApiVersion, nameOf(createUserWithAccountAccess), "POST", + "/banks/BANK_ID/accounts/ACCOUNT_ID/user-account-access", + "Create (DAuth) User with Account Access", + s"""Create (DAuth) User with Account Access.""", + postCreateUserAccountAccessJsonV400, List(viewJsonV300), + List($AuthenticatedUserIsRequired, UserLacksPermissionCanGrantAccessToViewForTargetAccount, + InvalidJsonFormat, SystemViewNotFound, ViewNotFound, CannotGrantAccountAccess, UnknownError), + List(apiTagAccountAccess, apiTagView, apiTagAccount, apiTagUser, apiTagOwnerRequired, apiTagDAuth), + None, + http4sPartialFunction = Some(createUserWithAccountAccess)) + + staticResourceDocs += ResourceDoc( + null, implementedInApiVersion, nameOf(createUserInvitation), "POST", + "/banks/BANK_ID/user-invitation", + "Create User Invitation", + s"""Create User Invitation.""", + userInvitationPostJsonV400, userInvitationJsonV400, + List($AuthenticatedUserIsRequired, $BankNotFound, UserCustomerLinksNotFoundForUser, UnknownError), + List(apiTagUserInvitation, apiTagKyc), + Some(canCreateUserInvitation :: Nil), + http4sPartialFunction = Some(createUserInvitation)) + } + initBatch19ResourceDocs() + // ─── allRoutes ──────────────────────────────────────────────────────────── private val allOwnRoutes: HttpRoutes[IO] = Kleisli[HttpF, Request[IO], Response[IO]] { req => @@ -2386,13 +8132,223 @@ object Http4s400 { .orElse(getFirehoseAccountsAtOneBank.run(req)) .orElse(createTransactionRequest.run(req)) .orElse(answerTransactionRequestChallenge.run(req)) + // Batch 1 — simple GETs + .orElse(getCallContext.run(req)) + .orElse(verifyRequestSignResponse.run(req)) + .orElse(getCurrentUserId.run(req)) + .orElse(getScannedApiVersions.run(req)) + .orElse(getMySpaces.run(req)) + .orElse(getBankAttributes.run(req)) + .orElse(getBankAttribute.run(req)) + .orElse(getSystemLevelEndpointTags.run(req)) + .orElse(getBankLevelEndpointTags.run(req)) + .orElse(getEndpointMapping.run(req)) + .orElse(getBankLevelEndpointMapping.run(req)) + .orElse(getAllEndpointMappings.run(req)) + .orElse(getAllBankLevelEndpointMappings.run(req)) + // Batch 2 — more GETs + .orElse(getEntitlementsForBank.run(req)) + .orElse(getMyPersonalUserAttributes.run(req)) + .orElse(getUserWithAttributes.run(req)) + .orElse(getCustomerAttributes.run(req)) + .orElse(getCustomerAttributeById.run(req)) + .orElse(getProductAttributeDefinition.run(req)) + .orElse(getCustomerAttributeDefinition.run(req)) + .orElse(getAccountAttributeDefinition.run(req)) + .orElse(getTransactionAttributeDefinition.run(req)) + .orElse(getCardAttributeDefinition.run(req)) + .orElse(getJsonSchemaValidation.run(req)) + .orElse(getAllJsonSchemaValidations.run(req)) + .orElse(getAuthenticationTypeValidation.run(req)) + .orElse(getAllAuthenticationTypeValidations.run(req)) + .orElse(getConnectorMethod.run(req)) + .orElse(getAllConnectorMethods.run(req)) + .orElse(getUserCustomerLinksByUserId.run(req)) + .orElse(getUserCustomerLinksByCustomerId.run(req)) + .orElse(getCustomerMessages.run(req)) + .orElse(createCustomerMessage.run(req)) + // Batch 3 — DELETEs + .orElse(deleteTransactionAttributeDefinition.run(req)) + .orElse(deleteCustomerAttributeDefinition.run(req)) + .orElse(deleteAccountAttributeDefinition.run(req)) + .orElse(deleteProductAttributeDefinition.run(req)) + .orElse(deleteCardAttributeDefinition.run(req)) + .orElse(deleteTransactionRequestAttributeDefinition.run(req)) + .orElse(deleteUser.run(req)) + .orElse(deleteUserCustomerLink.run(req)) + .orElse(deleteTransactionCascade.run(req)) + .orElse(deleteAccountCascade.run(req)) + .orElse(deleteBankCascade.run(req)) + .orElse(deleteProductCascade.run(req)) + .orElse(deleteCustomerCascade.run(req)) + .orElse(deleteSystemLevelEndpointTag.run(req)) + .orElse(deleteBankLevelEndpointTag.run(req)) + .orElse(deleteAuthenticationTypeValidation.run(req)) + .orElse(deleteJsonSchemaValidation.run(req)) + .orElse(deleteCustomerAttribute.run(req)) + .orElse(deleteBankAttribute.run(req)) + .orElse(deleteAtm.run(req)) + .orElse(deleteProductFee.run(req)) + .orElse(deleteEndpointMapping.run(req)) + .orElse(deleteBankLevelEndpointMapping.run(req)) + // Batch 4 — Consents, ApiCollections + .orElse(getConsentInfosByBank.run(req)) + .orElse(getConsentInfos.run(req)) + .orElse(getMyApiCollectionByName.run(req)) + .orElse(getMyApiCollectionById.run(req)) + .orElse(getSharableApiCollectionById.run(req)) + .orElse(getApiCollectionsForUser.run(req)) + .orElse(getFeaturedApiCollections.run(req)) + .orElse(getMyApiCollections.run(req)) + .orElse(getMyApiCollectionEndpoint.run(req)) + .orElse(getApiCollectionEndpoints.run(req)) + .orElse(getMyApiCollectionEndpoints.run(req)) + .orElse(getMyApiCollectionEndpointsById.run(req)) + .orElse(deleteMyApiCollection.run(req)) + .orElse(deleteMyApiCollectionEndpoint.run(req)) + .orElse(deleteMyApiCollectionEndpointByOperationId.run(req)) + .orElse(deleteMyApiCollectionEndpointById.run(req)) + // Batch 5 — more GETs + .orElse(getProductFee.run(req)) + .orElse(getProductFees.run(req)) + .orElse(getTransactionAttributes.run(req)) + .orElse(getTransactionAttributeById.run(req)) + .orElse(getTransactionRequestAttributes.run(req)) + .orElse(getTransactionRequestAttributeById.run(req)) + .orElse(getTransactionRequestAttributeDefinition.run(req)) + .orElse(getTransactionRequest.run(req)) + .orElse(getMyCorrelatedEntities.run(req)) + .orElse(getCorrelatedUsersInfoByCustomerId.run(req)) + .orElse(getAccountsMinimalByCustomerId.run(req)) + .orElse(getCustomersByCustomerPhoneNumber.run(req)) + .orElse(getCustomersAtAnyBank.run(req)) + .orElse(getCustomersMinimalAtAnyBank.run(req)) + .orElse(getUserInvitation.run(req)) + .orElse(getUserInvitations.run(req)) + // Batch 6 — ATM updates + .orElse(updateAtmSupportedCurrencies.run(req)) + .orElse(updateAtmSupportedLanguages.run(req)) + .orElse(updateAtmAccessibilityFeatures.run(req)) + .orElse(updateAtmServices.run(req)) + .orElse(updateAtmNotes.run(req)) + .orElse(updateAtmLocationCategories.run(req)) + .orElse(updateAtm.run(req)) + // Batch 7 — Attribute Definitions PUT + .orElse(createOrUpdateCustomerAttributeAttributeDefinition.run(req)) + .orElse(createOrUpdateAccountAttributeDefinition.run(req)) + .orElse(createOrUpdateProductAttributeDefinition.run(req)) + .orElse(createOrUpdateTransactionAttributeDefinition.run(req)) + .orElse(createOrUpdateCardAttributeDefinition.run(req)) + .orElse(createOrUpdateBankAttributeDefinition.run(req)) + // Batch 8 — Counterparty management + .orElse(getCounterpartiesForAnyAccount.run(req)) + .orElse(getCounterpartyByIdForAnyAccount.run(req)) + .orElse(getCounterpartyByNameForAnyAccount.run(req)) + // Batch 9 — Remaining v4 migrations + .orElse(createTransactionRequestCard.run(req)) + .orElse(deleteExplicitCounterparty.run(req)) + .orElse(deleteCounterpartyForAnyAccount.run(req)) + .orElse(deleteTagForViewOnAccount.run(req)) + .orElse(getTagsForViewOnAccount.run(req)) + .orElse(addTagForViewOnAccount.run(req)) + .orElse(getDoubleEntryTransaction.run(req)) + .orElse(getBalancingTransaction.run(req)) + .orElse(getBankAccountBalancesForCurrentUser.run(req)) + .orElse(getAccountByAccountRouting.run(req)) + .orElse(getAccountsByAccountRoutingRegex.run(req)) + .orElse(lockUser.run(req)) + .orElse(resetPasswordUrl.run(req)) + .orElse(getSettlementAccounts.run(req)) + // Batch 10 — Attribute create/update + .orElse(createBankAttribute.run(req)) + .orElse(updateBankAttribute.run(req)) + .orElse(createCustomerAttribute.run(req)) + .orElse(updateCustomerAttribute.run(req)) + .orElse(createTransactionAttribute.run(req)) + .orElse(updateTransactionAttribute.run(req)) + .orElse(createTransactionRequestAttribute.run(req)) + .orElse(updateTransactionRequestAttribute.run(req)) + .orElse(createProductFee.run(req)) + .orElse(updateProductFee.run(req)) + .orElse(createMyPersonalUserAttribute.run(req)) + .orElse(updateMyPersonalUserAttribute.run(req)) + // Batch 11 — account access, user invitations, consents, api collections + .orElse(getUserInvitationAnonymous.run(req)) + .orElse(grantUserAccessToView.run(req)) + .orElse(revokeUserAccessToView.run(req)) + .orElse(revokeGrantUserAccessToViews.run(req)) + .orElse(createMyApiCollection.run(req)) + .orElse(createMyApiCollectionEndpoint.run(req)) + .orElse(createMyApiCollectionEndpointById.run(req)) + .orElse(updateConsentStatus.run(req)) + .orElse(addConsentUser.run(req)) + // Batch 12 — direct debits, standing orders, webhooks, fast firehose + .orElse(createDirectDebit.run(req)) + .orElse(createDirectDebitManagement.run(req)) + .orElse(createStandingOrder.run(req)) + .orElse(createStandingOrderManagement.run(req)) + .orElse(createSystemAccountNotificationWebhook.run(req)) + .orElse(createBankAccountNotificationWebhook.run(req)) + .orElse(getFastFirehoseAccountsAtOneBank.run(req)) + // Batch 13 — Endpoint Mappings create/update + .orElse(createEndpointMapping.run(req)) + .orElse(updateEndpointMapping.run(req)) + .orElse(createBankLevelEndpointMapping.run(req)) + .orElse(updateBankLevelEndpointMapping.run(req)) + // Batch 14 — Endpoint Tags CRUD + .orElse(createSystemLevelEndpointTag.run(req)) + .orElse(updateSystemLevelEndpointTag.run(req)) + .orElse(createBankLevelEndpointTag.run(req)) + .orElse(updateBankLevelEndpointTag.run(req)) + // Batch 15 — JSON Schema + Auth Type Validation + Connector Method + .orElse(createJsonSchemaValidation.run(req)) + .orElse(updateJsonSchemaValidation.run(req)) + .orElse(createAuthenticationTypeValidation.run(req)) + .orElse(updateAuthenticationTypeValidation.run(req)) + .orElse(createConnectorMethod.run(req)) + .orElse(updateConnectorMethod.run(req)) + // Batch 16 — Dynamic Resource Doc CRUD + .orElse(createDynamicResourceDoc.run(req)) + .orElse(updateDynamicResourceDoc.run(req)) + .orElse(deleteDynamicResourceDoc.run(req)) + .orElse(getDynamicResourceDoc.run(req)) + .orElse(getAllDynamicResourceDocs.run(req)) + .orElse(createBankLevelDynamicResourceDoc.run(req)) + .orElse(updateBankLevelDynamicResourceDoc.run(req)) + .orElse(deleteBankLevelDynamicResourceDoc.run(req)) + .orElse(getBankLevelDynamicResourceDoc.run(req)) + .orElse(getAllBankLevelDynamicResourceDocs.run(req)) + // Batch 17 — Dynamic Message Doc CRUD + .orElse(createDynamicMessageDoc.run(req)) + .orElse(updateDynamicMessageDoc.run(req)) + .orElse(deleteDynamicMessageDoc.run(req)) + .orElse(getDynamicMessageDoc.run(req)) + .orElse(getAllDynamicMessageDocs.run(req)) + .orElse(createBankLevelDynamicMessageDoc.run(req)) + .orElse(updateBankLevelDynamicMessageDoc.run(req)) + .orElse(deleteBankLevelDynamicMessageDoc.run(req)) + .orElse(getBankLevelDynamicMessageDoc.run(req)) + .orElse(getAllBankLevelDynamicMessageDocs.run(req)) + // Batch 18 — buildDynamicEndpointTemplate + .orElse(buildDynamicEndpointTemplate.run(req)) + // Batch 19 — Complex authn (addAccount, createConsumer, createCounterpartyForAnyAccount, + // createHistoricalTransactionAtBank, createSettlementAccount, + // createUserInvitation, createUserWithAccountAccess, createUserWithRoles) + .orElse(addAccount.run(req)) + .orElse(createSettlementAccount.run(req)) + .orElse(createConsumer.run(req)) + .orElse(createCounterpartyForAnyAccount.run(req)) + .orElse(createHistoricalTransactionAtBank.run(req)) + .orElse(createUserWithRoles.run(req)) + .orElse(createUserWithAccountAccess.run(req)) + .orElse(createUserInvitation.run(req)) } - val allRoutesWithMiddleware: HttpRoutes[IO] = ResourceDocMiddleware.apply(resourceDocs)(allOwnRoutes) + lazy val allRoutesWithMiddleware: HttpRoutes[IO] = ResourceDocMiddleware.apply(resourceDocs)(allOwnRoutes) // ─── path-rewriting bridge: /obp/v4.0.0/… → /obp/v3.1.0/… ────────────── - val v400ToV310Bridge: HttpRoutes[IO] = Kleisli[HttpF, Request[IO], Response[IO]] { req => + lazy val v400ToV310Bridge: HttpRoutes[IO] = Kleisli[HttpF, Request[IO], Response[IO]] { req => val rawPath = req.uri.path.renderString if (rawPath.startsWith("/obp/v4.0.0/")) { val rewritten = rawPath.replaceFirst("/obp/v4\\.0\\.0/", "/obp/v3.1.0/") diff --git a/obp-api/src/main/scala/code/api/v6_0_0/Http4s600.scala b/obp-api/src/main/scala/code/api/v6_0_0/Http4s600.scala new file mode 100644 index 0000000000..f2f16991fb --- /dev/null +++ b/obp-api/src/main/scala/code/api/v6_0_0/Http4s600.scala @@ -0,0 +1,8490 @@ +package code.api.v6_0_0 + +import cats.data.{Kleisli, OptionT} +import cats.effect._ +import code.api.Constant._ +import code.api.ResourceDocs1_4_0.SwaggerDefinitionsJSON._ +import code.api.util.APIUtil.{EmptyBody, ResourceDoc} +import code.api.util.{APIUtil, CallContext, CustomJsonFormats, NewStyle} +import code.api.util.ApiRole._ +import code.api.util.ApiTag._ +import code.api.util.ErrorMessages._ +import code.api.util.http4s.{ErrorResponseConverter, RequestScopeConnection, ResourceDocMiddleware} +import code.api.util.http4s.Http4sRequestAttributes.{EndpointHelpers, RequestOps} +import code.api.util.newstyle.ViewNewStyle +import code.api.v2_0_0.JSONFactory200 +import code.api.v5_1_0.{Http4s510, JSONFactory510} +import code.api.v6_0_0.JSONFactory600.ScannedApiVersionJsonV600 +import code.accountattribute.AccountAttributeX +import code.api.Constant +import code.api.Constant.{PARAM_LOCALE, PARAM_TIMESTAMP} +import code.api.cache.Redis +import code.bankconnectors.{Connector => BankConnector} +import code.bankconnectors.storedprocedure.StoredProcedureUtils +import code.migration.MigrationScriptLogProvider +import code.api.dynamic.entity.helper.DynamicEntityInfo +import code.api.util.APIUtil.{createQueriesByHttpParamsFuture, unboxFull, unboxFullOrFail} +import code.api.util.{ApiVersionUtils, CertificateUtil, CommonsEmailWrapper, RateLimitingUtil} +import code.api.v2_0_0.{BasicViewJson, JSONFactory200} +import code.api.v3_0_0.JSONFactory300 +import code.abacrule.{AbacRuleEngine, MappedAbacRuleProvider} +import code.api.v3_1_0.PostCustomerNumberJsonV310 +import code.api.v4_0_0.CallLimitPostJsonV400 +import code.api.v5_1_0.UserAttributesResponseJsonV510 +import code.api.v5_1_0.PostCustomerLegalNameJsonV510 +import code.api.v5_1_0.UserAttributeJsonV510 +import code.api.v6_0_0.JSONFactory600.{createAbacRuleJsonV600, createAbacRulesJsonV600} +import code.api.v6_0_0.JSONFactory600.{GroupEntitlementJsonV600, GroupEntitlementsJsonV600, GroupJsonV600, GroupsJsonV600, PostGroupJsonV600, PutGroupJsonV600} +import code.group.{GroupTrait => GroupT} +import code.ratelimiting.RateLimitingDI +import com.openbankproject.commons.model.enums.UserAttributeType + +import java.text.SimpleDateFormat +import java.util.UUID.randomUUID +import code.api.v6_0_0.JSONFactory600.UpdateViewJsonV600 +import code.model._ +import code.model.dataAccess.AuthUser +import code.users.{Users, DoobieUserQueries} +import code.util.Helper.SILENCE_IS_GOLDEN +import com.openbankproject.commons.dto.GetProductsParam +import code.model.ModeratedTransaction +import com.openbankproject.commons.model.{CreditLimit, CreditRating, CustomerFaceImage} +import net.liftweb.common.{Empty, Failure} +import net.liftweb.http.provider.HTTPParam + +import scala.util.Random +import code.metrics.APIMetrics +import code.util.Helper +import code.webuiprops.{MappedWebUiPropsProvider, WebUiPropsCommons} +import code.DynamicData.DynamicData +import code.dynamicEntity.DynamicEntityCommons +import code.entitlement.Entitlement +import code.metadata.tags.Tags +import code.views.Views +import net.liftweb.mapper.{By, NullRef} +import com.github.dwickern.macros.NameOf.nameOf +import com.openbankproject.commons.ExecutionContext.Implicits.global +import com.openbankproject.commons.model.{BankId, BankIdAccountId, CustomerId, ListResult, ViewId} +import com.openbankproject.commons.util.{ApiVersion, ApiVersionStatus, ScannedApiVersion} +import net.liftweb.common.Full +import net.liftweb.json.{Extraction, Formats} +import net.liftweb.json.JsonAST.prettyRender +import org.http4s.{HttpRoutes, Request, Response, Uri} +import org.http4s.dsl.io._ + +import scala.collection.JavaConverters._ +import scala.collection.mutable.ArrayBuffer +import scala.concurrent.Future + +/** + * v6.0.0 http4s endpoints — Phase 1 in progress. + * + * Wire-in into `Http4sApp.baseServices` is performed alongside this object. + * The v600→v510 bridge (`v600ToV510Bridge`) is intentionally NOT appended to + * `allRoutes`: unmigrated v6 paths must fall through the http4s chain to the + * Lift fallback, which still serves the v6 Lift handlers. Adding the bridge + * would let v6 *overrides* be hijacked into v5.1 handlers (CLAUDE.md → + * "Bridge-cascade hijack"). The bridge val is kept here so it can be enabled + * later if the team decides to short-circuit Lift for v6 originals. + */ +object Http4s600 { + + type HttpF[A] = OptionT[IO, A] + + implicit val formats: Formats = CustomJsonFormats.formats + implicit def convertAnyToJsonString(any: Any): String = prettyRender(Extraction.decompose(any)) + + val implementedInApiVersion: ScannedApiVersion = ApiVersion.v6_0_0 + val versionStatus: String = ApiVersionStatus.BLEEDING_EDGE.toString + val resourceDocs: ArrayBuffer[ResourceDoc] = ArrayBuffer[ResourceDoc]() + + object Implementations6_0_0 { + + val prefixPath = Root / ApiPathZero.toString / implementedInApiVersion.toString + + // Route: GET /obp/v6.0.0/ and GET /obp/v6.0.0/root + // Mirrors v6 Lift root — both bare prefix and /root return the same + // info JSON. Reuses JSONFactory510.getApiInfoJSON because v6's API-info + // shape is unchanged from v5.1. + lazy val root: HttpRoutes[IO] = HttpRoutes.of[IO] { + case GET -> `prefixPath` => + Ok(convertAnyToJsonString( + JSONFactory510.getApiInfoJSON(implementedInApiVersion, versionStatus) + )) + case GET -> `prefixPath` / "root" => + Ok(convertAnyToJsonString( + JSONFactory510.getApiInfoJSON(implementedInApiVersion, versionStatus) + )) + } + + resourceDocs += ResourceDoc( + null, + implementedInApiVersion, + nameOf(root), + "GET", + "/root", + "Get API Info (root)", + """Returns information about: + | + |* API version + |* Hosted by information + |* Hosted at information + |* Energy source information + |* Git Commit""".stripMargin, + EmptyBody, + apiInfoJson400, + List(UnknownError, MandatoryPropertyIsNotSet), + apiTagApi :: Nil, + None, + http4sPartialFunction = Some(root) + ) + + // Route: GET /obp/v6.0.0/api/versions + // Returns the list of scanned API versions with `is_active` reflecting + // current `api_disabled_versions`/`api_enabled_versions` props. + lazy val getScannedApiVersions: HttpRoutes[IO] = HttpRoutes.of[IO] { + case req @ GET -> `prefixPath` / "api" / "versions" => + EndpointHelpers.executeAndRespond(req) { _ => + Future { + val versions: List[ScannedApiVersionJsonV600] = + ApiVersion.allScannedApiVersion.asScala.toList + .filter(v => v.urlPrefix.trim.nonEmpty) + .map { v => + ScannedApiVersionJsonV600( + url_prefix = v.urlPrefix, + api_standard = v.apiStandard, + api_short_version = v.apiShortVersion, + fully_qualified_version = v.fullyQualifiedVersion, + is_active = APIUtil.versionIsAllowed(v) + ) + } + ListResult("scanned_api_versions", versions) + } + } + } + + resourceDocs += ResourceDoc( + null, + implementedInApiVersion, + nameOf(getScannedApiVersions), + "GET", + "/api/versions", + "Get Scanned API Versions", + """Get all scanned API versions available in this codebase along with their active status.""", + EmptyBody, + ListResult( + "scanned_api_versions", + List(ScannedApiVersionJsonV600("obp", "OBP", "v6.0.0", "OBPv6.0.0", is_active = true)) + ), + List(UnknownError), + apiTagDocumentation :: apiTagApi :: Nil, + None, + http4sPartialFunction = Some(getScannedApiVersions) + ) + + // Route: GET /obp/v6.0.0/users/current + // Auth-only. Returns the logged-in user enriched with entitlements, + // virtual roles (super_admin / oidc_operator), permissions, and the + // optional on-behalf-of user when impersonation headers are set. + lazy val getCurrentUser: HttpRoutes[IO] = HttpRoutes.of[IO] { + case req @ GET -> `prefixPath` / "users" / "current" => + EndpointHelpers.withUser(req) { (user, cc) => + for { + entitlements <- NewStyle.function.getEntitlementsByUserId(user.userId, Some(cc)) + } yield { + val permissions = Views.views.vend.getPermissionForUser(user).toOption + val virtualRoleNames = + if (APIUtil.isSuperAdmin(user.userId)) JSONFactory200.superAdminVirtualRoles + else if (APIUtil.isOidcOperator(user.userId)) JSONFactory200.oidcOperatorVirtualRoles + else List.empty + val existingRoleNames = entitlements.map(_.roleName).toSet + val virtualEntitlements = virtualRoleNames.filterNot(existingRoleNames.contains).map { role => + new Entitlement { + def entitlementId = "" + def bankId = "" + def userId = user.userId + def roleName = role + def createdByProcess = + if (APIUtil.isSuperAdmin(user.userId)) "super_admin_user_ids" + else "oidc_operator_user_ids" + def entitlementRequestId: Option[String] = None + def groupId: Option[String] = None + def process: Option[String] = None + } + } + val currentUser = UserV600(user, entitlements ::: virtualEntitlements, permissions) + val onBehalfOfUser = + if (cc.onBehalfOfUser.isDefined) { + val u = cc.onBehalfOfUser.toOption.get + val ents = Entitlement.entitlement.vend.getEntitlementsByUserId(u.userId) + .headOption.toList.flatten + val perms = Views.views.vend.getPermissionForUser(u).toOption + Some(UserV600(u, ents, perms)) + } else None + JSONFactory600.createUserInfoJSON(currentUser, onBehalfOfUser) + } + } + } + + resourceDocs += ResourceDoc( + null, + implementedInApiVersion, + nameOf(getCurrentUser), + "GET", + "/users/current", + "Get User (Current)", + """Get the logged-in user (with entitlements, permissions, virtual roles, + |and the on-behalf-of user if impersonation headers are set).""".stripMargin, + EmptyBody, + userJsonV300, + List($AuthenticatedUserIsRequired, UnknownError), + apiTagUser :: Nil, + None, + http4sPartialFunction = Some(getCurrentUser) + ) + + // Route: GET /obp/v6.0.0/banks + lazy val getBanks: HttpRoutes[IO] = HttpRoutes.of[IO] { + case req @ GET -> `prefixPath` / "banks" => + EndpointHelpers.executeAndRespond(req) { implicit cc => + for { + (banks, _) <- NewStyle.function.getBanks(Some(cc)) + } yield JSONFactory600.createBanksJsonV600(banks) + } + } + + resourceDocs += ResourceDoc( + null, + implementedInApiVersion, + nameOf(getBanks), + "GET", + "/banks", + "Get Banks", + """Get banks on this API instance. + |Returns a list of banks supported on this server.""".stripMargin, + EmptyBody, + EmptyBody, + List(UnknownError), + apiTagBank :: Nil, + None, + http4sPartialFunction = Some(getBanks) + ) + + // Route: GET /obp/v6.0.0/banks/BANK_ID + lazy val getBank: HttpRoutes[IO] = HttpRoutes.of[IO] { + case req @ GET -> `prefixPath` / "banks" / _ => + EndpointHelpers.withBank(req) { (bank, cc) => + for { + (attributes, _) <- NewStyle.function.getBankAttributesByBank(bank.bankId, Some(cc)) + } yield JSONFactory600.createBankJsonV600(bank, attributes) + } + } + + resourceDocs += ResourceDoc( + null, + implementedInApiVersion, + nameOf(getBank), + "GET", + "/banks/BANK_ID", + "Get Bank", + """Get the bank specified by BANK_ID. Returns id, name, logo, website, + |routings and attributes.""".stripMargin, + EmptyBody, + EmptyBody, + List($BankNotFound, UnknownError), + apiTagBank :: Nil, + None, + http4sPartialFunction = Some(getBank) + ) + + // Route: GET /obp/v6.0.0/banks/BANK_ID/customers + lazy val getCustomersAtOneBank: HttpRoutes[IO] = HttpRoutes.of[IO] { + case req @ GET -> `prefixPath` / "banks" / bankIdStr / "customers" => + EndpointHelpers.withUserAndBank(req) { (_, _, cc) => + val bankId = BankId(bankIdStr) + for { + (requestParams, _) <- NewStyle.function.extractQueryParams( + req.uri.renderString, + List("limit", "offset", "sort_direction"), + cc.callContext + ) + customers <- NewStyle.function.getCustomers(bankId, cc.callContext, requestParams) + } yield JSONFactory600.createCustomersJson(customers.sortBy(_.bankId)) + } + } + + resourceDocs += ResourceDoc( + null, + implementedInApiVersion, + nameOf(getCustomersAtOneBank), + "GET", + "/banks/BANK_ID/customers", + "Get Customers at Bank", + """Get Customers at Bank. Returns a list of all customers at the + |specified bank.""".stripMargin, + EmptyBody, + customerJSONsV600, + List($AuthenticatedUserIsRequired, $BankNotFound, UserHasMissingRoles, UnknownError), + apiTagCustomer :: Nil, + Some(canGetCustomersAtOneBank :: Nil), + http4sPartialFunction = Some(getCustomersAtOneBank) + ) + + // Route: GET /obp/v6.0.0/banks/BANK_ID/customers/CUSTOMER_ID + lazy val getCustomerByCustomerId: HttpRoutes[IO] = HttpRoutes.of[IO] { + case req @ GET -> `prefixPath` / "banks" / _ / "customers" / customerId => + EndpointHelpers.withUserAndBank(req) { (_, bank, cc) => + for { + (customer, callContext) <- NewStyle.function.getCustomerByCustomerId(customerId, cc.callContext) + (customerAttributes, _) <- NewStyle.function.getCustomerAttributes( + bank.bankId, CustomerId(customerId), callContext + ) + } yield JSONFactory600.createCustomerWithAttributesJson(customer, customerAttributes) + } + } + + resourceDocs += ResourceDoc( + null, + implementedInApiVersion, + nameOf(getCustomerByCustomerId), + "GET", + "/banks/BANK_ID/customers/CUSTOMER_ID", + "Get Customer by CUSTOMER_ID", + """Gets the Customer specified by CUSTOMER_ID.""", + EmptyBody, + customerWithAttributesJsonV600, + List($AuthenticatedUserIsRequired, UserCustomerLinksNotFoundForUser, $BankNotFound, UserHasMissingRoles, UnknownError), + apiTagCustomer :: Nil, + Some(canGetCustomersAtOneBank :: Nil), + http4sPartialFunction = Some(getCustomerByCustomerId) + ) + + // Route: GET /obp/v6.0.0/my/banks/BANK_ID/accounts/ACCOUNT_ID/account + lazy val getCoreAccountByIdV600: HttpRoutes[IO] = HttpRoutes.of[IO] { + case req @ GET -> `prefixPath` / "my" / "banks" / _ / "accounts" / _ / "account" => + EndpointHelpers.withBankAccount(req) { (user, account, cc) => + for { + view <- ViewNewStyle.checkOwnerViewAccessAndReturnOwnerView( + user, BankIdAccountId(account.bankId, account.accountId), Some(cc)) + moderatedAccount <- NewStyle.function.moderatedBankAccountCore(account, view, Full(user), Some(cc)) + } yield { + val availableViews = Views.views.vend.privateViewsUserCanAccessForAccount( + user, BankIdAccountId(account.bankId, account.accountId)) + JSONFactory600.createModeratedCoreAccountJsonV600(moderatedAccount, availableViews) + } + } + } + + resourceDocs += ResourceDoc( + null, + implementedInApiVersion, + nameOf(getCoreAccountByIdV600), + "GET", + "/my/banks/BANK_ID/accounts/ACCOUNT_ID/account", + "Get Account by Id (Core)", + """Returns core information about the account specified by ACCOUNT_ID + |including balance, routings and available views.""".stripMargin, + EmptyBody, + EmptyBody, + List($AuthenticatedUserIsRequired, $BankAccountNotFound, UnknownError), + apiTagAccount :: Nil, + None, + http4sPartialFunction = Some(getCoreAccountByIdV600) + ) + + // Route: GET /obp/v6.0.0/my/dynamic-entities + lazy val getMyDynamicEntities: HttpRoutes[IO] = HttpRoutes.of[IO] { + case req @ GET -> `prefixPath` / "my" / "dynamic-entities" => + EndpointHelpers.withUser(req) { (user, _) => + for { + dynamicEntities <- Future(NewStyle.function.getDynamicEntitiesByUserId(user.userId)) + } yield { + val listCommons: List[DynamicEntityCommons] = dynamicEntities + JSONFactory600.createMyDynamicEntitiesJson(listCommons) + } + } + } + + resourceDocs += ResourceDoc( + null, + implementedInApiVersion, + nameOf(getMyDynamicEntities), + "GET", + "/my/dynamic-entities", + "Get My Dynamic Entities", + """Get all Dynamic Entity definitions I created.""", + EmptyBody, + EmptyBody, + List($AuthenticatedUserIsRequired, UnknownError), + apiTagManageDynamicEntity :: apiTagApi :: Nil, + None, + http4sPartialFunction = Some(getMyDynamicEntities) + ) + + // Route: GET /obp/v6.0.0/management/system-dynamic-entities + lazy val getSystemDynamicEntities: HttpRoutes[IO] = HttpRoutes.of[IO] { + case req @ GET -> `prefixPath` / "management" / "system-dynamic-entities" => + EndpointHelpers.withUser(req) { (_, _) => + for { + dynamicEntities <- Future(NewStyle.function.getDynamicEntities(None, false)) + } yield { + val listCommons: List[DynamicEntityCommons] = dynamicEntities.sortBy(_.entityName) + val entitiesWithCounts = listCommons.map { entity => + val recordCount = DynamicData.count( + By(DynamicData.DynamicEntityName, entity.entityName), + By(DynamicData.IsPersonalEntity, false), + if (entity.bankId.isEmpty) NullRef(DynamicData.BankId) else By(DynamicData.BankId, entity.bankId.get) + ) + (entity, recordCount) + } + JSONFactory600.createDynamicEntitiesWithCountJson(entitiesWithCounts) + } + } + } + + resourceDocs += ResourceDoc( + null, + implementedInApiVersion, + nameOf(getSystemDynamicEntities), + "GET", + "/management/system-dynamic-entities", + "Get System Dynamic Entities", + """Get all system-level Dynamic Entities with record counts.""", + EmptyBody, + EmptyBody, + List($AuthenticatedUserIsRequired, UserHasMissingRoles, UnknownError), + apiTagManageDynamicEntity :: apiTagApi :: Nil, + Some(canGetSystemLevelDynamicEntities :: Nil), + http4sPartialFunction = Some(getSystemDynamicEntities) + ) + + // Route: GET /obp/v6.0.0/management/banks/BANK_ID/dynamic-entities + lazy val getBankLevelDynamicEntities: HttpRoutes[IO] = HttpRoutes.of[IO] { + case req @ GET -> `prefixPath` / "management" / "banks" / bankIdStr / "dynamic-entities" => + EndpointHelpers.withUserAndBank(req) { (_, _, _) => + for { + dynamicEntities <- Future(NewStyle.function.getDynamicEntities(Some(bankIdStr), false)) + } yield { + val listCommons: List[DynamicEntityCommons] = dynamicEntities.sortBy(_.entityName) + val entitiesWithCounts = listCommons.map { entity => + val recordCount = DynamicData.count( + By(DynamicData.DynamicEntityName, entity.entityName), + By(DynamicData.IsPersonalEntity, false), + By(DynamicData.BankId, bankIdStr) + ) + (entity, recordCount) + } + JSONFactory600.createDynamicEntitiesWithCountJson(entitiesWithCounts) + } + } + } + + resourceDocs += ResourceDoc( + null, + implementedInApiVersion, + nameOf(getBankLevelDynamicEntities), + "GET", + "/management/banks/BANK_ID/dynamic-entities", + "Get Bank-Level Dynamic Entities", + """Get all bank-level Dynamic Entities with record counts for the + |specified bank.""".stripMargin, + EmptyBody, + EmptyBody, + List($AuthenticatedUserIsRequired, $BankNotFound, UserHasMissingRoles, UnknownError), + apiTagManageDynamicEntity :: apiTagApi :: Nil, + Some(canGetBankLevelDynamicEntities :: canGetAnyBankLevelDynamicEntities :: Nil), + http4sPartialFunction = Some(getBankLevelDynamicEntities) + ) + + // Route: GET /obp/v6.0.0/management/consumers/CONSUMER_ID + lazy val getConsumer: HttpRoutes[IO] = HttpRoutes.of[IO] { + case req @ GET -> `prefixPath` / "management" / "consumers" / consumerId => + EndpointHelpers.withUser(req) { (_, cc) => + for { + consumer <- NewStyle.function.getConsumerByConsumerId(consumerId, cc.callContext) + currentConsumerCallCounters <- Future(RateLimitingUtil.consumerRateLimitState(consumer.consumerId.get).toList) + date = new java.util.Date() + (activeRateLimit, rateLimitIds) <- RateLimitingUtil.getActiveRateLimitsWithIds(consumer.consumerId.get, date) + activeRateLimitsJson = JSONFactory600.createActiveRateLimitsJsonV600FromCallLimit(activeRateLimit, rateLimitIds, date) + callCountersJson = JSONFactory600.createRedisCallCountersJson(currentConsumerCallCounters) + } yield { + JSONFactory600.createConsumerJsonV600(consumer, None, activeRateLimitsJson, callCountersJson) + } + } + } + + resourceDocs += ResourceDoc( + null, + implementedInApiVersion, + nameOf(getConsumer), + "GET", + "/management/consumers/CONSUMER_ID", + "Get Consumer", + """Get the Consumer specified by CONSUMER_ID, including rate limits and + |current call counters.""".stripMargin, + EmptyBody, + EmptyBody, + List($AuthenticatedUserIsRequired, UserHasMissingRoles, UnknownError), + apiTagConsumer :: apiTagApi :: Nil, + Some(canGetConsumers :: Nil), + http4sPartialFunction = Some(getConsumer) + ) + + // Route: GET /obp/v6.0.0/customers + lazy val getCustomersAtAllBanks: HttpRoutes[IO] = HttpRoutes.of[IO] { + case req @ GET -> `prefixPath` / "customers" => + EndpointHelpers.withUser(req) { (_, cc) => + for { + (requestParams, callContext) <- NewStyle.function.extractQueryParams( + req.uri.renderString, + List("limit", "offset", "sort_direction"), + cc.callContext + ) + (customers, _) <- NewStyle.function.getCustomersAtAllBanks(callContext, requestParams) + } yield JSONFactory600.createCustomersJson(customers.sortBy(_.bankId)) + } + } + + resourceDocs += ResourceDoc( + null, + implementedInApiVersion, + nameOf(getCustomersAtAllBanks), + "GET", + "/customers", + "Get Customers at All Banks", + """Get Customers at All Banks. Returns all customers across all banks + |the caller has permission to see.""".stripMargin, + EmptyBody, + customerJSONsV600, + List($AuthenticatedUserIsRequired, UserHasMissingRoles, UnknownError), + apiTagCustomer :: Nil, + Some(canGetCustomersAtAllBanks :: Nil), + http4sPartialFunction = Some(getCustomersAtAllBanks) + ) + + // Route: GET /obp/v6.0.0/users/USER_ID/attributes + lazy val getUserAttributes: HttpRoutes[IO] = HttpRoutes.of[IO] { + case req @ GET -> `prefixPath` / "users" / userIdStr / "attributes" => + EndpointHelpers.withUser(req) { (_, cc) => + for { + (user, callContext) <- NewStyle.function.getUserByUserId(userIdStr, cc.callContext) + (attributes, _) <- NewStyle.function.getNonPersonalUserAttributes(user.userId, callContext) + } yield UserAttributesResponseJsonV510(attributes.map(JSONFactory510.createUserAttributeJson)) + } + } + + resourceDocs += ResourceDoc( + null, + implementedInApiVersion, + nameOf(getUserAttributes), + "GET", + "/users/USER_ID/attributes", + "Get User Attributes", + """Get all non-personal attributes for the specified user.""", + EmptyBody, + EmptyBody, + List($AuthenticatedUserIsRequired, UserHasMissingRoles, UserNotFoundByUserId, UnknownError), + apiTagUser :: apiTagUserAttribute :: apiTagAttribute :: Nil, + Some(canGetUserAttributes :: Nil), + http4sPartialFunction = Some(getUserAttributes) + ) + + // Route: GET /obp/v6.0.0/banks/BANK_ID/accounts/ACCOUNT_ID/VIEW_ID/account + lazy val getPrivateAccountByIdFull: HttpRoutes[IO] = HttpRoutes.of[IO] { + case req @ GET -> `prefixPath` / "banks" / _ / "accounts" / _ / viewIdStr / "account" => + EndpointHelpers.withView(req) { (user, account, view, cc) => + for { + moderatedAccount <- NewStyle.function.moderatedBankAccountCore(account, view, Full(user), Some(cc)) + (accountAttributes, _) <- NewStyle.function.getAccountAttributesByAccount( + account.bankId, account.accountId, Some(cc)) + } yield { + val availableViews = Views.views.vend.privateViewsUserCanAccessForAccount( + user, BankIdAccountId(account.bankId, account.accountId)) + val viewsAvailable = availableViews.map(JSONFactory600.createViewJsonV600).sortBy(_.view_name) + val tags = Tags.tags.vend.getTagsOnAccount(account.bankId, account.accountId)(ViewId(viewIdStr)) + JSONFactory600.createBankAccountJSON600(moderatedAccount, viewsAvailable, accountAttributes, tags) + } + } + } + + resourceDocs += ResourceDoc( + null, + implementedInApiVersion, + nameOf(getPrivateAccountByIdFull), + "GET", + "/banks/BANK_ID/accounts/ACCOUNT_ID/VIEW_ID/account", + "Get Account by Id (Full)", + """Returns full information about an account as moderated by the view (VIEW_ID).""", + EmptyBody, + EmptyBody, + List($AuthenticatedUserIsRequired, $BankNotFound, $BankAccountNotFound, $UserNoPermissionAccessView, UnknownError), + apiTagAccount :: Nil, + None, + http4sPartialFunction = Some(getPrivateAccountByIdFull) + ) + + // Route: POST /obp/v6.0.0/banks/BANK_ID/customers/customer-number + // POST that GETs (returns 200) — used to fetch a customer by their customer_number. + // Body is parsed manually so we preserve v6 Lift's "The Json body should be the …" + // wording verbatim, which the test suites assert on. + lazy val getCustomerByCustomerNumber: HttpRoutes[IO] = HttpRoutes.of[IO] { + case req @ POST -> `prefixPath` / "banks" / _ / "customers" / "customer-number" => + EndpointHelpers.withUserAndBank(req) { (_, bank, cc) => + val rawBody = cc.httpBody.getOrElse("") + for { + postedData <- NewStyle.function.tryons( + s"$InvalidJsonFormat The Json body should be the ${classOf[PostCustomerNumberJsonV310].getSimpleName}", + 400, Some(cc)) { + net.liftweb.json.parse(rawBody).extract[PostCustomerNumberJsonV310] + } + (customer, callContext) <- NewStyle.function.getCustomerByCustomerNumber( + postedData.customer_number, bank.bankId, Some(cc)) + (customerAttributes, _) <- NewStyle.function.getCustomerAttributes( + bank.bankId, CustomerId(customer.customerId), callContext) + } yield JSONFactory600.createCustomerWithAttributesJson(customer, customerAttributes) + } + } + + resourceDocs += ResourceDoc( + null, + implementedInApiVersion, + nameOf(getCustomerByCustomerNumber), + "POST", + "/banks/BANK_ID/customers/customer-number", + "Get Customer by CUSTOMER_NUMBER", + """Gets the Customer specified by CUSTOMER_NUMBER.""", + EmptyBody, + customerWithAttributesJsonV600, + List($AuthenticatedUserIsRequired, UserCustomerLinksNotFoundForUser, $BankNotFound, UserHasMissingRoles, UnknownError), + apiTagCustomer :: Nil, + Some(canGetCustomersAtOneBank :: Nil), + http4sPartialFunction = Some(getCustomerByCustomerNumber) + ) + + // Route: POST /obp/v6.0.0/banks/BANK_ID/customers/legal-name + // POST that GETs (returns 200) — fetch customers by legal name. + lazy val getCustomersByLegalName: HttpRoutes[IO] = HttpRoutes.of[IO] { + case req @ POST -> `prefixPath` / "banks" / _ / "customers" / "legal-name" => + EndpointHelpers.withUserAndBank(req) { (_, bank, cc) => + val rawBody = cc.httpBody.getOrElse("") + for { + postedData <- NewStyle.function.tryons( + s"$InvalidJsonFormat The Json body should be the ${classOf[PostCustomerLegalNameJsonV510].getSimpleName}", + 400, Some(cc)) { + net.liftweb.json.parse(rawBody).extract[PostCustomerLegalNameJsonV510] + } + (customers, _) <- NewStyle.function.getCustomersByCustomerLegalName( + bank.bankId, postedData.legal_name, Some(cc)) + } yield JSONFactory600.createCustomersJson(customers) + } + } + + resourceDocs += ResourceDoc( + null, + implementedInApiVersion, + nameOf(getCustomersByLegalName), + "POST", + "/banks/BANK_ID/customers/legal-name", + "Get Customers by Legal Name", + """Gets the Customers matching the provided legal name at the specified bank.""", + EmptyBody, + customerJSONsV600, + List($AuthenticatedUserIsRequired, $BankNotFound, UserHasMissingRoles, UnknownError), + apiTagCustomer :: Nil, + Some(canGetCustomersAtOneBank :: Nil), + http4sPartialFunction = Some(getCustomersByLegalName) + ) + + // Inlined helpers — match the v6 Lift private versions in APIMethods600. + private val validEntityNamePattern = "^[a-z][a-z0-9_]*$".r.pattern + private def validateEntityNameV600(entityName: String, cc: CallContext): Future[Unit] = + if (validEntityNamePattern.matcher(entityName).matches()) Future.successful(()) + else Future.failed(new RuntimeException(s"$InvalidDynamicEntityName Current value: '$entityName'")) + + private def createDynamicEntityV600(cc: CallContext, dynamicEntity: DynamicEntityCommons) = for { + // Wrap the connector call so a thrown RuntimeException (bad schema, etc.) + // becomes a 400 InvalidJsonFormat — matches v6 Lift's dispatch wrapper. + Full(result) <- NewStyle.function.createOrUpdateDynamicEntity(dynamicEntity, Some(cc)) + .recoverWith { + case e: Throwable if !Option(e.getMessage).exists(_.startsWith("OBP-")) => + val json = net.liftweb.json.Serialization.write( + code.api.APIFailureNewStyle(s"$InvalidJsonFormat ${e.getMessage}", 400, Some(cc).map(_.toLight)) + )(net.liftweb.json.DefaultFormats) + Future.failed(new Exception(json)) + } + crudRoles = List( + DynamicEntityInfo.canCreateRole(result.entityName, dynamicEntity.bankId), + DynamicEntityInfo.canUpdateRole(result.entityName, dynamicEntity.bankId), + DynamicEntityInfo.canGetRole(result.entityName, dynamicEntity.bankId), + DynamicEntityInfo.canDeleteRole(result.entityName, dynamicEntity.bankId) + ) + } yield { + crudRoles.foreach(role => + Entitlement.entitlement.vend.addEntitlement(dynamicEntity.bankId.getOrElse(""), cc.userId, role.toString())) + JSONFactory600.createMyDynamicEntitiesJson(List(result: DynamicEntityCommons)).dynamic_entities.head + } + + private def updateDynamicEntityV600(cc: CallContext, dynamicEntity: DynamicEntityCommons) = for { + Full(result) <- NewStyle.function.createOrUpdateDynamicEntity(dynamicEntity, Some(cc)) + .recoverWith { + case e: Throwable if !Option(e.getMessage).exists(_.startsWith("OBP-")) => + val json = net.liftweb.json.Serialization.write( + code.api.APIFailureNewStyle(s"$InvalidJsonFormat ${e.getMessage}", 400, Some(cc).map(_.toLight)) + )(net.liftweb.json.DefaultFormats) + Future.failed(new Exception(json)) + } + } yield { + JSONFactory600.createMyDynamicEntitiesJson(List(result: DynamicEntityCommons)).dynamic_entities.head + } + + // Route: POST /obp/v6.0.0/management/system-dynamic-entities (201) + lazy val createSystemDynamicEntity: HttpRoutes[IO] = HttpRoutes.of[IO] { + case req @ POST -> `prefixPath` / "management" / "system-dynamic-entities" => + EndpointHelpers.executeFutureCreated(req) { + implicit val cc: CallContext = req.callContext + val rawBody = cc.httpBody.getOrElse("") + for { + request <- NewStyle.function.tryons(InvalidJsonFormat, 400, Some(cc)) { + net.liftweb.json.parse(rawBody).extract[CreateDynamicEntityRequestJsonV600] + } + _ <- validateEntityNameV600(request.entity_name, cc) + dynamicEntity <- NewStyle.function.tryons(InvalidJsonFormat, 400, Some(cc)) { + DynamicEntityCommons(JSONFactory600.convertV600RequestToInternal(request), None, cc.userId, None) + } + result <- createDynamicEntityV600(cc, dynamicEntity) + } yield result + } + } + + resourceDocs += ResourceDoc( + null, implementedInApiVersion, nameOf(createSystemDynamicEntity), "POST", + "/management/system-dynamic-entities", "Create System Level Dynamic Entity", + """Create a system-level Dynamic Entity.""", + EmptyBody, EmptyBody, + List($AuthenticatedUserIsRequired, UserHasMissingRoles, InvalidJsonFormat, UnknownError), + apiTagManageDynamicEntity :: apiTagApi :: Nil, + Some(canCreateSystemLevelDynamicEntity :: Nil), + authMode = code.api.util.APIUtil.UserOrApplication, + http4sPartialFunction = Some(createSystemDynamicEntity) + ) + + // Route: POST /obp/v6.0.0/management/banks/BANK_ID/dynamic-entities (201) + lazy val createBankLevelDynamicEntity: HttpRoutes[IO] = HttpRoutes.of[IO] { + case req @ POST -> `prefixPath` / "management" / "banks" / bankIdStr / "dynamic-entities" => + EndpointHelpers.executeFutureCreated(req) { + implicit val cc: CallContext = req.callContext + val rawBody = cc.httpBody.getOrElse("") + for { + request <- NewStyle.function.tryons(InvalidJsonFormat, 400, Some(cc)) { + net.liftweb.json.parse(rawBody).extract[CreateDynamicEntityRequestJsonV600] + } + _ <- validateEntityNameV600(request.entity_name, cc) + dynamicEntity <- NewStyle.function.tryons(InvalidJsonFormat, 400, Some(cc)) { + DynamicEntityCommons(JSONFactory600.convertV600RequestToInternal(request), None, cc.userId, Some(bankIdStr)) + } + result <- createDynamicEntityV600(cc, dynamicEntity) + } yield result + } + } + + resourceDocs += ResourceDoc( + null, implementedInApiVersion, nameOf(createBankLevelDynamicEntity), "POST", + "/management/banks/BANK_ID/dynamic-entities", "Create Bank Level Dynamic Entity", + """Create a bank-level Dynamic Entity for the specified bank.""", + EmptyBody, EmptyBody, + List($AuthenticatedUserIsRequired, $BankNotFound, UserHasMissingRoles, InvalidJsonFormat, UnknownError), + apiTagManageDynamicEntity :: apiTagApi :: Nil, + Some(canCreateBankLevelDynamicEntity :: Nil), + authMode = code.api.util.APIUtil.UserOrApplication, + http4sPartialFunction = Some(createBankLevelDynamicEntity) + ) + + // Route: PUT /obp/v6.0.0/management/system-dynamic-entities/DYNAMIC_ENTITY_ID (200) + lazy val updateSystemDynamicEntity: HttpRoutes[IO] = HttpRoutes.of[IO] { + case req @ PUT -> `prefixPath` / "management" / "system-dynamic-entities" / dynamicEntityId => + EndpointHelpers.executeAndRespond(req) { implicit cc => + val rawBody = cc.httpBody.getOrElse("") + for { + request <- NewStyle.function.tryons(InvalidJsonFormat, 400, Some(cc)) { + net.liftweb.json.parse(rawBody).extract[UpdateDynamicEntityRequestJsonV600] + } + _ <- validateEntityNameV600(request.entity_name, cc) + internalJson = JSONFactory600.convertV600UpdateRequestToInternal(request) + dynamicEntity = DynamicEntityCommons(internalJson, Some(dynamicEntityId), cc.userId, None) + result <- updateDynamicEntityV600(cc, dynamicEntity) + } yield result + } + } + + resourceDocs += ResourceDoc( + null, implementedInApiVersion, nameOf(updateSystemDynamicEntity), "PUT", + "/management/system-dynamic-entities/DYNAMIC_ENTITY_ID", "Update System Level Dynamic Entity", + """Update a system-level Dynamic Entity.""", + EmptyBody, EmptyBody, + List($AuthenticatedUserIsRequired, UserHasMissingRoles, InvalidJsonFormat, UnknownError), + apiTagManageDynamicEntity :: apiTagApi :: Nil, + Some(canUpdateSystemDynamicEntity :: Nil), + http4sPartialFunction = Some(updateSystemDynamicEntity) + ) + + // Route: PUT /obp/v6.0.0/management/banks/BANK_ID/dynamic-entities/DYNAMIC_ENTITY_ID (200) + lazy val updateBankLevelDynamicEntity: HttpRoutes[IO] = HttpRoutes.of[IO] { + case req @ PUT -> `prefixPath` / "management" / "banks" / bankIdStr / "dynamic-entities" / dynamicEntityId => + EndpointHelpers.executeAndRespond(req) { implicit cc => + val rawBody = cc.httpBody.getOrElse("") + for { + request <- NewStyle.function.tryons(InvalidJsonFormat, 400, Some(cc)) { + net.liftweb.json.parse(rawBody).extract[UpdateDynamicEntityRequestJsonV600] + } + _ <- validateEntityNameV600(request.entity_name, cc) + internalJson = JSONFactory600.convertV600UpdateRequestToInternal(request) + dynamicEntity = DynamicEntityCommons(internalJson, Some(dynamicEntityId), cc.userId, Some(bankIdStr)) + result <- updateDynamicEntityV600(cc, dynamicEntity) + } yield result + } + } + + resourceDocs += ResourceDoc( + null, implementedInApiVersion, nameOf(updateBankLevelDynamicEntity), "PUT", + "/management/banks/BANK_ID/dynamic-entities/DYNAMIC_ENTITY_ID", "Update Bank Level Dynamic Entity", + """Update a bank-level Dynamic Entity.""", + EmptyBody, EmptyBody, + List($AuthenticatedUserIsRequired, $BankNotFound, UserHasMissingRoles, InvalidJsonFormat, UnknownError), + apiTagManageDynamicEntity :: apiTagApi :: Nil, + Some(canUpdateBankLevelDynamicEntity :: Nil), + http4sPartialFunction = Some(updateBankLevelDynamicEntity) + ) + + // Route: PUT /obp/v6.0.0/my/dynamic-entities/DYNAMIC_ENTITY_ID (200) + lazy val updateMyDynamicEntity: HttpRoutes[IO] = HttpRoutes.of[IO] { + case req @ PUT -> `prefixPath` / "my" / "dynamic-entities" / dynamicEntityId => + EndpointHelpers.executeAndRespond(req) { implicit cc => + val rawBody = cc.httpBody.getOrElse("") + for { + existingEntity <- Future( + NewStyle.function.getDynamicEntitiesByUserId(cc.userId).find(_.dynamicEntityId.contains(dynamicEntityId)) + ) + _ <- Helper.booleanToFuture(s"$DynamicEntityNotFoundByDynamicEntityId dynamicEntityId = $dynamicEntityId", cc = Some(cc)) { + existingEntity.isDefined + } + request <- NewStyle.function.tryons(InvalidJsonFormat, 400, Some(cc)) { + net.liftweb.json.parse(rawBody).extract[UpdateDynamicEntityRequestJsonV600] + } + _ <- validateEntityNameV600(request.entity_name, cc) + internalJson = JSONFactory600.convertV600UpdateRequestToInternal(request) + dynamicEntity = DynamicEntityCommons(internalJson, Some(dynamicEntityId), cc.userId, existingEntity.get.bankId) + result <- updateDynamicEntityV600(cc, dynamicEntity) + } yield result + } + } + + resourceDocs += ResourceDoc( + null, implementedInApiVersion, nameOf(updateMyDynamicEntity), "PUT", + "/my/dynamic-entities/DYNAMIC_ENTITY_ID", "Update My Dynamic Entity", + """Update a Dynamic Entity I created.""", + EmptyBody, EmptyBody, + List($AuthenticatedUserIsRequired, DynamicEntityNotFoundByDynamicEntityId, InvalidJsonFormat, UnknownError), + apiTagManageDynamicEntity :: apiTagApi :: Nil, + None, + http4sPartialFunction = Some(updateMyDynamicEntity) + ) + + // Route: PUT /obp/v6.0.0/system-views/UPD_VIEW_ID (200) + // Uses UPD_VIEW_ID (non-standard ALL_CAPS) so middleware skips view validation; + // system views aren't in the regular view tables that VIEW_ID resolution checks. + lazy val updateSystemView: HttpRoutes[IO] = HttpRoutes.of[IO] { + case req @ PUT -> `prefixPath` / "system-views" / viewIdStr if viewIdStr.nonEmpty => + EndpointHelpers.executeAndRespond(req) { implicit cc => + val rawBody = cc.httpBody.getOrElse("") + for { + user <- Future(cc.user.openOrThrowException(AuthenticatedUserIsRequired)) + _ <- Helper.booleanToFuture(UserHasMissingRoles + canUpdateSystemView, failCode = 403, cc = Some(cc)) { + APIUtil.hasEntitlement("", user.userId, canUpdateSystemView) + } + updateJson <- NewStyle.function.tryons( + s"$InvalidJsonFormat The Json body should be the UpdateViewJsonV600", 400, Some(cc)) { + net.liftweb.json.parse(rawBody).extract[UpdateViewJsonV600] + } + _ <- Helper.booleanToFuture(SystemViewCannotBePublicError, failCode = 400, cc = Some(cc)) { + updateJson.is_public == false + } + _ <- ViewNewStyle.systemView(ViewId(viewIdStr), Some(cc)) + updatedView <- ViewNewStyle.updateSystemView(ViewId(viewIdStr), updateJson.toUpdateViewJson, Some(cc)) + } yield JSONFactory600.createViewJsonV600(updatedView) + } + } + + resourceDocs += ResourceDoc( + null, implementedInApiVersion, nameOf(updateSystemView), "PUT", + "/system-views/UPD_VIEW_ID", "Update System View", + """Update an existing system view.""", + EmptyBody, EmptyBody, + List($AuthenticatedUserIsRequired, UserHasMissingRoles, InvalidJsonFormat, SystemViewCannotBePublicError, UnknownError), + apiTagView :: Nil, + None, + http4sPartialFunction = Some(updateSystemView) + ) + + // Inject default from_date so metrics queries don't hit all rows since epoch. + private def applyMetricsFromDateDefault(httpParams: List[HTTPParam]): List[HTTPParam] = { + val hasFromDate = httpParams.exists(p => p.name == "from_date" || p.name == "obp_from_date") + if (hasFromDate) httpParams + else { + val stableBoundary = APIUtil.getPropsAsIntValue("MappedMetrics.stable.boundary.seconds", 600) + val defaultFromDate = new java.util.Date(System.currentTimeMillis() - ((stableBoundary - 1) * 1000L)) + HTTPParam("from_date", List(APIUtil.DateWithMsFormat.format(defaultFromDate))) :: httpParams + } + } + + // Route: GET /obp/v6.0.0/management/metrics + lazy val getMetrics: HttpRoutes[IO] = HttpRoutes.of[IO] { + case req @ GET -> `prefixPath` / "management" / "metrics" => + EndpointHelpers.withUser(req) { (_, cc) => + for { + httpParams <- NewStyle.function.extractHttpParamsFromUrl(req.uri.renderString) + (obpQueryParams, callContext) <- createQueriesByHttpParamsFuture( + applyMetricsFromDateDefault(httpParams), cc.callContext) + metrics <- Future(APIMetrics.apiMetrics.vend.getAllMetrics(obpQueryParams)) + } yield { + val lookupMap = APIUtil.getAllResourceDocs.map(d => d.partialFunctionName -> d.operationId).toMap + JSONFactory600.createMetricsJsonV600(metrics, lookupMap) + } + } + } + + resourceDocs += ResourceDoc( + null, implementedInApiVersion, nameOf(getMetrics), "GET", + "/management/metrics", "Get Metrics", + """Returns metrics on API usage. Requires canReadMetrics role.""", + EmptyBody, EmptyBody, + List($AuthenticatedUserIsRequired, UserHasMissingRoles, UnknownError), + apiTagMetric :: apiTagApi :: Nil, + Some(canReadMetrics :: Nil), + http4sPartialFunction = Some(getMetrics) + ) + + // Route: GET /obp/v6.0.0/management/aggregate-metrics + lazy val getAggregateMetrics: HttpRoutes[IO] = HttpRoutes.of[IO] { + case req @ GET -> `prefixPath` / "management" / "aggregate-metrics" => + EndpointHelpers.withUser(req) { (_, cc) => + for { + httpParams <- NewStyle.function.extractHttpParamsFromUrl(req.uri.renderString) + _ <- NewStyle.function.tryons(ExcludeParametersNotSupported, 400, Some(cc)) { + val excludes = httpParams.filter(p => + p.name == "exclude_app_names" || + p.name == "exclude_url_patterns" || + p.name == "exclude_implemented_by_partial_functions") + if (excludes.nonEmpty) + throw new Exception(s"$ExcludeParametersNotSupported Parameters found: [${excludes.map(_.name).mkString(", ")}]") + else true + } + (obpQueryParams, callContext) <- createQueriesByHttpParamsFuture( + applyMetricsFromDateDefault(httpParams), cc.callContext) + aggregateMetrics <- APIMetrics.apiMetrics.vend.getAllAggregateMetricsFuture(obpQueryParams, false) map { + APIUtil.unboxFullOrFail(_, callContext, GetAggregateMetricsError) + } + } yield JSONFactory300.createAggregateMetricJson(aggregateMetrics) + } + } + + resourceDocs += ResourceDoc( + null, implementedInApiVersion, nameOf(getAggregateMetrics), "GET", + "/management/aggregate-metrics", "Get Aggregate Metrics", + """Returns aggregate metrics on API usage. Requires canReadAggregateMetrics role.""", + EmptyBody, EmptyBody, + List($AuthenticatedUserIsRequired, UserHasMissingRoles, UnknownError), + apiTagMetric :: apiTagApi :: Nil, + Some(canReadAggregateMetrics :: Nil), + http4sPartialFunction = Some(getAggregateMetrics) + ) + + // Route: GET /obp/v6.0.0/management/metrics/top-apis + lazy val getTopAPIs: HttpRoutes[IO] = HttpRoutes.of[IO] { + case req @ GET -> `prefixPath` / "management" / "metrics" / "top-apis" => + EndpointHelpers.withUser(req) { (_, cc) => + for { + httpParams <- NewStyle.function.extractHttpParamsFromUrl(req.uri.renderString) + (obpQueryParams, callContext) <- createQueriesByHttpParamsFuture(httpParams, cc.callContext) + topApis <- APIMetrics.apiMetrics.vend.getTopApisFuture(obpQueryParams) map { + APIUtil.unboxFullOrFail(_, callContext, GetTopApisError) + } + } yield { + val lookupMap: Map[String, String] = APIUtil.getAllResourceDocs.map(d => + d.partialFunctionName -> d.operationId).toMap + val topApisWithOperationId = topApis.map { api => + val operationId = lookupMap.getOrElse( + api.ImplementedByPartialFunction, + scala.util.Try(APIUtil.buildOperationId( + ApiVersionUtils.valueOf(api.implementedInVersion), api.ImplementedByPartialFunction)) + .getOrElse(s"${api.implementedInVersion}-${api.ImplementedByPartialFunction}")) + TopApiJsonV600( + count = api.count, + implemented_by_partial_function = api.ImplementedByPartialFunction, + implemented_in_version = api.implementedInVersion, + operation_id = operationId) + } + JSONFactory600.createTopApisJsonV600(topApisWithOperationId) + } + } + } + + resourceDocs += ResourceDoc( + null, implementedInApiVersion, nameOf(getTopAPIs), "GET", + "/management/metrics/top-apis", "Get Top APIs", + """Returns the top APIs by call count. Requires canReadMetrics role.""", + EmptyBody, EmptyBody, + List($AuthenticatedUserIsRequired, UserHasMissingRoles, UnknownError), + apiTagMetric :: apiTagApi :: Nil, + Some(canReadMetrics :: Nil), + http4sPartialFunction = Some(getTopAPIs) + ) + + // Route: GET /obp/v6.0.0/webui-props + lazy val getWebUiProps: HttpRoutes[IO] = HttpRoutes.of[IO] { + case req @ GET -> `prefixPath` / "webui-props" => + EndpointHelpers.executeAndRespond(req) { implicit cc => + val what = req.uri.query.params.getOrElse("what", "active") + for { + _ <- NewStyle.function.tryons( + s"$InvalidFilterParameterFormat `what` must be one of: active, database, config. Current value: $what", + 400, Some(cc)) { + what match { case "active" | "database" | "config" => true case _ => false } + } + explicitWebUiProps <- Future(MappedWebUiPropsProvider.getAll()) + } yield { + val explicitWebUiPropsWithSource = explicitWebUiProps.map(prop => + WebUiPropsCommons(prop.name, prop.value, prop.webUiPropsId, source = Some("database"))) + val implicitWebUiProps = APIUtil.getWebUIPropsPairs.map(p => + WebUiPropsCommons(p._1, p._2, webUiPropsId = Some("default"), source = Some("config"))) + val result = what match { + case "database" => explicitWebUiPropsWithSource + case "config" => implicitWebUiProps.distinct + case "active" => + val databasePropNames = explicitWebUiPropsWithSource.map(_.name).toSet + val configNotInDatabase = implicitWebUiProps.distinct.filterNot(p => databasePropNames.contains(p.name)) + explicitWebUiPropsWithSource ++ configNotInDatabase + } + ListResult("webui_props", result) + } + } + } + + resourceDocs += ResourceDoc( + null, implementedInApiVersion, nameOf(getWebUiProps), "GET", + "/webui-props", "Get WebUiProps", + """Get the WebUI props. Optional ?what=active|database|config filter.""", + EmptyBody, EmptyBody, + List(InvalidFilterParameterFormat, UnknownError), + apiTagWebUiProps :: apiTagApi :: Nil, + None, + http4sPartialFunction = Some(getWebUiProps) + ) + + // Route: GET /obp/v6.0.0/banks/BANK_ID/accounts + lazy val getAccountsAtBank: HttpRoutes[IO] = HttpRoutes.of[IO] { + case req @ GET -> `prefixPath` / "banks" / _ / "accounts" => + EndpointHelpers.withUserAndBank(req) { (user, bank, cc) => + val filteredParams: Map[String, List[String]] = req.uri.query.multiParams + .filterKeys(k => k != PARAM_TIMESTAMP && k != PARAM_LOCALE) + .map { case (k, vs) => k -> vs.toList } + .toMap + for { + (privateViewsUserCanAccessAtOneBank, privateAccountAccess) <- Future { + Views.views.vend.privateViewsUserCanAccessAtBank(user, bank.bankId) + } + privateAccountAccess2 <- + if (filteredParams.isEmpty || privateAccountAccess.isEmpty) Future.successful(privateAccountAccess) + else AccountAttributeX.accountAttributeProvider.vend + .getAccountIdsByParams(bank.bankId, filteredParams) + .map { boxedAccountIds => + val accountIds = boxedAccountIds.getOrElse(Nil) + privateAccountAccess.filter(aa => accountIds.contains(aa.account_id.get)) + } + (availablePrivateAccounts, _) <- BankExtended(bank).privateAccountsFuture(privateAccountAccess2, Some(cc)) + } yield { + val accountsJson = availablePrivateAccounts.map { account => + val viewsAvailable = privateViewsUserCanAccessAtOneBank + .filter(v => v.bankId == bank.bankId && v.accountId == account.accountId) + .map(v => BasicViewJson(v.viewId.value, v.name, v.isPublic)) + JSONFactory600.createBasicAccountJsonV600(account, viewsAvailable) + } + BasicAccountsJsonV600(accountsJson) + } + } + } + + resourceDocs += ResourceDoc( + null, implementedInApiVersion, nameOf(getAccountsAtBank), "GET", + "/banks/BANK_ID/accounts", "Get Accounts at Bank", + """Get private accounts the caller has access to at the specified bank.""", + EmptyBody, EmptyBody, + List($AuthenticatedUserIsRequired, $BankNotFound, UnknownError), + apiTagAccount :: Nil, + None, + http4sPartialFunction = Some(getAccountsAtBank) + ) + + // Route: GET /obp/v6.0.0/banks/BANK_ID/accounts/ACCOUNT_ID/VIEW_ID/transactions + lazy val getTransactionsForBankAccount: HttpRoutes[IO] = HttpRoutes.of[IO] { + case req @ GET -> `prefixPath` / "banks" / _ / "accounts" / _ / _ / "transactions" => + EndpointHelpers.withView(req) { (user, bankAccount, view, cc) => + for { + (bank, _) <- NewStyle.function.getBank(bankAccount.bankId, Some(cc)) + (params, _) <- createQueriesByHttpParamsFuture( + req.headers.headers.toList.map(h => HTTPParam(h.name.toString, h.value)), Some(cc)) + transactionsResult <- bankAccount.getModeratedTransactionsFuture(bank, Full(user), view, Some(cc), params) map { + APIUtil.connectorEmptyResponse(_, Some(cc)) + } + (transactions: List[ModeratedTransaction], _) = transactionsResult + moderatedTransactionsWithAttributes <- Future.sequence(transactions.map(transaction => + NewStyle.function.getTransactionAttributes(bankAccount.bankId, transaction.id, Some(cc)) + .map(attrs => code.api.v3_0_0.ModeratedTransactionWithAttributes(transaction, attrs._1)))) + } yield JSONFactory600.createTransactionsJsonV600(moderatedTransactionsWithAttributes) + } + } + + resourceDocs += ResourceDoc( + null, implementedInApiVersion, nameOf(getTransactionsForBankAccount), "GET", + "/banks/BANK_ID/accounts/ACCOUNT_ID/VIEW_ID/transactions", "Get Transactions for Account (Full)", + """Returns transactions list of the account specified by ACCOUNT_ID, moderated by VIEW_ID.""", + EmptyBody, EmptyBody, + List($AuthenticatedUserIsRequired, $BankNotFound, $BankAccountNotFound, $UserNoPermissionAccessView, UnknownError), + apiTagTransaction :: Nil, + None, + http4sPartialFunction = Some(getTransactionsForBankAccount) + ) + + // Route: GET /obp/v6.0.0/banks/BANK_ID/products + // Simplified port — skips the Redis cache layer (perf optimization only). + lazy val getProductsV600: HttpRoutes[IO] = HttpRoutes.of[IO] { + case req @ GET -> `prefixPath` / "banks" / _ / "products" => + EndpointHelpers.withBank(req) { (bank, cc) => + val params = req.uri.query.multiParams.toList.map { case (k, vs) => GetProductsParam(k, vs.toList) } + for { + (products, _) <- NewStyle.function.getProducts(bank.bankId, params, Some(cc)) + } yield JSONFactory600.createProductsJsonV600(products, Map.empty) + } + } + + resourceDocs += ResourceDoc( + null, implementedInApiVersion, nameOf(getProductsV600), "GET", + "/banks/BANK_ID/products", "Get Products", + """Returns the financial Products offered by the specified bank.""", + EmptyBody, EmptyBody, + List($BankNotFound, UnknownError), + apiTagProduct :: Nil, + None, + http4sPartialFunction = Some(getProductsV600) + ) + + // Route: GET /obp/v6.0.0/users + lazy val getUsers: HttpRoutes[IO] = HttpRoutes.of[IO] { + case req @ GET -> `prefixPath` / "users" => + EndpointHelpers.withUser(req) { (_, cc) => + for { + httpParams <- NewStyle.function.extractHttpParamsFromUrl(req.uri.renderString) + (obpQueryParams, callContext) <- createQueriesByHttpParamsFuture(httpParams, cc.callContext) + _ <- Future { + val requestedSort = obpQueryParams.collectFirst { case code.api.util.OBPSortBy(v) => v } + val allowed = DoobieUserQueries.SortableColumns.keySet + val valid = requestedSort match { + case Some(v) if !allowed.contains(v) => + Failure(filterSortByNotAllowedForEndpointDetail("GET /users", v, allowed)) + case _ => Full(()) + } + unboxFullOrFail(valid, callContext, FilterSortByNotAllowedForEndpoint, 400) + } + rows <- Users.users.vend.getUsersV600F(obpQueryParams) + } yield JSONFactory600.createUsersInfoJsonV600(rows) + } + } + + resourceDocs += ResourceDoc( + null, implementedInApiVersion, nameOf(getUsers), "GET", + "/users", "Get Users", + """Get all Users (paginated, sortable).""", + EmptyBody, EmptyBody, + List($AuthenticatedUserIsRequired, UserHasMissingRoles, UnknownError), + apiTagUser :: Nil, + Some(canGetAnyUser :: Nil), + http4sPartialFunction = Some(getUsers) + ) + + // Route: POST /obp/v6.0.0/banks (201) + lazy val createBank: HttpRoutes[IO] = HttpRoutes.of[IO] { + case req @ POST -> `prefixPath` / "banks" => + EndpointHelpers.executeFutureCreated(req) { + implicit val cc: CallContext = req.callContext + val rawBody = cc.httpBody.getOrElse("") + val failMsg = s"$InvalidJsonFormat The Json body should be the PostBankJson600" + for { + postJson <- NewStyle.function.tryons(failMsg, 400, Some(cc)) { + net.liftweb.json.parse(rawBody).extract[PostBankJson600] + } + checkShortStringValue = APIUtil.checkOptionalShortString(postJson.bank_id) + _ <- Helper.booleanToFuture(s"$InvalidJsonFormat BANK_ID: $checkShortStringValue", cc = Some(cc)) { + checkShortStringValue == SILENCE_IS_GOLDEN + } + _ <- Helper.booleanToFuture(InvalidConsumerCredentials, cc = Some(cc)) { + cc.consumer.isDefined + } + _ <- Helper.booleanToFuture(s"$InvalidJsonFormat Min length of BANK_ID should be greater than 3 characters.", cc = Some(cc)) { + postJson.bank_id.length > 3 + } + _ <- Helper.booleanToFuture(s"$InvalidJsonFormat BANK_ID can not contain space characters", cc = Some(cc)) { + !postJson.bank_id.contains(" ") + } + (banks, _) <- NewStyle.function.getBanks(Some(cc)) + _ <- Helper.booleanToFuture(bankIdAlreadyExists, failCode = 409, cc = Some(cc)) { + !banks.exists(_.bankId.value == postJson.bank_id) + } + (success, _) <- NewStyle.function.createOrUpdateBank( + postJson.bank_id, + postJson.full_name.getOrElse(""), + postJson.bank_code, + postJson.logo.getOrElse(""), + postJson.website.getOrElse(""), + postJson.bank_routings.getOrElse(Nil).find(_.scheme == "BIC").map(_.address).getOrElse(""), + "", + postJson.bank_routings.getOrElse(Nil).filterNot(_.scheme == "BIC").headOption.map(_.scheme).getOrElse(""), + postJson.bank_routings.getOrElse(Nil).filterNot(_.scheme == "BIC").headOption.map(_.address).getOrElse(""), + Some(cc) + ) + entitlements <- NewStyle.function.getEntitlementsByUserId(cc.userId, Some(cc)) + entitlementsByBank = entitlements.filter(_.bankId == postJson.bank_id) + _ = if (!entitlementsByBank.exists(_.roleName == CanCreateEntitlementAtOneBank.toString)) + Entitlement.entitlement.vend.addEntitlement(postJson.bank_id, cc.userId, CanCreateEntitlementAtOneBank.toString) + } yield JSONFactory600.createBankJSON600(success) + } + } + + resourceDocs += ResourceDoc( + null, implementedInApiVersion, nameOf(createBank), "POST", + "/banks", "Create Bank", + """Create a new bank.""", + EmptyBody, EmptyBody, + List($AuthenticatedUserIsRequired, UserHasMissingRoles, InvalidJsonFormat, bankIdAlreadyExists, UnknownError), + apiTagBank :: Nil, + Some(canCreateBank :: Nil), + http4sPartialFunction = Some(createBank) + ) + + // Route: POST /obp/v6.0.0/banks/BANK_ID/customers (201) + lazy val createCustomer: HttpRoutes[IO] = HttpRoutes.of[IO] { + case req @ POST -> `prefixPath` / "banks" / bankIdStr / "customers" => + EndpointHelpers.executeFutureCreated(req) { + implicit val cc: CallContext = req.callContext + val rawBody = cc.httpBody.getOrElse("") + val bankId = BankId(bankIdStr) + val failMsg = s"$InvalidJsonFormat The Json body should be the PostCustomerJsonV600 " + for { + postedData <- NewStyle.function.tryons(failMsg, 400, Some(cc)) { + net.liftweb.json.parse(rawBody).extract[PostCustomerJsonV600] + } + _ <- Helper.booleanToFuture(InvalidJsonContent, 400, Some(cc)) { + postedData.dependants.getOrElse(0) == postedData.dob_of_dependants.getOrElse(Nil).length + } + dateOfBirth <- Future { + postedData.date_of_birth.map { dateStr => + val formatter = new java.text.SimpleDateFormat("yyyy-MM-dd") + formatter.setTimeZone(java.util.TimeZone.getTimeZone("UTC")) + formatter.setLenient(false) + formatter.parse(dateStr) + }.orNull + } + dobOfDependants <- Future { + postedData.dob_of_dependants.getOrElse(Nil).map { dateStr => + val formatter = new java.text.SimpleDateFormat("yyyy-MM-dd") + formatter.setTimeZone(java.util.TimeZone.getTimeZone("UTC")) + formatter.setLenient(false) + formatter.parse(dateStr) + } + } + customerNumber = postedData.customer_number.getOrElse(Random.nextInt(Integer.MAX_VALUE).toString) + (_, callContext) <- NewStyle.function.checkCustomerNumberAvailable(bankId, customerNumber, Some(cc)) + customerType = postedData.customer_type.getOrElse("INDIVIDUAL") + _ <- Helper.booleanToFuture(InvalidCustomerType, 400, callContext) { + List("INDIVIDUAL", "CORPORATE", "SUBSIDIARY").contains(customerType) + } + parentCustomerIdValue = postedData.parent_customer_id.getOrElse("") + _ <- if (parentCustomerIdValue.nonEmpty) NewStyle.function.getCustomerByCustomerId(parentCustomerIdValue, callContext).map(_ => ()) + else Future.successful(()) + (customer, _) <- NewStyle.function.createCustomerC2( + bankId, postedData.legal_name, customerNumber, postedData.mobile_phone_number, + postedData.email.getOrElse(""), + CustomerFaceImage(postedData.face_image.map(_.date).getOrElse(null), postedData.face_image.map(_.url).getOrElse("")), + dateOfBirth, postedData.relationship_status.getOrElse(""), + postedData.dependants.getOrElse(0), dobOfDependants, + postedData.highest_education_attained.getOrElse(""), postedData.employment_status.getOrElse(""), + postedData.kyc_status.getOrElse(false), postedData.last_ok_date.getOrElse(null), + postedData.credit_rating.map(i => CreditRating(i.rating, i.source)), + postedData.credit_limit.map(i => CreditLimit(i.currency, i.amount)), + postedData.title.getOrElse(""), postedData.branch_id.getOrElse(""), + postedData.name_suffix.getOrElse(""), customerType, parentCustomerIdValue, callContext) + } yield JSONFactory600.createCustomerJson(customer) + } + } + + resourceDocs += ResourceDoc( + null, implementedInApiVersion, nameOf(createCustomer), "POST", + "/banks/BANK_ID/customers", "Create Customer", + """Create a new customer at the specified bank.""", + EmptyBody, EmptyBody, + List($AuthenticatedUserIsRequired, $BankNotFound, InvalidJsonFormat, UserHasMissingRoles, UnknownError), + apiTagCustomer :: Nil, + Some(canCreateCustomer :: Nil), + http4sPartialFunction = Some(createCustomer) + ) + + // Route: POST /obp/v6.0.0/users (201) + lazy val createUser: HttpRoutes[IO] = HttpRoutes.of[IO] { + case req @ POST -> `prefixPath` / "users" => + EndpointHelpers.executeFutureCreated(req) { + implicit val cc: CallContext = req.callContext + val rawBody = cc.httpBody.getOrElse("") + for { + postedData <- NewStyle.function.tryons(InvalidJsonFormat, 400, Some(cc)) { + net.liftweb.json.parse(rawBody).extract[CreateUserJsonV600] + } + _ <- Helper.booleanToFuture(InvalidStrongPasswordFormat, 400, Some(cc)) { + APIUtil.fullPasswordValidation(postedData.password) + } + _ <- Helper.booleanToFuture(DuplicateUsername, 409, Some(cc)) { + AuthUser.find(net.liftweb.mapper.By(AuthUser.username, postedData.username)).isEmpty + } + userCreated <- Future { + AuthUser.create + .firstName(postedData.first_name).lastName(postedData.last_name) + .username(postedData.username).email(postedData.email) + .password(postedData.password) + .validated(APIUtil.getPropsAsBoolValue("authUser.skipEmailValidation", defaultValue = false)) + } + _ <- Helper.booleanToFuture(InvalidJsonFormat + userCreated.validate.map(_.msg).mkString(";"), 400, Some(cc)) { + userCreated.validate.size == 0 + } + savedUser <- NewStyle.function.tryons(InvalidJsonFormat, 400, Some(cc)) { userCreated.saveMe() } + _ <- Helper.booleanToFuture(s"$UnknownError Error occurred during user creation.", 400, Some(cc)) { + userCreated.saved_? + } + } yield { + val skipEmailValidation = APIUtil.getPropsAsBoolValue("authUser.skipEmailValidation", defaultValue = false) + if (!skipEmailValidation) { + APIUtil.getPropsValue("portal_external_url").foreach { portalUrl => + val expiryMinutes = APIUtil.getPropsAsIntValue("email_validation_token_expiry_minutes", 1440) + val claimsSet = new com.nimbusds.jwt.JWTClaimsSet.Builder() + .subject(savedUser.uniqueId.get) + .expirationTime(new java.util.Date(System.currentTimeMillis() + expiryMinutes * 60L * 1000L)) + .issueTime(new java.util.Date()).build() + val jwtToken = CertificateUtil.jwtWithHmacProtection(claimsSet) + val emailLink = portalUrl + "/user-validation?token=" + java.net.URLEncoder.encode(jwtToken, "UTF-8") + CommonsEmailWrapper.sendHtmlEmail(CommonsEmailWrapper.EmailContent( + from = AuthUser.emailFrom, + to = List(savedUser.email.get), + bcc = AuthUser.bccEmail.toList, + subject = "Sign up confirmation", + textContent = Some(s"Welcome! Please validate your account: $emailLink"), + htmlContent = Some(s"

Welcome! Please validate your account.

") + )) + } + } + AuthUser.grantDefaultEntitlementsToAuthUser(savedUser) + JSONFactory200.createUserJSONfromAuthUser(userCreated) + } + } + } + + resourceDocs += ResourceDoc( + null, implementedInApiVersion, nameOf(createUser), "POST", + "/users", "Create User", + """Create a new user with username, email, password.""", + EmptyBody, EmptyBody, + List(InvalidJsonFormat, InvalidStrongPasswordFormat, DuplicateUsername, UnknownError), + apiTagUser :: Nil, + None, + http4sPartialFunction = Some(createUser) + ) + + // Route: POST /obp/v6.0.0/management/user/reset-password-url (201) + lazy val resetPasswordUrl: HttpRoutes[IO] = HttpRoutes.of[IO] { + case req @ POST -> `prefixPath` / "management" / "user" / "reset-password-url" => + EndpointHelpers.executeFutureCreated(req) { + implicit val cc: CallContext = req.callContext + val rawBody = cc.httpBody.getOrElse("") + for { + postedData <- NewStyle.function.tryons( + s"$InvalidJsonFormat The Json body should be the ${classOf[code.api.v6_0_0.JSONFactory600.PostResetPasswordUrlJsonV600]}", + 400, Some(cc)) { + net.liftweb.json.parse(rawBody).extract[code.api.v6_0_0.JSONFactory600.PostResetPasswordUrlJsonV600] + } + authUserBox <- Future { + AuthUser.find(net.liftweb.mapper.By(AuthUser.username, postedData.username)) + } + authUser <- NewStyle.function.tryons(s"$UnknownError User not found or validation failed", 400, Some(cc)) { + authUserBox match { + case Full(user) if user.validated.get && user.email.get == postedData.email => + Users.users.vend.getUserByUserId(postedData.user_id) match { + case Full(resourceUser) if resourceUser.name == postedData.username && + resourceUser.emailAddress == postedData.email => user + case _ => throw new Exception("User ID does not match username and email") + } + case _ => throw new Exception("User not found, not validated, or email mismatch") + } + } + portalUrl <- APIUtil.getPropsValue("portal_external_url") match { + case Full(url) => Future.successful(url) + case _ => Future.failed(new Exception(s"$IncompleteServerConfiguration portal_external_url is not set")) + } + } yield { + val user: AuthUser = authUser + user.uniqueId.set(java.util.UUID.randomUUID().toString.replace("-", "")) + user.save + val expiryMinutes = APIUtil.getPropsAsIntValue("password_reset_token_expiry_minutes", 120) + val claimsSet = new com.nimbusds.jwt.JWTClaimsSet.Builder() + .subject(user.uniqueId.get) + .expirationTime(new java.util.Date(System.currentTimeMillis() + expiryMinutes * 60L * 1000L)) + .issueTime(new java.util.Date()).build() + val jwtToken = CertificateUtil.jwtWithHmacProtection(claimsSet) + val resetLink = portalUrl + "/reset-password/" + java.net.URLEncoder.encode(jwtToken, "UTF-8") + CommonsEmailWrapper.sendHtmlEmail(CommonsEmailWrapper.EmailContent( + from = AuthUser.emailFrom, + to = List(user.email.get), + bcc = AuthUser.bccEmail.toList, + subject = "Reset your password - " + user.username.get, + textContent = Some(s"Please reset your password: $resetLink"), + htmlContent = Some(s"

Please reset your password: $resetLink

") + )) + JSONFactory600.ResetPasswordUrlJsonV600(resetLink) + } + } + } + + resourceDocs += ResourceDoc( + null, implementedInApiVersion, nameOf(resetPasswordUrl), "POST", + "/management/user/reset-password-url", "Send Password Reset URL", + """Generate and email a password reset URL for the specified user.""", + EmptyBody, EmptyBody, + List($AuthenticatedUserIsRequired, UserHasMissingRoles, InvalidJsonFormat, UnknownError), + apiTagUser :: Nil, + Some(canCreateResetPasswordUrl :: Nil), + http4sPartialFunction = Some(resetPasswordUrl) + ) + + // ─── Phase 2: system bucket (8 GETs) — wholly new in v6, no override risk ──── + + // Route: GET /obp/v6.0.0/system/connectors + lazy val getConnectors: HttpRoutes[IO] = HttpRoutes.of[IO] { + case req @ GET -> `prefixPath` / "system" / "connectors" => + EndpointHelpers.executeAndRespond(req) { _ => + Future.successful { + val connectorNames = BankConnector.nameToConnector.keys.toList :+ "star" + val connectorInfos = connectorNames.map { name => + ConnectorInfoJsonV600( + connector_name = name, + is_available_in_method_routing = NewStyle.function.getConnectorByName(name).isDefined) + } + JSONFactory600.createConnectorsJson(connectorInfos) + } + } + } + + resourceDocs += ResourceDoc( + null, implementedInApiVersion, nameOf(getConnectors), "GET", + "/system/connectors", "Get Connectors", + """Get the list of connectors and their availability for method routing.""", + EmptyBody, EmptyBody, + List(UnknownError), + apiTagConnector :: apiTagSystem :: apiTagApi :: Nil, + None, + http4sPartialFunction = Some(getConnectors) + ) + + // Route: GET /obp/v6.0.0/system/cache/config + lazy val getCacheConfig: HttpRoutes[IO] = HttpRoutes.of[IO] { + case req @ GET -> `prefixPath` / "system" / "cache" / "config" => + EndpointHelpers.withUser(req) { (_, _) => + Future.successful(JSONFactory600.createCacheConfigJsonV600()) + } + } + + resourceDocs += ResourceDoc( + null, implementedInApiVersion, nameOf(getCacheConfig), "GET", + "/system/cache/config", "Get Cache Configuration", + """Returns cache configuration including Redis status, in-memory cache status, instance ID, environment and global prefix.""", + EmptyBody, EmptyBody, + List($AuthenticatedUserIsRequired, UserHasMissingRoles, UnknownError), + apiTagCache :: apiTagSystem :: apiTagApi :: Nil, + Some(canGetCacheConfig :: Nil), + http4sPartialFunction = Some(getCacheConfig) + ) + + // Route: GET /obp/v6.0.0/system/cache/info + lazy val getCacheInfo: HttpRoutes[IO] = HttpRoutes.of[IO] { + case req @ GET -> `prefixPath` / "system" / "cache" / "info" => + EndpointHelpers.withUser(req) { (_, _) => + Future.successful(JSONFactory600.createCacheInfoJsonV600()) + } + } + + resourceDocs += ResourceDoc( + null, implementedInApiVersion, nameOf(getCacheInfo), "GET", + "/system/cache/info", "Get Cache Information", + """Returns detailed cache information for all namespaces.""", + EmptyBody, EmptyBody, + List($AuthenticatedUserIsRequired, UserHasMissingRoles, UnknownError), + apiTagCache :: apiTagSystem :: apiTagApi :: Nil, + Some(canGetCacheInfo :: Nil), + http4sPartialFunction = Some(getCacheInfo) + ) + + // Route: GET /obp/v6.0.0/system/cache/namespaces + lazy val getCacheNamespaces: HttpRoutes[IO] = HttpRoutes.of[IO] { + case req @ GET -> `prefixPath` / "system" / "cache" / "namespaces" => + EndpointHelpers.withUser(req) { (_, _) => + Future { + val namespaces = List( + (Constant.CALL_COUNTER_PREFIX, "Rate limiting counters per consumer and time period", "varies", "Rate Limiting"), + (Constant.RATE_LIMIT_ACTIVE_PREFIX, "Active rate limit configurations", Constant.RATE_LIMIT_ACTIVE_CACHE_TTL.toString, "Rate Limiting"), + (Constant.LOCALISED_RESOURCE_DOC_PREFIX, "Localized resource documentation", Constant.CREATE_LOCALISED_RESOURCE_DOC_JSON_TTL.toString, "Resource Documentation"), + (Constant.DYNAMIC_RESOURCE_DOC_CACHE_KEY_PREFIX, "Dynamic resource documentation", Constant.GET_DYNAMIC_RESOURCE_DOCS_TTL.toString, "Resource Documentation"), + (Constant.STATIC_RESOURCE_DOC_CACHE_KEY_PREFIX, "Static resource documentation", Constant.GET_STATIC_RESOURCE_DOCS_TTL.toString, "Resource Documentation"), + (Constant.ALL_RESOURCE_DOC_CACHE_KEY_PREFIX, "All resource documentation", Constant.GET_STATIC_RESOURCE_DOCS_TTL.toString, "Resource Documentation"), + (Constant.STATIC_SWAGGER_DOC_CACHE_KEY_PREFIX, "Swagger documentation", Constant.GET_STATIC_RESOURCE_DOCS_TTL.toString, "Resource Documentation"), + (Constant.CONNECTOR_PREFIX, "Connector method names and metadata", "3600", "Connector"), + (Constant.METRICS_STABLE_PREFIX, "Stable metrics (historical)", "86400", "Metrics"), + (Constant.METRICS_RECENT_PREFIX, "Recent metrics", "7", "Metrics"), + (Constant.ABAC_RULE_PREFIX, "ABAC rule cache", "indefinite", "ABAC") + ).map { case (prefix, description, ttl, category) => + JSONFactory600.createCacheNamespaceJsonV600( + prefix, description, ttl, category, + Redis.countKeys(s"${prefix}*"), + Redis.getSampleKey(s"${prefix}*")) + } + JSONFactory600.createCacheNamespacesJsonV600(namespaces) + } + } + } + + resourceDocs += ResourceDoc( + null, implementedInApiVersion, nameOf(getCacheNamespaces), "GET", + "/system/cache/namespaces", "Get Cache Namespaces", + """Returns all OBP cache namespaces with their prefixes, descriptions, TTLs, and current key counts.""", + EmptyBody, EmptyBody, + List($AuthenticatedUserIsRequired, UserHasMissingRoles, UnknownError), + apiTagCache :: apiTagSystem :: apiTagApi :: Nil, + Some(canGetCacheNamespaces :: Nil), + http4sPartialFunction = Some(getCacheNamespaces) + ) + + // Route: GET /obp/v6.0.0/system/database/pool + lazy val getDatabasePoolInfo: HttpRoutes[IO] = HttpRoutes.of[IO] { + case req @ GET -> `prefixPath` / "system" / "database" / "pool" => + EndpointHelpers.withUser(req) { (_, _) => + Future.successful(JSONFactory600.createDatabasePoolInfoJsonV600()) + } + } + + resourceDocs += ResourceDoc( + null, implementedInApiVersion, nameOf(getDatabasePoolInfo), "GET", + "/system/database/pool", "Get Database Pool Information", + """Returns HikariCP connection pool information.""", + EmptyBody, EmptyBody, + List($AuthenticatedUserIsRequired, UserHasMissingRoles, UnknownError), + apiTagSystem :: apiTagApi :: Nil, + Some(canGetDatabasePoolInfo :: Nil), + http4sPartialFunction = Some(getDatabasePoolInfo) + ) + + // Route: GET /obp/v6.0.0/system/migrations + lazy val getMigrations: HttpRoutes[IO] = HttpRoutes.of[IO] { + case req @ GET -> `prefixPath` / "system" / "migrations" => + EndpointHelpers.withUser(req) { (_, _) => + Future { + val migrations = MigrationScriptLogProvider.migrationScriptLogProvider.vend.getMigrationScriptLogs() + JSONFactory600.createMigrationScriptLogsJsonV600(migrations) + } + } + } + + resourceDocs += ResourceDoc( + null, implementedInApiVersion, nameOf(getMigrations), "GET", + "/system/migrations", "Get Database Migrations", + """Get all database migration script logs.""", + EmptyBody, EmptyBody, + List($AuthenticatedUserIsRequired, UserHasMissingRoles, UnknownError), + apiTagSystem :: apiTagApi :: Nil, + Some(canGetMigrations :: Nil), + http4sPartialFunction = Some(getMigrations) + ) + + // Route: GET /obp/v6.0.0/system/connectors/stored_procedure_vDec2019/health + lazy val getStoredProcedureConnectorHealth: HttpRoutes[IO] = HttpRoutes.of[IO] { + case req @ GET -> `prefixPath` / "system" / "connectors" / "stored_procedure_vDec2019" / "health" => + EndpointHelpers.withUser(req) { (_, _) => + Future { + val health = StoredProcedureUtils.getHealth() + StoredProcedureConnectorHealthJsonV600( + status = health.status, + server_name = health.serverName, + server_ip = health.serverIp, + database_name = health.databaseName, + response_time_ms = health.responseTimeMs, + error_message = health.errorMessage) + } + } + } + + resourceDocs += ResourceDoc( + null, implementedInApiVersion, nameOf(getStoredProcedureConnectorHealth), "GET", + "/system/connectors/stored_procedure_vDec2019/health", "Get Stored Procedure Connector Health", + """Returns health status of the stored procedure connector.""", + EmptyBody, EmptyBody, + List($AuthenticatedUserIsRequired, UserHasMissingRoles, UnknownError), + apiTagConnector :: apiTagSystem :: apiTagApi :: Nil, + Some(canGetConnectorHealth :: Nil), + http4sPartialFunction = Some(getStoredProcedureConnectorHealth) + ) + + // Route: GET /obp/v6.0.0/system/connector-method-names + // Simplified port — skips the Redis cache wrapper (perf only). + lazy val getConnectorMethodNames: HttpRoutes[IO] = HttpRoutes.of[IO] { + case req @ GET -> `prefixPath` / "system" / "connector-method-names" => + EndpointHelpers.withUser(req) { (_, _) => + Future { + val connectorName = APIUtil.getPropsValue("connector", "mapped") + val connector = code.bankconnectors.Connector.getConnectorInstance(connectorName) + JSONFactory600.createConnectorMethodNamesJson(connector.callableMethods.keys.toList) + } + } + } + + resourceDocs += ResourceDoc( + null, implementedInApiVersion, nameOf(getConnectorMethodNames), "GET", + "/system/connector-method-names", "Get Connector Method Names", + """Get all callable method names for the configured connector.""", + EmptyBody, EmptyBody, + List($AuthenticatedUserIsRequired, UserHasMissingRoles, UnknownError), + apiTagConnectorMethod :: apiTagSystem :: apiTagMethodRouting :: apiTagApi :: Nil, + Some(canGetSystemConnectorMethodNames :: Nil), + http4sPartialFunction = Some(getConnectorMethodNames) + ) + + val allRoutes: HttpRoutes[IO] = + Kleisli[HttpF, Request[IO], Response[IO]] { req: Request[IO] => + root(req) + .orElse(getScannedApiVersions(req)) + .orElse(getCurrentUser(req)) + .orElse(getBanks(req)) + .orElse(getBank(req)) + .orElse(getCustomersAtOneBank(req)) + .orElse(getCustomerByCustomerId(req)) + .orElse(getCoreAccountByIdV600(req)) + .orElse(getMyDynamicEntities(req)) + .orElse(getSystemDynamicEntities(req)) + .orElse(getBankLevelDynamicEntities(req)) + .orElse(getConsumer(req)) + .orElse(getCustomersAtAllBanks(req)) + .orElse(getUserAttributes(req)) + .orElse(getPrivateAccountByIdFull(req)) + .orElse(getCustomerByCustomerNumber(req)) + .orElse(getCustomersByLegalName(req)) + .orElse(createSystemDynamicEntity(req)) + .orElse(createBankLevelDynamicEntity(req)) + .orElse(updateSystemDynamicEntity(req)) + .orElse(updateBankLevelDynamicEntity(req)) + .orElse(updateMyDynamicEntity(req)) + .orElse(updateSystemView(req)) + .orElse(getMetrics(req)) + .orElse(getAggregateMetrics(req)) + .orElse(getTopAPIs(req)) + .orElse(getWebUiProps(req)) + .orElse(getAccountsAtBank(req)) + .orElse(getTransactionsForBankAccount(req)) + .orElse(getProductsV600(req)) + .orElse(getUsers(req)) + .orElse(createBank(req)) + .orElse(createCustomer(req)) + .orElse(createUser(req)) + .orElse(resetPasswordUrl(req)) + .orElse(getConnectors(req)) + .orElse(getCacheConfig(req)) + .orElse(getCacheInfo(req)) + .orElse(getCacheNamespaces(req)) + .orElse(getDatabasePoolInfo(req)) + .orElse(getMigrations(req)) + .orElse(getStoredProcedureConnectorHealth(req)) + .orElse(getConnectorMethodNames(req)) + .orElse(createMandate(req)) + .orElse(getMandates(req)) + .orElse(getMandate(req)) + .orElse(updateMandate(req)) + .orElse(deleteMandate(req)) + .orElse(createMandateProvision(req)) + .orElse(getMandateProvisions(req)) + .orElse(getMandateProvision(req)) + .orElse(updateMandateProvision(req)) + .orElse(deleteMandateProvision(req)) + .orElse(createApiProduct(req)) + .orElse(createOrUpdateApiProduct(req)) + .orElse(getApiProduct(req)) + .orElse(getApiProducts(req)) + .orElse(deleteApiProduct(req)) + .orElse(createApiProductAttribute(req)) + .orElse(updateApiProductAttribute(req)) + .orElse(getApiProductAttribute(req)) + .orElse(deleteApiProductAttribute(req)) + .orElse(createFeaturedApiCollection(req)) + .orElse(getFeaturedApiCollectionsAdmin(req)) + .orElse(updateFeaturedApiCollection(req)) + .orElse(deleteFeaturedApiCollection(req)) + .orElse(createPersonalDataField(req)) + .orElse(getPersonalDataFields(req)) + .orElse(getPersonalDataFieldById(req)) + .orElse(updatePersonalDataField(req)) + .orElse(deletePersonalDataField(req)) + .orElse(getConsumerCallCounters(req)) + .orElse(createCallLimits(req)) + .orElse(updateRateLimits(req)) + .orElse(deleteCallLimits(req)) + .orElse(getActiveRateLimitsNow(req)) + .orElse(getActiveRateLimitsAtDate(req)) + .orElse(createGroup(req)) + .orElse(getGroup(req)) + .orElse(getGroups(req)) + .orElse(updateGroup(req)) + .orElse(deleteGroup(req)) + .orElse(getGroupEntitlements(req)) + .orElse(createAbacRule(req)) + .orElse(getAbacRule(req)) + .orElse(getAbacRules(req)) + .orElse(getAbacRulesByPolicy(req)) + .orElse(updateAbacRule(req)) + .orElse(deleteAbacRule(req)) + .orElse(getFeatures(req)) + .orElse(getProviders(req)) + .orElse(getCurrentConsumer(req)) + .orElse(getPopularApis(req)) + .orElse(getAccountDirectory(req)) + .orElse(getConfigProps(req)) + .orElse(getAppDirectory(req)) + .orElse(getCustomViews(req)) + .orElse(getRolesWithEntitlementCountsAtAllBanks(req)) + .orElse(getCustomViewById(req)) + .orElse(invalidateCacheNamespace(req)) + .orElse(createCustomerLink(req)) + .orElse(getCustomerLinksByBankId(req)) + .orElse(getCustomerLinkById(req)) + .orElse(updateCustomerLink(req)) + .orElse(deleteCustomerLink(req)) + .orElse(getCorporateCustomersAtOneBank(req)) + .orElse(getCorporateCustomerByCustomerId(req)) + .orElse(getCorporateCustomerSubsidiaries(req)) + .orElse(getRetailCustomersAtOneBank(req)) + .orElse(getRetailCustomerByCustomerId(req)) + .orElse(getCustomerChildren(req)) + .orElse(getCustomerLinksByCustomerId(req)) + .orElse(getCustomerInvestigationReport(req)) + .orElse(getSystemViews(req)) + .orElse(getSystemViewById(req)) + .orElse(getAbacPolicies(req)) + .orElse(getConnectorCallCounts(req)) + .orElse(getConnectorTraces(req)) + .orElse(getDynamicEntityDiagnostics(req)) + .orElse(cleanupOrphanedDynamicEntityRecords(req)) + .orElse(createOrUpdateWebUiProps(req)) + .orElse(deleteWebUiProps(req)) + .orElse(createCustomViewManagement(req)) + .orElse(getProductTagsV600(req)) + .orElse(updateProductTagsV600(req)) + .orElse(getOidcClient(req)) + .orElse(verifyOidcClient(req)) + .orElse(getUserAttributeById(req)) + .orElse(createUserAttribute(req)) + .orElse(updateUserAttribute(req)) + .orElse(deleteUserAttribute(req)) + .orElse(addUserToGroup(req)) + .orElse(removeUserFromGroup(req)) + .orElse(deleteEntitlement(req)) + .orElse(getAvailablePersonalDynamicEntities(req)) + .orElse(getReferenceTypes(req)) + .orElse(joinSystemChatRoom(req)) + .orElse(createCounterpartyAttribute(req)) + .orElse(deleteCounterpartyAttribute(req)) + .orElse(getCounterpartyAttributeById(req)) + .orElse(getAllCounterpartyAttributes(req)) + .orElse(updateCounterpartyAttribute(req)) + .orElse(hasAccountAccess(req)) + .orElse(getMyAccountAccessRequests(req)) + .orElse(getWebUiProp(req)) + .orElse(getMessageDocsJsonSchema(req)) + .orElse(verifyUserCredentials(req)) + .orElse(getViewPermissions(req)) + .orElse(getAllApiProductsV600(req)) + .orElse(getAllProductsV600(req)) + .orElse(getAccountAccessRequestsForAccount(req)) + .orElse(getAccountAccessRequestById(req)) + .orElse(getHoldingAccountByReleaser(req)) + .orElse(createAccountAccessRequest(req)) + .orElse(approveAccountAccessRequest(req)) + .orElse(rejectAccountAccessRequest(req)) + .orElse(getSignalChannels(req)) + .orElse(getSignalChannelInfo(req)) + .orElse(getSignalStats(req)) + .orElse(publishSignalMessage(req)) + .orElse(getSignalMessages(req)) + .orElse(deleteSignalChannel(req)) + .orElse(getBankChatRooms(req)) + .orElse(getSystemChatRooms(req)) + .orElse(getBankChatRoom(req)) + .orElse(getSystemChatRoom(req)) + .orElse(getMyChatRooms(req)) + .orElse(getMyUnreadCounts(req)) + .orElse(markChatRoomRead(req)) + .orElse(getMyMentions(req)) + .orElse(searchChatRooms(req)) + .orElse(getBulkReactions(req)) + .orElse(archiveBankChatRoom(req)) + .orElse(archiveSystemChatRoom(req)) + .orElse(joinBankChatRoom(req)) + .orElse(refreshBankJoiningKey(req)) + .orElse(refreshSystemJoiningKey(req)) + .orElse(createBankChatRoom(req)) + .orElse(createSystemChatRoom(req)) + .orElse(updateBankChatRoom(req)) + .orElse(updateSystemChatRoom(req)) + .orElse(deleteBankChatRoom(req)) + .orElse(deleteSystemChatRoom(req)) + .orElse(setBankChatRoomOpenRoom(req)) + .orElse(setSystemChatRoomOpenRoom(req)) + .orElse(addBankChatRoomParticipant(req)) + .orElse(addSystemChatRoomParticipant(req)) + .orElse(getBankChatRoomParticipants(req)) + .orElse(getSystemChatRoomParticipants(req)) + .orElse(updateBankParticipantPermissions(req)) + .orElse(updateSystemParticipantPermissions(req)) + .orElse(removeBankChatRoomParticipant(req)) + .orElse(removeSystemChatRoomParticipant(req)) + .orElse(sendBankChatMessage(req)) + .orElse(sendSystemChatMessage(req)) + .orElse(getBankChatMessages(req)) + .orElse(getSystemChatMessages(req)) + .orElse(getBankChatMessage(req)) + .orElse(getSystemChatMessage(req)) + .orElse(editBankChatMessage(req)) + .orElse(editSystemChatMessage(req)) + .orElse(deleteBankChatMessage(req)) + .orElse(deleteSystemChatMessage(req)) + .orElse(getBankThreadReplies(req)) + .orElse(getSystemThreadReplies(req)) + .orElse(replyInBankThread(req)) + .orElse(replyInSystemThread(req)) + .orElse(addBankReaction(req)) + .orElse(addSystemReaction(req)) + .orElse(removeBankReaction(req)) + .orElse(removeSystemReaction(req)) + .orElse(getBankReactions(req)) + .orElse(getSystemReactions(req)) + .orElse(signalBankTyping(req)) + .orElse(signalSystemTyping(req)) + .orElse(getBankTypingUsers(req)) + .orElse(getSystemTypingUsers(req)) + .orElse(createSignatoryPanel(req)) + .orElse(getSignatoryPanels(req)) + .orElse(getSignatoryPanel(req)) + .orElse(updateSignatoryPanel(req)) + .orElse(deleteSignatoryPanel(req)) + .orElse(validateUserEmail(req)) + .orElse(resetPasswordComplete(req)) + .orElse(resetPasswordUrlAnonymous(req)) + .orElse(validateDynamicResourceDoc(req)) + .orElse(createTransactionRequestHold(req)) + .orElse(createTransactionRequestCardano(req)) + .orElse(createTransactionRequestEthereumeSendTransaction(req)) + .orElse(createTransactionRequestEthSendRawTransaction(req)) + .orElse(getUserGroupMemberships(req)) + .orElse(getUsersWithAccountAccess(req)) + .orElse(createRetailCustomer(req)) + .orElse(createCorporateCustomer(req)) + .orElse(getUserByUserId(req)) + .orElse(directLoginEndpoint(req)) + .orElse(validateAbacRule(req)) + .orElse(executeAbacRule(req)) + .orElse(executeAbacPolicy(req)) + .orElse(getAbacRuleSchema(req)) + .orElse(backupSystemDynamicEntity(req)) + .orElse(backupBankLevelDynamicEntity(req)) + .orElse(deleteSystemDynamicEntityCascade(req)) + // createCorporateCustomer + createRetailCustomer deferred — share + // the 60-line date-parsing/customer-number generation logic of + // createCustomer (already migrated); will batch as a focused pass. + } + + // ─── Phase 2: corporate-customers + retail-customers + banks/customers/* (8) ─── + + // Route: GET /obp/v6.0.0/banks/BANK_ID/corporate-customers + lazy val getCorporateCustomersAtOneBank: HttpRoutes[IO] = HttpRoutes.of[IO] { + case req @ GET -> `prefixPath` / "banks" / _ / "corporate-customers" => + EndpointHelpers.withUserAndBank(req) { (_, bank, cc) => + for { + (requestParams, _) <- NewStyle.function.extractQueryParams( + req.uri.renderString, List("limit", "offset", "sort_direction"), Some(cc)) + (customers, _) <- NewStyle.function.getCustomersByCustomerTypes( + bank.bankId, List("CORPORATE", "SUBSIDIARY"), Some(cc), requestParams) + } yield JSONFactory600.createCustomersJson(customers) + } + } + + resourceDocs += ResourceDoc( + null, implementedInApiVersion, nameOf(getCorporateCustomersAtOneBank), "GET", + "/banks/BANK_ID/corporate-customers", "Get Corporate Customers at Bank", + """Get all corporate (and subsidiary) customers at the specified bank.""", + EmptyBody, EmptyBody, + List($AuthenticatedUserIsRequired, $BankNotFound, UserHasMissingRoles, UnknownError), + apiTagCustomer :: Nil, + Some(canGetCustomersAtOneBank :: Nil), + http4sPartialFunction = Some(getCorporateCustomersAtOneBank) + ) + + // Route: GET /obp/v6.0.0/banks/BANK_ID/corporate-customers/CUSTOMER_ID + lazy val getCorporateCustomerByCustomerId: HttpRoutes[IO] = HttpRoutes.of[IO] { + case req @ GET -> `prefixPath` / "banks" / _ / "corporate-customers" / customerId => + EndpointHelpers.withUserAndBank(req) { (_, bank, cc) => + for { + (customer, callContext) <- NewStyle.function.getCustomerByCustomerId(customerId, Some(cc)) + _ <- Helper.booleanToFuture(CustomerTypeMismatch, 404, callContext) { + customer.customerType.exists(ct => List("CORPORATE", "SUBSIDIARY").contains(ct)) + } + (attrs, _) <- NewStyle.function.getCustomerAttributes(bank.bankId, CustomerId(customerId), callContext) + } yield JSONFactory600.createCustomerWithAttributesJson(customer, attrs) + } + } + + resourceDocs += ResourceDoc( + null, implementedInApiVersion, nameOf(getCorporateCustomerByCustomerId), "GET", + "/banks/BANK_ID/corporate-customers/CUSTOMER_ID", "Get Corporate Customer by Id", + """Get a corporate customer by CUSTOMER_ID. Returns 404 if the customer + |is not of type CORPORATE or SUBSIDIARY.""".stripMargin, + EmptyBody, EmptyBody, + List($AuthenticatedUserIsRequired, $BankNotFound, UserHasMissingRoles, CustomerTypeMismatch, UnknownError), + apiTagCustomer :: Nil, + Some(canGetCustomersAtOneBank :: Nil), + http4sPartialFunction = Some(getCorporateCustomerByCustomerId) + ) + + // Route: GET /obp/v6.0.0/banks/BANK_ID/corporate-customers/CUSTOMER_ID/subsidiaries + lazy val getCorporateCustomerSubsidiaries: HttpRoutes[IO] = HttpRoutes.of[IO] { + case req @ GET -> `prefixPath` / "banks" / _ / "corporate-customers" / customerId / "subsidiaries" => + EndpointHelpers.withUserAndBank(req) { (_, bank, cc) => + for { + (customer, callContext) <- NewStyle.function.getCustomerByCustomerId(customerId, Some(cc)) + _ <- Helper.booleanToFuture(CustomerTypeMismatch, 404, callContext) { + customer.customerType.exists(ct => List("CORPORATE", "SUBSIDIARY").contains(ct)) + } + (children, _) <- NewStyle.function.getCustomersByParentCustomerId(bank.bankId, customerId, callContext) + } yield JSONFactory600.createCustomersJson(children) + } + } + + resourceDocs += ResourceDoc( + null, implementedInApiVersion, nameOf(getCorporateCustomerSubsidiaries), "GET", + "/banks/BANK_ID/corporate-customers/CUSTOMER_ID/subsidiaries", "Get Subsidiaries", + """Get the subsidiaries of a corporate customer.""", + EmptyBody, EmptyBody, + List($AuthenticatedUserIsRequired, $BankNotFound, UserHasMissingRoles, CustomerTypeMismatch, UnknownError), + apiTagCustomer :: Nil, + Some(canGetCustomersAtOneBank :: Nil), + http4sPartialFunction = Some(getCorporateCustomerSubsidiaries) + ) + + // Route: GET /obp/v6.0.0/banks/BANK_ID/retail-customers + lazy val getRetailCustomersAtOneBank: HttpRoutes[IO] = HttpRoutes.of[IO] { + case req @ GET -> `prefixPath` / "banks" / _ / "retail-customers" => + EndpointHelpers.withUserAndBank(req) { (_, bank, cc) => + for { + (requestParams, _) <- NewStyle.function.extractQueryParams( + req.uri.renderString, List("limit", "offset", "sort_direction"), Some(cc)) + (customers, _) <- NewStyle.function.getCustomersByCustomerTypes( + bank.bankId, List("INDIVIDUAL"), Some(cc), requestParams) + } yield JSONFactory600.createCustomersJson(customers) + } + } + + resourceDocs += ResourceDoc( + null, implementedInApiVersion, nameOf(getRetailCustomersAtOneBank), "GET", + "/banks/BANK_ID/retail-customers", "Get Retail Customers at Bank", + """Get all retail (individual) customers at the specified bank.""", + EmptyBody, EmptyBody, + List($AuthenticatedUserIsRequired, $BankNotFound, UserHasMissingRoles, UnknownError), + apiTagCustomer :: Nil, + Some(canGetCustomersAtOneBank :: Nil), + http4sPartialFunction = Some(getRetailCustomersAtOneBank) + ) + + // Route: GET /obp/v6.0.0/banks/BANK_ID/retail-customers/CUSTOMER_ID + lazy val getRetailCustomerByCustomerId: HttpRoutes[IO] = HttpRoutes.of[IO] { + case req @ GET -> `prefixPath` / "banks" / _ / "retail-customers" / customerId => + EndpointHelpers.withUserAndBank(req) { (_, bank, cc) => + for { + (customer, callContext) <- NewStyle.function.getCustomerByCustomerId(customerId, Some(cc)) + _ <- Helper.booleanToFuture(CustomerTypeMismatch, 404, callContext) { + customer.customerType.contains("INDIVIDUAL") + } + (attrs, _) <- NewStyle.function.getCustomerAttributes(bank.bankId, CustomerId(customerId), callContext) + } yield JSONFactory600.createCustomerWithAttributesJson(customer, attrs) + } + } + + resourceDocs += ResourceDoc( + null, implementedInApiVersion, nameOf(getRetailCustomerByCustomerId), "GET", + "/banks/BANK_ID/retail-customers/CUSTOMER_ID", "Get Retail Customer by Id", + """Get a retail customer by CUSTOMER_ID. Returns 404 if the customer + |is not of type INDIVIDUAL.""".stripMargin, + EmptyBody, EmptyBody, + List($AuthenticatedUserIsRequired, $BankNotFound, UserHasMissingRoles, CustomerTypeMismatch, UnknownError), + apiTagCustomer :: Nil, + Some(canGetCustomersAtOneBank :: Nil), + http4sPartialFunction = Some(getRetailCustomerByCustomerId) + ) + + // Route: GET /obp/v6.0.0/banks/BANK_ID/customers/CUSTOMER_ID/children + lazy val getCustomerChildren: HttpRoutes[IO] = HttpRoutes.of[IO] { + case req @ GET -> `prefixPath` / "banks" / _ / "customers" / customerId / "children" => + EndpointHelpers.withUserAndBank(req) { (_, bank, cc) => + for { + _ <- NewStyle.function.getCustomerByCustomerId(customerId, Some(cc)) + (children, _) <- NewStyle.function.getCustomersByParentCustomerId(bank.bankId, customerId, Some(cc)) + } yield JSONFactory600.createCustomersJson(children) + } + } + + resourceDocs += ResourceDoc( + null, implementedInApiVersion, nameOf(getCustomerChildren), "GET", + "/banks/BANK_ID/customers/CUSTOMER_ID/children", "Get Customer Children", + """Get the child customers for the specified customer.""", + EmptyBody, EmptyBody, + List($AuthenticatedUserIsRequired, $BankNotFound, UserHasMissingRoles, UnknownError), + apiTagCustomer :: Nil, + Some(canGetCustomersAtOneBank :: Nil), + http4sPartialFunction = Some(getCustomerChildren) + ) + + // Route: GET /obp/v6.0.0/banks/BANK_ID/customers/CUSTOMER_ID/customer-links + lazy val getCustomerLinksByCustomerId: HttpRoutes[IO] = HttpRoutes.of[IO] { + case req @ GET -> `prefixPath` / "banks" / _ / "customers" / customerId / "customer-links" => + EndpointHelpers.withUserAndBank(req) { (_, _, cc) => + for { + _ <- NewStyle.function.getCustomerByCustomerId(customerId, Some(cc)) + (links, _) <- NewStyle.function.getCustomerLinksByCustomerId(customerId, Some(cc)) + } yield JSONFactory600.createCustomerLinksJson(links) + } + } + + resourceDocs += ResourceDoc( + null, implementedInApiVersion, nameOf(getCustomerLinksByCustomerId), "GET", + "/banks/BANK_ID/customers/CUSTOMER_ID/customer-links", "Get Customer Links by Customer Id", + """Get all customer links involving the specified customer.""", + EmptyBody, EmptyBody, + List($AuthenticatedUserIsRequired, $BankNotFound, UserHasMissingRoles, UnknownError), + apiTagCustomer :: Nil, + Some(canGetCustomerLinks :: Nil), + http4sPartialFunction = Some(getCustomerLinksByCustomerId) + ) + + // ─── Phase 2: six 2-endpoint management/* buckets (9 of 12) ─────────── + // Deferred: executeAbacPolicy (large response-building chain), + // backupSystemDynamicEntity (private backupDynamicEntityMethod helper), + // deleteSystemDynamicEntityCascade (private deleteDynamicEntityCascadeMethod). + + // GET /obp/v6.0.0/management/system-views + lazy val getSystemViews: HttpRoutes[IO] = HttpRoutes.of[IO] { + case req @ GET -> `prefixPath` / "management" / "system-views" => + EndpointHelpers.withUser(req) { (_, _) => + Views.views.vend.getSystemViews().map(JSONFactory600.createViewsJsonV600) + } + } + resourceDocs += ResourceDoc( + null, implementedInApiVersion, nameOf(getSystemViews), "GET", + "/management/system-views", "Get System Views", + """Get all system views.""", + EmptyBody, EmptyBody, + List($AuthenticatedUserIsRequired, UserHasMissingRoles, UnknownError), + apiTagView :: Nil, Some(canGetSystemViews :: Nil), + http4sPartialFunction = Some(getSystemViews)) + + // GET /obp/v6.0.0/management/system-views/SYS_VIEW_ID (non-standard var so middleware skips view validation) + lazy val getSystemViewById: HttpRoutes[IO] = HttpRoutes.of[IO] { + case req @ GET -> `prefixPath` / "management" / "system-views" / viewIdStr => + EndpointHelpers.withUser(req) { (_, cc) => + ViewNewStyle.systemView(ViewId(viewIdStr), Some(cc)).map(JSONFactory600.createViewJsonV600) + } + } + resourceDocs += ResourceDoc( + null, implementedInApiVersion, nameOf(getSystemViewById), "GET", + "/management/system-views/SYS_VIEW_ID", "Get System View by Id", + """Get a system view by its VIEW_ID.""", + EmptyBody, EmptyBody, + List($AuthenticatedUserIsRequired, UserHasMissingRoles, UnknownError), + apiTagView :: Nil, Some(canGetSystemViews :: Nil), + http4sPartialFunction = Some(getSystemViewById)) + + // GET /obp/v6.0.0/management/abac-policies + lazy val getAbacPolicies: HttpRoutes[IO] = HttpRoutes.of[IO] { + case req @ GET -> `prefixPath` / "management" / "abac-policies" => + EndpointHelpers.withUser(req) { (_, _) => + Future { + val policies = Constant.ABAC_POLICIES.map { p => + AbacPolicyJsonV600(policy = p, + description = Constant.ABAC_POLICY_DESCRIPTIONS.getOrElse(p, "No description available")) + } + AbacPoliciesJsonV600(policies) + } + } + } + resourceDocs += ResourceDoc( + null, implementedInApiVersion, nameOf(getAbacPolicies), "GET", + "/management/abac-policies", "Get ABAC Policies", + """List all available ABAC policies.""", + EmptyBody, EmptyBody, + List($AuthenticatedUserIsRequired, UserHasMissingRoles, UnknownError), + apiTagABAC :: Nil, Some(canGetAbacRule :: Nil), + http4sPartialFunction = Some(getAbacPolicies)) + + // GET /obp/v6.0.0/management/connector/metrics/counts + lazy val getConnectorCallCounts: HttpRoutes[IO] = HttpRoutes.of[IO] { + case req @ GET -> `prefixPath` / "management" / "connector" / "metrics" / "counts" => + EndpointHelpers.withUser(req) { (_, _) => + Future { + val counts = code.metrics.ConnectorCountsRedis.getAllCounts() + ConnectorCountsJsonV600( + enabled = code.metrics.ConnectorCountsRedis.isEnabled, + connector_counts = counts.map(c => ConnectorCountJsonV600( + connector_name = c.connector_name, method_name = c.method_name, + per_hour_outbound_count = c.per_hour_outbound_count, + per_hour_inbound_success_count = c.per_hour_inbound_success_count, + per_hour_inbound_failure_count = c.per_hour_inbound_failure_count, + ttl_seconds = c.ttl_seconds))) + } + } + } + resourceDocs += ResourceDoc( + null, implementedInApiVersion, nameOf(getConnectorCallCounts), "GET", + "/management/connector/metrics/counts", "Get Connector Call Counts", + """Per-hour Redis counters for connector outbound and inbound messages.""", + EmptyBody, EmptyBody, + List($AuthenticatedUserIsRequired, UserHasMissingRoles, UnknownError), + apiTagMetric :: Nil, Some(canReadMetrics :: Nil), + http4sPartialFunction = Some(getConnectorCallCounts)) + + // GET /obp/v6.0.0/management/connector/traces + lazy val getConnectorTraces: HttpRoutes[IO] = HttpRoutes.of[IO] { + case req @ GET -> `prefixPath` / "management" / "connector" / "traces" => + EndpointHelpers.withUser(req) { (_, cc) => + for { + httpParams <- NewStyle.function.extractHttpParamsFromUrl(req.uri.renderString) + (obpQueryParams, _) <- createQueriesByHttpParamsFuture(httpParams, Some(cc)) + traces <- Future(code.metrics.ConnectorTraceProvider.getAllConnectorTraces(obpQueryParams)) + } yield JSONFactory600.createConnectorTracesJsonV600(traces) + } + } + resourceDocs += ResourceDoc( + null, implementedInApiVersion, nameOf(getConnectorTraces), "GET", + "/management/connector/traces", "Get Connector Traces", + """Get recent connector trace records (paginated).""", + EmptyBody, EmptyBody, + List($AuthenticatedUserIsRequired, UnknownError), + apiTagMetric :: Nil, None, + http4sPartialFunction = Some(getConnectorTraces)) + + // GET /obp/v6.0.0/management/diagnostics/dynamic-entities + lazy val getDynamicEntityDiagnostics: HttpRoutes[IO] = HttpRoutes.of[IO] { + case req @ GET -> `prefixPath` / "management" / "diagnostics" / "dynamic-entities" => + EndpointHelpers.withUser(req) { (_, _) => + Future { + val result = code.api.util.DiagnosticDynamicEntityCheck.checkAllDynamicEntities() + val issuesJson = result.issues.map(i => JSONFactory600.DynamicEntityIssueJsonV600( + entity_name = i.entityName, bank_id = i.bankId.getOrElse("SYSTEM_LEVEL"), + field_name = i.fieldName, example_value = i.exampleValue, error_message = i.errorMessage)) + val orphanedJson = result.orphanedEntities.map(o => + JSONFactory600.OrphanedDynamicEntityJsonV600(o.entityName, o.bankId, o.recordCount)) + JSONFactory600.DynamicEntityDiagnosticsJsonV600(result.scannedEntities, issuesJson, result.issues.length, orphanedJson) + } + } + } + resourceDocs += ResourceDoc( + null, implementedInApiVersion, nameOf(getDynamicEntityDiagnostics), "GET", + "/management/diagnostics/dynamic-entities", "Get Dynamic Entity Diagnostics", + """Scan all Dynamic Entities for issues + orphaned data records.""", + EmptyBody, EmptyBody, + List($AuthenticatedUserIsRequired, UserHasMissingRoles, UnknownError), + apiTagManageDynamicEntity :: Nil, + Some(canGetDynamicEntityDiagnostics :: Nil), + http4sPartialFunction = Some(getDynamicEntityDiagnostics)) + + // DELETE /obp/v6.0.0/management/diagnostics/dynamic-entities/orphaned-records + lazy val cleanupOrphanedDynamicEntityRecords: HttpRoutes[IO] = HttpRoutes.of[IO] { + case req @ DELETE -> `prefixPath` / "management" / "diagnostics" / "dynamic-entities" / "orphaned-records" => + EndpointHelpers.withUser(req) { (_, _) => + Future { + val definitions = code.dynamicEntity.DynamicEntityProvider.connectorMethodProvider.vend.getDynamicEntities(None, true) + val orphaned = code.api.util.DiagnosticDynamicEntityCheck.checkOrphanedRecords(definitions) + var totalDeleted: Long = 0 + orphaned.foreach { orphan => + val records = if (orphan.bankId.isEmpty) + DynamicData.findAll(By(DynamicData.DynamicEntityName, orphan.entityName), NullRef(DynamicData.BankId)) + else + DynamicData.findAll(By(DynamicData.DynamicEntityName, orphan.entityName), By(DynamicData.BankId, orphan.bankId)) + records.foreach { r => r.delete_!; totalDeleted += 1 } + } + val orphanedJson = orphaned.map(o => JSONFactory600.OrphanedDynamicEntityJsonV600(o.entityName, o.bankId, o.recordCount)) + JSONFactory600.CleanupOrphanedDynamicEntityResponseJsonV600(orphanedJson, totalDeleted) + } + } + } + resourceDocs += ResourceDoc( + null, implementedInApiVersion, nameOf(cleanupOrphanedDynamicEntityRecords), "DELETE", + "/management/diagnostics/dynamic-entities/orphaned-records", "Cleanup Orphaned Dynamic Entity Records", + """Delete orphaned dynamic-entity data records (rows whose entityName/bankId has no matching definition).""", + EmptyBody, EmptyBody, + List($AuthenticatedUserIsRequired, UserHasMissingRoles, UnknownError), + apiTagManageDynamicEntity :: Nil, + Some(canCleanupOrphanedDynamicEntityRecords :: Nil), + http4sPartialFunction = Some(cleanupOrphanedDynamicEntityRecords)) + + // PUT /obp/v6.0.0/management/webui_props/WEBUI_PROP_NAME + lazy val createOrUpdateWebUiProps: HttpRoutes[IO] = HttpRoutes.of[IO] { + case req @ PUT -> `prefixPath` / "management" / "webui_props" / webUiPropName => + implicit val cc: CallContext = req.callContext + implicit val formats: Formats = code.api.util.CustomJsonFormats.formats + val rawBody = cc.httpBody.getOrElse("") + val nameLower = webUiPropName.toLowerCase + val fut: Future[(WebUiPropsCommons, Boolean)] = for { + _ <- NewStyle.function.tryons(s"$InvalidWebUiProps name must start with webui_, but current name is: $nameLower", 400, Some(cc)) { + require(nameLower.startsWith("webui_")) + } + _ <- NewStyle.function.tryons(s"$InvalidWebUiProps name must contain only alphanumeric characters, underscore, and dot. Current name: $nameLower", 400, Some(cc)) { + require(nameLower.matches("^[a-zA-Z0-9_.]+$")) + } + _ <- NewStyle.function.tryons(s"$InvalidWebUiProps name must not exceed 255 characters. Current length: ${nameLower.length}", 400, Some(cc)) { + require(nameLower.length <= 255) + } + existing <- Future(MappedWebUiPropsProvider.getByName(nameLower)) + resourceExists = existing.isDefined + valueJson <- NewStyle.function.tryons(s"$InvalidJsonFormat The Json body should contain a value field", 400, Some(cc)) { + net.liftweb.json.parse(rawBody).extract[code.webuiprops.WebUiPropsPutJsonV600] + } + saved <- Future(MappedWebUiPropsProvider.createOrUpdate(WebUiPropsCommons(nameLower, valueJson.value))) + } yield (saved.openOrThrowException("Could not save WebUiProps"), resourceExists) + + RequestScopeConnection.fromFuture(fut).attempt.flatMap { + case Right((commons, existed)) => + val jsonString = prettyRender(Extraction.decompose(commons)) + if (existed) Ok(jsonString) else Created(jsonString) + case Left(err) => + ErrorResponseConverter.toHttp4sResponse(err, cc) + } + } + resourceDocs += ResourceDoc( + null, implementedInApiVersion, nameOf(createOrUpdateWebUiProps), "PUT", + "/management/webui_props/WEBUI_PROP_NAME", "Create or Update WebUiProps", + """Create or update a WebUiProps. Name is converted to lowercase, must start with `webui_`.""", + EmptyBody, EmptyBody, + List($AuthenticatedUserIsRequired, UserHasMissingRoles, InvalidWebUiProps, InvalidJsonFormat, UnknownError), + apiTagWebUiProps :: Nil, + Some(canCreateWebUiProps :: Nil), + http4sPartialFunction = Some(createOrUpdateWebUiProps)) + + // DELETE /obp/v6.0.0/management/webui_props/WEBUI_PROP_NAME + lazy val deleteWebUiProps: HttpRoutes[IO] = HttpRoutes.of[IO] { + case req @ DELETE -> `prefixPath` / "management" / "webui_props" / webUiPropName => + EndpointHelpers.executeDelete(req) { cc => + val nameLower = webUiPropName.toLowerCase + for { + _ <- NewStyle.function.tryons(s"$InvalidWebUiProps name must start with webui_, but current name is: $nameLower", 400, Some(cc)) { + require(nameLower.startsWith("webui_")) + } + _ <- NewStyle.function.tryons(s"$InvalidWebUiProps name must contain only alphanumeric characters, underscore, and dot. Current name: $nameLower", 400, Some(cc)) { + require(nameLower.matches("^[a-zA-Z0-9_.]+$")) + } + _ <- NewStyle.function.tryons(s"$InvalidWebUiProps name must not exceed 255 characters. Current length: ${nameLower.length}", 400, Some(cc)) { + require(nameLower.length <= 255) + } + existing <- Future(MappedWebUiPropsProvider.getByName(nameLower)) + _ <- existing match { + case Full(prop) => Future(MappedWebUiPropsProvider.delete(prop.webUiPropsId.getOrElse(""))) + case _ => Future.successful(Full(true)) + } + } yield () + } + } + resourceDocs += ResourceDoc( + null, implementedInApiVersion, nameOf(deleteWebUiProps), "DELETE", + "/management/webui_props/WEBUI_PROP_NAME", "Delete WebUiProps", + """Delete a WebUiProps by name. Idempotent — 204 even if it didn't exist.""", + EmptyBody, EmptyBody, + List($AuthenticatedUserIsRequired, UserHasMissingRoles, InvalidWebUiProps, UnknownError), + apiTagWebUiProps :: Nil, + Some(canDeleteWebUiProps :: Nil), + http4sPartialFunction = Some(deleteWebUiProps)) + + // ─── Phase 2: 3 small mixed buckets (5 endpoints) ───────────────────── + + // POST /obp/v6.0.0/management/banks/BANK_ID/accounts/ACCOUNT_ID/views (201) + lazy val createCustomViewManagement: HttpRoutes[IO] = HttpRoutes.of[IO] { + case req @ POST -> `prefixPath` / "management" / "banks" / bankIdStr / "accounts" / accountIdStr / "views" => + EndpointHelpers.executeFutureCreated(req) { + implicit val cc: CallContext = req.callContext + val rawBody = cc.httpBody.getOrElse("") + val bankId = BankId(bankIdStr) + val accountId = com.openbankproject.commons.model.AccountId(accountIdStr) + for { + createViewJson <- NewStyle.function.tryons(s"$InvalidJsonFormat The Json body should be the CreateViewJson", 400, Some(cc)) { + net.liftweb.json.parse(rawBody).extract[com.openbankproject.commons.model.CreateViewJson] + } + _ <- Helper.booleanToFuture(InvalidCustomViewFormat + s"Current view_name (${createViewJson.name})", cc = Some(cc)) { + APIUtil.isValidCustomViewName(createViewJson.name) + } + (_, _) <- NewStyle.function.getBankAccount(bankId, accountId, Some(cc)) + (view, _) <- ViewNewStyle.createCustomView(BankIdAccountId(bankId, accountId), createViewJson, Some(cc)) + } yield JSONFactory600.createViewJsonV600(view) + } + } + resourceDocs += ResourceDoc( + null, implementedInApiVersion, nameOf(createCustomViewManagement), "POST", + "/management/banks/BANK_ID/accounts/ACCOUNT_ID/views", "Create Custom View (Management)", + """Create a custom view for an account.""", + EmptyBody, EmptyBody, + List($AuthenticatedUserIsRequired, $BankNotFound, $BankAccountNotFound, UserHasMissingRoles, InvalidJsonFormat, InvalidCustomViewFormat, UnknownError), + apiTagView :: Nil, + Some(canCreateCustomView :: Nil), + http4sPartialFunction = Some(createCustomViewManagement)) + + // GET /obp/v6.0.0/banks/BANK_ID/products/PRODUCT_CODE/tags + lazy val getProductTagsV600: HttpRoutes[IO] = HttpRoutes.of[IO] { + case req @ GET -> `prefixPath` / "banks" / _ / "products" / productCodeStr / "tags" => + EndpointHelpers.withUserAndBank(req) { (_, bank, cc) => + val productCode = com.openbankproject.commons.model.ProductCode(productCodeStr) + for { + (_, _) <- NewStyle.function.getProduct(bank.bankId, productCode, Some(cc)) + tags = code.products.ProductTagsProvider.getTags(bank.bankId, productCode) + } yield JSONFactory600.createProductTagsJsonV600(tags) + } + } + resourceDocs += ResourceDoc( + null, implementedInApiVersion, nameOf(getProductTagsV600), "GET", + "/banks/BANK_ID/products/PRODUCT_CODE/tags", "Get Product Tags", + """Get all tags for the specified bank product.""", + EmptyBody, EmptyBody, + List($AuthenticatedUserIsRequired, $BankNotFound, UnknownError), + apiTagProduct :: Nil, None, + http4sPartialFunction = Some(getProductTagsV600)) + + // PUT /obp/v6.0.0/banks/BANK_ID/products/PRODUCT_CODE/tags + lazy val updateProductTagsV600: HttpRoutes[IO] = HttpRoutes.of[IO] { + case req @ PUT -> `prefixPath` / "banks" / _ / "products" / productCodeStr / "tags" => + EndpointHelpers.executeAndRespond(req) { implicit cc => + val rawBody = cc.httpBody.getOrElse("") + val bank = cc.bank.get + val productCode = com.openbankproject.commons.model.ProductCode(productCodeStr) + val updateProductTagsEntitlements = canUpdateProductTagsAtOneBank :: canUpdateProductTagsAtAnyBank :: Nil + val user = cc.user.openOrThrowException(AuthenticatedUserIsRequired) + for { + _ <- NewStyle.function.hasAtLeastOneEntitlement( + failMsg = UserHasMissingRoles + updateProductTagsEntitlements.mkString(" or "))( + bank.bankId.value, user.userId, updateProductTagsEntitlements, Some(cc)) + (_, _) <- NewStyle.function.getProduct(bank.bankId, productCode, Some(cc)) + body <- NewStyle.function.tryons(s"$InvalidJsonFormat The Json body should be the ProductTagsJsonV600", 400, Some(cc)) { + net.liftweb.json.parse(rawBody).extract[ProductTagsJsonV600] + } + updatedTags <- NewStyle.function.tryons(UpdateProductError, 400, Some(cc)) { + code.products.ProductTagsProvider.setTags(bank.bankId, productCode, body.tags) + .openOrThrowException(UpdateProductError) + } + } yield JSONFactory600.createProductTagsJsonV600(updatedTags) + } + } + resourceDocs += ResourceDoc( + null, implementedInApiVersion, nameOf(updateProductTagsV600), "PUT", + "/banks/BANK_ID/products/PRODUCT_CODE/tags", "Update Product Tags", + """Replace the tags on the specified bank product.""", + EmptyBody, EmptyBody, + List($AuthenticatedUserIsRequired, $BankNotFound, UserHasMissingRoles, InvalidJsonFormat, UpdateProductError, UnknownError), + apiTagProduct :: Nil, None, + http4sPartialFunction = Some(updateProductTagsV600)) + + // GET /obp/v6.0.0/oidc/clients/CLIENT_ID + lazy val getOidcClient: HttpRoutes[IO] = HttpRoutes.of[IO] { + case req @ GET -> `prefixPath` / "oidc" / "clients" / clientId => + EndpointHelpers.withUser(req) { (_, cc) => + for { + consumerBox <- Future(code.consumer.Consumers.consumers.vend.getConsumerByConsumerKey(clientId)) + consumer <- NewStyle.function.tryons(s"OBP-OIDC-003: Client not found: $clientId", 404, Some(cc)) { + consumerBox match { + case Full(c) => c + case _ => throw new RuntimeException("Client not found") + } + } + } yield { + val redirectUris = Option(consumer.redirectURL.get).filter(_.nonEmpty) + .map(_.split("[,\\s]+").map(_.trim).filter(_.nonEmpty).toList).getOrElse(List.empty) + GetOidcClientResponseJsonV600( + client_id = clientId, client_name = consumer.name.get, + consumer_id = consumer.consumerId.get, + redirect_uris = redirectUris, enabled = consumer.isActive.get) + } + } + } + resourceDocs += ResourceDoc( + null, implementedInApiVersion, nameOf(getOidcClient), "GET", + "/oidc/clients/CLIENT_ID", "Get OIDC Client", + """Get an OIDC/OAuth2 client's metadata by client_id.""", + EmptyBody, EmptyBody, + List($AuthenticatedUserIsRequired, UserHasMissingRoles, UnknownError), + apiTagOIDC :: apiTagConsumer :: apiTagOAuth :: Nil, + Some(canGetOidcClient :: Nil), + authMode = code.api.util.APIUtil.UserOrApplication, + http4sPartialFunction = Some(getOidcClient)) + + // POST /obp/v6.0.0/oidc/clients/verify + lazy val verifyOidcClient: HttpRoutes[IO] = HttpRoutes.of[IO] { + case req @ POST -> `prefixPath` / "oidc" / "clients" / "verify" => + EndpointHelpers.executeAndRespond(req) { implicit cc => + val rawBody = cc.httpBody.getOrElse("") + for { + postedData <- NewStyle.function.tryons(s"$InvalidJsonFormat The Json body should be the VerifyOidcClientRequestJsonV600", 400, Some(cc)) { + net.liftweb.json.parse(rawBody).extract[VerifyOidcClientRequestJsonV600] + } + consumerBox <- Future(code.consumer.Consumers.consumers.vend.getConsumerByConsumerKey(postedData.client_id)) + } yield { + consumerBox match { + case Full(consumer) if consumer.isActive.get && consumer.secret.get == postedData.client_secret => + val redirectUris = Option(consumer.redirectURL.get).filter(_.nonEmpty) + .map(_.split("[,\\s]+").map(_.trim).filter(_.nonEmpty).toList) + VerifyOidcClientResponseJsonV600( + valid = true, + client_id = Some(postedData.client_id), + consumer_id = Some(consumer.consumerId.get), + redirect_uris = redirectUris) + case _ => VerifyOidcClientResponseJsonV600(valid = false) + } + } + } + } + resourceDocs += ResourceDoc( + null, implementedInApiVersion, nameOf(verifyOidcClient), "POST", + "/oidc/clients/verify", "Verify OIDC Client", + """Verify an OIDC client_id + client_secret pair. Returns valid=true on a successful match.""", + EmptyBody, EmptyBody, + List($AuthenticatedUserIsRequired, UserHasMissingRoles, InvalidJsonFormat, UnknownError), + apiTagOIDC :: apiTagConsumer :: apiTagOAuth :: Nil, + Some(canVerifyOidcClient :: Nil), + authMode = code.api.util.APIUtil.UserOrApplication, + http4sPartialFunction = Some(verifyOidcClient)) + + // ─── Phase 2: users bucket (6 of 16; chat-room + special-purpose deferred) ─── + + // GET /obp/v6.0.0/users/USER_ID/attributes/USER_ATTRIBUTE_ID + lazy val getUserAttributeById: HttpRoutes[IO] = HttpRoutes.of[IO] { + case req @ GET -> `prefixPath` / "users" / userIdStr / "attributes" / userAttributeId => + EndpointHelpers.withUser(req) { (_, cc) => + for { + (user, callContext) <- NewStyle.function.getUserByUserId(userIdStr, Some(cc)) + (attributes, _) <- NewStyle.function.getNonPersonalUserAttributes(user.userId, callContext) + attribute <- Future(attributes.find(_.userAttributeId == userAttributeId)) + .map(unboxFullOrFail(_, Some(cc), UserAttributeNotFound, 404)) + } yield JSONFactory510.createUserAttributeJson(attribute) + } + } + resourceDocs += ResourceDoc( + null, implementedInApiVersion, nameOf(getUserAttributeById), "GET", + "/users/USER_ID/attributes/USER_ATTRIBUTE_ID", "Get User Attribute by Id", + """Get a user attribute by USER_ATTRIBUTE_ID for the specified user.""", + EmptyBody, EmptyBody, + List($AuthenticatedUserIsRequired, UserHasMissingRoles, UserAttributeNotFound, UnknownError), + apiTagUser :: apiTagUserAttribute :: Nil, + Some(canGetUserAttributes :: Nil), + http4sPartialFunction = Some(getUserAttributeById)) + + // POST /obp/v6.0.0/users/USER_ID/attributes (201) + lazy val createUserAttribute: HttpRoutes[IO] = HttpRoutes.of[IO] { + case req @ POST -> `prefixPath` / "users" / userIdStr / "attributes" => + EndpointHelpers.executeFutureCreated(req) { + implicit val cc: CallContext = req.callContext + val rawBody = cc.httpBody.getOrElse("") + for { + (user, _) <- NewStyle.function.getUserByUserId(userIdStr, Some(cc)) + postedData <- NewStyle.function.tryons(s"$InvalidJsonFormat The Json body should be the UserAttributeJsonV510", 400, Some(cc)) { + net.liftweb.json.parse(rawBody).extract[UserAttributeJsonV510] + } + userAttributeType <- NewStyle.function.tryons(personalDataTypeErrorMsg, 400, Some(cc)) { + UserAttributeType.withName(postedData.`type`) + } + (userAttribute, _) <- NewStyle.function.createOrUpdateUserAttribute( + user.userId, None, postedData.name, userAttributeType, postedData.value, false, Some(cc)) + } yield JSONFactory510.createUserAttributeJson(userAttribute) + } + } + resourceDocs += ResourceDoc( + null, implementedInApiVersion, nameOf(createUserAttribute), "POST", + "/users/USER_ID/attributes", "Create User Attribute", + """Create a non-personal user attribute for the specified user.""", + EmptyBody, EmptyBody, + List($AuthenticatedUserIsRequired, UserHasMissingRoles, InvalidJsonFormat, UnknownError), + apiTagUser :: apiTagUserAttribute :: Nil, + Some(canCreateUserAttribute :: Nil), + http4sPartialFunction = Some(createUserAttribute)) + + // PUT /obp/v6.0.0/users/USER_ID/attributes/USER_ATTRIBUTE_ID + lazy val updateUserAttribute: HttpRoutes[IO] = HttpRoutes.of[IO] { + case req @ PUT -> `prefixPath` / "users" / userIdStr / "attributes" / userAttributeId => + EndpointHelpers.executeAndRespond(req) { implicit cc => + val rawBody = cc.httpBody.getOrElse("") + for { + (user, callContext) <- NewStyle.function.getUserByUserId(userIdStr, Some(cc)) + postedData <- NewStyle.function.tryons(s"$InvalidJsonFormat The Json body should be the UserAttributeJsonV510", 400, Some(cc)) { + net.liftweb.json.parse(rawBody).extract[UserAttributeJsonV510] + } + userAttributeType <- NewStyle.function.tryons(personalDataTypeErrorMsg, 400, Some(cc)) { + UserAttributeType.withName(postedData.`type`) + } + (attributes, _) <- NewStyle.function.getNonPersonalUserAttributes(user.userId, callContext) + _ <- Future(attributes.find(_.userAttributeId == userAttributeId)) + .map(unboxFullOrFail(_, Some(cc), UserAttributeNotFound, 404)) + (userAttribute, _) <- NewStyle.function.createOrUpdateUserAttribute( + user.userId, Some(userAttributeId), postedData.name, userAttributeType, postedData.value, false, Some(cc)) + } yield JSONFactory510.createUserAttributeJson(userAttribute) + } + } + resourceDocs += ResourceDoc( + null, implementedInApiVersion, nameOf(updateUserAttribute), "PUT", + "/users/USER_ID/attributes/USER_ATTRIBUTE_ID", "Update User Attribute", + """Update a user attribute by USER_ATTRIBUTE_ID.""", + EmptyBody, EmptyBody, + List($AuthenticatedUserIsRequired, UserHasMissingRoles, InvalidJsonFormat, UserAttributeNotFound, UnknownError), + apiTagUser :: apiTagUserAttribute :: Nil, + Some(canUpdateUserAttribute :: Nil), + http4sPartialFunction = Some(updateUserAttribute)) + + // DELETE /obp/v6.0.0/users/USER_ID/attributes/USER_ATTRIBUTE_ID + lazy val deleteUserAttribute: HttpRoutes[IO] = HttpRoutes.of[IO] { + case req @ DELETE -> `prefixPath` / "users" / userIdStr / "attributes" / userAttributeId => + EndpointHelpers.executeAndRespond(req) { implicit cc => + for { + (user, callContext) <- NewStyle.function.getUserByUserId(userIdStr, Some(cc)) + (attributes, _) <- NewStyle.function.getNonPersonalUserAttributes(user.userId, callContext) + _ <- Future(attributes.find(_.userAttributeId == userAttributeId)) + .map(unboxFullOrFail(_, Some(cc), UserAttributeNotFound, 404)) + _ <- BankConnector.connector.vend.deleteUserAttribute(userAttributeId, Some(cc)) + } yield "" + } + } + resourceDocs += ResourceDoc( + null, implementedInApiVersion, nameOf(deleteUserAttribute), "DELETE", + "/users/USER_ID/attributes/USER_ATTRIBUTE_ID", "Delete User Attribute", + """Delete a user attribute by USER_ATTRIBUTE_ID.""", + EmptyBody, EmptyBody, + List($AuthenticatedUserIsRequired, UserHasMissingRoles, UserAttributeNotFound, UnknownError), + apiTagUser :: apiTagUserAttribute :: Nil, + Some(canDeleteUserAttribute :: Nil), + http4sPartialFunction = Some(deleteUserAttribute)) + + // POST /obp/v6.0.0/users/USER_ID/group-entitlements (201) + lazy val addUserToGroup: HttpRoutes[IO] = HttpRoutes.of[IO] { + case req @ POST -> `prefixPath` / "users" / userIdStr / "group-entitlements" => + EndpointHelpers.executeFutureCreated(req) { + implicit val cc: CallContext = req.callContext + val rawBody = cc.httpBody.getOrElse("") + val user = cc.user.openOrThrowException(AuthenticatedUserIsRequired) + for { + postJson <- NewStyle.function.tryons(s"$InvalidJsonFormat The Json body should be the PostGroupMembershipJsonV600", 400, Some(cc)) { + net.liftweb.json.parse(rawBody).extract[JSONFactory600.PostGroupMembershipJsonV600] + } + _ <- NewStyle.function.findByUserId(userIdStr, Some(cc)) + group <- Future(code.group.GroupTrait.group.vend.getGroup(postJson.group_id)) + .map(unboxFullOrFail(_, Some(cc), s"$UnknownError Group not found", 404)) + _ <- groupRoleCheck(group.bankId, user.userId, canAddUserToGroupAtOneBank, canAddUserToGroupAtAllBanks, cc) + _ <- Helper.booleanToFuture(s"$UnknownError Group is not enabled", 400, Some(cc))(group.isEnabled) + existingEntitlements <- Future(Entitlement.entitlement.vend.getEntitlementsByUserId(userIdStr)) + entitlementResults <- Future.sequence(group.listOfRoles.map { roleName => + Future { + val alreadyHas = existingEntitlements.toOption.exists(_.exists { ent => + ent.roleName == roleName && ent.bankId == group.bankId.getOrElse("") + }) + if (!alreadyHas) { + Entitlement.entitlement.vend.addEntitlement( + group.bankId.getOrElse(""), userIdStr, roleName, "manual", + None, Some(postJson.group_id), Some("GROUP_MEMBERSHIP")) + (roleName, true) + } else (roleName, false) + } + }) + entitlementsAdded = entitlementResults.filter(_._2).map(_._1) + entitlementsAlreadyPresent = entitlementResults.filterNot(_._2).map(_._1) + } yield JSONFactory600.AddUserToGroupResponseJsonV600( + group_id = group.groupId, user_id = userIdStr, bank_id = group.bankId, + group_name = group.groupName, target_entitlements = group.listOfRoles, + entitlements_created = entitlementsAdded, + entitlements_skipped = entitlementsAlreadyPresent) + } + } + resourceDocs += ResourceDoc( + null, implementedInApiVersion, nameOf(addUserToGroup), "POST", + "/users/USER_ID/group-entitlements", "Add User to Group", + """Add a user to a group (grants the group's entitlements to the user).""", + EmptyBody, EmptyBody, + List($AuthenticatedUserIsRequired, UserHasMissingRoles, InvalidJsonFormat, UnknownError), + apiTagGroup :: apiTagUser :: Nil, None, + http4sPartialFunction = Some(addUserToGroup)) + + // DELETE /obp/v6.0.0/users/USER_ID/group-entitlements/GROUP_ID + lazy val removeUserFromGroup: HttpRoutes[IO] = HttpRoutes.of[IO] { + case req @ DELETE -> `prefixPath` / "users" / userIdStr / "group-entitlements" / groupId => + EndpointHelpers.executeAndRespond(req) { implicit cc => + val user = cc.user.openOrThrowException(AuthenticatedUserIsRequired) + for { + _ <- NewStyle.function.findByUserId(userIdStr, Some(cc)) + group <- Future(code.group.GroupTrait.group.vend.getGroup(groupId)) + .map(unboxFullOrFail(_, Some(cc), s"$UnknownError Group not found", 404)) + _ <- groupRoleCheck(group.bankId, user.userId, canRemoveUserFromGroupAtOneBank, canRemoveUserFromGroupAtAllBanks, cc) + entitlements <- Future(Entitlement.entitlement.vend.getEntitlementsByUserId(userIdStr)) + groupEntitlements = entitlements.toOption.getOrElse(List.empty).filter(e => + e.groupId == Some(groupId) && e.process == Some("GROUP_MEMBERSHIP")) + _ <- Future.sequence(groupEntitlements.map(e => + Future(Entitlement.entitlement.vend.deleteEntitlement(Full(e))))) + } yield "" + } + } + resourceDocs += ResourceDoc( + null, implementedInApiVersion, nameOf(removeUserFromGroup), "DELETE", + "/users/USER_ID/group-entitlements/GROUP_ID", "Remove User from Group", + """Remove a user from a group (deletes all entitlements that came from the group).""", + EmptyBody, EmptyBody, + List($AuthenticatedUserIsRequired, UserHasMissingRoles, UnknownError), + apiTagGroup :: apiTagUser :: Nil, None, + http4sPartialFunction = Some(removeUserFromGroup)) + + // ─── Phase 2: 4 more single-endpoint buckets ────────────────────────── + + // DELETE /obp/v6.0.0/entitlements/ENTITLEMENT_ID + lazy val deleteEntitlement: HttpRoutes[IO] = HttpRoutes.of[IO] { + case req @ DELETE -> `prefixPath` / "entitlements" / entitlementId => + EndpointHelpers.executeAndRespond(req) { implicit cc => + for { + entitlementBox <- Future(Entitlement.entitlement.vend.getEntitlementById(entitlementId)) + _ <- entitlementBox match { + case Full(ent) => Future(Entitlement.entitlement.vend.deleteEntitlement(Some(ent))) + case _ => Future.successful(Full(())) + } + } yield "" + } + } + resourceDocs += ResourceDoc( + null, implementedInApiVersion, nameOf(deleteEntitlement), "DELETE", + "/entitlements/ENTITLEMENT_ID", "Delete Entitlement", + """Delete an entitlement by ENTITLEMENT_ID. Idempotent.""", + EmptyBody, EmptyBody, + List($AuthenticatedUserIsRequired, UserHasMissingRoles, UnknownError), + apiTagEntitlement :: Nil, + Some(canDeleteEntitlementAtAnyBank :: Nil), + http4sPartialFunction = Some(deleteEntitlement)) + + // GET /obp/v6.0.0/personal-dynamic-entities/available + lazy val getAvailablePersonalDynamicEntities: HttpRoutes[IO] = HttpRoutes.of[IO] { + case req @ GET -> `prefixPath` / "personal-dynamic-entities" / "available" => + EndpointHelpers.withUser(req) { (_, _) => + Future(NewStyle.function.getDynamicEntities(None, true)) + .map(all => JSONFactory600.createMyDynamicEntitiesJson(all.filter(_.hasPersonalEntity))) + } + } + resourceDocs += ResourceDoc( + null, implementedInApiVersion, nameOf(getAvailablePersonalDynamicEntities), "GET", + "/personal-dynamic-entities/available", "Get Available Personal Dynamic Entities", + """List Dynamic Entities that support personal data storage.""", + EmptyBody, EmptyBody, + List($AuthenticatedUserIsRequired, UnknownError), + apiTagManageDynamicEntity :: apiTagApi :: Nil, None, + http4sPartialFunction = Some(getAvailablePersonalDynamicEntities)) + + // GET /obp/v6.0.0/management/dynamic-entities/reference-types + lazy val getReferenceTypes: HttpRoutes[IO] = HttpRoutes.of[IO] { + case req @ GET -> `prefixPath` / "management" / "dynamic-entities" / "reference-types" => + EndpointHelpers.withUser(req) { (_, _) => + Future { + val referenceTypeNames = code.dynamicEntity.ReferenceType.referenceTypeNames + val dynamicEntityNames = NewStyle.function.getDynamicEntities(None, true) + .map(e => s"reference:${e.entityName}").toSet + val exampleId1 = APIUtil.generateUUID() + val exampleId2 = APIUtil.generateUUID() + val exampleId3 = APIUtil.generateUUID() + val exampleId4 = APIUtil.generateUUID() + val reg1 = """reference:([^:]+)""".r + val reg2 = """reference:(?:[^:]+):([^&]+)&([^&]+)""".r + val reg3 = """reference:(?:[^:]+):([^&]+)&([^&]+)&([^&]+)""".r + val reg4 = """reference:(?:[^:]+):([^&]+)&([^&]+)&([^&]+)&([^&]+)""".r + val referenceTypes = referenceTypeNames.map { refTypeName => + val example = refTypeName match { + case reg1(entityName) => + val description = if (dynamicEntityNames.contains(refTypeName)) + s"Reference to $entityName (dynamic entity)" + else s"Reference to $entityName entity" + (exampleId1, description) + case reg2(a, b) => + (s"$a=$exampleId1&$b=$exampleId2", s"Composite reference with $a and $b") + case reg3(a, b, c) => + (s"$a=$exampleId1&$b=$exampleId2&$c=$exampleId3", s"Composite reference with $a, $b and $c") + case reg4(a, b, c, d) => + (s"$a=$exampleId1&$b=$exampleId2&$c=$exampleId3&$d=$exampleId4", s"Composite reference with $a, $b, $c and $d") + case _ => (exampleId1, "Reference type") + } + JSONFactory600.ReferenceTypeJsonV600( + type_name = refTypeName, + example_value = example._1, + description = example._2) + } + JSONFactory600.ReferenceTypesJsonV600(referenceTypes) + } + } + } + resourceDocs += ResourceDoc( + null, implementedInApiVersion, nameOf(getReferenceTypes), "GET", + "/management/dynamic-entities/reference-types", "Get Reference Types for Dynamic Entities", + """List all reference types available for use in Dynamic Entity field definitions.""", + EmptyBody, EmptyBody, + List($AuthenticatedUserIsRequired, UserHasMissingRoles, UnknownError), + apiTagManageDynamicEntity :: Nil, + Some(canGetDynamicEntityReferenceTypes :: Nil), + http4sPartialFunction = Some(getReferenceTypes)) + + // POST /obp/v6.0.0/chat-room-participants (201) — join a system chat room by joining_key + lazy val joinSystemChatRoom: HttpRoutes[IO] = HttpRoutes.of[IO] { + case req @ POST -> `prefixPath` / "chat-room-participants" => + EndpointHelpers.executeFutureCreated(req) { + implicit val cc: CallContext = req.callContext + val rawBody = cc.httpBody.getOrElse("") + val user = cc.user.openOrThrowException(AuthenticatedUserIsRequired) + for { + joiningKey <- Future( + (net.liftweb.json.parse(rawBody) \ "joining_key").extractOpt[String].getOrElse("")) + room <- Future(code.chat.ChatRoomTrait.chatRoomProvider.vend.getChatRoomByJoiningKey(joiningKey)) + .map(unboxFullOrFail(_, Some(cc), InvalidJoiningKey, 404)) + _ <- Helper.booleanToFuture(ChatRoomIsArchived, cc = Some(cc))(!room.isArchived) + existing <- Future(code.chat.ChatPermissions.isParticipant(room.chatRoomId, user.userId)) + _ <- Helper.booleanToFuture(ChatRoomParticipantAlreadyExists, failCode = 409, cc = Some(cc))(existing.isEmpty) + participant <- Future(code.chat.ParticipantTrait.participantProvider.vend + .addParticipant(room.chatRoomId, user.userId, "", List.empty, "")) + .map(unboxFullOrFail(_, Some(cc), s"$UnknownError Cannot join chat room", 400)) + } yield JSONFactory600.createParticipantJson(participant) + } + } + resourceDocs += ResourceDoc( + null, implementedInApiVersion, nameOf(joinSystemChatRoom), "POST", + "/chat-room-participants", "Join Chat Room", + """Join a chat room by providing its joining_key.""", + EmptyBody, EmptyBody, + List($AuthenticatedUserIsRequired, InvalidJoiningKey, ChatRoomIsArchived, ChatRoomParticipantAlreadyExists, UnknownError), + apiTagChat :: Nil, None, + http4sPartialFunction = Some(joinSystemChatRoom)) + + // ─── Phase 2: 6 banks/.../accounts subset (counterparty attrs + hasAccountAccess) ─── + + private val counterpartyAttributeTypeErrorMsg = + s"$InvalidJsonFormat The `Type` field can only accept the following field: " + + s"${com.openbankproject.commons.model.enums.CounterpartyAttributeType.DOUBLE}(12.1234), " + + s"${com.openbankproject.commons.model.enums.CounterpartyAttributeType.STRING}(TAX_NUMBER), " + + s"${com.openbankproject.commons.model.enums.CounterpartyAttributeType.INTEGER}(123) and " + + s"${com.openbankproject.commons.model.enums.CounterpartyAttributeType.DATE_WITH_DAY}(2012-04-23)" + + // POST /obp/v6.0.0/banks/BANK_ID/accounts/ACCOUNT_ID/counterparties/COUNTERPARTY_ID/attributes (201) + lazy val createCounterpartyAttribute: HttpRoutes[IO] = HttpRoutes.of[IO] { + case req @ POST -> `prefixPath` / "banks" / _ / "accounts" / _ / "counterparties" / counterpartyId / "attributes" => + EndpointHelpers.executeFutureCreated(req) { + implicit val cc: CallContext = req.callContext + val rawBody = cc.httpBody.getOrElse("") + for { + postedData <- NewStyle.function.tryons(s"$InvalidJsonFormat The Json body should be the CounterpartyAttributeRequestJsonV600", 400, Some(cc)) { + net.liftweb.json.parse(rawBody).extract[CounterpartyAttributeRequestJsonV600] + } + counterpartyAttributeType <- NewStyle.function.tryons(counterpartyAttributeTypeErrorMsg, 400, Some(cc)) { + com.openbankproject.commons.model.enums.CounterpartyAttributeType.withName(postedData.attribute_type) + } + (attribute, _) <- code.api.util.newstyle.CounterpartyAttributeNewStyle.createOrUpdateCounterpartyAttribute( + counterpartyId = com.openbankproject.commons.model.CounterpartyId(counterpartyId), + counterpartyAttributeId = None, + name = postedData.name, + attributeType = counterpartyAttributeType, + value = postedData.value, + isActive = postedData.is_active, + callContext = Some(cc)) + } yield JSONFactory600.createCounterpartyAttributeJson(attribute) + } + } + resourceDocs += ResourceDoc( + null, implementedInApiVersion, nameOf(createCounterpartyAttribute), "POST", + "/banks/BANK_ID/accounts/ACCOUNT_ID/counterparties/COUNTERPARTY_ID_PARAM/attributes", "Create Counterparty Attribute", + """Create a new attribute on the specified counterparty.""", + EmptyBody, EmptyBody, + List($AuthenticatedUserIsRequired, UserHasMissingRoles, InvalidJsonFormat, UnknownError), + apiTagCounterparty :: Nil, + Some(canCreateCounterpartyAttribute :: Nil), + http4sPartialFunction = Some(createCounterpartyAttribute)) + + // DELETE /obp/v6.0.0/banks/BANK_ID/accounts/ACCOUNT_ID/counterparties/COUNTERPARTY_ID/attributes/COUNTERPARTY_ATTRIBUTE_ID + lazy val deleteCounterpartyAttribute: HttpRoutes[IO] = HttpRoutes.of[IO] { + case req @ DELETE -> `prefixPath` / "banks" / _ / "accounts" / _ / "counterparties" / _ / "attributes" / attributeId => + EndpointHelpers.executeDelete(req) { cc => + code.api.util.newstyle.CounterpartyAttributeNewStyle.deleteCounterpartyAttribute(attributeId, Some(cc)) + } + } + resourceDocs += ResourceDoc( + null, implementedInApiVersion, nameOf(deleteCounterpartyAttribute), "DELETE", + "/banks/BANK_ID/accounts/ACCOUNT_ID/counterparties/COUNTERPARTY_ID_PARAM/attributes/COUNTERPARTY_ATTRIBUTE_ID", + "Delete Counterparty Attribute", + """Delete a counterparty attribute.""", + EmptyBody, EmptyBody, + List($AuthenticatedUserIsRequired, UserHasMissingRoles, UnknownError), + apiTagCounterparty :: Nil, + Some(canDeleteCounterpartyAttribute :: Nil), + http4sPartialFunction = Some(deleteCounterpartyAttribute)) + + // GET /obp/v6.0.0/banks/BANK_ID/accounts/ACCOUNT_ID/counterparties/COUNTERPARTY_ID/attributes/COUNTERPARTY_ATTRIBUTE_ID + lazy val getCounterpartyAttributeById: HttpRoutes[IO] = HttpRoutes.of[IO] { + case req @ GET -> `prefixPath` / "banks" / _ / "accounts" / _ / "counterparties" / _ / "attributes" / attributeId => + EndpointHelpers.withUser(req) { (_, cc) => + for { + (attribute, _) <- code.api.util.newstyle.CounterpartyAttributeNewStyle.getCounterpartyAttributeById(attributeId, Some(cc)) + } yield JSONFactory600.createCounterpartyAttributeJson(attribute) + } + } + resourceDocs += ResourceDoc( + null, implementedInApiVersion, nameOf(getCounterpartyAttributeById), "GET", + "/banks/BANK_ID/accounts/ACCOUNT_ID/counterparties/COUNTERPARTY_ID_PARAM/attributes/COUNTERPARTY_ATTRIBUTE_ID", + "Get Counterparty Attribute By Id", + """Get a counterparty attribute by its ID.""", + EmptyBody, EmptyBody, + List($AuthenticatedUserIsRequired, UserHasMissingRoles, UnknownError), + apiTagCounterparty :: Nil, + Some(canGetCounterpartyAttribute :: Nil), + http4sPartialFunction = Some(getCounterpartyAttributeById)) + + // GET /obp/v6.0.0/banks/BANK_ID/accounts/ACCOUNT_ID/counterparties/COUNTERPARTY_ID/attributes + lazy val getAllCounterpartyAttributes: HttpRoutes[IO] = HttpRoutes.of[IO] { + case req @ GET -> `prefixPath` / "banks" / _ / "accounts" / _ / "counterparties" / counterpartyId / "attributes" => + EndpointHelpers.withUser(req) { (_, cc) => + for { + (attributes, _) <- code.api.util.newstyle.CounterpartyAttributeNewStyle.getCounterpartyAttributes( + com.openbankproject.commons.model.CounterpartyId(counterpartyId), Some(cc)) + } yield JSONFactory600.createCounterpartyAttributesJson(attributes) + } + } + resourceDocs += ResourceDoc( + null, implementedInApiVersion, nameOf(getAllCounterpartyAttributes), "GET", + "/banks/BANK_ID/accounts/ACCOUNT_ID/counterparties/COUNTERPARTY_ID_PARAM/attributes", + "Get All Counterparty Attributes", + """Get all attributes for the specified counterparty.""", + EmptyBody, EmptyBody, + List($AuthenticatedUserIsRequired, UserHasMissingRoles, UnknownError), + apiTagCounterparty :: Nil, + Some(canGetCounterpartyAttributes :: Nil), + http4sPartialFunction = Some(getAllCounterpartyAttributes)) + + // PUT /obp/v6.0.0/banks/BANK_ID/accounts/ACCOUNT_ID/counterparties/COUNTERPARTY_ID/attributes/COUNTERPARTY_ATTRIBUTE_ID + lazy val updateCounterpartyAttribute: HttpRoutes[IO] = HttpRoutes.of[IO] { + case req @ PUT -> `prefixPath` / "banks" / _ / "accounts" / _ / "counterparties" / counterpartyId / "attributes" / attributeId => + EndpointHelpers.executeAndRespond(req) { implicit cc => + val rawBody = cc.httpBody.getOrElse("") + for { + postedData <- NewStyle.function.tryons(s"$InvalidJsonFormat The Json body should be the CounterpartyAttributeRequestJsonV600", 400, Some(cc)) { + net.liftweb.json.parse(rawBody).extract[CounterpartyAttributeRequestJsonV600] + } + counterpartyAttributeType <- NewStyle.function.tryons(counterpartyAttributeTypeErrorMsg, 400, Some(cc)) { + com.openbankproject.commons.model.enums.CounterpartyAttributeType.withName(postedData.attribute_type) + } + (updated, _) <- code.api.util.newstyle.CounterpartyAttributeNewStyle.createOrUpdateCounterpartyAttribute( + counterpartyId = com.openbankproject.commons.model.CounterpartyId(counterpartyId), + counterpartyAttributeId = Some(attributeId), + name = postedData.name, + attributeType = counterpartyAttributeType, + value = postedData.value, + isActive = postedData.is_active, + callContext = Some(cc)) + } yield JSONFactory600.createCounterpartyAttributeJson(updated) + } + } + resourceDocs += ResourceDoc( + null, implementedInApiVersion, nameOf(updateCounterpartyAttribute), "PUT", + "/banks/BANK_ID/accounts/ACCOUNT_ID/counterparties/COUNTERPARTY_ID_PARAM/attributes/COUNTERPARTY_ATTRIBUTE_ID", + "Update Counterparty Attribute", + """Update a counterparty attribute by its ID.""", + EmptyBody, EmptyBody, + List($AuthenticatedUserIsRequired, UserHasMissingRoles, InvalidJsonFormat, UnknownError), + apiTagCounterparty :: Nil, + Some(canUpdateCounterpartyAttribute :: Nil), + http4sPartialFunction = Some(updateCounterpartyAttribute)) + + // GET /obp/v6.0.0/banks/BANK_ID/accounts/ACCOUNT_ID/views/VIEW_ID/has-account-access + lazy val hasAccountAccess: HttpRoutes[IO] = HttpRoutes.of[IO] { + case req @ GET -> `prefixPath` / "banks" / bankIdStr / "accounts" / accountIdStr / "views" / viewIdStr / "has-account-access" => + EndpointHelpers.withUser(req) { (user, cc) => + val bankId = BankId(bankIdStr) + val accountId = com.openbankproject.commons.model.AccountId(accountIdStr) + val viewId = ViewId(viewIdStr) + val bia = BankIdAccountId(bankId, accountId) + for { + (_, _) <- NewStyle.function.getBank(bankId, Some(cc)) + _ <- Future { + Views.views.vend.customViewFuture(viewId, bia).flatMap { + case Full(v) => Future.successful(Full(v)) + case _ => Views.views.vend.systemViewFuture(viewId) + } + }.flatten.map(unboxFullOrFail(_, Some(cc), s"$ViewNotFound Current ViewId is ${viewId.value}")) + accessOpt <- Future(code.views.system.AccountAccess.findByBankIdAccountIdViewIdUserPrimaryKey( + bankId, accountId, viewId, user.userPrimaryKey)) + } yield accessOpt match { + case Full(aa) => JSONFactory600.HasAccountAccessJsonV600( + has_account_access = true, + access_source = "ACCOUNT_ACCESS", + account_access_id = aa.id.get.toString, + abac_rule_id = "") + case _ => JSONFactory600.HasAccountAccessJsonV600( + has_account_access = false, access_source = "", + account_access_id = "", abac_rule_id = "") + } + } + } + resourceDocs += ResourceDoc( + null, implementedInApiVersion, nameOf(hasAccountAccess), "GET", + "/banks/BANK_ID/accounts/ACCOUNT_ID/views/VIEW_ID/has-account-access", "Has Account Access", + """Check whether the caller has account access via this view.""", + EmptyBody, EmptyBody, + List($AuthenticatedUserIsRequired, $BankNotFound, ViewNotFound, UnknownError), + apiTagAccount :: Nil, None, + http4sPartialFunction = Some(hasAccountAccess)) + + // GET /obp/v6.0.0/my/account-access-requests + lazy val getMyAccountAccessRequests: HttpRoutes[IO] = HttpRoutes.of[IO] { + case req @ GET -> `prefixPath` / "my" / "account-access-requests" => + EndpointHelpers.withUser(req) { (user, cc) => + for { + requests <- Future(code.accountaccessrequest.AccountAccessRequestTrait + .accountAccessRequest.vend.getByRequestorUserId(user.userId)) + .map(unboxFullOrFail(_, Some(cc), s"$UnknownError Cannot get account access requests", 400)) + } yield JSONFactory600.createAccountAccessRequestsJsonV600(requests) + } + } + resourceDocs += ResourceDoc( + null, implementedInApiVersion, nameOf(getMyAccountAccessRequests), "GET", + "/my/account-access-requests", "Get My Account Access Requests", + """List account-access requests submitted by the logged-in user.""", + EmptyBody, EmptyBody, + List($AuthenticatedUserIsRequired, UnknownError), + apiTagAccountAccess :: Nil, None, + http4sPartialFunction = Some(getMyAccountAccessRequests)) + + // ─── Phase 2: 3 anonymous/UserOrApplication endpoints ───────────────── + + // GET /obp/v6.0.0/webui-props/WEBUI_PROP_NAME + lazy val getWebUiProp: HttpRoutes[IO] = HttpRoutes.of[IO] { + case req @ GET -> `prefixPath` / "webui-props" / webUiPropName => + EndpointHelpers.executeAndRespond(req) { implicit cc => + val active = req.uri.query.params.getOrElse("active", "false") + for { + isActived <- NewStyle.function.tryons( + s"$InvalidFilterParameterFormat `active` must be a boolean, but current `active` value is: $active", + 400, Some(cc)) { + active.toBoolean + } + explicitWebUiProps <- Future(MappedWebUiPropsProvider.getAll()) + explicitProp = explicitWebUiProps.find(_.name == webUiPropName) + result <- explicitProp match { + case Some(prop) => + Future.successful( + WebUiPropsCommons(prop.name, prop.value, prop.webUiPropsId, source = Some("database"))) + case None if isActived => + val implicitProps = APIUtil.getWebUIPropsPairs.map { case (k, v) => + WebUiPropsCommons(k, v, webUiPropsId = Some("default"), source = Some("config")) + } + implicitProps.find(_.name == webUiPropName) match { + case Some(prop) => Future.successful(prop) + case None => Future(unboxFullOrFail[WebUiPropsCommons]( + Empty, Some(cc), + s"$WebUiPropsNotFoundByName Current WEBUI_PROP_NAME($webUiPropName)", 400)) + } + case None => + Future(unboxFullOrFail[WebUiPropsCommons]( + Empty, Some(cc), + s"$WebUiPropsNotFoundByName Current WEBUI_PROP_NAME($webUiPropName)", 400)) + } + } yield result + } + } + resourceDocs += ResourceDoc( + null, implementedInApiVersion, nameOf(getWebUiProp), "GET", + "/webui-props/WEBUI_PROP_NAME", "Get WebUiProp by Name", + """Get a single WebUiProp by name. Anonymous endpoint.""", + EmptyBody, EmptyBody, + List(WebUiPropsNotFoundByName, InvalidFilterParameterFormat, UnknownError), + apiTagWebUiProps :: Nil, None, + http4sPartialFunction = Some(getWebUiProp)) + + // GET /obp/v6.0.0/message-docs/CONNECTOR/json-schema + lazy val getMessageDocsJsonSchema: HttpRoutes[IO] = HttpRoutes.of[IO] { + case req @ GET -> `prefixPath` / "message-docs" / connector / "json-schema" => + EndpointHelpers.executeAndRespond(req) { implicit cc => + val cacheKey = s"message-docs-json-schema-$connector" + val cacheValueFromRedis = code.api.cache.Caching.getStaticSwaggerDocCache(cacheKey) + for { + jsonSchema <- if (cacheValueFromRedis.isDefined) { + NewStyle.function.tryons(s"$UnknownError Cannot parse cached JSON Schema.", 400, Some(cc)) { + net.liftweb.json.parse(cacheValueFromRedis.get).asInstanceOf[net.liftweb.json.JObject] + } + } else { + NewStyle.function.tryons(s"$UnknownError Cannot generate JSON Schema.", 400, Some(cc)) { + val connectorObjectBox = net.liftweb.util.Helpers.tryo { BankConnector.getConnectorInstance(connector) } + val connectorObject = unboxFullOrFail( + connectorObjectBox, Some(cc), + s"$InvalidConnector Current input is: $connector. Valid connectors include: rabbitmq_vOct2024, rest_vMar2019, akka_vDec2018" + ) + val schema = code.api.util.JsonSchemaGenerator.messageDocsToJsonSchema( + connectorObject.messageDocs.toList, connector) + val schemaString = net.liftweb.json.compactRender(schema) + code.api.cache.Caching.setStaticSwaggerDocCache(cacheKey, schemaString) + schema + } + } + } yield jsonSchema + } + } + resourceDocs += ResourceDoc( + null, implementedInApiVersion, nameOf(getMessageDocsJsonSchema), "GET", + "/message-docs/CONNECTOR/json-schema", "Get Message Docs as JSON Schema", + """Returns the message-docs for a connector as a JSON Schema. Anonymous endpoint.""", + EmptyBody, EmptyBody, + List(InvalidConnector, UnknownError), + apiTagMessageDoc :: apiTagDocumentation :: apiTagApi :: Nil, None, + http4sPartialFunction = Some(getMessageDocsJsonSchema)) + + // POST /obp/v6.0.0/users/verify-credentials + lazy val verifyUserCredentials: HttpRoutes[IO] = HttpRoutes.of[IO] { + case req @ POST -> `prefixPath` / "users" / "verify-credentials" => + EndpointHelpers.executeAndRespond(req) { implicit cc => + val rawBody = cc.httpBody.getOrElse("") + for { + postedData <- NewStyle.function.tryons( + s"$InvalidJsonFormat The Json body should be the PostVerifyUserCredentialsJsonV600", 400, Some(cc)) { + net.liftweb.json.parse(rawBody).extract[PostVerifyUserCredentialsJsonV600] + } + decodedProvider = java.net.URLDecoder.decode(postedData.provider, java.nio.charset.StandardCharsets.UTF_8) + resourceUserIdBox = code.model.dataAccess.AuthUser.getResourceUserId( + postedData.username, postedData.password, decodedProvider) + _ <- Helper.booleanToFuture(UsernameHasBeenLocked, 401, Some(cc)) { + resourceUserIdBox != Full(code.model.dataAccess.AuthUser.usernameLockedStateCode) + } + _ <- Helper.booleanToFuture(UserEmailNotValidated, 401, Some(cc)) { + resourceUserIdBox != Full(code.model.dataAccess.AuthUser.userEmailNotValidatedStateCode) + } + resourceUserId <- Future(resourceUserIdBox).map( + unboxFullOrFail(_, Some(cc), s"$InvalidLoginCredentials Failed to authenticate user credentials.", 401)) + user <- Future(code.users.Users.users.vend.getUserByResourceUserId(resourceUserId)).map( + unboxFullOrFail(_, Some(cc), s"$InvalidLoginCredentials User account not found in system.", 401)) + _ <- Helper.booleanToFuture(s"$InvalidLoginCredentials Authentication provider mismatch.", 401, Some(cc)) { + decodedProvider.isEmpty || user.provider == decodedProvider + } + } yield JSONFactory200.createUserJSON(user) + } + } + resourceDocs += ResourceDoc( + null, implementedInApiVersion, nameOf(verifyUserCredentials), "POST", + "/users/verify-credentials", "Verify User Credentials", + """Verify a user's credentials (username, password, provider) and return user information if valid.""", + EmptyBody, EmptyBody, + List(UserHasMissingRoles, InvalidJsonFormat, InvalidLoginCredentials, UsernameHasBeenLocked, UnknownError), + apiTagUser :: Nil, + Some(canVerifyUserCredentials :: Nil), + authMode = code.api.util.APIUtil.UserOrApplication, + http4sPartialFunction = Some(verifyUserCredentials)) + + // GET /obp/v6.0.0/management/view-permissions + lazy val getViewPermissions: HttpRoutes[IO] = HttpRoutes.of[IO] { + case req @ GET -> `prefixPath` / "management" / "view-permissions" => + EndpointHelpers.withUser(req) { (_, cc) => + Future { + def categorize(permission: String): String = permission match { + case p if p.contains("transaction") && !p.contains("request") => "Transaction" + case p if p.contains("bank_account") || p.contains("bank_routing") || p.contains("available_funds") => "Account" + case p if p.contains("other_account") || p.contains("other_bank") || + p.contains("counterparty") || p.contains("more_info") || + p.contains("url") || p.contains("corporates") || + p.contains("location") || p.contains("alias") => "Counterparty" + case p if p.contains("comment") || p.contains("tag") || + p.contains("image") || p.contains("where_tag") => "Metadata" + case p if p.contains("transaction_request") || p.contains("direct_debit") || + p.contains("standing_order") => "Transaction Request" + case p if p.contains("view") => "View" + case p if p.contains("grant") || p.contains("revoke") => "Access Control" + case _ => "Other" + } + val permissions = ALL_VIEW_PERMISSION_NAMES.map { p => + JSONFactory600.ViewPermissionJsonV600(p, categorize(p)) + }.sortBy(p => (p.category, p.permission)) + JSONFactory600.ViewPermissionsJsonV600(permissions) + } + } + } + resourceDocs += ResourceDoc( + null, implementedInApiVersion, nameOf(getViewPermissions), "GET", + "/management/view-permissions", "Get View Permissions", + """Get a list of all available view permissions, organised by category.""", + EmptyBody, EmptyBody, + List($AuthenticatedUserIsRequired, UserHasMissingRoles, UnknownError), + apiTagSystemView :: apiTagView :: Nil, + Some(canGetViewPermissionsAtAllBanks :: Nil), + http4sPartialFunction = Some(getViewPermissions)) + + // GET /obp/v6.0.0/api-products (all banks; auth-required; cached) + lazy val getAllApiProductsV600: HttpRoutes[IO] = HttpRoutes.of[IO] { + case req @ GET -> `prefixPath` / "api-products" => + EndpointHelpers.withUser(req) { (_, cc) => + val tagFilter = req.uri.query.params.get("tag").map(_.trim).filter(_.nonEmpty) + val cacheKey = s"all:${tagFilter.getOrElse("")}" + val cacheTTL = APIUtil.getPropsAsIntValue("getAllApiProductsV600.cache.ttl.seconds", 5) + val hit = code.api.cache.Caching.getApiProductsCache(cacheKey, cacheTTL) + .flatMap(s => try Some(net.liftweb.json.parse(s).extract[ApiProductsJsonV600]) + catch { case _: Throwable => None }) + hit match { + case Some(cached) => Future.successful(cached) + case None => + for { + (banks, _) <- NewStyle.function.getBanks(Some(cc)) + perBank <- Future.sequence( + banks.map(b => NewStyle.function.getApiProductsByBankId(b.bankId.value, tagFilter, Some(cc)).map(_._1))) + apiProducts = perBank.flatten + } yield { + val result = JSONFactory600.createApiProductsJsonV600(apiProducts) + code.api.cache.Caching.setApiProductsCache( + cacheKey, net.liftweb.json.compactRender(Extraction.decompose(result)), cacheTTL) + result + } + } + } + } + resourceDocs += ResourceDoc( + null, implementedInApiVersion, nameOf(getAllApiProductsV600), "GET", + "/api-products", "Get Api Products At All Banks", + """Returns the Api Products across every bank, merged into a single list. Each product carries its bank_id.""", + EmptyBody, EmptyBody, + List($AuthenticatedUserIsRequired, UnknownError), + apiTagApi :: apiTagApiProduct :: Nil, None, + http4sPartialFunction = Some(getAllApiProductsV600)) + + // GET /obp/v6.0.0/products (all banks; auth-required; cached) + lazy val getAllProductsV600: HttpRoutes[IO] = HttpRoutes.of[IO] { + case req @ GET -> `prefixPath` / "products" => + EndpointHelpers.withUser(req) { (_, cc) => + val params = req.uri.query.multiParams.toList.map { case (k, vs) => GetProductsParam(k, vs.toList) } + val cacheKey = APIMethods600.productsCacheKey("__all__", params) + val cacheTTL = APIUtil.getPropsAsIntValue("getAllProductsV600.cache.ttl.seconds", 60) + val hit = code.api.cache.Caching.getFinancialProductsCache(cacheKey, cacheTTL) + .flatMap(s => try Some(net.liftweb.json.parse(s).extract[ProductsJsonV600]) + catch { case _: Throwable => None }) + hit match { + case Some(cached) => Future.successful(cached) + case None => + for { + (banks, _) <- NewStyle.function.getBanks(Some(cc)) + perBank <- Future.sequence( + banks.map(b => NewStyle.function.getProducts(b.bankId, params, Some(cc)).map(_._1))) + products = perBank.flatten + } yield { + val result = JSONFactory600.createProductsJsonV600(products, Map.empty) + code.api.cache.Caching.setFinancialProductsCache( + cacheKey, net.liftweb.json.compactRender(Extraction.decompose(result)), cacheTTL) + result + } + } + } + } + resourceDocs += ResourceDoc( + null, implementedInApiVersion, nameOf(getAllProductsV600), "GET", + "/products", "Get Products At All Banks", + """Returns the financial Products offered by every bank merged into a single list. Each product carries its bank_id.""", + EmptyBody, EmptyBody, + List($AuthenticatedUserIsRequired, UnknownError), + apiTagProduct :: Nil, None, + http4sPartialFunction = Some(getAllProductsV600)) + + // ─── Phase 2: account-access-requests + holding-accounts (3 endpoints) ─ + + // GET /obp/v6.0.0/banks/BANK_ID/accounts/ACCOUNT_ID/account-access-requests + lazy val getAccountAccessRequestsForAccount: HttpRoutes[IO] = HttpRoutes.of[IO] { + case req @ GET -> `prefixPath` / "banks" / bankIdStr / "accounts" / accountIdStr / "account-access-requests" => + EndpointHelpers.withBankAccount(req) { (_, _, cc) => + val status = req.uri.query.params.get("status") + for { + requestsBox <- Future { + status match { + case Some(s) => + code.accountaccessrequest.AccountAccessRequestTrait.accountAccessRequest.vend + .getByAccountAndStatus(bankIdStr, accountIdStr, s) + case _ => + code.accountaccessrequest.AccountAccessRequestTrait.accountAccessRequest.vend + .getByAccount(bankIdStr, accountIdStr) + } + } + requests <- Future(unboxFullOrFail(requestsBox, Some(cc), + s"$UnknownError Cannot get account access requests", 400)) + } yield JSONFactory600.createAccountAccessRequestsJsonV600(requests) + } + } + resourceDocs += ResourceDoc( + null, implementedInApiVersion, nameOf(getAccountAccessRequestsForAccount), "GET", + "/banks/BANK_ID/accounts/ACCOUNT_ID/account-access-requests", + "Get Account Access Requests for Account", + """Get all Account Access Requests on the specified account. Optional `status` query param filters by status.""", + EmptyBody, EmptyBody, + List($AuthenticatedUserIsRequired, UserHasMissingRoles, $BankNotFound, $BankAccountNotFound, UnknownError), + apiTagAccountAccess :: Nil, + Some(canGetAccountAccessRequestsAtOneBank :: canGetAccountAccessRequestsAtAnyBank :: Nil), + http4sPartialFunction = Some(getAccountAccessRequestsForAccount)) + + // GET /obp/v6.0.0/banks/BANK_ID/accounts/ACCOUNT_ID/account-access-requests/ACCOUNT_ACCESS_REQUEST_ID + lazy val getAccountAccessRequestById: HttpRoutes[IO] = HttpRoutes.of[IO] { + case req @ GET -> `prefixPath` / "banks" / bankIdStr / "accounts" / accountIdStr / "account-access-requests" / requestId => + EndpointHelpers.withBankAccount(req) { (_, _, cc) => + for { + requestBox <- Future { + code.accountaccessrequest.AccountAccessRequestTrait.accountAccessRequest.vend.getById(requestId) + } + request <- Future(unboxFullOrFail(requestBox, Some(cc), AccountAccessRequestNotFound, 404)) + _ <- Helper.booleanToFuture(AccountAccessRequestNotFound, cc = Some(cc)) { + request.bankId == bankIdStr && request.accountId == accountIdStr + } + } yield JSONFactory600.createAccountAccessRequestJsonV600(request) + } + } + resourceDocs += ResourceDoc( + null, implementedInApiVersion, nameOf(getAccountAccessRequestById), "GET", + "/banks/BANK_ID/accounts/ACCOUNT_ID/account-access-requests/ACCOUNT_ACCESS_REQUEST_ID", + "Get Account Access Request by Id", + """Get a single Account Access Request by its ID.""", + EmptyBody, EmptyBody, + List($AuthenticatedUserIsRequired, UserHasMissingRoles, $BankNotFound, $BankAccountNotFound, AccountAccessRequestNotFound, UnknownError), + apiTagAccountAccess :: Nil, + Some(canGetAccountAccessRequestsAtOneBank :: canGetAccountAccessRequestsAtAnyBank :: Nil), + http4sPartialFunction = Some(getAccountAccessRequestById)) + + // GET /obp/v6.0.0/banks/BANK_ID/accounts/ACCOUNT_ID/VIEW_ID/holding-accounts + lazy val getHoldingAccountByReleaser: HttpRoutes[IO] = HttpRoutes.of[IO] { + case req @ GET -> `prefixPath` / "banks" / bankIdStr / "accounts" / accountIdStr / viewIdStr / "holding-accounts" => + EndpointHelpers.withView(req) { (user, _, view, cc) => + val bankId = BankId(bankIdStr) + val accountId = com.openbankproject.commons.model.AccountId(accountIdStr) + for { + (accountIdsBox, _) <- AccountAttributeX.accountAttributeProvider.vend + .getAccountIdsByParams(bankId, Map("RELEASER_ACCOUNT_ID" -> List(accountId.value))) + .map(b => (b, Some(cc))) + accountIds = accountIdsBox.getOrElse(Nil) + holdingOpt <- { + def firstHolding(ids: List[String]): Future[Option[com.openbankproject.commons.model.BankAccount]] = ids match { + case Nil => Future.successful(None) + case id :: tail => + NewStyle.function.getBankAccount(bankId, com.openbankproject.commons.model.AccountId(id), Some(cc)).flatMap { case (acc, _) => + if (acc.accountType == "HOLDING") Future.successful(Some(acc)) else firstHolding(tail) + } + } + firstHolding(accountIds) + } + holding <- NewStyle.function.tryons($BankAccountNotFound, 404, Some(cc)) { holdingOpt.get } + moderatedAccount <- Future { + holding.moderatedBankAccount(view, + com.openbankproject.commons.model.BankIdAccountId(holding.bankId, holding.accountId), + Full(user), Some(cc)) + }.map(unboxFullOrFail(_, Some(cc), UnknownError)) + (attributes, _) <- NewStyle.function.getAccountAttributesByAccount(bankId, holding.accountId, Some(cc)) + } yield JSONFactory300.createFirehoseCoreBankAccountJSON(List(moderatedAccount), Some(attributes)) + } + } + resourceDocs += ResourceDoc( + null, implementedInApiVersion, nameOf(getHoldingAccountByReleaser), "GET", + "/banks/BANK_ID/accounts/ACCOUNT_ID/VIEW_ID/holding-accounts", + "Get Holding Accounts By Releaser", + """Return the first Holding Account linked to the given releaser account via account attribute RELEASER_ACCOUNT_ID.""", + EmptyBody, EmptyBody, + List($AuthenticatedUserIsRequired, $BankNotFound, $BankAccountNotFound, $UserNoPermissionAccessView, UnknownError), + apiTagAccount :: Nil, None, + http4sPartialFunction = Some(getHoldingAccountByReleaser)) + + // ─── Phase 2: account-access-request lifecycle (3 endpoints) ───────── + + // POST /obp/v6.0.0/banks/BANK_ID/accounts/ACCOUNT_ID/account-access-requests (201) + lazy val createAccountAccessRequest: HttpRoutes[IO] = HttpRoutes.of[IO] { + case req @ POST -> `prefixPath` / "banks" / bankIdStr / "accounts" / accountIdStr / "account-access-requests" => + EndpointHelpers.executeFutureCreated(req) { + implicit val cc: CallContext = req.callContext + val rawBody = cc.httpBody.getOrElse("") + val bankId = BankId(bankIdStr) + val accountId = com.openbankproject.commons.model.AccountId(accountIdStr) + val u = cc.user.openOrThrowException("User not found in CallContext") + for { + postJson <- NewStyle.function.tryons( + s"$InvalidJsonFormat The Json body should be the PostAccountAccessRequestJsonV600", 400, Some(cc)) { + net.liftweb.json.parse(rawBody).extract[JSONFactory600.PostAccountAccessRequestJsonV600] + } + _ <- Helper.booleanToFuture(BusinessJustificationRequired, cc = Some(cc)) { + postJson.business_justification.trim.nonEmpty + } + (_, _) <- NewStyle.function.findByUserId(postJson.target_user_id, Some(cc)) + _ <- Helper.booleanToFuture(AccountAccessRequestAlreadyExists, 409, Some(cc)) { + code.accountaccessrequest.AccountAccessRequestTrait.accountAccessRequest.vend + .getByUserAccountView(postJson.target_user_id, bankIdStr, accountIdStr, postJson.view_id) + .isEmpty + } + _ <- if (postJson.is_system_view) { + ViewNewStyle.systemView(ViewId(postJson.view_id), Some(cc)).map(_ => ()) + } else { + ViewNewStyle.customView(ViewId(postJson.view_id), + com.openbankproject.commons.model.BankIdAccountId(bankId, accountId), Some(cc)).map(_ => ()) + } + requestBox <- Future { + code.accountaccessrequest.AccountAccessRequestTrait.accountAccessRequest.vend.createAccountAccessRequest( + bankIdStr, accountIdStr, postJson.view_id, postJson.is_system_view, + u.userId, postJson.target_user_id, postJson.business_justification) + } + request <- Future(unboxFullOrFail(requestBox, Some(cc), AccountAccessRequestCannotBeCreated, 400)) + } yield JSONFactory600.createAccountAccessRequestJsonV600(request) + } + } + resourceDocs += ResourceDoc( + null, implementedInApiVersion, nameOf(createAccountAccessRequest), "POST", + "/banks/BANK_ID/accounts/ACCOUNT_ID/account-access-requests", + "Create Account Access Request", + """Create a new Account Access Request (maker step in maker/checker workflow).""", + EmptyBody, EmptyBody, + List($AuthenticatedUserIsRequired, UserHasMissingRoles, InvalidJsonFormat, + $BankNotFound, $BankAccountNotFound, BusinessJustificationRequired, + AccountAccessRequestAlreadyExists, AccountAccessRequestCannotBeCreated, UnknownError), + apiTagAccountAccess :: Nil, + Some(canCreateAccountAccessRequestAtOneBank :: canCreateAccountAccessRequestAtAnyBank :: Nil), + http4sPartialFunction = Some(createAccountAccessRequest)) + + // POST /obp/v6.0.0/banks/BANK_ID/accounts/ACCOUNT_ID/account-access-requests/.../approval (201) + lazy val approveAccountAccessRequest: HttpRoutes[IO] = HttpRoutes.of[IO] { + case req @ POST -> `prefixPath` / "banks" / bankIdStr / "accounts" / accountIdStr / "account-access-requests" / requestIdStr / "approval" => + EndpointHelpers.executeFutureCreated(req) { + implicit val cc: CallContext = req.callContext + val rawBody = cc.httpBody.getOrElse("") + val bankId = BankId(bankIdStr) + val accountId = com.openbankproject.commons.model.AccountId(accountIdStr) + val u = cc.user.openOrThrowException("User not found in CallContext") + for { + postJson <- NewStyle.function.tryons( + s"$InvalidJsonFormat The Json body should be the PostApproveAccountAccessRequestJsonV600", 400, Some(cc)) { + net.liftweb.json.parse(rawBody).extract[JSONFactory600.PostApproveAccountAccessRequestJsonV600] + } + requestBox <- Future { + code.accountaccessrequest.AccountAccessRequestTrait.accountAccessRequest.vend.getById(requestIdStr) + } + request <- Future(unboxFullOrFail(requestBox, Some(cc), AccountAccessRequestNotFound, 404)) + _ <- Helper.booleanToFuture(AccountAccessRequestNotFound, cc = Some(cc)) { + request.bankId == bankIdStr && request.accountId == accountIdStr + } + _ <- Helper.booleanToFuture(AccountAccessRequestStatusNotInitiated, cc = Some(cc)) { + request.status == com.openbankproject.commons.model.enums.AccountAccessRequestStatus.INITIATED.toString + } + _ <- Helper.booleanToFuture(MakerCheckerSameUser, cc = Some(cc)) { + u.userId != request.requestorUserId + } + (targetUser, _) <- NewStyle.function.findByUserId(request.targetUserId, Some(cc)) + _ <- if (request.isSystemView) { + ViewNewStyle.systemView(ViewId(request.viewId), Some(cc)).flatMap { view => + ViewNewStyle.grantAccessToSystemView(bankId, accountId, view, targetUser, Some(cc)) + } + } else { + ViewNewStyle.customView(ViewId(request.viewId), + com.openbankproject.commons.model.BankIdAccountId(bankId, accountId), Some(cc)).flatMap { view => + ViewNewStyle.grantAccessToCustomView(view, targetUser, Some(cc)) + } + } + updatedBox <- Future { + code.accountaccessrequest.AccountAccessRequestTrait.accountAccessRequest.vend.updateStatus( + requestIdStr, + com.openbankproject.commons.model.enums.AccountAccessRequestStatus.APPROVED.toString, + u.userId, + postJson.comment.getOrElse("")) + } + updated <- Future(unboxFullOrFail(updatedBox, Some(cc), AccountAccessRequestCannotBeUpdated, 400)) + } yield JSONFactory600.createAccountAccessRequestJsonV600(updated) + } + } + resourceDocs += ResourceDoc( + null, implementedInApiVersion, nameOf(approveAccountAccessRequest), "POST", + "/banks/BANK_ID/accounts/ACCOUNT_ID/account-access-requests/ACCOUNT_ACCESS_REQUEST_ID/approval", + "Approve Account Access Request", + """Approve an Account Access Request (checker step in maker/checker workflow).""", + EmptyBody, EmptyBody, + List($AuthenticatedUserIsRequired, UserHasMissingRoles, InvalidJsonFormat, + $BankNotFound, $BankAccountNotFound, AccountAccessRequestNotFound, + AccountAccessRequestStatusNotInitiated, MakerCheckerSameUser, + AccountAccessRequestCannotBeUpdated, UnknownError), + apiTagAccountAccess :: Nil, + Some(canUpdateAccountAccessRequestAtOneBank :: canUpdateAccountAccessRequestAtAnyBank :: Nil), + http4sPartialFunction = Some(approveAccountAccessRequest)) + + // POST /obp/v6.0.0/banks/BANK_ID/accounts/ACCOUNT_ID/account-access-requests/.../rejection (201) + lazy val rejectAccountAccessRequest: HttpRoutes[IO] = HttpRoutes.of[IO] { + case req @ POST -> `prefixPath` / "banks" / bankIdStr / "accounts" / accountIdStr / "account-access-requests" / requestIdStr / "rejection" => + EndpointHelpers.executeFutureCreated(req) { + implicit val cc: CallContext = req.callContext + val rawBody = cc.httpBody.getOrElse("") + val u = cc.user.openOrThrowException("User not found in CallContext") + for { + postJson <- NewStyle.function.tryons( + s"$InvalidJsonFormat The Json body should be the PostRejectAccountAccessRequestJsonV600", 400, Some(cc)) { + net.liftweb.json.parse(rawBody).extract[JSONFactory600.PostRejectAccountAccessRequestJsonV600] + } + _ <- Helper.booleanToFuture(CheckerCommentRequiredForRejection, cc = Some(cc)) { + postJson.comment.trim.nonEmpty + } + requestBox <- Future { + code.accountaccessrequest.AccountAccessRequestTrait.accountAccessRequest.vend.getById(requestIdStr) + } + request <- Future(unboxFullOrFail(requestBox, Some(cc), AccountAccessRequestNotFound, 404)) + _ <- Helper.booleanToFuture(AccountAccessRequestNotFound, cc = Some(cc)) { + request.bankId == bankIdStr && request.accountId == accountIdStr + } + _ <- Helper.booleanToFuture(AccountAccessRequestStatusNotInitiated, cc = Some(cc)) { + request.status == com.openbankproject.commons.model.enums.AccountAccessRequestStatus.INITIATED.toString + } + _ <- Helper.booleanToFuture(MakerCheckerSameUser, cc = Some(cc)) { + u.userId != request.requestorUserId + } + updatedBox <- Future { + code.accountaccessrequest.AccountAccessRequestTrait.accountAccessRequest.vend.updateStatus( + requestIdStr, + com.openbankproject.commons.model.enums.AccountAccessRequestStatus.REJECTED.toString, + u.userId, postJson.comment) + } + updated <- Future(unboxFullOrFail(updatedBox, Some(cc), AccountAccessRequestCannotBeUpdated, 400)) + } yield JSONFactory600.createAccountAccessRequestJsonV600(updated) + } + } + resourceDocs += ResourceDoc( + null, implementedInApiVersion, nameOf(rejectAccountAccessRequest), "POST", + "/banks/BANK_ID/accounts/ACCOUNT_ID/account-access-requests/ACCOUNT_ACCESS_REQUEST_ID/rejection", + "Reject Account Access Request", + """Reject an Account Access Request (checker step in maker/checker workflow).""", + EmptyBody, EmptyBody, + List($AuthenticatedUserIsRequired, UserHasMissingRoles, InvalidJsonFormat, + $BankNotFound, $BankAccountNotFound, AccountAccessRequestNotFound, + AccountAccessRequestStatusNotInitiated, MakerCheckerSameUser, + CheckerCommentRequiredForRejection, AccountAccessRequestCannotBeUpdated, UnknownError), + apiTagAccountAccess :: Nil, + Some(canUpdateAccountAccessRequestAtOneBank :: canUpdateAccountAccessRequestAtAnyBank :: Nil), + http4sPartialFunction = Some(rejectAccountAccessRequest)) + + // ─── Phase 2: Signal bucket (6 endpoints) ──────────────────────────── + + // GET /obp/v6.0.0/signal/channels + lazy val getSignalChannels: HttpRoutes[IO] = HttpRoutes.of[IO] { + case req @ GET -> `prefixPath` / "signal" / "channels" => + EndpointHelpers.withUser(req) { (_, cc) => + Future { + val names = code.api.cache.RedisMessaging.listChannels() + val infos = names.flatMap { name => + code.api.cache.RedisMessaging.channelInfo(name).map { case (count, ttl) => + val (messages, _) = code.api.cache.RedisMessaging.fetchMessages(name, 0, count.toInt) + val hasBroadcast = messages.exists { s => + scala.util.Try(net.liftweb.json.parse(s).extract[SignalMessageJsonV600].to_user_id.isEmpty).getOrElse(false) + } + (name, count, ttl, hasBroadcast) + } + } + val channels = infos.filter(_._4).map { case (name, count, ttl, _) => + SignalChannelInfoJsonV600(name, count, ttl) + } + SignalChannelsJsonV600(channels) + } + } + } + resourceDocs += ResourceDoc( + null, implementedInApiVersion, nameOf(getSignalChannels), "GET", + "/signal/channels", "List Signal Channels", + """List active signal channels with broadcast messages.""", + EmptyBody, EmptyBody, + List($AuthenticatedUserIsRequired, UnknownError), + apiTagAiAgent :: apiTagSignal :: apiTagSignalling :: apiTagChannel :: Nil, None, + http4sPartialFunction = Some(getSignalChannels)) + + // GET /obp/v6.0.0/signal/channels/CHANNEL_NAME/info + lazy val getSignalChannelInfo: HttpRoutes[IO] = HttpRoutes.of[IO] { + case req @ GET -> `prefixPath` / "signal" / "channels" / channelName / "info" => + EndpointHelpers.withUser(req) { (_, cc) => + for { + _ <- Helper.booleanToFuture(InvalidSignalChannelName, cc = Some(cc)) { + code.api.cache.RedisMessaging.validateChannelName(channelName) + } + info <- Future(code.api.cache.RedisMessaging.channelInfo(channelName)) + (count, ttl) <- info match { + case Some((c, t)) => Future.successful((c, t)) + case None => Future.failed(new RuntimeException(s"Channel '$channelName' not found")) + } + } yield SignalChannelInfoJsonV600(channelName, count, ttl) + } + } + resourceDocs += ResourceDoc( + null, implementedInApiVersion, nameOf(getSignalChannelInfo), "GET", + "/signal/channels/CHANNEL_NAME/info", "Get Signal Channel Info", + """Get metadata for a signal channel (message count + remaining TTL).""", + EmptyBody, EmptyBody, + List($AuthenticatedUserIsRequired, InvalidSignalChannelName, UnknownError), + apiTagAiAgent :: apiTagSignal :: apiTagSignalling :: apiTagChannel :: Nil, None, + http4sPartialFunction = Some(getSignalChannelInfo)) + + // GET /obp/v6.0.0/signal/channels/stats + lazy val getSignalStats: HttpRoutes[IO] = HttpRoutes.of[IO] { + case req @ GET -> `prefixPath` / "signal" / "channels" / "stats" => + EndpointHelpers.withUser(req) { (_, cc) => + Future { + val names = code.api.cache.RedisMessaging.listChannels() + val channels = names.flatMap { name => + code.api.cache.RedisMessaging.channelInfo(name).map { case (count, ttl) => + SignalChannelInfoJsonV600(name, count, ttl) + } + } + SignalStatsJsonV600( + total_channels = channels.size, + total_messages = channels.map(_.message_count).sum, + channels = channels) + } + } + } + resourceDocs += ResourceDoc( + null, implementedInApiVersion, nameOf(getSignalStats), "GET", + "/signal/channels/stats", "Get Signal Channel Stats", + """Stats for all signal channels including private-only.""", + EmptyBody, EmptyBody, + List($AuthenticatedUserIsRequired, UserHasMissingRoles, UnknownError), + apiTagAiAgent :: apiTagSignal :: apiTagSignalling :: apiTagChannel :: Nil, + Some(canGetSignalStats :: Nil), + http4sPartialFunction = Some(getSignalStats)) + + // POST /obp/v6.0.0/signal/channels/CHANNEL_NAME/messages (201) + lazy val publishSignalMessage: HttpRoutes[IO] = HttpRoutes.of[IO] { + case req @ POST -> `prefixPath` / "signal" / "channels" / channelName / "messages" => + EndpointHelpers.executeFutureCreated(req) { + implicit val cc: CallContext = req.callContext + val rawBody = cc.httpBody.getOrElse("") + val u = cc.user.openOrThrowException("User not found in CallContext") + for { + postJson <- NewStyle.function.tryons( + s"$InvalidJsonFormat The Json body should be the PostSignalMessageJsonV600", 400, Some(cc)) { + net.liftweb.json.parse(rawBody).extract[PostSignalMessageJsonV600] + } + _ <- Helper.booleanToFuture(InvalidSignalChannelName, cc = Some(cc)) { + code.api.cache.RedisMessaging.validateChannelName(channelName) + } + published <- Future { + val consumerId = cc.consumer match { case Full(c) => c.consumerId.get; case _ => "" } + val messageId = randomUUID().toString + val sdf = new java.text.SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss'Z'") + sdf.setTimeZone(java.util.TimeZone.getTimeZone("UTC")) + val timestamp = sdf.format(new java.util.Date()) + val envelope = SignalMessageJsonV600( + message_id = messageId, channel_name = channelName, + sender_consumer_id = consumerId, sender_user_id = u.userId, + to_user_id = postJson.to_user_id, timestamp = timestamp, + message_type = postJson.message_type.getOrElse(""), + payload = postJson.payload) + val msgStr = net.liftweb.json.compactRender(Extraction.decompose(envelope)) + val count = code.api.cache.RedisMessaging.publishMessage(channelName, msgStr) + SignalMessagePublishedJsonV600(messageId, channelName, timestamp, count) + } + } yield published + } + } + resourceDocs += ResourceDoc( + null, implementedInApiVersion, nameOf(publishSignalMessage), "POST", + "/signal/channels/CHANNEL_NAME/messages", "Publish Signal Message", + """Publish a message to a signal channel.""", + EmptyBody, EmptyBody, + List($AuthenticatedUserIsRequired, InvalidSignalChannelName, InvalidJsonFormat, UnknownError), + apiTagAiAgent :: apiTagSignal :: apiTagSignalling :: apiTagChannel :: Nil, None, + http4sPartialFunction = Some(publishSignalMessage)) + + // GET /obp/v6.0.0/signal/channels/CHANNEL_NAME/messages + lazy val getSignalMessages: HttpRoutes[IO] = HttpRoutes.of[IO] { + case req @ GET -> `prefixPath` / "signal" / "channels" / channelName / "messages" => + EndpointHelpers.withUser(req) { (user, cc) => + for { + _ <- Helper.booleanToFuture(InvalidSignalChannelName, cc = Some(cc)) { + code.api.cache.RedisMessaging.validateChannelName(channelName) + } + httpParams = req.headers.headers.toList.map(h => + net.liftweb.http.provider.HTTPParam(h.name.toString, h.value)) + (obpQueryParams, _) <- createQueriesByHttpParamsFuture(httpParams, Some(cc)) + limit = obpQueryParams.collectFirst { case code.api.util.OBPLimit(value) => value }.getOrElse(50) + offset = obpQueryParams.collectFirst { case code.api.util.OBPOffset(value) => value }.getOrElse(0) + (rawMessages, totalCount) <- Future(code.api.cache.RedisMessaging.fetchMessages(channelName, offset, limit)) + } yield { + val parsed = rawMessages.flatMap { s => + scala.util.Try(net.liftweb.json.parse(s).extract[SignalMessageJsonV600]).toOption + } + val filtered = parsed.filter { msg => + msg.to_user_id.isEmpty || + msg.to_user_id.contains(user.userId) || + msg.sender_user_id == user.userId + } + SignalMessagesJsonV600(channelName, filtered, totalCount, (offset + limit) < totalCount) + } + } + } + resourceDocs += ResourceDoc( + null, implementedInApiVersion, nameOf(getSignalMessages), "GET", + "/signal/channels/CHANNEL_NAME/messages", "Get Signal Messages", + """Fetch messages from a signal channel with offset/limit pagination.""", + EmptyBody, EmptyBody, + List($AuthenticatedUserIsRequired, InvalidSignalChannelName, UnknownError), + apiTagAiAgent :: apiTagSignal :: apiTagSignalling :: apiTagChannel :: Nil, None, + http4sPartialFunction = Some(getSignalMessages)) + + // DELETE /obp/v6.0.0/signal/channels/CHANNEL_NAME (200 with body — not 204) + lazy val deleteSignalChannel: HttpRoutes[IO] = HttpRoutes.of[IO] { + case req @ DELETE -> `prefixPath` / "signal" / "channels" / channelName => + EndpointHelpers.executeAndRespond(req) { implicit cc => + for { + _ <- Helper.booleanToFuture(InvalidSignalChannelName, cc = Some(cc)) { + code.api.cache.RedisMessaging.validateChannelName(channelName) + } + deleted <- Future(code.api.cache.RedisMessaging.deleteChannel(channelName)) + } yield SignalChannelDeletedJsonV600(channelName, deleted) + } + } + resourceDocs += ResourceDoc( + null, implementedInApiVersion, nameOf(deleteSignalChannel), "DELETE", + "/signal/channels/CHANNEL_NAME", "Delete Signal Channel", + """Delete a signal channel and all its messages.""", + EmptyBody, EmptyBody, + List($AuthenticatedUserIsRequired, InvalidSignalChannelName, UnknownError), + apiTagAiAgent :: apiTagSignal :: apiTagSignalling :: apiTagChannel :: Nil, None, + http4sPartialFunction = Some(deleteSignalChannel)) + + // ─── Phase 2: Chat-room reads (4 endpoints) ────────────────────────── + + private def computeParticipantCount(chatRoomId: String): Long = + code.chat.ParticipantTrait.participantProvider.vend.getParticipants(chatRoomId) + .map(_.length.toLong).openOr(0L) + + private def computeParticipantCounts(rooms: List[code.chat.ChatRoomTrait]): Map[String, Long] = + rooms.map(room => room.chatRoomId -> computeParticipantCount(room.chatRoomId)).toMap + + private def computeUnreadCounts(rooms: List[code.chat.ChatRoomTrait], userId: String): Map[String, Long] = + rooms.flatMap { room => + val participant = code.chat.ChatPermissions.isParticipant(room.chatRoomId, userId) + participant.toList.map { p => + val count = if (room.isOpenRoom) + code.chat.ChatMessageTrait.chatMessageProvider.vend.getUnreadMentionCount(room.chatRoomId, userId, p.lastReadAt) + else + code.chat.ChatMessageTrait.chatMessageProvider.vend.getUnreadCount(room.chatRoomId, userId, p.lastReadAt) + room.chatRoomId -> count.openOr(0L) + } + }.toMap + + // GET /obp/v6.0.0/banks/BANK_ID/chat-rooms + lazy val getBankChatRooms: HttpRoutes[IO] = HttpRoutes.of[IO] { + case req @ GET -> `prefixPath` / "banks" / bankIdStr / "chat-rooms" => + EndpointHelpers.withUserAndBank(req) { (user, _, cc) => + for { + roomsBox <- Future(code.chat.ChatRoomTrait.chatRoomProvider.vend + .getChatRoomsByBankIdForUser(bankIdStr, user.userId)) + rooms <- Future(unboxFullOrFail(roomsBox, Some(cc), + s"$UnknownError Cannot get chat rooms", 400)) + unreadCounts <- Future(computeUnreadCounts(rooms, user.userId)) + participantCounts <- Future(computeParticipantCounts(rooms)) + } yield JSONFactory600.createChatRoomsJson(rooms, unreadCounts, participantCounts) + } + } + resourceDocs += ResourceDoc( + null, implementedInApiVersion, nameOf(getBankChatRooms), "GET", + "/banks/BANK_ID/chat-rooms", "Get Bank Chat Rooms", + """Get all bank-scoped chat rooms the current user is a participant of.""", + EmptyBody, EmptyBody, + List($AuthenticatedUserIsRequired, $BankNotFound, UnknownError), + apiTagChat :: Nil, None, + http4sPartialFunction = Some(getBankChatRooms)) + + // GET /obp/v6.0.0/chat-rooms + lazy val getSystemChatRooms: HttpRoutes[IO] = HttpRoutes.of[IO] { + case req @ GET -> `prefixPath` / "chat-rooms" => + EndpointHelpers.withUser(req) { (user, cc) => + for { + roomsBox <- Future(code.chat.ChatRoomTrait.chatRoomProvider.vend + .getChatRoomsByBankIdForUser("", user.userId)) + rooms <- Future(unboxFullOrFail(roomsBox, Some(cc), + s"$UnknownError Cannot get chat rooms", 400)) + unreadCounts <- Future(computeUnreadCounts(rooms, user.userId)) + participantCounts <- Future(computeParticipantCounts(rooms)) + } yield JSONFactory600.createChatRoomsJson(rooms, unreadCounts, participantCounts) + } + } + resourceDocs += ResourceDoc( + null, implementedInApiVersion, nameOf(getSystemChatRooms), "GET", + "/chat-rooms", "Get System Chat Rooms", + """Get all system-level chat rooms the current user is a participant of.""", + EmptyBody, EmptyBody, + List($AuthenticatedUserIsRequired, UnknownError), + apiTagChat :: Nil, None, + http4sPartialFunction = Some(getSystemChatRooms)) + + // GET /obp/v6.0.0/banks/BANK_ID/chat-rooms/CHAT_ROOM_ID + lazy val getBankChatRoom: HttpRoutes[IO] = HttpRoutes.of[IO] { + case req @ GET -> `prefixPath` / "banks" / _ / "chat-rooms" / chatRoomId => + EndpointHelpers.withUserAndBank(req) { (user, _, cc) => + for { + roomBox <- Future(code.chat.ChatRoomTrait.chatRoomProvider.vend.getChatRoom(chatRoomId)) + room <- Future(unboxFullOrFail(roomBox, Some(cc), ChatRoomNotFound, 404)) + participantBox <- Future(code.chat.ChatPermissions.isParticipant(chatRoomId, user.userId)) + _ <- Future(unboxFullOrFail(participantBox, Some(cc), NotChatRoomParticipant, 403)) + } yield JSONFactory600.createChatRoomJson(room, participantCount = computeParticipantCount(room.chatRoomId)) + } + } + resourceDocs += ResourceDoc( + null, implementedInApiVersion, nameOf(getBankChatRoom), "GET", + "/banks/BANK_ID/chat-rooms/CHAT_ROOM_ID", "Get Bank Chat Room", + """Get a specific bank chat room by ID.""", + EmptyBody, EmptyBody, + List($AuthenticatedUserIsRequired, $BankNotFound, ChatRoomNotFound, NotChatRoomParticipant, UnknownError), + apiTagChat :: Nil, None, + http4sPartialFunction = Some(getBankChatRoom)) + + // GET /obp/v6.0.0/chat-rooms/CHAT_ROOM_ID + lazy val getSystemChatRoom: HttpRoutes[IO] = HttpRoutes.of[IO] { + case req @ GET -> `prefixPath` / "chat-rooms" / chatRoomId => + EndpointHelpers.withUser(req) { (user, cc) => + for { + roomBox <- Future(code.chat.ChatRoomTrait.chatRoomProvider.vend.getChatRoom(chatRoomId)) + room <- Future(unboxFullOrFail(roomBox, Some(cc), ChatRoomNotFound, 404)) + participantBox <- Future(code.chat.ChatPermissions.isParticipant(chatRoomId, user.userId)) + _ <- Future(unboxFullOrFail(participantBox, Some(cc), NotChatRoomParticipant, 403)) + } yield JSONFactory600.createChatRoomJson(room, participantCount = computeParticipantCount(room.chatRoomId)) + } + } + resourceDocs += ResourceDoc( + null, implementedInApiVersion, nameOf(getSystemChatRoom), "GET", + "/chat-rooms/CHAT_ROOM_ID", "Get System Chat Room", + """Get a specific system chat room by ID.""", + EmptyBody, EmptyBody, + List($AuthenticatedUserIsRequired, ChatRoomNotFound, NotChatRoomParticipant, UnknownError), + apiTagChat :: Nil, None, + http4sPartialFunction = Some(getSystemChatRoom)) + + // ─── Phase 2: Chat-room my-views (6 endpoints) ──────────────────────── + + // GET /obp/v6.0.0/users/current/chat-rooms + lazy val getMyChatRooms: HttpRoutes[IO] = HttpRoutes.of[IO] { + case req @ GET -> `prefixPath` / "users" / "current" / "chat-rooms" => + EndpointHelpers.withUser(req) { (user, cc) => + for { + participantBox <- Future(code.chat.ParticipantTrait.participantProvider.vend + .getParticipantRoomsByUserId(user.userId)) + participantRecords <- Future(unboxFullOrFail(participantBox, Some(cc), + s"$UnknownError Cannot get participant records", 400)) + roomsAndCounts <- Future { + participantRecords.flatMap { p => + code.chat.ChatRoomTrait.chatRoomProvider.vend.getChatRoom(p.chatRoomId).toList.map { room => + val count = if (room.isOpenRoom) + code.chat.ChatMessageTrait.chatMessageProvider.vend.getUnreadMentionCount(p.chatRoomId, p.userId, p.lastReadAt) + else + code.chat.ChatMessageTrait.chatMessageProvider.vend.getUnreadCount(p.chatRoomId, p.userId, p.lastReadAt) + (room, count.openOr(0L)) + } + } + } + participantCounts <- Future(computeParticipantCounts(roomsAndCounts.map(_._1))) + } yield { + val rooms = roomsAndCounts.map(_._1) + val unread = roomsAndCounts.map { case (r, c) => r.chatRoomId -> c }.toMap + JSONFactory600.createChatRoomsJson(rooms, unread, participantCounts) + } + } + } + resourceDocs += ResourceDoc( + null, implementedInApiVersion, nameOf(getMyChatRooms), "GET", + "/users/current/chat-rooms", "Get My Chat Rooms", + """Get all chat rooms (any bank or system) the current user is a participant of.""", + EmptyBody, EmptyBody, + List($AuthenticatedUserIsRequired, UnknownError), + apiTagChat :: Nil, None, + http4sPartialFunction = Some(getMyChatRooms)) + + // GET /obp/v6.0.0/users/current/chat-rooms/unread + lazy val getMyUnreadCounts: HttpRoutes[IO] = HttpRoutes.of[IO] { + case req @ GET -> `prefixPath` / "users" / "current" / "chat-rooms" / "unread" => + EndpointHelpers.withUser(req) { (user, cc) => + for { + participantBox <- Future(code.chat.ParticipantTrait.participantProvider.vend + .getParticipantRoomsByUserId(user.userId)) + participantRecords <- Future(unboxFullOrFail(participantBox, Some(cc), + s"$UnknownError Cannot get participant records", 400)) + counts <- Future { + participantRecords.flatMap { p => + val room = code.chat.ChatRoomTrait.chatRoomProvider.vend.getChatRoom(p.chatRoomId) + val isOpen = room.map(_.isOpenRoom).openOr(false) + val count = if (isOpen) + code.chat.ChatMessageTrait.chatMessageProvider.vend.getUnreadMentionCount(p.chatRoomId, p.userId, p.lastReadAt) + else + code.chat.ChatMessageTrait.chatMessageProvider.vend.getUnreadCount(p.chatRoomId, p.userId, p.lastReadAt) + count.toList.map(c => UnreadCountJsonV600(chat_room_id = p.chatRoomId, unread_count = c)) + } + } + } yield UnreadCountsJsonV600(unread_counts = counts) + } + } + resourceDocs += ResourceDoc( + null, implementedInApiVersion, nameOf(getMyUnreadCounts), "GET", + "/users/current/chat-rooms/unread", "Get My Unread Counts", + """Unread-message counts for every chat room the current user is in.""", + EmptyBody, EmptyBody, + List($AuthenticatedUserIsRequired, UnknownError), + apiTagChat :: Nil, None, + http4sPartialFunction = Some(getMyUnreadCounts)) + + // PUT /obp/v6.0.0/users/current/chat-rooms/CHAT_ROOM_ID/read-marker + lazy val markChatRoomRead: HttpRoutes[IO] = HttpRoutes.of[IO] { + case req @ PUT -> `prefixPath` / "users" / "current" / "chat-rooms" / chatRoomId / "read-marker" => + EndpointHelpers.executeAndRespond(req) { implicit cc => + val user = cc.user.openOrThrowException("User not found in CallContext") + for { + roomBox <- Future(code.chat.ChatRoomTrait.chatRoomProvider.vend.getChatRoom(chatRoomId)) + _ <- Future(unboxFullOrFail(roomBox, Some(cc), ChatRoomNotFound, 404)) + partBox <- Future(code.chat.ChatPermissions.isParticipant(chatRoomId, user.userId)) + _ <- Future(unboxFullOrFail(partBox, Some(cc), NotChatRoomParticipant, 403)) + updBox <- Future(code.chat.ParticipantTrait.participantProvider.vend + .updateLastReadAt(chatRoomId, user.userId)) + updated <- Future(unboxFullOrFail(updBox, Some(cc), s"$UnknownError Cannot mark as read", 400)) + } yield JSONFactory600.createParticipantJson(updated) + } + } + resourceDocs += ResourceDoc( + null, implementedInApiVersion, nameOf(markChatRoomRead), "PUT", + "/users/current/chat-rooms/CHAT_ROOM_ID/read-marker", "Mark Chat Room Read", + """Mark all messages in a chat room as read for the current user.""", + EmptyBody, EmptyBody, + List($AuthenticatedUserIsRequired, ChatRoomNotFound, NotChatRoomParticipant, UnknownError), + apiTagChat :: Nil, None, + http4sPartialFunction = Some(markChatRoomRead)) + + // GET /obp/v6.0.0/users/current/mentions + lazy val getMyMentions: HttpRoutes[IO] = HttpRoutes.of[IO] { + case req @ GET -> `prefixPath` / "users" / "current" / "mentions" => + EndpointHelpers.withUser(req) { (user, cc) => + val qp = req.uri.query.params + val limit = qp.get("limit").flatMap(s => scala.util.Try(s.toInt).toOption).getOrElse(50) + val offset = qp.get("offset").flatMap(s => scala.util.Try(s.toInt).toOption).getOrElse(0) + for { + msgsBox <- Future(code.chat.ChatMessageTrait.chatMessageProvider.vend + .getMentionsForUser(user.userId, limit, offset)) + messages <- Future(unboxFullOrFail(msgsBox, Some(cc), + s"$UnknownError Cannot get mentions", 400)) + allReactions <- Future { + messages.map { msg => + val r = code.chat.ReactionTrait.reactionProvider.vend.getReactions(msg.chatMessageId).openOr(List.empty) + msg.chatMessageId -> r + }.toMap + } + } yield JSONFactory600.createChatMessagesJson(messages, allReactions) + } + } + resourceDocs += ResourceDoc( + null, implementedInApiVersion, nameOf(getMyMentions), "GET", + "/users/current/mentions", "Get My Mentions", + """Messages where the current user is mentioned. Supports limit/offset query params.""", + EmptyBody, EmptyBody, + List($AuthenticatedUserIsRequired, UnknownError), + apiTagChat :: Nil, None, + http4sPartialFunction = Some(getMyMentions)) + + // POST /obp/v6.0.0/chat-rooms/search (200, NOT 201) + lazy val searchChatRooms: HttpRoutes[IO] = HttpRoutes.of[IO] { + case req @ POST -> `prefixPath` / "chat-rooms" / "search" => + EndpointHelpers.executeAndRespond(req) { implicit cc => + val rawBody = cc.httpBody.getOrElse("") + val user = cc.user.openOrThrowException("User not found in CallContext") + for { + postJson <- NewStyle.function.tryons( + s"$InvalidJsonFormat The Json body should be the ChatRoomSearchRequestJsonV600", 400, Some(cc)) { + net.liftweb.json.parse(rawBody).extract[ChatRoomSearchRequestJsonV600] + } + roomsBox <- Future(code.chat.ChatRoomTrait.chatRoomProvider.vend + .searchChatRoomsForUserWithParticipants(user.userId, postJson.with_user_ids, + postJson.exact_participants.getOrElse(false))) + rooms <- Future(unboxFullOrFail(roomsBox, Some(cc), + s"$UnknownError Cannot search chat rooms", 400)) + unreadCounts <- Future(computeUnreadCounts(rooms, user.userId)) + participantCounts <- Future(computeParticipantCounts(rooms)) + } yield JSONFactory600.createChatRoomsJson(rooms, unreadCounts, participantCounts) + } + } + resourceDocs += ResourceDoc( + null, implementedInApiVersion, nameOf(searchChatRooms), "POST", + "/chat-rooms/search", "Search Chat Rooms", + """Search chat rooms by participant set. POST body lists with_user_ids; response shape matches Get My Chat Rooms.""", + EmptyBody, EmptyBody, + List($AuthenticatedUserIsRequired, InvalidJsonFormat, UnknownError), + apiTagChat :: Nil, None, + http4sPartialFunction = Some(searchChatRooms)) + + // GET /obp/v6.0.0/chat-rooms/CHAT_ROOM_ID/messages/reactions + lazy val getBulkReactions: HttpRoutes[IO] = HttpRoutes.of[IO] { + case req @ GET -> `prefixPath` / "chat-rooms" / chatRoomId / "messages" / "reactions" => + EndpointHelpers.withUser(req) { (user, cc) => + for { + roomBox <- Future(code.chat.ChatRoomTrait.chatRoomProvider.vend.getChatRoom(chatRoomId)) + _ <- Future(unboxFullOrFail(roomBox, Some(cc), ChatRoomNotFound, 404)) + partBox <- Future(code.chat.ChatPermissions.isParticipant(chatRoomId, user.userId)) + _ <- Future(unboxFullOrFail(partBox, Some(cc), NotChatRoomParticipant, 403)) + messageIds = req.uri.query.params.get("message_ids") + .map(_.split(",").map(_.trim).filter(_.nonEmpty).toList).getOrElse(List.empty) + reactionsBox <- Future(code.chat.ReactionTrait.reactionProvider.vend + .getReactionsForMessages(messageIds)) + allReactions <- Future(unboxFullOrFail(reactionsBox, Some(cc), + s"$UnknownError Cannot get reactions", 400)) + } yield JSONFactory600.createBulkReactionsJson(allReactions, messageIds) + } + } + resourceDocs += ResourceDoc( + null, implementedInApiVersion, nameOf(getBulkReactions), "GET", + "/chat-rooms/CHAT_ROOM_ID/messages/reactions", "Get Bulk Reactions", + """Get reactions for multiple messages in one call (?message_ids=id1,id2,id3).""", + EmptyBody, EmptyBody, + List($AuthenticatedUserIsRequired, ChatRoomNotFound, NotChatRoomParticipant, UnknownError), + apiTagChat :: Nil, None, + http4sPartialFunction = Some(getBulkReactions)) + + // ─── Phase 2: Chat-room admin (5 endpoints) ─────────────────────────── + + // PUT /obp/v6.0.0/banks/BANK_ID/chat-rooms/CHAT_ROOM_ID/archive-status + lazy val archiveBankChatRoom: HttpRoutes[IO] = HttpRoutes.of[IO] { + case req @ PUT -> `prefixPath` / "banks" / _ / "chat-rooms" / chatRoomId / "archive-status" => + EndpointHelpers.withUserAndBank(req) { (_, _, cc) => + for { + roomBox <- Future(code.chat.ChatRoomTrait.chatRoomProvider.vend.getChatRoom(chatRoomId)) + _ <- Future(unboxFullOrFail(roomBox, Some(cc), ChatRoomNotFound, 404)) + archivedBox <- Future(code.chat.ChatRoomTrait.chatRoomProvider.vend.archiveChatRoom(chatRoomId)) + archived <- Future(unboxFullOrFail(archivedBox, Some(cc), + s"$UnknownError Cannot archive chat room", 400)) + } yield JSONFactory600.createChatRoomJson(archived, + participantCount = computeParticipantCount(archived.chatRoomId)) + } + } + resourceDocs += ResourceDoc( + null, implementedInApiVersion, nameOf(archiveBankChatRoom), "PUT", + "/banks/BANK_ID/chat-rooms/CHAT_ROOM_ID/archive-status", "Archive Bank Chat Room", + """Archive a bank chat room.""", + EmptyBody, EmptyBody, + List($AuthenticatedUserIsRequired, UserHasMissingRoles, $BankNotFound, ChatRoomNotFound, UnknownError), + apiTagChat :: Nil, + Some(canArchiveBankChatRoom :: Nil), + http4sPartialFunction = Some(archiveBankChatRoom)) + + // PUT /obp/v6.0.0/chat-rooms/CHAT_ROOM_ID/archive-status + lazy val archiveSystemChatRoom: HttpRoutes[IO] = HttpRoutes.of[IO] { + case req @ PUT -> `prefixPath` / "chat-rooms" / chatRoomId / "archive-status" => + EndpointHelpers.withUser(req) { (_, cc) => + for { + roomBox <- Future(code.chat.ChatRoomTrait.chatRoomProvider.vend.getChatRoom(chatRoomId)) + _ <- Future(unboxFullOrFail(roomBox, Some(cc), ChatRoomNotFound, 404)) + archivedBox <- Future(code.chat.ChatRoomTrait.chatRoomProvider.vend.archiveChatRoom(chatRoomId)) + archived <- Future(unboxFullOrFail(archivedBox, Some(cc), + s"$UnknownError Cannot archive chat room", 400)) + } yield JSONFactory600.createChatRoomJson(archived, + participantCount = computeParticipantCount(archived.chatRoomId)) + } + } + resourceDocs += ResourceDoc( + null, implementedInApiVersion, nameOf(archiveSystemChatRoom), "PUT", + "/chat-rooms/CHAT_ROOM_ID/archive-status", "Archive System Chat Room", + """Archive a system chat room.""", + EmptyBody, EmptyBody, + List($AuthenticatedUserIsRequired, UserHasMissingRoles, ChatRoomNotFound, UnknownError), + apiTagChat :: Nil, + Some(canArchiveSystemChatRoom :: Nil), + http4sPartialFunction = Some(archiveSystemChatRoom)) + + // POST /obp/v6.0.0/banks/BANK_ID/chat-room-participants (201) + lazy val joinBankChatRoom: HttpRoutes[IO] = HttpRoutes.of[IO] { + case req @ POST -> `prefixPath` / "banks" / _ / "chat-room-participants" => + EndpointHelpers.executeFutureCreated(req) { + implicit val cc: CallContext = req.callContext + val rawBody = cc.httpBody.getOrElse("") + val u = cc.user.openOrThrowException("User not found in CallContext") + for { + json <- Future(net.liftweb.json.parse(rawBody)) + joiningKey = (json \ "joining_key").extractOpt[String].getOrElse("") + roomBox <- Future(code.chat.ChatRoomTrait.chatRoomProvider.vend.getChatRoomByJoiningKey(joiningKey)) + room <- Future(unboxFullOrFail(roomBox, Some(cc), InvalidJoiningKey, 404)) + _ <- Helper.booleanToFuture(ChatRoomIsArchived, cc = Some(cc)) { !room.isArchived } + existing <- Future(code.chat.ChatPermissions.isParticipant(room.chatRoomId, u.userId)) + _ <- Helper.booleanToFuture(ChatRoomParticipantAlreadyExists, 409, Some(cc)) { + existing.isEmpty + } + partBox <- Future(code.chat.ParticipantTrait.participantProvider.vend + .addParticipant(room.chatRoomId, u.userId, "", List.empty, "")) + participant <- Future(unboxFullOrFail(partBox, Some(cc), + s"$UnknownError Cannot join chat room", 400)) + } yield JSONFactory600.createParticipantJson(participant) + } + } + resourceDocs += ResourceDoc( + null, implementedInApiVersion, nameOf(joinBankChatRoom), "POST", + "/banks/BANK_ID/chat-room-participants", "Join Bank Chat Room", + """Join a bank chat room using a joining key.""", + EmptyBody, EmptyBody, + List($AuthenticatedUserIsRequired, $BankNotFound, InvalidJoiningKey, + ChatRoomIsArchived, ChatRoomParticipantAlreadyExists, UnknownError), + apiTagChat :: Nil, None, + http4sPartialFunction = Some(joinBankChatRoom)) + + // PUT /obp/v6.0.0/banks/BANK_ID/chat-rooms/CHAT_ROOM_ID/joining-key + lazy val refreshBankJoiningKey: HttpRoutes[IO] = HttpRoutes.of[IO] { + case req @ PUT -> `prefixPath` / "banks" / _ / "chat-rooms" / chatRoomId / "joining-key" => + EndpointHelpers.withUserAndBank(req) { (user, _, cc) => + for { + roomBox <- Future(code.chat.ChatRoomTrait.chatRoomProvider.vend.getChatRoom(chatRoomId)) + _ <- Future(unboxFullOrFail(roomBox, Some(cc), ChatRoomNotFound, 404)) + permBox <- Future(code.chat.ChatPermissions.checkParticipantPermission( + chatRoomId, user.userId, code.chat.ChatPermissions.CAN_REFRESH_JOINING_KEY)) + _ <- Future(unboxFullOrFail(permBox, Some(cc), InsufficientChatPermission, 403)) + updBox <- Future(code.chat.ChatRoomTrait.chatRoomProvider.vend.refreshJoiningKey(chatRoomId)) + updated <- Future(unboxFullOrFail(updBox, Some(cc), + s"$UnknownError Cannot refresh joining key", 400)) + } yield JoiningKeyJsonV600(joining_key = updated.joiningKey) + } + } + resourceDocs += ResourceDoc( + null, implementedInApiVersion, nameOf(refreshBankJoiningKey), "PUT", + "/banks/BANK_ID/chat-rooms/CHAT_ROOM_ID/joining-key", "Refresh Bank Chat Room Joining Key", + """Refresh the joining key for a bank chat room.""", + EmptyBody, EmptyBody, + List($AuthenticatedUserIsRequired, $BankNotFound, ChatRoomNotFound, + InsufficientChatPermission, UnknownError), + apiTagChat :: Nil, None, + http4sPartialFunction = Some(refreshBankJoiningKey)) + + // PUT /obp/v6.0.0/chat-rooms/CHAT_ROOM_ID/joining-key + lazy val refreshSystemJoiningKey: HttpRoutes[IO] = HttpRoutes.of[IO] { + case req @ PUT -> `prefixPath` / "chat-rooms" / chatRoomId / "joining-key" => + EndpointHelpers.withUser(req) { (user, cc) => + for { + roomBox <- Future(code.chat.ChatRoomTrait.chatRoomProvider.vend.getChatRoom(chatRoomId)) + _ <- Future(unboxFullOrFail(roomBox, Some(cc), ChatRoomNotFound, 404)) + permBox <- Future(code.chat.ChatPermissions.checkParticipantPermission( + chatRoomId, user.userId, code.chat.ChatPermissions.CAN_REFRESH_JOINING_KEY)) + _ <- Future(unboxFullOrFail(permBox, Some(cc), InsufficientChatPermission, 403)) + updBox <- Future(code.chat.ChatRoomTrait.chatRoomProvider.vend.refreshJoiningKey(chatRoomId)) + updated <- Future(unboxFullOrFail(updBox, Some(cc), + s"$UnknownError Cannot refresh joining key", 400)) + } yield JoiningKeyJsonV600(joining_key = updated.joiningKey) + } + } + resourceDocs += ResourceDoc( + null, implementedInApiVersion, nameOf(refreshSystemJoiningKey), "PUT", + "/chat-rooms/CHAT_ROOM_ID/joining-key", "Refresh System Chat Room Joining Key", + """Refresh the joining key for a system chat room.""", + EmptyBody, EmptyBody, + List($AuthenticatedUserIsRequired, ChatRoomNotFound, + InsufficientChatPermission, UnknownError), + apiTagChat :: Nil, None, + http4sPartialFunction = Some(refreshSystemJoiningKey)) + + // ─── Phase 2: Chat-room mutations (8 endpoints) ─────────────────────── + + // POST /obp/v6.0.0/banks/BANK_ID/chat-rooms (201) + lazy val createBankChatRoom: HttpRoutes[IO] = HttpRoutes.of[IO] { + case req @ POST -> `prefixPath` / "banks" / bankIdStr / "chat-rooms" => + EndpointHelpers.executeFutureCreated(req) { + implicit val cc: CallContext = req.callContext + val rawBody = cc.httpBody.getOrElse("") + val u = cc.user.openOrThrowException("User not found in CallContext") + for { + postJson <- NewStyle.function.tryons( + s"$InvalidJsonFormat The Json body should be the PostChatRoomJsonV600", 400, Some(cc)) { + net.liftweb.json.parse(rawBody).extract[PostChatRoomJsonV600] + } + existing <- Future(code.chat.ChatRoomTrait.chatRoomProvider.vend + .getChatRoomByBankIdAndName(bankIdStr, postJson.name)) + _ <- Helper.booleanToFuture(ChatRoomAlreadyExists, 409, Some(cc)) { existing.isEmpty } + roomBox <- Future(code.chat.ChatRoomTrait.chatRoomProvider.vend + .createChatRoom(bankIdStr, postJson.name, postJson.description, u.userId)) + room <- Future(unboxFullOrFail(roomBox, Some(cc), + s"$UnknownError Cannot create chat room", 400)) + partBox <- Future(code.chat.ParticipantTrait.participantProvider.vend + .addParticipant(room.chatRoomId, u.userId, "", + code.chat.ChatPermissions.ALL_PERMISSIONS, "")) + _ <- Future(unboxFullOrFail(partBox, Some(cc), + s"$UnknownError Cannot add creator as participant", 400)) + } yield JSONFactory600.createChatRoomJson(room, + participantCount = computeParticipantCount(room.chatRoomId)) + } + } + resourceDocs += ResourceDoc( + null, implementedInApiVersion, nameOf(createBankChatRoom), "POST", + "/banks/BANK_ID/chat-rooms", "Create Bank Chat Room", + """Create a new bank-scoped chat room. Creator becomes participant with all permissions.""", + EmptyBody, EmptyBody, + List($AuthenticatedUserIsRequired, $BankNotFound, InvalidJsonFormat, + ChatRoomAlreadyExists, UnknownError), + apiTagChat :: Nil, None, + http4sPartialFunction = Some(createBankChatRoom)) + + // POST /obp/v6.0.0/chat-rooms (201) + lazy val createSystemChatRoom: HttpRoutes[IO] = HttpRoutes.of[IO] { + case req @ POST -> `prefixPath` / "chat-rooms" => + EndpointHelpers.executeFutureCreated(req) { + implicit val cc: CallContext = req.callContext + val rawBody = cc.httpBody.getOrElse("") + val u = cc.user.openOrThrowException("User not found in CallContext") + for { + postJson <- NewStyle.function.tryons( + s"$InvalidJsonFormat The Json body should be the PostChatRoomJsonV600", 400, Some(cc)) { + net.liftweb.json.parse(rawBody).extract[PostChatRoomJsonV600] + } + existing <- Future(code.chat.ChatRoomTrait.chatRoomProvider.vend + .getChatRoomByBankIdAndName("", postJson.name)) + _ <- Helper.booleanToFuture(ChatRoomAlreadyExists, 409, Some(cc)) { existing.isEmpty } + roomBox <- Future(code.chat.ChatRoomTrait.chatRoomProvider.vend + .createChatRoom("", postJson.name, postJson.description, u.userId)) + room <- Future(unboxFullOrFail(roomBox, Some(cc), + s"$UnknownError Cannot create chat room", 400)) + partBox <- Future(code.chat.ParticipantTrait.participantProvider.vend + .addParticipant(room.chatRoomId, u.userId, "", + code.chat.ChatPermissions.ALL_PERMISSIONS, "")) + _ <- Future(unboxFullOrFail(partBox, Some(cc), + s"$UnknownError Cannot add creator as participant", 400)) + } yield JSONFactory600.createChatRoomJson(room, + participantCount = computeParticipantCount(room.chatRoomId)) + } + } + resourceDocs += ResourceDoc( + null, implementedInApiVersion, nameOf(createSystemChatRoom), "POST", + "/chat-rooms", "Create System Chat Room", + """Create a new system-level chat room. Creator becomes participant with all permissions.""", + EmptyBody, EmptyBody, + List($AuthenticatedUserIsRequired, InvalidJsonFormat, + ChatRoomAlreadyExists, UnknownError), + apiTagChat :: Nil, None, + http4sPartialFunction = Some(createSystemChatRoom)) + + // PUT /obp/v6.0.0/banks/BANK_ID/chat-rooms/CHAT_ROOM_ID + lazy val updateBankChatRoom: HttpRoutes[IO] = HttpRoutes.of[IO] { + case req @ PUT -> `prefixPath` / "banks" / _ / "chat-rooms" / chatRoomId => + EndpointHelpers.executeAndRespond(req) { implicit cc => + val rawBody = cc.httpBody.getOrElse("") + val u = cc.user.openOrThrowException("User not found in CallContext") + for { + putJson <- NewStyle.function.tryons( + s"$InvalidJsonFormat The Json body should be the PutChatRoomJsonV600", 400, Some(cc)) { + net.liftweb.json.parse(rawBody).extract[PutChatRoomJsonV600] + } + roomBox <- Future(code.chat.ChatRoomTrait.chatRoomProvider.vend.getChatRoom(chatRoomId)) + _ <- Future(unboxFullOrFail(roomBox, Some(cc), ChatRoomNotFound, 404)) + permBox <- Future(code.chat.ChatPermissions.checkParticipantPermission( + chatRoomId, u.userId, code.chat.ChatPermissions.CAN_UPDATE_ROOM)) + _ <- Future(unboxFullOrFail(permBox, Some(cc), InsufficientChatPermission, 403)) + updBox <- Future(code.chat.ChatRoomTrait.chatRoomProvider.vend + .updateChatRoom(chatRoomId, putJson.name, putJson.description)) + updated <- Future(unboxFullOrFail(updBox, Some(cc), + s"$UnknownError Cannot update chat room", 400)) + } yield JSONFactory600.createChatRoomJson(updated, + participantCount = computeParticipantCount(updated.chatRoomId)) + } + } + resourceDocs += ResourceDoc( + null, implementedInApiVersion, nameOf(updateBankChatRoom), "PUT", + "/banks/BANK_ID/chat-rooms/CHAT_ROOM_ID", "Update Bank Chat Room", + """Update the name/description of a bank chat room.""", + EmptyBody, EmptyBody, + List($AuthenticatedUserIsRequired, InvalidJsonFormat, + ChatRoomNotFound, NotChatRoomParticipant, InsufficientChatPermission, UnknownError), + apiTagChat :: Nil, None, + http4sPartialFunction = Some(updateBankChatRoom)) + + // PUT /obp/v6.0.0/chat-rooms/CHAT_ROOM_ID + lazy val updateSystemChatRoom: HttpRoutes[IO] = HttpRoutes.of[IO] { + case req @ PUT -> `prefixPath` / "chat-rooms" / chatRoomId => + EndpointHelpers.executeAndRespond(req) { implicit cc => + val rawBody = cc.httpBody.getOrElse("") + val u = cc.user.openOrThrowException("User not found in CallContext") + for { + putJson <- NewStyle.function.tryons( + s"$InvalidJsonFormat The Json body should be the PutChatRoomJsonV600", 400, Some(cc)) { + net.liftweb.json.parse(rawBody).extract[PutChatRoomJsonV600] + } + roomBox <- Future(code.chat.ChatRoomTrait.chatRoomProvider.vend.getChatRoom(chatRoomId)) + _ <- Future(unboxFullOrFail(roomBox, Some(cc), ChatRoomNotFound, 404)) + permBox <- Future(code.chat.ChatPermissions.checkParticipantPermission( + chatRoomId, u.userId, code.chat.ChatPermissions.CAN_UPDATE_ROOM)) + _ <- Future(unboxFullOrFail(permBox, Some(cc), InsufficientChatPermission, 403)) + updBox <- Future(code.chat.ChatRoomTrait.chatRoomProvider.vend + .updateChatRoom(chatRoomId, putJson.name, putJson.description)) + updated <- Future(unboxFullOrFail(updBox, Some(cc), + s"$UnknownError Cannot update chat room", 400)) + } yield JSONFactory600.createChatRoomJson(updated, + participantCount = computeParticipantCount(updated.chatRoomId)) + } + } + resourceDocs += ResourceDoc( + null, implementedInApiVersion, nameOf(updateSystemChatRoom), "PUT", + "/chat-rooms/CHAT_ROOM_ID", "Update System Chat Room", + """Update the name/description of a system chat room.""", + EmptyBody, EmptyBody, + List($AuthenticatedUserIsRequired, InvalidJsonFormat, + ChatRoomNotFound, NotChatRoomParticipant, InsufficientChatPermission, UnknownError), + apiTagChat :: Nil, None, + http4sPartialFunction = Some(updateSystemChatRoom)) + + // DELETE /obp/v6.0.0/banks/BANK_ID/chat-rooms/CHAT_ROOM_ID (204) + lazy val deleteBankChatRoom: HttpRoutes[IO] = HttpRoutes.of[IO] { + case req @ DELETE -> `prefixPath` / "banks" / _ / "chat-rooms" / chatRoomId => + EndpointHelpers.executeDelete(req) { cc => + for { + roomBox <- Future(code.chat.ChatRoomTrait.chatRoomProvider.vend.getChatRoom(chatRoomId)) + _ <- Future(unboxFullOrFail(roomBox, Some(cc), ChatRoomNotFound, 404)) + delBox <- Future(code.chat.ChatRoomTrait.chatRoomProvider.vend.deleteChatRoom(chatRoomId)) + _ <- Future(unboxFullOrFail(delBox, Some(cc), + s"$UnknownError Cannot delete chat room", 400)) + } yield () + } + } + resourceDocs += ResourceDoc( + null, implementedInApiVersion, nameOf(deleteBankChatRoom), "DELETE", + "/banks/BANK_ID/chat-rooms/CHAT_ROOM_ID", "Delete Bank Chat Room", + """Delete a bank chat room.""", + EmptyBody, EmptyBody, + List($AuthenticatedUserIsRequired, UserHasMissingRoles, + $BankNotFound, ChatRoomNotFound, UnknownError), + apiTagChat :: Nil, + Some(canDeleteBankChatRoom :: Nil), + http4sPartialFunction = Some(deleteBankChatRoom)) + + // DELETE /obp/v6.0.0/chat-rooms/CHAT_ROOM_ID (204) + lazy val deleteSystemChatRoom: HttpRoutes[IO] = HttpRoutes.of[IO] { + case req @ DELETE -> `prefixPath` / "chat-rooms" / chatRoomId => + EndpointHelpers.executeDelete(req) { cc => + for { + roomBox <- Future(code.chat.ChatRoomTrait.chatRoomProvider.vend.getChatRoom(chatRoomId)) + _ <- Future(unboxFullOrFail(roomBox, Some(cc), ChatRoomNotFound, 404)) + delBox <- Future(code.chat.ChatRoomTrait.chatRoomProvider.vend.deleteChatRoom(chatRoomId)) + _ <- Future(unboxFullOrFail(delBox, Some(cc), + s"$UnknownError Cannot delete chat room", 400)) + } yield () + } + } + resourceDocs += ResourceDoc( + null, implementedInApiVersion, nameOf(deleteSystemChatRoom), "DELETE", + "/chat-rooms/CHAT_ROOM_ID", "Delete System Chat Room", + """Delete a system chat room.""", + EmptyBody, EmptyBody, + List($AuthenticatedUserIsRequired, UserHasMissingRoles, + ChatRoomNotFound, UnknownError), + apiTagChat :: Nil, + Some(canDeleteSystemChatRoom :: Nil), + http4sPartialFunction = Some(deleteSystemChatRoom)) + + // PUT /obp/v6.0.0/banks/BANK_ID/chat-rooms/CHAT_ROOM_ID/open-room + lazy val setBankChatRoomOpenRoom: HttpRoutes[IO] = HttpRoutes.of[IO] { + case req @ PUT -> `prefixPath` / "banks" / _ / "chat-rooms" / chatRoomId / "open-room" => + EndpointHelpers.withUserAndBank(req) { (_, _, cc) => + val rawBody = cc.httpBody.getOrElse("") + for { + json <- Future(net.liftweb.json.parse(rawBody)) + isOpenRoom = (json \ "is_open_room").extractOrElse[Boolean](false) + roomBox <- Future(code.chat.ChatRoomTrait.chatRoomProvider.vend.getChatRoom(chatRoomId)) + _ <- Future(unboxFullOrFail(roomBox, Some(cc), ChatRoomNotFound, 404)) + updBox <- Future(code.chat.ChatRoomTrait.chatRoomProvider.vend + .setIsOpenRoom(chatRoomId, isOpenRoom)) + updated <- Future(unboxFullOrFail(updBox, Some(cc), + s"$UnknownError Cannot update chat room", 400)) + } yield JSONFactory600.createChatRoomJson(updated, + participantCount = computeParticipantCount(updated.chatRoomId)) + } + } + resourceDocs += ResourceDoc( + null, implementedInApiVersion, nameOf(setBankChatRoomOpenRoom), "PUT", + "/banks/BANK_ID/chat-rooms/CHAT_ROOM_ID/open-room", "Set Bank Chat Room Open Room", + """Mark a bank chat room as open (all bank users implicit participants) or closed.""", + EmptyBody, EmptyBody, + List($AuthenticatedUserIsRequired, UserHasMissingRoles, + $BankNotFound, ChatRoomNotFound, UnknownError), + apiTagChat :: Nil, + Some(canSetBankChatRoomIsOpenRoom :: Nil), + http4sPartialFunction = Some(setBankChatRoomOpenRoom)) + + // PUT /obp/v6.0.0/chat-rooms/CHAT_ROOM_ID/open-room + lazy val setSystemChatRoomOpenRoom: HttpRoutes[IO] = HttpRoutes.of[IO] { + case req @ PUT -> `prefixPath` / "chat-rooms" / chatRoomId / "open-room" => + EndpointHelpers.executeAndRespond(req) { implicit cc => + val rawBody = cc.httpBody.getOrElse("") + for { + json <- Future(net.liftweb.json.parse(rawBody)) + isOpenRoom = (json \ "is_open_room").extractOrElse[Boolean](false) + roomBox <- Future(code.chat.ChatRoomTrait.chatRoomProvider.vend.getChatRoom(chatRoomId)) + _ <- Future(unboxFullOrFail(roomBox, Some(cc), ChatRoomNotFound, 404)) + updBox <- Future(code.chat.ChatRoomTrait.chatRoomProvider.vend + .setIsOpenRoom(chatRoomId, isOpenRoom)) + updated <- Future(unboxFullOrFail(updBox, Some(cc), + s"$UnknownError Cannot update chat room", 400)) + } yield JSONFactory600.createChatRoomJson(updated, + participantCount = computeParticipantCount(updated.chatRoomId)) + } + } + resourceDocs += ResourceDoc( + null, implementedInApiVersion, nameOf(setSystemChatRoomOpenRoom), "PUT", + "/chat-rooms/CHAT_ROOM_ID/open-room", "Set System Chat Room Open Room", + """Mark a system chat room as open (all users implicit participants) or closed.""", + EmptyBody, EmptyBody, + List($AuthenticatedUserIsRequired, UserHasMissingRoles, + ChatRoomNotFound, UnknownError), + apiTagChat :: Nil, + Some(canSetSystemChatRoomIsOpenRoom :: Nil), + http4sPartialFunction = Some(setSystemChatRoomOpenRoom)) + + // ─── Phase 2: Chat-room participants (8 endpoints) ──────────────────── + // Pattern: lazy val at object level so allRoutes can see them; ResourceDoc + // registrations live in a separate private def to keep under + // the JVM 64KB method-size limit. Apply this pattern for future batches. + + lazy val addBankChatRoomParticipant: HttpRoutes[IO] = HttpRoutes.of[IO] { + case req @ POST -> `prefixPath` / "banks" / _ / "chat-rooms" / chatRoomId / "participants" => + EndpointHelpers.executeFutureCreated(req) { + implicit val cc: CallContext = req.callContext + val rawBody = cc.httpBody.getOrElse("") + val u = cc.user.openOrThrowException("User not found in CallContext") + for { + postJson <- NewStyle.function.tryons( + s"$InvalidJsonFormat The Json body should be the PostParticipantJsonV600", 400, Some(cc)) { + net.liftweb.json.parse(rawBody).extract[PostParticipantJsonV600] + } + roomBox <- Future(code.chat.ChatRoomTrait.chatRoomProvider.vend.getChatRoom(chatRoomId)) + _ <- Future(unboxFullOrFail(roomBox, Some(cc), ChatRoomNotFound, 404)) + permBox <- Future(code.chat.ChatPermissions.checkParticipantPermission( + chatRoomId, u.userId, code.chat.ChatPermissions.CAN_MANAGE_PERMISSIONS)) + _ <- Future(unboxFullOrFail(permBox, Some(cc), InsufficientChatPermission, 403)) + userId = postJson.user_id.getOrElse("") + consumerId = postJson.consumer_id.getOrElse("") + _ <- Helper.booleanToFuture(MustSpecifyUserIdOrConsumerId, cc = Some(cc)) { + (userId.nonEmpty || consumerId.nonEmpty) && !(userId.nonEmpty && consumerId.nonEmpty) + } + existing <- Future { + if (userId.nonEmpty) code.chat.ChatPermissions.isParticipant(chatRoomId, userId) + else code.chat.ChatPermissions.isParticipantByConsumerId(chatRoomId, consumerId) + } + _ <- Helper.booleanToFuture(ChatRoomParticipantAlreadyExists, 409, Some(cc)) { + existing.isEmpty + } + partBox <- Future(code.chat.ParticipantTrait.participantProvider.vend.addParticipant( + chatRoomId, userId, consumerId, + postJson.permissions.getOrElse(List.empty), + postJson.webhook_url.getOrElse(""))) + participant <- Future(unboxFullOrFail(partBox, Some(cc), + s"$UnknownError Cannot add participant", 400)) + } yield JSONFactory600.createParticipantJson(participant) + } + } + + lazy val addSystemChatRoomParticipant: HttpRoutes[IO] = HttpRoutes.of[IO] { + case req @ POST -> `prefixPath` / "chat-rooms" / chatRoomId / "participants" => + EndpointHelpers.executeFutureCreated(req) { + implicit val cc: CallContext = req.callContext + val rawBody = cc.httpBody.getOrElse("") + val u = cc.user.openOrThrowException("User not found in CallContext") + for { + postJson <- NewStyle.function.tryons( + s"$InvalidJsonFormat The Json body should be the PostParticipantJsonV600", 400, Some(cc)) { + net.liftweb.json.parse(rawBody).extract[PostParticipantJsonV600] + } + roomBox <- Future(code.chat.ChatRoomTrait.chatRoomProvider.vend.getChatRoom(chatRoomId)) + _ <- Future(unboxFullOrFail(roomBox, Some(cc), ChatRoomNotFound, 404)) + permBox <- Future(code.chat.ChatPermissions.checkParticipantPermission( + chatRoomId, u.userId, code.chat.ChatPermissions.CAN_MANAGE_PERMISSIONS)) + _ <- Future(unboxFullOrFail(permBox, Some(cc), InsufficientChatPermission, 403)) + userId = postJson.user_id.getOrElse("") + consumerId = postJson.consumer_id.getOrElse("") + _ <- Helper.booleanToFuture(MustSpecifyUserIdOrConsumerId, cc = Some(cc)) { + (userId.nonEmpty || consumerId.nonEmpty) && !(userId.nonEmpty && consumerId.nonEmpty) + } + existing <- Future { + if (userId.nonEmpty) code.chat.ChatPermissions.isParticipant(chatRoomId, userId) + else code.chat.ChatPermissions.isParticipantByConsumerId(chatRoomId, consumerId) + } + _ <- Helper.booleanToFuture(ChatRoomParticipantAlreadyExists, 409, Some(cc)) { + existing.isEmpty + } + partBox <- Future(code.chat.ParticipantTrait.participantProvider.vend.addParticipant( + chatRoomId, userId, consumerId, + postJson.permissions.getOrElse(List.empty), + postJson.webhook_url.getOrElse(""))) + participant <- Future(unboxFullOrFail(partBox, Some(cc), + s"$UnknownError Cannot add participant", 400)) + } yield JSONFactory600.createParticipantJson(participant) + } + } + + lazy val getBankChatRoomParticipants: HttpRoutes[IO] = HttpRoutes.of[IO] { + case req @ GET -> `prefixPath` / "banks" / _ / "chat-rooms" / chatRoomId / "participants" => + EndpointHelpers.withUserAndBank(req) { (user, _, cc) => + for { + roomBox <- Future(code.chat.ChatRoomTrait.chatRoomProvider.vend.getChatRoom(chatRoomId)) + _ <- Future(unboxFullOrFail(roomBox, Some(cc), ChatRoomNotFound, 404)) + partBox <- Future(code.chat.ChatPermissions.isParticipant(chatRoomId, user.userId)) + _ <- Future(unboxFullOrFail(partBox, Some(cc), NotChatRoomParticipant, 403)) + listBox <- Future(code.chat.ParticipantTrait.participantProvider.vend.getParticipants(chatRoomId)) + participants <- Future(unboxFullOrFail(listBox, Some(cc), + s"$UnknownError Cannot get participants", 400)) + } yield JSONFactory600.createParticipantsJson(participants) + } + } + + lazy val getSystemChatRoomParticipants: HttpRoutes[IO] = HttpRoutes.of[IO] { + case req @ GET -> `prefixPath` / "chat-rooms" / chatRoomId / "participants" => + EndpointHelpers.withUser(req) { (user, cc) => + for { + roomBox <- Future(code.chat.ChatRoomTrait.chatRoomProvider.vend.getChatRoom(chatRoomId)) + _ <- Future(unboxFullOrFail(roomBox, Some(cc), ChatRoomNotFound, 404)) + partBox <- Future(code.chat.ChatPermissions.isParticipant(chatRoomId, user.userId)) + _ <- Future(unboxFullOrFail(partBox, Some(cc), NotChatRoomParticipant, 403)) + listBox <- Future(code.chat.ParticipantTrait.participantProvider.vend.getParticipants(chatRoomId)) + participants <- Future(unboxFullOrFail(listBox, Some(cc), + s"$UnknownError Cannot get participants", 400)) + } yield JSONFactory600.createParticipantsJson(participants) + } + } + + lazy val updateBankParticipantPermissions: HttpRoutes[IO] = HttpRoutes.of[IO] { + case req @ PUT -> `prefixPath` / "banks" / _ / "chat-rooms" / chatRoomId / "participants" / targetUserId => + EndpointHelpers.withUserAndBank(req) { (user, _, cc) => + val rawBody = cc.httpBody.getOrElse("") + for { + putJson <- NewStyle.function.tryons( + s"$InvalidJsonFormat The Json body should be the PutParticipantPermissionsJsonV600", 400, Some(cc)) { + net.liftweb.json.parse(rawBody).extract[PutParticipantPermissionsJsonV600] + } + roomBox <- Future(code.chat.ChatRoomTrait.chatRoomProvider.vend.getChatRoom(chatRoomId)) + _ <- Future(unboxFullOrFail(roomBox, Some(cc), ChatRoomNotFound, 404)) + permBox <- Future(code.chat.ChatPermissions.checkParticipantPermission( + chatRoomId, user.userId, code.chat.ChatPermissions.CAN_MANAGE_PERMISSIONS)) + _ <- Future(unboxFullOrFail(permBox, Some(cc), InsufficientChatPermission, 403)) + tgtBox <- Future(code.chat.ChatPermissions.isParticipant(chatRoomId, targetUserId)) + _ <- Future(unboxFullOrFail(tgtBox, Some(cc), ChatRoomParticipantNotFound, 404)) + updBox <- Future(code.chat.ParticipantTrait.participantProvider.vend + .updateParticipantPermissions(chatRoomId, targetUserId, putJson.permissions)) + updated <- Future(unboxFullOrFail(updBox, Some(cc), + s"$UnknownError Cannot update participant permissions", 400)) + } yield JSONFactory600.createParticipantJson(updated) + } + } + + lazy val updateSystemParticipantPermissions: HttpRoutes[IO] = HttpRoutes.of[IO] { + case req @ PUT -> `prefixPath` / "chat-rooms" / chatRoomId / "participants" / targetUserId => + EndpointHelpers.withUser(req) { (user, cc) => + val rawBody = cc.httpBody.getOrElse("") + for { + putJson <- NewStyle.function.tryons( + s"$InvalidJsonFormat The Json body should be the PutParticipantPermissionsJsonV600", 400, Some(cc)) { + net.liftweb.json.parse(rawBody).extract[PutParticipantPermissionsJsonV600] + } + roomBox <- Future(code.chat.ChatRoomTrait.chatRoomProvider.vend.getChatRoom(chatRoomId)) + _ <- Future(unboxFullOrFail(roomBox, Some(cc), ChatRoomNotFound, 404)) + permBox <- Future(code.chat.ChatPermissions.checkParticipantPermission( + chatRoomId, user.userId, code.chat.ChatPermissions.CAN_MANAGE_PERMISSIONS)) + _ <- Future(unboxFullOrFail(permBox, Some(cc), InsufficientChatPermission, 403)) + tgtBox <- Future(code.chat.ChatPermissions.isParticipant(chatRoomId, targetUserId)) + _ <- Future(unboxFullOrFail(tgtBox, Some(cc), ChatRoomParticipantNotFound, 404)) + updBox <- Future(code.chat.ParticipantTrait.participantProvider.vend + .updateParticipantPermissions(chatRoomId, targetUserId, putJson.permissions)) + updated <- Future(unboxFullOrFail(updBox, Some(cc), + s"$UnknownError Cannot update participant permissions", 400)) + } yield JSONFactory600.createParticipantJson(updated) + } + } + + lazy val removeBankChatRoomParticipant: HttpRoutes[IO] = HttpRoutes.of[IO] { + case req @ DELETE -> `prefixPath` / "banks" / _ / "chat-rooms" / chatRoomId / "participants" / targetUserId => + EndpointHelpers.executeDelete(req) { cc => + val u = cc.user.openOrThrowException("User not found in CallContext") + for { + roomBox <- Future(code.chat.ChatRoomTrait.chatRoomProvider.vend.getChatRoom(chatRoomId)) + _ <- Future(unboxFullOrFail(roomBox, Some(cc), ChatRoomNotFound, 404)) + _ <- if (u.userId == targetUserId) Future.successful(()) + else Future(code.chat.ChatPermissions.checkParticipantPermission( + chatRoomId, u.userId, code.chat.ChatPermissions.CAN_REMOVE_PARTICIPANT)) + .map(b => unboxFullOrFail(b, Some(cc), InsufficientChatPermission, 403)) + tgtBox <- Future(code.chat.ChatPermissions.isParticipant(chatRoomId, targetUserId)) + _ <- Future(unboxFullOrFail(tgtBox, Some(cc), ChatRoomParticipantNotFound, 404)) + delBox <- Future(code.chat.ParticipantTrait.participantProvider.vend + .removeParticipant(chatRoomId, targetUserId)) + _ <- Future(unboxFullOrFail(delBox, Some(cc), + s"$UnknownError Cannot remove participant", 400)) + } yield () + } + } + + lazy val removeSystemChatRoomParticipant: HttpRoutes[IO] = HttpRoutes.of[IO] { + case req @ DELETE -> `prefixPath` / "chat-rooms" / chatRoomId / "participants" / targetUserId => + EndpointHelpers.executeDelete(req) { cc => + val u = cc.user.openOrThrowException("User not found in CallContext") + for { + roomBox <- Future(code.chat.ChatRoomTrait.chatRoomProvider.vend.getChatRoom(chatRoomId)) + _ <- Future(unboxFullOrFail(roomBox, Some(cc), ChatRoomNotFound, 404)) + _ <- if (u.userId == targetUserId) Future.successful(()) + else Future(code.chat.ChatPermissions.checkParticipantPermission( + chatRoomId, u.userId, code.chat.ChatPermissions.CAN_REMOVE_PARTICIPANT)) + .map(b => unboxFullOrFail(b, Some(cc), InsufficientChatPermission, 403)) + tgtBox <- Future(code.chat.ChatPermissions.isParticipant(chatRoomId, targetUserId)) + _ <- Future(unboxFullOrFail(tgtBox, Some(cc), ChatRoomParticipantNotFound, 404)) + delBox <- Future(code.chat.ParticipantTrait.participantProvider.vend + .removeParticipant(chatRoomId, targetUserId)) + _ <- Future(unboxFullOrFail(delBox, Some(cc), + s"$UnknownError Cannot remove participant", 400)) + } yield () + } + } + + private def initParticipantResourceDocs(): Unit = { + resourceDocs += ResourceDoc( + null, implementedInApiVersion, nameOf(addBankChatRoomParticipant), "POST", + "/banks/BANK_ID/chat-rooms/CHAT_ROOM_ID/participants", "Add Bank Chat Room Participant", + """Add a participant to a bank chat room.""", + EmptyBody, EmptyBody, + List($AuthenticatedUserIsRequired, $BankNotFound, InvalidJsonFormat, + ChatRoomNotFound, InsufficientChatPermission, MustSpecifyUserIdOrConsumerId, + ChatRoomParticipantAlreadyExists, UnknownError), + apiTagChat :: Nil, None, + http4sPartialFunction = Some(addBankChatRoomParticipant)) + resourceDocs += ResourceDoc( + null, implementedInApiVersion, nameOf(addSystemChatRoomParticipant), "POST", + "/chat-rooms/CHAT_ROOM_ID/participants", "Add System Chat Room Participant", + """Add a participant to a system chat room.""", + EmptyBody, EmptyBody, + List($AuthenticatedUserIsRequired, InvalidJsonFormat, + ChatRoomNotFound, InsufficientChatPermission, MustSpecifyUserIdOrConsumerId, + ChatRoomParticipantAlreadyExists, UnknownError), + apiTagChat :: Nil, None, + http4sPartialFunction = Some(addSystemChatRoomParticipant)) + resourceDocs += ResourceDoc( + null, implementedInApiVersion, nameOf(getBankChatRoomParticipants), "GET", + "/banks/BANK_ID/chat-rooms/CHAT_ROOM_ID/participants", "Get Bank Chat Room Participants", + """List participants of a bank chat room.""", + EmptyBody, EmptyBody, + List($AuthenticatedUserIsRequired, $BankNotFound, + ChatRoomNotFound, NotChatRoomParticipant, UnknownError), + apiTagChat :: Nil, None, + http4sPartialFunction = Some(getBankChatRoomParticipants)) + resourceDocs += ResourceDoc( + null, implementedInApiVersion, nameOf(getSystemChatRoomParticipants), "GET", + "/chat-rooms/CHAT_ROOM_ID/participants", "Get System Chat Room Participants", + """List participants of a system chat room.""", + EmptyBody, EmptyBody, + List($AuthenticatedUserIsRequired, ChatRoomNotFound, NotChatRoomParticipant, UnknownError), + apiTagChat :: Nil, None, + http4sPartialFunction = Some(getSystemChatRoomParticipants)) + resourceDocs += ResourceDoc( + null, implementedInApiVersion, nameOf(updateBankParticipantPermissions), "PUT", + "/banks/BANK_ID/chat-rooms/CHAT_ROOM_ID/participants/USER_ID", + "Update Bank Chat Room Participant Permissions", + """Update permissions for a participant in a bank chat room.""", + EmptyBody, EmptyBody, + List($AuthenticatedUserIsRequired, $BankNotFound, InvalidJsonFormat, + ChatRoomNotFound, InsufficientChatPermission, ChatRoomParticipantNotFound, UnknownError), + apiTagChat :: Nil, None, + http4sPartialFunction = Some(updateBankParticipantPermissions)) + resourceDocs += ResourceDoc( + null, implementedInApiVersion, nameOf(updateSystemParticipantPermissions), "PUT", + "/chat-rooms/CHAT_ROOM_ID/participants/USER_ID", + "Update System Chat Room Participant Permissions", + """Update permissions for a participant in a system chat room.""", + EmptyBody, EmptyBody, + List($AuthenticatedUserIsRequired, InvalidJsonFormat, + ChatRoomNotFound, InsufficientChatPermission, ChatRoomParticipantNotFound, UnknownError), + apiTagChat :: Nil, None, + http4sPartialFunction = Some(updateSystemParticipantPermissions)) + resourceDocs += ResourceDoc( + null, implementedInApiVersion, nameOf(removeBankChatRoomParticipant), "DELETE", + "/banks/BANK_ID/chat-rooms/CHAT_ROOM_ID/participants/USER_ID", + "Remove Bank Chat Room Participant", + """Remove a participant from a bank chat room (self-removal allowed).""", + EmptyBody, EmptyBody, + List($AuthenticatedUserIsRequired, $BankNotFound, + ChatRoomNotFound, InsufficientChatPermission, ChatRoomParticipantNotFound, UnknownError), + apiTagChat :: Nil, None, + http4sPartialFunction = Some(removeBankChatRoomParticipant)) + resourceDocs += ResourceDoc( + null, implementedInApiVersion, nameOf(removeSystemChatRoomParticipant), "DELETE", + "/chat-rooms/CHAT_ROOM_ID/participants/USER_ID", + "Remove System Chat Room Participant", + """Remove a participant from a system chat room (self-removal allowed).""", + EmptyBody, EmptyBody, + List($AuthenticatedUserIsRequired, ChatRoomNotFound, + InsufficientChatPermission, ChatRoomParticipantNotFound, UnknownError), + apiTagChat :: Nil, None, + http4sPartialFunction = Some(removeSystemChatRoomParticipant)) + } + initParticipantResourceDocs() + + // ─── Phase 2: Chat messages (10 endpoints) ──────────────────────────── + + lazy val sendBankChatMessage: HttpRoutes[IO] = HttpRoutes.of[IO] { + case req @ POST -> `prefixPath` / "banks" / _ / "chat-rooms" / chatRoomId / "messages" => + EndpointHelpers.executeFutureCreated(req) { + implicit val cc: CallContext = req.callContext + val rawBody = cc.httpBody.getOrElse("") + val u = cc.user.openOrThrowException("User not found in CallContext") + for { + postJson <- NewStyle.function.tryons( + s"$InvalidJsonFormat The Json body should be the PostChatMessageJsonV600", 400, Some(cc)) { + net.liftweb.json.parse(rawBody).extract[PostChatMessageJsonV600] + } + roomBox <- Future(code.chat.ChatRoomTrait.chatRoomProvider.vend.getChatRoom(chatRoomId)) + room <- Future(unboxFullOrFail(roomBox, Some(cc), ChatRoomNotFound, 404)) + partBox <- Future(code.chat.ChatPermissions.isParticipant(chatRoomId, u.userId)) + _ <- Future(unboxFullOrFail(partBox, Some(cc), NotChatRoomParticipant, 403)) + _ <- Helper.booleanToFuture(ChatRoomIsArchived, cc = Some(cc)) { !room.isArchived } + msgBox <- Future(code.chat.ChatMessageTrait.chatMessageProvider.vend.createMessage( + chatRoomId, u.userId, "", postJson.content, + postJson.message_type.getOrElse("text"), + postJson.mentioned_user_ids.getOrElse(List.empty), + postJson.reply_to_message_id.getOrElse(""), + postJson.thread_id.getOrElse(""))) + msg <- Future(unboxFullOrFail(msgBox, Some(cc), + s"$UnknownError Cannot send message", 400)) + } yield { + code.chat.ChatEventPublisher.afterCreate(msg, u.name, u.provider, "") + JSONFactory600.createChatMessageJson(msg, List.empty) + } + } + } + + lazy val sendSystemChatMessage: HttpRoutes[IO] = HttpRoutes.of[IO] { + case req @ POST -> `prefixPath` / "chat-rooms" / chatRoomId / "messages" => + EndpointHelpers.executeFutureCreated(req) { + implicit val cc: CallContext = req.callContext + val rawBody = cc.httpBody.getOrElse("") + val u = cc.user.openOrThrowException("User not found in CallContext") + for { + postJson <- NewStyle.function.tryons( + s"$InvalidJsonFormat The Json body should be the PostChatMessageJsonV600", 400, Some(cc)) { + net.liftweb.json.parse(rawBody).extract[PostChatMessageJsonV600] + } + roomBox <- Future(code.chat.ChatRoomTrait.chatRoomProvider.vend.getChatRoom(chatRoomId)) + room <- Future(unboxFullOrFail(roomBox, Some(cc), ChatRoomNotFound, 404)) + partBox <- Future(code.chat.ChatPermissions.isParticipant(chatRoomId, u.userId)) + _ <- Future(unboxFullOrFail(partBox, Some(cc), NotChatRoomParticipant, 403)) + _ <- Helper.booleanToFuture(ChatRoomIsArchived, cc = Some(cc)) { !room.isArchived } + msgBox <- Future(code.chat.ChatMessageTrait.chatMessageProvider.vend.createMessage( + chatRoomId, u.userId, "", postJson.content, + postJson.message_type.getOrElse("text"), + postJson.mentioned_user_ids.getOrElse(List.empty), + postJson.reply_to_message_id.getOrElse(""), + postJson.thread_id.getOrElse(""))) + msg <- Future(unboxFullOrFail(msgBox, Some(cc), + s"$UnknownError Cannot send message", 400)) + } yield { + code.chat.ChatEventPublisher.afterCreate(msg, u.name, u.provider, "") + JSONFactory600.createChatMessageJson(msg, List.empty) + } + } + } + + lazy val getBankChatMessages: HttpRoutes[IO] = HttpRoutes.of[IO] { + case req @ GET -> `prefixPath` / "banks" / _ / "chat-rooms" / chatRoomId / "messages" => + EndpointHelpers.withUserAndBank(req) { (user, _, cc) => + val qp = req.uri.query.params + val limit = qp.get("limit").flatMap(s => scala.util.Try(s.toInt).toOption).getOrElse(50) + val offset = qp.get("offset").flatMap(s => scala.util.Try(s.toInt).toOption).getOrElse(0) + val fromDate = qp.get("from_date").flatMap(APIUtil.parseObpStandardDate(_).toOption).getOrElse(APIUtil.theEpochTime) + val toDate = qp.get("to_date").flatMap(APIUtil.parseObpStandardDate(_).toOption).getOrElse(APIUtil.DefaultToDate) + for { + roomBox <- Future(code.chat.ChatRoomTrait.chatRoomProvider.vend.getChatRoom(chatRoomId)) + _ <- Future(unboxFullOrFail(roomBox, Some(cc), ChatRoomNotFound, 404)) + partBox <- Future(code.chat.ChatPermissions.isParticipant(chatRoomId, user.userId)) + _ <- Future(unboxFullOrFail(partBox, Some(cc), NotChatRoomParticipant, 403)) + tuple <- Future(code.chat.DoobieChatMessageQueries + .getMessagesWithReactions(chatRoomId, fromDate, toDate, limit, offset)) + } yield JSONFactory600.createChatMessagesJsonFromRows(tuple._1, tuple._2) + } + } + + lazy val getSystemChatMessages: HttpRoutes[IO] = HttpRoutes.of[IO] { + case req @ GET -> `prefixPath` / "chat-rooms" / chatRoomId / "messages" => + EndpointHelpers.withUser(req) { (user, cc) => + val qp = req.uri.query.params + val limit = qp.get("limit").flatMap(s => scala.util.Try(s.toInt).toOption).getOrElse(50) + val offset = qp.get("offset").flatMap(s => scala.util.Try(s.toInt).toOption).getOrElse(0) + val fromDate = qp.get("from_date").flatMap(APIUtil.parseObpStandardDate(_).toOption).getOrElse(APIUtil.theEpochTime) + val toDate = qp.get("to_date").flatMap(APIUtil.parseObpStandardDate(_).toOption).getOrElse(APIUtil.DefaultToDate) + for { + roomBox <- Future(code.chat.ChatRoomTrait.chatRoomProvider.vend.getChatRoom(chatRoomId)) + _ <- Future(unboxFullOrFail(roomBox, Some(cc), ChatRoomNotFound, 404)) + partBox <- Future(code.chat.ChatPermissions.isParticipant(chatRoomId, user.userId)) + _ <- Future(unboxFullOrFail(partBox, Some(cc), NotChatRoomParticipant, 403)) + tuple <- Future(code.chat.DoobieChatMessageQueries + .getMessagesWithReactions(chatRoomId, fromDate, toDate, limit, offset)) + } yield JSONFactory600.createChatMessagesJsonFromRows(tuple._1, tuple._2) + } + } + + lazy val getBankChatMessage: HttpRoutes[IO] = HttpRoutes.of[IO] { + case req @ GET -> `prefixPath` / "banks" / _ / "chat-rooms" / chatRoomId / "messages" / chatMessageId => + EndpointHelpers.withUserAndBank(req) { (user, _, cc) => + for { + roomBox <- Future(code.chat.ChatRoomTrait.chatRoomProvider.vend.getChatRoom(chatRoomId)) + _ <- Future(unboxFullOrFail(roomBox, Some(cc), ChatRoomNotFound, 404)) + partBox <- Future(code.chat.ChatPermissions.isParticipant(chatRoomId, user.userId)) + _ <- Future(unboxFullOrFail(partBox, Some(cc), NotChatRoomParticipant, 403)) + msgBox <- Future(code.chat.ChatMessageTrait.chatMessageProvider.vend.getMessage(chatMessageId)) + msg <- Future(unboxFullOrFail(msgBox, Some(cc), ChatMessageNotFound, 404)) + reactions <- Future(code.chat.ReactionTrait.reactionProvider.vend.getReactions(chatMessageId).openOr(List.empty)) + } yield JSONFactory600.createChatMessageJson(msg, reactions) + } + } + + lazy val getSystemChatMessage: HttpRoutes[IO] = HttpRoutes.of[IO] { + case req @ GET -> `prefixPath` / "chat-rooms" / chatRoomId / "messages" / chatMessageId => + EndpointHelpers.withUser(req) { (user, cc) => + for { + roomBox <- Future(code.chat.ChatRoomTrait.chatRoomProvider.vend.getChatRoom(chatRoomId)) + _ <- Future(unboxFullOrFail(roomBox, Some(cc), ChatRoomNotFound, 404)) + partBox <- Future(code.chat.ChatPermissions.isParticipant(chatRoomId, user.userId)) + _ <- Future(unboxFullOrFail(partBox, Some(cc), NotChatRoomParticipant, 403)) + msgBox <- Future(code.chat.ChatMessageTrait.chatMessageProvider.vend.getMessage(chatMessageId)) + msg <- Future(unboxFullOrFail(msgBox, Some(cc), ChatMessageNotFound, 404)) + reactions <- Future(code.chat.ReactionTrait.reactionProvider.vend.getReactions(chatMessageId).openOr(List.empty)) + } yield JSONFactory600.createChatMessageJson(msg, reactions) + } + } + + lazy val editBankChatMessage: HttpRoutes[IO] = HttpRoutes.of[IO] { + case req @ PUT -> `prefixPath` / "banks" / _ / "chat-rooms" / chatRoomId / "messages" / chatMessageId => + EndpointHelpers.withUserAndBank(req) { (user, _, cc) => + val rawBody = cc.httpBody.getOrElse("") + for { + putJson <- NewStyle.function.tryons( + s"$InvalidJsonFormat The Json body should be the PutChatMessageJsonV600", 400, Some(cc)) { + net.liftweb.json.parse(rawBody).extract[PutChatMessageJsonV600] + } + roomBox <- Future(code.chat.ChatRoomTrait.chatRoomProvider.vend.getChatRoom(chatRoomId)) + _ <- Future(unboxFullOrFail(roomBox, Some(cc), ChatRoomNotFound, 404)) + partBox <- Future(code.chat.ChatPermissions.isParticipant(chatRoomId, user.userId)) + _ <- Future(unboxFullOrFail(partBox, Some(cc), NotChatRoomParticipant, 403)) + msgBox <- Future(code.chat.ChatMessageTrait.chatMessageProvider.vend.getMessage(chatMessageId)) + msg <- Future(unboxFullOrFail(msgBox, Some(cc), ChatMessageNotFound, 404)) + _ <- Helper.booleanToFuture(CannotEditOthersMessage, cc = Some(cc)) { + msg.senderUserId == user.userId + } + updBox <- Future(code.chat.ChatMessageTrait.chatMessageProvider.vend.updateMessage(chatMessageId, putJson.content)) + updated <- Future(unboxFullOrFail(updBox, Some(cc), s"$UnknownError Cannot edit message", 400)) + reactions <- Future(code.chat.ReactionTrait.reactionProvider.vend.getReactions(chatMessageId).openOr(List.empty)) + } yield { + code.chat.ChatEventPublisher.afterUpdate(updated, user.name, user.provider, "") + JSONFactory600.createChatMessageJson(updated, reactions) + } + } + } + + lazy val editSystemChatMessage: HttpRoutes[IO] = HttpRoutes.of[IO] { + case req @ PUT -> `prefixPath` / "chat-rooms" / chatRoomId / "messages" / chatMessageId => + EndpointHelpers.withUser(req) { (user, cc) => + val rawBody = cc.httpBody.getOrElse("") + for { + putJson <- NewStyle.function.tryons( + s"$InvalidJsonFormat The Json body should be the PutChatMessageJsonV600", 400, Some(cc)) { + net.liftweb.json.parse(rawBody).extract[PutChatMessageJsonV600] + } + roomBox <- Future(code.chat.ChatRoomTrait.chatRoomProvider.vend.getChatRoom(chatRoomId)) + _ <- Future(unboxFullOrFail(roomBox, Some(cc), ChatRoomNotFound, 404)) + partBox <- Future(code.chat.ChatPermissions.isParticipant(chatRoomId, user.userId)) + _ <- Future(unboxFullOrFail(partBox, Some(cc), NotChatRoomParticipant, 403)) + msgBox <- Future(code.chat.ChatMessageTrait.chatMessageProvider.vend.getMessage(chatMessageId)) + msg <- Future(unboxFullOrFail(msgBox, Some(cc), ChatMessageNotFound, 404)) + _ <- Helper.booleanToFuture(CannotEditOthersMessage, cc = Some(cc)) { + msg.senderUserId == user.userId + } + updBox <- Future(code.chat.ChatMessageTrait.chatMessageProvider.vend.updateMessage(chatMessageId, putJson.content)) + updated <- Future(unboxFullOrFail(updBox, Some(cc), s"$UnknownError Cannot edit message", 400)) + reactions <- Future(code.chat.ReactionTrait.reactionProvider.vend.getReactions(chatMessageId).openOr(List.empty)) + } yield { + code.chat.ChatEventPublisher.afterUpdate(updated, user.name, user.provider, "") + JSONFactory600.createChatMessageJson(updated, reactions) + } + } + } + + lazy val deleteBankChatMessage: HttpRoutes[IO] = HttpRoutes.of[IO] { + case req @ DELETE -> `prefixPath` / "banks" / _ / "chat-rooms" / chatRoomId / "messages" / chatMessageId => + EndpointHelpers.executeDelete(req) { cc => + val u = cc.user.openOrThrowException("User not found in CallContext") + for { + roomBox <- Future(code.chat.ChatRoomTrait.chatRoomProvider.vend.getChatRoom(chatRoomId)) + _ <- Future(unboxFullOrFail(roomBox, Some(cc), ChatRoomNotFound, 404)) + partBox <- Future(code.chat.ChatPermissions.isParticipant(chatRoomId, u.userId)) + _ <- Future(unboxFullOrFail(partBox, Some(cc), NotChatRoomParticipant, 403)) + msgBox <- Future(code.chat.ChatMessageTrait.chatMessageProvider.vend.getMessage(chatMessageId)) + msg <- Future(unboxFullOrFail(msgBox, Some(cc), ChatMessageNotFound, 404)) + _ <- if (msg.senderUserId == u.userId) Future.successful(()) + else Future(code.chat.ChatPermissions.checkParticipantPermission( + chatRoomId, u.userId, code.chat.ChatPermissions.CAN_DELETE_MESSAGE)) + .map(b => unboxFullOrFail(b, Some(cc), CannotDeleteMessage, 403)) + delBox <- Future(code.chat.ChatMessageTrait.chatMessageProvider.vend.softDeleteMessage(chatMessageId)) + deleted <- Future(unboxFullOrFail(delBox, Some(cc), s"$UnknownError Cannot delete message", 400)) + } yield { + code.chat.ChatEventPublisher.afterDelete(deleted, u.name, u.provider, "") + () + } + } + } + + lazy val deleteSystemChatMessage: HttpRoutes[IO] = HttpRoutes.of[IO] { + case req @ DELETE -> `prefixPath` / "chat-rooms" / chatRoomId / "messages" / chatMessageId => + EndpointHelpers.executeDelete(req) { cc => + val u = cc.user.openOrThrowException("User not found in CallContext") + for { + roomBox <- Future(code.chat.ChatRoomTrait.chatRoomProvider.vend.getChatRoom(chatRoomId)) + _ <- Future(unboxFullOrFail(roomBox, Some(cc), ChatRoomNotFound, 404)) + partBox <- Future(code.chat.ChatPermissions.isParticipant(chatRoomId, u.userId)) + _ <- Future(unboxFullOrFail(partBox, Some(cc), NotChatRoomParticipant, 403)) + msgBox <- Future(code.chat.ChatMessageTrait.chatMessageProvider.vend.getMessage(chatMessageId)) + msg <- Future(unboxFullOrFail(msgBox, Some(cc), ChatMessageNotFound, 404)) + _ <- if (msg.senderUserId == u.userId) Future.successful(()) + else Future(code.chat.ChatPermissions.checkParticipantPermission( + chatRoomId, u.userId, code.chat.ChatPermissions.CAN_DELETE_MESSAGE)) + .map(b => unboxFullOrFail(b, Some(cc), CannotDeleteMessage, 403)) + delBox <- Future(code.chat.ChatMessageTrait.chatMessageProvider.vend.softDeleteMessage(chatMessageId)) + deleted <- Future(unboxFullOrFail(delBox, Some(cc), s"$UnknownError Cannot delete message", 400)) + } yield { + code.chat.ChatEventPublisher.afterDelete(deleted, u.name, u.provider, "") + () + } + } + } + + private def initChatMessageResourceDocs(): Unit = { + resourceDocs += ResourceDoc( + null, implementedInApiVersion, nameOf(sendBankChatMessage), "POST", + "/banks/BANK_ID/chat-rooms/CHAT_ROOM_ID/messages", "Send Bank Chat Message", + """Send a message in a bank chat room.""", + EmptyBody, EmptyBody, + List($AuthenticatedUserIsRequired, $BankNotFound, InvalidJsonFormat, + ChatRoomNotFound, NotChatRoomParticipant, ChatRoomIsArchived, UnknownError), + apiTagChat :: Nil, None, + http4sPartialFunction = Some(sendBankChatMessage)) + resourceDocs += ResourceDoc( + null, implementedInApiVersion, nameOf(sendSystemChatMessage), "POST", + "/chat-rooms/CHAT_ROOM_ID/messages", "Send System Chat Message", + """Send a message in a system chat room.""", + EmptyBody, EmptyBody, + List($AuthenticatedUserIsRequired, InvalidJsonFormat, + ChatRoomNotFound, NotChatRoomParticipant, ChatRoomIsArchived, UnknownError), + apiTagChat :: Nil, None, + http4sPartialFunction = Some(sendSystemChatMessage)) + resourceDocs += ResourceDoc( + null, implementedInApiVersion, nameOf(getBankChatMessages), "GET", + "/banks/BANK_ID/chat-rooms/CHAT_ROOM_ID/messages", "Get Bank Chat Messages", + """Get messages in a bank chat room (limit/offset/from_date/to_date).""", + EmptyBody, EmptyBody, + List($AuthenticatedUserIsRequired, $BankNotFound, + ChatRoomNotFound, NotChatRoomParticipant, UnknownError), + apiTagChat :: Nil, None, + http4sPartialFunction = Some(getBankChatMessages)) + resourceDocs += ResourceDoc( + null, implementedInApiVersion, nameOf(getSystemChatMessages), "GET", + "/chat-rooms/CHAT_ROOM_ID/messages", "Get System Chat Messages", + """Get messages in a system chat room (limit/offset/from_date/to_date).""", + EmptyBody, EmptyBody, + List($AuthenticatedUserIsRequired, + ChatRoomNotFound, NotChatRoomParticipant, UnknownError), + apiTagChat :: Nil, None, + http4sPartialFunction = Some(getSystemChatMessages)) + resourceDocs += ResourceDoc( + null, implementedInApiVersion, nameOf(getBankChatMessage), "GET", + "/banks/BANK_ID/chat-rooms/CHAT_ROOM_ID/messages/CHAT_MESSAGE_ID", + "Get Bank Chat Message", + """Get a specific message in a bank chat room.""", + EmptyBody, EmptyBody, + List($AuthenticatedUserIsRequired, $BankNotFound, + ChatRoomNotFound, NotChatRoomParticipant, ChatMessageNotFound, UnknownError), + apiTagChat :: Nil, None, + http4sPartialFunction = Some(getBankChatMessage)) + resourceDocs += ResourceDoc( + null, implementedInApiVersion, nameOf(getSystemChatMessage), "GET", + "/chat-rooms/CHAT_ROOM_ID/messages/CHAT_MESSAGE_ID", "Get System Chat Message", + """Get a specific message in a system chat room.""", + EmptyBody, EmptyBody, + List($AuthenticatedUserIsRequired, + ChatRoomNotFound, NotChatRoomParticipant, ChatMessageNotFound, UnknownError), + apiTagChat :: Nil, None, + http4sPartialFunction = Some(getSystemChatMessage)) + resourceDocs += ResourceDoc( + null, implementedInApiVersion, nameOf(editBankChatMessage), "PUT", + "/banks/BANK_ID/chat-rooms/CHAT_ROOM_ID/messages/CHAT_MESSAGE_ID", + "Edit Bank Chat Message", + """Edit a message in a bank chat room (sender only).""", + EmptyBody, EmptyBody, + List($AuthenticatedUserIsRequired, $BankNotFound, InvalidJsonFormat, + ChatRoomNotFound, NotChatRoomParticipant, ChatMessageNotFound, + CannotEditOthersMessage, UnknownError), + apiTagChat :: Nil, None, + http4sPartialFunction = Some(editBankChatMessage)) + resourceDocs += ResourceDoc( + null, implementedInApiVersion, nameOf(editSystemChatMessage), "PUT", + "/chat-rooms/CHAT_ROOM_ID/messages/CHAT_MESSAGE_ID", "Edit System Chat Message", + """Edit a message in a system chat room (sender only).""", + EmptyBody, EmptyBody, + List($AuthenticatedUserIsRequired, InvalidJsonFormat, + ChatRoomNotFound, NotChatRoomParticipant, ChatMessageNotFound, + CannotEditOthersMessage, UnknownError), + apiTagChat :: Nil, None, + http4sPartialFunction = Some(editSystemChatMessage)) + resourceDocs += ResourceDoc( + null, implementedInApiVersion, nameOf(deleteBankChatMessage), "DELETE", + "/banks/BANK_ID/chat-rooms/CHAT_ROOM_ID/messages/CHAT_MESSAGE_ID", + "Delete Bank Chat Message", + """Soft-delete a chat message (sender or participant with can_delete_message).""", + EmptyBody, EmptyBody, + List($AuthenticatedUserIsRequired, $BankNotFound, + ChatRoomNotFound, NotChatRoomParticipant, ChatMessageNotFound, + CannotDeleteMessage, UnknownError), + apiTagChat :: Nil, None, + http4sPartialFunction = Some(deleteBankChatMessage)) + resourceDocs += ResourceDoc( + null, implementedInApiVersion, nameOf(deleteSystemChatMessage), "DELETE", + "/chat-rooms/CHAT_ROOM_ID/messages/CHAT_MESSAGE_ID", "Delete System Chat Message", + """Soft-delete a chat message in a system chat room.""", + EmptyBody, EmptyBody, + List($AuthenticatedUserIsRequired, + ChatRoomNotFound, NotChatRoomParticipant, ChatMessageNotFound, + CannotDeleteMessage, UnknownError), + apiTagChat :: Nil, None, + http4sPartialFunction = Some(deleteSystemChatMessage)) + } + initChatMessageResourceDocs() + + // ─── Phase 2: Chat threads + reactions + typing (14 endpoints) ──────── + + lazy val getBankThreadReplies: HttpRoutes[IO] = HttpRoutes.of[IO] { + case req @ GET -> `prefixPath` / "banks" / _ / "chat-rooms" / chatRoomId / "messages" / chatMessageId / "thread" => + EndpointHelpers.withUserAndBank(req) { (user, _, cc) => + for { + roomBox <- Future(code.chat.ChatRoomTrait.chatRoomProvider.vend.getChatRoom(chatRoomId)) + _ <- Future(unboxFullOrFail(roomBox, Some(cc), ChatRoomNotFound, 404)) + partBox <- Future(code.chat.ChatPermissions.isParticipant(chatRoomId, user.userId)) + _ <- Future(unboxFullOrFail(partBox, Some(cc), NotChatRoomParticipant, 403)) + msgBox <- Future(code.chat.ChatMessageTrait.chatMessageProvider.vend.getMessage(chatMessageId)) + _ <- Future(unboxFullOrFail(msgBox, Some(cc), ChatMessageNotFound, 404)) + repliesBox <- Future(code.chat.ChatMessageTrait.chatMessageProvider.vend.getThreadReplies(chatMessageId)) + replies <- Future(unboxFullOrFail(repliesBox, Some(cc), + s"$UnknownError Cannot get thread replies", 400)) + allReactions <- Future { + replies.map { msg => + val r = code.chat.ReactionTrait.reactionProvider.vend.getReactions(msg.chatMessageId).openOr(List.empty) + msg.chatMessageId -> r + }.toMap + } + } yield JSONFactory600.createChatMessagesJson(replies, allReactions) + } + } + + lazy val getSystemThreadReplies: HttpRoutes[IO] = HttpRoutes.of[IO] { + case req @ GET -> `prefixPath` / "chat-rooms" / chatRoomId / "messages" / chatMessageId / "thread" => + EndpointHelpers.withUser(req) { (user, cc) => + for { + roomBox <- Future(code.chat.ChatRoomTrait.chatRoomProvider.vend.getChatRoom(chatRoomId)) + _ <- Future(unboxFullOrFail(roomBox, Some(cc), ChatRoomNotFound, 404)) + partBox <- Future(code.chat.ChatPermissions.isParticipant(chatRoomId, user.userId)) + _ <- Future(unboxFullOrFail(partBox, Some(cc), NotChatRoomParticipant, 403)) + msgBox <- Future(code.chat.ChatMessageTrait.chatMessageProvider.vend.getMessage(chatMessageId)) + _ <- Future(unboxFullOrFail(msgBox, Some(cc), ChatMessageNotFound, 404)) + repliesBox <- Future(code.chat.ChatMessageTrait.chatMessageProvider.vend.getThreadReplies(chatMessageId)) + replies <- Future(unboxFullOrFail(repliesBox, Some(cc), + s"$UnknownError Cannot get thread replies", 400)) + allReactions <- Future { + replies.map { msg => + val r = code.chat.ReactionTrait.reactionProvider.vend.getReactions(msg.chatMessageId).openOr(List.empty) + msg.chatMessageId -> r + }.toMap + } + } yield JSONFactory600.createChatMessagesJson(replies, allReactions) + } + } + + lazy val replyInBankThread: HttpRoutes[IO] = HttpRoutes.of[IO] { + case req @ POST -> `prefixPath` / "banks" / _ / "chat-rooms" / chatRoomId / "messages" / chatMessageId / "thread" => + EndpointHelpers.executeFutureCreated(req) { + implicit val cc: CallContext = req.callContext + val rawBody = cc.httpBody.getOrElse("") + val u = cc.user.openOrThrowException("User not found in CallContext") + for { + postJson <- NewStyle.function.tryons( + s"$InvalidJsonFormat The Json body should be the PostChatMessageJsonV600", 400, Some(cc)) { + net.liftweb.json.parse(rawBody).extract[PostChatMessageJsonV600] + } + roomBox <- Future(code.chat.ChatRoomTrait.chatRoomProvider.vend.getChatRoom(chatRoomId)) + room <- Future(unboxFullOrFail(roomBox, Some(cc), ChatRoomNotFound, 404)) + partBox <- Future(code.chat.ChatPermissions.isParticipant(chatRoomId, u.userId)) + _ <- Future(unboxFullOrFail(partBox, Some(cc), NotChatRoomParticipant, 403)) + _ <- Helper.booleanToFuture(ChatRoomIsArchived, cc = Some(cc)) { !room.isArchived } + parentBox <- Future(code.chat.ChatMessageTrait.chatMessageProvider.vend.getMessage(chatMessageId)) + _ <- Future(unboxFullOrFail(parentBox, Some(cc), ChatMessageNotFound, 404)) + msgBox <- Future(code.chat.ChatMessageTrait.chatMessageProvider.vend.createMessage( + chatRoomId, u.userId, "", postJson.content, + postJson.message_type.getOrElse("text"), + postJson.mentioned_user_ids.getOrElse(List.empty), + postJson.reply_to_message_id.getOrElse(""), + chatMessageId)) + msg <- Future(unboxFullOrFail(msgBox, Some(cc), + s"$UnknownError Cannot send thread reply", 400)) + } yield { + code.chat.ChatEventPublisher.afterCreate(msg, u.name, u.provider, "") + JSONFactory600.createChatMessageJson(msg, List.empty) + } + } + } + + lazy val replyInSystemThread: HttpRoutes[IO] = HttpRoutes.of[IO] { + case req @ POST -> `prefixPath` / "chat-rooms" / chatRoomId / "messages" / chatMessageId / "thread" => + EndpointHelpers.executeFutureCreated(req) { + implicit val cc: CallContext = req.callContext + val rawBody = cc.httpBody.getOrElse("") + val u = cc.user.openOrThrowException("User not found in CallContext") + for { + postJson <- NewStyle.function.tryons( + s"$InvalidJsonFormat The Json body should be the PostChatMessageJsonV600", 400, Some(cc)) { + net.liftweb.json.parse(rawBody).extract[PostChatMessageJsonV600] + } + roomBox <- Future(code.chat.ChatRoomTrait.chatRoomProvider.vend.getChatRoom(chatRoomId)) + room <- Future(unboxFullOrFail(roomBox, Some(cc), ChatRoomNotFound, 404)) + partBox <- Future(code.chat.ChatPermissions.isParticipant(chatRoomId, u.userId)) + _ <- Future(unboxFullOrFail(partBox, Some(cc), NotChatRoomParticipant, 403)) + _ <- Helper.booleanToFuture(ChatRoomIsArchived, cc = Some(cc)) { !room.isArchived } + parentBox <- Future(code.chat.ChatMessageTrait.chatMessageProvider.vend.getMessage(chatMessageId)) + _ <- Future(unboxFullOrFail(parentBox, Some(cc), ChatMessageNotFound, 404)) + msgBox <- Future(code.chat.ChatMessageTrait.chatMessageProvider.vend.createMessage( + chatRoomId, u.userId, "", postJson.content, + postJson.message_type.getOrElse("text"), + postJson.mentioned_user_ids.getOrElse(List.empty), + postJson.reply_to_message_id.getOrElse(""), + chatMessageId)) + msg <- Future(unboxFullOrFail(msgBox, Some(cc), + s"$UnknownError Cannot send thread reply", 400)) + } yield { + code.chat.ChatEventPublisher.afterCreate(msg, u.name, u.provider, "") + JSONFactory600.createChatMessageJson(msg, List.empty) + } + } + } + + lazy val addBankReaction: HttpRoutes[IO] = HttpRoutes.of[IO] { + case req @ POST -> `prefixPath` / "banks" / _ / "chat-rooms" / chatRoomId / "messages" / chatMessageId / "reactions" => + EndpointHelpers.executeFutureCreated(req) { + implicit val cc: CallContext = req.callContext + val rawBody = cc.httpBody.getOrElse("") + val u = cc.user.openOrThrowException("User not found in CallContext") + for { + postJson <- NewStyle.function.tryons( + s"$InvalidJsonFormat The Json body should be the PostReactionJsonV600", 400, Some(cc)) { + net.liftweb.json.parse(rawBody).extract[PostReactionJsonV600] + } + roomBox <- Future(code.chat.ChatRoomTrait.chatRoomProvider.vend.getChatRoom(chatRoomId)) + _ <- Future(unboxFullOrFail(roomBox, Some(cc), ChatRoomNotFound, 404)) + partBox <- Future(code.chat.ChatPermissions.isParticipant(chatRoomId, u.userId)) + _ <- Future(unboxFullOrFail(partBox, Some(cc), NotChatRoomParticipant, 403)) + msgBox <- Future(code.chat.ChatMessageTrait.chatMessageProvider.vend.getMessage(chatMessageId)) + _ <- Future(unboxFullOrFail(msgBox, Some(cc), ChatMessageNotFound, 404)) + existing <- Future(code.chat.ReactionTrait.reactionProvider.vend.getReaction(chatMessageId, u.userId, postJson.emoji)) + _ <- Helper.booleanToFuture(ReactionAlreadyExists, 409, Some(cc)) { existing.isEmpty } + reactBox <- Future(code.chat.ReactionTrait.reactionProvider.vend.addReaction(chatMessageId, u.userId, postJson.emoji)) + reaction <- Future(unboxFullOrFail(reactBox, Some(cc), s"$UnknownError Cannot add reaction", 400)) + } yield { + code.chat.ChatEventPublisher.afterReactionAdd(chatRoomId, chatMessageId, postJson.emoji, u.userId, u.name, u.provider) + JSONFactory600.createReactionJson(reaction) + } + } + } + + lazy val addSystemReaction: HttpRoutes[IO] = HttpRoutes.of[IO] { + case req @ POST -> `prefixPath` / "chat-rooms" / chatRoomId / "messages" / chatMessageId / "reactions" => + EndpointHelpers.executeFutureCreated(req) { + implicit val cc: CallContext = req.callContext + val rawBody = cc.httpBody.getOrElse("") + val u = cc.user.openOrThrowException("User not found in CallContext") + for { + postJson <- NewStyle.function.tryons( + s"$InvalidJsonFormat The Json body should be the PostReactionJsonV600", 400, Some(cc)) { + net.liftweb.json.parse(rawBody).extract[PostReactionJsonV600] + } + roomBox <- Future(code.chat.ChatRoomTrait.chatRoomProvider.vend.getChatRoom(chatRoomId)) + _ <- Future(unboxFullOrFail(roomBox, Some(cc), ChatRoomNotFound, 404)) + partBox <- Future(code.chat.ChatPermissions.isParticipant(chatRoomId, u.userId)) + _ <- Future(unboxFullOrFail(partBox, Some(cc), NotChatRoomParticipant, 403)) + msgBox <- Future(code.chat.ChatMessageTrait.chatMessageProvider.vend.getMessage(chatMessageId)) + _ <- Future(unboxFullOrFail(msgBox, Some(cc), ChatMessageNotFound, 404)) + existing <- Future(code.chat.ReactionTrait.reactionProvider.vend.getReaction(chatMessageId, u.userId, postJson.emoji)) + _ <- Helper.booleanToFuture(ReactionAlreadyExists, 409, Some(cc)) { existing.isEmpty } + reactBox <- Future(code.chat.ReactionTrait.reactionProvider.vend.addReaction(chatMessageId, u.userId, postJson.emoji)) + reaction <- Future(unboxFullOrFail(reactBox, Some(cc), s"$UnknownError Cannot add reaction", 400)) + } yield { + code.chat.ChatEventPublisher.afterReactionAdd(chatRoomId, chatMessageId, postJson.emoji, u.userId, u.name, u.provider) + JSONFactory600.createReactionJson(reaction) + } + } + } + + lazy val removeBankReaction: HttpRoutes[IO] = HttpRoutes.of[IO] { + case req @ DELETE -> `prefixPath` / "banks" / _ / "chat-rooms" / chatRoomId / "messages" / chatMessageId / "reactions" / emoji => + EndpointHelpers.executeDelete(req) { cc => + val u = cc.user.openOrThrowException("User not found in CallContext") + val decodedEmoji = java.net.URLDecoder.decode(emoji, java.nio.charset.StandardCharsets.UTF_8.name()) + for { + roomBox <- Future(code.chat.ChatRoomTrait.chatRoomProvider.vend.getChatRoom(chatRoomId)) + _ <- Future(unboxFullOrFail(roomBox, Some(cc), ChatRoomNotFound, 404)) + partBox <- Future(code.chat.ChatPermissions.isParticipant(chatRoomId, u.userId)) + _ <- Future(unboxFullOrFail(partBox, Some(cc), NotChatRoomParticipant, 403)) + msgBox <- Future(code.chat.ChatMessageTrait.chatMessageProvider.vend.getMessage(chatMessageId)) + _ <- Future(unboxFullOrFail(msgBox, Some(cc), ChatMessageNotFound, 404)) + existing <- Future(code.chat.ReactionTrait.reactionProvider.vend.getReaction(chatMessageId, u.userId, decodedEmoji)) + _ <- Helper.booleanToFuture(ReactionNotFound, cc = Some(cc)) { existing.isDefined } + delBox <- Future(code.chat.ReactionTrait.reactionProvider.vend.removeReaction(chatMessageId, u.userId, decodedEmoji)) + _ <- Future(unboxFullOrFail(delBox, Some(cc), s"$UnknownError Cannot remove reaction", 400)) + } yield { + code.chat.ChatEventPublisher.afterReactionRemove(chatRoomId, chatMessageId, decodedEmoji, u.userId, u.name, u.provider) + () + } + } + } + + lazy val removeSystemReaction: HttpRoutes[IO] = HttpRoutes.of[IO] { + case req @ DELETE -> `prefixPath` / "chat-rooms" / chatRoomId / "messages" / chatMessageId / "reactions" / emoji => + EndpointHelpers.executeDelete(req) { cc => + val u = cc.user.openOrThrowException("User not found in CallContext") + val decodedEmoji = java.net.URLDecoder.decode(emoji, java.nio.charset.StandardCharsets.UTF_8.name()) + for { + roomBox <- Future(code.chat.ChatRoomTrait.chatRoomProvider.vend.getChatRoom(chatRoomId)) + _ <- Future(unboxFullOrFail(roomBox, Some(cc), ChatRoomNotFound, 404)) + partBox <- Future(code.chat.ChatPermissions.isParticipant(chatRoomId, u.userId)) + _ <- Future(unboxFullOrFail(partBox, Some(cc), NotChatRoomParticipant, 403)) + msgBox <- Future(code.chat.ChatMessageTrait.chatMessageProvider.vend.getMessage(chatMessageId)) + _ <- Future(unboxFullOrFail(msgBox, Some(cc), ChatMessageNotFound, 404)) + existing <- Future(code.chat.ReactionTrait.reactionProvider.vend.getReaction(chatMessageId, u.userId, decodedEmoji)) + _ <- Helper.booleanToFuture(ReactionNotFound, cc = Some(cc)) { existing.isDefined } + delBox <- Future(code.chat.ReactionTrait.reactionProvider.vend.removeReaction(chatMessageId, u.userId, decodedEmoji)) + _ <- Future(unboxFullOrFail(delBox, Some(cc), s"$UnknownError Cannot remove reaction", 400)) + } yield { + code.chat.ChatEventPublisher.afterReactionRemove(chatRoomId, chatMessageId, decodedEmoji, u.userId, u.name, u.provider) + () + } + } + } + + lazy val getBankReactions: HttpRoutes[IO] = HttpRoutes.of[IO] { + case req @ GET -> `prefixPath` / "banks" / _ / "chat-rooms" / chatRoomId / "messages" / chatMessageId / "reactions" => + EndpointHelpers.withUserAndBank(req) { (user, _, cc) => + for { + roomBox <- Future(code.chat.ChatRoomTrait.chatRoomProvider.vend.getChatRoom(chatRoomId)) + _ <- Future(unboxFullOrFail(roomBox, Some(cc), ChatRoomNotFound, 404)) + partBox <- Future(code.chat.ChatPermissions.isParticipant(chatRoomId, user.userId)) + _ <- Future(unboxFullOrFail(partBox, Some(cc), NotChatRoomParticipant, 403)) + msgBox <- Future(code.chat.ChatMessageTrait.chatMessageProvider.vend.getMessage(chatMessageId)) + _ <- Future(unboxFullOrFail(msgBox, Some(cc), ChatMessageNotFound, 404)) + reactionsBox <- Future(code.chat.ReactionTrait.reactionProvider.vend.getReactions(chatMessageId)) + reactions <- Future(unboxFullOrFail(reactionsBox, Some(cc), + s"$UnknownError Cannot get reactions", 400)) + } yield JSONFactory600.createReactionsJson(reactions) + } + } + + lazy val getSystemReactions: HttpRoutes[IO] = HttpRoutes.of[IO] { + case req @ GET -> `prefixPath` / "chat-rooms" / chatRoomId / "messages" / chatMessageId / "reactions" => + EndpointHelpers.withUser(req) { (user, cc) => + for { + roomBox <- Future(code.chat.ChatRoomTrait.chatRoomProvider.vend.getChatRoom(chatRoomId)) + _ <- Future(unboxFullOrFail(roomBox, Some(cc), ChatRoomNotFound, 404)) + partBox <- Future(code.chat.ChatPermissions.isParticipant(chatRoomId, user.userId)) + _ <- Future(unboxFullOrFail(partBox, Some(cc), NotChatRoomParticipant, 403)) + msgBox <- Future(code.chat.ChatMessageTrait.chatMessageProvider.vend.getMessage(chatMessageId)) + _ <- Future(unboxFullOrFail(msgBox, Some(cc), ChatMessageNotFound, 404)) + reactionsBox <- Future(code.chat.ReactionTrait.reactionProvider.vend.getReactions(chatMessageId)) + reactions <- Future(unboxFullOrFail(reactionsBox, Some(cc), + s"$UnknownError Cannot get reactions", 400)) + } yield JSONFactory600.createReactionsJson(reactions) + } + } + + lazy val signalBankTyping: HttpRoutes[IO] = HttpRoutes.of[IO] { + case req @ PUT -> `prefixPath` / "banks" / _ / "chat-rooms" / chatRoomId / "typing-indicators" => + EndpointHelpers.executeAndRespond(req) { implicit cc => + val u = cc.user.openOrThrowException("User not found in CallContext") + for { + roomBox <- Future(code.chat.ChatRoomTrait.chatRoomProvider.vend.getChatRoom(chatRoomId)) + _ <- Future(unboxFullOrFail(roomBox, Some(cc), ChatRoomNotFound, 404)) + partBox <- Future(code.chat.ChatPermissions.isParticipant(chatRoomId, u.userId)) + _ <- Future(unboxFullOrFail(partBox, Some(cc), NotChatRoomParticipant, 403)) + _ <- Future { + val key = s"chat_typing_${chatRoomId}_${u.userId}" + Redis.use(code.api.JedisMethod.SET, key, Some(5), Some("1")) + code.chat.ChatEventPublisher.afterTyping(chatRoomId, u.userId, u.name, u.provider, true) + } + } yield "" + } + } + + lazy val signalSystemTyping: HttpRoutes[IO] = HttpRoutes.of[IO] { + case req @ PUT -> `prefixPath` / "chat-rooms" / chatRoomId / "typing-indicators" => + EndpointHelpers.executeAndRespond(req) { implicit cc => + val u = cc.user.openOrThrowException("User not found in CallContext") + for { + roomBox <- Future(code.chat.ChatRoomTrait.chatRoomProvider.vend.getChatRoom(chatRoomId)) + _ <- Future(unboxFullOrFail(roomBox, Some(cc), ChatRoomNotFound, 404)) + partBox <- Future(code.chat.ChatPermissions.isParticipant(chatRoomId, u.userId)) + _ <- Future(unboxFullOrFail(partBox, Some(cc), NotChatRoomParticipant, 403)) + _ <- Future { + val key = s"chat_typing_${chatRoomId}_${u.userId}" + Redis.use(code.api.JedisMethod.SET, key, Some(5), Some("1")) + code.chat.ChatEventPublisher.afterTyping(chatRoomId, u.userId, u.name, u.provider, true) + } + } yield "" + } + } + + lazy val getBankTypingUsers: HttpRoutes[IO] = HttpRoutes.of[IO] { + case req @ GET -> `prefixPath` / "banks" / _ / "chat-rooms" / chatRoomId / "typing-indicators" => + EndpointHelpers.withUserAndBank(req) { (user, _, cc) => + for { + roomBox <- Future(code.chat.ChatRoomTrait.chatRoomProvider.vend.getChatRoom(chatRoomId)) + _ <- Future(unboxFullOrFail(roomBox, Some(cc), ChatRoomNotFound, 404)) + partBox <- Future(code.chat.ChatPermissions.isParticipant(chatRoomId, user.userId)) + _ <- Future(unboxFullOrFail(partBox, Some(cc), NotChatRoomParticipant, 403)) + participantsBox <- Future(code.chat.ParticipantTrait.participantProvider.vend.getParticipants(chatRoomId)) + participants <- Future(unboxFullOrFail(participantsBox, Some(cc), + s"$UnknownError Cannot get participants", 400)) + typingUsers <- Future { + participants.filter(_.userId.nonEmpty).flatMap { p => + val key = s"chat_typing_${chatRoomId}_${p.userId}" + try { + Redis.use(code.api.JedisMethod.GET, key) match { + case Some(_) => + val tu = code.users.Users.users.vend.getUserByUserId(p.userId) + Some(TypingUserJsonV600(p.userId, + tu.map(_.name).getOrElse(""), + tu.map(_.provider).getOrElse(""))) + case None => None + } + } catch { case _: Throwable => None } + } + } + } yield TypingUsersJsonV600(typingUsers) + } + } + + lazy val getSystemTypingUsers: HttpRoutes[IO] = HttpRoutes.of[IO] { + case req @ GET -> `prefixPath` / "chat-rooms" / chatRoomId / "typing-indicators" => + EndpointHelpers.withUser(req) { (user, cc) => + for { + roomBox <- Future(code.chat.ChatRoomTrait.chatRoomProvider.vend.getChatRoom(chatRoomId)) + _ <- Future(unboxFullOrFail(roomBox, Some(cc), ChatRoomNotFound, 404)) + partBox <- Future(code.chat.ChatPermissions.isParticipant(chatRoomId, user.userId)) + _ <- Future(unboxFullOrFail(partBox, Some(cc), NotChatRoomParticipant, 403)) + participantsBox <- Future(code.chat.ParticipantTrait.participantProvider.vend.getParticipants(chatRoomId)) + participants <- Future(unboxFullOrFail(participantsBox, Some(cc), + s"$UnknownError Cannot get participants", 400)) + typingUsers <- Future { + participants.filter(_.userId.nonEmpty).flatMap { p => + val key = s"chat_typing_${chatRoomId}_${p.userId}" + try { + Redis.use(code.api.JedisMethod.GET, key) match { + case Some(_) => + val tu = code.users.Users.users.vend.getUserByUserId(p.userId) + Some(TypingUserJsonV600(p.userId, + tu.map(_.name).getOrElse(""), + tu.map(_.provider).getOrElse(""))) + case None => None + } + } catch { case _: Throwable => None } + } + } + } yield TypingUsersJsonV600(typingUsers) + } + } + + private def initChatThreadReactionTypingResourceDocs(): Unit = { + resourceDocs += ResourceDoc( + null, implementedInApiVersion, nameOf(getBankThreadReplies), "GET", + "/banks/BANK_ID/chat-rooms/CHAT_ROOM_ID/messages/CHAT_MESSAGE_ID/thread", + "Get Bank Thread Replies", + """Get replies in a message thread (bank chat room).""", + EmptyBody, EmptyBody, + List($AuthenticatedUserIsRequired, $BankNotFound, + ChatRoomNotFound, NotChatRoomParticipant, ChatMessageNotFound, UnknownError), + apiTagChat :: Nil, None, + http4sPartialFunction = Some(getBankThreadReplies)) + resourceDocs += ResourceDoc( + null, implementedInApiVersion, nameOf(getSystemThreadReplies), "GET", + "/chat-rooms/CHAT_ROOM_ID/messages/CHAT_MESSAGE_ID/thread", "Get System Thread Replies", + """Get replies in a message thread (system chat room).""", + EmptyBody, EmptyBody, + List($AuthenticatedUserIsRequired, + ChatRoomNotFound, NotChatRoomParticipant, ChatMessageNotFound, UnknownError), + apiTagChat :: Nil, None, + http4sPartialFunction = Some(getSystemThreadReplies)) + resourceDocs += ResourceDoc( + null, implementedInApiVersion, nameOf(replyInBankThread), "POST", + "/banks/BANK_ID/chat-rooms/CHAT_ROOM_ID/messages/CHAT_MESSAGE_ID/thread", + "Reply In Bank Thread", + """Reply to a message in a bank-chat-room thread.""", + EmptyBody, EmptyBody, + List($AuthenticatedUserIsRequired, $BankNotFound, InvalidJsonFormat, + ChatRoomNotFound, NotChatRoomParticipant, ChatRoomIsArchived, ChatMessageNotFound, UnknownError), + apiTagChat :: Nil, None, + http4sPartialFunction = Some(replyInBankThread)) + resourceDocs += ResourceDoc( + null, implementedInApiVersion, nameOf(replyInSystemThread), "POST", + "/chat-rooms/CHAT_ROOM_ID/messages/CHAT_MESSAGE_ID/thread", "Reply In System Thread", + """Reply to a message in a system-chat-room thread.""", + EmptyBody, EmptyBody, + List($AuthenticatedUserIsRequired, InvalidJsonFormat, + ChatRoomNotFound, NotChatRoomParticipant, ChatRoomIsArchived, ChatMessageNotFound, UnknownError), + apiTagChat :: Nil, None, + http4sPartialFunction = Some(replyInSystemThread)) + resourceDocs += ResourceDoc( + null, implementedInApiVersion, nameOf(addBankReaction), "POST", + "/banks/BANK_ID/chat-rooms/CHAT_ROOM_ID/messages/CHAT_MESSAGE_ID/reactions", + "Add Bank Reaction", + """Add an emoji reaction to a message.""", + EmptyBody, EmptyBody, + List($AuthenticatedUserIsRequired, $BankNotFound, InvalidJsonFormat, + ChatRoomNotFound, NotChatRoomParticipant, ChatMessageNotFound, + ReactionAlreadyExists, UnknownError), + apiTagChat :: Nil, None, + http4sPartialFunction = Some(addBankReaction)) + resourceDocs += ResourceDoc( + null, implementedInApiVersion, nameOf(addSystemReaction), "POST", + "/chat-rooms/CHAT_ROOM_ID/messages/CHAT_MESSAGE_ID/reactions", "Add System Reaction", + """Add an emoji reaction to a message in a system chat room.""", + EmptyBody, EmptyBody, + List($AuthenticatedUserIsRequired, InvalidJsonFormat, + ChatRoomNotFound, NotChatRoomParticipant, ChatMessageNotFound, + ReactionAlreadyExists, UnknownError), + apiTagChat :: Nil, None, + http4sPartialFunction = Some(addSystemReaction)) + resourceDocs += ResourceDoc( + null, implementedInApiVersion, nameOf(removeBankReaction), "DELETE", + "/banks/BANK_ID/chat-rooms/CHAT_ROOM_ID/messages/CHAT_MESSAGE_ID/reactions/EMOJI_REACTION", + "Remove Bank Reaction", + """Remove your own reaction from a message.""", + EmptyBody, EmptyBody, + List($AuthenticatedUserIsRequired, $BankNotFound, + ChatRoomNotFound, NotChatRoomParticipant, ChatMessageNotFound, + ReactionNotFound, UnknownError), + apiTagChat :: Nil, None, + http4sPartialFunction = Some(removeBankReaction)) + resourceDocs += ResourceDoc( + null, implementedInApiVersion, nameOf(removeSystemReaction), "DELETE", + "/chat-rooms/CHAT_ROOM_ID/messages/CHAT_MESSAGE_ID/reactions/EMOJI_REACTION", + "Remove System Reaction", + """Remove your own reaction from a system-chat-room message.""", + EmptyBody, EmptyBody, + List($AuthenticatedUserIsRequired, + ChatRoomNotFound, NotChatRoomParticipant, ChatMessageNotFound, + ReactionNotFound, UnknownError), + apiTagChat :: Nil, None, + http4sPartialFunction = Some(removeSystemReaction)) + resourceDocs += ResourceDoc( + null, implementedInApiVersion, nameOf(getBankReactions), "GET", + "/banks/BANK_ID/chat-rooms/CHAT_ROOM_ID/messages/CHAT_MESSAGE_ID/reactions", + "Get Bank Reactions", + """List reactions on a message.""", + EmptyBody, EmptyBody, + List($AuthenticatedUserIsRequired, $BankNotFound, + ChatRoomNotFound, NotChatRoomParticipant, ChatMessageNotFound, UnknownError), + apiTagChat :: Nil, None, + http4sPartialFunction = Some(getBankReactions)) + resourceDocs += ResourceDoc( + null, implementedInApiVersion, nameOf(getSystemReactions), "GET", + "/chat-rooms/CHAT_ROOM_ID/messages/CHAT_MESSAGE_ID/reactions", "Get System Reactions", + """List reactions on a system-chat-room message.""", + EmptyBody, EmptyBody, + List($AuthenticatedUserIsRequired, + ChatRoomNotFound, NotChatRoomParticipant, ChatMessageNotFound, UnknownError), + apiTagChat :: Nil, None, + http4sPartialFunction = Some(getSystemReactions)) + resourceDocs += ResourceDoc( + null, implementedInApiVersion, nameOf(signalBankTyping), "PUT", + "/banks/BANK_ID/chat-rooms/CHAT_ROOM_ID/typing-indicators", "Signal Bank Typing", + """Signal that the current user is typing in a bank chat room (TTL 5s).""", + EmptyBody, EmptyBody, + List($AuthenticatedUserIsRequired, $BankNotFound, + ChatRoomNotFound, NotChatRoomParticipant, UnknownError), + apiTagChat :: Nil, None, + http4sPartialFunction = Some(signalBankTyping)) + resourceDocs += ResourceDoc( + null, implementedInApiVersion, nameOf(signalSystemTyping), "PUT", + "/chat-rooms/CHAT_ROOM_ID/typing-indicators", "Signal System Typing", + """Signal that the current user is typing in a system chat room (TTL 5s).""", + EmptyBody, EmptyBody, + List($AuthenticatedUserIsRequired, + ChatRoomNotFound, NotChatRoomParticipant, UnknownError), + apiTagChat :: Nil, None, + http4sPartialFunction = Some(signalSystemTyping)) + resourceDocs += ResourceDoc( + null, implementedInApiVersion, nameOf(getBankTypingUsers), "GET", + "/banks/BANK_ID/chat-rooms/CHAT_ROOM_ID/typing-indicators", "Get Bank Typing Users", + """List users currently typing in a bank chat room.""", + EmptyBody, EmptyBody, + List($AuthenticatedUserIsRequired, $BankNotFound, + ChatRoomNotFound, NotChatRoomParticipant, UnknownError), + apiTagChat :: Nil, None, + http4sPartialFunction = Some(getBankTypingUsers)) + resourceDocs += ResourceDoc( + null, implementedInApiVersion, nameOf(getSystemTypingUsers), "GET", + "/chat-rooms/CHAT_ROOM_ID/typing-indicators", "Get System Typing Users", + """List users currently typing in a system chat room.""", + EmptyBody, EmptyBody, + List($AuthenticatedUserIsRequired, + ChatRoomNotFound, NotChatRoomParticipant, UnknownError), + apiTagChat :: Nil, None, + http4sPartialFunction = Some(getSystemTypingUsers)) + } + initChatThreadReactionTypingResourceDocs() + + // ─── Phase 2: Signatory Panels (5 endpoints) ───────────────────────── + + lazy val createSignatoryPanel: HttpRoutes[IO] = HttpRoutes.of[IO] { + case req @ POST -> `prefixPath` / "banks" / _ / "mandates" / mandateId / "signatory-panels" => + EndpointHelpers.executeFutureCreated(req) { + implicit val cc: CallContext = req.callContext + val rawBody = cc.httpBody.getOrElse("") + for { + createJson <- NewStyle.function.tryons(InvalidJsonFormat, 400, Some(cc)) { + net.liftweb.json.parse(rawBody).extract[CreateSignatoryPanelJsonV600] + } + (panelBox, _) <- BankConnector.connector.vend.createSignatoryPanel( + mandateId, createJson.panel_name, createJson.description, + createJson.user_ids.mkString(","), Some(cc)) + .map(i => (i._1, i._2)) + panel <- Future(unboxFullOrFail(panelBox, Some(cc), "Could not create signatory panel")) + } yield JSONFactory600.createSignatoryPanelJsonV600(panel) + } + } + + lazy val getSignatoryPanels: HttpRoutes[IO] = HttpRoutes.of[IO] { + case req @ GET -> `prefixPath` / "banks" / _ / "mandates" / mandateId / "signatory-panels" => + EndpointHelpers.withUserAndBank(req) { (_, _, cc) => + for { + (panelsBox, _) <- BankConnector.connector.vend.getSignatoryPanelsByMandateId( + mandateId, Some(cc)).map(i => (i._1, i._2)) + panels <- Future(unboxFullOrFail(panelsBox, Some(cc), + s"Could not get signatory panels for mandate: $mandateId")) + } yield JSONFactory600.createSignatoryPanelsJsonV600(panels) + } + } + + lazy val getSignatoryPanel: HttpRoutes[IO] = HttpRoutes.of[IO] { + case req @ GET -> `prefixPath` / "banks" / _ / "mandates" / _ / "signatory-panels" / panelId => + EndpointHelpers.withUserAndBank(req) { (_, _, cc) => + for { + (panelBox, _) <- BankConnector.connector.vend.getSignatoryPanelById( + panelId, Some(cc)).map(i => (i._1, i._2)) + panel <- Future(unboxFullOrFail(panelBox, Some(cc), + s"Signatory panel not found. Panel ID: $panelId", 404)) + } yield JSONFactory600.createSignatoryPanelJsonV600(panel) + } + } + + lazy val updateSignatoryPanel: HttpRoutes[IO] = HttpRoutes.of[IO] { + case req @ PUT -> `prefixPath` / "banks" / _ / "mandates" / _ / "signatory-panels" / panelId => + EndpointHelpers.executeAndRespond(req) { implicit cc => + val rawBody = cc.httpBody.getOrElse("") + for { + updateJson <- NewStyle.function.tryons(InvalidJsonFormat, 400, Some(cc)) { + net.liftweb.json.parse(rawBody).extract[UpdateSignatoryPanelJsonV600] + } + (panelBox, _) <- BankConnector.connector.vend.updateSignatoryPanel( + panelId, updateJson.panel_name, updateJson.description, + updateJson.user_ids.mkString(","), Some(cc)) + .map(i => (i._1, i._2)) + panel <- Future(unboxFullOrFail(panelBox, Some(cc), + s"Could not update signatory panel. Panel ID: $panelId")) + } yield JSONFactory600.createSignatoryPanelJsonV600(panel) + } + } + + lazy val deleteSignatoryPanel: HttpRoutes[IO] = HttpRoutes.of[IO] { + case req @ DELETE -> `prefixPath` / "banks" / _ / "mandates" / _ / "signatory-panels" / panelId => + EndpointHelpers.executeDelete(req) { cc => + for { + (delBox, _) <- BankConnector.connector.vend.deleteSignatoryPanel( + panelId, Some(cc)).map(i => (i._1, i._2)) + _ <- Future(unboxFullOrFail(delBox, Some(cc), + s"Could not delete signatory panel. Panel ID: $panelId")) + } yield () + } + } + + private def initSignatoryPanelResourceDocs(): Unit = { + resourceDocs += ResourceDoc( + null, implementedInApiVersion, nameOf(createSignatoryPanel), "POST", + "/banks/BANK_ID/mandates/MANDATE_ID/signatory-panels", "Create Signatory Panel", + """Create a new signatory panel for a mandate.""", + EmptyBody, EmptyBody, + List($AuthenticatedUserIsRequired, UserHasMissingRoles, $BankNotFound, + InvalidJsonFormat, UnknownError), + apiTagMandate :: Nil, + Some(canCreateSignatoryPanel :: Nil), + http4sPartialFunction = Some(createSignatoryPanel)) + resourceDocs += ResourceDoc( + null, implementedInApiVersion, nameOf(getSignatoryPanels), "GET", + "/banks/BANK_ID/mandates/MANDATE_ID/signatory-panels", "Get Signatory Panels", + """Get all signatory panels for a mandate.""", + EmptyBody, EmptyBody, + List($AuthenticatedUserIsRequired, UserHasMissingRoles, $BankNotFound, UnknownError), + apiTagMandate :: Nil, + Some(canGetSignatoryPanel :: Nil), + http4sPartialFunction = Some(getSignatoryPanels)) + resourceDocs += ResourceDoc( + null, implementedInApiVersion, nameOf(getSignatoryPanel), "GET", + "/banks/BANK_ID/mandates/MANDATE_ID/signatory-panels/PANEL_ID", "Get Signatory Panel", + """Get a specific signatory panel.""", + EmptyBody, EmptyBody, + List($AuthenticatedUserIsRequired, UserHasMissingRoles, $BankNotFound, UnknownError), + apiTagMandate :: Nil, + Some(canGetSignatoryPanel :: Nil), + http4sPartialFunction = Some(getSignatoryPanel)) + resourceDocs += ResourceDoc( + null, implementedInApiVersion, nameOf(updateSignatoryPanel), "PUT", + "/banks/BANK_ID/mandates/MANDATE_ID/signatory-panels/PANEL_ID", "Update Signatory Panel", + """Update a signatory panel.""", + EmptyBody, EmptyBody, + List($AuthenticatedUserIsRequired, UserHasMissingRoles, $BankNotFound, + InvalidJsonFormat, UnknownError), + apiTagMandate :: Nil, + Some(canUpdateSignatoryPanel :: Nil), + http4sPartialFunction = Some(updateSignatoryPanel)) + resourceDocs += ResourceDoc( + null, implementedInApiVersion, nameOf(deleteSignatoryPanel), "DELETE", + "/banks/BANK_ID/mandates/MANDATE_ID/signatory-panels/PANEL_ID", "Delete Signatory Panel", + """Delete a signatory panel.""", + EmptyBody, EmptyBody, + List($AuthenticatedUserIsRequired, UserHasMissingRoles, $BankNotFound, UnknownError), + apiTagMandate :: Nil, + Some(canDeleteSignatoryPanel :: Nil), + http4sPartialFunction = Some(deleteSignatoryPanel)) + } + initSignatoryPanelResourceDocs() + + // ─── Phase 2: Auth/JWT/validation/transaction-request endpoints (7) ── + + lazy val validateUserEmail: HttpRoutes[IO] = HttpRoutes.of[IO] { + case req @ POST -> `prefixPath` / "users" / "email-validation" => + EndpointHelpers.executeAndRespond(req) { implicit cc => + val rawBody = cc.httpBody.getOrElse("") + for { + postedData <- NewStyle.function.tryons( + s"$InvalidJsonFormat The Json body should be the ValidateUserEmailJsonV600", 400, Some(cc)) { + net.liftweb.json.parse(rawBody).extract[JSONFactory600.ValidateUserEmailJsonV600] + } + token = postedData.token.trim + _ <- Helper.booleanToFuture(s"$InvalidJsonFormat Token cannot be empty", cc = Some(cc)) { + token.nonEmpty + } + uniqueId <- NewStyle.function.tryons( + s"$UserNotFoundByToken Invalid or expired validation token", 404, Some(cc)) { + val signedJWT = com.nimbusds.jwt.SignedJWT.parse(token) + val expiration = signedJWT.getJWTClaimsSet.getExpirationTime + if (expiration == null || expiration.before(new java.util.Date())) + throw new Exception("Token has expired") + if (!CertificateUtil.verifywtWithHmacProtection(token)) + throw new Exception("Invalid token signature") + signedJWT.getJWTClaimsSet.getSubject + } + authUser <- Future { + code.model.dataAccess.AuthUser.findUserByValidationToken(uniqueId) match { + case Full(u) => Full(u) + case Empty => Empty + case f: net.liftweb.common.Failure => f + } + } + user <- NewStyle.function.tryons( + s"$UserNotFoundByToken Invalid or expired validation token", 404, Some(cc)) { + authUser.openOrThrowException("User not found") + } + _ <- Helper.booleanToFuture(s"$UserAlreadyValidated User email is already validated", cc = Some(cc)) { + !user.validated.get + } + validatedUser <- Future(code.model.dataAccess.AuthUser.validateAndResetToken(user)) + _ <- Future(code.model.dataAccess.AuthUser.grantDefaultEntitlementsToAuthUser(validatedUser)) + } yield JSONFactory600.ValidateUserEmailResponseJsonV600( + user_id = validatedUser.user.obj.map(_.userId).getOrElse(""), + email = validatedUser.email.get, + username = validatedUser.username.get, + provider = validatedUser.provider.get, + validated = validatedUser.validated.get, + message = "Email validated successfully") + } + } + + lazy val resetPasswordComplete: HttpRoutes[IO] = HttpRoutes.of[IO] { + case req @ POST -> `prefixPath` / "users" / "password" => + EndpointHelpers.executeFutureCreated(req) { + implicit val cc: CallContext = req.callContext + val rawBody = cc.httpBody.getOrElse("") + for { + postedData <- NewStyle.function.tryons( + s"$InvalidJsonFormat The Json body should be the PostResetPasswordCompleteJsonV600", 400, Some(cc)) { + net.liftweb.json.parse(rawBody).extract[JSONFactory600.PostResetPasswordCompleteJsonV600] + } + token = postedData.token.trim + _ <- Helper.booleanToFuture(s"$InvalidJsonFormat Token cannot be empty", cc = Some(cc)) { + token.nonEmpty + } + _ <- Helper.booleanToFuture(InvalidStrongPasswordFormat, 400, Some(cc)) { + APIUtil.fullPasswordValidation(postedData.new_password) + } + _ <- Helper.booleanToFuture(s"$UnknownError Invalid or expired reset token", cc = Some(cc)) { + try CertificateUtil.verifywtWithHmacProtection(token) catch { case _: Exception => false } + } + uniqueId <- NewStyle.function.tryons( + s"$UnknownError Invalid or expired reset token", 400, Some(cc)) { + val signedJWT = com.nimbusds.jwt.SignedJWT.parse(token) + val expiration = signedJWT.getJWTClaimsSet.getExpirationTime + if (expiration == null || expiration.before(new java.util.Date())) + throw new Exception("Token has expired") + signedJWT.getJWTClaimsSet.getSubject + } + authUserBox <- Future(code.model.dataAccess.AuthUser.findUserByValidationToken(uniqueId)) + user <- NewStyle.function.tryons( + s"$UnknownError Invalid or expired reset token", 400, Some(cc)) { + authUserBox.openOrThrowException("User not found") + } + } yield { + user.password.set(postedData.new_password) + user.uniqueId.set(java.util.UUID.randomUUID().toString.replace("-", "")) + user.save + JSONFactory600.ResetPasswordCompleteResponseJsonV600("Password has been reset successfully.") + } + } + } + + lazy val resetPasswordUrlAnonymous: HttpRoutes[IO] = HttpRoutes.of[IO] { + case req @ POST -> `prefixPath` / "users" / "password-reset-url" => + EndpointHelpers.executeFutureCreated(req) { + implicit val cc: CallContext = req.callContext + val rawBody = cc.httpBody.getOrElse("") + for { + postedData <- NewStyle.function.tryons( + s"$InvalidJsonFormat The Json body should be the PostResetPasswordUrlAnonymousJsonV600", 400, Some(cc)) { + net.liftweb.json.parse(rawBody).extract[JSONFactory600.PostResetPasswordUrlAnonymousJsonV600] + } + } yield { + val authUserBox = code.model.dataAccess.AuthUser.find( + net.liftweb.mapper.By(code.model.dataAccess.AuthUser.username, postedData.username)) + (authUserBox, APIUtil.getPropsValue("portal_external_url")) match { + case (Full(u), Full(portalUrl)) if u.validated.get && u.email.get == postedData.email => + u.uniqueId.set(java.util.UUID.randomUUID().toString.replace("-", "")) + u.save + val expiryMinutes = APIUtil.getPropsAsIntValue("password_reset_token_expiry_minutes", 120) + val claimsSet = new com.nimbusds.jwt.JWTClaimsSet.Builder() + .subject(u.uniqueId.get) + .expirationTime(new java.util.Date(System.currentTimeMillis() + expiryMinutes * 60L * 1000L)) + .issueTime(new java.util.Date()).build() + val jwtToken = CertificateUtil.jwtWithHmacProtection(claimsSet) + val resetLink = portalUrl + "/reset-password/" + java.net.URLEncoder.encode(jwtToken, "UTF-8") + CommonsEmailWrapper.sendHtmlEmail(CommonsEmailWrapper.EmailContent( + from = code.model.dataAccess.AuthUser.emailFrom, + to = List(u.email.get), + bcc = code.model.dataAccess.AuthUser.bccEmail.toList, + subject = "Reset your password - " + u.username.get, + textContent = Some(s"Please use the following link to reset your password: $resetLink"), + htmlContent = Some(s"

Please use the following link to reset your password:

$resetLink

"))) + case _ => // do nothing — return same response to prevent user enumeration + } + JSONFactory600.ResetPasswordUrlAnonymousResponseJsonV600( + "If the account exists, a password reset email has been sent.") + } + } + } + + lazy val validateDynamicResourceDoc: HttpRoutes[IO] = HttpRoutes.of[IO] { + case req @ POST -> `prefixPath` / "management" / "dynamic-resource-docs" / "validate" => + EndpointHelpers.executeAndRespond(req) { implicit cc => + val rawBody = cc.httpBody.getOrElse("") + for { + body <- NewStyle.function.tryons( + s"$InvalidJsonFormat The Json body should be the JsonDynamicResourceDoc", 400, Some(cc)) { + net.liftweb.json.parse(rawBody).extract[code.dynamicResourceDoc.JsonDynamicResourceDoc] + } + _ <- Helper.booleanToFuture( + s"""$InvalidJsonFormat The request_verb must be one of ["POST", "PUT", "GET", "DELETE"]""", + cc = Some(cc)) { + Set("POST", "PUT", "GET", "DELETE").contains(body.requestVerb) + } + _ <- Helper.booleanToFuture( + s"""$InvalidJsonFormat When request_verb is "GET" or "DELETE", the example_request_body must be a blank String "" or just totally omit the field""", + cc = Some(cc)) { + (body.requestVerb, body.exampleRequestBody) match { + case ("GET" | "DELETE", Some(net.liftweb.json.JString(s))) => + org.apache.commons.lang3.StringUtils.isBlank(s) + case ("GET" | "DELETE", Some(rb)) => rb == net.liftweb.json.JNothing + case _ => true + } + } + } yield try { + code.api.dynamic.endpoint.helper.CompiledObjects( + body.exampleRequestBody, body.successResponseBody, body.methodBody).validateDependency() + ValidateDynamicResourceDocSuccessJsonV600( + valid = true, + message = "Dynamic Resource Doc method body is valid Scala and uses allowed dependencies.") + } catch { + case e: code.api.JsonResponseException => + val errorText = e.jsonResponse match { + case code.api.util.APIUtil.JsonResponseExtractor(msg, _) => msg + case _ => "" + } + ValidateDynamicResourceDocFailureJsonV600( + valid = false, error = errorText, message = DynamicResourceDocMethodDependency, + details = ValidateDynamicResourceDocErrorDetailsJsonV600(error_type = "DependencyError")) + case e: Exception => + ValidateDynamicResourceDocFailureJsonV600( + valid = false, error = Option(e.getMessage).getOrElse(""), message = DynamicCodeCompileFail, + details = ValidateDynamicResourceDocErrorDetailsJsonV600(error_type = "CompilationError")) + } + } + } + + // 4 transaction request types — all delegate to LocalMappedConnectorInternal + private def txReqDelegate(req: org.http4s.Request[IO], bankIdStr: String, accountIdStr: String, + viewIdStr: String, kind: String): IO[org.http4s.Response[IO]] = { + EndpointHelpers.executeFutureCreated(req) { + implicit val cc: CallContext = req.callContext + val rawBody = cc.httpBody.getOrElse("") + val bankId = BankId(bankIdStr) + val accountId = com.openbankproject.commons.model.AccountId(accountIdStr) + val viewId = ViewId(viewIdStr) + val txType = com.openbankproject.commons.model.TransactionRequestType(kind) + for { + json <- Future(net.liftweb.json.parse(rawBody)) + (resp, _) <- code.bankconnectors.LocalMappedConnectorInternal + .createTransactionRequest(bankId, accountId, viewId, txType, json) + } yield resp + } + } + + lazy val createTransactionRequestHold: HttpRoutes[IO] = HttpRoutes.of[IO] { + case req @ POST -> `prefixPath` / "banks" / bankIdStr / "accounts" / accountIdStr / viewIdStr / + "transaction-request-types" / "HOLD" / "transaction-requests" => + txReqDelegate(req, bankIdStr, accountIdStr, viewIdStr, "HOLD") + } + + lazy val createTransactionRequestCardano: HttpRoutes[IO] = HttpRoutes.of[IO] { + case req @ POST -> `prefixPath` / "banks" / bankIdStr / "accounts" / accountIdStr / viewIdStr / + "transaction-request-types" / "CARDANO" / "transaction-requests" => + txReqDelegate(req, bankIdStr, accountIdStr, viewIdStr, "CARDANO") + } + + lazy val createTransactionRequestEthereumeSendTransaction: HttpRoutes[IO] = HttpRoutes.of[IO] { + case req @ POST -> `prefixPath` / "banks" / bankIdStr / "accounts" / accountIdStr / viewIdStr / + "transaction-request-types" / "ETH_SEND_TRANSACTION" / "transaction-requests" => + txReqDelegate(req, bankIdStr, accountIdStr, viewIdStr, "ETH_SEND_TRANSACTION") + } + + lazy val createTransactionRequestEthSendRawTransaction: HttpRoutes[IO] = HttpRoutes.of[IO] { + case req @ POST -> `prefixPath` / "banks" / bankIdStr / "accounts" / accountIdStr / viewIdStr / + "transaction-request-types" / "ETH_SEND_RAW_TRANSACTION" / "transaction-requests" => + txReqDelegate(req, bankIdStr, accountIdStr, viewIdStr, "ETH_SEND_RAW_TRANSACTION") + } + + private def initAuthAndTxReqResourceDocs(): Unit = { + resourceDocs += ResourceDoc( + null, implementedInApiVersion, nameOf(validateUserEmail), "POST", + "/users/email-validation", "Validate User Email", + """Validate a user's email using a JWT token.""", + EmptyBody, EmptyBody, + List(InvalidJsonFormat, UserNotFoundByToken, UserAlreadyValidated, UnknownError), + apiTagUser :: Nil, None, + http4sPartialFunction = Some(validateUserEmail)) + resourceDocs += ResourceDoc( + null, implementedInApiVersion, nameOf(resetPasswordComplete), "POST", + "/users/password", "Complete Password Reset", + """Complete a password reset using a JWT token.""", + EmptyBody, EmptyBody, + List(InvalidJsonFormat, InvalidStrongPasswordFormat, UnknownError), + apiTagUser :: Nil, None, + http4sPartialFunction = Some(resetPasswordComplete)) + resourceDocs += ResourceDoc( + null, implementedInApiVersion, nameOf(resetPasswordUrlAnonymous), "POST", + "/users/password-reset-url", "Request Password Reset URL", + """Anonymous endpoint — generates and emails a password-reset link.""", + EmptyBody, EmptyBody, + List(InvalidJsonFormat, UnknownError), + apiTagUser :: Nil, None, + http4sPartialFunction = Some(resetPasswordUrlAnonymous)) + resourceDocs += ResourceDoc( + null, implementedInApiVersion, nameOf(validateDynamicResourceDoc), "POST", + "/management/dynamic-resource-docs/validate", "Validate Dynamic Resource Doc", + """Dry-run validation of a Dynamic Resource Doc (compile + dependency check).""", + EmptyBody, EmptyBody, + List($AuthenticatedUserIsRequired, UserHasMissingRoles, InvalidJsonFormat, UnknownError), + apiTagDynamicResourceDoc :: Nil, + Some(canCreateDynamicResourceDoc :: Nil), + http4sPartialFunction = Some(validateDynamicResourceDoc)) + val txReqErrors = List($AuthenticatedUserIsRequired, $BankNotFound, $BankAccountNotFound, + InsufficientAuthorisationToCreateTransactionRequest, InvalidTransactionRequestType, + InvalidJsonFormat, NotPositiveAmount, InvalidTransactionRequestCurrency, + TransactionDisabled, UnknownError) + val txReqTags = apiTagTransactionRequest :: apiTagPSD2PIS :: apiTagPsd2 :: Nil + resourceDocs += ResourceDoc( + null, implementedInApiVersion, nameOf(createTransactionRequestHold), "POST", + "/banks/BANK_ID/accounts/ACCOUNT_ID/VIEW_ID/transaction-request-types/HOLD/transaction-requests", + "Create Transaction Request (HOLD)", + """Create a HOLD transaction request.""", + EmptyBody, EmptyBody, txReqErrors, txReqTags, None, + http4sPartialFunction = Some(createTransactionRequestHold)) + resourceDocs += ResourceDoc( + null, implementedInApiVersion, nameOf(createTransactionRequestCardano), "POST", + "/banks/BANK_ID/accounts/ACCOUNT_ID/VIEW_ID/transaction-request-types/CARDANO/transaction-requests", + "Create Transaction Request (CARDANO)", + """Create a CARDANO transaction request.""", + EmptyBody, EmptyBody, txReqErrors, txReqTags, None, + http4sPartialFunction = Some(createTransactionRequestCardano)) + resourceDocs += ResourceDoc( + null, implementedInApiVersion, nameOf(createTransactionRequestEthereumeSendTransaction), "POST", + "/banks/BANK_ID/accounts/ACCOUNT_ID/owner/transaction-request-types/ETH_SEND_TRANSACTION/transaction-requests", + "Create Transaction Request (ETH_SEND_TRANSACTION)", + """Create an ETH_SEND_TRANSACTION transaction request.""", + EmptyBody, EmptyBody, txReqErrors, txReqTags, None, + http4sPartialFunction = Some(createTransactionRequestEthereumeSendTransaction)) + resourceDocs += ResourceDoc( + null, implementedInApiVersion, nameOf(createTransactionRequestEthSendRawTransaction), "POST", + "/banks/BANK_ID/accounts/ACCOUNT_ID/owner/transaction-request-types/ETH_SEND_RAW_TRANSACTION/transaction-requests", + "Create Transaction Request (ETH_SEND_RAW_TRANSACTION)", + """Create an ETH_SEND_RAW_TRANSACTION transaction request.""", + EmptyBody, EmptyBody, txReqErrors, txReqTags, None, + http4sPartialFunction = Some(createTransactionRequestEthSendRawTransaction)) + } + initAuthAndTxReqResourceDocs() + + // ─── Phase 2: User memberships, access listing, customer creation (4) ─ + + lazy val getUserGroupMemberships: HttpRoutes[IO] = HttpRoutes.of[IO] { + case req @ GET -> `prefixPath` / "users" / userId / "group-entitlements" => + EndpointHelpers.withUser(req) { (u, cc) => + for { + (_, _) <- NewStyle.function.findByUserId(userId, Some(cc)) + entitlements <- Future(code.entitlement.Entitlement.entitlement.vend.getEntitlementsByUserId(userId)) + groupEntitlements = entitlements.toOption.getOrElse(List.empty).filter(_.process == Some("GROUP_MEMBERSHIP")) + groupIds = groupEntitlements.flatMap(_.groupId).distinct + _ <- Future.sequence { + groupIds.flatMap { gid => + code.group.GroupTrait.group.vend.getGroup(gid).toOption.map { g => + g.bankId match { + case Some(bid) => + NewStyle.function.hasAtLeastOneEntitlement(bid, u.userId, + canGetUserGroupMembershipsAtOneBank :: canGetUserGroupMembershipsAtAllBanks :: Nil, Some(cc)) + case None => + NewStyle.function.hasEntitlement("", u.userId, canGetUserGroupMembershipsAtAllBanks, Some(cc)) + } + } + } + } + groups <- Future.sequence(groupIds.map(gid => + Future(code.group.GroupTrait.group.vend.getGroup(gid)))) + validGroups = groups.flatten + } yield { + val memberships = validGroups.map { g => + val grpEnts = groupEntitlements.filter(_.groupId.contains(g.groupId)).map(_.roleName).distinct + JSONFactory600.UserGroupMembershipJsonV600( + group_id = g.groupId, user_id = userId, bank_id = g.bankId, + group_name = g.groupName, list_of_entitlements = grpEnts) + } + JSONFactory600.UserGroupMembershipsJsonV600(group_entitlements = memberships) + } + } + } + + lazy val getUsersWithAccountAccess: HttpRoutes[IO] = HttpRoutes.of[IO] { + case req @ GET -> `prefixPath` / "banks" / bankIdStr / "accounts" / accountIdStr / "views" / viewIdStr / "users-with-access" => + EndpointHelpers.withBankAccount(req) { (_, _, cc) => + val bankId = BankId(bankIdStr) + val accountId = com.openbankproject.commons.model.AccountId(accountIdStr) + val viewId = ViewId(viewIdStr) + val bia = com.openbankproject.commons.model.BankIdAccountId(bankId, accountId) + for { + _ <- Future { + code.views.Views.views.vend.customViewFuture(viewId, bia).flatMap { + case Full(v) => Future.successful(Full(v)) + case _ => code.views.Views.views.vend.systemViewFuture(viewId) + } + }.flatten.map(unboxFullOrFail(_, Some(cc), s"$ViewNotFound Current ViewId is ${viewId.value}")) + permissions <- Future(code.views.Views.views.vend.permissions(bia)) + accountAccessUsers = permissions.flatMap { perm => + if (perm.views.exists(_.viewId == viewId)) + Some(JSONFactory600.UserWithViewAccessJsonV600( + user_id = perm.user.userId, username = perm.user.name, + email = perm.user.emailAddress, provider = perm.user.provider, + access_source = "ACCOUNT_ACCESS")) + else None + } + accountAccessUserIds = accountAccessUsers.map(_.user_id).toSet + abacEntitlements = code.entitlement.Entitlement.entitlement.vend.getEntitlementsByRole(canExecuteAbacRule.toString).getOrElse(Nil) + abacUserIds = abacEntitlements.map(_.userId).distinct.filterNot(accountAccessUserIds.contains) + abacUsersF: Future[List[JSONFactory600.UserWithViewAccessJsonV600]] = if (abacUserIds.isEmpty) + Future.successful(List.empty[JSONFactory600.UserWithViewAccessJsonV600]) + else + code.users.Users.users.vend.getUsersByUserIdsFuture(abacUserIds).flatMap { users => + Future.sequence(users.map { user => + code.abacrule.AbacRuleEngine.executeRulesByPolicyDetailed( + policy = ABAC_POLICY_ACCOUNT_ACCESS, + authenticatedUserId = user.userId, callContext = cc, + bankId = Some(bankId.value), accountId = Some(accountId.value), + viewId = Some(viewId.value) + ).map[Option[JSONFactory600.UserWithViewAccessJsonV600]] { + case Full((true, _)) => Some(JSONFactory600.UserWithViewAccessJsonV600( + user_id = user.userId, username = user.name, + email = user.emailAddress, provider = user.provider, + access_source = "ABAC")) + case _ => None + }.recover { case _ => None } + }).map(_.flatten) + } + abacUsers <- abacUsersF + } yield JSONFactory600.UsersWithViewAccessJsonV600(users = accountAccessUsers ++ abacUsers) + } + } + + lazy val createRetailCustomer: HttpRoutes[IO] = HttpRoutes.of[IO] { + case req @ POST -> `prefixPath` / "banks" / bankIdStr / "retail-customers" => + EndpointHelpers.executeFutureCreated(req) { + implicit val cc: CallContext = req.callContext + val rawBody = cc.httpBody.getOrElse("") + val bankId = BankId(bankIdStr) + for { + postedData <- NewStyle.function.tryons( + s"$InvalidJsonFormat The Json body should be the PostRetailCustomerJsonV600", 400, Some(cc)) { + net.liftweb.json.parse(rawBody).extract[PostRetailCustomerJsonV600] + } + _ <- Helper.booleanToFuture( + InvalidJsonContent + s" The field dependants(${postedData.dependants.getOrElse(0)}) not equal the length(${postedData.dob_of_dependants.getOrElse(Nil).length}) of dob_of_dependants array", + 400, Some(cc)) { + postedData.dependants.getOrElse(0) == postedData.dob_of_dependants.getOrElse(Nil).length + } + dateOfBirth <- NewStyle.function.tryons( + s"$InvalidJsonFormat date_of_birth must be in YYYY-MM-DD format (e.g., 1990-05-15)", + 400, Some(cc)) { + postedData.date_of_birth.map { ds => + val f = new java.text.SimpleDateFormat("yyyy-MM-dd") + f.setTimeZone(java.util.TimeZone.getTimeZone("UTC")) + f.setLenient(false); f.parse(ds) + }.orNull + } + dobOfDependants <- NewStyle.function.tryons( + s"$InvalidJsonFormat dob_of_dependants must contain dates in YYYY-MM-DD format (e.g., 2010-03-20)", + 400, Some(cc)) { + postedData.dob_of_dependants.getOrElse(Nil).map { ds => + val f = new java.text.SimpleDateFormat("yyyy-MM-dd") + f.setTimeZone(java.util.TimeZone.getTimeZone("UTC")) + f.setLenient(false); f.parse(ds) + } + } + customerNumber = postedData.customer_number.getOrElse(scala.util.Random.nextInt(Integer.MAX_VALUE).toString) + _ <- Helper.booleanToFuture( + s"$InvalidJsonFormat customer_number can not contain `::::` characters", cc = Some(cc)) { + !APIUtil.`checkIfContains::::`(customerNumber) + } + _ <- NewStyle.function.checkCustomerNumberAvailable(bankId, customerNumber, Some(cc)) + (customer, _) <- NewStyle.function.createCustomerC2( + bankId, postedData.legal_name, customerNumber, postedData.mobile_phone_number, + postedData.email.getOrElse(""), + com.openbankproject.commons.model.CustomerFaceImage( + postedData.face_image.map(_.date).getOrElse(null), + postedData.face_image.map(_.url).getOrElse("")), + dateOfBirth, postedData.relationship_status.getOrElse(""), + postedData.dependants.getOrElse(0), dobOfDependants, + postedData.highest_education_attained.getOrElse(""), + postedData.employment_status.getOrElse(""), + postedData.kyc_status.getOrElse(false), + postedData.last_ok_date.getOrElse(null), + postedData.credit_rating.map(i => com.openbankproject.commons.model.CreditRating(i.rating, i.source)), + postedData.credit_limit.map(i => com.openbankproject.commons.model.CreditLimit(i.currency, i.amount)), + postedData.title.getOrElse(""), postedData.branch_id.getOrElse(""), + postedData.name_suffix.getOrElse(""), "INDIVIDUAL", "", Some(cc)) + } yield JSONFactory600.createCustomerJson(customer) + } + } + + lazy val createCorporateCustomer: HttpRoutes[IO] = HttpRoutes.of[IO] { + case req @ POST -> `prefixPath` / "banks" / bankIdStr / "corporate-customers" => + EndpointHelpers.executeFutureCreated(req) { + implicit val cc: CallContext = req.callContext + val rawBody = cc.httpBody.getOrElse("") + val bankId = BankId(bankIdStr) + for { + postedData <- NewStyle.function.tryons( + s"$InvalidJsonFormat The Json body should be the PostCorporateCustomerJsonV600", 400, Some(cc)) { + net.liftweb.json.parse(rawBody).extract[PostCorporateCustomerJsonV600] + } + customerNumber = postedData.customer_number.getOrElse(scala.util.Random.nextInt(Integer.MAX_VALUE).toString) + _ <- Helper.booleanToFuture( + s"$InvalidJsonFormat customer_number can not contain `::::` characters", cc = Some(cc)) { + !APIUtil.`checkIfContains::::`(customerNumber) + } + _ <- NewStyle.function.checkCustomerNumberAvailable(bankId, customerNumber, Some(cc)) + customerType = postedData.customer_type.getOrElse("CORPORATE") + _ <- Helper.booleanToFuture( + InvalidCustomerType + " For corporate customers, must be CORPORATE or SUBSIDIARY.", + 400, Some(cc)) { + List("CORPORATE", "SUBSIDIARY").contains(customerType) + } + parentId = postedData.parent_customer_id.getOrElse("") + _ <- if (parentId.nonEmpty) + NewStyle.function.getCustomerByCustomerId(parentId, Some(cc)).map(_ => ()) + else Future.successful(()) + (customer, _) <- NewStyle.function.createCustomerC2( + bankId, postedData.legal_name, customerNumber, postedData.mobile_phone_number, + postedData.email.getOrElse(""), + com.openbankproject.commons.model.CustomerFaceImage(null, ""), + null, "", 0, Nil, "", "", + postedData.kyc_status.getOrElse(false), + postedData.last_ok_date.getOrElse(null), + postedData.credit_rating.map(i => com.openbankproject.commons.model.CreditRating(i.rating, i.source)), + postedData.credit_limit.map(i => com.openbankproject.commons.model.CreditLimit(i.currency, i.amount)), + "", postedData.branch_id.getOrElse(""), "", customerType, parentId, Some(cc)) + } yield JSONFactory600.createCustomerJson(customer) + } + } + + private def initUserCustomerResourceDocs(): Unit = { + resourceDocs += ResourceDoc( + null, implementedInApiVersion, nameOf(getUserGroupMemberships), "GET", + "/users/USER_ID/group-entitlements", "Get User Group Memberships", + """Get all group memberships for a user.""", + EmptyBody, EmptyBody, + List($AuthenticatedUserIsRequired, UserHasMissingRoles, UserNotFoundByUserId, UnknownError), + apiTagGroup :: apiTagUser :: apiTagEntitlement :: Nil, + Some(canGetUserGroupMembershipsAtAllBanks :: canGetUserGroupMembershipsAtOneBank :: Nil), + http4sPartialFunction = Some(getUserGroupMemberships)) + resourceDocs += ResourceDoc( + null, implementedInApiVersion, nameOf(getUsersWithAccountAccess), "GET", + "/banks/BANK_ID/accounts/ACCOUNT_ID/views/VIEW_ID/users-with-access", "Get Users With Account Access", + """List users with access to the specified view (AccountAccess + ABAC).""", + EmptyBody, EmptyBody, + List($BankNotFound, $BankAccountNotFound, ViewNotFound, UnknownError), + apiTagAccount :: apiTagView :: Nil, + Some(canSeeAccountAccessForAnyUser :: Nil), + http4sPartialFunction = Some(getUsersWithAccountAccess)) + resourceDocs += ResourceDoc( + null, implementedInApiVersion, nameOf(createRetailCustomer), "POST", + "/banks/BANK_ID/retail-customers", "Create Retail Customer", + """Create a retail (individual) customer.""", + EmptyBody, EmptyBody, + List($AuthenticatedUserIsRequired, UserHasMissingRoles, $BankNotFound, + InvalidJsonFormat, InvalidJsonContent, UnknownError), + apiTagRetailCustomer :: apiTagCustomer :: Nil, + Some(canCreateCustomer :: canCreateCustomerAtAnyBank :: Nil), + http4sPartialFunction = Some(createRetailCustomer)) + resourceDocs += ResourceDoc( + null, implementedInApiVersion, nameOf(createCorporateCustomer), "POST", + "/banks/BANK_ID/corporate-customers", "Create Corporate Customer", + """Create a corporate (or subsidiary) customer.""", + EmptyBody, EmptyBody, + List($AuthenticatedUserIsRequired, UserHasMissingRoles, $BankNotFound, + InvalidJsonFormat, InvalidCustomerType, UnknownError), + apiTagCorporateCustomer :: apiTagCustomer :: Nil, + Some(canCreateCustomer :: canCreateCustomerAtAnyBank :: Nil), + http4sPartialFunction = Some(createCorporateCustomer)) + } + initUserCustomerResourceDocs() + + // ─── Phase 2: Final batch — getUserByUserId, directLogin, ABAC (5), dynamic-entity backup/cascade (3) ─ + + lazy val getUserByUserId: HttpRoutes[IO] = HttpRoutes.of[IO] { + case req @ GET -> `prefixPath` / "users" / "user-id" / userId => + EndpointHelpers.withUser(req) { (_, cc) => + for { + userBox <- code.users.Users.users.vend.getUserByUserIdFuture(userId) + user <- Future(unboxFullOrFail(userBox, Some(cc), + s"$UserNotFoundByUserId Current UserId($userId)")) + entitlements <- NewStyle.function.getEntitlementsByUserId(user.userId, Some(cc)) + agreements <- Future { + val ami = code.users.UserAgreementProvider.userAgreementProvider.vend.getLastUserAgreement(user.userId, "accept_marketing_info") + val tac = code.users.UserAgreementProvider.userAgreementProvider.vend.getLastUserAgreement(user.userId, "terms_and_conditions") + val pc = code.users.UserAgreementProvider.userAgreementProvider.vend.getLastUserAgreement(user.userId, "privacy_conditions") + val all = ami.toList ::: tac.toList ::: pc.toList + if (all.isEmpty) None else Some(all) + } + isLocked = code.loginattempts.LoginAttempt.userIsLocked(user.provider, user.name) + authUser = code.model.dataAccess.AuthUser.find( + By(code.model.dataAccess.AuthUser.user, user.userPrimaryKey.value)) + userMetrics <- Future { + code.metrics.MappedMetric.findAll( + By(code.metrics.MappedMetric.userId, userId), + net.liftweb.mapper.OrderBy(code.metrics.MappedMetric.date, net.liftweb.mapper.Descending), + net.liftweb.mapper.MaxRows(5)) + } + lastActivityDate = userMetrics.headOption.map(_.getDate()) + recentOperationIds = userMetrics.map(_.getImplementedByPartialFunction()).distinct.take(5) + } yield JSONFactory600.createUserInfoJsonV600( + user, + authUser.map(_.firstName.get).getOrElse(""), + authUser.map(_.lastName.get).getOrElse(""), + entitlements, agreements, isLocked, lastActivityDate, recentOperationIds) + } + } + + // DirectLogin header parser — mirrors the parsing in code.api.directlogin.DirectLogin.getAllParameters + // but reads from CallContext.requestHeaders (populated by the http4s context builder) instead of + // Lift's thread-local S.request. + private def parseDirectLoginParams(cc: CallContext): Map[String, String] = { + def find(name: String): Option[String] = cc.requestHeaders + .find(_.name.equalsIgnoreCase(name)) + .flatMap(_.values.headOption) + val directLoginHeader = find("DirectLogin") + val authHeader = find("Authorization") + val raw = directLoginHeader + .orElse(authHeader.filter(h => h.startsWith("DirectLogin") || h.contains("DirectLogin"))) + .getOrElse("") + val cleaned = raw.stripPrefix("DirectLogin").split(",").map(_.trim).toList + val keys = Set("consumer_key", "token", "username", "password") + cleaned.flatMap { entry => + if (entry.contains("=")) { + val s = entry.split("=", 2) + val v = s(1).replaceAll("^\"|\"$", "") + if (keys.contains(s(0)) && v.nonEmpty) Some(s(0) -> v) else None + } else None + }.toMap + } + + lazy val directLoginEndpoint: HttpRoutes[IO] = HttpRoutes.of[IO] { + case req @ POST -> `prefixPath` / "my" / "logins" / "direct" => + EndpointHelpers.executeFutureCreated(req) { + implicit val cc: CallContext = req.callContext + // If the parser found nothing usable, fall back to a single "error" key so that + // validatorFutureWithParams returns the MissingDirectLoginHeader message (preserves + // Lift's getAllParameters behaviour for the no-header case). + val parsed = parseDirectLoginParams(cc) + val params = + if (parsed.isEmpty) Map("error" -> code.api.util.ErrorMessages.MissingDirectLoginHeader) + else parsed + for { + triple <- code.api.DirectLogin.validatorFutureWithParams("authorizationToken", "POST", params) + (httpCode, message, dlParams) = triple + tokenTriple = code.api.DirectLogin.createTokenCommonPart(httpCode, message, dlParams) + _ <- Future(code.api.DirectLogin.grantEntitlementsToUseDynamicEndpointsInSpacesInDirectLogin(tokenTriple._3)) + } yield { + if (tokenTriple._1 == 200) JSONFactory600.createTokenJSON(tokenTriple._2) + else unboxFullOrFail(Empty, None, tokenTriple._2, tokenTriple._1) + } + } + } + + lazy val validateAbacRule: HttpRoutes[IO] = HttpRoutes.of[IO] { + case req @ POST -> `prefixPath` / "management" / "abac-rules" / "validate" => + EndpointHelpers.executeAndRespond(req) { implicit cc => + val rawBody = cc.httpBody.getOrElse("") + for { + validateJson <- NewStyle.function.tryons(InvalidJsonFormat, 400, Some(cc)) { + net.liftweb.json.parse(rawBody).extract[ValidateAbacRuleJsonV600] + } + _ <- NewStyle.function.tryons(AbacRuleCodeEmpty, 400, Some(cc)) { + validateJson.rule_code.trim.nonEmpty + } + box <- code.abacrule.AbacRuleEngine.validateRuleCodeAsync(validateJson.rule_code) + } yield box match { + case Full(msg) => ValidateAbacRuleSuccessJsonV600(valid = true, message = msg): Any + case Failure(errorMsg, _, _) => + val cleanError = errorMsg.replace("Invalid ABAC rule code: ", "") + .replace("Failed to compile ABAC rule: ", "") + val (obpMsg, errorType) = + if (cleanError.toLowerCase.contains("too permissive") || cleanError.toLowerCase.contains("tautological")) { + val ec = if (cleanError.toLowerCase.contains("statistical")) + AbacRuleStatisticallyTooPermissive else AbacRuleTooPermissive + (ec, "PermissivenessError") + } else if (cleanError.toLowerCase.contains("type mismatch") || + (cleanError.toLowerCase.contains("found:") && cleanError.toLowerCase.contains("required: boolean"))) + (AbacRuleTypeMismatch, "TypeError") + else if (cleanError.toLowerCase.contains("syntax") || cleanError.toLowerCase.contains("parse")) + (AbacRuleSyntaxError, "SyntaxError") + else if (cleanError.toLowerCase.contains("not found") || cleanError.toLowerCase.contains("not a member")) + (AbacRuleFieldReferenceError, "FieldReferenceError") + else if (cleanError.toLowerCase.contains("compilation failed") || + cleanError.toLowerCase.contains("reflective compilation has failed")) + (AbacRuleCompilationFailed, "CompilationError") + else (AbacRuleValidationFailed, "ValidationError") + ValidateAbacRuleFailureJsonV600( + valid = false, error = cleanError, message = obpMsg, + details = ValidateAbacRuleErrorDetailsJsonV600(error_type = errorType)) + case _ => + ValidateAbacRuleFailureJsonV600( + valid = false, error = "Unknown validation error", + message = AbacRuleValidationFailed, + details = ValidateAbacRuleErrorDetailsJsonV600(error_type = "UnknownError")) + } + } + } + + lazy val executeAbacRule: HttpRoutes[IO] = HttpRoutes.of[IO] { + case req @ POST -> `prefixPath` / "management" / "abac-rules" / ruleId / "execute" => + EndpointHelpers.executeAndRespond(req) { implicit cc => + val rawBody = cc.httpBody.getOrElse("") + val u = cc.user.openOrThrowException("User not found in CallContext") + for { + execJson <- NewStyle.function.tryons(InvalidJsonFormat, 400, Some(cc)) { + net.liftweb.json.parse(rawBody).extract[ExecuteAbacRuleJsonV600] + } + ruleBox <- Future(code.abacrule.MappedAbacRuleProvider.getAbacRuleById(ruleId)) + _ <- Future(unboxFullOrFail(ruleBox, Some(cc), s"ABAC Rule not found with ID: $ruleId", 404)) + effectiveUserId = execJson.authenticated_user_id.getOrElse(u.userId) + result <- code.abacrule.AbacRuleEngine.executeRule( + ruleId = ruleId, authenticatedUserId = effectiveUserId, + onBehalfOfUserId = execJson.on_behalf_of_user_id, userId = execJson.user_id, + callContext = cc, bankId = execJson.bank_id, accountId = execJson.account_id, + viewId = execJson.view_id, transactionId = execJson.transaction_id, + transactionRequestId = execJson.transaction_request_id, customerId = execJson.customer_id) + .map { + case Full(allowed) => AbacRuleResultJsonV600(result = allowed) + case _ => AbacRuleResultJsonV600(result = false) + } + } yield result + } + } + + lazy val executeAbacPolicy: HttpRoutes[IO] = HttpRoutes.of[IO] { + case req @ POST -> `prefixPath` / "management" / "abac-policies" / policy / "execute" => + EndpointHelpers.executeAndRespond(req) { implicit cc => + val rawBody = cc.httpBody.getOrElse("") + val u = cc.user.openOrThrowException("User not found in CallContext") + for { + execJson <- NewStyle.function.tryons(InvalidJsonFormat, 400, Some(cc)) { + net.liftweb.json.parse(rawBody).extract[ExecuteAbacRuleJsonV600] + } + _ <- Future { + if (Constant.ABAC_POLICIES.contains(policy)) Full(true) + else Failure(s"Policy not found: $policy. Available policies: ${Constant.ABAC_POLICIES.mkString(", ")}") + }.map(unboxFullOrFail(_, Some(cc), s"Invalid ABAC Policy: $policy", 404)) + effectiveUserId = execJson.authenticated_user_id.getOrElse(u.userId) + result <- code.abacrule.AbacRuleEngine.executeRulesByPolicy( + policy = policy, authenticatedUserId = effectiveUserId, + onBehalfOfUserId = execJson.on_behalf_of_user_id, userId = execJson.user_id, + callContext = cc, bankId = execJson.bank_id, accountId = execJson.account_id, + viewId = execJson.view_id, transactionId = execJson.transaction_id, + transactionRequestId = execJson.transaction_request_id, customerId = execJson.customer_id) + .map { + case Full(allowed) => AbacRuleResultJsonV600(result = allowed) + case _ => AbacRuleResultJsonV600(result = false) + } + } yield result + } + } + + // 218-line static ABAC schema. Same shape as Lift's. Built once per call (no caching). + private def buildAbacRuleSchemaJson(): AbacRuleSchemaJsonV600 = AbacRuleSchemaJsonV600( + parameters = List( + AbacParameterJsonV600("authenticatedUser", "User", "The logged-in user (always present)", required = true, "User"), + AbacParameterJsonV600("authenticatedUserAttributes", "List[UserAttributeTrait]", "Non-personal attributes of authenticated user", required = true, "User"), + AbacParameterJsonV600("authenticatedUserAuthContext", "List[UserAuthContext]", "Auth context of authenticated user", required = true, "User"), + AbacParameterJsonV600("authenticatedUserEntitlements", "List[Entitlement]", "Entitlements (roles) of authenticated user", required = true, "User"), + AbacParameterJsonV600("onBehalfOfUserOpt", "Option[User]", "User being acted on behalf of (delegation)", required = false, "User"), + AbacParameterJsonV600("onBehalfOfUserAttributes", "List[UserAttributeTrait]", "Attributes of delegation user", required = false, "User"), + AbacParameterJsonV600("onBehalfOfUserAuthContext", "List[UserAuthContext]", "Auth context of delegation user", required = false, "User"), + AbacParameterJsonV600("onBehalfOfUserEntitlements", "List[Entitlement]", "Entitlements (roles) of delegation user", required = false, "User"), + AbacParameterJsonV600("userOpt", "Option[User]", "Target user being evaluated", required = false, "User"), + AbacParameterJsonV600("userAttributes", "List[UserAttributeTrait]", "Attributes of target user", required = false, "User"), + AbacParameterJsonV600("bankOpt", "Option[Bank]", "Bank context", required = false, "Bank"), + AbacParameterJsonV600("bankAttributes", "List[BankAttributeTrait]", "Bank attributes", required = false, "Bank"), + AbacParameterJsonV600("accountOpt", "Option[BankAccount]", "Account context", required = false, "Account"), + AbacParameterJsonV600("accountAttributes", "List[AccountAttribute]", "Account attributes", required = false, "Account"), + AbacParameterJsonV600("transactionOpt", "Option[Transaction]", "Transaction context", required = false, "Transaction"), + AbacParameterJsonV600("transactionAttributes", "List[TransactionAttribute]", "Transaction attributes", required = false, "Transaction"), + AbacParameterJsonV600("transactionRequestOpt", "Option[TransactionRequest]", "Transaction request context", required = false, "TransactionRequest"), + AbacParameterJsonV600("transactionRequestAttributes", "List[TransactionRequestAttributeTrait]", "Transaction request attributes", required = false, "TransactionRequest"), + AbacParameterJsonV600("customerOpt", "Option[Customer]", "Customer context", required = false, "Customer"), + AbacParameterJsonV600("customerAttributes", "List[CustomerAttribute]", "Customer attributes", required = false, "Customer"), + AbacParameterJsonV600("callContext", "Option[CallContext]", "Request call context with metadata (IP, user agent, etc.)", required = false, "Context") + ), + object_types = List( + AbacObjectTypeJsonV600("User", "User object with profile and authentication information", List( + AbacObjectPropertyJsonV600("userId", "String", "Unique user ID"), + AbacObjectPropertyJsonV600("emailAddress", "String", "User email address"), + AbacObjectPropertyJsonV600("provider", "String", "Authentication provider (e.g., 'obp')"), + AbacObjectPropertyJsonV600("name", "String", "User display name"), + AbacObjectPropertyJsonV600("idGivenByProvider", "String", "ID given by provider (same as username)"), + AbacObjectPropertyJsonV600("createdByConsentId", "Option[String]", "Consent ID that created the user (if any)"), + AbacObjectPropertyJsonV600("isDeleted", "Option[Boolean]", "Whether user is deleted") + )), + AbacObjectTypeJsonV600("Bank", "Bank object", List( + AbacObjectPropertyJsonV600("bankId", "BankId", "Bank ID"), + AbacObjectPropertyJsonV600("fullName", "String", "Bank full name"), + AbacObjectPropertyJsonV600("shortName", "String", "Bank short name"), + AbacObjectPropertyJsonV600("logoUrl", "String", "Bank logo URL"), + AbacObjectPropertyJsonV600("websiteUrl", "String", "Bank website URL"), + AbacObjectPropertyJsonV600("bankRoutingScheme", "String", "Bank routing scheme"), + AbacObjectPropertyJsonV600("bankRoutingAddress", "String", "Bank routing address") + )), + AbacObjectTypeJsonV600("BankAccount", "Bank account object", List( + AbacObjectPropertyJsonV600("accountId", "AccountId", "Account ID"), + AbacObjectPropertyJsonV600("bankId", "BankId", "Bank ID"), + AbacObjectPropertyJsonV600("accountType", "String", "Account type"), + AbacObjectPropertyJsonV600("balance", "BigDecimal", "Account balance"), + AbacObjectPropertyJsonV600("currency", "String", "Account currency"), + AbacObjectPropertyJsonV600("name", "String", "Account name"), + AbacObjectPropertyJsonV600("label", "String", "Account label"), + AbacObjectPropertyJsonV600("number", "String", "Account number"), + AbacObjectPropertyJsonV600("lastUpdate", "Date", "Last update date"), + AbacObjectPropertyJsonV600("branchId", "String", "Branch ID"), + AbacObjectPropertyJsonV600("accountRoutings", "List[AccountRouting]", "Account routings") + )), + AbacObjectTypeJsonV600("Transaction", "Transaction object", List( + AbacObjectPropertyJsonV600("id", "TransactionId", "Transaction ID"), + AbacObjectPropertyJsonV600("uuid", "String", "Universally unique ID"), + AbacObjectPropertyJsonV600("thisAccount", "BankAccount", "This account"), + AbacObjectPropertyJsonV600("otherAccount", "Counterparty", "Other account/counterparty"), + AbacObjectPropertyJsonV600("transactionType", "String", "Transaction type (e.g., cash withdrawal)"), + AbacObjectPropertyJsonV600("amount", "BigDecimal", "Transaction amount"), + AbacObjectPropertyJsonV600("currency", "String", "Transaction currency (ISO 4217)"), + AbacObjectPropertyJsonV600("description", "Option[String]", "Bank provided label"), + AbacObjectPropertyJsonV600("startDate", "Date", "Date transaction was initiated"), + AbacObjectPropertyJsonV600("finishDate", "Option[Date]", "Date money finished changing hands"), + AbacObjectPropertyJsonV600("balance", "BigDecimal", "New balance after transaction"), + AbacObjectPropertyJsonV600("status", "Option[String]", "Transaction status") + )), + AbacObjectTypeJsonV600("TransactionRequest", "Transaction request object", List( + AbacObjectPropertyJsonV600("id", "TransactionRequestId", "Transaction request ID"), + AbacObjectPropertyJsonV600("type", "String", "Transaction request type"), + AbacObjectPropertyJsonV600("from", "TransactionRequestAccount", "From account"), + AbacObjectPropertyJsonV600("status", "String", "Transaction request status"), + AbacObjectPropertyJsonV600("start_date", "Date", "Start date"), + AbacObjectPropertyJsonV600("end_date", "Date", "End date"), + AbacObjectPropertyJsonV600("transaction_ids", "String", "Associated transaction IDs"), + AbacObjectPropertyJsonV600("charge", "TransactionRequestCharge", "Charge information"), + AbacObjectPropertyJsonV600("this_bank_id", "BankId", "This bank ID"), + AbacObjectPropertyJsonV600("this_account_id", "AccountId", "This account ID"), + AbacObjectPropertyJsonV600("counterparty_id", "CounterpartyId", "Counterparty ID") + )), + AbacObjectTypeJsonV600("Customer", "Customer object", List( + AbacObjectPropertyJsonV600("customerId", "String", "Customer ID (UUID)"), + AbacObjectPropertyJsonV600("bankId", "String", "Bank ID"), + AbacObjectPropertyJsonV600("number", "String", "Customer number (bank identifier)"), + AbacObjectPropertyJsonV600("legalName", "String", "Customer legal name"), + AbacObjectPropertyJsonV600("mobileNumber", "String", "Customer mobile number"), + AbacObjectPropertyJsonV600("email", "String", "Customer email"), + AbacObjectPropertyJsonV600("dateOfBirth", "Date", "Date of birth"), + AbacObjectPropertyJsonV600("relationshipStatus", "String", "Relationship status"), + AbacObjectPropertyJsonV600("dependents", "Integer", "Number of dependents") + )), + AbacObjectTypeJsonV600("UserAttributeTrait", "User attribute", List( + AbacObjectPropertyJsonV600("name", "String", "Attribute name"), + AbacObjectPropertyJsonV600("value", "String", "Attribute value"), + AbacObjectPropertyJsonV600("attributeType", "AttributeType", "Attribute type (STRING, INTEGER, DOUBLE, DATE_WITH_DAY)") + )), + AbacObjectTypeJsonV600("AccountAttribute", "Account attribute", List( + AbacObjectPropertyJsonV600("name", "String", "Attribute name"), + AbacObjectPropertyJsonV600("value", "String", "Attribute value"), + AbacObjectPropertyJsonV600("attributeType", "AttributeType", "Attribute type") + )), + AbacObjectTypeJsonV600("TransactionAttribute", "Transaction attribute", List( + AbacObjectPropertyJsonV600("name", "String", "Attribute name"), + AbacObjectPropertyJsonV600("value", "String", "Attribute value"), + AbacObjectPropertyJsonV600("attributeType", "AttributeType", "Attribute type") + )), + AbacObjectTypeJsonV600("CustomerAttribute", "Customer attribute", List( + AbacObjectPropertyJsonV600("name", "String", "Attribute name"), + AbacObjectPropertyJsonV600("value", "String", "Attribute value"), + AbacObjectPropertyJsonV600("attributeType", "AttributeType", "Attribute type") + )), + AbacObjectTypeJsonV600("Entitlement", "User entitlement (role)", List( + AbacObjectPropertyJsonV600("entitlementId", "String", "Entitlement ID"), + AbacObjectPropertyJsonV600("roleName", "String", "Role name (e.g., CanCreateAccount, CanReadTransactions)"), + AbacObjectPropertyJsonV600("bankId", "String", "Bank ID (empty string for system-wide roles)"), + AbacObjectPropertyJsonV600("userId", "String", "User ID this entitlement belongs to") + )), + AbacObjectTypeJsonV600("CallContext", "Request context with metadata", List( + AbacObjectPropertyJsonV600("correlationId", "String", "Correlation ID for request tracking"), + AbacObjectPropertyJsonV600("url", "Option[String]", "Request URL"), + AbacObjectPropertyJsonV600("verb", "Option[String]", "HTTP verb (GET, POST, etc.)"), + AbacObjectPropertyJsonV600("ipAddress", "Option[String]", "Client IP address"), + AbacObjectPropertyJsonV600("userAgent", "Option[String]", "Client user agent"), + AbacObjectPropertyJsonV600("implementedByPartialFunction", "Option[String]", "Endpoint implementation name"), + AbacObjectPropertyJsonV600("startTime", "Option[Date]", "Request start time"), + AbacObjectPropertyJsonV600("endTime", "Option[Date]", "Request end time") + )) + ), + examples = List( + AbacRuleExampleJsonV600( + rule_name = "Branch Manager Internal Account Access", + rule_code = "authenticatedUserEntitlements.exists(e => e.roleName == \"CanReadAccountsAtOneBank\") && authenticatedUserAttributes.exists(a => a.name == \"branch\" && accountAttributes.exists(aa => aa.name == \"branch\" && a.value == aa.value)) && callContext.exists(_.verb.exists(_ == \"GET\")) && accountOpt.exists(_.accountType == \"CURRENT\")", + description = "Allow GET access to current accounts when user has CanReadAccountsAtOneBank role and branch matches account's branch", + policy = "account-access", is_active = true + ), + AbacRuleExampleJsonV600( + rule_name = "Internal Network High-Value Transaction Review", + rule_code = "callContext.exists(_.ipAddress.exists(_.startsWith(\"10.\"))) && authenticatedUserEntitlements.exists(e => e.roleName == \"CanReadTransactionsAtOneBank\") && transactionOpt.exists(_.amount > 10000)", + description = "Allow users with CanReadTransactionsAtOneBank role on internal network to review high-value transactions over 10,000", + policy = "transaction-access", is_active = true + ), + AbacRuleExampleJsonV600( + rule_name = "Department Head Same-Department Account Read where overdrawn", + rule_code = "authenticatedUserEntitlements.exists(e => e.roleName == \"CanReadAccountsAtOneBank\") && authenticatedUserAttributes.exists(ua => ua.name == \"department\" && accountAttributes.exists(aa => aa.name == \"department\" && ua.value == aa.value)) && callContext.exists(_.url.exists(_.contains(\"/accounts/\"))) && accountOpt.exists(_.balance < 0)", + description = "Allow users with CanReadAccountsAtOneBank role to read overdrawn accounts in their department", + policy = "account-access", is_active = true + ), + AbacRuleExampleJsonV600( + rule_name = "Manager Internal Network Transaction Approval", + rule_code = "authenticatedUserEntitlements.exists(e => e.roleName == \"CanCreateTransactionRequest\") && callContext.exists(_.ipAddress.exists(ip => ip.startsWith(\"10.\") || ip.startsWith(\"192.168.\"))) && transactionRequestOpt.exists(tr => tr.status == \"PENDING\" && tr.charge.value.toDouble < 50000)", + description = "Allow users with CanCreateTransactionRequest role on internal network to approve pending transaction requests under 50,000", + policy = "transaction-request", is_active = true + ), + AbacRuleExampleJsonV600( + rule_name = "KYC Officer Customer Creation from Branch", + rule_code = "authenticatedUserEntitlements.exists(e => e.roleName == \"CanCreateCustomer\") && authenticatedUserAttributes.exists(a => a.name == \"certification\" && a.value == \"kyc_certified\") && callContext.exists(_.verb.exists(_ == \"POST\")) && callContext.exists(_.ipAddress.exists(_.startsWith(\"10.20.\"))) && customerAttributes.exists(ca => ca.name == \"onboarding_status\" && ca.value == \"pending\")", + description = "Allow users with CanCreateCustomer role and KYC certification to create customers via POST from branch network (10.20.x.x) when status is pending", + policy = "customer-access", is_active = true + ), + AbacRuleExampleJsonV600( + rule_name = "International Team Foreign Currency Transaction", + rule_code = "authenticatedUserEntitlements.exists(e => e.roleName == \"CanReadTransactionsAtOneBank\") && authenticatedUserAttributes.exists(a => a.name == \"team\" && a.value == \"international\") && callContext.exists(_.url.exists(_.contains(\"/transactions/\"))) && transactionOpt.exists(t => t.currency != \"USD\" && t.amount < 100000) && accountOpt.exists(a => accountAttributes.exists(aa => aa.name == \"international_enabled\" && aa.value == \"true\"))", + description = "Allow international team users with CanReadTransactionsAtOneBank role to access foreign currency transactions under 100k on international-enabled accounts", + policy = "transaction-access", is_active = true + ), + AbacRuleExampleJsonV600( + rule_name = "Assistant with Limited Delegation Account View", + rule_code = "onBehalfOfUserOpt.isDefined && onBehalfOfUserEntitlements.exists(e => e.roleName == \"CanReadAccountsAtOneBank\") && authenticatedUserAttributes.exists(a => a.name == \"assistant_of\" && onBehalfOfUserOpt.exists(u => a.value == u.userId)) && callContext.exists(_.verb.exists(_ == \"GET\")) && accountOpt.exists(a => accountAttributes.exists(aa => aa.name == \"tier\" && List(\"gold\", \"platinum\").contains(aa.value)))", + description = "Allow assistants to view gold/platinum accounts via GET when acting on behalf of a user with CanReadAccountsAtOneBank role", + policy = "account-access", is_active = true + ), + AbacRuleExampleJsonV600( + rule_name = "Fraud Analyst High-Risk Transaction Access", + rule_code = "authenticatedUserEntitlements.exists(e => e.roleName == \"CanReadTransactionsAtOneBank\") && callContext.exists(c => c.verb.exists(_ == \"GET\") && c.implementedByPartialFunction.exists(_.contains(\"Transaction\"))) && transactionAttributes.exists(ta => ta.name == \"risk_score\" && ta.value.toInt >= 75) && transactionOpt.exists(_.status.exists(_ != \"COMPLETED\"))", + description = "Allow users with CanReadTransactionsAtOneBank role to GET high-risk (score ≥75) non-completed transactions", + policy = "transaction-access", is_active = true + ) + ), + available_operators = List( + "==", "!=", "&&", "||", "!", ">", "<", ">=", "<=", + "contains", "startsWith", "endsWith", + "isDefined", "isEmpty", "nonEmpty", + "exists", "forall", "find", "filter", + "get", "getOrElse" + ), + notes = List( + "PARAMETER NAMES: Use authenticatedUser, userOpt, accountOpt, bankOpt, transactionOpt, etc. (NOT user, account, bank)", + "PROPERTY NAMES: Use camelCase - userId (NOT user_id), accountId (NOT account_id), emailAddress (NOT email_address)", + "OPTION TYPES: Only authenticatedUser is guaranteed to exist. All others are Option types - check isDefined before using .get", + "ATTRIBUTES: All attributes are Lists - use Scala collection methods like exists(), find(), filter()", + "SAFE OPTION HANDLING: Use pattern matching: userOpt match { case Some(u) => u.userId == ... case None => false }", + "RETURN TYPE: Rule must return Boolean - true = access granted, false = access denied", + "AUTO-FETCHING: Objects are automatically fetched based on IDs passed to execute endpoint", + "COMMON MISTAKE: Writing 'user.user_id' instead of 'userOpt.get.userId' or 'authenticatedUser.userId'" + ) + ) + + lazy val getAbacRuleSchema: HttpRoutes[IO] = HttpRoutes.of[IO] { + case req @ GET -> `prefixPath` / "management" / "abac-rules-schema" => + EndpointHelpers.executeAndRespond(req) { implicit cc => + Future.successful(buildAbacRuleSchemaJson()) + } + } + + // Inlined dynamic-entity backup helper (mirrors Lift's private backupDynamicEntity). + private def backupDynamicEntityIo( + entity: code.dynamicEntity.DynamicEntityT, + backupName: String, + dataRecords: net.liftweb.json.JsonAST.JArray + ): Unit = { + code.dynamicEntity.DynamicEntityProvider.connectorMethodProvider.vend + .getByEntityName(entity.bankId, backupName).foreach { existingBackup => + code.DynamicData.DynamicDataProvider.connectorMethodProvider.vend + .getAll(entity.bankId, backupName, None, false) + .foreach { record => + code.DynamicData.DynamicDataProvider.connectorMethodProvider.vend.delete( + entity.bankId, backupName, record.dynamicDataId.getOrElse(""), None, false) + } + code.dynamicEntity.DynamicEntityProvider.connectorMethodProvider.vend.delete(existingBackup) + } + val originalMetadata = net.liftweb.json.parse(entity.metadataJson).asInstanceOf[net.liftweb.json.JObject] + val backupMetadata = net.liftweb.json.JObject(originalMetadata.obj.map { + case net.liftweb.json.JField(name, value) if name == entity.entityName => + net.liftweb.json.JField(backupName, value) + case other => other + }) + val backupEntity = code.dynamicEntity.DynamicEntityCommons( + entityName = backupName, + metadataJson = net.liftweb.json.compactRender(backupMetadata), + dynamicEntityId = None, + userId = entity.userId, + bankId = entity.bankId, + hasPersonalEntity = entity.hasPersonalEntity) + code.dynamicEntity.DynamicEntityProvider.connectorMethodProvider.vend.createOrUpdate(backupEntity) + val originalIdField = code.api.dynamic.entity.helper.DynamicEntityHelper.createEntityId(entity.entityName) + val backupIdField = code.api.dynamic.entity.helper.DynamicEntityHelper.createEntityId(backupName) + dataRecords.arr.foreach { record => + val recordObj = record.asInstanceOf[net.liftweb.json.JObject] + val transformedFields = recordObj.obj.map { + case net.liftweb.json.JField(name, _) if name == originalIdField => + net.liftweb.json.JField(backupIdField, + net.liftweb.json.JString(java.util.UUID.randomUUID().toString)) + case other => other + } + code.DynamicData.DynamicDataProvider.connectorMethodProvider.vend.save( + entity.bankId, backupName, net.liftweb.json.JObject(transformedFields), + Some(entity.userId), entity.hasPersonalEntity) + } + } + + private def computeBackupNameIo(bankId: Option[String], baseName: String): String = { + val first = s"${baseName}_BAK" + if (code.dynamicEntity.DynamicEntityProvider.connectorMethodProvider.vend + .getByEntityName(bankId, first).isEmpty) first + else { + var suffix = 2 + var candidate = s"${baseName}_BAK$suffix" + while (code.dynamicEntity.DynamicEntityProvider.connectorMethodProvider.vend + .getByEntityName(bankId, candidate).isDefined) { + suffix += 1 + candidate = s"${baseName}_BAK$suffix" + } + candidate + } + } + + private def backupDynamicEntityFut( + bankIdOpt: Option[String], + dynamicEntityId: String, + cc: CallContext + ): scala.concurrent.Future[code.api.v6_0_0.DynamicEntityDefinitionJsonV600] = { + for { + (entity, _) <- NewStyle.function.getDynamicEntityById(bankIdOpt, dynamicEntityId, Some(cc)) + canGetRole = code.api.dynamic.entity.helper.DynamicEntityInfo.canGetRole(entity.entityName, entity.bankId) + _ <- NewStyle.function.hasEntitlement(entity.bankId.getOrElse(""), cc.userId, canGetRole, Some(cc)) + (box, _) <- NewStyle.function.invokeDynamicConnector( + com.openbankproject.commons.model.enums.DynamicEntityOperation.GET_ALL, + entity.entityName, None, None, entity.bankId, None, None, false, Some(cc)) + resultList <- Future { + box.asInstanceOf[net.liftweb.common.Box[net.liftweb.json.JsonAST.JArray]] + .openOrThrowException(s"$UnknownError ") + } + backupName = computeBackupNameIo(entity.bankId, entity.entityName) + _ <- Future(backupDynamicEntityIo(entity, backupName, resultList)) + backupCanGetRole = code.api.dynamic.entity.helper.DynamicEntityInfo.canGetRole(backupName, entity.bankId) + _ <- Future(code.entitlement.Entitlement.entitlement.vend.addEntitlement( + entity.bankId.getOrElse(""), cc.userId, backupCanGetRole.toString())) + backupEntity <- Future { + code.dynamicEntity.DynamicEntityProvider.connectorMethodProvider.vend + .getByEntityName(entity.bankId, backupName) + .openOrThrowException("Backup entity not found after creation") + } + } yield { + val commonsData: code.dynamicEntity.DynamicEntityCommons = backupEntity + JSONFactory600.createMyDynamicEntitiesJson(List(commonsData)).dynamic_entities.head + } + } + + lazy val backupSystemDynamicEntity: HttpRoutes[IO] = HttpRoutes.of[IO] { + case req @ POST -> `prefixPath` / "management" / "system-dynamic-entities" / dynamicEntityId / "backup" => + EndpointHelpers.executeFutureCreated(req) { + implicit val cc: CallContext = req.callContext + backupDynamicEntityFut(None, dynamicEntityId, cc) + } + } + + lazy val backupBankLevelDynamicEntity: HttpRoutes[IO] = HttpRoutes.of[IO] { + case req @ POST -> `prefixPath` / "management" / "banks" / bankIdStr / "dynamic-entities" / dynamicEntityId / "backup" => + EndpointHelpers.executeFutureCreated(req) { + implicit val cc: CallContext = req.callContext + backupDynamicEntityFut(Some(bankIdStr), dynamicEntityId, cc) + } + } + + lazy val deleteSystemDynamicEntityCascade: HttpRoutes[IO] = HttpRoutes.of[IO] { + case req @ DELETE -> `prefixPath` / "management" / "system-dynamic-entities" / "cascade" / dynamicEntityId => + EndpointHelpers.executeAndRespond(req) { implicit cc => + for { + (entity, _) <- NewStyle.function.getDynamicEntityById(None, dynamicEntityId, Some(cc)) + _ <- Helper.booleanToFuture(CannotDeleteCascadePersonalEntity, cc = Some(cc)) { + !entity.hasPersonalEntity + } + (box, _) <- NewStyle.function.invokeDynamicConnector( + com.openbankproject.commons.model.enums.DynamicEntityOperation.GET_ALL, + entity.entityName, None, None, entity.bankId, None, None, false, Some(cc)) + resultList <- Future { + box.asInstanceOf[net.liftweb.common.Box[net.liftweb.json.JsonAST.JArray]] + .openOrThrowException(s"$UnknownError ") + } + _ <- Future { + if (!entity.entityName.startsWith("ZZ_BAK_")) + backupDynamicEntityIo(entity, s"ZZ_BAK_${entity.entityName}", resultList) + } + _ <- Future.sequence { + resultList.arr.map { record => + val idField = code.api.dynamic.entity.helper.DynamicEntityHelper.createEntityId(entity.entityName) + val recordId = (record \ idField).asInstanceOf[net.liftweb.json.JString].s + Future(code.DynamicData.DynamicDataProvider.connectorMethodProvider.vend.delete( + entity.bankId, entity.entityName, recordId, None, false)) + } + } + deleted <- NewStyle.function.deleteDynamicEntity(None, dynamicEntityId) + } yield deleted + } + } + + private def initFinal9ResourceDocs(): Unit = { + resourceDocs += ResourceDoc( + null, implementedInApiVersion, nameOf(getUserByUserId), "GET", + "/users/user-id/USER_ID", "Get User By User Id", + """Get detailed user info by user_id (entitlements, agreements, recent metrics).""", + EmptyBody, EmptyBody, + List($AuthenticatedUserIsRequired, UserHasMissingRoles, UserNotFoundByUserId, UnknownError), + apiTagUser :: Nil, + Some(canGetAnyUser :: Nil), + http4sPartialFunction = Some(getUserByUserId)) + resourceDocs += ResourceDoc( + null, implementedInApiVersion, nameOf(directLoginEndpoint), "POST", + "/my/logins/direct", "Direct Login", + """Direct Login — exchange username/password/consumer_key for a token (via DirectLogin header).""", + EmptyBody, EmptyBody, + List(InvalidLoginCredentials, UsernameHasBeenLocked, UnknownError), + apiTagUser :: Nil, None, + http4sPartialFunction = Some(directLoginEndpoint)) + resourceDocs += ResourceDoc( + null, implementedInApiVersion, nameOf(validateAbacRule), "POST", + "/management/abac-rules/validate", "Validate ABAC Rule", + """Compile-and-validate an ABAC rule body (dry run, no persistence).""", + EmptyBody, EmptyBody, + List($AuthenticatedUserIsRequired, UserHasMissingRoles, InvalidJsonFormat, + AbacRuleCodeEmpty, UnknownError), + apiTagABAC :: Nil, + Some(canCreateAbacRule :: Nil), + http4sPartialFunction = Some(validateAbacRule)) + resourceDocs += ResourceDoc( + null, implementedInApiVersion, nameOf(executeAbacRule), "POST", + "/management/abac-rules/ABAC_RULE_ID/execute", "Execute ABAC Rule", + """Execute an ABAC rule with a specific context.""", + EmptyBody, EmptyBody, + List($AuthenticatedUserIsRequired, UserHasMissingRoles, InvalidJsonFormat, UnknownError), + apiTagABAC :: Nil, + Some(canExecuteAbacRule :: Nil), + http4sPartialFunction = Some(executeAbacRule)) + resourceDocs += ResourceDoc( + null, implementedInApiVersion, nameOf(executeAbacPolicy), "POST", + "/management/abac-policies/POLICY/execute", "Execute ABAC Policy", + """Execute all ABAC rules in a policy (OR logic — first rule that returns true wins).""", + EmptyBody, EmptyBody, + List($AuthenticatedUserIsRequired, UserHasMissingRoles, InvalidJsonFormat, UnknownError), + apiTagABAC :: Nil, + Some(canExecuteAbacRule :: Nil), + http4sPartialFunction = Some(executeAbacPolicy)) + resourceDocs += ResourceDoc( + null, implementedInApiVersion, nameOf(getAbacRuleSchema), "GET", + "/management/abac-rules-schema", "Get ABAC Rule Schema", + """Static schema describing parameters, object types, examples and operators usable in ABAC rules.""", + EmptyBody, EmptyBody, + List($AuthenticatedUserIsRequired, UserHasMissingRoles, UnknownError), + apiTagABAC :: Nil, + Some(canGetAbacRule :: Nil), + http4sPartialFunction = Some(getAbacRuleSchema)) + resourceDocs += ResourceDoc( + null, implementedInApiVersion, nameOf(backupSystemDynamicEntity), "POST", + "/management/system-dynamic-entities/DYNAMIC_ENTITY_ID/backup", "Backup System Level Dynamic Entity", + """Create a _BAK backup of a system-level dynamic entity (definition + data).""", + EmptyBody, EmptyBody, + List($AuthenticatedUserIsRequired, UserHasMissingRoles, UnknownError), + apiTagManageDynamicEntity :: apiTagApi :: Nil, + Some(canBackupSystemDynamicEntity :: Nil), + http4sPartialFunction = Some(backupSystemDynamicEntity)) + resourceDocs += ResourceDoc( + null, implementedInApiVersion, nameOf(backupBankLevelDynamicEntity), "POST", + "/management/banks/BANK_ID/dynamic-entities/DYNAMIC_ENTITY_ID/backup", "Backup Bank Level Dynamic Entity", + """Create a _BAK backup of a bank-level dynamic entity (definition + data).""", + EmptyBody, EmptyBody, + List($AuthenticatedUserIsRequired, UserHasMissingRoles, UnknownError), + apiTagManageDynamicEntity :: apiTagApi :: Nil, + Some(canBackupBankLevelDynamicEntity :: Nil), + http4sPartialFunction = Some(backupBankLevelDynamicEntity)) + resourceDocs += ResourceDoc( + null, implementedInApiVersion, nameOf(deleteSystemDynamicEntityCascade), "DELETE", + "/management/system-dynamic-entities/cascade/DYNAMIC_ENTITY_ID", "Delete System Dynamic Entity Cascade", + """Delete a system-level dynamic entity and all its data records (auto-backs-up to ZZ_BAK_ first).""", + EmptyBody, EmptyBody, + List($AuthenticatedUserIsRequired, UserHasMissingRoles, + CannotDeleteCascadePersonalEntity, UnknownError), + apiTagManageDynamicEntity :: apiTagApi :: Nil, + Some(canDeleteCascadeSystemDynamicEntity :: Nil), + http4sPartialFunction = Some(deleteSystemDynamicEntityCascade)) + } + initFinal9ResourceDocs() + + // Route: GET /obp/v6.0.0/banks/BANK_ID/customers/CUSTOMER_ID/investigation-report + lazy val getCustomerInvestigationReport: HttpRoutes[IO] = HttpRoutes.of[IO] { + case req @ GET -> `prefixPath` / "banks" / _ / "customers" / customerId / "investigation-report" => + EndpointHelpers.withUserAndBank(req) { (_, bank, cc) => + val qp = req.uri.query.params + for { + connectorName <- Future(code.api.Constant.CONNECTOR.openOrThrowException("connector not set")) + _ <- Helper.booleanToFuture(InvestigationReportNotAvailable, cc = Some(cc)) { + connectorName == "mapped" + } + (customer, _) <- NewStyle.function.getCustomerByCustomerId(customerId, Some(cc)) + _ <- Helper.booleanToFuture( + s"Customer bank (${customer.bankId}) does not match BANK_ID (${bank.bankId.value})", + 400, Some(cc))(customer.bankId == bank.bankId.value) + fromDate = qp.get("from_date").flatMap(APIUtil.parseDate) + .getOrElse(new java.util.Date(System.currentTimeMillis() - 365L * 24 * 60 * 60 * 1000)) + toDate = qp.get("to_date").flatMap(APIUtil.parseDate).getOrElse(new java.util.Date()) + limit = qp.get("limit").flatMap(s => scala.util.Try(s.toInt).toOption).getOrElse(500) + accounts <- Future(code.investigation.DoobieInvestigationQueries.getAccountsForCustomer(customerId)) + transactions <- Future(code.investigation.DoobieInvestigationQueries.getTransactionsForAccounts( + accounts.map(_.accountId), bank.bankId.value, + new java.sql.Timestamp(fromDate.getTime), + new java.sql.Timestamp(toDate.getTime), limit)) + customerLinks <- Future(code.investigation.DoobieInvestigationQueries.getCustomerLinks(customerId)) + customerRow = code.investigation.DoobieInvestigationQueries.CustomerRow( + customerId = customer.customerId, legalName = customer.legalName, + email = customer.email, mobileNumber = customer.mobileNumber, + kycStatus = customer.kycStatus) + } yield JSONFactory600.createInvestigationReportJson( + customerRow, bank.bankId.value, accounts, transactions, customerLinks, fromDate, toDate) + } + } + + resourceDocs += ResourceDoc( + null, implementedInApiVersion, nameOf(getCustomerInvestigationReport), "GET", + "/banks/BANK_ID/customers/CUSTOMER_ID/investigation-report", "Get Customer Investigation Report", + """Generate an AML/fraud investigation report for the specified customer.""", + EmptyBody, EmptyBody, + List($AuthenticatedUserIsRequired, $BankNotFound, UserHasMissingRoles, InvestigationReportNotAvailable, UnknownError), + apiTagCustomer :: Nil, + Some(canGetInvestigationReport :: Nil), + http4sPartialFunction = Some(getCustomerInvestigationReport) + ) + + // ─── Phase 2: banks/.../customer-links bucket (5 endpoints) ─────────── + + // Route: POST /obp/v6.0.0/banks/BANK_ID/customer-links (201) + lazy val createCustomerLink: HttpRoutes[IO] = HttpRoutes.of[IO] { + case req @ POST -> `prefixPath` / "banks" / _ / "customer-links" => + EndpointHelpers.executeFutureCreated(req) { + implicit val cc: CallContext = req.callContext + val rawBody = cc.httpBody.getOrElse("") + val bank = cc.bank.get + for { + postedData <- NewStyle.function.tryons(s"$InvalidJsonFormat The Json body should be the PostCustomerLinkJsonV600", 400, Some(cc)) { + net.liftweb.json.parse(rawBody).extract[PostCustomerLinkJsonV600] + } + (customer, _) <- NewStyle.function.getCustomerByCustomerId(postedData.customer_id, Some(cc)) + _ <- Helper.booleanToFuture( + s"Bank of the customer specified by the CUSTOMER_ID(${customer.bankId}) has to match BANK_ID(${bank.bankId.value}) in URL", + 400, Some(cc))(customer.bankId == bank.bankId.value) + (_, _) <- NewStyle.function.getBank(BankId(postedData.other_bank_id), Some(cc)) + (otherCustomer, _) <- NewStyle.function.getCustomerByCustomerId(postedData.other_customer_id, Some(cc)) + _ <- Helper.booleanToFuture( + s"Bank of the other customer specified by the OTHER_CUSTOMER_ID(${otherCustomer.bankId}) has to match OTHER_BANK_ID(${postedData.other_bank_id})", + 400, Some(cc))(otherCustomer.bankId == postedData.other_bank_id) + (customerLink, _) <- NewStyle.function.createCustomerLink( + bank.bankId.value, postedData.customer_id, postedData.other_bank_id, + postedData.other_customer_id, postedData.relationship_to, Some(cc)) + } yield JSONFactory600.createCustomerLinkJson(customerLink) + } + } + + resourceDocs += ResourceDoc( + null, implementedInApiVersion, nameOf(createCustomerLink), "POST", + "/banks/BANK_ID/customer-links", "Create Customer Link", + """Create a link between two customers.""", + EmptyBody, EmptyBody, + List($AuthenticatedUserIsRequired, $BankNotFound, UserHasMissingRoles, InvalidJsonFormat, UnknownError), + apiTagCustomer :: Nil, + Some(canCreateCustomerLink :: Nil), + http4sPartialFunction = Some(createCustomerLink) + ) + + // Route: GET /obp/v6.0.0/banks/BANK_ID/customer-links + lazy val getCustomerLinksByBankId: HttpRoutes[IO] = HttpRoutes.of[IO] { + case req @ GET -> `prefixPath` / "banks" / _ / "customer-links" => + EndpointHelpers.withUserAndBank(req) { (_, bank, cc) => + for { + (links, _) <- NewStyle.function.getCustomerLinksByBankId(bank.bankId.value, Some(cc)) + } yield JSONFactory600.createCustomerLinksJson(links) + } + } + + resourceDocs += ResourceDoc( + null, implementedInApiVersion, nameOf(getCustomerLinksByBankId), "GET", + "/banks/BANK_ID/customer-links", "Get Customer Links by Bank", + """Get all customer links for the specified bank.""", + EmptyBody, EmptyBody, + List($AuthenticatedUserIsRequired, $BankNotFound, UserHasMissingRoles, UnknownError), + apiTagCustomer :: Nil, + Some(canGetCustomerLinks :: Nil), + http4sPartialFunction = Some(getCustomerLinksByBankId) + ) + + // Route: GET /obp/v6.0.0/banks/BANK_ID/customer-links/CUSTOMER_LINK_ID + lazy val getCustomerLinkById: HttpRoutes[IO] = HttpRoutes.of[IO] { + case req @ GET -> `prefixPath` / "banks" / _ / "customer-links" / customerLinkId => + EndpointHelpers.withUserAndBank(req) { (_, _, cc) => + for { + (link, _) <- NewStyle.function.getCustomerLinkById(customerLinkId, Some(cc)) + } yield JSONFactory600.createCustomerLinkJson(link) + } + } + + resourceDocs += ResourceDoc( + null, implementedInApiVersion, nameOf(getCustomerLinkById), "GET", + "/banks/BANK_ID/customer-links/CUSTOMER_LINK_ID", "Get Customer Link by Id", + """Get a customer link by CUSTOMER_LINK_ID.""", + EmptyBody, EmptyBody, + List($AuthenticatedUserIsRequired, $BankNotFound, UserHasMissingRoles, UnknownError), + apiTagCustomer :: Nil, + Some(canGetCustomerLink :: Nil), + http4sPartialFunction = Some(getCustomerLinkById) + ) + + // Route: PUT /obp/v6.0.0/banks/BANK_ID/customer-links/CUSTOMER_LINK_ID + lazy val updateCustomerLink: HttpRoutes[IO] = HttpRoutes.of[IO] { + case req @ PUT -> `prefixPath` / "banks" / _ / "customer-links" / customerLinkId => + EndpointHelpers.executeAndRespond(req) { implicit cc => + val rawBody = cc.httpBody.getOrElse("") + for { + postedData <- NewStyle.function.tryons(s"$InvalidJsonFormat The Json body should be the PutCustomerLinkJsonV600", 400, Some(cc)) { + net.liftweb.json.parse(rawBody).extract[PutCustomerLinkJsonV600] + } + (updated, _) <- NewStyle.function.updateCustomerLinkById(customerLinkId, postedData.relationship_to, Some(cc)) + } yield JSONFactory600.createCustomerLinkJson(updated) + } + } + + resourceDocs += ResourceDoc( + null, implementedInApiVersion, nameOf(updateCustomerLink), "PUT", + "/banks/BANK_ID/customer-links/CUSTOMER_LINK_ID", "Update Customer Link", + """Update the relationship of an existing customer link.""", + EmptyBody, EmptyBody, + List($AuthenticatedUserIsRequired, $BankNotFound, UserHasMissingRoles, InvalidJsonFormat, UnknownError), + apiTagCustomer :: Nil, + Some(canUpdateCustomerLink :: Nil), + http4sPartialFunction = Some(updateCustomerLink) + ) + + // Route: DELETE /obp/v6.0.0/banks/BANK_ID/customer-links/CUSTOMER_LINK_ID + lazy val deleteCustomerLink: HttpRoutes[IO] = HttpRoutes.of[IO] { + case req @ DELETE -> `prefixPath` / "banks" / _ / "customer-links" / customerLinkId => + EndpointHelpers.executeAndRespond(req) { implicit cc => + for { + _ <- NewStyle.function.deleteCustomerLinkById(customerLinkId, Some(cc)) + } yield "" + } + } + + resourceDocs += ResourceDoc( + null, implementedInApiVersion, nameOf(deleteCustomerLink), "DELETE", + "/banks/BANK_ID/customer-links/CUSTOMER_LINK_ID", "Delete Customer Link", + """Delete a customer link by CUSTOMER_LINK_ID.""", + EmptyBody, EmptyBody, + List($AuthenticatedUserIsRequired, $BankNotFound, UserHasMissingRoles, UnknownError), + apiTagCustomer :: Nil, + Some(canDeleteCustomerLink :: Nil), + http4sPartialFunction = Some(deleteCustomerLink) + ) + + // Route: GET /obp/v6.0.0/management/banks/BANK_ID/accounts/ACCOUNT_ID/views/VIEW_ID + lazy val getCustomViewById: HttpRoutes[IO] = HttpRoutes.of[IO] { + case req @ GET -> `prefixPath` / "management" / "banks" / bankIdStr / "accounts" / accountIdStr / "views" / viewIdStr => + EndpointHelpers.withUser(req) { (_, cc) => + for { + view <- ViewNewStyle.customView( + ViewId(viewIdStr), + BankIdAccountId(BankId(bankIdStr), com.openbankproject.commons.model.AccountId(accountIdStr)), + Some(cc)) + } yield JSONFactory600.createViewJsonV600(view) + } + } + + resourceDocs += ResourceDoc( + null, implementedInApiVersion, nameOf(getCustomViewById), "GET", + "/management/banks/BANK_ID/accounts/ACCOUNT_ID/views/VIEW_ID", "Get Custom View by Id", + """Get a single custom view by VIEW_ID for the given account.""", + EmptyBody, EmptyBody, + List($AuthenticatedUserIsRequired, UnknownError), + apiTagView :: Nil, None, + http4sPartialFunction = Some(getCustomViewById) + ) + + // Route: POST /obp/v6.0.0/management/cache/namespaces/invalidate + lazy val invalidateCacheNamespace: HttpRoutes[IO] = HttpRoutes.of[IO] { + case req @ POST -> `prefixPath` / "management" / "cache" / "namespaces" / "invalidate" => + EndpointHelpers.executeAndRespond(req) { implicit cc => + val rawBody = cc.httpBody.getOrElse("") + for { + postJson <- NewStyle.function.tryons(InvalidJsonFormat, 400, Some(cc)) { + net.liftweb.json.parse(rawBody).extract[InvalidateCacheNamespaceJsonV600] + } + namespaceId = postJson.namespace_id + _ <- Helper.booleanToFuture( + s"$InvalidCacheNamespaceId $namespaceId. Valid values: ${Constant.ALL_CACHE_NAMESPACES.mkString(", ")}", + 400, Some(cc))(Constant.ALL_CACHE_NAMESPACES.contains(namespaceId)) + oldVersion = Constant.getCacheNamespaceVersion(namespaceId) + newVersionOpt = Constant.incrementCacheNamespaceVersion(namespaceId) + _ <- Helper.booleanToFuture( + s"Failed to increment cache namespace version for: $namespaceId", + 500, Some(cc))(newVersionOpt.isDefined) + } yield InvalidatedCacheNamespaceJsonV600( + namespace_id = namespaceId, + old_version = oldVersion, + new_version = newVersionOpt.get, + status = "invalidated" + ) + } + } + + resourceDocs += ResourceDoc( + null, implementedInApiVersion, nameOf(invalidateCacheNamespace), "POST", + "/management/cache/namespaces/invalidate", "Invalidate Cache Namespace", + """Increment the version of the specified cache namespace, invalidating its keys.""", + EmptyBody, EmptyBody, + List($AuthenticatedUserIsRequired, UserHasMissingRoles, InvalidJsonFormat, UnknownError), + apiTagCache :: apiTagSystem :: apiTagApi :: Nil, + Some(canInvalidateCacheNamespace :: Nil), + http4sPartialFunction = Some(invalidateCacheNamespace) + ) + + // Route: GET /obp/v6.0.0/management/config-props + lazy val getConfigProps: HttpRoutes[IO] = HttpRoutes.of[IO] { + case req @ GET -> `prefixPath` / "management" / "config-props" => + EndpointHelpers.withUser(req) { (_, _) => + Future { + val props = APIUtil.getConfigPropsPairs.map { case (k, v) => + ConfigPropJsonV600(k, APIUtil.maskSensitivePropValue(k, v)) + } + ListResult("config_props", props) + } + } + } + + resourceDocs += ResourceDoc( + null, implementedInApiVersion, nameOf(getConfigProps), "GET", + "/management/config-props", "Get Config Props", + """Return all OBP config-file props (sensitive values masked).""", + EmptyBody, EmptyBody, + List($AuthenticatedUserIsRequired, UnknownError), + apiTagApi :: Nil, None, + http4sPartialFunction = Some(getConfigProps) + ) + + // Route: GET /obp/v6.0.0/app-directory + lazy val getAppDirectory: HttpRoutes[IO] = HttpRoutes.of[IO] { + case req @ GET -> `prefixPath` / "app-directory" => + EndpointHelpers.executeAndRespond(req) { _ => + Future { + val entries = APIUtil.getAppDiscoveryPairs.map { case (k, v) => ConfigPropJsonV600(k, v) } + ListResult("app_directory", entries) + } + } + } + + resourceDocs += ResourceDoc( + null, implementedInApiVersion, nameOf(getAppDirectory), "GET", + "/app-directory", "Get App Directory", + """Return apps registered in this OBP instance's discovery directory.""", + EmptyBody, EmptyBody, + List(UnknownError), + apiTagApi :: Nil, None, + http4sPartialFunction = Some(getAppDirectory) + ) + + // Route: GET /obp/v6.0.0/management/custom-views + lazy val getCustomViews: HttpRoutes[IO] = HttpRoutes.of[IO] { + case req @ GET -> `prefixPath` / "management" / "custom-views" => + EndpointHelpers.withUser(req) { (_, _) => + Future(JSONFactory600.createViewsJsonV600(code.views.system.ViewDefinition.getCustomViews())) + } + } + + resourceDocs += ResourceDoc( + null, implementedInApiVersion, nameOf(getCustomViews), "GET", + "/management/custom-views", "Get Custom Views", + """Get all custom views defined in this instance.""", + EmptyBody, EmptyBody, + List($AuthenticatedUserIsRequired, UserHasMissingRoles, UnknownError), + apiTagView :: Nil, + Some(canGetCustomViews :: Nil), + http4sPartialFunction = Some(getCustomViews) + ) + + // Route: GET /obp/v6.0.0/management/roles-with-entitlement-counts + lazy val getRolesWithEntitlementCountsAtAllBanks: HttpRoutes[IO] = HttpRoutes.of[IO] { + case req @ GET -> `prefixPath` / "management" / "roles-with-entitlement-counts" => + EndpointHelpers.withUser(req) { (_, _) => + val allRoles = code.api.util.ApiRole.availableRoles.sorted + Future.sequence(allRoles.map { role => + Entitlement.entitlement.vend.getEntitlementsByRoleFuture(role).map { box => + (role, box.map(_.length).getOrElse(0)) + } + }).map(JSONFactory600.createRolesWithEntitlementCountsJson) + } + } + + resourceDocs += ResourceDoc( + null, implementedInApiVersion, nameOf(getRolesWithEntitlementCountsAtAllBanks), "GET", + "/management/roles-with-entitlement-counts", "Get Roles with Entitlement Counts", + """List all available roles along with how many entitlements each has.""", + EmptyBody, EmptyBody, + List($AuthenticatedUserIsRequired, UserHasMissingRoles, UnknownError), + apiTagRole :: Nil, + Some(canGetRolesWithEntitlementCountsAtAllBanks :: Nil), + http4sPartialFunction = Some(getRolesWithEntitlementCountsAtAllBanks) + ) + + // ─── Phase 2: 5 small single-endpoint buckets ───────────────────────── + + // Route: GET /obp/v6.0.0/features + lazy val getFeatures: HttpRoutes[IO] = HttpRoutes.of[IO] { + case req @ GET -> `prefixPath` / "features" => + EndpointHelpers.executeAndRespond(req) { _ => + Future.successful(FeaturesJsonV600( + allow_public_views = APIUtil.getPropsAsBoolValue("allow_public_views", false), + allow_abac_account_access = APIUtil.getPropsAsBoolValue("allow_abac_account_access", false), + allow_account_firehose = APIUtil.getPropsAsBoolValue("allow_account_firehose", false), + allow_customer_firehose = APIUtil.getPropsAsBoolValue("allow_customer_firehose", false), + allow_direct_login = APIUtil.getPropsAsBoolValue("allow_direct_login", true), + allow_gateway_login = APIUtil.getPropsAsBoolValue("allow_gateway_login", false), + allow_oauth2_login = APIUtil.getPropsAsBoolValue("allow_oauth2_login", true), + allow_dauth = APIUtil.getPropsAsBoolValue("allow_dauth", false), + allow_sandbox_account_creation = APIUtil.getPropsAsBoolValue("allow_sandbox_account_creation", false), + allow_sandbox_data_import = APIUtil.getPropsAsBoolValue("allow_sandbox_data_import", false), + allow_account_deletion = APIUtil.getPropsAsBoolValue("allow_account_deletion", false), + allow_just_in_time_entitlements = APIUtil.getPropsAsBoolValue("create_just_in_time_entitlements", false) + )) + } + } + + resourceDocs += ResourceDoc( + null, implementedInApiVersion, nameOf(getFeatures), "GET", + "/features", "Get Features", + """Returns the enabled features for this OBP instance.""", + EmptyBody, EmptyBody, + List(UnknownError), + apiTagApi :: Nil, None, + http4sPartialFunction = Some(getFeatures) + ) + + // Route: GET /obp/v6.0.0/providers + lazy val getProviders: HttpRoutes[IO] = HttpRoutes.of[IO] { + case req @ GET -> `prefixPath` / "providers" => + EndpointHelpers.withUser(req) { (_, _) => + Future(code.model.dataAccess.ResourceUser.getDistinctProviders) + .map(JSONFactory600.createProvidersJson) + } + } + + resourceDocs += ResourceDoc( + null, implementedInApiVersion, nameOf(getProviders), "GET", + "/providers", "Get Providers", + """Get the distinct list of auth providers that have been used to create users.""", + EmptyBody, EmptyBody, + List($AuthenticatedUserIsRequired, UnknownError), + apiTagUser :: Nil, None, + http4sPartialFunction = Some(getProviders) + ) + + // Route: GET /obp/v6.0.0/consumers/current + lazy val getCurrentConsumer: HttpRoutes[IO] = HttpRoutes.of[IO] { + case req @ GET -> `prefixPath` / "consumers" / "current" => + EndpointHelpers.withUser(req) { (_, cc) => + for { + consumer <- Future(cc.consumer match { + case Full(c) => Full(c) + case _ => Empty + }).map(unboxFullOrFail(_, Some(cc), InvalidConsumerCredentials, 401)) + counters <- Future(RateLimitingUtil.consumerRateLimitState(consumer.consumerId.get).toList) + date = new java.util.Date() + (activeRateLimit, ids) <- RateLimitingUtil.getActiveRateLimitsWithIds(consumer.consumerId.get, date) + } yield CurrentConsumerJsonV600( + consumer.name.get, consumer.appType.get, consumer.description.get, consumer.consumerId.get, + JSONFactory600.createActiveRateLimitsJsonV600FromCallLimit(activeRateLimit, ids, date), + JSONFactory600.createRedisCallCountersJson(counters)) + } + } + + resourceDocs += ResourceDoc( + null, implementedInApiVersion, nameOf(getCurrentConsumer), "GET", + "/consumers/current", "Get Current Consumer", + """Get the Consumer used to make this request, including active rate limits and call counters.""", + EmptyBody, EmptyBody, + List($AuthenticatedUserIsRequired, InvalidConsumerCredentials, UnknownError), + apiTagConsumer :: Nil, + Some(canGetCurrentConsumer :: Nil), + http4sPartialFunction = Some(getCurrentConsumer) + ) + + // Route: GET /obp/v6.0.0/api/popular-endpoints + lazy val getPopularApis: HttpRoutes[IO] = HttpRoutes.of[IO] { + case req @ GET -> `prefixPath` / "api" / "popular-endpoints" => + EndpointHelpers.executeAndRespond(req) { implicit cc => + for { + httpParams <- NewStyle.function.extractHttpParamsFromUrl(req.uri.renderString) + (qp, _) <- createQueriesByHttpParamsFuture(httpParams, Some(cc)) + withLimit = qp ++ List(code.api.util.OBPLimit(50)) + topApis <- APIMetrics.apiMetrics.vend.getTopApisFuture(withLimit) + .map(unboxFullOrFail(_, Some(cc), UnknownError)) + } yield { + val lookupMap = APIUtil.getAllResourceDocs.map(d => d.partialFunctionName -> d.operationId).toMap + val operationIds = topApis.flatMap(api => + lookupMap.get(api.ImplementedByPartialFunction).orElse( + scala.util.Try(Some(APIUtil.buildOperationId( + ApiVersionUtils.valueOf(api.implementedInVersion), api.ImplementedByPartialFunction))).getOrElse(None))) + PopularApisJsonV600(operationIds) + } + } + } + + resourceDocs += ResourceDoc( + null, implementedInApiVersion, nameOf(getPopularApis), "GET", + "/api/popular-endpoints", "Get Popular Endpoints", + """Returns the operation IDs of the 50 most popular endpoints by usage.""", + EmptyBody, EmptyBody, + List(UnknownError), + apiTagApi :: Nil, None, + http4sPartialFunction = Some(getPopularApis) + ) + + // Route: GET /obp/v6.0.0/banks/BANK_ID/account-directory + lazy val getAccountDirectory: HttpRoutes[IO] = HttpRoutes.of[IO] { + case req @ GET -> `prefixPath` / "banks" / _ / "account-directory" => + EndpointHelpers.withUserAndBank(req) { (_, bank, cc) => + val allowedParams = List("limit", "offset", "sort_direction") + for { + httpParams <- NewStyle.function.extractHttpParamsFromUrl(req.uri.renderString) + (obpQueryParams, _) <- NewStyle.function.createObpParams(httpParams, allowedParams, Some(cc)) + (accounts, _) <- NewStyle.function.getAccountDirectory(bank.bankId, obpQueryParams, Some(cc)) + } yield { + val viewsPerAccount: Map[BankIdAccountId, List[String]] = accounts.map { a => + val biaId = BankIdAccountId(BankId(a.bankId), com.openbankproject.commons.model.AccountId(a.id)) + biaId -> Views.views.vend.availableViewsForAccount(biaId).map(_.viewId.value) + }.toMap + JSONFactory600.createAccountDirectoryJsonV600(accounts, viewsPerAccount) + } + } + } + + resourceDocs += ResourceDoc( + null, implementedInApiVersion, nameOf(getAccountDirectory), "GET", + "/banks/BANK_ID/account-directory", "Get Account Directory", + """Get the list of accounts in the bank's account directory (paginated, sortable).""", + EmptyBody, EmptyBody, + List($AuthenticatedUserIsRequired, $BankNotFound, UserHasMissingRoles, UnknownError), + apiTagAccount :: Nil, + Some(canGetAccountDirectoryAtOneBank :: Nil), + http4sPartialFunction = Some(getAccountDirectory) + ) + + // ─── Phase 2: management/groups bucket (6 endpoints) ────────────────── + // Doc roles are kept None and the bank-scoped vs system-scoped role check + // is done inline (it depends on whether bank_id is supplied in the body or + // on the existing group). + + private def groupRoleCheck(bankId: Option[String], userId: String, + bankRole: code.api.util.ApiRole, + allBanksRole: code.api.util.ApiRole, + cc: CallContext): Future[Any] = bankId match { + case Some(b) if b.nonEmpty => + NewStyle.function.hasAtLeastOneEntitlement(b, userId, bankRole :: allBanksRole :: Nil, Some(cc)) + case _ => + NewStyle.function.hasEntitlement("", userId, allBanksRole, Some(cc)) + } + + private def groupToJsonV600(group: GroupT): GroupJsonV600 = + GroupJsonV600( + group_id = group.groupId, bank_id = group.bankId, + group_name = group.groupName, group_description = group.groupDescription, + list_of_roles = group.listOfRoles, is_enabled = group.isEnabled) + + // Route: POST /obp/v6.0.0/management/groups (201) + lazy val createGroup: HttpRoutes[IO] = HttpRoutes.of[IO] { + case req @ POST -> `prefixPath` / "management" / "groups" => + EndpointHelpers.executeFutureCreated(req) { + implicit val cc: CallContext = req.callContext + val rawBody = cc.httpBody.getOrElse("") + val user = cc.user.openOrThrowException(AuthenticatedUserIsRequired) + for { + postJson <- NewStyle.function.tryons(s"$InvalidJsonFormat The Json body should be the PostGroupJsonV600", 400, Some(cc)) { + net.liftweb.json.parse(rawBody).extract[PostGroupJsonV600] + } + _ <- Helper.booleanToFuture(s"$InvalidJsonFormat group_name cannot be empty", cc = Some(cc)) { + postJson.group_name.nonEmpty + } + _ <- groupRoleCheck(postJson.bank_id, user.userId, canCreateGroupAtOneBank, canCreateGroupAtAllBanks, cc) + group <- Future(code.group.GroupTrait.group.vend.createGroup( + postJson.bank_id.filter(_.nonEmpty), postJson.group_name, + postJson.group_description, postJson.list_of_roles, postJson.is_enabled + )).map(x => unboxFullOrFail(x, Some(cc), s"$UnknownError Cannot create group", 400)) + } yield groupToJsonV600(group) + } + } + + resourceDocs += ResourceDoc( + null, implementedInApiVersion, nameOf(createGroup), "POST", + "/management/groups", "Create Group", + """Create a new Group.""", + EmptyBody, EmptyBody, + List($AuthenticatedUserIsRequired, UserHasMissingRoles, InvalidJsonFormat, UnknownError), + apiTagGroup :: Nil, None, + http4sPartialFunction = Some(createGroup) + ) + + // Route: GET /obp/v6.0.0/management/groups/GROUP_ID + lazy val getGroup: HttpRoutes[IO] = HttpRoutes.of[IO] { + case req @ GET -> `prefixPath` / "management" / "groups" / groupId => + EndpointHelpers.withUser(req) { (user, cc) => + for { + group <- Future(code.group.GroupTrait.group.vend.getGroup(groupId)) + .map(x => unboxFullOrFail(x, Some(cc), s"$UnknownError Group not found", 404)) + _ <- groupRoleCheck(group.bankId, user.userId, canGetGroupsAtOneBank, canGetGroupsAtAllBanks, cc) + } yield groupToJsonV600(group) + } + } + + resourceDocs += ResourceDoc( + null, implementedInApiVersion, nameOf(getGroup), "GET", + "/management/groups/GROUP_ID", "Get Group", + """Get a Group by ID.""", + EmptyBody, EmptyBody, + List($AuthenticatedUserIsRequired, UserHasMissingRoles, UnknownError), + apiTagGroup :: Nil, None, + http4sPartialFunction = Some(getGroup) + ) + + // Route: GET /obp/v6.0.0/management/groups + lazy val getGroups: HttpRoutes[IO] = HttpRoutes.of[IO] { + case req @ GET -> `prefixPath` / "management" / "groups" => + EndpointHelpers.withUser(req) { (user, cc) => + val bankIdParam = req.uri.query.params.get("bank_id") + val bankIdFilter = bankIdParam match { + case Some("null") | Some("") => None + case Some(id) => Some(id) + case None => None + } + for { + _ <- groupRoleCheck(bankIdFilter, user.userId, canGetGroupsAtOneBank, canGetGroupsAtAllBanks, cc) + groups <- (bankIdFilter match { + case Some(b) => code.group.GroupTrait.group.vend.getGroupsByBankId(Some(b)) + case None if bankIdParam.isDefined => code.group.GroupTrait.group.vend.getGroupsByBankId(None) + case None => code.group.GroupTrait.group.vend.getAllGroups() + }).map(x => unboxFullOrFail(x, Some(cc), s"$UnknownError Cannot get groups", 400)) + } yield GroupsJsonV600(groups.map(groupToJsonV600)) + } + } + + resourceDocs += ResourceDoc( + null, implementedInApiVersion, nameOf(getGroups), "GET", + "/management/groups", "Get Groups", + """Get all Groups (optional ?bank_id= filter).""", + EmptyBody, EmptyBody, + List($AuthenticatedUserIsRequired, UserHasMissingRoles, UnknownError), + apiTagGroup :: Nil, None, + http4sPartialFunction = Some(getGroups) + ) + + // Route: PUT /obp/v6.0.0/management/groups/GROUP_ID + lazy val updateGroup: HttpRoutes[IO] = HttpRoutes.of[IO] { + case req @ PUT -> `prefixPath` / "management" / "groups" / groupId => + EndpointHelpers.executeAndRespond(req) { implicit cc => + val rawBody = cc.httpBody.getOrElse("") + val user = cc.user.openOrThrowException(AuthenticatedUserIsRequired) + for { + putJson <- NewStyle.function.tryons(s"$InvalidJsonFormat The Json body should be the PutGroupJsonV600", 400, Some(cc)) { + net.liftweb.json.parse(rawBody).extract[PutGroupJsonV600] + } + existing <- Future(code.group.GroupTrait.group.vend.getGroup(groupId)) + .map(x => unboxFullOrFail(x, Some(cc), s"$UnknownError Group not found", 404)) + _ <- groupRoleCheck(existing.bankId, user.userId, canUpdateGroupAtOneBank, canUpdateGroupAtAllBanks, cc) + updated <- Future(code.group.GroupTrait.group.vend.updateGroup( + groupId, putJson.group_name, putJson.group_description, + putJson.list_of_roles, putJson.is_enabled + )).map(x => unboxFullOrFail(x, Some(cc), s"$UnknownError Cannot update group", 400)) + } yield groupToJsonV600(updated) + } + } + + resourceDocs += ResourceDoc( + null, implementedInApiVersion, nameOf(updateGroup), "PUT", + "/management/groups/GROUP_ID", "Update Group", + """Update an existing Group.""", + EmptyBody, EmptyBody, + List($AuthenticatedUserIsRequired, UserHasMissingRoles, InvalidJsonFormat, UnknownError), + apiTagGroup :: Nil, None, + http4sPartialFunction = Some(updateGroup) + ) + + // Route: DELETE /obp/v6.0.0/management/groups/GROUP_ID + lazy val deleteGroup: HttpRoutes[IO] = HttpRoutes.of[IO] { + case req @ DELETE -> `prefixPath` / "management" / "groups" / groupId => + EndpointHelpers.executeAndRespond(req) { implicit cc => + val user = cc.user.openOrThrowException(AuthenticatedUserIsRequired) + for { + existing <- Future(code.group.GroupTrait.group.vend.getGroup(groupId)) + .map(x => unboxFullOrFail(x, Some(cc), s"$UnknownError Group not found", 404)) + _ <- groupRoleCheck(existing.bankId, user.userId, canDeleteGroupAtOneBank, canDeleteGroupAtAllBanks, cc) + _ <- Future(code.group.GroupTrait.group.vend.deleteGroup(groupId)) + .map(x => unboxFullOrFail(x, Some(cc), s"$UnknownError Cannot delete group", 400)) + } yield "" + } + } + + resourceDocs += ResourceDoc( + null, implementedInApiVersion, nameOf(deleteGroup), "DELETE", + "/management/groups/GROUP_ID", "Delete Group", + """Delete a Group.""", + EmptyBody, EmptyBody, + List($AuthenticatedUserIsRequired, UserHasMissingRoles, UnknownError), + apiTagGroup :: Nil, None, + http4sPartialFunction = Some(deleteGroup) + ) + + // Route: GET /obp/v6.0.0/management/groups/GROUP_ID/entitlements + lazy val getGroupEntitlements: HttpRoutes[IO] = HttpRoutes.of[IO] { + case req @ GET -> `prefixPath` / "management" / "groups" / groupId / "entitlements" => + EndpointHelpers.withUser(req) { (_, cc) => + for { + _ <- Future(code.group.GroupTrait.group.vend.getGroup(groupId)) + .map(x => unboxFullOrFail(x, Some(cc), s"$UnknownError Group not found", 404)) + groupEntitlements <- Entitlement.entitlement.vend.getEntitlementsByGroupId(groupId) + .map(x => unboxFullOrFail(x, Some(cc), s"$UnknownError Cannot get entitlements", 400)) + withUsernames <- Future.sequence(groupEntitlements.map { ent => + Users.users.vend.getUserByUserIdFuture(ent.userId).map { userBox => + GroupEntitlementJsonV600( + entitlement_id = ent.entitlementId, role_name = ent.roleName, + bank_id = ent.bankId, user_id = ent.userId, + username = userBox.map(_.name).getOrElse(""), + group_id = ent.groupId, process = ent.process) + } + }) + } yield GroupEntitlementsJsonV600(withUsernames) + } + } + + resourceDocs += ResourceDoc( + null, implementedInApiVersion, nameOf(getGroupEntitlements), "GET", + "/management/groups/GROUP_ID/entitlements", "Get Group Entitlements", + """Get all entitlements granted to the specified group.""", + EmptyBody, EmptyBody, + List($AuthenticatedUserIsRequired, UserHasMissingRoles, UnknownError), + apiTagGroup :: Nil, + Some(canGetEntitlementsForAnyBank :: Nil), + http4sPartialFunction = Some(getGroupEntitlements) + ) + + // ─── Phase 2: management/abac-rules bucket (6 of 8) ─────────────────── + // executeAbacRule + validateAbacRule deferred — complex error + // classification + rule-engine integration warrants its own batch. + + // Route: POST /obp/v6.0.0/management/abac-rules (201) + lazy val createAbacRule: HttpRoutes[IO] = HttpRoutes.of[IO] { + case req @ POST -> `prefixPath` / "management" / "abac-rules" => + EndpointHelpers.executeFutureCreated(req) { + implicit val cc: CallContext = req.callContext + val rawBody = cc.httpBody.getOrElse("") + val user = cc.user.openOrThrowException(AuthenticatedUserIsRequired) + for { + createJson <- NewStyle.function.tryons(InvalidJsonFormat, 400, Some(cc)) { + net.liftweb.json.parse(rawBody).extract[CreateAbacRuleJsonV600] + } + _ <- Helper.booleanToFuture("Rule name must not be empty", cc = Some(cc)) { createJson.rule_name.nonEmpty } + _ <- Helper.booleanToFuture("Rule code must not be empty", cc = Some(cc)) { createJson.rule_code.nonEmpty } + _ <- AbacRuleEngine.validateRuleCodeAsync(createJson.rule_code) + .map(unboxFullOrFail(_, Some(cc), "Invalid ABAC rule code", 400)) + rule <- Future(MappedAbacRuleProvider.createAbacRule( + ruleName = createJson.rule_name, ruleCode = createJson.rule_code, + description = createJson.description, policy = createJson.policy, + isActive = createJson.is_active, createdBy = user.userId + )).map(unboxFullOrFail(_, Some(cc), "Could not create ABAC rule", 400)) + } yield createAbacRuleJsonV600(rule) + } + } + + resourceDocs += ResourceDoc( + null, implementedInApiVersion, nameOf(createAbacRule), "POST", + "/management/abac-rules", "Create ABAC Rule", + """Create a new ABAC rule.""", + EmptyBody, EmptyBody, + List($AuthenticatedUserIsRequired, UserHasMissingRoles, InvalidJsonFormat, UnknownError), + apiTagABAC :: Nil, + Some(canCreateAbacRule :: Nil), + http4sPartialFunction = Some(createAbacRule) + ) + + // Route: GET /obp/v6.0.0/management/abac-rules/ABAC_RULE_ID + lazy val getAbacRule: HttpRoutes[IO] = HttpRoutes.of[IO] { + case req @ GET -> `prefixPath` / "management" / "abac-rules" / ruleId => + EndpointHelpers.withUser(req) { (_, cc) => + for { + rule <- Future(MappedAbacRuleProvider.getAbacRuleById(ruleId)) + .map(unboxFullOrFail(_, Some(cc), s"ABAC Rule not found with ID: $ruleId", 404)) + } yield createAbacRuleJsonV600(rule) + } + } + + resourceDocs += ResourceDoc( + null, implementedInApiVersion, nameOf(getAbacRule), "GET", + "/management/abac-rules/ABAC_RULE_ID", "Get ABAC Rule", + """Get a single ABAC rule by ID.""", + EmptyBody, EmptyBody, + List($AuthenticatedUserIsRequired, UserHasMissingRoles, UnknownError), + apiTagABAC :: Nil, + Some(canGetAbacRule :: Nil), + http4sPartialFunction = Some(getAbacRule) + ) + + // Route: GET /obp/v6.0.0/management/abac-rules + lazy val getAbacRules: HttpRoutes[IO] = HttpRoutes.of[IO] { + case req @ GET -> `prefixPath` / "management" / "abac-rules" => + EndpointHelpers.withUser(req) { (_, _) => + Future(createAbacRulesJsonV600(MappedAbacRuleProvider.getAllAbacRules())) + } + } + + resourceDocs += ResourceDoc( + null, implementedInApiVersion, nameOf(getAbacRules), "GET", + "/management/abac-rules", "Get ABAC Rules", + """Get all ABAC rules.""", + EmptyBody, EmptyBody, + List($AuthenticatedUserIsRequired, UserHasMissingRoles, UnknownError), + apiTagABAC :: Nil, + Some(canGetAbacRule :: Nil), + http4sPartialFunction = Some(getAbacRules) + ) + + // Route: GET /obp/v6.0.0/management/abac-rules/policy/POLICY + lazy val getAbacRulesByPolicy: HttpRoutes[IO] = HttpRoutes.of[IO] { + case req @ GET -> `prefixPath` / "management" / "abac-rules" / "policy" / policy => + EndpointHelpers.withUser(req) { (_, _) => + Future(createAbacRulesJsonV600(MappedAbacRuleProvider.getAbacRulesByPolicy(policy))) + } + } + + resourceDocs += ResourceDoc( + null, implementedInApiVersion, nameOf(getAbacRulesByPolicy), "GET", + "/management/abac-rules/policy/POLICY", "Get ABAC Rules by Policy", + """Get all ABAC rules for a given policy.""", + EmptyBody, EmptyBody, + List($AuthenticatedUserIsRequired, UserHasMissingRoles, UnknownError), + apiTagABAC :: Nil, + Some(canGetAbacRule :: Nil), + http4sPartialFunction = Some(getAbacRulesByPolicy) + ) + + // Route: PUT /obp/v6.0.0/management/abac-rules/ABAC_RULE_ID + lazy val updateAbacRule: HttpRoutes[IO] = HttpRoutes.of[IO] { + case req @ PUT -> `prefixPath` / "management" / "abac-rules" / ruleId => + EndpointHelpers.executeAndRespond(req) { implicit cc => + val rawBody = cc.httpBody.getOrElse("") + val user = cc.user.openOrThrowException(AuthenticatedUserIsRequired) + for { + updateJson <- NewStyle.function.tryons(InvalidJsonFormat, 400, Some(cc)) { + net.liftweb.json.parse(rawBody).extract[UpdateAbacRuleJsonV600] + } + _ <- AbacRuleEngine.validateRuleCodeAsync(updateJson.rule_code) + .map(unboxFullOrFail(_, Some(cc), "Invalid ABAC rule code", 400)) + rule <- Future(MappedAbacRuleProvider.updateAbacRule( + ruleId = ruleId, ruleName = updateJson.rule_name, + ruleCode = updateJson.rule_code, description = updateJson.description, + policy = updateJson.policy, isActive = updateJson.is_active, + updatedBy = user.userId + )).map(unboxFullOrFail(_, Some(cc), s"Could not update ABAC rule with ID: $ruleId", 400)) + _ <- Future(AbacRuleEngine.clearRuleFromCache(ruleId)) + } yield createAbacRuleJsonV600(rule) + } + } + + resourceDocs += ResourceDoc( + null, implementedInApiVersion, nameOf(updateAbacRule), "PUT", + "/management/abac-rules/ABAC_RULE_ID", "Update ABAC Rule", + """Update an existing ABAC rule.""", + EmptyBody, EmptyBody, + List($AuthenticatedUserIsRequired, UserHasMissingRoles, InvalidJsonFormat, UnknownError), + apiTagABAC :: Nil, + Some(canUpdateAbacRule :: Nil), + http4sPartialFunction = Some(updateAbacRule) + ) + + // Route: DELETE /obp/v6.0.0/management/abac-rules/ABAC_RULE_ID + lazy val deleteAbacRule: HttpRoutes[IO] = HttpRoutes.of[IO] { + case req @ DELETE -> `prefixPath` / "management" / "abac-rules" / ruleId => + EndpointHelpers.executeAndRespond(req) { implicit cc => + for { + _ <- Future(MappedAbacRuleProvider.deleteAbacRule(ruleId)) + .map(unboxFullOrFail(_, Some(cc), s"Could not delete ABAC rule with ID: $ruleId", 400)) + _ <- Future(AbacRuleEngine.clearRuleFromCache(ruleId)) + } yield "" + } + } + + resourceDocs += ResourceDoc( + null, implementedInApiVersion, nameOf(deleteAbacRule), "DELETE", + "/management/abac-rules/ABAC_RULE_ID", "Delete ABAC Rule", + """Delete an ABAC rule.""", + EmptyBody, EmptyBody, + List($AuthenticatedUserIsRequired, UserHasMissingRoles, UnknownError), + apiTagABAC :: Nil, + Some(canDeleteAbacRule :: Nil), + http4sPartialFunction = Some(deleteAbacRule) + ) + + + // ─── Phase 2: my/personal-data-fields bucket (5 endpoints) ──────────── + // Auth-only; the v6 Lift docs declare `Some(List())` empty role list. + + private val personalDataTypeErrorMsg = + s"$InvalidJsonFormat The `type` field can only accept: ${UserAttributeType.DOUBLE}, ${UserAttributeType.STRING}, ${UserAttributeType.INTEGER}, ${UserAttributeType.DATE_WITH_DAY}" + + // Route: POST /obp/v6.0.0/my/personal-data-fields (201) + lazy val createPersonalDataField: HttpRoutes[IO] = HttpRoutes.of[IO] { + case req @ POST -> `prefixPath` / "my" / "personal-data-fields" => + EndpointHelpers.executeFutureCreated(req) { + implicit val cc: CallContext = req.callContext + val rawBody = cc.httpBody.getOrElse("") + val user = cc.user.openOrThrowException(AuthenticatedUserIsRequired) + for { + postedData <- NewStyle.function.tryons(s"$InvalidJsonFormat The Json body should be the UserAttributeJsonV510", 400, Some(cc)) { + net.liftweb.json.parse(rawBody).extract[UserAttributeJsonV510] + } + userAttributeType <- NewStyle.function.tryons(personalDataTypeErrorMsg, 400, Some(cc)) { + UserAttributeType.withName(postedData.`type`) + } + (userAttribute, _) <- NewStyle.function.createOrUpdateUserAttribute( + user.userId, None, postedData.name, userAttributeType, postedData.value, true, Some(cc)) + } yield JSONFactory510.createUserAttributeJson(userAttribute) + } + } + + resourceDocs += ResourceDoc( + null, implementedInApiVersion, nameOf(createPersonalDataField), "POST", + "/my/personal-data-fields", "Create Personal Data Field", + """Create a personal data field for the logged-in user.""", + EmptyBody, EmptyBody, + List($AuthenticatedUserIsRequired, InvalidJsonFormat, UnknownError), + apiTagUser :: Nil, + Some(Nil), + http4sPartialFunction = Some(createPersonalDataField) + ) + + // Route: GET /obp/v6.0.0/my/personal-data-fields + lazy val getPersonalDataFields: HttpRoutes[IO] = HttpRoutes.of[IO] { + case req @ GET -> `prefixPath` / "my" / "personal-data-fields" => + EndpointHelpers.withUser(req) { (user, cc) => + for { + (attributes, _) <- NewStyle.function.getPersonalUserAttributes(user.userId, Some(cc)) + } yield UserAttributesResponseJsonV510(attributes.map(JSONFactory510.createUserAttributeJson)) + } + } + + resourceDocs += ResourceDoc( + null, implementedInApiVersion, nameOf(getPersonalDataFields), "GET", + "/my/personal-data-fields", "Get Personal Data Fields", + """Get all personal data fields for the logged-in user.""", + EmptyBody, EmptyBody, + List($AuthenticatedUserIsRequired, UnknownError), + apiTagUser :: Nil, + Some(Nil), + http4sPartialFunction = Some(getPersonalDataFields) + ) + + // Route: GET /obp/v6.0.0/my/personal-data-fields/USER_ATTRIBUTE_ID + lazy val getPersonalDataFieldById: HttpRoutes[IO] = HttpRoutes.of[IO] { + case req @ GET -> `prefixPath` / "my" / "personal-data-fields" / userAttributeId => + EndpointHelpers.withUser(req) { (user, cc) => + for { + (attributes, _) <- NewStyle.function.getPersonalUserAttributes(user.userId, Some(cc)) + attribute <- Future(attributes.find(_.userAttributeId == userAttributeId)) + .map(unboxFullOrFail(_, Some(cc), UserAttributeNotFound, 404)) + } yield JSONFactory510.createUserAttributeJson(attribute) + } + } + + resourceDocs += ResourceDoc( + null, implementedInApiVersion, nameOf(getPersonalDataFieldById), "GET", + "/my/personal-data-fields/USER_ATTRIBUTE_ID", "Get Personal Data Field By Id", + """Get a personal data field by USER_ATTRIBUTE_ID for the logged-in user.""", + EmptyBody, EmptyBody, + List($AuthenticatedUserIsRequired, UserAttributeNotFound, UnknownError), + apiTagUser :: Nil, + Some(Nil), + http4sPartialFunction = Some(getPersonalDataFieldById) + ) + + // Route: PUT /obp/v6.0.0/my/personal-data-fields/USER_ATTRIBUTE_ID + lazy val updatePersonalDataField: HttpRoutes[IO] = HttpRoutes.of[IO] { + case req @ PUT -> `prefixPath` / "my" / "personal-data-fields" / userAttributeId => + EndpointHelpers.executeAndRespond(req) { implicit cc => + val rawBody = cc.httpBody.getOrElse("") + val user = cc.user.openOrThrowException(AuthenticatedUserIsRequired) + for { + postedData <- NewStyle.function.tryons(s"$InvalidJsonFormat The Json body should be the UserAttributeJsonV510", 400, Some(cc)) { + net.liftweb.json.parse(rawBody).extract[UserAttributeJsonV510] + } + userAttributeType <- NewStyle.function.tryons(personalDataTypeErrorMsg, 400, Some(cc)) { + UserAttributeType.withName(postedData.`type`) + } + (attributes, _) <- NewStyle.function.getPersonalUserAttributes(user.userId, Some(cc)) + _ <- Future(attributes.find(_.userAttributeId == userAttributeId)) + .map(unboxFullOrFail(_, Some(cc), UserAttributeNotFound, 404)) + (userAttribute, _) <- NewStyle.function.createOrUpdateUserAttribute( + user.userId, Some(userAttributeId), postedData.name, userAttributeType, postedData.value, true, Some(cc)) + } yield JSONFactory510.createUserAttributeJson(userAttribute) + } + } + + resourceDocs += ResourceDoc( + null, implementedInApiVersion, nameOf(updatePersonalDataField), "PUT", + "/my/personal-data-fields/USER_ATTRIBUTE_ID", "Update Personal Data Field", + """Update a personal data field by USER_ATTRIBUTE_ID.""", + EmptyBody, EmptyBody, + List($AuthenticatedUserIsRequired, InvalidJsonFormat, UserAttributeNotFound, UnknownError), + apiTagUser :: Nil, + Some(Nil), + http4sPartialFunction = Some(updatePersonalDataField) + ) + + // Route: DELETE /obp/v6.0.0/my/personal-data-fields/USER_ATTRIBUTE_ID + lazy val deletePersonalDataField: HttpRoutes[IO] = HttpRoutes.of[IO] { + case req @ DELETE -> `prefixPath` / "my" / "personal-data-fields" / userAttributeId => + EndpointHelpers.executeAndRespond(req) { implicit cc => + val user = cc.user.openOrThrowException(AuthenticatedUserIsRequired) + for { + (attributes, _) <- NewStyle.function.getPersonalUserAttributes(user.userId, Some(cc)) + _ <- Future(attributes.find(_.userAttributeId == userAttributeId)) + .map(unboxFullOrFail(_, Some(cc), UserAttributeNotFound, 404)) + _ <- BankConnector.connector.vend.deleteUserAttribute(userAttributeId, Some(cc)) + } yield "" + } + } + + resourceDocs += ResourceDoc( + null, implementedInApiVersion, nameOf(deletePersonalDataField), "DELETE", + "/my/personal-data-fields/USER_ATTRIBUTE_ID", "Delete Personal Data Field", + """Delete a personal data field by USER_ATTRIBUTE_ID.""", + EmptyBody, EmptyBody, + List($AuthenticatedUserIsRequired, UserAttributeNotFound, UnknownError), + apiTagUser :: Nil, + Some(Nil), + http4sPartialFunction = Some(deletePersonalDataField) + ) + + // ─── Phase 2: management/consumers bucket (6 endpoints) ─────────────── + + // Route: GET /obp/v6.0.0/management/consumers/CONSUMER_ID/call-counters + lazy val getConsumerCallCounters: HttpRoutes[IO] = HttpRoutes.of[IO] { + case req @ GET -> `prefixPath` / "management" / "consumers" / consumerId / "call-counters" => + EndpointHelpers.withUser(req) { (_, cc) => + for { + _ <- NewStyle.function.getConsumerByConsumerId(consumerId, Some(cc)) + counters <- Future(RateLimitingUtil.consumerRateLimitState(consumerId).toList) + } yield JSONFactory600.createRedisCallCountersJson(counters) + } + } + + resourceDocs += ResourceDoc( + null, implementedInApiVersion, nameOf(getConsumerCallCounters), "GET", + "/management/consumers/CONSUMER_ID/call-counters", "Get Consumer Call Counters", + """Get the current call counters (Redis-backed) for a specific consumer.""", + EmptyBody, EmptyBody, + List($AuthenticatedUserIsRequired, UserHasMissingRoles, UnknownError), + apiTagConsumer :: Nil, + Some(canGetRateLimits :: Nil), + http4sPartialFunction = Some(getConsumerCallCounters) + ) + + // Route: POST /obp/v6.0.0/management/consumers/CONSUMER_ID/consumer/rate-limits (201) + lazy val createCallLimits: HttpRoutes[IO] = HttpRoutes.of[IO] { + case req @ POST -> `prefixPath` / "management" / "consumers" / consumerId / "consumer" / "rate-limits" => + EndpointHelpers.executeFutureCreated(req) { + implicit val cc: CallContext = req.callContext + val rawBody = cc.httpBody.getOrElse("") + for { + postJson <- NewStyle.function.tryons(s"$InvalidJsonFormat The Json body should be the CallLimitPostJsonV600", 400, Some(cc)) { + net.liftweb.json.parse(rawBody).extract[CallLimitPostJsonV600] + } + _ <- NewStyle.function.getConsumerByConsumerId(consumerId, Some(cc)) + rateLimitingBox <- RateLimitingDI.rateLimiting.vend.createConsumerCallLimits( + consumerId, postJson.from_date, postJson.to_date, + postJson.api_version, postJson.api_name, postJson.bank_id, + Some(postJson.per_second_call_limit), Some(postJson.per_minute_call_limit), + Some(postJson.per_hour_call_limit), Some(postJson.per_day_call_limit), + Some(postJson.per_week_call_limit), Some(postJson.per_month_call_limit)) + rateLimiting <- Future(unboxFullOrFail(rateLimitingBox, Some(cc), UnknownError, 400)) + } yield JSONFactory600.createCallLimitJsonV600(rateLimiting) + } + } + + resourceDocs += ResourceDoc( + null, implementedInApiVersion, nameOf(createCallLimits), "POST", + "/management/consumers/CONSUMER_ID/consumer/rate-limits", "Create Rate Limits for a Consumer", + """Create a rate-limit configuration for a Consumer.""", + EmptyBody, EmptyBody, + List($AuthenticatedUserIsRequired, UserHasMissingRoles, InvalidJsonFormat, UnknownError), + apiTagConsumer :: Nil, + Some(canCreateRateLimits :: Nil), + http4sPartialFunction = Some(createCallLimits) + ) + + // Route: PUT /obp/v6.0.0/management/consumers/CONSUMER_ID/consumer/rate-limits/RATE_LIMITING_ID + lazy val updateRateLimits: HttpRoutes[IO] = HttpRoutes.of[IO] { + case req @ PUT -> `prefixPath` / "management" / "consumers" / consumerId / "consumer" / "rate-limits" / rateLimitingId => + EndpointHelpers.executeAndRespond(req) { implicit cc => + val rawBody = cc.httpBody.getOrElse("") + for { + postJson <- NewStyle.function.tryons(s"$InvalidJsonFormat The Json body should be the CallLimitPostJsonV400", 400, Some(cc)) { + net.liftweb.json.parse(rawBody).extract[CallLimitPostJsonV400] + } + _ <- NewStyle.function.getConsumerByConsumerId(consumerId, Some(cc)) + _ <- RateLimitingDI.rateLimiting.vend.updateConsumerCallLimits( + rateLimitingId, postJson.from_date, postJson.to_date, + postJson.api_version, postJson.api_name, postJson.bank_id, + Some(postJson.per_second_call_limit), Some(postJson.per_minute_call_limit), + Some(postJson.per_hour_call_limit), Some(postJson.per_day_call_limit), + Some(postJson.per_week_call_limit), Some(postJson.per_month_call_limit)) + date = new java.util.Date() + (activeRateLimit, ids) <- RateLimitingUtil.getActiveRateLimitsWithIds(consumerId, date) + } yield JSONFactory600.createActiveRateLimitsJsonV600FromCallLimit(activeRateLimit, ids, date) + } + } + + resourceDocs += ResourceDoc( + null, implementedInApiVersion, nameOf(updateRateLimits), "PUT", + "/management/consumers/CONSUMER_ID/consumer/rate-limits/RATE_LIMITING_ID", "Update Rate Limits for a Consumer", + """Update an existing rate-limit configuration for a Consumer.""", + EmptyBody, EmptyBody, + List($AuthenticatedUserIsRequired, UserHasMissingRoles, InvalidJsonFormat, UnknownError), + apiTagConsumer :: Nil, + Some(canUpdateRateLimits :: Nil), + http4sPartialFunction = Some(updateRateLimits) + ) + + // Route: DELETE /obp/v6.0.0/management/consumers/CONSUMER_ID/consumer/rate-limits/RATE_LIMITING_ID + lazy val deleteCallLimits: HttpRoutes[IO] = HttpRoutes.of[IO] { + case req @ DELETE -> `prefixPath` / "management" / "consumers" / consumerId / "consumer" / "rate-limits" / rateLimitingId => + EndpointHelpers.executeDelete(req) { cc => + for { + _ <- NewStyle.function.getConsumerByConsumerId(consumerId, Some(cc)) + _ <- RateLimitingDI.rateLimiting.vend.deleteByRateLimitingId(rateLimitingId) + } yield () + } + } + + resourceDocs += ResourceDoc( + null, implementedInApiVersion, nameOf(deleteCallLimits), "DELETE", + "/management/consumers/CONSUMER_ID/consumer/rate-limits/RATE_LIMITING_ID", "Delete Rate Limits for a Consumer", + """Delete a rate-limit configuration.""", + EmptyBody, EmptyBody, + List($AuthenticatedUserIsRequired, UserHasMissingRoles, UnknownError), + apiTagConsumer :: Nil, + Some(canDeleteRateLimits :: Nil), + http4sPartialFunction = Some(deleteCallLimits) + ) + + // Route: GET /obp/v6.0.0/management/consumers/CONSUMER_ID/active-rate-limits + lazy val getActiveRateLimitsNow: HttpRoutes[IO] = HttpRoutes.of[IO] { + case req @ GET -> `prefixPath` / "management" / "consumers" / consumerId / "active-rate-limits" => + EndpointHelpers.withUser(req) { (_, cc) => + for { + _ <- NewStyle.function.getConsumerByConsumerId(consumerId, Some(cc)) + date = new java.util.Date() + (rateLimit, ids) <- RateLimitingUtil.getActiveRateLimitsWithIds(consumerId, date) + } yield JSONFactory600.createActiveRateLimitsJsonV600FromCallLimit(rateLimit, ids, date) + } + } + + resourceDocs += ResourceDoc( + null, implementedInApiVersion, nameOf(getActiveRateLimitsNow), "GET", + "/management/consumers/CONSUMER_ID/active-rate-limits", "Get Active Rate Limits (now)", + """Get the currently active rate limits for a Consumer.""", + EmptyBody, EmptyBody, + List($AuthenticatedUserIsRequired, UserHasMissingRoles, UnknownError), + apiTagConsumer :: Nil, + Some(canGetRateLimits :: Nil), + http4sPartialFunction = Some(getActiveRateLimitsNow) + ) + + // Route: GET /obp/v6.0.0/management/consumers/CONSUMER_ID/active-rate-limits/DATE_WITH_HOUR + lazy val getActiveRateLimitsAtDate: HttpRoutes[IO] = HttpRoutes.of[IO] { + case req @ GET -> `prefixPath` / "management" / "consumers" / consumerId / "active-rate-limits" / dateWithHourString => + EndpointHelpers.withUser(req) { (_, cc) => + for { + _ <- NewStyle.function.getConsumerByConsumerId(consumerId, Some(cc)) + date <- NewStyle.function.tryons( + s"$InvalidDateFormat Current date format is: $dateWithHourString. Please use this format: YYYY-MM-DD-HH in UTC", + 400, Some(cc)) { + val fmt = java.time.format.DateTimeFormatter.ofPattern("yyyy-MM-dd-HH") + val ldt = java.time.LocalDateTime.parse(dateWithHourString, fmt) + java.util.Date.from(ldt.atZone(java.time.ZoneOffset.UTC).toInstant()) + } + (rateLimit, ids) <- RateLimitingUtil.getActiveRateLimitsWithIds(consumerId, date) + } yield JSONFactory600.createActiveRateLimitsJsonV600FromCallLimit(rateLimit, ids, date) + } + } + + resourceDocs += ResourceDoc( + null, implementedInApiVersion, nameOf(getActiveRateLimitsAtDate), "GET", + "/management/consumers/CONSUMER_ID/active-rate-limits/DATE_WITH_HOUR", "Get Active Rate Limits at Date", + """Get the active rate limits for a Consumer at the specified UTC hour (YYYY-MM-DD-HH).""", + EmptyBody, EmptyBody, + List($AuthenticatedUserIsRequired, UserHasMissingRoles, InvalidDateFormat, UnknownError), + apiTagConsumer :: Nil, + Some(canGetRateLimits :: Nil), + http4sPartialFunction = Some(getActiveRateLimitsAtDate) + ) + + // ─── Phase 2: management/api-collections bucket (4 endpoints) ───────── + + // Route: POST /obp/v6.0.0/management/api-collections/featured (201) + lazy val createFeaturedApiCollection: HttpRoutes[IO] = HttpRoutes.of[IO] { + case req @ POST -> `prefixPath` / "management" / "api-collections" / "featured" => + EndpointHelpers.executeFutureCreated(req) { + implicit val cc: CallContext = req.callContext + val rawBody = cc.httpBody.getOrElse("") + for { + postJson <- NewStyle.function.tryons(s"$InvalidJsonFormat The Json body should be the PostFeaturedApiCollectionJsonV600", 400, Some(cc)) { + net.liftweb.json.parse(rawBody).extract[PostFeaturedApiCollectionJsonV600] + } + (apiCollection, _) <- NewStyle.function.getApiCollectionById(postJson.api_collection_id, Some(cc)) + _ <- Helper.booleanToFuture(s"$ApiCollectionNotFound The API Collection must be sharable to be featured.", cc = Some(cc)) { + apiCollection.isSharable + } + _ <- NewStyle.function.checkFeaturedApiCollectionDoesNotExist(postJson.api_collection_id, Some(cc)) + (featured, _) <- NewStyle.function.createFeaturedApiCollection( + postJson.api_collection_id, postJson.sort_order, Some(cc)) + } yield JSONFactory600.createFeaturedApiCollectionJsonV600(featured) + } + } + + resourceDocs += ResourceDoc( + null, implementedInApiVersion, nameOf(createFeaturedApiCollection), "POST", + "/management/api-collections/featured", "Create Featured Api Collection", + """Mark an API collection as featured.""", + EmptyBody, EmptyBody, + List($AuthenticatedUserIsRequired, UserHasMissingRoles, InvalidJsonFormat, ApiCollectionNotFound, UnknownError), + apiTagApiCollection :: Nil, + Some(canManageFeaturedApiCollections :: Nil), + http4sPartialFunction = Some(createFeaturedApiCollection) + ) + + // Route: GET /obp/v6.0.0/management/api-collections/featured + lazy val getFeaturedApiCollectionsAdmin: HttpRoutes[IO] = HttpRoutes.of[IO] { + case req @ GET -> `prefixPath` / "management" / "api-collections" / "featured" => + EndpointHelpers.withUser(req) { (_, cc) => + for { + (featured, _) <- NewStyle.function.getAllFeaturedApiCollectionsAdmin(Some(cc)) + } yield JSONFactory600.createFeaturedApiCollectionsJsonV600(featured) + } + } + + resourceDocs += ResourceDoc( + null, implementedInApiVersion, nameOf(getFeaturedApiCollectionsAdmin), "GET", + "/management/api-collections/featured", "Get Featured Api Collections (Admin)", + """Get all featured API collections.""", + EmptyBody, EmptyBody, + List($AuthenticatedUserIsRequired, UserHasMissingRoles, UnknownError), + apiTagApiCollection :: Nil, + Some(canManageFeaturedApiCollections :: Nil), + http4sPartialFunction = Some(getFeaturedApiCollectionsAdmin) + ) + + // Route: PUT /obp/v6.0.0/management/api-collections/featured/API_COLLECTION_ID + lazy val updateFeaturedApiCollection: HttpRoutes[IO] = HttpRoutes.of[IO] { + case req @ PUT -> `prefixPath` / "management" / "api-collections" / "featured" / apiCollectionId => + EndpointHelpers.executeAndRespond(req) { implicit cc => + val rawBody = cc.httpBody.getOrElse("") + for { + putJson <- NewStyle.function.tryons(s"$InvalidJsonFormat The Json body should be the PutFeaturedApiCollectionJsonV600", 400, Some(cc)) { + net.liftweb.json.parse(rawBody).extract[PutFeaturedApiCollectionJsonV600] + } + (updated, _) <- NewStyle.function.updateFeaturedApiCollection( + apiCollectionId, putJson.sort_order, Some(cc)) + } yield JSONFactory600.createFeaturedApiCollectionJsonV600(updated) + } + } + + resourceDocs += ResourceDoc( + null, implementedInApiVersion, nameOf(updateFeaturedApiCollection), "PUT", + "/management/api-collections/featured/API_COLLECTION_ID", "Update Featured Api Collection", + """Update the sort order of a featured API collection.""", + EmptyBody, EmptyBody, + List($AuthenticatedUserIsRequired, UserHasMissingRoles, InvalidJsonFormat, UnknownError), + apiTagApiCollection :: Nil, + Some(canManageFeaturedApiCollections :: Nil), + http4sPartialFunction = Some(updateFeaturedApiCollection) + ) + + // Route: DELETE /obp/v6.0.0/management/api-collections/featured/API_COLLECTION_ID + lazy val deleteFeaturedApiCollection: HttpRoutes[IO] = HttpRoutes.of[IO] { + case req @ DELETE -> `prefixPath` / "management" / "api-collections" / "featured" / apiCollectionId => + EndpointHelpers.executeAndRespond(req) { implicit cc => + for { + _ <- NewStyle.function.deleteFeaturedApiCollectionByApiCollectionId(apiCollectionId, Some(cc)) + } yield "" + } + } + + resourceDocs += ResourceDoc( + null, implementedInApiVersion, nameOf(deleteFeaturedApiCollection), "DELETE", + "/management/api-collections/featured/API_COLLECTION_ID", "Delete Featured Api Collection", + """Remove a featured API collection.""", + EmptyBody, EmptyBody, + List($AuthenticatedUserIsRequired, UserHasMissingRoles, UnknownError), + apiTagApiCollection :: Nil, + Some(canManageFeaturedApiCollections :: Nil), + http4sPartialFunction = Some(deleteFeaturedApiCollection) + ) + + // ─── Phase 2: api-products bucket (9 endpoints) ─────────────────────── + // All endpoints always require auth + role; the v6 Lift conditional + // public-access path (getApiProductsIsPublic) is simplified — public + // gating would be a Phase 3 follow-up if needed. + + // Route: POST /obp/v6.0.0/banks/BANK_ID/api-products/API_PRODUCT_CODE (201) + lazy val createApiProduct: HttpRoutes[IO] = HttpRoutes.of[IO] { + case req @ POST -> `prefixPath` / "banks" / _ / "api-products" / apiProductCode => + EndpointHelpers.executeFutureCreated(req) { + implicit val cc: CallContext = req.callContext + val rawBody = cc.httpBody.getOrElse("") + val bank = cc.bank.get + for { + postJson <- NewStyle.function.tryons(s"$InvalidJsonFormat The Json body should be the PostPutApiProductJsonV600", 400, Some(cc)) { + net.liftweb.json.parse(rawBody).extract[PostPutApiProductJsonV600] + } + (apiProduct, _) <- NewStyle.function.createOrUpdateApiProduct( + bank.bankId.value, apiProductCode, + postJson.parent_api_product_code.getOrElse(""), + postJson.name, postJson.category.getOrElse(""), + postJson.more_info_url.getOrElse(""), postJson.terms_and_conditions_url.getOrElse(""), + postJson.description.getOrElse(""), postJson.collection_id.getOrElse(""), + postJson.monthly_subscription_currency.getOrElse(""), postJson.monthly_subscription_amount.getOrElse(""), + postJson.per_second_call_limit.getOrElse(-1L), postJson.per_minute_call_limit.getOrElse(-1L), + postJson.per_hour_call_limit.getOrElse(-1L), postJson.per_day_call_limit.getOrElse(-1L), + postJson.per_week_call_limit.getOrElse(-1L), postJson.per_month_call_limit.getOrElse(-1L), + postJson.tags.getOrElse(Nil), Some(cc) + ) + } yield JSONFactory600.createApiProductJsonV600(apiProduct, None) + } + } + + resourceDocs += ResourceDoc( + null, implementedInApiVersion, nameOf(createApiProduct), "POST", + "/banks/BANK_ID/api-products/API_PRODUCT_CODE", "Create Api Product", + """Create an Api Product for the Bank.""", + EmptyBody, EmptyBody, + List($AuthenticatedUserIsRequired, $BankNotFound, UserHasMissingRoles, InvalidJsonFormat, UnknownError), + apiTagApi :: apiTagApiProduct :: Nil, + Some(canCreateApiProduct :: Nil), + http4sPartialFunction = Some(createApiProduct) + ) + + // Route: PUT /obp/v6.0.0/banks/BANK_ID/api-products/API_PRODUCT_CODE (201 — Lift returns 201) + lazy val createOrUpdateApiProduct: HttpRoutes[IO] = HttpRoutes.of[IO] { + case req @ PUT -> `prefixPath` / "banks" / _ / "api-products" / apiProductCode => + EndpointHelpers.executeFutureCreated(req) { + implicit val cc: CallContext = req.callContext + val rawBody = cc.httpBody.getOrElse("") + val bank = cc.bank.get + for { + postJson <- NewStyle.function.tryons(s"$InvalidJsonFormat The Json body should be the PostPutApiProductJsonV600", 400, Some(cc)) { + net.liftweb.json.parse(rawBody).extract[PostPutApiProductJsonV600] + } + (apiProduct, _) <- NewStyle.function.createOrUpdateApiProduct( + bank.bankId.value, apiProductCode, + postJson.parent_api_product_code.getOrElse(""), + postJson.name, postJson.category.getOrElse(""), + postJson.more_info_url.getOrElse(""), postJson.terms_and_conditions_url.getOrElse(""), + postJson.description.getOrElse(""), postJson.collection_id.getOrElse(""), + postJson.monthly_subscription_currency.getOrElse(""), postJson.monthly_subscription_amount.getOrElse(""), + postJson.per_second_call_limit.getOrElse(-1L), postJson.per_minute_call_limit.getOrElse(-1L), + postJson.per_hour_call_limit.getOrElse(-1L), postJson.per_day_call_limit.getOrElse(-1L), + postJson.per_week_call_limit.getOrElse(-1L), postJson.per_month_call_limit.getOrElse(-1L), + postJson.tags.getOrElse(Nil), Some(cc) + ) + } yield JSONFactory600.createApiProductJsonV600(apiProduct, None) + } + } + + resourceDocs += ResourceDoc( + null, implementedInApiVersion, nameOf(createOrUpdateApiProduct), "PUT", + "/banks/BANK_ID/api-products/API_PRODUCT_CODE", "Create or Update Api Product", + """Create or update an Api Product for the Bank.""", + EmptyBody, EmptyBody, + List($AuthenticatedUserIsRequired, $BankNotFound, UserHasMissingRoles, InvalidJsonFormat, UnknownError), + apiTagApi :: apiTagApiProduct :: Nil, + Some(canUpdateApiProduct :: Nil), + http4sPartialFunction = Some(createOrUpdateApiProduct) + ) + + // Route: GET /obp/v6.0.0/banks/BANK_ID/api-products/API_PRODUCT_CODE + lazy val getApiProduct: HttpRoutes[IO] = HttpRoutes.of[IO] { + case req @ GET -> `prefixPath` / "banks" / _ / "api-products" / apiProductCode => + EndpointHelpers.withUserAndBank(req) { (_, bank, cc) => + for { + (apiProduct, _) <- NewStyle.function.getApiProductByBankIdAndCode(bank.bankId.value, apiProductCode, Some(cc)) + (attributes, _) <- NewStyle.function.getApiProductAttributesByBankIdAndCode(bank.bankId.value, apiProductCode, Some(cc)) + } yield JSONFactory600.createApiProductJsonV600(apiProduct, Some(attributes)) + } + } + + resourceDocs += ResourceDoc( + null, implementedInApiVersion, nameOf(getApiProduct), "GET", + "/banks/BANK_ID/api-products/API_PRODUCT_CODE", "Get Api Product", + """Get an Api Product by BANK_ID and API_PRODUCT_CODE.""", + EmptyBody, EmptyBody, + List($AuthenticatedUserIsRequired, $BankNotFound, UserHasMissingRoles, UnknownError), + apiTagApi :: apiTagApiProduct :: Nil, + Some(canGetApiProduct :: Nil), + http4sPartialFunction = Some(getApiProduct) + ) + + // Route: GET /obp/v6.0.0/banks/BANK_ID/api-products + lazy val getApiProducts: HttpRoutes[IO] = HttpRoutes.of[IO] { + case req @ GET -> `prefixPath` / "banks" / _ / "api-products" => + EndpointHelpers.withUserAndBank(req) { (_, bank, cc) => + val tagFilter = req.uri.query.params.get("tag").map(_.trim).filter(_.nonEmpty) + for { + (apiProducts, _) <- NewStyle.function.getApiProductsByBankId(bank.bankId.value, tagFilter, Some(cc)) + } yield JSONFactory600.createApiProductsJsonV600(apiProducts) + } + } + + resourceDocs += ResourceDoc( + null, implementedInApiVersion, nameOf(getApiProducts), "GET", + "/banks/BANK_ID/api-products", "Get Api Products", + """Get all Api Products for the Bank. Optional ?tag= filter.""", + EmptyBody, EmptyBody, + List($AuthenticatedUserIsRequired, $BankNotFound, UserHasMissingRoles, UnknownError), + apiTagApi :: apiTagApiProduct :: Nil, + Some(canGetApiProduct :: Nil), + http4sPartialFunction = Some(getApiProducts) + ) + + // Route: DELETE /obp/v6.0.0/banks/BANK_ID/api-products/API_PRODUCT_CODE + lazy val deleteApiProduct: HttpRoutes[IO] = HttpRoutes.of[IO] { + case req @ DELETE -> `prefixPath` / "banks" / _ / "api-products" / apiProductCode => + EndpointHelpers.withUserAndBank(req) { (_, bank, cc) => + for { + _ <- NewStyle.function.deleteApiProductAttributesByBankIdAndCode(bank.bankId.value, apiProductCode, Some(cc)) + _ <- NewStyle.function.deleteApiProduct(bank.bankId.value, apiProductCode, Some(cc)) + } yield "" + } + } + + resourceDocs += ResourceDoc( + null, implementedInApiVersion, nameOf(deleteApiProduct), "DELETE", + "/banks/BANK_ID/api-products/API_PRODUCT_CODE", "Delete Api Product", + """Delete an Api Product by BANK_ID and API_PRODUCT_CODE.""", + EmptyBody, EmptyBody, + List($AuthenticatedUserIsRequired, $BankNotFound, UserHasMissingRoles, UnknownError), + apiTagApi :: apiTagApiProduct :: Nil, + Some(canDeleteApiProduct :: Nil), + http4sPartialFunction = Some(deleteApiProduct) + ) + + // Route: POST /obp/v6.0.0/banks/BANK_ID/api-products/API_PRODUCT_CODE/attribute (201) + lazy val createApiProductAttribute: HttpRoutes[IO] = HttpRoutes.of[IO] { + case req @ POST -> `prefixPath` / "banks" / _ / "api-products" / apiProductCode / "attribute" => + EndpointHelpers.executeFutureCreated(req) { + implicit val cc: CallContext = req.callContext + val rawBody = cc.httpBody.getOrElse("") + val bank = cc.bank.get + for { + _ <- NewStyle.function.getApiProductByBankIdAndCode(bank.bankId.value, apiProductCode, Some(cc)) + postJson <- NewStyle.function.tryons(s"$InvalidJsonFormat The Json body should be the ApiProductAttributeJsonV600", 400, Some(cc)) { + net.liftweb.json.parse(rawBody).extract[ApiProductAttributeJsonV600] + } + (attribute, _) <- NewStyle.function.createOrUpdateApiProductAttribute( + bank.bankId.value, apiProductCode, None, + postJson.name, postJson.`type`, postJson.value, postJson.is_active, Some(cc)) + } yield JSONFactory600.createApiProductAttributeResponseJsonV600(attribute) + } + } + + resourceDocs += ResourceDoc( + null, implementedInApiVersion, nameOf(createApiProductAttribute), "POST", + "/banks/BANK_ID/api-products/API_PRODUCT_CODE/attribute", "Create Api Product Attribute", + """Create an attribute for the specified Api Product.""", + EmptyBody, EmptyBody, + List($AuthenticatedUserIsRequired, $BankNotFound, UserHasMissingRoles, InvalidJsonFormat, UnknownError), + apiTagApi :: apiTagApiProductAttribute :: Nil, + Some(canCreateApiProductAttribute :: Nil), + http4sPartialFunction = Some(createApiProductAttribute) + ) + + // Route: PUT /obp/v6.0.0/banks/BANK_ID/api-products/API_PRODUCT_CODE/attributes/API_PRODUCT_ATTRIBUTE_ID + lazy val updateApiProductAttribute: HttpRoutes[IO] = HttpRoutes.of[IO] { + case req @ PUT -> `prefixPath` / "banks" / _ / "api-products" / apiProductCode / "attributes" / apiProductAttributeId => + EndpointHelpers.executeAndRespond(req) { implicit cc => + val rawBody = cc.httpBody.getOrElse("") + val bank = cc.bank.get + for { + _ <- NewStyle.function.getApiProductByBankIdAndCode(bank.bankId.value, apiProductCode, Some(cc)) + postJson <- NewStyle.function.tryons(s"$InvalidJsonFormat The Json body should be the ApiProductAttributeJsonV600", 400, Some(cc)) { + net.liftweb.json.parse(rawBody).extract[ApiProductAttributeJsonV600] + } + (attribute, _) <- NewStyle.function.createOrUpdateApiProductAttribute( + bank.bankId.value, apiProductCode, Some(apiProductAttributeId), + postJson.name, postJson.`type`, postJson.value, postJson.is_active, Some(cc)) + } yield JSONFactory600.createApiProductAttributeResponseJsonV600(attribute) + } + } + + resourceDocs += ResourceDoc( + null, implementedInApiVersion, nameOf(updateApiProductAttribute), "PUT", + "/banks/BANK_ID/api-products/API_PRODUCT_CODE/attributes/API_PRODUCT_ATTRIBUTE_ID", "Update Api Product Attribute", + """Update an Api Product Attribute.""", + EmptyBody, EmptyBody, + List($AuthenticatedUserIsRequired, $BankNotFound, UserHasMissingRoles, InvalidJsonFormat, UnknownError), + apiTagApi :: apiTagApiProductAttribute :: Nil, + Some(canUpdateApiProductAttribute :: Nil), + http4sPartialFunction = Some(updateApiProductAttribute) + ) + + // Route: GET /obp/v6.0.0/banks/BANK_ID/api-products/API_PRODUCT_CODE/attributes/API_PRODUCT_ATTRIBUTE_ID + lazy val getApiProductAttribute: HttpRoutes[IO] = HttpRoutes.of[IO] { + case req @ GET -> `prefixPath` / "banks" / _ / "api-products" / _ / "attributes" / apiProductAttributeId => + EndpointHelpers.withUserAndBank(req) { (_, _, cc) => + for { + (attribute, _) <- NewStyle.function.getApiProductAttributeById(apiProductAttributeId, Some(cc)) + } yield JSONFactory600.createApiProductAttributeResponseJsonV600(attribute) + } + } + + resourceDocs += ResourceDoc( + null, implementedInApiVersion, nameOf(getApiProductAttribute), "GET", + "/banks/BANK_ID/api-products/API_PRODUCT_CODE/attributes/API_PRODUCT_ATTRIBUTE_ID", "Get Api Product Attribute", + """Get an Api Product Attribute by API_PRODUCT_ATTRIBUTE_ID.""", + EmptyBody, EmptyBody, + List($AuthenticatedUserIsRequired, $BankNotFound, UserHasMissingRoles, UnknownError), + apiTagApi :: apiTagApiProductAttribute :: Nil, + Some(canGetApiProductAttribute :: Nil), + http4sPartialFunction = Some(getApiProductAttribute) + ) + + // Route: DELETE /obp/v6.0.0/banks/BANK_ID/api-products/API_PRODUCT_CODE/attributes/API_PRODUCT_ATTRIBUTE_ID + lazy val deleteApiProductAttribute: HttpRoutes[IO] = HttpRoutes.of[IO] { + case req @ DELETE -> `prefixPath` / "banks" / _ / "api-products" / _ / "attributes" / apiProductAttributeId => + EndpointHelpers.withUserAndBank(req) { (_, _, cc) => + for { + _ <- NewStyle.function.deleteApiProductAttribute(apiProductAttributeId, Some(cc)) + } yield "" + } + } + + resourceDocs += ResourceDoc( + null, implementedInApiVersion, nameOf(deleteApiProductAttribute), "DELETE", + "/banks/BANK_ID/api-products/API_PRODUCT_CODE/attributes/API_PRODUCT_ATTRIBUTE_ID", "Delete Api Product Attribute", + """Delete an Api Product Attribute.""", + EmptyBody, EmptyBody, + List($AuthenticatedUserIsRequired, $BankNotFound, UserHasMissingRoles, UnknownError), + apiTagApi :: apiTagApiProductAttribute :: Nil, + Some(canDeleteApiProductAttribute :: Nil), + http4sPartialFunction = Some(deleteApiProductAttribute) + ) + + // ─── Phase 2: mandates bucket (10 endpoints) ────────────────────────── + + // Parse `yyyy-MM-dd'T'HH:mm:ss'Z'` UTC strings; v6 Lift's exact format. + private def parseMandateDate(s: String, field: String, cc: CallContext): Future[java.util.Date] = + NewStyle.function.tryons(s"$InvalidDateFormat $field must be in yyyy-MM-dd'T'HH:mm:ss'Z' format", 400, Some(cc)) { + val fmt = new java.text.SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss'Z'") + fmt.setTimeZone(java.util.TimeZone.getTimeZone("UTC")) + fmt.setLenient(false) + fmt.parse(s) + } + + // Route: POST /obp/v6.0.0/banks/BANK_ID/accounts/ACCOUNT_ID/mandates (201) + lazy val createMandate: HttpRoutes[IO] = HttpRoutes.of[IO] { + case req @ POST -> `prefixPath` / "banks" / _ / "accounts" / _ / "mandates" => + EndpointHelpers.executeFutureCreated(req) { + implicit val cc: CallContext = req.callContext + val rawBody = cc.httpBody.getOrElse("") + val bank = cc.bank.get + val account = cc.bankAccount.get + for { + createJson <- NewStyle.function.tryons(InvalidJsonFormat, 400, Some(cc)) { + net.liftweb.json.parse(rawBody).extract[CreateMandateJsonV600] + } + validFrom <- parseMandateDate(createJson.valid_from, "valid_from", cc) + validTo <- parseMandateDate(createJson.valid_to, "valid_to", cc) + (mandate, _) <- BankConnector.connector.vend.createMandate( + bank.bankId, account.accountId, createJson.customer_id, + createJson.mandate_name, createJson.mandate_reference, + createJson.legal_text, createJson.description, createJson.status, + validFrom, validTo, cc.userId, Some(cc) + ).map(i => (unboxFullOrFail(i._1, Some(cc), "Could not create mandate"), i._2)) + } yield JSONFactory600.createMandateJsonV600(mandate) + } + } + + resourceDocs += ResourceDoc( + null, implementedInApiVersion, nameOf(createMandate), "POST", + "/banks/BANK_ID/accounts/ACCOUNT_ID/mandates", "Create Mandate", + """Create a new mandate for an account.""", + EmptyBody, EmptyBody, + List($AuthenticatedUserIsRequired, $BankNotFound, $BankAccountNotFound, UserHasMissingRoles, UnknownError), + apiTagMandate :: Nil, + Some(canCreateMandate :: Nil), + http4sPartialFunction = Some(createMandate) + ) + + // Route: GET /obp/v6.0.0/banks/BANK_ID/accounts/ACCOUNT_ID/mandates + lazy val getMandates: HttpRoutes[IO] = HttpRoutes.of[IO] { + case req @ GET -> `prefixPath` / "banks" / _ / "accounts" / _ / "mandates" => + EndpointHelpers.withBankAccount(req) { (_, account, cc) => + for { + (mandates, _) <- BankConnector.connector.vend.getMandatesByBankAndAccount( + account.bankId, account.accountId, Some(cc)) + .map(i => (unboxFullOrFail(i._1, Some(cc), "Could not get mandates"), i._2)) + } yield JSONFactory600.createMandatesJsonV600(mandates) + } + } + + resourceDocs += ResourceDoc( + null, implementedInApiVersion, nameOf(getMandates), "GET", + "/banks/BANK_ID/accounts/ACCOUNT_ID/mandates", "Get Mandates", + """Get all mandates for an account.""", + EmptyBody, EmptyBody, + List($AuthenticatedUserIsRequired, $BankNotFound, $BankAccountNotFound, UserHasMissingRoles, UnknownError), + apiTagMandate :: Nil, + Some(canGetMandate :: Nil), + http4sPartialFunction = Some(getMandates) + ) + + // Route: GET /obp/v6.0.0/banks/BANK_ID/accounts/ACCOUNT_ID/mandates/MANDATE_ID + lazy val getMandate: HttpRoutes[IO] = HttpRoutes.of[IO] { + case req @ GET -> `prefixPath` / "banks" / _ / "accounts" / _ / "mandates" / mandateId => + EndpointHelpers.withBankAccount(req) { (_, _, cc) => + for { + (mandate, _) <- BankConnector.connector.vend.getMandateById(mandateId, Some(cc)) + .map(i => (unboxFullOrFail(i._1, Some(cc), s"Mandate not found. Mandate ID: $mandateId", 404), i._2)) + } yield JSONFactory600.createMandateJsonV600(mandate) + } + } + + resourceDocs += ResourceDoc( + null, implementedInApiVersion, nameOf(getMandate), "GET", + "/banks/BANK_ID/accounts/ACCOUNT_ID/mandates/MANDATE_ID", "Get Mandate", + """Get a specific mandate.""", + EmptyBody, EmptyBody, + List($AuthenticatedUserIsRequired, $BankNotFound, $BankAccountNotFound, UserHasMissingRoles, UnknownError), + apiTagMandate :: Nil, + Some(canGetMandate :: Nil), + http4sPartialFunction = Some(getMandate) + ) + + // Route: PUT /obp/v6.0.0/banks/BANK_ID/accounts/ACCOUNT_ID/mandates/MANDATE_ID + lazy val updateMandate: HttpRoutes[IO] = HttpRoutes.of[IO] { + case req @ PUT -> `prefixPath` / "banks" / _ / "accounts" / _ / "mandates" / mandateId => + EndpointHelpers.executeAndRespond(req) { implicit cc => + val rawBody = cc.httpBody.getOrElse("") + for { + updateJson <- NewStyle.function.tryons(InvalidJsonFormat, 400, Some(cc)) { + net.liftweb.json.parse(rawBody).extract[UpdateMandateJsonV600] + } + validFrom <- parseMandateDate(updateJson.valid_from, "valid_from", cc) + validTo <- parseMandateDate(updateJson.valid_to, "valid_to", cc) + (mandate, _) <- BankConnector.connector.vend.updateMandate( + mandateId, updateJson.mandate_name, updateJson.mandate_reference, + updateJson.legal_text, updateJson.description, updateJson.status, + validFrom, validTo, cc.userId, Some(cc) + ).map(i => (unboxFullOrFail(i._1, Some(cc), s"Could not update mandate. Mandate ID: $mandateId"), i._2)) + } yield JSONFactory600.createMandateJsonV600(mandate) + } + } + + resourceDocs += ResourceDoc( + null, implementedInApiVersion, nameOf(updateMandate), "PUT", + "/banks/BANK_ID/accounts/ACCOUNT_ID/mandates/MANDATE_ID", "Update Mandate", + """Update a mandate.""", + EmptyBody, EmptyBody, + List($AuthenticatedUserIsRequired, $BankNotFound, $BankAccountNotFound, UserHasMissingRoles, UnknownError), + apiTagMandate :: Nil, + Some(canUpdateMandate :: Nil), + http4sPartialFunction = Some(updateMandate) + ) + + // Route: DELETE /obp/v6.0.0/banks/BANK_ID/accounts/ACCOUNT_ID/mandates/MANDATE_ID (204) + lazy val deleteMandate: HttpRoutes[IO] = HttpRoutes.of[IO] { + case req @ DELETE -> `prefixPath` / "banks" / _ / "accounts" / _ / "mandates" / mandateId => + EndpointHelpers.executeAndRespond(req) { implicit cc => + for { + _ <- BankConnector.connector.vend.deleteMandate(mandateId, Some(cc)) + .map(i => (unboxFullOrFail(i._1, Some(cc), s"Could not delete mandate. Mandate ID: $mandateId"), i._2)) + } yield "" + } + } + + resourceDocs += ResourceDoc( + null, implementedInApiVersion, nameOf(deleteMandate), "DELETE", + "/banks/BANK_ID/accounts/ACCOUNT_ID/mandates/MANDATE_ID", "Delete Mandate", + """Delete a mandate and all its provisions and signatory panels.""", + EmptyBody, EmptyBody, + List($AuthenticatedUserIsRequired, $BankNotFound, UserHasMissingRoles, UnknownError), + apiTagMandate :: Nil, + Some(canDeleteMandate :: Nil), + http4sPartialFunction = Some(deleteMandate) + ) + + // Provision serializer — match Lift exactly. + private def serializeSignatoryRequirements(any: Any): String = { + net.liftweb.json.Serialization.write(any.asInstanceOf[AnyRef])(net.liftweb.json.DefaultFormats) + } + + // Route: POST /obp/v6.0.0/banks/BANK_ID/mandates/MANDATE_ID/provisions (201) + lazy val createMandateProvision: HttpRoutes[IO] = HttpRoutes.of[IO] { + case req @ POST -> `prefixPath` / "banks" / _ / "mandates" / mandateId / "provisions" => + EndpointHelpers.executeFutureCreated(req) { + implicit val cc: CallContext = req.callContext + val rawBody = cc.httpBody.getOrElse("") + for { + createJson <- NewStyle.function.tryons(InvalidJsonFormat, 400, Some(cc)) { + net.liftweb.json.parse(rawBody).extract[CreateMandateProvisionJsonV600] + } + sigReqJson = serializeSignatoryRequirements(createJson.signatory_requirements) + (provision, _) <- BankConnector.connector.vend.createMandateProvision( + mandateId, createJson.provision_name, createJson.provision_description, + createJson.legal_reference, createJson.provision_type, createJson.conditions, + sigReqJson, + createJson.linked_view_id.getOrElse(""), + createJson.linked_abac_rule_id.getOrElse(""), + createJson.linked_challenge_type.getOrElse(""), + createJson.is_active, createJson.sort_order, Some(cc) + ).map(i => (unboxFullOrFail(i._1, Some(cc), "Could not create mandate provision"), i._2)) + } yield JSONFactory600.createMandateProvisionJsonV600(provision) + } + } + + resourceDocs += ResourceDoc( + null, implementedInApiVersion, nameOf(createMandateProvision), "POST", + "/banks/BANK_ID/mandates/MANDATE_ID/provisions", "Create Mandate Provision", + """Create a provision under a mandate.""", + EmptyBody, EmptyBody, + List($AuthenticatedUserIsRequired, $BankNotFound, UserHasMissingRoles, UnknownError), + apiTagMandate :: Nil, + Some(canCreateMandateProvision :: Nil), + http4sPartialFunction = Some(createMandateProvision) + ) + + // Route: GET /obp/v6.0.0/banks/BANK_ID/mandates/MANDATE_ID/provisions + lazy val getMandateProvisions: HttpRoutes[IO] = HttpRoutes.of[IO] { + case req @ GET -> `prefixPath` / "banks" / _ / "mandates" / mandateId / "provisions" => + EndpointHelpers.withUserAndBank(req) { (_, _, cc) => + for { + (provisions, _) <- BankConnector.connector.vend.getMandateProvisionsByMandateId(mandateId, Some(cc)) + .map(i => (unboxFullOrFail(i._1, Some(cc), s"Could not get provisions for mandate: $mandateId"), i._2)) + } yield JSONFactory600.createMandateProvisionsJsonV600(provisions) + } + } + + resourceDocs += ResourceDoc( + null, implementedInApiVersion, nameOf(getMandateProvisions), "GET", + "/banks/BANK_ID/mandates/MANDATE_ID/provisions", "Get Mandate Provisions", + """Get all provisions for a mandate.""", + EmptyBody, EmptyBody, + List($AuthenticatedUserIsRequired, $BankNotFound, UserHasMissingRoles, UnknownError), + apiTagMandate :: Nil, + Some(canGetMandateProvision :: Nil), + http4sPartialFunction = Some(getMandateProvisions) + ) + + // Route: GET /obp/v6.0.0/banks/BANK_ID/mandates/MANDATE_ID/provisions/PROVISION_ID + lazy val getMandateProvision: HttpRoutes[IO] = HttpRoutes.of[IO] { + case req @ GET -> `prefixPath` / "banks" / _ / "mandates" / _ / "provisions" / provisionId => + EndpointHelpers.withUserAndBank(req) { (_, _, cc) => + for { + (provision, _) <- BankConnector.connector.vend.getMandateProvisionById(provisionId, Some(cc)) + .map(i => (unboxFullOrFail(i._1, Some(cc), s"Mandate provision not found. Provision ID: $provisionId", 404), i._2)) + } yield JSONFactory600.createMandateProvisionJsonV600(provision) + } + } + + resourceDocs += ResourceDoc( + null, implementedInApiVersion, nameOf(getMandateProvision), "GET", + "/banks/BANK_ID/mandates/MANDATE_ID/provisions/PROVISION_ID", "Get Mandate Provision", + """Get a specific mandate provision.""", + EmptyBody, EmptyBody, + List($AuthenticatedUserIsRequired, $BankNotFound, UserHasMissingRoles, UnknownError), + apiTagMandate :: Nil, + Some(canGetMandateProvision :: Nil), + http4sPartialFunction = Some(getMandateProvision) + ) + + // Route: PUT /obp/v6.0.0/banks/BANK_ID/mandates/MANDATE_ID/provisions/PROVISION_ID + lazy val updateMandateProvision: HttpRoutes[IO] = HttpRoutes.of[IO] { + case req @ PUT -> `prefixPath` / "banks" / _ / "mandates" / _ / "provisions" / provisionId => + EndpointHelpers.executeAndRespond(req) { implicit cc => + val rawBody = cc.httpBody.getOrElse("") + for { + updateJson <- NewStyle.function.tryons(InvalidJsonFormat, 400, Some(cc)) { + net.liftweb.json.parse(rawBody).extract[UpdateMandateProvisionJsonV600] + } + sigReqJson = serializeSignatoryRequirements(updateJson.signatory_requirements) + (provision, _) <- BankConnector.connector.vend.updateMandateProvision( + provisionId, updateJson.provision_name, updateJson.provision_description, + updateJson.legal_reference, updateJson.provision_type, updateJson.conditions, + sigReqJson, + updateJson.linked_view_id.getOrElse(""), + updateJson.linked_abac_rule_id.getOrElse(""), + updateJson.linked_challenge_type.getOrElse(""), + updateJson.is_active, updateJson.sort_order, Some(cc) + ).map(i => (unboxFullOrFail(i._1, Some(cc), s"Could not update provision. Provision ID: $provisionId"), i._2)) + } yield JSONFactory600.createMandateProvisionJsonV600(provision) + } + } + + resourceDocs += ResourceDoc( + null, implementedInApiVersion, nameOf(updateMandateProvision), "PUT", + "/banks/BANK_ID/mandates/MANDATE_ID/provisions/PROVISION_ID", "Update Mandate Provision", + """Update a mandate provision.""", + EmptyBody, EmptyBody, + List($AuthenticatedUserIsRequired, $BankNotFound, UserHasMissingRoles, UnknownError), + apiTagMandate :: Nil, + Some(canUpdateMandateProvision :: Nil), + http4sPartialFunction = Some(updateMandateProvision) + ) + + // Route: DELETE /obp/v6.0.0/banks/BANK_ID/mandates/MANDATE_ID/provisions/PROVISION_ID (204) + lazy val deleteMandateProvision: HttpRoutes[IO] = HttpRoutes.of[IO] { + case req @ DELETE -> `prefixPath` / "banks" / _ / "mandates" / _ / "provisions" / provisionId => + EndpointHelpers.executeAndRespond(req) { implicit cc => + for { + _ <- BankConnector.connector.vend.deleteMandateProvision(provisionId, Some(cc)) + .map(i => (unboxFullOrFail(i._1, Some(cc), s"Could not delete provision. Provision ID: $provisionId"), i._2)) + } yield "" + } + } + + resourceDocs += ResourceDoc( + null, implementedInApiVersion, nameOf(deleteMandateProvision), "DELETE", + "/banks/BANK_ID/mandates/MANDATE_ID/provisions/PROVISION_ID", "Delete Mandate Provision", + """Delete a mandate provision.""", + EmptyBody, EmptyBody, + List($AuthenticatedUserIsRequired, $BankNotFound, UserHasMissingRoles, UnknownError), + apiTagMandate :: Nil, + Some(canDeleteMandateProvision :: Nil), + http4sPartialFunction = Some(deleteMandateProvision) + ) + + val allRoutesWithMiddleware: HttpRoutes[IO] = + ResourceDocMiddleware.apply(resourceDocs)(allRoutes) + + // ─── path-rewriting bridge: /obp/v6.0.0/… → /obp/v5.1.0/… ───────────── + // NOT appended to allRoutes — see object-level scaladoc. + val v600ToV510Bridge: HttpRoutes[IO] = Kleisli[HttpF, Request[IO], Response[IO]] { req => + val rawPath = req.uri.path.renderString + if (rawPath.startsWith("/obp/v6.0.0/")) { + val rewritten = rawPath.replaceFirst("/obp/v6\\.0\\.0/", "/obp/v5.1.0/") + val newUri = req.uri.withPath(Uri.Path.unsafeFromString(rewritten)) + val rewrittenReq = req.withUri(newUri) + Http4s510.wrappedRoutesV510Services.run(rewrittenReq) + } else { + OptionT.none[IO, Response[IO]] + } + } + } + + val wrappedRoutesV600Services: HttpRoutes[IO] = + Implementations6_0_0.allRoutesWithMiddleware +}