2f3cdf8016
- Add PROGRESSIVE_ONBOARDING feature flag to KNOWN_FLAGS - Add onboarding_step column to users table (a/b/c) - New backend endpoints: - GET /v1/onboarding/status – check current step + feature flag - POST /v1/onboarding/quick-start – complete Step A (goal + precision) - POST /v1/onboarding/complete-b – complete Step B (diet, allergens, household) - POST /v1/onboarding/complete-c – complete Step C (health profile) - POST /v1/onboarding/skip – GDPR-friendly skip - Refactor mobile onboarding screen into 3 progressive layers - Update auth store with onboardingStep state - Update tab layout to only block on Step A - Update registration to set onboardingStep='a' - Add i18n keys for Step B/C titles across all 12 locales - Add Zod validation schemas (quickStartInputSchema, onboardingStatusSchema) - Add tests for validation and feature flag - Preserve existing /v1/me/onboarding for backward compatibility - Migration: 0004_progressive_onboarding.sql
95 lines
3.1 KiB
TypeScript
95 lines
3.1 KiB
TypeScript
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<FlagRow[]>;
|
||
/** Cache-TTL i ms (default 30 s). */
|
||
ttlMs?: number;
|
||
/** Miljövariabler för override: APP_FLAG_<KEY>=true/false. */
|
||
env?: Record<string, string | undefined>;
|
||
}
|
||
|
||
/**
|
||
* 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<string, FlagRow>();
|
||
private cachedAt = 0;
|
||
private readonly ttlMs: number;
|
||
private readonly env: Record<string, string | undefined>;
|
||
private readonly loadAll: () => Promise<FlagRow[]>;
|
||
|
||
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<boolean> {
|
||
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<FlagRow[]> {
|
||
await this.refreshIfStale();
|
||
return [...this.cache.values()];
|
||
}
|
||
|
||
invalidate(): void {
|
||
this.cachedAt = 0;
|
||
}
|
||
|
||
private async refreshIfStale(): Promise<void> {
|
||
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;
|
||
}
|