kithkin/app/Http/Controllers/EventController.php

416 lines
14 KiB
PHP
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<?php
namespace App\Http\Controllers;
use App\Models\Calendar;
use App\Models\Event;
use App\Models\Location;
use App\Services\Event\EventRecurrence;
use App\Services\Location\Geocoder;
use Carbon\Carbon;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Str;
use Illuminate\Support\Facades\Log;
use Symfony\Component\HttpFoundation\Response;
class EventController extends Controller
{
/**
* create a new event
*/
public function create(Calendar $calendar, Request $request)
{
$this->authorize('update', $calendar);
$instance = $calendar->instanceForUser($request->user());
$tz = $this->displayTimezone($calendar, $request);
// build a fresh event "shell" with meta defaults
$event = new Event;
$event->meta = (object) [
'title' => '',
'description' => '',
'location' => '',
'start_at' => null,
'end_at' => null,
'all_day' => false,
'category' => '',
];
// if ?date=YYYY-MM-DD is present, start that day at 9am; otherwise "now"
$anchor = $request->query('date')
? Carbon::parse($request->query('date'), $tz)->startOfDay()->addHours(9)
: Carbon::now($tz);
$anchor->second(0);
$start = $anchor->copy()->format('Y-m-d\TH:i');
$end = $anchor->copy()->addHour()->format('Y-m-d\TH:i');
$rrule = '';
return view('event.form', compact(
'calendar',
'instance',
'event',
'start',
'end',
'tz',
'rrule',
));
}
/**
* edit event
*/
public function edit(Calendar $calendar, Event $event, Request $request, EventRecurrence $recurrence)
{
$this->authorize('update', $calendar);
// ensure the event belongs to the parent calendar
if ((int) $event->calendarid !== (int) $calendar->id) {
abort(Response::HTTP_NOT_FOUND);
}
$instance = $calendar->instanceForUser($request->user());
$tz = $this->displayTimezone($calendar, $request);
$event->load('meta');
$start = $event->meta?->start_at
? Carbon::parse($event->meta->start_at)->timezone($tz)->format('Y-m-d\TH:i')
: null;
$end = $event->meta?->end_at
? Carbon::parse($event->meta->end_at)->timezone($tz)->format('Y-m-d\TH:i')
: null;
$rrule = $event->meta?->extra['rrule']
?? $recurrence->extractRrule($event)
?? '';
return view('event.form', compact('calendar', 'instance', 'event', 'start', 'end', 'tz', 'rrule'));
}
/**
* single event view handling
*/
public function show(Request $request, Calendar $calendar, Event $event, EventRecurrence $recurrence)
{
if ((int) $event->calendarid !== (int) $calendar->id) {
abort(Response::HTTP_NOT_FOUND);
}
$this->authorize('view', $event);
$event->load(['meta', 'meta.venue']);
$isHtmx = $request->header('HX-Request') === 'true';
$tz = $this->displayTimezone($calendar, $request);
// prefer occurrence when supplied (recurring events), fall back to meta, then sabre columns
$occurrenceParam = $request->query('occurrence');
$occurrenceStart = null;
if ($occurrenceParam) {
try {
$occurrenceStart = Carbon::parse($occurrenceParam)->utc();
} catch (\Throwable $e) {
$occurrenceStart = null;
}
}
$occurrence = $occurrenceStart
? $recurrence->resolveOccurrence($event, $occurrenceStart)
: null;
$startUtc = $occurrence['start'] ?? ($event->meta?->start_at
? Carbon::parse($event->meta->start_at)->utc()
: Carbon::createFromTimestamp($event->firstoccurence, 'UTC'));
$endUtc = $occurrence['end'] ?? ($event->meta?->end_at
? Carbon::parse($event->meta->end_at)->utc()
: ($event->lastoccurence
? Carbon::createFromTimestamp($event->lastoccurence, 'UTC')
: $startUtc->copy()));
// convert for display
$start = $startUtc->copy()->timezone($tz);
$end = $endUtc->copy()->timezone($tz);
$data = compact('calendar', 'event', 'start', 'end', 'tz');
return $isHtmx
? view('event.partials.details', $data)
: view('event.show', $data);
}
/**
* insert vevent into sabres calendarobjects + meta row
*/
public function store(Request $request, Calendar $calendar, Geocoder $geocoder, EventRecurrence $recurrence): RedirectResponse
{
$this->authorize('update', $calendar);
$data = $request->validate([
'title' => ['required', 'string', 'max:200'],
'start_at' => ['required', 'date_format:Y-m-d\TH:i'],
'end_at' => ['required', 'date_format:Y-m-d\TH:i', 'after:start_at'],
'description' => ['nullable', 'string'],
'location' => ['nullable', 'string'],
'all_day' => ['sometimes', 'boolean'],
'category' => ['nullable', 'string', 'max:50'],
'rrule' => ['nullable', 'string', 'max:255'],
// normalized location hints (optional)
'loc_display_name' => ['nullable', 'string'],
'loc_place_name' => ['nullable', 'string'],
'loc_street' => ['nullable', 'string'],
'loc_city' => ['nullable', 'string'],
'loc_state' => ['nullable', 'string'],
'loc_postal' => ['nullable', 'string'],
'loc_country' => ['nullable', 'string'],
'loc_lat' => ['nullable'],
'loc_lon' => ['nullable'],
]);
$tz = $this->displayTimezone($calendar, $request);
// parse input in display tz, store in utc
$startUtc = $this->parseLocalDatetimeToUtc($data['start_at'], $tz);
$endUtc = $this->parseLocalDatetimeToUtc($data['end_at'], $tz);
$uid = Str::uuid() . '@' . parse_url(config('app.url'), PHP_URL_HOST);
$rrule = $this->normalizeRrule($request);
$extra = $this->mergeRecurrenceExtra([], $rrule, $tz, $request);
$ical = $recurrence->buildCalendar([
'uid' => $uid,
'start_utc' => $startUtc,
'end_utc' => $endUtc,
'summary' => $data['title'],
'description' => $data['description'] ?? '',
'location' => $data['location'] ?? '',
'tzid' => $rrule ? $tz : null,
'rrule' => $rrule,
]);
$event = Event::create([
'calendarid' => $calendar->id,
'uri' => Str::uuid() . '.ics',
'lastmodified' => time(),
'etag' => md5($ical),
'size' => strlen($ical),
'componenttype' => 'VEVENT',
'uid' => $uid,
'calendardata' => $ical,
]);
$locationId = $this->resolveLocationId($request, $geocoder, $data);
$event->meta()->create([
'title' => $data['title'],
'description' => $data['description'] ?? null,
'location' => $data['location'] ?? null,
'location_id' => $locationId,
'all_day' => (bool) ($data['all_day'] ?? false),
'category' => $data['category'] ?? null,
'start_at' => $startUtc,
'end_at' => $endUtc,
'extra' => $extra,
]);
return redirect()->route('calendar.show', $calendar);
}
/**
* update vevent + meta
*/
public function update(Request $request, Calendar $calendar, Event $event, EventRecurrence $recurrence): RedirectResponse
{
$this->authorize('update', $calendar);
if ((int) $event->calendarid !== (int) $calendar->id) {
abort(Response::HTTP_NOT_FOUND);
}
$data = $request->validate([
'title' => ['required', 'string', 'max:200'],
'start_at' => ['required', 'date_format:Y-m-d\TH:i'],
'end_at' => ['required', 'date_format:Y-m-d\TH:i', 'after:start_at'],
'description' => ['nullable', 'string'],
'location' => ['nullable', 'string'],
'all_day' => ['sometimes', 'boolean'],
'category' => ['nullable', 'string', 'max:50'],
'rrule' => ['nullable', 'string', 'max:255'],
]);
$tz = $this->displayTimezone($calendar, $request);
$startUtc = $this->parseLocalDatetimeToUtc($data['start_at'], $tz);
$endUtc = $this->parseLocalDatetimeToUtc($data['end_at'], $tz);
$uid = $event->uid;
$rrule = $this->normalizeRrule($request);
$extra = $event->meta?->extra ?? [];
$extra = $this->mergeRecurrenceExtra($extra, $rrule, $tz, $request);
$rruleForIcs = $rrule ?? ($extra['rrule'] ?? $recurrence->extractRrule($event));
$ical = $recurrence->buildCalendar([
'uid' => $uid,
'start_utc' => $startUtc,
'end_utc' => $endUtc,
'summary' => $data['title'],
'description' => $data['description'] ?? '',
'location' => $data['location'] ?? '',
'tzid' => $rruleForIcs ? $tz : null,
'rrule' => $rruleForIcs,
'exdate' => $extra['exdate'] ?? [],
'rdate' => $extra['rdate'] ?? [],
]);
$event->update([
'calendardata' => $ical,
'etag' => md5($ical),
'lastmodified' => time(),
]);
$event->meta()->updateOrCreate([], [
'title' => $data['title'],
'description' => $data['description'] ?? null,
'location' => $data['location'] ?? null,
'all_day' => (bool) ($data['all_day'] ?? false),
'category' => $data['category'] ?? null,
'start_at' => $startUtc,
'end_at' => $endUtc,
'extra' => $extra,
]);
return redirect()->route('calendar.show', $calendar);
}
/**
* pick display timezone: calendar instance -> user -> utc
*/
private function displayTimezone(Calendar $calendar, Request $request): string
{
$instanceTz = $calendar->instanceForUser($request->user())?->timezone;
$userTz = $request->user()?->timezone;
return $instanceTz ?: ($userTz ?: 'UTC');
}
/**
* parse datetime-local in tz and return utc carbon
*/
private function parseLocalDatetimeToUtc(string $value, string $tz): Carbon
{
// datetime-local: 2026-01-21T09:00
$local = Carbon::createFromFormat('Y-m-d\TH:i', $value, $tz)->seconds(0);
return $local->utc();
}
/**
* minimal ics escaping for text properties
*/
private function escapeIcsText(string $text): string
{
$text = str_replace(["\r\n", "\r", "\n"], "\\n", $text);
$text = str_replace(["\\", ";", ","], ["\\\\", "\;", "\,"], $text);
return $text;
}
private function normalizeRrule(Request $request): ?string
{
if (! $request->has('rrule')) {
return null;
}
$rrule = trim((string) $request->input('rrule'));
return $rrule === '' ? '' : $rrule;
}
private function mergeRecurrenceExtra(array $extra, ?string $rrule, string $tz, Request $request): array
{
if ($rrule === null) {
return $extra; // no change requested
}
if ($rrule === '') {
unset($extra['rrule'], $extra['exdate'], $extra['rdate'], $extra['tzid']);
return $extra;
}
$extra['rrule'] = $rrule;
$extra['tzid'] = $tz;
$extra['exdate'] = $this->normalizeDateList($request->input('exdate', $extra['exdate'] ?? []), $tz);
$extra['rdate'] = $this->normalizeDateList($request->input('rdate', $extra['rdate'] ?? []), $tz);
return $extra;
}
private function normalizeDateList(mixed $value, string $tz): array
{
if (is_string($value)) {
$value = array_filter(array_map('trim', explode(',', $value)));
}
if (! is_array($value)) {
return [];
}
return array_values(array_filter(array_map(function ($item) use ($tz) {
if (! $item) {
return null;
}
return Carbon::parse($item, $tz)->utc()->toIso8601String();
}, $value)));
}
/**
* resolve location_id from hints or geocoding
*/
private function resolveLocationId(Request $request, Geocoder $geocoder, array $data): ?int
{
$raw = $data['location'] ?? null;
if (!$raw) return null;
$hasNormHints = $request->filled('loc_display_name') ||
$request->filled('loc_place_name') ||
$request->filled('loc_street') ||
$request->filled('loc_city') ||
$request->filled('loc_state') ||
$request->filled('loc_postal') ||
$request->filled('loc_country') ||
$request->filled('loc_lat') ||
$request->filled('loc_lon');
if ($hasNormHints) {
$norm = [
'display_name' => $request->input('loc_display_name') ?: $raw,
'place_name' => $request->input('loc_place_name'),
'raw_address' => $raw,
'street' => $request->input('loc_street'),
'city' => $request->input('loc_city'),
'state' => $request->input('loc_state'),
'postal' => $request->input('loc_postal'),
'country' => $request->input('loc_country'),
'lat' => $request->filled('loc_lat') ? (float) $request->input('loc_lat') : null,
'lon' => $request->filled('loc_lon') ? (float) $request->input('loc_lon') : null,
];
return Location::findOrCreateNormalized($norm, $raw)->id;
}
$norm = $geocoder->forward($raw);
if ($norm) {
return Location::findOrCreateNormalized($norm, $raw)->id;
}
return Location::labelOnly($raw)->id;
}
}