diff --git a/app/Http/Controllers/Api/V1/SyncController.php b/app/Http/Controllers/Api/V1/SyncController.php index 32aa0ef..893d43e 100644 --- a/app/Http/Controllers/Api/V1/SyncController.php +++ b/app/Http/Controllers/Api/V1/SyncController.php @@ -3,29 +3,36 @@ namespace App\Http\Controllers\Api\V1; use App\Http\Controllers\Controller; +use App\Models\Feature; +use App\Models\Inspection; +use App\Models\Issue; use App\Models\Phase; use App\Models\ProgressUpdate; +use App\Models\Project; use App\Models\User; +use Carbon\Carbon; use Illuminate\Http\Request; use Illuminate\Support\Facades\Validator; class SyncController extends Controller { /** - * Push a batch of offline mutations. Idempotent by client-generated `uuid`. - * Returns a per-operation result (applied | duplicate | conflict | error). + * Push a batch of offline mutations. Returns a per-operation result + * (applied | duplicate | conflict | error). Never aborts the whole batch. * - * Milestone 2: supports only progress_update.create (vertical slice). + * Identity: + * - create ops → the new record stores the client-generated `uuid` (idempotent). + * - update ops → target identified by `data.id` (server id); last-write-wins. */ public function sync(Request $request) { $data = $request->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'], + '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(); @@ -43,59 +50,256 @@ class SyncController extends Controller $uuid = $op['uuid']; try { - if ($op['entity'] === 'progress_update' && $op['op'] === 'create') { - return $this->progressUpdateCreate($user, $uuid, $op); - } - - return $this->error($uuid, 'unsupported entity/op: ' . $op['entity'] . '.' . $op['op']); + return 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), + 'feature.update' => $this->featureUpdate($user, $uuid, $op), + default => $this->error($uuid, 'unsupported entity/op: ' . $op['entity'] . '.' . $op['op']), + }; } catch (\Throwable $e) { return $this->error($uuid, $e->getMessage()); } } + // ── progress_update.create ─────────────────────────────────────────────────── + private function progressUpdateCreate(User $user, string $uuid, array $op): array { - // Idempotency: same uuid already applied → duplicate (no-op). - $existing = ProgressUpdate::where('uuid', $uuid)->first(); - if ($existing) { - return ['uuid' => $uuid, 'status' => 'duplicate', 'server_id' => $existing->id]; + if ($existing = ProgressUpdate::where('uuid', $uuid)->first()) { + return $this->duplicate($uuid, $existing->id); } - $validator = Validator::make($op['data'], [ + $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 ($validator->fails()) { - return $this->error($uuid, 'validation: ' . $validator->errors()->first()); + if ($v->fails()) { + return $this->error($uuid, 'validation: ' . $v->errors()->first()); } - $d = $validator->validated(); + $d = $v->validated(); $phase = Phase::with('project')->findOrFail($d['phase_id']); - - // Authorization: project membership + permission (per-op, never abort the batch). - $isMember = $user->can('manage all') - || ($phase->project && $phase->project->users()->where('user_id', $user->id)->exists()); - if (! $isMember || ! $user->can('update progress')) { + 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, // server-set, never trust client + 'user_id' => $user->id, 'progress_percent' => $d['progress'], 'comment' => $d['comment'] ?? null, 'location' => $d['location'] ?? null, 'client_updated_at' => $op['client_updated_at'] ?? null, ]); - // Mirror the web behaviour: reflect latest progress on the phase. $phase->progress_percent = $d['progress']; $phase->save(); - return ['uuid' => $uuid, 'status' => 'applied', 'server_id' => $pu->id]; + 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)], + ]); + 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', + '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)], + '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); + } + + // ── 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 diff --git a/app/Models/Feature.php b/app/Models/Feature.php index 902fded..6be23ba 100644 --- a/app/Models/Feature.php +++ b/app/Models/Feature.php @@ -15,6 +15,7 @@ class Feature extends Model protected $fillable = [ 'layer_id', 'name', 'geometry', 'properties', 'template_id', 'progress', 'status', 'responsible', 'responsible_user_id', + 'uuid', 'client_updated_at', ]; protected $casts = [ diff --git a/app/Models/Inspection.php b/app/Models/Inspection.php index 0be239b..76b735c 100644 --- a/app/Models/Inspection.php +++ b/app/Models/Inspection.php @@ -16,6 +16,7 @@ class Inspection extends Model protected $fillable = [ 'project_id', 'layer_id', 'feature_id', 'template_id', 'user_id', 'data', 'status', 'inspector_user_id', 'completed_at', 'result', 'notes', + 'uuid', 'client_updated_at', ]; protected $casts = [ diff --git a/app/Models/Issue.php b/app/Models/Issue.php index acedaec..7a774c9 100644 --- a/app/Models/Issue.php +++ b/app/Models/Issue.php @@ -16,7 +16,8 @@ class Issue extends Model protected $fillable = [ 'project_id', 'feature_id', 'inspection_id', 'title', 'description', 'status', 'priority', - 'reported_by', 'assigned_to', 'resolved_at', 'resolution_notes' + 'reported_by', 'assigned_to', 'resolved_at', 'resolution_notes', + 'uuid', 'client_updated_at', ]; protected $casts = ['resolved_at' => 'datetime']; diff --git a/database/migrations/2026_06_18_073000_add_sync_fields_to_syncable_tables.php b/database/migrations/2026_06_18_073000_add_sync_fields_to_syncable_tables.php new file mode 100644 index 0000000..2ca0fef --- /dev/null +++ b/database/migrations/2026_06_18_073000_add_sync_fields_to_syncable_tables.php @@ -0,0 +1,37 @@ +tables as $name) { + Schema::table($name, function (Blueprint $table) use ($name) { + if (! Schema::hasColumn($name, 'uuid')) { + $table->uuid('uuid')->nullable()->unique()->after('id'); + } + if (! Schema::hasColumn($name, 'client_updated_at')) { + $table->timestamp('client_updated_at')->nullable(); + } + }); + } + } + + public function down(): void + { + foreach ($this->tables as $name) { + Schema::table($name, function (Blueprint $table) use ($name) { + foreach (['uuid', 'client_updated_at'] as $col) { + if (Schema::hasColumn($name, $col)) { + $table->dropColumn($col); + } + } + }); + } + } +}; diff --git a/tests/Feature/Api/MobileApiTest.php b/tests/Feature/Api/MobileApiTest.php index 41ba0f5..1a15e36 100644 --- a/tests/Feature/Api/MobileApiTest.php +++ b/tests/Feature/Api/MobileApiTest.php @@ -3,7 +3,9 @@ namespace Tests\Feature\Api; use App\Models\Feature; +use App\Models\Inspection; use App\Models\InspectionTemplate; +use App\Models\Issue; use App\Models\Layer; use App\Models\Phase; use App\Models\Project; @@ -23,8 +25,9 @@ class MobileApiTest extends TestCase protected function setUp(): void { parent::setUp(); - Permission::findOrCreate('update progress'); - Permission::findOrCreate('manage all'); + foreach (['update progress', 'manage all', 'create inspections', 'create issues', 'edit issues'] as $p) { + Permission::findOrCreate($p); + } } private function makeProject(?User $member = null): Project @@ -277,4 +280,95 @@ class MobileApiTest extends TestCase $this->assertTrue(collect($res->json('templates'))->pluck('name')->contains('Plantilla A')); $this->assertNotNull($res->json('templates.0.hash')); } + + // ── Push: inspections / issues / features (Milestone 4) ────────────────────── + + public function test_sync_creates_inspection_and_is_idempotent(): void + { + $user = User::factory()->create(); + $user->givePermissionTo('create inspections'); + $project = $this->makeProject($user); + $feature = $this->makeFeature($this->makeLayer($this->makePhase($project))); + $uuid = (string) Str::uuid(); + + Sanctum::actingAs($user, ['mobile-sync']); + $payload = ['operations' => [[ + 'entity' => 'inspection', 'op' => 'create', 'uuid' => $uuid, + 'data' => ['feature_id' => $feature->id, 'data' => ['ok' => true], 'result' => 'pass'], + ]]]; + + $this->postJson('/api/v1/sync', $payload)->assertOk()->assertJsonPath('results.0.status', 'applied'); + $this->assertDatabaseHas('inspections', ['uuid' => $uuid, 'feature_id' => $feature->id, 'user_id' => $user->id]); + + $this->postJson('/api/v1/sync', $payload)->assertOk()->assertJsonPath('results.0.status', 'duplicate'); + $this->assertEquals(1, Inspection::where('uuid', $uuid)->count()); + } + + public function test_sync_creates_and_updates_an_issue(): void + { + $user = User::factory()->create(); + $user->givePermissionTo(['create issues', 'edit issues']); + $project = $this->makeProject($user); + + Sanctum::actingAs($user, ['mobile-sync']); + + // create + $uuid = (string) Str::uuid(); + $this->postJson('/api/v1/sync', ['operations' => [[ + 'entity' => 'issue', 'op' => 'create', 'uuid' => $uuid, + 'data' => ['project_id' => $project->id, 'title' => 'Grieta', 'priority' => 'high'], + ]]])->assertOk()->assertJsonPath('results.0.status', 'applied'); + + $issue = Issue::where('uuid', $uuid)->firstOrFail(); + $this->assertEquals('open', $issue->status); + + // update (resolve) → resolved_at set + $this->postJson('/api/v1/sync', ['operations' => [[ + 'entity' => 'issue', 'op' => 'update', 'uuid' => (string) Str::uuid(), + 'data' => ['id' => $issue->id, 'status' => 'resolved', 'resolution_notes' => 'Sellada'], + ]]])->assertOk()->assertJsonPath('results.0.status', 'applied'); + + $this->assertEquals('resolved', $issue->fresh()->status); + $this->assertNotNull($issue->fresh()->resolved_at); + } + + public function test_sync_updates_feature_and_recomputes_phase_progress(): void + { + $user = User::factory()->create(); + $user->givePermissionTo('update progress'); + $project = $this->makeProject($user); + $phase = $this->makePhase($project); + $feature = $this->makeFeature($this->makeLayer($phase)); + + Sanctum::actingAs($user, ['mobile-sync']); + $this->postJson('/api/v1/sync', ['operations' => [[ + 'entity' => 'feature', 'op' => 'update', 'uuid' => (string) Str::uuid(), + 'data' => ['id' => $feature->id, 'status' => 'completed', 'progress' => 100], + ]]])->assertOk()->assertJsonPath('results.0.status', 'applied'); + + $this->assertEquals(100, $feature->fresh()->progress); + $this->assertEquals(100, $phase->fresh()->progress_percent); + } + + public function test_sync_returns_conflict_when_server_is_newer(): void + { + $user = User::factory()->create(); + $user->givePermissionTo('update progress'); + $project = $this->makeProject($user); + + Carbon::setTestNow('2026-06-18 12:00:00'); + $feature = $this->makeFeature($this->makeLayer($this->makePhase($project))); + Carbon::setTestNow(); // feature->updated_at = 12:00 + + Sanctum::actingAs($user, ['mobile-sync']); + // client edited BEFORE the server's last update → conflict + $res = $this->postJson('/api/v1/sync', ['operations' => [[ + 'entity' => 'feature', 'op' => 'update', 'uuid' => (string) Str::uuid(), + 'client_updated_at' => '2026-06-18 11:00:00', + 'data' => ['id' => $feature->id, 'progress' => 50], + ]]])->assertOk(); + + $res->assertJsonPath('results.0.status', 'conflict'); + $this->assertEquals(0, $feature->fresh()->progress); // unchanged + } }