From a9d3a8bd64c8ce58bce3f6ee56157d11c420502f Mon Sep 17 00:00:00 2001 From: isaacclad Date: Wed, 1 Jul 2026 06:45:35 +0000 Subject: [PATCH] Sync email team invites, meeting room fixes, Afia, and org settings. Publish monorepo meet changes: identity API invites, join/room session fixes, Afia assistant panel, settings nav, and SSO team provisioning. Co-authored-by: Cursor --- .../Controllers/Auth/SsoLoginController.php | 41 ++++- app/Http/Controllers/Meet/AiController.php | 63 +++++++ app/Http/Controllers/Meet/JoinController.php | 34 +++- .../Controllers/Meet/MemberController.php | 30 +++- app/Http/Controllers/Meet/RoomController.php | 16 +- .../Controllers/Meet/SettingsController.php | 69 +++++++ app/Services/Afia/AfiaService.php | 168 ++++++++++++++++++ app/Services/Identity/IdentityTeamClient.php | 74 ++++++++ app/Services/Meet/MeetPermissions.php | 4 +- app/Services/Meet/SessionService.php | 15 ++ app/Services/Meet/TeamMemberProvisioner.php | 56 ++++++ app/Support/OrganizationBranding.php | 4 +- config/afia.php | 11 ++ .../views/components/app-layout.blade.php | 1 + .../views/meet/admin/members/create.blade.php | 14 +- .../views/meet/admin/members/index.blade.php | 9 +- resources/views/meet/join/show.blade.php | 6 + resources/views/meet/rooms/show.blade.php | 6 + resources/views/meet/settings/edit.blade.php | 56 ++++++ resources/views/partials/afia.blade.php | 99 +++++++++++ resources/views/partials/sidebar.blade.php | 14 +- routes/web.php | 5 + tests/Feature/MeetWebTest.php | 69 +++++++ 23 files changed, 829 insertions(+), 35 deletions(-) create mode 100644 app/Http/Controllers/Meet/AiController.php create mode 100644 app/Services/Afia/AfiaService.php create mode 100644 app/Services/Identity/IdentityTeamClient.php create mode 100644 app/Services/Meet/TeamMemberProvisioner.php create mode 100644 config/afia.php create mode 100644 resources/views/meet/settings/edit.blade.php create mode 100644 resources/views/partials/afia.blade.php diff --git a/app/Http/Controllers/Auth/SsoLoginController.php b/app/Http/Controllers/Auth/SsoLoginController.php index d1f0ea7..10ed459 100644 --- a/app/Http/Controllers/Auth/SsoLoginController.php +++ b/app/Http/Controllers/Auth/SsoLoginController.php @@ -4,6 +4,8 @@ namespace App\Http\Controllers\Auth; use App\Http\Controllers\Controller; use App\Models\User; +use App\Services\Identity\IdentityTeamClient; +use App\Services\Meet\TeamMemberProvisioner; use Illuminate\Http\Client\Response as HttpResponse; use Illuminate\Http\RedirectResponse; use Illuminate\Http\Request; @@ -26,12 +28,14 @@ class SsoLoginController extends Controller */ private const MAX_SSO_ATTEMPTS = 3; - public function connect(Request $request): RedirectResponse|View + public function connect(Request $request, IdentityTeamClient $identity): RedirectResponse|View { $intended = (string) $request->query('redirect', route('meet.dashboard')); if (Auth::check()) { - return $this->safeRedirect($intended, route('meet.dashboard')); + $redirect = $this->resolveLanding($identity, $request->user(), $intended); + + return $this->safeRedirect($redirect, route('meet.dashboard')); } if (! $request->boolean('fallback')) { @@ -39,7 +43,10 @@ class SsoLoginController extends Controller } if ($this->attemptSilentRefresh($request, $intended)) { - return $this->safeRedirect($intended, route('meet.dashboard')); + app(TeamMemberProvisioner::class)->sync(Auth::user()); + $redirect = $this->resolveLanding($identity, Auth::user(), $intended); + + return $this->safeRedirect($redirect, route('meet.dashboard')); } $verifier = Str::random(64); @@ -115,7 +122,9 @@ class SsoLoginController extends Controller $request->session()->regenerate(); $request->session()->forget('sso.attempts'); - return $this->finishCallback($request, $intended, null); + app(TeamMemberProvisioner::class)->sync($user); + + return $this->finishCallback($request, $intended, null, app(IdentityTeamClient::class)); } public function failed(Request $request): View @@ -241,8 +250,12 @@ class SsoLoginController extends Controller ); } - private function finishCallback(Request $request, string $intended, ?string $error = null): RedirectResponse - { + private function finishCallback( + Request $request, + string $intended, + ?string $error = null, + ?IdentityTeamClient $identity = null, + ): RedirectResponse { if ($error) { $attempts = (int) $request->session()->get('sso.attempts', 0) + 1; @@ -262,7 +275,21 @@ class SsoLoginController extends Controller ]); } - return $this->safeRedirect($intended, route('meet.dashboard')); + $user = Auth::user(); + $redirect = ($user instanceof User && $identity) + ? $this->resolveLanding($identity, $user, $intended) + : $intended; + + return $this->safeRedirect($redirect, route('meet.dashboard')); + } + + private function resolveLanding(IdentityTeamClient $identity, User $user, string $intended): string + { + try { + return $identity->postAuthRedirect($user->ownerRef(), $intended); + } catch (\Throwable) { + return $intended; + } } private function safeReturnUrl(string $url): string diff --git a/app/Http/Controllers/Meet/AiController.php b/app/Http/Controllers/Meet/AiController.php new file mode 100644 index 0000000..e221b31 --- /dev/null +++ b/app/Http/Controllers/Meet/AiController.php @@ -0,0 +1,63 @@ +validate([ + 'message' => ['required', 'string', 'max:2000'], + 'history' => ['nullable', 'array', 'max:20'], + 'history.*.role' => ['nullable', 'string'], + 'history.*.text' => ['nullable', 'string'], + ]); + + if (! $afia->enabled()) { + return response()->json(['message' => 'Afia is not available right now.'], 503); + } + + try { + $reply = $afia->chat(trim($validated['message']), $validated['history'] ?? [], $this->context($request)); + } catch (\Throwable $e) { + report($e); + + return response()->json(['message' => 'Afia could not respond right now. Please try again.'], 502); + } + + return response()->json(['reply' => $reply]); + } + + /** @return array */ + private function context(Request $request): array + { + $owner = $this->ownerRef($request); + $organization = $this->organization($request); + + return [ + 'signed_in' => 'yes', + 'organization' => $organization->name, + 'meetings_total' => Room::owned($owner)->where('organization_id', $organization->id)->count(), + 'meetings_upcoming' => Room::owned($owner) + ->where('organization_id', $organization->id) + ->where('status', 'scheduled') + ->where('starts_at', '>=', now()) + ->count(), + 'recordings_total' => Recording::owned($owner) + ->whereHas('session.room', fn ($q) => $q->where('organization_id', $organization->id)) + ->count(), + 'team_members' => Member::owned($owner)->where('organization_id', $organization->id)->count(), + ]; + } +} diff --git a/app/Http/Controllers/Meet/JoinController.php b/app/Http/Controllers/Meet/JoinController.php index b2ea63a..f921a2e 100644 --- a/app/Http/Controllers/Meet/JoinController.php +++ b/app/Http/Controllers/Meet/JoinController.php @@ -10,6 +10,7 @@ use App\Services\Meet\SessionService; use App\Services\Meet\WebinarService; use Illuminate\Http\RedirectResponse; use Illuminate\Http\Request; +use Illuminate\Validation\ValidationException; use Illuminate\View\View; class JoinController extends Controller @@ -50,7 +51,9 @@ class JoinController extends Controller { $request->validate(['passcode' => ['required', 'string']]); - abort_unless($room->passcode === $request->input('passcode'), 422); + if ($room->passcode !== $request->input('passcode')) { + return back()->withErrors(['passcode' => 'Incorrect passcode.']); + } $request->session()->put("meet.passcode.{$room->uuid}", true); @@ -63,13 +66,24 @@ class JoinController extends Controller return redirect()->route('meet.join', $room); } - $validated = $request->validate([ - 'display_name' => ['required_without:auth', 'string', 'max:100'], + $rules = [ 'email' => ['nullable', 'email'], - ]); + ]; + + if ($request->user()) { + $rules['display_name'] = ['nullable', 'string', 'max:100']; + } else { + $rules['display_name'] = ['required', 'string', 'max:100']; + } + + try { + $validated = $request->validate($rules); + } catch (ValidationException $e) { + throw $e->redirectTo(route('meet.join', $room)); + } $user = $request->user(); - $displayName = $user?->name ?? $validated['display_name']; + $displayName = $user?->name ?? ($validated['display_name'] ?? 'Guest'); $email = $user?->email ?? ($validated['email'] ?? null); [$allowed, $reason] = $this->security->canJoin($room, $user, $email ?? $displayName.'@guest.local'); @@ -86,12 +100,16 @@ class JoinController extends Controller } elseif ($room->setting('join_before_host') || ($user && $user->ownerRef() === $room->host_user_ref)) { 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.']); } else { - abort_unless($room->activeSession(), 422, 'Meeting has not started yet.'); $session = $room->activeSession(); } } else { - abort_unless($room->activeSession(), 422, 'Meeting has not started yet. Please wait for the host.'); + if (! $room->activeSession()) { + return back()->withErrors(['join' => 'Meeting has not started yet. Please wait for the host.']); + } + $session = $room->activeSession(); } @@ -116,7 +134,7 @@ class JoinController extends Controller app(InvitationService::class)->markAcceptedOnJoin($room, $user, $email); - $request->session()->put("meet.participant.{$session->uuid}", $participant->uuid); + $this->sessions->rememberParticipantSession($request, $participant, $session); return redirect()->route('meet.room', $session); } diff --git a/app/Http/Controllers/Meet/MemberController.php b/app/Http/Controllers/Meet/MemberController.php index b04925c..9e72d9c 100644 --- a/app/Http/Controllers/Meet/MemberController.php +++ b/app/Http/Controllers/Meet/MemberController.php @@ -6,6 +6,7 @@ use App\Http\Controllers\Controller; use App\Http\Controllers\Meet\Concerns\ScopesToAccount; use App\Models\Branch; use App\Models\Member; +use App\Services\Identity\IdentityTeamClient; use App\Services\Meet\AuditLogger; use Illuminate\Http\RedirectResponse; use Illuminate\Http\Request; @@ -51,22 +52,41 @@ class MemberController extends Controller ]); } - public function store(Request $request): RedirectResponse + public function store(Request $request, IdentityTeamClient $identity): RedirectResponse { $this->authorizeAbility($request, 'admin.members.manage'); $organization = $this->organization($request); $owner = $this->ownerRef($request); $validated = $request->validate([ - 'user_ref' => ['required', 'string', 'max:255'], + 'email' => ['required', 'email', 'max:255'], 'role' => ['required', 'string', 'in:'.implode(',', array_keys(config('meet.roles')))], 'branch_id' => ['nullable', 'integer', 'exists:meet_branches,id'], ]); + $email = strtolower(trim($validated['email'])); + + if ($email === strtolower((string) $request->user()->email)) { + return back()->withErrors(['email' => 'You cannot invite yourself.']); + } + + $identity->inviteAppMember( + $owner, + $email, + ['meet'], + [ + 'meet' => [ + 'organization_id' => $organization->id, + 'role' => $validated['role'], + 'branch_id' => $validated['branch_id'] ?? null, + ], + ], + ); + $member = Member::updateOrCreate( [ 'organization_id' => $organization->id, - 'user_ref' => $validated['user_ref'], + 'user_ref' => $email, ], [ 'owner_ref' => $owner, @@ -75,9 +95,9 @@ class MemberController extends Controller ], ); - AuditLogger::record($owner, 'member.created', $organization->id, $owner, Member::class, $member->id); + AuditLogger::record($owner, 'member.invited', $organization->id, $owner, Member::class, $member->id); - return redirect()->route('meet.members.index')->with('success', 'Member saved.'); + return redirect()->route('meet.members.index')->with('success', 'Invitation sent to '.$email.'.'); } public function destroy(Request $request, Member $member): RedirectResponse diff --git a/app/Http/Controllers/Meet/RoomController.php b/app/Http/Controllers/Meet/RoomController.php index 34ceeb3..2e1b52c 100644 --- a/app/Http/Controllers/Meet/RoomController.php +++ b/app/Http/Controllers/Meet/RoomController.php @@ -172,7 +172,11 @@ class RoomController extends Controller { $this->authorizeAbility($request, 'meetings.host'); $this->authorizeOwner($request, $room); - abort_if($room->status === 'cancelled', 422, 'Meeting was cancelled.'); + + if ($room->status === 'cancelled') { + return redirect()->route('meet.rooms.show', $room) + ->withErrors(['start' => 'Meeting was cancelled.']); + } if ($room->isTownHall() && $room->setting('green_room')) { $session = app(\App\Services\Meet\TownHallService::class)->startGreenRoom($room, $request->user()); @@ -180,6 +184,11 @@ class RoomController extends Controller $session = $this->sessions->start($room, $request->user()); } + $participant = $this->sessions->joinedParticipantForUser($session, $request->user()); + abort_unless($participant, 500, 'Unable to join as host.'); + + $this->sessions->rememberParticipantSession($request, $participant, $session); + return redirect()->route('meet.room', $session); } @@ -204,6 +213,11 @@ class RoomController extends Controller $session = $this->sessions->start($room, $request->user()); + $participant = $this->sessions->joinedParticipantForUser($session, $request->user()); + abort_unless($participant, 500, 'Unable to join as host.'); + + $this->sessions->rememberParticipantSession($request, $participant, $session); + return redirect()->route('meet.room', $session); } diff --git a/app/Http/Controllers/Meet/SettingsController.php b/app/Http/Controllers/Meet/SettingsController.php index 477c16b..bcc16dd 100644 --- a/app/Http/Controllers/Meet/SettingsController.php +++ b/app/Http/Controllers/Meet/SettingsController.php @@ -4,7 +4,11 @@ namespace App\Http\Controllers\Meet; use App\Http\Controllers\Controller; use App\Http\Controllers\Meet\Concerns\ScopesToAccount; +use App\Models\Branch; +use App\Services\Meet\AuditLogger; +use App\Services\Meet\MeetPermissions; use App\Services\Meet\SecurityPolicyService; +use App\Support\OrganizationBranding; use Illuminate\Http\RedirectResponse; use Illuminate\Http\Request; use Illuminate\View\View; @@ -13,6 +17,71 @@ class SettingsController extends Controller { use ScopesToAccount; + public function edit(Request $request): View + { + $this->authorizeAbility($request, 'settings.view'); + $organization = $this->organization($request); + $canManage = app(MeetPermissions::class)->can($this->member($request), 'settings.manage'); + + $branchCount = Branch::owned($this->ownerRef($request)) + ->where('organization_id', $organization->id) + ->count(); + + return view('meet.settings.edit', [ + 'organization' => $organization, + 'canManage' => $canManage, + 'branchCount' => $branchCount, + 'isAdmin' => app(MeetPermissions::class)->isAdmin($this->member($request)), + 'orgTypes' => [ + 'business' => 'Business', + 'healthcare' => 'Healthcare', + 'education' => 'Education', + 'nonprofit' => 'Nonprofit', + ], + ]); + } + + public function update(Request $request): RedirectResponse + { + $this->authorizeAbility($request, 'settings.manage'); + $organization = $this->organization($request); + $owner = $this->ownerRef($request); + + $validated = $request->validate([ + 'name' => ['required', 'string', 'max:255'], + 'timezone' => ['required', 'timezone'], + 'org_type' => ['required', 'string', 'in:business,healthcare,education,nonprofit'], + 'logo' => ['nullable', 'image', 'mimes:jpeg,png,jpg,webp,svg', 'max:2048'], + 'remove_logo' => ['nullable', 'boolean'], + ]); + + $settings = $organization->settings ?? []; + $settings['onboarded'] = true; + $settings['org_type'] = $validated['org_type']; + + $logoPath = $organization->logo_path; + + if ($request->boolean('remove_logo')) { + OrganizationBranding::deleteStoredLogo($organization); + $logoPath = null; + } + + if ($request->hasFile('logo')) { + $logoPath = OrganizationBranding::storeLogo($organization, $request->file('logo')); + } + + $organization->update([ + 'name' => $validated['name'], + 'timezone' => $validated['timezone'], + 'logo_path' => $logoPath, + 'settings' => $settings, + ]); + + AuditLogger::record($owner, 'organization.updated', $organization->id, $owner, \App\Models\Organization::class, $organization->id); + + return back()->with('success', 'Settings saved.'); + } + public function security(Request $request): View { $this->authorizeAbility($request, 'meetings.manage'); diff --git a/app/Services/Afia/AfiaService.php b/app/Services/Afia/AfiaService.php new file mode 100644 index 0000000..69fbbf5 --- /dev/null +++ b/app/Services/Afia/AfiaService.php @@ -0,0 +1,168 @@ +hasLocalKey() || $this->hasPlatformRelay(); + } + + /** + * @param array $history + * @param array $context + */ + public function chat(string $message, array $history, array $context): string + { + if (! $this->enabled()) { + throw new RuntimeException('Afia is not configured.'); + } + + if ($this->hasLocalKey()) { + return $this->chatLocally($message, $history, $context); + } + + return $this->chatViaPlatform($message, $history, $context); + } + + /** + * @param array $history + * @param array $context + */ + private function chatLocally(string $message, array $history, array $context): string + { + $provider = (string) config('afia.provider', 'openai'); + $model = (string) config('afia.model', 'gpt-4o-mini'); + $apiKey = (string) config('afia.api_key'); + + $messages = [['role' => 'system', 'content' => $this->systemPrompt($context)]]; + foreach (array_slice($history, -8) as $turn) { + $role = ($turn['role'] ?? 'user') === 'assistant' ? 'assistant' : 'user'; + $text = trim((string) ($turn['text'] ?? '')); + if ($text !== '') { + $messages[] = ['role' => $role, 'content' => $text]; + } + } + $messages[] = ['role' => 'user', 'content' => $message]; + + return $provider === 'anthropic' + ? $this->viaAnthropic($model, $apiKey, $messages) + : $this->viaOpenAi($model, $apiKey, $messages); + } + + /** + * @param array $history + * @param array $context + */ + private function chatViaPlatform(string $message, array $history, array $context): string + { + $base = rtrim((string) config('afia.platform_api_url', ''), '/'); + $token = (string) config('afia.platform_api_key', ''); + + $res = Http::withToken($token)->acceptJson()->timeout(50)->post($base.'/afia/chat', [ + 'product' => (string) config('afia.product', 'meet'), + 'message' => $message, + 'history' => $history, + 'system_prompt' => $this->systemPrompt($context), + ]); + + if ($res->status() === 503) { + throw new RuntimeException('Afia is not configured on the platform.'); + } + + if ($res->failed()) { + throw new RuntimeException('Platform Afia relay failed: '.$res->status()); + } + + $reply = trim((string) $res->json('reply', '')); + if ($reply === '') { + throw new RuntimeException('Platform Afia relay returned an empty response.'); + } + + return $reply; + } + + private function hasLocalKey(): bool + { + return (string) config('afia.api_key', '') !== ''; + } + + private function hasPlatformRelay(): bool + { + return rtrim((string) config('afia.platform_api_url', ''), '/') !== '' + && (string) config('afia.platform_api_key', '') !== ''; + } + + private function viaOpenAi(string $model, string $apiKey, array $messages): string + { + $res = Http::withToken($apiKey)->acceptJson()->timeout(45) + ->post('https://api.openai.com/v1/chat/completions', [ + 'model' => $model, + 'temperature' => 0.3, + 'max_tokens' => 600, + 'messages' => $messages, + ]); + + if ($res->failed()) { + throw new RuntimeException('OpenAI request failed: '.$res->status()); + } + + return trim((string) $res->json('choices.0.message.content', '')); + } + + private function viaAnthropic(string $model, string $apiKey, array $messages): string + { + $system = $messages[0]['content'] ?? ''; + $turns = array_values(array_filter($messages, fn ($m) => $m['role'] !== 'system')); + + $res = Http::withHeaders([ + 'x-api-key' => $apiKey, + 'anthropic-version' => '2023-06-01', + ])->acceptJson()->timeout(45)->post('https://api.anthropic.com/v1/messages', [ + 'model' => $model, + 'max_tokens' => 600, + 'system' => $system, + 'messages' => array_map(fn ($m) => ['role' => $m['role'], 'content' => $m['content']], $turns), + ]); + + if ($res->failed()) { + throw new RuntimeException('Anthropic request failed: '.$res->status()); + } + + return trim((string) $res->json('content.0.text', '')); + } + + /** @param array $context */ + private function systemPrompt(array $context): string + { + $ctx = collect($context)->map(fn ($v, $k) => "- {$k}: {$v}")->implode("\n"); + + return << $apps + * @param array $metadata + * + * @return array + */ + public function inviteAppMember( + string $ownerPublicId, + string $email, + array $apps, + array $metadata = [], + string $role = 'member', + ): array { + $response = $this->request('post', '/identity/team/invite', [ + 'owner' => $ownerPublicId, + 'email' => strtolower(trim($email)), + 'apps' => $apps, + 'role' => $role, + 'metadata' => $metadata, + ]); + + return (array) $response->json('data', []); + } + + public function postAuthRedirect(string $userPublicId, string $intendedUrl): string + { + $response = $this->request('get', '/identity/team/post-auth-redirect', [ + 'user' => $userPublicId, + 'redirect' => $intendedUrl, + ]); + + return (string) $response->json('data.url', $intendedUrl); + } + + /** @return list> */ + public function teamAccess(string $userPublicId): array + { + $response = $this->request('get', '/identity/team/access', [ + 'user' => $userPublicId, + ]); + + return (array) $response->json('data', []); + } + + private function request(string $method, string $path, array $query = []): Response + { + $url = rtrim((string) config('identity.api_url'), '/').$path; + $key = config('identity.api_key'); + + if ($url === '' || ! is_string($key) || $key === '') { + throw new RuntimeException('Identity API is not configured.'); + } + + $response = Http::withToken($key) + ->timeout(10) + ->{$method}($url, $query); + + if (! $response->successful()) { + throw new RuntimeException($response->json('message') ?: 'Identity API request failed.'); + } + + return $response; + } +} \ No newline at end of file diff --git a/app/Services/Meet/MeetPermissions.php b/app/Services/Meet/MeetPermissions.php index 4c239d9..035d765 100644 --- a/app/Services/Meet/MeetPermissions.php +++ b/app/Services/Meet/MeetPermissions.php @@ -12,10 +12,10 @@ class MeetPermissions 'admin' => ['*'], 'moderator' => [ 'dashboard.view', 'meetings.view', 'meetings.create', 'meetings.manage', - 'meetings.host', 'chat.moderate', 'reports.view', + 'meetings.host', 'chat.moderate', 'reports.view', 'settings.view', ], 'member' => [ - 'dashboard.view', 'meetings.view', 'meetings.create', 'meetings.host', + 'dashboard.view', 'meetings.view', 'meetings.create', 'meetings.host', 'settings.view', ], 'guest' => [ 'meetings.view', diff --git a/app/Services/Meet/SessionService.php b/app/Services/Meet/SessionService.php index fa3b794..dc748e2 100644 --- a/app/Services/Meet/SessionService.php +++ b/app/Services/Meet/SessionService.php @@ -7,6 +7,7 @@ use App\Models\Room; use App\Models\Session; use App\Models\User; use App\Services\Meet\Media\MediaProviderInterface; +use Illuminate\Http\Request; use Illuminate\Support\Str; class SessionService @@ -135,6 +136,20 @@ class SessionService return $participant; } + public function rememberParticipantSession(Request $request, Participant $participant, ?Session $session = null): void + { + $session ??= $participant->session; + $request->session()->put("meet.participant.{$session->uuid}", $participant->uuid); + } + + public function joinedParticipantForUser(Session $session, User $user): ?Participant + { + return $session->participants() + ->where('user_ref', $user->ownerRef()) + ->whereIn('status', ['invited', 'waiting', 'joined']) + ->first(); + } + public function leaveParticipant(Participant $participant): Participant { $participant->update([ diff --git a/app/Services/Meet/TeamMemberProvisioner.php b/app/Services/Meet/TeamMemberProvisioner.php new file mode 100644 index 0000000..55dc4c0 --- /dev/null +++ b/app/Services/Meet/TeamMemberProvisioner.php @@ -0,0 +1,56 @@ +where('user_ref', strtolower((string) $user->email)) + ->update(['user_ref' => $user->ownerRef()]); + + try { + $access = $this->identity->teamAccess($user->ownerRef()); + } catch (\Throwable) { + return; + } + + foreach ($access as $grant) { + if (($grant['self'] ?? false) || ($grant['full_access'] ?? false)) { + continue; + } + + $apps = (array) ($grant['apps'] ?? []); + if (! in_array('meet', $apps, true)) { + continue; + } + + $appMeta = (array) data_get($grant, 'metadata.meet', []); + $organizationId = (int) ($appMeta['organization_id'] ?? 0); + if ($organizationId <= 0) { + continue; + } + + Member::updateOrCreate( + [ + 'organization_id' => $organizationId, + 'user_ref' => $user->ownerRef(), + ], + [ + 'owner_ref' => (string) ($grant['account'] ?? $user->ownerRef()), + 'role' => (string) ($appMeta['role'] ?? 'member'), + 'branch_id' => $appMeta['branch_id'] ?? null, + ], + ); + } + } +} \ No newline at end of file diff --git a/app/Support/OrganizationBranding.php b/app/Support/OrganizationBranding.php index e4597b1..9b23050 100644 --- a/app/Support/OrganizationBranding.php +++ b/app/Support/OrganizationBranding.php @@ -8,7 +8,7 @@ use Illuminate\Support\Facades\Storage; class OrganizationBranding { - public const DEFAULT_LOGO = 'images/logo/ladillcare-logo.svg'; + public const DEFAULT_LOGO = 'images/logo/ladillmeet-logo.svg'; public static function logoUrl(Organization $organization): string { @@ -38,7 +38,7 @@ class OrganizationBranding { self::deleteStoredLogo($organization); - return $file->store('care/organizations/'.$organization->id, 'public'); + return $file->store('meet/organizations/'.$organization->id, 'public'); } public static function deleteStoredLogo(Organization $organization): void diff --git a/config/afia.php b/config/afia.php new file mode 100644 index 0000000..01fca31 --- /dev/null +++ b/config/afia.php @@ -0,0 +1,11 @@ + env('AFIA_PRODUCT', 'meet'), + 'enabled' => (bool) env('AFIA_ENABLED', true), + 'provider' => env('AFIA_PROVIDER', 'openai'), + 'model' => env('AFIA_MODEL', 'gpt-4o-mini'), + 'api_key' => env('AFIA_API_KEY', env('OPENAI_API_KEY')), + 'platform_api_url' => env('AFIA_PLATFORM_API_URL', env('IDENTITY_API_URL', 'https://ladill.com/api')), + 'platform_api_key' => env('AFIA_PLATFORM_API_KEY', env('IDENTITY_API_KEY_MEET')), +]; diff --git a/resources/views/components/app-layout.blade.php b/resources/views/components/app-layout.blade.php index 89f3fe0..de639dd 100644 --- a/resources/views/components/app-layout.blade.php +++ b/resources/views/components/app-layout.blade.php @@ -56,5 +56,6 @@ ]) @endauth @include('partials.wallet-topup-modal', ['openOnLoad' => (bool) session('topup_required')]) + @include('partials.afia') diff --git a/resources/views/meet/admin/members/create.blade.php b/resources/views/meet/admin/members/create.blade.php index 1bd031b..ae15795 100644 --- a/resources/views/meet/admin/members/create.blade.php +++ b/resources/views/meet/admin/members/create.blade.php @@ -1,16 +1,16 @@
-

Add team member

+

Invite team member

@csrf
- - -

The member's Ladill public_id (UUID).

+ + +

They will receive an email to accept and join your Meet organization.

@@ -32,7 +32,7 @@
- +
diff --git a/resources/views/meet/admin/members/index.blade.php b/resources/views/meet/admin/members/index.blade.php index fb8654c..75d1922 100644 --- a/resources/views/meet/admin/members/index.blade.php +++ b/resources/views/meet/admin/members/index.blade.php @@ -7,12 +7,17 @@
- + @foreach ($members as $member) + @php + $display = str_contains($member->user_ref, '@') + ? $member->user_ref + : (\App\Models\User::where('public_id', $member->user_ref)->value('email') ?? $member->user_ref); + @endphp - +
User IDRoleBranch
MemberRoleBranch
{{ $member->user_ref }}{{ $display }} {{ $roles[$member->role] ?? $member->role }} {{ $member->branch?->name ?? 'All branches' }} diff --git a/resources/views/meet/join/show.blade.php b/resources/views/meet/join/show.blade.php index 7709969..161cb87 100644 --- a/resources/views/meet/join/show.blade.php +++ b/resources/views/meet/join/show.blade.php @@ -13,6 +13,12 @@

{{ $room->title }}

Ready to join?

+ @if ($errors->any()) +
+ {{ $errors->first() }} +
+ @endif +
@csrf @guest diff --git a/resources/views/meet/rooms/show.blade.php b/resources/views/meet/rooms/show.blade.php index 29fe463..4c293e0 100644 --- a/resources/views/meet/rooms/show.blade.php +++ b/resources/views/meet/rooms/show.blade.php @@ -13,6 +13,12 @@ @endif + @if ($errors->any()) +
+ {{ $errors->first() }} +
+ @endif +

Join link

diff --git a/resources/views/meet/settings/edit.blade.php b/resources/views/meet/settings/edit.blade.php new file mode 100644 index 0000000..4198959 --- /dev/null +++ b/resources/views/meet/settings/edit.blade.php @@ -0,0 +1,56 @@ + +
+

Organization settings

+ + + @csrf @method('PUT') + +
+ + +
+ +
+ + +
+ +
+ + +
+ + @if ($canManage) +
+ + + @if (\App\Support\OrganizationBranding::hasCustomLogo($organization)) + + @endif +
+ + @endif + + + @if ($isAdmin ?? false) + + @endif + +

{{ $branchCount }} branch(es) configured.

+
+
diff --git a/resources/views/partials/afia.blade.php b/resources/views/partials/afia.blade.php new file mode 100644 index 0000000..ff0bbc9 --- /dev/null +++ b/resources/views/partials/afia.blade.php @@ -0,0 +1,99 @@ +@php + $afiaGreeting = "Hi, I'm Afia 👋 Ask me about meetings, rooms, recordings, channels, team members, or settings…"; + $afiaSuggestions = [ + 'How do I schedule a meeting?', + 'How do I start an instant meeting?', + 'Where are my recordings?', + 'How do I invite team members?', + ]; +@endphp +{{-- Afia — Ladill AI assistant slide-over. Opened via $dispatch('afia-open'). --}} +
+
+ +
+
+
+ + + + +
+

Afia

+

Meet assistant

+
+
+
+ History + +
+
+ +
+
+ +
+
+
+ + + +
+
+
+
+ +
+ +
+
+ +
+
+ + +
+

Afia can make mistakes — verify important details.

+
+
+
diff --git a/resources/views/partials/sidebar.blade.php b/resources/views/partials/sidebar.blade.php index 30407fd..5e9fbab 100644 --- a/resources/views/partials/sidebar.blade.php +++ b/resources/views/partials/sidebar.blade.php @@ -46,7 +46,7 @@ 'icon' => '']; } - $settingsActive = false; + $settingsActive = request()->routeIs('meet.settings*'); @endphp
@endif + +
+ @if ($permissions->can($member, 'settings.view')) + + + + + Settings + + @endif +
diff --git a/routes/web.php b/routes/web.php index 317bcfb..af79d48 100644 --- a/routes/web.php +++ b/routes/web.php @@ -2,6 +2,7 @@ use App\Http\Controllers\Auth\SsoLoginController; use App\Http\Controllers\Meet\AdminController; +use App\Http\Controllers\Meet\AiController; use App\Http\Controllers\Meet\CalendarController; use App\Http\Controllers\Meet\ChannelController; use App\Http\Controllers\Meet\ContactGroupController; @@ -57,6 +58,8 @@ Route::middleware(['auth', 'platform.session'])->group(function () { Route::post('/onboarding', [OnboardingController::class, 'store'])->name('meet.onboarding.store'); Route::middleware(['meet.setup'])->group(function () { + Route::post('/ai/chat', [AiController::class, 'chat'])->middleware('throttle:30,1')->name('meet.ai.chat'); + Route::get('/dashboard', [DashboardController::class, 'index'])->name('meet.dashboard'); Route::get('/meetings/instant', [RoomController::class, 'instant'])->name('meet.instant'); @@ -125,6 +128,8 @@ Route::middleware(['auth', 'platform.session'])->group(function () { Route::get('/settings/security', [SettingsController::class, 'security'])->name('meet.settings.security'); Route::put('/settings/security', [SettingsController::class, 'updateSecurity'])->name('meet.settings.security.update'); + Route::get('/settings', [SettingsController::class, 'edit'])->name('meet.settings'); + Route::put('/settings', [SettingsController::class, 'update'])->name('meet.settings.update'); Route::get('/room/{session}', [MeetingRoomController::class, 'show'])->name('meet.room'); Route::post('/room/{session}/end', [MeetingRoomController::class, 'end'])->name('meet.room.end'); diff --git a/tests/Feature/MeetWebTest.php b/tests/Feature/MeetWebTest.php index 3c36b9f..a58734f 100644 --- a/tests/Feature/MeetWebTest.php +++ b/tests/Feature/MeetWebTest.php @@ -78,6 +78,75 @@ class MeetWebTest extends TestCase ]); } + public function test_instant_meeting_opens_room(): void + { + $response = $this->actingAs($this->user) + ->get('/meetings/instant'); + + $response->assertRedirect(); + + $session = Session::where('owner_ref', $this->user->public_id)->latest()->first(); + $this->assertNotNull($session); + $this->assertTrue($session->isLive()); + + $this->actingAs($this->user) + ->get('/room/'.$session->uuid) + ->assertOk() + ->assertSee($session->room->title); + } + + public function test_host_can_start_scheduled_meeting_into_room(): void + { + $room = $this->createRoom(); + + $this->actingAs($this->user) + ->post('/meetings/'.$room->uuid.'/start') + ->assertRedirect(); + + $session = $room->fresh()->activeSession(); + $this->assertNotNull($session); + + $this->actingAs($this->user) + ->get('/room/'.$session->uuid) + ->assertOk(); + } + + public function test_guest_cannot_join_before_host_starts(): void + { + $room = $this->createRoom([ + 'settings' => array_merge(config('meet.default_settings'), [ + 'join_before_host' => false, + ]), + ]); + + $this->post('/r/'.$room->uuid.'/enter', [ + 'display_name' => 'Guest User', + ]) + ->assertRedirect() + ->assertSessionHasErrors('join'); + } + + public function test_authenticated_user_can_join_live_meeting_without_display_name(): void + { + $room = $this->createRoom(); + $session = Session::create([ + 'owner_ref' => $this->user->public_id, + 'room_id' => $room->id, + 'media_room_name' => 'room-'.$room->uuid, + 'status' => 'live', + 'started_at' => now(), + ]); + $room->update(['status' => 'live']); + + $this->actingAs($this->user) + ->post('/r/'.$room->uuid.'/enter') + ->assertRedirect(route('meet.room', $session)); + + $this->actingAs($this->user) + ->get('/room/'.$session->uuid) + ->assertOk(); + } + public function test_room_has_join_url(): void { $room = Room::create([