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:
- 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).
- 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.
- 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).
- 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.
- 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".
- 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:
- 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.
- 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).
- 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.
- 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ęć.
- 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.
- 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:
| Typ | payment_type | Funkcja wysyłająca | Szablon lokalny |
|---|---|---|---|
trial_registration_confirmed | trial | sendTrialRegistrationConfirmations() | trial-registration-confirmation.html |
regular_enrollment_confirmed | school | sendRegularEnrollmentConfirmations() | 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, przezsendTrialRegistrationConfirmations(),first_assignment— zsendFirstGameMail(), wołanego zhandleSkillAssessmentEnrollment()(lib/actions/payment.ts) orazprepareSkillAssignmentCompletionDBTasks()(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/registerwisi pod wewnętrznym linkiemis_organic = 1, aresolveTrialRegistrationLink()takie wiersze filtruje — celowo, bo w panelu nie mają czego szukać. Powrót kończył się ekranem „Nieprawidłowy link". Przyresume=1resolver dostajeallowOrganici wpuszcza je; każde inne wejście widzi je nadal jakonot_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:
- rola w sesji to
CLIENT— zapis samodzielny, nie dodanie przez recepcję, price > 0,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, bezseries_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.tsuruchamiacroner(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ą nowszegolast_run_atwnotification_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_URLiHEALTHCHECKS_SLUG_PREFIX. - Cloudflare —
worker-crons.tsmapuje 21 triggerów UTC zwrangler.admin.tomlna te same zadania (cloudflareCronsw tabeli), z bramkącloudflareGatedla 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*"
| Element | Wymagana treść |
|---|---|
| Nagłówek | "Potwierdzamy zapisanie na zajęcia!" |
| Szczegóły | Dane uczestnika, rodzaj zajęć, nazwa serii (sekcja „Dzień”), data startowa serii (sekcja „Godzina”), trener i miejsce |
| Regulamin | Zasada 24h odwołania, płatność do 7. dnia miesiąca |
| Informacje | Zmiana grupy, dni wolne, procedura rezygnacji |
Kroki testowe:
- Jako admin, utwórz serię zajęć cyklicznych
- Dodaj uczestnika (nie-admin) do całej serii
- Powiadomienie zostanie wysłane automatycznie
🚫 Odwołanie zajęć
Wyzwalacz: Odwołanie terminu przez uczestnika w Portalu Klienta
| Element | Wymagana treść |
|---|---|
| Nagłówek | "Właśnie odwołałeś zajęcia!" |
| Szczegóły | Dane odwołanych zajęć (uczestnik, rodzaj, dzień, godzina, trener, kort) |
| Odrabianie | Informacja o zakładce "Odwołane zajęcia" |
| Płatność | Wymagania dotyczące opłat za zajęcia odrabiające |
Kroki testowe:
- Utwórz zajęcia z zarejestrowanym uczestnikiem
- Zaloguj się jako uczestnik i odwołaj termin z poziomu Portalu Klienta
- Powiadomienie zostanie wysłane automatycznie
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
| Element | Wymagana treść |
|---|---|
| Nagłówek | "Właśnie zapisałeś się na odrabianie zajęć!" |
| Szczegóły | Dane zajęć odrabiających |
| Rezygnacja | Procedura ponownego odwołania (najpóźniej 24h przed) |
| Status | Wymagania dotyczące statusu płatności |
Kroki testowe:
- Jako admin, utwórz zajęcia odrabiające
- Dodaj uczestnika który wcześniej odwołał zajęcia
- 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*"
| Element | Wymagana treść |
|---|---|
| Nagłówek | "Widzimy się już jutro!", "Przypomnienie o Twoich zajęciach tenisowych" |
| Szczegóły | Dane uczestnika, rodzaj zajęć, data/godzina, trener, kort |
| Przygotowanie | Sekcja "Spakuj sprzęt", "Przyjdź wcześniej" (5-10 minut) |
| Kontakt | Numery: Opole (570 386 869), Legionowo (516 793 180), Lublin (730 706 030) |
Kroki testowe:
- Jako admin, utwórz zajęcia na jutro z dowolnym
activity_type - Dodaj uczestnika z
type: 'skill-assessment'w JSON - 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:
- Uruchom aplikację przez workera (inaczej cron i D1 nie działają):
yarn dev:worker
- 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*"
| Element | Wymagana treść |
|---|---|
| Nagłówek | "Termin płatności minął" |
| Informacja | "wczoraj minął termin płatności za zajęcia za bieżący miesiąc" |
| Portal | Odniesienie do "Portal Klienta" |
| Pomoc | "Nie możesz dokonać płatności?" - "odwiedź nas w Opolu" |
| Benefity | Informacja o zaletach terminowych płatności (zajęcia odrabiające) |
Kroki testowe:
- Utwórz rekord płatności z
due_dateustawionym na wczoraj - Ustaw
status: 'pending' - 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
| Element | Wymagana 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" |
| Konsekwencje | Lista: 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:
- Utwórz rekord płatności z
due_dateustawionym na 7 dni temu - Ustaw
status: 'pending' - 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 typupin. 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 zpayment_link→booking.door_pin) iapp/(dashboard)/dashboard/payments/success/page.tsx(Portal Klienta, PIN dociągany przezgetDoorPinsByGameIdsdla płatności typucourt_reservation). Oba renderują wspólny komponentcomponents/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, migracja0236) oraztrial_registration_confirmed,vacation_payment_success,game_substitution(próbne, wakacyjne, odrabianie — migracja0238). 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
doorPinniesie 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łaensurePlayerDoorPin(), więc uczestnik bez kodu dostaje go przy okazji. Potwierdzenie po płatności (regular_enrollment_confirmed) czytaplayer.door_pinwprost z bazy (loadPlayerDoorPinswlib/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.
seedNotificationConfigwyłącza SMS każdemu typowi, który ma szablon e-mail (sms_enabled = cfgDefaults.sms ?? (hasSms && !hasEmail)), aplayer_added_to_gameiplayer_added_to_recurring_seriesszablony mają — przez co wychodził sam mail. Oba mają teraz jawnedefaults: { email: true, sms: true }, a migracja0237włącza kanał u wdrożonych tenantów. Wyłączenie z powrotem to jeden przełącznik w Powiadomieniach → typ → SMS. - Warunki
{{#if}}renderujeinterpolate()— obie ścieżki, i tekst znotification_texts, i fallback zmessages/*.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.resolveNotificationConfigużywa JSON-a tylko jako fallbacku, gdy wnotification_textsnie ma wiersza — a zasiew (seedNotificationConfig) dla istniejących typów jestINSERT 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ącejnotification_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 to0198_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 tylkodynamic_template_data, w tymdoorPin. Kopia HTML wlib/mail-templates.generated.tssł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.createLocalUserzapisuje go wlocal_useri tworzyplayerz adresem zastępczym[email protected](generateLocalEmail); gość rezerwacji publicznej dostaje analogiczny adresguest+…@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, agetPlaceholderContact()czyta numer i miasto zlocal_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 wierszplayernie może być drogą naokoło tej decyzji — scalenie przeadresowuje graczy tylko w obrębie własnego tenanta. - Fallback na
playerobsł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ą potenant_id. getUserPhoneNumber()(lib/notifications.ts) dla adresu zastępczego omija listę kont zuseri idzie prosto dogetPlaceholderContact(). Wcześniej szukał wyłącznie wuser, nie znajdował klienta lokalnego i kończył logiemUser not found→No phone number on the account — SMS skipped, czyli cicho gubił jedyny kanał, jaki taki klient ma. Tą samą drogą idziegetUserCity()(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, logPlaceholder 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ładasmsdo listy kanałów, gdy adres jest zastępczy, a typ ma treść SMS (smsCarriesPlaceholderClientw loguSending notification via channels);sendSMSNotification()dostaje wtedyignoreChannelSwitch, bo drugi raz sprawdzaresolved.sms.enabledi 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łosendSMSNotification()bezignoreChannelSwitch, więc dla typu „tylko mailowego" guard!resolved.sms.enabledgasił wiadomość i sender dostawałfalse. Dziś funkcja przyjmujeignoreChannelSwitchw swoich argumentach i podaje je dalej — nadawca, który wie, że odbiorca nie ma skrzynki, ustawia je natrue. Domyślnie zostajefalse, 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,notifyReserveListUsersinotifyReserveListForFreedSlot). - 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) orazweekday,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.courtNamew 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 zrecurring_game_series.day_of_week— seria przeniesiona na inny dzień byłaby ogłoszona pod starym. Formatowanie idzie przezformatDateWithConditionalTimezonew 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) iexecuteRecurringTransfer(lib/recurring-transfer.ts). W dwóch pierwszych zbioryaddedPlayers/removedPlayers/notifiedPlayerstrzymają teraz najwcześniejsze wystąpienie na uczestnika zamiast samego identyfikatora. - Migracja
0257podmienia 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 dodata_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
| Lokalizacja | Numer telefonu |
|---|---|
| Opole | 570 386 869 |
| Legionowo | 516 793 180 |
| Lublin | 730 706 030 |
| [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(pliklib/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=truejest 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łudzefetch(kontekst ustawia wrapper OpenNext). Handlerscheduled(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łędemgetCloudflareContext has been called without having called initOpenNextCloudflareForDevw logach workera admina.