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

158 lines
6.6 KiB
TypeScript

/**
* AK-P19 — Abo-Baukasten mechanic (DoD #3, foundation for AK-P20).
*
* The platform operator assembles a tenant's subscription by toggling
* product-blueprint feature *connectors* on/off per tenant. A toggle is a
* `TenantFeatureOverride` upsert (enabled/disabled) or delete (inherit),
* which is exactly what the canonical `tenantHasModule()` connector gate
* already consults — so this REUSES the entitlement resolver instead of
* inventing a parallel feature store. Every change invalidates the module
* cache and is audited (reason-gated) through the operator-action gate.
*
* The billing-provider side of "Abo-Zusammenstellung" stays on the existing
* subscription-product helpers / tenant-detail route; this module owns the
* feature-connector mechanic, which is the part AK-P20 self-services in v2.
*/
import {
getDefaultProductBlueprint,
getProductBlueprint,
type ProductBlueprintFeatureDefinition,
} from "@hivegrid/entitlements";
import { runWithRlsContext } from "@hivegrid/db/rls-context.server";
import type { AdminConnectorInputRow, ConnectorOverrideState } from "@hivegrid/view-models";
import { db } from "~/utils/db.server";
import { invalidateTenantModuleAccessCache } from "~/utils/connector/tenantConnector.server";
import { assertAdminActionReason, recordAdminAction, type AdminActionAuditContext } from "./admin-action-audit.server";
export type AdminConnectorErrorCode = "FEATURE_NOT_FOUND" | "FEATURE_NOT_TOGGLEABLE";
/** Connector-toggle failure carrying a stable code + M002 i18n key. */
export class AdminConnectorError extends Error {
override readonly name = "AdminConnectorError";
readonly code: AdminConnectorErrorCode;
readonly i18nKey: string;
constructor(code: AdminConnectorErrorCode) {
super(code);
this.code = code;
this.i18nKey =
code === "FEATURE_NOT_FOUND"
? "admin.connectors.errors.featureNotFound"
: "admin.connectors.errors.featureNotToggleable";
}
}
/** Resolves the blueprint definition for a tenant, falling back to default. */
export type BlueprintReader = (blueprintId: string | null | undefined) => readonly ProductBlueprintFeatureDefinition[];
const defaultBlueprintReader: BlueprintReader = (blueprintId) =>
(blueprintId ? getProductBlueprint(blueprintId) : undefined)?.features ?? getDefaultProductBlueprint().features;
/**
* Pure join of blueprint features with a tenant's stored overrides into the
* framework-free rows the connector VM consumes. Unit-tested without a DB.
*/
export function buildConnectorInputRows(
features: readonly ProductBlueprintFeatureDefinition[],
overrides: ReadonlyMap<string, string>
): AdminConnectorInputRow[] {
return features.map((feature) => ({
featureKey: feature.featureKey,
toggleable: feature.tenantToggleable,
baseEnabled: feature.defaultEnabled,
overrideState: overrides.get(feature.featureKey) ?? null,
}));
}
export interface ConnectorServiceDeps {
readonly db: Pick<typeof db, "tenantProductConfiguration" | "tenantFeatureOverride">;
readonly invalidateCache: (tenantId: string) => void;
readonly readBlueprint: BlueprintReader;
readonly recordAction: (context: AdminActionAuditContext) => Promise<void>;
}
function defaultDeps(): ConnectorServiceDeps {
return {
db,
invalidateCache: invalidateTenantModuleAccessCache,
readBlueprint: defaultBlueprintReader,
recordAction: recordAdminAction,
};
}
/** Reads the connector rows (blueprint features + overrides) for a tenant. */
export async function getTenantConnectorRows(
tenantId: string,
deps: ConnectorServiceDeps = defaultDeps()
): Promise<AdminConnectorInputRow[]> {
return runWithRlsContext({ tenantId }, () => getTenantConnectorRowsInContext(tenantId, deps));
}
async function getTenantConnectorRowsInContext(tenantId: string, deps: ConnectorServiceDeps): Promise<AdminConnectorInputRow[]> {
const [configuration, overrides] = await Promise.all([
deps.db.tenantProductConfiguration.findUnique({ where: { tenantId }, select: { blueprintId: true } }),
deps.db.tenantFeatureOverride.findMany({ where: { tenantId }, select: { featureKey: true, override: true } }),
]);
const overrideMap = new Map(overrides.map((row) => [row.featureKey, row.override]));
return buildConnectorInputRows(deps.readBlueprint(configuration?.blueprintId), overrideMap);
}
export interface SetTenantFeatureConnectorInput {
readonly tenantId: string;
readonly featureKey: string;
readonly state: ConnectorOverrideState;
readonly reason: string;
readonly actorUserId: string | null;
readonly actorEmail?: string | null;
readonly request?: Request;
}
/**
* Toggles one feature connector for a tenant: validates the reason, ensures
* the feature exists in the blueprint and is operator-toggleable, writes the
* override (or clears it for `inherit`), invalidates the module cache, and
* audits the change. Errors propagate so the route surfaces Klartext.
*/
export async function setTenantFeatureConnector(
input: SetTenantFeatureConnectorInput,
deps: ConnectorServiceDeps = defaultDeps()
): Promise<void> {
return runWithRlsContext({ tenantId: input.tenantId }, () => setTenantFeatureConnectorInContext(input, deps));
}
async function setTenantFeatureConnectorInContext(input: SetTenantFeatureConnectorInput, deps: ConnectorServiceDeps): Promise<void> {
const reason = assertAdminActionReason(input.reason);
const configuration = await deps.db.tenantProductConfiguration.findUnique({
where: { tenantId: input.tenantId },
select: { blueprintId: true },
});
const feature = deps.readBlueprint(configuration?.blueprintId).find((item) => item.featureKey === input.featureKey);
if (!feature) throw new AdminConnectorError("FEATURE_NOT_FOUND");
if (!feature.tenantToggleable) throw new AdminConnectorError("FEATURE_NOT_TOGGLEABLE");
if (input.state === "inherit") {
await deps.db.tenantFeatureOverride.deleteMany({
where: { tenantId: input.tenantId, featureKey: input.featureKey },
});
} else {
await deps.db.tenantFeatureOverride.upsert({
where: { tenantId_featureKey: { tenantId: input.tenantId, featureKey: input.featureKey } },
create: { tenantId: input.tenantId, featureKey: input.featureKey, override: input.state, reason },
update: { override: input.state, reason },
});
}
deps.invalidateCache(input.tenantId);
await deps.recordAction({
action: "subscription.featureToggled",
actorUserId: input.actorUserId,
actorEmail: input.actorEmail ?? null,
targetKind: "tenant",
targetId: input.tenantId,
reason,
after: { featureKey: input.featureKey, state: input.state },
request: input.request,
});
}