feat(api): mobile API Milestones 5+6 — media upload, sync_logs idempotency, OpenAPI

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) <noreply@anthropic.com>
This commit is contained in:
2026-06-18 10:23:50 +02:00
parent 9d2b63c8f4
commit 14758136b6
10 changed files with 490 additions and 3 deletions
@@ -0,0 +1,106 @@
<?php
namespace App\Http\Controllers\Api\V1;
use App\Http\Controllers\Controller;
use App\Models\Feature;
use App\Models\Issue;
use App\Models\Layer;
use App\Models\Media;
use App\Models\Phase;
use App\Models\Project;
use App\Models\User;
use Illuminate\Http\Request;
use Illuminate\Validation\Rule;
use Illuminate\Support\Str;
class MediaController extends Controller
{
private array $map = [
'feature' => 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(),
];
}
}
@@ -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(),
];
}
}
+24 -2
View File
@@ -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 ───────────────────────────────────────────────────
+1
View File
@@ -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 = [
+12
View File
@@ -0,0 +1,12 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
class SyncLog extends Model
{
protected $fillable = [
'user_id', 'op_uuid', 'entity', 'op', 'status', 'server_id', 'error',
];
}