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

This commit is contained in:
Andrew Gioia 2026-02-19 14:45:59 -05:00
parent 337f41a86b
commit 909fae5eb4
Signed by: andrew
GPG Key ID: FC09694A000800C8
17 changed files with 1215 additions and 10 deletions

View File

@ -0,0 +1,163 @@
<?php
namespace App\Http\Controllers;
use App\Models\Card;
use App\Models\User;
use Illuminate\Http\Request;
use Illuminate\Support\Str;
use Sabre\VObject\Reader;
class AttendeeController extends Controller
{
public function suggest(Request $request)
{
$q = trim((string) $request->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;
}
}

View File

@ -5,13 +5,13 @@ namespace App\Http\Controllers;
use App\Models\Calendar; use App\Models\Calendar;
use App\Models\Event; use App\Models\Event;
use App\Models\Location; use App\Models\Location;
use App\Services\Event\EventAttendeeSynchronizer;
use App\Services\Event\EventRecurrence; use App\Services\Event\EventRecurrence;
use App\Services\Location\Geocoder; use App\Services\Location\Geocoder;
use Carbon\Carbon; use Carbon\Carbon;
use Illuminate\Http\RedirectResponse; use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request; use Illuminate\Http\Request;
use Illuminate\Support\Str; use Illuminate\Support\Str;
use Illuminate\Support\Facades\Log;
use Symfony\Component\HttpFoundation\Response; use Symfony\Component\HttpFoundation\Response;
class EventController extends Controller class EventController extends Controller
@ -59,6 +59,7 @@ class EventController extends Controller
'rrule', 'rrule',
); );
$data = array_merge($data, $this->buildRecurrenceFormData($request, $start, $tz, $rrule)); $data = array_merge($data, $this->buildRecurrenceFormData($request, $start, $tz, $rrule));
$data = array_merge($data, $this->buildAttendeeFormData($request));
if ($request->header('HX-Request') === 'true') { if ($request->header('HX-Request') === 'true') {
return view('event.partials.form-modal', $data); return view('event.partials.form-modal', $data);
@ -82,7 +83,7 @@ class EventController extends Controller
$instance = $calendar->instanceForUser($request->user()); $instance = $calendar->instanceForUser($request->user());
$tz = $this->displayTimezone($calendar, $request); $tz = $this->displayTimezone($calendar, $request);
$event->load('meta'); $event->load('meta', 'attendees');
$start = $event->meta?->start_at $start = $event->meta?->start_at
? Carbon::parse($event->meta->start_at)->timezone($tz)->format('Y-m-d\TH:i') ? 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 = compact('calendar', 'instance', 'event', 'start', 'end', 'tz', 'rrule');
$data = array_merge($data, $this->buildRecurrenceFormData($request, $start ?? '', $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') { if ($request->header('HX-Request') === 'true') {
return view('event.partials.form-modal', $data); return view('event.partials.form-modal', $data);
@ -117,7 +119,7 @@ class EventController extends Controller
$this->authorize('view', $event); $this->authorize('view', $event);
$event->loadMissing(['meta', 'meta.venue']); $event->loadMissing(['meta', 'meta.venue', 'attendees']);
$isHtmx = $request->header('HX-Request') === 'true'; $isHtmx = $request->header('HX-Request') === 'true';
$tz = $this->displayTimezone($calendar, $request); $tz = $this->displayTimezone($calendar, $request);
@ -182,7 +184,13 @@ class EventController extends Controller
/** /**
* insert vevent into sabres calendarobjects + meta row * insert vevent into sabres 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); $this->authorize('update', $calendar);
@ -213,6 +221,18 @@ class EventController extends Controller
'repeat_month_week' => ['nullable', 'in:first,second,third,fourth,last'], 'repeat_month_week' => ['nullable', 'in:first,second,third,fourth,last'],
'repeat_month_weekday' => ['nullable', 'in:SU,MO,TU,WE,TH,FR,SA'], '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) // normalized location hints (optional)
'loc_display_name' => ['nullable', 'string'], 'loc_display_name' => ['nullable', 'string'],
'loc_place_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); $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)); $rrule = $this->buildRruleFromRequest($request, $this->parseLocalInputToTz($data['start_at'], $tz, $isAllDay));
$extra = $this->mergeRecurrenceExtra([], $rrule, $tz, $request); $extra = $this->mergeRecurrenceExtra([], $rrule, $tz, $request);
@ -269,6 +290,7 @@ class EventController extends Controller
'location' => $data['location'] ?? '', 'location' => $data['location'] ?? '',
'tzid' => $rrule ? $tz : null, 'tzid' => $rrule ? $tz : null,
'rrule' => $rrule, 'rrule' => $rrule,
'attendees' => $attendees,
]); ]);
$event = Event::create([ $event = Event::create([
@ -299,13 +321,21 @@ class EventController extends Controller
'extra' => $extra, 'extra' => $extra,
]); ]);
$attendeeSync->syncRows($event, $attendees);
return redirect()->route('calendar.show', $calendar); return redirect()->route('calendar.show', $calendar);
} }
/** /**
* update vevent + meta * 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); $this->authorize('update', $calendar);
@ -339,6 +369,18 @@ class EventController extends Controller
'repeat_month_days.*' => ['integer', 'min:1', 'max:31'], 'repeat_month_days.*' => ['integer', 'min:1', 'max:31'],
'repeat_month_week' => ['nullable', 'in:first,second,third,fourth,last'], 'repeat_month_week' => ['nullable', 'in:first,second,third,fourth,last'],
'repeat_month_weekday' => ['nullable', 'in:SU,MO,TU,WE,TH,FR,SA'], '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')) { if ($request->boolean('all_day')) {
@ -368,6 +410,7 @@ class EventController extends Controller
} }
$uid = $event->uid; $uid = $event->uid;
$attendees = $this->resolveAttendeesPayload($request, $event, $attendeeSync);
$rrule = $this->buildRruleFromRequest($request, $this->parseLocalInputToTz($data['start_at'], $tz, $isAllDay)); $rrule = $this->buildRruleFromRequest($request, $this->parseLocalInputToTz($data['start_at'], $tz, $isAllDay));
$extra = $event->meta?->extra ?? []; $extra = $event->meta?->extra ?? [];
@ -388,6 +431,7 @@ class EventController extends Controller
'rrule' => $rruleForIcs, 'rrule' => $rruleForIcs,
'exdate' => $extra['exdate'] ?? [], 'exdate' => $extra['exdate'] ?? [],
'rdate' => $extra['rdate'] ?? [], 'rdate' => $extra['rdate'] ?? [],
'attendees' => $attendees,
]); ]);
$event->update([ $event->update([
@ -410,6 +454,8 @@ class EventController extends Controller
'extra' => $extra, 'extra' => $extra,
]); ]);
$attendeeSync->syncRows($event, $attendees);
return redirect()->route('calendar.show', $calendar); 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 private function mergeRecurrenceExtra(array $extra, ?string $rrule, string $tz, Request $request): array
{ {
if ($rrule === null) { if ($rrule === null) {
@ -708,6 +792,49 @@ class EventController extends Controller
}, $value))); }, $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 * resolve location_id from hints or geocoding
*/ */

View File

@ -7,6 +7,7 @@ use App\Models\CalendarInstance;
use App\Models\Event; use App\Models\Event;
use App\Models\EventMeta; use App\Models\EventMeta;
use App\Models\Subscription; use App\Models\Subscription;
use App\Services\Event\EventAttendeeSynchronizer;
use Carbon\Carbon; use Carbon\Carbon;
use Illuminate\Bus\Queueable; use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue; use Illuminate\Contracts\Queue\ShouldQueue;
@ -32,7 +33,7 @@ class SyncSubscription implements ShouldQueue
$this->subscription = $subscription; $this->subscription = $subscription;
} }
public function handle(): void public function handle(EventAttendeeSynchronizer $attendeeSync): void
{ {
// normalize the source a bit so comparisons/logging are consistent // normalize the source a bit so comparisons/logging are consistent
$source = rtrim(trim((string) $this->subscription->source), '/'); $source = rtrim(trim((string) $this->subscription->source), '/');
@ -171,6 +172,8 @@ class SyncSubscription implements ShouldQueue
'end_on' => $endOn, 'end_on' => $endOn,
'tzid' => $tzid, 'tzid' => $tzid,
]); ]);
$attendeeSync->syncFromCalendarData($object, $blob);
} }
// sync is ok // sync is ok

View File

@ -4,6 +4,7 @@ namespace App\Models;
use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo; use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Database\Eloquent\Relations\HasOne; use Illuminate\Database\Eloquent\Relations\HasOne;
class Event extends Model class Event extends Model
@ -48,6 +49,11 @@ class Event extends Model
return $this->hasOne(EventMeta::class, 'event_id'); return $this->hasOne(EventMeta::class, 'event_id');
} }
public function attendees(): HasMany
{
return $this->hasMany(EventAttendee::class, 'event_id');
}
/** /**
* scopes and helpers * scopes and helpers
**/ **/

View File

@ -0,0 +1,41 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
class EventAttendee extends Model
{
protected $table = 'event_attendees';
protected $fillable = [
'event_id',
'attendee_user_id',
'attendee_uri',
'email',
'name',
'role',
'partstat',
'cutype',
'rsvp',
'is_organizer',
'extra',
];
protected $casts = [
'rsvp' => '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');
}
}

View File

@ -159,6 +159,11 @@ class User extends Authenticatable
return $this->hasMany(UserSetting::class, 'user_id'); 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 * get a user setting by key
*/ */

View File

@ -0,0 +1,236 @@
<?php
namespace App\Services\Event;
use App\Models\Event;
use App\Models\User;
use Illuminate\Support\Arr;
use Illuminate\Support\Str;
use Sabre\VObject\Component\VEvent;
use Sabre\VObject\Reader;
class EventAttendeeSynchronizer
{
public function syncFromCalendarData(Event $event, ?string $calendarData = null): void
{
$attendees = $this->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;
}
}

View File

@ -59,6 +59,67 @@ class EventRecurrence
$vevent->add('LOCATION', $data['location']); $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; $rrule = $data['rrule'] ?? null;
if ($rrule) { if ($rrule) {
$vevent->add('RRULE', $rrule); $vevent->add('RRULE', $rrule);
@ -216,4 +277,22 @@ class EventRecurrence
return null; 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;
}
} }

View File

@ -0,0 +1,47 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::create('event_attendees', function (Blueprint $table) {
$table->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');
}
};

View File

@ -0,0 +1,156 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Hash;
use Illuminate\Support\Str;
return new class extends Migration
{
private array $contacts = [
[
'first' => '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();
}
}
};

View File

@ -77,6 +77,16 @@ return [
'alerts_coming' => 'No alerts set. (Coming soon)', 'alerts_coming' => 'No alerts set. (Coming soon)',
'invitees' => 'Invitees', 'invitees' => 'Invitees',
'invitees_coming' => 'Invitees and RSVP tracking coming soon.', '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' => 'Attachments',
'attachments_coming' => 'Attachment support coming soon.', 'attachments_coming' => 'Attachment support coming soon.',
'notes' => 'Notes', 'notes' => 'Notes',

View File

@ -77,6 +77,16 @@ return [
'alerts_coming' => 'Nessun avviso impostato. (In arrivo)', 'alerts_coming' => 'Nessun avviso impostato. (In arrivo)',
'invitees' => 'Invitati', 'invitees' => 'Invitati',
'invitees_coming' => 'Invitati e RSVP in arrivo.', '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' => 'Allegati',
'attachments_coming' => 'Supporto allegati in arrivo.', 'attachments_coming' => 'Supporto allegati in arrivo.',
'notes' => 'Note', 'notes' => 'Note',

View File

@ -144,9 +144,9 @@ dialog {
@apply opacity-0 invisible; @apply opacity-0 invisible;
pointer-events: none; pointer-events: none;
transition: transition:
translate 350ms ease-in-out, translate 250ms ease-in-out,
opacity 150ms ease-in-out, opacity 250ms ease-in-out,
visibility 150ms ease-in-out; visibility 250ms ease-in-out;
&.is-visible { &.is-visible {
@apply opacity-100 visible h-auto; @apply opacity-100 visible h-auto;

View File

@ -35,6 +35,22 @@ const SELECTORS = {
tabsRoot: '[data-tabs]', tabsRoot: '[data-tabs]',
tabButton: '[role=\"tab\"]', tabButton: '[role=\"tab\"]',
tabPanel: '[role=\"tabpanel\"]', 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) { function syncModalRootClass(modal) {
@ -1227,6 +1243,162 @@ function initTimeViewAutoScroll(root = document)
calendar.dataset.autoscrolled = '1'; 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 * calendar sidebar expand toggle
@ -1484,6 +1656,7 @@ function initUI() {
initNaturalEventParser(); initNaturalEventParser();
initEventAllDayToggles(); initEventAllDayToggles();
initRecurrenceControls(); initRecurrenceControls();
initAttendeeControls();
initModalHandlers(); initModalHandlers();
initTabs(); initTabs();
initTimeViewAutoScroll(); initTimeViewAutoScroll();
@ -1505,6 +1678,7 @@ document.addEventListener('htmx:afterSwap', (e) => {
initNaturalEventParser(e.target); initNaturalEventParser(e.target);
initEventAllDayToggles(e.target); initEventAllDayToggles(e.target);
initRecurrenceControls(e.target); initRecurrenceControls(e.target);
initAttendeeControls(e.target);
initModalHandlers(e.target); initModalHandlers(e.target);
initTabs(e.target); initTabs(e.target);
initTimeViewAutoScroll(e.target); initTimeViewAutoScroll(e.target);

View File

@ -0,0 +1,29 @@
@if (empty($suggestions))
<div></div>
@else
<ul class="mt-2 rounded-md border border-gray-200 shadow-sm bg-white divide-y divide-gray-100">
@foreach ($suggestions as $suggestion)
<li>
<button
type="button"
class="w-full text-left px-3 py-2 hover:bg-gray-50"
data-attendee-pick
data-attendee-email="{{ $suggestion['email'] ?? '' }}"
data-attendee-name="{{ $suggestion['name'] ?? '' }}"
data-attendee-uri="{{ $suggestion['attendee_uri'] ?? '' }}"
data-attendee-verified="{{ !empty($suggestion['verified']) ? '1' : '0' }}"
>
<div class="font-medium text-gray-800">
{{ $suggestion['name'] ?? ($suggestion['email'] ?? '') }}
</div>
@if (!empty($suggestion['name']) && !empty($suggestion['email']))
<div class="text-sm text-gray-500">{{ $suggestion['email'] }}</div>
@endif
@if (!empty($suggestion['verified']))
<div class="text-xs text-emerald-700 mt-1">{{ __('calendar.event.attendees.verified') }}</div>
@endif
</button>
</li>
@endforeach
</ul>
@endif

View File

@ -251,7 +251,122 @@
<div id="tab-invitees" role="tabpanel" aria-labelledby="tab-btn-invitees" hidden> <div id="tab-invitees" role="tabpanel" aria-labelledby="tab-btn-invitees" hidden>
<div class="input-row input-row--1"> <div class="input-row input-row--1">
<div class="input-cell"> <div class="input-cell">
<p class="text-sm text-gray-600">More to come</p> <input type="hidden" name="attendees_present" value="1">
<div class="flex flex-col gap-3" data-attendees data-next-index="{{ count($attendees ?? []) }}">
<div class="flex flex-col gap-2">
<x-input.label for="attendee_lookup" :value="__('calendar.event.attendees.add')" />
<p class="text-sm text-gray-600">{{ __('calendar.event.attendees.help') }}</p>
<div class="flex items-center gap-2">
<x-input.text
id="attendee_lookup"
name="attendee"
type="text"
placeholder="{{ __('calendar.event.attendees.search_placeholder') }}"
data-attendee-lookup
hx-get="{{ route('attendee.suggest') }}"
hx-trigger="keyup changed delay:250ms"
hx-target="#attendee-suggestions"
hx-swap="innerHTML"
/>
<x-button type="button" variant="tertiary" data-attendee-add-manual>
{{ __('calendar.event.attendees.add_button') }}
</x-button>
</div>
<div id="attendee-suggestions"></div>
</div>
<div class="flex flex-col gap-3" data-attendees-list>
@foreach (($attendees ?? []) as $index => $attendee)
<div class="attendee-row rounded-lg border border-gray-200 p-3 flex flex-col gap-3" data-attendee-row>
<input type="hidden" name="attendees[{{ $index }}][attendee_uri]" value="{{ $attendee['attendee_uri'] ?? '' }}" data-attendee-uri>
<input type="hidden" name="attendees[{{ $index }}][email]" value="{{ $attendee['email'] ?? '' }}" data-attendee-email>
<input type="hidden" name="attendees[{{ $index }}][name]" value="{{ $attendee['name'] ?? '' }}" data-attendee-name>
<input type="hidden" name="attendees[{{ $index }}][role]" value="{{ !empty($attendee['optional']) ? 'OPT-PARTICIPANT' : 'REQ-PARTICIPANT' }}" data-attendee-role>
<input type="hidden" name="attendees[{{ $index }}][partstat]" value="NEEDS-ACTION">
<input type="hidden" name="attendees[{{ $index }}][cutype]" value="INDIVIDUAL">
<input type="hidden" name="attendees[{{ $index }}][is_organizer]" value="0">
<div class="flex items-center justify-between gap-3">
<div class="flex flex-col">
<strong data-attendee-display>
{{ ($attendee['name'] ?? '') !== '' ? ($attendee['name'] . ' <' . ($attendee['email'] ?? '') . '>') : ($attendee['email'] ?? '') }}
</strong>
<span class="text-xs text-emerald-700 {{ !empty($attendee['verified']) ? '' : 'hidden' }}" data-attendee-verified>
{{ __('calendar.event.attendees.verified') }}
</span>
</div>
<button type="button" class="button button--tertiary" data-attendee-remove>
<x-icon-x width="18" />
<span class="sr-only">{{ __('calendar.event.attendees.remove') }}</span>
</button>
</div>
<div class="flex items-center gap-4">
<label class="inline-flex items-center gap-2 text-sm">
<input type="hidden" name="attendees[{{ $index }}][optional]" value="0">
<x-input.checkbox
name="attendees[{{ $index }}][optional]"
value="1"
:checked="(bool) ($attendee['optional'] ?? false)"
data-attendee-optional
/>
<span>{{ __('calendar.event.attendees.optional') }}</span>
</label>
<label class="inline-flex items-center gap-2 text-sm">
<input type="hidden" name="attendees[{{ $index }}][rsvp]" value="0">
<x-input.checkbox
name="attendees[{{ $index }}][rsvp]"
value="1"
:checked="(bool) ($attendee['rsvp'] ?? true)"
/>
<span>{{ __('calendar.event.attendees.rsvp') }}</span>
</label>
</div>
</div>
@endforeach
</div>
<template data-attendee-template>
<div class="attendee-row rounded-lg border border-gray-200 p-3 flex flex-col gap-3" data-attendee-row>
<input type="hidden" name="attendees[__INDEX__][attendee_uri]" value="" data-attendee-uri>
<input type="hidden" name="attendees[__INDEX__][email]" value="" data-attendee-email>
<input type="hidden" name="attendees[__INDEX__][name]" value="" data-attendee-name>
<input type="hidden" name="attendees[__INDEX__][role]" value="REQ-PARTICIPANT" data-attendee-role>
<input type="hidden" name="attendees[__INDEX__][partstat]" value="NEEDS-ACTION">
<input type="hidden" name="attendees[__INDEX__][cutype]" value="INDIVIDUAL">
<input type="hidden" name="attendees[__INDEX__][is_organizer]" value="0">
<div class="flex items-center justify-between gap-3">
<div class="flex flex-col">
<strong data-attendee-display></strong>
<span class="text-xs text-emerald-700 hidden" data-attendee-verified>
{{ __('calendar.event.attendees.verified') }}
</span>
</div>
<button type="button" class="button button--tertiary" data-attendee-remove>
<x-icon-x width="18" />
<span class="sr-only">{{ __('calendar.event.attendees.remove') }}</span>
</button>
</div>
<div class="flex items-center gap-4">
<label class="inline-flex items-center gap-2 text-sm">
<input type="hidden" name="attendees[__INDEX__][optional]" value="0">
<input name="attendees[__INDEX__][optional]" type="checkbox" value="1" data-attendee-optional>
<span>{{ __('calendar.event.attendees.optional') }}</span>
</label>
<label class="inline-flex items-center gap-2 text-sm">
<input type="hidden" name="attendees[__INDEX__][rsvp]" value="0">
<input name="attendees[__INDEX__][rsvp]" type="checkbox" value="1" checked>
<span>{{ __('calendar.event.attendees.rsvp') }}</span>
</label>
</div>
</div>
</template>
</div>
</div> </div>
</div> </div>
</div> </div>

View File

@ -2,6 +2,7 @@
use Illuminate\Support\Facades\Route; use Illuminate\Support\Facades\Route;
use App\Http\Controllers\AccountController; use App\Http\Controllers\AccountController;
use App\Http\Controllers\AttendeeController;
use App\Http\Controllers\BookController; use App\Http\Controllers\BookController;
use App\Http\Controllers\CalendarController; use App\Http\Controllers\CalendarController;
use App\Http\Controllers\CalendarSettingsController; use App\Http\Controllers\CalendarSettingsController;
@ -127,6 +128,9 @@ Route::middleware('auth')->group(function ()
Route::get('/location/suggest', [LocationController::class, 'suggest']) Route::get('/location/suggest', [LocationController::class, 'suggest'])
->name('location.suggest'); ->name('location.suggest');
Route::get('/attendee/suggest', [AttendeeController::class, 'suggest'])
->name('attendee.suggest');
// address books // address books
Route::resource('book', BookController::class) Route::resource('book', BookController::class)
->names('book') // books.index, books.create, … ->names('book') // books.index, books.create, …