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:
@@ -0,0 +1,35 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Api;
|
||||
|
||||
use App\Events\ServiceEventOccurred;
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Services\Events\ServiceEventSignature;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Cache;
|
||||
|
||||
class ServiceEventController extends Controller
|
||||
{
|
||||
public function __invoke(Request $request): JsonResponse
|
||||
{
|
||||
$secret = (string) config('service_events.inbound_secret');
|
||||
$signature = (string) $request->header('X-Ladill-Signature', '');
|
||||
|
||||
if (! ServiceEventSignature::verify($request->getContent(), $signature, $secret)) {
|
||||
return response()->json(['error' => 'invalid signature'], 401);
|
||||
}
|
||||
|
||||
$eventId = (string) $request->header('X-Ladill-Event-Id', (string) $request->input('id', ''));
|
||||
if ($eventId !== '') {
|
||||
if (Cache::has("svcevt:{$eventId}")) {
|
||||
return response()->json(['status' => 'duplicate']);
|
||||
}
|
||||
Cache::put("svcevt:{$eventId}", true, now()->addDay());
|
||||
}
|
||||
|
||||
event(new ServiceEventOccurred((string) $request->input('event'), (array) $request->input('data', [])));
|
||||
|
||||
return response()->json(['status' => 'accepted']);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Api\V1;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Http\Controllers\Meet\Concerns\ScopesToAccount;
|
||||
use App\Models\Room;
|
||||
use App\Services\Meet\RoomService;
|
||||
use App\Services\Meet\SessionService;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
|
||||
class RoomController extends Controller
|
||||
{
|
||||
use ScopesToAccount;
|
||||
|
||||
public function __construct(
|
||||
protected RoomService $rooms,
|
||||
protected SessionService $sessions,
|
||||
) {}
|
||||
|
||||
public function index(Request $request): JsonResponse
|
||||
{
|
||||
$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 response()->json($rooms);
|
||||
}
|
||||
|
||||
public function store(Request $request): JsonResponse
|
||||
{
|
||||
$this->authorizeAbility($request, 'meetings.create');
|
||||
$organization = $this->organization($request);
|
||||
|
||||
$validated = $request->validate([
|
||||
'title' => ['required', 'string', 'max:255'],
|
||||
'description' => ['nullable', 'string'],
|
||||
'scheduled_at' => ['nullable', 'date'],
|
||||
'passcode' => ['nullable', 'string', 'max:20'],
|
||||
'settings' => ['nullable', 'array'],
|
||||
'source' => ['nullable', 'array'],
|
||||
]);
|
||||
|
||||
$room = empty($validated['scheduled_at'])
|
||||
? $this->rooms->createInstant($request->user(), $organization, $validated)
|
||||
: $this->rooms->createScheduled($request->user(), $organization, $validated);
|
||||
|
||||
return response()->json([
|
||||
'room' => $room,
|
||||
'join_url' => $room->joinUrl(),
|
||||
], 201);
|
||||
}
|
||||
|
||||
public function show(Request $request, Room $room): JsonResponse
|
||||
{
|
||||
$this->authorizeAbility($request, 'meetings.view');
|
||||
$this->authorizeOwner($request, $room);
|
||||
|
||||
return response()->json([
|
||||
'room' => $room->load('sessions'),
|
||||
'join_url' => $room->joinUrl(),
|
||||
]);
|
||||
}
|
||||
|
||||
public function start(Request $request, Room $room): JsonResponse
|
||||
{
|
||||
$this->authorizeAbility($request, 'meetings.host');
|
||||
$this->authorizeOwner($request, $room);
|
||||
|
||||
$session = $this->sessions->start($room, $request->user());
|
||||
|
||||
return response()->json([
|
||||
'session' => $session,
|
||||
'room_url' => route('meet.room', $session),
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Api\V1;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\Organization;
|
||||
use App\Models\Room;
|
||||
use App\Models\User;
|
||||
use App\Services\Integrations\WebhookDispatcher;
|
||||
use App\Services\Meet\CalendarService;
|
||||
use App\Services\Meet\InvitationService;
|
||||
use App\Services\Meet\RoomService;
|
||||
use App\Services\Meet\SessionService;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
|
||||
class ServiceRoomController extends Controller
|
||||
{
|
||||
public function __construct(
|
||||
protected RoomService $rooms,
|
||||
protected SessionService $sessions,
|
||||
protected InvitationService $invitations,
|
||||
protected CalendarService $calendar,
|
||||
protected WebhookDispatcher $webhooks,
|
||||
) {}
|
||||
|
||||
public function store(Request $request): JsonResponse
|
||||
{
|
||||
$validated = $request->validate([
|
||||
'owner_ref' => ['required', 'string'],
|
||||
'organization_id' => ['required', 'integer', 'exists:meet_organizations,id'],
|
||||
'host_user_ref' => ['required', 'string'],
|
||||
'title' => ['required', 'string', 'max:255'],
|
||||
'description' => ['nullable', 'string'],
|
||||
'scheduled_at' => ['nullable', 'date'],
|
||||
'duration_minutes' => ['nullable', 'integer', 'min:15', 'max:480'],
|
||||
'passcode' => ['nullable', 'string', 'max:20'],
|
||||
'settings' => ['nullable', 'array'],
|
||||
'source' => ['required', 'array'],
|
||||
'source.app' => ['required', 'string'],
|
||||
'source.entity_type' => ['nullable', 'string'],
|
||||
'source.entity_id' => ['nullable', 'string'],
|
||||
'invite_emails' => ['nullable', 'array'],
|
||||
'invite_emails.*' => ['email'],
|
||||
]);
|
||||
|
||||
$organization = Organization::findOrFail($validated['organization_id']);
|
||||
abort_unless($organization->owner_ref === $validated['owner_ref'], 403);
|
||||
|
||||
$host = User::where('public_id', $validated['host_user_ref'])->firstOrFail();
|
||||
|
||||
$room = empty($validated['scheduled_at'])
|
||||
? $this->rooms->createInstant($host, $organization, $validated)
|
||||
: $this->rooms->createScheduled($host, $organization, $validated);
|
||||
|
||||
if (! empty($validated['invite_emails'])) {
|
||||
$invites = collect($validated['invite_emails'])->map(fn ($email) => ['email' => $email])->all();
|
||||
$this->invitations->inviteToRoom($room, $invites, $host);
|
||||
}
|
||||
|
||||
if ($room->scheduled_at) {
|
||||
$this->calendar->syncRoomCreated($room, $host);
|
||||
}
|
||||
|
||||
$this->webhooks->roomCreated($room);
|
||||
|
||||
return response()->json([
|
||||
'room' => $room->fresh(),
|
||||
'join_url' => $room->joinUrl(),
|
||||
'embed_url' => route('meet.join', $room),
|
||||
], 201);
|
||||
}
|
||||
|
||||
public function show(Room $room): JsonResponse
|
||||
{
|
||||
return response()->json([
|
||||
'room' => $room->load('sessions'),
|
||||
'join_url' => $room->joinUrl(),
|
||||
'embed_url' => route('meet.join', $room),
|
||||
]);
|
||||
}
|
||||
|
||||
public function start(Request $request, Room $room): JsonResponse
|
||||
{
|
||||
$host = User::where('public_id', $request->input('host_user_ref', $room->host_user_ref))->firstOrFail();
|
||||
abort_unless($room->host_user_ref === $host->ownerRef(), 403);
|
||||
|
||||
$session = $this->sessions->start($room, $host);
|
||||
|
||||
return response()->json([
|
||||
'session' => $session,
|
||||
'room_url' => route('meet.room', $session),
|
||||
'join_url' => $room->joinUrl(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,293 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Auth;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\User;
|
||||
use Illuminate\Http\Client\Response as HttpResponse;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Http\Response;
|
||||
use Illuminate\Support\Facades\Auth;
|
||||
use Illuminate\Support\Facades\Http;
|
||||
use Illuminate\Support\Str;
|
||||
use Illuminate\View\View;
|
||||
|
||||
/**
|
||||
* "Sign in with Ladill" — Authorization Code + PKCE against auth.ladill.com.
|
||||
* Establishes a local session + thin user mirror keyed by the OIDC `sub`.
|
||||
*/
|
||||
class SsoLoginController extends Controller
|
||||
{
|
||||
/**
|
||||
* Max consecutive failed callbacks before we stop bouncing back into the
|
||||
* authorize flow and show an error page instead (prevents an infinite
|
||||
* /sso/callback ↔ /sso/connect redirect loop on a persistent upstream error).
|
||||
*/
|
||||
private const MAX_SSO_ATTEMPTS = 3;
|
||||
|
||||
public function connect(Request $request): RedirectResponse|View
|
||||
{
|
||||
$intended = (string) $request->query('redirect', route('meet.dashboard'));
|
||||
|
||||
if (Auth::check()) {
|
||||
return $this->safeRedirect($intended, route('meet.dashboard'));
|
||||
}
|
||||
|
||||
if (! $request->boolean('fallback')) {
|
||||
$request->session()->forget('sso.attempts');
|
||||
}
|
||||
|
||||
if ($this->attemptSilentRefresh($request, $intended)) {
|
||||
return $this->safeRedirect($intended, route('meet.dashboard'));
|
||||
}
|
||||
|
||||
$verifier = Str::random(64);
|
||||
$state = Str::random(40);
|
||||
$request->session()->put('sso.verifier', $verifier);
|
||||
$request->session()->put('sso.state', $state);
|
||||
$request->session()->put('sso.intended', $intended);
|
||||
|
||||
$challenge = rtrim(strtr(base64_encode(hash('sha256', $verifier, true)), '+/', '-_'), '=');
|
||||
|
||||
$query = [
|
||||
'response_type' => 'code',
|
||||
'client_id' => (string) config('services.ladill_sso.client_id'),
|
||||
'redirect_uri' => (string) config('services.ladill_sso.redirect'),
|
||||
'scope' => 'openid profile email',
|
||||
'state' => $state,
|
||||
'code_challenge' => $challenge,
|
||||
'code_challenge_method' => 'S256',
|
||||
];
|
||||
|
||||
$loginHint = (string) $request->session()->get('sso.login_hint', '');
|
||||
if ($loginHint !== '') {
|
||||
$query['login_hint'] = $loginHint;
|
||||
}
|
||||
|
||||
if (! $request->boolean('interactive')) {
|
||||
$query['prompt'] = 'none';
|
||||
}
|
||||
|
||||
$authorizeUrl = rtrim((string) config('services.ladill_sso.issuer'), '/').'/oauth/authorize?'.http_build_query($query);
|
||||
|
||||
return redirect()->away($authorizeUrl);
|
||||
}
|
||||
|
||||
public function callback(Request $request): RedirectResponse
|
||||
{
|
||||
$intended = (string) $request->session()->get('sso.intended', route('meet.dashboard'));
|
||||
|
||||
if ($request->filled('error')) {
|
||||
if (in_array($request->query('error'), ['login_required', 'interaction_required', 'consent_required'], true)
|
||||
&& ! $request->boolean('interactive')) {
|
||||
return redirect()->away((string) config('ladill.marketing_url'));
|
||||
}
|
||||
|
||||
return $this->finishCallback($request, $intended, (string) $request->query('error_description', $request->query('error')));
|
||||
}
|
||||
|
||||
if (! $request->filled('code')
|
||||
|| $request->query('state') !== $request->session()->pull('sso.state')) {
|
||||
return $this->finishCallback($request, $intended, 'invalid_state');
|
||||
}
|
||||
|
||||
$issuer = rtrim((string) config('services.ladill_sso.issuer'), '/');
|
||||
|
||||
$tokenRes = Http::asForm()->post($issuer.'/oauth/token', [
|
||||
'grant_type' => 'authorization_code',
|
||||
'client_id' => (string) config('services.ladill_sso.client_id'),
|
||||
'client_secret' => (string) config('services.ladill_sso.client_secret'),
|
||||
'redirect_uri' => (string) config('services.ladill_sso.redirect'),
|
||||
'code' => (string) $request->query('code'),
|
||||
'code_verifier' => (string) $request->session()->pull('sso.verifier'),
|
||||
]);
|
||||
if ($tokenRes->failed()) {
|
||||
return $this->finishCallback($request, $intended, 'token_exchange_failed');
|
||||
}
|
||||
|
||||
$user = $this->loginFromTokenResponse($request, $tokenRes);
|
||||
if (! $user) {
|
||||
return $this->finishCallback($request, $intended, 'userinfo_failed');
|
||||
}
|
||||
|
||||
Auth::login($user, remember: true);
|
||||
$request->session()->regenerate();
|
||||
$request->session()->forget('sso.attempts');
|
||||
|
||||
return $this->finishCallback($request, $intended, null);
|
||||
}
|
||||
|
||||
public function failed(Request $request): View
|
||||
{
|
||||
return view('auth.sso-error', [
|
||||
'reason' => (string) $request->session()->get('sso.error', ''),
|
||||
]);
|
||||
}
|
||||
|
||||
public function logout(Request $request): RedirectResponse
|
||||
{
|
||||
Auth::logout();
|
||||
$request->session()->invalidate();
|
||||
$request->session()->regenerateToken();
|
||||
|
||||
// Per-app sign-out: end only this app's session and keep the platform
|
||||
// (auth.ladill.com) SSO session alive so "Sign in again" re-auths silently.
|
||||
return redirect()->route('meet.signed-out');
|
||||
}
|
||||
|
||||
/** Platform session ended — clear this app and offer silent sign-in again. */
|
||||
public function platformSignedOut(Request $request): RedirectResponse
|
||||
{
|
||||
Auth::logout();
|
||||
$request->session()->invalidate();
|
||||
$request->session()->regenerateToken();
|
||||
|
||||
return redirect()->route('sso.connect', [
|
||||
'redirect' => (string) $request->query('redirect', ''),
|
||||
]);
|
||||
}
|
||||
|
||||
public function logoutBridge(Request $request): View
|
||||
{
|
||||
$return = $this->safeReturnUrl((string) $request->query('return', ''));
|
||||
$hubUrl = 'https://'.config('app.auth_domain').'/logout/sso/hub?'.http_build_query([
|
||||
'embedded' => 1,
|
||||
'return' => $return,
|
||||
]);
|
||||
|
||||
return view('auth.sso-logout-bridge', [
|
||||
'hubUrl' => $hubUrl,
|
||||
'return' => $return,
|
||||
'authOrigin' => 'https://'.config('app.auth_domain'),
|
||||
]);
|
||||
}
|
||||
|
||||
public function frontchannelLogout(Request $request): Response|RedirectResponse
|
||||
{
|
||||
Auth::logout();
|
||||
$request->session()->invalidate();
|
||||
$request->session()->regenerateToken();
|
||||
|
||||
$return = (string) $request->query('return', '');
|
||||
$root = (string) config('app.platform_domain', 'ladill.com');
|
||||
$host = parse_url($return, PHP_URL_HOST);
|
||||
if (str_starts_with($return, 'https://') && is_string($host) && ($host === $root || str_ends_with($host, '.'.$root))) {
|
||||
return redirect()->away($return);
|
||||
}
|
||||
|
||||
return response('', 204);
|
||||
}
|
||||
|
||||
private function attemptSilentRefresh(Request $request, string $intended): bool
|
||||
{
|
||||
$refreshToken = (string) $request->session()->get('sso.refresh_token', '');
|
||||
if ($refreshToken === '') {
|
||||
return false;
|
||||
}
|
||||
|
||||
$issuer = rtrim((string) config('services.ladill_sso.issuer'), '/');
|
||||
$tokenRes = Http::asForm()->post($issuer.'/oauth/token', [
|
||||
'grant_type' => 'refresh_token',
|
||||
'refresh_token' => $refreshToken,
|
||||
'client_id' => (string) config('services.ladill_sso.client_id'),
|
||||
'client_secret' => (string) config('services.ladill_sso.client_secret'),
|
||||
'scope' => 'openid profile email',
|
||||
]);
|
||||
|
||||
if ($tokenRes->failed()) {
|
||||
$request->session()->forget('sso.refresh_token');
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
$user = $this->loginFromTokenResponse($request, $tokenRes);
|
||||
|
||||
if (! $user) {
|
||||
return false;
|
||||
}
|
||||
|
||||
Auth::login($user, remember: true);
|
||||
$request->session()->put('sso.intended', $intended);
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
private function loginFromTokenResponse(Request $request, HttpResponse $tokenRes): ?User
|
||||
{
|
||||
$refreshToken = (string) $tokenRes->json('refresh_token', '');
|
||||
if ($refreshToken !== '') {
|
||||
$request->session()->put('sso.refresh_token', $refreshToken);
|
||||
}
|
||||
|
||||
$issuer = rtrim((string) config('services.ladill_sso.issuer'), '/');
|
||||
$claims = Http::withToken((string) $tokenRes->json('access_token'))->acceptJson()->get($issuer.'/oauth/userinfo');
|
||||
if ($claims->failed() || ! $claims->json('sub')) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$email = (string) ($claims->json('email') ?: '');
|
||||
if ($email !== '') {
|
||||
$request->session()->put('sso.login_hint', $email);
|
||||
}
|
||||
|
||||
return User::updateOrCreate(
|
||||
['public_id' => (string) $claims->json('sub')],
|
||||
[
|
||||
'name' => $claims->json('name'),
|
||||
'email' => $email !== '' ? $email : (string) $claims->json('sub').'@users.ladill.com',
|
||||
'avatar_url' => $claims->json('picture'),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
private function finishCallback(Request $request, string $intended, ?string $error = null): RedirectResponse
|
||||
{
|
||||
if ($error) {
|
||||
$attempts = (int) $request->session()->get('sso.attempts', 0) + 1;
|
||||
|
||||
if ($attempts >= self::MAX_SSO_ATTEMPTS) {
|
||||
$request->session()->forget(['sso.attempts', 'sso.state', 'sso.verifier', 'sso.intended']);
|
||||
$request->session()->flash('sso.error', $error);
|
||||
|
||||
return redirect()->route('sso.failed');
|
||||
}
|
||||
|
||||
$request->session()->put('sso.attempts', $attempts);
|
||||
|
||||
return redirect()->route('sso.connect', [
|
||||
'redirect' => $intended,
|
||||
'interactive' => 1,
|
||||
'fallback' => 1,
|
||||
]);
|
||||
}
|
||||
|
||||
return $this->safeRedirect($intended, route('meet.dashboard'));
|
||||
}
|
||||
|
||||
private function safeReturnUrl(string $url): string
|
||||
{
|
||||
$host = parse_url($url, PHP_URL_HOST);
|
||||
$root = (string) config('app.platform_domain', 'ladill.com');
|
||||
|
||||
if (is_string($host) && str_starts_with($url, 'https://')
|
||||
&& ($host === $root || str_ends_with($host, '.'.$root))) {
|
||||
return $url;
|
||||
}
|
||||
|
||||
return route('meet.signed-out');
|
||||
}
|
||||
|
||||
private function safeRedirect(string $url, string $fallback): RedirectResponse
|
||||
{
|
||||
$host = parse_url($url, PHP_URL_HOST);
|
||||
$root = (string) config('app.platform_domain', 'ladill.com');
|
||||
|
||||
if (is_string($host) && str_starts_with($url, 'https://')
|
||||
&& ($host === $root || str_ends_with($host, '.'.$root))) {
|
||||
return redirect()->away($url);
|
||||
}
|
||||
|
||||
return redirect()->away($fallback);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
|
||||
|
||||
abstract class Controller
|
||||
{
|
||||
use AuthorizesRequests;
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\View\View;
|
||||
|
||||
class NotificationController extends Controller
|
||||
{
|
||||
public function index(Request $request): View
|
||||
{
|
||||
$notifications = $request->user()
|
||||
->notifications()
|
||||
->latest()
|
||||
->paginate(20);
|
||||
|
||||
return view('notifications.index', compact('notifications'));
|
||||
}
|
||||
|
||||
public function unread(Request $request): JsonResponse
|
||||
{
|
||||
$notifications = $request->user()
|
||||
->unreadNotifications()
|
||||
->latest()
|
||||
->take(10)
|
||||
->get()
|
||||
->map(fn ($n) => [
|
||||
'id' => $n->id,
|
||||
'type' => class_basename($n->type),
|
||||
'title' => $n->data['title'] ?? 'Notification',
|
||||
'message' => $n->data['message'] ?? '',
|
||||
'icon' => $n->data['icon'] ?? 'bell',
|
||||
'url' => $n->data['url'] ?? null,
|
||||
'created_at' => $n->created_at->diffForHumans(),
|
||||
]);
|
||||
|
||||
return response()->json([
|
||||
'notifications' => $notifications,
|
||||
'unread_count' => $request->user()->unreadNotifications()->count(),
|
||||
]);
|
||||
}
|
||||
|
||||
public function markAsRead(Request $request, string $id): JsonResponse
|
||||
{
|
||||
$notification = $request->user()
|
||||
->notifications()
|
||||
->where('id', $id)
|
||||
->first();
|
||||
|
||||
if ($notification) {
|
||||
$notification->markAsRead();
|
||||
}
|
||||
|
||||
return response()->json(['success' => true]);
|
||||
}
|
||||
|
||||
public function markAllAsRead(Request $request): JsonResponse
|
||||
{
|
||||
$request->user()->unreadNotifications->markAsRead();
|
||||
|
||||
return response()->json(['success' => true]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Middleware;
|
||||
|
||||
use Closure;
|
||||
use Illuminate\Http\Request;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
|
||||
/**
|
||||
* Generic service-to-service auth for internal APIs. Validates the bearer token
|
||||
* against per-consumer keys in config("{namespace}.service_api_keys") with a
|
||||
* constant-time compare, and records the caller. Usage: auth.service:crm.
|
||||
*/
|
||||
class AuthenticateService
|
||||
{
|
||||
public function handle(Request $request, Closure $next, string $namespace = 'crm'): Response
|
||||
{
|
||||
$token = (string) $request->bearerToken();
|
||||
$caller = null;
|
||||
|
||||
if ($token !== '') {
|
||||
foreach ((array) config("{$namespace}.service_api_keys", []) as $name => $key) {
|
||||
if (is_string($key) && $key !== '' && hash_equals($key, $token)) {
|
||||
$caller = $name;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if ($caller === null) {
|
||||
return response()->json(['error' => 'Unauthorized.'], 401);
|
||||
}
|
||||
|
||||
$request->attributes->set('service_caller', $caller);
|
||||
|
||||
return $next($request);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Middleware;
|
||||
|
||||
use App\Services\Meet\MeetPermissions;
|
||||
use App\Services\Meet\OrganizationResolver;
|
||||
use Closure;
|
||||
use Illuminate\Http\Request;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
|
||||
class EnsureMeetAbility
|
||||
{
|
||||
public function __construct(
|
||||
protected OrganizationResolver $organizations,
|
||||
protected MeetPermissions $permissions,
|
||||
) {}
|
||||
|
||||
public function handle(Request $request, Closure $next, string $ability): Response
|
||||
{
|
||||
$user = $request->user();
|
||||
abort_unless($user, 403);
|
||||
|
||||
$organization = $this->organizations->resolveForUser($user);
|
||||
abort_unless($organization, 403);
|
||||
|
||||
$member = $this->organizations->memberFor($user, $organization);
|
||||
abort_unless($this->permissions->can($member, $ability), 403);
|
||||
|
||||
$request->attributes->set('meet.organization', $organization);
|
||||
$request->attributes->set('meet.member', $member);
|
||||
|
||||
return $next($request);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Middleware;
|
||||
|
||||
use App\Services\Meet\OrganizationResolver;
|
||||
use Closure;
|
||||
use Illuminate\Http\Request;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
|
||||
class EnsureOrganizationSetup
|
||||
{
|
||||
public function __construct(
|
||||
protected OrganizationResolver $organizations,
|
||||
) {}
|
||||
|
||||
public function handle(Request $request, Closure $next): Response
|
||||
{
|
||||
$user = $request->user();
|
||||
if (! $user) {
|
||||
return $next($request);
|
||||
}
|
||||
|
||||
if ($request->routeIs('meet.onboarding*')) {
|
||||
return $next($request);
|
||||
}
|
||||
|
||||
if (! $this->organizations->isOnboarded($user)) {
|
||||
return redirect()->route('meet.onboarding.show');
|
||||
}
|
||||
|
||||
return $next($request);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Middleware;
|
||||
|
||||
use Closure;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Auth;
|
||||
use Illuminate\Support\Facades\Http;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
|
||||
/**
|
||||
* App sessions are subordinate to the platform (auth.ladill.com) session.
|
||||
* When the platform session ends, clear this app's local session too.
|
||||
*
|
||||
* Re-checks auth.ladill.com at most once per TTL — not on every request.
|
||||
* Client-side sso-keepalive still pings periodically between checks.
|
||||
*/
|
||||
class EnsurePlatformSession
|
||||
{
|
||||
private const VERIFY_TTL_SECONDS = 90;
|
||||
|
||||
public function handle(Request $request, Closure $next): Response
|
||||
{
|
||||
if (! Auth::check()) {
|
||||
return $next($request);
|
||||
}
|
||||
|
||||
$authDomain = trim((string) config('app.auth_domain', ''));
|
||||
if ($authDomain === '') {
|
||||
return $next($request);
|
||||
}
|
||||
|
||||
$verifiedAt = (int) $request->session()->get('platform_session_verified_at', 0);
|
||||
if ($verifiedAt > 0 && (time() - $verifiedAt) < self::VERIFY_TTL_SECONDS) {
|
||||
return $next($request);
|
||||
}
|
||||
|
||||
$cookieHeader = (string) $request->headers->get('Cookie', '');
|
||||
if ($cookieHeader === '') {
|
||||
return $this->clearAppSession($request);
|
||||
}
|
||||
|
||||
try {
|
||||
$response = Http::withHeaders(['Cookie' => $cookieHeader])
|
||||
->timeout(3)
|
||||
->connectTimeout(2)
|
||||
->get('https://'.$authDomain.'/sso/ping');
|
||||
} catch (\Throwable) {
|
||||
return $next($request);
|
||||
}
|
||||
|
||||
if ($response->status() === 401) {
|
||||
return $this->clearAppSession($request);
|
||||
}
|
||||
|
||||
if ($response->successful()) {
|
||||
$request->session()->put('platform_session_verified_at', time());
|
||||
}
|
||||
|
||||
return $next($request);
|
||||
}
|
||||
|
||||
private function clearAppSession(Request $request): Response
|
||||
{
|
||||
Auth::logout();
|
||||
$request->session()->invalidate();
|
||||
$request->session()->regenerateToken();
|
||||
|
||||
return redirect()->route('sso.connect', [
|
||||
'redirect' => $request->fullUrl(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Middleware;
|
||||
|
||||
use Closure;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\View;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
|
||||
/**
|
||||
* Shares the signed-in account with views. CRM data is scoped per account by
|
||||
* the user's public_id (owner_ref); today every user acts as their own
|
||||
* account (team support graduates here later, like the platform pattern).
|
||||
*/
|
||||
class SetActingAccount
|
||||
{
|
||||
public function handle(Request $request, Closure $next): Response
|
||||
{
|
||||
if ($user = $request->user()) {
|
||||
$request->attributes->set('actingAccount', $user);
|
||||
|
||||
if (! $request->is('api/*')) {
|
||||
View::share('actingAccount', $user);
|
||||
}
|
||||
}
|
||||
|
||||
return $next($request);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user