feat: dist/ pridan do repa pro server deploy

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-13 09:19:40 +01:00
parent 1d27d19157
commit b2a2937a35
119 changed files with 15628 additions and 1 deletions

991
dist/api/includes/AttendanceAdmin.php vendored Normal file
View File

@@ -0,0 +1,991 @@
<?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.id, a.user_id, a.shift_date, a.arrival_time, a.arrival_address,
a.break_start, a.break_end, a.departure_time, a.departure_address,
a.notes, a.project_id, a.leave_type, a.leave_hours, a.created_at,
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 id, user_id, shift_date, arrival_time, break_start, break_end,
departure_time, notes, project_id, leave_type, leave_hours
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.id, a.user_id, a.shift_date, a.arrival_time,
a.arrival_lat, a.arrival_lng, a.arrival_accuracy, a.arrival_address,
a.break_start, a.break_end, a.departure_time,
a.departure_lat, a.departure_lng, a.departure_accuracy,
a.departure_address, a.notes, a.project_id,
a.leave_type, a.leave_hours, a.created_at,
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 id, user_id, shift_date, arrival_time, break_start, break_end,
departure_time, notes, project_id, leave_type, leave_hours
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 id, user_id, shift_date, leave_type, leave_hours
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.id, a.user_id, a.shift_date, a.arrival_time, a.arrival_address,
a.break_start, a.break_end, a.departure_time, a.departure_address,
a.notes, a.project_id, a.leave_type, a.leave_hours, a.created_at,
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,
]);
}

386
dist/api/includes/AttendanceHelpers.php vendored Normal file
View File

@@ -0,0 +1,386 @@
<?php
/**
* Attendance helper functions - shared between user and admin handlers
*/
declare(strict_types=1);
/**
* Vraci aktualni cas a datum z MySQL (jednotny zdroj casu)
* @return array{now: string, today: string, year: int, month: int}
*/
function getDbNow(PDO $pdo): array
{
$row = $pdo->query("SELECT NOW() AS now, CURDATE() AS today, YEAR(NOW()) AS y, MONTH(NOW()) AS m")->fetch();
return [
'now' => $row['now'],
'today' => $row['today'],
'year' => (int)$row['y'],
'month' => (int)$row['m'],
];
}
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 id, attendance_id, project_id, started_at, ended_at, hours, minutes
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);
$stmt = $pdo->prepare("
SELECT DISTINCT user_id FROM attendance
WHERE shift_date = CURDATE()
AND arrival_time IS NOT NULL
AND departure_time IS NULL
AND (leave_type IS NULL OR leave_type = 'work')
");
$stmt->execute();
$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 [];
}
}

560
dist/api/includes/AuditLog.php vendored Normal file
View File

@@ -0,0 +1,560 @@
<?php
/**
* Audit Logging System
*
* Comprehensive audit trail for all administrative actions
*/
declare(strict_types=1);
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 id, user_id, username, user_ip, action,
entity_type, entity_id, description,
old_values, new_values, created_at
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 id, user_id, username, user_ip, action,
entity_type, entity_id, description,
old_values, new_values, created_at
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 id, user_id, username, user_ip, action,
entity_type, entity_id, description,
old_values, new_values, created_at
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
dist/api/includes/CnbRates.php vendored 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;
}
}

117
dist/api/includes/CzechHolidays.php vendored Normal file
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);
}
}

98
dist/api/includes/Encryption.php vendored Normal file
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
dist/api/includes/JWTAuth.php vendored Normal file
View File

@@ -0,0 +1,663 @@
<?php
/**
* 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);
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.id, rt.user_id, rt.token_hash, rt.expires_at,
rt.replaced_at, rt.remember_me,
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;
}
}
}

90
dist/api/includes/LeaveNotification.php vendored Normal file
View File

@@ -0,0 +1,90 @@
<?php
/**
* Leave Request Email Notifications
*
* Sends email notifications when leave requests are created.
*/
declare(strict_types=1);
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>
" . (env('APP_URL', '') ? "
<p style='margin-top: 20px;'>
<a href='" . htmlspecialchars(env('APP_URL', '')) . "/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.<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
dist/api/includes/Mailer.php vendored Normal file
View File

@@ -0,0 +1,45 @@
<?php
/**
* 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', 'System');
$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;
}
}

84
dist/api/includes/PaginationHelper.php vendored Normal file
View File

@@ -0,0 +1,84 @@
<?php
/**
* Pagination helper - extrakce spolecne logiky pro strankovani seznamu.
*/
declare(strict_types=1);
class PaginationHelper
{
private const DEFAULT_PER_PAGE = 25;
private const MAX_PER_PAGE = 500;
/**
* Nacte pagination parametry z GET requestu.
*
* @param array<string, string> $sortMap
* @return array{page: int, per_page: int, sort: string, order: string, search: string}
*/
public static function parseParams(array $sortMap, string $defaultSort = 'created_at'): array
{
$sort = $_GET['sort'] ?? $defaultSort;
$order = strtoupper($_GET['order'] ?? 'DESC') === 'ASC' ? 'ASC' : 'DESC';
$page = max(1, (int) ($_GET['page'] ?? 1));
$perPage = min(self::MAX_PER_PAGE, max(1, (int) ($_GET['per_page'] ?? self::DEFAULT_PER_PAGE)));
$search = trim($_GET['search'] ?? '');
if (!isset($sortMap[$sort])) {
errorResponse('Neplatný parametr řazení', 400);
}
return [
'page' => $page,
'per_page' => $perPage,
'sort' => $sortMap[$sort],
'order' => $order,
'search' => $search ? mb_substr($search, 0, 100) : '',
];
}
/**
* Spusti COUNT + SELECT dotazy s pagination a vrati vysledek.
*
* @param PDO $pdo
* @param string $countSql - COUNT(*) dotaz
* @param string $dataSql - SELECT dotaz (bez LIMIT/OFFSET)
* @param array<int, mixed> $params - parametry pro prepared statement
* @param array{page: int, per_page: int, sort: string, order: string} $pagination
* @return array{items: array<int, array<string, mixed>>,
* pagination: array{total: int, page: int, per_page: int, total_pages: int}}
*/
public static function paginate(
PDO $pdo,
string $countSql,
string $dataSql,
array $params,
array $pagination
): array {
$page = $pagination['page'];
$perPage = $pagination['per_page'];
$stmt = $pdo->prepare($countSql);
$stmt->execute($params);
$total = (int) $stmt->fetchColumn();
$offset = ($page - 1) * $perPage;
$totalPages = (int) ceil($total / $perPage);
$sql = "{$dataSql} LIMIT {$perPage} OFFSET {$offset}";
$stmt = $pdo->prepare($sql);
$stmt->execute($params);
$items = $stmt->fetchAll();
return [
'items' => $items,
'pagination' => [
'total' => $total,
'page' => $page,
'per_page' => $perPage,
'total_pages' => $totalPages,
],
];
}
}

220
dist/api/includes/RateLimiter.php vendored Normal file
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);
}
}
}

139
dist/api/includes/Validator.php vendored Normal file
View File

@@ -0,0 +1,139 @@
<?php
/**
* Validacni helper pro API vstupy.
*
* Pouziti:
* $v = new Validator($input);
* $v->required('name')->string('name', 1, 255);
* $v->required('email')->email('email');
* $v->int('amount', 0, 1000000);
* $v->in('status', ['active', 'inactive']);
* if ($v->fails()) errorResponse($v->firstError());
*/
declare(strict_types=1);
class Validator
{
/** @var array<string, mixed> */
private array $data;
/** @var array<string, string> */
private array $errors = [];
/** @param array<string, mixed> $data */
public function __construct(array $data)
{
$this->data = $data;
}
public function required(string $field, string $label = ''): self
{
$value = $this->data[$field] ?? null;
if ($value === null || $value === '') {
$this->errors[$field] = ($label ?: $field) . ' je povinné pole';
}
return $this;
}
public function string(string $field, int $min = 0, int $max = 0, string $label = ''): self
{
$value = $this->data[$field] ?? null;
if ($value === null || $value === '') {
return $this;
}
if (!is_string($value)) {
$this->errors[$field] = ($label ?: $field) . ' musí být text';
return $this;
}
$len = mb_strlen($value);
if ($min > 0 && $len < $min) {
$this->errors[$field] = ($label ?: $field) . " musí mít alespoň {$min} znaků";
} elseif ($max > 0 && $len > $max) {
$this->errors[$field] = ($label ?: $field) . " nesmí překročit {$max} znaků";
}
return $this;
}
public function int(string $field, ?int $min = null, ?int $max = null, string $label = ''): self
{
$value = $this->data[$field] ?? null;
if ($value === null || $value === '') {
return $this;
}
if (!is_numeric($value)) {
$this->errors[$field] = ($label ?: $field) . ' musí být číslo';
return $this;
}
$intVal = (int) $value;
if ($min !== null && $intVal < $min) {
$this->errors[$field] = ($label ?: $field) . " musí být alespoň {$min}";
} elseif ($max !== null && $intVal > $max) {
$this->errors[$field] = ($label ?: $field) . " nesmí překročit {$max}";
}
return $this;
}
public function email(string $field, string $label = ''): self
{
$value = $this->data[$field] ?? null;
if ($value === null || $value === '') {
return $this;
}
if (!is_string($value) || !filter_var($value, FILTER_VALIDATE_EMAIL)) {
$this->errors[$field] = ($label ?: $field) . ' musí být platný e-mail';
}
return $this;
}
/**
* @param list<string> $allowed
*/
public function in(string $field, array $allowed, string $label = ''): self
{
$value = $this->data[$field] ?? null;
if ($value === null || $value === '') {
return $this;
}
if (!in_array($value, $allowed, true)) {
$this->errors[$field] = ($label ?: $field) . ' má neplatnou hodnotu';
}
return $this;
}
public function numeric(string $field, ?float $min = null, ?float $max = null, string $label = ''): self
{
$value = $this->data[$field] ?? null;
if ($value === null || $value === '') {
return $this;
}
if (!is_numeric($value)) {
$this->errors[$field] = ($label ?: $field) . ' musí být číslo';
return $this;
}
$numVal = (float) $value;
if ($min !== null && $numVal < $min) {
$this->errors[$field] = ($label ?: $field) . " musí být alespoň {$min}";
} elseif ($max !== null && $numVal > $max) {
$this->errors[$field] = ($label ?: $field) . " nesmí překročit {$max}";
}
return $this;
}
public function fails(): bool
{
return count($this->errors) > 0;
}
public function firstError(): string
{
return reset($this->errors) ?: '';
}
/** @return array<string, string> */
public function errors(): array
{
return $this->errors;
}
}

38
dist/api/includes/constants.php vendored Normal file
View File

@@ -0,0 +1,38 @@
<?php
/**
* Aplikacni konstanty
*
* Definuje konstanty pouzivane v celé API.
* Vyzaduje, aby byl pred includovanim tohoto souboru nacten helpers.php a .env.
*/
declare(strict_types=1);
// Environment
define('APP_ENV', env('APP_ENV', 'production'));
define('DEBUG_MODE', APP_ENV === 'local');
// Database configuration
define('DB_HOST', env('DB_HOST', 'localhost'));
define('DB_NAME', env('DB_NAME', ''));
define('DB_USER', env('DB_USER', ''));
define('DB_PASS', env('DB_PASS', ''));
define('DB_CHARSET', 'utf8mb4');
// Security configuration
define('MAX_LOGIN_ATTEMPTS', 5);
define('LOCKOUT_MINUTES', 15);
define('BCRYPT_COST', 12);
// CORS - konfigurovatelne pres env (comma-separated), fallback na hardcoded hodnoty
define('CORS_ALLOWED_ORIGINS', env('CORS_ALLOWED_ORIGINS', '')
? array_map('trim', explode(',', (string) env('CORS_ALLOWED_ORIGINS', '')))
: ['http://www.boha-automation.cz', 'https://www.boha-automation.cz']);
// Paths
define('API_ROOT', dirname(__DIR__));
define('INCLUDES_PATH', API_ROOT . '/includes');
// Rate limiting
define('RATE_LIMIT_STORAGE_PATH', dirname(__DIR__) . '/rate_limits');

357
dist/api/includes/helpers.php vendored Normal file
View File

@@ -0,0 +1,357 @@
<?php
/**
* Sdilene helper funkce pro API
*
* Definuje pomocne funkce pouzivane v celé API.
* Tento soubor NEDELA zadne side effects - jen definuje funkce.
*/
declare(strict_types=1);
function loadEnv(string $path): bool
{
if (!file_exists($path)) {
return false;
}
$lines = file($path, FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES);
foreach ($lines as $line) {
if (strpos(trim($line), '#') === 0) {
continue;
}
$parts = explode('=', $line, 2);
if (count($parts) !== 2) {
continue;
}
$name = trim($parts[0]);
$value = trim($parts[1]);
if (
(substr($value, 0, 1) === '"' && substr($value, -1) === '"') ||
(substr($value, 0, 1) === "'" && substr($value, -1) === "'")
) {
$value = substr($value, 1, -1);
}
$_ENV[$name] = $value;
}
return true;
}
function env(string $key, mixed $default = null): mixed
{
return $_ENV[$key] ?? $default;
}
/**
* Get PDO database connection (singleton pattern)
*
* @return PDO Database connection instance
* @throws PDOException If connection fails
*/
function db(): PDO
{
static $pdo = null;
if ($pdo === null) {
$dsn = sprintf(
'mysql:host=%s;dbname=%s;charset=%s',
DB_HOST,
DB_NAME,
DB_CHARSET
);
$options = [
PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,
PDO::ATTR_EMULATE_PREPARES => false,
PDO::MYSQL_ATTR_INIT_COMMAND => 'SET NAMES ' . DB_CHARSET,
];
try {
$pdo = new PDO($dsn, DB_USER, DB_PASS, $options);
} catch (PDOException $e) {
if (DEBUG_MODE) {
throw $e;
}
error_log('Database connection failed: ' . $e->getMessage());
throw new PDOException('Database connection failed');
}
}
return $pdo;
}
/**
* Set CORS headers for API responses
*/
function setCorsHeaders(): void
{
$origin = $_SERVER['HTTP_ORIGIN'] ?? '';
if (in_array($origin, CORS_ALLOWED_ORIGINS)) {
header("Access-Control-Allow-Origin: $origin");
header('Access-Control-Allow-Credentials: true');
} elseif (DEBUG_MODE && str_starts_with($origin, 'http://127.0.0.1:')) {
header("Access-Control-Allow-Origin: $origin");
header('Access-Control-Allow-Credentials: true');
}
// Neznamy origin = zadny CORS header
header('Access-Control-Allow-Methods: GET, POST, PUT, DELETE, OPTIONS');
header('Access-Control-Allow-Headers: Content-Type, Authorization, X-Requested-With');
header('Access-Control-Max-Age: 86400');
// Handle preflight requests
if ($_SERVER['REQUEST_METHOD'] === 'OPTIONS') {
http_response_code(200);
exit();
}
}
/**
* Send JSON response and exit
*
* @param mixed $data Data to send
* @param int $statusCode HTTP status code
*/
function jsonResponse($data, int $statusCode = 200): void
{
http_response_code($statusCode);
header('Content-Type: application/json; charset=utf-8');
echo json_encode($data, JSON_UNESCAPED_UNICODE);
exit();
}
/**
* Send error response
*
* @param string $message Error message
* @param int $statusCode HTTP status code
*/
function errorResponse(string $message, int $statusCode = 400): void
{
jsonResponse(['success' => false, 'error' => $message], $statusCode);
}
/**
* Send success response
*
* @param mixed $data Data to include
* @param string $message Optional message
*/
function successResponse($data = null, string $message = ''): void
{
$response = ['success' => true];
if ($message) {
$response['message'] = $message;
}
if ($data !== null) {
$response['data'] = $data;
}
jsonResponse($response);
}
/**
* Get JSON request body
*
* @return array<string, mixed> Decoded JSON data
*/
function getJsonInput(): array
{
$input = file_get_contents('php://input');
$data = json_decode($input, true);
return is_array($data) ? $data : [];
}
/**
* Sanitize string input
*
* @param string $input Input string
* @return string Sanitized string
*/
function sanitize(string $input): string
{
return htmlspecialchars(trim($input), ENT_QUOTES, 'UTF-8');
}
/**
* Validate email format
*
* @param string $email Email to validate
* @return bool True if valid
*/
function isValidEmail(string $email): bool
{
return filter_var($email, FILTER_VALIDATE_EMAIL) !== false;
}
/**
* Validate and sanitize month parameter (YYYY-MM format)
*/
function validateMonth(string $param = 'month'): string
{
$month = $_GET[$param] ?? date('Y-m');
if (!preg_match('/^\d{4}-(0[1-9]|1[0-2])$/', $month)) {
$month = date('Y-m');
}
return $month;
}
/**
* Get client IP address
*
* Uses only REMOTE_ADDR which cannot be spoofed (TCP connection IP).
* If you add a reverse proxy (Cloudflare, Nginx, etc.) in the future,
* update this function to trust specific proxy headers only from known proxy IPs.
*
* @return string IP address
*/
function getClientIp(): string
{
return $_SERVER['REMOTE_ADDR'] ?? '0.0.0.0';
}
/**
* Set security headers for API responses
*
* Sets standard security headers to protect against common web vulnerabilities:
* - X-Content-Type-Options: Prevents MIME type sniffing
* - X-Frame-Options: Prevents clickjacking attacks
* - X-XSS-Protection: Enables browser XSS filter
* - Referrer-Policy: Controls referrer information sent with requests
*
* Note: Content-Security-Policy is not set here as it may interfere with the React frontend
*/
function setSecurityHeaders(): void
{
header('X-Content-Type-Options: nosniff');
header('X-Frame-Options: DENY');
header('Referrer-Policy: strict-origin-when-cross-origin');
if (!DEBUG_MODE && isset($_SERVER['HTTPS']) && $_SERVER['HTTPS'] === 'on') {
header('Strict-Transport-Security: max-age=31536000; includeSubDomains');
}
}
/**
* Set no-cache headers
*
* Prevents browser caching for sensitive endpoints
*/
function setNoCacheHeaders(): void
{
header('Cache-Control: no-store, no-cache, must-revalidate, max-age=0');
header('Cache-Control: post-check=0, pre-check=0', false);
header('Pragma: no-cache');
}
/**
* Sdilene generovani cisel pro objednavky a projekty (spolecny ciselny prostor)
*/
function generateSharedNumber(PDO $pdo): string
{
$yy = date('y');
$settings = $pdo->query('SELECT order_type_code FROM company_settings LIMIT 1')->fetch();
$typeCode = ($settings && !empty($settings['order_type_code'])) ? $settings['order_type_code'] : '71';
$prefix = $yy . $typeCode;
$prefixLen = strlen($prefix);
$likePattern = $prefix . '%';
$stmt = $pdo->prepare('
SELECT COALESCE(MAX(seq), 0) FROM (
SELECT CAST(SUBSTRING(order_number, ? + 1) AS UNSIGNED) AS seq
FROM orders WHERE order_number LIKE ?
UNION ALL
SELECT CAST(SUBSTRING(project_number, ? + 1) AS UNSIGNED) AS seq
FROM projects WHERE project_number LIKE ?
) combined
');
$stmt->execute([$prefixLen, $likePattern, $prefixLen, $likePattern]);
$max = (int) $stmt->fetchColumn();
return sprintf('%s%s%04d', $yy, $typeCode, $max + 1);
}
/**
* Get permissions for a user by their ID
* Cached per-request via static variable
*
* @return list<string>
*/
function getUserPermissions(int $userId): array
{
static $cache = [];
if (isset($cache[$userId])) {
return $cache[$userId];
}
try {
$pdo = db();
$stmt = $pdo->prepare('
SELECT r.name FROM users u
JOIN roles r ON u.role_id = r.id
WHERE u.id = ?
');
$stmt->execute([$userId]);
$role = $stmt->fetch();
if ($role && $role['name'] === 'admin') {
$stmt = $pdo->query('SELECT name FROM permissions');
$cache[$userId] = $stmt->fetchAll(PDO::FETCH_COLUMN);
return $cache[$userId];
}
$stmt = $pdo->prepare('
SELECT p.name
FROM permissions p
JOIN role_permissions rp ON p.id = rp.permission_id
JOIN users u ON u.role_id = rp.role_id
WHERE u.id = ?
');
$stmt->execute([$userId]);
$cache[$userId] = $stmt->fetchAll(PDO::FETCH_COLUMN);
return $cache[$userId];
} catch (PDOException $e) {
error_log('getUserPermissions error: ' . $e->getMessage());
return [];
}
}
/**
* Require a specific permission, return 403 if denied
*
* @param array<string, mixed> $authData
*/
function requirePermission(array $authData, string $permission): void
{
if ($authData['user']['is_admin'] ?? false) {
return;
}
$permissions = getUserPermissions($authData['user_id']);
if (!in_array($permission, $permissions)) {
errorResponse('Přístup odepřen. Nemáte potřebná oprávnění.', 403);
}
}
/**
* Check if user has a specific permission (returns bool)
*
* @param array<string, mixed> $authData
*/
function hasPermission(array $authData, string $permission): bool
{
if ($authData['user']['is_admin'] ?? false) {
return true;
}
$permissions = getUserPermissions($authData['user_id']);
return in_array($permission, $permissions);
}