# 03. Model danych

Wersja schematu: **1.0.0**

| Plik | Co zawiera |
|---|---|
| `db/schema/schema-1.0.0.sql` | Instalacja od zera dla wersji 1.0.0, czyli tabele etapu 1. To jedyny plik, który wykonuje instalator |
| `db/schema/schema-docelowy.sql` | Projekt całej bazy po etapie 7, plik referencyjny. Nie jest wykonywany. Stąd kopiuje się gotowe definicje przy pisaniu migracji kolejnych etapów |
| `db/migrations/0001_init.sql` | Migracja inicjalna, treść identyczna ze schematem 1.0.0 |
| `db/seeds/` | Dane startowe: plany, pakiety SMS, ustawienia i szablony nowego salonu |

Ten dokument opisuje wszystkie tabele, także te, które fizycznie powstaną dopiero w późniejszych etapach. Przy każdej grupie podany jest etap.

## Zasady obowiązujące w całej bazie

1. **Silnik i kodowanie.** InnoDB, `utf8mb4_0900_ai_ci` (MariaDB: `utf8mb4_unicode_ci`). Bez wyjątków, bo polskie znaki i emoji w treściach SMS muszą działać.
2. **Klucze główne.** `id BIGINT UNSIGNED AUTO_INCREMENT`, bez UUID. Wyjątek: `sessions` używa losowego identyfikatora tekstowego.
3. **Izolacja salonów.** Każda tabela z danymi salonu ma kolumnę `salon_id` i indeks, który zaczyna się od `salon_id`. Także tam, gdzie technicznie dałoby się dojść do salonu przez złączenie. Jest to celowa denormalizacja, dzięki której każde zapytanie może filtrować bezpośrednio.
4. **Klucze obce.** Włączone, z `ON DELETE RESTRICT` dla danych biznesowych i `ON DELETE CASCADE` dla danych podrzędnych, które bez rodzica nie mają sensu (pozycje wizyty, uprawnienia użytkownika).
5. **Usuwanie miękkie.** `clients`, `services`, `users`, `salons` mają `deleted_at`. Reszta usuwa się fizycznie. Dane, do których odwołują się rozliczenia albo historia, nie znikają nigdy.
6. **Znaczniki czasu.** Wszystkie `DATETIME` w UTC. `created_at` ustawiany przez aplikację, nie przez bazę, żeby zachowanie było takie samo niezależnie od strefy serwera.
7. **Statusy.** Typ `VARCHAR(20)` z listą dopuszczalnych wartości pilnowaną w kodzie, nie `ENUM`. Dodanie nowego statusu nie może wymagać `ALTER TABLE` na dużej tabeli.
8. **Kwoty.** `DECIMAL(10,2)`. Przy wizytach ceny są kopiowane w momencie utworzenia, żeby późniejsza zmiana cennika nie zmieniała historii przychodu.
9. **Nazewnictwo.** Tabele w liczbie mnogiej, kolumny `snake_case`, klucze obce jako `<tabela_pojedyncza>_id`, daty jako `<czynność>_at`, flagi jako `is_<cecha>`.

## Mapa tabel

| Grupa | Etap | Tabele |
|---|:---:|---|
| System | 1 | `migrations`, `app_settings`, `platform_admins`, `audit_log`, `login_attempts`, `sessions`, `password_resets`, `jobs`, `cron_runs` |
| Salon | 1 | `salons`, `salon_settings`, `business_hours` |
| Zespół | 1 i 2 | `users`, `user_permissions` (etap 1), `work_schedules`, `time_off` (etap 2) |
| Oferta | 2 | `service_categories`, `services`, `service_user` |
| Klientki | 2 | `clients` |
| Wizyty | 3 | `appointments`, `appointment_services`, `appointment_history` |
| SMS | 5 | `sms_templates`, `sms_messages`, `sms_transactions`, `sms_packages` |
| Rozliczenia | 6 | `plans`, `subscriptions`, `payments`, `stripe_events` |
| Rezerwacje online | 7 | `booking_settings`, `booking_holds` |
| Skrzynka SMS | 8 | `sms_inbox` |

Razem 34 tabele.

## System

### migrations
Historia zastosowanych migracji. Podstawa dla `/admin/health` i `/version`.

| Kolumna | Typ | Opis |
|---|---|---|
| `id` | BIGINT | |
| `version` | VARCHAR(20) | Wersja aplikacji, do której należy migracja, np. `1.2.0` |
| `filename` | VARCHAR(191) | Nazwa pliku, unikalna |
| `checksum` | CHAR(64) | SHA-256 treści pliku w chwili wykonania, wykrywa podmianę już wykonanej migracji |
| `applied_at` | DATETIME | |
| `duration_ms` | INT | |
| `status` | VARCHAR(20) | `applied`, `failed` |
| `error` | TEXT | Treść błędu, jeśli `failed` |

### app_settings
Ustawienia globalne systemu, klucz i wartość. Przykłady kluczy: `app_version`, `maintenance_mode`, `sms_cost_per_credit`, `default_trial_days`.

### platform_admins
Konta operatora. Osobna tabela od `users`, żeby dostęp do `/admin` nie mógł nigdy powstać przez błąd w roli użytkownika salonu.

Kolumny: `id`, `email` (unikalny), `password_hash`, `name`, `totp_secret`, `is_active`, `last_login_at`, `created_at`.

### audit_log
Ślad po operacjach wrażliwych: logowania, zmiany uprawnień, usunięcia klientek, zmiany cen, wysyłki masowe, akcje operatora.

Kolumny: `id`, `salon_id` (może być NULL dla akcji operatora), `actor_type` (`user`, `admin`, `system`, `client`), `actor_id`, `action`, `entity_type`, `entity_id`, `meta` (JSON), `ip`, `user_agent`, `created_at`.
Indeksy: `(salon_id, created_at)`, `(entity_type, entity_id)`.

### login_attempts
Próby logowania do ograniczania ataków słownikowych. Kolumny: `id`, `email`, `ip`, `success`, `created_at`. Indeks `(ip, created_at)` i `(email, created_at)`. Rekordy starsze niż 30 dni czyści zadanie cron.

### sessions
Sesje w bazie zamiast w plikach, dzięki czemu da się wylogować konto ze wszystkich urządzeń i pokazać listę aktywnych sesji.

Kolumny: `id` CHAR(64) klucz główny, `user_id`, `admin_id`, `salon_id`, `ip`, `user_agent`, `payload`, `created_at`, `last_seen_at`, `expires_at`.

### password_resets
`id`, `user_id`, `token_hash` (SHA-256, nigdy sam token), `expires_at`, `used_at`, `created_at`. Token ważny 60 minut, jednorazowy.

### jobs
Kolejka zadań. Wysyłka SMS, przeliczanie statystyk klientek, czyszczenie danych. Cron wywołuje runner co minutę.

Kolumny: `id`, `salon_id`, `type`, `payload` (JSON), `run_at`, `available_at`, `attempts`, `max_attempts`, `status` (`pending`, `running`, `done`, `failed`), `locked_at`, `locked_by`, `last_error`, `created_at`, `finished_at`.
Indeks `(status, run_at)` decyduje o wydajności całej kolejki.

### cron_runs
Historia uruchomień zadań cyklicznych. Bez niej `/admin/health` nie ma czego pokazać.

Kolumny: `id`, `job_name`, `started_at`, `finished_at`, `status`, `processed`, `failed`, `message`.

## Salon

### salons
Jeden wiersz to jedno konto klienta systemu.

| Kolumna | Typ | Opis |
|---|---|---|
| `id` | BIGINT | |
| `name` | VARCHAR(150) | Nazwa widoczna dla klientek, także w SMS |
| `slug` | VARCHAR(60) | Unikalny, adres publiczny `/{slug}` |
| `timezone` | VARCHAR(50) | Domyślnie `Europe/Warsaw` |
| `currency` | CHAR(3) | Domyślnie `PLN` |
| `email`, `phone` | VARCHAR | Kontakt do salonu |
| `street`, `postal_code`, `city` | VARCHAR | Adres, potrzebny na fakturach i w rezerwacjach |
| `nip` | VARCHAR(15) | Do faktur |
| `logo_path` | VARCHAR(255) | |
| `plan_code` | VARCHAR(20) | `basic`, `pro` |
| `status` | VARCHAR(20) | `trial`, `active`, `past_due`, `suspended`, `cancelled` |
| `trial_ends_at` | DATETIME | |
| `access_until` | DATETIME | Data, do której konto działa. Wyliczana z opłaconego okresu plus zapas |
| `stripe_customer_id` | VARCHAR(50) | |
| `sms_balance` | INT | Saldo kredytów, aktualizowane wyłącznie przez `sms_transactions` |
| `sms_sender` | VARCHAR(11) | Nazwa nadawcy zatwierdzona u operatora |
| `sms_sender_status` | VARCHAR(20) | `none`, `pending`, `approved`, `rejected` |
| `created_at`, `updated_at`, `deleted_at` | DATETIME | |

### salon_settings
Ustawienia salonu jako klucz i wartość, żeby dokładanie opcji nie wymagało migracji. Unikalny `(salon_id, key)`.

Klucze przewidziane na start: `reminder_enabled`, `reminder_hours_before`, `reminder_send_hour`, `after_visit_enabled`, `birthday_enabled`, `birthday_send_hour`, `winback_enabled`, `winback_days`, `calendar_start_hour`, `calendar_end_hour`, `calendar_slot_min`, `default_service_buffer`, `week_starts_on`, `no_show_block_after`.

### business_hours
Godziny otwarcia salonu. `id`, `salon_id`, `weekday` (0 poniedziałek do 6 niedziela), `opens_at` TIME, `closes_at` TIME, `is_closed`. Czas lokalny salonu.

## Zespół

### users
Konta pracowników, w tym właścicielki.

| Kolumna | Typ | Opis |
|---|---|---|
| `id` | BIGINT | |
| `salon_id` | BIGINT | |
| `role` | VARCHAR(20) | `owner`, `manager`, `staff`, `reception` |
| `first_name`, `last_name` | VARCHAR(80) | |
| `email` | VARCHAR(191) | Unikalny w całym systemie, służy do logowania |
| `phone` | VARCHAR(20) | |
| `password_hash` | VARCHAR(255) | `password_hash()` z `PASSWORD_DEFAULT` |
| `position` | VARCHAR(80) | Stanowisko widoczne przy rezerwacjach, np. Kosmetyczka |
| `color` | CHAR(7) | Kolor w kalendarzu, format `#RRGGBB` |
| `is_active` | TINYINT | |
| `is_bookable` | TINYINT | Czy pojawia się przy umawianiu wizyt |
| `avatar_path` | VARCHAR(255) | |
| `last_login_at`, `created_at`, `updated_at`, `deleted_at` | DATETIME | |

Przy usuwaniu miękkim adres e-mail jest zamieniany na `usuniete+<id>@salonio.local`, żeby zwolnić go do ponownego użycia.

### user_permissions
Odstępstwa od domyślnych uprawnień roli. `id`, `salon_id`, `user_id`, `permission`, `is_allowed`. Unikalny `(user_id, permission)`. Katalog uprawnień w `04-uprawnienia-i-role.md`.

### work_schedules
Grafik tygodniowy pracownika. `id`, `salon_id`, `user_id`, `weekday`, `starts_at` TIME, `ends_at` TIME, `is_working`. Czas lokalny.

### time_off
Urlopy, zwolnienia i pojedyncze przerwy. `id`, `salon_id`, `user_id`, `starts_at` DATETIME UTC, `ends_at`, `type` (`vacation`, `sick`, `break`, `other`), `note`, `created_at`. Blokuje terminy w kalendarzu i w rezerwacjach online.

## Oferta

### service_categories
`id`, `salon_id`, `name`, `sort_order`, `is_active`.

### services
| Kolumna | Typ | Opis |
|---|---|---|
| `id`, `salon_id`, `category_id` | BIGINT | |
| `name` | VARCHAR(150) | |
| `description` | TEXT | Widoczny przy rezerwacji online |
| `duration_min` | SMALLINT | Czas trwania w minutach |
| `buffer_after_min` | SMALLINT | Czas na sprzątnięcie stanowiska, blokowany w kalendarzu, ale niewidoczny dla klientki |
| `price` | DECIMAL(10,2) | |
| `is_price_from` | TINYINT | Czy cena jest "od" |
| `color` | CHAR(7) | |
| `is_active` | TINYINT | |
| `is_online_bookable` | TINYINT | Czy dostępna w rezerwacjach online |
| `sort_order` | INT | |
| `created_at`, `updated_at`, `deleted_at` | DATETIME | |

### service_user
Kto wykonuje daną usługę. `id`, `salon_id`, `service_id`, `user_id`. Unikalny `(service_id, user_id)`. Brak wierszy dla usługi oznacza, że wykonują ją wszyscy.

## Klientki

### clients
| Kolumna | Typ | Opis |
|---|---|---|
| `id`, `salon_id` | BIGINT | |
| `first_name`, `last_name` | VARCHAR(80) | |
| `phone` | VARCHAR(20) | E.164, unikalny w ramach salonu |
| `phone_display` | VARCHAR(25) | Format do pokazania, np. `500 600 700` |
| `email` | VARCHAR(191) | |
| `birth_date` | DATE | Do SMS urodzinowego |
| `notes` | TEXT | Notatka widoczna przy wizycie |
| `consent_sms` | TINYINT | Zgoda na SMS przypominające |
| `consent_sms_at` | DATETIME | |
| `consent_marketing` | TINYINT | Zgoda na treści marketingowe, wymagana do SMS odzyskujących i urodzinowych |
| `consent_marketing_at` | DATETIME | |
| `source` | VARCHAR(20) | `panel`, `online`, `import` |
| `is_blocked` | TINYINT | Klientka z historią nieobecności, blokada rezerwacji online |
| `first_visit_at`, `last_visit_at`, `next_visit_at` | DATETIME | Pola wyliczane |
| `visits_count`, `no_show_count`, `cancelled_count` | INT | Pola wyliczane |
| `total_spent` | DECIMAL(10,2) | Pole wyliczane |
| `avg_interval_days` | SMALLINT | Średni odstęp między wizytami, podstawa modułu odzyskiwania klientek |
| `stats_updated_at` | DATETIME | |
| `created_at`, `updated_at`, `deleted_at` | DATETIME | |

Pola wyliczane aktualizuje zadanie `RecalculateClientStats` po każdej zmianie statusu wizyty. Nie liczy się ich w locie, bo lista klientek musiałaby wtedy robić kilka zapytań na wiersz.

Indeksy: unikalny `(salon_id, phone)`, zwykłe `(salon_id, last_name, first_name)`, `(salon_id, last_visit_at)`, `(salon_id, birth_date)`.

## Wizyty

### appointments
| Kolumna | Typ | Opis |
|---|---|---|
| `id`, `salon_id`, `client_id`, `user_id` | BIGINT | `user_id` to pracownik prowadzący wizytę |
| `starts_at`, `ends_at` | DATETIME | UTC, `ends_at` zawiera bufor po usłudze |
| `status` | VARCHAR(20) | `scheduled`, `confirmed`, `completed`, `cancelled`, `no_show` |
| `source` | VARCHAR(20) | `panel`, `online`, `import` |
| `total_price` | DECIMAL(10,2) | Suma pozycji po rabacie |
| `discount` | DECIMAL(10,2) | |
| `paid_amount` | DECIMAL(10,2) | |
| `payment_method` | VARCHAR(20) | `cash`, `card`, `transfer`, `online`, `other` |
| `note` | TEXT | Widoczna dla klientki przy rezerwacji online |
| `internal_note` | TEXT | Tylko dla salonu |
| `confirmed_at` | DATETIME | Ustawiane przez odpowiedź SMS albo ręcznie |
| `reminder_sent_at` | DATETIME | Zabezpieczenie przed podwójnym przypomnieniem |
| `cancelled_at`, `cancelled_by`, `cancel_reason` | | |
| `created_by` | BIGINT | NULL przy rezerwacji online |
| `created_at`, `updated_at` | DATETIME | |

Indeksy: `(salon_id, starts_at)` dla kalendarza, `(salon_id, user_id, starts_at)` dla widoku pracownika, `(salon_id, client_id, starts_at)` dla historii klientki, `(salon_id, status, starts_at)` dla statystyk i przypomnień.

Reguła nakładania się wizyt: jedna osoba nie może mieć dwóch wizyt w tym samym czasie. Sprawdzenie robi zapytanie z warunkiem `starts_at < :ends AND ends_at > :starts` dla danego `user_id` i statusów innych niż `cancelled`. Baza tego nie wymusza, bo MySQL nie ma wykluczeń zakresów, więc kontrola jest w jednym miejscu w kodzie, w `AppointmentService::assertNoConflict()`, i wołana zawsze przed zapisem.

### appointment_services
Pozycje wizyty z zamrożonymi wartościami. `id`, `salon_id`, `appointment_id`, `service_id`, `user_id`, `name` (kopia nazwy), `price` (kopia ceny), `duration_min` (kopia czasu), `sort_order`. Usunięcie wizyty usuwa pozycje kaskadowo.

Kopiowanie nazwy, ceny i czasu jest celowe. Po zmianie cennika wizyty sprzed zmiany pokazują kwoty, które faktycznie zapłacono.

### appointment_history
Kto i co zmienił. `id`, `salon_id`, `appointment_id`, `user_id`, `action` (`created`, `moved`, `status_changed`, `services_changed`, `cancelled`), `from_value`, `to_value`, `created_at`. Potrzebna przy sporach typu "przecież miałam wizytę o innej godzinie".

## SMS

### sms_templates
`id`, `salon_id`, `type` (`reminder`, `after_visit`, `birthday`, `winback`, `confirmation`, `cancellation`, `manual`), `body`, `is_active`, `hours_before`, `send_hour`, `created_at`, `updated_at`. Unikalny `(salon_id, type)` dla szablonów systemowych.

### sms_messages
| Kolumna | Opis |
|---|---|
| `id`, `salon_id`, `client_id`, `appointment_id` | `client_id` i `appointment_id` mogą być NULL dla wysyłek ręcznych |
| `template_type` | Typ szablonu, z którego powstała treść |
| `phone` | Numer w E.164 |
| `body` | Treść po podstawieniu zmiennych |
| `parts` | Liczba części wiadomości |
| `encoding` | `gsm` albo `ucs2` |
| `status` | `queued`, `sent`, `delivered`, `undelivered`, `failed`, `cancelled` |
| `provider_message_id` | Identyfikator u operatora, do raportu doręczenia |
| `error_code`, `error_message` | |
| `scheduled_at`, `sent_at`, `delivered_at` | |
| `credits` | Ile kredytów pobrano |
| `created_at` | |

Indeksy: `(salon_id, created_at)`, `(status, scheduled_at)`, `(provider_message_id)`.

### sms_transactions
Księga kredytów SMS. Saldo w `salons.sms_balance` jest wyłącznie sumą tej tabeli i nigdy nie jest zmieniane bezpośrednio.

`id`, `salon_id`, `type` (`purchase`, `usage`, `refund`, `bonus`, `correction`), `credits` (dodatnie lub ujemne), `balance_after`, `sms_message_id`, `payment_id`, `note`, `created_by`, `created_at`.

### sms_packages
Katalog pakietów. `id`, `code`, `name`, `credits`, `price_gross`, `stripe_price_id`, `is_active`, `sort_order`. Wspólny dla wszystkich salonów.

### sms_inbox
Odpowiedzi klientek. `id`, `salon_id`, `phone`, `body`, `received_at`, `appointment_id`, `action` (`confirm`, `cancel`, `none`), `provider_id`, `raw` (JSON), `created_at`.

## Rozliczenia

### plans
`code` (klucz główny, `basic` albo `pro`), `name`, `price_month`, `price_year`, `stripe_price_month`, `stripe_price_year`, `max_users`, `features` (JSON), `is_active`, `sort_order`.

### subscriptions
`id`, `salon_id`, `plan_code`, `status` (odpowiada statusowi Stripe: `trialing`, `active`, `past_due`, `canceled`, `unpaid`, `incomplete`), `stripe_subscription_id`, `current_period_start`, `current_period_end`, `cancel_at_period_end`, `trial_ends_at`, `created_at`, `updated_at`.

### payments
`id`, `salon_id`, `type` (`subscription`, `sms_package`), `amount_gross`, `currency`, `status` (`pending`, `paid`, `failed`, `refunded`), `stripe_payment_intent_id`, `stripe_invoice_id`, `invoice_url`, `description`, `paid_at`, `created_at`.

### stripe_events
Zapis każdego odebranego zdarzenia webhooka, zanim zostanie przetworzone. `id`, `stripe_event_id` (unikalny, zapewnia jednokrotne przetworzenie), `type`, `status` (`received`, `processed`, `failed`, `ignored`), `payload` (JSON), `received_at`, `processed_at`, `attempts`, `error`.

## Rezerwacje online

### booking_settings
`id`, `salon_id`, `is_enabled`, `slot_interval_min` (domyślnie 15), `lead_time_hours` (minimalne wyprzedzenie, domyślnie 2), `max_days_ahead` (domyślnie 60), `cancel_window_hours` (domyślnie 24), `auto_confirm`, `require_consent`, `intro_text`, `terms_text`, `color`, `cover_path`, `updated_at`.

### booking_holds
Krótka blokada terminu w trakcie wypełniania formularza, żeby dwie klientki nie zajęły tej samej godziny. `id`, `salon_id`, `user_id`, `starts_at`, `ends_at`, `token`, `expires_at`, `created_at`. Blokada żyje 10 minut, wygasłe kasuje cron.

## Zapytania, które muszą być szybkie

| Zapytanie | Indeks, który je obsługuje |
|---|---|
| Wizyty w widoku tygodnia | `appointments(salon_id, starts_at)` |
| Wizyty jednego pracownika w dniu | `appointments(salon_id, user_id, starts_at)` |
| Historia klientki | `appointments(salon_id, client_id, starts_at)` |
| Wyszukiwanie klientki po nazwisku | `clients(salon_id, last_name, first_name)` |
| Wyszukiwanie klientki po numerze | `clients(salon_id, phone)` |
| Wizyty do przypomnienia | `appointments(salon_id, status, starts_at)` plus warunek na `reminder_sent_at IS NULL` |
| Klientki do odzyskania | `clients(salon_id, last_visit_at)` |
| Kolejka zadań | `jobs(status, run_at)` |
| Urodziny dzisiaj | `clients(salon_id, birth_date)` |

## Diagram relacji

```
salons 1---* users 1---* user_permissions
       |         |---* work_schedules
       |         |---* time_off
       |---* salon_settings
       |---* business_hours
       |---* service_categories 1---* services 1---* service_user *---1 users
       |---* clients 1---* appointments *---1 users
       |                        |---* appointment_services *---1 services
       |                        |---* appointment_history
       |                        |---* sms_messages
       |---* sms_templates
       |---* sms_transactions
       |---* sms_inbox
       |---1 subscriptions *---1 plans
       |---* payments
       |---1 booking_settings
       |---* booking_holds
       |---* jobs
       |---* audit_log
```
