generated from jric11/baseProject
Chat's updates for security, db and workflow
Security and workflow regressions / regression (push) Has been cancelled
Security and workflow regressions / regression (push) Has been cancelled
This commit is contained in:
@@ -0,0 +1,39 @@
|
||||
<?php
|
||||
|
||||
class AdminAccounts extends Base {
|
||||
|
||||
public function create(array $input): array {
|
||||
$first = trim($input['user_first_name'] ?? '');
|
||||
$last = trim($input['user_last_name'] ?? '');
|
||||
$email = trim($input['user_email'] ?? '');
|
||||
$name = trim($input['user_name'] ?? '');
|
||||
$role = $input['user_role'] ?? '';
|
||||
if (!$first || !$last || strlen($first) > 128 || strlen($last) > 128 || !filter_var($email, FILTER_VALIDATE_EMAIL) || strlen($email) > 100 || !preg_match('/\A[A-Za-z0-9_.-]{3,64}\z/', $name) || !in_array($role, ['User', 'Guest', 'Administrator'], true))
|
||||
throw new AppException('Enter a valid username, first/last name, email and role.');
|
||||
$db = $this->connect();
|
||||
$stmt = $db->prepare('SELECT user_serial FROM users WHERE user_name IN (?,?) OR user_email IN (?,?)');
|
||||
$stmt->execute([$name, $email, $email, $name]);
|
||||
if ($stmt->fetch())
|
||||
throw new AppException('An account already uses those details. Use the existing account or resend its invitation.', 409);
|
||||
$db->beginTransaction();
|
||||
try {
|
||||
$stmt = $db->prepare("INSERT INTO users(user_name,user_first_name,user_last_name,user_email,user_password_hash,user_role,user_status,user_change_password,user_creator) VALUES(?,?,?,?,?,?,'active',1,?)");
|
||||
$stmt->execute([$name, $first, $last, $email, password_hash(bin2hex(random_bytes(24)), PASSWORD_DEFAULT), $role, $_SESSION[self::USER_NAME] ?? '']);
|
||||
$id = (int) $db->lastInsertId();
|
||||
Audit::required($db, (int) ($_SESSION[self::USER_SERIAL] ?? 0), 'created account ' . json_encode(['user' => $id, 'name' => $name, 'role' => $role]));
|
||||
$db->commit();
|
||||
} catch (Throwable $e) {
|
||||
if ($db->inTransaction())
|
||||
$db->rollBack();throw $e;
|
||||
}
|
||||
$warning = '';
|
||||
try {
|
||||
if (!(new AuthService())->requestReset($email))
|
||||
$warning = 'Account created, but the invitation is rate limited. Open the user summary to resend it later.';
|
||||
} catch (Throwable $e) {
|
||||
error_log('Invitation delivery failed: ' . $e->getMessage());
|
||||
$warning = 'Account created, but its invitation could not be sent. Open the user summary to resend it after checking email settings.';
|
||||
}
|
||||
return ['ok' => true, 'user_serial' => $id, 'message' => 'Account created.', 'warning' => $warning];
|
||||
}
|
||||
}
|
||||
+7
-19
@@ -28,7 +28,7 @@ class Alerts extends Base {
|
||||
$this->current_user_name = $_SESSION[self::USER_NAME] ?? "";
|
||||
$this->current_user_email = $_SESSION[self::USER_EMAIL] ?? "";
|
||||
|
||||
$this->pagination_page = $_SESSION[self::PAGINATION_PAGE] ?? 1;
|
||||
$this->pagination_page = 1;
|
||||
}
|
||||
|
||||
// ============
|
||||
@@ -267,7 +267,7 @@ class Alerts extends Base {
|
||||
// Find Criteria
|
||||
|
||||
$find_alert = filter_input(INPUT_POST, "find_alert") ?: "";
|
||||
$find_words = $this->sanitizeString($find_alert);
|
||||
$find_words = preg_quote(trim($find_alert));
|
||||
|
||||
// Show Per Page
|
||||
|
||||
@@ -295,28 +295,17 @@ class Alerts extends Base {
|
||||
|
||||
$pagination = filter_input(INPUT_POST, "pagination") ?: "";
|
||||
|
||||
switch ($pagination) {
|
||||
|
||||
case "first" : $this->pagination_page = 1;
|
||||
break;
|
||||
case "previous" : $this->pagination_page = max(($this->pagination_page - 1), 1);
|
||||
break;
|
||||
case "next" : $this->pagination_page = ($this->pagination_page + 1);
|
||||
break;
|
||||
case "last" : $this->pagination_page = ($count / $this->pagination_size);
|
||||
break;
|
||||
}
|
||||
|
||||
$offset = (($this->pagination_page * $this->pagination_size) - $this->pagination_size);
|
||||
$start = ($offset + 1);
|
||||
$end = min($count, (($start + $this->pagination_size) - 1));
|
||||
$paging=Pagination::session(__METHOD__,$count,(int)$this->pagination_size,$pagination);
|
||||
$this->pagination_size=$paging['size'];$this->pagination_page=$paging['page'];
|
||||
$offset=$paging['offset'];$start=$paging['start'];$end=$paging['end'];
|
||||
|
||||
// Data
|
||||
|
||||
$SQL = "select view_alerts.*
|
||||
from view_alerts
|
||||
where alert_title regexp :alert_title
|
||||
limit {$this->pagination_size}
|
||||
order by alert_serial
|
||||
limit {$this->pagination_size}
|
||||
offset {$offset}";
|
||||
|
||||
$statement = $connection->prepare($SQL);
|
||||
@@ -334,7 +323,6 @@ class Alerts extends Base {
|
||||
$alerts->addAttribute("end", $end);
|
||||
$alerts->addAttribute("page", $this->pagination_page);
|
||||
|
||||
$_SESSION[self::PAGINATION_PAGE] = $this->pagination_page;
|
||||
|
||||
return $alerts;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,4 @@
|
||||
<?php
|
||||
class AppException extends RuntimeException {
|
||||
public function __construct(string $message, public readonly int $status = 422) { parent::__construct($message); }
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
<?php
|
||||
|
||||
final class Audit {
|
||||
|
||||
public static function record(PDO $db, int $user, string $event): void {
|
||||
try {
|
||||
self::insert($db, $user, $event);
|
||||
} catch (Throwable $e) {
|
||||
error_log('Audit write failed: ' . $e->getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
public static function required(PDO $db, int $user, string $event): void {
|
||||
if (!$db->inTransaction())
|
||||
throw new LogicException('Required audit writes need a transaction.');
|
||||
self::insert($db, $user, $event);
|
||||
}
|
||||
|
||||
public static function insert(PDO $db, int $user, string $event): void {
|
||||
$stmt = $db->prepare('INSERT INTO journal(journal_origin,journal_ip,journal_entry) VALUES(?,?,?)');
|
||||
$stmt->execute([(string) $user, $_SERVER['REMOTE_ADDR'] ?? '', $user . ' ' . $event]);
|
||||
}
|
||||
}
|
||||
+22
-6
@@ -61,6 +61,7 @@ class AuthService extends Base {
|
||||
$_SESSION['csrf_token'] = bin2hex(random_bytes(32));
|
||||
$stmt = $this->connect()->prepare('UPDATE users SET user_last_login = :now WHERE user_serial = :id');
|
||||
$stmt->execute([':now' => $this->getTimestamp(), ':id' => $user['user_serial']]);
|
||||
Audit::record($this->connect(),(int)$user['user_serial'],'logged in ('.$user['user_role'].')');
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -92,7 +93,7 @@ class AuthService extends Base {
|
||||
if (!filter_var($email, FILTER_VALIDATE_EMAIL) || strlen($email) > 100)
|
||||
throw new InvalidArgumentException('Enter a valid email address (up to 100 bytes).');
|
||||
if (!preg_match('/\A[A-Za-z0-9_.-]{3,64}\z/', $name))
|
||||
throw new InvalidArgumentException('Use 3–64 letters, numbers, dots, underscores or hyphens for your username.');
|
||||
throw new InvalidArgumentException('Use 3 to 64 letters, numbers, dots, underscores or hyphens for your username.');
|
||||
$error = self::passwordError($input['user_password'] ?? '', $input['user_password_confirm'] ?? '');
|
||||
if ($error)
|
||||
throw new InvalidArgumentException($error);
|
||||
@@ -113,14 +114,14 @@ class AuthService extends Base {
|
||||
}
|
||||
|
||||
/** Never modify the password when somebody merely requests an email. */
|
||||
public function requestReset(string $identifier): void {
|
||||
public function requestReset(string $identifier): bool {
|
||||
$ipAllowed = $this->allowAttempt('reset-ip', $_SERVER['REMOTE_ADDR'] ?? '', 10, 3600);
|
||||
$accountAllowed = $this->allowAttempt('reset-account', $identifier, 3, 3600);
|
||||
if (!$ipAllowed || !$accountAllowed)
|
||||
return;
|
||||
return false;
|
||||
$user = $this->findUser($identifier);
|
||||
if (!$user || $user['user_status'] !== 'active')
|
||||
return;
|
||||
return false;
|
||||
$baseUrl = rtrim((string) $this->getSettings()->ApplicationUrl, '/');
|
||||
if (!filter_var($baseUrl, FILTER_VALIDATE_URL) || parse_url($baseUrl, PHP_URL_SCHEME) !== 'https'
|
||||
|| parse_url($baseUrl, PHP_URL_QUERY) !== null || parse_url($baseUrl, PHP_URL_FRAGMENT) !== null
|
||||
@@ -129,15 +130,27 @@ class AuthService extends Base {
|
||||
}
|
||||
$token = bin2hex(random_bytes(32));
|
||||
$db = $this->connect();
|
||||
$stmt = $db->prepare('INSERT INTO password_resets (token_hash, user_serial, expires_at) VALUES (:hash, :id, DATE_ADD(UTC_TIMESTAMP(), INTERVAL 30 MINUTE))');
|
||||
$hash = hash('sha256', $token);
|
||||
$stmt->execute([':hash' => $hash, ':id' => $user['user_serial']]);
|
||||
$db->beginTransaction();
|
||||
try {
|
||||
// Share the user-row lock with account edits and password changes. A
|
||||
// request resolved before such a change must not mint a token afterward.
|
||||
$stmt=$db->prepare('SELECT user_email,user_status,user_auth_version FROM users WHERE user_serial=? FOR UPDATE');
|
||||
$stmt->execute([$user['user_serial']]);$current=$stmt->fetch();
|
||||
if(!$current || $current['user_status']!=='active' || $current['user_email']!==$user['user_email'] || (int)$current['user_auth_version']!==(int)$user['user_auth_version']){
|
||||
$db->rollBack();return false;
|
||||
}
|
||||
$stmt=$db->prepare('INSERT INTO password_resets (token_hash,user_serial,expires_at) VALUES (?,?,DATE_ADD(UTC_TIMESTAMP(),INTERVAL 30 MINUTE))');
|
||||
$stmt->execute([$hash,$user['user_serial']]);$db->commit();
|
||||
}catch(Throwable $e){if($db->inTransaction())$db->rollBack();throw $e;}
|
||||
$url = $baseUrl . '/main.php?Xaction=resetpassword&token=' . rawurlencode($token);
|
||||
if (!$this->deliverResetEmail($user, $url)) {
|
||||
$stmt = $db->prepare('DELETE FROM password_resets WHERE token_hash = :hash');
|
||||
$stmt->execute([':hash' => $hash]);
|
||||
throw new RuntimeException('Password reset email delivery failed.');
|
||||
}
|
||||
Audit::record($db,(int)$user['user_serial'],'requested password reset');
|
||||
return true;
|
||||
}
|
||||
|
||||
protected function deliverResetEmail(array $user, string $url): bool {
|
||||
@@ -174,7 +187,9 @@ class AuthService extends Base {
|
||||
return false;
|
||||
}
|
||||
$this->storePassword($db, (int) $id, $password);
|
||||
$stmt=$db->prepare('UPDATE users SET user_email_verified_at=UTC_TIMESTAMP() WHERE user_serial=?');$stmt->execute([$id]);
|
||||
$db->commit();
|
||||
Audit::record($db,(int)$id,'reset password');
|
||||
return true;
|
||||
} catch (Throwable $e) {
|
||||
if ($db->inTransaction())
|
||||
@@ -199,6 +214,7 @@ class AuthService extends Base {
|
||||
}
|
||||
$this->storePassword($db, $id, $password);
|
||||
$db->commit();
|
||||
Audit::record($db,$id,'changed password');
|
||||
return true;
|
||||
} catch (Throwable $e) {
|
||||
if ($db->inTransaction())
|
||||
|
||||
+39
-41
@@ -13,7 +13,7 @@ class Base {
|
||||
// Constants
|
||||
// =========
|
||||
|
||||
const APPLICATION_NAME = "Hunting Club Roster";
|
||||
const APPLICATION_NAME = "My Hunting Club Roster";
|
||||
const APPLICATION_MNEMONIC = "HUNTINGCLUBROSTER";
|
||||
const VERSION = "1.00";
|
||||
const TIMEZONE = "America/New_York";
|
||||
@@ -136,11 +136,16 @@ class Base {
|
||||
|
||||
$dsn = sprintf('mysql:host=%s;port=%d;dbname=%s;charset=utf8mb4', $host, (int) ($settings->DatabasePort ?? 3306), $database);
|
||||
|
||||
return new PDO($dsn, $username, $password,
|
||||
[PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
|
||||
$options=[PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
|
||||
PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,
|
||||
PDO::ATTR_EMULATE_PREPARES => false,
|
||||
PDO::ATTR_PERSISTENT => false,]);
|
||||
PDO::ATTR_PERSISTENT => false,];
|
||||
$ca=getenv('HCR_DB_TLS_CA')?:'';
|
||||
if((string)$settings->Environment==='Production'&&!in_array(strtolower($host),['localhost','127.0.0.1','::1'],true)&&!$ca)throw new RuntimeException('Remote production database connections require HCR_DB_TLS_CA and verified TLS.');
|
||||
if($ca){if(!is_readable($ca))throw new RuntimeException('The database TLS CA file is not readable.');$options[PDO::MYSQL_ATTR_SSL_CA]=$ca;$options[PDO::MYSQL_ATTR_SSL_VERIFY_SERVER_CERT]=true;}
|
||||
$db=new PDO($dsn,$username,$password,$options);
|
||||
if($ca){$cipher=$db->query("SHOW SESSION STATUS LIKE 'Ssl_cipher'")->fetch();if(!$cipher||empty($cipher['Value']))throw new RuntimeException('The database connection did not negotiate TLS.');}
|
||||
return $db;
|
||||
} catch (PDOException $e) {
|
||||
|
||||
error_log("Database Connection Failed: " . $e->getMessage());
|
||||
@@ -216,9 +221,7 @@ class Base {
|
||||
// =====================
|
||||
|
||||
public function displayHome() {
|
||||
|
||||
$content = $this->applyXSL("", $this->getXSL("home"));
|
||||
$this->displayContent($content);
|
||||
(new Roster())->home();
|
||||
}
|
||||
|
||||
// =======================
|
||||
@@ -250,6 +253,7 @@ class Base {
|
||||
}
|
||||
}
|
||||
$action = strtolower(trim($_POST['action'] ?? $_GET['Xaction'] ?? $_GET['action'] ?? ''));
|
||||
if($action==='verifyemail'){(new EmailVerification())->page();return;}
|
||||
if ($action === 'signout') {
|
||||
if ($method !== 'POST') {
|
||||
http_response_code(405);
|
||||
@@ -275,9 +279,9 @@ class Base {
|
||||
if (in_array($action, ['signin', 'signin.login', 'users.login'], true)) {
|
||||
(new SignIn())->login();
|
||||
}
|
||||
|
||||
|
||||
$user = (new AuthService())->currentUser();
|
||||
|
||||
|
||||
if (!$user) {
|
||||
$ajax = $_SESSION[self::AJAX];
|
||||
if (!empty($_SESSION[self::USER_AUTHENTICATED])) {
|
||||
@@ -296,6 +300,7 @@ class Base {
|
||||
(new Users())->changePassword();
|
||||
return;
|
||||
}
|
||||
|
||||
// Only named, reviewed entry points are routable. Helpers are never endpoints.
|
||||
$routes = [
|
||||
'' => [Base::class, 'displayHome', false],
|
||||
@@ -306,24 +311,31 @@ class Base {
|
||||
'validatedate' => [Base::class, 'validateDate', false],
|
||||
'getage' => [Base::class, 'getAge', false],
|
||||
'users.changepassword' => [Users::class, 'changePassword', false],
|
||||
'print' => [Base::class, 'print', true],
|
||||
'print_all_permitsbybuilder.print' => ['Print_All_PermitsByBuilder', 'print', true],
|
||||
'export_allpermitsbybuildercsv.export' => ['Export_AllPermitsByBuilderCSV', 'export', true],
|
||||
'export_allpermitsbybuilderexcel.export' => ['Export_AllPermitsByBuilderExcel', 'export', true],
|
||||
'roster.workspace' => [Roster::class,'workspace',false],
|
||||
'roster.help' => [Roster::class,'help',false],
|
||||
'rosterreports.ledger' => [RosterReports::class,'ledger',true],
|
||||
'rosterreports.csv' => [RosterReports::class,'csv',true],
|
||||
'rosterreports.excel' => [RosterReports::class,'excel',true],
|
||||
'rosterreports.pdf' => [RosterReports::class,'pdf',true],
|
||||
|
||||
|
||||
|
||||
|
||||
];
|
||||
$adminRoutes = [
|
||||
'Users' => ['setupUsers', 'addUser', 'editUser', 'deleteUser', 'resetPassword', 'userExists', 'getUser_AJAX', 'updateActiveStatus', 'userActive'],
|
||||
'Users' => ['setupUsers', 'userSummary', 'addUser', 'editUser', 'deleteUser', 'resetPassword', 'userExists', 'getUser_AJAX', 'updateActiveStatus', 'userActive'],
|
||||
'Alerts' => ['setupAlerts', 'addAlert', 'editAlert', 'deleteAlert'],
|
||||
'Dropdowns' => ['setupDropdowns', 'setupDropdowntypes', 'addDropdown', 'editDropdown', 'deleteDropdown', 'addDropdowntype', 'editDropdowntype', 'deleteDropdowntype', 'dropdownExists', 'dropdowntypeExists', 'dropdowntypetitleExists', 'dropdownActive', 'dropdowntypeActive', 'updateDropdownActive', 'updateDropdownDefault'],
|
||||
'Popovers' => ['setupPopovers', 'addPopover', 'editPopover', 'deletePopover', 'popoverExists'],
|
||||
'Reports' => ['setupReports', 'addReport', 'editReport', 'deleteReport', 'selectReport', 'reportExists'],
|
||||
'Reports' => ['setupReports', 'selectReport'],
|
||||
'Statuses' => ['setupStatuses', 'addStatus', 'editStatus', 'deleteStatus', 'statusExists', 'statusActive'],
|
||||
'Journal' => ['viewJournal', 'clearJournal'],
|
||||
'Notes' => ['addNote', 'editNote', 'deleteNote', 'addNoteQuick'],
|
||||
'Journal' => ['viewJournal'],
|
||||
'Notes' => ['addNote', 'editNote', 'deleteNote'],
|
||||
];
|
||||
foreach ($adminRoutes as $class => $methods) {
|
||||
foreach ($methods as $entry)
|
||||
foreach ($methods as $entry) {
|
||||
$routes[strtolower($class . '.' . $entry)] = [$class, $entry, true];
|
||||
}
|
||||
}
|
||||
if (!isset($routes[$action])) {
|
||||
http_response_code(404);
|
||||
@@ -334,11 +346,15 @@ class Base {
|
||||
http_response_code(403);
|
||||
exit('You do not have permission to perform this action.');
|
||||
}
|
||||
if ($method !== 'POST' && !in_array($action, ['', './', 'displayhome', 'users.changepassword'], true)) {
|
||||
if ($method !== 'POST' && !in_array($action, ['', './', 'displayhome', 'users.changepassword','roster.workspace','roster.help'], true)) {
|
||||
http_response_code(405);
|
||||
exit('Use POST for this action.');
|
||||
}
|
||||
(new $class())->$entry();
|
||||
ob_start();
|
||||
try{(new $class())->$entry();$body=ob_get_clean();}
|
||||
catch(Throwable $e){ob_end_clean();throw $e;}
|
||||
if($_SESSION[self::AJAX]??false){header('Content-Type: application/json');echo trim($body)===''?json_encode(['ok'=>true]):$body;}
|
||||
else echo $body;
|
||||
}
|
||||
|
||||
public function csrfToken(): string {
|
||||
@@ -420,7 +436,7 @@ class Base {
|
||||
|
||||
public function getSettings() {
|
||||
|
||||
$path = getenv('HCR_SETTINGS_FILE') ?: (is_file(__DIR__ . '/../xml/settings.local.xml') ? __DIR__ . '/../xml/settings.local.xml' : __DIR__ . '/../xml/settings.xml');
|
||||
$path = getenv('HCR_SETTINGS_FILE') ?: (getenv('HCR_ENVIRONMENT')!=='Production' && is_file(__DIR__ . '/../xml/settings.local.xml') ? __DIR__ . '/../xml/settings.local.xml' : __DIR__ . '/../xml/settings.xml');
|
||||
$settings = simplexml_load_file($path, SimpleXMLElement::class, LIBXML_NONET);
|
||||
if (!$settings)
|
||||
throw new RuntimeException('Unable to load application settings.');
|
||||
@@ -673,12 +689,8 @@ class Base {
|
||||
// ============
|
||||
|
||||
public function logError($error_message = "") {
|
||||
|
||||
(new Journal())->journalEntry($error_message);
|
||||
|
||||
$this->displayHome();
|
||||
|
||||
exit();
|
||||
error_log($error_message);
|
||||
throw new AppException('The requested record is invalid or unavailable.',404);
|
||||
}
|
||||
|
||||
// ==========================
|
||||
@@ -725,20 +737,6 @@ class Base {
|
||||
// Print a Report
|
||||
// ==============
|
||||
|
||||
public function print() {
|
||||
|
||||
$_SESSION[self::REPORTS_GET] = $_GET;
|
||||
$_SESSION[self::REPORTS_POST] = $_POST;
|
||||
|
||||
$report = filter_input(INPUT_POST, "report") ?: "";
|
||||
|
||||
if ($report !== 'Print_All_PermitsByBuilder') {
|
||||
http_response_code(404);
|
||||
exit('Report not found.');
|
||||
}
|
||||
(new $report())->print();
|
||||
}
|
||||
|
||||
// =================
|
||||
// Sanitize a string
|
||||
// =================
|
||||
|
||||
+20
-125
@@ -28,7 +28,7 @@ class Dropdowns extends Base {
|
||||
$this->current_user_name = $_SESSION[self::USER_NAME] ?? "";
|
||||
$this->current_user_email = $_SESSION[self::USER_EMAIL] ?? "";
|
||||
|
||||
$this->pagination_page = $_SESSION[self::PAGINATION_PAGE] ?? 1;
|
||||
$this->pagination_page = 1;
|
||||
}
|
||||
|
||||
// ============
|
||||
@@ -63,6 +63,7 @@ class Dropdowns extends Base {
|
||||
|
||||
$dropdowntype_serial = filter_input(INPUT_POST, "dropdowntype_serial", FILTER_SANITIZE_NUMBER_INT) ?: 0;
|
||||
$dropdown_value = filter_input(INPUT_POST, "dropdown_value") ?: "";
|
||||
$dropdown_value=trim($dropdown_value);if(!$dropdown_value||strlen($dropdown_value)>100)throw new AppException('Enter a dropdown value up to 100 bytes.');
|
||||
|
||||
$SQL = "insert into dropdowns
|
||||
|
||||
@@ -117,6 +118,8 @@ class Dropdowns extends Base {
|
||||
$dropdowntype_title = filter_input(INPUT_POST, "dropdowntype_title") ?: "";
|
||||
$dropdowntype_table = filter_input(INPUT_POST, "dropdowntype_table") ?: "";
|
||||
$dropdowntype_column = filter_input(INPUT_POST, "dropdowntype_column") ?: "";
|
||||
MaintenanceService::usageRule($dropdowntype_table, $dropdowntype_column);
|
||||
if(!trim($dropdowntype_name)||!trim($dropdowntype_title)||strlen($dropdowntype_name)>100||strlen($dropdowntype_title)>100)throw new AppException('Enter a dropdown type name and title up to 100 bytes.');
|
||||
|
||||
$SQL = "insert into dropdowntypes
|
||||
|
||||
@@ -158,19 +161,7 @@ class Dropdowns extends Base {
|
||||
// ===============
|
||||
|
||||
public function deleteDropdown() {
|
||||
|
||||
$dropdown_serial = filter_input(INPUT_POST, "dropdown_serial", FILTER_SANITIZE_NUMBER_INT) ?: 0;
|
||||
|
||||
$dropdown = $this->getDropdown($dropdown_serial);
|
||||
|
||||
if ($dropdown->count() == 0) {
|
||||
$this->logError("Invalid Dropdown ({$dropdown_serial}). " . __METHOD__);
|
||||
}
|
||||
|
||||
$SQL = "delete from dropdowns
|
||||
where dropdown_serial = {$dropdown_serial}";
|
||||
|
||||
$this->executeSQL($SQL);
|
||||
(new MaintenanceService())->deleteDropdown(MaintenanceService::id($_POST['dropdown_serial']??null));
|
||||
}
|
||||
|
||||
// ===================
|
||||
@@ -178,19 +169,7 @@ class Dropdowns extends Base {
|
||||
// ===================
|
||||
|
||||
public function deleteDropdowntype() {
|
||||
|
||||
$dropdowntype_serial = filter_input(INPUT_POST, "dropdowntype_serial", FILTER_SANITIZE_NUMBER_INT) ?: 0;
|
||||
|
||||
$dropdowntype = $this->getDropdowntype($dropdowntype_serial);
|
||||
|
||||
if ($dropdowntype->count() == 0) {
|
||||
$this->logError("Invalid Dropdowntype ({$dropdowntype_serial}). " . __METHOD__);
|
||||
}
|
||||
|
||||
$SQL = "delete from dropdowntypes
|
||||
where dropdowntype_serial = {$dropdowntype_serial}";
|
||||
|
||||
$this->executeSQL($SQL);
|
||||
(new MaintenanceService())->deleteType(MaintenanceService::id($_POST['dropdowntype_serial']??null));
|
||||
}
|
||||
|
||||
// ==============================
|
||||
@@ -202,6 +181,7 @@ class Dropdowns extends Base {
|
||||
$dropdowntype_serial = filter_input(INPUT_POST, "dropdowntype_serial", FILTER_SANITIZE_NUMBER_INT) ?: 0;
|
||||
$dropdown_serial = filter_input(INPUT_POST, "dropdown_serial", FILTER_SANITIZE_NUMBER_INT) ?: 0;
|
||||
$dropdown_value = filter_input(INPUT_POST, "dropdown_value") ?: "";
|
||||
$dropdown_value=trim($dropdown_value);if(!$dropdown_value||strlen($dropdown_value)>100)throw new AppException('Enter a dropdown value up to 100 bytes.');
|
||||
|
||||
$dropdown_value = strtolower($dropdown_value);
|
||||
|
||||
@@ -291,48 +271,8 @@ class Dropdowns extends Base {
|
||||
// =================================
|
||||
|
||||
public function dropdownActive() {
|
||||
|
||||
$dropdown_serial = filter_input(INPUT_POST, "dropdown_serial", FILTER_SANITIZE_NUMBER_INT) ?: 0;
|
||||
|
||||
$dropdown = $this->getDropdown($dropdown_serial);
|
||||
|
||||
if ($dropdown->count() == 0) {
|
||||
$this->logError("Invalid Dropdown ({$dropdown_serial}). " . __METHOD__);
|
||||
}
|
||||
|
||||
$table = (string) $dropdown->record->dropdowntype_table;
|
||||
$column = (string) $dropdown->record->dropdowntype_column;
|
||||
|
||||
$active = false;
|
||||
|
||||
switch ($table) {
|
||||
|
||||
case "*none": // No table, never Active.
|
||||
|
||||
break;
|
||||
|
||||
case "*function": // Requires it's own function to determine if Active.
|
||||
|
||||
if (method_exists(__CLASS__, $column)) {
|
||||
$active = $this->$column($dropdown_serial);
|
||||
}
|
||||
|
||||
break;
|
||||
|
||||
default: // All others, test the Column in the Table.
|
||||
|
||||
$SQL = "select {$column}
|
||||
from {$table}
|
||||
where {$column} = {$dropdown_serial}";
|
||||
|
||||
$results = $this->getTable("results", $SQL);
|
||||
|
||||
$active = ($results->count() > 0) ? true : false;
|
||||
|
||||
break;
|
||||
}
|
||||
|
||||
echo json_encode($active);
|
||||
$id=MaintenanceService::id($_POST['dropdown_serial']??null);
|
||||
echo json_encode((new MaintenanceService())->dropdownUsed($id));
|
||||
}
|
||||
|
||||
// =====================================
|
||||
@@ -386,6 +326,7 @@ class Dropdowns extends Base {
|
||||
|
||||
$dropdown_serial = filter_input(INPUT_POST, "dropdown_serial", FILTER_SANITIZE_NUMBER_INT) ?: 0;
|
||||
$dropdown_value = filter_input(INPUT_POST, "dropdown_value") ?: "";
|
||||
$dropdown_value=trim($dropdown_value);if(!$dropdown_value||strlen($dropdown_value)>100)throw new AppException('Enter a dropdown value up to 100 bytes.');
|
||||
|
||||
$dropdown = $this->getDropdown($dropdown_serial);
|
||||
|
||||
@@ -451,6 +392,8 @@ class Dropdowns extends Base {
|
||||
$dropdowntype_title = filter_input(INPUT_POST, "dropdowntype_title") ?: "";
|
||||
$dropdowntype_table = filter_input(INPUT_POST, "dropdowntype_table") ?: "";
|
||||
$dropdowntype_column = filter_input(INPUT_POST, "dropdowntype_column") ?: "";
|
||||
MaintenanceService::usageRule($dropdowntype_table, $dropdowntype_column);
|
||||
if(!trim($dropdowntype_name)||!trim($dropdowntype_title)||strlen($dropdowntype_name)>100||strlen($dropdowntype_title)>100)throw new AppException('Enter a dropdown type name and title up to 100 bytes.');
|
||||
|
||||
$dropdowntype = $this->getDropdowntype($dropdowntype_serial);
|
||||
|
||||
@@ -559,11 +502,11 @@ class Dropdowns extends Base {
|
||||
// Find Criteria
|
||||
|
||||
$find_dropdowntype = filter_input(INPUT_POST, "find_dropdowntype") ?: "";
|
||||
$find_words = $this->sanitizeString($find_dropdowntype);
|
||||
$find_words = preg_quote(trim($find_dropdowntype));
|
||||
|
||||
// Show Per Page
|
||||
|
||||
$this->pagination_size = filter_input(INPUT_POST, "users_per_page", FILTER_SANITIZE_NUMBER_INT) ?: $this->pagination_size;
|
||||
$this->pagination_size = filter_input(INPUT_POST, "dropdowntypes_per_page", FILTER_SANITIZE_NUMBER_INT) ?: $this->pagination_size;
|
||||
|
||||
// Record Count
|
||||
|
||||
@@ -593,21 +536,9 @@ class Dropdowns extends Base {
|
||||
|
||||
$pagination = filter_input(INPUT_POST, "pagination") ?: "";
|
||||
|
||||
switch ($pagination) {
|
||||
|
||||
case "first" : $this->pagination_page = 1;
|
||||
break;
|
||||
case "previous" : $this->pagination_page = max(($this->pagination_page - 1), 1);
|
||||
break;
|
||||
case "next" : $this->pagination_page = ($this->pagination_page + 1);
|
||||
break;
|
||||
case "last" : $this->pagination_page = ($count / $this->pagination_size);
|
||||
break;
|
||||
}
|
||||
|
||||
$offset = (($this->pagination_page * $this->pagination_size) - $this->pagination_size);
|
||||
$start = ($offset + 1);
|
||||
$end = min($count, (($start + $this->pagination_size) - 1));
|
||||
$paging=Pagination::session(__METHOD__,$count,(int)$this->pagination_size,$pagination);
|
||||
$this->pagination_size=$paging['size'];$this->pagination_page=$paging['page'];
|
||||
$offset=$paging['offset'];$start=$paging['start'];$end=$paging['end'];
|
||||
|
||||
// Data
|
||||
|
||||
@@ -617,6 +548,7 @@ class Dropdowns extends Base {
|
||||
or dropdowntype_title regexp :dropdowntype_title
|
||||
or dropdowntype_table regexp :dropdowntype_table
|
||||
or dropdowntype_column regexp :dropdowntype_column)
|
||||
order by dropdowntype_name,dropdowntype_serial
|
||||
limit {$this->pagination_size}
|
||||
offset {$offset}";
|
||||
|
||||
@@ -638,7 +570,6 @@ class Dropdowns extends Base {
|
||||
$dropdowntypes->addAttribute("end", $end);
|
||||
$dropdowntypes->addAttribute("page", $this->pagination_page);
|
||||
|
||||
$_SESSION[self::PAGINATION_PAGE] = $this->pagination_page;
|
||||
|
||||
return $dropdowntypes;
|
||||
}
|
||||
@@ -682,24 +613,7 @@ class Dropdowns extends Base {
|
||||
// ======================
|
||||
|
||||
public function updateDropdownActive() {
|
||||
|
||||
$dropdown_serial = filter_input(INPUT_POST, "dropdown_serial", FILTER_SANITIZE_NUMBER_INT) ?: 0;
|
||||
$dropdown_checked = filter_input(INPUT_POST, "dropdown_checked", FILTER_VALIDATE_BOOLEAN) ?: false;
|
||||
|
||||
$SQL = "update dropdowns
|
||||
set dropdown_active = :dropdown_active,
|
||||
dropdown_changer = :dropdown_changer,
|
||||
dropdown_changed = :dropdown_changed
|
||||
where dropdown_serial = :dropdown_serial";
|
||||
|
||||
$statement = $this->connect()->prepare($SQL);
|
||||
|
||||
$statement->bindValue(":dropdown_serial", $dropdown_serial);
|
||||
$statement->bindValue(":dropdown_active", $dropdown_checked, PDO::PARAM_BOOL);
|
||||
$statement->bindValue(":dropdown_changer", $this->current_user_name);
|
||||
$statement->bindValue(":dropdown_changed", $this->getTimestamp());
|
||||
|
||||
$statement->execute();
|
||||
(new MaintenanceService())->setDropdownActive(MaintenanceService::id($_POST['dropdown_serial']??null),MaintenanceService::boolean($_POST['dropdown_checked']??null));
|
||||
}
|
||||
|
||||
// =======================
|
||||
@@ -707,26 +621,7 @@ class Dropdowns extends Base {
|
||||
// =======================
|
||||
|
||||
public function updateDropdownDefault() {
|
||||
|
||||
$dropdown_serial = filter_input(INPUT_POST, "dropdown_serial", FILTER_SANITIZE_NUMBER_INT) ?: 0;
|
||||
$dropdown_dropdowntype = filter_input(INPUT_POST, "dropdown_dropdowntype", FILTER_SANITIZE_NUMBER_INT) ?: 0;
|
||||
$dropdown_checked = filter_input(INPUT_POST, "dropdown_checked") ?: "false";
|
||||
|
||||
// Unset the Default for the Dropdown Type.
|
||||
|
||||
$SQL = "update dropdowns
|
||||
set dropdown_default = false
|
||||
where dropdown_dropdowntype = {$dropdown_dropdowntype}";
|
||||
|
||||
$this->executeSQL($SQL);
|
||||
|
||||
// Set the Default for the clicked Dropdown.
|
||||
|
||||
$SQL = "update dropdowns
|
||||
set dropdown_default = {$dropdown_checked}
|
||||
where dropdown_serial = {$dropdown_serial}";
|
||||
|
||||
$this->executeSQL($SQL);
|
||||
(new MaintenanceService())->setDropdownDefault(MaintenanceService::id($_POST['dropdown_serial']??null),MaintenanceService::id($_POST['dropdown_dropdowntype']??null),MaintenanceService::boolean($_POST['dropdown_checked']??null));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,114 @@
|
||||
<?php
|
||||
|
||||
class EmailVerification extends Base {
|
||||
|
||||
public function request(string $identifier): bool {
|
||||
$auth = new AuthService();
|
||||
if (!$auth->allowAttempt('verify-ip', $_SERVER['REMOTE_ADDR'] ?? '', 10, 3600) || !$auth->allowAttempt('verify-account', $identifier, 3, 3600))
|
||||
return false;
|
||||
$user = $auth->findUser($identifier);
|
||||
if (!$user || $user['user_status'] !== 'active' || $user['user_email_verified_at'])
|
||||
return false;
|
||||
$url = rtrim((string) $this->getSettings()->ApplicationUrl, '/');
|
||||
if (!filter_var($url, FILTER_VALIDATE_URL) || parse_url($url, PHP_URL_SCHEME) !== 'https' || parse_url($url, PHP_URL_QUERY) !== null || parse_url($url, PHP_URL_FRAGMENT) !== null || parse_url($url, PHP_URL_USER) !== null || parse_url($url, PHP_URL_PASS) !== null)
|
||||
throw new RuntimeException('Configure a trusted HTTPS application URL.');
|
||||
$token = bin2hex(random_bytes(32));
|
||||
$db = $this->connect();
|
||||
$db->beginTransaction();
|
||||
try {
|
||||
$stmt = $db->prepare('SELECT * FROM users WHERE user_serial=? FOR UPDATE');
|
||||
$stmt->execute([$user['user_serial']]);
|
||||
$current = $stmt->fetch();
|
||||
if (!$current || $current['user_status'] !== 'active' || $current['user_email_verified_at'] || $current['user_email'] !== $user['user_email'] || (int) $current['user_auth_version'] !== (int) $user['user_auth_version']) {
|
||||
$db->rollBack();
|
||||
return false;
|
||||
}
|
||||
$stmt = $db->prepare('INSERT INTO email_verifications(token_hash,user_serial,email,auth_version,expires_at) VALUES(?,?,?,?,DATE_ADD(UTC_TIMESTAMP(),INTERVAL 24 HOUR))');
|
||||
$stmt->execute([hash('sha256', $token), $user['user_serial'], $user['user_email'], $user['user_auth_version']]);
|
||||
$db->commit();
|
||||
} catch (Throwable $e) {
|
||||
if ($db->inTransaction())
|
||||
$db->rollBack();throw $e;
|
||||
}
|
||||
if (!$this->deliver($user, $url . '/main.php?Xaction=verifyemail&token=' . $token)) {
|
||||
$stmt = $db->prepare('DELETE FROM email_verifications WHERE token_hash=?');
|
||||
$stmt->execute([hash('sha256', $token)]);
|
||||
throw new RuntimeException('Verification email could not be delivered.');
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
protected function deliver(array $user, string $url): bool {
|
||||
$h = static fn($v) => htmlspecialchars((string) $v, ENT_QUOTES, 'UTF-8');
|
||||
return (new Email())->sendEmail($user['user_email'], self::APPLICATION_NAME . ' - Verify your email', '<p>Verify the email address for account ' . $h($user['user_name']) . '.</p><p><a href="' . $h($url) . '">Verify email</a></p><p>The link expires in 24 hours. You must also enter the account password. If you did not create this account, ignore this message or use password reset to secure it.</p>');
|
||||
}
|
||||
|
||||
public function confirm(string $token, string $password): bool {
|
||||
if (!preg_match('/\A[a-f0-9]{64}\z/', $token))
|
||||
return false;
|
||||
$db = $this->connect();
|
||||
$db->beginTransaction();
|
||||
try {
|
||||
$stmt = $db->prepare('SELECT user_serial FROM email_verifications WHERE token_hash=?');
|
||||
$stmt->execute([hash('sha256', $token)]);
|
||||
$id = $stmt->fetchColumn();
|
||||
if (!$id) {
|
||||
$db->rollBack();
|
||||
return false;
|
||||
}
|
||||
$stmt = $db->prepare('SELECT * FROM users WHERE user_serial=? FOR UPDATE');
|
||||
$stmt->execute([$id]);
|
||||
$user = $stmt->fetch();
|
||||
$stmt = $db->prepare('SELECT * FROM email_verifications WHERE token_hash=? AND expires_at>UTC_TIMESTAMP() FOR UPDATE');
|
||||
$stmt->execute([hash('sha256', $token)]);
|
||||
$row = $stmt->fetch();
|
||||
if (!$row || !$user || $user['user_status'] !== 'active' || $row['email'] !== $user['user_email'] || (int) $row['auth_version'] !== (int) $user['user_auth_version'] || !password_verify($password, $user['user_password_hash'])) {
|
||||
$db->rollBack();
|
||||
return false;
|
||||
}
|
||||
$stmt = $db->prepare('UPDATE users SET user_email_verified_at=UTC_TIMESTAMP() WHERE user_serial=?');
|
||||
$stmt->execute([$id]);
|
||||
$stmt = $db->prepare('DELETE FROM email_verifications WHERE user_serial=?');
|
||||
$stmt->execute([$id]);
|
||||
Audit::required($db, (int) $id, 'verified email ownership');
|
||||
$db->commit();
|
||||
return true;
|
||||
} catch (Throwable $e) {
|
||||
if ($db->inTransaction())
|
||||
$db->rollBack();throw $e;
|
||||
}
|
||||
}
|
||||
|
||||
public function page(): void {
|
||||
$token = $_POST['token'] ?? $_GET['token'] ?? '';
|
||||
$message = '';
|
||||
$complete = false;
|
||||
if (($_SERVER['REQUEST_METHOD'] ?? 'GET') === 'POST') {
|
||||
$this->requireCsrf();
|
||||
if (($_POST['step'] ?? '') === 'confirm') {
|
||||
$allowed = (new AuthService())->allowAttempt('verify-submit-ip', $_SERVER['REMOTE_ADDR'] ?? '', 20, 900);
|
||||
$complete = $allowed && $this->confirm($token, $_POST['user_password'] ?? '');
|
||||
$message = $complete ? 'Email verified. You can now be assigned to a club.' : 'Unable to verify. Check the password or request a fresh link. You can also reset your password to prove mailbox ownership.';
|
||||
} else {
|
||||
try {
|
||||
$this->request(trim($_POST['user_email'] ?? ''));
|
||||
} catch (Throwable $e) {
|
||||
error_log('Email verification failed: ' . $e->getMessage());
|
||||
}
|
||||
$message = 'If an active account needs verification, a link will be sent. You can also reset your password to verify its email.';
|
||||
}
|
||||
}
|
||||
$h = static fn($v) => htmlspecialchars((string) $v, ENT_QUOTES, 'UTF-8');
|
||||
echo '<!doctype html><html lang="en"><head><meta charset="UTF-8"><meta name="viewport" content="width=device-width, initial-scale=1"><title>Verify email</title><link rel="stylesheet" href="css/themes/MyHuntingClubRoster/bootstrap.css"></head><body><main class="container py-4"><h1>Verify your email</h1><p>' . $h($message) . '</p>';
|
||||
if (!$complete) {
|
||||
echo '<form method="post" action="main.php"><input type="hidden" name="action" value="verifyemail"><input type="hidden" name="csrf_token" value="' . $h($this->csrfToken()) . '">';
|
||||
if ($token)
|
||||
echo '<input type="hidden" name="step" value="confirm"><input type="hidden" name="token" value="' . $h($token) . '"><label class="form-label">Account password <input class="form-control" type="password" name="user_password" autocomplete="current-password" required></label><button class="btn btn-primary">Verify email</button>';
|
||||
else
|
||||
echo '<label class="form-label">Email <input class="form-control" type="email" name="user_email" required></label><button class="btn btn-primary">Send verification link</button>';
|
||||
echo '</form>';
|
||||
}
|
||||
echo '<p><a href="main.php">Return to application</a> · <a href="main.php?Xaction=verifyemail">Request another link</a> · <a href="main.php?Xaction=resetpassword">Reset password</a></p></main></body></html>';
|
||||
exit;
|
||||
}
|
||||
}
|
||||
+10
-23
@@ -27,7 +27,7 @@ class Journal extends Base {
|
||||
$this->current_user_name = $_SESSION[self::USER_NAME] ?? "";
|
||||
$this->current_user_email = $_SESSION[self::USER_EMAIL] ?? "";
|
||||
|
||||
$this->pagination_page = $_SESSION[self::PAGINATION_PAGE] ?? 1;
|
||||
$this->pagination_page = 1;
|
||||
|
||||
}
|
||||
|
||||
@@ -37,12 +37,8 @@ class Journal extends Base {
|
||||
|
||||
public function clearJournal() {
|
||||
|
||||
$SQL = "truncate journal";
|
||||
|
||||
$this->executeSQL($SQL);
|
||||
|
||||
$this->journalEntry("Journal was Cleared.");
|
||||
|
||||
throw new AppException('Audit history cannot be cleared through the application.',403);
|
||||
|
||||
}
|
||||
|
||||
// =======================
|
||||
@@ -70,7 +66,7 @@ class Journal extends Base {
|
||||
// Find Criteria
|
||||
|
||||
$find_journal = filter_input(INPUT_POST, "find_journal") ?: "";
|
||||
$find_words = $this->sanitizeString($find_journal);
|
||||
$find_words = preg_quote(trim($find_journal));
|
||||
|
||||
// Show Per Page
|
||||
|
||||
@@ -103,19 +99,10 @@ class Journal extends Base {
|
||||
// Pagination
|
||||
|
||||
$pagination = filter_input(INPUT_POST, "pagination") ?: "";
|
||||
|
||||
switch ($pagination) {
|
||||
|
||||
case "first" : $this->pagination_page = 1; break;
|
||||
case "previous" : $this->pagination_page = max(($this->pagination_page - 1), 1); break;
|
||||
case "next" : $this->pagination_page = ($this->pagination_page + 1); break;
|
||||
case "last" : $this->pagination_page = ($count / $this->pagination_size); break;
|
||||
|
||||
}
|
||||
|
||||
$offset = (($this->pagination_page * $this->pagination_size) - $this->pagination_size);
|
||||
$start = ($offset + 1);
|
||||
$end = min($count, (($start + $this->pagination_size) - 1));
|
||||
|
||||
$paging=Pagination::session(__METHOD__,$count,(int)$this->pagination_size,$pagination);
|
||||
$this->pagination_size=$paging['size'];$this->pagination_page=$paging['page'];
|
||||
$offset=$paging['offset'];$start=$paging['start'];$end=$paging['end'];
|
||||
|
||||
// Data
|
||||
|
||||
@@ -125,6 +112,7 @@ class Journal extends Base {
|
||||
or journal_origin regexp :journal_origin
|
||||
or journal_ip regexp :journal_ip
|
||||
or journal_entry regexp :journal_entry
|
||||
order by journal_timestamp DESC,journal_serial DESC
|
||||
limit {$this->pagination_size}
|
||||
offset {$offset}";
|
||||
|
||||
@@ -145,8 +133,7 @@ class Journal extends Base {
|
||||
$journal->addAttribute("start", $start);
|
||||
$journal->addAttribute("end", $end);
|
||||
$journal->addAttribute("page", $this->pagination_page);
|
||||
|
||||
$_SESSION[self::PAGINATION_PAGE] = $this->pagination_page;
|
||||
|
||||
|
||||
return $journal;
|
||||
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
<?php
|
||||
/** Append-only financial records. Every write and its audit event commit together. */
|
||||
class LedgerService extends RosterService {
|
||||
private function actor(): int {return (int)($_SESSION[self::USER_SERIAL]??0);}
|
||||
private function key(string $key): void {if(!preg_match('/\A[a-f0-9]{64}\z/',$key))throw new AppException('Invalid request key.');}
|
||||
private function reason(string $reason): string {$reason=trim($reason);if(!$reason||strlen($reason)>500)throw new AppException('Enter a correction reason, up to 500 bytes.');return $reason;}
|
||||
private function transaction(callable $work): void {
|
||||
$this->requireAdministrator();$db=$this->connect();$db->beginTransaction();
|
||||
try{$work($db);$db->commit();}catch(Throwable $e){if($db->inTransaction())$db->rollBack();throw $e;}
|
||||
}
|
||||
private function lockedDue(PDO $db,int $club,int $due): array {
|
||||
$this->club($club,$db,true);
|
||||
$stmt=$db->prepare('SELECT * FROM club_dues WHERE due_serial=? AND club_serial=? FOR UPDATE');$stmt->execute([$due,$club]);$row=$stmt->fetch();
|
||||
if(!$row)throw new AppException('Dues record not found in this club.',404);
|
||||
$stmt=$db->prepare('SELECT COALESCE(SUM(amount_cents),0) FROM club_due_adjustments WHERE due_serial=?');$stmt->execute([$due]);$row['effective']=(int)$row['amount_cents']+(int)$stmt->fetchColumn();
|
||||
$stmt=$db->prepare('SELECT COALESCE(SUM(p.amount_cents),0) FROM club_payments p WHERE p.due_serial=? AND NOT EXISTS(SELECT 1 FROM club_payment_reversals r WHERE r.payment_serial=p.payment_serial)');$stmt->execute([$due]);$row['paid']=(int)$stmt->fetchColumn();return $row;
|
||||
}
|
||||
public function charge(int $club,int $membership,int $season,string $amount): void {
|
||||
$cents=self::cents($amount);
|
||||
$this->transaction(function(PDO $db)use($club,$membership,$season,$cents){
|
||||
$this->club($club,$db,true);
|
||||
$stmt=$db->prepare("SELECT membership_serial FROM club_memberships WHERE membership_serial=? AND club_serial=? AND membership_status='active' FOR UPDATE");$stmt->execute([$membership,$club]);if(!$stmt->fetch())throw new AppException('Select an active member of this club.',409);
|
||||
$stmt=$db->prepare('SELECT season_serial FROM club_seasons WHERE season_serial=? AND club_serial=? FOR UPDATE');$stmt->execute([$season,$club]);if(!$stmt->fetch())throw new AppException('Select a season belonging to this club.',409);
|
||||
$stmt=$db->prepare('INSERT INTO club_dues(club_serial,membership_serial,season_serial,amount_cents,recorded_by) VALUES(?,?,?,?,?)');$stmt->execute([$club,$membership,$season,$cents,$this->actor()]);
|
||||
Audit::required($db,$this->actor(),'created dues '.json_encode(['due'=>(int)$db->lastInsertId(),'club'=>$club,'membership'=>$membership,'season'=>$season,'amount_cents'=>$cents]));
|
||||
});
|
||||
}
|
||||
public function payment(int $club,int $due,string $amount,string $paid,string $key,string $method='unspecified',string $reference=''): void {
|
||||
$cents=self::cents($amount);self::date($paid);$this->key($key);$reference=trim($reference);
|
||||
if($cents<=0||$paid>date('Y-m-d')||!in_array($method,['unspecified','cash','check','transfer','other'],true)||strlen($reference)>100)throw new AppException('Enter a positive payment, a valid date/method, and a reference up to 100 bytes.');
|
||||
$this->transaction(function(PDO $db)use($club,$due,$cents,$paid,$key,$method,$reference){
|
||||
$row=$this->lockedDue($db,$club,$due);
|
||||
$stmt=$db->prepare('SELECT * FROM club_payments WHERE request_key=?');$stmt->execute([$key]);$existing=$stmt->fetch();
|
||||
if($existing){if((int)$existing['due_serial']!==$due||(int)$existing['amount_cents']!==$cents||$existing['paid_on']!==$paid||$existing['payment_method']!==$method||$existing['payment_reference']!==$reference)throw new AppException('This request key was already used for different payment details.',409);return;}
|
||||
if($cents>$row['effective']-$row['paid'])throw new AppException('Payment exceeds the outstanding balance.',409);
|
||||
$stmt=$db->prepare('INSERT INTO club_payments(due_serial,amount_cents,paid_on,request_key,recorded_by,payment_method,payment_reference) VALUES(?,?,?,?,?,?,?)');$stmt->execute([$due,$cents,$paid,$key,$this->actor(),$method,$reference]);
|
||||
Audit::required($db,$this->actor(),'received payment '.json_encode(['payment'=>(int)$db->lastInsertId(),'club'=>$club,'due'=>$due,'amount_cents'=>$cents,'paid_on'=>$paid,'method'=>$method,'reference'=>$reference]));
|
||||
});
|
||||
}
|
||||
public function adjust(int $club,int $due,string $total,string $reason,string $key): void {
|
||||
$cents=self::cents($total);$reason=$this->reason($reason);$this->key($key);
|
||||
$this->transaction(function(PDO $db)use($club,$due,$cents,$reason,$key){
|
||||
$row=$this->lockedDue($db,$club,$due);$stmt=$db->prepare('SELECT * FROM club_due_adjustments WHERE request_key=?');$stmt->execute([$key]);$existing=$stmt->fetch();
|
||||
if($existing){if((int)$existing['due_serial']!==$due||(int)$existing['corrected_amount_cents']!==$cents||$existing['reason']!==$reason)throw new AppException('This request key was already used for different correction details.',409);return;}
|
||||
if($cents<$row['paid'])throw new AppException('Corrected dues cannot be below received payments. Reverse an incorrect payment first.',409);
|
||||
if($cents===$row['effective'])throw new AppException('The corrected amount must differ from the current dues.',409);
|
||||
$stmt=$db->prepare('INSERT INTO club_due_adjustments(due_serial,amount_cents,corrected_amount_cents,reason,recorded_by,request_key) VALUES(?,?,?,?,?,?)');$stmt->execute([$due,$cents-$row['effective'],$cents,$reason,$this->actor(),$key]);
|
||||
Audit::required($db,$this->actor(),'corrected dues '.json_encode(['adjustment'=>(int)$db->lastInsertId(),'club'=>$club,'due'=>$due,'before_cents'=>$row['effective'],'after_cents'=>$cents,'reason'=>$reason]));
|
||||
});
|
||||
}
|
||||
public function reverse(int $club,int $payment,string $reason,string $key): void {
|
||||
$reason=$this->reason($reason);$this->key($key);
|
||||
$this->transaction(function(PDO $db)use($club,$payment,$reason,$key){
|
||||
$this->club($club,$db,true);$stmt=$db->prepare('SELECT p.* FROM club_payments p JOIN club_dues d ON d.due_serial=p.due_serial WHERE p.payment_serial=? AND d.club_serial=?');$stmt->execute([$payment,$club]);$row=$stmt->fetch();if(!$row)throw new AppException('Payment not found in this club.',404);
|
||||
$this->lockedDue($db,$club,(int)$row['due_serial']);
|
||||
$stmt=$db->prepare('SELECT * FROM club_payment_reversals WHERE request_key=? OR payment_serial=?');$stmt->execute([$key,$payment]);$existing=$stmt->fetch();
|
||||
if($existing){if($existing['request_key']===$key&&(int)$existing['payment_serial']===$payment&&$existing['reason']===$reason)return;throw new AppException('Payment is already reversed or this request key was already used.',409);}
|
||||
$stmt=$db->prepare('INSERT INTO club_payment_reversals(payment_serial,reason,recorded_by,request_key) VALUES(?,?,?,?)');$stmt->execute([$payment,$reason,$this->actor(),$key]);
|
||||
Audit::required($db,$this->actor(),'reversed payment '.json_encode(['reversal'=>(int)$db->lastInsertId(),'club'=>$club,'payment'=>$payment,'amount_cents'=>(int)$row['amount_cents'],'reason'=>$reason]));
|
||||
});
|
||||
}
|
||||
public function history(int $club): array {
|
||||
$this->club($club);$result=[];$db=$this->connect();
|
||||
$scope=$this->administrator()?'':' AND m.user_serial=?';$args=$this->administrator()?[$club]:[$club,$_SESSION[self::USER_SERIAL]??0];
|
||||
$stmt=$db->prepare('SELECT p.*,m.user_serial,r.reversal_serial,r.reason AS reversal_reason,r.created_at AS reversed_at,CONCAT_WS(" ",u.user_first_name,u.user_last_name) AS member_name,a.user_name AS actor,ra.user_name AS reversal_actor FROM club_payments p JOIN club_dues d ON d.due_serial=p.due_serial JOIN club_memberships m ON m.membership_serial=d.membership_serial JOIN users u ON u.user_serial=m.user_serial LEFT JOIN club_payment_reversals r ON r.payment_serial=p.payment_serial LEFT JOIN users a ON a.user_serial=p.recorded_by LEFT JOIN users ra ON ra.user_serial=r.recorded_by WHERE d.club_serial=?'.$scope.' ORDER BY p.paid_on DESC,p.payment_serial DESC');$stmt->execute($args);$result['payments']=$stmt->fetchAll();
|
||||
$stmt=$db->prepare('SELECT x.*,CONCAT_WS(" ",u.user_first_name,u.user_last_name) AS member_name,a.user_name AS actor FROM club_due_adjustments x JOIN club_dues d ON d.due_serial=x.due_serial JOIN club_memberships m ON m.membership_serial=d.membership_serial JOIN users u ON u.user_serial=m.user_serial JOIN users a ON a.user_serial=x.recorded_by WHERE d.club_serial=?'.$scope.' ORDER BY x.adjustment_serial DESC');$stmt->execute($args);$result['adjustments']=$stmt->fetchAll();return $result;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,310 @@
|
||||
<?php
|
||||
|
||||
/** Transactional rules shared by the administrative controllers. */
|
||||
class MaintenanceService extends Base {
|
||||
|
||||
public static function id(mixed $value): int {
|
||||
$id = filter_var($value, FILTER_VALIDATE_INT, ['options' => ['min_range' => 1]]);
|
||||
if ($id === false)
|
||||
throw new AppException('A valid record ID is required.');
|
||||
return $id;
|
||||
}
|
||||
|
||||
public static function boolean(mixed $value): bool {
|
||||
if (!is_scalar($value))
|
||||
throw new AppException('A true or false value is required.');
|
||||
$result = filter_var($value, FILTER_VALIDATE_BOOLEAN, FILTER_NULL_ON_FAILURE);
|
||||
if (!is_scalar($value) || $result === null || $value === '')
|
||||
throw new AppException('A true or false value is required.');
|
||||
return $result;
|
||||
}
|
||||
|
||||
public static function usageRule(string $table, string $column): void {
|
||||
// No current roster field uses dropdown IDs. Add explicit code-owned references
|
||||
// here when such a feature is implemented; database values are never SQL/callbacks.
|
||||
if ($table !== '*none' || $column !== '') {
|
||||
throw new AppException('Only "No usage reference" is supported. Custom table names and callbacks are disabled.');
|
||||
}
|
||||
}
|
||||
|
||||
public function dropdownUsed(int $id, ?PDO $db = null): bool {
|
||||
$stmt = ($db ?? $this->connect())->prepare('SELECT d.dropdown_default,t.dropdowntype_table, t.dropdowntype_column FROM dropdowns d JOIN dropdowntypes t ON t.dropdowntype_serial=d.dropdown_dropdowntype WHERE d.dropdown_serial=?');
|
||||
$stmt->execute([$id]);
|
||||
$row = $stmt->fetch();
|
||||
if (!$row)
|
||||
throw new AppException('Dropdown not found.', 404);
|
||||
self::usageRule((string) $row['dropdowntype_table'], (string) $row['dropdowntype_column']);
|
||||
return (bool) $row['dropdown_default'];
|
||||
}
|
||||
|
||||
public function deleteDropdown(int $id): void {
|
||||
$db = $this->connect();
|
||||
$db->beginTransaction();
|
||||
try {
|
||||
$stmt = $db->prepare('SELECT dropdown_dropdowntype FROM dropdowns WHERE dropdown_serial=?');
|
||||
$stmt->execute([$id]);
|
||||
$type = $stmt->fetchColumn();
|
||||
if (!$type)
|
||||
throw new AppException('Dropdown not found.', 404);
|
||||
$stmt = $db->prepare('SELECT dropdowntype_serial FROM dropdowntypes WHERE dropdowntype_serial=? FOR UPDATE');
|
||||
$stmt->execute([$type]);
|
||||
$stmt->fetch();
|
||||
$stmt = $db->prepare('SELECT dropdown_default FROM dropdowns WHERE dropdown_serial=? FOR UPDATE');
|
||||
$stmt->execute([$id]);
|
||||
$row = $stmt->fetch();
|
||||
if (!$row)
|
||||
throw new AppException('Dropdown not found.', 404);
|
||||
if ($row['dropdown_default'] || $this->dropdownUsed($id, $db))
|
||||
throw new AppException('Choose another default before deleting this dropdown.', 409);
|
||||
$stmt = $db->prepare('DELETE FROM dropdowns WHERE dropdown_serial=?');
|
||||
$stmt->execute([$id]);
|
||||
$db->commit();
|
||||
} catch (Throwable $e) {
|
||||
if ($db->inTransaction())
|
||||
$db->rollBack();throw $e;
|
||||
}
|
||||
}
|
||||
|
||||
public function deleteType(int $id): void {
|
||||
$db = $this->connect();
|
||||
$db->beginTransaction();
|
||||
try {
|
||||
$stmt = $db->prepare('SELECT dropdowntype_serial FROM dropdowntypes WHERE dropdowntype_serial=? FOR UPDATE');
|
||||
$stmt->execute([$id]);
|
||||
if (!$stmt->fetch())
|
||||
throw new AppException('Dropdown type not found.', 404);
|
||||
$stmt = $db->prepare('SELECT dropdown_serial FROM dropdowns WHERE dropdown_dropdowntype=? LIMIT 1');
|
||||
$stmt->execute([$id]);
|
||||
if ($stmt->fetch())
|
||||
throw new AppException('Remove the dropdown values before deleting their type.', 409);
|
||||
$stmt = $db->prepare('DELETE FROM dropdowntypes WHERE dropdowntype_serial=?');
|
||||
$stmt->execute([$id]);
|
||||
$db->commit();
|
||||
} catch (Throwable $e) {
|
||||
if ($db->inTransaction())
|
||||
$db->rollBack();throw $e;
|
||||
}
|
||||
}
|
||||
|
||||
public function setDropdownDefault(int $id, int $type, bool $checked): void {
|
||||
$db = $this->connect();
|
||||
$db->beginTransaction();
|
||||
try {
|
||||
$stmt = $db->prepare('SELECT dropdowntype_serial FROM dropdowntypes WHERE dropdowntype_serial=? FOR UPDATE');
|
||||
$stmt->execute([$type]);
|
||||
if (!$stmt->fetch())
|
||||
throw new AppException('Dropdown type not found.', 404);
|
||||
$stmt = $db->prepare('SELECT dropdown_dropdowntype,dropdown_active FROM dropdowns WHERE dropdown_serial=? FOR UPDATE');
|
||||
$stmt->execute([$id]);
|
||||
$row = $stmt->fetch();
|
||||
if (!$row || (int) $row['dropdown_dropdowntype'] !== $type)
|
||||
throw new AppException('Dropdown does not belong to this type.', 409);
|
||||
if ($checked && !$row['dropdown_active'])
|
||||
throw new AppException('Activate the dropdown before selecting it as the default.', 409);
|
||||
if ($checked) {
|
||||
$stmt = $db->prepare('UPDATE dropdowns SET dropdown_default=0 WHERE dropdown_dropdowntype=?');
|
||||
$stmt->execute([$type]);
|
||||
}
|
||||
$stmt = $db->prepare('UPDATE dropdowns SET dropdown_default=?, dropdown_changer=?,dropdown_changed=? WHERE dropdown_serial=?');
|
||||
$stmt->execute([(int) $checked, $_SESSION[self::USER_NAME] ?? '', $this->getTimestamp(), $id]);
|
||||
$db->commit();
|
||||
} catch (Throwable $e) {
|
||||
if ($db->inTransaction())
|
||||
$db->rollBack();throw $e;
|
||||
}
|
||||
}
|
||||
|
||||
public function setDropdownActive(int $id, bool $checked): void {
|
||||
$stmt = $this->connect()->prepare('UPDATE dropdowns SET dropdown_active=?,dropdown_default=IF(?=0,0,dropdown_default),dropdown_changer=?,dropdown_changed=? WHERE dropdown_serial=?');
|
||||
$stmt->execute([(int) $checked, (int) $checked, $_SESSION[self::USER_NAME] ?? '', $this->getTimestamp(), $id]);
|
||||
}
|
||||
|
||||
private function lockAdministrators(PDO $db): array {
|
||||
return $db->query("SELECT user_serial FROM users WHERE user_status='active' AND user_role IN ('Administrator','System Administrator') ORDER BY user_serial FOR UPDATE")->fetchAll(PDO::FETCH_COLUMN);
|
||||
}
|
||||
|
||||
private function guardAdministrator(array $admins, int $id, string $role, string $status, bool $deleting = false): void {
|
||||
if ($deleting && $id === (int) ($_SESSION[self::USER_SERIAL] ?? 0))
|
||||
throw new AppException('You cannot delete your own account.', 409);
|
||||
if (in_array($id, array_map('intval', $admins), true) && count($admins) <= 1 && ($deleting || $status !== 'active' || !in_array($role, ['Administrator', 'System Administrator'], true))) {
|
||||
throw new AppException('The last active administrator cannot be removed, disabled or demoted.', 409);
|
||||
}
|
||||
}
|
||||
|
||||
public function userUsed(int $id, ?PDO $db = null): bool {
|
||||
$db ??= $this->connect();
|
||||
$stmt = $db->prepare("SELECT COUNT(*) FROM notes WHERE note_type='User' AND note_reference=?");
|
||||
$stmt->execute([$id]);
|
||||
if ((int) $stmt->fetchColumn() > 0)
|
||||
return true;
|
||||
$stmt = $db->prepare('SELECT COUNT(*) FROM journal WHERE journal_origin=?');
|
||||
$stmt->execute([(string) $id]);
|
||||
if ((int) $stmt->fetchColumn() > 0)
|
||||
return true;
|
||||
// Roster tables are installed by the separate non-destructive roster migration.
|
||||
$exists = $db->query("SELECT COUNT(*) FROM information_schema.TABLES WHERE TABLE_SCHEMA=DATABASE() AND TABLE_NAME='club_memberships'")->fetchColumn();
|
||||
if ($exists) {
|
||||
$stmt = $db->prepare('SELECT COUNT(*) FROM club_memberships WHERE user_serial=?');
|
||||
$stmt->execute([$id]);
|
||||
if ((int) $stmt->fetchColumn() > 0)
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
public function deleteUser(int $id): void {
|
||||
$db = $this->connect();
|
||||
$db->beginTransaction();
|
||||
try {
|
||||
$admins = $this->lockAdministrators($db);
|
||||
$stmt = $db->prepare('SELECT user_role,user_status FROM users WHERE user_serial=? FOR UPDATE');
|
||||
$stmt->execute([$id]);
|
||||
$user = $stmt->fetch();
|
||||
if (!$user)
|
||||
throw new AppException('User not found.', 404);
|
||||
if ($user['user_role'] === 'System Administrator' && ($_SESSION[self::USER_ROLE] ?? '') !== 'System Administrator')
|
||||
throw new AppException('Only a system administrator can modify that account.', 403);
|
||||
$this->guardAdministrator($admins, $id, $user['user_role'], $user['user_status'], true);
|
||||
if ($this->userUsed($id, $db))
|
||||
throw new AppException('This user has recorded history. Disable the account to preserve its history.', 409);
|
||||
$stmt = $db->prepare('DELETE FROM password_resets WHERE user_serial=?');
|
||||
$stmt->execute([$id]);
|
||||
$stmt = $db->prepare('DELETE FROM users WHERE user_serial=?');
|
||||
$stmt->execute([$id]);
|
||||
Audit::required($db, (int) ($_SESSION[self::USER_SERIAL] ?? 0), 'deleted unused user ' . $id);
|
||||
$db->commit();
|
||||
} catch (Throwable $e) {
|
||||
if ($db->inTransaction())
|
||||
$db->rollBack();throw $e;
|
||||
}
|
||||
}
|
||||
|
||||
public function updateUser(int $id, array $input): void {
|
||||
$name = trim($input['user_name'] ?? '');
|
||||
$email = trim($input['user_email'] ?? '');
|
||||
$first = trim($input['user_first_name'] ?? '');
|
||||
$last = trim($input['user_last_name'] ?? '');
|
||||
$role = $input['user_role'] ?? '';
|
||||
$status = self::boolean($input['user_active'] ?? false) ? 'active' : 'inactive';
|
||||
if (!preg_match('/\A[A-Za-z0-9_.-]{3,64}\z/', $name) || !filter_var($email, FILTER_VALIDATE_EMAIL) || strlen($email) > 100 || !$first || !$last || strlen($first) > 128 || strlen($last) > 128 || !in_array($role, ['User', 'Guest', 'Administrator', 'System Administrator'], true))
|
||||
throw new AppException('Enter a valid username, first/last name, email and role.');
|
||||
// Only an existing system administrator may assign that role.
|
||||
if ($role === 'System Administrator' && ($_SESSION[self::USER_ROLE] ?? '') !== 'System Administrator')
|
||||
throw new AppException('Only a system administrator can assign that role.', 403);
|
||||
$db = $this->connect();
|
||||
$db->beginTransaction();
|
||||
try {
|
||||
$admins = $this->lockAdministrators($db);
|
||||
$stmt = $db->prepare('SELECT * FROM users WHERE user_serial=? FOR UPDATE');
|
||||
$stmt->execute([$id]);
|
||||
$old = $stmt->fetch();
|
||||
if (!$old)
|
||||
throw new AppException('User not found.', 404);
|
||||
if ($old['user_role'] === 'System Administrator' && ($_SESSION[self::USER_ROLE] ?? '') !== 'System Administrator')
|
||||
throw new AppException('Only a system administrator can modify that account.', 403);
|
||||
$this->guardAdministrator($admins, $id, $role, $status);
|
||||
$revoke = $old['user_role'] !== $role || $old['user_status'] !== $status || $old['user_email'] !== $email || $old['user_name'] !== $name;
|
||||
$stmt = $db->prepare('UPDATE users SET user_name=?,user_email=?,user_first_name=?,user_last_name=?,user_role=?,user_status=?,user_auth_version=user_auth_version+?,user_changer=?,user_changed=? WHERE user_serial=?');
|
||||
$stmt->execute([$name, $email, $first, $last, $role, $status, (int) $revoke, $_SESSION[self::USER_NAME] ?? '', $this->getTimestamp(), $id]);
|
||||
if ($old['user_email'] !== $email) {
|
||||
$stmt = $db->prepare('UPDATE users SET user_email_verified_at=NULL WHERE user_serial=?');
|
||||
$stmt->execute([$id]);
|
||||
}
|
||||
if ($revoke) {
|
||||
$stmt = $db->prepare('DELETE FROM password_resets WHERE user_serial=?');
|
||||
$stmt->execute([$id]);
|
||||
$stmt = $db->prepare('DELETE FROM email_verifications WHERE user_serial=?');
|
||||
$stmt->execute([$id]);
|
||||
}
|
||||
$fields = ['user_name', 'user_email', 'user_first_name', 'user_last_name', 'user_role', 'user_status'];
|
||||
Audit::required($db, (int) ($_SESSION[self::USER_SERIAL] ?? 0), 'updated user ' . json_encode(['user' => $id, 'before' => array_intersect_key($old, array_flip($fields)), 'after' => ['user_name' => $name, 'user_email' => $email, 'user_first_name' => $first, 'user_last_name' => $last, 'user_role' => $role, 'user_status' => $status]]));
|
||||
$db->commit();
|
||||
} catch (Throwable $e) {
|
||||
if ($db->inTransaction())
|
||||
$db->rollBack();throw $e;
|
||||
}
|
||||
}
|
||||
|
||||
public function setUserActive(int $id, bool $active): void {
|
||||
$db = $this->connect();
|
||||
$db->beginTransaction();
|
||||
try {
|
||||
$admins = $this->lockAdministrators($db);
|
||||
$stmt = $db->prepare('SELECT user_role FROM users WHERE user_serial=? FOR UPDATE');
|
||||
$stmt->execute([$id]);
|
||||
$role = $stmt->fetchColumn();
|
||||
if (!$role)
|
||||
throw new AppException('User not found.', 404);
|
||||
if ($role === 'System Administrator' && ($_SESSION[self::USER_ROLE] ?? '') !== 'System Administrator')
|
||||
throw new AppException('Only a system administrator can modify that account.', 403);
|
||||
$this->guardAdministrator($admins, $id, $role, $active ? 'active' : 'inactive');
|
||||
$stmt = $db->prepare('UPDATE users SET user_status=?,user_auth_version=user_auth_version+1 WHERE user_serial=?');
|
||||
$stmt->execute([$active ? 'active' : 'inactive', $id]);
|
||||
$stmt = $db->prepare('DELETE FROM password_resets WHERE user_serial=?');
|
||||
$stmt->execute([$id]);
|
||||
$stmt = $db->prepare('DELETE FROM email_verifications WHERE user_serial=?');
|
||||
$stmt->execute([$id]);
|
||||
Audit::required($db, (int) ($_SESSION[self::USER_SERIAL] ?? 0), 'changed user status ' . json_encode(['user' => $id, 'active' => $active]));
|
||||
$db->commit();
|
||||
} catch (Throwable $e) {
|
||||
if ($db->inTransaction())
|
||||
$db->rollBack();throw $e;
|
||||
}
|
||||
}
|
||||
|
||||
public function deleteStatus(int $id): void {
|
||||
$db = $this->connect();
|
||||
$db->beginTransaction();
|
||||
try {
|
||||
$rows = $db->query('SELECT status_serial,status_default FROM statuses ORDER BY status_serial FOR UPDATE')->fetchAll(PDO::FETCH_KEY_PAIR);
|
||||
if (!array_key_exists($id, $rows))
|
||||
throw new AppException('Status not found.', 404);
|
||||
if ($rows[$id])
|
||||
throw new AppException('Choose another default before deleting this status.', 409);
|
||||
// Current club membership statuses are explicit enums, not references to this list.
|
||||
$stmt = $db->prepare('DELETE FROM statuses WHERE status_serial=?');
|
||||
$stmt->execute([$id]);
|
||||
$db->commit();
|
||||
} catch (Throwable $e) {
|
||||
if ($db->inTransaction())
|
||||
$db->rollBack();throw $e;
|
||||
}
|
||||
}
|
||||
|
||||
public function saveStatus(int $id, array $input): void {
|
||||
$name = trim($input['status_name'] ?? '');
|
||||
$classes = trim($input['status_classes'] ?? '');
|
||||
$active = self::boolean($input['status_active'] ?? false);
|
||||
$default = self::boolean($input['status_default'] ?? false);
|
||||
if (!$name || strlen($name) > 100 || strlen($classes) > 100 || !preg_match('/\A[A-Za-z0-9_ -]*\z/', $classes))
|
||||
throw new AppException('Enter a status name and valid CSS classes (up to 100 bytes).');
|
||||
if ($default && !$active)
|
||||
throw new AppException('The default status must be active.');
|
||||
$db = $this->connect();
|
||||
$db->beginTransaction();
|
||||
try {
|
||||
$db->query('SELECT status_serial FROM statuses ORDER BY status_serial FOR UPDATE')->fetchAll();
|
||||
$stmt = $db->prepare('SELECT status_serial FROM statuses WHERE LOWER(status_name)=LOWER(?) AND status_serial<>?');
|
||||
$stmt->execute([$name, $id]);
|
||||
if ($stmt->fetch())
|
||||
throw new AppException('A status with that name already exists.', 409);
|
||||
if ($default)
|
||||
$db->exec('UPDATE statuses SET status_default=0');
|
||||
if ($id) {
|
||||
$stmt = $db->prepare('SELECT status_serial FROM statuses WHERE status_serial=?');
|
||||
$stmt->execute([$id]);
|
||||
if (!$stmt->fetch())
|
||||
throw new AppException('Status not found.', 404);
|
||||
$stmt = $db->prepare('UPDATE statuses SET status_name=?,status_classes=?,status_active=?,status_default=?,status_changer=?,status_changed=? WHERE status_serial=?');
|
||||
$stmt->execute([$name, $classes, (int) $active, (int) $default, $_SESSION[self::USER_NAME] ?? '', $this->getTimestamp(), $id]);
|
||||
} else {
|
||||
$stmt = $db->prepare('INSERT INTO statuses(status_name,status_classes,status_active,status_default,status_creator,status_created) VALUES(?,?,?,?,?,?)');
|
||||
$stmt->execute([$name, $classes, (int) $active, (int) $default, $_SESSION[self::USER_NAME] ?? '', $this->getTimestamp()]);
|
||||
}
|
||||
$db->commit();
|
||||
} catch (Throwable $e) {
|
||||
if ($db->inTransaction())
|
||||
$db->rollBack();throw $e;
|
||||
}
|
||||
}
|
||||
}
|
||||
+2
-93
@@ -1,96 +1,5 @@
|
||||
<?php
|
||||
|
||||
// =============
|
||||
// Metrics Class
|
||||
// =============
|
||||
|
||||
/** Roster overview replaces the unused work-order metrics module. */
|
||||
class Metrics extends Base {
|
||||
|
||||
// ==================
|
||||
// Class Constructor.
|
||||
// ==================
|
||||
|
||||
public function __construct() {
|
||||
|
||||
set_error_handler(array($this,"displayError"));
|
||||
|
||||
date_default_timezone_set(self::TIMEZONE);
|
||||
|
||||
}
|
||||
|
||||
// ========================================
|
||||
// Return a Daily Work Order Metrics Object
|
||||
// ========================================
|
||||
|
||||
private function getDailyWorkOrderMetrics() {
|
||||
|
||||
$SQL = "select dayname(workorder_created) as day,
|
||||
count(*) as count
|
||||
from view_workorders
|
||||
where date(workorder_created) >= (date(now()) - interval 6 day)
|
||||
group by day
|
||||
order by workorder_created desc";
|
||||
|
||||
return $this->getTable("dailymetrics", $SQL);
|
||||
|
||||
}
|
||||
|
||||
// =====================================
|
||||
// Return a Monthly Call Metrics Object.
|
||||
// =====================================
|
||||
|
||||
private function getMonthlyWorkOrderMetrics() {
|
||||
|
||||
$SQL = "select monthname(workorder_created) as month,
|
||||
count(*) as count
|
||||
from view_workorders
|
||||
where date(workorder_created) >= (date(now()) - interval 6 month)
|
||||
group by month
|
||||
order by workorder_created desc";
|
||||
|
||||
return $this->getTable("monthlymetrics", $SQL);
|
||||
|
||||
}
|
||||
|
||||
// =========================================
|
||||
// Return a Weekly Work Order Metrics Object
|
||||
// =========================================
|
||||
|
||||
private function getWeeklyWorkOrderMetrics() {
|
||||
|
||||
$SQL = "select week(workorder_created) as week,
|
||||
count(*) as count
|
||||
from view_workorders
|
||||
where date(workorder_created) >= (date(now()) - interval 6 week)
|
||||
group by week
|
||||
order by workorder_created desc";
|
||||
|
||||
return $this->getTable("weeklymetrics", $SQL);
|
||||
|
||||
}
|
||||
|
||||
// =======================
|
||||
// View Work Order Metrics
|
||||
// =======================
|
||||
|
||||
public function viewWorkOrderMetrics() {
|
||||
|
||||
$dailymetrics = $this->getDailyWorkOrderMetrics();
|
||||
$weeklymetrics = $this->getWeeklyWorkOrderMetrics();
|
||||
$monthlymetrics = $this->getMonthlyWorkorderMetrics();
|
||||
|
||||
$XSLParms["DAILYMETRICS"] = $dailymetrics->asXML();
|
||||
$XSLParms["WEEKLYMETRICS"] = $weeklymetrics->asXML();
|
||||
$XSLParms["MONTHLYMETRICS"] = $monthlymetrics->asXML();
|
||||
|
||||
$content = $this->applyXSL("", $this->getXSL("viewWorkOrderMetrics"), $XSLParms);
|
||||
|
||||
$this->displayContent($content);
|
||||
|
||||
exit();
|
||||
|
||||
}
|
||||
|
||||
public function viewRosterMetrics(){(new Roster())->home();}
|
||||
}
|
||||
|
||||
?>
|
||||
|
||||
+28
-237
@@ -1,244 +1,35 @@
|
||||
<?php
|
||||
|
||||
// ===========
|
||||
// Notes Class
|
||||
// ===========
|
||||
|
||||
class Notes extends Base {
|
||||
|
||||
// Constants
|
||||
|
||||
const VALID_TYPES = array("User");
|
||||
|
||||
// Variables
|
||||
|
||||
|
||||
private $current_user_name = "";
|
||||
private $current_user_email = "";
|
||||
|
||||
// =================
|
||||
// Class Constructor
|
||||
// =================
|
||||
|
||||
public function __construct() {
|
||||
|
||||
set_error_handler(array($this, "displayError"));
|
||||
|
||||
date_default_timezone_set(self::TIMEZONE);
|
||||
|
||||
|
||||
$this->current_user_name = $_SESSION[self::USER_NAME] ?? "";
|
||||
$this->current_user_email = $_SESSION[self::USER_EMAIL] ?? "";
|
||||
public const VALID_TYPES=['User'];
|
||||
public function addNote(){
|
||||
if(($_POST['step']??'prompt')==='add'){$this->write(0,$_POST['note_type']??'',MaintenanceService::id($_POST['note_reference']??null),$_POST['note_text']??'');return;}
|
||||
$type=$_POST['note_type']??'';$id=MaintenanceService::id($_POST['note_reference']??null);
|
||||
if(!in_array($type,self::VALID_TYPES,true))throw new AppException('Unsupported note type.');
|
||||
$stmt=$this->connect()->prepare('SELECT user_serial FROM users WHERE user_serial=?');$stmt->execute([$id]);if(!$stmt->fetch())throw new AppException('User not found.',404);
|
||||
$this->displayContent($this->applyXSL('<XML/>',$this->getXSL('addNote'),['NOTE_TYPE'=>$type,'NOTE_REFERENCE'=>$id]));
|
||||
}
|
||||
|
||||
// ========
|
||||
// Add Note
|
||||
// ========
|
||||
|
||||
public function addNote() {
|
||||
|
||||
$step = filter_input(INPUT_POST, "step") ?: "prompt";
|
||||
|
||||
switch ($step) {
|
||||
|
||||
case "prompt":
|
||||
|
||||
$note_type = filter_input(INPUT_POST, "note_type") ?: "";
|
||||
$note_reference = filter_input(INPUT_POST, "note_reference", FILTER_SANITIZE_NUMBER_INT) ?: 0;
|
||||
|
||||
if (!in_array($note_type, self::VALID_TYPES) or empty($note_reference)) {
|
||||
$this->logError("Invalid Note Type ({$note_type} or Reference {$note_reference} " . __METHOD__);
|
||||
}
|
||||
|
||||
$XSLParms["NOTE_TYPE"] = $note_type;
|
||||
$XSLParms["NOTE_REFERENCE"] = $note_reference;
|
||||
|
||||
$content = $this->applyXSL("", $this->getXSL("addNote"), $XSLParms);
|
||||
|
||||
$this->displayContent($content);
|
||||
|
||||
break;
|
||||
|
||||
case "add":
|
||||
|
||||
$note_type = filter_input(INPUT_POST, "note_type") ?: "";
|
||||
$note_reference = filter_input(INPUT_POST, "note_reference", FILTER_SANITIZE_NUMBER_INT) ?: 0;
|
||||
$note_text = filter_input(INPUT_POST, "note_text") ?: "";
|
||||
|
||||
$SQL = "insert into notes
|
||||
|
||||
( note_type,
|
||||
note_reference,
|
||||
note_text,
|
||||
note_origin )
|
||||
|
||||
VALUES ( :note_type,
|
||||
:note_reference,
|
||||
:note_text,
|
||||
:note_origin )";
|
||||
|
||||
$statement = $this->connect()->prepare($SQL);
|
||||
|
||||
$statement->bindValue(":note_type", $note_type);
|
||||
$statement->bindValue(":note_reference", $note_reference);
|
||||
$statement->bindValue(":note_text", $note_text);
|
||||
$statement->bindValue(":note_origin", $this->current_user_name);
|
||||
|
||||
$statement->execute();
|
||||
|
||||
break;
|
||||
|
||||
default:
|
||||
|
||||
$this->displayHome();
|
||||
}
|
||||
public function editNote(){
|
||||
$id=MaintenanceService::id($_POST['note_serial']??null);$note=$this->getNote($id);
|
||||
if(!$note->count())throw new AppException('Note not found.',404);
|
||||
if(($_POST['step']??'prompt')==='update'){$this->write($id,(string)$note->record->note_type,(int)$note->record->note_reference,$_POST['note_text']??'');return;}
|
||||
$this->displayContent($this->applyXSL($note,$this->getXSL('editNote')));
|
||||
}
|
||||
|
||||
// ===========
|
||||
// Delete Note
|
||||
// ===========
|
||||
|
||||
public function deleteNote() {
|
||||
|
||||
$note_serial = filter_input(INPUT_POST, "note_serial", FILTER_SANITIZE_NUMBER_INT) ?: 0;
|
||||
|
||||
$note = $this->getNote($note_serial);
|
||||
|
||||
if ($note->count() == 0) {
|
||||
$this->logError("Invalid Note ({$note_serial}). " . __METHOD__);
|
||||
}
|
||||
|
||||
$SQL = "delete from notes
|
||||
where note_serial = {$note_serial}";
|
||||
|
||||
$this->executeSQL($SQL);
|
||||
public function deleteNote(){
|
||||
$stmt=$this->connect()->prepare('DELETE FROM notes WHERE note_serial=?');$stmt->execute([MaintenanceService::id($_POST['note_serial']??null)]);if(!$stmt->rowCount())throw new AppException('Note not found.',404);
|
||||
}
|
||||
|
||||
// =========
|
||||
// Edit Note
|
||||
// =========
|
||||
|
||||
public function editNote() {
|
||||
|
||||
$step = filter_input(INPUT_POST, "step") ?: "prompt";
|
||||
|
||||
switch ($step) {
|
||||
|
||||
case "prompt":
|
||||
|
||||
$note_serial = filter_input(INPUT_POST, "note_serial", FILTER_SANITIZE_NUMBER_INT) ?: 0;
|
||||
|
||||
$note = $this->getNote($note_serial);
|
||||
|
||||
if ($note->count() == 0) {
|
||||
$this->logError("Invalid Note ({$note_serial}). " . __METHOD__);
|
||||
}
|
||||
|
||||
$XML = $this->mergeXML($note, "<XML/>");
|
||||
|
||||
$content = $this->applyXSL($XML, $this->getXSL("editNote"));
|
||||
|
||||
$this->displayContent($content);
|
||||
|
||||
break;
|
||||
|
||||
case "update":
|
||||
|
||||
$note_serial = filter_input(INPUT_POST, "note_serial", FILTER_SANITIZE_NUMBER_INT) ?: 0;
|
||||
$note_text = filter_input(INPUT_POST, "note_text") ?: "";
|
||||
|
||||
$SQL = "update notes
|
||||
set note_text = :note_text,
|
||||
note_origin = :note_origin
|
||||
where note_serial = :note_serial";
|
||||
|
||||
$statement = $this->connect()->prepare($SQL);
|
||||
|
||||
$statement->bindValue(":note_serial", $note_serial);
|
||||
$statement->bindValue(":note_text", $note_text);
|
||||
$statement->bindValue(":note_origin", $this->current_user_name);
|
||||
|
||||
$statement->execute();
|
||||
|
||||
break;
|
||||
|
||||
default:
|
||||
|
||||
$this->displayHome();
|
||||
}
|
||||
}
|
||||
|
||||
// ====================
|
||||
// Return a Note Object
|
||||
// ====================
|
||||
|
||||
public function getNote($note_serial = 0) {
|
||||
|
||||
$SQL = "select view_notes.*
|
||||
from view_notes
|
||||
where note_serial = {$note_serial}";
|
||||
|
||||
return $this->getTable("notes", $SQL);
|
||||
}
|
||||
|
||||
// =====================
|
||||
// Return a Notes Object
|
||||
// =====================
|
||||
|
||||
public function getNotes($note_type = "", $note_reference = 0) {
|
||||
|
||||
$SQL = "select view_notes.*
|
||||
from view_notes
|
||||
where note_type = '{$note_type}'
|
||||
and note_reference = {$note_reference}
|
||||
order by note_timestamp asc";
|
||||
|
||||
return $this->getTable("notes", $SQL);
|
||||
}
|
||||
|
||||
// =====================
|
||||
// Return a Notes Object
|
||||
// =====================
|
||||
|
||||
public function getAllNotes($note_reference = 0) {
|
||||
|
||||
$SQL = "select view_notes.*
|
||||
from view_notes
|
||||
where note_reference = {$note_reference}
|
||||
order by note_timestamp asc";
|
||||
|
||||
// $this->debug($SQL);
|
||||
|
||||
return $this->getTable("notes", $SQL);
|
||||
}
|
||||
|
||||
// ==============
|
||||
// Add Note Quick
|
||||
// ==============
|
||||
|
||||
public function addNoteQuick($note_type = "", $note_reference = 0, $note_text = "") {
|
||||
|
||||
$SQL = "insert into notes
|
||||
|
||||
( note_type,
|
||||
note_reference,
|
||||
note_text,
|
||||
note_origin )
|
||||
|
||||
VALUES ( :note_type,
|
||||
:note_reference,
|
||||
:note_text,
|
||||
:note_origin )";
|
||||
|
||||
$statement = $this->connect()->prepare($SQL);
|
||||
|
||||
$statement->bindValue(":note_type", $note_type);
|
||||
$statement->bindValue(":note_reference", $note_reference);
|
||||
$statement->bindValue(":note_text", $note_text);
|
||||
$statement->bindValue(":note_origin", $this->current_user_name);
|
||||
|
||||
$statement->execute();
|
||||
public function write(int $id,string $type,int $reference,string $text): int {
|
||||
$text=trim($text);if(!in_array($type,self::VALID_TYPES,true)||!$text||strlen($text)>65535)throw new AppException('Enter a valid user note (up to 65,535 bytes).');
|
||||
$db=$this->connect();$db->beginTransaction();
|
||||
try{
|
||||
$stmt=$db->prepare('SELECT user_serial FROM users WHERE user_serial=? FOR UPDATE');$stmt->execute([$reference]);if(!$stmt->fetch())throw new AppException('The note must refer to an existing user.',409);
|
||||
if($id){$stmt=$db->prepare('SELECT note_serial FROM notes WHERE note_serial=? AND note_type=? AND note_reference=? FOR UPDATE');$stmt->execute([$id,$type,$reference]);if(!$stmt->fetch())throw new AppException('Note not found.',404);
|
||||
$stmt=$db->prepare('UPDATE notes SET note_text=?,note_origin=? WHERE note_serial=?');$stmt->execute([$text,$_SESSION[self::USER_NAME]??'',$id]);
|
||||
}else{$stmt=$db->prepare('INSERT INTO notes(note_type,note_reference,note_text,note_origin) VALUES(?,?,?,?)');$stmt->execute([$type,$reference,$text,$_SESSION[self::USER_NAME]??'']);$id=(int)$db->lastInsertId();}
|
||||
$db->commit();return $id;
|
||||
}catch(Throwable $e){if($db->inTransaction())$db->rollBack();throw $e;}
|
||||
}
|
||||
public function getNote($id=0){$stmt=$this->connect()->prepare('SELECT * FROM view_notes WHERE note_serial=?');$stmt->execute([(int)$id]);return $this->getTableXML('notes',$stmt);}
|
||||
public function getNotes($type='',$reference=0){$stmt=$this->connect()->prepare('SELECT * FROM view_notes WHERE note_type=? AND note_reference=? ORDER BY note_timestamp DESC,note_serial DESC');$stmt->execute([$type,(int)$reference]);return $this->getTableXML('notes',$stmt);}
|
||||
public function getAllNotes($reference=0){return $this->getNotes('User',$reference);}
|
||||
public function addNoteQuick($type='',$reference=0,$text=''){return $this->write(0,$type,(int)$reference,$text);}
|
||||
}
|
||||
|
||||
?>
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
<?php
|
||||
final class Pagination {
|
||||
public static function calculate(int $count,int $size,int $current,string $action=''): array {
|
||||
$count=max(0,$count);$size=max(1,min(100,$size));$last=max(1,(int)ceil($count/$size));
|
||||
$page=match($action){'first'=>1,'previous'=>$current-1,'next'=>$current+1,'last'=>$last,default=>$current};
|
||||
$page=max(1,min($last,$page));$offset=($page-1)*$size;
|
||||
return ['size'=>$size,'page'=>$page,'offset'=>$offset,'start'=>$count?$offset+1:0,'end'=>min($count,$offset+$size)];
|
||||
}
|
||||
public static function session(string $scope,int $count,int $size,string $action=''): array {
|
||||
if(!is_array($_SESSION[Base::PAGINATION_PAGE]??null))$_SESSION[Base::PAGINATION_PAGE]=[];
|
||||
$result=self::calculate($count,$size,(int)($_SESSION[Base::PAGINATION_PAGE][$scope]??1),$action);
|
||||
$_SESSION[Base::PAGINATION_PAGE][$scope]=$result['page'];return $result;
|
||||
}
|
||||
}
|
||||
+6
-18
@@ -28,7 +28,7 @@ class Popovers extends Base {
|
||||
$this->current_user_name = $_SESSION[self::USER_NAME] ?? "";
|
||||
$this->current_user_email = $_SESSION[self::USER_EMAIL] ?? "";
|
||||
|
||||
$this->pagination_page = $_SESSION[self::PAGINATION_PAGE] ?? 1;
|
||||
$this->pagination_page = 1;
|
||||
}
|
||||
|
||||
// ===========
|
||||
@@ -229,7 +229,7 @@ class Popovers extends Base {
|
||||
// Find Criteria
|
||||
|
||||
$find_popover = filter_input(INPUT_POST, "find_popover") ?: "";
|
||||
$find_words = $this->sanitizeString($find_popover);
|
||||
$find_words = preg_quote(trim($find_popover));
|
||||
|
||||
// Show Per Page
|
||||
|
||||
@@ -259,21 +259,9 @@ class Popovers extends Base {
|
||||
|
||||
$pagination = filter_input(INPUT_POST, "pagination") ?: "";
|
||||
|
||||
switch ($pagination) {
|
||||
|
||||
case "first" : $this->pagination_page = 1;
|
||||
break;
|
||||
case "previous" : $this->pagination_page = max(($this->pagination_page - 1), 1);
|
||||
break;
|
||||
case "next" : $this->pagination_page = ($this->pagination_page + 1);
|
||||
break;
|
||||
case "last" : $this->pagination_page = ($count / $this->pagination_size);
|
||||
break;
|
||||
}
|
||||
|
||||
$offset = (($this->pagination_page * $this->pagination_size) - $this->pagination_size);
|
||||
$start = ($offset + 1);
|
||||
$end = min($count, (($start + $this->pagination_size) - 1));
|
||||
$paging=Pagination::session(__METHOD__,$count,(int)$this->pagination_size,$pagination);
|
||||
$this->pagination_size=$paging['size'];$this->pagination_page=$paging['page'];
|
||||
$offset=$paging['offset'];$start=$paging['start'];$end=$paging['end'];
|
||||
|
||||
// Data
|
||||
|
||||
@@ -281,6 +269,7 @@ class Popovers extends Base {
|
||||
from view_popovers
|
||||
where popover_name regexp :popover_name
|
||||
or popover_title regexp :popover_title
|
||||
order by popover_serial
|
||||
limit {$this->pagination_size}
|
||||
offset {$offset}";
|
||||
|
||||
@@ -300,7 +289,6 @@ class Popovers extends Base {
|
||||
$popovers->addAttribute("end", $end);
|
||||
$popovers->addAttribute("page", $this->pagination_page);
|
||||
|
||||
$_SESSION[self::PAGINATION_PAGE] = $this->pagination_page;
|
||||
|
||||
return $popovers;
|
||||
}
|
||||
|
||||
+2
-476
@@ -1,479 +1,5 @@
|
||||
<?php
|
||||
|
||||
// =============
|
||||
// Reports Class
|
||||
// =============
|
||||
|
||||
class Reports extends Base {
|
||||
|
||||
// Variables.
|
||||
|
||||
|
||||
private $current_user_name = "";
|
||||
private $current_user_email = "";
|
||||
private $pagination_size = 20;
|
||||
private $pagination_page = 1;
|
||||
|
||||
// =================
|
||||
// Class Constructor
|
||||
// =================
|
||||
|
||||
public function __construct() {
|
||||
|
||||
set_error_handler(array($this, "displayError"));
|
||||
|
||||
date_default_timezone_set(self::TIMEZONE);
|
||||
|
||||
|
||||
$this->current_user_name = $_SESSION[self::USER_NAME] ?? "";
|
||||
$this->current_user_email = $_SESSION[self::USER_EMAIL] ?? "";
|
||||
|
||||
$this->pagination_page = $_SESSION[self::PAGINATION_PAGE] ?? 1;
|
||||
}
|
||||
|
||||
// ==========
|
||||
// Add Report
|
||||
// ==========
|
||||
|
||||
public function addReport() {
|
||||
|
||||
$step = filter_input(INPUT_POST, "step") ?: "prompt";
|
||||
|
||||
switch ($step) {
|
||||
|
||||
case "prompt":
|
||||
|
||||
$constants = $this->getConstants();
|
||||
|
||||
$XML = $this->mergeXML($constants, "<XML/>");
|
||||
|
||||
$content = $this->applyXSL($XML, $this->getXSL("addReport"));
|
||||
|
||||
$this->displayContent($content);
|
||||
|
||||
break;
|
||||
|
||||
case "add":
|
||||
|
||||
$report_name = filter_input(INPUT_POST, "report_name") ?: "";
|
||||
$report_class = filter_input(INPUT_POST, "report_class") ?: "";
|
||||
$report_type = filter_input(INPUT_POST, "report_type") ?: "Report";
|
||||
$report_description = filter_input(INPUT_POST, "report_description") ?: "";
|
||||
$report_target = filter_input(INPUT_POST, "report_target") ?: "";
|
||||
|
||||
$report_class = trim(preg_replace("/\s+/", " ", $report_class));
|
||||
|
||||
$SQL = "insert into reports
|
||||
|
||||
( report_class,
|
||||
report_type,
|
||||
report_name,
|
||||
report_description,
|
||||
report_target )
|
||||
|
||||
VALUES ( :report_class,
|
||||
:report_type,
|
||||
:report_name,
|
||||
:report_description,
|
||||
:report_target)";
|
||||
|
||||
$statement = $this->connect()->prepare($SQL);
|
||||
|
||||
$statement->bindValue(":report_class", $report_class);
|
||||
$statement->bindValue(":report_type", $report_type);
|
||||
$statement->bindValue(":report_name", $report_name);
|
||||
$statement->bindValue(":report_description", $report_description);
|
||||
$statement->bindValue(":report_target", $report_target);
|
||||
|
||||
$statement->execute();
|
||||
|
||||
break;
|
||||
|
||||
default:
|
||||
|
||||
$this->displayHome();
|
||||
}
|
||||
}
|
||||
|
||||
// =============
|
||||
// Delete Report
|
||||
// =============
|
||||
|
||||
public function deleteReport() {
|
||||
|
||||
$report_serial = filter_input(INPUT_POST, "report_serial", FILTER_SANITIZE_NUMBER_INT) ?: 0;
|
||||
|
||||
$reports = $this->getReport($report_serial);
|
||||
|
||||
if ($reports->count() == 0) {
|
||||
$this->logError("Invalid Report ({$report_serial}): " . __METHOD__);
|
||||
}
|
||||
|
||||
$SQL = "delete from reports
|
||||
where report_serial = {$report_serial}";
|
||||
|
||||
$this->executeSQL($SQL);
|
||||
}
|
||||
|
||||
// ===========
|
||||
// Edit Report
|
||||
// ===========
|
||||
|
||||
public function editReport() {
|
||||
|
||||
$step = filter_input(INPUT_POST, "step") ?: "prompt";
|
||||
|
||||
switch ($step) {
|
||||
|
||||
case "prompt":
|
||||
|
||||
$report_serial = filter_input(INPUT_POST, "report_serial", FILTER_SANITIZE_NUMBER_INT) ?: 0;
|
||||
|
||||
$reports = $this->getReport($report_serial);
|
||||
|
||||
if ($reports->count() == 0) {
|
||||
$this->logError("Invalid Report ({$report_serial}): " . __METHOD__);
|
||||
}
|
||||
|
||||
$constants = $this->getConstants();
|
||||
|
||||
$XML = $this->mergeXML($reports, "<XML/>");
|
||||
$XML = $this->mergeXML($constants, $XML);
|
||||
|
||||
$content = $this->applyXSL($XML, $this->getXSL("editReport"));
|
||||
|
||||
$this->displayContent($content);
|
||||
|
||||
break;
|
||||
|
||||
case "update":
|
||||
|
||||
$report_serial = filter_input(INPUT_POST, "report_serial", FILTER_SANITIZE_NUMBER_INT) ?: 0;
|
||||
$report_name = filter_input(INPUT_POST, "report_name") ?: "";
|
||||
$report_class = filter_input(INPUT_POST, "report_class") ?: "";
|
||||
$report_type = filter_input(INPUT_POST, "report_type") ?: "Report";
|
||||
$report_description = filter_input(INPUT_POST, "report_description") ?: "";
|
||||
$report_target = filter_input(INPUT_POST, "report_target") ?: "";
|
||||
|
||||
$report_class = trim(preg_replace("/\s+/", " ", $report_class));
|
||||
|
||||
$reports = $this->getReport($report_serial);
|
||||
|
||||
if ($reports->count() == 0) {
|
||||
$this->logError("Invalid Report ({$report_serial}): " . __METHOD__);
|
||||
}
|
||||
|
||||
$SQL = "update reports
|
||||
set report_class = :report_class,
|
||||
report_type = :report_type,
|
||||
report_name = :report_name,
|
||||
report_description = :report_description,
|
||||
report_target = :report_target
|
||||
where report_serial = :report_serial";
|
||||
|
||||
$statement = $this->connect()->prepare($SQL);
|
||||
|
||||
$statement->bindValue(":report_serial", $report_serial);
|
||||
$statement->bindValue(":report_class", $report_class);
|
||||
$statement->bindValue(":report_type", $report_type);
|
||||
$statement->bindValue(":report_name", $report_name);
|
||||
$statement->bindValue(":report_description", $report_description);
|
||||
$statement->bindValue(":report_target", $report_target);
|
||||
|
||||
$statement->execute();
|
||||
|
||||
break;
|
||||
|
||||
default:
|
||||
|
||||
$this->displayHome();
|
||||
}
|
||||
}
|
||||
|
||||
// ======================
|
||||
// Return a Report Object
|
||||
// ======================
|
||||
|
||||
public function getReport($report_serial = 0) {
|
||||
|
||||
$SQL = "select view_reports.*
|
||||
from view_reports
|
||||
where report_serial = {$report_serial}";
|
||||
|
||||
return $this->getTable("reports", $SQL);
|
||||
}
|
||||
|
||||
// ===========================================
|
||||
// Return the report_serial for a report_class
|
||||
// ===========================================
|
||||
|
||||
public function getReportSerial($report_class = "") {
|
||||
|
||||
$SQL = "select view_reports.report_serial
|
||||
from view_reports
|
||||
where report_class = '{$report_class}'";
|
||||
|
||||
$reports = $this->getTable("reports", $SQL);
|
||||
|
||||
return (int) ($reports->record->report_serial ?? 0);
|
||||
}
|
||||
|
||||
// =======================
|
||||
// Return a Reports Object
|
||||
// =======================
|
||||
|
||||
public function getReports() {
|
||||
|
||||
$SQL = "select view_reports.*
|
||||
from view_reports";
|
||||
|
||||
return $this->getTable("reports", $SQL);
|
||||
}
|
||||
|
||||
// ============================
|
||||
// Return a Reports Page Object
|
||||
// ============================
|
||||
|
||||
public function getReportsPage() {
|
||||
|
||||
// Find Criteria
|
||||
|
||||
$find_report = filter_input(INPUT_POST, "find_report") ?: "";
|
||||
$find_words = $this->sanitizeString($find_report);
|
||||
|
||||
// $find_words = implode("|", explode(" ", $find_report));
|
||||
// Show Per Page
|
||||
|
||||
$this->pagination_size = filter_input(INPUT_POST, "reports_per_page", FILTER_SANITIZE_NUMBER_INT) ?: $this->pagination_size;
|
||||
|
||||
// Record Count
|
||||
|
||||
$connection = $this->connect();
|
||||
|
||||
$SQL = "select count(*) as count
|
||||
from view_reports
|
||||
where (report_class regexp :report_class
|
||||
or report_name regexp :report_name
|
||||
or report_type regexp :report_type
|
||||
or report_description regexp :report_description)";
|
||||
|
||||
$statement = $connection->prepare($SQL);
|
||||
|
||||
$statement->bindValue(":report_class", $find_words);
|
||||
$statement->bindValue(":report_name", $find_words);
|
||||
$statement->bindValue(":report_type", $find_words);
|
||||
$statement->bindValue(":report_description", $find_words);
|
||||
|
||||
$statement->execute();
|
||||
|
||||
$recordset = $statement->fetch(PDO::FETCH_ASSOC);
|
||||
|
||||
$count = (count($recordset) > 0) ? (int) $recordset["count"] : (int) 0;
|
||||
|
||||
// Pagination
|
||||
|
||||
$pagination = filter_input(INPUT_POST, "pagination") ?: "";
|
||||
|
||||
switch ($pagination) {
|
||||
|
||||
case "first" : $this->pagination_page = 1;
|
||||
break;
|
||||
case "previous" : $this->pagination_page = max(($this->pagination_page - 1), 1);
|
||||
break;
|
||||
case "next" : $this->pagination_page = ($this->pagination_page + 1);
|
||||
break;
|
||||
case "last" : $this->pagination_page = ($count / $this->pagination_size);
|
||||
break;
|
||||
}
|
||||
|
||||
$offset = (($this->pagination_page * $this->pagination_size) - $this->pagination_size);
|
||||
$start = ($offset + 1);
|
||||
$end = min($count, (($start + $this->pagination_size) - 1));
|
||||
|
||||
// Data
|
||||
|
||||
$SQL = "select view_reports.*
|
||||
from view_reports
|
||||
where (report_class regexp :report_class
|
||||
or report_name regexp :report_name
|
||||
or report_type regexp :report_type
|
||||
or report_description regexp :report_description)
|
||||
limit {$this->pagination_size}
|
||||
offset {$offset}";
|
||||
|
||||
$statement = $connection->prepare($SQL);
|
||||
|
||||
$statement->bindValue(":report_class", $find_words);
|
||||
$statement->bindValue(":report_name", $find_words);
|
||||
$statement->bindValue(":report_type", $find_words);
|
||||
$statement->bindValue(":report_description", $find_words);
|
||||
|
||||
$statement->execute();
|
||||
|
||||
$reports = $this->getTableXML("reports", $statement);
|
||||
|
||||
// Insert Pagination Attributes
|
||||
|
||||
$reports->addAttribute("count", $count);
|
||||
$reports->addAttribute("start", $start);
|
||||
$reports->addAttribute("end", $end);
|
||||
$reports->addAttribute("page", $this->pagination_page);
|
||||
|
||||
$_SESSION[self::PAGINATION_PAGE] = $this->pagination_page;
|
||||
|
||||
return $reports;
|
||||
}
|
||||
|
||||
// ==================================
|
||||
// Return a Select Report Page Object
|
||||
// ==================================
|
||||
|
||||
public function getSelectReportPage() {
|
||||
|
||||
// Find Criteria
|
||||
|
||||
$select_report = filter_input(INPUT_POST, "select_report") ?: "";
|
||||
$find_words = $this->sanitizeString($select_report);
|
||||
|
||||
// Show Per Page
|
||||
|
||||
$this->pagination_size = filter_input(INPUT_POST, "reports_per_page", FILTER_SANITIZE_NUMBER_INT) ?: $this->pagination_size;
|
||||
|
||||
// Record Count
|
||||
|
||||
$connection = $this->connect();
|
||||
|
||||
$SQL = "select count(*) as count
|
||||
from view_reports
|
||||
where report_name regexp :report_name
|
||||
or report_description regexp :report_description";
|
||||
|
||||
$statement = $connection->prepare($SQL);
|
||||
|
||||
$statement->bindValue(":report_name", $find_words);
|
||||
$statement->bindValue(":report_description", $find_words);
|
||||
|
||||
$statement->execute();
|
||||
|
||||
$recordset = $statement->fetch(PDO::FETCH_ASSOC);
|
||||
|
||||
$count = (count($recordset) > 0) ? (int) $recordset["count"] : (int) 0;
|
||||
|
||||
// Pagination
|
||||
|
||||
$pagination = filter_input(INPUT_POST, "pagination") ?: "";
|
||||
|
||||
switch ($pagination) {
|
||||
|
||||
case "first" : $this->pagination_page = 1;
|
||||
break;
|
||||
case "previous" : $this->pagination_page = max(($this->pagination_page - 1), 1);
|
||||
break;
|
||||
case "next" : $this->pagination_page = ($this->pagination_page + 1);
|
||||
break;
|
||||
case "last" : $this->pagination_page = ($count / $this->pagination_size);
|
||||
break;
|
||||
}
|
||||
|
||||
$offset = (($this->pagination_page * $this->pagination_size) - $this->pagination_size);
|
||||
$start = ($offset + 1);
|
||||
$end = min($count, (($start + $this->pagination_size) - 1));
|
||||
|
||||
// Data
|
||||
|
||||
$SQL = "select view_reports.*
|
||||
from view_reports
|
||||
where report_name regexp :report_name
|
||||
or report_description regexp :report_description
|
||||
limit {$this->pagination_size}
|
||||
offset {$offset}";
|
||||
|
||||
$statement = $connection->prepare($SQL);
|
||||
|
||||
$statement->bindValue(":report_name", $find_words);
|
||||
$statement->bindValue(":report_description", $find_words);
|
||||
|
||||
$statement->execute();
|
||||
|
||||
$reports = $this->getTableXML("reports", $statement);
|
||||
|
||||
// Insert Pagination Attributes
|
||||
|
||||
$reports->addAttribute("count", $count);
|
||||
$reports->addAttribute("start", $start);
|
||||
$reports->addAttribute("end", $end);
|
||||
$reports->addAttribute("page", $this->pagination_page);
|
||||
|
||||
$_SESSION[self::PAGINATION_PAGE] = $this->pagination_page;
|
||||
|
||||
return $reports;
|
||||
}
|
||||
|
||||
// ====================================
|
||||
// Determine if the Report Class Exists
|
||||
// ====================================
|
||||
|
||||
public function reportExists() {
|
||||
|
||||
$exists = false;
|
||||
|
||||
$report_serial = filter_input(INPUT_POST, "report_serial", FILTER_SANITIZE_NUMBER_INT) ?: 0;
|
||||
$report_class = filter_input(INPUT_POST, "report_class") ?: "";
|
||||
|
||||
$report_class = trim(preg_replace("/\s+/", " ", $report_class));
|
||||
|
||||
$SQL = "select report_serial
|
||||
from view_reports
|
||||
where report_serial != :report_serial
|
||||
and lower(report_class) = :report_class";
|
||||
|
||||
$statement = $this->connect()->prepare($SQL);
|
||||
|
||||
$statement->bindValue(":report_serial", $report_serial);
|
||||
$statement->bindValue(":report_class", $report_class);
|
||||
|
||||
$statement->execute();
|
||||
|
||||
$recordset = $statement->fetchAll(PDO::FETCH_ASSOC);
|
||||
|
||||
$exists = (count($recordset) > 0) ? true : false;
|
||||
|
||||
echo json_encode($exists);
|
||||
}
|
||||
|
||||
// ================
|
||||
// Select a Report.
|
||||
// ================
|
||||
|
||||
public function selectReport() {
|
||||
|
||||
$reports = $this->getSelectReportPage();
|
||||
$popovers = (new Popovers())->getPopovers();
|
||||
|
||||
$XML = $this->mergeXML($reports, "<XML/>");
|
||||
$XML = $this->mergeXML($popovers, $XML);
|
||||
|
||||
$content = $this->applyXSL($XML, $this->getXSL("selectReport"));
|
||||
|
||||
$this->displayContent($content);
|
||||
}
|
||||
|
||||
// =============
|
||||
// Setup Reports
|
||||
// =============
|
||||
|
||||
public function setupReports() {
|
||||
|
||||
$reports = $this->getReportsPage();
|
||||
$popovers = (new Popovers())->getPopovers();
|
||||
|
||||
$XML = $this->mergeXML($reports, "<XML/>");
|
||||
$XML = $this->mergeXML($popovers, $XML);
|
||||
|
||||
$content = $this->applyXSL($XML, $this->getXSL("setupReports"));
|
||||
|
||||
$this->displayContent($content);
|
||||
}
|
||||
public function selectReport(){(new RosterReports())->catalog();}
|
||||
public function setupReports(){(new RosterReports())->catalog();}
|
||||
}
|
||||
|
||||
?>
|
||||
|
||||
@@ -0,0 +1,97 @@
|
||||
<?php
|
||||
class Roster extends Base {
|
||||
private static function h(mixed $value): string{return htmlspecialchars((string)$value,ENT_QUOTES,'UTF-8');}
|
||||
private function form(string $step,int $club=0): string {
|
||||
return '<form method="post" action="main.php" class="row g-2 mb-3"><input type="hidden" name="action" value="Roster.workspace"><input type="hidden" name="csrf_token" value="'.self::h($this->csrfToken()).'"><input type="hidden" name="step" value="'.$step.'"><input type="hidden" name="club_serial" value="'.$club.'">';
|
||||
}
|
||||
private static function field(string $name,string $label,string $type='text',string $value='',int $max=128): string {
|
||||
$id=$name.'_'.bin2hex(random_bytes(3));
|
||||
return '<div class="col-md"><label class="form-label" for="'.$id.'">'.$label.'</label><input class="form-control" id="'.$id.'" type="'.$type.'" name="'.$name.'" value="'.self::h($value).'" maxlength="'.$max.'" required></div>';
|
||||
}
|
||||
public function help(): void {
|
||||
$this->displayContent('<main class="container py-3"><h1>Using your club workspace</h1><p>Open Clubs and dues to view clubs assigned to your account. Club rosters and your season dues are available inside each club.</p><p>Site administrators create clubs, assign existing accounts, add seasons, and record dues and received payments. Contact your club administrator for membership or account assistance.</p></main>');
|
||||
}
|
||||
public function home(): void {
|
||||
$service=new RosterService();$html='<div class="container-fluid py-3"><h1>Club workspace</h1><p>Club access requires a verified email. <a href="main.php?Xaction=verifyemail">Verify your email or request another link</a>.</p>';
|
||||
if(!$service->ready()){
|
||||
$html.=$service->administrator()?'<p>Club setup is ready to install. Run the database migrations described in PRODUCTION-FIXES.md.</p>':'<p>Your club workspace is being prepared. Please contact the site administrator.</p>';
|
||||
}else{
|
||||
$clubs=$service->clubs();
|
||||
if(!$clubs)$html.='<p>You are not assigned to an active club yet. A site administrator can create clubs and assign memberships.</p>';
|
||||
foreach($clubs as $club)$html.='<section class="card mb-3"><div class="card-body"><h2 class="h4">'.self::h($club['club_name']).'</h2><p>'.self::h(ucfirst($club['club_status'])).'</p><a class="btn btn-primary" href="main.php?Xaction=Roster.workspace&club_serial='.(int)$club['club_serial'].'">Open club</a></div></section>';
|
||||
$html.='<a href="main.php?Xaction=Roster.workspace">'.($service->administrator()?'Manage clubs and memberships':'View my clubs and dues').'</a>';
|
||||
}
|
||||
$this->displayContent($html.'</div>');
|
||||
}
|
||||
private function financialHistory(int $club,bool $active,bool $admin): string {
|
||||
$history=(new LedgerService())->history($club);$html='<h3 class="h4 mt-4">Payment history</h3>';
|
||||
if($admin)$html.='<form method="post" action="main.php"><input type="hidden" name="action" value="RosterReports.ledger"><input type="hidden" name="csrf_token" value="'.self::h($this->csrfToken()).'"><input type="hidden" name="club_serial" value="'.$club.'"><button class="btn btn-outline-primary">Export financial history (CSV)</button></form>';
|
||||
$html.='<div class="table-responsive"><table class="table"><thead><tr><th>Payment</th><th>Member</th><th>Date</th><th>Amount</th><th>Method / reference</th><th>Recorded by</th><th>Status</th></tr></thead><tbody>';
|
||||
foreach($history['payments'] as $p){
|
||||
$html.='<tr><td>#'.(int)$p['payment_serial'].'</td><td>'.self::h($p['member_name']).'</td><td>'.self::h($p['paid_on']).'</td><td>$'.number_format($p['amount_cents']/100,2).'</td><td>'.self::h($p['payment_method'].' '.$p['payment_reference']).'</td><td>'.self::h($p['actor']??'Historical record').'</td><td>';
|
||||
if($p['reversal_serial'])$html.='Reversed: '.self::h($p['reversal_reason'].' by '.$p['reversal_actor'].' on '.$p['reversed_at']);
|
||||
elseif($admin&&$active)$html.='<details><summary>Reverse incorrect payment</summary><p>This preserves the original entry and restores the amount owed. It does not send a refund.</p>'.$this->form('reverse',$club).'<input type="hidden" name="payment_serial" value="'.(int)$p['payment_serial'].'"><input type="hidden" name="request_key" value="'.bin2hex(random_bytes(32)).'">'.self::field('reason','Reversal reason','text','',500).'<button class="btn btn-outline-danger">Confirm reversal</button></form></details>';
|
||||
else $html.='Received';$html.='</td></tr>';
|
||||
}
|
||||
$html.='</tbody></table></div><h3 class="h4">Dues correction history</h3><ul>';
|
||||
foreach($history['adjustments'] as $a)$html.='<li>'.self::h($a['member_name']).': $'.number_format($a['amount_cents']/100,2).' adjustment; corrected total $'.number_format($a['corrected_amount_cents']/100,2).' — '.self::h($a['reason'].' ('.$a['actor'].', '.$a['created_at'].')').'</li>';
|
||||
return $html.'</ul>';
|
||||
}
|
||||
public function workspace(): void {
|
||||
$service=new RosterService();if(!$service->ready()){$this->home();return;}
|
||||
$clubRaw=$_POST['club_serial']??$_GET['club_serial']??'';$club=($clubRaw===''||$clubRaw==='0')?0:MaintenanceService::id($clubRaw);
|
||||
if(($_SERVER['REQUEST_METHOD']??'GET')==='POST' && ($_POST['step']??'')!==''){
|
||||
$service->requireAdministrator();$this->requireCsrf();
|
||||
switch($_POST['step']){
|
||||
case 'club':$club=$service->saveClub($club,$_POST['club_name']??'',$_POST['club_status']??'active');break;
|
||||
case 'member':$service->saveMember($club,empty($_POST['membership_serial'])?0:MaintenanceService::id($_POST['membership_serial']),$_POST['identifier']??'',$_POST['membership_status']??'active',$_POST['joined_on']??'');break;
|
||||
case 'season':$service->addSeason($club,$_POST['season_name']??'',$_POST['starts_on']??'',$_POST['ends_on']??'');break;
|
||||
case 'charge':$service->charge($club,MaintenanceService::id($_POST['membership_serial']??null),MaintenanceService::id($_POST['season_serial']??null),$_POST['amount']??'');break;
|
||||
case 'payment':$service->payment($club,MaintenanceService::id($_POST['due_serial']??null),$_POST['amount']??'',$_POST['paid_on']??'',$_POST['request_key']??'',$_POST['payment_method']??'unspecified',$_POST['payment_reference']??'');break;
|
||||
case 'adjust':(new LedgerService())->adjust($club,MaintenanceService::id($_POST['due_serial']??null),$_POST['corrected_amount']??'',$_POST['reason']??'',$_POST['request_key']??'');break;
|
||||
case 'reverse':(new LedgerService())->reverse($club,MaintenanceService::id($_POST['payment_serial']??null),$_POST['reason']??'',$_POST['request_key']??'');break;
|
||||
default:throw new AppException('Unknown club action.');
|
||||
}
|
||||
$_SESSION['roster_notice']='Changes saved.';header('Location: main.php?Xaction=Roster.workspace&club_serial='.$club,true,303);exit;
|
||||
}
|
||||
$html='<div class="container-fluid py-3"><h1>Clubs and memberships</h1>';
|
||||
if(!empty($_SESSION['roster_notice'])){$html.='<p class="alert alert-success" role="status">'.self::h($_SESSION['roster_notice']).'</p>';unset($_SESSION['roster_notice']);}
|
||||
$clubs=$service->clubs();$html.='<nav aria-label="Clubs" class="mb-3">';
|
||||
foreach($clubs as $row)$html.='<a class="btn btn-outline-primary me-2 mb-2" href="main.php?Xaction=Roster.workspace&club_serial='.(int)$row['club_serial'].'">'.self::h($row['club_name']).'</a>';
|
||||
$html.='</nav>';
|
||||
if($service->administrator())$html.='<section><h2 class="h4">Create club</h2>'.$this->form('club').self::field('club_name','Club name').' <div class="col-auto align-self-end"><button class="btn btn-primary">Create club</button></div></form></section>';
|
||||
if(!$club && $clubs)$club=(int)$clubs[0]['club_serial'];
|
||||
if(!$club){$this->displayContent($html.'<p>No clubs are available.</p></div>');return;}
|
||||
$selected=$service->club($club);$members=$service->members($club);$seasons=$service->seasons($club);$dues=$service->dues($club);
|
||||
$html.='<hr><h2>'.self::h($selected['club_name']).'</h2>';
|
||||
if($service->administrator()){
|
||||
$html.='<details class="mb-3"><summary>Edit or archive club</summary>'.$this->form('club',$club).self::field('club_name','Club name','text',$selected['club_name']).'<div class="col-md"><label class="form-label">Status<select class="form-select" name="club_status"><option value="active"'.($selected['club_status']==='active'?' selected':'').'>Active</option><option value="archived"'.($selected['club_status']==='archived'?' selected':'').'>Archived</option></select></label></div><div class="col-auto align-self-end"><button class="btn btn-primary">Save club</button></div></form></details>';
|
||||
}
|
||||
$html.='<h3 class="h4">Roster</h3><div class="table-responsive"><table class="table"><thead><tr><th>Name</th><th>Email</th><th>Membership</th><th>Joined</th>'.($service->administrator()?'<th>Update membership</th>':'').'</tr></thead><tbody>';
|
||||
foreach($members as $member){
|
||||
$html.='<tr><td>'.self::h($member['user_first_name'].' '.$member['user_last_name']).'</td><td>'.self::h($member['user_email']).'</td><td>'.self::h($member['membership_status']).'</td><td>'.self::h($member['joined_on']).'</td>';
|
||||
if($service->administrator())$html.='<td>'.$this->form('member',$club).'<input type="hidden" name="membership_serial" value="'.(int)$member['membership_serial'].'"><input type="hidden" name="joined_on" value="'.self::h($member['joined_on']).'"><label class="visually-hidden" for="membership_'.(int)$member['membership_serial'].'">Membership status for '.self::h($member['user_first_name'].' '.$member['user_last_name']).'</label><select class="form-select" name="membership_status" id="membership_'.(int)$member['membership_serial'].'"><option value="active"'.($member['membership_status']==='active'?' selected':'').'>Active</option><option value="inactive"'.($member['membership_status']==='inactive'?' selected':'').'>Inactive</option></select><button class="btn btn-outline-primary">Save membership</button></form></td>';
|
||||
$html.='</tr>';
|
||||
}
|
||||
if(!$members)$html.='<tr><td colspan="5">No members assigned.</td></tr>';
|
||||
$html.='</tbody></table></div>';
|
||||
if($service->administrator() && $selected['club_status']==='active')$html.='<h4 class="h5">Assign an existing account</h4>'.$this->form('member',$club).self::field('identifier','Username or email').self::field('joined_on','Joined on','date',date('Y-m-d')).'<input type="hidden" name="membership_status" value="active"><div class="col-auto align-self-end"><button class="btn btn-primary">Add member</button></div></form><p>Create or invite accounts through Users before assigning them to a club.</p>';
|
||||
$html.='<h3 class="h4">Seasons</h3><ul>';
|
||||
foreach($seasons as $season)$html.='<li>'.self::h($season['season_name'].' ('.$season['starts_on'].' to '.$season['ends_on'].')').'</li>';
|
||||
$html.='</ul>';
|
||||
if($service->administrator() && $selected['club_status']==='active')$html.=$this->form('season',$club).self::field('season_name','Season name','text','',100).self::field('starts_on','Starts on','date').self::field('ends_on','Ends on','date').'<div class="col-auto align-self-end"><button class="btn btn-primary">Add season</button></div></form>';
|
||||
$html.='<h3 class="h4" id="dues">'.($service->administrator()?'Season dues':'My season dues').'</h3><p>Amounts are in USD. Payments below are manually recorded receipts.</p><div class="table-responsive"><table class="table"><thead><tr><th>Member</th><th>Season</th><th>Dues</th><th>Paid</th><th>Balance</th>'.($service->administrator()?'<th>Record received payment</th>':'').'</tr></thead><tbody>';
|
||||
foreach($dues as $due){$balance=(int)$due['amount_cents']-(int)$due['paid_cents'];$html.='<tr><td>'.self::h($due['user_first_name'].' '.$due['user_last_name']).'</td><td>'.self::h($due['season_name']).'</td><td>$'.number_format($due['amount_cents']/100,2).'</td><td>$'.number_format($due['paid_cents']/100,2).'</td><td>$'.number_format($balance/100,2).'</td>';
|
||||
if($service->administrator()){$html.='<td>';if($balance>0 && $selected['club_status']==='active')$html.=$this->form('payment',$club).'<input type="hidden" name="due_serial" value="'.(int)$due['due_serial'].'"><input type="hidden" name="request_key" value="'.bin2hex(random_bytes(32)).'">'.self::field('amount','Payment (USD)','text').self::field('paid_on','Received on','date',date('Y-m-d')).'<label class="form-label">Method<select class="form-select" name="payment_method"><option value="unspecified">Unspecified</option><option value="cash">Cash</option><option value="check">Check</option><option value="transfer">Transfer</option><option value="other">Other</option></select></label><label class="form-label">Reference (optional)<input class="form-control" name="payment_reference" maxlength="100"></label><button class="btn btn-outline-primary">Record payment</button></form>';if($selected['club_status']==='active')$html.='<details class="mt-2"><summary>Correct dues</summary>'.$this->form('adjust',$club).'<input type="hidden" name="due_serial" value="'.(int)$due['due_serial'].'"><input type="hidden" name="request_key" value="'.bin2hex(random_bytes(32)).'">'.self::field('corrected_amount','Corrected total (USD)').self::field('reason','Reason','text','',500).'<button class="btn btn-outline-primary">Save correction</button></form></details>';$html.='</td>';}$html.='</tr>';
|
||||
}
|
||||
if(!$dues)$html.='<tr><td colspan="6">No dues recorded.</td></tr>';$html.='</tbody></table></div>';
|
||||
if($service->administrator() && $selected['club_status']==='active' && $members && $seasons){
|
||||
$html.='<h4 class="h5">Record season dues</h4>'.$this->form('charge',$club).'<div class="col-md"><label class="form-label">Member<select class="form-select" name="membership_serial">';foreach($members as $member)if($member['membership_status']==='active')$html.='<option value="'.(int)$member['membership_serial'].'">'.self::h($member['user_first_name'].' '.$member['user_last_name']).'</option>';
|
||||
$html.='</select></label></div><div class="col-md"><label class="form-label">Season<select class="form-select" name="season_serial">';foreach($seasons as $season)$html.='<option value="'.(int)$season['season_serial'].'">'.self::h($season['season_name']).'</option>';
|
||||
$html.='</select></label></div>'.self::field('amount','Dues (USD)','text').'<div class="col-auto align-self-end"><button class="btn btn-primary">Save dues</button></div></form>';
|
||||
}
|
||||
$html.=$this->financialHistory($club,$selected['club_status']==='active',$service->administrator());
|
||||
$this->displayContent($html.'</div>');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
<?php
|
||||
use PhpOffice\PhpSpreadsheet\Spreadsheet;
|
||||
use PhpOffice\PhpSpreadsheet\Writer\Xlsx;
|
||||
use PhpOffice\PhpSpreadsheet\Cell\DataType;
|
||||
class RosterReports extends Base {
|
||||
public function ledger(): void {
|
||||
$service=new RosterService();$service->requireAdministrator();$club=MaintenanceService::id($_POST['club_serial']??null);$name=$service->club($club)['club_name'];
|
||||
$history=(new LedgerService())->history($club);header('Content-Type: text/csv; charset=UTF-8');header('Content-Disposition: attachment; filename="club-financial-history.csv"');$file=fopen('php://output','w');
|
||||
$write=static function(array $row)use($file){fputcsv($file,array_map(static fn($v)=>self::csvText((string)$v),$row),',','"','');};
|
||||
$write(['Record type','Club','Record ID','Dues ID','Member','Amount cents (USD)','Date','Method','Reference','Recorded by','Status / reason']);
|
||||
foreach($service->dues($club) as $due)$write(['dues_total',$name,$due['due_serial'],$due['due_serial'],$due['user_first_name'].' '.$due['user_last_name'],$due['amount_cents'],'','','',$due['recorded_by']??'Historical','Paid cents '.$due['paid_cents'].'; balance cents '.($due['amount_cents']-$due['paid_cents'])]);
|
||||
foreach($history['payments'] as $p)$write(['payment',$name,$p['payment_serial'],$p['due_serial'],$p['member_name'],$p['amount_cents'],$p['paid_on'],$p['payment_method'],$p['payment_reference'],$p['actor']??'Historical',$p['reversal_serial']?'Reversed: '.$p['reversal_reason'].' by '.$p['reversal_actor'].' on '.$p['reversed_at']:'Received']);
|
||||
foreach($history['adjustments'] as $a)$write(['dues_adjustment',$name,$a['adjustment_serial'],$a['due_serial'],$a['member_name'],$a['amount_cents'],$a['created_at'],'','',$a['actor'],$a['reason']]);
|
||||
fclose($file);exit;
|
||||
}
|
||||
private function rows(): array {
|
||||
$service=new RosterService();$service->requireAdministrator();$id=MaintenanceService::id($_POST['club_serial']??null);$club=$service->club($id);
|
||||
$rows=[['Club','Username','First name','Last name','Email','Membership','Joined on']];
|
||||
foreach($service->members($id) as $member)$rows[]=[$club['club_name'],$member['user_name'],$member['user_first_name'],$member['user_last_name'],$member['user_email'],$member['membership_status'],$member['joined_on']];
|
||||
return $rows;
|
||||
}
|
||||
public static function csvText(string $value): string {return preg_match('/\A[\s]*[=+@\-]/u',$value)||preg_match('/\A[\t\r\n]/',$value)?"'".$value:$value;}
|
||||
public function csv(): void {
|
||||
$rows=$this->rows();header('Content-Type: text/csv; charset=UTF-8');header('Content-Disposition: attachment; filename="club-roster.csv"');$file=fopen('php://output','w');
|
||||
foreach($rows as $row)fputcsv($file,array_map(static fn($cell)=>self::csvText((string)$cell),$row),',','"','');fclose($file);exit;
|
||||
}
|
||||
public function excel(): void {
|
||||
$rows=$this->rows();$book=new Spreadsheet();$sheet=$book->getActiveSheet();$sheet->setTitle('Club roster');
|
||||
foreach($rows as $r=>$row)foreach($row as $c=>$cell)$sheet->setCellValueExplicit([$c+1,$r+1],(string)$cell,DataType::TYPE_STRING);
|
||||
$sheet->getStyle('A1:G1')->getFont()->setBold(true);$sheet->freezePane('A2');foreach(range('A','G') as $column)$sheet->getColumnDimension($column)->setAutoSize(true);
|
||||
header('Content-Type: application/vnd.openxmlformats-officedocument.spreadsheetml.sheet');header('Content-Disposition: attachment; filename="club-roster.xlsx"');(new Xlsx($book))->save('php://output');exit;
|
||||
}
|
||||
public function pdf(): void {
|
||||
$rows=$this->rows();$pdf=new TCPDF('L');$pdf->SetCreator(self::APPLICATION_NAME);$pdf->SetTitle('Club roster');$pdf->setPrintHeader(false);$pdf->setPrintFooter(false);$pdf->AddPage();$pdf->SetFont('helvetica','',9);
|
||||
$html='<h1>Club roster</h1><table border="1" cellpadding="4">';foreach($rows as $r=>$row){$html.='<tr>';foreach($row as $cell)$html.=($r===0?'<th>':'<td>').htmlspecialchars((string)$cell,ENT_QUOTES,'UTF-8').($r===0?'</th>':'</td>');$html.='</tr>';}$html.='</table>';$pdf->writeHTML($html);$pdf->Output('club-roster.pdf','D');exit;
|
||||
}
|
||||
public function catalog(): void {
|
||||
$service=new RosterService();$service->requireAdministrator();$html='<div class="container-fluid py-3"><h1>Roster reports</h1>';
|
||||
if(!$service->ready()){$this->displayContent($html.'<p>Install the roster migration before exporting reports.</p></div>');return;}
|
||||
foreach($service->clubs() as $club){$html.='<section class="card mb-3"><div class="card-body"><h2 class="h4">'.htmlspecialchars($club['club_name'],ENT_QUOTES,'UTF-8').'</h2><div class="d-flex gap-2">';foreach(['csv'=>'CSV','excel'=>'Excel','pdf'=>'PDF'] as $method=>$label)$html.='<form method="post" action="main.php"><input type="hidden" name="action" value="RosterReports.'.$method.'"><input type="hidden" name="club_serial" value="'.(int)$club['club_serial'].'"><input type="hidden" name="csrf_token" value="'.$this->csrfToken().'"><button class="btn btn-primary">Download '.$label.'</button></form>';$html.='</div></div></section>';}
|
||||
$this->displayContent($html.'</div>');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
<?php
|
||||
class RosterService extends Base {
|
||||
public function administrator(): bool {return in_array($_SESSION[self::USER_ROLE]??'', ['Administrator','System Administrator'],true);}
|
||||
public function requireAdministrator(): void {if(!$this->administrator())throw new AppException('Only site administrators may manage clubs and dues.',403);}
|
||||
public function ready(): bool {return (int)$this->connect()->query("SELECT COUNT(*) FROM information_schema.TABLES WHERE TABLE_SCHEMA=DATABASE() AND TABLE_NAME IN ('clubs','club_memberships','club_seasons','club_dues','club_payments','club_due_adjustments','club_payment_reversals','email_verifications')")->fetchColumn()===8;}
|
||||
public function clubs(): array {
|
||||
$db=$this->connect();
|
||||
if($this->administrator())return $db->query('SELECT * FROM clubs ORDER BY club_name,club_serial')->fetchAll();
|
||||
$stmt=$db->prepare("SELECT c.* FROM clubs c JOIN club_memberships m ON m.club_serial=c.club_serial JOIN users u ON u.user_serial=m.user_serial WHERE m.user_serial=? AND m.membership_status='active' AND c.club_status='active' AND u.user_status='active' AND u.user_email_verified_at IS NOT NULL ORDER BY c.club_name,c.club_serial");
|
||||
$stmt->execute([$_SESSION[self::USER_SERIAL]??0]);return $stmt->fetchAll();
|
||||
}
|
||||
public function club(int $id,?PDO $db=null,bool $writing=false): array {
|
||||
$db??=$this->connect();$stmt=$db->prepare('SELECT * FROM clubs WHERE club_serial=?'.($writing?' FOR UPDATE':''));$stmt->execute([$id]);$club=$stmt->fetch();
|
||||
if(!$club)throw new AppException('Club not found.',404);
|
||||
if(!$this->administrator()){
|
||||
$stmt=$db->prepare("SELECT m.membership_serial FROM club_memberships m JOIN users u ON u.user_serial=m.user_serial WHERE m.club_serial=? AND m.user_serial=? AND m.membership_status='active' AND u.user_status='active' AND u.user_email_verified_at IS NOT NULL");$stmt->execute([$id,$_SESSION[self::USER_SERIAL]??0]);
|
||||
if($club['club_status']!=='active'||!$stmt->fetch())throw new AppException('You do not have access to this club.',403);
|
||||
}
|
||||
if($writing && $club['club_status']!=='active')throw new AppException('Reactivate the club before adding or changing records.',409);
|
||||
return $club;
|
||||
}
|
||||
public function members(int $club): array {
|
||||
$this->club($club);$stmt=$this->connect()->prepare("SELECT m.*,u.user_name,u.user_first_name,u.user_last_name,u.user_email FROM club_memberships m JOIN users u ON u.user_serial=m.user_serial WHERE m.club_serial=?".($this->administrator()?'':" AND m.membership_status='active'").' ORDER BY u.user_last_name,u.user_first_name,m.membership_serial');$stmt->execute([$club]);return $stmt->fetchAll();
|
||||
}
|
||||
public function seasons(int $club): array {$this->club($club);$stmt=$this->connect()->prepare('SELECT * FROM club_seasons WHERE club_serial=? ORDER BY starts_on DESC,season_serial DESC');$stmt->execute([$club]);return $stmt->fetchAll();}
|
||||
public function dues(int $club): array {
|
||||
$this->club($club);$stmt=$this->connect()->prepare('SELECT d.*,d.amount_cents AS original_amount_cents,d.amount_cents+COALESCE((SELECT SUM(a.amount_cents) FROM club_due_adjustments a WHERE a.due_serial=d.due_serial),0) AS amount_cents,s.season_name,u.user_first_name,u.user_last_name,COALESCE((SELECT SUM(p.amount_cents) FROM club_payments p WHERE p.due_serial=d.due_serial AND NOT EXISTS(SELECT 1 FROM club_payment_reversals r WHERE r.payment_serial=p.payment_serial)),0) AS paid_cents FROM club_dues d JOIN club_memberships m ON m.membership_serial=d.membership_serial JOIN users u ON u.user_serial=m.user_serial JOIN club_seasons s ON s.season_serial=d.season_serial WHERE d.club_serial=?'.($this->administrator()?'':' AND m.user_serial=?').' ORDER BY s.starts_on DESC,u.user_last_name,d.due_serial');
|
||||
$stmt->execute($this->administrator()?[$club]:[$club,$_SESSION[self::USER_SERIAL]??0]);return $stmt->fetchAll();
|
||||
}
|
||||
private function text(string $text,int $limit): string {$text=trim($text);if(!$text||strlen($text)>$limit)throw new AppException('A name is required and must fit the displayed length limit.');return $text;}
|
||||
public static function date(string $value): string {$date=DateTime::createFromFormat('!Y-m-d',$value);if(!$date||$date->format('Y-m-d')!==$value)throw new AppException('Enter a valid date in YYYY-MM-DD format.');return $value;}
|
||||
public static function cents(string $value): int {
|
||||
if(!preg_match('/\A(?:0|[1-9][0-9]{0,6})(?:\.[0-9]{1,2})?\z/',$value))throw new AppException('Enter a nonnegative USD amount with at most two decimal places (maximum 9,999,999.99).');
|
||||
[$whole,$fraction]=array_pad(explode('.',$value,2),2,'');return (int)$whole*100+(int)str_pad($fraction,2,'0');
|
||||
}
|
||||
public function saveClub(int $id,string $name,string $status='active'): int {
|
||||
$this->requireAdministrator();$name=$this->text($name,128);if(!in_array($status,['active','archived'],true))throw new AppException('Invalid club status.');
|
||||
$db=$this->connect();$db->beginTransaction();
|
||||
try {
|
||||
$before=null;if($id){$stmt=$db->prepare('SELECT * FROM clubs WHERE club_serial=? FOR UPDATE');$stmt->execute([$id]);$before=$stmt->fetch();if(!$before)throw new AppException('Club not found.',404);}
|
||||
if($id){$stmt=$db->prepare('UPDATE clubs SET club_name=?,club_status=? WHERE club_serial=?');$stmt->execute([$name,$status,$id]);}
|
||||
else{$stmt=$db->prepare('INSERT INTO clubs(club_name,club_status) VALUES(?,?)');$stmt->execute([$name,$status]);$id=(int)$db->lastInsertId();}
|
||||
Audit::required($db,(int)($_SESSION[self::USER_SERIAL]??0),'saved club '.json_encode(['club'=>$id,'before'=>$before,'name'=>$name,'status'=>$status]));$db->commit();
|
||||
}catch(Throwable $e){if($db->inTransaction())$db->rollBack();throw $e;}
|
||||
return $id;
|
||||
}
|
||||
public function saveMember(int $club,int $membership,string $identifier,string $status,string $joined): void {
|
||||
$this->requireAdministrator();$joined=self::date($joined);if(!in_array($status,['active','inactive'],true))throw new AppException('Invalid membership status.');
|
||||
$db=$this->connect();$db->beginTransaction();
|
||||
try{
|
||||
// Lock user before membership writes, matching account deletion's lock order.
|
||||
if($membership){$stmt=$db->prepare('SELECT user_serial FROM club_memberships WHERE membership_serial=? AND club_serial=?');$stmt->execute([$membership,$club]);$user=$stmt->fetchColumn();if(!$user)throw new AppException('Membership not found in this club.',404);}
|
||||
else{$stmt=$db->prepare('SELECT user_serial FROM users WHERE user_name=? OR user_email=?');$stmt->execute([trim($identifier),trim($identifier)]);$user=$stmt->fetchColumn();if(!$user)throw new AppException('No account matches that username or email. Create the account first.',404);}
|
||||
$stmt=$db->prepare('SELECT user_serial,user_status,user_email_verified_at FROM users WHERE user_serial=? FOR UPDATE');$stmt->execute([$user]);if(!($account=$stmt->fetch()))throw new AppException('Account not found.',404);
|
||||
if($status==='active' && ($account['user_status']!=='active'||!$account['user_email_verified_at']))throw new AppException('The account must be active and its email verified before membership can be activated.',409);
|
||||
$this->club($club,$db,true);
|
||||
$before=null;if($membership){$stmt=$db->prepare('SELECT * FROM club_memberships WHERE membership_serial=? AND club_serial=? FOR UPDATE');$stmt->execute([$membership,$club]);$before=$stmt->fetch();if(!$before)throw new AppException('Membership not found.',404);}
|
||||
if($membership){$stmt=$db->prepare('UPDATE club_memberships SET membership_status=?,joined_on=? WHERE membership_serial=? AND club_serial=?');$stmt->execute([$status,$joined,$membership,$club]);}
|
||||
else{$stmt=$db->prepare('INSERT INTO club_memberships(club_serial,user_serial,membership_status,joined_on) VALUES(?,?,?,?)');$stmt->execute([$club,$user,$status,$joined]);}
|
||||
Audit::required($db,(int)($_SESSION[self::USER_SERIAL]??0),'saved membership '.json_encode(['membership'=>$membership?:((int)$db->lastInsertId()),'club'=>$club,'user'=>(int)$user,'before'=>$before,'status'=>$status,'joined_on'=>$joined]));
|
||||
$db->commit();
|
||||
}catch(Throwable $e){if($db->inTransaction())$db->rollBack();throw $e;}
|
||||
}
|
||||
public function addSeason(int $club,string $name,string $start,string $end): void {
|
||||
$this->requireAdministrator();$name=$this->text($name,100);self::date($start);self::date($end);if($end<$start)throw new AppException('Season end cannot precede its start.');
|
||||
$db=$this->connect();$db->beginTransaction();try{$this->club($club,$db,true);$stmt=$db->prepare('INSERT INTO club_seasons(club_serial,season_name,starts_on,ends_on) VALUES(?,?,?,?)');$stmt->execute([$club,$name,$start,$end]);Audit::required($db,(int)($_SESSION[self::USER_SERIAL]??0),'created season '.json_encode(['season'=>(int)$db->lastInsertId(),'club'=>$club,'name'=>$name,'start'=>$start,'end'=>$end]));$db->commit();}catch(Throwable $e){if($db->inTransaction())$db->rollBack();throw $e;}
|
||||
}
|
||||
public function charge(int $club,int $membership,int $season,string $amount): void {(new LedgerService())->charge($club,$membership,$season,$amount);}
|
||||
public function payment(int $club,int $due,string $amount,string $paid,string $key,string $method='unspecified',string $reference=''): void {(new LedgerService())->payment($club,$due,$amount,$paid,$key,$method,$reference);}
|
||||
}
|
||||
@@ -12,6 +12,8 @@ class SignUp extends Base {
|
||||
throw new InvalidArgumentException('Too many account requests. Please try again later.');
|
||||
}
|
||||
$auth->register($_POST);
|
||||
try{(new EmailVerification())->request($_POST['user_email']);}
|
||||
catch(Throwable $e){error_log('Signup verification delivery failed: '.$e->getMessage());}
|
||||
$_SESSION['signup_success'] = true;
|
||||
header('Location: main.php?Xaction=signup', true, 303);
|
||||
exit;
|
||||
|
||||
+15
-111
@@ -26,7 +26,7 @@ class Statuses extends Base {
|
||||
$this->current_user_name = $_SESSION[self::USER_NAME] ?? "";
|
||||
$this->current_user_email = $_SESSION[self::USER_EMAIL] ?? "";
|
||||
|
||||
$this->pagination_page = $_SESSION[self::PAGINATION_PAGE] ?? 1;
|
||||
$this->pagination_page = 1;
|
||||
}
|
||||
|
||||
// ==========
|
||||
@@ -48,34 +48,7 @@ class Statuses extends Base {
|
||||
break;
|
||||
|
||||
case "add":
|
||||
|
||||
$status_name = filter_input(INPUT_POST, "status_name") ?: "";
|
||||
$status_classes = filter_input(INPUT_POST, "status_classes") ?: "";
|
||||
$status_active = filter_input(INPUT_POST, "status_active", FILTER_VALIDATE_BOOLEAN) ?: false;
|
||||
|
||||
$SQL = "insert into statuses
|
||||
|
||||
( status_name,
|
||||
status_classes,
|
||||
status_active,
|
||||
status_creator,
|
||||
status_created )
|
||||
|
||||
VALUES ( :status_name,
|
||||
:status_classes,
|
||||
:status_active,
|
||||
:status_creator,
|
||||
:status_created )";
|
||||
|
||||
$statement = $this->connect()->prepare($SQL);
|
||||
|
||||
$statement->bindValue(":status_name", $status_name);
|
||||
$statement->bindValue(":status_classes", $status_classes);
|
||||
$statement->bindValue(":status_active", $status_active, PDO::PARAM_BOOL);
|
||||
$statement->bindValue(":status_creator", $this->current_user_name);
|
||||
$statement->bindValue(":status_created", $this->getTimestamp());
|
||||
|
||||
$statement->execute();
|
||||
(new MaintenanceService())->saveStatus(0,$_POST);
|
||||
|
||||
break;
|
||||
|
||||
@@ -90,19 +63,7 @@ class Statuses extends Base {
|
||||
// =============
|
||||
|
||||
public function deleteStatus() {
|
||||
|
||||
$status_serial = filter_input(INPUT_POST, "status_serial", FILTER_SANITIZE_NUMBER_INT) ?: 0;
|
||||
|
||||
$status = $this->getStatus($status_serial);
|
||||
|
||||
if ($status->count() == 0) {
|
||||
$this->logError("Invalid Status ({$status_serial}). " . __METHOD__);
|
||||
}
|
||||
|
||||
$SQL = "delete from statuses
|
||||
where status_serial = {$status_serial}";
|
||||
|
||||
$this->executeSQL($SQL);
|
||||
(new MaintenanceService())->deleteStatus(MaintenanceService::id($_POST['status_serial']??null));
|
||||
}
|
||||
|
||||
// ============================
|
||||
@@ -138,21 +99,10 @@ class Statuses extends Base {
|
||||
// ======================================
|
||||
|
||||
public function statusActive() {
|
||||
|
||||
$status_serial = filter_input(INPUT_POST, "status_serial", FILTER_SANITIZE_NUMBER_INT) ?: 0;
|
||||
|
||||
$active = false;
|
||||
|
||||
$SQL = "select workorder_status
|
||||
from workorders
|
||||
where workorder_status = {$status_serial}
|
||||
limit 1";
|
||||
|
||||
$workorders = $this->getTable("workorders", $SQL);
|
||||
|
||||
$active = ($workorders->count() > 0) ? true : false;
|
||||
|
||||
echo json_encode($active);
|
||||
$stmt=$this->connect()->prepare('SELECT status_default FROM statuses WHERE status_serial=?');
|
||||
$stmt->execute([MaintenanceService::id($_POST['status_serial']??null)]);$row=$stmt->fetch();
|
||||
if(!$row)throw new AppException('Status not found.',404);
|
||||
echo json_encode((bool)$row['status_default']);
|
||||
}
|
||||
|
||||
// =============
|
||||
@@ -184,41 +134,7 @@ class Statuses extends Base {
|
||||
break;
|
||||
|
||||
case "update":
|
||||
|
||||
$status_serial = filter_input(INPUT_POST, "status_serial", FILTER_SANITIZE_NUMBER_INT) ?: 0;
|
||||
$status_name = filter_input(INPUT_POST, "status_name") ?: "";
|
||||
$status_classes = filter_input(INPUT_POST, "status_classes") ?: "";
|
||||
$status_active = filter_input(INPUT_POST, "status_active", FILTER_VALIDATE_BOOLEAN) ?: false;
|
||||
$status_default = filter_input(INPUT_POST, "status_default", FILTER_VALIDATE_BOOLEAN) ?: false;
|
||||
|
||||
// Verify the Status
|
||||
|
||||
$status = $this->getStatus($status_serial);
|
||||
|
||||
if ($status->count() == 0) {
|
||||
$this->logError("Invalid Status ({$status_serial}). " . __METHOD__);
|
||||
}
|
||||
|
||||
$SQL = "update statuses
|
||||
set status_name = :status_name,
|
||||
status_classes = :status_classes,
|
||||
status_active = :status_active,
|
||||
status_default = :status_default,
|
||||
status_changer = :status_changer,
|
||||
status_changed = :status_changed
|
||||
where status_serial = :status_serial";
|
||||
|
||||
$statement = $this->connect()->prepare($SQL);
|
||||
|
||||
$statement->bindValue(":status_serial", $status_serial);
|
||||
$statement->bindValue(":status_name", $status_name);
|
||||
$statement->bindValue(":status_classes", $status_classes);
|
||||
$statement->bindValue(":status_active", $status_active, PDO::PARAM_BOOL);
|
||||
$statement->bindValue(":status_default", $status_default, PDO::PARAM_BOOL);
|
||||
$statement->bindValue(":status_changer", $this->current_user_name);
|
||||
$statement->bindValue(":status_changed", $this->getTimestamp());
|
||||
|
||||
$statement->execute();
|
||||
(new MaintenanceService())->saveStatus(MaintenanceService::id($_POST['status_serial']??null),$_POST);
|
||||
|
||||
break;
|
||||
|
||||
@@ -284,13 +200,14 @@ class Statuses extends Base {
|
||||
// ============================
|
||||
|
||||
public function getStatusesPage() {
|
||||
$this->pagination_size=(int)($_POST['statuses_per_page']??20);
|
||||
|
||||
// Find Criteria
|
||||
|
||||
$find_status = filter_input(INPUT_POST, "find_status") ?: "";
|
||||
$find_status = $this->sanitizeString($find_status);
|
||||
$find_status = trim($find_status);
|
||||
|
||||
$find_words = implode("|", explode(" ", $find_status));
|
||||
$find_words = implode("|", array_map(static fn($word) => preg_quote($word), explode(" ", $find_status)));
|
||||
|
||||
// Record Count
|
||||
|
||||
@@ -316,21 +233,9 @@ class Statuses extends Base {
|
||||
|
||||
$pagination = filter_input(INPUT_POST, "pagination") ?: "";
|
||||
|
||||
switch ($pagination) {
|
||||
|
||||
case "first" : $this->pagination_page = 1;
|
||||
break;
|
||||
case "previous" : $this->pagination_page = max(($this->pagination_page - 1), 1);
|
||||
break;
|
||||
case "next" : $this->pagination_page = ($this->pagination_page + 1);
|
||||
break;
|
||||
case "last" : $this->pagination_page = ($count / $this->pagination_size);
|
||||
break;
|
||||
}
|
||||
|
||||
$offset = (($this->pagination_page * $this->pagination_size) - $this->pagination_size);
|
||||
$start = ($offset + 1);
|
||||
$end = min($count, (($start + $this->pagination_size) - 1));
|
||||
$paging=Pagination::session(__METHOD__,$count,(int)$this->pagination_size,$pagination);
|
||||
$this->pagination_size=$paging['size'];$this->pagination_page=$paging['page'];
|
||||
$offset=$paging['offset'];$start=$paging['start'];$end=$paging['end'];
|
||||
|
||||
// Data
|
||||
|
||||
@@ -338,7 +243,7 @@ class Statuses extends Base {
|
||||
from view_statuses
|
||||
where status_name regexp :status_name
|
||||
or status_classes regexp :status_classes
|
||||
order by status_name
|
||||
order by status_name,status_serial
|
||||
limit {$this->pagination_size}
|
||||
offset {$offset}";
|
||||
|
||||
@@ -358,7 +263,6 @@ class Statuses extends Base {
|
||||
$statuses->addAttribute("end", $end);
|
||||
$statuses->addAttribute("page", $this->pagination_page);
|
||||
|
||||
$_SESSION[self::PAGINATION_PAGE] = $this->pagination_page;
|
||||
|
||||
return $statuses;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
<?php
|
||||
final class Transport {
|
||||
public static function https(array $server,string $trustedProxies=''): bool {
|
||||
if(in_array(strtolower((string)($server['HTTPS']??'')),['on','1','https'],true))return true;
|
||||
$trusted=array_filter(array_map('trim',explode(',',$trustedProxies)),static fn($ip)=>filter_var($ip,FILTER_VALIDATE_IP)!==false);
|
||||
return in_array($server['REMOTE_ADDR']??'',$trusted,true)&&strtolower(trim($server['HTTP_X_FORWARDED_PROTO']??''))==='https';
|
||||
}
|
||||
}
|
||||
+30
-193
@@ -28,7 +28,7 @@ class Users extends Base {
|
||||
$this->current_user_serial = (int) ($_SESSION[self::USER_SERIAL] ?? 0);
|
||||
$this->current_user_name = $_SESSION[self::USER_NAME] ?? "";
|
||||
$this->current_user_email = $_SESSION[self::USER_EMAIL] ?? "";
|
||||
$this->pagination_page = $_SESSION[self::PAGINATION_PAGE] ?? 1;
|
||||
$this->pagination_page = 1;
|
||||
}
|
||||
|
||||
// ===========
|
||||
@@ -62,11 +62,11 @@ class Users extends Base {
|
||||
$this->logError("Invalid User ({$user_serial}). " . __METHOD__);
|
||||
}
|
||||
|
||||
$notes = (new Notes())->getAllNotes($user_serial);
|
||||
$subscriptions = (new Subscriptions())->getAllUsersSubscriptions($user_serial);
|
||||
$notes = (new Notes())->getNotes('User',(int)$user_serial);
|
||||
$activity=$this->getLoginActivity($user_serial);
|
||||
|
||||
$XML = $this->mergeXML($user, "<XML/>");
|
||||
$XML = $this->mergeXML($subscriptions, $XML);
|
||||
$XML = $this->mergeXML($activity, $XML);
|
||||
$XML = $this->mergeXML($notes, $XML);
|
||||
|
||||
$content = $this->applyXSL($XML, $this->getXSL("userSummary"));
|
||||
@@ -99,71 +99,7 @@ class Users extends Base {
|
||||
break;
|
||||
|
||||
case "add":
|
||||
|
||||
$user_name = filter_input(INPUT_POST, "user_name") ?: "";
|
||||
$user_first_name = filter_input(INPUT_POST, "user_first_name") ?: "";
|
||||
$user_email = filter_input(INPUT_POST, "user_email") ?: "";
|
||||
$user_role = filter_input(INPUT_POST, "user_role") ?: "";
|
||||
if (!in_array($user_role, ['User', 'Guest', 'Administrator'], true) || !filter_var($user_email, FILTER_VALIDATE_EMAIL) || strlen($user_email) > 100 || !preg_match('/\A[A-Za-z0-9_.-]{3,64}\z/', $user_name)) {
|
||||
http_response_code(422); exit('Enter a valid username, email address and role.');
|
||||
}
|
||||
|
||||
|
||||
$user_name = strtoupper(preg_replace("/\s+/", " ", $user_name));
|
||||
|
||||
$user_random_password = $this->generateRandomPassword();
|
||||
$user_temp_password = password_hash($user_random_password, PASSWORD_DEFAULT);
|
||||
|
||||
$user_change_password = (int) true;
|
||||
$user_active = 'active';
|
||||
|
||||
// Add the User
|
||||
|
||||
$connection = $this->connect();
|
||||
|
||||
$SQL = "insert into users
|
||||
|
||||
( user_name,
|
||||
user_email,
|
||||
user_first_name,
|
||||
user_password_hash,
|
||||
user_role,
|
||||
user_change_password,
|
||||
user_status,
|
||||
user_creator,
|
||||
user_created )
|
||||
|
||||
values ( :user_name,
|
||||
:user_email,
|
||||
:user_first_name,
|
||||
:user_password_hash,
|
||||
:user_role,
|
||||
:user_change_password,
|
||||
:user_status,
|
||||
:user_creator,
|
||||
:user_created )";
|
||||
|
||||
$statement = $connection->prepare($SQL);
|
||||
|
||||
$statement->bindValue(":user_name", $user_name);
|
||||
$statement->bindValue(":user_email", $user_email);
|
||||
$statement->bindValue(":user_first_name", $user_first_name);
|
||||
$statement->bindValue(":user_password_hash", $user_temp_password);
|
||||
$statement->bindValue(":user_role", $user_role);
|
||||
$statement->bindValue(":user_change_password", $user_change_password, PDO::PARAM_BOOL);
|
||||
$statement->bindValue(":user_status", $user_active);
|
||||
$statement->bindValue(":user_creator", $this->current_user_name);
|
||||
$statement->bindValue(":user_created", $this->getTimestamp());
|
||||
|
||||
if (!$statement->execute()) {
|
||||
trigger_error($statement->errorInfo()[2]);
|
||||
}
|
||||
|
||||
$user = $this->getUserByUserName($user_name);
|
||||
|
||||
// Send User Credentials
|
||||
|
||||
(new AuthService())->requestReset($user_email);
|
||||
echo json_encode((new AdminAccounts())->create($_POST));
|
||||
|
||||
break;
|
||||
|
||||
@@ -178,19 +114,7 @@ class Users extends Base {
|
||||
// ===========
|
||||
|
||||
public function deleteUser() {
|
||||
|
||||
$user_serial = filter_input(INPUT_POST, "user_serial", FILTER_SANITIZE_NUMBER_INT) ?: 0;
|
||||
|
||||
$users = $this->getUser($user_serial);
|
||||
|
||||
if ($users->count() == 0) {
|
||||
$this->logError("Invalid User ({$user_serial}): " . __METHOD__);
|
||||
}
|
||||
|
||||
$SQL = "delete from users
|
||||
where user_serial = {$user_serial}";
|
||||
|
||||
$this->executeSQL($SQL);
|
||||
(new MaintenanceService())->deleteUser(MaintenanceService::id($_POST['user_serial']??null));
|
||||
}
|
||||
|
||||
// =========
|
||||
@@ -227,50 +151,7 @@ class Users extends Base {
|
||||
break;
|
||||
|
||||
case "update":
|
||||
|
||||
$user_serial = filter_input(INPUT_POST, "user_serial", FILTER_SANITIZE_NUMBER_INT) ?: 0;
|
||||
$user_name = filter_input(INPUT_POST, "user_name") ?: "";
|
||||
$user_email = filter_input(INPUT_POST, "user_email") ?: "";
|
||||
$user_first_name = filter_input(INPUT_POST, "user_first_name") ?: "";
|
||||
$user_role = filter_input(INPUT_POST, "user_role") ?: "";
|
||||
if (!in_array($user_role, ['User', 'Guest', 'Administrator'], true) || !filter_var($user_email, FILTER_VALIDATE_EMAIL) || strlen($user_email) > 100 || !preg_match('/\A[A-Za-z0-9_.-]{3,64}\z/', $user_name)) {
|
||||
http_response_code(422); exit('Enter a valid username, email address and role.');
|
||||
}
|
||||
|
||||
$user_active = filter_input(INPUT_POST, 'user_active', FILTER_VALIDATE_BOOLEAN) ? 'active' : 'inactive';
|
||||
|
||||
$users = $this->getUser($user_serial);
|
||||
|
||||
if ($users->count() == 0) {
|
||||
$this->logError("Invalid User ({$user_serial}): " . __METHOD__);
|
||||
}
|
||||
|
||||
// Update the User
|
||||
|
||||
$SQL = "update users
|
||||
set user_name = :user_name,
|
||||
user_email = :user_email,
|
||||
user_first_name = :user_first_name,
|
||||
user_role = :user_role,
|
||||
user_auth_version = user_auth_version + 1,
|
||||
user_status = :user_status,
|
||||
user_changer = :user_changer,
|
||||
user_changed = :user_changed
|
||||
where user_serial = :user_serial";
|
||||
|
||||
$statement = $this->connect()->prepare($SQL);
|
||||
|
||||
$statement->bindValue(":user_serial", $user_serial);
|
||||
$statement->bindValue(":user_name", $user_name);
|
||||
$statement->bindValue(":user_email", $user_email);
|
||||
$statement->bindValue(":user_first_name", $user_first_name);
|
||||
$statement->bindValue(":user_role", $user_role);
|
||||
$statement->bindValue(":user_status", $user_active);
|
||||
$statement->bindValue(":user_changer", $this->current_user_name);
|
||||
$statement->bindValue(":user_changed", $this->getTimestamp());
|
||||
|
||||
$statement->execute();
|
||||
|
||||
(new MaintenanceService())->updateUser(MaintenanceService::id($_POST['user_serial']??null),$_POST);
|
||||
break;
|
||||
|
||||
default:
|
||||
@@ -309,7 +190,7 @@ class Users extends Base {
|
||||
|
||||
public function getUserSerial($user_name = "") {
|
||||
|
||||
$users = $this->getUserByUserId($user_name);
|
||||
$users = $this->getUserByUserName($user_name);
|
||||
|
||||
return ($users->count() === 0) ? 0 : (int) $users->record->user_serial;
|
||||
}
|
||||
@@ -330,13 +211,14 @@ class Users extends Base {
|
||||
// ==========================
|
||||
|
||||
public function getUsersPage() {
|
||||
$this->pagination_size=(int)($_POST['users_per_page']??20);
|
||||
|
||||
// Find Criteria
|
||||
|
||||
$find_user = filter_input(INPUT_POST, "find_user") ?: "";
|
||||
$find_user = $this->sanitizeString($find_user);
|
||||
$find_user = trim($find_user);
|
||||
|
||||
$find_words = implode("|", explode(" ", $find_user));
|
||||
$find_words = implode("|", array_map(static fn($word) => preg_quote($word), explode(" ", $find_user)));
|
||||
|
||||
// Record Count
|
||||
|
||||
@@ -346,6 +228,7 @@ class Users extends Base {
|
||||
from view_users
|
||||
where user_name regexp :user_name
|
||||
or user_first_name regexp :user_first_name
|
||||
or user_last_name regexp :user_last_name
|
||||
or user_email regexp :user_email
|
||||
or user_role regexp :user_role";
|
||||
|
||||
@@ -353,6 +236,7 @@ class Users extends Base {
|
||||
|
||||
$statement->bindValue(":user_name", $find_words);
|
||||
$statement->bindValue(":user_first_name", $find_words);
|
||||
$statement->bindValue(":user_last_name", $find_words);
|
||||
$statement->bindValue(":user_email", $find_words);
|
||||
$statement->bindValue(":user_role", $find_words);
|
||||
|
||||
@@ -366,21 +250,9 @@ class Users extends Base {
|
||||
|
||||
$pagination = filter_input(INPUT_POST, "pagination") ?: "";
|
||||
|
||||
switch ($pagination) {
|
||||
|
||||
case "first" : $this->pagination_page = 1;
|
||||
break;
|
||||
case "previous" : $this->pagination_page = max(($this->pagination_page - 1), 1);
|
||||
break;
|
||||
case "next" : $this->pagination_page = ($this->pagination_page + 1);
|
||||
break;
|
||||
case "last" : $this->pagination_page = ($count / $this->pagination_size);
|
||||
break;
|
||||
}
|
||||
|
||||
$offset = (($this->pagination_page * $this->pagination_size) - $this->pagination_size);
|
||||
$start = ($offset + 1);
|
||||
$end = min($count, (($start + $this->pagination_size) - 1));
|
||||
$paging=Pagination::session(__METHOD__,$count,(int)$this->pagination_size,$pagination);
|
||||
$this->pagination_size=$paging['size'];$this->pagination_page=$paging['page'];
|
||||
$offset=$paging['offset'];$start=$paging['start'];$end=$paging['end'];
|
||||
|
||||
// Data
|
||||
|
||||
@@ -388,8 +260,10 @@ class Users extends Base {
|
||||
from view_users
|
||||
where user_name regexp :user_name
|
||||
or user_first_name regexp :user_first_name
|
||||
or user_last_name regexp :user_last_name
|
||||
or user_email regexp :user_email
|
||||
or user_role regexp :user_role
|
||||
order by user_serial
|
||||
limit {$this->pagination_size}
|
||||
offset {$offset}";
|
||||
|
||||
@@ -397,6 +271,7 @@ class Users extends Base {
|
||||
|
||||
$statement->bindValue(":user_name", $find_words);
|
||||
$statement->bindValue(":user_first_name", $find_words);
|
||||
$statement->bindValue(":user_last_name", $find_words);
|
||||
$statement->bindValue(":user_email", $find_words);
|
||||
$statement->bindValue(":user_role", $find_words);
|
||||
|
||||
@@ -411,7 +286,6 @@ class Users extends Base {
|
||||
$users->addAttribute("end", $end);
|
||||
$users->addAttribute("page", $this->pagination_page);
|
||||
|
||||
$_SESSION[self::PAGINATION_PAGE] = $this->pagination_page;
|
||||
|
||||
return $users;
|
||||
}
|
||||
@@ -474,18 +348,9 @@ class Users extends Base {
|
||||
// =============================
|
||||
|
||||
public function userActive() {
|
||||
|
||||
$user_serial = (int) filter_input(INPUT_POST, "user_serial", FILTER_SANITIZE_NUMBER_INT) ?: 0;
|
||||
|
||||
$SQL = "select request_serial
|
||||
from requests
|
||||
where request_user = {$user_serial}";
|
||||
|
||||
$requests = $this->getTable("requests", $SQL);
|
||||
|
||||
$active = ($requests->count() > 0) ? true : $active;
|
||||
|
||||
echo json_encode($active);
|
||||
$id=MaintenanceService::id($_POST['user_serial']??null);
|
||||
$used=(new MaintenanceService())->userUsed($id);
|
||||
echo json_encode($used || $id===(int)($_SESSION[self::USER_SERIAL]??0));
|
||||
}
|
||||
|
||||
// ===============
|
||||
@@ -514,12 +379,10 @@ class Users extends Base {
|
||||
|
||||
public function resetPassword() {
|
||||
$this->requireCsrf();
|
||||
$stmt = $this->connect()->prepare('SELECT user_email FROM users WHERE user_serial = :id');
|
||||
$stmt->execute([':id' => (int) ($_POST['user_serial'] ?? 0)]);
|
||||
$email = $stmt->fetchColumn();
|
||||
if ($email) (new AuthService())->requestReset($email);
|
||||
header('Content-Type: application/json');
|
||||
echo json_encode(true);
|
||||
$stmt=$this->connect()->prepare('SELECT user_email FROM users WHERE user_serial=?');$stmt->execute([MaintenanceService::id($_POST['user_serial']??null)]);$email=$stmt->fetchColumn();
|
||||
if(!$email)throw new AppException('Account not found.',404);
|
||||
if(!(new AuthService())->requestReset($email))throw new AppException('The invitation is rate limited or the account is inactive. Try again later.',429);
|
||||
echo json_encode(['ok'=>true,'message'=>'A password setup link was sent.']);
|
||||
}
|
||||
|
||||
// ===============================================
|
||||
@@ -545,27 +408,7 @@ class Users extends Base {
|
||||
// ====================
|
||||
|
||||
public function updateActiveStatus() {
|
||||
|
||||
$user_serial = filter_input(INPUT_POST, "user_serial", FILTER_SANITIZE_NUMBER_INT) ?: 0;
|
||||
$user_active = filter_input(INPUT_POST, 'user_active', FILTER_VALIDATE_BOOLEAN) ? 'active' : 'inactive';
|
||||
|
||||
$SQL = "update users
|
||||
set user_status = :user_status,
|
||||
user_auth_version = user_auth_version + 1,
|
||||
user_changed = :user_changed,
|
||||
user_changer = :user_changer
|
||||
where user_serial = :user_serial";
|
||||
|
||||
$statement = $this->connect()->prepare($SQL);
|
||||
|
||||
$statement->bindValue(":user_serial", $user_serial);
|
||||
$statement->bindValue(":user_status", $user_active);
|
||||
$statement->bindValue(":user_changed", $this->getTimestamp());
|
||||
$statement->bindValue(":user_changer", $this->current_user_name);
|
||||
|
||||
if (!$statement->execute()) {
|
||||
error_log($statement->errorInfo()[2]);
|
||||
}
|
||||
(new MaintenanceService())->setUserActive(MaintenanceService::id($_POST['user_serial']??null),MaintenanceService::boolean($_POST['user_active']??null));
|
||||
}
|
||||
|
||||
// ====================================
|
||||
@@ -573,14 +416,8 @@ class Users extends Base {
|
||||
// ====================================
|
||||
|
||||
public function getLoginActivity($userserial) {
|
||||
|
||||
$SQL = "select view_journal.journal_timestamp_verbose, view_journal.journal_ip
|
||||
from view_journal
|
||||
where view_journal.journal_origin = '{$userserial}'
|
||||
and view_journal.journal_entry like '{$userserial} %logged in%'";
|
||||
|
||||
return $this->getTable("loginactivity", $SQL);
|
||||
$stmt=$this->connect()->prepare("SELECT journal_timestamp_verbose,journal_ip FROM view_journal WHERE journal_origin=? AND journal_entry LIKE ? ORDER BY journal_timestamp DESC,journal_serial DESC");
|
||||
$id=MaintenanceService::id($userserial);$stmt->execute([(string)$id,$id.' logged in%']);
|
||||
return $this->getTableXML('loginactivity',$stmt);
|
||||
}
|
||||
}
|
||||
|
||||
?>
|
||||
|
||||
Reference in New Issue
Block a user