文件
hyb-oa/server/src/services/featureFlags/index.js
T

516 行
13 KiB
JavaScript

const FEATURE_FLAG_KEYS = {
aiAssist: "ai_assist",
erpSync: "erp_sync",
invoiceVerification: "invoice_verification",
leaveHolidayCalendar: "leave_holiday_calendar",
budgetPrecheck: "budget_precheck_v1",
reimbursementInvoiceGuard: "reimbursement_invoice_guard_v1",
paymentBatch: "payment_batch_v1",
portalHome: "portal_home_v1",
globalSearch: "global_search_v1",
announcements: "announcements_v1",
workflowDefinitionAdmin: "workflow_definition_admin_v1",
cutoverConsole: "cutover_console_v1",
presenceBoard: "presence_board_v1",
attendance: "attendance_v1",
attendanceUnified: "attendance_unified_v1",
weeklyReport: "weekly_report_v1",
employeeLifecycle: "employee_lifecycle_v1",
hrWecomDirectorySync: "hr_wecom_directory_sync_v1",
wecomHrMasterSync: "wecom_hr_master_sync_v1",
todoBoard: "todo_board_v1",
wecomEntry: "wecom_entry_v1",
projectDomain: "project_domain_v1",
projectLinking: "project_linking_v1",
projectCommunicationLog: "project_communication_log_v1",
projectClosureChecklist: "project_closure_checklist_v1",
confidentialDocRefs: "confidential_doc_refs_v1",
publicSecurityHardening: "public_security_hardening_v1",
};
const FEATURE_FLAG_KEY_LIST = Object.freeze(Object.values(FEATURE_FLAG_KEYS));
const OVERRIDE_MODE_LIST = Object.freeze(["inherit", "force_on", "force_off"]);
const ROLLOUT_TYPE_LIST = Object.freeze(["global", "department_allowlist"]);
function isKnownFeatureFlagKey(flagKey) {
return FEATURE_FLAG_KEY_LIST.includes(flagKey);
}
function normalizeOverrideMode(value) {
const mode = String(value ?? "force_off").trim().toLowerCase();
if (OVERRIDE_MODE_LIST.includes(mode)) {
return mode;
}
return "force_off";
}
function normalizeRolloutType(value) {
const type = String(value ?? "global").trim().toLowerCase();
if (ROLLOUT_TYPE_LIST.includes(type)) {
return type;
}
return "global";
}
function sanitizeDepartmentIds(value) {
if (!Array.isArray(value)) {
return [];
}
const seen = new Set();
const items = [];
for (const id of value) {
if (typeof id !== "string") {
continue;
}
const trimmed = id.trim();
if (!trimmed) {
continue;
}
if (!seen.has(trimmed)) {
seen.add(trimmed);
items.push(trimmed);
}
}
return items;
}
function resolveEnvGate(config, flagKey) {
if (flagKey === FEATURE_FLAG_KEYS.aiAssist) {
return Boolean(config.aiAssistEnabled);
}
if (flagKey === FEATURE_FLAG_KEYS.erpSync) {
return Boolean(config.erpSyncEnabled);
}
if (flagKey === FEATURE_FLAG_KEYS.invoiceVerification) {
return Boolean(config.invoiceVerificationEnabled);
}
if (flagKey === FEATURE_FLAG_KEYS.leaveHolidayCalendar) {
return Boolean(config.leaveHolidayCalendarEnabled);
}
if (flagKey === FEATURE_FLAG_KEYS.budgetPrecheck) {
return Boolean(config.budgetPrecheckEnabled);
}
if (flagKey === FEATURE_FLAG_KEYS.reimbursementInvoiceGuard) {
return Boolean(config.reimbursementInvoiceGuardEnabled);
}
if (flagKey === FEATURE_FLAG_KEYS.paymentBatch) {
return Boolean(config.paymentBatchEnabled);
}
if (flagKey === FEATURE_FLAG_KEYS.portalHome) {
return Boolean(config.portalHomeEnabled);
}
if (flagKey === FEATURE_FLAG_KEYS.globalSearch) {
return Boolean(config.globalSearchEnabled);
}
if (flagKey === FEATURE_FLAG_KEYS.announcements) {
return Boolean(config.announcementsEnabled);
}
if (flagKey === FEATURE_FLAG_KEYS.workflowDefinitionAdmin) {
return Boolean(config.workflowDefinitionAdminEnabled);
}
if (flagKey === FEATURE_FLAG_KEYS.cutoverConsole) {
return Boolean(config.cutoverConsoleEnabled);
}
if (flagKey === FEATURE_FLAG_KEYS.presenceBoard) {
return Boolean(config.presenceBoardEnabled);
}
if (flagKey === FEATURE_FLAG_KEYS.attendance) {
return false;
}
if (flagKey === FEATURE_FLAG_KEYS.attendanceUnified) {
return Boolean(config.attendanceUnifiedEnabled);
}
if (flagKey === FEATURE_FLAG_KEYS.weeklyReport) {
return Boolean(config.weeklyReportEnabled);
}
if (flagKey === FEATURE_FLAG_KEYS.employeeLifecycle) {
return Boolean(config.employeeLifecycleEnabled);
}
if (flagKey === FEATURE_FLAG_KEYS.hrWecomDirectorySync) {
return Boolean(config.wecomContactSyncEnabled);
}
if (flagKey === FEATURE_FLAG_KEYS.wecomHrMasterSync) {
return Boolean(config.wecomHrMasterSyncEnabled);
}
if (flagKey === FEATURE_FLAG_KEYS.todoBoard) {
return Boolean(config.todoBoardEnabled);
}
if (flagKey === FEATURE_FLAG_KEYS.wecomEntry) {
return Boolean(config.wecomEntryEnabled);
}
if (flagKey === FEATURE_FLAG_KEYS.projectDomain) {
return Boolean(config.projectDomainEnabled);
}
if (flagKey === FEATURE_FLAG_KEYS.projectLinking) {
return Boolean(config.projectLinkingEnabled);
}
if (flagKey === FEATURE_FLAG_KEYS.projectCommunicationLog) {
return Boolean(config.projectCommunicationLogEnabled);
}
if (flagKey === FEATURE_FLAG_KEYS.projectClosureChecklist) {
return Boolean(config.projectClosureChecklistEnabled);
}
if (flagKey === FEATURE_FLAG_KEYS.confidentialDocRefs) {
return Boolean(config.confidentialDocRefsEnabled);
}
if (flagKey === FEATURE_FLAG_KEYS.publicSecurityHardening) {
return Boolean(config.publicSecurityHardeningEnabled);
}
return false;
}
function mapFlagRecord(record) {
if (!record) {
return null;
}
const departmentIds = sanitizeDepartmentIds(record.department_ids);
const overrideMode = normalizeOverrideMode(record.override_mode);
const rolloutType = normalizeRolloutType(record.rollout_type);
return {
id: record.id,
flagKey: record.flag_key,
overrideMode,
rolloutType,
departmentIds,
note: record.note ?? "",
version: Number(record.version ?? 1),
createdAt: record.created_at?.toISOString?.() ?? null,
createdBy: record.created_by ?? null,
updatedAt: record.updated_at?.toISOString?.() ?? null,
updatedBy: record.updated_by ?? null,
};
}
function buildDefaultRecord(flagKey) {
return {
id: null,
flagKey,
overrideMode: "force_off",
rolloutType: "global",
departmentIds: [],
note: "",
version: 1,
createdAt: null,
createdBy: null,
updatedAt: null,
updatedBy: null,
};
}
function withEvaluation(base, evaluation) {
return {
...base,
envEnabled: evaluation.envEnabled,
effectiveEnabled: evaluation.enabled,
effectiveReason: evaluation.reason,
matchedDepartmentId: evaluation.matchedDepartmentId,
requiresDepartment: evaluation.requiresDepartment,
};
}
async function getFlagRecord(prisma, flagKey) {
const row = await prisma.featureFlag.findUnique({
where: { flag_key: flagKey },
});
return mapFlagRecord(row);
}
async function evaluateFeatureFlag({
prisma,
config,
flagKey,
departmentId = null,
strictDepartment = true,
}) {
const envEnabled = resolveEnvGate(config, flagKey);
const normalizedDepartmentId =
typeof departmentId === "string" && departmentId.trim() ? departmentId.trim() : null;
if (!envEnabled) {
return {
flagKey,
enabled: false,
reason: "env_hard_off",
envEnabled,
matchedDepartmentId: null,
requiresDepartment: false,
};
}
const record = await getFlagRecord(prisma, flagKey);
if (!record) {
return {
flagKey,
enabled: false,
reason: "missing_flag_record",
envEnabled,
matchedDepartmentId: null,
requiresDepartment: false,
};
}
if (!OVERRIDE_MODE_LIST.includes(record.overrideMode)) {
return {
flagKey,
enabled: false,
reason: "invalid_override_mode",
envEnabled,
matchedDepartmentId: null,
requiresDepartment: false,
};
}
if (!ROLLOUT_TYPE_LIST.includes(record.rolloutType)) {
return {
flagKey,
enabled: false,
reason: "invalid_rollout_type",
envEnabled,
matchedDepartmentId: null,
requiresDepartment: false,
};
}
if (record.overrideMode === "force_off") {
return {
flagKey,
enabled: false,
reason: "flag_force_off",
envEnabled,
matchedDepartmentId: null,
requiresDepartment: false,
};
}
const modeEnabled = record.overrideMode === "force_on" || record.overrideMode === "inherit";
if (!modeEnabled) {
return {
flagKey,
enabled: false,
reason: "mode_not_enabled",
envEnabled,
matchedDepartmentId: null,
requiresDepartment: false,
};
}
if (record.rolloutType === "global") {
return {
flagKey,
enabled: true,
reason: record.overrideMode === "inherit" ? "env_inherit_global_on" : "flag_force_on_global",
envEnabled,
matchedDepartmentId: null,
requiresDepartment: false,
};
}
const allowlist = record.departmentIds;
if (!allowlist.length) {
return {
flagKey,
enabled: false,
reason: "department_allowlist_empty",
envEnabled,
matchedDepartmentId: null,
requiresDepartment: true,
};
}
if (!normalizedDepartmentId) {
if (strictDepartment) {
return {
flagKey,
enabled: false,
reason: "department_missing",
envEnabled,
matchedDepartmentId: null,
requiresDepartment: true,
};
}
return {
flagKey,
enabled: true,
reason: "department_deferred",
envEnabled,
matchedDepartmentId: null,
requiresDepartment: true,
};
}
const matched = allowlist.includes(normalizedDepartmentId);
if (!matched) {
return {
flagKey,
enabled: false,
reason: "department_not_allowed",
envEnabled,
matchedDepartmentId: normalizedDepartmentId,
requiresDepartment: true,
};
}
return {
flagKey,
enabled: true,
reason:
record.overrideMode === "inherit"
? "env_inherit_department_allowed"
: "flag_force_on_department_allowed",
envEnabled,
matchedDepartmentId: normalizedDepartmentId,
requiresDepartment: true,
};
}
async function listFeatureFlags(prisma, config) {
const rows = await prisma.featureFlag.findMany({
where: {
flag_key: { in: FEATURE_FLAG_KEY_LIST },
},
orderBy: { flag_key: "asc" },
});
const recordMap = new Map(rows.map((row) => [row.flag_key, mapFlagRecord(row)]));
const items = [];
for (const flagKey of FEATURE_FLAG_KEY_LIST) {
const base = recordMap.get(flagKey) ?? buildDefaultRecord(flagKey);
const evaluation = await evaluateFeatureFlag({
prisma,
config,
flagKey,
departmentId: null,
strictDepartment: false,
});
items.push(withEvaluation(base, evaluation));
}
return items;
}
async function updateFeatureFlag(prisma, input) {
const flagKey = String(input.flagKey ?? "").trim();
if (!isKnownFeatureFlagKey(flagKey)) {
return {
status: "not_found",
current: null,
updated: null,
};
}
const overrideMode = normalizeOverrideMode(input.overrideMode);
const rolloutType = normalizeRolloutType(input.rolloutType);
const departmentIds =
rolloutType === "department_allowlist" ? sanitizeDepartmentIds(input.departmentIds) : [];
return prisma.$transaction(async (tx) => {
const currentRow = await tx.featureFlag.findUnique({
where: { flag_key: flagKey },
});
if (!currentRow) {
return {
status: "not_found",
current: null,
updated: null,
};
}
const current = mapFlagRecord(currentRow);
if (Number(input.expectedVersion) !== current.version) {
return {
status: "version_conflict",
current,
updated: null,
};
}
const updateResult = await tx.featureFlag.updateMany({
where: {
id: current.id,
version: current.version,
},
data: {
override_mode: overrideMode,
rollout_type: rolloutType,
department_ids: departmentIds,
note: input.note ?? "",
version: {
increment: 1,
},
updated_by: input.actorId ?? null,
},
});
if (updateResult.count !== 1) {
const latest = await tx.featureFlag.findUnique({
where: { flag_key: flagKey },
});
return {
status: "version_conflict",
current: mapFlagRecord(latest),
updated: null,
};
}
const updatedRow = await tx.featureFlag.findUnique({
where: { flag_key: flagKey },
});
return {
status: "updated",
current,
updated: mapFlagRecord(updatedRow),
};
});
}
module.exports = {
FEATURE_FLAG_KEYS,
FEATURE_FLAG_KEY_LIST,
OVERRIDE_MODE_LIST,
ROLLOUT_TYPE_LIST,
isKnownFeatureFlagKey,
sanitizeDepartmentIds,
evaluateFeatureFlag,
listFeatureFlags,
updateFeatureFlag,
};