text:
Script completed
Wall time 0.0 seconds
Output:

text: {"previous_status":{"completed":"No critical exploit was confirmed, but these major issues remain:\n\n- **Major — Required REST API surface is incomplete.** `routes/api.php:32-67` lacks the specified platform organization/store creation, staff invites, membership (`/me`), product media presigning, and OAuth endpoints from `specs/02-API-ROUTES.md`.\n\n- **Major — Product API silently drops documented data.** `StoreProductRequest` only validates `variants` as a generic array, while `ProductService::create()` does not persist options, option-value links, nested inventory payloads, or collection assignments. `UpdateProductRequest` cannot update variants/options. See `app/Http/Requests/StoreProductRequest.php`, `app/Http/Requests/UpdateProductRequest.php`, and `app/Services/ProductService.php`.\n\n- **Major — Media processing is a stub.** `app/Jobs/ProcessMediaUpload.php` only marks media as `ready`; it does not store/process files, create resized variants, populate metadata, or perform the specified retry/failure behavior.\n\n- **Major — Webhook creation fails against the current schema.** `app/Livewire/Admin/Developers/Index.php:25-30` creates only `event`, while the migrated `webhook_subscriptions` contract requires non-null `event_type`; creating a webhook will therefore fail at runtime.\n\n- **Major — Domain settings UI is unreachable.** `app/Livewire/Admin/Settings/Domains.php` exists, but `routes/web.php` exposes only `/admin/settings`, `/admin/settings/shipping`, and `/admin/settings/taxes`; there is no route or visible integration for the specified domain settings page.\n\nThese are not covered by the passing feature tests."}}


type: message

role: user

text:
<subagent_notification>
{"agent_path":"01a0219c-6293-7b40-b5e1-7376c4c0fe0d","status":{"completed":"No critical exploit was confirmed, but these major issues remain:\n\n- **Major — Required REST API surface is incomplete.** `routes/api.php:32-67` lacks the specified platform organization/store creation, staff invites, membership (`/me`), product media presigning, and OAuth endpoints from `specs/02-API-ROUTES.md`.\n\n- **Major — Product API silently drops documented data.** `StoreProductRequest` only validates `variants` as a generic array, while `ProductService::create()` does not persist options, option-value links, nested inventory payloads, or collection assignments. `UpdateProductRequest` cannot update variants/options. See `app/Http/Requests/StoreProductRequest.php`, `app/Http/Requests/UpdateProductRequest.php`, and `app/Services/ProductService.php`.\n\n- **Major — Media processing is a stub.** `app/Jobs/ProcessMediaUpload.php` only marks media as `ready`; it does not store/process files, create resized variants, populate metadata, or perform the specified retry/failure behavior.\n\n- **Major — Webhook creation fails against the current schema.** `app/Livewire/Admin/Developers/Index.php:25-30` creates only `event`, while the migrated `webhook_subscriptions` contract requires non-null `event_type`; creating a webhook will therefore fail at runtime.\n\n- **Major — Domain settings UI is unreachable.** `app/Livewire/Admin/Settings/Domains.php` exists, but `routes/web.php` exposes only `/admin/settings`, `/admin/settings/shipping`, and `/admin/settings/taxes`; there is no route or visible integration for the specified domain settings page.\n\nThese are not covered by the passing feature tests."}}
</subagent_notification>


type: AgentMessage

type: Text

text: The independent audit found five real gaps, so I’m not committing yet. I’ll close these now: add the missing platform/admin API contracts, make product writes persist nested catalog data, implement media-job lifecycle behavior, fix webhook creation against the migrated schema, and expose the domain settings UI. Then I’ll rerun the full verification and audit again.

phase: commentary


role: assistant


exec

status: completed

input:
const r = await tools.exec_command({
  cmd: "rg -n \"organization|stores|staff|members|/me|presign|media|OAuth|oauth|webhook|domains|domain\" specs/02-API-ROUTES.md specs/03-ADMIN-UI.md specs/05-BUSINESS-LOGIC.md | head -320 && sed -n '1,260p' app/Services/ProductService.php && sed -n '1,240p' app/Http/Requests/StoreProductRequest.php && sed -n '1,220p' app/Http/Requests/UpdateProductRequest.php && sed -n '1,260p' app/Livewire/Admin/Developers/Index.php && sed -n '1,260p' app/Livewire/Admin/Settings/Domains.php && sed -n '1,220p' app/Jobs/ProcessMediaUpload.php",
  workdir: "/Users/wesner/Herd/shop",
  yield_time_ms: 10000,
  max_output_tokens: 50000
});
text(r.output);


type: CommandExecution

command: rg -n "organization|stores|staff|members|/me|presign|media|OAuth|oauth|webhook|domains|domain" specs/02-API-ROUTES.md specs/03-ADMIN-UI.md specs/05-BUSINESS-LOGIC.md | head -320 && sed -n '1,260p' app/Services/ProductService.php && sed -n '1,240p' app/Http/Requests/StoreProductRequest.php && sed -n '1,220p' app/Http/Requests/UpdateProductRequest.php && sed -n '1,260p' app/Livewire/Admin/Developers/Index.php && sed -n '1,260p' app/Livewire/Admin/Settings/Domains.php && sed -n '1,220p' app/Jobs/ProcessMediaUpload.php

type: search

cmd: rg -n 'organization|stores|staff|members|/me|presign|media|OAuth|oauth|webhook|domains|domain' specs/02-API-ROUTES.md specs/03-ADMIN-UI.md specs/05-BUSINESS-LOGIC.md

query: organization|stores|staff|members|/me|presign|media|OAuth|oauth|webhook|domains|domain

ProductService.php

type: read

ProcessMediaUpload.php

status: completed

stdout:
specs/05-BUSINESS-LOGIC.md:3:> E-commerce platform - All domain logic, state machines, calculations, and rules for Laravel 12
specs/05-BUSINESS-LOGIC.md:16:| Currency | Single currency per store (`stores.default_currency`). No multi-currency conversion. |
specs/05-BUSINESS-LOGIC.md:31:2. Look up `store_domains` table where `hostname` matches.
specs/05-BUSINESS-LOGIC.md:32:3. Cache the hostname-to-store_id mapping using file cache with a 5-minute TTL. Cache key pattern: `store_domain:{hostname}`.
specs/05-BUSINESS-LOGIC.md:34:5. If hostname is not found in `store_domains`: return HTTP 404.
specs/05-BUSINESS-LOGIC.md:49:    D -->|No| F[Query store_domains table]
specs/05-BUSINESS-LOGIC.md:80:products, collections, customers, orders, carts, checkouts, discounts, shipping_zones, themes, pages, navigation_menus, analytics_events, analytics_daily, webhook_subscriptions, inventory_items, search_queries.
specs/05-BUSINESS-LOGIC.md:84:**Enum:** `App\Enums\StoreUserRole` - backed by string values: `owner`, `admin`, `staff`, `support`
specs/05-BUSINESS-LOGIC.md:91:| Manage staff | Yes | Yes | No | No |
specs/05-BUSINESS-LOGIC.md:489:**Method:** `selectPaymentMethod(checkout, paymentMethod)` - validates and stores the selected payment method
specs/05-BUSINESS-LOGIC.md:636:| `percent` | `discount = applicable_subtotal * value_amount / 100` | `value_amount` stores whole percentage (e.g., 15 = 15%) |
specs/05-BUSINESS-LOGIC.md:933:The platform uses a **Mock Payment Service Provider (PSP)** instead of real payment gateways. All payment processing happens in-process with no external API calls, no webhooks, and no redirects. This enables a fully functional checkout flow for development, testing, and demonstration purposes.
specs/05-BUSINESS-LOGIC.md:951:| `credit_card` | Simulates card authorization and immediate capture. Uses magic card numbers for test scenarios. | Auto-capture on checkout (instant) |
specs/05-BUSINESS-LOGIC.md:952:| `paypal` | Simulates PayPal approval and immediate capture. Always succeeds (no magic numbers). | Auto-capture on checkout (instant) |
specs/05-BUSINESS-LOGIC.md:1275:3. If the order contains a mix of physical and digital products: do **not** auto-fulfill. The admin must create fulfillments manually (they can fulfill digital items immediately and physical items when shipped).
specs/05-BUSINESS-LOGIC.md:1277:This ensures digital product orders are immediately marked as complete after payment.
specs/05-BUSINESS-LOGIC.md:1317:**Store isolation:** Customer accounts are per-store. The same email address can exist in different stores as separate accounts. Authentication always scopes the query to the current store.
specs/05-BUSINESS-LOGIC.md:1349:Stores (or app installations) subscribe to event types via `webhook_subscriptions`.
specs/05-BUSINESS-LOGIC.md:1366:**Listener:** `App\Listeners\DispatchWebhooks` - registered to listen for all domain events that have webhook counterparts.
specs/05-BUSINESS-LOGIC.md:1379:    Listener->>Listener: Query webhook_subscriptions for store_id + event_type where status = active
specs/05-BUSINESS-LOGIC.md:1388:    Job->>Job: Record result in webhook_deliveries
specs/05-BUSINESS-LOGIC.md:1407:| 1 (initial) | Immediate |
specs/05-BUSINESS-LOGIC.md:1421:- If a subscription's endpoint fails 5 consecutive times: automatically set `webhook_subscriptions.status = 'paused'`.
specs/05-BUSINESS-LOGIC.md:1496:    A[Admin uploads file via Livewire] --> B[Store original in storage/app/public/media/originals/]
specs/05-BUSINESS-LOGIC.md:1497:    B --> C[Create product_media record with status = processing]
specs/05-BUSINESS-LOGIC.md:1520:- Store all sizes with predictable path keys: `media/{product_id}/{media_id}/{size}.{ext}`
specs/05-BUSINESS-LOGIC.md:1521:- Update the `product_media` record with: `width`, `height`, `mime_type`, `byte_size` from the original, and `status = 'ready'`.
specs/05-BUSINESS-LOGIC.md:1527:When a `product_media` record is deleted, a model event listener removes all associated files from storage (all sizes and the original).
specs/05-BUSINESS-LOGIC.md:1590:Summary of all domain events and their listeners/side effects:
specs/05-BUSINESS-LOGIC.md:1594:| `OrderCreated` | Send confirmation email, dispatch webhooks (`order.created`), log analytics event |
specs/05-BUSINESS-LOGIC.md:1595:| `OrderRefunded` | Send refund notification email, dispatch webhooks (`order.refunded`), optionally restock |
specs/05-BUSINESS-LOGIC.md:1598:| `CheckoutCompleted` | Dispatch webhooks (`checkout.completed`) |
specs/05-BUSINESS-LOGIC.md:1601:| `ProductCreated` | Sync FTS5 index, dispatch webhooks (`product.created`) |
specs/05-BUSINESS-LOGIC.md:1602:| `ProductUpdated` | Sync FTS5 index, dispatch webhooks (`product.updated`) |
specs/05-BUSINESS-LOGIC.md:1603:| `ProductDeleted` | Remove from FTS5 index, dispatch webhooks (`product.deleted`) |
specs/05-BUSINESS-LOGIC.md:1606:| `FulfillmentShipped` | Send shipping notification email with tracking info, dispatch webhooks (`order.fulfilled` if fully fulfilled) |
specs/05-BUSINESS-LOGIC.md:1607:| `FulfillmentDelivered` | Update fulfillment record, dispatch webhooks |
specs/05-BUSINESS-LOGIC.md:1628:| `App\Jobs\DeliverWebhook` | POST webhook payload to subscriber endpoint |
specs/05-BUSINESS-LOGIC.md:1675:| StoreUserRole | Owner (`owner`), Admin (`admin`), Staff (`staff`), Support (`support`) |
specs/03-ADMIN-UI.md:127:| `stores` | `Collection` | List of stores the user has access to |
specs/03-ADMIN-UI.md:155:- When user clicks the store name, a dropdown appears listing all stores. Clicking a store calls `switchStore()` which updates the session and redirects to the dashboard.
specs/03-ADMIN-UI.md:371:| `products` | Paginated query result with search, filters, sorting applied. Eager loads `variants`, `media` (limit 1). Uses `withCount('variants')`. |
specs/03-ADMIN-UI.md:401:| Image | No | 40x40 rounded thumbnail of first media item, or placeholder icon |
specs/03-ADMIN-UI.md:488:| `media` | `array` | Existing media items: `[{id, url, alt_text, position}]` |
specs/03-ADMIN-UI.md:494:| `save()` | Validates and saves/creates the product, variants, options, and media |
specs/03-ADMIN-UI.md:501:| `removeMedia(int $mediaId)` | Removes a media item |
specs/03-ADMIN-UI.md:502:| `reorderMedia(array $order)` | Updates media positions |
specs/03-ADMIN-UI.md:503:| `updateMediaAlt(int $mediaId, string $alt)` | Updates alt text for a media item |
specs/03-ADMIN-UI.md:693:| Loading (upload) | Progress bar shown in media section |
specs/03-ADMIN-UI.md:1427:**Blade view:** `resources/views/livewire/admin/settings/domains.blade.php`
specs/03-ADMIN-UI.md:1433:| `domains` | `Collection` | Store domains |
specs/03-ADMIN-UI.md:1434:| `newHostname` | `string` | Hostname for new domain |
specs/03-ADMIN-UI.md:1435:| `newType` | `string` | Type for new domain: `storefront`, `admin`, `api` |
specs/03-ADMIN-UI.md:1439:| `addDomain()` | Creates a new domain record |
specs/03-ADMIN-UI.md:1440:| `removeDomain(int $domainId)` | Removes a domain |
specs/03-ADMIN-UI.md:1441:| `setPrimary(int $domainId)` | Sets domain as primary |
specs/03-ADMIN-UI.md:1446:- Table of existing domains:
specs/03-ADMIN-UI.md:1456:- "Add domain" button opens a modal
specs/03-ADMIN-UI.md:1462:| Modal name | `add-domain` |
specs/03-ADMIN-UI.md:1464:| Heading | "Add domain" |
specs/03-ADMIN-UI.md:1469:- Cancel button (ghost) and "Add domain" button (primary)
specs/03-ADMIN-UI.md:2022:| `webhooks` | `Collection` | Webhook subscriptions |
specs/03-ADMIN-UI.md:2025:| `webhookEventType` | `string` | Event type for webhook form |
specs/03-ADMIN-UI.md:2026:| `webhookUrl` | `string` | URL for webhook form |
specs/03-ADMIN-UI.md:2033:| `openWebhookModal(?WebhookSubscription $webhook)` | Opens webhook create/edit modal |
specs/03-ADMIN-UI.md:2034:| `saveWebhook()` | Saves webhook subscription |
specs/03-ADMIN-UI.md:2035:| `deleteWebhook(int $webhookId)` | Deletes a webhook |
specs/03-ADMIN-UI.md:2061:| Manage webhook subscriptions for real-time event notifications.  |
specs/03-ADMIN-UI.md:2068:| [+ Add webhook]                                                   |
specs/03-ADMIN-UI.md:2105:- "Add webhook" button opens modal
specs/03-ADMIN-UI.md:2111:| Modal name | `webhook-form` |
specs/03-ADMIN-UI.md:2113:| Heading | "Edit webhook" or "Add webhook" (dynamic) |
specs/03-ADMIN-UI.md:2117:- Endpoint URL input (type: url, placeholder: "https://example.com/webhooks")
specs/03-ADMIN-UI.md:2334:- Intermediate items: parent section (e.g., "Products" linking to products index)
specs/03-ADMIN-UI.md:2417:| `App\Livewire\Admin\Settings\Domains` | `livewire.admin.settings.domains` | Tab within `GET /admin/settings` |
specs/02-API-ROUTES.md:50:| GET | `/admin/products/{product}/edit` | Edit product form with variants, media, options | `Admin\Products\Edit` |
specs/02-API-ROUTES.md:122:| GET | `/admin/apps/{installation}` | Installed app detail - scopes, webhooks, usage | `Admin\Apps\Show` |
specs/02-API-ROUTES.md:128:| GET | `/admin/developers` | Developer settings - API keys/tokens, webhook management | `Admin\Developers\Index` |
specs/02-API-ROUTES.md:130:> **Note:** Webhook subscription management (CRUD) is available exclusively through the admin UI at `/admin/developers`. There are no REST API endpoints for programmatic webhook subscription management in the initial implementation.
specs/02-API-ROUTES.md:850:Process payment for a checkout using the selected payment method. All payment processing happens in-process via the Mock PSP - no external API calls, no redirects, no webhooks.
specs/02-API-ROUTES.md:1222:#### POST /api/admin/v1/platform/organizations
specs/02-API-ROUTES.md:1224:Create a new organization.
specs/02-API-ROUTES.md:1263:#### POST /api/admin/v1/platform/stores
specs/02-API-ROUTES.md:1265:Create a new store within an organization.
specs/02-API-ROUTES.md:1274:  "organization_id": 1,
specs/02-API-ROUTES.md:1285:| `organization_id` | integer | Yes | Must exist |
specs/02-API-ROUTES.md:1298:    "organization_id": 1,
specs/02-API-ROUTES.md:1316:#### POST /api/admin/v1/stores/{storeId}/invites
specs/02-API-ROUTES.md:1327:  "email": "staff@acme.com",
specs/02-API-ROUTES.md:1328:  "role": "staff"
specs/02-API-ROUTES.md:1335:| `role` | string | Yes | One of: `owner`, `admin`, `staff`, `support` |
specs/02-API-ROUTES.md:1342:    "email": "staff@acme.com",
specs/02-API-ROUTES.md:1343:    "role": "staff",
specs/02-API-ROUTES.md:1357:#### GET /api/admin/v1/stores/{storeId}/me
specs/02-API-ROUTES.md:1359:Get the current user's membership details for a store.
specs/02-API-ROUTES.md:1399:| GET | `/api/admin/v1/stores/{storeId}/products` | `read-products` | List products with filtering and pagination | `api.admin` (60/min) |
specs/02-API-ROUTES.md:1400:| POST | `/api/admin/v1/stores/{storeId}/products` | `write-products` | Create a new product | `api.admin` (60/min) |
specs/02-API-ROUTES.md:1401:| GET | `/api/admin/v1/stores/{storeId}/products/{productId}` | `read-products` | Get single product with all relations | `api.admin` (60/min) |
specs/02-API-ROUTES.md:1402:| PUT | `/api/admin/v1/stores/{storeId}/products/{productId}` | `write-products` | Update a product (partial updates supported) | `api.admin` (60/min) |
specs/02-API-ROUTES.md:1403:| DELETE | `/api/admin/v1/stores/{storeId}/products/{productId}` | `write-products` | Archive a product (soft delete) | `api.admin` (60/min) |
specs/02-API-ROUTES.md:1404:| POST | `/api/admin/v1/stores/{storeId}/products/{productId}/media/presign-upload` | `write-products` | Generate presigned URL for media upload | `api.admin` (60/min) |
specs/02-API-ROUTES.md:1406:#### GET /api/admin/v1/stores/{storeId}/products
specs/02-API-ROUTES.md:1416:| `collection_id` | integer | No | Filter by collection membership. |
specs/02-API-ROUTES.md:1457:#### POST /api/admin/v1/stores/{storeId}/products
specs/02-API-ROUTES.md:1539:Returns the full product resource with nested variants, options, and media.
specs/02-API-ROUTES.md:1547:#### GET /api/admin/v1/stores/{storeId}/products/{productId}
specs/02-API-ROUTES.md:1549:Get a single product with all variants, options, media, and collections.
specs/02-API-ROUTES.md:1613:    "media": [
specs/02-API-ROUTES.md:1617:        "storage_key": "stores/1/products/10/main.jpg",
specs/02-API-ROUTES.md:1618:        "url": "https://cdn.example.com/stores/1/products/10/main.jpg",
specs/02-API-ROUTES.md:1645:#### PUT /api/admin/v1/stores/{storeId}/products/{productId}
specs/02-API-ROUTES.md:1672:#### DELETE /api/admin/v1/stores/{storeId}/products/{productId}
specs/02-API-ROUTES.md:1694:#### POST /api/admin/v1/stores/{storeId}/products/{productId}/media/presign-upload
specs/02-API-ROUTES.md:1696:Generate a presigned URL for uploading product media directly to object storage (S3).
specs/02-API-ROUTES.md:1718:  "upload_url": "https://s3.example.com/...presigned...",
specs/02-API-ROUTES.md:1723:  "storage_key": "stores/1/products/10/media/uuid.jpg",
specs/02-API-ROUTES.md:1724:  "media_id": 2,
specs/02-API-ROUTES.md:1742:| GET | `/api/admin/v1/stores/{storeId}/collections` | `read-collections` | List collections | `api.admin` (60/min) |
specs/02-API-ROUTES.md:1743:| POST | `/api/admin/v1/stores/{storeId}/collections` | `write-collections` | Create a collection | `api.admin` (60/min) |
specs/02-API-ROUTES.md:1744:| PUT | `/api/admin/v1/stores/{storeId}/collections/{collectionId}` | `write-collections` | Update a collection | `api.admin` (60/min) |
specs/02-API-ROUTES.md:1745:| DELETE | `/api/admin/v1/stores/{storeId}/collections/{collectionId}` | `write-collections` | Delete a collection | `api.admin` (60/min) |
specs/02-API-ROUTES.md:1747:#### GET /api/admin/v1/stores/{storeId}/collections
specs/02-API-ROUTES.md:1789:#### POST /api/admin/v1/stores/{storeId}/collections
specs/02-API-ROUTES.md:1825:#### PUT /api/admin/v1/stores/{storeId}/collections/{collectionId}
specs/02-API-ROUTES.md:1840:#### DELETE /api/admin/v1/stores/{storeId}/collections/{collectionId}
specs/02-API-ROUTES.md:1866:| GET | `/api/admin/v1/stores/{storeId}/orders` | `read-orders` | List orders with filtering and pagination | `api.admin` (60/min) |
specs/02-API-ROUTES.md:1867:| GET | `/api/admin/v1/stores/{storeId}/orders/{orderId}` | `read-orders` | Get full order details | `api.admin` (60/min) |
specs/02-API-ROUTES.md:1868:| POST | `/api/admin/v1/stores/{storeId}/orders/{orderId}/fulfillments` | `write-orders` | Create a fulfillment (ship items) | `api.admin` (60/min) |
specs/02-API-ROUTES.md:1869:| POST | `/api/admin/v1/stores/{storeId}/orders/{orderId}/refunds` | `write-orders` | Create a refund (partial or full) | `api.admin` (60/min) |
specs/02-API-ROUTES.md:1871:#### GET /api/admin/v1/stores/{storeId}/orders
specs/02-API-ROUTES.md:1928:#### GET /api/admin/v1/stores/{storeId}/orders/{orderId}
specs/02-API-ROUTES.md:2016:#### POST /api/admin/v1/stores/{storeId}/orders/{orderId}/fulfillments
specs/02-API-ROUTES.md:2077:#### POST /api/admin/v1/stores/{storeId}/orders/{orderId}/refunds
specs/02-API-ROUTES.md:2138:| GET | `/api/admin/v1/stores/{storeId}/discounts` | `read-discounts` | List discounts | `api.admin` (60/min) |
specs/02-API-ROUTES.md:2139:| POST | `/api/admin/v1/stores/{storeId}/discounts` | `write-discounts` | Create a discount | `api.admin` (60/min) |
specs/02-API-ROUTES.md:2140:| PUT | `/api/admin/v1/stores/{storeId}/discounts/{discountId}` | `write-discounts` | Update a discount | `api.admin` (60/min) |
specs/02-API-ROUTES.md:2141:| DELETE | `/api/admin/v1/stores/{storeId}/discounts/{discountId}` | `write-discounts` | Delete a discount | `api.admin` (60/min) |
specs/02-API-ROUTES.md:2143:#### GET /api/admin/v1/stores/{storeId}/discounts
specs/02-API-ROUTES.md:2192:#### POST /api/admin/v1/stores/{storeId}/discounts
specs/02-API-ROUTES.md:2243:#### PUT /api/admin/v1/stores/{storeId}/discounts/{discountId}
specs/02-API-ROUTES.md:2258:#### DELETE /api/admin/v1/stores/{storeId}/discounts/{discountId}
specs/02-API-ROUTES.md:2284:| GET | `/api/admin/v1/stores/{storeId}/shipping/zones` | `read-settings` | List shipping zones with rates | `api.admin` (60/min) |
specs/02-API-ROUTES.md:2285:| POST | `/api/admin/v1/stores/{storeId}/shipping/zones` | `write-settings` | Create a shipping zone | `api.admin` (60/min) |
specs/02-API-ROUTES.md:2286:| PUT | `/api/admin/v1/stores/{storeId}/shipping/zones/{zoneId}` | `write-settings` | Update a shipping zone | `api.admin` (60/min) |
specs/02-API-ROUTES.md:2287:| POST | `/api/admin/v1/stores/{storeId}/shipping/zones/{zoneId}/rates` | `write-settings` | Add a shipping rate to a zone | `api.admin` (60/min) |
specs/02-API-ROUTES.md:2289:#### GET /api/admin/v1/stores/{storeId}/shipping/zones
specs/02-API-ROUTES.md:2337:#### POST /api/admin/v1/stores/{storeId}/shipping/zones
specs/02-API-ROUTES.md:2365:#### PUT /api/admin/v1/stores/{storeId}/shipping/zones/{zoneId}
specs/02-API-ROUTES.md:2380:#### POST /api/admin/v1/stores/{storeId}/shipping/zones/{zoneId}/rates
specs/02-API-ROUTES.md:2420:| GET | `/api/admin/v1/stores/{storeId}/tax/settings` | `read-settings` | Get tax configuration | `api.admin` (60/min) |
specs/02-API-ROUTES.md:2421:| PUT | `/api/admin/v1/stores/{storeId}/tax/settings` | `write-settings` | Update tax configuration | `api.admin` (60/min) |
specs/02-API-ROUTES.md:2423:#### GET /api/admin/v1/stores/{storeId}/tax/settings
specs/02-API-ROUTES.md:2460:#### PUT /api/admin/v1/stores/{storeId}/tax/settings
specs/02-API-ROUTES.md:2498:| POST | `/api/admin/v1/stores/{storeId}/themes` | `write-themes` | Upload or install a new theme | `api.admin` (60/min) |
specs/02-API-ROUTES.md:2499:| POST | `/api/admin/v1/stores/{storeId}/themes/{themeId}/publish` | `write-themes` | Publish a theme (make active) | `api.admin` (60/min) |
specs/02-API-ROUTES.md:2500:| PUT | `/api/admin/v1/stores/{storeId}/themes/{themeId}/settings` | `write-themes` | Update theme customization settings | `api.admin` (60/min) |
specs/02-API-ROUTES.md:2502:#### POST /api/admin/v1/stores/{storeId}/themes
specs/02-API-ROUTES.md:2535:#### POST /api/admin/v1/stores/{storeId}/themes/{themeId}/publish
specs/02-API-ROUTES.md:2558:#### PUT /api/admin/v1/stores/{storeId}/themes/{themeId}/settings
specs/02-API-ROUTES.md:2604:| GET | `/api/admin/v1/stores/{storeId}/pages` | `read-content` | List content pages | `api.admin` (60/min) |
specs/02-API-ROUTES.md:2605:| POST | `/api/admin/v1/stores/{storeId}/pages` | `write-content` | Create a content page | `api.admin` (60/min) |
specs/02-API-ROUTES.md:2606:| PUT | `/api/admin/v1/stores/{storeId}/pages/{pageId}` | `write-content` | Update a content page | `api.admin` (60/min) |
specs/02-API-ROUTES.md:2607:| DELETE | `/api/admin/v1/stores/{storeId}/pages/{pageId}` | `write-content` | Delete a content page | `api.admin` (60/min) |
specs/02-API-ROUTES.md:2609:#### GET /api/admin/v1/stores/{storeId}/pages
specs/02-API-ROUTES.md:2648:#### POST /api/admin/v1/stores/{storeId}/pages
specs/02-API-ROUTES.md:2678:#### PUT /api/admin/v1/stores/{storeId}/pages/{pageId}
specs/02-API-ROUTES.md:2693:#### DELETE /api/admin/v1/stores/{storeId}/pages/{pageId}
specs/02-API-ROUTES.md:2719:| POST | `/api/admin/v1/stores/{storeId}/search/reindex` | `write-settings` | Trigger full search reindex (async) | `api.admin` (60/min) |
specs/02-API-ROUTES.md:2720:| GET | `/api/admin/v1/stores/{storeId}/search/status` | `read-settings` | Get current search index status | `api.admin` (60/min) |
specs/02-API-ROUTES.md:2722:#### POST /api/admin/v1/stores/{storeId}/search/reindex
specs/02-API-ROUTES.md:2743:#### GET /api/admin/v1/stores/{storeId}/search/status
specs/02-API-ROUTES.md:2770:| GET | `/api/admin/v1/stores/{storeId}/analytics/summary` | `read-analytics` | Get analytics summary for date range | `api.admin` (60/min) |
specs/02-API-ROUTES.md:2771:| POST | `/api/admin/v1/stores/{storeId}/exports/orders` | `read-orders` | Queue an order export job (async) | `api.admin` (60/min) |
specs/02-API-ROUTES.md:2772:| GET | `/api/admin/v1/stores/{storeId}/exports/{exportId}` | `read-orders` | Check export status and get download URL | `api.admin` (60/min) |
specs/02-API-ROUTES.md:2774:#### GET /api/admin/v1/stores/{storeId}/analytics/summary
specs/02-API-ROUTES.md:2834:#### POST /api/admin/v1/stores/{storeId}/exports/orders
specs/02-API-ROUTES.md:2868:#### GET /api/admin/v1/stores/{storeId}/exports/{exportId}
specs/02-API-ROUTES.md:2881:    "download_url": "https://s3.example.com/...presigned...",
specs/02-API-ROUTES.md:2899:> **Note:** No inbound webhook endpoints are needed. All payment processing happens in-process via the Mock PSP -- there are no external payment provider callbacks. The webhook infrastructure defined in this section is reserved for **outbound** app webhooks (see Section 5, OAuth / App Ecosystem).
specs/02-API-ROUTES.md:2903:## 5. OAuth Endpoints (App Ecosystem)
specs/02-API-ROUTES.md:2905:> **Scope note:** OAuth and app ecosystem endpoints are deferred for initial implementation. The route structure below is defined for future use but implementations should return 501 Not Implemented.
specs/02-API-ROUTES.md:2907:These endpoints implement OAuth2 authorization for third-party apps, powered by Laravel Passport.
specs/02-API-ROUTES.md:2911:### GET /oauth/authorize
specs/02-API-ROUTES.md:2921:| `client_id` | string | Yes | The app's OAuth client ID |
specs/02-API-ROUTES.md:2931:### POST /oauth/token
specs/02-API-ROUTES.md:2979:Third-party apps use OAuth tokens to access store data via scoped endpoints.
specs/02-API-ROUTES.md:2983:| GET | `/api/apps/v1/stores/{storeId}/products` | `read-products` | List products (same schema as admin API) |
specs/02-API-ROUTES.md:2984:| GET | `/api/apps/v1/stores/{storeId}/orders` | `read-orders` | List orders |
specs/02-API-ROUTES.md:2985:| GET | `/api/apps/v1/stores/{storeId}/customers` | `read-customers` | List customers |
specs/02-API-ROUTES.md:3016:| `store.resolve` | Resolve current store from session/domain and set `app.store_id` |
specs/02-API-ROUTES.md:3025:| `store.resolve` | Resolve store from request hostname via `store_domains` table |
specs/02-API-ROUTES.md:3054:- **Web (admin):** Reads `store_id` from session. If missing, redirects to store selector (for platform-level users with access to multiple stores).
specs/02-API-ROUTES.md:3055:- **Web (storefront):** Looks up hostname in `store_domains` table (cached via file cache). Hostname must match a domain with type `storefront`.
specs/02-API-ROUTES.md:3057:- **API (admin):** Reads `storeId` from route parameter and verifies the authenticated user has membership.
specs/02-API-ROUTES.md:3064:- `503` if store status is `suspended` on storefront (shows maintenance page); `403` on admin mutation endpoints for suspended stores.
specs/02-API-ROUTES.md:3068:Checks that the authenticated user has the required role for the current store. Roles hierarchy: `owner` > `admin` > `staff` > `support`. The `support` role grants read-only access for customer service purposes. Specific route permission requirements are configured in the route definition or controller.
specs/02-API-ROUTES.md:3086:| `webhooks` | 100 requests | 1 minute | IP address | Reserved for future outbound app webhook endpoints |
specs/02-API-ROUTES.md:3130:| *(no inbound webhook endpoints - mock PSP)* | - |
specs/02-API-ROUTES.md:3174:- **Outbound webhook payloads:** Include a `api_version` field in outbound webhook deliveries to app installations. Each app installation pins to a version.
<?php

namespace App\Services;

use App\Enums\ProductStatus;
use App\Events\ProductStatusChanged;
use App\Exceptions\InvalidProductTransitionException;
use App\Models\InventoryItem;
use App\Models\Product;
use App\Models\Store;
use App\Support\HandleGenerator;
use App\Support\HtmlSanitizer;
use Illuminate\Support\Facades\DB;
use LogicException;

class ProductService
{
    public function __construct(private readonly HandleGenerator $handles, private readonly HtmlSanitizer $sanitizer, private readonly AuditLogger $audit) {}

    public function create(Store $store, array $data): Product
    {
        return DB::transaction(function () use ($store, $data): Product {
            $status = $data['status'] ?? ProductStatus::Draft;
            $status = $status instanceof ProductStatus ? $status : ProductStatus::from($status);
            $product = Product::withoutGlobalScopes()->create([
                'store_id' => $store->getKey(),
                'title' => $data['title'],
                'handle' => $data['handle'] ?? $this->handles->generate($data['title'], 'products', $store->getKey()),
                'description' => $this->sanitizer->sanitize($data['description'] ?? null),
                'description_html' => $this->sanitizer->sanitize($data['description_html'] ?? $data['description'] ?? null),
                'vendor' => $data['vendor'] ?? null,
                'product_type' => $data['product_type'] ?? null,
                'tags' => $data['tags'] ?? [],
                'status' => $status,
                'published_at' => $status === ProductStatus::Active ? now() : null,
            ]);

            foreach ($data['variants'] ?? [['title' => 'Default', 'price_amount' => 0, 'is_default' => true]] as $position => $variant) {
                $createdVariant = $product->variants()->create(array_merge($variant, ['position' => $position, 'is_default' => $variant['is_default'] ?? $position === 0]));
                InventoryItem::withoutGlobalScopes()->create([
                    'store_id' => $store->getKey(),
                    'variant_id' => $createdVariant->getKey(),
                    'quantity_on_hand' => (int) ($variant['quantity_on_hand'] ?? 0),
                    'policy' => $variant['policy'] ?? 'deny',
                ]);
            }

            if ($status === ProductStatus::Active && (trim((string) $product->title) === '' || ! $product->variants()->where('price_amount', '>', 0)->exists())) {
                throw new InvalidProductTransitionException('An active product requires a title and a priced variant.');
            }

            $this->audit->record('product.created', $product, ['store_id' => $store->getKey()]);

            return $product->load('variants');
        });
    }

    public function update(Product $product, array $data): Product
    {
        $updates = array_intersect_key($data, array_flip(['title', 'description', 'description_html', 'vendor', 'product_type', 'tags', 'status', 'published_at']));

        $newStatus = null;

        if (array_key_exists('status', $updates)) {
            $newStatus = $updates['status'] instanceof ProductStatus ? $updates['status'] : ProductStatus::from($updates['status']);
            unset($updates['status']);
        }

        if (array_key_exists('description', $data)) {
            $updates['description'] = $this->sanitizer->sanitize($data['description']);
            $updates['description_html'] = $this->sanitizer->sanitize($data['description']);
        } elseif (array_key_exists('description_html', $data)) {
            $updates['description_html'] = $this->sanitizer->sanitize($data['description_html']);
        }

        $product->update($updates);

        if ($newStatus !== null) {
            $this->transitionStatus($product->refresh(), $newStatus);
        }

        $product = $product->refresh()->load('variants');
        $this->audit->record('product.updated', $product, ['store_id' => $product->store_id]);

        return $product;
    }

    public function transitionStatus(Product $product, ProductStatus $newStatus): void
    {
        $from = $product->status instanceof ProductStatus ? $product->status : ProductStatus::from($product->status);

        if ($from === $newStatus) {
            return;
        }

        if ($newStatus === ProductStatus::Active && (! $product->variants()->where('price_amount', '>', 0)->exists() || trim((string) $product->title) === '')) {
            throw new InvalidProductTransitionException('An active product requires a title and a priced variant.');
        }

        if ($newStatus === ProductStatus::Draft && in_array($from, [ProductStatus::Active, ProductStatus::Archived], true) && $product->orders()->exists()) {
            throw new InvalidProductTransitionException('Products with order history cannot be reverted to draft.');
        }

        $product->update(['status' => $newStatus, 'published_at' => $newStatus === ProductStatus::Active ? ($product->published_at ?? now()) : null]);
        ProductStatusChanged::dispatch($product->refresh(), $from, $newStatus);
        $this->audit->record('product.updated', $product, ['from_status' => $from->value, 'to_status' => $newStatus->value, 'store_id' => $product->store_id]);
    }

    public function delete(Product $product): void
    {
        if ($product->status !== ProductStatus::Draft || $product->orders()->exists()) {
            throw new LogicException('Only draft products with no order history can be deleted.');
        }

        $product->delete();
        $this->audit->record('product.deleted', $product, ['store_id' => $product->store_id]);
    }
}
<?php

namespace App\Http\Requests;

use App\Models\Product;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Support\Facades\Gate;

class StoreProductRequest extends FormRequest
{
    /**
     * Determine if the user is authorized to make this request.
     */
    public function authorize(): bool
    {
        return Gate::allows('create', Product::class);
    }

    /**
     * Get the validation rules that apply to the request.
     *
     * @return array<string, \Illuminate\Contracts\Validation\ValidationRule|array<mixed>|string>
     */
    public function rules(): array
    {
        return [
            'title' => ['required', 'string', 'max:255'],
            'handle' => ['nullable', 'string', 'max:255'],
            'description_html' => ['nullable', 'string'],
            'description' => ['nullable', 'string'],
            'status' => ['required', 'in:active,draft,archived'],
            'vendor' => ['nullable', 'string', 'max:255'],
            'product_type' => ['nullable', 'string', 'max:255'],
            'variants' => ['nullable', 'array'],
        ];
    }
}
<?php

namespace App\Http\Requests;

use App\Models\Product;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Support\Facades\Gate;

class UpdateProductRequest extends FormRequest
{
    /**
     * Determine if the user is authorized to make this request.
     */
    public function authorize(): bool
    {
        return Gate::allows('viewAny', Product::class);
    }

    /**
     * Get the validation rules that apply to the request.
     *
     * @return array<string, \Illuminate\Contracts\Validation\ValidationRule|array<mixed>|string>
     */
    public function rules(): array
    {
        return [
            'title' => ['sometimes', 'string', 'max:255'],
            'handle' => ['sometimes', 'string', 'max:255'],
            'description_html' => ['sometimes', 'nullable', 'string'],
            'description' => ['sometimes', 'nullable', 'string'],
            'status' => ['sometimes', 'in:active,draft,archived'],
            'vendor' => ['sometimes', 'nullable', 'string', 'max:255'],
            'product_type' => ['sometimes', 'nullable', 'string', 'max:255'],
        ];
    }
}
<?php

namespace App\Livewire\Admin\Developers;

use App\Models\WebhookSubscription;
use Carbon\CarbonImmutable;
use Illuminate\Contracts\View\View;
use Illuminate\Support\Str;
use Livewire\Component;

class Index extends Component
{
    public string $event = 'order.created';

    public string $targetUrl = '';

    public string $tokenName = '';

    public string $tokenExpiresAt = '';

    public string $tokenAbilities = 'read-products,read-orders';

    public ?string $plainTextToken = null;

    public function createWebhook(): void
    {
        abort_unless(auth()->user()?->canManageStore(app('current_store')), 403);
        $data = $this->validate(['event' => ['required', 'string', 'max:100'], 'targetUrl' => ['required', 'url', 'max:2000']]);
        WebhookSubscription::query()->create(['event' => $data['event'], 'target_url' => $data['targetUrl'], 'signing_secret_encrypted' => Str::random(48), 'status' => 'active']);
        $this->reset('targetUrl');
    }

    public function pause(int $subscriptionId): void
    {
        abort_unless(auth()->user()?->canManageStore(app('current_store')), 403);
        $subscription = WebhookSubscription::query()->findOrFail($subscriptionId);
        $subscription->update(['status' => $subscription->status === 'active' ? 'paused' : 'active']);
    }

    public function createToken(): void
    {
        abort_unless(auth()->user()?->canManageStore(app('current_store')), 403);
        $data = $this->validate([
            'tokenName' => ['required', 'string', 'max:100'],
            'tokenExpiresAt' => ['nullable', 'date', 'after:today'],
            'tokenAbilities' => ['required', 'string', 'max:1000'],
        ]);
        $allowed = ['read-products', 'write-products', 'read-orders', 'write-orders', 'read-customers', 'write-customers', 'read-collections', 'write-collections', 'read-discounts', 'write-discounts', 'read-analytics', 'read-settings', 'write-settings', 'read-themes', 'write-themes', 'read-content', 'write-content'];
        $abilities = array_values(array_intersect($allowed, array_filter(array_map('trim', explode(',', $data['tokenAbilities'])))));
        abort_if($abilities === [], 422, 'Select at least one token ability.');
        $expiresAt = empty($data['tokenExpiresAt']) ? null : CarbonImmutable::parse($data['tokenExpiresAt']);
        $this->plainTextToken = auth()->user()->createToken($data['tokenName'], $abilities, $expiresAt)->plainTextToken;
        $this->reset(['tokenName', 'tokenExpiresAt']);
    }

    public function revokeToken(int $tokenId): void
    {
        abort_unless(auth()->user()?->canManageStore(app('current_store')), 403);
        auth()->user()->tokens()->whereKey($tokenId)->delete();
    }

    public function render(): View
    {
        return view('livewire.admin.developers.index', ['subscriptions' => WebhookSubscription::query()->latest()->get(), 'tokens' => auth()->user()->tokens()->latest()->get()])->layout('layouts.admin');
    }
}
<?php

namespace App\Livewire\Admin\Settings;

use App\Models\StoreDomain;
use Illuminate\Support\Facades\DB;
use Livewire\Component;

class Domains extends Component
{
    public string $newHostname = '';

    public string $newType = 'storefront';

    public bool $showModal = false;

    public function mount(): void
    {
        $this->authorize('update', app('current_store'));
    }

    public function addDomain(): void
    {
        $this->authorize('update', app('current_store'));
        $data = $this->validate([
            'newHostname' => ['required', 'string', 'max:255', 'regex:/^(?=.{1,253}$)([a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?\.)+[a-z]{2,}$/i'],
            'newType' => ['required', 'in:storefront,admin,api'],
        ]);
        $store = app('current_store');
        $isPrimary = ! StoreDomain::query()->where('store_id', $store->getKey())->where('type', $data['newType'])->exists();
        StoreDomain::create([
            'store_id' => $store->getKey(),
            'hostname' => strtolower($data['newHostname']),
            'type' => $data['newType'],
            'is_primary' => $isPrimary,
            'tls_mode' => 'managed',
        ]);
        $this->reset(['newHostname', 'showModal']);
        $this->newType = 'storefront';
        $this->dispatch('toast', message: 'Domain added.');
    }

    public function removeDomain(int $domainId): void
    {
        $this->authorize('update', app('current_store'));
        $domain = StoreDomain::query()->where('store_id', app('current_store')->getKey())->findOrFail($domainId);
        $wasPrimary = $domain->is_primary;
        $type = $domain->type->value;
        $domain->delete();

        if ($wasPrimary) {
            StoreDomain::query()->where('store_id', app('current_store')->getKey())->where('type', $type)->latest('id')->first()?->update(['is_primary' => true]);
        }
        $this->dispatch('toast', message: 'Domain removed.');
    }

    public function setPrimary(int $domainId): void
    {
        $this->authorize('update', app('current_store'));
        $domain = StoreDomain::query()->where('store_id', app('current_store')->getKey())->findOrFail($domainId);
        DB::transaction(function () use ($domain): void {
            StoreDomain::query()->where('store_id', $domain->store_id)->where('type', $domain->type->value)->update(['is_primary' => false]);
            $domain->update(['is_primary' => true]);
        });
        $this->dispatch('toast', message: 'Primary domain updated.');
    }

    public function render(): mixed
    {
        $domains = StoreDomain::query()->where('store_id', app('current_store')->getKey())->orderByDesc('is_primary')->orderBy('hostname')->get();

        return view('livewire.admin.settings.domains', compact('domains'));
    }
}
<?php

namespace App\Jobs;

use App\Models\ProductMedia;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\SerializesModels;
use Throwable;

class ProcessMediaUpload implements ShouldQueue
{
    use Dispatchable, InteractsWithQueue, SerializesModels;

    public function __construct(public ProductMedia $media) {}

    public function handle(): void
    {
        try {
            $this->media->update(['status' => 'ready']);
        } catch (Throwable $exception) {
            $this->media->update(['status' => 'failed', 'metadata' => ['error' => $exception->getMessage()]]);
            throw $exception;
        }
    }
}


text:
Script completed
Wall time 0.1 seconds
Output:

text:
specs/05-BUSINESS-LOGIC.md:3:> E-commerce platform - All domain logic, state machines, calculations, and rules for Laravel 12
specs/05-BUSINESS-LOGIC.md:16:| Currency | Single currency per store (`stores.default_currency`). No multi-currency conversion. |
specs/05-BUSINESS-LOGIC.md:31:2. Look up `store_domains` table where `hostname` matches.
specs/05-BUSINESS-LOGIC.md:32:3. Cache the hostname-to-store_id mapping using file cache with a 5-minute TTL. Cache key pattern: `store_domain:{hostname}`.
specs/05-BUSINESS-LOGIC.md:34:5. If hostname is not found in `store_domains`: return HTTP 404.
specs/05-BUSINESS-LOGIC.md:49:    D -->|No| F[Query store_domains table]
specs/05-BUSINESS-LOGIC.md:80:products, collections, customers, orders, carts, checkouts, discounts, shipping_zones, themes, pages, navigation_menus, analytics_events, analytics_daily, webhook_subscriptions, inventory_items, search_queries.
specs/05-BUSINESS-LOGIC.md:84:**Enum:** `App\Enums\StoreUserRole` - backed by string values: `owner`, `admin`, `staff`, `support`
specs/05-BUSINESS-LOGIC.md:91:| Manage staff | Yes | Yes | No | No |
specs/05-BUSINESS-LOGIC.md:489:**Method:** `selectPaymentMethod(checkout, paymentMethod)` - validates and stores the selected payment method
specs/05-BUSINESS-LOGIC.md:636:| `percent` | `discount = applicable_subtotal * value_amount / 100` | `value_amount` stores whole percentage (e.g., 15 = 15%) |
specs/05-BUSINESS-LOGIC.md:933:The platform uses a **Mock Payment Service Provider (PSP)** instead of real payment gateways. All payment processing happens in-process with no external API calls, no webhooks, and no redirects. This enables a fully functional checkout flow for development, testing, and demonstration purposes.
specs/05-BUSINESS-LOGIC.md:951:| `credit_card` | Simulates card authorization and immediate capture. Uses magic card numbers for test scenarios. | Auto-capture on checkout (instant) |
specs/05-BUSINESS-LOGIC.md:952:| `paypal` | Simulates PayPal approval and immediate capture. Always succeeds (no magic numbers). | Auto-capture on checkout (instant) |
specs/05-BUSINESS-LOGIC.md:1275:3. If the order contains a mix of physical and digital products: do **not** auto-fulfill. The admin must create fulfillments manually (they can fulfill digital items immediately and physical items when shipped).
specs/05-BUSINESS-LOGIC.md:1277:This ensures digital product orders are immediately marked as complete after payment.
specs/05-BUSINESS-LOGIC.md:1317:**Store isolation:** Customer accounts are per-store. The same email address can exist in different stores as separate accounts. Authentication always scopes the query to the current store.
specs/05-BUSINESS-LOGIC.md:1349:Stores (or app installations) subscribe to event types via `webhook_subscriptions`.
specs/05-BUSINESS-LOGIC.md:1366:**Listener:** `App\Listeners\DispatchWebhooks` - registered to listen for all domain events that have webhook counterparts.
specs/05-BUSINESS-LOGIC.md:1379:    Listener->>Listener: Query webhook_subscriptions for store_id + event_type where status = active
specs/05-BUSINESS-LOGIC.md:1388:    Job->>Job: Record result in webhook_deliveries
specs/05-BUSINESS-LOGIC.md:1407:| 1 (initial) | Immediate |
specs/05-BUSINESS-LOGIC.md:1421:- If a subscription's endpoint fails 5 consecutive times: automatically set `webhook_subscriptions.status = 'paused'`.
specs/05-BUSINESS-LOGIC.md:1496:    A[Admin uploads file via Livewire] --> B[Store original in storage/app/public/media/originals/]
specs/05-BUSINESS-LOGIC.md:1497:    B --> C[Create product_media record with status = processing]
specs/05-BUSINESS-LOGIC.md:1520:- Store all sizes with predictable path keys: `media/{product_id}/{media_id}/{size}.{ext}`
specs/05-BUSINESS-LOGIC.md:1521:- Update the `product_media` record with: `width`, `height`, `mime_type`, `byte_size` from the original, and `status = 'ready'`.
specs/05-BUSINESS-LOGIC.md:1527:When a `product_media` record is deleted, a model event listener removes all associated files from storage (all sizes and the original).
specs/05-BUSINESS-LOGIC.md:1590:Summary of all domain events and their listeners/side effects:
specs/05-BUSINESS-LOGIC.md:1594:| `OrderCreated` | Send confirmation email, dispatch webhooks (`order.created`), log analytics event |
specs/05-BUSINESS-LOGIC.md:1595:| `OrderRefunded` | Send refund notification email, dispatch webhooks (`order.refunded`), optionally restock |
specs/05-BUSINESS-LOGIC.md:1598:| `CheckoutCompleted` | Dispatch webhooks (`checkout.completed`) |
specs/05-BUSINESS-LOGIC.md:1601:| `ProductCreated` | Sync FTS5 index, dispatch webhooks (`product.created`) |
specs/05-BUSINESS-LOGIC.md:1602:| `ProductUpdated` | Sync FTS5 index, dispatch webhooks (`product.updated`) |
specs/05-BUSINESS-LOGIC.md:1603:| `ProductDeleted` | Remove from FTS5 index, dispatch webhooks (`product.deleted`) |
specs/05-BUSINESS-LOGIC.md:1606:| `FulfillmentShipped` | Send shipping notification email with tracking info, dispatch webhooks (`order.fulfilled` if fully fulfilled) |
specs/05-BUSINESS-LOGIC.md:1607:| `FulfillmentDelivered` | Update fulfillment record, dispatch webhooks |
specs/05-BUSINESS-LOGIC.md:1628:| `App\Jobs\DeliverWebhook` | POST webhook payload to subscriber endpoint |
specs/05-BUSINESS-LOGIC.md:1675:| StoreUserRole | Owner (`owner`), Admin (`admin`), Staff (`staff`), Support (`support`) |
specs/03-ADMIN-UI.md:127:| `stores` | `Collection` | List of stores the user has access to |
specs/03-ADMIN-UI.md:155:- When user clicks the store name, a dropdown appears listing all stores. Clicking a store calls `switchStore()` which updates the session and redirects to the dashboard.
specs/03-ADMIN-UI.md:371:| `products` | Paginated query result with search, filters, sorting applied. Eager loads `variants`, `media` (limit 1). Uses `withCount('variants')`. |
specs/03-ADMIN-UI.md:401:| Image | No | 40x40 rounded thumbnail of first media item, or placeholder icon |
specs/03-ADMIN-UI.md:488:| `media` | `array` | Existing media items: `[{id, url, alt_text, position}]` |
specs/03-ADMIN-UI.md:494:| `save()` | Validates and saves/creates the product, variants, options, and media |
specs/03-ADMIN-UI.md:501:| `removeMedia(int $mediaId)` | Removes a media item |
specs/03-ADMIN-UI.md:502:| `reorderMedia(array $order)` | Updates media positions |
specs/03-ADMIN-UI.md:503:| `updateMediaAlt(int $mediaId, string $alt)` | Updates alt text for a media item |
specs/03-ADMIN-UI.md:693:| Loading (upload) | Progress bar shown in media section |
specs/03-ADMIN-UI.md:1427:**Blade view:** `resources/views/livewire/admin/settings/domains.blade.php`
specs/03-ADMIN-UI.md:1433:| `domains` | `Collection` | Store domains |
specs/03-ADMIN-UI.md:1434:| `newHostname` | `string` | Hostname for new domain |
specs/03-ADMIN-UI.md:1435:| `newType` | `string` | Type for new domain: `storefront`, `admin`, `api` |
specs/03-ADMIN-UI.md:1439:| `addDomain()` | Creates a new domain record |
specs/03-ADMIN-UI.md:1440:| `removeDomain(int $domainId)` | Removes a domain |
specs/03-ADMIN-UI.md:1441:| `setPrimary(int $domainId)` | Sets domain as primary |
specs/03-ADMIN-UI.md:1446:- Table of existing domains:
specs/03-ADMIN-UI.md:1456:- "Add domain" button opens a modal
specs/03-ADMIN-UI.md:1462:| Modal name | `add-domain` |
specs/03-ADMIN-UI.md:1464:| Heading | "Add domain" |
specs/03-ADMIN-UI.md:1469:- Cancel button (ghost) and "Add domain" button (primary)
specs/03-ADMIN-UI.md:2022:| `webhooks` | `Collection` | Webhook subscriptions |
specs/03-ADMIN-UI.md:2025:| `webhookEventType` | `string` | Event type for webhook form |
specs/03-ADMIN-UI.md:2026:| `webhookUrl` | `string` | URL for webhook form |
specs/03-ADMIN-UI.md:2033:| `openWebhookModal(?WebhookSubscription $webhook)` | Opens webhook create/edit modal |
specs/03-ADMIN-UI.md:2034:| `saveWebhook()` | Saves webhook subscription |
specs/03-ADMIN-UI.md:2035:| `deleteWebhook(int $webhookId)` | Deletes a webhook |
specs/03-ADMIN-UI.md:2061:| Manage webhook subscriptions for real-time event notifications.  |
specs/03-ADMIN-UI.md:2068:| [+ Add webhook]                                                   |
specs/03-ADMIN-UI.md:2105:- "Add webhook" button opens modal
specs/03-ADMIN-UI.md:2111:| Modal name | `webhook-form` |
specs/03-ADMIN-UI.md:2113:| Heading | "Edit webhook" or "Add webhook" (dynamic) |
specs/03-ADMIN-UI.md:2117:- Endpoint URL input (type: url, placeholder: "https://example.com/webhooks")
specs/03-ADMIN-UI.md:2334:- Intermediate items: parent section (e.g., "Products" linking to products index)
specs/03-ADMIN-UI.md:2417:| `App\Livewire\Admin\Settings\Domains` | `livewire.admin.settings.domains` | Tab within `GET /admin/settings` |
specs/02-API-ROUTES.md:50:| GET | `/admin/products/{product}/edit` | Edit product form with variants, media, options | `Admin\Products\Edit` |
specs/02-API-ROUTES.md:122:| GET | `/admin/apps/{installation}` | Installed app detail - scopes, webhooks, usage | `Admin\Apps\Show` |
specs/02-API-ROUTES.md:128:| GET | `/admin/developers` | Developer settings - API keys/tokens, webhook management | `Admin\Developers\Index` |
specs/02-API-ROUTES.md:130:> **Note:** Webhook subscription management (CRUD) is available exclusively through the admin UI at `/admin/developers`. There are no REST API endpoints for programmatic webhook subscription management in the initial implementation.
specs/02-API-ROUTES.md:850:Process payment for a checkout using the selected payment method. All payment processing happens in-process via the Mock PSP - no external API calls, no redirects, no webhooks.
specs/02-API-ROUTES.md:1222:#### POST /api/admin/v1/platform/organizations
specs/02-API-ROUTES.md:1224:Create a new organization.
specs/02-API-ROUTES.md:1263:#### POST /api/admin/v1/platform/stores
specs/02-API-ROUTES.md:1265:Create a new store within an organization.
specs/02-API-ROUTES.md:1274:  "organization_id": 1,
specs/02-API-ROUTES.md:1285:| `organization_id` | integer | Yes | Must exist |
specs/02-API-ROUTES.md:1298:    "organization_id": 1,
specs/02-API-ROUTES.md:1316:#### POST /api/admin/v1/stores/{storeId}/invites
specs/02-API-ROUTES.md:1327:  "email": "staff@acme.com",
specs/02-API-ROUTES.md:1328:  "role": "staff"
specs/02-API-ROUTES.md:1335:| `role` | string | Yes | One of: `owner`, `admin`, `staff`, `support` |
specs/02-API-ROUTES.md:1342:    "email": "staff@acme.com",
specs/02-API-ROUTES.md:1343:    "role": "staff",
specs/02-API-ROUTES.md:1357:#### GET /api/admin/v1/stores/{storeId}/me
specs/02-API-ROUTES.md:1359:Get the current user's membership details for a store.
specs/02-API-ROUTES.md:1399:| GET | `/api/admin/v1/stores/{storeId}/products` | `read-products` | List products with filtering and pagination | `api.admin` (60/min) |
specs/02-API-ROUTES.md:1400:| POST | `/api/admin/v1/stores/{storeId}/products` | `write-products` | Create a new product | `api.admin` (60/min) |
specs/02-API-ROUTES.md:1401:| GET | `/api/admin/v1/stores/{storeId}/products/{productId}` | `read-products` | Get single product with all relations | `api.admin` (60/min) |
specs/02-API-ROUTES.md:1402:| PUT | `/api/admin/v1/stores/{storeId}/products/{productId}` | `write-products` | Update a product (partial updates supported) | `api.admin` (60/min) |
specs/02-API-ROUTES.md:1403:| DELETE | `/api/admin/v1/stores/{storeId}/products/{productId}` | `write-products` | Archive a product (soft delete) | `api.admin` (60/min) |
specs/02-API-ROUTES.md:1404:| POST | `/api/admin/v1/stores/{storeId}/products/{productId}/media/presign-upload` | `write-products` | Generate presigned URL for media upload | `api.admin` (60/min) |
specs/02-API-ROUTES.md:1406:#### GET /api/admin/v1/stores/{storeId}/products
specs/02-API-ROUTES.md:1416:| `collection_id` | integer | No | Filter by collection membership. |
specs/02-API-ROUTES.md:1457:#### POST /api/admin/v1/stores/{storeId}/products
specs/02-API-ROUTES.md:1539:Returns the full product resource with nested variants, options, and media.
specs/02-API-ROUTES.md:1547:#### GET /api/admin/v1/stores/{storeId}/products/{productId}
specs/02-API-ROUTES.md:1549:Get a single product with all variants, options, media, and collections.
specs/02-API-ROUTES.md:1613:    "media": [
specs/02-API-ROUTES.md:1617:        "storage_key": "stores/1/products/10/main.jpg",
specs/02-API-ROUTES.md:1618:        "url": "https://cdn.example.com/stores/1/products/10/main.jpg",
specs/02-API-ROUTES.md:1645:#### PUT /api/admin/v1/stores/{storeId}/products/{productId}
specs/02-API-ROUTES.md:1672:#### DELETE /api/admin/v1/stores/{storeId}/products/{productId}
specs/02-API-ROUTES.md:1694:#### POST /api/admin/v1/stores/{storeId}/products/{productId}/media/presign-upload
specs/02-API-ROUTES.md:1696:Generate a presigned URL for uploading product media directly to object storage (S3).
specs/02-API-ROUTES.md:1718:  "upload_url": "https://s3.example.com/...presigned...",
specs/02-API-ROUTES.md:1723:  "storage_key": "stores/1/products/10/media/uuid.jpg",
specs/02-API-ROUTES.md:1724:  "media_id": 2,
specs/02-API-ROUTES.md:1742:| GET | `/api/admin/v1/stores/{storeId}/collections` | `read-collections` | List collections | `api.admin` (60/min) |
specs/02-API-ROUTES.md:1743:| POST | `/api/admin/v1/stores/{storeId}/collections` | `write-collections` | Create a collection | `api.admin` (60/min) |
specs/02-API-ROUTES.md:1744:| PUT | `/api/admin/v1/stores/{storeId}/collections/{collectionId}` | `write-collections` | Update a collection | `api.admin` (60/min) |
specs/02-API-ROUTES.md:1745:| DELETE | `/api/admin/v1/stores/{storeId}/collections/{collectionId}` | `write-collections` | Delete a collection | `api.admin` (60/min) |
specs/02-API-ROUTES.md:1747:#### GET /api/admin/v1/stores/{storeId}/collections
specs/02-API-ROUTES.md:1789:#### POST /api/admin/v1/stores/{storeId}/collections
specs/02-API-ROUTES.md:1825:#### PUT /api/admin/v1/stores/{storeId}/collections/{collectionId}
specs/02-API-ROUTES.md:1840:#### DELETE /api/admin/v1/stores/{storeId}/collections/{collectionId}
specs/02-API-ROUTES.md:1866:| GET | `/api/admin/v1/stores/{storeId}/orders` | `read-orders` | List orders with filtering and pagination | `api.admin` (60/min) |
specs/02-API-ROUTES.md:1867:| GET | `/api/admin/v1/stores/{storeId}/orders/{orderId}` | `read-orders` | Get full order details | `api.admin` (60/min) |
specs/02-API-ROUTES.md:1868:| POST | `/api/admin/v1/stores/{storeId}/orders/{orderId}/fulfillments` | `write-orders` | Create a fulfillment (ship items) | `api.admin` (60/min) |
specs/02-API-ROUTES.md:1869:| POST | `/api/admin/v1/stores/{storeId}/orders/{orderId}/refunds` | `write-orders` | Create a refund (partial or full) | `api.admin` (60/min) |
specs/02-API-ROUTES.md:1871:#### GET /api/admin/v1/stores/{storeId}/orders
specs/02-API-ROUTES.md:1928:#### GET /api/admin/v1/stores/{storeId}/orders/{orderId}
specs/02-API-ROUTES.md:2016:#### POST /api/admin/v1/stores/{storeId}/orders/{orderId}/fulfillments
specs/02-API-ROUTES.md:2077:#### POST /api/admin/v1/stores/{storeId}/orders/{orderId}/refunds
specs/02-API-ROUTES.md:2138:| GET | `/api/admin/v1/stores/{storeId}/discounts` | `read-discounts` | List discounts | `api.admin` (60/min) |
specs/02-API-ROUTES.md:2139:| POST | `/api/admin/v1/stores/{storeId}/discounts` | `write-discounts` | Create a discount | `api.admin` (60/min) |
specs/02-API-ROUTES.md:2140:| PUT | `/api/admin/v1/stores/{storeId}/discounts/{discountId}` | `write-discounts` | Update a discount | `api.admin` (60/min) |
specs/02-API-ROUTES.md:2141:| DELETE | `/api/admin/v1/stores/{storeId}/discounts/{discountId}` | `write-discounts` | Delete a discount | `api.admin` (60/min) |
specs/02-API-ROUTES.md:2143:#### GET /api/admin/v1/stores/{storeId}/discounts
specs/02-API-ROUTES.md:2192:#### POST /api/admin/v1/stores/{storeId}/discounts
specs/02-API-ROUTES.md:2243:#### PUT /api/admin/v1/stores/{storeId}/discounts/{discountId}
specs/02-API-ROUTES.md:2258:#### DELETE /api/admin/v1/stores/{storeId}/discounts/{discountId}
specs/02-API-ROUTES.md:2284:| GET | `/api/admin/v1/stores/{storeId}/shipping/zones` | `read-settings` | List shipping zones with rates | `api.admin` (60/min) |
specs/02-API-ROUTES.md:2285:| POST | `/api/admin/v1/stores/{storeId}/shipping/zones` | `write-settings` | Create a shipping zone | `api.admin` (60/min) |
specs/02-API-ROUTES.md:2286:| PUT | `/api/admin/v1/stores/{storeId}/shipping/zones/{zoneId}` | `write-settings` | Update a shipping zone | `api.admin` (60/min) |
specs/02-API-ROUTES.md:2287:| POST | `/api/admin/v1/stores/{storeId}/shipping/zones/{zoneId}/rates` | `write-settings` | Add a shipping rate to a zone | `api.admin` (60/min) |
specs/02-API-ROUTES.md:2289:#### GET /api/admin/v1/stores/{storeId}/shipping/zones
specs/02-API-ROUTES.md:2337:#### POST /api/admin/v1/stores/{storeId}/shipping/zones
specs/02-API-ROUTES.md:2365:#### PUT /api/admin/v1/stores/{storeId}/shipping/zones/{zoneId}
specs/02-API-ROUTES.md:2380:#### POST /api/admin/v1/stores/{storeId}/shipping/zones/{zoneId}/rates
specs/02-API-ROUTES.md:2420:| GET | `/api/admin/v1/stores/{storeId}/tax/settings` | `read-settings` | Get tax configuration | `api.admin` (60/min) |
specs/02-API-ROUTES.md:2421:| PUT | `/api/admin/v1/stores/{storeId}/tax/settings` | `write-settings` | Update tax configuration | `api.admin` (60/min) |
specs/02-API-ROUTES.md:2423:#### GET /api/admin/v1/stores/{storeId}/tax/settings
specs/02-API-ROUTES.md:2460:#### PUT /api/admin/v1/stores/{storeId}/tax/settings
specs/02-API-ROUTES.md:2498:| POST | `/api/admin/v1/stores/{storeId}/themes` | `write-themes` | Upload or install a new theme | `api.admin` (60/min) |
specs/02-API-ROUTES.md:2499:| POST | `/api/admin/v1/stores/{storeId}/themes/{themeId}/publish` | `write-themes` | Publish a theme (make active) | `api.admin` (60/min) |
specs/02-API-ROUTES.md:2500:| PUT | `/api/admin/v1/stores/{storeId}/themes/{themeId}/settings` | `write-themes` | Update theme customization settings | `api.admin` (60/min) |
specs/02-API-ROUTES.md:2502:#### POST /api/admin/v1/stores/{storeId}/themes
specs/02-API-ROUTES.md:2535:#### POST /api/admin/v1/stores/{storeId}/themes/{themeId}/publish
specs/02-API-ROUTES.md:2558:#### PUT /api/admin/v1/stores/{storeId}/themes/{themeId}/settings
specs/02-API-ROUTES.md:2604:| GET | `/api/admin/v1/stores/{storeId}/pages` | `read-content` | List content pages | `api.admin` (60/min) |
specs/02-API-ROUTES.md:2605:| POST | `/api/admin/v1/stores/{storeId}/pages` | `write-content` | Create a content page | `api.admin` (60/min) |
specs/02-API-ROUTES.md:2606:| PUT | `/api/admin/v1/stores/{storeId}/pages/{pageId}` | `write-content` | Update a content page | `api.admin` (60/min) |
specs/02-API-ROUTES.md:2607:| DELETE | `/api/admin/v1/stores/{storeId}/pages/{pageId}` | `write-content` | Delete a content page | `api.admin` (60/min) |
specs/02-API-ROUTES.md:2609:#### GET /api/admin/v1/stores/{storeId}/pages
specs/02-API-ROUTES.md:2648:#### POST /api/admin/v1/stores/{storeId}/pages
specs/02-API-ROUTES.md:2678:#### PUT /api/admin/v1/stores/{storeId}/pages/{pageId}
specs/02-API-ROUTES.md:2693:#### DELETE /api/admin/v1/stores/{storeId}/pages/{pageId}
specs/02-API-ROUTES.md:2719:| POST | `/api/admin/v1/stores/{storeId}/search/reindex` | `write-settings` | Trigger full search reindex (async) | `api.admin` (60/min) |
specs/02-API-ROUTES.md:2720:| GET | `/api/admin/v1/stores/{storeId}/search/status` | `read-settings` | Get current search index status | `api.admin` (60/min) |
specs/02-API-ROUTES.md:2722:#### POST /api/admin/v1/stores/{storeId}/search/reindex
specs/02-API-ROUTES.md:2743:#### GET /api/admin/v1/stores/{storeId}/search/status
specs/02-API-ROUTES.md:2770:| GET | `/api/admin/v1/stores/{storeId}/analytics/summary` | `read-analytics` | Get analytics summary for date range | `api.admin` (60/min) |
specs/02-API-ROUTES.md:2771:| POST | `/api/admin/v1/stores/{storeId}/exports/orders` | `read-orders` | Queue an order export job (async) | `api.admin` (60/min) |
specs/02-API-ROUTES.md:2772:| GET | `/api/admin/v1/stores/{storeId}/exports/{exportId}` | `read-orders` | Check export status and get download URL | `api.admin` (60/min) |
specs/02-API-ROUTES.md:2774:#### GET /api/admin/v1/stores/{storeId}/analytics/summary
specs/02-API-ROUTES.md:2834:#### POST /api/admin/v1/stores/{storeId}/exports/orders
specs/02-API-ROUTES.md:2868:#### GET /api/admin/v1/stores/{storeId}/exports/{exportId}
specs/02-API-ROUTES.md:2881:    "download_url": "https://s3.example.com/...presigned...",
specs/02-API-ROUTES.md:2899:> **Note:** No inbound webhook endpoints are needed. All payment processing happens in-process via the Mock PSP -- there are no external payment provider callbacks. The webhook infrastructure defined in this section is reserved for **outbound** app webhooks (see Section 5, OAuth / App Ecosystem).
specs/02-API-ROUTES.md:2903:## 5. OAuth Endpoints (App Ecosystem)
specs/02-API-ROUTES.md:2905:> **Scope note:** OAuth and app ecosystem endpoints are deferred for initial implementation. The route structure below is defined for future use but implementations should return 501 Not Implemented.
specs/02-API-ROUTES.md:2907:These endpoints implement OAuth2 authorization for third-party apps, powered by Laravel Passport.
specs/02-API-ROUTES.md:2911:### GET /oauth/authorize
specs/02-API-ROUTES.md:2921:| `client_id` | string | Yes | The app's OAuth client ID |
specs/02-API-ROUTES.md:2931:### POST /oauth/token
specs/02-API-ROUTES.md:2979:Third-party apps use OAuth tokens to access store data via scoped endpoints.
specs/02-API-ROUTES.md:2983:| GET | `/api/apps/v1/stores/{storeId}/products` | `read-products` | List products (same schema as admin API) |
specs/02-API-ROUTES.md:2984:| GET | `/api/apps/v1/stores/{storeId}/orders` | `read-orders` | List orders |
specs/02-API-ROUTES.md:2985:| GET | `/api/apps/v1/stores/{storeId}/customers` | `read-customers` | List customers |
specs/02-API-ROUTES.md:3016:| `store.resolve` | Resolve current store from session/domain and set `app.store_id` |
specs/02-API-ROUTES.md:3025:| `store.resolve` | Resolve store from request hostname via `store_domains` table |
specs/02-API-ROUTES.md:3054:- **Web (admin):** Reads `store_id` from session. If missing, redirects to store selector (for platform-level users with access to multiple stores).
specs/02-API-ROUTES.md:3055:- **Web (storefront):** Looks up hostname in `store_domains` table (cached via file cache). Hostname must match a domain with type `storefront`.
specs/02-API-ROUTES.md:3057:- **API (admin):** Reads `storeId` from route parameter and verifies the authenticated user has membership.
specs/02-API-ROUTES.md:3064:- `503` if store status is `suspended` on storefront (shows maintenance page); `403` on admin mutation endpoints for suspended stores.
specs/02-API-ROUTES.md:3068:Checks that the authenticated user has the required role for the current store. Roles hierarchy: `owner` > `admin` > `staff` > `support`. The `support` role grants read-only access for customer service purposes. Specific route permission requirements are configured in the route definition or controller.
specs/02-API-ROUTES.md:3086:| `webhooks` | 100 requests | 1 minute | IP address | Reserved for future outbound app webhook endpoints |
specs/02-API-ROUTES.md:3130:| *(no inbound webhook endpoints - mock PSP)* | - |
specs/02-API-ROUTES.md:3174:- **Outbound webhook payloads:** Include a `api_version` field in outbound webhook deliveries to app installations. Each app installation pins to a version.
<?php

namespace App\Services;

use App\Enums\ProductStatus;
use App\Events\ProductStatusChanged;
use App\Exceptions\InvalidProductTransitionException;
use App\Models\InventoryItem;
use App\Models\Product;
use App\Models\Store;
use App\Support\HandleGenerator;
use App\Support\HtmlSanitizer;
use Illuminate\Support\Facades\DB;
use LogicException;

class ProductService
{
    public function __construct(private readonly HandleGenerator $handles, private readonly HtmlSanitizer $sanitizer, private readonly AuditLogger $audit) {}

    public function create(Store $store, array $data): Product
    {
        return DB::transaction(function () use ($store, $data): Product {
            $status = $data['status'] ?? ProductStatus::Draft;
            $status = $status instanceof ProductStatus ? $status : ProductStatus::from($status);
            $product = Product::withoutGlobalScopes()->create([
                'store_id' => $store->getKey(),
                'title' => $data['title'],
                'handle' => $data['handle'] ?? $this->handles->generate($data['title'], 'products', $store->getKey()),
                'description' => $this->sanitizer->sanitize($data['description'] ?? null),
                'description_html' => $this->sanitizer->sanitize($data['description_html'] ?? $data['description'] ?? null),
                'vendor' => $data['vendor'] ?? null,
                'product_type' => $data['product_type'] ?? null,
                'tags' => $data['tags'] ?? [],
                'status' => $status,
                'published_at' => $status === ProductStatus::Active ? now() : null,
            ]);

            foreach ($data['variants'] ?? [['title' => 'Default', 'price_amount' => 0, 'is_default' => true]] as $position => $variant) {
                $createdVariant = $product->variants()->create(array_merge($variant, ['position' => $position, 'is_default' => $variant['is_default'] ?? $position === 0]));
                InventoryItem::withoutGlobalScopes()->create([
                    'store_id' => $store->getKey(),
                    'variant_id' => $createdVariant->getKey(),
                    'quantity_on_hand' => (int) ($variant['quantity_on_hand'] ?? 0),
                    'policy' => $variant['policy'] ?? 'deny',
                ]);
            }

            if ($status === ProductStatus::Active && (trim((string) $product->title) === '' || ! $product->variants()->where('price_amount', '>', 0)->exists())) {
                throw new InvalidProductTransitionException('An active product requires a title and a priced variant.');
            }

            $this->audit->record('product.created', $product, ['store_id' => $store->getKey()]);

            return $product->load('variants');
        });
    }

    public function update(Product $product, array $data): Product
    {
        $updates = array_intersect_key($data, array_flip(['title', 'description', 'description_html', 'vendor', 'product_type', 'tags', 'status', 'published_at']));

        $newStatus = null;

        if (array_key_exists('status', $updates)) {
            $newStatus = $updates['status'] instanceof ProductStatus ? $updates['status'] : ProductStatus::from($updates['status']);
            unset($updates['status']);
        }

        if (array_key_exists('description', $data)) {
            $updates['description'] = $this->sanitizer->sanitize($data['description']);
            $updates['description_html'] = $this->sanitizer->sanitize($data['description']);
        } elseif (array_key_exists('description_html', $data)) {
            $updates['description_html'] = $this->sanitizer->sanitize($data['description_html']);
        }

        $product->update($updates);

        if ($newStatus !== null) {
            $this->transitionStatus($product->refresh(), $newStatus);
        }

        $product = $product->refresh()->load('variants');
        $this->audit->record('product.updated', $product, ['store_id' => $product->store_id]);

        return $product;
    }

    public function transitionStatus(Product $product, ProductStatus $newStatus): void
    {
        $from = $product->status instanceof ProductStatus ? $product->status : ProductStatus::from($product->status);

        if ($from === $newStatus) {
            return;
        }

        if ($newStatus === ProductStatus::Active && (! $product->variants()->where('price_amount', '>', 0)->exists() || trim((string) $product->title) === '')) {
            throw new InvalidProductTransitionException('An active product requires a title and a priced variant.');
        }

        if ($newStatus === ProductStatus::Draft && in_array($from, [ProductStatus::Active, ProductStatus::Archived], true) && $product->orders()->exists()) {
            throw new InvalidProductTransitionException('Products with order history cannot be reverted to draft.');
        }

        $product->update(['status' => $newStatus, 'published_at' => $newStatus === ProductStatus::Active ? ($product->published_at ?? now()) : null]);
        ProductStatusChanged::dispatch($product->refresh(), $from, $newStatus);
        $this->audit->record('product.updated', $product, ['from_status' => $from->value, 'to_status' => $newStatus->value, 'store_id' => $product->store_id]);
    }

    public function delete(Product $product): void
    {
        if ($product->status !== ProductStatus::Draft || $product->orders()->exists()) {
            throw new LogicException('Only draft products with no order history can be deleted.');
        }

        $product->delete();
        $this->audit->record('product.deleted', $product, ['store_id' => $product->store_id]);
    }
}
<?php

namespace App\Http\Requests;

use App\Models\Product;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Support\Facades\Gate;

class StoreProductRequest extends FormRequest
{
    /**
     * Determine if the user is authorized to make this request.
     */
    public function authorize(): bool
    {
        return Gate::allows('create', Product::class);
    }

    /**
     * Get the validation rules that apply to the request.
     *
     * @return array<string, \Illuminate\Contracts\Validation\ValidationRule|array<mixed>|string>
     */
    public function rules(): array
    {
        return [
            'title' => ['required', 'string', 'max:255'],
            'handle' => ['nullable', 'string', 'max:255'],
            'description_html' => ['nullable', 'string'],
            'description' => ['nullable', 'string'],
            'status' => ['required', 'in:active,draft,archived'],
            'vendor' => ['nullable', 'string', 'max:255'],
            'product_type' => ['nullable', 'string', 'max:255'],
            'variants' => ['nullable', 'array'],
        ];
    }
}
<?php

namespace App\Http\Requests;

use App\Models\Product;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Support\Facades\Gate;

class UpdateProductRequest extends FormRequest
{
    /**
     * Determine if the user is authorized to make this request.
     */
    public function authorize(): bool
    {
        return Gate::allows('viewAny', Product::class);
    }

    /**
     * Get the validation rules that apply to the request.
     *
     * @return array<string, \Illuminate\Contracts\Validation\ValidationRule|array<mixed>|string>
     */
    public function rules(): array
    {
        return [
            'title' => ['sometimes', 'string', 'max:255'],
            'handle' => ['sometimes', 'string', 'max:255'],
            'description_html' => ['sometimes', 'nullable', 'string'],
            'description' => ['sometimes', 'nullable', 'string'],
            'status' => ['sometimes', 'in:active,draft,archived'],
            'vendor' => ['sometimes', 'nullable', 'string', 'max:255'],
            'product_type' => ['sometimes', 'nullable', 'string', 'max:255'],
        ];
    }
}
<?php

namespace App\Livewire\Admin\Developers;

use App\Models\WebhookSubscription;
use Carbon\CarbonImmutable;
use Illuminate\Contracts\View\View;
use Illuminate\Support\Str;
use Livewire\Component;

class Index extends Component
{
    public string $event = 'order.created';

    public string $targetUrl = '';

    public string $tokenName = '';

    public string $tokenExpiresAt = '';

    public string $tokenAbilities = 'read-products,read-orders';

    public ?string $plainTextToken = null;

    public function createWebhook(): void
    {
        abort_unless(auth()->user()?->canManageStore(app('current_store')), 403);
        $data = $this->validate(['event' => ['required', 'string', 'max:100'], 'targetUrl' => ['required', 'url', 'max:2000']]);
        WebhookSubscription::query()->create(['event' => $data['event'], 'target_url' => $data['targetUrl'], 'signing_secret_encrypted' => Str::random(48), 'status' => 'active']);
        $this->reset('targetUrl');
    }

    public function pause(int $subscriptionId): void
    {
        abort_unless(auth()->user()?->canManageStore(app('current_store')), 403);
        $subscription = WebhookSubscription::query()->findOrFail($subscriptionId);
        $subscription->update(['status' => $subscription->status === 'active' ? 'paused' : 'active']);
    }

    public function createToken(): void
    {
        abort_unless(auth()->user()?->canManageStore(app('current_store')), 403);
        $data = $this->validate([
            'tokenName' => ['required', 'string', 'max:100'],
            'tokenExpiresAt' => ['nullable', 'date', 'after:today'],
            'tokenAbilities' => ['required', 'string', 'max:1000'],
        ]);
        $allowed = ['read-products', 'write-products', 'read-orders', 'write-orders', 'read-customers', 'write-customers', 'read-collections', 'write-collections', 'read-discounts', 'write-discounts', 'read-analytics', 'read-settings', 'write-settings', 'read-themes', 'write-themes', 'read-content', 'write-content'];
        $abilities = array_values(array_intersect($allowed, array_filter(array_map('trim', explode(',', $data['tokenAbilities'])))));
        abort_if($abilities === [], 422, 'Select at least one token ability.');
        $expiresAt = empty($data['tokenExpiresAt']) ? null : CarbonImmutable::parse($data['tokenExpiresAt']);
        $this->plainTextToken = auth()->user()->createToken($data['tokenName'], $abilities, $expiresAt)->plainTextToken;
        $this->reset(['tokenName', 'tokenExpiresAt']);
    }

    public function revokeToken(int $tokenId): void
    {
        abort_unless(auth()->user()?->canManageStore(app('current_store')), 403);
        auth()->user()->tokens()->whereKey($tokenId)->delete();
    }

    public function render(): View
    {
        return view('livewire.admin.developers.index', ['subscriptions' => WebhookSubscription::query()->latest()->get(), 'tokens' => auth()->user()->tokens()->latest()->get()])->layout('layouts.admin');
    }
}
<?php

namespace App\Livewire\Admin\Settings;

use App\Models\StoreDomain;
use Illuminate\Support\Facades\DB;
use Livewire\Component;

class Domains extends Component
{
    public string $newHostname = '';

    public string $newType = 'storefront';

    public bool $showModal = false;

    public function mount(): void
    {
        $this->authorize('update', app('current_store'));
    }

    public function addDomain(): void
    {
        $this->authorize('update', app('current_store'));
        $data = $this->validate([
            'newHostname' => ['required', 'string', 'max:255', 'regex:/^(?=.{1,253}$)([a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?\.)+[a-z]{2,}$/i'],
            'newType' => ['required', 'in:storefront,admin,api'],
        ]);
        $store = app('current_store');
        $isPrimary = ! StoreDomain::query()->where('store_id', $store->getKey())->where('type', $data['newType'])->exists();
        StoreDomain::create([
            'store_id' => $store->getKey(),
            'hostname' => strtolower($data['newHostname']),
            'type' => $data['newType'],
            'is_primary' => $isPrimary,
            'tls_mode' => 'managed',
        ]);
        $this->reset(['newHostname', 'showModal']);
        $this->newType = 'storefront';
        $this->dispatch('toast', message: 'Domain added.');
    }

    public function removeDomain(int $domainId): void
    {
        $this->authorize('update', app('current_store'));
        $domain = StoreDomain::query()->where('store_id', app('current_store')->getKey())->findOrFail($domainId);
        $wasPrimary = $domain->is_primary;
        $type = $domain->type->value;
        $domain->delete();

        if ($wasPrimary) {
            StoreDomain::query()->where('store_id', app('current_store')->getKey())->where('type', $type)->latest('id')->first()?->update(['is_primary' => true]);
        }
        $this->dispatch('toast', message: 'Domain removed.');
    }

    public function setPrimary(int $domainId): void
    {
        $this->authorize('update', app('current_store'));
        $domain = StoreDomain::query()->where('store_id', app('current_store')->getKey())->findOrFail($domainId);
        DB::transaction(function () use ($domain): void {
            StoreDomain::query()->where('store_id', $domain->store_id)->where('type', $domain->type->value)->update(['is_primary' => false]);
            $domain->update(['is_primary' => true]);
        });
        $this->dispatch('toast', message: 'Primary domain updated.');
    }

    public function render(): mixed
    {
        $domains = StoreDomain::query()->where('store_id', app('current_store')->getKey())->orderByDesc('is_primary')->orderBy('hostname')->get();

        return view('livewire.admin.settings.domains', compact('domains'));
    }
}
<?php

namespace App\Jobs;

use App\Models\ProductMedia;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\SerializesModels;
use Throwable;

class ProcessMediaUpload implements ShouldQueue
{
    use Dispatchable, InteractsWithQueue, SerializesModels;

    public function __construct(public ProductMedia $media) {}

    public function handle(): void
    {
        try {
            $this->media->update(['status' => 'ready']);
        } catch (Throwable $exception) {
            $this->media->update(['status' => 'failed', 'metadata' => ['error' => $exception->getMessage()]]);
            throw $exception;
        }
    }
}


exec

status: completed

input:
const r = await tools.exec_command({
  cmd: "sed -n '1200,1405p' specs/02-API-ROUTES.md && sed -n '1450,1745p' specs/02-API-ROUTES.md && sed -n '2890,2998p' specs/02-API-ROUTES.md && sed -n '1,280p' app/Models/Organization.php && sed -n '1,280p' app/Models/Store.php && sed -n '1,300p' app/Models/User.php && sed -n '1,220p' app/Models/ProductMedia.php && sed -n '1,260p' database/migrations/2026_08_20_000000_create_shop_schema.php",
  workdir: "/Users/wesner/Herd/shop",
  yield_time_ms: 10000,
  max_output_tokens: 50000
});
text(r.output);