2026-06-18 09:05:20 +02:00
|
|
|
<?php
|
|
|
|
|
|
|
|
|
|
namespace App\Http\Controllers\Api\V1;
|
|
|
|
|
|
|
|
|
|
use App\Http\Controllers\Controller;
|
2026-06-18 09:45:18 +02:00
|
|
|
use App\Models\Feature;
|
|
|
|
|
use App\Models\Inspection;
|
|
|
|
|
use App\Models\InspectionTemplate;
|
|
|
|
|
use App\Models\Issue;
|
2026-06-18 12:12:39 +02:00
|
|
|
use App\Models\IssueComment;
|
|
|
|
|
use App\Models\IssueTask;
|
2026-06-18 09:45:18 +02:00
|
|
|
use App\Models\Layer;
|
2026-06-18 10:23:50 +02:00
|
|
|
use App\Models\Media;
|
2026-06-18 09:45:18 +02:00
|
|
|
use App\Models\Phase;
|
2026-06-18 09:05:20 +02:00
|
|
|
use App\Models\Project;
|
2026-06-18 09:45:18 +02:00
|
|
|
use Carbon\Carbon;
|
2026-06-18 09:05:20 +02:00
|
|
|
use Illuminate\Http\Request;
|
|
|
|
|
|
|
|
|
|
class ProjectApiController extends Controller
|
|
|
|
|
{
|
|
|
|
|
/** Projects the authenticated user can access. */
|
|
|
|
|
public function index(Request $request)
|
|
|
|
|
{
|
|
|
|
|
$projects = Project::accessibleBy($request->user())
|
|
|
|
|
->orderBy('name')
|
|
|
|
|
->get(['id', 'reference', 'name', 'address', 'status', 'lat', 'lng', 'updated_at']);
|
|
|
|
|
|
|
|
|
|
return response()->json(['projects' => $projects]);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
2026-06-18 09:45:18 +02:00
|
|
|
* Offline bundle for one project. Full snapshot, or a delta when `?since=` is
|
|
|
|
|
* given (only records changed after that timestamp + tombstones for deletions).
|
2026-06-18 09:05:20 +02:00
|
|
|
*/
|
|
|
|
|
public function bundle(Request $request, Project $project)
|
2026-06-18 09:45:18 +02:00
|
|
|
{
|
|
|
|
|
$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();
|
|
|
|
|
|
2026-06-18 12:12:39 +02:00
|
|
|
$allIssueIds = Issue::withTrashed()->where('project_id', $project->id)->pluck('id');
|
|
|
|
|
$issueTasks = $changed(IssueTask::whereIn('issue_id', $allIssueIds))->get();
|
|
|
|
|
$issueComments = $changed(IssueComment::whereIn('issue_id', $allIssueIds))->get();
|
|
|
|
|
|
2026-06-18 10:23:50 +02:00
|
|
|
$featureIds = Feature::whereIn('layer_id', $allLayerIds)->pluck('id');
|
|
|
|
|
$issueIds = Issue::where('project_id', $project->id)->pluck('id');
|
2026-06-18 12:12:39 +02:00
|
|
|
$taskIds = IssueTask::whereIn('issue_id', $allIssueIds)->pluck('id');
|
|
|
|
|
$commentIds = IssueComment::whereIn('issue_id', $allIssueIds)->pluck('id');
|
|
|
|
|
$media = $changed(Media::where(function ($q) use ($project, $featureIds, $issueIds, $taskIds, $commentIds) {
|
2026-06-18 10:23:50 +02:00
|
|
|
$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))
|
2026-06-18 12:12:39 +02:00
|
|
|
->orWhere(fn ($w) => $w->where('mediable_type', Issue::class)->whereIn('mediable_id', $issueIds))
|
|
|
|
|
->orWhere(fn ($w) => $w->where('mediable_type', IssueTask::class)->whereIn('mediable_id', $taskIds))
|
|
|
|
|
->orWhere(fn ($w) => $w->where('mediable_type', IssueComment::class)->whereIn('mediable_id', $commentIds));
|
2026-06-18 10:23:50 +02:00
|
|
|
}))->get();
|
|
|
|
|
|
2026-06-18 09:45:18 +02:00
|
|
|
return response()->json([
|
2026-06-18 12:12:39 +02:00
|
|
|
'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(),
|
|
|
|
|
'issue_tasks' => $issueTasks->map(fn ($t) => $this->mapIssueTask($t))->values(),
|
|
|
|
|
'issue_comments' => $issueComments->map(fn ($c) => $this->mapIssueComment($c))->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, $allIssueIds) : (object) [],
|
2026-06-18 09:45:18 +02:00
|
|
|
]);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/** 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
|
2026-06-18 09:05:20 +02:00
|
|
|
{
|
|
|
|
|
$user = $request->user();
|
|
|
|
|
abort_unless(
|
|
|
|
|
$user->can('manage all') || $project->users()->where('user_id', $user->id)->exists(),
|
|
|
|
|
403
|
|
|
|
|
);
|
2026-06-18 09:45:18 +02:00
|
|
|
}
|
2026-06-18 09:05:20 +02:00
|
|
|
|
2026-06-18 12:12:39 +02:00
|
|
|
private function tombstones(Carbon $since, Project $project, $allPhaseIds, $allLayerIds, $allIssueIds): array
|
2026-06-18 09:45:18 +02:00
|
|
|
{
|
|
|
|
|
return [
|
2026-06-18 12:12:39 +02:00
|
|
|
'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(),
|
|
|
|
|
'issue_tasks' => IssueTask::onlyTrashed()->whereIn('issue_id', $allIssueIds)->where('deleted_at', '>', $since)->pluck('id')->values(),
|
|
|
|
|
'issue_comments' => IssueComment::onlyTrashed()->whereIn('issue_id', $allIssueIds)->where('deleted_at', '>', $since)->pluck('id')->values(),
|
2026-06-18 09:45:18 +02:00
|
|
|
];
|
|
|
|
|
}
|
2026-06-18 09:05:20 +02:00
|
|
|
|
2026-06-18 09:45:18 +02:00
|
|
|
private function mapProject(Project $p): array
|
|
|
|
|
{
|
|
|
|
|
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(),
|
|
|
|
|
];
|
|
|
|
|
}
|
2026-06-18 09:05:20 +02:00
|
|
|
|
2026-06-18 09:45:18 +02:00
|
|
|
private function mapPhase(Phase $p): array
|
|
|
|
|
{
|
|
|
|
|
return [
|
|
|
|
|
'id' => $p->id, 'name' => $p->name, 'order' => $p->order, 'color' => $p->color,
|
|
|
|
|
'progress_percent' => $p->progress_percent, 'updated_at' => $p->updated_at?->toIso8601String(),
|
|
|
|
|
];
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private function mapLayer(Layer $l): array
|
|
|
|
|
{
|
|
|
|
|
return [
|
|
|
|
|
'id' => $l->id, 'phase_id' => $l->phase_id, 'name' => $l->name,
|
|
|
|
|
'color' => $l->color, 'updated_at' => $l->updated_at?->toIso8601String(),
|
|
|
|
|
];
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private function mapFeature(Feature $f): array
|
|
|
|
|
{
|
|
|
|
|
return [
|
|
|
|
|
'id' => $f->id, 'layer_id' => $f->layer_id, 'name' => $f->name,
|
|
|
|
|
'geometry' => $f->geometry, 'status' => $f->status, 'progress' => $f->progress,
|
|
|
|
|
'responsible' => $f->responsible, 'template_id' => $f->template_id,
|
|
|
|
|
'updated_at' => $f->updated_at?->toIso8601String(),
|
|
|
|
|
];
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private function mapInspection(Inspection $i): array
|
|
|
|
|
{
|
|
|
|
|
return [
|
|
|
|
|
'id' => $i->id, 'feature_id' => $i->feature_id, 'layer_id' => $i->layer_id,
|
|
|
|
|
'template_id' => $i->template_id, 'user_id' => $i->user_id, 'data' => $i->data,
|
|
|
|
|
'status' => $i->status, 'result' => $i->result, 'notes' => $i->notes,
|
|
|
|
|
'created_at' => $i->created_at?->toIso8601String(), 'updated_at' => $i->updated_at?->toIso8601String(),
|
|
|
|
|
];
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
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(),
|
|
|
|
|
];
|
|
|
|
|
}
|
|
|
|
|
|
2026-06-18 12:12:39 +02:00
|
|
|
private function mapIssueTask(IssueTask $t): array
|
|
|
|
|
{
|
|
|
|
|
return [
|
|
|
|
|
'id' => $t->id, 'issue_id' => $t->issue_id, 'title' => $t->title,
|
|
|
|
|
'is_done' => $t->is_done, 'done_at' => $t->done_at?->toIso8601String(), 'done_by' => $t->done_by,
|
|
|
|
|
'assigned_to' => $t->assigned_to, 'due_date' => $t->due_date?->toDateString(),
|
|
|
|
|
'order' => $t->order, 'updated_at' => $t->updated_at?->toIso8601String(),
|
|
|
|
|
];
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private function mapIssueComment(IssueComment $c): array
|
|
|
|
|
{
|
|
|
|
|
return [
|
|
|
|
|
'id' => $c->id, 'issue_id' => $c->issue_id, 'user_id' => $c->user_id, 'body' => $c->body,
|
|
|
|
|
'created_at' => $c->created_at?->toIso8601String(), 'updated_at' => $c->updated_at?->toIso8601String(),
|
|
|
|
|
];
|
|
|
|
|
}
|
|
|
|
|
|
2026-06-18 09:45:18 +02:00
|
|
|
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(),
|
|
|
|
|
];
|
2026-06-18 09:05:20 +02:00
|
|
|
}
|
2026-06-18 10:23:50 +02:00
|
|
|
|
|
|
|
|
private function mapMedia(Media $m): array
|
|
|
|
|
{
|
|
|
|
|
$entity = [
|
2026-06-18 12:12:39 +02:00
|
|
|
Project::class => 'project',
|
|
|
|
|
Feature::class => 'feature',
|
|
|
|
|
Issue::class => 'issue',
|
|
|
|
|
IssueTask::class => 'issue_task',
|
|
|
|
|
IssueComment::class => 'issue_comment',
|
2026-06-18 10:23:50 +02:00
|
|
|
][$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(),
|
|
|
|
|
];
|
|
|
|
|
}
|
2026-06-18 09:05:20 +02:00
|
|
|
}
|
2026-06-18 10:23:50 +02:00
|
|
|
|