From 909fae5eb42783e1c3a8414a4b769f298429c099 Mon Sep 17 00:00:00 2001 From: Andrew Gioia Date: Thu, 19 Feb 2026 14:45:59 -0500 Subject: [PATCH] Adds migration for Daniel and James users and contact cards; adds attendee support to database with new event_attendee table; updates new event modal with basic attendee search ahead and add --- app/Http/Controllers/AttendeeController.php | 163 ++++++++++++ app/Http/Controllers/EventController.php | 137 +++++++++- app/Jobs/SyncSubscription.php | 5 +- app/Models/Event.php | 6 + app/Models/EventAttendee.php | 41 +++ app/Models/User.php | 5 + .../Event/EventAttendeeSynchronizer.php | 236 ++++++++++++++++++ app/Services/Event/EventRecurrence.php | 79 ++++++ ...19_000000_create_event_attendees_table.php | 47 ++++ ..._19_000100_add_demo_users_and_contacts.php | 156 ++++++++++++ lang/en/calendar.php | 10 + lang/it/calendar.php | 10 + resources/css/lib/modal.css | 6 +- resources/js/app.js | 174 +++++++++++++ .../partials/attendee-suggestions.blade.php | 29 +++ resources/views/event/partials/form.blade.php | 117 ++++++++- routes/web.php | 4 + 17 files changed, 1215 insertions(+), 10 deletions(-) create mode 100644 app/Http/Controllers/AttendeeController.php create mode 100644 app/Models/EventAttendee.php create mode 100644 app/Services/Event/EventAttendeeSynchronizer.php create mode 100644 database/migrations/2026_02_19_000000_create_event_attendees_table.php create mode 100644 database/migrations/2026_02_19_000100_add_demo_users_and_contacts.php create mode 100644 resources/views/event/partials/attendee-suggestions.blade.php diff --git a/app/Http/Controllers/AttendeeController.php b/app/Http/Controllers/AttendeeController.php new file mode 100644 index 0000000..8ab26ba --- /dev/null +++ b/app/Http/Controllers/AttendeeController.php @@ -0,0 +1,163 @@ +input('q', $request->input('attendee', ''))); + if ($q === '' || mb_strlen($q) < 2) { + return response()->view('event.partials.attendee-suggestions', ['suggestions' => []]); + } + + $principal = $request->user()?->principal_uri; + if (!$principal) { + return response()->view('event.partials.attendee-suggestions', ['suggestions' => []]); + } + + $query = Str::lower($q); + $contacts = []; + $seen = []; + + $cards = Card::query() + ->select('cards.carddata') + ->join('addressbooks', 'addressbooks.id', '=', 'cards.addressbookid') + ->where('addressbooks.principaluri', $principal) + ->whereRaw('LOWER(cards.carddata) LIKE ?', ['%' . $query . '%']) + ->orderByDesc('cards.lastmodified') + ->limit(200) + ->get(); + + foreach ($cards as $card) { + $parsed = $this->parseCardEmails((string) $card->carddata); + $name = $parsed['name']; + + foreach ($parsed['emails'] as $email) { + $emailLc = Str::lower($email); + $matches = str_contains($emailLc, $query) + || ($name !== '' && str_contains(Str::lower($name), $query)); + + if (!$matches || isset($seen[$emailLc])) { + continue; + } + + $seen[$emailLc] = true; + $contacts[] = [ + 'email' => $emailLc, + 'name' => $name !== '' ? $name : null, + 'attendee_uri' => 'mailto:' . $emailLc, + 'source' => 'contact', + ]; + } + } + + if (filter_var($q, FILTER_VALIDATE_EMAIL)) { + $emailLc = Str::lower($q); + if (!isset($seen[$emailLc])) { + $contacts[] = [ + 'email' => $emailLc, + 'name' => null, + 'attendee_uri' => 'mailto:' . $emailLc, + 'source' => 'email', + ]; + } + } + + if (empty($contacts)) { + return response()->view('event.partials.attendee-suggestions', ['suggestions' => []]); + } + + usort($contacts, function (array $a, array $b) use ($query) { + $scoreA = $this->score($a, $query); + $scoreB = $this->score($b, $query); + + if ($scoreA !== $scoreB) { + return $scoreA <=> $scoreB; + } + + return strcmp($a['email'], $b['email']); + }); + + $contacts = array_slice($contacts, 0, 8); + $emails = array_values(array_unique(array_map(fn ($item) => $item['email'], $contacts))); + $knownUsers = User::query() + ->whereIn('email', $emails) + ->pluck('email') + ->map(fn ($email) => Str::lower((string) $email)) + ->flip() + ->all(); + + foreach ($contacts as &$item) { + $item['verified'] = isset($knownUsers[$item['email']]); + } + unset($item); + + return response()->view('event.partials.attendee-suggestions', [ + 'suggestions' => $contacts, + ]); + } + + private function parseCardEmails(string $carddata): array + { + $name = ''; + $emails = []; + + try { + $vcard = Reader::read($carddata); + $name = trim((string) ($vcard->FN ?? '')); + foreach ($vcard->select('EMAIL') as $emailProp) { + $email = Str::lower(trim((string) $emailProp->getValue())); + if ($email !== '') { + $emails[] = $email; + } + } + } catch (\Throwable $e) { + if (preg_match('/^FN:(.+)$/mi', $carddata, $fn)) { + $name = trim($fn[1]); + } + + if (preg_match_all('/^EMAIL[^:]*:(.+)$/mi', $carddata, $matches)) { + foreach (($matches[1] ?? []) as $emailRaw) { + $email = Str::lower(trim((string) $emailRaw)); + if ($email !== '') { + $emails[] = $email; + } + } + } + } + + return [ + 'name' => $name, + 'emails' => array_values(array_unique($emails)), + ]; + } + + private function score(array $item, string $query): int + { + $email = Str::lower((string) ($item['email'] ?? '')); + $name = Str::lower((string) ($item['name'] ?? '')); + $source = (string) ($item['source'] ?? ''); + + if ($email === $query) { + return 0; + } + if ($email !== '' && str_starts_with($email, $query)) { + return 1; + } + if ($name !== '' && str_starts_with($name, $query)) { + return 2; + } + if ($source === 'contact') { + return 3; + } + + return 4; + } +} diff --git a/app/Http/Controllers/EventController.php b/app/Http/Controllers/EventController.php index 6e69e04..4dab18d 100644 --- a/app/Http/Controllers/EventController.php +++ b/app/Http/Controllers/EventController.php @@ -5,13 +5,13 @@ namespace App\Http\Controllers; use App\Models\Calendar; use App\Models\Event; use App\Models\Location; +use App\Services\Event\EventAttendeeSynchronizer; 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 @@ -59,6 +59,7 @@ class EventController extends Controller 'rrule', ); $data = array_merge($data, $this->buildRecurrenceFormData($request, $start, $tz, $rrule)); + $data = array_merge($data, $this->buildAttendeeFormData($request)); if ($request->header('HX-Request') === 'true') { return view('event.partials.form-modal', $data); @@ -82,7 +83,7 @@ class EventController extends Controller $instance = $calendar->instanceForUser($request->user()); $tz = $this->displayTimezone($calendar, $request); - $event->load('meta'); + $event->load('meta', 'attendees'); $start = $event->meta?->start_at ? Carbon::parse($event->meta->start_at)->timezone($tz)->format('Y-m-d\TH:i') @@ -98,6 +99,7 @@ class EventController extends Controller $data = compact('calendar', 'instance', 'event', 'start', 'end', 'tz', 'rrule'); $data = array_merge($data, $this->buildRecurrenceFormData($request, $start ?? '', $tz, $rrule)); + $data = array_merge($data, $this->buildAttendeeFormData($request, $event)); if ($request->header('HX-Request') === 'true') { return view('event.partials.form-modal', $data); @@ -117,7 +119,7 @@ class EventController extends Controller $this->authorize('view', $event); - $event->loadMissing(['meta', 'meta.venue']); + $event->loadMissing(['meta', 'meta.venue', 'attendees']); $isHtmx = $request->header('HX-Request') === 'true'; $tz = $this->displayTimezone($calendar, $request); @@ -182,7 +184,13 @@ class EventController extends Controller /** * insert vevent into sabre’s calendarobjects + meta row */ - public function store(Request $request, Calendar $calendar, Geocoder $geocoder, EventRecurrence $recurrence): RedirectResponse + public function store( + Request $request, + Calendar $calendar, + Geocoder $geocoder, + EventRecurrence $recurrence, + EventAttendeeSynchronizer $attendeeSync + ): RedirectResponse { $this->authorize('update', $calendar); @@ -213,6 +221,18 @@ class EventController extends Controller 'repeat_month_week' => ['nullable', 'in:first,second,third,fourth,last'], 'repeat_month_weekday' => ['nullable', 'in:SU,MO,TU,WE,TH,FR,SA'], + 'attendees' => ['nullable', 'array'], + 'attendees.*.attendee_uri' => ['nullable', 'string', 'max:500'], + 'attendees.*.email' => ['nullable', 'email', 'max:255'], + 'attendees.*.name' => ['nullable', 'string', 'max:200'], + 'attendees.*.optional' => ['nullable', 'boolean'], + 'attendees.*.role' => ['nullable', 'string', 'max:32'], + 'attendees.*.partstat' => ['nullable', 'string', 'max:32'], + 'attendees.*.cutype' => ['nullable', 'string', 'max:32'], + 'attendees.*.rsvp' => ['nullable', 'boolean'], + 'attendees.*.is_organizer' => ['nullable', 'boolean'], + 'attendees.*.extra' => ['nullable', 'array'], + // normalized location hints (optional) 'loc_display_name' => ['nullable', 'string'], 'loc_place_name' => ['nullable', 'string'], @@ -253,6 +273,7 @@ class EventController extends Controller } $uid = Str::uuid() . '@' . parse_url(config('app.url'), PHP_URL_HOST); + $attendees = $this->resolveAttendeesPayload($request, null, $attendeeSync); $rrule = $this->buildRruleFromRequest($request, $this->parseLocalInputToTz($data['start_at'], $tz, $isAllDay)); $extra = $this->mergeRecurrenceExtra([], $rrule, $tz, $request); @@ -269,6 +290,7 @@ class EventController extends Controller 'location' => $data['location'] ?? '', 'tzid' => $rrule ? $tz : null, 'rrule' => $rrule, + 'attendees' => $attendees, ]); $event = Event::create([ @@ -299,13 +321,21 @@ class EventController extends Controller 'extra' => $extra, ]); + $attendeeSync->syncRows($event, $attendees); + return redirect()->route('calendar.show', $calendar); } /** * update vevent + meta */ - public function update(Request $request, Calendar $calendar, Event $event, EventRecurrence $recurrence): RedirectResponse + public function update( + Request $request, + Calendar $calendar, + Event $event, + EventRecurrence $recurrence, + EventAttendeeSynchronizer $attendeeSync + ): RedirectResponse { $this->authorize('update', $calendar); @@ -339,6 +369,18 @@ class EventController extends Controller 'repeat_month_days.*' => ['integer', 'min:1', 'max:31'], 'repeat_month_week' => ['nullable', 'in:first,second,third,fourth,last'], 'repeat_month_weekday' => ['nullable', 'in:SU,MO,TU,WE,TH,FR,SA'], + + 'attendees' => ['nullable', 'array'], + 'attendees.*.attendee_uri' => ['nullable', 'string', 'max:500'], + 'attendees.*.email' => ['nullable', 'email', 'max:255'], + 'attendees.*.name' => ['nullable', 'string', 'max:200'], + 'attendees.*.optional' => ['nullable', 'boolean'], + 'attendees.*.role' => ['nullable', 'string', 'max:32'], + 'attendees.*.partstat' => ['nullable', 'string', 'max:32'], + 'attendees.*.cutype' => ['nullable', 'string', 'max:32'], + 'attendees.*.rsvp' => ['nullable', 'boolean'], + 'attendees.*.is_organizer' => ['nullable', 'boolean'], + 'attendees.*.extra' => ['nullable', 'array'], ]; if ($request->boolean('all_day')) { @@ -368,6 +410,7 @@ class EventController extends Controller } $uid = $event->uid; + $attendees = $this->resolveAttendeesPayload($request, $event, $attendeeSync); $rrule = $this->buildRruleFromRequest($request, $this->parseLocalInputToTz($data['start_at'], $tz, $isAllDay)); $extra = $event->meta?->extra ?? []; @@ -388,6 +431,7 @@ class EventController extends Controller 'rrule' => $rruleForIcs, 'exdate' => $extra['exdate'] ?? [], 'rdate' => $extra['rdate'] ?? [], + 'attendees' => $attendees, ]); $event->update([ @@ -410,6 +454,8 @@ class EventController extends Controller 'extra' => $extra, ]); + $attendeeSync->syncRows($event, $attendees); + return redirect()->route('calendar.show', $calendar); } @@ -669,6 +715,44 @@ class EventController extends Controller ); } + private function buildAttendeeFormData(Request $request, ?Event $event = null): array + { + $attendees = $request->old('attendees'); + + if (!is_array($attendees)) { + $attendees = $event?->attendees + ? $event->attendees->map(function ($attendee) { + return [ + 'attendee_uri' => $attendee->attendee_uri, + 'email' => $attendee->email, + 'name' => $attendee->name, + 'optional' => strtoupper((string) $attendee->role) === 'OPT-PARTICIPANT', + 'rsvp' => $attendee->rsvp, + 'verified' => !empty($attendee->attendee_user_id), + ]; + })->values()->all() + : []; + } + + $attendees = array_values(array_map(function ($attendee) { + $row = is_array($attendee) ? $attendee : []; + $optional = array_key_exists('optional', $row) + ? (bool) $row['optional'] + : strtoupper((string) ($row['role'] ?? '')) === 'OPT-PARTICIPANT'; + + return [ + 'attendee_uri' => $row['attendee_uri'] ?? null, + 'email' => $row['email'] ?? null, + 'name' => $row['name'] ?? null, + 'optional' => $optional, + 'rsvp' => array_key_exists('rsvp', $row) ? (bool) $row['rsvp'] : true, + 'verified' => (bool) ($row['verified'] ?? false), + ]; + }, $attendees)); + + return compact('attendees'); + } + private function mergeRecurrenceExtra(array $extra, ?string $rrule, string $tz, Request $request): array { if ($rrule === null) { @@ -708,6 +792,49 @@ class EventController extends Controller }, $value))); } + private function resolveAttendeesPayload( + Request $request, + ?Event $event, + EventAttendeeSynchronizer $attendeeSync + ): array { + if ($request->exists('attendees_present')) { + return $this->normalizeRequestAttendees((array) $request->input('attendees', [])); + } + + if (!$event) { + return []; + } + + return $attendeeSync->extractFromCalendarData($event->calendardata); + } + + private function normalizeRequestAttendees(array $attendees): array + { + $rows = []; + + foreach ($attendees as $attendee) { + if (!is_array($attendee)) { + continue; + } + + $isOptional = (bool) ($attendee['optional'] ?? false); + + $rows[] = [ + 'attendee_uri' => $attendee['attendee_uri'] ?? null, + 'email' => $attendee['email'] ?? null, + 'name' => $attendee['name'] ?? null, + 'role' => $isOptional ? 'OPT-PARTICIPANT' : 'REQ-PARTICIPANT', + 'partstat' => 'NEEDS-ACTION', + 'cutype' => 'INDIVIDUAL', + 'rsvp' => array_key_exists('rsvp', $attendee) ? (bool) $attendee['rsvp'] : true, + 'is_organizer' => false, + 'extra' => is_array($attendee['extra'] ?? null) ? $attendee['extra'] : null, + ]; + } + + return $rows; + } + /** * resolve location_id from hints or geocoding */ diff --git a/app/Jobs/SyncSubscription.php b/app/Jobs/SyncSubscription.php index 647f7b6..fd0de24 100644 --- a/app/Jobs/SyncSubscription.php +++ b/app/Jobs/SyncSubscription.php @@ -7,6 +7,7 @@ use App\Models\CalendarInstance; use App\Models\Event; use App\Models\EventMeta; use App\Models\Subscription; +use App\Services\Event\EventAttendeeSynchronizer; use Carbon\Carbon; use Illuminate\Bus\Queueable; use Illuminate\Contracts\Queue\ShouldQueue; @@ -32,7 +33,7 @@ class SyncSubscription implements ShouldQueue $this->subscription = $subscription; } - public function handle(): void + public function handle(EventAttendeeSynchronizer $attendeeSync): void { // normalize the source a bit so comparisons/logging are consistent $source = rtrim(trim((string) $this->subscription->source), '/'); @@ -171,6 +172,8 @@ class SyncSubscription implements ShouldQueue 'end_on' => $endOn, 'tzid' => $tzid, ]); + + $attendeeSync->syncFromCalendarData($object, $blob); } // sync is ok diff --git a/app/Models/Event.php b/app/Models/Event.php index 53f0680..33ac74c 100644 --- a/app/Models/Event.php +++ b/app/Models/Event.php @@ -4,6 +4,7 @@ namespace App\Models; use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Relations\BelongsTo; +use Illuminate\Database\Eloquent\Relations\HasMany; use Illuminate\Database\Eloquent\Relations\HasOne; class Event extends Model @@ -48,6 +49,11 @@ class Event extends Model return $this->hasOne(EventMeta::class, 'event_id'); } + public function attendees(): HasMany + { + return $this->hasMany(EventAttendee::class, 'event_id'); + } + /** * scopes and helpers **/ diff --git a/app/Models/EventAttendee.php b/app/Models/EventAttendee.php new file mode 100644 index 0000000..0fa4256 --- /dev/null +++ b/app/Models/EventAttendee.php @@ -0,0 +1,41 @@ + 'boolean', + 'is_organizer' => 'boolean', + 'extra' => 'array', + ]; + + public function event(): BelongsTo + { + return $this->belongsTo(Event::class, 'event_id'); + } + + public function user(): BelongsTo + { + return $this->belongsTo(User::class, 'attendee_user_id'); + } +} diff --git a/app/Models/User.php b/app/Models/User.php index 72e7101..734e5eb 100644 --- a/app/Models/User.php +++ b/app/Models/User.php @@ -159,6 +159,11 @@ class User extends Authenticatable return $this->hasMany(UserSetting::class, 'user_id'); } + public function eventAttendees(): HasMany + { + return $this->hasMany(EventAttendee::class, 'attendee_user_id'); + } + /** * get a user setting by key */ diff --git a/app/Services/Event/EventAttendeeSynchronizer.php b/app/Services/Event/EventAttendeeSynchronizer.php new file mode 100644 index 0000000..61aa1c0 --- /dev/null +++ b/app/Services/Event/EventAttendeeSynchronizer.php @@ -0,0 +1,236 @@ +extractFromCalendarData($calendarData ?? $event->calendardata); + $this->syncRows($event, $attendees); + } + + public function syncRows(Event $event, array $attendees): void + { + $rows = $this->normalizeRows($attendees); + + $event->attendees()->delete(); + if (empty($rows)) { + return; + } + + foreach ($rows as &$row) { + $email = $row['email'] ?? null; + if (!$email) { + $row['attendee_user_id'] = null; + continue; + } + + $row['attendee_user_id'] = User::query() + ->whereRaw('lower(email) = ?', [Str::lower($email)]) + ->value('id'); + } + unset($row); + + $event->attendees()->createMany($rows); + } + + public function extractFromCalendarData(?string $calendarData): array + { + if (!$calendarData) { + return []; + } + + try { + $vcalendar = Reader::read($calendarData); + } catch (\Throwable $e) { + return []; + } + + $vevent = $vcalendar->select('VEVENT')[0] ?? null; + if (!$vevent instanceof VEvent) { + return []; + } + + return $this->extractFromVevent($vevent); + } + + public function extractFromVevent(VEvent $vevent): array + { + $items = []; + + foreach ($vevent->select('ATTENDEE') as $attendee) { + $params = $this->extractParams($attendee); + $uri = $this->normalizeAttendeeUri((string) $attendee->getValue()); + $email = $this->extractEmail($uri, $params); + + $items[] = [ + 'attendee_uri' => $uri, + 'email' => $email, + 'name' => $params['CN'] ?? null, + 'role' => isset($params['ROLE']) ? strtoupper($params['ROLE']) : null, + 'partstat' => isset($params['PARTSTAT']) ? strtoupper($params['PARTSTAT']) : null, + 'cutype' => isset($params['CUTYPE']) ? strtoupper($params['CUTYPE']) : null, + 'rsvp' => $this->normalizeBooleanParam($params['RSVP'] ?? null), + 'is_organizer' => false, + 'extra' => $this->extraParams($params, [ + 'CN', + 'ROLE', + 'PARTSTAT', + 'CUTYPE', + 'RSVP', + 'EMAIL', + ]), + ]; + } + + if (isset($vevent->ORGANIZER)) { + $organizer = $vevent->ORGANIZER; + $params = $this->extractParams($organizer); + $uri = $this->normalizeAttendeeUri((string) $organizer->getValue()); + $email = $this->extractEmail($uri, $params); + + $items[] = [ + 'attendee_uri' => $uri, + 'email' => $email, + 'name' => $params['CN'] ?? null, + 'role' => 'CHAIR', + 'partstat' => null, + 'cutype' => isset($params['CUTYPE']) ? strtoupper($params['CUTYPE']) : null, + 'rsvp' => null, + 'is_organizer' => true, + 'extra' => $this->extraParams($params, ['CN', 'CUTYPE', 'EMAIL']), + ]; + } + + return $items; + } + + private function normalizeRows(array $attendees): array + { + $rows = []; + $seen = []; + + foreach ($attendees as $item) { + $email = $this->normalizeEmail(Arr::get($item, 'email')); + $uri = $this->normalizeAttendeeUri(Arr::get($item, 'attendee_uri')); + if (!$uri && $email) { + $uri = 'mailto:' . $email; + } + + if (!$uri && !$email) { + continue; + } + + $key = Str::lower($uri ?: $email); + if (isset($seen[$key])) { + continue; + } + $seen[$key] = true; + + $role = Arr::get($item, 'role'); + $partstat = Arr::get($item, 'partstat'); + $cutype = Arr::get($item, 'cutype'); + + $rows[] = [ + 'attendee_uri' => $uri, + 'email' => $email, + 'name' => $this->normalizeString(Arr::get($item, 'name')), + 'role' => $role ? strtoupper((string) $role) : null, + 'partstat' => $partstat ? strtoupper((string) $partstat) : null, + 'cutype' => $cutype ? strtoupper((string) $cutype) : null, + 'rsvp' => Arr::exists($item, 'rsvp') ? (bool) Arr::get($item, 'rsvp') : null, + 'is_organizer' => (bool) Arr::get($item, 'is_organizer', false), + 'extra' => is_array(Arr::get($item, 'extra')) ? Arr::get($item, 'extra') : null, + ]; + } + + return $rows; + } + + private function extractParams($property): array + { + $params = []; + foreach ($property->parameters() as $parameter) { + $params[strtoupper((string) $parameter->name)] = trim((string) $parameter->getValue()); + } + + return $params; + } + + private function extractEmail(?string $uri, array $params): ?string + { + $fromParam = $this->normalizeEmail($params['EMAIL'] ?? null); + if ($fromParam) { + return $fromParam; + } + + if (!$uri) { + return null; + } + + if (str_starts_with(Str::lower($uri), 'mailto:')) { + return $this->normalizeEmail(substr($uri, 7)); + } + + return str_contains($uri, '@') ? $this->normalizeEmail($uri) : null; + } + + private function normalizeAttendeeUri(mixed $uri): ?string + { + $value = trim((string) $uri); + if ($value === '') { + return null; + } + + if (str_contains($value, '@') && !str_contains($value, ':')) { + return 'mailto:' . Str::lower($value); + } + + if (str_starts_with(Str::lower($value), 'mailto:')) { + return 'mailto:' . Str::lower(substr($value, 7)); + } + + return $value; + } + + private function normalizeEmail(mixed $value): ?string + { + $email = trim((string) $value); + if ($email === '') { + return null; + } + + return Str::lower($email); + } + + private function normalizeString(mixed $value): ?string + { + $string = trim((string) $value); + return $string === '' ? null : $string; + } + + private function normalizeBooleanParam(?string $value): ?bool + { + if ($value === null || $value === '') { + return null; + } + + return in_array(strtoupper($value), ['1', 'TRUE', 'YES'], true); + } + + private function extraParams(array $params, array $known): ?array + { + $knownMap = array_fill_keys($known, true); + $extra = array_diff_key($params, $knownMap); + + return empty($extra) ? null : $extra; + } +} diff --git a/app/Services/Event/EventRecurrence.php b/app/Services/Event/EventRecurrence.php index 296d3a5..75ff496 100644 --- a/app/Services/Event/EventRecurrence.php +++ b/app/Services/Event/EventRecurrence.php @@ -59,6 +59,67 @@ class EventRecurrence $vevent->add('LOCATION', $data['location']); } + $attendees = $data['attendees'] ?? []; + if (is_array($attendees)) { + $organizerAdded = false; + + foreach ($attendees as $attendee) { + if (!is_array($attendee)) { + continue; + } + + $uri = $this->normalizeAttendeeUri( + $attendee['attendee_uri'] ?? ($attendee['email'] ?? null) + ); + + if (!$uri) { + continue; + } + + $params = []; + if (!empty($attendee['name'])) { + $params['CN'] = (string) $attendee['name']; + } + if (!empty($attendee['role'])) { + $params['ROLE'] = strtoupper((string) $attendee['role']); + } + if (!empty($attendee['partstat'])) { + $params['PARTSTAT'] = strtoupper((string) $attendee['partstat']); + } + if (!empty($attendee['cutype'])) { + $params['CUTYPE'] = strtoupper((string) $attendee['cutype']); + } + if (array_key_exists('rsvp', $attendee) && $attendee['rsvp'] !== null) { + $params['RSVP'] = (bool) $attendee['rsvp'] ? 'TRUE' : 'FALSE'; + } + + if (!empty($attendee['extra']) && is_array($attendee['extra'])) { + foreach ($attendee['extra'] as $key => $value) { + if (!is_scalar($value)) { + continue; + } + + $paramName = strtoupper((string) $key); + if ($paramName === '' || isset($params[$paramName])) { + continue; + } + + $params[$paramName] = (string) $value; + } + } + + if ((bool) ($attendee['is_organizer'] ?? false) && !$organizerAdded) { + $organizerParams = $params; + unset($organizerParams['ROLE'], $organizerParams['PARTSTAT'], $organizerParams['RSVP']); + $vevent->add('ORGANIZER', $uri, $organizerParams); + $organizerAdded = true; + continue; + } + + $vevent->add('ATTENDEE', $uri, $params); + } + } + $rrule = $data['rrule'] ?? null; if ($rrule) { $vevent->add('RRULE', $rrule); @@ -216,4 +277,22 @@ class EventRecurrence return null; } } + + private function normalizeAttendeeUri(mixed $value): ?string + { + $uri = trim((string) $value); + if ($uri === '') { + return null; + } + + if (str_contains($uri, '@') && !str_contains($uri, ':')) { + return 'mailto:' . Str::lower($uri); + } + + if (str_starts_with(Str::lower($uri), 'mailto:')) { + return 'mailto:' . Str::lower(substr($uri, 7)); + } + + return $uri; + } } diff --git a/database/migrations/2026_02_19_000000_create_event_attendees_table.php b/database/migrations/2026_02_19_000000_create_event_attendees_table.php new file mode 100644 index 0000000..eedac5f --- /dev/null +++ b/database/migrations/2026_02_19_000000_create_event_attendees_table.php @@ -0,0 +1,47 @@ +id(); + + $table->unsignedInteger('event_id'); + $table->foreign('event_id') + ->references('id') + ->on('calendarobjects') + ->cascadeOnDelete(); + + $table->ulid('attendee_user_id')->nullable(); + $table->foreign('attendee_user_id') + ->references('id') + ->on('users') + ->nullOnDelete(); + + $table->string('attendee_uri', 500)->nullable(); + $table->string('email')->nullable(); + $table->string('name')->nullable(); + $table->string('role', 32)->nullable(); + $table->string('partstat', 32)->nullable(); + $table->string('cutype', 32)->nullable(); + $table->boolean('rsvp')->nullable(); + $table->boolean('is_organizer')->default(false); + $table->json('extra')->nullable(); + $table->timestamps(); + + $table->index(['event_id', 'email']); + $table->index('attendee_user_id'); + $table->unique(['event_id', 'attendee_uri']); + }); + } + + public function down(): void + { + Schema::dropIfExists('event_attendees'); + } +}; diff --git a/database/migrations/2026_02_19_000100_add_demo_users_and_contacts.php b/database/migrations/2026_02_19_000100_add_demo_users_and_contacts.php new file mode 100644 index 0000000..02829c1 --- /dev/null +++ b/database/migrations/2026_02_19_000100_add_demo_users_and_contacts.php @@ -0,0 +1,156 @@ + 'Daniel', + 'last' => 'Gioia', + 'email' => 'daniel@gioia.email', + 'uid' => 'kithkin-contact-daniel-gioia', + ], + [ + 'first' => 'James', + 'last' => 'Gioia', + 'email' => 'james@gioia.email', + 'uid' => 'kithkin-contact-james-gioia', + ], + ]; + + public function up(): void + { + foreach ($this->contacts as $contact) { + $email = Str::lower($contact['email']); + $existingId = DB::table('users')->where('email', $email)->value('id'); + + if ($existingId) { + DB::table('users') + ->where('id', $existingId) + ->update([ + 'uri' => 'principals/' . $email, + 'firstname' => $contact['first'], + 'lastname' => $contact['last'], + 'displayname' => trim($contact['first'] . ' ' . $contact['last']), + 'timezone' => config('app.timezone', 'UTC'), + 'email_verified_at' => now(), + 'updated_at' => now(), + ]); + } else { + DB::table('users')->insert([ + 'id' => (string) Str::ulid(), + 'uri' => 'principals/' . $email, + 'firstname' => $contact['first'], + 'lastname' => $contact['last'], + 'displayname' => trim($contact['first'] . ' ' . $contact['last']), + 'email' => $email, + 'timezone' => config('app.timezone', 'UTC'), + 'password' => Hash::make((string) Str::uuid()), + 'email_verified_at' => now(), + 'created_at' => now(), + 'updated_at' => now(), + ]); + } + } + + $ownerEmail = Str::lower((string) env('ADMIN_EMAIL', 'admin@example.com')); + $owner = DB::table('users')->where('email', $ownerEmail)->first(); + if (!$owner) { + $owner = DB::table('users')->orderBy('created_at')->first(); + } + if (!$owner) { + return; + } + + $principal = $owner->uri ?: ('principals/' . $owner->email); + + $addressbookId = DB::table('addressbooks') + ->where('principaluri', $principal) + ->orderBy('id') + ->value('id'); + + if (!$addressbookId) { + $addressbookId = DB::table('addressbooks')->insertGetId([ + 'principaluri' => $principal, + 'uri' => 'default', + 'displayname' => 'Default Address Book', + ]); + + DB::table('addressbook_meta')->updateOrInsert( + ['addressbook_id' => $addressbookId], + [ + 'color' => '#ff40ff', + 'is_default' => true, + 'settings' => null, + 'created_at' => now(), + 'updated_at' => now(), + ] + ); + } + + foreach ($this->contacts as $contact) { + $email = Str::lower($contact['email']); + $fn = trim($contact['first'] . ' ' . $contact['last']); + $uid = $contact['uid']; + + $vcard = "BEGIN:VCARD\r\n" + . "VERSION:3.0\r\n" + . "FN:{$fn}\r\n" + . "EMAIL:{$email}\r\n" + . "UID:{$uid}\r\n" + . "END:VCARD\r\n"; + + $existingCardId = DB::table('cards') + ->where('addressbookid', $addressbookId) + ->where(function ($query) use ($uid, $email) { + $query->where('carddata', 'like', '%UID:' . $uid . '%') + ->orWhere('carddata', 'like', '%EMAIL:' . $email . '%'); + }) + ->value('id'); + + if ($existingCardId) { + DB::table('cards') + ->where('id', $existingCardId) + ->update([ + 'lastmodified' => now()->timestamp, + 'etag' => md5($vcard), + 'size' => strlen($vcard), + 'carddata' => $vcard, + ]); + continue; + } + + DB::table('cards')->insert([ + 'addressbookid' => $addressbookId, + 'uri' => (string) Str::uuid() . '.vcf', + 'lastmodified' => now()->timestamp, + 'etag' => md5($vcard), + 'size' => strlen($vcard), + 'carddata' => $vcard, + ]); + } + } + + public function down(): void + { + foreach ($this->contacts as $contact) { + $email = Str::lower($contact['email']); + $uid = $contact['uid']; + + DB::table('cards') + ->where(function ($query) use ($uid, $email) { + $query->where('carddata', 'like', '%UID:' . $uid . '%') + ->orWhere('carddata', 'like', '%EMAIL:' . $email . '%'); + }) + ->delete(); + + DB::table('users') + ->where('email', $email) + ->delete(); + } + } +}; diff --git a/lang/en/calendar.php b/lang/en/calendar.php index bb2c1f1..eb01f31 100644 --- a/lang/en/calendar.php +++ b/lang/en/calendar.php @@ -77,6 +77,16 @@ return [ 'alerts_coming' => 'No alerts set. (Coming soon)', 'invitees' => 'Invitees', 'invitees_coming' => 'Invitees and RSVP tracking coming soon.', + 'attendees' => [ + 'add' => 'Add attendee', + 'add_button' => 'Add', + 'remove' => 'Remove', + 'help' => 'Search your contacts or enter an email address.', + 'search_placeholder' => 'Search contacts or type email', + 'verified' => 'Verified Kithkin user', + 'optional' => 'Optional', + 'rsvp' => 'Request RSVP', + ], 'attachments' => 'Attachments', 'attachments_coming' => 'Attachment support coming soon.', 'notes' => 'Notes', diff --git a/lang/it/calendar.php b/lang/it/calendar.php index 54ba410..887338c 100644 --- a/lang/it/calendar.php +++ b/lang/it/calendar.php @@ -77,6 +77,16 @@ return [ 'alerts_coming' => 'Nessun avviso impostato. (In arrivo)', 'invitees' => 'Invitati', 'invitees_coming' => 'Invitati e RSVP in arrivo.', + 'attendees' => [ + 'add' => 'Aggiungi invitato', + 'add_button' => 'Aggiungi', + 'remove' => 'Rimuovi', + 'help' => 'Cerca nei tuoi contatti o inserisci un indirizzo email.', + 'search_placeholder' => 'Cerca contatti o digita email', + 'verified' => 'Utente Kithkin verificato', + 'optional' => 'Opzionale', + 'rsvp' => 'Richiedi RSVP', + ], 'attachments' => 'Allegati', 'attachments_coming' => 'Supporto allegati in arrivo.', 'notes' => 'Note', diff --git a/resources/css/lib/modal.css b/resources/css/lib/modal.css index aba3203..8012ec8 100644 --- a/resources/css/lib/modal.css +++ b/resources/css/lib/modal.css @@ -144,9 +144,9 @@ dialog { @apply opacity-0 invisible; pointer-events: none; transition: - translate 350ms ease-in-out, - opacity 150ms ease-in-out, - visibility 150ms ease-in-out; + translate 250ms ease-in-out, + opacity 250ms ease-in-out, + visibility 250ms ease-in-out; &.is-visible { @apply opacity-100 visible h-auto; diff --git a/resources/js/app.js b/resources/js/app.js index 46eded2..66ce0c5 100644 --- a/resources/js/app.js +++ b/resources/js/app.js @@ -35,6 +35,22 @@ const SELECTORS = { tabsRoot: '[data-tabs]', tabButton: '[role=\"tab\"]', tabPanel: '[role=\"tabpanel\"]', + attendeesRoot: '[data-attendees]', + attendeesList: '[data-attendees-list]', + attendeeTemplate: 'template[data-attendee-template]', + attendeeLookup: '[data-attendee-lookup]', + attendeeSuggestions: '#attendee-suggestions', + attendeeAddManual: '[data-attendee-add-manual]', + attendeePick: '[data-attendee-pick]', + attendeeRemove: '[data-attendee-remove]', + attendeeRow: '[data-attendee-row]', + attendeeRole: '[data-attendee-role]', + attendeeName: '[data-attendee-name]', + attendeeOptional: '[data-attendee-optional]', + attendeeDisplay: '[data-attendee-display]', + attendeeVerified: '[data-attendee-verified]', + attendeeEmail: '[data-attendee-email]', + attendeeUri: '[data-attendee-uri]', }; function syncModalRootClass(modal) { @@ -1227,6 +1243,162 @@ function initTimeViewAutoScroll(root = document) calendar.dataset.autoscrolled = '1'; } +/** + * + * attendees form controls (contact lookup + email fallback) + */ +function initAttendeeControls(root = document) { + const normalizeEmail = (value) => String(value || '').trim().toLowerCase(); + const isValidEmail = (value) => /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(normalizeEmail(value)); + + const formatDisplay = (name, email) => { + const n = String(name || '').trim(); + const e = normalizeEmail(email); + if (!e) return ''; + return n ? `${n} <${e}>` : e; + }; + + root.querySelectorAll(SELECTORS.attendeesRoot).forEach((container) => { + if (container.__attendeesWired) return; + container.__attendeesWired = true; + + const list = container.querySelector(SELECTORS.attendeesList); + const template = container.querySelector(SELECTORS.attendeeTemplate); + const lookup = container.querySelector(SELECTORS.attendeeLookup); + const suggestions = container.querySelector(SELECTORS.attendeeSuggestions); + const addManual = container.querySelector(SELECTORS.attendeeAddManual); + if (!list || !template || !lookup) return; + + const nextIndex = () => { + const current = parseInt(container.dataset.nextIndex || '0', 10); + const safe = Number.isNaN(current) ? 0 : current; + container.dataset.nextIndex = String(safe + 1); + return safe; + }; + + const updateRole = (row) => { + const optionalInput = row.querySelector(SELECTORS.attendeeOptional); + const roleInput = row.querySelector(SELECTORS.attendeeRole); + if (!optionalInput || !roleInput) return; + roleInput.value = optionalInput.checked ? 'OPT-PARTICIPANT' : 'REQ-PARTICIPANT'; + }; + + const wireRow = (row) => { + if (!row || row.__attendeeRowWired) return; + row.__attendeeRowWired = true; + + const optionalInput = row.querySelector(SELECTORS.attendeeOptional); + if (optionalInput) { + optionalInput.addEventListener('change', () => updateRole(row)); + } + + updateRole(row); + }; + + const hasEmail = (email) => { + const normalized = normalizeEmail(email); + if (!normalized) return true; + + return Array.from(list.querySelectorAll(`${SELECTORS.attendeeRow} ${SELECTORS.attendeeEmail}`)) + .some((input) => normalizeEmail(input.value) === normalized); + }; + + const createRow = () => { + const index = nextIndex(); + const html = template.innerHTML.replaceAll('__INDEX__', String(index)).trim(); + if (!html) return null; + + const fragment = document.createElement('div'); + fragment.innerHTML = html; + return fragment.firstElementChild; + }; + + const addAttendee = ({ email, name = '', attendeeUri = '', verified = false }) => { + const normalizedEmail = normalizeEmail(email); + if (!isValidEmail(normalizedEmail)) return false; + if (hasEmail(normalizedEmail)) return false; + + const row = createRow(); + if (!row) return false; + + const uriInput = row.querySelector(SELECTORS.attendeeUri); + const emailInput = row.querySelector(SELECTORS.attendeeEmail); + const nameInput = row.querySelector(SELECTORS.attendeeName); + const display = row.querySelector(SELECTORS.attendeeDisplay); + const verifiedEl = row.querySelector(SELECTORS.attendeeVerified); + + if (uriInput) uriInput.value = attendeeUri || `mailto:${normalizedEmail}`; + if (emailInput) emailInput.value = normalizedEmail; + if (nameInput) nameInput.value = String(name || '').trim(); + if (display) display.textContent = formatDisplay(name, normalizedEmail); + if (verifiedEl) { + verifiedEl.classList.toggle('hidden', !verified); + } + + list.appendChild(row); + wireRow(row); + return true; + }; + + const addFromLookupIfEmail = () => { + const raw = lookup.value; + if (!isValidEmail(raw)) return false; + + const ok = addAttendee({ + email: raw, + attendeeUri: `mailto:${normalizeEmail(raw)}`, + verified: false, + }); + + if (ok) { + lookup.value = ''; + if (suggestions) suggestions.innerHTML = ''; + } + + return ok; + }; + + container.addEventListener('click', (event) => { + const removeButton = event.target.closest(SELECTORS.attendeeRemove); + if (removeButton && container.contains(removeButton)) { + event.preventDefault(); + const row = removeButton.closest(SELECTORS.attendeeRow); + row?.remove(); + return; + } + + const pickButton = event.target.closest(SELECTORS.attendeePick); + if (pickButton && container.contains(pickButton)) { + event.preventDefault(); + const email = pickButton.dataset.attendeeEmail || ''; + const name = pickButton.dataset.attendeeName || ''; + const attendeeUri = pickButton.dataset.attendeeUri || ''; + const verified = pickButton.dataset.attendeeVerified === '1'; + + const ok = addAttendee({ email, name, attendeeUri, verified }); + if (ok) { + lookup.value = ''; + if (suggestions) suggestions.innerHTML = ''; + } + return; + } + + if (addManual && event.target.closest(SELECTORS.attendeeAddManual)) { + event.preventDefault(); + addFromLookupIfEmail(); + } + }); + + lookup.addEventListener('keydown', (event) => { + if (event.key !== 'Enter') return; + event.preventDefault(); + addFromLookupIfEmail(); + }); + + list.querySelectorAll(SELECTORS.attendeeRow).forEach(wireRow); + }); +} + /** * * calendar sidebar expand toggle @@ -1484,6 +1656,7 @@ function initUI() { initNaturalEventParser(); initEventAllDayToggles(); initRecurrenceControls(); + initAttendeeControls(); initModalHandlers(); initTabs(); initTimeViewAutoScroll(); @@ -1505,6 +1678,7 @@ document.addEventListener('htmx:afterSwap', (e) => { initNaturalEventParser(e.target); initEventAllDayToggles(e.target); initRecurrenceControls(e.target); + initAttendeeControls(e.target); initModalHandlers(e.target); initTabs(e.target); initTimeViewAutoScroll(e.target); diff --git a/resources/views/event/partials/attendee-suggestions.blade.php b/resources/views/event/partials/attendee-suggestions.blade.php new file mode 100644 index 0000000..bc07f11 --- /dev/null +++ b/resources/views/event/partials/attendee-suggestions.blade.php @@ -0,0 +1,29 @@ +@if (empty($suggestions)) +
+@else + +@endif diff --git a/resources/views/event/partials/form.blade.php b/resources/views/event/partials/form.blade.php index e890e1d..736d2b6 100644 --- a/resources/views/event/partials/form.blade.php +++ b/resources/views/event/partials/form.blade.php @@ -251,7 +251,122 @@ diff --git a/routes/web.php b/routes/web.php index 78ab5e0..063555c 100644 --- a/routes/web.php +++ b/routes/web.php @@ -2,6 +2,7 @@ use Illuminate\Support\Facades\Route; use App\Http\Controllers\AccountController; +use App\Http\Controllers\AttendeeController; use App\Http\Controllers\BookController; use App\Http\Controllers\CalendarController; use App\Http\Controllers\CalendarSettingsController; @@ -127,6 +128,9 @@ Route::middleware('auth')->group(function () Route::get('/location/suggest', [LocationController::class, 'suggest']) ->name('location.suggest'); + Route::get('/attendee/suggest', [AttendeeController::class, 'suggest']) + ->name('attendee.suggest'); + // address books Route::resource('book', BookController::class) ->names('book') // books.index, books.create, …