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:
@@ -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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user