togethere.cloud/public_html/api/match_service.php

427 lines
17 KiB
PHP

<?php
/**
* Lightweight service layer for match CRUD + sync operations.
* Extracted so it can be reused by API endpoints and tests.
*/
class MatchService
{
private $pdo;
private $validator;
private $allowedStatuses = ['planned', 'live', 'end'];
private $columnCache = [];
public function __construct(PDO $pdo, $validator = null)
{
$this->pdo = $pdo;
$this->validator = $validator; // Optional GameValidator
}
private function hasColumn($table, $column)
{
$key = strtolower($table . '.' . $column);
if (array_key_exists($key, $this->columnCache)) {
return $this->columnCache[$key];
}
try {
$stmt = $this->pdo->prepare('SHOW COLUMNS FROM ' . $table . ' LIKE :col');
$stmt->execute([':col' => $column]);
$exists = (bool)$stmt->fetch(PDO::FETCH_ASSOC);
$this->columnCache[$key] = $exists;
return $exists;
} catch (Throwable $e) {
$this->columnCache[$key] = false;
return false;
}
}
public function createMatch(array $payload, $userId)
{
$data = $this->normalizePayload($payload, true);
// Ensure optional fields have safe defaults for INSERT
$data += [
'end_time' => null,
'score' => null,
'rate' => 'free',
'participants' => $this->normalizeParticipants([])
];
// Optional: discipline + settings snapshot
$discipline = isset($payload['discipline']) ? trim((string)$payload['discipline']) : null;
$settingsSnapshot = null;
$settingsVersion = null;
if ($discipline) {
try {
require_once __DIR__ . '/DisciplineSettingsModel.php';
$model = new DisciplineSettingsModel($this->pdo);
$snap = $model->getSnapshot($discipline, null);
$settingsVersion = (int)($snap['settingsVersion'] ?? null);
$settingsSnapshot = json_encode($snap, JSON_UNESCAPED_UNICODE);
} catch (Throwable $e) {
// If snapshot fails, proceed without it
$settingsSnapshot = null;
$settingsVersion = null;
}
}
// Run server-side game validation if provided and game is finished
if ($this->validator && isset($data['status']) && $data['status'] === 'end' && isset($payload['gameData'])) {
$result = $this->validator->validateGameResult($payload['gameData']);
if (empty($result['valid'])) {
throw new InvalidArgumentException($this->formatValidatorErrors($result));
}
}
$this->pdo->beginTransaction();
try {
// Build dynamic INSERT to support new columns if present
$columns = ['Team1_ID','Team2_ID','StartTime','EndTime','Status','Score','Platform','MatchType','Rate','Participants'];
$params = [
':team1_id' => $data['team1_id'],
':team2_id' => $data['team2_id'],
':start_time' => $data['start_time'],
':end_time' => $data['end_time'],
':status' => $data['status'],
':score' => $data['score'],
':platform' => $data['platform'],
':match_type' => $data['match_type'],
':rate' => $data['rate'],
':participants' => $data['participants']
];
if ($discipline && $this->hasColumn('matches','Discipline')) {
$columns[] = 'Discipline';
$params[':discipline'] = $discipline;
}
if ($settingsVersion !== null && $this->hasColumn('matches','SettingsVersion')) {
$columns[] = 'SettingsVersion';
$params[':settings_version'] = $settingsVersion;
}
if ($settingsSnapshot !== null && $this->hasColumn('matches','SettingsSnapshot')) {
$columns[] = 'SettingsSnapshot';
$params[':settings_snapshot'] = $settingsSnapshot;
}
$placeholders = [];
foreach ($columns as $col) {
switch ($col) {
case 'Team1_ID': $placeholders[] = ':team1_id'; break;
case 'Team2_ID': $placeholders[] = ':team2_id'; break;
case 'StartTime': $placeholders[] = ':start_time'; break;
case 'EndTime': $placeholders[] = ':end_time'; break;
case 'Status': $placeholders[] = ':status'; break;
case 'Score': $placeholders[] = ':score'; break;
case 'Platform': $placeholders[] = ':platform'; break;
case 'MatchType': $placeholders[] = ':match_type'; break;
case 'Rate': $placeholders[] = ':rate'; break;
case 'Participants': $placeholders[] = ':participants'; break;
case 'Discipline': $placeholders[] = ':discipline'; break;
case 'SettingsVersion': $placeholders[] = ':settings_version'; break;
case 'SettingsSnapshot': $placeholders[] = ':settings_snapshot'; break;
}
}
$sql = 'INSERT INTO matches (' . implode(', ', $columns) . ') VALUES (' . implode(', ', $placeholders) . ')';
$stmt = $this->pdo->prepare($sql);
$stmt->execute($params);
$matchId = (int) $this->pdo->lastInsertId();
$record = $this->getMatch($matchId);
$this->pdo->commit();
return $record + ['created_by' => $userId];
} catch (Throwable $e) {
$this->pdo->rollBack();
throw $e;
}
}
public function updateMatch($matchId, array $payload, $userId)
{
$matchId = (int) $matchId;
if ($matchId <= 0) {
throw new InvalidArgumentException('Invalid match id');
}
// Ensure match exists
$existing = $this->getMatch($matchId);
if (!$existing) {
throw new InvalidArgumentException('Match not found');
}
$data = $this->normalizePayload($payload, false, $existing);
if ($this->validator && isset($data['status']) && $data['status'] === 'end' && isset($payload['gameData'])) {
$result = $this->validator->validateGameResult($payload['gameData']);
if (empty($result['valid'])) {
throw new InvalidArgumentException($this->formatValidatorErrors($result));
}
}
$set = [];
$params = [':id' => $matchId];
foreach (['team1_id' => 'Team1_ID', 'team2_id' => 'Team2_ID', 'start_time' => 'StartTime', 'end_time' => 'EndTime', 'status' => 'Status', 'score' => 'Score', 'platform' => 'Platform', 'match_type' => 'MatchType', 'rate' => 'Rate', 'participants' => 'Participants'] as $key => $column) {
if (array_key_exists($key, $data) && $data[$key] !== null) {
$set[] = "$column = :$key";
$params[":$key"] = $data[$key];
}
}
if (empty($set)) {
throw new InvalidArgumentException('No fields to update');
}
// Ensure EndTime is set when status becomes end
if (isset($data['status']) && $data['status'] === 'end' && !isset($data['end_time']) && empty($existing['EndTime'])) {
$set[] = 'EndTime = :auto_end_time';
$params[':auto_end_time'] = gmdate('Y-m-d H:i:s');
}
$this->pdo->beginTransaction();
try {
$sql = 'UPDATE matches SET ' . implode(', ', $set) . ' WHERE ID = :id';
$stmt = $this->pdo->prepare($sql);
$stmt->execute($params);
$record = $this->getMatch($matchId);
$this->pdo->commit();
return $record + ['updated_by' => $userId];
} catch (Throwable $e) {
$this->pdo->rollBack();
throw $e;
}
}
public function fetchUpdates($since = null, array $filters = [], $limit = 100)
{
$limit = max(1, min(500, (int) $limit));
$params = [':limit' => $limit];
$where = [];
if ($since) {
$this->assertDate($since, 'since');
$where[] = 'updated_at >= :since';
$params[':since'] = $since;
}
if (!empty($filters['status'])) {
if (!in_array($filters['status'], $this->allowedStatuses, true)) {
throw new InvalidArgumentException('Invalid status filter');
}
$where[] = 'Status = :status_filter';
$params[':status_filter'] = $filters['status'];
}
if (!empty($filters['team_id'])) {
$teamId = (int) $filters['team_id'];
if ($teamId <= 0) {
throw new InvalidArgumentException('Invalid team filter');
}
$where[] = '(Team1_ID = :team OR Team2_ID = :team)';
$params[':team'] = $teamId;
}
$selectCols = ['ID','Team1_ID','Team2_ID','StartTime','EndTime','Status','Score','Platform','MatchType','Rate','Participants','created_at','updated_at'];
if ($this->hasColumn('matches','Discipline')) $selectCols[] = 'Discipline';
if ($this->hasColumn('matches','SettingsVersion')) $selectCols[] = 'SettingsVersion';
if ($this->hasColumn('matches','SettingsSnapshot')) $selectCols[] = 'SettingsSnapshot';
$sql = 'SELECT ' . implode(', ', $selectCols) . ' FROM matches';
if (!empty($where)) {
$sql .= ' WHERE ' . implode(' AND ', $where);
}
$sql .= ' ORDER BY updated_at DESC, ID DESC LIMIT :limit';
$stmt = $this->pdo->prepare($sql);
foreach ($params as $key => $value) {
$stmt->bindValue($key, $value, $key === ':limit' ? PDO::PARAM_INT : PDO::PARAM_STR);
}
$stmt->execute();
return $stmt->fetchAll(PDO::FETCH_ASSOC);
}
private function normalizePayload(array $payload, $isCreate, array $existing = [])
{
$data = [];
if ($isCreate || isset($payload['team1_id'])) {
$team1 = (int) ($payload['team1_id'] ?? 0);
if ($team1 <= 0) {
throw new InvalidArgumentException('team1_id is required and must be positive');
}
$data['team1_id'] = $team1;
}
if ($isCreate || isset($payload['team2_id'])) {
$team2 = (int) ($payload['team2_id'] ?? 0);
if ($team2 <= 0) {
throw new InvalidArgumentException('team2_id is required and must be positive');
}
$data['team2_id'] = $team2;
}
if ($isCreate || isset($payload['startTime']) || isset($payload['start_time'])) {
$start = $payload['startTime'] ?? $payload['start_time'] ?? null;
if (!$start) {
throw new InvalidArgumentException('startTime is required');
}
$data['start_time'] = $this->normalizeDateTime($start, 'startTime');
}
if (isset($payload['endTime']) || isset($payload['end_time'])) {
$end = $payload['endTime'] ?? $payload['end_time'];
$data['end_time'] = $this->normalizeDateTime($end, 'endTime');
} elseif ($isCreate) {
$data['end_time'] = null;
}
if ($isCreate || isset($payload['status'])) {
$status = $payload['status'] ?? 'planned';
if (!in_array($status, $this->allowedStatuses, true)) {
throw new InvalidArgumentException('Invalid status value');
}
$data['status'] = $status;
}
if (isset($payload['score'])) {
$data['score'] = $this->normalizeScore($payload['score']);
} elseif ($isCreate) {
$data['score'] = null;
}
if ($isCreate || isset($payload['platform'])) {
$data['platform'] = $this->normalizeString($payload['platform'] ?? 'PC', 50, 'platform');
}
if ($isCreate || isset($payload['matchType']) || isset($payload['match_type'])) {
$matchType = $payload['matchType'] ?? $payload['match_type'] ?? 'friendly';
$data['match_type'] = $this->normalizeString($matchType, 50, 'matchType');
}
if (array_key_exists('rate', $payload)) {
$data['rate'] = $this->normalizeString($payload['rate'], 50, 'rate');
} elseif ($isCreate) {
$data['rate'] = 'free';
}
if (array_key_exists('participants', $payload)) {
$data['participants'] = $this->normalizeParticipants($payload['participants']);
} elseif ($isCreate) {
// Default participants include the creator when possible
$creator = isset($payload['creator_id']) ? (int) $payload['creator_id'] : null;
$data['participants'] = $this->normalizeParticipants($creator ? [$creator] : []);
}
if (!$isCreate && empty($data)) {
throw new InvalidArgumentException('No payload provided');
}
return $data;
}
private function normalizeScore($score)
{
$score = trim((string) $score);
if ($score === '') {
return null;
}
if (!preg_match('/^\\d{1,3}:\\d{1,3}$/', $score)) {
throw new InvalidArgumentException('Score must match format `X:Y`');
}
return $score;
}
private function normalizeParticipants($participants)
{
if ($participants === null || $participants === '') {
return json_encode([]);
}
if (!is_array($participants)) {
// Allow comma separated string
$participants = explode(',', (string) $participants);
}
$clean = [];
foreach ($participants as $value) {
$id = (int) trim((string) $value);
if ($id > 0) {
$clean[] = $id;
}
}
$clean = array_values(array_unique($clean));
return json_encode($clean);
}
private function normalizeString($value, $maxLength, $field)
{
$value = trim((string) ($value ?? ''));
if ($value === '') {
return null;
}
if (strlen($value) > $maxLength) {
throw new InvalidArgumentException($field . ' is too long');
}
return $value;
}
private function normalizeDateTime($value, $field)
{
$this->assertDate($value, $field);
return date('Y-m-d H:i:s', strtotime($value));
}
private function assertDate($value, $field)
{
if (!$value || strtotime($value) === false) {
throw new InvalidArgumentException($field . ' must be a valid datetime');
}
}
private function getMatch($matchId)
{
$cols = ['ID','Team1_ID','Team2_ID','StartTime','EndTime','Status','Score','Platform','MatchType','Rate','Participants','created_at','updated_at'];
if ($this->hasColumn('matches','Discipline')) $cols[] = 'Discipline';
if ($this->hasColumn('matches','SettingsVersion')) $cols[] = 'SettingsVersion';
if ($this->hasColumn('matches','SettingsSnapshot')) $cols[] = 'SettingsSnapshot';
$sql = 'SELECT ' . implode(', ', $cols) . ' FROM matches WHERE ID = :id';
$stmt = $this->pdo->prepare($sql);
$stmt->execute([':id' => $matchId]);
return $stmt->fetch(PDO::FETCH_ASSOC);
}
private function formatValidatorErrors(array $result)
{
if (!empty($result['errors']) && is_array($result['errors'])) {
return 'Validation failed: ' . implode('; ', $result['errors']);
}
return 'Validation failed';
}
}
// Utilities
class MatchServiceSchemaHelper {
public static function columnExists(PDO $pdo, $table, $column) {
$stmt = $pdo->prepare('SHOW COLUMNS FROM ' . $table . ' LIKE :col');
$stmt->execute([':col' => $column]);
return (bool)$stmt->fetch(PDO::FETCH_ASSOC);
}
}
// Inject helper method into MatchService via trait-like approach
if (!method_exists('MatchService','hasColumn')) {
MatchService::class;
}
// Add method to MatchService (defined inline)
// Note: PHP doesn't support adding methods dynamically; define inside class above. We already added property $columnCache.
// Implement function here by re-opening file content through patch in class (done earlier via hasColumn usage).