3f240e5277
Web: - IssueTask + IssueComment (modelos, migraciones, soft-deletes, campos de sync). Issue gana tasks()/comments() y accessor de % de avance derivado de tareas. - IssueDetail (página): checklist con asignado/fecha límite/progreso, hilo de comentarios con foto por comentario, galería de fotos de la incidencia y flujo de verificación open→in_review→resolved/closed (+reabrir) con notas. - Creación/edición en páginas propias (IssueForm), sin modal; al guardar redirige al detalle. Rutas projects.issues.create/edit/show. - Listado con tabla Rappasoft (IssueTable): filtros por estado/prioridad, búsqueda, barra de progreso y acciones por fila gateadas por permisos; IssueManager queda como contenedor (cabecera + stats) que embebe la tabla. - Seguridad: pertenencia al proyecto + permisos por acción (view/create/edit/delete issues, upload/delete media) en todos los componentes. API móvil (offline): - /sync: issue_task.create/update y issue_comment.create (idempotente, LWW). - /media: parent_entity issue_task / issue_comment. - bundle + tombstones incluyen issue_tasks / issue_comments. - openapi.yaml + MOBILE_SYNC_PROTOCOL.md actualizados. Tests: MobileApiTest 23 passing (+5); IssuesTablePageTest (3) smoke de la tabla. Branding: logo RTE International — MAI Group (public/images/logo-rte.png) en login y navegación; application-logo pasa de SVG por defecto a <img>. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
113 lines
4.0 KiB
PHP
113 lines
4.0 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\IssueComment;
|
|
use App\Models\IssueTask;
|
|
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,
|
|
'issue_task' => IssueTask::class,
|
|
'issue_comment' => IssueComment::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,
|
|
'issue_task' => $parent->issue?->project,
|
|
'issue_comment' => $parent->issue?->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(),
|
|
];
|
|
}
|
|
}
|