Pełny opis systemu moduł po module: co robi każdy element, jakie udostępnia trasy, jakie metody zawiera, jakie ma pola i z których tabel korzysta oraz co dokładnie dzieje się podczas działania. Dokument wygenerowany na podstawie analizy kodu źródłowego.
Legenda statusów:Aktywny — działa na produkcji ·
Demo — zbudowany, tryb pokazowy/testowy ·
Osobny tenant — pod inną domeną ·
Gotowy — kod gotowy, włączany konfiguracją.
Kwoty pieniężne w bazie przechowywane są w groszach (liczby całkowite).
1.
Architektura multi-tenant
Aktywny
Jedna aplikacja Laravel obsługuje kilka niezależnych serwisów. To, który moduł i która baza danych obsłużą żądanie, zależy od hostu (domeny). Rozstrzyga to middleware ResolveTenant, uruchamiany jako pierwszy — przed sesją i tokenem CSRF — bo musi ustawić bazę i kontekst zanim cokolwiek innego się wykona.
Czyta host żądania i mapuje go na tenant (moduł + tryb + baza + klucz API bramki).
Dynamicznie podmienia bazę połączenia MySQL (config.database.connections.mysql.database) — per żądanie, bez restartu.
Przełącza ścieżki widoków Blade zależnie od trybu (church / products), więc ten sam kontroler renderuje inny wygląd.
Rejestruje singleton app('tenant') dostępny w całej aplikacji oraz klucz API bramki w config('shop.gateway_api_key').
Połączenia bazy
Połączenie mysql jest przełączane per host (sklepy), a dedykowane połączenie gateway wskazuje zawsze na nfc_pay — modele bramki czytają z niego niezależnie od tego, jaki tenant obsługuje bieżące żądanie.
Płatności obsługuje osobny moduł Gateway (własna baza nfc_pay), z którym sklepy komunikują się przez REST API i webhooki. Sklep nigdy nie rozmawia bezpośrednio z PayU — robi to bramka, co pozwala obsłużyć wiele sklepów jednym, bezpiecznym połączeniem z operatorem. Pełny cykl jest wielowarstwowy: webhook + aktywny polling + podpisy kryptograficzne.
Przepływ end-to-end
Inicjacja. Sklep tworzy transakcję w bramce na wybraną kwotę — POST /api/v1/transactions z nagłówkiem X-Api-Key. Bramka zwraca uuid i payment_url.
Autoryzacja. Klient na stronie bramki płaci BLIK-iem (kod 6-cyfrowy) lub pay-by-link (przekierowanie do aplikacji banku). Bramka tworzy zamówienie w PayU (REST v2.1, OAuth).
Webhook PayU → bramka. PayU wysyła powiadomienie POST /webhooks/payu z nagłówkiem OpenPayu-Signature (weryfikacja MD5/SHA256). Bramka oznacza transakcję jako opłaconą/nieudaną.
Polling (gwarancja). Ekran powrotu odpytuje status; bramka aktywnie rekonsyliuje z PayU (getOrderStatus), a transakcje „oczekujące na potwierdzenie” domyka przez capture().
Tryby płatności
classic — klasyczny przepływ z 3DS; app2app — BLIK / pay-by-link z przekierowaniem do aplikacji bankowej. Tryb ustawiany per sklep w polu payment_mode.
3.
Sklep donacyjny NFC (/)
Aktywny
Strona główna serwisu. Wyświetla produkty oznaczone tagami NFC (Serduszko, Kubek, Koszulka, Pin, Brelok). Każdy produkt ma własną minimalną kwotę. Domyślny produkt („Serduszko”, min. 1 zł) otwiera się automatycznie w modalu po wejściu. Kwota jest edytowalna w modalu, a walidacja minimum działa zarówno w przeglądarce, jak i na serwerze. Obsługiwane przez CompanyStoreController.
Trasy
Metoda
URL
Nazwa
Opis
GET
/
home
Lista produktów + modal KUP
POST
/sklep/kup/{slug}
shop.buy
Zakup produktu na wybraną kwotę
Metody kontrolera
index()Pobiera aktywne produkty (sortowanie po sort), wyznacza domyślny (is_default) i renderuje stronę z siatką oraz danymi produktów dla modala (JSON).
purchase($slug)Waliduje kwotę (≥ minimum produktu, ≤ 5000 zł) po stronie serwera, tworzy zamówienie (product_id = null), woła bramkę i przekierowuje do płatności. Komunikat błędu zawiera nazwę produktu i jego minimum.
Pola produktu (model ShopItem → tabela shop_items)
Pole
Typ
Opis
slug
string, unikalny
Identyfikator URL (np. serduszko)
name
string
Nazwa produktu
image
string
Ścieżka do grafiki (raster lub SVG serca)
min_amount
int (grosze)
Minimalna kwota wpłaty (100 = 1 zł)
is_default
bool
Czy produkt domyślny (auto-modal); tylko jeden naraz
tag_uid
string, nullable
UID taga NFC kierującego wprost na ten produkt
active
bool
Widoczność w sklepie
sort
int
Kolejność na liście
Co się dzieje (przepływ zakupu)
Użytkownik wchodzi na / — domyślny produkt otwiera się w modalu (raz na sesję, przez sessionStorage), albo otwiera się produkt wskazany w ?produkt={slug}.
Klik karty produktu otwiera modal z edytowalnym polem kwoty (wstępnie = minimum produktu).
Walidacja w przeglądarce: przy kwocie poniżej minimum przycisk KUP jest blokowany i pojawia się komunikat.
POST /sklep/kup/{slug} — serwer ponownie waliduje kwotę (warstwa nie do obejścia), tworzy Order i transakcję w bramce.
Główna funkcja platformy. Darczyńca zbliża telefon do znacznika NFC w kościele, trafia na stronę parafii, wybiera kwotę i płaci, a na końcu widzi ekran „Bóg zapłać”. Wszystkim steruje StorefrontController; dane parafii zarządzane są w panelu (sekcja 16). Każdy etap loguje zdarzenie do analityki.
Trasy
Metoda
URL
Nazwa
Opis
GET
/main
main
Landing (sekcja 5)
GET
/kategoria/{slug}
category
Lista parafii w kategorii + wyszukiwarka
GET
/t/{tag_uid}
tag
Wejście z taga NFC → przekierowanie
GET
/p/{slug}
product.show
Strona parafii + wybór kwoty
POST
/p/{slug}/kup
product.buy
Utworzenie zamówienia i transakcji
Metody kontrolera
index()Renderuje landing: kategorie wsparcia z bazy (aktywne, najwyższy poziom) z ikoną, etykietą i opisem.
category($slug)Pokazuje parafie danej kategorii (gdy source = parishes) lub pusty stan. Po stronie klienta działa wyszukiwarka po nazwie, mieście i województwie.
tag($tagUid)Szuka parafii po tag_uid → przekierowuje na jej stronę i loguje tag_open. Jeśli to tag produktu sklepu → przekierowuje na sklep z modalem produktu. Nieznany tag → strona 404 „tabliczka nieprzypisana”.
show($slug)Strona parafii; loguje page_view; renderuje formularz wyboru kwoty z presetami.
buy($slug)Waliduje kwotę (2–5000 zł), loguje buy_click, tworzy Order powiązany z parafią i transakcję w bramce, przekierowuje do płatności.
Pola parafii (model Product → tabela products)
Pole
Typ
Opis
name
string
Nazwa parafii
city
string
Miasto
purpose
string
Cel zbiórki (np. „Remont dachu”)
slug
string, unikalny
Identyfikator URL
description_html
text
Opis parafii (WYSIWYG)
price
int (grosze)
Sugerowana kwota tacy (preset bazowy)
tag_uid
string, unikalny
UID taga NFC przypisanego do parafii
main_image
string
Zdjęcie główne parafii
active
bool
Publikacja (sterowana statusem CRM)
phone, website, voivodeship
string
Dane kontaktowe (CRM)
status
enum
CRM: kontakt · test · wdrożenie · aktywna
salesperson_id
FK
Handlowiec opiekujący się parafią
Presety kwot (strona parafii)
Domyślne przyciski: 10, 20, 50, 100, 200 zł + kafelek „inna kwota” zamieniający się w pole liczbowe. Zakres dozwolony: 2–5000 zł. Wybrana kwota aktualizuje etykietę przycisku „Wesprzyj — X zł”.
Co się dzieje (pełny przepływ tacy)
Telefon przy tagu NFC otwiera /t/{tag_uid}.
tag() znajduje parafię, loguje tag_open (lokalnie + asynchronicznie do bramki) i przekierowuje na /p/{slug}.
show() loguje page_view i pokazuje stronę z presetami.
Po wyborze kwoty POST /p/{slug}/kup: buy() loguje buy_click, tworzy zamówienie i transakcję, przekierowuje do PayU.
Po płatności następuje powrót na ekran zwrotu (sekcja 10) — „Bóg zapłać”.
Landing „Technologia, która pomaga czynić dobro” — zbudowany pixel-perfect z makiet Figmy (desktop i mobile). Sekcje są dynamiczne: kategorie „Kogo wspieramy?” pobierane są z bazy i zarządzane z panelu.
Sekcje
Hero — nagłówek z misją platformy.
Kogo wspieramy? — kafelki kategorii (ikona, etykieta HTML, opis); linki do /kategoria/{slug}.
Jak to działa? — 4 kroki: zbliż telefon do NFC → wybierz wsparcie → zapłać → otrzymaj podziękowanie.
Tekst zamykający + stopka z danymi firmy i linkami.
Modal „Wesprzyj” i podgląd linku (OG)
Przycisk „Wesprzyj” w nagłówku otwiera modal domyślnego produktu (/?produkt=serduszko). W <head> ustawione są tagi Open Graph i Twitter z grafiką serca z logo — przy udostępnianiu linku na WhatsApp / Facebook pojawia się ładny podgląd z serduszkiem.
Statyczna strona marketingowa (GET /inwestorzy, nazwa trasy investors) zbudowana 1:1 z makiety Figmy. Hero „Inwestorzy i akcjonariusze”, sekcja misji kapitałowej („Wierzymy, że kapitał może służyć dobru”) oraz karty akcjonariuszy z kwotami inwestycji (kapitałowa + wsparcie usługowe). Renderowana bez kontrolera (Route::view).
Pełny system rekrutacyjny: publiczna lista ofert (zarządzana z panelu), strony ofert, formularz aplikacji z uploadem CV oraz powiadomienie e-mail z załączonym CV. Obsługiwany przez CareersController. CV trafia na prywatny dysk (niedostępny publicznie), a pobrać je można tylko z panelu.
Trasy
Metoda
URL
Nazwa
Opis
GET
/praca
careers
Lista aktywnych ofert
GET
/praca/oferta/{position}
careers.show
Szczegóły oferty
GET/POST
/praca/aplikuj
careers.apply.general
Aplikacja spontaniczna (bez oferty)
GET/POST
/praca/{position}/aplikuj
careers.apply
Aplikacja na konkretną ofertę
Metody kontrolera
index()Lista aktywnych stanowisk; karty z typem zatrudnienia i lokalizacją; wykrywa „pracę zdalną” z treści.
show($position)Pełny opis oferty (WYSIWYG), chipy meta, sekcja „inne oferty”.
applyForm($position?)Renderuje formularz aplikacji (na ofertę lub spontaniczny).
applyStore($position?)Waliduje dane i plik CV, zapisuje zgłoszenie, przenosi CV na prywatny dysk, wysyła e-mail (jeśli skonfigurowany mailer), pokazuje podziękowanie.
Pola oferty (JobPosition → job_positions)
Pole
Typ
Opis
title
string
Nazwa stanowiska
location
string
Lokalizacja
employment_type
string
Rodzaj zatrudnienia
description_html
text
Opis (WYSIWYG)
active
bool
Widoczność publiczna
sort
int
Kolejność
Pola zgłoszenia (JobApplication → job_applications)
Pole
Typ
Opis
job_position_id
FK, nullable
Oferta (null = aplikacja spontaniczna)
name, email, phone
string
Dane kandydata
message
text, nullable
List motywacyjny
cv_path
string
Ścieżka pliku CV na prywatnym dysku
cv_original_name
string
Oryginalna nazwa pliku
is_read
bool
Czy odczytane w panelu
status
enum
pending · accepted · rejected
Walidacja i e-mail
CV: wymagane, do 5 MB, typy pdf/doc/docx (sprawdzane rozszerzenie i MIME). Zgoda RODO: wymagana. Po zapisie generowany jest mail JobApplicationReceived na config('shop.careers_email') z CV w załączniku i adresem kandydata w Reply-To.
Powiadomienia e-mailGotowy — kod wysyłki maila jest kompletny; na produkcji działa tryb „tylko panel” (zgłoszenia + CV trafiają do skrzynki w panelu). Wysyłkę mailem włącza się konfiguracją mailera.
Publiczny formularz kontaktowy (ContactController). Wiadomości zapisywane są do bazy i trafiają do skrzynki w panelu z licznikiem nieprzeczytanych. Temat może być wstępnie wypełniony z linku z oferty pracy (?stanowisko=…).
Po powrocie z bramki OrderReturnController synchronizuje status zamówienia i pokazuje właściwy ekran: sukces („Bóg zapłać”), oczekiwanie (z pollingiem) lub niepowodzenie (z opcją ponowienia). To zabezpiecza przed sytuacją, gdy klient wróci wcześniej niż dotrze webhook.
Trasy i metody
show($order)GET /zwrot/{order} (nazwa order.return) — synchronizuje status z bramki; jeśli opłacone, loguje purchase; renderuje ekran wg statusu.
status($order)GET /zwrot/{order}/status (nazwa order.status) — zwraca JSON {status} dla pollingu.
Ekrany (wg statusu zamówienia)
Status
Widok
Zachowanie
paid
return-success
Animacja świecy, kwota, nazwa parafii, nr potwierdzenia
pending
return-pending
Spinner + polling co 2 s (do ~60 s); po zmianie statusu przeładowanie
failed
return-failure
Komunikat o niepowodzeniu, przyciski „spróbuj ponownie” / „inna parafia”
Sklep komunikuje się z bramką przez serwis GatewayClient oraz odbiera powiadomienia zwrotne (webhooki) o opłaceniu. Zdarzenia analityczne (np. tag_open) wysyłane są asynchronicznie, by nie spowalniać odpowiedzi.
Metody GatewayClient
createTransaction($data)POST do API bramki z danymi: product_external_id, product_name, amount, currency, return_url, notify_url, tag_uid. Zwraca uuid i payment_url.
getTransaction($uuid)Pobiera bieżący status transakcji (używane przy synchronizacji ekranu zwrotu).
sendEvent($type, $tagUid)Wysyła zdarzenie do bramki (np. tag_open).
verifyWebhookSignature($payload, $sig)Weryfikuje podpis HMAC-SHA256 przychodzącego webhooka kluczem API sklepu.
Odbiór webhooka
GatewayWebhookController::handle() (POST /webhooks/gateway) weryfikuje podpis, a następnie ustawia Order.status = paid + paid_at i loguje zdarzenie purchase. SendGatewayEvent (job kolejkowy, dispatchAfterResponse) wysyła eventy do bramki już po odesłaniu odpowiedzi do użytkownika.
Klient bramki to jedyny punkt styku sklepu z warstwą płatności. Kluczowa zasada bezpieczeństwa: sklep nigdy nie ufa przekierowaniu z PayU ani redirectowi powrotnemu — status zamówienia ustala wyłącznie po zweryfikowanym webhooku lub po aktywnym odpytaniu API bramki (getTransaction). Dzięki temu nawet jeśli klient zamknie kartę zanim wróci na stronę, opłacone zamówienie i tak zostanie domknięte powiadomieniem.
Na co uważać: webhook wychodzący z bramki jest podpisany HMAC-SHA256 kluczem API sklepu — odrzuć każde żądanie z niepoprawnym lub brakującym podpisem (ryzyko podszycia i sztucznego oznaczania zamówień jako opłacone). Eventy analityczne (tag_open) wysyłamy po odesłaniu odpowiedzi (dispatchAfterResponse), aby nie wydłużać czasu odpowiedzi widzianego przez użytkownika.
Krytyczny kod: kontrakt żądania tworzącego transakcję (z Api/TransactionController::store po stronie bramki) — to dokładnie te pola, które klient sklepu musi wysłać.
Dlaczego to ważne: amount jest w groszach (liczba całkowita) — przesłanie „10.00” zamiast „1000” to najczęstszy błąd integracji. currency jest twardo ograniczone do PLN, a return_url musi być realnym URL-em (walidacja url), bo to na niego klient wróci po płatności.
Pola webhooka przychodzącego do sklepu
Pole
Za co odpowiada
Kolumna (tabela.kolumna)
Weryfikacja / źródło
transaction_id
UUID transakcji w bramce
orders.transaction_id
musi wskazywać istniejące zamówienie
status
Wynik płatności
orders.status (enum: pending·paid·failed)
ustawiany tylko z webhooka
paid_at
Czas zaksięgowania wpłaty
orders.paid_at (timestamp, nullable)
now() przy paid
signature
Podpis HMAC ładunku
— (nagłówek/payload)
HMAC-SHA256 kluczem API sklepu
Zrzut: wrzuć do public/img/docs/docs-client.png (np. log webhooka, kod GatewayClient, ekran zamówienia opłaconego) — pojawi się tu automatycznie.
12.
Moduł Gateway — bramka PayU
Aktywny
Samodzielna bramka płatnicza działająca jako odrębna aplikacja (pay.please-support-me.com, baza nfc_pay). Integruje PayU, obsługuje wiele sklepów, tagi NFC, transakcje, zdarzenia i statystyki. Zawiera 15 kontrolerów, własne API, panel oraz warstwę dostawców płatności (provider).
Dostawca płatności — PayUProvider
createTransaction()Tworzy zamówienie w PayU (POST /api/v2_1/orders, OAuth client_credentials); zwraca provider_order_id i URL przekierowania.
getOrderStatus()Pobiera status zamówienia z PayU (aktywna rekonsyliacja).
capture()Domyka płatność oczekującą na potwierdzenie (PUT statusu → COMPLETED).
payByLinks()Zwraca listę banków do ekranu wyboru (pay-by-link).
handleWebhook()Weryfikuje OpenPayu-Signature i parsuje powiadomienie. Obsługa BLIK Level 0 i pay-by-link.
Interfejs PaymentProviderInterface pozwala podmienić dostawcę; alternatywą jest MockProvider (tryb testowy).
Logika transakcji — TransactionService
reconcileWithProvider()Aktywnie sprawdza status u PayU (gdy webhook się spóźnia).
markPaid() / markFailed()Idempotentnie ustawia status transakcji.
logEvent()Zapisuje zdarzenie transakcji do bazy.
notifyShop()Wysyła webhook wychodzący do sklepu, podpisany HMAC-SHA256.
Bramka jest celowo wydzielona jako odrębny tenant z własną bazą nfc_pay i dedykowanym połączeniem Eloquent (protected $connection = 'gateway' w każdym modelu). Dzięki temu dane finansowe są fizycznie odseparowane od danych sklepów, a jedne dane uwierzytelniające PayU obsługują wszystkie sklepy. Token OAuth jest cache'owany (payu_access_token, TTL ~12 h z marginesem 5 min), więc nie autoryzujemy się przy każdej transakcji.
Na co uważać: PayU zwraca utworzenie zamówienia jako HTTP 302 z JSON-em w body — dlatego klient HTTP ma wyłączone podążanie za przekierowaniem (allow_redirects => false). Status sukcesu to nie tylko SUCCESS, ale też WARNING_CONTINUE_3DS i WARNING_CONTINUE_REDIRECT (karty/3DS). Pominięcie tych dwóch zerwałoby płatności kartą.
Krytyczny kod: tworzenie zamówienia w PayU (PayUProvider::createTransaction) — budowa ładunku i interpretacja odpowiedzi 302+JSON.
Dlaczego to ważne: extOrderId = UUID transakcji bramki, więc PayU nie utworzy dwóch zamówień dla tej samej transakcji (idempotencja). totalAmount w groszach jako string — typowy błąd to przeliczenie na złotówki.
Pola transakcji i mapowanie na bazę (Transaction → transactions)
Pole
Za co odpowiada
Kolumna (tabela.kolumna)
Typ / wartości
id
UUID transakcji (= uuid u sklepu, = extOrderId w PayU)
transactions.id
uuid, primary, HasUuids
shop_id
Sklep, który utworzył transakcję
transactions.shop_id
FK → shops, constrained
tag_id
Tag NFC (jeśli wejście z tagu)
transactions.tag_id
FK → tags, nullable, nullOnDelete
product_external_id
ID produktu po stronie sklepu
transactions.product_external_id
string
product_name
Nazwa pozycji (opis w PayU)
transactions.product_name
string
amount
Kwota wpłaty
transactions.amount
unsignedInteger (grosze), cast int
currency
Waluta
transactions.currency
char(3), default PLN
status
Stan transakcji
transactions.status
enum: created·pending·paid·failed·abandoned
mode
Tryb płatności (z konfiguracji sklepu)
transactions.mode
enum: classic·app2app
return_url
Powrót klienta do sklepu
transactions.return_url
string(500)
notify_url
Webhook wychodzący do sklepu
transactions.notify_url
string(500), nullable
provider_order_id
ID zamówienia w PayU
transactions.provider_order_id
string, nullable
provider_redirect_url
URL przekierowania z PayU
transactions.provider_redirect_url
string(1000), nullable
paid_at
Czas zaksięgowania
transactions.paid_at
timestamp, nullable
Zrzut: wrzuć do public/img/docs/docs-gateway.png (np. lista transakcji, diagram stanów, panel PayU) — pojawi się tu automatycznie.
13.
Gateway: API i webhooki
Aktywny
Bramka udostępnia REST API dla sklepów (autoryzacja kluczem) oraz endpointy płatności i webhooki. Wszystkie powiadomienia są podpisywane i weryfikowane kryptograficznie.
Cała pewność, że wpłata faktycznie nastąpiła, opiera się na weryfikacji podpisu. Webhook PayU niesie nagłówek OpenPayu-Signature w formacie sender=...;signature=...;algorithm=MD5;content=DOCUMENT. Podpis liczymy jako hash(body + second_key) wskazanym algorytmem i porównujemy hash_equals (porównanie odporne na timing attack). Po poprawnej weryfikacji bramka zawsze odpowiada 200 — w przeciwnym razie PayU ponawia powiadomienie.
Na co uważać: nie polegamy wyłącznie na webhooku. Endpoint statusu (GET /api/v1/transactions/{uuid}) przy każdym odpytaniu robi aktywną rekonsyliację z PayU (reconcileWithProvider) — gdy webhook się spóźni, status i tak zostanie domknięty. Statusy PayU mapujemy zachowawczo: tylko COMPLETED → opłacone, CANCELED → nieudane, reszta (PENDING, WAITING_FOR_CONFIRMATION) jest ignorowana.
Krytyczny kod: weryfikacja podpisu webhooka PayU (PayUProvider::verifySignature + mapowanie statusu) — bez tego każde żądanie mogłoby fałszywie oznaczyć transakcję jako opłaconą.
Dlaczego to ważne: hash_equals zamiast == chroni przed atakiem czasowym na porównanie podpisu. Brak second_key lub pustego nagłówka = natychmiastowe odrzucenie (return false).
Pola żądań API i mapowanie na bazę
Pole (żądanie)
Za co odpowiada
Kolumna (tabela.kolumna)
Walidacja
product_external_id
ID produktu w sklepie
transactions.product_external_id
required · string · max:255
product_name
Nazwa pozycji
transactions.product_name
required · string · max:255
amount
Kwota w groszach
transactions.amount
required · integer · min:1
currency
Waluta
transactions.currency
nullable · in:PLN
return_url
Powrót do sklepu
transactions.return_url
required · url · max:500
notify_url
Webhook sklepu
transactions.notify_url
nullable · url · max:500
tag_uid
Tag NFC źródła
→ transactions.tag_id (lookup)
nullable · string · max:255
type (events)
Typ zdarzenia
events.type
required · in:tag_open
code (BLIK)
Kod BLIK Level 0
— (do PayU)
required · regex:/^\d{6}$/
method (PBL)
Wybrany bank pay-by-link
— (do PayU)
required · in:<lista banków z POS>
Pełna mapa tras płatności (z PaymentController)
Metoda
URL
Co robi
GET
/pay/{uuid}
Wejście klienta; classic → 302 do PayU, app2app → hostowana strona BLIK/banki
POST
/pay/{uuid}/blik
BLIK Level 0 — kod 6 cyfr, potwierdzenie pushem w banku
POST
/pay/{uuid}/bank
pay-by-link — redirect otwierający aplikację banku na telefonie
Zrzut: wrzuć do public/img/docs/docs-gw-api.png (np. log webhooka PayU, ekran BLIK, odpowiedź API) — pojawi się tu automatycznie.
14.
Gateway: panel zarządzania
Aktywny
Panel bramki (osobne logowanie) służy do zarządzania sklepami, tagami NFC, statystykami i leadami. Zawiera też moduły demonstracyjne (anti-theft, tryb testowy płatności).
Pola formularza sklepu i mapowanie na bazę (Shop → shops)
Pole
Za co odpowiada
Kolumna (tabela.kolumna)
Uwagi
name
Nazwa sklepu
shops.name
string
slug
Identyfikator URL sklepu
shops.slug
unique
base_url
Adres bazowy sklepu (do webhooków/powrotów)
shops.base_url
string
api_key
Klucz API (autoryzacja X-Api-Key)
shops.api_key
char(64) unique, $hidden, generowany
payment_mode
Tryb płatności sklepu
shops.payment_mode
enum: classic·app2app, default classic
Krytyczny kod: generowanie klucza API sklepu (Shop::generateApiKey) oraz ukrycie go w serializacji.
// 64 znaki hex = 256 bitów entropii
public static function generateApiKey(): string
{
return bin2hex(random_bytes(32));
}
// klucz nigdy nie wycieka do JSON/odpowiedzi:
protected $hidden = ['api_key'];
// modele bramki zawsze czytają z bazy nfc_pay, niezależnie od tenanta hosta:
protected $connection = 'gateway';
Dlaczego to ważne: random_bytes to kryptograficznie bezpieczny generator (nie rand), a $hidden gwarantuje, że klucz nie pojawi się przypadkiem w żadnej odpowiedzi API. Stałe połączenie gateway izoluje dane finansowe od bazy aktywnego sklepu.
Pola taga NFC i leada (Tag → tags, Lead → leads)
Pole
Za co odpowiada
Kolumna (tabela.kolumna)
Uwagi
shop_id
Sklep właściciel taga
tags.shop_id
FK → shops, cascadeOnDelete
tag_uid
UID fizycznego taga NFC
tags.tag_uid
unique
target_url
Adres docelowy po zbliżeniu
tags.target_url
string
label
Etykieta opisowa
tags.label
nullable
active
Czy tag aktywny
tags.active
boolean, default true
name / email / phone
Dane kontaktowe leada
leads.name·email·phone
string
company
Firma (opcjonalnie)
leads.company
nullable
message
Treść zapytania
leads.message
text
Moduł Anti-theft (AntitheftCheck → antitheft_checks: shop_id, status ok/warning, foreign_tags_found, checked_at) jest oznaczony w kodzie komentarzem // FIKCYJNE — moduł demo, brak realnej detekcji i zawsze zwraca foreign_tags_found = 0 — szkielet gotowy do rozbudowy.
Zrzut: wrzuć do public/img/docs/docs-gw-panel.png (np. lista sklepów, formularz taga, statystyki) — pojawi się tu automatycznie.
15.
Panel: logowanie i dashboard
Aktywny
Panel sklepu (/panel) chroniony jest logowaniem (middleware auth). Po zalogowaniu dashboard pokazuje kondycję sprzedaży. Łącznie panel udostępnia ponad 50 tras.
Logowanie (LoginController)
show()GET /panel/login — formularz (przekierowanie do dashboardu, jeśli zalogowany).
seria dziennych zakupów (30 dni) do wykresu słupkowego
Tabele
userseventsorders
Pola formularza logowania i mapowanie na bazę
Pole
Za co odpowiada
Kolumna (tabela.kolumna)
Walidacja
email
Login administratora
users.email
required · email
password
Hasło (porównywane z hashem)
users.password (hash)
required
Na co uważać: błąd logowania zwraca celowo ogólny komunikat „Niepoprawny login lub hasło.” (nie zdradza, czy istnieje konto o danym e-mailu). Po sukcesie regenerujemy sesję (session()->regenerate()) — ochrona przed session fixation. Logowanie jest „remember me” na stałe (drugi argument Auth::attempt(..., true)).
Krytyczny kod: logowanie do panelu (LoginController::login) — walidacja, próba uwierzytelnienia i twardnienie sesji.
Dlaczego to ważne: ogólny komunikat błędu utrudnia enumerację kont, a regeneracja ID sesji po zalogowaniu zamyka klasę ataków na przejęcie sesji. Wylogowanie dodatkowo unieważnia sesję i regeneruje token CSRF.
Panel: ekran logowania i dashboard z metrykami.
Zrzut: wrzuć do public/img/docs/docs-panel-auth.png (np. formularz logowania, dashboard, wykres) — pojawi się tu automatycznie.
16.
Panel: Parafie + CRM
Aktywny
Najbogatsza sekcja panelu (ProductController): pełny CRUD parafii wraz z galerią zdjęć, opisem WYSIWYG, statystykami oraz wbudowanym CRM (statusy leada, notatki, przypisany handlowiec). Publikacja parafii sterowana jest statusem — ustawienie „aktywna” włącza widoczność publiczną.
Metody
index()Lista parafii z filtrem statusu i wyszukiwarką (nazwa/miasto/województwo) + liczniki statusów.
create() / store()Dodawanie parafii.
edit() / update()Edycja (z notatkami CRM).
toggle()Włącz/wyłącz widoczność.
deleteImage()Usunięcie zdjęcia z galerii.
stats()Statystyki konwersji parafii (eventy, wykres).
status()Szybka zmiana statusu CRM (AJAX) — steruje publikacją.
Reguły poniżej pochodzą wprost z ProductController::validated(). Cena wpisywana jest w złotych, a do bazy trafia w groszach. Status steruje publikacją: tylko aktywna ustawia active = true (parafia widoczna publicznie); pozostałe traktowane są jak lead (ukryta).
Pole
Za co odpowiada
Kolumna (tabela.kolumna)
Walidacja
name
Nazwa parafii
products.name
required · string · max:255
city
Miejscowość
products.city
nullable · string · max:255
price
Kwota sugerowana (zł → grosze)
products.price (unsignedInteger, grosze)
required · regex /^\d{1,5}([.,]\d{1,2})?$/
tag_uid
UID taga NFC parafii
products.tag_uid
required · string · max:255 · unique(ignore self)
pickup_instruction
Instrukcja / informacja dodatkowa
products.pickup_instruction
nullable · string · max:2000
description_html
Opis WYSIWYG (Quill)
products.description_html (text)
nullable · string
phone
Telefon kontaktowy (CRM)
products.phone
nullable · string · max:255
website
Strona www parafii (CRM)
products.website
nullable · string · max:255
voivodeship
Województwo (CRM, mapa)
products.voivodeship
nullable · string · max:255
status
Status w lejku wdrożenia
products.status (string 20, default kontakt)
required · in:kontakt,test,wdrozenie,aktywna
salesperson_id
Przypisany handlowiec
products.salesperson_id
nullable · integer · exists:salespeople,id
main_image
Zdjęcie główne
products.main_image
image · max:8192 (KB)
gallery[]
Zdjęcia galerii (wielokrotny upload)
product_images.path, .sort
each: image · max:8192
slug
Identyfikator URL (auto z nazwy)
products.slug
generowany, unikalny iteracyjnie
Krytyczny kod: normalizacja ceny i sterowanie publikacją statusem (ProductController::validated) — pojedyncze miejsce, w którym złotówki stają się groszami, a status decyduje o widoczności.
// cena wpisywana w złotówkach — w bazie trzymamy grosze
$data['price'] = (int) round(((float) str_replace(',', '.', (string) $data['price'])) * 100);
// status steruje publikacją: 'aktywna' => publiczna, pozostałe => lead (ukryta)
$data['active'] = $data['status'] === 'aktywna';
$data['salesperson_id'] = $data['salesperson_id'] ?: null;
Dlaczego to ważne: konwersja zł→grosze jest tu jedyna i spójna; obsługuje zarówno kropkę, jak i przecinek (PL). Sprzężenie active = (status === 'aktywna') oznacza, że nie da się opublikować parafii inaczej niż przez ustawienie statusu „aktywna” — to zapobiega publikacji niedokończonych leadów.
Pola notatki CRM i mapowanie (ParishNote → parish_notes)
Pole
Za co odpowiada
Kolumna (tabela.kolumna)
Walidacja
body
Treść notatki
parish_notes.body (text)
required · string · max:5000
type
Typ zdarzenia CRM
parish_notes.type (string 20)
required · in:kontakt,telefon,mail,spotkanie,inne
author
Autor (z konta użytkownika)
parish_notes.author
auto: name/email zalogowanego
product_id
Parafia, której dotyczy
parish_notes.product_id
FK → products, cascadeOnDelete
Notatki dodawane są AJAX-em i zwracane jako JSON (bez przeładowania) wraz z etykietą typu i datą. Usunięcie notatki sprawdza, że należy ona do tej parafii (abort_unless($note->product_id === $product->id, 404)) — ochrona przed manipulacją ID.
Panel parafii: formularz CRM z galerią, statusem i notatkami.
Zrzut: wrzuć do public/img/docs/docs-panel-parafie.png (np. lista parafii z zakładkami statusów, formularz, panel notatek) — pojawi się tu automatycznie.
17.
Panel: Kategorie
Aktywny
Drzewo kategorii „Kogo wspieramy?” (CategoryController) sterujące sekcjami na stronie głównej. Obsługuje zagnieżdżanie (parent/child), zmianę kolejności i ikony, z ochroną przed cyklami.
Metody
index()Spłaszczone drzewo z wcięciami (rekurencja).
store() / update() / destroy()CRUD; usunięcie przenosi dzieci na poziom wyższy.
reorder()Zamiana kolejności (swap pozycji) w górę/dół.
Pola (Category → categories)
Pole
Opis
parent_id
Kategoria nadrzędna (zagnieżdżenie)
label / label_html / label_text
Etykiety (tekst + wersja HTML)
slug
Identyfikator URL (auto z nazwy)
intro
Opis sekcji
icon
Ikona (upload)
source
none (pusta) lub parishes (lista parafii)
position
Kolejność w drzewie
active
Widoczność
Tabele
categories
Pola formularza kategorii i mapowanie na bazę
Reguły z CategoryController::validated(). label_text domyślnie przyjmuje wartość label, a label_html — escaped label_text; slug generowany jest z nazwy, gdy puste, i uspójniany do unikalności iteracyjnie.
Pole
Za co odpowiada
Kolumna (tabela.kolumna)
Walidacja
parent_id
Kategoria nadrzędna (zagnieżdżenie)
categories.parent_id
nullable · integer · exists (≠ self)
label
Nazwa kategorii
categories.label
required · string · max:255
label_html
Etykieta z dopuszczalnym <br> (render)
categories.label_html (text)
nullable · string · max:1000
label_text
Czysta wersja tekstowa
categories.label_text
nullable · string · max:255
slug
Identyfikator URL
categories.slug
nullable · max:255 · unique(ignore self)
intro
Opis sekcji
categories.intro (text)
nullable · string · max:2000
source
Źródło pozycji na stronie kategorii
categories.source (string 20)
required · in:none,parishes
position
Kolejność w drzewie
categories.position
nullable · integer · 0–65535
active
Widoczność
categories.active
nullable · boolean
icon
Ikonka (upload do storage public)
categories.icon
nullable · image · max:8192
Krytyczny kod: ochrona przed cyklami w drzewie kategorii (CategoryController::parentOptions + descendantIds) — bez tego można by ustawić kategorię jako własnego potomka i zapętlić render.
// z listy możliwych rodziców wykluczamy edytowaną kategorię i WSZYSTKICH jej potomków
$excluded = $current ? $this->descendantIds($all, $current->id) : [];
foreach ($all->where('parent_id', $parentId) as $cat) {
if (in_array($cat->id, $excluded, true)) { continue; } // blokada cyklu
$options[$cat->id] = str_repeat('— ', $depth) . $cat->label_text;
}
// usunięcie rodzica NIE kasuje dzieci — FK nullOnDelete czyni je top-level
Dlaczego to ważne: rekurencyjne descendantIds zbiera całe poddrzewo, więc select rodzica nigdy nie zaproponuje opcji tworzącej cykl. Usunięcie kategorii dzięki nullOnDelete przenosi dzieci na poziom wyższy zamiast je kasować.
Panel kategorii: drzewo z wcięciami, zmiana kolejności i ikony.
Zrzut: wrzuć do public/img/docs/docs-panel-kat.png (np. drzewo kategorii, formularz, select rodzica) — pojawi się tu automatycznie.
18.
Panel: Handlowcy
Aktywny
CRUD handlowców (SalespersonController) z przypisaniem obsługiwanych województw i licznikiem przypisanych parafii. Integruje się z CRM parafii i mapą pokrycia.
Pola (Salesperson → salespeople)
Pole
Opis
name
Imię i nazwisko
email, phone
Kontakt (opcjonalne)
voivodeships
Tablica obsługiwanych województw (JSON, 16 do wyboru)
active
Czy aktywny
Tabele
salespeopleproductspotential_parishes
Pola formularza handlowca i mapowanie na bazę
Reguły z SalespersonController::validated(). Lista województw jest słownikiem zamkniętym (stała Salesperson::VOIVODESHIPS, 16 pozycji RP) — każde wybrane województwo musi do niej należeć.
Pole
Za co odpowiada
Kolumna (tabela.kolumna)
Walidacja
name
Imię i nazwisko
salespeople.name
required · string · max:255
email
E-mail kontaktowy
salespeople.email
nullable · email · max:255
phone
Telefon
salespeople.phone
nullable · string · max:255
voivodeships[]
Obsługiwane województwa
salespeople.voivodeships (text, cast array→JSON)
nullable · array; each in:<16 województw>
active
Czy handlowiec aktywny
salespeople.active
nullable · boolean
Krytyczny kod: walidacja województw względem słownika i zapis jako JSON (SalespersonController::validated + cast modelu).
// walidacja: każde województwo musi należeć do zamkniętego słownika
'voivodeships' => ['nullable', 'array'],
'voivodeships.*' => ['string', 'in:' . implode(',', Salesperson::VOIVODESHIPS)],
// pusta lista => null (kolumna nullable); tablica => cast 'array' w modelu => JSON
$data['voivodeships'] = ! empty($data['voivodeships']) ? array_values($data['voivodeships']) : null;
Dlaczego to ważne: zamknięty słownik (in:) blokuje literówki i „lewe” województwa, dzięki czemu filtr mapy pokrycia i przypisania CRM zawsze działają na spójnych wartościach. Cast 'array' w modelu serializuje listę do JSON i deserializuje z powrotem bez ręcznego json_encode.
Panel handlowców: lista z licznikiem parafii i wybór województw.
Zrzut: wrzuć do public/img/docs/docs-panel-hand.png (np. lista handlowców, formularz z checkboxami województw) — pojawi się tu automatycznie.
19.
Panel: Parafie do obdzwonienia + mapa pokrycia
Aktywny
Lista leadów parafialnych (PotentialParishController) zaimportowanych z OpenStreetMap, z lejkiem obdzwaniania i interaktywną mapą pokrycia (Leaflet). Filtry: województwo, status, handlowiec, obecność telefonu. Paginacja po 50.
Metody
index()Lista z filtrami i paginacją; liczniki per status.
updateStatus()Zmiana statusu/handlowca/notatki/telefonu (AJAX); ustawia called_at przy pierwszym kontakcie.
coverageData()Zwraca punkty (JSON) do mapy — tylko rekordy ze współrzędnymi, z kolorem wg statusu.
Mapa pokrycia
GET /panel/coverage — mapa Leaflet z klastrowaniem markerów, licznikami per województwo i status, popupami (nazwa, miasto, telefon, status, handlowiec) i filtrami przeładowującymi dane AJAX-em.
Reguły z PotentialParishController::updateStatus(). Dataset (~20 tys. parafii) pochodzi z OpenStreetMap (import parishes:import); telefon i adres uzupełniane są później (parishes:enrich / inline w panelu), bo OSM ich nie zawiera.
Krytyczny kod: stempel pierwszego kontaktu i filtr obecności telefonu (PotentialParishController) — called_at ustawiamy raz, a domyślny widok pokazuje tylko parafie z numerem.
// stempel pierwszego kontaktu — ustawiany RAZ, przy przejściu do „zadzwoniono”
if ($data['status'] === 'zadzwoniono' && $potentialParish->called_at === null) {
$potentialParish->called_at = now();
}
// filtr telefonu: domyślnie 'with' (pokazujemy TYLKO parafie z numerem)
$hasPhone = $request->query('has_phone', 'with');
if ($hasPhone === 'with') {
$query->whereNotNull('phone')->where('phone', '!=', '');
}
Dlaczego to ważne: warunek called_at === null gwarantuje, że data pierwszego telefonu nie jest nadpisywana przy kolejnych zmianach statusu. Domyślny filtr with chroni handlowca przed listą 20 tys. rekordów bez numerów — od razu widzi to, co można obdzwonić.
Mapa pokrycia (coverageData) — transfer danych
Endpoint GET /panel/coverage ładuje markery AJAX-em (NIE wkleja 20 tys. punktów do HTML). coverageData() zwraca tylko niezbędne kolumny (id, name, city, voivodeship, lat, lon, phone, status, salesperson_id, note) plus kolor wg statusu — oszczędność transferu i czasu renderu. Filtry mapy są te same co listy, więc markery można zawężać re-fetchem.
Panel leadów: lista do obdzwonienia + interaktywna mapa pokrycia (Leaflet).
Zrzut: wrzuć do public/img/docs/docs-panel-leady.png (np. mapa z klastrami markerów, lista z filtrami, popup parafii) — pojawi się tu automatycznie.
20.
Panel: Sklep — produkty NFC
Aktywny
CRUD produktów sklepu donacyjnego (ShopItemController): minimalna kwota, tag NFC, produkt domyślny (tylko jeden), upload grafiki, kolejność i aktywność. Cena wpisywana w złotych, zapisywana w groszach.
Metody
index()Lista produktów (sort, miniatura, min. kwota, tag, domyślny, status).
store() / update()Zapis; ustawienie „domyślny” zdejmuje flagę z pozostałych produktów.
Reguły z ShopItemController::validated(). Kwota minimalna wpisywana jest w złotych (min_amount_pln) i przeliczana na grosze. Slug generowany ze slugu lub z nazwy; grafika trafia na dysk public, a ścieżka zapisywana z prefiksem storage/.
Pole
Za co odpowiada
Kolumna (tabela.kolumna)
Walidacja
name
Nazwa produktu
shop_items.name
required · string · max:255
slug
Identyfikator URL
shop_items.slug
nullable · max:255 · unique(ignore self)
min_amount_pln
Minimalna kwota (zł → grosze)
shop_items.min_amount (unsignedInteger, grosze)
required · integer · 1–5000
tag_uid
UID taga NFC kierującego na sklep
shop_items.tag_uid
nullable · max:255 · unique(ignore self)
sort
Kolejność na liście
shop_items.sort
nullable · integer · 0–65535
image_file
Grafika produktu
shop_items.image (ścieżka „storage/…”)
nullable · image · max:5120 (KB)
is_default
Produkt domyślny („Serduszko”, auto-modal)
shop_items.is_default
boolean (tylko jeden naraz)
active
Widoczność produktu
shop_items.active
nullable · boolean
Krytyczny kod: wymuszenie pojedynczego produktu domyślnego (ShopItemController::applyDefault) i przeliczenie zł→grosze.
// zł → grosze (kwota minimalna)
'min_amount' => (int) $data['min_amount_pln'] * 100,
// tylko JEDEN produkt może być domyślny — zdejmij flagę z pozostałych
if ($request->boolean('is_default')) {
ShopItem::where('id', '!=', $item->id)->update(['is_default' => false]);
$item->update(['is_default' => true]);
}
Dlaczego to ważne: produkt domyślny („Serduszko”) pokazuje się w modalu po wejściu na stronę sklepu — gdyby dwa były domyślne, modal byłby niejednoznaczny. applyDefault atomowo zdejmuje flagę ze wszystkich innych przed ustawieniem nowego domyślnego.
Panel sklepu: lista produktów NFC z miniaturą, kwotą i flagą domyślnego.
Zrzut: wrzuć do public/img/docs/docs-panel-sklep.png (np. lista produktów, formularz z kwotą i tagiem) — pojawi się tu automatycznie.
21.
Panel: Praca — stanowiska
Aktywny
CRUD ofert pracy (PositionController) z opisem WYSIWYG, lokalizacją, typem zatrudnienia, kolejnością i licznikiem aplikacji per oferta. Oferty pojawiają się publicznie na /praca.
Reguły z PositionController::validated(). Oferty z active = true pojawiają się publicznie na /praca; sort ustala kolejność wyświetlania.
Pole
Za co odpowiada
Kolumna (tabela.kolumna)
Walidacja
title
Tytuł stanowiska
job_positions.title
required · string · max:255
location
Lokalizacja
job_positions.location
nullable · string · max:255
employment_type
Rodzaj zatrudnienia (np. etat, wolontariat)
job_positions.employment_type
nullable · string · max:255
description_html
Opis WYSIWYG
job_positions.description_html (text)
nullable · string
sort
Kolejność na liście
job_positions.sort
nullable · integer · 0–65535
active
Czy oferta widoczna publicznie
job_positions.active
nullable · boolean
Na co uważać: usunięcie stanowiska nie kasuje powiązanych zgłoszeń — FK job_applications.job_position_id ma nullOnDelete, więc aplikacje stają się „spontaniczne” (bez oferty), zamiast zniknąć. Lista stanowisk pokazuje licznik aplikacji per oferta (withCount('applications')).
Panel pracy: lista stanowisk z licznikiem aplikacji.
Zrzut: wrzuć do public/img/docs/docs-panel-praca.png (np. lista ofert, formularz stanowiska) — pojawi się tu automatycznie.
22.
Panel: Aplikacje rekrutacyjne
Aktywny
Skrzynka zgłoszeń rekrutacyjnych (ApplicationController) z pobieraniem CV z prywatnego dysku, statusami i filtrami. Licznik nieprzeczytanych widoczny w nawigacji panelu.
Metody
index()Skrzynka (najnowsze na górze), filtry po ofercie i statusie, liczniki.
show()Szczegóły zgłoszenia; oznacza jako przeczytane.
cv()Pobranie pliku CV z prywatnego dysku.
updateStatus()Zmiana statusu: do sprawdzenia / zaakceptowany / odrzucony.
destroy()Usunięcie zgłoszenia wraz z plikiem CV.
Tabele
job_applicationsjob_positions
Pola zgłoszenia rekrutacyjnego i mapowanie na bazę
Zgłoszenia trafiają z publicznego formularza /praca; panel służy do ich przeglądania, zmiany statusu i pobrania CV. job_position_id = NULL oznacza aplikację spontaniczną (bez konkretnej oferty).
Krytyczny kod: bezpieczne pobieranie CV (ApplicationController::cv) — plik leży na dysku prywatnym i jest serwowany wyłącznie zalogowanemu adminowi.
// CV na dysku 'local' (NIE public) — niedostępne z URL-a; tylko przez panel
abort_unless($application->cv_path && Storage::disk('local')->exists($application->cv_path), 404);
return Storage::disk('local')->download(
$application->cv_path,
$application->cv_original_name ?: basename($application->cv_path)
);
// usunięcie zgłoszenia kasuje też plik CV (sprzątanie dysku)
Dlaczego to ważne: CV zawiera dane osobowe — przechowywanie na dysku local (poza public/) oznacza, że nie da się go pobrać bez przejścia przez kontroler chroniony logowaniem. Walidacja exists chroni przed 500 przy zgubionym pliku.
Panel aplikacji: skrzynka zgłoszeń ze statusami i pobieraniem CV.
Zrzut: wrzuć do public/img/docs/docs-panel-apl.png (np. skrzynka zgłoszeń, szczegóły kandydata, przycisk CV) — pojawi się tu automatycznie.
23.
Panel: Wiadomości
Aktywny
Skrzynka wiadomości z formularza kontaktowego (MessageController) z licznikiem nieprzeczytanych.
Metody
index()Lista wiadomości (najnowsze na górze).
show()Szczegóły; oznacza jako przeczytane.
destroy()Usunięcie.
Tabele
contact_messages
Pola wiadomości kontaktowej i mapowanie na bazę
Wiadomości pochodzą z publicznego formularza kontaktowego; panel jest skrzynką odbiorczą (najnowsze na górze, licznik nieprzeczytanych w nawigacji). Otwarcie wiadomości oznacza ją jako przeczytaną.
Pole
Za co odpowiada
Kolumna (tabela.kolumna)
Uwagi
name
Imię i nazwisko nadawcy
contact_messages.name
string
email
Adres zwrotny
contact_messages.email
string
phone
Telefon (opcjonalnie)
contact_messages.phone
nullable
subject
Temat
contact_messages.subject
nullable
message
Treść wiadomości
contact_messages.message (text)
wymagane
is_read
Czy przeczytane
contact_messages.is_read
boolean, auto przy otwarciu
Na co uważać: model ma public $timestamps = false i własne created_at z DB default (useCurrent()) — nie ma kolumny updated_at. Oznaczenie jako przeczytane to jedyna mutacja rekordu w panelu (poza usunięciem).
Panel wiadomości: skrzynka kontaktowa z licznikiem nieprzeczytanych.
Zrzut: wrzuć do public/img/docs/docs-panel-wiad.png (np. lista wiadomości, widok pojedynczej wiadomości) — pojawi się tu automatycznie.
24.
Eventy i analityka
Aktywny
System rejestruje każde istotne zdarzenie — od zbliżenia telefonu po finalną wpłatę — co zasila statystyki w panelu i pozwala mierzyć konwersję. Zdarzenia trzymane są w tabeli events (z indeksem po typie i dacie), a agregacją zajmuje się ShopStatsService.
W systemie istnieją dwie odrębne tabele events (po jednej w bazie sklepu i w bazie bramki), o różnych zestawach typów. To celowe: sklep mierzy lejek sprzedażowy parafii/produktu, a bramka — lejek płatności. Obie mają indeksy po typie i dacie, by agregacje były szybkie.
Typ
Moduł / tabela
Kiedy powstaje
Kolumna źródłowa
tag_open
Sklep · events.type
Zbliżenie telefonu do tagu NFC
enum
page_view
Sklep · events.type
Wyświetlenie strony parafii/produktu
enum
buy_click
Sklep · events.type
Kliknięcie „Wesprzyj / Kup”
enum
purchase
Sklep · events.type
Potwierdzona wpłata
enum
tag_open
Bramka · events.type
Otwarcie taga (raport z API sklepu)
enum
payment_started
Bramka · events.type
Utworzenie zamówienia u operatora
enum
payment_success
Bramka · events.type
Webhook/rekonsyliacja: opłacone
enum
payment_failed
Bramka · events.type
Anulowane / nieudane
enum
Krytyczny kod: rejestracja eventu z API (Api/EventController::store) — sklep raportuje zdarzenia kluczem API, a tag jest dowiązywany po UID w obrębie tego sklepu.
// POST /api/v1/events — sklep autoryzowany middleware (X-Api-Key → $shop)
$data = $request->validate([
'type' => ['required', 'in:tag_open'],
'tag_uid' => ['nullable', 'string', 'max:255'],
]);
// tag szukany TYLKO w obrębie sklepu, który raportuje (izolacja tenantów)
$tag = Tag::where('shop_id', $shop->id)->where('tag_uid', $data['tag_uid'])->first();
Event::create(['shop_id' => $shop->id, 'tag_id' => $tag?->id, 'type' => $data['type']]);
Dlaczego to ważne: dowiązanie taga warunkowane shop_id uniemożliwia jednemu sklepowi zaraportowanie eventu na tag innego sklepu (izolacja danych w multi-tenant). tag?->id pozwala zapisać event nawet bez znanego taga.
Metryki dashboardu (ShopStatsService)
Metryka
Jak liczona
Źródło
Otwarcia / wyświetlenia / kliknięcia
count po typie
events
Opłacone zamówienia
count statusu paid
orders.status
Przychód łączny
suma amount (grosze → zł)
orders.amount
Konwersja %
zakupy / otwarcia
events + orders
Seria dzienna (30 dni)
group by data
orders / events
Analityka: lejek zdarzeń i wykres dziennej konwersji.
Zrzut: wrzuć do public/img/docs/docs-analityka.png (np. dashboard z metrykami, wykres słupkowy zakupów) — pojawi się tu automatycznie.
25.
Pełny schemat bazy danych
Aktywny
18 migracji budujących ~25 tabel w trzech bazach (multi-tenant). Poniżej wszystkie tabele z kluczowymi kolumnami.
nullable (zamówienia sklepu nie wiążą się z products)
orders.status
enum
pending·paid·failed
shop_items.min_amount
unsignedInteger
grosze
shop_items.tag_uid
string
nullable · unique
shop_items.is_default
boolean
default false (tylko jeden true)
categories.parent_id
FK
→ categories · nullOnDelete (samoodniesienie)
categories.source
string(20)
none·parishes
salespeople.voivodeships
text
cast array → JSON
potential_parishes.lat / .lon
decimal(10,7)
współrzędne
potential_parishes.status
string
default nowa · index
parish_notes.product_id
FK
→ products · cascadeOnDelete
parish_notes.type
string(20)
default kontakt
job_applications.job_position_id
FK
nullable · nullOnDelete
job_applications.cv_path
string
dysk local (prywatny)
job_applications.status
string(20)
default pending · index
contact_messages.is_read
boolean
default false
Mapa kluczy obcych i zachowań kasowania
Relacja (FK)
onDelete
Efekt
tags.shop_id → shops
cascade
usunięcie sklepu kasuje jego tagi
transactions.tag_id → tags
nullOnDelete
transakcja zostaje, traci tag
product_images.product_id → products
cascade
usunięcie parafii kasuje galerię
parish_notes.product_id → products
cascade
usunięcie parafii kasuje notatki
products.salesperson_id → salespeople
nullOnDelete
parafia traci handlowca, nie znika
categories.parent_id → categories
nullOnDelete
dzieci stają się top-level
job_applications.job_position_id → job_positions
nullOnDelete
aplikacja staje się spontaniczna
Ważne — multi-tenant na poziomie połączeń: tabela events istnieje w dwóch bazach o różnych enumach type; modele bramki wymuszają połączenie nfc_pay niezależnie od aktywnego tenanta.
// każdy model bramki:
protected $connection = 'gateway'; // zawsze nfc_pay, nawet gdy host = sklep// połączenie 'mysql' jest podmieniane per host w ResolveTenant:
config(['database.connections.mysql.database' => $tenant['database']]);
Zrzut: wrzuć do public/img/docs/docs-baza.png (np. diagram ERD, lista tabel w phpMyAdmin) — pojawi się tu automatycznie.
26.
Stack technologiczny i statystyki
Aktywny
Lekki, nowoczesny stos oparty o Laravel 12 — bez ciężkiego frontendu SPA, co przekłada się na szybkość i prostotę utrzymania.
Dokument wygenerowany automatycznie na podstawie analizy kodu źródłowego. Moduły oznaczone „Aktywny” działają na produkcji please-support-me.com; „Demo” są zbudowane i działają w trybie pokazowym/testowym.