feat(support): brand support mail by the app the ticket was opened from (X-Client)#3937
feat(support): brand support mail by the app the ticket was opened from (X-Client)#3937joshuakrueger-dfx wants to merge 8 commits into
Conversation
…count history Support replies (newSupportMessage) sent the mail without an explicit input.wallet, so resolveMailWallet's account-history override forced RealUnit branding onto any account ever linked to a preferred (RealUnit) wallet - including DFX support answers, which arrived RealUnit-branded. Resolve the user's origin wallet explicitly and pass it, so branding follows the user source (DFX -> DFX, RealUnit -> RealUnit) and never falls back to the account-history override. The wallet is resolved via the user (cached) rather than the entity relation, because not every caller eager-loads it (e.g. the hourly auto-responder job) - reading the unloaded relation would otherwise mis-brand RealUnit users as DFX. Falls back to the default (DFX) wallet when no origin wallet is recorded. Adds unit tests covering origin branding, the default fallback and the no-mail case. Mirrors the login-mail fix #3846 for support-message mails.
63f8ba8 to
5daa1c0
Compare
… from Builds on the previous commit (brand by account origin) to follow the actual ticket source: a user can have both a DFX and a RealUnit identity on one account, so account origin alone could not tell which app a ticket was opened from. - SupportIssue gains a nullable 'wallet' relation (the wallet/app the issue was opened from) + migration (column, index, FK). - Capture the source on creation: customer issues from the originating login user (jwt.user) -> its wallet; transaction-request issues from the request's user wallet; support-created issues have no source. - newSupportMessage brands by issue.wallet first, then the account origin wallet (legacy/support-created issues), then the default (DFX) wallet. Extends the unit tests: ticket source wins over account origin, origin fallback, default fallback.
TaprootFreak
left a comment
There was a problem hiding this comment.
Re-reviewed the updated approach (issue-source-wallet branding via the new SupportIssue.wallet). This is a clean solution and resolves the concern from the earlier UserData.wallet-only version: branding now follows the wallet the ticket was opened from — the login User row via jwt.user (getUser(sourceUserId, { wallet: true })) — with origin-wallet and DFX-default fallbacks. SupportIssue.wallet being eager makes entity.issue.wallet reliably available on every path, including the bot job. jwt.account (userData id) + jwt.user (user/wallet id) are threaded correctly.
Verified locally off a clean clone (head 719a6bf): tsc --noEmit ✅, eslint ✅, prettier ✅, unit spec ✅ 5/5.
One should-fix before merge (migration index/FK names — inline) plus minor notes.
Two notes that don't map to a changed line:
- Reused issues keep their original source wallet.
createIssueInternalreuses a matching open issue (const entity = existingIssue ?? save(newIssue)), so the freshly resolvedsourceWalletis intentionally dropped when an issue is reused — branding follows the ticket's original source. Looks intended; flagging in case a continued ticket from a different app should re-evaluate the source. - Same override still affects other USER_V2 mails (out of scope here): KYC, TFA, account-merge, user-data and unassigned-tx notifications still pass
userDatawithoutinput.wallet, so the RealUnit account-history override still applies there. This is now the second caller-side fix for the same heuristic (after the #3846 login-mail fix) — worth a follow-up, or hardeningresolveMailWalletcentrally.
| await queryRunner.query(`CREATE INDEX "IDX_b9a1f6c3e0d24a7f8c2b5e1a3d" ON "support_issue" ("walletId")`); | ||
| await queryRunner.query(`ALTER TABLE "support_issue" ADD CONSTRAINT "FK_b9a1f6c3e0d24a7f8c2b5e1a3d" FOREIGN KEY ("walletId") REFERENCES "wallet"("id") ON DELETE NO ACTION ON UPDATE NO ACTION`); |
There was a problem hiding this comment.
Index and FK names don't match TypeORM's DefaultNamingStrategy — they look hand-written. The repo convention is that hand-written migrations use the exact names TypeORM would generate, so the live schema matches the entity metadata.
For support_issue.walletId the correct names are:
- index:
IDX_f5224979beab23e21df3066a60 - FK:
FK_f5224979beab23e21df3066a60a
Verified against this repo's TypeORM DefaultNamingStrategy; sanity-checked by reproducing the existing user_data.walletId names (IDX_dcf41efbd8bd1f2a80f369028b / FK_dcf41efbd8bd1f2a80f369028b2) exactly with the same method.
The current names are also structurally off: the FK reuses the same 26-char hash as the index, whereas a TypeORM FK name is one char longer than the index hash. With the wrong names, the next migration:generate / schema comparison will treat the entity's @Index()/FK as missing and emit a spurious drop+recreate (schema drift). Please update all four occurrences — up (lines 18–19) and down (lines 26–27).
| // the user (cached) because not every caller eager-loads the relation (e.g. the bot job) | ||
| // 3. the default (DFX) wallet | ||
| const wallet = | ||
| entity.issue.wallet ?? |
There was a problem hiding this comment.
Correction — I retract my earlier praise of this priority chain; it was wrong given the hard requirement for this feature.
Requirement (non-negotiable): every support ticket must be attributed to its originating application exactly — RealUnit (RealUnitCH/app) or DFX (DFXswiss/services, app.dfx.swiss) — and the mail template must be chosen solely from that exact attribution. Heuristics and fallbacks are forbidden. If the source cannot be determined exactly, the system must fail closed (no mail / explicit error), never guess a brand.
This line violates it. entity.issue.wallet ?? getUserData(...).wallet ?? getDefault() is a two-step heuristic fallback (account-origin wallet, then DFX default). It silently mis-brands whenever issue.wallet is null.
issue.wallet is null today on at least one path: createIssueBySupport → createIssue(+userDataId, input) (support-issue.controller.ts:69) passes no source user, so support-created tickets carry no exact source and fall into the heuristic.
Required instead:
- Set
SupportIssue.walletto the exact originating client on every creation path, including support-created tickets (the agent/UI must specify the product, or derive it from an unambiguous signal — never guess). - Make the column NOT NULL (after backfill) so an unattributed ticket cannot exist.
- Brand from only
issue.wallet; drop the?? origin ?? defaultchain. If it is ever unresolvable, fail closed.
Template mapping for reference (mail.factory.ts:152-153): branding keys off wallet.name via Config.mail.wallet — RealUnit → realunit; there is no explicit DFX entry, so DFX is the default template. For 100% exactness an explicit DFX wallet→template mapping is preferable to "anything not RealUnit = default".
There was a problem hiding this comment.
You're right — the heuristic is gone. Fixed in bd674c37e.
Branding is now exact-or-fail-closed. newSupportMessage brands strictly from issue.wallet; if it's null the mail is skipped (logged), never guessed. Dropped the ?? origin ?? default chain and the UserDataService/WalletService deps with it.
On the three required items:
1. Exact source on every creation path
- Customer tickets: exact, server-derived from the login user (
jwt.user→ its wallet) — not client-supplied, so it can't be spoofed. ✓ - Transaction-request tickets: exact from the request's user wallet. ✓
- Support-created tickets (
createIssueBySupport): no app source exists server-side. Per your note these need the agent/UI to specify the product — that's a SUPPORT-role-only signal (must not be client-trusted) and a new FE+API contract, so I've left it as a follow-up. Until then they fail closed (no mail), no guess.
2. NOT NULL column — can't be satisfied yet without breaking the no-guess rule on legacy rows. Existing tickets have no recorded source; transaction-linked ones are backfillable exactly from the transaction's user wallet, but non-transaction legacy tickets have no exact source, so backfilling them would itself be a guess. Proposal: backfill the exactly-derivable rows, decide a policy for the rest (or close them), then a follow-up migration flips NOT NULL. Needs a data/product call.
3. Explicit DFX template mapping — branding from issue.wallet is already exact: RealUnit → realunit, and a DFX-sourced ticket carries the DFX wallet whose name has no Config.mail.wallet entry → default = DFX template (mail.factory.ts:152-153). Happy to add an explicit DFX entry if you'd prefer it spelled out rather than relying on "default = DFX".
Net: no mis-branding is now possible. The open items (support-created attribution, NOT NULL backfill) are the ones needing a coordinated FE/data decision — can split them into a follow-up issue if you prefer to keep this PR to the safety fix.
| async function sentMailInput(issueWallet?: Wallet): Promise<MailRequest['input']> { | ||
| const sendMail = jest.spyOn(notificationService, 'sendMail').mockResolvedValue(undefined); | ||
| const userData = createCustomUserData({ id: 7, mail: 'user@test.com' }); | ||
| await service.newSupportMessage(createSupportMessage(userData, issueWallet)); |
There was a problem hiding this comment.
Minor (coverage): since NotificationService is fully mocked and sendMail stubbed (line 56), resolveMailWallet never runs in any test — so the actual safeguard that fixes the bug (if (input.wallet) return, notification.service.ts:119) is never exercised. The unit boundary is reasonable, but if that line or the override changes later, this suite stays green while branding silently regresses. An integration-style test letting the real resolveMailWallet run (override skipped when input.wallet is set, applied when it isn't) would lock it in.
…llet safeguard Review fixes (PR #3937): - Migration used hand-written index/FK names that did not match TypeORM's DefaultNamingStrategy, which would make the next schema comparison emit a spurious drop+recreate. Use the generated names (verified: sha1 of 'support_issue_walletId' -> IDX_f5224979beab23e21df3066a60 / FK_...a60a, and the same method reproduces the existing user_data.walletId names exactly). - Add a NotificationService spec exercising the real resolveMailWallet: an explicitly set input.wallet skips the account-history override (the safeguard our support-mail fix relies on), and absent it falls back to the account wallet. Previously every test mocked sendMail, so that line was never covered.
|
Thanks for the thorough re-review, @TaprootFreak — all addressed in Should-fix — migration index/FK names (fixed). You were right; mine were hand-written and both wrong and structurally invalid (FK reused the 26-char index hash). Now using the
(Editing the migration is safe vs. the immutability check — the file is new in this PR, so it diffs as added against Coverage note (added). New Note 1 — reused issues keep their original source wallet: intended. A continued ticket stays branded by where it was first opened; re-evaluating the source on reuse from a different app would be a deliberate product change, not done here. Note 2 — other USER_V2 mails (KYC/TFA/merge/…): agreed, out of scope. This is now the second caller-side fix for the same heuristic; I'd suggest a follow-up to harden |
Required: exact, deterministic source attribution for support-ticket mail branding (no heuristics)Authoritative requirement for this PR. Supersedes my earlier notes that endorsed deriving the source from the user's wallet — see the hard constraint below. Requirement (non-negotiable)Every support ticket is created from exactly one application — RealUnit ( Hard constraint — forbidden sourcesThe attribution source must be an explicit, reliable signal of the creating application. The following are not reliable and are not permitted as the source:
Consequently the current resolution is built entirely on forbidden sources: Blockers in the current PR (@6f90a1f1c) — verified against the code
Fixed and verified (credit where due)
What a correct solution requires
Until 1–4 hold, the feature cannot meet the requirement. Note: the proposed follow-up "brand by the user's origin wallet" is itself one of the forbidden sources above and would not satisfy this. |
…d on unknown Per review (PR #3937): the priority chain (issue.wallet ?? account-origin ?? DFX-default) was a heuristic that silently mis-brands when the source is unknown - violating the hard requirement to attribute every ticket to its exact originating app or not brand at all. newSupportMessage now brands only from issue.wallet and fails closed (skips the mail, logs a warning) when it is null, instead of guessing. Drops the origin/default fallback and the UserDataService/WalletService deps. Customer and transaction-request tickets already carry the exact source (jwt.user / request user wallet). Support-created tickets and legacy rows have no exact source yet and therefore fail closed - exact attribution there (agent-specified product, NOT NULL after backfill) is a follow-up needing an FE/API contract and a backfill decision. Tests updated: exact branding by issue source (RealUnit/DFX), fail-closed when absent, no-mail case.
…FX default Replaces the wallet-derived source (forbidden per review: user.wallet / userData.wallet are set once at signup and don't identify the calling app) with the per-request, server-read X-Client signal. - Centralize client detection in shared/utils/request-client (single source for the api-trace middleware and the support flow). - SupportIssue.wallet is set from the X-Client header at creation: RealUnit app -> RealUnit wallet; every other client -> null (= DFX default). Product decision: DFX is the default brand, only RealUnit-app tickets are RealUnit. - newSupportMessage brands by issue.wallet, falling back to the default (DFX) wallet; passing it explicitly still bypasses the account-history override. - Migrate the sibling limit-request mail to the same issue source instead of userData.wallet origin. - Fix createCustomWallet mock to carry name, so branding assertions are real. - Drop the jwt.user/UserService path and the origin/fail-closed heuristics. Tests: request-client detection (6), branding by issue source vs DFX default, no-mail; full suite 1068 passed. Prereqs (flagged on PR): RealUnit app must send 'X-Client: realunit-app' on support requests; REALUNIT_MAIL_USER must be set for the RealUnit template.
|
Reworked per your authoritative requirement — the source is no longer wallet-derived. Pushed in Source = the trusted per-request
One deliberate product decision from @joshuakrueger-dfx that diverges from your spec: DFX is the default brand; only RealUnit-app tickets are branded RealUnit. So
Addressed from your list regardless of the default decision:
Two real prerequisites for RealUnit branding to work in prod (flagging, not solved here):
If you still require positive DFX attribution + fail-closed over the product's default-DFX decision, that's a call for you and @joshuakrueger-dfx — happy to switch to strict mode, but it would gate all DFX support mails on a new DFX-app header first. Full suite: 1068 passed, 0 failed. |
| // Mail branding follows the app the ticket was opened from (set on the issue at creation from the | ||
| // trusted X-Client signal): RealUnit-app tickets carry the RealUnit wallet, everything else defaults | ||
| // to DFX. Passing the wallet explicitly also bypasses resolveMailWallet's account-history override. | ||
| const wallet = entity.issue.wallet ?? (await this.walletService.getDefault()); |
There was a problem hiding this comment.
Blockers 1, 2, 12 from TaprootFreak's comment still apply here.
1. Heuristic default not removed. issue.wallet ?? walletService.getDefault() still defaults when source is unknown — exactly the implicit guess the requirement forbids.
2. No explicit DFX template. Config.mail.wallet has no DFX entry, so an exactly-attributed DFX ticket and a ticket with unknown source share the same catch-all path. "Unknown" cannot be expressed; fail-closed is structurally impossible.
12. Env-gated RealUnit template. Even for an exactly-attributed RealUnit ticket, the mail silently renders DFX when REALUNIT_MAIL_USER is unset (because Config.mail.wallet.RealUnit is conditional). PR description calls this a prereq; under strict attribution this should at minimum warn-log when source=RealUnit but template is absent.
To meet the requirement: add an explicit DFX entry in Config.mail.wallet, drop the ?? default, and skip-with-warn when issue.wallet is null.
| ? this.supportIssueService.createIssue(jwt.account, input) | ||
| : this.supportIssueService.createTransactionRequestIssue(input); | ||
| ? this.supportIssueService.createIssue(jwt.account, input, client) | ||
| : this.supportIssueService.createTransactionRequestIssue(input, client); |
There was a problem hiding this comment.
Blocker 4 (TaprootFreak): support-staff path remains unattributed.
createIssueBySupport (just below, line 71) still calls createIssue(+userDataId, input) without forwarding any client/source, so every support-staff-opened ticket gets issue.wallet = NULL and falls through to the DFX default. Under strict attribution this is not allowed.
Options: (a) add an explicit source field to CreateSupportIssueSupportDto that the agent must pick (DFX/RealUnit), (b) forward X-Client on this endpoint too, or (c) explicitly document and encode that staff-created tickets are always DFX (requires the explicit DFX wallet entry from Blocker 2).
|
|
||
| /** | ||
| * @param {QueryRunner} queryRunner | ||
| */ |
There was a problem hiding this comment.
Blocker 7 (TaprootFreak): nullable column, no backfill, no NOT NULL invariant.
walletId stays nullable indefinitely. Combined with the support-staff path (Blocker 4) and dedup (Blocker 8), NULL is a permanent normal state for new tickets, not just legacy — so the attribution invariant is not enforced at the schema level.
The strict requirement is: backfill existing rows, then make the column NOT NULL. If the team accepts NULL=DFX as policy instead, that decision needs to be encoded explicitly (via the DFX wallet entry from Blocker 2), not left implicit.
| if (!userData.mail) throw new BadRequestException('Mail is missing'); | ||
|
|
||
| const newIssue = this.supportIssueRepo.create({ userData, ...dto }); | ||
| const newIssue = this.supportIssueRepo.create({ userData, wallet: sourceWallet, ...dto }); |
There was a problem hiding this comment.
Blocker 8 (TaprootFreak): dedup will discard the freshly resolved source.
The newly created newIssue carries sourceWallet, but at line 230 the code does const entity = existingIssue ?? (await this.supportIssueRepo.save(newIssue)). When an existing matching issue is reused (same userData/type/reason), its (possibly NULL or stale) wallet sticks and the freshly resolved sourceWallet is silently dropped.
Concrete failure: user opens a ticket from the DFX app, later re-opens from the RealUnit app under the same userData/type/reason — second request keeps DFX attribution. Mitigation: detect divergence (existingIssue.wallet?.id !== sourceWallet?.id) and either update the row or treat as a new issue.
Hardens the X-Client source attribution (no behavioural reversal of the trusted-signal model, no persisted-wallet heuristics): - newSupportMessage logs when an issue has no attributed source and falls back to the DFX house brand, so the default path is observable instead of silent; and warns when a ticket is RealUnit-attributed but REALUNIT_MAIL_USER is unset (factory would silently render DFX otherwise). - Dedup now upgrades a not-yet-attributed (legacy/DFX-defaulted) issue to a positively resolved source on a follow-up message, but never clobbers an existing source (no RealUnit -> DFX downgrade). - Document the deliberate decisions: support-created tickets are DFX, and walletId is nullable by design (null = DFX; X-Client is RealUnit-only today, so there is no positive DFX signal to backfill against). - Tests assert the observability log and the REALUNIT_MAIL_USER guard. Note: X-Client propagation verified end-to-end - the RealUnit app sends X-Client: realunit-app on /v1/support/issue (api_client.dart, test-contract); the DFX ecosystem sends no header and correctly falls to the DFX default.
- request-client: anchor the RealUnit client regex (^realunit-app$) so substrings like 'realunit-app-proxy' no longer match; trim the header value. Clarify in the comment that X-Client is client-supplied and NOT authenticated - it only selects mail branding, never authorization. - support-issue.service: split the support-tool path into a dedicated createIssueBySupport (no client param) so the 'support-created = DFX' invariant cannot be broken by a future caller forwarding a client header. - support-issue.service: warn when a RealUnit ticket is requested but the RealUnit wallet row is missing (was a silent DFX fallback). - Tests: anchored-regex cases for request-client.
Problem
Support reply mails could arrive RealUnit-branded for DFX support answers (and vice-versa). Branding was sent without an explicit wallet, so
resolveMailWallet's account-history override forced RealUnit onto any account ever linked to a RealUnit user.Approach
Brand each support mail by the app the ticket was opened from, derived from the trusted per-request
X-Clientheader — not from the user's persisted wallet (which is set once at signup and doesn't identify the calling app).src/shared/utils/request-client.ts— central client detection (X-Client: realunit-app, plus the legacy/v{n}/realunit/path); the api-trace middleware now uses it too (single source of truth).SupportIssue.wallet— new nullable relation, eager-loaded; migration adds column + index + FK (TypeORMDefaultNamingStrategynames). Set at creation fromX-Client: RealUnit app → RealUnit wallet, every other client → null.nullmeans DFX by design →walletIdstays nullable, no backfill.newSupportMessagebrands byissue.wallet ?? default(DFX); passing the wallet explicitly also bypasses the account-history override.Prerequisites for RealUnit branding in prod
X-Client: realunit-appon the support-ticket request.REALUNIT_MAIL_USERenv must be set (else the RealUnit template is absent and it renders DFX).Verification
Local (clean worktree off develop):
tsc, prettier, eslint clean; full unit suite 1068 passed, 0 failed. Tests: X-Client detection (6), branding by issue source vs DFX default, no-mail; NotificationServiceresolveMailWalletsafeguard. Migration is a new file (immutability check passes).