Files
Cibello-app/packages/feature-flags/src/index.ts
T
Sven (AAMOS AI) 2f3cdf8016 FAS 1b: Progressive onboarding (3-layer flow)
- 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
2026-08-06 00:40:59 +07:00

95 lines
3.1 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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 → 099: 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;
}