Messaging Module Guide¶
Last reviewed: 2026-06-23
This guide is a how-to/reference for maintainers of the direct-messaging surface in Project NEXUS. It covers conversations and threads, sending/editing/deleting messages, file and voice attachments, real-time delivery via Pusher, broker safeguarding visibility, federation cross-community messaging, tenant scoping, privacy invariants, failure modes, and regression tests.
Audience and supported workflows¶
Use this guide when changing any part of the messaging stack: the send path, attachment handling, broker review, notification delivery, or federation messaging.
Supported workflows:
- Direct messaging — a 1-to-1 text, image/file, or voice conversation between two members on the same tenant.
- Contextual messaging — a message linked to a listing, event, job, volunteering opportunity, or group via
context_type/context_id. - Voice messaging — an audio recording uploaded by the sender, optionally auto-transcribed and translated.
- Message translation — on-demand translation of any message body or voice transcript to a target language (gated by the
message_translationfeature flag). - Broker safeguarding visibility — automatic copy of qualifying messages to a broker review queue, with in-app and email alerts to broker-role users.
- Federation messaging — cross-community messages between members of different federated tenants (see docs/FEDERATION_API_MANUAL.md).
Tenant and feature-gate rules¶
| Gate | Kind | Default | Effect when disabled |
|---|---|---|---|
messages |
Module (tenants.configuration.modules) |
true |
React redirects to /; ConversationPage returns early on !hasModule('messages'). |
direct_messaging |
Feature (tenants.features) |
true |
React hides the compose UI and shows a disabled state in MessagesPage and ConversationPage; controlled by hasFeature('direct_messaging'). The broker-side toggle is BrokerControlConfigService::isDirectMessagingEnabled() via the messaging.direct_messaging_enabled config key. |
message_translation |
Feature | (per tenant) | POST /api/v2/messages/{id}/translate returns FEATURE_DISABLED (HTTP 403). |
Every API route under /v2/messages/* is protected by the auth middleware (JWT). The send endpoint additionally requires onboarding-required middleware.
All queries are scoped by tenant_id from App\Core\TenantContext::getId() and the HasTenantScope Eloquent trait. There is no cross-tenant query path in the direct-messaging surface.
Key code and data locations¶
Routes are defined in routes/api.php (lines 214–234 for direct messages, lines 441–445 for federation messages). Do not reproduce the full endpoint table here — read the route file or docs/API.md for the live list.
| Concern | Route prefix | Controller |
|---|---|---|
| Conversations and direct messages | /v2/messages/* |
app/Http/Controllers/Api/MessagesController.php |
| Voice upload/send | /v2/messages/upload-voice, /v2/messages/voice |
MessagesController (native Laravel request()->file()) |
| Group conversations | /v2/conversations/{id}/messages |
app/Http/Controllers/Api/GroupConversationController.php |
| Federation messages | /v2/federation/messages/* |
app/Http/Controllers/Api/FederationV2Controller.php |
| Broker review queue | /v2/admin/broker/messages/* |
app/Http/Controllers/Api/AdminBrokerController.php |
| Pusher auth / config | /pusher/auth, /v2/pusher/config |
app/Http/Controllers/Api/PusherController.php |
Services:
app/Services/MessageService.php— conversation list, thread fetch, send, edit, delete, mark-read, archive/restore, typing indicator, reactions.app/Services/BrokerMessageVisibilityService.php— broker copy eligibility check, copy creation, restriction status.app/Services/BrokerControlConfigService.php— broker configuration read/write (messaging,broker_visibilityconfig sections).app/Services/ContextualMessageService.php— attach / resolve context cards (listing, event, job, volunteering, group).app/Services/FederatedMessageService.php— send and receive cross-tenant messages in thefederation_messagestable.app/Services/TranscriptionService.php— voice-to-text and text translation via OpenAI.app/Services/TranslationConfigurationService.php— context-aware translation settings and glossary.
Listeners (both implement ShouldQueue):
app/Listeners/NotifyMessageReceived.php— sends in-app bell, optional email, and push notification to the recipient in theirpreferred_language.app/Listeners/CopyMessageForBrokerReview.php— evaluates the broker copy criteria and writes tobroker_message_copieswhen matched.
Event:
app/Events/MessageSent.php— fired byMessageService::send(); implementsShouldBroadcast, broadcasts to the Pusher private channeltenant.{tenantId}.conversation.{conversationId}with event namemessage.sent.
Models and tables:
| Model | Table | Notes |
|---|---|---|
App\Models\Message |
messages |
body column is text. Includes is_voice, audio_url, audio_duration, transcript, transcript_language, reactions (JSON), is_edited, is_deleted, is_deleted_sender, is_deleted_receiver, context_type, context_id, archived_by_sender, archived_by_receiver. |
App\Models\BrokerMessageCopy |
broker_message_copies |
Safeguarding copy. copy_reason enum: first_contact, high_risk_listing, new_member, flagged_user, manual_monitoring, random_sample. flagged, flag_severity, reviewed_by, reviewed_at. Unique index on (tenant_id, original_message_id) prevents duplicate copies. |
| — | federation_messages |
Cross-tenant messages. receiver_tenant_id, external_partner_id, external_message_id (idempotency). |
| — | user_messaging_restrictions |
Per-user messaging_disabled and under_monitoring flags with optional expiry. |
| — | user_first_contacts |
Tracks first-message events between pairs; used by broker first-contact monitoring. |
React entry points:
react-frontend/src/pages/messages/MessagesPage.tsx— conversation list.react-frontend/src/pages/messages/ConversationPage.tsx— thread view, send form, voice recorder, reactions, translation. Feature gate onhasModule('messages')is enforced here.react-frontend/src/contexts/PusherContext.tsx— Pusher WebSocket connection lifecycle.
Conversations and threads¶
MessageService::getConversations() returns a cursor-paginated list of the current user's conversations (default 20 per page, max 100). The archived query parameter filters to archived conversations. MessageService::getMessages() returns a cursor-paginated thread (default 50, max 100) with a direction parameter (older or newer) for bi-directional scroll.
Opening a conversation (GET /api/v2/messages/{id}) automatically marks it as read, unless the client is polling for newer messages (direction=newer with a cursor).
GET /api/v2/messages/unread-count returns the aggregate unread count across all conversations for the authenticated user. This is polled by the navigation badge.
Sending, editing, and deleting¶
Send¶
POST /api/v2/messages requires recipient_id. At least one of body, voice_url, or an attachments[] file upload must be present.
bodyis validated server-side withHtmlSanitizer::stripAll()(all HTML stripped before storage).- Maximum body length: 10,000 characters.
- Rate limit: 30 requests per 60 seconds per user (key
messages_send). - Sending a message awards XP via
GamificationService(non-blocking; a failure is logged and does not block delivery).
Edit¶
PUT /api/v2/messages/{id} allows the sender only to edit a message body within a 24-hour window from creation. Editing an older message returns EDIT_EXPIRED (HTTP 403). The body is re-sanitized through HtmlSanitizer::stripAll(). The is_edited and edited_at columns are updated on the messages row.
Delete¶
DELETE /api/v2/messages/{id} accepts an optional scope body parameter:
scope |
Default? | Effect |
|---|---|---|
everyone |
Yes | Sets is_deleted = true, blanks body to [Message deleted], clears reactions, sets deleted_at. Both parties see the placeholder. |
self |
No | Sets is_deleted_sender or is_deleted_receiver (depending on role). The other party's view is unchanged. |
Either the sender or receiver may delete with scope=everyone. Only the respective party is affected by scope=self.
Conversation archive and restore¶
DELETE /api/v2/messages/conversations/{id} archives a conversation. The scope parameter accepts self (default — hides from the current user's inbox only, restorable) or everyone (hides from both inboxes). POST /api/v2/messages/conversations/{id}/restore restores an archived conversation for the calling user.
Reactions¶
POST /api/v2/messages/{id}/reactions toggles an emoji reaction. Only emojis in App\Support\EmojiConstants::MESSAGE_REACTIONS are accepted. Reactions are stored in both the message_reactions table (per-user tracking) and the messages.reactions JSON column (backward compatibility). GET /api/v2/messages/reactions/batch?ids=1,2,3 fetches reactions for up to 100 messages in one request.
Attachments¶
File and image attachments¶
Up to 5 files per message (MessageAttachmentUploader::MAX_FILES). Each file is limited to 10 MB (MessageAttachmentUploader::MAX_BYTES = 10 * 1024 * 1024).
Accepted types (extension verified against MIME content detected by finfo):
| Extension | Detected MIME |
|---|---|
| jpg, jpeg | image/jpeg |
| png | image/png |
| gif | image/gif |
| webp | image/webp |
| application/pdf | |
| txt | text/plain |
| csv | text/plain, text/csv, application/csv |
| doc | application/msword |
| docx | application/vnd.openxmlformats-officedocument.wordprocessingml.document |
| xls | application/vnd.ms-excel |
| xlsx | application/vnd.openxmlformats-officedocument.spreadsheetml.sheet |
Files are stored under httpdocs/uploads/{tenantId}/message_attachments/ with a random hex filename. The public path is /uploads/{tenantId}/message_attachments/{filename}. Executable extensions and MIME-type mismatches (e.g. a .png that is actually a script) are rejected.
Voice messages¶
POST /api/v2/messages/upload-voice — upload audio and receive a voice_url and duration without sending (two-step flow).
POST /api/v2/messages/voice — upload and send in one step (field: voice_message, plus recipient_id).
Accepted audio MIME types: audio/webm, video/webm, audio/ogg, audio/mpeg, audio/mp3, audio/aac, audio/mp4, audio/x-m4a, video/mp4. Maximum file size: 10 MB. Maximum duration: 5 minutes (300 seconds, enforced by AudioUploader::$maxDuration).
After upload, TranscriptionService::transcribe() runs non-blocking. On success the transcript and transcript_language columns are updated on the message row and included in the response. Transcription failure is logged as a warning and does not fail the send.
Real-time delivery via Pusher¶
MessageSent (in app/Events/MessageSent.php) implements ShouldBroadcast and is dispatched by MessageService::send(). It broadcasts to the Pusher private channel:
with the event name message.sent. The broadcast payload includes id, body, sender_id, created_at, is_voice, and audio_url. Full user objects are excluded to avoid leaking email, phone, or other private fields.
Typing indicators use a separate path: POST /api/v2/messages/typing triggers MessageService::setTypingIndicator(), which calls Pusher::trigger() directly on the private channel private-tenant.{tenantId}.user.{recipientId}. Rate limit: 60 requests per 60 seconds.
Pusher channel authorization is handled by PusherController::auth() at /pusher/auth. The React client fetches Pusher connection credentials from GET /api/v2/pusher/config.
The NotifyMessageReceived listener runs asynchronously on the queue and sends:
- An in-app bell notification (
Notification::createNotification). - An optional HTML email (suppressed if the recipient's
email_messagesnotification preference is false). - A push notification via
NotificationDispatcher::fanOutPush().
All three are rendered in the recipient's preferred_language using LocaleContext::withLocale() — not the caller's locale.
Both listeners (CopyMessageForBrokerReview, NotifyMessageReceived) use a Redis-backed idempotency guard (Cache::add on a claim key, Cache::put on a done key) to suppress duplicate deliveries from queue re-attempts. Both are configured with $tries = 1 and $timeout = 60 seconds.
Broker safeguarding visibility¶
The broker safeguarding feature allows an admin team to review a copy of qualifying messages. This is tenant-controlled and off by default unless configured.
How copies are triggered¶
CopyMessageForBrokerReview (queued listener on MessageSent) calls BrokerMessageVisibilityService::shouldCopyMessage(), which evaluates these criteria in order:
| Criterion | Config key | Default |
|---|---|---|
| Either participant is under active safeguarding monitoring | user_messaging_restrictions.under_monitoring |
Off (per-user flag) |
| First contact between two members | messaging.first_contact_monitoring |
true |
| Sender is a new member within the monitoring window | messaging.new_member_monitoring_days |
30 days |
| Message relates to a high-risk listing | broker_visibility.copy_high_risk_listing_messages |
true |
| Random compliance sampling | broker_visibility.random_sample_percentage |
0 |
Only the first matching criterion determines the copy_reason. Broker visibility must be enabled via broker_visibility.enabled for any copy to occur.
Copy storage and notification¶
BrokerMessageVisibilityService::copyMessageForBroker() inserts into broker_message_copies using firstOrCreate against the unique index on (tenant_id, original_message_id). A concurrent or retried queue job that loses the race returns the existing row without sending a second notification.
All users with role in admin, tenant_admin, broker, or super_admin with status = active receive an in-app bell notification rendered in their preferred_language. For the two highest-priority reasons (flagged_user, high_risk_listing), an HTML email is also sent via BrokerMessageVisibilityService::sendBrokerReviewEmail().
Broker review admin panel¶
Routes at /v2/admin/broker/messages/* expose filter (unreviewed, flagged, reviewed, all), mark-reviewed, flag, and approve actions. The reviewed_by and reviewed_at columns record who reviewed and when. Flagged copies carry flag_severity (info, warning, concern, urgent) and optional action_taken.
Retention¶
The default broker copy retention is 365 days (broker_visibility.retention_days). This is tenant-configurable via AdminBrokerController. Expired monitoring on user_messaging_restrictions is auto-cleared by BrokerMessageVisibilityService::clearExpiredMonitoring() the next time a restriction status is evaluated.
Messaging restrictions¶
GET /api/v2/messages/restriction-status calls BrokerMessageVisibilityService::getUserRestrictionStatus() and returns:
An expired monitoring window is auto-cleared inline when this endpoint is called.
Contextual messaging¶
Messages can carry a reference to a platform entity via context_type and context_id on the messages table. ContextualMessageService resolves the entity into a context card (title, subtitle, description, link) for display in the thread view.
Valid context_type values: listing, event, job, volunteering, group.
The service populates the listing_id column as well as context_type/context_id when the context type is listing, for backward compatibility.
Federation cross-community messaging¶
Federation messaging uses a separate table (federation_messages) and service (FederatedMessageService). It is only available when:
- Both the sender and receiver have opted into federated messaging (
federation_user_settings.federation_optin = trueandmessaging_enabled_federated = true). - An active federation partnership exists between the two tenants with
messaging_enabled = 1infederation_partnerships.
FederatedMessageService::storeExternalMessage() is idempotent: if the partner re-delivers a message with the same external_message_id, it repairs any missing notification or email side effects without inserting a duplicate row.
Inbound federation messages trigger:
- An in-app bell notification via
Notification::createNotification(), rendered in the recipient'spreferred_language. - A push notification via
NotificationDispatcher::fanOutPush(). - An email via
FederationEmailService::sendExternalMessageNotification().
For full federation operational notes, partner onboarding, and the external partner API, see docs/FEDERATION_API_MANUAL.md.
Privacy invariants¶
- Tenant isolation is absolute. Every query in
MessageService,BrokerMessageVisibilityService, andFederatedMessageServiceincludestenant_idfromTenantContext::getId(). There is no cross-tenant query path. - No participant leakage in broadcast payloads.
MessageSent::broadcastWith()emits onlyid,body,sender_id,created_at,is_voice, andaudio_url. FullUserobjects (including email, phone) are never broadcast. - Broker copies are immutable. A broker copy stores
message_bodyat the moment of copy. Subsequent edits or deletions of the original message do not propagate to the copy. - Translation is opt-in per message. Translations are not stored; they are computed on demand and returned in the response. The source text for translation is either
transcript(voice) orbody(text), both already stored. - Voice transcripts are stored on the server in the
transcriptcolumn. Tenants should include this in their privacy policy.
Failure modes and recovery¶
| Failure | Effect | Recovery |
|---|---|---|
| Pusher unavailable at send time | MessageSent broadcast is caught and logged as a warning (MessageSent broadcast failed). The message is still persisted and the HTTP 201 response is returned. The recipient misses the real-time event but can poll or refresh. |
No action needed. The message is durable. Pusher will reconnect on the next page load. |
NotifyMessageReceived listener failure |
Logged to Log::error. The message is already persisted. |
The listener has $tries = 1. If the queue worker is healthy and the failure was transient, the job is dead. Check failed_jobs and re-dispatch if needed. |
CopyMessageForBrokerReview listener failure |
Logged to Log::error. The original message is unaffected. The broker copy may be missing. |
Check failed_jobs. Re-dispatch manually. The idempotency guard (firstOrCreate) prevents a double-copy on re-dispatch. |
| Voice transcription failure | Logged as Log::warning('Voice message transcription failed'). The voice message is sent successfully without a transcript. |
No action needed. Transcription can be re-attempted manually by updating the transcript column directly if required. |
| Translation failure | TranslationConfigurationService / TranscriptionService::translate() returns null. API returns TRANSLATION_FAILED (HTTP 500). |
Check that OPENAI_API_KEY is set and the OpenAI endpoint is reachable. |
| Attachment upload failure | Storage write fails; MessageAttachmentUploader throws \RuntimeException. API returns UPLOAD_FAILED (HTTP 400). |
Check disk space and directory permissions under httpdocs/uploads/{tenantId}/message_attachments/. |
| Federation message delivery failure | storeExternalMessage() returns ['success' => false, 'retryable' => true]. The row is in federation_messages but notification_sent_at or email_sent_at may be null. |
Re-deliver from the partner side (the idempotency guard repairs missing side effects). Or update notification_sent_at/email_sent_at manually after resolving the queue issue. |
Test commands and key regression tests¶
# Run all messaging-related PHP tests
vendor/bin/phpunit --filter="Message|Broker|Federated" --colors=always
# Run all tests
vendor/bin/phpunit --testsuite=Laravel,LaravelMigrated --colors=always
Key test files:
| File | What it covers |
|---|---|
tests/Laravel/Feature/Controllers/MessagesControllerTest.php |
Send, edit, delete, reactions, pagination, rate limits. |
tests/Laravel/Feature/Messages/MessageAttachmentsTest.php |
File upload allow-list, MIME verification, size limits, 5-file cap. |
tests/Laravel/Feature/Messages/VoiceMessageSendTest.php |
Voice upload, duration cap, transcription path. |
tests/Laravel/Feature/Listeners/CopyMessageForBrokerReviewTest.php |
Copy criteria evaluation, idempotency guard, duplicate suppression. |
tests/Laravel/Unit/Listeners/NotifyMessageReceivedTest.php |
Notification dispatch, locale wrapping, idempotency. |
tests/Laravel/Unit/Services/BrokerMessageVisibilityServiceTest.php |
All copy-reason branches, restriction status, expired monitoring. |
tests/Laravel/Feature/Services/BrokerMessageVisibilityTenantIsolationTest.php |
Tenant isolation: copies and restrictions cannot cross tenants. |
tests/Laravel/Unit/Services/ContextualMessageServiceTest.php |
Context card resolution for all five entity types. |
tests/Laravel/Unit/Services/FederatedMessageServiceTest.php |
Opt-in checks, partnership check, idempotent re-delivery, locale wrapping. |
tests/Laravel/Unit/Models/BrokerMessageCopyTest.php |
Model scoping and copy-reason enum. |
tests/Laravel/Migrated/Controllers/Api/MessagesApiControllerTest.php |
Legacy API surface migration coverage. |
tests/Laravel/Feature/GovukAlpha/MessagesParityTest.php |
Accessible frontend messaging parity. |