command: 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

Store.php

cmd: sed -n '1,280p' app/Models/Store.php

User.php

cmd: sed -n '1,300p' app/Models/User.php

ProductMedia.php

stdout:
**Error responses:**
- `422` - Validation errors (invalid event types, too many events, etc.)

---

## 3. Admin REST API

**Prefix:** `/api/admin/v1`
**Middleware:** `auth:sanctum`, `store.resolve`, `throttle:api.admin`
**Content-Type:** `application/json`

These endpoints provide programmatic access for integrations, CLI tools, and third-party apps. The admin web UI uses Livewire actions directly and does not call these endpoints.

All endpoints require a valid Sanctum API token with appropriate scopes. Responses follow Laravel's API Resource conventions.

---

### 3.1 Platform Management (Super-Admin Only)

These endpoints are restricted to users with the `owner` (platform) role.

#### POST /api/admin/v1/platform/organizations

Create a new organization.

- **Authentication:** Sanctum token with `manage-platform` scope
- **Rate limit:** `api.admin` (60/min per token)

**Request body:**

```json
{
  "name": "Acme Corp",
  "billing_email": "billing@acme.com"
}
```

| Field | Type | Required | Validation |
|-------|------|----------|------------|
| `name` | string | Yes | Max: 255 |
| `billing_email` | string | Yes | Valid email, max: 255 |

**Success response:** `201 Created`

```json
{
  "data": {
    "id": 1,
    "name": "Acme Corp",
    "billing_email": "billing@acme.com",
    "created_at": "2026-02-11T10:00:00Z",
    "updated_at": "2026-02-11T10:00:00Z"
  }
}
```

**Error responses:**
- `403` - Insufficient permissions
- `422` - Validation errors

---

#### POST /api/admin/v1/platform/stores

Create a new store within an organization.

- **Authentication:** Sanctum token with `manage-platform` scope
- **Rate limit:** `api.admin` (60/min per token)

**Request body:**

```json
{
  "organization_id": 1,
  "name": "Acme Store",
  "handle": "acme",
  "default_currency": "EUR",
  "default_locale": "en",
  "timezone": "Europe/Berlin"
}
```

| Field | Type | Required | Validation |
|-------|------|----------|------------|
| `organization_id` | integer | Yes | Must exist |
| `name` | string | Yes | Max: 255 |
| `handle` | string | Yes | Unique, lowercase, alphanumeric with hyphens. Max: 63. Regex: `^[a-z0-9]([a-z0-9-]*[a-z0-9])?$` |
| `default_currency` | string | Yes | Valid ISO 4217 code |
| `default_locale` | string | Yes | Valid locale code (e.g., `en`, `de`, `fr`) |
| `timezone` | string | Yes | Valid IANA timezone |

**Success response:** `201 Created`

```json
{
  "data": {
    "id": 1,
    "organization_id": 1,
    "name": "Acme Store",
    "handle": "acme",
    "status": "active",
    "default_currency": "EUR",
    "default_locale": "en",
    "timezone": "Europe/Berlin",
    "created_at": "2026-02-11T10:00:00Z"
  }
}
```

**Error responses:**
- `403` - Insufficient permissions
- `422` - Validation errors (duplicate handle, invalid timezone, etc.)

---

#### POST /api/admin/v1/stores/{storeId}/invites

Invite a user to a store with a specific role.

- **Authentication:** Sanctum token with `manage-platform` scope
- **Rate limit:** `api.admin` (60/min per token)

**Request body:**

```json
{
  "email": "staff@acme.com",
  "role": "staff"
}
```

| Field | Type | Required | Validation |
|-------|------|----------|------------|
| `email` | string | Yes | Valid email, max: 255 |
| `role` | string | Yes | One of: `owner`, `admin`, `staff`, `support` |

**Success response:** `201 Created`

```json
{
  "data": {
    "email": "staff@acme.com",
    "role": "staff",
    "invited_at": "2026-02-11T10:00:00Z",
    "expires_at": "2026-02-18T10:00:00Z"
  }
}
```

**Error responses:**
- `403` - Insufficient permissions (only `owner` and `admin` can invite)
- `409` - User already a member of this store
- `422` - Validation errors

---

#### GET /api/admin/v1/stores/{storeId}/me

Get the current user's membership details for a store.

- **Authentication:** Sanctum token
- **Rate limit:** `api.admin` (60/min per token)

**Success response:** `200 OK`

```json
{
  "data": {
    "user_id": 3,
    "store_id": 1,
    "role": "admin",
    "email": "admin@acme.com",
    "name": "Jane Admin",
    "permissions": [
      "read-products",
      "write-products",
      "read-orders",
      "write-orders",
      "read-settings",
      "write-settings",
      "read-customers"
    ]
  }
}
```

**Error responses:**
- `403` - Not a member of this store
- `404` - Store not found

---

### 3.2 Products

#### Admin Products Endpoint Summary

| Method | Path | Auth Scope | Description | Rate Limit |
|--------|------|------------|-------------|------------|
| GET | `/api/admin/v1/stores/{storeId}/products` | `read-products` | List products with filtering and pagination | `api.admin` (60/min) |
| POST | `/api/admin/v1/stores/{storeId}/products` | `write-products` | Create a new product | `api.admin` (60/min) |
| GET | `/api/admin/v1/stores/{storeId}/products/{productId}` | `read-products` | Get single product with all relations | `api.admin` (60/min) |
| PUT | `/api/admin/v1/stores/{storeId}/products/{productId}` | `write-products` | Update a product (partial updates supported) | `api.admin` (60/min) |
| DELETE | `/api/admin/v1/stores/{storeId}/products/{productId}` | `write-products` | Archive a product (soft delete) | `api.admin` (60/min) |
| POST | `/api/admin/v1/stores/{storeId}/products/{productId}/media/presign-upload` | `write-products` | Generate presigned URL for media upload | `api.admin` (60/min) |

    "last_page": 2
  }
}
```

---

#### POST /api/admin/v1/stores/{storeId}/products

Create a new product.

**Request body:**

```json
{
  "title": "Classic T-Shirt",
  "handle": "classic-t-shirt",
  "description_html": "
A comfortable cotton t-shirt.
",
  "vendor": "Acme Apparel",
  "product_type": "Apparel",
  "status": "draft",
  "tags": ["organic", "cotton"],
  "options": [
    { "name": "Color", "position": 1 },
    { "name": "Size", "position": 2 }
  ],
  "variants": [
    {
      "sku": "TSH-BLU-S",
      "barcode": "1234567890123",
      "price_amount": 2500,
      "compare_at_amount": 3500,
      "currency": "EUR",
      "weight_g": 200,
      "requires_shipping": true,
      "is_default": true,
      "position": 1,
      "status": "active",
      "option_values": [
        { "option_name": "Color", "value": "Blue" },
        { "option_name": "Size", "value": "Small" }
      ],
      "inventory": {
        "quantity_on_hand": 50,
        "policy": "deny"
      }
    }
  ],
  "collections": [5]
}
```

**Product fields:**

| Field | Type | Required | Validation |
|-------|------|----------|------------|
| `title` | string | Yes | Max: 255 |
| `handle` | string | No | Auto-generated from title if not provided. Unique per store. Lowercase, hyphens only. Max: 255. |
| `description_html` | string | No | Max: 65535 |
| `vendor` | string | No | Max: 255 |
| `product_type` | string | No | Max: 255 |
| `status` | string | No | One of: `draft`, `active`. Default: `draft`. |
| `tags` | array of strings | No | Max: 50 tags, each max: 255. |
| `options` | array | No | Max: 3 options. |
| `options[].name` | string | Yes | Max: 255 |
| `options[].position` | integer | Yes | Min: 1, max: 3 |
| `collections` | array of integers | No | Collection IDs to add this product to. |

**Variant fields:**

| Field | Type | Required | Validation |
|-------|------|----------|------------|
| `variants` | array | Yes | At least one variant required. Max: 100. |
| `variants[].sku` | string | Yes | Max: 255. Unique per store (`store_id`, `sku`). |
| `variants[].barcode` | string | No | Max: 255 |
| `variants[].price_amount` | integer | Yes | Min: 0 (in minor currency units) |
| `variants[].compare_at_amount` | integer | No | Min: 0. Must be > price_amount if set. |
| `variants[].currency` | string | No | Defaults to store currency. |
| `variants[].weight_g` | integer | No | Min: 0 |
| `variants[].requires_shipping` | boolean | No | Default: true |
| `variants[].is_default` | boolean | No | Exactly one variant must be default. |
| `variants[].position` | integer | No | Auto-assigned if not provided. |
| `variants[].status` | string | No | One of: `active`, `archived`. Default: `active`. |
| `variants[].option_values` | array | No | Must match defined options. |
| `variants[].inventory.quantity_on_hand` | integer | No | Default: 0. Min: 0. |
| `variants[].inventory.policy` | string | No | One of: `deny`, `continue`. Default: `deny`. |

**Success response:** `201 Created`

Returns the full product resource with nested variants, options, and media.

**Error responses:**
- `403` - Insufficient permissions
- `422` - Validation errors (duplicate SKU, duplicate handle, missing required fields)

---

#### GET /api/admin/v1/stores/{storeId}/products/{productId}

Get a single product with all variants, options, media, and collections.

**Success response:** `200 OK`

```json
{
  "data": {
    "id": 10,
    "store_id": 1,
    "title": "Classic T-Shirt",
    "handle": "classic-t-shirt",
    "description_html": "
A comfortable cotton t-shirt.
",
    "vendor": "Acme Apparel",
    "product_type": "Apparel",
    "status": "active",
    "tags": ["organic", "cotton"],
    "published_at": "2026-02-10T08:00:00Z",
    "created_at": "2026-02-09T14:00:00Z",
    "updated_at": "2026-02-11T09:00:00Z",
    "options": [
      {
        "id": 1,
        "name": "Color",
        "position": 1,
        "values": [
          { "id": 1, "value": "Blue", "position": 1 },
          { "id": 2, "value": "Red", "position": 2 }
        ]
      },
      {
        "id": 2,
        "name": "Size",
        "position": 2,
        "values": [
          { "id": 3, "value": "Small", "position": 1 },
          { "id": 4, "value": "Medium", "position": 2 },
          { "id": 5, "value": "Large", "position": 3 }
        ]
      }
    ],
    "variants": [
      {
        "id": 101,
        "sku": "TSH-BLU-S",
        "barcode": "1234567890123",
        "price_amount": 2500,
        "compare_at_amount": 3500,
        "currency": "EUR",
        "weight_g": 200,
        "requires_shipping": true,
        "is_default": true,
        "position": 1,
        "status": "active",
        "option_values": [
          { "option_name": "Color", "value": "Blue" },
          { "option_name": "Size", "value": "Small" }
        ],
        "inventory": {
          "quantity_on_hand": 50,
          "quantity_reserved": 2,
          "policy": "deny"
        }
      }
    ],
    "media": [
      {
        "id": 1,
        "type": "image",
        "storage_key": "stores/1/products/10/main.jpg",
        "url": "https://cdn.example.com/stores/1/products/10/main.jpg",
        "alt_text": "Classic T-Shirt in Blue",
        "width": 1200,
        "height": 1600,
        "mime_type": "image/jpeg",
        "byte_size": 245000,
        "position": 1,
        "status": "ready"
      }
    ],
    "collections": [
      {
        "id": 5,
        "title": "Summer Collection",
        "handle": "summer-collection"
      }
    ]
  }
}
```

**Error responses:**
- `403` - Insufficient permissions
- `404` - Product not found (or belongs to another store)

---

#### PUT /api/admin/v1/stores/{storeId}/products/{productId}

Update a product. Supports partial updates.

Same fields as POST. Only provided fields are updated. Variants can be added, updated (by ID), or removed.

**Example partial update request:**

```json
{
  "title": "Classic Cotton T-Shirt",
  "status": "active",
  "tags": ["organic", "cotton", "bestseller"]
}
```

**Success response:** `200 OK`

Returns the full updated product resource.

**Error responses:**
- `403` - Insufficient permissions
- `404` - Product not found
- `422` - Validation errors

---

#### DELETE /api/admin/v1/stores/{storeId}/products/{productId}

Archive a product (soft delete). Products with existing orders cannot be hard-deleted.

**Success response:** `200 OK`

```json
{
  "data": {
    "id": 10,
    "status": "archived",
    "updated_at": "2026-02-11T11:00:00Z"
  }
}
```

**Error responses:**
- `403` - Insufficient permissions
- `404` - Product not found

---

#### POST /api/admin/v1/stores/{storeId}/products/{productId}/media/presign-upload

Generate a presigned URL for uploading product media directly to object storage (S3).

**Request body:**

```json
{
  "filename": "tshirt-front.jpg",
  "content_type": "image/jpeg",
  "byte_size": 245000
}
```

| Field | Type | Required | Validation |
|-------|------|----------|------------|
| `filename` | string | Yes | Max: 255. Must have a valid extension. |
| `content_type` | string | Yes | One of: `image/jpeg`, `image/png`, `image/webp`, `image/avif`, `video/mp4`. |
| `byte_size` | integer | Yes | Min: 1, max: configurable (default 50MB for images, 500MB for video). |

**Success response:** `201 Created`

```json
{
  "upload_url": "https://s3.example.com/...presigned...",
  "method": "PUT",
  "headers": {
    "Content-Type": "image/jpeg"
  },
  "storage_key": "stores/1/products/10/media/uuid.jpg",
  "media_id": 2,
  "expires_at": "2026-02-11T10:10:00Z"
}
```

**Error responses:**
- `403` - Insufficient permissions
- `404` - Product not found
- `422` - Validation errors (invalid content type, file too large)

---

### 3.3 Collections

#### Admin Collections Endpoint Summary

| Method | Path | Auth Scope | Description | Rate Limit |
|--------|------|------------|-------------|------------|
| GET | `/api/admin/v1/stores/{storeId}/collections` | `read-collections` | List collections | `api.admin` (60/min) |
| POST | `/api/admin/v1/stores/{storeId}/collections` | `write-collections` | Create a collection | `api.admin` (60/min) |
| PUT | `/api/admin/v1/stores/{storeId}/collections/{collectionId}` | `write-collections` | Update a collection | `api.admin` (60/min) |
| DELETE | `/api/admin/v1/stores/{storeId}/collections/{collectionId}` | `write-collections` | Delete a collection | `api.admin` (60/min) |

**Error responses:**
- `403` - Insufficient permissions
- `404` - Export not found

---

## 4. Webhook Receiver Endpoints

> **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).

---

## 5. OAuth Endpoints (App Ecosystem)

> **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.

These endpoints implement OAuth2 authorization for third-party apps, powered by Laravel Passport.

---

### GET /oauth/authorize

Display the authorization prompt to the merchant.

- **Middleware:** `web`, `auth`

**Query parameters:**

| Parameter | Type | Required | Description |
|-----------|------|----------|-------------|
| `client_id` | string | Yes | The app's OAuth client ID |
| `redirect_uri` | string | Yes | Must match one of the registered redirect URIs |
| `response_type` | string | Yes | Must be `code` |
| `scope` | string | Yes | Space-separated list of requested scopes |
| `state` | string | Yes | CSRF token for the client |

**Flow:** Merchant reviews requested scopes and approves/denies. On approval, redirects to `redirect_uri` with `code` and `state` parameters. Authorization code is single-use.

---

### POST /oauth/token

Exchange an authorization code for access and refresh tokens.

- **Middleware:** None (public endpoint)

**Authorization code exchange request:**

```json
{
  "grant_type": "authorization_code",
  "client_id": "app_client_abc123",
  "client_secret": "secret_xyz",
  "redirect_uri": "https://myapp.example.com/callback",
  "code": "auth_code_abc123"
}
```

**Success response:** `200 OK`

```json
{
  "token_type": "Bearer",
  "expires_in": 3600,
  "access_token": "eyJ...",
  "refresh_token": "def..."
}
```

**Error responses:**
- `400` - Invalid grant (code already used, expired, etc.)
- `401` - Invalid client credentials

**Refresh token request:**

```json
{
  "grant_type": "refresh_token",
  "client_id": "app_client_abc123",
  "client_secret": "secret_xyz",
  "refresh_token": "def..."
}
```

---

### App API Endpoints

Third-party apps use OAuth tokens to access store data via scoped endpoints.

| Method | Path | Scope Required | Description |
|--------|------|----------------|-------------|
| GET | `/api/apps/v1/stores/{storeId}/products` | `read-products` | List products (same schema as admin API) |
| GET | `/api/apps/v1/stores/{storeId}/orders` | `read-orders` | List orders |
| GET | `/api/apps/v1/stores/{storeId}/customers` | `read-customers` | List customers |

Token scopes are enforced on every endpoint. Requests with insufficient scopes return `403 Forbidden`.

---

## 6. Middleware Stack

### 6.1 Route Group Middleware

#### Web (default)

Applied to all web routes via the `web` middleware group:

<?php

namespace App\Models;

use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\HasMany;

class Organization extends Model
{
    use HasFactory;

    protected $fillable = ['name', 'slug', 'billing_email', 'status'];

    public function stores(): HasMany
    {
        return $this->hasMany(Store::class);
    }
}
<?php

namespace App\Models;

use App\Enums\StoreStatus;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\BelongsToMany;
use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Database\Eloquent\Relations\HasOne;

class Store extends Model
{
    use HasFactory;

    protected $fillable = ['organization_id', 'name', 'handle', 'status', 'default_currency', 'default_locale', 'timezone', 'primary_domain', 'metadata'];

    protected $attributes = [
        'status' => StoreStatus::Active->value,
        'default_currency' => 'USD',
        'default_locale' => 'en',
        'timezone' => 'UTC',
    ];

    protected function casts(): array
    {
        return ['status' => StoreStatus::class, 'metadata' => 'array'];
    }

    public function organization(): BelongsTo
    {
        return $this->belongsTo(Organization::class);
    }

    public function domains(): HasMany
    {
        return $this->hasMany(StoreDomain::class);
    }

    public function users(): BelongsToMany
    {
        return $this->belongsToMany(User::class, 'store_users')->using(StoreUser::class)->withPivot('role')->withTimestamps();
    }

    public function settings(): HasOne
    {
        return $this->hasOne(StoreSettings::class);
    }

    public function isActive(): bool
    {
        return $this->status === StoreStatus::Active;
    }
}
<?php

namespace App\Models;

use App\Enums\StoreUserRole;
use Illuminate\Database\Eloquent\Factories\HasFactory;
// use Illuminate\Contracts\Auth\MustVerifyEmail;
use Illuminate\Database\Eloquent\Relations\BelongsToMany;
use Illuminate\Foundation\Auth\User as Authenticatable;
use Illuminate\Notifications\Notifiable;
use Illuminate\Support\Str;
use Laravel\Fortify\TwoFactorAuthenticatable;
use Laravel\Sanctum\HasApiTokens;

class User extends Authenticatable
{
    /** @use HasFactory<\Database\Factories\UserFactory> */
    use HasApiTokens, HasFactory, Notifiable, TwoFactorAuthenticatable;

    /**
     * The attributes that are mass assignable.
     *
     * @var list<string>
     */
    protected $fillable = [
        'name',
        'email',
        'password',
        'password_hash',
        'status',
        'last_login_at',
    ];

    /**
     * The attributes that should be hidden for serialization.
     *
     * @var list<string>
     */
    protected $hidden = [
        'password',
        'password_hash',
        'two_factor_secret',
        'two_factor_recovery_codes',
        'remember_token',
    ];

    /**
     * Get the attributes that should be cast.
     *
     * @return array<string, string>
     */
    protected function casts(): array
    {
        return [
            'email_verified_at' => 'datetime',
            'password' => 'hashed',
            'last_login_at' => 'datetime',
        ];
    }

    protected static function booted(): void
    {
        static::saving(function (User $user): void {
            if ($user->isDirty('password')) {
                $user->password_hash = $user->password;
            } elseif ($user->isDirty('password_hash')) {
                $user->password = $user->password_hash;
            }
        });
    }

    public function stores(): BelongsToMany
    {
        return $this->belongsToMany(Store::class, 'store_users')->using(StoreUser::class)->withPivot('role')->withTimestamps();
    }

    public function roleForStore(Store $store): ?StoreUserRole
    {
        $pivot = $this->stores()->where('stores.id', $store->getKey())->first()?->pivot;

        return $pivot?->role instanceof StoreUserRole ? $pivot->role : ($pivot?->role ? StoreUserRole::tryFrom($pivot->role) : null);
    }

    public function canManageStore(Store $store): bool
    {
        return in_array($this->roleForStore($store), [StoreUserRole::Owner, StoreUserRole::Admin], true);
    }

    /**
     * Get the user's initials
     */
    public function initials(): string
    {
        return Str::of($this->name)
            ->explode(' ')
            ->take(2)
            ->map(fn ($word) => Str::substr($word, 0, 1))
            ->implode('');
    }
}
<?php

namespace App\Models;

use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;

class ProductMedia extends Model
{
    protected $fillable = ['product_id', 'type', 'path', 'storage_key', 'url', 'alt_text', 'width', 'height', 'mime_type', 'byte_size', 'checksum', 'status', 'position', 'metadata'];

    protected function casts(): array
    {
        return ['metadata' => 'array'];
    }

    public function product(): BelongsTo
    {
        return $this->belongsTo(Product::class);
    }
}
<?php

use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;

return new class extends Migration
{
    public function up(): void
    {
        Schema::create('organizations', function (Blueprint $table): void {
            $table->id();
            $table->string('name');
            $table->string('slug')->unique();
            $table->string('billing_email')->nullable()->index();
            $table->string('status')->default('active')->index();
            $table->timestamps();
        });

        Schema::create('stores', function (Blueprint $table): void {
            $table->id();
            $table->foreignId('organization_id')->constrained()->cascadeOnDelete();
            $table->string('name');
            $table->string('handle')->unique();
            $table->string('status')->default('active')->index();
            $table->string('default_currency', 3)->default('EUR');
            $table->string('default_locale', 10)->default('en');
            $table->string('timezone')->default('UTC');
            $table->string('primary_domain')->nullable();
            $table->json('metadata')->nullable();
            $table->timestamps();
            $table->index(['organization_id', 'status']);
        });

        Schema::create('store_domains', function (Blueprint $table): void {
            $table->id();
            $table->foreignId('store_id')->constrained()->cascadeOnDelete();
            $table->string('hostname')->unique();
            $table->string('type')->default('storefront');
            $table->boolean('is_primary')->default(false);
            $table->timestamps();
            $table->index(['store_id', 'is_primary']);
        });

        Schema::table('users', function (Blueprint $table): void {
            $table->string('status')->default('active')->index();
            $table->timestamp('last_login_at')->nullable();
        });

        Schema::create('store_users', function (Blueprint $table): void {
            $table->foreignId('store_id')->constrained()->cascadeOnDelete();
            $table->foreignId('user_id')->constrained()->cascadeOnDelete();
            $table->string('role')->default('staff');
            $table->timestamps();
            $table->primary(['store_id', 'user_id']);
            $table->index('user_id');
            $table->index(['store_id', 'role']);
        });

        Schema::create('store_settings', function (Blueprint $table): void {
            $table->foreignId('store_id')->primary()->constrained()->cascadeOnDelete();
            $table->json('general_json')->nullable();
            $table->json('checkout_json')->nullable();
            $table->json('notification_json')->nullable();
            $table->json('social_json')->nullable();
            $table->timestamps();
        });

        Schema::create('products', function (Blueprint $table): void {
            $table->id();
            $table->foreignId('store_id')->constrained()->cascadeOnDelete();
            $table->string('title');
            $table->string('handle');
            $table->text('description')->nullable();
            $table->string('vendor')->nullable()->index();
            $table->string('product_type')->nullable()->index();
            $table->json('tags')->nullable();
            $table->string('status')->default('draft')->index();
            $table->timestamp('published_at')->nullable();
            $table->unsignedInteger('sales_count')->default(0);
            $table->json('metadata')->nullable();
            $table->timestamps();
            $table->unique(['store_id', 'handle']);
            $table->index(['store_id', 'status']);
            $table->index(['store_id', 'published_at']);
        });

        Schema::create('product_options', function (Blueprint $table): void {
            $table->id();
            $table->foreignId('product_id')->constrained()->cascadeOnDelete();
            $table->string('name');
            $table->unsignedInteger('position')->default(0);
            $table->timestamps();
            $table->unique(['product_id', 'position']);
        });

        Schema::create('product_option_values', function (Blueprint $table): void {
            $table->id();
            $table->foreignId('product_option_id')->constrained()->cascadeOnDelete();
            $table->string('value');
            $table->unsignedInteger('position')->default(0);
            $table->timestamps();
            $table->unique(['product_option_id', 'position']);
        });

        Schema::create('product_variants', function (Blueprint $table): void {
            $table->id();
            $table->foreignId('product_id')->constrained()->cascadeOnDelete();
            $table->string('title')->default('Default');
            $table->string('sku')->nullable()->index();
            $table->string('barcode')->nullable()->index();
            $table->unsignedInteger('price_amount');
            $table->unsignedInteger('compare_at_amount')->nullable();
            $table->unsignedInteger('cost_amount')->nullable();
            $table->unsignedInteger('weight_grams')->default(0);
            $table->boolean('requires_shipping')->default(true);
            $table->boolean('is_default')->default(false);
            $table->unsignedInteger('position')->default(0);
            $table->json('metadata')->nullable();
            $table->timestamps();
            $table->index(['product_id', 'position']);
            $table->index(['product_id', 'is_default']);
        });

        Schema::create('variant_option_values', function (Blueprint $table): void {
            $table->foreignId('variant_id')->constrained('product_variants')->cascadeOnDelete();
            $table->foreignId('product_option_value_id')->constrained()->cascadeOnDelete();
            $table->primary(['variant_id', 'product_option_value_id']);
            $table->index('product_option_value_id');
        });

        Schema::create('inventory_items', function (Blueprint $table): void {
            $table->id();
            $table->foreignId('store_id')->constrained()->cascadeOnDelete();
            $table->foreignId('variant_id')->unique()->constrained('product_variants')->cascadeOnDelete();
            $table->integer('quantity_on_hand')->default(0);
            $table->integer('quantity_reserved')->default(0);
            $table->string('policy')->default('deny');
            $table->timestamps();
            $table->index('store_id');
        });

        Schema::create('collections', function (Blueprint $table): void {
            $table->id();
            $table->foreignId('store_id')->constrained()->cascadeOnDelete();
            $table->string('title');
            $table->string('handle');
            $table->text('description')->nullable();
            $table->string('status')->default('active')->index();
            $table->string('image_url')->nullable();
            $table->timestamps();
            $table->unique(['store_id', 'handle']);
            $table->index(['store_id', 'status']);
        });

        Schema::create('collection_products', function (Blueprint $table): void {
            $table->foreignId('collection_id')->constrained()->cascadeOnDelete();
            $table->foreignId('product_id')->constrained()->cascadeOnDelete();
            $table->unsignedInteger('position')->default(0);
            $table->primary(['collection_id', 'product_id']);
            $table->index('product_id');
            $table->index(['collection_id', 'position']);
        });

        Schema::create('product_media', function (Blueprint $table): void {
            $table->id();
            $table->foreignId('product_id')->constrained()->cascadeOnDelete();
            $table->string('path');
            $table->string('url')->nullable();
            $table->string('alt_text')->nullable();
            $table->string('status')->default('ready');
            $table->unsignedInteger('position')->default(0);
            $table->json('metadata')->nullable();
            $table->timestamps();
            $table->index(['product_id', 'position']);
            $table->index('status');
        });

        Schema::create('themes', function (Blueprint $table): void {
            $table->id();
            $table->foreignId('store_id')->constrained()->cascadeOnDelete();
            $table->string('name');
            $table->string('status')->default('draft')->index();
            $table->string('version')->default('1.0.0');
            $table->json('settings')->nullable();
            $table->timestamps();
            $table->index(['store_id', 'status']);
        });

        Schema::create('theme_files', function (Blueprint $table): void {
            $table->id();
            $table->foreignId('theme_id')->constrained()->cascadeOnDelete();
            $table->string('path');
            $table->longText('content')->nullable();
            $table->timestamps();
            $table->unique(['theme_id', 'path']);
        });

        Schema::create('theme_settings', function (Blueprint $table): void {
            $table->id();
            $table->foreignId('theme_id')->constrained()->cascadeOnDelete();
            $table->string('key');
            $table->json('value')->nullable();
            $table->timestamps();
            $table->unique(['theme_id', 'key']);
        });

        Schema::create('pages', function (Blueprint $table): void {
            $table->id();
            $table->foreignId('store_id')->constrained()->cascadeOnDelete();
            $table->string('title');
            $table->string('handle');
            $table->longText('content')->nullable();
            $table->string('status')->default('draft')->index();
            $table->timestamp('published_at')->nullable();
            $table->timestamps();
            $table->unique(['store_id', 'handle']);
        });

        Schema::create('navigation_menus', function (Blueprint $table): void {
            $table->id();
            $table->foreignId('store_id')->constrained()->cascadeOnDelete();
            $table->string('name');
            $table->string('handle');
            $table->timestamps();
            $table->unique(['store_id', 'handle']);
        });

        Schema::create('navigation_items', function (Blueprint $table): void {
            $table->id();
            $table->foreignId('navigation_menu_id')->constrained('navigation_menus')->cascadeOnDelete();
            $table->string('label');
            $table->string('type')->default('link');
            $table->string('url')->nullable();
            $table->unsignedBigInteger('resource_id')->nullable();
            $table->unsignedInteger('position')->default(0);
            $table->foreignId('parent_id')->nullable()->constrained('navigation_items')->nullOnDelete();
            $table->timestamps();
            $table->index(['navigation_menu_id', 'position']);
        });

        Schema::create('search_settings', function (Blueprint $table): void {
            $table->foreignId('store_id')->primary()->constrained()->cascadeOnDelete();
            $table->json('synonyms')->nullable();
            $table->json('stopwords')->nullable();
            $table->boolean('enabled')->default(true);
            $table->timestamps();
        });

        Schema::create('search_queries', function (Blueprint $table): void {
            $table->id();
            $table->foreignId('store_id')->constrained()->cascadeOnDelete();
            $table->string('query');
            $table->unsignedInteger('results_count')->default(0);
            $table->foreignId('customer_id')->nullable()->constrained()->nullOnDelete();
            $table->timestamps();
            $table->index(['store_id', 'created_at']);
            $table->index(['store_id', 'query']);
        });


text:
Script completed
Wall time 0.1 seconds
Output:

text:
**Error responses:**
- `422` - Validation errors (invalid event types, too many events, etc.)

---

## 3. Admin REST API

**Prefix:** `/api/admin/v1`
**Middleware:** `auth:sanctum`, `store.resolve`, `throttle:api.admin`
**Content-Type:** `application/json`

These endpoints provide programmatic access for integrations, CLI tools, and third-party apps. The admin web UI uses Livewire actions directly and does not call these endpoints.

All endpoints require a valid Sanctum API token with appropriate scopes. Responses follow Laravel's API Resource conventions.

---

### 3.1 Platform Management (Super-Admin Only)

These endpoints are restricted to users with the `owner` (platform) role.

#### POST /api/admin/v1/platform/organizations

Create a new organization.

- **Authentication:** Sanctum token with `manage-platform` scope
- **Rate limit:** `api.admin` (60/min per token)

**Request body:**

```json
{
  "name": "Acme Corp",
  "billing_email": "billing@acme.com"
}
```

| Field | Type | Required | Validation |
|-------|------|----------|------------|
| `name` | string | Yes | Max: 255 |
| `billing_email` | string | Yes | Valid email, max: 255 |

**Success response:** `201 Created`

```json
{
  "data": {
    "id": 1,
    "name": "Acme Corp",
    "billing_email": "billing@acme.com",
    "created_at": "2026-02-11T10:00:00Z",
    "updated_at": "2026-02-11T10:00:00Z"
  }
}
```

**Error responses:**
- `403` - Insufficient permissions
- `422` - Validation errors

---

#### POST /api/admin/v1/platform/stores

Create a new store within an organization.

- **Authentication:** Sanctum token with `manage-platform` scope
- **Rate limit:** `api.admin` (60/min per token)

**Request body:**

```json
{
  "organization_id": 1,
  "name": "Acme Store",
  "handle": "acme",
  "default_currency": "EUR",
  "default_locale": "en",
  "timezone": "Europe/Berlin"
}
```

| Field | Type | Required | Validation |
|-------|------|----------|------------|
| `organization_id` | integer | Yes | Must exist |
| `name` | string | Yes | Max: 255 |
| `handle` | string | Yes | Unique, lowercase, alphanumeric with hyphens. Max: 63. Regex: `^[a-z0-9]([a-z0-9-]*[a-z0-9])?$` |
| `default_currency` | string | Yes | Valid ISO 4217 code |
| `default_locale` | string | Yes | Valid locale code (e.g., `en`, `de`, `fr`) |
| `timezone` | string | Yes | Valid IANA timezone |

**Success response:** `201 Created`

```json
{
  "data": {
    "id": 1,
    "organization_id": 1,
    "name": "Acme Store",
    "handle": "acme",
    "status": "active",
    "default_currency": "EUR",
    "default_locale": "en",
    "timezone": "Europe/Berlin",
    "created_at": "2026-02-11T10:00:00Z"
  }
}
```

**Error responses:**
- `403` - Insufficient permissions
- `422` - Validation errors (duplicate handle, invalid timezone, etc.)

---

#### POST /api/admin/v1/stores/{storeId}/invites

Invite a user to a store with a specific role.

- **Authentication:** Sanctum token with `manage-platform` scope
- **Rate limit:** `api.admin` (60/min per token)

**Request body:**

```json
{
  "email": "staff@acme.com",
  "role": "staff"
}
```

| Field | Type | Required | Validation |
|-------|------|----------|------------|
| `email` | string | Yes | Valid email, max: 255 |
| `role` | string | Yes | One of: `owner`, `admin`, `staff`, `support` |

**Success response:** `201 Created`

```json
{
  "data": {
    "email": "staff@acme.com",
    "role": "staff",
    "invited_at": "2026-02-11T10:00:00Z",
    "expires_at": "2026-02-18T10:00:00Z"
  }
}
```

**Error responses:**
- `403` - Insufficient permissions (only `owner` and `admin` can invite)
- `409` - User already a member of this store
- `422` - Validation errors

---

#### GET /api/admin/v1/stores/{storeId}/me

Get the current user's membership details for a store.

- **Authentication:** Sanctum token
- **Rate limit:** `api.admin` (60/min per token)

**Success response:** `200 OK`

```json
{
  "data": {
    "user_id": 3,
    "store_id": 1,
    "role": "admin",
    "email": "admin@acme.com",
    "name": "Jane Admin",
    "permissions": [
      "read-products",
      "write-products",
      "read-orders",
      "write-orders",
      "read-settings",
      "write-settings",
      "read-customers"
    ]
  }
}
```

**Error responses:**
- `403` - Not a member of this store
- `404` - Store not found

---

### 3.2 Products

#### Admin Products Endpoint Summary

| Method | Path | Auth Scope | Description | Rate Limit |
|--------|------|------------|-------------|------------|
| GET | `/api/admin/v1/stores/{storeId}/products` | `read-products` | List products with filtering and pagination | `api.admin` (60/min) |
| POST | `/api/admin/v1/stores/{storeId}/products` | `write-products` | Create a new product | `api.admin` (60/min) |
| GET | `/api/admin/v1/stores/{storeId}/products/{productId}` | `read-products` | Get single product with all relations | `api.admin` (60/min) |
| PUT | `/api/admin/v1/stores/{storeId}/products/{productId}` | `write-products` | Update a product (partial updates supported) | `api.admin` (60/min) |
| DELETE | `/api/admin/v1/stores/{storeId}/products/{productId}` | `write-products` | Archive a product (soft delete) | `api.admin` (60/min) |
| POST | `/api/admin/v1/stores/{storeId}/products/{productId}/media/presign-upload` | `write-products` | Generate presigned URL for media upload | `api.admin` (60/min) |

    "last_page": 2
  }
}
```

---

#### POST /api/admin/v1/stores/{storeId}/products

Create a new product.

**Request body:**

```json
{
  "title": "Classic T-Shirt",
  "handle": "classic-t-shirt",
  "description_html": "
A comfortable cotton t-shirt.
",
  "vendor": "Acme Apparel",
  "product_type": "Apparel",
  "status": "draft",
  "tags": ["organic", "cotton"],
  "options": [
    { "name": "Color", "position": 1 },
    { "name": "Size", "position": 2 }
  ],
  "variants": [
    {
      "sku": "TSH-BLU-S",
      "barcode": "1234567890123",
      "price_amount": 2500,
      "compare_at_amount": 3500,
      "currency": "EUR",
      "weight_g": 200,
      "requires_shipping": true,
      "is_default": true,
      "position": 1,
      "status": "active",
      "option_values": [
        { "option_name": "Color", "value": "Blue" },
        { "option_name": "Size", "value": "Small" }
      ],
      "inventory": {
        "quantity_on_hand": 50,
        "policy": "deny"
      }
    }
  ],
  "collections": [5]
}
```

**Product fields:**

| Field | Type | Required | Validation |
|-------|------|----------|------------|
| `title` | string | Yes | Max: 255 |
| `handle` | string | No | Auto-generated from title if not provided. Unique per store. Lowercase, hyphens only. Max: 255. |
| `description_html` | string | No | Max: 65535 |
| `vendor` | string | No | Max: 255 |
| `product_type` | string | No | Max: 255 |
| `status` | string | No | One of: `draft`, `active`. Default: `draft`. |
| `tags` | array of strings | No | Max: 50 tags, each max: 255. |
| `options` | array | No | Max: 3 options. |
| `options[].name` | string | Yes | Max: 255 |
| `options[].position` | integer | Yes | Min: 1, max: 3 |
| `collections` | array of integers | No | Collection IDs to add this product to. |

**Variant fields:**

| Field | Type | Required | Validation |
|-------|------|----------|------------|
| `variants` | array | Yes | At least one variant required. Max: 100. |
| `variants[].sku` | string | Yes | Max: 255. Unique per store (`store_id`, `sku`). |
| `variants[].barcode` | string | No | Max: 255 |
| `variants[].price_amount` | integer | Yes | Min: 0 (in minor currency units) |
| `variants[].compare_at_amount` | integer | No | Min: 0. Must be > price_amount if set. |
| `variants[].currency` | string | No | Defaults to store currency. |
| `variants[].weight_g` | integer | No | Min: 0 |
| `variants[].requires_shipping` | boolean | No | Default: true |
| `variants[].is_default` | boolean | No | Exactly one variant must be default. |
| `variants[].position` | integer | No | Auto-assigned if not provided. |
| `variants[].status` | string | No | One of: `active`, `archived`. Default: `active`. |
| `variants[].option_values` | array | No | Must match defined options. |
| `variants[].inventory.quantity_on_hand` | integer | No | Default: 0. Min: 0. |
| `variants[].inventory.policy` | string | No | One of: `deny`, `continue`. Default: `deny`. |

**Success response:** `201 Created`

Returns the full product resource with nested variants, options, and media.

**Error responses:**
- `403` - Insufficient permissions
- `422` - Validation errors (duplicate SKU, duplicate handle, missing required fields)

---

#### GET /api/admin/v1/stores/{storeId}/products/{productId}

Get a single product with all variants, options, media, and collections.

**Success response:** `200 OK`

```json
{
  "data": {
    "id": 10,
    "store_id": 1,
    "title": "Classic T-Shirt",
    "handle": "classic-t-shirt",
    "description_html": "
A comfortable cotton t-shirt.
",
    "vendor": "Acme Apparel",
    "product_type": "Apparel",
    "status": "active",
    "tags": ["organic", "cotton"],
    "published_at": "2026-02-10T08:00:00Z",
    "created_at": "2026-02-09T14:00:00Z",
    "updated_at": "2026-02-11T09:00:00Z",
    "options": [
      {
        "id": 1,
        "name": "Color",
        "position": 1,
        "values": [
          { "id": 1, "value": "Blue", "position": 1 },
          { "id": 2, "value": "Red", "position": 2 }
        ]
      },
      {
        "id": 2,
        "name": "Size",
        "position": 2,
        "values": [
          { "id": 3, "value": "Small", "position": 1 },
          { "id": 4, "value": "Medium", "position": 2 },
          { "id": 5, "value": "Large", "position": 3 }
        ]
      }
    ],
    "variants": [
      {
        "id": 101,
        "sku": "TSH-BLU-S",
        "barcode": "1234567890123",
        "price_amount": 2500,
        "compare_at_amount": 3500,
        "currency": "EUR",
        "weight_g": 200,
        "requires_shipping": true,
        "is_default": true,
        "position": 1,
        "status": "active",
        "option_values": [
          { "option_name": "Color", "value": "Blue" },
          { "option_name": "Size", "value": "Small" }
        ],
        "inventory": {
          "quantity_on_hand": 50,
          "quantity_reserved": 2,
          "policy": "deny"
        }
      }
    ],
    "media": [
      {
        "id": 1,
        "type": "image",
        "storage_key": "stores/1/products/10/main.jpg",
        "url": "https://cdn.example.com/stores/1/products/10/main.jpg",
        "alt_text": "Classic T-Shirt in Blue",
        "width": 1200,
        "height": 1600,
        "mime_type": "image/jpeg",
        "byte_size": 245000,
        "position": 1,
        "status": "ready"
      }
    ],
    "collections": [
      {
        "id": 5,
        "title": "Summer Collection",
        "handle": "summer-collection"
      }
    ]
  }
}
```

**Error responses:**
- `403` - Insufficient permissions
- `404` - Product not found (or belongs to another store)

---

#### PUT /api/admin/v1/stores/{storeId}/products/{productId}

Update a product. Supports partial updates.

Same fields as POST. Only provided fields are updated. Variants can be added, updated (by ID), or removed.

**Example partial update request:**

```json
{
  "title": "Classic Cotton T-Shirt",
  "status": "active",
  "tags": ["organic", "cotton", "bestseller"]
}
```

**Success response:** `200 OK`

Returns the full updated product resource.

**Error responses:**
- `403` - Insufficient permissions
- `404` - Product not found
- `422` - Validation errors

---

#### DELETE /api/admin/v1/stores/{storeId}/products/{productId}

Archive a product (soft delete). Products with existing orders cannot be hard-deleted.

**Success response:** `200 OK`

```json
{
  "data": {
    "id": 10,
    "status": "archived",
    "updated_at": "2026-02-11T11:00:00Z"
  }
}
```

**Error responses:**
- `403` - Insufficient permissions
- `404` - Product not found

---

#### POST /api/admin/v1/stores/{storeId}/products/{productId}/media/presign-upload

Generate a presigned URL for uploading product media directly to object storage (S3).

**Request body:**

```json
{
  "filename": "tshirt-front.jpg",
  "content_type": "image/jpeg",
  "byte_size": 245000
}
```

| Field | Type | Required | Validation |
|-------|------|----------|------------|
| `filename` | string | Yes | Max: 255. Must have a valid extension. |
| `content_type` | string | Yes | One of: `image/jpeg`, `image/png`, `image/webp`, `image/avif`, `video/mp4`. |
| `byte_size` | integer | Yes | Min: 1, max: configurable (default 50MB for images, 500MB for video). |

**Success response:** `201 Created`

```json
{
  "upload_url": "https://s3.example.com/...presigned...",
  "method": "PUT",
  "headers": {
    "Content-Type": "image/jpeg"
  },
  "storage_key": "stores/1/products/10/media/uuid.jpg",
  "media_id": 2,
  "expires_at": "2026-02-11T10:10:00Z"
}
```

**Error responses:**
- `403` - Insufficient permissions
- `404` - Product not found
- `422` - Validation errors (invalid content type, file too large)

---

### 3.3 Collections

#### Admin Collections Endpoint Summary

| Method | Path | Auth Scope | Description | Rate Limit |
|--------|------|------------|-------------|------------|
| GET | `/api/admin/v1/stores/{storeId}/collections` | `read-collections` | List collections | `api.admin` (60/min) |
| POST | `/api/admin/v1/stores/{storeId}/collections` | `write-collections` | Create a collection | `api.admin` (60/min) |
| PUT | `/api/admin/v1/stores/{storeId}/collections/{collectionId}` | `write-collections` | Update a collection | `api.admin` (60/min) |
| DELETE | `/api/admin/v1/stores/{storeId}/collections/{collectionId}` | `write-collections` | Delete a collection | `api.admin` (60/min) |

**Error responses:**
- `403` - Insufficient permissions
- `404` - Export not found

---

## 4. Webhook Receiver Endpoints

> **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).

---

## 5. OAuth Endpoints (App Ecosystem)

> **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.

These endpoints implement OAuth2 authorization for third-party apps, powered by Laravel Passport.

---

### GET /oauth/authorize

Display the authorization prompt to the merchant.

- **Middleware:** `web`, `auth`

**Query parameters:**

| Parameter | Type | Required | Description |
|-----------|------|----------|-------------|
| `client_id` | string | Yes | The app's OAuth client ID |
| `redirect_uri` | string | Yes | Must match one of the registered redirect URIs |
| `response_type` | string | Yes | Must be `code` |
| `scope` | string | Yes | Space-separated list of requested scopes |
| `state` | string | Yes | CSRF token for the client |

**Flow:** Merchant reviews requested scopes and approves/denies. On approval, redirects to `redirect_uri` with `code` and `state` parameters. Authorization code is single-use.

---

### POST /oauth/token

Exchange an authorization code for access and refresh tokens.

- **Middleware:** None (public endpoint)

**Authorization code exchange request:**

```json
{
  "grant_type": "authorization_code",
  "client_id": "app_client_abc123",
  "client_secret": "secret_xyz",
  "redirect_uri": "https://myapp.example.com/callback",
  "code": "auth_code_abc123"
}
```

**Success response:** `200 OK`

```json
{
  "token_type": "Bearer",
  "expires_in": 3600,
  "access_token": "eyJ...",
  "refresh_token": "def..."
}
```

**Error responses:**
- `400` - Invalid grant (code already used, expired, etc.)
- `401` - Invalid client credentials

**Refresh token request:**

```json
{
  "grant_type": "refresh_token",
  "client_id": "app_client_abc123",
  "client_secret": "secret_xyz",
  "refresh_token": "def..."
}
```

---

### App API Endpoints

Third-party apps use OAuth tokens to access store data via scoped endpoints.

| Method | Path | Scope Required | Description |
|--------|------|----------------|-------------|
| GET | `/api/apps/v1/stores/{storeId}/products` | `read-products` | List products (same schema as admin API) |
| GET | `/api/apps/v1/stores/{storeId}/orders` | `read-orders` | List orders |
| GET | `/api/apps/v1/stores/{storeId}/customers` | `read-customers` | List customers |

Token scopes are enforced on every endpoint. Requests with insufficient scopes return `403 Forbidden`.

---

## 6. Middleware Stack

### 6.1 Route Group Middleware

#### Web (default)

Applied to all web routes via the `web` middleware group:

<?php

namespace App\Models;

use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\HasMany;

class Organization extends Model
{
    use HasFactory;

    protected $fillable = ['name', 'slug', 'billing_email', 'status'];

    public function stores(): HasMany
    {
        return $this->hasMany(Store::class);
    }
}
<?php

namespace App\Models;

use App\Enums\StoreStatus;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\BelongsToMany;
use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Database\Eloquent\Relations\HasOne;

class Store extends Model
{
    use HasFactory;

    protected $fillable = ['organization_id', 'name', 'handle', 'status', 'default_currency', 'default_locale', 'timezone', 'primary_domain', 'metadata'];

    protected $attributes = [
        'status' => StoreStatus::Active->value,
        'default_currency' => 'USD',
        'default_locale' => 'en',
        'timezone' => 'UTC',
    ];

    protected function casts(): array
    {
        return ['status' => StoreStatus::class, 'metadata' => 'array'];
    }

    public function organization(): BelongsTo
    {
        return $this->belongsTo(Organization::class);
    }

    public function domains(): HasMany
    {
        return $this->hasMany(StoreDomain::class);
    }

    public function users(): BelongsToMany
    {
        return $this->belongsToMany(User::class, 'store_users')->using(StoreUser::class)->withPivot('role')->withTimestamps();
    }

    public function settings(): HasOne
    {
        return $this->hasOne(StoreSettings::class);
    }

    public function isActive(): bool
    {
        return $this->status === StoreStatus::Active;
    }
}
<?php

namespace App\Models;

use App\Enums\StoreUserRole;
use Illuminate\Database\Eloquent\Factories\HasFactory;
// use Illuminate\Contracts\Auth\MustVerifyEmail;
use Illuminate\Database\Eloquent\Relations\BelongsToMany;
use Illuminate\Foundation\Auth\User as Authenticatable;
use Illuminate\Notifications\Notifiable;
use Illuminate\Support\Str;
use Laravel\Fortify\TwoFactorAuthenticatable;
use Laravel\Sanctum\HasApiTokens;

class User extends Authenticatable
{
    /** @use HasFactory<\Database\Factories\UserFactory> */
    use HasApiTokens, HasFactory, Notifiable, TwoFactorAuthenticatable;

    /**
     * The attributes that are mass assignable.
     *
     * @var list<string>
     */
    protected $fillable = [
        'name',
        'email',
        'password',
        'password_hash',
        'status',
        'last_login_at',
    ];

    /**
     * The attributes that should be hidden for serialization.
     *
     * @var list<string>
     */
    protected $hidden = [
        'password',
        'password_hash',
        'two_factor_secret',
        'two_factor_recovery_codes',
        'remember_token',
    ];

    /**
     * Get the attributes that should be cast.
     *
     * @return array<string, string>
     */
    protected function casts(): array
    {
        return [
            'email_verified_at' => 'datetime',
            'password' => 'hashed',
            'last_login_at' => 'datetime',
        ];
    }

    protected static function booted(): void
    {
        static::saving(function (User $user): void {
            if ($user->isDirty('password')) {
                $user->password_hash = $user->password;
            } elseif ($user->isDirty('password_hash')) {
                $user->password = $user->password_hash;
            }
        });
    }

    public function stores(): BelongsToMany
    {
        return $this->belongsToMany(Store::class, 'store_users')->using(StoreUser::class)->withPivot('role')->withTimestamps();
    }

    public function roleForStore(Store $store): ?StoreUserRole
    {
        $pivot = $this->stores()->where('stores.id', $store->getKey())->first()?->pivot;

        return $pivot?->role instanceof StoreUserRole ? $pivot->role : ($pivot?->role ? StoreUserRole::tryFrom($pivot->role) : null);
    }

    public function canManageStore(Store $store): bool
    {
        return in_array($this->roleForStore($store), [StoreUserRole::Owner, StoreUserRole::Admin], true);
    }

    /**
     * Get the user's initials
     */
    public function initials(): string
    {
        return Str::of($this->name)
            ->explode(' ')
            ->take(2)
            ->map(fn ($word) => Str::substr($word, 0, 1))
            ->implode('');
    }
}
<?php

namespace App\Models;

use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;

class ProductMedia extends Model
{
    protected $fillable = ['product_id', 'type', 'path', 'storage_key', 'url', 'alt_text', 'width', 'height', 'mime_type', 'byte_size', 'checksum', 'status', 'position', 'metadata'];

    protected function casts(): array
    {
        return ['metadata' => 'array'];
    }

    public function product(): BelongsTo
    {
        return $this->belongsTo(Product::class);
    }
}
<?php

use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;

return new class extends Migration
{
    public function up(): void
    {
        Schema::create('organizations', function (Blueprint $table): void {
            $table->id();
            $table->string('name');
            $table->string('slug')->unique();
            $table->string('billing_email')->nullable()->index();
            $table->string('status')->default('active')->index();
            $table->timestamps();
        });

        Schema::create('stores', function (Blueprint $table): void {
            $table->id();
            $table->foreignId('organization_id')->constrained()->cascadeOnDelete();
            $table->string('name');
            $table->string('handle')->unique();
            $table->string('status')->default('active')->index();
            $table->string('default_currency', 3)->default('EUR');
            $table->string('default_locale', 10)->default('en');
            $table->string('timezone')->default('UTC');
            $table->string('primary_domain')->nullable();
            $table->json('metadata')->nullable();
            $table->timestamps();
            $table->index(['organization_id', 'status']);
        });

        Schema::create('store_domains', function (Blueprint $table): void {
            $table->id();
            $table->foreignId('store_id')->constrained()->cascadeOnDelete();
            $table->string('hostname')->unique();
            $table->string('type')->default('storefront');
            $table->boolean('is_primary')->default(false);
            $table->timestamps();
            $table->index(['store_id', 'is_primary']);
        });

        Schema::table('users', function (Blueprint $table): void {
            $table->string('status')->default('active')->index();
            $table->timestamp('last_login_at')->nullable();
        });

        Schema::create('store_users', function (Blueprint $table): void {
            $table->foreignId('store_id')->constrained()->cascadeOnDelete();
            $table->foreignId('user_id')->constrained()->cascadeOnDelete();
            $table->string('role')->default('staff');
            $table->timestamps();
            $table->primary(['store_id', 'user_id']);
            $table->index('user_id');
            $table->index(['store_id', 'role']);
        });

        Schema::create('store_settings', function (Blueprint $table): void {
            $table->foreignId('store_id')->primary()->constrained()->cascadeOnDelete();
            $table->json('general_json')->nullable();
            $table->json('checkout_json')->nullable();
            $table->json('notification_json')->nullable();
            $table->json('social_json')->nullable();
            $table->timestamps();
        });

        Schema::create('products', function (Blueprint $table): void {
            $table->id();
            $table->foreignId('store_id')->constrained()->cascadeOnDelete();
            $table->string('title');
            $table->string('handle');
            $table->text('description')->nullable();
            $table->string('vendor')->nullable()->index();
            $table->string('product_type')->nullable()->index();
            $table->json('tags')->nullable();
            $table->string('status')->default('draft')->index();
            $table->timestamp('published_at')->nullable();
            $table->unsignedInteger('sales_count')->default(0);
            $table->json('metadata')->nullable();
            $table->timestamps();
            $table->unique(['store_id', 'handle']);
            $table->index(['store_id', 'status']);
            $table->index(['store_id', 'published_at']);
        });

        Schema::create('product_options', function (Blueprint $table): void {
            $table->id();
            $table->foreignId('product_id')->constrained()->cascadeOnDelete();
            $table->string('name');
            $table->unsignedInteger('position')->default(0);
            $table->timestamps();
            $table->unique(['product_id', 'position']);
        });

        Schema::create('product_option_values', function (Blueprint $table): void {
            $table->id();
            $table->foreignId('product_option_id')->constrained()->cascadeOnDelete();
            $table->string('value');
            $table->unsignedInteger('position')->default(0);
            $table->timestamps();
            $table->unique(['product_option_id', 'position']);
        });

        Schema::create('product_variants', function (Blueprint $table): void {
            $table->id();
            $table->foreignId('product_id')->constrained()->cascadeOnDelete();
            $table->string('title')->default('Default');
            $table->string('sku')->nullable()->index();
            $table->string('barcode')->nullable()->index();
            $table->unsignedInteger('price_amount');
            $table->unsignedInteger('compare_at_amount')->nullable();
            $table->unsignedInteger('cost_amount')->nullable();
            $table->unsignedInteger('weight_grams')->default(0);
            $table->boolean('requires_shipping')->default(true);
            $table->boolean('is_default')->default(false);
            $table->unsignedInteger('position')->default(0);
            $table->json('metadata')->nullable();
            $table->timestamps();
            $table->index(['product_id', 'position']);
            $table->index(['product_id', 'is_default']);
        });

        Schema::create('variant_option_values', function (Blueprint $table): void {
            $table->foreignId('variant_id')->constrained('product_variants')->cascadeOnDelete();
            $table->foreignId('product_option_value_id')->constrained()->cascadeOnDelete();
            $table->primary(['variant_id', 'product_option_value_id']);
            $table->index('product_option_value_id');
        });

        Schema::create('inventory_items', function (Blueprint $table): void {
            $table->id();
            $table->foreignId('store_id')->constrained()->cascadeOnDelete();
            $table->foreignId('variant_id')->unique()->constrained('product_variants')->cascadeOnDelete();
            $table->integer('quantity_on_hand')->default(0);
            $table->integer('quantity_reserved')->default(0);
            $table->string('policy')->default('deny');
            $table->timestamps();
            $table->index('store_id');
        });

        Schema::create('collections', function (Blueprint $table): void {
            $table->id();
            $table->foreignId('store_id')->constrained()->cascadeOnDelete();
            $table->string('title');
            $table->string('handle');
            $table->text('description')->nullable();
            $table->string('status')->default('active')->index();
            $table->string('image_url')->nullable();
            $table->timestamps();
            $table->unique(['store_id', 'handle']);
            $table->index(['store_id', 'status']);
        });

        Schema::create('collection_products', function (Blueprint $table): void {
            $table->foreignId('collection_id')->constrained()->cascadeOnDelete();
            $table->foreignId('product_id')->constrained()->cascadeOnDelete();
            $table->unsignedInteger('position')->default(0);
            $table->primary(['collection_id', 'product_id']);
            $table->index('product_id');
            $table->index(['collection_id', 'position']);
        });

        Schema::create('product_media', function (Blueprint $table): void {
            $table->id();
            $table->foreignId('product_id')->constrained()->cascadeOnDelete();
            $table->string('path');
            $table->string('url')->nullable();
            $table->string('alt_text')->nullable();
            $table->string('status')->default('ready');
            $table->unsignedInteger('position')->default(0);
            $table->json('metadata')->nullable();
            $table->timestamps();
            $table->index(['product_id', 'position']);
            $table->index('status');
        });

        Schema::create('themes', function (Blueprint $table): void {
            $table->id();
            $table->foreignId('store_id')->constrained()->cascadeOnDelete();
            $table->string('name');
            $table->string('status')->default('draft')->index();
            $table->string('version')->default('1.0.0');
            $table->json('settings')->nullable();
            $table->timestamps();
            $table->index(['store_id', 'status']);
        });

        Schema::create('theme_files', function (Blueprint $table): void {
            $table->id();
            $table->foreignId('theme_id')->constrained()->cascadeOnDelete();
            $table->string('path');
            $table->longText('content')->nullable();
            $table->timestamps();
            $table->unique(['theme_id', 'path']);
        });

        Schema::create('theme_settings', function (Blueprint $table): void {
            $table->id();
            $table->foreignId('theme_id')->constrained()->cascadeOnDelete();
            $table->string('key');
            $table->json('value')->nullable();
            $table->timestamps();
            $table->unique(['theme_id', 'key']);
        });

        Schema::create('pages', function (Blueprint $table): void {
            $table->id();
            $table->foreignId('store_id')->constrained()->cascadeOnDelete();
            $table->string('title');
            $table->string('handle');
            $table->longText('content')->nullable();
            $table->string('status')->default('draft')->index();
            $table->timestamp('published_at')->nullable();
            $table->timestamps();
            $table->unique(['store_id', 'handle']);
        });

        Schema::create('navigation_menus', function (Blueprint $table): void {
            $table->id();
            $table->foreignId('store_id')->constrained()->cascadeOnDelete();
            $table->string('name');
            $table->string('handle');
            $table->timestamps();
            $table->unique(['store_id', 'handle']);
        });

        Schema::create('navigation_items', function (Blueprint $table): void {
            $table->id();
            $table->foreignId('navigation_menu_id')->constrained('navigation_menus')->cascadeOnDelete();
            $table->string('label');
            $table->string('type')->default('link');
            $table->string('url')->nullable();
            $table->unsignedBigInteger('resource_id')->nullable();
            $table->unsignedInteger('position')->default(0);
            $table->foreignId('parent_id')->nullable()->constrained('navigation_items')->nullOnDelete();
            $table->timestamps();
            $table->index(['navigation_menu_id', 'position']);
        });

        Schema::create('search_settings', function (Blueprint $table): void {
            $table->foreignId('store_id')->primary()->constrained()->cascadeOnDelete();
            $table->json('synonyms')->nullable();
            $table->json('stopwords')->nullable();
            $table->boolean('enabled')->default(true);
            $table->timestamps();
        });

        Schema::create('search_queries', function (Blueprint $table): void {
            $table->id();
            $table->foreignId('store_id')->constrained()->cascadeOnDelete();
            $table->string('query');
            $table->unsignedInteger('results_count')->default(0);
            $table->foreignId('customer_id')->nullable()->constrained()->nullOnDelete();
            $table->timestamps();
            $table->index(['store_id', 'created_at']);
            $table->index(['store_id', 'query']);
        });


exec

status: completed

input:
const r = await tools.exec_command({
  cmd: "for f in app/Models/Product.php app/Models/ProductVariant.php app/Models/ProductOption.php app/Models/ProductOptionValue.php app/Models/Collection.php app/Models/StoreDomain.php app/Models/StoreUser.php app/Jobs/ProcessMediaUpload.php app/Models/WebhookSubscription.php database/migrations/*webhook*; do echo \"--- $f\"; sed -n '1,260p' \"$f\"; done",
  workdir: "/Users/wesner/Herd/shop",
  yield_time_ms: 10000,
  max_output_tokens: 40000
});
text(r.output);


type: CommandExecution

command: for f in app/Models/Product.php app/Models/ProductVariant.php app/Models/ProductOption.php app/Models/ProductOptionValue.php app/Models/Collection.php app/Models/StoreDomain.php app/Models/StoreUser.php app/Jobs/ProcessMediaUpload.php app/Models/WebhookSubscription.php database/migrations/*webhook*; do echo "--- $f"; sed -n '1,260p' "$f"; done

type: unknown

status: completed

stdout:
--- app/Models/Product.php
<?php

namespace App\Models;

use App\Enums\ProductStatus;
use App\Models\Concerns\BelongsToStore;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsToMany;
use Illuminate\Database\Eloquent\Relations\HasMany;

class Product extends Model
{
    use BelongsToStore, HasFactory;

    protected $fillable = ['store_id', 'title', 'handle', 'description', 'description_html', 'vendor', 'product_type', 'tags', 'status', 'published_at', 'sales_count', 'metadata'];

    protected function casts(): array
    {
        return ['status' => ProductStatus::class, 'tags' => 'array', 'metadata' => 'array', 'published_at' => 'datetime'];
    }

    public function variants(): HasMany
    {
        return $this->hasMany(ProductVariant::class)->orderBy('position');
    }

    public function options(): HasMany
    {
        return $this->hasMany(ProductOption::class)->orderBy('position');
    }

    public function media(): HasMany
    {
        return $this->hasMany(ProductMedia::class)->orderBy('position');
    }

    public function collections(): BelongsToMany
    {
        return $this->belongsToMany(Collection::class, 'collection_products')->withPivot('position');
    }

    public function orders(): BelongsToMany
    {
        return $this->belongsToMany(Order::class, 'order_lines');
    }

    public function scopePublished($query): void
    {
        $query->where('status', ProductStatus::Active)->whereNotNull('published_at')->where('published_at', '<=', now());
    }

    public function defaultVariant(): ?ProductVariant
    {
        return $this->variants->firstWhere('is_default', true) ?? $this->variants->first();
    }
}
--- app/Models/ProductVariant.php
<?php

namespace App\Models;

use App\Enums\VariantStatus;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\BelongsToMany;
use Illuminate\Database\Eloquent\Relations\HasOne;

class ProductVariant extends Model
{
    protected $fillable = ['product_id', 'title', 'sku', 'barcode', 'price_amount', 'compare_at_amount', 'cost_amount', 'currency', 'weight_grams', 'weight_g', 'requires_shipping', 'is_default', 'position', 'status', 'metadata'];

    protected function casts(): array
    {
        return ['requires_shipping' => 'boolean', 'is_default' => 'boolean', 'metadata' => 'array', 'status' => VariantStatus::class];
    }

    public function product(): BelongsTo
    {
        return $this->belongsTo(Product::class);
    }

    public function inventory(): HasOne
    {
        return $this->hasOne(InventoryItem::class, 'variant_id');
    }

    public function optionValues(): BelongsToMany
    {
        return $this->belongsToMany(ProductOptionValue::class, 'variant_option_values', 'variant_id', 'product_option_value_id');
    }

    public function orders(): BelongsToMany
    {
        return $this->belongsToMany(Order::class, 'order_lines', 'variant_id', 'order_id');
    }

    public function availableQuantity(): int
    {
        $inventory = $this->inventory;

        return $inventory ? $inventory->availableQuantity() : 0;
    }
}
--- app/Models/ProductOption.php
<?php

namespace App\Models;

use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\HasMany;

class ProductOption extends Model
{
    protected $fillable = ['product_id', 'name', 'position'];

    public function product(): BelongsTo
    {
        return $this->belongsTo(Product::class);
    }

    public function values(): HasMany
    {
        return $this->hasMany(ProductOptionValue::class)->orderBy('position');
    }
}
--- app/Models/ProductOptionValue.php
<?php

namespace App\Models;

use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\BelongsToMany;

class ProductOptionValue extends Model
{
    protected $fillable = ['product_option_id', 'value', 'position'];

    public function option(): BelongsTo
    {
        return $this->belongsTo(ProductOption::class, 'product_option_id');
    }

    public function variants(): BelongsToMany
    {
        return $this->belongsToMany(ProductVariant::class, 'variant_option_values', 'product_option_value_id', 'variant_id');
    }
}
--- app/Models/Collection.php
<?php

namespace App\Models;

use App\Enums\CollectionStatus;
use App\Models\Concerns\BelongsToStore;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsToMany;

class Collection extends Model
{
    use BelongsToStore;

    protected $fillable = ['store_id', 'title', 'handle', 'description', 'description_html', 'type', 'status', 'image_url'];

    protected function casts(): array
    {
        return ['status' => CollectionStatus::class];
    }

    public function products(): BelongsToMany
    {
        return $this->belongsToMany(Product::class, 'collection_products')->withPivot('position')->orderBy('collection_products.position');
    }
}
--- app/Models/StoreDomain.php
<?php

namespace App\Models;

use App\Enums\StoreDomainType;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;

class StoreDomain extends Model
{
    use HasFactory;

    protected $fillable = ['store_id', 'hostname', 'type', 'is_primary', 'tls_mode'];

    protected function casts(): array
    {
        return ['type' => StoreDomainType::class, 'is_primary' => 'boolean'];
    }

    public function store(): BelongsTo
    {
        return $this->belongsTo(Store::class);
    }
}
--- app/Models/StoreUser.php
<?php

namespace App\Models;

use App\Enums\StoreUserRole;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\Pivot;

class StoreUser extends Pivot
{
    /** @use HasFactory<\Database\Factories\StoreUserFactory> */
    use HasFactory;

    public $incrementing = false;

    protected $table = 'store_users';

    protected $fillable = ['store_id', 'user_id', 'role'];

    protected function casts(): array
    {
        return ['role' => StoreUserRole::class];
    }

    public function store(): BelongsTo
    {
        return $this->belongsTo(Store::class);
    }

    public function user(): BelongsTo
    {
        return $this->belongsTo(User::class);
    }
}
--- app/Jobs/ProcessMediaUpload.php
<?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;
        }
    }
}
--- app/Models/WebhookSubscription.php
<?php

namespace App\Models;

use App\Models\Concerns\BelongsToStore;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\HasMany;

class WebhookSubscription extends Model
{
    use BelongsToStore;

    protected $fillable = ['store_id', 'event', 'event_type', 'target_url', 'app_installation_id', 'signing_secret_encrypted', 'status', 'consecutive_failures'];

    protected $hidden = ['signing_secret_encrypted'];

    protected function casts(): array
    {
        return ['signing_secret_encrypted' => 'encrypted'];
    }

    protected static function booted(): void
    {
        static::saving(function (WebhookSubscription $subscription): void {
            $subscription->event_type = $subscription->event;
        });
    }

    public function deliveries(): HasMany
    {
        return $this->hasMany(WebhookDelivery::class);
    }
}
--- database/migrations/2026_08_20_220001_add_webhook_delivery_status.php
<?php

use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;

return new class extends Migration
{
    public function up(): void
    {
        Schema::table('webhook_deliveries', function (Blueprint $table): void {
            $table->string('status')->default('pending')->after('payload')->index();
        });
    }

    public function down(): void
    {
        Schema::table('webhook_deliveries', function (Blueprint $table): void {
            $table->dropColumn('status');
        });
    }
};
--- database/migrations/2026_08_20_234417_align_theme_settings_password_reset_and_webhook_contracts.php
<?php

use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;

return new class extends Migration
{
    public function up(): void
    {
        $this->migrateThemeSettings();
        $this->migrateCustomerPasswordResetTokens();
        $this->migrateWebhookSubscriptions();
    }

    public function down(): void
    {
        $this->restoreWebhookSubscriptions();
        $this->restoreCustomerPasswordResetTokens();
        $this->restoreThemeSettings();
    }

    private function migrateThemeSettings(): void
    {
        if (! Schema::hasTable('theme_settings') || Schema::hasColumn('theme_settings', 'settings_json')) {
            return;
        }

        Schema::create('theme_settings_contract', function (Blueprint $table): void {
            $table->unsignedBigInteger('theme_id')->primary();
            $table->text('settings_json')->default('{}');
            $table->timestamp('updated_at')->nullable();
            $table->foreign('theme_id')->references('id')->on('themes')->cascadeOnDelete();
        });

        $settingsByTheme = [];
        $updatedAtByTheme = [];

        DB::table('theme_settings')->orderBy('theme_id')->orderBy('id')->get()->each(function (object $row) use (&$settingsByTheme, &$updatedAtByTheme): void {
            $themeId = (int) $row->theme_id;
            $settingsByTheme[$themeId][$row->key] = $this->decodeJson($row->value);

            if ($row->updated_at !== null && (! isset($updatedAtByTheme[$themeId]) || $row->updated_at > $updatedAtByTheme[$themeId])) {
                $updatedAtByTheme[$themeId] = $row->updated_at;
            }
        });

        foreach ($settingsByTheme as $themeId => $settings) {
            DB::table('theme_settings_contract')->insert([
                'theme_id' => $themeId,
                'settings_json' => $this->encodeJsonObject($settings),
                'updated_at' => $updatedAtByTheme[$themeId] ?? null,
            ]);
        }

        $this->replaceTable('theme_settings', 'theme_settings_contract');
    }

    private function restoreThemeSettings(): void
    {
        if (! Schema::hasTable('theme_settings') || ! Schema::hasColumn('theme_settings', 'settings_json')) {
            return;
        }

        Schema::create('theme_settings_legacy', function (Blueprint $table): void {
            $table->id();
            $table->foreignId('theme_id')->constrained()->cascadeOnDelete();
            $table->string('key');
            $table->json('value')->nullable();
            $table->timestamps();
            $table->unique(['theme_id', 'key']);
        });

        DB::table('theme_settings')->orderBy('theme_id')->get()->each(function (object $row): void {
            $settings = $this->decodeJson($row->settings_json);

            if (! is_array($settings)) {
                return;
            }

            foreach ($settings as $key => $value) {
                DB::table('theme_settings_legacy')->insert([
                    'theme_id' => $row->theme_id,
                    'key' => (string) $key,
                    'value' => $this->encodeJson($value),
                    'created_at' => null,
                    'updated_at' => $row->updated_at,
                ]);
            }
        });

        $this->replaceTable('theme_settings', 'theme_settings_legacy');
    }

    private function migrateCustomerPasswordResetTokens(): void
    {
        if (! Schema::hasTable('customer_password_reset_tokens')) {
            return;
        }

        Schema::create('customer_password_reset_tokens_contract', function (Blueprint $table): void {
            $table->foreignId('store_id')->constrained('stores')->cascadeOnDelete();
            $table->string('email');
            $table->string('token');
            $table->timestamp('created_at')->nullable();
            $table->primary(['store_id', 'email']);
            $table->index('email');
        });

        DB::table('customer_password_reset_tokens')
            ->whereNotNull('store_id')
            ->orderBy('store_id')
            ->orderBy('email')
            ->get()
            ->each(function (object $row): void {
                DB::table('customer_password_reset_tokens_contract')->insert([
                    'store_id' => $row->store_id,
                    'email' => $row->email,
                    'token' => $row->token,
                    'created_at' => $row->created_at,
                ]);
            });

        $this->replaceTable('customer_password_reset_tokens', 'customer_password_reset_tokens_contract');
    }

    private function restoreCustomerPasswordResetTokens(): void
    {
        if (! Schema::hasTable('customer_password_reset_tokens')) {
            return;
        }

        Schema::create('customer_password_reset_tokens_legacy', function (Blueprint $table): void {
            $table->unsignedBigInteger('store_id')->nullable();
            $table->string('email');
            $table->string('token');
            $table->timestamp('created_at')->nullable();
            $table->primary(['store_id', 'email']);
            $table->index('email');
        });

        DB::table('customer_password_reset_tokens')->get()->each(function (object $row): void {
            DB::table('customer_password_reset_tokens_legacy')->insert([
                'store_id' => $row->store_id,
                'email' => $row->email,
                'token' => $row->token,
                'created_at' => $row->created_at,
            ]);
        });

        $this->replaceTable('customer_password_reset_tokens', 'customer_password_reset_tokens_legacy');
    }

    private function migrateWebhookSubscriptions(): void
    {
        if (! Schema::hasTable('webhook_subscriptions') || Schema::hasColumn('webhook_subscriptions', 'secret_encrypted') === false) {
            return;
        }

        Schema::create('webhook_subscriptions_contract', function (Blueprint $table): void {
            $table->id();
            $table->foreignId('store_id')->constrained()->cascadeOnDelete();
            $table->string('event');
            $table->string('event_type');
            $table->text('target_url');
            $table->unsignedBigInteger('app_installation_id')->nullable()->index();
            $table->text('signing_secret_encrypted');
            $table->string('status')->default('active')->index();
            $table->unsignedInteger('consecutive_failures')->default(0);
            $table->timestamps();
            $table->index(['store_id', 'event']);
            $table->index(['store_id', 'event_type']);
        });

        DB::table('webhook_subscriptions')->orderBy('id')->get()->each(function (object $row): void {
            DB::table('webhook_subscriptions_contract')->insert([
                'id' => $row->id,
                'store_id' => $row->store_id,
                'event' => $row->event,
                'event_type' => $row->event_type ?? $row->event,
                'target_url' => $row->target_url,
                'app_installation_id' => $row->app_installation_id ?? null,
                'signing_secret_encrypted' => $row->signing_secret_encrypted ?? $row->secret_encrypted,
                'status' => $row->status,
                'consecutive_failures' => $row->consecutive_failures,
                'created_at' => $row->created_at,
                'updated_at' => $row->updated_at,
            ]);
        });

        $this->replaceTable('webhook_subscriptions', 'webhook_subscriptions_contract');
    }

    private function restoreWebhookSubscriptions(): void
    {
        if (! Schema::hasTable('webhook_subscriptions') || ! Schema::hasColumn('webhook_subscriptions', 'signing_secret_encrypted')) {
            return;
        }

        Schema::create('webhook_subscriptions_legacy', function (Blueprint $table): void {
            $table->id();
            $table->foreignId('store_id')->constrained()->cascadeOnDelete();
            $table->string('event');
            $table->string('event_type')->nullable();
            $table->text('target_url');
            $table->unsignedBigInteger('app_installation_id')->nullable()->index();
            $table->text('secret_encrypted');
            $table->string('status')->default('active');
            $table->unsignedInteger('consecutive_failures')->default(0);
            $table->timestamps();
            $table->index(['store_id', 'event']);
        });

        DB::table('webhook_subscriptions')->orderBy('id')->get()->each(function (object $row): void {
            DB::table('webhook_subscriptions_legacy')->insert([
                'id' => $row->id,
                'store_id' => $row->store_id,
                'event' => $row->event,
                'event_type' => $row->event_type,
                'target_url' => $row->target_url,
                'app_installation_id' => $row->app_installation_id,
                'secret_encrypted' => $row->signing_secret_encrypted,
                'status' => $row->status,
                'consecutive_failures' => $row->consecutive_failures,
                'created_at' => $row->created_at,
                'updated_at' => $row->updated_at,
            ]);
        });

        $this->replaceTable('webhook_subscriptions', 'webhook_subscriptions_legacy');
    }

    private function replaceTable(string $table, string $replacement): void
    {
        Schema::disableForeignKeyConstraints();

        try {
            Schema::drop($table);
            Schema::rename($replacement, $table);
        } finally {
            Schema::enableForeignKeyConstraints();
        }
    }

    private function decodeJson(mixed $value): mixed
    {
        if ($value === null || ! is_string($value)) {
            return $value;
        }

        try {
            return json_decode($value, true, 512, JSON_THROW_ON_ERROR);
        } catch (JsonException) {
            return $value;
        }
    }

    private function encodeJson(mixed $value): string
    {