Initial commit

This commit is contained in:
2026-03-12 12:43:56 +01:00
commit f733dee856
137 changed files with 51192 additions and 0 deletions

View 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,
]);
}

View 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
View 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
View 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;
}
}

View 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);
}
}

View 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
View 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;
}
}
}

View 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
View 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;
}
}

View 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);
}
}
}