Sifa professional network API (Fastify, AT Protocol, Jetstream) sifa.id/
at main 694 lines 24 kB view raw
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}