516 行
13 KiB
JavaScript
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,
|
|
};
|