255 lines
10 KiB
TypeScript
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;
|
|
}
|