Add SportPath auth APIs and training content foundation
This commit is contained in:
@@ -0,0 +1,36 @@
|
||||
# مصادقة SportPath — API v1
|
||||
|
||||
هذه الواجهات تأسيس أولي لمصادقة رقم الهاتف. لا تشغّل إرسال OTP قبل إعداد مزود حقيقي ومراجعة عقده على بيئة sandbox. لا يخرج الرمز أو المفاتيح في response أو logs.
|
||||
|
||||
## إعداد الخادم
|
||||
|
||||
ضع القيم في ملف البيئة الخاص خارج مجلد `public/`:
|
||||
|
||||
```dotenv
|
||||
JWT_SIGNING_KEY=<random secret, at least 32 bytes>
|
||||
JWT_ACCESS_TTL_SECONDS=900
|
||||
JWT_REFRESH_TTL_DAYS=30
|
||||
OTP_PROVIDER=generic_json
|
||||
OTP_API_URL=https://provider.example/api/send
|
||||
OTP_API_KEY=<server-only token>
|
||||
OTP_SENDER_ID=<approved sender>
|
||||
OTP_MESSAGE_TEMPLATE=رمز التحقق الخاص بك هو {code}
|
||||
OTP_ENABLED=true
|
||||
OTP_HASH_KEY=<independent random secret, at least 32 bytes>
|
||||
```
|
||||
|
||||
المحول الحالي يرسل JSON بالشكل `{"to":"+...","sender":"...","message":"..."}` مع `Authorization: Bearer ...`، ويعد أي HTTP 2xx نجاحًا. هذا عقد عام مؤقت وليس افتراضًا عن أي مزود؛ عدّل `ConfiguredHttpOtpProvider` ليتوافق مع توثيق المزود الفعلي قبل تفعيل الإنتاج. يرفض endpoint العناوين غير HTTPS. في حال غياب الإعداد يبقى الطلب مغلقًا ويرجع 503.
|
||||
|
||||
## المسارات
|
||||
|
||||
- `POST /api/v1/auth/request-otp.php`: JSON `{ "phone_e164": "+962…", "purpose": "register|login", "device_uuid": "UUID" }`. يرد `challenge_id` ومدة الصلاحية. حد الإرسال الحالي 5 لكل رقم/ساعة، 20 لكل IP/ساعة، و10 لكل device/ساعة؛ المحاولات لكل تحدٍ 5 خلال 5 دقائق.
|
||||
- `POST /api/v1/auth/verify-otp.php`: JSON `{ "challenge_id": "UUID", "code": "123456", "display_name": "…", "device_uuid": "UUID", "platform": "ios|android|web" }`. يستهلك التحدي مرة واحدة ويصدر JWT وrefresh token عشوائيًا. يجب حفظ refresh token في مخزن آمن على الجهاز، وعدم تسجيل أي token.
|
||||
- `POST /api/v1/auth/refresh.php`: JSON `{ "refresh_token": "…" }`. تدوير الرمز يصدر refresh جديدًا؛ إعادة استخدام رمز سبق تدويره تبطل عائلة الجلسة.
|
||||
- `POST /api/v1/auth/logout.php`: يتطلب `Authorization: Bearer <access-token>` ويبطل الجلسة الحالية.
|
||||
- `/api/v1/admin/settings.php`: يتطلب bearer token ودور `owner` أو `content_manager`؛ GET للقراءة وPATCH للتعديل مع audit trail.
|
||||
|
||||
كل Access Token قصير العمر ويرتبط بجلسة حية في MySQL. الدور وحالة الحساب يعاد التحقق منهما من قاعدة البيانات لكل طلب محمي. لا تُقبل معرفات عتادية ثابتة؛ `device_uuid` معرّف تثبيت عشوائي. ترقية أول مالك تتم يدويًا على قاعدة الخادم بعد التحقق من رقم الهاتف، ولا توجد واجهة bootstrap عامة لمنح دور owner.
|
||||
|
||||
## ما لم يكتمل بعد
|
||||
|
||||
هذا API غير مربوط بعد بتطبيق Flutter، ونقاط التتبع القديمة ما زالت تستخدم HMAC الانتقالي. قبل الإنتاج يجب نقل التطبيق إلى OTP/refresh الآمن، ثم إيقاف بيانات HMAC القديمة أو حصرها بفترة انتقال معلومة. لم يُختبر اتصال MySQL الفعلي أو إرسال SMS؛ فحوص PHP المتاحة حتى الآن ساكنة فقط. نفّذ migration `001_phone_auth_and_sessions.sql` على قاعدة staging احتياطية أولًا، وتحقق من rate limits والتدوير والإبطال مع مزود OTP في sandbox.
|
||||
@@ -0,0 +1,42 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
final class ApiAuth
|
||||
{
|
||||
public static function bearerClaims(): array
|
||||
{
|
||||
$header = $_SERVER['HTTP_AUTHORIZATION'] ?? '';
|
||||
if (!preg_match('/^Bearer\s+([A-Za-z0-9_.-]+)$/i', $header, $matches)) {
|
||||
api_json(['error' => 'unauthorized'], 401);
|
||||
}
|
||||
$claims = JwtToken::verify($matches[1]);
|
||||
if ($claims === null) {
|
||||
api_json(['error' => 'unauthorized'], 401);
|
||||
}
|
||||
try {
|
||||
$db = Database::getInstance();
|
||||
$stmt = $db->prepare('SELECT u.id, u.account_role, u.is_active FROM auth_sessions s JOIN users u ON u.id = s.user_id WHERE s.session_uuid = ? AND s.user_id = ? AND s.revoked_at IS NULL AND s.replaced_by IS NULL AND s.expires_at > UTC_TIMESTAMP() AND u.is_active = 1 LIMIT 1');
|
||||
$userId = (int) $claims['sub'];
|
||||
$sessionId = $claims['sid'];
|
||||
$stmt->bind_param('si', $sessionId, $userId);
|
||||
$stmt->execute();
|
||||
$result = $stmt->get_result();
|
||||
$user = $result->fetch_assoc();
|
||||
$stmt->close();
|
||||
if (!$user) {
|
||||
api_json(['error' => 'unauthorized'], 401);
|
||||
}
|
||||
return ['user_id' => (int) $user['id'], 'role' => $user['account_role'], 'session_id' => $sessionId];
|
||||
} catch (Throwable $exception) {
|
||||
error_log('Bearer authorization check failed: ' . $exception->getMessage());
|
||||
api_json(['error' => 'service_unavailable'], 503);
|
||||
}
|
||||
}
|
||||
|
||||
public static function requireRole(array $auth, array $allowedRoles): void
|
||||
{
|
||||
if (!in_array($auth['role'] ?? null, $allowedRoles, true)) {
|
||||
api_json(['error' => 'forbidden'], 403);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -25,8 +25,7 @@ class Database {
|
||||
$this->connection->query("SET time_zone = '+00:00'");
|
||||
} catch (Throwable $e) {
|
||||
error_log('Database initialization failed: ' . $e->getMessage());
|
||||
http_response_code(500);
|
||||
die(json_encode(['error' => 'Database connection failed']));
|
||||
throw new RuntimeException('Database connection failed', 0, $e);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -109,6 +108,8 @@ class Database {
|
||||
// Prevent cloning
|
||||
final class SingletonDatabase extends Database {
|
||||
private function __clone() {}
|
||||
private function __wakeup() {}
|
||||
public function __wakeup() {
|
||||
throw new LogicException('Database singleton cannot be unserialized');
|
||||
}
|
||||
}
|
||||
?>
|
||||
|
||||
@@ -0,0 +1,66 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
final class JwtToken
|
||||
{
|
||||
public static function issue(int $userId, string $sessionId, int $ttlSeconds): string
|
||||
{
|
||||
$key = AppConfig::required('JWT_SIGNING_KEY');
|
||||
if (strlen($key) < 32) {
|
||||
throw new RuntimeException('JWT_SIGNING_KEY must be at least 32 bytes');
|
||||
}
|
||||
$now = time();
|
||||
$header = self::base64UrlEncode(json_encode(['alg' => 'HS256', 'typ' => 'JWT']));
|
||||
$payload = self::base64UrlEncode(json_encode([
|
||||
'iss' => rtrim((string) (getenv('APP_URL') ?: ''), '/'),
|
||||
'sub' => (string) $userId,
|
||||
'sid' => $sessionId,
|
||||
'iat' => $now,
|
||||
'exp' => $now + $ttlSeconds,
|
||||
'jti' => bin2hex(random_bytes(16)),
|
||||
], JSON_UNESCAPED_SLASHES));
|
||||
$signingInput = $header . '.' . $payload;
|
||||
return $signingInput . '.' . self::base64UrlEncode(hash_hmac('sha256', $signingInput, $key, true));
|
||||
}
|
||||
|
||||
public static function verify(string $token): ?array
|
||||
{
|
||||
$parts = explode('.', $token);
|
||||
if (count($parts) !== 3) {
|
||||
return null;
|
||||
}
|
||||
[$headerPart, $payloadPart, $signaturePart] = $parts;
|
||||
$header = json_decode(self::base64UrlDecode($headerPart), true);
|
||||
$claims = json_decode(self::base64UrlDecode($payloadPart), true);
|
||||
if (!is_array($header) || ($header['alg'] ?? null) !== 'HS256' || !is_array($claims)) {
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
$key = AppConfig::required('JWT_SIGNING_KEY');
|
||||
} catch (Throwable $exception) {
|
||||
return null;
|
||||
}
|
||||
if (strlen($key) < 32) {
|
||||
return null;
|
||||
}
|
||||
$expected = self::base64UrlEncode(hash_hmac('sha256', $headerPart . '.' . $payloadPart, $key, true));
|
||||
if (!hash_equals($expected, $signaturePart) || (int) ($claims['exp'] ?? 0) <= time()) {
|
||||
return null;
|
||||
}
|
||||
if (!ctype_digit((string) ($claims['sub'] ?? '')) || !is_string($claims['sid'] ?? null)) {
|
||||
return null;
|
||||
}
|
||||
return $claims;
|
||||
}
|
||||
|
||||
private static function base64UrlEncode(string $value): string
|
||||
{
|
||||
return rtrim(strtr(base64_encode($value), '+/', '-_'), '=');
|
||||
}
|
||||
|
||||
private static function base64UrlDecode(string $value): string
|
||||
{
|
||||
$decoded = base64_decode(strtr($value, '-_', '+/'), true);
|
||||
return $decoded === false ? '' : $decoded;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
interface OtpProvider
|
||||
{
|
||||
public function send(string $phoneE164, string $code): void;
|
||||
}
|
||||
|
||||
/** Minimal HTTP JSON adapter. Map request fields to the selected vendor contract only after confirmation. */
|
||||
final class ConfiguredHttpOtpProvider implements OtpProvider
|
||||
{
|
||||
public function send(string $phoneE164, string $code): void
|
||||
{
|
||||
AppConfig::loadEnvironment();
|
||||
if (getenv('OTP_ENABLED') !== 'true') {
|
||||
throw new RuntimeException('OTP provider is disabled');
|
||||
}
|
||||
if (AppConfig::required('OTP_PROVIDER') !== 'generic_json') {
|
||||
throw new RuntimeException('Configure a supported OTP provider adapter before enabling delivery');
|
||||
}
|
||||
$url = AppConfig::required('OTP_API_URL');
|
||||
$apiKey = AppConfig::required('OTP_API_KEY');
|
||||
if (!filter_var($url, FILTER_VALIDATE_URL) || parse_url($url, PHP_URL_SCHEME) !== 'https') {
|
||||
throw new RuntimeException('OTP endpoint must use HTTPS');
|
||||
}
|
||||
if (!function_exists('curl_init')) {
|
||||
throw new RuntimeException('The cURL extension is required for OTP delivery');
|
||||
}
|
||||
|
||||
$sender = getenv('OTP_SENDER_ID') ?: '';
|
||||
$message = getenv('OTP_MESSAGE_TEMPLATE') ?: 'رمز التحقق الخاص بك هو {code}';
|
||||
if (strpos($message, '{code}') === false) {
|
||||
throw new RuntimeException('OTP message template must include {code}');
|
||||
}
|
||||
$payload = json_encode([
|
||||
'to' => $phoneE164,
|
||||
'sender' => $sender,
|
||||
'message' => str_replace('{code}', $code, $message),
|
||||
], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
|
||||
if ($payload === false) {
|
||||
throw new RuntimeException('Unable to encode OTP request');
|
||||
}
|
||||
$handle = curl_init($url);
|
||||
curl_setopt_array($handle, [
|
||||
CURLOPT_POST => true,
|
||||
CURLOPT_POSTFIELDS => $payload,
|
||||
CURLOPT_HTTPHEADER => ['Content-Type: application/json', 'Authorization: Bearer ' . $apiKey],
|
||||
CURLOPT_RETURNTRANSFER => true,
|
||||
CURLOPT_CONNECTTIMEOUT => 5,
|
||||
CURLOPT_TIMEOUT => 12,
|
||||
CURLOPT_PROTOCOLS => CURLPROTO_HTTPS,
|
||||
]);
|
||||
$response = curl_exec($handle);
|
||||
$status = (int) curl_getinfo($handle, CURLINFO_RESPONSE_CODE);
|
||||
$error = curl_error($handle);
|
||||
curl_close($handle);
|
||||
if ($response === false || $status < 200 || $status >= 300) {
|
||||
error_log('OTP delivery failed; HTTP ' . $status . '; transport error: ' . $error);
|
||||
throw new RuntimeException('OTP provider rejected the request');
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
-- Exercise media library and versioned training-plan content.
|
||||
-- Apply to an existing database after backing it up.
|
||||
|
||||
CREATE TABLE exercises (
|
||||
id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
|
||||
exercise_uuid CHAR(36) NOT NULL UNIQUE,
|
||||
slug VARCHAR(100) NOT NULL UNIQUE,
|
||||
title_ar VARCHAR(160) NOT NULL,
|
||||
title_en VARCHAR(160) NULL,
|
||||
instructions_ar JSON NOT NULL,
|
||||
target_muscles JSON NOT NULL,
|
||||
equipment JSON NOT NULL,
|
||||
difficulty ENUM('beginner', 'intermediate', 'advanced') NOT NULL DEFAULT 'beginner',
|
||||
movement_type ENUM('strength', 'mobility', 'cardio', 'recovery') NOT NULL,
|
||||
gif_url VARCHAR(500) NULL,
|
||||
gif_poster_url VARCHAR(500) NULL,
|
||||
duration_seconds SMALLINT UNSIGNED NULL,
|
||||
repetitions VARCHAR(80) NULL,
|
||||
safety_notes_ar JSON NOT NULL,
|
||||
alternative_exercise_id BIGINT UNSIGNED NULL,
|
||||
is_published BOOLEAN NOT NULL DEFAULT FALSE,
|
||||
content_revision INT UNSIGNED NOT NULL DEFAULT 1,
|
||||
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||
INDEX idx_exercises_public (is_published, movement_type, difficulty),
|
||||
CONSTRAINT fk_exercise_alternative FOREIGN KEY (alternative_exercise_id) REFERENCES exercises(id) ON DELETE SET NULL
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||
|
||||
CREATE TABLE training_plans (
|
||||
id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
|
||||
plan_uuid CHAR(36) NOT NULL UNIQUE,
|
||||
slug VARCHAR(100) NOT NULL UNIQUE,
|
||||
title_ar VARCHAR(160) NOT NULL,
|
||||
summary_ar TEXT NOT NULL,
|
||||
goal ENUM('general_fitness', 'weight_management', 'mobility', 'endurance') NOT NULL,
|
||||
level ENUM('beginner', 'intermediate', 'advanced') NOT NULL,
|
||||
weeks_duration TINYINT UNSIGNED NOT NULL,
|
||||
source_notes JSON NULL,
|
||||
safety_notes_ar JSON NOT NULL,
|
||||
is_published BOOLEAN NOT NULL DEFAULT FALSE,
|
||||
content_revision INT UNSIGNED NOT NULL DEFAULT 1,
|
||||
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||
INDEX idx_plans_public (is_published, goal, level)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||
|
||||
CREATE TABLE training_plan_sessions (
|
||||
id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
|
||||
plan_id BIGINT UNSIGNED NOT NULL,
|
||||
week_number TINYINT UNSIGNED NOT NULL,
|
||||
day_number TINYINT UNSIGNED NOT NULL,
|
||||
title_ar VARCHAR(160) NOT NULL,
|
||||
session_type ENUM('strength', 'walking', 'mobility', 'rest') NOT NULL,
|
||||
duration_minutes SMALLINT UNSIGNED NOT NULL DEFAULT 0,
|
||||
intensity ENUM('easy', 'moderate', 'vigorous') NOT NULL DEFAULT 'easy',
|
||||
notes_ar TEXT NULL,
|
||||
sort_order SMALLINT UNSIGNED NOT NULL DEFAULT 0,
|
||||
UNIQUE KEY uq_plan_week_day (plan_id, week_number, day_number),
|
||||
CONSTRAINT fk_plan_sessions_plan FOREIGN KEY (plan_id) REFERENCES training_plans(id) ON DELETE CASCADE,
|
||||
INDEX idx_plan_sessions_order (plan_id, week_number, sort_order)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||
|
||||
CREATE TABLE training_session_exercises (
|
||||
session_id BIGINT UNSIGNED NOT NULL,
|
||||
exercise_id BIGINT UNSIGNED NOT NULL,
|
||||
sort_order SMALLINT UNSIGNED NOT NULL DEFAULT 0,
|
||||
sets TINYINT UNSIGNED NULL,
|
||||
reps VARCHAR(60) NULL,
|
||||
duration_seconds SMALLINT UNSIGNED NULL,
|
||||
rest_seconds SMALLINT UNSIGNED NOT NULL DEFAULT 45,
|
||||
PRIMARY KEY (session_id, exercise_id),
|
||||
CONSTRAINT fk_session_exercises_session FOREIGN KEY (session_id) REFERENCES training_plan_sessions(id) ON DELETE CASCADE,
|
||||
CONSTRAINT fk_session_exercises_exercise FOREIGN KEY (exercise_id) REFERENCES exercises(id) ON DELETE RESTRICT
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||
@@ -51,6 +51,79 @@ CREATE TABLE app_setting_audit (
|
||||
CONSTRAINT fk_settings_audit_actor FOREIGN KEY (actor_user_id) REFERENCES users(id) ON DELETE SET NULL
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||
|
||||
-- Curated workout library. GIFs are optional, versioned media URLs, not uploaded inline.
|
||||
CREATE TABLE exercises (
|
||||
id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
|
||||
exercise_uuid CHAR(36) NOT NULL UNIQUE,
|
||||
slug VARCHAR(100) NOT NULL UNIQUE,
|
||||
title_ar VARCHAR(160) NOT NULL,
|
||||
title_en VARCHAR(160) NULL,
|
||||
instructions_ar JSON NOT NULL,
|
||||
target_muscles JSON NOT NULL,
|
||||
equipment JSON NOT NULL,
|
||||
difficulty ENUM('beginner', 'intermediate', 'advanced') NOT NULL DEFAULT 'beginner',
|
||||
movement_type ENUM('strength', 'mobility', 'cardio', 'recovery') NOT NULL,
|
||||
gif_url VARCHAR(500) NULL,
|
||||
gif_poster_url VARCHAR(500) NULL,
|
||||
duration_seconds SMALLINT UNSIGNED NULL,
|
||||
repetitions VARCHAR(80) NULL,
|
||||
safety_notes_ar JSON NOT NULL,
|
||||
alternative_exercise_id BIGINT UNSIGNED NULL,
|
||||
is_published BOOLEAN NOT NULL DEFAULT FALSE,
|
||||
content_revision INT UNSIGNED NOT NULL DEFAULT 1,
|
||||
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||
INDEX idx_exercises_public (is_published, movement_type, difficulty),
|
||||
CONSTRAINT fk_exercise_alternative FOREIGN KEY (alternative_exercise_id) REFERENCES exercises(id) ON DELETE SET NULL
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||
|
||||
CREATE TABLE training_plans (
|
||||
id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
|
||||
plan_uuid CHAR(36) NOT NULL UNIQUE,
|
||||
slug VARCHAR(100) NOT NULL UNIQUE,
|
||||
title_ar VARCHAR(160) NOT NULL,
|
||||
summary_ar TEXT NOT NULL,
|
||||
goal ENUM('general_fitness', 'weight_management', 'mobility', 'endurance') NOT NULL,
|
||||
level ENUM('beginner', 'intermediate', 'advanced') NOT NULL,
|
||||
weeks_duration TINYINT UNSIGNED NOT NULL,
|
||||
source_notes JSON NULL,
|
||||
safety_notes_ar JSON NOT NULL,
|
||||
is_published BOOLEAN NOT NULL DEFAULT FALSE,
|
||||
content_revision INT UNSIGNED NOT NULL DEFAULT 1,
|
||||
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||
INDEX idx_plans_public (is_published, goal, level)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||
|
||||
CREATE TABLE training_plan_sessions (
|
||||
id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
|
||||
plan_id BIGINT UNSIGNED NOT NULL,
|
||||
week_number TINYINT UNSIGNED NOT NULL,
|
||||
day_number TINYINT UNSIGNED NOT NULL,
|
||||
title_ar VARCHAR(160) NOT NULL,
|
||||
session_type ENUM('strength', 'walking', 'mobility', 'rest') NOT NULL,
|
||||
duration_minutes SMALLINT UNSIGNED NOT NULL DEFAULT 0,
|
||||
intensity ENUM('easy', 'moderate', 'vigorous') NOT NULL DEFAULT 'easy',
|
||||
notes_ar TEXT NULL,
|
||||
sort_order SMALLINT UNSIGNED NOT NULL DEFAULT 0,
|
||||
UNIQUE KEY uq_plan_week_day (plan_id, week_number, day_number),
|
||||
CONSTRAINT fk_plan_sessions_plan FOREIGN KEY (plan_id) REFERENCES training_plans(id) ON DELETE CASCADE,
|
||||
INDEX idx_plan_sessions_order (plan_id, week_number, sort_order)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||
|
||||
CREATE TABLE training_session_exercises (
|
||||
session_id BIGINT UNSIGNED NOT NULL,
|
||||
exercise_id BIGINT UNSIGNED NOT NULL,
|
||||
sort_order SMALLINT UNSIGNED NOT NULL DEFAULT 0,
|
||||
sets TINYINT UNSIGNED NULL,
|
||||
reps VARCHAR(60) NULL,
|
||||
duration_seconds SMALLINT UNSIGNED NULL,
|
||||
rest_seconds SMALLINT UNSIGNED NOT NULL DEFAULT 45,
|
||||
PRIMARY KEY (session_id, exercise_id),
|
||||
CONSTRAINT fk_session_exercises_session FOREIGN KEY (session_id) REFERENCES training_plan_sessions(id) ON DELETE CASCADE,
|
||||
CONSTRAINT fk_session_exercises_exercise FOREIGN KEY (exercise_id) REFERENCES exercises(id) ON DELETE RESTRICT
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||
|
||||
-- Phone OTP challenges contain keyed digests, never the plaintext code.
|
||||
CREATE TABLE otp_challenges (
|
||||
challenge_uuid CHAR(36) PRIMARY KEY,
|
||||
|
||||
Reference in New Issue
Block a user