3
0
mirror of https://github.com/snipe/snipe-it.git synced 2026-08-18 11:15:42 +00:00
Files
snipe-it/app/Http/Controllers/Api/CalendarEventsController.php

331 lines
14 KiB
PHP

<?php
namespace App\Http\Controllers\Api;
use App\Http\Controllers\Controller;
use App\Http\Transformers\CalendarEventsTransformer;
use App\Models\CalendarEvent;
use Carbon\Carbon;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
/**
* Unified read endpoint for the calendar page. Queries the
* calendar_events index table (populated by the HasCalendarEvents
* trait's observer) and hydrates each row from its live source
* model, so titles / urls / colors reflect current source state
* rather than a denormalized snapshot.
*
* Filtering:
* - `start`, `end` (ISO-8601) - FullCalendar's visible range.
* Defaults to a wide six-month window centered on today.
* - `event_type[]` - array of allowed event_type values. Empty
* means "all types". Frontend filter buttons flip these on and
* off.
* - `limit` - hard cap on returned events. Defaults to 500 for the
* full calendar page and gets overridden downward for the
* dashboard "Today" widget. Guards the browser from a 10k-row
* JSON blob when a big install's calendar range straddles a
* dense date. Response is `{ events, truncated, total }` so the
* frontend can render a "narrow filters" banner when truncated.
*
* Access control:
* - Base gate: the viewer must be able to view AT LEAST ONE of the
* source types (Maintenance / Asset / License / User). A user
* with only license-view permission still legitimately wants the
* calendar to render their license expirations, so the endpoint
* shouldn't 403 them just because they can't see assets.
* - Source-specific access is enforced per-row via each source's
* own view policy - if the actor can't view the underlying model
* (FMCS mismatch, location scoping, etc.), the event is filtered
* out before it hits the response. That per-row gate does the
* actual scoping work.
*/
class CalendarEventsController extends Controller
{
public function index(Request $request): JsonResponse
{
// Base gate: viewer must be able to view AT LEAST ONE of the
// registered HasCalendarEvents source models. Source list is
// discovered from CalendarEvent::sourceModels() so a new
// model adopting the trait is picked up automatically without
// touching this controller. Per-row policy checks below then
// do the actual event-level scoping.
$viewer = $request->user();
$canViewAnySource = false;
foreach (CalendarEvent::sourceModels() as $sourceClass) {
if ($viewer?->can('view', $sourceClass)) {
$canViewAnySource = true;
break;
}
}
abort_unless($canViewAnySource, 403);
$rangeStart = $request->filled('start')
? Carbon::parse($request->input('start'))
: now()->subMonths(3)->startOfDay();
$rangeEnd = $request->filled('end')
? Carbon::parse($request->input('end'))
: now()->addMonths(3)->endOfDay();
// event_type filter comes as either ?event_type[]=a&event_type[]=b
// (repeatable) or ?event_type=a,b (single param, comma-split).
// Empty or missing means "all types allowed".
$eventTypes = $request->input('event_type');
if (is_string($eventTypes)) {
$eventTypes = array_filter(array_map('trim', explode(',', $eventTypes)));
}
$eventTypes = is_array($eventTypes) ? array_values(array_filter($eventTypes)) : [];
// Hard cap on returned rows. Two calendar contexts hit this
// endpoint: the full calendar page (default 500) and the
// dashboard's Today widget (much smaller, sends ?limit=25). A
// request that asks for more than the ceiling silently gets
// capped so the browser can't be exhausted.
$ceiling = 500;
$limit = (int) $request->input('limit', $ceiling);
$limit = max(1, min($limit, $ceiling));
// FullCalendar's fetchInfo passes ranges as [start, end)
// (end exclusive) so a listDay view of today = start=today
// 00:00, end=tomorrow 00:00. whereBetween is inclusive on
// BOTH sides though, which was pulling tomorrow's midnight
// events into today's listDay widget and inflating the
// truncation count. Use explicit >= start / < end to match
// FC's half-open interval semantics.
$baseQuery = CalendarEvent::query()
->where(function ($q) use ($rangeStart, $rangeEnd) {
$q->where(function ($inner) use ($rangeStart, $rangeEnd) {
$inner->where('start', '>=', $rangeStart)
->where('start', '<', $rangeEnd);
})
->orWhere(function ($inner) use ($rangeStart, $rangeEnd) {
$inner->where('end', '>=', $rangeStart)
->where('end', '<', $rangeEnd);
})
->orWhere(function ($inner) use ($rangeStart, $rangeEnd) {
$inner->where('start', '<', $rangeStart)
->where('end', '>=', $rangeEnd);
});
})
->when(! empty($eventTypes), fn ($q) => $q->whereIn('event_type', $eventTypes));
$total = (clone $baseQuery)->count();
$rows = $baseQuery
->orderBy('start')
->limit($limit)
->get();
// Whether we hit the query limit. This is the honest signal for
// "there might be more events after this batch". The alternative
// (comparing $total to count($events) post-per-row-filter) reports
// truncated=true any time an FMCS-filtered event brings the
// returned count below $total, which was misleading users into
// clicking "+N more on calendar" links for events they never had
// permission to see in the first place.
$hitLimit = $rows->count() >= $limit;
// Batch-load source models per source_type in one query per
// type. Groups rows by source_type, whereIn's the ids, and
// key-lookups them from the resulting collection. Avoids the
// N+1 that a naive $row->source access would produce.
$rowsByType = $rows->groupBy('source_type');
$sourcesByType = [];
foreach ($rowsByType as $sourceType => $typeRows) {
if (! class_exists($sourceType)) {
continue;
}
$ids = $typeRows->pluck('source_id')->unique()->all();
$sourcesByType[$sourceType] = $sourceType::query()
->when(
in_array(\Illuminate\Database\Eloquent\SoftDeletes::class, class_uses_recursive($sourceType), true),
fn ($q) => $q->withTrashed(),
)
->whereIn('id', $ids)
->get()
->keyBy('id');
}
$events = [];
foreach ($rows as $row) {
$source = $sourcesByType[$row->source_type][$row->source_id] ?? null;
if (! $source) {
// Source disappeared between the index write and now
// (mid-request delete without a completed observer
// cascade). Skip the row so the client doesn't see a
// ghost event; the reconcile command will clean up
// the orphan on its next pass.
continue;
}
// Per-row access check via each source's own policy.
// Without this, a user with view-Asset but no access to
// a particular license (FMCS mismatch) would still see
// its expiration event on the calendar. Any source that
// doesn't have a view policy is treated as visible; add
// policies to lock down access.
if (! $request->user()?->can('view', $source)) {
continue;
}
$allDay = $this->isAllDayField($source, $row->source_field);
$events[] = [
'id' => $row->id,
'title' => $this->titleFor($source, $row->event_type),
'start' => $this->formatDate($row->start, $allDay),
'end' => $this->formatDate($row->end, $allDay),
'allDay' => $allDay,
'url' => $this->urlFor($source),
'color' => $this->colorFor($source),
'extendedProps' => [
'source_type' => $row->source_type,
'source_id' => $row->source_id,
'source_field' => $row->source_field,
'event_type' => $row->event_type,
],
];
}
return response()->json([
'events' => (new CalendarEventsTransformer)->transformCalendarEvents($events),
'total' => $total,
'truncated' => $hitLimit,
]);
}
/**
* Resolve a display name from the source model, prefixed with a
* human-readable label for the event_type so a viewer can tell at
* a glance why a row is on the calendar (e.g. "Audit due: Laptop
* #7" vs. just "Laptop #7"). Presenter's name() is the canonical
* Snipe-IT accessor; falls through to common attribute names for
* models that don't have a presenter wired up yet.
*/
protected function titleFor($source, string $eventType): string
{
if (method_exists($source, 'present')) {
$presenter = $source->present();
if (method_exists($presenter, 'name')) {
$name = (string) $presenter->name();
}
}
$name ??= (string) ($source->display_name ?? $source->name ?? (class_basename($source).'#'.$source->getKey()));
$label = $this->eventTypeLabel($eventType);
return $label ? sprintf('%s: %s', $label, $name) : $name;
}
/**
* Human-readable label for an event_type. Keyed on the canonical
* event_type strings emitted by each model's
* calendarEventDefinitions(); unknown types fall through to the
* event_type itself so a new source at least reads coherently
* before its label is registered.
*/
protected function eventTypeLabel(string $eventType): string
{
$keys = [
'maintenance.start' => 'general.calendar_event_maintenance',
'asset.audit_due' => 'general.calendar_event_asset_audit_due',
'asset.expected_checkin' => 'general.calendar_event_asset_expected_checkin',
'asset.checkout' => 'general.calendar_event_asset_checkout',
'asset.eol' => 'general.calendar_event_asset_eol',
'asset.warranty_expiration' => 'general.calendar_event_asset_warranty_expiration',
'license.expiration' => 'general.calendar_event_license_expiration',
'license.termination' => 'general.calendar_event_license_termination',
'user.end_date' => 'general.calendar_event_user_end_date',
];
if (! array_key_exists($eventType, $keys)) {
return $eventType;
}
return trans($keys[$eventType]);
}
/**
* True when the underlying source field is cast as a bare date
* (not a datetime). Rendering these on a timegrid as ISO-8601 with
* a T00:00:00 component stacks the whole day's events at midnight;
* FullCalendar's `allDay: true` mode turns them into a solid bar
* across the day instead. Also flips the payload's start/end to
* YYYY-MM-DD so FullCalendar doesn't reinterpret the missing time
* as UTC midnight.
*/
protected function isAllDayField($source, ?string $field): bool
{
if (! $field) {
return false;
}
if (method_exists($source, 'calendarEventDefinitions')) {
foreach ($source->calendarEventDefinitions() as $definition) {
if (($definition['field'] ?? null) === $field && array_key_exists('all_day', $definition)) {
return (bool) $definition['all_day'];
}
}
}
if (! method_exists($source, 'getCasts')) {
return false;
}
$cast = $source->getCasts()[$field] ?? null;
return in_array($cast, ['date', 'immutable_date'], true);
}
protected function formatDate(mixed $date, bool $allDay): ?string
{
if ($date === null || $date === '') {
return null;
}
if (! $date instanceof \DateTimeInterface) {
$date = Carbon::parse($date);
}
return $allDay ? $date->format('Y-m-d') : $date->toIso8601String();
}
/**
* Resolve a link to the source model. Presenter's route() (or
* similar) is preferred so each source owns its own routing
* conventions; falls back to a source-type-keyed default when
* absent.
*/
protected function urlFor($source): ?string
{
if (method_exists($source, 'present')) {
$presenter = $source->present();
if (method_exists($presenter, 'calendarUrl')) {
return $presenter->calendarUrl();
}
}
return null;
}
/**
* Resolve a color for the event via the source model's
* presenter. Null means "no per-event color" - frontend paints
* from a per-event_type CSS palette so uncolored installs still
* read cleanly.
*/
protected function colorFor($source): ?string
{
if (method_exists($source, 'present')) {
$presenter = $source->present();
if (method_exists($presenter, 'calendarColor')) {
return $presenter->calendarColor();
}
}
return null;
}
}