Files
HiveGrid-Wireframes/03 Quellen und Referenzen/hivegrid-cloud/apps/web/app/services/admin/admin-dashboard-personalization.server.ts
T

521 lines
15 KiB
TypeScript

import type {
AdminDashboardVM,
AdminDashboardWidgetId,
AdminDashboardWidgetInput,
} from "@hivegrid/view-models";
import { buildAdminDashboardVM } from "@hivegrid/view-models";
import type {
AdminPermissionName,
AdminRoleKey,
} from "@hivegrid/db/admin-permissions";
import {
adminSalesDealReadWhere,
adminSalesTaskReadWhere,
} from "@hivegrid/db/admin-sales-access-scope";
import { runWithRlsContext } from "@hivegrid/db/rls-context.server";
import { adminPath } from "~/utils/app/CanonicalUrlUtils";
import { db } from "~/utils/db.server";
import { adminGetAllTenantsIdsAndNames } from "~/utils/db/tenants.db.server";
import { getMrr } from "~/utils/services/.server/subscriptionService";
import { getOperatorHealthSnapshot } from "./operator-health.server";
type DashboardSource =
| "salesOwn"
| "salesTeam"
| "support"
| "engineering"
| "executive";
type DashboardSourceLoader = (input: {
readonly adminUserId: string;
readonly now: Date;
readonly locale: string;
}) => Promise<readonly AdminDashboardWidgetInput[]>;
export type AdminDashboardDataSources = Readonly<
Record<DashboardSource, DashboardSourceLoader>
>;
const ROLE_WIDGETS = {
super_admin: ["super_platform"],
sales: [
"sales_own_pipeline",
"sales_today",
"sales_appointments",
"sales_followups",
],
sales_manager: [
"sales_team_pipeline",
"sales_unassigned_leads",
"sales_team_activity",
],
support: ["support_inbox", "support_customer_events"],
engineering: [
"engineering_health",
"engineering_queues",
"engineering_errors",
"engineering_deploy",
],
executive: [
"executive_forecast",
"executive_revenue",
"executive_mrr",
"executive_arr",
"executive_churn",
],
viewer: ["viewer_reports"],
} as const satisfies Record<AdminRoleKey, readonly AdminDashboardWidgetId[]>;
const WIDGET_PERMISSION = {
sales_own_pipeline: "admin_sales.crm.read",
sales_today: "admin_sales.crm.read",
sales_appointments: "admin_sales.booking.manage",
sales_followups: "admin_sales.crm.read",
sales_team_pipeline: "admin_sales.crm.read_all",
sales_unassigned_leads: "admin_sales.crm.read_all",
sales_team_activity: "admin_sales.reporting.read_all",
support_inbox: "admin_identity.impersonation.start",
support_customer_events: "admin_identity.impersonation.start",
engineering_health: "admin.metrics.view",
engineering_queues: "admin.metrics.view",
engineering_errors: "admin.events.view",
engineering_deploy: "admin.metrics.view",
executive_forecast: "admin_sales.reporting.read_all",
executive_revenue: "admin_sales.reporting.read_all",
executive_mrr: "admin_sales.reporting.read_all",
executive_arr: "admin_sales.reporting.read_all",
executive_churn: "admin_sales.reporting.read_all",
viewer_reports: "admin_sales.reporting.read",
super_platform: "admin_identity.roles.manage",
} as const satisfies Record<AdminDashboardWidgetId, AdminPermissionName>;
const WIDGET_SOURCE = {
sales_own_pipeline: "salesOwn",
sales_today: "salesOwn",
sales_appointments: "salesOwn",
sales_followups: "salesOwn",
sales_team_pipeline: "salesTeam",
sales_unassigned_leads: "salesTeam",
sales_team_activity: "salesTeam",
support_inbox: "support",
support_customer_events: "support",
engineering_health: "engineering",
engineering_queues: "engineering",
engineering_errors: "engineering",
engineering_deploy: "engineering",
executive_forecast: "executive",
executive_revenue: "executive",
executive_mrr: "executive",
executive_arr: "executive",
executive_churn: "executive",
viewer_reports: null,
super_platform: null,
} as const satisfies Record<
AdminDashboardWidgetId,
DashboardSource | null
>;
export function selectAdminDashboardWidgetIds(input: {
readonly roleKeys: readonly AdminRoleKey[];
readonly permissions: readonly AdminPermissionName[];
}): readonly AdminDashboardWidgetId[] {
const permissions = new Set(input.permissions);
const selected = new Set<AdminDashboardWidgetId>();
for (const roleKey of input.roleKeys) {
for (const widgetId of ROLE_WIDGETS[roleKey]) {
if (permissions.has(WIDGET_PERMISSION[widgetId])) {
selected.add(widgetId);
}
}
}
return [...selected];
}
export async function loadPersonalizedAdminDashboard(
input: {
readonly adminUserId: string;
readonly roleKeys: readonly AdminRoleKey[];
readonly permissions: readonly AdminPermissionName[];
readonly now?: Date;
readonly locale?: string;
},
sources: AdminDashboardDataSources = DEFAULT_SOURCES
): Promise<AdminDashboardVM> {
const selectedIds = selectAdminDashboardWidgetIds(input);
const selectedSet = new Set(selectedIds);
const sourceNames = new Set(
selectedIds
.map((widgetId) => WIDGET_SOURCE[widgetId])
.filter((source): source is DashboardSource => source !== null)
);
const now = input.now ?? new Date();
const locale = input.locale ?? "de";
const loaded = await Promise.all(
[...sourceNames].map((sourceName) =>
sources[sourceName]({ adminUserId: input.adminUserId, now, locale })
)
);
const loadedById = new Map(
loaded.flat().map((widget) => [widget.id, widget])
);
const widgets = selectedIds.flatMap((widgetId) => {
const loadedWidget = loadedById.get(widgetId);
if (loadedWidget) return [loadedWidget];
if (widgetId === "viewer_reports") {
return [
widget(
widgetId,
"admin.dashboard.personalized.readOnly",
adminPath("dashboard", { mode: "host-aware" })
),
];
}
if (widgetId === "super_platform") {
return [
widget(
widgetId,
"admin.dashboard.personalized.fullAccess",
adminPath("accounts", { mode: "host-aware" }),
"positive"
),
];
}
return [];
});
return buildAdminDashboardVM({
roles: input.roleKeys,
widgets: widgets.filter((entry) => selectedSet.has(entry.id)),
});
}
const DEFAULT_SOURCES: AdminDashboardDataSources = {
salesOwn: loadSalesOwnWidgets,
salesTeam: loadSalesTeamWidgets,
support: loadSupportWidgets,
engineering: loadEngineeringWidgets,
executive: loadExecutiveWidgets,
};
async function loadSalesOwnWidgets({
adminUserId,
now,
locale,
}: Parameters<DashboardSourceLoader>[0]) {
const endOfToday = new Date(now);
endOfToday.setHours(24, 0, 0, 0);
const nextThirtyDays = new Date(now);
nextThirtyDays.setDate(nextThirtyDays.getDate() + 30);
const dealScope = adminSalesDealReadWhere({
adminUserId,
canReadAll: false,
canWriteAll: false,
canReadAllCommunication: false,
});
const taskScope = adminSalesTaskReadWhere({
adminUserId,
canReadAll: false,
canWriteAll: false,
canReadAllCommunication: false,
});
const [pipeline, today, appointments, followups] = await Promise.all([
db.adminDeal.aggregate({
where: { ...dealScope, archivedAt: null, status: "open" },
_sum: { amountCents: true },
}),
db.adminTask.count({
where: {
...taskScope,
archivedAt: null,
status: "open",
dueAt: { lt: endOfToday },
},
}),
db.adminBooking.count({
where: {
adminUserId,
status: { in: ["reserved", "confirmed"] },
startsAt: { gte: now, lt: nextThirtyDays },
},
}),
db.adminTask.count({
where: {
...taskScope,
archivedAt: null,
status: "open",
reminderAt: { not: null },
},
}),
]);
return [
widget(
"sales_own_pipeline",
formatEuroCents(pipeline._sum.amountCents ?? 0, locale),
adminPath("sales/deals", { mode: "host-aware" })
),
widget(
"sales_today",
String(today),
adminPath("sales/tasks/today", { mode: "host-aware" }),
today > 0 ? "warning" : "positive"
),
widget(
"sales_appointments",
String(appointments),
adminPath("sales/bookings", { mode: "host-aware" })
),
widget(
"sales_followups",
String(followups),
adminPath("sales/tasks/today", { mode: "host-aware" })
),
];
}
async function loadSalesTeamWidgets({
now,
locale,
}: Parameters<DashboardSourceLoader>[0]) {
const activityStart = new Date(now);
activityStart.setDate(activityStart.getDate() - 7);
const [pipeline, unassignedLeads, activity] = await Promise.all([
db.adminDeal.aggregate({
where: { archivedAt: null, status: "open" },
_sum: { amountCents: true },
}),
db.adminLead.count({
where: {
archivedAt: null,
assignedToAdminUserId: null,
status: { in: ["new", "contacted", "qualified"] },
},
}),
db.adminActivity.count({
where: { occurredAt: { gte: activityStart } },
}),
]);
return [
widget(
"sales_team_pipeline",
formatEuroCents(pipeline._sum.amountCents ?? 0, locale),
adminPath("sales/deals", { mode: "host-aware" })
),
widget(
"sales_unassigned_leads",
String(unassignedLeads),
adminPath("leads", { mode: "host-aware" }),
unassignedLeads > 0 ? "warning" : "positive"
),
widget(
"sales_team_activity",
String(activity),
adminPath("sales/tasks/today", { mode: "host-aware" })
),
];
}
async function loadSupportWidgets({
now,
}: Parameters<DashboardSourceLoader>[0]) {
const eventStart = new Date(now);
eventStart.setDate(eventStart.getDate() - 1);
const [openTickets, customerEvents] = await Promise.all([
db.supportTicket.count({
where: { status: { in: ["open", "answered"] } },
}),
db.supportTicket.count({ where: { updatedAt: { gte: eventStart } } }),
]);
return [
widget(
"support_inbox",
String(openTickets),
adminPath("support", { mode: "host-aware" }),
openTickets > 0 ? "warning" : "positive"
),
widget(
"support_customer_events",
String(customerEvents),
adminPath("audit", { mode: "host-aware" })
),
];
}
async function loadEngineeringWidgets() {
try {
const snapshot = await getOperatorHealthSnapshot();
const issueCount = snapshot.subsystems.filter(
(subsystem) => subsystem.status !== "ok"
).length;
return [
widget(
"engineering_health",
issueCount === 0 ? "OK" : String(issueCount),
adminPath("system", { mode: "host-aware" }),
issueCount === 0 ? "positive" : "warning"
),
widget(
"engineering_queues",
String(snapshot.signals.failedJobsTotal),
adminPath("system/jobs", { mode: "host-aware" }),
snapshot.signals.failedJobsTotal > 0 ? "critical" : "positive"
),
widget(
"engineering_errors",
String(
snapshot.signals.webhookFailures +
snapshot.signals.billingErrors
),
adminPath("events", { mode: "host-aware" }),
snapshot.signals.webhookFailures + snapshot.signals.billingErrors > 0
? "critical"
: "positive"
),
widget(
"engineering_deploy",
snapshot.deploy.release,
adminPath("system/deploys", { mode: "host-aware" })
),
];
} catch {
return [
widget(
"engineering_health",
"admin.dashboard.personalized.unavailable",
adminPath("system", { mode: "host-aware" }),
"critical"
),
widget(
"engineering_queues",
"admin.dashboard.personalized.unavailable",
adminPath("system/jobs", { mode: "host-aware" }),
"critical"
),
widget(
"engineering_errors",
"admin.dashboard.personalized.unavailable",
adminPath("events", { mode: "host-aware" }),
"critical"
),
widget(
"engineering_deploy",
"admin.dashboard.personalized.unavailable",
adminPath("system/deploys", { mode: "host-aware" }),
"critical"
),
];
}
}
async function loadExecutiveWidgets({
now,
locale,
}: Parameters<DashboardSourceLoader>[0]) {
const yearStart = new Date(now.getFullYear(), 0, 1);
const tenants = await adminGetAllTenantsIdsAndNames();
const tenantIds = tenants.map((tenant) => tenant.id);
const [forecast, revenue, mrr, subscriptions] = await Promise.all([
db.adminDeal.aggregate({
where: { archivedAt: null, status: "open" },
_sum: { amountCents: true },
}),
db.adminDeal.aggregate({
where: { archivedAt: null, status: "won", wonAt: { gte: yearStart } },
_sum: { amountCents: true },
}),
getMrr("eur", tenantIds),
loadTenantSubscriptions(tenantIds),
]);
const churned = subscriptions.filter(
(subscription) =>
subscription?.cancelledAt !== null ||
subscription?.status === "cancelled"
).length;
const churnRate =
subscriptions.length === 0
? 0
: Math.round((churned / subscriptions.length) * 10_000) / 100;
return [
widget(
"executive_forecast",
formatEuroCents(forecast._sum.amountCents ?? 0, locale),
adminPath("sales/deals", { mode: "host-aware" })
),
widget(
"executive_revenue",
formatEuroCents(revenue._sum.amountCents ?? 0, locale),
adminPath("sales/deals", { mode: "host-aware" })
),
widget(
"executive_mrr",
formatEuroUnits(mrr.total, locale),
adminPath("dashboard", { mode: "host-aware" })
),
widget(
"executive_arr",
formatEuroUnits(mrr.total * 12, locale),
adminPath("dashboard", { mode: "host-aware" })
),
widget(
"executive_churn",
`${new Intl.NumberFormat(locale, {
maximumFractionDigits: 2,
}).format(churnRate)} %`,
adminPath("dashboard", { mode: "host-aware" }),
churnRate > 5 ? "warning" : "neutral"
),
];
}
async function loadTenantSubscriptions(tenantIds: readonly string[]) {
const subscriptions: Array<{
readonly status: string;
readonly cancelledAt: Date | null;
} | null> = [];
for (let index = 0; index < tenantIds.length; index += 10) {
const batch = tenantIds.slice(index, index + 10);
subscriptions.push(
...(await Promise.all(
batch.map((tenantId) =>
runWithRlsContext({ tenantId }, () =>
db.tenantSubscription.findUnique({
where: { tenantId },
select: { status: true, cancelledAt: true },
})
)
)
))
);
}
return subscriptions.filter(
(
subscription
): subscription is { readonly status: string; readonly cancelledAt: Date | null } =>
subscription !== null
);
}
function widget(
id: AdminDashboardWidgetId,
value: string,
href: string,
tone: AdminDashboardWidgetInput["tone"] = "neutral"
): AdminDashboardWidgetInput {
return { id, value, href, tone };
}
function formatEuroCents(value: number, locale: string) {
return formatEuroUnits(value / 100, locale);
}
function formatEuroUnits(value: number, locale: string) {
return new Intl.NumberFormat(locale, {
style: "currency",
currency: "EUR",
maximumFractionDigits: 0,
}).format(value);
}