Files
kapteins-daagbok/server/src/routes/admin.ts
T

225 lines
6.4 KiB
TypeScript

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<TimeSeries[]> {
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<string, number>()
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