Files
ladill-meet/app/Services/Meet/ReportService.php
T
isaaccladandCursor 965fb992e9
Deploy Ladill Meet / deploy (push) Failing after 7s
Initial Ladill Meet release.
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>
2026-06-30 23:35:29 +00:00

248 lines
9.4 KiB
PHP

<?php
namespace App\Services\Meet;
use App\Models\Invitation;
use App\Models\Member;
use App\Models\Participant;
use App\Models\Recording;
use App\Models\Room;
use App\Models\Session;
use App\Models\User;
use Illuminate\Support\Carbon;
use Illuminate\Support\Collection;
class ReportService
{
/**
* @return array<string, mixed>
*/
public function orgSummary(string $ownerRef, int $organizationId, Carbon $from, Carbon $to, ?int $branchId = null): array
{
$rooms = $this->roomQuery($ownerRef, $organizationId, $branchId)
->whereBetween('scheduled_at', [$from, $to]);
$sessions = Session::owned($ownerRef)
->whereHas('room', fn ($q) => $q->where('organization_id', $organizationId)
->when($branchId, fn ($q2) => $q2->where('branch_id', $branchId)))
->whereBetween('started_at', [$from, $to]);
$recordings = Recording::owned($ownerRef)
->whereHas('session.room', fn ($q) => $q->where('organization_id', $organizationId)
->when($branchId, fn ($q2) => $q2->where('branch_id', $branchId)))
->whereBetween('created_at', [$from, $to]);
$totalMinutes = (clone $sessions)->whereNotNull('ended_at')->get()->sum(function (Session $s) {
return $s->started_at && $s->ended_at ? (int) $s->started_at->diffInMinutes($s->ended_at) : 0;
});
return [
'meetings_scheduled' => (clone $rooms)->count(),
'meetings_completed' => (clone $rooms)->where('status', 'ended')->count(),
'meetings_cancelled' => (clone $rooms)->where('status', 'cancelled')->count(),
'sessions_held' => (clone $sessions)->count(),
'total_minutes' => $totalMinutes,
'peak_participants' => (clone $sessions)->max('peak_participants') ?? 0,
'recordings_count' => (clone $recordings)->count(),
'recording_storage_bytes' => (int) (clone $recordings)->where('status', 'ready')->sum('file_size'),
'active_members' => Member::owned($ownerRef)->where('organization_id', $organizationId)->count(),
];
}
/**
* @return array<string, mixed>
*/
public function meetingsReport(string $ownerRef, int $organizationId, Carbon $from, Carbon $to, ?int $branchId = null): array
{
$rooms = $this->roomQuery($ownerRef, $organizationId, $branchId)
->whereBetween('scheduled_at', [$from, $to])
->with(['sessions'])
->orderBy('scheduled_at')
->get();
$rows = $rooms->map(fn (Room $room) => [
'title' => $room->title,
'type' => $room->type,
'status' => $room->status,
'scheduled_at' => $room->scheduled_at?->toIso8601String(),
'duration_minutes' => $room->duration_minutes,
'sessions' => $room->sessions->count(),
'peak_participants' => $room->sessions->max('peak_participants') ?? 0,
'invitations' => $room->invitations()->count(),
'accepted' => $room->invitations()->where('status', 'accepted')->count(),
]);
return [
'total' => $rows->count(),
'by_type' => $rows->groupBy('type')->map->count(),
'rows' => $rows,
];
}
/**
* @return array<string, mixed>
*/
public function usersReport(string $ownerRef, int $organizationId, Carbon $from, Carbon $to, ?int $branchId = null): array
{
$participants = Participant::owned($ownerRef)
->whereHas('session.room', fn ($q) => $q->where('organization_id', $organizationId)
->when($branchId, fn ($q2) => $q2->where('branch_id', $branchId)))
->whereBetween('joined_at', [$from, $to])
->get();
$hosts = Room::owned($ownerRef)
->where('organization_id', $organizationId)
->when($branchId, fn ($q) => $q->where('branch_id', $branchId))
->whereBetween('scheduled_at', [$from, $to])
->select('host_user_ref')
->distinct()
->count('host_user_ref');
$byUser = $participants->groupBy('user_ref')->map(function (Collection $group, ?string $userRef) {
$minutes = $group->sum(fn (Participant $p) => $p->joined_at && $p->left_at
? (int) $p->joined_at->diffInMinutes($p->left_at)
: ($p->joined_at ? (int) $p->joined_at->diffInMinutes(now()) : 0));
return [
'user_ref' => $userRef,
'display_name' => $group->first()->display_name,
'meetings_joined' => $group->count(),
'minutes' => $minutes,
'hosted' => $group->where('role', 'host')->count(),
];
})->sortByDesc('minutes')->values();
return [
'unique_participants' => $participants->pluck('user_ref')->filter()->unique()->count(),
'unique_hosts' => $hosts,
'top_users' => $byUser->take(20),
];
}
/**
* @return array<string, mixed>
*/
public function invitationsReport(string $ownerRef, int $organizationId, Carbon $from, Carbon $to, ?int $branchId = null): array
{
$invites = Invitation::owned($ownerRef)
->whereHas('room', fn ($q) => $q->where('organization_id', $organizationId)
->when($branchId, fn ($q2) => $q2->where('branch_id', $branchId)))
->whereBetween('created_at', [$from, $to])
->get();
$total = $invites->count();
return [
'total' => $total,
'pending' => $invites->where('status', 'pending')->count(),
'accepted' => $invites->where('status', 'accepted')->count(),
'declined' => $invites->where('status', 'declined')->count(),
'acceptance_rate' => $total > 0 ? round($invites->where('status', 'accepted')->count() / $total * 100, 1) : 0,
];
}
/**
* @return array<string, mixed>
*/
public function branchRollup(string $ownerRef, int $organizationId, Carbon $from, Carbon $to): array
{
return \App\Models\Branch::owned($ownerRef)
->where('organization_id', $organizationId)
->where('is_active', true)
->orderBy('name')
->get()
->map(fn ($branch) => array_merge(
['branch_id' => $branch->id, 'branch_name' => $branch->name],
$this->orgSummary($ownerRef, $organizationId, $from, $to, $branch->id),
))
->all();
}
/**
* @param array<string, mixed> $data
*/
public function toCsv(string $type, array $data): string
{
return match ($type) {
'meetings' => $this->meetingsCsv($data),
'users' => $this->usersCsv($data),
'summary' => $this->summaryCsv($data),
'branches' => $this->branchesCsv($data),
default => '',
};
}
/** @param array<string, mixed> $data */
protected function meetingsCsv(array $data): string
{
$rows = [['Title', 'Type', 'Status', 'Scheduled', 'Duration', 'Sessions', 'Peak', 'Invites', 'Accepted']];
foreach ($data['rows'] ?? [] as $row) {
$rows[] = [
$row['title'], $row['type'], $row['status'], $row['scheduled_at'] ?? '',
(string) ($row['duration_minutes'] ?? ''), (string) $row['sessions'],
(string) $row['peak_participants'], (string) $row['invitations'], (string) $row['accepted'],
];
}
return $this->encodeCsv($rows);
}
/** @param array<string, mixed> $data */
protected function usersCsv(array $data): string
{
$rows = [['Name', 'User ref', 'Meetings joined', 'Minutes', 'Hosted']];
foreach ($data['top_users'] ?? [] as $row) {
$rows[] = [
$row['display_name'], $row['user_ref'] ?? '', (string) $row['meetings_joined'],
(string) $row['minutes'], (string) $row['hosted'],
];
}
return $this->encodeCsv($rows);
}
/** @param array<string, mixed> $data */
protected function summaryCsv(array $data): string
{
$rows = [['Metric', 'Value']];
foreach ($data as $key => $value) {
if (is_array($value)) {
continue;
}
$rows[] = [$key, (string) $value];
}
return $this->encodeCsv($rows);
}
/** @param array<int, array<string, mixed>> $data */
protected function branchesCsv(array $data): string
{
$rows = [['Branch', 'Scheduled', 'Completed', 'Sessions', 'Minutes', 'Recordings']];
foreach ($data as $row) {
$rows[] = [
$row['branch_name'], (string) $row['meetings_scheduled'], (string) $row['meetings_completed'],
(string) $row['sessions_held'], (string) $row['total_minutes'], (string) $row['recordings_count'],
];
}
return $this->encodeCsv($rows);
}
/** @param array<int, array<int, string>> $rows */
protected function encodeCsv(array $rows): string
{
return collect($rows)->map(fn ($r) => implode(',', array_map(
fn ($c) => '"'.str_replace('"', '""', (string) $c).'"',
$r,
)))->implode("\n");
}
protected function roomQuery(string $ownerRef, int $organizationId, ?int $branchId)
{
return Room::owned($ownerRef)
->where('organization_id', $organizationId)
->when($branchId, fn ($q) => $q->where('branch_id', $branchId));
}
}