diff --git a/app/Http/Controllers/Api/StoreActivationController.php b/app/Http/Controllers/Api/StoreActivationController.php index ade6663..8b9115c 100644 --- a/app/Http/Controllers/Api/StoreActivationController.php +++ b/app/Http/Controllers/Api/StoreActivationController.php @@ -3,6 +3,7 @@ namespace App\Http\Controllers\Api; use App\Http\Controllers\Controller; +use App\Jobs\StoreBootstrapSync; use App\Models\WooStore; use App\Services\Woo\InstallTokenService; use Illuminate\Http\JsonResponse; @@ -37,6 +38,8 @@ class StoreActivationController extends Controller $pluginToken = $this->tokens->issuePluginToken($store); $store = $store->fresh(); + StoreBootstrapSync::dispatch($store->id)->afterResponse(); + return response()->json([ 'store_id' => $store->public_id, 'webhook_url' => $store->webhookUrl(), diff --git a/app/Http/Controllers/Woo/OrderController.php b/app/Http/Controllers/Woo/OrderController.php index 36e8be6..53ee8c8 100644 --- a/app/Http/Controllers/Woo/OrderController.php +++ b/app/Http/Controllers/Woo/OrderController.php @@ -6,6 +6,7 @@ use App\Http\Controllers\Controller; use App\Models\WooOrder; use App\Models\WooStore; use App\Services\Woo\FulfillmentSyncService; +use App\Services\Woo\OrderSyncService; use Illuminate\Http\RedirectResponse; use Illuminate\Http\Request; use Illuminate\Validation\Rule; @@ -13,7 +14,10 @@ use Illuminate\View\View; class OrderController extends Controller { - public function __construct(private FulfillmentSyncService $sync) {} + public function __construct( + private FulfillmentSyncService $sync, + private OrderSyncService $orderSync, + ) {} public function index(Request $request): View { @@ -58,4 +62,25 @@ class OrderController extends Controller return back()->with('success', 'Fulfillment status updated.'); } + + public function sync(Request $request): RedirectResponse + { + $validated = $request->validate([ + 'store' => ['required', 'integer'], + ]); + + $store = WooStore::query() + ->where('user_id', $request->user()->id) + ->whereKey((int) $validated['store']) + ->where('status', WooStore::STATUS_ACTIVE) + ->firstOrFail(); + + $count = $this->orderSync->syncOrders($store); + + return back()->with('success', sprintf( + 'Synced %d orders from %s.', + $count, + $store->site_name ?? $store->site_url, + )); + } } diff --git a/app/Jobs/StoreBootstrapSync.php b/app/Jobs/StoreBootstrapSync.php new file mode 100644 index 0000000..63aa09f --- /dev/null +++ b/app/Jobs/StoreBootstrapSync.php @@ -0,0 +1,34 @@ +whereKey($this->storeId) + ->where('status', WooStore::STATUS_ACTIVE) + ->first(); + + if (! $store) { + return; + } + + $counts = $sync->syncAll($store); + + Log::info('Woo store bootstrap sync completed', [ + 'store_id' => $store->id, + 'counts' => $counts, + ]); + } +} diff --git a/app/Services/Woo/OrderSyncService.php b/app/Services/Woo/OrderSyncService.php new file mode 100644 index 0000000..aa6db3b --- /dev/null +++ b/app/Services/Woo/OrderSyncService.php @@ -0,0 +1,42 @@ +client->get($store, 'orders', ['per_page' => 100]); + if (! is_array($response)) { + return 0; + } + + $items = $response['data'] ?? $response; + if (! is_array($items)) { + return 0; + } + + $count = 0; + foreach ($items as $item) { + if (! is_array($item)) { + continue; + } + + try { + $this->orders->ingest($store, $item); + $count++; + } catch (\Throwable) { + continue; + } + } + + return $count; + } +} diff --git a/app/Services/Woo/StoreSyncService.php b/app/Services/Woo/StoreSyncService.php new file mode 100644 index 0000000..c3953bf --- /dev/null +++ b/app/Services/Woo/StoreSyncService.php @@ -0,0 +1,46 @@ + 0, + 'products' => 0, + 'orders' => 0, + ]; + + try { + $catalogCounts = $this->catalog->syncAll($store); + $counts['categories'] = $catalogCounts['categories']; + $counts['products'] = $catalogCounts['products']; + } catch (\Throwable $e) { + Log::warning('Woo catalog sync failed', [ + 'store_id' => $store->id, + 'message' => $e->getMessage(), + ]); + } + + try { + $counts['orders'] = $this->orders->syncOrders($store); + } catch (\Throwable $e) { + Log::warning('Woo order sync failed', [ + 'store_id' => $store->id, + 'message' => $e->getMessage(), + ]); + } + + return $counts; + } +} diff --git a/bootstrap/app.php b/bootstrap/app.php index 2e2ca96..18611f1 100644 --- a/bootstrap/app.php +++ b/bootstrap/app.php @@ -13,6 +13,10 @@ return Application::configure(basePath: dirname(__DIR__)) health: '/up', ) ->withMiddleware(function (Middleware $middleware): void { + $middleware->validateCsrfTokens(except: [ + 'webhooks/woocommerce/*', + ]); + $middleware->redirectGuestsTo(fn (Request $request) => route('sso.connect', [ 'redirect' => $request->fullUrl(), ])); diff --git a/resources/views/woo/orders/index.blade.php b/resources/views/woo/orders/index.blade.php index 42b7411..521a979 100644 --- a/resources/views/woo/orders/index.blade.php +++ b/resources/views/woo/orders/index.blade.php @@ -1,9 +1,18 @@ Orders
-
-

Orders

-

WooCommerce orders synced for fulfillment.

+
+
+

Orders

+

WooCommerce orders synced for fulfillment.

+
+ @if($stores->isNotEmpty()) +
+ @csrf + + +
+ @endif
@@ -23,7 +32,7 @@
@if($orders->isEmpty()) -

No orders yet. Connect a store and place a test order in WooCommerce.

+

No orders yet. Use Sync from store to import existing orders, or wait for new WooCommerce order events.

@else
diff --git a/routes/web.php b/routes/web.php index 60963c8..f3fb8da 100644 --- a/routes/web.php +++ b/routes/web.php @@ -41,6 +41,7 @@ Route::middleware(['auth', 'platform.session'])->group(function () { Route::get('/dashboard', [DashboardController::class, 'index'])->name('woo.dashboard'); Route::get('/orders', [OrderController::class, 'index'])->name('woo.orders.index'); + Route::post('/orders/sync', [OrderController::class, 'sync'])->name('woo.orders.sync'); Route::patch('/orders/{order}/status', [OrderController::class, 'updateStatus'])->name('woo.orders.update-status'); Route::get('/products', [ProductController::class, 'index'])->name('woo.products.index'); Route::get('/products/create', [ProductController::class, 'create'])->name('woo.products.create'); diff --git a/tests/Feature/WooWebTest.php b/tests/Feature/WooWebTest.php index 39441fa..053aa9a 100644 --- a/tests/Feature/WooWebTest.php +++ b/tests/Feature/WooWebTest.php @@ -230,6 +230,59 @@ class WooWebTest extends TestCase ->assertSee('Manage connected stores'); } + public function test_store_activation_dispatches_bootstrap_sync(): void + { + \Illuminate\Support\Facades\Bus::fake(); + + $user = User::factory()->create(); + $store = WooStore::create([ + 'user_id' => $user->id, + 'site_url' => 'https://shop.example.com', + 'status' => WooStore::STATUS_PENDING, + ]); + + $token = app(InstallTokenService::class)->issue($store)['token']; + + $this->postJson('/api/v1/stores/activate', [ + 'install_token' => $token, + 'site_url' => 'https://shop.example.com', + ])->assertOk(); + + \Illuminate\Support\Facades\Bus::assertDispatched(\App\Jobs\StoreBootstrapSync::class, fn ($job) => $job->storeId === $store->id); + } + + public function test_webhook_accepts_post_without_csrf_token(): void + { + $user = User::factory()->create(); + $store = WooStore::create([ + 'user_id' => $user->id, + 'site_url' => 'https://shop.example.com', + 'status' => WooStore::STATUS_ACTIVE, + 'connected_at' => now(), + ]); + + $payload = ['id' => 99, 'number' => '1099', 'status' => 'processing', 'currency' => 'GHS', 'total' => '10.00', 'billing' => ['email' => 'a@b.com'], 'line_items' => [['name' => 'Item', 'quantity' => 1, 'total' => '10.00']], 'needs_payment' => false]; + $body = json_encode($payload, JSON_THROW_ON_ERROR); + $signature = base64_encode(hash_hmac('sha256', $body, (string) $store->webhook_secret, true)); + + $this->withMiddleware(\Illuminate\Foundation\Http\Middleware\ValidateCsrfToken::class) + ->call( + 'POST', + route('woo.webhooks.woocommerce', $store->public_id), + [], + [], + [], + [ + 'CONTENT_TYPE' => 'application/json', + 'HTTP_X_WC_WEBHOOK_SIGNATURE' => $signature, + 'HTTP_X_WC_WEBHOOK_TOPIC' => 'order.updated', + ], + $body, + )->assertOk(); + + $this->assertDatabaseHas('woo_orders', ['external_order_id' => 99]); + } + public function test_merchant_can_view_products_page(): void { $user = User::factory()->create();