427 lines
17 KiB
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).
|