Initial commit
This commit is contained in:
968
api/includes/AttendanceAdmin.php
Normal file
968
api/includes/AttendanceAdmin.php
Normal file
@@ -0,0 +1,968 @@
|
||||
<?php
|
||||
|
||||
/**
|
||||
* Attendance admin handler functions
|
||||
* Requires: AttendanceHelpers.php, CzechHolidays.php, AuditLog.php
|
||||
*/
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
function handleGetAdmin(PDO $pdo): void
|
||||
{
|
||||
$month = validateMonth();
|
||||
$filterUserId = isset($_GET['user_id']) ? (int)$_GET['user_id'] : null;
|
||||
|
||||
$year = (int)substr($month, 0, 4);
|
||||
$startDate = "{$month}-01";
|
||||
$endDate = date('Y-m-t', strtotime($startDate));
|
||||
|
||||
$sql = "
|
||||
SELECT a.*, CONCAT(u.first_name, ' ', u.last_name) as user_name
|
||||
FROM attendance a
|
||||
JOIN users u ON a.user_id = u.id
|
||||
WHERE a.shift_date BETWEEN ? AND ?
|
||||
";
|
||||
$params = [$startDate, $endDate];
|
||||
|
||||
if ($filterUserId) {
|
||||
$sql .= ' AND a.user_id = ?';
|
||||
$params[] = $filterUserId;
|
||||
}
|
||||
|
||||
$sql .= ' ORDER BY a.shift_date DESC, a.arrival_time DESC';
|
||||
|
||||
$stmt = $pdo->prepare($sql);
|
||||
$stmt->execute($params);
|
||||
$records = $stmt->fetchAll();
|
||||
|
||||
enrichRecordsWithProjectLogs($pdo, $records);
|
||||
|
||||
$stmt = $pdo->query(
|
||||
"SELECT id, CONCAT(first_name, ' ', last_name) as name
|
||||
FROM users WHERE is_active = 1 ORDER BY last_name"
|
||||
);
|
||||
$users = $stmt->fetchAll();
|
||||
|
||||
$userTotals = calculateUserTotals($records);
|
||||
$leaveBalances = getLeaveBalancesBatch(
|
||||
$pdo,
|
||||
array_keys($userTotals),
|
||||
$year
|
||||
);
|
||||
|
||||
$monthNum = (int)substr($month, 5, 2);
|
||||
addFundDataToUserTotals($pdo, $userTotals, $year, $monthNum);
|
||||
|
||||
successResponse([
|
||||
'records' => $records,
|
||||
'users' => $users,
|
||||
'month' => $month,
|
||||
'user_totals' => $userTotals,
|
||||
'leave_balances' => $leaveBalances,
|
||||
]);
|
||||
}
|
||||
|
||||
function handleGetBalances(PDO $pdo): void
|
||||
{
|
||||
$year = (int)($_GET['year'] ?? date('Y'));
|
||||
|
||||
$stmt = $pdo->query(
|
||||
"SELECT id, CONCAT(first_name, ' ', last_name) as name
|
||||
FROM users WHERE is_active = 1 ORDER BY last_name"
|
||||
);
|
||||
$users = $stmt->fetchAll();
|
||||
|
||||
$userIds = array_column($users, 'id');
|
||||
$batchBalances = getLeaveBalancesBatch($pdo, $userIds, $year);
|
||||
|
||||
$balances = [];
|
||||
foreach ($users as $user) {
|
||||
$balances[$user['id']] = array_merge(
|
||||
['name' => $user['name']],
|
||||
$batchBalances[$user['id']]
|
||||
);
|
||||
}
|
||||
|
||||
successResponse([
|
||||
'users' => $users,
|
||||
'balances' => $balances,
|
||||
'year' => $year,
|
||||
]);
|
||||
}
|
||||
|
||||
function handleGetWorkFund(PDO $pdo): void
|
||||
{
|
||||
$year = (int)($_GET['year'] ?? date('Y'));
|
||||
$currentYear = (int)date('Y');
|
||||
$currentMonth = (int)date('m');
|
||||
|
||||
$maxMonth = ($year < $currentYear) ? 12 : (($year === $currentYear) ? $currentMonth : 0);
|
||||
|
||||
if ($maxMonth === 0) {
|
||||
successResponse(['months' => [], 'holidays' => [], 'year' => $year]);
|
||||
return;
|
||||
}
|
||||
|
||||
$stmt = $pdo->query(
|
||||
"SELECT id, CONCAT(first_name, ' ', last_name) as name
|
||||
FROM users WHERE is_active = 1 ORDER BY last_name"
|
||||
);
|
||||
$users = $stmt->fetchAll();
|
||||
|
||||
$startDate = sprintf('%04d-01-01', $year);
|
||||
$endDate = sprintf('%04d-%02d-%02d', $year, $maxMonth, cal_days_in_month(CAL_GREGORIAN, $maxMonth, $year));
|
||||
|
||||
$stmt = $pdo->prepare('SELECT * FROM attendance WHERE shift_date BETWEEN ? AND ? ORDER BY shift_date');
|
||||
$stmt->execute([$startDate, $endDate]);
|
||||
$allRecords = $stmt->fetchAll();
|
||||
|
||||
$monthUserData = [];
|
||||
foreach ($allRecords as $rec) {
|
||||
$m = (int)date('m', strtotime($rec['shift_date']));
|
||||
$uid = $rec['user_id'];
|
||||
if (!isset($monthUserData[$m][$uid])) {
|
||||
$monthUserData[$m][$uid] = ['minutes' => 0, 'vacation' => 0, 'sick' => 0, 'holiday' => 0, 'unpaid' => 0];
|
||||
}
|
||||
$lt = $rec['leave_type'] ?? 'work';
|
||||
$lh = (float)($rec['leave_hours'] ?? 0);
|
||||
if ($lt === 'work') {
|
||||
if ($rec['departure_time']) {
|
||||
$monthUserData[$m][$uid]['minutes'] += calculateWorkMinutes($rec);
|
||||
}
|
||||
} elseif ($lt === 'vacation') {
|
||||
$monthUserData[$m][$uid]['vacation'] += $lh;
|
||||
} elseif ($lt === 'sick') {
|
||||
$monthUserData[$m][$uid]['sick'] += $lh;
|
||||
} elseif ($lt === 'holiday') {
|
||||
$monthUserData[$m][$uid]['holiday'] += $lh;
|
||||
} elseif ($lt === 'unpaid') {
|
||||
$monthUserData[$m][$uid]['unpaid'] += $lh;
|
||||
}
|
||||
}
|
||||
|
||||
$months = [];
|
||||
for ($m = 1; $m <= $maxMonth; $m++) {
|
||||
$fund = CzechHolidays::getMonthlyWorkFund($year, $m);
|
||||
$businessDays = CzechHolidays::getBusinessDaysInMonth($year, $m);
|
||||
$monthName = getCzechMonthName($m);
|
||||
|
||||
$userStats = [];
|
||||
foreach ($users as $user) {
|
||||
$uid = $user['id'];
|
||||
$ud = $monthUserData[$m][$uid] ?? [
|
||||
'minutes' => 0, 'vacation' => 0, 'sick' => 0,
|
||||
'holiday' => 0, 'unpaid' => 0,
|
||||
];
|
||||
$worked = round($ud['minutes'] / 60, 1);
|
||||
$leave = $ud['vacation'] + $ud['sick'];
|
||||
$covered = $worked + $leave;
|
||||
$missing = max(0, round($fund - $covered, 1));
|
||||
$overtime = max(0, round($covered - $fund, 1));
|
||||
|
||||
$userStats[$uid] = [
|
||||
'name' => $user['name'],
|
||||
'worked' => $worked,
|
||||
'vacation' => $ud['vacation'],
|
||||
'sick' => $ud['sick'],
|
||||
'holiday' => $ud['holiday'],
|
||||
'unpaid' => $ud['unpaid'],
|
||||
'leave' => $leave,
|
||||
'covered' => $covered,
|
||||
'missing' => $missing,
|
||||
'overtime' => $overtime,
|
||||
];
|
||||
}
|
||||
|
||||
$months[$m] = [
|
||||
'month' => $m,
|
||||
'month_name' => $monthName,
|
||||
'fund' => $fund,
|
||||
'business_days' => $businessDays,
|
||||
'users' => $userStats,
|
||||
];
|
||||
}
|
||||
|
||||
$userIds = array_column($users, 'id');
|
||||
$batchBalances = getLeaveBalancesBatch($pdo, $userIds, $year);
|
||||
$balances = [];
|
||||
foreach ($users as $user) {
|
||||
$balances[$user['id']] = array_merge(
|
||||
['name' => $user['name']],
|
||||
$batchBalances[$user['id']]
|
||||
);
|
||||
}
|
||||
|
||||
$holidays = CzechHolidays::getHolidays($year);
|
||||
|
||||
successResponse([
|
||||
'months' => $months,
|
||||
'balances' => $balances,
|
||||
'holidays' => $holidays,
|
||||
'users' => $users,
|
||||
'year' => $year,
|
||||
]);
|
||||
}
|
||||
|
||||
function handleGetLocation(PDO $pdo, int $recordId): void
|
||||
{
|
||||
$stmt = $pdo->prepare("
|
||||
SELECT a.*, CONCAT(u.first_name, ' ', u.last_name) as user_name
|
||||
FROM attendance a
|
||||
JOIN users u ON a.user_id = u.id
|
||||
WHERE a.id = ?
|
||||
");
|
||||
$stmt->execute([$recordId]);
|
||||
$record = $stmt->fetch();
|
||||
|
||||
if (!$record) {
|
||||
errorResponse('Záznam nebyl nalezen', 404);
|
||||
}
|
||||
|
||||
successResponse(['record' => $record]);
|
||||
}
|
||||
|
||||
function handleGetUsers(PDO $pdo): void
|
||||
{
|
||||
$stmt = $pdo->query(
|
||||
"SELECT id, CONCAT(first_name, ' ', last_name) as name
|
||||
FROM users WHERE is_active = 1 ORDER BY last_name"
|
||||
);
|
||||
$users = $stmt->fetchAll();
|
||||
successResponse(['users' => $users]);
|
||||
}
|
||||
|
||||
function handleCreateAttendance(PDO $pdo): void
|
||||
{
|
||||
$input = getJsonInput();
|
||||
|
||||
$userId = (int)($input['user_id'] ?? 0);
|
||||
$shiftDate = $input['shift_date'] ?? '';
|
||||
$leaveType = $input['leave_type'] ?? 'work';
|
||||
$leaveHours = $input['leave_hours'] ?? null;
|
||||
$notes = $input['notes'] ?? null;
|
||||
|
||||
if (!$userId || !$shiftDate) {
|
||||
errorResponse('Vyplňte zaměstnance a datum směny');
|
||||
}
|
||||
|
||||
if ($leaveType !== 'work') {
|
||||
$leaveHours = $leaveHours ?: 8;
|
||||
$pdo->beginTransaction();
|
||||
try {
|
||||
$stmt = $pdo->prepare('
|
||||
INSERT INTO attendance (user_id, shift_date, leave_type, leave_hours, notes)
|
||||
VALUES (?, ?, ?, ?, ?)
|
||||
');
|
||||
$stmt->execute([$userId, $shiftDate, $leaveType, $leaveHours, $notes]);
|
||||
|
||||
updateLeaveBalance($pdo, $userId, $shiftDate, $leaveType, (float)$leaveHours);
|
||||
$pdo->commit();
|
||||
} catch (\Throwable $e) {
|
||||
$pdo->rollBack();
|
||||
throw $e;
|
||||
}
|
||||
} else {
|
||||
$arrivalDate = $input['arrival_date'] ?? $shiftDate;
|
||||
$arrivalTime = $input['arrival_time'] ?? null;
|
||||
$breakStartDate = $input['break_start_date'] ?? null;
|
||||
$breakStartTime = $input['break_start_time'] ?? null;
|
||||
$breakEndDate = $input['break_end_date'] ?? null;
|
||||
$breakEndTime = $input['break_end_time'] ?? null;
|
||||
$departureDate = $input['departure_date'] ?? null;
|
||||
$departureTime = $input['departure_time'] ?? null;
|
||||
/** @var mixed $rawProjectId */
|
||||
$rawProjectId = $input['project_id'] ?? null;
|
||||
$projectId = isset($input['project_id']) && $rawProjectId !== '' && $rawProjectId !== null
|
||||
? (int)$rawProjectId
|
||||
: null;
|
||||
|
||||
$arrival = $arrivalTime ? "{$arrivalDate} {$arrivalTime}:00" : null;
|
||||
$breakStart = ($breakStartDate && $breakStartTime) ? "{$breakStartDate} {$breakStartTime}:00" : null;
|
||||
$breakEnd = ($breakEndDate && $breakEndTime) ? "{$breakEndDate} {$breakEndTime}:00" : null;
|
||||
$departure = ($departureDate && $departureTime) ? "{$departureDate} {$departureTime}:00" : null;
|
||||
|
||||
$stmt = $pdo->prepare("
|
||||
INSERT INTO attendance
|
||||
(user_id, shift_date, arrival_time, break_start,
|
||||
break_end, departure_time, leave_type, notes, project_id)
|
||||
VALUES (?, ?, ?, ?, ?, ?, 'work', ?, ?)
|
||||
");
|
||||
$stmt->execute([$userId, $shiftDate, $arrival, $breakStart, $breakEnd, $departure, $notes, $projectId]);
|
||||
}
|
||||
|
||||
$newId = (int)$pdo->lastInsertId();
|
||||
|
||||
$projectLogs = $input['project_logs'] ?? [];
|
||||
if (!empty($projectLogs) && $leaveType === 'work') {
|
||||
$logStmt = $pdo->prepare(
|
||||
'INSERT INTO attendance_project_logs
|
||||
(attendance_id, project_id, hours, minutes)
|
||||
VALUES (?, ?, ?, ?)'
|
||||
);
|
||||
foreach ($projectLogs as $log) {
|
||||
$pid = (int)($log['project_id'] ?? 0);
|
||||
if (!$pid) {
|
||||
continue;
|
||||
}
|
||||
$h = (int)($log['hours'] ?? 0);
|
||||
$m = (int)($log['minutes'] ?? 0);
|
||||
if ($h === 0 && $m === 0) {
|
||||
continue;
|
||||
}
|
||||
$logStmt->execute([$newId, $pid, $h, $m]);
|
||||
}
|
||||
}
|
||||
|
||||
AuditLog::logCreate('attendance', $newId, $input, 'Admin vytvořil záznam docházky');
|
||||
|
||||
successResponse(['id' => $newId], 'Záznam byl vytvořen');
|
||||
}
|
||||
|
||||
function handleBulkAttendance(PDO $pdo): void
|
||||
{
|
||||
$input = getJsonInput();
|
||||
|
||||
$monthStr = $input['month'] ?? '';
|
||||
$userIds = $input['user_ids'] ?? [];
|
||||
$arrivalTime = trim($input['arrival_time'] ?? '08:00');
|
||||
$departureTime = trim($input['departure_time'] ?? '16:30');
|
||||
$breakStartTime = trim($input['break_start_time'] ?? '12:00');
|
||||
$breakEndTime = trim($input['break_end_time'] ?? '12:30');
|
||||
|
||||
if (!$monthStr || !preg_match('/^\d{4}-\d{2}$/', $monthStr)) {
|
||||
errorResponse('Měsíc je povinný (formát YYYY-MM)');
|
||||
}
|
||||
|
||||
if (empty($userIds) || !is_array($userIds)) {
|
||||
errorResponse('Vyberte alespoň jednoho zaměstnance');
|
||||
}
|
||||
|
||||
$year = (int)substr($monthStr, 0, 4);
|
||||
$month = (int)substr($monthStr, 5, 2);
|
||||
|
||||
$holidays = CzechHolidays::getHolidays($year);
|
||||
$daysInMonth = cal_days_in_month(CAL_GREGORIAN, $month, $year);
|
||||
|
||||
$inserted = 0;
|
||||
$skipped = 0;
|
||||
|
||||
// Batch fetch existing records (eliminates N*M queries)
|
||||
$dateFrom = sprintf('%04d-%02d-01', $year, $month);
|
||||
$dateTo = sprintf('%04d-%02d-%02d', $year, $month, $daysInMonth);
|
||||
$userIdInts = array_map('intval', $userIds);
|
||||
$placeholders = implode(',', array_fill(0, count($userIdInts), '?'));
|
||||
$existStmt = $pdo->prepare("
|
||||
SELECT user_id, shift_date FROM attendance
|
||||
WHERE user_id IN ($placeholders) AND shift_date BETWEEN ? AND ?
|
||||
");
|
||||
$existParams = array_merge($userIdInts, [$dateFrom, $dateTo]);
|
||||
$existStmt->execute($existParams);
|
||||
$existingRecords = [];
|
||||
foreach ($existStmt->fetchAll() as $row) {
|
||||
$existingRecords[$row['user_id'] . ':' . $row['shift_date']] = true;
|
||||
}
|
||||
|
||||
$holidayStmt = $pdo->prepare("
|
||||
INSERT INTO attendance (user_id, shift_date, leave_type, leave_hours, notes)
|
||||
VALUES (?, ?, 'holiday', 8, 'Státní svátek')
|
||||
");
|
||||
$workStmt = $pdo->prepare('
|
||||
INSERT INTO attendance (user_id, shift_date, arrival_time, departure_time, break_start, break_end)
|
||||
VALUES (?, ?, ?, ?, ?, ?)
|
||||
');
|
||||
|
||||
foreach ($userIdInts as $userId) {
|
||||
for ($day = 1; $day <= $daysInMonth; $day++) {
|
||||
$date = sprintf('%04d-%02d-%02d', $year, $month, $day);
|
||||
$dayOfWeek = (int)date('N', strtotime($date));
|
||||
|
||||
if ($dayOfWeek > 5) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$isHoliday = in_array($date, $holidays, true);
|
||||
|
||||
if (isset($existingRecords[$userId . ':' . $date])) {
|
||||
$skipped++;
|
||||
continue;
|
||||
}
|
||||
|
||||
if ($isHoliday) {
|
||||
$holidayStmt->execute([$userId, $date]);
|
||||
} else {
|
||||
$arrival = $date . ' ' . $arrivalTime . ':00';
|
||||
$departure = $date . ' ' . $departureTime . ':00';
|
||||
$breakStart = $date . ' ' . $breakStartTime . ':00';
|
||||
$breakEnd = $date . ' ' . $breakEndTime . ':00';
|
||||
$workStmt->execute([$userId, $date, $arrival, $departure, $breakStart, $breakEnd]);
|
||||
}
|
||||
$inserted++;
|
||||
}
|
||||
}
|
||||
|
||||
AuditLog::logCreate('attendance', 0, [
|
||||
'month' => $monthStr,
|
||||
'user_ids' => $userIds,
|
||||
'inserted' => $inserted,
|
||||
'skipped' => $skipped,
|
||||
], "Admin hromadně vytvořil $inserted záznamů docházky pro měsíc $monthStr");
|
||||
|
||||
$msg = "Vytvořeno $inserted záznamů";
|
||||
if ($skipped > 0) {
|
||||
$msg .= " ($skipped přeskočeno — již existují)";
|
||||
}
|
||||
|
||||
successResponse(['inserted' => $inserted, 'skipped' => $skipped], $msg);
|
||||
}
|
||||
|
||||
function handleUpdateBalance(PDO $pdo): void
|
||||
{
|
||||
$input = getJsonInput();
|
||||
|
||||
$userId = (int)($input['user_id'] ?? 0);
|
||||
$year = (int)($input['year'] ?? date('Y'));
|
||||
$actionType = $input['action_type'] ?? 'edit';
|
||||
|
||||
if (!$userId) {
|
||||
errorResponse('ID uživatele je povinné');
|
||||
}
|
||||
|
||||
$stmt = $pdo->prepare('SELECT id FROM leave_balances WHERE user_id = ? AND year = ?');
|
||||
$stmt->execute([$userId, $year]);
|
||||
$exists = $stmt->fetch();
|
||||
|
||||
if ($actionType === 'reset') {
|
||||
if ($exists) {
|
||||
$stmt = $pdo->prepare(
|
||||
'UPDATE leave_balances
|
||||
SET vacation_used = 0, sick_used = 0
|
||||
WHERE user_id = ? AND year = ?'
|
||||
);
|
||||
$stmt->execute([$userId, $year]);
|
||||
}
|
||||
successResponse(null, 'Bilance byla resetována');
|
||||
} else {
|
||||
$vacationTotal = (float)($input['vacation_total'] ?? 160);
|
||||
$vacationUsed = (float)($input['vacation_used'] ?? 0);
|
||||
$sickUsed = (float)($input['sick_used'] ?? 0);
|
||||
|
||||
if ($exists) {
|
||||
$stmt = $pdo->prepare(
|
||||
'UPDATE leave_balances
|
||||
SET vacation_total = ?, vacation_used = ?, sick_used = ?
|
||||
WHERE user_id = ? AND year = ?'
|
||||
);
|
||||
$stmt->execute([$vacationTotal, $vacationUsed, $sickUsed, $userId, $year]);
|
||||
} else {
|
||||
$stmt = $pdo->prepare(
|
||||
'INSERT INTO leave_balances
|
||||
(user_id, year, vacation_total, vacation_used, sick_used)
|
||||
VALUES (?, ?, ?, ?, ?)'
|
||||
);
|
||||
$stmt->execute([$userId, $year, $vacationTotal, $vacationUsed, $sickUsed]);
|
||||
}
|
||||
successResponse(null, 'Bilance byla aktualizována');
|
||||
}
|
||||
}
|
||||
|
||||
function handleUpdateAttendance(PDO $pdo, int $recordId): void
|
||||
{
|
||||
$stmt = $pdo->prepare('SELECT * FROM attendance WHERE id = ?');
|
||||
$stmt->execute([$recordId]);
|
||||
$record = $stmt->fetch();
|
||||
|
||||
if (!$record) {
|
||||
errorResponse('Záznam nebyl nalezen', 404);
|
||||
}
|
||||
|
||||
$input = getJsonInput();
|
||||
|
||||
$shiftDate = $input['shift_date'] ?? $record['shift_date'];
|
||||
$leaveType = $input['leave_type'] ?? 'work';
|
||||
$leaveHours = $input['leave_hours'] ?? null;
|
||||
$notes = $input['notes'] ?? null;
|
||||
|
||||
$oldLeaveType = $record['leave_type'] ?? 'work';
|
||||
$oldLeaveHours = $record['leave_hours'] ?? 0;
|
||||
|
||||
$pdo->beginTransaction();
|
||||
try {
|
||||
if ($leaveType !== 'work') {
|
||||
$leaveHours = $leaveHours ?: 8;
|
||||
$stmt = $pdo->prepare('
|
||||
UPDATE attendance
|
||||
SET shift_date = ?, leave_type = ?, leave_hours = ?,
|
||||
arrival_time = NULL, break_start = NULL,
|
||||
break_end = NULL, departure_time = NULL, notes = ?
|
||||
WHERE id = ?
|
||||
');
|
||||
$stmt->execute([$shiftDate, $leaveType, $leaveHours, $notes, $recordId]);
|
||||
|
||||
if ($oldLeaveType !== 'work' && $oldLeaveHours > 0) {
|
||||
updateLeaveBalance(
|
||||
$pdo,
|
||||
(int)$record['user_id'],
|
||||
$record['shift_date'],
|
||||
$oldLeaveType,
|
||||
-(float)$oldLeaveHours
|
||||
);
|
||||
}
|
||||
updateLeaveBalance(
|
||||
$pdo,
|
||||
(int)$record['user_id'],
|
||||
$shiftDate,
|
||||
$leaveType,
|
||||
(float)$leaveHours
|
||||
);
|
||||
} else {
|
||||
$arrivalDate = $input['arrival_date'] ?? $shiftDate;
|
||||
$arrivalTime = $input['arrival_time'] ?? null;
|
||||
$breakStartDate = $input['break_start_date'] ?? null;
|
||||
$breakStartTime = $input['break_start_time'] ?? null;
|
||||
$breakEndDate = $input['break_end_date'] ?? null;
|
||||
$breakEndTime = $input['break_end_time'] ?? null;
|
||||
$departureDate = $input['departure_date'] ?? null;
|
||||
$departureTime = $input['departure_time'] ?? null;
|
||||
/** @var mixed $rawProjectId */
|
||||
$rawProjectId = $input['project_id'] ?? null;
|
||||
$projectId = isset($input['project_id']) && $rawProjectId !== '' && $rawProjectId !== null
|
||||
? (int)$rawProjectId
|
||||
: null;
|
||||
|
||||
$arrival = $arrivalTime ? "{$arrivalDate} {$arrivalTime}:00" : null;
|
||||
$breakStart = $breakStartTime ? "{$breakStartDate} {$breakStartTime}:00" : null;
|
||||
$breakEnd = $breakEndTime ? "{$breakEndDate} {$breakEndTime}:00" : null;
|
||||
$departure = $departureTime ? "{$departureDate} {$departureTime}:00" : null;
|
||||
|
||||
$stmt = $pdo->prepare("
|
||||
UPDATE attendance
|
||||
SET shift_date = ?, arrival_time = ?, break_start = ?,
|
||||
break_end = ?, departure_time = ?,
|
||||
leave_type = 'work', leave_hours = NULL,
|
||||
notes = ?, project_id = ?
|
||||
WHERE id = ?
|
||||
");
|
||||
$stmt->execute([$shiftDate, $arrival, $breakStart, $breakEnd, $departure, $notes, $projectId, $recordId]);
|
||||
|
||||
if ($oldLeaveType !== 'work' && $oldLeaveHours > 0) {
|
||||
updateLeaveBalance(
|
||||
$pdo,
|
||||
(int)$record['user_id'],
|
||||
$record['shift_date'],
|
||||
$oldLeaveType,
|
||||
-(float)$oldLeaveHours
|
||||
);
|
||||
}
|
||||
}
|
||||
$pdo->commit();
|
||||
} catch (\Throwable $e) {
|
||||
$pdo->rollBack();
|
||||
throw $e;
|
||||
}
|
||||
|
||||
$projectLogs = $input['project_logs'] ?? null;
|
||||
if ($projectLogs !== null) {
|
||||
$stmt = $pdo->prepare('DELETE FROM attendance_project_logs WHERE attendance_id = ?');
|
||||
$stmt->execute([$recordId]);
|
||||
|
||||
if (!empty($projectLogs) && ($input['leave_type'] ?? 'work') === 'work') {
|
||||
$logStmt = $pdo->prepare(
|
||||
'INSERT INTO attendance_project_logs
|
||||
(attendance_id, project_id, hours, minutes)
|
||||
VALUES (?, ?, ?, ?)'
|
||||
);
|
||||
foreach ($projectLogs as $log) {
|
||||
$pid = (int)($log['project_id'] ?? 0);
|
||||
if (!$pid) {
|
||||
continue;
|
||||
}
|
||||
$h = (int)($log['hours'] ?? 0);
|
||||
$m = (int)($log['minutes'] ?? 0);
|
||||
if ($h === 0 && $m === 0) {
|
||||
continue;
|
||||
}
|
||||
$logStmt->execute([$recordId, $pid, $h, $m]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
AuditLog::logUpdate('attendance', $recordId, $record, $input, 'Admin upravil záznam docházky');
|
||||
|
||||
successResponse(null, 'Záznam byl aktualizován');
|
||||
}
|
||||
|
||||
function handleDeleteAttendance(PDO $pdo, int $recordId): void
|
||||
{
|
||||
$stmt = $pdo->prepare('SELECT * FROM attendance WHERE id = ?');
|
||||
$stmt->execute([$recordId]);
|
||||
$record = $stmt->fetch();
|
||||
|
||||
if (!$record) {
|
||||
errorResponse('Záznam nebyl nalezen', 404);
|
||||
}
|
||||
|
||||
$leaveType = $record['leave_type'] ?? 'work';
|
||||
$leaveHours = $record['leave_hours'] ?? 0;
|
||||
if ($leaveType !== 'work' && $leaveHours > 0) {
|
||||
updateLeaveBalance($pdo, (int)$record['user_id'], $record['shift_date'], $leaveType, -(float)$leaveHours);
|
||||
}
|
||||
|
||||
$stmt = $pdo->prepare('DELETE FROM attendance_project_logs WHERE attendance_id = ?');
|
||||
$stmt->execute([$recordId]);
|
||||
|
||||
$stmt = $pdo->prepare('DELETE FROM attendance WHERE id = ?');
|
||||
$stmt->execute([$recordId]);
|
||||
|
||||
AuditLog::logDelete('attendance', $recordId, $record, 'Admin smazal záznam docházky');
|
||||
|
||||
successResponse(null, 'Záznam byl smazán');
|
||||
}
|
||||
|
||||
function handleGetProjectReport(PDO $pdo): void
|
||||
{
|
||||
$yearParam = $_GET['year'] ?? null;
|
||||
$monthParam = $_GET['month'] ?? null;
|
||||
|
||||
if ($yearParam) {
|
||||
$yearInt = (int)$yearParam;
|
||||
$currentYear = (int)date('Y');
|
||||
$currentMonth = (int)date('m');
|
||||
$maxMonth = ($yearInt < $currentYear) ? 12 : (($yearInt === $currentYear) ? $currentMonth : 0);
|
||||
|
||||
if ($maxMonth === 0) {
|
||||
successResponse(['months' => [], 'year' => $yearInt]);
|
||||
return;
|
||||
}
|
||||
|
||||
$startDate = sprintf('%04d-01-01', $yearInt);
|
||||
$lastDay = cal_days_in_month(CAL_GREGORIAN, $maxMonth, $yearInt);
|
||||
$endDate = sprintf('%04d-%02d-%02d', $yearInt, $maxMonth, $lastDay);
|
||||
|
||||
$stmt = $pdo->prepare("
|
||||
SELECT a.user_id, a.id as attendance_id, a.shift_date,
|
||||
a.arrival_time, a.departure_time,
|
||||
a.break_start, a.break_end,
|
||||
CONCAT(u.first_name, ' ', u.last_name) as user_name
|
||||
FROM attendance a
|
||||
JOIN users u ON a.user_id = u.id
|
||||
WHERE a.shift_date BETWEEN ? AND ?
|
||||
AND a.departure_time IS NOT NULL
|
||||
AND (a.leave_type IS NULL OR a.leave_type = 'work')
|
||||
ORDER BY u.last_name ASC
|
||||
");
|
||||
$stmt->execute([$startDate, $endDate]);
|
||||
$workRecords = $stmt->fetchAll();
|
||||
|
||||
$totalWork = [];
|
||||
$attendanceIds = [];
|
||||
foreach ($workRecords as $rec) {
|
||||
$m = (int)date('m', strtotime($rec['shift_date']));
|
||||
$uid = $rec['user_id'];
|
||||
$attendanceIds[] = $rec['attendance_id'];
|
||||
if (!isset($totalWork[$m][$uid])) {
|
||||
$totalWork[$m][$uid] = ['name' => $rec['user_name'], 'minutes' => 0];
|
||||
}
|
||||
$totalWork[$m][$uid]['minutes'] += calculateWorkMinutes($rec);
|
||||
}
|
||||
|
||||
$loggedMinutes = [];
|
||||
$monthData = [];
|
||||
$projectIds = [];
|
||||
|
||||
if (!empty($attendanceIds)) {
|
||||
$placeholders = implode(',', array_fill(0, count($attendanceIds), '?'));
|
||||
$stmt = $pdo->prepare("
|
||||
SELECT pl.project_id, pl.started_at, pl.ended_at, pl.hours, pl.minutes AS mins, a.user_id, a.shift_date
|
||||
FROM attendance_project_logs pl
|
||||
JOIN attendance a ON pl.attendance_id = a.id
|
||||
WHERE pl.attendance_id IN ($placeholders)
|
||||
AND (pl.hours IS NOT NULL OR pl.ended_at IS NOT NULL)
|
||||
");
|
||||
$stmt->execute($attendanceIds);
|
||||
$logs = $stmt->fetchAll();
|
||||
|
||||
foreach ($logs as $log) {
|
||||
$m = (int)date('m', strtotime($log['shift_date']));
|
||||
$pid = (int)$log['project_id'];
|
||||
$uid = $log['user_id'];
|
||||
$projectIds[$pid] = true;
|
||||
if ($log['hours'] !== null) {
|
||||
$minutes = (int)$log['hours'] * 60 + (int)$log['mins'];
|
||||
} else {
|
||||
$minutes = max(0, (strtotime($log['ended_at']) - strtotime($log['started_at'])) / 60);
|
||||
}
|
||||
|
||||
if (!isset($monthData[$m][$pid][$uid])) {
|
||||
$monthData[$m][$pid][$uid] = ['minutes' => 0];
|
||||
}
|
||||
$monthData[$m][$pid][$uid]['minutes'] += $minutes;
|
||||
|
||||
if (!isset($loggedMinutes[$m][$uid])) {
|
||||
$loggedMinutes[$m][$uid] = 0;
|
||||
}
|
||||
$loggedMinutes[$m][$uid] += $minutes;
|
||||
}
|
||||
}
|
||||
|
||||
// "Bez projektu" = total work - logged
|
||||
foreach ($totalWork as $m => $users) {
|
||||
foreach ($users as $uid => $ud) {
|
||||
$logged = $loggedMinutes[$m][$uid] ?? 0;
|
||||
$unlogged = $ud['minutes'] - $logged;
|
||||
if ($unlogged > 1) {
|
||||
if (!isset($monthData[$m][0][$uid])) {
|
||||
$monthData[$m][0][$uid] = ['minutes' => 0];
|
||||
}
|
||||
$monthData[$m][0][$uid]['minutes'] += $unlogged;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
$projectMap = [];
|
||||
if (!empty($projectIds)) {
|
||||
try {
|
||||
$offersPdo = db();
|
||||
$ids = array_keys($projectIds);
|
||||
$placeholders = implode(',', array_fill(0, count($ids), '?'));
|
||||
$stmt2 = $offersPdo->prepare(
|
||||
"SELECT id, project_number, name
|
||||
FROM projects WHERE id IN ($placeholders)"
|
||||
);
|
||||
$stmt2->execute($ids);
|
||||
foreach ($stmt2->fetchAll() as $p) {
|
||||
$projectMap[$p['id']] = $p;
|
||||
}
|
||||
} catch (\Exception $e) {
|
||||
error_log('Failed to fetch project names for yearly report: ' . $e->getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
$userNames = [];
|
||||
foreach ($totalWork as $m => $users) {
|
||||
foreach ($users as $uid => $ud) {
|
||||
$userNames[$uid] = $ud['name'];
|
||||
}
|
||||
}
|
||||
|
||||
$months = [];
|
||||
for ($m = 1; $m <= $maxMonth; $m++) {
|
||||
$projects = [];
|
||||
if (isset($monthData[$m])) {
|
||||
foreach ($monthData[$m] as $pid => $usersData) {
|
||||
$proj = $pid ? ($projectMap[$pid] ?? null) : null;
|
||||
$users = [];
|
||||
$projectTotal = 0;
|
||||
foreach ($usersData as $uid => $ud) {
|
||||
$hours = round($ud['minutes'] / 60, 1);
|
||||
$projectTotal += $hours;
|
||||
$users[] = [
|
||||
'user_id' => $uid,
|
||||
'user_name' => $userNames[$uid] ?? "User #$uid",
|
||||
'hours' => $hours,
|
||||
];
|
||||
}
|
||||
usort($users, fn ($a, $b) => $b['hours'] <=> $a['hours']);
|
||||
$projects[] = [
|
||||
'project_id' => $pid ?: null,
|
||||
'project_number' => $proj ? $proj['project_number'] : null,
|
||||
'project_name' => $proj ? $proj['name'] : null,
|
||||
'hours' => round($projectTotal, 1),
|
||||
'users' => $users,
|
||||
];
|
||||
}
|
||||
usort($projects, fn ($a, $b) => $b['hours'] <=> $a['hours']);
|
||||
}
|
||||
$months[$m] = [
|
||||
'month' => $m,
|
||||
'month_name' => getCzechMonthName($m),
|
||||
'projects' => $projects,
|
||||
];
|
||||
}
|
||||
|
||||
successResponse(['months' => $months, 'year' => $yearInt]);
|
||||
return;
|
||||
}
|
||||
|
||||
// Single month mode
|
||||
$month = $monthParam ?? date('Y-m');
|
||||
$startDate = "{$month}-01";
|
||||
$endDate = date('Y-m-t', strtotime($startDate));
|
||||
|
||||
$stmt = $pdo->prepare("
|
||||
SELECT a.user_id, a.id as attendance_id, a.arrival_time, a.departure_time, a.break_start, a.break_end,
|
||||
CONCAT(u.first_name, ' ', u.last_name) as user_name
|
||||
FROM attendance a
|
||||
JOIN users u ON a.user_id = u.id
|
||||
WHERE a.shift_date BETWEEN ? AND ?
|
||||
AND a.departure_time IS NOT NULL
|
||||
AND (a.leave_type IS NULL OR a.leave_type = 'work')
|
||||
ORDER BY u.last_name ASC
|
||||
");
|
||||
$stmt->execute([$startDate, $endDate]);
|
||||
$workRecords = $stmt->fetchAll();
|
||||
|
||||
$userTotalMinutes = [];
|
||||
$userNames = [];
|
||||
$attendanceIds = [];
|
||||
foreach ($workRecords as $rec) {
|
||||
$uid = $rec['user_id'];
|
||||
$attendanceIds[] = $rec['attendance_id'];
|
||||
$userNames[$uid] = $rec['user_name'];
|
||||
if (!isset($userTotalMinutes[$uid])) {
|
||||
$userTotalMinutes[$uid] = 0;
|
||||
}
|
||||
$userTotalMinutes[$uid] += calculateWorkMinutes($rec);
|
||||
}
|
||||
|
||||
$aggregated = [];
|
||||
$projectIds = [];
|
||||
$userLoggedMinutes = [];
|
||||
|
||||
if (!empty($attendanceIds)) {
|
||||
$placeholders = implode(',', array_fill(0, count($attendanceIds), '?'));
|
||||
$stmt = $pdo->prepare("
|
||||
SELECT pl.project_id, pl.started_at, pl.ended_at, pl.hours, pl.minutes AS mins, a.user_id
|
||||
FROM attendance_project_logs pl
|
||||
JOIN attendance a ON pl.attendance_id = a.id
|
||||
WHERE pl.attendance_id IN ($placeholders)
|
||||
AND (pl.hours IS NOT NULL OR pl.ended_at IS NOT NULL)
|
||||
");
|
||||
$stmt->execute($attendanceIds);
|
||||
$logs = $stmt->fetchAll();
|
||||
|
||||
foreach ($logs as $log) {
|
||||
$uid = $log['user_id'];
|
||||
$pid = (int)$log['project_id'];
|
||||
$key = "{$uid}_{$pid}";
|
||||
$projectIds[$pid] = true;
|
||||
if ($log['hours'] !== null) {
|
||||
$minutes = (int)$log['hours'] * 60 + (int)$log['mins'];
|
||||
} else {
|
||||
$minutes = max(0, (strtotime($log['ended_at']) - strtotime($log['started_at'])) / 60);
|
||||
}
|
||||
|
||||
if (!isset($aggregated[$key])) {
|
||||
$aggregated[$key] = ['user_id' => $uid, 'project_id' => $pid, 'minutes' => 0];
|
||||
}
|
||||
$aggregated[$key]['minutes'] += $minutes;
|
||||
|
||||
if (!isset($userLoggedMinutes[$uid])) {
|
||||
$userLoggedMinutes[$uid] = 0;
|
||||
}
|
||||
$userLoggedMinutes[$uid] += $minutes;
|
||||
}
|
||||
}
|
||||
|
||||
// "Bez projektu" per user
|
||||
foreach ($userTotalMinutes as $uid => $total) {
|
||||
$logged = $userLoggedMinutes[$uid] ?? 0;
|
||||
$unlogged = $total - $logged;
|
||||
if ($unlogged > 1) {
|
||||
$key = "{$uid}_0";
|
||||
if (!isset($aggregated[$key])) {
|
||||
$aggregated[$key] = ['user_id' => $uid, 'project_id' => 0, 'minutes' => 0];
|
||||
}
|
||||
$aggregated[$key]['minutes'] += $unlogged;
|
||||
}
|
||||
}
|
||||
|
||||
$projectMap = [];
|
||||
if (!empty($projectIds)) {
|
||||
try {
|
||||
$offersPdo = db();
|
||||
$ids = array_keys($projectIds);
|
||||
$placeholders = implode(',', array_fill(0, count($ids), '?'));
|
||||
$stmt = $offersPdo->prepare("SELECT id, project_number, name FROM projects WHERE id IN ($placeholders)");
|
||||
$stmt->execute($ids);
|
||||
foreach ($stmt->fetchAll() as $p) {
|
||||
$projectMap[$p['id']] = $p;
|
||||
}
|
||||
} catch (\Exception $e) {
|
||||
error_log('Failed to fetch project names for report: ' . $e->getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
$report = [];
|
||||
foreach ($aggregated as $item) {
|
||||
$pid = $item['project_id'];
|
||||
$proj = $pid ? ($projectMap[$pid] ?? null) : null;
|
||||
$report[] = [
|
||||
'user_id' => $item['user_id'],
|
||||
'user_name' => $userNames[$item['user_id']] ?? "User #{$item['user_id']}",
|
||||
'project_id' => $pid ?: null,
|
||||
'project_number' => $proj ? $proj['project_number'] : null,
|
||||
'project_name' => $proj ? $proj['name'] : null,
|
||||
'hours' => round($item['minutes'] / 60, 2),
|
||||
];
|
||||
}
|
||||
|
||||
successResponse([
|
||||
'report' => $report,
|
||||
'month' => $month,
|
||||
]);
|
||||
}
|
||||
|
||||
function handleGetPrint(PDO $pdo): void
|
||||
{
|
||||
$month = validateMonth();
|
||||
$filterUserId = isset($_GET['user_id']) && $_GET['user_id'] !== '' ? (int)$_GET['user_id'] : null;
|
||||
|
||||
$year = (int)substr($month, 0, 4);
|
||||
$monthNum = (int)substr($month, 5, 2);
|
||||
|
||||
$startDate = "{$month}-01";
|
||||
$endDate = date('Y-m-t', strtotime($startDate));
|
||||
|
||||
$stmt = $pdo->query(
|
||||
"SELECT id, CONCAT(first_name, ' ', last_name) as name
|
||||
FROM users WHERE is_active = 1 ORDER BY last_name"
|
||||
);
|
||||
$users = $stmt->fetchAll();
|
||||
|
||||
$sql = "
|
||||
SELECT a.*, CONCAT(u.first_name, ' ', u.last_name) as user_name
|
||||
FROM attendance a
|
||||
JOIN users u ON a.user_id = u.id
|
||||
WHERE a.shift_date BETWEEN ? AND ?
|
||||
";
|
||||
$params = [$startDate, $endDate];
|
||||
|
||||
if ($filterUserId) {
|
||||
$sql .= ' AND a.user_id = ?';
|
||||
$params[] = $filterUserId;
|
||||
}
|
||||
|
||||
$sql .= ' ORDER BY u.last_name ASC, a.shift_date ASC';
|
||||
|
||||
$stmt = $pdo->prepare($sql);
|
||||
$stmt->execute($params);
|
||||
$records = $stmt->fetchAll();
|
||||
|
||||
enrichRecordsWithProjectLogs($pdo, $records);
|
||||
|
||||
$userTotals = calculateUserTotals($records, true);
|
||||
$leaveBalances = getLeaveBalancesBatch($pdo, array_keys($userTotals), $year);
|
||||
addFundDataToUserTotals($pdo, $userTotals, $year, $monthNum);
|
||||
|
||||
$selectedUserName = '';
|
||||
if ($filterUserId) {
|
||||
$stmt = $pdo->prepare("SELECT CONCAT(first_name, ' ', last_name) as name FROM users WHERE id = ?");
|
||||
$stmt->execute([$filterUserId]);
|
||||
$user = $stmt->fetch();
|
||||
$selectedUserName = $user ? $user['name'] : '';
|
||||
}
|
||||
|
||||
$fund = CzechHolidays::getMonthlyWorkFund($year, $monthNum);
|
||||
|
||||
successResponse([
|
||||
'user_totals' => $userTotals,
|
||||
'leave_balances' => $leaveBalances,
|
||||
'users' => $users,
|
||||
'month' => $month,
|
||||
'month_name' => getCzechMonthName($monthNum) . ' ' . $year,
|
||||
'selected_user' => $filterUserId,
|
||||
'selected_user_name' => $selectedUserName,
|
||||
'year' => $year,
|
||||
'fund' => $fund,
|
||||
]);
|
||||
}
|
||||
370
api/includes/AttendanceHelpers.php
Normal file
370
api/includes/AttendanceHelpers.php
Normal file
@@ -0,0 +1,370 @@
|
||||
<?php
|
||||
|
||||
/**
|
||||
* Attendance helper functions - shared between user and admin handlers
|
||||
*/
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
function roundUpTo15Minutes(string $datetime): string
|
||||
{
|
||||
$timestamp = strtotime($datetime);
|
||||
$minutes = (int)date('i', $timestamp);
|
||||
|
||||
$remainder = $minutes % 15;
|
||||
|
||||
if ($remainder === 0) {
|
||||
$roundedMinutes = $minutes;
|
||||
} else {
|
||||
$roundedMinutes = $minutes + (15 - $remainder);
|
||||
}
|
||||
|
||||
$baseTime = strtotime(date('Y-m-d H:00:00', $timestamp));
|
||||
return date('Y-m-d H:i:s', $baseTime + ($roundedMinutes * 60));
|
||||
}
|
||||
|
||||
function roundDownTo15Minutes(string $datetime): string
|
||||
{
|
||||
$timestamp = strtotime($datetime);
|
||||
$minutes = (int)date('i', $timestamp);
|
||||
|
||||
$remainder = $minutes % 15;
|
||||
$roundedMinutes = $minutes - $remainder;
|
||||
|
||||
$baseTime = strtotime(date('Y-m-d H:00:00', $timestamp));
|
||||
return date('Y-m-d H:i:s', $baseTime + ($roundedMinutes * 60));
|
||||
}
|
||||
|
||||
function roundToNearest10Minutes(string $datetime): string
|
||||
{
|
||||
$timestamp = strtotime($datetime);
|
||||
$minutes = (int)date('i', $timestamp);
|
||||
|
||||
$remainder = $minutes % 10;
|
||||
|
||||
if ($remainder < 5) {
|
||||
$roundedMinutes = $minutes - $remainder;
|
||||
} else {
|
||||
$roundedMinutes = $minutes + (10 - $remainder);
|
||||
}
|
||||
|
||||
$baseTime = strtotime(date('Y-m-d H:00:00', $timestamp));
|
||||
return date('Y-m-d H:i:s', $baseTime + ($roundedMinutes * 60));
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $record
|
||||
*/
|
||||
function calculateWorkMinutes(array $record): int
|
||||
{
|
||||
if (!$record['arrival_time'] || !$record['departure_time']) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
$arrival = strtotime($record['arrival_time']);
|
||||
$departure = strtotime($record['departure_time']);
|
||||
$totalMinutes = ($departure - $arrival) / 60;
|
||||
|
||||
if ($record['break_start'] && $record['break_end']) {
|
||||
$breakStart = strtotime($record['break_start']);
|
||||
$breakEnd = strtotime($record['break_end']);
|
||||
$breakMinutes = ($breakEnd - $breakStart) / 60;
|
||||
$totalMinutes -= $breakMinutes;
|
||||
}
|
||||
|
||||
return max(0, (int)$totalMinutes);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{vacation_total: float, vacation_used: float, vacation_remaining: float, sick_used: float}
|
||||
*/
|
||||
function getLeaveBalance(PDO $pdo, int $userId, ?int $year = null): array
|
||||
{
|
||||
$year = $year ?: (int)date('Y');
|
||||
|
||||
$stmt = $pdo->prepare(
|
||||
'SELECT vacation_total, vacation_used, sick_used FROM leave_balances WHERE user_id = ? AND year = ?'
|
||||
);
|
||||
$stmt->execute([$userId, $year]);
|
||||
$balance = $stmt->fetch();
|
||||
|
||||
if (!$balance) {
|
||||
return [
|
||||
'vacation_total' => 160,
|
||||
'vacation_used' => 0,
|
||||
'vacation_remaining' => 160,
|
||||
'sick_used' => 0,
|
||||
];
|
||||
}
|
||||
|
||||
return [
|
||||
'vacation_total' => (float)$balance['vacation_total'],
|
||||
'vacation_used' => (float)$balance['vacation_used'],
|
||||
'vacation_remaining' => (float)$balance['vacation_total'] - (float)$balance['vacation_used'],
|
||||
'sick_used' => (float)$balance['sick_used'],
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Batch get leave balances for multiple users (eliminates N+1 queries)
|
||||
*
|
||||
* @param array<int, int> $userIds
|
||||
* @return array<int, array{vacation_total: float, vacation_used: float, vacation_remaining: float, sick_used: float}>
|
||||
*/
|
||||
function getLeaveBalancesBatch(PDO $pdo, array $userIds, ?int $year = null): array
|
||||
{
|
||||
$year = $year ?: (int)date('Y');
|
||||
$result = [];
|
||||
|
||||
$default = [
|
||||
'vacation_total' => 160,
|
||||
'vacation_used' => 0,
|
||||
'vacation_remaining' => 160,
|
||||
'sick_used' => 0,
|
||||
];
|
||||
|
||||
if (empty($userIds)) {
|
||||
return $result;
|
||||
}
|
||||
|
||||
$placeholders = implode(',', array_fill(0, count($userIds), '?'));
|
||||
$stmt = $pdo->prepare("
|
||||
SELECT user_id, vacation_total, vacation_used, sick_used
|
||||
FROM leave_balances
|
||||
WHERE user_id IN ($placeholders) AND year = ?
|
||||
");
|
||||
$params = array_values($userIds);
|
||||
$params[] = $year;
|
||||
$stmt->execute($params);
|
||||
$rows = $stmt->fetchAll();
|
||||
|
||||
$balanceMap = [];
|
||||
foreach ($rows as $row) {
|
||||
$balanceMap[$row['user_id']] = [
|
||||
'vacation_total' => (float)$row['vacation_total'],
|
||||
'vacation_used' => (float)$row['vacation_used'],
|
||||
'vacation_remaining' => (float)$row['vacation_total'] - (float)$row['vacation_used'],
|
||||
'sick_used' => (float)$row['sick_used'],
|
||||
];
|
||||
}
|
||||
|
||||
foreach ($userIds as $uid) {
|
||||
$result[$uid] = $balanceMap[$uid] ?? $default;
|
||||
}
|
||||
|
||||
return $result;
|
||||
}
|
||||
|
||||
function updateLeaveBalance(PDO $pdo, int $userId, string $date, string $leaveType, float $hours): void
|
||||
{
|
||||
if ($leaveType === 'work' || $leaveType === 'holiday' || $leaveType === 'unpaid') {
|
||||
return;
|
||||
}
|
||||
|
||||
$year = (int)date('Y', strtotime($date));
|
||||
|
||||
$stmt = $pdo->prepare('SELECT id FROM leave_balances WHERE user_id = ? AND year = ?');
|
||||
$stmt->execute([$userId, $year]);
|
||||
$balance = $stmt->fetch();
|
||||
|
||||
if (!$balance) {
|
||||
$stmt = $pdo->prepare(
|
||||
'INSERT INTO leave_balances (user_id, year, vacation_total, vacation_used, sick_used)
|
||||
VALUES (?, ?, 160, 0, 0)'
|
||||
);
|
||||
$stmt->execute([$userId, $year]);
|
||||
}
|
||||
|
||||
if ($leaveType === 'vacation') {
|
||||
$stmt = $pdo->prepare(
|
||||
'UPDATE leave_balances SET vacation_used = vacation_used + ? WHERE user_id = ? AND year = ?'
|
||||
);
|
||||
$stmt->execute([$hours, $userId, $year]);
|
||||
} elseif ($leaveType === 'sick') {
|
||||
$stmt = $pdo->prepare('UPDATE leave_balances SET sick_used = sick_used + ? WHERE user_id = ? AND year = ?');
|
||||
$stmt->execute([$hours, $userId, $year]);
|
||||
}
|
||||
}
|
||||
|
||||
function getCzechMonthName(int $month): string
|
||||
{
|
||||
$months = [
|
||||
1 => 'Leden', 2 => 'Únor', 3 => 'Březen', 4 => 'Duben',
|
||||
5 => 'Květen', 6 => 'Červen', 7 => 'Červenec', 8 => 'Srpen',
|
||||
9 => 'Září', 10 => 'Říjen', 11 => 'Listopad', 12 => 'Prosinec',
|
||||
];
|
||||
return $months[$month] ?? '';
|
||||
}
|
||||
|
||||
function getCzechDayName(int $dayOfWeek): string
|
||||
{
|
||||
$days = [
|
||||
0 => 'neděle', 1 => 'pondělí', 2 => 'úterý', 3 => 'středa',
|
||||
4 => 'čtvrtek', 5 => 'pátek', 6 => 'sobota',
|
||||
];
|
||||
return $days[$dayOfWeek] ?? '';
|
||||
}
|
||||
|
||||
/**
|
||||
* Enrich attendance records with project logs and project names (in-place)
|
||||
*
|
||||
* @param array<int, array<string, mixed>> $records
|
||||
*/
|
||||
function enrichRecordsWithProjectLogs(PDO $pdo, array &$records): void
|
||||
{
|
||||
$recordIds = array_column($records, 'id');
|
||||
$recordProjectLogs = [];
|
||||
if (!empty($recordIds)) {
|
||||
$placeholders = implode(',', array_fill(0, count($recordIds), '?'));
|
||||
$stmt = $pdo->prepare(
|
||||
"SELECT * FROM attendance_project_logs WHERE attendance_id IN ($placeholders) ORDER BY started_at ASC"
|
||||
);
|
||||
$stmt->execute($recordIds);
|
||||
foreach ($stmt->fetchAll() as $log) {
|
||||
$recordProjectLogs[$log['attendance_id']][] = $log;
|
||||
}
|
||||
}
|
||||
|
||||
$projectIds = [];
|
||||
foreach ($records as $rec) {
|
||||
if ($rec['project_id']) {
|
||||
$projectIds[$rec['project_id']] = $rec['project_id'];
|
||||
}
|
||||
}
|
||||
foreach ($recordProjectLogs as $logs) {
|
||||
foreach ($logs as $l) {
|
||||
$projectIds[$l['project_id']] = $l['project_id'];
|
||||
}
|
||||
}
|
||||
$projectNameMap = fetchProjectNames($projectIds);
|
||||
|
||||
foreach ($records as &$rec) {
|
||||
$rec['project_name'] = $rec['project_id'] ? ($projectNameMap[$rec['project_id']] ?? null) : null;
|
||||
$logs = $recordProjectLogs[$rec['id']] ?? [];
|
||||
foreach ($logs as &$l) {
|
||||
$l['project_name'] = $projectNameMap[$l['project_id']] ?? null;
|
||||
}
|
||||
unset($l);
|
||||
$rec['project_logs'] = $logs;
|
||||
}
|
||||
unset($rec);
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate per-user totals from records array
|
||||
*
|
||||
* @param list<array<string, mixed>> $records
|
||||
* @return array<int, array<string, mixed>>
|
||||
*/
|
||||
function calculateUserTotals(array $records, bool $includeRecords = false): array
|
||||
{
|
||||
$userTotals = [];
|
||||
foreach ($records as $record) {
|
||||
$uid = $record['user_id'];
|
||||
if (!isset($userTotals[$uid])) {
|
||||
$userTotals[$uid] = [
|
||||
'name' => $record['user_name'],
|
||||
'minutes' => 0,
|
||||
'working' => false,
|
||||
'vacation_hours' => 0,
|
||||
'sick_hours' => 0,
|
||||
'holiday_hours' => 0,
|
||||
'unpaid_hours' => 0,
|
||||
];
|
||||
if ($includeRecords) {
|
||||
$userTotals[$uid]['records'] = [];
|
||||
}
|
||||
}
|
||||
|
||||
$leaveType = $record['leave_type'] ?? 'work';
|
||||
$leaveHours = (float)($record['leave_hours'] ?? 0);
|
||||
|
||||
if ($leaveType === 'vacation') {
|
||||
$userTotals[$uid]['vacation_hours'] += $leaveHours;
|
||||
} elseif ($leaveType === 'sick') {
|
||||
$userTotals[$uid]['sick_hours'] += $leaveHours;
|
||||
} elseif ($leaveType === 'holiday') {
|
||||
$userTotals[$uid]['holiday_hours'] += $leaveHours;
|
||||
} elseif ($leaveType === 'unpaid') {
|
||||
$userTotals[$uid]['unpaid_hours'] += $leaveHours;
|
||||
} else {
|
||||
$userTotals[$uid]['minutes'] += calculateWorkMinutes($record);
|
||||
}
|
||||
|
||||
if ($includeRecords) {
|
||||
$userTotals[$uid]['records'][] = $record;
|
||||
}
|
||||
|
||||
if ($record['arrival_time'] && !$record['departure_time']) {
|
||||
$userTotals[$uid]['working'] = true;
|
||||
}
|
||||
}
|
||||
return $userTotals;
|
||||
}
|
||||
|
||||
/**
|
||||
* Add monthly fund data and "working now" status to user totals
|
||||
*
|
||||
* @param array<array<string, mixed>> $userTotals
|
||||
*/
|
||||
function addFundDataToUserTotals(PDO $pdo, array &$userTotals, int $year, int $monthNum): void
|
||||
{
|
||||
$fund = CzechHolidays::getMonthlyWorkFund($year, $monthNum);
|
||||
$businessDays = CzechHolidays::getBusinessDaysInMonth($year, $monthNum);
|
||||
|
||||
foreach ($userTotals as $uid => &$ut) {
|
||||
$workedHours = round($ut['minutes'] / 60, 1);
|
||||
$leaveHours = $ut['vacation_hours'] + $ut['sick_hours'];
|
||||
$covered = $workedHours + $leaveHours;
|
||||
$ut['fund'] = $fund;
|
||||
$ut['business_days'] = $businessDays;
|
||||
$ut['worked_hours'] = $workedHours;
|
||||
$ut['covered'] = $covered;
|
||||
$ut['missing'] = max(0, round($fund - $covered, 1));
|
||||
$ut['overtime'] = max(0, round($covered - $fund, 1));
|
||||
}
|
||||
unset($ut);
|
||||
|
||||
$today = date('Y-m-d');
|
||||
$stmt = $pdo->prepare("
|
||||
SELECT DISTINCT user_id FROM attendance
|
||||
WHERE shift_date = ?
|
||||
AND arrival_time IS NOT NULL
|
||||
AND departure_time IS NULL
|
||||
AND (leave_type IS NULL OR leave_type = 'work')
|
||||
");
|
||||
$stmt->execute([$today]);
|
||||
$workingNow = $stmt->fetchAll(PDO::FETCH_COLUMN);
|
||||
foreach ($workingNow as $uid) {
|
||||
if (isset($userTotals[$uid])) {
|
||||
$userTotals[$uid]['working'] = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch project names from offers DB
|
||||
*
|
||||
* @param array<int, int> $projectIds
|
||||
* @return array<int, string>
|
||||
*/
|
||||
function fetchProjectNames(array $projectIds): array
|
||||
{
|
||||
if (empty($projectIds)) {
|
||||
return [];
|
||||
}
|
||||
try {
|
||||
$pdo = db();
|
||||
$placeholders = implode(',', array_fill(0, count($projectIds), '?'));
|
||||
$stmt = $pdo->prepare("SELECT id, project_number, name FROM projects WHERE id IN ($placeholders)");
|
||||
$stmt->execute(array_values($projectIds));
|
||||
$map = [];
|
||||
foreach ($stmt->fetchAll() as $p) {
|
||||
$map[$p['id']] = $p['project_number'] . ' – ' . $p['name'];
|
||||
}
|
||||
return $map;
|
||||
} catch (\Exception $e) {
|
||||
error_log('Failed to fetch project names: ' . $e->getMessage());
|
||||
return [];
|
||||
}
|
||||
}
|
||||
556
api/includes/AuditLog.php
Normal file
556
api/includes/AuditLog.php
Normal file
@@ -0,0 +1,556 @@
|
||||
<?php
|
||||
|
||||
/**
|
||||
* BOHA Automation - Audit Logging System
|
||||
*
|
||||
* Comprehensive audit trail for all administrative actions
|
||||
*/
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
require_once dirname(__DIR__) . '/config.php';
|
||||
|
||||
class AuditLog
|
||||
{
|
||||
// Action types
|
||||
public const ACTION_LOGIN = 'login';
|
||||
public const ACTION_LOGIN_FAILED = 'login_failed';
|
||||
public const ACTION_LOGOUT = 'logout';
|
||||
public const ACTION_CREATE = 'create';
|
||||
public const ACTION_UPDATE = 'update';
|
||||
public const ACTION_DELETE = 'delete';
|
||||
public const ACTION_VIEW = 'view';
|
||||
public const ACTION_ACTIVATE = 'activate';
|
||||
public const ACTION_DEACTIVATE = 'deactivate';
|
||||
public const ACTION_PASSWORD_CHANGE = 'password_change';
|
||||
public const ACTION_PERMISSION_CHANGE = 'permission_change';
|
||||
public const ACTION_ACCESS_DENIED = 'access_denied';
|
||||
|
||||
private static ?int $currentUserId = null;
|
||||
private static ?string $currentUsername = null;
|
||||
|
||||
/**
|
||||
* Nastaví kontext aktuálního uživatele pro všechny následující logy
|
||||
*/
|
||||
public static function setUser(int $userId, string $username): void
|
||||
{
|
||||
self::$currentUserId = $userId;
|
||||
self::$currentUsername = $username;
|
||||
}
|
||||
|
||||
/**
|
||||
* Log an action
|
||||
*
|
||||
* @param string $action Action type (use class constants)
|
||||
* @param string|null $entityType Entity type (e.g., 'user', 'project')
|
||||
* @param int|null $entityId Entity ID
|
||||
* @param string|null $description Human-readable description
|
||||
* @param array<string, mixed>|null $oldValues Previous values (for updates)
|
||||
* @param array<string, mixed>|null $newValues New values (for updates/creates)
|
||||
*/
|
||||
public static function log(
|
||||
string $action,
|
||||
?string $entityType = null,
|
||||
?int $entityId = null,
|
||||
?string $description = null,
|
||||
?array $oldValues = null,
|
||||
?array $newValues = null
|
||||
): void {
|
||||
try {
|
||||
$pdo = db();
|
||||
|
||||
$userId = self::$currentUserId;
|
||||
$username = self::$currentUsername;
|
||||
|
||||
$stmt = $pdo->prepare('
|
||||
INSERT INTO audit_logs (
|
||||
user_id,
|
||||
username,
|
||||
user_ip,
|
||||
action,
|
||||
entity_type,
|
||||
entity_id,
|
||||
description,
|
||||
old_values,
|
||||
new_values,
|
||||
user_agent,
|
||||
session_id
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
');
|
||||
|
||||
$stmt->execute([
|
||||
$userId,
|
||||
$username,
|
||||
getClientIp(),
|
||||
$action,
|
||||
$entityType,
|
||||
$entityId,
|
||||
$description,
|
||||
$oldValues ? json_encode($oldValues, JSON_UNESCAPED_UNICODE) : null,
|
||||
$newValues ? json_encode($newValues, JSON_UNESCAPED_UNICODE) : null,
|
||||
substr($_SERVER['HTTP_USER_AGENT'] ?? 'unknown', 0, 500),
|
||||
session_id() ?: null,
|
||||
]);
|
||||
} catch (PDOException $e) {
|
||||
error_log('AuditLog error: ' . $e->getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Log successful login
|
||||
*
|
||||
* @param int $userId User ID
|
||||
* @param string $username Username
|
||||
*/
|
||||
public static function logLogin(int $userId, string $username): void
|
||||
{
|
||||
try {
|
||||
$pdo = db();
|
||||
|
||||
$stmt = $pdo->prepare('
|
||||
INSERT INTO audit_logs (
|
||||
user_id,
|
||||
username,
|
||||
user_ip,
|
||||
action,
|
||||
entity_type,
|
||||
entity_id,
|
||||
description,
|
||||
user_agent,
|
||||
session_id
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
');
|
||||
|
||||
$stmt->execute([
|
||||
$userId,
|
||||
$username,
|
||||
getClientIp(),
|
||||
self::ACTION_LOGIN,
|
||||
'user',
|
||||
$userId,
|
||||
"Přihlášení uživatele '$username'",
|
||||
substr($_SERVER['HTTP_USER_AGENT'] ?? 'unknown', 0, 500),
|
||||
session_id() ?: null,
|
||||
]);
|
||||
} catch (PDOException $e) {
|
||||
error_log('AuditLog login error: ' . $e->getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Log failed login attempt
|
||||
*
|
||||
* @param string $username Attempted username
|
||||
* @param string $reason Failure reason
|
||||
*/
|
||||
public static function logLoginFailed(string $username, string $reason = 'invalid_credentials'): void
|
||||
{
|
||||
try {
|
||||
$pdo = db();
|
||||
|
||||
$stmt = $pdo->prepare('
|
||||
INSERT INTO audit_logs (
|
||||
username,
|
||||
user_ip,
|
||||
action,
|
||||
entity_type,
|
||||
description,
|
||||
user_agent,
|
||||
session_id
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?)
|
||||
');
|
||||
|
||||
$stmt->execute([
|
||||
$username,
|
||||
getClientIp(),
|
||||
self::ACTION_LOGIN_FAILED,
|
||||
'user',
|
||||
"Neúspěšné přihlášení '$username': $reason",
|
||||
substr($_SERVER['HTTP_USER_AGENT'] ?? 'unknown', 0, 500),
|
||||
session_id() ?: null,
|
||||
]);
|
||||
} catch (PDOException $e) {
|
||||
error_log('AuditLog login failed error: ' . $e->getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Log logout
|
||||
*
|
||||
* @param int|null $userId User ID (optional, for JWT-based auth)
|
||||
* @param string|null $username Username (optional, for JWT-based auth)
|
||||
*/
|
||||
public static function logLogout(?int $userId = null, ?string $username = null): void
|
||||
{
|
||||
if ($userId === null) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
$pdo = db();
|
||||
|
||||
$stmt = $pdo->prepare('
|
||||
INSERT INTO audit_logs (
|
||||
user_id,
|
||||
username,
|
||||
user_ip,
|
||||
action,
|
||||
entity_type,
|
||||
entity_id,
|
||||
description,
|
||||
user_agent
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?)
|
||||
');
|
||||
|
||||
$stmt->execute([
|
||||
$userId,
|
||||
$username,
|
||||
getClientIp(),
|
||||
self::ACTION_LOGOUT,
|
||||
'user',
|
||||
$userId,
|
||||
"Odhlášení uživatele '{$username}'",
|
||||
substr($_SERVER['HTTP_USER_AGENT'] ?? 'unknown', 0, 500),
|
||||
]);
|
||||
} catch (PDOException $e) {
|
||||
error_log('AuditLog logout error: ' . $e->getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Log entity creation
|
||||
*
|
||||
* @param string $entityType Entity type
|
||||
* @param int $entityId Entity ID
|
||||
* @param array<string, mixed> $data Created data
|
||||
* @param string|null $description Optional description
|
||||
*/
|
||||
public static function logCreate(
|
||||
string $entityType,
|
||||
int $entityId,
|
||||
array $data,
|
||||
?string $description = null
|
||||
): void {
|
||||
// Remove sensitive fields from logged data
|
||||
$safeData = self::sanitizeData($data);
|
||||
|
||||
self::log(
|
||||
self::ACTION_CREATE,
|
||||
$entityType,
|
||||
$entityId,
|
||||
$description ?? "Vytvořen $entityType #$entityId",
|
||||
null,
|
||||
$safeData
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Log entity update
|
||||
*
|
||||
* @param string $entityType Entity type
|
||||
* @param int $entityId Entity ID
|
||||
* @param array<string, mixed> $oldData Old values
|
||||
* @param array<string, mixed> $newData New values
|
||||
* @param string|null $description Optional description
|
||||
*/
|
||||
public static function logUpdate(
|
||||
string $entityType,
|
||||
int $entityId,
|
||||
array $oldData,
|
||||
array $newData,
|
||||
?string $description = null
|
||||
): void {
|
||||
// Only log changed fields
|
||||
$changes = self::getChanges($oldData, $newData);
|
||||
|
||||
if (empty($changes['old']) && empty($changes['new'])) {
|
||||
return; // No actual changes
|
||||
}
|
||||
|
||||
self::log(
|
||||
self::ACTION_UPDATE,
|
||||
$entityType,
|
||||
$entityId,
|
||||
$description ?? "Upraven $entityType #$entityId",
|
||||
$changes['old'],
|
||||
$changes['new']
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Log entity deletion
|
||||
*
|
||||
* @param string $entityType Entity type
|
||||
* @param int $entityId Entity ID
|
||||
* @param array<string, mixed>|null $data Deleted entity data
|
||||
* @param string|null $description Optional description
|
||||
*/
|
||||
public static function logDelete(
|
||||
string $entityType,
|
||||
int $entityId,
|
||||
?array $data = null,
|
||||
?string $description = null
|
||||
): void {
|
||||
$safeData = $data ? self::sanitizeData($data) : null;
|
||||
|
||||
self::log(
|
||||
self::ACTION_DELETE,
|
||||
$entityType,
|
||||
$entityId,
|
||||
$description ?? "Smazán $entityType #$entityId",
|
||||
$safeData,
|
||||
null
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Log access denied
|
||||
*
|
||||
* @param string $resource Resource that was denied
|
||||
* @param string|null $permission Required permission
|
||||
*/
|
||||
public static function logAccessDenied(string $resource, ?string $permission = null): void
|
||||
{
|
||||
$description = "Přístup odepřen k '$resource'";
|
||||
if ($permission) {
|
||||
$description .= " (vyžaduje: $permission)";
|
||||
}
|
||||
|
||||
self::log(
|
||||
self::ACTION_ACCESS_DENIED,
|
||||
null,
|
||||
null,
|
||||
$description
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get changes between old and new data
|
||||
*
|
||||
* @param array<string, mixed> $oldData Old values
|
||||
* @param array<string, mixed> $newData New values
|
||||
* @return array{old: array<string, mixed>, new: array<string, mixed>}
|
||||
*/
|
||||
private static function getChanges(array $oldData, array $newData): array
|
||||
{
|
||||
$oldData = self::sanitizeData($oldData);
|
||||
$newData = self::sanitizeData($newData);
|
||||
|
||||
$changedOld = [];
|
||||
$changedNew = [];
|
||||
|
||||
// Find changed fields
|
||||
foreach ($newData as $key => $newValue) {
|
||||
$oldValue = $oldData[$key] ?? null;
|
||||
|
||||
if ($oldValue !== $newValue) {
|
||||
$changedOld[$key] = $oldValue;
|
||||
$changedNew[$key] = $newValue;
|
||||
}
|
||||
}
|
||||
|
||||
// Find removed fields
|
||||
foreach ($oldData as $key => $oldValue) {
|
||||
if (!array_key_exists($key, $newData)) {
|
||||
$changedOld[$key] = $oldValue;
|
||||
$changedNew[$key] = null;
|
||||
}
|
||||
}
|
||||
|
||||
return ['old' => $changedOld, 'new' => $changedNew];
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove sensitive fields from data before logging
|
||||
*
|
||||
* @param array<string, mixed> $data Data to sanitize
|
||||
* @return array<string, mixed> Sanitized data
|
||||
*/
|
||||
private static function sanitizeData(array $data): array
|
||||
{
|
||||
$sensitiveFields = [
|
||||
'password',
|
||||
'password_hash',
|
||||
'token',
|
||||
'token_hash',
|
||||
'secret',
|
||||
'api_key',
|
||||
'private_key',
|
||||
'csrf_token',
|
||||
];
|
||||
|
||||
foreach ($sensitiveFields as $field) {
|
||||
if (isset($data[$field])) {
|
||||
$data[$field] = '[REDACTED]';
|
||||
}
|
||||
}
|
||||
|
||||
return $data;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get audit logs with filtering and pagination
|
||||
*
|
||||
* @param array<string, mixed> $filters Filter options
|
||||
* @param int $page Page number (1-based)
|
||||
* @param int $perPage Items per page
|
||||
* @return array{logs: list<array<string, mixed>>, total: int, pages: int, page: int, per_page: int}
|
||||
*/
|
||||
public static function getLogs(array $filters = [], int $page = 1, int $perPage = 50): array
|
||||
{
|
||||
try {
|
||||
$pdo = db();
|
||||
|
||||
$where = [];
|
||||
$params = [];
|
||||
|
||||
// Apply filters
|
||||
if (!empty($filters['user_id'])) {
|
||||
$where[] = 'user_id = ?';
|
||||
$params[] = $filters['user_id'];
|
||||
}
|
||||
|
||||
if (!empty($filters['username'])) {
|
||||
$where[] = 'username LIKE ?';
|
||||
$params[] = '%' . $filters['username'] . '%';
|
||||
}
|
||||
|
||||
if (!empty($filters['action'])) {
|
||||
$where[] = 'action = ?';
|
||||
$params[] = $filters['action'];
|
||||
}
|
||||
|
||||
if (!empty($filters['entity_type'])) {
|
||||
$where[] = 'entity_type = ?';
|
||||
$params[] = $filters['entity_type'];
|
||||
}
|
||||
|
||||
if (!empty($filters['ip'])) {
|
||||
$where[] = 'user_ip LIKE ?';
|
||||
$params[] = '%' . $filters['ip'] . '%';
|
||||
}
|
||||
|
||||
if (!empty($filters['date_from'])) {
|
||||
$where[] = 'created_at >= ?';
|
||||
$params[] = $filters['date_from'] . ' 00:00:00';
|
||||
}
|
||||
|
||||
if (!empty($filters['date_to'])) {
|
||||
$where[] = 'created_at <= ?';
|
||||
$params[] = $filters['date_to'] . ' 23:59:59';
|
||||
}
|
||||
|
||||
if (!empty($filters['search'])) {
|
||||
$where[] = '(description LIKE ? OR username LIKE ?)';
|
||||
$searchTerm = '%' . $filters['search'] . '%';
|
||||
$params[] = $searchTerm;
|
||||
$params[] = $searchTerm;
|
||||
}
|
||||
|
||||
$whereClause = !empty($where) ? 'WHERE ' . implode(' AND ', $where) : '';
|
||||
|
||||
// Count total
|
||||
$countSql = "SELECT COUNT(*) FROM audit_logs $whereClause";
|
||||
$stmt = $pdo->prepare($countSql);
|
||||
$stmt->execute($params);
|
||||
$total = (int) $stmt->fetchColumn();
|
||||
|
||||
// Calculate pagination
|
||||
$pages = max(1, ceil($total / $perPage));
|
||||
$page = max(1, min($page, $pages));
|
||||
$offset = ($page - 1) * $perPage;
|
||||
|
||||
// Get logs
|
||||
$sql = "
|
||||
SELECT *
|
||||
FROM audit_logs
|
||||
$whereClause
|
||||
ORDER BY created_at DESC
|
||||
LIMIT $perPage OFFSET $offset
|
||||
";
|
||||
|
||||
$stmt = $pdo->prepare($sql);
|
||||
$stmt->execute($params);
|
||||
$logs = $stmt->fetchAll();
|
||||
|
||||
// Parse JSON fields
|
||||
foreach ($logs as &$log) {
|
||||
$log['old_values'] = $log['old_values'] ? json_decode($log['old_values'], true) : null;
|
||||
$log['new_values'] = $log['new_values'] ? json_decode($log['new_values'], true) : null;
|
||||
}
|
||||
|
||||
return [
|
||||
'logs' => $logs,
|
||||
'total' => $total,
|
||||
'pages' => $pages,
|
||||
'page' => $page,
|
||||
'per_page' => $perPage,
|
||||
];
|
||||
} catch (PDOException $e) {
|
||||
error_log('AuditLog getLogs error: ' . $e->getMessage());
|
||||
return ['logs' => [], 'total' => 0, 'pages' => 0, 'page' => 1, 'per_page' => $perPage];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get recent activity for a user
|
||||
*
|
||||
* @param int $userId User ID
|
||||
* @param int $limit Number of records
|
||||
* @return list<array<string, mixed>> Recent logs
|
||||
*/
|
||||
public static function getUserActivity(int $userId, int $limit = 10): array
|
||||
{
|
||||
try {
|
||||
$pdo = db();
|
||||
|
||||
$stmt = $pdo->prepare('
|
||||
SELECT *
|
||||
FROM audit_logs
|
||||
WHERE user_id = ?
|
||||
ORDER BY created_at DESC
|
||||
LIMIT ?
|
||||
');
|
||||
$stmt->execute([$userId, $limit]);
|
||||
|
||||
return $stmt->fetchAll();
|
||||
} catch (PDOException $e) {
|
||||
error_log('AuditLog getUserActivity error: ' . $e->getMessage());
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get entity history
|
||||
*
|
||||
* @param string $entityType Entity type
|
||||
* @param int $entityId Entity ID
|
||||
* @return list<array<string, mixed>> Audit log history
|
||||
*/
|
||||
public static function getEntityHistory(string $entityType, int $entityId): array
|
||||
{
|
||||
try {
|
||||
$pdo = db();
|
||||
|
||||
$stmt = $pdo->prepare('
|
||||
SELECT *
|
||||
FROM audit_logs
|
||||
WHERE entity_type = ? AND entity_id = ?
|
||||
ORDER BY created_at DESC
|
||||
');
|
||||
$stmt->execute([$entityType, $entityId]);
|
||||
|
||||
$logs = $stmt->fetchAll();
|
||||
|
||||
foreach ($logs as &$log) {
|
||||
$log['old_values'] = $log['old_values'] ? json_decode($log['old_values'], true) : null;
|
||||
$log['new_values'] = $log['new_values'] ? json_decode($log['new_values'], true) : null;
|
||||
}
|
||||
|
||||
return $logs;
|
||||
} catch (PDOException $e) {
|
||||
error_log('AuditLog getEntityHistory error: ' . $e->getMessage());
|
||||
return [];
|
||||
}
|
||||
}
|
||||
}
|
||||
205
api/includes/CnbRates.php
Normal file
205
api/includes/CnbRates.php
Normal file
@@ -0,0 +1,205 @@
|
||||
<?php
|
||||
|
||||
/**
|
||||
* Devizove kurzy CNB s file cache per datum.
|
||||
*
|
||||
* Pouziti:
|
||||
* $cnb = CnbRates::getInstance();
|
||||
* $czk = $cnb->toCzk(1000.0, 'EUR', '2026-03-01');
|
||||
*/
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
class CnbRates
|
||||
{
|
||||
private const API_URL = 'https://api.cnb.cz/cnbapi/exrates/daily';
|
||||
private const CACHE_FILE = 'cnb_rates_cache.json';
|
||||
|
||||
// Kurzy starsi nez dnesek se nemeni, cachujem navzdy.
|
||||
// Dnesni kurz cachujeme na 6 hodin (muze se behem dne aktualizovat).
|
||||
private const TODAY_CACHE_TTL = 21600;
|
||||
|
||||
/**
|
||||
* In-memory cache: date => currency => {rate, amount}
|
||||
* @var array<string, array<string, array{rate: float, amount: int}>>
|
||||
*/
|
||||
private array $ratesByDate = [];
|
||||
|
||||
private static ?CnbRates $instance = null;
|
||||
|
||||
public static function getInstance(): self
|
||||
{
|
||||
if (self::$instance === null) {
|
||||
self::$instance = new self();
|
||||
}
|
||||
return self::$instance;
|
||||
}
|
||||
|
||||
private function __construct()
|
||||
{
|
||||
$this->loadCache();
|
||||
}
|
||||
|
||||
/**
|
||||
* Prevede castku na CZK dle kurzu platneho k danemu datu.
|
||||
*/
|
||||
public function toCzk(float $amount, string $currency, string $date = ''): float
|
||||
{
|
||||
if ($currency === 'CZK') {
|
||||
return $amount;
|
||||
}
|
||||
|
||||
$rates = $this->getRatesForDate($date ?: date('Y-m-d'));
|
||||
if (!isset($rates[$currency])) {
|
||||
return $amount;
|
||||
}
|
||||
|
||||
$info = $rates[$currency];
|
||||
return $amount * ($info['rate'] / $info['amount']);
|
||||
}
|
||||
|
||||
/**
|
||||
* Secte pole [{amount, currency, date?}] do jedne CZK castky.
|
||||
* Kazda polozka muze mit vlastni datum pro kurz.
|
||||
*
|
||||
* @param array<int, array{amount: float, currency: string, date?: string}> $items
|
||||
*/
|
||||
public function sumToCzk(array $items): float
|
||||
{
|
||||
$total = 0.0;
|
||||
foreach ($items as $item) {
|
||||
$total += $this->toCzk(
|
||||
(float) $item['amount'],
|
||||
(string) $item['currency'],
|
||||
(string) ($item['date'] ?? '')
|
||||
);
|
||||
}
|
||||
return round($total, 2);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, array{rate: float, amount: int}>
|
||||
*/
|
||||
private function getRatesForDate(string $date): array
|
||||
{
|
||||
if (isset($this->ratesByDate[$date])) {
|
||||
return $this->ratesByDate[$date];
|
||||
}
|
||||
|
||||
$rates = $this->fetchFromApi($date);
|
||||
if ($rates !== []) {
|
||||
$this->ratesByDate[$date] = $rates;
|
||||
$this->saveCache();
|
||||
}
|
||||
|
||||
return $rates;
|
||||
}
|
||||
|
||||
// --- Cache ---
|
||||
|
||||
private function getCachePath(): string
|
||||
{
|
||||
return sys_get_temp_dir() . DIRECTORY_SEPARATOR . self::CACHE_FILE;
|
||||
}
|
||||
|
||||
private function loadCache(): void
|
||||
{
|
||||
$path = $this->getCachePath();
|
||||
if (!file_exists($path)) {
|
||||
return;
|
||||
}
|
||||
|
||||
$content = file_get_contents($path);
|
||||
if ($content === false) {
|
||||
return;
|
||||
}
|
||||
|
||||
$data = json_decode($content, true);
|
||||
if (!is_array($data)) {
|
||||
return;
|
||||
}
|
||||
|
||||
$today = date('Y-m-d');
|
||||
foreach ($data as $date => $entry) {
|
||||
if (!is_array($entry) || !isset($entry['rates'], $entry['fetched_at'])) {
|
||||
continue;
|
||||
}
|
||||
// Dnesni kurz expiruje po TTL, starsi zustavaji navzdy
|
||||
if ($date === $today) {
|
||||
$age = time() - (int) $entry['fetched_at'];
|
||||
if ($age > self::TODAY_CACHE_TTL) {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
$this->ratesByDate[$date] = $entry['rates'];
|
||||
}
|
||||
}
|
||||
|
||||
private function saveCache(): void
|
||||
{
|
||||
$path = $this->getCachePath();
|
||||
|
||||
// Nacist existujici cache a mergovat
|
||||
$existing = [];
|
||||
if (file_exists($path)) {
|
||||
$content = file_get_contents($path);
|
||||
if ($content !== false) {
|
||||
$decoded = json_decode($content, true);
|
||||
if (is_array($decoded)) {
|
||||
$existing = $decoded;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
$now = time();
|
||||
foreach ($this->ratesByDate as $date => $rates) {
|
||||
// Neprepisuj existujici pokud uz tam je (zachovej fetched_at)
|
||||
if (!isset($existing[$date])) {
|
||||
$existing[$date] = [
|
||||
'rates' => $rates,
|
||||
'fetched_at' => $now,
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
$json = json_encode($existing, JSON_THROW_ON_ERROR);
|
||||
file_put_contents($path, $json, LOCK_EX);
|
||||
}
|
||||
|
||||
// --- API ---
|
||||
|
||||
/**
|
||||
* @return array<string, array{rate: float, amount: int}>
|
||||
*/
|
||||
private function fetchFromApi(string $date): array
|
||||
{
|
||||
$url = self::API_URL . '?lang=EN&date=' . urlencode($date);
|
||||
|
||||
$context = stream_context_create([
|
||||
'http' => ['timeout' => 5],
|
||||
]);
|
||||
|
||||
$response = @file_get_contents($url, false, $context);
|
||||
if ($response === false) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$data = json_decode($response, true);
|
||||
if (!is_array($data) || !isset($data['rates'])) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$rates = [];
|
||||
foreach ($data['rates'] as $entry) {
|
||||
if (!isset($entry['currencyCode'], $entry['rate'], $entry['amount'])) {
|
||||
continue;
|
||||
}
|
||||
$rates[$entry['currencyCode']] = [
|
||||
'rate' => (float) $entry['rate'],
|
||||
'amount' => (int) $entry['amount'],
|
||||
];
|
||||
}
|
||||
|
||||
return $rates;
|
||||
}
|
||||
}
|
||||
117
api/includes/CzechHolidays.php
Normal file
117
api/includes/CzechHolidays.php
Normal file
@@ -0,0 +1,117 @@
|
||||
<?php
|
||||
|
||||
/**
|
||||
* Czech Holidays & Work Fund Calculator
|
||||
*
|
||||
* Provides Czech public holidays (including movable Easter dates)
|
||||
* and monthly work fund calculations.
|
||||
*/
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
class CzechHolidays
|
||||
{
|
||||
/** @var array<int, list<string>> Static cache for holidays by year */
|
||||
private static array $holidayCache = [];
|
||||
|
||||
/**
|
||||
* Get all Czech public holidays for a given year.
|
||||
* Returns array of 'Y-m-d' strings (11 fixed + 2 Easter-based).
|
||||
* Results are cached per-request to avoid recalculation.
|
||||
*
|
||||
* @return list<string>
|
||||
*/
|
||||
public static function getHolidays(int $year): array
|
||||
{
|
||||
if (isset(self::$holidayCache[$year])) {
|
||||
return self::$holidayCache[$year];
|
||||
}
|
||||
// Fixed holidays
|
||||
$holidays = [
|
||||
sprintf('%04d-01-01', $year), // Den obnovy samostatného českého státu
|
||||
sprintf('%04d-05-01', $year), // Svátek práce
|
||||
sprintf('%04d-05-08', $year), // Den vítězství
|
||||
sprintf('%04d-07-05', $year), // Den slovanských věrozvěstů Cyrila a Metoděje
|
||||
sprintf('%04d-07-06', $year), // Den upálení mistra Jana Husa
|
||||
sprintf('%04d-09-28', $year), // Den české státnosti
|
||||
sprintf('%04d-10-28', $year), // Den vzniku samostatného československého státu
|
||||
sprintf('%04d-11-17', $year), // Den boje za svobodu a demokracii
|
||||
sprintf('%04d-12-24', $year), // Štědrý den
|
||||
sprintf('%04d-12-25', $year), // 1. svátek vánoční
|
||||
sprintf('%04d-12-26', $year), // 2. svátek vánoční
|
||||
];
|
||||
|
||||
// Easter-based holidays (Anonymous Gregorian algorithm)
|
||||
$easterSunday = self::getEasterSunday($year);
|
||||
$goodFriday = date('Y-m-d', strtotime($easterSunday . ' -2 days'));
|
||||
$easterMonday = date('Y-m-d', strtotime($easterSunday . ' +1 day'));
|
||||
$holidays[] = $goodFriday; // Velký pátek
|
||||
$holidays[] = $easterMonday; // Velikonoční pondělí
|
||||
|
||||
sort($holidays);
|
||||
self::$holidayCache[$year] = $holidays;
|
||||
return $holidays;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a date is a Czech public holiday.
|
||||
*/
|
||||
public static function isHoliday(string $date): bool
|
||||
{
|
||||
$year = (int)date('Y', strtotime($date));
|
||||
$formatted = date('Y-m-d', strtotime($date));
|
||||
return in_array($formatted, self::getHolidays($year), true);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get number of business days (Mon-Fri, excluding holidays) in a month.
|
||||
*/
|
||||
public static function getBusinessDaysInMonth(int $year, int $month): int
|
||||
{
|
||||
$holidays = self::getHolidays($year);
|
||||
$daysInMonth = cal_days_in_month(CAL_GREGORIAN, $month, $year);
|
||||
$businessDays = 0;
|
||||
|
||||
for ($day = 1; $day <= $daysInMonth; $day++) {
|
||||
$date = sprintf('%04d-%02d-%02d', $year, $month, $day);
|
||||
$dayOfWeek = (int)date('N', strtotime($date)); // 1=Mon, 7=Sun
|
||||
if ($dayOfWeek <= 5 && !in_array($date, $holidays, true)) {
|
||||
$businessDays++;
|
||||
}
|
||||
}
|
||||
|
||||
return $businessDays;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get monthly work fund in hours (business days × 8).
|
||||
*/
|
||||
public static function getMonthlyWorkFund(int $year, int $month): float
|
||||
{
|
||||
return self::getBusinessDaysInMonth($year, $month) * 8.0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate Easter Sunday date using the Anonymous Gregorian algorithm.
|
||||
* Returns 'Y-m-d' string.
|
||||
*/
|
||||
private static function getEasterSunday(int $year): string
|
||||
{
|
||||
$a = $year % 19;
|
||||
$b = intdiv($year, 100);
|
||||
$c = $year % 100;
|
||||
$d = intdiv($b, 4);
|
||||
$e = $b % 4;
|
||||
$f = intdiv($b + 8, 25);
|
||||
$g = intdiv($b - $f + 1, 3);
|
||||
$h = (19 * $a + $b - $d - $g + 15) % 30;
|
||||
$i = intdiv($c, 4);
|
||||
$k = $c % 4;
|
||||
$l = (32 + 2 * $e + 2 * $i - $h - $k) % 7;
|
||||
$m = intdiv($a + 11 * $h + 22 * $l, 451);
|
||||
$month = intdiv($h + $l - 7 * $m + 114, 31);
|
||||
$day = (($h + $l - 7 * $m + 114) % 31) + 1;
|
||||
|
||||
return sprintf('%04d-%02d-%02d', $year, $month, $day);
|
||||
}
|
||||
}
|
||||
98
api/includes/Encryption.php
Normal file
98
api/includes/Encryption.php
Normal file
@@ -0,0 +1,98 @@
|
||||
<?php
|
||||
|
||||
/**
|
||||
* AES-256-GCM encryption helper for sensitive data at rest (e.g., TOTP secrets).
|
||||
*
|
||||
* Requires TOTP_ENCRYPTION_KEY in .env (64 hex chars = 32 bytes).
|
||||
* Format: base64(nonce + ciphertext + tag)
|
||||
*/
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
class Encryption
|
||||
{
|
||||
private const CIPHER = 'aes-256-gcm';
|
||||
private const NONCE_LENGTH = 12;
|
||||
private const TAG_LENGTH = 16;
|
||||
|
||||
private static ?string $key = null;
|
||||
|
||||
private static function getKey(): string
|
||||
{
|
||||
if (self::$key === null) {
|
||||
$hex = env('TOTP_ENCRYPTION_KEY', '');
|
||||
if (strlen($hex) !== 64 || !ctype_xdigit($hex)) {
|
||||
throw new RuntimeException('TOTP_ENCRYPTION_KEY must be 64 hex chars (32 bytes)');
|
||||
}
|
||||
self::$key = hex2bin($hex);
|
||||
}
|
||||
return self::$key;
|
||||
}
|
||||
|
||||
public static function encrypt(string $plaintext): string
|
||||
{
|
||||
$key = self::getKey();
|
||||
$nonce = random_bytes(self::NONCE_LENGTH);
|
||||
$tag = '';
|
||||
|
||||
$ciphertext = openssl_encrypt(
|
||||
$plaintext,
|
||||
self::CIPHER,
|
||||
$key,
|
||||
OPENSSL_RAW_DATA,
|
||||
$nonce,
|
||||
$tag,
|
||||
'',
|
||||
self::TAG_LENGTH
|
||||
);
|
||||
|
||||
if ($ciphertext === false) {
|
||||
throw new RuntimeException('Encryption failed');
|
||||
}
|
||||
|
||||
return base64_encode($nonce . $ciphertext . $tag);
|
||||
}
|
||||
|
||||
public static function decrypt(string $encoded): string
|
||||
{
|
||||
$key = self::getKey();
|
||||
$raw = base64_decode($encoded, true);
|
||||
|
||||
if ($raw === false || strlen($raw) < self::NONCE_LENGTH + self::TAG_LENGTH + 1) {
|
||||
throw new RuntimeException('Invalid encrypted data');
|
||||
}
|
||||
|
||||
$nonce = substr($raw, 0, self::NONCE_LENGTH);
|
||||
$tag = substr($raw, -self::TAG_LENGTH);
|
||||
$ciphertext = substr($raw, self::NONCE_LENGTH, -self::TAG_LENGTH);
|
||||
|
||||
$plaintext = openssl_decrypt(
|
||||
$ciphertext,
|
||||
self::CIPHER,
|
||||
$key,
|
||||
OPENSSL_RAW_DATA,
|
||||
$nonce,
|
||||
$tag
|
||||
);
|
||||
|
||||
if ($plaintext === false) {
|
||||
throw new RuntimeException('Decryption failed');
|
||||
}
|
||||
|
||||
return $plaintext;
|
||||
}
|
||||
|
||||
/**
|
||||
* Zjisti, zda je hodnota sifrovana (base64 s ocekavanou delkou).
|
||||
* TOTP secret je vzdy 16-32 ASCII znaku, sifrovany je base64 s nonce+tag.
|
||||
*/
|
||||
public static function isEncrypted(string $value): bool
|
||||
{
|
||||
if (strlen($value) < 40) {
|
||||
return false;
|
||||
}
|
||||
$decoded = base64_decode($value, true);
|
||||
return $decoded !== false
|
||||
&& strlen($decoded) > self::NONCE_LENGTH + self::TAG_LENGTH;
|
||||
}
|
||||
}
|
||||
663
api/includes/JWTAuth.php
Normal file
663
api/includes/JWTAuth.php
Normal file
@@ -0,0 +1,663 @@
|
||||
<?php
|
||||
|
||||
/**
|
||||
* BOHA Automation - JWT Authentication Handler
|
||||
*
|
||||
* Handles JWT access tokens and refresh tokens for stateless authentication.
|
||||
* Access tokens: Short-lived (configurable, default 15 min), stored in memory on client
|
||||
* Refresh tokens: Long-lived, stored in httpOnly cookie
|
||||
*
|
||||
* Without "remember me": Session cookie + 1 hour DB expiry (sliding window on activity)
|
||||
* With "remember me": Persistent cookie + 30 day expiry
|
||||
*/
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
require_once dirname(__DIR__, 2) . '/vendor/autoload.php';
|
||||
require_once dirname(__DIR__) . '/config.php';
|
||||
|
||||
use Firebase\JWT\JWT;
|
||||
use Firebase\JWT\Key;
|
||||
use Firebase\JWT\ExpiredException;
|
||||
|
||||
class JWTAuth
|
||||
{
|
||||
private const ALGORITHM = 'HS256';
|
||||
|
||||
// Cache for config values
|
||||
private static ?int $accessTokenExpiry = null;
|
||||
private static ?int $refreshTokenExpirySession = null;
|
||||
private static ?int $refreshTokenExpiryDays = null;
|
||||
|
||||
private static ?string $secretKey = null;
|
||||
|
||||
/**
|
||||
* Get the secret key from environment
|
||||
*/
|
||||
private static function getSecretKey(): string
|
||||
{
|
||||
if (self::$secretKey === null) {
|
||||
self::$secretKey = env('JWT_SECRET');
|
||||
if (empty(self::$secretKey)) {
|
||||
throw new Exception('JWT_SECRET not configured in environment');
|
||||
}
|
||||
if (strlen(self::$secretKey) < 32) {
|
||||
throw new Exception('JWT_SECRET must be at least 32 characters');
|
||||
}
|
||||
}
|
||||
return self::$secretKey;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get access token expiry in seconds (from env or default 900 = 15 min)
|
||||
*/
|
||||
public static function getAccessTokenExpiry(): int
|
||||
{
|
||||
if (self::$accessTokenExpiry === null) {
|
||||
self::$accessTokenExpiry = (int) env('JWT_ACCESS_TOKEN_EXPIRY', 900);
|
||||
}
|
||||
return self::$accessTokenExpiry;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get refresh token session expiry in seconds (from env or default 3600 = 1 hour)
|
||||
* Used when "remember me" is NOT checked
|
||||
*/
|
||||
private static function getRefreshTokenExpirySession(): int
|
||||
{
|
||||
if (self::$refreshTokenExpirySession === null) {
|
||||
self::$refreshTokenExpirySession = (int) env('JWT_REFRESH_TOKEN_EXPIRY_SESSION', 3600);
|
||||
}
|
||||
return self::$refreshTokenExpirySession;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get refresh token expiry in days (from env or default 30)
|
||||
* Used when "remember me" IS checked
|
||||
*/
|
||||
private static function getRefreshTokenExpiryDays(): int
|
||||
{
|
||||
if (self::$refreshTokenExpiryDays === null) {
|
||||
self::$refreshTokenExpiryDays = (int) env('JWT_REFRESH_TOKEN_EXPIRY_DAYS', 30);
|
||||
}
|
||||
return self::$refreshTokenExpiryDays;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate an access token (short-lived, for API requests)
|
||||
*
|
||||
* @param array<string, mixed> $userData
|
||||
*/
|
||||
public static function generateAccessToken(array $userData): string
|
||||
{
|
||||
$issuedAt = time();
|
||||
$expiry = $issuedAt + self::getAccessTokenExpiry();
|
||||
|
||||
$payload = [
|
||||
'iss' => 'boha-automation', // Issuer
|
||||
'iat' => $issuedAt, // Issued at
|
||||
'exp' => $expiry, // Expiry
|
||||
'type' => 'access', // Token type
|
||||
'sub' => $userData['id'], // Subject (user ID)
|
||||
'user' => [
|
||||
'id' => $userData['id'],
|
||||
'username' => $userData['username'],
|
||||
'email' => $userData['email'],
|
||||
'full_name' => trim(($userData['first_name'] ?? '') . ' ' . ($userData['last_name'] ?? '')),
|
||||
'role' => $userData['role'] ?? null,
|
||||
'role_display' => $userData['role_display'] ?? $userData['role'] ?? null,
|
||||
'is_admin' => $userData['is_admin'] ?? ($userData['role'] === 'admin'),
|
||||
],
|
||||
];
|
||||
|
||||
return JWT::encode($payload, self::getSecretKey(), self::ALGORITHM);
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate a refresh token (stored in httpOnly cookie)
|
||||
*
|
||||
* @param int $userId User ID
|
||||
* @param bool $remember If true: 30 day persistent cookie. If false: session cookie (1 hour DB expiry)
|
||||
*/
|
||||
public static function generateRefreshToken(int $userId, bool $remember = false): string
|
||||
{
|
||||
$token = bin2hex(random_bytes(32)); // 64 character random string
|
||||
$hashedToken = hash('sha256', $token);
|
||||
|
||||
// Calculate expiry based on remember me
|
||||
if ($remember) {
|
||||
$dbExpiry = time() + (self::getRefreshTokenExpiryDays() * 86400); // 30 days default
|
||||
$cookieExpiry = $dbExpiry; // Persistent cookie
|
||||
} else {
|
||||
$dbExpiry = time() + self::getRefreshTokenExpirySession(); // 1 hour default
|
||||
$cookieExpiry = 0; // Session cookie (deleted on browser close)
|
||||
}
|
||||
|
||||
$expiresAt = date('Y-m-d H:i:s', $dbExpiry);
|
||||
|
||||
try {
|
||||
$pdo = db();
|
||||
|
||||
// Pročistit replaced tokeny (po grace period uz nepotřebné)
|
||||
$stmt = $pdo->prepare(
|
||||
'DELETE FROM refresh_tokens WHERE user_id = ? AND replaced_at IS NOT NULL'
|
||||
. ' AND replaced_at < DATE_SUB(NOW(), INTERVAL ' . self::ROTATION_GRACE_PERIOD . ' SECOND)'
|
||||
);
|
||||
$stmt->execute([$userId]);
|
||||
|
||||
// Limit aktivních sessions per user (max 5 devices)
|
||||
$stmt = $pdo->prepare(
|
||||
'SELECT COUNT(*) FROM refresh_tokens WHERE user_id = ? AND replaced_at IS NULL'
|
||||
);
|
||||
$stmt->execute([$userId]);
|
||||
$count = $stmt->fetchColumn();
|
||||
|
||||
if ($count >= 5) {
|
||||
$stmt = $pdo->prepare('
|
||||
DELETE FROM refresh_tokens
|
||||
WHERE user_id = ? AND replaced_at IS NULL
|
||||
ORDER BY created_at ASC
|
||||
LIMIT 1
|
||||
');
|
||||
$stmt->execute([$userId]);
|
||||
}
|
||||
|
||||
// Store new refresh token
|
||||
$stmt = $pdo->prepare('
|
||||
INSERT INTO refresh_tokens (user_id, token_hash, expires_at, ip_address, user_agent, remember_me)
|
||||
VALUES (?, ?, ?, ?, ?, ?)
|
||||
');
|
||||
$stmt->execute([
|
||||
$userId,
|
||||
$hashedToken,
|
||||
$expiresAt,
|
||||
getClientIp(),
|
||||
substr($_SERVER['HTTP_USER_AGENT'] ?? '', 0, 255),
|
||||
$remember ? 1 : 0,
|
||||
]);
|
||||
|
||||
// Set httpOnly cookie
|
||||
$secure = !DEBUG_MODE || (isset($_SERVER['HTTPS']) && $_SERVER['HTTPS'] === 'on');
|
||||
|
||||
setcookie('refresh_token', $token, [
|
||||
'expires' => $cookieExpiry,
|
||||
'path' => '/api/',
|
||||
'domain' => '',
|
||||
'secure' => $secure,
|
||||
'httponly' => true,
|
||||
'samesite' => 'Strict',
|
||||
]);
|
||||
|
||||
return $token;
|
||||
} catch (PDOException $e) {
|
||||
error_log('JWTAuth refresh token error: ' . $e->getMessage());
|
||||
throw new Exception('Failed to create refresh token');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Verify and decode an access token
|
||||
*
|
||||
* @return array{user_id: mixed, user: array<string, mixed>}|null
|
||||
*/
|
||||
public static function verifyAccessToken(string $token): ?array
|
||||
{
|
||||
try {
|
||||
$decoded = JWT::decode($token, new Key(self::getSecretKey(), self::ALGORITHM));
|
||||
$payload = (array) $decoded;
|
||||
|
||||
// Verify it's an access token
|
||||
if (($payload['type'] ?? '') !== 'access') {
|
||||
return null;
|
||||
}
|
||||
|
||||
return [
|
||||
'user_id' => $payload['sub'],
|
||||
'user' => (array) $payload['user'],
|
||||
];
|
||||
} catch (ExpiredException $e) {
|
||||
// Token expired - client should use refresh token
|
||||
return null;
|
||||
} catch (Exception $e) {
|
||||
error_log('JWT verification error: ' . $e->getMessage());
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Verify refresh token and return user data if valid
|
||||
* Returns array with 'user' data and 'remember_me' flag
|
||||
* Deletes expired tokens from database when found
|
||||
*
|
||||
* @return array{user: array<string, mixed>, remember_me: bool, in_grace_period?: bool}|null
|
||||
*/
|
||||
public static function verifyRefreshToken(?string $token = null): ?array
|
||||
{
|
||||
// Get token from cookie if not provided
|
||||
if ($token === null) {
|
||||
$token = $_COOKIE['refresh_token'] ?? null;
|
||||
}
|
||||
|
||||
if (empty($token)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
$pdo = db();
|
||||
$hashedToken = hash('sha256', $token);
|
||||
|
||||
// First check if token exists (regardless of expiry)
|
||||
$stmt = $pdo->prepare('
|
||||
SELECT rt.*, u.id as user_id, u.username, u.email, u.first_name, u.last_name,
|
||||
u.is_active, r.name as role_name, r.display_name as role_display_name
|
||||
FROM refresh_tokens rt
|
||||
JOIN users u ON rt.user_id = u.id
|
||||
LEFT JOIN roles r ON u.role_id = r.id
|
||||
WHERE rt.token_hash = ?
|
||||
');
|
||||
$stmt->execute([$hashedToken]);
|
||||
$data = $stmt->fetch();
|
||||
|
||||
if (!$data) {
|
||||
self::clearRefreshCookie();
|
||||
return null;
|
||||
}
|
||||
|
||||
// Token byl rotovan - zkontrolovat grace period
|
||||
if ($data['replaced_at'] !== null) {
|
||||
$replacedAt = strtotime($data['replaced_at']);
|
||||
if ((time() - $replacedAt) <= self::ROTATION_GRACE_PERIOD) {
|
||||
// Grace period - token jeste plati (souběžny request)
|
||||
if (!$data['is_active']) {
|
||||
return null;
|
||||
}
|
||||
return [
|
||||
'user' => [
|
||||
'id' => $data['user_id'],
|
||||
'username' => $data['username'],
|
||||
'email' => $data['email'],
|
||||
'first_name' => $data['first_name'],
|
||||
'last_name' => $data['last_name'],
|
||||
'role' => $data['role_name'],
|
||||
'role_display' => $data['role_display_name'] ?? $data['role_name'],
|
||||
'is_admin' => $data['role_name'] === 'admin',
|
||||
'permissions' => self::getUserPermissions($data['user_id']),
|
||||
],
|
||||
'remember_me' => (bool) ($data['remember_me'] ?? false),
|
||||
'in_grace_period' => true,
|
||||
];
|
||||
}
|
||||
|
||||
// Po grace period - stary token uz neni platny, smazat jen tento token
|
||||
$uid = $data['user_id'];
|
||||
error_log("Refresh token reuse after grace period for user {$uid}");
|
||||
$stmt = $pdo->prepare('DELETE FROM refresh_tokens WHERE token_hash = ?');
|
||||
$stmt->execute([$hashedToken]);
|
||||
self::clearRefreshCookie();
|
||||
return null;
|
||||
}
|
||||
|
||||
// Check if token is expired
|
||||
if (strtotime($data['expires_at']) < time()) {
|
||||
$stmt = $pdo->prepare('DELETE FROM refresh_tokens WHERE token_hash = ?');
|
||||
$stmt->execute([$hashedToken]);
|
||||
self::clearRefreshCookie();
|
||||
return null;
|
||||
}
|
||||
|
||||
// Check user is still active
|
||||
if (!$data['is_active']) {
|
||||
self::revokeRefreshToken($token);
|
||||
return null;
|
||||
}
|
||||
|
||||
return [
|
||||
'user' => [
|
||||
'id' => $data['user_id'],
|
||||
'username' => $data['username'],
|
||||
'email' => $data['email'],
|
||||
'first_name' => $data['first_name'],
|
||||
'last_name' => $data['last_name'],
|
||||
'role' => $data['role_name'],
|
||||
'role_display' => $data['role_display_name'] ?? $data['role_name'],
|
||||
'is_admin' => $data['role_name'] === 'admin',
|
||||
'permissions' => self::getUserPermissions($data['user_id']),
|
||||
],
|
||||
'remember_me' => (bool) ($data['remember_me'] ?? false),
|
||||
];
|
||||
} catch (PDOException $e) {
|
||||
error_log('JWTAuth verify refresh error: ' . $e->getMessage());
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/** Grace period pro rotovane tokeny (sekundy) */
|
||||
private const ROTATION_GRACE_PERIOD = 30;
|
||||
|
||||
public static function getGracePeriod(): int
|
||||
{
|
||||
return self::ROTATION_GRACE_PERIOD;
|
||||
}
|
||||
|
||||
/**
|
||||
* Refresh tokens - issue new access token + rotate refresh token
|
||||
* Grace period 30s pro souběžné requesty
|
||||
*
|
||||
* @return array{access_token: string, user: array<string, mixed>, expires_in: int}|null
|
||||
*/
|
||||
public static function refreshTokens(): ?array
|
||||
{
|
||||
$token = $_COOKIE['refresh_token'] ?? null;
|
||||
|
||||
$tokenData = self::verifyRefreshToken($token);
|
||||
if (!$tokenData) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
$userData = $tokenData['user'];
|
||||
$accessToken = self::generateAccessToken($userData);
|
||||
|
||||
// Rotace: pokud token nebyl jiz nahrazen (grace period request), rotovat
|
||||
if (!($tokenData['in_grace_period'] ?? false)) {
|
||||
self::rotateRefreshToken(
|
||||
$token,
|
||||
$userData['id'],
|
||||
(bool) $tokenData['remember_me']
|
||||
);
|
||||
}
|
||||
|
||||
return [
|
||||
'access_token' => $accessToken,
|
||||
'user' => [
|
||||
'id' => $userData['id'],
|
||||
'username' => $userData['username'],
|
||||
'email' => $userData['email'],
|
||||
'full_name' => trim(($userData['first_name'] ?? '') . ' ' . ($userData['last_name'] ?? '')),
|
||||
'role' => $userData['role'],
|
||||
'role_display' => $userData['role_display'],
|
||||
'is_admin' => $userData['is_admin'],
|
||||
'permissions' => $userData['permissions'] ?? self::getUserPermissions($userData['id']),
|
||||
],
|
||||
'expires_in' => self::getAccessTokenExpiry(),
|
||||
];
|
||||
} catch (Exception $e) {
|
||||
error_log('JWTAuth refresh error: ' . $e->getMessage());
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Rotace refresh tokenu - vygeneruje novy, stary oznaci jako replaced
|
||||
*/
|
||||
private static function rotateRefreshToken(string $oldToken, int $userId, bool $remember): void
|
||||
{
|
||||
$pdo = db();
|
||||
$oldHash = hash('sha256', $oldToken);
|
||||
|
||||
$newToken = bin2hex(random_bytes(32));
|
||||
$newHash = hash('sha256', $newToken);
|
||||
|
||||
if ($remember) {
|
||||
$dbExpiry = time() + (self::getRefreshTokenExpiryDays() * 86400);
|
||||
$cookieExpiry = $dbExpiry;
|
||||
} else {
|
||||
$dbExpiry = time() + self::getRefreshTokenExpirySession();
|
||||
$cookieExpiry = 0;
|
||||
}
|
||||
|
||||
$expiresAt = date('Y-m-d H:i:s', $dbExpiry);
|
||||
|
||||
// Oznacit stary token jako replaced (atomicky - race condition ochrana)
|
||||
$stmt = $pdo->prepare('
|
||||
UPDATE refresh_tokens SET replaced_at = NOW(), replaced_by_hash = ?
|
||||
WHERE token_hash = ? AND replaced_at IS NULL
|
||||
');
|
||||
$stmt->execute([$newHash, $oldHash]);
|
||||
|
||||
// Jiny request uz token rotoval - nepokracovat
|
||||
if ($stmt->rowCount() === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Procistit drive replaced tokeny (az po uspesne rotaci, respektovat grace period)
|
||||
$pdo->prepare(
|
||||
'DELETE FROM refresh_tokens WHERE user_id = ? AND replaced_at IS NOT NULL AND token_hash != ?'
|
||||
. ' AND replaced_at < DATE_SUB(NOW(), INTERVAL ' . self::ROTATION_GRACE_PERIOD . ' SECOND)'
|
||||
)->execute([$userId, $oldHash]);
|
||||
|
||||
// Vlozit novy token
|
||||
$stmt = $pdo->prepare('
|
||||
INSERT INTO refresh_tokens (user_id, token_hash, expires_at, ip_address, user_agent, remember_me)
|
||||
VALUES (?, ?, ?, ?, ?, ?)
|
||||
');
|
||||
$stmt->execute([
|
||||
$userId,
|
||||
$newHash,
|
||||
$expiresAt,
|
||||
getClientIp(),
|
||||
substr($_SERVER['HTTP_USER_AGENT'] ?? '', 0, 255),
|
||||
$remember ? 1 : 0,
|
||||
]);
|
||||
|
||||
// Novy cookie
|
||||
$secure = !DEBUG_MODE || (isset($_SERVER['HTTPS']) && $_SERVER['HTTPS'] === 'on');
|
||||
setcookie('refresh_token', $newToken, [
|
||||
'expires' => $cookieExpiry,
|
||||
'path' => '/api/',
|
||||
'domain' => '',
|
||||
'secure' => $secure,
|
||||
'httponly' => true,
|
||||
'samesite' => 'Strict',
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Revoke a specific refresh token
|
||||
*/
|
||||
public static function revokeRefreshToken(string $token): bool
|
||||
{
|
||||
try {
|
||||
$pdo = db();
|
||||
$hashedToken = hash('sha256', $token);
|
||||
|
||||
$stmt = $pdo->prepare('DELETE FROM refresh_tokens WHERE token_hash = ?');
|
||||
$stmt->execute([$hashedToken]);
|
||||
|
||||
self::clearRefreshCookie();
|
||||
|
||||
return true;
|
||||
} catch (PDOException $e) {
|
||||
error_log('JWTAuth revoke error: ' . $e->getMessage());
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Revoke all refresh tokens for a user (logout from all devices)
|
||||
*/
|
||||
public static function revokeAllUserTokens(int $userId): bool
|
||||
{
|
||||
try {
|
||||
$pdo = db();
|
||||
$stmt = $pdo->prepare('DELETE FROM refresh_tokens WHERE user_id = ?');
|
||||
$stmt->execute([$userId]);
|
||||
|
||||
self::clearRefreshCookie();
|
||||
|
||||
return true;
|
||||
} catch (PDOException $e) {
|
||||
error_log('JWTAuth revoke all error: ' . $e->getMessage());
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear the refresh token cookie
|
||||
*/
|
||||
private static function clearRefreshCookie(): void
|
||||
{
|
||||
$secure = !DEBUG_MODE || (isset($_SERVER['HTTPS']) && $_SERVER['HTTPS'] === 'on');
|
||||
setcookie('refresh_token', '', [
|
||||
'expires' => time() - 3600,
|
||||
'path' => '/api/',
|
||||
'domain' => '',
|
||||
'secure' => $secure,
|
||||
'httponly' => true,
|
||||
'samesite' => 'Strict',
|
||||
]);
|
||||
unset($_COOKIE['refresh_token']);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get access token from Authorization header
|
||||
*/
|
||||
public static function getTokenFromHeader(): ?string
|
||||
{
|
||||
$headers = getallheaders();
|
||||
$authHeader = $headers['Authorization'] ?? $headers['authorization'] ?? '';
|
||||
|
||||
if (preg_match('/Bearer\s+(.+)$/i', $authHeader, $matches)) {
|
||||
return $matches[1];
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Middleware: Require valid access token
|
||||
* Also verifies refresh token still exists in database (session not revoked)
|
||||
* Extends session expiry only when less than 50% of time remaining (smart extend)
|
||||
*
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
public static function requireAuth(): array
|
||||
{
|
||||
$token = self::getTokenFromHeader();
|
||||
|
||||
if (!$token) {
|
||||
errorResponse('Access token required', 401);
|
||||
}
|
||||
|
||||
$payload = self::verifyAccessToken($token);
|
||||
|
||||
if (!$payload) {
|
||||
errorResponse('Invalid or expired token', 401);
|
||||
}
|
||||
|
||||
// Verify refresh token exists + smart extend in a single query
|
||||
$refreshToken = $_COOKIE['refresh_token'] ?? null;
|
||||
if ($refreshToken) {
|
||||
$hashedToken = hash('sha256', $refreshToken);
|
||||
try {
|
||||
$pdo = db();
|
||||
// Verify session - tolerovat replaced tokeny v grace period
|
||||
$stmt = $pdo->prepare('
|
||||
SELECT id, remember_me, expires_at, replaced_at
|
||||
FROM refresh_tokens
|
||||
WHERE token_hash = ? AND expires_at > NOW()
|
||||
');
|
||||
$stmt->execute([$hashedToken]);
|
||||
$tokenData = $stmt->fetch();
|
||||
|
||||
if (!$tokenData) {
|
||||
self::clearRefreshCookie();
|
||||
errorResponse('Session revoked', 401);
|
||||
}
|
||||
|
||||
// Replaced token v grace period - jen validovat, neextendovat
|
||||
if ($tokenData['replaced_at'] !== null) {
|
||||
$replacedAt = strtotime($tokenData['replaced_at']);
|
||||
if ((time() - $replacedAt) > self::ROTATION_GRACE_PERIOD) {
|
||||
self::clearRefreshCookie();
|
||||
errorResponse('Session revoked', 401);
|
||||
}
|
||||
// V grace period - skip extend, access token jeste plati
|
||||
return $payload;
|
||||
}
|
||||
|
||||
// Smart extend: only UPDATE when less than 50% of session time remaining
|
||||
$expiresAt = strtotime($tokenData['expires_at']);
|
||||
$now = time();
|
||||
$remaining = $expiresAt - $now;
|
||||
|
||||
if ($tokenData['remember_me']) {
|
||||
$totalWindow = self::getRefreshTokenExpiryDays() * 86400;
|
||||
} else {
|
||||
$totalWindow = self::getRefreshTokenExpirySession();
|
||||
}
|
||||
|
||||
// Only extend if less than 50% remaining
|
||||
if ($remaining < ($totalWindow * 0.5)) {
|
||||
$newExpiry = date('Y-m-d H:i:s', $now + $totalWindow);
|
||||
$stmt = $pdo->prepare('UPDATE refresh_tokens SET expires_at = ? WHERE id = ?');
|
||||
$stmt->execute([$newExpiry, $tokenData['id']]);
|
||||
|
||||
// Refresh cookie expiry for remember-me sessions
|
||||
if ($tokenData['remember_me']) {
|
||||
$secure = !DEBUG_MODE || (isset($_SERVER['HTTPS']) && $_SERVER['HTTPS'] === 'on');
|
||||
setcookie('refresh_token', $refreshToken, [
|
||||
'expires' => $now + $totalWindow,
|
||||
'path' => '/api/',
|
||||
'domain' => '',
|
||||
'secure' => $secure,
|
||||
'httponly' => true,
|
||||
'samesite' => 'Strict',
|
||||
]);
|
||||
}
|
||||
}
|
||||
} catch (PDOException $e) {
|
||||
error_log('JWTAuth session check error: ' . $e->getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
return $payload;
|
||||
}
|
||||
|
||||
/**
|
||||
* Middleware: Optional auth - returns user data if valid token, null otherwise
|
||||
*
|
||||
* @return array<string, mixed>|null
|
||||
*/
|
||||
public static function optionalAuth(): ?array
|
||||
{
|
||||
$token = self::getTokenFromHeader();
|
||||
|
||||
if (!$token) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return self::verifyAccessToken($token);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get permission names for a user
|
||||
* Admin role returns all permissions.
|
||||
*
|
||||
* @return list<string>
|
||||
*/
|
||||
public static function getUserPermissions(int $userId): array
|
||||
{
|
||||
return getUserPermissions($userId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Cleanup expired and replaced refresh tokens
|
||||
*/
|
||||
public static function cleanupExpiredTokens(): int
|
||||
{
|
||||
try {
|
||||
$pdo = db();
|
||||
$stmt = $pdo->prepare(
|
||||
'DELETE FROM refresh_tokens WHERE expires_at < NOW()'
|
||||
. ' OR (replaced_at IS NOT NULL AND replaced_at < DATE_SUB(NOW(), INTERVAL '
|
||||
. self::ROTATION_GRACE_PERIOD . ' SECOND))'
|
||||
);
|
||||
$stmt->execute();
|
||||
return $stmt->rowCount();
|
||||
} catch (PDOException $e) {
|
||||
error_log('JWTAuth cleanup error: ' . $e->getMessage());
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
91
api/includes/LeaveNotification.php
Normal file
91
api/includes/LeaveNotification.php
Normal file
@@ -0,0 +1,91 @@
|
||||
<?php
|
||||
|
||||
/**
|
||||
* BOHA Automation - Leave Request Email Notifications
|
||||
*
|
||||
* Sends email notifications when leave requests are created.
|
||||
*/
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
require_once __DIR__ . '/Mailer.php';
|
||||
|
||||
class LeaveNotification
|
||||
{
|
||||
/** @var array<string, string> */
|
||||
private static array $leaveTypeLabels = [
|
||||
'vacation' => 'Dovolená',
|
||||
'sick' => 'Nemocenská',
|
||||
'unpaid' => 'Neplacené volno',
|
||||
];
|
||||
|
||||
/**
|
||||
* Send notification about a new leave request
|
||||
*
|
||||
* @param array<string, mixed> $request
|
||||
*/
|
||||
public static function notifyNewRequest(array $request, string $employeeName): void
|
||||
{
|
||||
$notifyEmail = env('LEAVE_NOTIFY_EMAIL', '');
|
||||
if (!$notifyEmail) {
|
||||
return;
|
||||
}
|
||||
|
||||
$leaveType = self::$leaveTypeLabels[$request['leave_type']] ?? $request['leave_type'];
|
||||
$dateFrom = date('d.m.Y', strtotime($request['date_from']));
|
||||
$dateTo = date('d.m.Y', strtotime($request['date_to']));
|
||||
$notes = $request['notes'] ?? '';
|
||||
|
||||
$subject = "Nová žádost o nepřítomnost - $employeeName ($leaveType)";
|
||||
|
||||
$html = "
|
||||
<html>
|
||||
<body style='font-family: Arial, sans-serif; line-height: 1.6; color: #333;'>
|
||||
<h2 style='color: #de3a3a;'>Nová žádost o nepřítomnost</h2>
|
||||
<table style='width: 100%; border-collapse: collapse; margin: 20px 0;'>
|
||||
<tr>
|
||||
<td style='padding: 10px; background: #f5f5f5; font-weight: bold; width: 180px;'>
|
||||
Zaměstnanec:</td>
|
||||
<td style='padding: 10px; border-bottom: 1px solid #ddd;'>"
|
||||
. htmlspecialchars($employeeName) . "</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td style='padding: 10px; background: #f5f5f5; font-weight: bold;'>Typ:</td>
|
||||
<td style='padding: 10px; border-bottom: 1px solid #ddd;'>" . htmlspecialchars($leaveType) . "</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td style='padding: 10px; background: #f5f5f5; font-weight: bold;'>Období:</td>
|
||||
<td style='padding: 10px; border-bottom: 1px solid #ddd;'>$dateFrom – $dateTo</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td style='padding: 10px; background: #f5f5f5; font-weight: bold;'>Pracovní dny:</td>
|
||||
<td style='padding: 10px; border-bottom: 1px solid #ddd;'>"
|
||||
. "{$request['total_days']} dní ({$request['total_hours']} hodin)</td>
|
||||
</tr>"
|
||||
. ($notes ? "
|
||||
<tr>
|
||||
<td style='padding: 10px; background: #f5f5f5; font-weight: bold;'>Poznámka:</td>
|
||||
<td style='padding: 10px; border-bottom: 1px solid #ddd;'>" . htmlspecialchars($notes) . '</td>
|
||||
</tr>' : '') . "
|
||||
</table>
|
||||
<p style='margin-top: 20px;'>
|
||||
<a href='https://www.boha-automation.cz/boha/leave-approval'
|
||||
style='background: #de3a3a; color: #fff; padding: 10px 20px;
|
||||
text-decoration: none; border-radius: 5px;'>
|
||||
Přejít ke schvalování
|
||||
</a>
|
||||
</p>
|
||||
<hr style='margin: 30px 0; border: none; border-top: 1px solid #ddd;'>
|
||||
<p style='font-size: 12px; color: #999;'>
|
||||
Tato zpráva byla automaticky vygenerována systémem BOHA Automation.<br>
|
||||
Datum: " . date('d.m.Y H:i:s') . '
|
||||
</p>
|
||||
</body>
|
||||
</html>';
|
||||
|
||||
$sent = Mailer::send($notifyEmail, $subject, $html);
|
||||
if (!$sent) {
|
||||
error_log("LeaveNotification: Failed to send new request notification to $notifyEmail");
|
||||
}
|
||||
}
|
||||
}
|
||||
45
api/includes/Mailer.php
Normal file
45
api/includes/Mailer.php
Normal file
@@ -0,0 +1,45 @@
|
||||
<?php
|
||||
|
||||
/**
|
||||
* BOHA Automation - Email Helper
|
||||
*
|
||||
* Sends emails via PHP mail() function.
|
||||
* Configuration via .env variables.
|
||||
*/
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
class Mailer
|
||||
{
|
||||
/**
|
||||
* Send an email
|
||||
*
|
||||
* @param string $to Recipient email address
|
||||
* @param string $subject Email subject (plain text, will be UTF-8 encoded)
|
||||
* @param string $htmlBody HTML email body
|
||||
* @param string|null $replyTo Optional reply-to address
|
||||
* @return bool True if sent successfully
|
||||
*/
|
||||
public static function send(string $to, string $subject, string $htmlBody, ?string $replyTo = null): bool
|
||||
{
|
||||
$fromEmail = env('SMTP_FROM_EMAIL', env('CONTACT_EMAIL_FROM', 'web@boha-automation.cz'));
|
||||
$fromName = env('SMTP_FROM_NAME', 'BOHA Automation');
|
||||
|
||||
$encodedSubject = '=?UTF-8?B?' . base64_encode($subject) . '?=';
|
||||
|
||||
$headers = "MIME-Version: 1.0\r\n";
|
||||
$headers .= "Content-type: text/html; charset=UTF-8\r\n";
|
||||
$headers .= "From: $fromName <$fromEmail>\r\n";
|
||||
if ($replyTo) {
|
||||
$headers .= "Reply-To: $replyTo\r\n";
|
||||
}
|
||||
|
||||
$sent = mail($to, $encodedSubject, $htmlBody, $headers);
|
||||
|
||||
if (!$sent) {
|
||||
error_log("Mailer error: mail() failed for recipient $to");
|
||||
}
|
||||
|
||||
return $sent;
|
||||
}
|
||||
}
|
||||
220
api/includes/RateLimiter.php
Normal file
220
api/includes/RateLimiter.php
Normal file
@@ -0,0 +1,220 @@
|
||||
<?php
|
||||
|
||||
/**
|
||||
* BOHA Automation - IP-based Rate Limiter
|
||||
*
|
||||
* Implements rate limiting using file-based storage to prevent abuse
|
||||
* and protect API endpoints from excessive requests.
|
||||
*
|
||||
* Features:
|
||||
* - IP-based rate limiting
|
||||
* - Configurable limits per endpoint
|
||||
* - File-based storage (no database dependency)
|
||||
* - Automatic cleanup of expired entries
|
||||
*/
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
class RateLimiter
|
||||
{
|
||||
/** @var string Directory for storing rate limit data */
|
||||
private string $storagePath;
|
||||
|
||||
/** @var int Default requests per minute */
|
||||
private int $defaultLimit = 60;
|
||||
|
||||
/** @var int Time window in seconds (1 minute) */
|
||||
private int $windowSeconds = 60;
|
||||
|
||||
/** @var bool Whether storage directory has been verified */
|
||||
private static bool $dirVerified = false;
|
||||
|
||||
/**
|
||||
* Initialize the rate limiter
|
||||
*
|
||||
* @param string|null $storagePath Path to store rate limit files
|
||||
*/
|
||||
public function __construct(?string $storagePath = null)
|
||||
{
|
||||
$this->storagePath = $storagePath
|
||||
?? (defined('RATE_LIMIT_STORAGE_PATH')
|
||||
? RATE_LIMIT_STORAGE_PATH
|
||||
: __DIR__ . '/../rate_limits');
|
||||
|
||||
// Only check directory once per process (static flag)
|
||||
if (!self::$dirVerified) {
|
||||
if (!is_dir($this->storagePath)) {
|
||||
mkdir($this->storagePath, 0755, true);
|
||||
}
|
||||
self::$dirVerified = true;
|
||||
}
|
||||
|
||||
// Cleanup old files very rarely (0.1% of requests instead of 1%)
|
||||
if (rand(1, 1000) === 1) {
|
||||
$this->cleanup();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if the request should be rate limited
|
||||
*
|
||||
* Uses exclusive file locking for the entire read-check-increment-write cycle
|
||||
* to prevent race conditions under concurrent requests.
|
||||
*
|
||||
* @param string $endpoint Endpoint identifier (e.g., 'login', 'session')
|
||||
* @param int|null $limit Custom limit for this endpoint (requests per minute)
|
||||
* @return bool True if request is allowed, false if rate limited
|
||||
*/
|
||||
/** @var bool Fail-closed: blokuj request pri chybe FS (pro kriticke endpointy) */
|
||||
private bool $failClosed = false;
|
||||
|
||||
public function setFailClosed(bool $failClosed = true): self
|
||||
{
|
||||
$this->failClosed = $failClosed;
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function check(string $endpoint, ?int $limit = null): bool
|
||||
{
|
||||
$limit = $limit ?? $this->defaultLimit;
|
||||
$ip = $this->getClientIp();
|
||||
$key = $this->getKey($ip, $endpoint);
|
||||
$file = $this->storagePath . '/' . $key . '.json';
|
||||
$now = time();
|
||||
|
||||
// Open file with exclusive lock for atomic read-check-increment-write
|
||||
$fp = @fopen($file, 'c+');
|
||||
if (!$fp) {
|
||||
return !$this->failClosed;
|
||||
}
|
||||
|
||||
if (!flock($fp, LOCK_EX)) {
|
||||
fclose($fp);
|
||||
return !$this->failClosed;
|
||||
}
|
||||
|
||||
// Read current data under lock
|
||||
$content = stream_get_contents($fp);
|
||||
$data = $content ? json_decode($content, true) : null;
|
||||
|
||||
if (is_array($data) && $data['window_start'] > ($now - $this->windowSeconds)) {
|
||||
// Same window - check count
|
||||
if ($data['count'] >= $limit) {
|
||||
flock($fp, LOCK_UN);
|
||||
fclose($fp);
|
||||
return false; // Rate limited
|
||||
}
|
||||
$data['count']++;
|
||||
} else {
|
||||
// New window - reset counter
|
||||
$data = ['window_start' => $now, 'count' => 1];
|
||||
}
|
||||
|
||||
// Write updated data
|
||||
ftruncate($fp, 0);
|
||||
rewind($fp);
|
||||
fwrite($fp, json_encode($data));
|
||||
fflush($fp);
|
||||
|
||||
flock($fp, LOCK_UN);
|
||||
fclose($fp);
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Enforce rate limit and return 429 response if exceeded
|
||||
*
|
||||
* @param string $endpoint Endpoint identifier
|
||||
* @param int|null $limit Custom limit for this endpoint
|
||||
*/
|
||||
public function enforce(string $endpoint, ?int $limit = null): void
|
||||
{
|
||||
if (!$this->check($endpoint, $limit)) {
|
||||
$this->sendRateLimitResponse();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Send 429 Too Many Requests response and exit
|
||||
*/
|
||||
private function sendRateLimitResponse(): void
|
||||
{
|
||||
http_response_code(429);
|
||||
header('Content-Type: application/json; charset=utf-8');
|
||||
header('Retry-After: ' . $this->windowSeconds);
|
||||
|
||||
echo json_encode([
|
||||
'success' => false,
|
||||
'error' => 'Prilis mnoho pozadavku. Zkuste to prosim pozdeji.',
|
||||
'retry_after' => $this->windowSeconds,
|
||||
], JSON_UNESCAPED_UNICODE);
|
||||
|
||||
exit();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get client IP address
|
||||
*
|
||||
* @return string IP address
|
||||
*/
|
||||
private function getClientIp(): string
|
||||
{
|
||||
// Use the global helper if available
|
||||
if (function_exists('getClientIp')) {
|
||||
return getClientIp();
|
||||
}
|
||||
|
||||
// Fallback: use only REMOTE_ADDR (cannot be spoofed)
|
||||
return $_SERVER['REMOTE_ADDR'] ?? '0.0.0.0';
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate storage key for IP and endpoint
|
||||
*
|
||||
* @param string $ip Client IP
|
||||
* @param string $endpoint Endpoint identifier
|
||||
* @return string Storage key (filename-safe)
|
||||
*/
|
||||
private function getKey(string $ip, string $endpoint): string
|
||||
{
|
||||
// Create a safe filename from IP and endpoint
|
||||
return md5($ip . ':' . $endpoint);
|
||||
}
|
||||
|
||||
/**
|
||||
* Cleanup expired rate limit files
|
||||
*
|
||||
* Removes files older than the time window to prevent disk space issues
|
||||
*/
|
||||
private function cleanup(): void
|
||||
{
|
||||
if (!is_dir($this->storagePath)) {
|
||||
return;
|
||||
}
|
||||
|
||||
$files = glob($this->storagePath . '/*.json');
|
||||
$expireTime = time() - ($this->windowSeconds * 2); // Keep for 2x window to be safe
|
||||
|
||||
foreach ($files as $file) {
|
||||
if (filemtime($file) < $expireTime) {
|
||||
@unlink($file);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear all rate limit data (useful for testing)
|
||||
*/
|
||||
public function clearAll(): void
|
||||
{
|
||||
if (!is_dir($this->storagePath)) {
|
||||
return;
|
||||
}
|
||||
|
||||
$files = glob($this->storagePath . '/*.json');
|
||||
foreach ($files as $file) {
|
||||
@unlink($file);
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user