342 lines
13 KiB
TypeScript
342 lines
13 KiB
TypeScript
/**
|
|
* Feature-Flags Engine v2 admin — list + rule-JSON editor + audit.
|
|
*
|
|
* Sits alongside the existing flag-management routes; this one is the
|
|
* thin surface for the v2 targeting engine (#53) and intentionally
|
|
* doesn't replace the legacy boolean-toggle UI. A visual rule-builder
|
|
* is deferred to a follow-up — operators here author the rules-tree
|
|
* as JSON in a textarea, which the action validates by re-evaluating
|
|
* with a representative context before persisting.
|
|
*/
|
|
|
|
import { ActionFunctionArgs, LoaderFunction, useFetcher, useLoaderData } from "react-router";
|
|
import { useRef } from "react";
|
|
import { useTranslation } from "react-i18next";
|
|
import { db } from "~/utils/db.server";
|
|
import { verifyUserHasPermission } from "~/utils/helpers/.server/PermissionsService";
|
|
import { validateCSRFToken, getUserInfo } from "~/utils/session.server";
|
|
import { appendCSRFToken } from "~/utils/csrf";
|
|
import { evaluateRule, type TargetingRule } from "@toolctrl/feature-flags";
|
|
import { invalidateFlagCache } from "~/utils/services/.server/featureFlagEvaluator.server";
|
|
import { getTranslations } from "~/locale/i18next.server";
|
|
import { useLocaleFormatters } from "~/locale/format";
|
|
|
|
type V2Flag = {
|
|
id: string;
|
|
name: string;
|
|
description: string;
|
|
enabled: boolean;
|
|
rules: unknown;
|
|
variantsJson: string;
|
|
enabledFrom: string | null;
|
|
enabledUntil: string | null;
|
|
parentFlagId: string | null;
|
|
};
|
|
|
|
type AuditRow = {
|
|
id: string;
|
|
flagId: string;
|
|
flagName: string;
|
|
actorUserId: string | null;
|
|
action: string;
|
|
createdAt: string;
|
|
};
|
|
|
|
type LoaderData = {
|
|
flags: V2Flag[];
|
|
audits: AuditRow[];
|
|
};
|
|
|
|
type RuleValidationError = {
|
|
key: string;
|
|
options?: Record<string, string | number>;
|
|
};
|
|
|
|
const ruleTypes = new Set(["boolean", "allowlist", "percentage", "plan-tier", "role-based", "time-based", "composite"]);
|
|
const compositeOperators = new Set(["AND", "OR", "NOT"]);
|
|
|
|
function validateTargetingRuleShape(value: unknown, path = "rule"): RuleValidationError | null {
|
|
if (!isRecord(value)) {
|
|
return { key: "featureFlags.engine.errors.invalidRuleObject", options: { path } };
|
|
}
|
|
|
|
if (typeof value.type !== "string" || !ruleTypes.has(value.type)) {
|
|
return { key: "featureFlags.engine.errors.invalidRuleType", options: { path } };
|
|
}
|
|
|
|
switch (value.type) {
|
|
case "boolean":
|
|
return typeof value.value === "boolean" ? null : { key: "featureFlags.engine.errors.invalidBooleanValue", options: { path } };
|
|
case "allowlist": {
|
|
for (const key of ["tenantIds", "userIds", "emails"] as const) {
|
|
if (value[key] !== undefined && !isStringArray(value[key])) {
|
|
return { key: "featureFlags.engine.errors.invalidStringArray", options: { path: `${path}.${key}` } };
|
|
}
|
|
}
|
|
return null;
|
|
}
|
|
case "percentage":
|
|
if (typeof value.rolloutPercent !== "number" || !Number.isFinite(value.rolloutPercent) || value.rolloutPercent < 0 || value.rolloutPercent > 100) {
|
|
return { key: "featureFlags.engine.errors.invalidRolloutPercent", options: { path } };
|
|
}
|
|
if (value.bucketBy !== undefined && value.bucketBy !== "userId" && value.bucketBy !== "tenantId") {
|
|
return { key: "featureFlags.engine.errors.invalidBucketBy", options: { path } };
|
|
}
|
|
return null;
|
|
case "plan-tier":
|
|
return isStringArray(value.plans) ? null : { key: "featureFlags.engine.errors.invalidStringArray", options: { path: `${path}.plans` } };
|
|
case "role-based":
|
|
return isStringArray(value.roles) ? null : { key: "featureFlags.engine.errors.invalidStringArray", options: { path: `${path}.roles` } };
|
|
case "time-based":
|
|
for (const key of ["enabledFrom", "enabledUntil"] as const) {
|
|
if (value[key] !== undefined && typeof value[key] !== "string") {
|
|
return { key: "featureFlags.engine.errors.invalidTimeBound", options: { path: `${path}.${key}` } };
|
|
}
|
|
}
|
|
return null;
|
|
case "composite": {
|
|
if (typeof value.op !== "string" || !compositeOperators.has(value.op)) {
|
|
return { key: "featureFlags.engine.errors.invalidCompositeOperator", options: { path } };
|
|
}
|
|
if (!Array.isArray(value.children)) {
|
|
return { key: "featureFlags.engine.errors.invalidCompositeChildren", options: { path } };
|
|
}
|
|
for (let index = 0; index < value.children.length; index += 1) {
|
|
const childError = validateTargetingRuleShape(value.children[index], `${path}.children[${index}]`);
|
|
if (childError) return childError;
|
|
}
|
|
return null;
|
|
}
|
|
default:
|
|
return { key: "featureFlags.engine.errors.invalidRule" };
|
|
}
|
|
}
|
|
|
|
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
}
|
|
|
|
function isStringArray(value: unknown): value is string[] {
|
|
return Array.isArray(value) && value.every((entry) => typeof entry === "string");
|
|
}
|
|
|
|
export const loader: LoaderFunction = async ({ request }) => {
|
|
await verifyUserHasPermission(request, "admin.featureFlags.view");
|
|
const flags = await db.featureFlag.findMany({
|
|
orderBy: { createdAt: "desc" },
|
|
select: {
|
|
id: true,
|
|
name: true,
|
|
description: true,
|
|
enabled: true,
|
|
rules: true,
|
|
variants: true,
|
|
enabledFrom: true,
|
|
enabledUntil: true,
|
|
parentFlagId: true,
|
|
},
|
|
});
|
|
const audits = await db.featureFlagAudit.findMany({
|
|
orderBy: { createdAt: "desc" },
|
|
take: 50,
|
|
include: { flag: { select: { name: true } } },
|
|
});
|
|
const data: LoaderData = {
|
|
flags: flags.map((f) => ({
|
|
id: f.id,
|
|
name: f.name,
|
|
description: f.description,
|
|
enabled: f.enabled,
|
|
rules: f.rules ?? null,
|
|
variantsJson: JSON.stringify(f.variants ?? []),
|
|
enabledFrom: f.enabledFrom ? f.enabledFrom.toISOString() : null,
|
|
enabledUntil: f.enabledUntil ? f.enabledUntil.toISOString() : null,
|
|
parentFlagId: f.parentFlagId,
|
|
})),
|
|
audits: audits.map((a) => ({
|
|
id: a.id,
|
|
flagId: a.flagId,
|
|
flagName: a.flag.name,
|
|
actorUserId: a.actorUserId,
|
|
action: a.action,
|
|
createdAt: a.createdAt.toISOString(),
|
|
})),
|
|
};
|
|
return Response.json(data);
|
|
};
|
|
|
|
export const action = async ({ request }: ActionFunctionArgs) => {
|
|
await verifyUserHasPermission(request, "admin.featureFlags.manage");
|
|
await validateCSRFToken(request);
|
|
const { t } = await getTranslations(request);
|
|
const form = await request.formData();
|
|
const intent = form.get("intent")?.toString() ?? "";
|
|
const id = form.get("id")?.toString() ?? "";
|
|
const userInfo = await getUserInfo(request);
|
|
const actorUserId = userInfo.userId || null;
|
|
|
|
const existing = await db.featureFlag.findUnique({ where: { id } });
|
|
if (!existing) return Response.json({ error: t("featureFlags.engine.errors.notFound") }, { status: 404 });
|
|
|
|
if (intent === "toggle") {
|
|
const newEnabled = !existing.enabled;
|
|
await db.$transaction([
|
|
db.featureFlag.update({ where: { id }, data: { enabled: newEnabled } }),
|
|
db.featureFlagAudit.create({
|
|
data: {
|
|
flagId: id,
|
|
actorUserId,
|
|
action: "toggle",
|
|
oldValue: { enabled: existing.enabled },
|
|
newValue: { enabled: newEnabled },
|
|
},
|
|
}),
|
|
]);
|
|
await invalidateFlagCache(existing.name);
|
|
return Response.json({ ok: true });
|
|
}
|
|
|
|
if (intent === "save-rules") {
|
|
const rulesRaw = form.get("rules")?.toString() ?? "null";
|
|
let parsed: TargetingRule | null;
|
|
try {
|
|
const value = JSON.parse(rulesRaw);
|
|
parsed = value === null ? null : (value as TargetingRule);
|
|
} catch {
|
|
return Response.json({ error: t("featureFlags.engine.errors.invalidRulesJson") }, { status: 400 });
|
|
}
|
|
const validationError = parsed ? validateTargetingRuleShape(parsed) : null;
|
|
if (validationError) {
|
|
return Response.json({ error: t(validationError.key, validationError.options) }, { status: 400 });
|
|
}
|
|
// Validation: evaluate against a representative context to ensure
|
|
// the rule does not throw (it never does — see evaluator.ts — but
|
|
// this catches a structurally-wrong tree early). Empty composite
|
|
// children → false, which is acceptable.
|
|
if (parsed) {
|
|
try {
|
|
evaluateRule(parsed, { flagName: existing.name, tenantId: "ctx_probe", userId: "ctx_probe" });
|
|
} catch (err: unknown) {
|
|
const message = err instanceof Error ? err.message : t("featureFlags.engine.errors.invalidRule");
|
|
return Response.json({ error: message }, { status: 400 });
|
|
}
|
|
}
|
|
// Prisma's JsonValue type rejects our typed TargetingRule narrowing — cast
|
|
// through `unknown` so the JSON column accepts the validated tree (the
|
|
// evaluateRule call above is the runtime guarantee, not the TS shape).
|
|
const rulesForDb = (parsed ?? null) as unknown as never;
|
|
const auditOldRules = (existing.rules ?? null) as unknown as never;
|
|
const auditNewRules = (parsed ?? null) as unknown as never;
|
|
await db.$transaction([
|
|
db.featureFlag.update({ where: { id }, data: { rules: rulesForDb } }),
|
|
db.featureFlagAudit.create({
|
|
data: {
|
|
flagId: id,
|
|
actorUserId,
|
|
action: "update",
|
|
oldValue: { rules: auditOldRules } as unknown as never,
|
|
newValue: { rules: auditNewRules } as unknown as never,
|
|
},
|
|
}),
|
|
]);
|
|
await invalidateFlagCache(existing.name);
|
|
return Response.json({ ok: true });
|
|
}
|
|
|
|
return Response.json({ error: t("featureFlags.engine.errors.unknownIntent") }, { status: 400 });
|
|
};
|
|
|
|
export default function FeatureFlagsEngineRoute() {
|
|
const data = useLoaderData<LoaderData>();
|
|
const fetcher = useFetcher();
|
|
const { t } = useTranslation();
|
|
const formatters = useLocaleFormatters();
|
|
|
|
function submitToggle(flagId: string) {
|
|
const form = new FormData();
|
|
appendCSRFToken(form);
|
|
form.set("intent", "toggle");
|
|
form.set("id", flagId);
|
|
fetcher.submit(form, { method: "post" });
|
|
}
|
|
|
|
function submitRules(flagId: string, rules: string) {
|
|
const form = new FormData();
|
|
appendCSRFToken(form);
|
|
form.set("intent", "save-rules");
|
|
form.set("id", flagId);
|
|
form.set("rules", rules);
|
|
fetcher.submit(form, { method: "post" });
|
|
}
|
|
|
|
return (
|
|
<div className="mx-auto mb-12 max-w-5xl space-y-5 px-4 py-4 sm:px-6 lg:px-8 xl:max-w-7xl">
|
|
<div className="border-border border-b pb-5">
|
|
<h3 className="text-lg leading-6 font-medium">{t("featureFlags.engine.title")}</h3>
|
|
<p className="text-muted-foreground text-sm">{t("featureFlags.engine.description")}</p>
|
|
</div>
|
|
|
|
<section>
|
|
<h4 className="text-sm font-semibold tracking-wide uppercase">{t("featureFlags.plural")}</h4>
|
|
<ul className="divide-border mt-2 divide-y">
|
|
{data.flags.map((flag) => (
|
|
<FlagRow key={flag.id} flag={flag} onToggle={() => submitToggle(flag.id)} onSaveRules={(rules) => submitRules(flag.id, rules)} />
|
|
))}
|
|
</ul>
|
|
</section>
|
|
|
|
<section>
|
|
<h4 className="text-sm font-semibold tracking-wide uppercase">{t("featureFlags.engine.auditLogLast50")}</h4>
|
|
<ul className="divide-border mt-2 divide-y text-sm">
|
|
{data.audits.map((a) => (
|
|
<li key={a.id} className="py-2">
|
|
<time dateTime={a.createdAt} className="text-muted-foreground font-mono text-xs">
|
|
{formatters.dateTime(a.createdAt, { timeStyle: "medium" })}
|
|
</time>{" "}
|
|
<span className="font-medium">{a.flagName}</span> —{" "}
|
|
<span>{a.action}</span>
|
|
{a.actorUserId ? <span className="text-muted-foreground"> {t("featureFlags.engine.auditActor", { actor: a.actorUserId })}</span> : null}
|
|
</li>
|
|
))}
|
|
</ul>
|
|
</section>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function FlagRow(props: { flag: V2Flag; onToggle: () => void; onSaveRules: (rules: string) => void }) {
|
|
const { flag } = props;
|
|
const { t } = useTranslation();
|
|
const rulesRef = useRef<HTMLTextAreaElement | null>(null);
|
|
return (
|
|
<li className="space-y-2 py-3">
|
|
<div className="flex items-center justify-between">
|
|
<div>
|
|
<div className="font-medium">{flag.name}</div>
|
|
<div className="text-muted-foreground text-xs">{flag.description}</div>
|
|
</div>
|
|
<button
|
|
type="button"
|
|
onClick={props.onToggle}
|
|
className={`rounded-md border px-3 py-1 text-sm ${flag.enabled ? "border-green-300 bg-green-50" : "border-gray-300 bg-gray-50"}`}
|
|
>
|
|
{flag.enabled ? t("featureFlags.engine.status.enabled") : t("featureFlags.engine.status.disabled")}
|
|
</button>
|
|
</div>
|
|
<div className="space-y-1">
|
|
<textarea
|
|
ref={rulesRef}
|
|
defaultValue={flag.rules ? JSON.stringify(flag.rules, null, 2) : ""}
|
|
placeholder={t("featureFlags.engine.rulesPlaceholder")}
|
|
rows={6}
|
|
className="border-border bg-background w-full rounded-md border px-2 py-1 font-mono text-xs"
|
|
/>
|
|
<button type="button" onClick={() => props.onSaveRules(rulesRef.current?.value ?? "")} className="rounded-md border px-3 py-1 text-sm">
|
|
{t("featureFlags.engine.actions.saveRules")}
|
|
</button>
|
|
</div>
|
|
{flag.parentFlagId ? <p className="text-muted-foreground text-xs">{t("featureFlags.engine.parentFlag", { id: flag.parentFlagId })}</p> : null}
|
|
</li>
|
|
);
|
|
}
|