/** * Validation schemas - pure functions for consistent input validation. */ import { sanitizeString, sanitizeIdentifier, sanitizeLabel } from './sanitize.js' import { DEVICE_TYPES, SOURCE_TYPES } from './deviceUtils.js' import { POI_ICON_TYPES } from './poiConstants.js' const ROLES = ['admin', 'leader', 'member'] const validateNumber = (value, field) => { const num = Number(value) return Number.isFinite(num) ? { valid: true, value: num } : { valid: false, error: `${field} must be a finite number` } } const validateEnum = (value, allowed, field) => allowed.includes(value) ? { valid: true, value } : { valid: false, error: `Invalid ${field}` } const handleField = (d, field, handler, updates, errors, outputField = null) => { if (d[field] !== undefined) { const result = handler(d[field]) if (result.valid) updates[outputField || field] = result.value else errors.push(result.error) } } export function validateDevice(data) { if (!data || typeof data !== 'object') return { valid: false, errors: ['body required'] } const d = /** @type {Record} */ (data) const errors = [] const latCheck = validateNumber(d.lat, 'lat') const lngCheck = validateNumber(d.lng, 'lng') if (!latCheck.valid || !lngCheck.valid) errors.push('lat and lng required as finite numbers') if (errors.length > 0) return { valid: false, errors } return { valid: true, errors: [], data: { name: sanitizeString(d.name, 1000), device_type: validateEnum(d.device_type, DEVICE_TYPES, 'device_type').value || 'feed', vendor: d.vendor !== undefined ? sanitizeString(d.vendor, 255) : null, lat: latCheck.value, lng: lngCheck.value, stream_url: typeof d.stream_url === 'string' ? sanitizeString(d.stream_url, 2000) : '', source_type: validateEnum(d.source_type, SOURCE_TYPES, 'source_type').value || 'mjpeg', config: d.config == null ? null : (typeof d.config === 'string' ? d.config : JSON.stringify(d.config)), }, } } export function validateUpdateDevice(data) { if (!data || typeof data !== 'object') return { valid: true, errors: [], data: {} } const d = /** @type {Record} */ (data) const errors = [] const updates = {} if (d.name !== undefined) updates.name = sanitizeString(d.name, 1000) handleField(d, 'device_type', v => validateEnum(v, DEVICE_TYPES, 'device_type'), updates, errors) if (d.vendor !== undefined) updates.vendor = d.vendor === null || d.vendor === '' ? null : sanitizeString(d.vendor, 255) handleField(d, 'lat', v => validateNumber(v, 'lat'), updates, errors) handleField(d, 'lng', v => validateNumber(v, 'lng'), updates, errors) if (d.stream_url !== undefined) updates.stream_url = sanitizeString(d.stream_url, 2000) handleField(d, 'source_type', v => validateEnum(v, SOURCE_TYPES, 'source_type'), updates, errors) if (d.config !== undefined) updates.config = d.config === null ? null : (typeof d.config === 'string' ? d.config : JSON.stringify(d.config)) return errors.length > 0 ? { valid: false, errors } : { valid: true, errors: [], data: updates } } export function validateUser(data) { if (!data || typeof data !== 'object') return { valid: false, errors: ['body required'] } const d = /** @type {Record} */ (data) const errors = [] const identifier = sanitizeIdentifier(d.identifier) const password = typeof d.password === 'string' ? d.password : '' const role = typeof d.role === 'string' ? d.role : '' if (!identifier) errors.push('identifier required') if (!password) errors.push('password required') if (!role || !ROLES.includes(role)) errors.push('role must be admin, leader, or member') return errors.length > 0 ? { valid: false, errors } : { valid: true, errors: [], data: { identifier, password, role: role || 'member' } } } export function validateUpdateUser(data) { if (!data || typeof data !== 'object') return { valid: true, errors: [], data: {} } const d = /** @type {Record} */ (data) const errors = [] const updates = {} if (d.role !== undefined) { if (ROLES.includes(d.role)) updates.role = d.role else errors.push('role must be admin, leader, or member') } if (d.identifier !== undefined) { const identifier = sanitizeIdentifier(d.identifier) if (!identifier) errors.push('identifier cannot be empty') else updates.identifier = identifier } if (d.password !== undefined && d.password !== '') { if (typeof d.password !== 'string' || !d.password) errors.push('password cannot be empty') else updates.password = d.password } return errors.length > 0 ? { valid: false, errors } : { valid: true, errors: [], data: updates } } export function validatePoi(data) { if (!data || typeof data !== 'object') return { valid: false, errors: ['body required'] } const d = /** @type {Record} */ (data) const latCheck = validateNumber(d.lat, 'lat') const lngCheck = validateNumber(d.lng, 'lng') if (!latCheck.valid || !lngCheck.valid) return { valid: false, errors: ['lat and lng required as finite numbers'] } return { valid: true, errors: [], data: { lat: latCheck.value, lng: lngCheck.value, label: sanitizeLabel(d.label, 500), icon_type: validateEnum(d.iconType, POI_ICON_TYPES, 'iconType').value || 'pin', }, } } export function validateUpdatePoi(data) { if (!data || typeof data !== 'object') return { valid: true, errors: [], data: {} } const d = /** @type {Record} */ (data) const errors = [] const updates = {} if (d.label !== undefined) updates.label = sanitizeLabel(d.label, 500) handleField(d, 'iconType', v => validateEnum(v, POI_ICON_TYPES, 'iconType'), updates, errors, 'icon_type') handleField(d, 'lat', v => validateNumber(v, 'lat'), updates, errors) handleField(d, 'lng', v => validateNumber(v, 'lng'), updates, errors) return errors.length > 0 ? { valid: false, errors } : { valid: true, errors: [], data: updates } }