status: completed

input:
*** Begin Patch
*** Update File: app/Http/Controllers/Api/Admin/V1/ProductController.php
@@
 namespace App\Http\Controllers\Api\Admin\V1;

+use App\Enums\MediaStatus;
+use App\Enums\MediaType;
+use App\Enums\ProductStatus;
+use App\Enums\VariantStatus;
+use App\Exceptions\InvalidProductTransitionException;
 use App\Http\Controllers\Controller;
+use App\Http\Requests\Api\Admin\V1\CreateProductMediaUploadRequest;
+use App\Http\Requests\Api\Admin\V1\StoreProductRequest;
+use App\Http\Requests\Api\Admin\V1\UpdateProductRequest;
 use App\Http\Resources\Admin\V1\ProductResource;
+use App\Models\InventoryItem;
 use App\Models\Product;
+use App\Models\ProductMedia;
+use App\Models\ProductOption;
+use App\Models\ProductOptionValue;
+use App\Models\ProductVariant;
 use App\Models\Store;
+use App\Services\ProductService;
+use Illuminate\Contracts\Filesystem\Filesystem;
 use Illuminate\Database\Eloquent\Builder;
+use Illuminate\Http\JsonResponse;
 use Illuminate\Http\Request;
 use Illuminate\Http\Resources\Json\AnonymousResourceCollection;
+use Illuminate\Support\Arr;
+use Illuminate\Support\Facades\DB;
+use Illuminate\Support\Facades\Schema;
+use Illuminate\Support\Facades\Storage;
+use Illuminate\Support\Str;
 use Illuminate\Validation\Rule;
+use Illuminate\Validation\ValidationException;
+use InvalidArgumentException;
+use RuntimeException;
+use Throwable;

 class ProductController extends Controller
 {
@@
     public function show(Request $request, Store $store, Product $product): ProductResource
     {
         $this->authorizeStore($request, $store);
         $this->abortUnlessProductBelongsToStore($product, $store);

         return ProductResource::make($this->loadProduct($product));
     }
+
+    public function store(StoreProductRequest $request, Store $store, ProductService $products): JsonResponse
+    {
+        $this->authorizeStore($request, $store);
+
+        $validated = $request->validated();
+
+        try {
+            $product = DB::transaction(function () use ($products, $store, $validated): Product {
+                $product = $products->create($store, $this->attributesForCreate($validated, $store));
+
+                if (array_key_exists('collections', $validated)) {
+                    $product->collections()->sync($this->integerList($validated['collections']));
+                }
+
+                return $product->refresh();
+            });
+        } catch (InvalidProductTransitionException|InvalidArgumentException|RuntimeException $exception) {
+            $this->throwProductValidationException($exception);
+        }
+
+        return ProductResource::make($this->loadProduct($product))
+            ->response()
+            ->setStatusCode(201);
+    }
+
+    public function update(UpdateProductRequest $request, Store $store, Product $product, ProductService $products): ProductResource
+    {
+        $this->authorizeStore($request, $store);
+        $this->abortUnlessProductBelongsToStore($product, $store);
+
+        $validated = $request->validated();
+
+        try {
+            $product = DB::transaction(function () use ($products, $store, $product, $validated): Product {
+                $product = Product::withoutGlobalScopes()
+                    ->whereKey($product->getKey())
+                    ->lockForUpdate()
+                    ->firstOrFail();
+
+                $attributes = $this->attributesForUpdate($validated);
+                $requestedStatus = null;
+
+                if (array_key_exists('status', $attributes)) {
+                    $requestedStatus = ProductStatus::from((string) $attributes['status']);
+                    unset($attributes['status']);
+                }
+
+                if ($attributes !== []) {
+                    $product = $products->update($product, $attributes);
+                }
+
+                if (array_key_exists('options', $validated)) {
+                    $this->replaceProductOptions($product, $this->optionPayload($validated));
+                }
+
+                if (array_key_exists('variants', $validated)) {
+                    $this->syncProductVariants($product, $store, $validated['variants']);
+                }
+
+                if (array_key_exists('collections', $validated)) {
+                    $product->collections()->sync($this->integerList($validated['collections']));
+                }
+
+                if ($requestedStatus instanceof ProductStatus && $requestedStatus !== $product->refresh()->status) {
+                    $products->transitionStatus($product, $requestedStatus);
+                }
+
+                return $product->refresh();
+            });
+        } catch (InvalidProductTransitionException|InvalidArgumentException|RuntimeException $exception) {
+            $this->throwProductValidationException($exception);
+        }
+
+        return ProductResource::make($this->loadProduct($product));
+    }
+
+    public function destroy(Request $request, Store $store, Product $product, ProductService $products): JsonResponse
+    {
+        $this->authorizeStore($request, $store);
+        $this->abortUnlessProductBelongsToStore($product, $store);
+
+        $products->transitionStatus($product, ProductStatus::Archived);
+
+        return response()->json([
+            'data' => [
+                'id' => $product->getKey(),
+                'status' => ProductStatus::Archived->value,
+                'updated_at' => $product->refresh()->updated_at?->toIso8601String(),
+            ],
+        ]);
+    }
+
+    public function presignUpload(CreateProductMediaUploadRequest $request, Store $store, Product $product): JsonResponse
+    {
+        $this->authorizeStore($request, $store);
+        $this->abortUnlessProductBelongsToStore($product, $store);
+
+        $validated = $request->validated();
+        $expiresAt = now()->addMinutes(10);
+        $storageKey = sprintf(
+            'media/originals/%d/%s.%s',
+            $product->getKey(),
+            Str::uuid(),
+            $this->extensionForContentType((string) $validated['content_type']),
+        );
+        $media = ProductMedia::withoutGlobalScopes()->create([
+            'product_id' => $product->getKey(),
+            'type' => $this->mediaTypeForContentType((string) $validated['content_type']),
+            'storage_key' => $storageKey,
+            'alt_text' => $product->title,
+            'mime_type' => $validated['content_type'],
+            'byte_size' => (int) $validated['byte_size'],
+            'position' => $this->nextMediaPosition($product),
+            'status' => MediaStatus::Processing,
+        ]);
+        $upload = $this->temporaryUploadPayload(Storage::disk('public'), $storageKey, $expiresAt);
+
+        return response()->json([
+            'upload_url' => $upload['url'],
+            'method' => 'PUT',
+            'headers' => [
+                ...$upload['headers'],
+                'Content-Type' => $validated['content_type'],
+            ],
+            'storage_key' => $storageKey,
+            'media_id' => $media->getKey(),
+            'expires_at' => $expiresAt->toIso8601String(),
+        ], 201);
+    }

     private function authorizeStore(Request $request, Store $store): void
     {
         if (! $request->attributes->has('admin_api_oauth_token')) {
@@
     private function applySort(Builder $query, string $sort): void
     {
         match ($sort) {
             'title_asc' => $query->orderBy('title')->orderBy('id'),
             'title_desc' => $query->orderByDesc('title')->orderByDesc('id'),
             'created_at_asc' => $query->orderBy('created_at')->orderBy('id'),
             'created_at_desc' => $query->orderByDesc('created_at')->orderByDesc('id'),
             default => $query->orderByDesc('updated_at')->orderByDesc('id'),
         };
     }
+
+    /**
+     * @param  array<string, mixed>  $validated
+     * @return array<string, mixed>
+     */
+    private function attributesForCreate(array $validated, Store $store): array
+    {
+        $attributes = [
+            'title' => $validated['title'],
+            'description_html' => $validated['description_html'] ?? null,
+            'vendor' => $validated['vendor'] ?? null,
+            'product_type' => $validated['product_type'] ?? null,
+            'status' => $validated['status'] ?? ProductStatus::Draft->value,
+            'tags' => $this->tagList($validated['tags'] ?? []),
+            'options' => $this->optionPayload($validated),
+            'variants' => $this->variantPayloads($validated['variants'], $store, true),
+        ];
+
+        if (filled($validated['handle'] ?? null)) {
+            $attributes['handle'] = Str::slug((string) $validated['handle']);
+        }
+
+        return $attributes;
+    }
+
+    /**
+     * @param  array<string, mixed>  $validated
+     * @return array<string, mixed>
+     */
+    private function attributesForUpdate(array $validated): array
+    {
+        $attributes = Arr::only($validated, [
+            'title',
+            'description_html',
+            'vendor',
+            'product_type',
+            'status',
+        ]);
+
+        if (array_key_exists('tags', $validated)) {
+            $attributes['tags'] = $this->tagList($validated['tags']);
+        }
+
+        if (filled($validated['handle'] ?? null)) {
+            $attributes['handle'] = Str::slug((string) $validated['handle']);
+        }
+
+        return $attributes;
+    }
+
+    /**
+     * @param  array<string, mixed>  $validated
+     * @return array<int, array{name: string, position: int, values: array<int, array{value: string, position: int}>}>
+     */
+    private function optionPayload(array $validated): array
+    {
+        $declaredOptions = collect($validated['options'] ?? []);
+
+        if ($declaredOptions->isEmpty()) {
+            $declaredOptions = collect($validated['variants'] ?? [])
+                ->flatMap(fn (array $variant): array => $variant['option_values'] ?? [])
+                ->pluck('option_name')
+                ->filter()
+                ->unique()
+                ->values()
+                ->map(fn (string $name, int $position): array => [
+                    'name' => $name,
+                    'position' => $position + 1,
+                ]);
+        }
+
+        return $declaredOptions
+            ->map(function (array $option, int $position) use ($validated): array {
+                $name = trim((string) $option['name']);
+                $values = collect($option['values'] ?? [])
+                    ->merge($this->variantOptionValues($validated['variants'] ?? [], $name))
+                    ->map(fn (mixed $value): string => trim((string) $value))
+                    ->filter()
+                    ->unique()
+                    ->values();
+
+                return [
+                    'name' => $name,
+                    'position' => (int) ($option['position'] ?? ($position + 1)),
+                    'values' => $values
+                        ->map(fn (string $value, int $valuePosition): array => [
+                            'value' => $value,
+                            'position' => $valuePosition + 1,
+                        ])
+                        ->all(),
+                ];
+            })
+            ->filter(fn (array $option): bool => $option['name'] !== '')
+            ->values()
+            ->all();
+    }
+
+    /**
+     * @param  array<int, array<string, mixed>>  $variants
+     * @return list<string>
+     */
+    private function variantOptionValues(array $variants, string $optionName): array
+    {
+        return collect($variants)
+            ->flatMap(fn (array $variant): array => $variant['option_values'] ?? [])
+            ->filter(fn (array $value): bool => ($value['option_name'] ?? null) === $optionName)
+            ->pluck('value')
+            ->values()
+            ->all();
+    }
+
+    /**
+     * @param  array<int, array<string, mixed>>  $variants
+     * @return array<int, array<string, mixed>>
+     */
+    private function variantPayloads(array $variants, Store $store, bool $creating = false): array
+    {
+        return collect($variants)
+            ->values()
+            ->map(fn (array $variant, int $position): array => [
+                'sku' => trim((string) $variant['sku']),
+                'barcode' => $variant['barcode'] ?? null,
+                'price_amount' => (int) $variant['price_amount'],
+                'compare_at_amount' => array_key_exists('compare_at_amount', $variant) ? $variant['compare_at_amount'] : null,
+                'currency' => strtoupper((string) ($variant['currency'] ?? $store->default_currency)),
+                'weight_g' => array_key_exists('weight_g', $variant) ? $variant['weight_g'] : null,
+                'requires_shipping' => (bool) data_get($variant, 'requires_shipping', true),
+                'is_default' => (bool) $variant['is_default'],
+                'position' => (int) data_get($variant, 'position', $position),
+                'status' => data_get($variant, 'status', VariantStatus::Active->value),
+                'quantity_on_hand' => data_get($variant, 'inventory.quantity_on_hand', $creating ? 0 : null),
+                'inventory_policy' => data_get($variant, 'inventory.policy', $creating ? 'deny' : null),
+                'options' => $this->variantOptions($variant),
+            ])
+            ->all();
+    }
+
+    /**
+     * @param  array<string, mixed>  $variant
+     * @return array<string, string>
+     */
+    private function variantOptions(array $variant): array
+    {
+        return collect($variant['option_values'] ?? [])
+            ->mapWithKeys(fn (array $optionValue): array => [
+                (string) $optionValue['option_name'] => (string) $optionValue['value'],
+            ])
+            ->all();
+    }
+
+    /**
+     * @param  array<int, array{name: string, position: int, values: array<int, array{value: string, position: int}>}>  $optionPayload
+     */
+    private function replaceProductOptions(Product $product, array $optionPayload): void
+    {
+        $syncedOptionIds = [];
+
+        foreach ($optionPayload as $optionData) {
+            $option = ProductOption::withoutGlobalScopes()
+                ->where('product_id', $product->getKey())
+                ->where('position', $optionData['position'])
+                ->first()
+                ?? $product->options()->create([
+                    'name' => $optionData['name'],
+                    'position' => $optionData['position'],
+                ]);
+
+            $option->forceFill([
+                'name' => $optionData['name'],
+                'position' => $optionData['position'],
+            ])->save();
+
+            $syncedOptionIds[] = $option->getKey();
+            $syncedValueIds = [];
+
+            foreach ($optionData['values'] as $valueData) {
+                $value = ProductOptionValue::withoutGlobalScopes()
+                    ->where('product_option_id', $option->getKey())
+                    ->where('position', $valueData['position'])
+                    ->first()
+                    ?? $option->values()->create([
+                        'value' => $valueData['value'],
+                        'position' => $valueData['position'],
+                    ]);
+
+                $value->forceFill([
+                    'value' => $valueData['value'],
+                    'position' => $valueData['position'],
+                ])->save();
+
+                $syncedValueIds[] = $value->getKey();
+            }
+
+            ProductOptionValue::withoutGlobalScopes()
+                ->where('product_option_id', $option->getKey())
+                ->when($syncedValueIds !== [], fn (Builder $query) => $query->whereNotIn('id', $syncedValueIds))
+                ->delete();
+        }
+
+        ProductOption::withoutGlobalScopes()
+            ->where('product_id', $product->getKey())
+            ->when($syncedOptionIds !== [], fn (Builder $query) => $query->whereNotIn('id', $syncedOptionIds))
+            ->delete();
+    }
+
+    /**
+     * @param  array<int, array<string, mixed>>  $variants
+     */
+    private function syncProductVariants(Product $product, Store $store, array $variants): void
+    {
+        $variantPayloads = $this->variantPayloads($variants, $store);
+        $syncedVariantIds = [];
+
+        foreach ($variantPayloads as $position => $variantData) {
+            $variantId = $variants[$position]['id'] ?? null;
+            $attributes = Arr::only($variantData, [
+                'sku',
+                'barcode',
+                'price_amount',
+                'compare_at_amount',
+                'currency',
+                'weight_g',
+                'requires_shipping',
+                'is_default',
+                'position',
+                'status',
+            ]);
+            $variant = $variantId
+                ? ProductVariant::withoutGlobalScopes()->where('product_id', $product->getKey())->findOrFail($variantId)
+                : ProductVariant::withoutGlobalScopes()->create([
+                    'product_id' => $product->getKey(),
+                    ...$attributes,
+                ]);
+
+            if ($variantId) {
+                $variant->forceFill($attributes)->save();
+            }
+
+            $this->syncVariantInventory($variant, $store, $variantData);
+            $variant->optionValues()->sync($this->optionValueIdsForVariant($product, $variantData['options'] ?? []));
+            $syncedVariantIds[] = $variant->getKey();
+        }
+
+        ProductVariant::withoutGlobalScopes()
+            ->where('product_id', $product->getKey())
+            ->when($syncedVariantIds !== [], fn (Builder $query) => $query->whereNotIn('id', $syncedVariantIds))
+            ->get()
+            ->each(function (ProductVariant $variant): void {
+                if ($this->variantHasOrderLines($variant)) {
+                    $variant->forceFill(['status' => VariantStatus::Archived])->save();
+
+                    return;
+                }
+
+                $variant->delete();
+            });
+    }
+
+    /**
+     * @param  array<string, mixed>  $variantData
+     */
+    private function syncVariantInventory(ProductVariant $variant, Store $store, array $variantData): void
+    {
+        $inventory = InventoryItem::withoutGlobalScopes()
+            ->where('variant_id', $variant->getKey())
+            ->first();
+        $attributes = [];
+
+        if ($variantData['quantity_on_hand'] !== null) {
+            $attributes['quantity_on_hand'] = (int) $variantData['quantity_on_hand'];
+        }
+
+        if ($variantData['inventory_policy'] !== null) {
+            $attributes['policy'] = $variantData['inventory_policy'];
+        }
+
+        if ($inventory instanceof InventoryItem) {
+            if ($attributes !== []) {
+                $inventory->forceFill($attributes)->save();
+            }
+
+            return;
+        }
+
+        InventoryItem::withoutGlobalScopes()->create([
+            'store_id' => $store->getKey(),
+            'variant_id' => $variant->getKey(),
+            'quantity_on_hand' => (int) ($variantData['quantity_on_hand'] ?? 0),
+            'quantity_reserved' => 0,
+            'policy' => $variantData['inventory_policy'] ?? 'deny',
+        ]);
+    }
+
+    /**
+     * @param  array<string, string>  $options
+     * @return list<int>
+     */
+    private function optionValueIdsForVariant(Product $product, array $options): array
+    {
+        if ($options === []) {
+            return [];
+        }
+
+        return collect($options)
+            ->map(function (string $value, string $optionName) use ($product): int {
+                $valueId = ProductOptionValue::withoutGlobalScopes()
+                    ->where('value', $value)
+                    ->whereHas('option', function (Builder $query) use ($product, $optionName): void {
+                        $query
+                            ->withoutGlobalScopes()
+                            ->where('product_id', $product->getKey())
+                            ->where('name', $optionName);
+                    })
+                    ->value('id');
+
+                if ($valueId === null) {
+                    throw new InvalidArgumentException("Variant option selection [{$optionName}: {$value}] is invalid for this product.");
+                }
+
+                return (int) $valueId;
+            })
+            ->values()
+            ->all();
+    }
+
+    private function variantHasOrderLines(ProductVariant $variant): bool
+    {
+        return Schema::hasTable('order_lines')
+            && DB::table('order_lines')->where('variant_id', $variant->getKey())->exists();
+    }
+
+    /**
+     * @param  array<int, mixed>  $tags
+     * @return list<string>
+     */
+    private function tagList(array $tags): array
+    {
+        return collect($tags)
+            ->map(fn (mixed $tag): string => trim((string) $tag))
+            ->filter()
+            ->unique()
+            ->values()
+            ->all();
+    }
+
+    /**
+     * @param  array<int, mixed>  $values
+     * @return list<int>
+     */
+    private function integerList(array $values): array
+    {
+        return collect($values)
+            ->map(fn (mixed $value): int => (int) $value)
+            ->unique()
+            ->values()
+            ->all();
+    }
+
+    private function nextMediaPosition(Product $product): int
+    {
+        $position = ProductMedia::withoutGlobalScopes()
+            ->where('product_id', $product->getKey())
+            ->max('position');
+
+        return $position === null ? 0 : ((int) $position) + 1;
+    }
+
+    private function mediaTypeForContentType(string $contentType): MediaType
+    {
+        return $contentType === 'video/mp4' ? MediaType::Video : MediaType::Image;
+    }
+
+    private function extensionForContentType(string $contentType): string
+    {
+        return match ($contentType) {
+            'image/jpeg' => 'jpg',
+            'image/png' => 'png',
+            'image/webp' => 'webp',
+            'image/avif' => 'avif',
+            'video/mp4' => 'mp4',
+            default => 'bin',
+        };
+    }
+
+    /**
+     * @return array{url: string, headers: array<string, string>}
+     */
+    private function temporaryUploadPayload(Filesystem $disk, string $storageKey, mixed $expiresAt): array
+    {
+        try {
+            $payload = $disk->temporaryUploadUrl($storageKey, $expiresAt);
+
+            return [
+                'url' => (string) $payload['url'],
+                'headers' => $payload['headers'] ?? [],
+            ];
+        } catch (Throwable) {
+            return [
+                'url' => $disk->url($storageKey),
+                'headers' => [],
+            ];
+        }
+    }
+
+    private function throwProductValidationException(Throwable $exception): never
+    {
+        throw ValidationException::withMessages([
+            'product' => [$exception->getMessage()],
+        ]);
+    }
 }
*** End Patch


stdout:
Success. Updated the following files:
M app/Http/Controllers/Api/Admin/V1/ProductController.php

type: update

unified_diff:
@@ -4,10 +4,35 @@

+use App\Enums\MediaStatus;
+use App\Enums\MediaType;
+use App\Enums\ProductStatus;
+use App\Enums\VariantStatus;
+use App\Exceptions\InvalidProductTransitionException;
 use App\Http\Controllers\Controller;
+use App\Http\Requests\Api\Admin\V1\CreateProductMediaUploadRequest;
+use App\Http\Requests\Api\Admin\V1\StoreProductRequest;
+use App\Http\Requests\Api\Admin\V1\UpdateProductRequest;
 use App\Http\Resources\Admin\V1\ProductResource;
+use App\Models\InventoryItem;
 use App\Models\Product;
+use App\Models\ProductMedia;
+use App\Models\ProductOption;
+use App\Models\ProductOptionValue;
+use App\Models\ProductVariant;
 use App\Models\Store;
+use App\Services\ProductService;
+use Illuminate\Contracts\Filesystem\Filesystem;
 use Illuminate\Database\Eloquent\Builder;
+use Illuminate\Http\JsonResponse;
 use Illuminate\Http\Request;
 use Illuminate\Http\Resources\Json\AnonymousResourceCollection;
+use Illuminate\Support\Arr;
+use Illuminate\Support\Facades\DB;
+use Illuminate\Support\Facades\Schema;
+use Illuminate\Support\Facades\Storage;
+use Illuminate\Support\Str;
 use Illuminate\Validation\Rule;
+use Illuminate\Validation\ValidationException;
+use InvalidArgumentException;
+use RuntimeException;
+use Throwable;

@@ -62,2 +87,132 @@

+    public function store(StoreProductRequest $request, Store $store, ProductService $products): JsonResponse
+    {
+        $this->authorizeStore($request, $store);
+
+        $validated = $request->validated();
+
+        try {
+            $product = DB::transaction(function () use ($products, $store, $validated): Product {
+                $product = $products->create($store, $this->attributesForCreate($validated, $store));
+
+                if (array_key_exists('collections', $validated)) {
+                    $product->collections()->sync($this->integerList($validated['collections']));
+                }
+
+                return $product->refresh();
+            });
+        } catch (InvalidProductTransitionException|InvalidArgumentException|RuntimeException $exception) {
+            $this->throwProductValidationException($exception);
+        }
+
+        return ProductResource::make($this->loadProduct($product))
+            ->response()
+            ->setStatusCode(201);
+    }
+
+    public function update(UpdateProductRequest $request, Store $store, Product $product, ProductService $products): ProductResource
+    {
+        $this->authorizeStore($request, $store);
+        $this->abortUnlessProductBelongsToStore($product, $store);
+
+        $validated = $request->validated();
+
+        try {
+            $product = DB::transaction(function () use ($products, $store, $product, $validated): Product {
+                $product = Product::withoutGlobalScopes()
+                    ->whereKey($product->getKey())
+                    ->lockForUpdate()
+                    ->firstOrFail();
+
+                $attributes = $this->attributesForUpdate($validated);
+                $requestedStatus = null;
+
+                if (array_key_exists('status', $attributes)) {
+                    $requestedStatus = ProductStatus::from((string) $attributes['status']);
+                    unset($attributes['status']);
+                }
+
+                if ($attributes !== []) {
+                    $product = $products->update($product, $attributes);
+                }
+
+                if (array_key_exists('options', $validated)) {
+                    $this->replaceProductOptions($product, $this->optionPayload($validated));
+                }
+
+                if (array_key_exists('variants', $validated)) {
+                    $this->syncProductVariants($product, $store, $validated['variants']);
+                }
+
+                if (array_key_exists('collections', $validated)) {
+                    $product->collections()->sync($this->integerList($validated['collections']));
+                }
+
+                if ($requestedStatus instanceof ProductStatus && $requestedStatus !== $product->refresh()->status) {
+                    $products->transitionStatus($product, $requestedStatus);
+                }
+
+                return $product->refresh();
+            });
+        } catch (InvalidProductTransitionException|InvalidArgumentException|RuntimeException $exception) {
+            $this->throwProductValidationException($exception);
+        }
+
+        return ProductResource::make($this->loadProduct($product));
+    }
+
+    public function destroy(Request $request, Store $store, Product $product, ProductService $products): JsonResponse
+    {
+        $this->authorizeStore($request, $store);
+        $this->abortUnlessProductBelongsToStore($product, $store);
+
+        $products->transitionStatus($product, ProductStatus::Archived);
+
+        return response()->json([
+            'data' => [
+                'id' => $product->getKey(),
+                'status' => ProductStatus::Archived->value,
+                'updated_at' => $product->refresh()->updated_at?->toIso8601String(),
+            ],
+        ]);
+    }
+
+    public function presignUpload(CreateProductMediaUploadRequest $request, Store $store, Product $product): JsonResponse
+    {
+        $this->authorizeStore($request, $store);
+        $this->abortUnlessProductBelongsToStore($product, $store);
+
+        $validated = $request->validated();
+        $expiresAt = now()->addMinutes(10);
+        $storageKey = sprintf(
+            'media/originals/%d/%s.%s',
+            $product->getKey(),
+            Str::uuid(),
+            $this->extensionForContentType((string) $validated['content_type']),
+        );
+        $media = ProductMedia::withoutGlobalScopes()->create([
+            'product_id' => $product->getKey(),
+            'type' => $this->mediaTypeForContentType((string) $validated['content_type']),
+            'storage_key' => $storageKey,
+            'alt_text' => $product->title,
+            'mime_type' => $validated['content_type'],
+            'byte_size' => (int) $validated['byte_size'],
+            'position' => $this->nextMediaPosition($product),
+            'status' => MediaStatus::Processing,
+        ]);
+        $upload = $this->temporaryUploadPayload(Storage::disk('public'), $storageKey, $expiresAt);
+
+        return response()->json([
+            'upload_url' => $upload['url'],
+            'method' => 'PUT',
+            'headers' => [
+                ...$upload['headers'],
+                'Content-Type' => $validated['content_type'],
+            ],
+            'storage_key' => $storageKey,
+            'media_id' => $media->getKey(),
+            'expires_at' => $expiresAt->toIso8601String(),
+        ], 201);
+    }
+
     private function authorizeStore(Request $request, Store $store): void
@@ -97,2 +252,412 @@
     }
+
+    /**
+     * @param  array<string, mixed>  $validated
+     * @return array<string, mixed>
+     */
+    private function attributesForCreate(array $validated, Store $store): array
+    {
+        $attributes = [
+            'title' => $validated['title'],
+            'description_html' => $validated['description_html'] ?? null,
+            'vendor' => $validated['vendor'] ?? null,
+            'product_type' => $validated['product_type'] ?? null,
+            'status' => $validated['status'] ?? ProductStatus::Draft->value,
+            'tags' => $this->tagList($validated['tags'] ?? []),
+            'options' => $this->optionPayload($validated),
+            'variants' => $this->variantPayloads($validated['variants'], $store, true),
+        ];
+
+        if (filled($validated['handle'] ?? null)) {
+            $attributes['handle'] = Str::slug((string) $validated['handle']);
+        }
+
+        return $attributes;
+    }
+
+    /**
+     * @param  array<string, mixed>  $validated
+     * @return array<string, mixed>
+     */
+    private function attributesForUpdate(array $validated): array
+    {
+        $attributes = Arr::only($validated, [
+            'title',
+            'description_html',
+            'vendor',
+            'product_type',
+            'status',
+        ]);
+
+        if (array_key_exists('tags', $validated)) {
+            $attributes['tags'] = $this->tagList($validated['tags']);
+        }
+
+        if (filled($validated['handle'] ?? null)) {
+            $attributes['handle'] = Str::slug((string) $validated['handle']);
+        }
+
+        return $attributes;
+    }
+
+    /**
+     * @param  array<string, mixed>  $validated
+     * @return array<int, array{name: string, position: int, values: array<int, array{value: string, position: int}>}>
+     */
+    private function optionPayload(array $validated): array
+    {
+        $declaredOptions = collect($validated['options'] ?? []);
+
+        if ($declaredOptions->isEmpty()) {
+            $declaredOptions = collect($validated['variants'] ?? [])
+                ->flatMap(fn (array $variant): array => $variant['option_values'] ?? [])
+                ->pluck('option_name')
+                ->filter()
+                ->unique()
+                ->values()
+                ->map(fn (string $name, int $position): array => [
+                    'name' => $name,
+                    'position' => $position + 1,
+                ]);
+        }
+
+        return $declaredOptions
+            ->map(function (array $option, int $position) use ($validated): array {
+                $name = trim((string) $option['name']);
+                $values = collect($option['values'] ?? [])
+                    ->merge($this->variantOptionValues($validated['variants'] ?? [], $name))
+                    ->map(fn (mixed $value): string => trim((string) $value))
+                    ->filter()
+                    ->unique()
+                    ->values();
+
+                return [
+                    'name' => $name,
+                    'position' => (int) ($option['position'] ?? ($position + 1)),
+                    'values' => $values
+                        ->map(fn (string $value, int $valuePosition): array => [
+                            'value' => $value,
+                            'position' => $valuePosition + 1,
+                        ])
+                        ->all(),
+                ];
+            })
+            ->filter(fn (array $option): bool => $option['name'] !== '')
+            ->values()
+            ->all();
+    }
+
+    /**
+     * @param  array<int, array<string, mixed>>  $variants
+     * @return list<string>
+     */
+    private function variantOptionValues(array $variants, string $optionName): array
+    {
+        return collect($variants)
+            ->flatMap(fn (array $variant): array => $variant['option_values'] ?? [])
+            ->filter(fn (array $value): bool => ($value['option_name'] ?? null) === $optionName)
+            ->pluck('value')
+            ->values()
+            ->all();
+    }
+
+    /**
+     * @param  array<int, array<string, mixed>>  $variants
+     * @return array<int, array<string, mixed>>
+     */
+    private function variantPayloads(array $variants, Store $store, bool $creating = false): array
+    {
+        return collect($variants)
+            ->values()
+            ->map(fn (array $variant, int $position): array => [
+                'sku' => trim((string) $variant['sku']),
+                'barcode' => $variant['barcode'] ?? null,
+                'price_amount' => (int) $variant['price_amount'],
+                'compare_at_amount' => array_key_exists('compare_at_amount', $variant) ? $variant['compare_at_amount'] : null,
+                'currency' => strtoupper((string) ($variant['currency'] ?? $store->default_currency)),
+                'weight_g' => array_key_exists('weight_g', $variant) ? $variant['weight_g'] : null,
+                'requires_shipping' => (bool) data_get($variant, 'requires_shipping', true),
+                'is_default' => (bool) $variant['is_default'],
+                'position' => (int) data_get($variant, 'position', $position),
+                'status' => data_get($variant, 'status', VariantStatus::Active->value),
+                'quantity_on_hand' => data_get($variant, 'inventory.quantity_on_hand', $creating ? 0 : null),
+                'inventory_policy' => data_get($variant, 'inventory.policy', $creating ? 'deny' : null),
+                'options' => $this->variantOptions($variant),
+            ])
+            ->all();
+    }
+
+    /**
+     * @param  array<string, mixed>  $variant
+     * @return array<string, string>
+     */
+    private function variantOptions(array $variant): array
+    {
+        return collect($variant['option_values'] ?? [])
+            ->mapWithKeys(fn (array $optionValue): array => [
+                (string) $optionValue['option_name'] => (string) $optionValue['value'],
+            ])
+            ->all();
+    }
+
+    /**
+     * @param  array<int, array{name: string, position: int, values: array<int, array{value: string, position: int}>}>  $optionPayload
+     */
+    private function replaceProductOptions(Product $product, array $optionPayload): void
+    {
+        $syncedOptionIds = [];
+
+        foreach ($optionPayload as $optionData) {
+            $option = ProductOption::withoutGlobalScopes()
+                ->where('product_id', $product->getKey())
+                ->where('position', $optionData['position'])
+                ->first()
+                ?? $product->options()->create([
+                    'name' => $optionData['name'],
+                    'position' => $optionData['position'],
+                ]);
+
+            $option->forceFill([
+                'name' => $optionData['name'],
+                'position' => $optionData['position'],
+            ])->save();
+
+            $syncedOptionIds[] = $option->getKey();
+            $syncedValueIds = [];
+
+            foreach ($optionData['values'] as $valueData) {
+                $value = ProductOptionValue::withoutGlobalScopes()
+                    ->where('product_option_id', $option->getKey())
+                    ->where('position', $valueData['position'])
+                    ->first()
+                    ?? $option->values()->create([
+                        'value' => $valueData['value'],
+                        'position' => $valueData['position'],
+                    ]);
+
+                $value->forceFill([
+                    'value' => $valueData['value'],
+                    'position' => $valueData['position'],
+                ])->save();
+
+                $syncedValueIds[] = $value->getKey();
+            }
+
+            ProductOptionValue::withoutGlobalScopes()
+                ->where('product_option_id', $option->getKey())
+                ->when($syncedValueIds !== [], fn (Builder $query) => $query->whereNotIn('id', $syncedValueIds))
+                ->delete();
+        }
+
+        ProductOption::withoutGlobalScopes()
+            ->where('product_id', $product->getKey())
+            ->when($syncedOptionIds !== [], fn (Builder $query) => $query->whereNotIn('id', $syncedOptionIds))
+            ->delete();
+    }
+
+    /**
+     * @param  array<int, array<string, mixed>>  $variants
+     */
+    private function syncProductVariants(Product $product, Store $store, array $variants): void
+    {
+        $variantPayloads = $this->variantPayloads($variants, $store);
+        $syncedVariantIds = [];
+
+        foreach ($variantPayloads as $position => $variantData) {
+            $variantId = $variants[$position]['id'] ?? null;
+            $attributes = Arr::only($variantData, [
+                'sku',
+                'barcode',
+                'price_amount',
+                'compare_at_amount',
+                'currency',
+                'weight_g',
+                'requires_shipping',
+                'is_default',
+                'position',
+                'status',
+            ]);
+            $variant = $variantId
+                ? ProductVariant::withoutGlobalScopes()->where('product_id', $product->getKey())->findOrFail($variantId)
+                : ProductVariant::withoutGlobalScopes()->create([
+                    'product_id' => $product->getKey(),
+                    ...$attributes,
+                ]);
+
+            if ($variantId) {
+                $variant->forceFill($attributes)->save();
+            }
+
+            $this->syncVariantInventory($variant, $store, $variantData);
+            $variant->optionValues()->sync($this->optionValueIdsForVariant($product, $variantData['options'] ?? []));
+            $syncedVariantIds[] = $variant->getKey();
+        }
+
+        ProductVariant::withoutGlobalScopes()
+            ->where('product_id', $product->getKey())
+            ->when($syncedVariantIds !== [], fn (Builder $query) => $query->whereNotIn('id', $syncedVariantIds))
+            ->get()
+            ->each(function (ProductVariant $variant): void {
+                if ($this->variantHasOrderLines($variant)) {
+                    $variant->forceFill(['status' => VariantStatus::Archived])->save();
+
+                    return;
+                }
+
+                $variant->delete();
+            });
+    }
+
+    /**
+     * @param  array<string, mixed>  $variantData
+     */
+    private function syncVariantInventory(ProductVariant $variant, Store $store, array $variantData): void
+    {
+        $inventory = InventoryItem::withoutGlobalScopes()
+            ->where('variant_id', $variant->getKey())
+            ->first();
+        $attributes = [];
+
+        if ($variantData['quantity_on_hand'] !== null) {
+            $attributes['quantity_on_hand'] = (int) $variantData['quantity_on_hand'];
+        }
+
+        if ($variantData['inventory_policy'] !== null) {
+            $attributes['policy'] = $variantData['inventory_policy'];
+        }
+
+        if ($inventory instanceof InventoryItem) {
+            if ($attributes !== []) {
+                $inventory->forceFill($attributes)->save();
+            }
+
+            return;
+        }
+
+        InventoryItem::withoutGlobalScopes()->create([
+            'store_id' => $store->getKey(),
+            'variant_id' => $variant->getKey(),
+            'quantity_on_hand' => (int) ($variantData['quantity_on_hand'] ?? 0),
+            'quantity_reserved' => 0,
+            'policy' => $variantData['inventory_policy'] ?? 'deny',
+        ]);
+    }
+
+    /**
+     * @param  array<string, string>  $options
+     * @return list<int>
+     */
+    private function optionValueIdsForVariant(Product $product, array $options): array
+    {
+        if ($options === []) {
+            return [];
+        }
+
+        return collect($options)
+            ->map(function (string $value, string $optionName) use ($product): int {
+                $valueId = ProductOptionValue::withoutGlobalScopes()
+                    ->where('value', $value)
+                    ->whereHas('option', function (Builder $query) use ($product, $optionName): void {
+                        $query
+                            ->withoutGlobalScopes()
+                            ->where('product_id', $product->getKey())
+                            ->where('name', $optionName);
+                    })
+                    ->value('id');
+
+                if ($valueId === null) {
+                    throw new InvalidArgumentException("Variant option selection [{$optionName}: {$value}] is invalid for this product.");
+                }
+
+                return (int) $valueId;
+            })
+            ->values()
+            ->all();
+    }
+
+    private function variantHasOrderLines(ProductVariant $variant): bool
+    {
+        return Schema::hasTable('order_lines')
+            && DB::table('order_lines')->where('variant_id', $variant->getKey())->exists();
+    }
+
+    /**
+     * @param  array<int, mixed>  $tags
+     * @return list<string>
+     */
+    private function tagList(array $tags): array
+    {
+        return collect($tags)
+            ->map(fn (mixed $tag): string => trim((string) $tag))
+            ->filter()
+            ->unique()
+            ->values()
+            ->all();
+    }
+
+    /**
+     * @param  array<int, mixed>  $values
+     * @return list<int>
+     */
+    private function integerList(array $values): array
+    {
+        return collect($values)
+            ->map(fn (mixed $value): int => (int) $value)
+            ->unique()
+            ->values()
+            ->all();
+    }
+
+    private function nextMediaPosition(Product $product): int
+    {
+        $position = ProductMedia::withoutGlobalScopes()
+            ->where('product_id', $product->getKey())
+            ->max('position');
+
+        return $position === null ? 0 : ((int) $position) + 1;
+    }
+
+    private function mediaTypeForContentType(string $contentType): MediaType
+    {
+        return $contentType === 'video/mp4' ? MediaType::Video : MediaType::Image;
+    }
+
+    private function extensionForContentType(string $contentType): string
+    {
+        return match ($contentType) {
+            'image/jpeg' => 'jpg',
+            'image/png' => 'png',
+            'image/webp' => 'webp',
+            'image/avif' => 'avif',
+            'video/mp4' => 'mp4',
+            default => 'bin',
+        };
+    }
+
+    /**
+     * @return array{url: string, headers: array<string, string>}
+     */
+    private function temporaryUploadPayload(Filesystem $disk, string $storageKey, mixed $expiresAt): array
+    {
+        try {
+            $payload = $disk->temporaryUploadUrl($storageKey, $expiresAt);
+
+            return [
+                'url' => (string) $payload['url'],
+                'headers' => $payload['headers'] ?? [],
+            ];
+        } catch (Throwable) {
+            return [
+                'url' => $disk->url($storageKey),
+                'headers' => [],
+            ];
+        }
+    }
+
+    private function throwProductValidationException(Throwable $exception): never
+    {
+        throw ValidationException::withMessages([
+            'product' => [$exception->getMessage()],
+        ]);
+    }
 }

status: completed


output: {"output":"Success. Updated the following files:\nM app/Http/Controllers/Api/Admin/V1/ProductController.php\n","metadata":{"exit_code":0,"duration_seconds":0.0}}


status: completed

input:
*** Begin Patch
*** Update File: app/Http/Controllers/Api/Admin/V1/ProductController.php
@@
 use App\Models\ProductOptionValue;
 use App\Models\ProductVariant;
 use App\Models\Store;
 use App\Services\ProductService;
-use Illuminate\Contracts\Filesystem\Filesystem;
 use Illuminate\Database\Eloquent\Builder;
+use Illuminate\Filesystem\FilesystemAdapter;
@@
-    private function temporaryUploadPayload(Filesystem $disk, string $storageKey, mixed $expiresAt): array
+    private function temporaryUploadPayload(FilesystemAdapter $disk, string $storageKey, mixed $expiresAt): array
*** End Patch


unified_diff:
@@ -22,4 +22,4 @@
 use App\Services\ProductService;
-use Illuminate\Contracts\Filesystem\Filesystem;
 use Illuminate\Database\Eloquent\Builder;
+use Illuminate\Filesystem\FilesystemAdapter;
 use Illuminate\Http\JsonResponse;
@@ -639,3 +639,3 @@
      */
-    private function temporaryUploadPayload(Filesystem $disk, string $storageKey, mixed $expiresAt): array
+    private function temporaryUploadPayload(FilesystemAdapter $disk, string $storageKey, mixed $expiresAt): array
     {


output: {"output":"Success. Updated the following files:\nM app/Http/Controllers/Api/Admin/V1/ProductController.php\n","metadata":{"exit_code":0,"duration_seconds":0.0}}


status: completed

input:
*** Begin Patch
*** Update File: routes/api.php
@@
         Route::middleware('admin.api:read-products')->group(function (): void {
             Route::get('products', [AdminProductController::class, 'index'])->name('products.index');
             Route::get('products/{product}', [AdminProductController::class, 'show'])->name('products.show');
         });

+        Route::middleware('admin.api:write-products')->group(function (): void {
+            Route::post('products', [AdminProductController::class, 'store'])->name('products.store');
+            Route::put('products/{product}', [AdminProductController::class, 'update'])->name('products.update');
+            Route::delete('products/{product}', [AdminProductController::class, 'destroy'])->name('products.destroy');
+            Route::post('products/{product}/media/presign-upload', [AdminProductController::class, 'presignUpload'])->name('products.media.presign-upload');
+        });
+
         Route::middleware('admin.api:read-customers')->group(function (): void {
             Route::get('customers', [AdminCustomerController::class, 'index'])->name('customers.index');
             Route::get('customers/{customer}', [AdminCustomerController::class, 'show'])->name('customers.show');
         });
*** End Patch


stdout:
Success. Updated the following files:
M routes/api.php

unified_diff:
@@ -71,2 +71,9 @@

+        Route::middleware('admin.api:write-products')->group(function (): void {
+            Route::post('products', [AdminProductController::class, 'store'])->name('products.store');
+            Route::put('products/{product}', [AdminProductController::class, 'update'])->name('products.update');
+            Route::delete('products/{product}', [AdminProductController::class, 'destroy'])->name('products.destroy');
+            Route::post('products/{product}/media/presign-upload', [AdminProductController::class, 'presignUpload'])->name('products.media.presign-upload');
+        });
+
         Route::middleware('admin.api:read-customers')->group(function (): void {


output: {"output":"Success. Updated the following files:\nM routes/api.php\n","metadata":{"exit_code":0,"duration_seconds":0.0}}


status: completed

input:
*** Begin Patch
*** Update File: tests/Feature/Api/AdminCatalogApiTest.php
@@
+use App\Enums\MediaStatus;
+use App\Enums\ProductStatus;
+use App\Models\Collection as ProductCollection;
 use App\Models\Customer;
 use App\Models\CustomerAddress;
 use App\Models\Order;
 use App\Models\Product;
+use App\Models\ProductMedia;
 use App\Models\ProductVariant;
 use App\Models\Store;
 use App\Models\User;
@@
 test('admin product api lists and shows store scoped products', function (): void {
@@
 });
+
+test('admin product api creates updates and archives products', function (): void {
+    $store = adminCatalogApiStore();
+    $collection = ProductCollection::factory()->create([
+        'store_id' => $store->getKey(),
+        'title' => 'API Summer',
+        'handle' => 'api-summer',
+    ]);
+    $user = adminCatalogApiUser();
+
+    $createResponse = $this->actingAs($user)
+        ->postJson("/api/admin/v1/stores/{$store->getKey()}/products", [
+            'title' => 'API Cotton T-Shirt',
+            'handle' => 'api-cotton-t-shirt',
+            'description_html' => '
Soft cotton tee.
',
+            'vendor' => 'API Apparel',
+            'product_type' => 'Shirts',
+            'status' => 'active',
+            'tags' => ['organic', 'cotton'],
+            'options' => [
+                ['name' => 'Color', 'position' => 1],
+                ['name' => 'Size', 'position' => 2],
+            ],
+            'variants' => [
+                [
+                    'sku' => 'API-TEE-BLU-S',
+                    'price_amount' => 2500,
+                    'compare_at_amount' => 3000,
+                    'is_default' => true,
+                    'position' => 1,
+                    'option_values' => [
+                        ['option_name' => 'Color', 'value' => 'Blue'],
+                        ['option_name' => 'Size', 'value' => 'Small'],
+                    ],
+                    'inventory' => [
+                        'quantity_on_hand' => 12,
+                        'policy' => 'deny',
+                    ],
+                ],
+                [
+                    'sku' => 'API-TEE-BLU-M',
+                    'price_amount' => 2600,
+                    'is_default' => false,
+                    'position' => 2,
+                    'option_values' => [
+                        ['option_name' => 'Color', 'value' => 'Blue'],
+                        ['option_name' => 'Size', 'value' => 'Medium'],
+                    ],
+                    'inventory' => [
+                        'quantity_on_hand' => 8,
+                        'policy' => 'continue',
+                    ],
+                ],
+            ],
+            'collections' => [$collection->getKey()],
+        ])
+        ->assertCreated()
+        ->assertJsonPath('data.title', 'API Cotton T-Shirt')
+        ->assertJsonPath('data.status', 'active')
+        ->assertJsonPath('data.variants.0.inventory.quantity_on_hand', 12)
+        ->assertJsonPath('data.collections.0.handle', 'api-summer');
+
+    $product = Product::withoutGlobalScopes()
+        ->where('store_id', $store->getKey())
+        ->where('handle', 'api-cotton-t-shirt')
+        ->firstOrFail();
+    $defaultVariant = ProductVariant::withoutGlobalScopes()
+        ->where('product_id', $product->getKey())
+        ->where('is_default', true)
+        ->firstOrFail();
+    $removedVariant = ProductVariant::withoutGlobalScopes()
+        ->where('product_id', $product->getKey())
+        ->where('sku', 'API-TEE-BLU-M')
+        ->firstOrFail();
+
+    expect($product->status)->toBe(ProductStatus::Active)
+        ->and($product->options()->count())->toBe(2)
+        ->and($product->variants()->count())->toBe(2);
+
+    $this->actingAs($user)
+        ->putJson("/api/admin/v1/stores/{$store->getKey()}/products/{$product->getKey()}", [
+            'title' => 'API Cotton T-Shirt Updated',
+            'tags' => ['organic', 'bestseller'],
+            'variants' => [
+                [
+                    'id' => $defaultVariant->getKey(),
+                    'sku' => 'API-TEE-BLU-S-UPDATED',
+                    'price_amount' => 2700,
+                    'is_default' => true,
+                    'position' => 1,
+                    'option_values' => [
+                        ['option_name' => 'Color', 'value' => 'Blue'],
+                        ['option_name' => 'Size', 'value' => 'Small'],
+                    ],
+                    'inventory' => [
+                        'quantity_on_hand' => 15,
+                    ],
+                ],
+            ],
+        ])
+        ->assertOk()
+        ->assertJsonPath('data.title', 'API Cotton T-Shirt Updated')
+        ->assertJsonPath('data.variants.0.sku', 'API-TEE-BLU-S-UPDATED')
+        ->assertJsonPath('data.variants.0.inventory.quantity_on_hand', 15);
+
+    $this->assertModelMissing($removedVariant);
+
+    $this->actingAs($user)
+        ->deleteJson("/api/admin/v1/stores/{$store->getKey()}/products/{$product->getKey()}")
+        ->assertOk()
+        ->assertJsonPath('data.status', 'archived');
+
+    expect($product->refresh()->status)->toBe(ProductStatus::Archived);
+});
+
+test('admin product api presigns media uploads', function (): void {
+    $store = adminCatalogApiStore();
+    $product = Product::factory()->withDefaultVariant()->create([
+        'store_id' => $store->getKey(),
+        'title' => 'API Media Product',
+    ]);
+
+    $this->actingAs(adminCatalogApiUser())
+        ->postJson("/api/admin/v1/stores/{$store->getKey()}/products/{$product->getKey()}/media/presign-upload", [
+            'filename' => 'front.jpg',
+            'content_type' => 'image/jpeg',
+            'byte_size' => 245000,
+        ])
+        ->assertCreated()
+        ->assertJsonPath('method', 'PUT')
+        ->assertJsonPath('headers.Content-Type', 'image/jpeg')
+        ->assertJsonPath('storage_key', fn (string $storageKey): bool => str_starts_with($storageKey, "media/originals/{$product->getKey()}/"))
+        ->assertJsonPath('upload_url', fn (string $uploadUrl): bool => $uploadUrl !== '');
+
+    $media = ProductMedia::withoutGlobalScopes()
+        ->where('product_id', $product->getKey())
+        ->firstOrFail();
+
+    expect($media->status)->toBe(MediaStatus::Processing)
+        ->and($media->mime_type)->toBe('image/jpeg')
+        ->and($media->byte_size)->toBe(245000)
+        ->and($media->position)->toBe(0);
+});
*** End Patch


stdout:
Success. Updated the following files:
M tests/Feature/Api/AdminCatalogApiTest.php

unified_diff:
@@ -2,2 +2,5 @@

+use App\Enums\MediaStatus;
+use App\Enums\ProductStatus;
+use App\Models\Collection as ProductCollection;
 use App\Models\Customer;
@@ -6,2 +9,3 @@
 use App\Models\Product;
+use App\Models\ProductMedia;
 use App\Models\ProductVariant;
@@ -79,2 +83,145 @@

+test('admin product api creates updates and archives products', function (): void {
+    $store = adminCatalogApiStore();
+    $collection = ProductCollection::factory()->create([
+        'store_id' => $store->getKey(),
+        'title' => 'API Summer',
+        'handle' => 'api-summer',
+    ]);
+    $user = adminCatalogApiUser();
+
+    $createResponse = $this->actingAs($user)
+        ->postJson("/api/admin/v1/stores/{$store->getKey()}/products", [
+            'title' => 'API Cotton T-Shirt',
+            'handle' => 'api-cotton-t-shirt',
+            'description_html' => '
Soft cotton tee.
',
+            'vendor' => 'API Apparel',
+            'product_type' => 'Shirts',
+            'status' => 'active',
+            'tags' => ['organic', 'cotton'],
+            'options' => [
+                ['name' => 'Color', 'position' => 1],
+                ['name' => 'Size', 'position' => 2],
+            ],
+            'variants' => [
+                [
+                    'sku' => 'API-TEE-BLU-S',
+                    'price_amount' => 2500,
+                    'compare_at_amount' => 3000,
+                    'is_default' => true,
+                    'position' => 1,
+                    'option_values' => [
+                        ['option_name' => 'Color', 'value' => 'Blue'],
+                        ['option_name' => 'Size', 'value' => 'Small'],
+                    ],
+                    'inventory' => [
+                        'quantity_on_hand' => 12,
+                        'policy' => 'deny',
+                    ],
+                ],
+                [
+                    'sku' => 'API-TEE-BLU-M',
+                    'price_amount' => 2600,
+                    'is_default' => false,
+                    'position' => 2,
+                    'option_values' => [
+                        ['option_name' => 'Color', 'value' => 'Blue'],
+                        ['option_name' => 'Size', 'value' => 'Medium'],
+                    ],
+                    'inventory' => [
+                        'quantity_on_hand' => 8,
+                        'policy' => 'continue',
+                    ],
+                ],
+            ],
+            'collections' => [$collection->getKey()],
+        ])
+        ->assertCreated()
+        ->assertJsonPath('data.title', 'API Cotton T-Shirt')
+        ->assertJsonPath('data.status', 'active')
+        ->assertJsonPath('data.variants.0.inventory.quantity_on_hand', 12)
+        ->assertJsonPath('data.collections.0.handle', 'api-summer');
+
+    $product = Product::withoutGlobalScopes()
+        ->where('store_id', $store->getKey())
+        ->where('handle', 'api-cotton-t-shirt')
+        ->firstOrFail();
+    $defaultVariant = ProductVariant::withoutGlobalScopes()
+        ->where('product_id', $product->getKey())
+        ->where('is_default', true)
+        ->firstOrFail();
+    $removedVariant = ProductVariant::withoutGlobalScopes()
+        ->where('product_id', $product->getKey())
+        ->where('sku', 'API-TEE-BLU-M')
+        ->firstOrFail();
+
+    expect($product->status)->toBe(ProductStatus::Active)
+        ->and($product->options()->count())->toBe(2)
+        ->and($product->variants()->count())->toBe(2);
+
+    $this->actingAs($user)
+        ->putJson("/api/admin/v1/stores/{$store->getKey()}/products/{$product->getKey()}", [
+            'title' => 'API Cotton T-Shirt Updated',
+            'tags' => ['organic', 'bestseller'],
+            'variants' => [
+                [
+                    'id' => $defaultVariant->getKey(),
+                    'sku' => 'API-TEE-BLU-S-UPDATED',
+                    'price_amount' => 2700,
+                    'is_default' => true,
+                    'position' => 1,
+                    'option_values' => [
+                        ['option_name' => 'Color', 'value' => 'Blue'],
+                        ['option_name' => 'Size', 'value' => 'Small'],
+                    ],
+                    'inventory' => [
+                        'quantity_on_hand' => 15,
+                    ],
+                ],
+            ],
+        ])
+        ->assertOk()
+        ->assertJsonPath('data.title', 'API Cotton T-Shirt Updated')
+        ->assertJsonPath('data.variants.0.sku', 'API-TEE-BLU-S-UPDATED')
+        ->assertJsonPath('data.variants.0.inventory.quantity_on_hand', 15);
+
+    $this->assertModelMissing($removedVariant);
+
+    $this->actingAs($user)
+        ->deleteJson("/api/admin/v1/stores/{$store->getKey()}/products/{$product->getKey()}")
+        ->assertOk()
+        ->assertJsonPath('data.status', 'archived');
+
+    expect($product->refresh()->status)->toBe(ProductStatus::Archived);
+});
+
+test('admin product api presigns media uploads', function (): void {
+    $store = adminCatalogApiStore();
+    $product = Product::factory()->withDefaultVariant()->create([
+        'store_id' => $store->getKey(),
+        'title' => 'API Media Product',
+    ]);
+
+    $this->actingAs(adminCatalogApiUser())
+        ->postJson("/api/admin/v1/stores/{$store->getKey()}/products/{$product->getKey()}/media/presign-upload", [
+            'filename' => 'front.jpg',
+            'content_type' => 'image/jpeg',
+            'byte_size' => 245000,
+        ])
+        ->assertCreated()
+        ->assertJsonPath('method', 'PUT')
+        ->assertJsonPath('headers.Content-Type', 'image/jpeg')
+        ->assertJsonPath('storage_key', fn (string $storageKey): bool => str_starts_with($storageKey, "media/originals/{$product->getKey()}/"))
+        ->assertJsonPath('upload_url', fn (string $uploadUrl): bool => $uploadUrl !== '');
+
+    $media = ProductMedia::withoutGlobalScopes()
+        ->where('product_id', $product->getKey())
+        ->firstOrFail();
+
+    expect($media->status)->toBe(MediaStatus::Processing)
+        ->and($media->mime_type)->toBe('image/jpeg')
+        ->and($media->byte_size)->toBe(245000)
+        ->and($media->position)->toBe(0);
+});
+
 test('admin customer api lists and shows store scoped customers', function (): void {


output: {"output":"Success. Updated the following files:\nM tests/Feature/Api/AdminCatalogApiTest.php\n","metadata":{"exit_code":0,"duration_seconds":0.0}}


status: completed

input:
*** Begin Patch
*** Update File: tests/Feature/Api/AdminCatalogApiTest.php
@@
     $productToken = adminCatalogApiToken($store, ['read-products']);
+    $writeProductToken = adminCatalogApiToken($store, ['write-products']);
     $customerToken = adminCatalogApiToken($store, ['read-customers']);
@@
     $this->withToken($productToken['plain_text'])
         ->getJson("/api/admin/v1/stores/{$store->getKey()}/customers")
         ->assertForbidden();

+    $this->withToken($productToken['plain_text'])
+        ->postJson("/api/admin/v1/stores/{$store->getKey()}/products", [
+            'title' => 'Token Created Product',
+            'variants' => [
+                [
+                    'sku' => 'TOKEN-CREATED-001',
+                    'price_amount' => 1999,
+                    'is_default' => true,
+                ],
+            ],
+        ])
+        ->assertForbidden();
+
+    $this->withToken($writeProductToken['plain_text'])
+        ->postJson("/api/admin/v1/stores/{$store->getKey()}/products", [
+            'title' => 'Token Created Product',
+            'variants' => [
+                [
+                    'sku' => 'TOKEN-CREATED-001',
+                    'price_amount' => 1999,
+                    'is_default' => true,
+                ],
+            ],
+        ])
+        ->assertCreated()
+        ->assertJsonPath('data.title', 'Token Created Product');
+
     $this->withToken($customerToken['plain_text'])
         ->getJson("/api/admin/v1/stores/{$store->getKey()}/customers?query=token-customer")
         ->assertOk()
         ->assertJsonPath('data.0.email', 'token-customer@example.test');

     expect($productToken['token']->refresh()->last_used_at)->not->toBeNull()
+        ->and($writeProductToken['token']->refresh()->last_used_at)->not->toBeNull()
         ->and($customerToken['token']->refresh()->last_used_at)->not->toBeNull();
 });
*** End Patch


unified_diff:
@@ -272,2 +272,3 @@
     $productToken = adminCatalogApiToken($store, ['read-products']);
+    $writeProductToken = adminCatalogApiToken($store, ['write-products']);
     $customerToken = adminCatalogApiToken($store, ['read-customers']);
@@ -283,2 +284,29 @@

+    $this->withToken($productToken['plain_text'])
+        ->postJson("/api/admin/v1/stores/{$store->getKey()}/products", [
+            'title' => 'Token Created Product',
+            'variants' => [
+                [
+                    'sku' => 'TOKEN-CREATED-001',
+                    'price_amount' => 1999,
+                    'is_default' => true,
+                ],
+            ],
+        ])
+        ->assertForbidden();
+
+    $this->withToken($writeProductToken['plain_text'])
+        ->postJson("/api/admin/v1/stores/{$store->getKey()}/products", [
+            'title' => 'Token Created Product',
+            'variants' => [
+                [
+                    'sku' => 'TOKEN-CREATED-001',
+                    'price_amount' => 1999,
+                    'is_default' => true,
+                ],
+            ],
+        ])
+        ->assertCreated()
+        ->assertJsonPath('data.title', 'Token Created Product');
+
     $this->withToken($customerToken['plain_text'])
@@ -289,2 +317,3 @@
     expect($productToken['token']->refresh()->last_used_at)->not->toBeNull()
+        ->and($writeProductToken['token']->refresh()->last_used_at)->not->toBeNull()
         ->and($customerToken['token']->refresh()->last_used_at)->not->toBeNull();


output: {"output":"Success. Updated the following files:\nM tests/Feature/Api/AdminCatalogApiTest.php\n","metadata":{"exit_code":0,"duration_seconds":0.0}}


message: The code now has the missing `write-products` routes and focused coverage for create/update/archive, media presign, and token abilities. I’m doing quick syntax/route checks before running the feature test so failures are fast and specific.

phase: commentary


type: message

role: assistant

text: The code now has the missing `write-products` routes and focused coverage for create/update/archive, media presign, and token abilities. I’m doing quick syntax/route checks before running the feature test so failures are fast and specific.


cmd: php artisan route:list --path=api/admin/v1/stores --except-vendor


type: unknown

aggregated_output: No syntax errors detected in app/Http/Requests/Api/Admin/V1/UpdateProductRequest.php


aggregated_output: No syntax errors detected in app/Http/Controllers/Api/Admin/V1/ProductController.php


aggregated_output: No syntax errors detected in app/Http/Requests/Api/Admin/V1/StoreProductRequest.php


aggregated_output: No syntax errors detected in app/Http/Requests/Api/Admin/V1/CreateProductMediaUploadRequest.php


output:
Chunk ID: f26a3d
Wall time: 0.0000 seconds
Process exited with code 0
Original token count: 22
Output:
No syntax errors detected in app/Http/Controllers/Api/Admin/V1/ProductController.php


output:
Chunk ID: b2bae8
Wall time: 0.0000 seconds
Process exited with code 0
Original token count: 21
Output:
No syntax errors detected in app/Http/Requests/Api/Admin/V1/StoreProductRequest.php


output:
Chunk ID: 07ceb9
Wall time: 0.0000 seconds
Process exited with code 0
Original token count: 22
Output:
No syntax errors detected in app/Http/Requests/Api/Admin/V1/UpdateProductRequest.php


output:
Chunk ID: 6d40d2
Wall time: 0.0000 seconds
Process exited with code 0
Original token count: 24
Output:
No syntax errors detected in app/Http/Requests/Api/Admin/V1/CreateProductMediaUploadRequest.php


output:
Chunk ID: ac7c38
Wall time: 0.1868 seconds
Process exited with code 0
Original token count: 856
Output:

  GET|HEAD  api/admin/v1/stores/{store}/analytics/summary api.admin.v1.analyt…
  GET|HEAD  api/admin/v1/stores/{store}/collections api.admin.v1.collections.…
  POST      api/admin/v1/stores/{store}/collections api.admin.v1.collections.…
  PUT       api/admin/v1/stores/{store}/collections/{collection} api.admin.v1…
  DELETE    api/admin/v1/stores/{store}/collections/{collection} api.admin.v1…
  GET|HEAD  api/admin/v1/stores/{store}/customers api.admin.v1.customers.inde…
  GET|HEAD  api/admin/v1/stores/{store}/customers/{customer} api.admin.v1.cus…
  GET|HEAD  api/admin/v1/stores/{store}/discounts api.admin.v1.discounts.inde…
  POST      api/admin/v1/stores/{store}/discounts api.admin.v1.discounts.stor…
  PUT       api/admin/v1/stores/{store}/discounts/{discount} api.admin.v1.dis…
  DELETE    api/admin/v1/stores/{store}/discounts/{discount} api.admin.v1.dis…
  POST      api/admin/v1/stores/{store}/exports/orders api.admin.v1.exports.o…
  GET|HEAD  api/admin/v1/stores/{store}/exports/{dataExport} api.admin.v1.exp…
  GET|HEAD  api/admin/v1/stores/{store}/orders api.admin.v1.orders.index › Ap…
  GET|HEAD  api/admin/v1/stores/{store}/orders/{order} api.admin.v1.orders.sh…
  POST      api/admin/v1/stores/{store}/orders/{order}/fulfillments api.admin…
  POST      api/admin/v1/stores/{store}/orders/{order}/refunds api.admin.v1.o…
  GET|HEAD  api/admin/v1/stores/{store}/pages api.admin.v1.pages.index › Api\…
  POST      api/admin/v1/stores/{store}/pages api.admin.v1.pages.store › Api\…
  PUT       api/admin/v1/stores/{store}/pages/{page} api.admin.v1.pages.updat…
  DELETE    api/admin/v1/stores/{store}/pages/{page} api.admin.v1.pages.destr…
  GET|HEAD  api/admin/v1/stores/{store}/products api.admin.v1.products.index …
  POST      api/admin/v1/stores/{store}/products api.admin.v1.products.store …
  GET|HEAD  api/admin/v1/stores/{store}/products/{product} api.admin.v1.produ…
  PUT       api/admin/v1/stores/{store}/products/{product} api.admin.v1.produ…
  DELETE    api/admin/v1/stores/{store}/products/{product} api.admin.v1.produ…
  POST      api/admin/v1/stores/{store}/products/{product}/media/presign-upload api.admin.v1.products.media.presign-upload › Api\Admin\V1\ProductController@presignUpl…
  POST      api/admin/v1/stores/{store}/search/reindex api.admin.v1.search.re…
  GET|HEAD  api/admin/v1/stores/{store}/search/status api.admin.v1.search.sta…
  GET|HEAD  api/admin/v1/stores/{store}/settings api.admin.v1.settings.show  …
  PUT       api/admin/v1/stores/{store}/settings api.admin.v1.settings.update…
  GET|HEAD  api/admin/v1/stores/{store}/shipping/zones api.admin.v1.shipping.…
  POST      api/admin/v1/stores/{store}/shipping/zones api.admin.v1.shipping.…
  PUT       api/admin/v1/stores/{store}/shipping/zones/{shippingZone} api.adm…
  POST      api/admin/v1/stores/{store}/shipping/zones/{shippingZone}/rates a…
  GET|HEAD  api/admin/v1/stores/{store}/tax/settings api.admin.v1.tax.setting…
  PUT       api/admin/v1/stores/{store}/tax/settings api.admin.v1.tax.setting…
  POST      api/admin/v1/stores/{store}/themes api.admin.v1.themes.store › Ap…
  POST      api/admin/v1/stores/{store}/themes/{theme}/publish api.admin.v1.t…
  PUT       api/admin/v1/stores/{store}/themes/{theme}/settings api.admin.v1.…

                                                           Showing [40] routes


command: php artisan route:list --path=api/admin/v1/stores --except-vendor

aggregated_output:
  GET|HEAD  api/admin/v1/stores/{store}/analytics/summary api.admin.v1.analyt…
  GET|HEAD  api/admin/v1/stores/{store}/collections api.admin.v1.collections.…
  POST      api/admin/v1/stores/{store}/collections api.admin.v1.collections.…
  PUT       api/admin/v1/stores/{store}/collections/{collection} api.admin.v1…
  DELETE    api/admin/v1/stores/{store}/collections/{collection} api.admin.v1…
  GET|HEAD  api/admin/v1/stores/{store}/customers api.admin.v1.customers.inde…
  GET|HEAD  api/admin/v1/stores/{store}/customers/{customer} api.admin.v1.cus…
  GET|HEAD  api/admin/v1/stores/{store}/discounts api.admin.v1.discounts.inde…
  POST      api/admin/v1/stores/{store}/discounts api.admin.v1.discounts.stor…
  PUT       api/admin/v1/stores/{store}/discounts/{discount} api.admin.v1.dis…
  DELETE    api/admin/v1/stores/{store}/discounts/{discount} api.admin.v1.dis…
  POST      api/admin/v1/stores/{store}/exports/orders api.admin.v1.exports.o…
  GET|HEAD  api/admin/v1/stores/{store}/exports/{dataExport} api.admin.v1.exp…
  GET|HEAD  api/admin/v1/stores/{store}/orders api.admin.v1.orders.index › Ap…
  GET|HEAD  api/admin/v1/stores/{store}/orders/{order} api.admin.v1.orders.sh…
  POST      api/admin/v1/stores/{store}/orders/{order}/fulfillments api.admin…
  POST      api/admin/v1/stores/{store}/orders/{order}/refunds api.admin.v1.o…
  GET|HEAD  api/admin/v1/stores/{store}/pages api.admin.v1.pages.index › Api\…
  POST      api/admin/v1/stores/{store}/pages api.admin.v1.pages.store › Api\…
  PUT       api/admin/v1/stores/{store}/pages/{page} api.admin.v1.pages.updat…
  DELETE    api/admin/v1/stores/{store}/pages/{page} api.admin.v1.pages.destr…
  GET|HEAD  api/admin/v1/stores/{store}/products api.admin.v1.products.index …
  POST      api/admin/v1/stores/{store}/products api.admin.v1.products.store …
  GET|HEAD  api/admin/v1/stores/{store}/products/{product} api.admin.v1.produ…
  PUT       api/admin/v1/stores/{store}/products/{product} api.admin.v1.produ…
  DELETE    api/admin/v1/stores/{store}/products/{product} api.admin.v1.produ…
  POST      api/admin/v1/stores/{store}/products/{product}/media/presign-upload api.admin.v1.products.media.presign-upload › Api\Admin\V1\ProductController@presignUpl…
  POST      api/admin/v1/stores/{store}/search/reindex api.admin.v1.search.re…
  GET|HEAD  api/admin/v1/stores/{store}/search/status api.admin.v1.search.sta…
  GET|HEAD  api/admin/v1/stores/{store}/settings api.admin.v1.settings.show  …
  PUT       api/admin/v1/stores/{store}/settings api.admin.v1.settings.update…
  GET|HEAD  api/admin/v1/stores/{store}/shipping/zones api.admin.v1.shipping.…
  POST      api/admin/v1/stores/{store}/shipping/zones api.admin.v1.shipping.…
  PUT       api/admin/v1/stores/{store}/shipping/zones/{shippingZone} api.adm…
  POST      api/admin/v1/stores/{store}/shipping/zones/{shippingZone}/rates a…
  GET|HEAD  api/admin/v1/stores/{store}/tax/settings api.admin.v1.tax.setting…
  PUT       api/admin/v1/stores/{store}/tax/settings api.admin.v1.tax.setting…
  POST      api/admin/v1/stores/{store}/themes api.admin.v1.themes.store › Ap…
  POST      api/admin/v1/stores/{store}/themes/{theme}/publish api.admin.v1.t…
  PUT       api/admin/v1/stores/{store}/themes/{theme}/settings api.admin.v1.…

                                                           Showing [40] routes


cmd: php artisan test --compact tests/Feature/Api/AdminCatalogApiTest.php


output:
Chunk ID: b58da7
Wall time: 3.2818 seconds
Process exited with code 0
Original token count: 17
Output:

  .......

  Tests:    7 passed (63 assertions)
  Duration: 3.18s


command: php artisan test --compact tests/Feature/Api/AdminCatalogApiTest.php

aggregated_output:
  .......

  Tests:    7 passed (63 assertions)
  Duration: 3.18s


message: The focused catalog API test passes: 7 tests and 63 assertions. I’m formatting the PHP changes now, then I’ll run the broader API feature set to catch any route or shared validation regressions.


text: The focused catalog API test passes: 7 tests and 63 assertions. I’m formatting the PHP changes now, then I’ll run the broader API feature set to catch any route or shared validation regressions.


output:
Chunk ID: 3faf94
Wall time: 0.6794 seconds
Process exited with code 0
Original token count: 5
Output:
{"result":"pass"}


command:
/bin/zsh
-lc
vendor/bin/pint --dirty --format agent

cmd: vendor/bin/pint --dirty --format agent

result: pass


cmd: php artisan test --compact tests/Feature/Api


output:
Chunk ID: e38a0a
Wall time: 2.9855 seconds
Process exited with code 0
Original token count: 17
Output:

  .......

  Tests:    7 passed (63 assertions)
  Duration: 2.90s


aggregated_output:
  .......

  Tests:    7 passed (63 assertions)
  Duration: 2.90s


output:
Chunk ID: 2a7c21
Wall time: 21.8921 seconds
Process exited with code 0
Original token count: 31
Output:

  .........................................................

  Tests:    57 passed (480 assertions)
  Duration: 21.81s


command: php artisan test --compact tests/Feature/Api


message: The broader API feature directory also passes: 57 tests and 480 assertions. I’m updating the progress notes to remove the product API gap from the audit record, then I’ll commit this slice.


text: The broader API feature directory also passes: 57 tests and 480 assertions. I’m updating the progress notes to remove the product API gap from the audit record, then I’ll commit this slice.


command:
/bin/zsh
-lc
sed -n '1,260p' specs/progress.md

progress.md

type: read

cmd: sed -n '1,260p' specs/progress.md

aggregated_output:
# Shop Build Progress

## Objective

Build a complete, self-contained Laravel shop system from `specs/*`, with implemented acceptance criteria mapped to evidence, passing automated checks, verified browser flows, independent QA, and meaningful commits.

## Current Status

- Status: in progress
- Active slice: Phase 8 - final verification and completion audit
- Started from: Laravel Livewire/Fortify starter kit with no shop domain tables or models present
- Last updated: 2026-05-04

## Phased Plan

1. Foundation: SQLite config, tenancy tables/models/factories/seeders, store resolution middleware, store scoping, admin/customer auth foundations, policies, and Phase 1 tests.
2. Catalog: product, variant, inventory, collection, media schema/models/services, admin catalog CRUD, storefront browsing, and tests.
3. Storefront theme/layout: theme/page/navigation data, storefront layout, product/collection/search/page views, and smoke browser verification.
4. Cart/checkout/pricing: cart API/UI, discount, shipping, tax, checkout state machine, and tests.
5. Payments/orders/fulfillment: mock PSP, order creation, refunds, fulfillments, customer account order views, and tests.
6. Admin panel: dashboard and all admin list/form/detail pages from Spec 03, with Livewire/Flux interactions.
7. Search/analytics/apps/webhooks: SQLite search, event aggregation, developer token/webhook UI, delivery jobs, and tests.
8. Final verification: full Pest suite, Pint, frontend build, Playwright MCP customer/admin flows, independent QA, fixes, and completion audit.

## Acceptance Checklist

| Area | Criteria Source | Status | Evidence |
| --- | --- | --- | --- |
| Database schema | `specs/01-DATABASE-SCHEMA.md` | partial | Phase 1 tenancy/auth tables, Phase 2 catalog tables, Phase 3 theme/content/navigation tables including `navigation_items.parent_id` nesting, Phase 4 cart/checkout/pricing tables, Phase 5 order/payment/fulfillment tables, and Phase 7 search/analytics/apps/webhooks/export tables are implemented: search_settings, search_queries, SQLite FTS5 `products_fts`, analytics_events, analytics_daily, apps, app_installations, oauth_clients, oauth_tokens, webhook_subscriptions, webhook_deliveries, and data_exports. |
| Routes/API | `specs/02-API-ROUTES.md` | partial | Phase 1 auth routes plus catalog/cart/order/customer/discount/content/settings/search/analytics/apps/developers UI routes exist: `/`, `/collections`, `/collections/{handle}`, `/products/{handle}`, `/cart`, `/checkout`, `/checkout/confirmation/{order}`, `/search`, `/pages/{handle}`, `/account/login`, `/account/register`, `/forgot-password`, `/reset-password/{token}`, `/account/forgot-password`, `/account/reset-password/{token}`, `/account`, `/account/orders`, `/account/orders/{order}`, `/account/addresses`, `/account/logout`, `/admin/login`, `/admin/forgot-password`, `/admin/reset-password/{token}`, `/admin/products`, `/admin/products/create`, `/admin/products/{product}/edit`, `/admin/collections`, `/admin/collections/create`, `/admin/collections/{collection}/edit`, `/admin/inventory`, `/admin/orders`, `/admin/orders/{order}`, `/admin/customers`, `/admin/customers/{customer}`, `/admin/discounts`, `/admin/discounts/create`, `/admin/discounts/{discount}/edit`, `/admin/pages`, `/admin/pages/create`, `/admin/pages/{page}/edit`, `/admin/navigation`, `/admin/themes`, `/admin/themes/{theme}/editor`, `/admin/settings`, `/admin/settings/shipping`, `/admin/settings/taxes`, `/admin/settings/checkout`, `/admin/settings/notifications`, `/admin/search/settings`, `/admin/analytics`, `/admin/apps`, `/admin/apps/{installation}`, and `/admin/developers`. Storefront REST API now includes cart line CRUD, checkout address/shipping/discount/payment-method selection, checkout payment completion, token-gated order lookup, product search, search suggestions, and analytics event ingestion under `/api/storefront/v1`. Admin REST API now includes store-scoped product/customer/order list/detail, collection CRUD, discount CRUD, content page CRUD, store settings read/update, search status/reindex endpoints, analytics summary, order export creation/status, shipping zone/rate settings, tax settings read/update, theme upload/publish/settings update, fulfillment creation, and refund creation under `/api/admin/v1/stores/{store}` with session or scoped bearer-token authentication. `/oauth/authorize`, `/oauth/token`, and `/api/apps/v1/stores/{store}` app routes are registered as explicit `501 Not Implemented` stubs per the deferred OAuth/app ecosystem scope note. |
| Admin UI | `specs/03-ADMIN-UI.md` | partial | Flux/Livewire admin shell now includes dashboard KPI/date-range reporting, analytics reporting with filters/export, catalog/product/collection/inventory management, product option matrix generation, product media upload/alt text/reorder/delete controls, order/customer/discount management, page index/form, nested navigation menu editor with drag sorting and parent selection, theme grid, theme settings edi…41115 chars truncated…ervices/CustomerPasswordResetService --no-interaction`, `php artisan make:notification CustomerResetPassword --no-interaction`, `php artisan make:livewire Storefront/Account/Auth/ForgotPassword --class --no-interaction`, `php artisan make:livewire Storefront/Account/Auth/ResetPassword --class --no-interaction`, and `php artisan make:test Auth/CustomerPasswordResetTest --pest --no-interaction` created the customer password reset service, notification, Livewire pages, and tests.
- 2026-05-04: `vendor/bin/pint --dirty --format agent` fixed formatting after the customer password reset changes.
- 2026-05-04: `php artisan test --compact tests/Feature/Auth/CustomerPasswordResetTest.php` passed: 5 tests, 31 assertions.
- 2026-05-04: `php artisan test --compact tests/Feature/Auth/CustomerPasswordResetTest.php tests/Feature/Auth/PasswordResetTest.php tests/Feature/Foundation/CustomerAuthTest.php` passed: 12 tests, 55 assertions.
- 2026-05-04: `php artisan test --compact` passed after the customer password reset changes: 187 tests, 940 assertions.
- 2026-05-04: `php artisan route:list | rg "account/(forgot|reset)| forgot-password | reset-password"` confirmed customer reset routes under `/account/*` and existing Fortify reset routes at the root paths remain registered separately.
- 2026-05-04: `npm run build` passed after the customer password reset Blade changes.
- 2026-05-04: Playwright MCP verified `http://shop.test/account/forgot-password` renders in a fresh browser session, submits an unknown email through Livewire, shows the generic reset-link response, and reports no current console warnings/errors.
- 2026-05-04: `mcp__laravel_boost__.search_docs` consulted Livewire 4 file upload/testing docs, Flux file input docs, Laravel file validation/storage testing docs, and Pest 4 docs before the product media admin UI changes.
- 2026-05-04: `php artisan make:test Admin/ProductMediaManagementTest --pest --no-interaction` created focused product media management coverage.
- 2026-05-04: `vendor/bin/pint --dirty --format agent` passed after adding product form media upload code.
- 2026-05-04: `php artisan test --compact tests/Feature/Admin/ProductMediaManagementTest.php` passed: 4 tests, 24 assertions.
- 2026-05-04: `php artisan test --compact tests/Feature/Admin/ProductMediaManagementTest.php tests/Feature/Catalog/MediaProcessingTest.php tests/Feature/Catalog/CatalogUiTest.php` passed: 9 tests, 81 assertions.
- 2026-05-04: `php artisan test --compact tests/Feature/Admin` passed after the product media UI changes: 41 tests, 246 assertions.
- 2026-05-04: `php artisan test --compact` passed after the product media UI changes: 191 tests, 964 assertions.
- 2026-05-04: `npm run build` passed after the product media Blade changes.
- 2026-05-04: `mcp__laravel_boost__.get_absolute_url(path: "/admin/products/1/edit")` resolved `http://shop.test/admin/products/1/edit`; Playwright MCP verified the product edit page renders the Media section and current console checks report no warnings/errors.
- 2026-05-04: `php artisan migrate:fresh --seed --no-interaction` passed after product media browser verification to restore seeded fixtures.
- 2026-05-04: `mcp__laravel_boost__.search_docs` consulted Laravel 12 query/JSON testing docs and Pest 4 dataset/testing docs before the discount redemption-history changes.
- 2026-05-04: `vendor/bin/pint --dirty --format agent` passed after the discount service/order allocation changes.
- 2026-05-04: `php artisan test --compact tests/Feature/Checkout/PricingServicesTest.php tests/Feature/Orders/OrderServiceTest.php` passed: 11 tests, 63 assertions.
- 2026-05-04: `php artisan test --compact tests/Feature/Checkout tests/Feature/Orders tests/Feature/Api/StorefrontCheckoutApiTest.php tests/Feature/Storefront/CartCheckoutUiTest.php` passed after the discount redemption-history changes: 33 tests, 196 assertions.
- 2026-05-04: `php artisan test --compact` passed after the discount redemption-history changes: 192 tests, 966 assertions.
- 2026-05-04: `mcp__laravel_boost__.search_docs` consulted Laravel 12 query/database docs and Pest 4 testing docs before the search synonym/stop-word parser changes.
- 2026-05-04: `vendor/bin/pint --dirty --format agent` passed after the search synonym/stop-word parser changes.
- 2026-05-04: `php artisan test --compact tests/Feature/Search/SearchServiceTest.php` passed after the search synonym/stop-word parser changes: 5 tests, 15 assertions.
- 2026-05-04: `php artisan test --compact tests/Feature/Search/SearchServiceTest.php tests/Feature/Api/StorefrontSearchApiTest.php tests/Feature/Admin/SearchSettingsTest.php` passed after the search synonym/stop-word parser changes: 12 tests, 50 assertions.
- 2026-05-04: `php artisan test --compact` passed after the search synonym/stop-word parser changes: 194 tests, 970 assertions.
- 2026-05-04: `mcp__laravel_boost__.search_docs` consulted Laravel 12 database/JSON testing docs and Pest 4 docs before the automatic discount attribution changes.