# 09. Wersjonowanie, migracje i stan systemu

Ten dokument opisuje mechanizm, dzięki któremu po pół roku i trzydziestu paczkach ZIP nadal wiadomo, co jest wgrane na serwerze i czy baza jest w zgodzie z kodem.

## Numeracja wersji

Format `MAJOR.MINOR.PATCH`, na przykład `1.4.2`.

| Człon | Zmienia się, gdy | Przykład |
|---|---|---|
| MAJOR | Przebudowa, która zmienia sposób pracy albo wymaga ręcznych działań przy aktualizacji | 1.8.0 → 2.0.0 przy przebudowie kalendarza |
| MINOR | Nowy moduł albo nowa funkcja, zgodna wstecz | 1.3.0 → 1.4.0 przy wejściu modułu SMS |
| PATCH | Poprawka błędu, bez zmian w bazie i bez nowych funkcji | 1.4.0 → 1.4.1 |

Zasady dodatkowe:

- Wersja `0.x.y` to dokumentacja i prace przygotowawcze, bez działającego kodu.
- Pierwsza wersja z działającym logowaniem i kalendarzem to `1.0.0`.
- Jeden etap to jedna wersja MINOR. Poprawki w trakcie etapu to PATCH tej wersji.
- Numer wersji żyje w pliku `VERSION` w katalogu głównym i jest jedynym miejscem, które trzeba zmienić przy wydaniu. Kod czyta go raz i trzyma w pamięci.

## `/version`

Adres publiczny, zwracający wersję aplikacji. Dwa formaty, w zależności od nagłówka `Accept` albo przyrostka `.json`.

**Tekst, dla człowieka:**
```
Salonio 1.4.2
```

**JSON:**
```json
{
  "app": "Salonio",
  "version": "1.4.2",
  "released": "2026-11-04",
  "db_version": "1.4.0",
  "db_migration": "0023_sms_inbox.sql",
  "php": "8.2.24",
  "environment": "production"
}
```

Odpowiedź nie zawiera nazw katalogów, wersji bibliotek ani niczego, co pomaga w ataku. Jeśli `version` i `db_version` się nie zgadzają, `/version` zwraca dodatkowe pole `"db_pending": true`, a `/admin/health` podnosi alarm.

Zalogowana właścicielka widzi numer wersji także w stopce panelu, jako link do `/changelog`. Klientka na stronie rezerwacji nie widzi wersji nigdzie.

## `/changelog`

Strona generowana z pliku `CHANGELOG.md`, dostępna publicznie. Parser czyta nagłówki wersji i sekcje, i wyświetla je jako listę zwijaną, od najnowszej.

Zasady pisania wpisów:

- Piszemy dla użytkowniczki, nie dla programisty. "Kalendarz otwiera się teraz na dzisiejszym dniu" zamiast "poprawiono inicjalizację widoku".
- Trzy sekcje: **Dodane**, **Zmienione**, **Poprawione**. Sekcja pusta znika.
- Zmiany wymagające działania użytkowniczki mają wyróżnienie **Uwaga**.
- Zmiany techniczne bez wpływu na pracę w systemie nie trafiają do changelogu, tylko do opisu migracji.

Wpis powstaje razem z kodem, w tym samym etapie. Uzupełnianie changelogu po fakcie zawsze kończy się tym, że go nie ma.

## Migracje bazy danych

### Zasada

Baza zmienia się wyłącznie przez pliki migracji. Żadnych ręcznych zmian w phpMyAdmin, także "tej jednej małej". Ręczna zmiana rozjeżdża środowiska i po trzech miesiącach nikt nie wie, dlaczego u klienta działa inaczej.

### Nazewnictwo

```
db/migrations/0001_init.sql
db/migrations/0002_appointment_history.sql
db/migrations/0003_add_client_stats.sql
```

Cztery cyfry, podkreślnik, krótki opis po angielsku, rozszerzenie `.sql`. Numery nadawane kolejno, nigdy nie zmieniane po wypuszczeniu paczki.

### Nagłówek pliku

Każda migracja zaczyna się blokiem komentarza:

```sql
-- Migracja: 0012_add_booking_holds.sql
-- Wersja aplikacji: 1.6.0
-- Data: 2026-12-02
-- Opis: Blokady terminów dla rezerwacji online
-- Wycofanie: DROP TABLE booking_holds;
-- Uwaga: brak, migracja bezpieczna na działającym systemie
```

Pole "Wycofanie" jest obowiązkowe. Jeśli wycofanie jest niemożliwe, na przykład przy usunięciu kolumny z danymi, pole zawiera słowo `niemożliwe` i opis, jak przywrócić dane z kopii zapasowej.

### Zasady pisania migracji

1. **Idempotentność tam, gdzie się da.** `CREATE TABLE IF NOT EXISTS`, `ADD COLUMN IF NOT EXISTS` w MariaDB, a w MySQL sprawdzenie w `information_schema` przed `ALTER`.
2. **Jedna migracja, jeden cel.** Nowa tabela i zmiana trzech innych to dwie migracje.
3. **Dane osobno od struktury.** Migracja zmieniająca strukturę nie przenosi danych. Przeniesienie danych to osobny plik, zaraz za nią.
4. **Bez `DROP` w tej samej wersji, w której powstaje nowa kolumna.** Najpierw dodaj nową, przepisz dane, wypuść wersję, sprawdź, dopiero w kolejnej wersji usuń starą. Inaczej wycofanie nie istnieje.
5. **Domyślne wartości dla nowych kolumn `NOT NULL`.** Inaczej `ALTER` się wywali na tabeli z danymi.
6. **Duże tabele ostrożnie.** `ALTER TABLE` na tabeli `appointments` z setkami tysięcy wierszy blokuje zapisy. Przy takiej zmianie migracja ma wyróżnioną uwagę i wykonuje się w oknie serwisowym.

### Migrator

Uruchamiany z panelu `/admin/migracje` oraz z wiersza poleceń:

```
php bin/migrate.php status
php bin/migrate.php run
php bin/migrate.php run --pretend
```

Przebieg:

```
1. Wczytaj listę plików z db/migrations, posortuj po numerze
2. Wczytaj listę wykonanych z tabeli migrations
3. Dla każdego wykonanego sprawdź sumę kontrolną pliku.
   Różnica oznacza podmianę już wykonanej migracji, przerwij z błędem
4. Dla każdego niewykonanego, po kolei:
   a. zapisz wiersz ze statusem running
   b. wykonaj zawartość pliku w transakcji, jeśli to możliwe
   c. zapisz applied, czas wykonania i sumę kontrolną
   d. przy błędzie zapisz failed z treścią błędu i przerwij dalsze
5. Zaktualizuj app_settings.db_version
```

Uwaga o transakcjach: MySQL wykonuje niejawne zatwierdzenie przy `CREATE TABLE`, `ALTER TABLE` i podobnych. Migracja strukturalna nie wycofa się sama. Dlatego każda migracja zmieniająca strukturę robi dokładnie jedną rzecz, a przed uruchomieniem migratora na produkcji robiona jest kopia zapasowa bazy. Migrator sprawdza, czy kopia z ostatnich 24 godzin istnieje, i bez niej ostrzega.

### Pełny schemat

Równolegle z migracjami utrzymywany jest plik `db/schema/schema-X.Y.Z.sql`, czyli komplet tabel w stanie po wszystkich migracjach do tej wersji. Służy do instalacji od zera i do porównania, czy baza po migracjach wygląda tak, jak powinna.

Generowanie:

```
php bin/dump-schema.php
```

Skrypt zrzuca strukturę bez danych, porządkuje kolejność tabel i zapisuje plik. Uruchamiany przy każdym wydaniu MINOR. Instalator na czystej bazie wykonuje najnowszy schemat i wpisuje do `migrations` wszystkie migracje do tej wersji jako wykonane, żeby migrator później ich nie powtórzył.

### Dane startowe

`db/seeds/` zawiera dane, bez których system nie działa: plany, pakiety SMS, domyślne szablony wiadomości, katalog uprawnień. Osobno `db/seeds/demo/` z danymi przykładowymi do testów, nigdy nieuruchamiane na produkcji.

## `/admin/health`

Jeden adres, który odpowiada na pytanie, czy system działa poprawnie. Dostępny wyłącznie dla zalogowanego operatora albo z tokenem w nagłówku, do monitoringu zewnętrznego.

### Odpowiedź

HTML z kolorowymi wierszami dla człowieka, JSON dla monitoringu pod `/admin/health.json`. Oba wymagają zalogowanego operatora.

Monitoring zewnętrzny nie loguje się, więc ma osobne wejście: `/admin/health-token?token=...`, gdzie token pochodzi z `config/config.php`, klucz `security.health_token`. Ten sam token można podać w nagłówku `X-Health-Token`.

```json
{
  "status": "ok",
  "checked_at": "2026-09-17T14:32:11Z",
  "version": "1.4.2",
  "checks": {
    "database":      { "status": "ok",   "time_ms": 3 },
    "migrations":    { "status": "ok",   "applied": 23, "pending": 0 },
    "storage":       { "status": "ok",   "writable": true, "free_mb": 4820 },
    "cron":          { "status": "ok",   "last_run": "2026-09-17T14:31:03Z", "delay_s": 68 },
    "queue":         { "status": "warn", "pending": 142, "failed": 3, "oldest_min": 22 },
    "sms_provider":  { "status": "ok",   "balance": 1284.50, "failed_24h": 1 },
    "stripe":        { "status": "ok",   "last_event": "2026-09-17T13:02:44Z", "unprocessed": 0 },
    "errors":        { "status": "ok",   "count_24h": 2 },
    "php":           { "status": "ok",   "version": "8.2.24", "extensions": "ok" },
    "backup":        { "status": "warn", "last": "2026-09-15T02:00:00Z", "age_h": 60 }
  }
}
```

### Sprawdzenia

| Sprawdzenie | Warunek `ok` | `warn` | `error` |
|---|---|---|---|
| `database` | Połączenie i `SELECT 1` poniżej 100 ms | poniżej 500 ms | brak połączenia albo wolniej |
| `migrations` | Brak oczekujących, sumy kontrolne zgodne | brak | oczekujące migracje albo migracja `failed` |
| `storage` | Katalogi `logs`, `cache`, `uploads` zapisywalne, powyżej 1 GB wolnego | poniżej 1 GB | brak zapisu albo poniżej 200 MB |
| `cron` | Ostatnie uruchomienie mniej niż 5 minut temu | do 30 minut | powyżej 30 minut |
| `queue` | Poniżej 100 oczekujących, brak nieudanych | poniżej 500 albo są nieudane | powyżej 500 albo najstarsze powyżej godziny |
| `sms_provider` | API odpowiada, saldo powyżej progu | saldo poniżej progu albo ponad 5 nieudanych na dobę | API nie odpowiada |
| `stripe` | Zdarzenie w ciągu 7 dni, brak nieprzetworzonych | nieprzetworzone starsze niż 15 minut | ponad 10 nieprzetworzonych |
| `errors` | Poniżej 10 błędów 500 na dobę | do 50 | powyżej 50 |
| `php` | Wersja 8.2 lub wyższa, wymagane rozszerzenia obecne | wersja niższa niż zalecana | brak wymaganego rozszerzenia |
| `backup` | Kopia z ostatnich 26 godzin | do 48 godzin | starsza albo brak |

Status ogólny to najgorszy ze statusów składowych. Kod HTTP: 200 dla `ok` i `warn`, 503 dla `error`, żeby monitoring zewnętrzny mógł reagować bez parsowania treści.

### Sprawdzenia wchodzące później

- Liczba salonów według statusu, przychód miesięczny, salony kończące okres próbny w tym tygodniu. To `/admin` i wchodzi w etapie 9, nie miesza się ze stanem technicznym.
- Czas odpowiedzi kluczowych stron, mierzony na próbkach z logu.

## Zadania cykliczne

Jeden wpis w cronie, reszta w kodzie:

```cron
* * * * * /usr/bin/php /home/uzytkownik/bin/cron.php >> /home/uzytkownik/storage/logs/cron.log 2>&1
```

`cron.php` decyduje, co uruchomić:

| Zadanie | Częstotliwość | Co robi |
|---|---|---|
| `ProcessQueue` | co minutę | Bierze do 20 zadań z kolejki i je wykonuje |
| `ScheduleReminders` | co 15 minut | Szuka wizyt wymagających przypomnienia i tworzy wiadomości |
| `SendBirthdaySms` | raz dziennie, 9:00 | Klientki z urodzinami dzisiaj i zgodą marketingową |
| `RecalculateClientStats` | co godzinę | Przelicza pola wyliczane klientek zmienionych od ostatniego przebiegu |
| `CleanupHolds` | co 5 minut | Kasuje wygasłe blokady terminów |
| `ReconcileSubscriptions` | raz dziennie, 3:00 | Porównuje subskrypcje ze Stripe |
| `CheckTrials` | raz dziennie, 8:00 | Wysyła przypomnienia o kończącym się okresie próbnym |
| `Cleanup` | raz dziennie, 4:00 | Kasuje stare logi, próby logowania, wygasłe sesje i tokeny |
| `Backup` | raz dziennie, 2:00 | Kopia bazy do `storage/backups`, opisana w `11-instalacja-i-srodowisko.md` |

Każde uruchomienie zapisuje wiersz w `cron_runs`. Zadanie, które trwa dłużej niż 5 minut, jest przerywane i zgłaszane jako błąd. Dwa równoległe przebiegi tego samego zadania nie są możliwe dzięki blokadzie na pliku.

## Procedura wydania wersji

Lista do odhaczenia przy każdym wydaniu:

1. Wszystkie punkty kryteriów odbioru etapu odhaczone.
2. Test izolacji salonów przeszedł.
3. Migracje uruchomione na kopii bazy produkcyjnej, bez błędów.
4. `php bin/dump-schema.php` wykonane, plik schematu w paczce.
5. `VERSION` podbity.
6. `CHANGELOG.md` uzupełniony, wpisy napisane językiem użytkownika.
7. Dokumentacja w `docs/` zaktualizowana, jeśli etap zmienił coś, co tam jest opisane.
8. Kopia zapasowa produkcji wykonana i sprawdzona.
9. Paczka `salonio-X.Y.Z.zip` zbudowana.
10. Po wgraniu: `/version` pokazuje nowy numer, `/admin/health` świeci na zielono, `/changelog` pokazuje nowy wpis.

## Wycofanie wersji

Kiedy coś pójdzie nie tak:

1. Wgraj poprzednią paczkę kodu, kod jest wymienny w całości.
2. Jeśli nowa wersja miała migracje, wykonaj sekcję "Wycofanie" z ich nagłówków, w odwrotnej kolejności.
3. Jeśli wycofanie migracji jest niemożliwe, odtwórz bazę z kopii sprzed aktualizacji i licz się z utratą danych wprowadzonych po aktualizacji.
4. Usuń wpisy wycofanych migracji z tabeli `migrations`.
5. Sprawdź `/version` i `/admin/health`.

Punkt trzeci jest powodem, dla którego kopia zapasowa przed migracją nie jest opcjonalna.
