| Server IP : 35.80.110.71 / Your IP : 216.73.216.221 Web Server : Apache/2.4.58 (Ubuntu) System : Linux ip-172-31-21-44 6.17.0-1019-aws #19~24.04.1-Ubuntu SMP Tue Jun 23 18:53:06 UTC 2026 x86_64 User : ubuntu ( 1000) PHP Version : 8.3.31 Disable Function : NONE MySQL : OFF | cURL : ON | WGET : ON | Perl : ON | Python : OFF | Sudo : ON | Pkexec : OFF Directory : /var/www/switchyard/current/app/Jobs/ |
Upload File : |
<?php
namespace App\Jobs;
use App\Enums\LeadEventKind;
use App\Enums\LeadStatus;
use App\Models\ClientMilestone;
use App\Models\Lead;
use App\Models\LeadEvent;
use App\Models\ProjectTemplate;
use App\Models\Scopes\WorkspaceScope;
use App\Models\Workspace;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\SerializesModels;
use Illuminate\Support\Facades\Log;
/**
* Maps an incoming ClickUp webhook event onto local mirrors. A single
* webhook can touch:
*
* 1. A Lead — when the task is the original lead row (Phase 1 path).
* Translates via the workspace's `status_mapping` and writes back to
* `leads.status` (+ terminal-timestamp columns).
*
* 2. A ClientMilestone — when the task lives inside a tracked
* ClientProject's ClickUp folder (Phase 2 path). Writes the new
* ClickUp status name/color/type onto the milestone mirror so the
* public `/status/[token]` page reflects it without a fresh sync.
*
* Both mirrors update independently; either, both, or neither may match
* for a given event.
*/
class MirrorClickUpStatusJob implements ShouldQueue
{
use Dispatchable;
use InteractsWithQueue;
use Queueable;
use SerializesModels;
public function __construct(
public readonly string $workspaceId,
public readonly array $payload,
) {}
public function handle(): void
{
$taskId = $this->payload['task_id'] ?? null;
if (! $taskId) {
Log::info('MirrorClickUpStatusJob: payload missing task_id', ['workspace_id' => $this->workspaceId]);
return;
}
$newStatus = $this->extractStatus($this->payload);
$matched = $this->mirrorLead($taskId, $newStatus);
$matched = $this->mirrorMilestone($taskId, $newStatus) || $matched;
if (! $matched) {
Log::info('MirrorClickUpStatusJob: no matching lead or milestone', [
'workspace_id' => $this->workspaceId,
'task_id' => $taskId,
]);
}
}
/**
* @param array{name: string|null, color: string|null, type: string|null} $newStatus
*/
private function mirrorLead(string $taskId, array $newStatus): bool
{
$lead = Lead::withoutGlobalScope(WorkspaceScope::class)
->where('workspace_id', $this->workspaceId)
->where('clickup_task_id', $taskId)
->first();
if (! $lead) {
return false;
}
$workspace = Workspace::find($this->workspaceId);
$mapping = (array) ($workspace?->clickupConfig?->status_mapping ?? []);
$statusName = $newStatus['name'];
$newLocalStatus = $statusName !== null
? $this->mapToLocalStatus($statusName, $mapping)
: null;
$eventPayload = [
'event' => $this->payload['event'] ?? null,
'task_id' => $taskId,
'clickup_status' => $statusName,
'mapped_status' => $newLocalStatus?->value,
];
if ($newLocalStatus !== null && $newLocalStatus !== $lead->status) {
$lead->status = $newLocalStatus;
$col = $this->terminalColumnFor($newLocalStatus);
$lead->{$col} = $newLocalStatus->isTerminal() ? now() : $lead->{$col};
$lead->save();
$this->emit($lead, LeadEventKind::StatusChanged, $eventPayload);
if ($newLocalStatus === LeadStatus::Won) {
$this->maybeAutoScaffold($lead);
}
} else {
$this->emit($lead, LeadEventKind::Mirrored, $eventPayload);
}
return true;
}
/**
* Phase 3 hook — if the workspace has a ProjectTemplate matching the
* lead's service_tag with `auto_scaffold = true`, dispatch a
* ScaffoldProjectJob. A template with no service_tag set acts as the
* workspace-wide default if no exact-tag match exists.
*/
private function maybeAutoScaffold(Lead $lead): void
{
$template = ProjectTemplate::withoutGlobalScope(WorkspaceScope::class)
->where('workspace_id', $lead->workspace_id)
->where('auto_scaffold', true)
->where(function ($q) use ($lead) {
$q->where('service_tag', $lead->service_tag)
->orWhereNull('service_tag');
})
->orderByRaw('CASE WHEN service_tag = ? THEN 0 ELSE 1 END', [$lead->service_tag])
->first();
if (! $template) {
return;
}
ScaffoldProjectJob::dispatch(
workspaceId: $lead->workspace_id,
templateId: $template->id,
leadId: $lead->id,
trigger: 'lead.status=won',
);
}
/**
* @param array{name: string|null, color: string|null, type: string|null} $newStatus
*/
private function mirrorMilestone(string $taskId, array $newStatus): bool
{
$milestone = ClientMilestone::withoutGlobalScope(WorkspaceScope::class)
->where('workspace_id', $this->workspaceId)
->where('clickup_task_id', $taskId)
->first();
if (! $milestone) {
return false;
}
$isClosed = $newStatus['type'] === 'closed';
$milestone->forceFill([
'status_name' => $newStatus['name'] ?? $milestone->status_name,
'status_color' => $newStatus['color'] ?? $milestone->status_color,
'status_type' => $newStatus['type'] ?? $milestone->status_type,
'is_complete' => $isClosed,
'completed_at' => $isClosed ? ($milestone->completed_at ?? now()) : null,
'last_seen_at' => now(),
])->save();
return true;
}
/**
* @return array{name: string|null, color: string|null, type: string|null}
*/
private function extractStatus(array $payload): array
{
foreach ((array) ($payload['history_items'] ?? []) as $item) {
if (($item['field'] ?? null) === 'status') {
$after = $item['after'] ?? [];
return [
'name' => is_string($after['status'] ?? null) && $after['status'] !== '' ? $after['status'] : null,
'color' => is_string($after['color'] ?? null) ? $after['color'] : null,
'type' => is_string($after['type'] ?? null) ? $after['type'] : null,
];
}
}
$envelopeStatus = $payload['task']['status'] ?? [];
return [
'name' => is_string($envelopeStatus['status'] ?? null) ? $envelopeStatus['status'] : null,
'color' => is_string($envelopeStatus['color'] ?? null) ? $envelopeStatus['color'] : null,
'type' => is_string($envelopeStatus['type'] ?? null) ? $envelopeStatus['type'] : null,
];
}
private function mapToLocalStatus(string $clickupName, array $mapping): ?LeadStatus
{
foreach ($mapping as $clickupLabel => $localStatus) {
if (strcasecmp((string) $clickupLabel, $clickupName) === 0) {
return LeadStatus::tryFrom((string) $localStatus);
}
}
return null;
}
private function terminalColumnFor(LeadStatus $status): string
{
return match ($status) {
LeadStatus::Won => 'won_at',
LeadStatus::Lost => 'lost_at',
LeadStatus::Dismissed => 'dismissed_at',
default => 'updated_at',
};
}
private function emit(Lead $lead, LeadEventKind $kind, array $payload): void
{
LeadEvent::create([
'workspace_id' => $lead->workspace_id,
'lead_id' => $lead->id,
'kind' => $kind,
'payload' => $payload,
'created_at' => now(),
]);
}
}