Files
karim ddc04796d5 fix(admin): pastDueSubscriptions ins stats-JSON (Feld hatte gefehlt)
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>
2026-05-31 12:54:37 +02:00

204 lines
8.1 KiB
JavaScript
Raw Permalink 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, 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 });
});