WellNuo/app/(auth)/activate.tsx
Sergei 54336986ad Improve serial number validation with comprehensive testing
Added robust serial validation with support for multiple formats:
- Production format: WELLNUO-XXXX-XXXX (strict validation)
- Demo serials: DEMO-00000 and DEMO-1234-5678
- Legacy format: 8+ alphanumeric characters with hyphens

Frontend improvements (activate.tsx):
- Real-time validation feedback with error messages
- Visual error indicators (red border, error icon)
- Proper normalization (uppercase, trimmed)
- Better user experience with clear error messages

Backend improvements (beneficiaries.js):
- Enhanced serial validation on activation endpoint
- Stores normalized serial in device_id field
- Better logging for debugging
- Consistent error responses with validation details

Testing:
- 52 frontend tests covering all validation scenarios
- 40 backend tests ensuring consistency
- Edge case handling (long serials, special chars, etc.)

Code quality:
- ESLint configuration for test files
- All tests passing
- Zero linting errors
2026-01-29 11:33:54 -08:00

365 lines
11 KiB
TypeScript

import React, { useState } from 'react';
import {
View,
Text,
StyleSheet,
TouchableOpacity,
TextInput,
ScrollView,
ActivityIndicator,
Alert,
} from 'react-native';
import { SafeAreaView } from 'react-native-safe-area-context';
import { router, useLocalSearchParams } from 'expo-router';
import { Ionicons } from '@expo/vector-icons';
import { AppColors, Spacing, BorderRadius, FontSizes, FontWeights } from '@/constants/theme';
import { api } from '@/services/api';
import { validateSerial, getSerialErrorMessage } from '@/utils/serialValidation';
export default function ActivateScreen() {
// Get params - beneficiaryId is REQUIRED (created in add-loved-one.tsx)
// lovedOneName is for display purposes
const params = useLocalSearchParams<{ lovedOneName?: string; beneficiaryId?: string }>();
const beneficiaryId = params.beneficiaryId ? parseInt(params.beneficiaryId, 10) : null;
const lovedOneName = params.lovedOneName || '';
const [activationCode, setActivationCode] = useState('');
const [isActivating, setIsActivating] = useState(false);
const [step, setStep] = useState<'code' | 'complete'>('code');
const [validationError, setValidationError] = useState<string>('');
// Real-time validation as user types
const handleCodeChange = (text: string) => {
setActivationCode(text);
// Clear error when user starts typing
if (validationError) {
setValidationError('');
}
};
const handleActivate = async () => {
// Validate serial number
const validation = validateSerial(activationCode);
if (!validation.isValid) {
const errorMessage = getSerialErrorMessage(activationCode);
setValidationError(errorMessage);
Alert.alert('Invalid Serial Number', errorMessage);
return;
}
// Use normalized serial (uppercase, trimmed)
const normalizedSerial = validation.normalized;
// Beneficiary ID is required - was created in add-loved-one.tsx
if (!beneficiaryId) {
Alert.alert('Error', 'Beneficiary not found. Please go back and try again.');
return;
}
setIsActivating(true);
try {
// Call API to activate - sets has_existing_devices = true on backend
const response = await api.activateBeneficiary(beneficiaryId, normalizedSerial);
if (!response.ok) {
const errorMsg = response.error?.message || 'Failed to activate equipment';
setValidationError(errorMsg);
Alert.alert('Activation Failed', errorMsg);
return;
}
// Mark onboarding as completed
await api.setOnboardingCompleted(true);
setStep('complete');
} catch {
const errorMsg = 'Failed to activate kit. Please try again.';
setValidationError(errorMsg);
Alert.alert('Error', errorMsg);
} finally {
setIsActivating(false);
}
};
const handleComplete = () => {
// Navigate to beneficiary detail page after activation
if (beneficiaryId) {
router.replace(`/(tabs)/beneficiaries/${beneficiaryId}` as const);
} else {
router.replace('/(tabs)');
}
};
// Step 1: Enter activation code
if (step === 'code') {
return (
<SafeAreaView style={styles.container} edges={['top', 'bottom']}>
<ScrollView contentContainerStyle={styles.content} keyboardShouldPersistTaps="handled">
{/* Header */}
<View style={styles.header}>
<TouchableOpacity style={styles.backButton} onPress={() => router.back()}>
<Ionicons name="arrow-back" size={24} color={AppColors.textPrimary} />
</TouchableOpacity>
<Text style={styles.title}>Connect Sensors</Text>
<View style={styles.placeholder} />
</View>
{/* Icon */}
<View style={styles.iconContainer}>
<Ionicons name="qr-code" size={64} color={AppColors.primary} />
</View>
{/* Instructions */}
<Text style={styles.instructions}>
{lovedOneName
? `Connect sensors for ${lovedOneName}`
: 'Enter the serial number from your sensors'}
</Text>
{/* Input */}
<View style={styles.inputContainer}>
<TextInput
style={[styles.input, validationError && styles.inputError]}
value={activationCode}
onChangeText={handleCodeChange}
placeholder="WELLNUO-XXXX-XXXX"
placeholderTextColor={AppColors.textMuted}
autoCapitalize="characters"
autoCorrect={false}
maxLength={20}
/>
{validationError && (
<View style={styles.errorContainer}>
<Ionicons name="alert-circle" size={16} color={AppColors.error} />
<Text style={styles.errorText}>{validationError}</Text>
</View>
)}
</View>
{/* Demo Code Link */}
<TouchableOpacity
style={styles.demoCodeLink}
onPress={() => setActivationCode('DEMO-1234-5678')}
>
<Ionicons name="flask-outline" size={14} color={AppColors.textMuted} />
<Text style={styles.demoCodeLinkText}>Use demo code</Text>
</TouchableOpacity>
{/* Activate Button */}
<TouchableOpacity
style={[styles.primaryButton, isActivating && styles.buttonDisabled]}
onPress={handleActivate}
disabled={isActivating}
>
{isActivating ? (
<ActivityIndicator color={AppColors.white} />
) : (
<Text style={styles.primaryButtonText}>Activate</Text>
)}
</TouchableOpacity>
</ScrollView>
</SafeAreaView>
);
}
// Step 2: Complete
return (
<SafeAreaView style={styles.container} edges={['top', 'bottom']}>
<View style={styles.content}>
{/* Success Icon */}
<View style={styles.successContainer}>
<View style={styles.successIcon}>
<Ionicons name="checkmark-circle" size={80} color={AppColors.success} />
</View>
<Text style={styles.successTitle}>Sensors Connected!</Text>
<Text style={styles.successMessage}>
Your sensors have been successfully connected for{' '}
<Text style={styles.beneficiaryHighlight}>{lovedOneName || 'your loved one'}</Text>
</Text>
{/* Next Steps */}
<View style={styles.nextSteps}>
<Text style={styles.nextStepsTitle}>Next Steps:</Text>
<View style={styles.stepItem}>
<Text style={styles.stepNumber}>1</Text>
<Text style={styles.stepText}>Place sensors in your loved one&apos;s home</Text>
</View>
<View style={styles.stepItem}>
<Text style={styles.stepNumber}>2</Text>
<Text style={styles.stepText}>Connect the hub to WiFi</Text>
</View>
<View style={styles.stepItem}>
<Text style={styles.stepNumber}>3</Text>
<Text style={styles.stepText}>Subscribe to start monitoring ($49/month)</Text>
</View>
</View>
</View>
{/* Complete Button */}
<TouchableOpacity style={styles.primaryButton} onPress={handleComplete}>
<Text style={styles.primaryButtonText}>Go to Dashboard</Text>
</TouchableOpacity>
</View>
</SafeAreaView>
);
}
const styles = StyleSheet.create({
container: {
flex: 1,
backgroundColor: AppColors.background,
},
content: {
flex: 1,
padding: Spacing.lg,
},
header: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
marginBottom: Spacing.xl,
},
backButton: {
padding: Spacing.sm,
marginLeft: -Spacing.sm,
},
title: {
fontSize: FontSizes.xl,
fontWeight: FontWeights.bold,
color: AppColors.textPrimary,
},
placeholder: {
width: 40,
},
iconContainer: {
alignItems: 'center',
marginBottom: Spacing.xl,
},
instructions: {
fontSize: FontSizes.base,
color: AppColors.textSecondary,
textAlign: 'center',
marginBottom: Spacing.xl,
lineHeight: 24,
},
inputContainer: {
marginBottom: Spacing.lg,
},
input: {
backgroundColor: AppColors.surface,
borderRadius: BorderRadius.lg,
paddingHorizontal: Spacing.lg,
paddingVertical: Spacing.md,
fontSize: FontSizes.lg,
color: AppColors.textPrimary,
textAlign: 'center',
borderWidth: 1,
borderColor: AppColors.border,
},
inputError: {
borderColor: AppColors.error,
borderWidth: 2,
},
errorContainer: {
flexDirection: 'row',
alignItems: 'center',
gap: 6,
marginTop: Spacing.sm,
paddingHorizontal: Spacing.sm,
},
errorText: {
fontSize: FontSizes.sm,
color: AppColors.error,
flex: 1,
},
demoCodeLink: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'center',
gap: 4,
marginBottom: Spacing.md,
},
demoCodeLinkText: {
fontSize: FontSizes.sm,
color: AppColors.textMuted,
},
primaryButton: {
backgroundColor: AppColors.primary,
paddingVertical: Spacing.lg,
borderRadius: BorderRadius.lg,
alignItems: 'center',
marginTop: Spacing.md,
},
buttonDisabled: {
opacity: 0.7,
},
primaryButtonText: {
fontSize: FontSizes.lg,
fontWeight: FontWeights.semibold,
color: AppColors.white,
},
successContainer: {
flex: 1,
alignItems: 'center',
justifyContent: 'center',
},
successIcon: {
marginBottom: Spacing.xl,
},
successTitle: {
fontSize: FontSizes['2xl'],
fontWeight: FontWeights.bold,
color: AppColors.textPrimary,
marginBottom: Spacing.md,
},
successMessage: {
fontSize: FontSizes.base,
color: AppColors.textSecondary,
textAlign: 'center',
marginBottom: Spacing.md,
},
beneficiaryHighlight: {
fontWeight: FontWeights.bold,
color: AppColors.primary,
},
nextSteps: {
width: '100%',
backgroundColor: AppColors.surface,
borderRadius: BorderRadius.lg,
padding: Spacing.lg,
},
nextStepsTitle: {
fontSize: FontSizes.base,
fontWeight: FontWeights.semibold,
color: AppColors.textPrimary,
marginBottom: Spacing.md,
},
stepItem: {
flexDirection: 'row',
alignItems: 'center',
gap: Spacing.md,
marginBottom: Spacing.sm,
},
stepNumber: {
width: 24,
height: 24,
borderRadius: 12,
backgroundColor: AppColors.primary,
color: AppColors.white,
textAlign: 'center',
lineHeight: 24,
fontSize: FontSizes.sm,
fontWeight: FontWeights.bold,
overflow: 'hidden',
},
stepText: {
fontSize: FontSizes.sm,
color: AppColors.textSecondary,
flex: 1,
},
});