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