Skip to content

In-app Chat (Grimoire) - #577

Open
skarif2 wants to merge 9 commits into
masterfrom
feat/in-app-chat-grimoire
Open

In-app Chat (Grimoire)#577
skarif2 wants to merge 9 commits into
masterfrom
feat/in-app-chat-grimoire

Conversation

@skarif2

@skarif2 skarif2 commented Jun 26, 2026

Copy link
Copy Markdown
Collaborator

Summary

Adds real-time, authenticated, participant-only in-app chat between a donor and a seeker, full-stack (backend business logic, AWS infrastructure, and the Expo mobile client). A chat channel opens automatically when a donor accepts a blood request (and re-opens on re-accept), locks when the request resolves (IGNORED, COMPLETED, CANCELLED, or EXPIRED), and purges messages after 90 days. Users get live delivery over WebSocket with an offline push-notification fallback, a channel inbox with an unread indicator, and paginated newest-first history. The whole feature is gated behind the EXPO_PUBLIC_CHAT_ENABLED build-time flag so it can be shipped dark and rolled out deliberately.

The work was delivered in seven dependency-ordered phases (domain model → service layer → infrastructure → handlers → OpenAPI/REST wiring → mobile data layer → mobile UI).

Fixes / Resolves

Type of change

  • Feature
  • Infra
  • Bugfix
  • Refactor
  • Docs
  • Other: __________

What changed

Domain model & DTOs (commons/)

  • New commons/dto/ChatDTO.ts: ChatChannelDTO, ChatMembershipDTO, ChatMessageDTO, ChatConnectionDTO, the ChatContextSnapshot, ChatChannelStatus/ChatRole enums, and the shared buildChannelId / parseChannelId / isChannelParticipant helpers (one source of truth for the composite channelId = "seekerId#requestPostId#donorId").
  • Added CHAT_MESSAGE to the NotificationType enum.

Business logic (core/application/chatWorkflow/)

  • ChatService (AWS-free): openChannel, sendMessage, listChannels, markRead, getHistory, lockChannel, lockChannelsForRequest.
  • Participant-only enforcement by parsing channelId (no DB read); send path enforces not-locked + an atomic DynamoDB rate limit (60 msg/min/channel).
  • Three typed errors mapped to HTTP codes: not-a-participant (401), locked (400), rate-limited (429).
  • BloodDonationService hook: locks channels on COMPLETED (per donor) and CANCELLED/EXPIRED (per request), via an optional injected ChatService (existing call sites unaffected).

Storage (single DynamoDB table)

  • Channel item (PK=CHANNEL#<seekerId>#<requestPostId>, SK=DONOR#<donorId>) with status + a request-context snapshot.
  • Per-participant membership items (PK=CHATUSER#<userId>) so each user lists their own channels without a shared GSI.
  • Time-ordered message items with a 90-day TTL; connection items keyed by connectionId with GSI1 on userId for fanout.

Infrastructure (iac/terraform/aws/chat/)

  • Self-contained module: WebSocket API ($connect/$disconnect/sendMessage routes), a REQUEST Lambda authorizer on $connect, explicit deployment + stage, the six WS/pipe Lambdas and three REST Lambdas, and IAM.
  • Two EventBridge stream pipes: INSERT(ACCEPTED#) → channel creator, REMOVE(ACCEPTED#) → channel locker.
  • Enabled DynamoDB TTL on the shared ttl attribute.

Lambda handlers (core/services/aws/chat/)

  • chatConnect / chatDisconnect, chatConnectAuthorizer (verifies the Cognito access token with aws-jwt-verify), chatChannelCreator / chatChannelLocker (pipe handlers), chatSendMessage (resolve sender by connection, persist, fan out via ApiGatewayManagementApi, prune stale connections, enqueue offline push), and the chatGetHistory / chatListChannels / chatMarkRead REST handlers.

REST API (openapi/)

  • New bloodconnect-chat endpoints: GET /chat/channels, POST /chat/history, PATCH /chat/read (POST/PATCH with JSON bodies so the composite channelId and the opaque pagination cursor round-trip cleanly), wired to their Lambdas via x-amazon-apigateway-integration and VTL templates.

Mobile (clients/mobile/src/chat/)

  • Data layer: ChatWebSocketClient (exponential-backoff reconnect, offline send queue), chatService (REST), and the useChatInbox / useChatRoom hooks. Unread is derived client-side.
  • UI: ChatInbox (channel list + unread badge), ChatRoom (sent/received bubbles, context header, read-only locked banner), and ChatRoomHeader.
  • Entry points: a Chat button on the seeker's accepted-donor list and on the donor's "My Responses" card; CHAT_MESSAGE push deep-links to the room.
  • Rollout gate: isChatEnabled() (EXPO_PUBLIC_CHAT_ENABLED) gates both the navigation routes and the entry-point buttons.

Notable design decisions

  • Channel listing uses per-participant membership items, not a GSI — the table's single overloaded GSI cannot index one channel under both participants.
  • Cancel/expire lock via an in-service hook, not a stream pipe, because those transitions don't delete the acceptance record (no REMOVE event fires); only IGNORED does.
  • getHistory returns { channel: {status, context}, page }, not just messages, so the chat room header and locked banner render on a cold-start deep-link from the one call the room already makes — no separate post fetch. (The context was snapshotted onto the channel at creation specifically for this path.)
  • WebSocket auth is a REQUEST Lambda authorizer with the access token on the querystring, because API Gateway v2 native JWT authorizers are HTTP-API-only and WS clients can't set $connect headers.

Checklist

  • Tested locally (unit + render tests; full suite green — see below)
  • Added/updated tests or docs
  • Follows code style guidelines (ESLint clean on all changed files)

Dependencies

  • Depends on: #

Testing & verification

  • Full suite: TZ=UTC npm test52 suites / 384 tests pass (the 2 formattedDate cases are local-timezone artifacts that pass under TZ=UTC/CI).
  • Backend: ChatService, DDB adapters, pipe/REST/WS handlers, and the BloodDonationService lock hooks are unit-tested (aws-sdk-client-mock).
  • Mobile: WS reconnect/backoff, offline-queue flush, unread derivation, markRead-on-open, CHAT_MESSAGE routing, and render tests for ChatInbox / ChatRoom (header on deep-link, open vs locked) plus the entry-point and rollout-flag gating.
  • Type-check: zero errors reference any new/edited file (the few errors in touched files are pre-existing Toast/FlatList typings at shifted line numbers).
  • Infra: terraform validate + fmt -check pass per-module for the chat and dynamodb modules; make lint-api is clean. Root tf-validate is deferred to CI (a pre-existing template_file provider limitation on darwin_arm64).

Notes

  • No automated end-to-end test. API Gateway v2 WebSocket APIs and EventBridge Pipes are LocalStack Pro-only, so the live WebSocket flow (connect → send → fanout → offline push) is verified manually on a deployed dev environment.
  • Shipping dark. With EXPO_PUBLIC_CHAT_ENABLED unset, no chat routes or entry points are exposed and a build without WEBSOCKET_URL still succeeds.
  • Known risk (minor): listChannels has no pagination cursor and membership items have no TTL, so a very prolific user's channel partition could eventually truncate at the 1 MB query page; mitigation is to add a cursor mirroring getHistory if it ever bites.
  • Out of scope: read receipts, end-to-end encryption beyond TLS + at-rest, org/monitoring web dashboards, group chat, and media/attachments.

Cost Breakdown (Claude Generated)

Session Phase Local time Active work Peak context Cost (API-equiv)
a5d82217 /init + /plan 10:40 → finished later 38 min 244K $23.82
e65f1baa /gg Phase 1 11:32 → 11:47 10 min 103K $9.66
9ff1ce90 /gg Phase 2, 3, 4 11:47 → 16:51 88 min 380K $114.04
2cb410bd /gg Phase 5 16:52 → 17:12 20 min 164K $19.62
caf199cf /gg Phase 6 17:14 → next day 25 min 156K $20.46
e469dc3c /gg Phase 7 17:39 → 00:18 74 min 276K $67.41

Feature total: ≈ $255 · Active work: ≈ 4.25 h · spread across a 14.2 h window (10:40 AM → 00:52 AM) because of the two ~5-hour limit resets you waited through.

skarif2 added 8 commits June 26, 2026 11:46
Phase 1 of in-app donor/seeker chat. Adds ChatChannelDTO/ChatMembershipDTO/ChatMessageDTO/ChatConnectionDTO and CHAT_MESSAGE notification type; the four single-table DDB models (channel keyed per request for per-request lock, per-participant membership items for channel listing, time-ordered messages with 90-day TTL, connections keyed by connectionId with GSI1 on userId); and the ChatRepository + ChatConnectionRepository ports. Model unit tests cover all four access patterns.
ChatService (AWS-free) with openChannel/sendMessage/listChannels/markRead/getHistory/lock; participant + locked + rate-limit rejection. Atomic per-channel rate limiter via a conditional DynamoDB counter. Composite Chat/ChatConnection DynamoDB adapters implementing the Phase 1 ports, plus targeted lastMessageAt bumps. channelId helpers moved to commons so the application layer stays adapter-free.
WebSocket connect/disconnect/sendMessage handlers plus a Cognito JWT $connect authorizer (aws-jwt-verify). sendMessage resolves the sender from the connection store, enforces participant-only/not-locked/rate-limit via ChatService, fans out to both participants' live connections, prunes stale ones on 410, and enqueues a CHAT_MESSAGE push for offline recipients. EventBridge pipe handlers open the channel (snapshotting request context from the donation post) on accept and lock it on ignore. BloodDonationService locks channels on complete (per-donor), cancel and expire. REST getHistory/listChannels/markRead handlers, participant-only. Adds @aws-sdk/client-apigatewaymanagementapi and aws-jwt-verify.
Add GET /chat/channels, POST /chat/history and PATCH /chat/read paths, schemas and Lambda integrations, and register the three REST chat lambdas (filtered lambda_metadata) so the existing API-GW invoke permission and OpenAPI ARN substitution cover them.
WebSocket client with token-querystring auth and exponential-backoff reconnection; useChatInbox (client-side unread from lastMessageAt vs lastReadAt, optimistic markRead with rollback) and useChatRoom (REST history, live fanout, offline send-queue flushed on reconnect, markRead-on-open); CHAT_MESSAGE notification routes to a new ChatRoom screen. WEBSOCKET_URL is an optional Expo config var so chat-gated builds still succeed.
ChatInbox and ChatRoom screens with context header and read-only locked banner, gated behind EXPO_PUBLIC_CHAT_ENABLED, with Chat entry buttons on the seeker donor list and donor responses. getHistory now returns the channel status+context snapshot alongside the message page so the room header renders on a cold-start deep-link without a separate post fetch.
@skarif2 skarif2 self-assigned this Jun 26, 2026
Resolve the three tf-security (Checkov) findings on the chat WebSocket infra so `make tf-security` passes:

- CKV_AWS_76: enable stage access logging via a dedicated CloudWatch log group + access_log_settings (mirrors the REST api_gw pattern)
- CKV_AWS_309: suppress on the $disconnect and sendMessage routes; the connection is authorized once at $connect via the Cognito custom authorizer and WebSocket routes do not re-authorize per message
- CKV2_AWS_51: suppress on the stage; client-cert/mTLS applies to HTTP backends, but the chat integrations are Lambda AWS_PROXY

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LsNmuBjgsa4aBfKg6AmbWE
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.

FEAT: In app chat among donor and seeker

1 participant