# Seller orders: customer privacy, answers without reload, WhatsApp link expiry

Branch: `fix/seller-orders-privacy-ajax-2026-10-21` (from `dev`) · Date: 2026-10-21

## 1. Customer data hidden from the seller

**Before:** the seller order page (`/seller/orders/{id}`) showed the customer's name, email, phone, full address and address notes. The same data was also in the seller's invoice PDF, the "order placed" invoice e-mail sent to the seller, and the seller app's order details (`shipping_address`).

**Now:** the seller sees **the customer's name only**. Phone, email, address and notes are hidden on every surface a seller can reach. We deliver with our own captain, so the seller never needs them.

| Surface | Change |
|---|---|
| Seller order page `seller/orders/show` | Address block replaced by "Customer: <name>" |
| Seller orders list `seller/orders/index` | Same name source (a guest no longer shows its guest id) |
| Seller invoice PDF (`backend/invoices/invoice`, `sellerView`) | Name only; the card title reads "Customer" instead of "Shipping Address" |
| "Order placed" invoice e-mail to the seller (`emails/invoice`) | Name only for the seller's copy. The customer and admin copies are unchanged |
| Seller app API `Api/V2/Seller/OrderDetailResource` | `shipping_address` keeps the same keys, so the app parses it as before, but every value except `name` is `""` |

Already name-only before this change: the seller push notification, the WhatsApp alert and its action page, and the Excel export.

Single source: `app/Utility/SellerCustomerPrivacy.php` (`name()`, `shippingAddressForApi()`). Any new seller-facing order screen must read the customer through it.

Unchanged on purpose: the admin pages, the customer's own pages and invoices, and the delivery-boy e-mail (the delivery agent needs the address).

## 2. Confirm / "not available" without a full page reload

**Before:** on the seller order page, "Confirm order", the per-product "Not available" / "Mark available" buttons and the "Not available" modal each sent an AJAX request and then called `location.reload()`, so the whole page (layout, menus, every query) loaded again.

**Now:** the page sends `panel=1`. The controller (`Seller\OrderController::panelHtml()`) returns the three parts of the page that an answer changes, rendered again, and the page swaps them in place:

- `seller/orders/partials/actions.blade.php`: the confirm / "not available" box
- `seller/orders/partials/products.blade.php`: the products table with the availability buttons
- `seller/orders/partials/unavailable_items.blade.php`: the checkboxes in the "not available" modal

The modal closes and its note is cleared after sending. The products table is an `aiz-table`, which the theme keeps invisible (`opacity: 0; height: 0`) until its table plugin (FooTable) has set it up. That setup normally runs once on page load, so the page runs it again on the swapped-in table (`AIZ.plugins.sectionFooTable('#seller-order-products')`); without that call the table disappeared after an answer. Click handlers are delegated, so the swapped-in buttons keep working. If the response carries no HTML, the page falls back to a reload.

Requests without `panel` (the WhatsApp action page, a page left open from before the deploy) get exactly the same JSON as before.

### Extra performance fixes on the same pages

- **Orders list:** every row used to be loaded twice (`Order::find` in the controller loop and again in the view) and saved one by one to mark it viewed. Now the page's orders load in one query with their lines and customers, and one `UPDATE` marks them viewed.
- **Order page:** removed a query that loaded every delivery boy in the customer's city, whose result the page never showed. The order is saved as "viewed" only when it was not viewed already.

The payment-status dropdown (accountants only) still reloads the page; it was not part of this change.

## 3. "This link is no longer valid" (WhatsApp order alert)

**Finding:** these links are not Laravel signed URLs. The WhatsApp alert buttons (`/so/a/{token}` accept, `/so/o/{token}` out of stock, `/so/v/{token}` view order) carry a one-time token (only its hash is stored). A link shows "This link is no longer valid" in two cases:

1. **It expired.** `token_expires_at` = sent time + `seller_whatsapp_token_hours` (default **72 h**).
2. **A newer message for the same order replaced it.** Every reminder (default at **15 and 45 minutes** after the alert) and every resend creates a new token and voids the previous one. Only the buttons in the newest WhatsApp message work. This is most likely the "stops working after a while" that sellers see: the first message's buttons die when the 15-minute reminder goes out.

The seller order page link (`/seller/orders/{encrypted id}`) does not expire. It is protected by the seller login.

**Fix (2026-10-21): every message of an alert carries the same link.** The link is no longer random per message. It is computed from the alert's id with the server's secret key (HMAC-SHA256 under `APP_KEY`), so the first message, every reminder and every resend carry the **same** link. Nothing gets replaced, so the first message's buttons keep working after the 15- and 45-minute reminders. No table, migration or cache is involved.

- **Duration:** **72 hours** (the default), counted from the **latest** message for that order: each reminder or resend moves the expiry forward. It can be raised on the admin page **"Seller WhatsApp Order Alerts"** (`seller_whatsapp_token_hours`, up to 720 h = 30 days).
- **Safety:** nobody can compute a link without `APP_KEY`. Only the link's sha256 is stored, and the link is never logged, as before. An order can still be answered only once: the first tap wins and a later tap shows "already answered". Opening a link (GET) still decides nothing. If `APP_KEY` were ever empty, the code falls back to the old random link rather than a guessable one.
- **Trade-off:** one alert's link can't be replaced early; it only ends at expiry. Nothing in the system needed that.
- **At deploy:** an alert still waiting when the code goes live has a random first link. Its next reminder switches it to the computed link, so that first message dies one last time. This affects only alerts sent in the hour or so before the deploy.
- **APP_KEY:** changing it would end every open link. It already breaks every encrypted order URL, so it's never changed casually.

Code: `SellerWhatsAppUtility::rotateToken()`.

## Files

- New: `app/Utility/SellerCustomerPrivacy.php`, `resources/views/seller/orders/partials/{actions,products,unavailable_items}.blade.php`
- Changed: `app/Http/Controllers/Seller/OrderController.php`, `resources/views/seller/orders/{show,index}.blade.php`, `resources/views/backend/invoices/invoice.blade.php`, `resources/views/emails/invoice.blade.php`, `app/Mail/InvoiceEmailManager.php`, `app/Utility/NotificationUtility.php`, `app/Traits/FirebaseNotificationTrait.php`, `app/Http/Resources/V2/Seller/OrderDetailResource.php`, `app/Utility/SellerWhatsAppUtility.php`
- Docs: `docs/codebase/01-orders-checkout.md` (§4.5 link expiry, §6 items 17 and 18, §9)

## Tests

- `SellerOrderConfirmationTest`: the redrawn parts come back with `panel=1` and not without it; the seller sees the name only (helper, app resource, page, invoice and e-mail sources). The test schema gained an empty `products` table.
- `SellerOrderUnavailableTest`: the per-product and modal answers return the redrawn buttons and ticked checkboxes.
- `SellerWhatsAppAlertTest`: a resend and a reminder carry the same link as the first message, it still answers, each send moves the expiry forward, and another alert gets a different link. This replaced the old test that pinned "only the newest link works". The test schema gained a `translations` table (the reminder text uses `translate()`).
- `SellerConfirmationRuntimeTest` (opt-in, live MySQL, rolled back): the real seller order page does not contain the customer's phone or email.

## Deploy

No migration and no setting change are required. After deploying, run `php artisan view:clear` (new partials). The new string "Customer" uses the existing translation.

Mobile app: the seller app now receives empty strings for the customer's address fields. If the app shows an address card, it will show it empty; the app can hide it.
