Improve conferences, guest entry, Afia, and cross-app scheduling.
Deploy Ladill Meet / deploy (push) Successful in 50s
Deploy Ladill Meet / deploy (push) Successful in 50s
Route guests through silent SSO to the Meet product page, fix Afia context query, add conference green-room UX and copy, and extend service API for Care/CRM/Invoice calendar integration. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
+5
-1
@@ -6,6 +6,7 @@ APP_URL=https://meet.ladill.com
|
||||
|
||||
PLATFORM_URL=https://ladill.com
|
||||
PLATFORM_DOMAIN=ladill.com
|
||||
LADILL_MARKETING_URL=https://ladill.com/products/meet
|
||||
|
||||
LOG_CHANNEL=stack
|
||||
LOG_LEVEL=debug
|
||||
@@ -59,8 +60,11 @@ MEET_API_KEY_EVENTS=
|
||||
MEET_API_KEY_FRONTDESK=
|
||||
MEET_API_KEY_HR=
|
||||
MEET_API_KEY_SUPPORT=
|
||||
MEET_API_KEY_INVOICE=
|
||||
|
||||
# --- Calendar OAuth (optional) ---
|
||||
# --- Ladill Mail calendar (auto-sync scheduled meetings) ---
|
||||
LADILL_MAIL_API_URL=https://mail.ladill.com/api/service/v1
|
||||
MAIL_API_KEY_MEET=
|
||||
GOOGLE_CALENDAR_CLIENT_ID=
|
||||
GOOGLE_CALENDAR_CLIENT_SECRET=
|
||||
GOOGLE_CALENDAR_REDIRECT_URI=https://meet.ladill.com/settings/calendar/callback
|
||||
|
||||
@@ -28,7 +28,7 @@ class ServiceRoomController extends Controller
|
||||
{
|
||||
$validated = $request->validate([
|
||||
'owner_ref' => ['required', 'string'],
|
||||
'organization_id' => ['required', 'integer', 'exists:meet_organizations,id'],
|
||||
'organization_id' => ['nullable', 'integer', 'exists:meet_organizations,id'],
|
||||
'host_user_ref' => ['required', 'string'],
|
||||
'title' => ['required', 'string', 'max:255'],
|
||||
'description' => ['nullable', 'string'],
|
||||
@@ -44,7 +44,9 @@ class ServiceRoomController extends Controller
|
||||
'invite_emails.*' => ['email'],
|
||||
]);
|
||||
|
||||
$organization = Organization::findOrFail($validated['organization_id']);
|
||||
$organization = isset($validated['organization_id'])
|
||||
? Organization::findOrFail($validated['organization_id'])
|
||||
: Organization::owned($validated['owner_ref'])->firstOrFail();
|
||||
abort_unless($organization->owner_ref === $validated['owner_ref'], 403);
|
||||
|
||||
$host = User::where('public_id', $validated['host_user_ref'])->firstOrFail();
|
||||
|
||||
@@ -88,11 +88,7 @@ class SsoLoginController extends Controller
|
||||
if ($request->filled('error')) {
|
||||
if (in_array($request->query('error'), ['login_required', 'interaction_required', 'consent_required'], true)
|
||||
&& ! $request->boolean('interactive')) {
|
||||
return redirect()->route('sso.connect', [
|
||||
'redirect' => $intended,
|
||||
'interactive' => 1,
|
||||
'fallback' => 1,
|
||||
]);
|
||||
return redirect()->away((string) config('ladill.marketing_url'));
|
||||
}
|
||||
|
||||
return $this->finishCallback($request, $intended, (string) $request->query('error_description', $request->query('error')));
|
||||
|
||||
@@ -52,7 +52,7 @@ class AiController extends Controller
|
||||
'meetings_upcoming' => Room::owned($owner)
|
||||
->where('organization_id', $organization->id)
|
||||
->where('status', 'scheduled')
|
||||
->where('starts_at', '>=', now())
|
||||
->where('scheduled_at', '>=', now())
|
||||
->count(),
|
||||
'recordings_total' => Recording::owned($owner)
|
||||
->whereHas('session.room', fn ($q) => $q->where('organization_id', $organization->id))
|
||||
|
||||
@@ -4,6 +4,7 @@ namespace App\Http\Controllers\Meet;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Http\Controllers\Meet\Concerns\ScopesToAccount;
|
||||
use App\Models\Member;
|
||||
use App\Models\Room;
|
||||
use App\Models\Template;
|
||||
use App\Services\Meet\CalendarService;
|
||||
@@ -56,9 +57,16 @@ class ConferenceController extends Controller
|
||||
->orderBy('name')
|
||||
->get();
|
||||
|
||||
$members = Member::owned($this->ownerRef($request))
|
||||
->where('organization_id', $organization->id)
|
||||
->where('user_ref', '!=', $this->ownerRef($request))
|
||||
->orderBy('user_ref')
|
||||
->get();
|
||||
|
||||
return view('meet.conferences.create', [
|
||||
'organization' => $organization,
|
||||
'templates' => $templates,
|
||||
'members' => $members,
|
||||
'timezones' => timezone_identifiers_list(),
|
||||
'defaultSettings' => config('meet.default_settings'),
|
||||
]);
|
||||
@@ -78,6 +86,8 @@ class ConferenceController extends Controller
|
||||
'passcode' => ['nullable', 'string', 'max:20'],
|
||||
'template_id' => ['nullable', 'integer', 'exists:meet_templates,id'],
|
||||
'invite_emails' => ['nullable', 'string'],
|
||||
'presenter_refs' => ['nullable', 'array'],
|
||||
'presenter_refs.*' => ['string', 'max:64'],
|
||||
'waiting_room' => ['sometimes', 'boolean'],
|
||||
'join_before_host' => ['sometimes', 'boolean'],
|
||||
'mute_on_join' => ['sometimes', 'boolean'],
|
||||
@@ -87,6 +97,12 @@ class ConferenceController extends Controller
|
||||
'practice_mode' => ['sometimes', 'boolean'],
|
||||
]);
|
||||
|
||||
$presenterRefs = collect($validated['presenter_refs'] ?? [])
|
||||
->filter()
|
||||
->unique()
|
||||
->values()
|
||||
->all();
|
||||
|
||||
$settings = [
|
||||
'waiting_room' => $request->boolean('waiting_room', true),
|
||||
'join_before_host' => $request->boolean('join_before_host'),
|
||||
@@ -96,6 +112,7 @@ class ConferenceController extends Controller
|
||||
'green_room' => $request->boolean('green_room', true),
|
||||
'practice_mode' => $request->boolean('practice_mode'),
|
||||
'stage_mode' => true,
|
||||
'presenter_refs' => $presenterRefs,
|
||||
];
|
||||
|
||||
$data = array_merge($validated, [
|
||||
@@ -115,7 +132,7 @@ class ConferenceController extends Controller
|
||||
if ($emails = trim((string) ($validated['invite_emails'] ?? ''))) {
|
||||
$invites = collect(preg_split('/[\s,;]+/', $emails))
|
||||
->filter()
|
||||
->map(fn ($email) => ['email' => $email])
|
||||
->map(fn ($email) => ['email' => $email, 'role' => 'panelist'])
|
||||
->all();
|
||||
$this->invitations->inviteToRoom($room, $invites, $request->user());
|
||||
}
|
||||
@@ -135,7 +152,13 @@ class ConferenceController extends Controller
|
||||
|
||||
$room->load(['sessions.participants', 'invitations', 'sessions.recordings', 'sessions.aiSummaries', 'sessionFiles']);
|
||||
|
||||
return view('meet.conferences.show', compact('room'));
|
||||
$presenterRefs = (array) $room->setting('presenter_refs', []);
|
||||
$speakers = Member::owned($this->ownerRef($request))
|
||||
->where('organization_id', $room->organization_id)
|
||||
->whereIn('user_ref', $presenterRefs)
|
||||
->get();
|
||||
|
||||
return view('meet.conferences.show', compact('room', 'speakers'));
|
||||
}
|
||||
|
||||
public function start(Request $request, Room $room): RedirectResponse
|
||||
|
||||
@@ -6,6 +6,7 @@ use App\Http\Controllers\Controller;
|
||||
use App\Models\Participant;
|
||||
use App\Models\Room;
|
||||
use App\Models\Session;
|
||||
use App\Services\Meet\ConferenceService;
|
||||
use App\Services\Meet\InvitationService;
|
||||
use App\Services\Meet\SecurityPolicyService;
|
||||
use App\Services\Meet\SessionService;
|
||||
@@ -24,6 +25,7 @@ class JoinController extends Controller
|
||||
protected SecurityPolicyService $security,
|
||||
protected WebinarService $webinars,
|
||||
protected SpaceService $spaces,
|
||||
protected ConferenceService $conferences,
|
||||
) {}
|
||||
|
||||
public function show(Request $request, Room $room): View|RedirectResponse
|
||||
@@ -120,9 +122,11 @@ class JoinController extends Controller
|
||||
$displayName = $user?->name ?? ($validated['display_name'] ?? 'Guest');
|
||||
$email = $user?->email ?? ($validated['email'] ?? null);
|
||||
|
||||
$kind = $room->isConference() ? 'conference' : 'meeting';
|
||||
|
||||
[$allowed, $reason] = $this->security->canJoin($room, $user, $email ?? $displayName.'@guest.local');
|
||||
if (! $allowed && $reason !== 'passcode_required') {
|
||||
return back()->withErrors(['join' => $reason ?? 'Unable to join meeting.']);
|
||||
return back()->withErrors(['join' => $reason ?? "Unable to join {$kind}."]);
|
||||
}
|
||||
|
||||
try {
|
||||
@@ -136,20 +140,20 @@ class JoinController extends Controller
|
||||
if (! $room->activeSession() && $user && $user->ownerRef() === $room->host_user_ref) {
|
||||
$session = $this->sessions->start($room, $user);
|
||||
} elseif (! $room->activeSession()) {
|
||||
return back()->withErrors(['join' => 'Meeting has not started yet. Please wait for the host.']);
|
||||
return back()->withErrors(['join' => ucfirst($kind).' has not started yet. Please wait for the host.']);
|
||||
} else {
|
||||
$session = $room->activeSession();
|
||||
}
|
||||
} else {
|
||||
if (! $room->activeSession()) {
|
||||
return back()->withErrors(['join' => 'Meeting has not started yet. Please wait for the host.']);
|
||||
return back()->withErrors(['join' => ucfirst($kind).' has not started yet. Please wait for the host.']);
|
||||
}
|
||||
|
||||
$session = $room->activeSession();
|
||||
}
|
||||
} catch (\Symfony\Component\HttpKernel\Exception\HttpException $e) {
|
||||
if (in_array($e->getStatusCode(), [402, 503], true)) {
|
||||
return back()->withErrors(['join' => $e->getMessage() ?: 'Unable to start meeting.']);
|
||||
return back()->withErrors(['join' => $e->getMessage() ?: "Unable to start {$kind}."]);
|
||||
}
|
||||
|
||||
throw $e;
|
||||
@@ -159,6 +163,8 @@ class JoinController extends Controller
|
||||
|
||||
if ($room->isSpace()) {
|
||||
$role = $this->spaces->resolveJoinRole($room, $user, $role);
|
||||
} elseif ($room->isConference() && $role !== 'host') {
|
||||
$role = $this->conferences->resolveJoinRole($room, $user, $email, $role);
|
||||
} elseif ($room->isWebinar() && $role !== 'host') {
|
||||
$token = $request->session()->get("meet.webinar.{$room->uuid}");
|
||||
$reg = $token
|
||||
@@ -174,10 +180,20 @@ class JoinController extends Controller
|
||||
$displayName = $reg->display_name;
|
||||
}
|
||||
|
||||
if ($room->isConference() && ! $this->conferences->canJoinSession($room, $session, $role)) {
|
||||
return back()->withErrors([
|
||||
'join' => 'The conference has not gone live yet. Assigned speakers can join from the green room link when the host starts.',
|
||||
]);
|
||||
}
|
||||
|
||||
$joinStatus = $room->requiresWaitingRoom($user, $role) ? 'waiting' : 'joined';
|
||||
|
||||
$participant = $this->sessions->joinParticipant($session, $user, $role, $displayName, $email, $joinStatus);
|
||||
|
||||
if ($room->isConference() && $session->session_mode === 'green_room') {
|
||||
$this->conferences->registerPresenter($session, $user, $role);
|
||||
}
|
||||
|
||||
app(InvitationService::class)->markAcceptedOnJoin($room, $user, $email);
|
||||
|
||||
$this->sessions->rememberParticipantSession($request, $participant, $session);
|
||||
|
||||
@@ -282,8 +282,32 @@ class MeetingFeaturesController extends Controller
|
||||
{
|
||||
$this->hostOnly($request, $session);
|
||||
$live = $this->townHall->goLive($session, $request->user());
|
||||
$participant = app(SessionService::class)->joinedParticipantForUser($live, $request->user());
|
||||
|
||||
return response()->json(['session_uuid' => $live->uuid, 'mode' => 'live']);
|
||||
if ($participant) {
|
||||
app(SessionService::class)->rememberParticipantSession($request, $participant, $live);
|
||||
}
|
||||
|
||||
return response()->json([
|
||||
'session_uuid' => $live->uuid,
|
||||
'mode' => 'live',
|
||||
'room_url' => route('meet.room', $live),
|
||||
]);
|
||||
}
|
||||
|
||||
public function addPresenter(Request $request, Session $session): JsonResponse
|
||||
{
|
||||
$this->hostOnly($request, $session);
|
||||
abort_unless($session->room->isTownHall(), 422);
|
||||
|
||||
$validated = $request->validate(['user_ref' => ['required', 'string', 'max:64']]);
|
||||
|
||||
$updated = $this->townHall->addPresenter($session, $validated['user_ref']);
|
||||
|
||||
return response()->json([
|
||||
'session' => $updated,
|
||||
'presenter_refs' => $updated->presenter_refs,
|
||||
]);
|
||||
}
|
||||
|
||||
public function handoffCoHost(Request $request, Session $session): JsonResponse
|
||||
|
||||
@@ -5,6 +5,7 @@ namespace App\Http\Controllers\Meet;
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\Participant;
|
||||
use App\Models\Recording;
|
||||
use App\Models\Room;
|
||||
use App\Models\Session;
|
||||
use App\Services\Meet\ChatService;
|
||||
use App\Services\Meet\Media\MediaProviderInterface;
|
||||
@@ -37,8 +38,11 @@ class MeetingRoomController extends Controller
|
||||
return redirect()->route('meet.ended', $session);
|
||||
}
|
||||
|
||||
$room = $session->room;
|
||||
$kind = $room->isConference() ? 'conference' : 'meeting';
|
||||
|
||||
$participantUuid = $request->session()->get("meet.participant.{$session->uuid}");
|
||||
abort_unless($participantUuid, 403, 'Please join the meeting first.');
|
||||
abort_unless($participantUuid, 403, "Please join the {$kind} first.");
|
||||
|
||||
$participant = Participant::where('uuid', $participantUuid)
|
||||
->where('session_id', $session->id)
|
||||
@@ -48,7 +52,7 @@ class MeetingRoomController extends Controller
|
||||
return redirect()->route('meet.join.waiting', $session->room);
|
||||
}
|
||||
|
||||
abort_unless($participant->status === 'joined', 403, 'You are not in this meeting.');
|
||||
abort_unless($participant->status === 'joined', 403, "You are not in this {$kind}.");
|
||||
|
||||
$token = $this->media->isConfigured()
|
||||
? $this->sessions->generateMediaToken($participant)
|
||||
@@ -72,6 +76,10 @@ class MeetingRoomController extends Controller
|
||||
'activeRecording' => $session->recordings()->where('status', 'recording')->first(),
|
||||
'watermark' => $room->organization?->securitySetting('watermark_enabled', false),
|
||||
'isWebinar' => $room->isWebinar() || $room->isConference() || $room->isSpace(),
|
||||
'isConference' => $room->isConference(),
|
||||
'isGreenRoom' => $session->session_mode === 'green_room',
|
||||
'sessionMode' => $session->session_mode,
|
||||
'presenterRefs' => (array) $session->presenter_refs,
|
||||
'isAudioOnly' => $isAudioOnly,
|
||||
'isSpace' => $room->isSpace(),
|
||||
'canPublish' => $room->isSpace()
|
||||
@@ -91,10 +99,43 @@ class MeetingRoomController extends Controller
|
||||
$this->sessions->end($session, $participant->user_ref ?? $participant->uuid);
|
||||
|
||||
$room = $session->room;
|
||||
$route = $room->isWebinar() ? 'meet.webinars.show' : 'meet.rooms.show';
|
||||
|
||||
return redirect()->route($route, $room)
|
||||
->with('success', $room->isWebinar() ? 'Webinar ended.' : 'Meeting ended.');
|
||||
return redirect()->route($this->roomShowRoute($room), $room)
|
||||
->with('success', $this->roomEndedMessage($room));
|
||||
}
|
||||
|
||||
protected function roomShowRoute(Room $room): string
|
||||
{
|
||||
if ($room->isWebinar()) {
|
||||
return 'meet.webinars.show';
|
||||
}
|
||||
|
||||
if ($room->isConference()) {
|
||||
return 'meet.conferences.show';
|
||||
}
|
||||
|
||||
if ($room->isSpace()) {
|
||||
return 'meet.spaces.show';
|
||||
}
|
||||
|
||||
return 'meet.rooms.show';
|
||||
}
|
||||
|
||||
protected function roomEndedMessage(Room $room): string
|
||||
{
|
||||
if ($room->isWebinar()) {
|
||||
return 'Webinar ended.';
|
||||
}
|
||||
|
||||
if ($room->isConference()) {
|
||||
return 'Conference ended.';
|
||||
}
|
||||
|
||||
if ($room->isSpace()) {
|
||||
return 'Room closed.';
|
||||
}
|
||||
|
||||
return 'Meeting ended.';
|
||||
}
|
||||
|
||||
public function leave(Request $request, Session $session): RedirectResponse
|
||||
|
||||
@@ -17,6 +17,11 @@ class MailCalendarProvider
|
||||
return null;
|
||||
}
|
||||
|
||||
return $this->createEventForMailbox($mailbox, $room);
|
||||
}
|
||||
|
||||
public function createEventForMailbox(string $mailbox, Room $room): ?string
|
||||
{
|
||||
$response = Http::withToken((string) config('meet.calendar.mail.api_key'))
|
||||
->acceptJson()
|
||||
->timeout(10)
|
||||
@@ -32,13 +37,18 @@ class MailCalendarProvider
|
||||
}
|
||||
|
||||
public function updateEvent(CalendarConnection $connection, Room $room, User $host): bool
|
||||
{
|
||||
$mailbox = $connection->calendar_id ?? $host->email;
|
||||
|
||||
return $mailbox ? $this->updateEventForMailbox($mailbox, $room) : false;
|
||||
}
|
||||
|
||||
public function updateEventForMailbox(string $mailbox, Room $room): bool
|
||||
{
|
||||
if (! $room->calendar_event_id) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$mailbox = $connection->calendar_id ?? $host->email;
|
||||
|
||||
return Http::withToken((string) config('meet.calendar.mail.api_key'))
|
||||
->acceptJson()
|
||||
->timeout(10)
|
||||
@@ -47,6 +57,11 @@ class MailCalendarProvider
|
||||
}
|
||||
|
||||
public function deleteEvent(CalendarConnection $connection, Room $room): bool
|
||||
{
|
||||
return $this->deleteEventForMailbox($connection->calendar_id, $room);
|
||||
}
|
||||
|
||||
public function deleteEventForMailbox(?string $mailbox, Room $room): bool
|
||||
{
|
||||
if (! $room->calendar_event_id) {
|
||||
return false;
|
||||
@@ -55,10 +70,10 @@ class MailCalendarProvider
|
||||
return Http::withToken((string) config('meet.calendar.mail.api_key'))
|
||||
->acceptJson()
|
||||
->timeout(10)
|
||||
->delete($this->baseUrl().'/calendar/events/'.$room->calendar_event_id, [
|
||||
'mailbox_email' => $connection->calendar_id,
|
||||
->delete($this->baseUrl().'/calendar/events/'.$room->calendar_event_id, array_filter([
|
||||
'mailbox_email' => $mailbox ? strtolower(trim($mailbox)) : null,
|
||||
'external_ref' => $room->uuid,
|
||||
])
|
||||
]))
|
||||
->successful();
|
||||
}
|
||||
|
||||
|
||||
@@ -20,35 +20,54 @@ class CalendarService
|
||||
|
||||
public function syncRoomCreated(Room $room, User $host): void
|
||||
{
|
||||
$connection = $this->connectionFor($host);
|
||||
if (! $connection) {
|
||||
if (! $room->scheduled_at) {
|
||||
return;
|
||||
}
|
||||
|
||||
$eventId = $this->provider($connection)->createEvent($connection, $room, $host);
|
||||
if ($host->email) {
|
||||
$eventId = $this->mail->createEventForMailbox($host->email, $room);
|
||||
if ($eventId) {
|
||||
$room->update(['calendar_event_id' => $eventId]);
|
||||
}
|
||||
}
|
||||
|
||||
$connection = $this->connectionFor($host);
|
||||
if ($connection && in_array($connection->provider, ['google', 'microsoft'], true)) {
|
||||
$this->provider($connection)->createEvent($connection, $room, $host);
|
||||
}
|
||||
}
|
||||
|
||||
public function syncRoomUpdated(Room $room, User $host): void
|
||||
{
|
||||
$connection = $this->connectionFor($host);
|
||||
if (! $connection || ! $room->calendar_event_id) {
|
||||
if (! $room->scheduled_at) {
|
||||
return;
|
||||
}
|
||||
|
||||
if ($host->email) {
|
||||
if ($room->calendar_event_id) {
|
||||
$this->mail->updateEventForMailbox($host->email, $room);
|
||||
} else {
|
||||
$this->syncRoomCreated($room, $host);
|
||||
}
|
||||
}
|
||||
|
||||
$connection = $this->connectionFor($host);
|
||||
if ($connection && $room->calendar_event_id && in_array($connection->provider, ['google', 'microsoft'], true)) {
|
||||
$this->provider($connection)->updateEvent($connection, $room, $host);
|
||||
}
|
||||
}
|
||||
|
||||
public function syncRoomCancelled(Room $room, User $host): void
|
||||
{
|
||||
$connection = $this->connectionFor($host);
|
||||
if (! $connection || ! $room->calendar_event_id) {
|
||||
return;
|
||||
if ($host->email && $room->calendar_event_id) {
|
||||
$this->mail->deleteEventForMailbox($host->email, $room);
|
||||
}
|
||||
|
||||
$connection = $this->connectionFor($host);
|
||||
if ($connection && $room->calendar_event_id && in_array($connection->provider, ['google', 'microsoft'], true)) {
|
||||
$this->provider($connection)->deleteEvent($connection, $room);
|
||||
}
|
||||
|
||||
$room->update(['calendar_event_id' => null]);
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,87 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services\Meet;
|
||||
|
||||
use App\Models\Invitation;
|
||||
use App\Models\Participant;
|
||||
use App\Models\Room;
|
||||
use App\Models\Session;
|
||||
use App\Models\User;
|
||||
|
||||
class ConferenceService
|
||||
{
|
||||
/**
|
||||
* @return list<string>
|
||||
*/
|
||||
public function presenterRefs(Room $room): array
|
||||
{
|
||||
return array_values(array_unique(array_filter(
|
||||
(array) $room->setting('presenter_refs', []),
|
||||
)));
|
||||
}
|
||||
|
||||
public function resolveJoinRole(Room $room, ?User $user, ?string $email, string $fallbackRole = 'guest'): string
|
||||
{
|
||||
if ($user && $user->ownerRef() === $room->host_user_ref) {
|
||||
return 'host';
|
||||
}
|
||||
|
||||
$presenterRefs = $this->presenterRefs($room);
|
||||
|
||||
if ($user && in_array($user->ownerRef(), $presenterRefs, true)) {
|
||||
return 'panelist';
|
||||
}
|
||||
|
||||
if ($email && $this->invitationRole($room, $email) === 'panelist') {
|
||||
return 'panelist';
|
||||
}
|
||||
|
||||
if ($room->isTownHall()) {
|
||||
return 'attendee';
|
||||
}
|
||||
|
||||
return $fallbackRole;
|
||||
}
|
||||
|
||||
public function canJoinSession(Room $room, Session $session, string $role): bool
|
||||
{
|
||||
if ($session->session_mode !== 'green_room') {
|
||||
return true;
|
||||
}
|
||||
|
||||
return in_array($role, ['host', 'co_host', 'panelist'], true);
|
||||
}
|
||||
|
||||
public function isPresenter(Session $session, Participant $participant): bool
|
||||
{
|
||||
if (in_array($participant->role, ['host', 'co_host', 'panelist'], true)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
$refs = (array) $session->presenter_refs;
|
||||
|
||||
return $participant->user_ref && in_array($participant->user_ref, $refs, true);
|
||||
}
|
||||
|
||||
public function canPublish(Room $room, Participant $participant): bool
|
||||
{
|
||||
return in_array($participant->role, ['host', 'co_host', 'panelist'], true);
|
||||
}
|
||||
|
||||
public function registerPresenter(Session $session, ?User $user, string $role): void
|
||||
{
|
||||
if (! $user || ! in_array($role, ['host', 'co_host', 'panelist'], true)) {
|
||||
return;
|
||||
}
|
||||
|
||||
app(TownHallService::class)->addPresenter($session, $user->ownerRef());
|
||||
}
|
||||
|
||||
protected function invitationRole(Room $room, string $email): ?string
|
||||
{
|
||||
return Invitation::query()
|
||||
->where('room_id', $room->id)
|
||||
->where('email', strtolower(trim($email)))
|
||||
->value('role');
|
||||
}
|
||||
}
|
||||
@@ -36,6 +36,7 @@ class ParticipantPresenter
|
||||
{
|
||||
return [
|
||||
'uuid' => $participant->uuid,
|
||||
'user_ref' => $participant->user_ref,
|
||||
'display_name' => $participant->display_name,
|
||||
'role' => $participant->role,
|
||||
'status' => $participant->status,
|
||||
|
||||
@@ -13,20 +13,21 @@ class SecurityPolicyService
|
||||
{
|
||||
$org = $room->organization;
|
||||
$policy = $this->mergedPolicy($org, $room);
|
||||
$kind = $room->isConference() ? 'conference' : 'meeting';
|
||||
|
||||
if ($policy['org_only'] && ! $user) {
|
||||
return [false, 'This meeting requires a Ladill account.'];
|
||||
return [false, "This {$kind} requires a Ladill account."];
|
||||
}
|
||||
|
||||
if ($policy['authenticated_only'] && ! $user) {
|
||||
return [false, 'Please sign in to join this meeting.'];
|
||||
return [false, "Please sign in to join this {$kind}."];
|
||||
}
|
||||
|
||||
if (! empty($policy['allowed_domains']) && $email) {
|
||||
$domain = Str($email)->after('@')->lower()->toString();
|
||||
$allowed = collect($policy['allowed_domains'])->map(fn ($d) => strtolower(trim($d)))->filter();
|
||||
if ($allowed->isNotEmpty() && ! $allowed->contains($domain)) {
|
||||
return [false, 'Your email domain is not allowed to join this meeting.'];
|
||||
return [false, "Your email domain is not allowed to join this {$kind}."];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -35,7 +36,7 @@ class SecurityPolicyService
|
||||
}
|
||||
|
||||
if ($room->activeSession()?->is_locked && ! $this->isHost($room, $user)) {
|
||||
return [false, 'This meeting is locked.'];
|
||||
return [false, "This {$kind} is locked."];
|
||||
}
|
||||
|
||||
if ($policy['recording_host_only'] && ! $this->isHost($room, $user)) {
|
||||
|
||||
+1
-1
@@ -2,5 +2,5 @@
|
||||
|
||||
return [
|
||||
'product_slug' => 'meet',
|
||||
'marketing_url' => env('LADILL_MARKETING_URL', 'https://ladill.com'),
|
||||
'marketing_url' => env('LADILL_MARKETING_URL', 'https://ladill.com/products/meet'),
|
||||
];
|
||||
|
||||
@@ -41,6 +41,16 @@ return [
|
||||
'monthly' => 'Monthly',
|
||||
],
|
||||
|
||||
'schedule_timezones' => [
|
||||
'UTC',
|
||||
'America/New_York',
|
||||
'America/Chicago',
|
||||
'America/Los_Angeles',
|
||||
'Europe/London',
|
||||
'Africa/Accra',
|
||||
'Africa/Lagos',
|
||||
],
|
||||
|
||||
'participant_roles' => [
|
||||
'host' => 'Host',
|
||||
'co_host' => 'Co-host',
|
||||
@@ -139,6 +149,7 @@ return [
|
||||
'frontdesk' => env('MEET_API_KEY_FRONTDESK'),
|
||||
'hr' => env('MEET_API_KEY_HR'),
|
||||
'support' => env('MEET_API_KEY_SUPPORT'),
|
||||
'invoice' => env('MEET_API_KEY_INVOICE'),
|
||||
],
|
||||
|
||||
'webhook_events' => [
|
||||
|
||||
@@ -38,6 +38,10 @@ function meetRoom() {
|
||||
needsMediaUnlock: false,
|
||||
waitingCount: 0,
|
||||
isWebinar: false,
|
||||
isConference: false,
|
||||
isGreenRoom: false,
|
||||
goingLive: false,
|
||||
presenterRefs: [],
|
||||
audioOnly: false,
|
||||
isSpace: false,
|
||||
qaItems: [],
|
||||
@@ -96,6 +100,8 @@ function meetRoom() {
|
||||
filesUploadUrl: el.dataset.filesUploadUrl,
|
||||
whiteboardUrl: el.dataset.whiteboardUrl,
|
||||
whiteboardSaveUrl: el.dataset.whiteboardSaveUrl,
|
||||
goLiveUrl: el.dataset.goLiveUrl,
|
||||
addPresenterUrl: el.dataset.addPresenterUrl,
|
||||
csrf: el.dataset.csrf,
|
||||
sessionUuid: el.dataset.sessionUuid,
|
||||
};
|
||||
@@ -107,6 +113,8 @@ function meetRoom() {
|
||||
this.isHost = el.dataset.isHost === '1';
|
||||
this.canPublish = el.dataset.canPublish !== '0';
|
||||
this.isWebinar = el.dataset.isWebinar === '1';
|
||||
this.isConference = el.dataset.isConference === '1';
|
||||
this.isGreenRoom = el.dataset.isGreenRoom === '1';
|
||||
this.audioOnly = el.dataset.audioOnly === '1';
|
||||
this.isSpace = el.dataset.isSpace === '1';
|
||||
this.muteOnJoin = el.dataset.muteOnJoin === '1';
|
||||
@@ -117,6 +125,12 @@ function meetRoom() {
|
||||
this.liveCaptions = el.dataset.liveCaptions === '1';
|
||||
this.displayName = el.dataset.displayName || '';
|
||||
|
||||
try {
|
||||
this.presenterRefs = JSON.parse(el.dataset.presenterRefs || '[]');
|
||||
} catch {
|
||||
this.presenterRefs = [];
|
||||
}
|
||||
|
||||
try {
|
||||
this.sessionParticipants = JSON.parse(el.dataset.initialParticipants || '[]');
|
||||
} catch {
|
||||
@@ -888,10 +902,10 @@ function meetRoom() {
|
||||
if (blob && recordingUuid) {
|
||||
const uploaded = await this.uploadRecording(blob, recordingUuid);
|
||||
if (!uploaded) {
|
||||
window.alert('Recording upload failed. Open Recordings and try again, or re-record the meeting.');
|
||||
window.alert(`Recording upload failed. Open Recordings and try again, or re-record the ${this.sessionNoun()}.`);
|
||||
}
|
||||
} else if (recordingUuid) {
|
||||
window.alert('No recording was captured from this meeting. Start recording again while the meeting is live.');
|
||||
window.alert(`No recording was captured from this ${this.sessionNoun()}. Start recording again while the ${this.sessionNoun()} is live.`);
|
||||
}
|
||||
|
||||
this.activeRecordingUuid = null;
|
||||
@@ -1202,6 +1216,52 @@ function meetRoom() {
|
||||
});
|
||||
},
|
||||
|
||||
async goLive() {
|
||||
if (!this.config.goLiveUrl || this.goingLive) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.goingLive = true;
|
||||
|
||||
try {
|
||||
const res = await fetch(this.config.goLiveUrl, {
|
||||
method: 'POST',
|
||||
headers: this.headers(),
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
throw new Error('Go live failed');
|
||||
}
|
||||
|
||||
const data = await res.json();
|
||||
window.location.href = data.room_url || `/room/${data.session_uuid}`;
|
||||
} catch {
|
||||
this.goingLive = false;
|
||||
}
|
||||
},
|
||||
|
||||
async makeSpeaker(userRef) {
|
||||
if (!this.config.addPresenterUrl || !userRef) {
|
||||
return;
|
||||
}
|
||||
|
||||
const res = await fetch(this.config.addPresenterUrl, {
|
||||
method: 'POST',
|
||||
headers: this.headers(),
|
||||
body: JSON.stringify({ user_ref: userRef }),
|
||||
});
|
||||
|
||||
if (! res.ok) {
|
||||
return;
|
||||
}
|
||||
|
||||
const data = await res.json();
|
||||
this.presenterRefs = data.presenter_refs || this.presenterRefs;
|
||||
this.sessionParticipants = this.sessionParticipants.map((person) => (
|
||||
person.user_ref === userRef ? { ...person, role: 'panelist' } : person
|
||||
));
|
||||
},
|
||||
|
||||
toggleChat() {
|
||||
this.chatOpen = !this.chatOpen;
|
||||
if (this.chatOpen) {
|
||||
@@ -1238,7 +1298,7 @@ function meetRoom() {
|
||||
try {
|
||||
await navigator.share({
|
||||
title: this.roomTitle || 'Ladill Meet',
|
||||
text: 'Join my meeting on Ladill Meet',
|
||||
text: `Join my ${this.sessionNoun()} on Ladill Meet`,
|
||||
url: this.joinUrl,
|
||||
});
|
||||
|
||||
@@ -1329,6 +1389,14 @@ function meetRoom() {
|
||||
}
|
||||
},
|
||||
|
||||
sessionNoun() {
|
||||
return this.isConference ? 'conference' : 'meeting';
|
||||
},
|
||||
|
||||
sessionNounTitle() {
|
||||
return this.isConference ? 'Conference' : 'Meeting';
|
||||
},
|
||||
|
||||
redirectToMeetingEnded(url) {
|
||||
if (this.pollTimer) {
|
||||
clearInterval(this.pollTimer);
|
||||
|
||||
@@ -41,7 +41,7 @@
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-slate-700">Timezone</label>
|
||||
<select name="timezone" class="mt-1 w-full rounded-lg border-slate-300 text-sm">
|
||||
@foreach (['UTC', 'America/New_York', 'America/Chicago', 'America/Los_Angeles', 'Europe/London', 'Africa/Lagos'] as $tz)
|
||||
@foreach (config('meet.schedule_timezones') as $tz)
|
||||
<option value="{{ $tz }}" @selected(old('timezone', $organization->timezone) === $tz)>{{ $tz }}</option>
|
||||
@endforeach
|
||||
</select>
|
||||
@@ -49,10 +49,25 @@
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-slate-700">Invite by email (comma-separated)</label>
|
||||
<label class="block text-sm font-medium text-slate-700">Invite speakers by email (comma-separated)</label>
|
||||
<input type="text" name="invite_emails" value="{{ old('invite_emails') }}" placeholder="speaker@example.com" class="mt-1 w-full rounded-lg border-slate-300 text-sm">
|
||||
</div>
|
||||
|
||||
@if ($members->isNotEmpty())
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-slate-700">Assigned speakers</label>
|
||||
<p class="mt-0.5 text-xs text-slate-500">Team members who can speak on stage. You are always the host.</p>
|
||||
<div class="mt-2 max-h-40 space-y-2 overflow-y-auto rounded-lg border border-slate-200 p-3">
|
||||
@foreach ($members as $member)
|
||||
<label class="flex items-center gap-2 text-sm">
|
||||
<input type="checkbox" name="presenter_refs[]" value="{{ $member->user_ref }}" @checked(in_array($member->user_ref, old('presenter_refs', []))) class="rounded border-slate-300">
|
||||
<span>{{ $member->user_ref }}</span>
|
||||
</label>
|
||||
@endforeach
|
||||
</div>
|
||||
</div>
|
||||
@endif
|
||||
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-slate-700">Passcode (optional)</label>
|
||||
<input type="text" name="passcode" value="{{ old('passcode') }}" class="mt-1 w-full rounded-lg border-slate-300 text-sm">
|
||||
|
||||
@@ -12,14 +12,6 @@
|
||||
</div>
|
||||
@endif
|
||||
|
||||
<div class="mt-4 rounded-xl border border-violet-100 bg-violet-50 px-4 py-3 text-sm text-violet-900">
|
||||
Multi-speaker event with assigned hosts and presenters.
|
||||
Billed at <span class="font-medium">GHS {{ number_format(config('meet.billing.price_per_participant_ghs', 0.30), 2) }} per participant</span>.
|
||||
@if ($room->setting('green_room'))
|
||||
Green room is enabled — presenters rehearse before the audience joins live.
|
||||
@endif
|
||||
</div>
|
||||
|
||||
<div class="mt-6 rounded-2xl border border-slate-200 bg-white p-6">
|
||||
<h2 class="text-sm font-medium text-slate-700">Join link</h2>
|
||||
<div class="mt-2 flex gap-2">
|
||||
@@ -59,8 +51,30 @@
|
||||
</p>
|
||||
@endif
|
||||
|
||||
@if ($room->description)
|
||||
<p class="mt-3 text-sm text-slate-600">{{ $room->description }}</p>
|
||||
@if ($room->setting('green_room'))
|
||||
<p class="mt-3 text-sm text-violet-700">Green room is on — when you start, you and assigned speakers rehearse before going live to the audience.</p>
|
||||
@endif
|
||||
|
||||
@if ($speakers->isNotEmpty())
|
||||
<div class="mt-4">
|
||||
<h3 class="text-sm font-medium text-slate-700">Assigned speakers</h3>
|
||||
<ul class="mt-2 space-y-1 text-sm text-slate-600">
|
||||
@foreach ($speakers as $speaker)
|
||||
<li>{{ $speaker->user_ref }}</li>
|
||||
@endforeach
|
||||
</ul>
|
||||
</div>
|
||||
@endif
|
||||
|
||||
@if ($room->invitations->where('role', 'panelist')->isNotEmpty())
|
||||
<div class="mt-4">
|
||||
<h3 class="text-sm font-medium text-slate-700">Invited speakers</h3>
|
||||
<ul class="mt-2 space-y-1 text-sm text-slate-600">
|
||||
@foreach ($room->invitations->where('role', 'panelist') as $invite)
|
||||
<li>{{ $invite->email }}</li>
|
||||
@endforeach
|
||||
</ul>
|
||||
</div>
|
||||
@endif
|
||||
</div>
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
<x-meet-kiosk-layout :title="'Meeting ended · '.$room->title" :organization="$organization">
|
||||
<x-meet-kiosk-layout :title="($room->isConference() ? 'Conference ended' : 'Meeting ended').' · '.$room->title" :organization="$organization">
|
||||
<div class="flex min-h-[calc(100vh-8rem)] flex-col items-center justify-center text-center">
|
||||
<div class="w-full max-w-md rounded-3xl border border-slate-200 bg-white p-8 shadow-sm">
|
||||
<div class="mx-auto flex h-14 w-14 items-center justify-center rounded-full bg-slate-100 text-slate-600">
|
||||
@@ -6,8 +6,8 @@
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M15.75 9V5.25A2.25 2.25 0 0 0 13.5 3h-6a2.25 2.25 0 0 0-2.25 2.25v13.5A2.25 2.25 0 0 0 7.5 21h6a2.25 2.25 0 0 0 2.25-2.25V15m3 0 3-3m0 0-3-3m3 3H9"/>
|
||||
</svg>
|
||||
</div>
|
||||
<h1 class="mt-6 text-2xl font-bold text-slate-900">Meeting ended</h1>
|
||||
<p class="mt-3 text-sm text-slate-600">The host has ended this meeting.</p>
|
||||
<h1 class="mt-6 text-2xl font-bold text-slate-900">{{ $room->isConference() ? 'Conference ended' : 'Meeting ended' }}</h1>
|
||||
<p class="mt-3 text-sm text-slate-600">The host has ended this {{ $room->isConference() ? 'conference' : 'meeting' }}.</p>
|
||||
<p class="mt-1 text-sm font-medium text-slate-700">{{ $room->title }}</p>
|
||||
|
||||
<div class="mt-8 flex flex-col gap-3">
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
<x-meet-kiosk-layout :title="'Meeting cancelled'" :organization="$organization ?? null">
|
||||
<x-meet-kiosk-layout :title="($room->isConference() ? 'Conference cancelled' : 'Meeting cancelled')" :organization="$organization ?? null">
|
||||
<div class="flex min-h-[calc(100vh-8rem)] flex-col items-center justify-center text-center">
|
||||
<div class="w-full max-w-md rounded-3xl border border-slate-200 bg-white p-8 shadow-sm">
|
||||
<h1 class="text-2xl font-bold text-slate-900">{{ $room->title }}</h1>
|
||||
<p class="mt-3 text-sm text-slate-600">This meeting has been cancelled by the host.</p>
|
||||
<p class="mt-3 text-sm text-slate-600">This {{ $room->isConference() ? 'conference' : 'meeting' }} has been cancelled by the host.</p>
|
||||
</div>
|
||||
</div>
|
||||
</x-meet-kiosk-layout>
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
</svg>
|
||||
</div>
|
||||
<h1 class="mt-6 text-2xl font-bold text-slate-900">Unable to join</h1>
|
||||
<p class="mt-3 text-sm text-slate-600">{{ $reason ?? 'You do not have access to this meeting.' }}</p>
|
||||
<p class="mt-3 text-sm text-slate-600">{{ $reason ?? 'You do not have access to this '.($room->isConference() ? 'conference' : 'meeting').'.' }}</p>
|
||||
<a href="{{ route('sso.connect', ['redirect' => route('meet.join', $room), 'interactive' => 1]) }}" class="btn-primary btn-primary-lg mt-8 inline-flex w-full items-center justify-center py-3.5 font-semibold">
|
||||
Sign in with Ladill
|
||||
</a>
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
<x-meet-kiosk-layout :title="'Passcode · '.$room->title" :organization="$organization" eyebrow="Secure meeting">
|
||||
<x-meet-kiosk-layout :title="'Passcode · '.$room->title" :organization="$organization" eyebrow="{{ $room->isConference() ? 'Secure conference' : 'Secure meeting' }}">
|
||||
<div class="flex min-h-[calc(100vh-8rem)] flex-col items-center justify-center">
|
||||
<div class="w-full max-w-md rounded-3xl border border-slate-200 bg-white p-8 shadow-sm">
|
||||
<h1 class="text-2xl font-bold text-slate-900">{{ $room->title }}</h1>
|
||||
<p class="mt-2 text-sm text-slate-500">This meeting is protected. Enter the passcode to continue.</p>
|
||||
<p class="mt-2 text-sm text-slate-500">This {{ $room->isConference() ? 'conference' : 'meeting' }} is protected. Enter the passcode to continue.</p>
|
||||
|
||||
@if ($errors->any())
|
||||
<div class="mt-4 rounded-2xl border border-red-200 bg-red-50 px-4 py-3 text-sm text-red-700">
|
||||
|
||||
@@ -1,4 +1,7 @@
|
||||
<x-meet-kiosk-layout :title="'Join · '.$room->title" :organization="$organization">
|
||||
@php
|
||||
$sessionNoun = $room->isConference() ? 'conference' : 'meeting';
|
||||
@endphp
|
||||
<div class="flex min-h-[calc(100vh-8rem)] flex-col items-center justify-center text-center">
|
||||
<div class="w-full max-w-lg">
|
||||
<h1 class="text-3xl font-bold tracking-tight text-slate-900 sm:text-4xl">{{ $room->title }}</h1>
|
||||
@@ -47,11 +50,11 @@
|
||||
@endauth
|
||||
|
||||
@if ($room->setting('waiting_room', true) && ! ($user && $user->ownerRef() === $room->host_user_ref))
|
||||
<p class="mt-4 text-xs text-slate-500">The host will admit you when the meeting is ready.</p>
|
||||
<p class="mt-4 text-xs text-slate-500">The host will admit you when the {{ $sessionNoun }} is ready.</p>
|
||||
@endif
|
||||
|
||||
<button type="submit" class="btn-primary btn-primary-lg mt-8 w-full py-4 text-base font-semibold">
|
||||
Join meeting
|
||||
Join {{ $sessionNoun }}
|
||||
</button>
|
||||
</form>
|
||||
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
@csrf
|
||||
|
||||
<div class="text-center">
|
||||
<h1 class="text-2xl font-bold text-slate-900">You left the meeting</h1>
|
||||
<h1 class="text-2xl font-bold text-slate-900">You left the {{ $room->isConference() ? 'conference' : 'meeting' }}</h1>
|
||||
<p class="mt-2 text-sm text-slate-500">{{ $room->title }}</p>
|
||||
@if (! empty($feedback['display_name']))
|
||||
<p class="mt-1 text-sm text-slate-600">Thanks, {{ $feedback['display_name'] }}.</p>
|
||||
@@ -14,7 +14,7 @@
|
||||
</div>
|
||||
|
||||
<div class="mt-8">
|
||||
<p class="text-sm font-medium text-slate-700">How was the meeting overall?</p>
|
||||
<p class="text-sm font-medium text-slate-700">How was the {{ $room->isConference() ? 'conference' : 'meeting' }} overall?</p>
|
||||
<div class="mt-3 flex justify-center gap-2">
|
||||
@foreach (range(1, 5) as $star)
|
||||
<button type="button"
|
||||
|
||||
@@ -10,7 +10,7 @@
|
||||
@if (session('rated'))
|
||||
Thanks for your feedback
|
||||
@else
|
||||
You have left the meeting
|
||||
You have left the {{ $room->isConference() ? 'conference' : 'meeting' }}
|
||||
@endif
|
||||
</h1>
|
||||
<p class="mt-3 text-sm text-slate-600">{{ $room->title }}</p>
|
||||
@@ -19,7 +19,7 @@
|
||||
@auth
|
||||
<a href="{{ route('meet.dashboard') }}" class="btn-primary btn-primary-lg py-3.5 font-semibold">Back to Meet</a>
|
||||
@else
|
||||
<a href="{{ route('meet.join', $room) }}" class="btn-primary btn-primary-lg py-3.5 font-semibold">Rejoin meeting</a>
|
||||
<a href="{{ route('meet.join', $room) }}" class="btn-primary btn-primary-lg py-3.5 font-semibold">Rejoin {{ $room->isConference() ? 'conference' : 'meeting' }}</a>
|
||||
@endauth
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -25,6 +25,8 @@
|
||||
<p class="mt-1 text-sm text-slate-500">
|
||||
@if ($room->scheduled_at)
|
||||
{{ $room->scheduled_at->timezone($room->timezone)->format('M j, Y g:i A T') }}
|
||||
@elseif ($room->isConference())
|
||||
Instant conference
|
||||
@else
|
||||
Instant meeting
|
||||
@endif
|
||||
|
||||
@@ -1,5 +1,12 @@
|
||||
@php
|
||||
$room = $recording->session->room;
|
||||
$sessionNoun = $room->isConference() ? 'conference' : 'meeting';
|
||||
$roomDetailsRoute = match (true) {
|
||||
$room->isConference() => route('meet.conferences.show', $room),
|
||||
$room->isWebinar() => route('meet.webinars.show', $room),
|
||||
$room->isSpace() => route('meet.spaces.show', $room),
|
||||
default => route('meet.rooms.show', $room),
|
||||
};
|
||||
$statusLabel = config('meet.recording_statuses')[$recording->status] ?? ucfirst($recording->status);
|
||||
$statusClass = match ($recording->status) {
|
||||
'ready' => 'bg-emerald-50 text-emerald-700',
|
||||
@@ -80,7 +87,7 @@
|
||||
</svg>
|
||||
<div>
|
||||
<p class="text-sm font-medium text-amber-900">Processing recording</p>
|
||||
<p class="mt-0.5 text-sm text-amber-800">This usually takes a moment after the meeting ends. Refresh shortly.</p>
|
||||
<p class="mt-0.5 text-sm text-amber-800">This usually takes a moment after the {{ $sessionNoun }} ends. Refresh shortly.</p>
|
||||
</div>
|
||||
</div>
|
||||
@elseif ($recording->status === 'failed')
|
||||
@@ -98,7 +105,7 @@
|
||||
Download
|
||||
</a>
|
||||
@endif
|
||||
<a href="{{ route('meet.rooms.show', $room) }}" class="btn-secondary btn-secondary-sm">View meeting</a>
|
||||
<a href="{{ $roomDetailsRoute }}" class="btn-secondary btn-secondary-sm">View {{ $sessionNoun }}</a>
|
||||
@if ($summary)
|
||||
<a href="{{ route('meet.summaries.show', $summary) }}" class="btn-secondary btn-secondary-sm">AI summary</a>
|
||||
@endif
|
||||
|
||||
@@ -24,6 +24,9 @@
|
||||
'display_name' => $roomHostUser?->name ?? 'Host',
|
||||
'avatar_url' => $roomHostUser?->avatarUrl(),
|
||||
];
|
||||
$isConferenceSession = $isConference ?? false;
|
||||
$sessionNoun = $isConferenceSession ? 'conference' : 'meeting';
|
||||
$sessionNounTitle = $isConferenceSession ? 'Conference' : 'Meeting';
|
||||
@endphp
|
||||
<div id="meet-config"
|
||||
data-token="{{ $mediaToken }}"
|
||||
@@ -53,6 +56,12 @@
|
||||
data-locked="{{ $session->is_locked ? '1' : '0' }}"
|
||||
data-recording-active="{{ $activeRecording ? '1' : '0' }}"
|
||||
data-is-webinar="{{ ($isWebinar ?? false) ? '1' : '0' }}"
|
||||
data-is-conference="{{ ($isConference ?? false) ? '1' : '0' }}"
|
||||
data-is-green-room="{{ ($isGreenRoom ?? false) ? '1' : '0' }}"
|
||||
data-session-mode="{{ $session->session_mode }}"
|
||||
data-go-live-url="{{ ($isConference ?? false) && ($isGreenRoom ?? false) && $participant->isHost() ? route('meet.room.townhall.live', $session) : '' }}"
|
||||
data-add-presenter-url="{{ ($isConference ?? false) && $participant->isHost() ? route('meet.room.townhall.presenters', $session) : '' }}"
|
||||
data-presenter-refs='@json($presenterRefs ?? [])'
|
||||
data-can-publish="{{ ($canPublish ?? true) ? '1' : '0' }}"
|
||||
data-qa-url="{{ route('meet.room.qa.submit', $session) }}"
|
||||
data-polls-create-url="{{ route('meet.room.polls.create', $session) }}"
|
||||
@@ -84,6 +93,10 @@
|
||||
<h1 class="truncate text-sm font-semibold">{{ $room->title }}</h1>
|
||||
@if ($isAudioOnly ?? false)
|
||||
<p class="text-[10px] font-medium uppercase tracking-wide text-emerald-400/90">Audio room</p>
|
||||
@elseif ($isConference ?? false)
|
||||
<p class="text-[10px] font-medium uppercase tracking-wide"
|
||||
:class="isGreenRoom ? 'text-violet-400/90' : 'text-sky-400/90'"
|
||||
x-text="isGreenRoom ? 'Green room' : 'Conference live'"></p>
|
||||
@endif
|
||||
<p class="text-xs text-slate-400" x-text="connectionStatus"></p>
|
||||
<p class="min-h-4 truncate text-xs font-medium leading-4"
|
||||
@@ -94,7 +107,7 @@
|
||||
<div class="flex shrink-0 items-center gap-2 text-xs text-slate-400">
|
||||
<button @click.stop="openShare()" data-share-trigger type="button"
|
||||
class="inline-flex items-center gap-1.5 rounded-lg bg-slate-900 px-2.5 py-1.5 text-xs font-medium text-slate-200 transition-colors hover:bg-slate-800"
|
||||
title="Share meeting link">
|
||||
title="Share {{ $sessionNoun }} link">
|
||||
@include('meet.room.partials.meet-icon', ['icon' => 'share', 'class' => 'h-4 w-4'])
|
||||
Share
|
||||
</button>
|
||||
@@ -122,6 +135,18 @@
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div x-show="isConference && isGreenRoom" x-cloak
|
||||
class="flex shrink-0 items-center justify-between gap-3 border-b border-violet-500/30 bg-violet-500/15 px-4 py-2.5">
|
||||
<div class="min-w-0 text-sm text-violet-100">
|
||||
<p class="font-medium">Green room</p>
|
||||
<p class="truncate text-xs text-violet-200/90">Only hosts and speakers are visible. Attendees join after you go live.</p>
|
||||
</div>
|
||||
<button x-show="isHost" @click="goLive()" type="button" :disabled="goingLive"
|
||||
class="shrink-0 rounded-lg bg-violet-500 px-3 py-1.5 text-xs font-semibold text-white transition-colors hover:bg-violet-400 disabled:opacity-60">
|
||||
<span x-text="goingLive ? 'Starting…' : 'Go live'">Go live</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div x-show="isHost && waitingCount > 0" x-cloak
|
||||
class="flex shrink-0 items-center justify-between gap-3 border-b border-amber-500/30 bg-amber-500/15 px-4 py-2.5">
|
||||
<div class="flex min-w-0 items-center gap-2 text-sm text-amber-100">
|
||||
@@ -151,7 +176,7 @@
|
||||
<div class="flex items-start justify-between gap-2">
|
||||
<div>
|
||||
<p class="text-sm font-medium text-white">Invite others</p>
|
||||
<p class="mt-0.5 text-xs text-slate-400">Share this link so people can join the meeting.</p>
|
||||
<p class="mt-0.5 text-xs text-slate-400">Share this link so people can join the {{ $sessionNoun }}.</p>
|
||||
</div>
|
||||
<button @click="shareOpen = false" type="button" class="rounded p-1 text-slate-400 hover:bg-slate-800 hover:text-white" title="Close">
|
||||
<svg class="h-4 w-4" fill="none" stroke="currentColor" stroke-width="1.5" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M6 18 18 6M6 6l12 12"/></svg>
|
||||
@@ -254,8 +279,8 @@
|
||||
@csrf
|
||||
<button type="submit"
|
||||
class="rounded-full bg-red-600 p-3 font-medium hover:bg-red-500 sm:px-5 sm:py-3 sm:text-sm"
|
||||
title="Leave meeting"
|
||||
aria-label="Leave meeting">
|
||||
title="Leave {{ $sessionNoun }}"
|
||||
aria-label="Leave {{ $sessionNoun }}">
|
||||
<span class="sm:hidden">
|
||||
@include('meet.room.partials.meet-icon', ['icon' => 'leave'])
|
||||
</span>
|
||||
@@ -265,8 +290,8 @@
|
||||
@if ($participant->isHost())
|
||||
<button @click="openEndMeetingConfirm()" type="button"
|
||||
class="rounded-full bg-red-950 p-3 text-red-400 hover:bg-red-900 sm:px-4 sm:py-3 sm:text-sm"
|
||||
title="End meeting for everyone"
|
||||
aria-label="End meeting for everyone">
|
||||
title="End {{ $sessionNoun }} for everyone"
|
||||
aria-label="End {{ $sessionNoun }} for everyone">
|
||||
<span class="sm:hidden">
|
||||
@include('meet.room.partials.meet-icon', ['icon' => 'end'])
|
||||
</span>
|
||||
@@ -307,19 +332,19 @@
|
||||
class="fixed inset-0 z-50 flex items-center justify-center bg-black/60 p-4 backdrop-blur-sm"
|
||||
@keydown.escape.window="endMeetingConfirmOpen = false">
|
||||
<div class="w-full max-w-sm rounded-2xl bg-slate-900 p-5 shadow-2xl" @click.outside="endMeetingConfirmOpen = false">
|
||||
<h3 class="text-base font-semibold text-white">End meeting for everyone?</h3>
|
||||
<h3 class="text-base font-semibold text-white">End {{ $sessionNoun }} for everyone?</h3>
|
||||
<p class="mt-2 text-sm text-slate-400">All participants will be disconnected and the session will close.</p>
|
||||
<div class="mt-5 flex justify-end gap-2">
|
||||
<button type="button" @click="endMeetingConfirmOpen = false"
|
||||
class="rounded-xl px-4 py-2 text-sm font-medium text-slate-300 hover:bg-slate-800">Cancel</button>
|
||||
<button type="button" @click="submitEndMeeting()"
|
||||
class="rounded-xl bg-red-600 px-4 py-2 text-sm font-medium text-white hover:bg-red-500">End meeting</button>
|
||||
class="rounded-xl bg-red-600 px-4 py-2 text-sm font-medium text-white hover:bg-red-500">End {{ $sessionNoun }}</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<x-meet.room.panel show="chatOpen" title="Meeting chat">
|
||||
<x-meet.room.panel show="chatOpen" title="{{ $sessionNounTitle }} chat">
|
||||
<x-slot:subtitle>
|
||||
<p>Messages are visible to everyone in the call</p>
|
||||
</x-slot:subtitle>
|
||||
@@ -403,6 +428,13 @@
|
||||
<span x-show="isSpeaking(person.uuid)" class="block text-xs font-medium text-emerald-400">Speaking</span>
|
||||
<span x-show="!isSpeaking(person.uuid)" class="block text-xs text-slate-400" x-text="roleLabel(person.role)"></span>
|
||||
</span>
|
||||
<span class="flex shrink-0 items-center gap-1">
|
||||
<button x-show="isConference && isHost && isGreenRoom && person.user_ref && !['host', 'co_host', 'panelist'].includes(person.role)"
|
||||
type="button" @click="makeSpeaker(person.user_ref)"
|
||||
class="rounded-lg bg-violet-600 px-2 py-1 text-[10px] font-medium text-white hover:bg-violet-500">
|
||||
Make speaker
|
||||
</button>
|
||||
</span>
|
||||
<span class="flex shrink-0 items-center gap-1 text-slate-400">
|
||||
<span x-show="isSpeaking(person.uuid)" class="meet-speaking-badge rounded-full bg-emerald-500/20 p-1 text-emerald-300" title="Speaking">
|
||||
<svg class="h-3.5 w-3.5" viewBox="0 0 16 16" fill="currentColor" aria-hidden="true">
|
||||
@@ -429,7 +461,7 @@
|
||||
|
||||
<x-meet.room.panel show="featuresOpen" title="More options">
|
||||
<x-slot:subtitle>
|
||||
<p>Meeting tools and extras</p>
|
||||
<p>{{ $sessionNounTitle }} tools and extras</p>
|
||||
</x-slot:subtitle>
|
||||
|
||||
<div class="flex min-h-0 flex-1 flex-col overflow-y-auto">
|
||||
@@ -441,7 +473,7 @@
|
||||
@click="sendReaction('thumbs'); featuresOpen = false" />
|
||||
<x-meet.room.menu-item icon="react" title="React" subtitle="Send a heart to the room"
|
||||
@click="sendReaction('heart'); featuresOpen = false" />
|
||||
<x-meet.room.menu-item icon="chat" title="Chat" subtitle="Open the meeting chat"
|
||||
<x-meet.room.menu-item icon="chat" title="Chat" subtitle="Open the {{ $sessionNoun }} chat"
|
||||
@click="toggleChat(); featuresOpen = false" />
|
||||
<div class="my-1 border-t border-slate-800"></div>
|
||||
</div>
|
||||
@@ -465,7 +497,7 @@
|
||||
</span>
|
||||
<span class="min-w-0">
|
||||
<span class="block font-medium" x-text="isRecording ? 'Stop recording' : 'Start recording'"></span>
|
||||
<span class="block text-xs text-slate-400">Save this meeting to the cloud</span>
|
||||
<span class="block text-xs text-slate-400">Save this {{ $sessionNoun }} to the cloud</span>
|
||||
</span>
|
||||
</button>
|
||||
|
||||
@@ -475,7 +507,7 @@
|
||||
@include('meet.room.partials.meet-icon', ['icon' => 'lock', 'class' => 'h-5 w-5'])
|
||||
</span>
|
||||
<span class="min-w-0">
|
||||
<span class="block font-medium" x-text="isLocked ? 'Unlock meeting' : 'Lock meeting'"></span>
|
||||
<span class="block font-medium" x-text="isLocked ? 'Unlock ' + sessionNoun() : 'Lock ' + sessionNoun()"></span>
|
||||
<span class="block text-xs text-slate-400">Control who can join</span>
|
||||
</span>
|
||||
</button>
|
||||
|
||||
@@ -40,7 +40,7 @@
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-slate-700">Timezone</label>
|
||||
<select name="timezone" class="mt-1 w-full rounded-lg border-slate-300 text-sm">
|
||||
@foreach (['UTC', 'America/New_York', 'America/Chicago', 'America/Los_Angeles', 'Europe/London', 'Africa/Lagos'] as $tz)
|
||||
@foreach (config('meet.schedule_timezones') as $tz)
|
||||
<option value="{{ $tz }}" @selected(old('timezone', $organization->timezone) === $tz)>{{ $tz }}</option>
|
||||
@endforeach
|
||||
</select>
|
||||
|
||||
@@ -41,7 +41,7 @@
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-slate-700">Timezone</label>
|
||||
<select name="timezone" class="mt-1 w-full rounded-lg border-slate-300 text-sm">
|
||||
@foreach (['UTC', 'America/New_York', 'America/Chicago', 'America/Los_Angeles', 'Europe/London', 'Africa/Lagos'] as $tz)
|
||||
@foreach (config('meet.schedule_timezones') as $tz)
|
||||
<option value="{{ $tz }}" @selected(old('timezone', $organization->timezone) === $tz)>{{ $tz }}</option>
|
||||
@endforeach
|
||||
</select>
|
||||
|
||||
+2
-1
@@ -32,7 +32,7 @@ use Illuminate\Support\Facades\Route;
|
||||
|
||||
Route::get('/', fn () => auth()->check()
|
||||
? redirect()->route('meet.dashboard')
|
||||
: redirect()->route('sso.connect', ['interactive' => 1]))->name('meet.root');
|
||||
: redirect()->route('sso.connect'))->name('meet.root');
|
||||
|
||||
Route::get('/login', [SsoLoginController::class, 'connect'])->name('login');
|
||||
Route::get('/sso/connect', [SsoLoginController::class, 'connect'])->name('sso.connect');
|
||||
@@ -99,6 +99,7 @@ Route::post('/room/{session}/live-stream/start', [MeetingFeaturesController::cla
|
||||
Route::post('/room/{session}/live-stream/stop', [MeetingFeaturesController::class, 'stopLiveStream'])->name('meet.room.livestream.stop');
|
||||
Route::post('/room/{session}/town-hall/green-room', [MeetingFeaturesController::class, 'startGreenRoom'])->name('meet.room.townhall.green');
|
||||
Route::post('/room/{session}/town-hall/go-live', [MeetingFeaturesController::class, 'goLiveTownHall'])->name('meet.room.townhall.live');
|
||||
Route::post('/room/{session}/town-hall/presenters', [MeetingFeaturesController::class, 'addPresenter'])->name('meet.room.townhall.presenters');
|
||||
Route::post('/room/{session}/town-hall/handoff', [MeetingFeaturesController::class, 'handoffCoHost'])->name('meet.room.townhall.handoff');
|
||||
|
||||
Route::middleware(['auth', 'platform.session'])->group(function () {
|
||||
|
||||
@@ -67,6 +67,20 @@ class MeetWebTest extends TestCase
|
||||
$this->get('/dashboard')->assertRedirect();
|
||||
}
|
||||
|
||||
public function test_guest_root_uses_silent_sso_not_interactive_auth(): void
|
||||
{
|
||||
$response = $this->get('/');
|
||||
|
||||
$response->assertRedirect(route('sso.connect'));
|
||||
$this->assertStringNotContainsString('interactive=1', (string) $response->headers->get('Location'));
|
||||
}
|
||||
|
||||
public function test_sso_callback_sends_guest_without_session_to_marketing_page(): void
|
||||
{
|
||||
$this->get('/sso/callback?error=login_required')
|
||||
->assertRedirect(config('ladill.marketing_url'));
|
||||
}
|
||||
|
||||
public function test_onboarded_user_can_view_dashboard(): void
|
||||
{
|
||||
$this->actingAs($this->user)
|
||||
@@ -464,6 +478,7 @@ class MeetWebTest extends TestCase
|
||||
|
||||
public function test_service_api_can_create_room(): void
|
||||
{
|
||||
\Illuminate\Support\Facades\Http::fake();
|
||||
config(['meet.service_api_keys.care' => 'test-care-key']);
|
||||
|
||||
$this->postJson('/api/service/v1/rooms', [
|
||||
@@ -482,6 +497,51 @@ class MeetWebTest extends TestCase
|
||||
]);
|
||||
}
|
||||
|
||||
public function test_service_api_resolves_organization_from_owner_ref(): void
|
||||
{
|
||||
\Illuminate\Support\Facades\Http::fake();
|
||||
config(['meet.service_api_keys.crm' => 'test-crm-key']);
|
||||
|
||||
$this->postJson('/api/service/v1/rooms', [
|
||||
'owner_ref' => $this->user->public_id,
|
||||
'host_user_ref' => $this->user->public_id,
|
||||
'title' => 'CRM follow-up',
|
||||
'scheduled_at' => now()->addDay()->toIso8601String(),
|
||||
'source' => ['app' => 'crm', 'entity_type' => 'deal', 'entity_id' => '42'],
|
||||
], ['Authorization' => 'Bearer test-crm-key'])
|
||||
->assertCreated()
|
||||
->assertJsonPath('room.organization_id', $this->organization->id);
|
||||
}
|
||||
|
||||
public function test_scheduled_room_syncs_to_ladill_mail_calendar(): void
|
||||
{
|
||||
\Illuminate\Support\Facades\Http::fake([
|
||||
'mail.ladill.com/*' => \Illuminate\Support\Facades\Http::response(['data' => ['id' => 99]], 201),
|
||||
]);
|
||||
|
||||
config([
|
||||
'meet.calendar.mail.api_url' => 'https://mail.ladill.com/api/service/v1',
|
||||
'meet.calendar.mail.api_key' => 'mail-test-key',
|
||||
]);
|
||||
|
||||
$this->actingAs($this->user)
|
||||
->post('/meetings', [
|
||||
'title' => 'Calendar sync test',
|
||||
'scheduled_at' => now()->addDay()->format('Y-m-d H:i'),
|
||||
'duration_minutes' => 30,
|
||||
])
|
||||
->assertRedirect();
|
||||
|
||||
$room = \App\Models\Room::where('title', 'Calendar sync test')->first();
|
||||
$this->assertNotNull($room);
|
||||
$this->assertSame('99', (string) $room->calendar_event_id);
|
||||
|
||||
\Illuminate\Support\Facades\Http::assertSent(function ($request) {
|
||||
return str_contains($request->url(), '/calendar/events')
|
||||
&& $request['mailbox_email'] === strtolower($this->user->email);
|
||||
});
|
||||
}
|
||||
|
||||
public function test_invitation_rsvp_accepts(): void
|
||||
{
|
||||
$room = $this->createRoom();
|
||||
@@ -614,6 +674,55 @@ class MeetWebTest extends TestCase
|
||||
]);
|
||||
}
|
||||
|
||||
public function test_host_can_end_conference_and_view_details(): void
|
||||
{
|
||||
$room = Room::create([
|
||||
'owner_ref' => $this->user->public_id,
|
||||
'organization_id' => $this->organization->id,
|
||||
'host_user_ref' => $this->user->public_id,
|
||||
'title' => 'NextGen Summit',
|
||||
'type' => 'town_hall',
|
||||
'status' => 'live',
|
||||
'scheduled_at' => now()->addHour(),
|
||||
'timezone' => 'UTC',
|
||||
'settings' => array_merge(config('meet.default_settings'), [
|
||||
'green_room' => true,
|
||||
'stage_mode' => true,
|
||||
'presenter_refs' => [],
|
||||
]),
|
||||
]);
|
||||
|
||||
$session = Session::create([
|
||||
'owner_ref' => $this->user->public_id,
|
||||
'room_id' => $room->id,
|
||||
'media_room_name' => 'meet-'.$room->uuid,
|
||||
'status' => 'live',
|
||||
'session_mode' => 'live',
|
||||
'started_at' => now(),
|
||||
]);
|
||||
|
||||
$participant = \App\Models\Participant::create([
|
||||
'owner_ref' => $this->user->public_id,
|
||||
'session_id' => $session->id,
|
||||
'user_ref' => $this->user->public_id,
|
||||
'display_name' => $this->user->name,
|
||||
'role' => 'host',
|
||||
'status' => 'joined',
|
||||
'joined_at' => now(),
|
||||
]);
|
||||
|
||||
$this->actingAs($this->user)
|
||||
->withSession(["meet.participant.{$session->uuid}" => $participant->uuid])
|
||||
->post('/room/'.$session->uuid.'/end')
|
||||
->assertRedirect(route('meet.conferences.show', $room))
|
||||
->assertSessionHas('success', 'Conference ended.');
|
||||
|
||||
$this->actingAs($this->user)
|
||||
->get(route('meet.conferences.show', $room))
|
||||
->assertOk()
|
||||
->assertSee('NextGen Summit');
|
||||
}
|
||||
|
||||
public function test_host_can_create_space_room(): void
|
||||
{
|
||||
$this->actingAs($this->user)
|
||||
@@ -629,6 +738,27 @@ class MeetWebTest extends TestCase
|
||||
]);
|
||||
}
|
||||
|
||||
public function test_afia_chat_returns_reply_when_ai_configured(): void
|
||||
{
|
||||
config([
|
||||
'afia.api_key' => 'test-openai-key',
|
||||
'afia.platform_api_key' => '',
|
||||
]);
|
||||
|
||||
Http::fake([
|
||||
'api.openai.com/*' => Http::response([
|
||||
'choices' => [['message' => ['content' => 'Go to Meetings and click Schedule meeting.']]],
|
||||
]),
|
||||
]);
|
||||
|
||||
$this->createRoom(['title' => 'Upcoming sync']);
|
||||
|
||||
$this->actingAs($this->user)
|
||||
->postJson('/ai/chat', ['message' => 'How do I schedule a meeting?'])
|
||||
->assertOk()
|
||||
->assertJson(['reply' => 'Go to Meetings and click Schedule meeting.']);
|
||||
}
|
||||
|
||||
protected function createRoom(array $overrides = []): Room
|
||||
{
|
||||
return Room::create(array_merge([
|
||||
|
||||
Reference in New Issue
Block a user