# 02. Architektura

## Stack

| Element | Wybór | Uzasadnienie |
|---|---|---|
| Język | PHP 8.2 lub nowszy | Działa na każdym hostingu współdzielonym, znany z systemu galerii |
| Baza | MySQL 8 lub MariaDB 10.6+ | Standard na hostingu, wystarczy do kilkuset salonów |
| Dostęp do bazy | PDO z zapytaniami parametryzowanymi | Bez ORM, mniej magii, pełna kontrola nad zapytaniami |
| Szablony | Czysty PHP jako widoki, bez silnika szablonów | Jedna zależność mniej |
| Frontend | HTML, CSS i JavaScript bez frameworka | Kalendarz wymaga trochę JS, reszta radzi sobie formularzami |
| Zależności zewnętrzne | Tylko Stripe PHP SDK, reszta własna | Composer opcjonalny, SDK można wgrać ręcznie |
| Wysyłka SMS | Adapter nad API sms.pl | Zmiana operatora to podmiana jednego pliku |

Bez frameworka, bo projekt ma zostać prosty w utrzymaniu i przenośny między hostingami. Struktura poniżej daje porządek, którego zwykle szuka się we frameworku, bez jego wagi.

## Struktura katalogów

```
/                          katalog domowy na serwerze, poza public_html
  app/
    Core/                  router, kontener, żądanie, odpowiedź, sesja, walidacja
    Support/               Db, Auth, Tenant, Permissions, Csrf, Log, Money, Clock
    Modules/
      Auth/                logowanie, rejestracja, reset hasła
      Dashboard/
      Calendar/
      Clients/
      Appointments/
      Services/
      Staff/
      Stats/
      Sms/
      Billing/
      Booking/             publiczne rezerwacje
      Admin/               panel operatora
      System/              /version, /changelog, /admin/health, migrator
    Views/
      layouts/
      partials/
      <modul>/
    Jobs/                  zadania kolejki uruchamiane cronem
  config/
    config.php             tworzony przez instalator, nie trafia do repozytorium
    config.example.php
    permissions.php        katalog uprawnień
    plans.php              definicje planów abonamentowych
  db/
    schema/
    migrations/
  tests/
    run.php                testy uruchamiane jednym poleceniem, bez composera
  storage/
    logs/
    cache/
    uploads/
    backups/
  vendor/                  opcjonalnie, Stripe SDK
  public_html/             jedyny katalog widoczny z internetu
    index.php              front controller
    .htaccess
    assets/
      css/ js/ img/
    uploads/               symlink albo katalog na pliki publiczne
```

Wszystko poza `public_html` jest niedostępne z przeglądarki. Jeśli hosting nie pozwala przenieść katalogów poza katalog publiczny, każdy katalog wewnętrzny dostaje `.htaccess` z `Require all denied`, a instalator to sprawdza.

## Przepływ żądania

```
przeglądarka
  → public_html/.htaccess przepisuje wszystko do index.php
  → index.php ładuje config, autoloader, sesję
  → Router dopasowuje ścieżkę do kontrolera
  → middleware: sesja, CSRF, Auth, Tenant, Permission, limit żądań
  → kontroler pobiera dane przez repozytorium
  → widok renderuje HTML albo kontroler zwraca JSON
```

Middleware wykonuje się w tej kolejności i każdy może przerwać żądanie. `Tenant` ustawia aktywny `salon_id` na podstawie sesji i od tego momentu każde zapytanie do bazy musi ten identyfikator uwzględniać. Szczegóły w `04-uprawnienia-i-role.md`.

## Czyste adresy bez .php

`.htaccess` w `public_html`:

```apache
RewriteEngine On
RewriteCond %{REQUEST_FILENAME} !-f
RewriteCond %{REQUEST_FILENAME} !-d
RewriteRule ^ index.php [QSA,L]
```

Wszystkie odwołania w kodzie idą przez helper `url('/kalendarz')`, nigdy przez sztywne ścieżki z rozszerzeniem.

## Mapa adresów

### Publiczne

| Adres | Opis |
|---|---|
| `/` | Strona produktu |
| `/cennik` | Plany abonamentowe |
| `/rejestracja` | Zakładanie salonu |
| `/logowanie` | Logowanie |
| `/wylogowanie` | Wylogowanie |
| `/reset-hasla` | Wysłanie linku resetu |
| `/reset-hasla/{token}` | Ustawienie nowego hasła |
| `/version` | Wersja aplikacji, tekst i JSON |
| `/changelog` | Historia zmian dla użytkowników |
| `/regulamin`, `/polityka-prywatnosci` | Dokumenty prawne |
| `/{slug}` | Publiczne rezerwacje salonu, ostatnia reguła routera |

### Panel salonu, wymaga zalogowania

| Adres | Opis |
|---|---|
| `/panel` | Dashboard |
| `/kalendarz` | Kalendarz, parametry `?widok=dzien\|tydzien\|miesiac&data=YYYY-MM-DD` |
| `/wizyty/nowa` | Nowa wizyta |
| `/wizyty/{id}` | Podgląd i edycja wizyty |
| `/klientki` | Lista i wyszukiwarka |
| `/klientki/nowa`, `/klientki/{id}` | Karta klientki |
| `/uslugi` | Usługi i kategorie |
| `/pracownicy` | Pracownicy, uprawnienia, grafik |
| `/sms` | Saldo, ustawienia przypomnień |
| `/sms/szablony` | Treści wiadomości |
| `/sms/historia` | Wysłane i zaplanowane |
| `/sms/pakiety` | Doładowanie |
| `/statystyki` | Podstawowe liczby |
| `/ustawienia` | Dane salonu, godziny pracy, rezerwacje online |
| `/abonament` | Plan, płatności, faktury |

### API wewnętrzne, JSON

| Adres | Metoda | Opis |
|---|---|---|
| `/api/kalendarz/wizyty` | GET | Wizyty w zakresie dat |
| `/api/wizyty/{id}/przesun` | POST | Drag and drop |
| `/api/wizyty/{id}/status` | POST | Zmiana statusu |
| `/api/klientki/szukaj` | GET | Podpowiedzi w polu wyboru klientki |
| `/api/terminy/wolne` | GET | Wolne terminy dla usługi i pracownika |

### Webhooki i panel operatora

| Adres | Opis |
|---|---|
| `/webhook/stripe` | Zdarzenia Stripe, bez CSRF, z weryfikacją podpisu |
| `/webhook/sms/dlr` | Raporty doręczeń od operatora |
| `/webhook/sms/in` | Wiadomości przychodzące od klientek |
| `/admin` | Panel operatora |
| `/admin/health` | Stan systemu, HTML i JSON, dla zalogowanego operatora |
| `/admin/health-token` | To samo w JSON, dla monitoringu zewnętrznego, autoryzacja tokenem |
| `/admin/migracje` | Podgląd i uruchamianie migracji |
| `/admin/salony` | Lista salonów |
| `/admin/logi` | Przegląd logów |

### Zarezerwowane slugi

Ponieważ `/{slug}` łapie wszystko, czego nie złapały wcześniejsze reguły, lista zakazanych nazw salonu jest twarda: `admin`, `api`, `panel`, `webhook`, `assets`, `uploads`, `version`, `changelog`, `logowanie`, `wylogowanie`, `rejestracja`, `cennik`, `regulamin`, `polityka-prywatnosci`, `kalendarz`, `klientki`, `wizyty`, `uslugi`, `pracownicy`, `sms`, `statystyki`, `ustawienia`, `abonament`, `reset-hasla`, `app`, `www`, `mail`, `blog`, `pomoc`, `kontakt`, `salonio`. Lista siedzi w `config/reserved_slugs.php` i jest sprawdzana przy zakładaniu salonu oraz przy zmianie slugu.

## Warstwy w module

Każdy moduł ma ten sam układ:

```
Modules/Clients/
  ClientsController.php     przyjmuje żądanie, waliduje, zwraca odpowiedź
  ClientsRepository.php     zapytania SQL, zawsze z salon_id
  ClientService.php         logika, np. scalanie duplikatów, liczenie statystyk
  ClientValidator.php       reguły walidacji formularza
```

Kontroler nie pisze SQL. Repozytorium nie zna sesji ani żądania. Logika wychodzi z kontrolera do serwisu, kiedy robi się dłuższa niż kilkanaście linii.

## Czas i strefy czasowe

To jeden z dwóch miejsc, gdzie takie systemy najczęściej się sypią, więc reguła jest sztywna.

- W bazie wszystkie znaczniki czasu są w UTC, typ `DATETIME`.
- PHP i MySQL działają w UTC: `date_default_timezone_set('UTC')` oraz `SET time_zone = '+00:00'` po nawiązaniu połączenia.
- Salon ma kolumnę `timezone`, domyślnie `Europe/Warsaw`.
- Konwersja na czas lokalny następuje wyłącznie w widoku, przez helper `Clock::toSalon($utc)` i `Clock::toUtc($local)`.
- Godziny pracy i godziny otwarcia to typ `TIME` i są czasem lokalnym salonu, bo opisują zegar na ścianie, a nie punkt na osi czasu.
- Zmiana czasu: przy przejściu na czas zimowy godzina 02:00 do 03:00 występuje dwa razy, przy letnim nie występuje wcale. Konwersja lokalnego czasu na UTC musi to obsłużyć i przy niejednoznaczności wybrać wcześniejszy wariant, a przy nieistniejącej godzinie przesunąć o godzinę do przodu i pokazać ostrzeżenie.

## Pieniądze

- Kwoty w bazie: `DECIMAL(10,2)`, nigdy `FLOAT`.
- W PHP kwoty przechowywane jako grosze w typie `int` wszędzie tam, gdzie się liczy, i formatowane przy wyświetlaniu przez `Money::format()`.
- Waluta w kolumnie `currency`, domyślnie `PLN`. Na start tylko PLN, ale kolumna zostaje.

## Numery telefonów

- Zapis w formacie E.164, czyli `+48500600700`.
- Przy zapisie normalizacja: usunięcie spacji, myślników i nawiasów, dodanie `+48`, jeśli numer ma dziewięć cyfr i nie ma prefiksu.
- Kolumna `phone` trzyma format E.164, kolumna `phone_display` format przyjazny do wyświetlenia. Unikalność klientki w salonie liczy się po `phone`.

## Obsługa błędów

- Wyjątki aplikacji dziedziczą po `AppException` i niosą kod HTTP.
- Błąd 500 loguje pełny ślad do `storage/logs/error-YYYY-MM-DD.log`, a użytkownikowi pokazuje stronę z identyfikatorem zdarzenia.
- W trybie `debug` ślad jest widoczny na ekranie, a tryb ten jest domyślnie wyłączony i nie da się go włączyć z przeglądarki.
- Każdy błąd 500 powiększa licznik widoczny w `/admin/health`.

## Wydajność

Na hostingu współdzielonym liczy się liczba zapytań, nie mikrooptymalizacje.

- Kalendarz tygodniowy pobiera wizyty jednym zapytaniem z `JOIN`, nie po jednym na dzień.
- Lista klientek stronicowana po 50, wyszukiwanie po indeksie na `salon_id, last_name` oraz `salon_id, phone`.
- Statystyki liczone zapytaniami agregującymi z zakresem dat, bez pętli w PHP.
- Cache w plikach dla rzeczy rzadko zmiennych: lista usług, ustawienia salonu, katalog uprawnień. Klucz cache zawiera `salon_id` i znacznik `updated_at`.
- Docelowy budżet: strona panelu poniżej 300 ms i poniżej 15 zapytań do bazy.
