Skip to content

The Messages module provides a secure, role-aware messaging system for communication between couples and vendors within the Wedissimo platform.

Overview

The Messages module facilitates private communication between platform users, primarily couples seeking vendors and vendors responding to inquiries. Built on a thread-based architecture, it ensures conversations remain organized, secure, and performant.

Key Features

  • Thread-based conversations: Messages are organized into threads between two participants
  • Role-aware permissions: Couples can initiate threads with vendors; vendors can only respond
  • Read tracking: Automatic tracking of read/unread status per participant
  • Content moderation: Flagging system for messages containing potential contact information
  • Automated reminders: Bidirectional escalating reminders when messages need responses
  • AI-powered detection: Vertex AI determines if messages require a response (with heuristic pre-filtering)
  • Performance optimized: Cached unread counts, indexed queries, PostgreSQL partial indexes, and N+1 prevention
  • Soft deletes: Complete audit trail with recoverable message history

Architecture

Module Structure

modules/Messages/
├── Config/
│   └── config.php                      # Module configuration (incl. reminders)
├── Console/
│   └── Commands/
│       └── SendMessageRemindersCommand.php  # Scheduled reminder dispatch
├── Database/
│   └── Migrations/                     # Database schema
├── Events/
│   └── ParticipantRespondedToThread.php     # Fired when participant responds
├── Http/
│   ├── Controllers/
│   │   └── MessageThreadController.php # API endpoints
│   ├── Requests/                       # Form validation
│   └── Resources/                      # API response formatting
├── Jobs/
│   ├── CheckMessageNeedsResponseJob.php     # AI check + schedule reminders
│   ├── ScanMessageJob.php                   # Content moderation scanning
│   └── SendSingleReminderJob.php            # Dispatch individual reminder
├── Listeners/
│   └── CancelRemindersOnResponse.php        # Cancel when recipient responds
├── Models/
│   ├── Message.php                     # Individual messages
│   ├── MessageThread.php               # Conversation threads
│   └── MessageThreadReminder.php       # Pre-scheduled reminders
├── Notifications/
│   ├── NewMessageNotification.php      # Email notifications
│   └── UnrespondedMessageReminderNotification.php  # Escalating reminders
├── OpenAPI/
│   └── messages.yaml                   # API documentation
├── Services/
│   ├── MessageReminderService.php      # Reminder business logic
│   ├── MessageThreadService.php        # Thread queries & caching
│   └── VertexAiScanningService.php     # AI scanning (disintermediation + needs-response)
└── Tests/
    ├── Feature/
    │   └── MessageReminderTest.php     # Reminder integration tests
    └── Unit/
        └── VertexAiScanningServiceTest.php  # AI service unit tests

Design Principles

Thread-Centric: All messages belong to a thread with exactly two participants. This simplifies permissions, notifications, and UI rendering.

Role-Based Initiation: Couples can start new threads with vendors to ask questions. Vendors cannot initiate threads but can respond to any thread they're part of. This prevents unsolicited vendor outreach.

Automatic Read Tracking: When a user views a thread's messages, the system automatically marks that thread as read for them. No manual "mark as read" action required.

Performance First: Unread counts are cached for 5 minutes and invalidated on new messages. Database queries use composite indexes and eager loading to prevent N+1 problems.

Core Entities

MessageThread

Primary Entity: Represents a conversation between a couple and a vendor

Key Attributes:

  • id (UUID)
  • subject (nullable string, max 255)
  • vendor_id (FK to Vendor)
  • user_id (FK to User - the couple who initiated)
  • last_message_at (timestamp)

Relationships:

  • vendor - BelongsTo Vendor
  • user - BelongsTo User (couple)
  • messages - HasMany Message
  • reads - HasMany MessageRead
  • flags - HasMany MessageFlag

Model Location: modules/Messages/Models/MessageThread.php

Message

Entity: Individual message within a thread

Key Attributes:

  • id (UUID)
  • thread_id (FK to MessageThread)
  • sender_id (FK to User)
  • body (text, max 5000 chars)
  • body_redacted (nullable text - sanitized version)
  • status (enum: pending, approved, flagged, blocked, redacted)
  • risk_score (float 0.0-1.0)
  • detected_patterns (JSON array)
  • deleted_at (nullable timestamp - soft delete)

Soft Deletes: Messages use soft deletes to maintain audit trails for compliance and dispute resolution. Soft-deleted messages remain in the database but are hidden from API responses and excluded from unread counts.

Statuses:

  • pending - Awaiting scan results
  • approved - Clean, no patterns detected
  • flagged - Low-risk patterns, flagged for admin review
  • redacted - Medium-risk, contact info replaced with [REDACTED]
  • blocked - High-risk, hidden from recipient

Relationships:

  • thread - BelongsTo MessageThread
  • sender - BelongsTo User
  • flags - HasMany MessageFlag

Model Location: modules/Messages/Models/Message.php

MessageRead

Entity: Tracks read status per user per thread

Key Attributes:

  • id (UUID)
  • thread_id (FK to MessageThread)
  • user_id (FK to User)
  • last_read_message_id (nullable FK to Message)
  • last_read_at (timestamp)

Purpose: Enables unread count calculation for thread lists

Model Location: modules/Messages/Models/MessageRead.php

MessageFlag

Entity: Admin review flags for suspicious messages

Key Attributes:

  • id (UUID)
  • message_id (FK to Message)
  • flagged_by_user_id (nullable FK to User - null for system flags)
  • reason (text)
  • status (enum: pending, reviewed, dismissed)
  • risk_score (float)
  • detected_patterns (JSON array)

Model Location: modules/Messages/Models/MessageFlag.php

MessageThreadReminder

Entity: Pre-scheduled reminder for unanswered messages

Key Attributes:

  • id (UUID)
  • thread_id (FK to MessageThread)
  • trigger_message_id (FK to Message - the message needing response)
  • recipient_id (FK to User - who should be reminded)
  • recipient_type (string: 'vendor' or 'couple')
  • level (tinyint: 1, 2, or 3 - escalation level)
  • scheduled_for (timestamp - when to send)
  • sent_at (nullable timestamp)
  • cancelled_at (nullable timestamp)
  • cancellation_reason (nullable string: 'recipient_responded', 'thread_closed')

Relationships:

  • thread - BelongsTo MessageThread
  • triggerMessage - BelongsTo Message
  • recipient - BelongsTo User

Model Location: modules/Messages/Models/MessageThreadReminder.php

Bidirectional Logic:

Message DirectionAI Says Needs ResponseWho Gets Reminded
Couple → VendorYesVendor (recipient)
Vendor → CoupleYesCouple (recipient)

Domain Services

MessageThreadService

Location: modules/Messages/Services/MessageThreadService.php

Responsibilities:

  • Query optimization for thread lists
  • Handles complex eager loading and unread counts
  • Prevents N+1 query problems

Key Methods:

php
class MessageThreadService
{
    public function getUserThreads(mixed $user, int $perPage): LengthAwarePaginator;
}

MessageScanningService

Location: modules/Messages/Services/MessageScanningService.php

Responsibilities:

  • Pattern detection using regex (UK phone numbers, emails, URLs, social handles)
  • Risk score calculation based on weighted pattern matches
  • Content redaction with [REDACTED] placeholders
  • Automatic admin flag creation

Pattern Detection:

  • UK Mobile: 07XXX XXXXXX, +44 7XXX XXXXXX (weight: 0.9)
  • UK Landline: 0XXXX XXXXXX, +44 XXXX XXXXXX (weight: 0.8)
  • Email: Standard email format (weight: 0.85)
  • URL: https://..., www.... (weight: 0.7)
  • Social Handle: @username (weight: 0.6)

Risk Thresholds:

  • >= 0.5 - Redact content
  • >= 0.3 - Flag for review
  • < 0.3 - Auto-approve

Key Methods:

php
class MessageScanningService
{
    public function scanMessage(Message $message): void;
    public function shouldScanMessage(Message $message): bool;
}

VertexAiScanningService

Location: modules/Messages/Services/VertexAiScanningService.php

Responsibilities:

  • AI-powered disintermediation detection
  • AI-powered needs-response detection (for reminders)
  • Heuristic pre-filtering to reduce AI costs

Key Methods:

php
class VertexAiScanningService
{
    public function scanForDisintermediation(string $message): array;
    public function scanForAvailability(string $message): array;
    public function scanForNeedsResponse(string $message): array;
    public function isEnabled(): bool;
}

scanForNeedsResponse Logic:

  1. Heuristic pre-filter (reduces AI costs):
    • Messages with ?needs_response: true (confidence: heuristic)
    • Short messages with "thank", "ok", "great" → needs_response: false
  2. AI fallback for ambiguous messages → Calls Vertex AI (Gemini 2.0 Flash)
  3. Default when AI disabled → needs_response: true (safe default)

MessageReminderService

Location: modules/Messages/Services/MessageReminderService.php

Responsibilities:

  • Query due reminders efficiently (uses PostgreSQL partial index)
  • Send reminders with pessimistic locking (prevents duplicates)
  • Cancel reminders when recipient responds

Key Methods:

php
class MessageReminderService
{
    public function getDueReminders(): Collection;
    public function sendReminder(MessageThreadReminder $reminder): bool;
    public function cancelPendingRemindersForRecipient(
        MessageThread $thread,
        User $recipient,
        string $reason = 'recipient_responded'
    ): int;
    public function cancelAllPendingReminders(
        MessageThread $thread,
        string $reason = 'thread_closed'
    ): int;
}

Pessimistic Locking: sendReminder() uses lockForUpdate() within a transaction to prevent race conditions when multiple scheduler processes run simultaneously.

Background Jobs

ScanMessageJob

Location: modules/Messages/Jobs/ScanMessageJob.php

Purpose: Asynchronously scan messages after creation

Configuration:

  • Queue: default
  • Retries: 3 attempts
  • Timeout: 60 seconds
  • Fail-open: Approves message if all retries fail (UX over security)

Dispatched From:

  • MessageThreadController::store() - New thread creation
  • MessageController::store() - Reply to thread

CheckMessageNeedsResponseJob

Location: modules/Messages/Jobs/CheckMessageNeedsResponseJob.php

Purpose: Check if a message needs a response and schedule reminders

Configuration:

  • Queue: default
  • Retries: 3 attempts
  • Timeout: 30 seconds
  • Backoff: 60s, 120s, 300s (escalating)
  • Fail-safe: Schedules reminders if AI check fails after all retries

Process Flow:

  1. Check if reminders are enabled globally
  2. Check if reminders are enabled for recipient type (vendor/couple)
  3. Guard against race condition (recipient may have already responded)
  4. Call VertexAiScanningService::scanForNeedsResponse()
  5. If needs_response: true, schedule 3 reminder levels (24h, 72h, 168h)

Dispatched From:

  • MessageService::sendMessage() - After any message creation

SendSingleReminderJob

Location: modules/Messages/Jobs/SendSingleReminderJob.php

Purpose: Send an individual reminder notification with duplicate prevention

Configuration:

  • Queue: default
  • Retries: 3 attempts
  • Timeout: 30 seconds

Process: Uses pessimistic locking via MessageReminderService::sendReminder() to ensure only one worker sends the reminder even if multiple schedulers run.

Scheduled Commands

SendMessageRemindersCommand

Location: modules/Messages/Console/Commands/SendMessageRemindersCommand.php

Signature: php artisan messages:send-reminders

Schedule: Every 5 minutes (configured in app/Console/Kernel.php)

Process:

  1. Query due reminders using efficient partial index
  2. Dispatch individual SendSingleReminderJob for each reminder
  3. Jobs handle locking and actual notification send

Scheduler Configuration:

php
$schedule->command('messages:send-reminders')
    ->everyFiveMinutes()
    ->withoutOverlapping()
    ->onOneServer();

Events & Listeners

ParticipantRespondedToThread

Location: modules/Messages/Events/ParticipantRespondedToThread.php

Fired When: Any participant (vendor or couple) sends a message in a thread

Payload:

  • thread - The MessageThread
  • sender - The User who sent the message
  • senderType - 'vendor' or 'couple'

CancelRemindersOnResponse

Location: modules/Messages/Listeners/CancelRemindersOnResponse.php

Listens To: ParticipantRespondedToThread

Action: Cancels all pending reminders where the sender was the intended recipient (they responded, so no reminder needed)

Reminder Configuration

Configuration in modules/Messages/Config/config.php:

php
'reminders' => [
    'enabled' => env('MESSAGE_REMINDERS_ENABLED', true),

    'recipients' => [
        'vendor' => env('MESSAGE_REMINDERS_VENDOR_ENABLED', true),
        'couple' => env('MESSAGE_REMINDERS_COUPLE_ENABLED', true),
    ],

    'levels' => [
        1 => ['min_hours' => 24],   // First reminder at 24h
        2 => ['min_hours' => 72],   // Second at 3 days
        3 => ['min_hours' => 168],  // Third at 7 days
    ],

    'ai_prompt' => "The client wrote a message: ':message'. Please analyze whether the client is waiting for an answer to this question. Answer in one word: if you need a reply to the message, write 'Yes'. If the answer is optional, write 'No'.",
],

Environment Variables:

VariableDefaultDescription
MESSAGE_REMINDERS_ENABLEDtrueMaster switch for all reminders
MESSAGE_REMINDERS_VENDOR_ENABLEDtrueEnable reminders to vendors
MESSAGE_REMINDERS_COUPLE_ENABLEDtrueEnable reminders to couples

OpenAPI Documentation

Location: modules/Messages/OpenAPI/messages.yaml

Schemas: Separate files in modules/Messages/OpenAPI/schemas/

  • MessageThread.yaml
  • Message.yaml
  • CreateThreadRequest.yaml
  • CreateMessageRequest.yaml
  • VendorSummary.yaml
  • UserSummary.yaml

Bundling: Run npm run openapi:bundle to generate openapi/openapi.yaml

Authorization

Policies

MessageThreadPolicy (modules/Messages/Policies/MessageThreadPolicy.php):

  • viewAny - Any authenticated user
  • view - User is thread participant (couple OR vendor owner)
  • create - Any authenticated user
  • reply - User is thread participant

MessagePolicy (modules/Messages/Policies/MessagePolicy.php):

  • reply - Handled by MessageThreadPolicy

Policy Registration

Policies are registered in MessagesServiceProvider (not main AuthServiceProvider) for module self-containment:

php
protected array $policies = [
    MessageThread::class => MessageThreadPolicy::class,
    Message::class => MessagePolicy::class,
];

Security Features

Anti-Disintermediation

Prevents users from exchanging contact information to bypass platform:

  1. All messages created with status = 'pending'
  2. ScanMessageJob dispatched to queue
  3. Regex patterns detect UK phone numbers, emails, URLs, social handles
  4. Risk score calculated based on pattern weights
  5. Content redacted or flagged based on thresholds
  6. Admin receives flag for manual review

Rate Limiting

Thread Creation: 10 per minute per user Replies: 60 per minute per user

Exception Handling

Server-side errors are logged but generic messages returned to client:

json
{
  "message": "Failed to send message. Please try again."
}

No stack traces or sensitive data leaked.

Foreign Key Constraints

message_reads.last_read_message_id has foreign key with onDelete('set null') to prevent orphaned references.

API Endpoints

The Messages API provides endpoints for thread-based messaging between couples and vendors.

Base Path: /api/v1/messages

Key Endpoints:

  • GET /unread-count - Global unread thread count (cached 5 minutes)
  • GET /threads - List user's message threads with pagination
  • POST /threads - Create new thread (rate limited: 10/min)
  • GET /threads/{id} - View thread and messages (auto-marks as read)
  • POST /threads/{id}/messages - Reply to thread (rate limited: 60/min)

Postman Integration

The bundled OpenAPI spec is automatically synced to Postman via CI/CD. Import the collection from the Postman workspace for interactive testing.

Key Behaviors

  • Viewing a thread automatically marks it as read and clears cache
  • Messages created with status: "pending", scanned asynchronously
  • Unread count returns number of threads with unread messages, not total message count
  • All contact information (email, phone) hidden from responses for anti-disintermediation

For detailed request/response schemas, authentication, and interactive testing, see the full API documentation

Content Moderation

Automatic Flagging

Messages are automatically scanned for potential contact information including:

  • Phone numbers (various formats)
  • Email addresses
  • URLs and domains
  • Social media handles

Flag States

Flagged: is_flagged: true with flagged_reason populated (e.g., "contact_info", "inappropriate")

Not Flagged: is_flagged: false with flagged_reason: null

Admin Workflow

  1. Query flagged messages using is_flagged index
  2. Review message in context of full thread
  3. Take action: warn user, delete message, or clear flag
  4. Soft-deleted messages remain in database for compliance

Performance & Caching

Caching Strategy

Cache TTL: 5 minutes for all unread counts

Cache Keys:

  • Global unread count: user:{userId}:messages:unread-count
  • Per-thread unread: Calculated on-demand, not cached separately

Why 5 Minutes?

The system balances real-time accuracy with performance. For a messaging system, a 5-minute delay in badge counts is acceptable because:

  • Most users don't switch between devices within 5 minutes
  • Viewing a thread immediately clears the cache, showing accurate counts
  • Reduced database load allows the system to handle more concurrent users

Automatic Cache Invalidation:

Cache automatically clears when:

  1. User marks thread as read via MessageThread::markAsReadBy()
  2. New message created in any thread user participates in
  3. Message status changes to approved (scanning complete)

Manual Cache Clearing (for debugging):

bash
docker compose exec -T wedissimo-api php artisan tinker
>>> Cache::forget('user:' . $userId . ':messages:unread-count');

N+1 Prevention

MessageThreadService::getUserThreads() uses eager loading to prevent N+1 query problems:

Eager Loading:

php
MessageThread::with([
    'vendor',
    'user',
    'messages' => fn($q) => $q->latest()->limit(1),
])->withCount('unreadMessages')->get();

Impact:

  • Before optimization: 60-80 queries for 20 threads
  • After optimization: ~5 queries total

Query Reduction:

  • with() - Eager load relationships in bulk
  • withCount() - Efficient unread counts (single aggregation query vs 3-4 per thread)
  • latest()->limit(1) - Load only most recent message per thread (Laravel 12 feature)

Database Indexes

Optimized for unread count queries (reduces query time from ~200ms to <50ms):

Messages Table:

  • idx_messages_unread_lookup - Composite index on (thread_id, sender_id, created_at)
  • idx_messages_status_date - Index on (status, created_at) for approved messages
  • messages_sender_id_index - Foreign key index on sender_id

Message Reads Table:

  • idx_reads_lookup - Composite index on (thread_id, user_id, last_read_message_id)
  • message_reads_last_read_message_id_foreign - Foreign key index with onDelete('set null')

Message Threads Table:

  • threads_last_response_idx - Index on last_response_at for filtering

Message Thread Reminders Table:

  • reminders_due_idx - PostgreSQL partial index: scheduled_for WHERE sent_at IS NULL AND cancelled_at IS NULL
  • reminders_cancel_idx - Compound index on (thread_id, recipient_id, sent_at, cancelled_at)
  • unique_reminder - Unique constraint on (thread_id, trigger_message_id, level)

Migrations:

  • modules/Messages/Database/Migrations/2025_11_11_210244_add_message_performance_indexes.php
  • modules/Messages/Database/Migrations/2025_12_03_xxxxxx_create_message_thread_reminders_table.php
  • modules/Messages/Database/Migrations/2025_12_03_185736_add_missing_indexes_to_message_reminder_tables.php

Testing

Test Coverage

Location: modules/Messages/Tests/

Test Suites:

  1. Feature/MessagingFlowTest.php - End-to-end messaging workflows
  2. Feature/AntiDisintermediationTest.php - Privacy and security validation
  3. Feature/GlobalUnreadCountTest.php - Unread count accuracy and caching behavior
  4. Feature/MessageReminderTest.php - Reminder scheduling, sending, and cancellation
  5. Unit/VertexAiScanningServiceTest.php - AI service including scanForNeedsResponse()

Key Test Scenarios

Messaging Flow:

  • Thread creation and replies
  • Read/unread tracking
  • Authorization checks
  • Validation errors
  • Pagination and ordering

Anti-Disintermediation:

  • No email/phone exposure in any API response
  • Vendor contact info hidden from couples
  • Couple contact info hidden from vendors
  • Booking URL safely exposed (platform-hosted)

Unread Count Testing:

  • Global unread count calculation
  • Cache behavior (TTL, invalidation triggers)
  • Multi-thread unread scenarios
  • Soft-deleted messages excluded from counts

Message Reminders:

  • AI needs-response detection (heuristic and AI fallback)
  • Reminder scheduling at correct intervals (24h, 72h, 168h)
  • Reminders cancelled when recipient responds
  • Race condition handling (recipient responds before AI check)
  • Escalating notification tone (friendly → urgent → final)
  • Pessimistic locking prevents duplicate sends

Running Tests

bash
# All Messages tests
docker compose exec -T wedissimo-api vendor/bin/pest modules/Messages/Tests/

# Specific test suite
docker compose exec -T wedissimo-api vendor/bin/pest --filter="GlobalUnreadCount"

# With coverage
docker compose exec -T wedissimo-api vendor/bin/pest modules/Messages/Tests/ --coverage

Future Enhancements

Important: The features listed below are aspirational ideas for potential future development. They are NOT currently implemented and should not be treated as committed roadmap items. These represent possibilities that may be considered based on user feedback and business priorities.

Potential Features Under Consideration

  • AI-Assisted Scanning - Use AI to validate regex matches and reduce false positives in contact info detection
  • Typesense Search - Full-text search within messages for faster conversation discovery
  • Archive Functionality - Allow users to hide threads without deletion
  • MessageThread Soft Deletes - Recoverable deletion for threads (Messages already use soft deletes)
  • Read Receipts - Show timestamp when recipient viewed message
  • Typing Indicators - Real-time "User is typing..." notifications via WebSockets

Not Planned (Out of Scope)

  • Message Editing - Not planned due to audit trail concerns and policy compliance
  • User Message Deletion - Not planned due to dispute resolution and compliance requirements
  • Admin API Endpoints - Laravel admin panel provides sufficient admin functionality

Guides

Project Guides


Last Updated: 2025-12-03 Module Version: 1.1 (FOU-59: Message Reminders) Maintainer: Wedissimo Platform Team

Wedissimo API Documentation