From 14758136b6ca1bc7c5a9563a2622127a11e60f34 Mon Sep 17 00:00:00 2001 From: javier Date: Thu, 18 Jun 2026 10:23:50 +0200 Subject: [PATCH] =?UTF-8?q?feat(api):=20mobile=20API=20Milestones=205+6=20?= =?UTF-8?q?=E2=80=94=20media=20upload,=20sync=5Flogs=20idempotency,=20Open?= =?UTF-8?q?API?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Milestone 5 (media): - POST /api/v1/media — multipart upload, attaches to feature/issue/project/ phase/layer, idempotent by uuid, authz member + 'upload media'. Added uuid+client_updated_at to media. - Bundle now includes a 'media' array (URLs) for the project's project/feature/ issue attachments (delta-aware). Milestone 6 (hardening + docs): - sync_logs table/model: every applied op is logged; /sync short-circuits on a repeated op uuid -> 'duplicate' (true idempotency for updates too, not just creates). - Rate limiting on login (10/min), sync (60/min), media (120/min). - docs/openapi.yaml: OpenAPI 3 contract for the mobile team. Tests: 18 passing (added media upload idempotency + sync_logs idempotency). The mobile API (Milestones 1-6) is now feature-complete on the webapp side. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../Controllers/Api/V1/MediaController.php | 106 ++++++++++ .../Api/V1/ProjectApiController.php | 27 +++ .../Controllers/Api/V1/SyncController.php | 26 ++- app/Models/Media.php | 1 + app/Models/SyncLog.php | 12 ++ ..._080000_add_sync_fields_to_media_table.php | 31 +++ ...26_06_18_080100_create_sync_logs_table.php | 31 +++ docs/openapi.yaml | 198 ++++++++++++++++++ routes/api.php | 2 + tests/Feature/Api/MobileApiTest.php | 59 +++++- 10 files changed, 490 insertions(+), 3 deletions(-) create mode 100644 app/Http/Controllers/Api/V1/MediaController.php create mode 100644 app/Models/SyncLog.php create mode 100644 database/migrations/2026_06_18_080000_add_sync_fields_to_media_table.php create mode 100644 database/migrations/2026_06_18_080100_create_sync_logs_table.php create mode 100644 docs/openapi.yaml diff --git a/app/Http/Controllers/Api/V1/MediaController.php b/app/Http/Controllers/Api/V1/MediaController.php new file mode 100644 index 0000000..6c27fe1 --- /dev/null +++ b/app/Http/Controllers/Api/V1/MediaController.php @@ -0,0 +1,106 @@ + Feature::class, + 'issue' => Issue::class, + 'project' => Project::class, + 'phase' => Phase::class, + 'layer' => Layer::class, + ]; + + /** Upload a file (multipart) and attach it to a parent record. Idempotent by uuid. */ + public function upload(Request $request) + { + $data = $request->validate([ + 'uuid' => ['required', 'uuid'], + 'parent_entity' => ['required', Rule::in(array_keys($this->map))], + 'parent_id' => ['required', 'integer'], + 'file' => ['required', 'file', 'max:20480'], // 20 MB + 'category' => ['nullable', 'in:image,document,other'], + 'description' => ['nullable', 'string'], + ]); + + // Idempotency: same uuid already uploaded → return it. + if ($existing = Media::where('uuid', $data['uuid'])->first()) { + return response()->json(['status' => 'duplicate', 'media' => $this->payload($existing)]); + } + + $parent = $this->map[$data['parent_entity']]::find($data['parent_id']); + if (! $parent) { + return response()->json(['status' => 'error', 'error' => 'parent not found'], 422); + } + + $user = $request->user(); + $project = $this->projectOf($data['parent_entity'], $parent); + abort_unless($this->canAccess($user, $project) && $user->can('upload media'), 403); + + $file = $request->file('file'); + $path = $file->store("media/{$data['parent_entity']}/{$parent->id}", 'public'); + $mime = $file->getClientMimeType(); + + $media = $parent->media()->create([ + 'uuid' => $data['uuid'], + 'name' => $file->getClientOriginalName(), + 'file_path' => $path, + 'file_type' => $mime, + 'file_extension' => $file->getClientOriginalExtension(), + 'file_size' => $file->getSize(), + 'category' => $data['category'] ?? (Str::startsWith($mime, 'image/') ? 'image' : 'document'), + 'description' => $data['description'] ?? null, + 'uploaded_by' => $user->id, + 'client_updated_at' => $request->input('client_updated_at'), + ]); + + return response()->json(['status' => 'applied', 'media' => $this->payload($media)]); + } + + private function projectOf(string $entity, $parent): ?Project + { + return match ($entity) { + 'project' => $parent, + 'phase' => $parent->project, + 'layer' => $parent->phase?->project, + 'feature' => $parent->layer?->phase?->project, + 'issue' => $parent->project, + default => null, + }; + } + + 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(); + } + + private function payload(Media $m): array + { + return [ + 'id' => $m->id, + 'uuid' => $m->uuid, + 'url' => $m->url, + 'name' => $m->name, + 'file_type' => $m->file_type, + 'category' => $m->category, + 'updated_at' => $m->updated_at?->toIso8601String(), + ]; + } +} diff --git a/app/Http/Controllers/Api/V1/ProjectApiController.php b/app/Http/Controllers/Api/V1/ProjectApiController.php index 687b444..fa80213 100644 --- a/app/Http/Controllers/Api/V1/ProjectApiController.php +++ b/app/Http/Controllers/Api/V1/ProjectApiController.php @@ -8,6 +8,7 @@ use App\Models\Inspection; use App\Models\InspectionTemplate; use App\Models\Issue; use App\Models\Layer; +use App\Models\Media; use App\Models\Phase; use App\Models\Project; use Carbon\Carbon; @@ -48,6 +49,14 @@ class ProjectApiController extends Controller $issues = $changed(Issue::where('project_id', $project->id))->get(); $templates = $changed(InspectionTemplate::where('project_id', $project->id))->get(); + $featureIds = Feature::whereIn('layer_id', $allLayerIds)->pluck('id'); + $issueIds = Issue::where('project_id', $project->id)->pluck('id'); + $media = $changed(Media::where(function ($q) use ($project, $featureIds, $issueIds) { + $q->where(fn ($w) => $w->where('mediable_type', Project::class)->where('mediable_id', $project->id)) + ->orWhere(fn ($w) => $w->where('mediable_type', Feature::class)->whereIn('mediable_id', $featureIds)) + ->orWhere(fn ($w) => $w->where('mediable_type', Issue::class)->whereIn('mediable_id', $issueIds)); + }))->get(); + return response()->json([ 'server_time' => now()->toIso8601String(), 'project' => $this->mapProject($project), @@ -57,6 +66,7 @@ class ProjectApiController extends Controller '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(), + 'media' => $media->map(fn ($m) => $this->mapMedia($m))->values(), 'deleted' => $since ? $this->tombstones($since, $project, $allPhaseIds, $allLayerIds) : (object) [], ]); } @@ -166,4 +176,21 @@ class ProjectApiController extends Controller 'updated_at' => $t->updated_at?->toIso8601String(), ]; } + + private function mapMedia(Media $m): array + { + $entity = [ + Project::class => 'project', + Feature::class => 'feature', + Issue::class => 'issue', + ][$m->mediable_type] ?? class_basename($m->mediable_type); + + return [ + 'id' => $m->id, 'uuid' => $m->uuid, + 'parent_entity' => $entity, 'parent_id' => $m->mediable_id, + 'url' => $m->url, 'name' => $m->name, 'file_type' => $m->file_type, + 'category' => $m->category, 'updated_at' => $m->updated_at?->toIso8601String(), + ]; + } } + diff --git a/app/Http/Controllers/Api/V1/SyncController.php b/app/Http/Controllers/Api/V1/SyncController.php index 893d43e..716a1ff 100644 --- a/app/Http/Controllers/Api/V1/SyncController.php +++ b/app/Http/Controllers/Api/V1/SyncController.php @@ -9,6 +9,7 @@ use App\Models\Issue; use App\Models\Phase; use App\Models\ProgressUpdate; use App\Models\Project; +use App\Models\SyncLog; use App\Models\User; use Carbon\Carbon; use Illuminate\Http\Request; @@ -49,8 +50,15 @@ class SyncController extends Controller { $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 { - return match ($op['entity'] . '.' . $op['op']) { + $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), @@ -59,8 +67,22 @@ class SyncController extends Controller default => $this->error($uuid, 'unsupported entity/op: ' . $op['entity'] . '.' . $op['op']), }; } catch (\Throwable $e) { - return $this->error($uuid, $e->getMessage()); + $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 ─────────────────────────────────────────────────── diff --git a/app/Models/Media.php b/app/Models/Media.php index 677c8cf..02c6cc6 100644 --- a/app/Models/Media.php +++ b/app/Models/Media.php @@ -13,6 +13,7 @@ class Media extends Model 'mediable_type', 'mediable_id', 'name', 'file_path', 'file_type', 'file_extension', 'file_size', 'category', 'description', 'metadata', 'uploaded_by', + 'uuid', 'client_updated_at', ]; protected $casts = [ diff --git a/app/Models/SyncLog.php b/app/Models/SyncLog.php new file mode 100644 index 0000000..6213440 --- /dev/null +++ b/app/Models/SyncLog.php @@ -0,0 +1,12 @@ +uuid('uuid')->nullable()->unique()->after('id'); + } + if (! Schema::hasColumn('media', 'client_updated_at')) { + $table->timestamp('client_updated_at')->nullable(); + } + }); + } + + public function down(): void + { + Schema::table('media', function (Blueprint $table) { + foreach (['uuid', 'client_updated_at'] as $col) { + if (Schema::hasColumn('media', $col)) { + $table->dropColumn($col); + } + } + }); + } +}; diff --git a/database/migrations/2026_06_18_080100_create_sync_logs_table.php b/database/migrations/2026_06_18_080100_create_sync_logs_table.php new file mode 100644 index 0000000..590de0e --- /dev/null +++ b/database/migrations/2026_06_18_080100_create_sync_logs_table.php @@ -0,0 +1,31 @@ +id(); + $table->foreignId('user_id')->nullable()->constrained()->nullOnDelete(); + $table->uuid('op_uuid')->index(); // idempotency key of the operation + $table->string('entity'); + $table->string('op'); + $table->string('status'); // applied | duplicate | conflict | error + $table->unsignedBigInteger('server_id')->nullable(); + $table->text('error')->nullable(); + $table->timestamps(); + + // One processed result per (entity, op, op_uuid). + $table->unique(['entity', 'op', 'op_uuid']); + }); + } + + public function down(): void + { + Schema::dropIfExists('sync_logs'); + } +}; diff --git a/docs/openapi.yaml b/docs/openapi.yaml new file mode 100644 index 0000000..b2eee13 --- /dev/null +++ b/docs/openapi.yaml @@ -0,0 +1,198 @@ +openapi: 3.0.3 +info: + title: ConstruProgress Mobile API + version: "1.0.0" + description: > + Offline-first sync API for the mobile app. Auth via Laravel Sanctum bearer + tokens (ability `mobile-sync`). All protected endpoints require + `Authorization: Bearer `. See docs/MOBILE_SYNC_PROTOCOL.md. +servers: + - url: /api/v1 +security: + - bearerAuth: [] +paths: + /login: + post: + summary: Issue a device token + security: [] + requestBody: + required: true + content: + application/json: + schema: + type: object + required: [email, password, device_name] + properties: + email: { type: string, format: email } + password: { type: string } + device_name: { type: string } + app_version: { type: string, nullable: true } + responses: + "200": + description: Token issued + content: + application/json: + schema: + type: object + properties: + token: { type: string } + user: { $ref: '#/components/schemas/User' } + "422": { description: Invalid credentials } + /me: + get: + summary: Current user + effective permissions + responses: + "200": + description: OK + content: + application/json: + schema: + type: object + properties: + user: { $ref: '#/components/schemas/User' } + "401": { description: Unauthenticated } + /logout: + post: + summary: Revoke the current device token + responses: + "200": { description: Logged out } + /projects: + get: + summary: Projects the user can access + responses: + "200": { description: OK } + /projects/{project}/bundle: + get: + summary: Offline bundle (full, or delta when `since` is given) + parameters: + - name: project + in: path + required: true + schema: { type: integer } + - name: since + in: query + required: false + description: > + ISO8601 timestamp. Returns only records changed after it, plus + `deleted` tombstones. MUST be URL-encoded (the `+` offset). + schema: { type: string, format: date-time } + responses: + "200": + description: Bundle + content: + application/json: + schema: { $ref: '#/components/schemas/Bundle' } + "403": { description: Not a member of the project } + /templates: + get: + summary: Inspection templates for accessible projects (with version/hash) + parameters: + - name: since + in: query + required: false + schema: { type: string, format: date-time } + responses: + "200": { description: OK } + /sync: + post: + summary: Push a batch of offline mutations (idempotent by uuid) + requestBody: + required: true + content: + application/json: + schema: + type: object + required: [operations] + properties: + operations: + type: array + items: { $ref: '#/components/schemas/Operation' } + responses: + "200": + description: Per-operation results + content: + application/json: + schema: + type: object + properties: + results: + type: array + items: { $ref: '#/components/schemas/OperationResult' } + /media: + post: + summary: Upload a file (multipart) and attach it to a parent record + requestBody: + required: true + content: + multipart/form-data: + schema: + type: object + required: [uuid, parent_entity, parent_id, file] + properties: + uuid: { type: string, format: uuid } + parent_entity: { type: string, enum: [feature, issue, project, phase, layer] } + parent_id: { type: integer } + file: { type: string, format: binary } + category: { type: string, enum: [image, document, other] } + description: { type: string } + responses: + "200": { description: applied | duplicate } + "403": { description: Forbidden } +components: + securitySchemes: + bearerAuth: + type: http + scheme: bearer + schemas: + User: + type: object + properties: + id: { type: integer } + name: { type: string } + email: { type: string } + roles: { type: array, items: { type: string } } + permissions: { type: array, items: { type: string } } + Operation: + type: object + required: [entity, op, uuid, data] + properties: + entity: { type: string, enum: [progress_update, inspection, issue, feature] } + op: { type: string, enum: [create, update] } + uuid: { type: string, format: uuid, description: client-generated idempotency key } + client_updated_at: { type: string, format: date-time } + data: { type: object } + example: + entity: feature + op: update + uuid: 0f8e...-uuid + client_updated_at: "2026-06-18T12:00:00+00:00" + data: { id: 5, status: completed, progress: 100 } + OperationResult: + type: object + properties: + uuid: { type: string, format: uuid } + status: { type: string, enum: [applied, duplicate, conflict, error] } + server_id: { type: integer, nullable: true } + error: { type: string, nullable: true } + server: { type: object, nullable: true, description: current server value on conflict } + Bundle: + type: object + properties: + server_time: { type: string, format: date-time } + project: { type: object } + phases: { type: array, items: { type: object } } + layers: { type: array, items: { type: object } } + features: { type: array, items: { type: object } } + inspections: { type: array, items: { type: object } } + issues: { type: array, items: { type: object } } + templates: { type: array, items: { type: object } } + media: { type: array, items: { type: object } } + deleted: + type: object + description: tombstones (ids of soft-deleted records) when `since` is given + properties: + phases: { type: array, items: { type: integer } } + layers: { type: array, items: { type: integer } } + features: { type: array, items: { type: integer } } + inspections: { type: array, items: { type: integer } } + issues: { type: array, items: { type: integer } } diff --git a/routes/api.php b/routes/api.php index 6c96489..5281777 100644 --- a/routes/api.php +++ b/routes/api.php @@ -1,6 +1,7 @@ group(function () { // PUSH Route::post('sync', [SyncController::class, 'sync'])->middleware('throttle:60,1'); + Route::post('media', [MediaController::class, 'upload'])->middleware('throttle:120,1'); }); }); diff --git a/tests/Feature/Api/MobileApiTest.php b/tests/Feature/Api/MobileApiTest.php index 1a15e36..a7825b5 100644 --- a/tests/Feature/Api/MobileApiTest.php +++ b/tests/Feature/Api/MobileApiTest.php @@ -11,8 +11,11 @@ use App\Models\Phase; use App\Models\Project; use App\Models\ProgressUpdate; use App\Models\User; +use App\Models\SyncLog; use Illuminate\Foundation\Testing\RefreshDatabase; +use Illuminate\Http\UploadedFile; use Illuminate\Support\Carbon; +use Illuminate\Support\Facades\Storage; use Illuminate\Support\Str; use Laravel\Sanctum\Sanctum; use Spatie\Permission\Models\Permission; @@ -25,7 +28,7 @@ class MobileApiTest extends TestCase protected function setUp(): void { parent::setUp(); - foreach (['update progress', 'manage all', 'create inspections', 'create issues', 'edit issues'] as $p) { + foreach (['update progress', 'manage all', 'create inspections', 'create issues', 'edit issues', 'upload media'] as $p) { Permission::findOrCreate($p); } } @@ -371,4 +374,58 @@ class MobileApiTest extends TestCase $res->assertJsonPath('results.0.status', 'conflict'); $this->assertEquals(0, $feature->fresh()->progress); // unchanged } + + // ── Media (Milestone 5) + op idempotency via sync_logs (Milestone 6) ───────── + + public function test_media_upload_attaches_to_feature_and_is_idempotent(): void + { + Storage::fake('public'); + + $user = User::factory()->create(); + $user->givePermissionTo('upload media'); + $project = $this->makeProject($user); + $feature = $this->makeFeature($this->makeLayer($this->makePhase($project))); + $uuid = (string) Str::uuid(); + + Sanctum::actingAs($user, ['mobile-sync']); + $payload = [ + 'uuid' => $uuid, 'parent_entity' => 'feature', 'parent_id' => $feature->id, + 'file' => UploadedFile::fake()->image('foto.jpg'), + ]; + + $this->post('/api/v1/media', $payload)->assertOk()->assertJsonPath('status', 'applied'); + $this->assertDatabaseHas('media', [ + 'uuid' => $uuid, 'mediable_type' => Feature::class, 'mediable_id' => $feature->id, + ]); + + // Re-send same uuid → duplicate, no second row + $this->post('/api/v1/media', [ + 'uuid' => $uuid, 'parent_entity' => 'feature', 'parent_id' => $feature->id, + 'file' => UploadedFile::fake()->image('foto.jpg'), + ])->assertOk()->assertJsonPath('status', 'duplicate'); + + $this->assertEquals(1, \App\Models\Media::where('uuid', $uuid)->count()); + } + + public function test_sync_operation_is_idempotent_via_sync_logs(): void + { + $user = User::factory()->create(); + $user->givePermissionTo('update progress'); + $project = $this->makeProject($user); + $feature = $this->makeFeature($this->makeLayer($this->makePhase($project))); + $opUuid = (string) Str::uuid(); + + Sanctum::actingAs($user, ['mobile-sync']); + $op = ['operations' => [[ + 'entity' => 'feature', 'op' => 'update', 'uuid' => $opUuid, + 'data' => ['id' => $feature->id, 'progress' => 60], + ]]]; + + $this->postJson('/api/v1/sync', $op)->assertOk()->assertJsonPath('results.0.status', 'applied'); + $this->assertEquals(1, SyncLog::where('op_uuid', $opUuid)->count()); + + // Replaying the same operation uuid → duplicate (served from sync_logs) + $this->postJson('/api/v1/sync', $op)->assertOk()->assertJsonPath('results.0.status', 'duplicate'); + $this->assertEquals(1, SyncLog::where('op_uuid', $opUuid)->count()); + } }