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
This commit is contained in:
twotalesanimation
2026-01-02 13:47:31 +02:00
parent 4730f5770c
commit 12aadfd917
35 changed files with 1815 additions and 0 deletions
+150
View File
@@ -0,0 +1,150 @@
<?php
namespace App\Services;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Facades\Storage;
class CourierService
{
protected string $apiKey;
protected string $baseUrl;
public function __construct()
{
$this->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);
}
}
+90
View File
@@ -0,0 +1,90 @@
<?php
namespace App\Services;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Log;
class SlackNotifierService
{
protected array $webhooks = [];
public function __construct()
{
$this->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(),
]);
}
}
}
+217
View File
@@ -0,0 +1,217 @@
<?php
namespace App\Services;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Log;
class TrelloService
{
protected string $apiKey;
protected string $apiToken;
protected string $boardId;
protected string $baseUrl = 'https://api.trello.com/1';
public function __construct()
{
$this->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);
}
}