Skip to content

feat(web): chat transcript export - #79

Open
naasanov wants to merge 1 commit into
nick/member-refactorfrom
nick/transcript-export
Open

feat(web): chat transcript export#79
naasanov wants to merge 1 commit into
nick/member-refactorfrom
nick/transcript-export

Conversation

@naasanov

Copy link
Copy Markdown
Collaborator

Closes #73

Stacked on #78 — base is nick/member-refactor, so this diff shows only the feature. Merge #78 first; this will retarget to main automatically.

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/html and text/plain in one ClipboardItem, 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:

  • Block elements, no layout tables — the opposite of usual email-HTML advice, because tables are exactly what converts to text badly.
  • Inline style= only — clients strip <style> blocks on paste.
  • Explicit color/font-size on every text node — Outlook's Word engine inherits unreliably and will otherwise render black-on-black.
  • border-left for the sender accent, not background-color — Outlook drops div backgrounds; a border degrades to nothing rather than to an unreadable block.

navigator.clipboard.write must 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 through writeTextexecCommand → 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:59pm means exactly that.
  • Select just this day is real Eastern midnight-to-midnight, correct across DST — verified on the 23-hour spring-forward and 25-hour fall-back days, where a naive +24h silently clips or double-counts.
  • A range can legitimately contain zero messages; Copy disables with a reason.

Direct manipulation over date entry: clicking a message moves the nearer edge, hovering reveals explicit Start here / End here for when that guesses wrong, and shift-click sets the far edge. Edges are role="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

recordTranscriptExport lives in a new staff/ module, not admin/ — that module's invariant is that every export is superuser-gated, and this has to work for every social worker.

  • The client sends only the two endpoint message ids; the function derives timestamps and count server-side.
  • The watermark advances under a transaction and only ever moves forward, so re-exporting an older range can't regress it and re-surface archived messages as "new". Deriving server-side also means a bad client value can't poison it permanently.
  • Client writes to the watermark stay denied — chatSummaryKeys() is deliberately not widened, which is the reason this callable exists at all.
  • Each export appends to a staff-only transcriptExports audit 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

  • Adds @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.
  • The three transcript hooks have a circular dependency broken by ref-forwarding callbacks in the dialog shell. It's a bit clever; the alternative was widening the fetch effect's deps, which reintroduces "refetch on watermark change and discard the user's in-progress selection". Documented in place.
  • A shareable-link alternative to copy-paste was considered and rejected: they're BCCing into an archive, and an archive whose contents are a revocable URL isn't an archive. Worth revisiting for a different job (sharing with an outside attorney).

Verification

npm run typecheck, npm run lint, npm run build clean; backend/functions builds 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.

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>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant