Files
RAPPORT-HOST/server/routes/admin.js
T
karim 6471221dec feat(admin): CSV-Export der Kunden für die Buchhaltung
GET /admin/export/accounts.csv — eine Zeile pro Kunde (Profil + aktuelles
Abo + Plan-Preis + Instanzen). Semikolon-getrennt, UTF-8 BOM (Excel-CH),
Content-Disposition mit Datum. Nur mit Admin-Token (401 sonst).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-05-31 12:15:56 +02:00

160 lines
6.7 KiB
JavaScript
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// RAPPORT-HOST — Betreiber-Bereich (/api/admin). Separates Admin-Login
// (ADMIN_PASSWORD), getrennt von Kundenkonten. Übersicht aller Kunden, Abos,
// Instanzen + Sperren/Reaktivieren.
import { Router } from "express";
import { one, query } from "../db.js";
import { requireAdmin, signAdminToken } from "../auth.js";
import { getPlan } from "../plans.js";
import { env } from "../env.js";
export const adminRouter = Router();
// — Separates Admin-Login (Passwort aus ADMIN_PASSWORD) — kein requireAdmin davor.
adminRouter.post("/login", (req, res) => {
if (!env.adminPassword) return res.status(503).json({ error: "Admin-Bereich nicht konfiguriert." });
if ((req.body?.password || "") !== env.adminPassword) {
return res.status(401).json({ error: "Passwort falsch." });
}
res.json({ token: signAdminToken() });
});
// Alle folgenden Routen verlangen einen gültigen Operator-Token.
adminRouter.use(requireAdmin);
// — Kennzahlen fürs Dashboard —
adminRouter.get("/stats", async (_req, res) => {
const accounts = await one("select count(*)::int n from accounts");
const newAccounts = await one("select count(*)::int n from accounts where created_at > now() - interval '30 days'");
const activeSubs = await one("select count(*)::int n from subscriptions where status = 'active'");
const instances = await one("select count(*)::int n from instances");
const activeInst = await one("select count(*)::int n from instances where status = 'active'");
const suspendedInst = await one("select count(*)::int n from instances where status = 'suspended'");
// MRR + Verteilung nach Plan (nur aktive Abos).
const { rows: subs } = await query("select plan from subscriptions where status = 'active'");
let mrr = 0;
const byPlan = {};
for (const s of subs) {
const p = getPlan(s.plan);
mrr += p?.priceChf || 0;
byPlan[s.plan] = byPlan[s.plan] || { count: 0, revenue: 0, name: p?.name || s.plan };
byPlan[s.plan].count += 1;
byPlan[s.plan].revenue += p?.priceChf || 0;
}
res.json({
accounts: accounts.n,
newAccounts30d: newAccounts.n,
activeSubscriptions: activeSubs.n,
instances: instances.n,
activeInstances: activeInst.n,
suspendedInstances: suspendedInst.n,
mrrChf: mrr,
arrChf: mrr * 12,
byPlan, // { solo: {count,revenue,name}, ... }
});
});
// — Alle Kunden mit Abo + Instanzen —
adminRouter.get("/accounts", async (_req, res) => {
const { rows } = await query(`
select a.id, a.email, a.company, a.created_at,
s.plan, s.status as sub_status, s.current_period_end,
coalesce(i.cnt, 0)::int as instance_count
from accounts a
left join lateral (
select plan, status, current_period_end
from subscriptions where account_id = a.id
order by created_at desc limit 1
) s on true
left join (
select account_id, count(*) cnt from instances group by account_id
) i on i.account_id = a.id
order by a.created_at desc
`);
res.json({ accounts: rows });
});
// — Voll-Detail eines Kontos: Profil + Abo-Historie + Instanzen —
adminRouter.get("/accounts/:id", async (req, res) => {
const account = await one(
`select id, email, company, contact_name, street, zip, city, country, phone, created_at
from accounts where id = $1`,
[req.params.id]
);
if (!account) return res.status(404).json({ error: "Konto nicht gefunden." });
const { rows: subscriptions } = await query(
`select plan, status, current_period_end, stripe_subscription_id, created_at
from subscriptions where account_id = $1 order by created_at desc`,
[req.params.id]
);
const { rows: instances } = await query(
"select id, studio_slug, label, instance_url, status, created_at from instances where account_id = $1 order by created_at",
[req.params.id]
);
// Plan-Preis an die Abos hängen (fürs UI).
for (const s of subscriptions) s.priceChf = getPlan(s.plan)?.priceChf ?? null;
res.json({ account, subscriptions, instances });
});
// — CSV-Export für die Buchhaltung —
// Eine Zeile pro Kunde mit aktuellem Abo + Plan-Preis. Semikolon-getrennt
// (Excel-CH-freundlich), UTF-8 BOM, damit Umlaute korrekt erscheinen.
function csvCell(v) {
const s = v == null ? "" : String(v);
return /[";\n]/.test(s) ? '"' + s.replace(/"/g, '""') + '"' : s;
}
adminRouter.get("/export/accounts.csv", async (_req, res) => {
const { rows } = await query(`
select a.email, a.company, a.contact_name, a.street, a.zip, a.city, a.country, a.created_at,
s.plan, s.status as sub_status, s.current_period_end,
coalesce(i.cnt, 0)::int as instance_count
from accounts a
left join lateral (
select plan, status, current_period_end from subscriptions
where account_id = a.id order by created_at desc limit 1
) s on true
left join (select account_id, count(*) cnt from instances group by account_id) i
on i.account_id = a.id
order by a.created_at
`);
const header = ["Email","Firma","Ansprechperson","Strasse","PLZ","Ort","Land",
"Kunde_seit","Plan","Abo_Status","Periode_bis","Preis_CHF_mtl","Instanzen"];
const lines = [header.join(";")];
for (const r of rows) {
const price = getPlan(r.plan)?.priceChf ?? "";
lines.push([
r.email, r.company, r.contact_name, r.street, r.zip, r.city, r.country,
r.created_at ? new Date(r.created_at).toISOString().slice(0, 10) : "",
r.plan || "", r.sub_status || "", r.current_period_end ? new Date(r.current_period_end).toISOString().slice(0, 10) : "",
r.sub_status === "active" ? price : "", r.instance_count,
].map(csvCell).join(";"));
}
const csv = "" + lines.join("\r\n");
res.setHeader("Content-Type", "text/csv; charset=utf-8");
res.setHeader("Content-Disposition", `attachment; filename="rapport-kunden-${new Date().toISOString().slice(0,10)}.csv"`);
res.send(csv);
});
// — Instanzen eines Kontos (Kurzform) —
adminRouter.get("/accounts/:id/instances", async (req, res) => {
const { rows } = await query(
"select id, studio_slug, label, instance_url, status, created_at from instances where account_id = $1 order by created_at",
[req.params.id]
);
res.json({ instances: rows });
});
// — Instanz sperren / reaktivieren —
adminRouter.post("/instances/:id/:action", async (req, res) => {
const { id, action } = req.params;
const map = { suspend: "suspended", reactivate: "active" };
const status = map[action];
if (!status) return res.status(400).json({ error: "Unbekannte Aktion." });
const row = await one("update instances set status = $1 where id = $2 returning id, status", [status, id]);
if (!row) return res.status(404).json({ error: "Instanz nicht gefunden." });
res.json({ instance: row });
});