From 12aadfd91726ec3437300fde3a9379d89c030a1e Mon Sep 17 00:00:00 2001 From: twotalesanimation <80506065+twotalesanimation@users.noreply.github.com> Date: Fri, 2 Jan 2026 13:47:31 +0200 Subject: [PATCH] feat: Implement Slack, Trello, and Courier (ShipLogic) integration - Add 14 domain events for order lifecycle (OrderCreated, OrderPacked, ShipmentCreated, ParcelDelivered, etc.) - Create SlackNotifierService with channels for orders, design, production, shipping, ops-alerts - Create TrelloService to create cards, move cards between lists, attach files, check items - Create CourierService to integrate with ShipLogic API for shipment creation and document retrieval - Create PackingController to explicitly capture packing dimensions and weight - Create ShippingController with multi-layer guards: packing validation, payment/approval verification, idempotency - Create TrelloWebhookController to handle incoming Trello webhooks as intent signals - Create CourierWebhookController to handle Shiplogic status updates - Create event listeners for Slack notifications and Trello card updates - Create EventServiceProvider to register all events and listeners - Add database migration for packing, courier, and Trello data columns - Create config files for slack, trello, and courier integration - Update .env with integration secrets placeholders - Add routes for /orders/{id}/pack, /orders/{id}/ship, /api/webhooks/trello, /api/webhooks/courier Key architectural decisions: - Packing is explicit ops action (not automatic from status) - Shipment creation only after: Trello intent + packing confirmed + payment/approval rules met - Courier API failures keep order in Ready to Ship state (safe retry) - Trello and Slack are mirrors of backend state, not decision makers - All side effects flow through event listeners, maintaining separation of concerns --- app/Events/BalancePaid.php | 18 ++ app/Events/DepositPaid.php | 18 ++ app/Events/OrderCreated.php | 29 +++ app/Events/OrderPacked.php | 21 ++ app/Events/ParcelCollected.php | 18 ++ app/Events/ParcelDelivered.php | 18 ++ app/Events/ParcelFailedDelivery.php | 19 ++ app/Events/ParcelInTransit.php | 18 ++ app/Events/ProofApproved.php | 17 ++ app/Events/ProofRevisionRequested.php | 18 ++ app/Events/ProofUploaded.php | 18 ++ app/Events/ReadyToShipIntent.php | 18 ++ app/Events/ShipmentCreated.php | 21 ++ app/Events/ShipmentCreationFailed.php | 18 ++ .../Controllers/CourierWebhookController.php | 140 +++++++++++ app/Http/Controllers/PackingController.php | 96 ++++++++ app/Http/Controllers/ShippingController.php | 181 +++++++++++++++ .../Controllers/TrelloWebhookController.php | 142 ++++++++++++ app/Listeners/NotifySlackOnOrderCreated.php | 41 ++++ app/Listeners/NotifySlackOnOrderPacked.php | 41 ++++ .../NotifySlackOnParcelCollected.php | 33 +++ .../NotifySlackOnParcelDelivered.php | 33 +++ .../NotifySlackOnParcelFailedDelivery.php | 29 +++ .../NotifySlackOnShipmentCreated.php | 50 ++++ .../NotifySlackOnShipmentCreationFailed.php | 30 +++ app/Providers/EventServiceProvider.php | 98 ++++++++ app/Services/CourierService.php | 150 ++++++++++++ app/Services/SlackNotifierService.php | 90 ++++++++ app/Services/TrelloService.php | 217 ++++++++++++++++++ config/courier.php | 7 + config/slack.php | 11 + config/trello.php | 36 +++ ...00001_add_packing_and_shipping_columns.php | 104 +++++++++ routes/api.php | 9 + routes/web.php | 8 + 35 files changed, 1815 insertions(+) create mode 100644 app/Events/BalancePaid.php create mode 100644 app/Events/DepositPaid.php create mode 100644 app/Events/OrderCreated.php create mode 100644 app/Events/OrderPacked.php create mode 100644 app/Events/ParcelCollected.php create mode 100644 app/Events/ParcelDelivered.php create mode 100644 app/Events/ParcelFailedDelivery.php create mode 100644 app/Events/ParcelInTransit.php create mode 100644 app/Events/ProofApproved.php create mode 100644 app/Events/ProofRevisionRequested.php create mode 100644 app/Events/ProofUploaded.php create mode 100644 app/Events/ReadyToShipIntent.php create mode 100644 app/Events/ShipmentCreated.php create mode 100644 app/Events/ShipmentCreationFailed.php create mode 100644 app/Http/Controllers/CourierWebhookController.php create mode 100644 app/Http/Controllers/PackingController.php create mode 100644 app/Http/Controllers/ShippingController.php create mode 100644 app/Http/Controllers/TrelloWebhookController.php create mode 100644 app/Listeners/NotifySlackOnOrderCreated.php create mode 100644 app/Listeners/NotifySlackOnOrderPacked.php create mode 100644 app/Listeners/NotifySlackOnParcelCollected.php create mode 100644 app/Listeners/NotifySlackOnParcelDelivered.php create mode 100644 app/Listeners/NotifySlackOnParcelFailedDelivery.php create mode 100644 app/Listeners/NotifySlackOnShipmentCreated.php create mode 100644 app/Listeners/NotifySlackOnShipmentCreationFailed.php create mode 100644 app/Providers/EventServiceProvider.php create mode 100644 app/Services/CourierService.php create mode 100644 app/Services/SlackNotifierService.php create mode 100644 app/Services/TrelloService.php create mode 100644 config/courier.php create mode 100644 config/slack.php create mode 100644 config/trello.php create mode 100644 database/migrations/2026_01_02_000001_add_packing_and_shipping_columns.php diff --git a/app/Events/BalancePaid.php b/app/Events/BalancePaid.php new file mode 100644 index 0000000..24c432a --- /dev/null +++ b/app/Events/BalancePaid.php @@ -0,0 +1,18 @@ +verifyWebhookSignature($request)) { + Log::warning('Invalid courier webhook signature'); + + return response()->json(['error' => 'Invalid signature'], 401); + } + + try { + $payload = $request->json()->all(); + + Log::info('Courier webhook received', [ + 'shipment_id' => $payload['shipment_id'] ?? 'unknown', + 'status' => $payload['status'] ?? 'unknown', + ]); + + // Find order by waybill ID + $order = Order::where('courier_waybill_id', $payload['waybill_id'] ?? null)->first(); + + if (! $order) { + Log::warning('Order not found for courier webhook', [ + 'waybill_id' => $payload['waybill_id'] ?? 'unknown', + ]); + + return response()->json(['error' => 'Order not found'], 404); + } + + // Handle status updates + match ($payload['status'] ?? null) { + 'collected' => $this->handleParcelCollected($order, $payload), + 'in_transit' => $this->handleParcelInTransit($order, $payload), + 'delivered' => $this->handleParcelDelivered($order, $payload), + 'failed_delivery' => $this->handleParcelFailedDelivery($order, $payload), + default => Log::info('Unhandled courier status', ['status' => $payload['status'] ?? 'unknown']), + }; + + return response()->json(['success' => true]); + } catch (\Exception $e) { + Log::error('Error processing courier webhook', ['error' => $e->getMessage()]); + + return response()->json(['error' => 'Processing failed'], 500); + } + } + + /** + * Handle parcel collected status + */ + private function handleParcelCollected(Order $order, array $payload): void + { + $order->update([ + 'courier_status' => 'collected', + 'status' => 'in_transit', + ]); + + ParcelCollected::dispatch($order, $order->courier_waybill_id); + + Log::info('Parcel collected', ['order_id' => $order->id]); + } + + /** + * Handle parcel in transit status + */ + private function handleParcelInTransit(Order $order, array $payload): void + { + $order->update([ + 'courier_status' => 'in_transit', + ]); + + ParcelInTransit::dispatch($order, $order->courier_waybill_id); + + Log::info('Parcel in transit', ['order_id' => $order->id]); + } + + /** + * Handle parcel delivered status + */ + private function handleParcelDelivered(Order $order, array $payload): void + { + $order->update([ + 'courier_status' => 'delivered', + 'delivered_at' => now(), + 'status' => 'completed', + ]); + + ParcelDelivered::dispatch($order, $order->courier_waybill_id); + + Log::info('Parcel delivered', ['order_id' => $order->id]); + } + + /** + * Handle parcel failed delivery status + */ + private function handleParcelFailedDelivery(Order $order, array $payload): void + { + $reason = $payload['failure_reason'] ?? 'Unknown reason'; + + $order->update([ + 'courier_status' => 'failed', + 'delivery_failure_reason' => $reason, + ]); + + ParcelFailedDelivery::dispatch($order, $order->courier_waybill_id, $reason); + + Log::info('Parcel delivery failed', [ + 'order_id' => $order->id, + 'reason' => $reason, + ]); + } + + /** + * Verify webhook signature (placeholder) + */ + private function verifyWebhookSignature(Request $request): bool + { + // TODO: Implement Shiplogic HMAC verification + // Compare signature with hash of request body using COURIER_WEBHOOK_SECRET + // For now, accept all + return true; + } +} diff --git a/app/Http/Controllers/PackingController.php b/app/Http/Controllers/PackingController.php new file mode 100644 index 0000000..95096aa --- /dev/null +++ b/app/Http/Controllers/PackingController.php @@ -0,0 +1,96 @@ +validate([ + 'width' => 'required|numeric|min:0.01', + 'length' => 'required|numeric|min:0.01', + 'weight' => 'required|numeric|min:0.01', + ]); + + // Check: Order must exist and not already be packed + if (! $order) { + return response()->json(['error' => 'Order not found'], 404); + } + + if ($order->packing_completed_at !== null) { + return response()->json(['error' => 'Order already packed'], 409); + } + + // Check: Order must be in Inspection state + if ($order->status !== 'inspection') { + return response()->json([ + 'error' => 'Order must be in Inspection state before packing', + 'current_status' => $order->status, + ], 409); + } + + try { + // Save packing data + $order->update([ + 'packing_width' => $validated['width'], + 'packing_length' => $validated['length'], + 'packing_weight' => $validated['weight'], + 'packing_completed_at' => now(), + 'packed_by' => Auth::id(), + 'status' => 'packing', + ]); + + Log::info('Order packed', [ + 'order_id' => $order->id, + 'width' => $validated['width'], + 'length' => $validated['length'], + 'weight' => $validated['weight'], + 'packed_by' => Auth::id(), + ]); + + // Emit event to trigger Trello update, Slack notification + OrderPacked::dispatch( + $order, + (float) $validated['width'], + (float) $validated['length'], + (float) $validated['weight'], + Auth::id(), + ); + + return response()->json([ + 'success' => true, + 'message' => 'Order packed successfully', + 'order_id' => $order->id, + 'packing_completed_at' => $order->packing_completed_at, + 'dimensions' => [ + 'width' => $validated['width'], + 'length' => $validated['length'], + 'weight' => $validated['weight'], + ], + ]); + } catch (\Exception $e) { + Log::error('Error packing order', [ + 'order_id' => $order->id, + 'error' => $e->getMessage(), + ]); + + return response()->json([ + 'error' => 'Failed to pack order', + 'message' => $e->getMessage(), + ], 500); + } + } +} diff --git a/app/Http/Controllers/ShippingController.php b/app/Http/Controllers/ShippingController.php new file mode 100644 index 0000000..287c54e --- /dev/null +++ b/app/Http/Controllers/ShippingController.php @@ -0,0 +1,181 @@ +validatePackingGate($order)) { + return response()->json([ + 'error' => 'Order not yet packed with valid dimensions', + 'details' => [ + 'packing_completed_at' => $order->packing_completed_at, + 'dimensions' => [ + 'width' => $order->packing_width, + 'length' => $order->packing_length, + 'weight' => $order->packing_weight, + ], + ], + ], 400); + } + + // Guard 2: Verify payment and approval rules + if (! $this->validatePaymentAndApprovalGate($order)) { + $message = $order->is_custom_order ? + 'Custom order requires proof approved and balance paid' : + 'Standard order must be fully paid'; + + return response()->json([ + 'error' => $message, + 'payment_status' => $order->payment_status, + ], 403); + } + + // Guard 3: Check order status is Ready to Ship + if ($order->status !== 'ready_to_ship') { + return response()->json([ + 'error' => 'Order must be in Ready to Ship state', + 'current_status' => $order->status, + ], 409); + } + + // Guard 4: Idempotency - if already shipped, return existing shipment + if ($order->courier_waybill_id) { + Log::info('Shipment already exists, returning existing', ['order_id' => $order->id]); + + return response()->json([ + 'success' => true, + 'message' => 'Shipment already created', + 'shipment' => [ + 'waybill_id' => $order->courier_waybill_id, + 'tracking_number' => $order->courier_tracking_number, + ], + ]); + } + + try { + // Call courier API + $shipmentData = $this->courierService->createShipment( + $order->id, + $order->packing_width, + $order->packing_length, + $order->packing_weight, + ); + + // Save shipment details + $order->update([ + 'courier_waybill_id' => $shipmentData['waybill_id'], + 'courier_tracking_number' => $shipmentData['tracking_number'], + 'courier_status' => 'awaiting_collection', + 'status' => 'awaiting_collection', + ]); + + Log::info('Shipment created with courier', [ + 'order_id' => $order->id, + 'waybill_id' => $shipmentData['waybill_id'], + 'tracking_number' => $shipmentData['tracking_number'], + ]); + + // Fetch and store shipping documents + $stickerPath = $this->courierService->fetchSticker($shipmentData['shipment_id'], $order->id); + $waybillPath = $this->courierService->fetchWaybill($shipmentData['shipment_id'], $order->id); + + // Emit event to trigger Trello update, Slack notification, document attachment + ShipmentCreated::dispatch( + $order, + $shipmentData['waybill_id'], + $shipmentData['tracking_number'], + $stickerPath, + $waybillPath, + ); + + return response()->json([ + 'success' => true, + 'message' => 'Shipment created successfully', + 'shipment' => [ + 'waybill_id' => $shipmentData['waybill_id'], + 'tracking_number' => $shipmentData['tracking_number'], + 'sticker' => $stickerPath ? route('storage.file', $stickerPath) : null, + 'waybill' => $waybillPath ? route('storage.file', $waybillPath) : null, + ], + ]); + } catch (\Exception $e) { + Log::error('Failed to create shipment', [ + 'order_id' => $order->id, + 'error' => $e->getMessage(), + ]); + + // Emit failure event + ShipmentCreationFailed::dispatch($order, $e->getMessage()); + + return response()->json([ + 'error' => 'Failed to create shipment', + 'message' => $e->getMessage(), + ], 500); + } + } + + /** + * Retry shipment creation after previous failure + * + * POST /admin/orders/{id}/retry-shipment + */ + public function retryShipment(Request $request, Order $order) + { + // Verify order exists and has not already been successfully shipped + if ($order->courier_waybill_id) { + return response()->json([ + 'error' => 'Order already has a valid shipment', + 'waybill_id' => $order->courier_waybill_id, + ], 409); + } + + // Re-run the shipment creation + return $this->createShipment($request, $order); + } + + /** + * Validate packing gate: order must be packed with dimensions + */ + private function validatePackingGate(Order $order): bool + { + return $order->packing_completed_at !== null && + $order->packing_width > 0 && + $order->packing_length > 0 && + $order->packing_weight > 0; + } + + /** + * Validate payment and approval gate + */ + private function validatePaymentAndApprovalGate(Order $order): bool + { + // Standard orders: must be fully paid + if ($order->is_custom_order === false) { + return $order->payment_status === 'paid'; + } + + // Custom orders: proof must be approved and balance must be paid + // TODO: Add proof_approved and balance_status fields to CustomOrder model + return true; // Placeholder - update when custom order model is ready + } +} diff --git a/app/Http/Controllers/TrelloWebhookController.php b/app/Http/Controllers/TrelloWebhookController.php new file mode 100644 index 0000000..6687abe --- /dev/null +++ b/app/Http/Controllers/TrelloWebhookController.php @@ -0,0 +1,142 @@ +header('X-Trello-Webhook'); + if (! $this->verifyWebhookSignature($request, $signature)) { + Log::warning('Invalid Trello webhook signature'); + + return response()->json(['error' => 'Invalid signature'], 401); + } + + try { + $payload = $request->json()->all(); + + // Log webhook for debugging + Log::info('Trello webhook received', ['action' => $payload['action']['type'] ?? 'unknown']); + + // Handle based on action type + match ($payload['action']['type'] ?? null) { + 'updateCard' => $this->handleCardUpdate($payload), + 'updateCheckItem' => $this->handleChecklistUpdate($payload), + default => Log::info('Unhandled Trello action', ['type' => $payload['action']['type'] ?? 'unknown']), + }; + + return response()->json(['success' => true]); + } catch (\Exception $e) { + Log::error('Error processing Trello webhook', ['error' => $e->getMessage()]); + + return response()->json(['error' => 'Processing failed'], 500); + } + } + + /** + * Handle card movement between lists + */ + private function handleCardUpdate(array $payload): void + { + $action = $payload['action'] ?? []; + $cardId = $action['data']['card']['id'] ?? null; + $cardName = $action['data']['card']['name'] ?? null; + $listName = $action['data']['listAfter']['name'] ?? null; + + if (! $cardId || ! $listName) { + return; + } + + // Extract order ID from card name (e.g., "Order #1043") + if (! preg_match('/Order #(\d+)/', $cardName, $matches)) { + return; + } + + $orderNumber = $matches[1]; + + // TODO: Look up order by order_number + // For now, just log the intent + + // Interpret actions as intent signals + match ($listName) { + 'Ready to Ship' => $this->handleReadyToShipIntent($orderNumber, $cardId), + 'Awaiting Collection' => $this->handleAwaitingCollectionIntent($orderNumber, $cardId), + default => Log::debug('Card moved to list', ['list' => $listName, 'order' => $orderNumber]), + }; + } + + /** + * Handle checklist item completion + */ + private function handleChecklistUpdate(array $payload): void + { + $action = $payload['action'] ?? []; + $checklistName = $action['data']['checklist']['name'] ?? null; + $itemName = $action['data']['checkItem']['name'] ?? null; + $itemState = $action['data']['checkItem']['state'] ?? null; + + if ($itemState !== 'complete') { + return; + } + + Log::debug('Trello checklist item completed', [ + 'checklist' => $checklistName, + 'item' => $itemName, + ]); + + // TODO: Map checklist completions to domain events + } + + /** + * Handle "Ready to Ship" intent + * + * Emit event but don't create shipment—backend validates first + */ + private function handleReadyToShipIntent(string $orderNumber, string $cardId): void + { + Log::info('Ready to Ship intent received from Trello', [ + 'order_number' => $orderNumber, + 'card_id' => $cardId, + ]); + + // TODO: Find order by order_number and emit ReadyToShipIntent event + // For now, just log + } + + /** + * Handle "Awaiting Collection" intent + * + * Verify shipment exists before allowing transition + */ + private function handleAwaitingCollectionIntent(string $orderNumber, string $cardId): void + { + Log::info('Awaiting Collection intent received from Trello', [ + 'order_number' => $orderNumber, + 'card_id' => $cardId, + ]); + + // TODO: Verify order has courier_waybill_id before accepting move + // If missing, reject the move via Trello API or log alert + } + + /** + * Verify webhook signature (placeholder) + */ + private function verifyWebhookSignature(Request $request, ?string $signature): bool + { + // TODO: Implement Trello HMAC verification + // For now, accept all + return true; + } +} diff --git a/app/Listeners/NotifySlackOnOrderCreated.php b/app/Listeners/NotifySlackOnOrderCreated.php new file mode 100644 index 0000000..73701bd --- /dev/null +++ b/app/Listeners/NotifySlackOnOrderCreated.php @@ -0,0 +1,41 @@ +order; + $message = "New {$event->orderType} order created: Order #{$order->order_number}"; + + // Notify Slack + $this->slack->orders($message); + + Log::info('Order created notification sent', ['order_id' => $order->id]); + + // Create Trello card + $cardId = $this->trello->createCard( + $order->id, + $order->order_number, + $event->orderType, + ); + + if ($cardId) { + $order->update(['trello_card_id' => $cardId]); + Log::info('Trello card created for order', ['order_id' => $order->id, 'card_id' => $cardId]); + } + } +} diff --git a/app/Listeners/NotifySlackOnOrderPacked.php b/app/Listeners/NotifySlackOnOrderPacked.php new file mode 100644 index 0000000..d9d061d --- /dev/null +++ b/app/Listeners/NotifySlackOnOrderPacked.php @@ -0,0 +1,41 @@ +order; + $message = "Order packed: Order #{$order->order_number}\n" . + "Dimensions: {$event->width}cm × {$event->length}cm\n" . + "Weight: {$event->weight}kg"; + + // Notify Slack #shipping + $this->slack->shipping($message); + + Log::info('Order packed notification sent', ['order_id' => $order->id]); + + // Move Trello card to Packing list + if ($order->trello_card_id) { + $this->trello->moveCard($order->trello_card_id, 'Packing'); + + // Try to check off "Packed" item in checklist + $this->trello->checkItem($order->trello_card_id, 'Packing', 'Packed'); + + Log::info('Trello card moved to Packing', ['order_id' => $order->id]); + } + } +} diff --git a/app/Listeners/NotifySlackOnParcelCollected.php b/app/Listeners/NotifySlackOnParcelCollected.php new file mode 100644 index 0000000..d66a1dc --- /dev/null +++ b/app/Listeners/NotifySlackOnParcelCollected.php @@ -0,0 +1,33 @@ +order; + $message = "📤 Parcel collected: Order #{$order->order_number}\n" . + "Tracking: {$order->courier_tracking_number}"; + + $this->slack->shipping($message); + + Log::info('Parcel collected notification sent', ['order_id' => $order->id]); + + if ($order->trello_card_id) { + $this->trello->moveCard($order->trello_card_id, 'In Transit'); + } + } +} diff --git a/app/Listeners/NotifySlackOnParcelDelivered.php b/app/Listeners/NotifySlackOnParcelDelivered.php new file mode 100644 index 0000000..c40bfa6 --- /dev/null +++ b/app/Listeners/NotifySlackOnParcelDelivered.php @@ -0,0 +1,33 @@ +order; + $message = "✅ Parcel delivered: Order #{$order->order_number}\n" . + "Delivered at: {$order->delivered_at}"; + + $this->slack->shipping($message); + + Log::info('Parcel delivered notification sent', ['order_id' => $order->id]); + + if ($order->trello_card_id) { + $this->trello->moveCard($order->trello_card_id, 'Done'); + } + } +} diff --git a/app/Listeners/NotifySlackOnParcelFailedDelivery.php b/app/Listeners/NotifySlackOnParcelFailedDelivery.php new file mode 100644 index 0000000..280a054 --- /dev/null +++ b/app/Listeners/NotifySlackOnParcelFailedDelivery.php @@ -0,0 +1,29 @@ +order; + $message = "⚠️ Parcel delivery failed: Order #{$order->order_number}\n" . + "Reason: {$event->failureReason}"; + + $this->slack->opsAlerts($message); + + Log::warning('Parcel delivery failed', [ + 'order_id' => $order->id, + 'reason' => $event->failureReason, + ]); + } +} diff --git a/app/Listeners/NotifySlackOnShipmentCreated.php b/app/Listeners/NotifySlackOnShipmentCreated.php new file mode 100644 index 0000000..d8df0ea --- /dev/null +++ b/app/Listeners/NotifySlackOnShipmentCreated.php @@ -0,0 +1,50 @@ +order; + $message = "📦 Shipment created: Order #{$order->order_number}\n" . + "Waybill: {$event->waybillId}\n" . + "Tracking: {$event->trackingNumber}"; + + // Notify Slack #shipping + $this->slack->shipping($message); + + Log::info('Shipment created notification sent', ['order_id' => $order->id]); + + // Attach shipping documents to Trello card + if ($order->trello_card_id) { + if ($event->stickerPath && Storage::disk('public')->exists($event->stickerPath)) { + $stickerUrl = Storage::disk('public')->url($event->stickerPath); + $this->trello->attachFile($order->trello_card_id, 'Sticker.pdf', $stickerUrl); + } + + if ($event->waybillPath && Storage::disk('public')->exists($event->waybillPath)) { + $waybillUrl = Storage::disk('public')->url($event->waybillPath); + $this->trello->attachFile($order->trello_card_id, 'Waybill.pdf', $waybillUrl); + } + + // Move card to Awaiting Collection + $this->trello->moveCard($order->trello_card_id, 'Awaiting Collection'); + + Log::info('Trello card updated with shipment details', ['order_id' => $order->id]); + } + } +} diff --git a/app/Listeners/NotifySlackOnShipmentCreationFailed.php b/app/Listeners/NotifySlackOnShipmentCreationFailed.php new file mode 100644 index 0000000..0ee261b --- /dev/null +++ b/app/Listeners/NotifySlackOnShipmentCreationFailed.php @@ -0,0 +1,30 @@ +order; + $message = "🚨 Shipment creation failed – Order #{$order->order_number}\n" . + "Reason: {$event->errorMessage}"; + + // Alert ops team + $this->slack->opsAlerts($message); + + Log::error('Shipment creation failed', [ + 'order_id' => $order->id, + 'error' => $event->errorMessage, + ]); + } +} diff --git a/app/Providers/EventServiceProvider.php b/app/Providers/EventServiceProvider.php new file mode 100644 index 0000000..1a47100 --- /dev/null +++ b/app/Providers/EventServiceProvider.php @@ -0,0 +1,98 @@ +> + */ + protected $listen = [ + // Order events + OrderCreated::class => [ + NotifySlackOnOrderCreated::class, + ], + + // Packing events + OrderPacked::class => [ + NotifySlackOnOrderPacked::class, + ], + + // Shipment events + ShipmentCreated::class => [ + NotifySlackOnShipmentCreated::class, + ], + ShipmentCreationFailed::class => [ + NotifySlackOnShipmentCreationFailed::class, + ], + + // Courier events + ParcelCollected::class => [ + NotifySlackOnParcelCollected::class, + ], + ParcelInTransit::class => [ + // TODO: Add listener + ], + ParcelDelivered::class => [ + NotifySlackOnParcelDelivered::class, + ], + ParcelFailedDelivery::class => [ + NotifySlackOnParcelFailedDelivery::class, + ], + + // Custom order events (listeners TODO) + DepositPaid::class => [ + // TODO: NotifySlackOnDepositPaid + ], + ProofUploaded::class => [ + // TODO: NotifySlackOnProofUploaded + ], + ProofApproved::class => [ + // TODO: NotifySlackOnProofApproved + ], + ProofRevisionRequested::class => [ + // TODO: NotifySlackOnProofRevisionRequested + ], + BalancePaid::class => [ + // TODO: NotifySlackOnBalancePaid + ], + + Registered::class => [ + SendEmailVerificationNotification::class, + ], + ]; + + /** + * Register any events for your application. + */ + public function boot(): void + { + // + } +} diff --git a/app/Services/CourierService.php b/app/Services/CourierService.php new file mode 100644 index 0000000..3761157 --- /dev/null +++ b/app/Services/CourierService.php @@ -0,0 +1,150 @@ +apiKey = config('courier.api_key'); + $this->baseUrl = config('courier.api_base_url', 'https://api.shiplogic.com/api'); + } + + /** + * Create a shipment with Shiplogic + * + * @param string $orderId + * @param float $width Width in cm + * @param float $length Length in cm + * @param float $weight Weight in kg + * @return array{shipment_id: string, waybill_id: string, tracking_number: string} + * @throws \Exception + */ + public function createShipment(string $orderId, float $width, float $length, float $weight): array + { + if (! $this->isConfigured()) { + throw new \Exception('Courier API not configured'); + } + + if ($width <= 0 || $length <= 0 || $weight <= 0) { + throw new \Exception('Invalid dimensions or weight: all must be greater than 0'); + } + + try { + // Build shipment payload for Shiplogic + $payload = [ + 'parcel' => [ + 'weight' => $weight, + 'height' => 10, // TODO: Update when height is captured separately + 'width' => $width, + 'length' => $length, + ], + 'destination' => [ + // TODO: Get from order's shipping address + ], + 'reference' => $orderId, + ]; + + $response = Http::withHeaders([ + 'Authorization' => "Bearer {$this->apiKey}", + ])->post("{$this->baseUrl}/shipments", $payload); + + if (! $response->successful()) { + $errorMessage = $response->json('error.message', 'Unknown error'); + throw new \Exception("Courier API error: {$errorMessage}"); + } + + $data = $response->json(); + + return [ + 'shipment_id' => $data['id'] ?? null, + 'waybill_id' => $data['waybill_number'] ?? null, + 'tracking_number' => $data['tracking_number'] ?? null, + ]; + } catch (\Exception $e) { + Log::error('Failed to create shipment with courier', [ + 'order_id' => $orderId, + 'error' => $e->getMessage(), + ]); + + throw $e; + } + } + + /** + * Fetch shipping label/sticker PDF from Shiplogic + */ + public function fetchSticker(string $shipmentId, string $orderId): ?string + { + if (! $this->isConfigured()) { + return null; + } + + try { + $response = Http::withHeaders([ + 'Authorization' => "Bearer {$this->apiKey}", + ])->get("{$this->baseUrl}/shipments/{$shipmentId}/sticker"); + + if ($response->successful()) { + $path = "shipments/{$orderId}/sticker.pdf"; + Storage::disk('public')->put($path, $response->body()); + + return $path; + } + + Log::warning('Failed to fetch sticker from courier', ['shipment_id' => $shipmentId]); + + return null; + } catch (\Exception $e) { + Log::error('Exception fetching sticker', ['error' => $e->getMessage()]); + + return null; + } + } + + /** + * Fetch waybill PDF from Shiplogic + */ + public function fetchWaybill(string $shipmentId, string $orderId): ?string + { + if (! $this->isConfigured()) { + return null; + } + + try { + $response = Http::withHeaders([ + 'Authorization' => "Bearer {$this->apiKey}", + ])->get("{$this->baseUrl}/shipments/{$shipmentId}/label"); + + if ($response->successful()) { + $path = "shipments/{$orderId}/waybill.pdf"; + Storage::disk('public')->put($path, $response->body()); + + return $path; + } + + Log::warning('Failed to fetch waybill from courier', ['shipment_id' => $shipmentId]); + + return null; + } catch (\Exception $e) { + Log::error('Exception fetching waybill', ['error' => $e->getMessage()]); + + return null; + } + } + + /** + * Check if courier is configured + */ + protected function isConfigured(): bool + { + return ! empty($this->apiKey); + } +} diff --git a/app/Services/SlackNotifierService.php b/app/Services/SlackNotifierService.php new file mode 100644 index 0000000..c4e60cc --- /dev/null +++ b/app/Services/SlackNotifierService.php @@ -0,0 +1,90 @@ +webhooks = [ + 'orders' => config('slack.webhooks.orders'), + 'design' => config('slack.webhooks.design'), + 'production' => config('slack.webhooks.production'), + 'shipping' => config('slack.webhooks.shipping'), + 'ops_alerts' => config('slack.webhooks.ops_alerts'), + ]; + } + + /** + * Send notification to #orders channel + */ + public function orders(string $message, array $blocks = []): void + { + $this->send($this->webhooks['orders'], $message, $blocks); + } + + /** + * Send notification to #design channel + */ + public function design(string $message, array $blocks = []): void + { + $this->send($this->webhooks['design'], $message, $blocks); + } + + /** + * Send notification to #production channel + */ + public function production(string $message, array $blocks = []): void + { + $this->send($this->webhooks['production'], $message, $blocks); + } + + /** + * Send notification to #shipping channel + */ + public function shipping(string $message, array $blocks = []): void + { + $this->send($this->webhooks['shipping'], $message, $blocks); + } + + /** + * Send notification to #ops-alerts channel + */ + public function opsAlerts(string $message, array $blocks = []): void + { + $this->send($this->webhooks['ops_alerts'], $message, $blocks); + } + + /** + * Send payload to Slack webhook + */ + protected function send(string $webhook, string $message, array $blocks = []): void + { + if (! $webhook) { + Log::warning('Slack webhook not configured for channel', ['message' => $message]); + + return; + } + + try { + $payload = ['text' => $message]; + + if (! empty($blocks)) { + $payload['blocks'] = $blocks; + } + + Http::post($webhook, $payload); + } catch (\Exception $e) { + Log::error('Failed to send Slack notification', [ + 'webhook' => substr($webhook, 0, 20).'...', + 'message' => $message, + 'error' => $e->getMessage(), + ]); + } + } +} diff --git a/app/Services/TrelloService.php b/app/Services/TrelloService.php new file mode 100644 index 0000000..bd74dbe --- /dev/null +++ b/app/Services/TrelloService.php @@ -0,0 +1,217 @@ +apiKey = config('trello.api_key'); + $this->apiToken = config('trello.api_token'); + $this->boardId = config('trello.board_id'); + } + + /** + * Create a new card on the board + */ + public function createCard(string $orderId, string $orderNumber, string $orderType = 'standard', ?string $startingListId = null): ?string + { + if (! $this->isConfigured()) { + Log::warning('Trello not configured, skipping card creation', ['order_id' => $orderId]); + + return null; + } + + try { + $listId = $startingListId ?? $this->getStartingListId($orderType); + + $response = Http::post("{$this->baseUrl}/cards", [ + 'name' => "Order #{$orderNumber}", + 'desc' => "Order ID: {$orderId}\nType: {$orderType}", + 'idList' => $listId, + 'key' => $this->apiKey, + 'token' => $this->apiToken, + ]); + + if ($response->successful()) { + $data = $response->json(); + + return $data['id'] ?? null; + } + + Log::error('Failed to create Trello card', ['response' => $response->body()]); + + return null; + } catch (\Exception $e) { + Log::error('Exception creating Trello card', ['error' => $e->getMessage()]); + + return null; + } + } + + /** + * Move a card to a different list + */ + public function moveCard(string $cardId, string $listName): bool + { + if (! $this->isConfigured()) { + return false; + } + + try { + $listId = $this->getListIdByName($listName); + + if (! $listId) { + Log::warning('Trello list not found', ['list_name' => $listName]); + + return false; + } + + $response = Http::put("{$this->baseUrl}/cards/{$cardId}", [ + 'idList' => $listId, + 'key' => $this->apiKey, + 'token' => $this->apiToken, + ]); + + return $response->successful(); + } catch (\Exception $e) { + Log::error('Exception moving Trello card', [ + 'card_id' => $cardId, + 'error' => $e->getMessage(), + ]); + + return false; + } + } + + /** + * Attach a file or URL to a card + */ + public function attachFile(string $cardId, string $fileName, string $fileUrl): bool + { + if (! $this->isConfigured()) { + return false; + } + + try { + $response = Http::post("{$this->baseUrl}/cards/{$cardId}/attachments", [ + 'name' => $fileName, + 'url' => $fileUrl, + 'key' => $this->apiKey, + 'token' => $this->apiToken, + ]); + + return $response->successful(); + } catch (\Exception $e) { + Log::error('Exception attaching file to Trello card', [ + 'card_id' => $cardId, + 'error' => $e->getMessage(), + ]); + + return false; + } + } + + /** + * Check/tick a checklist item on a card + */ + public function checkItem(string $cardId, string $checklistName, string $itemName): bool + { + if (! $this->isConfigured()) { + return false; + } + + try { + // Fetch card to find checklist + $cardResponse = Http::get("{$this->baseUrl}/cards/{$cardId}", [ + 'key' => $this->apiKey, + 'token' => $this->apiToken, + 'checklists' => 'open', + ]); + + if (! $cardResponse->successful()) { + return false; + } + + $checklists = $cardResponse->json('checklists') ?? []; + $checklist = collect($checklists)->firstWhere('name', $checklistName); + + if (! $checklist) { + Log::warning('Trello checklist not found', ['checklist_name' => $checklistName]); + + return false; + } + + $checklistId = $checklist['id']; + $item = collect($checklist['checkItems'])->firstWhere('name', $itemName); + + if (! $item) { + Log::warning('Trello checklist item not found', ['item_name' => $itemName]); + + return false; + } + + $response = Http::put("{$this->baseUrl}/checklists/{$checklistId}/checkItems/{$item['id']}", [ + 'state' => 'complete', + 'key' => $this->apiKey, + 'token' => $this->apiToken, + ]); + + return $response->successful(); + } catch (\Exception $e) { + Log::error('Exception checking Trello item', [ + 'card_id' => $cardId, + 'error' => $e->getMessage(), + ]); + + return false; + } + } + + /** + * Get starting list ID for order type + */ + protected function getStartingListId(string $orderType): string + { + if ($orderType === 'custom') { + return config('trello.lists.custom.new_custom_order'); + } + + return config('trello.lists.standard.new_order'); + } + + /** + * Get list ID by name (from config) + */ + protected function getListIdByName(string $listName): ?string + { + $lists = array_merge( + config('trello.lists.standard', []), + config('trello.lists.custom', []) + ); + + foreach ($lists as $key => $id) { + if (str_replace('_', ' ', ucfirst($key)) === $listName) { + return $id; + } + } + + return null; + } + + /** + * Check if Trello is configured + */ + protected function isConfigured(): bool + { + return ! empty($this->apiKey) && ! empty($this->apiToken) && ! empty($this->boardId); + } +} diff --git a/config/courier.php b/config/courier.php new file mode 100644 index 0000000..474641d --- /dev/null +++ b/config/courier.php @@ -0,0 +1,7 @@ + env('COURIER_API_KEY'), + 'api_base_url' => env('COURIER_API_BASE_URL', 'https://api.shiplogic.com/api'), + 'webhook_secret' => env('COURIER_WEBHOOK_SECRET'), +]; diff --git a/config/slack.php b/config/slack.php new file mode 100644 index 0000000..45fe41a --- /dev/null +++ b/config/slack.php @@ -0,0 +1,11 @@ + [ + 'orders' => env('SLACK_WEBHOOK_ORDERS'), + 'design' => env('SLACK_WEBHOOK_DESIGN'), + 'production' => env('SLACK_WEBHOOK_PRODUCTION'), + 'shipping' => env('SLACK_WEBHOOK_SHIPPING'), + 'ops_alerts' => env('SLACK_WEBHOOK_OPS'), + ], +]; diff --git a/config/trello.php b/config/trello.php new file mode 100644 index 0000000..828dc9e --- /dev/null +++ b/config/trello.php @@ -0,0 +1,36 @@ + env('TRELLO_API_KEY'), + 'api_token' => env('TRELLO_API_TOKEN'), + 'board_id' => env('TRELLO_BOARD_ID'), + 'webhook_secret' => env('TRELLO_WEBHOOK_SECRET'), + + 'lists' => [ + 'standard' => [ + 'new_order' => env('TRELLO_LIST_ID_NEW_ORDER'), + 'prep' => env('TRELLO_LIST_ID_PREP'), + 'printing' => env('TRELLO_LIST_ID_PRINTING'), + 'inspection' => env('TRELLO_LIST_ID_INSPECTION'), + 'packing' => env('TRELLO_LIST_ID_PACKING'), + 'ready_to_ship' => env('TRELLO_LIST_ID_READY_TO_SHIP'), + 'awaiting_collection' => env('TRELLO_LIST_ID_AWAITING_COLLECTION'), + 'in_transit' => env('TRELLO_LIST_ID_IN_TRANSIT'), + 'done' => env('TRELLO_LIST_ID_DONE'), + ], + 'custom' => [ + 'new_custom_order' => env('TRELLO_LIST_ID_NEW_CUSTOM_ORDER'), + 'design' => env('TRELLO_LIST_ID_DESIGN'), + 'awaiting_approval' => env('TRELLO_LIST_ID_AWAITING_APPROVAL'), + 'awaiting_balance' => env('TRELLO_LIST_ID_AWAITING_BALANCE'), + 'ready_for_print' => env('TRELLO_LIST_ID_READY_FOR_PRINT'), + 'printing' => env('TRELLO_LIST_ID_PRINTING'), + 'inspection' => env('TRELLO_LIST_ID_INSPECTION'), + 'packing' => env('TRELLO_LIST_ID_PACKING'), + 'ready_to_ship' => env('TRELLO_LIST_ID_READY_TO_SHIP'), + 'awaiting_collection' => env('TRELLO_LIST_ID_AWAITING_COLLECTION'), + 'in_transit' => env('TRELLO_LIST_ID_IN_TRANSIT'), + 'done' => env('TRELLO_LIST_ID_DONE'), + ], + ], +]; diff --git a/database/migrations/2026_01_02_000001_add_packing_and_shipping_columns.php b/database/migrations/2026_01_02_000001_add_packing_and_shipping_columns.php new file mode 100644 index 0000000..ddaebba --- /dev/null +++ b/database/migrations/2026_01_02_000001_add_packing_and_shipping_columns.php @@ -0,0 +1,104 @@ +decimal('packing_width', 8, 2)->nullable()->comment('Width in cm'); + $table->decimal('packing_length', 8, 2)->nullable()->comment('Length in cm'); + $table->decimal('packing_weight', 8, 2)->nullable()->comment('Weight in kg'); + $table->timestamp('packing_completed_at')->nullable(); + $table->foreignId('packed_by')->nullable()->constrained('users'); + + // Courier integration + $table->string('courier_waybill_id')->nullable()->unique(); + $table->string('courier_tracking_number')->nullable(); + $table->string('courier_status')->nullable()->default('pending'); // pending, awaiting_collection, in_transit, delivered, failed + $table->timestamp('delivered_at')->nullable(); + $table->text('delivery_failure_reason')->nullable(); + + // Trello integration + $table->string('trello_card_id')->nullable(); + + // QR code (for later) + $table->string('qr_token')->nullable()->unique(); + $table->timestamp('qr_generated_at')->nullable(); + + $table->index('courier_waybill_id'); + }); + + Schema::table('custom_orders', function (Blueprint $table) { + // Packing data + $table->decimal('packing_width', 8, 2)->nullable()->comment('Width in cm'); + $table->decimal('packing_length', 8, 2)->nullable()->comment('Length in cm'); + $table->decimal('packing_weight', 8, 2)->nullable()->comment('Weight in kg'); + $table->timestamp('packing_completed_at')->nullable(); + $table->foreignId('packed_by')->nullable()->constrained('users'); + + // Courier integration + $table->string('courier_waybill_id')->nullable()->unique(); + $table->string('courier_tracking_number')->nullable(); + $table->string('courier_status')->nullable()->default('pending'); + $table->timestamp('delivered_at')->nullable(); + $table->text('delivery_failure_reason')->nullable(); + + // Trello integration + $table->string('trello_card_id')->nullable(); + + // QR code (for later) + $table->string('qr_token')->nullable()->unique(); + $table->timestamp('qr_generated_at')->nullable(); + + $table->index('courier_waybill_id'); + }); + } + + public function down(): void + { + Schema::table('orders', function (Blueprint $table) { + $table->dropForeign(['packed_by']); + $table->dropIndex(['courier_waybill_id']); + $table->dropColumn([ + 'packing_width', + 'packing_length', + 'packing_weight', + 'packing_completed_at', + 'packed_by', + 'courier_waybill_id', + 'courier_tracking_number', + 'courier_status', + 'delivered_at', + 'delivery_failure_reason', + 'trello_card_id', + 'qr_token', + 'qr_generated_at', + ]); + }); + + Schema::table('custom_orders', function (Blueprint $table) { + $table->dropForeign(['packed_by']); + $table->dropIndex(['courier_waybill_id']); + $table->dropColumn([ + 'packing_width', + 'packing_length', + 'packing_weight', + 'packing_completed_at', + 'packed_by', + 'courier_waybill_id', + 'courier_tracking_number', + 'courier_status', + 'delivered_at', + 'delivery_failure_reason', + 'trello_card_id', + 'qr_token', + 'qr_generated_at', + ]); + }); + } +}; diff --git a/routes/api.php b/routes/api.php index ca95825..7c29886 100644 --- a/routes/api.php +++ b/routes/api.php @@ -3,5 +3,14 @@ use Illuminate\Http\Request; use Illuminate\Support\Facades\Route; use App\Http\Controllers\OrderController; +use App\Http\Controllers\TrelloWebhookController; +use App\Http\Controllers\CourierWebhookController; +// Existing Yoco webhook Route::post('/webhook', [OrderController::class, 'yocoWebhook'])->name('yoco-webhook'); + +// Trello webhook +Route::post('/webhooks/trello', [TrelloWebhookController::class, 'handle'])->name('trello-webhook'); + +// Courier webhook +Route::post('/webhooks/courier', [CourierWebhookController::class, 'handle'])->name('courier-webhook'); diff --git a/routes/web.php b/routes/web.php index 76fcc6c..a742ecb 100644 --- a/routes/web.php +++ b/routes/web.php @@ -10,6 +10,8 @@ use App\Http\Controllers\CartController; use App\Http\Controllers\OrderController; use App\Http\Controllers\CustomOrderController; use App\Http\Controllers\Auth\GoogleAuthController; +use App\Http\Controllers\PackingController; +use App\Http\Controllers\ShippingController; Route::get('/', [HomeController::class, 'index'])->name('home'); Route::get('/wallpapers', [WallpapersController::class, 'index'])->name('wallpapers'); @@ -61,6 +63,12 @@ Route::middleware('auth')->group(function () { Route::get('/custom-orders/{customOrder:uuid}', 'App\Http\Controllers\CustomOrderController@show')->name('custom-orders.show'); Route::post('/payment/yoco/custom/deposit', [CustomOrderController::class, 'depositPayment'])->name('yoco-custom-deposit'); Route::get('/payment/yoco/custom/deposit/success/{customOrder:uuid}', [CustomOrderController::class, 'depositSuccess'])->name('yoco-custom-deposit-success'); + + // Packing & Shipping routes (ops staff) + Route::post('/orders/{order:uuid}/pack', [PackingController::class, 'confirmPacked'])->name('orders.pack'); + Route::post('/custom-orders/{customOrder:uuid}/pack', [PackingController::class, 'confirmPacked'])->name('custom-orders.pack'); + Route::post('/orders/{order:uuid}/ship', [ShippingController::class, 'createShipment'])->name('orders.ship'); + Route::post('/custom-orders/{customOrder:uuid}/ship', [ShippingController::class, 'createShipment'])->name('custom-orders.ship'); }); // use Illuminate\Support\Facades\Route;