Sync email team invites, meeting room fixes, Afia, and org settings.
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:
isaacclad
2026-07-01 06:45:35 +00:00
co-authored by Cursor
parent 3348d418b8
commit a9d3a8bd64
23 changed files with 829 additions and 35 deletions
@@ -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(),
];
}
}
+26 -8
View File
@@ -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);
}
+25 -5
View File
@@ -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
+15 -1
View File
@@ -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');