| Server IP : 188.114.96.3 / Your IP : 216.73.216.143 Web Server : Apache System : Linux sv058.home.net.pl 6.8.2 husak_dirty_pages_fix #1 SMP PREEMPT_DYNAMIC Tue Apr 2 22:06:51 CEST 2024 x86_64 User : ( 3194769) PHP Version : 8.5.12 Disable Function : NONE MySQL : OFF | cURL : ON | WGET : ON | Perl : ON | Python : ON | Sudo : ON | Pkexec : OFF Directory : /polana/includes/ |
Upload File : |
<?php
declare(strict_types=1);
function ensure_reservations_schema(): void
{
db()->exec(
"CREATE TABLE IF NOT EXISTS reservations (
id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
reservation_code VARCHAR(40) DEFAULT NULL,
guest_name VARCHAR(160) NOT NULL,
phone VARCHAR(80) NOT NULL,
email VARCHAR(190) NOT NULL,
reservation_date DATE NOT NULL,
reservation_time TIME NOT NULL,
guests SMALLINT UNSIGNED NOT NULL DEFAULT 2,
notes TEXT DEFAULT NULL,
status VARCHAR(30) NOT NULL DEFAULT 'new',
internal_note TEXT DEFAULT NULL,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
confirmed_at DATETIME DEFAULT NULL,
cancelled_at DATETIME DEFAULT NULL,
UNIQUE KEY uq_reservation_code (reservation_code),
INDEX idx_reservation_date (reservation_date, reservation_time),
INDEX idx_reservation_status (status),
INDEX idx_reservation_email (email)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_polish_ci"
);
db()->exec(
"CREATE TABLE IF NOT EXISTS reservation_events (
id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
reservation_id INT UNSIGNED NOT NULL,
event_type VARCHAR(40) NOT NULL,
message TEXT DEFAULT NULL,
admin_user_id INT UNSIGNED DEFAULT NULL,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
INDEX idx_res_event_reservation (reservation_id),
CONSTRAINT fk_res_event_reservation
FOREIGN KEY (reservation_id) REFERENCES reservations(id) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_polish_ci"
);
}
function reservation_default_settings(): array
{
return [
'reservations_enabled' => '1',
'reservation_admin_email' => setting('email', ''),
'reservation_min_guests' => '1',
'reservation_max_guests' => '20',
'reservation_days_ahead' => '90',
'reservation_intro' => 'Wyślij prośbę o rezerwację stolika. Rezerwacja zostanie potwierdzona przez obsługę.',
'reservation_success_message' => 'Dziękujemy. Otrzymaliśmy prośbę o rezerwację. Potwierdzenie wyślemy osobną wiadomością.',
'reservation_auto_reply_enabled' => '1',
'reservation_received_subject' => 'Otrzymaliśmy Twoją prośbę o rezerwację – {site}',
'reservation_received_body' => "Cześć {name},\n\notrzymaliśmy Twoją prośbę o rezerwację {code} na {date} o {time} dla {guests} osób.\n\nTo jeszcze nie jest potwierdzenie stolika. Obsługa sprawdzi dostępność i wróci z odpowiedzią.\n\nDo zobaczenia,\n{site}",
'reservation_confirmed_subject' => 'Rezerwacja potwierdzona – {site}',
'reservation_confirmed_body' => "Cześć {name},\n\npotwierdzamy rezerwację {code} na {date} o {time} dla {guests} osób.\n\nDo zobaczenia!\n{site}",
'reservation_rejected_subject' => 'Odpowiedź w sprawie rezerwacji – {site}',
'reservation_rejected_body' => "Cześć {name},\n\nniestety nie możemy potwierdzić rezerwacji {code} na {date} o {time} dla {guests} osób.\n\nSkontaktuj się z nami, a spróbujemy znaleźć inny termin.\n\n{site}",
'reservation_cancelled_subject' => 'Rezerwacja anulowana – {site}',
'reservation_cancelled_body' => "Cześć {name},\n\nrezerwacja {code} na {date} o {time} została anulowana.\n\n{site}",
'smtp_enabled' => '0',
'smtp_host' => '',
'smtp_port' => '587',
'smtp_security' => 'starttls',
'smtp_username' => '',
'smtp_from_email' => setting('email', ''),
'smtp_from_name' => setting('site_name', APP_NAME),
];
}
function reservation_setting(string $key): string
{
$defaults = reservation_default_settings();
return setting($key, $defaults[$key] ?? '');
}
function reservations_enabled(): bool
{
return reservation_setting('reservations_enabled') === '1';
}
function reservation_statuses(): array
{
return [
'new' => 'Nowa',
'confirmed' => 'Potwierdzona',
'rejected' => 'Odrzucona',
'cancelled' => 'Anulowana',
'completed' => 'Zrealizowana',
];
}
function reservation_status_label(string $status): string
{
return reservation_statuses()[$status] ?? $status;
}
function reservation_add_event(
int $reservationId,
string $type,
string $message = '',
?int $adminUserId = null
): void {
ensure_reservations_schema();
$stmt = db()->prepare(
'INSERT INTO reservation_events
(reservation_id, event_type, message, admin_user_id)
VALUES (?, ?, ?, ?)'
);
$stmt->execute([
$reservationId,
$type,
$message !== '' ? $message : null,
$adminUserId,
]);
}
function reservation_by_id(int $id): ?array
{
ensure_reservations_schema();
$stmt = db()->prepare('SELECT * FROM reservations WHERE id = ? LIMIT 1');
$stmt->execute([$id]);
$row = $stmt->fetch();
return $row ?: null;
}
function reservation_by_code(string $code): ?array
{
ensure_reservations_schema();
$stmt = db()->prepare('SELECT * FROM reservations WHERE reservation_code = ? LIMIT 1');
$stmt->execute([$code]);
$row = $stmt->fetch();
return $row ?: null;
}
function reservation_events(int $reservationId): array
{
ensure_reservations_schema();
$stmt = db()->prepare(
'SELECT e.*, u.name AS admin_name, u.username AS admin_username
FROM reservation_events e
LEFT JOIN users u ON u.id = e.admin_user_id
WHERE e.reservation_id = ?
ORDER BY e.id DESC'
);
$stmt->execute([$reservationId]);
return $stmt->fetchAll();
}
function reservation_code_for_id(int $id, string $date): string
{
return sprintf('POLANA-%04d', $id);
}
/** POLANA V18.13: #POLANA-XXXX dla nowych i starych kodow bez migracji bazy. */
function reservation_display_code(string $code): string
{
$code = trim($code);
if (preg_match('/^#?POLANA-(\d+)$/D', $code, $m)) return '#POLANA-' . str_pad($m[1], 4, '0', STR_PAD_LEFT);
if (preg_match('/^MPS-\d{8}-(\d+)$/D', $code, $m)) return '#POLANA-' . str_pad($m[1], 4, '0', STR_PAD_LEFT);
return $code;
}
function reservation_create(array $data): array
{
ensure_reservations_schema();
$pdo = db();
$pdo->beginTransaction();
try {
$stmt = $pdo->prepare(
'INSERT INTO reservations
(guest_name, phone, email, reservation_date, reservation_time, guests, notes, status)
VALUES (?, ?, ?, ?, ?, ?, ?, "new")'
);
$stmt->execute([
trim((string)$data['guest_name']),
trim((string)$data['phone']),
trim((string)$data['email']),
(string)$data['reservation_date'],
(string)$data['reservation_time'],
(int)$data['guests'],
trim((string)($data['notes'] ?? '')) ?: null,
]);
$id = (int)$pdo->lastInsertId();
$code = reservation_code_for_id($id, (string)$data['reservation_date']);
$stmt = $pdo->prepare('UPDATE reservations SET reservation_code = ? WHERE id = ?');
$stmt->execute([$code, $id]);
// Rejestrujemy zdarzenie bez ponownego CREATE TABLE, który w MySQL
// wykonywałby niejawny COMMIT wewnątrz transakcji.
$event = $pdo->prepare(
'INSERT INTO reservation_events (reservation_id, event_type, message, admin_user_id)
VALUES (?, "created", ?, NULL)'
);
$event->execute([$id, 'Prośba o rezerwację została wysłana przez formularz.']);
$stmt = $pdo->prepare('SELECT * FROM reservations WHERE id = ? LIMIT 1');
$stmt->execute([$id]);
$reservation = $stmt->fetch(PDO::FETCH_ASSOC);
if (!$reservation) {
throw new RuntimeException('Nie udało się odczytać zapisanej rezerwacji.');
}
$pdo->commit();
return $reservation;
} catch (Throwable $e) {
if ($pdo->inTransaction()) $pdo->rollBack();
throw $e;
}
}
/**
* Blokada idempotentna obejmująca obie domeny korzystające z tej samej bazy.
* GET_LOCK w MySQL/MariaDB serializuje zapis dla telefonu oraz e-maila.
* Brak uprawnienia/blokady = brak zapisu (fail closed).
* Nie usuwa starych duplikatów i nie wymaga ALTER TABLE.
*/
function reservation_contact_keys(array $data): array
{
$phone = preg_replace('/[^0-9]/', '', (string)($data['phone'] ?? '')) ?? '';
if (strlen($phone) === 11 && str_starts_with($phone, '48')) {
$phone = substr($phone, 2);
}
$email = mb_strtolower(trim((string)($data['email'] ?? '')), 'UTF-8');
$keys = [];
if ($phone !== '') {
$keys[] = 'phone:' . $phone;
}
if ($email !== '') {
$keys[] = 'email:' . $email;
}
return $keys;
}
function reservation_create_once(array $data): array
{
ensure_reservations_schema();
$pdo = db();
$date = (string)$data['reservation_date'];
$time = substr((string)$data['reservation_time'], 0, 5) . ':00';
$contacts = reservation_contact_keys($data);
if (!$contacts) {
throw new RuntimeException('Brak danych kontaktowych do rezerwacji.');
}
$lockNames = [];
foreach ($contacts as $key) {
$lockNames[] = 'mps-res-' . substr(hash('sha256', $date . '|' . $time . '|' . $key), 0, 52);
}
sort($lockNames, SORT_STRING); // Stała kolejność zapobiega zakleszczeniu.
$lockNames = array_values(array_unique($lockNames));
$acquired = [];
try {
$getLock = $pdo->prepare('SELECT GET_LOCK(?, 10)');
foreach ($lockNames as $lockName) {
$getLock->execute([$lockName]);
$result = $getLock->fetchColumn();
$getLock->closeCursor();
if ((string)$result !== '1') {
throw new RuntimeException('System rezerwacji jest zajęty. Spróbuj ponownie za chwilę.');
}
$acquired[] = $lockName;
}
// Porównujemy znormalizowane kontakty; ignorujemy odrzucone/anulowane.
// Dwa telefony zapisane np. „+48 604 123 456” oraz „604123456” są tym samym.
$check = $pdo->prepare(
"SELECT id, phone, email FROM reservations
WHERE reservation_date = ? AND reservation_time = ?
AND status NOT IN ('cancelled', 'rejected')"
);
$check->execute([$date, $time]);
foreach ($check as $existing) {
if (array_intersect($contacts, reservation_contact_keys($existing))) {
$check->closeCursor();
return ['created' => false, 'reservation' => null];
}
}
$check->closeCursor();
// reservation_create pozostaje jedyną ścieżką INSERT wraz z kodem i historią.
// Locki utrzymujemy do zakończenia wszystkich zapisów.
$reservation = reservation_create($data);
return ['created' => true, 'reservation' => $reservation];
} finally {
if ($acquired) {
$release = $pdo->prepare('SELECT RELEASE_LOCK(?)');
foreach (array_reverse($acquired) as $lockName) {
try {
$release->execute([$lockName]);
$release->closeCursor();
} catch (Throwable $ignored) {
error_log('[POLANA RESERVATIONS] Nie udało się zwolnić blokady.');
}
}
}
}
}
function reservation_template_values(array $reservation): array
{
$date = DateTimeImmutable::createFromFormat('Y-m-d', (string)$reservation['reservation_date']);
$time = substr((string)$reservation['reservation_time'], 0, 5);
return [
'{code}' => reservation_display_code((string)$reservation['reservation_code']),
'{name}' => (string)$reservation['guest_name'],
'{date}' => $date ? $date->format('d.m.Y') : (string)$reservation['reservation_date'],
'{time}' => $time,
'{guests}' => (string)$reservation['guests'],
'{phone}' => (string)$reservation['phone'],
'{email}' => (string)$reservation['email'],
'{notes}' => (string)($reservation['notes'] ?? ''),
'{site}' => setting('site_name', APP_NAME),
];
}
function reservation_fill_template(string $template, array $reservation): string
{
return strtr($template, reservation_template_values($reservation));
}
function reservation_email_html(string $title, string $body): string
{
$safeTitle = e($title);
$safeBody = nl2br(e($body));
$logo = asset_v('img/logo-splash.png');
return '<!doctype html>
<html lang="pl">
<head><meta charset="utf-8"></head>
<body style="margin:0;background:#f4f2eb;font-family:Arial,Helvetica,sans-serif;color:#111">
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" style="background:#f4f2eb;padding:24px">
<tr><td align="center">
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" style="max-width:620px;background:#fff;border-radius:18px;overflow:hidden">
<tr><td style="padding:28px 34px 10px;text-align:center">
<img src="' . e($logo) . '" alt="' . e(setting('site_name', APP_NAME)) . '" style="max-width:220px;height:auto">
</td></tr>
<tr><td style="padding:14px 34px 34px">
<h1 style="font-size:26px;margin:0 0 18px">' . $safeTitle . '</h1>
<div style="font-size:16px;line-height:1.65;color:#383838">' . $safeBody . '</div>
</td></tr>
</table>
</td></tr>
</table>
</body>
</html>';
}
function reservation_send_admin_notification(array $reservation): void
{
$adminEmail = trim(reservation_setting('reservation_admin_email'));
if ($adminEmail === '') {
return;
}
$values = reservation_template_values($reservation);
$subject = 'Nowa rezerwacja ' . $values['{code}'] . ' – ' .
$values['{date}'] . ' ' . $values['{time}'];
$body =
"Nowa prośba o rezerwację.\n\n" .
"Kod: {$values['{code}']}\n" .
"Gość: {$values['{name}']}\n" .
"Termin: {$values['{date}']} o {$values['{time}']}\n" .
"Liczba osób: {$values['{guests}']}\n" .
"Telefon: {$values['{phone}']}\n" .
"E-mail: {$values['{email}']}\n" .
($values['{notes}'] !== '' ? "Uwagi: {$values['{notes}']}\n" : '') .
"\nPanel: " . site_url('/admin/reservation.php?id=' . (int)$reservation['id']);
smtp_send_mail(
$adminEmail,
$subject,
pv175_email_html('Nowa rezerwacja', $body, $reservation, 'admin'),
$body,
(string)$reservation['email']
);
reservation_add_event(
(int)$reservation['id'],
'email',
'Powiadomienie SMTP wysłane do obsługi: ' . $adminEmail
);
}
function reservation_send_guest_message(array $reservation, string $kind): void
{
if (trim((string)$reservation['email']) === '') {
return;
}
$subjectKey = 'reservation_' . $kind . '_subject';
$bodyKey = 'reservation_' . $kind . '_body';
$subject = reservation_fill_template(reservation_setting($subjectKey), $reservation);
$body = reservation_fill_template(reservation_setting($bodyKey), $reservation);
smtp_send_mail(
(string)$reservation['email'],
$subject,
pv175_email_html($subject, $body, $reservation, $kind),
$body,
trim(reservation_setting('reservation_admin_email')) ?: null
);
reservation_add_event(
(int)$reservation['id'],
'email',
'Wiadomość "' . $kind . '" wysłana do gościa.'
);
}
/** POLANA_V1813_STAFF_STATUS — po zmianie statusu, osobne powiadomienie do obslugi. */
function reservation_send_admin_status_notification(array $reservation, string $status): void
{
$to = trim(reservation_setting('reservation_admin_email'));
if ($to === '' || !filter_var($to, FILTER_VALIDATE_EMAIL)) {
throw new RuntimeException('Brak prawidlowego adresu powiadomien restauracji.');
}
$values = reservation_template_values($reservation);
$statuses = ['confirmed' => 'POTWIERDZONA', 'rejected' => 'ODRZUCONA', 'cancelled' => 'ANULOWANA'];
if (!isset($statuses[$status])) return;
$subject = 'POLANA — ' . $statuses[$status] . ' ' . $values['{code}'];
$body = "Zmiana statusu rezerwacji: " . $statuses[$status] . "\n\n" .
"Numer: " . $values['{code}'] . "\n" .
"Gosc: " . $values['{name}'] . "\n" .
"Termin: " . $values['{date}'] . ' ' . $values['{time}'] . "\n" .
"Liczba gosci: " . $values['{guests}'] . "\n" .
"Telefon: " . $values['{phone}'] . "\n" .
"E-mail: " . $values['{email}'] . "\n\n" .
"Panel: " . site_url('/admin/reservation.php?id=' . (int)$reservation['id']);
smtp_send_mail($to, $subject, reservation_email_html($subject, $body), $body);
reservation_add_event((int)$reservation['id'], 'email', 'Powiadomienie o statusie ' . $status . ' przyjete przez SMTP dla obslugi.');
}
function reservation_send_after_create(array $reservation): array
{
$results = [
'admin' => null,
'guest' => null,
];
if (setting('smtp_enabled', '0') !== '1') {
return $results;
}
try {
reservation_send_admin_notification($reservation);
$results['admin'] = true;
} catch (Throwable $e) {
$results['admin'] = false;
reservation_add_event(
(int)$reservation['id'],
'email_error',
'Błąd powiadomienia obsługi: ' . $e->getMessage()
);
}
if (reservation_setting('reservation_auto_reply_enabled') === '1') {
try {
reservation_send_guest_message($reservation, 'received');
$results['guest'] = true;
} catch (Throwable $e) {
$results['guest'] = false;
reservation_add_event(
(int)$reservation['id'],
'email_error',
'Błąd autorespondera: ' . $e->getMessage()
);
}
}
return $results;
}
function reservation_change_status(
int $id,
string $status,
?int $adminUserId = null
): array {
$previous = reservation_by_id($id);
if (!$previous) throw new RuntimeException('Nie znaleziono rezerwacji.');
$allowed = array_keys(reservation_statuses());
if (!in_array($status, $allowed, true)) {
throw new InvalidArgumentException('Nieprawidłowy status rezerwacji.');
}
$sql = 'UPDATE reservations SET status = ?';
$params = [$status];
if ($status === 'confirmed') {
$sql .= ', confirmed_at = NOW()';
}
if ($status === 'cancelled') {
$sql .= ', cancelled_at = NOW()';
}
$sql .= ' WHERE id = ?';
$params[] = $id;
$stmt = db()->prepare($sql);
$stmt->execute($params);
reservation_add_event(
$id,
'status',
'Status zmieniony na: ' . reservation_status_label($status),
$adminUserId
);
$reservation = reservation_by_id($id);
if (!$reservation) {
throw new RuntimeException('Nie znaleziono rezerwacji po zmianie statusu.');
}
if (
setting('smtp_enabled', '0') === '1' &&
(string)$previous['status'] !== $status &&
in_array($status, ['confirmed', 'rejected', 'cancelled'], true)
) {
try {
reservation_send_guest_message($reservation, $status);
} catch (Throwable $e) {
reservation_add_event(
$id,
'email_error',
'Błąd wiadomości statusowej: ' . $e->getMessage(),
$adminUserId
);
}
}
// POLANA V18.13: tylko faktyczna zmiana decyzji, nie powtarzane klikniecie.
if ((string)$previous['status'] !== $status && setting('smtp_enabled', '0') === '1'
&& in_array($status, ['confirmed', 'rejected', 'cancelled'], true)) {
try {
reservation_send_admin_status_notification($reservation, $status);
} catch (Throwable $e) {
reservation_add_event($id, 'email_error', 'Powiadomienie obslugi o statusie: ' . $e->getMessage(), $adminUserId);
}
}
// Nie wysyłamy powtórnego ntfy, jeśli status nie uległ zmianie.
if ((string)$previous['status'] !== $status) {
try {
require_once __DIR__ . '/reservation_integrations.php';
polana_ntfy_reservation($reservation, 'status');
} catch (Throwable $notifyError) {
error_log('[POLANA NTFY STATUS] ' . $notifyError->getMessage());
}
}
// POLANA_V1818_GCAL_AUTO_STATUS — wyłącznie po rzeczywistej zmianie statusu.
if (is_array($previous) && (string)($previous['status'] ?? '') !== (string)$status) {
try {
require_once __DIR__ . '/polana_gcal.php';
if (!function_exists('pg173_after_change')) {
throw new RuntimeException('Brak pg173_after_change w polana_gcal.php');
}
pg173_after_change((int)$id, (int)$adminUserId);
} catch (Throwable $calendarError) {
error_log('[POLANA V18.18 CALENDAR] Rezerwacja #'.(int)$id.': '.$calendarError->getMessage());
if (function_exists('reservation_add_event')) {
try {
reservation_add_event((int)$id, 'calendar_error', 'Automatyczna synchronizacja Google: '.substr($calendarError->getMessage(), 0, 200), (int)$adminUserId);
} catch (Throwable $ignored) {}
}
}
}
return $reservation;
}
/* POLANA_V175_RESERVATIONS */
require_once __DIR__.'/polana_v175_booking.php';
function reservation_validate_public_input(array $input): array
{
$errors = [];
$name = trim((string)($input['guest_name'] ?? ''));
$phone = trim((string)($input['phone'] ?? ''));
$email = trim((string)($input['email'] ?? ''));
$date = trim((string)($input['reservation_date'] ?? ''));
$time = trim((string)($input['reservation_time'] ?? ''));
$guests = (int)($input['guests'] ?? 0);
$notes = trim((string)($input['notes'] ?? ''));
if (mb_strlen($name, 'UTF-8') < 2) {
$errors[] = 'Podaj imię i nazwisko.';
}
if (mb_strlen($phone, 'UTF-8') < 6) {
$errors[] = 'Podaj numer telefonu.';
}
if (!filter_var($email, FILTER_VALIDATE_EMAIL)) {
$errors[] = 'Podaj prawidłowy adres e-mail.';
}
$dateObj = DateTimeImmutable::createFromFormat('!Y-m-d', $date);
$today = new DateTimeImmutable('today');
$maxDate = $today->modify('+' . max(1, (int)reservation_setting('reservation_days_ahead')) . ' days');
if (!$dateObj || $dateObj < $today || $dateObj > $maxDate) {
$errors[] = 'Wybierz prawidłową datę rezerwacji.';
}
if (!preg_match('/^(?:[01]\d|2[0-3]):[0-5]\d$/', $time)) {
$errors[] = 'Wybierz prawidłową godzinę.';
}
$minGuests = max(1, (int)reservation_setting('reservation_min_guests'));
$maxGuests = max($minGuests, (int)reservation_setting('reservation_max_guests'));
if ($guests < $minGuests || $guests > $maxGuests) {
$errors[] = 'Liczba osób musi mieścić się w zakresie ' . $minGuests . '–' . $maxGuests . '.';
}
if (empty($input['consent'])) {
$errors[] = 'Zaznacz zgodę na kontakt w sprawie rezerwacji.';
}
/* POLANA_V175_SERVER_VALIDATION — no bypass by manual POST */
$slotCheck = pv175_validate_slot($date, $time);
if (!$slotCheck['ok']) $errors[] = $slotCheck['message'];
return [
'errors' => $errors,
'data' => [
'guest_name' => $name,
'phone' => $phone,
'email' => $email,
'reservation_date' => $date,
'reservation_time' => $time,
'guests' => $guests,
'notes' => $notes,
],
];
}