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 { 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 { 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(tenantIds: string[], operation: (tenantId: string) => Promise): Promise { 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; }