feat(web): chat transcript export - #79
Open
naasanov wants to merge 1 commit into
Open
Conversation
Closes #73 CancerLINC staff run all client communication through email, so anything said in the in-app chat never reaches the client record. This adds an escape hatch: select a range of messages, copy, paste into an email, BCC the archive. The deliverable is less "a copy button" than a block of content engineered to survive a paste into Outlook/Gmail and read well as an archival record. Clipboard Writes text/html and text/plain in one ClipboardItem, so a rich composer gets formatted content and a plain one gets clean text from the same action. The HTML has to survive twice over: it must render in the mail client, and it must still read correctly after that client generates its own plain-text MIME part from it — our hand-written plain text usually isn't what reaches the archive. Hence block elements over tables (tables convert to text badly), inline styles only (clients strip <style> on paste), explicit color/font-size on every text node (Outlook inherits unreliably), and border-left rather than background-color for the sender accent. navigator.clipboard.write must run inside the click's gesture task or Safari fails it silently, so the strings are built synchronously and the watermark call fires strictly after the write resolves. Degrades through writeText and execCommand to a manual-select textarea, so a permission denial can't dead-end the feature. Selection The range is a real time range ({startMs, endMs}), not a pair of message indices; membership is derived. Indices could only express "message X to message Y", which made the date inputs snap to message timestamps and fight the user. "Select just this day" is genuine Eastern midnight-to-midnight, correct across DST (23h and 25h days included). Clicking a message moves the nearer edge; hover reveals explicit Start/End here buttons for when that guesses wrong, and shift-click sets the far edge. Edges are role=slider and arrow-steppable, so the interaction is fully keyboard operable. Backend recordTranscriptExport lives in a new staff/ module — not admin/, whose invariant is that every export is superuser-gated, and this must work for every social worker. The client sends only the two endpoint message ids; the function derives timestamps and count server-side. The watermark is advanced under a transaction and only ever moves forward, so re-exporting an older range can't regress it and re-expose archived messages as new. Deriving server-side also means an untrusted client value can't poison it permanently. Client writes to the watermark stay denied — chatSummaryKeys() is deliberately not widened. Exports append to a staff-only transcriptExports audit trail. Images export as [Image attachment: name] with no URL: Firebase download URLs bypass Storage rules, so emailing one is a permanent uncontrolled PHI leak. Adds @radix-ui/react-dialog for the modal — focus trap, focus restore, Escape and scroll lock, none of which the app's hand-rolled dialogs have today. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Closes #73
CancerLINC staff run all client communication through email, so anything said in the in-app chat never reaches the client record. This adds the escape hatch the issue asks for: pick a range of messages, copy, paste into an email, BCC the archive.
The real deliverable is less "a copy button" than content engineered to survive a paste into Outlook/Gmail and read well as an archival record.
Making it paste well
Copies both
text/htmlandtext/plainin oneClipboardItem, so a rich composer takes the formatted version and a plain one takes clean text — same click.The HTML has to survive twice over: render correctly in the mail client, and still read correctly after that client generates its own plain-text MIME part from it. Our hand-written plain text usually isn't what reaches the archive. That drove some choices that look wrong out of context:
style=only — clients strip<style>blocks on paste.color/font-sizeon every text node — Outlook's Word engine inherits unreliably and will otherwise render black-on-black.border-leftfor the sender accent, notbackground-color— Outlook drops div backgrounds; a border degrades to nothing rather than to an unreadable block.navigator.clipboard.writemust run inside the click's user-gesture task or Safari fails it silently, so the strings are built synchronously and the watermark call fires strictly after the write resolves. It degrades throughwriteText→execCommand→ a manual-select textarea, so a clipboard permission denial can't dead-end the whole feature.Selection
The range is a genuine time range (
{ startMs, endMs }), and message membership is derived from it. It started as a pair of message indices, which could only ever express "message X to message Y" — so the date inputs could only hold a value equal to some message's timestamp and snapped away from whatever you typed. Consequences of the change:8/9 12:00am → 8/10 11:59pmmeans exactly that.Select just this dayis real Eastern midnight-to-midnight, correct across DST — verified on the 23-hour spring-forward and 25-hour fall-back days, where a naive+24hsilently clips or double-counts.Direct manipulation over date entry: clicking a message moves the nearer edge, hovering reveals explicit
Start here/End herefor when that guesses wrong, and shift-click sets the far edge. Edges arerole="slider"and arrow-steppable, so it's fully keyboard operable. Rows never move when the selection changes — the band is a contiguous background over a flat, never-re-parented list, with the edges as absolute overlay pills.Backend
recordTranscriptExportlives in a newstaff/module, notadmin/— that module's invariant is that every export is superuser-gated, and this has to work for every social worker.chatSummaryKeys()is deliberately not widened, which is the reason this callable exists at all.transcriptExportsaudit trail.Privacy
Images export as
[Image attachment: name.jpg]with no URL. Firebase download URLs carry a token that bypasses Storage rules, so putting one in an email is a permanent, unrevocable PHI leak. Sender emails appear in the transcript (an acceptance criterion) but were removed from the modal UI, where they only added noise.Notes for review
@radix-ui/react-dialog— focus trap, focus restore, Escape, scroll lock, none of which the app's four hand-rolled dialogs have. The other three aren't migrated here.Verification
npm run typecheck,npm run lint,npm run buildclean;backend/functionsbuilds and lints.Exercised end-to-end against the deployed backend: dual-flavor clipboard confirmed by reading both flavors back off the clipboard; the monotonic guard confirmed by exporting an older range and watching the watermark hold.