Skip to content

In-app messaging

In-app Messaging keeps coach-client communication inside CoachMe with reply context, reactions, GIFs, and reliable delivery state.

Tags: Communication, Replies, Reactions, GIFs, Read receipts

Area: Communication / Conversation Threads / Realtime Delivery

The V1 baseline messaging contract covers one-to-one coach-client conversations, text messages, message replies, reaction toggles, GIF messages, read receipts, push handoff, and mobile offline queues. REST remains the source-of-truth contract, while realtime delivery uses a gateway that publishes the same message DTOs to connected web and mobile clients.

Boundary decision

Communication owns conversation membership, messages, reply references, reactions, GIF metadata, read state, delivery receipts, and notification intents. It references client relationships and users by external ids instead of mutating CoachClientManagement or IdentityAccess aggregates.

V1 baseline

Replies are flat message references, not nested discussion trees. A reply stores the target message id plus a small immutable preview snapshot so the UI can still show context if the original message is later deleted, redacted, or outside the loaded window.

Messaging Flows

The feature must feel instant when online but still preserve idempotency, moderation hooks, and mobile retry behavior.

Send Text Or GIF

  1. Coach or client opens the active relationship conversation from inbox, profile, Today, workout review, or notification deep link.
  2. Client loads recent messages, participant state, read cursor, reaction summaries, and a pagination cursor.
  3. Sender creates a local pending message with a stable client_message_id before calling the API.
  4. Text messages submit sanitized rich text; GIF messages submit selected provider metadata, content rating, preview dimensions, and attribution.
  5. Backend verifies relationship access, validates content, persists the message transactionally, emits outbox events, and returns canonical ids.
  6. Connected recipients receive the same message DTO over the realtime gateway; disconnected recipients receive push according to notification settings.

Reply To A Message

  1. User taps reply on a visible message and the composer stores a reply_to_message_id.
  2. API confirms the target belongs to the same conversation and is visible to both participants.
  3. Message creation stores a ReplyReference snapshot with target author, message kind, short preview, and redaction state.
  4. Conversation ordering remains chronological by the new message timestamp; the reply preview links back to the target when loaded.
  5. If the target message is later redacted, the reply preview shows a redacted state instead of leaking removed content.

React To A Message

  1. User selects a supported reaction key such as like, heart, clap, fire, check, or laugh.
  2. Client applies an optimistic reaction count update and sends a toggle command with a client operation id.
  3. Backend validates that the actor can see the message, writes or removes the active reaction, and emits a reaction event.
  4. Other participants receive updated reaction summary counts and the actor's own reaction state.
  5. Removing a reaction sets removed_at for audit and replay instead of hard deleting the row.

Offline And Retry

  1. Mobile apps write outgoing text, reply, reaction, read, and selected GIF operations into SQLite first.
  2. Text and reaction commands can queue offline; GIF search requires network, but a selected GIF payload can be sent later from the queue.
  3. Sync sends ordered operations with device id, client operation id, local creation time, and expected conversation revision.
  4. Server deduplicates by conversation, actor, device, and client operation id, then returns canonical ids and rejected operations.
  5. Client replaces local pending state with sent, failed, or needs-attention state without duplicating messages.

Domain Model

Messaging belongs to Communication and references users, roles, and relationships through external ids and policy checks.

Aggregates & Entities

  • ConversationThread: aggregate root for one relationship-scoped conversation, participant set, status, last message summary, unread counts, and revision.
  • ConversationParticipant: user membership, role in the thread, mute/archive state, last read message, and notification preference snapshot.
  • ConversationMessage: sent content item with sender, message kind, body, reply reference, status, timestamps, and source device metadata.
  • MessageGif: GIF payload for messages that reference an external GIF provider asset instead of app-uploaded binary media.
  • MessageReaction: actor reaction to a message with reaction key, active/removed state, and idempotency metadata.
  • MessageReadReceipt: participant read cursor and optional per-message receipt event for audit and realtime fanout.
  • ConversationDeliveryReceipt: accepted, delivered, failed, and push-attempt state used for sync and support diagnostics.

Value Objects & Rules

  • MessageKind: TEXT, GIF, ATTACHMENT, VOICE_NOTE, SYSTEM.
  • MessageBody: sanitized text with max length, mention references, normalized whitespace, and no executable markup.
  • ReplyReference: target message id, target sender id, target kind, short preview, target created time, and redaction flag.
  • ReactionKey: curated app-supported emoji or symbolic reaction code. Arbitrary uploaded reaction assets are out of scope for V1 baseline.
  • GifProviderAsset: provider, provider asset id, title, content rating, selected rendition keys, original dimensions, provider page URL, attribution fields, and analytics metadata required by provider terms.
  • MessageStatus: PENDING, ACCEPTED, DELIVERED, READ, FAILED, REDACTED.
  • A message can reply to one visible message in the same conversation. Replies cannot target redacted system-only messages.
  • Reaction toggles are idempotent by actor, message, reaction key, device id, and client operation id.
  • GIF messages store provider references and attribution metadata; CoachMe does not ingest, proxy, rewrite, or cache provider GIF binaries or media URLs unless a later provider-approved caching policy requires it.
ModelOwned ByImportant MethodsEmits
ConversationThreadCommunicationopenForRelationship(), addParticipant(), recordMessage(), advanceRevision(), archiveForParticipant()ConversationOpened, ConversationUpdated
ConversationMessageCommunicationcreateText(), createGif(), withReplyTo(), accept(), redact(), toNotificationPreview()MessageSent, MessageRedacted
MessageReactionCommunicationadd(), remove(), restore(), summarize()MessageReactionAdded, MessageReactionRemoved
MessageReadReceiptCommunicationmarkReadThrough(), dedupeDeviceReceipt(), toUnreadCounter()ConversationRead

Table Structure

Persist conversation records relationally, keep rich payloads in typed JSON fields, and use idempotency keys for mobile sync.

TablePurposeImportant ColumnsConstraints & Indexes
communication_conversationsRelationship-scoped conversation thread.id, workspace_id, relationship_id, conversation_type, status, last_message_id, last_message_at, revision, created_at, archived_atUnique active (workspace_id, relationship_id, conversation_type); index (workspace_id, last_message_at desc); relationship id is an external reference.
communication_participantsPer-user conversation membership and read state.id, conversation_id, user_id, participant_role, status, last_read_message_id, last_read_at, muted_until, archived_at, notification_snapshot jsonbUnique active (conversation_id, user_id); index (user_id, archived_at, last_read_at).
communication_messagesCanonical sent messages and reply references.id, client_message_id, conversation_id, workspace_id, relationship_id, sender_user_id, message_kind, body_text, reply_to_message_id, reply_snapshot jsonb, status, source_device_id, client_created_at, sent_at, edited_at, redacted_atUnique nullable (conversation_id, sender_user_id, client_message_id); index (conversation_id, sent_at desc); index (reply_to_message_id); check body/GIF payload by message kind.
communication_message_gifsGIF metadata for GIF messages.id, message_id, provider, provider_asset_id, title, content_rating, preview_rendition, display_rendition, provider_page_url, width, height, attribution jsonb, analytics jsonb, selected_atUnique (message_id); index (provider, provider_asset_id); rendition keys are render hints and provider media URLs are not cached or proxied.
communication_message_reactionsActive and removed message reactions.id, message_id, conversation_id, actor_user_id, reaction_key, client_operation_id, source_device_id, created_at, removed_atUnique active (message_id, actor_user_id, reaction_key); unique nullable (conversation_id, actor_user_id, source_device_id, client_operation_id); index (message_id, removed_at).
communication_read_receiptsAppend-only read cursor changes for audit and fanout.id, conversation_id, participant_user_id, read_through_message_id, client_operation_id, source_device_id, read_at, created_atUnique nullable (conversation_id, participant_user_id, source_device_id, client_operation_id); index (conversation_id, participant_user_id, read_at desc).
communication_delivery_receiptsDelivery, push, and sync diagnostic receipts.id, message_id, recipient_user_id, delivery_channel, delivery_status, attempt_count, last_error_code, delivered_at, read_at, updated_atUnique (message_id, recipient_user_id, delivery_channel); index (recipient_user_id, delivery_status).
mobile_message_sync_receiptsServer receipts for mobile queued message operations.id, conversation_id, actor_user_id, device_id, client_operation_id, operation_type, canonical_record_id, accepted_revision, rejection_reason, accepted_atUnique (conversation_id, actor_user_id, device_id, client_operation_id); supports idempotent retries and client reconciliation.

Implementation note

SQLite mirrors conversation list summaries, recent message windows, pending operations, reaction summaries, read cursor, and sync receipts. It should not cache full GIF search result pages; only the selected GIF payload needed for a queued message is stored locally.

Lifecycle Policy

Messaging records are part of coach-client history. Prefer redaction, archive, and retention policies over destructive edits.

Conversation And Message Lifecycle

  • Conversation threads are created lazily for an active relationship and become read-only when the relationship is ended, unless support/admin policy reopens them.
  • Participant archive and mute state is per-user; archiving a thread for one participant never hides it for the other participant.
  • Accepted messages are immutable for V1 baseline except support/admin redaction and future edit windows explicitly defined by product policy.
  • Redacted messages keep id, sender, kind, sent time, and redaction reason but hide body, GIF metadata, and attachment preview from normal clients.
  • Reply snapshots must update to a redacted placeholder when their target message is redacted.

Reactions, GIFs, And Retention

  • Removing a reaction sets removed_at; active reaction summaries filter removed rows.
  • GIF messages retain provider asset ids, selected rendition keys, provider page URL, content rating, analytics metadata, and attribution for history rendering, subject to provider terms and app retention policy.
  • Delivery receipts and mobile sync receipts can be pruned after the support diagnostics window when canonical message history remains available.
  • Closed relationships follow CoachClientManagement retention and privacy rules; hard purge jobs anonymize user ids and remove sensitive body content when required.

Guardrail

Do not hard delete messages during normal user workflows. Coaches need continuity for accountability, and clients need clear history for instructions, clarification, and follow-up.

API Contracts

REST endpoints own validation and persistence; realtime channels mirror accepted state to connected clients.

EndpointPurposeRequestResponse
GET /api/v1/communication/conversationsList conversations for the signed-in user.Status, archived filter, unread filter, pagination cursor.Conversation summaries, participants, last message preview, unread counts, next cursor.
POST /api/v1/communication/conversationsOpen or return a relationship conversation.Relationship id, conversation type.Conversation dto with participant state and current revision.
GET /api/v1/communication/conversations/{conversationId}/messagesLoad a message window.Before/after cursor, limit, include reaction summary flag.Messages, reply snapshots, GIF payloads, reaction summaries, read state, pagination cursors.
POST /api/v1/communication/conversations/{conversationId}/messagesSend text, GIF, attachment placeholder, voice-note placeholder, or system-approved message.Client message id, message kind, body text or GIF payload, optional reply target, source device id.Canonical message dto, conversation revision, delivery state, sync receipt.
POST /api/v1/communication/messages/{messageId}/reactionsAdd or restore a reaction.Reaction key, client operation id, source device id.Reaction dto, updated reaction summary, conversation revision.
DELETE /api/v1/communication/messages/{messageId}/reactions/{reactionKey}Remove the actor's reaction.Client operation id, source device id.Removed reaction dto, updated reaction summary, conversation revision.
POST /api/v1/communication/conversations/{conversationId}/readAdvance participant read cursor.Read through message id, client operation id, read at.Participant read state, unread count, realtime receipt metadata.
GET /api/v1/communication/gifs/configReturn the approved GIF provider configuration for client-side search.Conversation id, locale, surface.Provider, public key or SDK config, max content rating, attribution requirements, allowed rendition keys, and tracking requirements.
POST /api/v1/communication/syncBulk sync queued mobile message, reaction, and read operations.Device id, ordered operations, client operation ids, local timestamps, expected conversation revisions.Accepted operations, rejected operations, canonical id map, updated conversations, refresh hints.
GET /api/v1/realtime/communication/tokenIssue a short-lived realtime subscription token.Conversation ids or workspace scope requested by the client.Token, channel names, expiry, reconnect cursor.

Implementation note

Realtime events should use versioned DTO names such as communication.message.sent.v1, communication.message.reaction_added.v1, and communication.conversation.read.v1. Clients must be able to recover from missed realtime events by calling REST with the latest cursor.

Key Code Snippets

Implementation sketches show the message schema, send-message use case, and reaction toggle boundary.

Drizzle schema shape

apps/api/src/communication/db/messaging.schema.ts

ts
export const communicationMessages = pgTable("communication_messages", {
  id: uuid("id").primaryKey().defaultRandom(),
  clientMessageId: uuid("client_message_id"),
  conversationId: uuid("conversation_id").notNull(),
  workspaceId: uuid("workspace_id").notNull(),
  relationshipId: uuid("relationship_id").notNull(),
  senderUserId: uuid("sender_user_id").notNull(),
  messageKind: text("message_kind").$type<MessageKind>().notNull(),
  bodyText: text("body_text"),
  replyToMessageId: uuid("reply_to_message_id"),
  replySnapshot: jsonb("reply_snapshot").$type<ReplySnapshot>(),
  status: text("status").$type<MessageStatus>().notNull().default("ACCEPTED"),
  sourceDeviceId: uuid("source_device_id"),
  clientCreatedAt: timestamp("client_created_at", { withTimezone: true }),
  sentAt: timestamp("sent_at", { withTimezone: true }).notNull().defaultNow(),
  redactedAt: timestamp("redacted_at", { withTimezone: true }),
}, (table) => ({
  clientMessageUnique: uniqueIndex("communication_messages_client_message_unique")
    .on(table.conversationId, table.senderUserId, table.clientMessageId),
  conversationTimelineIdx: index("communication_messages_timeline_idx")
    .on(table.conversationId, table.sentAt),
  replyTargetIdx: index("communication_messages_reply_target_idx")
    .on(table.replyToMessageId),
}));

Send message use case

apps/api/src/communication/use-cases/send-message.ts

ts
@Injectable()
export class SendMessageUseCase {
  constructor(
    private readonly conversations: ConversationRepository,
    private readonly messages: MessageRepository,
    private readonly gifPolicy: GifPolicy,
    private readonly access: CommunicationAccessPolicy,
    private readonly outbox: OutboxPort,
    private readonly tx: TransactionRunner,
  ) {}

  async execute(command: SendMessageCommand, actor: Actor): Promise<MessageDto> {
    return this.tx.run(async () => {
      const conversation = await this.conversations.get(command.conversationId);
      await this.access.assertCanSend(actor, conversation);

      const reply = command.replyToMessageId
        ? await this.messages.buildReplyReference(conversation.id, command.replyToMessageId)
        : null;

      if (command.kind === "GIF") {
        await this.gifPolicy.assertAllowed(command.gif);
      }

      const message = conversation.recordMessage({
        senderUserId: actor.userId,
        clientMessageId: command.clientMessageId,
        kind: command.kind,
        bodyText: command.bodyText,
        gif: command.gif,
        reply,
        sourceDeviceId: command.sourceDeviceId,
      });

      await this.messages.save(message);
      await this.conversations.save(conversation);
      await this.outbox.publish(message.pullDomainEvents());
      return MessageDto.fromDomain(message);
    });
  }
}

Reaction toggle

apps/api/src/communication/use-cases/toggle-message-reaction.ts

ts
@Injectable()
export class ToggleMessageReactionUseCase {
  constructor(
    private readonly messages: MessageRepository,
    private readonly reactions: MessageReactionRepository,
    private readonly access: CommunicationAccessPolicy,
    private readonly outbox: OutboxPort,
    private readonly tx: TransactionRunner,
  ) {}

  async add(input: ReactionCommand, actor: Actor): Promise<ReactionSummaryDto> {
    return this.tx.run(async () => {
      const message = await this.messages.get(input.messageId);
      await this.access.assertCanReadMessage(actor, message);

      const reaction = MessageReaction.add({
        messageId: message.id,
        conversationId: message.conversationId,
        actorUserId: actor.userId,
        reactionKey: input.reactionKey,
        clientOperationId: input.clientOperationId,
        sourceDeviceId: input.sourceDeviceId,
      });

      await this.reactions.upsertActive(reaction);
      await this.outbox.publish(reaction.pullDomainEvents());
      return this.reactions.summaryForMessage(message.id, actor.userId);
    });
  }
}

Events & Background Jobs

Messaging publishes durable outbox events first, then fans out realtime updates, push notifications, counters, and operational signals.

Domain And Integration Events

  • ConversationOpened: initializes participant state and optional welcome/system message.
  • MessageSent: updates conversation summary, unread counters, realtime channels, push notification jobs, and coach response SLA signals.
  • MessageReactionAdded and MessageReactionRemoved: update reaction summaries and connected clients.
  • ConversationRead: advances unread counters and read-receipt fanout.
  • MessageRedacted: updates message projections, reply snapshots, notification previews, and search indexes.

Jobs

  • Push notification worker sends relationship-scoped previews without leaking content to unauthorized devices.
  • Unread counter projector rebuilds participant conversation badges and coach task inbox signals.
  • GIF provider policy adapter exposes approved client-side search configuration and validates selected GIF payloads for provider, content rating, attribution fields, rendition keys, and tracking requirements.
  • Delivery receipt retry worker marks failed push/realtime attempts and retries transient failures.
  • Retention job prunes delivery diagnostics and sync receipts after the configured support window.

Permissions & Access Rules

Messaging exposes sensitive coach-client context and must be relationship-scoped at every use case.

Actor Rules

  • Coach can open, read, send, reply, react, and mark read for conversations tied to active relationships in their workspace.
  • Client can read, send, reply, react, and mark read only for their own active coach relationship conversation.
  • Ended or paused relationships are read-only unless the relationship status policy allows post-closure communication.
  • Support/admin access is audited and limited to explicit troubleshooting, moderation, or legal retention flows.

Content Rules

  • Reply target must belong to the same conversation and be visible to the actor.
  • Reaction target must be an accepted, non-redacted, user-visible message.
  • GIF client-side search and send must apply workspace content rating policy, locale, provider attribution, analytics, and safe-search settings.
  • Push previews must respect participant notification settings and hide content when the device or workspace policy requires private notifications.

Web, Coach Mobile, Client Mobile

All surfaces share the same conversation contract while adapting composer density, offline behavior, and notification handling.

Coach Web

  • Desktop thread list with unread filters, client profile context, reply composer, reaction picker, GIF picker, and attachment hooks.
  • Realtime messages should update the active thread without losing scroll position or composer draft.
  • Coach can jump from a message to client profile, task inbox item, workout review, or form response when linked by source context.

Coach Mobile

  • Mobile-first inbox with quick reply, voice-note entry point, GIF picker, reaction long-press, and offline outbox indicators.
  • SQLite stores recent message windows, drafts, queued outgoing operations, and selected GIF payloads awaiting sync.
  • Push deep links open the conversation and reconcile missed realtime events by cursor.

Client Mobile

  • Simple conversation view reachable from Today, coach tab, notifications, and contextual plan screens.
  • Client can send text, reply to a specific message, react, and send GIFs without leaving the app.
  • Offline state distinguishes queued, sending, sent, failed, and rejected messages so coaching instructions are never silently lost.

Test Scenarios & V1 Baseline Decisions

Messaging requires domain, API, realtime, mobile sync, access, and privacy coverage before implementation.

Tests

  • Domain: message send creates conversation update, unread increment, and outbox events exactly once for duplicate client message ids.
  • Domain: reply target must be in the same conversation and visible; redaction updates reply snapshots.
  • Domain: reaction add/remove is idempotent and summary counts ignore removed rows.
  • API: client cannot read, reply to, react to, or mark read on another relationship's messages.
  • API: GIF send rejects unsupported provider payloads, unsafe content ratings, missing attribution, and oversized metadata.
  • Realtime: missed events are recoverable through REST cursor reload without duplicate messages.
  • Mobile E2E: offline text reply and reaction queue sync to canonical ids after reconnect.
  • Web E2E: active conversation receives message, reaction, read receipt, and reply preview updates without full-page reload.

V1 Baseline Decisions

  • Use GIPHY as the Public V1 GIF provider. Search runs client-side through approved provider config, sent messages store provider asset metadata only, the default content-rating cap is pg with workspace policy allowed to tighten to g, attribution and analytics are required, and CoachMe does not cache, proxy, rewrite, or ingest provider GIF media.
  • Support one active reaction key per user per message in Public V1. Changing reactions replaces the actor's previous active reaction; removed reactions remain soft-removed for audit and sync replay.
  • Do not support user message edits in Public V1. Accepted messages are immutable except support/admin redaction, and redaction updates reply snapshots, previews, push payloads, and search projections.
  • Ended or paused coach-client relationships become read-only immediately. No post-closure user messaging is allowed; normal read access follows the 30-day retained-history window before CoachClientManagement retention cleanup hides, purges, or anonymizes sensitive content.
  • Retain message body and GIF metadata during the active relationship and 30-day ended-relationship history window unless account deletion or workspace policy requires a shorter purge. Delivery receipts and mobile sync receipts keep detailed diagnostics for 30 days, then summarize, anonymize, or purge while canonical message history remains.

CoachMe internal planning documentation.