Files
RAPPORT-HOST/server/routes/admin.js
T
karim 4d45cdcba3 feat(admin): erweiterte Kennzahlen + Kunden-Detail-API
- /admin/stats: newAccounts30d, suspendedInstances, ARR, byPlan (count+revenue)
- /admin/accounts/🆔 Voll-Detail (Profil + Abo-Historie + Instanzen, Plan-Preis)

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

121 lines
4.8 KiB
JavaScript

// 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 });
});
// — 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 });
});