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:
parent
337f41a86b
commit
909fae5eb4
163
app/Http/Controllers/AttendeeController.php
Normal file
163
app/Http/Controllers/AttendeeController.php
Normal 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;
|
||||
}
|
||||
}
|
||||
@ -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
|
||||
*/
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
**/
|
||||
|
||||
41
app/Models/EventAttendee.php
Normal file
41
app/Models/EventAttendee.php
Normal 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');
|
||||
}
|
||||
}
|
||||
@ -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
|
||||
*/
|
||||
|
||||
236
app/Services/Event/EventAttendeeSynchronizer.php
Normal file
236
app/Services/Event/EventAttendeeSynchronizer.php
Normal 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;
|
||||
}
|
||||
}
|
||||
@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@ -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');
|
||||
}
|
||||
};
|
||||
@ -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();
|
||||
}
|
||||
}
|
||||
};
|
||||
@ -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',
|
||||
|
||||
@ -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',
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -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
|
||||
@ -251,7 +251,122 @@
|
||||
<div id="tab-invitees" role="tabpanel" aria-labelledby="tab-btn-invitees" hidden>
|
||||
<div class="input-row input-row--1">
|
||||
<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>
|
||||
|
||||
@ -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, …
|
||||
|
||||
Loading…
Reference in New Issue
Block a user