Przejdź do głównej zawartości

Powiadomienia systemowe (Automatyczne)

Zestawienie wszystkich sytuacji, w których system samodzielnie (bez Twojej ingerencji) komunikuje się z klientem za pomocą e-maili i SMS-ów.

👤 Instrukcja dla pracownika (Recepcja / Administracja)

System AcePark realizuje proces zautomatyzowanego monitorowania terminów oraz statusów kont klienckich. Optymalizuje to pracę administracyjną, redukując konieczność ręcznego weryfikowania i powiadamiania uczestników. Poniżej przedstawiono wykaz scenariuszy, w których komunikacja wychodząca (e-mail, SMS) generowana jest całkowicie przez system.

Znajomość poniższych mechanizmów stanowi wsparcie podczas udzielania odpowiedzi na standardowe pytania klientów (np. dotyczące braku potwierdzeń czy trybu postępowania przy zaległościach finansowych).

Powiadomienia Transakcyjne (Generowane w czasie rzeczywistym)

Wiadomości wysyłane są bezzwłocznie w odpowiedzi na określoną akcję w systemie:

  1. Rejestracja na zajęcia cykliczne (grupowe) — dodanie przez pracownika
    • Wyzwalacz: Przypisanie klienta do grupy przez pracownika.
    • Zawartość komunikatu: E-mail powitalny zawierający szczegóły organizacyjne (termin, kort, trener), instrukcje przygotowawcze oraz regulamin (m.in. wymóg regulowania opłat do 7. dnia danego miesiąca).
  2. Zapis na zajęcia stałe przyjęty — oczekiwanie na płatność
    • Wyzwalacz: Samodzielny zapis klienta, w momencie utworzenia płatności za pierwszy miesiąc.
    • Zawartość komunikatu: Wyłącznie e-mail — informacja, że miejsce jest trzymane, kwota do zapłaty i przycisk do Portalu Klienta. Bez SMS-a, żeby nie dublować potwierdzenia, które przychodzi kilka minut później.
    • Uwaga dla recepcji: Klient, który dzwoni z tym e-mailem, nie ma jeszcze opłaconego zapisu. Miejsce jest zajęte, ale płatność wisi jako oczekująca w zakładce Płatności.
  3. Potwierdzenie zapisu na zajęcia stałe — zapis samodzielny przez klienta
    • Wyzwalacz: Zaksięgowanie płatności za pierwszy miesiąc, a nie sam moment kliknięcia „Zapisz się".
    • Zawartość komunikatu: E-mail i SMS potwierdzające jednocześnie płatność i zapis na cały cykl (grupa, data i godzina startu, trener, kort, kwota i liczba opłaconych zajęć).
    • Dlaczego tak: Klient, który przerwie płatność, nie dostanie potwierdzenia „jesteś zapisany" — dostaje wcześniejszy e-mail z punktu 2 i nic więcej. Zapis dodany ręcznie przez recepcję działa jak dotąd (punkt 1).
  4. Potwierdzenie zapisu na zajęcia próbne
    • Wyzwalacz: Zaksięgowanie płatności za zajęcia próbne (rejestracja z landing page lub Portalu Klienta).
    • Zawartość komunikatu: E-mail i SMS z terminem, kortem, adresem, trenerem i opłaconą kwotą oraz wskazówkami na pierwsze zajęcia.
    • Uwaga: Jeśli rodzic zapisał w jednym kroku kilkoro dzieci na ten sam termin, dostaje jedną wiadomość z listą uczestników. Różne terminy oznaczają osobne wiadomości.
  5. Anulowanie zajęć przez klienta
    • Wyzwalacz: Usunięcie obecności z poziomu aplikacji klienckiej.
    • Zawartość komunikatu: E-mail potwierdzający rezygnację z określonego terminu oraz instrukcja procedury wykorzystania zajęć "Do odrobienia".
  6. Zapis na termin odrabiania
    • Wyzwalacz: Rejestracja uczestnika posiadającego niewykorzystane zajęcia w nowej, otwartej grupie.
    • Zawartość komunikatu: E-mail z formalnym potwierdzeniem jednorazowego terminu odrabiania zajęć.

Powiadomienia Harmonogramowe (Generowane cyklicznie)

Wiadomości dystrybuowane na podstawie algorytmów weryfikujących warunki o ściśle określonych porach doby:

  1. Przypomnienie o dokończeniu zapisu na próbne
    • Wyzwalacz: Co 15 minut system szuka rejestracji, w których klient potwierdził i e-mail, i numer telefonu, po czym przerwał — domyślnie 30 minut od ostatniej aktywności (do zmiany w panelu, bez wdrożenia).
    • Zawartość komunikatu: E-mail „Jeden krok dzieli Cię od zapisu" z linkiem, który wraca do formularza w miejscu, w którym klient skończył — nie musi wpisywać danych od nowa.
    • Wysyłane raz. Klient, który zignoruje ten e-mail, nie dostanie drugiego; zostaje zgłoszenie na tablicy zadań i telefon z recepcji.
    • Uwaga dla recepcji: to inny przypadek niż „płatność wygasła". Tutaj klient nie doszedł nawet do wyboru terminu — nie ma zapisu ani płatności, są tylko jego dane kontaktowe.
  2. Powiadomienie przed zajęciami próbnymi
    • Wyzwalacz: Codziennie o 9:00 czasu polskiego, weryfikacja klientów zarejestrowanych na zajęcia próbne w dniu następnym.
    • Zawartość komunikatu: Standardowy komunikat e-mail pełniący funkcję informacyjno-przypominającą (zalecenia dotyczące stroju sportowego, obuwiu oraz przybyciu przed czasem).
  3. Powiadomienie przed wynajmem kortu
    • Wyzwalacz: Zależnie od konfiguracji lokalnej, np. 24h przed planowaną rezerwacją.
    • Zawartość komunikatu: Przypomnienie e-mail/SMS kierowane do osoby dokonującej rezerwacji.
  4. Upomnienie o braku płatności (1 dzień po terminie)
    • Wyzwalacz: 8. dzień miesiąca w godzinach porannych (przy założeniu wymagalności płatności do 7. dnia).
    • Zawartość komunikatu: Oficjalny e-mail informujący o przekroczeniu terminu płatności z instrukcją opłacenia należności przez portal oraz informacją o potencjalnej utracie możliwości odrabiania zajęć.
  5. Ostateczne wezwanie do zapłaty (7 dni po terminie)
    • Wyzwalacz: 14. dzień miesiąca w godzinach porannych.
    • Zawartość komunikatu: Ostateczne wezwanie w formie wiadomości e-mail oraz powiadomienia SMS na zarejestrowany numer telefonu. Komunikat zawiera informację o natychmiastowym wstrzymaniu możliwości udziału w zajęciach do momentu uregulowania należności.
  6. Wezwanie do wystawienia opinii (Camp Feedback)
    • Wyzwalacz: Ustaloną liczbę dni po zakończeniu turnusu (domyślnie: 1 dzień).
    • Zawartość komunikatu: Wiadomość e-mail z anonimowym linkiem do ankiety oceniającej półkolonie (w skali 1-5). Istnieje możliwość wygenerowania tego wezwania ręcznie z poziomu karty turnusu w przypadku awarii wysyłki automatycznej.

🛠️ Dokumentacja techniczna

Szczegóły funkcjonowania kolejek powiadomień i harmonogramów. Przeznaczone do testów i debugowania (QA / Devs).

✅ Potwierdzenia zapisu po zaksięgowaniu płatności

Dwa typy powiadomień wychodzą wyłącznie z webhooka Przelewy24 (app/api/payments/webhook/route.ts), po zweryfikowaniu transakcji:

Typpayment_typeFunkcja wysyłającaSzablon lokalny
trial_registration_confirmedtrialsendTrialRegistrationConfirmations()trial-registration-confirmation.html
regular_enrollment_confirmedschoolsendRegularEnrollmentConfirmations()regular-enrollment-confirmation.html

Obie funkcje żyją w lib/notifications.ts obok sendBookingPaymentSuccessNotifications() i przyjmują bazę danych parametrem, bo webhook działa poza kontekstem Next.js.

Jeden mail na jeden zapis na próbne

Dla użytkownika. Po opłaceniu zajęć próbnych klient dostaje jedną wiadomość — „Zapis potwierdzony!". Znajdzie w niej termin, kort, trenera, kwotę, kod do bramki oraz adres podlinkowany do Map Google, a pod spodem praktyczne wskazówki przed pierwszym treningiem (co zabrać, kiedy przyjść, czego się spodziewać).

Dla developera. Do 24.08.2026 ten sam moment obsługiwały dwa typy powiadomień:

  • trial_registration_confirmed — z webhooka P24, przez sendTrialRegistrationConfirmations(),
  • first_assignment — z sendFirstGameMail(), wołanego z handleSkillAssessmentEnrollment() (lib/actions/payment.ts) oraz prepareSkillAssignmentCompletionDBTasks() (lib/actions/game.ts), czyli z tej samej ścieżki domykania płatności, tylko o warstwę niżej.

Klient dostawał więc dwa maile na jeden zapis. first_assignment został wycofany: wysyłka i funkcja usunięte, wpis w notification-config.ts i szablon first-assignment.html skasowane, a migracja 0239 czyści wiersze w notification_types / notification_texts u wszystkich najemców. Jego unikalna treść (dojazd, co zabrać, dobrze wiedzieć) została przeniesiona do trial-registration-confirmation.html.

Adres w potwierdzeniu jest linkiem generowanym z danych kortu — mapUrl i addressLine powstają w deliverTrialRegistrationConfirmations() z court.street i court.city, zamiast dawnej mapy trzech miast zaszytej w kodzie. Nowa lokalizacja działa więc bez zmiany kodu.

:::caution Szablony HTML są kopią, nie źródłem wysyłki Maile wychodzą z dynamicznych szablonów SendGrid (templateId, np. d-d0bb82ce…). Pliki w /mails służą podglądowi w panelu i historii zmian — edycja pliku nie zmienia tego, co dostaje klient, dopóki treść nie zostanie wklejona do szablonu w SendGrid. :::

📨 Przypomnienie o porzuconej rejestracji

trial_registration_abandoned wychodzi z sendTrialRegistrationReminders() (lib/trial-registration-reminder.ts), podpiętego pod istniejący cron */15 * * * * obok zaproszeń po próbnych — nowy trigger nie był potrzebny.

Kwalifikacja draftu wymaga obu weryfikacji (email_verified_at i phone_verified_at), braku completed_at, nieprzeterminowanego expires_at oraz ciszy dłuższej niż afterMinutes. Górny limit wieku (maxAgeDays) istnieje po to, żeby pierwszy przebieg po wdrożeniu nie wysłał maila do każdej niedokończonej rejestracji w historii. Oba parametry siedzą w wierszu notification_crons i zmienia się je z panelu.

reminder_sent_at jest stemplowane przed wysyłką, nie po niej. Worker, który padnie w trakcie, zostawiłby inaczej draft kwalifikujący się w kolejnym tiku — a klient dostający to samo przypomnienie co 15 minut to gorsza awaria niż klient, który je przegapi. Rolę zapasową pełni zgłoszenie na tablicy zadań (lib/abandoned-registration.ts), które istnieje niezależnie.

Link powrotny. Draft żyje za ciasteczkiem httpOnly, więc sam adres landing page nic by nie wznowił — otwarcie maila na telefonie po rozpoczęciu na laptopie zaczynałoby formularz od zera. Route app/register/continue/[token]/route.ts wymienia token draftu na to ciasteczko i przekierowuje do /register/{link_token}. Token jest poświadczeniem na okaziciela dokładnie jak w /continue/[token] po zajęciach próbnych: 128 bitów losowości, związany z jednym draftem, wygasa razem z nim i trafia wyłącznie na adres, który ten sam draft wcześniej zweryfikował. Nieznany albo przeterminowany token kończy się przekierowaniem na stronę główną, nie błędem.

Parametr resume=1. Przekierowanie z route'u niesie ten znacznik i bez niego link z maila nie potrafił dowieźć nikogo do kreatora — landing page odrzucała powracających na dwa niezależne sposoby:

  • Zapisy bez linku kampanijnego. Draft rozpoczęty na /auth/register wisi pod wewnętrznym linkiem is_organic = 1, a resolveTrialRegistrationLink() takie wiersze filtruje — celowo, bo w panelu nie mają czego szukać. Powrót kończył się ekranem „Nieprawidłowy link". Przy resume=1 resolver dostaje allowOrganic i wpuszcza je; każde inne wejście widzi je nadal jako not_found.
  • Zalogowany klient. Konto powstaje już przy weryfikacji SMS, czyli przed krokami terminu i płatności, które przypomnienie ściga. Każdy adresat tego maila ma więc konto, a strona z zasady odsyła zalogowanego do aplikacji zamiast do kreatora (page.tsx) i nie czyta ciasteczka draftu (registration-entry-client.tsx). Na ścieżce powrotu oba te zabezpieczenia ustępują draftowi — poza nią działają bez zmian.

Wejście z resume=1 nie liczy się też jako visit w statystykach linku: powrót to nie jest ruch, który kampania zdobyła.

⏳ Komunikat o oczekiwaniu na płatność

regular_enrollment_pending_payment wychodzi z enrollPlayerInSeries() (lib/recurring-enrollment.ts) zaraz po wyliczeniu należności za pierwszy miesiąc — tylko wtedy, gdy faktycznie powstała jakaś płatność. Seria bezpłatna potwierdza się sama w addPlayerToRecurringSeries(), więc kierowanie klienta do nieistniejącego rachunku byłoby błędem.

Treść zależy od tego, czy miejsce ma termin wygaśnięcia. addPlayerToRecurringSeries() ustawia payment_expires_at (i znacznik draft: true na uczestniku), gdy spełnione są wszystkie trzy warunki:

  1. rola w sesji to CLIENT — zapis samodzielny, nie dodanie przez recepcję,
  2. price > 0,
  3. payment_due_type = 'immediate' na typie aktywności.

Okno to booking_settings.online_payment_time dla lokalizacji kortu (domyślnie 10 minut). Termin trafia do powiadomienia jako paymentDeadline i pojawia się w treści; przy typach płatnych później pole jest puste i zdanie o wygaśnięciu się nie renderuje.

Po upływie okna expirePendingPayments (cron */5 * * * *) zwalnia miejsce, dopisuje attendee_expired do historii zajęć i wysyła payment_expired — ta ścieżka obejmuje school i trial na równi.

Kanał: wyłącznie e-mail (sms_enabled = 0), tak jak miał zastępowany player_added_to_recurring_series. Potwierdzenie po płatności przychodzi kilka minut później na e-mailu i SMS-ie; SMS w obu miejscach czytałby się jak duplikat.

Wysyłka idzie przez sendNotificationInBackground(), bo klient jest w tym momencie przekierowywany do bramki płatniczej i komunikat nie może stać przed tym przekierowaniem. Błąd wysyłki jest łapany i logowany — nigdy nie wywraca samego zapisu.

Grupowanie wiadomości. Jedna transakcja potrafi objąć wiele wierszy w tabeli payment, więc przed wysyłką są składane w grupy — inaczej klient dostałby SMS za każdy wiersz:

  • Próbne — klucz user_email + game_id. Rodzeństwo zapisane na ten sam termin dostaje jedną wiadomość z listą imion; różne terminy to osobne wiadomości, bo jedna godzina w treści wprowadzałaby w błąd co do pozostałych.
  • Stałe — klucz user_email + player_id, bez series_id. Cykl rozbija pierwszy miesiąc na jedną płatność za każde zajęcia (related_ids = [gameId, seriesId]), a klient kupił miesiąc, nie pięć osobnych miejsc.

Dwa treningi w tygodniu to dwie serie. recurring_game_series.day_of_week jest pojedynczy, więc klient trenujący we wtorki i czwartki jest zapisany do dwóch serii — ale wybiera obie w jednym kreatorze i płaci za nie jedną transakcją. Dlatego grupowanie pomija series_id: inaczej dostałby dwie wiadomości, każda z połową kwoty.

Wiadomość niesie terms — listę z wpisem na każdy dzień tygodnia, po której szablon e-mail iteruje — oraz termsSummary, czyli tę samą listę zwiniętą do jednego napisu dla SMS-a i push-a, gdzie pętli nie ma. amount i sessions to sumy dla całego zapisu. Pola pojedyncze (seriesName, startDate, …) zostały i powtarzają pierwszy termin, żeby szablon w SendGridzie nienauczony jeszcze pętli renderował sensowną treść zamiast pustek.

Tylko pierwszy miesiąc. Zapis wystawia płatność za każde wystąpienie całego cyklu, wszystkie z tym samym related_ids = [gameId, seriesId]. Do bramki idzie tylko pierwszy miesiąc — reszta czeka i jest opłacana miesiąc po miesiącu z zakładki Płatności, tym samym webhookiem. Bez dodatkowego warunku każda taka wpłata wyglądałaby jak nowy zapis i klient co miesiąc dostawałby „Witamy w grupie!".

Dlatego sendRegularEnrollmentConfirmations sprawdza, czy opłacone zajęcia należą do pierwszego miesiąca cyklu (firstMonthGameIds). Wyznacza go ten sam selectFirstMonthOccurrences, którego używa rozliczenie zapisu, ale zakotwiczony na najwcześniejszym wystąpieniu, a nie na „teraz" — ta funkcja patrzy wyłącznie w przód od swojego punktu odniesienia, więc liczona w dniu płatności uznałaby za pierwszy ten miesiąc, który akurat jest opłacany.

Wyciszenie starego powiadomienia. addPlayerToRecurringSeries() przyjmuje opcję deferConfirmationUntilPaid. Ustawia ją enrollPlayerInSeries() (lib/recurring-enrollment.ts), czyli wspólne wejście ścieżek self-service. Dzięki temu player_added_to_recurring_series nie leci przed płatnością. Dodanie przez recepcję idzie prosto do addPlayerToRecurringSeries() bez tej opcji i zachowuje dotychczasowe zachowanie. Serie bezpłatne potwierdzają natychmiast — nie ma na co czekać, bo żaden webhook nigdy nie nadejdzie.

:::tip Szablony SendGrid i skąd biorą się ustawienia E-maile wychodzą wyłącznie przez szablony dynamiczne SendGrid — sendEmailNotification() pomija kanał e-mail przy pustym email_template_id. Pliki w katalogu mails/ są źródłem HTML do wklejenia w SendGrid oraz podglądem w panelu; same z siebie niczego nie wysyłają.

Bloki defaults w lib/notification-config.ts to wyłącznie fallback na wypadek braku wiersza w notification_types. Gdy wiersz istnieje — a migracje go zakładają — wygrywa baza (row?.email_enabled ?? default w resolveNotificationConfig()). Kanały, ID szablonu i treści SMS/push edytuje się w /dashboard/notifications bez deployu; zapis czyści cache typów i zmiana działa od razu. :::

⌛ Wygaśnięcie płatności

Jedno powiadomienie na uczestnika. Zapis na cykl wystawia osobny wiersz payment za każde wystąpienie, wszystkie z jednym wspólnym holdem, więc jeden porzucony zapis wygasza kilkadziesiąt wierszy w tym samym przebiegu crona. Wysyłka szła kiedyś per wiersz — czterdzieści SMS-ów do jednej osoby w kilka sekund, co przebijało limit bramki SMS (SMS_RATE_LIMIT_EXCEEDED z JustSend) i gubiło wszystkie wiadomości po pierwszych kilku.

groupExpiredPaymentsByPlayer() składa je w grupy po kluczu user_email + player_id + currency + payment_type:

  • Po uczestniku, nie po kliencie — jeden klient zapisuje kilkoro dzieci, a wiadomość ma mówić, czyj zapis przepadł. Rodzeństwo dostaje dwie osobne wiadomości.
  • Waluta w kluczu, żeby sumowana kwota miała sens.
  • Typ płatności w kluczu, bo wygasły hold na kort i zapis na cykl to dwa osobne zapisy — scalone dałyby jedną wiadomość o „41 terminach" zajęć.

Grupa niesie sumę kwot w amount, liczbę wierszy w paymentCount oraz flagę multiplePayments (pusta przy pojedynczej płatności), dzięki której klauzula o liczbie terminów renderuje się tylko wtedy, gdy naprawdę jest ich więcej niż jeden. description to lista różnych opisów w grupie, przycięta do trzech. paymentId wskazuje najstarszy wiersz grupy.

Licznik notificationGroups w wyniku crona mówi, ile wiadomości faktycznie wyszło — expiredCount nadal liczy wiersze.

E-mail wymaga wgrania szablonu do SendGrida. sendEmailNotification() renderuje wyłącznie przez email_template_id, więc treść maila bierze się z szablonu d-e172d3901cc94687a3e7dd9da00f619b po stronie SendGrida. mails/payment-expired.html jest jego lokalnym źródłem — obsługuje podgląd w panelu admina i to jego zawartość wkleja się do SendGrida — ale zmiana w repo nie propaguje się sama. Dopóki szablon nie zostanie wgrany, mail pokazuje samą zsumowaną kwotę, a SMS i push niosą pełną treść.

Zmienne paymentCount i multiplePayments lecą do SendGrida w dynamic_template_data razem z resztą pól, więc po wgraniu szablonu działają bez zmian w kodzie.

Dlaczego „9:00" ma dwa triggery

Cloudflare odpala crony według UTC, a Polska zmienia czas dwa razy w roku. Pojedynczy 0 7 * * * to 9:00 wyłącznie w czasie letnim — od końca października do końca marca wypadał o 8:00, godzinę przed tym, co klub deklaruje klientom.

Dlatego każde „poranne" zadanie jest zarejestrowane dwa razy, o 7:00 i 8:00 UTC. skipOutsideMorning() w worker-crons.ts sprawdza, która z tej pary jest właśnie 9:00 w Warszawie, i drugą odrzuca — zadanie nadal wykonuje się raz dziennie. Zweryfikowane na wszystkich 365 dniach roku: zawsze przechodzi dokładnie jedno odpalenie.

Dotyczy to cronu dziennego oraz zaległości z 1., 8. i 14. dnia miesiąca. Raport dla biura (17. i 20.) oraz zbiorcze przypomnienie z 5. dnia zostały bez zmian — ich godziny nie są w ticketcie deklarowane.

Zaplanowane zadania (Crons)

Harmonogram zadań jest kodem: lib/cron/jobs.ts to jedyne źródło prawdy (nazwa zadania, wyrażenie cron w strefie Europe/Warsaw, zadania składowe z kluczami notification_crons, okres karencji dla Healthchecks). Ten sam plik obsługuje oba runtime'y:

  • VPS (Node)cron.ts uruchamia croner (protect: true, czyli tick jest pomijany, gdy poprzedni bieg jeszcze trwa) i dodatkowo cztery zadania utrzymaniowe (kv-expire, logs-retention, db-snapshot, db-optimize). Po starcie procesu zadania dzienne i miesięczne, które przypadły w ciągu ostatnich 6 godzin i nie mają nowszego last_run_at w notification_crons, są uruchamiane od razu (catch-up po deployu w minucie triggera). Każdy bieg pinguje Healthchecks (/start, sukces albo /fail), gdy ustawione są HEALTHCHECKS_PING_URL i HEALTHCHECKS_SLUG_PREFIX.
  • Cloudflareworker-crons.ts mapuje 21 triggerów UTC z wrangler.admin.toml na te same zadania (cloudflareCrons w tabeli), z bramką cloudflareGate dla par 7/8 UTC i 20/21 UTC opisanych wyżej.

Polecenia (runtime Node):

yarn dev:cron # scheduler na ./.data/dev.db (APP_RUNTIME=node)
npx tsx cron.ts list # harmonogram z najbliższymi terminami w czasie lokalnym
npx tsx cron.ts run payments-overdue # jedno zadanie teraz, wynik per klucz
npx tsx cron.ts list --sync-healthchecks # tworzy/aktualizuje checki przez API Healthchecks
yarn build:server && yarn start:cron # produkcja: node dist/cron.js

Testy: __tests__/cron/jobs.test.ts sprawdza, że każdy trigger z wrangler.admin.toml mapuje się dokładnie na jedno zadanie, a runner.test.ts i maintenance.test.ts pokrywają bramkę isCronEnabled/recordCronRun, izolację zadań składowych, pingi Healthchecks, catch-up oraz zadania utrzymaniowe na realnym schemacie.

Na Cloudflare powiadomienia cykliczne opierają się na Workers Cron Triggers. Aby je przetestować lokalnie:

  • Otwórz środowisko workera: yarn dev:worker
  • Wywołuj konkretne trigger endpointy z CLI.

Wywołania Curl:

  • Przypomnienie o testach na jutro (oraz prośby o opinię po campie) – wywoływane w cronie dziennym o 7:00: curl "http://localhost:3000/__scheduled?cron=0%207%20*%20*%20*"
  • Miękkie przypomnienie o płatności – 8 dzień miesiąca: curl "http://localhost:3000/__scheduled?cron=0%207%208%20*%20*"
  • Pilne przypomnienie – 14 dzień miesiąca (w tym przypadku odpalany jest Provider SMS, warunek: posiadanie przez ownera telefonu w Auth0 user_metadata.phone_number): curl "http://localhost:3000/__scheduled?cron=0%207%2014%20*%20*"
ElementWymagana treść
Nagłówek"Potwierdzamy zapisanie na zajęcia!"
SzczegółyDane uczestnika, rodzaj zajęć, nazwa serii (sekcja „Dzień”), data startowa serii (sekcja „Godzina”), trener i miejsce
RegulaminZasada 24h odwołania, płatność do 7. dnia miesiąca
InformacjeZmiana grupy, dni wolne, procedura rezygnacji

Kroki testowe:

  1. Jako admin, utwórz serię zajęć cyklicznych
  2. Dodaj uczestnika (nie-admin) do całej serii
  3. Powiadomienie zostanie wysłane automatycznie

🚫 Odwołanie zajęć

Wyzwalacz: Odwołanie terminu przez uczestnika w Portalu Klienta

ElementWymagana treść
Nagłówek"Właśnie odwołałeś zajęcia!"
SzczegółyDane odwołanych zajęć (uczestnik, rodzaj, dzień, godzina, trener, kort)
OdrabianieInformacja o zakładce "Odwołane zajęcia"
PłatnośćWymagania dotyczące opłat za zajęcia odrabiające

Kroki testowe:

  1. Utwórz zajęcia z zarejestrowanym uczestnikiem
  2. Zaloguj się jako uczestnik i odwołaj termin z poziomu Portalu Klienta
  3. Powiadomienie zostanie wysłane automatycznie
informacja

Ręczne usunięcie uczestnika w panelu admina wysyła alternatywny szablon „Zmiana w grze” (player_removed_from_game).

🔄 Zapis na odrabianie

Wyzwalacz: Dodanie uczestnika do zajęć odrabiających

ElementWymagana treść
Nagłówek"Właśnie zapisałeś się na odrabianie zajęć!"
SzczegółyDane zajęć odrabiających
RezygnacjaProcedura ponownego odwołania (najpóźniej 24h przed)
StatusWymagania dotyczące statusu płatności

Kroki testowe:

  1. Jako admin, utwórz zajęcia odrabiające
  2. Dodaj uczestnika który wcześniej odwołał zajęcia
  3. Powiadomienie zostanie wysłane automatycznie

⏰ Powiadomienia zaplanowane

Powiadomienia wysyłane w określonych terminach przez zadania cron.

🔔 Przypomnienie o zajęciach próbnych (24h)

Harmonogram: Codziennie o 9:00 czasu polskiego (para 0 7 * * * / 0 8 * * *)

curl "http://localhost:3000/__scheduled?cron=0%207%20*%20*%20*"
ElementWymagana treść
Nagłówek"Widzimy się już jutro!", "Przypomnienie o Twoich zajęciach tenisowych"
SzczegółyDane uczestnika, rodzaj zajęć, data/godzina, trener, kort
PrzygotowanieSekcja "Spakuj sprzęt", "Przyjdź wcześniej" (5-10 minut)
KontaktNumery: Opole (570 386 869), Legionowo (516 793 180), Lublin (730 706 030)

Kroki testowe:

  1. Jako admin, utwórz zajęcia na jutro z dowolnym activity_type
  2. Dodaj uczestnika z type: 'skill-assessment' w JSON
  3. Uruchom zaplanowane zadanie powyższym poleceniem curl

📅 Przypomnienia o rezerwacjach kortu

Harmonogram: Co godzinę (0 * * * *). Progi w RESERVATION_REMINDER_CONFIG.hoursBeforeReservation (np. 24h, 12h, 2h przed startem).

Test lokalny:

  1. Uruchom aplikację przez workera (inaczej cron i D1 nie działają):
    yarn dev:worker
  2. Wywołaj symulację crona (hourly = przypomnienia o rezerwacjach):
    curl "http://localhost:3000/__scheduled?cron=0%20*%20*%20*%20*"

Żeby przypomnienie faktycznie się wysłało: w bazie musi być rezerwacja (tabela booking + game), której start_time mieści się w oknie dla danego progu. Dla domyślnego [24] i windowMinutes: 30 okno to 23,5h–24,5h od bieżącej chwili. Łatwiejszy test: tymczasowo ustaw w lib/notification-config.ts np. hoursBeforeReservation: [0.5] (30 min) i utwórz rezerwację zaczynającą się za ok. 30 minut; po wywołaniu curl przypomnienie powinno pójść.

Które rezerwacje cron w ogóle widzi: getReservationsInWindow pomija rezerwacje odwołane i zarchiwizowane (po stronie booking i game) oraz — co najważniejsze — rezerwacje z wygasłym holdem płatniczym. Porzucona płatność nie kasuje wiersza; blokada jest tylko odfiltrowywana z każdego odczytu (booking_expires_at na rezerwacji, game_expires_at na grze, patrz gameOccupiesSlotSql). Bez tego filtra klient, który zaczął rezerwację online i nie dokończył płatności, dostawał dzień wcześniej przypomnienie o korcie, którego nie ma — razem z PIN-em do drzwi z payloadu. Warunek na game_expires_at nie jest nadmiarowy: clearGameExpiresAt celowo zostawia go ustawionym, gdy w czasie wygaśnięcia holdu slot zajął ktoś inny, podczas gdy clearBookingExpiresAt czyści wiersz rezerwacji bezwarunkowo.

SMS: Aby dostać SMS, użytkownik (owner rezerwacji) musi mieć w Auth0 w user_metadata.phone_number ustawiony numer. W ustawieniach powiadomień muszą być włączone kanały SMS oraz typ „Przypomnienia o rezerwacjach”. W pliku .env (lub u workerze) musi być NOTIFICATIONS_ENABLED=true.

Gość rezerwacji publicznej: rezerwacja z kalendarza publicznego nie ma za sobą konta — jej owner_email to adres zastępczy guest+…@public.acepark.pl, którego nikt nie czyta. Cron pomija wtedy mail i wysyła SMS wprost na numer z rezerwacji (sendSMSNotificationDirect z ignoreChannelSwitch: true). Waiver jest tu konieczny, bo reservation_reminder ma w bazie sms_enabled = 0 — bez niego przypomnienie nie docierało do gościa żadnym kanałem, a cron logował Failed to send 24h notification to guest+… przy pustym sms_log (wysyłka była blokowana przed bramką, więc nie było nawet wpisu FAILED). Włączanie sms_enabled dla tego typu nie jest do tego potrzebne — i posłałoby SMS-y również posiadaczom kont, którzy dostają maila.

💰 Przypomnienie o płatności (dzień po terminie)

Harmonogram: 8. dnia miesiąca o 9:00 czasu polskiego (para 0 7 8 * * / 0 8 8 * *)

curl "http://localhost:3000/__scheduled?cron=0%207%208%20*%20*"
ElementWymagana treść
Nagłówek"Termin płatności minął"
Informacja"wczoraj minął termin płatności za zajęcia za bieżący miesiąc"
PortalOdniesienie do "Portal Klienta"
Pomoc"Nie możesz dokonać płatności?" - "odwiedź nas w Opolu"
BenefityInformacja o zaletach terminowych płatności (zajęcia odrabiające)

Kroki testowe:

  1. Utwórz rekord płatności z due_date ustawionym na wczoraj
  2. Ustaw status: 'pending'
  3. Uruchom zaplanowane zadanie powyższym poleceniem curl

⚠️ Pilne przypomnienie o płatności (7 dni po terminie)

Harmonogram: 14. dnia miesiąca o 9:00 czasu polskiego (para 0 7 14 * * / 0 8 14 * *)

curl "http://localhost:3000/__scheduled?cron=0%207%2014%20*%20*"

:::warning Podwójne powiadomienie Ten scenariusz wysyła jednocześnie email i SMS z tego samego zadania cron. :::

📧 Email

ElementWymagana treść
Nagłówek"Pilne przypomnienie o zaległej płatności za zajęcia"
Termin"Termin płatności minął 7 dni temu", "Minął już tydzień od terminu"
KonsekwencjeLista: wstrzymanie zajęć, utrata benefitów
Działanie"skontaktuj się z nami jeszcze dziś", odniesienie do Portalu Klienta

📱 SMS

Pilne przypomnienie! Minął tydzień od terminu płatności za zajęcia. Prosimy o natychmiastowe uregulowanie należności. Centrum Tenisowe AcePark.

Kroki testowe:

  1. Utwórz rekord płatności z due_date ustawionym na 7 dni temu
  2. Ustaw status: 'pending'
  3. Uruchom zaplanowane zadanie powyższym poleceniem curl

⭐ Prośba o opinię po półkolonii

Harmonogram: Codziennie o 9:00 czasu polskiego (para 0 7 * * * / 0 8 * * *). Wysyłka daysAfter dni po zakończeniu turnusu (domyślnie 1; konfigurowalne w panelu Powiadomienia → zadanie camp_feedback_request, parametr params.daysAfter).

curl "http://localhost:3000/__scheduled?cron=0%207%20*%20*%20*"

Do każdego opłaconego opiekuna (deduplikacja po e-mailu) trafia e-mail z linkiem …/feedback/{token} do wystawienia oceny 1–5 + komentarza. Szczegóły działania, strona publiczna i widok w panelu: zob. Półkolonie → Opinie po zakończonej półkolonii.

Żeby prośba faktycznie się wysłała: w bazie musi istnieć turnus z end_date = dziś − daysAfter oraz rejestracja status = 'paid'. Można też wywołać ręcznie przyciskiem „Wyślij prośbę o opinię" na stronie turnusu.

🔐 Kod PIN do drzwi — rezerwacje i zapisy na zajęcia

Dla pracownika: Po zaksięgowaniu płatności za rezerwację kortu kod PIN do drzwi pokazuje się na ekranie potwierdzenia — zarówno w Portalu Klienta, jak i na publicznej stronie płatności (rezerwacja z kalendarza bez logowania). Ten sam kod klient dostaje w SMS-ie/mailu potwierdzającym płatność (booking_payment_success); osobna wiadomość z samym PIN-em nie jest wysyłana. Jeśli klient dzwoni, że „nie ma PIN-u", sprawdź, czy rezerwacja ma przypisany PIN i czy włączona jest flaga dostarczania PIN-ów.

Zapis na zajęcia (szkółka, trening, zajęcia wakacyjne): PIN dopisuje się do wiadomości potwierdzającej zapis — jednorazowy (player_added_to_game), na cykl (player_added_to_recurring_series) i po opłaceniu zapisu na zajęcia stałe (regular_enrollment_confirmed). Wysyłamy go raz, przy zapisie. Kod należy do osoby i się nie zmienia, więc powtarzanie go przed każdymi zajęciami byłoby spamem; przypomnienia o zajęciach PIN-u nie zawierają. Uczestnik, który kod zgubi, odczyta go w Portalu Klienta (Moi zawodnicy → ⋮ → Kod PIN) albo dostanie od recepcji. Jeden uczestnik = jeden kod: rodzic zapisujący dwoje dzieci dostaje dwie wiadomości, każdą z kodem właściwego dziecka.

Technicznie:

  • Cała funkcja jest bramkowana flagą DOOR_PIN_DELIVERY_ENABLED=true (lib/feature-flags.ts) oraz obecnością pinpada w danej lokalizacji. Klub prowadzi kilka obiektów i nie wszystkie są zautomatyzowane: kod, który otwiera drzwi na Oleskiej, na Spokojnej nie otwiera niczego, więc podanie go tam to cztery cyfry i zamknięte drzwi. LOCATION_HAS_DOOR_PIN_HARDWARE_SQL / courtHasDoorPinHardware() (lib/door-pin-availability.ts) sprawdzają, czy w lokalizacji (miasto + ulica) jest aktywny sensor typu pin. Rozstrzygnięcie idzie per lokalizacja, nie per kort — pinpad pilnuje wejścia na obiekt, więc kort, którego ktoś zapomniał zaznaczyć w formularzu sensora, nie może po cichu pozbawić klientów kodu. Gdy Spokojna dostanie sprzęt, kody zaczną wychodzić same, bez przełączania czegokolwiek.
  • Warunek obowiązuje we wszystkich siedmiu miejscach: SMS/mail o rezerwacji, przypomnienie o rezerwacji, potwierdzenie zapisu na zajęcia (jednorazowe, cykl i po płatności), dane dla maila w createBooking, baner po płatności w Portalu Klienta i na publicznej stronie płatności. Zapis obejmujący dwie grupy w dwóch lokalizacjach dostaje PIN, jeśli choć jedna z nich jest zautomatyzowana.
  • Ekrany sukcesu: app/pay/success/page.tsx (publiczna, PIN z payment_linkbooking.door_pin) i app/(dashboard)/dashboard/payments/success/page.tsx (Portal Klienta, PIN dociągany przez getDoorPinsByGameIds dla płatności typu court_reservation). Oba renderują wspólny komponent components/payments/DoorPinBanner.tsx, który sam ukrywa się przy wyłączonej fladze.
  • W treści SMS-ów PIN siedzi w warunkowym fragmencie {{#if doorPin}}…{{/if}} — jeśli sesja nie ma PIN-u albo flaga jest wyłączona, fragment nie renderuje się wcale. Dotyczy typów: booking_payment_success, player_added_to_reservation, existing_player_added_to_reservation, reservation_reminder (rezerwacje), player_added_to_game, player_added_to_recurring_series, regular_enrollment_confirmed (zapisy na zajęcia, migracja 0236) oraz trial_registration_confirmed, vacation_payment_success, game_substitution (próbne, wakacyjne, odrabianie — migracja 0238). W potwierdzeniu zajęć próbnych PIN pokazywany jest dodatkowo w mailu, w bloku szczegółów.
  • Potwierdzenie próbnych może obejmować kilkoro rodzeństwa naraz — wiadomość jest grupowana po opiekunie i zajęciach, więc doorPin niesie wtedy kody wszystkich uczestników po przecinku, w tej samej kolejności co imiona. Klawiatura przyjmuje każdy z nich.
  • Wartość dla zapisów bierze się ze statycznego PIN-u uczestnika, nie z rezerwacji: doorPinForEnrollmentMessage() (lib/enrollment-door-pin.ts) sprawdza flagę i woła ensurePlayerDoorPin(), więc uczestnik bez kodu dostaje go przy okazji. Potwierdzenie po płatności (regular_enrollment_confirmed) czyta player.door_pin wprost z bazy (loadPlayerDoorPins w lib/notifications.ts) — leci z workera cronowego, który nie powinien wciągać modułu server actions. Błąd odczytu nigdy nie przerywa zapisu: PIN-u po prostu nie ma w treści.
  • Kanał SMS musi być włączony dla typu. seedNotificationConfig wyłącza SMS każdemu typowi, który ma szablon e-mail (sms_enabled = cfgDefaults.sms ?? (hasSms && !hasEmail)), a player_added_to_game i player_added_to_recurring_series szablony mają — przez co wychodził sam mail. Oba mają teraz jawne defaults: { email: true, sms: true }, a migracja 0237 włącza kanał u wdrożonych tenantów. Wyłączenie z powrotem to jeden przełącznik w Powiadomieniach → typ → SMS.
  • Warunki {{#if}} renderuje interpolate()obie ścieżki, i tekst z notification_texts, i fallback z messages/*.json. Wcześniej fallback miał własne, prostsze podstawianie {zmienna}, przez co przy braku wiersza w bazie do klienta poszedłby dosłowny {{#if doorPin}}.
  • Treść z bazy wygrywa nad plikami messages/*.json. resolveNotificationConfig używa JSON-a tylko jako fallbacku, gdy w notification_texts nie ma wiersza — a zasiew (seedNotificationConfig) dla istniejących typów jest INSERT OR IGNORE, więc zmiana treści w JSON-ie nie dociera do wdrożonych tenantów. Każda zmiana istniejącego szablonu wymaga migracji aktualizującej notification_texts (albo ręcznej edycji w panelu). Fragment z PIN-em dopisany do JSON-a w lipcu 2026 nie miał takiej migracji i przez to nie wychodził w SMS-ach — nadrabia to 0198_backfill_door_pin_in_reservation_sms.sql.
  • E-maile transakcyjne renderuje SendGrid ze swojego dynamic template (dla potwierdzenia płatności: d-67bd1a6fea1343db9d7413856aee763e); kod przekazuje tylko dynamic_template_data, w tym doorPin. Kopia HTML w lib/mail-templates.generated.ts służy wyłącznie podglądowi w panelu — jeśli PIN ma zniknąć/pojawić się w mailu, zmiana idzie po stronie szablonu w SendGridzie.

📵 Klient bez konta — klient lokalny i gość rezerwacji publicznej

Dla pracownika: Klienta założonego na recepcji („klient lokalny") identyfikuje numer telefonu — konta i adresu e-mail taki klient nie ma. Wszystkie powiadomienia, które go dotyczą (zapis na zajęcia jednorazowe i cykliczne, przypomnienia, potwierdzenia płatności), docierają do niego wyłącznie SMS-em. Nie szukaj u niego maila i nie zakładaj, że „nic nie poszło", jeśli skrzynka jest pusta. Tak samo działa gość, który zarezerwował kort z publicznego kalendarza bez logowania.

Kanał SMS nie jest dla takiego klienta opcją. Przełącznik Powiadomienia → typ → SMS decyduje, którym kanałem pisać do osoby, którą można złapać na dwa sposoby — nie o tym, czy klientowi lokalnemu powiedzieć cokolwiek. Typy wysyłane „tylko mailem" (np. wypisanie z cyklu grupowego) idą do klienta lokalnego SMS-em mimo wyłączonego przełącznika. Klient z kontem dostaje dokładnie to, co ustawione: SMS, mail albo oba.

Jeśli klient lokalny nie dostał SMS-a, sprawdź: czy w jego kartotece jest numer telefonu, czy typ powiadomienia ma w ogóle treść SMS (są typy wyłącznie mailowe, bez tekstu) i czy klient nie został już połączony z prawdziwym kontem — po scaleniu wiadomości idą na adres tego konta.

Technicznie:

  • Klient lokalny nie ma wiersza w tabeli user. createLocalUser zapisuje go w local_user i tworzy player z adresem zastępczym [email protected] (generateLocalEmail); gość rezerwacji publicznej dostaje analogiczny adres guest+…@public.acepark.pl. Powiadomienia adresuje się e-mailem (sendNotificationToUser({ userEmail })), więc dla obu tych grup adres jest tylko kluczem — nikt go nie czyta.
  • Rozwiązywanie kontaktu dla adresów zastępczych robi lib/local-user-contact.ts: isPlaceholderEmail() rozpoznaje obie domeny, a getPlaceholderContact() czyta numer i miasto z local_user — rekord recepcyjny rozstrzyga wszędzie tam, gdzie istnieje, bo jedna kartoteka może mieć kilku zawodników. Rekord scalony (merged_to_user_id) albo zarchiwizowany nie odpowiada nic, również z pominięciem gracza: po scaleniu wiadomości idą na adres konta, a archiwizacja jest sposobem, w jaki recepcja przestaje pisać do klienta. Zostawiony wiersz player nie może być drogą naokoło tej decyzji — scalenie przeadresowuje graczy tylko w obrębie własnego tenanta.
  • Fallback na player obsługuje adresy bez kartoteki recepcyjnej, czyli gości rezerwacji publicznej. Pod jednym adresem gościa może siedzieć kilka wierszy (kalendarz zakłada nowego gracza przy rezerwacji na inne imię), więc miasto raportowane jest tylko wtedy, gdy wiersze są zgodne; numer nie bywa sporny, bo adres jest z niego zbudowany. Oba zapytania filtrują po tenant_id.
  • getUserPhoneNumber() (lib/notifications.ts) dla adresu zastępczego omija listę kont z user i idzie prosto do getPlaceholderContact(). Wcześniej szukał wyłącznie w user, nie znajdował klienta lokalnego i kończył logiem User not foundNo phone number on the account — SMS skipped, czyli cicho gubił jedyny kanał, jaki taki klient ma. Tą samą drogą idzie getUserCity() (lib/utils/user-city.ts), inaczej wiadomość traci miasto i wraz z nim podmiany treści dla Legionowa i Lublina.
  • Kanał e-mail dla adresu zastępczego jest pomijany (sendToChannel, log Placeholder address — email skipped). Wysyłka na nieistniejącą domenę i tak nie miała odbiorcy, a każde takie odbicie obciąża reputację domeny nadawczej.
  • Skoro kanał e-mail odpada, kanał SMS musi go zastąpić — inaczej typ skonfigurowany jako „tylko mail" nie dociera do klienta lokalnego wcale. sendNotificationToUser() dokłada sms do listy kanałów, gdy adres jest zastępczy, a typ ma treść SMS (smsCarriesPlaceholderClient w logu Sending notification via channels); sendSMSNotification() dostaje wtedy ignoreChannelSwitch, bo drugi raz sprawdza resolved.sms.enabled i sam by wiadomość zdusił. Brak treści SMS nadal zatrzymuje wysyłkę — nie ma czego wysłać. Dla adresu prawdziwego nie zmienia się nic: obowiązują kanały ustawione na typie i preferencje klienta (shouldSendNotification), które i dla klienta lokalnego dalej działają — rezygnacja z powiadomień jest respektowana.
  • Ścieżki, które trzymają numer pod ręką (rezerwacje kortów, listy rezerwowe, obozy, przypomnienia), mają własny objazd — !player.client_id?.includes('local')sendSMSNotificationDirect(player.phone_number). Objazd omija preferencje klienta, ale nie omijał przełącznika SMS na typie: sendSMSNotificationDirect() wołało sendSMSNotification() bez ignoreChannelSwitch, więc dla typu „tylko mailowego" guard !resolved.sms.enabled gasił wiadomość i sender dostawał false. Dziś funkcja przyjmuje ignoreChannelSwitch w swoich argumentach i podaje je dalej — nadawca, który wie, że odbiorca nie ma skrzynki, ustawia je na true. Domyślnie zostaje false, więc pozostali wołający dalej podlegają przełącznikowi. Waiver ustawiają dziś dwa nadawcy, oba wysyłające typ „tylko mailowy" do odbiorcy bez skrzynki: przypomnienie o rezerwacji (reservation_reminder, cron godzinowy) oraz powiadomienie o zwolnionym slocie z listy rezerwowej (slot_available, notifyReserveListUsers i notifyReserveListForFreedSlot).
  • Testy: __tests__/lib/local-client-notifications.test.ts (prawdziwa baza SQLite, zaślepiona tylko bramka SMS/mail) — w tym para przypadków pilnująca, że typ „tylko mailowy" idzie SMS-em do klienta lokalnego i nie idzie SMS-em do posiadacza konta.

🔁 Zapis i wypisanie z cyklu grupowego — co mówi SMS

Dla pracownika: SMS o zapisie na cykliczne zajęcia podaje dzień tygodnia, godzinę, kort i datę pierwszych zajęć, a SMS o wypisaniu — dzień tygodnia, godzinę i datę, od której zmiana obowiązuje. Wcześniej obie wiadomości niosły samą nazwę aktywności, a ta o zapisie odsyłała po szczegóły „do maila", którego klient lokalny nigdy nie dostaje. Klient zapisany na dwa terminy w tygodniu dostaje dwie wiadomości — to dwie serie — i po treści rozpozna, której dotyczy każda z nich.

Przykłady:

  • Zapisano Cię na cykliczne zajęcia Klub Seniora: poniedziałek 10:00, Kort 1. Pierwsze zajęcia 15 wrz 2026. Kod PIN do bramki: 8918.
  • Usunięto Cię z cyklicznych zajęć Klub Seniora: poniedziałek 10:00. Zmiana obowiązuje od zajęć 15 wrz 2026.

Technicznie:

  • Nowe zmienne: weekday, firstClassDate (player_added_to_recurring_series) oraz weekday, startTime, firstRemovedDate (player_removed_from_recurring_series). Każda siedzi w warunku {{#if …}}, więc brak wystąpienia do odczytu zostawia wiadomość w skróconej, poprawnej formie zamiast dziury w zdaniu. courtName w wiadomości o zapisie jest warunkowy z tego samego powodu — w payloadzie był od dawna, tyle że treść go nie używała.
  • Wiadomość o zapisie mieści się w dwóch segmentach UCS-2 (polskie znaki wykluczają GSM-7). Długa nazwa kortu w parze z długą nazwą aktywności może ją pchnąć na trzeci — to kwestia cennika SMS, nie poprawności.
  • Wartości liczy describeSeriesOccurrence() (lib/utils/series-occurrence-text.ts) z rzeczywistego wystąpienia, nie z recurring_game_series.day_of_week — seria przeniesiona na inny dzień byłaby ogłoszona pod starym. Formatowanie idzie przez formatDateWithConditionalTimezone w strefie Polski.
  • „Pierwsze zajęcia" to pierwsze wystąpienie, na które trafił ten uczestnik, a nie data startu serii: recepcja rutynowo dopisuje klienta w połowie semestru. Analogicznie przy wypisaniu — pierwsze zdjęte wystąpienie. Ścieżki, które to wyliczają: addPlayerToRecurringSeries, generateRecurringGames, updateRecurringGames, removePlayerFromRecurringSeries (lib/actions/game.ts) i executeRecurringTransfer (lib/recurring-transfer.ts). W dwóch pierwszych zbiory addedPlayers/removedPlayers/notifiedPlayers trzymają teraz najwcześniejsze wystąpienie na uczestnika zamiast samego identyfikatora.
  • Migracja 0257 podmienia treść u wdrożonych tenantów tylko tam, gdzie stoi domyślny tekst (porównanie pełnym napisem), więc klub, który przeredagował wiadomość w panelu, zachowuje swoją. Ta sama migracja dopisuje nowe zmienne do data_keys, inaczej edytor powiadomień twierdziłby, że takich zmiennych nie ma.
  • Testy: __tests__/lib/local-client-notifications.test.ts — dwa przypadki na treść obu SMS-ów.

🧪 Wytyczne testowe

📋 Lista kontrolna

  • Zaplanowane powiadomienia: Używaj poleceń curl do wyzwalania zadań cron lokalnie
  • Automatyczne powiadomienia: Sprawdź natychmiastowe wysyłanie po wystąpieniu zdarzenia
  • Weryfikacja kontaktów: Powiadomienia o zajęciach próbnych, płatnościach i odrabianiu zawierają numery dla Opola, Legionowa i Lublina; e-maile „Zmiana w grze” i „Gra jutro!” udostępniają jedynie numer ogólny (570 386 869)
  • Kontrola designu: Szablony email z niebieskim gradientem i brandingiem AcePark
  • Limit znaków SMS: Dokładne dopasowanie do podanego tekstu
  • Podwójne dostarczanie: Scenariusz pilnego przypomnienia wysyła email + SMS jednocześnie

📞 Wymagane kontakty

LokalizacjaNumer telefonu
Opole570 386 869
Legionowo516 793 180
Lublin730 706 030
Email[email protected]

:::tip Zawartość w języku polskim Cała zawartość musi dokładnie odpowiadać polskiemu tekstowi podanemu w wymaganiach systemowych. :::

Ważne aspekty techniczne:

  • Konfiguracja progu przypomnień rezerwacji: Określana w RESERVATION_REMINDER_CONFIG.hoursBeforeReservation (plik lib/notification-config.ts).
  • Gated Crons (runGatedCron): Poszczególne daily zadania są bramkowane, aby zapobiec duplikacji w przypadku opóźnień lub redundancji Cloudflare.
  • SMS Integration: Jeśli flaga NOTIFICATIONS_ENABLED=true jest obecna, bramki SMS (np. SMSAPI) pobierają treść i adresatów. Należy ostrożnie wywoływać ręczne crony z prod-db, by uniknąć przypadkowego zaspamowania bazy SMS. Wszystkie numery testowe i podglądy są dostępne w zakładce Dashboard -> Powiadomienia w adminie.
  • Brak kontekstu Cloudflare w cronach: getCloudflareContext() działa tylko w obsłudze fetch (kontekst ustawia wrapper OpenNext). Handler scheduled (worker-crons.ts) go nie ma, więc każda funkcja wołana z crona musi przyjmować db (env.DB) parametrem i przekazywać go dalej — dotyczy to m.in. getUserPhoneNumber, getUserCity, listUsersFromDb. Pominięcie parametru kończy się błędem getCloudflareContext has been called without having called initOpenNextCloudflareForDev w logach workera admina.