Files
fitness/backend/WorkoutValidator.php
T

283 lines
9.8 KiB
PHP

<?php
/**
* Workout Validator
* Validates incoming workout payload from mobile client
*/
class WorkoutValidator {
private $errors = [];
private const VALID_WORKOUT_TYPES = ['running', 'walking'];
private const MIN_DISTANCE = 0; // meters; short/aborted sessions remain syncable
private const MAX_DISTANCE = 100000; // 100km
private const MIN_DURATION = 0; // seconds; timestamp order is validated separately
private const MAX_DURATION = 36000; // 10 hours
private const MIN_COORDINATES = 2;
private const MAX_COORDINATES = 50000;
/**
* Validate complete workout payload
*
* @param array $payload The workout data to validate
* @return array ['valid' => bool, 'errors' => array]
*/
public function validate($payload) {
$this->errors = [];
// Validate required fields
$this->validateRequiredFields($payload);
if (!empty($this->errors)) {
return ['valid' => false, 'errors' => $this->errors];
}
// Validate data types and values
$this->validateWorkoutType($payload['workout_type']);
if (isset($payload['client_workout_id']) &&
(!is_string($payload['client_workout_id']) ||
preg_match('/^[0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i', $payload['client_workout_id']) !== 1)) {
$this->errors[] = 'client_workout_id must be a UUID';
}
$this->validateNumericField('distance_meters', $payload['distance_meters'], self::MIN_DISTANCE, self::MAX_DISTANCE);
$this->validateNumericField('duration_seconds', $payload['duration_seconds'], self::MIN_DURATION, self::MAX_DURATION);
$this->validateNumericField('elevation_gain_meters', $payload['elevation_gain_meters'], 0, 10000);
$this->validateNumericField('elevation_loss_meters', $payload['elevation_loss_meters'], 0, 10000);
$this->validateNumericField('calories_burned', $payload['calories_burned'], 0, 5000);
$this->validateNumericField('max_speed_mps', $payload['max_speed_mps'], 0, 50);
// Validate polyline
$this->validatePolyline($payload['route_polyline']);
// Validate timestamps
$this->validateTimestamps($payload['start_time'], $payload['end_time']);
// Validate optional fields
if (isset($payload['weather_condition']) && !empty($payload['weather_condition'])) {
$this->validateWeatherCondition($payload['weather_condition']);
}
if (isset($payload['temperature_celsius']) && $payload['temperature_celsius'] !== null) {
if (!is_numeric($payload['temperature_celsius'])) {
$this->errors[] = 'temperature_celsius must be numeric';
} else {
$temp = floatval($payload['temperature_celsius']);
if ($temp < -50 || $temp > 60) {
$this->errors[] = 'temperature_celsius must be between -50 and 60 degrees';
}
}
}
// Validate optional segments array
if (isset($payload['segments']) && is_array($payload['segments'])) {
$this->validateSegments($payload['segments']);
}
// Validate optional fields
if (isset($payload['is_public']) && !is_bool($payload['is_public'])) {
$this->errors[] = 'is_public must be a boolean';
}
if (isset($payload['notes']) && !is_string($payload['notes'])) {
$this->errors[] = 'notes must be a string';
} elseif (isset($payload['notes']) && strlen($payload['notes']) > 1000) {
$this->errors[] = 'notes must not exceed 1000 characters';
}
return [
'valid' => empty($this->errors),
'errors' => $this->errors
];
}
/**
* Validate required fields exist
*/
private function validateRequiredFields($payload) {
$required = [
'workout_type',
'distance_meters',
'duration_seconds',
'route_polyline',
'start_time',
'end_time',
'elevation_gain_meters',
'elevation_loss_meters',
'calories_burned',
'max_speed_mps'
];
foreach ($required as $field) {
if (!isset($payload[$field])) {
$this->errors[] = "Missing required field: {$field}";
}
}
}
/**
* Validate workout type
*/
private function validateWorkoutType($type) {
if (!in_array($type, self::VALID_WORKOUT_TYPES, true)) {
$this->errors[] = 'Invalid workout_type. Must be: ' . implode(', ', self::VALID_WORKOUT_TYPES);
}
}
/**
* Validate numeric field within range
*/
private function validateNumericField($field_name, $value, $min, $max) {
if (!is_numeric($value)) {
$this->errors[] = "{$field_name} must be numeric";
return;
}
$numeric_value = floatval($value);
if ($numeric_value < $min || $numeric_value > $max) {
$this->errors[] = "{$field_name} must be between {$min} and {$max}";
}
}
/**
* Validate polyline
*/
private function validatePolyline($polyline) {
if (!is_string($polyline) || empty($polyline)) {
$this->errors[] = 'route_polyline must be a non-empty string';
return;
}
// Validate format
if (!PolylineUtility::validatePolyline($polyline)) {
$this->errors[] = 'route_polyline has invalid format';
return;
}
// Decode and check coordinate count
$coordinates = PolylineUtility::decodePolyline($polyline);
if (count($coordinates) < self::MIN_COORDINATES) {
$this->errors[] = 'route_polyline must contain at least ' . self::MIN_COORDINATES . ' coordinates';
}
if (count($coordinates) > self::MAX_COORDINATES) {
$this->errors[] = 'route_polyline must not exceed ' . self::MAX_COORDINATES . ' coordinates';
}
// Validate coordinate format
foreach ($coordinates as $coord) {
if (!is_array($coord) || !isset($coord['lat']) || !isset($coord['lng'])) {
$this->errors[] = 'Invalid coordinate format in polyline';
break;
}
if (!is_numeric($coord['lat']) || !is_numeric($coord['lng'])) {
$this->errors[] = 'Coordinate values must be numeric';
break;
}
// Validate lat/lng ranges
if ($coord['lat'] < -90 || $coord['lat'] > 90) {
$this->errors[] = 'Latitude must be between -90 and 90';
break;
}
if ($coord['lng'] < -180 || $coord['lng'] > 180) {
$this->errors[] = 'Longitude must be between -180 and 180';
break;
}
}
}
/**
* Validate timestamps
*/
private function validateTimestamps($start_time, $end_time) {
// Validate ISO 8601 format
if (!$this->isValidISO8601($start_time)) {
$this->errors[] = 'start_time must be in ISO 8601 format';
return;
}
if (!$this->isValidISO8601($end_time)) {
$this->errors[] = 'end_time must be in ISO 8601 format';
return;
}
$start = strtotime($start_time);
$end = strtotime($end_time);
if ($start === false || $end === false) {
$this->errors[] = 'Invalid timestamp format';
return;
}
if ($start >= $end) {
$this->errors[] = 'start_time must be before end_time';
}
// Validate timestamps are not in the future
if ($end > time()) {
$this->errors[] = 'end_time cannot be in the future';
}
// Validate timestamps are recent (not more than 30 days old)
if ($start < (time() - 30 * 24 * 60 * 60)) {
$this->errors[] = 'Workout must be submitted within 30 days';
}
}
/**
* Validate ISO 8601 datetime format
*/
private function isValidISO8601($date_string) {
$pattern = '/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(\.\d{3})?([+-]\d{2}:\d{2}|Z)?$/';
return preg_match($pattern, $date_string) === 1;
}
/**
* Validate weather condition
*/
private function validateWeatherCondition($condition) {
$valid_conditions = ['sunny', 'cloudy', 'rainy', 'snowy', 'windy', 'foggy', 'hail'];
if (!in_array(strtolower($condition), $valid_conditions, true)) {
$this->errors[] = 'Invalid weather_condition. Must be one of: ' . implode(', ', $valid_conditions);
}
}
/**
* Validate segments array
*/
private function validateSegments($segments) {
if (!is_array($segments) || empty($segments)) {
$this->errors[] = 'segments must be a non-empty array';
return;
}
if (count($segments) > 1000) {
$this->errors[] = 'segments array must not exceed 1000 items';
return;
}
foreach ($segments as $index => $segment) {
if (!is_array($segment)) {
$this->errors[] = "Segment {$index} must be an object";
continue;
}
if (!isset($segment['duration_seconds']) || !isset($segment['distance_meters'])) {
$this->errors[] = "Segment {$index} must have duration_seconds and distance_meters";
}
if (!is_numeric($segment['duration_seconds']) || intval($segment['duration_seconds']) < 0) {
$this->errors[] = "Segment {$index} duration_seconds must be a positive integer";
}
if (!is_numeric($segment['distance_meters']) || intval($segment['distance_meters']) < 0) {
$this->errors[] = "Segment {$index} distance_meters must be a positive integer";
}
}
}
}
?>