ddc04796d5
Der Edit im vorigen Commit (7c100e9) hatte die const angelegt, das Feld aber
nicht in die Antwort gehängt → pastDue war im Frontend undefined.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
204 lines
8.1 KiB
JavaScript
204 lines
8.1 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, provisioningMock } 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'");
|
||
// Zahlungsprobleme: Abos in past_due (fehlgeschlagene Zahlung) — braucht Aufmerksamkeit.
|
||
const pastDue = await one("select count(*)::int n from subscriptions where status = 'past_due'");
|
||
|
||
// 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,
|
||
pastDueSubscriptions: pastDue.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 });
|
||
});
|
||
|
||
// — Health-Check aller aktiven Instanzen —
|
||
// Pingt jede Instanz-URL (HEAD, kurzes Timeout) und meldet up/down. Im
|
||
// MOCK-Modus (provisioningMock) sind die URLs synthetisch → Status "unknown"
|
||
// statt fake "up", damit das Cockpit ehrlich bleibt.
|
||
adminRouter.get("/health", async (_req, res) => {
|
||
const { rows } = await query(
|
||
"select id, studio_slug, label, instance_url, status from instances where status = 'active'"
|
||
);
|
||
|
||
if (provisioningMock) {
|
||
return res.json({
|
||
mock: true,
|
||
checked: 0,
|
||
instances: rows.map((i) => ({ ...i, health: "unknown" })),
|
||
});
|
||
}
|
||
|
||
async function ping(url) {
|
||
const ctrl = new AbortController();
|
||
const t = setTimeout(() => ctrl.abort(), 4000);
|
||
try {
|
||
const r = await fetch(url, { method: "HEAD", signal: ctrl.signal, redirect: "manual" });
|
||
return r.status < 500 ? "up" : "down";
|
||
} catch {
|
||
return "down";
|
||
} finally {
|
||
clearTimeout(t);
|
||
}
|
||
}
|
||
|
||
const checked = await Promise.all(
|
||
rows.map(async (i) => ({ ...i, health: await ping(i.instance_url) }))
|
||
);
|
||
res.json({
|
||
mock: false,
|
||
checked: checked.length,
|
||
down: checked.filter((i) => i.health === "down").length,
|
||
instances: checked,
|
||
});
|
||
});
|
||
|
||
// — 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 });
|
||
});
|