Files
EpicNext-Cms/src/app/api/admin/analytics/export/route.ts
T
remco e85e4d74ea
Local Build and Deploy / deploy (push) Successful in 1m11s
revert fb8e77bb68
revert style: clean up code with prettier and eslint
2026-07-12 21:02:03 +02:00

102 lines
3.2 KiB
TypeScript

import { withAdmin } from '@/lib/api-handler'
import { apiError } from '@/lib/api-response'
import { PERMS } from '@/lib/permission-slugs'
import { prisma } from '@/lib/prisma'
const VALID_REPORTS = new Set(['registrations', 'online-by-hour', 'economy'])
function csvEscape(value: unknown): string {
if (value == null) return ''
const str = String(value)
if (/[",\n\r]/.test(str)) {
return `"${str.replace(/"/g, '""')}"`
}
return str
}
function csvRow(values: unknown[]): string {
return `${values.map(csvEscape).join(',')}\n`
}
async function streamRegistrations(): Promise<ReadableStream<Uint8Array>> {
const rows = await prisma.$queryRaw<{ day: string; cnt: bigint }[]>`
SELECT FROM_UNIXTIME(account_created, '%Y-%m-%d') AS day, COUNT(*) AS cnt
FROM users
WHERE account_created > UNIX_TIMESTAMP(NOW() - INTERVAL 90 DAY)
GROUP BY day
ORDER BY day ASC
`
const encoder = new TextEncoder()
return new ReadableStream({
start(controller) {
controller.enqueue(encoder.encode(csvRow(['day', 'registrations'])))
for (const r of rows) {
controller.enqueue(encoder.encode(csvRow([r.day, Number(r.cnt)])))
}
controller.close()
},
})
}
async function streamOnlineByHour(): Promise<ReadableStream<Uint8Array>> {
const rows = await prisma.$queryRaw<{ hour: number; cnt: bigint }[]>`
SELECT HOUR(FROM_UNIXTIME(last_online)) AS hour, COUNT(*) AS cnt
FROM users
WHERE last_online > UNIX_TIMESTAMP(NOW() - INTERVAL 30 DAY)
GROUP BY hour
ORDER BY hour ASC
`
const encoder = new TextEncoder()
return new ReadableStream({
start(controller) {
controller.enqueue(encoder.encode(csvRow(['hour', 'active_users'])))
for (const r of rows) {
controller.enqueue(encoder.encode(csvRow([Number(r.hour), Number(r.cnt)])))
}
controller.close()
},
})
}
async function streamEconomy(): Promise<ReadableStream<Uint8Array>> {
const [totals] = await prisma.$queryRaw<{ total_credits: bigint; total_pixels: bigint }[]>`
SELECT SUM(credits) AS total_credits, SUM(pixels) AS total_pixels FROM users
`
const encoder = new TextEncoder()
return new ReadableStream({
start(controller) {
controller.enqueue(encoder.encode(csvRow(['metric', 'value'])))
controller.enqueue(
encoder.encode(csvRow(['total_credits', Number(totals?.total_credits ?? 0)])),
)
controller.enqueue(
encoder.encode(csvRow(['total_pixels', Number(totals?.total_pixels ?? 0)])),
)
controller.close()
},
})
}
export const GET = withAdmin({ permission: PERMS.ANALYTICS_EXPORT }, async (request) => {
const url = new URL(request.url)
const report = url.searchParams.get('report') ?? ''
if (!VALID_REPORTS.has(report)) {
return apiError(`Unknown report. Use one of: ${[...VALID_REPORTS].join(', ')}`, 400)
}
const stream =
report === 'registrations'
? await streamRegistrations()
: report === 'online-by-hour'
? await streamOnlineByHour()
: await streamEconomy()
return new Response(stream, {
headers: {
'Content-Type': 'text/csv; charset=utf-8',
'Content-Disposition': `attachment; filename="${report}.csv"`,
'Cache-Control': 'no-store',
},
})
})