Skip to content

Commit 17a98b6

Browse files
committed
Add workspace credit analytics and velocity alerts
1 parent 5f4ce59 commit 17a98b6

24 files changed

Lines changed: 2384 additions & 35 deletions

CHANGELOG.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -143,3 +143,9 @@
143143
- Top-up revenue = sum(ai_credit_purchases.amount_cents where status=paid) by month.
144144
- Churn rate = cancelled last 30 days / (active + cancelled last 30 days).
145145
- LTV = average subscription lifetime (months) × ARPU.
146+
- Add workspace report preferences with weekly/monthly digest configuration and recipient lists.
147+
- Add workspace settings storage with JSON payloads for report preferences.
148+
- Add scheduled workspace digest emails (weekly/monthly) with credit usage, depletion forecasts, top categories, and cost breakdown summaries.
149+
- Add Super Admin credit analytics with usage segments, heatmaps, usage type trends, anomaly detection, and drill-down table.
150+
- Add spending velocity alerts for System Admins with configurable thresholds and daily checks.
151+
- Add Workspace Admin credit analytics with segments, heatmaps, anomaly detection, drill-down, and velocity alerts.

app/AI/CreditConsumer.php

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,8 @@ public function consume(
2020
int $credits,
2121
string $usageType,
2222
?array $metadata = null,
23-
?string $description = null
23+
?string $description = null,
24+
?int $userId = null
2425
): array {
2526
if ($workspaceId <= 0) {
2627
return ['ok' => false, 'balance' => 0, 'error' => 'Invalid workspace.'];
@@ -60,11 +61,12 @@ public function consume(
6061
$update->execute([$newBalance, $workspaceId]);
6162

6263
$insert = $this->pdo->prepare(
63-
'INSERT INTO workspace_credit_ledger (workspace_id, change_type, credits, balance_after, source_type, source_id, description, usage_type, metadata, created_at)
64-
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)'
64+
'INSERT INTO workspace_credit_ledger (workspace_id, user_id, change_type, credits, balance_after, source_type, source_id, description, usage_type, metadata, created_at)
65+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)'
6566
);
6667
$insert->execute([
6768
$workspaceId,
69+
$userId,
6870
'consume',
6971
-$credits,
7072
$newBalance,

app/Controllers/AI/ConsumeController.php

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -74,7 +74,12 @@ public function consume(Request $request): Response
7474
}
7575
}
7676

77-
$result = $this->consumer->consume($workspaceId, $credits, $usageType, $metadata);
77+
$actorId = $userId > 0 ? $userId : null;
78+
if ($actorId === null && is_array($metadata) && isset($metadata['user_id'])) {
79+
$actorId = (int)$metadata['user_id'];
80+
}
81+
82+
$result = $this->consumer->consume($workspaceId, $credits, $usageType, $metadata, null, $actorId);
7883
if (!$result['ok']) {
7984
$code = $result['error'] === 'Insufficient credits.' ? 'insufficient_credits' : 'consume_failed';
8085
return $this->json(

app/Controllers/Admin/AnalyticsController.php

Lines changed: 292 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,11 +4,31 @@
44

55
final class AnalyticsController
66
{
7+
private PDO $pdo;
8+
private AppSettingsService $settings;
9+
10+
public function __construct(PDO $pdo, AppSettingsService $settings)
11+
{
12+
$this->pdo = $pdo;
13+
$this->settings = $settings;
14+
}
15+
716
public function index(Request $request): Response
817
{
918
$user = $request->session('user');
1019
$isAdmin = is_array($user) ? (int)($user['is_system_admin'] ?? 0) === 1 : false;
1120

21+
[$startDate, $endDate] = $this->dateRange($request);
22+
$usageTypeTrend = $this->usageTypeTrend($startDate, $endDate);
23+
$heatmap = $this->usageHeatmap($startDate, $endDate);
24+
$anomalies = $this->detectAnomalies();
25+
$segments = $this->segmentUsage($startDate, $endDate);
26+
$drilldown = $this->drilldownResults($request, $startDate, $endDate);
27+
$alertSettings = [
28+
'enabled' => $this->settings->get('analytics.velocity.enabled', '0') === '1',
29+
'threshold_percent' => (int)$this->settings->get('analytics.velocity.threshold_percent', '50'),
30+
];
31+
1232
$kpis = [
1333
['label' => 'Monthly Active Users', 'value' => '1,284', 'delta' => '+8%'],
1434
['label' => 'Active Workspaces', 'value' => '214', 'delta' => '+3%'],
@@ -52,6 +72,278 @@ public function index(Request $request): Response
5272
'charts' => $charts,
5373
'futureSources' => $futureSources,
5474
'showRevenue' => $isAdmin,
75+
'start' => $startDate,
76+
'end' => $endDate,
77+
'usageTypeTrend' => $usageTypeTrend,
78+
'heatmap' => $heatmap,
79+
'anomalies' => $anomalies,
80+
'segments' => $segments,
81+
'drilldown' => $drilldown,
82+
'alertSettings' => $alertSettings,
5583
]));
5684
}
85+
86+
/**
87+
* @return array{0:string,1:string}
88+
*/
89+
private function dateRange(Request $request): array
90+
{
91+
$startParam = trim((string)$request->query('start', ''));
92+
$endParam = trim((string)$request->query('end', ''));
93+
$end = $endParam !== '' ? DateTimeImmutable::createFromFormat('Y-m-d', $endParam) : new DateTimeImmutable('today');
94+
$start = $startParam !== '' ? DateTimeImmutable::createFromFormat('Y-m-d', $startParam) : $end->modify('-29 days');
95+
if (!$end) {
96+
$end = new DateTimeImmutable('today');
97+
}
98+
if (!$start) {
99+
$start = $end->modify('-29 days');
100+
}
101+
if ($start > $end) {
102+
[$start, $end] = [$end, $start];
103+
}
104+
105+
return [$start->format('Y-m-d'), $end->format('Y-m-d')];
106+
}
107+
108+
private function usageTypeTrend(string $start, string $end): array
109+
{
110+
$driver = $this->pdo->getAttribute(PDO::ATTR_DRIVER_NAME);
111+
$dateExpr = $driver === 'sqlite' ? "strftime('%Y-%m-%d', created_at)" : 'DATE(created_at)';
112+
$stmt = $this->pdo->prepare(
113+
"SELECT {$dateExpr} AS date, COALESCE(usage_type, 'unknown') AS usage_type, SUM(-credits) AS credits
114+
FROM workspace_credit_ledger
115+
WHERE change_type = 'consume' AND created_at >= ? AND created_at <= ?
116+
GROUP BY {$dateExpr}, usage_type
117+
ORDER BY {$dateExpr} ASC"
118+
);
119+
$stmt->execute([$start . ' 00:00:00', $end . ' 23:59:59']);
120+
return $stmt->fetchAll(PDO::FETCH_ASSOC) ?: [];
121+
}
122+
123+
private function usageHeatmap(string $start, string $end): array
124+
{
125+
$driver = $this->pdo->getAttribute(PDO::ATTR_DRIVER_NAME);
126+
if ($driver === 'sqlite') {
127+
$dayExpr = "CAST(strftime('%w', created_at) AS INTEGER)";
128+
$hourExpr = "CAST(strftime('%H', created_at) AS INTEGER)";
129+
} else {
130+
$dayExpr = 'DAYOFWEEK(created_at) - 1';
131+
$hourExpr = 'HOUR(created_at)';
132+
}
133+
134+
$stmt = $this->pdo->prepare(
135+
"SELECT {$dayExpr} AS day_of_week, {$hourExpr} AS hour_of_day, SUM(-credits) AS credits
136+
FROM workspace_credit_ledger
137+
WHERE change_type = 'consume' AND created_at >= ? AND created_at <= ?
138+
GROUP BY day_of_week, hour_of_day"
139+
);
140+
$stmt->execute([$start . ' 00:00:00', $end . ' 23:59:59']);
141+
$rows = $stmt->fetchAll(PDO::FETCH_ASSOC) ?: [];
142+
143+
$grid = array_fill(0, 7, array_fill(0, 24, 0));
144+
$max = 0;
145+
foreach ($rows as $row) {
146+
$day = (int)$row['day_of_week'];
147+
$hour = (int)$row['hour_of_day'];
148+
$value = (int)$row['credits'];
149+
if ($day >= 0 && $day < 7 && $hour >= 0 && $hour < 24) {
150+
$grid[$day][$hour] = $value;
151+
if ($value > $max) {
152+
$max = $value;
153+
}
154+
}
155+
}
156+
157+
return [
158+
'grid' => $grid,
159+
'max' => $max,
160+
];
161+
}
162+
163+
private function detectAnomalies(): array
164+
{
165+
$driver = $this->pdo->getAttribute(PDO::ATTR_DRIVER_NAME);
166+
$dateExpr = $driver === 'sqlite' ? "strftime('%Y-%m-%d', created_at)" : 'DATE(created_at)';
167+
$stmt = $this->pdo->prepare(
168+
"SELECT {$dateExpr} AS date, SUM(-credits) AS credits
169+
FROM workspace_credit_ledger
170+
WHERE change_type = 'consume' AND created_at >= ?
171+
GROUP BY {$dateExpr}
172+
ORDER BY {$dateExpr} ASC"
173+
);
174+
$since = (new DateTimeImmutable('today'))->modify('-30 days')->format('Y-m-d');
175+
$stmt->execute([$since . ' 00:00:00']);
176+
$rows = $stmt->fetchAll(PDO::FETCH_ASSOC) ?: [];
177+
178+
$values = array_map(static fn($row): int => (int)$row['credits'], $rows);
179+
if (count($values) < 5) {
180+
return [];
181+
}
182+
183+
$mean = array_sum($values) / count($values);
184+
$variance = 0.0;
185+
foreach ($values as $value) {
186+
$variance += ($value - $mean) ** 2;
187+
}
188+
$std = sqrt($variance / count($values));
189+
190+
$anomalies = [];
191+
for ($i = 7; $i < count($rows); $i++) {
192+
$window = array_slice($values, $i - 7, 7);
193+
$rolling = array_sum($window) / count($window);
194+
$current = $values[$i];
195+
if ($current > max(1, $rolling) * 2 && $current > ($mean + (2 * $std))) {
196+
$anomalies[] = [
197+
'date' => $rows[$i]['date'],
198+
'credits' => $current,
199+
'rolling_avg' => round($rolling, 2),
200+
'mean' => round($mean, 2),
201+
'std' => round($std, 2),
202+
];
203+
}
204+
}
205+
206+
return $anomalies;
207+
}
208+
209+
private function segmentUsage(string $start, string $end): array
210+
{
211+
$userSegments = $this->segmentByEntity(
212+
'user_id',
213+
'users',
214+
'u.id = wcl.user_id',
215+
$start,
216+
$end
217+
);
218+
219+
$workspaceSegments = $this->segmentByEntity(
220+
'workspace_id',
221+
'workspaces',
222+
'w.id = wcl.workspace_id',
223+
$start,
224+
$end
225+
);
226+
227+
return [
228+
'users' => $userSegments,
229+
'workspaces' => $workspaceSegments,
230+
];
231+
}
232+
233+
private function segmentByEntity(string $field, string $table, string $join, string $start, string $end): array
234+
{
235+
$stmt = $this->pdo->prepare(
236+
"SELECT wcl.{$field} AS entity_id, SUM(-wcl.credits) AS credits
237+
FROM workspace_credit_ledger wcl
238+
JOIN {$table} AS w ON {$join}
239+
WHERE wcl.change_type = 'consume' AND wcl.{$field} IS NOT NULL
240+
AND wcl.created_at >= ? AND wcl.created_at <= ?
241+
GROUP BY wcl.{$field}
242+
ORDER BY credits DESC"
243+
);
244+
$stmt->execute([$start . ' 00:00:00', $end . ' 23:59:59']);
245+
$rows = $stmt->fetchAll(PDO::FETCH_ASSOC) ?: [];
246+
247+
$totalCount = count($rows);
248+
if ($totalCount === 0) {
249+
return [
250+
'high' => ['count' => 0, 'credits' => 0],
251+
'medium' => ['count' => 0, 'credits' => 0],
252+
'low' => ['count' => 0, 'credits' => 0],
253+
];
254+
}
255+
256+
$highCount = (int)ceil($totalCount * 0.2);
257+
$mediumCount = (int)ceil($totalCount * 0.5);
258+
$lowCount = max(0, $totalCount - $highCount - $mediumCount);
259+
260+
$high = array_slice($rows, 0, $highCount);
261+
$medium = array_slice($rows, $highCount, $mediumCount);
262+
$low = array_slice($rows, $highCount + $mediumCount);
263+
264+
return [
265+
'high' => ['count' => count($high), 'credits' => array_sum(array_column($high, 'credits'))],
266+
'medium' => ['count' => count($medium), 'credits' => array_sum(array_column($medium, 'credits'))],
267+
'low' => ['count' => count($low), 'credits' => array_sum(array_column($low, 'credits'))],
268+
];
269+
}
270+
271+
private function drilldownResults(Request $request, string $start, string $end): array
272+
{
273+
$filters = [
274+
'start' => (string)$request->query('drill_start', $start),
275+
'end' => (string)$request->query('drill_end', $end),
276+
'usage_type' => trim((string)$request->query('drill_usage_type', '')),
277+
'workspace' => trim((string)$request->query('drill_workspace', '')),
278+
'user' => trim((string)$request->query('drill_user', '')),
279+
'page' => max(1, (int)$request->query('drill_page', 1)),
280+
];
281+
$limit = 25;
282+
$offset = ($filters['page'] - 1) * $limit;
283+
284+
$conditions = ['wcl.change_type = "consume"'];
285+
$params = [];
286+
if ($filters['start'] !== '') {
287+
$conditions[] = 'wcl.created_at >= ?';
288+
$params[] = $filters['start'] . ' 00:00:00';
289+
}
290+
if ($filters['end'] !== '') {
291+
$conditions[] = 'wcl.created_at <= ?';
292+
$params[] = $filters['end'] . ' 23:59:59';
293+
}
294+
if ($filters['usage_type'] !== '') {
295+
$conditions[] = 'wcl.usage_type = ?';
296+
$params[] = $filters['usage_type'];
297+
}
298+
if ($filters['workspace'] !== '') {
299+
$conditions[] = 'w.name LIKE ?';
300+
$params[] = '%' . $filters['workspace'] . '%';
301+
}
302+
if ($filters['user'] !== '') {
303+
if (filter_var($filters['user'], FILTER_VALIDATE_EMAIL)) {
304+
$conditions[] = 'u.email LIKE ?';
305+
$params[] = '%' . $filters['user'] . '%';
306+
} else {
307+
$conditions[] = 'u.name LIKE ?';
308+
$params[] = '%' . $filters['user'] . '%';
309+
}
310+
}
311+
312+
$where = 'WHERE ' . implode(' AND ', $conditions);
313+
$countStmt = $this->pdo->prepare(
314+
"SELECT COUNT(*) FROM workspace_credit_ledger wcl
315+
LEFT JOIN users u ON u.id = wcl.user_id
316+
LEFT JOIN workspaces w ON w.id = wcl.workspace_id
317+
{$where}"
318+
);
319+
$countStmt->execute($params);
320+
$total = (int)$countStmt->fetchColumn();
321+
$totalPages = max(1, (int)ceil($total / $limit));
322+
$filters['page'] = min($filters['page'], $totalPages);
323+
$offset = ($filters['page'] - 1) * $limit;
324+
325+
$stmt = $this->pdo->prepare(
326+
"SELECT wcl.created_at, wcl.usage_type, wcl.credits, wcl.metadata,
327+
w.name AS workspace_name, u.name AS user_name, u.email AS user_email
328+
FROM workspace_credit_ledger wcl
329+
LEFT JOIN users u ON u.id = wcl.user_id
330+
LEFT JOIN workspaces w ON w.id = wcl.workspace_id
331+
{$where}
332+
ORDER BY wcl.created_at DESC
333+
LIMIT {$limit} OFFSET {$offset}"
334+
);
335+
$stmt->execute($params);
336+
$rows = $stmt->fetchAll(PDO::FETCH_ASSOC) ?: [];
337+
338+
$usageTypes = $this->pdo->query('SELECT DISTINCT usage_type FROM workspace_credit_ledger WHERE usage_type IS NOT NULL ORDER BY usage_type ASC');
339+
$usageTypeOptions = $usageTypes ? array_values(array_filter($usageTypes->fetchAll(PDO::FETCH_COLUMN) ?: [])) : [];
340+
341+
return [
342+
'filters' => $filters,
343+
'rows' => $rows,
344+
'total' => $total,
345+
'total_pages' => $totalPages,
346+
'usage_types' => $usageTypeOptions,
347+
];
348+
}
57349
}

0 commit comments

Comments
 (0)