Sync email team invites, meeting room fixes, Afia, and org settings.
Deploy Ladill Meet / deploy (push) Successful in 47s
Deploy Ladill Meet / deploy (push) Successful in 47s
Publish monorepo meet changes: identity API invites, join/room session fixes, Afia assistant panel, settings nav, and SSO team provisioning. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Meet;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Http\Controllers\Meet\Concerns\ScopesToAccount;
|
||||
use App\Models\Member;
|
||||
use App\Models\Recording;
|
||||
use App\Models\Room;
|
||||
use App\Services\Afia\AfiaService;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
|
||||
class AiController extends Controller
|
||||
{
|
||||
use ScopesToAccount;
|
||||
|
||||
public function chat(Request $request, AfiaService $afia): JsonResponse
|
||||
{
|
||||
$validated = $request->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<string, mixed> */
|
||||
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(),
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -0,0 +1,168 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services\Afia;
|
||||
|
||||
use Illuminate\Support\Facades\Http;
|
||||
use RuntimeException;
|
||||
|
||||
class AfiaService
|
||||
{
|
||||
public function enabled(): bool
|
||||
{
|
||||
if (! (bool) config('afia.enabled', true)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return $this->hasLocalKey() || $this->hasPlatformRelay();
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<int, array{role: string, text: string}> $history
|
||||
* @param array<string, mixed> $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<int, array{role: string, text: string}> $history
|
||||
* @param array<string, mixed> $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<int, array{role: string, text: string}> $history
|
||||
* @param array<string, mixed> $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<string, mixed> $context */
|
||||
private function systemPrompt(array $context): string
|
||||
{
|
||||
$ctx = collect($context)->map(fn ($v, $k) => "- {$k}: {$v}")->implode("\n");
|
||||
|
||||
return <<<PROMPT
|
||||
You are Afia, the assistant inside Ladill Meet (meet.ladill.com).
|
||||
Help staff schedule and run video meetings, manage rooms, recordings, channels, and team access. Be concise and actionable.
|
||||
|
||||
Where things live:
|
||||
- Dashboard: upcoming meetings and quick actions.
|
||||
- Meetings: create instant, scheduled, personal, or webinar rooms; send invitations.
|
||||
- Recordings & summaries: review past sessions and AI summaries.
|
||||
- Channels & messages: team chat inside the organization.
|
||||
- Admin: security policy, calendar sync, webhooks, contacts, and team members.
|
||||
- Settings (bottom of sidebar): organization name, timezone, and logo.
|
||||
|
||||
Rules:
|
||||
- Only answer questions about Ladill Meet.
|
||||
- Never invent meeting counts or participant names — use the context below or say where to check.
|
||||
|
||||
Current user context:
|
||||
{$ctx}
|
||||
PROMPT;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services\Identity;
|
||||
|
||||
use Illuminate\Http\Client\Response;
|
||||
use Illuminate\Support\Facades\Http;
|
||||
use RuntimeException;
|
||||
|
||||
class IdentityTeamClient
|
||||
{
|
||||
/**
|
||||
* @param list<string> $apps
|
||||
* @param array<string, mixed> $metadata
|
||||
*
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
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<array<string, mixed>> */
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -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',
|
||||
|
||||
@@ -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([
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services\Meet;
|
||||
|
||||
use App\Models\Member;
|
||||
use App\Models\User;
|
||||
use App\Services\Identity\IdentityTeamClient;
|
||||
|
||||
class TeamMemberProvisioner
|
||||
{
|
||||
public function __construct(
|
||||
protected IdentityTeamClient $identity,
|
||||
) {}
|
||||
|
||||
public function sync(User $user): void
|
||||
{
|
||||
Member::query()
|
||||
->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,
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
<?php
|
||||
|
||||
return [
|
||||
'product' => 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')),
|
||||
];
|
||||
@@ -56,5 +56,6 @@
|
||||
])
|
||||
@endauth
|
||||
@include('partials.wallet-topup-modal', ['openOnLoad' => (bool) session('topup_required')])
|
||||
@include('partials.afia')
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -1,16 +1,16 @@
|
||||
<x-app-layout title="Add member">
|
||||
<div class="mx-auto max-w-lg">
|
||||
<h1 class="text-xl font-semibold text-slate-900">Add team member</h1>
|
||||
<h1 class="text-xl font-semibold text-slate-900">Invite team member</h1>
|
||||
|
||||
<form method="POST" action="{{ route('meet.members.store') }}" class="mt-6 space-y-4 rounded-2xl border border-slate-200 bg-white p-6">
|
||||
@csrf
|
||||
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-slate-700">User public ID</label>
|
||||
<input type="text" name="user_ref" value="{{ old('user_ref') }}" required
|
||||
class="mt-1 w-full rounded-lg border-slate-300 font-mono text-sm"
|
||||
placeholder="UUID from Ladill account">
|
||||
<p class="mt-1 text-xs text-slate-500">The member's Ladill public_id (UUID).</p>
|
||||
<label class="block text-sm font-medium text-slate-700">Email address</label>
|
||||
<input type="email" name="email" value="{{ old('email') }}" required
|
||||
class="mt-1 w-full rounded-lg border-slate-300 text-sm"
|
||||
placeholder="colleague@company.com">
|
||||
<p class="mt-1 text-xs text-slate-500">They will receive an email to accept and join your Meet organization.</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
@@ -32,7 +32,7 @@
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<button type="submit" class="btn-primary">Save member</button>
|
||||
<button type="submit" class="btn-primary">Send invitation</button>
|
||||
</form>
|
||||
</div>
|
||||
</x-app-layout>
|
||||
|
||||
@@ -7,12 +7,17 @@
|
||||
<div class="mt-4 overflow-hidden rounded-2xl border border-slate-200 bg-white">
|
||||
<table class="min-w-full text-sm">
|
||||
<thead class="bg-slate-50 text-left text-xs uppercase text-slate-500">
|
||||
<tr><th class="px-4 py-3">User ID</th><th class="px-4 py-3">Role</th><th class="px-4 py-3">Branch</th><th class="px-4 py-3"></th></tr>
|
||||
<tr><th class="px-4 py-3">Member</th><th class="px-4 py-3">Role</th><th class="px-4 py-3">Branch</th><th class="px-4 py-3"></th></tr>
|
||||
</thead>
|
||||
<tbody class="divide-y divide-slate-50">
|
||||
@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
|
||||
<tr>
|
||||
<td class="px-4 py-3 font-mono text-xs">{{ $member->user_ref }}</td>
|
||||
<td class="px-4 py-3 text-sm">{{ $display }}</td>
|
||||
<td class="px-4 py-3">{{ $roles[$member->role] ?? $member->role }}</td>
|
||||
<td class="px-4 py-3">{{ $member->branch?->name ?? 'All branches' }}</td>
|
||||
<td class="px-4 py-3 text-right">
|
||||
|
||||
@@ -13,6 +13,12 @@
|
||||
<h1 class="text-xl font-semibold">{{ $room->title }}</h1>
|
||||
<p class="mt-2 text-sm text-slate-400">Ready to join?</p>
|
||||
|
||||
@if ($errors->any())
|
||||
<div class="mt-4 rounded-lg border border-red-500/40 bg-red-500/10 px-4 py-3 text-sm text-red-200">
|
||||
{{ $errors->first() }}
|
||||
</div>
|
||||
@endif
|
||||
|
||||
<form method="POST" action="{{ route('meet.join.enter', $room) }}" class="mt-6 space-y-4">
|
||||
@csrf
|
||||
@guest
|
||||
|
||||
@@ -13,6 +13,12 @@
|
||||
@endif
|
||||
</div>
|
||||
|
||||
@if ($errors->any())
|
||||
<div class="mt-4 rounded-lg border border-red-200 bg-red-50 px-4 py-3 text-sm text-red-700">
|
||||
{{ $errors->first() }}
|
||||
</div>
|
||||
@endif
|
||||
|
||||
<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">
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
<x-app-layout title="Settings">
|
||||
<div class="mx-auto max-w-2xl">
|
||||
<h1 class="text-xl font-semibold text-slate-900">Organization settings</h1>
|
||||
|
||||
<form method="POST" action="{{ route('meet.settings.update') }}" enctype="multipart/form-data" class="mt-6 space-y-5 rounded-2xl border border-slate-200 bg-white p-6">
|
||||
@csrf @method('PUT')
|
||||
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-slate-700">Organization name</label>
|
||||
<input type="text" name="name" value="{{ old('name', $organization->name) }}" @disabled(! $canManage) required class="mt-1 w-full rounded-lg border-slate-300 text-sm">
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-slate-700">Organization type</label>
|
||||
<select name="org_type" @disabled(! $canManage) class="mt-1 w-full rounded-lg border-slate-300 text-sm">
|
||||
@foreach ($orgTypes as $value => $label)
|
||||
<option value="{{ $value }}" @selected(old('org_type', data_get($organization->settings, 'org_type', 'business')) === $value)>{{ $label }}</option>
|
||||
@endforeach
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-slate-700">Timezone</label>
|
||||
<select name="timezone" @disabled(! $canManage) class="mt-1 w-full rounded-lg border-slate-300 text-sm">
|
||||
@foreach (timezone_identifiers_list() as $tz)
|
||||
<option value="{{ $tz }}" @selected(old('timezone', $organization->timezone) === $tz)>{{ $tz }}</option>
|
||||
@endforeach
|
||||
</select>
|
||||
</div>
|
||||
|
||||
@if ($canManage)
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-slate-700">Logo</label>
|
||||
<input type="file" name="logo" accept="image/png,image/jpeg,image/webp,image/svg+xml" class="mt-2 block w-full text-sm">
|
||||
@if (\App\Support\OrganizationBranding::hasCustomLogo($organization))
|
||||
<label class="mt-2 flex items-center gap-2 text-sm"><input type="checkbox" name="remove_logo" value="1"> Remove current logo</label>
|
||||
@endif
|
||||
</div>
|
||||
<button type="submit" class="btn-primary">Save settings</button>
|
||||
@endif
|
||||
</form>
|
||||
|
||||
@if ($isAdmin ?? false)
|
||||
<div class="mt-6 rounded-2xl border border-slate-200 bg-white p-6">
|
||||
<h2 class="text-sm font-semibold text-slate-900">Advanced</h2>
|
||||
<ul class="mt-3 space-y-2 text-sm">
|
||||
<li><a href="{{ route('meet.settings.security') }}" class="text-indigo-600 hover:text-indigo-800">Security policy</a></li>
|
||||
<li><a href="{{ route('meet.settings.calendar') }}" class="text-indigo-600 hover:text-indigo-800">Calendar connections</a></li>
|
||||
<li><a href="{{ route('meet.settings.webhooks') }}" class="text-indigo-600 hover:text-indigo-800">Webhooks</a></li>
|
||||
</ul>
|
||||
</div>
|
||||
@endif
|
||||
|
||||
<p class="mt-4 text-sm text-slate-500">{{ $branchCount }} branch(es) configured.</p>
|
||||
</div>
|
||||
</x-app-layout>
|
||||
@@ -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'). --}}
|
||||
<div x-data="afia({
|
||||
chatUrl: '{{ route('meet.ai.chat') }}',
|
||||
csrf: '{{ csrf_token() }}',
|
||||
greeting: @js($afiaGreeting),
|
||||
suggestions: @js($afiaSuggestions),
|
||||
})" @afia-open.window="open = true; $nextTick(() => $refs.input && $refs.input.focus())">
|
||||
<div x-show="open" x-cloak @click="close()" class="fixed inset-0 z-50 bg-slate-900/20"></div>
|
||||
|
||||
<section x-show="open" x-cloak
|
||||
x-transition:enter="transition transform ease-out duration-200"
|
||||
x-transition:enter-start="translate-x-full" x-transition:enter-end="translate-x-0"
|
||||
x-transition:leave="transition transform ease-in duration-150"
|
||||
x-transition:leave-start="translate-x-0" x-transition:leave-end="translate-x-full"
|
||||
class="fixed inset-y-0 right-0 z-50 flex w-full max-w-md flex-col bg-white shadow-2xl">
|
||||
<div class="flex items-center justify-between border-b border-slate-100 px-5 py-3.5">
|
||||
<div class="flex items-center gap-2.5">
|
||||
<span class="relative flex h-8 w-8 shrink-0 items-center justify-center">
|
||||
<svg viewBox="0 0 36 36" class="h-8 w-8" aria-hidden="true">
|
||||
<defs>
|
||||
<radialGradient id="afia-orb-meet" cx="40%" cy="35%" r="60%">
|
||||
<stop offset="0%" stop-color="#a5b4fc"/>
|
||||
<stop offset="50%" stop-color="#6366f1"/>
|
||||
<stop offset="100%" stop-color="#3730a3"/>
|
||||
</radialGradient>
|
||||
</defs>
|
||||
<circle cx="18" cy="18" r="14" fill="url(#afia-orb-meet)">
|
||||
<animate attributeName="r" values="14;14.8;14" dur="3s" repeatCount="indefinite"/>
|
||||
</circle>
|
||||
</svg>
|
||||
<span class="absolute -bottom-0.5 -right-0.5 h-2.5 w-2.5 rounded-full border-2 border-white bg-emerald-400"></span>
|
||||
</span>
|
||||
<div class="leading-tight">
|
||||
<p class="text-sm font-semibold text-slate-900">Afia</p>
|
||||
<p class="text-[11px] text-slate-400">Meet assistant</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex items-center gap-1">
|
||||
<a href="{{ ladill_account_url('ai') }}"
|
||||
class="rounded-lg px-2 py-1 text-[11px] font-medium text-slate-500 hover:bg-slate-100 hover:text-slate-700"
|
||||
title="AI usage history on your Ladill account">History</a>
|
||||
<button @click="close()" class="rounded-lg p-1.5 text-slate-400 hover:bg-slate-100 hover:text-slate-600">
|
||||
<svg class="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" d="M6 18 18 6M6 6l12 12"/></svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex-1 overflow-y-auto px-4 py-4" x-ref="scroll">
|
||||
<div class="space-y-3">
|
||||
<template x-for="(m, i) in messages" :key="i">
|
||||
<div class="flex" :class="m.role === 'user' ? 'justify-end' : 'justify-start'">
|
||||
<div class="max-w-[85%] whitespace-pre-line rounded-2xl px-3.5 py-2.5 text-[13px] leading-relaxed"
|
||||
:class="m.role === 'user' ? 'bg-indigo-600 text-white rounded-br-md' : 'bg-slate-100 text-slate-700 rounded-bl-md'"
|
||||
x-html="m.text.replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>').replace(/\*\*([^*]+?)\*\*/g,'<strong>$1</strong>')"></div>
|
||||
</div>
|
||||
</template>
|
||||
<div x-show="loading" class="flex justify-start">
|
||||
<div class="rounded-2xl rounded-bl-md bg-slate-100 px-4 py-3">
|
||||
<div class="flex gap-1">
|
||||
<span class="h-1.5 w-1.5 animate-bounce rounded-full bg-slate-400" style="animation-delay:0ms"></span>
|
||||
<span class="h-1.5 w-1.5 animate-bounce rounded-full bg-slate-400" style="animation-delay:150ms"></span>
|
||||
<span class="h-1.5 w-1.5 animate-bounce rounded-full bg-slate-400" style="animation-delay:300ms"></span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div x-show="messages.length <= 1 && suggestions.length" class="mt-4 space-y-2">
|
||||
<template x-for="s in suggestions" :key="s">
|
||||
<button @click="useSuggestion(s)" :disabled="loading"
|
||||
class="flex w-full items-center gap-2 rounded-xl border border-slate-200 bg-white px-3.5 py-2.5 text-left text-[13px] font-medium text-slate-700 transition hover:border-indigo-200 hover:bg-indigo-50 disabled:opacity-50">
|
||||
<span class="text-indigo-400">→</span><span x-text="s"></span>
|
||||
</button>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="border-t border-slate-100 px-4 pb-4 pt-3">
|
||||
<form @submit.prevent="send()" class="flex items-end gap-2">
|
||||
<input type="text" x-ref="input" x-model="input" :disabled="loading" placeholder="Message Afia…"
|
||||
class="flex-1 rounded-xl border border-slate-200 bg-slate-50 px-4 py-2.5 text-sm text-slate-700 placeholder-slate-400 focus:border-indigo-300 focus:bg-white focus:outline-none focus:ring-2 focus:ring-indigo-100">
|
||||
<button type="submit" :disabled="loading || !input.trim()"
|
||||
class="btn-fab shrink-0 disabled:opacity-40">
|
||||
<svg class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke-width="2.5" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" d="M4.5 10.5 12 3m0 0 7.5 7.5M12 3v18"/></svg>
|
||||
</button>
|
||||
</form>
|
||||
<p class="mt-2 text-center text-[10px] text-slate-400">Afia can make mistakes — verify important details.</p>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
@@ -46,7 +46,7 @@
|
||||
'icon' => '<path stroke-linecap="round" stroke-linejoin="round" d="M18 18.72a9.094 9.094 0 0 0 3.741-.479 3 3 0 0 0-4.682-2.72m.94 3.198.001.031c0 .225-.012.447-.037.666A11.944 11.944 0 0 1 12 21c-2.17 0-4.207-.576-5.963-1.584A6.062 6.062 0 0 1 6 18.719m12 0a5.971 5.971 0 0 0-.941-3.197m0 0A5.995 5.995 0 0 0 12 12.75a5.995 5.995 0 0 0-5.058 2.772m0 0a3 3 0 0 0-4.681 2.72 8.986 8.986 0 0 0 3.74.477m.94-3.197a5.971 5.971 0 0 0-.94 3.197M15 6.75a3 3 0 1 1-6 0 3 3 0 0 1 6 0Zm6 3a2.25 2.25 0 1 1-4.5 0 2.25 2.25 0 0 1 4.5 0Zm-13.5 0a2.25 2.25 0 1 1-4.5 0 2.25 2.25 0 0 1 4.5 0Z" />'];
|
||||
}
|
||||
|
||||
$settingsActive = false;
|
||||
$settingsActive = request()->routeIs('meet.settings*');
|
||||
@endphp
|
||||
|
||||
<nav class="flex-1 space-y-0.5 overflow-y-auto px-3 py-4">
|
||||
@@ -69,4 +69,16 @@
|
||||
</div>
|
||||
@endif
|
||||
</nav>
|
||||
|
||||
<div class="shrink-0 border-t border-slate-200 px-3 py-3">
|
||||
@if ($permissions->can($member, 'settings.view'))
|
||||
<a href="{{ route('meet.settings') }}"
|
||||
class="group flex items-center gap-3 rounded-lg px-3 py-2 text-[13px] transition {{ $settingsActive ? 'bg-indigo-50 text-indigo-700 font-semibold' : 'text-slate-600 hover:bg-slate-50 hover:text-slate-900' }}">
|
||||
<svg class="h-[18px] w-[18px] shrink-0 {{ $settingsActive ? 'text-indigo-600' : 'text-slate-400' }}" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M9.594 3.94c.09-.542.56-.94 1.11-.94h2.593c.55 0 1.02.398 1.11.94l.213 1.281c.063.374.313.686.645.87.074.04.147.083.22.127.324.196.72.257 1.075.124l1.217-.456a1.125 1.125 0 0 1 1.37.49l1.296 2.247a1.125 1.125 0 0 1-.26 1.431l-1.003.827c-.293.241-.438.613-.43.992a7.723 7.723 0 0 1 0 .255c-.008.378.137.75.43.991l1.004.827c.424.35.534.955.26 1.43l-1.298 2.247a1.125 1.125 0 0 1-1.369.491l-1.217-.456c-.355-.133-.75-.072-1.076.124a6.47 6.47 0 0 1-.22.128c-.331.183-.581.495-.644.869l-.213 1.281c-.09.543-.56.94-1.11.94h-2.594c-.55 0-1.019-.398-1.11-.94l-.213-1.281c-.062-.374-.312-.686-.644-.87a6.52 6.52 0 0 1-.22-.127c-.325-.196-.72-.257-1.076-.124l-1.217.456a1.125 1.125 0 0 1-1.369-.49l-1.297-2.247a1.125 1.125 0 0 1 .26-1.431l1.004-.827c.292-.24.437-.613.43-.991a6.932 6.932 0 0 1 0-.255c.007-.38-.138-.751-.43-.992l-1.004-.827a1.125 1.125 0 0 1-.26-1.43l1.297-2.247a1.125 1.125 0 0 1 1.37-.491l1.216.456c.356.133.751.072 1.076-.124.072-.044.146-.086.22-.128.332-.183.582-.495.644-.869l.214-1.281Z" /><path stroke-linecap="round" stroke-linejoin="round" d="M15 12a3 3 0 1 1-6 0 3 3 0 0 1 6 0Z" />
|
||||
</svg>
|
||||
<span>Settings</span>
|
||||
</a>
|
||||
@endif
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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([
|
||||
|
||||
Reference in New Issue
Block a user