This commit is contained in:
2026-05-06 23:16:47 +02:00
parent ea77c8ea35
commit c4a485e530
23 changed files with 2141 additions and 1772 deletions

View File

@@ -0,0 +1,325 @@
---
phase: 09-finalizacja
plan: 02
type: execute
wave: 1
depends_on: ["09-01"]
files_modified:
- wp-content/plugins/yacht-booking-system/integrations/ical/class-ical-feed.php
- wp-content/plugins/yacht-booking-system/integrations/ical/class-ical-import.php
- wp-content/plugins/yacht-booking-system/admin/class-admin.php
- wp-content/plugins/yacht-booking-system/admin/views/settings.php
- wp-content/plugins/yacht-booking-system/includes/class-yacht.php
- wp-content/plugins/yacht-booking-system/admin/views/yacht-edit.php
- wp-content/plugins/yacht-booking-system/includes/class-installer.php
autonomous: false
delegation: off
---
<objective>
## Goal
Wprowadzić dwukierunkową synchronizację z **jednym wspólnym Google Calendar** (przez iCal feed) z rozróżnieniem rezerwacji per jacht po prefiksie w tytule eventu (`"{nazwa_jachtu} - {tekst}"`). Frontend kalendarz dla danego jachtu pokazuje tylko jego rezerwacje.
## Purpose
Klient (właściciel wypożyczalni) chce zarządzać rezerwacjami wszystkich jachtów w jednym Google Calendar — jednym widokiem floty. Tworzy ręcznie eventy w GCal (np. blokady, rezerwacje telefoniczne) używając prefiksu z nazwą jachtu, a strona automatycznie przypisuje je do właściwego jachtu. Jednocześnie rezerwacje złożone przez frontend trafiają z powrotem do tego samego kalendarza w spójnym formacie.
## Output
- Globalny iCal Export feed (jeden URL z tokenem) — zawiera rezerwacje wszystkich jachtów z prefiksem nazwy jachtu w SUMMARY
- Globalny iCal Import URL — parser dopasowuje prefiks do jachtu, importuje tylko rozpoznane eventy
- Settings page — pola dla globalnego import URL i wyświetlanie globalnego export URL
- Pole `_yacht_gcal_alias` (opcjonalny alias dla skróconej nazwy w GCal)
- Per-yacht feedy iCal pozostają sprawne (kompatybilność wsteczna)
</objective>
<context>
## Project Context
@.paul/PROJECT.md
@.paul/ROADMAP.md
@.paul/STATE.md
@.paul/codebase/architecture.md
## Source Files
@wp-content/plugins/yacht-booking-system/integrations/ical/class-ical-feed.php
@wp-content/plugins/yacht-booking-system/integrations/ical/class-ical-import.php
@wp-content/plugins/yacht-booking-system/admin/class-admin.php
@wp-content/plugins/yacht-booking-system/includes/class-yacht.php
@wp-content/plugins/yacht-booking-system/includes/class-settings.php
<clarifications>
- **Mechanizm** — iCal feed (publiczny URL Google Calendar) zamiast OAuth API. Powód: klient nie chce przechodzić przez OAuth, woli dodać URL i mieć synchronizację automatyczną.
→ Odpowiedź: dwukierunkowo przez iCal — import z GCal feedu + export własnego feedu (admin subskrybuje go w GCal).
- **Identyfikacja jachtu** — po prefiksie w tytule eventu, separator `" - "`. Przykład: `"Maja - Kowalski 5 osób"`, `"Kubuś - blokada serwisowa"`.
→ Odpowiedź: parser wyciąga tekst przed pierwszym `" - "`, dopasowuje case-insensitive do `post_title` jachtu lub `_yacht_gcal_alias` (jeśli ustawiony — ma priorytet).
- **Eventy bez rozpoznanego jachtu** — ignorowane.
→ Odpowiedź: jeśli prefiks nie pasuje do żadnego jachtu (lub brak separatora `" - "`), event pomijany w imporcie. Brak fallbacku na "blokuj wszystkie" — klient nie chce niespodzianek.
- **Push do GCal** — przez ten sam globalny feed iCal (admin subskrybuje feed pluginu w GCal).
→ Odpowiedź: rezerwacje ze strony pojawiają się w GCal po następnej refresh subskrypcji (Google odświeża iCal subscription co kilka godzin — limit Google'a, nie pluginu).
- **Frontend filtrowanie** — kalendarz per jacht pokazuje tylko eventy danego jachtu.
→ Odpowiedź: bez zmian w widgecie — działa już teraz przez `_booking_yacht_id`. Wystarczy że import poprawnie przypisze yacht_id.
</clarifications>
</context>
<acceptance_criteria>
## AC-1: Globalny iCal Export feed
```gherkin
Given admin ma kilka rezerwacji w bazie dla różnych jachtów (np. "Maja", "Kubuś")
When admin otwiera URL `/yacht-ical-global/{token}.ics`
Then odpowiedź ma Content-Type `text/calendar`
And feed zawiera VEVENT dla każdej niezanullowanej rezerwacji wszystkich jachtów
And SUMMARY każdego eventu ma format `"{nazwa_jachtu} - {imię_klienta}"` (lub z prefiksem `[Oczekująca]` gdy status pending)
And UID jest stabilny (`booking-{id}@{domain}`) kolejne fetche zwracają ten sam UID
```
## AC-2: Globalny iCal Import — dopasowanie po prefiksie
```gherkin
Given globalny iCal Import URL jest ustawiony w Settings
And kalendarz Google zawiera event "Maja - Test rezerwacja" (jacht "Maja" istnieje w bazie)
And kalendarz Google zawiera event "Nieznany - cokolwiek" (brak takiego jachtu)
And kalendarz Google zawiera event "Brak separatora" (bez " - ")
When uruchamiany jest cron `yacht_booking_ical_import` (lub manualny przycisk)
Then plugin tworzy/aktualizuje booking dla jachtu "Maja" z source `ical_import_global`
And blokuje dostępność `wp_yacht_availability` tylko dla yacht_id jachtu "Maja"
And event "Nieznany" jest pominięty (logowane jako info, brak booking)
And event "Brak separatora" jest pominięty
And kolejny przebieg (nawet bez zmian w GCal) nie tworzy duplikatów (idempotencja po UID)
```
## AC-3: Globalny Import — czyszczenie usuniętych eventów
```gherkin
Given event "Maja - Foo" został zaimportowany przy poprzednim cron'ie
When admin usuwa go z Google Calendar
And uruchamia się kolejny cron import
Then powiązany booking (z source `ical_import_global` i tym UID) jest usuwany
And `Availability::clear_booking_availability()` zwalnia daty
```
## AC-4: Alias jachtu (opcjonalny)
```gherkin
Given jacht ma `post_title="Marina Maja Sailing 35"` i `_yacht_gcal_alias="Maja"`
And event w GCal ma SUMMARY "Maja - Klient X"
When uruchamia się import
Then plugin dopasowuje event do tego jachtu po aliasie
And tworzy booking z `_booking_yacht_id` = ID tego jachtu
```
## AC-5: Frontend filtrowanie (regresja)
```gherkin
Given booking dla jachtu "Maja" zaimportowany przez globalny iCal
When klient otwiera stronę z widgetem `[yacht_calendar yacht_id=ID_MAJA]`
Then kalendarz pokazuje datę rezerwacji jako zablokowaną
And widget jachtu "Kubuś" NIE pokazuje tej daty jako zablokowanej
```
## AC-6: Settings UI
```gherkin
Given admin otwiera Settings zakładka Google Calendar / iCal
When widzi sekcję "Globalna synchronizacja iCal"
Then widzi pole "iCal Import URL" (input type=url) globalny URL kalendarza Google
And widzi pole tylko-do-odczytu "iCal Export URL" z linkiem `/yacht-ical-global/{token}.ics` i przyciskiem "Kopiuj"
And widzi przycisk "Wygeneruj nowy token" (regeneruje token, unieważniając poprzedni URL)
And widzi przycisk "Importuj teraz" (manual trigger run_global_import)
And w yacht-edit jest nowe pole "Alias dla Google Calendar" (placeholder: nazwa jachtu) opcjonalne
```
</acceptance_criteria>
<tasks>
<task type="auto">
<name>Task 1: Globalny iCal Export feed + alias jachtu</name>
<files>
wp-content/plugins/yacht-booking-system/integrations/ical/class-ical-feed.php,
wp-content/plugins/yacht-booking-system/includes/class-yacht.php,
wp-content/plugins/yacht-booking-system/includes/class-installer.php
</files>
<action>
1. W `class-ical-feed.php` dodaj nową ścieżkę feed:
- rewrite rule `^yacht-ical-global/([a-zA-Z0-9]+)\.ics$``index.php?yacht_ical_global=1&yacht_ical_token=$matches[1]`
- query var `yacht_ical_global`
- handler `handle_global_feed_request()` weryfikuje token przez `hash_equals` względem opcji `yacht_booking_global_ical_token`
- metoda `output_global_ics()`: pobiera WSZYSTKIE bookingi (nie filtrowane po yacht), iteruje, dla każdego ustala SUMMARY = `"{nazwa_jachtu} - {imię_klienta}"` (analogicznie do istniejącego `output_ics`, ale z prefiksem nazwy jachtu z `Yacht::get_post_title( yacht_id )` zamiast tylko klienta)
- skip bookingi ze statusem `cancelled` oraz z source `ical_import_global` (nie wysyłaj z powrotem do Google tego co stamtąd przyszło — anti-loop)
- UID format: `booking-{id}@{domain}` (taki sam jak per-yacht — zachować stabilność)
- X-WR-CALNAME: `"Wszystkie jachty - " . get_bloginfo('name')`
2. Helper `get_global_feed_token()` — generuje/zwraca token z opcji, `regenerate_global_token()` — nadpisuje token.
3. Helper `get_global_feed_url()` — zwraca pełny URL.
4. W `class-yacht.php` dodaj akcesory:
- `get_gcal_alias( $yacht_id )``get_post_meta( $yacht_id, '_yacht_gcal_alias', true )`
- `update_gcal_alias( $yacht_id, $alias )` → sanitize_text_field + update_post_meta
5. W `class-installer.php` (jeśli wersja schematu się ewoluuje) dodaj domyślne opcje:
- `add_option( 'yacht_booking_global_ical_import_url', '' )`
- `add_option( 'yacht_booking_global_ical_token', '' )` (token generowany lazy przy pierwszym fetch URL)
- flush_rewrite_rules przy aktywacji (rewrite rule).
Avoid: nie modyfikuj istniejących per-yacht feedów — działają jako fallback i są używane przez OTA/iCal subscribers innych systemów.
</action>
<verify>
- `php -l class-ical-feed.php` (syntax OK)
- W przeglądarce: po zapisie pluginu i flush rewrite, URL `/yacht-ical-global/{token}.ics` zwraca .ics z eventami wszystkich jachtów, każdy z prefiksem nazwy jachtu
- `curl /yacht-ical-global/{token}.ics | grep "SUMMARY"` zawiera np. `SUMMARY:Maja - Kowalski`
- URL z błędnym tokenem zwraca 403
</verify>
<done>AC-1, AC-4 (część dot. aliasu — akcesory) zaspokojone</done>
</task>
<task type="auto">
<name>Task 2: Globalny iCal Import + parser prefiksu</name>
<files>
wp-content/plugins/yacht-booking-system/integrations/ical/class-ical-import.php
</files>
<action>
1. Dodaj stałą `const GLOBAL_IMPORT_SOURCE = 'ical_import_global';` (osobna od per-yacht `IMPORT_SOURCE = 'ical_import'`).
2. Nowa metoda statyczna `run_global_import()`:
- Pobiera URL z `get_option( 'yacht_booking_global_ical_import_url' )`
- Jeśli pusty — return false
- `wp_remote_get` z timeout 30s
- parse_ics (re-use istniejącej metody, jest prywatna — wystawić jako protected lub wynieść do helpera; preferowane: zmienić visibility na `protected` lub utworzyć publiczną wrapping)
- Pre-fetch: lista jachtów + mapa `lowercase_name_or_alias → yacht_id`. Budowa: dla każdego jachtu klucz = `_yacht_gcal_alias` (jeśli != '') albo `post_title`, wszystko `mb_strtolower` + trim.
- Pre-fetch: `existing_global_map` — bookingi z source `GLOBAL_IMPORT_SOURCE`, mapa `uid → booking_id` (analogicznie do `get_existing_import_map` ale bez filtrowania po yacht).
- Iteracja po eventach:
a. Skip jeśli brak uid/start/end lub event w przeszłości (`strtotime($event['end']) < time()`)
b. Parse SUMMARY: `$pos = mb_strpos( $summary, ' - ' )`. Jeśli `$pos === false` → log "skip: no separator", continue.
c. `$prefix = mb_strtolower( trim( mb_substr( $summary, 0, $pos ) ) )`
d. Lookup w mapie. Jeśli brak → log "skip: yacht not found for prefix '{prefix}'", continue.
e. Wywołaj `upsert_global_booking( $yacht_id, $event, $existing_id )` (nowa metoda — jak `upsert_booking` ale z `_booking_source = GLOBAL_IMPORT_SOURCE`, `_booking_customer_name = "Google Calendar (import)"`, post_title `"GCal: {summary}"`).
f. Dodaj uid do `$seen_uids`.
- Po pętli: stale cleanup — dla każdego `$uid => $booking_id` w `$existing_global_map` którego nie ma w `$seen_uids``Availability::clear_booking_availability($booking_id)` + `wp_delete_post($booking_id, true)`.
- `update_option( 'yacht_booking_global_ical_last_import', current_time('mysql') )`.
- return true.
3. Hook cron: w `register()` dodać `add_action( 'yacht_booking_ical_global_import', array( __CLASS__, 'run_global_import' ) )` i w `setup_cron()` zarejestruj hourly.
4. Hook do uruchomienia obu importów (per-yacht + global) z jednego cron tick'a — nie zmieniać istniejącego, dodać osobny `wp_schedule_event` dla `yacht_booking_ical_global_import`.
Avoid:
- Nie usuwać per-yacht importu (kompatybilność)
- Nie używać sztywno `strpos` po bajtach — używać `mb_*` (polskie znaki w nazwach jachtów: "Kubuś", "Maja Słoneczna")
- Nie matchować po fragmentach — tylko exact equality po lower-case (inaczej "Maja" pasuje do "Maja Słoneczna" jednocześnie)
</action>
<verify>
- `php -l class-ical-import.php`
- Test ręczny: stworzyć w Google Calendar event "Maja - test", subskrybować feed Google iCal URL w settings, kliknąć "Importuj teraz", sprawdzić że w admin → Bookings pojawia się booking przypisany do jachtu "Maja"
- Event "FooBar - test" (brak takiego jachtu) — nie tworzy bookingu, w error log linia "skip: yacht not found"
- Event "BezSeparatora" — nie tworzy bookingu
- Drugi cron tick — brak duplikatu
- Usunięcie eventu w GCal + cron → booking zniknął, daty zwolnione w `wp_yacht_availability`
</verify>
<done>AC-2, AC-3, AC-4 (matching po aliasie) zaspokojone</done>
</task>
<task type="auto">
<name>Task 3: Settings UI + yacht-edit alias + admin actions</name>
<files>
wp-content/plugins/yacht-booking-system/admin/class-admin.php,
wp-content/plugins/yacht-booking-system/admin/views/settings.php,
wp-content/plugins/yacht-booking-system/admin/views/yacht-edit.php
</files>
<action>
1. `settings.php` — dodaj sekcję "Globalna synchronizacja iCal":
- Pole `yacht_booking_global_ical_import_url` (input type=url, full width)
- Read-only display: globalny export URL (`ICal_Feed::get_global_feed_url()`) — input readonly + przycisk "Kopiuj" (JS clipboard)
- Przycisk "Wygeneruj nowy token" (form POST z nonce + action `regenerate_global_ical_token`)
- Przycisk "Importuj teraz" (form POST z nonce + action `run_global_ical_import`)
- Wyświetl `yacht_booking_global_ical_last_import` ("Ostatni import: ...")
- Krótka instrukcja: "Skopiuj Export URL i dodaj jako 'Z URL-a' w Google Calendar (Inne kalendarze → Z URL). Eventy w Google nazywaj wg wzorca: 'Nazwa jachtu - opis'. Wklej iCal URL kalendarza Google poniżej."
2. `class-admin.php` — w `process_settings_save()` (lub equivalent) zapisz nowe pole.
3. `class-admin.php``process_booking_actions()` (lub nowy `process_ical_actions()`):
- obsłuż `regenerate_global_ical_token``ICal_Feed::regenerate_global_token()` + `?notice=token_regenerated` redirect
- obsłuż `run_global_ical_import``ICal_Import::run_global_import()` + `?notice=import_done` redirect
4. `yacht-edit.php` — dodaj wiersz tabeli "Alias dla Google Calendar":
- input text `yacht_gcal_alias`, value = `Yacht::get_gcal_alias( $yacht_id )`
- Description: "Opcjonalny krótki alias używany w tytule eventu Google Calendar (np. 'Maja' zamiast 'Marina Maja Sailing 35'). Jeśli puste — używana jest pełna nazwa jachtu."
5. `class-admin.php::process_yacht_save()` — zapisz `yacht_gcal_alias` przez `Yacht::update_gcal_alias()`.
6. Dodaj admin notices dla token_regenerated i import_done.
Avoid:
- Nie usuwaj istniejących pól per-yacht ("Google Calendar ID" i "iCal Import URL") — out of scope. Klient zdecyduje w kolejnym planie czy je sprzątać.
- Nie dotykaj OAuth UI (sekcja Google Calendar OAuth zostaje bez zmian).
</action>
<verify>
- `php -l` na każdym zmienionym pliku
- W panelu admin → Settings: nowa sekcja jest widoczna, zapis URL działa, "Wygeneruj token" zmienia URL, "Importuj teraz" wywołuje import (sprawdzalne w error log lub bookingach)
- W yacht-edit: pole alias zapisuje się i odczytuje
- Token w URL ma >=20 znaków, zmiana tokenu unieważnia stary URL (403)
</verify>
<done>AC-6 zaspokojone, AC-4 (UI dla aliasu) zaspokojone</done>
</task>
<task type="checkpoint:human-verify" gate="blocking">
<what-built>
Dwukierunkowa synchronizacja iCal z jednym wspólnym Google Calendar:
- Globalny export feed (admin subskrybuje w GCal)
- Globalny import URL (plugin pobiera kalendarz Google)
- Identyfikacja jachtu po prefiksie "Nazwa - opis" w tytule eventu
- Frontend per-jacht pokazuje tylko swoje eventy
</what-built>
<how-to-verify>
1. Settings → skopiuj Export URL.
2. W Google Calendar → "Inne kalendarze" → "Z URL-a" → wklej Export URL → potwierdź że pojawiają się rezerwacje wszystkich jachtów z prefiksem nazwy.
3. W Google Calendar (głównym admina) utwórz event ręczny: tytuł "Maja - Test ręcznie", data za tydzień.
4. Skopiuj URL iCal swojego głównego kalendarza Google (Ustawienia kalendarza → Tajny adres w formacie iCal).
5. Wklej do Settings pluginu → "iCal Import URL".
6. Kliknij "Importuj teraz".
7. Sprawdź: WP Admin → Rezerwacje → pojawia się booking "GCal: Maja - Test ręcznie" przypisany do jachtu "Maja".
8. Otwórz frontend kalendarz jachtu Maja — data zablokowana. Otwórz kalendarz innego jachtu — data wolna.
9. Usuń event w Google → uruchom "Importuj teraz" ponownie → booking znika.
10. Stwórz w GCal "Nieznany - test" → import → brak bookingu (sprawdź WP error log: "skip: yacht not found").
</how-to-verify>
<resume-signal>Wpisz "approved" gdy wszystkie 10 punktów weryfikacji przejdzie, lub opisz problemy do poprawy</resume-signal>
</task>
</tasks>
<boundaries>
## DO NOT CHANGE
- `class-availability.php` (logika availability stabilna)
- `class-booking.php` (CPT booking — nie ruszamy)
- `wp_yacht_availability` schema (zablokowane)
- `frontend/class-calendar-widget.php` i `frontend/class-shortcode.php` (widget renderingu — działa już per-yacht)
- `class-rest-controller.php` (REST endpointy — bez zmian)
- OAuth integration (`class-oauth-handler.php`, `class-gcal-service.php`, `class-sync-controller.php`) — out of scope tego planu, push przez OAuth zostaje na razie jak jest (drugi mechanizm; klient użyje iCal a OAuth zignoruje, lub w kolejnym planie wyłączymy)
- Per-yacht iCal feed (`output_ics`) i per-yacht import (`run_import` + `import_for_yacht`) — pozostają funkcjonalne dla kompatybilności wstecznej
## SCOPE LIMITS
- Plan NIE usuwa pól per-yacht ("Google Calendar ID", "iCal Import URL") z yacht-edit — to decyzja na osobny plan po pozytywnej weryfikacji nowej globalnej synchronizacji
- Plan NIE wyłącza OAuth push do GCal — drugi (równoległy) mechanizm; jeśli klient nie połączy OAuth, nie jest aktywny
- Brak nowych zależności (PHP libs, npm, CDN)
- Brak zmian w bazie poza dodaniem `wp_options` keys i opcjonalnym yacht meta `_yacht_gcal_alias`
- Brak tłumaczeń .pot w tym planie (Phase 9 osobno)
</boundaries>
<verification>
Przed deklaracją ukończenia planu:
- [ ] `php -l` na wszystkich zmodyfikowanych plikach przechodzi
- [ ] Globalny export URL zwraca poprawny .ics z prefiksami nazw jachtów
- [ ] Globalny import URL parsuje eventy z GCal i przypisuje do jachtów po prefiksie
- [ ] Eventy bez separatora lub bez dopasowanego jachtu są ignorowane (logowane)
- [ ] Idempotencja: wielokrotne uruchomienie importu nie tworzy duplikatów
- [ ] Stale cleanup: usunięcie eventu w GCal + import → booking znika
- [ ] Frontend kalendarz per-jacht pokazuje tylko swoje rezerwacje (regresja)
- [ ] Per-yacht feedy iCal nadal działają (nie zostały zepsute)
- [ ] Settings UI: zapis URL, regeneracja tokenu, manual import, alias jachtu działają
- [ ] Wszystkie acceptance criteria spełnione
- [ ] Checkpoint human-verify zaakceptowany przez klienta
</verification>
<success_criteria>
- Klient może zarządzać rezerwacjami całej floty w jednym Google Calendar
- Eventy ręczne w GCal z prefiksem "Nazwa jachtu - ..." automatycznie pojawiają się jako blokady na frontendzie tego jachtu
- Rezerwacje ze strony pojawiają się w GCal admina (po refresh subskrypcji Google)
- Każdy frontend kalendarz pokazuje tylko swoje rezerwacje
- Zachowana kompatybilność wsteczna (per-yacht feedy + OAuth push nadal działają)
</success_criteria>
<output>
Po ukończeniu utwórz `.paul/phases/09-finalizacja/09-02-SUMMARY.md` zawierający:
- Co zbudowano (export feed, import, settings UI, alias)
- Decyzje (separator " - ", lookup case-insensitive po post_title/alias, ignore unmatched)
- Otwarte pytania na kolejny plan (czy usuwać pola per-yacht? czy wyłączać OAuth push?)
- Ścieżkę testową odtworzeniową
</output>