| 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/Services/ClickUp/ |
Upload File : |
<?php
namespace App\Services\ClickUp;
use App\Models\ClientMilestone;
use App\Models\ClientProject;
use Illuminate\Support\Carbon;
use RuntimeException;
/**
* Pulls every task from a ClientProject's ClickUp folder, upserts the
* `client_milestones` mirror, and records a `last_synced_at` on the
* project.
*
* Operator-set fields (`is_visible`, `display_order`) survive sync — only
* ClickUp-derived columns (name, status, due dates) get overwritten. Tasks
* not seen in this pass have a stale `last_seen_at`; the caller (or a
* scheduled prune job) can hide them by flipping `is_visible = false`.
*/
class MilestoneSyncer
{
public function __construct(private readonly ClickUpApiClient $api) {}
/**
* @return array{lists: int, tasks: int}
*/
public function sync(ClientProject $project): array
{
$connection = $project->workspace?->clickupConnection;
if (! $connection?->isActive()) {
throw new RuntimeException('Workspace has no active ClickUp connection.');
}
$listsResponse = $this->api->getFolderLists($project->clickup_folder_id);
$lists = (array) ($listsResponse['lists'] ?? []);
$now = now();
$taskCount = 0;
$displayOrder = 0;
foreach ($lists as $list) {
$listId = (string) ($list['id'] ?? '');
if ($listId === '') {
continue;
}
foreach ($this->pageTasks($listId) as $task) {
$taskId = (string) ($task['id'] ?? '');
if ($taskId === '') {
continue;
}
$existing = ClientMilestone::query()
->where('client_project_id', $project->id)
->where('clickup_task_id', $taskId)
->first();
$status = (array) ($task['status'] ?? []);
$isClosed = ($status['type'] ?? null) === 'closed';
$values = [
'workspace_id' => $project->workspace_id,
'client_project_id' => $project->id,
'clickup_list_id' => $listId,
'name' => (string) ($task['name'] ?? 'Untitled'),
'status_name' => $status['status'] ?? null,
'status_color' => $status['color'] ?? null,
'status_type' => $status['type'] ?? null,
'is_complete' => $isClosed,
'due_at' => $this->toDate($task['due_date'] ?? null),
'completed_at' => $isClosed ? $this->toDate($task['date_closed'] ?? null) : null,
'last_seen_at' => $now,
];
if ($existing) {
$existing->forceFill($values)->save();
} else {
ClientMilestone::create(array_merge($values, [
'clickup_task_id' => $taskId,
'is_visible' => true,
'display_order' => $displayOrder++,
]));
}
}
$taskCount += 1;
}
$project->forceFill(['last_synced_at' => $now])->save();
return ['lists' => count($lists), 'tasks' => ClientMilestone::query()->where('client_project_id', $project->id)->count()];
}
private function pageTasks(string $listId): iterable
{
$page = 0;
while (true) {
$resp = $this->api->getListTasks($listId, $page);
$tasks = (array) ($resp['tasks'] ?? []);
foreach ($tasks as $task) {
yield $task;
}
if (($resp['last_page'] ?? null) === true || count($tasks) === 0) {
return;
}
$page++;
if ($page > 50) {
return;
}
}
}
private function toDate(mixed $value): ?Carbon
{
if ($value === null || $value === '') {
return null;
}
if (is_numeric($value)) {
$ms = (int) $value;
return Carbon::createFromTimestampMs($ms);
}
return null;
}
}