283 lines
9.8 KiB
PHP
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";
|
|
}
|
|
}
|
|
}
|
|
}
|
|
?>
|