Sifa professional network API (Fastify, AT Protocol, Jetstream)
sifa.id/
1import { count, desc, sql, gte, eq, isNotNull, or, notInArray } from 'drizzle-orm';
2import { z } from 'zod';
3import type { FastifyInstance } from 'fastify';
4import type { NodeOAuthClient } from '@atproto/oauth-client-node';
5import type { Database } from '../db/index.js';
6import type { ValkeyClient } from '../cache/index.js';
7import type { Env } from '../config.js';
8import {
9 profiles,
10 linkedinImports,
11 positions,
12 education,
13 skills,
14 certifications,
15} from '../db/schema/index.js';
16import { userAppStats } from '../db/schema/user-app-stats.js';
17import { getAppsRegistry } from '../lib/atproto-app-registry.js';
18import { mapPdsHostToProvider } from '../lib/pds-provider.js';
19import { COUNTRY_CENTROIDS, resolveCountryCode } from '../lib/country-centroids.js';
20import { createAuthMiddleware } from '../middleware/auth.js';
21import { createAdminMiddleware } from '../middleware/admin.js';
22
23const CACHE_TTL = 300; // 5 minutes
24const SITE_LAUNCH_DATE = '2026-03-04';
25
26/** Generate all YYYY-MM-DD strings from startDate to endDate (inclusive). */
27function allDatesBetween(startDate: string, endDate: string): string[] {
28 const dates: string[] = [];
29 const current = new Date(startDate + 'T00:00:00Z');
30 const end = new Date(endDate + 'T00:00:00Z');
31 while (current <= end) {
32 dates.push(current.toISOString().slice(0, 10));
33 current.setUTCDate(current.getUTCDate() + 1);
34 }
35 return dates;
36}
37
38/** Fill missing dates in a sparse array with a default record. */
39function fillDateGaps<T extends { date: string }>(
40 rows: T[],
41 days: number,
42 defaults: Omit<T, 'date'>,
43): T[] {
44 const today = new Date().toISOString().slice(0, 10);
45 let startDate: string;
46 if (days > 0) {
47 const start = new Date();
48 start.setUTCDate(start.getUTCDate() - days);
49 startDate = start.toISOString().slice(0, 10);
50 } else {
51 startDate = SITE_LAUNCH_DATE;
52 }
53 if (startDate < SITE_LAUNCH_DATE) startDate = SITE_LAUNCH_DATE;
54 const endDate = today;
55
56 const lookup = new Map(rows.map((r) => [r.date, r]));
57 return allDatesBetween(startDate, endDate).map(
58 (date) => lookup.get(date) ?? ({ date, ...defaults } as T),
59 );
60}
61
62const querySchema = z.object({
63 days: z.enum(['7', '30', '90', '0']).default('30'),
64});
65
66const latestSignupsSchema = z.object({
67 limit: z.coerce.number().int().min(1).max(100).default(20),
68 offset: z.coerce.number().int().min(0).default(0),
69 filter: z.enum(['all', 'no-import', 'gt50', 'complete']).default('all'),
70});
71
72interface SignupRow {
73 date: string;
74 count: number;
75}
76
77interface SignupEntry {
78 date: string;
79 count: number;
80 cumulative: number;
81}
82
83interface SignupsResponse {
84 totalUsers: number;
85 signups: SignupEntry[];
86}
87
88export function registerAdminStatsRoutes(
89 app: FastifyInstance,
90 db: Database,
91 valkey: ValkeyClient | null,
92 oauthClient: NodeOAuthClient | null,
93 config: Env,
94) {
95 const requireAuth = createAuthMiddleware(oauthClient, db);
96 const requireAdmin = createAdminMiddleware(config);
97
98 app.get(
99 '/api/admin/stats/signups',
100 { preHandler: [requireAuth, requireAdmin] },
101 async (request, reply) => {
102 const parsed = querySchema.safeParse(request.query);
103 if (!parsed.success) {
104 return reply.status(400).send({ error: 'Invalid query', details: parsed.error.format() });
105 }
106
107 const days = Number(parsed.data.days);
108 const cacheKey = `admin:stats:signups:${days}`;
109
110 // Check cache
111 if (valkey) {
112 const cached = await valkey.get(cacheKey);
113 if (cached !== null) {
114 return reply.send(JSON.parse(cached) as SignupsResponse);
115 }
116 }
117
118 // Total user count (always all-time)
119 const [totalResult] = await db.select({ value: count() }).from(profiles);
120 const totalUsers = totalResult?.value ?? 0;
121
122 // Signups grouped by date
123 let signupRows: SignupRow[];
124 if (days > 0) {
125 const rows = await db
126 .select({
127 date: sql<string>`DATE(${profiles.createdAt})`.as('date'),
128 count: count().as('count'),
129 })
130 .from(profiles)
131 .where(gte(profiles.createdAt, sql`NOW() - INTERVAL '${sql.raw(String(days))} days'`))
132 .groupBy(sql`DATE(${profiles.createdAt})`)
133 .orderBy(sql`DATE(${profiles.createdAt})`);
134 signupRows = rows.map((r) => ({ date: String(r.date), count: r.count }));
135 } else {
136 const rows = await db
137 .select({
138 date: sql<string>`DATE(${profiles.createdAt})`.as('date'),
139 count: count().as('count'),
140 })
141 .from(profiles)
142 .groupBy(sql`DATE(${profiles.createdAt})`)
143 .orderBy(sql`DATE(${profiles.createdAt})`);
144 signupRows = rows.map((r) => ({ date: String(r.date), count: r.count }));
145 }
146
147 // Fill in missing dates with zero counts
148 const filledRows = fillDateGaps(signupRows, days, { count: 0 });
149
150 // For windowed queries, get count of users before the window
151 // so cumulative reflects the real total at each date
152 let priorCount = 0;
153 if (days > 0) {
154 const [priorResult] = await db
155 .select({ value: count() })
156 .from(profiles)
157 .where(sql`${profiles.createdAt} < NOW() - INTERVAL '${sql.raw(String(days))} days'`);
158 priorCount = priorResult?.value ?? 0;
159 }
160
161 // Build cumulative
162 let running = priorCount;
163 const signups: SignupEntry[] = filledRows.map((row) => {
164 running += row.count;
165 return { date: row.date, count: row.count, cumulative: running };
166 });
167
168 const response: SignupsResponse = { totalUsers, signups };
169
170 // Cache result
171 if (valkey) {
172 await valkey.setex(cacheKey, CACHE_TTL, JSON.stringify(response));
173 }
174
175 return reply.send(response);
176 },
177 );
178
179 app.get(
180 '/api/admin/stats/latest-signups',
181 { preHandler: [requireAuth, requireAdmin] },
182 async (request, reply) => {
183 const parsed = latestSignupsSchema.safeParse(request.query);
184 if (!parsed.success) {
185 return reply.status(400).send({ error: 'Invalid query', details: parsed.error.format() });
186 }
187
188 const { limit, offset, filter } = parsed.data;
189 const cacheKey = `admin:stats:latest-signups:${filter}:${limit}:${offset}`;
190
191 if (valkey) {
192 const cached = await valkey.get(cacheKey);
193 if (cached !== null) {
194 return reply.send(JSON.parse(cached));
195 }
196 }
197
198 // Subquery: count of successful LinkedIn imports per user
199 const importCountSq = db
200 .select({
201 did: linkedinImports.did,
202 cnt: count().as('import_cnt'),
203 })
204 .from(linkedinImports)
205 .where(eq(linkedinImports.success, true))
206 .groupBy(linkedinImports.did)
207 .as('import_counts');
208
209 // Subqueries for profile completion counts
210 const posCountSq = db
211 .select({
212 did: positions.did,
213 cnt: count().as('pos_cnt'),
214 })
215 .from(positions)
216 .groupBy(positions.did)
217 .as('pos_counts');
218
219 const eduCountSq = db
220 .select({
221 did: education.did,
222 cnt: count().as('edu_cnt'),
223 })
224 .from(education)
225 .groupBy(education.did)
226 .as('edu_counts');
227
228 const skillCountSq = db
229 .select({
230 did: skills.did,
231 cnt: count().as('skill_cnt'),
232 })
233 .from(skills)
234 .groupBy(skills.did)
235 .as('skill_counts');
236
237 const certCountSq = db
238 .select({
239 did: certifications.did,
240 cnt: count().as('cert_cnt'),
241 })
242 .from(certifications)
243 .groupBy(certifications.did)
244 .as('cert_counts');
245
246 let query = db
247 .select({
248 did: profiles.did,
249 handle: profiles.handle,
250 displayName: profiles.displayName,
251 avatarUrl: profiles.avatarUrl,
252 headline: profiles.headline,
253 about: profiles.about,
254 createdAt: profiles.createdAt,
255 importCount: sql<number>`COALESCE(${importCountSq.cnt}, 0)`.as('import_count'),
256 positionCount: sql<number>`COALESCE(${posCountSq.cnt}, 0)`.as('position_count'),
257 educationCount: sql<number>`COALESCE(${eduCountSq.cnt}, 0)`.as('education_count'),
258 skillCount: sql<number>`COALESCE(${skillCountSq.cnt}, 0)`.as('skill_count'),
259 certificationCount: sql<number>`COALESCE(${certCountSq.cnt}, 0)`.as(
260 'certification_count',
261 ),
262 })
263 .from(profiles)
264 .leftJoin(importCountSq, eq(profiles.did, importCountSq.did))
265 .leftJoin(posCountSq, eq(profiles.did, posCountSq.did))
266 .leftJoin(eduCountSq, eq(profiles.did, eduCountSq.did))
267 .leftJoin(skillCountSq, eq(profiles.did, skillCountSq.did))
268 .leftJoin(certCountSq, eq(profiles.did, certCountSq.did))
269 .orderBy(desc(profiles.createdAt))
270 .limit(limit)
271 .offset(offset)
272 .$dynamic();
273
274 const completionExpr = sql`(
275 CASE WHEN ${profiles.headline} IS NOT NULL AND ${profiles.headline} != '' THEN 1 ELSE 0 END +
276 CASE WHEN ${profiles.about} IS NOT NULL AND ${profiles.about} != '' THEN 1 ELSE 0 END +
277 CASE WHEN ${posCountSq.cnt} IS NOT NULL THEN 1 ELSE 0 END +
278 CASE WHEN ${eduCountSq.cnt} IS NOT NULL THEN 1 ELSE 0 END +
279 CASE WHEN ${skillCountSq.cnt} IS NOT NULL THEN 1 ELSE 0 END +
280 CASE WHEN ${certCountSq.cnt} IS NOT NULL THEN 1 ELSE 0 END
281 )`;
282
283 const noImportFilter = sql`${importCountSq.cnt} IS NULL AND ${completionExpr} <= 3`;
284 const gt50Filter = sql`${completionExpr} > 3 AND ${completionExpr} < 6`;
285 const completeFilter = sql`${completionExpr} = 6`;
286
287 const filterMap: Record<string, ReturnType<typeof sql> | null> = {
288 all: null,
289 'no-import': noImportFilter,
290 gt50: gt50Filter,
291 complete: completeFilter,
292 };
293
294 const activeFilter = filterMap[filter] ?? null;
295 if (activeFilter) {
296 query = query.where(activeFilter);
297 }
298
299 // Total count for pagination
300 let totalCount: number;
301 if (activeFilter) {
302 const [countResult] = await db
303 .select({ value: count() })
304 .from(profiles)
305 .leftJoin(importCountSq, eq(profiles.did, importCountSq.did))
306 .leftJoin(posCountSq, eq(profiles.did, posCountSq.did))
307 .leftJoin(eduCountSq, eq(profiles.did, eduCountSq.did))
308 .leftJoin(skillCountSq, eq(profiles.did, skillCountSq.did))
309 .leftJoin(certCountSq, eq(profiles.did, certCountSq.did))
310 .where(activeFilter);
311 totalCount = countResult?.value ?? 0;
312 } else {
313 const [countResult] = await db.select({ value: count() }).from(profiles);
314 totalCount = countResult?.value ?? 0;
315 }
316
317 const rows = await query;
318
319 const users = rows.map((r) => ({
320 did: r.did,
321 handle: r.handle,
322 displayName: r.displayName,
323 avatarUrl: r.avatarUrl,
324 createdAt: r.createdAt.toISOString(),
325 hasImported: Number(r.importCount) > 0,
326 profileCompletion: {
327 hasHeadline: !!r.headline,
328 hasAbout: !!r.about,
329 positionCount: Number(r.positionCount),
330 educationCount: Number(r.educationCount),
331 skillCount: Number(r.skillCount),
332 certificationCount: Number(r.certificationCount),
333 },
334 }));
335
336 const response = { users, total: totalCount };
337
338 if (valkey) {
339 await valkey.setex(cacheKey, CACHE_TTL, JSON.stringify(response));
340 }
341
342 return reply.send(response);
343 },
344 );
345
346 app.get(
347 '/api/admin/stats/active-users',
348 { preHandler: [requireAuth, requireAdmin] },
349 async (request, reply) => {
350 const parsed = querySchema.safeParse(request.query);
351 if (!parsed.success) {
352 return reply.status(400).send({ error: 'Invalid query', details: parsed.error.format() });
353 }
354
355 const days = Number(parsed.data.days);
356 const cacheKey = `admin:stats:active-users:${days}`;
357
358 if (valkey) {
359 const cached = await valkey.get(cacheKey);
360 if (cached !== null) {
361 return reply.send(JSON.parse(cached));
362 }
363 }
364
365 // DAU: count distinct users active per day
366 const dauDays = days > 0 ? days : 90;
367 const dauRows = await db
368 .select({
369 date: sql<string>`DATE(${profiles.lastActiveAt})`.as('date'),
370 count: count().as('count'),
371 })
372 .from(profiles)
373 .where(
374 sql`${profiles.lastActiveAt} IS NOT NULL AND ${profiles.lastActiveAt} >= NOW() - INTERVAL '${sql.raw(String(dauDays))} days'`,
375 )
376 .groupBy(sql`DATE(${profiles.lastActiveAt})`)
377 .orderBy(sql`DATE(${profiles.lastActiveAt})`);
378
379 const dailySparse = dauRows.map((r) => ({ date: String(r.date), count: r.count }));
380 const daily = fillDateGaps(dailySparse, dauDays, { count: 0 });
381
382 // MAU: count distinct users active per month (last 12 months max)
383 const mauRows = await db
384 .select({
385 month: sql<string>`TO_CHAR(${profiles.lastActiveAt}, 'YYYY-MM')`.as('month'),
386 count: count().as('count'),
387 })
388 .from(profiles)
389 .where(
390 sql`${profiles.lastActiveAt} IS NOT NULL AND ${profiles.lastActiveAt} >= NOW() - INTERVAL '12 months'`,
391 )
392 .groupBy(sql`TO_CHAR(${profiles.lastActiveAt}, 'YYYY-MM')`)
393 .orderBy(sql`TO_CHAR(${profiles.lastActiveAt}, 'YYYY-MM')`);
394
395 const monthly = mauRows.map((r) => ({ month: String(r.month), count: r.count }));
396
397 const response = { daily, monthly };
398
399 if (valkey) {
400 await valkey.setex(cacheKey, CACHE_TTL, JSON.stringify(response));
401 }
402
403 return reply.send(response);
404 },
405 );
406
407 app.get(
408 '/api/admin/stats/pds-distribution',
409 { preHandler: [requireAuth, requireAdmin] },
410 async (_request, reply) => {
411 const cacheKey = 'admin:stats:pds-distribution';
412
413 if (valkey) {
414 const cached = await valkey.get(cacheKey);
415 if (cached !== null) {
416 return reply.send(JSON.parse(cached));
417 }
418 }
419
420 const rows = await db
421 .select({
422 pdsHost: profiles.pdsHost,
423 count: count().as('count'),
424 })
425 .from(profiles)
426 .where(sql`${profiles.pdsHost} IS NOT NULL`)
427 .groupBy(profiles.pdsHost);
428
429 // Group using known provider mapping: bluesky, eurosky, etc. → display name; selfhosted → "Self-hosted"
430 const PROVIDER_LABELS: Record<string, string> = {
431 bluesky: 'Bluesky',
432 blacksky: 'Blacksky',
433 eurosky: 'Eurosky',
434 northsky: 'Northsky',
435 'selfhosted-social': 'selfhosted.social',
436 selfhosted: 'Self-hosted',
437 };
438 const groups = new Map<string, number>();
439 for (const row of rows) {
440 const host = row.pdsHost ?? '';
441 const provider = mapPdsHostToProvider(host);
442 const label = PROVIDER_LABELS[provider.name] ?? provider.name;
443 groups.set(label, (groups.get(label) ?? 0) + row.count);
444 }
445
446 const slices = Array.from(groups.entries())
447 .map(([name, value]) => ({ name, value }))
448 .sort((a, b) => b.value - a.value);
449
450 const response = { slices };
451
452 if (valkey) {
453 await valkey.setex(cacheKey, CACHE_TTL, JSON.stringify(response));
454 }
455
456 return reply.send(response);
457 },
458 );
459
460 app.get(
461 '/api/admin/stats/linkedin-imports',
462 { preHandler: [requireAuth, requireAdmin] },
463 async (request, reply) => {
464 const parsed = querySchema.safeParse(request.query);
465 if (!parsed.success) {
466 return reply.status(400).send({ error: 'Invalid query', details: parsed.error.format() });
467 }
468
469 const days = Number(parsed.data.days);
470 const cacheKey = `admin:stats:linkedin-imports:${days}`;
471
472 if (valkey) {
473 const cached = await valkey.get(cacheKey);
474 if (cached !== null) {
475 return reply.send(JSON.parse(cached));
476 }
477 }
478
479 const importDays = days > 0 ? days : 90;
480 const rows = await db
481 .select({
482 date: sql<string>`DATE(${linkedinImports.createdAt})`.as('date'),
483 successCount: sql<number>`COUNT(*) FILTER (WHERE ${linkedinImports.success} = true)`.as(
484 'success_count',
485 ),
486 failureCount: sql<number>`COUNT(*) FILTER (WHERE ${linkedinImports.success} = false)`.as(
487 'failure_count',
488 ),
489 totalItems: sql<number>`COALESCE(SUM(
490 ${linkedinImports.positionCount} + ${linkedinImports.educationCount} +
491 ${linkedinImports.skillCount} + ${linkedinImports.certificationCount} +
492 ${linkedinImports.projectCount} + ${linkedinImports.volunteeringCount} +
493 ${linkedinImports.publicationCount} + ${linkedinImports.courseCount} +
494 ${linkedinImports.honorCount} + ${linkedinImports.languageCount}
495 ), 0)`.as('total_items'),
496 })
497 .from(linkedinImports)
498 .where(
499 sql`${linkedinImports.createdAt} >= NOW() - INTERVAL '${sql.raw(String(importDays))} days'`,
500 )
501 .groupBy(sql`DATE(${linkedinImports.createdAt})`)
502 .orderBy(sql`DATE(${linkedinImports.createdAt})`);
503
504 const dailySparse = rows.map((r) => ({
505 date: String(r.date),
506 successCount: Number(r.successCount),
507 failureCount: Number(r.failureCount),
508 totalItems: Number(r.totalItems),
509 }));
510 const daily = fillDateGaps(dailySparse, importDays, {
511 successCount: 0,
512 failureCount: 0,
513 totalItems: 0,
514 });
515
516 const totalImports = daily.reduce((s, d) => s + d.successCount + d.failureCount, 0);
517 const totalSuccess = daily.reduce((s, d) => s + d.successCount, 0);
518 const totalItems = daily.reduce((s, d) => s + d.totalItems, 0);
519 const successRate = totalImports > 0 ? Math.round((totalSuccess / totalImports) * 100) : 0;
520
521 const response = { daily, summary: { totalImports, totalSuccess, totalItems, successRate } };
522
523 if (valkey) {
524 await valkey.setex(cacheKey, CACHE_TTL, JSON.stringify(response));
525 }
526
527 return reply.send(response);
528 },
529 );
530
531 app.get(
532 '/api/admin/stats/user-locations',
533 { preHandler: [requireAuth, requireAdmin] },
534 async (_request, reply) => {
535 const cacheKey = 'admin:stats:user-locations';
536
537 if (valkey) {
538 const cached = await valkey.get(cacheKey);
539 if (cached !== null) {
540 return reply.send(JSON.parse(cached));
541 }
542 }
543
544 // Aggregate profiles by country_code + location_city
545 const rows = await db
546 .select({
547 countryCode: profiles.countryCode,
548 locationCountry: profiles.locationCountry,
549 locationCity: profiles.locationCity,
550 latitude: profiles.latitude,
551 longitude: profiles.longitude,
552 count: count().as('count'),
553 })
554 .from(profiles)
555 .where(or(isNotNull(profiles.countryCode), isNotNull(profiles.locationCountry)))
556 .groupBy(
557 profiles.countryCode,
558 profiles.locationCountry,
559 profiles.locationCity,
560 profiles.latitude,
561 profiles.longitude,
562 );
563
564 interface LocationPoint {
565 lat: number;
566 lng: number;
567 count: number;
568 label: string;
569 }
570
571 // Merge rows into coordinate-based points.
572 // City-level rows get a small offset from the country centroid to avoid
573 // perfect overlap, while country-only rows use the centroid directly.
574 const pointMap = new Map<string, LocationPoint>();
575
576 for (const row of rows) {
577 const code = resolveCountryCode(row.countryCode, row.locationCountry);
578 if (!code) continue;
579 const centroid = COUNTRY_CENTROIDS[code];
580 if (!centroid) continue;
581
582 const city = row.locationCity?.trim() ?? '';
583 const country = row.locationCountry?.trim() ?? code;
584
585 // Build a dedup key: country+city
586 const key = `${code}:${city.toLowerCase()}`;
587 const label = city ? `${city}, ${country}` : country;
588
589 const existing = pointMap.get(key);
590 if (existing) {
591 existing.count += row.count;
592 } else {
593 // Prefer stored coordinates, fall back to country centroid
594 const lat = row.latitude ?? centroid.lat;
595 const lng = row.longitude ?? centroid.lng;
596 pointMap.set(key, { lat, lng, count: row.count, label });
597 }
598 }
599
600 const locations = Array.from(pointMap.values()).sort((a, b) => b.count - a.count);
601
602 const response = { locations };
603
604 if (valkey) {
605 await valkey.setex(cacheKey, CACHE_TTL, JSON.stringify(response));
606 }
607
608 return reply.send(response);
609 },
610 );
611
612 const unregisteredSchema = z.object({
613 limit: z.coerce.number().int().min(1).max(100).default(10),
614 offset: z.coerce.number().int().min(0).default(0),
615 });
616
617 app.get<{ Querystring: { limit?: string; offset?: string } }>(
618 '/api/admin/stats/unregistered-collections',
619 { preHandler: [requireAuth, requireAdmin] },
620 async (request, reply) => {
621 const parsed = unregisteredSchema.safeParse(request.query);
622 if (!parsed.success) {
623 return reply.status(400).send({ error: 'Invalid query', details: parsed.error.format() });
624 }
625
626 const { limit, offset } = parsed.data;
627 const cacheKey = `admin:stats:unregistered-collections:${limit}:${offset}`;
628
629 if (valkey) {
630 try {
631 const cached = await valkey.get(cacheKey);
632 if (cached !== null) {
633 return reply.send(JSON.parse(cached));
634 }
635 } catch {
636 // Cache miss -- fall through to query
637 }
638 }
639
640 const registeredIds = getAppsRegistry().map((e) => e.id);
641
642 const whereClause = sql`${notInArray(userAppStats.appId, registeredIds)} AND ${eq(userAppStats.isActive, true)}`;
643
644 const [countResult] = await db
645 .select({ value: sql<number>`COUNT(DISTINCT ${userAppStats.appId})`.as('total') })
646 .from(userAppStats)
647 .where(whereClause);
648 const total = Number(countResult?.value ?? 0);
649
650 const rows = await db
651 .select({
652 collection: userAppStats.appId,
653 userCount: sql<number>`COUNT(DISTINCT ${userAppStats.did})`.as('user_count'),
654 totalRecords: sql<number>`COALESCE(SUM(${userAppStats.recentCount}), 0)`.as(
655 'total_records',
656 ),
657 latestSeenAt: sql<string>`MAX(${userAppStats.latestRecordAt})`.as('latest_seen_at'),
658 })
659 .from(userAppStats)
660 .where(whereClause)
661 .groupBy(userAppStats.appId)
662 .orderBy(
663 desc(sql`COUNT(DISTINCT ${userAppStats.did})`),
664 desc(sql`COALESCE(SUM(${userAppStats.recentCount}), 0)`),
665 )
666 .limit(limit)
667 .offset(offset);
668
669 const collections = rows.map((r) => {
670 const parts = r.collection.split('.');
671 const namespace = parts.length >= 2 ? `${parts[0]}.${parts[1]}` : r.collection;
672 return {
673 collection: r.collection,
674 namespace,
675 userCount: Number(r.userCount),
676 totalRecords: Number(r.totalRecords),
677 latestSeenAt: r.latestSeenAt ?? null,
678 };
679 });
680
681 const response = { collections, total };
682
683 if (valkey) {
684 try {
685 await valkey.setex(cacheKey, CACHE_TTL, JSON.stringify(response));
686 } catch {
687 // Cache write failure is non-critical
688 }
689 }
690
691 return reply.send(response);
692 },
693 );
694}