diff --git a/app/Http/Controllers/Meet/MeetingRoomController.php b/app/Http/Controllers/Meet/MeetingRoomController.php index 3a7446c..4a71351 100644 --- a/app/Http/Controllers/Meet/MeetingRoomController.php +++ b/app/Http/Controllers/Meet/MeetingRoomController.php @@ -79,8 +79,11 @@ class MeetingRoomController extends Controller $this->sessions->end($session, $participant->user_ref ?? $participant->uuid); - return redirect()->route('meet.rooms.show', $session->room) - ->with('success', 'Meeting ended.'); + $room = $session->room; + $route = $room->isWebinar() ? 'meet.webinars.show' : 'meet.rooms.show'; + + return redirect()->route($route, $room) + ->with('success', $room->isWebinar() ? 'Webinar ended.' : 'Meeting ended.'); } public function leave(Request $request, Session $session): RedirectResponse diff --git a/app/Http/Controllers/Meet/RoomController.php b/app/Http/Controllers/Meet/RoomController.php index 2e1b52c..4e40807 100644 --- a/app/Http/Controllers/Meet/RoomController.php +++ b/app/Http/Controllers/Meet/RoomController.php @@ -44,7 +44,8 @@ class RoomController extends Controller $organization = $this->organization($request); $owner = $this->ownerRef($request); - $query = Room::owned($owner)->where('organization_id', $organization->id); + $query = Room::owned($owner)->where('organization_id', $organization->id) + ->where('type', '!=', 'webinar'); $this->scopeToBranch($request, $query); $rooms = $query->orderByDesc('scheduled_at')->paginate(20); @@ -79,7 +80,7 @@ class RoomController extends Controller $validated = $request->validate([ 'title' => ['required', 'string', 'max:255'], 'description' => ['nullable', 'string', 'max:2000'], - 'type' => ['nullable', 'string', 'in:scheduled,webinar,town_hall'], + 'type' => ['nullable', 'string', 'in:scheduled,town_hall'], 'scheduled_at' => ['nullable', 'date'], 'duration_minutes' => ['nullable', 'integer', 'min:15', 'max:480'], 'timezone' => ['nullable', 'timezone'], @@ -104,7 +105,7 @@ class RoomController extends Controller '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', + 'stage_mode' => false, ]; $data = array_merge($validated, [ @@ -121,12 +122,6 @@ class RoomController extends Controller 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, [ @@ -163,6 +158,10 @@ class RoomController extends Controller $this->authorizeAbility($request, 'meetings.view'); $this->authorizeOwner($request, $room); + if ($room->isWebinar()) { + return redirect()->route('meet.webinars.show', $room); + } + $room->load(['sessions.participants', 'invitations', 'sessions.recordings', 'sessions.aiSummaries', 'sessionFiles', 'webinarRegistrations']); return view('meet.rooms.show', compact('room')); diff --git a/app/Http/Controllers/Meet/WebinarController.php b/app/Http/Controllers/Meet/WebinarController.php new file mode 100644 index 0000000..ec4c6af --- /dev/null +++ b/app/Http/Controllers/Meet/WebinarController.php @@ -0,0 +1,157 @@ +authorizeAbility($request, 'meetings.view'); + $organization = $this->organization($request); + $owner = $this->ownerRef($request); + + $query = Room::owned($owner) + ->where('organization_id', $organization->id) + ->where('type', 'webinar'); + + $this->scopeToBranch($request, $query); + + $webinars = $query->orderByDesc('scheduled_at')->paginate(20); + + return view('meet.webinars.index', compact('webinars', '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.webinars.create', [ + 'organization' => $organization, + 'templates' => $templates, + 'timezones' => timezone_identifiers_list(), + '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'], + 'scheduled_at' => ['required', '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'], + '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' => true, + ]; + + $data = array_merge($validated, [ + 'settings' => $settings, + 'timezone' => $validated['timezone'] ?? $organization->timezone, + 'type' => 'webinar', + 'status' => 'scheduled', + ]); + + if (! empty($validated['template_id'])) { + $template = Template::findOrFail($validated['template_id']); + $data = $this->templates->applyToRoomData($template, $data); + } + + $room = $this->rooms->create($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 ($room->scheduled_at) { + $this->calendar->syncRoomCreated($room, $request->user()); + } + + return redirect()->route('meet.webinars.show', $room)->with('success', 'Webinar created.'); + } + + public function show(Request $request, Room $room): View + { + $this->authorizeAbility($request, 'meetings.view'); + $this->authorizeOwner($request, $room); + abort_unless($room->isWebinar(), 404); + + $room->load(['sessions.participants', 'invitations', 'sessions.recordings', 'sessions.aiSummaries', 'sessionFiles', 'webinarRegistrations']); + + return view('meet.webinars.show', compact('room')); + } + + public function start(Request $request, Room $room): RedirectResponse + { + $this->authorizeAbility($request, 'meetings.host'); + $this->authorizeOwner($request, $room); + abort_unless($room->isWebinar(), 404); + + if ($room->status === 'cancelled') { + return redirect()->route('meet.webinars.show', $room) + ->withErrors(['start' => 'Webinar was cancelled.']); + } + + $session = $this->sessions->start($room, $request->user()); + + $participant = $this->sessions->joinedParticipantForUser($session, $request->user()); + abort_unless($participant, 500, 'Unable to join as host.'); + + $this->sessions->rememberParticipantSession($request, $participant, $session); + + return redirect()->route('meet.room', $session); + } +} diff --git a/app/Models/Room.php b/app/Models/Room.php index 5fd4a1a..bc76ea1 100644 --- a/app/Models/Room.php +++ b/app/Models/Room.php @@ -81,6 +81,28 @@ class Room extends Model return $this->type === 'webinar'; } + public function canRestart(): bool + { + if ($this->status === 'cancelled' || $this->activeSession()) { + return false; + } + + return $this->status === 'ended' + || $this->type === 'personal' + || $this->status === 'scheduled'; + } + + public function restartLabel(): string + { + $restarting = $this->status === 'ended' || $this->sessions()->exists(); + + if ($this->isWebinar()) { + return $restarting ? 'Restart webinar' : 'Start webinar'; + } + + return $restarting ? 'Restart meeting' : 'Start meeting'; + } + public function isTownHall(): bool { return $this->type === 'town_hall'; diff --git a/app/Services/Meet/LicenseService.php b/app/Services/Meet/LicenseService.php index e08f41a..d6a6711 100644 --- a/app/Services/Meet/LicenseService.php +++ b/app/Services/Meet/LicenseService.php @@ -3,6 +3,7 @@ namespace App\Services\Meet; use App\Models\Organization; +use App\Models\Room; use App\Models\UsageRecord; class LicenseService @@ -59,8 +60,8 @@ class LicenseService return ($used + $additional) <= $limit; } - public function assertCanStartSession(Organization $organization, ?int $branchId = null): void + public function assertCanStartSession(Organization $organization, ?int $branchId = null, ?Room $room = null): void { - app(MeetBillingService::class)->assertCanStartSession($organization); + app(MeetBillingService::class)->assertCanStartSession($organization, $room); } } diff --git a/app/Services/Meet/MeetBillingService.php b/app/Services/Meet/MeetBillingService.php index 589b277..551c31c 100644 --- a/app/Services/Meet/MeetBillingService.php +++ b/app/Services/Meet/MeetBillingService.php @@ -4,6 +4,7 @@ namespace App\Services\Meet; use App\Models\Organization; use App\Models\Recording; +use App\Models\Room; use App\Models\Session; use App\Models\SessionFile; use App\Models\UsageRecord; @@ -27,6 +28,16 @@ class MeetBillingService return (float) config('meet.billing.price_per_hour_ghs', 0.30); } + public function pricePerParticipantGhs(): float + { + return (float) config('meet.billing.price_per_participant_ghs', 0.30); + } + + public function usesParticipantBilling(?Room $room): bool + { + return $room?->isWebinar() ?? false; + } + public function pricePerGbMonthGhs(): float { return (float) config('meet.billing.price_per_gb_month_ghs', 0.30); @@ -66,6 +77,18 @@ class MeetBillingService return max((int) round($ghs * 100), 1); } + public function participantsBillableFor(Session $session): int + { + return max(1, (int) ($session->peak_participants ?? 0)); + } + + public function amountMinorForParticipants(int $count): int + { + $ghs = round($count * $this->pricePerParticipantGhs(), 2); + + return max((int) round($ghs * 100), 1); + } + public function amountMinorForStorageBytes(int $bytes): int { if ($bytes <= 0) { @@ -77,11 +100,23 @@ class MeetBillingService return max((int) round($ghs * 100), 1); } - public function assertCanStartSession(Organization $organization): void + public function assertCanStartSession(Organization $organization, ?Room $room = null): void { - $amountMinor = $this->amountMinorForStreamingHours( - max(1, (int) config('meet.billing.minimum_streaming_hours', 1)) - ); + if ($this->usesParticipantBilling($room)) { + $amountMinor = $this->amountMinorForParticipants(1); + $message = sprintf( + 'Insufficient wallet balance. Webinars are billed at GHS %.2f per participant — top up your Ladill wallet to start.', + $this->pricePerParticipantGhs(), + ); + } else { + $amountMinor = $this->amountMinorForStreamingHours( + max(1, (int) config('meet.billing.minimum_streaming_hours', 1)) + ); + $message = sprintf( + 'Insufficient wallet balance. Meetings are billed at GHS %.2f per hour — top up your Ladill wallet to start.', + $this->pricePerHourGhs(), + ); + } $affordable = $this->canAffordSafely($organization->owner_ref, $amountMinor); @@ -89,14 +124,20 @@ class MeetBillingService abort(503, 'Billing is temporarily unavailable. Please try again shortly.'); } - abort_unless( - $affordable, - 402, - sprintf( - 'Insufficient wallet balance. Meetings are billed at GHS %.2f per hour — top up your Ladill wallet to start.', - $this->pricePerHourGhs(), - ), - ); + abort_unless($affordable, 402, $message); + } + + public function chargeSession(Session $session): void + { + $session->loadMissing('room'); + + if ($this->usesParticipantBilling($session->room)) { + $this->chargeParticipants($session); + + return; + } + + $this->chargeStreaming($session); } public function chargeStreaming(Session $session): void @@ -125,6 +166,32 @@ class MeetBillingService ); } + public function chargeParticipants(Session $session): void + { + $session->loadMissing('room.organization'); + $organization = $session->room->organization; + $count = $this->participantsBillableFor($session); + $amountMinor = $this->amountMinorForParticipants($count); + + if ($amountMinor <= 0) { + return; + } + + $this->recordUsage($organization, 'participant_count', $count, [ + 'session_uuid' => $session->uuid, + 'room_uuid' => $session->room->uuid, + 'participants' => $count, + ], $session->room->branch_id); + + $this->debit( + $organization->owner_ref, + $amountMinor, + 'meet_participants', + 'meet-participants-'.$session->uuid, + sprintf('Meet webinar: %s (%d participants)', $session->room->title, $count), + ); + } + public function chargeRecordingStorageInitial(Recording $recording): void { $recording->loadMissing('session.room.organization'); diff --git a/app/Services/Meet/SessionService.php b/app/Services/Meet/SessionService.php index dec07c4..7a8c0a9 100644 --- a/app/Services/Meet/SessionService.php +++ b/app/Services/Meet/SessionService.php @@ -24,7 +24,7 @@ class SessionService return $existing; } - app(LicenseService::class)->assertCanStartSession($room->organization, $room->branch_id); + app(LicenseService::class)->assertCanStartSession($room->organization, $room->branch_id, $room); $session = Session::create([ 'owner_ref' => $room->owner_ref, diff --git a/app/Services/Meet/UsageMeteringService.php b/app/Services/Meet/UsageMeteringService.php index 7449a5a..23008ba 100644 --- a/app/Services/Meet/UsageMeteringService.php +++ b/app/Services/Meet/UsageMeteringService.php @@ -14,7 +14,7 @@ class UsageMeteringService public function recordSessionUsage(Session $session): void { - $this->billing->chargeStreaming($session); + $this->billing->chargeSession($session); } public function recordAiMinutes(Organization $organization, int $minutes, string $sessionUuid): void @@ -49,6 +49,8 @@ class UsageMeteringService foreach ($raw as $metric => $total) { if ($metric === 'streaming_hours') { $summary['streaming_hours'] = round(((int) $total) / 100, 2); + } elseif ($metric === 'participant_count') { + $summary['participant_count'] = (int) $total; } else { $summary[$metric] = (int) $total; } @@ -60,11 +62,13 @@ class UsageMeteringService public function estimatedCostGhs(array $summary): array { $streamingHours = (float) ($summary['streaming_hours'] ?? 0); + $participantCount = (int) ($summary['participant_count'] ?? 0); $recordingBytes = (int) ($summary['recording_storage_bytes'] ?? 0); $fileBytes = (int) ($summary['file_storage_bytes'] ?? 0); return [ 'streaming' => round($streamingHours * $this->billing->pricePerHourGhs(), 2), + 'participants' => round($participantCount * $this->billing->pricePerParticipantGhs(), 2), 'recording_storage' => round($this->billing->storageGb($recordingBytes) * $this->billing->pricePerGbMonthGhs(), 2), 'file_storage' => round($this->billing->storageGb($fileBytes) * $this->billing->pricePerGbMonthGhs(), 2), ]; diff --git a/config/meet.php b/config/meet.php index 1b8e792..702623d 100644 --- a/config/meet.php +++ b/config/meet.php @@ -173,6 +173,7 @@ return [ 'billing' => [ 'price_per_hour_ghs' => (float) env('MEET_PRICE_PER_HOUR', 0.30), + 'price_per_participant_ghs' => (float) env('MEET_PRICE_PER_PARTICIPANT', 0.30), 'price_per_gb_month_ghs' => (float) env('MEET_PRICE_PER_GB_MONTH', 0.30), 'billing_period_days' => (int) env('MEET_BILLING_PERIOD_DAYS', 30), 'grace_period_days' => (int) env('MEET_GRACE_PERIOD_DAYS', 15), diff --git a/resources/views/meet/admin/usage.blade.php b/resources/views/meet/admin/usage.blade.php index 008c83c..0df42b5 100644 --- a/resources/views/meet/admin/usage.blade.php +++ b/resources/views/meet/admin/usage.blade.php @@ -7,6 +7,7 @@

Pay-as-you-go rates

Charges debit your Ladill wallet. Storage renews monthly; unpaid items enter a {{ $billing->gracePeriodDays() }}-day grace period before deletion.

@@ -16,6 +17,7 @@ @php $rows = [ 'streaming_hours' => ['label' => 'Streaming hours', 'used' => number_format($summary['streaming_hours'] ?? 0, 1), 'cost' => $costs['streaming'] ?? 0], + 'participant_count' => ['label' => 'Webinar participants', 'used' => number_format($summary['participant_count'] ?? 0), 'cost' => $costs['participants'] ?? 0], 'recording_storage_bytes' => ['label' => 'Recording storage', 'used' => number_format(($summary['recording_storage_bytes'] ?? 0) / 1073741824, 2).' GB', 'cost' => $costs['recording_storage'] ?? 0], 'file_storage_bytes' => ['label' => 'Shared file storage', 'used' => number_format(($summary['file_storage_bytes'] ?? 0) / 1073741824, 2).' GB', 'cost' => $costs['file_storage'] ?? 0], 'ai_minutes' => ['label' => 'AI minutes', 'used' => number_format($summary['ai_minutes'] ?? 0), 'cost' => null], diff --git a/resources/views/meet/invitations/index.blade.php b/resources/views/meet/invitations/index.blade.php index 6fe1246..4ec0bfb 100644 --- a/resources/views/meet/invitations/index.blade.php +++ b/resources/views/meet/invitations/index.blade.php @@ -4,7 +4,7 @@

Invitations

{{ $room->title }}

- ← Back to meeting + ← Back to {{ $room->isWebinar() ? 'webinar' : 'meeting' }}
diff --git a/resources/views/meet/partials/room-card.blade.php b/resources/views/meet/partials/room-card.blade.php index c1dd98e..8ed05a7 100644 --- a/resources/views/meet/partials/room-card.blade.php +++ b/resources/views/meet/partials/room-card.blade.php @@ -5,11 +5,16 @@ 'ended' => 'bg-slate-100 text-slate-600', 'cancelled' => 'bg-red-50 text-red-700', ]; + $detailsRoute = $detailsRoute ?? route('meet.rooms.show', $room); + $startRoute = $startRoute ?? route('meet.rooms.start', $room); @endphp

{{ $room->title }}

+ @if ($room->isWebinar()) + Webinar + @endif {{ config('meet.room_statuses')[$room->status] ?? $room->status }} @@ -27,12 +32,12 @@ @if ($room->activeSession()) Join @endif - @elseif ($room->status === 'scheduled') -
+ @elseif ($room->canRestart()) + @csrf - +
@endif - Details + Details
diff --git a/resources/views/meet/room/show.blade.php b/resources/views/meet/room/show.blade.php index 4bb42f6..6b7be07 100644 --- a/resources/views/meet/room/show.blade.php +++ b/resources/views/meet/room/show.blade.php @@ -81,10 +81,10 @@

{{ $room->title }}

-

+ x-text="activeSpeakersMessage() || '...'">

diff --git a/resources/views/meet/rooms/show.blade.php b/resources/views/meet/rooms/show.blade.php index 34aaa1b..862322d 100644 --- a/resources/views/meet/rooms/show.blade.php +++ b/resources/views/meet/rooms/show.blade.php @@ -5,7 +5,12 @@

{{ $room->title }}

{{ config('meet.room_statuses')[$room->status] ?? $room->status }}

- @if ($room->status === 'scheduled') + @if ($room->canRestart()) +
+ @csrf + +
+ @elseif ($room->status === 'scheduled')
@csrf @@ -33,9 +38,11 @@ Attendance CSV @endif Manage invitations - @if ($room->isWebinar()) - Registrations - Registration page + @if ($room->canRestart() && ($room->status === 'ended' || $room->type === 'personal' || $room->sessions->isNotEmpty())) + + @csrf + +
@endif
diff --git a/resources/views/meet/webinar/registrations.blade.php b/resources/views/meet/webinar/registrations.blade.php index 4976e59..4a2f582 100644 --- a/resources/views/meet/webinar/registrations.blade.php +++ b/resources/views/meet/webinar/registrations.blade.php @@ -4,6 +4,7 @@

Registrations

{{ $room->title }}

+ ← Back to webinar
Public registration page
diff --git a/resources/views/meet/webinars/create.blade.php b/resources/views/meet/webinars/create.blade.php new file mode 100644 index 0000000..f9dae0d --- /dev/null +++ b/resources/views/meet/webinars/create.blade.php @@ -0,0 +1,80 @@ + +
+

Schedule a webinar

+

Attendees register in advance. Billed at GHS {{ number_format(config('meet.billing.price_per_participant_ghs', 0.30), 2) }} per participant when the session ends.

+ +
+ @csrf + + @if ($templates->isNotEmpty()) +
+ + +
+ @endif + +
+ + +
+ +
+ + +
+ +
+ + +
+ +
+
+ + +
+
+ + +
+
+ +
+ + +
+ +
+ + +
+ +
+ Webinar options + + + + + + +
+ + +
+
+
diff --git a/resources/views/meet/webinars/index.blade.php b/resources/views/meet/webinars/index.blade.php new file mode 100644 index 0000000..1742492 --- /dev/null +++ b/resources/views/meet/webinars/index.blade.php @@ -0,0 +1,24 @@ + +
+
+

Webinars

+

Stage-mode events billed at GHS {{ number_format(config('meet.billing.price_per_participant_ghs', 0.30), 2) }} per participant.

+
+ Schedule webinar +
+ +
+ @forelse ($webinars as $room) + @include('meet.partials.room-card', [ + 'room' => $room, + 'live' => $room->status === 'live', + 'detailsRoute' => route('meet.webinars.show', $room), + 'startRoute' => route('meet.webinars.start', $room), + ]) + @empty +

No webinars yet.

+ @endforelse +
+ +
{{ $webinars->links() }}
+
diff --git a/resources/views/meet/webinars/show.blade.php b/resources/views/meet/webinars/show.blade.php new file mode 100644 index 0000000..2f9e67e --- /dev/null +++ b/resources/views/meet/webinars/show.blade.php @@ -0,0 +1,121 @@ + +
+
+
+

Webinar

+

{{ $room->title }}

+

{{ config('meet.room_statuses')[$room->status] ?? $room->status }}

+
+ @if ($room->canRestart()) +
+ @csrf + +
+ @endif +
+ + @if ($errors->any()) +
+ {{ $errors->first() }} +
+ @endif + +
+ Conference billing: GHS {{ number_format(config('meet.billing.price_per_participant_ghs', 0.30), 2) }} per participant (peak attendance when the session ends). +
+ +
+

Registration link

+
+ + +
+ +

Join link (hosts & panelists)

+
+ + +
+ +
+ Download iCal + QR code + @if ($room->sessions->isNotEmpty()) + Attendance CSV + @endif + Manage invitations + @if ($room->canRestart() && ($room->status === 'ended' || $room->sessions->isNotEmpty())) +
+ @csrf + +
+ @endif + Registrations + Registration page +
+ + @if ($room->passcode) +

Passcode: {{ $room->passcode }}

+ @endif + + @if ($room->scheduled_at) +

+ Scheduled: {{ $room->scheduled_at->timezone($room->timezone)->format('M j, Y g:i A T') }} +

+ @endif +
+ + @php + $recordings = $room->sessions->flatMap->recordings->sortByDesc('created_at'); + $summaries = $room->sessions->flatMap->aiSummaries->where('status', 'ready')->sortByDesc('created_at'); + @endphp + + @if ($recordings->isNotEmpty()) +
+

Recordings

+ +
+ @endif + + @if ($summaries->isNotEmpty()) +
+

AI summaries

+ +
+ @endif + + @if ($room->sessions->isNotEmpty()) +
+

Sessions

+
    + @foreach ($room->sessions as $session) +
  • + {{ $session->started_at?->format('M j, Y g:i A') ?? 'Pending' }} + {{ $session->peak_participants }} participants · {{ config('meet.session_statuses')[$session->status] ?? $session->status }} +
  • + @endforeach +
+
+ @endif + + @if ($room->status === 'scheduled') +
+ @csrf + +
+ @endif +
+
diff --git a/resources/views/partials/sidebar.blade.php b/resources/views/partials/sidebar.blade.php index 5e9fbab..a08fb9a 100644 --- a/resources/views/partials/sidebar.blade.php +++ b/resources/views/partials/sidebar.blade.php @@ -13,8 +13,10 @@ $nav = [ ['name' => 'Dashboard', 'route' => route('meet.dashboard'), 'active' => request()->routeIs('meet.dashboard'), 'icon' => ''], - ['name' => 'Meetings', 'route' => route('meet.rooms.index'), 'active' => request()->routeIs('meet.rooms.*'), + ['name' => 'Meetings', 'route' => route('meet.rooms.index'), 'active' => request()->routeIs('meet.rooms.*') && ! request()->routeIs('meet.webinar.*'), 'icon' => ''], + ['name' => 'Webinars', 'route' => route('meet.webinars.index'), 'active' => request()->routeIs('meet.webinars.*') || request()->routeIs('meet.webinar.*'), + 'icon' => ''], ['name' => 'Recordings', 'route' => route('meet.recordings.index'), 'active' => request()->routeIs('meet.recordings.*'), 'icon' => ''], ['name' => 'Reports', 'route' => route('meet.reports.index'), 'active' => request()->routeIs('meet.reports.*'), diff --git a/routes/web.php b/routes/web.php index dba2462..520c8c2 100644 --- a/routes/web.php +++ b/routes/web.php @@ -22,6 +22,7 @@ use App\Http\Controllers\Meet\SettingsController; use App\Http\Controllers\Meet\SummaryController; use App\Http\Controllers\Meet\TemplateController; use App\Http\Controllers\Meet\WebinarRegistrationController; +use App\Http\Controllers\Meet\WebinarController; use App\Http\Controllers\Meet\WebhookSettingsController; use App\Http\Controllers\NotificationController; use App\Http\Controllers\WalletBalanceController; @@ -123,6 +124,13 @@ Route::middleware(['auth', 'platform.session'])->group(function () { Route::get('/meetings/{room}/ical', [RoomController::class, 'ical'])->name('meet.rooms.ical'); Route::get('/meetings/{room}/qr', [RoomController::class, 'qr'])->name('meet.rooms.qr'); Route::get('/meetings/{room}/attendance', [RoomController::class, 'attendance'])->name('meet.rooms.attendance'); + + Route::get('/webinars', [WebinarController::class, 'index'])->name('meet.webinars.index'); + Route::get('/webinars/create', [WebinarController::class, 'create'])->name('meet.webinars.create'); + Route::post('/webinars', [WebinarController::class, 'store'])->name('meet.webinars.store'); + Route::get('/webinars/{room}', [WebinarController::class, 'show'])->name('meet.webinars.show'); + Route::post('/webinars/{room}/start', [WebinarController::class, 'start'])->name('meet.webinars.start'); + Route::get('/meetings/{room}/webinar/registrations', [WebinarRegistrationController::class, 'manage'])->name('meet.webinar.registrations'); Route::post('/meetings/{room}/webinar/registrations/{registration}/approve', [WebinarRegistrationController::class, 'approve'])->name('meet.webinar.registrations.approve'); Route::post('/meetings/{room}/webinar/registrations/{registration}/reject', [WebinarRegistrationController::class, 'reject'])->name('meet.webinar.registrations.reject'); diff --git a/tests/Feature/MeetBillingTest.php b/tests/Feature/MeetBillingTest.php index 51c76bc..e88a75f 100644 --- a/tests/Feature/MeetBillingTest.php +++ b/tests/Feature/MeetBillingTest.php @@ -66,6 +66,49 @@ class MeetBillingTest extends TestCase && ($request->data()['source'] ?? '') === 'meet_streaming'); } + public function test_webinar_session_end_charges_per_participant(): void + { + $this->fakeAffordableBilling(); + + $organization = Organization::create([ + 'owner_ref' => 'owner-webinar', + 'name' => 'Webinar Org', + 'slug' => 'webinar-org', + 'timezone' => 'UTC', + ]); + + $room = Room::create([ + 'owner_ref' => 'owner-webinar', + 'organization_id' => $organization->id, + 'host_user_ref' => 'owner-webinar', + 'title' => 'Webinar Room', + 'type' => 'webinar', + 'status' => 'ended', + 'settings' => config('meet.default_settings'), + ]); + + $session = Session::create([ + 'owner_ref' => 'owner-webinar', + 'room_id' => $room->id, + 'media_room_name' => 'webinar-test', + 'status' => 'ended', + 'started_at' => now()->subHour(), + 'ended_at' => now(), + 'peak_participants' => 5, + ]); + + app(UsageMeteringService::class)->recordSessionUsage($session); + + $this->assertDatabaseHas('meet_usage_records', [ + 'organization_id' => $organization->id, + 'metric' => 'participant_count', + 'quantity' => 5, + ]); + + Http::assertSent(fn ($request) => str_contains($request->url(), '/debit') + && ($request->data()['source'] ?? '') === 'meet_participants'); + } + public function test_storage_renewal_enters_grace_when_wallet_is_empty(): void { $base = rtrim((string) config('billing.api_url'), '/'); diff --git a/tests/Feature/MeetWebTest.php b/tests/Feature/MeetWebTest.php index 6948c7c..91098ef 100644 --- a/tests/Feature/MeetWebTest.php +++ b/tests/Feature/MeetWebTest.php @@ -541,9 +541,8 @@ class MeetWebTest extends TestCase public function test_host_can_create_webinar_room(): void { $this->actingAs($this->user) - ->post('/meetings', [ + ->post('/webinars', [ 'title' => 'Product launch', - 'type' => 'webinar', 'scheduled_at' => now()->addDay()->format('Y-m-d\TH:i'), 'duration_minutes' => 60, ]) diff --git a/tests/Unit/MeetBillingServiceTest.php b/tests/Unit/MeetBillingServiceTest.php index 9bf4545..a189daa 100644 --- a/tests/Unit/MeetBillingServiceTest.php +++ b/tests/Unit/MeetBillingServiceTest.php @@ -30,6 +30,16 @@ class MeetBillingServiceTest extends TestCase $this->assertSame(60, $billing->amountMinorForStreamingHours(2)); } + public function test_participant_amount_minor_matches_rate(): void + { + config(['meet.billing.price_per_participant_ghs' => 0.30]); + + $billing = app(MeetBillingService::class); + + $this->assertSame(30, $billing->amountMinorForParticipants(1)); + $this->assertSame(90, $billing->amountMinorForParticipants(3)); + } + public function test_storage_amount_minor_matches_gb_monthly_rate(): void { config(['meet.billing.price_per_gb_month_ghs' => 0.30]);