14758136b6
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>
107 lines
3.7 KiB
PHP
107 lines
3.7 KiB
PHP
<?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(),
|
|
];
|
|
}
|
|
}
|