From 43f8f7d092824a1cc2adfec285b311646d6d1ef9 Mon Sep 17 00:00:00 2001 From: itsrubberduck Date: Thu, 9 Jul 2026 16:25:29 +0200 Subject: [PATCH] feat(onboarding): add sanitization and callsign computation helpers --- server/utils/onboardingProfile.test.ts | 87 +++++++++++++ server/utils/onboardingProfile.ts | 163 +++++++++++++++++++++++++ 2 files changed, 250 insertions(+) create mode 100644 server/utils/onboardingProfile.test.ts create mode 100644 server/utils/onboardingProfile.ts diff --git a/server/utils/onboardingProfile.test.ts b/server/utils/onboardingProfile.test.ts new file mode 100644 index 0000000..03549f0 --- /dev/null +++ b/server/utils/onboardingProfile.test.ts @@ -0,0 +1,87 @@ +import { describe, it } from 'node:test' +import assert from 'node:assert/strict' +import { sanitizeOnboardingUpdate, computeResultCallsign } from '~~/server/utils/onboardingProfile' + +describe('sanitizeOnboardingUpdate', () => { + it('accepts a valid simulator value', () => { + const result = sanitizeOnboardingUpdate({ simulator: 'msfs2024' }) + assert.equal(result.simulator, 'msfs2024') + }) + + it('drops an unknown simulator value', () => { + const result = sanitizeOnboardingUpdate({ simulator: 'flightgear' }) + assert.equal('simulator' in result, false) + }) + + it('only keeps os when simulator is other', () => { + const withOther = sanitizeOnboardingUpdate({ simulator: 'other', os: 'linux' }) + assert.equal(withOther.os, 'linux') + const withMsfs = sanitizeOnboardingUpdate({ simulator: 'msfs2024', os: 'linux' }) + assert.equal('os' in withMsfs, false) + }) + + it('dedupes and filters hardware to known values', () => { + const result = sanitizeOnboardingUpdate({ hardware: ['hotas', 'hotas', 'jetpack'] }) + assert.deepEqual(result.hardware, ['hotas']) + }) + + it('clamps radioConfidence to 1-5', () => { + assert.equal(sanitizeOnboardingUpdate({ radioConfidence: 9 }).radioConfidence, 5) + assert.equal(sanitizeOnboardingUpdate({ radioConfidence: 0 }).radioConfidence, 1) + assert.equal(sanitizeOnboardingUpdate({ radioConfidence: 3.7 }).radioConfidence, 4) + }) + + it('caps topFeatures at MAX_FEATURE_WISHES', () => { + const result = sanitizeOnboardingUpdate({ + topFeatures: ['live_ai_atc', 'more_airports', 'voice_packs'], + }) + assert.equal(result.topFeatures?.length, 2) + }) + + it('only keeps toolkitDuration entries for tools present in toolkit', () => { + const result = sanitizeOnboardingUpdate({ + toolkit: ['navigraph'], + toolkitDuration: { navigraph: 'six_plus_months', sayintentions: 'trying' }, + }) + assert.deepEqual(result.toolkitDuration, { navigraph: 'six_plus_months' }) + }) + + it('sets completedAt/skippedAt flags to true when requested', () => { + assert.equal(sanitizeOnboardingUpdate({ completed: true }).completed, true) + assert.equal(sanitizeOnboardingUpdate({ skipped: true }).skipped, true) + }) + + it('ignores unknown top-level fields', () => { + const result = sanitizeOnboardingUpdate({ notAField: 'x' } as any) + assert.equal('notAField' in result, false) + }) +}) + +describe('computeResultCallsign', () => { + it('returns Subscription-Ready Power User for long-term paid-tool users choosing monthly', () => { + const callsign = computeResultCallsign({ + toolkit: ['sayintentions'], + toolkitDuration: { sayintentions: 'cant_fly_without' }, + pricingPreference: 'monthly', + } as any) + assert.equal(callsign, 'Subscription-Ready Power User') + }) + + it('returns Freeware Purist for free_self_host preference', () => { + const callsign = computeResultCallsign({ pricingPreference: 'free_self_host' } as any) + assert.equal(callsign, 'Freeware Purist') + }) + + it('returns Weekend VATSIM Learner for low-confidence network flyers', () => { + const callsign = computeResultCallsign({ + networkExperience: ['vatsim'], + radioConfidence: 2, + } as any) + assert.equal(callsign, 'Weekend VATSIM Learner') + }) + + it('falls back to a neutral default', () => { + const callsign = computeResultCallsign({} as any) + assert.equal(callsign, 'Cleared for Takeoff') + }) +}) diff --git a/server/utils/onboardingProfile.ts b/server/utils/onboardingProfile.ts new file mode 100644 index 0000000..1cb2a06 --- /dev/null +++ b/server/utils/onboardingProfile.ts @@ -0,0 +1,163 @@ +import { + SIMULATOR_OPTIONS, OS_OPTIONS, HARDWARE_OPTIONS, RADIO_PAIN_POINT_OPTIONS, + NETWORK_OPTIONS, TOOLKIT_OPTIONS, PAID_TOOLKIT_VALUES, TOOLKIT_DURATION_OPTIONS, + FEATURE_WISH_OPTIONS, PRICING_PREFERENCE_OPTIONS, MAX_FEATURE_WISHES, +} from '~~/shared/onboarding/config' +import type { + FeatureWish, HardwareItem, NetworkExperience, OperatingSystem, PricingPreference, + RadioPainPoint, Simulator, ToolkitDuration, ToolkitItem, +} from '~~/shared/onboarding/config' + +const SIMULATOR_VALUES = new Set(SIMULATOR_OPTIONS.map(o => o.value)) +const OS_VALUES = new Set(OS_OPTIONS.map(o => o.value)) +const HARDWARE_VALUES = new Set(HARDWARE_OPTIONS.map(o => o.value)) +const RADIO_PAIN_POINT_VALUES = new Set(RADIO_PAIN_POINT_OPTIONS.map(o => o.value)) +const NETWORK_VALUES = new Set(NETWORK_OPTIONS.map(o => o.value)) +const TOOLKIT_VALUES = new Set(TOOLKIT_OPTIONS.map(o => o.value)) +const TOOLKIT_DURATION_VALUES = new Set(TOOLKIT_DURATION_OPTIONS.map(o => o.value)) +const FEATURE_WISH_VALUES = new Set(FEATURE_WISH_OPTIONS.map(o => o.value)) +const PRICING_PREFERENCE_VALUES = new Set(PRICING_PREFERENCE_OPTIONS.map(o => o.value)) +const PAID_TOOLKIT_SET = new Set(PAID_TOOLKIT_VALUES) + +function dedupeFiltered(input: unknown, allowed: Set): T[] { + if (!Array.isArray(input)) return [] + return Array.from(new Set(input.filter((v): v is T => typeof v === 'string' && allowed.has(v as T)))) +} + +export interface OnboardingUpdateInput { + simulator?: unknown + os?: unknown + hardware?: unknown + radioConfidence?: unknown + radioPainPoint?: unknown + networkExperience?: unknown + toolkit?: unknown + toolkitDuration?: unknown + topFeatures?: unknown + pricingPreference?: unknown + completed?: unknown + skipped?: unknown +} + +export interface SanitizedOnboardingUpdate { + simulator?: Simulator + os?: OperatingSystem + hardware?: HardwareItem[] + radioConfidence?: number + radioPainPoint?: RadioPainPoint + networkExperience?: NetworkExperience[] + toolkit?: ToolkitItem[] + toolkitDuration?: Partial> + topFeatures?: FeatureWish[] + pricingPreference?: PricingPreference + completed?: boolean + skipped?: boolean +} + +/** WHY: os is only meaningful when simulator === 'other' — MSFS/X-Plane imply Windows/cross-platform already. */ +export function sanitizeOnboardingUpdate(body: OnboardingUpdateInput): SanitizedOnboardingUpdate { + const result: SanitizedOnboardingUpdate = {} + + if (typeof body.simulator === 'string' && SIMULATOR_VALUES.has(body.simulator as Simulator)) { + result.simulator = body.simulator as Simulator + } + + if (result.simulator === 'other' && typeof body.os === 'string' && OS_VALUES.has(body.os as OperatingSystem)) { + result.os = body.os as OperatingSystem + } + + if (body.hardware !== undefined) { + result.hardware = dedupeFiltered(body.hardware, HARDWARE_VALUES) + } + + if (typeof body.radioConfidence === 'number' && Number.isFinite(body.radioConfidence)) { + result.radioConfidence = Math.min(5, Math.max(1, Math.round(body.radioConfidence))) + } + + if (typeof body.radioPainPoint === 'string' && RADIO_PAIN_POINT_VALUES.has(body.radioPainPoint as RadioPainPoint)) { + result.radioPainPoint = body.radioPainPoint as RadioPainPoint + } + + if (body.networkExperience !== undefined) { + result.networkExperience = dedupeFiltered(body.networkExperience, NETWORK_VALUES) + } + + let toolkit: ToolkitItem[] | undefined + if (body.toolkit !== undefined) { + toolkit = dedupeFiltered(body.toolkit, TOOLKIT_VALUES) + result.toolkit = toolkit + } + + if (body.toolkitDuration !== undefined && body.toolkitDuration && typeof body.toolkitDuration === 'object') { + const allowedTools = toolkit ?? [] + const duration: Partial> = {} + for (const [tool, value] of Object.entries(body.toolkitDuration as Record)) { + if ( + PAID_TOOLKIT_SET.has(tool as ToolkitItem) && + allowedTools.includes(tool as ToolkitItem) && + typeof value === 'string' && + TOOLKIT_DURATION_VALUES.has(value as ToolkitDuration) + ) { + duration[tool as ToolkitItem] = value as ToolkitDuration + } + } + result.toolkitDuration = duration + } + + if (body.topFeatures !== undefined) { + result.topFeatures = dedupeFiltered(body.topFeatures, FEATURE_WISH_VALUES).slice(0, MAX_FEATURE_WISHES) + } + + if (typeof body.pricingPreference === 'string' && PRICING_PREFERENCE_VALUES.has(body.pricingPreference as PricingPreference)) { + result.pricingPreference = body.pricingPreference as PricingPreference + } + + if (body.completed === true) result.completed = true + if (body.skipped === true) result.skipped = true + + return result +} + +interface CallsignInput { + networkExperience?: NetworkExperience[] + radioConfidence?: number + toolkit?: ToolkitItem[] + toolkitDuration?: Partial> + hardware?: HardwareItem[] + pricingPreference?: PricingPreference +} + +const LONG_TERM_DURATIONS = new Set(['six_plus_months', 'cant_fly_without']) + +/** + * Ordered rule list — first match wins. WHY ordered: a user can match multiple + * archetypes (e.g. long-term Navigraph subscriber who also flies VATSIM); the + * order reflects which signal is the strongest predictor for pricing conversations. + */ +export function computeResultCallsign(profile: CallsignInput): string { + const hasLongTermPaidTool = (profile.toolkit ?? []).some( + tool => LONG_TERM_DURATIONS.has(profile.toolkitDuration?.[tool] as ToolkitDuration), + ) + + if (hasLongTermPaidTool && profile.pricingPreference === 'monthly') { + return 'Subscription-Ready Power User' + } + + if (profile.pricingPreference === 'free_self_host') { + return 'Freeware Purist' + } + + if ((profile.hardware ?? []).some(h => h === 'yoke_pedals' || h === 'multi_monitor')) { + return 'Cockpit Builder' + } + + if ((profile.networkExperience ?? []).length > 0 && (profile.radioConfidence ?? 5) <= 3) { + return 'Weekend VATSIM Learner' + } + + if ((profile.networkExperience ?? []).length > 0) { + return 'Network-Ready Radio Pro' + } + + return 'Cleared for Takeoff' +}