From 0df9a29022551bbb516dc60a00ac95d354af298a Mon Sep 17 00:00:00 2001 From: Bryce Date: Tue, 4 Aug 2026 22:55:53 -0700 Subject: [PATCH] feat(ssr): add report email hand-off to ledger export modal The SSR ledger reports could generate and download a PDF but had lost the SPA's ability to hand the report off to the client's email contacts. This restores it for Profit and Loss, Balance Sheet and Cash Flows. The server still sends nothing: the modal offers a mailto: link, pre-filled with the client's email contacts, subject and body, that opens in the user's own mail client so they can review before sending. Extracts the modal the three reports duplicated into a shared auto-ap.ssr.ledger.export-modal namespace, and tightens the SPA's rules along the way: - admin-only, uniformly (the SPA's Cash Flows page skipped this check) - single client only, uniformly (the SPA's Balance Sheet page did not check, so a multi-client report could be mailed to one client's contacts) - recipients joined with "," per RFC 6068 rather than Outlook's ";" - subject percent-encoded, like the body already was - body links built from :base-url and bidi routes, fixing the dead /reports/ link (the page now lives at /company/reports) and the hardcoded prod domain in the requires-feedback link Co-Authored-By: Claude Opus 5 --- src/clj/auto_ap/ssr/ledger/balance_sheet.clj | 28 +---- src/clj/auto_ap/ssr/ledger/cash_flows.clj | 29 +---- src/clj/auto_ap/ssr/ledger/export_modal.clj | 105 ++++++++++++++++++ .../auto_ap/ssr/ledger/profit_and_loss.clj | 29 +---- .../auto_ap/ssr/ledger/export_modal_test.clj | 86 ++++++++++++++ 5 files changed, 206 insertions(+), 71 deletions(-) create mode 100644 src/clj/auto_ap/ssr/ledger/export_modal.clj create mode 100644 test/clj/auto_ap/ssr/ledger/export_modal_test.clj diff --git a/src/clj/auto_ap/ssr/ledger/balance_sheet.clj b/src/clj/auto_ap/ssr/ledger/balance_sheet.clj index 45f6b56d..4236cd40 100644 --- a/src/clj/auto_ap/ssr/ledger/balance_sheet.clj +++ b/src/clj/auto_ap/ssr/ledger/balance_sheet.clj @@ -16,12 +16,12 @@ [auto-ap.ssr.components :as com] [auto-ap.ssr.form-cursor :as fc] [auto-ap.ssr.hx :as hx] + [auto-ap.ssr.ledger.export-modal :refer [export-modal]] [auto-ap.ssr.ledger.report-table :as rtable] - [auto-ap.ssr.svg :as svg] [auto-ap.ssr.ui :refer [base-page]] [auto-ap.ssr.utils :refer [apply-middleware-to-all-handlers clj-date-schema - html-response modal-response wrap-form-4xx-2 + html-response wrap-form-4xx-2 wrap-schema-enforce]] [auto-ap.time :as atime] [bidi.bidi :as bidi] @@ -235,30 +235,12 @@ :report/creator (:user (:identity request)) :report/created (java.util.Date.)}]) {:report/name name - :report/url url})) + :report/url url + :report/clients client})) ;; TODO PRINT WARNING (defn export [request] - (modal-response - (com/modal {} - (com/modal-card - {} - "Ready!" - (com/modal-body {} - (let [bs (print-balance-sheet request)] - [:div.flex.flex-col.mt-4.space-y-4.items-center - [:a {:href (:report/url bs)} - [:div.w-24.h-24.bg-green-50.rounded-full.p-4.text-green-300 {:class " hover:scale-110 transition duration-100"} - - svg/download]] - [:span.text-gray-800 - "Click " - (com/link {:href (:report/url bs)} "here") - " to download"]])) - nil)) - :headers (-> {} - (assoc "hx-retarget" ".modal-stack") - (assoc "hx-reswap" "beforeend")))) + (export-modal request (print-balance-sheet request))) (def key->handler (apply-middleware-to-all-handlers diff --git a/src/clj/auto_ap/ssr/ledger/cash_flows.clj b/src/clj/auto_ap/ssr/ledger/cash_flows.clj index c5ad8ed3..ec5d9306 100644 --- a/src/clj/auto_ap/ssr/ledger/cash_flows.clj +++ b/src/clj/auto_ap/ssr/ledger/cash_flows.clj @@ -16,13 +16,13 @@ [auto-ap.ssr.components :as com] [auto-ap.ssr.form-cursor :as fc] [auto-ap.ssr.hx :as hx] + [auto-ap.ssr.ledger.export-modal :refer [export-modal]] [auto-ap.ssr.ledger.report-table :refer [cell-count concat-tables table]] [auto-ap.ssr.nested-form-params :refer [wrap-nested-form-params]] - [auto-ap.ssr.svg :as svg] [auto-ap.ssr.ui :refer [base-page]] [auto-ap.ssr.utils :refer [apply-middleware-to-all-handlers clj-date-schema html-response - modal-response wrap-form-4xx-2 wrap-schema-enforce]] + wrap-form-4xx-2 wrap-schema-enforce]] [auto-ap.time :as atime] [bidi.bidi :as bidi] [clj-pdf.core :as pdf] @@ -226,31 +226,12 @@ :report/creator (:user (:identity request)) :report/created (java.util.Date.)}]) {:report/name name - :report/url url})) + :report/url url + :report/clients client})) ;; TODO PRINT WARNING (defn export [request] - (modal-response - (com/modal {} - (com/modal-card - {} - "Ready!" - (com/modal-body {} - (let [bs (print-cash-flows request)] - [:div.flex.flex-col.mt-4.space-y-4.items-center - [:a {:href (:report/url bs)} - [:div.w-24.h-24.bg-green-50.rounded-full.p-4.text-green-300 {:class " hover:scale-110 transition duration-100"} - - svg/download]] - [:span.text-gray-800 - "Click " - (com/link {:href (:report/url bs)} "here") - " to download"]])) - - nil)) - :headers (-> {} - (assoc "hx-retarget" ".modal-stack") - (assoc "hx-reswap" "beforeend")))) + (export-modal request (print-cash-flows request))) (def key->handler (apply-middleware-to-all-handlers diff --git a/src/clj/auto_ap/ssr/ledger/export_modal.clj b/src/clj/auto_ap/ssr/ledger/export_modal.clj new file mode 100644 index 00000000..fc25891b --- /dev/null +++ b/src/clj/auto_ap/ssr/ledger/export_modal.clj @@ -0,0 +1,105 @@ +(ns auto-ap.ssr.ledger.export-modal + "Shared \"your report is ready\" modal for the SSR ledger reports. + + Offers the generated PDF for download and, for admins running a report for a + single client, a `mailto:` hand-off pre-filled with that client's email + contacts. Nothing is sent by the server - the link opens the user's own mail + client so they can review before sending." + (:require + [auto-ap.datomic :refer [conn pull-many]] + [auto-ap.graphql.utils :refer [is-admin?]] + [auto-ap.routes.transactions :as transaction-routes] + [auto-ap.ssr-routes :as ssr-routes] + [auto-ap.ssr.components :as com] + [auto-ap.ssr.svg :as svg] + [auto-ap.ssr.utils :refer [modal-response]] + [bidi.bidi :as bidi] + [clojure.string :as str] + [config.core :refer [env]] + [datomic.api :as dc]) + (:import + [java.net URLEncoder])) + +(def recipient-separator + "RFC 6068 separator for multiple mailto recipients. Outlook, Gmail and Apple + Mail all accept a comma; the legacy SPA used a semicolon, which only Outlook + understood." + ",") + +(defn url-encode + "Percent-encode a mailto header value. URLEncoder does form encoding, so + spaces come back as `+`; mail clients want %20." + [s] + (-> (URLEncoder/encode (str s) "UTF-8") + (str/replace "+" "%20"))) + +(defn app-url [route] + (str (:base-url env) (bidi/path-for ssr-routes/only-routes route))) + +(defn email-body [report-url] + (str + "Hello, +Click here (" report-url ") to download your financial reports. We have not finished reviewing and reconciling these numbers with you. Please review and let us know if anything seems missing or in need of correction. +Click here (" (:base-url env) ") to login to the Financials app to review the details here. +Click here (https://share.vidyard.com/watch/MHTo5PyXPxXUpVH93RWFM9?) for a video on how to run a P&L on your own. +To see a history of past financial reports, click here: " (app-url :company-reports) " + +NOTE: Please review the transactions we may have question for you here: " (app-url ::transaction-routes/requires-feedback-page) ". You can either edit the transaction to what expense account it should be or email back what it should be.")) + +(defn email-contacts + "Email contacts configured on `clients` (a seq of entity maps with :db/id)." + [clients] + (->> (pull-many (dc/db conn) + [{:client/emails [:email-contact/email :email-contact/description]}] + (map :db/id clients)) + (mapcat :client/emails) + (filter :email-contact/email))) + +(defn recipients + "The contacts to offer a mailto hand-off to, or nil when we should not offer + one: only admins may email a report out, and only for a single client - a + multi-client report would expose one client's numbers to another's contacts." + [request clients] + (when (and (is-admin? (:identity request)) + (= 1 (count clients))) + (seq (email-contacts clients)))) + +(defn mailto-href [contacts {:report/keys [name url]}] + (str "mailto:" (str/join recipient-separator (map :email-contact/email contacts)) + "?subject=" (url-encode (str name " is ready")) + "&body=" (url-encode (email-body url)))) + +(defn describe-contacts [contacts] + (str/join ", " (map (fn [{:email-contact/keys [email description]}] + (if (str/blank? description) + email + (str email " (" description ")"))) + contacts))) + +(defn export-modal + "Modal response for a freshly printed `report` - the map returned by the + report namespaces' print-* functions." + [request {:report/keys [url clients] :as report}] + (let [contacts (recipients request clients)] + (modal-response + (com/modal {} + (com/modal-card + {} + "Ready!" + (com/modal-body {} + [:div.flex.flex-col.mt-4.space-y-4.items-center + [:a {:href url} + [:div.w-24.h-24.bg-green-50.rounded-full.p-4.text-green-300 {:class " hover:scale-110 transition duration-100"} + svg/download]] + [:span.text-gray-800 + "Click " + (com/link {:href url} "here") + " to download"] + (when contacts + [:span.text-gray-800.text-center + "Once you've confirmed you're happy with it, click " + (com/link {:href (mailto-href contacts report)} "here") + " to open your email client and to send it to " + (describe-contacts contacts) + "."])]) + nil))))) diff --git a/src/clj/auto_ap/ssr/ledger/profit_and_loss.clj b/src/clj/auto_ap/ssr/ledger/profit_and_loss.clj index e869355c..1093418f 100644 --- a/src/clj/auto_ap/ssr/ledger/profit_and_loss.clj +++ b/src/clj/auto_ap/ssr/ledger/profit_and_loss.clj @@ -16,13 +16,13 @@ [auto-ap.ssr.components :as com] [auto-ap.ssr.form-cursor :as fc] [auto-ap.ssr.hx :as hx] + [auto-ap.ssr.ledger.export-modal :refer [export-modal]] [auto-ap.ssr.ledger.report-table :refer [cell-count concat-tables table]] [auto-ap.ssr.nested-form-params :refer [wrap-nested-form-params]] - [auto-ap.ssr.svg :as svg] [auto-ap.ssr.ui :refer [base-page]] [auto-ap.ssr.utils :refer [apply-middleware-to-all-handlers clj-date-schema html-response - modal-response wrap-form-4xx-2 wrap-schema-enforce]] + wrap-form-4xx-2 wrap-schema-enforce]] [auto-ap.time :as atime] [bidi.bidi :as bidi] [clj-pdf.core :as pdf] @@ -282,31 +282,12 @@ :report/creator (:user (:identity request)) :report/created (java.util.Date.)}]) {:report/name name - :report/url url})) + :report/url url + :report/clients client})) ;; TODO PRINT WARNING (defn export [request] - (modal-response - (com/modal {} - (com/modal-card - {} - "Ready!" - (com/modal-body {} - (let [bs (print-profit-and-loss request)] - [:div.flex.flex-col.mt-4.space-y-4.items-center - [:a {:href (:report/url bs)} - [:div.w-24.h-24.bg-green-50.rounded-full.p-4.text-green-300 {:class " hover:scale-110 transition duration-100"} - - svg/download]] - [:span.text-gray-800 - "Click " - (com/link {:href (:report/url bs)} "here") - " to download"]])) - - nil)) - :headers (-> {} - (assoc "hx-retarget" ".modal-stack") - (assoc "hx-reswap" "beforeend")))) + (export-modal request (print-profit-and-loss request))) (def key->handler (apply-middleware-to-all-handlers diff --git a/test/clj/auto_ap/ssr/ledger/export_modal_test.clj b/test/clj/auto_ap/ssr/ledger/export_modal_test.clj new file mode 100644 index 00000000..301972ae --- /dev/null +++ b/test/clj/auto_ap/ssr/ledger/export_modal_test.clj @@ -0,0 +1,86 @@ +(ns auto-ap.ssr.ledger.export-modal-test + (:require + [auto-ap.datomic :refer [conn]] + [auto-ap.integration.util :refer [admin-token test-client user-token + wrap-setup]] + [auto-ap.ssr.ledger.export-modal :as sut] + [clojure.string :as str] + [clojure.test :refer [deftest is testing use-fixtures]] + [datomic.api :as dc])) + +(use-fixtures :each wrap-setup) + +(def report {:report/name "Profit-and-loss-2026-07-31-for-Acme" + :report/url "https://data.example.com/reports/pnl/abc/report.pdf"}) + +(defn client-with-emails + "Transacts a client with `emails` contacts. :client/emails is a plain ref, not + a component, so each nested contact needs its own tempid." + [emails] + (let [suffix (rand-int 1000000) + client-tid (str "client-" suffix) + contacts (map-indexed (fn [i e] (assoc e :db/id (str "email-contact-" suffix "-" i))) + emails) + tempids (:tempids @(dc/transact conn + (into [(test-client :db/id client-tid + :client/emails (map :db/id contacts))] + contacts)))] + {:db/id (get tempids client-tid)})) + +(defn modal-body [request report] + (:body (sut/export-modal request report))) + +(deftest export-modal-download-link + (testing "Should always offer the report for download" + (let [client (client-with-emails []) + body (modal-body {:identity (admin-token)} + (assoc report :report/clients [client]))] + (is (str/includes? body (:report/url report)) + "the S3 url should be linked") + (is (str/includes? body "to download"))))) + +(deftest export-modal-email-handoff + (testing "Should offer a mailto hand-off addressed to the client's contacts" + (let [client (client-with-emails [{:email-contact/email "owner@acme.com" + :email-contact/description "Owner"} + {:email-contact/email "cpa@acme.com"}]) + body (modal-body {:identity (admin-token)} + (assoc report :report/clients [client]))] + (is (str/includes? body "mailto:")) + (is (str/includes? body "owner@acme.com")) + (is (str/includes? body "cpa@acme.com")) + (is (str/includes? body "Owner") + "contacts should be described so the sender can see who it goes to"))) + + (testing "Should not offer a hand-off when the client has no email contacts" + (let [client (client-with-emails []) + body (modal-body {:identity (admin-token)} + (assoc report :report/clients [client]))] + (is (not (str/includes? body "mailto:"))))) + + (testing "Should not offer a hand-off to non-admins" + (let [client (client-with-emails [{:email-contact/email "owner@acme.com"}]) + body (modal-body {:identity (user-token (:db/id client))} + (assoc report :report/clients [client]))] + (is (not (str/includes? body "mailto:"))))) + + (testing "Should not offer a hand-off for a multi-client report" + (let [a (client-with-emails [{:email-contact/email "owner@acme.com"}]) + b (client-with-emails [{:email-contact/email "owner@beta.com"}]) + body (modal-body {:identity (admin-token)} + (assoc report :report/clients [a b]))] + (is (not (str/includes? body "mailto:")) + "one client's numbers must not be mailed to another client's contacts")))) + +(deftest mailto-href-encoding + (let [contacts [{:email-contact/email "owner@acme.com"} + {:email-contact/email "cpa@acme.com"}] + href (sut/mailto-href contacts report)] + (testing "Should address every contact" + (is (str/starts-with? href "mailto:owner@acme.com,cpa@acme.com?"))) + (testing "Should percent-encode the subject and body" + (is (str/includes? href "subject=Profit-and-loss-2026-07-31-for-Acme%20is%20ready")) + (is (not (re-find #"[\s\n]" href)) + "raw whitespace would truncate the href")) + (testing "Should carry the report url in the body" + (is (str/includes? (sut/email-body (:report/url report)) (:report/url report))))))