diff --git a/app/Http/Controllers/Api/V1/ProjectApiController.php b/app/Http/Controllers/Api/V1/ProjectApiController.php index 756bfa0..687b444 100644 --- a/app/Http/Controllers/Api/V1/ProjectApiController.php +++ b/app/Http/Controllers/Api/V1/ProjectApiController.php @@ -3,7 +3,14 @@ namespace App\Http\Controllers\Api\V1; use App\Http\Controllers\Controller; +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; +use Carbon\Carbon; use Illuminate\Http\Request; class ProjectApiController extends Controller @@ -19,61 +26,144 @@ class ProjectApiController extends Controller } /** - * Offline bundle for one project (Milestone 2 — minimal: structure, no media yet). + * Offline bundle for one project. Full snapshot, or a delta when `?since=` is + * given (only records changed after that timestamp + tombstones for deletions). */ public function bundle(Request $request, Project $project) + { + $this->authorizeProject($request, $project); + + $since = $request->query('since'); + $since = $since ? Carbon::parse($since) : null; + + $changed = fn ($query) => $since ? $query->where('updated_at', '>', $since) : $query; + + $allPhaseIds = Phase::withTrashed()->where('project_id', $project->id)->pluck('id'); + $allLayerIds = Layer::withTrashed()->whereIn('phase_id', $allPhaseIds)->pluck('id'); + + $phases = $changed(Phase::where('project_id', $project->id))->orderBy('order')->get(); + $layers = $changed(Layer::whereIn('phase_id', $allPhaseIds))->get(); + $features = $changed(Feature::whereIn('layer_id', $allLayerIds))->get(); + $inspections = $changed(Inspection::where('project_id', $project->id))->get(); + $issues = $changed(Issue::where('project_id', $project->id))->get(); + $templates = $changed(InspectionTemplate::where('project_id', $project->id))->get(); + + return response()->json([ + 'server_time' => now()->toIso8601String(), + 'project' => $this->mapProject($project), + 'phases' => $phases->map(fn ($p) => $this->mapPhase($p))->values(), + 'layers' => $layers->map(fn ($l) => $this->mapLayer($l))->values(), + 'features' => $features->map(fn ($f) => $this->mapFeature($f))->values(), + 'inspections' => $inspections->map(fn ($i) => $this->mapInspection($i))->values(), + 'issues' => $issues->map(fn ($i) => $this->mapIssue($i))->values(), + 'templates' => $templates->map(fn ($t) => $this->mapTemplate($t))->values(), + 'deleted' => $since ? $this->tombstones($since, $project, $allPhaseIds, $allLayerIds) : (object) [], + ]); + } + + /** Inspection templates for the projects the user can access (with version/hash). */ + public function templates(Request $request) + { + $since = $request->query('since'); + $since = $since ? Carbon::parse($since) : null; + + $projectIds = Project::accessibleBy($request->user())->pluck('id'); + + $query = InspectionTemplate::whereIn('project_id', $projectIds); + if ($since) { + $query->where('updated_at', '>', $since); + } + + return response()->json([ + 'templates' => $query->get()->map(fn ($t) => $this->mapTemplate($t))->values(), + ]); + } + + // ── Helpers ──────────────────────────────────────────────────────────────── + + private function authorizeProject(Request $request, Project $project): void { $user = $request->user(); abort_unless( $user->can('manage all') || $project->users()->where('user_id', $user->id)->exists(), 403 ); + } - $project->load([ - 'phases' => fn ($q) => $q->orderBy('order'), - 'phases.layers', - 'phases.layers.features', - ]); + private function tombstones(Carbon $since, Project $project, $allPhaseIds, $allLayerIds): array + { + return [ + 'phases' => Phase::onlyTrashed()->where('project_id', $project->id)->where('deleted_at', '>', $since)->pluck('id')->values(), + 'layers' => Layer::onlyTrashed()->whereIn('phase_id', $allPhaseIds)->where('deleted_at', '>', $since)->pluck('id')->values(), + 'features' => Feature::onlyTrashed()->whereIn('layer_id', $allLayerIds)->where('deleted_at', '>', $since)->pluck('id')->values(), + 'inspections' => Inspection::onlyTrashed()->where('project_id', $project->id)->where('deleted_at', '>', $since)->pluck('id')->values(), + 'issues' => Issue::onlyTrashed()->where('project_id', $project->id)->where('deleted_at', '>', $since)->pluck('id')->values(), + ]; + } - $layers = $project->phases->flatMap->layers; - $features = $layers->flatMap->features; + private function mapProject(Project $p): array + { + return [ + 'id' => $p->id, 'reference' => $p->reference, 'name' => $p->name, + 'address' => $p->address, 'lat' => $p->lat, 'lng' => $p->lng, + 'status' => $p->status, 'updated_at' => $p->updated_at?->toIso8601String(), + ]; + } - return response()->json([ - 'server_time' => now()->toIso8601String(), - 'project' => [ - 'id' => $project->id, - 'reference' => $project->reference, - 'name' => $project->name, - 'address' => $project->address, - 'lat' => $project->lat, - 'lng' => $project->lng, - 'status' => $project->status, - 'updated_at' => $project->updated_at?->toIso8601String(), - ], - 'phases' => $project->phases->map(fn ($p) => [ - 'id' => $p->id, - 'name' => $p->name, - 'order' => $p->order, - 'color' => $p->color, - 'progress_percent' => $p->progress_percent, - 'updated_at' => $p->updated_at?->toIso8601String(), - ])->values(), - 'layers' => $layers->map(fn ($l) => [ - 'id' => $l->id, - 'phase_id' => $l->phase_id, - 'name' => $l->name, - 'color' => $l->color, - 'updated_at' => $l->updated_at?->toIso8601String(), - ])->values(), - 'features' => $features->map(fn ($f) => [ - 'id' => $f->id, - 'layer_id' => $f->layer_id, - 'name' => $f->name, - 'geometry' => $f->geometry, - 'status' => $f->status, - 'progress' => $f->progress, - 'updated_at' => $f->updated_at?->toIso8601String(), - ])->values(), - ]); + private function mapPhase(Phase $p): array + { + return [ + 'id' => $p->id, 'name' => $p->name, 'order' => $p->order, 'color' => $p->color, + 'progress_percent' => $p->progress_percent, 'updated_at' => $p->updated_at?->toIso8601String(), + ]; + } + + private function mapLayer(Layer $l): array + { + return [ + 'id' => $l->id, 'phase_id' => $l->phase_id, 'name' => $l->name, + 'color' => $l->color, 'updated_at' => $l->updated_at?->toIso8601String(), + ]; + } + + private function mapFeature(Feature $f): array + { + return [ + 'id' => $f->id, 'layer_id' => $f->layer_id, 'name' => $f->name, + 'geometry' => $f->geometry, 'status' => $f->status, 'progress' => $f->progress, + 'responsible' => $f->responsible, 'template_id' => $f->template_id, + 'updated_at' => $f->updated_at?->toIso8601String(), + ]; + } + + private function mapInspection(Inspection $i): array + { + return [ + 'id' => $i->id, 'feature_id' => $i->feature_id, 'layer_id' => $i->layer_id, + 'template_id' => $i->template_id, 'user_id' => $i->user_id, 'data' => $i->data, + 'status' => $i->status, 'result' => $i->result, 'notes' => $i->notes, + 'created_at' => $i->created_at?->toIso8601String(), 'updated_at' => $i->updated_at?->toIso8601String(), + ]; + } + + private function mapIssue(Issue $i): array + { + return [ + 'id' => $i->id, 'feature_id' => $i->feature_id, 'title' => $i->title, + 'description' => $i->description, 'status' => $i->status, 'priority' => $i->priority, + 'reported_by' => $i->reported_by, 'assigned_to' => $i->assigned_to, + 'resolved_at' => $i->resolved_at?->toIso8601String(), 'updated_at' => $i->updated_at?->toIso8601String(), + ]; + } + + private function mapTemplate(InspectionTemplate $t): array + { + return [ + 'id' => $t->id, 'project_id' => $t->project_id, 'phase_id' => $t->phase_id, + 'name' => $t->name, 'description' => $t->description, 'fields' => $t->fields, + 'version' => $t->updated_at?->timestamp, + 'hash' => md5(json_encode($t->fields) . $t->name), + 'updated_at' => $t->updated_at?->toIso8601String(), + ]; } } diff --git a/routes/api.php b/routes/api.php index d798a05..6c96489 100644 --- a/routes/api.php +++ b/routes/api.php @@ -18,6 +18,7 @@ Route::prefix('v1')->group(function () { // PULL Route::get('projects', [ProjectApiController::class, 'index']); Route::get('projects/{project}/bundle', [ProjectApiController::class, 'bundle']); + Route::get('templates', [ProjectApiController::class, 'templates']); // PUSH Route::post('sync', [SyncController::class, 'sync'])->middleware('throttle:60,1'); diff --git a/tests/Feature/Api/MobileApiTest.php b/tests/Feature/Api/MobileApiTest.php index 3938886..41ba0f5 100644 --- a/tests/Feature/Api/MobileApiTest.php +++ b/tests/Feature/Api/MobileApiTest.php @@ -2,11 +2,15 @@ namespace Tests\Feature\Api; +use App\Models\Feature; +use App\Models\InspectionTemplate; +use App\Models\Layer; use App\Models\Phase; use App\Models\Project; use App\Models\ProgressUpdate; use App\Models\User; use Illuminate\Foundation\Testing\RefreshDatabase; +use Illuminate\Support\Carbon; use Illuminate\Support\Str; use Laravel\Sanctum\Sanctum; use Spatie\Permission\Models\Permission; @@ -44,17 +48,39 @@ class MobileApiTest extends TestCase return $project; } - private function makePhase(Project $project): Phase + private function makePhase(Project $project, string $name = 'Fase 1'): Phase { return Phase::create([ 'project_id' => $project->id, - 'name' => 'Fase 1', + 'name' => $name, 'order' => 1, 'color' => '#3b82f6', 'progress_percent' => 0, ]); } + private function makeLayer(Phase $phase): Layer + { + return Layer::create([ + 'project_id' => $phase->project_id, + 'phase_id' => $phase->id, + 'name' => 'Capa 1', + 'color' => '#10b981', + 'uploaded_by' => $phase->project->created_by, + ]); + } + + private function makeFeature(Layer $layer): Feature + { + return Feature::create([ + 'layer_id' => $layer->id, + 'name' => 'Elemento 1', + 'geometry' => ['type' => 'Point', 'coordinates' => [-3.0, 40.0]], + 'progress' => 0, + 'status' => 'planned', + ]); + } + // ── Auth ─────────────────────────────────────────────────────────────────── public function test_login_returns_a_token(): void @@ -188,4 +214,67 @@ class MobileApiTest extends TestCase $this->assertDatabaseCount('progress_updates', 0); } + + // ── Pull: delta + tombstones + templates (Milestone 3) ─────────────────────── + + public function test_bundle_delta_returns_only_changed_records(): void + { + $user = User::factory()->create(); + + Carbon::setTestNow('2026-06-18 10:00:00'); + $project = $this->makeProject($user); + $old = $this->makePhase($project, 'Fase vieja'); + + $since = Carbon::parse('2026-06-18 10:00:30'); + + Carbon::setTestNow('2026-06-18 10:05:00'); + $new = $this->makePhase($project, 'Fase nueva'); + + Sanctum::actingAs($user, ['mobile-sync']); + $res = $this->getJson("/api/v1/projects/{$project->id}/bundle?since=" . urlencode($since->toIso8601String()))->assertOk(); + + $ids = collect($res->json('phases'))->pluck('id'); + $this->assertTrue($ids->contains($new->id)); + $this->assertFalse($ids->contains($old->id)); + + Carbon::setTestNow(); + } + + public function test_bundle_delta_includes_tombstones_for_deletions(): void + { + $user = User::factory()->create(); + + Carbon::setTestNow('2026-06-18 10:00:00'); + $project = $this->makeProject($user); + $feature = $this->makeFeature($this->makeLayer($this->makePhase($project))); + + $since = Carbon::parse('2026-06-18 10:00:30'); + + Carbon::setTestNow('2026-06-18 10:05:00'); + $feature->delete(); // soft delete + + Sanctum::actingAs($user, ['mobile-sync']); + $res = $this->getJson("/api/v1/projects/{$project->id}/bundle?since=" . urlencode($since->toIso8601String()))->assertOk(); + + $this->assertContains($feature->id, $res->json('deleted.features')); + + Carbon::setTestNow(); + } + + public function test_templates_endpoint_returns_accessible_templates_with_hash(): void + { + $user = User::factory()->create(); + $project = $this->makeProject($user); + InspectionTemplate::create([ + 'project_id' => $project->id, + 'name' => 'Plantilla A', + 'fields' => [['name' => 'ok', 'label' => 'OK', 'type' => 'boolean']], + ]); + + Sanctum::actingAs($user, ['mobile-sync']); + $res = $this->getJson('/api/v1/templates')->assertOk(); + + $this->assertTrue(collect($res->json('templates'))->pluck('name')->contains('Plantilla A')); + $this->assertNotNull($res->json('templates.0.hash')); + } }