158 lines
6.6 KiB
TypeScript
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,
|
|
});
|
|
}
|