'method_not_allowed'], 405); } $auth = ApiAuth::bearerClaims(); ApiAuth::requireRole($auth, ['owner', 'content_manager']); function content_json_value($value, int $maxBytes = 20000): string { $json = json_encode($value, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES); if ($json === false || strlen($json) > $maxBytes) { api_json(['error' => 'content_value_too_large_or_invalid'], 400); } return $json; } function content_string_list($value, string $field, int $maxItems = 30): array { if (!is_array($value) || count($value) > $maxItems) { api_json(['error' => 'invalid_' . $field], 400); } foreach ($value as $item) { if (!is_string($item) || trim($item) === '' || mb_strlen($item) > 500) { api_json(['error' => 'invalid_' . $field], 400); } } return array_values(array_map('trim', $value)); } function content_text($value, string $field, int $maxLength, bool $required = true): ?string { if ($value === null && !$required) return null; if (!is_string($value)) api_json(['error' => 'invalid_' . $field], 400); $value = trim($value); if (($required && $value === '') || mb_strlen($value) > $maxLength) { api_json(['error' => 'invalid_' . $field], 400); } return $value; } function content_enum($value, string $field, array $allowed): string { if (!is_string($value) || !in_array($value, $allowed, true)) { api_json(['error' => 'invalid_' . $field], 400); } return $value; } function content_https_url($value, string $field): ?string { if ($value === null || $value === '') return null; if (!is_string($value) || strlen($value) > 500) api_json(['error' => 'invalid_' . $field], 400); $parts = parse_url($value); if (!is_array($parts) || ($parts['scheme'] ?? '') !== 'https' || empty($parts['host']) || isset($parts['user']) || isset($parts['pass'])) { api_json(['error' => 'invalid_' . $field], 400); } return $value; } try { $db = Database::getInstance(); if ($method === 'GET') { $connection = $db->getConnection(); $exerciseResult = $connection->query('SELECT exercise_uuid, slug, title_ar, instructions_ar, target_muscles, equipment, difficulty, movement_type, gif_url, gif_poster_url, duration_seconds, repetitions, safety_notes_ar, is_published, content_revision FROM exercises ORDER BY updated_at DESC LIMIT 500'); $exercises = []; while ($row = $exerciseResult->fetch_assoc()) { foreach (['instructions_ar', 'target_muscles', 'equipment', 'safety_notes_ar'] as $key) { $row[$key] = json_decode($row[$key], true) ?? []; } $row['is_published'] = (bool) $row['is_published']; $row['content_revision'] = (int) $row['content_revision']; $exercises[] = $row; } $planResult = $connection->query('SELECT id, plan_uuid, slug, title_ar, summary_ar, goal, level, weeks_duration, source_notes, safety_notes_ar, is_published, content_revision FROM training_plans ORDER BY updated_at DESC LIMIT 200'); $plans = []; $planIds = []; while ($row = $planResult->fetch_assoc()) { $row['source_notes'] = json_decode($row['source_notes'] ?? '[]', true) ?? []; $row['safety_notes_ar'] = json_decode($row['safety_notes_ar'], true) ?? []; $row['is_published'] = (bool) $row['is_published']; $row['content_revision'] = (int) $row['content_revision']; $plans[$row['plan_uuid']] = $row; $planIds[(int) $row['id']] = $row['plan_uuid']; } if ($planIds) { $sessionResult = $connection->query('SELECT s.id, s.plan_id, s.week_number, s.day_number, s.title_ar, s.session_type, s.duration_minutes, s.intensity, s.notes_ar, e.exercise_uuid, se.sort_order, se.sets, se.reps, se.duration_seconds, se.rest_seconds FROM training_plan_sessions s LEFT JOIN training_session_exercises se ON se.session_id = s.id LEFT JOIN exercises e ON e.id = se.exercise_id ORDER BY s.plan_id, s.week_number, s.day_number, se.sort_order'); foreach ($plans as &$plan) $plan['sessions'] = []; unset($plan); while ($row = $sessionResult->fetch_assoc()) { $planUuid = $planIds[(int) $row['plan_id']] ?? null; if ($planUuid === null) continue; $sessionKey = $row['week_number'] . ':' . $row['day_number']; if (!isset($plans[$planUuid]['sessions'][$sessionKey])) { $plans[$planUuid]['sessions'][$sessionKey] = [ 'week' => (int) $row['week_number'], 'day' => (int) $row['day_number'], 'title_ar' => $row['title_ar'], 'type' => $row['session_type'], 'duration_minutes' => (int) $row['duration_minutes'], 'intensity' => $row['intensity'], 'notes_ar' => $row['notes_ar'], 'exercises' => [], ]; } if ($row['exercise_uuid'] !== null) { $plans[$planUuid]['sessions'][$sessionKey]['exercises'][] = [ 'exercise_uuid' => $row['exercise_uuid'], 'sets' => $row['sets'] === null ? null : (int) $row['sets'], 'reps' => $row['reps'], 'duration_seconds' => $row['duration_seconds'] === null ? null : (int) $row['duration_seconds'], 'rest_seconds' => (int) $row['rest_seconds'], ]; } } foreach ($plans as &$plan) $plan['sessions'] = array_values($plan['sessions']); unset($plan); } foreach ($plans as &$plan) { unset($plan['id']); $plan['sessions'] = $plan['sessions'] ?? []; } unset($plan); api_json(['exercises' => $exercises, 'plans' => array_values($plans)]); } $body = json_decode(file_get_contents('php://input') ?: '', true); if (!is_array($body) || !in_array($body['entity'] ?? null, ['exercise', 'plan'], true)) { api_json(['error' => 'invalid_content_payload'], 400); } $entity = $body['entity']; $data = $body['content'] ?? null; if (!is_array($data)) api_json(['error' => 'invalid_content_payload'], 400); $uuid = $data['uuid'] ?? null; if ($uuid !== null && (!is_string($uuid) || !preg_match('/^[0-9a-f-]{36}$/i', $uuid))) { api_json(['error' => 'invalid_content_uuid'], 400); } $uuid = $uuid ?: sprintf('%04x%04x-%04x-4%03x-%04x-%04x%04x%04x', random_int(0, 0xffff), random_int(0, 0xffff), random_int(0, 0xffff), random_int(0, 0xfff), random_int(0, 0x3fff) | 0x8000, random_int(0, 0xffff), random_int(0, 0xffff), random_int(0, 0xffff)); $published = $data['is_published'] ?? false; if (!is_bool($published)) api_json(['error' => 'invalid_publication_state'], 400); $connection = $db->getConnection(); $connection->begin_transaction(); $previous = null; if ($entity === 'exercise') { $slug = content_text($data['slug'] ?? null, 'slug', 100); if (!preg_match('/^[a-z0-9]+(?:-[a-z0-9]+)*$/', $slug)) api_json(['error' => 'invalid_slug'], 400); $title = content_text($data['title_ar'] ?? null, 'title_ar', 160); $instructions = content_string_list($data['instructions_ar'] ?? [], 'instructions_ar'); $muscles = content_string_list($data['target_muscles'] ?? [], 'target_muscles'); $equipment = content_string_list($data['equipment'] ?? [], 'equipment'); $safety = content_string_list($data['safety_notes_ar'] ?? [], 'safety_notes_ar'); $difficulty = content_enum($data['difficulty'] ?? 'beginner', 'difficulty', ['beginner', 'intermediate', 'advanced']); $movement = content_enum($data['movement_type'] ?? null, 'movement_type', ['strength', 'mobility', 'cardio', 'recovery']); $gif = content_https_url($data['gif_url'] ?? null, 'gif_url'); $poster = content_https_url($data['gif_poster_url'] ?? null, 'gif_poster_url'); $duration = $data['duration_seconds'] ?? null; if ($duration !== null && (!is_int($duration) || $duration < 1 || $duration > 3600)) api_json(['error' => 'invalid_duration_seconds'], 400); $repetitions = content_text($data['repetitions'] ?? null, 'repetitions', 80, false); $alternative = $data['alternative_exercise_uuid'] ?? null; if ($alternative !== null && (!is_string($alternative) || !preg_match('/^[0-9a-f-]{36}$/i', $alternative))) api_json(['error' => 'invalid_alternative_exercise_uuid'], 400); $check = $db->prepare('SELECT exercise_uuid, slug, title_ar, instructions_ar, target_muscles, equipment, difficulty, movement_type, gif_url, gif_poster_url, duration_seconds, repetitions, safety_notes_ar, is_published FROM exercises WHERE exercise_uuid = ? FOR UPDATE'); $check->bind_param('s', $uuid); $check->execute(); $previous = $check->get_result()->fetch_assoc() ?: null; $check->close(); $slugCheck = $db->prepare('SELECT exercise_uuid FROM exercises WHERE slug = ? AND exercise_uuid <> ? LIMIT 1'); $slugCheck->bind_param('ss', $slug, $uuid); $slugCheck->execute(); $slugTaken = $slugCheck->get_result()->fetch_assoc(); $slugCheck->close(); if ($slugTaken) api_json(['error' => 'slug_already_exists'], 409); if (!$published && $previous !== null) { $usage = $db->prepare('SELECT COUNT(*) AS total FROM training_session_exercises se JOIN training_plan_sessions s ON s.id = se.session_id JOIN training_plans p ON p.id = s.plan_id JOIN exercises e ON e.id = se.exercise_id WHERE e.exercise_uuid = ? AND p.is_published = 1'); $usage->bind_param('s', $uuid); $usage->execute(); $linked = (int) $usage->get_result()->fetch_assoc()['total']; $usage->close(); if ($linked > 0) api_json(['error' => 'exercise_used_by_published_plan'], 409); } $altId = null; if ($alternative !== null) { $altQuery = $db->prepare('SELECT id FROM exercises WHERE exercise_uuid = ? LIMIT 1'); $altQuery->bind_param('s', $alternative); $altQuery->execute(); $altRow = $altQuery->get_result()->fetch_assoc(); $altQuery->close(); if (!$altRow) api_json(['error' => 'alternative_exercise_not_found'], 400); $altId = (int) $altRow['id']; } $instructionJson = content_json_value($instructions); $muscleJson = content_json_value($muscles); $equipmentJson = content_json_value($equipment); $safetyJson = content_json_value($safety); if ($previous === null) { $write = $db->prepare('INSERT INTO exercises (exercise_uuid, slug, title_ar, instructions_ar, target_muscles, equipment, difficulty, movement_type, gif_url, gif_poster_url, duration_seconds, repetitions, safety_notes_ar, alternative_exercise_id, is_published) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)'); $write->bind_param('ssssssssssissii', $uuid, $slug, $title, $instructionJson, $muscleJson, $equipmentJson, $difficulty, $movement, $gif, $poster, $duration, $repetitions, $safetyJson, $altId, $published); } else { $write = $db->prepare('UPDATE exercises SET slug = ?, title_ar = ?, instructions_ar = ?, target_muscles = ?, equipment = ?, difficulty = ?, movement_type = ?, gif_url = ?, gif_poster_url = ?, duration_seconds = ?, repetitions = ?, safety_notes_ar = ?, alternative_exercise_id = ?, is_published = ?, content_revision = content_revision + 1 WHERE exercise_uuid = ?'); $write->bind_param('sssssssssissiis', $slug, $title, $instructionJson, $muscleJson, $equipmentJson, $difficulty, $movement, $gif, $poster, $duration, $repetitions, $safetyJson, $altId, $published, $uuid); } $write->execute(); $write->close(); $newValue = ['uuid' => $uuid, 'slug' => $slug, 'title_ar' => $title, 'instructions_ar' => $instructions, 'target_muscles' => $muscles, 'equipment' => $equipment, 'difficulty' => $difficulty, 'movement_type' => $movement, 'gif_url' => $gif, 'gif_poster_url' => $poster, 'duration_seconds' => $duration, 'repetitions' => $repetitions, 'safety_notes_ar' => $safety, 'alternative_exercise_uuid' => $alternative, 'is_published' => $published]; } else { $slug = content_text($data['slug'] ?? null, 'slug', 100); if (!preg_match('/^[a-z0-9]+(?:-[a-z0-9]+)*$/', $slug)) api_json(['error' => 'invalid_slug'], 400); $title = content_text($data['title_ar'] ?? null, 'title_ar', 160); $summary = content_text($data['summary_ar'] ?? '', 'summary_ar', 4000, false) ?? ''; $goal = content_enum($data['goal'] ?? null, 'goal', ['general_fitness', 'weight_management', 'mobility', 'endurance']); $level = content_enum($data['level'] ?? 'beginner', 'level', ['beginner', 'intermediate', 'advanced']); $weeks = $data['weeks_duration'] ?? null; if (!is_int($weeks) || $weeks < 1 || $weeks > 52) api_json(['error' => 'invalid_weeks_duration'], 400); $sourceNotes = content_string_list($data['source_notes'] ?? [], 'source_notes'); $safety = content_string_list($data['safety_notes_ar'] ?? [], 'safety_notes_ar'); $sessions = $data['sessions'] ?? []; if (!is_array($sessions) || count($sessions) > 364) api_json(['error' => 'invalid_sessions'], 400); $check = $db->prepare('SELECT plan_uuid, slug, title_ar, summary_ar, goal, level, weeks_duration, source_notes, safety_notes_ar, is_published FROM training_plans WHERE plan_uuid = ? FOR UPDATE'); $check->bind_param('s', $uuid); $check->execute(); $previous = $check->get_result()->fetch_assoc() ?: null; $check->close(); $slugCheck = $db->prepare('SELECT plan_uuid FROM training_plans WHERE slug = ? AND plan_uuid <> ? LIMIT 1'); $slugCheck->bind_param('ss', $slug, $uuid); $slugCheck->execute(); $slugTaken = $slugCheck->get_result()->fetch_assoc(); $slugCheck->close(); if ($slugTaken) api_json(['error' => 'slug_already_exists'], 409); $sourceJson = content_json_value($sourceNotes); $safetyJson = content_json_value($safety); if ($previous === null) { $write = $db->prepare('INSERT INTO training_plans (plan_uuid, slug, title_ar, summary_ar, goal, level, weeks_duration, source_notes, safety_notes_ar, is_published) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)'); $write->bind_param('ssssssissi', $uuid, $slug, $title, $summary, $goal, $level, $weeks, $sourceJson, $safetyJson, $published); } else { $write = $db->prepare('UPDATE training_plans SET slug = ?, title_ar = ?, summary_ar = ?, goal = ?, level = ?, weeks_duration = ?, source_notes = ?, safety_notes_ar = ?, is_published = ?, content_revision = content_revision + 1 WHERE plan_uuid = ?'); $write->bind_param('sssssissis', $slug, $title, $summary, $goal, $level, $weeks, $sourceJson, $safetyJson, $published, $uuid); } $write->execute(); $planId = (int) $connection->insert_id; if ($previous !== null || $planId === 0) { $idQuery = $db->prepare('SELECT id FROM training_plans WHERE plan_uuid = ? LIMIT 1'); $idQuery->bind_param('s', $uuid); $idQuery->execute(); $planId = (int) $idQuery->get_result()->fetch_assoc()['id']; $idQuery->close(); } $write->close(); $remove = $db->prepare('DELETE FROM training_plan_sessions WHERE plan_id = ?'); $remove->bind_param('i', $planId); $remove->execute(); $remove->close(); $sessionInsert = $db->prepare('INSERT INTO training_plan_sessions (plan_id, week_number, day_number, title_ar, session_type, duration_minutes, intensity, notes_ar, sort_order) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)'); $exerciseLookup = $db->prepare('SELECT id FROM exercises WHERE exercise_uuid = ? AND is_published = 1 LIMIT 1'); $linkInsert = $db->prepare('INSERT INTO training_session_exercises (session_id, exercise_id, sort_order, sets, reps, duration_seconds, rest_seconds) VALUES (?, ?, ?, ?, ?, ?, ?)'); $sessionKeys = []; foreach ($sessions as $sessionIndex => $session) { if (!is_array($session)) api_json(['error' => 'invalid_session'], 400); $week = $session['week'] ?? null; $day = $session['day'] ?? null; $minutes = $session['duration_minutes'] ?? 0; if (!is_int($week) || $week < 1 || $week > $weeks || !is_int($day) || $day < 1 || $day > 7 || !is_int($minutes) || $minutes < 0 || $minutes > 600) api_json(['error' => 'invalid_session_schedule'], 400); $key = $week . ':' . $day; if (isset($sessionKeys[$key])) api_json(['error' => 'duplicate_session_day'], 400); $sessionKeys[$key] = true; $sessionTitle = content_text($session['title_ar'] ?? null, 'session_title_ar', 160); $sessionType = content_enum($session['type'] ?? null, 'session_type', ['strength', 'walking', 'mobility', 'rest']); $intensity = content_enum($session['intensity'] ?? 'easy', 'intensity', ['easy', 'moderate', 'vigorous']); $notes = content_text($session['notes_ar'] ?? null, 'session_notes_ar', 1000, false); $exerciseItems = $session['exercises'] ?? []; if (!is_array($exerciseItems) || count($exerciseItems) > 20) api_json(['error' => 'invalid_session_exercises'], 400); $exerciseUuids = []; $order = (int) $sessionIndex; $sessionInsert->bind_param('iiississi', $planId, $week, $day, $sessionTitle, $sessionType, $minutes, $intensity, $notes, $order); $sessionInsert->execute(); $sessionId = (int) $connection->insert_id; foreach ($exerciseItems as $exerciseIndex => $item) { if (!is_array($item) || !is_string($item['exercise_uuid'] ?? null)) api_json(['error' => 'invalid_session_exercise'], 400); if (isset($exerciseUuids[$item['exercise_uuid']])) api_json(['error' => 'duplicate_session_exercise'], 400); $exerciseUuids[$item['exercise_uuid']] = true; $exerciseLookup->bind_param('s', $item['exercise_uuid']); $exerciseLookup->execute(); $exerciseRow = $exerciseLookup->get_result()->fetch_assoc(); if (!$exerciseRow) api_json(['error' => 'exercise_not_published'], 400); $exerciseId = (int) $exerciseRow['id']; $sort = (int) $exerciseIndex; $sets = $item['sets'] ?? null; if ($sets !== null && (!is_int($sets) || $sets < 1 || $sets > 50)) api_json(['error' => 'invalid_sets'], 400); $reps = content_text($item['reps'] ?? null, 'reps', 60, false); $durationSeconds = $item['duration_seconds'] ?? null; $rest = $item['rest_seconds'] ?? 45; if (($durationSeconds !== null && (!is_int($durationSeconds) || $durationSeconds < 1 || $durationSeconds > 3600)) || !is_int($rest) || $rest < 0 || $rest > 3600) api_json(['error' => 'invalid_exercise_prescription'], 400); $linkInsert->bind_param('iiiisii', $sessionId, $exerciseId, $sort, $sets, $reps, $durationSeconds, $rest); $linkInsert->execute(); } } $sessionInsert->close(); $exerciseLookup->close(); $linkInsert->close(); $newValue = ['uuid' => $uuid, 'slug' => $slug, 'title_ar' => $title, 'summary_ar' => $summary, 'goal' => $goal, 'level' => $level, 'weeks_duration' => $weeks, 'source_notes' => $sourceNotes, 'safety_notes_ar' => $safety, 'is_published' => $published, 'sessions' => $sessions]; } $previousJson = $previous === null ? null : content_json_value($previous, 65535); $newJson = content_json_value($newValue, 65535); $action = $previous === null ? 'create' : (($previous['is_published'] ?? '0') != ($published ? '1' : '0') ? ($published ? 'publish' : 'unpublish') : 'update'); $audit = $db->prepare('INSERT INTO training_content_audit (entity_type, entity_uuid, action, previous_value, new_value, actor_user_id) VALUES (?, ?, ?, ?, ?, ?)'); $audit->bind_param('sssssi', $entity, $uuid, $action, $previousJson, $newJson, $auth['user_id']); $audit->execute(); $audit->close(); $connection->commit(); api_json(['status' => 'saved', 'entity' => $entity, 'uuid' => $uuid]); } catch (Throwable $exception) { if (isset($connection) && $connection instanceof mysqli) { try { $connection->rollback(); } catch (Throwable $ignored) {} } error_log('Admin content operation failed: ' . $exception->getMessage()); api_json(['error' => 'service_unavailable'], 503); }