Add protected training content admin

This commit is contained in:
Hamza-Ayed
2026-10-04 01:59:41 +03:00
parent 66efcdac6a
commit d892e3d981
6 changed files with 773 additions and 1 deletions
+325
View File
@@ -0,0 +1,325 @@
<?php
declare(strict_types=1);
require_once dirname(__DIR__, 4) . '/backend/JwtToken.php';
require_once dirname(__DIR__, 4) . '/backend/ApiAuth.php';
require_once dirname(__DIR__) . '/_bootstrap.php';
$method = $_SERVER['REQUEST_METHOD'] ?? 'GET';
if (!in_array($method, ['GET', 'POST'], true)) {
header('Allow: GET, POST');
api_json(['error' => '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);
}