Files
HiveGrid-Wireframes/03 Quellen und Referenzen/hivegrid-cloud/apps/web/app/utils/services/adminDashboardService.ts
T

255 lines
10 KiB
TypeScript

import { getAdminActiveUserKpiCounts, getAdminTenantKpiCounts } from "@hivegrid/db/kpi.server";
import { getDbClient } from "@hivegrid/db/client.server";
import { listAuditEvents } from "@hivegrid/db/audit-events-query.server";
import { runWithRlsContext } from "@hivegrid/db/rls-context.server";
import { getPlatformOutboxCounts } from "@hivegrid/db/admin-dashboard.server";
import { Stat } from "~/application/dtos/stats/Stat";
import currencies from "~/application/pricing/currencies";
import { getStatChangeType } from "../app/DashboardUtils";
import NumberUtils from "../shared/NumberUtils";
import { getMrr } from "./.server/subscriptionService";
import { promiseHash } from "../promises/promiseHash";
import { adminPath } from "~/utils/app/CanonicalUrlUtils";
import { adminGetAllTenantsIdsAndNames } from "../db/tenants.db.server";
const ADMIN_TENANT_QUERY_BATCH_SIZE = 10;
type AdminDashboardStatsOptions = {
t: (key: string) => string;
gte?: Date;
};
export type AdminDashboardSignalTone = "success" | "warning" | "danger" | "neutral";
export type AdminDashboardSignal = {
id: string;
labelKey: string;
detailKey: string;
value: string;
tone: AdminDashboardSignalTone;
href?: string;
};
export type AdminDashboardActivity = {
id: string;
label: string;
detail: string;
timestamp: string;
tone: AdminDashboardSignalTone;
href: string;
};
export type AdminDashboardOverview = {
billing: {
openIssueCount: number;
pastDueCount: number;
frozenCount: number;
missingProviderCount: number;
signals: AdminDashboardSignal[];
};
system: {
signals: AdminDashboardSignal[];
};
deploy: {
environment: string;
release: string;
signals: AdminDashboardSignal[];
};
recentEvents: AdminDashboardActivity[];
};
export async function getAdminDashboardStats({ t, gte }: AdminDashboardStatsOptions): Promise<Stat[]> {
const { tenantStat, mrrStat, activeUsersStat } = await promiseHash({
tenantStat: getTenantStat({ t, gte }),
mrrStat: getMMRStat(t),
activeUsersStat: getActiveUsersStat({ t, gte }),
});
return [tenantStat, mrrStat, activeUsersStat];
}
export async function getAdminDashboardOverview(): Promise<AdminDashboardOverview> {
const db = getDbClient();
const tenants = await adminGetAllTenantsIdsAndNames();
const subscriptionRows = await mapTenantIdsInBatches(
tenants.map((tenant) => tenant.id),
(tenantId) =>
db.tenantSubscription.findUnique({
where: { tenantId },
select: {
status: true,
pastDueSince: true,
frozenAt: true,
planCode: true,
lagoCustomerId: true,
mollieCustomerId: true,
},
})
);
const subscriptions = subscriptionRows.filter((subscription) => subscription !== null);
const pastDueCount = subscriptions.filter((subscription) => subscription.status === "past_due" || subscription.pastDueSince !== null).length;
const frozenCount = subscriptions.filter((subscription) => subscription.status === "frozen" || subscription.frozenAt !== null).length;
const missingProviderCount = subscriptions.filter(
(subscription) =>
(subscription.planCode !== null || ["active", "past_due", "frozen"].includes(subscription.status)) &&
(subscription.lagoCustomerId === null || subscription.mollieCustomerId === null)
).length;
const { failedWebhookCount, outboxCounts, recentAudit } = await promiseHash({
failedWebhookCount: db.eventWebhookAttempt.count({ where: { success: false } }),
outboxCounts: getPlatformOutboxCounts(undefined, {
allowMarkedLocalDatabaseFallback: process.env.LOCAL_DEV_CREDENTIALS === "1",
}),
recentAudit: listAuditEvents({ take: 5, skip: 0 }),
});
const pendingOutboxCount = outboxCounts.pendingOrProcessing;
const failedOutboxCount = outboxCounts.failed;
const billingIssueCount = pastDueCount + frozenCount + missingProviderCount;
return {
billing: {
openIssueCount: billingIssueCount,
pastDueCount,
frozenCount,
missingProviderCount,
signals: [
signal({
id: "billing-issues",
labelKey: "admin.dashboard.billing.signals.issues.label",
detailKey: billingIssueCount > 0 ? "admin.dashboard.billing.signals.issues.detailOpen" : "admin.dashboard.billing.signals.issues.detailOk",
value: billingIssueCount.toString(),
tone: billingIssueCount > 0 ? "warning" : "success",
href: adminPath("system/billing", { mode: "host-aware" }),
}),
signal({
id: "billing-past-due",
labelKey: "admin.dashboard.billing.signals.pastDue.label",
detailKey: pastDueCount > 0 ? "admin.dashboard.billing.signals.pastDue.detailOpen" : "admin.dashboard.billing.signals.pastDue.detailOk",
value: pastDueCount.toString(),
tone: pastDueCount > 0 ? "danger" : "success",
href: adminPath("system/billing", { mode: "host-aware" }),
}),
signal({
id: "billing-providers",
labelKey: "admin.dashboard.billing.signals.providers.label",
detailKey: missingProviderCount > 0 ? "admin.dashboard.billing.signals.providers.detailOpen" : "admin.dashboard.billing.signals.providers.detailOk",
value: missingProviderCount.toString(),
tone: missingProviderCount > 0 ? "warning" : "success",
href: adminPath("system/billing", { mode: "host-aware" }),
}),
],
},
system: {
signals: [
envSignal("redis", "admin.dashboard.system.signals.redis", Boolean(process.env.BULLMQ_REDIS_URL || process.env.REDIS_URL)),
envSignal("meili", "admin.dashboard.system.signals.meili", Boolean(process.env.MEILI_URL && process.env.MEILI_API_KEY)),
envSignal("lago", "admin.dashboard.system.signals.lago", Boolean(process.env.LAGO_API_URL && process.env.LAGO_API_KEY)),
envSignal("mollie", "admin.dashboard.system.signals.mollie", Boolean(process.env.MOLLIE_API_KEY && process.env.MOLLIE_PROFILE_ID)),
signal({
id: "webhooks",
labelKey: "admin.dashboard.system.signals.webhooks.label",
detailKey: failedWebhookCount > 0 ? "admin.dashboard.system.signals.webhooks.detailOpen" : "admin.dashboard.system.signals.webhooks.detailOk",
value: failedWebhookCount.toString(),
tone: failedWebhookCount > 0 ? "warning" : "success",
href: adminPath("system/webhooks", { mode: "host-aware" }),
}),
signal({
id: "outbox",
labelKey: "admin.dashboard.system.signals.outbox.label",
detailKey: failedOutboxCount > 0 ? "admin.dashboard.system.signals.outbox.detailFailed" : "admin.dashboard.system.signals.outbox.detailOk",
value: `${pendingOutboxCount}/${failedOutboxCount}`,
tone: failedOutboxCount > 0 ? "danger" : pendingOutboxCount > 0 ? "neutral" : "success",
href: adminPath("events", { mode: "host-aware" }),
}),
],
},
deploy: {
environment: process.env.SENTRY_ENVIRONMENT ?? process.env.NODE_ENV ?? "development",
release: process.env.SENTRY_RELEASE ?? process.env.GIT_SHA ?? process.env.COMMIT_SHA ?? "local",
signals: [
envSignal("sentry", "admin.dashboard.deploy.signals.sentry", Boolean(process.env.SENTRY_DSN || process.env.VITE_SENTRY_DSN)),
signal({
id: "release",
labelKey: "admin.dashboard.deploy.signals.release.label",
detailKey: "admin.dashboard.deploy.signals.release.detail",
value: process.env.SENTRY_RELEASE ?? process.env.GIT_SHA?.slice(0, 8) ?? "local",
tone: "neutral",
}),
],
},
recentEvents: recentAudit.rows.map((event) => ({
id: event.id,
label: event.summaryText ?? event.action,
detail: [event.actorEmail, event.targetType].filter(Boolean).join(" · ") || event.sourceModule || event.actorType,
timestamp: event.createdAt.toISOString(),
tone: event.outcome === "success" ? "success" : "warning",
href: adminPath("audit", { mode: "host-aware" }),
})),
};
}
function signal(input: AdminDashboardSignal): AdminDashboardSignal {
return input;
}
function envSignal(id: string, baseKey: string, configured: boolean): AdminDashboardSignal {
return signal({
id,
labelKey: `${baseKey}.label`,
detailKey: configured ? `${baseKey}.detailOk` : `${baseKey}.detailMissing`,
value: configured ? "OK" : "!",
tone: configured ? "success" : "warning",
});
}
async function getTenantStat({ t, gte }: AdminDashboardStatsOptions) {
const { added, total } = await getAdminTenantKpiCounts({ gte });
const tenantStat: Stat = {
name: t("admin.kpi.accounts.name"),
hint: t("admin.kpi.accounts.hint"),
stat: total.toString(),
previousStat: (total - added).toString(),
change: "+" + added.toString(),
changeType: getStatChangeType(added, total),
path: adminPath("accounts", { mode: "host-aware" }),
};
return tenantStat;
}
async function getMMRStat(t: AdminDashboardStatsOptions["t"]) {
const defaultCurrency = currencies.find((f) => f.default);
const currency = defaultCurrency ?? { value: "usd", symbol: "$" };
const tenants = await adminGetAllTenantsIdsAndNames();
const mrr = await getMrr(
currency.value,
tenants.map((tenant) => tenant.id)
);
const tenantStat: Stat = {
name: t("admin.kpi.mrr.name"),
hint: t("admin.kpi.mrr.hint"),
stat: currency.symbol + NumberUtils.decimalFormat(mrr.total),
previousStat: t("admin.kpi.mrr.previousStat").replace("{{0}}", NumberUtils.intFormat(mrr.count)),
};
return tenantStat;
}
async function mapTenantIdsInBatches<T>(tenantIds: string[], operation: (tenantId: string) => Promise<T>): Promise<T[]> {
const results: T[] = [];
for (let index = 0; index < tenantIds.length; index += ADMIN_TENANT_QUERY_BATCH_SIZE) {
const batch = tenantIds.slice(index, index + ADMIN_TENANT_QUERY_BATCH_SIZE);
results.push(...(await Promise.all(batch.map((tenantId) => runWithRlsContext({ tenantId }, () => operation(tenantId))))));
}
return results;
}
async function getActiveUsersStat({ t, gte }: AdminDashboardStatsOptions) {
const { added, total } = await getAdminActiveUserKpiCounts({ gte });
const activeUsersStat: Stat = {
name: t("admin.kpi.activeUsers.name"),
hint: t("admin.kpi.activeUsers.hint"),
stat: total.toString(),
previousStat: (total - added).toString(),
change: "+" + added.toString(),
changeType: getStatChangeType(added, total),
};
return activeUsersStat;
}