Operations Docs
Chat API System Design
/operations/chat-api-system-designChat API System Design
Date: 2026-05-06
Purpose
Membloc currently has two chat backends:
- Firebase/Firestore is the default production chat path.
- App Engine already has a Stage 14G HTTP chat bridge for channels, messages, pinned messages, attachments, read cursors, and push fanout.
The next API cycle should make the engine chat domain safe to run under
MEMBLOC_BACKEND_MODE=engine without losing the newer Firebase chat
features: requested messages, due/completion state, vote/proposal cards,
thread state, reactions, edit/delete, search, and live update semantics.
Current Facts
Engine routes already exist under:
GET /api/families/:familyId/channelsPOST /api/families/:familyId/channelsGET /api/families/:familyId/channels/:channelIdGET /api/families/:familyId/channels/:channelId/messagesPOST /api/families/:familyId/channels/:channelId/messagesGET /api/families/:familyId/channels/:channelId/pinned-messagesPOST /api/families/:familyId/channels/:channelId/messages/:messageId/pinDELETE /api/families/:familyId/channels/:channelId/messages/:messageId/pinPOST /api/families/:familyId/channels/:channelId/readGET /api/families/:familyId/channel-reads
Engine chat persistence currently has:
channelsmessageschannel_reads
Engine message type support currently allows only:
textimagevideoaudiofilecontextsystem
Flutter ChannelRepository expects a wider surface:
- requested-message stream and mutation
- requested-message due date and completion
- vote/proposal creation, response, close, promote, retry promote
- reaction stream and toggle
- edit and soft-delete
- channel state mute/draft
- thread state read/mute/draft
- search
- typing/presence
EngineChannelRepository currently polls every 3 seconds and throws
UnimplementedError for requested messages, votes, proposals, reactions,
edit/delete, and several state APIs.
Decision
Use Postgres/app-engine as the source of truth for engine mode. Do not mirror engine-mode chat writes back to Firestore.
Keep Firestore as the default path until the engine reaches feature parity for the user-visible chat flows. Firebase Cloud Functions can remain for legacy/default mode while engine mode gets equivalent HTTP endpoints.
Use HTTP polling for the next cycle, but design every endpoint as if it will later emit typed SSE/WebSocket events. This keeps the implementation small now without boxing the system into polling.
Architecture
flowchart LR
A["Flutter UI"] --> B["ChannelRepository"]
B --> C{"Backend mode"}
C -->|"firebase"| D["Firestore + Firebase Functions"]
C -->|"engine"| E["EngineChannelRepository"]
E --> F["/api/families/:familyId/channels/..."]
F --> G["ChannelHandler"]
G --> H["ChannelService"]
H --> I["ChannelRepository (Postgres)"]
H --> J["CalendarService"]
H --> K["PushService"]
I --> L["Postgres chat tables"]
Data Model Additions
1. Extend messages
Add typed JSON columns for card messages and summaries:
ALTER TABLE messages
ADD COLUMN vote JSONB,
ADD COLUMN proposal JSONB,
ADD COLUMN reaction_summary JSONB NOT NULL DEFAULT '{}'::jsonb,
ADD COLUMN search_prefixes TEXT[] NOT NULL DEFAULT '{}';
ALTER TABLE messages
DROP CONSTRAINT IF EXISTS messages_type_check,
ADD CONSTRAINT messages_type_check CHECK (
type IN (
'text', 'image', 'video', 'audio', 'file', 'context', 'system',
'vote', 'proposal'
)
);
CREATE INDEX idx_messages_search_prefixes
ON messages USING GIN (search_prefixes);
Store vote and proposal in JSONB for the next cycle because the
Flutter payload shape already exists and can evolve without adding many
narrow tables. If vote/proposal analytics become important, split them
into relational tables later.
2. Add requested-message projection
CREATE TABLE requested_messages (
message_id TEXT PRIMARY KEY REFERENCES messages(id) ON DELETE CASCADE,
family_id TEXT NOT NULL REFERENCES families(id) ON DELETE CASCADE,
channel_id TEXT NOT NULL REFERENCES channels(id) ON DELETE CASCADE,
requested_by TEXT NOT NULL REFERENCES users(uid),
requested_at TIMESTAMPTZ NOT NULL DEFAULT now(),
due_at TIMESTAMPTZ,
completed_at TIMESTAMPTZ,
completed_by TEXT REFERENCES users(uid),
assignee_uid TEXT REFERENCES users(uid),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE INDEX idx_requested_messages_channel_open
ON requested_messages(channel_id, requested_at DESC);
CREATE INDEX idx_requested_messages_family_due
ON requested_messages(family_id, due_at)
WHERE completed_at IS NULL;
This mirrors the Firestore sibling projection. It avoids scanning messages for requested items and gives the daily panel an efficient family-wide aggregate path.
3. Add reactions
CREATE TABLE message_reactions (
channel_id TEXT NOT NULL REFERENCES channels(id) ON DELETE CASCADE,
message_id TEXT NOT NULL REFERENCES messages(id) ON DELETE CASCADE,
uid TEXT NOT NULL REFERENCES users(uid),
emoji TEXT NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
PRIMARY KEY (message_id, uid, emoji)
);
CREATE INDEX idx_message_reactions_message
ON message_reactions(message_id);
messages.reaction_summary is maintained transactionally when reactions
change, so list responses do not need to aggregate per row.
4. Add user channel/thread state
CREATE TABLE channel_user_states (
family_id TEXT NOT NULL REFERENCES families(id) ON DELETE CASCADE,
channel_id TEXT NOT NULL REFERENCES channels(id) ON DELETE CASCADE,
uid TEXT NOT NULL REFERENCES users(uid),
muted BOOLEAN NOT NULL DEFAULT false,
pinned BOOLEAN NOT NULL DEFAULT false,
draft_text TEXT NOT NULL DEFAULT '',
last_read_message_id TEXT REFERENCES messages(id) ON DELETE SET NULL,
last_read_at TIMESTAMPTZ,
last_seen_at TIMESTAMPTZ,
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
PRIMARY KEY (channel_id, uid)
);
CREATE TABLE thread_user_states (
family_id TEXT NOT NULL REFERENCES families(id) ON DELETE CASCADE,
channel_id TEXT NOT NULL REFERENCES channels(id) ON DELETE CASCADE,
thread_root_message_id TEXT NOT NULL REFERENCES messages(id)
ON DELETE CASCADE,
uid TEXT NOT NULL REFERENCES users(uid),
muted BOOLEAN NOT NULL DEFAULT false,
draft_text TEXT NOT NULL DEFAULT '',
last_read_message_id TEXT REFERENCES messages(id) ON DELETE SET NULL,
last_read_at TIMESTAMPTZ,
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
PRIMARY KEY (channel_id, thread_root_message_id, uid)
);
The existing channel_reads table can be folded into
channel_user_states with a compatibility view or left in place for
the first migration. The cleaner target is one state table per scope.
5. Add presence/typing as ephemeral state
For the next API cycle, use in-memory TTL for typing status if only one
engine instance is running in dev/closed beta. For multi-instance
production, use Redis or Postgres with short TTL cleanup. Do not encode
typing state into messages.
API Additions
Message parity
PATCH /api/families/:familyId/channels/:channelId/messages/:messageId
DELETE /api/families/:familyId/channels/:channelId/messages/:messageId
GET /api/families/:familyId/channels/:channelId/messages/search
Patch body:
{
"content": "updated text",
"mentionUserIds": [],
"mentionDisplayNames": []
}
Delete is soft-delete only. Keep row identity so source links, calendar events, memories, and archives do not dangle.
Reactions
POST /api/families/:familyId/channels/:channelId/messages/:messageId/reactions/:emoji/toggle
Response for message list includes reactionSummary.
Requested messages
GET /api/families/:familyId/channels/:channelId/requested-messages
GET /api/families/:familyId/requested-messages
PUT /api/families/:familyId/channels/:channelId/messages/:messageId/requested
DELETE /api/families/:familyId/channels/:channelId/messages/:messageId/requested
PATCH /api/families/:familyId/channels/:channelId/messages/:messageId/requested
Patch body:
{
"dueAt": "2026-05-07T00:00:00Z",
"completed": true,
"assigneeUid": "uid-or-null"
}
Family-wide GET /requested-messages is required for the daily panel
and cross-channel aggregator.
Chat summary
GET /api/families/:familyId/chat/summary
Response includes channel-card summaries, recent cross-channel pinned notices, and open requested messages:
{
"channels": [
{
"id": "channel-id",
"name": "전체 가족",
"lastMessagePreview": "토요일 병원 잊지 말자",
"lastMessageSenderName": "민지",
"lastMessageAt": "2026-05-03T01:00:00Z",
"unreadCount": 2,
"openRequestedCount": 1
}
],
"pinnedNotices": [],
"requestedMessages": [],
"generatedAt": "2026-05-06T00:00:00Z"
}
Vote/proposal cards
POST /api/families/:familyId/channels/:channelId/messages/vote
POST /api/families/:familyId/channels/:channelId/messages/:messageId/vote
POST /api/families/:familyId/channels/:channelId/messages/:messageId/vote/close
POST /api/families/:familyId/channels/:channelId/messages/:messageId/vote/promote
POST /api/families/:familyId/channels/:channelId/messages/proposal
POST /api/families/:familyId/channels/:channelId/messages/:messageId/proposal
POST /api/families/:familyId/channels/:channelId/messages/:messageId/proposal/close
POST /api/families/:familyId/channels/:channelId/messages/:messageId/proposal/promote
Promotion calls should run inside a database transaction and call the calendar service in the same unit of work where possible. If a single transaction cannot span both service operations cleanly, use an idempotent outbox row:
CREATE TABLE chat_promotions (
id TEXT PRIMARY KEY,
family_id TEXT NOT NULL,
channel_id TEXT NOT NULL,
message_id TEXT NOT NULL,
kind TEXT NOT NULL CHECK (kind IN ('vote', 'proposal')),
status TEXT NOT NULL CHECK (status IN ('pending', 'completed', 'failed')),
calendar_event_id TEXT,
error TEXT,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
UNIQUE (channel_id, message_id, kind)
);
State APIs
GET /api/families/:familyId/channel-states
PATCH /api/families/:familyId/channels/:channelId/state
GET /api/families/:familyId/channels/:channelId/thread-states
PATCH /api/families/:familyId/channels/:channelId/threads/:threadRootMessageId/state
Patch body:
{
"muted": true,
"draftText": "...",
"lastReadMessageId": "..."
}
The existing channel-reads endpoint can remain as a compatibility
alias until Flutter switches to the state endpoints.
Live transport
Keep polling in this cycle. Add a versioned endpoint stub only after the DB events are shaped:
GET /api/families/:familyId/chat/events?cursor=...
Event shape:
{
"id": "monotonic-event-id",
"familyId": "...",
"channelId": "...",
"type": "message.created",
"entityId": "...",
"createdAt": "..."
}
This can be backed by a chat_events outbox table later. Do not start
with WebSocket state until message/state parity is done.
Backend Configuration
Required local/prod settings:
MEMBLOC_BACKEND_MODE=engine
MEMBLOC_ENGINE_URL=http://localhost:8080
FIREBASE_PROJECT_ID=homblabs-a1e67
FIREBASE_CREDENTIALS_PATH=/secure/path/service-account.json
DATABASE_URL=postgres://membloc:.../membloc
TOKEN_SECRET=...
Recommended new settings:
CHAT_POLL_MIN_INTERVAL_SECONDS=3
CHAT_MESSAGE_PAGE_LIMIT_DEFAULT=50
CHAT_MESSAGE_PAGE_LIMIT_MAX=200
CHAT_ATTACHMENT_READ_TTL_SECONDS=600
CHAT_TYPING_TTL_SECONDS=8
CHAT_FEATURE_REQUESTED_MESSAGES=true
CHAT_FEATURE_VOTE_PROPOSAL=true
CHAT_FEATURE_REACTIONS=true
CHAT_FEATURE_EDIT_DELETE=true
Feature flags are server-side guardrails. The Flutter app may still hide UI with build flags, but the API must reject unsupported feature calls deterministically instead of exposing partial behavior.
Permission Model
Baseline:
- Family member can read channels/messages/pinned/requested.
- Family member can send messages.
- Sender can edit or soft-delete own message.
- Family member can pin/unpin in MVP.
- Family member can mark requested messages.
- Requested-message completion can be changed by any family member for MVP; assignee-only can be a later policy.
- Vote/proposal response is self-only.
- Vote/proposal close/promote is family member in MVP, but the service must enforce idempotency.
- User state writes are self-only.
All endpoints must validate both family membership and channel/message
ownership within that family. Never trust familyId alone.
Implementation Phases
Phase 1: Safe parity foundation
- Add migrations for message type expansion, reactions, requested messages, channel/thread user state.
- Add repository methods with service-level membership validation.
- Add handlers and route registration for requested-message and state APIs.
- Wire
EngineChannelRepositoryrequested-message/state methods. - Add handler/service tests for 401, 403, 404, idempotent toggle, and requested due/completion.
Exit criteria:
- Long-press requested flow works in engine mode.
- Daily panel can read family-wide requested messages.
- No
UnimplementedErrorfor requested-message methods.
Phase 2: Message actions
- Add edit/soft-delete endpoints.
- Add reaction endpoints and transactionally maintain summary.
- Add search prefixes to message writes and search endpoint.
- Wire Flutter edit/delete/reaction/search methods.
Exit criteria:
- Engine mode supports the same basic chat room actions as Firebase for text/image/context messages.
Phase 3: Vote/proposal cards
- Extend
messages.typetovoteandproposal. - Persist
voteandproposalJSONB payloads. - Add submit/close/promote/retry-promote service methods.
- Integrate promotion with
CalendarService.CreateFromChator achat_promotionsoutbox. - Wire Flutter
EngineChannelRepositoryvote/proposal methods.
Exit criteria:
- Vote/proposal creation, response, close, and calendar promotion work in engine mode.
- Promotion is idempotent under repeated taps or network retry.
Phase 4: Transport hardening
- Add
chat_eventsoutbox when parity is stable. - Replace or augment polling with SSE.
- Keep
ChannelRepositorystream interface unchanged. - Add observability for active subscribers, event lag, DB query time, and push result counts.
Exit criteria:
- Polling can be reduced or disabled for active chat rooms.
- New message latency is below 1 second in normal network conditions.
Verification Plan
Engine:
go test ./...- Handler tests for every new route.
- Service tests with Postgres for transaction and idempotency.
- Migration up/down test on an empty DB and on a DB seeded with Stage 14G messages.
Flutter:
flutter analyzeflutter test test/repositories/engine_channel_repository_test.dart- Chat-room controller tests covering requested, reaction, vote, proposal, edit/delete under engine repository fakes.
Manual:
- Run app with:
flutter run \
--dart-define=MEMBLOC_BACKEND_MODE=engine \
--dart-define=MEMBLOC_ENGINE_URL=http://localhost:8080
- Verify:
- send text/image/context
- long-press pin/request
- requested due/completion
- reaction toggle
- edit/delete
- vote/proposal promote to calendar
- notification deep link opens channel/thread
Risks
- Dual-source drift: Firebase and engine schemas are already different.
Mitigation: only flip
MEMBLOC_BACKEND_MODE=engineafter parity exit criteria are met. - Polling load: current 3-second polling is acceptable for dev/closed beta, not public scale. Mitigation: add chat event outbox before scale-up.
- Promotion consistency: vote/proposal promotion touches chat and
calendar. Mitigation: use transaction when possible; otherwise use
idempotent outbox with unique
(channel_id, message_id, kind). - Attachment URLs: keep the Stage 14G-2 rule. Persist file identity, derive read URLs per response.
- Push credentials: engine logs show push sender disabled without
Firebase admin credentials. Production engine mode needs
FIREBASE_CREDENTIALS_PATH.
Recommended Next Step
Implement Phase 1 first. It directly unlocks the latest user-facing work: requested-message badges, due dates, completion, and daily panel aggregation under engine mode. Defer vote/proposal parity until the requested-message API is complete and tested.
SDK assets