SRS — Messaging & Notifications
Module owner: Domain 7 — Communication. Two shared engines: (1) Messaging — customer↔vendor conversations always scoped to a business context (event, RFQ or booking); (2) the Notification engine — one catalog-driven service that every other module publishes events into. Engines are shared services: no module implements its own sending, ever.
Related: Quotations · Booking · Admin & CMS
Purpose
Keep every negotiation, question and file exchange inside Yam3at — organized per event, moderated for safety, and admissible when disputes arise — and deliver the right notification to the right person on the right channel without spamming anyone.
Actors
| Actor |
Role |
| Customer |
Chats with vendors within their events' contexts; manages notification preferences |
| Vendor |
Chats with customers on received RFQs/bookings; manages preferences |
| Admin (support) |
Reads conversations only under an open dispute or abuse report; handles reports |
| System |
Delivers messages (WebSocket + push), scans content, renders and sends notifications |
User stories
- As a customer, I want one conversation per vendor per event, so that my wedding chats never mix with my office party chats.
- As a customer, I want to send the venue floor plan as a PDF, so that the decorator quotes accurately.
- As a vendor, I want read receipts, so that I know my quote clarification was seen.
- As a vendor, I want a push the instant a new RFQ arrives, so that I'm first to respond.
- As a user, I want to report an abusive message, so that the platform stays professional.
- As a support admin, I want conversation access when a dispute is open, so that I can judge "the vendor promised X" claims — and only then.
- As a user, I want to mute marketing notifications but keep booking alerts, so that I control my attention.
Functional requirements
Conversations — MVP
| ID |
Requirement |
Phase |
| SRS-MSG-001 |
A Conversation is between exactly one customer and one vendor, created lazily on first message, and carries a mandatory context: context_type (rfq | booking | event_vendor) + context_id. One conversation per (customer, vendor, event) — RFQs and bookings of the same pair on the same event thread into it with context chips inline; a different event → a separate conversation. |
MVP |
| SRS-MSG-002 |
Message types: text (≤ 5,000 chars), image (jpg/png/webp ≤ 10 MB), file (PDF only ≤ 20 MB, MVP). Up to 5 attachments per message. System messages (quote sent, booking confirmed…) render inline as non-editable context cards. Voice notes = Scale. |
MVP |
| SRS-MSG-003 |
Real-time delivery over WebSockets (Laravel Reverb/Echo) with FCM push fallback when the recipient has no live connection. Messages persist first, then fan out — no at-most-once delivery. |
MVP |
| SRS-MSG-004 |
Read receipts: per-message delivered_at, read_at; sender sees single/double state. Conversation-level unread counters power badges and the event dashboard. |
MVP |
| SRS-MSG-005 |
Typing indicator over the socket channel, throttled to 1 signal per 3 s, auto-clears after 6 s. Never persisted. |
MVP |
| SRS-MSG-006 |
Sender can delete own message within 15 minutes ("deleted" placeholder remains; original retained internally 90 days for moderation). No editing in MVP. |
MVP |
| SRS-MSG-007 |
Conversation list per user: sorted by last activity, with search by vendor/customer name and event filter, archive (hide) without deleting. |
MVP |
Safety & moderation — MVP
| ID |
Requirement |
Phase |
| SRS-MSG-030 |
Blocked-words engine (shared with RFQ/quote text per SRS-RFQ business rule 8): admin-managed AR/EN term lists in two tiers — warn (profanity: message sent, flagged for moderation) and contact-leak patterns (phone regex [\d٠-٩]{8,}, emails, instagram/wa.me handles: sender warned inline "keep contact in Yam3at to stay protected", message delivered but flagged; 3 flags in 7 days → account review task). Blocking is deterrence + signal, not censorship theater. |
MVP |
| SRS-MSG-031 |
Report abuse: any participant reports a specific message or the whole conversation (reason enum: harassment, scam, spam, inappropriate, off-platform pressure + note). Creates a moderation case in the admin queue; reporter can also block the counterpart (blocks new messages both ways; existing history untouched; active bookings unaffected and flagged to support). |
MVP |
| SRS-MSG-032 |
Admin read access is dispute-gated: support/ops admins can open a conversation transcript only when it is attached to an open dispute or abuse report. Every access is audit-logged (admin, case ref, timestamp) and both participants' privacy notice discloses this policy. Super admin override exists but is logged identically. |
MVP |
Notification engine — MVP core
| ID |
Requirement |
Phase |
| SRS-MSG-040 |
Central engine: modules publish domain events; the engine maps event → catalog entry → audience → channels → localized template (AR/EN by recipient's locale) → delivery jobs on Redis queues, with per-channel providers (Mailgun/SES email, FCM push; SMS & WhatsApp Business API = Scale behind the same channel abstraction). |
MVP |
| SRS-MSG-041 |
Every notification also writes an in-app notification-center entry (bell icon, read/unread) regardless of channel outcomes. |
MVP |
| SRS-MSG-042 |
Delivery rules: retries with exponential backoff (3 attempts/channel); email respects suppression lists (bounces/complaints auto-suppress); push tokens pruned on FCM invalid-token responses; message-received pushes are collapsed per conversation (max 1 push/conversation/5 min, latest content). |
MVP |
| SRS-MSG-043 |
User preferences: per category (transactional, reminders, marketing) × per channel toggle. Transactional (payments, booking state, security) cannot be fully disabled — email stays locked on. Marketing is opt-in-first. Quiet hours (default 22:00–08:00 Asia/Kuwait) hold non-critical pushes. |
MVP |
| SRS-MSG-044 |
Template management: template_key + channel + locale versions with variables (e.g. {{vendor_name}}, {{amount}}); admin-editable via CMS with preview + test-send; missing-locale falls back EN with an alert. |
MVP |
Notification-event catalog
| Trigger |
Audience |
Channels (MVP) |
Template key |
Category |
| RFQ received |
Vendor |
Push, Email |
rfq.received |
Transactional |
| Quote received |
Customer |
Push, Email |
quote.received |
Transactional |
| Quote revised / counter answered |
Customer |
Push |
quote.revised |
Transactional |
| Negotiation counter received |
Vendor |
Push, Email |
quote.counter |
Transactional |
| Quote accepted (won) |
Vendor |
Push, Email |
quote.accepted |
Transactional |
| Quote not selected |
Vendor |
Push |
quote.not_selected |
Transactional |
| Quote/RFQ expiring in 24h |
Customer / Vendor |
Push |
rfq.expiring |
Reminder |
| RFQ/quote expired |
Both parties |
Push |
rfq.expired |
Transactional |
| Deposit due (booking created) |
Customer |
Push, Email |
booking.deposit_due |
Transactional |
| Deposit deadline in 12h |
Customer |
Push, Email |
booking.deposit_reminder |
Reminder |
| Deposit paid |
Vendor |
Push, Email |
booking.deposit_paid |
Transactional |
| Booking confirmed |
Customer |
Push, Email |
booking.confirmed |
Transactional |
| Booking cancelled / declined |
Both parties |
Push, Email |
booking.cancelled |
Transactional |
| Reschedule proposed / accepted |
Counterpart |
Push, Email |
booking.reschedule |
Transactional |
| Event date changed (has bookings) |
Booked vendors |
Push, Email |
event.date_changed |
Transactional |
| Completion check ("did vendor deliver?") |
Customer |
Push, Email |
booking.completion_check |
Transactional |
| Booking completed → review invite |
Customer |
Push, Email |
review.invite |
Transactional |
| Review published |
Vendor |
Push, Email |
review.published |
Transactional |
| Vendor replied to your review |
Customer |
Push |
review.replied |
Transactional |
| Payment succeeded (receipt) |
Payer |
Email (locked), Push |
payment.receipt |
Transactional |
| Payment failed |
Payer |
Push, Email |
payment.failed |
Transactional |
| Refund approved / processed / denied |
Customer |
Push, Email |
refund.status |
Transactional |
| Subscription renewal upcoming (D−7/−3/−1) |
Vendor |
Email, Push |
subscription.renewal_reminder |
Reminder |
| Subscription payment failed / grace |
Vendor |
Email, Push |
subscription.payment_failed |
Transactional |
| Subscription downgraded to Free |
Vendor |
Email, Push |
subscription.downgraded |
Transactional |
| Vendor application approved / rejected |
Vendor |
Email, Push |
vendor.approval_result |
Transactional |
| New message |
Recipient |
Push (collapsed) |
message.received |
Transactional |
| Abuse report resolved |
Reporter |
Push |
moderation.report_resolved |
Transactional |
| Checklist item due (Scale) |
Customer |
Push |
checklist.due |
Reminder |
| Event in 7 days summary |
Customer |
Push, Email |
event.week_out |
Reminder |
| Promos / announcements |
Opted-in users |
Email, Push (Scale: SMS/WhatsApp) |
marketing.campaign |
Marketing |
Business rules
- No conversation without a business context — cold vendor-to-customer outreach does not exist.
- Vendors can initiate messages only on RFQs they received or bookings they hold; customers can initiate with any vendor via an RFQ or from a listing's "ask a question" (which creates an
event_vendor context tied to a chosen event).
- Attachments live on private S3 with short-lived signed URLs; direct object URLs never exposed.
- Conversation history is retained while either party's account exists; account deletion anonymizes their side (name → "Deleted user") without destroying the counterpart's record.
- One notification per (event, recipient, template) occurrence — the engine deduplicates on an idempotency hash so job retries never double-send.
- Channel failures never fail business transactions: notification dispatch is strictly async post-commit.
- All templates exist in AR and EN before a catalog entry can be activated.
Validation rules
| Field |
Rule |
| Message text |
1–5,000 chars after trim; not only whitespace |
| Attachments |
≤ 5 per message; image ≤ 10 MB (jpg/png/webp), PDF ≤ 20 MB; MIME sniffed server-side (not extension-trusted); images EXIF-stripped; all virus-scanned |
| Report |
reason enum required; note ≤ 1,000 chars |
| Preferences |
valid category × channel matrix; transactional email cannot be set off |
| Template |
valid variable set for its template_key; both locales present to activate |
| Push token |
FCM format, one active per device, refreshed on app launch |
Permissions matrix
| Action |
Participant (customer/vendor) |
Non-participant user |
Admin support |
Admin ops |
Admin super |
| Send/read messages in conversation |
✔ |
✘ |
✘ |
✘ |
✘ |
| Delete own message (≤15 min) |
✔ |
✘ |
✘ |
✘ |
✘ |
| Report / block |
✔ |
✘ |
✘ |
✘ |
✘ |
| Read transcript (dispute/report open) |
— |
✘ |
✔ (logged) |
✔ (logged) |
✔ (logged, incl. override) |
| Manage blocked-word lists |
✘ |
✘ |
✘ |
✔ |
✔ |
| Manage templates & catalog |
✘ |
✘ |
✘ |
✔ |
✔ |
| Send marketing campaign |
✘ |
✘ |
✘ |
✔ (approval by super) |
✔ |
| Edit own notification preferences |
✔ |
✔ |
✔ |
✔ |
✔ |
Status / state machine
Message delivery lifecycle:
stateDiagram-v2
[*] --> Sent : persisted
Sent --> Delivered : recipient device/socket ack
Delivered --> Read : recipient opened conversation
Sent --> Deleted : sender deletes ≤ 15 min
Delivered --> Deleted : sender deletes ≤ 15 min
Read --> [*]
Deleted --> [*]
Notification job: queued → sent → delivered | bounced | failed(retry ×3) → suppressed? per channel; in-app entry: unread → read.
Edge cases
- Recipient blocked the sender: send returns 403 with a neutral "cannot message this user" (no block disclosure); system messages on active bookings still deliver.
- Vendor at plan limit gets a message on an existing conversation: messaging is never plan-limited — only RFQ receipt is.
- Attachment scan finds malware: message delivered without the attachment + "attachment removed" placeholder; sender notified; repeat → account review.
- Push token invalid mid-fanout: token pruned silently; in-app entry still created; email fallback only if the catalog entry includes email.
- Clock-skewed client timestamps: server time is authoritative; client times only for optimistic rendering.
- Dispute closed while admin has transcript open: access re-checked per request; closed case → 403 on next page.
- User disables push at OS level: engine still records in-app; a periodic "you're missing updates" email nudge (max 1/month) if transactional pushes consistently undeliverable.
- Two devices, same user: read state syncs across devices via socket events; unread counters converge.
Empty / loading / error states
| Surface |
Empty |
Loading |
Error |
| Conversation list |
"Conversations start from a quote request" + CTA to browse vendors |
Skeleton rows |
Cached list + retry banner |
| Conversation view |
Context card only ("You're talking about: {RFQ title}") |
Paginated history spinner (reverse infinite scroll, 50/page) |
Failed sends queue locally with retry/discard per message; offline banner |
| Notification center |
"You're all caught up" |
Skeleton rows |
Retry; badge count from cache |
| Preferences |
— |
Toggle-level optimistic update |
Revert toggle + toast on save failure |
Acceptance criteria
Data entities touched
conversations, conversation_participants, messages, message_attachments, message_receipts, message_flags (blocked-word hits), abuse_reports, user_blocks, notification_catalog, notification_templates, notifications (in-app), notification_deliveries (per-channel attempts), notification_preferences, push_tokens, email_suppressions, admin_transcript_access_log; reads on events, quotation_requests, bookings, disputes.
GET /api/v1/conversations?event_id=&archived=
POST /api/v1/conversations # context_type + context_id (+ event) required
GET /api/v1/conversations/{id}/messages?before=
POST /api/v1/conversations/{id}/messages
DELETE /api/v1/messages/{id} # ≤ 15 min own message
POST /api/v1/conversations/{id}/read # mark read up to message
POST /api/v1/conversations/{id}/archive
POST /api/v1/messages/{id}/report
POST /api/v1/users/{id}/block | DELETE /api/v1/users/{id}/block
WS /ws (channels: conversation.{id}, user.{id})
# notifications
GET /api/v1/notifications?unread=1
POST /api/v1/notifications/read-all
GET|PUT /api/v1/me/notification-preferences
POST /api/v1/me/push-tokens | DELETE /api/v1/me/push-tokens/{token}
# admin
GET /api/v1/admin/moderation/reports?status=open
GET /api/v1/admin/conversations/{id}/transcript # dispute-gated, logged
GET|PUT /api/v1/admin/notification-templates/{key}
GET|PUT /api/v1/admin/blocked-words