import { Router } from 'express' import { prisma } from '../db.js' import { requireUser, requireAdmin, type AuthedRequest } from '../middleware/auth.js' const router = Router() router.get('/me', requireUser, requireAdmin, (req, res) => { const { userId } = req as AuthedRequest res.json({ isAdmin: true, userId }) }) router.get('/summary', requireUser, requireAdmin, async (_req, res) => { try { const [totalUsers, totalLogbooks, totalPhotos, totalVoiceMemos, totalGpsTracks, totalCollaborations, totalInvitations, aiSummaryEntries] = await Promise.all([ prisma.user.count(), prisma.logbook.count(), prisma.photoPayload.count(), prisma.voiceMemoPayload.count(), prisma.gpsTrackPayload.count(), prisma.collaboration.count(), prisma.invitation.count(), prisma.aiSummaryUsage.count() ]) const rawDbSize = await prisma.$queryRaw<[{ size: string }]>` SELECT pg_database_size(current_database())::text as size ` const dbSize = Number(rawDbSize[0]?.size || '0') res.json({ totalUsers, totalLogbooks, totalPhotos, totalVoiceMemos, totalGpsTracks, totalCollaborations, totalInvitations, aiSummaryEntries, dbSize }) } catch (error: unknown) { console.error('admin/summary error', error) res.status(500).json({ error: 'Failed to load admin summary' }) } }) type TimeBucket = 'day' | 'week' | 'month' interface TimeSeriesPoint { date: string count: number } interface TimeSeries { metric: string points: TimeSeriesPoint[] } function normalizeBucket(value: string | undefined | null): TimeBucket { if (value === 'week' || value === 'month') return value return 'day' } function parseWindowDays(raw: string | undefined | null): number { const n = raw ? Number.parseInt(raw, 10) : NaN if (!Number.isFinite(n) || n <= 0) return 90 return Math.min(n, 365) } function startOfDay(date: Date): Date { const d = new Date(date) d.setUTCHours(0, 0, 0, 0) return d } function startOfWeek(date: Date): Date { const d = startOfDay(date) const day = d.getUTCDay() || 7 d.setUTCDate(d.getUTCDate() - (day - 1)) return d } function startOfMonth(date: Date): Date { const d = startOfDay(date) d.setUTCDate(1) return d } function bucketDate(date: Date, bucket: TimeBucket): string { const base = bucket === 'week' ? startOfWeek(date) : bucket === 'month' ? startOfMonth(date) : startOfDay(date) return base.toISOString().slice(0, 10) } async function buildTimeSeries(bucket: TimeBucket, windowDays: number): Promise { const since = new Date() since.setUTCDate(since.getUTCDate() - windowDays) const [users, logbooks, photos, dbSizeRaw, photosSize, voiceSize, tracksSize, entriesSize] = await Promise.all([ prisma.user.findMany({ where: { createdAt: { gte: since } }, select: { createdAt: true } }), prisma.logbook.findMany({ where: { createdAt: { gte: since } }, select: { createdAt: true } }), prisma.photoPayload.findMany({ where: { updatedAt: { gte: since } }, select: { updatedAt: true } }), prisma.$queryRaw<[{ size: string }]>` SELECT pg_database_size(current_database())::text as size `, prisma.photoPayload.findMany({ select: { updatedAt: true, encryptedData: true } }), prisma.voiceMemoPayload.findMany({ select: { updatedAt: true, encryptedData: true } }), prisma.gpsTrackPayload.findMany({ select: { updatedAt: true, encryptedData: true } }), prisma.entryPayload.findMany({ select: { updatedAt: true, encryptedData: true } }) ]) const dbSizeVal = Number(dbSizeRaw[0]?.size || '0') const payloads: { date: Date; size: number }[] = [] for (const p of photosSize) { payloads.push({ date: p.updatedAt, size: p.encryptedData.length }) } for (const v of voiceSize) { payloads.push({ date: v.updatedAt, size: v.encryptedData.length }) } for (const g of tracksSize) { payloads.push({ date: g.updatedAt, size: g.encryptedData.length }) } for (const e of entriesSize) { payloads.push({ date: e.updatedAt, size: e.encryptedData.length }) } const totalPayloadsSize = payloads.reduce((acc, p) => acc + p.size, 0) const baseDbSize = Math.max(0, dbSizeVal - totalPayloadsSize) payloads.sort((a, b) => a.date.getTime() - b.date.getTime()) // Generate complete list of date keys for the window const dateKeys: string[] = [] const current = new Date(since) const todayStr = bucketDate(new Date(), bucket) while (true) { const key = bucketDate(current, bucket) if (!dateKeys.includes(key)) { dateKeys.push(key) } if (key >= todayStr) break current.setUTCDate(current.getUTCDate() + 1) } const dbSizePoints = dateKeys.map((key) => { let sizeSum = 0 for (const p of payloads) { if (bucketDate(p.date, bucket) <= key) { sizeSum += p.size } else { break } } const totalBytes = baseDbSize + sizeSum const sizeInMb = Math.round((totalBytes / (1024 * 1024)) * 10) / 10 return { date: key, count: sizeInMb } }) function aggregate(dates: Date[], metric: string): TimeSeries { const map = new Map() for (const d of dates) { const key = bucketDate(d, bucket) map.set(key, (map.get(key) ?? 0) + 1) } const points = Array.from(map.entries()) .sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0)) .map(([date, count]) => ({ date, count })) return { metric, points } } return [ aggregate( users.map((u) => u.createdAt), 'users_created' ), aggregate( logbooks.map((l) => l.createdAt), 'logbooks_created' ), aggregate( photos.map((p) => p.updatedAt), 'photos_updated' ), { metric: 'database_size', points: dbSizePoints } ] } router.get('/timeseries', requireUser, requireAdmin, async (req, res) => { try { const bucket = normalizeBucket(typeof req.query.bucket === 'string' ? req.query.bucket : undefined) const windowDays = parseWindowDays(typeof req.query.window === 'string' ? req.query.window : undefined) const series = await buildTimeSeries(bucket, windowDays) res.json({ bucket, windowDays, series }) } catch (error: unknown) { console.error('admin/timeseries error', error) res.status(500).json({ error: 'Failed to load admin time series' }) } }) export default router