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

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

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
isaacclad
2026-06-30 23:35:29 +00:00
co-authored by Cursor
commit 965fb992e9
319 changed files with 30322 additions and 0 deletions
+18
View File
@@ -0,0 +1,18 @@
root = true
[*]
charset = utf-8
end_of_line = lf
indent_size = 4
indent_style = space
insert_final_newline = true
trim_trailing_whitespace = true
[*.md]
trim_trailing_whitespace = false
[*.{yml,yaml}]
indent_size = 2
[compose.yaml]
indent_size = 4
+72
View File
@@ -0,0 +1,72 @@
APP_NAME="Ladill Meet"
APP_ENV=local
APP_KEY=
APP_DEBUG=true
APP_URL=https://meet.ladill.com
PLATFORM_URL=https://ladill.com
PLATFORM_DOMAIN=ladill.com
LOG_CHANNEL=stack
LOG_LEVEL=debug
DB_CONNECTION=sqlite
DB_DATABASE=database/database.sqlite
# Production: DB_CONNECTION=mysql, DB_HOST=127.0.0.1, DB_DATABASE=ladill_meet, DB_USERNAME=ladill_meet
REDIS_CLIENT=phpredis
REDIS_HOST=127.0.0.1
REDIS_PASSWORD=null
REDIS_PORT=6379
SESSION_DRIVER=redis
SESSION_LIFETIME=1440
CACHE_STORE=redis
QUEUE_CONNECTION=database
# --- Ladill SSO (Sign in with Ladill — auth.ladill.com OIDC client) ---
LADILL_SSO_CLIENT_ID=
LADILL_SSO_CLIENT_SECRET=
# --- Platform APIs this app consumes (per-consumer service keys) ---
BILLING_API_URL=https://ladill.com/api/billing
BILLING_API_KEY_MEET=
IDENTITY_API_URL=https://ladill.com/api
IDENTITY_API_KEY_MEET=
# Platform events webhook (user/org lifecycle from the monolith)
SERVICE_EVENTS_INBOUND_SECRET=
# --- LiveKit (WebRTC SFU) ---
LIVEKIT_URL=wss://your-livekit-server.example.com
LIVEKIT_API_KEY=
LIVEKIT_API_SECRET=
# --- Recordings & AI ---
MEET_RECORDINGS_DISK=local
MEET_AI_DRIVER=openai
MEET_AI_API_KEY=
MEET_AI_MODEL=gpt-4o-mini
MEET_AI_PLATFORM_URL=
MEET_AI_PLATFORM_KEY=
# --- Inbound service API keys (sibling Ladill apps calling Meet) ---
MEET_API_KEY_CARE=
MEET_API_KEY_CRM=
MEET_API_KEY_BOOKINGS=
MEET_API_KEY_EVENTS=
MEET_API_KEY_FRONTDESK=
MEET_API_KEY_HR=
MEET_API_KEY_SUPPORT=
# --- Calendar OAuth (optional) ---
GOOGLE_CALENDAR_CLIENT_ID=
GOOGLE_CALENDAR_CLIENT_SECRET=
GOOGLE_CALENDAR_REDIRECT_URI=https://meet.ladill.com/settings/calendar/callback
MICROSOFT_CALENDAR_CLIENT_ID=
MICROSOFT_CALENDAR_CLIENT_SECRET=
MICROSOFT_CALENDAR_REDIRECT_URI=https://meet.ladill.com/settings/calendar/callback
MEET_FILES_DISK=local
MEET_FILES_EXPIRY_DAYS=30
LADILL_DRIVE_API_URL=
LADILL_DRIVE_API_KEY_MEET=
+11
View File
@@ -0,0 +1,11 @@
* text=auto eol=lf
*.blade.php diff=html
*.css diff=css
*.html diff=html
*.md diff=markdown
*.php diff=php
/.github export-ignore
CHANGELOG.md export-ignore
.styleci.yml export-ignore
+110
View File
@@ -0,0 +1,110 @@
name: Deploy Ladill Meet
on:
push:
branches:
- main
workflow_dispatch:
permissions:
contents: read
concurrency:
group: deploy-meet
cancel-in-progress: true
jobs:
deploy:
runs-on: deploy
env:
NODE_ROOT: /tmp/ladill-node-22-r1
NODE_VERSION: "22.14.0"
RELEASE_ARCHIVE: /tmp/ladill-meet-release-${{ gitea.run_id }}-${{ gitea.run_attempt }}.tgz
WORKSPACE: /tmp/${{ gitea.repository_owner }}-meet-${{ gitea.run_id }}-${{ gitea.run_attempt }}
LADILL_APP_ROOT: /var/www/ladill-meet
steps:
- name: Checkout
shell: bash {0}
run: |
set -Eeuo pipefail
DEPLOY_GITEA_TOKEN="$(cat /home/deploy/.ladill-deploy-gitea-token)"
REPO_URL="${{ gitea.server_url }}/${{ gitea.repository }}.git"
rm -rf "$WORKSPACE"
mkdir -p "$WORKSPACE"
export GIT_TERMINAL_PROMPT=0
git \
-c credential.helper= \
-c http.extraHeader="Authorization: token ${DEPLOY_GITEA_TOKEN}" \
clone --depth 1 --single-branch --no-tags --branch "${{ gitea.ref_name }}" \
"$REPO_URL" "$WORKSPACE"
- name: Setup Node.js
shell: bash {0}
run: |
set -Eeuo pipefail
if [ ! -x "$NODE_ROOT/bin/node" ]; then
rm -rf "$NODE_ROOT"
mkdir -p "$NODE_ROOT"
case "$(uname -m)" in
x86_64|amd64) NODE_ARCH="x64" ;;
aarch64|arm64) NODE_ARCH="arm64" ;;
*) echo "Unsupported architecture: $(uname -m)" >&2; exit 1 ;;
esac
curl -fsSL "https://nodejs.org/download/release/v${NODE_VERSION}/node-v${NODE_VERSION}-linux-${NODE_ARCH}.tar.xz" \
| tar -xJ --strip-components=1 -C "$NODE_ROOT"
fi
"$NODE_ROOT/bin/node" -v
- name: Build frontend assets
shell: bash {0}
run: |
set -Eeuo pipefail
cd "$WORKSPACE"
export PATH="$NODE_ROOT/bin:$PATH"
npm ci --no-audit --no-fund
npm run build
- name: Build release archive
shell: bash {0}
run: |
set -Eeuo pipefail
cd "$WORKSPACE"
printf '%s\n' "${{ gitea.sha }}" > REVISION
rm -f "$RELEASE_ARCHIVE"
tar -czf "$RELEASE_ARCHIVE" \
--exclude=.git --exclude=.gitea --exclude=.github --exclude=.env \
--exclude=node_modules --exclude=vendor --exclude=storage --exclude=tests .
- name: Deploy release
shell: bash {0}
env:
LADILL_RELEASE_ARCHIVE: /tmp/ladill-meet-release-${{ gitea.run_id }}-${{ gitea.run_attempt }}.tgz
run: |
set -Eeuo pipefail
: "${LADILL_APP_ROOT:?Set LADILL_APP_ROOT}"
bash "$WORKSPACE/deploy/deploy.sh"
- name: Ensure nginx vhost
shell: bash {0}
run: |
set -Eeuo pipefail
NGINX_SCRIPT="$WORKSPACE/deployment/setup-service-subdomain-nginx.sh"
if [ ! -f "$NGINX_SCRIPT" ]; then
NGINX_SCRIPT="/var/www/ladill.com/current/deployment/setup-service-subdomain-nginx.sh"
fi
if [ ! -f "$NGINX_SCRIPT" ]; then
echo "WARN: setup-service-subdomain-nginx.sh not found — configure meet.ladill.com vhost manually"
exit 0
fi
if sudo -n bash "$NGINX_SCRIPT" meet --app /var/www/ladill-meet/current; then
echo "nginx vhost updated for meet.ladill.com"
else
echo "WARN: nginx vhost step skipped (deploy user cannot sudo) — vhost must already be provisioned"
fi
- name: Cleanup
if: always()
shell: bash {0}
run: |
rm -rf "$WORKSPACE"
rm -f "$RELEASE_ARCHIVE"
+31
View File
@@ -0,0 +1,31 @@
*.log
.DS_Store
.env
.env.backup
.env.production
.phpactor.json
.phpunit.result.cache
/.fleet
/.idea
/.nova
/.phpunit.cache
/.vscode
/.zed
/auth.json
/node_modules
/public/build
/public/hot
/public/storage
/storage/*.key
/storage/pail
/storage/framework/views/*
!/storage/framework/views/.gitignore
/vendor
Homestead.json
Homestead.yaml
Thumbs.db
# Native mobile apps — kept locally, not tracked in this repo
/apps
/database/database.sqlite
+86
View File
@@ -0,0 +1,86 @@
# Ladill Care — deploy runbook
Healthcare management at **care.ladill.com** (patients, appointments, consultations, lab, pharmacy, billing).
## 1. Gitea repo + CI
- Repo: **ladill-care** (`isaacclad/ladill-care`).
- Push to `main` triggers `.gitea/workflows/deploy.yml` (on-host `deploy` runner).
- App root: `/var/www/ladill-care`.
To publish from the monorepo copy:
```bash
bash scripts/extract-ladill-care.sh
```
## 2. Server app-slot + database
```bash
sudo install -d -o deploy -g www-data /var/www/ladill-care
sudo install -d -o deploy -g www-data /var/www/ladill-care/{releases,shared}
sudo chown -R deploy:www-data /var/www/ladill-care
sudo mysql -e "CREATE DATABASE ladill_care CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;"
sudo mysql -e "CREATE USER 'ladill_care'@'127.0.0.1' IDENTIFIED BY '<pw>';"
sudo mysql -e "GRANT ALL ON ladill_care.* TO 'ladill_care'@'127.0.0.1'; FLUSH PRIVILEGES;"
```
## 3. Platform wiring (monolith)
On the Ladill platform host:
```bash
cd /var/www/ladill.com/current
php artisan ladill:onboard-app care --run-dns
php artisan passport:client --name="Ladill Care" --redirect_uri=https://care.ladill.com/sso/callback
php artisan ladill:launcher:sync --propagate
```
Apply the generated `IDENTITY_API_KEY_CARE`, `BILLING_API_KEY_CARE`,
`SERVICE_EVENTS_CARE_*`, and `SERVICE_EVENTS_INBOUND_SECRET` values to the monolith `.env`, then:
```bash
php artisan config:cache
```
## 4. Shared `.env` (`/var/www/ladill-care/shared/.env`)
Copy `.env.example`, set production values:
| Variable | Notes |
|----------|-------|
| `APP_KEY` | `php artisan key:generate --show` |
| `APP_URL` | `https://care.ladill.com` |
| `DB_*` | MySQL `ladill_care` credentials |
| `LADILL_SSO_CLIENT_ID` / `LADILL_SSO_CLIENT_SECRET` | Passport client (plain text from `passport:client`) |
| `BILLING_API_KEY_CARE` | Same as monolith consumer key |
| `IDENTITY_API_KEY_CARE` | Same as monolith consumer key |
| `SERVICE_EVENTS_INBOUND_SECRET` | Same as monolith `SERVICE_EVENTS_CARE_SECRET` |
**SSO secret:** use the plain-text value printed once by `passport:client` — never copy
`oauth_clients.secret` from the database (Passport stores a bcrypt hash there).
## 5. nginx + TLS
```bash
sudo bash deployment/setup-service-subdomain-nginx.sh care --app /var/www/ladill-care/current
```
DNS: `care.ladill.com` → app server (via `dns:sync-subdomains` on the monolith).
## 6. First deploy
Push to `main` or run the Gitea deploy workflow manually. Then verify:
```bash
curl -sI https://care.ladill.com | head -1
curl -s -o /dev/null -w '%{http_code}\n' https://care.ladill.com/login
curl -s https://care.ladill.com/api/health
```
## 7. Optional queue worker
```bash
sudo cp deployment/supervisor/ladill-care-worker.conf /etc/supervisor/conf.d/
sudo supervisorctl reread && sudo supervisorctl update
```
+55
View File
@@ -0,0 +1,55 @@
# Ladill Care
Healthcare management for clinics and hospitals at **care.ladill.com** — patients,
appointments, queue, consultations, vitals, lab, prescriptions, pharmacy inventory,
encounter billing, and operational reports.
Authenticates via **Sign in with Ladill** (OIDC against `auth.ladill.com`). Every
clinical record is scoped to a platform account (`owner_ref`) and organization.
## Features
- **Patients** — registration, medical history, documents, visit timeline
- **Appointments & queue** — booking, walk-in, check-in, department queue
- **Consultations** — vitals, diagnoses, investigations, prescriptions
- **Laboratory** — test catalog, sample collection, results, approval
- **Pharmacy** — dispensing queue, drug inventory, batch stock tracking
- **Billing** — visit invoices, line items, payments, print view
- **Reports** — patients, appointments, lab, finance, clinical (CSV export)
- **Admin** — branches, departments, practitioners, members, audit log
## Platform integration
| Integration | Purpose |
|-------------|---------|
| OIDC SSO | User authentication (`LADILL_SSO_*`) |
| Billing API | Wallet top-up / purchases (`BILLING_API_KEY_CARE`) |
| Identity API | Platform user lookups (`IDENTITY_API_KEY_CARE`) |
| Service events | Inbound webhooks for `user.deleted`, `user.suspended`, `organization.updated` |
Registry entry lives in the monolith `config/ladill_apps.php` (`care` slug).
## Local development
```bash
composer install
cp .env.example .env && php artisan key:generate
touch database/database.sqlite # sqlite is fine for local
php artisan migrate
npm install && npm run build # or `npm run dev`
php artisan serve
php artisan test
```
For SSO locally, point `LADILL_SSO_*` at a Passport client on your Ladill platform
dev instance, or bypass `EnsurePlatformSession` in tests (see `CareWebTest`).
## Deployment
See [DEPLOY.md](DEPLOY.md) for production cutover (`/var/www/ladill-care`, Gitea CI,
nginx vhost, platform onboarding).
## API
REST API under `/api/v1` (Sanctum + organization setup middleware). Health check:
`GET /api/health`.
@@ -0,0 +1,34 @@
<?php
namespace App\Console\Commands;
use App\Models\Room;
use App\Models\User;
use App\Services\Meet\MeetNotificationService;
use Illuminate\Console\Command;
class SendMeetingReminders extends Command
{
protected $signature = 'meet:send-reminders';
protected $description = 'Send reminders for meetings starting within the next 15 minutes';
public function handle(MeetNotificationService $notifications): int
{
$rooms = Room::where('status', 'scheduled')
->whereBetween('scheduled_at', [now(), now()->addMinutes(15)])
->whereNull('deleted_at')
->get();
foreach ($rooms as $room) {
$host = User::where('public_id', $room->host_user_ref)->first();
if ($host) {
$notifications->sendReminder($room, $host);
}
}
$this->info('Sent '.$rooms->count().' reminder(s).');
return self::SUCCESS;
}
}
@@ -0,0 +1,13 @@
<?php
namespace App\Contracts\Printers;
use App\Models\Visit;
interface PrinterDriverInterface
{
public function name(): string;
/** @return array{format: string, content: string, filename?: string} */
public function renderBadge(Visit $visit): array;
}
+22
View File
@@ -0,0 +1,22 @@
<?php
namespace App\Events;
use Illuminate\Foundation\Events\Dispatchable;
use Illuminate\Queue\SerializesModels;
/**
* A platform domain event received via the inbound service-events webhook.
*/
class ServiceEventOccurred
{
use Dispatchable, SerializesModels;
/**
* @param array<string, mixed> $data
*/
public function __construct(
public readonly string $name,
public readonly array $data = [],
) {}
}
@@ -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);
}
}
+10
View File
@@ -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);
}
}
+34
View File
@@ -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(),
]);
}
}
+29
View File
@@ -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);
}
}
+27
View File
@@ -0,0 +1,27 @@
<?php
namespace App\Jobs;
use App\Models\Transcript;
use App\Services\Meet\MeetAiService;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Queue\Queueable;
class GenerateAiSummary implements ShouldQueue
{
use Queueable;
public function __construct(
public int $transcriptId,
) {}
public function handle(MeetAiService $ai): void
{
$transcript = Transcript::find($this->transcriptId);
if (! $transcript) {
return;
}
$ai->summarize($transcript);
}
}
+27
View File
@@ -0,0 +1,27 @@
<?php
namespace App\Jobs;
use App\Models\Recording;
use App\Services\Meet\RecordingService;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Queue\Queueable;
class ProcessRecording implements ShouldQueue
{
use Queueable;
public function __construct(
public int $recordingId,
) {}
public function handle(RecordingService $recordings): void
{
$recording = Recording::find($this->recordingId);
if (! $recording || $recording->status !== 'processing') {
return;
}
$recordings->process($recording);
}
}
@@ -0,0 +1,85 @@
<?php
namespace App\Listeners;
use App\Events\ServiceEventOccurred;
use App\Models\Member;
use App\Models\Organization;
use App\Models\User;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Log;
/**
* Reacts to platform lifecycle events pushed from the Ladill monolith.
*/
class PlatformServiceEventListener
{
public function handle(ServiceEventOccurred $event): void
{
match ($event->name) {
'user.deleted' => $this->handleUserDeleted($event),
'user.suspended' => $this->handleUserSuspended($event),
'organization.updated' => $this->handleOrganizationUpdated($event),
default => null,
};
}
protected function handleUserDeleted(ServiceEventOccurred $event): void
{
$publicId = (string) ($event->data['user'] ?? '');
if ($publicId === '') {
return;
}
DB::transaction(function () use ($publicId) {
Member::where('user_ref', $publicId)->delete();
User::where('public_id', $publicId)->delete();
});
Log::info('PlatformServiceEventListener: user deleted', ['public_id' => $publicId]);
}
protected function handleUserSuspended(ServiceEventOccurred $event): void
{
$publicId = (string) ($event->data['user'] ?? '');
if ($publicId === '') {
return;
}
$user = User::where('public_id', $publicId)->first();
if ($user) {
$user->tokens()->delete();
}
Log::info('PlatformServiceEventListener: user suspended', ['public_id' => $publicId]);
}
protected function handleOrganizationUpdated(ServiceEventOccurred $event): void
{
$ownerRef = (string) ($event->data['owner'] ?? $event->data['user'] ?? '');
if ($ownerRef === '') {
return;
}
$organization = Organization::owned($ownerRef)->first();
if (! $organization) {
return;
}
$updates = array_filter([
'name' => $event->data['name'] ?? null,
'timezone' => $event->data['timezone'] ?? null,
], fn ($value) => $value !== null && $value !== '');
if ($updates === []) {
return;
}
$organization->update($updates);
Log::info('PlatformServiceEventListener: organization updated', [
'organization_id' => $organization->id,
'owner_ref' => $ownerRef,
]);
}
}
+40
View File
@@ -0,0 +1,40 @@
<?php
namespace App\Mail;
use Illuminate\Bus\Queueable;
use Illuminate\Mail\Mailable;
use Illuminate\Mail\Mailables\Content;
use Illuminate\Mail\Mailables\Envelope;
use Illuminate\Queue\SerializesModels;
class ContactMessageMail extends Mailable
{
use Queueable, SerializesModels;
public function __construct(
public string $subjectLine,
public string $bodyText,
public ?string $fromName = null,
public ?string $replyToAddress = null,
) {}
public function envelope(): Envelope
{
return new Envelope(
subject: $this->subjectLine,
replyTo: $this->replyToAddress ? [$this->replyToAddress] : [],
);
}
public function content(): Content
{
return new Content(
view: 'email.contact-message',
with: [
'bodyText' => $this->bodyText,
'fromName' => $this->fromName,
],
);
}
}
+39
View File
@@ -0,0 +1,39 @@
<?php
namespace App\Models;
use App\Models\Concerns\BelongsToOwner;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Support\Str;
class ActionItem extends Model
{
use BelongsToOwner;
protected $table = 'meet_action_items';
protected $fillable = [
'uuid', 'owner_ref', 'ai_summary_id', 'assignee_ref', 'assignee_name',
'description', 'due_date', 'status',
];
protected function casts(): array
{
return ['due_date' => 'date'];
}
protected static function booted(): void
{
static::creating(function (ActionItem $item) {
if (empty($item->uuid)) {
$item->uuid = (string) Str::uuid();
}
});
}
public function aiSummary(): BelongsTo
{
return $this->belongsTo(AiSummary::class, 'ai_summary_id');
}
}
+55
View File
@@ -0,0 +1,55 @@
<?php
namespace App\Models;
use App\Models\Concerns\BelongsToOwner;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Support\Str;
class AiSummary extends Model
{
use BelongsToOwner;
protected $table = 'meet_ai_summaries';
protected $fillable = [
'uuid', 'owner_ref', 'session_id', 'transcript_id', 'status',
'summary', 'decisions',
];
protected function casts(): array
{
return ['decisions' => 'array'];
}
protected static function booted(): void
{
static::creating(function (AiSummary $summary) {
if (empty($summary->uuid)) {
$summary->uuid = (string) Str::uuid();
}
});
}
public function getRouteKeyName(): string
{
return 'uuid';
}
public function session(): BelongsTo
{
return $this->belongsTo(Session::class, 'session_id');
}
public function transcript(): BelongsTo
{
return $this->belongsTo(Transcript::class, 'transcript_id');
}
public function actionItems(): HasMany
{
return $this->hasMany(ActionItem::class, 'ai_summary_id');
}
}
+56
View File
@@ -0,0 +1,56 @@
<?php
namespace App\Models;
use App\Models\Concerns\BelongsToOwner;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
class AuditLog extends Model
{
use BelongsToOwner;
protected $table = 'meet_audit_logs';
public $timestamps = false;
protected $fillable = [
'owner_ref', 'organization_id', 'actor_ref', 'action',
'subject_type', 'subject_id', 'metadata', 'ip_address', 'created_at',
];
protected function casts(): array
{
return [
'metadata' => 'array',
'created_at' => 'datetime',
];
}
public function organization(): BelongsTo
{
return $this->belongsTo(Organization::class, 'organization_id');
}
public static function record(
string $ownerRef,
string $action,
?int $organizationId = null,
?string $actorRef = null,
?string $subjectType = null,
?int $subjectId = null,
?array $metadata = null,
): self {
return static::create([
'owner_ref' => $ownerRef,
'organization_id' => $organizationId,
'actor_ref' => $actorRef,
'action' => $action,
'subject_type' => $subjectType,
'subject_id' => $subjectId,
'metadata' => $metadata,
'ip_address' => request()?->ip(),
'created_at' => now(),
]);
}
}
+29
View File
@@ -0,0 +1,29 @@
<?php
namespace App\Models;
use App\Models\Concerns\BelongsToOwner;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\SoftDeletes;
class Branch extends Model
{
use BelongsToOwner, SoftDeletes;
protected $table = 'meet_branches';
protected $fillable = [
'owner_ref', 'organization_id', 'name', 'code', 'address', 'phone', 'is_active',
];
protected function casts(): array
{
return ['is_active' => 'boolean'];
}
public function organization(): BelongsTo
{
return $this->belongsTo(Organization::class, 'organization_id');
}
}
+23
View File
@@ -0,0 +1,23 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
class BreakoutAssignment extends Model
{
protected $table = 'meet_breakout_assignments';
protected $fillable = ['breakout_room_id', 'participant_id'];
public function breakoutRoom(): BelongsTo
{
return $this->belongsTo(BreakoutRoom::class, 'breakout_room_id');
}
public function participant(): BelongsTo
{
return $this->belongsTo(Participant::class, 'participant_id');
}
}
+49
View File
@@ -0,0 +1,49 @@
<?php
namespace App\Models;
use App\Models\Concerns\BelongsToOwner;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Support\Str;
class BreakoutRoom extends Model
{
use BelongsToOwner;
protected $table = 'meet_breakout_rooms';
protected $fillable = [
'uuid', 'owner_ref', 'session_id', 'name', 'media_room_name', 'status', 'closes_at',
];
protected function casts(): array
{
return ['closes_at' => 'datetime'];
}
protected static function booted(): void
{
static::creating(function (BreakoutRoom $room) {
if (empty($room->uuid)) {
$room->uuid = (string) Str::uuid();
}
});
}
public function getRouteKeyName(): string
{
return 'uuid';
}
public function session(): BelongsTo
{
return $this->belongsTo(Session::class, 'session_id');
}
public function assignments(): HasMany
{
return $this->hasMany(BreakoutAssignment::class, 'breakout_room_id');
}
}
+49
View File
@@ -0,0 +1,49 @@
<?php
namespace App\Models;
use App\Models\Concerns\BelongsToOwner;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Support\Str;
class CalendarConnection extends Model
{
use BelongsToOwner;
protected $table = 'meet_calendar_connections';
protected $fillable = [
'uuid', 'owner_ref', 'organization_id', 'user_ref', 'provider',
'access_token', 'refresh_token', 'expires_at', 'calendar_id', 'metadata',
];
protected function casts(): array
{
return [
'expires_at' => 'datetime',
'metadata' => 'array',
'access_token' => 'encrypted',
'refresh_token' => 'encrypted',
];
}
protected static function booted(): void
{
static::creating(function (CalendarConnection $connection) {
if (empty($connection->uuid)) {
$connection->uuid = (string) Str::uuid();
}
});
}
public function organization(): BelongsTo
{
return $this->belongsTo(Organization::class, 'organization_id');
}
public function isExpired(): bool
{
return $this->expires_at !== null && $this->expires_at->isPast();
}
}
+54
View File
@@ -0,0 +1,54 @@
<?php
namespace App\Models;
use App\Models\Concerns\BelongsToOwner;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Support\Str;
class Channel extends Model
{
use BelongsToOwner;
protected $table = 'meet_channels';
protected $fillable = [
'uuid', 'owner_ref', 'organization_id', 'branch_id', 'name', 'slug', 'visibility', 'description',
];
protected static function booted(): void
{
static::creating(function (Channel $channel) {
if (empty($channel->uuid)) {
$channel->uuid = (string) Str::uuid();
}
});
}
public function getRouteKeyName(): string
{
return 'uuid';
}
public function organization(): BelongsTo
{
return $this->belongsTo(Organization::class, 'organization_id');
}
public function members(): HasMany
{
return $this->hasMany(ChannelMember::class, 'channel_id');
}
public function messages(): HasMany
{
return $this->hasMany(ChannelMessage::class, 'channel_id');
}
public function isPrivate(): bool
{
return $this->visibility === 'private';
}
}
+18
View File
@@ -0,0 +1,18 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
class ChannelMember extends Model
{
protected $table = 'meet_channel_members';
protected $fillable = ['channel_id', 'user_ref', 'role'];
public function channel(): BelongsTo
{
return $this->belongsTo(Channel::class, 'channel_id');
}
}
+49
View File
@@ -0,0 +1,49 @@
<?php
namespace App\Models;
use App\Models\Concerns\BelongsToOwner;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Support\Str;
class ChannelMessage extends Model
{
use BelongsToOwner;
protected $table = 'meet_channel_messages';
protected $fillable = [
'uuid', 'owner_ref', 'channel_id', 'sender_ref', 'sender_name', 'body', 'parent_id', 'metadata',
];
protected function casts(): array
{
return ['metadata' => 'array'];
}
protected static function booted(): void
{
static::creating(function (ChannelMessage $message) {
if (empty($message->uuid)) {
$message->uuid = (string) Str::uuid();
}
});
}
public function channel(): BelongsTo
{
return $this->belongsTo(Channel::class, 'channel_id');
}
public function parent(): BelongsTo
{
return $this->belongsTo(ChannelMessage::class, 'parent_id');
}
public function replies(): HasMany
{
return $this->hasMany(ChannelMessage::class, 'parent_id');
}
}
+13
View File
@@ -0,0 +1,13 @@
<?php
namespace App\Models\Concerns;
use Illuminate\Database\Eloquent\Builder;
trait BelongsToOwner
{
public function scopeOwned(Builder $query, string $ownerRef): Builder
{
return $query->where($this->getTable().'.owner_ref', $ownerRef);
}
}
+44
View File
@@ -0,0 +1,44 @@
<?php
namespace App\Models;
use App\Models\Concerns\BelongsToOwner;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Support\Str;
class ContactGroup extends Model
{
use BelongsToOwner;
protected $table = 'meet_contact_groups';
protected $fillable = [
'uuid', 'owner_ref', 'organization_id', 'name', 'description',
];
protected static function booted(): void
{
static::creating(function (ContactGroup $group) {
if (empty($group->uuid)) {
$group->uuid = (string) Str::uuid();
}
});
}
public function getRouteKeyName(): string
{
return 'uuid';
}
public function organization(): BelongsTo
{
return $this->belongsTo(Organization::class, 'organization_id');
}
public function members(): HasMany
{
return $this->hasMany(ContactGroupMember::class, 'contact_group_id');
}
}
+20
View File
@@ -0,0 +1,20 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
class ContactGroupMember extends Model
{
protected $table = 'meet_contact_group_members';
protected $fillable = [
'contact_group_id', 'email', 'user_ref', 'display_name',
];
public function group(): BelongsTo
{
return $this->belongsTo(ContactGroup::class, 'contact_group_id');
}
}
+47
View File
@@ -0,0 +1,47 @@
<?php
namespace App\Models;
use App\Models\Concerns\BelongsToOwner;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Support\Str;
class DirectConversation extends Model
{
use BelongsToOwner;
protected $table = 'meet_direct_conversations';
protected $fillable = ['uuid', 'owner_ref', 'organization_id', 'type', 'title'];
protected static function booted(): void
{
static::creating(function (DirectConversation $conversation) {
if (empty($conversation->uuid)) {
$conversation->uuid = (string) Str::uuid();
}
});
}
public function getRouteKeyName(): string
{
return 'uuid';
}
public function organization(): BelongsTo
{
return $this->belongsTo(Organization::class, 'organization_id');
}
public function participants(): HasMany
{
return $this->hasMany(DirectParticipant::class, 'conversation_id');
}
public function messages(): HasMany
{
return $this->hasMany(DirectMessage::class, 'conversation_id');
}
}
+44
View File
@@ -0,0 +1,44 @@
<?php
namespace App\Models;
use App\Models\Concerns\BelongsToOwner;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Support\Str;
class DirectMessage extends Model
{
use BelongsToOwner;
protected $table = 'meet_direct_messages';
protected $fillable = [
'uuid', 'owner_ref', 'conversation_id', 'sender_ref', 'sender_name', 'body', 'parent_id',
];
protected static function booted(): void
{
static::creating(function (DirectMessage $message) {
if (empty($message->uuid)) {
$message->uuid = (string) Str::uuid();
}
});
}
public function conversation(): BelongsTo
{
return $this->belongsTo(DirectConversation::class, 'conversation_id');
}
public function parent(): BelongsTo
{
return $this->belongsTo(DirectMessage::class, 'parent_id');
}
public function replies(): HasMany
{
return $this->hasMany(DirectMessage::class, 'parent_id');
}
}
+18
View File
@@ -0,0 +1,18 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
class DirectParticipant extends Model
{
protected $table = 'meet_direct_participants';
protected $fillable = ['conversation_id', 'user_ref'];
public function conversation(): BelongsTo
{
return $this->belongsTo(DirectConversation::class, 'conversation_id');
}
}
+27
View File
@@ -0,0 +1,27 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
class FileDownload extends Model
{
public $timestamps = false;
protected $table = 'meet_file_downloads';
protected $fillable = [
'session_file_id', 'downloaded_by_ref', 'downloaded_by_name', 'ip_address', 'downloaded_at',
];
protected function casts(): array
{
return ['downloaded_at' => 'datetime'];
}
public function file(): BelongsTo
{
return $this->belongsTo(SessionFile::class, 'session_file_id');
}
}
+56
View File
@@ -0,0 +1,56 @@
<?php
namespace App\Models;
use App\Models\Concerns\BelongsToOwner;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Support\Str;
class Invitation extends Model
{
use BelongsToOwner;
protected $table = 'meet_invitations';
protected $fillable = [
'uuid', 'owner_ref', 'room_id', 'email', 'user_ref',
'display_name', 'role', 'status', 'rsvp_token', 'responded_at',
'calendar_event_id', 'sent_at',
];
protected function casts(): array
{
return [
'sent_at' => 'datetime',
'responded_at' => 'datetime',
];
}
protected static function booted(): void
{
static::creating(function (Invitation $invitation) {
if (empty($invitation->uuid)) {
$invitation->uuid = (string) Str::uuid();
}
if (empty($invitation->rsvp_token)) {
$invitation->rsvp_token = Str::random(40);
}
});
}
public function getRouteKeyName(): string
{
return 'uuid';
}
public function rsvpUrl(): string
{
return route('meet.invitations.rsvp', ['invitation' => $this->uuid, 'token' => $this->rsvp_token]);
}
public function room(): BelongsTo
{
return $this->belongsTo(Room::class, 'room_id');
}
}
+61
View File
@@ -0,0 +1,61 @@
<?php
namespace App\Models;
use App\Models\Concerns\BelongsToOwner;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Support\Facades\Crypt;
use Illuminate\Support\Str;
class LiveStream extends Model
{
use BelongsToOwner;
protected $table = 'meet_live_streams';
protected $fillable = [
'uuid', 'owner_ref', 'session_id', 'platform', 'rtmp_url', 'stream_key',
'status', 'layout', 'health', 'started_at', 'ended_at',
];
protected function casts(): array
{
return [
'started_at' => 'datetime',
'ended_at' => 'datetime',
];
}
protected static function booted(): void
{
static::creating(function (LiveStream $stream) {
if (empty($stream->uuid)) {
$stream->uuid = (string) Str::uuid();
}
});
}
public function session(): BelongsTo
{
return $this->belongsTo(Session::class, 'session_id');
}
public function setStreamKeyAttribute(?string $value): void
{
$this->attributes['stream_key'] = $value ? Crypt::encryptString($value) : null;
}
public function getStreamKeyAttribute(?string $value): ?string
{
if (! $value) {
return null;
}
try {
return Crypt::decryptString($value);
} catch (\Throwable) {
return null;
}
}
}
+49
View File
@@ -0,0 +1,49 @@
<?php
namespace App\Models;
use App\Models\Concerns\BelongsToOwner;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Support\Str;
class MeetPoll extends Model
{
use BelongsToOwner;
protected $table = 'meet_polls';
protected $fillable = [
'uuid', 'owner_ref', 'session_id', 'question', 'options', 'status',
];
protected function casts(): array
{
return ['options' => 'array'];
}
protected static function booted(): void
{
static::creating(function (MeetPoll $poll) {
if (empty($poll->uuid)) {
$poll->uuid = (string) Str::uuid();
}
});
}
public function getRouteKeyName(): string
{
return 'uuid';
}
public function session(): BelongsTo
{
return $this->belongsTo(Session::class, 'session_id');
}
public function votes(): HasMany
{
return $this->hasMany(PollVote::class, 'poll_id');
}
}
+31
View File
@@ -0,0 +1,31 @@
<?php
namespace App\Models;
use App\Models\Concerns\BelongsToOwner;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
class Member extends Model
{
use BelongsToOwner;
protected $table = 'meet_members';
protected $fillable = ['owner_ref', 'organization_id', 'user_ref', 'role', 'branch_id'];
public function organization(): BelongsTo
{
return $this->belongsTo(Organization::class, 'organization_id');
}
public function branch(): BelongsTo
{
return $this->belongsTo(Branch::class, 'branch_id');
}
public function hasRole(string ...$roles): bool
{
return in_array($this->role, $roles, true);
}
}
+39
View File
@@ -0,0 +1,39 @@
<?php
namespace App\Models;
use App\Models\Concerns\BelongsToOwner;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Support\Str;
class Message extends Model
{
use BelongsToOwner;
protected $table = 'meet_messages';
protected $fillable = [
'uuid', 'owner_ref', 'session_id', 'breakout_room_id', 'sender_ref', 'sender_name',
'type', 'recipient_ref', 'body', 'metadata',
];
protected function casts(): array
{
return ['metadata' => 'array'];
}
protected static function booted(): void
{
static::creating(function (Message $message) {
if (empty($message->uuid)) {
$message->uuid = (string) Str::uuid();
}
});
}
public function session(): BelongsTo
{
return $this->belongsTo(Session::class, 'session_id');
}
}
+49
View File
@@ -0,0 +1,49 @@
<?php
namespace App\Models;
use App\Models\Concerns\BelongsToOwner;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Database\Eloquent\SoftDeletes;
class Organization extends Model
{
use BelongsToOwner, SoftDeletes;
protected $table = 'meet_organizations';
protected $fillable = [
'owner_ref', 'name', 'slug', 'logo_path', 'timezone', 'license_tier', 'usage_quotas',
'settings', 'security_policy',
];
protected function casts(): array
{
return [
'settings' => 'array',
'security_policy' => 'array',
'usage_quotas' => 'array',
];
}
public function branches(): HasMany
{
return $this->hasMany(Branch::class, 'organization_id');
}
public function members(): HasMany
{
return $this->hasMany(Member::class, 'organization_id');
}
public function templates(): HasMany
{
return $this->hasMany(Template::class, 'organization_id');
}
public function securitySetting(string $key, mixed $default = null): mixed
{
return data_get($this->security_policy, $key, data_get(config('meet.security_defaults'), $key, $default));
}
}
+81
View File
@@ -0,0 +1,81 @@
<?php
namespace App\Models;
use App\Models\Concerns\BelongsToOwner;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Support\Str;
class Participant extends Model
{
use BelongsToOwner;
protected $table = 'meet_participants';
protected $fillable = [
'uuid', 'owner_ref', 'session_id', 'user_ref', 'display_name', 'email',
'role', 'status', 'joined_at', 'left_at', 'device_info', 'ip_address',
'hand_raised', 'is_muted', 'is_video_off', 'breakout_room_id',
];
protected function casts(): array
{
return [
'joined_at' => 'datetime',
'left_at' => 'datetime',
'hand_raised' => 'boolean',
'is_muted' => 'boolean',
'is_video_off' => 'boolean',
];
}
protected static function booted(): void
{
static::creating(function (Participant $participant) {
if (empty($participant->uuid)) {
$participant->uuid = (string) Str::uuid();
}
});
}
public function getRouteKeyName(): string
{
return 'uuid';
}
public function session(): BelongsTo
{
return $this->belongsTo(Session::class, 'session_id');
}
public function isHost(): bool
{
return in_array($this->role, ['host', 'co_host'], true);
}
public function isPanelist(): bool
{
return $this->role === 'panelist';
}
public function isAttendee(): bool
{
return $this->role === 'attendee';
}
public function canPublishMedia(): bool
{
return $this->role !== 'attendee';
}
public function breakoutRoom(): BelongsTo
{
return $this->belongsTo(BreakoutRoom::class, 'breakout_room_id');
}
public function isJoined(): bool
{
return $this->status === 'joined';
}
}
+18
View File
@@ -0,0 +1,18 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
class PollVote extends Model
{
protected $table = 'meet_poll_votes';
protected $fillable = ['poll_id', 'participant_uuid', 'option_index'];
public function poll(): BelongsTo
{
return $this->belongsTo(MeetPoll::class, 'poll_id');
}
}
+39
View File
@@ -0,0 +1,39 @@
<?php
namespace App\Models;
use App\Models\Concerns\BelongsToOwner;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Support\Str;
class QaQuestion extends Model
{
use BelongsToOwner;
protected $table = 'meet_qa_questions';
protected $fillable = [
'uuid', 'owner_ref', 'session_id', 'participant_uuid', 'asker_name',
'question', 'status', 'answer', 'answered_by', 'upvotes',
];
protected static function booted(): void
{
static::creating(function (QaQuestion $q) {
if (empty($q->uuid)) {
$q->uuid = (string) Str::uuid();
}
});
}
public function getRouteKeyName(): string
{
return 'uuid';
}
public function session(): BelongsTo
{
return $this->belongsTo(Session::class, 'session_id');
}
}
+23
View File
@@ -0,0 +1,23 @@
<?php
namespace App\Models;
use App\Models\Concerns\BelongsToOwner;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
class Reaction extends Model
{
use BelongsToOwner;
protected $table = 'meet_reactions';
protected $fillable = [
'owner_ref', 'session_id', 'participant_ref', 'sender_name', 'emoji',
];
public function session(): BelongsTo
{
return $this->belongsTo(Session::class, 'session_id');
}
}
+54
View File
@@ -0,0 +1,54 @@
<?php
namespace App\Models;
use App\Models\Concerns\BelongsToOwner;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Support\Str;
class Recording extends Model
{
use BelongsToOwner;
protected $table = 'meet_recordings';
protected $fillable = [
'uuid', 'owner_ref', 'session_id', 'status', 'layout', 'storage_path',
'thumbnail_path', 'file_size', 'duration_seconds', 'started_by_ref',
'started_at', 'ended_at', 'failure_reason', 'metadata',
];
protected function casts(): array
{
return [
'started_at' => 'datetime',
'ended_at' => 'datetime',
'metadata' => 'array',
];
}
protected static function booted(): void
{
static::creating(function (Recording $recording) {
if (empty($recording->uuid)) {
$recording->uuid = (string) Str::uuid();
}
});
}
public function getRouteKeyName(): string
{
return 'uuid';
}
public function session(): BelongsTo
{
return $this->belongsTo(Session::class, 'session_id');
}
public function isReady(): bool
{
return $this->status === 'ready';
}
}
+118
View File
@@ -0,0 +1,118 @@
<?php
namespace App\Models;
use App\Models\Concerns\BelongsToOwner;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Database\Eloquent\SoftDeletes;
use Illuminate\Support\Str;
class Room extends Model
{
use BelongsToOwner, SoftDeletes;
protected $table = 'meet_rooms';
protected $fillable = [
'uuid', 'owner_ref', 'organization_id', 'branch_id', 'host_user_ref',
'title', 'description', 'type', 'status', 'slug', 'scheduled_at',
'duration_minutes', 'timezone', 'passcode', 'settings', 'source', 'calendar_event_id',
'template_id', 'recurring_series_id', 'recurrence_rule', 'recurrence_interval', 'recurrence_until',
];
protected function casts(): array
{
return [
'scheduled_at' => 'datetime',
'recurrence_until' => 'date',
'settings' => 'array',
'source' => 'array',
];
}
protected static function booted(): void
{
static::creating(function (Room $room) {
if (empty($room->uuid)) {
$room->uuid = (string) Str::uuid();
}
});
}
public function getRouteKeyName(): string
{
return 'uuid';
}
public function organization(): BelongsTo
{
return $this->belongsTo(Organization::class, 'organization_id');
}
public function branch(): BelongsTo
{
return $this->belongsTo(Branch::class, 'branch_id');
}
public function sessions(): HasMany
{
return $this->hasMany(Session::class, 'room_id');
}
public function invitations(): HasMany
{
return $this->hasMany(Invitation::class, 'room_id');
}
public function webinarRegistrations(): HasMany
{
return $this->hasMany(WebinarRegistration::class, 'room_id');
}
public function sessionFiles(): HasMany
{
return $this->hasMany(SessionFile::class, 'room_id');
}
public function isWebinar(): bool
{
return $this->type === 'webinar';
}
public function isTownHall(): bool
{
return $this->type === 'town_hall';
}
public function template(): BelongsTo
{
return $this->belongsTo(Template::class, 'template_id');
}
public function isRecurring(): bool
{
return filled($this->recurrence_rule);
}
public function activeSession(): ?Session
{
return $this->sessions()->where('status', 'live')->latest()->first();
}
public function setting(string $key, mixed $default = null): mixed
{
return data_get($this->settings, $key, data_get(config('meet.default_settings'), $key, $default));
}
public function isLive(): bool
{
return $this->status === 'live';
}
public function joinUrl(): string
{
return url('/r/'.$this->uuid);
}
}
+123
View File
@@ -0,0 +1,123 @@
<?php
namespace App\Models;
use App\Models\Concerns\BelongsToOwner;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Support\Str;
class Session extends Model
{
use BelongsToOwner;
protected $table = 'meet_sessions';
protected $fillable = [
'uuid', 'owner_ref', 'room_id', 'parent_session_id', 'media_room_name', 'status',
'session_mode', 'presenter_refs', 'is_breakout', 'started_at', 'ended_at',
'peak_participants', 'is_locked', 'locked_at',
];
protected function casts(): array
{
return [
'started_at' => 'datetime',
'ended_at' => 'datetime',
'locked_at' => 'datetime',
'is_locked' => 'boolean',
'is_breakout' => 'boolean',
'presenter_refs' => 'array',
];
}
protected static function booted(): void
{
static::creating(function (Session $session) {
if (empty($session->uuid)) {
$session->uuid = (string) Str::uuid();
}
});
}
public function getRouteKeyName(): string
{
return 'uuid';
}
public function room(): BelongsTo
{
return $this->belongsTo(Room::class, 'room_id');
}
public function participants(): HasMany
{
return $this->hasMany(Participant::class, 'session_id');
}
public function messages(): HasMany
{
return $this->hasMany(Message::class, 'session_id');
}
public function reactions(): HasMany
{
return $this->hasMany(Reaction::class, 'session_id');
}
public function recordings(): HasMany
{
return $this->hasMany(Recording::class, 'session_id');
}
public function transcripts(): HasMany
{
return $this->hasMany(Transcript::class, 'session_id');
}
public function aiSummaries(): HasMany
{
return $this->hasMany(AiSummary::class, 'session_id');
}
public function qaQuestions(): HasMany
{
return $this->hasMany(QaQuestion::class, 'session_id');
}
public function polls(): HasMany
{
return $this->hasMany(MeetPoll::class, 'session_id');
}
public function breakoutRooms(): HasMany
{
return $this->hasMany(BreakoutRoom::class, 'session_id');
}
public function whiteboard(): \Illuminate\Database\Eloquent\Relations\HasOne
{
return $this->hasOne(Whiteboard::class, 'session_id');
}
public function sessionFiles(): HasMany
{
return $this->hasMany(SessionFile::class, 'session_id');
}
public function liveStreams(): HasMany
{
return $this->hasMany(LiveStream::class, 'session_id');
}
public function activeParticipants(): HasMany
{
return $this->participants()->where('status', 'joined');
}
public function isLive(): bool
{
return $this->status === 'live';
}
}
+60
View File
@@ -0,0 +1,60 @@
<?php
namespace App\Models;
use App\Models\Concerns\BelongsToOwner;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Support\Str;
class SessionFile extends Model
{
use BelongsToOwner;
protected $table = 'meet_session_files';
protected $fillable = [
'uuid', 'owner_ref', 'room_id', 'session_id', 'uploaded_by_ref', 'uploaded_by_name',
'original_name', 'storage_path', 'mime_type', 'file_size', 'drive_file_id', 'expires_at',
];
protected function casts(): array
{
return ['expires_at' => 'datetime'];
}
protected static function booted(): void
{
static::creating(function (SessionFile $file) {
if (empty($file->uuid)) {
$file->uuid = (string) Str::uuid();
}
});
}
public function getRouteKeyName(): string
{
return 'uuid';
}
public function room(): BelongsTo
{
return $this->belongsTo(Room::class, 'room_id');
}
public function session(): BelongsTo
{
return $this->belongsTo(Session::class, 'session_id');
}
public function downloads(): HasMany
{
return $this->hasMany(FileDownload::class, 'session_file_id');
}
public function isExpired(): bool
{
return $this->expires_at !== null && $this->expires_at->isPast();
}
}
+50
View File
@@ -0,0 +1,50 @@
<?php
namespace App\Models;
use App\Models\Concerns\BelongsToOwner;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\SoftDeletes;
use Illuminate\Support\Str;
class Template extends Model
{
use BelongsToOwner, SoftDeletes;
protected $table = 'meet_templates';
protected $fillable = [
'uuid', 'owner_ref', 'organization_id', 'name', 'description',
'duration_minutes', 'settings', 'is_default',
];
protected function casts(): array
{
return ['settings' => 'array', 'is_default' => 'boolean'];
}
protected static function booted(): void
{
static::creating(function (Template $template) {
if (empty($template->uuid)) {
$template->uuid = (string) Str::uuid();
}
});
}
public function getRouteKeyName(): string
{
return 'uuid';
}
public function organization(): BelongsTo
{
return $this->belongsTo(Organization::class, 'organization_id');
}
public function mergedSettings(): array
{
return array_merge(config('meet.default_settings'), $this->settings ?? []);
}
}
+50
View File
@@ -0,0 +1,50 @@
<?php
namespace App\Models;
use App\Models\Concerns\BelongsToOwner;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\HasOne;
use Illuminate\Support\Str;
class Transcript extends Model
{
use BelongsToOwner;
protected $table = 'meet_transcripts';
protected $fillable = [
'uuid', 'owner_ref', 'session_id', 'recording_id', 'status',
'content', 'segments', 'live_captions_enabled',
];
protected function casts(): array
{
return ['segments' => 'array', 'live_captions_enabled' => 'boolean'];
}
protected static function booted(): void
{
static::creating(function (Transcript $transcript) {
if (empty($transcript->uuid)) {
$transcript->uuid = (string) Str::uuid();
}
});
}
public function session(): BelongsTo
{
return $this->belongsTo(Session::class, 'session_id');
}
public function recording(): BelongsTo
{
return $this->belongsTo(Recording::class, 'recording_id');
}
public function aiSummary(): HasOne
{
return $this->hasOne(AiSummary::class, 'transcript_id');
}
}
+31
View File
@@ -0,0 +1,31 @@
<?php
namespace App\Models;
use App\Models\Concerns\BelongsToOwner;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
class UsageRecord extends Model
{
use BelongsToOwner;
protected $table = 'meet_usage_records';
protected $fillable = [
'owner_ref', 'organization_id', 'branch_id', 'metric', 'quantity', 'metadata', 'recorded_at',
];
protected function casts(): array
{
return [
'metadata' => 'array',
'recorded_at' => 'datetime',
];
}
public function organization(): BelongsTo
{
return $this->belongsTo(Organization::class, 'organization_id');
}
}
+41
View File
@@ -0,0 +1,41 @@
<?php
namespace App\Models;
use Illuminate\Foundation\Auth\User as Authenticatable;
use Illuminate\Notifications\Notifiable;
use Laravel\Sanctum\HasApiTokens;
/**
* Thin local mirror of the platform identity (auth.ladill.com owns users).
* Keyed by the OIDC `sub` (public_id); every Care record is scoped to it.
*/
class User extends Authenticatable
{
use HasApiTokens, Notifiable;
protected $fillable = ['public_id', 'name', 'email', 'avatar_url', 'password', 'last_app_active_at'];
protected $hidden = ['password', 'remember_token'];
protected function casts(): array
{
return [
'email_verified_at' => 'datetime',
'last_app_active_at' => 'datetime',
'password' => 'hashed',
];
}
public function ownerRef(): string
{
return (string) $this->public_id;
}
public function avatarUrl(): ?string
{
$url = trim((string) $this->avatar_url);
return $url !== '' ? $url : null;
}
}
+51
View File
@@ -0,0 +1,51 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Support\Str;
class WebhookEndpoint extends Model
{
protected $table = 'meet_webhook_endpoints';
protected $fillable = [
'uuid', 'owner_ref', 'organization_id', 'url', 'secret', 'events', 'is_active',
];
protected function casts(): array
{
return [
'events' => 'array',
'is_active' => 'boolean',
'secret' => 'encrypted',
];
}
protected static function booted(): void
{
static::creating(function (WebhookEndpoint $endpoint) {
if (empty($endpoint->uuid)) {
$endpoint->uuid = (string) Str::uuid();
}
});
}
public function getRouteKeyName(): string
{
return 'uuid';
}
public function organization(): BelongsTo
{
return $this->belongsTo(Organization::class, 'organization_id');
}
public function subscribesTo(string $event): bool
{
$events = $this->events ?? config('meet.webhook_events', []);
return in_array('*', $events, true) || in_array($event, $events, true);
}
}
+52
View File
@@ -0,0 +1,52 @@
<?php
namespace App\Models;
use App\Models\Concerns\BelongsToOwner;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Support\Str;
class WebinarRegistration extends Model
{
use BelongsToOwner;
protected $table = 'meet_webinar_registrations';
protected $fillable = [
'uuid', 'owner_ref', 'room_id', 'email', 'display_name', 'status',
'access_token', 'approved_at', 'metadata',
];
protected function casts(): array
{
return ['approved_at' => 'datetime', 'metadata' => 'array'];
}
protected static function booted(): void
{
static::creating(function (WebinarRegistration $reg) {
if (empty($reg->uuid)) {
$reg->uuid = (string) Str::uuid();
}
if (empty($reg->access_token)) {
$reg->access_token = Str::random(48);
}
});
}
public function getRouteKeyName(): string
{
return 'uuid';
}
public function room(): BelongsTo
{
return $this->belongsTo(Room::class, 'room_id');
}
public function isApproved(): bool
{
return $this->status === 'approved';
}
}
+48
View File
@@ -0,0 +1,48 @@
<?php
namespace App\Models;
use App\Models\Concerns\BelongsToOwner;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Support\Str;
class Whiteboard extends Model
{
use BelongsToOwner;
protected $table = 'meet_whiteboards';
protected $fillable = [
'uuid', 'owner_ref', 'room_id', 'session_id', 'template', 'state',
];
protected function casts(): array
{
return ['state' => 'array'];
}
protected static function booted(): void
{
static::creating(function (Whiteboard $board) {
if (empty($board->uuid)) {
$board->uuid = (string) Str::uuid();
}
});
}
public function getRouteKeyName(): string
{
return 'uuid';
}
public function room(): BelongsTo
{
return $this->belongsTo(Room::class, 'room_id');
}
public function session(): BelongsTo
{
return $this->belongsTo(Session::class, 'session_id');
}
}
@@ -0,0 +1,32 @@
<?php
namespace App\Notifications;
use App\Models\AiSummary;
use Illuminate\Bus\Queueable;
use Illuminate\Notifications\Notification;
class AiSummaryReadyNotification extends Notification
{
use Queueable;
public function __construct(
public AiSummary $summary,
) {}
public function via(object $notifiable): array
{
return ['database'];
}
public function toArray(object $notifiable): array
{
return [
'type' => 'ai_summary_ready',
'title' => 'AI summary ready',
'body' => 'Summary for '.$this->summary->session->room->title.' is ready.',
'url' => route('meet.summaries.show', $this->summary),
'summary_uuid' => $this->summary->uuid,
];
}
}
@@ -0,0 +1,34 @@
<?php
namespace App\Notifications;
use App\Models\Room;
use App\Models\User;
use Illuminate\Bus\Queueable;
use Illuminate\Notifications\Notification;
class MeetingInvitationNotification extends Notification
{
use Queueable;
public function __construct(
public Room $room,
public User $host,
) {}
public function via(object $notifiable): array
{
return ['database'];
}
public function toArray(object $notifiable): array
{
return [
'type' => 'meeting_invitation',
'title' => 'Meeting invitation',
'body' => $this->host->name.' invited you to '.$this->room->title,
'url' => $this->room->joinUrl(),
'room_uuid' => $this->room->uuid,
];
}
}
@@ -0,0 +1,32 @@
<?php
namespace App\Notifications;
use App\Models\Room;
use Illuminate\Bus\Queueable;
use Illuminate\Notifications\Notification;
class MeetingReminderNotification extends Notification
{
use Queueable;
public function __construct(
public Room $room,
) {}
public function via(object $notifiable): array
{
return ['database'];
}
public function toArray(object $notifiable): array
{
return [
'type' => 'meeting_reminder',
'title' => 'Meeting reminder',
'body' => $this->room->title.' starts soon.',
'url' => route('meet.rooms.show', $this->room),
'room_uuid' => $this->room->uuid,
];
}
}
@@ -0,0 +1,32 @@
<?php
namespace App\Notifications;
use App\Models\Recording;
use Illuminate\Bus\Queueable;
use Illuminate\Notifications\Notification;
class RecordingReadyNotification extends Notification
{
use Queueable;
public function __construct(
public Recording $recording,
) {}
public function via(object $notifiable): array
{
return ['database'];
}
public function toArray(object $notifiable): array
{
return [
'type' => 'recording_ready',
'title' => 'Recording ready',
'body' => 'Recording for '.$this->recording->session->room->title.' is ready.',
'url' => route('meet.recordings.show', $this->recording),
'recording_uuid' => $this->recording->uuid,
];
}
}
+28
View File
@@ -0,0 +1,28 @@
<?php
namespace App\Providers;
use App\Events\ServiceEventOccurred;
use App\Listeners\PlatformServiceEventListener;
use App\Services\Meet\Media\LiveKitProvider;
use App\Services\Meet\Media\MediaProviderInterface;
use Illuminate\Support\Facades\Event;
use Illuminate\Support\Facades\View;
use Illuminate\Support\ServiceProvider;
class AppServiceProvider extends ServiceProvider
{
public function register(): void
{
$this->app->bind(MediaProviderInterface::class, LiveKitProvider::class);
}
public function boot(): void
{
Event::listen(ServiceEventOccurred::class, PlatformServiceEventListener::class);
View::composer(['partials.topbar'], function ($view) {
$view->with(\App\Support\MobileTopbar::resolve());
});
}
}
+64
View File
@@ -0,0 +1,64 @@
<?php
namespace App\Services\Billing;
use Illuminate\Support\Facades\Http;
/**
* Client for the platform Billing HTTP API the one UserWallet lives on the
* platform; CRM only consumes it. Amounts are integer minor units; the user is
* identified by public_id. Authenticates with the per-consumer config('billing.api_key').
*/
class BillingClient
{
private function base(): string
{
return rtrim((string) config('billing.api_url'), '/');
}
private function token(): string
{
return (string) (config('billing.api_key') ?? '');
}
public function balanceMinor(string $publicId): int
{
$res = Http::withToken($this->token())->acceptJson()->timeout(8)
->get($this->base().'/balance', ['user' => $publicId]);
$res->throw();
return (int) ($res->json('balance_minor') ?? 0);
}
public function canAfford(string $publicId, int $amountMinor): bool
{
$res = Http::withToken($this->token())->acceptJson()->timeout(8)
->get($this->base().'/can-afford', ['user' => $publicId, 'amount_minor' => $amountMinor]);
$res->throw();
return (bool) ($res->json('affordable') ?? false);
}
/**
* Debit the wallet. Returns true on success, false on insufficient balance
* (HTTP 402). Idempotent by $reference.
*/
public function debit(string $publicId, int $amountMinor, string $source, string $reference, ?string $description = null): bool
{
$res = Http::withToken($this->token())->acceptJson()->timeout(10)->post($this->base().'/debit', array_filter([
'user' => $publicId,
'amount_minor' => $amountMinor,
'service' => (string) config('billing.service', 'crm'),
'source' => $source,
'reference' => $reference,
'description' => $description,
], static fn ($v) => $v !== null));
if ($res->status() === 402) {
return false;
}
$res->throw();
return true;
}
}
@@ -0,0 +1,76 @@
<?php
namespace App\Services\Billing;
use App\Models\CrmPurchase;
use Illuminate\Support\Str;
class OneTimePurchaseService
{
public function __construct(private readonly BillingClient $billing)
{
}
/** @return array<string, mixed>|null */
public function product(string $productKey): ?array
{
$product = config('crm_products.'.$productKey);
return is_array($product) ? $product : null;
}
public function hasPurchased(string $ownerRef, string $productKey): bool
{
return CrmPurchase::has($ownerRef, $productKey);
}
public function priceMinor(string $productKey): int
{
return (int) ($this->product($productKey)['price_minor'] ?? 0);
}
public function purchase(string $ownerRef, string $productKey): bool
{
if ($this->hasPurchased($ownerRef, $productKey)) {
return true;
}
$product = $this->product($productKey);
if (! $product) {
return false;
}
$costMinor = (int) ($product['price_minor'] ?? 0);
if ($costMinor > 0) {
try {
if (! $this->billing->canAfford($ownerRef, $costMinor)) {
return false;
}
$charged = $this->billing->debit(
$ownerRef,
$costMinor,
'purchase',
'crm-purchase-'.$productKey.'-'.$ownerRef,
'CRM: '.($product['name'] ?? $productKey),
);
if (! $charged) {
return false;
}
} catch (\Throwable) {
return false;
}
}
CrmPurchase::create([
'owner_ref' => $ownerRef,
'product_key' => $productKey,
'cost_minor' => $costMinor,
'purchased_at' => now(),
]);
return true;
}
}
+32
View File
@@ -0,0 +1,32 @@
<?php
namespace App\Services\Comms;
use App\Mail\ContactMessageMail;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Facades\Mail;
/**
* Outbound email to contacts. Uses the app's configured mailer (set MAIL_* to
* a Ladill Bird SMTP credential in production so mail leaves as the account's
* sender). Failures are swallowed + logged; the caller decides what to record.
*/
class EmailService
{
public function send(string $to, string $subject, string $body, ?string $fromName = null, ?string $replyTo = null): bool
{
if (! filter_var($to, FILTER_VALIDATE_EMAIL)) {
return false;
}
try {
Mail::to($to)->send(new ContactMessageMail($subject, $body, $fromName, $replyTo));
return true;
} catch (\Throwable $e) {
Log::warning('CRM email send failed', ['to' => $to, 'error' => $e->getMessage()]);
return false;
}
}
}
+67
View File
@@ -0,0 +1,67 @@
<?php
namespace App\Services\Comms;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Log;
/**
* Outbound SMS to contacts via Arkesel (the platform SMS provider). Low-volume,
* best-effort transactional send mirrors the platform's direct Arkesel calls;
* uses the shared ARKESEL_API_KEY. Failures are swallowed + logged.
*/
class SmsService
{
public function send(string $to, string $message): bool
{
$apiKey = (string) config('arkesel.api_key', '');
$sender = (string) config('arkesel.sender_id', 'Ladill');
$msisdn = $this->normalise($to);
if ($apiKey === '' || $msisdn === null) {
return false;
}
try {
$res = Http::timeout(20)
->withHeaders(['api-key' => $apiKey])
->acceptJson()
->post(rtrim((string) config('arkesel.base_url', 'https://sms.arkesel.com'), '/').'/api/v2/sms/send', [
'sender' => $sender,
'message' => $message,
'recipients' => [$msisdn],
]);
return $res->successful() && ($res->json('status') === 'success');
} catch (\Throwable $e) {
Log::warning('CRM SMS send failed', ['to' => $msisdn, 'error' => $e->getMessage()]);
return false;
}
}
/** Normalise to E.164-without-plus (e.g. 233XXXXXXXXX). */
private function normalise(string $to): ?string
{
$digits = preg_replace('/\D+/', '', $to) ?? '';
$country = (string) config('arkesel.default_country_code', '233');
if (strlen($digits) < 9) {
return null;
}
if (str_starts_with($digits, $country)) {
return $digits;
}
if (str_starts_with($digits, '0')) {
return $country.substr($digits, 1);
}
if (strlen($digits) === 9) {
return $country.$digits;
}
return $digits;
}
}
@@ -0,0 +1,112 @@
<?php
namespace App\Services\CrossApp;
use App\Models\Customer;
use App\Models\Deal;
use App\Support\CrmPrefillCodec;
use App\Support\LadillAppUrl;
class CrossAppLinkService
{
public function invoiceFromDeal(Deal $deal): string
{
$deal->loadMissing(['customer', 'lines']);
$customer = $deal->customer;
$lines = $deal->lines->map(fn ($line) => [
'description' => $line->description,
'quantity' => (float) $line->quantity,
'unit_price' => number_format($line->unit_price_minor / 100, 2, '.', ''),
])->values()->all();
if ($lines === [] && (int) $deal->value_minor > 0) {
$lines = [[
'description' => $deal->title,
'quantity' => 1,
'unit_price' => number_format($deal->value_minor / 100, 2, '.', ''),
]];
}
$prefill = CrmPrefillCodec::encode([
'kind' => 'invoice',
'crm_customer_id' => $customer?->id,
'client_name' => $customer?->name ?: $deal->title,
'client_email' => $customer?->email,
'client_address' => $customer ? $this->formatAddress($customer) : null,
'notes' => 'Invoice for CRM deal: '.$deal->title,
'payment_enabled' => true,
'lines' => $lines,
]);
return LadillAppUrl::connect('invoice', '/invoices/create?prefill='.urlencode($prefill));
}
public function merchantStorefrontFromDeal(Deal $deal): string
{
$deal->loadMissing('customer');
$amount = number_format(((int) $deal->value_minor) / 100, 2, '.', '');
$itemName = $deal->title;
$prefill = CrmPrefillCodec::encode([
'kind' => 'merchant_storefront',
'type' => 'shop',
'label' => 'Payment — '.$deal->title,
'shop_title' => $deal->customer?->company ?: $deal->title,
'sections' => [[
'name' => 'Payment',
'items' => [[
'name' => $itemName,
'description' => $deal->notes ?: 'Payment for '.$deal->title,
'price' => $amount,
'image_path' => '',
]],
]],
'accepts_payment' => true,
]);
return LadillAppUrl::connect('merchant', '/storefronts/create?prefill='.urlencode($prefill));
}
public function businessQrFromContact(Customer $contact): string
{
$prefill = CrmPrefillCodec::encode([
'kind' => 'qr_business',
'type' => 'business',
'label' => 'Card — '.$contact->name,
'name' => $contact->company ?: $contact->name,
'phone' => $contact->phone,
'email' => $contact->email,
'address' => $this->formatAddress($contact, singleLine: true),
]);
return LadillAppUrl::connect('qrplus', '/qr-codes/create?prefill='.urlencode($prefill));
}
public function eventsHubForContact(Customer $contact): string
{
$query = http_build_query(array_filter([
'search' => $contact->company ?: $contact->name,
]));
return LadillAppUrl::connect('events', '/attendees'.($query !== '' ? '?'.$query : ''));
}
private function formatAddress(Customer $contact, bool $singleLine = false): ?string
{
$parts = array_filter([
$contact->address_line1,
$contact->address_line2,
trim(implode(', ', array_filter([$contact->city, $contact->region, $contact->country]))),
]);
if ($parts === []) {
return null;
}
return $singleLine
? implode(', ', $parts)
: implode("\n", $parts);
}
}
@@ -0,0 +1,23 @@
<?php
namespace App\Services\Events;
/**
* HMAC-SHA256 signing for platform service-event webhooks.
*/
class ServiceEventSignature
{
public static function sign(string $body, string $secret): string
{
return 'sha256='.hash_hmac('sha256', $body, $secret);
}
public static function verify(string $body, string $signature, string $secret): bool
{
if ($secret === '' || $signature === '') {
return false;
}
return hash_equals(self::sign($body, $secret), $signature);
}
}
@@ -0,0 +1,114 @@
<?php
namespace App\Services\Integrations;
use App\Models\Recording;
use App\Models\Room;
use App\Models\Session;
use App\Models\WebhookEndpoint;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Log;
class WebhookDispatcher
{
public function meetingStarted(Session $session): void
{
$session->loadMissing('room');
$this->dispatch($session->room->organization_id, 'meeting.started', $this->sessionPayload($session));
}
public function meetingEnded(Session $session): void
{
$session->loadMissing('room');
$this->dispatch($session->room->organization_id, 'meeting.ended', $this->sessionPayload($session));
}
public function recordingReady(Recording $recording): void
{
$recording->loadMissing('session.room');
$payload = array_merge($this->sessionPayload($recording->session), [
'recording' => [
'uuid' => $recording->uuid,
'status' => $recording->status,
'duration_seconds' => $recording->duration_seconds,
'download_url' => route('meet.recordings.download', $recording),
],
]);
$this->dispatch($recording->session->room->organization_id, 'recording.ready', $payload);
}
public function roomCreated(Room $room): void
{
$this->dispatch($room->organization_id, 'meeting.scheduled', $this->roomPayload($room));
}
public function roomCancelled(Room $room): void
{
$this->dispatch($room->organization_id, 'meeting.cancelled', $this->roomPayload($room));
}
/** @param array<string, mixed> $payload */
protected function dispatch(int $organizationId, string $event, array $payload): void
{
$body = array_merge(['event' => $event, 'timestamp' => now()->toIso8601String()], $payload);
WebhookEndpoint::query()
->where('organization_id', $organizationId)
->where('is_active', true)
->get()
->filter(fn (WebhookEndpoint $endpoint) => $endpoint->subscribesTo($event))
->each(fn (WebhookEndpoint $endpoint) => $this->send($endpoint, $body));
}
/** @param array<string, mixed> $payload */
protected function send(WebhookEndpoint $endpoint, array $payload): void
{
$body = json_encode($payload);
$headers = ['Content-Type' => 'application/json', 'X-Ladill-Meet-Event' => $payload['event']];
if ($endpoint->secret) {
$headers['X-Ladill-Meet-Signature'] = hash_hmac('sha256', $body, $endpoint->secret);
}
try {
Http::timeout(10)->withHeaders($headers)->withBody($body, 'application/json')->post($endpoint->url);
} catch (\Throwable $e) {
Log::warning('Meet webhook delivery failed', [
'endpoint_id' => $endpoint->id,
'url' => $endpoint->url,
'error' => $e->getMessage(),
]);
}
}
/** @return array<string, mixed> */
protected function roomPayload(Room $room): array
{
return [
'room' => [
'uuid' => $room->uuid,
'title' => $room->title,
'status' => $room->status,
'type' => $room->type,
'scheduled_at' => $room->scheduled_at?->toIso8601String(),
'join_url' => $room->joinUrl(),
'source' => $room->source,
],
];
}
/** @return array<string, mixed> */
protected function sessionPayload(Session $session): array
{
return array_merge($this->roomPayload($session->room), [
'session' => [
'uuid' => $session->uuid,
'status' => $session->status,
'started_at' => $session->started_at?->toIso8601String(),
'ended_at' => $session->ended_at?->toIso8601String(),
'peak_participants' => $session->peak_participants,
],
]);
}
}
+46
View File
@@ -0,0 +1,46 @@
<?php
namespace App\Services\Meet;
use App\Models\Session;
use Illuminate\Support\Collection;
class AttendanceService
{
/**
* @return Collection<int, array<string, mixed>>
*/
public function forSession(Session $session): Collection
{
return $session->participants()
->orderBy('joined_at')
->get()
->map(fn ($p) => [
'display_name' => $p->display_name,
'email' => $p->email,
'role' => $p->role,
'joined_at' => $p->joined_at?->toIso8601String(),
'left_at' => $p->left_at?->toIso8601String(),
'duration_minutes' => ($p->joined_at && $p->left_at)
? (int) $p->joined_at->diffInMinutes($p->left_at)
: ($p->joined_at ? (int) $p->joined_at->diffInMinutes(now()) : 0),
]);
}
public function toCsv(Session $session): string
{
$rows = [['Name', 'Email', 'Role', 'Joined', 'Left', 'Duration (min)']];
foreach ($this->forSession($session) as $row) {
$rows[] = [
$row['display_name'],
$row['email'] ?? '',
$row['role'],
$row['joined_at'] ?? '',
$row['left_at'] ?? '',
(string) $row['duration_minutes'],
];
}
return collect($rows)->map(fn ($r) => implode(',', array_map(fn ($c) => '"'.str_replace('"', '""', $c).'"', $r)))->implode("\n");
}
}
+28
View File
@@ -0,0 +1,28 @@
<?php
namespace App\Services\Meet;
use App\Models\AuditLog;
class AuditLogger
{
public static function record(
string $ownerRef,
string $action,
?int $organizationId = null,
?string $actorRef = null,
?string $subjectType = null,
?int $subjectId = null,
?array $metadata = null,
): AuditLog {
return AuditLog::record(
$ownerRef,
$action,
$organizationId,
$actorRef,
$subjectType,
$subjectId,
$metadata,
);
}
}
+49
View File
@@ -0,0 +1,49 @@
<?php
namespace App\Services\Meet;
use App\Models\Room;
use Illuminate\Support\Carbon;
class AvailabilityService
{
/**
* @return array{available: bool, conflicts: array<int, array<string, mixed>>}
*/
public function checkHostAvailability(
string $hostUserRef,
int $organizationId,
Carbon $start,
int $durationMinutes,
?int $excludeRoomId = null,
): array {
$end = (clone $start)->addMinutes($durationMinutes);
$conflicts = Room::query()
->where('organization_id', $organizationId)
->where('host_user_ref', $hostUserRef)
->whereIn('status', ['scheduled', 'live'])
->when($excludeRoomId, fn ($q) => $q->where('id', '!=', $excludeRoomId))
->whereNotNull('scheduled_at')
->get()
->filter(function (Room $room) use ($start, $end) {
$roomStart = Carbon::parse($room->scheduled_at);
$roomEnd = (clone $roomStart)->addMinutes($room->duration_minutes ?? 60);
return $start->lt($roomEnd) && $end->gt($roomStart);
})
->map(fn (Room $room) => [
'uuid' => $room->uuid,
'title' => $room->title,
'scheduled_at' => $room->scheduled_at?->toIso8601String(),
'duration_minutes' => $room->duration_minutes,
])
->values()
->all();
return [
'available' => count($conflicts) === 0,
'conflicts' => $conflicts,
];
}
}
+106
View File
@@ -0,0 +1,106 @@
<?php
namespace App\Services\Meet;
use App\Models\BreakoutAssignment;
use App\Models\BreakoutRoom;
use App\Models\Message;
use App\Models\Participant;
use App\Models\Session;
use App\Services\Meet\Media\MediaProviderInterface;
use Illuminate\Support\Str;
class BreakoutService
{
public function __construct(
protected MediaProviderInterface $media,
) {}
/**
* @param array<int, array{participant_id: int, breakout_room_id?: int}>|null $assignments
* @return \Illuminate\Support\Collection<int, BreakoutRoom>
*/
public function createRooms(Session $session, int $count, ?int $timerMinutes = null, ?array $assignments = null)
{
$timerMinutes ??= config('meet.breakouts.default_timer_minutes', 15);
$closesAt = now()->addMinutes($timerMinutes);
$rooms = collect();
for ($i = 1; $i <= $count; $i++) {
$mediaName = $session->media_room_name.'-breakout-'.Str::random(8);
$this->media->createRoom($mediaName);
$rooms->push(BreakoutRoom::create([
'owner_ref' => $session->owner_ref,
'session_id' => $session->id,
'name' => 'Breakout '.$i,
'media_room_name' => $mediaName,
'status' => 'open',
'closes_at' => $closesAt,
]));
}
$participants = $session->participants()->where('status', 'joined')->get();
if ($assignments) {
foreach ($assignments as $assignment) {
$this->assignParticipant(
$rooms->firstWhere('id', $assignment['breakout_room_id']) ?? $rooms->first(),
Participant::findOrFail($assignment['participant_id']),
);
}
} else {
$participants->values()->each(function (Participant $participant, int $index) use ($rooms) {
$this->assignParticipant($rooms[$index % $rooms->count()], $participant);
});
}
return $rooms;
}
public function assignParticipant(BreakoutRoom $breakout, Participant $participant): void
{
BreakoutAssignment::updateOrCreate(
['breakout_room_id' => $breakout->id, 'participant_id' => $participant->id],
);
$participant->update(['breakout_room_id' => $breakout->id]);
}
public function moveParticipant(Participant $participant, BreakoutRoom $breakout): void
{
$this->assignParticipant($breakout, $participant);
}
public function returnToMain(Participant $participant): void
{
$participant->update(['breakout_room_id' => null]);
BreakoutAssignment::where('participant_id', $participant->id)->delete();
}
public function broadcast(Session $session, string $message, string $senderName): Message
{
return Message::create([
'owner_ref' => $session->owner_ref,
'session_id' => $session->id,
'sender_name' => $senderName,
'type' => 'breakout_broadcast',
'body' => $message,
]);
}
public function closeAll(Session $session): void
{
$session->breakoutRooms()->update(['status' => 'closed']);
$session->participants()->whereNotNull('breakout_room_id')->update(['breakout_room_id' => null]);
BreakoutAssignment::whereIn('breakout_room_id', $session->breakoutRooms()->pluck('id'))->delete();
}
public function mediaRoomForParticipant(Participant $participant): string
{
if ($participant->breakout_room_id) {
return $participant->breakoutRoom?->media_room_name ?? $participant->session->media_room_name;
}
return $participant->session->media_room_name;
}
}

Some files were not shown because too many files have changed in this diff Show More