fix(valuation): send the report email only when there are numbers

Merged
SW
2mo ago
+421-155

Implements the single api item of recoupable/chat#1969 — the valuation email consumes the valuation the handler already computed and is only sent when totalStreams > 0. The zero-stream shell email ("Your catalog valuation is ready" with no valuation, no streams, no explanation — three real sends, one to a real funnel signup on 2026-08-16) becomes unrepresentable.

What changed

  • Handler gates and passes data (runValuationHandler): one deferred block, in order — the email fires only inside if (aggregate && aggregate.totalStreams > 0) with the handler's own {valuation, totalStreams, measuredSongCount, catalogAgeYears, ageFlooredToOneYear}; then the lead capture reports the email's actual fate. "Email after valuation" is now a data dependency, not an after() timing rule.
  • sendValuationReportEmail is presentation-only: deleted its selectCatalogById / selectCatalogMeasurementsAggregate / getCatalogEarliestReleaseDate / computeValuationBand imports, the enrichment Promise.all, and the silent-degrade try/catch (the same swallow class #1965 removed from the roster attach). Kept: the email_send_log long-window idempotency guard, the no_email skip, and the best-effort release table (buildReleaseRows self-degrades to no table, never to no numbers).
  • Types enforce the decision: ValuationReportEmailParams.valuation (+ totalStreams, measuredSongCount, catalogAgeYears, ageFlooredToOneYear) are required; the shell arm of renderValuationBlock, the conditional disclaimer, and the unclaimed-snapshot deep-link fallback are deleted. git grep computeValuationBand lib/emails returns nothing.
  • Outcome-aware alert line (decision refined on the issue, 2026-08-18): the Telegram lead alert renders the email's real fate from the send result that was previously discarded — Email: sent / Email: skipped (0 streams) / Email: skipped (no measurements) / Email: skipped (already sent) / Email: SEND FAILED — <error> — via the new toValuationEmailOutcome mapper.
  • Young-catalog honesty: computeValuationBand now also returns ageFlooredToOneYear; the email carries a one-line caveat when a sub-year catalog is priced on the model's one-year floor (Math.max(1, …)).

LOC note

Source diff is +127/−111 (+16): the approved outcome-aware alert (mapper file + plumbing, ~+45) is the entirety of the growth — the gated-email consolidation itself is net negative, per the issue's deletion-led intent. Test diff adds the suites the issue's Done-when demands.

Verification (local)

  • TDD red→green per unit across computeValuationBand, render/types, sendValuationReportEmail (including "does no data fetching or valuation math of its own" — data-layer mocks asserted never called), toValuationEmailOutcome, captureValuationLead, and the handler gate (zero streams → no send call + skip in the alert payload; thrown send → 200 + failed outcome).
  • Full domain suites green; tsc --noEmit and eslint clean in touched domains (baseline noise elsewhere unchanged).

Preview verification (zero-stream run → no new Resend row + Email: skipped (0 streams) in Telegram; real-catalog run → exactly one email with dollars + caveat correctness; in-app zero-state check per the issue) to follow as a PR comment.

Merge note: independent — no other PR in this train. No docs or database PR (no contract change).

🤖 Generated with Claude Code


Summary by cubic

Stops sending “valuation is ready” emails without numbers. The handler now emails only when it measured streams (>0), passes its computed valuation to the template, and the lead alert reports the actual send outcome.

  • Handler gates on aggregate.totalStreams > 0, then calls sendValuationReportEmail with {snapshot, catalogId, catalogName, valuation, totalStreams, measuredSongCount, catalogAgeYears, ageFlooredToOneYear, artist}; deferred with after() but driven by the precomputed data. Lead capture renders “Report email: sent | skipped (0 streams | measurements unavailable | already sent | no email on account) | SEND FAILED — ” via toValuationEmailOutcome.
  • sendValuationReportEmail is presentation‑only. It no longer fetches catalog data or recomputes valuation; it keeps the long‑window idempotency guard, the no_email skip, and a best‑effort release table.
  • Types make numbers required; the shell render path and unclaimed‑snapshot deep‑link fallback are removed, so an empty valuation email is unrepresentable.
  • computeValuationBand adds ageFlooredToOneYear and guards unparseable dates by falling back to the default age; the email adds a one‑line caveat for sub‑year catalogs.

Migration

  • Callers must pass computed numbers to sendValuationReportEmail: snapshot, catalogId, catalogName, valuation, totalStreams, measuredSongCount, catalogAgeYears, ageFlooredToOneYear (optional artist).

Summary by CodeRabbit

  • New Features

    • Valuation reports now explain when calculations use a minimum one-year age.
    • Reports consistently display all key statistics, including unavailable values.
    • Lead alerts now show whether valuation email delivery was sent, skipped, or failed.
  • Bug Fixes

    • Improved valuation report generation and delivery handling.
    • Valuation emails are skipped when required measurements or stream data are unavailable, with the outcome clearly recorded.