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).