feat(api): mobile API Milestone 3 — full PULL (delta, tombstones, templates)
ProjectApiController bundle now supports incremental sync: - ?since=<ISO8601> returns only records changed after that time (phases, layers, features, inspections, issues, templates), each filtered by its own updated_at. - 'deleted' tombstones (soft-deleted ids since 'since') for phases/layers/ features/inspections/issues so the device can purge locally. - Bundle now also includes inspections, issues and inspection templates (with version + content hash for incremental template download). - New GET /api/v1/templates (accessible projects, ?since= delta). Tests: 12 passing (added delta, tombstones, templates cases). Note: the 'since' query param must be URL-encoded by clients (ISO8601 '+' offset). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -3,7 +3,14 @@
|
|||||||
namespace App\Http\Controllers\Api\V1;
|
namespace App\Http\Controllers\Api\V1;
|
||||||
|
|
||||||
use App\Http\Controllers\Controller;
|
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 App\Models\Project;
|
||||||
|
use Carbon\Carbon;
|
||||||
use Illuminate\Http\Request;
|
use Illuminate\Http\Request;
|
||||||
|
|
||||||
class ProjectApiController extends Controller
|
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)
|
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();
|
$user = $request->user();
|
||||||
abort_unless(
|
abort_unless(
|
||||||
$user->can('manage all') || $project->users()->where('user_id', $user->id)->exists(),
|
$user->can('manage all') || $project->users()->where('user_id', $user->id)->exists(),
|
||||||
403
|
403
|
||||||
);
|
);
|
||||||
|
}
|
||||||
|
|
||||||
$project->load([
|
private function tombstones(Carbon $since, Project $project, $allPhaseIds, $allLayerIds): array
|
||||||
'phases' => fn ($q) => $q->orderBy('order'),
|
{
|
||||||
'phases.layers',
|
return [
|
||||||
'phases.layers.features',
|
'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;
|
private function mapProject(Project $p): array
|
||||||
$features = $layers->flatMap->features;
|
{
|
||||||
|
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([
|
private function mapPhase(Phase $p): array
|
||||||
'server_time' => now()->toIso8601String(),
|
{
|
||||||
'project' => [
|
return [
|
||||||
'id' => $project->id,
|
'id' => $p->id, 'name' => $p->name, 'order' => $p->order, 'color' => $p->color,
|
||||||
'reference' => $project->reference,
|
'progress_percent' => $p->progress_percent, 'updated_at' => $p->updated_at?->toIso8601String(),
|
||||||
'name' => $project->name,
|
];
|
||||||
'address' => $project->address,
|
}
|
||||||
'lat' => $project->lat,
|
|
||||||
'lng' => $project->lng,
|
private function mapLayer(Layer $l): array
|
||||||
'status' => $project->status,
|
{
|
||||||
'updated_at' => $project->updated_at?->toIso8601String(),
|
return [
|
||||||
],
|
'id' => $l->id, 'phase_id' => $l->phase_id, 'name' => $l->name,
|
||||||
'phases' => $project->phases->map(fn ($p) => [
|
'color' => $l->color, 'updated_at' => $l->updated_at?->toIso8601String(),
|
||||||
'id' => $p->id,
|
];
|
||||||
'name' => $p->name,
|
}
|
||||||
'order' => $p->order,
|
|
||||||
'color' => $p->color,
|
private function mapFeature(Feature $f): array
|
||||||
'progress_percent' => $p->progress_percent,
|
{
|
||||||
'updated_at' => $p->updated_at?->toIso8601String(),
|
return [
|
||||||
])->values(),
|
'id' => $f->id, 'layer_id' => $f->layer_id, 'name' => $f->name,
|
||||||
'layers' => $layers->map(fn ($l) => [
|
'geometry' => $f->geometry, 'status' => $f->status, 'progress' => $f->progress,
|
||||||
'id' => $l->id,
|
'responsible' => $f->responsible, 'template_id' => $f->template_id,
|
||||||
'phase_id' => $l->phase_id,
|
'updated_at' => $f->updated_at?->toIso8601String(),
|
||||||
'name' => $l->name,
|
];
|
||||||
'color' => $l->color,
|
}
|
||||||
'updated_at' => $l->updated_at?->toIso8601String(),
|
|
||||||
])->values(),
|
private function mapInspection(Inspection $i): array
|
||||||
'features' => $features->map(fn ($f) => [
|
{
|
||||||
'id' => $f->id,
|
return [
|
||||||
'layer_id' => $f->layer_id,
|
'id' => $i->id, 'feature_id' => $i->feature_id, 'layer_id' => $i->layer_id,
|
||||||
'name' => $f->name,
|
'template_id' => $i->template_id, 'user_id' => $i->user_id, 'data' => $i->data,
|
||||||
'geometry' => $f->geometry,
|
'status' => $i->status, 'result' => $i->result, 'notes' => $i->notes,
|
||||||
'status' => $f->status,
|
'created_at' => $i->created_at?->toIso8601String(), 'updated_at' => $i->updated_at?->toIso8601String(),
|
||||||
'progress' => $f->progress,
|
];
|
||||||
'updated_at' => $f->updated_at?->toIso8601String(),
|
}
|
||||||
])->values(),
|
|
||||||
]);
|
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(),
|
||||||
|
];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ Route::prefix('v1')->group(function () {
|
|||||||
// PULL
|
// PULL
|
||||||
Route::get('projects', [ProjectApiController::class, 'index']);
|
Route::get('projects', [ProjectApiController::class, 'index']);
|
||||||
Route::get('projects/{project}/bundle', [ProjectApiController::class, 'bundle']);
|
Route::get('projects/{project}/bundle', [ProjectApiController::class, 'bundle']);
|
||||||
|
Route::get('templates', [ProjectApiController::class, 'templates']);
|
||||||
|
|
||||||
// PUSH
|
// PUSH
|
||||||
Route::post('sync', [SyncController::class, 'sync'])->middleware('throttle:60,1');
|
Route::post('sync', [SyncController::class, 'sync'])->middleware('throttle:60,1');
|
||||||
|
|||||||
@@ -2,11 +2,15 @@
|
|||||||
|
|
||||||
namespace Tests\Feature\Api;
|
namespace Tests\Feature\Api;
|
||||||
|
|
||||||
|
use App\Models\Feature;
|
||||||
|
use App\Models\InspectionTemplate;
|
||||||
|
use App\Models\Layer;
|
||||||
use App\Models\Phase;
|
use App\Models\Phase;
|
||||||
use App\Models\Project;
|
use App\Models\Project;
|
||||||
use App\Models\ProgressUpdate;
|
use App\Models\ProgressUpdate;
|
||||||
use App\Models\User;
|
use App\Models\User;
|
||||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||||
|
use Illuminate\Support\Carbon;
|
||||||
use Illuminate\Support\Str;
|
use Illuminate\Support\Str;
|
||||||
use Laravel\Sanctum\Sanctum;
|
use Laravel\Sanctum\Sanctum;
|
||||||
use Spatie\Permission\Models\Permission;
|
use Spatie\Permission\Models\Permission;
|
||||||
@@ -44,17 +48,39 @@ class MobileApiTest extends TestCase
|
|||||||
return $project;
|
return $project;
|
||||||
}
|
}
|
||||||
|
|
||||||
private function makePhase(Project $project): Phase
|
private function makePhase(Project $project, string $name = 'Fase 1'): Phase
|
||||||
{
|
{
|
||||||
return Phase::create([
|
return Phase::create([
|
||||||
'project_id' => $project->id,
|
'project_id' => $project->id,
|
||||||
'name' => 'Fase 1',
|
'name' => $name,
|
||||||
'order' => 1,
|
'order' => 1,
|
||||||
'color' => '#3b82f6',
|
'color' => '#3b82f6',
|
||||||
'progress_percent' => 0,
|
'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 ───────────────────────────────────────────────────────────────────
|
// ── Auth ───────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
public function test_login_returns_a_token(): void
|
public function test_login_returns_a_token(): void
|
||||||
@@ -188,4 +214,67 @@ class MobileApiTest extends TestCase
|
|||||||
|
|
||||||
$this->assertDatabaseCount('progress_updates', 0);
|
$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'));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user