import { createHash } from "node:crypto"; export interface FlagRow { key: string; enabled: boolean; rolloutPercent: number; } /** Kända flaggor – centralt register så att döda flaggor syns i kodgranskning. */ export const KNOWN_FLAGS = { COMMUNITY_PUBLISHING: "community_publishing", WEEK_PLAN_AI: "week_plan_ai", PLATE_PHOTO_ANALYSIS: "plate_photo_analysis", RECEIPT_SCANNING: "receipt_scanning", PANTRY_FORECAST: "pantry_forecast", HEALTH_INTEGRATION: "health_integration", WEATHER_CONTEXT: "weather_context", CREATOR_RANKINGS: "creator_rankings", FOOD_MEMORIES: "food_memories", VOICE_INPUT: "voice_input", AI_RERANK: "ai_rerank", PROGRESSIVE_ONBOARDING: "progressive_onboarding", } as const; export type KnownFlagKey = (typeof KNOWN_FLAGS)[keyof typeof KNOWN_FLAGS]; export interface FeatureFlagServiceOptions { /** Laddar alla flaggor från databasen (injiceras för att undvika DB-beroende här). */ loadAll: () => Promise; /** Cache-TTL i ms (default 30 s). */ ttlMs?: number; /** Miljövariabler för override: APP_FLAG_=true/false. */ env?: Record; } /** * Flaggtjänst med tre lager: * 1. env-override (drift/incident: stäng av utan deploy), * 2. databasvärde med rollout-procent (gradvis utrullning per användare), * 3. default false (fail closed för ny funktionalitet). */ export class FeatureFlagService { private cache = new Map(); private cachedAt = 0; private readonly ttlMs: number; private readonly env: Record; private readonly loadAll: () => Promise; constructor(options: FeatureFlagServiceOptions) { this.loadAll = options.loadAll; this.ttlMs = options.ttlMs ?? 30_000; this.env = options.env ?? process.env; } async isEnabled(key: string, userId?: string): Promise { const envOverride = this.env[`APP_FLAG_${key.toUpperCase()}`]; if (envOverride === "true") return true; if (envOverride === "false") return false; await this.refreshIfStale(); const row = this.cache.get(key); if (!row || !row.enabled) return false; if (row.rolloutPercent >= 100) return true; if (row.rolloutPercent <= 0) return false; if (!userId) return false; return bucketFor(key, userId) < row.rolloutPercent; } async all(): Promise { await this.refreshIfStale(); return [...this.cache.values()]; } invalidate(): void { this.cachedAt = 0; } private async refreshIfStale(): Promise { if (Date.now() - this.cachedAt < this.ttlMs) return; try { const rows = await this.loadAll(); this.cache = new Map(rows.map((r) => [r.key, r])); this.cachedAt = Date.now(); } catch { // Behåll gammal cache vid DB-fel – flaggor får aldrig fälla en request. this.cachedAt = Date.now() - this.ttlMs + 5_000; } } } /** Stabil hash → 0–99: samma användare hamnar alltid i samma bucket per flagga. */ export function bucketFor(flagKey: string, userId: string): number { const hash = createHash("sha256").update(`${flagKey}:${userId}`).digest(); return ((hash[0]! << 8) | hash[1]!) % 100; }