diff --git a/.github/workflows/api-pr.yaml b/.github/workflows/api-pr.yaml index fb25bc8180..112f648391 100644 --- a/.github/workflows/api-pr.yaml +++ b/.github/workflows/api-pr.yaml @@ -228,3 +228,46 @@ jobs: run: npm test -- --shard=${{ matrix.shard }}/3 env: MIGRATION_TEST_PG: postgres://postgres:postgres@localhost:5432/postgres + + projection: + name: Read-path projections + runs-on: ubuntu-latest + services: + # These specs build the schema from the entity metadata and assert against real rows: a mocked + # repository cannot observe which columns a query asked for, so none of the four levels in + # docs/read-path-projections.md can be tested without a database. + postgres: + image: postgres:16 + env: + POSTGRES_PASSWORD: postgres + ports: + - 5432:5432 + options: >- + --health-cmd pg_isready + --health-interval 10s + --health-timeout 5s + --health-retries 5 + steps: + - name: Checkout + uses: actions/checkout@v5 + + - name: Use Node.js ${{ env.NODE_VERSION }} + uses: actions/setup-node@v5 + with: + node-version: ${{ env.NODE_VERSION }} + cache: 'npm' + + - name: Install packages + uses: nick-fields/retry@v4 + with: + timeout_minutes: 10 + max_attempts: 3 + retry_on: any + command: npm ci + + # Separate from the sharded run because it compiles with full type information; see + # jest.projection.config.js for why the main suite cannot host these. + - name: Run projection tests + run: npm run test:projection + env: + MIGRATION_TEST_PG: postgres://postgres:postgres@localhost:5432/postgres diff --git a/docs/endpoints.md b/docs/endpoints.md index fd9626ff63..ac526e2fa9 100644 --- a/docs/endpoints.md +++ b/docs/endpoints.md @@ -23,25 +23,20 @@ Two rules follow from that, and both are binding: 1. **An endpoint counts as converted only when its tests reach `4/4`** against the four levels in [read-path-projections.md](read-path-projections.md#test-definition). A projection without them is worse than no projection: a forgotten field does not crash, it returns a wrong value with a 200, and in a service moving money that can run for weeks unnoticed. Anything short of `4/4` is unfinished work, not a partial success. 2. **The state of every endpoint is recorded here**, in the `Tests` column, and kept in sync with the code in the same pull request that changes it. An undocumented conversion is indistinguishable from one that was never tested. -Nine endpoints moved into the `whole rows` group without their handlers changing: `GET /health` and its -five siblings, `GET /monitoring/data`, `GET /statistic` and `GET /dashboard/financial/latest`. Their state -used to live in an in-process subject and now comes from the database, because the process that writes it -is no longer the process that serves the request. - -Today 2 endpoints read only what they need and 444 do not, so the column reads `not yet` almost everywhere. Two further endpoints project only when the caller supplies a field list and load the whole table otherwise, which is why they are counted separately rather than as converted. That is the point of recording it: the number is the distance to the target. +Today 36 endpoints read only what they need and 410 do not, so the column reads `not yet` almost everywhere. That is the point of recording it: the number is the distance to the target. ## What the numbers say | Data access | Endpoints | Share | | ----------- | --------: | ----: | -| `whole rows` | 444 | 83 % | +| `whole rows` | 410 | 76 % | | `none` | 89 | 17 % | -| `projected` | 2 | 0 % | +| `projected` | 36 | 7 % | | `caller-defined` | 2 | 0 % | -Two endpoints are classified as reading only what they need, on the strength of reads resolved in the source rather than an exhaustive proof: `PUT /log/financial/validity`, whose query names `log.id` and `log.valid`, and `POST /gs/debug`, which assembles its select list from the request. `POST /gs/db` and `POST /gs/db/custom` project only when the caller sends a field list — `request.select(query.select)` — and load the full table otherwise. How far the test suite actually covers those reads is recorded per site in [read-path-projections.md](read-path-projections.md#which-endpoints-these-apply-to); the short answer is that the projection behind `PUT /log/financial/validity` is never executed in a test. +Of the 36 that read only what they need, 17 were converted deliberately and carry tests on all four levels: `GET /user/profile` (253 columns to 41), `GET /buy/:id/history` (497 columns to 12), `GET /swap/:id/history` (509 columns to 12), `GET /sell/:id/history` (470 columns to 14), `GET /support/issue/:id/data` (951 columns to 81), `GET /support/issue` (450 columns to 11), `GET /support/issue/:id` (450 columns to 11), `GET /kyc/users` (328 columns to 7), `GET /kyc/:id/documents` (328 columns to 2), `GET /custody/order` (19 columns to 14), `GET /support/issue/list` (16 columns to 10), `GET /realunit/support/list` (16 columns to 10), `GET /dashboard/accounting/ledger/suspense` (11 columns to 10), `GET /liquidityManagement/pipeline/:id/status` (112 columns to 2), `PUT /paymentLink/:id/pos` (513 columns to 7), `POST /user/apiKey/CT` (253 columns to 3), `GET /user` (351 columns to 66). The other 19 were already projecting — mostly counts, maxima and id lookups written with a query builder, which name their columns one at a time rather than as a list. They are not covered by the tests below, which is why 18 of them read `0/4` rather than `n/a`: a projection without those tests is exactly the state this document warns about, whether it was written today or three years ago. The nineteenth is `POST /gs/debug`, which stays `n/a` because its field list comes from the request and there is no fixed projection to test. `POST /gs/db` and `POST /gs/db/custom` project only when the caller sends a field list — `request.select(query.select)` — and load the full table otherwise. -Among the 444 that fetch whole rows, the widest query they can trigger is **308 columns** at the median of the recorded maxima; at least 320 exceed 100, 89 exceed 500 and 21 exceed 1000. Postgres refuses a statement with more than 1664 columns; the widest an endpoint can trigger is 1367, so about three hundred columns separate it from a statement the database rejects. The load-site table records a wider one still, at 1453 — that site is reached by a scheduled job rather than by a request. +Among the 410 that fetch whole rows, the widest query they can trigger is **308 columns** at the median of the recorded maxima; at least 306 exceed 100, 73 exceed 500 and 21 exceed 1000. Postgres refuses a statement with more than 1664 columns, so a query near that number is one added column away from failing outright. ### How to read this column, and how not to @@ -49,16 +44,16 @@ Among the 444 that fetch whole rows, the widest query they can trigger is **308 ### Deprecation -24 handlers carry `@ApiOperation({ deprecated: true })`: 21 of them fetch whole rows, 3 read nothing. Deprecation does not follow the version, and the duplicated paths are not simply an old handler beside its replacement: `GET /kyc/countries` is marked on **both** its v1 and its v2 handler, and `GET /user/ref` on neither. +24 handlers carry `@ApiOperation({ deprecated: true })`: 19 of them fetch whole rows, 2 project, 3 read nothing. Deprecation does not follow the version, and the duplicated paths are not simply an old handler beside its replacement: `GET /kyc/countries` is marked on **both** its v1 and its v2 handler, and `GET /user/ref` on neither. ### Limits of this classification Stated exactly, so the numbers can be checked rather than believed: -- **448 of the 537 route entries rest on a call graph that is not fully resolved** — a target chosen at runtime, a method reached through inheritance, an entity manager handed into a transaction callback. This does not weaken the `whole rows` group: an unresolved edge can only add load sites, never remove one, so 444 is a lower bound in that direction. In the other direction 440 of them are backed by at least one measured query; the remaining four are the entries discussed below. +- **448 of the 537 route entries rest on a call graph that is not fully resolved** — a target chosen at runtime, a method reached through inheritance, an entity manager handed into a transaction callback. This does not weaken the `whole rows` group: an unresolved edge can only add load sites, never remove one, so 410 is a lower bound in that direction. In the other direction 407 of them are backed by at least one measured query; the remaining three are the entries discussed below. - All 89 endpoints marked `none` are the opposite case: their graph resolved completely, or the remaining target was read in the source (27 of them, listed below). None of them rests on an unresolved edge. -- The 2 `projected` and 2 `caller-defined` endpoints do each carry an unresolved edge — a call through the entity manager inside a transaction callback. Their reads were read in the source, but the classification is not proven exhaustive the way the `none` group is. -- 4 endpoints in the `whole rows` group have no measured column count and show `—`: `POST /payIn/retry`, `GET /support/issue/:id/message/:messageId/file`, `PUT /userData/:id/volumes` and `PUT /buyCrypto/:id/amlCheck/reviewReset`. Those four are also the ones most exposed to the upper bound described in [load-sites.md](load-sites.md#measurements): with no measured query behind them, nothing here shows that they reach a whole-row read at all. +- The 36 `projected` and 2 `caller-defined` endpoints do each carry an unresolved edge — a call through the entity manager inside a transaction callback. Their reads were read in the source, but the classification is not proven exhaustive the way the `none` group is. +- 3 endpoints in the `whole rows` group have no measured column count and show `—`: `POST /payIn/retry`, `GET /support/issue/:id/message/:messageId/file` and `PUT /buyCrypto/:id/amlCheck/reviewReset`. They are also the ones most exposed to the upper bound described in [load-sites.md](load-sites.md#measurements): with no measured query behind them, nothing here shows that they reach a whole-row read at all. ### Two controller classes may share a name @@ -148,7 +143,7 @@ For 27 endpoints the call graph ends at a target chosen at runtime. Each was rea | GET | 1 | | `/balance/pdf` | public | whole rows | 40 | not yet | | `BalanceController.getBalancePdf` | `subdomains/supporting/balance/controllers/balance.controller.ts` | | GET | 1 | | `/balance/pdf/blockchains` | public | none | — | n/a | | `BalanceController.getSupportedBlockchains` | `subdomains/supporting/balance/controllers/balance.controller.ts` | | GET | 1 | | `/bank` | public | whole rows | 46 | not yet | | `BankController.getAllBanks` | `subdomains/supporting/bank/bank/bank.controller.ts` | -| PUT | 1 | | `/bank/receiveIban` | public | whole rows | 101 | not yet | yes | `BankController.checkReceiveIban` | `subdomains/supporting/bank/bank/bank.controller.ts` | +| PUT | 1 | | `/bank/receiveIban` | public | whole rows | 97 | not yet | yes | `BankController.checkReceiveIban` | `subdomains/supporting/bank/bank/bank.controller.ts` | | POST | 1 | | `/bank/yapeal/webhook` | hidden | none | — | n/a | | `YapealWebhookController.handleYapealWebhook` | `integration/bank/controllers/yapeal-webhook.controller.ts` | | GET | 1 | | `/bankAccount` | public | whole rows | 261 | not yet | | `BankAccountController.getAllUserBankAccount` | `subdomains/supporting/bank/bank-account/bank-account.controller.ts` | | POST | 1 | | `/bankAccount` | public | whole rows | 261 | not yet | | `BankAccountController.createBankAccount` | `subdomains/supporting/bank/bank-account/bank-account.controller.ts` | @@ -167,31 +162,31 @@ For 27 endpoints the call graph ends at a target chosen at runtime. Each was rea | POST | 1 | | `/blockchain/broadcast` | public | none | — | n/a | | `BlockchainApiController.broadcastTransaction` | `integration/blockchain/api/controllers/blockchain-api.controller.ts` | | POST | 1 | | `/blockchain/transaction` | public | whole rows | 33 | not yet | | `BlockchainApiController.createTransaction` | `integration/blockchain/api/controllers/blockchain-api.controller.ts` | | GET | 1 | | `/buy` | hidden | whole rows | 308 | not yet | | `BuyController.getAllBuy` | `subdomains/core/buy-crypto/routes/buy/buy.controller.ts` | -| POST | 1 | | `/buy` | hidden | whole rows | 364 | not yet | | `BuyController.createBuy` | `subdomains/core/buy-crypto/routes/buy/buy.controller.ts` | +| POST | 1 | | `/buy` | hidden | whole rows | 360 | not yet | | `BuyController.createBuy` | `subdomains/core/buy-crypto/routes/buy/buy.controller.ts` | | GET | 1 | | `/buy/:id` | public | whole rows | 308 | not yet | | `BuyController.getBuy` | `subdomains/core/buy-crypto/routes/buy/buy.controller.ts` | | PUT | 1 | | `/buy/:id` | hidden | whole rows | 308 | not yet | | `BuyController.updateBuyRoute` | `subdomains/core/buy-crypto/routes/buy/buy.controller.ts` | -| GET | 1 | | `/buy/:id/history` | hidden | whole rows | 498 | not yet | | `BuyController.getBuyRouteHistory` | `subdomains/core/buy-crypto/routes/buy/buy.controller.ts` | -| PUT | 1 | | `/buy/paymentInfos` | public | whole rows | 364 | not yet | | `BuyController.createBuyWithPaymentInfo` | `subdomains/core/buy-crypto/routes/buy/buy.controller.ts` | -| PUT | 1 | | `/buy/paymentInfos/:id/confirm` | public | whole rows | 504 | not yet | | `BuyController.confirmBuy` | `subdomains/core/buy-crypto/routes/buy/buy.controller.ts` | -| PUT | 1 | | `/buy/paymentInfos/:id/invoice` | public | whole rows | 504 | not yet | yes | `BuyController.generateInvoicePDF` | `subdomains/core/buy-crypto/routes/buy/buy.controller.ts` | -| GET | 1 | | `/buy/personalIban` | public | whole rows | 331 | not yet | | `BuyController.getAllPersonalIbans` | `subdomains/core/buy-crypto/routes/buy/buy.controller.ts` | +| GET | 1 | | `/buy/:id/history` | hidden | projected | 12 | 4/4 | | `BuyController.getBuyRouteHistory` | `subdomains/core/buy-crypto/routes/buy/buy.controller.ts` | +| PUT | 1 | | `/buy/paymentInfos` | public | whole rows | 360 | not yet | | `BuyController.createBuyWithPaymentInfo` | `subdomains/core/buy-crypto/routes/buy/buy.controller.ts` | +| PUT | 1 | | `/buy/paymentInfos/:id/confirm` | public | whole rows | 484 | not yet | | `BuyController.confirmBuy` | `subdomains/core/buy-crypto/routes/buy/buy.controller.ts` | +| PUT | 1 | | `/buy/paymentInfos/:id/invoice` | public | whole rows | 484 | not yet | yes | `BuyController.generateInvoicePDF` | `subdomains/core/buy-crypto/routes/buy/buy.controller.ts` | +| GET | 1 | | `/buy/personalIban` | public | whole rows | 327 | not yet | | `BuyController.getAllPersonalIbans` | `subdomains/core/buy-crypto/routes/buy/buy.controller.ts` | | POST | 1 | | `/buy/personalIban` | public | whole rows | 253 | not yet | | `BuyController.createPersonalIban` | `subdomains/core/buy-crypto/routes/buy/buy.controller.ts` | | PUT | 1 | | `/buy/quote` | public | whole rows | 143 | not yet | | `BuyController.getBuyQuote` | `subdomains/core/buy-crypto/routes/buy/buy.controller.ts` | -| PUT | 1 | | `/buyCrypto/:id` | hidden | whole rows | 1092 | not yet | | `BuyCryptoController.update` | `subdomains/core/buy-crypto/process/buy-crypto.controller.ts` | -| PUT | 1 | | `/buyCrypto/:id/amlCheck` | hidden | whole rows | 1092 | not yet | | `BuyCryptoController.manualPassAmlCheck` | `subdomains/core/buy-crypto/process/buy-crypto.controller.ts` | +| PUT | 1 | | `/buyCrypto/:id` | hidden | whole rows | 1088 | not yet | | `BuyCryptoController.update` | `subdomains/core/buy-crypto/process/buy-crypto.controller.ts` | +| PUT | 1 | | `/buyCrypto/:id/amlCheck` | hidden | whole rows | 1088 | not yet | | `BuyCryptoController.manualPassAmlCheck` | `subdomains/core/buy-crypto/process/buy-crypto.controller.ts` | | PUT | 1 | | `/buyCrypto/:id/amlCheck/reviewReset` | hidden | whole rows | — | not yet | | `BuyCryptoController.resetAmlCheckForReview` | `subdomains/core/buy-crypto/process/buy-crypto.controller.ts` | | POST | 1 | | `/buyCrypto/:id/refund` | hidden | whole rows | 1053 | not yet | | `BuyCryptoController.refundBuyCrypto` | `subdomains/core/buy-crypto/process/buy-crypto.controller.ts` | -| POST | 1 | | `/buyCrypto/:id/scorechain` | hidden | whole rows | 718 | not yet | | `BuyCryptoController.retriggerScorechain` | `subdomains/core/buy-crypto/process/buy-crypto.controller.ts` | +| POST | 1 | | `/buyCrypto/:id/scorechain` | hidden | whole rows | 714 | not yet | | `BuyCryptoController.retriggerScorechain` | `subdomains/core/buy-crypto/process/buy-crypto.controller.ts` | | POST | 1 | | `/buyCrypto/:id/webhook` | hidden | whole rows | 846 | not yet | | `BuyCryptoController.triggerWebhook` | `subdomains/core/buy-crypto/process/buy-crypto.controller.ts` | -| PUT | 1 | | `/buyCrypto/refVolumes` | hidden | whole rows | 77 | not yet | | `BuyCryptoController.updateRefVolumes` | `subdomains/core/buy-crypto/process/buy-crypto.controller.ts` | -| PUT | 1 | | `/buyCrypto/volumes` | hidden | whole rows | 488 | not yet | | `BuyCryptoController.updateBuyVolumes` | `subdomains/core/buy-crypto/process/buy-crypto.controller.ts` | +| PUT | 1 | | `/buyCrypto/refVolumes` | hidden | projected | 2 | 0/4 | | `BuyCryptoController.updateRefVolumes` | `subdomains/core/buy-crypto/process/buy-crypto.controller.ts` | +| PUT | 1 | | `/buyCrypto/volumes` | hidden | whole rows | 484 | not yet | | `BuyCryptoController.updateBuyVolumes` | `subdomains/core/buy-crypto/process/buy-crypto.controller.ts` | | PUT | 1 | | `/buyFiat/:id` | hidden | whole rows | 1034 | not yet | | `BuyFiatController.update` | `subdomains/core/sell-crypto/process/buy-fiat.controller.ts` | | DELETE | 1 | | `/buyFiat/:id/amlCheck` | hidden | whole rows | 490 | not yet | | `BuyFiatController.resetAmlCheck` | `subdomains/core/sell-crypto/process/buy-fiat.controller.ts` | | PUT | 1 | | `/buyFiat/:id/amlCheck` | hidden | whole rows | 1034 | not yet | | `BuyFiatController.manualPassAmlCheck` | `subdomains/core/sell-crypto/process/buy-fiat.controller.ts` | | POST | 1 | | `/buyFiat/:id/refund` | hidden | whole rows | 803 | not yet | | `BuyFiatController.refundBuyFiat` | `subdomains/core/sell-crypto/process/buy-fiat.controller.ts` | | POST | 1 | | `/buyFiat/:id/scorechain` | hidden | whole rows | 517 | not yet | | `BuyFiatController.retriggerScorechain` | `subdomains/core/sell-crypto/process/buy-fiat.controller.ts` | | POST | 1 | | `/buyFiat/:id/webhook` | hidden | whole rows | 645 | not yet | | `BuyFiatController.triggerWebhook` | `subdomains/core/sell-crypto/process/buy-fiat.controller.ts` | -| PUT | 1 | | `/buyFiat/refVolumes` | hidden | whole rows | 77 | not yet | | `BuyFiatController.updateRefVolumes` | `subdomains/core/sell-crypto/process/buy-fiat.controller.ts` | +| PUT | 1 | | `/buyFiat/refVolumes` | hidden | projected | 2 | 0/4 | | `BuyFiatController.updateRefVolumes` | `subdomains/core/sell-crypto/process/buy-fiat.controller.ts` | | PUT | 1 | | `/buyFiat/volumes` | hidden | whole rows | 308 | not yet | | `BuyFiatController.updateVolumes` | `subdomains/core/sell-crypto/process/buy-fiat.controller.ts` | | GET | 1 | | `/country` | public | whole rows | 23 | not yet | | `CountryController.getAllCountry` | `shared/models/country/country.controller.ts` | | GET | 1 | | `/cryptoRoute` | hidden | none | — | n/a | | `CryptoRouteController.getAllCrypto` | `subdomains/core/buy-crypto/routes/swap/crypto-route.controller.ts` | @@ -213,27 +208,27 @@ For 27 endpoints the call graph ends at a target chosen at runtime. Each was rea | GET | 1 | | `/custody/account/:id/history` | public | whole rows | 253 | not yet | | `CustodyAccountController.getAccountHistory` | `subdomains/core/custody/controllers/custody-account.controller.ts` | | GET | 1 | | `/custody/account/:id/order` | public | whole rows | 253 | not yet | | `CustodyAccountController.getAccountOrders` | `subdomains/core/custody/controllers/custody-account.controller.ts` | | GET | 1 | | `/custody/account/:id/pdf` | public | whole rows | 253 | not yet | | `CustodyAccountController.getAccountPdf` | `subdomains/core/custody/controllers/custody-account.controller.ts` | -| POST | 1 | | `/custody/admin/order/:id/approve` | public | whole rows | 217 | not yet | | `CustodyAdminController.approveOrder` | `subdomains/core/custody/controllers/custody.controller.ts` | -| GET | 1 | | `/custody/admin/orders` | public | whole rows | 525 | not yet | | `CustodyAdminController.getOrders` | `subdomains/core/custody/controllers/custody.controller.ts` | +| POST | 1 | | `/custody/admin/order/:id/approve` | public | whole rows | 119 | not yet | | `CustodyAdminController.approveOrder` | `subdomains/core/custody/controllers/custody.controller.ts` | +| GET | 1 | | `/custody/admin/orders` | public | whole rows | 427 | not yet | | `CustodyAdminController.getOrders` | `subdomains/core/custody/controllers/custody.controller.ts` | | PUT | 1 | | `/custody/admin/user/:id/balance` | public | whole rows | 308 | not yet | | `CustodyAdminController.updateUserBalance` | `subdomains/core/custody/controllers/custody.controller.ts` | | GET | 1 | | `/custody/history` | public | whole rows | 253 | not yet | | `CustodyController.getUserCustodyHistory` | `subdomains/core/custody/controllers/custody.controller.ts` | -| GET | 1 | | `/custody/order` | public | whole rows | 19 | not yet | | `CustodyController.getOrders` | `subdomains/core/custody/controllers/custody.controller.ts` | -| POST | 1 | | `/custody/order` | public | whole rows | 364 | not yet | | `CustodyController.createOrder` | `subdomains/core/custody/controllers/custody.controller.ts` | -| POST | 1 | | `/custody/order/:id/confirm` | public | whole rows | 525 | not yet | | `CustodyController.confirmOrder` | `subdomains/core/custody/controllers/custody.controller.ts` | +| GET | 1 | | `/custody/order` | public | projected | 14 | 4/4 | | `CustodyController.getOrders` | `subdomains/core/custody/controllers/custody.controller.ts` | +| POST | 1 | | `/custody/order` | public | whole rows | 360 | not yet | | `CustodyController.createOrder` | `subdomains/core/custody/controllers/custody.controller.ts` | +| POST | 1 | | `/custody/order/:id/confirm` | public | whole rows | 427 | not yet | | `CustodyController.confirmOrder` | `subdomains/core/custody/controllers/custody.controller.ts` | | GET | 1 | | `/custody/pdf` | public | whole rows | 253 | not yet | | `CustodyController.getCustodyPdf` | `subdomains/core/custody/controllers/custody.controller.ts` | | GET | 1 | | `/dashboard/accounting/ledger/accounts` | hidden | whole rows | 54 | not yet | yes | `LedgerController.getAccounts` | `subdomains/core/accounting/controllers/ledger.controller.ts` | | GET | 1 | | `/dashboard/accounting/ledger/accounts/:accountId/legs` | hidden | whole rows | 30 | not yet | yes | `LedgerController.getAccountDetail` | `subdomains/core/accounting/controllers/ledger.controller.ts` | | GET | 1 | | `/dashboard/accounting/ledger/equity-comparison` | hidden | whole rows | 54 | not yet | yes | `LedgerController.getEquityComparison` | `subdomains/core/accounting/controllers/ledger.controller.ts` | -| GET | 1 | | `/dashboard/accounting/ledger/margin` | hidden | whole rows | 11 | not yet | yes | `LedgerController.getMargin` | `subdomains/core/accounting/controllers/ledger.controller.ts` | +| GET | 1 | | `/dashboard/accounting/ledger/margin` | hidden | projected | 4 | 0/4 | yes | `LedgerController.getMargin` | `subdomains/core/accounting/controllers/ledger.controller.ts` | | GET | 1 | | `/dashboard/accounting/ledger/reconciliation` | hidden | whole rows | 54 | not yet | yes | `LedgerController.getReconStatus` | `subdomains/core/accounting/controllers/ledger.controller.ts` | -| GET | 1 | | `/dashboard/accounting/ledger/suspense` | hidden | whole rows | 11 | not yet | yes | `LedgerController.getSuspense` | `subdomains/core/accounting/controllers/ledger.controller.ts` | +| GET | 1 | | `/dashboard/accounting/ledger/suspense` | hidden | projected | 10 | 4/4 | yes | `LedgerController.getSuspense` | `subdomains/core/accounting/controllers/ledger.controller.ts` | | GET | 1 | | `/dashboard/financial/changes` | hidden | whole rows | 11 | not yet | | `DashboardFinancialController.getFinancialChanges` | `subdomains/supporting/dashboard/dashboard-financial.controller.ts` | | GET | 1 | | `/dashboard/financial/changes/latest` | hidden | whole rows | 11 | not yet | | `DashboardFinancialController.getLatestChanges` | `subdomains/supporting/dashboard/dashboard-financial.controller.ts` | | GET | 1 | | `/dashboard/financial/latest` | hidden | whole rows | 33 | not yet | | `DashboardFinancialController.getLatestBalance` | `subdomains/supporting/dashboard/dashboard-financial.controller.ts` | | GET | 1 | | `/dashboard/financial/log` | hidden | whole rows | 33 | not yet | yes | `DashboardFinancialController.getFinancialLog` | `subdomains/supporting/dashboard/dashboard-financial.controller.ts` | | GET | 1 | | `/dashboard/financial/reconciliation` | hidden | whole rows | 229 | not yet | | `DashboardReconciliationController.getReconciliation` | `subdomains/supporting/dashboard/dashboard-reconciliation.controller.ts` | | GET | 1 | | `/dashboard/financial/reconciliation/overview` | hidden | whole rows | 229 | not yet | | `DashboardReconciliationController.getOverview` | `subdomains/supporting/dashboard/dashboard-reconciliation.controller.ts` | -| GET | 1 | | `/dashboard/financial/ref-recipients` | hidden | whole rows | 2 | not yet | | `DashboardFinancialController.getRefRewardRecipients` | `subdomains/supporting/dashboard/dashboard-financial.controller.ts` | +| GET | 1 | | `/dashboard/financial/ref-recipients` | hidden | projected | 2 | 0/4 | | `DashboardFinancialController.getRefRewardRecipients` | `subdomains/supporting/dashboard/dashboard-financial.controller.ts` | | POST | 1 | | `/deposit` | hidden | whole rows | 6 | not yet | | `DepositController.createDeposits` | `subdomains/supporting/address-pool/deposit/deposit.controller.ts` | | PUT | 1 | | `/deposit/lightningWebhook` | hidden | none | — | n/a | | `DepositController.updateLightningDepositWebhook` | `subdomains/supporting/address-pool/deposit/deposit.controller.ts` | | GET | 1 | | `/deuro/info` | public | whole rows | 11 | not yet | | `DEuroController.getInfo` | `integration/blockchain/deuro/controllers/deuro.controller.ts` | @@ -260,7 +255,7 @@ For 27 endpoints the call graph ends at a target chosen at runtime. Each was rea | PUT | 1 | | `/fiatOutput/:id` | hidden | whole rows | 59 | not yet | | `FiatOutputController.update` | `subdomains/supporting/fiat-output/fiat-output.controller.ts` | | GET | 1 | | `/frankencoin/info` | public | whole rows | 11 | not yet | | `FrankencoinController.getInfo` | `integration/blockchain/frankencoin/controllers/frankencoin.controller.ts` | | POST | 1 | | `/gs/db` | hidden | caller-defined | 13 | n/a | yes | `GsController.getDbData` | `subdomains/generic/gs/gs.controller.ts` | -| POST | 1 | | `/gs/db/custom` | hidden | caller-defined | — | n/a | yes | `GsController.getExtendedData` | `subdomains/generic/gs/gs.controller.ts` | +| POST | 1 | | `/gs/db/custom` | hidden | caller-defined | 2 | n/a | yes | `GsController.getExtendedData` | `subdomains/generic/gs/gs.controller.ts` | | POST | 1 | | `/gs/debug` | hidden | projected | — | n/a | yes | `GsController.executeDebugQuery` | `subdomains/generic/gs/gs.controller.ts` | | POST | 1 | | `/gs/evm/bridgeApproval` | hidden | whole rows | 33 | not yet | | `GsEvmController.approveBridge` | `subdomains/generic/gs/gs-evm.controller.ts` | | POST | 1 | | `/gs/evm/coinTransaction` | hidden | whole rows | 6 | not yet | | `GsEvmController.sendCoinTransaction` | `subdomains/generic/gs/gs-evm.controller.ts` | @@ -268,17 +263,17 @@ For 27 endpoints the call graph ends at a target chosen at runtime. Each was rea | POST | 1 | | `/gs/evm/contractTransaction` | hidden | none | — | n/a | | `GsEvmController.sendContractTransaction` | `subdomains/generic/gs/gs-evm.controller.ts` | | POST | 1 | | `/gs/evm/rawTransaction` | hidden | whole rows | 6 | not yet | | `GsEvmController.sendRawTransaction` | `subdomains/generic/gs/gs-evm.controller.ts` | | POST | 1 | | `/gs/evm/tokenTransaction` | hidden | whole rows | 33 | not yet | | `GsEvmController.sendTokenTransaction` | `subdomains/generic/gs/gs-evm.controller.ts` | -| GET | 1 | | `/gs/support` | hidden | whole rows | 910 | not yet | yes | `GsController.getSupportData` | `subdomains/generic/gs/gs.controller.ts` | +| GET | 1 | | `/gs/support` | hidden | whole rows | 906 | not yet | yes | `GsController.getSupportData` | `subdomains/generic/gs/gs.controller.ts` | | GET | neutral | | `/health` | public | whole rows | 4 | not yet | | `HealthController.getHealth` | `subdomains/core/monitoring/health.controller.ts` | | GET | neutral | | `/health/banking` | public | whole rows | 4 | not yet | | `HealthController.getBankingHealth` | `subdomains/core/monitoring/health.controller.ts` | | GET | neutral | | `/health/external` | public | whole rows | 4 | not yet | | `HealthController.getExternalHealth` | `subdomains/core/monitoring/health.controller.ts` | | GET | neutral | | `/health/liquidity` | public | whole rows | 4 | not yet | | `HealthController.getLiquidityHealth` | `subdomains/core/monitoring/health.controller.ts` | | GET | neutral | | `/health/nodes` | public | whole rows | 4 | not yet | | `HealthController.getNodeHealth` | `subdomains/core/monitoring/health.controller.ts` | | GET | neutral | | `/health/payment` | public | whole rows | 4 | not yet | | `HealthController.getPaymentHealth` | `subdomains/core/monitoring/health.controller.ts` | -| GET | 1 | | `/history` | hidden | whole rows | 1367 | not yet | | `HistoryController.getHistory` | `subdomains/core/history/controllers/history.controller.ts` | -| GET | 1 | | `/history/:exportType` | hidden | whole rows | 1367 | not yet | | `HistoryController.getApiHistory` | `subdomains/core/history/controllers/history.controller.ts` | +| GET | 1 | | `/history` | hidden | whole rows | 1363 | not yet | | `HistoryController.getHistory` | `subdomains/core/history/controllers/history.controller.ts` | +| GET | 1 | | `/history/:exportType` | hidden | whole rows | 1363 | not yet | | `HistoryController.getApiHistory` | `subdomains/core/history/controllers/history.controller.ts` | | GET | 1 | | `/history/csv` | hidden | none | — | n/a | | `HistoryController.getCsv` | `subdomains/core/history/controllers/history.controller.ts` | -| POST | 1 | | `/history/csv` | hidden | whole rows | 1367 | not yet | | `HistoryController.createCsv` | `subdomains/core/history/controllers/history.controller.ts` | +| POST | 1 | | `/history/csv` | hidden | whole rows | 1363 | not yet | | `HistoryController.createCsv` | `subdomains/core/history/controllers/history.controller.ts` | | GET | 1 | | `/ikna/bfs/:id` | hidden | none | — | n/a | | `IknaController.getBfsResult` | `integration/ikna/controllers/ikna.controller.ts` | | POST | 1 | | `/ikna/bfs/address` | hidden | none | — | n/a | | `IknaController.createBfsAddressRequest` | `integration/ikna/controllers/ikna.controller.ts` | | GET | 1 | | `/ikna/tag` | hidden | none | — | n/a | | `IknaController.getIknaAddressTag` | `integration/ikna/controllers/ikna.controller.ts` | @@ -293,22 +288,22 @@ For 27 endpoints the call graph ends at a target chosen at runtime. Each was rea | GET | 1 | yes | `/kyc/:code` | public | whole rows | 351 | not yet | | `KycController.getKycProgressByCodeV1` | `subdomains/generic/user/models/kyc/kyc.controller.ts` | | POST | 1 | yes | `/kyc/:code` | public | whole rows | 351 | not yet | | `KycController.requestKycByCodeV1` | `subdomains/generic/user/models/kyc/kyc.controller.ts` | | GET | 1 | yes | `/kyc/:code/countries` | public | whole rows | 351 | not yet | | `KycController.getKycCountriesByCodeV1` | `subdomains/generic/user/models/kyc/kyc.controller.ts` | -| GET | 1 | yes | `/kyc/:id/documents` | public | whole rows | 328 | not yet | | `KycClientController.getKycFilesV1` | `subdomains/generic/user/models/kyc/kyc.controller.ts` | +| GET | 1 | yes | `/kyc/:id/documents` | public | projected | 2 | 4/4 | | `KycClientController.getKycFilesV1` | `subdomains/generic/user/models/kyc/kyc.controller.ts` | | GET | 1 | yes | `/kyc/:id/documents/:type` | public | whole rows | 328 | not yet | | `KycClientController.getKycFileV1` | `subdomains/generic/user/models/kyc/kyc.controller.ts` | | GET | 2 | | `/kyc/:step` | hidden | whole rows | 364 | not yet | | `KycController.initiateStep` | `subdomains/generic/kyc/controllers/kyc.controller.ts` | | DELETE | 1 | | `/kyc/admin/blacklist/ip` | hidden | none | — | n/a | | `KycAdminController.deleteIpToBlacklist` | `subdomains/generic/kyc/controllers/kyc-admin.controller.ts` | -| PUT | 1 | | `/kyc/admin/blacklist/ip` | hidden | whole rows | 12 | not yet | | `KycAdminController.addIpToBlacklist` | `subdomains/generic/kyc/controllers/kyc-admin.controller.ts` | +| PUT | 1 | | `/kyc/admin/blacklist/ip` | hidden | projected | 1 | 0/4 | | `KycAdminController.addIpToBlacklist` | `subdomains/generic/kyc/controllers/kyc-admin.controller.ts` | | POST | 1 | | `/kyc/admin/ident/file/sync` | hidden | whole rows | 243 | not yet | | `KycAdminController.syncIdentFiles` | `subdomains/generic/kyc/controllers/kyc-admin.controller.ts` | | POST | 1 | | `/kyc/admin/log` | hidden | whole rows | 253 | not yet | | `KycAdminController.createLog` | `subdomains/generic/kyc/controllers/kyc-admin.controller.ts` | | PUT | 1 | | `/kyc/admin/log/:id` | hidden | whole rows | 17 | not yet | | `KycAdminController.updateLog` | `subdomains/generic/kyc/controllers/kyc-admin.controller.ts` | | PUT | 1 | | `/kyc/admin/nameCheck/:id` | hidden | whole rows | 245 | not yet | | `KycAdminController.updateNameCheckLog` | `subdomains/generic/kyc/controllers/kyc-admin.controller.ts` | | PUT | 1 | | `/kyc/admin/step/:id` | hidden | whole rows | 385 | not yet | | `KycAdminController.updateKycStep` | `subdomains/generic/kyc/controllers/kyc-admin.controller.ts` | | POST | 1 | | `/kyc/admin/webhook` | hidden | whole rows | 364 | not yet | | `KycAdminController.triggerWebhook` | `subdomains/generic/kyc/controllers/kyc-admin.controller.ts` | -| GET | 2 | | `/kyc/client/payments` | public | whole rows | 1095 | not yet | | `KycClientController.getAllPayments` | `subdomains/generic/kyc/controllers/kyc-client.controller.ts` | +| GET | 2 | | `/kyc/client/payments` | public | whole rows | 1091 | not yet | | `KycClientController.getAllPayments` | `subdomains/generic/kyc/controllers/kyc-client.controller.ts` | | GET | 2 | | `/kyc/client/users` | public | whole rows | 20 | not yet | | `KycClientController.getAllKycData` | `subdomains/generic/kyc/controllers/kyc-client.controller.ts` | | GET | 2 | | `/kyc/client/users/:id/documents` | public | whole rows | 78 | not yet | | `KycClientController.getKycFiles` | `subdomains/generic/kyc/controllers/kyc-client.controller.ts` | | GET | 2 | | `/kyc/client/users/:id/documents/:type` | public | whole rows | 78 | not yet | | `KycClientController.getKycFile` | `subdomains/generic/kyc/controllers/kyc-client.controller.ts` | -| GET | 2 | | `/kyc/client/users/:id/payments` | public | whole rows | 1095 | not yet | | `KycClientController.getUserPayments` | `subdomains/generic/kyc/controllers/kyc-client.controller.ts` | +| GET | 2 | | `/kyc/client/users/:id/payments` | public | whole rows | 1091 | not yet | | `KycClientController.getUserPayments` | `subdomains/generic/kyc/controllers/kyc-client.controller.ts` | | GET | 1 | yes | `/kyc/countries` | public | whole rows | 351 | not yet | | `KycController.getKycCountriesV1` | `subdomains/generic/user/models/kyc/kyc.controller.ts` | | GET | 2 | yes | `/kyc/countries` | public | whole rows | 351 | not yet | | `KycController.getKycCountries` | `subdomains/generic/kyc/controllers/kyc.controller.ts` | | DELETE | 2 | | `/kyc/data/:type/:id` | public | whole rows | 351 | not yet | | `KycController.cancelStep` | `subdomains/generic/kyc/controllers/kyc.controller.ts` | @@ -339,13 +334,13 @@ For 27 endpoints the call graph ends at a target chosen at runtime. Each was rea | DELETE | 2 | | `/kyc/transfer` | hidden | whole rows | 351 | not yet | | `KycController.removeKycClient` | `subdomains/generic/kyc/controllers/kyc.controller.ts` | | POST | 2 | | `/kyc/transfer` | hidden | whole rows | 364 | not yet | | `KycController.addKycClient` | `subdomains/generic/kyc/controllers/kyc.controller.ts` | | PUT | 1 | yes | `/kyc/transfer` | public | whole rows | 364 | not yet | | `KycController.transferKycDataV1` | `subdomains/generic/user/models/kyc/kyc.controller.ts` | -| GET | 1 | yes | `/kyc/users` | public | whole rows | 328 | not yet | | `KycClientController.getAllKycDataV1` | `subdomains/generic/user/models/kyc/kyc.controller.ts` | +| GET | 1 | yes | `/kyc/users` | public | projected | 7 | 4/4 | | `KycClientController.getAllKycDataV1` | `subdomains/generic/user/models/kyc/kyc.controller.ts` | | GET | 1 | | `/language` | public | whole rows | 7 | not yet | | `LanguageController.getAllLanguage` | `shared/models/language/language.controller.ts` | | PUT | 1 | | `/limitRequest/:id` | hidden | whole rows | 364 | not yet | | `LimitRequestController.updateUserData` | `subdomains/supporting/support-issue/limit-request.controller.ts` | | GET | 1 | | `/liquidityManagement/balance` | hidden | whole rows | 40 | not yet | | `LiquidityBalanceController.getBalances` | `subdomains/core/liquidity-management/controllers/balance.controller.ts` | | PUT | 1 | | `/liquidityManagement/order/:id/resolveUncertain` | hidden | whole rows | 139 | not yet | | `LiquidityManagementOrderController.resolveUncertainOrder` | `subdomains/core/liquidity-management/controllers/order.controller.ts` | | GET | 1 | | `/liquidityManagement/order/in-progress` | hidden | whole rows | 139 | not yet | | `LiquidityManagementOrderController.getProcessingOrders` | `subdomains/core/liquidity-management/controllers/order.controller.ts` | -| GET | 1 | | `/liquidityManagement/pipeline/:id/status` | hidden | whole rows | 112 | not yet | | `LiquidityManagementPipelineController.getPipelineStatus` | `subdomains/core/liquidity-management/controllers/pipeline.controller.ts` | +| GET | 1 | | `/liquidityManagement/pipeline/:id/status` | hidden | projected | 2 | 4/4 | | `LiquidityManagementPipelineController.getPipelineStatus` | `subdomains/core/liquidity-management/controllers/pipeline.controller.ts` | | POST | 1 | | `/liquidityManagement/pipeline/buy` | hidden | whole rows | 112 | not yet | | `LiquidityManagementPipelineController.buyLiquidity` | `subdomains/core/liquidity-management/controllers/pipeline.controller.ts` | | GET | 1 | | `/liquidityManagement/pipeline/in-progress` | hidden | whole rows | 112 | not yet | | `LiquidityManagementPipelineController.getProcessingPipelines` | `subdomains/core/liquidity-management/controllers/pipeline.controller.ts` | | POST | 1 | | `/liquidityManagement/pipeline/sell` | hidden | whole rows | 112 | not yet | | `LiquidityManagementPipelineController.sellLiquidity` | `subdomains/core/liquidity-management/controllers/pipeline.controller.ts` | @@ -396,7 +391,7 @@ For 27 endpoints the call graph ends at a target chosen at runtime. Each was rea | PUT | 1 | | `/paymentLink` | public | whole rows | 513 | not yet | | `PaymentLinkController.updatePaymentLink` | `subdomains/core/payment-link/controllers/payment-link.controller.ts` | | DELETE | 1 | | `/paymentLink/:id` | hidden | whole rows | 195 | not yet | | `PaymentLinkController.deletePaymentLink` | `subdomains/core/payment-link/controllers/payment-link.controller.ts` | | PUT | 1 | | `/paymentLink/:id` | hidden | whole rows | 513 | not yet | | `PaymentLinkController.updatePaymentLinkAdmin` | `subdomains/core/payment-link/controllers/payment-link.controller.ts` | -| PUT | 1 | | `/paymentLink/:id/pos` | hidden | whole rows | 513 | not yet | | `PaymentLinkController.createPosLinkAdmin` | `subdomains/core/payment-link/controllers/payment-link.controller.ts` | +| PUT | 1 | | `/paymentLink/:id/pos` | hidden | projected | 7 | 4/4 | | `PaymentLinkController.createPosLinkAdmin` | `subdomains/core/payment-link/controllers/payment-link.controller.ts` | | PUT | 1 | | `/paymentLink/assign` | public | whole rows | 513 | not yet | yes | `PaymentLinkController.assignPaymentLink` | `subdomains/core/payment-link/controllers/payment-link.controller.ts` | | GET | 1 | | `/paymentLink/config` | public | whole rows | 253 | not yet | | `PaymentLinkController.getUserPaymentLinksConfig` | `subdomains/core/payment-link/controllers/payment-link.controller.ts` | | PUT | 1 | | `/paymentLink/config` | public | whole rows | 253 | not yet | | `PaymentLinkController.updateUserPaymentLinksConfig` | `subdomains/core/payment-link/controllers/payment-link.controller.ts` | @@ -444,8 +439,8 @@ For 27 endpoints the call graph ends at a target chosen at runtime. Each was rea | GET | 1 | yes | `/realunit/brokerbot/price` | public | none | — | n/a | | `RealUnitController.getBrokerbotPrice` | `subdomains/supporting/realunit/controllers/realunit.controller.ts` | | GET | 1 | yes | `/realunit/brokerbot/sellPrice` | public | whole rows | 308 | not yet | | `RealUnitController.getBrokerbotSellPrice` | `subdomains/supporting/realunit/controllers/realunit.controller.ts` | | GET | 1 | yes | `/realunit/brokerbot/sellShares` | public | whole rows | 308 | not yet | | `RealUnitController.getBrokerbotSellShares` | `subdomains/supporting/realunit/controllers/realunit.controller.ts` | -| PUT | 1 | | `/realunit/buy` | public | whole rows | 364 | not yet | yes | `RealUnitController.getPaymentInfo` | `subdomains/supporting/realunit/controllers/realunit.controller.ts` | -| PUT | 1 | | `/realunit/buy/:id/confirm` | public | whole rows | 504 | not yet | | `RealUnitController.confirmBuy` | `subdomains/supporting/realunit/controllers/realunit.controller.ts` | +| PUT | 1 | | `/realunit/buy` | public | whole rows | 360 | not yet | yes | `RealUnitController.getPaymentInfo` | `subdomains/supporting/realunit/controllers/realunit.controller.ts` | +| PUT | 1 | | `/realunit/buy/:id/confirm` | public | whole rows | 484 | not yet | | `RealUnitController.confirmBuy` | `subdomains/supporting/realunit/controllers/realunit.controller.ts` | | GET | 1 | | `/realunit/compliance/customers` | hidden | whole rows | 308 | not yet | | `RealUnitComplianceController.searchCustomers` | `subdomains/supporting/realunit/controllers/realunit-compliance.controller.ts` | | GET | 1 | | `/realunit/compliance/customers/:id` | hidden | whole rows | 1039 | not yet | | `RealUnitComplianceController.getCustomer` | `subdomains/supporting/realunit/controllers/realunit-compliance.controller.ts` | | GET | 1 | | `/realunit/compliance/customers/:id/dossier` | hidden | whole rows | 264 | not yet | | `RealUnitComplianceController.downloadCustomerDossier` | `subdomains/supporting/realunit/controllers/realunit-compliance.controller.ts` | @@ -473,22 +468,22 @@ For 27 endpoints the call graph ends at a target chosen at runtime. Each was rea | POST | 1 | | `/realunit/register/wallet` | public | whole rows | 493 | not yet | yes | `RealUnitController.completeRegistrationForWalletAddress` | `subdomains/supporting/realunit/controllers/realunit.controller.ts` | | GET | 1 | | `/realunit/registration` | public | whole rows | 308 | not yet | yes | `RealUnitController.getRegistrationInfo` | `subdomains/supporting/realunit/controllers/realunit.controller.ts` | | PUT | 1 | | `/realunit/sell` | public | whole rows | 308 | not yet | yes | `RealUnitController.getSellPaymentInfo` | `subdomains/supporting/realunit/controllers/realunit.controller.ts` | -| PUT | 1 | | `/realunit/sell/:id/broadcast` | public | whole rows | 504 | not yet | | `RealUnitController.broadcastSellTransaction` | `subdomains/supporting/realunit/controllers/realunit.controller.ts` | -| PUT | 1 | | `/realunit/sell/:id/confirm` | public | whole rows | 504 | not yet | | `RealUnitController.confirmSell` | `subdomains/supporting/realunit/controllers/realunit.controller.ts` | -| PUT | 1 | | `/realunit/sell/:id/unsigned-transactions` | public | whole rows | 504 | not yet | | `RealUnitController.getSellUnsignedTransactions` | `subdomains/supporting/realunit/controllers/realunit.controller.ts` | +| PUT | 1 | | `/realunit/sell/:id/broadcast` | public | whole rows | 484 | not yet | | `RealUnitController.broadcastSellTransaction` | `subdomains/supporting/realunit/controllers/realunit.controller.ts` | +| PUT | 1 | | `/realunit/sell/:id/confirm` | public | whole rows | 484 | not yet | | `RealUnitController.confirmSell` | `subdomains/supporting/realunit/controllers/realunit.controller.ts` | +| PUT | 1 | | `/realunit/sell/:id/unsigned-transactions` | public | whole rows | 484 | not yet | | `RealUnitController.getSellUnsignedTransactions` | `subdomains/supporting/realunit/controllers/realunit.controller.ts` | | PUT | 1 | | `/realunit/support/:id` | hidden | whole rows | 421 | not yet | | `RealUnitSupportController.updateSupportIssue` | `subdomains/supporting/realunit/controllers/realunit-support.controller.ts` | -| GET | 1 | | `/realunit/support/:id/data` | hidden | whole rows | 952 | not yet | | `RealUnitSupportController.getIssueData` | `subdomains/supporting/realunit/controllers/realunit-support.controller.ts` | +| GET | 1 | | `/realunit/support/:id/data` | hidden | whole rows | 421 | not yet | | `RealUnitSupportController.getIssueData` | `subdomains/supporting/realunit/controllers/realunit-support.controller.ts` | | POST | 1 | | `/realunit/support/:id/message` | hidden | whole rows | 441 | not yet | | `RealUnitSupportController.createSupportMessage` | `subdomains/supporting/realunit/controllers/realunit-support.controller.ts` | | GET | 1 | | `/realunit/support/:id/message/:messageId/file` | hidden | whole rows | 421 | not yet | | `RealUnitSupportController.getFile` | `subdomains/supporting/realunit/controllers/realunit-support.controller.ts` | | GET | 1 | | `/realunit/support/:id/messages` | hidden | whole rows | 428 | not yet | | `RealUnitSupportController.getIssueMessages` | `subdomains/supporting/realunit/controllers/realunit-support.controller.ts` | -| GET | 1 | | `/realunit/support/activity` | hidden | whole rows | 99 | not yet | | `RealUnitSupportController.getSupportIssueActivity` | `subdomains/supporting/realunit/controllers/realunit-support.controller.ts` | +| GET | 1 | | `/realunit/support/activity` | hidden | projected | 2 | 0/4 | | `RealUnitSupportController.getSupportIssueActivity` | `subdomains/supporting/realunit/controllers/realunit-support.controller.ts` | | GET | 1 | | `/realunit/support/clerks` | hidden | none | — | n/a | | `RealUnitSupportController.getRealUnitSupportClerks` | `subdomains/supporting/realunit/controllers/realunit-support.controller.ts` | -| GET | 1 | | `/realunit/support/counts` | hidden | whole rows | 99 | not yet | | `RealUnitSupportController.getSupportIssueCounts` | `subdomains/supporting/realunit/controllers/realunit-support.controller.ts` | -| GET | 1 | | `/realunit/support/list` | hidden | whole rows | 99 | not yet | | `RealUnitSupportController.getSupportIssueList` | `subdomains/supporting/realunit/controllers/realunit-support.controller.ts` | -| GET | 1 | | `/realunit/support/statistics` | hidden | whole rows | 99 | not yet | | `RealUnitSupportController.getSupportIssueStatistics` | `subdomains/supporting/realunit/controllers/realunit-support.controller.ts` | +| GET | 1 | | `/realunit/support/counts` | hidden | projected | 2 | 0/4 | | `RealUnitSupportController.getSupportIssueCounts` | `subdomains/supporting/realunit/controllers/realunit-support.controller.ts` | +| GET | 1 | | `/realunit/support/list` | hidden | projected | 10 | 4/4 | | `RealUnitSupportController.getSupportIssueList` | `subdomains/supporting/realunit/controllers/realunit-support.controller.ts` | +| GET | 1 | | `/realunit/support/statistics` | hidden | projected | 3 | 0/4 | | `RealUnitSupportController.getSupportIssueStatistics` | `subdomains/supporting/realunit/controllers/realunit-support.controller.ts` | | PUT | 1 | | `/realunit/swap` | public | whole rows | 308 | not yet | yes | `RealUnitController.getSwapPaymentInfo` | `subdomains/supporting/realunit/controllers/realunit.controller.ts` | -| PUT | 1 | | `/realunit/swap/:id/broadcast` | public | whole rows | 504 | not yet | yes | `RealUnitController.broadcastSwapTransaction` | `subdomains/supporting/realunit/controllers/realunit.controller.ts` | -| PUT | 1 | | `/realunit/swap/:id/unsigned-transaction` | public | whole rows | 504 | not yet | yes | `RealUnitController.getSwapUnsignedTransaction` | `subdomains/supporting/realunit/controllers/realunit.controller.ts` | +| PUT | 1 | | `/realunit/swap/:id/broadcast` | public | whole rows | 484 | not yet | yes | `RealUnitController.broadcastSwapTransaction` | `subdomains/supporting/realunit/controllers/realunit.controller.ts` | +| PUT | 1 | | `/realunit/swap/:id/unsigned-transaction` | public | whole rows | 484 | not yet | yes | `RealUnitController.getSwapUnsignedTransaction` | `subdomains/supporting/realunit/controllers/realunit.controller.ts` | | GET | 1 | | `/realunit/tokenInfo` | public | none | — | n/a | | `RealUnitController.getTokenInfo` | `subdomains/supporting/realunit/controllers/realunit.controller.ts` | | POST | 1 | | `/realunit/transactions/receipt/multi` | public | whole rows | 308 | not yet | | `RealUnitController.generateHistoryMultiReceipt` | `subdomains/supporting/realunit/controllers/realunit.controller.ts` | | POST | 1 | | `/realunit/transactions/receipt/single` | public | whole rows | 308 | not yet | | `RealUnitController.generateHistoryReceipt` | `subdomains/supporting/realunit/controllers/realunit.controller.ts` | @@ -509,16 +504,16 @@ For 27 endpoints the call graph ends at a target chosen at runtime. Each was rea | POST | 1 | | `/reward/ref/manual` | hidden | whole rows | 308 | not yet | | `RefRewardController.createManualRefReward` | `subdomains/core/referral/reward/ref-reward.controller.ts` | | PUT | 1 | | `/reward/ref/volumes` | hidden | whole rows | 308 | not yet | | `RefRewardController.updateVolumes` | `subdomains/core/referral/reward/ref-reward.controller.ts` | | GET | 1 | | `/route` | hidden | whole rows | 308 | not yet | | `RouteController.getAllRoutes` | `subdomains/core/route/route.controller.ts` | -| PUT | 1 | | `/route/:id` | hidden | whole rows | 174 | not yet | | `RouteController.updateRoute` | `subdomains/core/route/route.controller.ts` | +| PUT | 1 | | `/route/:id` | hidden | whole rows | 170 | not yet | | `RouteController.updateRoute` | `subdomains/core/route/route.controller.ts` | | POST | 1 | | `/scorechain/screening` | hidden | whole rows | 14 | not yet | | `ScorechainController.screen` | `integration/scorechain/controllers/scorechain.controller.ts` | | GET | 1 | | `/sell` | hidden | whole rows | 308 | not yet | | `SellController.getAllSell` | `subdomains/core/sell-crypto/route/sell.controller.ts` | | POST | 1 | | `/sell` | hidden | whole rows | 308 | not yet | | `SellController.createSell` | `subdomains/core/sell-crypto/route/sell.controller.ts` | | GET | 1 | | `/sell/:id` | public | whole rows | 377 | not yet | | `SellController.getSell` | `subdomains/core/sell-crypto/route/sell.controller.ts` | | PUT | 1 | | `/sell/:id` | hidden | whole rows | 308 | not yet | | `SellController.updateSell` | `subdomains/core/sell-crypto/route/sell.controller.ts` | -| GET | 1 | | `/sell/:id/history` | hidden | whole rows | 470 | not yet | | `SellController.getSellRouteHistory` | `subdomains/core/sell-crypto/route/sell.controller.ts` | +| GET | 1 | | `/sell/:id/history` | hidden | projected | 14 | 4/4 | | `SellController.getSellRouteHistory` | `subdomains/core/sell-crypto/route/sell.controller.ts` | | PUT | 1 | | `/sell/paymentInfos` | public | whole rows | 308 | not yet | | `SellController.createSellWithPaymentInfo` | `subdomains/core/sell-crypto/route/sell.controller.ts` | | PUT | 1 | | `/sell/paymentInfos/:id/confirm` | public | whole rows | 545 | not yet | | `SellController.confirmSell` | `subdomains/core/sell-crypto/route/sell.controller.ts` | -| GET | 1 | | `/sell/paymentInfos/:id/tx` | public | whole rows | 504 | not yet | | `SellController.depositTx` | `subdomains/core/sell-crypto/route/sell.controller.ts` | +| GET | 1 | | `/sell/paymentInfos/:id/tx` | public | whole rows | 484 | not yet | | `SellController.depositTx` | `subdomains/core/sell-crypto/route/sell.controller.ts` | | PUT | 1 | | `/sell/quote` | public | whole rows | 143 | not yet | | `SellController.getSellQuote` | `subdomains/core/sell-crypto/route/sell.controller.ts` | | GET | 1 | | `/setting` | hidden | whole rows | 5 | not yet | | `SettingController.getSettings` | `shared/models/setting/setting.controller.ts` | | PUT | 1 | | `/setting/:key` | hidden | whole rows | 5 | not yet | | `SettingController.updateSetting` | `shared/models/setting/setting.controller.ts` | @@ -528,7 +523,7 @@ For 27 endpoints the call graph ends at a target chosen at runtime. Each was rea | POST | 1 | | `/specialExternalAccount` | hidden | whole rows | 7 | not yet | | `SpecialExternalAccountController.createSpecialExternalAccount` | `subdomains/supporting/payment/controllers/special-external-account.controller.ts` | | GET | 1 | | `/statistic` | public | whole rows | 5 | not yet | | `StatisticController.getAll` | `subdomains/core/statistic/statistic.controller.ts` | | GET | 1 | | `/statistic/status` | public | whole rows | 5 | not yet | | `StatisticController.getStatus` | `subdomains/core/statistic/statistic.controller.ts` | -| GET | 1 | | `/statistic/transactions` | public | whole rows | 420 | not yet | | `StatisticController.getTransactions` | `subdomains/core/statistic/statistic.controller.ts` | +| GET | 1 | | `/statistic/transactions` | public | whole rows | 416 | not yet | | `StatisticController.getTransactions` | `subdomains/core/statistic/statistic.controller.ts` | | GET | 1 | | `/support` | hidden | whole rows | 594 | not yet | | `SupportController.searchUserByKey` | `subdomains/generic/support/support.controller.ts` | | GET | 1 | | `/support/:id` | hidden | whole rows | 1039 | not yet | | `SupportController.getUserData` | `subdomains/generic/support/support.controller.ts` | | GET | 1 | | `/support/:id/ip-log-pdf` | hidden | whole rows | 12 | not yet | | `SupportController.getIpLogPdf` | `subdomains/generic/support/support.controller.ts` | @@ -537,80 +532,80 @@ For 27 endpoints the call graph ends at a target chosen at runtime. Each was rea | GET | 1 | | `/support/:id/scorechain` | hidden | whole rows | 14 | not yet | | `SupportController.getScorechainScreenings` | `subdomains/generic/support/support.controller.ts` | | GET | 1 | | `/support/:id/transaction-pdf` | hidden | whole rows | 1039 | not yet | | `SupportController.getTransactionPdf` | `subdomains/generic/support/support.controller.ts` | | GET | 1 | | `/support/call-queues` | hidden | none | — | n/a | | `SupportController.getCallQueues` | `subdomains/generic/support/support.controller.ts` | -| GET | 1 | | `/support/call-queues/:queue/items` | hidden | whole rows | 673 | not yet | | `SupportController.getCallQueueItems` | `subdomains/generic/support/support.controller.ts` | +| GET | 1 | | `/support/call-queues/:queue/items` | hidden | whole rows | 669 | not yet | | `SupportController.getCallQueueItems` | `subdomains/generic/support/support.controller.ts` | | GET | 1 | | `/support/call-queues/clerks` | hidden | none | — | n/a | | `SupportController.getCallQueueClerks` | `subdomains/generic/support/support.controller.ts` | -| GET | 1 | | `/support/issue` | public | whole rows | 450 | not yet | | `SupportIssueController.getIssues` | `subdomains/supporting/support-issue/support-issue.controller.ts` | +| GET | 1 | | `/support/issue` | public | projected | 11 | 4/4 | | `SupportIssueController.getIssues` | `subdomains/supporting/support-issue/support-issue.controller.ts` | | POST | 1 | | `/support/issue` | public | whole rows | 493 | not yet | | `SupportIssueController.createIssue` | `subdomains/supporting/support-issue/support-issue.controller.ts` | -| GET | 1 | | `/support/issue/:id` | public | whole rows | 450 | not yet | | `SupportIssueController.getIssue` | `subdomains/supporting/support-issue/support-issue.controller.ts` | +| GET | 1 | | `/support/issue/:id` | public | projected | 11 | 4/4 | | `SupportIssueController.getIssue` | `subdomains/supporting/support-issue/support-issue.controller.ts` | | PUT | 1 | | `/support/issue/:id` | hidden | whole rows | 421 | not yet | | `SupportIssueController.updateSupportIssue` | `subdomains/supporting/support-issue/support-issue.controller.ts` | | PUT | 1 | | `/support/issue/:id/close` | public | whole rows | 450 | not yet | | `SupportIssueController.closeIssue` | `subdomains/supporting/support-issue/support-issue.controller.ts` | -| GET | 1 | | `/support/issue/:id/data` | hidden | whole rows | 952 | not yet | | `SupportIssueController.getIssueData` | `subdomains/supporting/support-issue/support-issue.controller.ts` | +| GET | 1 | | `/support/issue/:id/data` | hidden | projected | 81 | 4/4 | | `SupportIssueController.getIssueData` | `subdomains/supporting/support-issue/support-issue.controller.ts` | | POST | 1 | | `/support/issue/:id/message` | public | whole rows | 441 | not yet | yes | `SupportIssueController.createSupportMessage` | `subdomains/supporting/support-issue/support-issue.controller.ts` | | GET | 1 | | `/support/issue/:id/message/:messageId/file` | public | whole rows | — | not yet | | `SupportIssueController.getFile` | `subdomains/supporting/support-issue/support-issue.controller.ts` | -| GET | 1 | | `/support/issue/activity` | hidden | whole rows | 7 | not yet | | `SupportIssueController.getSupportIssueActivity` | `subdomains/supporting/support-issue/support-issue.controller.ts` | +| GET | 1 | | `/support/issue/activity` | hidden | projected | 2 | 0/4 | | `SupportIssueController.getSupportIssueActivity` | `subdomains/supporting/support-issue/support-issue.controller.ts` | | GET | 1 | | `/support/issue/clerk` | hidden | none | — | n/a | | `SupportIssueController.getSupportIssueClerk` | `subdomains/supporting/support-issue/support-issue.controller.ts` | | GET | 1 | | `/support/issue/clerks` | hidden | none | — | n/a | | `SupportIssueController.getSupportIssueClerks` | `subdomains/supporting/support-issue/support-issue.controller.ts` | -| GET | 1 | | `/support/issue/counts` | hidden | whole rows | 16 | not yet | | `SupportIssueController.getSupportIssueCounts` | `subdomains/supporting/support-issue/support-issue.controller.ts` | +| GET | 1 | | `/support/issue/counts` | hidden | projected | 2 | 0/4 | | `SupportIssueController.getSupportIssueCounts` | `subdomains/supporting/support-issue/support-issue.controller.ts` | | POST | 1 | | `/support/issue/escalation/telegram-bind` | hidden | whole rows | 5 | not yet | | `SupportIssueController.bindEscalationChat` | `subdomains/supporting/support-issue/support-issue.controller.ts` | | GET | 1 | | `/support/issue/escalation/telegram-chats` | hidden | none | — | n/a | | `SupportIssueController.getEscalationChats` | `subdomains/supporting/support-issue/support-issue.controller.ts` | | POST | 1 | | `/support/issue/escalation/telegram-test` | hidden | whole rows | 5 | not yet | | `SupportIssueController.testEscalationChat` | `subdomains/supporting/support-issue/support-issue.controller.ts` | -| GET | 1 | | `/support/issue/list` | hidden | whole rows | 16 | not yet | | `SupportIssueController.getSupportIssueList` | `subdomains/supporting/support-issue/support-issue.controller.ts` | -| GET | 1 | | `/support/issue/statistics` | hidden | whole rows | 16 | not yet | | `SupportIssueController.getSupportIssueStatistics` | `subdomains/supporting/support-issue/support-issue.controller.ts` | +| GET | 1 | | `/support/issue/list` | hidden | projected | 10 | 4/4 | | `SupportIssueController.getSupportIssueList` | `subdomains/supporting/support-issue/support-issue.controller.ts` | +| GET | 1 | | `/support/issue/statistics` | hidden | projected | 3 | 0/4 | | `SupportIssueController.getSupportIssueStatistics` | `subdomains/supporting/support-issue/support-issue.controller.ts` | | POST | 1 | | `/support/issue/support` | hidden | whole rows | 493 | not yet | | `SupportIssueController.createIssueBySupport` | `subdomains/supporting/support-issue/support-issue.controller.ts` | | GET | 1 | | `/support/kycFileList` | hidden | whole rows | 253 | not yet | | `SupportController.getKycFileList` | `subdomains/generic/support/support.controller.ts` | -| GET | 1 | | `/support/kycFileStats` | hidden | whole rows | 99 | not yet | | `SupportController.getKycFileStats` | `subdomains/generic/support/support.controller.ts` | +| GET | 1 | | `/support/kycFileStats` | hidden | projected | 1 | 0/4 | | `SupportController.getKycFileStats` | `subdomains/generic/support/support.controller.ts` | | GET | 1 | | `/support/note` | hidden | whole rows | 9 | not yet | | `SupportController.getNotes` | `subdomains/generic/support/support.controller.ts` | | POST | 1 | | `/support/note` | hidden | whole rows | 253 | not yet | | `SupportController.createNote` | `subdomains/generic/support/support.controller.ts` | | DELETE | 1 | | `/support/note/:id` | hidden | whole rows | 9 | not yet | | `SupportController.deleteNote` | `subdomains/generic/support/support.controller.ts` | | PUT | 1 | | `/support/note/:id` | hidden | whole rows | 239 | not yet | | `SupportController.updateNote` | `subdomains/generic/support/support.controller.ts` | -| GET | 1 | | `/support/note/users` | hidden | whole rows | 9 | not yet | | `SupportController.listNoteUsers` | `subdomains/generic/support/support.controller.ts` | -| GET | 1 | | `/support/pending-reviews` | hidden | whole rows | 15 | not yet | | `SupportController.getPendingReviews` | `subdomains/generic/support/support.controller.ts` | +| GET | 1 | | `/support/note/users` | hidden | projected | 5 | 0/4 | | `SupportController.listNoteUsers` | `subdomains/generic/support/support.controller.ts` | +| GET | 1 | | `/support/pending-reviews` | hidden | projected | 3 | 0/4 | | `SupportController.getPendingReviews` | `subdomains/generic/support/support.controller.ts` | | GET | 1 | | `/support/pending-reviews/items` | hidden | whole rows | 261 | not yet | | `SupportController.getPendingReviewItems` | `subdomains/generic/support/support.controller.ts` | -| GET | 1 | | `/support/pending-transactions` | hidden | whole rows | 673 | not yet | | `SupportController.getPendingTransactions` | `subdomains/generic/support/support.controller.ts` | +| GET | 1 | | `/support/pending-transactions` | hidden | whole rows | 669 | not yet | | `SupportController.getPendingTransactions` | `subdomains/generic/support/support.controller.ts` | | GET | 1 | | `/support/recommendation-graph/:id/neighbors` | hidden | whole rows | 474 | not yet | yes | `SupportController.getRecommendationGraphNeighbors` | `subdomains/generic/support/support.controller.ts` | | GET | 1 | | `/support/template` | hidden | whole rows | 8 | not yet | | `SupportController.getTemplates` | `subdomains/generic/support/support.controller.ts` | | POST | 1 | | `/support/template` | hidden | whole rows | 253 | not yet | | `SupportController.createTemplate` | `subdomains/generic/support/support.controller.ts` | | DELETE | 1 | | `/support/template/:id` | hidden | whole rows | 8 | not yet | | `SupportController.deleteTemplate` | `subdomains/generic/support/support.controller.ts` | | PUT | 1 | | `/support/template/:id` | hidden | whole rows | 8 | not yet | | `SupportController.updateTemplate` | `subdomains/generic/support/support.controller.ts` | | GET | 1 | | `/support/transaction/:id/refund` | hidden | whole rows | 143 | not yet | | `SupportController.getTransactionRefund` | `subdomains/generic/support/support.controller.ts` | -| PUT | 1 | | `/support/transaction/:id/refund` | hidden | whole rows | 415 | not yet | | `SupportController.setTransactionRefund` | `subdomains/generic/support/support.controller.ts` | +| PUT | 1 | | `/support/transaction/:id/refund` | hidden | whole rows | 411 | not yet | | `SupportController.setTransactionRefund` | `subdomains/generic/support/support.controller.ts` | | GET | 1 | | `/support/transactionList` | hidden | whole rows | 20 | not yet | | `SupportController.getTransactionList` | `subdomains/generic/support/support.controller.ts` | | GET | 1 | | `/swap` | hidden | whole rows | 308 | not yet | | `SwapController.getAllSwap` | `subdomains/core/buy-crypto/routes/swap/swap.controller.ts` | | POST | 1 | | `/swap` | hidden | whole rows | 308 | not yet | | `SwapController.createSwap` | `subdomains/core/buy-crypto/routes/swap/swap.controller.ts` | | GET | 1 | | `/swap/:id` | public | whole rows | 308 | not yet | | `SwapController.getSwap` | `subdomains/core/buy-crypto/routes/swap/swap.controller.ts` | | PUT | 1 | | `/swap/:id` | hidden | whole rows | 396 | not yet | | `SwapController.updateSwapRoute` | `subdomains/core/buy-crypto/routes/swap/swap.controller.ts` | -| GET | 1 | | `/swap/:id/history` | hidden | whole rows | 510 | not yet | | `SwapController.getSwapRouteHistory` | `subdomains/core/buy-crypto/routes/swap/swap.controller.ts` | +| GET | 1 | | `/swap/:id/history` | hidden | projected | 12 | 4/4 | | `SwapController.getSwapRouteHistory` | `subdomains/core/buy-crypto/routes/swap/swap.controller.ts` | | PUT | 1 | | `/swap/paymentInfos` | public | whole rows | 308 | not yet | | `SwapController.createSwapWithPaymentInfo` | `subdomains/core/buy-crypto/routes/swap/swap.controller.ts` | | PUT | 1 | | `/swap/paymentInfos/:id/confirm` | public | whole rows | 545 | not yet | | `SwapController.confirmSwap` | `subdomains/core/buy-crypto/routes/swap/swap.controller.ts` | -| GET | 1 | | `/swap/paymentInfos/:id/tx` | public | whole rows | 504 | not yet | | `SwapController.depositTx` | `subdomains/core/buy-crypto/routes/swap/swap.controller.ts` | +| GET | 1 | | `/swap/paymentInfos/:id/tx` | public | whole rows | 484 | not yet | | `SwapController.depositTx` | `subdomains/core/buy-crypto/routes/swap/swap.controller.ts` | | PUT | 1 | | `/swap/quote` | public | whole rows | 143 | not yet | | `SwapController.getSwapQuote` | `subdomains/core/buy-crypto/routes/swap/swap.controller.ts` | | POST | 1 | | `/tatum/addressWebhook` | hidden | none | — | n/a | | `TatumController.addressWebhook` | `integration/tatum/controllers/tatum.controller.ts` | | PUT | 1 | | `/trading/rule/:id` | hidden | whole rows | 87 | not yet | | `TradingRuleController.update` | `subdomains/core/trading/controllers/trading-rule.controller.ts` | -| GET | 1 | | `/transaction` | public | whole rows | 1367 | not yet | yes | `TransactionController.getTransactions` | `subdomains/core/history/controllers/transaction.controller.ts` | -| PUT | 1 | | `/transaction/:id/invoice` | public | whole rows | 331 | not yet | yes | `TransactionController.generateInvoiceFromTransaction` | `subdomains/core/history/controllers/transaction.controller.ts` | -| PUT | 1 | | `/transaction/:id/receipt` | public | whole rows | 331 | not yet | yes | `TransactionController.generateReceiptFromTransaction` | `subdomains/core/history/controllers/transaction.controller.ts` | -| GET | 1 | | `/transaction/:id/refund` | public | whole rows | 331 | not yet | yes | `TransactionController.getTransactionRefund` | `subdomains/core/history/controllers/transaction.controller.ts` | -| PUT | 1 | | `/transaction/:id/refund` | public | whole rows | 488 | not yet | | `TransactionController.setTransactionRefundTarget` | `subdomains/core/history/controllers/transaction.controller.ts` | +| GET | 1 | | `/transaction` | public | whole rows | 1363 | not yet | yes | `TransactionController.getTransactions` | `subdomains/core/history/controllers/transaction.controller.ts` | +| PUT | 1 | | `/transaction/:id/invoice` | public | whole rows | 327 | not yet | yes | `TransactionController.generateInvoiceFromTransaction` | `subdomains/core/history/controllers/transaction.controller.ts` | +| PUT | 1 | | `/transaction/:id/receipt` | public | whole rows | 327 | not yet | yes | `TransactionController.generateReceiptFromTransaction` | `subdomains/core/history/controllers/transaction.controller.ts` | +| GET | 1 | | `/transaction/:id/refund` | public | whole rows | 327 | not yet | yes | `TransactionController.getTransactionRefund` | `subdomains/core/history/controllers/transaction.controller.ts` | +| PUT | 1 | | `/transaction/:id/refund` | public | whole rows | 484 | not yet | | `TransactionController.setTransactionRefundTarget` | `subdomains/core/history/controllers/transaction.controller.ts` | | PUT | 1 | | `/transaction/:id/target` | hidden | whole rows | 1053 | not yet | | `TransactionController.setTransactionTarget` | `subdomains/core/history/controllers/transaction.controller.ts` | -| GET | 1 | | `/transaction/ChainReport` | hidden | whole rows | 1367 | not yet | yes | `TransactionController.getCsvChainReport` | `subdomains/core/history/controllers/transaction.controller.ts` | -| GET | 1 | | `/transaction/CoinTracking` | hidden | whole rows | 1367 | not yet | yes | `TransactionController.getCsvCT` | `subdomains/core/history/controllers/transaction.controller.ts` | +| GET | 1 | | `/transaction/ChainReport` | hidden | whole rows | 1363 | not yet | yes | `TransactionController.getCsvChainReport` | `subdomains/core/history/controllers/transaction.controller.ts` | +| GET | 1 | | `/transaction/CoinTracking` | hidden | whole rows | 1363 | not yet | yes | `TransactionController.getCsvCT` | `subdomains/core/history/controllers/transaction.controller.ts` | | PUT | 1 | | `/transaction/admin/:id` | hidden | whole rows | 276 | not yet | | `TransactionAdminController.updateTransaction` | `subdomains/supporting/payment/controllers/transaction-admin.controller.ts` | | POST | 1 | | `/transaction/admin/:id/resume` | hidden | whole rows | 98 | not yet | | `TransactionAdminController.resumeTransaction` | `subdomains/supporting/payment/controllers/transaction-admin.controller.ts` | | POST | 1 | | `/transaction/admin/:id/stop` | hidden | whole rows | 98 | not yet | | `TransactionAdminController.stopTransaction` | `subdomains/supporting/payment/controllers/transaction-admin.controller.ts` | | POST | 1 | | `/transaction/admin/:txId/riskAssessment` | hidden | none | — | n/a | | `TransactionAdminController.createRiskAssessment` | `subdomains/supporting/payment/controllers/transaction-admin.controller.ts` | | PUT | 1 | | `/transaction/admin/:txId/riskAssessment/:id` | hidden | whole rows | 13 | not yet | | `TransactionAdminController.updateRiskAssessment` | `subdomains/supporting/payment/controllers/transaction-admin.controller.ts` | | GET | 1 | | `/transaction/csv` | public | none | — | n/a | | `TransactionController.getCsv` | `subdomains/core/history/controllers/transaction.controller.ts` | -| PUT | 1 | | `/transaction/csv` | public | whole rows | 1367 | not yet | yes | `TransactionController.createCsv` | `subdomains/core/history/controllers/transaction.controller.ts` | -| GET | 1 | | `/transaction/detail` | public | whole rows | 1367 | not yet | | `TransactionController.getTransactionDetails` | `subdomains/core/history/controllers/transaction.controller.ts` | -| PUT | 1 | | `/transaction/detail/csv` | public | whole rows | 1367 | not yet | | `TransactionController.createDetailCsv` | `subdomains/core/history/controllers/transaction.controller.ts` | -| GET | 1 | | `/transaction/detail/single` | public | whole rows | 488 | not yet | yes | `TransactionController.getSingleTransactionDetails` | `subdomains/core/history/controllers/transaction.controller.ts` | -| GET | 1 | | `/transaction/single` | public | whole rows | 488 | not yet | yes | `TransactionController.getSingleTransaction` | `subdomains/core/history/controllers/transaction.controller.ts` | -| GET | 1 | | `/transaction/target` | hidden | whole rows | 134 | not yet | | `TransactionController.getTransactionTargets` | `subdomains/core/history/controllers/transaction.controller.ts` | +| PUT | 1 | | `/transaction/csv` | public | whole rows | 1363 | not yet | yes | `TransactionController.createCsv` | `subdomains/core/history/controllers/transaction.controller.ts` | +| GET | 1 | | `/transaction/detail` | public | whole rows | 1363 | not yet | | `TransactionController.getTransactionDetails` | `subdomains/core/history/controllers/transaction.controller.ts` | +| PUT | 1 | | `/transaction/detail/csv` | public | whole rows | 1363 | not yet | | `TransactionController.createDetailCsv` | `subdomains/core/history/controllers/transaction.controller.ts` | +| GET | 1 | | `/transaction/detail/single` | public | whole rows | 484 | not yet | yes | `TransactionController.getSingleTransactionDetails` | `subdomains/core/history/controllers/transaction.controller.ts` | +| GET | 1 | | `/transaction/single` | public | whole rows | 484 | not yet | yes | `TransactionController.getSingleTransaction` | `subdomains/core/history/controllers/transaction.controller.ts` | +| GET | 1 | | `/transaction/target` | hidden | whole rows | 130 | not yet | | `TransactionController.getTransactionTargets` | `subdomains/core/history/controllers/transaction.controller.ts` | | GET | 1 | | `/transaction/unassigned` | hidden | whole rows | 357 | not yet | | `TransactionController.getUnassignedTransactions` | `subdomains/core/history/controllers/transaction.controller.ts` | | DELETE | 1 | yes | `/user` | public | whole rows | 344 | not yet | | `UserController.deleteUser` | `subdomains/generic/user/models/user/user.controller.ts` | | DELETE | 2 | | `/user` | public | whole rows | 344 | not yet | | `UserV2Controller.deleteAccount` | `subdomains/generic/user/models/user/user.controller.ts` | | GET | 1 | yes | `/user` | public | whole rows | 328 | not yet | | `UserController.getUserV1` | `subdomains/generic/user/models/user/user.controller.ts` | -| GET | 2 | | `/user` | public | whole rows | 351 | not yet | | `UserV2Controller.getUser` | `subdomains/generic/user/models/user/user.controller.ts` | +| GET | 2 | | `/user` | public | projected | 66 | 4/4 | | `UserV2Controller.getUser` | `subdomains/generic/user/models/user/user.controller.ts` | | PUT | 1 | yes | `/user` | public | whole rows | 406 | not yet | | `UserController.updateUserV1` | `subdomains/generic/user/models/user/user.controller.ts` | | PUT | 2 | | `/user` | public | whole rows | 351 | not yet | | `UserV2Controller.updateUser` | `subdomains/generic/user/models/user/user.controller.ts` | | PUT | 1 | | `/user/:id` | hidden | whole rows | 364 | not yet | | `UserController.updateUserAdmin` | `subdomains/generic/user/models/user/user.controller.ts` | @@ -619,7 +614,7 @@ For 27 endpoints the call graph ends at a target chosen at runtime. Each was rea | PUT | 2 | | `/user/addresses/:address` | public | whole rows | 351 | not yet | | `UserV2Controller.updateAddress` | `subdomains/generic/user/models/user/user.controller.ts` | | PUT | 1 | | `/user/apiFilter/CT` | public | whole rows | 331 | not yet | | `UserController.updateApiFilter` | `subdomains/generic/user/models/user/user.controller.ts` | | DELETE | 1 | | `/user/apiKey/CT` | public | none | — | n/a | | `UserController.deleteApiKey` | `subdomains/generic/user/models/user/user.controller.ts` | -| POST | 1 | | `/user/apiKey/CT` | public | whole rows | 253 | not yet | | `UserController.createApiKey` | `subdomains/generic/user/models/user/user.controller.ts` | +| POST | 1 | | `/user/apiKey/CT` | public | projected | 3 | 4/4 | | `UserController.createApiKey` | `subdomains/generic/user/models/user/user.controller.ts` | | POST | 1 | | `/user/change` | public | whole rows | 643 | not yet | | `UserController.changeUser` | `subdomains/generic/user/models/user/user.controller.ts` | | POST | 1 | | `/user/data` | public | whole rows | 406 | not yet | | `UserController.updateKycData` | `subdomains/generic/user/models/user/user.controller.ts` | | GET | 1 | yes | `/user/detail` | public | whole rows | 328 | not yet | | `UserController.getUserDetailV1` | `subdomains/generic/user/models/user/user.controller.ts` | @@ -627,12 +622,12 @@ For 27 endpoints the call graph ends at a target chosen at runtime. Each was rea | PUT | 2 | | `/user/mail` | public | whole rows | 364 | not yet | | `UserV2Controller.updateUserMail` | `subdomains/generic/user/models/user/user.controller.ts` | | POST | 2 | | `/user/mail/verify` | public | whole rows | 364 | not yet | | `UserV2Controller.verifyMail` | `subdomains/generic/user/models/user/user.controller.ts` | | PUT | 1 | | `/user/name` | hidden | whole rows | 386 | not yet | | `UserController.updateUserName` | `subdomains/generic/user/models/user/user.controller.ts` | -| GET | 2 | | `/user/profile` | public | whole rows | 253 | not yet | | `UserV2Controller.getProfile` | `subdomains/generic/user/models/user/user.controller.ts` | -| GET | 1 | | `/user/ref` | hidden | whole rows | 45 | not yet | | `UserController.getRefInfo` | `subdomains/generic/user/models/user/user.controller.ts` | +| GET | 2 | | `/user/profile` | public | projected | 41 | 4/4 | | `UserV2Controller.getProfile` | `subdomains/generic/user/models/user/user.controller.ts` | +| GET | 1 | | `/user/ref` | hidden | projected | 1 | 0/4 | | `UserController.getRefInfo` | `subdomains/generic/user/models/user/user.controller.ts` | | GET | 2 | | `/user/ref` | public | whole rows | 98 | not yet | | `UserV2Controller.getRef` | `subdomains/generic/user/models/user/user.controller.ts` | | PUT | 2 | | `/user/ref` | public | whole rows | 98 | not yet | | `UserV2Controller.updateRefAsset` | `subdomains/generic/user/models/user/user.controller.ts` | | PUT | 1 | | `/user/specialCodes` | public | whole rows | 308 | not yet | | `UserController.addSpecialCode` | `subdomains/generic/user/models/user/user.controller.ts` | -| GET | 1 | | `/user/volumes` | hidden | whole rows | 45 | not yet | | `UserController.getVolumes` | `subdomains/generic/user/models/user/user.controller.ts` | +| GET | 1 | | `/user/volumes` | hidden | projected | 1 | 0/4 | | `UserController.getVolumes` | `subdomains/generic/user/models/user/user.controller.ts` | | GET | 1 | | `/userData` | hidden | whole rows | 253 | not yet | | `UserDataController.getAllUserData` | `subdomains/generic/user/models/user-data/user-data.controller.ts` | | POST | 1 | | `/userData` | hidden | whole rows | 253 | not yet | | `UserDataController.createEmptyUserData` | `subdomains/generic/user/models/user-data/user-data.controller.ts` | | GET | 1 | | `/userData/:id` | hidden | whole rows | 253 | not yet | | `UserDataController.getUserData` | `subdomains/generic/user/models/user-data/user-data.controller.ts` | @@ -643,7 +638,7 @@ For 27 endpoints the call graph ends at a target chosen at runtime. Each was rea | POST | 1 | | `/userData/:id/kycFile` | hidden | whole rows | 253 | not yet | | `UserDataController.uploadKycFile` | `subdomains/generic/user/models/user-data/user-data.controller.ts` | | PUT | 1 | | `/userData/:id/kycStatus/check` | hidden | whole rows | 364 | not yet | | `UserDataController.setKycStatusCheck` | `subdomains/generic/user/models/user-data/user-data.controller.ts` | | PUT | 1 | | `/userData/:id/merge` | hidden | whole rows | 364 | not yet | | `UserDataController.mergeUserData` | `subdomains/generic/user/models/user-data/user-data.controller.ts` | -| PUT | 1 | | `/userData/:id/volumes` | hidden | whole rows | — | not yet | | `UserDataController.updateVolumes` | `subdomains/generic/user/models/user-data/user-data.controller.ts` | +| PUT | 1 | | `/userData/:id/volumes` | hidden | projected | 9 | 0/4 | | `UserDataController.updateVolumes` | `subdomains/generic/user/models/user-data/user-data.controller.ts` | | PUT | 1 | | `/userData/auditPeriodNumbers` | hidden | whole rows | 40 | not yet | | `UserDataController.calculateAuditPeriodNumbers` | `subdomains/generic/user/models/user-data/user-data.controller.ts` | | POST | 1 | | `/userData/download` | hidden | whole rows | 253 | not yet | | `UserDataController.downloadUserData` | `subdomains/generic/user/models/user-data/user-data.controller.ts` | | POST | 1 | | `/userDataRelation` | public | whole rows | 253 | not yet | | `UserDataRelationController.create` | `subdomains/generic/user/models/user-data-relation/user-data-relation.controller.ts` | diff --git a/docs/load-sites.md b/docs/load-sites.md index 50fef32804..e111b2f78b 100644 --- a/docs/load-sites.md +++ b/docs/load-sites.md @@ -1,6 +1,6 @@ # Database load sites -Every place in the code that reads from the database: **at most 1158 load sites** across 243 files — an upper bound, for the reason given under *Measurements*. +Every place in the code that reads from the database: **at most 1158 load sites** across 251 files — an upper bound, for the reason given under *Measurements*. This is the level at which the statement is unambiguous. An endpoint reaches several load sites — a permission check, a lookup, the actual query — so asking whether *an endpoint* loads efficiently has no single answer. Asking it of a load site does. [endpoints.md](endpoints.md) carries the per-endpoint summary derived from these sites. @@ -8,37 +8,40 @@ This is the level at which the statement is unambiguous. An endpoint reaches sev | Mechanism | Sites | Eager relations | Columns selected | | --------- | ----: | --------------- | ---------------- | -| `find` family | 1021 | **applied** — expanded recursively | all root columns by default; a `select` in the find options narrows the root, but the eager relations stay | -| `createQueryBuilder` | 130 | not applied | all columns of the root entity, unless `.select([...])` narrows it | +| `find` family | 1007 | **applied** — expanded recursively | all columns of the entity plus every eager relation | +| `createQueryBuilder` | 144 | not applied | all columns of the root entity, unless `.select([...])` narrows it | | raw SQL | 7 | not applied | whatever the statement lists | -Statements that load nothing are excluded from the count: 2 `createQueryBuilder` calls carrying `.update()`, 6 advisory locks (`SELECT pg_advisory_xact_lock(...)`, which return no rows) and 4 raw `INSERT`. Each of the 7 raw reads that remain names its columns — together with the one query builder below, raw SQL is the only place in this repository where a read states which columns it wants. +Statements that load nothing are excluded from the count: 2 `createQueryBuilder` calls carrying `.update()`, 6 advisory locks (`SELECT pg_advisory_xact_lock(...)`, which return no rows) and 4 raw `INSERT`. Each of the 7 raw reads that remain names its columns. Among the query builders, the field list is what decides whether anything is actually saved: | | Sites | | --- | ---: | -| `.select([...])` — an explicit field list | **1** | -| `.select('alias')` — selects the root alias, **loads every column** | 105 | -| no `select` at all — loads every column | 24 | +| `.select([...])` or `PROJECTION.apply(...)` — an explicit field list | **18** | +| `.select('alias.column')` — names columns one by one | **90** | +| `.select('alias')` — selects the root alias, **loads every column** | 17 | +| no `select` at all — loads every column | 15 | +| `getCount()` or `getExists()` — the select list is discarded, **no row is materialised** | 3 | +| projects, but a `leftJoinAndSelect` loads a relation whole | 1 | -`.select('alias')` is the trap: it reads like a projection but the argument is the entity alias, not a field list. Such a query still loads every column of the root entity — it merely avoids the eager relations. +`.select('alias')` is the trap: it reads like a projection but the argument is the entity alias, not a field list. Such a query still loads every column of the root entity — it merely avoids the eager relations. `.select('alias.column')` is the opposite case and easy to lump in with it: it names a column and does narrow the query. The rule is the one stated in [read-path-projections.md](read-path-projections.md): a bare identifier is the root alias and loads everything, anything else — a column or an expression such as `COUNT(*)` — narrows the query. It matters — the sites that name columns this way select 1.5 columns at the median, and 45 of the 90 select a single one, against 1,007 `find` calls that select every column there is. Most of them are counts, maxima and id lookups rather than response payloads, which is why the endpoint summary still reads the way it does. ## Measurements -Columns were measured against the real entity metadata by building the query and counting its SELECT list — 788 of 1158 sites. +Columns were measured against the real entity metadata by building the query and counting its SELECT list — 796 of 1158 sites. -- **351 are exact**: the `relations` tree is written at the call site. -- **437 are lower bounds**: the tree arrives as a parameter, so only the base query is visible here. `transaction.service.ts` is the clearest case — its callers pass trees reaching well over a thousand columns. -- 370 could not be measured: no resolvable target entity, or raw SQL. +- **341 are exact**: the `relations` tree is written at the call site. +- **455 are lower bounds**: the tree arrives as a parameter, so only the base query is visible here. `transaction.service.ts` is the clearest case — its callers pass trees reaching well over a thousand columns. +- 362 could not be measured: no resolvable target entity, or raw SQL. **That last group is also why the total is an upper bound.** The collection matches `find` by name, and `find` on a repository is indistinguishable by name from `find` on an array. Where the target entity resolved, the distinction is settled; where it did not, the group holds both. A sample of 30 of those rows, read in the source, came out at 21 array operations to 9 genuine repository reads. That group holds 343 rows, so on the order of 240 of them are not database reads at all, and the true count is nearer 900. -What that does and does not affect: the median and the counts above are computed only over the 788 rows that carry a measured width, and an array operation never has one, so those figures stand. Nor does it move the conclusion this table exists for: the sites that name their columns are a small fraction either way, and the verdict reads the same against 900 as against 1158. It does reach the per-endpoint summary in [endpoints.md](endpoints.md): an endpoint could be listed as fetching whole rows on the strength of such a row alone. The three with no measured width at all are the exposed cases and are named there. For the rest a measured query stands behind the entry, which limits the effect without excluding it — establishing that would need the collection to separate the two kinds of `find`, which is the fix this note stands in for. +What that does and does not affect: the median and the counts below are computed only over the rows that carry a measured width, and an array operation never has one, so those figures stand. Nor does it move the conclusion this table exists for — the sites that name their columns are a small fraction either way. It does reach the per-endpoint summary in [endpoints.md](endpoints.md), where the endpoints with no measured width at all are marked as the exposed cases. -Median across measured sites: **118 columns**. At least 14 sites exceed 1000, 78 exceed 500 and 412 exceed 100 — "at least", because 437 of these measurements are lower bounds, and a resolved relation tree can only widen a query, never narrow it. +Median across measured sites: **98 columns**. At least 14 sites exceed 1000, 72 exceed 500 and 392 exceed 100 — "at least", because 455 of these measurements are lower bounds, and a resolved relation tree can only widen a query, never narrow it. -Postgres refuses a statement with more than 1664 columns. The widest measured site here sits at 1453, so a little over two hundred columns separate it from a rejected statement — and 437 of these measurements are lower bounds, so the real margin can be smaller. +Postgres refuses a statement with more than 1664 columns. The widest measured site here sits at 1453, so a little over two hundred columns separate it from a rejected statement — and 455 of these measurements are lower bounds, so the real margin can be smaller. ## Load sites @@ -47,47 +50,46 @@ Sorted by measured columns, largest first. `—` means not measurable, not zero. | Columns | Joins | Mechanism | Entity | Location | Method | | ------: | ----: | --------- | ------ | -------- | ------ | | 1453 | 49 | find | `FiatOutput` | `subdomains/supporting/fiat-output/fiat-output-job.service.ts:229` | `FiatOutputJobService.setReadyDate` | -| 1363 | 47 | find | `Transaction` | `subdomains/supporting/payment/services/transaction.service.ts:368` | `TransactionService.getTransactionsForAccount` | +| 1359 | 46 | find | `Transaction` | `subdomains/supporting/payment/services/transaction.service.ts:368` | `TransactionService.getTransactionsForAccount` | | 1282 | 50 | find | `BuyCrypto` | `subdomains/core/buy-crypto/process/services/buy-crypto-preparation.service.ts:565` | `BuyCryptoPreparationService.fillPaymentLinkPayments` | | 1231 | 38 | find | `FiatOutput` | `subdomains/supporting/fiat-output/fiat-output-job.service.ts:174` | `FiatOutputJobService.assignBankAccount` | -| 1162 | 44 | find | `BuyCrypto` | `subdomains/core/buy-crypto/process/services/buy-crypto-batch.service.ts:68` | `BuyCryptoBatchService.batchAndOptimizeTransactions` | -| 1139 | 41 | find | `BuyCrypto` | `subdomains/core/buy-crypto/process/services/buy-crypto-preparation.service.ts:422` | `BuyCryptoPreparationService.In` | -| 1092 | 41 | find | `Transaction` | `subdomains/supporting/payment/services/transaction.service.ts:400` | `TransactionService.getTransactionsForUsers` | -| 1090 | 40 | find | `BuyCrypto` | `subdomains/core/buy-crypto/process/services/buy-crypto.service.ts:309` | `BuyCryptoService.update` | -| 1063 | 42 | find | `BuyCrypto` | `subdomains/core/buy-crypto/process/services/buy-crypto-preparation.service.ts:120` | `BuyCryptoPreparationService.doAmlCheck` | +| 1158 | 43 | find | `BuyCrypto` | `subdomains/core/buy-crypto/process/services/buy-crypto-batch.service.ts:68` | `BuyCryptoBatchService.batchAndOptimizeTransactions` | +| 1135 | 40 | find | `BuyCrypto` | `subdomains/core/buy-crypto/process/services/buy-crypto-preparation.service.ts:422` | `BuyCryptoPreparationService.In` | +| 1088 | 40 | find | `Transaction` | `subdomains/supporting/payment/services/transaction.service.ts:400` | `TransactionService.getTransactionsForUsers` | +| 1086 | 39 | find | `BuyCrypto` | `subdomains/core/buy-crypto/process/services/buy-crypto.service.ts:309` | `BuyCryptoService.update` | +| 1059 | 41 | find | `BuyCrypto` | `subdomains/core/buy-crypto/process/services/buy-crypto-preparation.service.ts:120` | `BuyCryptoPreparationService.doAmlCheck` | | 1051 | 36 | find | `BuyCrypto` | `subdomains/core/buy-crypto/process/services/buy-crypto.service.ts:624` | `BuyCryptoService.refundBuyCrypto` | | 1051 | 32 | find | `BankTx` | `subdomains/supporting/bank-tx/bank-tx/services/bank-tx.service.ts:402` | `BankTxService.update` | | 1051 | 32 | find | `BankTx` | `subdomains/supporting/bank-tx/bank-tx/services/bank-tx.service.ts:385` | `BankTxService.create` | -| 1033 | 40 | find | `BuyFiat` | `subdomains/core/sell-crypto/process/services/buy-fiat.service.ts:181` | `BuyFiatService.update` | +| 1033 | 40 | find | `BuyFiat` | `subdomains/core/sell-crypto/process/services/buy-fiat.service.ts:180` | `BuyFiatService.update` | | 1003 | 36 | find | `BuyFiat` | `subdomains/core/sell-crypto/process/services/buy-fiat-preparation.service.ts:534` | `BuyFiatPreparationService.addFiatOutputs` | -| 951 | 33 | find | `SupportIssue` | `subdomains/supporting/support-issue/services/support-issue.service.ts:685` | `SupportIssueService.getIssueData` | -| 907 | 31 | find | `BuyCrypto` | `subdomains/core/buy-crypto/process/services/buy-crypto.service.ts:1575` | `BuyCryptoService.getAllUserTransactions` | +| 903 | 30 | find | `BuyCrypto` | `subdomains/core/buy-crypto/process/services/buy-crypto.service.ts:1549` | `BuyCryptoService.getAllUserTransactions` | | 891 | 37 | find | `BuyFiat` | `subdomains/core/sell-crypto/process/services/buy-fiat-preparation.service.ts:340` | `BuyFiatPreparationService.fillPaymentLinkPayments` | -| 885 | 33 | find | `BuyCryptoBatch` | `subdomains/core/buy-crypto/process/services/buy-crypto-out.service.ts:133` | `BuyCryptoOutService.fetchBatchesForPayout` | +| 881 | 32 | find | `BuyCryptoBatch` | `subdomains/core/buy-crypto/process/services/buy-crypto-out.service.ts:133` | `BuyCryptoOutService.fetchBatchesForPayout` | | 880 | 26 | find | `BuyCrypto` | `subdomains/core/buy-crypto/process/services/buy-crypto-preparation.service.ts:811` | `BuyCryptoPreparationService.chargebackFillUp` | | 844 | 26 | find | `BuyCrypto` | `subdomains/core/buy-crypto/process/services/buy-crypto-webhook.service.ts:17` | `BuyCryptoWebhookService.triggerWebhookManual` | | 826 | 27 | find | `Transaction` | `subdomains/supporting/payment/services/transaction.service.ts:282` | `TransactionService.getTransactionsWithoutUid` | | 826 | 27 | find | `Transaction` | `subdomains/supporting/payment/services/transaction.service.ts:291` | `TransactionService.getTransactionsByUserDataId` | -| 815 | 28 | find | `BuyCrypto` | `subdomains/core/buy-crypto/process/services/buy-crypto.service.ts:1306` | `BuyCryptoService.getRefTransactions` | -| 815 | 28 | find | `BuyCrypto` | `subdomains/core/buy-crypto/process/services/buy-crypto.service.ts:1557` | `BuyCryptoService.getAllRefTransactions` | | 813 | 29 | find | `BuyFiat` | `subdomains/core/sell-crypto/process/services/buy-fiat-preparation.service.ts:255` | `BuyFiatPreparationService.refreshFee` | -| 803 | 29 | find | `BuyFiat` | `subdomains/core/sell-crypto/process/services/buy-fiat.service.ts:401` | `BuyFiatService.refundBuyFiat` | -| 794 | 27 | find | `Transaction` | `subdomains/supporting/payment/services/transaction-notification.service.ts:37` | `TransactionNotificationService.txAssigned` | +| 811 | 27 | find | `BuyCrypto` | `subdomains/core/buy-crypto/process/services/buy-crypto.service.ts:1306` | `BuyCryptoService.getRefTransactions` | +| 811 | 27 | find | `BuyCrypto` | `subdomains/core/buy-crypto/process/services/buy-crypto.service.ts:1531` | `BuyCryptoService.getAllRefTransactions` | +| 803 | 29 | find | `BuyFiat` | `subdomains/core/sell-crypto/process/services/buy-fiat.service.ts:400` | `BuyFiatService.refundBuyFiat` | +| 790 | 26 | find | `Transaction` | `subdomains/supporting/payment/services/transaction-notification.service.ts:37` | `TransactionNotificationService.txAssigned` | | 785 | 24 | find | `BuyCrypto` | `subdomains/core/buy-crypto/process/services/buy-crypto-notification.service.ts:269` | `BuyCryptoNotificationService.chargebackInitiated` | | 765 | 23 | find | `BuyCrypto` | `subdomains/core/buy-crypto/process/services/buy-crypto-preparation.service.ts:760` | `BuyCryptoPreparationService.chargebackTx` | | 737 | 30 | find | `BuyFiat` | `subdomains/core/sell-crypto/process/services/buy-fiat-preparation.service.ts:107` | `BuyFiatPreparationService.doAmlCheck` | | 727 | 23 | find | `BankTxReturn` | `subdomains/supporting/bank-tx/bank-tx-return/bank-tx-return.service.ts:176` | `BankTxReturnService.refundBankTxReturn` | -| 717 | 27 | find | `BuyCrypto` | `subdomains/core/buy-crypto/process/services/buy-crypto.service.ts:1149` | `BuyCryptoService.retriggerScorechain` | +| 713 | 26 | find | `BuyCrypto` | `subdomains/core/buy-crypto/process/services/buy-crypto.service.ts:1149` | `BuyCryptoService.retriggerScorechain` | | 703 | 24 | find | `BuyFiat` | `subdomains/core/sell-crypto/process/services/buy-fiat-preparation.service.ts:491` | `BuyFiatPreparationService.complete` | -| 672 | 23 | find | `BuyCrypto` | `subdomains/core/buy-crypto/process/services/buy-crypto.service.ts:1613` | `BuyCryptoService.getByAmlReason` | -| 644 | 22 | find | `BuyFiat` | `subdomains/core/sell-crypto/process/services/buy-fiat.service.ts:375` | `BuyFiatService.getBuyFiat` | -| 644 | 22 | find | `BuyFiat` | `subdomains/core/sell-crypto/process/services/buy-fiat.service.ts:379` | `BuyFiatService.triggerWebhookManual` | +| 668 | 22 | find | `BuyCrypto` | `subdomains/core/buy-crypto/process/services/buy-crypto.service.ts:1587` | `BuyCryptoService.getByAmlReason` | +| 644 | 22 | find | `BuyFiat` | `subdomains/core/sell-crypto/process/services/buy-fiat.service.ts:374` | `BuyFiatService.getBuyFiat` | +| 644 | 22 | find | `BuyFiat` | `subdomains/core/sell-crypto/process/services/buy-fiat.service.ts:378` | `BuyFiatService.triggerWebhookManual` | | 643 | 21 | find | `Recommendation` | `subdomains/generic/user/models/recommendation/recommendation.service.ts:196` | `RecommendationService.confirmRecommendation` | | 643 | 21 | find | `Recommendation` | `subdomains/generic/user/models/recommendation/recommendation.service.ts:255` | `RecommendationService.getAndCheckRecommendationByCode` | | 643 | 21 | find | `Recommendation` | `subdomains/generic/user/models/recommendation/recommendation.service.ts:294` | `RecommendationService.checkAndConfirmRecommendInvitation` | | 639 | 24 | find | `FiatOutput` | `subdomains/supporting/fiat-output/fiat-output-job.service.ts:108` | `FiatOutputJobService.generateReports` | | 630 | 20 | find | `Recommendation` | `subdomains/generic/user/models/recommendation/recommendation.service.ts:284` | `RecommendationService.getUserDataRecommendation` | -| 623 | 20 | find | `BuyFiat` | `subdomains/core/sell-crypto/process/services/buy-fiat.service.ts:620` | `BuyFiatService.getAllUserTransactions` | +| 623 | 20 | find | `BuyFiat` | `subdomains/core/sell-crypto/process/services/buy-fiat.service.ts:619` | `BuyFiatService.getAllUserTransactions` | | 613 | 19 | find | `BuyCrypto` | `subdomains/core/buy-crypto/process/services/buy-crypto-notification.service.ts:82` | `BuyCryptoNotificationService.paymentCompleted` | | 613 | 19 | find | `BuyCrypto` | `subdomains/core/buy-crypto/process/services/buy-crypto-notification.service.ts:177` | `BuyCryptoNotificationService.pendingBuyCrypto` | | 613 | 19 | find | `BuyCrypto` | `subdomains/core/buy-crypto/process/services/buy-crypto-notification.service.ts:352` | `BuyCryptoNotificationService.chargebackUnconfirmed` | @@ -98,65 +100,57 @@ Sorted by measured columns, largest first. `—` means not measurable, not zero. | 567 | 17 | find | `BuyCrypto` | `subdomains/core/accounting/services/consumers/buy-crypto.consumer.ts:114` | `BuyCryptoConsumer.processForward` | | 558 | 24 | find | `PaymentQuote` | `subdomains/core/payment-link/services/payment-quote.service.ts:159` | `PaymentQuoteService.getEarliestQuoteClaimingTx` | | 558 | 24 | find | `PaymentQuote` | `subdomains/core/payment-link/services/payment-quote.service.ts:170` | `PaymentQuoteService.getConfirmingQuotes` | -| 545 | 23 | find | `PaymentLink` | `subdomains/core/payment-link/repositories/payment-link.repository.ts:41` | `PaymentLinkRepository.getHistoryByStatus` | +| 545 | 23 | find | `PaymentLink` | `subdomains/core/payment-link/repositories/payment-link.repository.ts:92` | `PaymentLinkRepository.getHistoryByStatus` | | 545 | 23 | find | `PaymentLinkPayment` | `subdomains/core/payment-link/services/payment-link-payment.service.ts:246` | `PaymentLinkPaymentService.updatePayment` | | 545 | 23 | find | `PaymentLinkPayment` | `subdomains/core/payment-link/services/payment-link-payment.service.ts:253` | `PaymentLinkPaymentService.getPendingPaymentByUniqueId` | | 545 | 23 | find | `PaymentLinkPayment` | `subdomains/core/payment-link/services/payment-link-payment.service.ts:730` | `PaymentLinkPaymentService.handleBlockchainConfirmed` | | 545 | 23 | find | `PaymentLinkPayment` | `subdomains/core/payment-link/services/payment-link-payment.service.ts:840` | `PaymentLinkPaymentService.sendWebhook` | -| 540 | 17 | find | `BuyFiat` | `subdomains/core/sell-crypto/process/services/buy-fiat.service.ts:784` | `BuyFiatService.getByAmlReason` | +| 540 | 17 | find | `BuyFiat` | `subdomains/core/sell-crypto/process/services/buy-fiat.service.ts:763` | `BuyFiatService.getByAmlReason` | | 538 | 21 | find | `CryptoInput` | `subdomains/core/accounting/services/consumers/crypto-input.consumer.ts:89` | `CryptoInputConsumer.processForward` | -| 535 | 15 | find | `BuyCrypto` | `subdomains/core/buy-crypto/process/services/buy-crypto.service.ts:1339` | `BuyCryptoService.getPendingTransactions` | -| 525 | 16 | find | `CustodyOrder` | `subdomains/core/custody/services/custody-order.service.ts:300` | `CustodyOrderService.confirmOrder` | -| 525 | 16 | find | `CustodyOrder` | `subdomains/core/custody/services/custody-order.service.ts:316` | `CustodyOrderService.getOrdersForSupport` | +| 535 | 15 | find | `BuyCrypto` | `subdomains/core/buy-crypto/process/services/buy-crypto.service.ts:1331` | `BuyCryptoService.getPendingTransactions` | | 517 | 16 | find | `BuyFiat` | `subdomains/core/sell-crypto/process/services/buy-fiat-preparation.service.ts:615` | `BuyFiatPreparationService.chargebackTx` | -| 517 | 16 | find | `BuyFiat` | `subdomains/core/sell-crypto/process/services/buy-fiat.service.ts:487` | `BuyFiatService.retriggerScorechain` | -| 513 | 21 | find | `PaymentLink` | `subdomains/core/payment-link/repositories/payment-link.repository.ts:14` | `PaymentLinkRepository.getAllPaymentLinks` | -| 513 | 21 | find | `PaymentLink` | `subdomains/core/payment-link/repositories/payment-link.repository.ts:21` | `PaymentLinkRepository.getAllPaymentLinksByExternalLinkId` | -| 513 | 21 | find | `PaymentLink` | `subdomains/core/payment-link/repositories/payment-link.repository.ts:28` | `PaymentLinkRepository.getAllPaymentLinksByExternalPaymentId` | -| 513 | 21 | find | `PaymentLink` | `subdomains/core/payment-link/repositories/payment-link.repository.ts:65` | `PaymentLinkRepository.getPaymentLinkByLinkId` | -| 513 | 21 | find | `PaymentLink` | `subdomains/core/payment-link/repositories/payment-link.repository.ts:72` | `PaymentLinkRepository.getPaymentLinkByExternalId` | -| 513 | 21 | find | `PaymentLink` | `subdomains/core/payment-link/repositories/payment-link.repository.ts:82` | `PaymentLinkRepository.getPaymentLinkByExternalPaymentId` | +| 517 | 16 | find | `BuyFiat` | `subdomains/core/sell-crypto/process/services/buy-fiat.service.ts:486` | `BuyFiatService.retriggerScorechain` | +| 513 | 21 | find | `PaymentLink` | `subdomains/core/payment-link/repositories/payment-link.repository.ts:65` | `PaymentLinkRepository.getAllPaymentLinks` | +| 513 | 21 | find | `PaymentLink` | `subdomains/core/payment-link/repositories/payment-link.repository.ts:72` | `PaymentLinkRepository.getAllPaymentLinks` | +| 513 | 21 | find | `PaymentLink` | `subdomains/core/payment-link/repositories/payment-link.repository.ts:79` | `PaymentLinkRepository.getAllPaymentLinksByExternalLinkId` | +| 513 | 21 | find | `PaymentLink` | `subdomains/core/payment-link/repositories/payment-link.repository.ts:116` | `PaymentLinkRepository.getPaymentLinkByLinkId` | +| 513 | 21 | find | `PaymentLink` | `subdomains/core/payment-link/repositories/payment-link.repository.ts:123` | `PaymentLinkRepository.getPaymentLinkByLinkId` | +| 513 | 21 | find | `PaymentLink` | `subdomains/core/payment-link/repositories/payment-link.repository.ts:133` | `PaymentLinkRepository.getPaymentLinkByExternalPaymentId` | | 513 | 21 | find | `PaymentLink` | `subdomains/core/payment-link/services/payment-link.service.ts:430` | `PaymentLinkService.updatePaymentLinkAdmin` | | 513 | 21 | find | `PaymentLink` | `subdomains/core/payment-link/services/payment-link.service.ts:485` | `PaymentLinkService.getActivePaymentLink` | | 513 | 21 | find | `PaymentLink` | `subdomains/core/payment-link/services/payment-link.service.ts:504` | `PaymentLinkService.assignPaymentLink` | | 513 | 21 | find | `PaymentLink` | `subdomains/core/payment-link/services/payment-link.service.ts:520` | `PaymentLinkService.getLocations` | -| 513 | 21 | find | `PaymentLink` | `subdomains/core/payment-link/services/payment-link.service.ts:702` | `PaymentLinkService.createPosLinkAdmin` | -| 509 | 17 | find | `BuyCrypto` | `subdomains/core/buy-crypto/process/services/buy-crypto.service.ts:1331` | `BuyCryptoService.getCryptoHistory` | | 507 | 18 | find | `KycStep` | `subdomains/generic/kyc/services/kyc.service.ts:360` | `KycService.reviewRecommendationStep` | -| 504 | 16 | find | `TransactionRequest` | `subdomains/supporting/payment/services/transaction-request.service.ts:224` | `TransactionRequestService.getOrThrow` | | 499 | 14 | find | `BankTxReturn` | `subdomains/supporting/bank-tx/bank-tx-return/bank-tx-return.service.ts:169` | `BankTxReturnService.getPendingTx` | -| 497 | 16 | find | `BuyCrypto` | `subdomains/core/buy-crypto/process/services/buy-crypto.service.ts:1321` | `BuyCryptoService.getBuyHistory` | | 497 | 15 | find | `BankTxReturn` | `subdomains/supporting/bank-tx/bank-tx-return/bank-tx-return.service.ts:86` | `BankTxReturnService.setFiatAmounts` | | 497 | 18 | find | `BuyFiat` | `subdomains/core/sell-crypto/process/services/buy-fiat-notification.service.ts:128` | `BuyFiatNotificationService.pendingBuyFiat` | | 493 | 19 | find | `SupportIssue` | `subdomains/supporting/support-issue/services/support-issue.service.ts:366` | `SupportIssueService.createIssueInternal` | | 490 | 20 | find | `PaymentLink` | `subdomains/core/payment-link/services/payment-link.service.ts:587` | `PaymentLinkService.getPublicPaymentLinkByUniqueId` | -| 490 | 15 | find | `BuyFiat` | `subdomains/core/sell-crypto/process/services/buy-fiat.service.ts:474` | `BuyFiatService.resetAmlCheck` | -| 487 | 19 | find | `BuyCrypto` | `subdomains/core/buy-crypto/process/services/buy-crypto.service.ts:958` | `BuyCryptoService.getBuyCryptoByTransactionId` | -| 487 | 19 | find | `BuyCrypto` | `subdomains/core/buy-crypto/process/services/buy-crypto.service.ts:962` | `BuyCryptoService.getBuyCrypto` | -| 487 | 19 | find | `BuyCrypto` | `subdomains/core/buy-crypto/process/services/buy-crypto.service.ts:966` | `BuyCryptoService.updateVolumes` | +| 490 | 15 | find | `BuyFiat` | `subdomains/core/sell-crypto/process/services/buy-fiat.service.ts:473` | `BuyFiatService.resetAmlCheck` | | 484 | 15 | find | `RefReward` | `subdomains/core/referral/reward/services/ref-reward-notification.service.ts:27` | `RefRewardNotificationService.refRewardPayouts` | +| 484 | 15 | find | `TransactionRequest` | `subdomains/supporting/payment/services/transaction-request.service.ts:224` | `TransactionRequestService.getOrThrow` | +| 483 | 18 | find | `BuyCrypto` | `subdomains/core/buy-crypto/process/services/buy-crypto.service.ts:958` | `BuyCryptoService.getBuyCryptoByTransactionId` | +| 483 | 18 | find | `BuyCrypto` | `subdomains/core/buy-crypto/process/services/buy-crypto.service.ts:962` | `BuyCryptoService.getBuyCrypto` | +| 483 | 18 | find | `BuyCrypto` | `subdomains/core/buy-crypto/process/services/buy-crypto.service.ts:966` | `BuyCryptoService.updateVolumes` | | 474 | 14 | find | `KycStep` | `subdomains/generic/kyc/services/kyc.service.ts:200` | `KycService.reviewIdentSteps` | | 474 | 16 | find | `Recommendation` | `subdomains/generic/user/models/recommendation/recommendation.service.ts:274` | `RecommendationService.getAllRecommendationForUserData` | | 474 | 16 | find | `Recommendation` | `subdomains/generic/user/models/recommendation/recommendation.service.ts:318` | `RecommendationService.getRecommendationsByKycStepIdsOrUserDataId` | | 474 | 16 | find | `Recommendation` | `subdomains/generic/user/models/recommendation/recommendation.service.ts:326` | `RecommendationService.getRecommendationsByKycStepIdsOrUserDataId` | | 474 | 16 | find | `Recommendation` | `subdomains/generic/user/models/recommendation/recommendation.service.ts:336` | `RecommendationService.getAllRecommendationsByRecommenderId` | | 474 | 16 | find | `Recommendation` | `subdomains/generic/user/models/recommendation/recommendation.service.ts:344` | `RecommendationService.getRecommendationsByRecommendedId` | -| 473 | 18 | find | `SupportIssue` | `subdomains/supporting/support-issue/services/support-issue.service.ts:652` | `SupportIssueService.getIssueEntities` | +| 473 | 18 | find | `SupportIssue` | `subdomains/supporting/support-issue/services/support-issue.service.ts:578` | `SupportIssueService.getIssueEntities` | | 472 | 14 | find | `BuyFiat` | `subdomains/core/accounting/services/consumers/buy-fiat.consumer.ts:122` | `BuyFiatConsumer.processForward` | | 472 | 19 | find | `DepositRoute` | `subdomains/supporting/address-pool/route/deposit-route.service.ts:15` | `DepositRouteService.get` | | 472 | 19 | find | `DepositRoute` | `subdomains/supporting/address-pool/route/deposit-route.service.ts:25` | `DepositRouteService.getById` | | 472 | 19 | find | `DepositRoute` | `subdomains/supporting/address-pool/route/deposit-route.service.ts:29` | `DepositRouteService.getLatest` | | 472 | 19 | find | `DepositRoute` | `subdomains/supporting/address-pool/route/deposit-route.service.ts:75` | `DepositRouteService.getPaymentRoutesForPublicName` | -| 470 | 16 | find | `BuyFiat` | `subdomains/core/sell-crypto/process/services/buy-fiat.service.ts:637` | `BuyFiatService.getSellHistory` | | 470 | 16 | find | `AccountMerge` | `subdomains/generic/user/models/account-merge/account-merge.service.ts:119` | `AccountMergeService.executeMerge` | | 458 | 14 | find | `BankTxReturn` | `subdomains/supporting/bank-tx/bank-tx-return/bank-tx-return-notification.service.ts:31` | `BankTxReturnNotificationService.chargebackInitiated` | | 454 | 16 | find | `LimitRequest` | `subdomains/supporting/support-issue/services/limit-request-notification.service.ts:35` | `LimitRequestNotificationService.limitRequestAcceptedManual` | | 451 | 14 | find | `BuyFiat` | `subdomains/core/sell-crypto/process/services/buy-fiat-notification.service.ts:42` | `BuyFiatNotificationService.paymentCompleted` | | 451 | 14 | find | `BuyFiat` | `subdomains/core/sell-crypto/process/services/buy-fiat-notification.service.ts:290` | `BuyFiatNotificationService.chargebackUnconfirmed` | | 450 | 16 | find | `SupportIssue` | `subdomains/supporting/support-issue/services/support-issue.service.ts:457` | `SupportIssueService.closeIssue` | -| 450 | 16 | find | `SupportIssue` | `subdomains/supporting/support-issue/services/support-issue.service.ts:661` | `SupportIssueService.getIssues` | -| 450 | 16 | find | `SupportIssue` | `subdomains/supporting/support-issue/services/support-issue.service.ts:670` | `SupportIssueService.getIssue` | -| 450 | 16 | find | `SupportIssue` | `subdomains/supporting/support-issue/services/support-issue.service.ts:737` | `SupportIssueService.getUserIssues` | +| 450 | 16 | find | `SupportIssue` | `subdomains/supporting/support-issue/services/support-issue.service.ts:642` | `SupportIssueService.getUserIssues` | | 449 | 13 | find | `BuyCrypto` | `subdomains/core/accounting/services/ledger-cutover.service.ts:475` | `LedgerCutoverService.openBuyCryptoReceived` | | 449 | 13 | find | `BuyCrypto` | `subdomains/core/accounting/services/ledger-cutover.service.ts:536` | `LedgerCutoverService.openBuyCryptoOwed` | | 449 | 13 | find | `BuyCrypto` | `subdomains/core/buy-crypto/process/services/buy-crypto-registration.service.ts:25` | `BuyCryptoRegistrationService.syncReturnTxId` | @@ -171,28 +165,29 @@ Sorted by measured columns, largest first. `—` means not measurable, not zero. | 434 | 15 | find | `LimitRequest` | `subdomains/supporting/support-issue/services/limit-request.service.ts:120` | `LimitRequestService.getUserLimitRequests` | | 433 | 12 | find | `BankTx` | `subdomains/core/accounting/services/consumers/bank-tx.consumer.ts:106` | `BankTxConsumer.processForward` | | 428 | 15 | find | `SupportMessage` | `subdomains/supporting/support-issue/services/support-issue.service.ts:478` | `SupportIssueService.closeIssue` | -| 428 | 15 | find | `SupportMessage` | `subdomains/supporting/support-issue/services/support-issue.service.ts:723` | `SupportIssueService.getIssueMessages` | -| 427 | 12 | find | `TransactionRequest` | `subdomains/supporting/payment/services/transaction-request.service.ts:73` | `TransactionRequestService.txRequestWaitingExpiryCheck` | -| 427 | 12 | find | `TransactionRequest` | `subdomains/supporting/payment/services/transaction-request.service.ts:84` | `TransactionRequestService.deleteOldTxRequests` | +| 428 | 15 | find | `SupportMessage` | `subdomains/supporting/support-issue/services/support-issue.service.ts:628` | `SupportIssueService.getIssueMessages` | +| 427 | 13 | find | `CustodyOrder` | `subdomains/core/custody/services/custody-order.service.ts:281` | `CustodyOrderService.confirmOrder` | +| 427 | 13 | find | `CustodyOrder` | `subdomains/core/custody/services/custody-order.service.ts:297` | `CustodyOrderService.getOrdersForSupport` | | 421 | 14 | find | `SupportIssue` | `subdomains/supporting/support-issue/services/support-escalation.service.ts:214` | `SupportEscalationService.checkEscalations` | | 421 | 14 | find | `SupportIssue` | `subdomains/supporting/support-issue/services/support-issue.service.ts:484` | `SupportIssueService.updateIssue` | -| 421 | 14 | find | `SupportIssue` | `subdomains/supporting/support-issue/services/support-issue.service.ts:713` | `SupportIssueService.getIssueMessages` | -| 421 | 14 | find | `SupportIssue` | `subdomains/supporting/support-issue/services/support-issue.service.ts:750` | `SupportIssueService.getIssueUserDataId` | -| 419 | 14 | find | `BuyCrypto` | `subdomains/core/buy-crypto/process/services/buy-crypto.service.ts:1596` | `BuyCryptoService.getTransactions` | -| 418 | 18 | find | `CustodyOrderStep` | `subdomains/core/custody/services/custody-job.service.ts:80` | `CustodyJobService.executeStep` | +| 421 | 14 | find | `SupportIssue` | `subdomains/supporting/support-issue/services/support-issue.service.ts:618` | `SupportIssueService.getIssueMessages` | +| 421 | 14 | find | `SupportIssue` | `subdomains/supporting/support-issue/services/support-issue.service.ts:655` | `SupportIssueService.getIssueUserDataId` | | 418 | 11 | find | `User` | `subdomains/generic/user/models/user/user-job.service.ts:19` | `UserJobService.approveUser` | -| 415 | 16 | find | `Buy` | `subdomains/core/buy-crypto/process/services/buy-crypto.service.ts:1434` | `BuyCryptoService.getBuy` | -| 406 | 12 | find | `User` | `subdomains/generic/user/models/user/user.service.ts:400` | `UserService.updateUserV1` | -| 406 | 12 | find | `User` | `subdomains/generic/user/models/user/user.service.ts:454` | `UserService.updateUserData` | +| 415 | 13 | find | `BuyCrypto` | `subdomains/core/buy-crypto/process/services/buy-crypto.service.ts:1570` | `BuyCryptoService.getTransactions` | +| 411 | 15 | find | `Buy` | `subdomains/core/buy-crypto/process/services/buy-crypto.service.ts:1408` | `BuyCryptoService.getBuy` | +| 407 | 11 | find | `TransactionRequest` | `subdomains/supporting/payment/services/transaction-request.service.ts:73` | `TransactionRequestService.txRequestWaitingExpiryCheck` | +| 407 | 11 | find | `TransactionRequest` | `subdomains/supporting/payment/services/transaction-request.service.ts:84` | `TransactionRequestService.deleteOldTxRequests` | +| 406 | 12 | find | `User` | `subdomains/generic/user/models/user/user.service.ts:394` | `UserService.updateUserV1` | +| 406 | 12 | find | `User` | `subdomains/generic/user/models/user/user.service.ts:448` | `UserService.updateUserData` | | 396 | 15 | find | `Swap` | `subdomains/core/buy-crypto/process/services/buy-crypto-registration.service.ts:69` | `BuyCryptoRegistrationService.filterBuyCryptoPayIns` | | 396 | 15 | find | `Swap` | `subdomains/core/buy-crypto/routes/swap/swap.service.ts:260` | `SwapService.updateSwap` | | 396 | 15 | find | `Swap` | `subdomains/core/buy-crypto/routes/swap/swap.service.ts:268` | `SwapService.confirmSwap` | -| 392 | 14 | find | `BuyFiat` | `subdomains/core/sell-crypto/process/services/buy-fiat.service.ts:645` | `BuyFiatService.getPendingTransactions` | -| 386 | 11 | find | `User` | `subdomains/generic/user/models/user/user.service.ts:446` | `UserService.updateUserName` | +| 392 | 14 | find | `BuyFiat` | `subdomains/core/sell-crypto/process/services/buy-fiat.service.ts:639` | `BuyFiatService.getPendingTransactions` | +| 386 | 11 | find | `User` | `subdomains/generic/user/models/user/user.service.ts:440` | `UserService.updateUserName` | | 385 | 15 | find | `Sell` | `subdomains/core/sell-crypto/process/services/buy-fiat-registration.service.ts:139` | `BuyFiatRegistrationService.createBuyFiatsAndAckPayIns` | | 385 | 14 | find | `KycStep` | `subdomains/generic/kyc/services/kyc-admin.service.ts:45` | `KycAdminService.updateKycStep` | -| 384 | 14 | find | `Buy` | `subdomains/core/buy-crypto/routes/buy/buy.service.ts:226` | `BuyService.getByBankUsage` | | 384 | 13 | find | `UserData` | `subdomains/generic/user/models/user-data/user-data.service.ts:353` | `UserDataService.updateUserData` | +| 380 | 13 | find | `Buy` | `subdomains/core/buy-crypto/routes/buy/buy.service.ts:226` | `BuyService.getByBankUsage` | | 377 | 14 | find | `Sell` | `subdomains/core/sell-crypto/route/sell.service.ts:80` | `SellService.get` | | 377 | 12 | find | `BankTxReturn` | `subdomains/supporting/bank-tx/bank-tx-return/bank-tx-return.service.ts:118` | `BankTxReturnService.create` | | 377 | 12 | find | `BankTxReturn` | `subdomains/supporting/bank-tx/bank-tx-return/bank-tx-return.service.ts:148` | `BankTxReturnService.updateInternal` | @@ -201,7 +196,6 @@ Sorted by measured columns, largest first. `—` means not measurable, not zero. | 374 | 14 | find | `Sell` | `subdomains/core/sell-crypto/route/sell.service.ts:287` | `SellService.confirmSell` | | 370 | 10 | find | `BankTx` | `subdomains/supporting/bank-tx/bank-tx/services/bank-tx.service.ts:325` | `BankTxService.fillBankTx` | | 370 | 9 | find | `FiatOutput` | `subdomains/supporting/fiat-output/fiat-output-job.service.ts:554` | `FiatOutputJobService.searchOutgoingBankTx` | -| 364 | 13 | find | `Buy` | `subdomains/core/buy-crypto/routes/buy/buy.service.ts:165` | `BuyService.createBuy` | | 364 | 12 | find | `Webhook` | `subdomains/generic/user/services/webhook/webhook-notification.service.ts:32` | `WebhookNotificationService.sendOpenWebhooks` | | 364 | 12 | find | `Webhook` | `subdomains/generic/user/services/webhook/webhook.service.ts:146` | `WebhookService.createAndSendWebhook` | | 363 | 10 | find | `BuyCrypto` | `subdomains/core/accounting/services/consumers/payout-order.consumer.ts:297` | `PayoutOrderConsumer.owedCompletionChf` | @@ -213,37 +207,36 @@ Sorted by measured columns, largest first. `—` means not measurable, not zero. | 363 | 10 | find | `BuyCrypto` | `subdomains/supporting/fiat-output/fiat-output.service.ts:109` | `FiatOutputService.create` | | 363 | 10 | find | `UserData` | `subdomains/generic/user/models/user-data/user-data-job.service.ts:25` | `UserDataJobService.bankTxVerification` | | 362 | 11 | find | `Transaction` | `subdomains/supporting/payment/services/transaction.service.ts:476` | `TransactionService.getByAssetId` | -| 358 | 16 | find | `CryptoStaking` | `subdomains/core/staking/services/staking.service.ts:48` | `StakingService.getUserInvests` | +| 360 | 12 | find | `Buy` | `subdomains/core/buy-crypto/routes/buy/buy.service.ts:165` | `BuyService.createBuy` | | 356 | 10 | find | `BankTx` | `subdomains/supporting/bank-tx/bank-tx/services/bank-tx.service.ts:761` | `BankTxService.getUnassignedBankTx` | | 356 | 10 | find | `BankTx` | `subdomains/supporting/bank-tx/bank-tx/services/bank-tx.service.ts:771` | `BankTxService.getBankTxsByVirtualIban` | | 354 | 13 | find | `Sell` | `subdomains/core/sell-crypto/route/sell.service.ts:117` | `SellService.getSellsByIban` | -| 351 | 11 | find | `UserData` | `subdomains/generic/user/models/kyc/kyc.service.ts:127` | `KycService.getUserByKycCode` | -| 351 | 11 | find | `UserData` | `subdomains/generic/user/models/user/user.service.ts:267` | `UserService.getUserDtoV2` | -| 351 | 11 | find | `UserData` | `subdomains/generic/user/models/user/user.service.ts:413` | `UserService.updateUser` | -| 351 | 11 | find | `UserData` | `subdomains/generic/user/models/user/user.service.ts:425` | `UserService.updateUserMail` | -| 351 | 11 | find | `UserData` | `subdomains/generic/user/models/user/user.service.ts:435` | `UserService.verifyMail` | -| 351 | 11 | find | `UserData` | `subdomains/generic/user/models/user/user.service.ts:509` | `UserService.updateAddress` | -| 344 | 11 | find | `UserData` | `subdomains/generic/user/models/user/user.service.ts:524` | `UserService.deactivateUser` | +| 352 | 15 | find | `CryptoStaking` | `subdomains/core/staking/services/staking.service.ts:48` | `StakingService.getUserInvests` | +| 351 | 11 | find | `UserData` | `subdomains/generic/user/models/kyc/kyc.service.ts:126` | `KycService.getUserByKycCode` | +| 351 | 11 | find | `UserData` | `subdomains/generic/user/models/user/user.service.ts:407` | `UserService.updateUser` | +| 351 | 11 | find | `UserData` | `subdomains/generic/user/models/user/user.service.ts:419` | `UserService.updateUserMail` | +| 351 | 11 | find | `UserData` | `subdomains/generic/user/models/user/user.service.ts:429` | `UserService.verifyMail` | +| 351 | 11 | find | `UserData` | `subdomains/generic/user/models/user/user.service.ts:503` | `UserService.updateAddress` | +| 344 | 11 | find | `UserData` | `subdomains/generic/user/models/user/user.service.ts:518` | `UserService.deactivateUser` | | 343 | 10 | find | `CheckoutTx` | `subdomains/supporting/fiat-payin/services/fiat-payin-sync.service.ts:92` | `FiatPayInSyncService.createCheckoutTx` | | 331 | 10 | find | `UserData` | `subdomains/generic/user/models/user-data/user-data.service.ts:1112` | `UserDataService.updateApiFilter` | | 331 | 10 | find | `UserData` | `subdomains/generic/user/models/user-data/user-data.service.ts:1122` | `UserDataService.checkApiKey` | -| 331 | 14 | find | `VirtualIban` | `subdomains/supporting/bank/virtual-iban/virtual-iban.service.ts:176` | `VirtualIbanService.getByIdForUser` | -| 331 | 14 | find | `VirtualIban` | `subdomains/supporting/bank/virtual-iban/virtual-iban.service.ts:1384` | `VirtualIbanService.getActiveForBuyAndCurrency` | -| 331 | 14 | find | `VirtualIban` | `subdomains/supporting/bank/virtual-iban/virtual-iban.service.ts:1396` | `VirtualIbanService.getByIban` | -| 331 | 14 | find | `VirtualIban` | `subdomains/supporting/bank/virtual-iban/virtual-iban.service.ts:1438` | `VirtualIbanService.getVirtualIbansForAccount` | -| 329 | 9 | find | `TransactionRequest` | `subdomains/supporting/payment/services/transaction-request.service.ts:284` | `TransactionRequestService.findAndComplete` | | 328 | 10 | find | `User` | `subdomains/generic/user/models/auth/auth.controller.ts:157` | `AuthController.createAccessTokenAfterMerge` | -| 328 | 10 | find | `Wallet` | `subdomains/generic/user/models/kyc/kyc.service.ts:138` | `KycService.getAllKycData` | -| 328 | 10 | find | `User` | `subdomains/generic/user/models/kyc/kyc.service.ts:147` | `KycService.getKycFiles` | -| 328 | 10 | find | `User` | `subdomains/generic/user/models/kyc/kyc.service.ts:162` | `KycService.getKycFile` | +| 328 | 10 | find | `User` | `subdomains/generic/user/models/kyc/kyc.service.ts:155` | `KycService.getKycFile` | | 328 | 10 | find | `User` | `subdomains/generic/user/models/user/user.service.ts:130` | `UserService.getUserDto` | +| 327 | 13 | find | `VirtualIban` | `subdomains/supporting/bank/virtual-iban/virtual-iban.service.ts:176` | `VirtualIbanService.getByIdForUser` | +| 327 | 13 | find | `VirtualIban` | `subdomains/supporting/bank/virtual-iban/virtual-iban.service.ts:1384` | `VirtualIbanService.getActiveForBuyAndCurrency` | +| 327 | 13 | find | `VirtualIban` | `subdomains/supporting/bank/virtual-iban/virtual-iban.service.ts:1396` | `VirtualIbanService.getByIban` | +| 327 | 13 | find | `VirtualIban` | `subdomains/supporting/bank/virtual-iban/virtual-iban.service.ts:1438` | `VirtualIbanService.getVirtualIbansForAccount` | | 323 | 10 | find | `AktionariatRegistration` | `subdomains/supporting/realunit/realunit.service.ts:1247` | `RealUnitService.forwardRegistrationToAktionariat` | -| 321 | 8 | find | `BuyFiat` | `subdomains/core/sell-crypto/process/services/buy-fiat.service.ts:363` | `BuyFiatService.getBuyFiatByTransactionId` | -| 321 | 8 | find | `BuyFiat` | `subdomains/core/sell-crypto/process/services/buy-fiat.service.ts:368` | `BuyFiatService.getBuyFiatsByTransactionIds` | +| 321 | 8 | find | `BuyFiat` | `subdomains/core/sell-crypto/process/services/buy-fiat.service.ts:362` | `BuyFiatService.getBuyFiatByTransactionId` | +| 321 | 8 | find | `BuyFiat` | `subdomains/core/sell-crypto/process/services/buy-fiat.service.ts:367` | `BuyFiatService.getBuyFiatsByTransactionIds` | +| 320 | 15 | find | `CustodyOrderStep` | `subdomains/core/custody/services/custody-job.service.ts:80` | `CustodyJobService.executeStep` | | 319 | 10 | find | `BuyFiat` | `subdomains/core/accounting/services/ledger-cutover.service.ts:308` | `LedgerCutoverService.openBuyFiatReceived` | | 319 | 10 | find | `BuyFiat` | `subdomains/core/accounting/services/ledger-cutover.service.ts:357` | `LedgerCutoverService.openBuyFiatOwed` | -| 308 | 9 | find | `User` | `subdomains/generic/user/models/kyc/kyc.service.ts:61` | `KycService.transferKycData` | -| 308 | 9 | find | `User` | `subdomains/generic/user/models/kyc/kyc.service.ts:77` | `KycService.transferKycData` | +| 309 | 8 | find | `TransactionRequest` | `subdomains/supporting/payment/services/transaction-request.service.ts:284` | `TransactionRequestService.findAndComplete` | +| 308 | 9 | find | `User` | `subdomains/generic/user/models/kyc/kyc.service.ts:60` | `KycService.transferKycData` | +| 308 | 9 | find | `User` | `subdomains/generic/user/models/kyc/kyc.service.ts:76` | `KycService.transferKycData` | | 308 | 9 | find | `User` | `subdomains/generic/user/models/user/staff-kyc-clearance.service.ts:61` | `StaffKycClearanceService.syncStaffKycClearance` | | 308 | 9 | find | `User` | `subdomains/generic/user/models/user/user.service.ts:81` | `UserService.getAllUser` | | 308 | 9 | find | `User` | `subdomains/generic/user/models/user/user.service.ts:85` | `UserService.getUser` | @@ -253,16 +246,14 @@ Sorted by measured columns, largest first. `—` means not measurable, not zero. | 308 | 9 | find | `User` | `subdomains/generic/user/models/user/user.service.ts:205` | `UserService.getRefUser` | | 308 | 9 | find | `User` | `subdomains/generic/user/models/user/user.service.ts:210` | `UserService.getRefUsersByRefs` | | 308 | 9 | find | `User` | `subdomains/generic/user/models/user/user.service.ts:216` | `UserService.getUsersByUsedRefs` | -| 308 | 9 | find | `User` | `subdomains/generic/user/models/user/user.service.ts:468` | `UserService.updateUserAdmin` | -| 308 | 9 | find | `User` | `subdomains/generic/user/models/user/user.service.ts:488` | `UserService.updateUserInternal` | -| 308 | 9 | find | `User` | `subdomains/generic/user/models/user/user.service.ts:591` | `UserService.updateUserDataVolume` | -| 308 | 9 | find | `User` | `subdomains/generic/user/models/user/user.service.ts:758` | `UserService.checkApiKey` | -| 308 | 9 | find | `User` | `subdomains/generic/user/models/user/user.service.ts:767` | `UserService.updateApiFilter` | +| 308 | 9 | find | `User` | `subdomains/generic/user/models/user/user.service.ts:462` | `UserService.updateUserAdmin` | +| 308 | 9 | find | `User` | `subdomains/generic/user/models/user/user.service.ts:482` | `UserService.updateUserInternal` | +| 308 | 9 | find | `User` | `subdomains/generic/user/models/user/user.service.ts:585` | `UserService.updateUserDataVolume` | +| 308 | 9 | find | `User` | `subdomains/generic/user/models/user/user.service.ts:752` | `UserService.checkApiKey` | +| 308 | 9 | find | `User` | `subdomains/generic/user/models/user/user.service.ts:761` | `UserService.updateApiFilter` | | 301 | 6 | find | `BankTxRepeat` | `subdomains/supporting/bank-tx/bank-tx-repeat/bank-tx-repeat.service.ts:89` | `BankTxRepeatService.getAllUserRepeats` | | 299 | 12 | find | `TradingOrder` | `subdomains/core/trading/services/trading-order.service.ts:65` | `TradingOrderService.startNewOrders` | -| 295 | 8 | find | `CustodyOrder` | `subdomains/core/custody/services/custody-job.service.ts:113` | `CustodyJobService.onStepComplete` | -| 295 | 8 | find | `CustodyOrder` | `subdomains/core/custody/services/custody-order.service.ts:290` | `CustodyOrderService.getCustodyOrderByTx` | -| 287 | 8 | find | `BuyFiat` | `subdomains/core/sell-crypto/process/services/buy-fiat.service.ts:767` | `BuyFiatService.getTransactions` | +| 287 | 8 | find | `BuyFiat` | `subdomains/core/sell-crypto/process/services/buy-fiat.service.ts:746` | `BuyFiatService.getTransactions` | | 284 | 10 | find | `UserData` | `subdomains/generic/user/models/bank-data/bank-data.service.ts:177` | `BankDataService.addBankData` | | 276 | 10 | find | `NameCheckLog` | `subdomains/generic/kyc/services/name-check.service.ts:205` | `NameCheckService.createNameCheckLog` | | 274 | 10 | find | `BankData` | `subdomains/generic/user/models/bank-data/bank-data.service.ts:63` | `BankDataService.checkUnverifiedBankDatas` | @@ -303,14 +294,12 @@ Sorted by measured columns, largest first. `—` means not measurable, not zero. | 253 | 8 | find | `UserData` | `subdomains/generic/user/models/user-data/user-data.service.ts:292` | `UserDataService.getUsersByPhone` | | 253 | 8 | find | `UserData` | `subdomains/generic/user/models/user-data/user-data.service.ts:296` | `UserDataService.getUserDatasWithKycFile` | | 253 | 8 | find | `UserData` | `subdomains/generic/user/models/user-data/user-data.service.ts:644` | `UserDataService.assignNextKycFileId` | -| 253 | 8 | find | `UserData` | `subdomains/generic/user/models/user-data/user-data.service.ts:1093` | `UserDataService.createApiKey` | | 253 | 8 | find | `UserData` | `subdomains/generic/user/models/user-data/user-data.service.ts:1166` | `UserDataService.loadRelationsAndVerify` | | 253 | 8 | find | `UserData` | `subdomains/generic/user/models/user-data/user-data.service.ts:1177` | `UserDataService.loadRelationsAndVerify` | | 253 | 8 | find | `UserData` | `subdomains/generic/user/models/user-data/user-data.service.ts:1184` | `UserDataService.loadRelationsAndVerify` | | 253 | 8 | find | `UserData` | `subdomains/generic/user/models/user-data/user-data.service.ts:1821` | `UserDataService.getByPhoneCallStatuses` | -| 253 | 8 | find | `UserData` | `subdomains/generic/user/models/user/user.service.ts:320` | `UserService.getUserProfile` | | 247 | 6 | find | `BankTx` | `subdomains/supporting/bank-tx/bank-tx/services/bank-tx.service.ts:451` | `BankTxService.reset` | -| 247 | 9 | find | `BuyFiat` | `subdomains/core/sell-crypto/process/services/buy-fiat.service.ts:576` | `BuyFiatService.updateVolumes` | +| 247 | 9 | find | `BuyFiat` | `subdomains/core/sell-crypto/process/services/buy-fiat.service.ts:575` | `BuyFiatService.updateVolumes` | | 246 | 9 | find | `CustodyAccountAccess` | `subdomains/core/custody/services/custody-account.service.ts:61` | `CustodyAccountService.getCustodyAccountsForUser` | | 245 | 8 | find | `NameCheckLog` | `subdomains/generic/kyc/services/name-check.service.ts:40` | `NameCheckService.updateLog` | | 245 | 8 | find | `NameCheckLog` | `subdomains/generic/kyc/services/name-check.service.ts:169` | `NameCheckService.closeAndRefreshRiskStatus` | @@ -335,16 +324,13 @@ Sorted by measured columns, largest first. `—` means not measurable, not zero. | 234 | 6 | find | `RefReward` | `subdomains/core/referral/reward/services/ref-reward.service.ts:199` | `RefRewardService.getAllUserRewards` | | 234 | 6 | find | `RefReward` | `subdomains/core/referral/reward/services/ref-reward.service.ts:207` | `RefRewardService.getRefRewardsByUserDataId` | | 229 | 11 | find | `CryptoInput` | `subdomains/supporting/dashboard/dashboard-reconciliation.service.ts:527` | `DashboardReconciliationService.getCryptoInputs` | -| 226 | 7 | find | `CustodyOrderStep` | `subdomains/core/custody/services/custody-job.service.ts:94` | `CustodyJobService.checkStep` | -| 217 | 6 | find | `CustodyOrder` | `subdomains/core/custody/services/custody-job.service.ts:52` | `CustodyJobService.resetExpiredConfirmedOrders` | -| 217 | 6 | find | `CustodyOrder` | `subdomains/core/custody/services/custody-order.service.ts:325` | `CustodyOrderService.approveOrder` | -| 217 | 6 | find | `CustodyOrder` | `subdomains/core/custody/services/custody.service.ts:257` | `CustodyService.getUserCustodyHistory` | -| 217 | 6 | find | `CustodyOrder` | `subdomains/core/custody/services/custody.service.ts:470` | `CustodyService.calculateAccruedInterest` | | 201 | 5 | find | `BuyFiat` | `subdomains/core/accounting/services/consumers/payout-order.consumer.ts:293` | `PayoutOrderConsumer.owedCompletionChf` | | 201 | 8 | find | `RefReward` | `subdomains/core/referral/reward/services/ref-reward-dex.service.ts:36` | `RefRewardDexService.secureLiquidity` | -| 201 | 5 | find | `BuyFiat` | `subdomains/core/sell-crypto/process/services/buy-fiat.service.ts:109` | `BuyFiatService.checkAmlResetTx` | -| 201 | 5 | find | `BuyFiat` | `subdomains/core/sell-crypto/process/services/buy-fiat.service.ts:549` | `BuyFiatService.manualPassAmlCheck` | +| 201 | 5 | find | `BuyFiat` | `subdomains/core/sell-crypto/process/services/buy-fiat.service.ts:108` | `BuyFiatService.checkAmlResetTx` | +| 201 | 5 | find | `BuyFiat` | `subdomains/core/sell-crypto/process/services/buy-fiat.service.ts:548` | `BuyFiatService.manualPassAmlCheck` | | 201 | 5 | find | `BuyFiat` | `subdomains/supporting/fiat-output/fiat-output.service.ts:99` | `FiatOutputService.create` | +| 197 | 5 | find | `CustodyOrder` | `subdomains/core/custody/services/custody-job.service.ts:113` | `CustodyJobService.onStepComplete` | +| 197 | 5 | find | `CustodyOrder` | `subdomains/core/custody/services/custody-order.service.ts:271` | `CustodyOrderService.getCustodyOrderByTx` | | 195 | 8 | find | `PaymentLink` | `subdomains/core/payment-link/services/payment-link.service.ts:612` | `PaymentLinkService.deletePaymentLink` | | 182 | 5 | find | `FiatOutput` | `subdomains/supporting/fiat-output/fiat-output.service.ts:232` | `FiatOutputService.delete` | | 179 | 4 | find | `BankTxRepeat` | `subdomains/core/accounting/services/consumers/bank-tx.consumer.ts:553` | `BankTxConsumer.openingBankTxId` | @@ -355,7 +341,7 @@ Sorted by measured columns, largest first. `—` means not measurable, not zero. | 174 | 4 | find | `Recall` | `subdomains/supporting/recall/recall.service.ts:51` | `RecallService.update` | | 174 | 4 | find | `Recall` | `subdomains/supporting/recall/recall.service.ts:63` | `RecallService.getAll` | | 174 | 4 | find | `Recall` | `subdomains/supporting/recall/recall.service.ts:75` | `RecallService.getById` | -| 174 | 13 | find | `Route` | `subdomains/core/route/route.service.ts:19` | `RouteService.updateRoute` | +| 170 | 12 | find | `Route` | `subdomains/core/route/route.service.ts:19` | `RouteService.updateRoute` | | 164 | 9 | find | `DepositRoute` | `subdomains/supporting/address-pool/route/deposit-route.service.ts:41` | `DepositRouteService.getByLabel` | | 159 | 3 | find | `Transaction` | `subdomains/supporting/payment/services/transaction-notification.service.ts:114` | `TransactionNotificationService.txUnassigned` | | 156 | 4 | find | `LiquidityOrder` | `subdomains/core/accounting/services/consumers/liquidity-order-dex.consumer.ts:104` | `LiquidityOrderDexConsumer.processForward` | @@ -392,7 +378,7 @@ Sorted by measured columns, largest first. `—` means not measurable, not zero. | 146 | 6 | find | `Swap` | `subdomains/core/buy-crypto/routes/swap/swap.service.ts:182` | `SwapService.getSwapsByUserDataId` | | 146 | 6 | find | `Swap` | `subdomains/core/buy-crypto/routes/swap/swap.service.ts:214` | `SwapService.createSwap` | | 146 | 6 | find | `Swap` | `subdomains/core/buy-crypto/routes/swap/swap.service.ts:253` | `SwapService.getUserSwaps` | -| 144 | 6 | find | `Sell` | `subdomains/core/sell-crypto/process/services/buy-fiat.service.ts:685` | `BuyFiatService.getSell` | +| 144 | 6 | find | `Sell` | `subdomains/core/sell-crypto/process/services/buy-fiat.service.ts:664` | `BuyFiatService.getSell` | | 143 | 4 | find | `Fee` | `subdomains/supporting/payment/services/fee.service.ts:346` | `FeeService.getAllFees` | | 139 | 9 | find | `LiquidityManagementOrder` | `subdomains/core/accounting/services/consumers/liquidity-mgmt.consumer.ts:89` | `LiquidityMgmtConsumer.processForward` | | 139 | 9 | find | `LiquidityManagementOrder` | `subdomains/core/liquidity-management/adapters/actions/liquidity-pipeline.adapter.ts:62` | `LiquidityPipelineAdapter.buy` | @@ -406,15 +392,16 @@ Sorted by measured columns, largest first. `—` means not measurable, not zero. | 139 | 9 | find | `LiquidityManagementOrder` | `subdomains/core/liquidity-management/services/liquidity-management-pipeline.service.ts:837` | `LiquidityManagementPipelineService.resolveUncertainOrderManually` | | 139 | 9 | find | `LiquidityManagementOrder` | `subdomains/core/liquidity-management/services/liquidity-management-pipeline.service.ts:890` | `LiquidityManagementPipelineService.checkRunningOrders` | | 139 | 9 | find | `LiquidityManagementOrder` | `subdomains/supporting/dashboard/dashboard-reconciliation.service.ts:510` | `DashboardReconciliationService.getLmOrders` | -| 134 | 5 | find | `Buy` | `subdomains/core/buy-crypto/routes/buy/buy.service.ts:87` | `BuyService.updateVolume` | -| 134 | 5 | find | `Buy` | `subdomains/core/buy-crypto/routes/buy/buy.service.ts:123` | `BuyService.getAllBankUsages` | -| 134 | 5 | find | `Buy` | `subdomains/core/buy-crypto/routes/buy/buy.service.ts:134` | `BuyService.get` | -| 134 | 5 | find | `Buy` | `subdomains/core/buy-crypto/routes/buy/buy.service.ts:207` | `BuyService.getBuyWithoutRoute` | -| 134 | 5 | find | `Buy` | `subdomains/core/buy-crypto/routes/buy/buy.service.ts:211` | `BuyService.getUserBuys` | -| 134 | 5 | find | `Buy` | `subdomains/core/buy-crypto/routes/buy/buy.service.ts:215` | `BuyService.getUserDataBuys` | -| 134 | 5 | find | `Buy` | `subdomains/core/buy-crypto/routes/buy/buy.service.ts:253` | `BuyService.getAllUserBuys` | | 131 | 3 | find | `StakingRefReward` | `subdomains/core/staking/services/staking.service.ts:37` | `StakingService.getUserStakingRefRewards` | +| 130 | 4 | find | `Buy` | `subdomains/core/buy-crypto/routes/buy/buy.service.ts:87` | `BuyService.updateVolume` | +| 130 | 4 | find | `Buy` | `subdomains/core/buy-crypto/routes/buy/buy.service.ts:123` | `BuyService.getAllBankUsages` | +| 130 | 4 | find | `Buy` | `subdomains/core/buy-crypto/routes/buy/buy.service.ts:134` | `BuyService.get` | +| 130 | 4 | find | `Buy` | `subdomains/core/buy-crypto/routes/buy/buy.service.ts:207` | `BuyService.getBuyWithoutRoute` | +| 130 | 4 | find | `Buy` | `subdomains/core/buy-crypto/routes/buy/buy.service.ts:211` | `BuyService.getUserBuys` | +| 130 | 4 | find | `Buy` | `subdomains/core/buy-crypto/routes/buy/buy.service.ts:215` | `BuyService.getUserDataBuys` | +| 130 | 4 | find | `Buy` | `subdomains/core/buy-crypto/routes/buy/buy.service.ts:253` | `BuyService.getAllUserBuys` | | 130 | 9 | find | `LiquidityManagementPipeline` | `subdomains/core/liquidity-management/services/liquidity-management-pipeline.service.ts:178` | `LiquidityManagementPipelineService.checkRunningPipelines` | +| 128 | 4 | find | `CustodyOrderStep` | `subdomains/core/custody/services/custody-job.service.ts:94` | `CustodyJobService.checkStep` | | 126 | 2 | find | `BankTx` | `subdomains/supporting/bank-tx/bank-tx/services/bank-tx.service.ts:229` | `BankTxService.assignTransactions` | | 126 | 2 | find | `BankTx` | `subdomains/supporting/bank-tx/bank-tx/services/bank-tx.service.ts:484` | `BankTxService.getBankTxByTransactionId` | | 126 | 2 | find | `BankTx` | `subdomains/supporting/bank-tx/bank-tx/services/bank-tx.service.ts:489` | `BankTxService.getBankTxsByTransactionIds` | @@ -439,34 +426,27 @@ Sorted by measured columns, largest first. `—` means not measurable, not zero. | 123 | 3 | find | `PayoutOrder` | `subdomains/supporting/payout/services/payout.service.ts:266` | `PayoutService.prepareNewOrders` | | 123 | 3 | find | `PayoutOrder` | `subdomains/supporting/payout/services/payout.service.ts:283` | `PayoutService.payoutOrders` | | 123 | 3 | find | `PayoutOrder` | `subdomains/supporting/payout/services/payout.service.ts:300` | `PayoutService.processFailedOrders` | -| 118 | 3 | find | `CustodyBalance` | `subdomains/core/custody/services/custody-pdf.service.ts:47` | `CustodyPdfService.getBalancesWithHistoricalPrices` | -| 118 | 3 | find | `CustodyBalance` | `subdomains/core/custody/services/custody.service.ts:111` | `CustodyService.getUserCustodyBalance` | -| 118 | 3 | find | `CustodyBalance` | `subdomains/core/custody/services/custody.service.ts:238` | `CustodyService.updateCustodyBalance` | +| 119 | 3 | find | `CustodyOrder` | `subdomains/core/custody/services/custody-job.service.ts:52` | `CustodyJobService.resetExpiredConfirmedOrders` | +| 119 | 3 | find | `CustodyOrder` | `subdomains/core/custody/services/custody-order.service.ts:306` | `CustodyOrderService.approveOrder` | +| 119 | 3 | find | `CustodyOrder` | `subdomains/core/custody/services/custody.service.ts:257` | `CustodyService.getUserCustodyHistory` | +| 119 | 3 | find | `CustodyOrder` | `subdomains/core/custody/services/custody.service.ts:470` | `CustodyService.calculateAccruedInterest` | | 118 | 3 | find | `BankTxRepeat` | `subdomains/supporting/bank-tx/bank-tx-repeat/bank-tx-repeat.service.ts:49` | `BankTxRepeatService.update` | | 118 | 3 | find | `BankTxRepeat` | `subdomains/supporting/bank-tx/bank-tx-repeat/bank-tx-repeat.service.ts:62` | `BankTxRepeatService.update` | | 118 | 3 | find | `BankTxRepeat` | `subdomains/supporting/bank-tx/bank-tx-repeat/bank-tx-repeat.service.ts:104` | `BankTxRepeatService.getBankTxRepeat` | | 112 | 7 | find | `LiquidityManagementPipeline` | `subdomains/core/liquidity-management/adapters/actions/liquidity-pipeline.adapter.ts:86` | `LiquidityPipelineAdapter.checkBuyCompletion` | | 112 | 7 | find | `LiquidityManagementPipeline` | `subdomains/core/liquidity-management/services/liquidity-management-pipeline.service.ts:115` | `LiquidityManagementPipelineService.getProcessingPipelines` | | 112 | 7 | find | `LiquidityManagementPipeline` | `subdomains/core/liquidity-management/services/liquidity-management-pipeline.service.ts:121` | `LiquidityManagementPipelineService.getStoppedPipelines` | -| 112 | 7 | find | `LiquidityManagementPipeline` | `subdomains/core/liquidity-management/services/liquidity-management-pipeline.service.ts:150` | `LiquidityManagementPipelineService.getPipelineStatus` | | 112 | 7 | find | `LiquidityManagementPipeline` | `subdomains/core/liquidity-management/services/liquidity-management-pipeline.service.ts:160` | `LiquidityManagementPipelineService.startNewPipelines` | | 112 | 7 | find | `LiquidityManagementPipeline` | `subdomains/core/liquidity-management/services/liquidity-management.service.ts:200` | `LiquidityManagementService.findRunningPipeline` | | 112 | 2 | find | `TransactionRequest` | `subdomains/supporting/payment/services/transaction-request.service.ts:263` | `TransactionRequestService.getTransactionRequestByUid` | | 112 | 2 | find | `TransactionRequest` | `subdomains/supporting/payment/services/transaction-request.service.ts:267` | `TransactionRequestService.getOpenBuyQuotes` | | 112 | 2 | find | `TransactionRequest` | `subdomains/supporting/payment/services/transaction-request.service.ts:430` | `TransactionRequestService.getByAssetId` | -| 101 | 6 | find | `VirtualIban` | `subdomains/supporting/bank/bank/bank.service.ts:216` | `BankService.getReceiveIbanStatus` | -| 101 | 6 | find | `VirtualIban` | `subdomains/supporting/bank/virtual-iban/virtual-iban.service.ts:137` | `VirtualIbanService.getActiveReceivingForUserAndCurrency` | -| 101 | 6 | find | `VirtualIban` | `subdomains/supporting/bank/virtual-iban/virtual-iban.service.ts:162` | `VirtualIbanService.getActiveSendingCandidatesForUserAndCurrency` | -| 101 | 6 | find | `VirtualIban` | `subdomains/supporting/bank/virtual-iban/virtual-iban.service.ts:710` | `VirtualIbanService.isIbanProtectedFromReconciliationDeactivation` | | 99 | 0 | query-builder (alias only) | `UserData` | `subdomains/generic/user/models/user-data/user-data-notification.service.ts:173` | `UserDataNotificationService.blackSquadInvitation` | | 99 | 0 | query-builder (no select) | `UserData` | `subdomains/generic/user/models/user-data/user-data.service.ts:148` | `UserDataService.getUserDataByUser` | | 99 | 0 | query-builder (alias only) | `UserData` | `subdomains/generic/user/models/user-data/user-data.service.ts:320` | `UserDataService.getUserDataByKey` | -| 99 | 0 | query-builder (alias only) | `UserData` | `subdomains/generic/user/models/user-data/user-data.service.ts:186` | `UserDataService.getUserDataIdsByServiceProvider` | -| 99 | 0 | query-builder (alias only) | `UserData` | `subdomains/generic/user/models/user-data/user-data.service.ts:1812` | `UserDataService.getMaxKycFileIdByDateRange` | -| 99 | 0 | query-builder (no select) | `UserData` | `subdomains/generic/user/models/user-data/user-data.service.ts:1804` | `UserDataService.countByDateRange` | | 98 | 2 | find | `User` | `subdomains/generic/user/models/user-data/user-data.service.ts:1071` | `UserDataService.customIdentMethod` | -| 98 | 2 | find | `User` | `subdomains/generic/user/models/user/user.service.ts:278` | `UserService.getRefDtoV2` | -| 98 | 2 | find | `User` | `subdomains/generic/user/models/user/user.service.ts:286` | `UserService.updateRef` | +| 98 | 2 | find | `User` | `subdomains/generic/user/models/user/user.service.ts:275` | `UserService.getRefDtoV2` | +| 98 | 2 | find | `User` | `subdomains/generic/user/models/user/user.service.ts:283` | `UserService.updateRef` | | 98 | 2 | find | `User` | `subdomains/generic/user/services/webhook/webhook.service.ts:107` | `WebhookService.sendWebhooks` | | 98 | 2 | find | `User` | `subdomains/generic/user/services/webhook/webhook.service.ts:176` | `WebhookService.getUsers` | | 98 | 2 | find | `Transaction` | `subdomains/supporting/payment/services/transaction.service.ts:239` | `TransactionService.getTransactionById` | @@ -476,6 +456,10 @@ Sorted by measured columns, largest first. `—` means not measurable, not zero. | 98 | 2 | find | `Transaction` | `subdomains/supporting/payment/services/transaction.service.ts:266` | `TransactionService.getTransactionByRequestUid` | | 98 | 2 | find | `Transaction` | `subdomains/supporting/payment/services/transaction.service.ts:274` | `TransactionService.getTransactionByExternalId` | | 98 | 2 | find | `Transaction` | `subdomains/supporting/payment/services/transaction.service.ts:278` | `TransactionService.getTransactionByCkoId` | +| 97 | 5 | find | `VirtualIban` | `subdomains/supporting/bank/bank/bank.service.ts:216` | `BankService.getReceiveIbanStatus` | +| 97 | 5 | find | `VirtualIban` | `subdomains/supporting/bank/virtual-iban/virtual-iban.service.ts:137` | `VirtualIbanService.getActiveReceivingForUserAndCurrency` | +| 97 | 5 | find | `VirtualIban` | `subdomains/supporting/bank/virtual-iban/virtual-iban.service.ts:162` | `VirtualIbanService.getActiveSendingCandidatesForUserAndCurrency` | +| 97 | 5 | find | `VirtualIban` | `subdomains/supporting/bank/virtual-iban/virtual-iban.service.ts:710` | `VirtualIbanService.isIbanProtectedFromReconciliationDeactivation` | | 93 | 2 | find | `AktionariatRegistration` | `subdomains/supporting/realunit/realunit.service.ts:1282` | `RealUnitService.findRegistration` | | 93 | 2 | find | `AktionariatRegistration` | `subdomains/supporting/realunit/realunit.service.ts:1297` | `RealUnitService.findRegistration` | | 91 | 4 | find | `PaymentActivation` | `subdomains/core/payment-link/services/payment-activation.service.ts:67` | `PaymentActivationService.getActivationByTxId` | @@ -494,25 +478,15 @@ Sorted by measured columns, largest first. `—` means not measurable, not zero. | 83 | 4 | find | `LiquidityManagementRule` | `subdomains/core/liquidity-management/services/liquidity-management-rule.service.ts:114` | `LiquidityManagementRuleService.reactivateRules` | | 83 | 4 | find | `LiquidityManagementRule` | `subdomains/core/liquidity-management/services/liquidity-management-rule.service.ts:151` | `LiquidityManagementRuleService.findExistingRuleOnCreation` | | 83 | 4 | find | `LiquidityManagementRule` | `subdomains/core/liquidity-management/services/liquidity-management.service.ts:113` | `LiquidityManagementService.findRuleByAssetOrThrow` | +| 81 | 0 | query-builder (field list) | `SupportIssue` | `subdomains/supporting/support-issue/repositories/support-issue.repository.ts:341` | `SupportIssueRepository.findIssueData` | | 78 | 1 | find | `User` | `subdomains/generic/user/models/user/user.service.ts:98` | `UserService.getUserByAddress` | | 78 | 3 | find | `Mros` | `subdomains/supporting/mros/mros.service.ts:32` | `MrosService.update` | | 77 | 0 | query-builder (alias only) | `BuyCrypto` | `subdomains/core/buy-crypto/process/services/buy-crypto.service.ts:925` | `BuyCryptoService.getBuyCryptoByKeys` | -| 77 | 0 | query-builder (alias only) | `BuyCrypto` | `subdomains/core/buy-crypto/process/services/buy-crypto.service.ts:1461` | `BuyCryptoService.updateBuyVolume` | -| 77 | 0 | query-builder (alias only) | `BuyCrypto` | `subdomains/core/buy-crypto/process/services/buy-crypto.service.ts:1489` | `BuyCryptoService.updateCryptoRouteVolume` | -| 77 | 0 | query-builder (alias only) | `BuyCrypto` | `subdomains/core/buy-crypto/process/services/buy-crypto.service.ts:1532` | `BuyCryptoService.getRefVolume` | -| 77 | 0 | query-builder (alias only) | `BuyCrypto` | `subdomains/core/buy-crypto/process/services/buy-crypto.service.ts:1544` | `BuyCryptoService.getPartnerFeeRefVolume` | -| 77 | 0 | query-builder (alias only) | `BuyCrypto` | `subdomains/core/buy-crypto/process/services/buy-crypto.service.ts:980` | `BuyCryptoService.updateRefVolumes` | -| 77 | 0 | query-builder (alias only) | `BuyCrypto` | `subdomains/core/buy-crypto/process/services/buy-crypto.service.ts:1267` | `BuyCryptoService.getUserVolumeForType` | -| 77 | 0 | query-builder (alias only) | `BuyCrypto` | `subdomains/core/buy-crypto/process/services/buy-crypto.service.ts:1347` | `BuyCryptoService.getPendingLiquidityDemandChf` | | 75 | 2 | find | `BuyCryptoBatch` | `subdomains/core/buy-crypto/process/services/buy-crypto-batch.service.ts:284` | `BuyCryptoBatchService.filterOutExistingBatches` | -| 71 | 0 | query-builder (alias only) | `BuyFiat` | `subdomains/core/sell-crypto/process/services/buy-fiat.service.ts:341` | `BuyFiatService.getBuyFiatByKey` | +| 71 | 0 | query-builder (alias only) | `BuyFiat` | `subdomains/core/sell-crypto/process/services/buy-fiat.service.ts:340` | `BuyFiatService.getBuyFiatByKey` | | 71 | 1 | find | `Recall` | `subdomains/supporting/recall/recall.service.ts:68` | `RecallService.getByBankTxIds` | -| 71 | 0 | query-builder (alias only) | `BuyFiat` | `subdomains/core/sell-crypto/process/services/buy-fiat.service.ts:699` | `BuyFiatService.updateSellVolume` | -| 71 | 0 | query-builder (alias only) | `BuyFiat` | `subdomains/core/sell-crypto/process/services/buy-fiat.service.ts:742` | `BuyFiatService.getRefVolume` | -| 71 | 0 | query-builder (alias only) | `BuyFiat` | `subdomains/core/sell-crypto/process/services/buy-fiat.service.ts:754` | `BuyFiatService.getPartnerFeeRefVolume` | -| 71 | 0 | query-builder (alias only) | `BuyFiat` | `subdomains/core/sell-crypto/process/services/buy-fiat.service.ts:587` | `BuyFiatService.updateRefVolumes` | -| 71 | 0 | query-builder (alias only) | `BuyFiat` | `subdomains/core/sell-crypto/process/services/buy-fiat.service.ts:604` | `BuyFiatService.getUserVolume` | | 68 | 4 | find | `Swap` | `subdomains/core/buy-crypto/routes/swap/swap.service.ts:204` | `SwapService.getById` | +| 66 | 0 | query-builder (field list) | `UserData` | `subdomains/generic/user/models/user-data/user-data.repository.ts:252` | `UserDataRepository.getUserV2` | | 65 | 2 | find | `Fee` | `subdomains/supporting/payment/services/fee.service.ts:119` | `FeeService.createFee` | | 62 | 0 | find | `BankTx` | `subdomains/supporting/bank-tx/bank-tx/services/bank-tx.service.ts:593` | `BankTxService.getTrackedInternalTransfers` | | 61 | 0 | find | `BankTx` | `subdomains/core/accounting/services/consumers/exchange-tx.consumer.ts:386` | `ExchangeTxConsumer.hasBankRouteMatch` | @@ -527,9 +501,6 @@ Sorted by measured columns, largest first. `—` means not measurable, not zero. | 61 | 0 | find | `BankTx` | `subdomains/supporting/bank-tx/bank-tx/services/bank-tx.service.ts:659` | `BankTxService.getRecentExchangeTx` | | 61 | 0 | find | `BankTx` | `subdomains/supporting/bank-tx/bank-tx/services/bank-tx.service.ts:679` | `BankTxService.storeSepaFile` | | 61 | 0 | find | `BankTx` | `subdomains/supporting/dashboard/dashboard-reconciliation.service.ts:464` | `DashboardReconciliationService.getBankFlows` | -| 61 | 0 | query-builder (alias only) | `BankTx` | `subdomains/supporting/bank-tx/bank-tx/services/bank-tx.service.ts:528` | `BankTxService.getBankTxFee` | -| 61 | 0 | query-builder (alias only) | `BankTx` | `subdomains/supporting/bank-tx/bank-tx/services/bank-tx.service.ts:572` | `BankTxService.getBankTxFee` | -| 61 | 0 | query-builder (alias only) | `BankTx` | `subdomains/supporting/bank-tx/bank-tx/services/bank-tx.service.ts:516` | `BankTxService.getBankTxFee` | | 59 | 2 | find | `PaymentQuote` | `subdomains/core/payment-link/services/payment-quote.service.ts:97` | `PaymentQuoteService.getActualQuoteByUniqueId` | | 59 | 1 | find | `FiatOutput` | `subdomains/supporting/fiat-output/fiat-output-frick.service.ts:37` | `FiatOutputFrickService.checkFrickOrderStatus` | | 59 | 1 | find | `FiatOutput` | `subdomains/supporting/fiat-output/fiat-output-frick.service.ts:101` | `FiatOutputFrickService.transmitPayments` | @@ -541,13 +512,13 @@ Sorted by measured columns, largest first. `—` means not measurable, not zero. | 59 | 1 | find | `FiatOutput` | `subdomains/supporting/fiat-output/fiat-output-job.service.ts:610` | `FiatOutputJobService.getLastBatchId` | | 59 | 1 | find | `FiatOutput` | `subdomains/supporting/fiat-output/fiat-output-job.service.ts:645` | `FiatOutputJobService.notifyScryptDeposits` | | 59 | 1 | find | `FiatOutput` | `subdomains/supporting/fiat-output/fiat-output.service.ts:213` | `FiatOutputService.update` | -| 56 | 3 | find | `Buy` | `subdomains/core/buy-crypto/routes/buy/buy.service.ts:143` | `BuyService.getById` | -| 56 | 3 | find | `Buy` | `subdomains/core/buy-crypto/routes/buy/buy.service.ts:261` | `BuyService.updateBuy` | | 54 | 2 | find | `LedgerAccount` | `subdomains/core/accounting/services/ledger-query.service.ts:89` | `LedgerQueryService.getAccounts` | | 54 | 2 | find | `LedgerAccount` | `subdomains/core/accounting/services/ledger-query.service.ts:157` | `LedgerQueryService.getReconStatus` | -| 54 | 2 | find | `LedgerAccount` | `subdomains/core/accounting/services/ledger-query.service.ts:619` | `LedgerQueryService.unverifiedAccountIds` | +| 54 | 2 | find | `LedgerAccount` | `subdomains/core/accounting/services/ledger-query.service.ts:613` | `LedgerQueryService.unverifiedAccountIds` | | 54 | 2 | find | `LedgerAccount` | `subdomains/core/accounting/services/ledger-reconciliation.service.ts:146` | `LedgerReconciliationService.reconcileAssets` | | 53 | 1 | find | `PriceRule` | `subdomains/supporting/pricing/services/pricing.service.ts:163` | `PricingService.updatePrices` | +| 52 | 2 | find | `Buy` | `subdomains/core/buy-crypto/routes/buy/buy.service.ts:143` | `BuyService.getById` | +| 52 | 2 | find | `Buy` | `subdomains/core/buy-crypto/routes/buy/buy.service.ts:261` | `BuyService.updateBuy` | | 50 | 2 | find | `PaymentLinkPayment` | `subdomains/core/payment-link/services/payment-link-payment.service.ts:155` | `PaymentLinkPaymentService.processExpiredPayments` | | 50 | 2 | find | `PaymentLinkPayment` | `subdomains/core/payment-link/services/payment-link-payment.service.ts:387` | `PaymentLinkPaymentService.deliverToWaitingCallers` | | 50 | 2 | find | `PaymentLinkPayment` | `subdomains/core/payment-link/services/payment-link-payment.service.ts:615` | `PaymentLinkPaymentService.expirePaymentIfPending` | @@ -569,17 +540,12 @@ Sorted by measured columns, largest first. `—` means not measurable, not zero. | 45 | 0 | query-builder (no select) | `User` | `subdomains/generic/user/models/user/user.service.ts:178` | `UserService.getOpenRefCreditUser` | | 45 | 2 | find | `PaymentQuote` | `subdomains/core/payment-link/services/payment-quote.service.ts:127` | `PaymentQuoteService.getQuoteByAsset` | | 45 | 2 | find | `PaymentQuote` | `subdomains/core/payment-link/services/payment-quote.service.ts:146` | `PaymentQuoteService.getQuoteByTxId` | -| 45 | 0 | query-builder (alias only) | `User` | `subdomains/generic/user/models/user/user.service.ts:227` | `UserService.countRefChildrenByUserDataIds` | -| 45 | 0 | query-builder (alias only) | `User` | `subdomains/generic/user/models/user/user.service.ts:249` | `UserService.countRefReferrersByUserDataIds` | -| 45 | 0 | query-builder (alias only) | `User` | `subdomains/generic/user/models/user/user.service.ts:141` | `UserService.getAllLinkedUsers` | -| 45 | 0 | query-builder (alias only) | `User` | `subdomains/generic/user/models/user/user.service.ts:196` | `UserService.getOpenRefCreditEur` | -| 45 | 0 | query-builder (alias only) | `User` | `subdomains/generic/user/models/user/user.service.ts:601` | `UserService.getUserVolumes` | -| 45 | 0 | query-builder (alias only) | `User` | `subdomains/generic/user/models/user/user.service.ts:611` | `UserService.getUserVolumes` | -| 45 | 0 | query-builder (alias only) | `User` | `subdomains/generic/user/models/user/user.service.ts:673` | `UserService.getRefInfo` | -| 45 | 0 | query-builder (alias only) | `User` | `subdomains/generic/user/models/user/user.service.ts:686` | `UserService.getRefInfo` | -| 45 | 0 | query-builder (alias only) | `User` | `subdomains/generic/user/models/user/user.service.ts:745` | `UserService.getTotalRefRewards` | | 42 | 1 | find | `FaucetRequest` | `subdomains/core/faucet-request/services/faucet-request.service.ts:41` | `FaucetRequestService.checkFaucetRequests` | | 42 | 1 | find | `FaucetRequest` | `subdomains/core/faucet-request/services/faucet-request.service.ts:93` | `FaucetRequestService.resetFaucet` | +| 41 | 0 | query-builder (field list) | `UserData` | `subdomains/generic/user/models/user-data/user-data.repository.ts:267` | `UserDataRepository.getProfile` | +| 40 | 1 | find | `CustodyBalance` | `subdomains/core/custody/services/custody-pdf.service.ts:47` | `CustodyPdfService.getBalancesWithHistoricalPrices` | +| 40 | 1 | find | `CustodyBalance` | `subdomains/core/custody/services/custody.service.ts:111` | `CustodyService.getUserCustodyBalance` | +| 40 | 1 | find | `CustodyBalance` | `subdomains/core/custody/services/custody.service.ts:238` | `CustodyService.updateCustodyBalance` | | 40 | 1 | find | `LiquidityBalance` | `subdomains/core/liquidity-management/services/liquidity-management-balance.service.ts:38` | `LiquidityManagementBalanceService.getAllLiqBalancesForAssets` | | 40 | 1 | find | `LiquidityBalance` | `subdomains/core/liquidity-management/services/liquidity-management-balance.service.ts:68` | `LiquidityManagementBalanceService.refreshBankBalance` | | 40 | 1 | find | `LiquidityBalance` | `subdomains/core/liquidity-management/services/liquidity-management-balance.service.ts:82` | `LiquidityManagementBalanceService.getBalances` | @@ -594,8 +560,6 @@ Sorted by measured columns, largest first. `—` means not measurable, not zero. | 34 | 0 | find | `TransactionRequest` | `subdomains/supporting/payment/services/transaction-request.service.ts:239` | `TransactionRequestService.getTransactionRequest` | | 34 | 0 | find | `TransactionRequest` | `subdomains/supporting/payment/services/transaction-request.service.ts:250` | `TransactionRequestService.getWaitingTransactionRequest` | | 34 | 0 | find | `TransactionRequest` | `subdomains/supporting/payment/services/transaction-request.service.ts:347` | `TransactionRequestService.getConsumedSettlementEventIds` | -| 34 | 0 | query-builder (alias only) | `TransactionRequest` | `subdomains/supporting/payment/services/transaction-request.service.ts:358` | `TransactionRequestService.getLegacySettlementTxIds` | -| 34 | 0 | query-builder (alias only) | `TransactionRequest` | `subdomains/supporting/payment/services/transaction-request.service.ts:412` | `TransactionRequestService.getActiveDepositAddresses` | | 33 | 0 | find | `Asset` | `shared/models/asset/asset.service.ts:21` | `AssetService.updateAsset` | | 33 | 0 | find | `Asset` | `shared/models/asset/asset.service.ts:30` | `AssetService.getAssetsWith` | | 33 | 0 | find | `Asset` | `shared/models/asset/asset.service.ts:42` | `AssetService.getAllBlockchainAssets` | @@ -615,7 +579,6 @@ Sorted by measured columns, largest first. `—` means not measurable, not zero. | 33 | 0 | find | `Asset` | `shared/models/asset/asset.service.ts:169` | `AssetService.getEvmAssetsWithoutDecimals` | | 33 | 0 | find | `Asset` | `subdomains/supporting/dashboard/dashboard-reconciliation.service.ts:106` | `DashboardReconciliationService.getReconciliation` | | 33 | 0 | find | `Asset` | `subdomains/supporting/dashboard/dashboard-reconciliation.service.ts:377` | `DashboardReconciliationService.getExchangeFlows` | -| 33 | 0 | query-builder (alias only) | `Asset` | `shared/models/asset/asset.service.ts:179` | `AssetService.getAssetsUsedOn` | | 32 | 1 | find | `PaymentLinkPayment` | `subdomains/core/payment-link/services/payment-link-payment.service.ts:274` | `PaymentLinkPaymentService.getPaymentByExternalId` | | 32 | 1 | find | `PaymentLinkPayment` | `subdomains/core/payment-link/services/payment-link-payment.service.ts:280` | `PaymentLinkPaymentService.getMostRecentPayment` | | 32 | 1 | find | `PaymentLinkPayment` | `subdomains/core/payment-link/services/payment-link-payment.service.ts:415` | `PaymentLinkPaymentService.deliverToConnectedDevices` | @@ -639,7 +602,7 @@ Sorted by measured columns, largest first. `—` means not measurable, not zero. | 30 | 2 | find | `LedgerLeg` | `subdomains/core/accounting/services/consumers/exchange-tx.consumer.ts:363` | `ExchangeTxConsumer.matchRaiffeisenSweep` | | 30 | 0 | find | `ExchangeTx` | `subdomains/core/accounting/services/consumers/exchange-tx.consumer.ts:417` | `ExchangeTxConsumer.buildFillIndexMap` | | 30 | 2 | find | `LedgerTx` | `subdomains/core/accounting/services/consumers/payout-order.consumer.ts:321` | `PayoutOrderConsumer.cutoverOwedOpeningChf` | -| 30 | 2 | find | `LedgerLeg` | `subdomains/core/accounting/services/ledger-query.service.ts:349` | `LedgerQueryService.counterAccountByTxId` | +| 30 | 2 | find | `LedgerLeg` | `subdomains/core/accounting/services/ledger-query.service.ts:343` | `LedgerQueryService.counterAccountByTxId` | | 30 | 0 | find | `ExchangeTx` | `subdomains/supporting/dashboard/dashboard-reconciliation.service.ts:286` | `DashboardReconciliationService.getBlockchainFlows` | | 30 | 0 | find | `ExchangeTx` | `subdomains/supporting/dashboard/dashboard-reconciliation.service.ts:387` | `DashboardReconciliationService.getExchangeFlows` | | 30 | 0 | find | `ExchangeTx` | `subdomains/supporting/dashboard/dashboard-reconciliation.service.ts:544` | `DashboardReconciliationService.getExchangeWithdrawalsForBlockchain` | @@ -653,9 +616,6 @@ Sorted by measured columns, largest first. `—` means not measurable, not zero. | 25 | 0 | find | `CheckoutTx` | `subdomains/supporting/fiat-payin/services/checkout-tx.service.ts:53` | `CheckoutTxService.getCheckoutTx` | | 25 | 0 | find | `CheckoutTx` | `subdomains/supporting/fiat-payin/services/checkout-tx.service.ts:60` | `CheckoutTxService.getPendingRefundedList` | | 25 | 0 | find | `CheckoutTx` | `subdomains/supporting/fiat-payin/services/checkout-tx.service.ts:74` | `CheckoutTxService.getSyncDate` | -| 25 | 0 | query-builder (alias only) | `RefReward` | `subdomains/core/referral/reward/services/ref-reward.service.ts:278` | `RefRewardService.getRewardRecipients` | -| 25 | 0 | query-builder (alias only) | `RefReward` | `subdomains/core/referral/reward/services/ref-reward.service.ts:216` | `RefRewardService.getRefRewardVolume` | -| 25 | 0 | query-builder (alias only) | `RefReward` | `subdomains/core/referral/reward/services/ref-reward.service.ts:249` | `RefRewardService.updatePaidRefCredit` | | 23 | 0 | find | `Country` | `shared/models/country/country.service.ts:12` | `CountryService.getAllCountry` | | 23 | 0 | find | `Country` | `shared/models/country/country.service.ts:16` | `CountryService.getCountry` | | 23 | 0 | find | `Country` | `shared/models/country/country.service.ts:21` | `CountryService.getCountryWithSymbol` | @@ -663,58 +623,38 @@ Sorted by measured columns, largest first. `—` means not measurable, not zero. | 23 | 0 | find | `Country` | `shared/models/country/country.service.ts:28` | `CountryService.getCountriesByKycType` | | 23 | 0 | find | `Country` | `shared/models/country/country.service.ts:31` | `CountryService.getCountriesByKycType` | | 23 | 0 | find | `BankTxBatch` | `subdomains/supporting/bank-tx/bank-tx/services/bank-tx-batch.service.ts:10` | `BankTxBatchService.getBankTxBatchByIban` | -| 23 | 0 | query-builder (alias only) | `TradingOrder` | `subdomains/core/trading/services/trading-order.service.ts:53` | `TradingOrderService.getTradingOrderYield` | -| 23 | 0 | query-builder (alias only) | `TradingOrder` | `subdomains/core/trading/services/trading-rule.service.ts:35` | `TradingRuleService.getCurrentTradingOrders` | | 21 | 0 | query-builder (no select) | `DepositRoute` | `subdomains/supporting/address-pool/route/deposit-route.service.ts:87` | `DepositRouteService.getPaymentRouteForKey` | | 20 | 0 | query-builder (alias only) | `Transaction` | `subdomains/supporting/payment/services/transaction.service.ts:322` | `TransactionService.getTransactionList` | | 20 | 0 | query-builder (alias only) | `Transaction` | `subdomains/supporting/payment/services/transaction.service.ts:490` | `TransactionService.getTransactionByKey` | | 20 | 0 | query-builder (no select) | `PriceRule` | `subdomains/supporting/pricing/services/pricing.service.ts:272` | `PricingService.getRuleFor` | | 20 | 0 | query-builder (alias only) | `Sell` | `subdomains/core/sell-crypto/route/sell.service.ts:95` | `SellService.getSellByKey` | -| 20 | 0 | find | `Wallet` | `subdomains/generic/user/models/wallet/wallet.repository.ts:13` | `WalletRepository.getByAddress` | +| 20 | 0 | find | `Wallet` | `subdomains/generic/user/models/wallet/wallet.repository.ts:54` | `WalletRepository.getByAddress` | | 20 | 0 | find | `Wallet` | `subdomains/generic/user/models/wallet/wallet.service.ts:19` | `WalletService.updateWallet` | | 20 | 0 | find | `Wallet` | `subdomains/generic/user/models/wallet/wallet.service.ts:28` | `WalletService.getByAddress` | | 20 | 0 | find | `Wallet` | `subdomains/generic/user/models/wallet/wallet.service.ts:36` | `WalletService.getByIdOrName` | | 20 | 0 | find | `Wallet` | `subdomains/generic/user/models/wallet/wallet.service.ts:40` | `WalletService.getKycClients` | | 20 | 0 | find | `Wallet` | `subdomains/generic/user/models/wallet/wallet.service.ts:44` | `WalletService.getDefault` | -| 20 | 0 | query-builder (alias only) | `Sell` | `subdomains/core/sell-crypto/process/services/buy-fiat-registration.service.ts:97` | `BuyFiatRegistrationService.filterSellPayIns` | -| 20 | 0 | query-builder (alias only) | `Sell` | `subdomains/core/sell-crypto/route/sell.service.ts:261` | `SellService.getUserVolume` | -| 20 | 0 | query-builder (alias only) | `Transaction` | `subdomains/supporting/payment/services/transaction.service.ts:425` | `TransactionService.getManualRefVolume` | -| 20 | 0 | query-builder (alias only) | `Transaction` | `subdomains/supporting/payment/services/transaction.service.ts:454` | `TransactionService.getAuditPeriodVolumes` | -| 20 | 0 | query-builder (alias only) | `Sell` | `subdomains/core/sell-crypto/route/sell.service.ts:271` | `SellService.getTotalVolume` | -| 19 | 0 | query-builder (no select) | `CustodyOrder` | `subdomains/core/custody/services/custody-order.service.ts:237` | `CustodyOrderService.getOrdersByUserData` | | 19 | 0 | query-builder (no select) | `Swap` | `subdomains/core/buy-crypto/routes/swap/swap.service.ts:79` | `SwapService.getSwapByAddress` | | 19 | 0 | query-builder (alias only) | `Swap` | `subdomains/core/buy-crypto/routes/swap/swap.service.ts:152` | `SwapService.getSwapByKey` | -| 19 | 0 | query-builder (alias only) | `Swap` | `subdomains/core/buy-crypto/routes/swap/swap.service.ts:123` | `SwapService.getUserVolume` | -| 19 | 0 | query-builder (alias only) | `CustodyOrder` | `subdomains/core/custody/services/custody.service.ts:677` | `CustodyService.getHistoricalBalances` | -| 19 | 0 | query-builder (alias only) | `CustodyOrder` | `subdomains/core/custody/services/custody.service.ts:689` | `CustodyService.getHistoricalBalances` | -| 19 | 0 | query-builder (alias only) | `Swap` | `subdomains/core/buy-crypto/routes/swap/swap.service.ts:133` | `SwapService.getTotalVolume` | -| 19 | 0 | query-builder (alias only) | `CustodyOrder` | `subdomains/core/custody/services/custody.service.ts:221` | `CustodyService.updateCustodyBalance` | -| 19 | 0 | query-builder (alias only) | `CustodyOrder` | `subdomains/core/custody/services/custody.service.ts:229` | `CustodyService.updateCustodyBalance` | | 17 | 0 | find | `KycLog` | `subdomains/generic/kyc/services/kyc-log.service.ts:83` | `KycLogService.updateLog` | | 17 | 0 | find | `KycLog` | `subdomains/generic/kyc/services/kyc-log.service.ts:90` | `KycLogService.updateLogPdfUrl` | | 17 | 0 | find | `KycLog` | `subdomains/generic/kyc/services/kyc-log.service.ts:109` | `KycLogService.getLogsByUserDataId` | -| 16 | 0 | query-builder (no select) | `SupportIssue` | `subdomains/supporting/support-issue/services/support-issue.service.ts:549` | `SupportIssueService.getSupportIssueList` | | 16 | 0 | query-builder (alias only) | `VirtualIban` | `subdomains/supporting/bank/virtual-iban/virtual-iban.service.ts:1416` | `VirtualIbanService.getVirtualIbanByKey` | -| 16 | 0 | query-builder (alias only) | `PaymentLinkPayment` | `subdomains/core/payment-link/services/payment-link-payment.service.ts:297` | `PaymentLinkPaymentService.getMostRecentPayments` | +| 16 | 0 | query-builder (projected, full join) | `PaymentLinkPayment` | `subdomains/core/payment-link/services/payment-link-payment.service.ts:297` | `PaymentLinkPaymentService.getMostRecentPayments` | | 16 | 0 | find | `Fiat` | `shared/models/fiat/fiat.service.ts:12` | `FiatService.getAllFiat` | | 16 | 0 | find | `Fiat` | `shared/models/fiat/fiat.service.ts:16` | `FiatService.getActiveFiat` | | 16 | 0 | find | `Fiat` | `shared/models/fiat/fiat.service.ts:27` | `FiatService.getFiat` | | 16 | 0 | find | `Fiat` | `shared/models/fiat/fiat.service.ts:33` | `FiatService.getFiatByName` | | 16 | 0 | find | `Fiat` | `shared/models/fiat/fiat.service.ts:43` | `FiatService.getFiatByCountry` | -| 16 | 0 | query-builder (alias only) | `SupportIssue` | `subdomains/supporting/support-issue/services/support-issue.service.ts:245` | `SupportIssueService.getSupportIssueStatistics` | -| 16 | 0 | query-builder (alias only) | `SupportIssue` | `subdomains/supporting/support-issue/services/support-issue.service.ts:109` | `SupportIssueService.getSupportIssueCounts` | -| 16 | 0 | query-builder (alias only) | `SupportIssue` | `subdomains/supporting/support-issue/services/support-issue.service.ts:198` | `SupportIssueService.getSupportIssueStatistics` | -| 16 | 0 | query-builder (alias only) | `SupportIssue` | `subdomains/supporting/support-issue/services/support-issue.service.ts:177` | `SupportIssueService.getSupportIssueStatistics` | | 15 | 0 | query-builder (alias only) | `BankData` | `subdomains/generic/user/models/bank-data/bank-data.service.ts:288` | `BankDataService.getBankDataByKey` | | 15 | 0 | find | `WalletApp` | `subdomains/core/payment-link/services/wallet-app.service.ts:20` | `WalletAppService.getAllBlockchainWalletApps` | | 15 | 0 | find | `WalletApp` | `subdomains/core/payment-link/services/wallet-app.service.ts:24` | `WalletAppService.getRecommendedWalletApps` | | 15 | 0 | find | `WalletApp` | `subdomains/core/payment-link/services/wallet-app.service.ts:28` | `WalletAppService.getWalletAppById` | | 15 | 0 | find | `AktionariatRegistration` | `subdomains/supporting/realunit/realunit.service.ts:2831` | `RealUnitService.getRegisteredWalletAddresses` | -| 15 | 0 | query-builder (alias only) | `BankData` | `subdomains/generic/user/models/bank-data/bank-data.service.ts:497` | `BankDataService.getPendingReviewSummary` | | 14 | 0 | find | `ScorechainScreening` | `integration/scorechain/repositories/scorechain-screening.repository.ts:18` | `ScorechainScreeningRepository.getByObjectIds` | | 14 | 0 | find | `ScorechainScreening` | `integration/scorechain/services/scorechain-screening.service.ts:233` | `ScorechainScreeningService.getCached` | -| 14 | 0 | query-builder (alias only) | `Recommendation` | `subdomains/generic/user/models/recommendation/recommendation.service.ts:354` | `RecommendationService.countByRecommenderIds` | -| 14 | 0 | query-builder (alias only) | `Recommendation` | `subdomains/generic/user/models/recommendation/recommendation.service.ts:369` | `RecommendationService.countByRecommendedIds` | +| 14 | 0 | query-builder (field list) | `CustodyOrder` | `subdomains/core/custody/repositories/custody-order.repository.ts:61` | `CustodyOrderRepository.findHistoryFor` | +| 14 | 0 | query-builder (field list) | `BuyFiat` | `subdomains/core/sell-crypto/process/buy-fiat.repository.ts:57` | `BuyFiatRepository.findSellHistory` | | 13 | 0 | query-builder (alias only) | `Buy` | `subdomains/core/buy-crypto/routes/buy/buy.service.ts:231` | `BuyService.getBuyByKey` | | 13 | 0 | find | `PaymentQuote` | `subdomains/core/payment-link/services/payment-quote.service.ts:79` | `PaymentQuoteService.processExpiredQuotes` | | 13 | 0 | find | `PaymentQuote` | `subdomains/core/payment-link/services/payment-quote.service.ts:112` | `PaymentQuoteService.getActualQuoteByPaymentId` | @@ -736,56 +676,33 @@ Sorted by measured columns, largest first. `—` means not measurable, not zero. | 13 | 0 | find | `Notification` | `subdomains/supporting/notification/services/notification.service.ts:49` | `NotificationService.getMails` | | 13 | 0 | find | `Notification` | `subdomains/supporting/notification/services/notification.service.ts:107` | `NotificationService.isSuppressed` | | 13 | 0 | find | `TransactionRiskAssessment` | `subdomains/supporting/payment/services/transaction-risk-assessment.service.ts:20` | `TransactionRiskAssessmentService.update` | -| 13 | 0 | query-builder (alias only) | `Buy` | `subdomains/core/buy-crypto/routes/buy/buy.service.ts:104` | `BuyService.getUserVolume` | -| 13 | 0 | query-builder (alias only) | `KycStep` | `subdomains/generic/kyc/services/kyc.service.ts:1989` | `KycService.getPendingReviewSummary` | -| 13 | 0 | query-builder (alias only) | `Buy` | `subdomains/core/buy-crypto/routes/buy/buy.service.ts:114` | `BuyService.getTotalVolume` | | 12 | 0 | find | `IpLog` | `shared/models/ip-log/ip-log.service.ts:66` | `IpLogService.getByUserDataId` | | 12 | 0 | find | `IpLog` | `shared/models/ip-log/ip-log.service.ts:75` | `IpLogService.getLoginCountries` | | 12 | 0 | find | `IpLog` | `shared/models/ip-log/ip-log.service.ts:119` | `IpLogService.updateUserIpLogs` | -| 12 | 0 | query-builder (alias only) | `IpLog` | `shared/models/ip-log/ip-log.service.ts:79` | `IpLogService.getLoginCountries` | -| 12 | 0 | query-builder (alias only) | `IpLog` | `shared/models/ip-log/ip-log.service.ts:92` | `IpLogService.getUserDataIdsWith` | -| 12 | 0 | query-builder (alias only) | `IpLog` | `shared/models/ip-log/ip-log.service.ts:103` | `IpLogService.getUserDataIdsWith` | +| 12 | 0 | query-builder (field list) | `BuyCrypto` | `subdomains/core/buy-crypto/process/repositories/buy-crypto.repository.ts:77` | `BuyCryptoRepository.findBuyHistory` | +| 12 | 0 | query-builder (field list) | `BuyCrypto` | `subdomains/core/buy-crypto/process/repositories/buy-crypto.repository.ts:91` | `BuyCryptoRepository.findSwapHistory` | | 11 | 0 | find | `OlkyRecipient` | `integration/bank/services/olkypay.service.ts:104` | `OlkypayService.getOrCreateRecipient` | | 11 | 0 | query-builder (no select) | `LedgerLeg` | `subdomains/core/accounting/services/ledger-query.service.ts:125` | `LedgerQueryService.getAccountDetail` | -| 11 | 0 | query-builder (no select) | `LedgerLeg` | `subdomains/core/accounting/services/ledger-query.service.ts:184` | `LedgerQueryService.getSuspense` | | 11 | 0 | find | `Log` | `subdomains/supporting/log/log.repository.ts:119` | `LogRepository.getFinancialLogAt` | | 11 | 0 | find | `Log` | `subdomains/supporting/log/log.repository.ts:132` | `LogRepository.getLatestFinancialLog` | | 11 | 0 | find | `Log` | `subdomains/supporting/log/log.repository.ts:142` | `LogRepository.getLatestValidFinancialLogs` | | 11 | 0 | find | `Log` | `subdomains/supporting/log/log.repository.ts:150` | `LogRepository.getLatestFinancialChangesLog` | -| 11 | 0 | query-builder (alias only) | `Log` | `subdomains/supporting/log/log.repository.ts:165` | `LogRepository.getFinancialChangesLogs` | | 11 | 0 | find | `Log` | `subdomains/supporting/log/log.repository.ts:187` | `LogRepository.getFinancialChangesLogs` | -| 11 | 0 | query-builder (alias only) | `Log` | `subdomains/supporting/log/log.repository.ts:214` | `LogRepository.getFinancialLogs` | -| 11 | 0 | query-builder (no select) | `Log` | `subdomains/supporting/log/log.repository.ts:244` | `LogRepository.getFinancialLogs` | | 11 | 0 | find | `Log` | `subdomains/supporting/log/log.service.ts:51` | `LogService.update` | | 11 | 0 | find | `Log` | `subdomains/supporting/log/log.service.ts:131` | `LogService.getLog` | | 11 | 0 | find | `Log` | `subdomains/supporting/log/log.service.ts:135` | `LogService.maxEntity` | | 11 | 0 | query-builder (no select) | `Log` | `subdomains/supporting/log/log.service.ts:191` | `LogService.getBankLog` | -| 11 | 0 | query-builder (alias only) | `LedgerLeg` | `subdomains/core/accounting/services/ledger-mark-to-market.service.ts:173` | `LedgerMarkToMarketService.accountBalance` | -| 11 | 0 | query-builder (alias only) | `LedgerLeg` | `subdomains/core/accounting/services/ledger-reconciliation.service.ts:517` | `LedgerReconciliationService.nativeBalanceByAccount` | -| 11 | 0 | query-builder (no select) | `LedgerLeg` | `subdomains/core/accounting/services/ledger-query.service.ts:466` | `LedgerQueryService.marginBuckets` | -| 11 | 0 | query-builder (alias only) | `LedgerLeg` | `subdomains/core/accounting/services/ledger-query.service.ts:540` | `LedgerQueryService.cumulativeEquityByDay` | -| 11 | 0 | query-builder (alias only) | `LedgerLeg` | `subdomains/core/accounting/services/ledger-query.service.ts:284` | `LedgerQueryService.balancesByAccount` | -| 11 | 0 | query-builder (alias only) | `LedgerLeg` | `subdomains/core/accounting/services/ledger-reconciliation.service.ts:271` | `LedgerReconciliationService.checkTransitAge` | -| 11 | 0 | query-builder (alias only) | `LedgerLeg` | `subdomains/core/accounting/services/ledger-reconciliation.service.ts:347` | `LedgerReconciliationService.openResidualSince` | -| 11 | 0 | query-builder (alias only) | `LedgerLeg` | `subdomains/core/accounting/services/ledger-query.service.ts:326` | `LedgerQueryService.nativeBalanceByAccount` | -| 11 | 0 | query-builder (alias only) | `LedgerLeg` | `subdomains/core/accounting/services/ledger-reconciliation.service.ts:400` | `LedgerReconciliationService.checkSuspense` | -| 11 | 0 | query-builder (alias only) | `LedgerLeg` | `subdomains/core/accounting/services/ledger-mark-to-market.service.ts:106` | `LedgerMarkToMarketService.selectCandidates` | -| 11 | 0 | query-builder (alias only) | `LedgerLeg` | `subdomains/core/accounting/services/ledger-query.service.ts:298` | `LedgerQueryService.nativeBalanceBefore` | -| 11 | 0 | query-builder (alias only) | `LedgerLeg` | `subdomains/core/accounting/services/ledger-query.service.ts:310` | `LedgerQueryService.nativeBalanceInPeriod` | -| 11 | 0 | query-builder (alias only) | `LedgerLeg` | `subdomains/core/accounting/services/ledger-reconciliation.service.ts:487` | `LedgerReconciliationService.journalEquity` | -| 11 | 0 | query-builder (alias only) | `Log` | `subdomains/supporting/log/log.repository.ts:97` | `LogRepository.cleanup` | -| 11 | 0 | query-builder (alias only) | `Log` | `subdomains/supporting/log/log.repository.ts:104` | `LogRepository.cleanup` | -| 11 | 0 | query-builder (no select) | `Log` | `subdomains/supporting/log/log.repository.ts:158` | `LogRepository.getFinancialChangesLogs` | -| 11 | 0 | query-builder (no select) | `Log` | `subdomains/supporting/log/log.repository.ts:206` | `LogRepository.getFinancialLogs` | -| 11 | 0 | query-builder (no select) | `LedgerLeg` | `subdomains/core/accounting/services/ledger-mark-to-market.service.ts:212` | `LedgerMarkToMarketService.alreadyBooked` | -| 11 | 0 | query-builder (no select) | `Log` | `subdomains/supporting/log/log.repository.ts:688` | `LogRepository.assertEmptyResultIsEndOfData` | +| 11 | 0 | query-builder (field list) | `SupportIssue` | `subdomains/supporting/support-issue/repositories/support-issue.repository.ts:351` | `SupportIssueRepository.findIssuesForAccount` | +| 11 | 0 | query-builder (field list) | `SupportIssue` | `subdomains/supporting/support-issue/repositories/support-issue.repository.ts:369` | `SupportIssueRepository.findIssueBy` | +| 10 | 0 | query-builder (field list) | `LedgerLeg` | `subdomains/core/accounting/repositories/ledger-leg.repository.ts:54` | `LedgerLegRepository.findSuspenseLegs` | | 10 | 0 | find | `AccountMerge` | `subdomains/generic/user/models/account-merge/account-merge.service.ts:61` | `AccountMergeService.sendMergeRequest` | | 10 | 0 | find | `AccountMerge` | `subdomains/generic/user/models/account-merge/account-merge.service.ts:162` | `AccountMergeService.pendingMergeRequest` | +| 10 | 0 | query-builder (field list) | `SupportIssue` | `subdomains/supporting/support-issue/repositories/support-issue.repository.ts:276` | `SupportIssueRepository.findIssueList` | | 9 | 0 | find | `SupportNote` | `subdomains/generic/support/services/support-note.service.ts:57` | `SupportNoteService.search` | | 9 | 0 | find | `SupportNote` | `subdomains/generic/support/services/support-note.service.ts:76` | `SupportNoteService.search` | | 9 | 0 | find | `SupportNote` | `subdomains/generic/support/services/support-note.service.ts:152` | `SupportNoteService.delete` | +| 9 | 0 | query-builder (named columns) | `—` | `subdomains/generic/user/models/user-data/user-data.service.ts:1252` | `UserDataService.updateVolumes` | | 9 | 0 | find | `TransactionSpecification` | `subdomains/supporting/payment/services/transaction-helper.ts:92` | `TransactionHelper.updateCache` | -| 9 | 0 | query-builder (alias only) | `SupportNote` | `subdomains/generic/support/services/support-note.service.ts:84` | `SupportNoteService.listUsers` | | 8 | 0 | find | `LedgerAccount` | `subdomains/core/accounting/services/ledger-account.service.ts:12` | `LedgerAccountService.findByName` | | 8 | 0 | find | `LedgerAccount` | `subdomains/core/accounting/services/ledger-account.service.ts:16` | `LedgerAccountService.findByAssetId` | | 8 | 0 | find | `LedgerAccount` | `subdomains/core/accounting/services/ledger-mark-to-market.service.ts:122` | `LedgerMarkToMarketService.selectCandidates` | @@ -800,17 +717,17 @@ Sorted by measured columns, largest first. `—` means not measurable, not zero. | 7 | 0 | find | `Language` | `shared/models/language/language.service.ts:15` | `LanguageService.getLanguage` | | 7 | 0 | find | `Language` | `shared/models/language/language.service.ts:19` | `LanguageService.getLanguageBySymbol` | | 7 | 0 | find | `Language` | `shared/models/language/language.service.ts:24` | `LanguageService.getLanguageByCountry` | +| 7 | 0 | query-builder (field list) | `PaymentLink` | `subdomains/core/payment-link/repositories/payment-link.repository.ts:59` | `PaymentLinkRepository.findForPosLink` | | 7 | 0 | find | `UserDataRelation` | `subdomains/generic/user/models/user-data-relation/user-data-relation.service.ts:40` | `UserDataRelationService.updateUserDataRelation` | +| 7 | 0 | query-builder (field list) | `Wallet` | `subdomains/generic/user/models/wallet/wallet.repository.ts:48` | `WalletRepository.findKycData` | | 7 | 0 | find | `SpecialExternalAccount` | `subdomains/supporting/payment/services/special-external-account.service.ts:12` | `SpecialExternalAccountService.createSpecialExternalAccount` | | 7 | 0 | find | `SpecialExternalAccount` | `subdomains/supporting/payment/services/special-external-account.service.ts:24` | `SpecialExternalAccountService.getMultiAccounts` | | 7 | 0 | find | `SpecialExternalAccount` | `subdomains/supporting/payment/services/special-external-account.service.ts:48` | `SpecialExternalAccountService.getPhoneCallList` | | 7 | 0 | find | `SpecialExternalAccount` | `subdomains/supporting/payment/services/special-external-account.service.ts:58` | `SpecialExternalAccountService.getBlacklist` | | 7 | 0 | find | `AssetPrice` | `subdomains/supporting/pricing/services/asset-prices-job.service.ts:81` | `AssetPricesJobService.saveAssetPrices` | | 7 | 0 | find | `RealUnitLegalAcceptance` | `subdomains/supporting/realunit/realunit-legal.service.ts:57` | `RealUnitLegalService.getLatestAcceptance` | -| 7 | 0 | query-builder (alias only) | `SupportMessage` | `subdomains/supporting/support-issue/services/support-issue.service.ts:614` | `SupportIssueService.getMessageStats` | -| 7 | 0 | query-builder (alias only) | `SupportMessage` | `subdomains/supporting/support-issue/services/support-escalation.service.ts:310` | `SupportEscalationService.getLastMessages` | -| 7 | 0 | query-builder (alias only) | `SupportMessage` | `subdomains/supporting/support-issue/services/support-issue.service.ts:133` | `SupportIssueService.getSupportIssueActivity` | -| 7 | 0 | query-builder (alias only) | `SupportMessage` | `subdomains/supporting/support-issue/services/support-issue.service.ts:186` | `SupportIssueService.getSupportIssueStatistics` | +| 6 | 0 | query-builder (named columns) | `LedgerLeg` | `subdomains/core/accounting/services/ledger-mark-to-market.service.ts:173` | `LedgerMarkToMarketService.accountBalance` | +| 6 | 0 | query-builder (named columns) | `LedgerLeg` | `subdomains/core/accounting/services/ledger-reconciliation.service.ts:517` | `LedgerReconciliationService.nativeBalanceByAccount` | | 6 | 0 | find | `Ref` | `subdomains/core/referral/process/ref.repository.ts:13` | `RefRepository.getAndRemove` | | 6 | 0 | find | `Ref` | `subdomains/core/referral/process/ref.service.ts:23` | `RefService.checkRefs` | | 6 | 0 | find | `Ref` | `subdomains/core/referral/process/ref.service.ts:29` | `RefService.addOrUpdate` | @@ -823,7 +740,6 @@ Sorted by measured columns, largest first. `—` means not measurable, not zero. | 6 | 0 | find | `Deposit` | `subdomains/supporting/address-pool/deposit/deposit.service.ts:79` | `DepositService.getDepositByBlockchainAndIndex` | | 6 | 0 | find | `Deposit` | `subdomains/supporting/address-pool/deposit/deposit.service.ts:83` | `DepositService.getUsedDepositsByBlockchain` | | 6 | 0 | query-builder (no select) | `Deposit` | `subdomains/supporting/address-pool/deposit/deposit.service.ts:89` | `DepositService.getNextDeposit` | -| 6 | 0 | query-builder (alias only) | `Deposit` | `subdomains/supporting/address-pool/deposit/deposit.service.ts:189` | `DepositService.getNextDepositIndex` | | 5 | 0 | find | `Setting` | `shared/models/setting/setting.repository.ts:36` | `SettingRepository.getStatusSettings` | | 5 | 0 | find | `Setting` | `shared/models/setting/setting.service.ts:22` | `SettingService.getAll` | | 5 | 0 | find | `Setting` | `shared/models/setting/setting.service.ts:32` | `SettingService.get` | @@ -832,20 +748,106 @@ Sorted by measured columns, largest first. `—` means not measurable, not zero. | 5 | 0 | find | `Setting` | `shared/models/setting/setting.service.ts:226` | `SettingService.getObjCached` | | 5 | 0 | find | `Setting` | `shared/models/setting/setting.service.ts:230` | `SettingService.setObj` | | 5 | 0 | find | `Sanction` | `subdomains/core/aml/services/sanction.service.ts:54` | `SanctionService.syncList` | +| 5 | 0 | query-builder (named columns) | `SupportNote` | `subdomains/generic/support/services/support-note.service.ts:84` | `SupportNoteService.listUsers` | +| 5 | 0 | query-builder (field list) | `SupportMessage` | `subdomains/supporting/support-issue/repositories/support-message.repository.ts:60` | `SupportMessageRepository.findThread` | +| 4 | 0 | query-builder (named columns) | `LedgerLeg` | `subdomains/core/accounting/services/ledger-query.service.ts:460` | `LedgerQueryService.marginBuckets` | +| 4 | 0 | query-builder (named columns) | `LedgerLeg` | `subdomains/core/accounting/services/ledger-query.service.ts:534` | `LedgerQueryService.cumulativeEquityByDay` | | 4 | 0 | find | `SystemStateSnapshot` | `subdomains/core/monitoring/monitoring.service.ts:237` | `MonitoringService.readState` | +| 3 | 0 | query-builder (named columns) | `LedgerLeg` | `subdomains/core/accounting/services/ledger-query.service.ts:278` | `LedgerQueryService.balancesByAccount` | +| 3 | 0 | query-builder (named columns) | `LedgerLeg` | `subdomains/core/accounting/services/ledger-reconciliation.service.ts:271` | `LedgerReconciliationService.checkTransitAge` | +| 3 | 0 | query-builder (named columns) | `LedgerLeg` | `subdomains/core/accounting/services/ledger-reconciliation.service.ts:347` | `LedgerReconciliationService.openResidualSince` | +| 3 | 0 | query-builder (named columns) | `BuyCrypto` | `subdomains/core/buy-crypto/process/services/buy-crypto.service.ts:1435` | `BuyCryptoService.updateBuyVolume` | +| 3 | 0 | query-builder (named columns) | `BuyCrypto` | `subdomains/core/buy-crypto/process/services/buy-crypto.service.ts:1463` | `BuyCryptoService.updateCryptoRouteVolume` | +| 3 | 0 | query-builder (named columns) | `Buy` | `subdomains/core/buy-crypto/routes/buy/buy.service.ts:104` | `BuyService.getUserVolume` | +| 3 | 0 | query-builder (named columns) | `Swap` | `subdomains/core/buy-crypto/routes/swap/swap.service.ts:123` | `SwapService.getUserVolume` | +| 3 | 0 | query-builder (named columns) | `CustodyOrder` | `subdomains/core/custody/services/custody.service.ts:677` | `CustodyService.getHistoricalBalances` | +| 3 | 0 | query-builder (named columns) | `CustodyOrder` | `subdomains/core/custody/services/custody.service.ts:689` | `CustodyService.getHistoricalBalances` | +| 3 | 0 | query-builder (named columns) | `Sell` | `subdomains/core/sell-crypto/process/services/buy-fiat-registration.service.ts:97` | `BuyFiatRegistrationService.filterSellPayIns` | +| 3 | 0 | query-builder (named columns) | `BuyFiat` | `subdomains/core/sell-crypto/process/services/buy-fiat.service.ts:678` | `BuyFiatService.updateSellVolume` | +| 3 | 0 | query-builder (named columns) | `Sell` | `subdomains/core/sell-crypto/route/sell.service.ts:261` | `SellService.getUserVolume` | +| 3 | 0 | query-builder (named columns) | `KycStep` | `subdomains/generic/kyc/services/kyc.service.ts:1989` | `KycService.getPendingReviewSummary` | +| 3 | 0 | query-builder (field list) | `UserData` | `subdomains/generic/user/models/user-data/user-data.repository.ts:240` | `UserDataRepository.getForApiKey` | +| 3 | 0 | query-builder (named columns) | `SupportMessage` | `subdomains/supporting/support-issue/services/support-escalation.service.ts:310` | `SupportEscalationService.getLastMessages` | +| 3 | 0 | query-builder (named columns) | `RefReward` | `subdomains/core/referral/reward/services/ref-reward.service.ts:278` | `RefRewardService.getRewardRecipients` | +| 3 | 0 | query-builder (named columns) | `BankTx` | `subdomains/supporting/bank-tx/bank-tx/services/bank-tx.service.ts:572` | `BankTxService.getBankTxFee` | +| 3 | 0 | query-builder (named columns) | `SupportIssue` | `subdomains/supporting/support-issue/services/support-issue.service.ts:177` | `SupportIssueService.getSupportIssueStatistics` | +| 2 | 0 | query-builder (named columns) | `LedgerLeg` | `subdomains/core/accounting/services/ledger-query.service.ts:320` | `LedgerQueryService.nativeBalanceByAccount` | +| 2 | 0 | query-builder (named columns) | `LedgerLeg` | `subdomains/core/accounting/services/ledger-reconciliation.service.ts:400` | `LedgerReconciliationService.checkSuspense` | +| 2 | 0 | query-builder (named columns) | `BuyCrypto` | `subdomains/core/buy-crypto/process/services/buy-crypto.service.ts:1506` | `BuyCryptoService.getRefVolume` | +| 2 | 0 | query-builder (named columns) | `BuyCrypto` | `subdomains/core/buy-crypto/process/services/buy-crypto.service.ts:1518` | `BuyCryptoService.getPartnerFeeRefVolume` | +| 2 | 0 | query-builder (field list) | `LiquidityManagementPipeline` | `subdomains/core/liquidity-management/repositories/liquidity-management-pipeline.repository.ts:43` | `LiquidityManagementPipelineRepository.findForStatus` | +| 2 | 0 | query-builder (named columns) | `—` | `subdomains/core/monitoring/observers/payment.observer.ts:72` | `PaymentObserver.getPayment` | +| 2 | 0 | query-builder (named columns) | `BuyFiat` | `subdomains/core/sell-crypto/process/services/buy-fiat.service.ts:721` | `BuyFiatService.getRefVolume` | +| 2 | 0 | query-builder (named columns) | `BuyFiat` | `subdomains/core/sell-crypto/process/services/buy-fiat.service.ts:733` | `BuyFiatService.getPartnerFeeRefVolume` | +| 2 | 0 | query-builder (named columns) | `TradingOrder` | `subdomains/core/trading/services/trading-order.service.ts:53` | `TradingOrderService.getTradingOrderYield` | +| 2 | 0 | query-builder (named columns) | `—` | `subdomains/generic/gs/gs.service.ts:868` | `GsService.getExtendedBankTxData` | +| 2 | 0 | query-builder (named columns) | `—` | `subdomains/generic/gs/gs.service.ts:887` | `GsService.getExtendedBankTxData` | +| 2 | 0 | query-builder (named columns) | `BankData` | `subdomains/generic/user/models/bank-data/bank-data.service.ts:497` | `BankDataService.getPendingReviewSummary` | +| 2 | 0 | query-builder (named columns) | `Recommendation` | `subdomains/generic/user/models/recommendation/recommendation.service.ts:354` | `RecommendationService.countByRecommenderIds` | +| 2 | 0 | query-builder (named columns) | `Recommendation` | `subdomains/generic/user/models/recommendation/recommendation.service.ts:369` | `RecommendationService.countByRecommendedIds` | +| 2 | 0 | query-builder (field list) | `User` | `subdomains/generic/user/models/user/user.repository.ts:47` | `UserRepository.findAccountIdForAddress` | +| 2 | 0 | query-builder (named columns) | `User` | `subdomains/generic/user/models/user/user.service.ts:227` | `UserService.countRefChildrenByUserDataIds` | +| 2 | 0 | query-builder (named columns) | `User` | `subdomains/generic/user/models/user/user.service.ts:249` | `UserService.countRefReferrersByUserDataIds` | | 2 | 0 | query-builder (field list) | `Log` | `subdomains/supporting/log/log.repository.ts:699` | `LogRepository.getFinancialLogValidityChangeSet` | +| 2 | 0 | query-builder (named columns) | `Transaction` | `subdomains/supporting/payment/services/transaction.service.ts:425` | `TransactionService.getManualRefVolume` | +| 2 | 0 | query-builder (named columns) | `Transaction` | `subdomains/supporting/payment/services/transaction.service.ts:454` | `TransactionService.getAuditPeriodVolumes` | +| 2 | 0 | query-builder (named columns) | `SupportMessage` | `subdomains/supporting/support-issue/repositories/support-message.repository.ts:97` | `SupportMessageRepository.findStatsFor` | +| 2 | 0 | query-builder (named columns) | `SupportIssue` | `subdomains/supporting/support-issue/services/support-issue.service.ts:109` | `SupportIssueService.getSupportIssueCounts` | +| 2 | 0 | query-builder (named columns) | `SupportMessage` | `subdomains/supporting/support-issue/services/support-issue.service.ts:133` | `SupportIssueService.getSupportIssueActivity` | +| 2 | 0 | query-builder (named columns) | `SupportIssue` | `subdomains/supporting/support-issue/services/support-issue.service.ts:198` | `SupportIssueService.getSupportIssueStatistics` | +| 2 | 0 | query-builder (named columns) | `BankTx` | `subdomains/supporting/bank-tx/bank-tx/services/bank-tx.service.ts:516` | `BankTxService.getBankTxFee` | +| 1 | 0 | query-builder (named columns) | `Log` | `subdomains/supporting/log/log.repository.ts:165` | `LogRepository.getFinancialChangesLogs` | +| 1 | 0 | query-builder (named columns) | `Log` | `subdomains/supporting/log/log.repository.ts:214` | `LogRepository.getFinancialLogs` | +| 1 | 0 | query-builder (no select) | `Log` | `subdomains/supporting/log/log.repository.ts:244` | `LogRepository.getFinancialLogs` | +| 1 | 0 | query-builder (named columns) | `BankTx` | `subdomains/supporting/bank-tx/bank-tx/services/bank-tx.service.ts:528` | `BankTxService.getBankTxFee` | +| 1 | 0 | query-builder (named columns) | `SupportIssue` | `subdomains/supporting/support-issue/services/support-issue.service.ts:245` | `SupportIssueService.getSupportIssueStatistics` | +| 1 | 0 | query-builder (named columns) | `Asset` | `shared/models/asset/asset.service.ts:179` | `AssetService.getAssetsUsedOn` | +| 1 | 0 | query-builder (named columns) | `IpLog` | `shared/models/ip-log/ip-log.service.ts:79` | `IpLogService.getLoginCountries` | +| 1 | 0 | query-builder (named columns) | `IpLog` | `shared/models/ip-log/ip-log.service.ts:92` | `IpLogService.getUserDataIdsWith` | +| 1 | 0 | query-builder (named columns) | `IpLog` | `shared/models/ip-log/ip-log.service.ts:103` | `IpLogService.getUserDataIdsWith` | +| 1 | 0 | query-builder (named columns) | `—` | `subdomains/core/accounting/services/ledger-booking.service.ts:335` | `LedgerBookingService.nextSeqFrom` | +| 1 | 0 | query-builder (named columns) | `—` | `subdomains/core/accounting/services/ledger-cutover.service.ts:958` | `LedgerCutoverService.maxSettledId` | +| 1 | 0 | query-builder (named columns) | `—` | `subdomains/core/accounting/services/ledger-cutover.service.ts:1002` | `LedgerCutoverService.idsUpToBoundary` | +| 1 | 0 | query-builder (named columns) | `LedgerLeg` | `subdomains/core/accounting/services/ledger-mark-to-market.service.ts:106` | `LedgerMarkToMarketService.selectCandidates` | +| 1 | 0 | query-builder (named columns) | `LedgerLeg` | `subdomains/core/accounting/services/ledger-query.service.ts:292` | `LedgerQueryService.nativeBalanceBefore` | +| 1 | 0 | query-builder (named columns) | `LedgerLeg` | `subdomains/core/accounting/services/ledger-query.service.ts:304` | `LedgerQueryService.nativeBalanceInPeriod` | +| 1 | 0 | query-builder (named columns) | `LedgerLeg` | `subdomains/core/accounting/services/ledger-reconciliation.service.ts:487` | `LedgerReconciliationService.journalEquity` | +| 1 | 0 | query-builder (named columns) | `BuyCrypto` | `subdomains/core/buy-crypto/process/services/buy-crypto.service.ts:980` | `BuyCryptoService.updateRefVolumes` | +| 1 | 0 | query-builder (named columns) | `BuyCrypto` | `subdomains/core/buy-crypto/process/services/buy-crypto.service.ts:1267` | `BuyCryptoService.getUserVolumeForType` | +| 1 | 0 | query-builder (named columns) | `BuyCrypto` | `subdomains/core/buy-crypto/process/services/buy-crypto.service.ts:1339` | `BuyCryptoService.getPendingLiquidityDemandChf` | +| 1 | 0 | query-builder (named columns) | `Buy` | `subdomains/core/buy-crypto/routes/buy/buy.service.ts:114` | `BuyService.getTotalVolume` | +| 1 | 0 | query-builder (named columns) | `Swap` | `subdomains/core/buy-crypto/routes/swap/swap.service.ts:133` | `SwapService.getTotalVolume` | +| 1 | 0 | query-builder (named columns) | `CustodyOrder` | `subdomains/core/custody/services/custody.service.ts:221` | `CustodyService.updateCustodyBalance` | +| 1 | 0 | query-builder (named columns) | `CustodyOrder` | `subdomains/core/custody/services/custody.service.ts:229` | `CustodyService.updateCustodyBalance` | +| 1 | 0 | query-builder (named columns) | `—` | `subdomains/core/monitoring/observers/bank.observer.ts:117` | `BankObserver.getDbBalance` | +| 1 | 0 | query-builder (named columns) | `RefReward` | `subdomains/core/referral/reward/services/ref-reward.service.ts:216` | `RefRewardService.getRefRewardVolume` | +| 1 | 0 | query-builder (named columns) | `RefReward` | `subdomains/core/referral/reward/services/ref-reward.service.ts:249` | `RefRewardService.updatePaidRefCredit` | +| 1 | 0 | query-builder (named columns) | `BuyFiat` | `subdomains/core/sell-crypto/process/services/buy-fiat.service.ts:586` | `BuyFiatService.updateRefVolumes` | +| 1 | 0 | query-builder (named columns) | `BuyFiat` | `subdomains/core/sell-crypto/process/services/buy-fiat.service.ts:603` | `BuyFiatService.getUserVolume` | +| 1 | 0 | query-builder (named columns) | `Sell` | `subdomains/core/sell-crypto/route/sell.service.ts:271` | `SellService.getTotalVolume` | +| 1 | 0 | query-builder (named columns) | `TradingOrder` | `subdomains/core/trading/services/trading-rule.service.ts:35` | `TradingRuleService.getCurrentTradingOrders` | +| 1 | 0 | query-builder (named columns) | `UserData` | `subdomains/generic/user/models/user-data/user-data.service.ts:186` | `UserDataService.getUserDataIdsByServiceProvider` | +| 1 | 0 | query-builder (named columns) | `UserData` | `subdomains/generic/user/models/user-data/user-data.service.ts:1812` | `UserDataService.getMaxKycFileIdByDateRange` | +| 1 | 0 | query-builder (named columns) | `User` | `subdomains/generic/user/models/user/user.service.ts:141` | `UserService.getAllLinkedUsers` | +| 1 | 0 | query-builder (named columns) | `User` | `subdomains/generic/user/models/user/user.service.ts:196` | `UserService.getOpenRefCreditEur` | +| 1 | 0 | query-builder (named columns) | `User` | `subdomains/generic/user/models/user/user.service.ts:595` | `UserService.getUserVolumes` | +| 1 | 0 | query-builder (named columns) | `User` | `subdomains/generic/user/models/user/user.service.ts:605` | `UserService.getUserVolumes` | +| 1 | 0 | query-builder (named columns) | `User` | `subdomains/generic/user/models/user/user.service.ts:667` | `UserService.getRefInfo` | +| 1 | 0 | query-builder (named columns) | `User` | `subdomains/generic/user/models/user/user.service.ts:680` | `UserService.getRefInfo` | +| 1 | 0 | query-builder (named columns) | `User` | `subdomains/generic/user/models/user/user.service.ts:739` | `UserService.getTotalRefRewards` | +| 1 | 0 | query-builder (named columns) | `Deposit` | `subdomains/supporting/address-pool/deposit/deposit.service.ts:189` | `DepositService.getNextDepositIndex` | +| 1 | 0 | query-builder (named columns) | `Log` | `subdomains/supporting/log/log.repository.ts:97` | `LogRepository.cleanup` | +| 1 | 0 | query-builder (named columns) | `Log` | `subdomains/supporting/log/log.repository.ts:104` | `LogRepository.cleanup` | +| 1 | 0 | query-builder (no select) | `Log` | `subdomains/supporting/log/log.repository.ts:158` | `LogRepository.getFinancialChangesLogs` | +| 1 | 0 | query-builder (no select) | `Log` | `subdomains/supporting/log/log.repository.ts:206` | `LogRepository.getFinancialLogs` | +| 1 | 0 | query-builder (named columns) | `—` | `subdomains/supporting/payin/services/payin.service.ts:246` | `PayInService.getPayInFee` | +| 1 | 0 | query-builder (named columns) | `TransactionRequest` | `subdomains/supporting/payment/services/transaction-request.service.ts:358` | `TransactionRequestService.getLegacySettlementTxIds` | +| 1 | 0 | query-builder (named columns) | `TransactionRequest` | `subdomains/supporting/payment/services/transaction-request.service.ts:412` | `TransactionRequestService.getActiveDepositAddresses` | +| 1 | 0 | query-builder (named columns) | `SupportMessage` | `subdomains/supporting/support-issue/services/support-issue.service.ts:186` | `SupportIssueService.getSupportIssueStatistics` | +| 1 | 0 | query-builder (no select) | `—` | `subdomains/generic/gs/gs.service.ts:906` | `GsService.getExtendedBankTxData` | | — | — | raw-sql | `Log` | `subdomains/supporting/log/log.repository.ts:341` | `LogRepository.getFinancialLogAssetPrices` | | — | — | raw-sql | `Log` | `subdomains/supporting/log/log.repository.ts:511` | `LogRepository.getFinancialLogSummariesFull` | | — | — | raw-sql | `Log` | `subdomains/supporting/log/log.repository.ts:664` | `LogRepository.getFinancialLogSummariesChartOnly` | -| — | — | query-builder (alias only) | `—` | `subdomains/generic/user/models/user-data/user-data.service.ts:1252` | `UserDataService.updateVolumes` | -| — | — | query-builder (alias only) | `—` | `subdomains/core/monitoring/observers/payment.observer.ts:72` | `PaymentObserver.getPayment` | -| — | — | query-builder (no select) | `—` | `subdomains/generic/gs/gs.service.ts:868` | `GsService.getExtendedBankTxData` | -| — | — | query-builder (no select) | `—` | `subdomains/generic/gs/gs.service.ts:887` | `GsService.getExtendedBankTxData` | -| — | — | query-builder (alias only) | `—` | `subdomains/core/accounting/services/ledger-booking.service.ts:335` | `LedgerBookingService.nextSeqFrom` | -| — | — | query-builder (alias only) | `—` | `subdomains/core/accounting/services/ledger-cutover.service.ts:958` | `LedgerCutoverService.maxSettledId` | -| — | — | query-builder (alias only) | `—` | `subdomains/core/accounting/services/ledger-cutover.service.ts:1002` | `LedgerCutoverService.idsUpToBoundary` | -| — | — | query-builder (alias only) | `—` | `subdomains/core/monitoring/observers/bank.observer.ts:117` | `BankObserver.getDbBalance` | -| — | — | query-builder (alias only) | `—` | `subdomains/supporting/payin/services/payin.service.ts:246` | `PayInService.getPayInFee` | | — | — | find | `—` | `config/config.ts:1365` | `Configuration.isDomesticIban` | | — | — | find | `—` | `integration/binance-pay/services/binance-pay.service.ts:271` | `BinancePayService.verifySignature` | | — | — | find | `—` | `integration/blockchain/api/services/blockchain-balance.service.ts:52` | `BlockchainBalanceService.getSolanaBalances` | @@ -901,7 +903,7 @@ Sorted by measured columns, largest first. `—` means not measurable, not zero. | — | — | find | `—` | `shared/repositories/cached.repository.ts:28` | `CachedRepository.findCachedBy` | | — | — | raw-sql | `—` | `shared/services/cron-lease.service.ts:186` | `CronLeaseService.onModuleInit` | | — | — | find | `—` | `shared/services/http.service.ts:87` | `HttpService.getMockResponse` | -| — | — | find | `—` | `shared/utils/util.ts:786` | — | +| — | — | find | `—` | `shared/utils/util.ts:786` | `Util.clearTimeout` | | — | — | find | `—` | `subdomains/core/accounting/services/consumers/bank-tx.consumer.ts:339` | `BankTxConsumer.cutoverOwedOpeningChf` | | — | — | find | `—` | `subdomains/core/accounting/services/consumers/bank-tx.consumer.ts:567` | `BankTxConsumer.openingLiabilityLegChf` | | — | — | find | `—` | `subdomains/core/accounting/services/consumers/bank-tx.consumer.ts:586` | `BankTxConsumer.cutoverOpeningLiabilityChf` | @@ -915,6 +917,7 @@ Sorted by measured columns, largest first. `—` means not measurable, not zero. | — | — | find | `—` | `subdomains/core/accounting/services/ledger-booking.service.ts:264` | `LedgerBookingService.activeTx` | | — | — | find | `—` | `subdomains/core/accounting/services/ledger-booking.service.ts:268` | `LedgerBookingService.activeTx` | | — | — | find | `—` | `subdomains/core/accounting/services/ledger-booking.service.ts:274` | `LedgerBookingService.activeTx` | +| — | — | query-builder (count only) | `LedgerLeg` | `subdomains/core/accounting/services/ledger-mark-to-market.service.ts:212` | `LedgerMarkToMarketService.alreadyBooked` | | — | — | find | `—` | `subdomains/core/aml/services/aml-helper.service.ts:375` | — | | — | — | find | `—` | `subdomains/core/aml/services/aml-helper.service.ts:684` | — | | — | — | find | `—` | `subdomains/core/aml/services/aml-helper.service.ts:685` | — | @@ -953,14 +956,14 @@ Sorted by measured columns, largest first. `—` means not measurable, not zero. | — | — | find | `—` | `subdomains/core/buy-crypto/process/services/buy-crypto.service.ts:1057` | `BuyCryptoService.resetAmlCheckForReview` | | — | — | find | `—` | `subdomains/core/buy-crypto/process/services/buy-crypto.service.ts:1088` | `BuyCryptoService.resetAmlCheckForReview` | | — | — | find | `—` | `subdomains/core/buy-crypto/process/services/buy-crypto.service.ts:1094` | `BuyCryptoService.resetAmlCheckForReview` | -| — | — | find | `—` | `subdomains/core/buy-crypto/process/services/buy-crypto.service.ts:1447` | `BuyCryptoService.getCryptoRoute` | +| — | — | find | `—` | `subdomains/core/buy-crypto/process/services/buy-crypto.service.ts:1421` | `BuyCryptoService.getCryptoRoute` | | — | — | find | `—` | `subdomains/core/custody/controllers/custody-account.controller.ts:66` | `CustodyAccountController.getCustodyAccount` | | — | — | find | `—` | `subdomains/core/custody/controllers/custody-account.controller.ts:67` | `CustodyAccountController.getCustodyAccount` | | — | — | query-builder (no select) | `—` | `subdomains/core/custody/services/custody-account.service.ts:482` | `CustodyAccountService.lockActiveAccessGrant` | | — | — | find | `—` | `subdomains/core/custody/services/custody-account.service.ts:504` | `CustodyAccountService.createGrant` | | — | — | find | `—` | `subdomains/core/custody/services/custody-account.service.ts:598` | `CustodyAccountService.grantAccessForLegacy` | | — | — | find | `CustodyOrder` | `subdomains/core/custody/services/custody-job.service.ts:65` | `CustodyJobService.executeOrder` | -| — | — | find | `—` | `subdomains/core/custody/services/custody-order.service.ts:405` | `CustodyOrderService.checkBalance` | +| — | — | find | `—` | `subdomains/core/custody/services/custody-order.service.ts:386` | `CustodyOrderService.checkBalance` | | — | — | find | `—` | `subdomains/core/custody/services/custody.service.ts:307` | `CustodyService.getUserCustodyHistory` | | — | — | find | `—` | `subdomains/core/history/mappers/transaction-dto.mapper.ts:211` | `TransactionDtoMapper.feeAmountType` | | — | — | find | `—` | `subdomains/core/history/services/history-access.service.ts:126` | `HistoryAccessService.resolveFromApiKey` | @@ -1010,12 +1013,11 @@ Sorted by measured columns, largest first. `—` means not measurable, not zero. | — | — | find | `—` | `subdomains/core/sell-crypto/process/services/buy-fiat-registration.service.ts:124` | `BuyFiatRegistrationService.findMatchingRoute` | | — | — | find | `—` | `subdomains/core/sell-crypto/process/services/buy-fiat-registration.service.ts:126` | `BuyFiatRegistrationService.findMatchingRoute` | | — | — | find | `—` | `subdomains/core/sell-crypto/process/services/buy-fiat-registration.service.ts:128` | `BuyFiatRegistrationService.findMatchingRoute` | -| — | — | find | `—` | `subdomains/core/sell-crypto/process/services/buy-fiat.service.ts:206` | `BuyFiatService.update` | +| — | — | find | `—` | `subdomains/core/sell-crypto/process/services/buy-fiat.service.ts:205` | `BuyFiatService.update` | | — | — | raw-sql | `—` | `subdomains/generic/gs/gs.service.ts:337` | `GsService.executeDebugQuery` | | — | — | find | `—` | `subdomains/generic/gs/gs.service.ts:739` | `GsService.getParsedJsonData` | | — | — | find | `—` | `subdomains/generic/gs/gs.service.ts:742` | `GsService.getParsedJsonData` | | — | — | query-builder (no select) | `—` | `subdomains/generic/gs/gs.service.ts:805` | `GsService.getRawDbData` | -| — | — | query-builder (no select) | `—` | `subdomains/generic/gs/gs.service.ts:906` | `GsService.getExtendedBankTxData` | | — | — | find | `—` | `subdomains/generic/kyc/dto/mapper/kyc-info.mapper.ts:35` | — | | — | — | find | `—` | `subdomains/generic/kyc/dto/mapper/kyc-info.mapper.ts:36` | — | | — | — | find | `—` | `subdomains/generic/kyc/dto/mapper/kyc-info.mapper.ts:125` | — | @@ -1055,9 +1057,9 @@ Sorted by measured columns, largest first. `—` means not measurable, not zero. | — | — | find | `—` | `subdomains/generic/user/models/bank-data/bank-data.service.ts:344` | `BankDataService.getVerifiedBankDataWithIban` | | — | — | find | `—` | `subdomains/generic/user/models/bank-data/bank-data.service.ts:366` | `BankDataService.getAllBankDatasForUser` | | — | — | find | `—` | `subdomains/generic/user/models/bank-data/bank-data.service.ts:448` | `BankDataService.createIbanForUserInternal` | -| — | — | find | `Wallet` | `subdomains/generic/user/models/kyc/kyc.service.ts:58` | `KycService.transferKycData` | -| — | — | find | `—` | `subdomains/generic/user/models/kyc/kyc.service.ts:193` | `KycService.getFileFor` | -| — | — | find | `—` | `subdomains/generic/user/models/kyc/kyc.service.ts:200` | `KycService.getFileFor` | +| — | — | find | `Wallet` | `subdomains/generic/user/models/kyc/kyc.service.ts:57` | `KycService.transferKycData` | +| — | — | find | `—` | `subdomains/generic/user/models/kyc/kyc.service.ts:178` | `KycService.getFileFor` | +| — | — | find | `—` | `subdomains/generic/user/models/kyc/kyc.service.ts:185` | `KycService.getFileFor` | | — | — | find | `—` | `subdomains/generic/user/models/recommendation/recommendation.service.ts:45` | `RecommendationService.createRecommendationByRecommender` | | — | — | find | `—` | `subdomains/generic/user/models/recommendation/recommendation.service.ts:135` | `RecommendationService.handleRecommendationRequest` | | — | — | find | `—` | `subdomains/generic/user/models/recommendation/recommendation.service.ts:246` | `RecommendationService.setRecommenderRefCode` | @@ -1077,11 +1079,12 @@ Sorted by measured columns, largest first. `—` means not measurable, not zero. | — | — | find | `—` | `subdomains/generic/user/models/user-data/user-data.service.ts:1382` | `UserDataService.mergeUserData` | | — | — | find | `—` | `subdomains/generic/user/models/user-data/user-data.service.ts:1585` | `UserDataService.mergeUserData` | | — | — | find | `—` | `subdomains/generic/user/models/user-data/user-data.service.ts:1763` | `UserDataService.updateBankTxTime` | +| — | — | query-builder (count only) | `UserData` | `subdomains/generic/user/models/user-data/user-data.service.ts:1804` | `UserDataService.countByDateRange` | | — | — | find | `—` | `subdomains/generic/user/models/user/dto/user-dto.mapper.ts:27` | — | -| — | — | find | `—` | `subdomains/generic/user/models/user/user.repository.ts:31` | `UserRepository.getNextRef` | -| — | — | find | `—` | `subdomains/generic/user/models/user/user.service.ts:343` | `UserService.createUser` | -| — | — | find | `—` | `subdomains/generic/user/models/user/user.service.ts:515` | `UserService.updateAddress` | -| — | — | find | `—` | `subdomains/generic/user/models/user/user.service.ts:531` | `UserService.deactivateUser` | +| — | — | find | `—` | `subdomains/generic/user/models/user/user.repository.ts:70` | `UserRepository.getNextRef` | +| — | — | find | `—` | `subdomains/generic/user/models/user/user.service.ts:337` | `UserService.createUser` | +| — | — | find | `—` | `subdomains/generic/user/models/user/user.service.ts:509` | `UserService.updateAddress` | +| — | — | find | `—` | `subdomains/generic/user/models/user/user.service.ts:525` | `UserService.deactivateUser` | | — | — | find | `—` | `subdomains/supporting/balance/services/balance-pdf.service.ts:105` | `BalancePdfService.getBalancesForAddress` | | — | — | find | `—` | `subdomains/supporting/bank-tx/bank-tx/entities/bank-tx.entity.ts:337` | `BankTx.bankDataName` | | — | — | find | `—` | `subdomains/supporting/bank-tx/bank-tx/entities/bank-tx.entity.ts:349` | `BankTx.getSenderAccount` | @@ -1145,6 +1148,7 @@ Sorted by measured columns, largest first. `—` means not measurable, not zero. | — | — | find | `—` | `subdomains/supporting/log/log-job.service.ts:616` | `LogJobService.getAssetLog` | | — | — | find | `—` | `subdomains/supporting/log/log-job.service.ts:982` | `LogJobService.getAssetLog` | | — | — | find | `—` | `subdomains/supporting/log/log-job.service.ts:1833` | `LogJobService.findSenderReceiverPair` | +| — | — | query-builder (count only) | `Log` | `subdomains/supporting/log/log.repository.ts:688` | `LogRepository.assertEmptyResultIsEndOfData` | | — | — | find | `—` | `subdomains/supporting/notification/services/notification.service.ts:128` | `NotificationService.resolveMailWallet` | | — | — | find | `—` | `subdomains/supporting/payin/services/payin-notification.service.ts:32` | `PayInNotificationService.returnedCryptoInput` | | — | — | find | `—` | `subdomains/supporting/payin/services/payin.service.ts:177` | `PayInService.getCryptoInputsByTransactionIds` | @@ -1201,6 +1205,5 @@ Sorted by measured columns, largest first. `—` means not measurable, not zero. | — | — | query-builder (no select) | `—` | `subdomains/supporting/support-issue/services/limit-request.service.ts:71` | `LimitRequestService.updateLimitRequest` | | — | — | find | `—` | `subdomains/supporting/support-issue/services/support-escalation.service.ts:166` | `SupportEscalationService.bindGroupChat` | | — | — | find | `—` | `subdomains/supporting/support-issue/services/support-issue.service.ts:92` | `SupportIssueService.getSupportIssueClerkForAccount` | -| — | — | find | `SupportMessage` | `subdomains/supporting/support-issue/services/support-issue.service.ts:676` | `SupportIssueService.getIssue` | -| — | — | find | `SupportMessage` | `subdomains/supporting/support-issue/services/support-issue.service.ts:728` | `SupportIssueService.getIssueFile` | -| — | — | find | `SupportMessage` | `subdomains/supporting/support-issue/services/support-issue.service.ts:743` | `SupportIssueService.getUserIssues` | +| — | — | find | `SupportMessage` | `subdomains/supporting/support-issue/services/support-issue.service.ts:633` | `SupportIssueService.getIssueFile` | +| — | — | find | `SupportMessage` | `subdomains/supporting/support-issue/services/support-issue.service.ts:648` | `SupportIssueService.getUserIssues` | diff --git a/docs/read-path-projections.md b/docs/read-path-projections.md index 5371a5d7a4..92d5c286a4 100644 --- a/docs/read-path-projections.md +++ b/docs/read-path-projections.md @@ -27,14 +27,11 @@ This service loads far more data than it returns. Measured against the real enti - The whole database schema has **1,742 columns across 100 tables**. - `PUT /v1/transaction/:id/invoice` selected **1,664 of them** — 96% of the entire schema — to - render a PDF containing a handful of values. That query sat exactly on Postgres' limit of 1,664 - columns per statement, so a single column added elsewhere (`settlementEventId` on - `transaction_request`) was enough to push it over. -- Of the 537 route entries, **444 reach at least one load site that fetches whole rows**; 89 read - nothing at all, 2 read only the fields they need, and 2 more project only when the caller - supplies a field list. The widest query a fetching endpoint can trigger is 308 columns at the - median of the recorded maxima, and at least 21 of them exceed 1,000 — the call graph does not - fully resolve, and an unresolved edge can only widen a query. + render a PDF containing a handful of values. That is exactly Postgres' limit of 1,664 columns per + statement, so one further column in that query would have made it fail outright. +- Of the 537 route entries, **410 reach at least one load site that fetches whole rows**; 89 read + nothing at all, and **36 read only the fields they return**. The widest query a fetching endpoint + can trigger is 308 columns at the median, and at least 21 of them exceed 1,000. The column limit was the symptom, not the cause. Loading a thousand columns to return one is equally wasteful under a limit of 4,096 — it simply would not have failed yet. @@ -43,18 +40,45 @@ equally wasteful under a limit of 4,096 — it simply would not have failed yet. Two properties combine: -**Eager relations.** 95 relations in this repo are declared `eager: true`. TypeORM expands them +**Eager relations.** 90 relations in this repo are declared `eager: true`, across 44 entities. TypeORM expands them recursively, so a plain `findOne()` on `UserData` already selects **253 columns across 8 joins**, and one on `LimitRequest` **434 across 15** — before any `relations` option is passed. The decision what to load therefore lives in the entity definition, not at the call site, and no call site can see what it triggers. -**No read model.** Of the load sites in this repository — at most 1,158, see [load-sites.md](load-sites.md#measurements) — **eight** name the columns they need: -one query builder and the seven raw statements. Practically all the rest request whole rows — 1,021 through the -`find` family, and of the 130 query builders, 105 pass the root alias to `.select(...)`, which reads -like a projection but is not, while 23 pass no select at all. The same entities serve persistence, -business logic and pure output paths such as invoices, receipts, history and exports — which need -fields, not objects. +**No read model.** Of the load sites in this repository — at most 1,158, see +[load-sites.md](load-sites.md#measurements) — **118** load less than a whole row: 108 query builders +that name their columns, three that end in `getCount()` or `getExists()` and materialise none, and +the seven raw statements. Those 118 are counted, not estimated. At most 1,040 request whole rows — 1,007 through the +`find` family, and of the 144 query builders, 17 pass the root alias to `.select(...)`, which reads +like a projection but is not, 15 pass no select at all, and one projects its root but pulls a +relation in whole. The same entities serve persistence, business logic and pure output paths such +as invoices, receipts, history and exports — which need fields, not objects. + +Read the first number carefully. The 90 +query builders that name columns one at a time are almost entirely counts, maxima and id lookups — +`.select('userData.id', 'id')`, `.select('COUNT(*)', 'count')` and the like — and they select **1.5 +columns at the median**, half of them exactly one. They are projections. The classification recognised only the array form +`.select([...])` and read every string argument as the bare root alias, which is what put them in +the `whole rows` group. The rule that holds: a bare identifier is the root alias and loads +everything; anything else — a column or an expression — narrows the query. Correcting that does not +change the picture: a +`COUNT(*)` that was always narrow is not a read path that was converted, and the response payloads — +history, profile, invoices, exports — are still served by `find`. + +Two further shapes were counted as full loads for the same kind of reason, and both are decided by +something the select list does not show: + +- **The terminal call can discard the select list.** `getCount()` and `getExists()` replace it with + `COUNT(…)` and `SELECT 1`, so a chain ending in either materialises no row whatever precedes it. + Three load sites do this, and `GET /support/kycFileStats` was listed as fetching 99 columns + because of one of them. +- **The select argument can be a variable.** `.select(bucketExpr, 'bucket')` names an expression as + surely as a literal does; whether it narrows depends on what the variable holds, which has to be + resolved in the enclosing method. Four load sites do this — three of them in the two + caller-defined `/gs/db` endpoints, the fourth an aggregate that was reported as a full load. + +Neither changes the picture either, and both are now part of the classification. ## Vocabulary @@ -114,12 +138,59 @@ All of the following must hold: The first four are pre-filters; the fifth decides. +## What qualifies, measured + +The pre-filters above are stated as criteria; this is what they select when applied to the +inventory. Every step is mechanical except the last two, which were read in the source. + +| | before | now | +| --- | ---: | ---: | +| fetch whole rows | 415 | 398 | +| … every load site they reach can be narrowed at all | 37 | 29 | +| … no write to the loaded entity, and the response is a structured value | 16 | 8 | +| … and no DTO field passes an entity through | **9** | **1** | + +The step that decides the size of this work is the first one, and it has a single cause: at 321 of +the 484 load sites involved, **the loaded entity leaves the loading method**. +`UserDataService.getUserData` returns `Promise` to 113 different endpoints. What fields +are needed is decided by each caller, not at the load site, so a projection there would be guessed +rather than derived — and the union over 113 callers is the whole entity anyway. Splitting those +hubs into per-caller reads is a separate piece of work with a different shape. It is not assumed +here, and the numbers above do not depend on whether it happens. + +The last step removes seven endpoints whose DTO has a field typed as an entity — `currency: Fiat`, +`targetAsset: Asset`. The response then contains every column of that entity, so a projection would +have to list them all and would save nothing. Narrowing those means changing the contract, which is +a different decision. + +**One endpoint remains, and it is excluded by the second criterion rather than by the first four.** +`POST /support/issue/:id/message` hands the message it creates — carrying the issue and the account +behind it — to the notification service. What that code reads is not determinable at the load site, +so a field list here would be guessed. + +The filters are deliberately conservative and reject endpoints that can in fact be converted: an +endpoint fails the first step if *any* load site it reaches leaks, including one on a branch that +has nothing to do with the response. Nine of the seventeen conversions recorded in +[endpoints.md](endpoints.md) were found that way — by reading the endpoint after the filter had +rejected it. The counts above are therefore a lower bound on what is possible, not a ceiling. + +Two of those seventeen would fall foul of a blanket no-write rule, and are the reason the first +criterion does not state one. It rejects an endpoint that writes the entity it loaded, and the hazard +it names is saving a partially loaded row back — the unselected columns are undefined on the entity +and would be written as null. +`PUT /paymentLink/:id/pos` and `POST /user/apiKey/CT` write through `update(id, …)`, which sends +only the columns named in the call, so a projected read cannot blank anything. What the criterion +does have to keep excluding is a value the write *derives* from what was read: the point-of-sale +write merges into the existing configuration, so `config` is part of that projection and is +asserted directly. + ## The risk this must guard against A missing field does not crash. It is simply absent, getters compute with it anyway, and the endpoint answers 200 with a wrong value. -The concrete case, from the code that was already fixed once: +The concrete case, from `UserData` in +`src/subdomains/generic/user/models/user-data/user-data.entity.ts`: ```typescript get requiredInvoiceFields(): string[] { @@ -135,20 +206,126 @@ load returns `true`. The invoice is refused with "user data is not complete" alt complete. No error, no log entry. This service carries **238 such getters across 50 of its 113 entities**. In an application moving -money, a silent wrong value is worse than a crash: a statement that exceeds the column limit fails -loudly and is found at once, while a wrong value can run for weeks. +money, a silent wrong value is the expensive kind of defect: nothing reports it, so it runs until +someone notices the answer is wrong. + +## The guard: making the failure loud + +Before the levels, the mechanism they used to substitute for. + +A column the query did not select is `undefined` on the entity. Getters compute with it, the mapper +puts the result in the response, and the endpoint answers 200. Nothing fails. The obvious defence is +to prove completeness field by field — drop each one, require the response to change — and that is +what level 3 below does. It works, and it is expensive in a particular way: it needs a fixture that +reaches every branch reading every field, and a fixture that misses its branch is **green while +proving nothing**. The safety net then fails exactly the way the defect fails, which is why writing +these tests kept producing assertions that could not fail. + +So the silence is switched off instead. Every query a `ReadProjection` builds returns rows wrapped +by `guardProjection`, and reading a column the field list did not ask for **throws**, naming the +column: + + read of 'UserData.organizationName', which this query did not select — + add it to the projection, or stop reading it + +The consequences are worth stating plainly: + +- **A test that reaches the read fails at it**, whether or not the missing column would have been + visible in the response. What the fixture still has to do is reach the branch; what it no longer + has to do is make the resulting value observable. +- It is installed once for the whole configuration (`jest-projection.setup.ts`), not per spec, so a + spec written later cannot lose the protection quietly. +- It reports **where the column was needed**, not where the wrong value surfaced. A getter keeps + running — reading *through* a getter is how the missing column is reached — and the failure names + the column the getter wanted. +- It enforces a stricter rule than "the response is correct": it requires the projection to cover + what the code **reads**, not what the response happens to depend on. That difference is not + academic — it is what found `PUT /paymentLink/:id/pos` assembling a recipient block out of the + account's name, contact data and address, and discarding it. The fix was to stop reading, not to + load more. +- Relations the projection does not join are left alone: they are `undefined`, and dereferencing + them already throws. So is a column the caller assigned itself before reading it back, which is + what the write paths do. + +The guard is verified by `projection-guard.projection.spec.ts` — it is the one piece the levels +below cannot check, because they rely on it. + +## The other route: reducing the eager relations + +Converting endpoints one at a time treats the symptom. The cause is named above — the entity +decides what a query loads, and no call site can see what it triggers. So the cheaper-looking route +is to take the decision away from the entity: drop `eager: true`, and let each call site say what +it needs. This section is what that route costs, measured rather than estimated, because the +measurement changes the answer. + +**How it was measured.** Which relations are declared is an AST question — a text search counts +comments and misses modifiers. Where each one is *read* is a type question: `.userData` occurs on +dozens of unrelated receivers, so the reads were resolved with the TypeScript compiler, including +reads through a base class, which is how single-table inheritance is reached. What a response +*contains* is neither: it comes from the TypeORM metadata, whose eager closure is what a query +actually joins. + +The two counts in this document are not the same count, and the difference is not a discrepancy. +The 90 above are declarations in the source. At runtime they are 103 relations, because a +declaration on a base class is carried by every entity that inherits it (6), and because +single-table inheritance surfaces a child's relation on the parent as well (8) — a query on +`DepositRoute` joins what `Sell`, `Swap` and `Staking` declare. In the other direction, `Sell.route` +and `Swap.route` are two declarations of one column in one table and count once. + +**What the measurement says.** 55 handlers answer with an entity rather than a response object, 35 +distinct entities between them. For those, the eager relations are not a loading detail — they are +the answer. Followed recursively, their closure covers **57 of the 103 eager relations** this +repository builds at runtime — 17 of the 35 entities carry no eager relation at all, which is why +the closure is smaller than the root count suggests. Removing one of those 57 changes what an +endpoint returns; adding one changes it too. All 55 handlers carry a role guard and all but two +(`POST /userDataRelation`, `PUT /userDataRelation/:id`) are excluded from the Swagger schema, so the +consumer is the operator's own tooling rather than a published schema — which makes it a decision to +take, not a wall, but a decision rather than a refactor either way. + +That is the part worth carrying forward: **the majority of the eager relations cannot be removed +mechanically**, and the criterion that decides it is not visible in the entity, the call site, or +the test. It is visible in the return type of a controller. + +**What was removable, and was removed.** Four declarations that no code reads and that no response +contains: `Buy.route`, `CustodyBalance.user`, `CustodyOrder.transaction`, +`CryptoStaking.paybackDeposit`. That narrowed 55 load sites — the custody order paths by 98 columns +each, the widest transaction paths by four — and 47 endpoints in +[endpoints.md](endpoints.md) now show a smaller number. + +**What is left.** 32 declarations are read somewhere and are in no response. Each is removable, but +not by rule: the compiler says where a relation is read, not which query produced the value that +was read. Connecting the two is a per-case reading of the code, and it is the same work as +converting an endpoint — with a failure mode that is worse, because a relation that is no longer +loaded is `undefined` at a call site that no test may reach. + +**What guards it.** `eager-relations.projection.spec.ts` pins two things: the closure above, so a +relation added to any entity in it fails the run naming the controller whose answer it changes, and +the total count, so a new one anywhere is a decision rather than a detail of an unrelated change. +It finds the entities that leave through a controller by reading the controllers rather than from a +list, so adding a controller does not silently narrow what the closure covers. + +So the two routes are not alternatives, and neither is cheap. The difference between them is the +failure mode: an incomplete field list now throws, and an eager relation added to the wrong entity +now fails a test — which is the property both of these are for. ## Test definition -All four levels must pass for an endpoint to count as converted. `endpoints.md` records the state -per endpoint as `0/4` through `4/4`; only `4/4` is done. +The guard covers completeness. The levels cover what it cannot see: whether the response is right, +whether the variants that matter are exercised, and whether the projection carries more than it +needs. `endpoints.md` records the state per endpoint as `0/4` through `4/4`; only `4/4` is done. ### Which endpoints these apply to To any load site that carries an explicit field list — that is where a forgotten field silently yields an empty value. -Today that is **eight sites**, and this is what the suite covers of them: +A hundred and thirteen sites carry a field list. The table below covers the six that were known when +this document was written — one query builder and five raw statements — and none of them was +converted, so it is unchanged. Another 90 are the query builders that name columns one at a time; they are +not covered by these levels either, which is what their endpoints' `0/4` in +[endpoints.md](endpoints.md) records. The remaining 17 belong to the endpoints converted so far and +are covered on all four. Sites a conversion adds are recorded there too, where only +`4/4` counts as done. | Site | Form | Runs in a test | Column list asserted | Real database | | ---- | ---- | -------------- | -------------------- | ------------- | @@ -156,14 +333,12 @@ Today that is **eight sites**, and this is what the suite covers of them: | `log.repository.ts:341` — `getFinancialLogAssetPrices` | raw SQL, columns listed | **no** | no | no | | `log.repository.ts:511` — `getFinancialLogSummariesFull` | raw SQL, columns listed | yes | yes | no | | `log.repository.ts:664` — `getFinancialLogSummariesChartOnly` | raw SQL, columns listed | yes | yes | no | -| `virtual-iban.service.ts:891` — `hasOrderedOwnershipPath` | raw SQL, columns listed | **no** | no | no | +| `virtual-iban.service.ts:769` — `hasOrderedOwnershipPath` | raw SQL, columns listed | **no** | no | no | | `gs.service.ts:337` — `executeDebugQuery` | raw SQL, list supplied by the caller | yes | yes | no | -| `cron-lease.service.ts:186` — `onModuleInit` | raw SQL, `SELECT 1` — a reachability probe, no column to name | yes | n/a | no | -| `bank-tx.service.ts:597` — `recoverRollingInternalTransfers` | raw SQL, columns listed | **no** | no | no | Read that column by column, because the three answers mean different things. -**Runs in a test.** Four of the eight are never executed. `getFinancialLogValidityChangeSet` is +**Runs in a test.** Three of the six are never executed. `getFinancialLogValidityChangeSet` is replaced by `jest.spyOn(logRepo, …).mockResolvedValue([…])` at all nine of its appearances, so the projection line itself never runs. `getFinancialLogAssetPrices` is stood in for by a hand-written fake that reimplements the filtering in TypeScript. `hasOrderedOwnershipPath` appears in no spec at @@ -178,7 +353,7 @@ without being level 3: that level requires level 1 to fail, and level 1 is satis these sites. For the three that never run, removing a column changes nothing at all — the mock supplies the value regardless. -**Real database.** None of the eight. Every spec stubs the boundary — `createQueryBuilder` as a +**Real database.** None of the six. Every spec stubs the boundary — `createQueryBuilder` as a chainable mock in the repository spec, `createMock()` in the service spec. A mock cannot observe which columns were requested, so **level 1, the completeness test, is satisfied nowhere today**, not even for the three sites whose SQL is asserted. Asserting that a column appears in a @@ -189,14 +364,18 @@ incomplete result is the caller's doing rather than a defect here. Its 197 specs axis — the table and column allowlist, PII masking, parameter binding — and that is the right axis for it. -So the gap is narrow and specific: **`log.repository.ts:699` is the one site that carries the -projection risk, serves a live endpoint (`PUT /log/financial/validity`), and is not exercised at -all.** What surrounds it is well covered — batching into blocks of 100, the audit trail, rejection -of fabricated audit records, the block on changing validity through the generic update path. +So among the six reads this section is about, the gap is narrow and specific: +**`log.repository.ts:699` carries the projection risk, serves a live endpoint +(`PUT /log/financial/validity`), and is not exercised at all.** What surrounds it is well covered — +batching into blocks of 100, the audit trail, rejection of fabricated audit records, the block on +changing validity through the generic update path. -Every other read in the repository either selects only the root alias or nothing at all and -therefore still loads every column; nothing can be missing from those. Each becomes subject to these -tests the moment it is given a field list. +That was the whole picture when this document was written. It no longer is: 115 sites now name their +columns — the 18 with an explicit field list and the 90 that name them one at a time, plus the seven +raw statements — and each of them can drop a field silently. The 17 conversions below carry the four +levels. Of the endpoints behind the others, 18 record that they do not, as `0/4`; three stay `n/a` +because their field list comes from the request rather than the code, so there is no fixed +projection to test — `POST /gs/db`, `POST /gs/db/custom` and `POST /gs/debug`. ### How they run @@ -216,15 +395,49 @@ in **all three test shards** — Jest distributes the suites across shards, so e own instance. Without the variable the blocks are skipped, which is why the suite still passes on a machine with no database. -The projection tests should use the same mechanism rather than introduce a second one. - -Two things are missing, and only two: - -- **The schema.** The migration specs each create their own Postgres schema (`const SCHEMA = '…'`) - so that parallel specs cannot collide. The projection tests need the same isolation, but their - schema comes from the entity metadata via `synchronize` rather than from replayed migrations — - the reference for a projection is the entity definition, not the migration history. -- **The fixtures**, generated from the same metadata. +The projection tests use that same gate. What they add lives in +`src/shared/utils/projection-test.util.ts`: + +- **The schema.** Each spec file creates its own Postgres schema (`const SCHEMA = '…'`) so parallel + specs cannot collide, and fills it from the entity metadata via `synchronize` rather than from + replayed migrations — the reference for a projection is the entity definition, not the migration + history. Measured: 113 entities, 100 tables, 1,742 columns, about half a minute per spec file. +- **The fixtures**, generated from the same metadata. Every scalar column gets a non-empty value and + required relations are created recursively, so an empty field in a response proves the query + failed to load something. Three kinds of value have to be pinned by hand, and all three are the + fixture's business rather than the projection's: enum columns stored as text (the metadata reports + them as `varchar`, so a generated value is not a member of the enum and a mapper looking it up + answers `undefined`), values a check constraint relates to each other, and relations the schema + allows to be null but a mapper reads without a guard. + +They do **not** run in the main suite, and that is not a preference. The main suite compiles +transpile-only (`tsconfig.json` sets `isolatedModules: true`), which makes TypeScript emit +`design:type` as `Object` for any imported type; building a data source from the entity sources then +fails outright with `Data type "Object" in "Fiat.amlRuleFrom" is not supported`, and no projection +spec could run at all. `jest.projection.config.js` compiles with full type information — the same +reason the Frick and coverage gates have their own configuration — and `npm run test:projection` is +a separate CI job with its own Postgres service. + +### What a converted endpoint looks like + +The field list is a value, not a chain of calls at the query site: `ReadProjection` in +`src/shared/models/read-projection.ts`. That is what lets level 3 re-run the *production* query with +one field removed. A query rebuilt inside the spec could be wrong in exactly the way the projection +is wrong, and would prove nothing. + +A projection separates two kinds of field: + +- **Response fields** feed the answer. These are what level 3 drops one at a time. +- **Guards** are loaded but never shown: the primary keys that make the ORM materialise a joined + row, and values a check reads before the mapper runs — `UserData.status`, which + `GET /user/profile` refuses merged accounts on. Dropping a guard changes no response field, so + level 3 would report it as removable although the endpoint breaks without it. Each guard needs an + assertion of its own instead. + +Where a getter branches on a field, the response fields split per branch and each variant asserts +over its own set. `UserData.address` reads the organization's address for a business account and the +account's own for a personal one; a personal-account fixture can say nothing about the organization +fields, and claiming otherwise would be the vacuous kind of green this document is against. ### 1. Completeness @@ -254,23 +467,66 @@ depends on a status field. ### 3. Mutation -**Remove each field of the projection individually; level 1 must fail every time.** +**Remove each field of the projection individually; the response must change every time.** + +Since the guard, this level no longer protects against a *missing* field — a dropped field now +throws before it can produce a wrong value, and `expectEveryFieldRequired` counts that throw as the +field carrying weight. What it still does is the opposite direction: it shows that a field in the +list is **needed**, so a projection cannot quietly grow past what the endpoint uses. That is a cost +question rather than a correctness one, and it is the reason the fixture-per-branch work below is +worth doing where a field list is intricate and can be skipped where it is not. This proves the test looks at anything at all. Where removing a field changes nothing, one of two -things is true: the field is unnecessary and can be dropped permanently, or the fixture has a gap -at exactly that point — and a real defect would have slipped through there. +things is true: the field is unnecessary and can be dropped permanently, or the fixture never +reaches the branch that reads it — and a real defect would have slipped through there. + +**The measure is the response, not emptiness**, and the difference is not academic. The first +version of this level asked whether a field went empty, and it passed a projection missing +`UserData.kycStatus`: `getKycWebhookStatus` answers `NA` when handed nothing, which is a valid value +and a wrong one. Comparing against the response the full projection produced catches it. It is the +same standard as level 4, applied one field at a time. Without this level you never know whether a green test verified something or is merely green. +**Where a value has a fallback, the candidate may be the chain rather than the column.** +`UserData.completeName` is `organizationName ?? firstname + surname`. Dropping the chain shows the +value depends on it at all; each column on its own is covered by the variant in which it is the one +that gets read — which is also why a fixture has to reach that branch. `kycType` only changes the +answer for a LOCK account: against a DFX one, the value the absent column would produce and the +value it does produce are the same, and no assertion can tell them apart. +`expectEveryFieldRequired` therefore accepts a group of fields as one candidate. + +**A missing summand is not a missing field, and level 1 has to say so anyway.** The annual volume on +the support view is `annualBuyVolume + annualSellVolume + annualCryptoVolume`. Leave one of the three +out of the projection and the sum is `NaN` — not absent, so an `undefined` check waves it through, +and the endpoint answers 200 with a number that is not a number. `NaN` therefore counts as empty. + +**It needs a baseline, or it is itself merely green.** If the response is already incomplete with +the *full* field list, every reduced run fails too, and "every field is required" comes out true +without a single field having been shown to matter. That happened while the first conversions were +written — a fixture had left an enum at a value the mapper did not know — and the level reported +success. `expectEveryFieldRequired` therefore runs the query unreduced first and refuses to continue +unless that response is complete. + ### 4. Consistency against a second source **Where the same value exists twice, the two must agree.** -Applies wherever a value was materialised into its own column while the original source is still -present. In the financial log, `totalBalanceChf` exists both as a column and inside -`message->balancesTotal->totalBalanceChf`; every row must carry the same value in both. Where such -an invariant exists it is the strongest available test, because it needs no second implementation -that could itself be written wrong. +For a conversion the second source is always available: **the unprojected load.** Run the endpoint's +mapper over a full `find` of the same fixture, and the two responses must be identical, per variant. +The full load fetches every column, so the *field set* it produces is by construction the one the +endpoint answered from before the conversion — the mapper is the same function in both runs, so a +difference can only come from the columns. + +What the level does not verify is the query around them: each spec restates the filter and the +joins, so a spec that restates them wrongly compares two things neither of which is the endpoint. +The endpoint specs do exercise the filter, because they run it against seeded rows, but no level requires that — level 2 is about branches that change the required field set. It is also the only +level that catches a projection loading the *wrong* field rather than too few: level 1 sees a field +that went empty, level 4 sees any field that changed. + +It applies separately wherever a value was materialised into its own column while the original +source is still present. In the financial log, `totalBalanceChf` exists both as a column and inside +`message->balancesTotal->totalBalanceChf`; every row must carry the same value in both. ### Deliberately not part of this: a column budget diff --git a/jest-projection.setup.ts b/jest-projection.setup.ts new file mode 100644 index 0000000000..0308c46ac6 --- /dev/null +++ b/jest-projection.setup.ts @@ -0,0 +1,9 @@ +// Turns the projection guard on for every spec in this configuration. +// +// A projected query that answers with a column it did not select is the defect this suite exists +// to catch, and it is silent by nature. The guard makes it throw. Installing it here rather than +// per spec is deliberate: a spec written later would otherwise lose the protection without anything +// saying so. +import { installProjectionGuard } from 'src/shared/utils/projection-test.util'; + +installProjectionGuard(); diff --git a/jest.coverage-gate.config.js b/jest.coverage-gate.config.js index 51f3b5eae8..15f3f324c6 100644 --- a/jest.coverage-gate.config.js +++ b/jest.coverage-gate.config.js @@ -69,6 +69,7 @@ const PINNED_LOGIC = [ 'src/shared/decorators/log-rejected-value.decorator.ts', 'src/shared/models/fiat/fiat.controller.ts', 'src/shared/pipes/detailed-validation.pipe.ts', + 'src/shared/models/read-projection.ts', 'src/shared/services/typeorm-logger.ts', 'src/shared/utils/bitbox-ascii.util.ts', 'src/shared/utils/cron.ts', diff --git a/jest.projection.config.js b/jest.projection.config.js new file mode 100644 index 0000000000..08f93f14cb --- /dev/null +++ b/jest.projection.config.js @@ -0,0 +1,27 @@ +// Read-path projection tests (docs/read-path-projections.md). +// +// Kept out of the main suite for one reason, and it is a hard one: these specs build a TypeORM data +// source from the entity sources, and that needs the decorator metadata to carry real types. The +// main suite runs ts-jest transpile-only (tsconfig.json sets isolatedModules: true), which emits +// `design:type` as `Object` for any imported type — an enum column then fails metadata validation +// with `Data type "Object" ... is not supported`, and no projection spec could run at all. The same +// reasoning is why the Frick and coverage gates compile with tsconfig.coverage.json. +// +// The database gate is shared with the migration specs on purpose: without MIGRATION_TEST_PG these +// suites skip, so a machine with no database still runs a green suite. +const base = require('./package.json').jest; + +module.exports = { + ...base, + transform: { '^.+\\.(t|j)s$': ['ts-jest', { tsconfig: 'tsconfig.coverage.json' }] }, + testRegex: '.*\\.projection\\.spec\\.ts$', + // The guard is installed once for the whole configuration; see jest-projection.setup.ts. + setupFilesAfterEnv: ['/../jest-projection.setup.ts'], + // The base config excludes these specs so the main suite does not pick them up. Spreading it in + // would exclude them here too — this run is the one that must find them. + testPathIgnorePatterns: ['/node_modules/'], + // Each spec file creates and drops its own Postgres schema, which is what allows them to run in + // parallel — but the schema is built by `synchronize` over 113 entities and costs about half a + // minute, so the wall clock is dominated by how many files there are, not by how many assertions. + testTimeout: 300000, +}; diff --git a/package.json b/package.json index 7f1253f473..1f27dfac5c 100644 --- a/package.json +++ b/package.json @@ -23,6 +23,7 @@ "test:frick:cov": "jest --config jest.frick.config.js integration/bank/services/__tests__/frick.service.spec.ts integration/bank/services/__tests__/iso20022.service.spec.ts config/__tests__/frick.config.spec.ts config/__tests__/bank-frick-config.spec.ts subdomains/supporting/bank-tx/bank-tx/services/__tests__/bank-tx-frick.service.spec.ts subdomains/supporting/bank-tx/bank-tx/services/__tests__/bank-tx-outgoing-match.service.spec.ts subdomains/supporting/fiat-output/__tests__/fiat-output-frick.service.spec.ts subdomains/supporting/bank/virtual-iban/__tests__/virtual-iban-frick-issuance-reconciliation.service.spec.ts subdomains/supporting/bank/virtual-iban/__tests__/virtual-iban.service.spec.ts subdomains/supporting/bank/virtual-iban/providers/__tests__/frick-viban.provider.spec.ts --coverage --runInBand --collectCoverageFrom=integration/bank/dto/frick.dto.ts --collectCoverageFrom=integration/bank/services/frick.service.ts --collectCoverageFrom=integration/bank/services/iso20022.service.ts --collectCoverageFrom=config/frick.config.ts --collectCoverageFrom=subdomains/supporting/bank-tx/bank-tx/services/bank-tx-frick.service.ts --collectCoverageFrom=subdomains/supporting/bank-tx/bank-tx/services/bank-tx-outgoing-match.service.ts --collectCoverageFrom=subdomains/supporting/fiat-output/fiat-output-frick.service.ts --collectCoverageFrom=subdomains/supporting/bank/virtual-iban/virtual-iban-frick-issuance-reconciliation.service.ts --collectCoverageFrom=subdomains/supporting/bank/virtual-iban/virtual-iban.service.ts --collectCoverageFrom=subdomains/supporting/bank/virtual-iban/providers/frick-viban.provider.ts", "test:gate:cov": "jest --config jest.coverage-gate.config.js --coverage --silent", "test:staff-gate:cov": "jest --config jest.staff-gate.config.js shared/auth/__tests__/role.guard.spec.ts shared/auth/__tests__/staff-kyc-clearance.spec.ts subdomains/generic/user/models/user/__tests__/staff-kyc-clearance.service.spec.ts --coverage --runInBand --collectCoverageFrom=shared/auth/role.guard.ts --collectCoverageFrom=shared/auth/staff-kyc-clearance.ts --collectCoverageFrom=shared/auth/exceptions/staff-kyc-required.exception.ts --collectCoverageFrom=subdomains/generic/user/models/user/staff-kyc-clearance.service.ts", + "test:projection": "jest --config jest.projection.config.js --maxWorkers=2", "type-check": "tsc --noEmit", "format:check": "prettier --check \"src/**/*.ts\" \"test/**/*.ts\"", "check": "npm run lint && npm run test", @@ -193,6 +194,9 @@ "^@arkade-os/sdk$": "/integration/blockchain/arkade/__mocks__/arkade-sdk.mock.ts" }, "testRegex": ".*\\.spec\\.ts$", + "testPathIgnorePatterns": [ + "\\.projection\\.spec\\.ts$" + ], "transform": { "^.+\\.(t|j)s$": "ts-jest" }, diff --git a/src/shared/models/__tests__/eager-relations.projection.spec.ts b/src/shared/models/__tests__/eager-relations.projection.spec.ts new file mode 100644 index 0000000000..05d9503d3a --- /dev/null +++ b/src/shared/models/__tests__/eager-relations.projection.spec.ts @@ -0,0 +1,272 @@ +import { readdirSync, readFileSync } from 'fs'; +import { join } from 'path'; +import { + createProjectionDataSource, + describeProjection, + destroyProjectionDataSource, +} from 'src/shared/utils/projection-test.util'; +import { DataSource, EntityMetadata } from 'typeorm'; + +const SCHEMA = 'eager_relations_spec'; +const SRC = join(__dirname, '../../..'); + +/** + * The entities that leave through a controller as themselves — for those, the eager relations are + * the response rather than a loading detail. + * + * Read out of the source rather than listed, so that a controller added later is covered without + * anyone remembering a list. Deliberately generous: any method in a controller file whose return + * type names an entity counts, including through a renamed import. An over-match costs precision + * in the message, a miss costs the guarantee. + */ +function entitiesReturnedWhole(entities: Set): Map { + const controllers: string[] = []; + const walk = (directory: string): void => { + for (const item of readdirSync(directory, { withFileTypes: true })) { + const path = join(directory, item.name); + if (item.isDirectory()) walk(path); + else if (item.name.endsWith('.controller.ts')) controllers.push(path); + } + }; + walk(SRC); + + const found = new Map(); + for (const path of controllers) { + for (const name of entitiesReturnedBy(readFileSync(path, 'utf8'), entities)) { + const file = path.slice(SRC.length + 1); + const where = found.get(name) ?? []; + if (!where.includes(file)) where.push(file); + found.set(name, where); + } + } + + return found; +} + +/** + * The entities one controller file answers with, by their own names. + * + * Separate from the walk above so it can be exercised on source text rather than on whatever the + * repository happens to contain today — the alias case has no instance here yet, and a guard whose + * hardest branch never runs is the thing this suite exists to argue against. + */ +export function entitiesReturnedBy(source: string, entities: Set): Set { + // `import { SupportIssue as Issue }` — the handler then names `Issue`, which is not an entity + // name and would drop out of the scan. Mapped back, so renaming an import cannot quietly remove + // a controller from the closure. + const renamed = new Map(); + // The other direction of the same clause: a local name that happens to match an entity while + // standing for something else. Counting it would attribute relations to a response that never + // carries them. + const shadowed = new Set(); + for (const clause of source.matchAll(/import\s*\{([^}]*)\}/g)) + for (const part of clause[1].split(',')) { + const [original, alias] = part.split(/\s+as\s+/).map((piece) => piece.trim()); + if (!alias) continue; + if (entities.has(original)) renamed.set(alias, original); + else shadowed.add(alias); + } + + const found = new Set(); + // The whole return type, then every entity name in it: `Promise`, `Promise` + // and any wrapper around them all have to count. Matching the first identifier after the colon + // reads more simply and silently misses the union forms — the expensive direction, because a + // handler it misses is one whose answer the closure below then fails to cover. + for (const match of source.matchAll(/\)\s*:\s*([^;{]+?)\s*\{/g)) + for (const identifier of match[1].match(/[A-Za-z0-9_]+/g) ?? []) { + if (shadowed.has(identifier)) continue; + const name = renamed.get(identifier) ?? identifier; + if (entities.has(name)) found.add(name); + } + + return found; +} + +/** + * Every eager relation those responses contain, reached recursively. + * + * A relation on this list cannot be removed without changing an answer, and one added to any entity + * on it becomes part of an answer. That is the decision this list exists to force: when it fails, + * the question is not how to make the test pass but whether the endpoints above should carry the + * relation. + */ +const IN_A_PAYLOAD = [ + 'BankData.preferredCurrency', + 'BankTxRepeat.transaction', + 'BankTxReturn.transaction', + 'BankTxReturn.userData', + 'BuyCrypto.batch', + 'BuyCrypto.fee', + 'BuyCrypto.outputAsset', + 'BuyCrypto.outputReferenceAsset', + 'BuyCrypto.transaction', + 'BuyCryptoBatch.outputAsset', + 'BuyCryptoBatch.outputReferenceAsset', + 'BuyCryptoFee.feeReferenceAsset', + 'BuyFiat.outputAsset', + 'BuyFiat.outputReferenceAsset', + 'BuyFiat.transaction', + 'CryptoInput.asset', + 'CryptoInput.route', + // The deposit routes share one table, and the metadata of the parent carries the relations of + // every child. A query on the parent loads all of them, which is why they are on this list. + 'DepositRoute.asset', + 'DepositRoute.deposit', + 'DepositRoute.fiat', + 'DepositRoute.paybackAsset', + 'DepositRoute.paybackDeposit', + 'DepositRoute.rewardAsset', + 'DepositRoute.rewardDeposit', + 'DepositRoute.route', + 'DepositRoute.targetDeposit', + 'Fee.bank', + 'Fee.wallet', + 'FiatOutput.bank', + 'LimitRequest.supportIssue', + 'LiquidityBalance.asset', + 'LiquidityManagementOrder.action', + 'LiquidityManagementOrder.pipeline', + 'LiquidityManagementPipeline.currentAction', + 'LiquidityManagementPipeline.previousAction', + 'LiquidityManagementPipeline.rule', + 'LiquidityManagementRule.deficitStartAction', + 'LiquidityManagementRule.redundancyStartAction', + 'LiquidityManagementRule.targetAsset', + 'LiquidityManagementRule.targetFiat', + 'Organization.country', + 'PaymentLinkPayment.currency', + 'RefReward.outputAsset', + 'RefReward.transaction', + 'SupportIssue.transaction', + 'SupportIssue.transactionRequest', + 'SupportIssue.userData', + 'SupportIssue.wallet', + 'Transaction.user', + 'User.refAsset', + 'UserData.country', + 'UserData.currency', + 'UserData.language', + 'UserData.nationality', + 'UserData.organization', + 'UserData.organizationCountry', + 'UserData.verifiedCountry', +]; + +/** + * Eager relations in this repository, counted per entity — an inherited one counts once for each + * entity that carries it, because that is how often it is loaded. + */ +const EAGER_RELATIONS = 103; + +describeProjection('eager relations', () => { + let dataSource: DataSource; + let byName: Map; + + beforeAll(async () => { + dataSource = await createProjectionDataSource(SCHEMA); + byName = new Map(dataSource.entityMetadatas.map((metadata) => [metadata.name, metadata])); + }, 300000); + + afterAll(async () => { + await destroyProjectionDataSource(dataSource, SCHEMA); + }); + + /** + * Every eager relation reachable from `roots`, with every root that reaches it. + * + * All of them, not the first: `UserData.wallet` is in the answer of the account endpoints and of + * everything that carries an account, and a message naming one of those sends the reader to the + * wrong controller. + */ + function closureOf(roots: string[]): Map> { + const found = new Map>(); + + for (const root of roots) { + const visited = new Set(); + const pending = [root]; + + while (pending.length) { + const name = pending.pop(); + if (visited.has(name)) continue; + visited.add(name); + + for (const relation of byName.get(name)?.eagerRelations ?? []) { + const path = `${name}.${relation.propertyName}`; + if (!found.has(path)) found.set(path, new Set()); + found.get(path).add(root); + pending.push(relation.inverseEntityMetadata.name); + } + } + } + + return found; + } + + it('reads an entity out of a return type, through a union, an array and a renamed import', () => { + const entities = new Set(['SupportIssue', 'UserData']); + const source = ` + import { SupportIssue as Issue } from './support-issue.entity'; + import { UserData } from './user-data.entity'; + import { Something } from './elsewhere'; + + class C { + async one(id: number): Promise { return null; } + async many(): Promise { return []; } + async neither(): Promise { return null; } + }`; + + // The renamed one is the case with no instance in this repository today, which is why it is + // asserted here rather than left to the walk over the real controllers. + expect(entitiesReturnedBy(source, entities)).toEqual(new Set(['SupportIssue', 'UserData'])); + }); + + it('takes a name that is not an entity for nothing, renamed or not', () => { + const entities = new Set(['SupportIssue']); + const source = ` + import { Helper as SupportIssue } from './helper'; + + class C { + async one(): Promise { return null; } + }`; + + // `SupportIssue` here is a local name for something else entirely. Reading the alias map in the + // other direction would report the entity and put a relation in a closure it is not part of. + expect(entitiesReturnedBy(source, entities)).toEqual(new Set()); + }); + + it('finds the controllers that answer with an entity', () => { + // If this reads zero, the search above stopped matching and every assertion below would pass + // for the wrong reason. + expect(entitiesReturnedWhole(new Set(byName.keys())).size).toBeGreaterThan(20); + }); + + it('the responses that are entities contain exactly the eager relations recorded here', () => { + const returned = entitiesReturnedWhole(new Set(byName.keys())); + const closure = closureOf([...returned.keys()]); + + // Reported with the controllers, because that is the part a diff of relation names does not + // show: an added relation is a changed response, and this says whose. + const withOrigin = (paths: string[]): string[] => + paths.map((path) => { + const controllers = [...closure.get(path)].flatMap((origin) => returned.get(origin) ?? []); + return `${path} — in the answer of ${[...new Set(controllers)].sort().join(', ')}`; + }); + + const added = [...closure.keys()].filter((path) => !IN_A_PAYLOAD.includes(path)).sort(); + expect(withOrigin(added)).toEqual([]); + + const removed = IN_A_PAYLOAD.filter((path) => !closure.has(path)).sort(); + expect(removed).toEqual([]); + }); + + it('has no more eager relations than are recorded', () => { + const all = dataSource.entityMetadatas.flatMap((metadata) => + metadata.eagerRelations.map((relation) => `${metadata.name}.${relation.propertyName}`), + ); + + // Not a limit, a count. Every one of these makes some query load a table it was not asked for, + // and the point of writing the number down is that adding one is a decision rather than a + // detail of an unrelated change. + expect(all.length).toEqual(EAGER_RELATIONS); + }); +}); diff --git a/src/shared/models/__tests__/read-projection.spec.ts b/src/shared/models/__tests__/read-projection.spec.ts new file mode 100644 index 0000000000..90f1edfa8d --- /dev/null +++ b/src/shared/models/__tests__/read-projection.spec.ts @@ -0,0 +1,167 @@ +import { readFileSync } from 'fs'; +import { join } from 'path'; +import { ReadProjection } from 'src/shared/models/read-projection'; +import { + BUY_CRYPTO_BUY_HISTORY_PROJECTION, + BUY_CRYPTO_ROUTE_HISTORY_PROJECTION, +} from 'src/subdomains/core/buy-crypto/process/repositories/buy-crypto.repository'; +import { CUSTODY_ORDER_HISTORY_PROJECTION } from 'src/subdomains/core/custody/repositories/custody-order.repository'; +import { PIPELINE_STATUS_PROJECTION } from 'src/subdomains/core/liquidity-management/repositories/liquidity-management-pipeline.repository'; +import { POS_LINK_PROJECTION } from 'src/subdomains/core/payment-link/repositories/payment-link.repository'; +import { SUSPENSE_LEG_PROJECTION } from 'src/subdomains/core/accounting/repositories/ledger-leg.repository'; +import { BUY_FIAT_HISTORY_PROJECTION } from 'src/subdomains/core/sell-crypto/process/buy-fiat.repository'; +import { + API_KEY_PROJECTION, + USER_PROFILE_PROJECTION, + USER_V2_PROJECTION, +} from 'src/subdomains/generic/user/models/user-data/user-data.repository'; +import { USER_KYC_FILES_PROJECTION } from 'src/subdomains/generic/user/models/user/user.repository'; +import { WALLET_KYC_DATA_PROJECTION } from 'src/subdomains/generic/user/models/wallet/wallet.repository'; +import { + SUPPORT_ISSUE_DATA_PROJECTION, + SUPPORT_ISSUE_LIST_PROJECTION, + SUPPORT_ISSUE_PROJECTION, +} from 'src/subdomains/supporting/support-issue/repositories/support-issue.repository'; +import { SelectQueryBuilder } from 'typeorm'; + +/** + * Every endpoint whose `Max cols` in the inventory is the size of a projection. + * + * The version is part of the key: `/user` exists twice, and only the v2 handler is projected. + */ +const DOCUMENTED: [string, string, string, ReadProjection][] = [ + ['GET', '2', '/user/profile', USER_PROFILE_PROJECTION], + ['GET', '2', '/user', USER_V2_PROJECTION], + ['POST', '1', '/user/apiKey/CT', API_KEY_PROJECTION], + ['GET', '1', '/buy/:id/history', BUY_CRYPTO_BUY_HISTORY_PROJECTION], + ['GET', '1', '/swap/:id/history', BUY_CRYPTO_ROUTE_HISTORY_PROJECTION], + ['GET', '1', '/sell/:id/history', BUY_FIAT_HISTORY_PROJECTION], + ['GET', '1', '/support/issue/:id/data', SUPPORT_ISSUE_DATA_PROJECTION], + ['GET', '1', '/support/issue', SUPPORT_ISSUE_PROJECTION], + ['GET', '1', '/support/issue/:id', SUPPORT_ISSUE_PROJECTION], + ['GET', '1', '/support/issue/list', SUPPORT_ISSUE_LIST_PROJECTION], + ['GET', '1', '/realunit/support/list', SUPPORT_ISSUE_LIST_PROJECTION], + ['GET', '1', '/kyc/users', WALLET_KYC_DATA_PROJECTION], + ['GET', '1', '/kyc/:id/documents', USER_KYC_FILES_PROJECTION], + ['GET', '1', '/custody/order', CUSTODY_ORDER_HISTORY_PROJECTION], + ['GET', '1', '/dashboard/accounting/ledger/suspense', SUSPENSE_LEG_PROJECTION], + ['GET', '1', '/liquidityManagement/pipeline/:id/status', PIPELINE_STATUS_PROJECTION], + ['PUT', '1', '/paymentLink/:id/pos', POS_LINK_PROJECTION], +]; + +describe('ReadProjection', () => { + it('applies the joins and selects exactly the fields it was given', () => { + const selected: string[][] = []; + const joined: [string, string][] = []; + const query = { + leftJoin: (path: string, alias: string) => { + joined.push([path, alias]); + return query; + }, + select: (fields: string[]) => { + selected.push(fields); + return query; + }, + } as unknown as SelectQueryBuilder; + + const projection = new ReadProjection('root', [['root.rel', 'rel']], ['root.a', 'rel.b'], ['root.id']); + projection.apply(query); + + expect(joined).toEqual([['root.rel', 'rel']]); + expect(selected).toEqual([['root.a', 'rel.b', 'root.id']]); + }); + + it('keeps the guards when a reduced field list is passed', () => { + const selected: string[][] = []; + const query = { + leftJoin: () => query, + select: (fields: string[]) => { + selected.push(fields); + return query; + }, + } as unknown as SelectQueryBuilder; + + const projection = new ReadProjection('root', [], ['root.a', 'root.b'], ['root.id']); + // This is what the mutation test does: drop one response field, keep everything else. A guard + // dropped along with it would make every mutation run fail for the wrong reason. + projection.apply(query, ['root.a']); + + expect(selected).toEqual([['root.a', 'root.id']]); + }); + + it('applies a join declared as inner as an inner join', () => { + const joined: [string, string, string][] = []; + const query = { + leftJoin: (path: string, alias: string) => { + joined.push([path, alias, 'left']); + return query; + }, + innerJoin: (path: string, alias: string) => { + joined.push([path, alias, 'inner']); + return query; + }, + select: () => query, + } as unknown as SelectQueryBuilder; + + new ReadProjection( + 'root', + [ + ['root.a', 'a'], + ['root.b', 'b', 'inner'], + ], + ['a.x', 'b.y'], + ).apply(query); + + expect(joined).toEqual([ + ['root.a', 'a', 'left'], + ['root.b', 'b', 'inner'], + ]); + }); + + it('refuses a field list naming an alias it does not join', () => { + // The guard watches the aliases the projection declares. A relation joined on the query builder + // instead is selected but unwatched, so reading a column it did not select answers undefined + // rather than throwing — the exact defect this whole suite exists to make loud. + expect(() => new ReadProjection('root', [], ['root.a', 'rel.b'])).toThrow( + "projection 'root' names aliases it does not join: 'rel'", + ); + // Guards are checked too: they are selected columns like any other, and a guard on an unjoined + // alias is the same silent hole. + expect(() => new ReadProjection('root', [['root.rel', 'rel']], ['root.a'], ['other.id'])).toThrow("'other'"); + }); + + it('every projection in the inventory joins each alias its fields name', () => { + // Asserted over the real projections rather than a constructed one: the rule above only helps + // if it holds for what the endpoints actually use. + for (const [method, version, path, projection] of DOCUMENTED) { + const declared = new Set([projection.alias, ...projection.joins.map(([, alias]) => alias)]); + const named = [...projection.fields, ...projection.guards].map((field) => field.split('.')[0]); + + expect({ endpoint: `${method} v${version} ${path}`, undeclared: named.filter((a) => !declared.has(a)) }).toEqual({ + endpoint: `${method} v${version} ${path}`, + undeclared: [], + }); + } + }); + + describe('the column counts in docs/endpoints.md', () => { + // The inventory is the work list and is required to stay in sync with the code. A number + // written by hand drifts the first time a field is added, and nothing would say so — this reads + // the document and compares. + const inventory = readFileSync(join(__dirname, '../../../../docs/endpoints.md'), 'utf8').split('\n'); + + it.each(DOCUMENTED)('%s v%s %s matches the projection', (verb, version, path, projection) => { + const row = inventory.find((line) => { + const cells = line.split('|').map((cell) => cell.trim()); + return cells[1] === verb && cells[2] === version && cells[4] === `\`${path}\``; + }); + expect(row).toBeDefined(); + + const cells = row.split('|').map((cell) => cell.trim()); + const [access, maxCols] = [cells[6], cells[7]]; + + expect(access).toEqual('projected'); + expect(+maxCols).toEqual(projection.fields.length + projection.guards.length); + }); + }); +}); diff --git a/src/shared/models/read-projection.ts b/src/shared/models/read-projection.ts new file mode 100644 index 0000000000..d87c56615c --- /dev/null +++ b/src/shared/models/read-projection.ts @@ -0,0 +1,60 @@ +import { SelectQueryBuilder } from 'typeorm'; + +/** + * An explicit field list for a read path, together with the joins it needs. + * + * A value rather than a chain of calls at the call site, so that the mutation test can re-run the + * production query with one field removed instead of rebuilding it. `select` inside `find` options + * would not do: it narrows the root entity but still pulls in the eager relations. + * + * Field names are what the query builder expects: `alias.property`, where `alias` is the root alias + * or one declared in `joins`. + */ +export class ReadProjection { + constructor( + readonly alias: string, + /** + * `[relation path, alias]` or `[relation path, alias, 'inner']`, applied in order. A later join + * may build on an earlier alias. Joins belong here rather than at the query: the guard derives + * what it watches from this list, so a relation joined on the builder instead is not watched. + */ + readonly joins: ReadonlyArray, + /** Fields that feed the response. These are what the mutation test drops one by one. */ + readonly fields: ReadonlyArray, + /** + * Fields the query needs but the response never shows: primary keys that make the ORM + * materialise a joined relation, and values a guard decides on before the mapper runs. + * + * They are kept apart because the mutation test asserts through the response. Dropping a guard + * changes no response field, so it would be reported as removable although the endpoint breaks + * without it. Each one needs its own assertion instead — see the specs. + */ + readonly guards: ReadonlyArray = [], + ) { + const declared = new Set([alias, ...joins.map(([, joinAlias]) => joinAlias)]); + const undeclared = [...new Set([...fields, ...guards].map((field) => field.split('.')[0]))].filter( + (fieldAlias) => !declared.has(fieldAlias), + ); + + if (undeclared.length) + throw new Error( + `projection '${alias}' names aliases it does not join: ${undeclared.map((a) => `'${a}'`).join(', ')} — ` + + `declare them here rather than on the query builder, or the guard does not watch them`, + ); + } + + /** + * Applies the joins and the field list to a query builder. + * + * `fields` defaults to the declared list; the mutation test passes a reduced one. Guards and joins + * are applied either way — dropping a field must change the selected columns and nothing else, or + * the test would be measuring the join instead of the projection. + */ + apply(query: SelectQueryBuilder, fields: ReadonlyArray = this.fields): SelectQueryBuilder { + for (const [path, alias, kind] of this.joins) { + if (kind === 'inner') query.innerJoin(path, alias); + else query.leftJoin(path, alias); + } + return query.select([...fields, ...this.guards]); + } +} diff --git a/src/shared/utils/__tests__/projection-guard.projection.spec.ts b/src/shared/utils/__tests__/projection-guard.projection.spec.ts new file mode 100644 index 0000000000..fdabd23ce5 --- /dev/null +++ b/src/shared/utils/__tests__/projection-guard.projection.spec.ts @@ -0,0 +1,254 @@ +import { ReadProjection } from 'src/shared/models/read-projection'; +import { + createProjectionDataSource, + describeProjection, + destroyProjectionDataSource, + guardProjection, + seedEntity, +} from 'src/shared/utils/projection-test.util'; +import { Country } from 'src/shared/models/country/country.entity'; +import { CryptoInput } from 'src/subdomains/supporting/payin/entities/crypto-input.entity'; +import { Language } from 'src/shared/models/language/language.entity'; +import { LedgerLeg } from 'src/subdomains/core/accounting/entities/ledger-leg.entity'; +import { UserData } from 'src/subdomains/generic/user/models/user-data/user-data.entity'; +import { DataSource } from 'typeorm'; + +const SCHEMA = 'projection_guard_spec'; + +/** + * The guard itself, tested against a real query. + * + * Everything else in this suite relies on it: it is what turns a silently missing column into a + * failure, and it is therefore the one piece that cannot be verified by the levels it protects. + */ +describeProjection('guardProjection', () => { + let dataSource: DataSource; + + beforeAll(async () => { + dataSource = await createProjectionDataSource(SCHEMA); + }, 300000); + + afterAll(async () => { + await destroyProjectionDataSource(dataSource, SCHEMA); + }); + + const FIELDS = ['userData.id', 'userData.mail', 'userData.firstname']; + const PROJECTION = new ReadProjection('userData', [['userData.language', 'language']], FIELDS); + + async function load(fields: ReadonlyArray, id: number): Promise { + const row = await PROJECTION.apply(dataSource.getRepository(UserData).createQueryBuilder('userData'), fields) + .where('userData.id = :id', { id }) + .getOne(); + + return guardProjection(dataSource, UserData, PROJECTION, fields, row); + } + + it('passes through a column the query selected', async () => { + const language = await seedEntity(dataSource, Language); + const seeded = await seedEntity(dataSource, UserData, { values: { language } }); + + const row = await load(FIELDS, seeded.id); + + expect(row.mail).toEqual(seeded.mail); + expect(row.firstname).toEqual(seeded.firstname); + }, 120000); + + it('throws on a column the query did not select, naming it', async () => { + const language = await seedEntity(dataSource, Language); + const seeded = await seedEntity(dataSource, UserData, { values: { language } }); + + const row = await load(FIELDS, seeded.id); + + // Without the guard this reads `undefined` and every getter downstream computes with it. + expect(() => row.surname).toThrow("read of 'UserData.surname', which this query did not select"); + }, 120000); + + it('reports the column a getter reaches through, not the getter', async () => { + const language = await seedEntity(dataSource, Language); + const seeded = await seedEntity(dataSource, UserData, { values: { language } }); + + // `completeName` is `organizationName ?? firstname + surname`. The getter has to keep running — + // it is how the missing column is reached — and the failure has to name the column. + const row = await load(FIELDS, seeded.id); + + expect(() => row.completeName).toThrow('UserData.organizationName'); + }, 120000); + + it('guards a joined relation in turn', async () => { + const language = await seedEntity(dataSource, Language); + const seeded = await seedEntity(dataSource, UserData, { values: { language } }); + + const fields = [...FIELDS, 'language.id', 'language.name']; + const row = await load(fields, seeded.id); + + expect(row.language.name).toEqual(language.name); + // Two levels down, and reported where it is missing rather than at the relation. + expect(() => row.language.symbol).toThrow("read of 'Language.symbol'"); + }, 120000); + + it('throws on a relation it joins but selects nothing from', async () => { + const language = await seedEntity(dataSource, Language); + const seeded = await seedEntity(dataSource, UserData, { values: { language } }); + + // `FIELDS` names no `language.*`, so the join exists for filtering alone. The relation is then + // undefined on the row for every account, and code checking it takes the absent branch — which + // reads exactly like an account that genuinely has no language. + const row = await load(FIELDS, seeded.id); + + expect(() => row.language).toThrow( + "read of 'UserData.language', a relation this query joins but selects nothing from", + ); + }, 120000); + + it('leaves a relation the query does not join alone, eager or not', async () => { + const language = await seedEntity(dataSource, Language); + const seeded = await seedEntity(dataSource, UserData, { values: { language } }); + + const bare = new ReadProjection('userData', [], ['userData.id']); + const row = await bare + .apply(dataSource.getRepository(UserData).createQueryBuilder('userData'), bare.fields) + .where('userData.id = :id', { id: seeded.id }) + .getOne(); + const guarded = guardProjection(dataSource, UserData, bare, bare.fields, row); + + // Whether an undeclared relation should have been joined depends on what the replaced query + // loaded, which this entity's metadata does not record — `language` is eager and `kycSteps` is + // not, and neither fact settles it, because a `find` can switch eager loading off and name its + // relations instead. Level 4 compares the two answers where that is observable; here both are + // simply passed through. + expect(guarded.language).toBeUndefined(); + expect(guarded.kycSteps).toBeUndefined(); + }, 120000); + + it('hands out the same guarded relation on every read', async () => { + const language = await seedEntity(dataSource, Language); + const seeded = await seedEntity(dataSource, UserData, { values: { language } }); + + const fields = [...FIELDS, 'language.id']; + const row = await load(fields, seeded.id); + + // A fresh proxy per access would make this false, and production code comparing relations by + // identity — or using one as a map key — would behave differently under the guard than without + // it. The guard has to be invisible except where it throws. + expect(row.language).toBe(row.language); + + // It also carries the caller's assignments across reads: with a new proxy each time, each one + // starts with an empty set of them and reading back what was just written throws. + row.language.symbol = 'assigned-by-the-caller'; + expect(row.language.symbol).toEqual('assigned-by-the-caller'); + }, 120000); + + it('throws on a @RelationId, which no field list can select', async () => { + // The property is filled from the foreign-key column of the row, which a query naming its + // fields does not carry — the defect this suite exists to catch, in the one shape where the + // fix is never "add it to the projection". + const leg = guardProjection( + dataSource, + LedgerLeg, + new ReadProjection('leg', [], ['leg.id']), + ['leg.id'], + { id: 1 } as LedgerLeg, + ); + + expect(() => leg.txId).toThrow("read of 'LedgerLeg.txId', a @RelationId that a projected query never fills"); + }, 120000); + + it('guards a column inside an embedded object, by its full path', async () => { + const projection = new ReadProjection('input', [], ['input.id', 'input.address.address']); + const row = { id: 1, address: { address: 'selected', blockchain: 'hidden' } } as unknown as CryptoInput; + + const input = guardProjection(dataSource, CryptoInput, projection, projection.fields, row); + + // Selecting one column of the embedded must not mark the rest of it as selected. + expect(input.address.address).toEqual('selected'); + expect(() => input.address.blockchain).toThrow("read of 'CryptoInput.address.blockchain'"); + }, 120000); + + it('leaves a @RelationId alone when the query did fill it', async () => { + // Guarded on the value, not on the declaration: the throw is for the silent `undefined`, and a + // value that did arrive is not a defect whatever filled it. + const leg = guardProjection( + dataSource, + LedgerLeg, + new ReadProjection('leg', [], ['leg.id']), + ['leg.id'], + { id: 1, txId: 7 } as LedgerLeg, + ); + + expect(leg.txId).toEqual(7); + }, 120000); + + it('lets an embedded column be read back after the caller assigned it', async () => { + const projection = new ReadProjection('input', [], ['input.id']); + const input = guardProjection(dataSource, CryptoInput, projection, projection.fields, { + id: 1, + address: {}, + } as unknown as CryptoInput); + + input.address.address = 'written-by-the-caller'; + + // Without the write being recorded this throws, because the query never selected the column. + expect(input.address.address).toEqual('written-by-the-caller'); + }, 120000); + + it.each(['getMany', 'getOneOrFail'] as const)( + 'guards the rows of %s', + async (method) => { + const language = await seedEntity(dataSource, Language); + const seeded = await seedEntity(dataSource, UserData, { values: { language } }); + + const query = PROJECTION.apply(dataSource.getRepository(UserData).createQueryBuilder('userData'), FIELDS).where( + 'userData.id = :id', + { id: seeded.id }, + ); + const rows = await query[method](); + const row = Array.isArray(rows) ? rows[0] : rows; + + expect(() => row.surname).toThrow("read of 'UserData.surname'"); + }, + 120000, + ); + + it('guards the entities of getRawAndEntities and leaves the raw rows untouched', async () => { + const language = await seedEntity(dataSource, Language); + const seeded = await seedEntity(dataSource, UserData, { values: { language } }); + + const { entities, raw } = await PROJECTION.apply( + dataSource.getRepository(UserData).createQueryBuilder('userData'), + FIELDS, + ) + .where('userData.id = :id', { id: seeded.id }) + .getRawAndEntities(); + + expect(() => entities[0].surname).toThrow("read of 'UserData.surname'"); + // The raw half is what the caller asked the database for, not an entity — reading it is not a + // projection question and the guard must not touch it. + expect(raw).toHaveLength(1); + expect(Object.keys(raw[0]).length).toBeGreaterThan(0); + }, 120000); + + it('guards the rows of getManyAndCount, which runs its own query', async () => { + const language = await seedEntity(dataSource, Language); + await seedEntity(dataSource, UserData, { values: { language } }); + + const [rows] = await PROJECTION.apply( + dataSource.getRepository(UserData).createQueryBuilder('userData'), + FIELDS, + ).getManyAndCount(); + + expect(() => rows[0].surname).toThrow("read of 'UserData.surname'"); + }, 120000); + + it('leaves a column the query selected but the row has as null alone', async () => { + const language = await seedEntity(dataSource, Language); + const country = await seedEntity(dataSource, Country); + const seeded = await seedEntity(dataSource, UserData, { + values: { language, country, firstname: null }, + }); + + // A selected column that is genuinely null is data, not a projection defect. + const row = await load(FIELDS, seeded.id); + + expect(row.firstname).toBeNull(); + }, 120000); +}); diff --git a/src/shared/utils/__tests__/query-builder-alias.spec.ts b/src/shared/utils/__tests__/query-builder-alias.spec.ts index f31076713b..4fa43d20db 100644 --- a/src/shared/utils/__tests__/query-builder-alias.spec.ts +++ b/src/shared/utils/__tests__/query-builder-alias.spec.ts @@ -132,8 +132,53 @@ describe('Query Builder Alias Enforcement', () => { /** * Extract all aliases defined in a query chain (main alias + joins + subqueries) */ - const extractAllAliases = (queryChain: string, mainAlias: string): Set => { - const aliases = new Set([mainAlias]); + /** + * Aliases declared by a `ReadProjection` constant rather than in the chain. + * + * `PROJECTION.apply(this.createQueryBuilder('x'))` applies its joins from the constant, so the + * aliases they introduce never appear in the chain the scan sees. Without this the chain's own + * `where('joinedAlias.id = :id')` looks like a bare column reference — the opposite of what this + * test is for. + */ + const extractProjectionAliases = (fileContent: string, queryChain: string): Set => { + const aliases = new Set(); + + // Only the projection this chain applies. Taking every projection in the file would let a query + // reference an alias declared by a different one - the file often holds several - and the check + // would pass on a query that never joined it. + const applied = new Set(); + const applyPattern = /\b([A-Z][A-Z0-9_]*)\s*\.\s*apply\s*\(/g; + let applyMatch; + while ((applyMatch = applyPattern.exec(queryChain)) !== null) applied.add(applyMatch[1]); + if (!applied.size) return aliases; + + // The root alias, then every `['relation.path', 'alias']` pair up to the end of the declaration. + // Reading the join array with a lazy match would stop at the first inner `]` and pick up only + // one of the joins. + const projectionPattern = /export const ([A-Z][A-Z0-9_]*) = new ReadProjection<[^>]*>\(\s*['"`](\w+)['"`]/g; + let match; + while ((match = projectionPattern.exec(fileContent)) !== null) { + if (!applied.has(match[1])) continue; + aliases.add(match[2]); + const end = fileContent.indexOf('\n);', match.index); + const declaration = fileContent.slice(match.index, end < 0 ? undefined : end); + // The third element is the optional join kind. Without it in the pattern a projection that + // declares an inner join contributes no alias at all, and every reference to it is reported + // as a bare column. + const joinPattern = /\[\s*['"`][^'"`]+\.[^'"`]+['"`]\s*,\s*['"`](\w+)['"`]\s*(?:,\s*['"`]\w+['"`]\s*)?\]/g; + let joinMatch; + while ((joinMatch = joinPattern.exec(declaration)) !== null) aliases.add(joinMatch[1]); + } + return aliases; + }; + + const extractAllAliases = ( + queryChain: string, + mainAlias: string, + fileContent = '', + applyContext = '', + ): Set => { + const aliases = new Set([mainAlias, ...extractProjectionAliases(fileContent, applyContext + queryChain)]); // Find join aliases: .leftJoin('relation', 'alias') or .innerJoin('relation', 'alias') // This handles both relation joins and entity joins @@ -210,7 +255,10 @@ describe('Query Builder Alias Enforcement', () => { // Get all valid aliases (main + joins + subqueries) // Also include all query aliases from the file for correlated subquery support - const validAliases = extractAllAliases(queryChain, mainAlias); + // `PROJECTION.apply(this.createQueryBuilder('x'), fields)` wraps the builder, so the constant + // stands before the chain rather than inside it. The preceding text is passed along for that. + const applyContext = content.slice(Math.max(0, chainStartIndex - 200), chainStartIndex); + const validAliases = extractAllAliases(queryChain, mainAlias, content, applyContext); for (const alias of allQueryAliases) { validAliases.add(alias); } @@ -494,6 +542,64 @@ describe('Query Builder Alias Enforcement', () => { }); }); + describe('projection aliases', () => { + // A repository file usually declares several projections. Their aliases are not + // interchangeable: a query applying one of them has joined only that one's relations. + const twoProjections = ` +export const FIRST_PROJECTION = new ReadProjection( + 'thing', + [['thing.owner', 'firstOwner']], + ['thing.id'], +); + +export const SECOND_PROJECTION = new ReadProjection( + 'thing', + [['thing.other', 'secondOther']], + ['thing.id'], +); + +export const THIRD_PROJECTION = new ReadProjection( + 'thing', + [ + ['thing.inner', 'innerJoined', 'inner'], + ['thing.left', 'leftJoined'], + ], + ['thing.id'], +); +`; + + it('accepts an alias declared by the projection the query applies', () => { + const chain = `FIRST_PROJECTION.apply(this.createQueryBuilder('thing')).where('firstOwner.id = :id', { id })`; + + expect(extractAllAliases(chain, 'thing', twoProjections).has('firstOwner')).toBe(true); + }); + + it('rejects an alias declared by a different projection in the same file', () => { + const chain = `FIRST_PROJECTION.apply(this.createQueryBuilder('thing')).where('secondOther.id = :id', { id })`; + + // Without this the query would pass the scan while referencing a relation it never joined. + expect(extractAllAliases(chain, 'thing', twoProjections).has('secondOther')).toBe(false); + }); + + it('takes the aliases of a projection declaring a join kind', () => { + // A join carrying `'inner'` is a three-element tuple. Read with a two-element pattern it + // matches nothing, and the scan then rejects every reference to the relation it joined. + const chain = `THIRD_PROJECTION.apply(this.createQueryBuilder('thing')).where('innerJoined.id = :id', { id })`; + + const aliases = extractAllAliases(chain, 'thing', twoProjections); + + expect(aliases.has('innerJoined')).toBe(true); + // The plain join beside it still resolves — the optional element must not swallow the pair. + expect(aliases.has('leftJoined')).toBe(true); + }); + + it('takes no projection aliases at all when the chain applies none', () => { + const chain = `this.createQueryBuilder('thing').where('thing.id = :id', { id })`; + + expect(extractAllAliases(chain, 'thing', twoProjections)).toEqual(new Set(['thing'])); + }); + }); + it('should have all createQueryBuilder() calls with an alias', () => { const files = getAllTypeScriptFiles(srcDir); const allIssues: { file: string; issues: { line: number; content: string }[] }[] = []; diff --git a/src/shared/utils/projection-test.util.ts b/src/shared/utils/projection-test.util.ts new file mode 100644 index 0000000000..439841174b --- /dev/null +++ b/src/shared/utils/projection-test.util.ts @@ -0,0 +1,575 @@ +import { ReadProjection } from 'src/shared/models/read-projection'; +import { DataSource, EntityMetadata, EntityTarget, ObjectLiteral, SelectQueryBuilder } from 'typeorm'; +import { ColumnMetadata } from 'typeorm/metadata/ColumnMetadata'; + +/** + * Test support for the read-path projections described in `docs/read-path-projections.md`. + * + * Needs a real database and skips without `MIGRATION_TEST_PG`, the gate the migration specs use. + * The schema is built from the entity metadata, which is what a projection has to be complete + * against. + */ + +export const PROJECTION_TEST_PG = process.env.MIGRATION_TEST_PG; + +/** `describe` when a database is configured, `describe.skip` otherwise. */ +export const describeProjection = PROJECTION_TEST_PG ? describe : describe.skip; + +/** + * A data source over its own Postgres schema, with the schema created from the entity metadata. + * + * Every spec file passes its own schema name — Jest distributes spec files across workers, and two + * of them writing the same tables would make the results depend on the scheduling. + */ +export async function createProjectionDataSource(schema: string): Promise { + const bootstrap = new DataSource({ type: 'postgres', url: PROJECTION_TEST_PG, logging: false }); + await bootstrap.initialize(); + try { + // Recreate rather than reuse: a schema left behind by an earlier run may predate the entities. + await bootstrap.query(`DROP SCHEMA IF EXISTS "${schema}" CASCADE`); + await bootstrap.query(`CREATE SCHEMA "${schema}"`); + } finally { + // Closed even when the schema could not be prepared: the caller never receives this instance, + // so `afterAll` has nothing to close and the connection would outlive the run. + await bootstrap.destroy(); + } + + const dataSource = new DataSource({ + type: 'postgres', + url: PROJECTION_TEST_PG, + schema, + // Loaded as sources, so the spec sees the entities of the working tree rather than a stale build. + entities: [__dirname + '/../../**/*.entity.ts'], + synchronize: false, + logging: false, + }); + await dataSource.initialize(); + try { + await dataSource.synchronize(); + } catch (e) { + await dataSource.destroy(); + throw e; + } + + return dataSource; +} + +/** Drops the schema and closes the connection. Safe to call with `undefined`. */ +export async function destroyProjectionDataSource(dataSource: DataSource | undefined, schema: string): Promise { + if (!dataSource?.isInitialized) return; + await dataSource.query(`DROP SCHEMA IF EXISTS "${schema}" CASCADE`); + await dataSource.destroy(); +} + +/** Which optional relations to populate, and with what. `true` means "populate with defaults". */ +export interface SeedSpec { + /** + * Fixed column values. Anything not listed gets a generated one. + * + * Pin every column holding a TypeScript enum in a text column: the metadata reports it as + * `varchar`, so the generated value is not a member and a mapper looking it up answers + * `undefined` — indistinguishable from a missing column. + */ + values?: ObjectLiteral; + /** Optional relations to populate. Required ones are always populated. */ + relations?: Record; +} + +// One counter for the whole process, so that generated numbers, dates and strings differ between +// rows — which is what keeps unique constraints satisfied when a spec seeds the same entity twice. +// A very short column is the limit: the counter is base-36 encoded and truncated to the declared +// length, so a `varchar(1)` repeats after 36 rows and such a column has to be pinned. Booleans and +// enums cannot be: a boolean has two values and an enum as many as it declares, so a spec that needs +// to tell two such columns apart pins them in the fixture. What every generated value is, is +// non-empty — which is what makes an empty field in a response proof that the query failed to load +// something. +let counter = 0; +const nextSeed = (): number => ++counter; + +const BASE_DATE = new Date('2020-01-01T00:00:00.000Z').getTime(); + +function generatedValue(column: ColumnMetadata, seed: number): unknown { + if (column.enum?.length) return column.enum[seed % column.enum.length]; + + const type = typeof column.type === 'function' ? column.type.name.toLowerCase() : String(column.type).toLowerCase(); + + if (['boolean', 'bool'].includes(type)) return true; + if (['date', 'datetime', 'datetime2', 'timestamp', 'timestamptz', 'timestamp with time zone'].includes(type)) + return new Date(BASE_DATE + seed * 1000); + if ( + [ + 'number', + 'int', + 'int2', + 'int4', + 'int8', + 'integer', + 'smallint', + 'bigint', + 'float', + 'float4', + 'float8', + 'double precision', + 'real', + 'numeric', + 'decimal', + ].includes(type) + ) + return seed; + if (['json', 'jsonb'].includes(type)) return { seed }; + + // Everything else is treated as text. Two constraints pull in opposite directions here: the + // declared length rejects anything longer (`country.symbol3` is varchar(3)), while uniqueness + // requires the counter to survive the cut — truncating `symbol3-12` to three characters yields + // "sym" for every row and the second insert fails on the unique index. So the counter is what + // gets kept, base-36 encoded to fit more values into a short column, and the name fills whatever + // room is left. + const token = seed.toString(36); + const text = `${column.propertyName}-${token}`; + const length = Number(column.length); + if (!(length > 0) || length >= text.length) return text; + if (length <= token.length) return token.slice(-length); + return column.propertyName.slice(0, length - token.length) + token; +} + +function isPopulatable(column: ColumnMetadata): boolean { + return ( + !column.isGenerated && !column.isCreateDate && !column.isUpdateDate && !column.isVersion && !column.relationMetadata + ); +} + +/** + * Assigns along a dotted path, creating the intermediate objects. + * + * Needed for embedded columns: their `propertyPath` is `address.address`, and writing that as one + * flat key leaves a string where the ORM expects an object — it then fails with "cannot create + * property 'address' on string" while flushing. + */ +function setPath(target: ObjectLiteral, path: string, value: unknown): void { + const parts = path.split('.'); + let current = target; + for (const part of parts.slice(0, -1)) { + if (typeof current[part] !== 'object' || current[part] === null) current[part] = {}; + current = current[part] as ObjectLiteral; + } + current[parts[parts.length - 1]] = value; +} + +/** Whether the caller pinned this column, by property name or by the full embedded path. */ +function isPinned(spec: SeedSpec, column: ColumnMetadata): boolean { + if (!spec.values) return false; + if (column.propertyPath in spec.values || column.propertyName in spec.values) return true; + const [root] = column.propertyPath.split('.'); + return root in spec.values; +} + +/** + * Inserts one row with a non-empty value in every column, creating required relations recursively. + * + * Generated from the metadata on purpose: a hand-written fixture that leaves a field empty makes + * the completeness test green and the defect invisible. + */ +export async function seedEntity( + dataSource: DataSource, + target: EntityTarget, + spec: SeedSpec = {}, +): Promise { + const metadata = dataSource.getMetadata(target); + const entity: ObjectLiteral = {}; + + for (const relation of metadata.relations) { + const requested = spec.relations?.[relation.propertyName]; + // Only owning to-one sides carry a foreign key on this row; the inverse sides are populated + // from the other end and would recurse without end. + if (!relation.isManyToOne && !(relation.isOneToOne && relation.isOwning)) continue; + if (spec.values && relation.propertyName in spec.values) continue; + const required = !relation.isNullable; + if (!requested && !required) continue; + entity[relation.propertyName] = await seedEntity( + dataSource, + relation.inverseEntityMetadata.target, + requested === true || requested === undefined ? {} : requested, + ); + } + + for (const column of metadata.columns) { + if (!isPopulatable(column)) continue; + if (isPinned(spec, column)) continue; + setPath(entity, column.propertyPath, generatedValue(column, nextSeed())); + } + + // Through `setPath` rather than `Object.assign`: `isPinned` accepts a full embedded path, and a + // flat `'address.address'` key would leave the embedded field itself unset while suppressing the + // generated value for it. A key without a dot is assigned exactly as before. + for (const [key, value] of Object.entries(spec.values ?? {})) setPath(entity, key, value); + + return dataSource.getRepository(target).save(entity as E); +} + +/** + * Level 1 — with a fully populated fixture, no field of the response may be empty. + * + * `undefined`, `null`, `''` and `NaN` count as empty; `0` and `false` do not. `NaN` is on the list + * because a field computed as `a + b + c` becomes `NaN` as soon as one column is missing. + * + * `optional` lists paths allowed to be empty for the fixture at hand. Covering those fields is the + * job of another fixture, which this function does not track. + */ +export function expectNoEmptyFields(value: unknown, optional: string[] = [], path = ''): void { + const empty = + value === undefined || value === null || value === '' || (typeof value === 'number' && Number.isNaN(value)); + if (empty) { + if (optional.includes(path)) return; + throw new Error(`field '${path || ''}' is empty — the query did not load it`); + } + if (Array.isArray(value)) { + if (!value.length && !optional.includes(path)) throw new Error(`array '${path}' is empty`); + value.forEach((item, index) => expectNoEmptyFields(item, optional, `${path}[${index}]`)); + return; + } + if (value instanceof Date || typeof value !== 'object') return; + for (const [key, item] of Object.entries(value as ObjectLiteral)) + expectNoEmptyFields(item, optional, path ? `${path}.${key}` : key); +} + +/** + * Passed to a mutation run to keep the projection whole — it matches no field name, so + * `projectionFieldsWithout` returns the list unchanged. Used for the baseline run. + */ +export const NOTHING_OMITTED: string[] = []; + +/** A projection's field list without the given field or fields. */ +export function projectionFieldsWithout(fields: ReadonlyArray, omitted: string | string[]): string[] { + const drop = new Set(Array.isArray(omitted) ? omitted : [omitted]); + return fields.filter((field) => !drop.has(field)); +} + +/** + * Level 3 — removing any candidate from the projection must change the response. + * + * `run` receives the fields to leave out and returns what the production query answers without + * them. The caller picks the candidates: which fields feed a response depends on the fixture, and + * columns behind a fallback have to be dropped as a group. + * + * Compared against the full response rather than checked for emptiness — a missing column can + * yield a valid-looking wrong value. Candidates whose removal changes nothing are reported by name. + */ +export async function expectEveryFieldRequired( + candidates: ReadonlyArray, + run: (omitted: string | string[]) => Promise, + optional: string[] = [], +): Promise { + // Establish the baseline first. Without it this level passes vacuously: if the response is already + // incomplete with the full field list — a fixture that leaves an enum at a value no mapper knows, + // say — then every reduced run fails too, and "every field is required" would be reported for a + // projection nothing was ever proven about. `NOTHING_OMITTED` matches no field name, so the caller + // reduces by nothing and runs the query as production does. + let baseline: unknown; + try { + baseline = await run(NOTHING_OMITTED); + expectNoEmptyFields(baseline, optional); + } catch (error) { + throw new Error( + `the response is already incomplete with the full projection, so dropping fields proves ` + + `nothing — fix the fixture or the projection first. Cause: ${error.message ?? error}`, + ); + } + const reference = JSON.stringify(baseline); + + const survived: string[] = []; + for (const candidate of candidates) { + let unchanged = false; + try { + unchanged = JSON.stringify(await run(candidate)) === reference; + } catch { + // The query itself refused to run without the field — it carries weight, which is what this + // level asserts. + } + if (unchanged) survived.push(Array.isArray(candidate) ? `[${candidate.join(' + ')}]` : candidate); + } + if (survived.length) + throw new Error( + `these fields can be dropped without changing the response: ${survived.join(', ')} — ` + + `either they are not needed, or the fixture does not reach the branch that reads them`, + ); +} + +/** Metadata helper: every column an entity would load without a projection. */ +export function allColumnNames(metadata: EntityMetadata): string[] { + return metadata.columns.map((column) => column.propertyName); +} + +/** + * Makes an incomplete projection fail loudly: reading a column the field list did not ask for + * throws, naming it. See `docs/read-path-projections.md` for why. + * + * The decision comes from the field list, not from the row — this repository compiles to a target + * where class fields are defined on the instance, so an unselected column and a selected `null` + * look the same on the object. + * + * Joined relations are guarded against their own alias. Relations the projection does not join are + * left alone; they are `undefined` and dereferencing them already throws. Getters pass through, + * because reading through one is how the missing column is reached. + */ +export function guardProjection( + dataSource: DataSource, + target: EntityTarget, + projection: GuardableProjection, + fields: ReadonlyArray, + entity: E, +): E; +export function guardProjection( + dataSource: DataSource, + target: EntityTarget, + projection: GuardableProjection, + fields: ReadonlyArray, + entity: E[], +): E[]; +export function guardProjection( + dataSource: DataSource, + target: EntityTarget, + projection: GuardableProjection, + fields: ReadonlyArray, + entity: E | E[] | null, +): E | E[] | null { + if (entity == null) return entity; + return guardAgainst(dataSource.getMetadata(target), projection, fields, entity) as E | E[]; +} + +/** The part of a `ReadProjection` the guard needs. */ +export interface GuardableProjection { + alias: string; + joins: ReadonlyArray; + guards: ReadonlyArray; +} + +function guardAgainst( + rootMetadata: EntityMetadata, + projection: GuardableProjection, + fields: ReadonlyArray, + entity: unknown, +): unknown { + if (entity == null) return entity; + + /** + * What the query selected, per alias. Guards are selected too — `apply` adds them. + * + * The path after the alias is kept whole rather than split off at the first segment: an embedded + * column is addressed as `alias.address.city`, and keeping only `address` would mark every + * column of the embedded as selected. + */ + const selected = new Map>(); + for (const field of [...fields, ...projection.guards]) { + const [alias, ...path] = field.split('.'); + if (!selected.has(alias)) selected.set(alias, new Set()); + selected.get(alias).add(path.join('.')); + } + + interface Node { + metadata: EntityMetadata; + asked: Set; + children: Map; + } + + const node = (metadata: EntityMetadata, alias: string): Node => ({ + metadata, + asked: selected.get(alias) ?? new Set(), + children: new Map(), + }); + + const root = node(rootMetadata, projection.alias); + const byAlias = new Map([[projection.alias, root]]); + + // `['parentAlias.relation', 'alias']`, in order — a later join may build on an earlier alias. + for (const [path, alias] of projection.joins) { + const [parentAlias, property] = path.split('.'); + const parent = byAlias.get(parentAlias); + const relation = parent?.metadata.relations.find((r) => r.propertyName === property); + if (!relation) continue; + const child = node(relation.inverseEntityMetadata, alias); + parent.children.set(property, child); + byAlias.set(alias, child); + } + + /** Is `path` an embedded object rather than a column — that is, does anything sit below it? */ + const isEmbedded = (at: Node, path: string): boolean => + at.metadata.columns.some((column) => column.propertyPath.startsWith(`${path}.`)); + + /** + * An embedded object, guarded one level down. + * + * Its columns are addressed by their full path (`address.city`), so the same selection set + * answers for them; only the walk to reach them differs. `written` is the root's set and holds + * the same full paths, so a value the caller assigned reads back here as it does on the root — + * a fresh proxy is handed out on every access, and it would otherwise have nowhere to remember. + */ + const wrapEmbedded = (value: ObjectLiteral, at: Node, prefix: string, written: Set): ObjectLiteral => + value == null + ? value + : new Proxy(value, { + set(source, property, assigned, receiver) { + written.add(`${prefix}.${String(property)}`); + return Reflect.set(source, property, assigned, receiver); + }, + get(source, property, receiver) { + const path = `${prefix}.${String(property)}`; + const inner = Reflect.get(source, property, receiver); + + if (isEmbedded(at, path)) return wrapEmbedded(inner as ObjectLiteral, at, path, written); + if ( + at.metadata.columns.some((column) => column.propertyPath === path) && + !at.asked.has(path) && + !written.has(path) + ) { + throw new Error( + `read of '${at.metadata.name}.${path}', which this query did not select — ` + + `add it to the projection, or stop reading it`, + ); + } + + return inner; + }, + }); + + /** + * One proxy per source row and node. + * + * Wrapping on every access would hand out a new proxy each time, and two things break with it: + * `row.relation === row.relation` is false, and each proxy starts with an empty set of caller + * assignments, so writing through a relation and reading it back throws. Neither is a projection + * defect, and a guard that invents them is measuring itself. + */ + const proxies = new WeakMap>(); + + const wrap = (row: T | T[] | null, at: Node): T | T[] | null => { + if (row == null) return row; + if (Array.isArray(row)) return row.map((one) => wrap(one, at)) as T[]; + + const known = proxies.get(row) ?? new Map(); + proxies.set(row, known); + if (known.has(at)) return known.get(at) as T; + + // Properties the caller assigned itself. A projected read does not carry them, but writing one + // and reading it back is what the write paths do — `createApiKey` sets the filter code on the + // row before passing it to the update — and that is not a projection defect. + const written = new Set(); + + const proxy = new Proxy(row, { + set(source, property, value, receiver) { + written.add(String(property)); + return Reflect.set(source, property, value, receiver); + }, + get(source, property, receiver) { + const name = String(property); + const value = Reflect.get(source, property, receiver); + + const child = at.children.get(name); + if (child) { + // A relation joined for filtering only: with nothing of it selected the ORM never + // materialises it, so the value is undefined whatever the row holds. That is not a row + // without a relation — it is a question the query cannot answer, and reading it takes the + // absent-relation branch silently. + if (!child.asked.size && value == null && !written.has(name)) + throw new Error( + `read of '${at.metadata.name}.${name}', a relation this query joins but selects nothing from — ` + + `select at least its primary key, or stop reading it`, + ); + + return wrap(value, child); + } + + // A relation the projection does not declare is left alone, and deliberately so. + // + // Reading one answers `undefined`, and `if (row.relation)` then takes the absent branch + // without dereferencing anything — a real hazard. But whether that is a defect depends on + // what the replaced query loaded, and no metadata on this entity says. `isEager` is not it: + // `getIssueData` passed `loadEagerRelations: false` and named its relations explicitly, so + // an eager relation it never carried would fail here, while a non-eager one it did load and + // the projection dropped would pass. Both directions exist in this branch. + // + // What the replaced query loaded is compared where it can actually be observed: level 4 + // runs the endpoint against a full load and compares the two answers. + if (at.metadata.relations.some((relation) => relation.propertyName === name)) return value; + + // A `@RelationId` is filled from the foreign-key column of the row, which a query naming its + // fields does not carry, and no field list can select it. Guarded on the value rather than + // on the declaration: if it did arrive filled, reading it is not a defect. + if ( + value === undefined && + at.metadata.relationIds.some((relationId) => relationId.propertyName === name) && + !written.has(name) + ) { + throw new Error( + `read of '${at.metadata.name}.${name}', a @RelationId that a projected query never fills — ` + + `join the relation and read the id off it`, + ); + } + + if (isEmbedded(at, name)) return wrapEmbedded(value as ObjectLiteral, at, name, written); + + if ( + at.metadata.columns.some((column) => column.propertyPath === name) && + !at.asked.has(name) && + !written.has(name) + ) { + throw new Error( + `read of '${at.metadata.name}.${name}', which this query did not select — ` + + `add it to the projection, or stop reading it`, + ); + } + + return value; + }, + }) as T; + + known.set(at, proxy); + + return proxy; + }; + + return wrap(entity as ObjectLiteral, root); +} + +/** + * Turns the guard on for every query a `ReadProjection` builds, for the rest of the process. + * + * Installed once for the whole configuration rather than per spec, so that a spec added later is + * covered by the same call. It applies to every projected query in the suite, including the ones + * the mutation level runs with a reduced field list. + */ +export function installProjectionGuard(): void { + const original = ReadProjection.prototype.apply; + if ((original as { guarded?: boolean }).guarded) return; + + function guarded( + this: ReadProjection, + query: SelectQueryBuilder, + fields: ReadonlyArray = this.fields, + ): SelectQueryBuilder { + const built = original.call(this, query, fields) as SelectQueryBuilder; + const metadata = built.expressionMap.mainAlias?.metadata; + if (!metadata) return built; + + const guard = (rows: unknown): unknown => guardAgainst(metadata, this, fields, rows); + + // `getRawAndEntities` is where `getOne`, `getMany` and `getOneOrFail` all hydrate, so guarding + // it covers them and any direct caller in one place. `getManyAndCount` runs its own query and + // needs its own hook. The raw methods return rows rather than entities and are left alone. + const rawAndEntities = built.getRawAndEntities.bind(built); + built.getRawAndEntities = async () => { + const results = await rawAndEntities(); + return { ...results, entities: guard(results.entities) as E[] }; + }; + + const manyAndCount = built.getManyAndCount.bind(built); + built.getManyAndCount = async () => { + const [rows, count] = await manyAndCount(); + return [guard(rows) as E[], count]; + }; + + return built; + } + + (guarded as { guarded?: boolean }).guarded = true; + ReadProjection.prototype.apply = guarded as typeof ReadProjection.prototype.apply; +} diff --git a/src/subdomains/core/accounting/__tests__/ledger-suspense.projection.spec.ts b/src/subdomains/core/accounting/__tests__/ledger-suspense.projection.spec.ts new file mode 100644 index 0000000000..9dc94b3ccd --- /dev/null +++ b/src/subdomains/core/accounting/__tests__/ledger-suspense.projection.spec.ts @@ -0,0 +1,197 @@ +import { Util } from 'src/shared/utils/util'; +import { + createProjectionDataSource, + describeProjection, + destroyProjectionDataSource, + expectEveryFieldRequired, + expectNoEmptyFields, + projectionFieldsWithout, + seedEntity, +} from 'src/shared/utils/projection-test.util'; +import { LedgerDtoMapper, SuspenseLegRow } from 'src/subdomains/core/accounting/dto/ledger-dto.mapper'; +import { SuspenseLegDto } from 'src/subdomains/core/accounting/dto/ledger-reconciliation.dto'; +import { AccountType, LedgerAccount } from 'src/subdomains/core/accounting/entities/ledger-account.entity'; +import { LedgerLeg } from 'src/subdomains/core/accounting/entities/ledger-leg.entity'; +import { LedgerTx } from 'src/subdomains/core/accounting/entities/ledger-tx.entity'; +import { + SUSPENSE_LEG_PROJECTION, + SUSPENSE_LEG_RESPONSE_FIELDS, + LedgerLegRepository, +} from 'src/subdomains/core/accounting/repositories/ledger-leg.repository'; +import { DataSource } from 'typeorm'; + +const SCHEMA = 'ledger_suspense_projection_spec'; + +/** + * `GET /dashboard/accounting/ledger/suspense` — the four levels from + * `docs/read-path-projections.md`. + */ +describeProjection('ledger suspense — read-path projection', () => { + let dataSource: DataSource; + let legs: LedgerLegRepository; + + beforeAll(async () => { + dataSource = await createProjectionDataSource(SCHEMA); + legs = new LedgerLegRepository(dataSource.manager); + }, 300000); + + afterAll(async () => { + await destroyProjectionDataSource(dataSource, SCHEMA); + }); + + // This endpoint takes no parameter — it answers with every leg on a suspense account. Rows left + // behind by an earlier test would therefore show up in the next one, and level 3 would compare a + // baseline that already contains a deliberately incomplete row. + beforeEach(async () => { + await dataSource.query( + `TRUNCATE TABLE "${SCHEMA}"."ledger_leg", "${SCHEMA}"."ledger_tx", "${SCHEMA}"."ledger_account" RESTART IDENTITY CASCADE`, + ); + }); + + /** + * One leg on an account of the given type. + * + * `type` is set explicitly: it is a TypeScript enum in a text column and the query filters on it, + * so a generated value would make the endpoint answer with nothing for a reason that has nothing + * to do with the projection. + */ + async function seedLeg( + type = AccountType.SUSPENSE, + values: Partial = {}, + ): Promise<{ leg: LedgerLeg; tx: LedgerTx; account: LedgerAccount }> { + const account = await seedEntity(dataSource, LedgerAccount, { values: { type } }); + // `amountChfSum` carries a check constraint pinning it to 0 — the single-row balance gate. The + // generated value would be distinct and therefore non-zero, and the insert would be rejected. + const tx = await seedEntity(dataSource, LedgerTx, { values: { amountChfSum: 0 } }); + const leg = await seedEntity(dataSource, LedgerLeg, { values: { tx, account, ...values } }); + return { leg, tx, account }; + } + + /** + * The response the endpoint produces, through the projected query. + * + * `now` is a parameter because the age is derived from it: a comparison that takes its own + * timestamp disagrees with this one across a day boundary, which would fail for the calendar + * rather than for the projection. + */ + async function suspenseOf( + fields = SUSPENSE_LEG_PROJECTION.fields, + now = new Date(), + ): Promise<{ totalChf: number; legs: SuspenseLegDto[] }> { + const rows = await legs.findSuspenseLegs(fields); + const totalChf = Util.round(Util.sum(rows.map((l) => l.amountChf ?? 0)), 2); + const mapped: SuspenseLegRow[] = rows.map((leg) => ({ + leg, + bookingDate: leg.tx.bookingDate, + age: Util.daysDiff(leg.tx.bookingDate, now), + })); + + return { totalChf, legs: mapped.map((row) => LedgerDtoMapper.mapSuspenseLeg(row)) }; + } + + // --- LEVEL 1: completeness --- // + + it('level 1 — a suspense leg answers with no empty field', async () => { + await seedLeg(); + + const response = await suspenseOf(); + + expect(response.legs).toHaveLength(1); + expectNoEmptyFields(response); + }, 120000); + + // --- LEVEL 2: variants --- // + + it('level 2 — only legs on suspense accounts are listed', async () => { + const suspense = await seedLeg(AccountType.SUSPENSE); + await seedLeg(AccountType.INCOME); + + const response = await suspenseOf(); + + expect(response.legs.map((row) => row.legId)).toEqual([suspense.leg.id]); + }, 120000); + + it('level 2 — legs are ordered by booking date, oldest first', async () => { + // Seeded newest first: a statement that lost its ORDER BY falls back to insertion or id order, + // which is the reverse of what is expected here rather than a match for it. + const newer = await seedLeg(); + const older = await seedLeg(); + await dataSource.getRepository(LedgerTx).update(older.tx.id, { bookingDate: new Date('2020-01-01T00:00:00.000Z') }); + await dataSource.getRepository(LedgerTx).update(newer.tx.id, { bookingDate: new Date('2026-01-01T00:00:00.000Z') }); + + // The sort column lives on the joined transaction; with the join reduced to a bare id the + // statement would still run and the order would silently be the storage order. + const response = await suspenseOf(); + + expect(response.legs.map((row) => row.legId)).toEqual([older.leg.id, newer.leg.id]); + }, 120000); + + it('guards the joined transaction and account, not only the leg', async () => { + // The joins were applied on the query builder before, which selects the columns but leaves the + // relation outside what the guard watches: a read of a column these joins did not select + // answered undefined instead of throwing. + await seedLeg(); + + const [leg] = await legs.findSuspenseLegs(); + + expect(() => leg.tx.valueDate).toThrow("read of 'LedgerTx.valueDate'"); + expect(() => leg.account.name).toThrow("read of 'LedgerAccount.name'"); + // The columns the projection does select stay readable, so the guard is not simply refusing the + // whole relation. + expect(leg.tx.description).toBeDefined(); + }, 120000); + + it('level 2 — a leg without a CHF amount contributes nothing to the total', async () => { + await seedLeg(AccountType.SUSPENSE, { amountChf: null }); + const withAmount = await seedLeg(AccountType.SUSPENSE, { amountChf: 25 }); + + const response = await suspenseOf(); + + // `amountChf` is nullable and the sum reads it with a fallback. A projection that dropped the + // column would produce the same total as a row that genuinely has none — which is why level 3 + // covers it against a fixture where the value is set. + expect(response.totalChf).toEqual(25); + expect(response.legs.find((row) => row.legId === withAmount.leg.id)?.amountChf).toEqual(25); + }, 120000); + + // --- LEVEL 3: mutation --- // + + it('level 3 — every field feeding the suspense response is required', async () => { + await seedLeg(AccountType.SUSPENSE, { amountChf: 42 }); + + await expectEveryFieldRequired(SUSPENSE_LEG_RESPONSE_FIELDS, (omitted) => + suspenseOf(projectionFieldsWithout(SUSPENSE_LEG_PROJECTION.fields, omitted)), + ); + }, 300000); + + // --- LEVEL 4: consistency against a second source --- // + + it('level 4 — the projected response equals the one from a full load', async () => { + await seedLeg(); + const now = new Date(); + + const projected = await suspenseOf(SUSPENSE_LEG_PROJECTION.fields, now); + // The unprojected load is the second source: the same rows selected without a field list. + const full = await dataSource + .getRepository(LedgerLeg) + .createQueryBuilder('leg') + .innerJoinAndSelect('leg.tx', 'tx') + .innerJoinAndSelect('leg.account', 'account') + .where('account.type = :type', { type: AccountType.SUSPENSE }) + .orderBy('tx.bookingDate', 'ASC') + .getMany(); + + // The whole response, not just the rows: the total is derived from a column the projection has + // to carry, so comparing only the legs would leave it unchecked. + expect(projected).toEqual({ + totalChf: Util.round(Util.sum(full.map((leg) => leg.amountChf ?? 0)), 2), + legs: full.map((leg) => + LedgerDtoMapper.mapSuspenseLeg({ + leg, + bookingDate: leg.tx.bookingDate, + age: Util.daysDiff(leg.tx.bookingDate, now), + }), + ), + }); + }, 120000); +}); diff --git a/src/subdomains/core/accounting/dto/ledger-dto.mapper.ts b/src/subdomains/core/accounting/dto/ledger-dto.mapper.ts index 99ee2f1677..336d1e0d37 100644 --- a/src/subdomains/core/accounting/dto/ledger-dto.mapper.ts +++ b/src/subdomains/core/accounting/dto/ledger-dto.mapper.ts @@ -97,7 +97,10 @@ export class LedgerDtoMapper { const { leg } = row; return { legId: leg.id, - txId: leg.txId, + // Read off the joined transaction rather than through the `@RelationId`: that property is + // filled from the foreign-key column of the leg row, which a query selecting named fields does + // not carry. Every other value here already comes from `leg.tx`, and it is the same number. + txId: leg.tx?.id, bookingDate: row.bookingDate.toISOString(), description: leg.tx?.description, sourceType: leg.tx?.sourceType, diff --git a/src/subdomains/core/accounting/repositories/__tests__/ledger-leg.repository.spec.ts b/src/subdomains/core/accounting/repositories/__tests__/ledger-leg.repository.spec.ts index 3446ec97ce..6c10d173a7 100644 --- a/src/subdomains/core/accounting/repositories/__tests__/ledger-leg.repository.spec.ts +++ b/src/subdomains/core/accounting/repositories/__tests__/ledger-leg.repository.spec.ts @@ -1,8 +1,12 @@ import { createMock } from '@golevelup/ts-jest'; -import { EntityManager } from 'typeorm'; +import { EntityManager, SelectQueryBuilder } from 'typeorm'; import { BaseRepository } from 'src/shared/repositories/base.repository'; +import { AccountType } from 'src/subdomains/core/accounting/entities/ledger-account.entity'; import { LedgerLeg } from '../../entities/ledger-leg.entity'; -import { LedgerLegRepository } from '../ledger-leg.repository'; +import { + LedgerLegRepository, + SUSPENSE_LEG_PROJECTION, +} from 'src/subdomains/core/accounting/repositories/ledger-leg.repository'; describe('LedgerLegRepository', () => { let manager: EntityManager; @@ -24,4 +28,70 @@ describe('LedgerLegRepository', () => { it('manages the LedgerLeg entity', () => { expect(repository.target).toBe(LedgerLeg); }); + + /** + * The suspense query, asserted on the builder rather than on rows. + * + * What it selects is covered against a real database in + * `subdomains/core/accounting/__tests__/ledger-suspense.projection.spec.ts`. Two properties are + * not observable there and are asserted here instead: that both relations are joined as INNER + * joins, and that the ordering is applied to the joined transaction. Both relations are + * `nullable: false`, so an inner and a left join select the same rows today — a database test + * cannot tell them apart, and the difference would surface only once that changed. + */ + describe('findSuspenseLegs', () => { + let calls: [string, unknown[]][]; + + const argsOf = (method: string): unknown[][] => calls.filter(([name]) => name === method).map(([, args]) => args); + + beforeEach(() => { + calls = []; + + const builder = new Proxy({} as SelectQueryBuilder, { + get: (_target, property: string) => { + if (property === 'getMany') return () => Promise.resolve([]); + return (...args: unknown[]) => { + calls.push([property, args]); + return builder; + }; + }, + }); + + jest.spyOn(repository, 'createQueryBuilder').mockReturnValue(builder); + }); + + it('joins the transaction and the account as inner joins', async () => { + await repository.findSuspenseLegs(); + + expect(argsOf('innerJoin')).toEqual([ + ['leg.tx', 'tx'], + ['leg.account', 'account'], + ]); + // A left join here would widen the result set the moment either relation became nullable. + expect(argsOf('leftJoin')).toEqual([]); + }); + + it('selects the projected fields together with their guards', async () => { + await repository.findSuspenseLegs(); + + expect(argsOf('select')).toEqual([[[...SUSPENSE_LEG_PROJECTION.fields, ...SUSPENSE_LEG_PROJECTION.guards]]]); + }); + + it('filters on suspense accounts and orders by the booking date of the transaction', async () => { + await repository.findSuspenseLegs(); + + expect(argsOf('where')).toEqual([['account.type = :type', { type: AccountType.SUSPENSE }]]); + expect(argsOf('orderBy')).toEqual([['tx.bookingDate', 'ASC']]); + }); + + it('passes a reduced field list through, keeping the guards', async () => { + // This is what the mutation test does: drop one response field, re-run the same query with + // the rest of the projection intact. + const reduced = SUSPENSE_LEG_PROJECTION.fields.filter((field) => field !== 'leg.amountChf'); + + await repository.findSuspenseLegs(reduced); + + expect(argsOf('select')).toEqual([[[...reduced, ...SUSPENSE_LEG_PROJECTION.guards]]]); + }); + }); }); diff --git a/src/subdomains/core/accounting/repositories/ledger-leg.repository.ts b/src/subdomains/core/accounting/repositories/ledger-leg.repository.ts index 4214b07543..ec0b4f1ec6 100644 --- a/src/subdomains/core/accounting/repositories/ledger-leg.repository.ts +++ b/src/subdomains/core/accounting/repositories/ledger-leg.repository.ts @@ -1,11 +1,59 @@ import { Injectable } from '@nestjs/common'; +import { ReadProjection } from 'src/shared/models/read-projection'; import { BaseRepository } from 'src/shared/repositories/base.repository'; import { EntityManager } from 'typeorm'; +import { AccountType } from 'src/subdomains/core/accounting/entities/ledger-account.entity'; import { LedgerLeg } from '../entities/ledger-leg.entity'; +/** What `LedgerDtoMapper.mapSuspenseLeg` reads, including the booking date the age is computed from. */ +export const SUSPENSE_LEG_RESPONSE_FIELDS = [ + 'leg.id', + // `mapSuspenseLeg` answers with this as `txId`, read off the joined row rather than through the + // entity's `@RelationId` — that property is filled from the leg's foreign-key column, which a + // query naming its fields does not carry. + 'tx.id', + 'leg.amount', + 'leg.amountChf', + 'tx.bookingDate', + 'tx.description', + 'tx.sourceType', + 'tx.sourceId', + 'account.currency', +]; + +/** + * `GET /dashboard/accounting/ledger/suspense`. + * + * `account.id` is a guard: without a primary key the ORM cannot materialise the joined row. + * + * Both joins are inner: a leg without a transaction or an account is not a suspense entry. + */ +export const SUSPENSE_LEG_PROJECTION = new ReadProjection( + 'leg', + [ + ['leg.tx', 'tx', 'inner'], + ['leg.account', 'account', 'inner'], + ], + SUSPENSE_LEG_RESPONSE_FIELDS, + ['account.id'], +); + @Injectable() export class LedgerLegRepository extends BaseRepository { constructor(manager: EntityManager) { super(LedgerLeg, manager); } + + /** + * The legs sitting on suspense accounts, oldest booking first. + * + * `fields` is what the mutation test in `ledger-suspense.projection.spec.ts` re-runs the query + * with; `LedgerQueryService.getSuspense` calls this without it. + */ + async findSuspenseLegs(fields: ReadonlyArray = SUSPENSE_LEG_PROJECTION.fields): Promise { + return SUSPENSE_LEG_PROJECTION.apply(this.createQueryBuilder('leg'), fields) + .where('account.type = :type', { type: AccountType.SUSPENSE }) + .orderBy('tx.bookingDate', 'ASC') + .getMany(); + } } diff --git a/src/subdomains/core/accounting/services/__tests__/ledger-query.service.spec.ts b/src/subdomains/core/accounting/services/__tests__/ledger-query.service.spec.ts index 7a14d76e27..22081e0f63 100644 --- a/src/subdomains/core/accounting/services/__tests__/ledger-query.service.spec.ts +++ b/src/subdomains/core/accounting/services/__tests__/ledger-query.service.spec.ts @@ -186,6 +186,11 @@ describe('LedgerQueryService', () => { logService = createMock(); jest.spyOn(ledgerLegRepository, 'createQueryBuilder').mockImplementation(() => legQb()); + // The suspense query lives in the repository; what it selects is asserted against a real + // database in ledger-suspense.projection.spec.ts. + jest + .spyOn(ledgerLegRepository, 'findSuspenseLegs') + .mockImplementation(() => Promise.resolve(qbStub.suspenseLegs ?? [])); jest.spyOn(ledgerLegRepository, 'find').mockResolvedValue([]); jest.spyOn(ledgerAccountRepository, 'find').mockResolvedValue([]); jest.spyOn(ledgerAccountRepository, 'findOneBy').mockResolvedValue(null); diff --git a/src/subdomains/core/accounting/services/ledger-query.service.ts b/src/subdomains/core/accounting/services/ledger-query.service.ts index 422381698f..bb62079d91 100644 --- a/src/subdomains/core/accounting/services/ledger-query.service.ts +++ b/src/subdomains/core/accounting/services/ledger-query.service.ts @@ -180,13 +180,7 @@ export class LedgerQueryService { async getSuspense(): Promise { const now = new Date(); - const legs = await this.ledgerLegRepository - .createQueryBuilder('leg') - .innerJoinAndSelect('leg.tx', 'tx') - .innerJoinAndSelect('leg.account', 'account') - .where('account.type = :type', { type: AccountType.SUSPENSE }) - .orderBy('tx.bookingDate', 'ASC') - .getMany(); + const legs = await this.ledgerLegRepository.findSuspenseLegs(); const rows: SuspenseLegRow[] = legs.map((leg) => ({ leg, diff --git a/src/subdomains/core/buy-crypto/process/__tests__/buy-crypto-history.projection.spec.ts b/src/subdomains/core/buy-crypto/process/__tests__/buy-crypto-history.projection.spec.ts new file mode 100644 index 0000000000..4bf40e63ba --- /dev/null +++ b/src/subdomains/core/buy-crypto/process/__tests__/buy-crypto-history.projection.spec.ts @@ -0,0 +1,227 @@ +import { Blockchain } from 'src/integration/blockchain/shared/enums/blockchain.enum'; +import { Asset } from 'src/shared/models/asset/asset.entity'; +import { BuyCryptoHistoryMapper } from 'src/subdomains/core/buy-crypto/process/dto/buy-crypto-history.mapper'; +import { + BUY_CRYPTO_BUY_HISTORY_PROJECTION, + BUY_CRYPTO_HISTORY_RESPONSE_FIELDS, + BUY_CRYPTO_ROUTE_HISTORY_PROJECTION, + BuyCryptoRepository, +} from 'src/subdomains/core/buy-crypto/process/repositories/buy-crypto.repository'; +import { BuyCrypto, BuyCryptoStatus } from 'src/subdomains/core/buy-crypto/process/entities/buy-crypto.entity'; +import { Buy } from 'src/subdomains/core/buy-crypto/routes/buy/buy.entity'; +import { Swap } from 'src/subdomains/core/buy-crypto/routes/swap/swap.entity'; +import { User } from 'src/subdomains/generic/user/models/user/user.entity'; +import { + createProjectionDataSource, + describeProjection, + destroyProjectionDataSource, + expectEveryFieldRequired, + expectNoEmptyFields, + projectionFieldsWithout, + seedEntity, +} from 'src/shared/utils/projection-test.util'; +import { DataSource } from 'typeorm'; + +const SCHEMA = 'buy_crypto_history_projection_spec'; + +/** + * `GET /buy/:id/history` and `GET /swap/:id/history` — the four levels from + * `docs/read-path-projections.md`. + * + * Both answer a `HistoryDtoDeprecated[]` built by the same mapper, over the ten values the + * mapper reads. They differ only in the route they filter by — `buy` for one, `cryptoRoute` for the + * other — which is why there are two projections over one field list. + */ +describeProjection('buy-crypto history — read-path projection', () => { + let dataSource: DataSource; + let repository: BuyCryptoRepository; + + beforeAll(async () => { + dataSource = await createProjectionDataSource(SCHEMA); + repository = new BuyCryptoRepository(dataSource.manager); + }, 300000); + + afterAll(async () => { + await destroyProjectionDataSource(dataSource, SCHEMA); + }); + + /** + * A completed transaction on a buy route, with every column of every entity populated. + * + * `blockchain` and `status` are set explicitly: both are TypeScript enums stored in text columns, + * so a generated value would not be a member of the enum the mapper switches on, and the response would look incomplete + * for a reason that has nothing to do with the projection. + */ + async function seedBuyCrypto( + status = BuyCryptoStatus.COMPLETE, + ): Promise<{ buyCrypto: BuyCrypto; user: User; buy: Buy }> { + const user = await seedEntity(dataSource, User); + const buy = await seedEntity(dataSource, Buy, { values: { user } }); + const outputAsset = await seedEntity(dataSource, Asset, { values: { blockchain: Blockchain.ETHEREUM } }); + const buyCrypto = await seedEntity(dataSource, BuyCrypto, { values: { buy, outputAsset, status } }); + return { buyCrypto, user, buy }; + } + + /** A second buy route of the same caller, with one transaction on it. */ + async function seedSecondBuyRouteFor(user: User): Promise<{ buyCrypto: BuyCrypto; buy: Buy }> { + const buy = await seedEntity(dataSource, Buy, { values: { user } }); + const outputAsset = await seedEntity(dataSource, Asset, { values: { blockchain: Blockchain.ETHEREUM } }); + const buyCrypto = await seedEntity(dataSource, BuyCrypto, { + values: { buy, outputAsset, status: BuyCryptoStatus.COMPLETE }, + }); + return { buyCrypto, buy }; + } + + /** A second swap route of the same caller, with one transaction on it. */ + async function seedSecondSwapRouteFor(user: User): Promise<{ buyCrypto: BuyCrypto; route: Swap }> { + const route = await seedEntity(dataSource, Swap, { values: { user } }); + const outputAsset = await seedEntity(dataSource, Asset, { values: { blockchain: Blockchain.ETHEREUM } }); + const buyCrypto = await seedEntity(dataSource, BuyCrypto, { + values: { cryptoRoute: route, outputAsset, status: BuyCryptoStatus.COMPLETE }, + }); + return { buyCrypto, route }; + } + + /** The same, on a swap route. */ + async function seedSwapCrypto(): Promise<{ buyCrypto: BuyCrypto; user: User; route: Swap }> { + const user = await seedEntity(dataSource, User); + const route = await seedEntity(dataSource, Swap, { values: { user } }); + const outputAsset = await seedEntity(dataSource, Asset, { values: { blockchain: Blockchain.ETHEREUM } }); + const buyCrypto = await seedEntity(dataSource, BuyCrypto, { + values: { cryptoRoute: route, outputAsset, status: BuyCryptoStatus.COMPLETE }, + }); + return { buyCrypto, user, route }; + } + + // --- LEVEL 1: completeness --- // + + it('level 1 — the buy history answers with no empty field', async () => { + const { user, buy } = await seedBuyCrypto(); + + const history = (await repository.findBuyHistory(user.id, buy.id)).map(BuyCryptoHistoryMapper.toDto); + + expect(history).toHaveLength(1); + expectNoEmptyFields(history); + }, 120000); + + it('level 1 — the swap history answers with no empty field', async () => { + const { user, route } = await seedSwapCrypto(); + + const history = (await repository.findSwapHistory(user.id, route.id)).map(BuyCryptoHistoryMapper.toDto); + + expect(history).toHaveLength(1); + expectNoEmptyFields(history); + }, 120000); + + // --- LEVEL 2: variants --- // + + it('level 2 — a transaction without an output asset still answers', async () => { + const user = await seedEntity(dataSource, User); + const buy = await seedEntity(dataSource, Buy, { values: { user } }); + await seedEntity(dataSource, BuyCrypto, { + values: { buy, outputAsset: null, status: BuyCryptoStatus.WAITING_FOR_LOWER_FEE }, + }); + + const history = (await repository.findBuyHistory(user.id, buy.id)).map(BuyCryptoHistoryMapper.toDto); + + // The mapper guards on `outputAsset` for both the asset name and the explorer link, so a + // pending transaction has neither. Everything the transaction itself carries must still be there + // — a projection that only ever ran against a completed row would not show that. + expect(history).toHaveLength(1); + expect(history[0].outputAsset).toBeUndefined(); + expect(history[0].txUrl).toBeUndefined(); + expectNoEmptyFields(history, ['[0].outputAsset', '[0].txUrl']); + }, 120000); + + it('level 2 — both filters are needed to select the right buy transactions', async () => { + // One caller with one route against a stranger with another is not enough: asked with that + // caller's id and that caller's route id, either predicate on its own still returns exactly the + // expected row. A second route of the same caller makes the route id necessary; a foreign route + // asked for with the caller's id makes the user id necessary. + const mine = await seedBuyCrypto(); + const second = await seedSecondBuyRouteFor(mine.user); + const other = await seedBuyCrypto(); + + const onOneRoute = (await repository.findBuyHistory(mine.user.id, mine.buy.id)).map(BuyCryptoHistoryMapper.toDto); + expect(onOneRoute.map((row) => row.txId)).toEqual([mine.buyCrypto.txId]); + expect(onOneRoute.map((row) => row.txId)).not.toContain(second.buyCrypto.txId); + + const allOfMine = (await repository.findBuyHistory(mine.user.id)).map(BuyCryptoHistoryMapper.toDto); + expect(allOfMine.map((row) => row.txId).sort()).toEqual([mine.buyCrypto.txId, second.buyCrypto.txId].sort()); + + expect(await repository.findBuyHistory(mine.user.id, other.buy.id)).toHaveLength(0); + }, 120000); + + it('level 2 — both filters are needed to select the right swap transactions', async () => { + // The same for the swap route: its own query and its own two predicates, which the case above + // does not reach — that one calls `findBuyHistory`. + const mine = await seedSwapCrypto(); + const second = await seedSecondSwapRouteFor(mine.user); + const other = await seedSwapCrypto(); + + const onOneRoute = (await repository.findSwapHistory(mine.user.id, mine.route.id)).map( + BuyCryptoHistoryMapper.toDto, + ); + expect(onOneRoute.map((row) => row.txId)).toEqual([mine.buyCrypto.txId]); + expect(onOneRoute.map((row) => row.txId)).not.toContain(second.buyCrypto.txId); + + const allOfMine = (await repository.findSwapHistory(mine.user.id)).map(BuyCryptoHistoryMapper.toDto); + expect(allOfMine.map((row) => row.txId).sort()).toEqual([mine.buyCrypto.txId, second.buyCrypto.txId].sort()); + + expect(await repository.findSwapHistory(mine.user.id, other.route.id)).toHaveLength(0); + }, 120000); + + // --- LEVEL 3: mutation --- // + + it('level 3 — every field feeding the buy history is required', async () => { + const { user, buy } = await seedBuyCrypto(); + + await expectEveryFieldRequired(BUY_CRYPTO_HISTORY_RESPONSE_FIELDS, (omitted) => + repository + .findBuyHistory(user.id, buy.id, projectionFieldsWithout(BUY_CRYPTO_BUY_HISTORY_PROJECTION.fields, omitted)) + .then((rows) => rows.map(BuyCryptoHistoryMapper.toDto)), + ); + }, 300000); + + it('level 3 — every field feeding the swap history is required', async () => { + const { user, route } = await seedSwapCrypto(); + + await expectEveryFieldRequired(BUY_CRYPTO_HISTORY_RESPONSE_FIELDS, (omitted) => + repository + .findSwapHistory( + user.id, + route.id, + projectionFieldsWithout(BUY_CRYPTO_ROUTE_HISTORY_PROJECTION.fields, omitted), + ) + .then((rows) => rows.map(BuyCryptoHistoryMapper.toDto)), + ); + }, 300000); + + // --- LEVEL 4: consistency against a second source --- // + + it('level 4 — the projected buy history equals the one from a full load', async () => { + const { user, buy } = await seedBuyCrypto(); + + const projected = (await repository.findBuyHistory(user.id, buy.id)).map(BuyCryptoHistoryMapper.toDto); + // The unprojected load is the second source: it fetches every column, so what it produces + // depends on no field list at all. + const full = await dataSource.getRepository(BuyCrypto).find({ + where: { buy: { id: buy.id, user: { id: user.id } } }, + relations: { buy: { user: true } }, + }); + + expect(projected).toEqual(full.map(BuyCryptoHistoryMapper.toDto)); + }, 120000); + + it('level 4 — the projected swap history equals the one from a full load', async () => { + const { user, route } = await seedSwapCrypto(); + + const projected = (await repository.findSwapHistory(user.id, route.id)).map(BuyCryptoHistoryMapper.toDto); + const full = await dataSource.getRepository(BuyCrypto).find({ + where: { cryptoRoute: { id: route.id, user: { id: user.id } } }, + relations: { cryptoRoute: { user: true } }, + }); + + expect(projected).toEqual(full.map(BuyCryptoHistoryMapper.toDto)); + }, 120000); +}); diff --git a/src/subdomains/core/buy-crypto/process/dto/buy-crypto-history.mapper.ts b/src/subdomains/core/buy-crypto/process/dto/buy-crypto-history.mapper.ts new file mode 100644 index 0000000000..c83e83f66b --- /dev/null +++ b/src/subdomains/core/buy-crypto/process/dto/buy-crypto-history.mapper.ts @@ -0,0 +1,33 @@ +import { txExplorerUrl } from 'src/integration/blockchain/shared/util/blockchain.util'; +import { HistoryDtoDeprecated, PaymentStatusMapper } from 'src/subdomains/core/history/dto/history.dto'; +import { BuyCrypto } from 'src/subdomains/core/buy-crypto/process/entities/buy-crypto.entity'; + +/** + * The history entry `GET /buy/:id/history` and `GET /swap/:id/history` answer with. + * + * Its own mapper so that the projection spec can drive the same mapping the + * endpoints use. A copy in the spec could be wrong in exactly the way the projection is wrong and + * would prove nothing. + * + * The fields it reads are what `BUY_CRYPTO_HISTORY_RESPONSE_FIELDS` selects — the two are meant to + * be changed together, and the mutation test fails if they drift apart. + */ +export class BuyCryptoHistoryMapper { + static toDto(buyCrypto: BuyCrypto): HistoryDtoDeprecated { + return { + inputAmount: buyCrypto.inputAmount, + inputAsset: buyCrypto.inputAsset, + amlCheck: buyCrypto.amlCheck, + outputAmount: buyCrypto.outputAmount, + outputAsset: buyCrypto.outputAsset?.dexName, + txId: buyCrypto.txId, + txUrl: + buyCrypto.outputAsset && buyCrypto.txId + ? txExplorerUrl(buyCrypto.outputAsset.blockchain, buyCrypto.txId) + : undefined, + isComplete: buyCrypto.isComplete, + date: buyCrypto.outputDate, + status: PaymentStatusMapper[buyCrypto.status], + }; + } +} diff --git a/src/subdomains/core/buy-crypto/process/repositories/buy-crypto.repository.ts b/src/subdomains/core/buy-crypto/process/repositories/buy-crypto.repository.ts index eb049badc4..4c0218b4ec 100644 --- a/src/subdomains/core/buy-crypto/process/repositories/buy-crypto.repository.ts +++ b/src/subdomains/core/buy-crypto/process/repositories/buy-crypto.repository.ts @@ -1,15 +1,101 @@ import { Injectable } from '@nestjs/common'; +import { ReadProjection } from 'src/shared/models/read-projection'; import { BaseRepository } from 'src/shared/repositories/base.repository'; import { EntityManager, UpdateResult } from 'typeorm'; import { BuyCryptoFee } from '../entities/buy-crypto-fees.entity'; import { BuyCrypto } from '../entities/buy-crypto.entity'; +/** + * The ten values `BuyCryptoHistoryMapper.toDto` reads. + * + * Both history endpoints share them; only the route they filter by differs, which is what the two + * projections below encode. + */ +export const BUY_CRYPTO_HISTORY_RESPONSE_FIELDS = [ + 'buyCrypto.inputAmount', + 'buyCrypto.inputAsset', + 'buyCrypto.amlCheck', + 'buyCrypto.outputAmount', + 'buyCrypto.txId', + 'buyCrypto.isComplete', + 'buyCrypto.outputDate', + 'buyCrypto.status', + 'outputAsset.dexName', + 'outputAsset.blockchain', +]; + +// Never part of the response: the primary keys that make the ORM materialise the joined rows. +const HISTORY_GUARDS = ['buyCrypto.id', 'outputAsset.id']; + +/** + * `GET /buy/:id/history` — filtered by the buy route and its user. + * + * Without it the query loads whole `BuyCrypto` rows for these ten values. + */ +export const BUY_CRYPTO_BUY_HISTORY_PROJECTION = new ReadProjection( + 'buyCrypto', + [ + ['buyCrypto.outputAsset', 'outputAsset'], + ['buyCrypto.buy', 'buy'], + ['buy.user', 'buyUser'], + ], + BUY_CRYPTO_HISTORY_RESPONSE_FIELDS, + HISTORY_GUARDS, +); + +/** `GET /swap/:id/history` — the same response, filtered by the swap route instead. */ +export const BUY_CRYPTO_ROUTE_HISTORY_PROJECTION = new ReadProjection( + 'buyCrypto', + [ + ['buyCrypto.outputAsset', 'outputAsset'], + ['buyCrypto.cryptoRoute', 'cryptoRoute'], + ['cryptoRoute.user', 'routeUser'], + ], + BUY_CRYPTO_HISTORY_RESPONSE_FIELDS, + HISTORY_GUARDS, +); + @Injectable() export class BuyCryptoRepository extends BaseRepository { constructor(manager: EntityManager) { super(BuyCrypto, manager); } + /** + * Transactions on a user's buy route, loaded with the history fields only. + * + * `routeId` narrows to a single route; without it the caller gets every buy route they own, which + * is what `GET /history` relies on. `fields` is what the mutation test in + * `buy-crypto-history.projection.spec.ts` re-runs the query with; `BuyCryptoService` calls this + * without it. + */ + async findBuyHistory( + userId: number, + routeId?: number, + fields: ReadonlyArray = BUY_CRYPTO_BUY_HISTORY_PROJECTION.fields, + ): Promise { + const query = BUY_CRYPTO_BUY_HISTORY_PROJECTION.apply(this.createQueryBuilder('buyCrypto'), fields).where( + 'buyUser.id = :userId', + { userId }, + ); + if (routeId != null) query.andWhere('buy.id = :routeId', { routeId }); + return query.getMany(); + } + + /** The same for a swap route. */ + async findSwapHistory( + userId: number, + routeId?: number, + fields: ReadonlyArray = BUY_CRYPTO_ROUTE_HISTORY_PROJECTION.fields, + ): Promise { + const query = BUY_CRYPTO_ROUTE_HISTORY_PROJECTION.apply(this.createQueryBuilder('buyCrypto'), fields).where( + 'routeUser.id = :userId', + { userId }, + ); + if (routeId != null) query.andWhere('cryptoRoute.id = :routeId', { routeId }); + return query.getMany(); + } + async updateFee(id: number, update: Partial): Promise { return this.manager.update(BuyCryptoFee, id, update); } diff --git a/src/subdomains/core/buy-crypto/process/services/__tests__/buy-crypto.service.spec.ts b/src/subdomains/core/buy-crypto/process/services/__tests__/buy-crypto.service.spec.ts index e97e4d35d6..acb8b2817e 100644 --- a/src/subdomains/core/buy-crypto/process/services/__tests__/buy-crypto.service.spec.ts +++ b/src/subdomains/core/buy-crypto/process/services/__tests__/buy-crypto.service.spec.ts @@ -218,7 +218,10 @@ describe('BuyCryptoService', () => { break; } - jest.spyOn(buyCryptoRepo, 'find').mockResolvedValue(wantedData); + // The history goes through the projected queries; both are stubbed because one `setup` + // serves the buy-route and the swap-route cases. + jest.spyOn(buyCryptoRepo, 'findBuyHistory').mockResolvedValue(wantedData); + jest.spyOn(buyCryptoRepo, 'findSwapHistory').mockResolvedValue(wantedData); } } diff --git a/src/subdomains/core/buy-crypto/process/services/buy-crypto.service.ts b/src/subdomains/core/buy-crypto/process/services/buy-crypto.service.ts index 4d115fad96..c3d38dc62b 100644 --- a/src/subdomains/core/buy-crypto/process/services/buy-crypto.service.ts +++ b/src/subdomains/core/buy-crypto/process/services/buy-crypto.service.ts @@ -8,7 +8,6 @@ import { OnModuleInit, } from '@nestjs/common'; import { Config } from 'src/config/config'; -import { txExplorerUrl } from 'src/integration/blockchain/shared/util/blockchain.util'; import { CheckoutPaymentStatus } from 'src/integration/checkout/dto/checkout.dto'; import { CheckoutService } from 'src/integration/checkout/services/checkout.service'; import { toScorechainBlockchain } from 'src/integration/scorechain/dto/scorechain.dto'; @@ -28,7 +27,8 @@ import { Swap } from 'src/subdomains/core/buy-crypto/routes/swap/swap.entity'; import { SwapService } from 'src/subdomains/core/buy-crypto/routes/swap/swap.service'; import { CustodyOrderType } from 'src/subdomains/core/custody/enums/custody'; import { CustodyOrderService } from 'src/subdomains/core/custody/services/custody-order.service'; -import { HistoryDtoDeprecated, PaymentStatusMapper } from 'src/subdomains/core/history/dto/history.dto'; +import { HistoryDtoDeprecated } from 'src/subdomains/core/history/dto/history.dto'; +import { BuyCryptoHistoryMapper } from 'src/subdomains/core/buy-crypto/process/dto/buy-crypto-history.mapper'; import { BankTxRefund, CheckoutTxRefund, @@ -1316,23 +1316,15 @@ export class BuyCryptoService implements OnModuleInit { } async getBuyHistory(userId: number, buyId?: number): Promise { - const where = Util.removeNullFields({ user: { id: userId }, id: buyId }); return this.buyCryptoRepo - .find({ - where: { buy: where }, - relations: { buy: { user: true } }, - }) - .then((buyCryptos) => buyCryptos.map(this.toHistoryDto)); + .findBuyHistory(userId, buyId) + .then((buyCryptos) => buyCryptos.map(BuyCryptoHistoryMapper.toDto)); } async getCryptoHistory(userId: number, routeId?: number): Promise { - const where = Util.removeNullFields({ user: { id: userId }, id: routeId }); return this.buyCryptoRepo - .find({ - where: { cryptoRoute: where }, - relations: { cryptoRoute: { user: true } }, - }) - .then((history) => history.map(this.toHistoryDto)); + .findSwapHistory(userId, routeId) + .then((history) => history.map(BuyCryptoHistoryMapper.toDto)); } async getPendingTransactions(): Promise { @@ -1411,24 +1403,6 @@ export class BuyCryptoService implements OnModuleInit { return request; } - private toHistoryDto(buyCrypto: BuyCrypto): HistoryDtoDeprecated { - return { - inputAmount: buyCrypto.inputAmount, - inputAsset: buyCrypto.inputAsset, - amlCheck: buyCrypto.amlCheck, - outputAmount: buyCrypto.outputAmount, - outputAsset: buyCrypto.outputAsset?.dexName, - txId: buyCrypto.txId, - txUrl: - buyCrypto.outputAsset && buyCrypto.txId - ? txExplorerUrl(buyCrypto.outputAsset.blockchain, buyCrypto.txId) - : undefined, - isComplete: buyCrypto.isComplete, - date: buyCrypto.outputDate, - status: PaymentStatusMapper[buyCrypto.status], - }; - } - private async getBuy(buyId: number): Promise { // buy const buy = await this.buyRepo.findOne({ diff --git a/src/subdomains/core/buy-crypto/routes/buy/buy.entity.ts b/src/subdomains/core/buy-crypto/routes/buy/buy.entity.ts index 5a8eb12290..b7553a135b 100644 --- a/src/subdomains/core/buy-crypto/routes/buy/buy.entity.ts +++ b/src/subdomains/core/buy-crypto/routes/buy/buy.entity.ts @@ -40,7 +40,7 @@ export class Buy extends IEntity { @ManyToOne(() => Deposit, { eager: true, nullable: true }) deposit?: Deposit; - @OneToOne(() => Route, { eager: true, nullable: true }) + @OneToOne(() => Route, { nullable: true }) @JoinColumn() route?: Route; diff --git a/src/subdomains/core/custody/__tests__/custody-order-history.projection.spec.ts b/src/subdomains/core/custody/__tests__/custody-order-history.projection.spec.ts new file mode 100644 index 0000000000..a51b53d81e --- /dev/null +++ b/src/subdomains/core/custody/__tests__/custody-order-history.projection.spec.ts @@ -0,0 +1,233 @@ +import { Asset } from 'src/shared/models/asset/asset.entity'; +import { + CUSTODY_ORDER_HISTORY_PROJECTION, + CUSTODY_ORDER_HISTORY_RESPONSE_FIELDS, + CustodyOrderRepository, +} from 'src/subdomains/core/custody/repositories/custody-order.repository'; +import { CustodyOrder } from 'src/subdomains/core/custody/entities/custody-order.entity'; +import { CustodyOrderHistoryDto } from 'src/subdomains/core/custody/dto/output/custody-order-history.dto'; +import { CustodyOrderHistoryDtoMapper } from 'src/subdomains/core/custody/mappers/custody-order-history-dto.mapper'; +import { CustodyOrderStatus, CustodyOrderType } from 'src/subdomains/core/custody/enums/custody'; +import { TransactionRequest } from 'src/subdomains/supporting/payment/entities/transaction-request.entity'; +import { UserData } from 'src/subdomains/generic/user/models/user-data/user-data.entity'; +import { User } from 'src/subdomains/generic/user/models/user/user.entity'; +import { + createProjectionDataSource, + describeProjection, + destroyProjectionDataSource, + expectEveryFieldRequired, + expectNoEmptyFields, + projectionFieldsWithout, + seedEntity, +} from 'src/shared/utils/projection-test.util'; +import { DataSource } from 'typeorm'; + +const SCHEMA = 'custody_order_history_projection_spec'; + +/** + * `GET /custody/order` — the four levels from `docs/read-path-projections.md`. + * + * The query joins both assets and the transaction request, which the response draws two names and + * two amounts from. + */ +describeProjection('GET /custody/order — read-path projection', () => { + let dataSource: DataSource; + let orders: CustodyOrderRepository; + + beforeAll(async () => { + dataSource = await createProjectionDataSource(SCHEMA); + orders = new CustodyOrderRepository(dataSource.manager); + }, 300000); + + afterAll(async () => { + await destroyProjectionDataSource(dataSource, SCHEMA); + }); + + /** + * One order of a user, with every column populated. + * + * `type` and `status` are set explicitly: both are TypeScript enums in text columns, and the + * mapper switches on them — a generated value lands in the default branch. + */ + async function seedOrder( + type = CustodyOrderType.DEPOSIT, + status = CustodyOrderStatus.COMPLETED, + withAmounts = true, + ): Promise<{ order: CustodyOrder; userData: UserData; transactionRequest: TransactionRequest }> { + const userData = await seedEntity(dataSource, UserData); + const user = await seedEntity(dataSource, User, { values: { userData } }); + const inputAsset = await seedEntity(dataSource, Asset); + const outputAsset = await seedEntity(dataSource, Asset); + const transactionRequest = await seedEntity(dataSource, TransactionRequest); + const order = await seedEntity(dataSource, CustodyOrder, { + // Without its own amounts the order falls back to the request it came from — the only state in + // which the two transactionRequest columns reach the response. + values: { + user, + inputAsset, + outputAsset, + transactionRequest, + type, + status, + ...(withAmounts ? {} : { inputAmount: null, outputAmount: null }), + }, + }); + return { order, userData, transactionRequest }; + } + + /** The response the endpoint produces, through the projected query. */ + async function historyOf( + userDataId: number, + fields = CUSTODY_ORDER_HISTORY_PROJECTION.fields, + ): Promise { + return CustodyOrderHistoryDtoMapper.mapList(await orders.findHistoryFor(userDataId, fields)); + } + + // --- LEVEL 1: completeness --- // + + it('level 1 — a completed order answers with no empty field', async () => { + const { userData } = await seedOrder(); + + const history = await historyOf(userData.id); + + expect(history).toHaveLength(1); + expectNoEmptyFields(history); + }, 120000); + + // --- LEVEL 2: variants --- // + + it.each([ + [CustodyOrderType.DEPOSIT, CustodyOrderStatus.CONFIRMED], + [CustodyOrderType.WITHDRAWAL, CustodyOrderStatus.CONFIRMED], + [CustodyOrderType.SWAP, CustodyOrderStatus.IN_PROGRESS], + [CustodyOrderType.WITHDRAWAL, CustodyOrderStatus.FAILED], + ])( + 'level 2 — %s / %s answers with no empty field', + async (type, status) => { + const { userData } = await seedOrder(type, status); + + // `mapStatus` reads type and status together, and the incoming/outgoing split only shows on a + // confirmed order. `inputAmount` and `outputAmount` fall back to the transaction request for + // incoming and swap orders, so both directions have to be covered. + expectNoEmptyFields(await historyOf(userData.id)); + }, + 120000, + ); + + it('level 2 — an order still in creation is not listed', async () => { + const { userData } = await seedOrder(CustodyOrderType.DEPOSIT, CustodyOrderStatus.CREATED); + + expect(await historyOf(userData.id)).toHaveLength(0); + }, 120000); + + it('level 2 — a user sees only their own orders', async () => { + const mine = await seedOrder(); + const other = await seedOrder(); + + const history = await historyOf(mine.userData.id); + + expect(history).toHaveLength(1); + expect(history[0].created).toEqual(mine.order.created); + expect(history[0].created).not.toEqual(other.order.created); + }, 120000); + + it('level 2 — an order without its optional relations is still listed', async () => { + // All three joined relations are nullable, and every other fixture sets them. If one of the + // left joins were written as an inner one, orders without that relation would vanish from the + // history without a trace — this is the only case that would notice. + const userData = await seedEntity(dataSource, UserData); + const user = await seedEntity(dataSource, User, { values: { userData } }); + const bare = await seedEntity(dataSource, CustodyOrder, { + values: { + user, + inputAsset: null, + outputAsset: null, + transactionRequest: null, + type: CustodyOrderType.DEPOSIT, + status: CustodyOrderStatus.COMPLETED, + }, + }); + + const history = await historyOf(userData.id); + + expect(history).toHaveLength(1); + expect(history[0].created).toEqual(bare.created); + expect(history[0].inputAsset).toBeUndefined(); + expect(history[0].outputAsset).toBeUndefined(); + // The full load answers the same, so nothing here depends on the projection either. + const full = await dataSource.getRepository(CustodyOrder).find({ + where: { user: { userData: { id: userData.id } } }, + relations: { inputAsset: true, outputAsset: true, transactionRequest: true }, + }); + expect(history).toEqual(CustodyOrderHistoryDtoMapper.mapList(full)); + }, 120000); + + // --- LEVEL 3: mutation --- // + + const REQUEST_FALLBACK = ['transactionRequest.estimatedAmount', 'transactionRequest.amount']; + + it.each([ + // With its own amounts set, an order never reads the request — those two columns are covered by + // the row below, where the fallback is the only source the response has. + [ + CustodyOrderType.DEPOSIT, + true, + CUSTODY_ORDER_HISTORY_RESPONSE_FIELDS.filter((f) => !REQUEST_FALLBACK.includes(f)), + ], + [ + CustodyOrderType.WITHDRAWAL, + true, + CUSTODY_ORDER_HISTORY_RESPONSE_FIELDS.filter((f) => !REQUEST_FALLBACK.includes(f)), + ], + [CustodyOrderType.SWAP, false, REQUEST_FALLBACK], + ] as [CustodyOrderType, boolean, string[]][])( + 'level 3 — for %s with own amounts=%s every field feeding the response is required', + async (type, withAmounts, candidates) => { + const { userData, transactionRequest } = await seedOrder(type, CustodyOrderStatus.CONFIRMED, withAmounts); + + // Assert the fallback actually fires before mutating: with an exception list covering the two + // amounts, a baseline that answers nothing at all would pass every reduced run as well. + if (!withAmounts) { + const [row] = await historyOf(userData.id); + expect(row.inputAmount).toEqual(transactionRequest.estimatedAmount); + expect(row.outputAmount).toEqual(transactionRequest.amount); + } + + await expectEveryFieldRequired(candidates, (omitted) => + historyOf(userData.id, projectionFieldsWithout(CUSTODY_ORDER_HISTORY_PROJECTION.fields, omitted)), + ); + }, + 300000, + ); + + // --- LEVEL 4: consistency against a second source --- // + + it.each([ + [CustodyOrderType.DEPOSIT, CustodyOrderStatus.COMPLETED], + [CustodyOrderType.SWAP, CustodyOrderStatus.CONFIRMED], + [CustodyOrderType.WITHDRAWAL, CustodyOrderStatus.FAILED], + ])( + 'level 4 — for %s / %s the projected response equals the one from a full load', + async (type, status) => { + const { userData } = await seedOrder(type, status); + + const projected = await historyOf(userData.id); + // The unprojected load is the second source: `leftJoinAndSelect` on the three relations, + // which selects each of them whole. + const full = await dataSource + .getRepository(CustodyOrder) + .createQueryBuilder('custodyOrder') + .leftJoinAndSelect('custodyOrder.inputAsset', 'inputAsset') + .leftJoinAndSelect('custodyOrder.outputAsset', 'outputAsset') + .leftJoinAndSelect('custodyOrder.transactionRequest', 'transactionRequest') + .innerJoin('custodyOrder.user', 'user') + .innerJoin('user.userData', 'userData') + .where('userData.id = :userDataId', { userDataId: userData.id }) + .andWhere('custodyOrder.status != :createdStatus', { createdStatus: CustodyOrderStatus.CREATED }) + .getMany(); + + expect(projected).toEqual(CustodyOrderHistoryDtoMapper.mapList(full)); + }, + 120000, + ); +}); diff --git a/src/subdomains/core/custody/entities/custody-balance.entity.ts b/src/subdomains/core/custody/entities/custody-balance.entity.ts index 55e2025f20..90cfdbc8a5 100644 --- a/src/subdomains/core/custody/entities/custody-balance.entity.ts +++ b/src/subdomains/core/custody/entities/custody-balance.entity.ts @@ -11,7 +11,7 @@ export class CustodyBalance extends IEntity { balance: number; @Index() - @ManyToOne(() => User, (user) => user.custodyBalances, { nullable: false, eager: true }) + @ManyToOne(() => User, (user) => user.custodyBalances, { nullable: false }) user: User; @Index() diff --git a/src/subdomains/core/custody/entities/custody-order.entity.ts b/src/subdomains/core/custody/entities/custody-order.entity.ts index 340ab7ca64..21586f4fdb 100644 --- a/src/subdomains/core/custody/entities/custody-order.entity.ts +++ b/src/subdomains/core/custody/entities/custody-order.entity.ts @@ -68,7 +68,7 @@ export class CustodyOrder extends IEntity { @JoinColumn() transactionRequest?: TransactionRequest; - @OneToOne(() => Transaction, (transaction) => transaction.custodyOrder, { nullable: true, eager: true }) + @OneToOne(() => Transaction, (transaction) => transaction.custodyOrder, { nullable: true }) @JoinColumn() transaction?: Transaction; diff --git a/src/subdomains/core/custody/repositories/custody-order.repository.ts b/src/subdomains/core/custody/repositories/custody-order.repository.ts index e5ec46cb06..3a6fec40da 100644 --- a/src/subdomains/core/custody/repositories/custody-order.repository.ts +++ b/src/subdomains/core/custody/repositories/custody-order.repository.ts @@ -1,11 +1,80 @@ import { Injectable } from '@nestjs/common'; +import { ReadProjection } from 'src/shared/models/read-projection'; import { BaseRepository } from 'src/shared/repositories/base.repository'; import { EntityManager } from 'typeorm'; import { CustodyOrder } from '../entities/custody-order.entity'; +import { CustodyOrderStatus } from 'src/subdomains/core/custody/enums/custody'; + +/** What `CustodyOrderHistoryDtoMapper.map` reads. */ +export const CUSTODY_ORDER_HISTORY_RESPONSE_FIELDS = [ + 'custodyOrder.type', + // `mapStatus` translates it together with `type`; both branches of that switch are response state. + 'custodyOrder.status', + 'custodyOrder.created', + 'custodyOrder.completedAt', + 'custodyOrder.inputAmount', + 'custodyOrder.outputAmount', + 'inputAsset.name', + 'outputAsset.name', + 'transactionRequest.estimatedAmount', + 'transactionRequest.amount', +]; + +/** + * `GET /custody/order` — a user's order history. + * + * The two assets and the transaction request are joined for the two names and two amounts the + * response shows. + */ +export const CUSTODY_ORDER_HISTORY_PROJECTION = new ReadProjection( + 'custodyOrder', + [ + ['custodyOrder.inputAsset', 'inputAsset'], + ['custodyOrder.outputAsset', 'outputAsset'], + ['custodyOrder.transactionRequest', 'transactionRequest'], + ], + CUSTODY_ORDER_HISTORY_RESPONSE_FIELDS, + // Never part of the response: the primary keys that materialise the joined rows, and the id the + // query orders by to keep pagination deterministic. + ['custodyOrder.id', 'inputAsset.id', 'outputAsset.id', 'transactionRequest.id'], +); @Injectable() export class CustodyOrderRepository extends BaseRepository { constructor(manager: EntityManager) { super(CustodyOrder, manager); } + + /** + * The custody orders of an account, newest first, capped at a hundred. + * + * `fields` is what the mutation test in `custody-order-history.projection.spec.ts` re-runs the + * query with; `CustodyOrderService.getOrdersByUserData` calls this without it. That method serves + * two endpoints — `GET /custody/order` and `GET /custody/account/:id/order` — so both answer out + * of this projection, and the second one is wider only because its access check loads elsewhere. + */ + async findHistoryFor( + userDataId: number, + fields: ReadonlyArray = CUSTODY_ORDER_HISTORY_PROJECTION.fields, + ): Promise { + return ( + CUSTODY_ORDER_HISTORY_PROJECTION.apply(this.createQueryBuilder('custodyOrder'), fields) + .innerJoin('custodyOrder.user', 'user') + .innerJoin('user.userData', 'userData') + .where('userData.id = :userDataId', { userDataId }) + .andWhere('custodyOrder.status != :createdStatus', { createdStatus: CustodyOrderStatus.CREATED }) + // The list shows completedAt where the order is completed, created otherwise. Sorting by + // created alone would put rows out of order against the dates the reader can see. + .orderBy('COALESCE("custodyOrder"."completedAt", "custodyOrder"."created")', 'DESC') + // Two orders can share a timestamp, and an undefined order among them would let rows swap + // places between calls - or cross the cap below and vanish. The id keeps it deterministic. + .addOrderBy('custodyOrder.id', 'DESC') + // limit, not take: take() splits the query in two and parses the raw orderBy at every dot, + // which turns the expression above into a lookup for an alias named COALESCE("custodyOrder" + // and throws on every call. Every relation joined here is to-one, so no row can be + // duplicated and limiting rows is the same as limiting entities. + .limit(100) + .getMany() + ); + } } diff --git a/src/subdomains/core/custody/services/custody-order.service.ts b/src/subdomains/core/custody/services/custody-order.service.ts index 4c00fec9c0..cdedde93ee 100644 --- a/src/subdomains/core/custody/services/custody-order.service.ts +++ b/src/subdomains/core/custody/services/custody-order.service.ts @@ -232,28 +232,9 @@ export class CustodyOrderService { }; } + /** A user's custody order history, for `GET /custody/order` and `GET /custody/account/:id/order`. */ async getOrdersByUserData(userDataId: number): Promise { - const orders = await this.custodyOrderRepo - .createQueryBuilder('custodyOrder') - .leftJoinAndSelect('custodyOrder.inputAsset', 'inputAsset') - .leftJoinAndSelect('custodyOrder.outputAsset', 'outputAsset') - .leftJoinAndSelect('custodyOrder.transactionRequest', 'transactionRequest') - .innerJoin('custodyOrder.user', 'user') - .innerJoin('user.userData', 'userData') - .where('userData.id = :userDataId', { userDataId }) - .andWhere('custodyOrder.status != :createdStatus', { createdStatus: CustodyOrderStatus.CREATED }) - // The list shows completedAt where the order is completed, created otherwise. Sorting by - // created alone would put rows out of order against the dates the reader can see. - .orderBy('COALESCE("custodyOrder"."completedAt", "custodyOrder"."created")', 'DESC') - // Two orders can share a timestamp, and an undefined order among them would let rows swap - // places between calls - or cross the cap below and vanish. The id keeps it deterministic. - .addOrderBy('custodyOrder.id', 'DESC') - // limit, not take: take() splits the query in two and parses the raw orderBy at every dot, - // which turns the expression above into a lookup for an alias named COALESCE("custodyOrder" - // and throws on every call. Every relation joined here is to-one, so no row can be - // duplicated and limiting rows is the same as limiting entities. - .limit(100) - .getMany(); + const orders = await this.custodyOrderRepo.findHistoryFor(userDataId); return CustodyOrderHistoryDtoMapper.mapList(orders); } diff --git a/src/subdomains/core/liquidity-management/__tests__/pipeline-status.projection.spec.ts b/src/subdomains/core/liquidity-management/__tests__/pipeline-status.projection.spec.ts new file mode 100644 index 0000000000..91c05c5ca2 --- /dev/null +++ b/src/subdomains/core/liquidity-management/__tests__/pipeline-status.projection.spec.ts @@ -0,0 +1,127 @@ +import { + createProjectionDataSource, + describeProjection, + destroyProjectionDataSource, + expectEveryFieldRequired, + expectNoEmptyFields, + projectionFieldsWithout, + seedEntity, +} from 'src/shared/utils/projection-test.util'; +import { LiquidityManagementPipeline } from 'src/subdomains/core/liquidity-management/entities/liquidity-management-pipeline.entity'; +import { LiquidityManagementRule } from 'src/subdomains/core/liquidity-management/entities/liquidity-management-rule.entity'; +import { LiquidityManagementPipelineStatus } from 'src/subdomains/core/liquidity-management/enums'; +import { + PIPELINE_STATUS_PROJECTION, + PIPELINE_STATUS_RESPONSE_FIELDS, + LiquidityManagementPipelineRepository, +} from 'src/subdomains/core/liquidity-management/repositories/liquidity-management-pipeline.repository'; +import { DataSource } from 'typeorm'; + +const SCHEMA = 'pipeline_status_projection_spec'; + +/** + * `GET /liquidityManagement/pipeline/:id/status` — the four levels from + * `docs/read-path-projections.md`. + * + * The endpoint answers with one string, and the pipeline expands its rule and both of its action + * relations eagerly — so asking for the row by id reaches all of them. + */ +describeProjection('liquidity management pipeline status — read-path projection', () => { + let dataSource: DataSource; + let pipelines: LiquidityManagementPipelineRepository; + + beforeAll(async () => { + dataSource = await createProjectionDataSource(SCHEMA); + pipelines = new LiquidityManagementPipelineRepository(dataSource.manager); + }, 300000); + + afterAll(async () => { + await destroyProjectionDataSource(dataSource, SCHEMA); + }); + + /** + * One pipeline in the given status. + * + * `status` is set explicitly: it is a TypeScript enum in a text column and it is the whole + * response, so a generated value would prove nothing about the projection. + */ + async function seedPipeline( + status = LiquidityManagementPipelineStatus.IN_PROGRESS, + ): Promise { + const rule = await seedEntity(dataSource, LiquidityManagementRule); + return seedEntity(dataSource, LiquidityManagementPipeline, { + values: { rule, status }, + }); + } + + /** The response the endpoint produces, through the projected query. */ + async function statusOf( + id: number, + fields = PIPELINE_STATUS_PROJECTION.fields, + ): Promise { + const pipeline = await pipelines.findForStatus(id, fields); + return pipeline?.status; + } + + // --- LEVEL 1: completeness --- // + + it('level 1 — a pipeline answers with its status', async () => { + const pipeline = await seedPipeline(); + + expectNoEmptyFields(await statusOf(pipeline.id)); + }, 120000); + + // --- LEVEL 2: variants --- // + + it.each(Object.values(LiquidityManagementPipelineStatus))( + 'level 2 — a pipeline in status %s answers with that status', + async (status) => { + const pipeline = await seedPipeline(status); + + expect(await statusOf(pipeline.id)).toEqual(status); + }, + 120000, + ); + + it('level 2 — an unknown id resolves to nothing, so the endpoint can answer 404', async () => { + const pipeline = await seedPipeline(); + + // The distinction matters: the repository returns the row rather than the status, so a missing + // pipeline stays separable from one whose status is not set. + expect(await pipelines.findForStatus(pipeline.id + 1_000_000)).toBeNull(); + }, 120000); + + it('level 2 — the answer belongs to the pipeline that was asked for', async () => { + const mine = await seedPipeline(LiquidityManagementPipelineStatus.COMPLETE); + await seedPipeline(LiquidityManagementPipelineStatus.FAILED); + + expect(await statusOf(mine.id)).toEqual(LiquidityManagementPipelineStatus.COMPLETE); + }, 120000); + + // --- LEVEL 3: mutation --- // + + it('level 3 — the status field is required', async () => { + const pipeline = await seedPipeline(); + + await expectEveryFieldRequired(PIPELINE_STATUS_RESPONSE_FIELDS, (omitted) => + statusOf(pipeline.id, projectionFieldsWithout(PIPELINE_STATUS_PROJECTION.fields, omitted)), + ); + }, 300000); + + // --- LEVEL 4: consistency against a second source --- // + + it.each([LiquidityManagementPipelineStatus.CREATED, LiquidityManagementPipelineStatus.STOPPED])( + 'level 4 — for %s the projected answer equals the one from a full load', + async (status) => { + const pipeline = await seedPipeline(status); + + const projected = await statusOf(pipeline.id); + // The unprojected load is the second source: a find without a field list, with every eager + // relation it pulls in. + const full = await dataSource.getRepository(LiquidityManagementPipeline).findOneBy({ id: pipeline.id }); + + expect(projected).toEqual(full.status); + }, + 120000, + ); +}); diff --git a/src/subdomains/core/liquidity-management/repositories/liquidity-management-pipeline.repository.ts b/src/subdomains/core/liquidity-management/repositories/liquidity-management-pipeline.repository.ts index 37deff4833..eb212b9dd1 100644 --- a/src/subdomains/core/liquidity-management/repositories/liquidity-management-pipeline.repository.ts +++ b/src/subdomains/core/liquidity-management/repositories/liquidity-management-pipeline.repository.ts @@ -1,11 +1,47 @@ import { Injectable } from '@nestjs/common'; +import { ReadProjection } from 'src/shared/models/read-projection'; import { BaseRepository } from 'src/shared/repositories/base.repository'; import { EntityManager } from 'typeorm'; import { LiquidityManagementPipeline } from '../entities/liquidity-management-pipeline.entity'; +/** The single value `GET /liquidityManagement/pipeline/:id/status` answers with. */ +export const PIPELINE_STATUS_RESPONSE_FIELDS = ['pipeline.status']; + +/** + * `GET /liquidityManagement/pipeline/:id/status` — one column, for one value. + * + * A plain lookup by id expands the pipeline's eager graph, and its rule's in turn, to read a status + * string. + */ +export const PIPELINE_STATUS_PROJECTION = new ReadProjection( + 'pipeline', + [], + PIPELINE_STATUS_RESPONSE_FIELDS, + // Never shown, but without the primary key the ORM has nothing to identify the row by. + ['pipeline.id'], +); + @Injectable() export class LiquidityManagementPipelineRepository extends BaseRepository { constructor(manager: EntityManager) { super(LiquidityManagementPipeline, manager); } + + /** + * One pipeline, carrying its status and nothing else. + * + * Returns the row rather than the status so that a missing pipeline stays distinguishable from a + * pipeline whose status is not set — the endpoint answers 404 only for the first. + * + * `fields` is what the mutation test in `pipeline-status.projection.spec.ts` re-runs the query + * with; `LiquidityManagementPipelineService.getPipelineStatus` calls this without it. + */ + async findForStatus( + id: number, + fields: ReadonlyArray = PIPELINE_STATUS_PROJECTION.fields, + ): Promise { + return PIPELINE_STATUS_PROJECTION.apply(this.createQueryBuilder('pipeline'), fields) + .where('pipeline.id = :id', { id }) + .getOne(); + } } diff --git a/src/subdomains/core/liquidity-management/services/liquidity-management-pipeline.service.ts b/src/subdomains/core/liquidity-management/services/liquidity-management-pipeline.service.ts index da87a948ce..886b978a4c 100644 --- a/src/subdomains/core/liquidity-management/services/liquidity-management-pipeline.service.ts +++ b/src/subdomains/core/liquidity-management/services/liquidity-management-pipeline.service.ts @@ -147,7 +147,7 @@ export class LiquidityManagementPipelineService { } async getPipelineStatus(pipelineId: number): Promise { - const pipeline = await this.pipelineRepo.findOneBy({ id: pipelineId }); + const pipeline = await this.pipelineRepo.findForStatus(pipelineId); if (!pipeline) throw new NotFoundException(`No liquidity management pipeline found for id ${pipelineId}`); diff --git a/src/subdomains/core/payment-link/__tests__/pos-link.projection.spec.ts b/src/subdomains/core/payment-link/__tests__/pos-link.projection.spec.ts new file mode 100644 index 0000000000..d82ad443aa --- /dev/null +++ b/src/subdomains/core/payment-link/__tests__/pos-link.projection.spec.ts @@ -0,0 +1,372 @@ +import { createMock } from '@golevelup/ts-jest'; +import { ConfigService } from 'src/config/config'; +import { + createProjectionDataSource, + describeProjection, + destroyProjectionDataSource, + expectEveryFieldRequired, + expectNoEmptyFields, + projectionFieldsWithout, + seedEntity, +} from 'src/shared/utils/projection-test.util'; +import { PaymentLink } from 'src/subdomains/core/payment-link/entities/payment-link.entity'; +import { + POS_LINK_PROJECTION, + POS_LINK_RESPONSE_FIELDS, + PaymentLinkRepository, +} from 'src/subdomains/core/payment-link/repositories/payment-link.repository'; +import { C2BPaymentLinkService } from 'src/subdomains/core/payment-link/services/c2b-payment-link.service'; +import { PaymentLinkPaymentService } from 'src/subdomains/core/payment-link/services/payment-link-payment.service'; +import { PaymentLinkService } from 'src/subdomains/core/payment-link/services/payment-link.service'; +import { PaymentQuoteService } from 'src/subdomains/core/payment-link/services/payment-quote.service'; +import { Sell } from 'src/subdomains/core/sell-crypto/route/sell.entity'; +import { BankData } from 'src/subdomains/generic/user/models/bank-data/bank-data.entity'; +import { Organization } from 'src/subdomains/generic/user/models/organization/organization.entity'; +import { AccountType } from 'src/subdomains/generic/user/models/user-data/account-type.enum'; +import { UserData } from 'src/subdomains/generic/user/models/user-data/user-data.entity'; +import { UserDataRepository } from 'src/subdomains/generic/user/models/user-data/user-data.repository'; +import { UserDataService } from 'src/subdomains/generic/user/models/user-data/user-data.service'; +import { User } from 'src/subdomains/generic/user/models/user/user.entity'; +import { DepositRouteService } from 'src/subdomains/supporting/address-pool/route/deposit-route.service'; +import { DataSource } from 'typeorm'; + +const SCHEMA = 'pos_link_projection_spec'; + +/** + * `PUT /paymentLink/:id/pos` — the four levels from `docs/read-path-projections.md`. + * + * Driven through `PaymentLinkService.createPosLinkAdmin` rather than a rebuilt query, with every + * collaborator mocked except the repository under test. The account-side write goes through + * `UserDataService` and is asserted on rather than executed. + */ +describeProjection('point-of-sale link — read-path projection', () => { + let dataSource: DataSource; + let paymentLinks: PaymentLinkRepository; + let userDataService: UserDataService; + let userData: UserDataRepository; + let service: PaymentLinkService; + + beforeAll(async () => { + // The URL prefix comes from the module-level Config. + new ConfigService(); + dataSource = await createProjectionDataSource(SCHEMA); + paymentLinks = new PaymentLinkRepository(dataSource.manager); + userData = new UserDataRepository(dataSource.manager); + }, 300000); + + beforeEach(() => { + userDataService = createMock(); + // `updatePaymentLinksConfig` is the one collaborator method that matters here: it receives the + // projected account entity and re-reads `paymentLinksConfig` off it to merge into. Bound to a + // real repository it runs for real, so a projection that dropped that column would reset the + // account's configuration and this spec would see it. + userDataService.updatePaymentLinksConfig = jest.fn((user, dto) => + UserDataService.prototype.updatePaymentLinksConfig.call({ userDataRepo: userData }, user, dto), + ); + service = new PaymentLinkService( + paymentLinks, + createMock(), + createMock(), + userDataService, + createMock(), + createMock(), + ); + }); + + afterAll(async () => { + await destroyProjectionDataSource(dataSource, SCHEMA); + }); + + /** + * A link on a route of an account. + * + * `accountType` is set explicitly because `UserData.address` branches on it — the endpoint does + * not read that address, and the organization fixture below is what shows that it does not. + */ + async function seedLink( + accountType = AccountType.PERSONAL, + account: Partial = {}, + link: Partial = {}, + ): Promise<{ paymentLink: PaymentLink; userData: UserData }> { + const organization = await seedEntity(dataSource, Organization); + const userData = await seedEntity(dataSource, UserData, { + // `paymentLinksConfig` holds JSON and is read through `JSON.parse`; a generated string throws + // before any assertion is reached. + values: { organization, accountType, paymentLinksConfig: '{}', ...account }, + }); + const user = await seedEntity(dataSource, User, { values: { userData } }); + // An active sell route carries a check constraint requiring a bank data row. + const bankData = await seedEntity(dataSource, BankData, { values: { userData } }); + const route = await seedEntity(dataSource, Sell, { values: { user, active: true, bankData } }); + const paymentLink = await seedEntity(dataSource, PaymentLink, { + values: { route, config: null, ...link }, + }); + return { paymentLink, userData }; + } + + /** A configuration carrying one access key, as the endpoint stores it. */ + const withKey = (key: string): string => JSON.stringify({ accessKeys: [key] }); + + /** + * The `key` parameter of the URL the endpoint answers with. + * + * Read off the query string rather than through `URL`: the prefix comes from the configuration + * and is not necessarily absolute. + */ + const keyOf = (url: string): string => { + const key = new URLSearchParams(url.slice(url.indexOf('?') + 1)).get('key'); + if (key == null) throw new Error(`No key in ${url}`); + + return key; + }; + + /** The answer of the endpoint, through the projected query. */ + async function posLinkOf( + id: number, + scoped?: boolean, + fields = POS_LINK_PROJECTION.fields, + ): Promise<{ url: string; key: string }> { + jest + .spyOn(paymentLinks, 'findForPosLink') + .mockImplementationOnce((linkId) => + POS_LINK_PROJECTION.apply(paymentLinks.createQueryBuilder('paymentLink'), fields) + .where('paymentLink.id = :linkId', { linkId }) + .getOne(), + ); + + const url = await service.createPosLinkAdmin(id, scoped); + + return { url, key: keyOf(url) }; + } + + // --- LEVEL 1: completeness --- // + + it('level 1 — a link with a stored key answers with it', async () => { + const { paymentLink } = await seedLink(AccountType.PERSONAL, {}, { config: withKey('stored-on-the-link') }); + + const answer = await posLinkOf(paymentLink.id); + + expect(answer.key).toEqual('stored-on-the-link'); + expectNoEmptyFields(answer); + }, 120000); + + // --- LEVEL 2: variants --- // + + it.each<[string, boolean | undefined]>([ + ['unset, which merges the account into the link', undefined], + ['true, which reads the link alone', true], + ])( + 'level 2 — with scoped %s the key comes from the link', + async (_name, scoped) => { + const { paymentLink } = await seedLink(AccountType.PERSONAL, {}, { config: withKey('from-the-link') }); + + expect((await posLinkOf(paymentLink.id, scoped)).key).toEqual('from-the-link'); + }, + 120000, + ); + + it.each<[string, boolean | undefined]>([ + ['unset, which merges the account into the link', undefined], + ['false, which reads the account alone', false], + ])( + 'level 2 — with scoped %s the key comes from the account', + async (_name, scoped) => { + const { paymentLink } = await seedLink(AccountType.PERSONAL, { paymentLinksConfig: withKey('from-the-account') }); + + expect((await posLinkOf(paymentLink.id, scoped)).key).toEqual('from-the-account'); + }, + 120000, + ); + + it('level 2 — a link that sets the keys to null overrides the account rather than falling back', async () => { + // The merge is by spread, so a key the link carries wins even when its value is null. Without + // this case the two configurations could be merged the other way round and every other variant + // would still pass. + const { paymentLink } = await seedLink( + AccountType.PERSONAL, + { paymentLinksConfig: withKey('from-the-account') }, + { config: JSON.stringify({ accessKeys: null }) }, + ); + + const answer = await posLinkOf(paymentLink.id); + + // A freshly generated key rather than the account's: the link's null wins the merge, so the + // endpoint finds no key at all and issues one. + expect(answer.key).not.toEqual('from-the-account'); + expect(answer.key).toMatch(/^[0-9A-Z]{40,}$/); + }, 120000); + + it.each([AccountType.ORGANIZATION, AccountType.SOLE_PROPRIETORSHIP])( + 'level 2 — a %s account answers without reading its address', + async (accountType) => { + // `configObj` assembles a recipient block the endpoint discards, and that block reads + // `UserData.address`, a getter that switches to the organization row for these two account + // types. The projection joins no organization — this is the case that shows it needs none. + const { paymentLink } = await seedLink(accountType, {}, { config: withKey('regardless-of-address') }); + + expect((await posLinkOf(paymentLink.id)).key).toEqual('regardless-of-address'); + }, + 120000, + ); + + it('level 2 — a link without a stored key gets one, written to the link', async () => { + const { paymentLink } = await seedLink(); + + const answer = await posLinkOf(paymentLink.id, true); + + const stored = await dataSource.getRepository(PaymentLink).findOneBy({ id: paymentLink.id }); + expect(JSON.parse(stored.config).accessKeys).toEqual([answer.key]); + }, 120000); + + it('level 2 — the unscoped branch writes through the account service instead', async () => { + const { paymentLink, userData } = await seedLink(); + + const answer = await posLinkOf(paymentLink.id, false); + + expect(userDataService.updatePaymentLinksConfig).toHaveBeenCalledWith( + expect.objectContaining({ id: userData.id }), + { + accessKeys: [answer.key], + }, + ); + }, 120000); + + it('level 2 — an unknown id is refused', async () => { + const { paymentLink } = await seedLink(); + + await expect(posLinkOf(paymentLink.id + 1_000_000)).rejects.toThrow('Payment link not found'); + }, 120000); + + // --- LEVEL 3: mutation --- // + + /** The fields each configuration source contributes, keyed by the fixture that reads it. */ + const LINK_FIELDS = ['paymentLink.uniqueId', 'paymentLink.config']; + const ACCOUNT_FIELDS = ['paymentLink.uniqueId', 'posUserData.paymentLinksConfig']; + + // Only one of the two configurations is consulted per call, so each is a candidate in the fixture + // that reads it and not in the other — dropping the unread one changes nothing, which is true and + // proves nothing. + it.each([ + ['the link', undefined, { config: withKey('mutation-link') }, {}, LINK_FIELDS], + ['the account', false, {}, { paymentLinksConfig: withKey('mutation-account') }, ACCOUNT_FIELDS], + ] as [string, boolean, Partial, Partial, string[]][])( + 'level 3 — with the key on %s every field feeding the answer is required', + async (_name, scoped, link, account, candidates) => { + const { paymentLink } = await seedLink(AccountType.PERSONAL, account, link); + + await expectEveryFieldRequired(candidates, (omitted) => + posLinkOf(paymentLink.id, scoped, projectionFieldsWithout(POS_LINK_PROJECTION.fields, omitted)), + ); + }, + 300000, + ); + + it('level 3 — the two fixtures together cover every field of the projection', () => { + // Splitting the candidates per fixture is only sound if nothing falls between them. + expect([...new Set([...LINK_FIELDS, ...ACCOUNT_FIELDS])].sort()).toEqual([...POS_LINK_RESPONSE_FIELDS].sort()); + }); + + // --- the claim that makes this endpoint convertible at all --- // + + it.each([ + ['the link', 'payment_link', 'config'], + ['the account', 'user_data', 'paymentLinksConfig'], + ])( + 'writing %s after a projected read leaves every other column untouched', + async (_name, table, column) => { + // This is the whole reason a write endpoint can be converted: both updates name their columns, + // so neither can blank the ones the projection left out. Saving a loaded row back would. + const { paymentLink, userData } = await seedLink(); + const id = table === 'payment_link' ? paymentLink.id : userData.id; + const rowOf = async (): Promise> => + (await dataSource.query(`SELECT * FROM "${SCHEMA}"."${table}" WHERE id = $1`, [id]))[0]; + const before = await rowOf(); + + const loaded = await paymentLinks.findForPosLink(paymentLink.id); + const written = withKey('written-key'); + if (table === 'payment_link') await paymentLinks.update(loaded.id, { config: written }); + else await dataSource.getRepository(UserData).update(loaded.route.userData.id, { paymentLinksConfig: written }); + + const after = await rowOf(); + expect(after[column]).toEqual(written); + + const ignored = [column, 'updated']; + const comparable = (row: Record): Record => + Object.fromEntries(Object.entries(row).filter(([name]) => !ignored.includes(name))); + + expect(comparable(after)).toEqual(comparable(before)); + }, + 120000, + ); + + it('keeps the existing configuration when the write adds an access key to the link', async () => { + // The scoped write merges the new key into what was read, so this is the failure the projection + // could cause: a `config` it did not load hands the merge an empty object, and the stored + // configuration is replaced by nothing but the key. Run end to end for that reason. + const { paymentLink } = await seedLink( + AccountType.PERSONAL, + {}, + // Both values differ from the defaults: the merge strips anything equal to them, which is + // the service's own behaviour and would make an equal value look lost. + { config: JSON.stringify({ fee: 0.9, paymentTimeout: 12345 }) }, + ); + + const answer = await posLinkOf(paymentLink.id, true); + + const stored = JSON.parse((await dataSource.getRepository(PaymentLink).findOneBy({ id: paymentLink.id })).config); + expect(stored.accessKeys).toEqual([answer.key]); + expect(stored.fee).toEqual(0.9); + expect(stored.paymentTimeout).toEqual(12345); + }, 120000); + + it('keeps the existing configuration when the write adds an access key to the account', async () => { + // The unscoped branch merges on the account side, out of the projected entity it is handed — + // `updatePaymentLinksConfig` re-reads `paymentLinksConfig` off that entity. A projection missing + // the column would hand it an empty object and replace the account's configuration with nothing + // but the new key, which is why that method runs for real here. + const { paymentLink, userData: account } = await seedLink(AccountType.PERSONAL, { + paymentLinksConfig: JSON.stringify({ fee: 0.4 }), + }); + + const answer = await posLinkOf(paymentLink.id, false); + + const stored = JSON.parse( + (await dataSource.getRepository(UserData).findOneBy({ id: account.id })).paymentLinksConfig, + ); + expect(stored.accessKeys).toEqual([answer.key]); + expect(stored.fee).toEqual(0.4); + }, 120000); + + it('loads the two ids the endpoint scopes its updates by', async () => { + // Neither appears in the answer, so no level would notice their absence — and both updates are + // scoped by them. + const { paymentLink, userData } = await seedLink(); + + const loaded = await paymentLinks.findForPosLink(paymentLink.id); + + expect(loaded.id).toEqual(paymentLink.id); + expect(loaded.route.userData.id).toEqual(userData.id); + }, 120000); + + // --- LEVEL 4: consistency against a second source --- // + + it.each([AccountType.PERSONAL, AccountType.ORGANIZATION])( + 'level 4 — for %s the projected answer equals the one from a full load', + async (accountType) => { + const { paymentLink } = await seedLink(accountType, {}, { config: withKey('same-either-way') }); + + const projected = await posLinkOf(paymentLink.id); + // The unprojected load is the second source: the same relations selected whole. + jest.spyOn(paymentLinks, 'findForPosLink').mockImplementationOnce((id) => + paymentLinks.findOne({ + where: { id }, + relations: { route: { user: { userData: { organization: true } } } }, + }), + ); + const full = await service.createPosLinkAdmin(paymentLink.id); + + expect(projected.url).toEqual(full); + }, + 120000, + ); +}); diff --git a/src/subdomains/core/payment-link/entities/payment-link.entity.ts b/src/subdomains/core/payment-link/entities/payment-link.entity.ts index dc54c6f38c..1e81e45018 100644 --- a/src/subdomains/core/payment-link/entities/payment-link.entity.ts +++ b/src/subdomains/core/payment-link/entities/payment-link.entity.ts @@ -104,6 +104,22 @@ export class PaymentLink extends IEntity { return Object.assign({}, DefaultPaymentLinkConfig, JSON.parse(this.config ?? '{}')); } + /** + * The configuration a point-of-sale link reads its access keys from, scoped to the link, to the + * account, or merged with the link winning. + * + * Separate from `configObj` because that one also assembles the recipient — name, contact data + * and address of the account — which `PaymentLinkService.createPosLinkFor` discards. Each side is + * read lazily: both getters parse their own JSON column, and a scoped call must not fail on the + * column it does not use. + */ + accessConfig(scoped?: boolean): PaymentLinkConfig { + const account = (): PaymentLinkConfig => this.route.userData.paymentLinksConfigObj; + const link = (): PaymentLinkConfig => this.linkConfigObj; + + return scoped == null ? { ...account(), ...link() } : scoped ? link() : account(); + } + get defaultStandard(): PaymentStandard { return this.configObj.standards[0]; } diff --git a/src/subdomains/core/payment-link/repositories/payment-link.repository.ts b/src/subdomains/core/payment-link/repositories/payment-link.repository.ts index cb28937c10..b0236f20b6 100644 --- a/src/subdomains/core/payment-link/repositories/payment-link.repository.ts +++ b/src/subdomains/core/payment-link/repositories/payment-link.repository.ts @@ -1,15 +1,66 @@ import { Injectable } from '@nestjs/common'; +import { ReadProjection } from 'src/shared/models/read-projection'; import { BaseRepository } from 'src/shared/repositories/base.repository'; import { Between, EntityManager, Equal, In } from 'typeorm'; import { PaymentLink } from '../entities/payment-link.entity'; import { PaymentLinkPaymentStatus } from '../enums'; +/** + * What `PUT /paymentLink/:id/pos` reads: a URL built from `uniqueId` and one access key, taken from + * the link's configuration, the account's, or the two merged. + * + * `accountType` is deliberately NOT selected: `UserData.address` switches to the organization row + * for an organization account and would dereference a relation this query has no reason to join. + */ +export const POS_LINK_RESPONSE_FIELDS = [ + 'paymentLink.uniqueId', + 'paymentLink.config', + 'posUserData.paymentLinksConfig', +]; + +/** + * `PUT /paymentLink/:id/pos` — the access keys, and the ids that carry the joins. + * + * The endpoint writes, but through `update(id, …)` on the link and on the account rather than by + * saving either row back, so a projected read cannot blank a column it did not load. `config` and + * `paymentLinksConfig` are in the projection for the write as much as for the answer: it merges the + * new key into whichever of them applies, and a configuration the query failed to load would be a + * configuration silently reset. + */ +export const POS_LINK_PROJECTION = new ReadProjection( + 'paymentLink', + [ + ['paymentLink.route', 'posRoute'], + ['posRoute.user', 'posUser'], + ['posUser.userData', 'posUserData'], + ], + POS_LINK_RESPONSE_FIELDS, + // Never part of the answer: the primary keys that make the ORM materialise the joined rows, and + // the two ids the two updates are scoped by. + ['paymentLink.id', 'posRoute.id', 'posUser.id', 'posUserData.id'], +); + @Injectable() export class PaymentLinkRepository extends BaseRepository { constructor(manager: EntityManager) { super(PaymentLink, manager); } + /** + * One link, carrying what a point-of-sale link is built from. + * + * `fields` is what the mutation test in `pos-link.projection.spec.ts` re-runs the query with; + * `PaymentLinkService.createPosLinkAdmin` calls this without it. + */ + async findForPosLink( + id: number, + fields: ReadonlyArray = POS_LINK_PROJECTION.fields, + ): Promise { + return POS_LINK_PROJECTION.apply(this.createQueryBuilder('paymentLink'), fields) + .where('paymentLink.id = :id', { id }) + .getOne(); + } + async getAllPaymentLinks(userId: number): Promise { return this.find({ where: { route: { user: { id: Equal(userId) }, active: true } }, diff --git a/src/subdomains/core/payment-link/services/payment-link.service.ts b/src/subdomains/core/payment-link/services/payment-link.service.ts index 57a5452678..bf36adb166 100644 --- a/src/subdomains/core/payment-link/services/payment-link.service.ts +++ b/src/subdomains/core/payment-link/services/payment-link.service.ts @@ -699,22 +699,14 @@ export class PaymentLinkService { } async createPosLinkAdmin(paymentLinkId: number, scoped?: boolean): Promise { - const paymentLink = await this.paymentLinkRepo.findOne({ - where: { id: paymentLinkId }, - relations: { route: { user: { userData: { organization: true } } } }, - }); + const paymentLink = await this.paymentLinkRepo.findForPosLink(paymentLinkId); if (!paymentLink) throw new NotFoundException('Payment link not found'); return this.createPosLinkFor(paymentLink, scoped); } private async createPosLinkFor(paymentLink: PaymentLink, scoped?: boolean): Promise { - const config = - scoped == null - ? paymentLink.configObj - : scoped - ? paymentLink.linkConfigObj - : paymentLink.route.userData.paymentLinksConfigObj; + const config = paymentLink.accessConfig(scoped); let accessKey = config.accessKeys?.at(0); if (!accessKey) { diff --git a/src/subdomains/core/sell-crypto/process/__tests__/buy-fiat-history.projection.spec.ts b/src/subdomains/core/sell-crypto/process/__tests__/buy-fiat-history.projection.spec.ts new file mode 100644 index 0000000000..ca036a15e8 --- /dev/null +++ b/src/subdomains/core/sell-crypto/process/__tests__/buy-fiat-history.projection.spec.ts @@ -0,0 +1,167 @@ +import { Blockchain } from 'src/integration/blockchain/shared/enums/blockchain.enum'; +import { Asset } from 'src/shared/models/asset/asset.entity'; +import { Fiat } from 'src/shared/models/fiat/fiat.entity'; +import { + BUY_FIAT_HISTORY_PROJECTION, + BUY_FIAT_HISTORY_RESPONSE_FIELDS, + BuyFiatRepository, +} from 'src/subdomains/core/sell-crypto/process/buy-fiat.repository'; +import { BuyFiat } from 'src/subdomains/core/sell-crypto/process/buy-fiat.entity'; +import { BuyFiatHistoryMapper } from 'src/subdomains/core/sell-crypto/process/dto/buy-fiat-history.mapper'; +import { BankData } from 'src/subdomains/generic/user/models/bank-data/bank-data.entity'; +import { Sell } from 'src/subdomains/core/sell-crypto/route/sell.entity'; +import { User } from 'src/subdomains/generic/user/models/user/user.entity'; +import { CryptoInput } from 'src/subdomains/supporting/payin/entities/crypto-input.entity'; +import { FiatOutput } from 'src/subdomains/supporting/fiat-output/fiat-output.entity'; +import { + createProjectionDataSource, + describeProjection, + destroyProjectionDataSource, + expectEveryFieldRequired, + expectNoEmptyFields, + projectionFieldsWithout, + seedEntity, +} from 'src/shared/utils/projection-test.util'; +import { DataSource } from 'typeorm'; + +const SCHEMA = 'buy_fiat_history_projection_spec'; + +/** + * `GET /sell/:id/history` — the four levels from `docs/read-path-projections.md`. + * + * The endpoint answers a `SellHistoryDto[]`. Reading it without a projection loads whole `BuyFiat` + * rows for the nine values the mapper reads. + */ +describeProjection('GET /sell/:id/history — read-path projection', () => { + let dataSource: DataSource; + let repository: BuyFiatRepository; + + beforeAll(async () => { + dataSource = await createProjectionDataSource(SCHEMA); + repository = new BuyFiatRepository(dataSource.manager); + }, 300000); + + afterAll(async () => { + await destroyProjectionDataSource(dataSource, SCHEMA); + }); + + /** + * A paid-out transaction on a sell route, with every column populated. + * + * `blockchain` is set explicitly: it is a TypeScript enum in a text column, and the explorer link + * the mapper builds resolves to `undefined` for anything that is not a member. + */ + async function seedBuyFiat(withFiatOutput = true): Promise<{ buyFiat: BuyFiat; user: User; sell: Sell }> { + const user = await seedEntity(dataSource, User); + // An active sell route must carry a bankData — a check constraint on `deposit_route` enforces + // it, and a route with transactions on it is active by definition. + const bankData = await seedEntity(dataSource, BankData); + const sell = await seedEntity(dataSource, Sell, { values: { user, bankData } }); + const asset = await seedEntity(dataSource, Asset, { values: { blockchain: Blockchain.ETHEREUM } }); + const cryptoInput = await seedEntity(dataSource, CryptoInput, { values: { asset } }); + const fiatOutput = withFiatOutput ? await seedEntity(dataSource, FiatOutput) : null; + // The fixture sets `outputAsset` because the mapper dereferences it without a guard, although + // the column is nullable. What happens without one is the mapper's behaviour, not the + // projection's, and this file does not change either. + const outputAsset = await seedEntity(dataSource, Fiat); + const buyFiat = await seedEntity(dataSource, BuyFiat, { + values: { sell, cryptoInput, fiatOutput, outputAsset }, + }); + return { buyFiat, user, sell }; + } + + /** A second sell route of the same user, with one transaction on it. */ + async function seedSecondRouteFor(user: User): Promise<{ buyFiat: BuyFiat; sell: Sell }> { + const bankData = await seedEntity(dataSource, BankData); + const sell = await seedEntity(dataSource, Sell, { values: { user, bankData } }); + const asset = await seedEntity(dataSource, Asset, { values: { blockchain: Blockchain.ETHEREUM } }); + const cryptoInput = await seedEntity(dataSource, CryptoInput, { values: { asset } }); + const fiatOutput = await seedEntity(dataSource, FiatOutput); + const outputAsset = await seedEntity(dataSource, Fiat); + const buyFiat = await seedEntity(dataSource, BuyFiat, { + values: { sell, cryptoInput, fiatOutput, outputAsset }, + }); + return { buyFiat, sell }; + } + + // --- LEVEL 1: completeness --- // + + it('level 1 — the sell history answers with no empty field', async () => { + const { user, sell } = await seedBuyFiat(); + + const history = (await repository.findSellHistory(user.id, sell.id)).map(BuyFiatHistoryMapper.toDto); + + expect(history).toHaveLength(1); + expectNoEmptyFields(history); + }, 120000); + + // --- LEVEL 2: variants --- // + + it('level 2 — a transaction not yet paid out answers without a date', async () => { + const { user, sell } = await seedBuyFiat(false); + + const history = (await repository.findSellHistory(user.id, sell.id)).map(BuyFiatHistoryMapper.toDto); + + // `fiatOutput` is the nullable one of the four joins. The date is the only field it feeds, so + // everything else must still be complete — otherwise a left join was written as an inner one. + expect(history).toHaveLength(1); + expect(history[0].date).toBeUndefined(); + expectNoEmptyFields(history, ['[0].date']); + }, 120000); + + it('level 2 — both the user and the route filter are needed to select the right transactions', async () => { + // Two fixtures with a user and a route each are not enough: asking with one user's id and that + // user's route id, either predicate on its own still returns exactly the expected row. The + // second route below makes the route id necessary, and the foreign route makes the user id + // necessary — dropping either one then changes the answer. + const mine = await seedBuyFiat(); + const second = await seedSecondRouteFor(mine.user); + const other = await seedBuyFiat(); + + const onOneRoute = (await repository.findSellHistory(mine.user.id, mine.sell.id)).map(BuyFiatHistoryMapper.toDto); + expect(onOneRoute.map((row) => row.inputAmount)).toEqual([mine.buyFiat.inputAmount]); + expect(onOneRoute.map((row) => row.inputAmount)).not.toContain(second.buyFiat.inputAmount); + + // Without a route the caller gets every route they own, and still nothing of anyone else's. + const allOfMine = (await repository.findSellHistory(mine.user.id)).map(BuyFiatHistoryMapper.toDto); + expect(allOfMine.map((row) => row.inputAmount).sort()).toEqual( + [mine.buyFiat.inputAmount, second.buyFiat.inputAmount].sort(), + ); + + // A foreign route asked for with this caller's id resolves to nothing: the user predicate is + // what refuses it. + expect(await repository.findSellHistory(mine.user.id, other.sell.id)).toHaveLength(0); + }, 120000); + + // --- LEVEL 3: mutation --- // + + it('level 3 — every field feeding the response is required', async () => { + const { user, sell } = await seedBuyFiat(); + + await expectEveryFieldRequired(BUY_FIAT_HISTORY_RESPONSE_FIELDS, (omitted) => + repository + .findSellHistory(user.id, sell.id, projectionFieldsWithout(BUY_FIAT_HISTORY_PROJECTION.fields, omitted)) + .then((rows) => rows.map(BuyFiatHistoryMapper.toDto)), + ); + }, 300000); + + // --- LEVEL 4: consistency against a second source --- // + + it.each([true, false])( + 'level 4 — with fiatOutput=%s the projected response equals the one from a full load', + async (withFiatOutput) => { + const { user, sell } = await seedBuyFiat(withFiatOutput); + + const projected = (await repository.findSellHistory(user.id, sell.id)).map(BuyFiatHistoryMapper.toDto); + // The unprojected load is the second source: it fetches every column, so what it produces + // depends on no field list at all. + const full = await dataSource.getRepository(BuyFiat).find({ + where: { sell: { id: sell.id, user: { id: user.id } } }, + relations: { sell: { user: true }, cryptoInput: true, fiatOutput: true }, + }); + + expect(projected).toEqual(full.map(BuyFiatHistoryMapper.toDto)); + }, + 120000, + ); +}); diff --git a/src/subdomains/core/sell-crypto/process/__tests__/buy-fiat.service.spec.ts b/src/subdomains/core/sell-crypto/process/__tests__/buy-fiat.service.spec.ts index b34ec1b0c4..a85708001f 100644 --- a/src/subdomains/core/sell-crypto/process/__tests__/buy-fiat.service.spec.ts +++ b/src/subdomains/core/sell-crypto/process/__tests__/buy-fiat.service.spec.ts @@ -181,7 +181,8 @@ describe('BuyFiatService', () => { ]; } - jest.spyOn(buyFiatRepo, 'find').mockResolvedValue(wantedData); + // The history goes through the projected query. + jest.spyOn(buyFiatRepo, 'findSellHistory').mockResolvedValue(wantedData); } } diff --git a/src/subdomains/core/sell-crypto/process/buy-fiat.repository.ts b/src/subdomains/core/sell-crypto/process/buy-fiat.repository.ts index 61f9bcdce2..06f0a997ab 100644 --- a/src/subdomains/core/sell-crypto/process/buy-fiat.repository.ts +++ b/src/subdomains/core/sell-crypto/process/buy-fiat.repository.ts @@ -1,11 +1,64 @@ import { Injectable } from '@nestjs/common'; +import { ReadProjection } from 'src/shared/models/read-projection'; import { BaseRepository } from 'src/shared/repositories/base.repository'; import { EntityManager } from 'typeorm'; import { BuyFiat } from './buy-fiat.entity'; +/** The nine values `BuyFiatHistoryMapper.toDto` reads. */ +export const BUY_FIAT_HISTORY_RESPONSE_FIELDS = [ + 'buyFiat.inputAmount', + 'buyFiat.inputAsset', + 'buyFiat.outputAmount', + 'buyFiat.amlCheck', + 'buyFiat.isComplete', + 'outputAsset.name', + 'cryptoInput.inTxId', + 'cryptoInputAsset.blockchain', + 'fiatOutput.outputDate', +]; + +/** + * `GET /sell/:id/history` — filtered by the sell route and its user. + * + * Without it the query loads whole `BuyFiat` rows for these nine values. + */ +export const BUY_FIAT_HISTORY_PROJECTION = new ReadProjection( + 'buyFiat', + [ + ['buyFiat.outputAsset', 'outputAsset'], + ['buyFiat.cryptoInput', 'cryptoInput'], + ['cryptoInput.asset', 'cryptoInputAsset'], + ['buyFiat.fiatOutput', 'fiatOutput'], + ['buyFiat.sell', 'sell'], + ['sell.user', 'sellUser'], + ], + BUY_FIAT_HISTORY_RESPONSE_FIELDS, + // Never part of the response: the primary keys that make the ORM materialise the joined rows. + ['buyFiat.id', 'outputAsset.id', 'cryptoInput.id', 'cryptoInputAsset.id', 'fiatOutput.id'], +); + @Injectable() export class BuyFiatRepository extends BaseRepository { constructor(manager: EntityManager) { super(BuyFiat, manager); } + + /** + * Transactions on a user's sell route, loaded with the history fields only. + * + * `fields` is what the mutation test in `buy-fiat-history.projection.spec.ts` re-runs the query + * with; `BuyFiatService.getSellHistory` calls this without it. + */ + async findSellHistory( + userId: number, + routeId?: number, + fields: ReadonlyArray = BUY_FIAT_HISTORY_PROJECTION.fields, + ): Promise { + const query = BUY_FIAT_HISTORY_PROJECTION.apply(this.createQueryBuilder('buyFiat'), fields).where( + 'sellUser.id = :userId', + { userId }, + ); + if (routeId != null) query.andWhere('sell.id = :routeId', { routeId }); + return query.getMany(); + } } diff --git a/src/subdomains/core/sell-crypto/process/dto/buy-fiat-history.mapper.ts b/src/subdomains/core/sell-crypto/process/dto/buy-fiat-history.mapper.ts new file mode 100644 index 0000000000..db91992903 --- /dev/null +++ b/src/subdomains/core/sell-crypto/process/dto/buy-fiat-history.mapper.ts @@ -0,0 +1,31 @@ +import { txExplorerUrl } from 'src/integration/blockchain/shared/util/blockchain.util'; +import { PaymentStatus } from 'src/subdomains/core/history/dto/history.dto'; +import { SellHistoryDto } from 'src/subdomains/core/sell-crypto/route/dto/sell-history.dto'; +import { BuyFiat } from 'src/subdomains/core/sell-crypto/process/buy-fiat.entity'; + +/** + * The history entry `GET /sell/:id/history` answers with. + * + * Its own mapper so that the projection spec can drive the same mapping the endpoint + * uses — see `BuyCryptoHistoryMapper` for the reasoning. + * + * Note that `cryptoInput` and `outputAsset` are read without a guard here: `cryptoInput` is a + * non-nullable relation, and a row whose `outputAsset` is unset throws — which the projection + * leaves as it is. + */ +export class BuyFiatHistoryMapper { + static toDto(buyFiat: BuyFiat): SellHistoryDto { + return { + inputAmount: buyFiat.inputAmount, + inputAsset: buyFiat.inputAsset, + outputAmount: buyFiat.outputAmount, + outputAsset: buyFiat.outputAsset.name, + txId: buyFiat.cryptoInput.inTxId, + txUrl: txExplorerUrl(buyFiat.cryptoInput.asset.blockchain, buyFiat.cryptoInput.inTxId), + date: buyFiat.fiatOutput?.outputDate, + amlCheck: buyFiat.amlCheck, + isComplete: buyFiat.isComplete, + status: buyFiat.isComplete ? PaymentStatus.COMPLETE : PaymentStatus.PENDING, + }; + } +} diff --git a/src/subdomains/core/sell-crypto/process/services/buy-fiat.service.ts b/src/subdomains/core/sell-crypto/process/services/buy-fiat.service.ts index ca453e3f0f..c2af19b86e 100644 --- a/src/subdomains/core/sell-crypto/process/services/buy-fiat.service.ts +++ b/src/subdomains/core/sell-crypto/process/services/buy-fiat.service.ts @@ -1,5 +1,4 @@ import { BadRequestException, forwardRef, Inject, Injectable, NotFoundException, OnModuleInit } from '@nestjs/common'; -import { txExplorerUrl } from 'src/integration/blockchain/shared/util/blockchain.util'; import { toScorechainBlockchain } from 'src/integration/scorechain/dto/scorechain.dto'; import { ScorechainScreening } from 'src/integration/scorechain/entities/scorechain-screening.entity'; import { ScorechainScreeningService } from 'src/integration/scorechain/services/scorechain-screening.service'; @@ -46,7 +45,6 @@ import { AmlReason, PhoneAmlReasons } from '../../../aml/enums/aml-reason.enum'; import { CheckStatus } from '../../../aml/enums/check-status.enum'; import { TransactionAmlCheckService } from '../../../aml/services/transaction-aml-check.service'; import { BuyCryptoService } from '../../../buy-crypto/process/services/buy-crypto.service'; -import { PaymentStatus } from '../../../history/dto/history.dto'; import { CryptoInputRefund, RefundInternalDto } from '../../../history/dto/refund-internal.dto'; import { TransactionDetailsDto } from '../../../statistic/dto/statistic.dto'; import { SellHistoryDto } from '../../route/dto/sell-history.dto'; @@ -55,6 +53,7 @@ import { SellRepository } from '../../route/sell.repository'; import { SellService } from '../../route/sell.service'; import { BuyFiat, BuyFiatEditableAmlCheck } from '../buy-fiat.entity'; import { BuyFiatRepository } from '../buy-fiat.repository'; +import { BuyFiatHistoryMapper } from 'src/subdomains/core/sell-crypto/process/dto/buy-fiat-history.mapper'; import { UpdateBuyFiatDto } from '../dto/update-buy-fiat.dto'; import { BuyFiatNotificationService } from './buy-fiat-notification.service'; @@ -631,14 +630,9 @@ export class BuyFiatService implements OnModuleInit { } async getSellHistory(userId: number, sellId?: number): Promise { - const where = Util.removeNullFields({ user: { id: userId }, id: sellId }); - return this.buyFiatRepo - .find({ - where: { sell: where }, - relations: { sell: { user: true }, cryptoInput: true, fiatOutput: true }, - }) - .then((buyFiats) => buyFiats.map(this.toHistoryDto)); + .findSellHistory(userId, sellId) + .then((buyFiats) => buyFiats.map(BuyFiatHistoryMapper.toDto)); } async getPendingTransactions(): Promise { @@ -665,21 +659,6 @@ export class BuyFiatService implements OnModuleInit { return request; } - private toHistoryDto(buyFiat: BuyFiat): SellHistoryDto { - return { - inputAmount: buyFiat.inputAmount, - inputAsset: buyFiat.inputAsset, - outputAmount: buyFiat.outputAmount, - outputAsset: buyFiat.outputAsset.name, - txId: buyFiat.cryptoInput.inTxId, - txUrl: txExplorerUrl(buyFiat.cryptoInput.asset.blockchain, buyFiat.cryptoInput.inTxId), - date: buyFiat.fiatOutput?.outputDate, - amlCheck: buyFiat.amlCheck, - isComplete: buyFiat.isComplete, - status: buyFiat.isComplete ? PaymentStatus.COMPLETE : PaymentStatus.PENDING, - }; - } - private async getSell(sellId: number): Promise { // sell const sell = await this.sellRepo.findOne({ where: { id: sellId }, relations: { user: { wallet: true } } }); diff --git a/src/subdomains/core/staking/entities/crypto-staking.entity.ts b/src/subdomains/core/staking/entities/crypto-staking.entity.ts index aa9517ea6f..0b7baf23cb 100644 --- a/src/subdomains/core/staking/entities/crypto-staking.entity.ts +++ b/src/subdomains/core/staking/entities/crypto-staking.entity.ts @@ -57,7 +57,7 @@ export class CryptoStaking extends IEntity { payoutType: PayoutType; @Index() - @ManyToOne(() => Deposit, { eager: true, nullable: true }) + @ManyToOne(() => Deposit, { nullable: true }) paybackDeposit?: Deposit; @OneToOne(() => CryptoInput, { nullable: false }) diff --git a/src/subdomains/generic/user/models/kyc/__tests__/kyc-data.projection.spec.ts b/src/subdomains/generic/user/models/kyc/__tests__/kyc-data.projection.spec.ts new file mode 100644 index 0000000000..314470998d --- /dev/null +++ b/src/subdomains/generic/user/models/kyc/__tests__/kyc-data.projection.spec.ts @@ -0,0 +1,201 @@ +import { KycDataDtoMapper } from 'src/subdomains/generic/user/models/kyc/dto/kyc-data-dto.mapper'; +import { KycStatus, KycType } from 'src/subdomains/generic/user/models/user-data/user-data.enum'; +import { UserData } from 'src/subdomains/generic/user/models/user-data/user-data.entity'; +import { + USER_KYC_FILES_PROJECTION, + USER_KYC_FILES_RESPONSE_FIELDS, + UserRepository, +} from 'src/subdomains/generic/user/models/user/user.repository'; +import { User } from 'src/subdomains/generic/user/models/user/user.entity'; +import { + WALLET_KYC_DATA_PROJECTION, + WALLET_KYC_DATA_RESPONSE_FIELDS, + WalletRepository, +} from 'src/subdomains/generic/user/models/wallet/wallet.repository'; +import { Wallet } from 'src/subdomains/generic/user/models/wallet/wallet.entity'; +import { + createProjectionDataSource, + describeProjection, + destroyProjectionDataSource, + expectEveryFieldRequired, + expectNoEmptyFields, + projectionFieldsWithout, + seedEntity, +} from 'src/shared/utils/projection-test.util'; +import { DataSource } from 'typeorm'; + +const SCHEMA = 'kyc_data_projection_spec'; + +/** + * `GET /kyc/users` and `GET /kyc/:id/documents` — the four levels from + * `docs/read-path-projections.md`. + * + * Both loaded a whole row graph for very little: the first for an address, two + * status fields and a hash per user, the second for nothing but the account id the document store + * is keyed by. + */ +describeProjection('kyc data — read-path projection', () => { + let dataSource: DataSource; + let wallets: WalletRepository; + let users: UserRepository; + + beforeAll(async () => { + dataSource = await createProjectionDataSource(SCHEMA); + wallets = new WalletRepository(dataSource.manager); + users = new UserRepository(dataSource.manager); + }, 300000); + + afterAll(async () => { + await destroyProjectionDataSource(dataSource, SCHEMA); + }); + + /** + * A wallet with one user on it. + * + * `kycStatus` and `kycType` are set explicitly: both are TypeScript enums in text columns, and + * `getKycWebhookStatus` maps them by value — a generated string lands in the fallback branch and + * would make the response look complete for the wrong reason. + */ + async function seedWalletUser( + kycStatus = KycStatus.COMPLETED, + kycType = KycType.DFX, + ): Promise<{ wallet: Wallet; user: User; userData: UserData }> { + const wallet = await seedEntity(dataSource, Wallet); + const userData = await seedEntity(dataSource, UserData, { values: { kycStatus, kycType } }); + const user = await seedEntity(dataSource, User, { values: { wallet, userData } }); + return { wallet, user, userData }; + } + + // --- LEVEL 1: completeness --- // + + it('level 1 — the kyc user list answers with no empty field', async () => { + const { wallet } = await seedWalletUser(); + + const loaded = await wallets.findKycData(wallet.id); + + expect(loaded.users).toHaveLength(1); + expectNoEmptyFields(loaded.users.map(KycDataDtoMapper.toDto)); + }, 120000); + + it('level 1 — the document lookup loads the account id', async () => { + const { user, userData } = await seedWalletUser(); + + const loaded = await users.findAccountIdForAddress(user.address, user.wallet.id); + + expect(loaded?.userData?.id).toEqual(userData.id); + }, 120000); + + // --- LEVEL 2: variants --- // + + it.each([ + [KycStatus.COMPLETED, KycType.LOCK], + [KycStatus.REJECTED, KycType.DFX], + [KycStatus.NA, KycType.DFX], + ])( + 'level 2 — kycStatus %s with kycType %s answers with no empty field', + async (kycStatus, kycType) => { + const { wallet } = await seedWalletUser(kycStatus, kycType); + + const loaded = await wallets.findKycData(wallet.id); + + // `getKycWebhookStatus` branches on both values, and the LOCK/DFX split only shows on a + // completed account. A fixture covering one combination says nothing about the others. + expectNoEmptyFields(loaded.users.map(KycDataDtoMapper.toDto)); + }, + 120000, + ); + + it('level 2 — a wallet answers only with its own users', async () => { + const mine = await seedWalletUser(); + const other = await seedWalletUser(); + + const loaded = await wallets.findKycData(mine.wallet.id); + + expect(loaded.users.map((u) => u.address)).toEqual([mine.user.address]); + expect(loaded.users.map((u) => u.address)).not.toContain(other.user.address); + }, 120000); + + it('level 2 — the document lookup is scoped to the wallet', async () => { + const { user } = await seedWalletUser(); + const foreign = await seedWalletUser(); + + // The address alone must not resolve: it is scoped to the wallet the caller authenticated with. + expect(await users.findAccountIdForAddress(user.address, foreign.wallet.id)).toBeNull(); + }, 120000); + + // --- LEVEL 3: mutation --- // + + it.each([KycType.LOCK, KycType.DFX])( + 'level 3 — with kycType %s every field feeding the kyc user list is required', + async (kycType) => { + const { wallet } = await seedWalletUser(KycStatus.COMPLETED, kycType); + // `kycType` only changes the answer on a completed account, and only for LOCK: the DFX branch + // produces the same value the absent field would. One fixture therefore cannot show that the + // column is needed — which is what the row above is for. + const candidates = + kycType === KycType.LOCK + ? WALLET_KYC_DATA_RESPONSE_FIELDS + : WALLET_KYC_DATA_RESPONSE_FIELDS.filter((field) => field !== 'walletUserData.kycType'); + + await expectEveryFieldRequired(candidates, (omitted) => + wallets + .findKycData(wallet.id, projectionFieldsWithout(WALLET_KYC_DATA_PROJECTION.fields, omitted)) + .then((loaded) => loaded.users.map(KycDataDtoMapper.toDto)), + ); + }, + 300000, + ); + + it('level 3 — the account id is required for the document lookup', async () => { + const { user } = await seedWalletUser(); + + await expectEveryFieldRequired(USER_KYC_FILES_RESPONSE_FIELDS, (omitted) => + users + .findAccountIdForAddress( + user.address, + user.wallet.id, + projectionFieldsWithout(USER_KYC_FILES_PROJECTION.fields, omitted), + ) + .then((loaded) => ({ accountId: loaded?.userData?.id })), + ); + }, 300000); + + // --- LEVEL 4: consistency against a second source --- // + + it('level 4 — the document lookup resolves the same account id as a full load', async () => { + // `KycService.getKycFiles` keys the document store by this id and shows nothing else off the + // row, so the id is the entire contribution of this projection to that response. + const { user, userData } = await seedWalletUser(); + + const projected = await users.findAccountIdForAddress(user.address, user.wallet.id); + // The unprojected load is the second source: a find without a field list. + const full = await dataSource.getRepository(User).findOne({ + where: { address: user.address, wallet: { id: user.wallet.id } }, + relations: { userData: true }, + }); + + expect(projected.userData.id).toEqual(full.userData.id); + expect(projected.userData.id).toEqual(userData.id); + }, 120000); + + it.each([ + [KycStatus.COMPLETED, KycType.DFX], + [KycStatus.COMPLETED, KycType.LOCK], + [KycStatus.REJECTED, KycType.DFX], + ])( + 'level 4 — for %s / %s the projected response equals the one from a full load', + async (kycStatus, kycType) => { + const { wallet } = await seedWalletUser(kycStatus, kycType); + + const projected = (await wallets.findKycData(wallet.id)).users.map(KycDataDtoMapper.toDto); + // The unprojected load is the second source: the same relations selected whole. + const full = await dataSource.getRepository(Wallet).findOne({ + where: { id: wallet.id }, + relations: { users: { userData: true } }, + }); + + expect(projected).toEqual(full.users.map(KycDataDtoMapper.toDto)); + }, + 120000, + ); +}); diff --git a/src/subdomains/generic/user/models/kyc/dto/kyc-data-dto.mapper.ts b/src/subdomains/generic/user/models/kyc/dto/kyc-data-dto.mapper.ts new file mode 100644 index 0000000000..fc6795cc86 --- /dev/null +++ b/src/subdomains/generic/user/models/kyc/dto/kyc-data-dto.mapper.ts @@ -0,0 +1,19 @@ +import { getKycWebhookStatus } from 'src/subdomains/generic/user/services/webhook/mapper/webhook-data.mapper'; +import { User } from 'src/subdomains/generic/user/models/user/user.entity'; +import { KycDataDto } from 'src/subdomains/generic/user/models/kyc/dto/kyc-data.dto'; + +/** + * The per-user entry `GET /kyc/users` answers with. + * + * Its own mapper so the projection spec can drive the same mapping the endpoint uses; a + * copy in the spec could be wrong in exactly the way the projection is wrong. + */ +export class KycDataDtoMapper { + static toDto(user: User): KycDataDto { + return { + id: user.address, + kycStatus: getKycWebhookStatus(user.userData.kycStatus, user.userData.kycType), + kycHash: user.userData.kycHash, + }; + } +} diff --git a/src/subdomains/generic/user/models/kyc/kyc.service.ts b/src/subdomains/generic/user/models/kyc/kyc.service.ts index b58256b938..3ad19825a9 100644 --- a/src/subdomains/generic/user/models/kyc/kyc.service.ts +++ b/src/subdomains/generic/user/models/kyc/kyc.service.ts @@ -17,15 +17,14 @@ import { ContentType } from 'src/subdomains/generic/kyc/enums/content-type.enum' import { FileCategory } from 'src/subdomains/generic/kyc/enums/file-category.enum'; import { KycDocumentService } from 'src/subdomains/generic/kyc/services/integration/kyc-document.service'; import { Blank, UserData } from 'src/subdomains/generic/user/models/user-data/user-data.entity'; -import { getKycWebhookStatus } from '../../services/webhook/mapper/webhook-data.mapper'; import { BlankType, KycLevel, KycState } from '../user-data/user-data.enum'; import { UserDataRepository } from '../user-data/user-data.repository'; import { UserDataService } from '../user-data/user-data.service'; -import { User } from '../user/user.entity'; import { UserRepository } from '../user/user.repository'; import { WalletRepository } from '../wallet/wallet.repository'; import { KycDataTransferDto } from './dto/kyc-data-transfer.dto'; import { KycDataDto } from './dto/kyc-data.dto'; +import { KycDataDtoMapper } from 'src/subdomains/generic/user/models/kyc/dto/kyc-data-dto.mapper'; import { KycDocumentType, KycFileDto } from './dto/kyc-file.dto'; import { KycInfo } from './dto/kyc-info.dto'; @@ -135,19 +134,13 @@ export class KycService { // --- GET COMPANY KYC --- // async getAllKycData(walletId: number): Promise { - const wallet = await this.walletRepo.findOne({ - where: { id: walletId }, - relations: { users: { userData: true } }, - }); + const wallet = await this.walletRepo.findKycData(walletId); - return wallet.users.map((b) => this.toKycDataDto(b)); + return wallet.users.map(KycDataDtoMapper.toDto); } async getKycFiles(userAddress: string, walletId: number): Promise { - const user = await this.userRepo.findOne({ - where: { address: userAddress, wallet: { id: walletId } }, - relations: { userData: true, wallet: true }, - }); + const user = await this.userRepo.findAccountIdForAddress(userAddress, walletId); if (!user) throw new NotFoundException('User not found'); const allDocuments = await this.documentService.listUserFiles(user.userData.id); @@ -175,14 +168,6 @@ export class KycService { } // --- HELPER METHODS --- // - private toKycDataDto(user: User): KycDataDto { - return { - id: user.address, - kycStatus: getKycWebhookStatus(user.userData.kycStatus, user.userData.kycType), - kycHash: user.userData.kycHash, - }; - } - private toKycFileDto(type: KycDocumentType, { contentType }: KycFileBlob): KycFileDto { return { type, contentType }; } diff --git a/src/subdomains/generic/user/models/user-data/__tests__/api-key.projection.spec.ts b/src/subdomains/generic/user/models/user-data/__tests__/api-key.projection.spec.ts new file mode 100644 index 0000000000..7e7440edbf --- /dev/null +++ b/src/subdomains/generic/user/models/user-data/__tests__/api-key.projection.spec.ts @@ -0,0 +1,241 @@ +import { ConfigService } from 'src/config/config'; +import { ApiKeyService } from 'src/shared/services/api-key.service'; +import { HistoryFilter } from 'src/subdomains/core/history/dto/history-filter.dto'; +import { ApiKeyDto } from 'src/subdomains/generic/user/models/user/dto/api-key.dto'; +import { UserDataService } from 'src/subdomains/generic/user/models/user-data/user-data.service'; +import { + createProjectionDataSource, + describeProjection, + destroyProjectionDataSource, + expectEveryFieldRequired, + expectNoEmptyFields, + projectionFieldsWithout, + seedEntity, +} from 'src/shared/utils/projection-test.util'; +import { + API_KEY_PROJECTION, + UserDataRepository, +} from 'src/subdomains/generic/user/models/user-data/user-data.repository'; +import { UserData } from 'src/subdomains/generic/user/models/user-data/user-data.entity'; +import { DataSource } from 'typeorm'; + +const SCHEMA = 'api_key_projection_spec'; + +/** + * `POST /user/apiKey/CT` — the four levels from `docs/read-path-projections.md`. + * + * The endpoint read a whole `UserData` row, eager joins included, to check + * whether a key exists and to derive a new one from the account id and its creation date. + * + * It writes as well as reads, but through `update(id, …)` rather than by saving the row it read, so + * a projected read cannot blank a column it did not load. + */ +describeProjection('API key — read-path projection', () => { + let dataSource: DataSource; + let userDataRepo: UserDataRepository; + + beforeAll(async () => { + // `createKey` reads the key version off the module-level Config. + new ConfigService(); + dataSource = await createProjectionDataSource(SCHEMA); + userDataRepo = new UserDataRepository(dataSource.manager); + }, 300000); + + afterAll(async () => { + await destroyProjectionDataSource(dataSource, SCHEMA); + }); + + const seedAccount = (values: Partial = {}): Promise => + seedEntity(dataSource, UserData, { values }); + + /** + * What the endpoint answers, through the projected query. + * + * `created` is part of the read because `getSecret` hashes it together with the key. + * + * The key is pinned rather than generated: the production one mixes in the current time, so every + * field would come out of level 3 as required, which is evidence about nothing. + */ + async function apiKeyOf( + id: number, + fields = API_KEY_PROJECTION.fields, + ): Promise<{ conflict: true } | { key: string; secret: string }> { + const userData = await userDataRepo.getForApiKey(id, fields); + if (userData.apiKeyCT) return { conflict: true as const }; + + userData.apiKeyCT = `KEY-FOR-ACCOUNT-${userData.id}`; + + return { key: userData.apiKeyCT, secret: ApiKeyService.getSecret(userData) }; + } + + // --- LEVEL 1: completeness --- // + + it('level 1 — a fresh account answers with a key and a secret', async () => { + const userData = await seedAccount({ apiKeyCT: null }); + + expectNoEmptyFields(await apiKeyOf(userData.id)); + }, 120000); + + // --- LEVEL 2: variants --- // + + it('level 2 — an account that already has a key is refused', async () => { + // The existing key is the whole conflict check, so a projection that dropped the column would + // hand out a second key and overwrite the first. + const userData = await seedAccount({ apiKeyCT: 'existing-key-refused' }); + + expect(await apiKeyOf(userData.id)).toEqual({ conflict: true }); + }, 120000); + + it('level 2 — two accounts get different keys and different secrets', async () => { + const first = await apiKeyOf((await seedAccount({ apiKeyCT: null })).id); + const second = await apiKeyOf((await seedAccount({ apiKeyCT: null })).id); + + expect(first).not.toEqual(second); + }, 120000); + + it('level 2 — an unknown id resolves to nothing, so the endpoint can refuse', async () => { + const userData = await seedAccount({ apiKeyCT: null }); + + expect(await userDataRepo.getForApiKey(userData.id + 1_000_000)).toBeNull(); + }, 120000); + + // --- LEVEL 3: mutation --- // + + it('level 3 — the account id and the creation date are required', async () => { + const userData = await seedAccount({ apiKeyCT: null }); + + // The id feeds the key, the creation date the secret derived from it. + await expectEveryFieldRequired(['userData.id', 'userData.created'], (omitted) => + apiKeyOf(userData.id, projectionFieldsWithout(API_KEY_PROJECTION.fields, omitted)), + ); + }, 300000); + + it('level 3 — the existing key is required to refuse a second one', async () => { + // On an account without a key, dropping the column produces the same answer as reading it — the + // conflict branch is only reachable where a key is actually stored. + const userData = await seedAccount({ apiKeyCT: 'existing-key-required' }); + + await expectEveryFieldRequired(['userData.apiKeyCT'], (omitted) => + apiKeyOf(userData.id, projectionFieldsWithout(API_KEY_PROJECTION.fields, omitted)), + ); + }, 300000); + + // --- the claim that makes this endpoint convertible at all --- // + + it('writing after a projected read leaves every column the query did not load untouched', async () => { + // This is the whole reason a write endpoint can be converted: the update names its columns, so + // it cannot blank the ones the projection left out. Saving the loaded row back would. + const account = await seedAccount({ apiKeyCT: null }); + // Read at the storage level, so the comparison covers every column of the table rather than the + // ones some load happens to materialise. + const rowOf = async (): Promise> => + (await dataSource.query(`SELECT * FROM "${SCHEMA}"."user_data" WHERE id = $1`, [account.id]))[0]; + const before = await rowOf(); + + // Through the production method rather than a write of the spec's own: a test that issues the + // update itself proves the update is safe, not that the endpoint uses it. + const answer = await createApiKey(account.id, { buy: true }); + + const after = await rowOf(); + expect(after.apiKeyCT).toEqual(answer.key); + expect(after.apiFilterCT).toEqual(ApiKeyService.getFilterCode({ buy: true })); + // Every other column - including the ones the projection never selected - has to be what it + // was. `updated` is excluded because the write is what moves it. + const ignored = ['apiKeyCT', 'apiFilterCT', 'updated']; + const comparable = (row: Record): Record => + Object.fromEntries(Object.entries(row).filter(([column]) => !ignored.includes(column))); + + expect(comparable(after)).toEqual(comparable(before)); + expect(Object.keys(before).length).toBeGreaterThan(50); + }, 120000); + + it('runs the production key derivation over the projected row', async () => { + // The levels above pin the key so that responses are comparable across runs. That leaves the + // real derivation unexercised, and it is what reads the projected id and creation date. + const account = await seedAccount({ apiKeyCT: null }); + + const loaded = await userDataRepo.getForApiKey(account.id); + loaded.apiKeyCT = ApiKeyService.createKey(loaded.id); + + expect(loaded.apiKeyCT).toMatch(/^[0-9A-F]+$/); + expect(ApiKeyService.getSecret(loaded)).toMatch(/^[0-9A-F]{64}$/); + // The same account and the same key must produce the same secret through an independently + // loaded row: comparing the projected row with itself would hold whatever the projection left + // out. The creation date is the second input, and it comes out of the projection. + const full = await dataSource.getRepository(UserData).findOneBy({ id: account.id }); + full.apiKeyCT = loaded.apiKeyCT; + + expect(ApiKeyService.getSecret(loaded)).toEqual(ApiKeyService.getSecret(full)); + }, 120000); + + it('derives the secret from the creation date as well as the key', async () => { + // Two accounts inserted in the same millisecond share a creation date, so the secret is not + // unique per account by construction. What has to hold is that the date is an input at all — a + // projection that dropped it would answer the same secret for every date. + const account = await seedAccount({ apiKeyCT: null }); + + const loaded = await userDataRepo.getForApiKey(account.id); + loaded.apiKeyCT = 'SHARED-KEY'; + // Built from the two columns `getSecret` reads rather than spread from the row: a spread touches + // every property, including ones this query had no reason to select. + const withOtherDate = { + apiKeyCT: loaded.apiKeyCT, + created: new Date('2001-02-03T04:05:06.000Z'), + } as typeof loaded; + + expect(ApiKeyService.getSecret(loaded)).not.toEqual(ApiKeyService.getSecret(withOtherDate)); + }, 120000); + + // --- the production path, end to end --- // + + /** + * `UserDataService.createApiKey`, bound to the real repository. + * + * The service takes twenty-seven collaborators and this method uses exactly one of them, so it is + * called on a minimal receiver rather than through a constructed service — what matters is that + * the production method runs against the projected read and its own write. + */ + const createApiKey = (userDataId: number, filter: HistoryFilter): Promise => + UserDataService.prototype.createApiKey.call({ userDataRepo }, userDataId, filter); + + it('issues a key through the service and persists both columns', async () => { + const account = await seedAccount({ apiKeyCT: null, apiFilterCT: null }); + + const answer = await createApiKey(account.id, { buy: true }); + + const stored = await dataSource.getRepository(UserData).findOneBy({ id: account.id }); + expect(stored.apiKeyCT).toEqual(answer.key); + expect(stored.apiFilterCT).toEqual(ApiKeyService.getFilterCode({ buy: true })); + // The secret is derived rather than stored, from the key and the creation date the projection + // supplies. + expect(answer.secret).toEqual(ApiKeyService.getSecret(stored)); + }, 120000); + + it('refuses a second key through the service, and writes nothing', async () => { + const account = await seedAccount({ apiKeyCT: 'already-issued' }); + + await expect(createApiKey(account.id, { buy: true })).rejects.toThrow('API key already exists'); + + const stored = await dataSource.getRepository(UserData).findOneBy({ id: account.id }); + expect(stored.apiKeyCT).toEqual('already-issued'); + }, 120000); + + // --- LEVEL 4: consistency against a second source --- // + + it('level 4 — the projected answer equals the one from a full load', async () => { + const userData = await seedAccount({ apiKeyCT: null }); + + const projected = await userDataRepo.getForApiKey(userData.id); + // The unprojected load is the second source: every column of the row. + const full = await dataSource.getRepository(UserData).findOneBy({ id: userData.id }); + + // Same key on both rows, so what has to agree is the secret derived from it — which is where + // the creation date enters, and the only value the projection can get wrong here. + const key = ApiKeyService.createKey(full.id); + projected.apiKeyCT = key; + full.apiKeyCT = key; + + expect(ApiKeyService.getSecret(projected)).toEqual(ApiKeyService.getSecret(full)); + expect(projected.id).toEqual(full.id); + }, 120000); +}); diff --git a/src/subdomains/generic/user/models/user-data/user-data.repository.ts b/src/subdomains/generic/user/models/user-data/user-data.repository.ts index 943be3b7cc..e95f3da436 100644 --- a/src/subdomains/generic/user/models/user-data/user-data.repository.ts +++ b/src/subdomains/generic/user/models/user-data/user-data.repository.ts @@ -1,15 +1,274 @@ import { Injectable } from '@nestjs/common'; +import { ReadProjection } from 'src/shared/models/read-projection'; import { CachedRepository } from 'src/shared/repositories/cached.repository'; import { EntityManager } from 'typeorm'; import { UserData } from './user-data.entity'; import { UserDataStatus } from './user-data.enum'; +/** The fields `CountryDtoMapper.entityToDto` reads, for a given join alias. */ +const countryFields = (alias: string): string[] => + [ + 'id', + 'symbol', + 'name', + 'foreignName', + 'ipEnable', + 'fatfEnable', + 'dfxEnable', + 'dfxOrganizationEnable', + 'nationalityStepEnable', + 'bankEnable', + 'checkoutEnable', + 'cryptoEnable', + ].map((field) => `${alias}.${field}`); + +/** + * Fields the profile response shows regardless of account type. + * + * `organization.name` belongs here rather than with the organization address: the mapper reads it + * whenever an organization is linked, while only the *address* branches on `accountType`. + */ +export const USER_PROFILE_ACCOUNT_FIELDS = [ + 'userData.accountType', + 'userData.firstname', + 'userData.surname', + 'userData.mail', + 'userData.phone', + 'organization.name', +]; + +/** + * The address `UserData.address` returns for a personal account. + * + * The getter branches on `accountType`; a business account never reaches these, which is why the + * mutation test asserts over one branch at a time. + */ +export const USER_PROFILE_PERSONAL_ADDRESS_FIELDS = [ + 'userData.street', + 'userData.houseNumber', + 'userData.location', + 'userData.zip', + ...countryFields('country'), +]; + +/** The address `UserData.address` returns for an organization or sole-proprietorship account. */ +export const USER_PROFILE_ORGANIZATION_ADDRESS_FIELDS = [ + 'organization.street', + 'organization.houseNumber', + 'organization.location', + 'organization.zip', + ...countryFields('organizationCountry'), +]; + +/** + * `GET /user/profile` — the seven values `UserDtoMapper.mapProfile` returns. + * + * Without it a `findOne` on `UserData` selects every column of the row and of its eager joins, `organization` + * among them. Covered by `user-profile.projection.spec.ts` on all four levels. + */ +export const USER_PROFILE_PROJECTION = new ReadProjection( + 'userData', + [ + ['userData.country', 'country'], + ['userData.organization', 'organization'], + ['organization.country', 'organizationCountry'], + ], + [ + ...USER_PROFILE_ACCOUNT_FIELDS, + ...USER_PROFILE_PERSONAL_ADDRESS_FIELDS, + ...USER_PROFILE_ORGANIZATION_ADDRESS_FIELDS, + ], + // Never part of the response: the primary keys that make the ORM materialise the joined rows, and + // the status the endpoint refuses merged accounts on before it maps anything. + ['userData.id', 'userData.status', 'organization.id'], +); + +/** The nine columns `UserDtoMapper.mapVolumes` reads, for either the account or one of its users. */ +const volumeFields = (alias: string): string[] => + [ + 'buyVolume', + 'annualBuyVolume', + 'monthlyBuyVolume', + 'sellVolume', + 'annualSellVolume', + 'monthlySellVolume', + 'cryptoVolume', + 'annualCryptoVolume', + 'monthlyCryptoVolume', + ].map((field) => `${alias}.${field}`); + +/** + * What the account itself contributes to `UserDtoMapper.mapUser`. + * + * The address columns are not shown by this response; they are read by `isDataComplete`, which + * answers `false` for any of them that the query did not load — a wrong value, not a missing one. + * `country` and `organizationCountry` are relations, and the same getter tests them for truthiness, + * so both are joined below for their primary key alone. + */ +export const USER_V2_ACCOUNT_FIELDS = [ + // The response keys itself by this: `mapUser` returns it as `accountId`. + 'userData.id', + 'userData.accountType', + 'userData.mail', + 'userData.phone', + 'userData.kycHash', + // `kycLevelDisplay`, `tradingLimit` and `isKycTerminated` are getters over this and the limit. + 'userData.kycLevel', + 'userData.depositLimit', + 'userData.phoneCallAccepted', + 'userData.phoneCallStatus', + // `phoneCallTimesObject` splits this column. + 'userData.phoneCallTimes', + 'userData.paymentLinksAllowed', + 'userData.apiKeyCT', + 'userData.apiFilterCT', + ...volumeFields('userData'), + // Read only by `isDataComplete`, together with the two joined countries. + 'userData.firstname', + 'userData.surname', + 'userData.street', + 'userData.location', + 'userData.zip', + 'userData.organizationName', + 'userData.organizationStreet', + 'userData.organizationLocation', + 'userData.organizationZip', + // Relations, and `isDataComplete` tests them for truthiness like any other required field. They + // are response fields rather than guards for exactly that reason: dropping either one changes the + // answer, so the mutation test has to cover them. + 'country.id', + 'organizationCountry.id', +]; + +/** What `LanguageDtoMapper.entityToDto` and `FiatDtoMapper.toDto` read. */ +export const USER_V2_LANGUAGE_AND_CURRENCY_FIELDS = [ + 'language.id', + 'language.name', + 'language.symbol', + 'language.foreignName', + 'language.enable', + 'currency.id', + 'currency.name', + 'currency.buyable', + 'currency.sellable', + 'currency.instantBuyable', + 'currency.instantSellable', +]; + +/** + * What `UserDtoMapper.mapAddress` reads off a user and its wallet. + * + * `user.status` drives `isBlockedOrDeleted`, which decides whether an address is listed as active or + * as disabled; `wallet.name` and `user.address` are what the `blockchains` getter derives the chain + * list from, and `wallet.usesDummyAddresses` hides an address entirely. + */ +export const USER_V2_ADDRESS_FIELDS = [ + 'user.id', + 'user.label', + 'user.address', + 'user.ref', + 'user.apiKeyCT', + 'user.apiFilterCT', + 'user.role', + 'user.status', + ...volumeFields('user'), + 'userWallet.name', + 'userWallet.displayName', + 'userWallet.usesDummyAddresses', +]; + +/** + * `GET /user` (v2) — the widest read path left in the inventory. + * + * Without it a `findOne` on `UserData` selects every column of the row and of its eager joins: four countries, a language, a currency + * and an organization expand eagerly, and every user of the account brings its whole wallet. + * + * `computeCapabilities` reads `kycSteps` through `getStepsWith`. This query does not load that + * relation and neither did the one before it, so the two capabilities derived from it do not depend + * on the steps. The projection reproduces that; changing it would change the response. + */ +export const USER_V2_PROJECTION = new ReadProjection( + 'userData', + [ + ['userData.language', 'language'], + ['userData.currency', 'currency'], + ['userData.country', 'country'], + ['userData.organizationCountry', 'organizationCountry'], + ['userData.users', 'user'], + ['user.wallet', 'userWallet'], + ], + [...USER_V2_ACCOUNT_FIELDS, ...USER_V2_LANGUAGE_AND_CURRENCY_FIELDS, ...USER_V2_ADDRESS_FIELDS], + // Never shown: the status the endpoint refuses merged accounts on before it maps anything, and + // the wallet key that makes the ORM materialise the joined row. + ['userData.status', 'userWallet.id'], +); + +/** + * What `POST /user/apiKey/CT` reads. + * + * `apiKeyCT` is read twice: once to refuse a second key, and once after the new one is assigned, + * because `ApiKeyService.getSecret` hashes it together with `created`. + */ +export const API_KEY_RESPONSE_FIELDS = [ + // The key is derived from the account id, and the secret from the key and the creation date. + 'userData.id', + 'userData.apiKeyCT', + 'userData.created', +]; + +/** + * `POST /user/apiKey/CT` — two values and the id. + * + * The endpoint writes, but through `update(id, …)` rather than by saving the row it read, so a + * projected read cannot blank a column it did not load. + */ +export const API_KEY_PROJECTION = new ReadProjection('userData', [], API_KEY_RESPONSE_FIELDS); + @Injectable() export class UserDataRepository extends CachedRepository { constructor(manager: EntityManager) { super(UserData, manager); } + /** + * The account, carrying what an API key is built from. + * + * `fields` is what the mutation test in `api-key.projection.spec.ts` re-runs the query with; + * `UserDataService.createApiKey` calls this without it. + */ + async getForApiKey(id: number, fields: ReadonlyArray = API_KEY_PROJECTION.fields): Promise { + return API_KEY_PROJECTION.apply(this.createQueryBuilder('userData'), fields) + .where('userData.id = :id', { id }) + .getOne(); + } + + /** + * Loads exactly what the v2 user response needs, users and wallets included. + * + * `fields` is what the mutation test in `user-v2.projection.spec.ts` re-runs the query with; + * `UserService.getUserDtoV2` calls this without it. + */ + async getUserV2(id: number, fields: ReadonlyArray = USER_V2_PROJECTION.fields): Promise { + return USER_V2_PROJECTION.apply(this.createQueryBuilder('userData'), fields) + .where('userData.id = :id', { id }) + .getOne(); + } + + /** + * Loads exactly what the profile response needs. + * + * `fields` is what the mutation test in `user-profile.projection.spec.ts` re-runs the query + * with; `UserService.getUserProfile` calls this without it. + */ + async getProfile( + id: number, + fields: ReadonlyArray = USER_PROFILE_PROJECTION.fields, + ): Promise { + return USER_PROFILE_PROJECTION.apply(this.createQueryBuilder('userData'), fields) + .where('userData.id = :id', { id }) + .getOne(); + } + async setNewUpdateTime(userDataId: number): Promise { await this.update(userDataId, { updated: new Date() }); } diff --git a/src/subdomains/generic/user/models/user-data/user-data.service.ts b/src/subdomains/generic/user/models/user-data/user-data.service.ts index 2680e04f9d..a6dd8bdf52 100644 --- a/src/subdomains/generic/user/models/user-data/user-data.service.ts +++ b/src/subdomains/generic/user/models/user-data/user-data.service.ts @@ -1090,7 +1090,7 @@ export class UserDataService { // --- API KEY --- // async createApiKey(userDataId: number, filter: HistoryFilter): Promise { - const userData = await this.userDataRepo.findOneBy({ id: userDataId }); + const userData = await this.userDataRepo.getForApiKey(userDataId); if (!userData) throw new BadRequestException('User not found'); if (userData.apiKeyCT) throw new ConflictException('API key already exists'); diff --git a/src/subdomains/generic/user/models/user/__tests__/user-profile.projection.spec.ts b/src/subdomains/generic/user/models/user/__tests__/user-profile.projection.spec.ts new file mode 100644 index 0000000000..4aa050e71c --- /dev/null +++ b/src/subdomains/generic/user/models/user/__tests__/user-profile.projection.spec.ts @@ -0,0 +1,176 @@ +import { + USER_PROFILE_ACCOUNT_FIELDS, + USER_PROFILE_ORGANIZATION_ADDRESS_FIELDS, + USER_PROFILE_PERSONAL_ADDRESS_FIELDS, + USER_PROFILE_PROJECTION, + UserDataRepository, +} from 'src/subdomains/generic/user/models/user-data/user-data.repository'; +import { AccountType } from 'src/subdomains/generic/user/models/user-data/account-type.enum'; +import { UserData } from 'src/subdomains/generic/user/models/user-data/user-data.entity'; +import { UserDtoMapper } from 'src/subdomains/generic/user/models/user/dto/user-dto.mapper'; +import { UserProfileDto } from 'src/subdomains/generic/user/models/user/dto/user-profile.dto'; +import { + createProjectionDataSource, + describeProjection, + destroyProjectionDataSource, + expectEveryFieldRequired, + expectNoEmptyFields, + projectionFieldsWithout, + seedEntity, +} from 'src/shared/utils/projection-test.util'; +import { DataSource } from 'typeorm'; + +const SCHEMA = 'user_profile_projection_spec'; + +/** + * `GET /user/profile` — the four levels from `docs/read-path-projections.md`. + * + * The endpoint answers a `UserProfileDto` built by `UserDtoMapper.mapProfile`. Reading it without a + * projection loads every column of the row and of its eager joins for the seven values it returns. + * + * The branch that makes this worth testing is `UserData.address`: for an organization account it + * reads the address off `organization`, for a personal one off `userData` itself. A projection + * covering only one of the two answers 200 with an empty address for the other. + */ +describeProjection('GET /user/profile — read-path projection', () => { + let dataSource: DataSource; + let repository: UserDataRepository; + + beforeAll(async () => { + dataSource = await createProjectionDataSource(SCHEMA); + repository = new UserDataRepository(dataSource.manager); + }, 300000); + + afterAll(async () => { + await destroyProjectionDataSource(dataSource, SCHEMA); + }); + + /** Fully populated fixture: every column of every participating entity carries a non-empty value. */ + async function seedAccount(accountType: AccountType): Promise { + return seedEntity(dataSource, UserData, { + values: { accountType }, + relations: { country: true, organization: { relations: { country: true } } }, + }); + } + + /** A personal account with no organization linked — the branch where the join finds no row. */ + async function seedAccountWithoutOrganization(): Promise { + return seedEntity(dataSource, UserData, { + values: { accountType: AccountType.PERSONAL }, + relations: { country: true }, + }); + } + + /** The response the endpoint produces, through the projected query. */ + async function profileOf(id: number, fields = USER_PROFILE_PROJECTION.fields): Promise { + const userData = await repository.getProfile(id, fields); + return UserDtoMapper.mapProfile(userData); + } + + // --- LEVEL 1: completeness --- // + + it('level 1 — a personal account answers with no empty field', async () => { + const userData = await seedAccount(AccountType.PERSONAL); + + expectNoEmptyFields(await profileOf(userData.id)); + }, 120000); + + // --- LEVEL 2: variants --- // + + it.each([AccountType.ORGANIZATION, AccountType.SOLE_PROPRIETORSHIP])( + 'level 2 — %s reads the address off the organization and answers with no empty field', + async (accountType) => { + const userData = await seedAccount(accountType); + + const profile = await profileOf(userData.id); + + expectNoEmptyFields(profile); + }, + 120000, + ); + + it('level 2 — the address comes from a different source per account type', async () => { + const personal = await seedAccount(AccountType.PERSONAL); + const organization = await seedAccount(AccountType.ORGANIZATION); + + const personalProfile = await profileOf(personal.id); + const organizationProfile = await profileOf(organization.id); + + // Both fixtures carry an organization, so the difference isolates the branch: the personal + // account must answer with its own street, the organization one with the organization's. Had the + // projection loaded only one of the two sources, one of these would be empty rather than merely + // different — and the assertion holds because every seeded value is distinct. + expect(personalProfile.address.street).toEqual(personal.street); + expect(organizationProfile.address.street).toEqual(organization.organization.street); + expect(personalProfile.address.street).not.toEqual(organizationProfile.address.street); + }, 120000); + + it('level 2 — a personal account without an organization answers without one', async () => { + const userData = await seedAccountWithoutOrganization(); + + const profile = await profileOf(userData.id); + + // The ordinary case: no organization row, so the mapper has no name to report. Everything else + // must still be complete — a projection that only ever ran against the fixture above would not + // show whether the left joins tolerate the missing row. + expect(profile.organizationName).toBeUndefined(); + expectNoEmptyFields(profile, ['organizationName']); + }, 120000); + + // --- LEVEL 3: mutation --- // + + it.each([ + [AccountType.PERSONAL, [...USER_PROFILE_ACCOUNT_FIELDS, ...USER_PROFILE_PERSONAL_ADDRESS_FIELDS]], + [AccountType.ORGANIZATION, [...USER_PROFILE_ACCOUNT_FIELDS, ...USER_PROFILE_ORGANIZATION_ADDRESS_FIELDS]], + ])( + 'level 3 — for %s every field feeding the response is required', + async (accountType, candidates) => { + const userData = await seedAccount(accountType); + + // Drops each candidate in turn and re-runs the same production query with the rest of the + // projection intact. Only the fields this account type actually reads are candidates: a personal + // account never touches the organization address, so dropping it would prove nothing here — the + // organization row above is what covers those. + await expectEveryFieldRequired(candidates, (omitted) => + profileOf(userData.id, projectionFieldsWithout(USER_PROFILE_PROJECTION.fields, omitted)), + ); + }, + 300000, + ); + + // --- LEVEL 4: consistency against a second source --- // + + it.each([ + ['Personal', () => seedAccount(AccountType.PERSONAL)], + ['Organization', () => seedAccount(AccountType.ORGANIZATION)], + ['SoleProprietorship', () => seedAccount(AccountType.SOLE_PROPRIETORSHIP)], + ['Personal without an organization', seedAccountWithoutOrganization], + ])( + 'level 4 — for %s the projected response equals the one from a full load', + async (_name, seed) => { + const userData = await seed(); + + const projected = await profileOf(userData.id); + // The unprojected load is the second source: it fetches every column, so whatever it produces + // depends on no field list at all. No second implementation is involved that could be wrong + // in the same way. + const full = await dataSource.getRepository(UserData).findOne({ + where: { id: userData.id }, + relations: { organization: true }, + }); + + expect(projected).toEqual(UserDtoMapper.mapProfile(full)); + }, + 120000, + ); + + // --- the projection must not lose the guard the endpoint depends on --- // + + it('loads the status the endpoint refuses merged accounts on', async () => { + const userData = await seedAccount(AccountType.PERSONAL); + + const loaded = await repository.getProfile(userData.id); + + expect(loaded.status).toEqual(userData.status); + }, 120000); +}); diff --git a/src/subdomains/generic/user/models/user/__tests__/user-v2.projection.spec.ts b/src/subdomains/generic/user/models/user/__tests__/user-v2.projection.spec.ts new file mode 100644 index 0000000000..614beaff60 --- /dev/null +++ b/src/subdomains/generic/user/models/user/__tests__/user-v2.projection.spec.ts @@ -0,0 +1,335 @@ +import { ConfigService } from 'src/config/config'; +import { Fiat } from 'src/shared/models/fiat/fiat.entity'; +import { Language } from 'src/shared/models/language/language.entity'; +import { + createProjectionDataSource, + describeProjection, + destroyProjectionDataSource, + expectEveryFieldRequired, + expectNoEmptyFields, + projectionFieldsWithout, + seedEntity, +} from 'src/shared/utils/projection-test.util'; +import { Country } from 'src/shared/models/country/country.entity'; +import { UserDtoMapper } from 'src/subdomains/generic/user/models/user/dto/user-dto.mapper'; +import { UserV2Dto } from 'src/subdomains/generic/user/models/user/dto/user-v2.dto'; +import { AccountType } from 'src/subdomains/generic/user/models/user-data/account-type.enum'; +import { + KycLevel, + PhoneCallPreferredTime, + PhoneCallStatus, + UserDataStatus, +} from 'src/subdomains/generic/user/models/user-data/user-data.enum'; +import { UserData } from 'src/subdomains/generic/user/models/user-data/user-data.entity'; +import { + USER_V2_ACCOUNT_FIELDS, + USER_V2_ADDRESS_FIELDS, + USER_V2_LANGUAGE_AND_CURRENCY_FIELDS, + USER_V2_PROJECTION, + UserDataRepository, +} from 'src/subdomains/generic/user/models/user-data/user-data.repository'; +import { User } from 'src/subdomains/generic/user/models/user/user.entity'; +import { UserRole } from 'src/shared/auth/user-role.enum'; +import { UserStatus } from 'src/subdomains/generic/user/models/user/user.enum'; +import { Wallet } from 'src/subdomains/generic/user/models/wallet/wallet.entity'; +import { DataSource } from 'typeorm'; + +const SCHEMA = 'user_v2_projection_spec'; + +/** + * Addresses have to look like addresses. + * + * `user.blockchains` derives the chain list from the address itself, and `explorerUrl` is built + * from the first entry — a generated string belongs to no chain, so both come out empty and the + * completeness assertion would report a projection problem where there is none. + */ +let addressCount = 0; +const nextEvmAddress = (): string => `0x${(++addressCount).toString(16).padStart(40, 'a')}`; + +/** + * `GET /user` (v2) — the four levels from `docs/read-path-projections.md`. + * + * The widest read path in the inventory. Most of the response comes out of getters, several of + * which answer a valid-looking value from a missing column — `isDataComplete` reports `false`, the + * trading limit falls back to the no-KYC default — which is why level 3 compares responses. + */ +describeProjection('GET /user v2 — read-path projection', () => { + let dataSource: DataSource; + let userDataRepo: UserDataRepository; + + beforeAll(async () => { + // `tradingLimit` reads the no-KYC default off the module-level Config. + new ConfigService(); + dataSource = await createProjectionDataSource(SCHEMA); + userDataRepo = new UserDataRepository(dataSource.manager); + }, 300000); + + afterAll(async () => { + await destroyProjectionDataSource(dataSource, SCHEMA); + }); + + /** + * An account with one address on it, every column populated. + * + * `accountType`, `kycLevel`, `status` and the user's `status` are set explicitly: all four are + * TypeScript enums in text columns, and the mapper branches on each — a generated value lands in + * the wrong branch and the response looks complete for the wrong reason. + */ + async function seedAccount( + account: Partial = {}, + user: Partial = {}, + wallet: Partial = {}, + ): Promise<{ userData: UserData; user: User }> { + const language = await seedEntity(dataSource, Language); + const currency = await seedEntity(dataSource, Fiat); + const country = await seedEntity(dataSource, Country); + const organizationCountry = await seedEntity(dataSource, Country); + const userData = await seedEntity(dataSource, UserData, { + values: { + language, + currency, + country, + organizationCountry, + accountType: AccountType.PERSONAL, + kycLevel: KycLevel.LEVEL_50, + status: UserDataStatus.ACTIVE, + // Read through a lookup table, so a generated value maps to undefined and reads exactly + // like a column the query failed to load. + phoneCallStatus: PhoneCallStatus.COMPLETED, + // Split on ';' by `phoneCallTimesObject`; a value has to be a member for the list to mean + // anything. + phoneCallTimes: PhoneCallPreferredTime.H_9_TO_10, + ...account, + }, + }); + const userWallet = await seedEntity(dataSource, Wallet, { + values: { usesDummyAddresses: false, ...wallet }, + }); + const seeded = await seedEntity(dataSource, User, { + values: { userData, wallet: userWallet, status: UserStatus.ACTIVE, address: nextEvmAddress(), ...user }, + }); + return { userData, user: seeded }; + } + + /** The response the endpoint produces, through the projected query. */ + async function userV2Of(id: number, activeUserId?: number, fields = USER_V2_PROJECTION.fields): Promise { + const userData = await userDataRepo.getUserV2(id, fields); + return UserDtoMapper.mapUser(userData, activeUserId); + } + + // --- LEVEL 1: completeness --- // + + it('level 1 — a complete account answers with no empty field', async () => { + const { userData, user } = await seedAccount(); + + const dto = await userV2Of(userData.id, user.id); + + expect(dto.addresses).toHaveLength(1); + // `disabledAddresses` is legitimately empty for an account with no blocked address; the case + // where it is filled is covered below. + expectNoEmptyFields(dto, ['disabledAddresses']); + }, 120000); + + // --- LEVEL 2: variants --- // + + it('level 2 — an organization account answers with no empty field', async () => { + // `requiredKycFields` swaps the personal name fields for the four organization address columns, + // so a projection missing one of those is only visible on this account type. + const { userData, user } = await seedAccount({ accountType: AccountType.ORGANIZATION }); + + expectNoEmptyFields(await userV2Of(userData.id, user.id), ['disabledAddresses']); + }, 120000); + + it.each([KycLevel.LEVEL_0, KycLevel.LEVEL_50, KycLevel.TERMINATED])( + 'level 2 — kycLevel %s answers with a trading limit', + async (kycLevel) => { + const { userData, user } = await seedAccount({ kycLevel }); + + // The three branches of `tradingLimit` are a yearly limit, a zero limit for a terminated + // account, and the configured default. Only the first reads the deposit limit and the volumes. + const dto = await userV2Of(userData.id, user.id); + + expect(dto.tradingLimit.limit).toBeDefined(); + expect(dto.tradingLimit.period).toBeDefined(); + }, + 120000, + ); + + it('level 2 — a blocked address is listed as disabled rather than active', async () => { + const { userData } = await seedAccount({}, { status: UserStatus.BLOCKED }); + + const dto = await userV2Of(userData.id); + + expect(dto.addresses).toHaveLength(0); + expect(dto.disabledAddresses).toHaveLength(1); + }, 120000); + + it('level 2 — an address on a dummy-address wallet is not listed at all', async () => { + const { userData } = await seedAccount({}, {}, { usesDummyAddresses: true }); + + const dto = await userV2Of(userData.id); + + expect(dto.addresses).toHaveLength(0); + expect(dto.disabledAddresses).toHaveLength(0); + }, 120000); + + it('level 2 — the active address is the one the caller authenticated with', async () => { + const { userData, user } = await seedAccount(); + + expect((await userV2Of(userData.id, user.id)).activeAddress?.address).toEqual(user.address); + expect((await userV2Of(userData.id)).activeAddress).toBeUndefined(); + }, 120000); + + it('level 2 — the account answers only with its own addresses', async () => { + const mine = await seedAccount(); + const other = await seedAccount(); + + const dto = await userV2Of(mine.userData.id); + + expect(dto.addresses.map((a) => a.address)).toEqual([mine.user.address]); + expect(dto.addresses.map((a) => a.address)).not.toContain(other.user.address); + }, 120000); + + it('level 2 — an incomplete account reports dataComplete false', async () => { + // The counter-case to level 1: with a required field genuinely absent the flag must be false, + // which is what makes the level-1 assertion meaningful. + const { userData, user } = await seedAccount({ street: null }); + + expect((await userV2Of(userData.id, user.id)).kyc.dataComplete).toBe(false); + }, 120000); + + // --- LEVEL 3: mutation --- // + + /** The account fields only an organization account reads. */ + const ORGANIZATION_ONLY = [ + 'userData.organizationName', + 'userData.organizationStreet', + 'userData.organizationLocation', + 'userData.organizationZip', + 'organizationCountry.id', + ]; + + /** + * Fields the mapper only reaches through a fallback, or through a branch a default fixture does + * not enter. Each gets its own fixture below; against the default one they are droppable, which + * is true and proves nothing. + */ + const NEEDS_ITS_OWN_FIXTURE = [ + 'user.apiKeyCT', + 'user.apiFilterCT', + 'user.role', + 'userWallet.name', + 'userWallet.usesDummyAddresses', + ]; + + const withoutFields = (fields: string[], excluded: string[]): string[] => + fields.filter((field) => !excluded.includes(field)); + + /** Adds a second, blocked address so that `disabledAddresses` is populated as well. */ + async function seedBlockedAddress(userData: UserData): Promise { + const wallet = await seedEntity(dataSource, Wallet, { values: { usesDummyAddresses: false } }); + await seedEntity(dataSource, User, { + values: { userData, wallet, status: UserStatus.BLOCKED, address: nextEvmAddress() }, + }); + } + + it.each([ + ['personal', AccountType.PERSONAL, ORGANIZATION_ONLY], + ['organization', AccountType.ORGANIZATION, []], + ])( + 'level 3 — on a %s account every field feeding the response is required', + async (_name, accountType, skipped) => { + const { userData, user } = await seedAccount({ accountType }); + // The mutation run compares whole responses, and a list that is empty in the baseline can never + // differ — so both address lists have to be populated. + await seedBlockedAddress(userData); + + await expectEveryFieldRequired( + withoutFields( + [...USER_V2_ACCOUNT_FIELDS, ...USER_V2_LANGUAGE_AND_CURRENCY_FIELDS, ...USER_V2_ADDRESS_FIELDS], + [...skipped, ...NEEDS_ITS_OWN_FIXTURE], + ), + (omitted) => userV2Of(userData.id, user.id, projectionFieldsWithout(USER_V2_PROJECTION.fields, omitted)), + ); + }, + 300000, + ); + + it('level 3 — the address falls back to its own key and its wallet name when the account has none', async () => { + // `mapAddress` reads `userData.apiKeyCT ?? user.apiKeyCT` and `wallet.displayName ?? wallet.name`. + // With the left-hand side set, the right-hand columns are never reached and look removable. + const { userData, user } = await seedAccount({ apiKeyCT: null, apiFilterCT: null }, {}, { displayName: null }); + await seedBlockedAddress(userData); + + await expectEveryFieldRequired( + ['user.apiKeyCT', 'user.apiFilterCT', 'userWallet.name'], + (omitted) => userV2Of(userData.id, user.id, projectionFieldsWithout(USER_V2_PROJECTION.fields, omitted)), + // The account-level key is what this fixture removes, so the two fields it feeds at the top of + // the response are empty by construction — that is the state that makes the address fall back. + ['apiKeyCT', 'apiFilterCT'], + ); + }, 300000); + + it('level 3 — the custody role is required to report an address as custody', async () => { + // `isCustody` compares the role against one value. On any other role, dropping the column + // produces `undefined === CUSTODY` — false, the same answer, so only a custody address shows it. + const { userData, user } = await seedAccount({}, { role: UserRole.CUSTODY }); + + await expectEveryFieldRequired( + ['user.role'], + (omitted) => userV2Of(userData.id, user.id, projectionFieldsWithout(USER_V2_PROJECTION.fields, omitted)), + // With one custody address and none blocked, this list is empty by contract. + ['disabledAddresses'], + ); + }, 300000); + + it('level 3 — the dummy-address flag is required to hide an address', async () => { + // The flag hides the address entirely. Dropping the column leaves it undefined, which is falsy, + // so the address reappears — visible only on a wallet where the flag is actually set. + const { userData, user } = await seedAccount({}, {}, { usesDummyAddresses: true }); + + await expectEveryFieldRequired( + ['userWallet.usesDummyAddresses'], + (omitted) => userV2Of(userData.id, user.id, projectionFieldsWithout(USER_V2_PROJECTION.fields, omitted)), + // Both lists are empty while the address is hidden — that is the state under test. + ['addresses', 'disabledAddresses', 'activeAddress'], + ); + }, 300000); + + // --- the projection must not lose the guard the endpoint depends on --- // + + it('loads the status the endpoint refuses merged accounts on', async () => { + // `getUserDtoV2` reads this column before it maps anything and answers 401 for a merged account. + // The mapper never shows it, so every comparison in this file would stay green without it — this + // is the only assertion that keeps the guard in the projection. + const { userData } = await seedAccount({ status: UserDataStatus.MERGED }); + + const loaded = await userDataRepo.getUserV2(userData.id); + + expect(loaded.status).toEqual(UserDataStatus.MERGED); + }, 120000); + + // --- LEVEL 4: consistency against a second source --- // + + it.each([ + ['a personal account', AccountType.PERSONAL, UserStatus.ACTIVE], + ['an organization account', AccountType.ORGANIZATION, UserStatus.ACTIVE], + ['an account whose address is blocked', AccountType.PERSONAL, UserStatus.BLOCKED], + ])( + 'level 4 — for %s the projected response equals the one from a full load', + async (_name, accountType, status) => { + const { userData, user } = await seedAccount({ accountType }, { status }); + + const projected = await userV2Of(userData.id, user.id); + // The unprojected load is the second source: a find without a field list, with every eager + // relation it pulls in. + const full = await dataSource.getRepository(UserData).findOne({ + where: { id: userData.id }, + relations: { users: { wallet: true } }, + }); + + expect(projected).toEqual(UserDtoMapper.mapUser(full, user.id)); + }, + 120000, + ); +}); diff --git a/src/subdomains/generic/user/models/user/user.repository.ts b/src/subdomains/generic/user/models/user/user.repository.ts index b5d9db658c..90f86a4f2d 100644 --- a/src/subdomains/generic/user/models/user/user.repository.ts +++ b/src/subdomains/generic/user/models/user/user.repository.ts @@ -1,16 +1,55 @@ import { Injectable } from '@nestjs/common'; +import { ReadProjection } from 'src/shared/models/read-projection'; import { BaseRepository } from 'src/shared/repositories/base.repository'; import { Util } from 'src/shared/utils/util'; import { EntityManager, Raw, Repository } from 'typeorm'; import { KycLevel } from '../user-data/user-data.enum'; import { User } from './user.entity'; +/** What `GET /kyc/:id/documents` needs off the account row: the id the document store is keyed by. */ +export const USER_KYC_FILES_RESPONSE_FIELDS = ['kycFilesUserData.id']; + +/** + * `GET /kyc/:id/documents` — resolves the account behind an address on a wallet. + * + * The wallet is joined for the filter only; nothing of it reaches the response. + */ +export const USER_KYC_FILES_PROJECTION = new ReadProjection( + 'user', + [ + ['user.userData', 'kycFilesUserData'], + ['user.wallet', 'kycFilesWallet'], + ], + USER_KYC_FILES_RESPONSE_FIELDS, + ['user.id'], +); + @Injectable() export class UserRepository extends BaseRepository { constructor(manager: EntityManager) { super(User, manager); } + /** + * The account behind an address on a wallet, loaded with the account id only. + * + * `GET /kyc/:id/documents` reads nothing else off the row: the response is assembled from the + * document store, keyed by that id. The unprojected load fetches the whole row graph for it. + * + * `fields` is what the mutation test in `kyc-data.projection.spec.ts` re-runs the query with; + * `KycService` calls this without it. + */ + async findAccountIdForAddress( + address: string, + walletId: number, + fields: ReadonlyArray = USER_KYC_FILES_PROJECTION.fields, + ): Promise { + return USER_KYC_FILES_PROJECTION.apply(this.createQueryBuilder('user'), fields) + .where('user.address = :address', { address }) + .andWhere('kycFilesWallet.id = :walletId', { walletId }) + .getOne(); + } + async setUserRef(user: User, kycLevel: KycLevel, manager?: EntityManager): Promise { if (!user.ref && kycLevel >= KycLevel.LEVEL_50) { const repo = manager?.getRepository(User) ?? this; diff --git a/src/subdomains/generic/user/models/user/user.service.ts b/src/subdomains/generic/user/models/user/user.service.ts index d6bba889a4..22919efabe 100644 --- a/src/subdomains/generic/user/models/user/user.service.ts +++ b/src/subdomains/generic/user/models/user/user.service.ts @@ -264,10 +264,7 @@ export class UserService { } async getUserDtoV2(userDataId: number, userId?: number): Promise { - const userData = await this.userDataRepo.findOne({ - where: { id: userDataId }, - relations: { users: { wallet: true } }, - }); + const userData = await this.userDataRepo.getUserV2(userDataId); if (!userData) throw new NotFoundException('User not found'); if (userData.status === UserDataStatus.MERGED) throw new UnauthorizedException('User is merged'); @@ -317,10 +314,7 @@ export class UserService { } async getUserProfile(userDataId: number): Promise { - const userData = await this.userDataRepo.findOne({ - where: { id: userDataId }, - relations: { organization: true }, - }); + const userData = await this.userDataRepo.getProfile(userDataId); if (!userData) throw new NotFoundException('User not found'); if (userData.status === UserDataStatus.MERGED) throw new UnauthorizedException('User is merged'); diff --git a/src/subdomains/generic/user/models/wallet/wallet.repository.ts b/src/subdomains/generic/user/models/wallet/wallet.repository.ts index 77efeb52c8..acb3ad7c8e 100644 --- a/src/subdomains/generic/user/models/wallet/wallet.repository.ts +++ b/src/subdomains/generic/user/models/wallet/wallet.repository.ts @@ -1,15 +1,56 @@ import { Injectable } from '@nestjs/common'; +import { ReadProjection } from 'src/shared/models/read-projection'; import { CachedRepository } from 'src/shared/repositories/cached.repository'; import { EntityManager } from 'typeorm'; import { Wallet } from './wallet.entity'; +/** The four values `KycDataDtoMapper.toDto` reads per user of the wallet. */ +export const WALLET_KYC_DATA_RESPONSE_FIELDS = [ + 'walletUser.address', + 'walletUserData.kycStatus', + 'walletUserData.kycType', + 'walletUserData.kycHash', +]; + +/** + * `GET /kyc/users` — the KYC state of every user on a wallet. + * + * Loading the wallet with `relations: { users: { userData: true } }` fetches every column per user, + * for an address, two status fields and a hash. + */ +export const WALLET_KYC_DATA_PROJECTION = new ReadProjection( + 'wallet', + [ + ['wallet.users', 'walletUser'], + ['walletUser.userData', 'walletUserData'], + ], + WALLET_KYC_DATA_RESPONSE_FIELDS, + // Never part of the response: the primary keys that make the ORM materialise the joined rows. + ['wallet.id', 'walletUser.id', 'walletUserData.id'], +); + @Injectable() export class WalletRepository extends CachedRepository { constructor(manager: EntityManager) { super(Wallet, manager); } - async getByAddress(address: string): Promise { + /** + * A wallet with its users' KYC state, and nothing else. + * + * `fields` is what the mutation test in `kyc-data.projection.spec.ts` re-runs the query with; + * `KycService` calls this without it. + */ + async findKycData( + walletId: number, + fields: ReadonlyArray = WALLET_KYC_DATA_PROJECTION.fields, + ): Promise { + return WALLET_KYC_DATA_PROJECTION.apply(this.createQueryBuilder('wallet'), fields) + .where('wallet.id = :walletId', { walletId }) + .getOne(); + } + + async getByAddress(address: string): Promise { return this.findOneBy({ address }); } } diff --git a/src/subdomains/supporting/support-issue/__tests__/support-issue-data.projection.spec.ts b/src/subdomains/supporting/support-issue/__tests__/support-issue-data.projection.spec.ts new file mode 100644 index 0000000000..3870ab485a --- /dev/null +++ b/src/subdomains/supporting/support-issue/__tests__/support-issue-data.projection.spec.ts @@ -0,0 +1,291 @@ +import { Blockchain } from 'src/integration/blockchain/shared/enums/blockchain.enum'; +import { Asset } from 'src/shared/models/asset/asset.entity'; +import { Fiat } from 'src/shared/models/fiat/fiat.entity'; +import { + SUPPORT_ISSUE_DATA_ACCOUNT_FIELDS, + SUPPORT_ISSUE_DATA_ISSUE_FIELDS, + SUPPORT_ISSUE_DATA_LIMIT_REQUEST_FIELDS, + SUPPORT_ISSUE_DATA_PROJECTION, + SUPPORT_ISSUE_DATA_TRANSACTION_FIELDS, + SupportIssueRepository, +} from 'src/subdomains/supporting/support-issue/repositories/support-issue.repository'; +import { SupportIssue } from 'src/subdomains/supporting/support-issue/entities/support-issue.entity'; +import { SupportIssueDtoMapper } from 'src/subdomains/supporting/support-issue/dto/support-issue-dto.mapper'; +import { SupportIssueInternalDataDto } from 'src/subdomains/supporting/support-issue/dto/support-issue.dto'; +import { BuyCrypto } from 'src/subdomains/core/buy-crypto/process/entities/buy-crypto.entity'; +import { BuyFiat } from 'src/subdomains/core/sell-crypto/process/buy-fiat.entity'; +import { Sell } from 'src/subdomains/core/sell-crypto/route/sell.entity'; +import { BankData } from 'src/subdomains/generic/user/models/bank-data/bank-data.entity'; +import { User } from 'src/subdomains/generic/user/models/user/user.entity'; +import { Wallet } from 'src/subdomains/generic/user/models/wallet/wallet.entity'; +import { LimitRequest } from 'src/subdomains/supporting/support-issue/entities/limit-request.entity'; +import { Transaction } from 'src/subdomains/supporting/payment/entities/transaction.entity'; +import { CryptoInput } from 'src/subdomains/supporting/payin/entities/crypto-input.entity'; +import { UserData } from 'src/subdomains/generic/user/models/user-data/user-data.entity'; +import { + createProjectionDataSource, + describeProjection, + destroyProjectionDataSource, + expectEveryFieldRequired, + expectNoEmptyFields, + projectionFieldsWithout, + seedEntity, +} from 'src/shared/utils/projection-test.util'; +import { DataSource } from 'typeorm'; + +const SCHEMA = 'support_issue_data_projection_spec'; + +/** + * `GET /support/issue/:id/data` — the four levels from `docs/read-path-projections.md`. + * + * The widest read path in the service: the unprojected load fetches the whole graph for a response of + * about sixty values, because the issue's four eager relations expand recursively and the + * transaction pulls in both of its sides. + * + * The branch worth testing is `mapTransactionData`, which reads `buyCrypto ?? buyFiat`. A projection + * covering only one of the two answers 200 with an empty transaction block for every issue on the + * other. + */ +describeProjection('GET /support/issue/:id/data — read-path projection', () => { + let dataSource: DataSource; + let repository: SupportIssueRepository; + + beforeAll(async () => { + dataSource = await createProjectionDataSource(SCHEMA); + repository = new SupportIssueRepository(dataSource.manager); + }, 300000); + + afterAll(async () => { + await destroyProjectionDataSource(dataSource, SCHEMA); + }); + + /** A crypto input whose asset carries a blockchain the explorer knows. */ + async function seedCryptoInput(): Promise { + const asset = await seedEntity(dataSource, Asset, { values: { blockchain: Blockchain.ETHEREUM } }); + return seedEntity(dataSource, CryptoInput, { values: { asset } }); + } + + /** + * An issue with a transaction on the requested side, and everything else populated. + * + * `named` picks which side of two fallback chains the fixture exercises. `UserData.completeName` + * is `organizationName ?? firstname + surname`, and the wallet name is `displayName ?? name`: with + * the first alternative present the second never runs, so one fixture can only ever show that half + * of those fields are needed. Setting the first to `null` is what makes the rest load-bearing. + */ + async function seedIssue( + side: 'buyCrypto' | 'buyFiat' | 'none', + named: 'organization' | 'personal' = 'organization', + ): Promise { + const personal = named === 'personal'; + const userData = await seedEntity(dataSource, UserData, { + values: personal ? { organizationName: null } : {}, + relations: { country: true, language: true }, + }); + let transaction: Transaction | null = null; + if (side !== 'none') { + // The wallet block of the response hangs off `transaction.user.wallet`, and both are nullable + // in the schema — a fixture without them says nothing about those four fields. + const wallet = await seedEntity(dataSource, Wallet, { + values: personal ? { displayName: null } : {}, + }); + const user = await seedEntity(dataSource, User, { values: { userData, wallet } }); + transaction = await seedEntity(dataSource, Transaction, { values: { userData, user } }); + if (side === 'buyCrypto') { + const outputAsset = await seedEntity(dataSource, Asset, { values: { blockchain: Blockchain.ETHEREUM } }); + await seedEntity(dataSource, BuyCrypto, { + values: { transaction, outputAsset, cryptoInput: await seedCryptoInput() }, + }); + } else { + const outputAsset = await seedEntity(dataSource, Fiat); + // An active sell route must carry a bankData; a check constraint on `deposit_route` + // enforces it, and `BuyFiat.sell` is not nullable. + const bankData = await seedEntity(dataSource, BankData); + const sell = await seedEntity(dataSource, Sell, { values: { user, bankData } }); + await seedEntity(dataSource, BuyFiat, { + values: { transaction, outputAsset, sell, cryptoInput: await seedCryptoInput() }, + }); + } + } + const limitRequest = await seedEntity(dataSource, LimitRequest); + return seedEntity(dataSource, SupportIssue, { + values: { + userData, + transaction, + limitRequest, + // Every field the transactionMissing block reports, so that an empty one means the column + // was not loaded rather than never written. + information: JSON.stringify({ senderIban: 'CH10', receiverIban: 'CH20', date: '2024-01-01' }), + }, + }); + } + + /** The response the endpoint produces, through the projected query. */ + async function issueDataOf( + id: number, + fields = SUPPORT_ISSUE_DATA_PROJECTION.fields, + hideLimitRequest = false, + ): Promise { + const issue = await repository.findIssueData(id, fields); + return SupportIssueDtoMapper.mapSupportIssueData(issue, hideLimitRequest); + } + + // --- LEVEL 1: completeness --- // + + it('level 1 — an issue on a buy transaction answers with no empty field', async () => { + const issue = await seedIssue('buyCrypto'); + + expectNoEmptyFields(await issueDataOf(issue.id)); + }, 120000); + + // --- LEVEL 2: variants --- // + + it('level 2 — an issue on a sell transaction reads the other side', async () => { + const issue = await seedIssue('buyFiat'); + + const data = await issueDataOf(issue.id); + + // The fiat side has no output blockchain: its output asset is a currency, not a token. That is + // the one field the two sides do not share, so it is the one exception here. + expect(data.transaction.outputBlockchain).toBeUndefined(); + expectNoEmptyFields(data, ['transaction.outputBlockchain']); + }, 120000); + + it('level 2 — an issue without a transaction answers without a transaction block', async () => { + const issue = await seedIssue('none'); + + const data = await issueDataOf(issue.id); + + // `mapTransactionData` returns undefined when there is no transaction id. Everything the issue + // and the account carry must still be complete — otherwise a left join was written as an inner + // one, and every issue raised without a transaction would 404 or come back empty. + expect(data.transaction).toBeUndefined(); + expectNoEmptyFields(data, ['transaction']); + }, 120000); + + it('level 2 — the limit request is withheld from support staff but the rest is not', async () => { + const issue = await seedIssue('buyCrypto'); + + const hidden = await issueDataOf(issue.id, SUPPORT_ISSUE_DATA_PROJECTION.fields, true); + + // The role decides whether the mapper runs, not what the query loads — so hiding it must not + // take anything else with it. + expect(hidden.limitRequest).toBeUndefined(); + expectNoEmptyFields(hidden, ['limitRequest']); + }, 120000); + + it('level 2 — an issue with no additional information answers without that block', async () => { + const userData = await seedEntity(dataSource, UserData, { + relations: { country: true, language: true }, + }); + const limitRequest = await seedEntity(dataSource, LimitRequest); + const issue = await seedEntity(dataSource, SupportIssue, { + values: { userData, transaction: null, limitRequest, information: null }, + }); + + const data = await issueDataOf(issue.id); + + expect(data.transactionMissing).toBeUndefined(); + expectNoEmptyFields(data, ['transaction', 'transactionMissing']); + }, 120000); + + // --- LEVEL 3: mutation --- // + + // Two response values are fed by a fallback chain, so the chain is the candidate rather than each + // of its columns: `completeName` is `organizationName ?? firstname + surname`, and the wallet name + // is `displayName ?? name`. Dropping any single column leaves the value filled by the next + // alternative — true of every one of them, and therefore no evidence about any of them. + const COMPLETE_NAME_CHAIN = ['userData.organizationName', 'userData.firstname', 'userData.surname']; + const WALLET_NAME_CHAIN = ['transactionUserWallet.displayName', 'transactionUserWallet.name']; + const CHAINED = [...COMPLETE_NAME_CHAIN, ...WALLET_NAME_CHAIN]; + + it.each([ + [ + 'buyCrypto', + 'organization', + [ + ...[ + ...SUPPORT_ISSUE_DATA_ISSUE_FIELDS, + ...SUPPORT_ISSUE_DATA_ACCOUNT_FIELDS, + ...SUPPORT_ISSUE_DATA_LIMIT_REQUEST_FIELDS, + ...SUPPORT_ISSUE_DATA_TRANSACTION_FIELDS.filter((field) => !field.startsWith('buyFiat')), + ].filter((field) => !CHAINED.includes(field)), + COMPLETE_NAME_CHAIN, + WALLET_NAME_CHAIN, + ], + ], + [ + 'buyFiat', + 'organization', + SUPPORT_ISSUE_DATA_TRANSACTION_FIELDS.filter( + (field) => field.startsWith('buyFiat') && field !== 'buyFiatOutputAsset.blockchain', + ), + ], + // The row above asserts the two chains as groups, which is all a fixture that fills the first + // alternative can do. On an account without an organization name and a wallet without a display + // name the fallbacks fire, and the columns behind them become individually required. + ['buyCrypto', 'personal', ['userData.firstname', 'userData.surname', 'transactionUserWallet.name']], + ] as ['buyCrypto' | 'buyFiat', 'organization' | 'personal', (string | string[])[]][])( + 'level 3 — for a %s transaction on a %s account every field feeding the response is required', + async (side, named, candidates) => { + const issue = await seedIssue(side, named); + // The fiat fixture reaches only the fiat side of the mapper, so the crypto fields are asserted + // by the crypto row and vice versa — dropping the other side's fields here would prove nothing. + const optional = side === 'buyFiat' ? ['transaction.outputBlockchain'] : []; + + await expectEveryFieldRequired( + candidates, + (omitted) => issueDataOf(issue.id, projectionFieldsWithout(SUPPORT_ISSUE_DATA_PROJECTION.fields, omitted)), + optional, + ); + }, + 300000, + ); + + // --- LEVEL 4: consistency against a second source --- // + + it.each([ + ['buyCrypto', 'organization'], + ['buyFiat', 'organization'], + ['none', 'organization'], + // Both fallback chains take their second alternative here, which is a different response shape + // than the three rows above produce. + ['buyCrypto', 'personal'], + ] as ['buyCrypto' | 'buyFiat' | 'none', 'organization' | 'personal'][])( + 'level 4 — for a %s issue on a %s account the projected response equals the one from a full load', + async (side, named) => { + const issue = await seedIssue(side, named); + + const projected = await issueDataOf(issue.id); + // The unprojected load is the second source: the same relations, fetching every column of each. + const full = await dataSource.getRepository(SupportIssue).findOne({ + where: { id: issue.id }, + relations: { + userData: { country: true, language: true }, + transaction: { + user: { wallet: true }, + buyCrypto: { outputAsset: true, cryptoInput: { asset: true } }, + buyFiat: { outputAsset: true, cryptoInput: { asset: true } }, + }, + limitRequest: true, + }, + loadEagerRelations: false, + }); + + expect(projected).toEqual(SupportIssueDtoMapper.mapSupportIssueData(full, false)); + }, + 120000, + ); + + // --- the projection must not lose what the endpoint checks before mapping --- // + + it('loads the account id the customer scope is enforced on', async () => { + const issue = await seedIssue('buyCrypto'); + + const loaded = await repository.findIssueData(issue.id); + + // `getIssueData` answers 404 when the issue does not belong to a scoped customer, and reads + // `issue.userData?.id` to decide. Losing it would open every issue to every tenant. + expect(loaded.userData?.id).toEqual(issue.userData.id); + }, 120000); +}); diff --git a/src/subdomains/supporting/support-issue/__tests__/support-issue-list.projection.spec.ts b/src/subdomains/supporting/support-issue/__tests__/support-issue-list.projection.spec.ts new file mode 100644 index 0000000000..c0659f97a4 --- /dev/null +++ b/src/subdomains/supporting/support-issue/__tests__/support-issue-list.projection.spec.ts @@ -0,0 +1,327 @@ +import { + createProjectionDataSource, + describeProjection, + destroyProjectionDataSource, + expectEveryFieldRequired, + expectNoEmptyFields, + projectionFieldsWithout, + seedEntity, +} from 'src/shared/utils/projection-test.util'; +import { UserData } from 'src/subdomains/generic/user/models/user-data/user-data.entity'; +import { + ListOrderDirection, + SupportIssueListOrderBy, +} from 'src/subdomains/supporting/support-issue/dto/get-support-issue.dto'; +import { SupportIssueDtoMapper } from 'src/subdomains/supporting/support-issue/dto/support-issue-dto.mapper'; +import { SupportIssueListDto } from 'src/subdomains/supporting/support-issue/dto/support-issue.dto'; +import { SupportIssue } from 'src/subdomains/supporting/support-issue/entities/support-issue.entity'; +import { SupportMessage } from 'src/subdomains/supporting/support-issue/entities/support-message.entity'; +import { Department } from 'src/subdomains/supporting/support-issue/enums/department.enum'; +import { + SupportIssueInternalState, + SupportIssueReason, + SupportIssueType, +} from 'src/subdomains/supporting/support-issue/enums/support-issue.enum'; +import { + SUPPORT_ISSUE_LIST_PROJECTION, + SUPPORT_ISSUE_LIST_RESPONSE_FIELDS, + SupportIssueListQuery, + SupportIssueRepository, +} from 'src/subdomains/supporting/support-issue/repositories/support-issue.repository'; +import { SupportMessageRepository } from 'src/subdomains/supporting/support-issue/repositories/support-message.repository'; +import { DataSource } from 'typeorm'; + +const SCHEMA = 'support_issue_list_projection_spec'; + +/** + * `GET /support/issue/list` and `GET /realunit/support/list` — the four levels from + * `docs/read-path-projections.md`. + * + * Both answer through `SupportIssueDtoMapper.mapSupportIssueListItem`, over the ten values the row + * shows. + * + * The two endpoints differ only in their scope — one filters by department, the other by a list of + * customer accounts — so both scopes are exercised here. + */ +describeProjection('support issue list — read-path projection', () => { + let dataSource: DataSource; + let issues: SupportIssueRepository; + let messages: SupportMessageRepository; + + beforeAll(async () => { + dataSource = await createProjectionDataSource(SCHEMA); + issues = new SupportIssueRepository(dataSource.manager); + messages = new SupportMessageRepository(dataSource.manager); + }, 300000); + + afterAll(async () => { + await destroyProjectionDataSource(dataSource, SCHEMA); + }); + + const BASE_QUERY: SupportIssueListQuery = { + terms: [], + orderBy: SupportIssueListOrderBy.CREATED, + orderDir: ListOrderDirection.DESC, + }; + + /** + * One issue with every column populated. + * + * `state`, `type`, `reason` and `department` are set explicitly: all four are TypeScript enums in + * text columns, and the list filters on three of them — a generated value is not a member, so a + * filtered query would answer with nothing for a reason that has nothing to do with the + * projection. + */ + async function seedIssue(values: Partial = {}): Promise<{ issue: SupportIssue; userData: UserData }> { + const userData = await seedEntity(dataSource, UserData); + const issue = await seedEntity(dataSource, SupportIssue, { + values: { + userData, + transaction: null, + state: SupportIssueInternalState.IN_PROGRESS, + type: SupportIssueType.TRANSACTION_ISSUE, + reason: SupportIssueReason.FUNDS_NOT_RECEIVED, + department: Department.SUPPORT, + ...values, + }, + }); + return { issue, userData }; + } + + /** + * The response the endpoint produces: the projected page, and the message aggregate the endpoint + * runs over it. Both queries are part of the answer, so both belong in every level. + */ + async function listOf( + query: Partial, + fields = SUPPORT_ISSUE_LIST_PROJECTION.fields, + ): Promise<{ data: SupportIssueListDto[]; total: number }> { + const [rows, total] = await issues.findIssueList({ ...BASE_QUERY, ...query }, fields); + const stats = await messages.findStatsFor(rows.map((row) => row.id)); + + return { + data: rows.map((row) => SupportIssueDtoMapper.mapSupportIssueListItem(row, stats.get(row.id))), + total, + }; + } + + // --- LEVEL 1: completeness --- // + + it('level 1 — a listed issue answers with no empty field', async () => { + const { issue } = await seedIssue(); + // The three message fields of a row come from the aggregate, so a row without messages leaves + // them legitimately empty — the completeness assertion needs one that has them. + await seedEntity(dataSource, SupportMessage, { values: { issue } }); + + const list = await listOf({ departments: [Department.SUPPORT], clerk: issue.clerk }); + + expect(list.data).toHaveLength(1); + expectNoEmptyFields(list); + }, 120000); + + it('level 1 — an issue without messages answers with a zero count and no last message', async () => { + const { issue } = await seedIssue(); + + const [row] = (await listOf({ departments: [Department.SUPPORT], clerk: issue.clerk })).data; + + expect(row.messageCount).toEqual(0); + expect(row.lastMessageDate).toBeUndefined(); + expect(row.lastMessageAuthor).toBeUndefined(); + }, 120000); + + // --- LEVEL 2: variants --- // + + it('level 2 — the customer scope answers only with issues of the scoped accounts', async () => { + const mine = await seedIssue(); + const other = await seedIssue(); + + const list = await listOf({ customerIds: [mine.userData.id] }); + + expect(list.data.map((row) => row.uid)).toEqual([mine.issue.uid]); + expect(list.data.map((row) => row.uid)).not.toContain(other.issue.uid); + }, 120000); + + it('level 2 — the department gate answers only with issues of the visible departments', async () => { + const visible = await seedIssue({ department: Department.SUPPORT }); + const hidden = await seedIssue({ department: Department.COMPLIANCE }); + + const list = await listOf({ departments: [Department.SUPPORT] }); + + expect(list.data.map((row) => row.uid)).toContain(visible.issue.uid); + expect(list.data.map((row) => row.uid)).not.toContain(hidden.issue.uid); + }, 120000); + + it.each([ + SupportIssueListOrderBy.CREATED, + SupportIssueListOrderBy.UPDATED, + SupportIssueListOrderBy.CLERK, + SupportIssueListOrderBy.DEPARTMENT, + SupportIssueListOrderBy.STATE, + ])( + 'level 2 — the list can be sorted by %s', + async (orderBy) => { + // Two rows whose order under every sortable column is the REVERSE of their insertion order, + // so that a query falling back to the id tie-break fails instead of passing by coincidence. + // The clerk differs too and the scope is by account, because ordering by a column the scope + // pins to one value proves nothing. + const clerk = `sort-clerk-${orderBy}`; + const first = await seedIssue({ + clerk: `${clerk}-b`, + created: new Date('2021-01-01T00:00:00.000Z'), + updated: new Date('2021-01-01T00:00:00.000Z'), + department: Department.SUPPORT, + state: SupportIssueInternalState.IN_PROGRESS, + }); + const second = await seedIssue({ + clerk: `${clerk}-a`, + created: new Date('2020-01-01T00:00:00.000Z'), + updated: new Date('2020-01-01T00:00:00.000Z'), + department: Department.COMPLIANCE, + state: SupportIssueInternalState.CREATED, + }); + + // Every sort column has to be part of the projection: with take/skip set, the paginated form of + // getManyAndCount orders a distinct-id subquery by it, and a column the select does not carry + // makes Postgres reject the statement. + const customerIds = [first.userData.id, second.userData.id]; + const page = (orderDir: ListOrderDirection): Promise<{ data: SupportIssueListDto[]; total: number }> => + listOf({ customerIds, orderBy, orderDir, take: 10, skip: 0 }); + + const ascending = await page(ListOrderDirection.ASC); + const descending = await page(ListOrderDirection.DESC); + + expect(ascending.total).toEqual(2); + // Ascending puts the second-inserted row first: the assertion holds only if the requested + // column decided the order. + expect(ascending.data.map((row) => row.uid)).toEqual([second.issue.uid, first.issue.uid]); + expect(descending.data.map((row) => row.uid)).toEqual([first.issue.uid, second.issue.uid]); + }, + 120000, + ); + + it('level 2 — the search matches the fields it names, on the issue and on the account', async () => { + // Scoped to a clerk of its own: the fixtures give every row of a column the same value, so an + // account term would otherwise match whatever else the suite has seeded by then. + const clerk = 'search-branch-clerk'; + const { issue, userData } = await seedIssue({ clerk }); + const found = async (term: string): Promise => + (await listOf({ clerk, terms: [term] })).data.map((row) => row.uid); + + // Each of these reaches the row through a different branch of the search predicate; the account + // branches resolve through a join the projection does not select from. + expect(await found(issue.name)).toEqual([issue.uid]); + expect(await found(issue.uid)).toEqual([issue.uid]); + expect(await found(String(issue.id))).toEqual([issue.uid]); + expect(await found(userData.firstname)).toEqual([issue.uid]); + expect(await found('no-such-term')).toHaveLength(0); + }, 120000); + + it('level 2 — the search finds a term in the message body', async () => { + const { issue } = await seedIssue(); + const message = await seedEntity(dataSource, SupportMessage, { values: { issue } }); + + // This branch is a correlated subquery over another table. Built from a literal table name it + // resolves against the search path rather than the schema the rest of the query runs in. + expect((await listOf({ terms: [message.message] })).data.map((r) => r.uid)).toEqual([issue.uid]); + }, 120000); + + // The id branch of the search predicate is added only when the term is fully numeric AND fits + // int4. Anything above 2^31-1 would make Postgres raise a 22003 range error and fail the whole + // search rather than answer nothing. + it.each([ + ['12345', 'a small numeric term'], + ['2147483647', 'exactly int4 max'], + ['41791234567', 'above int4 max — the phone-number case'], + ['alice', 'a non-numeric term'], + ['alice 12345', 'both shapes at once'], + ])( + 'level 2 — the search answers for %s (%s)', + async (term) => { + // The assertion is that the statement runs at all: without the guard the third row raises 22003 + // and the search rejects instead of answering. + await expect(listOf({ terms: term.split(' ') })).resolves.toBeDefined(); + }, + 120000, + ); + + it('level 2 — the id branch matches by id alone, and only for a term that fits int4', async () => { + const clerk = 'id-branch-clerk'; + const { issue } = await seedIssue({ name: 'no-digits-here', uid: 'uid-without-digits', clerk }); + const scoped = (term: string): Promise<{ data: SupportIssueListDto[]; total: number }> => + listOf({ clerk, terms: [term] }); + + // Scoped to this issue's clerk, so the only candidate row is this one — and none of its text + // fields carries a digit, so a match can only come from `issue.id = :termNId`. + expect((await scoped(String(issue.id))).data.map((row) => row.uid)).toEqual([issue.uid]); + + // At int4 max the branch is still emitted; one above it the guard has to drop the comparison, + // or Postgres raises 22003 and the search fails instead of answering nothing. + expect((await scoped('2147483647')).data).toHaveLength(0); + expect((await scoped('41791234567')).data).toHaveLength(0); + }, 120000); + + it('level 2 — the id tie-break orders rows that share a sort key', async () => { + // Two issues in the same state, sorted by state: the primary sort key is equal for both, so the + // order is decided by the id tie-break alone. Without it the order is whatever storage returns, + // and a page boundary can drop or repeat a row. + const first = await seedIssue({ clerk: 'tie-break', state: SupportIssueInternalState.IN_PROGRESS }); + const second = await seedIssue({ clerk: 'tie-break', state: SupportIssueInternalState.IN_PROGRESS }); + + const ascending = await listOf({ + clerk: 'tie-break', + orderBy: SupportIssueListOrderBy.STATE, + orderDir: ListOrderDirection.ASC, + }); + const descending = await listOf({ + clerk: 'tie-break', + orderBy: SupportIssueListOrderBy.STATE, + orderDir: ListOrderDirection.DESC, + }); + + expect(ascending.data.map((row) => row.id)).toEqual([first.issue.id, second.issue.id]); + expect(descending.data.map((row) => row.id)).toEqual([second.issue.id, first.issue.id]); + }, 120000); + + it('level 2 — the total counts every match, not just the page', async () => { + const first = await seedIssue({ clerk: 'page-clerk' }); + await seedIssue({ clerk: 'page-clerk' }); + + const list = await listOf({ clerk: 'page-clerk', take: 1, skip: 0, orderDir: ListOrderDirection.ASC }); + + expect(list.data).toHaveLength(1); + expect(list.data[0].uid).toEqual(first.issue.uid); + expect(list.total).toEqual(2); + }, 120000); + + // --- LEVEL 3: mutation --- // + + it('level 3 — every field feeding the list response is required', async () => { + const { issue } = await seedIssue(); + await seedEntity(dataSource, SupportMessage, { values: { issue } }); + + await expectEveryFieldRequired(SUPPORT_ISSUE_LIST_RESPONSE_FIELDS, (omitted) => + listOf( + { departments: [Department.SUPPORT], clerk: issue.clerk }, + projectionFieldsWithout(SUPPORT_ISSUE_LIST_PROJECTION.fields, omitted), + ), + ); + }, 300000); + + // --- LEVEL 4: consistency against a second source --- // + + it('level 4 — the projected response equals the one from a full load', async () => { + const { issue } = await seedIssue(); + await seedEntity(dataSource, SupportMessage, { values: { issue } }); + + const projected = await listOf({ departments: [Department.SUPPORT], clerk: issue.clerk }); + // The unprojected load is the second source: every column of the row. + const full = await dataSource + .getRepository(SupportIssue) + .find({ where: { clerk: issue.clerk }, loadEagerRelations: false }); + + const stats = await messages.findStatsFor(full.map((row) => row.id)); + expect(projected.data).toEqual( + full.map((row) => SupportIssueDtoMapper.mapSupportIssueListItem(row, stats.get(row.id))), + ); + }, 120000); +}); diff --git a/src/subdomains/supporting/support-issue/__tests__/support-issue-view.projection.spec.ts b/src/subdomains/supporting/support-issue/__tests__/support-issue-view.projection.spec.ts new file mode 100644 index 0000000000..df6a7ab1ae --- /dev/null +++ b/src/subdomains/supporting/support-issue/__tests__/support-issue-view.projection.spec.ts @@ -0,0 +1,204 @@ +import { + SUPPORT_ISSUE_PROJECTION, + SUPPORT_ISSUE_RESPONSE_FIELDS, + SupportIssueRepository, +} from 'src/subdomains/supporting/support-issue/repositories/support-issue.repository'; +import { + SUPPORT_MESSAGE_PROJECTION, + SUPPORT_MESSAGE_RESPONSE_FIELDS, + SupportMessageRepository, +} from 'src/subdomains/supporting/support-issue/repositories/support-message.repository'; +import { SupportIssue } from 'src/subdomains/supporting/support-issue/entities/support-issue.entity'; +import { SupportIssueDtoMapper } from 'src/subdomains/supporting/support-issue/dto/support-issue-dto.mapper'; +import { SupportMessage } from 'src/subdomains/supporting/support-issue/entities/support-message.entity'; +import { LimitRequest } from 'src/subdomains/supporting/support-issue/entities/limit-request.entity'; +import { Transaction } from 'src/subdomains/supporting/payment/entities/transaction.entity'; +import { SupportIssueInternalState } from 'src/subdomains/supporting/support-issue/enums/support-issue.enum'; +import { UserData } from 'src/subdomains/generic/user/models/user-data/user-data.entity'; +import { + createProjectionDataSource, + describeProjection, + destroyProjectionDataSource, + expectEveryFieldRequired, + expectNoEmptyFields, + projectionFieldsWithout, + seedEntity, +} from 'src/shared/utils/projection-test.util'; +import { ConfigService } from 'src/config/config'; +import { DataSource } from 'typeorm'; + +const SCHEMA = 'support_issue_view_projection_spec'; + +/** + * `GET /support/issue` and `GET /support/issue/:id` — the four levels from + * `docs/read-path-projections.md`. + * + * Both answer through `SupportIssueDtoMapper.mapSupportIssue`, over the `SupportIssue` + * rows for nine values. `GET /support/issue/:id` additionally loads the message + * thread, which is projected separately. + * + * The search condition of `GET /support/issue/:id` is the access check for this endpoint family, so + * it gets assertions of its own below. + */ +describeProjection('support issue view — read-path projection', () => { + let dataSource: DataSource; + let issues: SupportIssueRepository; + let messages: SupportMessageRepository; + + beforeAll(async () => { + // `Transaction.url` is a getter over the module-level Config; without this it reads undefined + // and the mapper throws before any assertion is reached. + new ConfigService(); + dataSource = await createProjectionDataSource(SCHEMA); + issues = new SupportIssueRepository(dataSource.manager); + messages = new SupportMessageRepository(dataSource.manager); + }, 300000); + + afterAll(async () => { + await destroyProjectionDataSource(dataSource, SCHEMA); + }); + + async function seedIssue( + withTransaction = true, + state = SupportIssueInternalState.IN_PROGRESS, + ): Promise<{ issue: SupportIssue; userData: UserData }> { + const userData = await seedEntity(dataSource, UserData); + const transaction = withTransaction + ? await seedEntity(dataSource, Transaction, { values: { userData } }) + : null; + const limitRequest = await seedEntity(dataSource, LimitRequest); + // `state` is a TypeScript enum in a text column: a generated value is not a member, and the + // mapper that translates it to the public state then answers undefined. + const issue = await seedEntity(dataSource, SupportIssue, { + values: { userData, transaction, limitRequest, state }, + }); + return { issue, userData }; + } + + // --- LEVEL 1: completeness --- // + + it('level 1 — the issue list answers with no empty field', async () => { + const { userData } = await seedIssue(); + + const list = (await issues.findIssuesForAccount(userData.id)).map(SupportIssueDtoMapper.mapSupportIssue); + + expect(list).toHaveLength(1); + // The list endpoint does not load the thread — `mapSupportIssue` falls back to an empty array. + expectNoEmptyFields(list, ['[0].messages']); + }, 120000); + + it('level 1 — a single issue answers with no empty field, thread included', async () => { + const { issue } = await seedIssue(); + await seedEntity(dataSource, SupportMessage, { values: { issue } }); + + const loaded = await issues.findIssueBy({ uid: issue.uid }); + loaded.messages = await messages.findThread(loaded.id); + + expectNoEmptyFields(SupportIssueDtoMapper.mapSupportIssue(loaded)); + }, 120000); + + // --- LEVEL 2: variants --- // + + it('level 2 — an issue without a transaction answers without one', async () => { + const { issue } = await seedIssue(false); + await seedEntity(dataSource, SupportMessage, { values: { issue } }); + + const loaded = await issues.findIssueBy({ uid: issue.uid }); + loaded.messages = await messages.findThread(loaded.id); + const dto = SupportIssueDtoMapper.mapSupportIssue(loaded); + + // `mapTransaction` answers null without a transaction id. The rest must stay complete, or the + // left join was written as an inner one and every issue raised without a transaction vanishes. + expect(dto.transaction).toBeNull(); + expectNoEmptyFields(dto, ['transaction']); + }, 120000); + + it('level 2 — the thread returns only messages newer than the given id', async () => { + const { issue } = await seedIssue(); + const first = await seedEntity(dataSource, SupportMessage, { values: { issue } }); + const second = await seedEntity(dataSource, SupportMessage, { values: { issue } }); + + const incremental = await messages.findThread(issue.id, first.id); + + expect(incremental.map((message) => message.id)).toEqual([second.id]); + }, 120000); + + // --- the search condition is the access check, so it gets its own assertions --- // + + it('finds an issue by its uid, and by numeric id scoped to the owning account', async () => { + const { issue, userData } = await seedIssue(); + const stranger = await seedEntity(dataSource, UserData); + + expect((await issues.findIssueBy({ uid: issue.uid }))?.id).toEqual(issue.id); + expect((await issues.findIssueBy({ id: issue.id, userData: { id: userData.id } }))?.id).toEqual(issue.id); + // Scoped to a different account it must not resolve — the projection must not have widened the + // condition by dropping the join it rests on. + expect(await issues.findIssueBy({ id: issue.id, userData: { id: stranger.id } })).toBeNull(); + }, 120000); + + it('finds an issue by the uid of the quote behind it', async () => { + // `transactionRequest` is nullable, so it has to be asked for explicitly. + const userData = await seedEntity(dataSource, UserData); + const limitRequest = await seedEntity(dataSource, LimitRequest); + const issue = await seedEntity(dataSource, SupportIssue, { + values: { userData, transaction: null, limitRequest, state: SupportIssueInternalState.IN_PROGRESS }, + relations: { transactionRequest: true }, + }); + + // The third branch of the search condition resolves through `transactionRequest`, a relation the + // projection does not join for the response. `setFindOptions` has to add it. + expect(issue.transactionRequest).toBeDefined(); + const found = await issues.findIssueBy({ transactionRequest: { uid: issue.transactionRequest.uid } }); + expect(found?.id).toEqual(issue.id); + }, 120000); + + // --- LEVEL 3: mutation --- // + + it('level 3 — every field feeding the issue response is required', async () => { + const { issue } = await seedIssue(); + await seedEntity(dataSource, SupportMessage, { values: { issue } }); + + await expectEveryFieldRequired(SUPPORT_ISSUE_RESPONSE_FIELDS, async (omitted) => { + const loaded = await issues.findIssueBy( + { uid: issue.uid }, + projectionFieldsWithout(SUPPORT_ISSUE_PROJECTION.fields, omitted), + ); + loaded.messages = await messages.findThread(loaded.id); + return SupportIssueDtoMapper.mapSupportIssue(loaded); + }); + }, 300000); + + it('level 3 — every field feeding a message is required', async () => { + const { issue } = await seedIssue(); + await seedEntity(dataSource, SupportMessage, { values: { issue } }); + + await expectEveryFieldRequired(SUPPORT_MESSAGE_RESPONSE_FIELDS, (omitted) => + messages + .findThread(issue.id, 0, projectionFieldsWithout(SUPPORT_MESSAGE_PROJECTION.fields, omitted)) + .then((thread) => thread.map(SupportIssueDtoMapper.mapSupportMessage)), + ); + }, 300000); + + // --- LEVEL 4: consistency against a second source --- // + + it.each([true, false])( + 'level 4 — with transaction=%s the projected response equals the one from a full load', + async (withTransaction) => { + const { issue } = await seedIssue(withTransaction); + await seedEntity(dataSource, SupportMessage, { values: { issue } }); + + const loaded = await issues.findIssueBy({ uid: issue.uid }); + loaded.messages = await messages.findThread(loaded.id); + + // The unprojected load is the second source: the same relations selected whole. + const full = await dataSource.getRepository(SupportIssue).findOne({ + where: { uid: issue.uid }, + relations: { transaction: true, limitRequest: true }, + }); + full.messages = await dataSource.getRepository(SupportMessage).findBy({ issue: { id: issue.id } }); + + expect(SupportIssueDtoMapper.mapSupportIssue(loaded)).toEqual(SupportIssueDtoMapper.mapSupportIssue(full)); + }, + 120000, + ); +}); diff --git a/src/subdomains/supporting/support-issue/repositories/support-issue.repository.ts b/src/subdomains/supporting/support-issue/repositories/support-issue.repository.ts index af670364e9..17138e9c82 100644 --- a/src/subdomains/supporting/support-issue/repositories/support-issue.repository.ts +++ b/src/subdomains/supporting/support-issue/repositories/support-issue.repository.ts @@ -1,11 +1,373 @@ import { Injectable } from '@nestjs/common'; +import { ReadProjection } from 'src/shared/models/read-projection'; import { BaseRepository } from 'src/shared/repositories/base.repository'; -import { EntityManager } from 'typeorm'; +import { EntityManager, FindOptionsWhere } from 'typeorm'; +import { + ListOrderDirection, + SupportIssueListOrderBy, +} from 'src/subdomains/supporting/support-issue/dto/get-support-issue.dto'; import { SupportIssue } from '../entities/support-issue.entity'; +import { SupportMessage } from 'src/subdomains/supporting/support-issue/entities/support-message.entity'; +import { Department } from 'src/subdomains/supporting/support-issue/enums/department.enum'; +import { + SupportIssueInternalState, + SupportIssueType, +} from 'src/subdomains/supporting/support-issue/enums/support-issue.enum'; + +/** The fields `CountryDtoMapper.entityToDto` reads, for a given join alias. */ +const countryFields = (alias: string): string[] => + [ + 'id', + 'symbol', + 'name', + 'foreignName', + 'ipEnable', + 'fatfEnable', + 'dfxEnable', + 'dfxOrganizationEnable', + 'nationalityStepEnable', + 'bankEnable', + 'checkoutEnable', + 'cryptoEnable', + ].map((field) => `${alias}.${field}`); + +/** + * The fields a transaction contributes, for either of the two sides. + * + * `mapTransactionData` reads `buyCrypto ?? buyFiat`, so both are joined and both carry the same + * shape — except for the output asset, which is an `Asset` on one side and a `Fiat` on the other: + * only the crypto side has a blockchain to report. + */ +const transactionSideFields = (side: string, inputAsset: string, outputAsset: string): string[] => [ + `${side}.amlReason`, + `${side}.comment`, + `${side}.inputAmount`, + `${side}.inputAsset`, + `${side}.outputAmount`, + `${side}.isComplete`, + `${inputAsset}.blockchain`, + `${outputAsset}.name`, +]; + +/** What the issue itself contributes, including the JSON column behind `additionalInformation`. */ +export const SUPPORT_ISSUE_DATA_ISSUE_FIELDS = [ + 'supportIssue.id', + 'supportIssue.created', + 'supportIssue.uid', + 'supportIssue.type', + 'supportIssue.department', + 'supportIssue.reason', + 'supportIssue.state', + 'supportIssue.name', + 'supportIssue.clerk', + // `additionalInformation` is a getter over this column; the mapper reads the parsed object. + 'supportIssue.information', +]; + +/** What `SupportIssueDtoMapper.mapUserData` reads. */ +export const SUPPORT_ISSUE_DATA_ACCOUNT_FIELDS = [ + 'userData.id', + 'userData.status', + 'userData.verifiedName', + // `completeName` is a getter: organizationName, falling back to firstname and surname. + 'userData.organizationName', + 'userData.firstname', + 'userData.surname', + 'userData.accountType', + 'userData.kycLevel', + 'userData.depositLimit', + 'userData.annualBuyVolume', + 'userData.annualSellVolume', + 'userData.annualCryptoVolume', + 'userData.kycHash', + ...countryFields('userDataCountry'), + 'userDataLanguage.id', + 'userDataLanguage.name', + 'userDataLanguage.symbol', + 'userDataLanguage.foreignName', + 'userDataLanguage.enable', +]; + +/** What `SupportIssueDtoMapper.mapTransactionData` reads. */ +export const SUPPORT_ISSUE_DATA_TRANSACTION_FIELDS = [ + 'transaction.id', + 'transaction.sourceType', + 'transaction.type', + 'transaction.amlCheck', + ...transactionSideFields('buyCrypto', 'buyCryptoInputAsset', 'buyCryptoOutputAsset'), + // Only the crypto side reports an output blockchain — the fiat side's output asset is a currency. + 'buyCryptoOutputAsset.blockchain', + ...transactionSideFields('buyFiat', 'buyFiatInputAsset', 'buyFiatOutputAsset'), + 'transactionUserWallet.displayName', + 'transactionUserWallet.name', + 'transactionUserWallet.amlRules', + 'transactionUserWallet.isKycClient', +]; + +/** + * What `SupportIssueDtoMapper.mapLimitRequestData` reads. + * + * Withheld from support and tenant staff by the endpoint, but the field list does not depend on the + * role: the query is the same and the mapper is skipped. + */ +export const SUPPORT_ISSUE_DATA_LIMIT_REQUEST_FIELDS = [ + 'limitRequest.id', + 'limitRequest.fundOrigin', + 'limitRequest.investmentDate', + 'limitRequest.limit', + 'limitRequest.acceptedLimit', + 'limitRequest.decision', +]; + +/** + * What `SupportIssueDtoMapper.mapSupportIssue` reads — the customer-facing view of an issue. + * + * `issueTransaction.uid` covers both values the transaction contributes: `url` is a getter over it. + * Messages are not part of this; they are loaded separately and projected by + * `SUPPORT_MESSAGE_RESPONSE_FIELDS`. + */ +export const SUPPORT_ISSUE_RESPONSE_FIELDS = [ + // `mapTransaction` decides between a transaction object and `null` on this, so it determines the + // response even though it is never shown. + 'issueTransaction.id', + 'supportIssue.uid', + 'supportIssue.state', + 'supportIssue.type', + 'supportIssue.reason', + 'supportIssue.name', + 'supportIssue.created', + 'issueTransaction.uid', + 'issueLimitRequest.id', + 'issueLimitRequest.limit', +]; + +/** + * `GET /support/issue` and `GET /support/issue/:id` — nine values. + * + * `supportIssue.id` is a guard rather than a response field: the mapper never shows it and no value + * depends on it, but `getIssue` loads the message thread by it afterwards. + */ +export const SUPPORT_ISSUE_PROJECTION = new ReadProjection( + 'supportIssue', + [ + ['supportIssue.transaction', 'issueTransaction'], + ['supportIssue.limitRequest', 'issueLimitRequest'], + ], + SUPPORT_ISSUE_RESPONSE_FIELDS, + ['supportIssue.id'], +); + +/** + * `GET /support/issue/:id/data` — the widest read path in the service. + * + * The unprojected load fetches the whole graph: the issue's four eager relations expand recursively, + * and the transaction pulls in both of its sides with their inputs and assets. The response is + * about sixty values. + */ +export const SUPPORT_ISSUE_DATA_PROJECTION = new ReadProjection( + 'supportIssue', + [ + ['supportIssue.userData', 'userData'], + ['userData.country', 'userDataCountry'], + ['userData.language', 'userDataLanguage'], + ['supportIssue.transaction', 'transaction'], + ['transaction.user', 'transactionUser'], + ['transactionUser.wallet', 'transactionUserWallet'], + ['transaction.buyCrypto', 'buyCrypto'], + ['buyCrypto.outputAsset', 'buyCryptoOutputAsset'], + ['buyCrypto.cryptoInput', 'buyCryptoInput'], + ['buyCryptoInput.asset', 'buyCryptoInputAsset'], + ['transaction.buyFiat', 'buyFiat'], + ['buyFiat.outputAsset', 'buyFiatOutputAsset'], + ['buyFiat.cryptoInput', 'buyFiatInput'], + ['buyFiatInput.asset', 'buyFiatInputAsset'], + ['supportIssue.limitRequest', 'limitRequest'], + ], + [ + ...SUPPORT_ISSUE_DATA_ISSUE_FIELDS, + ...SUPPORT_ISSUE_DATA_ACCOUNT_FIELDS, + ...SUPPORT_ISSUE_DATA_TRANSACTION_FIELDS, + ...SUPPORT_ISSUE_DATA_LIMIT_REQUEST_FIELDS, + ], + // Never part of the response: the primary keys that make the ORM materialise the joined rows. + // `transaction.id`, `userData.id` and `limitRequest.id` are response fields already, and the + // mapper uses two of them to decide whether the relation is there at all. + [ + 'transactionUser.id', + 'transactionUserWallet.id', + 'buyCrypto.id', + 'buyCryptoInput.id', + 'buyCryptoInputAsset.id', + 'buyCryptoOutputAsset.id', + 'buyFiat.id', + 'buyFiatInput.id', + 'buyFiatInputAsset.id', + 'buyFiatOutputAsset.id', + ], +); + +/** What `SupportIssueDtoMapper.mapSupportIssueListItem` reads off the issue itself. */ +export const SUPPORT_ISSUE_LIST_RESPONSE_FIELDS = [ + 'issue.id', + 'issue.uid', + 'issue.type', + 'issue.reason', + 'issue.state', + 'issue.name', + 'issue.clerk', + 'issue.department', + 'issue.created', + 'issue.updated', +]; + +/** + * `GET /support/issue/list` and `GET /realunit/support/list` — the ten values the row shows. + * + * The six it drops are the five foreign keys and `information`, an unbounded `text` column holding + * the free-form body of the issue, which the list does not show. + * + * Every column `SupportIssueListOrderBy` allows is in the list above, which the query needs: the + * paginated form of `getManyAndCount` orders a distinct-id subquery by the sort column. + */ +export const SUPPORT_ISSUE_LIST_PROJECTION = new ReadProjection( + 'issue', + [], + SUPPORT_ISSUE_LIST_RESPONSE_FIELDS, +); + +/** + * The already-authorised shape of a list request. + * + * `departments` and `customerIds` are the two scopes the endpoint can be called under, resolved + * from the role before they get here — this is the query, not the access decision. + */ +export interface SupportIssueListQuery { + departments?: Department[]; + customerIds?: number[]; + states?: SupportIssueInternalState[]; + type?: SupportIssueType; + clerk?: string; + createdFrom?: Date; + createdTo?: Date; + /** Search terms, already split and trimmed. Each must match at least one field. */ + terms: string[]; + orderBy: SupportIssueListOrderBy; + orderDir: ListOrderDirection; + take?: number; + skip?: number; +} @Injectable() export class SupportIssueRepository extends BaseRepository { constructor(manager: EntityManager) { super(SupportIssue, manager); } + + /** + * The issue list, with the page and the unpaged total. + * + * `fields` is what the mutation test in `support-issue-list.projection.spec.ts` re-runs the query + * with; `SupportIssueService.getSupportIssueList` calls this without it. + */ + async findIssueList( + query: SupportIssueListQuery, + fields: ReadonlyArray = SUPPORT_ISSUE_LIST_PROJECTION.fields, + ): Promise<[SupportIssue[], number]> { + const qb = SUPPORT_ISSUE_LIST_PROJECTION.apply(this.createQueryBuilder('issue'), fields); + + // The search predicate and the customer scope both need the account; they share one alias. + if (query.terms.length > 0 || query.customerIds) qb.leftJoin('issue.userData', 'userData'); + + // The customer scope replaces the department gate rather than adding to it. With a left join, + // an issue without an account is never IN the scope list, so it fails closed. + if (query.customerIds) qb.andWhere('"userData".id IN (:...customerIds)', { customerIds: query.customerIds }); + else if (query.departments) + qb.andWhere('issue.department IN (:...departments)', { departments: query.departments }); + + if (query.states?.length) qb.andWhere('issue.state IN (:...states)', { states: query.states }); + if (query.type) qb.andWhere('issue.type = :type', { type: query.type }); + if (query.clerk) qb.andWhere('issue.clerk = :clerk', { clerk: query.clerk }); + if (query.createdFrom) qb.andWhere('issue.created >= :createdFrom', { createdFrom: query.createdFrom }); + if (query.createdTo) qb.andWhere('issue.created <= :createdTo', { createdTo: query.createdTo }); + + for (let i = 0; i < query.terms.length; i++) { + const param = `term${i}`; + // Only emit the id branch when the term is fully numeric AND fits int4 (Postgres rejects + // larger values with 22003, which would fail the whole search rather than answer nothing). + // Keeps the predicate on the PK index (no cast-to-text) and avoids partial-match surprises + // (term "42" doesn't match id 142). + const numeric = +query.terms[i]; + const idTerm = /^\d+$/.test(query.terms[i]) && numeric <= 2147483647 ? numeric : null; + const idClause = idTerm != null ? ` OR issue.id = :${param}Id` : ''; + // The message branch goes through the query builder rather than a literal table name, so the + // table is resolved the way the ORM resolves every other one — a bare `support_message` is + // looked up against the search path instead. + const messageMatch = qb + .subQuery() + .select('1') + .from(SupportMessage, 'message') + .where('message."issueId" = issue.id') + .andWhere(`message.message LIKE :${param}`) + .getQuery(); + qb.andWhere( + `(issue.name LIKE :${param} OR issue.uid LIKE :${param} OR issue.clerk LIKE :${param} OR "userData".firstname LIKE :${param} OR "userData".surname LIKE :${param} OR "userData"."organizationName" LIKE :${param} OR EXISTS ${messageMatch}${idClause})`, + { [param]: `%${query.terms[i]}%`, ...(idTerm != null ? { [`${param}Id`]: idTerm } : {}) }, + ); + } + + // Whitelisted sort column and direction, with an id tie-break for stable pagination on equal + // sort keys. + qb.orderBy(`issue.${query.orderBy}`, query.orderDir); + qb.addOrderBy('issue.id', query.orderDir); + + if (query.take != null) { + qb.take(query.take); + if (query.skip != null) qb.skip(query.skip); + } + + return qb.getManyAndCount(); + } + + /** + * Loads exactly what the internal issue view needs. + * + * `fields` is what the mutation test in `support-issue-data.projection.spec.ts` re-runs the query + * with; `SupportIssueService.getIssueData` calls this without it. + */ + async findIssueData( + id: number, + fields: ReadonlyArray = SUPPORT_ISSUE_DATA_PROJECTION.fields, + ): Promise { + return SUPPORT_ISSUE_DATA_PROJECTION.apply(this.createQueryBuilder('supportIssue'), fields) + .where('supportIssue.id = :id', { id }) + .getOne(); + } + + /** An account's own issues, loaded with the customer-facing fields only. */ + async findIssuesForAccount( + userDataId: number, + fields: ReadonlyArray = SUPPORT_ISSUE_PROJECTION.fields, + ): Promise { + return SUPPORT_ISSUE_PROJECTION.apply(this.createQueryBuilder('supportIssue'), fields) + .leftJoin('supportIssue.userData', 'issueUserData') + .where('issueUserData.id = :userDataId', { userDataId }) + .getMany(); + } + + /** + * A single issue, found by the caller's search condition. + * + * The condition is passed through rather than rebuilt here on purpose: it is the access check for + * this endpoint family — an issue is reachable by its UID, by the UID of the quote behind it, or + * by numeric id scoped to the owning account — and stating it twice is how the two copies drift + * apart. `setFindOptions` applies it to the same query builder that carries the projection. + */ + async findIssueBy( + search: FindOptionsWhere, + fields: ReadonlyArray = SUPPORT_ISSUE_PROJECTION.fields, + ): Promise { + return SUPPORT_ISSUE_PROJECTION.apply(this.createQueryBuilder('supportIssue'), fields) + .setFindOptions({ where: search, loadEagerRelations: false }) + .getOne(); + } } diff --git a/src/subdomains/supporting/support-issue/repositories/support-message.repository.ts b/src/subdomains/supporting/support-issue/repositories/support-message.repository.ts index c3ef3dbaf8..d36d4bcaa4 100644 --- a/src/subdomains/supporting/support-issue/repositories/support-message.repository.ts +++ b/src/subdomains/supporting/support-issue/repositories/support-message.repository.ts @@ -1,11 +1,115 @@ import { Injectable } from '@nestjs/common'; +import { ReadProjection } from 'src/shared/models/read-projection'; import { BaseRepository } from 'src/shared/repositories/base.repository'; -import { EntityManager } from 'typeorm'; +import { Util } from 'src/shared/utils/util'; +import { EntityManager, SelectQueryBuilder } from 'typeorm'; import { SupportMessage } from '../entities/support-message.entity'; +/** + * What `SupportIssueDtoMapper.mapSupportMessage` reads. + * + * `fileName` is a getter over `fileUrl`, so the column is what has to be selected. Naming the getter + * here does not fail loudly: the ORM does not recognise it as a column, passes the expression + * through unquoted, and Postgres then rejects the statement with a missing FROM-clause entry for a + * lower-cased table name that appears nowhere in the query. + */ +export const SUPPORT_MESSAGE_RESPONSE_FIELDS = [ + 'supportMessage.id', + 'supportMessage.author', + 'supportMessage.created', + 'supportMessage.message', + 'supportMessage.fileUrl', +]; + +/** What the list rows show about the messages of an issue. */ +export interface SupportMessageStats { + count: number; + lastDate?: Date; + lastAuthor?: string; +} + +/** + * The message thread of an issue. + * + * Loaded on its own rather than as a relation of the issue. + */ +export const SUPPORT_MESSAGE_PROJECTION = new ReadProjection( + 'supportMessage', + [['supportMessage.issue', 'messageIssue']], + SUPPORT_MESSAGE_RESPONSE_FIELDS, +); + @Injectable() export class SupportMessageRepository extends BaseRepository { constructor(manager: EntityManager) { super(SupportMessage, manager); } + + /** + * The messages of an issue, newer than `fromMessageId`. + * + * `fields` is what the mutation test in `support-issue-view.projection.spec.ts` re-runs the query + * with; `SupportIssueService.getIssue` calls this without it. + */ + async findThread( + issueId: number, + fromMessageId = 0, + fields: ReadonlyArray = SUPPORT_MESSAGE_PROJECTION.fields, + ): Promise { + return ( + SUPPORT_MESSAGE_PROJECTION.apply(this.createQueryBuilder('supportMessage'), fields) + .where('messageIssue.id = :issueId', { issueId }) + // Written out rather than as `{ id: MoreThan(...) }`: the object form is resolved against the + // find-options alias, not the query builder's, and the statement then refers to a table that + // is not in its FROM clause. + .andWhere('supportMessage.id > :fromMessageId', { fromMessageId }) + .getMany() + ); + } + + /** + * Message count, last date and last author per issue, for the list rows. + * + * Its own aggregate rather than part of the list query: the list is paginated, and joining the + * messages would multiply the rows before the page is cut. + */ + async findStatsFor(issueIds: number[]): Promise> { + if (issueIds.length === 0) return new Map(); + + // The newest message per issue, as a correlated subquery. One factory for the two columns the + // row shows, so the two subqueries cannot drift apart. + const lastOf = + ( + column: 'created' | 'author', + ): ((sub: SelectQueryBuilder) => SelectQueryBuilder) => + (sub: SelectQueryBuilder): SelectQueryBuilder => + sub + .select(`m2.${column}`) + .from(SupportMessage, 'm2') + .where('m2."issueId" = m."issueId"') + .orderBy('m2.id', 'DESC') + .limit(1); + + // Batched to stay below the parameter limit of a single statement. + const rows = await Util.doInBatchesAndJoin( + issueIds, + (chunk): Promise<{ issueId: string; count: string; lastDate: Date | null; lastAuthor: string | null }[]> => + this.createQueryBuilder('m') + .select('m."issueId"', 'issueId') + .addSelect('COUNT(*)', 'count') + .addSelect(lastOf('created'), 'lastDate') + .addSelect(lastOf('author'), 'lastAuthor') + .where('m."issueId" IN (:...ids)', { ids: chunk }) + .groupBy('m."issueId"') + .getRawMany(), + 1000, + ); + + return new Map( + rows.map((row) => [ + +row.issueId, + { count: +row.count, lastDate: row.lastDate ?? undefined, lastAuthor: row.lastAuthor ?? undefined }, + ]), + ); + } } diff --git a/src/subdomains/supporting/support-issue/services/__tests__/support-issue.service.spec.ts b/src/subdomains/supporting/support-issue/services/__tests__/support-issue.service.spec.ts index 66fb4635f9..ab97a12f88 100644 --- a/src/subdomains/supporting/support-issue/services/__tests__/support-issue.service.spec.ts +++ b/src/subdomains/supporting/support-issue/services/__tests__/support-issue.service.spec.ts @@ -26,7 +26,10 @@ import { SupportIssueType, } from 'src/subdomains/supporting/support-issue/enums/support-issue.enum'; import { SupportLogType } from 'src/subdomains/supporting/support-issue/enums/support-log.enum'; -import { SupportIssueRepository } from 'src/subdomains/supporting/support-issue/repositories/support-issue.repository'; +import { + SupportIssueListQuery, + SupportIssueRepository, +} from 'src/subdomains/supporting/support-issue/repositories/support-issue.repository'; import { SupportMessageRepository } from 'src/subdomains/supporting/support-issue/repositories/support-message.repository'; import { LimitRequestService } from 'src/subdomains/supporting/support-issue/services/limit-request.service'; import { SupportDocumentService } from 'src/subdomains/supporting/support-issue/services/support-document.service'; @@ -36,39 +39,27 @@ import { SupportLogService } from 'src/subdomains/supporting/support-issue/servi import { REALUNIT_WALLET_NAME } from 'src/subdomains/supporting/notification/realunit-mail-rules'; import { Wallet } from 'src/subdomains/generic/user/models/wallet/wallet.entity'; import { CreateSupportIssueDto } from 'src/subdomains/supporting/support-issue/dto/create-support-issue.dto'; -import { SupportIssueDto } from 'src/subdomains/supporting/support-issue/dto/support-issue.dto'; +import { SupportIssueDto, SupportIssueListDto } from 'src/subdomains/supporting/support-issue/dto/support-issue.dto'; describe('SupportIssueService.getSupportIssueList', () => { let service: SupportIssueService; let supportIssueRepo: DeepMocked; - let qb: Record; - - // chainable query-builder recorder: every builder method returns the same object, - // getManyAndCount short-circuits getMessageStats (empty result set). - function createQbMock(): Record { - const builder: Record = {}; - for (const method of ['leftJoin', 'andWhere', 'orderBy', 'addOrderBy', 'take', 'skip']) { - builder[method] = jest.fn(() => builder); - } - builder.getManyAndCount = jest.fn().mockResolvedValue([[], 0]); - return builder; - } - const run = (filter: Partial, role: UserRole = UserRole.ADMIN) => + const run = ( + filter: Partial, + role: UserRole = UserRole.ADMIN, + ): Promise<{ data: SupportIssueListDto[]; total: number }> => service.getSupportIssueList(filter as GetSupportIssueListFilter, role); - const andWhereClauses = (): string[] => qb.andWhere.mock.calls.map((c) => String(c[0])); - - // the department parameter handed to the "issue.department IN (:...departments)" clause (undefined if absent) - const departmentsParam = (): Department[] | undefined => { - const call = qb.andWhere.mock.calls.find((c) => String(c[0]).includes('issue.department IN')); - return call?.[1]?.departments as Department[] | undefined; - }; + // What the service resolved the request into. The query itself is built and tested against a real + // database in support-issue-list.projection.spec.ts; what belongs here is the decision that + // precedes it — which departments the role may see, and how the filter is normalised. + const query = (): SupportIssueListQuery => + (supportIssueRepo.findIssueList as jest.Mock).mock.calls[0][0] as SupportIssueListQuery; beforeEach(() => { - qb = createQbMock(); supportIssueRepo = createMock(); - (supportIssueRepo.createQueryBuilder as jest.Mock).mockReturnValue(qb); + (supportIssueRepo.findIssueList as jest.Mock).mockResolvedValue([[], 0]); service = new SupportIssueService( supportIssueRepo, @@ -87,56 +78,51 @@ describe('SupportIssueService.getSupportIssueList', () => { }); describe('clerk filter', () => { - it('filters by clerk when provided', async () => { + it('passes the clerk through when provided', async () => { await run({ clerk: 'Alice' }); - expect(qb.andWhere).toHaveBeenCalledWith('issue.clerk = :clerk', { clerk: 'Alice' }); + expect(query().clerk).toEqual('Alice'); }); - it('does not add a clerk clause when absent', async () => { + it('leaves the clerk unset when absent', async () => { await run({}); - expect(andWhereClauses().some((c) => c.includes('issue.clerk ='))).toBe(false); + expect(query().clerk).toBeUndefined(); }); }); describe('timeframe filter', () => { - it('filters by createdFrom as a Date lower bound', async () => { + it('passes createdFrom through as a Date lower bound', async () => { await run({ createdFrom: '2026-01-01T00:00:00.000Z' }); - expect(qb.andWhere).toHaveBeenCalledWith('issue.created >= :createdFrom', { - createdFrom: new Date('2026-01-01T00:00:00.000Z'), - }); + expect(query().createdFrom).toEqual(new Date('2026-01-01T00:00:00.000Z')); }); - it('filters by createdTo as a Date upper bound', async () => { + it('passes createdTo through as a Date upper bound', async () => { await run({ createdTo: '2026-02-01T00:00:00.000Z' }); - expect(qb.andWhere).toHaveBeenCalledWith('issue.created <= :createdTo', { - createdTo: new Date('2026-02-01T00:00:00.000Z'), - }); + expect(query().createdTo).toEqual(new Date('2026-02-01T00:00:00.000Z')); }); it('extends a date-only createdTo to the end of that day (inclusive)', async () => { await run({ createdTo: '2026-02-01' }); - expect(qb.andWhere).toHaveBeenCalledWith('issue.created <= :createdTo', { - createdTo: new Date('2026-02-01T23:59:59.999Z'), - }); + expect(query().createdTo).toEqual(new Date('2026-02-01T23:59:59.999Z')); }); - it('does not add date clauses when absent', async () => { + it('leaves both bounds unset when absent', async () => { await run({}); - expect(andWhereClauses().some((c) => c.includes('issue.created'))).toBe(false); + expect(query().createdFrom).toBeUndefined(); + expect(query().createdTo).toBeUndefined(); }); }); describe('sorting', () => { - it('defaults to created DESC with an id tie-break for stable pagination', async () => { + it('defaults to created DESC', async () => { await run({}); - expect(qb.orderBy).toHaveBeenCalledWith('issue.created', 'DESC'); - expect(qb.addOrderBy).toHaveBeenCalledWith('issue.id', 'DESC'); + expect(query().orderBy).toEqual(SupportIssueListOrderBy.CREATED); + expect(query().orderDir).toEqual(ListOrderDirection.DESC); }); - it('applies a whitelisted sort column with an id tie-break for stable pagination', async () => { + it('passes a whitelisted sort column through', async () => { await run({ orderBy: SupportIssueListOrderBy.CLERK, orderDir: ListOrderDirection.ASC }); - expect(qb.orderBy).toHaveBeenCalledWith('issue.clerk', 'ASC'); - expect(qb.addOrderBy).toHaveBeenCalledWith('issue.id', 'ASC'); + expect(query().orderBy).toEqual(SupportIssueListOrderBy.CLERK); + expect(query().orderDir).toEqual(ListOrderDirection.ASC); }); it('rejects an out-of-whitelist orderBy at DTO validation (the actual injection guard)', async () => { @@ -151,91 +137,55 @@ describe('SupportIssueService.getSupportIssueList', () => { describe('department narrowing', () => { it('keeps support locked to its own department, ignoring an out-of-set ?department (no escalation)', async () => { await run({ department: Department.COMPLIANCE }, UserRole.SUPPORT); - expect(departmentsParam()).toEqual([Department.SUPPORT]); + expect(query().departments).toEqual([Department.SUPPORT]); }); it('lets compliance narrow to the support department via ?department', async () => { await run({ department: Department.SUPPORT }, UserRole.COMPLIANCE); - expect(departmentsParam()).toEqual([Department.SUPPORT]); + expect(query().departments).toEqual([Department.SUPPORT]); }); it('defaults compliance to its full allowed set (support + compliance) without ?department', async () => { await run({}, UserRole.COMPLIANCE); - expect(departmentsParam()).toEqual([Department.SUPPORT, Department.COMPLIANCE]); + expect(query().departments).toEqual([Department.SUPPORT, Department.COMPLIANCE]); }); it('applies an arbitrary ?department for an unrestricted admin', async () => { await run({ department: Department.MARKETING }, UserRole.ADMIN); - expect(departmentsParam()).toEqual([Department.MARKETING]); + expect(query().departments).toEqual([Department.MARKETING]); }); it('applies no department filter for admin without ?department (unrestricted)', async () => { await run({}, UserRole.ADMIN); - expect(departmentsParam()).toBeUndefined(); + expect(query().departments).toBeUndefined(); }); it('applies no department filter for super admin without ?department (unrestricted)', async () => { await run({}, UserRole.SUPER_ADMIN); - expect(departmentsParam()).toBeUndefined(); + expect(query().departments).toBeUndefined(); }); it('returns nothing for a role with no department access, without querying', async () => { const result = await run({}, UserRole.USER); expect(result).toEqual({ data: [], total: 0 }); - expect(supportIssueRepo.createQueryBuilder).not.toHaveBeenCalled(); + expect(supportIssueRepo.findIssueList).not.toHaveBeenCalled(); }); }); - // The id branch of the search predicate is added only when the term is fully numeric AND - // fits int4. Anything above 2^31-1 (a pasted phone number) would produce a Postgres 22003 - // range error and 500 the entire search — this block pins the guard against that regression. - describe('search-term id branch', () => { - // returns the parameter bag from the last andWhere call that includes the search predicate - const lastSearchParams = (): Record | undefined => { - const calls = qb.andWhere.mock.calls; - const call = [...calls].reverse().find((c) => String(c[0]).includes('issue.name LIKE')); - return call?.[1] as Record | undefined; - }; - - // returns the SQL fragment string from the last search predicate - const lastSearchFragment = (): string => { - const calls = qb.andWhere.mock.calls; - const call = [...calls].reverse().find((c) => String(c[0]).includes('issue.name LIKE')); - return String(call?.[0] ?? ''); - }; - - it('emits the id clause for a small numeric term and binds the int', async () => { - await run({ query: '12345' }); - expect(lastSearchFragment()).toContain('issue.id = :term0Id'); - expect(lastSearchParams()).toEqual({ term0: '%12345%', term0Id: 12345 }); - }); - - it('emits the id clause for exactly int4 max (2147483647)', async () => { - await run({ query: '2147483647' }); - expect(lastSearchFragment()).toContain('issue.id = :term0Id'); - expect(lastSearchParams()).toMatchObject({ term0Id: 2147483647 }); + describe('search terms', () => { + it('splits the query on whitespace and drops empty terms', async () => { + await run({ query: ' alice 12345 ' }); + expect(query().terms).toEqual(['alice', '12345']); }); - it('omits the id clause and Id-bind for a numeric term above int4 max (phone-number regression)', async () => { - await run({ query: '41791234567' }); - expect(lastSearchFragment()).not.toContain('issue.id = :term0Id'); - expect(lastSearchParams()).toEqual({ term0: '%41791234567%' }); + it('caps the term count at ten', async () => { + await run({ query: Array.from({ length: 15 }, (_, i) => `t${i}`).join(' ') }); + expect(query().terms).toHaveLength(10); }); - it('omits the id clause for a non-numeric term', async () => { - await run({ query: 'alice' }); - expect(lastSearchFragment()).not.toContain('issue.id = :term0Id'); - expect(lastSearchParams()).toEqual({ term0: '%alice%' }); - }); - - it('mixes term shapes across ANDed clauses', async () => { - await run({ query: 'alice 12345' }); - const calls = qb.andWhere.mock.calls.filter((c) => String(c[0]).includes('issue.name LIKE')); - expect(calls).toHaveLength(2); - expect(String(calls[0][0])).not.toContain('issue.id = :term0Id'); - expect(String(calls[1][0])).toContain('issue.id = :term1Id'); - expect(calls[0][1]).toEqual({ term0: '%alice%' }); - expect(calls[1][1]).toEqual({ term1: '%12345%', term1Id: 12345 }); + it('produces no terms for an absent query', async () => { + await run({}); + expect(query().terms).toEqual([]); }); }); }); diff --git a/src/subdomains/supporting/support-issue/services/support-issue.service.ts b/src/subdomains/supporting/support-issue/services/support-issue.service.ts index 9b181d422a..964323964a 100644 --- a/src/subdomains/supporting/support-issue/services/support-issue.service.ts +++ b/src/subdomains/supporting/support-issue/services/support-issue.service.ts @@ -525,8 +525,6 @@ export class SupportIssueService { role: UserRole, customerIds?: number[], ): Promise<{ data: SupportIssueListDto[]; total: number }> { - const where: FindOptionsWhere = {}; - // department filtering: the role defines the allowed departments, an explicit filter may narrow within them const allowedDepartments = getVisibleDepartments(role); if (!customerIds && allowedDepartments?.length === 0) return { data: [], total: 0 }; // no department access @@ -537,63 +535,28 @@ export class SupportIssueService { ? [filter.department] : allowedDepartments; - if (filter.type) where.type = filter.type; - - // server-side search: split query into terms, each term must match at least one field (AND between terms, OR between fields) - const terms = (filter.query ?? '') - .split(/\s+/) - .map((t) => t.trim()) - .filter((t) => t.length > 0) - .slice(0, 10); - - const qb = this.supportIssueRepo.createQueryBuilder('issue'); - // the search predicate and the RealUnit customer scope both need the userData join; share the single 'userData' alias - if (terms.length > 0 || customerIds) qb.leftJoin('issue.userData', 'userData'); - - // customer scope (RealUnit) takes precedence over and replaces the department gate; the left join + IN filter - // fail-closes issues without a userData (NULL is never IN the scope list) - if (customerIds) qb.andWhere('"userData".id IN (:...customerIds)', { customerIds }); - else if (departments) qb.andWhere('issue.department IN (:...departments)', { departments }); - if (filter.states?.length) qb.andWhere('issue.state IN (:...states)', { states: filter.states }); - if (where.type) qb.andWhere('issue.type = :type', { type: where.type }); - if (filter.clerk) qb.andWhere('issue.clerk = :clerk', { clerk: filter.clerk }); - if (filter.createdFrom) qb.andWhere('issue.created >= :createdFrom', { createdFrom: new Date(filter.createdFrom) }); - if (filter.createdTo) { - const createdTo = new Date(filter.createdTo); - // a date-only bound (no time component) means "on or before that day" → include the whole day - if (!filter.createdTo.includes('T')) createdTo.setUTCHours(23, 59, 59, 999); - qb.andWhere('issue.created <= :createdTo', { createdTo }); - } - - const termCount = Math.min(terms.length, 10); - for (let i = 0; i < termCount; i++) { - const param = `term${i}`; - // Only emit the id branch when the term is fully numeric AND fits int4 (Postgres rejects - // larger values with 22003, which would 500 the entire search — a pasted phone number - // like "41791234567" is a realistic trigger). Keeps the predicate on the PK index (no - // cast-to-text) and avoids partial-match surprises (term "42" doesn't match id 142). - const idTerm = /^\d+$/.test(terms[i]) && parseInt(terms[i], 10) <= 2147483647 ? parseInt(terms[i], 10) : null; - const idClause = idTerm != null ? ` OR issue.id = :${param}Id` : ''; - qb.andWhere( - `(issue.name LIKE :${param} OR issue.uid LIKE :${param} OR issue.clerk LIKE :${param} OR "userData".firstname LIKE :${param} OR "userData".surname LIKE :${param} OR "userData"."organizationName" LIKE :${param} OR EXISTS (SELECT 1 FROM support_message m WHERE m."issueId" = issue.id AND m.message LIKE :${param})${idClause})`, - { [param]: `%${terms[i]}%`, ...(idTerm != null ? { [`${param}Id`]: idTerm } : {}) }, - ); - } - - // whitelisted sort column + direction, with an id tie-break for stable pagination on equal sort keys - const orderBy = filter.orderBy ?? SupportIssueListOrderBy.CREATED; - const orderDir = filter.orderDir ?? ListOrderDirection.DESC; - qb.orderBy(`issue.${orderBy}`, orderDir); - qb.addOrderBy('issue.id', orderDir); - - if (filter.take != null) { - qb.take(filter.take); - if (filter.skip != null) qb.skip(filter.skip); - } - - const [issues, total] = await qb.getManyAndCount(); + const [issues, total] = await this.supportIssueRepo.findIssueList({ + departments, + customerIds, + states: filter.states, + type: filter.type, + clerk: filter.clerk, + createdFrom: filter.createdFrom ? new Date(filter.createdFrom) : undefined, + createdTo: this.parseCreatedTo(filter.createdTo), + // server-side search: split query into terms, each term must match at least one field + // (AND between terms, OR between fields) + terms: (filter.query ?? '') + .split(/\s+/) + .map((t) => t.trim()) + .filter((t) => t.length > 0) + .slice(0, 10), + orderBy: filter.orderBy ?? SupportIssueListOrderBy.CREATED, + orderDir: filter.orderDir ?? ListOrderDirection.DESC, + take: filter.take, + skip: filter.skip, + }); - const stats = await this.getMessageStats(issues.map((i) => i.id)); + const stats = await this.messageRepo.findStatsFor(issues.map((i) => i.id)); return { data: issues.map((i) => SupportIssueDtoMapper.mapSupportIssueListItem(i, stats.get(i.id))), @@ -601,51 +564,14 @@ export class SupportIssueService { }; } - private async getMessageStats( - issueIds: number[], - ): Promise> { - if (issueIds.length === 0) return new Map(); - - // batched to stay below SQL Server's 2100 parameter limit - const rows = await Util.doInBatchesAndJoin( - issueIds, - (chunk): Promise<{ issueId: string; count: string; lastDate: Date | null; lastAuthor: string | null }[]> => - this.messageRepo - .createQueryBuilder('m') - .select('m."issueId"', 'issueId') - .addSelect('COUNT(*)', 'count') - .addSelect( - (sub) => - sub - .select('m2.created') - .from(SupportMessage, 'm2') - .where('m2."issueId" = m."issueId"') - .orderBy('m2.id', 'DESC') - .limit(1), - 'lastDate', - ) - .addSelect( - (sub) => - sub - .select('m2.author') - .from(SupportMessage, 'm2') - .where('m2."issueId" = m."issueId"') - .orderBy('m2.id', 'DESC') - .limit(1), - 'lastAuthor', - ) - .where('m."issueId" IN (:...ids)', { ids: chunk }) - .groupBy('m."issueId"') - .getRawMany(), - 1000, - ); + /** A date-only upper bound (no time component) means "on or before that day" — include the whole day. */ + private parseCreatedTo(createdTo?: string): Date | undefined { + if (!createdTo) return undefined; - return new Map( - rows.map((r) => [ - +r.issueId, - { count: +r.count, lastDate: r.lastDate ?? undefined, lastAuthor: r.lastAuthor ?? undefined }, - ]), - ); + const date = new Date(createdTo); + if (!createdTo.includes('T')) date.setUTCHours(23, 59, 59, 999); + + return date; } async getIssueEntities(userDataId: number): Promise { @@ -658,43 +584,22 @@ export class SupportIssueService { } async getIssues(userDataId: number): Promise { - const issues = await this.supportIssueRepo.find({ - where: { userData: { id: userDataId } }, - relations: { transaction: true, limitRequest: true }, - }); + const issues = await this.supportIssueRepo.findIssuesForAccount(userDataId); return issues.map(SupportIssueDtoMapper.mapSupportIssue); } async getIssue(id: string, query: GetSupportIssueFilter, userDataId?: number): Promise { - const issue = await this.supportIssueRepo.findOne({ - where: this.getIssueSearch(id, userDataId), - relations: { transaction: true, limitRequest: true }, - }); + const issue = await this.supportIssueRepo.findIssueBy(this.getIssueSearch(id, userDataId)); if (!issue) throw new NotFoundException('Support issue not found'); - issue.messages = await this.messageRepo.findBy({ - issue: { id: issue.id }, - id: MoreThan(query.fromMessageId ?? 0), - }); + issue.messages = await this.messageRepo.findThread(issue.id, query.fromMessageId ?? 0); return SupportIssueDtoMapper.mapSupportIssue(issue); } async getIssueData(id: number, role: UserRole, customerIds?: number[]): Promise { - const issue = await this.supportIssueRepo.findOne({ - where: { id }, - relations: { - userData: { country: true, language: true }, - transaction: { - user: { wallet: true }, - buyCrypto: { outputAsset: true, cryptoInput: { asset: true } }, - buyFiat: { outputAsset: true, cryptoInput: { asset: true } }, - }, - limitRequest: true, - }, - loadEagerRelations: false, - }); + const issue = await this.supportIssueRepo.findIssueData(id); if (!issue) throw new NotFoundException('Support issue not found'); // customer scope (RealUnit): fail-closed 404 when the issue does not belong to a scoped customer (no existence leak) if (customerIds && !customerIds.includes(issue.userData?.id))