text:
Script completed
Wall time 0.1 seconds
Output:

text:
--- 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
    {


exec

status: completed

input:
const r = await tools.exec_command({
  cmd: "rg -n \"createToken|abilities|manage-platform|read-membership|api/admin|Platform|organization|invite|presign\" tests app database routes | head -320 && sed -n '1,260p' tests/Feature/ApiTokenTest.php && sed -n '1,260p' app/Http/Controllers/Api/AdminController.php",
  workdir: "/Users/wesner/Herd/shop",
  yield_time_ms: 10000,
  max_output_tokens: 40000
});
text(r.output);


type: CommandExecution

command: rg -n "createToken|abilities|manage-platform|read-membership|api/admin|Platform|organization|invite|presign" tests app database routes | head -320 && sed -n '1,260p' tests/Feature/ApiTokenTest.php && sed -n '1,260p' app/Http/Controllers/Api/AdminController.php

type: search

cmd: rg -n 'createToken|abilities|manage-platform|read-membership|api/admin|Platform|organization|invite|presign' tests app database routes

query: createToken|abilities|manage-platform|read-membership|api/admin|Platform|organization|invite|presign

path: tests

ApiTokenTest.php

type: read

AdminController.php

status: completed

stdout:
tests/Feature/ContractBehaviorTest.php:53:    $firstToken = Password::broker('customers')->createToken($firstCustomer);
tests/Feature/ContractBehaviorTest.php:61:    $secondToken = Password::broker('customers')->createToken($secondCustomer);
tests/Feature/ContractBehaviorTest.php:108:        $timestamp = $request->header('X-Platform-Timestamp')[0];
tests/Feature/ContractBehaviorTest.php:112:            && $request->header('X-Platform-Event')[0] === 'order.created'
tests/Feature/ContractBehaviorTest.php:113:            && $request->header('X-Platform-Signature')[0] === hash_hmac('sha256', $timestamp.'.'.$body, 'test-secret');
tests/Feature/ApiTokenTest.php:17:test('admin bearer tokens can read only the abilities they were granted', function (): void {
tests/Feature/ApiTokenTest.php:18:    $token = $this->admin->createToken('catalog-reader', ['read-products'])->plainTextToken;
tests/Feature/ApiTokenTest.php:21:        ->getJson("http://shop.test/api/admin/v1/stores/{$this->store->getKey()}/products")
tests/Feature/ApiTokenTest.php:25:        ->postJson("http://shop.test/api/admin/v1/stores/{$this->store->getKey()}/collections", ['title' => 'Blocked', 'type' => 'manual'])
tests/Feature/ApiTokenTest.php:30:    $token = $this->admin->createToken('catalog-manager', ['write-collections'])->plainTextToken;
tests/Feature/ApiTokenTest.php:33:        ->postJson("http://shop.test/api/admin/v1/stores/{$this->store->getKey()}/collections", ['title' => 'Token Collection', 'type' => 'manual'])
tests/Feature/ApiTokenTest.php:40:        ->getJson("http://shop.test/api/admin/v1/stores/{$this->store->getKey()}/products")
tests/Feature/ApiTokenTest.php:46:    $token = $this->admin->createToken('foreign-store-reader', ['read-products'])->plainTextToken;
tests/Feature/ApiTokenTest.php:49:        ->getJson("http://shop.test/api/admin/v1/stores/{$otherStore->getKey()}/products")
tests/Feature/SearchAnalyticsWebhookTest.php:72:        return $request->hasHeader('X-Platform-Signature')
tests/Feature/SearchAnalyticsWebhookTest.php:73:            && $request->header('X-Platform-Event')[0] === 'order.created'
tests/Feature/SearchAnalyticsWebhookTest.php:74:            && $request->header('X-Platform-Delivery-Id') !== null;
tests/Feature/AdminApiTest.php:18:    $token = $this->admin->createToken('catalog-manager', ['read-products', 'write-collections'])->plainTextToken;
tests/Feature/AdminApiTest.php:21:        ->getJson("http://shop.test/api/admin/v1/stores/{$this->store->getKey()}/products")
tests/Feature/AdminApiTest.php:26:        ->postJson("http://shop.test/api/admin/v1/stores/{$this->store->getKey()}/collections", [
tests/Feature/AdminApiTest.php:37:    $token = $this->admin->createToken('catalog-reader', ['read-products'])->plainTextToken;
tests/Feature/AdminApiTest.php:40:        ->getJson("http://shop.test/api/admin/v1/stores/{$otherStore->getKey()}/products")
tests/Unit/Tenancy/BelongsToStoreTest.php:19:    $organization = Organization::factory()->create();
tests/Unit/Tenancy/BelongsToStoreTest.php:20:    $store = Store::factory()->for($organization)->create();
tests/Unit/Tenancy/BelongsToStoreTest.php:21:    $otherStore = Store::factory()->for($organization)->create();
tests/Unit/Tenancy/BelongsToStoreTest.php:34:    $organization = Organization::factory()->create();
tests/Unit/Tenancy/BelongsToStoreTest.php:35:    $store = Store::factory()->for($organization)->create();
database/factories/StoreFactory.php:22:            'organization_id' => Organization::factory(),
app/Jobs/DeliverWebhook.php:36:            'X-Platform-Signature' => $webhooks->sign($timestamp.'.'.$payload, $subscription->signing_secret_encrypted),
app/Jobs/DeliverWebhook.php:37:            'X-Platform-Event' => $this->delivery->event,
app/Jobs/DeliverWebhook.php:38:            'X-Platform-Delivery-Id' => (string) $this->delivery->getKey(),
app/Jobs/DeliverWebhook.php:39:            'X-Platform-Timestamp' => $timestamp,
app/Livewire/Admin/Developers/Index.php:40:    public function createToken(): void
app/Livewire/Admin/Developers/Index.php:49:        $abilities = array_values(array_intersect($allowed, array_filter(array_map('trim', explode(',', $data['tokenAbilities'])))));
app/Livewire/Admin/Developers/Index.php:50:        abort_if($abilities === [], 422, 'Select at least one token ability.');
app/Livewire/Admin/Developers/Index.php:52:        $this->plainTextToken = auth()->user()->createToken($data['tokenName'], $abilities, $expiresAt)->plainTextToken;
database/seeders/StoreSeeder.php:16:        $organization = Organization::query()->where('slug', 'acme-corp')->firstOrFail();
database/seeders/StoreSeeder.php:24:                ['organization_id' => $organization->getKey(), 'name' => $store['name'], 'status' => 'active', 'default_currency' => 'EUR', 'default_locale' => 'en', 'timezone' => 'Europe/Berlin', 'metadata' => []],
app/Models/Store.php:17:    protected $fillable = ['organization_id', 'name', 'handle', 'status', 'default_currency', 'default_locale', 'timezone', 'primary_domain', 'metadata'];
app/Models/Store.php:31:    public function organization(): BelongsTo
database/migrations/2026_08_20_000000_create_shop_schema.php:11:        Schema::create('organizations', function (Blueprint $table): void {
database/migrations/2026_08_20_000000_create_shop_schema.php:22:            $table->foreignId('organization_id')->constrained()->cascadeOnDelete();
database/migrations/2026_08_20_000000_create_shop_schema.php:32:            $table->index(['organization_id', 'status']);
database/migrations/2026_08_20_000000_create_shop_schema.php:587:            'store_domains', 'stores', 'organizations',
database/migrations/2026_08_20_220000_add_search_and_auth_support.php:17:            $table->text('abilities')->nullable();
app/Auth/StoreScopedPasswordBrokerManager.php:11:    protected function createTokenRepository(array $config)
<?php

use App\Models\Store;
use App\Models\User;
use Database\Seeders\ShopSeeder;
use Illuminate\Foundation\Testing\RefreshDatabase;

uses(RefreshDatabase::class);

beforeEach(function (): void {
    config(['cache.default' => 'array']);
    $this->seed(ShopSeeder::class);
    $this->store = Store::query()->where('handle', 'acme-fashion')->firstOrFail();
    $this->admin = User::query()->where('email', 'admin@acme.test')->firstOrFail();
});

test('admin bearer tokens can read only the abilities they were granted', function (): void {
    $token = $this->admin->createToken('catalog-reader', ['read-products'])->plainTextToken;

    $this->withToken($token)
        ->getJson("http://shop.test/api/admin/v1/stores/{$this->store->getKey()}/products")
        ->assertOk();

    $this->withToken($token)
        ->postJson("http://shop.test/api/admin/v1/stores/{$this->store->getKey()}/collections", ['title' => 'Blocked', 'type' => 'manual'])
        ->assertForbidden();
});

test('admin bearer tokens can write with the matching ability', function (): void {
    $token = $this->admin->createToken('catalog-manager', ['write-collections'])->plainTextToken;

    $this->withToken($token)
        ->postJson("http://shop.test/api/admin/v1/stores/{$this->store->getKey()}/collections", ['title' => 'Token Collection', 'type' => 'manual'])
        ->assertCreated()
        ->assertJsonPath('data.title', 'Token Collection');
});

test('session-authenticated admins cannot use the admin API', function (): void {
    $this->actingAs($this->admin)
        ->getJson("http://shop.test/api/admin/v1/stores/{$this->store->getKey()}/products")
        ->assertUnauthorized();
});

test('admin bearer tokens cannot access stores where the user is not a member', function (): void {
    $otherStore = Store::factory()->create();
    $token = $this->admin->createToken('foreign-store-reader', ['read-products'])->plainTextToken;

    $this->withToken($token)
        ->getJson("http://shop.test/api/admin/v1/stores/{$otherStore->getKey()}/products")
        ->assertForbidden();
});
<?php

namespace App\Http\Controllers\Api;

use App\Enums\ProductStatus;
use App\Http\Controllers\Controller;
use App\Http\Requests\CreateFulfillmentRequest;
use App\Http\Requests\CreateRefundRequest;
use App\Http\Requests\StoreCollectionRequest;
use App\Http\Requests\StoreDiscountRequest;
use App\Http\Requests\StorePageRequest;
use App\Http\Requests\StoreProductRequest;
use App\Http\Requests\StoreShippingRateRequest;
use App\Http\Requests\StoreShippingZoneRequest;
use App\Http\Requests\StoreThemeRequest;
use App\Http\Requests\UpdateCollectionRequest;
use App\Http\Requests\UpdateDiscountRequest;
use App\Http\Requests\UpdatePageRequest;
use App\Http\Requests\UpdateProductRequest;
use App\Http\Requests\UpdateShippingZoneRequest;
use App\Http\Requests\UpdateTaxSettingsRequest;
use App\Http\Requests\UpdateThemeSettingsRequest;
use App\Models\Collection;
use App\Models\Customer;
use App\Models\Discount;
use App\Models\Order;
use App\Models\Page;
use App\Models\Product;
use App\Models\ShippingRate;
use App\Models\ShippingZone;
use App\Models\TaxSettings;
use App\Models\Theme;
use App\Services\FulfillmentService;
use App\Services\ProductService;
use App\Services\RefundService;
use App\Services\SearchService;
use Illuminate\Contracts\Pagination\LengthAwarePaginator;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Str;

class AdminController extends Controller
{
    public function products(Request $request, int $storeId): JsonResponse
    {
        $this->assertStore($storeId);
        $data = $request->validate(['status' => ['nullable', 'in:draft,active,archived'], 'query' => ['nullable', 'string'], 'per_page' => ['nullable', 'integer', 'min:1', 'max:100']]);
        $products = Product::withoutGlobalScopes()->where('store_id', $storeId)->with(['variants.inventory', 'collections'])->when($data['status'] ?? null, fn ($query, string $status) => $query->where('status', $status))->when($data['query'] ?? null, fn ($query, string $queryText) => $query->where(function ($nested) use ($queryText): void {
            $nested->where('title', 'like', '%'.$queryText.'%')->orWhere('vendor', 'like', '%'.$queryText.'%')->orWhereHas('variants', fn ($variants) => $variants->where('sku', 'like', '%'.$queryText.'%'));
        }))->latest('updated_at')->paginate($data['per_page'] ?? 25);

        return $this->paginated($products);
    }

    public function storeProduct(StoreProductRequest $request, int $storeId, ProductService $products): JsonResponse
    {
        $this->assertStore($storeId);
        $data = $request->validated();
        $product = $products->create(app('current_store'), [...$data, 'status' => ProductStatus::from($data['status'] ?? ProductStatus::Draft->value)]);

        return response()->json(['data' => $product->load('variants')->toArray()], 201);
    }

    public function showProduct(int $storeId, int $productId): JsonResponse
    {
        $this->assertStore($storeId);
        $product = Product::withoutGlobalScopes()->where('store_id', $storeId)->with(['variants.inventory', 'options.values', 'media', 'collections'])->findOrFail($productId);

        return response()->json(['data' => $product->toArray()]);
    }

    public function updateProduct(UpdateProductRequest $request, int $storeId, int $productId, ProductService $products): JsonResponse
    {
        $this->assertStore($storeId);
        $product = Product::withoutGlobalScopes()->where('store_id', $storeId)->findOrFail($productId);
        $data = $request->validated();

        return response()->json(['data' => $products->update($product, $data)->toArray()]);
    }

    public function deleteProduct(int $storeId, int $productId, ProductService $products): JsonResponse
    {
        $this->assertStore($storeId);
        $product = Product::withoutGlobalScopes()->where('store_id', $storeId)->findOrFail($productId);
        $products->transitionStatus($product, ProductStatus::Archived);

        return response()->json(['message' => 'Product archived']);
    }

    public function collections(Request $request, int $storeId): JsonResponse
    {
        $this->assertStore($storeId);
        $data = $request->validate(['query' => ['nullable', 'string'], 'per_page' => ['nullable', 'integer', 'min:1', 'max:100']]);
        $collections = Collection::withoutGlobalScopes()->where('store_id', $storeId)->withCount('products')->when($data['query'] ?? null, fn ($query, string $queryText) => $query->where('title', 'like', '%'.$queryText.'%'))->latest()->paginate($data['per_page'] ?? 25);

        return $this->paginated($collections);
    }

    public function storeCollection(StoreCollectionRequest $request, int $storeId): JsonResponse
    {
        $this->assertStore($storeId);
        $data = $request->validated();
        $collection = Collection::withoutGlobalScopes()->create(['store_id' => $storeId, 'title' => $data['title'], 'handle' => $data['handle'] ?? Str::slug($data['title']), 'description' => $data['description_html'] ?? null, 'status' => $data['status'] ?? 'active']);
        $this->syncCollectionProducts($collection, $data['product_ids'] ?? [], $storeId);

        return response()->json(['data' => $collection->load('products')->toArray()], 201);
    }

    public function updateCollection(UpdateCollectionRequest $request, int $storeId, int $collectionId): JsonResponse
    {
        $this->assertStore($storeId);
        $collection = Collection::withoutGlobalScopes()->where('store_id', $storeId)->findOrFail($collectionId);
        $data = $request->validated();
        $collection->update(array_filter(['title' => $data['title'] ?? null, 'description' => $data['description_html'] ?? null, 'status' => $data['status'] ?? null], fn ($value): bool => $value !== null));

        if (array_key_exists('product_ids', $data)) {
            $this->syncCollectionProducts($collection, $data['product_ids'], $storeId);
        }

        return response()->json(['data' => $collection->load('products')->toArray()]);
    }

    public function deleteCollection(int $storeId, int $collectionId): JsonResponse
    {
        $this->assertStore($storeId);
        Collection::withoutGlobalScopes()->where('store_id', $storeId)->findOrFail($collectionId)->delete();

        return response()->json(['message' => 'Collection deleted']);
    }

    public function orders(Request $request, int $storeId): JsonResponse
    {
        $this->assertStore($storeId);
        $data = $request->validate(['status' => ['nullable', 'string'], 'financial_status' => ['nullable', 'string'], 'per_page' => ['nullable', 'integer', 'min:1', 'max:100']]);
        $orders = Order::withoutGlobalScopes()->where('store_id', $storeId)->with('customer')->when($data['status'] ?? null, fn ($query, string $status) => $query->where('status', $status))->when($data['financial_status'] ?? null, fn ($query, string $status) => $query->where('financial_status', $status))->latest('placed_at')->paginate($data['per_page'] ?? 25);

        return $this->paginated($orders);
    }

    public function showOrder(int $storeId, int $orderId): JsonResponse
    {
        $this->assertStore($storeId);
        $order = Order::withoutGlobalScopes()->where('store_id', $storeId)->with(['customer', 'lines', 'payments', 'refunds', 'fulfillments.lines'])->findOrFail($orderId);

        return response()->json(['data' => $order->toArray()]);
    }

    public function customers(Request $request, int $storeId): JsonResponse
    {
        $this->assertStore($storeId);
        $data = $request->validate(['query' => ['nullable', 'string'], 'per_page' => ['nullable', 'integer', 'min:1', 'max:100']]);
        $customers = Customer::withoutGlobalScopes()->where('store_id', $storeId)->when($data['query'] ?? null, fn ($query, string $queryText) => $query->where(function ($nested) use ($queryText): void {
            $nested->where('email', 'like', '%'.$queryText.'%')->orWhere('first_name', 'like', '%'.$queryText.'%')->orWhere('last_name', 'like', '%'.$queryText.'%');
        }))->latest()->paginate($data['per_page'] ?? 25);

        return $this->paginated($customers);
    }

    public function discounts(Request $request, int $storeId): JsonResponse
    {
        $this->assertStore($storeId);

        return $this->paginated(Discount::withoutGlobalScopes()->where('store_id', $storeId)->latest()->paginate($request->integer('per_page', 25)));
    }

    public function storeDiscount(StoreDiscountRequest $request, int $storeId): JsonResponse
    {
        $this->assertStore($storeId);
        $data = $request->validated();
        $discount = Discount::withoutGlobalScopes()->create([...$data, 'store_id' => $storeId, 'status' => $data['status'] ?? 'active', 'usage_count' => 0]);

        return response()->json(['data' => $discount], 201);
    }

    public function updateDiscount(UpdateDiscountRequest $request, int $storeId, int $discountId): JsonResponse
    {
        $this->assertStore($storeId);
        $discount = Discount::withoutGlobalScopes()->where('store_id', $storeId)->findOrFail($discountId);
        $discount->update($request->validated());

        return response()->json(['data' => $discount->refresh()]);
    }

    public function deleteDiscount(int $storeId, int $discountId): JsonResponse
    {
        $this->assertStore($storeId);
        Discount::withoutGlobalScopes()->where('store_id', $storeId)->findOrFail($discountId)->delete();

        return response()->json(['message' => 'Discount deleted.']);
    }

    public function fulfillOrder(CreateFulfillmentRequest $request, int $storeId, int $orderId, FulfillmentService $fulfillments): JsonResponse
    {
        $this->assertStore($storeId);
        $order = Order::withoutGlobalScopes()->where('store_id', $storeId)->findOrFail($orderId);
        $data = $request->validated();
        $fulfillment = $fulfillments->create($order, $data['lines'], array_filter(['tracking_company' => $data['tracking_company'] ?? null, 'tracking_number' => $data['tracking_number'] ?? null, 'tracking_url' => $data['tracking_url'] ?? null]));

        return response()->json(['data' => $fulfillment->toArray()], 201);
    }

    public function refundOrder(CreateRefundRequest $request, int $storeId, int $orderId, RefundService $refunds): JsonResponse
    {
        $this->assertStore($storeId);
        $order = Order::withoutGlobalScopes()->where('store_id', $storeId)->with('payments')->findOrFail($orderId);
        $data = $request->validated();
        $payment = $order->payments->firstWhere('id', $data['payment_id'] ?? null) ?? $order->payments->firstWhere('status', 'captured');
        abort_unless($payment !== null, 422, 'No captured payment is available.');
        $refund = $refunds->create($order, $payment, $data['lines'] ?? $data['amount'] ?? null, $data['reason'] ?? null, (bool) ($data['restock'] ?? false), $data['lines'] ?? []);

        return response()->json(['data' => $refund->toArray()], 201);
    }

    public function shippingZones(int $storeId): JsonResponse
    {
        $this->assertStore($storeId);

        return response()->json(['data' => ShippingZone::withoutGlobalScopes()->where('store_id', $storeId)->with('rates')->get()]);
    }

    public function storeShippingZone(StoreShippingZoneRequest $request, int $storeId): JsonResponse
    {
        $this->assertStore($storeId);
        $data = $request->validated();

        return response()->json(['data' => ShippingZone::withoutGlobalScopes()->create([...$data, 'store_id' => $storeId])], 201);
    }

    public function updateShippingZone(UpdateShippingZoneRequest $request, int $storeId, int $zoneId): JsonResponse
    {
        $this->assertStore($storeId);
        $zone = ShippingZone::withoutGlobalScopes()->where('store_id', $storeId)->findOrFail($zoneId);
        $zone->update($request->validated());

        return response()->json(['data' => $zone->refresh()]);
    }

    public function storeShippingRate(StoreShippingRateRequest $request, int $storeId, int $zoneId): JsonResponse
    {
        $this->assertStore($storeId);
        ShippingZone::withoutGlobalScopes()->where('store_id', $storeId)->findOrFail($zoneId);
        $data = $request->validated();

        return response()->json(['data' => ShippingRate::create([...$data, 'shipping_zone_id' => $zoneId, 'is_active' => $data['is_active'] ?? true])], 201);
    }

    public function taxSettings(int $storeId): JsonResponse
    {
        $this->assertStore($storeId);

        return response()->json(['data' => TaxSettings::withoutGlobalScopes()->firstOrCreate(['store_id' => $storeId])]);
    }

    public function updateTaxSettings(UpdateTaxSettingsRequest $request, int $storeId): JsonResponse
    {
        $this->assertStore($storeId);
        $data = $request->validated();
        $settings = TaxSettings::withoutGlobalScopes()->updateOrCreate(['store_id' => $storeId], $data);

        return response()->json(['data' => $settings]);


text:
Script completed
Wall time 0.1 seconds
Output:

text:
tests/Feature/ContractBehaviorTest.php:53:    $firstToken = Password::broker('customers')->createToken($firstCustomer);
tests/Feature/ContractBehaviorTest.php:61:    $secondToken = Password::broker('customers')->createToken($secondCustomer);
tests/Feature/ContractBehaviorTest.php:108:        $timestamp = $request->header('X-Platform-Timestamp')[0];
tests/Feature/ContractBehaviorTest.php:112:            && $request->header('X-Platform-Event')[0] === 'order.created'
tests/Feature/ContractBehaviorTest.php:113:            && $request->header('X-Platform-Signature')[0] === hash_hmac('sha256', $timestamp.'.'.$body, 'test-secret');
tests/Feature/ApiTokenTest.php:17:test('admin bearer tokens can read only the abilities they were granted', function (): void {
tests/Feature/ApiTokenTest.php:18:    $token = $this->admin->createToken('catalog-reader', ['read-products'])->plainTextToken;
tests/Feature/ApiTokenTest.php:21:        ->getJson("http://shop.test/api/admin/v1/stores/{$this->store->getKey()}/products")
tests/Feature/ApiTokenTest.php:25:        ->postJson("http://shop.test/api/admin/v1/stores/{$this->store->getKey()}/collections", ['title' => 'Blocked', 'type' => 'manual'])
tests/Feature/ApiTokenTest.php:30:    $token = $this->admin->createToken('catalog-manager', ['write-collections'])->plainTextToken;
tests/Feature/ApiTokenTest.php:33:        ->postJson("http://shop.test/api/admin/v1/stores/{$this->store->getKey()}/collections", ['title' => 'Token Collection', 'type' => 'manual'])
tests/Feature/ApiTokenTest.php:40:        ->getJson("http://shop.test/api/admin/v1/stores/{$this->store->getKey()}/products")
tests/Feature/ApiTokenTest.php:46:    $token = $this->admin->createToken('foreign-store-reader', ['read-products'])->plainTextToken;
tests/Feature/ApiTokenTest.php:49:        ->getJson("http://shop.test/api/admin/v1/stores/{$otherStore->getKey()}/products")
tests/Feature/SearchAnalyticsWebhookTest.php:72:        return $request->hasHeader('X-Platform-Signature')
tests/Feature/SearchAnalyticsWebhookTest.php:73:            && $request->header('X-Platform-Event')[0] === 'order.created'
tests/Feature/SearchAnalyticsWebhookTest.php:74:            && $request->header('X-Platform-Delivery-Id') !== null;
tests/Feature/AdminApiTest.php:18:    $token = $this->admin->createToken('catalog-manager', ['read-products', 'write-collections'])->plainTextToken;
tests/Feature/AdminApiTest.php:21:        ->getJson("http://shop.test/api/admin/v1/stores/{$this->store->getKey()}/products")
tests/Feature/AdminApiTest.php:26:        ->postJson("http://shop.test/api/admin/v1/stores/{$this->store->getKey()}/collections", [
tests/Feature/AdminApiTest.php:37:    $token = $this->admin->createToken('catalog-reader', ['read-products'])->plainTextToken;
tests/Feature/AdminApiTest.php:40:        ->getJson("http://shop.test/api/admin/v1/stores/{$otherStore->getKey()}/products")
tests/Unit/Tenancy/BelongsToStoreTest.php:19:    $organization = Organization::factory()->create();
tests/Unit/Tenancy/BelongsToStoreTest.php:20:    $store = Store::factory()->for($organization)->create();
tests/Unit/Tenancy/BelongsToStoreTest.php:21:    $otherStore = Store::factory()->for($organization)->create();
tests/Unit/Tenancy/BelongsToStoreTest.php:34:    $organization = Organization::factory()->create();
tests/Unit/Tenancy/BelongsToStoreTest.php:35:    $store = Store::factory()->for($organization)->create();
database/factories/StoreFactory.php:22:            'organization_id' => Organization::factory(),
app/Jobs/DeliverWebhook.php:36:            'X-Platform-Signature' => $webhooks->sign($timestamp.'.'.$payload, $subscription->signing_secret_encrypted),
app/Jobs/DeliverWebhook.php:37:            'X-Platform-Event' => $this->delivery->event,
app/Jobs/DeliverWebhook.php:38:            'X-Platform-Delivery-Id' => (string) $this->delivery->getKey(),
app/Jobs/DeliverWebhook.php:39:            'X-Platform-Timestamp' => $timestamp,
app/Livewire/Admin/Developers/Index.php:40:    public function createToken(): void
app/Livewire/Admin/Developers/Index.php:49:        $abilities = array_values(array_intersect($allowed, array_filter(array_map('trim', explode(',', $data['tokenAbilities'])))));
app/Livewire/Admin/Developers/Index.php:50:        abort_if($abilities === [], 422, 'Select at least one token ability.');
app/Livewire/Admin/Developers/Index.php:52:        $this->plainTextToken = auth()->user()->createToken($data['tokenName'], $abilities, $expiresAt)->plainTextToken;
database/seeders/StoreSeeder.php:16:        $organization = Organization::query()->where('slug', 'acme-corp')->firstOrFail();
database/seeders/StoreSeeder.php:24:                ['organization_id' => $organization->getKey(), 'name' => $store['name'], 'status' => 'active', 'default_currency' => 'EUR', 'default_locale' => 'en', 'timezone' => 'Europe/Berlin', 'metadata' => []],
app/Models/Store.php:17:    protected $fillable = ['organization_id', 'name', 'handle', 'status', 'default_currency', 'default_locale', 'timezone', 'primary_domain', 'metadata'];
app/Models/Store.php:31:    public function organization(): BelongsTo
database/migrations/2026_08_20_000000_create_shop_schema.php:11:        Schema::create('organizations', function (Blueprint $table): void {
database/migrations/2026_08_20_000000_create_shop_schema.php:22:            $table->foreignId('organization_id')->constrained()->cascadeOnDelete();
database/migrations/2026_08_20_000000_create_shop_schema.php:32:            $table->index(['organization_id', 'status']);
database/migrations/2026_08_20_000000_create_shop_schema.php:587:            'store_domains', 'stores', 'organizations',
database/migrations/2026_08_20_220000_add_search_and_auth_support.php:17:            $table->text('abilities')->nullable();
app/Auth/StoreScopedPasswordBrokerManager.php:11:    protected function createTokenRepository(array $config)
<?php

use App\Models\Store;
use App\Models\User;
use Database\Seeders\ShopSeeder;
use Illuminate\Foundation\Testing\RefreshDatabase;

uses(RefreshDatabase::class);

beforeEach(function (): void {
    config(['cache.default' => 'array']);
    $this->seed(ShopSeeder::class);
    $this->store = Store::query()->where('handle', 'acme-fashion')->firstOrFail();
    $this->admin = User::query()->where('email', 'admin@acme.test')->firstOrFail();
});

test('admin bearer tokens can read only the abilities they were granted', function (): void {
    $token = $this->admin->createToken('catalog-reader', ['read-products'])->plainTextToken;

    $this->withToken($token)
        ->getJson("http://shop.test/api/admin/v1/stores/{$this->store->getKey()}/products")
        ->assertOk();

    $this->withToken($token)
        ->postJson("http://shop.test/api/admin/v1/stores/{$this->store->getKey()}/collections", ['title' => 'Blocked', 'type' => 'manual'])
        ->assertForbidden();
});

test('admin bearer tokens can write with the matching ability', function (): void {
    $token = $this->admin->createToken('catalog-manager', ['write-collections'])->plainTextToken;

    $this->withToken($token)
        ->postJson("http://shop.test/api/admin/v1/stores/{$this->store->getKey()}/collections", ['title' => 'Token Collection', 'type' => 'manual'])
        ->assertCreated()
        ->assertJsonPath('data.title', 'Token Collection');
});

test('session-authenticated admins cannot use the admin API', function (): void {
    $this->actingAs($this->admin)
        ->getJson("http://shop.test/api/admin/v1/stores/{$this->store->getKey()}/products")
        ->assertUnauthorized();
});

test('admin bearer tokens cannot access stores where the user is not a member', function (): void {
    $otherStore = Store::factory()->create();
    $token = $this->admin->createToken('foreign-store-reader', ['read-products'])->plainTextToken;

    $this->withToken($token)
        ->getJson("http://shop.test/api/admin/v1/stores/{$otherStore->getKey()}/products")
        ->assertForbidden();
});
<?php

namespace App\Http\Controllers\Api;

use App\Enums\ProductStatus;
use App\Http\Controllers\Controller;
use App\Http\Requests\CreateFulfillmentRequest;
use App\Http\Requests\CreateRefundRequest;
use App\Http\Requests\StoreCollectionRequest;
use App\Http\Requests\StoreDiscountRequest;
use App\Http\Requests\StorePageRequest;
use App\Http\Requests\StoreProductRequest;
use App\Http\Requests\StoreShippingRateRequest;
use App\Http\Requests\StoreShippingZoneRequest;
use App\Http\Requests\StoreThemeRequest;
use App\Http\Requests\UpdateCollectionRequest;
use App\Http\Requests\UpdateDiscountRequest;
use App\Http\Requests\UpdatePageRequest;
use App\Http\Requests\UpdateProductRequest;
use App\Http\Requests\UpdateShippingZoneRequest;
use App\Http\Requests\UpdateTaxSettingsRequest;
use App\Http\Requests\UpdateThemeSettingsRequest;
use App\Models\Collection;
use App\Models\Customer;
use App\Models\Discount;
use App\Models\Order;
use App\Models\Page;
use App\Models\Product;
use App\Models\ShippingRate;
use App\Models\ShippingZone;
use App\Models\TaxSettings;
use App\Models\Theme;
use App\Services\FulfillmentService;
use App\Services\ProductService;
use App\Services\RefundService;
use App\Services\SearchService;
use Illuminate\Contracts\Pagination\LengthAwarePaginator;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Str;

class AdminController extends Controller
{
    public function products(Request $request, int $storeId): JsonResponse
    {
        $this->assertStore($storeId);
        $data = $request->validate(['status' => ['nullable', 'in:draft,active,archived'], 'query' => ['nullable', 'string'], 'per_page' => ['nullable', 'integer', 'min:1', 'max:100']]);
        $products = Product::withoutGlobalScopes()->where('store_id', $storeId)->with(['variants.inventory', 'collections'])->when($data['status'] ?? null, fn ($query, string $status) => $query->where('status', $status))->when($data['query'] ?? null, fn ($query, string $queryText) => $query->where(function ($nested) use ($queryText): void {
            $nested->where('title', 'like', '%'.$queryText.'%')->orWhere('vendor', 'like', '%'.$queryText.'%')->orWhereHas('variants', fn ($variants) => $variants->where('sku', 'like', '%'.$queryText.'%'));
        }))->latest('updated_at')->paginate($data['per_page'] ?? 25);

        return $this->paginated($products);
    }

    public function storeProduct(StoreProductRequest $request, int $storeId, ProductService $products): JsonResponse
    {
        $this->assertStore($storeId);
        $data = $request->validated();
        $product = $products->create(app('current_store'), [...$data, 'status' => ProductStatus::from($data['status'] ?? ProductStatus::Draft->value)]);

        return response()->json(['data' => $product->load('variants')->toArray()], 201);
    }

    public function showProduct(int $storeId, int $productId): JsonResponse
    {
        $this->assertStore($storeId);
        $product = Product::withoutGlobalScopes()->where('store_id', $storeId)->with(['variants.inventory', 'options.values', 'media', 'collections'])->findOrFail($productId);

        return response()->json(['data' => $product->toArray()]);
    }

    public function updateProduct(UpdateProductRequest $request, int $storeId, int $productId, ProductService $products): JsonResponse
    {
        $this->assertStore($storeId);
        $product = Product::withoutGlobalScopes()->where('store_id', $storeId)->findOrFail($productId);
        $data = $request->validated();

        return response()->json(['data' => $products->update($product, $data)->toArray()]);
    }

    public function deleteProduct(int $storeId, int $productId, ProductService $products): JsonResponse
    {
        $this->assertStore($storeId);
        $product = Product::withoutGlobalScopes()->where('store_id', $storeId)->findOrFail($productId);
        $products->transitionStatus($product, ProductStatus::Archived);

        return response()->json(['message' => 'Product archived']);
    }

    public function collections(Request $request, int $storeId): JsonResponse
    {
        $this->assertStore($storeId);
        $data = $request->validate(['query' => ['nullable', 'string'], 'per_page' => ['nullable', 'integer', 'min:1', 'max:100']]);
        $collections = Collection::withoutGlobalScopes()->where('store_id', $storeId)->withCount('products')->when($data['query'] ?? null, fn ($query, string $queryText) => $query->where('title', 'like', '%'.$queryText.'%'))->latest()->paginate($data['per_page'] ?? 25);

        return $this->paginated($collections);
    }

    public function storeCollection(StoreCollectionRequest $request, int $storeId): JsonResponse
    {
        $this->assertStore($storeId);
        $data = $request->validated();
        $collection = Collection::withoutGlobalScopes()->create(['store_id' => $storeId, 'title' => $data['title'], 'handle' => $data['handle'] ?? Str::slug($data['title']), 'description' => $data['description_html'] ?? null, 'status' => $data['status'] ?? 'active']);
        $this->syncCollectionProducts($collection, $data['product_ids'] ?? [], $storeId);

        return response()->json(['data' => $collection->load('products')->toArray()], 201);
    }

    public function updateCollection(UpdateCollectionRequest $request, int $storeId, int $collectionId): JsonResponse
    {
        $this->assertStore($storeId);
        $collection = Collection::withoutGlobalScopes()->where('store_id', $storeId)->findOrFail($collectionId);
        $data = $request->validated();
        $collection->update(array_filter(['title' => $data['title'] ?? null, 'description' => $data['description_html'] ?? null, 'status' => $data['status'] ?? null], fn ($value): bool => $value !== null));

        if (array_key_exists('product_ids', $data)) {
            $this->syncCollectionProducts($collection, $data['product_ids'], $storeId);
        }

        return response()->json(['data' => $collection->load('products')->toArray()]);
    }

    public function deleteCollection(int $storeId, int $collectionId): JsonResponse
    {
        $this->assertStore($storeId);
        Collection::withoutGlobalScopes()->where('store_id', $storeId)->findOrFail($collectionId)->delete();

        return response()->json(['message' => 'Collection deleted']);
    }

    public function orders(Request $request, int $storeId): JsonResponse
    {
        $this->assertStore($storeId);
        $data = $request->validate(['status' => ['nullable', 'string'], 'financial_status' => ['nullable', 'string'], 'per_page' => ['nullable', 'integer', 'min:1', 'max:100']]);
        $orders = Order::withoutGlobalScopes()->where('store_id', $storeId)->with('customer')->when($data['status'] ?? null, fn ($query, string $status) => $query->where('status', $status))->when($data['financial_status'] ?? null, fn ($query, string $status) => $query->where('financial_status', $status))->latest('placed_at')->paginate($data['per_page'] ?? 25);

        return $this->paginated($orders);
    }

    public function showOrder(int $storeId, int $orderId): JsonResponse
    {
        $this->assertStore($storeId);
        $order = Order::withoutGlobalScopes()->where('store_id', $storeId)->with(['customer', 'lines', 'payments', 'refunds', 'fulfillments.lines'])->findOrFail($orderId);

        return response()->json(['data' => $order->toArray()]);
    }

    public function customers(Request $request, int $storeId): JsonResponse
    {
        $this->assertStore($storeId);
        $data = $request->validate(['query' => ['nullable', 'string'], 'per_page' => ['nullable', 'integer', 'min:1', 'max:100']]);
        $customers = Customer::withoutGlobalScopes()->where('store_id', $storeId)->when($data['query'] ?? null, fn ($query, string $queryText) => $query->where(function ($nested) use ($queryText): void {
            $nested->where('email', 'like', '%'.$queryText.'%')->orWhere('first_name', 'like', '%'.$queryText.'%')->orWhere('last_name', 'like', '%'.$queryText.'%');
        }))->latest()->paginate($data['per_page'] ?? 25);

        return $this->paginated($customers);
    }

    public function discounts(Request $request, int $storeId): JsonResponse
    {
        $this->assertStore($storeId);

        return $this->paginated(Discount::withoutGlobalScopes()->where('store_id', $storeId)->latest()->paginate($request->integer('per_page', 25)));
    }

    public function storeDiscount(StoreDiscountRequest $request, int $storeId): JsonResponse
    {
        $this->assertStore($storeId);
        $data = $request->validated();
        $discount = Discount::withoutGlobalScopes()->create([...$data, 'store_id' => $storeId, 'status' => $data['status'] ?? 'active', 'usage_count' => 0]);

        return response()->json(['data' => $discount], 201);
    }

    public function updateDiscount(UpdateDiscountRequest $request, int $storeId, int $discountId): JsonResponse
    {
        $this->assertStore($storeId);
        $discount = Discount::withoutGlobalScopes()->where('store_id', $storeId)->findOrFail($discountId);
        $discount->update($request->validated());

        return response()->json(['data' => $discount->refresh()]);
    }

    public function deleteDiscount(int $storeId, int $discountId): JsonResponse
    {
        $this->assertStore($storeId);
        Discount::withoutGlobalScopes()->where('store_id', $storeId)->findOrFail($discountId)->delete();

        return response()->json(['message' => 'Discount deleted.']);
    }

    public function fulfillOrder(CreateFulfillmentRequest $request, int $storeId, int $orderId, FulfillmentService $fulfillments): JsonResponse
    {
        $this->assertStore($storeId);
        $order = Order::withoutGlobalScopes()->where('store_id', $storeId)->findOrFail($orderId);
        $data = $request->validated();
        $fulfillment = $fulfillments->create($order, $data['lines'], array_filter(['tracking_company' => $data['tracking_company'] ?? null, 'tracking_number' => $data['tracking_number'] ?? null, 'tracking_url' => $data['tracking_url'] ?? null]));

        return response()->json(['data' => $fulfillment->toArray()], 201);
    }

    public function refundOrder(CreateRefundRequest $request, int $storeId, int $orderId, RefundService $refunds): JsonResponse
    {
        $this->assertStore($storeId);
        $order = Order::withoutGlobalScopes()->where('store_id', $storeId)->with('payments')->findOrFail($orderId);
        $data = $request->validated();
        $payment = $order->payments->firstWhere('id', $data['payment_id'] ?? null) ?? $order->payments->firstWhere('status', 'captured');
        abort_unless($payment !== null, 422, 'No captured payment is available.');
        $refund = $refunds->create($order, $payment, $data['lines'] ?? $data['amount'] ?? null, $data['reason'] ?? null, (bool) ($data['restock'] ?? false), $data['lines'] ?? []);

        return response()->json(['data' => $refund->toArray()], 201);
    }

    public function shippingZones(int $storeId): JsonResponse
    {
        $this->assertStore($storeId);

        return response()->json(['data' => ShippingZone::withoutGlobalScopes()->where('store_id', $storeId)->with('rates')->get()]);
    }

    public function storeShippingZone(StoreShippingZoneRequest $request, int $storeId): JsonResponse
    {
        $this->assertStore($storeId);
        $data = $request->validated();

        return response()->json(['data' => ShippingZone::withoutGlobalScopes()->create([...$data, 'store_id' => $storeId])], 201);
    }

    public function updateShippingZone(UpdateShippingZoneRequest $request, int $storeId, int $zoneId): JsonResponse
    {
        $this->assertStore($storeId);
        $zone = ShippingZone::withoutGlobalScopes()->where('store_id', $storeId)->findOrFail($zoneId);
        $zone->update($request->validated());

        return response()->json(['data' => $zone->refresh()]);
    }

    public function storeShippingRate(StoreShippingRateRequest $request, int $storeId, int $zoneId): JsonResponse
    {
        $this->assertStore($storeId);
        ShippingZone::withoutGlobalScopes()->where('store_id', $storeId)->findOrFail($zoneId);
        $data = $request->validated();

        return response()->json(['data' => ShippingRate::create([...$data, 'shipping_zone_id' => $zoneId, 'is_active' => $data['is_active'] ?? true])], 201);
    }

    public function taxSettings(int $storeId): JsonResponse
    {
        $this->assertStore($storeId);

        return response()->json(['data' => TaxSettings::withoutGlobalScopes()->firstOrCreate(['store_id' => $storeId])]);
    }

    public function updateTaxSettings(UpdateTaxSettingsRequest $request, int $storeId): JsonResponse
    {
        $this->assertStore($storeId);
        $data = $request->validated();
        $settings = TaxSettings::withoutGlobalScopes()->updateOrCreate(['store_id' => $storeId], $data);

        return response()->json(['data' => $settings]);


exec

status: completed

input:
const r = await tools.exec_command({
  cmd: "rg -n \"invite|invitation|store_users|api_installation|oauth|media\" database/migrations app/Models database/factories | head -220 && sed -n '300,680p' database/migrations/2026_08_20_000000_create_shop_schema.php && sed -n '1,220p' app/Http/Middleware/RoleCheck.php && sed -n '1,120p' bootstrap/app.php",
  workdir: "/Users/wesner/Herd/shop",
  yield_time_ms: 10000,
  max_output_tokens: 30000
});
text(r.output);


command: rg -n "invite|invitation|store_users|api_installation|oauth|media" database/migrations app/Models database/factories | head -220 && sed -n '300,680p' database/migrations/2026_08_20_000000_create_shop_schema.php && sed -n '1,220p' app/Http/Middleware/RoleCheck.php && sed -n '1,120p' bootstrap/app.php

type: search

cmd: rg -n 'invite|invitation|store_users|api_installation|oauth|media' database/migrations app/Models database/factories

query: invite|invitation|store_users|api_installation|oauth|media

path: migrations

app.php

cmd: sed -n '1,120p' bootstrap/app.php

status: failed

stdout:
database/migrations/2026_08_20_230144_add_remaining_spec_contract_fields.php:59:        Schema::table('oauth_clients', function (Blueprint $table): void {
database/migrations/2026_08_20_230144_add_remaining_spec_contract_fields.php:60:            if (! Schema::hasColumn('oauth_clients', 'app_id')) {
database/migrations/2026_08_20_230144_add_remaining_spec_contract_fields.php:64:            if (! Schema::hasColumn('oauth_clients', 'redirect_uris_json')) {
database/migrations/2026_08_20_230144_add_remaining_spec_contract_fields.php:69:        Schema::table('oauth_tokens', function (Blueprint $table): void {
database/migrations/2026_08_20_230144_add_remaining_spec_contract_fields.php:70:            if (! Schema::hasColumn('oauth_tokens', 'installation_id')) {
database/migrations/2026_08_20_230144_add_remaining_spec_contract_fields.php:74:            if (! Schema::hasColumn('oauth_tokens', 'access_token_hash')) {
database/migrations/2026_08_20_230144_add_remaining_spec_contract_fields.php:78:            if (! Schema::hasColumn('oauth_tokens', 'refresh_token_hash')) {
database/migrations/2026_08_20_230144_add_remaining_spec_contract_fields.php:108:            'app_id' => 'oauth_clients',
database/migrations/2026_08_20_230144_add_remaining_spec_contract_fields.php:109:            'redirect_uris_json' => 'oauth_clients',
database/migrations/2026_08_20_230144_add_remaining_spec_contract_fields.php:110:            'installation_id' => 'oauth_tokens',
database/migrations/2026_08_20_230144_add_remaining_spec_contract_fields.php:111:            'access_token_hash' => 'oauth_tokens',
database/migrations/2026_08_20_230144_add_remaining_spec_contract_fields.php:112:            'refresh_token_hash' => 'oauth_tokens',
app/Models/StoreUser.php:17:    protected $table = 'store_users';
database/migrations/2026_08_20_000000_create_shop_schema.php:50:        Schema::create('store_users', function (Blueprint $table): void {
database/migrations/2026_08_20_000000_create_shop_schema.php:165:        Schema::create('product_media', function (Blueprint $table): void {
database/migrations/2026_08_20_000000_create_shop_schema.php:528:        Schema::create('oauth_clients', function (Blueprint $table): void {
database/migrations/2026_08_20_000000_create_shop_schema.php:538:        Schema::create('oauth_tokens', function (Blueprint $table): void {
database/migrations/2026_08_20_000000_create_shop_schema.php:540:            $table->foreignId('oauth_client_id')->constrained()->cascadeOnDelete();
database/migrations/2026_08_20_000000_create_shop_schema.php:578:            'webhook_deliveries', 'webhook_subscriptions', 'oauth_tokens', 'oauth_clients',
database/migrations/2026_08_20_000000_create_shop_schema.php:584:            'product_media', 'collection_products', 'collections', 'inventory_items',
database/migrations/2026_08_20_000000_create_shop_schema.php:586:            'products', 'customer_addresses', 'customers', 'store_settings', 'store_users',
database/migrations/2026_08_20_210057_add_enum_defaults_to_tenancy_tables.php:23:        Schema::table('store_users', function (Blueprint $table): void {
database/migrations/2026_08_20_210057_add_enum_defaults_to_tenancy_tables.php:41:        Schema::table('store_users', function (Blueprint $table): void {
database/migrations/2026_08_20_220002_add_spec_compatibility_columns.php:19:        Schema::table('product_media', function (Blueprint $table): void {
database/migrations/2026_08_20_220002_add_spec_compatibility_columns.php:65:        Schema::table('product_media', function (Blueprint $table): void {
database/migrations/2026_08_20_223045_add_remaining_schema_contract_columns.php:21:        Schema::table('product_media', function (Blueprint $table): void {
database/migrations/2026_08_20_223045_add_remaining_schema_contract_columns.php:22:            if (! Schema::hasColumn('product_media', 'width')) {
database/migrations/2026_08_20_223045_add_remaining_schema_contract_columns.php:25:            if (! Schema::hasColumn('product_media', 'height')) {
database/migrations/2026_08_20_223045_add_remaining_schema_contract_columns.php:113:        foreach (['password_hash' => 'users', 'store_id' => 'customer_password_reset_tokens', 'width' => 'product_media', 'height' => 'product_media', 'storage_key' => 'theme_files', 'sha256' => 'theme_files', 'byte_size' => 'theme_files', 'body_html' => 'pages', 'menu_id' => 'navigation_items', 'synonyms_json' => 'search_settings', 'stop_words_json' => 'search_settings', 'total_amount' => 'order_lines', 'currency' => 'payments', 'properties_json' => 'analytics_events', 'occurred_at' => 'analytics_events', 'checkout_completed_count' => 'analytics_daily', 'event_type' => 'webhook_subscriptions', 'app_installation_id' => 'webhook_subscriptions', 'subscription_id' => 'webhook_deliveries', 'event_id' => 'webhook_deliveries', 'attempt_count' => 'webhook_deliveries', 'last_attempt_at' => 'webhook_deliveries', 'response_code' => 'webhook_deliveries', 'response_body_snippet' => 'webhook_deliveries'] as $column => $tableName) {
app/Models/User.php:74:        return $this->belongsToMany(Store::class, 'store_users')->using(StoreUser::class)->withPivot('role')->withTimestamps();
app/Models/Store.php:43:        return $this->belongsToMany(User::class, 'store_users')->using(StoreUser::class)->withPivot('role')->withTimestamps();
app/Models/Product.php:33:    public function media(): HasMany
            $table->string('value_type')->default('percent');
            $table->unsignedInteger('value_amount')->default(0);
            $table->string('status')->default('active')->index();
            $table->unsignedInteger('usage_limit')->nullable();
            $table->unsignedInteger('usage_count')->default(0);
            $table->timestamp('starts_at')->nullable();
            $table->timestamp('ends_at')->nullable();
            $table->json('rules_json')->nullable();
            $table->timestamps();
            $table->unique(['store_id', 'code']);
            $table->index(['store_id', 'type']);
        });

        Schema::create('customers', function (Blueprint $table): void {
            $table->id();
            $table->foreignId('store_id')->constrained()->cascadeOnDelete();
            $table->string('first_name');
            $table->string('last_name');
            $table->string('email');
            $table->string('password_hash')->nullable();
            $table->string('status')->default('active')->index();
            $table->timestamp('email_verified_at')->nullable();
            $table->timestamp('last_login_at')->nullable();
            $table->rememberToken();
            $table->json('metadata')->nullable();
            $table->timestamps();
            $table->unique(['store_id', 'email']);
            $table->index('store_id');
        });

        Schema::create('customer_addresses', function (Blueprint $table): void {
            $table->id();
            $table->foreignId('customer_id')->constrained()->cascadeOnDelete();
            $table->string('label')->nullable();
            $table->json('address_json');
            $table->boolean('is_default')->default(false);
            $table->timestamps();
            $table->index(['customer_id', 'is_default']);
        });

        Schema::create('carts', function (Blueprint $table): void {
            $table->id();
            $table->foreignId('store_id')->constrained()->cascadeOnDelete();
            $table->foreignId('customer_id')->nullable()->constrained()->nullOnDelete();
            $table->string('currency', 3)->default('EUR');
            $table->unsignedInteger('cart_version')->default(1);
            $table->string('status')->default('active')->index();
            $table->timestamps();
            $table->index('store_id');
            $table->index('customer_id');
        });

        Schema::create('cart_lines', function (Blueprint $table): void {
            $table->id();
            $table->foreignId('cart_id')->constrained()->cascadeOnDelete();
            $table->foreignId('variant_id')->constrained('product_variants')->restrictOnDelete();
            $table->unsignedInteger('quantity');
            $table->unsignedInteger('unit_price_amount');
            $table->unsignedInteger('line_subtotal_amount');
            $table->unsignedInteger('line_discount_amount')->default(0);
            $table->unsignedInteger('line_total_amount');
            $table->timestamps();
            $table->unique(['cart_id', 'variant_id']);
            $table->index('cart_id');
        });

        Schema::create('checkouts', function (Blueprint $table): void {
            $table->id();
            $table->foreignId('store_id')->constrained()->cascadeOnDelete();
            $table->foreignId('cart_id')->constrained()->restrictOnDelete();
            $table->foreignId('customer_id')->nullable()->constrained()->nullOnDelete();
            $table->string('status')->default('started')->index();
            $table->string('email');
            $table->string('payment_method')->nullable();
            $table->json('shipping_address_json')->nullable();
            $table->json('billing_address_json')->nullable();
            $table->foreignId('shipping_rate_id')->nullable()->constrained('shipping_rates')->nullOnDelete();
            $table->string('discount_code')->nullable();
            $table->json('totals_json')->nullable();
            $table->json('tax_provider_snapshot_json')->nullable();
            $table->timestamp('expires_at')->nullable()->index();
            $table->timestamps();
            $table->index('cart_id');
            $table->index('customer_id');
        });

        Schema::create('orders', function (Blueprint $table): void {
            $table->id();
            $table->foreignId('store_id')->constrained()->cascadeOnDelete();
            $table->foreignId('customer_id')->nullable()->constrained()->nullOnDelete();
            $table->foreignId('checkout_id')->nullable()->constrained()->nullOnDelete();
            $table->string('order_number');
            $table->string('currency', 3)->default('EUR');
            $table->string('status')->default('pending')->index();
            $table->string('financial_status')->default('pending')->index();
            $table->string('fulfillment_status')->default('unfulfilled')->index();
            $table->string('payment_method')->nullable();
            $table->string('email');
            $table->json('shipping_address_json')->nullable();
            $table->json('billing_address_json')->nullable();
            $table->unsignedInteger('subtotal_amount')->default(0);
            $table->unsignedInteger('discount_amount')->default(0);
            $table->unsignedInteger('shipping_amount')->default(0);
            $table->unsignedInteger('tax_amount')->default(0);
            $table->unsignedInteger('total_amount')->default(0);
            $table->timestamp('placed_at')->nullable()->index();
            $table->json('metadata')->nullable();
            $table->timestamps();
            $table->unique(['store_id', 'order_number']);
            $table->index(['store_id', 'status']);
            $table->index(['store_id', 'financial_status']);
            $table->index(['store_id', 'fulfillment_status']);
        });

        Schema::create('order_lines', function (Blueprint $table): void {
            $table->id();
            $table->foreignId('order_id')->constrained()->cascadeOnDelete();
            $table->foreignId('product_id')->nullable()->constrained()->nullOnDelete();
            $table->foreignId('variant_id')->nullable()->constrained('product_variants')->nullOnDelete();
            $table->string('product_title');
            $table->string('variant_title')->nullable();
            $table->string('sku')->nullable();
            $table->unsignedInteger('quantity');
            $table->unsignedInteger('unit_price_amount');
            $table->unsignedInteger('line_subtotal_amount');
            $table->unsignedInteger('line_discount_amount')->default(0);
            $table->unsignedInteger('line_total_amount');
            $table->json('tax_lines_json')->nullable();
            $table->json('discount_allocations_json')->nullable();
            $table->timestamps();
            $table->index('order_id');
        });

        Schema::create('payments', function (Blueprint $table): void {
            $table->id();
            $table->foreignId('order_id')->constrained()->cascadeOnDelete();
            $table->string('provider')->default('mock');
            $table->string('provider_payment_id')->nullable();
            $table->string('method');
            $table->string('status')->default('pending')->index();
            $table->unsignedInteger('amount');
            $table->text('raw_json_encrypted')->nullable();
            $table->timestamps();
            $table->index(['provider', 'provider_payment_id']);
            $table->index('order_id');
        });

        Schema::create('refunds', function (Blueprint $table): void {
            $table->id();
            $table->foreignId('order_id')->constrained()->cascadeOnDelete();
            $table->foreignId('payment_id')->nullable()->constrained()->nullOnDelete();
            $table->unsignedInteger('amount');
            $table->string('status')->default('pending');
            $table->text('reason')->nullable();
            $table->boolean('restock')->default(false);
            $table->timestamps();
            $table->index(['order_id', 'status']);
        });

        Schema::create('fulfillments', function (Blueprint $table): void {
            $table->id();
            $table->foreignId('order_id')->constrained()->cascadeOnDelete();
            $table->string('status')->default('pending')->index();
            $table->string('tracking_company')->nullable();
            $table->string('tracking_number')->nullable();
            $table->text('tracking_url')->nullable();
            $table->timestamp('shipped_at')->nullable();
            $table->timestamp('delivered_at')->nullable();
            $table->timestamp('fulfilled_at')->nullable();
            $table->timestamps();
            $table->index(['tracking_company', 'tracking_number']);
        });

        Schema::create('fulfillment_lines', function (Blueprint $table): void {
            $table->id();
            $table->foreignId('fulfillment_id')->constrained()->cascadeOnDelete();
            $table->foreignId('order_line_id')->constrained()->cascadeOnDelete();
            $table->unsignedInteger('quantity');
            $table->timestamps();
            $table->unique(['fulfillment_id', 'order_line_id']);
        });

        Schema::create('analytics_events', function (Blueprint $table): void {
            $table->id();
            $table->foreignId('store_id')->constrained()->cascadeOnDelete();
            $table->string('type');
            $table->string('session_id')->nullable()->index();
            $table->foreignId('customer_id')->nullable()->constrained()->nullOnDelete();
            $table->string('client_event_id')->nullable();
            $table->json('payload')->nullable();
            $table->timestamps();
            $table->unique(['store_id', 'client_event_id']);
            $table->index(['store_id', 'type']);
            $table->index(['store_id', 'created_at']);
        });

        Schema::create('analytics_daily', function (Blueprint $table): void {
            $table->foreignId('store_id')->constrained()->cascadeOnDelete();
            $table->date('date');
            $table->unsignedInteger('orders_count')->default(0);
            $table->unsignedInteger('revenue_amount')->default(0);
            $table->unsignedInteger('aov_amount')->default(0);
            $table->unsignedInteger('visits_count')->default(0);
            $table->unsignedInteger('add_to_cart_count')->default(0);
            $table->unsignedInteger('checkout_started_count')->default(0);
            $table->primary(['store_id', 'date']);
        });

        Schema::create('apps', function (Blueprint $table): void {
            $table->id();
            $table->string('name');
            $table->string('slug')->unique();
            $table->text('description')->nullable();
            $table->string('status')->default('active');
            $table->json('scopes')->nullable();
            $table->timestamps();
        });

        Schema::create('app_installations', function (Blueprint $table): void {
            $table->id();
            $table->foreignId('store_id')->constrained()->cascadeOnDelete();
            $table->foreignId('app_id')->constrained()->cascadeOnDelete();
            $table->string('status')->default('active');
            $table->json('config')->nullable();
            $table->timestamps();
            $table->unique(['store_id', 'app_id']);
        });

        Schema::create('oauth_clients', function (Blueprint $table): void {
            $table->id();
            $table->foreignId('store_id')->constrained()->cascadeOnDelete();
            $table->string('name');
            $table->string('client_id')->unique();
            $table->text('client_secret_encrypted');
            $table->text('redirect_uris');
            $table->timestamps();
        });

        Schema::create('oauth_tokens', function (Blueprint $table): void {
            $table->id();
            $table->foreignId('oauth_client_id')->constrained()->cascadeOnDelete();
            $table->foreignId('user_id')->nullable()->constrained()->nullOnDelete();
            $table->string('token_hash')->unique();
            $table->json('scopes')->nullable();
            $table->timestamp('expires_at')->nullable();
            $table->timestamp('revoked_at')->nullable();
            $table->timestamps();
        });

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

        Schema::create('webhook_deliveries', function (Blueprint $table): void {
            $table->id();
            $table->foreignId('webhook_subscription_id')->constrained()->cascadeOnDelete();
            $table->string('event');
            $table->json('payload');
            $table->unsignedInteger('attempts')->default(0);
            $table->unsignedSmallInteger('response_status')->nullable();
            $table->text('response_body')->nullable();
            $table->timestamp('delivered_at')->nullable();
            $table->timestamp('next_attempt_at')->nullable();
            $table->timestamps();
        });
    }

    public function down(): void
    {
        foreach ([
            'webhook_deliveries', 'webhook_subscriptions', 'oauth_tokens', 'oauth_clients',
            'app_installations', 'apps', 'analytics_daily', 'analytics_events',
            'fulfillment_lines', 'fulfillments', 'refunds', 'payments', 'order_lines', 'orders',
            'checkouts', 'cart_lines', 'carts', 'discounts', 'tax_settings', 'shipping_rates',
            'shipping_zones', 'search_queries', 'search_settings', 'navigation_items',
            'navigation_menus', 'pages', 'theme_settings', 'theme_files', 'themes',
            'product_media', 'collection_products', 'collections', 'inventory_items',
            'variant_option_values', 'product_variants', 'product_option_values', 'product_options',
            'products', 'customer_addresses', 'customers', 'store_settings', 'store_users',
            'store_domains', 'stores', 'organizations',
        ] as $table) {
            Schema::dropIfExists($table);
        }

        Schema::table('users', function (Blueprint $table): void {
            $table->dropColumn(['status', 'last_login_at']);
        });
    }
};
sed: app/Http/Middleware/RoleCheck.php: No such file or directory