Initial Ladill Meet release.
Deploy Ladill Meet / deploy (push) Failing after 7s

Phases 0–18: core meetings, webinar, breakouts, team chat, live streaming, town hall, billing, and Ladill Mail calendar wiring.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
isaacclad
2026-06-30 23:35:29 +00:00
co-authored by Cursor
commit 965fb992e9
319 changed files with 30322 additions and 0 deletions
@@ -0,0 +1,35 @@
<?php
namespace App\Http\Controllers\Meet;
use App\Http\Controllers\Controller;
use App\Http\Controllers\Meet\Concerns\ScopesToAccount;
use App\Models\Organization;
use App\Services\Meet\LicenseService;
use App\Services\Meet\OrganizationResolver;
use App\Services\Meet\UsageMeteringService;
use Illuminate\Http\Request;
use Illuminate\View\View;
class AdminController extends Controller
{
use ScopesToAccount;
public function __construct(
protected UsageMeteringService $metering,
protected LicenseService $licenses,
) {}
public function usage(Request $request): View
{
$this->authorizeAbility($request, 'meetings.manage');
$organization = $this->organization($request);
$branchId = app(OrganizationResolver::class)->branchScope($this->member($request));
$summary = $this->metering->usageSummary($organization, $branchId);
$quotas = $this->licenses->quotas($organization);
$tier = $organization->license_tier ?? 'standard';
return view('meet.admin.usage', compact('organization', 'summary', 'quotas', 'tier'));
}
}
@@ -0,0 +1,115 @@
<?php
namespace App\Http\Controllers\Meet;
use App\Http\Controllers\Controller;
use App\Http\Controllers\Meet\Concerns\ScopesToAccount;
use App\Services\Meet\AvailabilityService;
use App\Services\Meet\CalendarService;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Carbon;
use Illuminate\View\View;
class CalendarController extends Controller
{
use ScopesToAccount;
public function __construct(
protected CalendarService $calendar,
protected AvailabilityService $availability,
) {}
public function settings(Request $request): View
{
$this->authorizeAbility($request, 'meetings.manage');
$organization = $this->organization($request);
$user = $request->user();
return view('meet.settings.calendar', [
'organization' => $organization,
'googleConnected' => (bool) $this->calendar->connectionFor($user, 'google'),
'microsoftConnected' => (bool) $this->calendar->connectionFor($user, 'microsoft'),
'mailConnected' => (bool) $this->calendar->connectionFor($user, 'mail'),
'mailConfigured' => filled(config('meet.calendar.mail.api_key')),
'googleAuthUrl' => $this->calendar->oauthRedirectUrl('google'),
'microsoftAuthUrl' => $this->calendar->oauthRedirectUrl('microsoft'),
]);
}
public function connectMail(Request $request): RedirectResponse
{
$this->authorizeAbility($request, 'meetings.manage');
abort_unless(config('meet.calendar.mail.api_key'), 503, 'Ladill Mail calendar is not configured.');
$validated = $request->validate([
'mailbox_email' => ['nullable', 'email', 'max:255'],
]);
$this->calendar->connectMail(
$request->user(),
$this->organization($request)->id,
$validated['mailbox_email'] ?? null,
);
return redirect()->route('meet.settings.calendar')
->with('success', 'Ladill Mail calendar connected.');
}
public function connect(Request $request, string $provider): RedirectResponse
{
$this->authorizeAbility($request, 'meetings.manage');
abort_unless(in_array($provider, ['google', 'microsoft'], true), 404);
$url = $this->calendar->oauthRedirectUrl($provider);
abort_unless($url, 503, 'Calendar integration is not configured.');
$request->session()->put('meet.calendar.provider', $provider);
return redirect()->away($url);
}
public function callback(Request $request): RedirectResponse
{
$this->authorizeAbility($request, 'meetings.manage');
$provider = $request->session()->pull('meet.calendar.provider', 'google');
$code = (string) $request->query('code');
abort_unless($code !== '', 422, 'Authorization failed.');
$connection = $this->calendar->handleOAuthCallback(
$provider,
$code,
$request->user(),
$this->organization($request)->id,
);
return redirect()->route('meet.settings.calendar')
->with($connection ? 'success' : 'error', $connection
? ucfirst($provider).' Calendar connected.'
: 'Could not connect calendar.');
}
public function availability(Request $request): JsonResponse
{
$this->authorizeAbility($request, 'meetings.create');
$validated = $request->validate([
'scheduled_at' => ['required', 'date'],
'duration_minutes' => ['nullable', 'integer', 'min:15', 'max:480'],
]);
$start = Carbon::parse($validated['scheduled_at']);
$duration = (int) ($validated['duration_minutes'] ?? 60);
$result = $this->availability->checkHostAvailability(
$request->user()->ownerRef(),
$this->organization($request)->id,
$start,
$duration,
);
return response()->json($result);
}
}
@@ -0,0 +1,130 @@
<?php
namespace App\Http\Controllers\Meet;
use App\Http\Controllers\Controller;
use App\Http\Controllers\Meet\Concerns\ScopesToAccount;
use App\Models\Channel;
use App\Models\ChannelMessage;
use App\Services\Meet\ChannelService;
use App\Services\Meet\WorkspaceSearchService;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\View\View;
class ChannelController extends Controller
{
use ScopesToAccount;
public function __construct(
protected ChannelService $channels,
protected WorkspaceSearchService $search,
) {}
public function index(Request $request): View
{
$this->authorizeAbility($request, 'meetings.view');
$organization = $this->organization($request);
$owner = $this->ownerRef($request);
$query = Channel::owned($owner)->where('organization_id', $organization->id);
$this->scopeToBranch($request, $query);
$channels = $query->orderBy('name')->get();
return view('meet.channels.index', compact('channels', 'organization'));
}
public function create(Request $request): View
{
$this->authorizeAbility($request, 'meetings.manage');
return view('meet.channels.create', [
'organization' => $this->organization($request),
]);
}
public function store(Request $request): RedirectResponse
{
$this->authorizeAbility($request, 'meetings.manage');
$validated = $request->validate([
'name' => ['required', 'string', 'max:120'],
'visibility' => ['required', 'in:public,private'],
'description' => ['nullable', 'string', 'max:2000'],
]);
$channel = $this->channels->create(
$request->user(),
$this->organization($request),
$validated,
);
return redirect()->route('meet.channels.show', $channel)->with('success', 'Channel created.');
}
public function show(Request $request, Channel $channel): View
{
$this->authorizeAbility($request, 'meetings.view');
$this->authorizeOwner($request, $channel);
abort_unless($this->channels->canAccess($channel, $this->ownerRef($request)), 403);
$messages = $channel->messages()
->whereNull('parent_id')
->with('replies')
->orderBy('created_at')
->paginate(50);
return view('meet.channels.show', compact('channel', 'messages'));
}
public function postMessage(Request $request, Channel $channel): RedirectResponse|JsonResponse
{
$this->authorizeAbility($request, 'meetings.view');
$this->authorizeOwner($request, $channel);
$validated = $request->validate([
'body' => ['required', 'string', 'max:5000'],
'parent_id' => ['nullable', 'integer', 'exists:meet_channel_messages,id'],
]);
$message = $this->channels->postMessage(
$channel,
$request->user(),
$validated['body'],
$validated['parent_id'] ?? null,
);
if ($request->expectsJson()) {
return response()->json(['message' => $message]);
}
return back()->with('success', 'Message posted.');
}
public function react(Request $request, Channel $channel, ChannelMessage $message): JsonResponse
{
$this->authorizeOwner($request, $channel);
abort_unless($message->channel_id === $channel->id, 404);
$validated = $request->validate(['emoji' => ['required', 'string', 'max:16']]);
return response()->json([
'message' => $this->channels->react($message, $validated['emoji'], $this->ownerRef($request)),
]);
}
public function search(Request $request): JsonResponse
{
$this->authorizeAbility($request, 'meetings.view');
$validated = $request->validate(['q' => ['required', 'string', 'min:2', 'max:100']]);
return response()->json(
$this->search->search(
$this->organization($request),
$this->ownerRef($request),
$validated['q'],
),
);
}
}
@@ -0,0 +1,59 @@
<?php
namespace App\Http\Controllers\Meet\Concerns;
use App\Models\Member;
use App\Models\Organization;
use App\Services\Meet\MeetPermissions;
use App\Services\Meet\OrganizationResolver;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Http\Request;
trait ScopesToAccount
{
protected function ownerRef(Request $request): string
{
return (string) $request->user()->public_id;
}
protected function organization(Request $request): Organization
{
$organization = $request->attributes->get('meet.organization')
?? app(OrganizationResolver::class)->resolveForUser($request->user());
abort_unless($organization, 404);
return $organization;
}
protected function member(Request $request): ?Member
{
return $request->attributes->get('meet.member')
?? app(OrganizationResolver::class)->memberFor($request->user(), $this->organization($request));
}
protected function authorizeAbility(Request $request, string $ability): void
{
abort_unless(
app(MeetPermissions::class)->can($this->member($request), $ability),
403,
);
}
protected function authorizeOwner(Request $request, Model $model): void
{
abort_unless($model->getAttribute('owner_ref') === $this->ownerRef($request), 404);
}
protected function scopeToBranch(Request $request, Builder $query, string $column = 'branch_id'): Builder
{
$branchId = app(OrganizationResolver::class)->branchScope($this->member($request));
if ($branchId !== null) {
$query->where($column, $branchId);
}
return $query;
}
}
@@ -0,0 +1,59 @@
<?php
namespace App\Http\Controllers\Meet;
use App\Http\Controllers\Controller;
use App\Http\Controllers\Meet\Concerns\ScopesToAccount;
use App\Models\ContactGroup;
use App\Services\Meet\ContactService;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\View\View;
class ContactGroupController extends Controller
{
use ScopesToAccount;
public function index(Request $request): View
{
$this->authorizeAbility($request, 'meetings.manage');
$organization = $this->organization($request);
$groups = ContactGroup::owned($this->ownerRef($request))
->where('organization_id', $organization->id)
->withCount('members')
->orderBy('name')
->get();
return view('meet.contacts.groups', compact('groups', 'organization'));
}
public function store(Request $request): RedirectResponse
{
$this->authorizeAbility($request, 'meetings.manage');
$organization = $this->organization($request);
$validated = $request->validate([
'name' => ['required', 'string', 'max:255'],
'description' => ['nullable', 'string'],
'emails' => ['nullable', 'string'],
]);
$group = ContactGroup::create([
'owner_ref' => $this->ownerRef($request),
'organization_id' => $organization->id,
'name' => $validated['name'],
'description' => $validated['description'] ?? null,
]);
if ($emails = trim((string) ($validated['emails'] ?? ''))) {
foreach (preg_split('/[\s,;]+/', $emails) as $email) {
if ($email = trim($email)) {
$group->members()->create(['email' => $email]);
}
}
}
return redirect()->route('meet.contacts.groups')->with('success', 'Contact group created.');
}
}
@@ -0,0 +1,74 @@
<?php
namespace App\Http\Controllers\Meet;
use App\Http\Controllers\Controller;
use App\Http\Controllers\Meet\Concerns\ScopesToAccount;
use App\Models\Branch;
use App\Models\Member;
use App\Models\Recording;
use App\Models\Room;
use App\Services\Meet\ReportService;
use Illuminate\Http\Request;
use Illuminate\Support\Carbon;
use Illuminate\View\View;
class DashboardController extends Controller
{
use ScopesToAccount;
public function index(Request $request): View
{
$this->authorizeAbility($request, 'dashboard.view');
$organization = $this->organization($request);
$owner = $this->ownerRef($request);
$reports = app(ReportService::class);
$roomQuery = Room::owned($owner)->where('organization_id', $organization->id);
$this->scopeToBranch($request, $roomQuery);
$upcoming = (clone $roomQuery)
->where('status', 'scheduled')
->where('scheduled_at', '>=', now())
->orderBy('scheduled_at')
->limit(10)
->get();
$active = (clone $roomQuery)
->where('status', 'live')
->orderByDesc('updated_at')
->get();
$past = (clone $roomQuery)
->whereIn('status', ['ended', 'cancelled'])
->orderByDesc('scheduled_at')
->limit(10)
->get();
$stats = [
'branches' => Branch::owned($owner)->where('organization_id', $organization->id)->where('is_active', true)->count(),
'team_members' => Member::owned($owner)->where('organization_id', $organization->id)->count(),
'upcoming_count' => (clone $roomQuery)->where('status', 'scheduled')->where('scheduled_at', '>=', now())->count(),
'live_count' => (clone $roomQuery)->where('status', 'live')->count(),
'recordings_count' => Recording::owned($owner)
->whereHas('session.room', fn ($q) => $q->where('organization_id', $organization->id))
->count(),
];
$recentRecordings = Recording::owned($owner)
->whereHas('session.room', fn ($q) => $q->where('organization_id', $organization->id))
->with(['session.room'])
->orderByDesc('created_at')
->limit(5)
->get();
$analytics = $reports->orgSummary(
$owner,
$organization->id,
Carbon::now()->subDays(30)->startOfDay(),
Carbon::now()->endOfDay(),
);
return view('meet.dashboard', compact('organization', 'stats', 'upcoming', 'active', 'past', 'recentRecordings', 'analytics'));
}
}
@@ -0,0 +1,94 @@
<?php
namespace App\Http\Controllers\Meet;
use App\Http\Controllers\Controller;
use App\Http\Controllers\Meet\Concerns\ScopesToAccount;
use App\Models\DirectConversation;
use App\Models\Member;
use App\Services\Meet\DirectMessageService;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\View\View;
class DirectMessageController extends Controller
{
use ScopesToAccount;
public function __construct(
protected DirectMessageService $dms,
) {}
public function index(Request $request): View
{
$this->authorizeAbility($request, 'meetings.view');
$owner = $this->ownerRef($request);
$organization = $this->organization($request);
$conversations = DirectConversation::owned($owner)
->where('organization_id', $organization->id)
->with(['participants', 'messages' => fn ($q) => $q->latest()->limit(1)])
->orderByDesc('updated_at')
->get();
$members = Member::owned($owner)
->where('organization_id', $organization->id)
->where('user_ref', '!=', $owner)
->get();
return view('meet.messages.index', compact('conversations', 'members', 'organization'));
}
public function start(Request $request): RedirectResponse
{
$this->authorizeAbility($request, 'meetings.view');
$validated = $request->validate([
'user_ref' => ['required', 'string', 'max:64'],
]);
$conversation = $this->dms->findOrCreateDm(
$request->user(),
$this->organization($request),
$validated['user_ref'],
);
return redirect()->route('meet.messages.show', $conversation);
}
public function show(Request $request, DirectConversation $conversation): View
{
$this->authorizeAbility($request, 'meetings.view');
$this->authorizeOwner($request, $conversation);
abort_unless(
$conversation->participants()->where('user_ref', $this->ownerRef($request))->exists(),
403,
);
$messages = $conversation->messages()
->whereNull('parent_id')
->with('replies')
->orderBy('created_at')
->paginate(50);
return view('meet.messages.show', compact('conversation', 'messages'));
}
public function postMessage(Request $request, DirectConversation $conversation): RedirectResponse
{
$this->authorizeOwner($request, $conversation);
$validated = $request->validate([
'body' => ['required', 'string', 'max:5000'],
'parent_id' => ['nullable', 'integer', 'exists:meet_direct_messages,id'],
]);
$this->dms->postMessage(
$conversation,
$request->user(),
$validated['body'],
$validated['parent_id'] ?? null,
);
return back()->with('success', 'Message sent.');
}
}
@@ -0,0 +1,142 @@
<?php
namespace App\Http\Controllers\Meet;
use App\Http\Controllers\Controller;
use App\Http\Controllers\Meet\Concerns\ScopesToAccount;
use App\Models\ContactGroup;
use App\Models\Invitation;
use App\Models\Room;
use App\Services\Meet\ContactService;
use App\Services\Meet\InvitationService;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Http\Response;
use Illuminate\View\View;
class InvitationController extends Controller
{
use ScopesToAccount;
public function __construct(
protected InvitationService $invitations,
protected ContactService $contacts,
) {}
public function index(Request $request, Room $room): View
{
$this->authorizeAbility($request, 'meetings.manage');
$this->authorizeOwner($request, $room);
$room->load(['invitations' => fn ($q) => $q->orderByDesc('created_at')]);
$groups = ContactGroup::owned($this->ownerRef($request))
->where('organization_id', $room->organization_id)
->withCount('members')
->orderBy('name')
->get();
return view('meet.invitations.index', compact('room', 'groups'));
}
public function store(Request $request, Room $room): RedirectResponse
{
$this->authorizeAbility($request, 'meetings.manage');
$this->authorizeOwner($request, $room);
$validated = $request->validate([
'emails' => ['nullable', 'string'],
'member_refs' => ['nullable', 'array'],
'member_refs.*' => ['string'],
'contact_group_id' => ['nullable', 'integer', 'exists:meet_contact_groups,id'],
]);
$invites = [];
if ($emails = trim((string) ($validated['emails'] ?? ''))) {
foreach (preg_split('/[\s,;]+/', $emails) as $email) {
if ($email = trim($email)) {
$invites[] = ['email' => $email];
}
}
}
foreach ($validated['member_refs'] ?? [] as $userRef) {
$user = \App\Models\User::where('public_id', $userRef)->first();
if ($user?->email) {
$invites[] = ['email' => $user->email, 'name' => $user->name, 'user_ref' => $userRef];
}
}
if (! empty($validated['contact_group_id'])) {
$group = ContactGroup::owned($this->ownerRef($request))->findOrFail($validated['contact_group_id']);
$invites = array_merge($invites, $this->contacts->fromGroup($group)->all());
}
$this->invitations->inviteToRoom($room, $invites, $request->user());
return back()->with('success', count($invites).' invitation(s) sent.');
}
public function bulkCsv(Request $request, Room $room): RedirectResponse
{
$this->authorizeAbility($request, 'meetings.manage');
$this->authorizeOwner($request, $room);
$request->validate(['csv' => ['required', 'file', 'mimes:csv,txt', 'max:2048']]);
$count = $this->invitations->bulkFromCsv($room, $request->file('csv')->getContent(), $request->user());
return back()->with('success', "{$count} invitation(s) imported.");
}
public function resend(Request $request, Invitation $invitation): RedirectResponse
{
$this->authorizeAbility($request, 'meetings.manage');
$this->authorizeOwner($request, $invitation);
$this->invitations->resend($invitation, $request->user());
return back()->with('success', 'Invitation resent.');
}
public function destroy(Request $request, Invitation $invitation): RedirectResponse
{
$this->authorizeAbility($request, 'meetings.manage');
$this->authorizeOwner($request, $invitation);
$room = $invitation->room;
$invitation->delete();
return redirect()->route('meet.invitations.index', $room)->with('success', 'Invitation removed.');
}
public function rsvp(Request $request, Invitation $invitation): View|RedirectResponse
{
abort_unless(hash_equals((string) $invitation->rsvp_token, (string) $request->query('token')), 403);
if ($request->isMethod('post')) {
$validated = $request->validate(['status' => ['required', 'in:accepted,declined']]);
$this->invitations->respond($invitation, $validated['status'], $invitation->rsvp_token);
return view('meet.invitations.rsvp-done', [
'invitation' => $invitation->fresh(),
'room' => $invitation->room,
]);
}
return view('meet.invitations.rsvp', [
'invitation' => $invitation,
'room' => $invitation->room,
]);
}
public function searchContacts(Request $request): JsonResponse
{
$this->authorizeAbility($request, 'meetings.create');
$organization = $this->organization($request);
$query = (string) $request->query('q', '');
$results = $this->contacts->search($organization->id, $this->ownerRef($request), $query);
return response()->json(['contacts' => $results]);
}
}
@@ -0,0 +1,123 @@
<?php
namespace App\Http\Controllers\Meet;
use App\Http\Controllers\Controller;
use App\Models\Room;
use App\Services\Meet\InvitationService;
use App\Services\Meet\SecurityPolicyService;
use App\Services\Meet\SessionService;
use App\Services\Meet\WebinarService;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\View\View;
class JoinController extends Controller
{
public function __construct(
protected SessionService $sessions,
protected SecurityPolicyService $security,
protected WebinarService $webinars,
) {}
public function show(Request $request, Room $room): View|RedirectResponse
{
if ($room->status === 'cancelled') {
return view('meet.join.cancelled', compact('room'));
}
if ($room->isWebinar() && $request->query('token')) {
$request->session()->put("meet.webinar.{$room->uuid}", $request->query('token'));
}
[$allowed, $reason] = $this->security->canJoin($room, $request->user(), $request->user()?->email);
if (! $allowed && $reason !== 'passcode_required') {
return view('meet.join.denied', compact('room', 'reason'));
}
if ($room->passcode && ! $request->session()->get("meet.passcode.{$room->uuid}")) {
return view('meet.join.passcode', compact('room'));
}
return view('meet.join.show', [
'room' => $room,
'user' => $request->user(),
'isWebinar' => $room->isWebinar(),
]);
}
public function verifyPasscode(Request $request, Room $room): RedirectResponse
{
$request->validate(['passcode' => ['required', 'string']]);
abort_unless($room->passcode === $request->input('passcode'), 422);
$request->session()->put("meet.passcode.{$room->uuid}", true);
return redirect()->route('meet.join', $room);
}
public function enter(Request $request, Room $room): RedirectResponse
{
if ($room->passcode && ! $request->session()->get("meet.passcode.{$room->uuid}")) {
return redirect()->route('meet.join', $room);
}
$validated = $request->validate([
'display_name' => ['required_without:auth', 'string', 'max:100'],
'email' => ['nullable', 'email'],
]);
$user = $request->user();
$displayName = $user?->name ?? $validated['display_name'];
$email = $user?->email ?? ($validated['email'] ?? null);
[$allowed, $reason] = $this->security->canJoin($room, $user, $email ?? $displayName.'@guest.local');
if (! $allowed && $reason !== 'passcode_required') {
return back()->withErrors(['join' => $reason ?? 'Unable to join meeting.']);
}
if ($room->isLive() && $room->activeSession()) {
$session = $room->activeSession();
} elseif ($room->isTownHall() && $user && $user->ownerRef() === $room->host_user_ref && $room->setting('green_room')) {
$session = app(\App\Services\Meet\TownHallService::class)->startGreenRoom($room, $user);
} elseif ($user && $user->ownerRef() === $room->host_user_ref) {
$session = $this->sessions->start($room, $user);
} 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);
} 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.');
$session = $room->activeSession();
}
$role = ($user && $user->ownerRef() === $room->host_user_ref) ? 'host' : 'guest';
if ($room->isWebinar() && $role !== 'host') {
$token = $request->session()->get("meet.webinar.{$room->uuid}");
$reg = $token
? $this->webinars->findByAccessToken($token)
: ($email ? $this->webinars->findApprovedRegistration($room, $email) : null);
if (! $reg) {
return redirect()->route('meet.webinar.register', $room)
->withErrors(['register' => 'Approved registration is required for this webinar.']);
}
$role = 'attendee';
$displayName = $reg->display_name;
}
$participant = $this->sessions->joinParticipant($session, $user, $role, $displayName, $email);
app(InvitationService::class)->markAcceptedOnJoin($room, $user, $email);
$request->session()->put("meet.participant.{$session->uuid}", $participant->uuid);
return redirect()->route('meet.room', $session);
}
}
@@ -0,0 +1,314 @@
<?php
namespace App\Http\Controllers\Meet;
use App\Http\Controllers\Controller;
use App\Models\BreakoutRoom;
use App\Models\MeetPoll;
use App\Models\Participant;
use App\Models\QaQuestion;
use App\Models\Session;
use App\Models\SessionFile;
use App\Models\Whiteboard;
use App\Services\Meet\BreakoutService;
use App\Services\Meet\FileSharingService;
use App\Services\Meet\LiveStreamService;
use App\Services\Meet\PollService;
use App\Services\Meet\QaService;
use App\Services\Meet\TownHallService;
use App\Services\Meet\WhiteboardService;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Storage;
use Symfony\Component\HttpFoundation\StreamedResponse;
class MeetingFeaturesController extends Controller
{
public function __construct(
protected QaService $qa,
protected PollService $polls,
protected BreakoutService $breakouts,
protected FileSharingService $files,
protected WhiteboardService $whiteboards,
protected LiveStreamService $liveStreams,
protected TownHallService $townHall,
) {}
// --- Q&A ---
public function submitQuestion(Request $request, Session $session): JsonResponse
{
$participant = $this->participant($request, $session);
$validated = $request->validate(['question' => ['required', 'string', 'max:1000']]);
$question = $this->qa->submit($session, $participant, $validated['question']);
return response()->json(['question' => $question]);
}
public function approveQuestion(Request $request, Session $session, QaQuestion $question): JsonResponse
{
$this->hostOnly($request, $session);
abort_unless($question->session_id === $session->id, 404);
return response()->json(['question' => $this->qa->approve($question)]);
}
public function answerQuestion(Request $request, Session $session, QaQuestion $question): JsonResponse
{
$participant = $this->hostOnly($request, $session);
abort_unless($question->session_id === $session->id, 404);
$validated = $request->validate(['answer' => ['required', 'string', 'max:2000']]);
return response()->json([
'question' => $this->qa->answer($question, $validated['answer'], $participant->display_name),
]);
}
public function upvoteQuestion(Request $request, Session $session, QaQuestion $question): JsonResponse
{
$this->participant($request, $session);
abort_unless($question->session_id === $session->id, 404);
return response()->json(['question' => $this->qa->upvote($question)]);
}
// --- Polls ---
public function createPoll(Request $request, Session $session): JsonResponse
{
$this->hostOnly($request, $session);
$validated = $request->validate([
'question' => ['required', 'string', 'max:500'],
'options' => ['required', 'array', 'min:2', 'max:6'],
'options.*' => ['required', 'string', 'max:200'],
]);
$poll = $this->polls->create($session, $validated['question'], $validated['options']);
return response()->json(['poll' => $poll], 201);
}
public function votePoll(Request $request, Session $session, MeetPoll $poll): JsonResponse
{
$participant = $this->participant($request, $session);
abort_unless($poll->session_id === $session->id, 404);
$validated = $request->validate(['option_index' => ['required', 'integer', 'min:0']]);
$this->polls->vote($poll, $participant, $validated['option_index']);
return response()->json($this->polls->results($poll));
}
public function closePoll(Request $request, Session $session, MeetPoll $poll): JsonResponse
{
$this->hostOnly($request, $session);
abort_unless($poll->session_id === $session->id, 404);
return response()->json($this->polls->results($this->polls->close($poll)));
}
// --- Breakouts ---
public function createBreakouts(Request $request, Session $session): JsonResponse
{
$this->hostOnly($request, $session);
$validated = $request->validate([
'count' => ['required', 'integer', 'min:1', 'max:20'],
'timer_minutes' => ['nullable', 'integer', 'min:5', 'max:120'],
]);
$rooms = $this->breakouts->createRooms(
$session,
$validated['count'],
$validated['timer_minutes'] ?? null,
);
return response()->json(['breakouts' => $rooms]);
}
public function moveToBreakout(Request $request, Session $session, BreakoutRoom $breakout): JsonResponse
{
$participant = $this->participant($request, $session);
abort_unless($breakout->session_id === $session->id, 404);
$this->breakouts->moveParticipant($participant, $breakout);
return response()->json(['breakout_uuid' => $breakout->uuid, 'media_room' => $breakout->media_room_name]);
}
public function returnFromBreakout(Request $request, Session $session): JsonResponse
{
$participant = $this->participant($request, $session);
$this->breakouts->returnToMain($participant);
return response()->json(['returned' => true]);
}
public function broadcastBreakout(Request $request, Session $session): JsonResponse
{
$participant = $this->hostOnly($request, $session);
$validated = $request->validate(['message' => ['required', 'string', 'max:1000']]);
$message = $this->breakouts->broadcast($session, $validated['message'], $participant->display_name);
return response()->json(['message' => $message]);
}
public function closeBreakouts(Request $request, Session $session): JsonResponse
{
$this->hostOnly($request, $session);
$this->breakouts->closeAll($session);
return response()->json(['closed' => true]);
}
// --- Files ---
public function uploadFile(Request $request, Session $session): JsonResponse
{
$participant = $this->participant($request, $session);
$validated = $request->validate(['file' => ['required', 'file', 'max:51200']]);
$file = $this->files->upload(
$session->room,
$session,
$validated['file'],
$participant->user_ref ?? $participant->uuid,
$participant->display_name,
);
return response()->json(['file' => $file], 201);
}
public function downloadFile(Request $request, Session $session, SessionFile $file): StreamedResponse|JsonResponse
{
$participant = $this->participant($request, $session);
abort_unless($file->room_id === $session->room_id, 404);
if ($file->drive_file_id) {
return response()->json(['url' => $this->files->download($file, $participant)]);
}
$path = $this->files->download($file, $participant);
return Storage::disk(config('meet.files.disk', 'local'))->download($file->storage_path, $file->original_name);
}
// --- Whiteboard ---
public function showWhiteboard(Request $request, Session $session): JsonResponse
{
$this->participant($request, $session);
$board = $this->whiteboards->ensureForSession($session);
return response()->json(['whiteboard' => $board]);
}
public function saveWhiteboard(Request $request, Session $session): JsonResponse
{
$this->participant($request, $session);
$validated = $request->validate(['state' => ['required', 'array']]);
$board = $this->whiteboards->ensureForSession($session);
return response()->json(['whiteboard' => $this->whiteboards->saveState($board, $validated['state'])]);
}
public function exportWhiteboard(Request $request, Session $session): JsonResponse
{
$this->participant($request, $session);
$validated = $request->validate(['image_data' => ['required', 'string']]);
$data = preg_replace('#^data:image/\w+;base64,#i', '', $validated['image_data']);
$binary = base64_decode($data, true);
abort_unless($binary !== false, 422);
$path = 'whiteboards/'.$session->uuid.'/'.now()->timestamp.'.png';
Storage::disk(config('meet.files.disk', 'local'))->put($path, $binary);
return response()->json(['export_path' => $path]);
}
// --- Live streaming ---
public function configureLiveStream(Request $request, Session $session): JsonResponse
{
$this->hostOnly($request, $session);
$validated = $request->validate([
'platform' => ['required', 'string', 'in:'.implode(',', config('meet.live_stream.platforms'))],
'rtmp_url' => ['nullable', 'url', 'max:2048'],
'stream_key' => ['required', 'string', 'max:512'],
'layout' => ['nullable', 'string', 'in:'.implode(',', config('meet.live_stream.layouts'))],
]);
$stream = $this->liveStreams->configure($session, $request->user(), $validated);
return response()->json(['stream' => $stream->makeHidden('stream_key')], 201);
}
public function startLiveStream(Request $request, Session $session): JsonResponse
{
$this->hostOnly($request, $session);
$validated = $request->validate([
'platform' => ['required', 'string', 'in:'.implode(',', config('meet.live_stream.platforms'))],
]);
$stream = $session->liveStreams()->where('platform', $validated['platform'])->firstOrFail();
$started = $this->liveStreams->start($stream);
return response()->json(['stream' => $started->makeHidden('stream_key')]);
}
public function stopLiveStream(Request $request, Session $session): JsonResponse
{
$this->hostOnly($request, $session);
$validated = $request->validate([
'platform' => ['required', 'string', 'in:'.implode(',', config('meet.live_stream.platforms'))],
]);
$stream = $session->liveStreams()->where('platform', $validated['platform'])->firstOrFail();
return response()->json(['stream' => $this->liveStreams->stop($stream)->makeHidden('stream_key')]);
}
// --- Town hall ---
public function startGreenRoom(Request $request, Session $session): JsonResponse
{
$this->hostOnly($request, $session);
abort_unless($session->room->isTownHall(), 422);
$greenRoom = $this->townHall->startGreenRoom($session->room, $request->user());
return response()->json(['session_uuid' => $greenRoom->uuid, 'mode' => 'green_room']);
}
public function goLiveTownHall(Request $request, Session $session): JsonResponse
{
$this->hostOnly($request, $session);
$live = $this->townHall->goLive($session, $request->user());
return response()->json(['session_uuid' => $live->uuid, 'mode' => 'live']);
}
public function handoffCoHost(Request $request, Session $session): JsonResponse
{
$participant = $this->hostOnly($request, $session);
$validated = $request->validate(['user_ref' => ['required', 'string', 'max:64']]);
return response()->json([
'participant' => $this->townHall->handoffCoHost($session, $participant, $validated['user_ref']),
]);
}
protected function participant(Request $request, Session $session): Participant
{
$uuid = $request->session()->get("meet.participant.{$session->uuid}");
abort_unless($uuid, 403);
return Participant::where('uuid', $uuid)->where('session_id', $session->id)->firstOrFail();
}
protected function hostOnly(Request $request, Session $session): Participant
{
$participant = $this->participant($request, $session);
abort_unless($participant->isHost(), 403);
return $participant;
}
}
@@ -0,0 +1,286 @@
<?php
namespace App\Http\Controllers\Meet;
use App\Http\Controllers\Controller;
use App\Models\Participant;
use App\Models\Session;
use App\Services\Meet\ChatService;
use App\Services\Meet\Media\MediaProviderInterface;
use App\Services\Meet\RecordingService;
use App\Services\Meet\SecurityPolicyService;
use App\Services\Meet\SessionService;
use App\Services\Meet\TranscriptService;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\View\View;
class MeetingRoomController extends Controller
{
public function __construct(
protected SessionService $sessions,
protected ChatService $chat,
protected MediaProviderInterface $media,
protected RecordingService $recordings,
protected TranscriptService $transcripts,
protected SecurityPolicyService $security,
) {}
public function show(Request $request, Session $session): View|RedirectResponse
{
abort_unless($session->isLive(), 404, 'Meeting has ended.');
$participantUuid = $request->session()->get("meet.participant.{$session->uuid}");
abort_unless($participantUuid, 403, 'Please join the meeting first.');
$participant = Participant::where('uuid', $participantUuid)
->where('session_id', $session->id)
->firstOrFail();
$token = $this->media->isConfigured()
? $this->sessions->generateMediaToken($participant)
: '';
$session->load(['room', 'participants']);
return view('meet.room.show', [
'session' => $session,
'room' => $session->room,
'participant' => $participant,
'mediaToken' => $token,
'mediaUrl' => $this->media->serverUrl(),
'mediaConfigured' => $this->media->isConfigured(),
'messages' => $this->chat->recentMessages($session),
'activeRecording' => $session->recordings()->where('status', 'recording')->first(),
'watermark' => $session->room->organization?->securitySetting('watermark_enabled', false),
'isWebinar' => $session->room->isWebinar(),
'canPublish' => app(\App\Services\Meet\WebinarService::class)->canPublish($session->room, $participant),
'isAttendee' => $participant->isAttendee(),
]);
}
public function end(Request $request, Session $session): RedirectResponse
{
$participantUuid = $request->session()->get("meet.participant.{$session->uuid}");
$participant = Participant::where('uuid', $participantUuid)->first();
abort_unless($participant?->isHost(), 403);
$this->sessions->end($session, $participant->user_ref ?? $participant->uuid);
return redirect()->route('meet.rooms.show', $session->room)
->with('success', 'Meeting ended.');
}
public function leave(Request $request, Session $session): RedirectResponse
{
$participantUuid = $request->session()->get("meet.participant.{$session->uuid}");
$participant = Participant::where('uuid', $participantUuid)->first();
if ($participant) {
$this->sessions->leaveParticipant($participant);
}
$request->session()->forget("meet.participant.{$session->uuid}");
return redirect()->route('meet.dashboard');
}
public function raiseHand(Request $request, Session $session): JsonResponse
{
$participant = $this->currentParticipant($request, $session);
$participant->update(['hand_raised' => ! $participant->hand_raised]);
return response()->json(['hand_raised' => $participant->hand_raised]);
}
public function sendMessage(Request $request, Session $session): JsonResponse
{
$participant = $this->currentParticipant($request, $session);
$validated = $request->validate(['body' => ['required', 'string', 'max:2000']]);
$message = $this->chat->sendPublicMessage(
$session,
$participant->display_name,
$validated['body'],
$participant->user_ref,
);
return response()->json([
'message' => [
'uuid' => $message->uuid,
'sender_name' => $message->sender_name,
'body' => $message->body,
'created_at' => $message->created_at->toIso8601String(),
],
]);
}
public function sendReaction(Request $request, Session $session): JsonResponse
{
$participant = $this->currentParticipant($request, $session);
$validated = $request->validate(['emoji' => ['required', 'string', 'max:32']]);
$reaction = $this->chat->sendReaction(
$session,
$participant->display_name,
$validated['emoji'],
$participant->uuid,
);
return response()->json([
'reaction' => [
'sender_name' => $reaction->sender_name,
'emoji' => $reaction->emoji,
'created_at' => $reaction->created_at->toIso8601String(),
],
]);
}
public function poll(Request $request, Session $session): JsonResponse
{
$this->currentParticipant($request, $session);
$session->load(['participants' => fn ($q) => $q->where('status', 'joined')]);
$messages = $this->chat->recentMessages($session, 50);
$reactions = $session->reactions()->where('created_at', '>=', now()->subMinutes(5))->latest()->limit(20)->get();
$qa = app(\App\Services\Meet\QaService::class)->forSession($session, true);
$polls = $session->polls()->where('status', 'open')->with('votes')->get();
$breakouts = $session->breakoutRooms()->where('status', 'open')->get();
$broadcasts = $session->messages()->where('type', 'breakout_broadcast')->where('created_at', '>=', now()->subMinutes(30))->latest()->limit(5)->get();
$files = $session->sessionFiles()
->where(function ($q) {
$q->whereNull('expires_at')->orWhere('expires_at', '>', now());
})
->latest()
->limit(20)
->get();
return response()->json([
'participants' => $session->participants->map(fn ($p) => [
'uuid' => $p->uuid,
'display_name' => $p->display_name,
'role' => $p->role,
'hand_raised' => $p->hand_raised,
'is_muted' => $p->is_muted,
'is_video_off' => $p->is_video_off,
'breakout_room_id' => $p->breakout_room_id,
]),
'messages' => $messages->map(fn ($m) => [
'uuid' => $m->uuid,
'sender_name' => $m->sender_name,
'body' => $m->body,
'type' => $m->type,
'created_at' => $m->created_at->toIso8601String(),
]),
'reactions' => $reactions->map(fn ($r) => [
'sender_name' => $r->sender_name,
'emoji' => $r->emoji,
'created_at' => $r->created_at->toIso8601String(),
]),
'qa' => $qa->map(fn ($q) => [
'uuid' => $q->uuid,
'asker_name' => $q->asker_name,
'question' => $q->question,
'status' => $q->status,
'answer' => $q->answer,
'upvotes' => $q->upvotes,
]),
'polls' => $polls->map(fn ($p) => [
'uuid' => $p->uuid,
'question' => $p->question,
'options' => $p->options,
'votes' => $p->votes->count(),
]),
'breakouts' => $breakouts->map(fn ($b) => [
'uuid' => $b->uuid,
'name' => $b->name,
'closes_at' => $b->closes_at?->toIso8601String(),
]),
'broadcasts' => $broadcasts->map(fn ($m) => [
'body' => $m->body,
'sender_name' => $m->sender_name,
'created_at' => $m->created_at->toIso8601String(),
]),
'files' => $files->map(fn ($f) => [
'uuid' => $f->uuid,
'original_name' => $f->original_name,
'file_size' => $f->file_size,
]),
]);
}
public function startRecording(Request $request, Session $session): JsonResponse
{
$participant = $this->currentParticipant($request, $session);
abort_unless($participant->isHost(), 403);
abort_if($session->room->organization?->securitySetting('recording_host_only') && ! $participant->isHost(), 403);
$host = $request->user() ?? \App\Models\User::where('public_id', $session->room->host_user_ref)->first();
abort_unless($host, 403);
$recording = $this->recordings->start($session, $host);
return response()->json(['recording_uuid' => $recording->uuid, 'status' => $recording->status]);
}
public function stopRecording(Request $request, Session $session): JsonResponse
{
$participant = $this->currentParticipant($request, $session);
abort_unless($participant->isHost(), 403);
$recording = $session->recordings()->where('status', 'recording')->firstOrFail();
$this->recordings->stop($recording, $participant->user_ref ?? $participant->uuid);
return response()->json(['status' => 'processing']);
}
public function lock(Request $request, Session $session): JsonResponse
{
$participant = $this->currentParticipant($request, $session);
abort_unless($participant->isHost(), 403);
$this->sessions->lock($session, $participant->user_ref ?? $participant->uuid);
return response()->json(['locked' => true]);
}
public function unlock(Request $request, Session $session): JsonResponse
{
$participant = $this->currentParticipant($request, $session);
abort_unless($participant->isHost(), 403);
$this->sessions->unlock($session, $participant->user_ref ?? $participant->uuid);
return response()->json(['locked' => false]);
}
public function caption(Request $request, Session $session): JsonResponse
{
$participant = $this->currentParticipant($request, $session);
$validated = $request->validate(['text' => ['required', 'string', 'max:500']]);
if (! $session->room->setting('live_captions', false)) {
return response()->json(['ok' => false], 422);
}
$transcript = $session->transcripts()->latest()->first()
?? $this->transcripts->ensureForSession($session);
$this->transcripts->appendSegment($transcript, $participant->display_name, $validated['text']);
return response()->json(['ok' => true]);
}
protected function currentParticipant(Request $request, Session $session): Participant
{
$participantUuid = $request->session()->get("meet.participant.{$session->uuid}");
abort_unless($participantUuid, 403);
return Participant::where('uuid', $participantUuid)
->where('session_id', $session->id)
->firstOrFail();
}
}
@@ -0,0 +1,98 @@
<?php
namespace App\Http\Controllers\Meet;
use App\Http\Controllers\Controller;
use App\Http\Controllers\Meet\Concerns\ScopesToAccount;
use App\Models\Branch;
use App\Models\Member;
use App\Services\Meet\AuditLogger;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\View\View;
class MemberController extends Controller
{
use ScopesToAccount;
public function index(Request $request): View
{
$this->authorizeAbility($request, 'admin.members.view');
$organization = $this->organization($request);
$members = Member::owned($this->ownerRef($request))
->where('organization_id', $organization->id)
->with('branch')
->orderBy('created_at')
->get();
return view('meet.admin.members.index', [
'members' => $members,
'organization' => $organization,
'roles' => config('meet.roles'),
]);
}
public function create(Request $request): View
{
$this->authorizeAbility($request, 'admin.members.manage');
$organization = $this->organization($request);
$branches = Branch::owned($this->ownerRef($request))
->where('organization_id', $organization->id)
->where('is_active', true)
->orderBy('name')
->get();
return view('meet.admin.members.create', [
'organization' => $organization,
'branches' => $branches,
'roles' => config('meet.roles'),
]);
}
public function store(Request $request): RedirectResponse
{
$this->authorizeAbility($request, 'admin.members.manage');
$organization = $this->organization($request);
$owner = $this->ownerRef($request);
$validated = $request->validate([
'user_ref' => ['required', 'string', 'max:255'],
'role' => ['required', 'string', 'in:'.implode(',', array_keys(config('meet.roles')))],
'branch_id' => ['nullable', 'integer', 'exists:meet_branches,id'],
]);
$member = Member::updateOrCreate(
[
'organization_id' => $organization->id,
'user_ref' => $validated['user_ref'],
],
[
'owner_ref' => $owner,
'role' => $validated['role'],
'branch_id' => $validated['branch_id'] ?? null,
],
);
AuditLogger::record($owner, 'member.created', $organization->id, $owner, Member::class, $member->id);
return redirect()->route('meet.members.index')->with('success', 'Member saved.');
}
public function destroy(Request $request, Member $member): RedirectResponse
{
$this->authorizeAbility($request, 'admin.members.manage');
$this->authorizeOwner($request, $member);
abort_if($member->user_ref === $this->ownerRef($request), 422, 'You cannot remove yourself.');
$memberId = $member->id;
$organizationId = $member->organization_id;
$member->delete();
AuditLogger::record($this->ownerRef($request), 'member.deleted', $organizationId, $this->ownerRef($request), Member::class, $memberId);
return redirect()->route('meet.members.index')->with('success', 'Member removed.');
}
}
@@ -0,0 +1,65 @@
<?php
namespace App\Http\Controllers\Meet;
use App\Http\Controllers\Controller;
use App\Http\Controllers\Meet\Concerns\ScopesToAccount;
use App\Services\Meet\OrganizationResolver;
use App\Support\OrganizationBranding;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\View\View;
class OnboardingController extends Controller
{
use ScopesToAccount;
public function __construct(
protected OrganizationResolver $organizations,
) {}
public function show(Request $request): View|RedirectResponse
{
if ($this->organizations->isOnboarded($request->user())) {
return redirect()->route('meet.dashboard');
}
return view('meet.onboarding.show', [
'user' => $request->user(),
'timezones' => timezone_identifiers_list(),
'orgTypes' => [
'business' => 'Business',
'healthcare' => 'Healthcare',
'education' => 'Education',
'nonprofit' => 'Nonprofit',
],
]);
}
public function store(Request $request): RedirectResponse
{
if ($this->organizations->isOnboarded($request->user())) {
return redirect()->route('meet.dashboard');
}
$validated = $request->validate([
'organization_name' => ['required', 'string', 'max:255'],
'org_type' => ['required', 'string', 'in:business,healthcare,education,nonprofit'],
'branch_name' => ['required', 'string', 'max:255'],
'branch_address' => ['nullable', 'string', 'max:500'],
'branch_phone' => ['nullable', 'string', 'max:50'],
'timezone' => ['required', 'timezone'],
'logo' => ['nullable', 'image', 'mimes:jpeg,png,jpg,webp,svg', 'max:2048'],
]);
$organization = $this->organizations->completeOnboarding($request->user(), $validated);
if ($request->hasFile('logo')) {
$organization->update([
'logo_path' => OrganizationBranding::storeLogo($organization, $request->file('logo')),
]);
}
return redirect()->route('meet.dashboard')->with('success', 'Welcome to Ladill Meet!');
}
}
@@ -0,0 +1,66 @@
<?php
namespace App\Http\Controllers\Meet;
use App\Http\Controllers\Controller;
use App\Http\Controllers\Meet\Concerns\ScopesToAccount;
use App\Models\Recording;
use App\Services\Meet\RecordingService;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Storage;
use Illuminate\View\View;
use Symfony\Component\HttpFoundation\StreamedResponse;
class RecordingController extends Controller
{
use ScopesToAccount;
public function __construct(
protected RecordingService $recordings,
) {}
public function index(Request $request): View
{
$this->authorizeAbility($request, 'meetings.view');
$owner = $this->ownerRef($request);
$organization = $this->organization($request);
$recordings = Recording::owned($owner)
->whereHas('session.room', fn ($q) => $q->where('organization_id', $organization->id))
->with(['session.room'])
->orderByDesc('created_at')
->paginate(20);
return view('meet.recordings.index', compact('recordings'));
}
public function show(Request $request, Recording $recording): View
{
$this->authorizeAbility($request, 'meetings.view');
$this->authorizeOwner($request, $recording);
$recording->load(['session.room', 'session.participants']);
return view('meet.recordings.show', compact('recording'));
}
public function download(Request $request, Recording $recording): StreamedResponse
{
$this->authorizeAbility($request, 'meetings.view');
$this->authorizeOwner($request, $recording);
abort_unless($recording->isReady() && $recording->storage_path, 404);
return Storage::disk(config('meet.recordings.disk', 'local'))
->download($recording->storage_path, $recording->session->room->title.'.mp4');
}
public function destroy(Request $request, Recording $recording): RedirectResponse
{
$this->authorizeAbility($request, 'meetings.manage');
$this->authorizeOwner($request, $recording);
$this->recordings->delete($recording, $this->ownerRef($request));
return redirect()->route('meet.recordings.index')->with('success', 'Recording deleted.');
}
}
@@ -0,0 +1,114 @@
<?php
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\MeetPermissions;
use App\Services\Meet\OrganizationResolver;
use App\Services\Meet\ReportService;
use Illuminate\Http\Request;
use Illuminate\Support\Carbon;
use Illuminate\View\View;
use Symfony\Component\HttpFoundation\StreamedResponse;
class ReportController extends Controller
{
use ScopesToAccount;
public function __construct(
protected ReportService $reports,
) {}
public function index(Request $request): View
{
$this->authorizeAbility($request, 'reports.view');
$organization = $this->organization($request);
$branches = Branch::owned($this->ownerRef($request))
->where('organization_id', $organization->id)
->where('is_active', true)
->orderBy('name')
->get();
return view('meet.reports.index', [
'organization' => $organization,
'branches' => $branches,
'reports' => config('meet.report_types'),
]);
}
public function show(Request $request, string $type): View
{
$this->authorizeReport($request, $type);
$organization = $this->organization($request);
$branchScope = app(OrganizationResolver::class)->branchScope($this->member($request));
$branchId = $request->input('branch_id') ? (int) $request->input('branch_id') : $branchScope;
[$from, $to] = $this->dateRange($request);
$data = $this->reportData($type, $this->ownerRef($request), $organization->id, $from, $to, $branchId);
$branches = Branch::owned($this->ownerRef($request))
->where('organization_id', $organization->id)
->orderBy('name')
->get();
return view('meet.reports.show', [
'type' => $type,
'label' => config('meet.report_types')[$type] ?? $type,
'data' => $data,
'from' => $from->toDateString(),
'to' => $to->toDateString(),
'branchId' => $branchId,
'branches' => $branches,
'canExport' => app(MeetPermissions::class)->can($this->member($request), 'reports.export'),
]);
}
public function export(Request $request, string $type): StreamedResponse
{
$this->authorizeReport($request, $type);
abort_unless(app(MeetPermissions::class)->can($this->member($request), 'reports.export'), 403);
$organization = $this->organization($request);
$branchScope = app(OrganizationResolver::class)->branchScope($this->member($request));
$branchId = $request->input('branch_id') ? (int) $request->input('branch_id') : $branchScope;
[$from, $to] = $this->dateRange($request);
$data = $this->reportData($type, $this->ownerRef($request), $organization->id, $from, $to, $branchId);
$filename = 'meet-report-'.$type.'-'.now()->format('Y-m-d').'.csv';
return response()->streamDownload(function () use ($type, $data) {
echo $this->reports->toCsv($type, $data);
}, $filename, ['Content-Type' => 'text/csv']);
}
/** @return array<string, mixed> */
protected function reportData(string $type, string $ownerRef, int $organizationId, Carbon $from, Carbon $to, ?int $branchId): array
{
return match ($type) {
'summary' => $this->reports->orgSummary($ownerRef, $organizationId, $from, $to, $branchId),
'meetings' => $this->reports->meetingsReport($ownerRef, $organizationId, $from, $to, $branchId),
'users' => $this->reports->usersReport($ownerRef, $organizationId, $from, $to, $branchId),
'invitations' => $this->reports->invitationsReport($ownerRef, $organizationId, $from, $to, $branchId),
'branches' => $this->reports->branchRollup($ownerRef, $organizationId, $from, $to),
default => abort(404),
};
}
protected function authorizeReport(Request $request, string $type): void
{
$this->authorizeAbility($request, 'reports.view');
abort_unless(array_key_exists($type, config('meet.report_types', [])), 404);
}
/** @return array{0: Carbon, 1: Carbon} */
protected function dateRange(Request $request): array
{
$from = Carbon::parse($request->input('from', now()->subDays(30)->toDateString()))->startOfDay();
$to = Carbon::parse($request->input('to', now()->toDateString()))->endOfDay();
return [$from, $to];
}
}
@@ -0,0 +1,256 @@
<?php
namespace App\Http\Controllers\Meet;
use App\Http\Controllers\Controller;
use App\Http\Controllers\Meet\Concerns\ScopesToAccount;
use App\Models\Room;
use App\Models\Template;
use App\Services\Meet\AttendanceService;
use App\Services\Meet\AvailabilityService;
use App\Services\Meet\CalendarService;
use App\Services\Meet\IcalService;
use App\Services\Meet\InvitationService;
use App\Services\Meet\RecurringMeetingService;
use App\Services\Meet\RoomService;
use App\Services\Meet\SessionService;
use App\Services\Meet\TemplateService;
use chillerlan\QRCode\QRCode;
use chillerlan\QRCode\QROptions;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Http\Response;
use Illuminate\View\View;
class RoomController extends Controller
{
use ScopesToAccount;
public function __construct(
protected RoomService $rooms,
protected SessionService $sessions,
protected TemplateService $templates,
protected RecurringMeetingService $recurring,
protected InvitationService $invitations,
protected CalendarService $calendar,
protected AvailabilityService $availability,
protected IcalService $ical,
protected AttendanceService $attendance,
) {}
public function index(Request $request): View
{
$this->authorizeAbility($request, 'meetings.view');
$organization = $this->organization($request);
$owner = $this->ownerRef($request);
$query = Room::owned($owner)->where('organization_id', $organization->id);
$this->scopeToBranch($request, $query);
$rooms = $query->orderByDesc('scheduled_at')->paginate(20);
return view('meet.rooms.index', compact('rooms', 'organization'));
}
public function create(Request $request): View
{
$this->authorizeAbility($request, 'meetings.create');
$organization = $this->organization($request);
$templates = Template::owned($this->ownerRef($request))
->where('organization_id', $organization->id)
->orderBy('name')
->get();
return view('meet.rooms.create', [
'organization' => $organization,
'templates' => $templates,
'timezones' => timezone_identifiers_list(),
'recurrenceRules' => config('meet.recurrence_rules'),
'defaultSettings' => config('meet.default_settings'),
]);
}
public function store(Request $request): RedirectResponse
{
$this->authorizeAbility($request, 'meetings.create');
$organization = $this->organization($request);
$validated = $request->validate([
'title' => ['required', 'string', 'max:255'],
'description' => ['nullable', 'string', 'max:2000'],
'type' => ['nullable', 'string', 'in:scheduled,webinar,town_hall'],
'scheduled_at' => ['nullable', 'date'],
'duration_minutes' => ['nullable', 'integer', 'min:15', 'max:480'],
'timezone' => ['nullable', 'timezone'],
'passcode' => ['nullable', 'string', 'max:20'],
'template_id' => ['nullable', 'integer', 'exists:meet_templates,id'],
'recurrence_rule' => ['nullable', 'string', 'in:'.implode(',', array_keys(config('meet.recurrence_rules')))],
'recurrence_interval' => ['nullable', 'integer', 'min:1', 'max:12'],
'recurrence_until' => ['nullable', 'date'],
'invite_emails' => ['nullable', 'string'],
'waiting_room' => ['sometimes', 'boolean'],
'join_before_host' => ['sometimes', 'boolean'],
'mute_on_join' => ['sometimes', 'boolean'],
'auto_record' => ['sometimes', 'boolean'],
'live_captions' => ['sometimes', 'boolean'],
'webinar_auto_approve' => ['sometimes', 'boolean'],
]);
$settings = [
'waiting_room' => $request->boolean('waiting_room', true),
'join_before_host' => $request->boolean('join_before_host'),
'mute_on_join' => $request->boolean('mute_on_join', true),
'auto_record' => $request->boolean('auto_record'),
'live_captions' => $request->boolean('live_captions'),
'webinar_auto_approve' => $request->boolean('webinar_auto_approve'),
'stage_mode' => ($validated['type'] ?? '') === 'webinar',
];
$data = array_merge($validated, [
'settings' => $settings,
'timezone' => $validated['timezone'] ?? $organization->timezone,
]);
if (! empty($validated['template_id'])) {
$template = Template::findOrFail($validated['template_id']);
$data = $this->templates->applyToRoomData($template, $data);
}
$isInstant = empty($validated['scheduled_at']);
if (! empty($validated['recurrence_rule']) && ! $isInstant) {
$room = $this->recurring->createSeries($request->user(), $organization, $data);
} elseif (($validated['type'] ?? '') === 'webinar') {
abort_if($isInstant, 422, 'Webinars must be scheduled.');
$room = $this->rooms->create($request->user(), $organization, array_merge($data, [
'type' => 'webinar',
'status' => 'scheduled',
]));
} elseif (($validated['type'] ?? '') === 'town_hall') {
abort_if($isInstant, 422, 'Town halls must be scheduled.');
$room = $this->rooms->create($request->user(), $organization, array_merge($data, [
'type' => 'town_hall',
'status' => 'scheduled',
'settings' => array_merge($settings, [
'green_room' => true,
'practice_mode' => $request->boolean('practice_mode'),
]),
]));
} elseif ($isInstant) {
$room = $this->rooms->createInstant($request->user(), $organization, $data);
} else {
$room = $this->rooms->createScheduled($request->user(), $organization, $data);
}
if ($emails = trim((string) ($validated['invite_emails'] ?? ''))) {
$invites = collect(preg_split('/[\s,;]+/', $emails))
->filter()
->map(fn ($email) => ['email' => $email])
->all();
$this->invitations->inviteToRoom($room, $invites, $request->user());
}
if (! $isInstant && $room->scheduled_at) {
$this->calendar->syncRoomCreated($room, $request->user());
}
return redirect()->route('meet.rooms.show', $room)->with('success', 'Meeting created.');
}
public function show(Request $request, Room $room): View
{
$this->authorizeAbility($request, 'meetings.view');
$this->authorizeOwner($request, $room);
$room->load(['sessions.participants', 'invitations', 'sessions.recordings', 'sessions.aiSummaries', 'sessionFiles', 'webinarRegistrations']);
return view('meet.rooms.show', compact('room'));
}
public function start(Request $request, Room $room): RedirectResponse
{
$this->authorizeAbility($request, 'meetings.host');
$this->authorizeOwner($request, $room);
abort_if($room->status === 'cancelled', 422, 'Meeting was cancelled.');
if ($room->isTownHall() && $room->setting('green_room')) {
$session = app(\App\Services\Meet\TownHallService::class)->startGreenRoom($room, $request->user());
} else {
$session = $this->sessions->start($room, $request->user());
}
return redirect()->route('meet.room', $session);
}
public function cancel(Request $request, Room $room): RedirectResponse
{
$this->authorizeAbility($request, 'meetings.manage');
$this->authorizeOwner($request, $room);
$this->rooms->cancel($room, $this->ownerRef($request), $request->user());
return redirect()->route('meet.rooms.index')->with('success', 'Meeting cancelled.');
}
public function instant(Request $request): RedirectResponse
{
$this->authorizeAbility($request, 'meetings.create');
$organization = $this->organization($request);
$room = $this->rooms->createInstant($request->user(), $organization, [
'title' => $request->user()->name."'s meeting",
]);
$session = $this->sessions->start($room, $request->user());
return redirect()->route('meet.room', $session);
}
public function personal(Request $request): RedirectResponse
{
$this->authorizeAbility($request, 'meetings.host');
$organization = $this->organization($request);
$room = $this->rooms->ensurePersonalRoom($request->user(), $organization);
return redirect()->route('meet.rooms.show', $room);
}
public function ical(Request $request, Room $room): Response
{
$this->authorizeAbility($request, 'meetings.view');
$this->authorizeOwner($request, $room);
$content = $this->ical->forRoom($room, $request->user());
return response($content, 200, [
'Content-Type' => 'text/calendar; charset=utf-8',
'Content-Disposition' => 'attachment; filename="meeting-'.$room->uuid.'.ics"',
]);
}
public function qr(Request $request, Room $room): Response
{
$this->authorizeAbility($request, 'meetings.view');
$this->authorizeOwner($request, $room);
$options = new QROptions(['outputType' => QRCode::OUTPUT_IMAGE_PNG, 'scale' => 8]);
$binary = (new QRCode($options))->render($room->joinUrl());
return response($binary, 200, ['Content-Type' => 'image/png']);
}
public function attendance(Request $request, Room $room): Response
{
$this->authorizeAbility($request, 'meetings.view');
$this->authorizeOwner($request, $room);
$session = $room->sessions()->latest()->first();
abort_unless($session, 404);
return response($this->attendance->toCsv($session), 200, [
'Content-Type' => 'text/csv',
'Content-Disposition' => 'attachment; filename="attendance-'.$room->uuid.'.csv"',
]);
}
}
@@ -0,0 +1,58 @@
<?php
namespace App\Http\Controllers\Meet;
use App\Http\Controllers\Controller;
use App\Http\Controllers\Meet\Concerns\ScopesToAccount;
use App\Services\Meet\SecurityPolicyService;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\View\View;
class SettingsController extends Controller
{
use ScopesToAccount;
public function security(Request $request): View
{
$this->authorizeAbility($request, 'meetings.manage');
$organization = $this->organization($request);
return view('meet.settings.security', [
'organization' => $organization,
'policy' => array_merge(config('meet.security_defaults'), $organization->security_policy ?? []),
]);
}
public function updateSecurity(Request $request, SecurityPolicyService $security): RedirectResponse
{
$this->authorizeAbility($request, 'meetings.manage');
$organization = $this->organization($request);
$validated = $request->validate([
'org_only' => ['sometimes', 'boolean'],
'authenticated_only' => ['sometimes', 'boolean'],
'allowed_domains' => ['nullable', 'string'],
'recording_host_only' => ['sometimes', 'boolean'],
'watermark_enabled' => ['sometimes', 'boolean'],
'session_idle_minutes' => ['nullable', 'integer', 'min:15', 'max:480'],
]);
$domains = collect(explode(',', (string) ($validated['allowed_domains'] ?? '')))
->map(fn ($d) => trim($d))
->filter()
->values()
->all();
$security->updateOrganizationPolicy($organization, [
'org_only' => $request->boolean('org_only'),
'authenticated_only' => $request->boolean('authenticated_only'),
'allowed_domains' => $domains,
'recording_host_only' => $request->boolean('recording_host_only', true),
'watermark_enabled' => $request->boolean('watermark_enabled'),
'session_idle_minutes' => $validated['session_idle_minutes'] ?? 120,
]);
return redirect()->route('meet.settings.security')->with('success', 'Security policy updated.');
}
}
@@ -0,0 +1,23 @@
<?php
namespace App\Http\Controllers\Meet;
use App\Http\Controllers\Controller;
use App\Http\Controllers\Meet\Concerns\ScopesToAccount;
use App\Models\AiSummary;
use Illuminate\Http\Request;
use Illuminate\View\View;
class SummaryController extends Controller
{
use ScopesToAccount;
public function show(Request $request, AiSummary $summary): View
{
$this->authorizeAbility($request, 'meetings.view');
$this->authorizeOwner($request, $summary);
$summary->load(['session.room', 'actionItems', 'transcript']);
return view('meet.summaries.show', compact('summary'));
}
}
@@ -0,0 +1,68 @@
<?php
namespace App\Http\Controllers\Meet;
use App\Http\Controllers\Controller;
use App\Http\Controllers\Meet\Concerns\ScopesToAccount;
use App\Models\Template;
use App\Services\Meet\TemplateService;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\View\View;
class TemplateController extends Controller
{
use ScopesToAccount;
public function __construct(
protected TemplateService $templates,
) {}
public function index(Request $request): View
{
$this->authorizeAbility($request, 'meetings.manage');
$organization = $this->organization($request);
$templates = Template::owned($this->ownerRef($request))
->where('organization_id', $organization->id)
->orderBy('name')
->get();
return view('meet.templates.index', compact('templates', 'organization'));
}
public function create(Request $request): View
{
$this->authorizeAbility($request, 'meetings.manage');
return view('meet.templates.create', [
'defaultSettings' => config('meet.default_settings'),
]);
}
public function store(Request $request): RedirectResponse
{
$this->authorizeAbility($request, 'meetings.manage');
$organization = $this->organization($request);
$validated = $request->validate([
'name' => ['required', 'string', 'max:255'],
'description' => ['nullable', 'string'],
'duration_minutes' => ['nullable', 'integer', 'min:15', 'max:480'],
'is_default' => ['sometimes', 'boolean'],
'waiting_room' => ['sometimes', 'boolean'],
'auto_record' => ['sometimes', 'boolean'],
'live_captions' => ['sometimes', 'boolean'],
]);
$this->templates->create($request->user(), $organization, array_merge($validated, [
'settings' => [
'waiting_room' => $request->boolean('waiting_room', true),
'auto_record' => $request->boolean('auto_record'),
'live_captions' => $request->boolean('live_captions'),
],
]));
return redirect()->route('meet.templates.index')->with('success', 'Template saved.');
}
}
@@ -0,0 +1,64 @@
<?php
namespace App\Http\Controllers\Meet;
use App\Http\Controllers\Controller;
use App\Http\Controllers\Meet\Concerns\ScopesToAccount;
use App\Models\WebhookEndpoint;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\View\View;
class WebhookSettingsController extends Controller
{
use ScopesToAccount;
public function index(Request $request): View
{
$this->authorizeAbility($request, 'webhooks.manage');
$organization = $this->organization($request);
$endpoints = WebhookEndpoint::where('organization_id', $organization->id)
->orderByDesc('created_at')
->get();
return view('meet.settings.webhooks', [
'organization' => $organization,
'endpoints' => $endpoints,
'events' => config('meet.webhook_events'),
]);
}
public function store(Request $request): RedirectResponse
{
$this->authorizeAbility($request, 'webhooks.manage');
$organization = $this->organization($request);
$validated = $request->validate([
'url' => ['required', 'url', 'max:500'],
'secret' => ['nullable', 'string', 'max:255'],
'events' => ['required', 'array'],
'events.*' => ['string'],
]);
WebhookEndpoint::create([
'owner_ref' => $this->ownerRef($request),
'organization_id' => $organization->id,
'url' => $validated['url'],
'secret' => $validated['secret'] ?? null,
'events' => $validated['events'],
'is_active' => true,
]);
return redirect()->route('meet.settings.webhooks')->with('success', 'Webhook endpoint added.');
}
public function destroy(Request $request, WebhookEndpoint $webhook): RedirectResponse
{
$this->authorizeAbility($request, 'webhooks.manage');
abort_unless($webhook->organization_id === $this->organization($request)->id, 403);
$webhook->delete();
return redirect()->route('meet.settings.webhooks')->with('success', 'Webhook removed.');
}
}
@@ -0,0 +1,88 @@
<?php
namespace App\Http\Controllers\Meet;
use App\Http\Controllers\Controller;
use App\Models\BreakoutRoom;
use App\Models\MeetPoll;
use App\Models\QaQuestion;
use App\Models\Room;
use App\Models\Session;
use App\Models\SessionFile;
use App\Models\Whiteboard;
use App\Services\Meet\BreakoutService;
use App\Services\Meet\FileSharingService;
use App\Services\Meet\PollService;
use App\Services\Meet\QaService;
use App\Services\Meet\WebinarService;
use App\Services\Meet\WhiteboardService;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\View\View;
use Symfony\Component\HttpFoundation\StreamedResponse;
class WebinarRegistrationController extends Controller
{
public function __construct(
protected WebinarService $webinars,
) {}
public function show(Room $room): View
{
abort_unless($room->isWebinar(), 404);
return view('meet.webinar.register', compact('room'));
}
public function store(Request $request, Room $room): View
{
abort_unless($room->isWebinar(), 404);
$validated = $request->validate([
'email' => ['required', 'email'],
'display_name' => ['required', 'string', 'max:100'],
]);
$registration = $this->webinars->register(
$room,
$validated['email'],
$validated['display_name'],
);
return view('meet.webinar.register-done', compact('room', 'registration'));
}
public function manage(Request $request, Room $room): View
{
$this->authorizeWebinarHost($request, $room);
$registrations = $room->webinarRegistrations()->orderByDesc('created_at')->get();
return view('meet.webinar.registrations', compact('room', 'registrations'));
}
public function approve(Request $request, Room $room, \App\Models\WebinarRegistration $registration): RedirectResponse
{
$this->authorizeWebinarHost($request, $room);
abort_unless($registration->room_id === $room->id, 404);
$this->webinars->approve($registration, $request->user());
return back()->with('success', 'Registration approved.');
}
public function reject(Request $request, Room $room, \App\Models\WebinarRegistration $registration): RedirectResponse
{
$this->authorizeWebinarHost($request, $room);
abort_unless($registration->room_id === $room->id, 404);
$this->webinars->reject($registration);
return back()->with('success', 'Registration rejected.');
}
protected function authorizeWebinarHost(Request $request, Room $room): void
{
abort_unless($request->user()?->ownerRef() === $room->host_user_ref, 403);
}
}