# Layer 9 — Purchase Messaging (Design)

**Status:** Draft · 2026-05-06
**Predecessors:** Layer 5 (Fulfillment), Layer 6 (Notifications), Layer 7 (Seller Dashboard), Layer 8 (Admin Console & Disputes)
**Successors:** TBD (likely: financial reporting, multi-admin RBAC)

---

## Purpose

Give buyers and sellers a structured way to talk about an order without falling back to email. Reduces the number of disagreements that escalate into Stripe disputes and gives admins a paper trail when one does. Admins can read every thread and post as a system persona to mediate.

This is the first feature surface that isn't strictly about money movement — it's the support tooling that the dispute work made obvious was missing.

## User model

Three audiences, three reasons to be in a thread:

- **Buyer.** "Where's my package?" / "This came damaged." / "Did you mean to ship me size M?" Comes in via the purchase detail page when something feels off; mostly reactive, low frequency per buyer (≤ a handful of threads/year).
- **Seller (store owner).** Replying to buyers, sending tracking updates, asking clarifying questions. Higher cadence per user; sellers may juggle 10-50 active threads in a busy week.
- **Admin.** Reads only when intervening — typically when a dispute is filed, when a buyer escalates via support email, or as a periodic spot-check. Posts as the platform ("Alqove Support") rather than as themselves.

Implications:

- Polling is fine. Buyers and sellers will see new messages on next page load or 30s focus refresh; no realtime infrastructure.
- Desktop-first for sellers, mobile-friendly for buyers (the buyer surface lives inside the existing purchase detail page which is already mobile-friendly).
- Sellers manage many threads → need an inbox-style overview surface, not just the per-order panel.
- Admins don't need to compose; reading is the dominant case. Composing exists but is a 1% path.

## Scope

### In scope

- One thread per **Order** (not per Purchase — a multi-seller cart has independent conversations per store).
- **Text messages** with a 5,000-character cap.
- **Image attachments** — multiple per message, JPEG/PNG/HEIC, up to 5 MB each, max 4 per message. Buyer might attach photos of damage; seller might attach a packing slip.
- **Three roles** with distinct presentation: `buyer`, `seller`, `admin`. Admin posts render with an "Alqove Support" name and a distinct color/badge.
- **Read state** as a single `last_read_at` timestamp per (user, thread) pair, stored in a `message_thread_reads` table. Unread count derived: `count(messages where created_at > last_read_at AND author_user_id != me)`.
- **Soft-delete** on messages — author can delete their own; admins retain visibility into deleted content with a "(deleted by author)" placeholder for buyer/seller.
- **Notifications** — when the other party posts, the recipient gets a database + email notification (gated by a new `Support` category in the existing preferences). Buyer↔seller posts notify each other; admin posts notify both buyer and seller.
- **Surfaces:**
  - Buyer: messages section on `/purchases/[id]` per order in the purchase
  - Seller: dedicated `/seller/inbox/messages` route + a panel on `/seller/orders/[id]`
  - Admin: read-only-by-default panel on `/admin/orders/[id]` with a "Post as Alqove Support" toggle
- **Audit:** every admin post writes a `spatie/activitylog` row (`message.admin_posted`).

### Out of scope

- Search across messages (defer; threads are short enough that scrollback is fine)
- Typing indicators / realtime presence
- Translation
- Bulk archive
- Threading within a thread (replies-to-specific-messages)
- Read receipts per-message (we use last_read_at only)
- Voice/video, link previews, markdown
- Buyer-buyer or seller-seller threads
- Off-platform contact methods (no email/SMS reply-by-email)
- Admin canned responses / templates (defer to a later admin tooling layer)

## Information architecture

### Buyer

The existing purchase detail page (`/purchases/[id]`) already lists each order in the purchase. Each order's card gets a new collapsed "Messages with {Store Name}" section below the items list. Click to expand → full thread. Compose box at the bottom.

Unread count badge on the section header per order (e.g. "Messages with Revive Boutique · 2 new").

### Seller

A new `/seller/inbox/messages` route — list of threads sorted by latest activity, with order id, buyer name, snippet of the latest message, and unread count per thread. Click a row → `/seller/orders/[id]` with the messages panel pre-expanded and scrolled to bottom.

The existing `SellerTopBar` notification bell already covers the message-arrived notification — clicking it routes to the inbox.

The existing `/seller/orders/[id]` page gets a Messages panel (rendered after Timeline, before Payout). Same component as the buyer side, just from the seller's role.

### Admin

`/admin/orders/[id]` (already exists) gets a Messages panel below Stripe state. Read-only by default; an "Intervene" toggle exposes a compose box that posts as `author_role = admin`. Posts include an automatic prefix in the rendered output ("Alqove Support: …") and a contrasting badge color so buyer/seller see clearly that this isn't from their counterparty.

## Section-by-section design

### Data model

```
message_threads (one row per Order, lazy-created on first message)
  id (uuid, PK)
  order_id (uuid, FK, unique)
  created_at, updated_at

messages
  id (uuid, PK)
  thread_id (uuid, FK)
  author_user_id (uuid, FK nullable — null for "system" rows like cancellation notices, future)
  author_role (enum: buyer / seller / admin)
  body (text, max 5000 chars)
  attachments (json — array of { url, content_type, size_bytes }; capped at 4 entries server-side)
  created_at
  deleted_at (nullable; soft delete)
  index (thread_id, created_at)

message_thread_reads
  thread_id (uuid, FK, composite PK with user_id)
  user_id (uuid, FK)
  last_read_at (timestamp)
  index (user_id) — for "all my threads" unread queries
```

Authorization: a user can access a thread iff:
- `purchase.buyer_id == user.id` (buyer)
- OR `order.store.owner_user_id == user.id` (seller — store owner)
- OR `user->hasRole('admin')`

Inline check in a `MessageThreadAccess` action class — kept out of policies because the rule is small and one-shot.

### Endpoints

- `GET /v1/orders/{order}/messages?after=<message_id>` — paginated; `after` is for incremental fetch on the open thread. Returns 200 with `data: Message[]` and `meta` (total, has_more). Auto-creates the thread row if absent. Auto-stamps `last_read_at` on the user's `message_thread_reads` row.
- `POST /v1/orders/{order}/messages` — body `{ body: string, attachment_ids: string[] }`. Returns 201 with the new message. Author role inferred server-side from auth check. Triggers notification fan-out.
- `DELETE /v1/messages/{message}` — soft-delete; only the author can call this. Admin can soft-delete *any* message and the row carries `deleted_by_user_id` so audit log shows who.
- `GET /v1/me/threads` — list of threads the user participates in (buyer or seller view). Sorted by latest activity. Each row: thread_id, order_id, counterparty_name, last_message_snippet, unread_count.
- `POST /v1/orders/{order}/messages/attachments` — image upload, returns `{ id, url, content_type, size_bytes }` for client to attach to a subsequent POST. Validates content-type + size on receipt.

Admin-side reuses the buyer/seller endpoints — the auth check passes because of the role. The admin "Intervene" UI just sets `author_role` to `admin` server-side via the same role check.

### Rate limiting

- POST messages: 30/min per user (existing api throttle is ample for now; bump if abused)
- POST attachments: 10/min per user
- GET endpoints: standard `api` group throttle (60/min) is fine

### Notifications

New `NotificationCategory::Support` case. Two notification classes:

- `MessagePostedToBuyerNotification` — fired when seller or admin posts. Recipient: buyer.
- `MessagePostedToSellerNotification` — fired when buyer or admin posts. Recipient: store owner.

Body includes the message snippet (first 140 chars), a CTA URL deep-linking to the thread, and the order id. Category `Support` so users can disable email but not the in-app row (database channel is always on, like all categories).

Admin-posted messages always notify both parties (buyer + seller); buyer/seller posts only notify the other party.

### Image attachments

Storage: Spatie MediaLibrary is already in the project (per Item images). Use it for messages too — collection `message_attachments` on the `Message` model.

Upload flow:
1. Client POSTs file to `POST /v1/orders/{order}/messages/attachments`
2. Server validates: content-type ∈ {image/jpeg, image/png, image/heic}, size ≤ 5 MB, signed URL returned
3. Client gets `{ id, url, content_type, size_bytes }` and stores it in component state
4. Client POSTs the message with `attachment_ids: [id, id, ...]`
5. Server transfers the uploaded media to the new Message's collection (preserves the upload-then-attach UX)

Server cleans up unattached uploads via a daily console command (`messages:gc-orphan-attachments`) — same pattern as item-image cleanup if it exists.

Display: thumbnails inline in the message; click to open full-size in a lightbox modal (use the existing `ItemGallery` lightbox component).

### Soft-delete

`Message::deleted_at` set on user-initiated delete. The endpoint:
- Author calls `DELETE /v1/messages/{message}` → if `author_user_id == user.id` and not already deleted, set `deleted_at = now()` and `deleted_by_user_id = user.id`. 204.
- Admin calls the same endpoint → same logic but no author-match check; `deleted_by_user_id = admin.id`.
- Buyer/seller see deleted rows as a stub: "(deleted by author)" or "(deleted by Alqove Support)" with the timestamp; body and attachments hidden.
- Admin sees the original body + attachments + deleted-by metadata.

Body retained in DB indefinitely (no separate retention policy this layer; flag for legal review later).

### Audit log

Every admin post and every admin-initiated delete writes a row on the `admin` log_name (so it shows up in `/admin/activity`):

- `message.admin_posted` — properties: `thread_id`, `order_id`, `body_preview` (first 140 chars), `attachment_count`
- `message.admin_deleted` — properties: `thread_id`, `message_id`, `original_author_user_id`, `body_preview`

Buyer/seller posts and self-deletes do NOT write activity log rows — too high-volume; the `messages` table itself is the audit trail for those.

## Cross-cutting

### Auth & guards

- All endpoints under `auth:sanctum`.
- `MessageThreadAccess` helper centralizes the buyer/seller/admin check; reused by every controller method.
- The `messages.send` action will reject if the order's `cancelled_at` is set AND `now() > cancelled_at + 30 days`. Past that window, threads go read-only. Configurable via `config('messaging.compose_window_days', 30)` so we can extend.

### Data fetching (web)

- TanStack Query for the thread reader; key `['messages', order_id]`. `staleTime: 15s`, `refetchOnWindowFocus: true`.
- Polling every 30 seconds when the thread is the active route (matches NotificationBell cadence).
- Optimistic insert on POST: append the message immediately, mark as "sending"; rollback on 4xx.
- Image upload uses `useMutation` with progress tracking via a custom XHR; the api-client gets a `messages.uploadAttachment(orderId, file, onProgress)` helper.

### API client

`@alqove/api-client`:
- `messages.list(orderId, { after?: string })`
- `messages.post(orderId, { body, attachment_ids })`
- `messages.delete(messageId)`
- `messages.uploadAttachment(orderId, File, onProgress)`
- `me.threads()`

OpenAPI: contracts/openapi.yaml gains the five paths; `Message`, `MessageThreadSummary`, `MessageAttachment` schemas.

### File / module layout

Backend (`api/`):

```
app/Modules/Messaging/                            (new module)
  Controllers/
    MessageController.php                         GET/POST/DELETE on /orders/{order}/messages, DELETE /messages/{m}
    AttachmentController.php                      POST /orders/{order}/messages/attachments
    MyThreadsController.php                       GET /me/threads
  Services/
    MessageThreadAccess.php                       canAccess(User, Order): bool, roleFor(User, Order): MessageRole
    MessagePoster.php                             create message + attach media + dispatch notifications
  Resources/
    MessageResource.php
    MessageThreadSummaryResource.php
    MessageAttachmentResource.php
  Requests/
    PostMessageRequest.php                        validate body length + attachment_ids
    UploadAttachmentRequest.php                   image content-type + size
  routes.php

app/Modules/Notifications/Notifications/
  MessagePostedToBuyerNotification.php
  MessagePostedToSellerNotification.php

app/Models/
  MessageThread.php
  Message.php
  (extend User: messageThreadReads relationship)

app/Support/Enums/
  MessageRole.php                                 buyer / seller / admin
  NotificationCategory.php                        + Support case

database/migrations/
  ..._create_message_threads_table.php
  ..._create_messages_table.php
  ..._create_message_thread_reads_table.php
```

Web (`web/`):

```
src/components/messaging/
  message-thread.tsx                              shared component for buyer/seller/admin
  message-row.tsx
  message-composer.tsx
  attachment-uploader.tsx
src/lib/queries/
  use-messages.ts                                 useMessages, usePostMessage, useUploadAttachment
  use-my-threads.ts
src/app/(buyer)/purchases/[id]/                   (extend purchase-detail-client.tsx)
src/app/(seller)/seller/inbox/messages/page.tsx   (new — threads list)
src/app/(seller)/seller/orders/[id]/              (extend order-detail-client.tsx)
src/app/(admin)/admin/orders/[id]/                (extend order-detail-client.tsx with admin variant)
```

### Testing

Backend (Pest feature tests in `tests/Feature/Messaging/`):

- Auth gate (buyer/seller/admin/unrelated user) for GET, POST, DELETE
- Thread auto-creation on first GET
- POST creates message + dispatches the right notification
- POST with attachments transfers media correctly
- DELETE soft-deletes; buyer/seller see stub, admin sees original
- Admin DELETE writes `message.admin_deleted` activity row
- Last-read tracking: GET stamps last_read_at, unread count derives correctly
- Compose-window: 30 days after order cancel returns 409
- Image validation: oversize, wrong content-type → 422

Web (Vitest + RTL):

- `MessageThread` rendering with buyer/seller/admin roles
- Optimistic insert + rollback on POST failure
- Attachment upload progress + retry
- `MessageRow` deleted-stub variant
- Buyer-side purchase-detail integration: messages section appears per order, unread badge counts right

### Privacy / compliance notes

- No PII automatically extracted from message bodies (no NER, no auto-redaction).
- Soft-deleted bodies retained indefinitely — flag for legal review when GDPR/data-retention work happens.
- Image attachments stored in the existing media disk; same retention as item images.
- No third-party processors involved.

## Open items

These flag for the implementation plan, not blockers for this spec:

- **Media disk** — confirm whether item images use a separate disk (e.g. S3) or the local public disk. Message attachments should match.
- **MediaLibrary collection migration** — if MediaLibrary's `media` table is keyed by polymorphic `model_type`/`model_id`, no migration; if there's a per-collection table, add one.
- **`@alqove/api-client` upload helper** — the existing client has no `multipart/form-data` helper; the AttachmentController endpoint needs the client to know how to upload. Spec assumes a small extension to `AlqoveClient` for `uploadFile`.
- **Compose window default** — 30 days is a guess. Adjust to whatever the buyer-side return policy actually allows.
- **Seller inbox bell behavior** — when a buyer posts on an order, the seller's existing NotificationBell shows it via the new database notification. Confirm during planning that the bell's category filter doesn't accidentally exclude `Support`.
- **Mobile attachment UX** — out of scope for the spec, but mobile buyers may want camera capture. Defer to a later mobile-focused layer.
- **Email rendering of attachment thumbnails** — emails include a snippet but no inline image previews this layer; the email links back to the thread.
