validate([ 'operations' => ['required', 'array'], 'operations.*.entity' => ['required', 'string'], 'operations.*.op' => ['required', 'string'], 'operations.*.uuid' => ['required', 'uuid'], 'operations.*.data' => ['required', 'array'], 'operations.*.client_updated_at' => ['nullable', 'date'], ]); $user = $request->user(); $results = []; foreach ($data['operations'] as $op) { $results[] = $this->handle($user, $op); } return response()->json(['results' => $results]); } private function handle(User $user, array $op): array { $uuid = $op['uuid']; // Op-level idempotency: if this operation was already applied, replay its result. $prior = SyncLog::where('op_uuid', $uuid) ->where('entity', $op['entity'])->where('op', $op['op'])->first(); if ($prior) { return ['uuid' => $uuid, 'status' => 'duplicate', 'server_id' => $prior->server_id]; } try { $result = match ($op['entity'] . '.' . $op['op']) { 'progress_update.create' => $this->progressUpdateCreate($user, $uuid, $op), 'inspection.create' => $this->inspectionCreate($user, $uuid, $op), 'issue.create' => $this->issueCreate($user, $uuid, $op), 'issue.update' => $this->issueUpdate($user, $uuid, $op), 'issue_task.create' => $this->issueTaskCreate($user, $uuid, $op), 'issue_task.update' => $this->issueTaskUpdate($user, $uuid, $op), 'issue_comment.create' => $this->issueCommentCreate($user, $uuid, $op), 'feature.update' => $this->featureUpdate($user, $uuid, $op), default => $this->error($uuid, 'unsupported entity/op: ' . $op['entity'] . '.' . $op['op']), }; } catch (\Throwable $e) { $result = $this->error($uuid, $e->getMessage()); } // Record only terminal successes so conflicts/errors can be safely retried. if ($result['status'] === 'applied') { SyncLog::create([ 'user_id' => $user->id, 'op_uuid' => $uuid, 'entity' => $op['entity'], 'op' => $op['op'], 'status' => 'applied', 'server_id' => $result['server_id'] ?? null, ]); } return $result; } // ── progress_update.create ─────────────────────────────────────────────────── private function progressUpdateCreate(User $user, string $uuid, array $op): array { if ($existing = ProgressUpdate::where('uuid', $uuid)->first()) { return $this->duplicate($uuid, $existing->id); } $v = Validator::make($op['data'], [ 'phase_id' => ['required', 'integer', 'exists:phases,id'], 'progress' => ['required', 'integer', 'min:0', 'max:100'], 'comment' => ['nullable', 'string'], 'location' => ['nullable', 'array'], ]); if ($v->fails()) { return $this->error($uuid, 'validation: ' . $v->errors()->first()); } $d = $v->validated(); $phase = Phase::with('project')->findOrFail($d['phase_id']); if (! $this->canAccess($user, $phase->project) || ! $user->can('update progress')) { return $this->error($uuid, 'forbidden'); } $pu = ProgressUpdate::create([ 'uuid' => $uuid, 'phase_id' => $phase->id, 'user_id' => $user->id, 'progress_percent' => $d['progress'], 'comment' => $d['comment'] ?? null, 'location' => $d['location'] ?? null, 'client_updated_at' => $op['client_updated_at'] ?? null, ]); $phase->progress_percent = $d['progress']; $phase->save(); return $this->applied($uuid, $pu->id); } // ── inspection.create ────────────────────────────────────────────────────────── private function inspectionCreate(User $user, string $uuid, array $op): array { if ($existing = Inspection::where('uuid', $uuid)->first()) { return $this->duplicate($uuid, $existing->id); } $v = Validator::make($op['data'], [ 'feature_id' => ['required', 'integer', 'exists:features,id'], 'template_id' => ['nullable', 'integer', 'exists:inspection_templates,id'], 'data' => ['nullable', 'array'], 'status' => ['nullable', 'string'], 'result' => ['nullable', 'string'], 'notes' => ['nullable', 'string'], ]); if ($v->fails()) { return $this->error($uuid, 'validation: ' . $v->errors()->first()); } $d = $v->validated(); $feature = Feature::with('layer.phase.project')->findOrFail($d['feature_id']); $project = $feature->layer?->phase?->project; if (! $this->canAccess($user, $project) || ! $user->can('create inspections')) { return $this->error($uuid, 'forbidden'); } $inspection = Inspection::create([ 'uuid' => $uuid, 'project_id' => $project->id, 'layer_id' => $feature->layer_id, 'feature_id' => $feature->id, 'template_id' => $d['template_id'] ?? null, 'user_id' => $user->id, 'data' => $d['data'] ?? [], 'status' => $d['status'] ?? 'completed', 'result' => $d['result'] ?? null, 'notes' => $d['notes'] ?? null, 'client_updated_at' => $op['client_updated_at'] ?? null, ]); return $this->applied($uuid, $inspection->id); } // ── issue.create / issue.update ────────────────────────────────────────────────── private function issueCreate(User $user, string $uuid, array $op): array { if ($existing = Issue::where('uuid', $uuid)->first()) { return $this->duplicate($uuid, $existing->id); } $v = Validator::make($op['data'], [ 'project_id' => ['required', 'integer', 'exists:projects,id'], 'feature_id' => ['nullable', 'integer', 'exists:features,id'], 'title' => ['required', 'string', 'max:255'], 'description' => ['nullable', 'string'], 'priority' => ['nullable', 'in:' . implode(',', Issue::PRIORITIES)], 'status' => ['nullable', 'in:' . implode(',', Issue::STATUSES)], 'type' => ['nullable', 'in:' . implode(',', Issue::TYPES)], ]); if ($v->fails()) { return $this->error($uuid, 'validation: ' . $v->errors()->first()); } $d = $v->validated(); $project = Project::find($d['project_id']); if (! $this->canAccess($user, $project) || ! $user->can('create issues')) { return $this->error($uuid, 'forbidden'); } $issue = Issue::create([ 'uuid' => $uuid, 'project_id' => $project->id, 'feature_id' => $d['feature_id'] ?? null, 'title' => $d['title'], 'description' => $d['description'] ?? null, 'priority' => $d['priority'] ?? 'medium', 'status' => $d['status'] ?? 'open', 'type' => $d['type'] ?? 'other', 'reported_by' => $user->id, 'client_updated_at' => $op['client_updated_at'] ?? null, ]); return $this->applied($uuid, $issue->id); } private function issueUpdate(User $user, string $uuid, array $op): array { $v = Validator::make($op['data'], [ 'id' => ['required', 'integer', 'exists:issues,id'], 'title' => ['nullable', 'string', 'max:255'], 'description' => ['nullable', 'string'], 'priority' => ['nullable', 'in:' . implode(',', Issue::PRIORITIES)], 'status' => ['nullable', 'in:' . implode(',', Issue::STATUSES)], 'type' => ['nullable', 'in:' . implode(',', Issue::TYPES)], 'assigned_to' => ['nullable', 'integer', 'exists:users,id'], 'resolution_notes' => ['nullable', 'string'], ]); if ($v->fails()) { return $this->error($uuid, 'validation: ' . $v->errors()->first()); } $d = $v->validated(); $issue = Issue::with('project')->findOrFail($d['id']); if (! $this->canAccess($user, $issue->project) || ! $user->can('edit issues')) { return $this->error($uuid, 'forbidden'); } if ($conflict = $this->conflict($uuid, $issue, $op)) { return $conflict; } $issue->fill(collect($d)->except('id')->toArray()); if (in_array($issue->status, ['resolved', 'closed'], true) && ! $issue->resolved_at) { $issue->resolved_at = now(); } $issue->client_updated_at = $op['client_updated_at'] ?? null; $issue->save(); return $this->applied($uuid, $issue->id); } // ── issue_task.create / issue_task.update ────────────────────────────────────── private function issueTaskCreate(User $user, string $uuid, array $op): array { if ($existing = IssueTask::where('uuid', $uuid)->first()) { return $this->duplicate($uuid, $existing->id); } $v = Validator::make($op['data'], [ 'issue_id' => ['required', 'integer', 'exists:issues,id'], 'title' => ['required', 'string', 'max:255'], 'assigned_to' => ['nullable', 'integer', 'exists:users,id'], 'due_date' => ['nullable', 'date'], 'is_done' => ['nullable', 'boolean'], ]); if ($v->fails()) { return $this->error($uuid, 'validation: ' . $v->errors()->first()); } $d = $v->validated(); $issue = Issue::with('project')->findOrFail($d['issue_id']); if (! $this->canAccess($user, $issue->project) || ! $user->can('edit issues')) { return $this->error($uuid, 'forbidden'); } $done = $d['is_done'] ?? false; $task = IssueTask::create([ 'uuid' => $uuid, 'issue_id' => $issue->id, 'title' => $d['title'], 'assigned_to' => $d['assigned_to'] ?? null, 'due_date' => $d['due_date'] ?? null, 'is_done' => $done, 'done_at' => $done ? now() : null, 'done_by' => $done ? $user->id : null, 'order' => ((int) $issue->tasks()->max('order')) + 1, 'client_updated_at' => $op['client_updated_at'] ?? null, ]); return $this->applied($uuid, $task->id); } private function issueTaskUpdate(User $user, string $uuid, array $op): array { $v = Validator::make($op['data'], [ 'id' => ['required', 'integer', 'exists:issue_tasks,id'], 'title' => ['nullable', 'string', 'max:255'], 'assigned_to' => ['nullable', 'integer', 'exists:users,id'], 'due_date' => ['nullable', 'date'], 'is_done' => ['nullable', 'boolean'], ]); if ($v->fails()) { return $this->error($uuid, 'validation: ' . $v->errors()->first()); } $d = $v->validated(); $task = IssueTask::with('issue.project')->findOrFail($d['id']); if (! $this->canAccess($user, $task->issue?->project) || ! $user->can('edit issues')) { return $this->error($uuid, 'forbidden'); } if ($conflict = $this->conflict($uuid, $task, $op)) { return $conflict; } if (array_key_exists('is_done', $d)) { $task->is_done = (bool) $d['is_done']; $task->done_at = $d['is_done'] ? ($task->done_at ?? now()) : null; $task->done_by = $d['is_done'] ? ($task->done_by ?? $user->id) : null; } $task->fill(collect($d)->only('title', 'assigned_to', 'due_date')->toArray()); $task->client_updated_at = $op['client_updated_at'] ?? null; $task->save(); return $this->applied($uuid, $task->id); } // ── issue_comment.create ──────────────────────────────────────────────────────── private function issueCommentCreate(User $user, string $uuid, array $op): array { if ($existing = IssueComment::where('uuid', $uuid)->first()) { return $this->duplicate($uuid, $existing->id); } $v = Validator::make($op['data'], [ 'issue_id' => ['required', 'integer', 'exists:issues,id'], 'body' => ['required', 'string', 'max:5000'], ]); if ($v->fails()) { return $this->error($uuid, 'validation: ' . $v->errors()->first()); } $d = $v->validated(); $issue = Issue::with('project')->findOrFail($d['issue_id']); if (! $this->canAccess($user, $issue->project) || ! $user->can('view issues')) { return $this->error($uuid, 'forbidden'); } $comment = IssueComment::create([ 'uuid' => $uuid, 'issue_id' => $issue->id, 'user_id' => $user->id, 'body' => $d['body'], 'client_updated_at' => $op['client_updated_at'] ?? null, ]); return $this->applied($uuid, $comment->id); } // ── feature.update ──────────────────────────────────────────────────────────── private function featureUpdate(User $user, string $uuid, array $op): array { $v = Validator::make($op['data'], [ 'id' => ['required', 'integer', 'exists:features,id'], 'status' => ['nullable', 'string'], 'progress' => ['nullable', 'integer', 'min:0', 'max:100'], 'responsible' => ['nullable', 'string'], ]); if ($v->fails()) { return $this->error($uuid, 'validation: ' . $v->errors()->first()); } $d = $v->validated(); $feature = Feature::with('layer.phase.project')->findOrFail($d['id']); $project = $feature->layer?->phase?->project; if (! $this->canAccess($user, $project) || ! $user->can('update progress')) { return $this->error($uuid, 'forbidden'); } if ($conflict = $this->conflict($uuid, $feature, $op)) { return $conflict; } $feature->fill(collect($d)->except('id')->toArray()); $feature->client_updated_at = $op['client_updated_at'] ?? null; $feature->save(); // Mirror web behaviour: recompute the phase progress from its features. if ($phase = $feature->layer?->phase) { $phase->progress_percent = (int) round($phase->features()->avg('progress') ?: 0); $phase->save(); } return $this->applied($uuid, $feature->id); } // ── Helpers ────────────────────────────────────────────────────────────────────── private function canAccess(User $user, ?Project $project): bool { if (! $project) { return false; } return $user->can('manage all') || $project->users()->where('user_id', $user->id)->exists(); } /** * Last-write-wins conflict detection: if the server row was updated after the * client's edit, reject with the current server value. */ private function conflict(string $uuid, $model, array $op): ?array { if (empty($op['client_updated_at'])) { return null; } $clientAt = Carbon::parse($op['client_updated_at']); if ($model->updated_at && $model->updated_at->gt($clientAt)) { return [ 'uuid' => $uuid, 'status' => 'conflict', 'server' => $model->fresh()->toArray(), ]; } return null; } private function applied(string $uuid, int $id): array { return ['uuid' => $uuid, 'status' => 'applied', 'server_id' => $id]; } private function duplicate(string $uuid, int $id): array { return ['uuid' => $uuid, 'status' => 'duplicate', 'server_id' => $id]; } private function error(string $uuid, string $message): array { return ['uuid' => $uuid, 'status' => 'error', 'error' => $message]; } }