Kulcare Docs
DeveloperConnectInbox

Connect Inbox -- developer guide

The Inbox is where a practitioner sees the things addressed to them across every facility they work at: a referral from a colleague, a lab result against an order they placed, a message from another practitioner. Issue #2449, PR #2463.

It is not a chat. Every message is produced by something that happened, and a message is an ordered array of typed blocks rather than a string. Read that sentence again before changing anything here -- most of the design follows from it.

HalfWhereRead first
Backendbackend/domains/connect/models/inbox_thread.rb, then concerns/inbox_party.rb
Frontendfrontend/apps/connect/src/components/inbox/frontend/packages/shared-services/src/domains/connect/inbox.hooks.ts

Other documents: requirements and plan, legacy UI reference, user guide, QA guide.


1. The promises the code keeps

When a change would break one of these, it is the wrong change.

  1. visible_to(reader) is the entire access control. These tables carry no row-level security, so a query that reaches them another way is a cross-tenant leak. There is no second predicate anywhere, and there must not be.
  2. No clinical document enters the Inbox. A document block names a record by reference_type and reference_id and resolves through that record's own rules. The model rejects a link on it rather than merely not setting one.
  3. No patient name appears in a produced message, or on the realtime channel. The producers never read a referral's reason or notes -- free text a human wrote routinely names the patient.
  4. An action is re-derived on the server. The client's {link, method} is compared and discarded; a symbol derived from the stored message is what dispatches.
  5. A blocked sender cannot tell they were blocked. The block is checked after the per-party rule, so the refusal is whatever that pairing already emits.

2. Why there is no RLS here

Connect::BaseController#clear_tenant_context! empties the app.current_tenant_id GUC on every Connect request, because Connect reads across every tenant a practitioner belongs to. A tenant_isolation policy on a connect.* table would therefore return zero rows to every reader, not the right rows.

So connect.* and notifications.* carry no RLS, by design, and scoping is application-layer only. That is the single most important fact about this code, and the reason promise 1 is written the way it is.

Two consequences worth knowing:

  • A FORCE-RLS table read from here returns zero rows; it does not raise. Clinical::Patient is FORCE-RLS, so a patient counterparty resolves to nil and renders without a name. Do not wrap such a read in a rescue and call the exception "expected" -- it cannot happen, and the rescue will then hide a real fault instead (this was a review finding).
  • Cross-tenant scoping tests are load-bearing. Every list endpoint needs one asserting another practitioner's rows are invisible. With no RLS backstop, that assertion is the only thing between a bug and a leak.

3. Data model

Six tables in the connect schema. Migrations 20260920040000, 050000, 060000, 070000, plus 080000 which adds a policy to clinical.referrals.

TableHolds
inbox_threadsOne row per counterparty pair. party_a_* / party_b_* are canonically ordered, so A-with-B and B-with-A are one row.
inbox_messagesThe messages. message_payload is a jsonb array of blocks. Five attachment_* columns.
inbox_sender_blocksDirectional rows; the predicate is symmetric.
inbox_reportsA whole moderation ticket: status, assignee, resolution, and an evidence snapshot.
pharma_opt_insCurrent state, one active grant per practitioner-vendor pair.
pharma_opt_in_eventsThe immutable audit trail behind it.

A thread was never an entity before

The legacy implementation computed one with DISTINCT ON (sender_id, sender_type) at query time. Here it is a real row, which is what makes unread counts, blocking and a stable sort possible.

Canonical ordering is enforced twice: before_validation :canonicalise_parties in the model, and a CHECK constraint with COLLATE "C" in the database. InboxParty.sort_key must agree with that CHECK, including its nil-UUID sentinel for the 'System' party. The unique index COALESCEs both party ids and facility_id to that same sentinel.

Blocks

message_payload is ordered and typed: from, title, text, html, document, action.

Only from, title and text may be composed by a person. html, document and action are producer-only, and the compose services refuse them. An unknown block type is stored unvalidated on purpose, so a newer producer does not need a schema change -- but that also means R22a is enforced for the exact key link, not for every URL-shaped key. Treat a new block type as a contract change.


4. Endpoints

GET    /connect/me/inbox                                 threads, per counterparty
GET    /connect/me/inbox/:thread_id                      one thread, oldest first; marks read (see below)
POST   /connect/me/inbox/threads                         open a thread
POST   /connect/me/inbox/threads/:thread_id/messages     reply
POST   /connect/me/inbox/actions                         run one action block
GET    /connect/me/inbox/messages/:message_id/attachment stream a file
GET    /connect/me/inbox/blocks                          the reader's own blocks
POST   /connect/me/inbox/blocks                          block
DELETE /connect/me/inbox/blocks                          unblock -- NO row id
POST   /connect/me/inbox/reports                         report a message

GET    /connect/admin/inbox_reports[/:id]                platform admin only
PATCH  /connect/admin/inbox_reports/:id                  assign, resolve, reopen

Reading a thread marks the served page read. With newest_first=true, page 1 also marks every older unread message in the thread, up to the newest message it served. The client opens a thread newest first and fetches older pages only on scroll, so without this the unread count of a thread longer than one page never reached zero. Other pages mark only themselves.

Route order matters: /blocks is declared above get 'inbox/:thread_id', which would otherwise swallow it as a thread whose id is the string "blocks". There is a test for it.

The Inbox sits on Connect::PersonalEndpointBase, which writes one Audit::AdminAccessLog row per request (HIPAA 164.312(b)). That log records the keys of a write, not its values -- see §8.


5. Where the messages come from

Clinical::ReferralReceivedNotificationJob ─┐
Clinical::ReferralStatusNotificationJob   ─┴─> Connect::InboxReferralMessageJob
                                                 └─> InboxProducers::ReferralProducer

Clinical::DiagnosticReport after_commit ──────> Clinical::ResultLandedNotificationJob
                                                 └─> Clinical::Results::LandedNotifier

Practitioners::ProfileCreationService (#2464) ─> Connect::InboxWelcomeMessageJob
                                                 └─> InboxProducers::WelcomeProducer

event_type holds the record's Rails class name -- Clinical::Referral, Clinical::DiagnosticReport, Identity::Practitioner. Not a demodulized word. RouteDeriver once matched 'referral' against a stored 'Clinical::Referral', which made every Accept answer 501 and left the whole cross-tenant accept unreachable; producer_dispatcher_integration_test.rb exists so that cannot recur, and it deliberately takes no event_type argument.

The bell (notifications.notifications) and the Inbox are separate stores. One referral writes a row in each. Do not try to derive one from the other.


6. The action dispatcher

InboxActions::Dispatcher#call does four things in order:

  1. load the message through visible_to (404 otherwise);
  2. derive the permitted actions from the stored payload;
  3. compare the client's asserted {link, method} against them as whole strings;
  4. dispatch the derived symbol, never a string off the wire.

An action this build cannot map answers a sentence ("this version of Kulcare cannot run it yet"), not a 500. If you see that sentence for a referral, step 2 and the producer disagree about event_type.


7. Cross-tenant referral accept

The case: a referee who holds no membership in the sending tenant -- the independent specialist the cross-tenant read was built for -- could see a referral and not accept it, because clinical.referrals is FORCE-RLS and referral_referee_read is SELECT-only.

Migration 20260920080000 adds a FOR UPDATE policy keyed on the referee GUC. The policy alone is not enough, because RLS restricts rows and not columns, and a column privilege is invisible both to the table owner and to any role that already holds table-wide UPDATE -- which the app role does, and must keep, since the sender's own tenant edits reason, notes and priority through the ordinary update path.

So the accept assumes a second, deliberately impoverished role for the length of one statement:

kulcare_referral_referee
  NOSUPERUSER NOBYPASSRLS NOLOGIN NOINHERIT NOCREATEDB NOCREATEROLE
  GRANT SELECT             ON clinical.referrals
  GRANT UPDATE (status, accepted_at, completed_at, updated_at)
  GRANT USAGE              ON SCHEMA clinical
  -- and nothing else, anywhere

RlsContext.with_referee_write assumes it and restores the previous role. The policy carries no TO clause -- pg_dump would write it into structure.sql verbatim and never emit the CREATE ROLE, so the file would fail to load. The narrowing lives inside the USING expression as CURRENT_USER = 'kulcare_referral_referee'.

Neither the role nor its grants can live in structure.sql (pg_dump -x). db:referral_referee_role:provision creates them, db:rls_test_role:provision calls it, and test_helper.rb's parallelize_setup provisions it per worker database -- without that, 19 write-security tests skip, and bin/ci-test-domains counts only failures and errors, so skipping reads as green.


8. Attachments

has_one_attached is not used, and that is not a style choice: public.active_storage_attachments.record_id is a bigint while every domains/ model has a uuid primary key, so the join row stores record_id = 0 and records of a type collide. That defect is issue #2467 and is latent in five other models today. The Inbox owns five columns on its own table and addresses bytes by its own key, while still storing through a registered ActiveStorage service.

  • No blob URL is ever emitted. ActiveStorage is mounted at / with no authentication, so a signed blob id is a bearer capability. Connect::InboxAttachmentsController authorises the reader as a party and then streams. Holding the URL is not authorisation, and a non-party gets 404 rather than 403.
  • The content type comes from the bytes, via Marcel, not from what the client declared. SVG is deliberately absent from the allowlist. 10 MB, one file per message.
  • Only a composed message may carry a file, where composed means the reserved category and a blank event_type/event_id, so a producer cannot claim it.

Retention is two ages -- 2 years for a message, 1 year for its file, because the file is the personal-data-dense object while the surrounding text is administrative correspondence. Connect::InboxRetentionJob does the sweep each night at 21:30 UTC, from config/schedule.rb (#2468). The sweep also clears the evidence snapshot of a report older than 2 years. Account closure clears the snapshot of each report that touches the closed party, and erases the correspondence of the practitioner, vendor and patient records of the user. A file whose delete fails keeps its reference, is counted as a failure, and is tried again the next night. When that file belongs to a message past its age, the message also stays, and the sweep counts it apart as messages_kept_past_max_age.

One bad row does not stop the sweep. Each row (message, attachment purge, empty thread, report snapshot) and each step has its own rescue. The night sends one report that names every failure. A message kept past its age makes that report an error, not a warning. When a row or a step failed, the sweep raises Connect::InboxRetention::SweepFailed at the end, so the job fails in Sidekiq. The job has no Sidekiq retry (the next night runs again). A file delete failure alone does not raise. The evidence prune skips a snapshot whose messages is not an array; the 2-year report rule erases it later. It drops a copied message with a missing or malformed created_at as old.

The audit log is not a copy of the correspondence

PersonalEndpointBase redacts by allowlist on a non-GET request: identifiers and pagination knobs keep their values, everything else is recorded as its key with the value redacted. A GET keeps its values.

This matters because audit.admin_access_logs sits outside every control the Inbox builds -- it is cross-tenant, readable by any platform admin, and reached by neither InboxRetention nor the account-closure erase. A body copied there outlives the message it came from. The previous blocklist of eight credential keys let message bodies, report details and attachment filenames through the moment this controller first served a POST.


9. Realtime

Connect::InboxMessage carries after_commit :publish_realtime_signal, on: :create. The hook is on the model, not in each producer: every write path goes through it, and a producer that forgets to publish is a thread that silently never updates, which review does not catch.

The payload is { id, thread_id } and nothing else -- not even category, which is an open vocabulary whose values name the producing event and so describe the content. The client treats it as a refetch signal and re-hits the endpoint, where visible_to is the access control. useConnectRealtime listens to connect.inbox.message_created and invalidates connectKeys.inbox.all(), debounced with the notification events (#2380). The Ably token grants subscribe and deliberately not history.

Shared::RealtimePublisher is the shared publisher. Its raise_on_error: flag makes the failure posture the caller's choice: a job with retry_on raises (a dropped signal is lost otherwise); a webhook whose write already committed logs.


10. Frontend

The hooks and keys live in @kulcare/shared-services (inbox.api.ts, inbox.hooks.ts, connectKeys.inbox), following the feed's precedent. The app holds the components only.

  • connectKeys.inbox.threads.all() is a prefix of every list and of no thread, so reading a thread invalidates the list without invalidating itself.
  • usableParams drops undefined, null and blank strings only. A falsy-dropping version turns read=false into "the whole inbox" with nothing on screen saying so.
  • runInboxAction reads a refusal envelope back off the axios rejection, because the actions controller renders the same success shape on every outcome. The compose, block and report mutations reject instead, and getAPIErrorMessage reads error.message ahead of any status default -- so the server's own words reach the screen.
  • queryPhase in components/direction-b/queryStates.tsx separates offline, loading, error and the empty states. A paused offline query is isPending && isPaused; do not re-derive this.
  • documentLabel strips the Rails namespace. Without it the fallback renders "Clinical::Referral" to a doctor.
  • Both panes are paged. useInboxThreadPages loads 20 conversations at a time. useInboxThread asks for a thread with newest_first=true, so page 1 is the newest 50 messages, and fetchOlder loads the page before them. It merges the pages oldest first.
  • useInboxUnreadCount reads meta.total_unread_count for the count on the Inbox sidebar entry.

11. Testing

# backend
cd backend
RAILS_ENV=test PARALLEL_WORKERS=1 bundle exec rails test domains/connect/test

# frontend
cd frontend
mise exec node@22 -- pnpm --filter @kulcare/connect exec vitest run
mise exec node@22 -- pnpm --filter @kulcare/shared-services exec vitest run

Three traps this suite has already fallen into:

  1. A missing route also answers 404. A test asserting "a non-party is refused" passes against an endpoint that does not exist. Pair every negative with a positive control in the same test.
  2. A skipped test reads as green. kulcare_rls_test is granted table-by-table, so any table created after the last provision makes its tests skip. Re-run RAILS_ENV=test bundle exec rake db:rls_test_role:provision after every migration, and treat a non-zero skip count on this suite as a finding.
  3. The local test role bypasses RLS. An assertion outside rls_enforced! / with_rls_enforced_role cannot fail. To prove a policy bites, drop it and watch the test go red.

Open test gaps are listed in #2475.


12. Not built yet

WhatWhere it stands
The S3 bucketThe credential is unset, so staging writes Inbox attachments to local disk. #2468
Pharma opt-in surfaceTable, services and audit exist; no controller and no route, so vendor messaging is refused by design until the Connect consent screen lands.
Patient-initiated messagingInitiationRules::PATIENT_INITIATION_SWITCH is off.

On this page