query( "SELECT u.userID, u.username, u.role, u.entityID, u.mustChangePassword, u.createdAt, COALESCE(a.location, s.location, '') AS location, c.supervisorID, sv.username AS supervisorUsername FROM users u LEFT JOIN admin a ON a.adminID = u.entityID AND u.role = 'admin' LEFT JOIN supervisor s ON s.supervisorID = u.entityID AND u.role = 'supervisor' LEFT JOIN coach c ON c.coachID = u.entityID AND u.role = 'coach' LEFT JOIN supervisor sv ON sv.supervisorID = c.supervisorID ORDER BY u.role, u.username" )->fetchAll(PDO::FETCH_ASSOC); $supervisors = $pdo->query( "SELECT supervisorID, username, location FROM supervisor ORDER BY username" )->fetchAll(PDO::FETCH_ASSOC); } else { $svNameStmt = $pdo->prepare("SELECT username FROM supervisor WHERE supervisorID = :svid"); $svNameStmt->execute([':svid' => $callerEntityID]); $svName = $svNameStmt->fetchColumn() ?: ''; $stmt = $pdo->prepare( "SELECT u.userID, u.username, u.role, u.entityID, u.mustChangePassword, u.createdAt, '' AS location, c.supervisorID, :svname AS supervisorUsername FROM users u JOIN coach c ON c.coachID = u.entityID AND u.role = 'coach' WHERE c.supervisorID = :svid ORDER BY u.username" ); $stmt->execute([':svid' => $callerEntityID, ':svname' => $svName]); $users = $stmt->fetchAll(PDO::FETCH_ASSOC); $supervisors = []; } qdb_discard($tmpDb, $lockFp); json_success([ 'users' => $users, 'supervisors' => $supervisors, 'callerRole' => $callerRole, ]); } catch (Throwable $e) { qdb_handler_fail($e, 'users', null, $tmpDb ?? null, $lockFp ?? null); } break; case 'POST': $body = read_json_body(); $username = trim($body['username'] ?? ''); $password = (string)($body['password'] ?? ''); $role = strtolower(trim($body['role'] ?? '')); $location = trim($body['location'] ?? ''); $supervisorID = trim($body['supervisorID'] ?? ''); $mustChange = isset($body['mustChangePassword']) ? (int)(bool)$body['mustChangePassword'] : 1; if ($username === '' || $password === '' || $role === '') { json_error('MISSING_FIELDS', 'username, password, and role are required', 400); } if (!in_array($role, ['admin', 'supervisor', 'coach'], true)) { json_error('INVALID_ROLE', 'role must be admin, supervisor, or coach', 400); } if (strlen($password) < 6) { json_error('PASSWORD_TOO_SHORT', 'Password must be at least 6 characters', 400); } if ($callerRole === 'supervisor' && $role !== 'coach') { json_error('FORBIDDEN', 'Supervisors may only create coaches', 403); } if ($callerRole === 'supervisor') { $supervisorID = $callerEntityID; } if ($role === 'coach' && $supervisorID === '') { json_error('MISSING_FIELDS', 'supervisorID is required for coaches', 400); } try { [$pdo, $tmpDb, $lockFp] = qdb_open_write_or_fail(); $chk = $pdo->prepare("SELECT userID FROM users WHERE username = :u"); $chk->execute([':u' => $username]); if ($chk->fetch()) { qdb_discard($tmpDb, $lockFp); json_error('DUPLICATE', 'Username already exists', 409); } if ($role === 'coach') { $svCheck = $pdo->prepare("SELECT supervisorID FROM supervisor WHERE supervisorID = :sid"); $svCheck->execute([':sid' => $supervisorID]); if (!$svCheck->fetch()) { qdb_discard($tmpDb, $lockFp); json_error('NOT_FOUND', 'Supervisor not found', 400); } if ($callerRole === 'supervisor' && $supervisorID !== $callerEntityID) { qdb_discard($tmpDb, $lockFp); json_error('FORBIDDEN', 'Cannot create coaches for another supervisor', 403); } } $entityID = bin2hex(random_bytes(16)); $userID = bin2hex(random_bytes(16)); $hash = password_hash($password, PASSWORD_DEFAULT); $now = time(); $pdo->beginTransaction(); switch ($role) { case 'admin': $pdo->prepare("INSERT INTO admin (adminID, username, location) VALUES (:id, :u, :loc)") ->execute([':id' => $entityID, ':u' => $username, ':loc' => $location]); break; case 'supervisor': $pdo->prepare("INSERT INTO supervisor (supervisorID, username, location) VALUES (:id, :u, :loc)") ->execute([':id' => $entityID, ':u' => $username, ':loc' => $location]); break; case 'coach': $pdo->prepare("INSERT INTO coach (coachID, supervisorID, username) VALUES (:id, :sid, :u)") ->execute([':id' => $entityID, ':sid' => $supervisorID, ':u' => $username]); break; } $pdo->prepare( "INSERT INTO users (userID, username, passwordHash, role, entityID, mustChangePassword, createdAt) VALUES (:uid, :u, :hash, :role, :eid, :mcp, :now)" )->execute([ ':uid' => $userID, ':u' => $username, ':hash' => $hash, ':role' => $role, ':eid' => $entityID, ':mcp' => $mustChange, ':now' => $now, ]); $pdo->commit(); qdb_save($tmpDb, $lockFp); $svUsername = null; if ($role === 'coach') { [$pdo2, $tmp2, $lk2] = qdb_open_read_or_fail(); $svr = $pdo2->prepare("SELECT username FROM supervisor WHERE supervisorID = :sid"); $svr->execute([':sid' => $supervisorID]); $svUsername = $svr->fetchColumn() ?: null; qdb_discard($tmp2, $lk2); } json_success([ 'userID' => $userID, 'entityID' => $entityID, 'username' => $username, 'role' => $role, 'location' => $location, 'supervisorID' => $role === 'coach' ? $supervisorID : null, 'supervisorUsername' => $svUsername, 'mustChangePassword' => $mustChange, 'createdAt' => $now, ]); } catch (Throwable $e) { qdb_handler_fail($e, 'users', $pdo ?? null, $tmpDb ?? null, $lockFp ?? null); } break; case 'PUT': case 'PATCH': $body = read_json_body(); $targetUserID = trim($body['userID'] ?? ''); $action = trim((string)($body['action'] ?? '')); $newPassword = (string)($body['password'] ?? $body['newPassword'] ?? ''); if ($action === 'revokeSessions') { if ($targetUserID === '') { json_error('MISSING_FIELDS', 'userID is required', 400); } if ($targetUserID === ($tokenRec['userID'] ?? '')) { json_error('FORBIDDEN', 'Use Admin settings to revoke your own other sessions, or sign out', 400); } try { [$pdo, $tmpDb, $lockFp] = qdb_open_write_or_fail(); $row = $pdo->prepare('SELECT userID, username, role, entityID FROM users WHERE userID = :uid'); $row->execute([':uid' => $targetUserID]); $target = $row->fetch(PDO::FETCH_ASSOC); if (!$target) { qdb_discard($tmpDb, $lockFp); json_error('NOT_FOUND', 'User not found', 404); } $targetRole = $target['role'] ?? ''; if ($callerRole === 'admin') { if (!in_array($targetRole, ['admin', 'supervisor', 'coach'], true)) { qdb_discard($tmpDb, $lockFp); json_error('FORBIDDEN', 'Cannot revoke sessions for this user', 403); } } elseif ($callerRole === 'supervisor') { if ($targetRole !== 'coach') { qdb_discard($tmpDb, $lockFp); json_error('FORBIDDEN', 'Supervisors may only revoke sessions for their coaches', 403); } $chk = $pdo->prepare( 'SELECT coachID FROM coach WHERE coachID = :cid AND supervisorID = :svid' ); $chk->execute([':cid' => $target['entityID'], ':svid' => $callerEntityID]); if (!$chk->fetch()) { qdb_discard($tmpDb, $lockFp); json_error('FORBIDDEN', 'Coach is not under your supervision', 403); } } else { qdb_discard($tmpDb, $lockFp); json_error('FORBIDDEN', 'Not allowed to revoke sessions', 403); } $removed = token_revoke_all_for_user_on_pdo($pdo, $targetUserID); qdb_save($tmpDb, $lockFp); json_success([ 'userID' => $targetUserID, 'username' => $target['username'], 'revokedSessions' => $removed, ]); } catch (Throwable $e) { qdb_handler_fail($e, 'Revoke user sessions', null, $tmpDb ?? null, $lockFp ?? null); } break; } if ($newPassword !== '') { if ($targetUserID === '') { json_error('MISSING_FIELDS', 'userID is required', 400); } if ($targetUserID === ($tokenRec['userID'] ?? '')) { json_error('FORBIDDEN', 'Cannot reset your own password', 400); } if (strlen($newPassword) < 6) { json_error('PASSWORD_TOO_SHORT', 'Password must be at least 6 characters', 400); } $mustChange = isset($body['mustChangePassword']) ? (int)(bool)$body['mustChangePassword'] : 1; try { [$pdo, $tmpDb, $lockFp] = qdb_open_write_or_fail(); $row = $pdo->prepare( "SELECT u.userID, u.username, u.role, u.entityID, c.supervisorID FROM users u LEFT JOIN coach c ON c.coachID = u.entityID AND u.role = 'coach' WHERE u.userID = :uid" ); $row->execute([':uid' => $targetUserID]); $target = $row->fetch(PDO::FETCH_ASSOC); if (!$target) { qdb_discard($tmpDb, $lockFp); json_error('NOT_FOUND', 'User not found', 404); } $targetRole = $target['role'] ?? ''; if ($callerRole === 'admin') { if (!in_array($targetRole, ['supervisor', 'coach'], true)) { qdb_discard($tmpDb, $lockFp); json_error('FORBIDDEN', 'Admins may only reset passwords for supervisors and coaches', 403); } } elseif ($callerRole === 'supervisor') { if ($targetRole !== 'coach') { qdb_discard($tmpDb, $lockFp); json_error('FORBIDDEN', 'Supervisors may only reset passwords for their coaches', 403); } $chk = $pdo->prepare( 'SELECT coachID FROM coach WHERE coachID = :cid AND supervisorID = :svid' ); $chk->execute([':cid' => $target['entityID'], ':svid' => $callerEntityID]); if (!$chk->fetch()) { qdb_discard($tmpDb, $lockFp); json_error('FORBIDDEN', 'Coach is not under your supervision', 403); } } else { qdb_discard($tmpDb, $lockFp); json_error('FORBIDDEN', 'Not allowed to reset passwords', 403); } $hash = password_hash($newPassword, PASSWORD_DEFAULT); $pdo->prepare( 'UPDATE users SET passwordHash = :h, mustChangePassword = :mcp WHERE userID = :uid' )->execute([':h' => $hash, ':mcp' => $mustChange, ':uid' => $targetUserID]); token_revoke_all_for_user_on_pdo($pdo, $targetUserID); qdb_save($tmpDb, $lockFp); json_success([ 'userID' => $targetUserID, 'username' => $target['username'], 'mustChangePassword' => $mustChange, ]); } catch (Throwable $e) { qdb_handler_fail($e, 'users', null, $tmpDb ?? null, $lockFp ?? null); } break; } if ($callerRole !== 'admin') { json_error('FORBIDDEN', 'Only admins can reassign coaches', 403); } $supervisorID = trim($body['supervisorID'] ?? ''); if ($targetUserID === '' || $supervisorID === '') { json_error('MISSING_FIELDS', 'userID and supervisorID are required', 400); } try { [$pdo, $tmpDb, $lockFp] = qdb_open_write_or_fail(); $row = $pdo->prepare( "SELECT u.userID, u.username, u.role, u.entityID, c.supervisorID FROM users u LEFT JOIN coach c ON c.coachID = u.entityID AND u.role = 'coach' WHERE u.userID = :uid" ); $row->execute([':uid' => $targetUserID]); $target = $row->fetch(PDO::FETCH_ASSOC); if (!$target) { qdb_discard($tmpDb, $lockFp); json_error('NOT_FOUND', 'User not found', 404); } if (($target['role'] ?? '') !== 'coach') { qdb_discard($tmpDb, $lockFp); json_error('INVALID_FIELD', 'Only coaches can be reassigned to a supervisor', 400); } $svCheck = $pdo->prepare('SELECT username FROM supervisor WHERE supervisorID = :sid'); $svCheck->execute([':sid' => $supervisorID]); $svUsername = $svCheck->fetchColumn(); if ($svUsername === false) { qdb_discard($tmpDb, $lockFp); json_error('NOT_FOUND', 'Supervisor not found', 404); } if (($target['supervisorID'] ?? '') === $supervisorID) { qdb_discard($tmpDb, $lockFp); json_success([ 'userID' => $targetUserID, 'supervisorID' => $supervisorID, 'supervisorUsername' => $svUsername, ]); } $pdo->prepare('UPDATE coach SET supervisorID = :sid WHERE coachID = :cid') ->execute([':sid' => $supervisorID, ':cid' => $target['entityID']]); qdb_save($tmpDb, $lockFp); json_success([ 'userID' => $targetUserID, 'supervisorID' => $supervisorID, 'supervisorUsername' => $svUsername, ]); } catch (Throwable $e) { qdb_handler_fail($e, 'users', null, $tmpDb ?? null, $lockFp ?? null); } break; case 'DELETE': $body = read_json_body(); $targetUserID = trim($body['userID'] ?? ''); if ($targetUserID === '') { json_error('MISSING_FIELDS', 'userID is required', 400); } if ($targetUserID === ($tokenRec['userID'] ?? '')) { json_error('FORBIDDEN', 'Cannot delete your own account', 400); } try { [$pdo, $tmpDb, $lockFp] = qdb_open_write_or_fail(); $row = $pdo->prepare("SELECT role, entityID FROM users WHERE userID = :uid"); $row->execute([':uid' => $targetUserID]); $target = $row->fetch(PDO::FETCH_ASSOC); if (!$target) { qdb_discard($tmpDb, $lockFp); json_error('NOT_FOUND', 'User not found', 404); } if ($callerRole === 'supervisor') { if ($target['role'] !== 'coach') { qdb_discard($tmpDb, $lockFp); json_error('FORBIDDEN', 'Supervisors can only delete coaches', 403); } $chk = $pdo->prepare("SELECT coachID FROM coach WHERE coachID = :cid AND supervisorID = :svid"); $chk->execute([':cid' => $target['entityID'], ':svid' => $callerEntityID]); if (!$chk->fetch()) { qdb_discard($tmpDb, $lockFp); json_error('FORBIDDEN', 'Coach is not under your supervision', 403); } } $pdo->beginTransaction(); $pdo->prepare("DELETE FROM users WHERE userID = :uid")->execute([':uid' => $targetUserID]); switch ($target['role']) { case 'admin': $pdo->prepare("DELETE FROM admin WHERE adminID = :id")->execute([':id' => $target['entityID']]); break; case 'supervisor': $pdo->prepare("DELETE FROM supervisor WHERE supervisorID = :id")->execute([':id' => $target['entityID']]); break; case 'coach': $pdo->prepare("DELETE FROM coach WHERE coachID = :id")->execute([':id' => $target['entityID']]); break; } $pdo->commit(); qdb_save($tmpDb, $lockFp); json_success([]); } catch (Throwable $e) { qdb_handler_fail($e, 'users', $pdo ?? null, $tmpDb ?? null, $lockFp ?? null); } break; default: json_error('METHOD_NOT_ALLOWED', 'Method not allowed', 405); }