status: completed

input:
*** Begin Patch
*** Update File: /Users/fabianwesner/Herd/shop/specs/progress.md
@@
-| 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`, `/account/forgot-password`, `/account/reset-password/{token}`, `/account`, `/account/orders/{order}`, `/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 order list/detail, fulfillment creation, and refund creation under `/api/admin/v1/stores/{store}` with session or scoped bearer-token authentication. OAuth/app API endpoints and broader admin REST surfaces are still missing. |
+| 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`, `/account/forgot-password`, `/account/reset-password/{token}`, `/account`, `/account/orders/{order}`, `/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, 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. Write endpoints for non-order admin REST surfaces are still missing. |
@@
 - 2026-05-04: `mcp__laravel_boost__.get_absolute_url(path: "/admin/navigation")` resolved `http://shop.test/admin/navigation` before browser verification.
 - 2026-05-04: Playwright MCP verified `http://shop.test/admin/navigation` renders nested menu items and the parent selector, and `http://shop.test/` renders the `Shop` dropdown with collection child links; current Playwright console checks reported no warnings/errors. Boost `browser_logs` only returned older May 3 account-login warnings from a previously fixed issue.
+- 2026-05-04: `mcp__laravel_boost__.search_docs` consulted Laravel 12 API resource, route middleware group, bearer token, form request validation, and JSON HTTP testing docs plus Pest 4 docs before the admin catalog API changes.
+- 2026-05-04: `php artisan make:controller Api/Admin/V1/ProductController --no-interaction`, `php artisan make:controller Api/Admin/V1/CustomerController --no-interaction`, `php artisan make:resource Admin/V1/ProductResource --no-interaction`, `php artisan make:resource Admin/V1/CustomerResource --no-interaction`, `php artisan make:controller Api/OAuthController --no-interaction`, `php artisan make:controller Api/Apps/V1/DeferredEndpointController --no-interaction`, and `php artisan make:test Api/AdminCatalogApiTest --pest --no-interaction` created the admin catalog API controllers/resources, deferred app/OAuth controllers, and tests.
+- 2026-05-04: `vendor/bin/pint --dirty --format agent` passed after the admin catalog API changes.
+- 2026-05-04: `php artisan route:list --path=api/admin/v1/stores --except-vendor` confirmed 8 admin API routes: product/customer/order list/detail plus order fulfillment/refund mutations.
+- 2026-05-04: `php artisan route:list --path=oauth --except-vendor` confirmed `/oauth/authorize` and `/oauth/token`; `php artisan route:list --path=api/apps/v1 --except-vendor` confirmed 3 deferred app API routes.
+- 2026-05-04: `php artisan test --compact tests/Feature/Api/AdminCatalogApiTest.php` passed after the admin catalog API changes: 5 tests, 34 assertions.
+- 2026-05-04: `php artisan test --compact tests/Feature/Api` passed after the admin catalog API changes: 25 tests, 194 assertions.
@@
-- Admin order API routes currently use the existing session `auth` middleware and store-user membership checks because Sanctum is not installed yet.
+- Admin REST API routes use the `admin.api` middleware with session-authenticated store users or hashed `oauth_tokens` bearer tokens; the current implemented surfaces are read-only products/customers/orders plus order fulfillment/refund mutations.
@@
-- OAuth/Passport app authorization remains deferred per the roadmap; the developer token UI uses the existing app installation and `oauth_tokens` schema as a self-contained local token store, but it is not Sanctum/Passport authentication yet.
+- OAuth/Passport app authorization remains deferred per the roadmap; `/oauth/*` and `/api/apps/v1/*` now return explicit `501 Not Implemented` responses, while the developer token UI uses the existing app installation and `oauth_tokens` schema as a self-contained local token store.
@@
-- OAuth/Passport app API endpoints and broader admin REST endpoints outside order management are still missing; outbound webhook subscription management is implemented through the admin UI only, as specified for the initial implementation.
+- Non-order admin REST write endpoints and the larger admin REST surfaces for collections, discounts, settings, themes, content, search, analytics, and exports are still missing; outbound webhook subscription management is implemented through the admin UI only, as specified for the initial implementation.
@@
-Not complete. Phase 1 foundation, Phase 2 catalog data/UI surfaces, product media processing/admin upload controls, Phase 3 storefront theme/content/navigation data, the Phase 4 cart/checkout/pricing backend foundation, cart/checkout storefront UI through order completion, cart-page estimates, cart/checkout REST APIs, Phase 5 order/payment backend foundation, Phase 5 refund/fulfillment services, customer order views, admin dashboard/order/customer/discount/content/settings/theme-file/navigation/search/analytics/apps/developers management, storefront search and analytics APIs, order API surfaces, and outbound webhook delivery foundations are implemented, with known auth/token route compatibility, OAuth/app API, browser-test, and database CHECK constraint gaps tracked above.
+Not complete. Phase 1 foundation, Phase 2 catalog data/UI surfaces, product media processing/admin upload controls, Phase 3 storefront theme/content/navigation data, the Phase 4 cart/checkout/pricing backend foundation, cart/checkout storefront UI through order completion, cart-page estimates, cart/checkout REST APIs, Phase 5 order/payment backend foundation, Phase 5 refund/fulfillment services, customer order views, admin dashboard/order/customer/discount/content/settings/theme-file/navigation/search/analytics/apps/developers management, storefront search and analytics APIs, product/customer/order API surfaces, deferred OAuth/app route stubs, and outbound webhook delivery foundations are implemented, with known auth/token route compatibility, broader admin REST, browser-test, and database CHECK constraint gaps tracked above.
*** End Patch


stdout:
Success. Updated the following files:
M /Users/fabianwesner/Herd/shop/specs/progress.md

type: update

unified_diff:
@@ -29,3 +29,3 @@
 | 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 tables are implemented: search_settings, search_queries, SQLite FTS5 `products_fts`, analytics_events, analytics_daily, apps, app_installations, oauth_clients, oauth_tokens, webhook_subscriptions, and webhook_deliveries. |
-| 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`, `/account/forgot-password`, `/account/reset-password/{token}`, `/account`, `/account/orders/{order}`, `/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 order list/detail, fulfillment creation, and refund creation under `/api/admin/v1/stores/{store}` with session or scoped bearer-token authentication. OAuth/app API endpoints and broader admin REST surfaces are still missing. |
+| 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`, `/account/forgot-password`, `/account/reset-password/{token}`, `/account`, `/account/orders/{order}`, `/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, 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. Write endpoints for non-order admin REST surfaces are still missing. |
 | 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 editor with iframe preview, theme file list/editor, general settings, domain management, shipping zone/rate management, tax settings, checkout policy settings, notification settings, search settings/reindexing, installed app list/detail, and developer token/webhook management with auth protection and store scoping. |
@@ -320,2 +320,9 @@
 - 2026-05-04: Playwright MCP verified `http://shop.test/admin/navigation` renders nested menu items and the parent selector, and `http://shop.test/` renders the `Shop` dropdown with collection child links; current Playwright console checks reported no warnings/errors. Boost `browser_logs` only returned older May 3 account-login warnings from a previously fixed issue.
+- 2026-05-04: `mcp__laravel_boost__.search_docs` consulted Laravel 12 API resource, route middleware group, bearer token, form request validation, and JSON HTTP testing docs plus Pest 4 docs before the admin catalog API changes.
+- 2026-05-04: `php artisan make:controller Api/Admin/V1/ProductController --no-interaction`, `php artisan make:controller Api/Admin/V1/CustomerController --no-interaction`, `php artisan make:resource Admin/V1/ProductResource --no-interaction`, `php artisan make:resource Admin/V1/CustomerResource --no-interaction`, `php artisan make:controller Api/OAuthController --no-interaction`, `php artisan make:controller Api/Apps/V1/DeferredEndpointController --no-interaction`, and `php artisan make:test Api/AdminCatalogApiTest --pest --no-interaction` created the admin catalog API controllers/resources, deferred app/OAuth controllers, and tests.
+- 2026-05-04: `vendor/bin/pint --dirty --format agent` passed after the admin catalog API changes.
+- 2026-05-04: `php artisan route:list --path=api/admin/v1/stores --except-vendor` confirmed 8 admin API routes: product/customer/order list/detail plus order fulfillment/refund mutations.
+- 2026-05-04: `php artisan route:list --path=oauth --except-vendor` confirmed `/oauth/authorize` and `/oauth/token`; `php artisan route:list --path=api/apps/v1 --except-vendor` confirmed 3 deferred app API routes.
+- 2026-05-04: `php artisan test --compact tests/Feature/Api/AdminCatalogApiTest.php` passed after the admin catalog API changes: 5 tests, 34 assertions.
+- 2026-05-04: `php artisan test --compact tests/Feature/Api` passed after the admin catalog API changes: 25 tests, 194 assertions.

@@ -353,3 +360,3 @@
 - Storefront order lookup uses a deterministic HMAC access token derived from store/order identity so confirmation links can be token-gated without adding a new runtime column.
-- Admin order API routes currently use the existing session `auth` middleware and store-user membership checks because Sanctum is not installed yet.
+- Admin REST API routes use the `admin.api` middleware with session-authenticated store users or hashed `oauth_tokens` bearer tokens; the current implemented surfaces are read-only products/customers/orders plus order fulfillment/refund mutations.
 - Admin dashboard sales and top-product metrics are derived from paid or partially refunded orders; the dedicated analytics page now reads analytics event and daily aggregate tables.
@@ -370,3 +377,3 @@
 - The admin analytics CSV export is generated as a data URL for the self-contained local app rather than creating persistent export files/jobs.
-- OAuth/Passport app authorization remains deferred per the roadmap; the developer token UI uses the existing app installation and `oauth_tokens` schema as a self-contained local token store, but it is not Sanctum/Passport authentication yet.
+- OAuth/Passport app authorization remains deferred per the roadmap; `/oauth/*` and `/api/apps/v1/*` now return explicit `501 Not Implemented` responses, while the developer token UI uses the existing app installation and `oauth_tokens` schema as a self-contained local token store.
 - Admin order API routes use the `admin.api` middleware, which accepts either the existing session-authenticated admin user or a hashed `oauth_tokens` bearer token scoped to the route store; token requests require `read-orders` for GET routes and `write-orders` for refund/fulfillment mutations and update `last_used_at` on successful use.
@@ -384,3 +391,3 @@
 - `php artisan route:list --except-vendor` hides Livewire full-page routes because their controller is vendor-provided; path-filtered route-list commands are used as evidence for those routes.
-- OAuth/Passport app API endpoints and broader admin REST endpoints outside order management are still missing; outbound webhook subscription management is implemented through the admin UI only, as specified for the initial implementation.
+- Non-order admin REST write endpoints and the larger admin REST surfaces for collections, discounts, settings, themes, content, search, analytics, and exports are still missing; outbound webhook subscription management is implemented through the admin UI only, as specified for the initial implementation.
 - Automated browser tests from Spec 08 are still missing; current browser coverage is manual Playwright MCP smoke verification.
@@ -390,2 +397,2 @@

-Not complete. Phase 1 foundation, Phase 2 catalog data/UI surfaces, product media processing/admin upload controls, Phase 3 storefront theme/content/navigation data, the Phase 4 cart/checkout/pricing backend foundation, cart/checkout storefront UI through order completion, cart-page estimates, cart/checkout REST APIs, Phase 5 order/payment backend foundation, Phase 5 refund/fulfillment services, customer order views, admin dashboard/order/customer/discount/content/settings/theme-file/navigation/search/analytics/apps/developers management, storefront search and analytics APIs, order API surfaces, and outbound webhook delivery foundations are implemented, with known auth/token route compatibility, OAuth/app API, browser-test, and database CHECK constraint gaps tracked above.
+Not complete. Phase 1 foundation, Phase 2 catalog data/UI surfaces, product media processing/admin upload controls, Phase 3 storefront theme/content/navigation data, the Phase 4 cart/checkout/pricing backend foundation, cart/checkout storefront UI through order completion, cart-page estimates, cart/checkout REST APIs, Phase 5 order/payment backend foundation, Phase 5 refund/fulfillment services, customer order views, admin dashboard/order/customer/discount/content/settings/theme-file/navigation/search/analytics/apps/developers management, storefront search and analytics APIs, product/customer/order API surfaces, deferred OAuth/app route stubs, and outbound webhook delivery foundations are implemented, with known auth/token route compatibility, broader admin REST, browser-test, and database CHECK constraint gaps tracked above.

status: completed


output: {"output":"Success. Updated the following files:\nM /Users/fabianwesner/Herd/shop/specs/progress.md\n","metadata":{"exit_code":0,"duration_seconds":0.0}}


message: The API evidence and open-gap wording are updated. I’m running the full suite now; after that I’ll inspect the diff, clean any scratch files, and commit this API slice.

phase: commentary


type: message

role: assistant

text: The API evidence and open-gap wording are updated. I’m running the full suite now; after that I’ll inspect the diff, clean any scratch files, and commit this API slice.


command:
/bin/zsh
-lc
git diff --check

type: unknown

cmd: git diff --check


output:
Chunk ID: 6f32c9
Wall time: 30.0026 seconds
Process running with session ID 78136
Original token count: 28
Output:

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


output:
Chunk ID: d5fd43
Wall time: 0.0000 seconds
Process exited with code 0
Original token count: 0
Output:


output:
Chunk ID: a4fa88
Wall time: 7.2062 seconds
Process exited with code 0
Original token count: 43
Output:
................................................
  .........................................................

  Tests:    209 passed (1125 assertions)
  Duration: 42.86s


command:
/bin/zsh
-lc
php artisan test --compact

cmd: php artisan test --compact


status: completed

input:
*** Begin Patch
*** Update File: /Users/fabianwesner/Herd/shop/specs/progress.md
@@
 - 2026-05-04: `php artisan test --compact tests/Feature/Api/AdminCatalogApiTest.php` passed after the admin catalog API changes: 5 tests, 34 assertions.
 - 2026-05-04: `php artisan test --compact tests/Feature/Api` passed after the admin catalog API changes: 25 tests, 194 assertions.
+- 2026-05-04: `php artisan test --compact` passed after the admin catalog API changes: 209 tests, 1125 assertions.
*** End Patch


unified_diff:
@@ -327,2 +327,3 @@
 - 2026-05-04: `php artisan test --compact tests/Feature/Api` passed after the admin catalog API changes: 25 tests, 194 assertions.
+- 2026-05-04: `php artisan test --compact` passed after the admin catalog API changes: 209 tests, 1125 assertions.


output: {"output":"Success. Updated the following files:\nM /Users/fabianwesner/Herd/shop/specs/progress.md\n","metadata":{"exit_code":0,"duration_seconds":0.0}}


command:
/bin/zsh
-lc
git diff --stat

cmd: git diff --stat


command:
/bin/zsh
-lc
git status --short

cmd: git status --short

aggregated_output:
 M routes/api.php
 M routes/web.php
 M specs/progress.md
?? .playwright-mcp/console-2026-05-03T23-28-42-443Z.log
?? .playwright-mcp/console-2026-05-04T04-24-15-455Z.log
?? .playwright-mcp/console-2026-05-04T05-58-46-727Z.log
?? app/Http/Controllers/Api/Admin/V1/CustomerController.php
?? app/Http/Controllers/Api/Admin/V1/ProductController.php
?? app/Http/Controllers/Api/Apps/
?? app/Http/Controllers/Api/OAuthController.php
?? app/Http/Resources/Admin/V1/CustomerResource.php
?? app/Http/Resources/Admin/V1/ProductResource.php
?? tests/Feature/Api/AdminCatalogApiTest.php


output:
Chunk ID: 488115
Wall time: 0.0000 seconds
Process exited with code 0
Original token count: 144
Output:
 M routes/api.php
 M routes/web.php
 M specs/progress.md
?? .playwright-mcp/console-2026-05-03T23-28-42-443Z.log
?? .playwright-mcp/console-2026-05-04T04-24-15-455Z.log
?? .playwright-mcp/console-2026-05-04T05-58-46-727Z.log
?? app/Http/Controllers/Api/Admin/V1/CustomerController.php
?? app/Http/Controllers/Api/Admin/V1/ProductController.php
?? app/Http/Controllers/Api/Apps/
?? app/Http/Controllers/Api/OAuthController.php
?? app/Http/Resources/Admin/V1/CustomerResource.php
?? app/Http/Resources/Admin/V1/ProductResource.php
?? tests/Feature/Api/AdminCatalogApiTest.php


output:
Chunk ID: 8124c5
Wall time: 0.0000 seconds
Process exited with code 0
Original token count: 44
Output:
 routes/api.php    | 22 ++++++++++++++++++++++
 routes/web.php    |  8 ++++++++
 specs/progress.md | 18 +++++++++++++-----
 3 files changed, 43 insertions(+), 5 deletions(-)


output:
Chunk ID: 71cb45
Wall time: 0.0000 seconds
Process exited with code 0
Original token count: 0
Output:


command:
/bin/zsh
-lc
git clean -f .playwright-mcp

cmd: git clean -f .playwright-mcp

aggregated_output:
Removing .playwright-mcp/console-2026-05-03T23-28-42-443Z.log
Removing .playwright-mcp/console-2026-05-04T04-24-15-455Z.log
Removing .playwright-mcp/console-2026-05-04T05-58-46-727Z.log


output:
Chunk ID: 5e8658
Wall time: 0.0000 seconds
Process exited with code 0
Original token count: 47
Output:
Removing .playwright-mcp/console-2026-05-03T23-28-42-443Z.log
Removing .playwright-mcp/console-2026-05-04T04-24-15-455Z.log
Removing .playwright-mcp/console-2026-05-04T05-58-46-727Z.log


cmd: sed -n '1,240p' app/Http/Controllers/Api/Admin/V1/ProductController.php


cmd: sed -n '1,220p' app/Http/Controllers/Api/Admin/V1/CustomerController.php


cmd: sed -n '1,260p' app/Http/Resources/Admin/V1/ProductResource.php


cmd: sed -n '1,260p' tests/Feature/Api/AdminCatalogApiTest.php


command: sed -n '1,240p' app/Http/Controllers/Api/Admin/V1/ProductController.php

ProductController.php

type: read

aggregated_output:
<?php

namespace App\Http\Controllers\Api\Admin\V1;

use App\Http\Controllers\Controller;
use App\Http\Resources\Admin\V1\ProductResource;
use App\Models\Product;
use App\Models\Store;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\AnonymousResourceCollection;
use Illuminate\Validation\Rule;

class ProductController extends Controller
{
    public function index(Request $request, Store $store): AnonymousResourceCollection
    {
        $this->authorizeStore($request, $store);

        $validated = $request->validate([
            'status' => ['nullable', Rule::in(['draft', 'active', 'archived'])],
            'query' => ['nullable', 'string', 'max:255'],
            'collection_id' => ['nullable', 'integer'],
            'sort' => ['nullable', Rule::in(['title_asc', 'title_desc', 'created_at_asc', 'created_at_desc', 'updated_at_desc'])],
            'per_page' => ['nullable', 'integer', 'min:1', 'max:100'],
        ]);

        $query = Product::withoutGlobalScopes()
            ->with([
                'media',
                'variants.inventoryItem',
            ])
            ->withCount('variants')
            ->where('store_id', $store->getKey())
            ->when(data_get($validated, 'status'), fn (Builder $query, string $status) => $query->where('status', $status))
            ->when(data_get($validated, 'collection_id'), function (Builder $query, int $collectionId): void {
                $query->whereHas('collections', fn (Builder $query) => $query->withoutGlobalScopes()->whereKey($collectionId));
            })
            ->when(data_get($validated, 'query'), function (Builder $query, string $search): void {
                $like = '%'.$search.'%';

                $query->where(function (Builder $query) use ($like): void {
                    $query
                        ->where('title', 'like', $like)
                        ->orWhere('vendor', 'like', $like)
                        ->orWhereHas('variants', fn (Builder $query) => $query->withoutGlobalScopes()->where('sku', 'like', $like));
                });
            });

        $this->applySort($query, (string) data_get($validated, 'sort', 'updated_at_desc'));

        return ProductResource::collection($query->paginate((int) data_get($validated, 'per_page', 25)));
    }

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

        return ProductResource::make($this->loadProduct($product));
    }

    private function authorizeStore(Request $request, Store $store): void
    {
        if (! $request->attributes->has('admin_api_oauth_token')) {
            abort_unless($request->user()?->stores()->whereKey($store->getKey())->exists(), 403);
        }

        app()->instance('current_store', $store);
    }

    private function abortUnlessProductBelongsToStore(Product $product, Store $store): void
    {
        abort_unless((int) $product->store_id === $store->getKey(), 404);
    }

    private function loadProduct(Product $product): Product
    {
        return $product->load([
            'collections',
            'media',
            'options.values',
            'variants.inventoryItem',
            'variants.optionValues.option',
        ])->loadCount('variants');
    }

    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'),
        };
    }
}


command: sed -n '1,220p' app/Http/Controllers/Api/Admin/V1/CustomerController.php

aggregated_output:
<?php

namespace App\Http\Controllers\Api\Admin\V1;

use App\Http\Controllers\Controller;
use App\Http\Resources\Admin\V1\CustomerResource;
use App\Models\Customer;
use App\Models\Store;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\AnonymousResourceCollection;

class CustomerController extends Controller
{
    public function index(Request $request, Store $store): AnonymousResourceCollection
    {
        $this->authorizeStore($request, $store);

        $validated = $request->validate([
            'query' => ['nullable', 'string', 'max:255'],
            'marketing_opt_in' => ['nullable', 'boolean'],
            'per_page' => ['nullable', 'integer', 'min:1', 'max:100'],
        ]);

        $customers = Customer::withoutGlobalScopes()
            ->withCount('orders')
            ->withSum('orders as total_spent_amount', 'total_amount')
            ->where('store_id', $store->getKey())
            ->when(data_get($validated, 'query'), function (Builder $query, string $search): void {
                $like = '%'.$search.'%';

                $query->where(function (Builder $query) use ($like): void {
                    $query
                        ->where('email', 'like', $like)
                        ->orWhere('name', 'like', $like);
                });
            })
            ->when(array_key_exists('marketing_opt_in', $validated), fn (Builder $query) => $query->where('marketing_opt_in', (bool) $validated['marketing_opt_in']))
            ->latest('created_at')
            ->latest('id')
            ->paginate((int) data_get($validated, 'per_page', 25));

        return CustomerResource::collection($customers);
    }

    public function show(Request $request, Store $store, Customer $customer): CustomerResource
    {
        $this->authorizeStore($request, $store);
        $this->abortUnlessCustomerBelongsToStore($customer, $store);

        return CustomerResource::make($this->loadCustomer($customer));
    }

    private function authorizeStore(Request $request, Store $store): void
    {
        if (! $request->attributes->has('admin_api_oauth_token')) {
            abort_unless($request->user()?->stores()->whereKey($store->getKey())->exists(), 403);
        }

        app()->instance('current_store', $store);
    }

    private function abortUnlessCustomerBelongsToStore(Customer $customer, Store $store): void
    {
        abort_unless((int) $customer->store_id === $store->getKey(), 404);
    }

    private function loadCustomer(Customer $customer): Customer
    {
        return $customer->load([
            'addresses',
            'orders' => fn ($query) => $query->latest('placed_at')->limit(10),
        ])
            ->loadCount('orders')
            ->loadSum('orders as total_spent_amount', 'total_amount');
    }
}


output:
Chunk ID: 4d5051
Wall time: 0.0000 seconds
Process exited with code 0
Original token count: 983
Output:
<?php

namespace App\Http\Controllers\Api\Admin\V1;

use App\Http\Controllers\Controller;
use App\Http\Resources\Admin\V1\ProductResource;
use App\Models\Product;
use App\Models\Store;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\AnonymousResourceCollection;
use Illuminate\Validation\Rule;

class ProductController extends Controller
{
    public function index(Request $request, Store $store): AnonymousResourceCollection
    {
        $this->authorizeStore($request, $store);

        $validated = $request->validate([
            'status' => ['nullable', Rule::in(['draft', 'active', 'archived'])],
            'query' => ['nullable', 'string', 'max:255'],
            'collection_id' => ['nullable', 'integer'],
            'sort' => ['nullable', Rule::in(['title_asc', 'title_desc', 'created_at_asc', 'created_at_desc', 'updated_at_desc'])],
            'per_page' => ['nullable', 'integer', 'min:1', 'max:100'],
        ]);

        $query = Product::withoutGlobalScopes()
            ->with([
                'media',
                'variants.inventoryItem',
            ])
            ->withCount('variants')
            ->where('store_id', $store->getKey())
            ->when(data_get($validated, 'status'), fn (Builder $query, string $status) => $query->where('status', $status))
            ->when(data_get($validated, 'collection_id'), function (Builder $query, int $collectionId): void {
                $query->whereHas('collections', fn (Builder $query) => $query->withoutGlobalScopes()->whereKey($collectionId));
            })
            ->when(data_get($validated, 'query'), function (Builder $query, string $search): void {
                $like = '%'.$search.'%';

                $query->where(function (Builder $query) use ($like): void {
                    $query
                        ->where('title', 'like', $like)
                        ->orWhere('vendor', 'like', $like)
                        ->orWhereHas('variants', fn (Builder $query) => $query->withoutGlobalScopes()->where('sku', 'like', $like));
                });
            });

        $this->applySort($query, (string) data_get($validated, 'sort', 'updated_at_desc'));

        return ProductResource::collection($query->paginate((int) data_get($validated, 'per_page', 25)));
    }

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

        return ProductResource::make($this->loadProduct($product));
    }

    private function authorizeStore(Request $request, Store $store): void
    {
        if (! $request->attributes->has('admin_api_oauth_token')) {
            abort_unless($request->user()?->stores()->whereKey($store->getKey())->exists(), 403);
        }

        app()->instance('current_store', $store);
    }

    private function abortUnlessProductBelongsToStore(Product $product, Store $store): void
    {
        abort_unless((int) $product->store_id === $store->getKey(), 404);
    }

    private function loadProduct(Product $product): Product
    {
        return $product->load([
            'collections',
            'media',
            'options.values',
            'variants.inventoryItem',
            'variants.optionValues.option',
        ])->loadCount('variants');
    }

    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'),
        };
    }
}


output:
Chunk ID: 0de2fa
Wall time: 0.0000 seconds
Process exited with code 0
Original token count: 711
Output:
<?php

namespace App\Http\Controllers\Api\Admin\V1;

use App\Http\Controllers\Controller;
use App\Http\Resources\Admin\V1\CustomerResource;
use App\Models\Customer;
use App\Models\Store;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\AnonymousResourceCollection;

class CustomerController extends Controller
{
    public function index(Request $request, Store $store): AnonymousResourceCollection
    {
        $this->authorizeStore($request, $store);

        $validated = $request->validate([
            'query' => ['nullable', 'string', 'max:255'],
            'marketing_opt_in' => ['nullable', 'boolean'],
            'per_page' => ['nullable', 'integer', 'min:1', 'max:100'],
        ]);

        $customers = Customer::withoutGlobalScopes()
            ->withCount('orders')
            ->withSum('orders as total_spent_amount', 'total_amount')
            ->where('store_id', $store->getKey())
            ->when(data_get($validated, 'query'), function (Builder $query, string $search): void {
                $like = '%'.$search.'%';

                $query->where(function (Builder $query) use ($like): void {
                    $query
                        ->where('email', 'like', $like)
                        ->orWhere('name', 'like', $like);
                });
            })
            ->when(array_key_exists('marketing_opt_in', $validated), fn (Builder $query) => $query->where('marketing_opt_in', (bool) $validated['marketing_opt_in']))
            ->latest('created_at')
            ->latest('id')
            ->paginate((int) data_get($validated, 'per_page', 25));

        return CustomerResource::collection($customers);
    }

    public function show(Request $request, Store $store, Customer $customer): CustomerResource
    {
        $this->authorizeStore($request, $store);
        $this->abortUnlessCustomerBelongsToStore($customer, $store);

        return CustomerResource::make($this->loadCustomer($customer));
    }

    private function authorizeStore(Request $request, Store $store): void
    {
        if (! $request->attributes->has('admin_api_oauth_token')) {
            abort_unless($request->user()?->stores()->whereKey($store->getKey())->exists(), 403);
        }

        app()->instance('current_store', $store);
    }

    private function abortUnlessCustomerBelongsToStore(Customer $customer, Store $store): void
    {
        abort_unless((int) $customer->store_id === $store->getKey(), 404);
    }

    private function loadCustomer(Customer $customer): Customer
    {
        return $customer->load([
            'addresses',
            'orders' => fn ($query) => $query->latest('placed_at')->limit(10),
        ])
            ->loadCount('orders')
            ->loadSum('orders as total_spent_amount', 'total_amount');
    }
}


command: sed -n '1,260p' app/Http/Resources/Admin/V1/ProductResource.php

aggregated_output:
<?php

namespace App\Http\Resources\Admin\V1;

use App\Models\ProductMedia;
use App\Models\ProductVariant;
use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\JsonResource;
use Illuminate\Support\Facades\Storage;

class ProductResource extends JsonResource
{
    /**
     * Transform the resource into an array.
     *
     * @return array<string, mixed>
     */
    public function toArray(Request $request): array
    {
        return [
            'id' => $this->id,
            'store_id' => $this->store_id,
            'title' => $this->title,
            'handle' => $this->handle,
            'description_html' => $this->when($this->relationLoaded('options'), $this->description_html),
            'status' => $this->status?->value,
            'vendor' => $this->vendor,
            'product_type' => $this->product_type,
            'tags' => $this->tags ?? [],
            'variants_count' => $this->whenCounted('variants'),
            'total_inventory' => $this->when($this->relationLoaded('variants'), fn (): int => $this->variants->sum(
                fn (ProductVariant $variant): int => $variant->inventoryItem?->quantity_on_hand ?? 0,
            )),
            'featured_image' => $this->when($this->relationLoaded('media'), function (): ?array {
                $media = $this->media->first();

                return $media instanceof ProductMedia ? $this->mediaPayload($media) : null;
            }),
            'options' => $this->whenLoaded('options', fn () => $this->options->map(fn ($option): array => [
                'id' => $option->id,
                'name' => $option->name,
                'position' => $option->position,
                'values' => $option->values->map(fn ($value): array => [
                    'id' => $value->id,
                    'value' => $value->value,
                    'position' => $value->position,
                ])->values(),
            ])->values()),
            'variants' => $this->when(
                $this->relationLoaded('variants') && $this->relationLoaded('options'),
                fn () => $this->variants->map(fn (ProductVariant $variant): array => [
                    'id' => $variant->id,
                    'sku' => $variant->sku,
                    'barcode' => $variant->barcode,
                    'price_amount' => $variant->price_amount,
                    'compare_at_amount' => $variant->compare_at_amount,
                    'currency' => $variant->currency,
                    'weight_g' => $variant->weight_g,
                    'requires_shipping' => $variant->requires_shipping,
                    'is_default' => $variant->is_default,
                    'position' => $variant->position,
                    'status' => $variant->status?->value,
                    'option_values' => $variant->optionValues->map(fn ($value): array => [
                        'option_name' => $value->option?->name,
                        'value' => $value->value,
                    ])->values(),
                    'inventory' => [
                        'quantity_on_hand' => $variant->inventoryItem?->quantity_on_hand ?? 0,
                        'quantity_reserved' => $variant->inventoryItem?->quantity_reserved ?? 0,
                        'policy' => $variant->inventoryItem?->policy?->value,
                    ],
                ])->values(),
            ),
            'media' => $this->whenLoaded('media', fn () => $this->media->map(fn (ProductMedia $media): array => $this->mediaPayload($media))->values()),
            'collections' => $this->whenLoaded('collections', fn () => $this->collections->map(fn ($collection): array => [
                'id' => $collection->id,
                'title' => $collection->title,
                'handle' => $collection->handle,
            ])->values()),
            'published_at' => $this->published_at?->toIso8601String(),
            'created_at' => $this->created_at?->toIso8601String(),
            'updated_at' => $this->updated_at?->toIso8601String(),
        ];
    }

    /**
     * @return array<string, mixed>
     */
    private function mediaPayload(ProductMedia $media): array
    {
        return [
            'id' => $media->id,
            'type' => $media->type?->value,
            'storage_key' => $media->storage_key,
            'url' => Storage::disk('public')->url($media->storage_key),
            'alt_text' => $media->alt_text,
            'width' => $media->width,
            'height' => $media->height,
            'mime_type' => $media->mime_type,
            'byte_size' => $media->byte_size,
            'position' => $media->position,
            'status' => $media->status?->value,
        ];
    }
}


output:
Chunk ID: e210cd
Wall time: 0.0000 seconds
Process exited with code 0
Original token count: 1179
Output:
<?php

namespace App\Http\Resources\Admin\V1;

use App\Models\ProductMedia;
use App\Models\ProductVariant;
use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\JsonResource;
use Illuminate\Support\Facades\Storage;

class ProductResource extends JsonResource
{
    /**
     * Transform the resource into an array.
     *
     * @return array<string, mixed>
     */
    public function toArray(Request $request): array
    {
        return [
            'id' => $this->id,
            'store_id' => $this->store_id,
            'title' => $this->title,
            'handle' => $this->handle,
            'description_html' => $this->when($this->relationLoaded('options'), $this->description_html),
            'status' => $this->status?->value,
            'vendor' => $this->vendor,
            'product_type' => $this->product_type,
            'tags' => $this->tags ?? [],
            'variants_count' => $this->whenCounted('variants'),
            'total_inventory' => $this->when($this->relationLoaded('variants'), fn (): int => $this->variants->sum(
                fn (ProductVariant $variant): int => $variant->inventoryItem?->quantity_on_hand ?? 0,
            )),
            'featured_image' => $this->when($this->relationLoaded('media'), function (): ?array {
                $media = $this->media->first();

                return $media instanceof ProductMedia ? $this->mediaPayload($media) : null;
            }),
            'options' => $this->whenLoaded('options', fn () => $this->options->map(fn ($option): array => [
                'id' => $option->id,
                'name' => $option->name,
                'position' => $option->position,
                'values' => $option->values->map(fn ($value): array => [
                    'id' => $value->id,
                    'value' => $value->value,
                    'position' => $value->position,
                ])->values(),
            ])->values()),
            'variants' => $this->when(
                $this->relationLoaded('variants') && $this->relationLoaded('options'),
                fn () => $this->variants->map(fn (ProductVariant $variant): array => [
                    'id' => $variant->id,
                    'sku' => $variant->sku,
                    'barcode' => $variant->barcode,
                    'price_amount' => $variant->price_amount,
                    'compare_at_amount' => $variant->compare_at_amount,
                    'currency' => $variant->currency,
                    'weight_g' => $variant->weight_g,
                    'requires_shipping' => $variant->requires_shipping,
                    'is_default' => $variant->is_default,
                    'position' => $variant->position,
                    'status' => $variant->status?->value,
                    'option_values' => $variant->optionValues->map(fn ($value): array => [
                        'option_name' => $value->option?->name,
                        'value' => $value->value,
                    ])->values(),
                    'inventory' => [
                        'quantity_on_hand' => $variant->inventoryItem?->quantity_on_hand ?? 0,
                        'quantity_reserved' => $variant->inventoryItem?->quantity_reserved ?? 0,
                        'policy' => $variant->inventoryItem?->policy?->value,
                    ],
                ])->values(),
            ),
            'media' => $this->whenLoaded('media', fn () => $this->media->map(fn (ProductMedia $media): array => $this->mediaPayload($media))->values()),
            'collections' => $this->whenLoaded('collections', fn () => $this->collections->map(fn ($collection): array => [
                'id' => $collection->id,
                'title' => $collection->title,
                'handle' => $collection->handle,
            ])->values()),
            'published_at' => $this->published_at?->toIso8601String(),
            'created_at' => $this->created_at?->toIso8601String(),
            'updated_at' => $this->updated_at?->toIso8601String(),
        ];
    }

    /**
     * @return array<string, mixed>
     */
    private function mediaPayload(ProductMedia $media): array
    {
        return [
            'id' => $media->id,
            'type' => $media->type?->value,
            'storage_key' => $media->storage_key,
            'url' => Storage::disk('public')->url($media->storage_key),
            'alt_text' => $media->alt_text,
            'width' => $media->width,
            'height' => $media->height,
            'mime_type' => $media->mime_type,
            'byte_size' => $media->byte_size,
            'position' => $media->position,
            'status' => $media->status?->value,
        ];
    }
}


command: sed -n '1,260p' tests/Feature/Api/AdminCatalogApiTest.php

aggregated_output:
<?php

use App\Models\Customer;
use App\Models\CustomerAddress;
use App\Models\Order;
use App\Models\Product;
use App\Models\ProductVariant;
use App\Models\Store;
use App\Models\User;
use App\Services\WebhookService;
use Database\Seeders\DatabaseSeeder;
use Illuminate\Foundation\Testing\RefreshDatabase;

uses(RefreshDatabase::class);

beforeEach(function (): void {
    $this->withoutVite();
    $this->seed(DatabaseSeeder::class);
});

function adminCatalogApiStore(): Store
{
    return Store::query()->where('handle', 'acme-fashion')->firstOrFail();
}

function adminCatalogApiUser(): User
{
    return User::query()->where('email', 'admin@acme.test')->firstOrFail();
}

/**
 * @param  list<string>  $abilities
 * @return array{token: \App\Models\OauthToken, plain_text: string}
 */
function adminCatalogApiToken(Store $store, array $abilities): array
{
    return app(WebhookService::class)->createApiToken($store, 'Catalog integration', $abilities);
}

test('admin product api lists and shows store scoped products', function (): void {
    $store = adminCatalogApiStore();
    $product = Product::factory()
        ->withDefaultVariant(3299)
        ->create([
            'store_id' => $store->getKey(),
            'title' => 'Admin API Jacket',
            'handle' => 'admin-api-jacket',
            'vendor' => 'Catalog Test Vendor',
            'tags' => ['outerwear', 'api'],
        ]);
    $variant = ProductVariant::withoutGlobalScopes()
        ->where('product_id', $product->getKey())
        ->firstOrFail();
    Product::factory()
        ->withDefaultVariant()
        ->create([
            'store_id' => Store::factory()->create()->getKey(),
            'title' => 'Other Store Jacket',
        ]);

    $this->getJson("/api/admin/v1/stores/{$store->getKey()}/products")
        ->assertUnauthorized();

    $this->actingAs(adminCatalogApiUser())
        ->getJson("/api/admin/v1/stores/{$store->getKey()}/products?query={$variant->sku}&sort=title_asc")
        ->assertOk()
        ->assertJsonPath('data.0.title', 'Admin API Jacket')
        ->assertJsonPath('data.0.variants_count', 1)
        ->assertJsonPath('data.0.total_inventory', 50)
        ->assertJsonMissing(['title' => 'Other Store Jacket']);

    $this->actingAs(adminCatalogApiUser())
        ->getJson("/api/admin/v1/stores/{$store->getKey()}/products/{$product->getKey()}")
        ->assertOk()
        ->assertJsonPath('data.title', 'Admin API Jacket')
        ->assertJsonPath('data.variants.0.price_amount', 3299)
        ->assertJsonPath('data.variants.0.inventory.quantity_on_hand', 50);
});

test('admin customer api lists and shows store scoped customers', function (): void {
    $store = adminCatalogApiStore();
    $customer = Customer::factory()->create([
        'store_id' => $store->getKey(),
        'email' => 'customer-api@example.test',
        'name' => 'Customer API',
        'marketing_opt_in' => true,
    ]);
    CustomerAddress::factory()->default()->create(['customer_id' => $customer->getKey()]);
    Order::factory()->paid()->forCustomer($customer)->create([
        'store_id' => $store->getKey(),
        'order_number' => '#CA-1001',
        'total_amount' => 4400,
    ]);
    Customer::factory()->create([
        'store_id' => Store::factory()->create()->getKey(),
        'email' => 'other-customer@example.test',
    ]);

    $this->actingAs(adminCatalogApiUser())
        ->getJson("/api/admin/v1/stores/{$store->getKey()}/customers?query=customer-api")
        ->assertOk()
        ->assertJsonPath('data.0.email', 'customer-api@example.test')
        ->assertJsonPath('data.0.orders_count', 1)
        ->assertJsonPath('data.0.total_spent_amount', 4400)
        ->assertJsonMissing(['email' => 'other-customer@example.test']);

    $this->actingAs(adminCatalogApiUser())
        ->getJson("/api/admin/v1/stores/{$store->getKey()}/customers/{$customer->getKey()}")
        ->assertOk()
        ->assertJsonPath('data.email', 'customer-api@example.test')
        ->assertJsonPath('data.addresses.0.is_default', true)
        ->assertJsonPath('data.orders.0.order_number', '#CA-1001');
});

test('admin catalog api accepts scoped tokens and enforces abilities', function (): void {
    $store = adminCatalogApiStore();
    Product::factory()->withDefaultVariant()->create([
        'store_id' => $store->getKey(),
        'title' => 'Token Visible Product',
    ]);
    Customer::factory()->create([
        'store_id' => $store->getKey(),
        'email' => 'token-customer@example.test',
    ]);
    $productToken = adminCatalogApiToken($store, ['read-products']);
    $customerToken = adminCatalogApiToken($store, ['read-customers']);

    $this->withToken($productToken['plain_text'])
        ->getJson("/api/admin/v1/stores/{$store->getKey()}/products?query=Token Visible")
        ->assertOk()
        ->assertJsonPath('data.0.title', 'Token Visible Product');

    $this->withToken($productToken['plain_text'])
        ->getJson("/api/admin/v1/stores/{$store->getKey()}/customers")
        ->assertForbidden();

    $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($customerToken['token']->refresh()->last_used_at)->not->toBeNull();
});

test('admin catalog api rejects resources outside the requested store', function (): void {
    $store = adminCatalogApiStore();
    $otherStore = Store::factory()->create();
    $otherProduct = Product::factory()->withDefaultVariant()->create(['store_id' => $otherStore->getKey()]);
    $otherCustomer = Customer::factory()->create(['store_id' => $otherStore->getKey()]);

    $this->actingAs(adminCatalogApiUser())
        ->getJson("/api/admin/v1/stores/{$store->getKey()}/products/{$otherProduct->getKey()}")
        ->assertNotFound();

    $this->actingAs(adminCatalogApiUser())
        ->getJson("/api/admin/v1/stores/{$store->getKey()}/customers/{$otherCustomer->getKey()}")
        ->assertNotFound();
});

test('deferred oauth and app ecosystem routes return not implemented', function (): void {
    $store = adminCatalogApiStore();

    $this->actingAs(adminCatalogApiUser())
        ->getJson('/oauth/authorize?client_id=test&redirect_uri=https://example.test/callback&response_type=code&scope=read-products&state=state')
        ->assertStatus(501)
        ->assertJsonPath('message', 'OAuth app ecosystem endpoints are deferred for initial implementation.');

    $this->postJson('/oauth/token', [
        'grant_type' => 'authorization_code',
        'client_id' => 'test',
        'client_secret' => 'secret',
        'redirect_uri' => 'https://example.test/callback',
        'code' => 'code',
    ])
        ->assertStatus(501)
        ->assertJsonPath('message', 'OAuth app ecosystem endpoints are deferred for initial implementation.');

    $this->getJson("/api/apps/v1/stores/{$store->getKey()}/products")
        ->assertStatus(501)
        ->assertJsonPath('message', 'App API endpoints are deferred for initial implementation.');
});


output:
Chunk ID: c886bb
Wall time: 0.0000 seconds
Process exited with code 0
Original token count: 1803
Output:
<?php

use App\Models\Customer;
use App\Models\CustomerAddress;
use App\Models\Order;
use App\Models\Product;
use App\Models\ProductVariant;
use App\Models\Store;
use App\Models\User;
use App\Services\WebhookService;
use Database\Seeders\DatabaseSeeder;
use Illuminate\Foundation\Testing\RefreshDatabase;

uses(RefreshDatabase::class);

beforeEach(function (): void {
    $this->withoutVite();
    $this->seed(DatabaseSeeder::class);
});

function adminCatalogApiStore(): Store
{
    return Store::query()->where('handle', 'acme-fashion')->firstOrFail();
}

function adminCatalogApiUser(): User
{
    return User::query()->where('email', 'admin@acme.test')->firstOrFail();
}

/**
 * @param  list<string>  $abilities
 * @return array{token: \App\Models\OauthToken, plain_text: string}
 */
function adminCatalogApiToken(Store $store, array $abilities): array
{
    return app(WebhookService::class)->createApiToken($store, 'Catalog integration', $abilities);
}

test('admin product api lists and shows store scoped products', function (): void {
    $store = adminCatalogApiStore();
    $product = Product::factory()
        ->withDefaultVariant(3299)
        ->create([
            'store_id' => $store->getKey(),
            'title' => 'Admin API Jacket',
            'handle' => 'admin-api-jacket',
            'vendor' => 'Catalog Test Vendor',
            'tags' => ['outerwear', 'api'],
        ]);
    $variant = ProductVariant::withoutGlobalScopes()
        ->where('product_id', $product->getKey())
        ->firstOrFail();
    Product::factory()
        ->withDefaultVariant()
        ->create([
            'store_id' => Store::factory()->create()->getKey(),
            'title' => 'Other Store Jacket',
        ]);

    $this->getJson("/api/admin/v1/stores/{$store->getKey()}/products")
        ->assertUnauthorized();

    $this->actingAs(adminCatalogApiUser())
        ->getJson("/api/admin/v1/stores/{$store->getKey()}/products?query={$variant->sku}&sort=title_asc")
        ->assertOk()
        ->assertJsonPath('data.0.title', 'Admin API Jacket')
        ->assertJsonPath('data.0.variants_count', 1)
        ->assertJsonPath('data.0.total_inventory', 50)
        ->assertJsonMissing(['title' => 'Other Store Jacket']);

    $this->actingAs(adminCatalogApiUser())
        ->getJson("/api/admin/v1/stores/{$store->getKey()}/products/{$product->getKey()}")
        ->assertOk()
        ->assertJsonPath('data.title', 'Admin API Jacket')
        ->assertJsonPath('data.variants.0.price_amount', 3299)
        ->assertJsonPath('data.variants.0.inventory.quantity_on_hand', 50);
});

test('admin customer api lists and shows store scoped customers', function (): void {
    $store = adminCatalogApiStore();
    $customer = Customer::factory()->create([
        'store_id' => $store->getKey(),
        'email' => 'customer-api@example.test',
        'name' => 'Customer API',
        'marketing_opt_in' => true,
    ]);
    CustomerAddress::factory()->default()->create(['customer_id' => $customer->getKey()]);
    Order::factory()->paid()->forCustomer($customer)->create([
        'store_id' => $store->getKey(),
        'order_number' => '#CA-1001',
        'total_amount' => 4400,
    ]);
    Customer::factory()->create([
        'store_id' => Store::factory()->create()->getKey(),
        'email' => 'other-customer@example.test',
    ]);

    $this->actingAs(adminCatalogApiUser())
        ->getJson("/api/admin/v1/stores/{$store->getKey()}/customers?query=customer-api")
        ->assertOk()
        ->assertJsonPath('data.0.email', 'customer-api@example.test')
        ->assertJsonPath('data.0.orders_count', 1)
        ->assertJsonPath('data.0.total_spent_amount', 4400)
        ->assertJsonMissing(['email' => 'other-customer@example.test']);

    $this->actingAs(adminCatalogApiUser())
        ->getJson("/api/admin/v1/stores/{$store->getKey()}/customers/{$customer->getKey()}")
        ->assertOk()
        ->assertJsonPath('data.email', 'customer-api@example.test')
        ->assertJsonPath('data.addresses.0.is_default', true)
        ->assertJsonPath('data.orders.0.order_number', '#CA-1001');
});

test('admin catalog api accepts scoped tokens and enforces abilities', function (): void {
    $store = adminCatalogApiStore();
    Product::factory()->withDefaultVariant()->create([
        'store_id' => $store->getKey(),
        'title' => 'Token Visible Product',
    ]);
    Customer::factory()->create([
        'store_id' => $store->getKey(),
        'email' => 'token-customer@example.test',
    ]);
    $productToken = adminCatalogApiToken($store, ['read-products']);
    $customerToken = adminCatalogApiToken($store, ['read-customers']);

    $this->withToken($productToken['plain_text'])
        ->getJson("/api/admin/v1/stores/{$store->getKey()}/products?query=Token Visible")
        ->assertOk()
        ->assertJsonPath('data.0.title', 'Token Visible Product');

    $this->withToken($productToken['plain_text'])
        ->getJson("/api/admin/v1/stores/{$store->getKey()}/customers")
        ->assertForbidden();

    $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($customerToken['token']->refresh()->last_used_at)->not->toBeNull();
});

test('admin catalog api rejects resources outside the requested store', function (): void {
    $store = adminCatalogApiStore();
    $otherStore = Store::factory()->create();
    $otherProduct = Product::factory()->withDefaultVariant()->create(['store_id' => $otherStore->getKey()]);
    $otherCustomer = Customer::factory()->create(['store_id' => $otherStore->getKey()]);

    $this->actingAs(adminCatalogApiUser())
        ->getJson("/api/admin/v1/stores/{$store->getKey()}/products/{$otherProduct->getKey()}")
        ->assertNotFound();

    $this->actingAs(adminCatalogApiUser())
        ->getJson("/api/admin/v1/stores/{$store->getKey()}/customers/{$otherCustomer->getKey()}")
        ->assertNotFound();
});

test('deferred oauth and app ecosystem routes return not implemented', function (): void {
    $store = adminCatalogApiStore();

    $this->actingAs(adminCatalogApiUser())
        ->getJson('/oauth/authorize?client_id=test&redirect_uri=https://example.test/callback&response_type=code&scope=read-products&state=state')
        ->assertStatus(501)
        ->assertJsonPath('message', 'OAuth app ecosystem endpoints are deferred for initial implementation.');

    $this->postJson('/oauth/token', [
        'grant_type' => 'authorization_code',
        'client_id' => 'test',
        'client_secret' => 'secret',
        'redirect_uri' => 'https://example.test/callback',
        'code' => 'code',
    ])
        ->assertStatus(501)
        ->assertJsonPath('message', 'OAuth app ecosystem endpoints are deferred for initial implementation.');

    $this->getJson("/api/apps/v1/stores/{$store->getKey()}/products")
        ->assertStatus(501)
        ->assertJsonPath('message', 'App API endpoints are deferred for initial implementation.');
});


cmd: git diff -- routes/api.php routes/web.php specs/progress.md


cmd: find app/Http/Controllers/Api app/Http/Resources/Admin/V1 tests/Feature/Api -path '*AdminCatalogApiTest.php' -o -path '*ProductController.php' -o -path '*CustomerController.php' -o -path '*OAuthController.php' -o -path '*DeferredEndpointController.php' -o -path '*ProductResource.php' -o -path '*CustomerResource.php' | sort


aggregated_output:
 M routes/api.php
 M routes/web.php
 M specs/progress.md
?? app/Http/Controllers/Api/Admin/V1/CustomerController.php
?? app/Http/Controllers/Api/Admin/V1/ProductController.php
?? app/Http/Controllers/Api/Apps/
?? app/Http/Controllers/Api/OAuthController.php
?? app/Http/Resources/Admin/V1/CustomerResource.php
?? app/Http/Resources/Admin/V1/ProductResource.php
?? tests/Feature/Api/AdminCatalogApiTest.php


output:
Chunk ID: 87f2d1
Wall time: 0.0000 seconds
Process exited with code 0
Original token count: 102
Output:
 M routes/api.php
 M routes/web.php
 M specs/progress.md
?? app/Http/Controllers/Api/Admin/V1/CustomerController.php
?? app/Http/Controllers/Api/Admin/V1/ProductController.php
?? app/Http/Controllers/Api/Apps/
?? app/Http/Controllers/Api/OAuthController.php
?? app/Http/Resources/Admin/V1/CustomerResource.php
?? app/Http/Resources/Admin/V1/ProductResource.php
?? tests/Feature/Api/AdminCatalogApiTest.php


command: git diff -- routes/api.php routes/web.php specs/progress.md

aggregated_output:
diff --git a/routes/api.php b/routes/api.php
index 23ee6a2b..778f5abd 100644
--- a/routes/api.php
+++ b/routes/api.php
@@ -1,8 +1,11 @@
 <?php

+use App\Http\Controllers\Api\Admin\V1\CustomerController as AdminCustomerController;
 use App\Http\Controllers\Api\Admin\V1\OrderController as AdminOrderController;
 use App\Http\Controllers\Api\Admin\V1\OrderFulfillmentController as AdminOrderFulfillmentController;
 use App\Http\Controllers\Api\Admin\V1\OrderRefundController as AdminOrderRefundController;
+use App\Http\Controllers\Api\Admin\V1\ProductController as AdminProductController;
+use App\Http\Controllers\Api\Apps\V1\DeferredEndpointController as DeferredAppEndpointController;
 use App\Http\Controllers\Api\Storefront\V1\AnalyticsEventController as StorefrontAnalyticsEventController;
 use App\Http\Controllers\Api\Storefront\V1\CartController;
 use App\Http\Controllers\Api\Storefront\V1\CartLineController;
@@ -49,6 +52,16 @@
     ->prefix('admin/v1/stores/{store}')
     ->name('api.admin.v1.')
     ->group(function (): void {
+        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:read-customers')->group(function (): void {
+            Route::get('customers', [AdminCustomerController::class, 'index'])->name('customers.index');
+            Route::get('customers/{customer}', [AdminCustomerController::class, 'show'])->name('customers.show');
+        });
+
         Route::middleware('admin.api:read-orders')->group(function (): void {
             Route::get('orders', [AdminOrderController::class, 'index'])->name('orders.index');
             Route::get('orders/{order}', [AdminOrderController::class, 'show'])->name('orders.show');
@@ -59,3 +72,12 @@
             Route::post('orders/{order}/refunds', [AdminOrderRefundController::class, 'store'])->name('orders.refunds.store');
         });
     });
+
+Route::middleware('throttle:60,1')
+    ->prefix('apps/v1/stores/{store}')
+    ->name('api.apps.v1.')
+    ->group(function (): void {
+        Route::get('products', DeferredAppEndpointController::class)->name('products.index');
+        Route::get('orders', DeferredAppEndpointController::class)->name('orders.index');
+        Route::get('customers', DeferredAppEndpointController::class)->name('customers.index');
+    });
diff --git a/routes/web.php b/routes/web.php
index d10a27a2..a0528ba9 100644
--- a/routes/web.php
+++ b/routes/web.php
@@ -1,5 +1,6 @@
 <?php

+use App\Http\Controllers\Api\OAuthController;
 use App\Http\Middleware\EnsureUserEmailIsVerified;
 use App\Livewire\Admin\Analytics\Index as AdminAnalyticsIndex;
 use App\Livewire\Admin\Apps\Index as AdminAppsIndex;
@@ -72,6 +73,13 @@
     return redirect()->route('admin.login');
 })->middleware('auth')->name('admin.logout');

+Route::get('oauth/authorize', [OAuthController::class, 'authorize'])
+    ->middleware('auth')
+    ->name('oauth.authorize');
+
+Route::post('oauth/token', [OAuthController::class, 'token'])
+    ->name('oauth.token');
+
 Route::middleware(['auth', EnsureUserEmailIsVerified::class, 'admin'])->prefix('admin')->name('admin.')->group(function (): void {
     Route::livewire('/', AdminDashboard::class)->name('dashboard');
     Route::livewire('analytics', AdminAnalyticsIndex::class)->name('analytics.index');
diff --git a/specs/progress.md b/specs/progress.md
index 0030d47e..d143249d 100644
--- a/specs/progress.md
+++ b/specs/progress.md
@@ -27,7 +27,7 @@ ## 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 tables are implemented: search_settings, search_queries, SQLite FTS5 `products_fts`, analytics_events, analytics_daily, apps, app_installations, oauth_clients, oauth_tokens, webhook_subscriptions, and webhook_deliveries. |
-| 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`, `/account/forgot-password`, `/account/reset-password/{token}`, `/account`, `/account/orders/{order}`, `/admin/products`, `/admin/products/create`, `/admin/products/{product}/edit`, `/admin/collections`, `/admin/collections/create`, `/admin/collections/{collection}/edit`, `/admin/inventory`,…10179 chars truncated…registered with Livewire persistent middleware so component POST requests keep `current_store` binding and store-role checks after the initial full-page route load.
@@ -368,7 +376,7 @@ ## Decisions
 - Analytics ingestion stores raw events with client-event deduplication and keeps aggregation separate in `analytics_daily`; admin analytics reads pre-aggregated daily metrics for KPIs and raw events for referrers.
 - Seeded analytics metrics are deterministic demo data and are not tied to seeded orders because order/payment seed data remains intentionally absent.
 - The admin analytics CSV export is generated as a data URL for the self-contained local app rather than creating persistent export files/jobs.
-- OAuth/Passport app authorization remains deferred per the roadmap; the developer token UI uses the existing app installation and `oauth_tokens` schema as a self-contained local token store, but it is not Sanctum/Passport authentication yet.
+- OAuth/Passport app authorization remains deferred per the roadmap; `/oauth/*` and `/api/apps/v1/*` now return explicit `501 Not Implemented` responses, while the developer token UI uses the existing app installation and `oauth_tokens` schema as a self-contained local token store.
 - Admin order API routes use the `admin.api` middleware, which accepts either the existing session-authenticated admin user or a hashed `oauth_tokens` bearer token scoped to the route store; token requests require `read-orders` for GET routes and `write-orders` for refund/fulfillment mutations and update `last_used_at` on successful use.
 - Outbound webhook delivery jobs are forced onto the database queue and `webhooks` queue name so domain events enqueue delivery work instead of making external HTTP requests inline when the default queue connection is `sync`.
 - Webhook signatures follow the security spec message shape of `{timestamp}.{json_body}` with HMAC-SHA256, while the delivery payload is wrapped with `id`, `api_version`, `event_type`, `store_id`, `occurred_at`, and `data`.
@@ -382,10 +390,10 @@ ## Open Issues

 - Customer password reset is implemented under `/account/forgot-password` and `/account/reset-password/{token}`; the exact spec root paths remain occupied by the existing Fortify starter/admin reset routes.
 - `php artisan route:list --except-vendor` hides Livewire full-page routes because their controller is vendor-provided; path-filtered route-list commands are used as evidence for those routes.
-- OAuth/Passport app API endpoints and broader admin REST endpoints outside order management are still missing; outbound webhook subscription management is implemented through the admin UI only, as specified for the initial implementation.
+- Non-order admin REST write endpoints and the larger admin REST surfaces for collections, discounts, settings, themes, content, search, analytics, and exports are still missing; outbound webhook subscription management is implemented through the admin UI only, as specified for the initial implementation.
 - Automated browser tests from Spec 08 are still missing; current browser coverage is manual Playwright MCP smoke verification.
 - SQLite enum/check constraints from the schema spec are not yet explicitly enforced as database `CHECK` constraints; enum validation is currently enforced through casts/services/model invariants.

 ## Completion Summary

-Not complete. Phase 1 foundation, Phase 2 catalog data/UI surfaces, product media processing/admin upload controls, Phase 3 storefront theme/content/navigation data, the Phase 4 cart/checkout/pricing backend foundation, cart/checkout storefront UI through order completion, cart-page estimates, cart/checkout REST APIs, Phase 5 order/payment backend foundation, Phase 5 refund/fulfillment services, customer order views, admin dashboard/order/customer/discount/content/settings/theme-file/navigation/search/analytics/apps/developers management, storefront search and analytics APIs, order API surfaces, and outbound webhook delivery foundations are implemented, with known auth/token route compatibility, OAuth/app API, browser-test, and database CHECK constraint gaps tracked above.
+Not complete. Phase 1 foundation, Phase 2 catalog data/UI surfaces, product media processing/admin upload controls, Phase 3 storefront theme/content/navigation data, the Phase 4 cart/checkout/pricing backend foundation, cart/checkout storefront UI through order completion, cart-page estimates, cart/checkout REST APIs, Phase 5 order/payment backend foundation, Phase 5 refund/fulfillment services, customer order views, admin dashboard/order/customer/discount/content/settings/theme-file/navigation/search/analytics/apps/developers management, storefront search and analytics APIs, product/customer/order API surfaces, deferred OAuth/app route stubs, and outbound webhook delivery foundations are implemented, with known auth/token route compatibility, broader admin REST, browser-test, and database CHECK constraint gaps tracked above.


output:
Chunk ID: ec6328
Wall time: 0.0000 seconds
Process exited with code 0
Original token count: 5045
Output:
diff --git a/routes/api.php b/routes/api.php
index 23ee6a2b..778f5abd 100644
--- a/routes/api.php
+++ b/routes/api.php
@@ -1,8 +1,11 @@
 <?php

+use App\Http\Controllers\Api\Admin\V1\CustomerController as AdminCustomerController;
 use App\Http\Controllers\Api\Admin\V1\OrderController as AdminOrderController;
 use App\Http\Controllers\Api\Admin\V1\OrderFulfillmentController as AdminOrderFulfillmentController;
 use App\Http\Controllers\Api\Admin\V1\OrderRefundController as AdminOrderRefundController;
+use App\Http\Controllers\Api\Admin\V1\ProductController as AdminProductController;
+use App\Http\Controllers\Api\Apps\V1\DeferredEndpointController as DeferredAppEndpointController;
 use App\Http\Controllers\Api\Storefront\V1\AnalyticsEventController as StorefrontAnalyticsEventController;
 use App\Http\Controllers\Api\Storefront\V1\CartController;
 use App\Http\Controllers\Api\Storefront\V1\CartLineController;
@@ -49,6 +52,16 @@
     ->prefix('admin/v1/stores/{store}')
     ->name('api.admin.v1.')
     ->group(function (): void {
+        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:read-customers')->group(function (): void {
+            Route::get('customers', [AdminCustomerController::class, 'index'])->name('customers.index');
+            Route::get('customers/{customer}', [AdminCustomerController::class, 'show'])->name('customers.show');
+        });
+
         Route::middleware('admin.api:read-orders')->group(function (): void {
             Route::get('orders', [AdminOrderController::class, 'index'])->name('orders.index');
             Route::get('orders/{order}', [AdminOrderController::class, 'show'])->name('orders.show');
@@ -59,3 +72,12 @@
             Route::post('orders/{order}/refunds', [AdminOrderRefundController::class, 'store'])->name('orders.refunds.store');
         });
     });
+
+Route::middleware('throttle:60,1')
+    ->prefix('apps/v1/stores/{store}')
+    ->name('api.apps.v1.')
+    ->group(function (): void {
+        Route::get('products', DeferredAppEndpointController::class)->name('products.index');
+        Route::get('orders', DeferredAppEndpointController::class)->name('orders.index');
+        Route::get('customers', DeferredAppEndpointController::class)->name('customers.index');
+    });
diff --git a/routes/web.php b/routes/web.php
index d10a27a2..a0528ba9 100644
--- a/routes/web.php
+++ b/routes/web.php
@@ -1,5 +1,6 @@
 <?php

+use App\Http\Controllers\Api\OAuthController;
 use App\Http\Middleware\EnsureUserEmailIsVerified;
 use App\Livewire\Admin\Analytics\Index as AdminAnalyticsIndex;
 use App\Livewire\Admin\Apps\Index as AdminAppsIndex;
@@ -72,6 +73,13 @@
     return redirect()->route('admin.login');
 })->middleware('auth')->name('admin.logout');

+Route::get('oauth/authorize', [OAuthController::class, 'authorize'])
+    ->middleware('auth')
+    ->name('oauth.authorize');
+
+Route::post('oauth/token', [OAuthController::class, 'token'])
+    ->name('oauth.token');
+
 Route::middleware(['auth', EnsureUserEmailIsVerified::class, 'admin'])->prefix('admin')->name('admin.')->group(function (): void {
     Route::livewire('/', AdminDashboard::class)->name('dashboard');
     Route::livewire('analytics', AdminAnalyticsIndex::class)->name('analytics.index');
diff --git a/specs/progress.md b/specs/progress.md
index 0030d47e..d143249d 100644
--- a/specs/progress.md
+++ b/specs/progress.md
@@ -27,7 +27,7 @@ ## 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 tables are implemented: search_settings, search_queries, SQLite FTS5 `products_fts`, analytics_events, analytics_daily, apps, app_installations, oauth_clients, oauth_tokens, webhook_subscriptions, and webhook_deliveries. |
-| 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`, `/account/forgot-password`, `/account/reset-password/{token}`, `/account`, `/account/orders/{order}`, `/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 order list/detail, fulfillment creation, and refund creation under `/api/admin/v1/stores/{store}` with session or scoped bearer-token authentication. OAuth/app API endpoints and broader admin REST surfaces are still missing. |
+| 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`, `/account/forgot-password`, `/account/reset-password/{token}`, `/account`, `/account/orders/{order}`, `/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, 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. Write endpoints for non-order admin REST surfaces are still missing. |
 | 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 editor with iframe preview, theme file list/editor, general settings, domain management, shipping zone/rate management, tax settings, checkout policy settings, notification settings, search settings/reindexing, installed app list/detail, and developer token/webhook management with auth protection and store scoping. |
 | Storefront UI | `specs/04-STOREFRONT-UI.md` | partial | Storefront layout, home, collections index/detail, product detail, SQLite-backed search with filters/sort/pagination, breadcrumbs, price display, product cards, DB-backed pages, seeded navigation menus with one-level dropdown children, cached theme settings, product add-to-cart persistence, cart drawer, cart page with discount and shipping estimates, checkout address/shipping/discount/payment-selection/order-submission UI, order confirmation, customer password reset forms, and customer order list/detail views render seeded and runtime data. |
 | Business logic | `specs/05-BUSINESS-LOGIC.md` | partial | `ResolveStore`, `BelongsToStore`, `StoreScope`, role checks, customer guard provider, store-scoped `CustomerPasswordResetService`, catalog product lifecycle transitions, SKU uniqueness, variant matrix rebuilding, inventory reserve/release/commit/restock, automatic variant inventory, variant option mapping, media resize/cleanup job, `SearchService` with per-store synonym expansion and stop-word removal, product observer FTS sync, search query logging, search API rate limiting, `AnalyticsService`, analytics event deduplication, daily analytics aggregation, scheduled `AggregateAnalytics`, analytics API rate limiting, `WebhookService`, timestamped HMAC signing/verification, database-queued `DeliverWebhook`, delivery logging/retry backoff metadata, webhook circuit breaker pausing, domain-event webhook dispatch listener, developer token generation backed by `oauth_tokens`, `CartService`, `DiscountService` including one-per-customer code discount redemption checks and automatic/code order-line attribution, `ShippingCalculator`, `TaxCalculator`, `PricingEngine`, checkout transitions through completion, checkout expiration, abandoned cart cleanup, session cart lookup, cart discount handoff to checkout, customer login cart merge, product add-to-cart mutations, `PaymentService`, mock PSP, idempotent order creation, checkout UI/API order submission, session/customer/token-gated order confirmation and lookup, customer order-history scoping, admin dashboard order aggregates, admin analytics reporting, admin customer address management, admin discount rule management, admin content/theme/navigation/settings/search/apps/developers orchestration, admin order UI/API action orchestration, `RefundService`, `FulfillmentService`, bank-transfer payment confirmation, and unpaid bank-transfer cancellation are implemented. |
@@ -318,6 +318,14 @@ ## Verification Evidence
 - 2026-05-04: `php artisan test --compact` passed after the nested navigation changes: 204 tests, 1091 assertions.
 - 2026-05-04: `mcp__laravel_boost__.get_absolute_url(path: "/admin/navigation")` resolved `http://shop.test/admin/navigation` before browser verification.
 - 2026-05-04: Playwright MCP verified `http://shop.test/admin/navigation` renders nested menu items and the parent selector, and `http://shop.test/` renders the `Shop` dropdown with collection child links; current Playwright console checks reported no warnings/errors. Boost `browser_logs` only returned older May 3 account-login warnings from a previously fixed issue.
+- 2026-05-04: `mcp__laravel_boost__.search_docs` consulted Laravel 12 API resource, route middleware group, bearer token, form request validation, and JSON HTTP testing docs plus Pest 4 docs before the admin catalog API changes.
+- 2026-05-04: `php artisan make:controller Api/Admin/V1/ProductController --no-interaction`, `php artisan make:controller Api/Admin/V1/CustomerController --no-interaction`, `php artisan make:resource Admin/V1/ProductResource --no-interaction`, `php artisan make:resource Admin/V1/CustomerResource --no-interaction`, `php artisan make:controller Api/OAuthController --no-interaction`, `php artisan make:controller Api/Apps/V1/DeferredEndpointController --no-interaction`, and `php artisan make:test Api/AdminCatalogApiTest --pest --no-interaction` created the admin catalog API controllers/resources, deferred app/OAuth controllers, and tests.
+- 2026-05-04: `vendor/bin/pint --dirty --format agent` passed after the admin catalog API changes.
+- 2026-05-04: `php artisan route:list --path=api/admin/v1/stores --except-vendor` confirmed 8 admin API routes: product/customer/order list/detail plus order fulfillment/refund mutations.
+- 2026-05-04: `php artisan route:list --path=oauth --except-vendor` confirmed `/oauth/authorize` and `/oauth/token`; `php artisan route:list --path=api/apps/v1 --except-vendor` confirmed 3 deferred app API routes.
+- 2026-05-04: `php artisan test --compact tests/Feature/Api/AdminCatalogApiTest.php` passed after the admin catalog API changes: 5 tests, 34 assertions.
+- 2026-05-04: `php artisan test --compact tests/Feature/Api` passed after the admin catalog API changes: 25 tests, 194 assertions.
+- 2026-05-04: `php artisan test --compact` passed after the admin catalog API changes: 209 tests, 1125 assertions.

 ## Decisions

@@ -351,7 +359,7 @@ ## Decisions
 - Admin order Livewire components persist locked `storeId`/`orderId` state and reload orders without global scopes but with explicit store constraints before each service-backed action.
 - Admin order actions deliberately delegate to `OrderService`, `RefundService`, and `FulfillmentService` so UI validation cannot bypass payment, refund, fulfillment, or inventory guards.
 - Storefront order lookup uses a deterministic HMAC access token derived from store/order identity so confirmation links can be token-gated without adding a new runtime column.
-- Admin order API routes currently use the existing session `auth` middleware and store-user membership checks because Sanctum is not installed yet.
+- Admin REST API routes use the `admin.api` middleware with session-authenticated store users or hashed `oauth_tokens` bearer tokens; the current implemented surfaces are read-only products/customers/orders plus order fulfillment/refund mutations.
 - Admin dashboard sales and top-product metrics are derived from paid or partially refunded orders; the dedicated analytics page now reads analytics event and daily aggregate tables.
 - Customer addresses are scoped through the locked customer id because `customer_addresses` intentionally belongs to customers and has no direct `store_id` column.
 - Admin/store Livewire route middleware is registered with Livewire persistent middleware so component POST requests keep `current_store` binding and store-role checks after the initial full-page route load.
@@ -368,7 +376,7 @@ ## Decisions
 - Analytics ingestion stores raw events with client-event deduplication and keeps aggregation separate in `analytics_daily`; admin analytics reads pre-aggregated daily metrics for KPIs and raw events for referrers.
 - Seeded analytics metrics are deterministic demo data and are not tied to seeded orders because order/payment seed data remains intentionally absent.
 - The admin analytics CSV export is generated as a data URL for the self-contained local app rather than creating persistent export files/jobs.
-- OAuth/Passport app authorization remains deferred per the roadmap; the developer token UI uses the existing app installation and `oauth_tokens` schema as a self-contained local token store, but it is not Sanctum/Passport authentication yet.
+- OAuth/Passport app authorization remains deferred per the roadmap; `/oauth/*` and `/api/apps/v1/*` now return explicit `501 Not Implemented` responses, while the developer token UI uses the existing app installation and `oauth_tokens` schema as a self-contained local token store.
 - Admin order API routes use the `admin.api` middleware, which accepts either the existing session-authenticated admin user or a hashed `oauth_tokens` bearer token scoped to the route store; token requests require `read-orders` for GET routes and `write-orders` for refund/fulfillment mutations and update `last_used_at` on successful use.
 - Outbound webhook delivery jobs are forced onto the database queue and `webhooks` queue name so domain events enqueue delivery work instead of making external HTTP requests inline when the default queue connection is `sync`.
 - Webhook signatures follow the security spec message shape of `{timestamp}.{json_body}` with HMAC-SHA256, while the delivery payload is wrapped with `id`, `api_version`, `event_type`, `store_id`, `occurred_at`, and `data`.
@@ -382,10 +390,10 @@ ## Open Issues

 - Customer password reset is implemented under `/account/forgot-password` and `/account/reset-password/{token}`; the exact spec root paths remain occupied by the existing Fortify starter/admin reset routes.
 - `php artisan route:list --except-vendor` hides Livewire full-page routes because their controller is vendor-provided; path-filtered route-list commands are used as evidence for those routes.
-- OAuth/Passport app API endpoints and broader admin REST endpoints outside order management are still missing; outbound webhook subscription management is implemented through the admin UI only, as specified for the initial implementation.
+- Non-order admin REST write endpoints and the larger admin REST surfaces for collections, discounts, settings, themes, content, search, analytics, and exports are still missing; outbound webhook subscription management is implemented through the admin UI only, as specified for the initial implementation.
 - Automated browser tests from Spec 08 are still missing; current browser coverage is manual Playwright MCP smoke verification.
 - SQLite enum/check constraints from the schema spec are not yet explicitly enforced as database `CHECK` constraints; enum validation is currently enforced through casts/services/model invariants.

 ## Completion Summary

-Not complete. Phase 1 foundation, Phase 2 catalog data/UI surfaces, product media processing/admin upload controls, Phase 3 storefront theme/content/navigation data, the Phase 4 cart/checkout/pricing backend foundation, cart/checkout storefront UI through order completion, cart-page estimates, cart/checkout REST APIs, Phase 5 order/payment backend foundation, Phase 5 refund/fulfillment services, customer order views, admin dashboard/order/customer/discount/content/settings/theme-file/navigation/search/analytics/apps/developers management, storefront search and analytics APIs, order API surfaces, and outbound webhook delivery foundations are implemented, with known auth/token route compatibility, OAuth/app API, browser-test, and database CHECK constraint gaps tracked above.
+Not complete. Phase 1 foundation, Phase 2 catalog data/UI surfaces, product media processing/admin upload controls, Phase 3 storefront theme/content/navigation data, the Phase 4 cart/checkout/pricing backend foundation, cart/checkout storefront UI through order completion, cart-page estimates, cart/checkout REST APIs, Phase 5 order/payment backend foundation, Phase 5 refund/fulfillment services, customer order views, admin dashboard/order/customer/discount/content/settings/theme-file/navigation/search/analytics/apps/developers management, storefront search and analytics APIs, product/customer/order API surfaces, deferred OAuth/app route stubs, and outbound webhook delivery foundations are implemented, with known auth/token route compatibility, broader admin REST, browser-test, and database CHECK constraint gaps tracked above.


command: find app/Http/Controllers/Api app/Http/Resources/Admin/V1 tests/Feature/Api -path '*AdminCatalogApiTest.php' -o -path '*ProductController.php' -o -path '*CustomerController.php' -o -path '*OAuthController.php' -o -path '*DeferredEndpointController.php' -o -path '*ProductResource.php' -o -path '*CustomerResource.php' | sort

type: search

query: *AdminCatalogApiTest.php

path: Api


output:
Chunk ID: c36c87
Wall time: 0.0000 seconds
Process exited with code 0
Original token count: 91
Output:
app/Http/Controllers/Api/Admin/V1/CustomerController.php
app/Http/Controllers/Api/Admin/V1/ProductController.php
app/Http/Controllers/Api/Apps/V1/DeferredEndpointController.php
app/Http/Controllers/Api/OAuthController.php
app/Http/Resources/Admin/V1/CustomerResource.php
app/Http/Resources/Admin/V1/ProductResource.php
tests/Feature/Api/AdminCatalogApiTest.php


cmd: git add app/Http/Controllers/Api/Admin/V1/CustomerController.php app/Http/Controllers/Api/Admin/V1/ProductController.php app/Http/Controllers/Api/Apps/V1/DeferredEndpointController.php app/Http/Controllers/Api/OAuthController.php app/Http/Resources/Admin/V1/CustomerResource.php app/Http/Resources/Admin/V1/ProductResource.php routes/api.php routes/web.php specs/progress.md tests/Feature/Api/AdminCatalogApiTest.php && git diff --cached --stat


command: git add app/Http/Controllers/Api/Admin/V1/CustomerController.php app/Http/Controllers/Api/Admin/V1/ProductController.php app/Http/Controllers/Api/Apps/V1/DeferredEndpointController.php app/Http/Controllers/Api/OAuthController.php app/Http/Resources/Admin/V1/CustomerResource.php app/Http/Resources/Admin/V1/ProductResource.php routes/api.php routes/web.php specs/progress.md tests/Feature/Api/AdminCatalogApiTest.php && git diff --cached --stat

aggregated_output:
 .../Api/Admin/V1/CustomerController.php            |  77 +++++++++
 .../Controllers/Api/Admin/V1/ProductController.php |  98 +++++++++++
 .../Api/Apps/V1/DeferredEndpointController.php     |  16 ++
 app/Http/Controllers/Api/OAuthController.php       |  26 +++
 app/Http/Resources/Admin/V1/CustomerResource.php   |  45 +++++
 app/Http/Resources/Admin/V1/ProductResource.php    | 105 ++++++++++++
 routes/api.php                                     |  22 +++
 routes/web.php                                     |   8 +
 specs/progress.md                                  |  18 +-
 tests/Feature/Api/AdminCatalogApiTest.php          | 182 +++++++++++++++++++++
 10 files changed, 592 insertions(+), 5 deletions(-)


output:
Chunk ID: 368406
Wall time: 0.0000 seconds
Process exited with code 0
Original token count: 178
Output:
 .../Api/Admin/V1/CustomerController.php            |  77 +++++++++
 .../Controllers/Api/Admin/V1/ProductController.php |  98 +++++++++++
 .../Api/Apps/V1/DeferredEndpointController.php     |  16 ++
 app/Http/Controllers/Api/OAuthController.php       |  26 +++
 app/Http/Resources/Admin/V1/CustomerResource.php   |  45 +++++
 app/Http/Resources/Admin/V1/ProductResource.php    | 105 ++++++++++++
 routes/api.php                                     |  22 +++
 routes/web.php                                     |   8 +
 specs/progress.md                                  |  18 +-
 tests/Feature/Api/AdminCatalogApiTest.php          | 182 +++++++++++++++++++++
 10 files changed, 592 insertions(+), 5 deletions(-)


aggregated_output:
A  app/Http/Controllers/Api/Admin/V1/CustomerController.php
A  app/Http/Controllers/Api/Admin/V1/ProductController.php
A  app/Http/Controllers/Api/Apps/V1/DeferredEndpointController.php
A  app/Http/Controllers/Api/OAuthController.php
A  app/Http/Resources/Admin/V1/CustomerResource.php
A  app/Http/Resources/Admin/V1/ProductResource.php
M  routes/api.php
M  routes/web.php
M  specs/progress.md
A  tests/Feature/Api/AdminCatalogApiTest.php