Files
HiveGrid-Wireframes/03 Quellen und Referenzen/hivegrid-cloud/apps/web/app/utils/data/.server/appData.ts
T

202 lines
9.2 KiB
TypeScript

import { Params, redirect } from "react-router";
import { DefaultAdminRoles } from "~/application/dtos/shared/DefaultAdminRoles";
import { DefaultAppRoles } from "~/application/dtos/shared/DefaultAppRoles";
import { DefaultPermission } from "~/application/dtos/shared/DefaultPermissions";
import { TenantUserType } from "~/application/enums/tenants/TenantUserType";
import { timeFake, TimeFunction } from "~/modules/metrics/services/.server/MetricTracker";
import OnboardingService from "~/modules/onboarding/services/OnboardingService";
import { absoluteAppRootPath } from "~/utils/app/CrossHostUrls";
import { appPath } from "~/utils/app/CanonicalUrlUtils";
import UrlUtils from "~/utils/app/UrlUtils";
import { loadRequiredWebConfig } from "@hivegrid/config";
import { normalizeBillingSubscriptionStatus } from "@hivegrid/db/billing-domain.server";
import { buildSubscriptionBannerVM } from "@hivegrid/view-models";
import { getMyGroups } from "~/utils/db/permissions/groups.db.server";
import { getAllRolesWithoutPermissions } from "~/utils/db/permissions/roles.db.server";
import { getPermissionsByUser, getUserRoleInTenant, getUserRoleInAdmin } from "~/utils/db/permissions/userRoles.db.server";
import { getUserEffectiveSeatType } from "~/utils/db/permissions/seatType.db.server";
import { adminGetAllActiveTenantSimple, getTenantSimple, getTenantUserType } from "~/utils/db/tenants.db.server";
import { getAuthUserWithoutPasswordById as getUser, getTenantsForUser as getMyTenants } from "@hivegrid/db/auth-boundary.server";
import { promiseHash } from "~/utils/promises/promiseHash";
import { getTenantIdFromUrl } from "~/utils/services/.server/urlService";
import { getActiveTenantSubscriptions, getPlanFeatureUsage } from "~/utils/services/.server/subscriptionService";
import { getUserInfo } from "~/utils/session.server";
import { AppLoaderData } from "../useAppData";
import { PlanFeatureUsageDto } from "~/application/dtos/subscriptions/PlanFeatureUsageDto";
import { CreditTypes } from "~/modules/usage/dtos/CreditType";
import { DefaultFeatures } from "~/application/dtos/shared/DefaultFeatures";
import { TFunction } from "i18next";
import { featureKeys, type FeatureKey } from "@hivegrid/entitlements";
import { resolveTenantProductAccessBatch } from "~/utils/entitlements/.server/productAccess.server";
import { WALKING_SKELETON_MODULE_KEY } from "~/modules/walkingSkeleton/constants";
// Every feature key the AppSidebar gates on via hasFeatureAccess() MUST be
// listed here — a key missing from this list resolves to no productAccess entry
// and silently hides the sidebar item for ALL tenants/plans (recurring footgun:
// files+tasks were re-added in 1017cf58b; dashboard/automation/leads/projects/
// hr/integrations/email-inbox/booking/forms re-added 2026-07-07 after the R-P5
// verification found "Integrationen" unreachable despite being plan-included).
const tenantNavigationFeatureKeys = [
featureKeys.dashboard,
featureKeys.crmBasic,
featureKeys.leads,
featureKeys.projects,
featureKeys.files,
featureKeys.tasks,
featureKeys.hr,
featureKeys.integrations,
featureKeys.telephony,
featureKeys.chat,
featureKeys.booking,
featureKeys.forms,
featureKeys.billing,
featureKeys.search,
featureKeys.automation,
featureKeys.emailMarketing,
featureKeys.emailInbox,
featureKeys.api,
featureKeys.workflowEngine,
featureKeys.credits,
// Not a sidebar entry: gates the topbar "Mit KI verbinden" launcher (VD-MCP).
featureKeys.mcp,
] as const satisfies readonly FeatureKey[];
// R-P1c: Konnektor-Demo-Modul (kein Entitlement-FeatureKey) — speist das Sidebar-Gate der
// Walking-Skeleton-Route. Liefert für reale Tenants "blueprint-disabled" → Eintrag ausgeblendet.
const tenantConnectorDemoModuleKeys: readonly string[] = [WALKING_SKELETON_MODULE_KEY];
export async function loadAppData({
request,
params,
t,
time = timeFake,
tenantId: preloadedTenantId,
userInfo: preloadedUserInfo,
user: preloadedUser,
isOperatorAdmin: preloadedIsOperatorAdmin,
}: {
request: Request;
params: Params;
t: TFunction;
time?: TimeFunction;
tenantId?: string;
userInfo?: Awaited<ReturnType<typeof getUserInfo>>;
user?: Awaited<ReturnType<typeof getUser>>;
isOperatorAdmin?: boolean;
}) {
const { tenantId, userInfo } = await time(
promiseHash({
tenantId: preloadedTenantId ? Promise.resolve(preloadedTenantId) : getTenantIdFromUrl(params),
userInfo: preloadedUserInfo ? Promise.resolve(preloadedUserInfo) : getUserInfo(request),
}),
"loadAppData.session"
);
const url = new URL(request.url);
const operatorAdminPromise: Promise<boolean> =
preloadedIsOperatorAdmin !== undefined
? Promise.resolve(preloadedIsOperatorAdmin)
: userInfo?.userId
? getUserRoleInAdmin(userInfo.userId, DefaultAdminRoles.SuperAdmin).then((role) => Boolean(role))
: Promise.resolve(false);
const {
user,
currentTenant,
operatorAdmin: operatorAdminValue,
} = await time(
promiseHash({
user: preloadedUser ? Promise.resolve(preloadedUser) : getUser(userInfo?.userId),
currentTenant: getTenantSimple(tenantId),
operatorAdmin: operatorAdminPromise,
}),
"loadAppData.getUserAndTenant"
);
const operatorAdmin = operatorAdminValue === true;
const redirectTo = url.pathname + url.search;
if (!userInfo || !user) {
let searchParams = new URLSearchParams([["redirect", redirectTo]]);
throw redirect(`/login?${searchParams}`);
}
if (!currentTenant) {
throw redirect(absoluteAppRootPath(loadRequiredWebConfig().routing));
}
const [, tenantPathSegment = "", ...childPathSegments] = url.pathname.split("/");
const childPath = childPathSegments.join("/");
if (params.tenant && params.tenant !== currentTenant.slug) {
throw redirect(`${appPath({ tenantSlug: currentTenant.slug, path: childPath || "dashboard", mode: "host-aware" })}${url.search}`);
}
if (UrlUtils.stripTrailingSlash(url.pathname) === UrlUtils.stripTrailingSlash(appPath({ tenantSlug: tenantPathSegment, mode: "host-aware" }))) {
throw redirect(`${appPath({ tenantSlug: currentTenant.slug, path: "dashboard", mode: "host-aware" })}${url.search}`);
}
let { myTenants, mySubscription, allPermissions, superUserRole, allRoles, myGroups, onboardingSession, effectiveSeatType } = await time(
promiseHash({
myTenants: time(operatorAdmin ? adminGetAllActiveTenantSimple() : getMyTenants(user.id), "loadAppData.getDetails.getMyTenants"),
mySubscription: time(getActiveTenantSubscriptions(tenantId), "loadAppData.getDetails.getActiveTenantSubscriptions"),
allPermissions: time(getPermissionsByUser(userInfo.userId, tenantId), "loadAppData.getDetails.getPermissionsByUser"),
superUserRole: time(getUserRoleInTenant(userInfo.userId, tenantId, DefaultAppRoles.SuperUser), "loadAppData.getDetails.getUserRoleInTenant"),
allRoles: time(getAllRolesWithoutPermissions("app"), "loadAppData.getDetails.getAllRolesWithoutPermissions"),
myGroups: time(getMyGroups(user.id, currentTenant.id), "loadAppData.getDetails.getMyGroups"),
onboardingSession: time(
OnboardingService.getUserActiveOnboarding({ userId: user.id, tenantId: currentTenant.id, request }),
"loadAppData.getDetails.OnboardingService.getUserActiveOnboarding"
),
effectiveSeatType: time(getUserEffectiveSeatType(userInfo.userId, tenantId), "loadAppData.getDetails.getUserEffectiveSeatType"),
}),
"loadAppData.getDetails"
);
const { tenantUser, credits, productAccessEntries } = await time(
promiseHash({
tenantUser: time(getTenantUserType(userInfo.userId, tenantId), "loadAppData.postDetails.getTenantUserType"),
credits:
CreditTypes.length > 0
? time(getPlanFeatureUsage(tenantId, DefaultFeatures.Credits), "loadAppData.postDetails.getPlanFeatureUsage")
: Promise.resolve(undefined as PlanFeatureUsageDto | undefined),
productAccessEntries: time(
resolveTenantProductAccessBatch({
tenantId,
userId: userInfo.userId,
featureKeys: [...tenantNavigationFeatureKeys, ...tenantConnectorDemoModuleKeys],
subscription: mySubscription,
tenantRolePermissionNames: allPermissions,
bypassTenantRolePermission: operatorAdmin,
}),
"loadAppData.postDetails.resolveTenantProductAccessBatch"
),
}),
"loadAppData.postDetails"
);
const currentRole = operatorAdmin ? TenantUserType.ADMIN : (tenantUser?.type ?? TenantUserType.MEMBER);
const billingBanner = buildSubscriptionBannerVM({
status: normalizeBillingSubscriptionStatus(mySubscription?.status, {
frozenAt: mySubscription?.frozenAt,
cancelledAt: mySubscription?.cancelledAt,
}),
hasSubscription: mySubscription !== null,
trialEndsAt: mySubscription?.trialEndsAt,
});
const data: AppLoaderData = {
// i18n: i18n.translations,
user,
myTenants,
currentTenant,
currentRole,
mySubscription,
billingBanner,
// roles,
allRoles,
permissions: allPermissions.map((f) => f as DefaultPermission),
myGroups,
isSuperUser: !!superUserRole,
isSuperAdmin: operatorAdmin,
lng: user?.locale ?? userInfo.lng,
onboardingSession,
credits,
productAccess: Object.fromEntries(productAccessEntries),
effectiveSeatType,
};
return data;
}