119 lines
3.3 KiB
TypeScript
119 lines
3.3 KiB
TypeScript
import { sql } from "drizzle-orm";
|
|
import { withAdmin } from "@/lib/api-handler";
|
|
import { apiError } from "@/lib/api-response";
|
|
import { db } from "@/lib/db";
|
|
import { PERMS } from "@/lib/permission-slugs";
|
|
|
|
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 db.execute(sql`
|
|
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
|
|
`)) as unknown as [{ day: string; cnt: bigint }[], unknown];
|
|
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 db.execute(sql`
|
|
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
|
|
`)) as unknown as [{ hour: number; cnt: bigint }[], unknown];
|
|
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 [rows] = (await db.execute(sql`
|
|
SELECT SUM(credits) AS total_credits, SUM(pixels) AS total_pixels FROM users
|
|
`)) as unknown as [
|
|
{ total_credits: bigint; total_pixels: bigint }[],
|
|
unknown,
|
|
];
|
|
const totals = rows[0];
|
|
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",
|
|
},
|
|
});
|
|
},
|
|
);
|