feat(121+122): smsplanet conversation, notifications, default footer
Phase 121 — SMSPLANET Conversation + Notifications:
- migration 20260512_000110 adds smsplanet conversation + notifications tables
- src/Modules/Sms (SmsConversationService, SmsMessageRepository, SmsplanetWebhookController)
- src/Modules/Notifications (Repository, Controller, ApiController)
- order SMS tab, notification center, sender mode, inbound webhook
- public notifications.js + layouts/app.php integration
Phase 122 — SMSPLANET Default SMS Footer:
- migration 20260512_000111 adds smsplanet_integration_settings.default_footer
- footer appended to test SMS and order SMS, validated against 918 char limit
- settings textarea + compact order SMS note when footer configured
Bundled (could not split per-phase without hunk staging):
- routes/web.php (also carries Phase 118 fakturownia redirects)
- DOCS/{ARCHITECTURE,DB_SCHEMA,TECH_CHANGELOG}.md (118 + 121 + 122 entries)
- .paul/codebase/{architecture,db_schema,tech_changelog}.md (118 + 121 + 122)
- .paul/STATE.md, ROADMAP.md, changelog/2026-05-12.md (UNIFY closure)
Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
@@ -20,12 +20,15 @@ Wystawianie faktur dla klientow z NIP poprzez integracje z Fakturownia (app.fakt
|
||||
| 118 | Fakturownia Single Instance | 1/1 | Applied (2026-05-12; migration/manual Fakturownia verification pending) |
|
||||
| 119 | Re-import total_paid Protection | 1/1 | Complete (2026-05-12; phpunit run + manual shoppro smoke pending env) |
|
||||
| 120 | Alert Component Unification | 1/1 | Complete (2026-05-12; CSS rebuilt; smoke tests pending operator) |
|
||||
| 121 | SMSPLANET Conversation + Notifications | 1/1 | Complete (2026-05-12; live SMS/browser smoke pending operator) |
|
||||
| 122 | SMSPLANET Default SMS Footer | 1/1 | Complete (2026-05-12; live SMS smoke + over-limit UI test pending operator) |
|
||||
|
||||
Planowane kolejne fazy v3.7 (kandydaci, do rozplanowania):
|
||||
- Eksport XLSX listy wystawionych faktur (analogicznie do paragonow)
|
||||
- Idempotencja podwojnego POST do Fakturowni (INVOICE-IDEMP-115)
|
||||
- Event automatyzacji `invoice.created` (jezeli operator chce wysylac faktury mailem)
|
||||
- Automatyzacje SMS / odbior odpowiedzi SMS po aktywacji HostedSMS
|
||||
- SMSPLANET conversation mode: wybor nadpis/numer 2WAY, odbior odpowiedzi, historia SMS w zamowieniu i notification center - Phase 121 planning
|
||||
- Manualne potwierdzenie SMSPLANET na zywej bazie i danych produkcyjnych
|
||||
- Backfill `curl_close()` w `ShopproIntegrationsRepository` (PHP 8.5 compat, poza zakresem 115)
|
||||
|
||||
@@ -501,4 +504,4 @@ Archive: `.paul/milestones/v0.1-ROADMAP.md`
|
||||
|
||||
---
|
||||
*Roadmap created: 2026-03-12*
|
||||
*Last updated: 2026-05-12 - Phase 118 (Fakturownia Single Instance) applied with environment verification gaps; v3.7 milestone in progress*
|
||||
*Last updated: 2026-05-12 - Phase 122 SMSPLANET default footer complete (UNIFY closed; phase git commit + Phase 118 cleanup pending)*
|
||||
|
||||
@@ -5,41 +5,41 @@
|
||||
See: .paul/PROJECT.md (updated 2026-05-07)
|
||||
|
||||
**Core value:** Sprzedawca moze obslugiwac zamowienia ze wszystkich kanalow sprzedazy i nadawac przesylki bez przelaczania sie miedzy platformami.
|
||||
**Current focus:** v3.7 Invoices + operational integrations - Phase 118 Fakturownia single-instance APPLY complete; DB/manual verification remains environment-dependent.
|
||||
**Current focus:** v3.7 Invoices + operational integrations - Phase 122 SMSPLANET default SMS footer complete (UNIFY closed).
|
||||
|
||||
## Current Position
|
||||
|
||||
Milestone: v3.7 Invoices (Fakturownia integration) - In progress
|
||||
Phase: 120 of TBD (Alert Component Unification) - Complete
|
||||
Plan: 120-01 complete (UNIFY closed)
|
||||
Status: Loop closed; CSS rebuilt inline; manual smoke tests pending operator (fakturownia test, login error, dismiss).
|
||||
Last activity: 2026-05-12 - UNIFY complete for .paul/phases/120-alert-component-unification/120-01-PLAN.md
|
||||
Phase: 122 of TBD (SMSPLANET Default SMS Footer) - Complete
|
||||
Plan: 122-01 complete
|
||||
Status: UNIFY complete, ready to plan next phase
|
||||
Last activity: 2026-05-12 22:00:00 - UNIFY closed for .paul/phases/122-smsplanet-default-sms-footer/122-01-PLAN.md
|
||||
|
||||
Progress:
|
||||
- Milestone v3.7: [##########] ~90% (Phase 113-117 closed; Phase 118 applied; Phase 119 complete; Phase 120 complete)
|
||||
- Phase 120: [##########] 100% - Complete
|
||||
- Milestone v3.7: [#########-] ~95% (Phase 113-117 closed; Phase 118 applied; Phase 119-122 complete)
|
||||
- Phase 122: [##########] 100%
|
||||
|
||||
## Loop Position
|
||||
|
||||
Current loop state:
|
||||
```
|
||||
PLAN -> APPLY -> UNIFY
|
||||
done done done [Loop closed - ready for transition]
|
||||
done done done [Loop complete - ready for next PLAN]
|
||||
```
|
||||
|
||||
## Session Continuity
|
||||
|
||||
Last session: 2026-05-12
|
||||
Stopped at: Phase 120 UNIFY closed
|
||||
Next action: Phase transition (git commit feat(120): alert component unification) then choose next phase candidate from v3.7 backlog or pause.
|
||||
Resume file: .paul/phases/120-alert-component-unification/120-01-SUMMARY.md
|
||||
Last session: 2026-05-12 22:00:00
|
||||
Stopped at: Phase 122 UNIFY closed (transition + git commit pending; worktree dirty with Phase 118 files)
|
||||
Next action: Resolve Phase 118 UNIFY/commit, then scope a clean Phase 122 commit and pick next v3.7 phase
|
||||
Resume file: .paul/ROADMAP.md
|
||||
|
||||
## Pending parallel work
|
||||
- Phase 118 still awaiting UNIFY (.paul/phases/118-fakturownia-single-instance/118-01-SUMMARY.md exists; DB verification gated on local MySQL).
|
||||
|
||||
## Git State
|
||||
|
||||
Last phase commit: feat(117): smsplanet integration settings
|
||||
Last phase commit: 933dfcc feat(120): alert component unification
|
||||
Branch: main
|
||||
|
||||
## Pending Actions
|
||||
@@ -51,6 +51,10 @@ Branch: main
|
||||
- HostedSMS inbound replies: requires DCS/HostedSMS activation before implementation.
|
||||
- Phase 117 follow-up: run migration when XAMPP MySQL is online and manually test real SMSPLANET sends for Bearer token and key + password.
|
||||
- Phase 119 follow-up: `composer install` + `vendor/bin/phpunit tests/Unit/OrderImportRepositoryTest.php` to run the 3 new tests; manual smoke test re-syncing order #976 from shoppro to confirm `total_paid=91.00` persists across re-import.
|
||||
- Phase 121 follow-up: manually verify SMSPLANET settings/test send, inbound webhook insertion, order SMS tab, and notification polling/browser notification.
|
||||
- Phase 121 transition note: git commit was not created during UNIFY because the worktree contains unrelated Phase 118/local dirty files; prepare a scoped commit manually.
|
||||
- Phase 122 follow-up: manually verify settings save/reload and real SMSPLANET test/order sends with non-empty and empty footer; manually trigger over-limit final body rejection in UI.
|
||||
- Phase 122 transition note: git commit + ROADMAP/PROJECT evolution not executed during UNIFY because worktree contains uncommitted Phase 118 (Fakturownia single-instance) files. Resolve Phase 118 first or stage Phase 122 files in isolation.
|
||||
|
||||
## Deferred to Next Milestones
|
||||
|
||||
@@ -61,4 +65,4 @@ Branch: main
|
||||
|
||||
## Skill Requirements
|
||||
|
||||
- `sonar-scanner` required after APPLY; Phase 116 and Phase 117 gaps documented because CLI was not available in PATH.
|
||||
- `sonar-scanner` required after APPLY; Phase 116, Phase 117, Phase 121 and Phase 122 gaps documented because CLI was not available in PATH.
|
||||
|
||||
@@ -2,99 +2,49 @@
|
||||
|
||||
## Co zrobiono
|
||||
|
||||
- [Phase 116, Plan 01] Dodano integracje HostedSMS: ustawienia konta, szyfrowane haslo, karta w hubie integracji i realna wysylka testowego SMS.
|
||||
- Dodano klienta HostedSMS SimpleAPI (`POST https://api.hostedsms.pl/SimpleApi`) z obsluga `MessageId` i `ErrorMessage`.
|
||||
- Poprawiono prezentacje ostatniego testu HostedSMS: status, HTTP i osobny identyfikator wiadomosci.
|
||||
- Potwierdzono test na zywo: `2026-05-12 12:03:22 OK HTTP 200`, MessageId `d935d71a-d9a0-4cfb-be06-03fe36c71150`.
|
||||
- Odnotowano przyszly zakres: odbior odpowiedzi SMS wymaga aktywacji interfejsu po stronie DCS/HostedSMS.
|
||||
- [Phase 117, Plan 01] Dodano integracje SMSPLANET: globalne ustawienia konta, dwie metody autoryzacji, szyfrowane sekrety, karta w hubie integracji i realna wysylka testowego SMS.
|
||||
- Dodano klienta SMSPLANET (`POST https://api2.smsplanet.pl/sms`) z obsluga Bearer token oraz `key` + `password`, bez parametru `test=1` dla testow realnych.
|
||||
- Poprawiono uklad checkboxow i radio buttonow na ekranie integracji SMSPLANET przez wspolny komponent SCSS.
|
||||
- Odnotowano blokery weryfikacji: lokalny MySQL odmawial polaczenia, `vendor\bin\phpunit` i `sonar-scanner` nie byly dostepne.
|
||||
- [Phase 119, Plan 01] Re-import zamowien chroni `total_paid` przed nadpisaniem gdy `payment_status` sie nie zmienia (incydent #976: operator zwrocil 28,00 PLN klientowi).
|
||||
- `OrderImportRepository::updateOrderDelta()` przepisane na dynamic SET builder z warunkowymi `total_paid` i `is_canceled_by_buyer`; cancel propagation ze zrodla nadal wymusza wpis flagi.
|
||||
- Test PHPUnit `tests/Unit/OrderImportRepositoryTest.php` z 3 scenariuszami (preserve / transition / cancel) - syntax-checked, run odroczony do `composer install`.
|
||||
- Operacyjnie: zamowienie #976 poprawione recznie w bazie (delete pozycji Girlanda, total_with_tax/total_paid 119->91, wpis do `order_activity_log`).
|
||||
- [Phase 120, Plan 01] Ujednolicony moduł alertów: reusable komponent PHP `components/alert.php` z ikoną SVG i dismiss, brakujący wariant `.alert--info` (#eff6ff/#bfdbfe/#1e3a8a) - naprawa czarnego tekstu po teście Fakturowni.
|
||||
- `Flash::push(type, message)` + `Flash::all()` z BC dla `set/get`; heurystyka klucza legacy (error/.save/warning/success).
|
||||
- Centralny renderer flash w layoutach `app.php`, `auth.php`, `public.php` (foreach Flash::all() → component) - przyszłe `Flash::push()` zadziała bez ifów w widokach.
|
||||
- Vanilla JS `alert-dismiss.js` z idempotent guardem + delegated click handlerem.
|
||||
- 36 widoków zmigrowanych z inline `<div class="alert alert--TYPE">` / `.flash--*` na komponent (34 z planu + odkryte `orders/show.php` i `shipments/prepare.php`).
|
||||
- CSS przebudowane via `npx sass --style=compressed`: `public/assets/css/app.css` (63 560 B), `login.css` (7 409 B).
|
||||
- [Phase 121, Plan 01] Wdrożono dwukierunkową komunikację SMSPLANET: sender mode, webhook inbound, zakładkę SMS w zamówieniu oraz centrum powiadomień.
|
||||
- [Phase 121, Plan 01] Poprawiono migrację po realnym błędzie FK: `orders.id` na bazie ma typ `BIGINT UNSIGNED`, więc referencje SMS/notifications używają tego samego typu.
|
||||
- [Phase 121, Plan 01] Zamknięto UNIFY; manualne smoke testy SMSPLANET/browser notifications i SonarQube pozostają zależne od środowiska.
|
||||
|
||||
## Zmienione pliki
|
||||
|
||||
- `.paul/PROJECT.md`
|
||||
- `.paul/ROADMAP.md`
|
||||
- `.paul/STATE.md`
|
||||
- `.paul/codebase/architecture.md`
|
||||
- `.paul/codebase/db_schema.md`
|
||||
- `.paul/codebase/tech_changelog.md`
|
||||
- `.paul/phases/116-hostedsms-integration/116-01-PLAN.md`
|
||||
- `.paul/phases/116-hostedsms-integration/116-01-SUMMARY.md`
|
||||
- `.paul/phases/117-smsplanet-integration/117-01-PLAN.md`
|
||||
- `.paul/phases/117-smsplanet-integration/117-01-SUMMARY.md`
|
||||
- `DOCS/ARCHITECTURE.md`
|
||||
- `DOCS/DB_SCHEMA.md`
|
||||
- `DOCS/TECH_CHANGELOG.md`
|
||||
- `database/migrations/20260512_000107_create_hostedsms_integration_settings.sql`
|
||||
- `database/migrations/20260512_000108_create_smsplanet_integration_settings.sql`
|
||||
- `database/migrations/20260512_000110_smsplanet_conversation_notifications.sql`
|
||||
- `src/Modules/Sms/SmsMessageRepository.php`
|
||||
- `src/Modules/Sms/SmsConversationService.php`
|
||||
- `src/Modules/Sms/SmsplanetWebhookController.php`
|
||||
- `src/Modules/Notifications/NotificationRepository.php`
|
||||
- `src/Modules/Notifications/NotificationController.php`
|
||||
- `src/Modules/Notifications/NotificationApiController.php`
|
||||
- `src/Modules/Settings/SmsplanetIntegrationRepository.php`
|
||||
- `src/Modules/Settings/SmsplanetIntegrationController.php`
|
||||
- `src/Modules/Orders/OrdersController.php`
|
||||
- `routes/web.php`
|
||||
- `resources/views/settings/smsplanet.php`
|
||||
- `resources/views/orders/show.php`
|
||||
- `resources/views/notifications/index.php`
|
||||
- `resources/views/layouts/app.php`
|
||||
- `resources/lang/pl.php`
|
||||
- `resources/scss/app.scss`
|
||||
- `resources/views/settings/hostedsms.php`
|
||||
- `resources/views/settings/smsplanet.php`
|
||||
- `routes/web.php`
|
||||
- `src/Modules/Settings/HostedSmsApiClient.php`
|
||||
- `src/Modules/Settings/HostedSmsIntegrationController.php`
|
||||
- `src/Modules/Settings/HostedSmsIntegrationRepository.php`
|
||||
- `src/Modules/Settings/IntegrationSecretCipher.php`
|
||||
- `src/Modules/Settings/IntegrationsHubController.php`
|
||||
- `src/Modules/Settings/SmsplanetApiClient.php`
|
||||
- `src/Modules/Settings/SmsplanetIntegrationController.php`
|
||||
- `src/Modules/Settings/SmsplanetIntegrationRepository.php`
|
||||
- `.paul/phases/119-reimport-total-paid-protection/119-01-PLAN.md`
|
||||
- `.paul/phases/119-reimport-total-paid-protection/119-01-SUMMARY.md`
|
||||
- `src/Modules/Orders/OrderImportRepository.php`
|
||||
- `tests/Unit/OrderImportRepositoryTest.php`
|
||||
- `.paul/phases/120-alert-component-unification/120-01-PLAN.md`
|
||||
- `.paul/phases/120-alert-component-unification/120-01-SUMMARY.md`
|
||||
- `resources/views/components/alert.php`
|
||||
- `public/assets/js/modules/alert-dismiss.js`
|
||||
- `resources/scss/shared/_ui-components.scss`
|
||||
- `public/assets/css/app.css`
|
||||
- `public/assets/css/login.css`
|
||||
- `src/Core/Support/Flash.php`
|
||||
- `resources/views/layouts/app.php`
|
||||
- `resources/views/layouts/auth.php`
|
||||
- `resources/views/layouts/public.php`
|
||||
- `resources/views/settings/fakturownia.php`
|
||||
- `resources/views/settings/accounting-invoice-edit.php`
|
||||
- `resources/views/settings/accounting-receipt-edit.php`
|
||||
- `resources/views/settings/accounting-receipts.php`
|
||||
- `resources/views/settings/accounting-invoices.php`
|
||||
- `resources/views/settings/accounting.php`
|
||||
- `resources/views/settings/allegro.php`
|
||||
- `resources/views/settings/apaczka.php`
|
||||
- `resources/views/settings/company.php`
|
||||
- `resources/views/settings/cron.php`
|
||||
- `resources/views/settings/database.php`
|
||||
- `resources/views/settings/delivery-status-form.php`
|
||||
- `resources/views/settings/delivery-statuses.php`
|
||||
- `resources/views/settings/email-mailboxes.php`
|
||||
- `resources/views/settings/email-templates.php`
|
||||
- `resources/views/settings/email-templates-form.php`
|
||||
- `resources/views/settings/integrations.php`
|
||||
- `resources/views/settings/printing.php`
|
||||
- `resources/views/settings/project-mappings.php`
|
||||
- `resources/views/settings/shoppro.php`
|
||||
- `resources/views/settings/statuses.php`
|
||||
- `resources/views/orders/list.php`
|
||||
- `public/assets/js/modules/notifications.js`
|
||||
- `DOCS/DB_SCHEMA.md`
|
||||
- `DOCS/ARCHITECTURE.md`
|
||||
- `DOCS/TECH_CHANGELOG.md`
|
||||
- [Phase 122, Plan 01] Dodano konfigurowalny domyślny stopkę SMS w SMSPLANET — kolumna `default_footer` w `smsplanet_integration_settings`, doklejanie do test SMS i wiadomości z zamówienia, walidacja 918 znaków, UI textarea i podgląd w zamówieniu.
|
||||
- [Phase 122, Plan 01] Zamknięto UNIFY; manualne smoke testy (test SMS i SMS z zamówienia z/bez stopki, over-limit) oraz SonarQube zależne od środowiska.
|
||||
|
||||
## Zmienione pliki (Phase 122)
|
||||
|
||||
- `database/migrations/20260512_000111_smsplanet_default_footer.sql`
|
||||
- `src/Modules/Settings/SmsplanetIntegrationRepository.php`
|
||||
- `src/Modules/Settings/SmsplanetIntegrationController.php`
|
||||
- `src/Modules/Sms/SmsConversationService.php`
|
||||
- `src/Modules/Orders/OrdersController.php`
|
||||
- `resources/views/settings/smsplanet.php`
|
||||
- `resources/views/orders/show.php`
|
||||
- `resources/views/orders/receipt-create.php`
|
||||
- `resources/views/shipments/prepare.php`
|
||||
- `resources/views/accounting/invoice_form.php`
|
||||
- `resources/views/automation/index.php`
|
||||
- `resources/views/automation/form.php`
|
||||
- `resources/views/users/index.php`
|
||||
- `resources/views/statistics/orders.php`
|
||||
- `resources/views/auth/login.php`
|
||||
- `resources/lang/pl.php`
|
||||
- `resources/scss/app.scss`
|
||||
- `public/assets/css/app.css`
|
||||
- `DOCS/DB_SCHEMA.md`
|
||||
- `DOCS/ARCHITECTURE.md`
|
||||
- `DOCS/TECH_CHANGELOG.md`
|
||||
|
||||
@@ -40,6 +40,8 @@ HTTP Request
|
||||
| **Email** | 3 | `EmailSendingService`, `VariableResolver`, `AttachmentGenerator` | Template-based email with PDF attachments |
|
||||
| **Automation** | 6 | `AutomationService` (834 LOC), `AutomationRepository`, `AutomationExecutionLogRepository` | Event→condition→action rules, email triggers |
|
||||
| **Settings** | 54+ | Integration controllers, OAuth clients, API clients (Fakturownia incl.), mappers | Allegro/shopPRO/Apaczka/InPost/Fakturownia config, status mappings |
|
||||
| **Sms** | 3 | `SmsMessageRepository`, `SmsConversationService`, `SmsplanetWebhookController` | SMSPLANET outbound order SMS, inbound webhook parsing, order matching |
|
||||
| **Notifications** | 3 | `NotificationRepository`, `NotificationController`, `NotificationApiController` | Global notification history, unread polling API, mark-read actions |
|
||||
| **Cron** | 12 | `CronRepository`, `CronHandlerFactory`, handler classes | Scheduled imports, syncs, token refresh |
|
||||
| **Printing** | 4 | `PrintApiController`, `PrintJobRepository`, `ApiKeyMiddleware` | REST API for Windows print client |
|
||||
| **Statistics** | 3 | `OrdersStatisticsController`, `OrdersStatisticsRepository`, `statistics-summary-charts.js` | Daily order statistics and monthly summary charts |
|
||||
@@ -309,7 +311,7 @@ tests/
|
||||
- Zarzadza pojedynczym rekordem `smsplanet_integration_settings` (`id=1`) i bazowym wpisem `integrations` typu `smsplanet`.
|
||||
- Obsluguje dwie metody autoryzacji: Bearer token oraz `key` + `password`.
|
||||
- Szyfruje token, klucz API i haslo przez `IntegrationSecretCipher`; formularz widzi tylko flagi `has_api_token`, `has_api_key` i `has_api_password`.
|
||||
- Udostepnia `getCredentials()` tylko dla kompletnej i aktywnej konfiguracji testowej wysylki SMS.
|
||||
- Udostepnia `getCredentials()` tylko dla kompletnej i aktywnej konfiguracji testowej wysylki SMS, razem z opcjonalna `default_footer`.
|
||||
|
||||
### SmsplanetApiClient (`src/Modules/Settings/SmsplanetApiClient.php`)
|
||||
- Wykonuje `POST https://api2.smsplanet.pl/sms` jako `application/x-www-form-urlencoded`.
|
||||
@@ -319,11 +321,24 @@ tests/
|
||||
|
||||
### SmsplanetIntegrationController (`src/Modules/Settings/SmsplanetIntegrationController.php`)
|
||||
- Endpointy: `GET /settings/integrations/smsplanet`, `POST /settings/integrations/smsplanet/save`, `POST /settings/integrations/smsplanet/test`.
|
||||
- `test` realnie wysyla SMS z edytowalna trescia i zapisuje wynik w `integrations.last_test_*`.
|
||||
- `test` realnie wysyla SMS z edytowalna trescia, dopisuje `default_footer` gdy jest skonfigurowana i zapisuje wynik w `integrations.last_test_*`.
|
||||
|
||||
### IntegrationsHubController
|
||||
- Dodaje wiersz SMSPLANET do `/settings/integrations` ze statusem konfiguracji, sekretu, aktywnosci i ostatniego testu.
|
||||
|
||||
## Phase 121 - SMSPLANET Conversation + Notifications
|
||||
|
||||
### SmsConversationService (`src/Modules/Sms/SmsConversationService.php`)
|
||||
- Wysyla SMS z poziomu zamowienia przez `SmsplanetApiClient`, dopisuje `default_footer` gdy jest skonfigurowana, zapisuje finalna tresc w `sms_messages` i uzywa `sender_mode` do wyboru nadpisu albo numeru 2WAY.
|
||||
- Parsuje publiczny webhook `/webhooks/smsplanet/inbound`, normalizuje telefony i dopasowuje przychodzacy SMS do najnowszego zamowienia po telefonie klienta/adresu.
|
||||
- Endpoint inbound akceptuje POST i GET; format 2WAY `message=<JSON>` jest dekodowany, sukces zwraca plain `OK`, a dopasowanie zamowienia korzysta z `order_addresses.phone`.
|
||||
- Tworzy `notifications.type='sms_inbound'` z linkiem do `/orders/{id}?tab=sms`.
|
||||
|
||||
### Notifications module
|
||||
- `/notifications` pokazuje historie powiadomien i pozwala oznaczac wpisy jako przeczytane.
|
||||
- `/api/notifications/unread` zasila topbar badge oraz `public/assets/js/modules/notifications.js`.
|
||||
- Browser Notification API jest progresywne: brak zgody nie blokuje strony ani pollingu.
|
||||
|
||||
## Phase 120 — Alert Component Unification
|
||||
|
||||
### Alert component (`resources/views/components/alert.php`)
|
||||
|
||||
@@ -558,11 +558,11 @@ UNIQUE: `(type, name)`
|
||||
| `created_at` | DATETIME | NO | |
|
||||
| `updated_at` | DATETIME | NO | |
|
||||
|
||||
**fakturownia_integration_settings** — Fakturownia account credentials (Phase 113-01; multi-account via integration_id)
|
||||
**fakturownia_integration_settings** — Fakturownia account credentials (Phase 118; fixed 1 row)
|
||||
| Column | Type | Nullable | Notes |
|
||||
|--------|------|----------|-------|
|
||||
| `id` | INT UNSIGNED | NO | PK |
|
||||
| `integration_id` | INT UNSIGNED | NO | UNIQUE, FK → integrations(id) CASCADE |
|
||||
| `id` | INT UNSIGNED | NO | PK, always 1 |
|
||||
| `integration_id` | INT UNSIGNED | NO | UNIQUE, FK → integrations(id) CASCADE; points to the single global Fakturownia integration |
|
||||
| `account_prefix` | VARCHAR(64) | NO | Subdomain: {prefix}.fakturownia.pl |
|
||||
| `api_token_encrypted` | TEXT | YES | AES-encrypted via `IntegrationSecretCipher` |
|
||||
| `department_id` | VARCHAR(64) | YES | Optional Fakturownia department |
|
||||
@@ -571,7 +571,7 @@ UNIQUE: `(type, name)`
|
||||
| `created_at` | DATETIME | NO | |
|
||||
| `updated_at` | DATETIME | NO | |
|
||||
|
||||
UNIQUE: `(integration_id)` — one settings row per Fakturownia integration. Multiple integrations of `type='fakturownia'` allowed.
|
||||
UNIQUE: `(integration_id)` — one global Fakturownia settings row. Phase 118 migration keeps the active Fakturownia integration, rewires delegated `invoice_configs.integration_id` to it, and removes extra Fakturownia integration rows.
|
||||
|
||||
---
|
||||
|
||||
@@ -600,9 +600,12 @@ UNIQUE: `(integration_id)` - one global HostedSMS settings row.
|
||||
| `api_token_encrypted` | TEXT | YES | AES-encrypted Bearer token via `IntegrationSecretCipher` |
|
||||
| `api_key_encrypted` | TEXT | YES | AES-encrypted API key via `IntegrationSecretCipher` |
|
||||
| `api_password_encrypted` | TEXT | YES | AES-encrypted API password via `IntegrationSecretCipher` |
|
||||
| `sender` | VARCHAR(32) | YES | SMSPLANET `from` sender |
|
||||
| `sender` | VARCHAR(32) | YES | Text sender / nadpis |
|
||||
| `sender_mode` | VARCHAR(16) | NO | DEFAULT `text`; `text` uses `sender`, `phone` uses `sender_phone` |
|
||||
| `sender_phone` | VARCHAR(32) | YES | SMSPLANET 2WAY phone number |
|
||||
| `clear_polish` | TINYINT(1) | NO | DEFAULT 0 |
|
||||
| `transactional` | TINYINT(1) | NO | DEFAULT 0 |
|
||||
| `default_footer` | TEXT | YES | Optional global footer appended to SMSPLANET test and order SMS |
|
||||
| `created_at` | DATETIME | NO | |
|
||||
| `updated_at` | DATETIME | NO | |
|
||||
|
||||
@@ -610,6 +613,12 @@ UNIQUE: `(integration_id)` - one global SMSPLANET settings row.
|
||||
|
||||
---
|
||||
|
||||
**sms_messages** - SMSPLANET inbound/outbound conversation history (Phase 121): stores direction, provider, nullable `order_id BIGINT UNSIGNED`, original and normalized phone endpoints, SMS body, provider `message_id`, status, raw JSON payload, optional `created_by`, and timestamps. Indexes: `(order_id, created_at)`, normalized phone columns, and `(provider, message_id)`.
|
||||
|
||||
**notifications** - Global notification center (Phase 121): stores type, title, body, target URL, related order/SMS references, `read_at`, and `created_at`. Indexes support unread polling by `(read_at, created_at)` and relation lookups.
|
||||
|
||||
---
|
||||
|
||||
## Accounting / Receipts
|
||||
|
||||
**receipt_configs** — Receipt generation configurations
|
||||
@@ -666,7 +675,7 @@ UNIQUE: `(config_id, year, month)`
|
||||
|--------|------|----------|-------|
|
||||
| `id` | INT UNSIGNED | NO | PK |
|
||||
| `name` | VARCHAR(128) | NO | |
|
||||
| `integration_id` | INT UNSIGNED | YES | FK → integrations(id) SET NULL — points to Fakturownia account when delegated |
|
||||
| `integration_id` | INT UNSIGNED | YES | FK → integrations(id) SET NULL — delegated configs always point to the single global Fakturownia integration |
|
||||
| `is_delegated` | TINYINT(1) | NO | DEFAULT 0 — when 1, invoice creation calls Fakturownia API |
|
||||
| `is_active` | TINYINT(1) | NO | DEFAULT 1 |
|
||||
| `number_format` | VARCHAR(64) | NO | DEFAULT 'FV/%N/%M/%Y' |
|
||||
|
||||
@@ -1,5 +1,44 @@
|
||||
# Technical Changelog
|
||||
|
||||
## 2026-05-12 - SMSPLANET Inbound Webhook Fix
|
||||
|
||||
**Co zrobiono:**
|
||||
- Poprawiono inbound SMSPLANET: dopasowanie po telefonie uzywa `order_addresses.phone`, a nie nieistniejacego w produkcji `orders.buyer_phone`.
|
||||
- Dodano GET dla `/webhooks/smsplanet/inbound` obok POST, dekodowanie formatu 2WAY `message=<JSON>`, odpowiedz plain `OK` po sukcesie i odporniejsze scalanie JSON body z parametrami requestu.
|
||||
|
||||
**Dlaczego:**
|
||||
- Publiczny POST webhooka zwracal 422 przez blad SQL `Unknown column 'o.buyer_phone'`, wiec odpowiedzi SMS nie byly zapisywane.
|
||||
|
||||
## 2026-05-12 - Phase 122 Plan 01: SMSPLANET Default SMS Footer
|
||||
|
||||
**Co zrobiono:**
|
||||
- Dodano migracje `20260512_000111_smsplanet_default_footer.sql` z kolumna `smsplanet_integration_settings.default_footer`.
|
||||
- Rozszerzono konfiguracje SMSPLANET o opcjonalna stopke SMS z limitem 300 znakow.
|
||||
- Testowa wysylka oraz SMS z zamowienia dopinaja stopke przez pusta linie, waliduja finalna tresc w limicie 918 znakow i zapisuja finalne body w historii rozmowy.
|
||||
|
||||
**Dlaczego:**
|
||||
- Operator ma utrzymywac jeden wspolny podpis firmy bez recznego kopiowania go do kazdej wiadomosci SMS.
|
||||
|
||||
**Weryfikacja:**
|
||||
- Do uzupelnienia po APPLY.
|
||||
|
||||
## 2026-05-12 - Phase 121 Plan 01: SMSPLANET Conversation + Notifications
|
||||
|
||||
**Co zrobiono:**
|
||||
- Dodano migracje `20260512_000110_smsplanet_conversation_notifications.sql` z tabelami `sms_messages`, `notifications` oraz polami `sender_mode` i `sender_phone`.
|
||||
- Dodano backend `Sms` i `Notifications`, publiczny webhook SMSPLANET, zakladke SMS w zamowieniu, centrum powiadomien, topbar badge i polling JS.
|
||||
- Usunieto tymczasowy override testowego nadawcy SMSPLANET; API uzywa wybranego trybu nadawcy.
|
||||
- Poprawiono migracje po pierwszej probie na bazie: rzeczywiste `orders.id` ma typ `BIGINT UNSIGNED`, wiec `sms_messages.order_id` i `notifications.related_order_id` tez musza miec `BIGINT UNSIGNED`.
|
||||
|
||||
**Dlaczego:**
|
||||
- Operator ma prowadzic dwukierunkowa rozmowe SMSPLANET w szczegolach zamowienia i widziec nowe odpowiedzi klientow globalnie.
|
||||
|
||||
**Weryfikacja:**
|
||||
- `php -l` PASS dla nowych/zmienionych PHP.
|
||||
- `npm run build:css` PASS.
|
||||
- Migracja PASS przez techniczne polaczenie `DB_HOST_REMOTE`; manualne smoke testy UI/webhook nadal wymagaja sesji w aplikacji.
|
||||
- `sonar-scanner` niedostepny w PATH.
|
||||
|
||||
## 2026-05-12 - Phase 120 Plan 01: Alert Component Unification
|
||||
|
||||
**Co zrobiono:**
|
||||
|
||||
122
.paul/governance/governance_2026-05-12.jsonl
Normal file
122
.paul/governance/governance_2026-05-12.jsonl
Normal file
@@ -0,0 +1,122 @@
|
||||
{"ts":"2026-05-12T10:49:50Z","tool":"Bash","cmd":"powershell -Command \"if (Test-Path '$HOME/.claude/projects/c--visual-studio-code-projekty-orderPRO/memory') { (Get-Item '$HOME/.claude/projects/c--visual-studio-code-projekty-orderPRO/memory').Target","cwd":"/c/visual studio code/projekty/orderPRO"}
|
||||
{"ts":"2026-05-12T10:50:02Z","tool":"Bash","cmd":"powershell -Command \"if (-not (Test-Path '.claude/memory')) { New-Item -ItemType Directory -Path '.claude/memory' -Force | Out-Null }; Remove-Item '$HOME/.claude/projects/c--visual-studio-code-projek","cwd":"/c/visual studio code/projekty/orderPRO"}
|
||||
{"ts":"2026-05-12T10:50:14Z","tool":"Bash","cmd":"\"C:/xampp/mysql/bin/mysql.exe\" --default-character-set=utf8mb4 -h host700513.hostido.net.pl -P 3306 -u host700513_orderpro -phrDNtUBg9grwZ7syN77S host700513_orderpro -e \"SELECT oi.id AS item_id, oi","cwd":"/c/visual studio code/projekty/orderPRO"}
|
||||
{"ts":"2026-05-12T10:50:28Z","tool":"Bash","cmd":"python tools/generowanie/email_message_fetcher.py --email \"karwowska.i@interia.pl\" --days 60 --json 2>&1 | head -100\",\"description\":\"Check emails from client\"},\"tool_response\":{\"stdout\":\"B","cwd":"/c/visual studio code/projekty/orderPRO"}
|
||||
{"ts":"2026-05-12T10:50:42Z","tool":"Bash","cmd":"python tools/generowanie/pudelko_komunia_kielich.py --help 2>&1 | head -40\",\"description\":\"Check script params\"},\"tool_response\":{\"stdout\":\"usage: pudelko_komunia_kielich.py [-h] --klient KL","cwd":"/c/visual studio code/projekty/orderPRO"}
|
||||
{"ts":"2026-05-12T10:50:48Z","tool":"Bash","cmd":"tasklist //FI \"IMAGENAME eq Photoshop.exe\" 2>/dev/null | grep -i photoshop || echo \"NOT_RUNNING\"\",\"description\":\"Check Photoshop\"},\"tool_response\":{\"stdout\":\"NOT_RUNNING\",\"stderr\":\"\","cwd":"/c/visual studio code/projekty/orderPRO"}
|
||||
{"ts":"2026-05-12T10:51:27Z","tool":"Bash","cmd":"powershell -Command \"Start-Process 'C:\\\\Program Files\\\\Adobe\\\\Adobe Photoshop 2024\\\\Photoshop.exe' -ErrorAction SilentlyContinue; if (-not $?) { Start-Process 'C:\\\\Program Files\\\\Adobe\\\","cwd":"/c/visual studio code/projekty/orderPRO"}
|
||||
{"ts":"2026-05-12T10:51:36Z","tool":"Bash","cmd":"powershell -Command \"Start-Process 'C:\\\\Program Files\\\\Adobe\\\\Adobe Photoshop 2026\\\\Photoshop.exe'; 'started'\"\",\"description\":\"Start Photoshop 2026\"},\"tool_response\":{\"stdout\":\"sta","cwd":"/c/visual studio code/projekty/orderPRO"}
|
||||
{"ts":"2026-05-12T10:51:43Z","tool":"Bash","cmd":"until tasklist //FI \"IMAGENAME eq Photoshop.exe\" 2>/dev/null | grep -qi photoshop; do sleep 2; done; echo \"PS_RUNNING\"\",\"timeout\":60000,\"description\":\"Wait for Photoshop\"},\"tool_response\"","cwd":"/c/visual studio code/projekty/orderPRO"}
|
||||
{"ts":"2026-05-12T10:52:13Z","tool":"Bash","cmd":"sleep 15; echo \"PS warmed up\"\",\"description\":\"Wait for Photoshop init\"},\"tool_response\":{\"stdout\":\"PS warmed up\",\"stderr\":\"\",\"interrupted\":false,\"isImage\":false,\"noOutputExpected","cwd":"/c/visual studio code/projekty/orderPRO"}
|
||||
{"ts":"2026-05-12T10:52:26Z","tool":"Write","file":"c:\\\\visual studio code\\\\projekty\\\\orderPRO\\\\tools\\\\generowanie\\\\_run_ilona.py","cwd":"/c/visual studio code/projekty/orderPRO"}
|
||||
{"ts":"2026-05-12T10:52:41Z","tool":"Bash","cmd":"cd \"c:/visual studio code/projekty/orderPRO\" && python tools/generowanie/_run_ilona.py 2>&1 | tail -80\",\"timeout\":600000,\"description\":\"Generate both PSDs\"},\"tool_response\":{\"stdout\":\"==","cwd":"/c/visual studio code/projekty/orderPRO"}
|
||||
{"ts":"2026-05-12T10:52:50Z","tool":"Bash","cmd":"\"C:/xampp/mysql/bin/mysql.exe\" --default-character-set=utf8mb4 -h host700513.hostido.net.pl -P 3306 -u host700513_orderpro -phrDNtUBg9grwZ7syN77S host700513_orderpro -e \"UPDATE order_items SET proj","cwd":"/c/visual studio code/projekty/orderPRO"}
|
||||
{"ts":"2026-05-12T11:29:39Z","tool":"Bash","cmd":"\"C:/xampp/mysql/bin/mysql.exe\" --default-character-set=utf8mb4 -h host700513.hostido.net.pl -P 3306 -u host700513_orderpro -phrDNtUBg9grwZ7syN77S host700513_orderpro -e \"SELECT id, product_name_pat","cwd":"/c/visual studio code/projekty/orderPRO"}
|
||||
{"ts":"2026-05-12T11:29:50Z","tool":"Bash","cmd":"\"C:/xampp/mysql/bin/mysql.exe\" --default-character-set=utf8mb4 -h host700513.hostido.net.pl -P 3306 -u host700513_orderpro -phrDNtUBg9grwZ7syN77S host700513_orderpro --batch -e \"SELECT oi.id AS ite","cwd":"/c/visual studio code/projekty/orderPRO"}
|
||||
{"ts":"2026-05-12T11:30:06Z","tool":"Bash","cmd":"tasklist //FI \"IMAGENAME eq Photoshop.exe\" 2>/dev/null | grep -i photoshop\",\"description\":\"Check if Photoshop is running\"},\"tool_response\":{\"stdout\":\"Photoshop.exe 25592 Con","cwd":"/c/visual studio code/projekty/orderPRO"}
|
||||
{"ts":"2026-05-12T11:30:30Z","tool":"Bash","cmd":"python -c \"\\nimport subprocess\\nzyczenia = 'Niech ten wyjątkowy dzień Pierwszej Komunii Świętej\\\\rbędzie początkiem pięknej drogi wiary, pełnej Bożej łaski, pokoju i miłości.\\\\rNiec","cwd":"/c/visual studio code/projekty/orderPRO"}
|
||||
{"ts":"2026-05-12T11:30:39Z","tool":"Bash","cmd":"\"C:/xampp/mysql/bin/mysql.exe\" --default-character-set=utf8mb4 -h host700513.hostido.net.pl -P 3306 -u host700513_orderpro -phrDNtUBg9grwZ7syN77S host700513_orderpro -e \"UPDATE order_items SET proj","cwd":"/c/visual studio code/projekty/orderPRO"}
|
||||
{"ts":"2026-05-12T12:24:08Z","tool":"Bash","cmd":"mysql -h host700513.hostido.net.pl -u host700513_orderpro -phrDNtUBg9grwZ7syN77S host700513_orderpro -e \"SHOW TABLES LIKE '%order%';\" 2>&1 | head -30\",\"description\":\"List order tables\"},\"tool_","cwd":"/c/visual studio code/projekty/orderPRO"}
|
||||
{"ts":"2026-05-12T12:25:25Z","tool":"Write","file":"C:\\\\visual studio code\\\\projekty\\\\orderPRO\\\\tmp_order976_fix.sql","cwd":"/c/visual studio code/projekty/orderPRO"}
|
||||
{"ts":"2026-05-12T12:39:31Z","tool":"Bash","cmd":"mkdir -p \".paul/phases/119-reimport-total-paid-protection\"\",\"description\":\"Create phase directory\"},\"tool_response\":{\"stdout\":\"\",\"stderr\":\"\",\"interrupted\":false,\"isImage\":false,\"","cwd":"/c/visual studio code/projekty/orderPRO"}
|
||||
{"ts":"2026-05-12T12:40:47Z","tool":"Write","file":"C:\\\\visual studio code\\\\projekty\\\\orderPRO\\\\.paul\\\\phases\\\\119-reimport-total-paid-protection\\\\119-01-PLAN.md","cwd":"/c/visual studio code/projekty/orderPRO"}
|
||||
{"ts":"2026-05-12T12:41:01Z","tool":"Edit","file":"C:\\\\visual studio code\\\\projekty\\\\orderPRO\\\\.paul\\\\STATE.md","cwd":"/c/visual studio code/projekty/orderPRO"}
|
||||
{"ts":"2026-05-12T12:41:06Z","tool":"Edit","file":"C:\\\\visual studio code\\\\projekty\\\\orderPRO\\\\.paul\\\\ROADMAP.md","cwd":"/c/visual studio code/projekty/orderPRO"}
|
||||
{"ts":"2026-05-12T12:44:08Z","tool":"Edit","file":"C:\\\\visual studio code\\\\projekty\\\\orderPRO\\\\src\\\\Modules\\\\Orders\\\\OrderImportRepository.php","cwd":"/c/visual studio code/projekty/orderPRO"}
|
||||
{"ts":"2026-05-12T12:44:25Z","tool":"Edit","file":"C:\\\\visual studio code\\\\projekty\\\\orderPRO\\\\src\\\\Modules\\\\Orders\\\\OrderImportRepository.php","cwd":"/c/visual studio code/projekty/orderPRO"}
|
||||
{"ts":"2026-05-12T12:45:24Z","tool":"Write","file":"C:\\\\visual studio code\\\\projekty\\\\orderPRO\\\\tests\\\\Unit\\\\OrderImportRepositoryTest.php","cwd":"/c/visual studio code/projekty/orderPRO"}
|
||||
{"ts":"2026-05-12T12:46:55Z","tool":"Edit","file":"C:\\\\visual studio code\\\\projekty\\\\orderPRO\\\\.paul\\\\codebase\\\\tech_changelog.md","cwd":"/c/visual studio code/projekty/orderPRO"}
|
||||
{"ts":"2026-05-12T12:47:02Z","tool":"Edit","file":"C:\\\\visual studio code\\\\projekty\\\\orderPRO\\\\.paul\\\\codebase\\\\architecture.md","cwd":"/c/visual studio code/projekty/orderPRO"}
|
||||
{"ts":"2026-05-12T12:47:11Z","tool":"Edit","file":"C:\\\\visual studio code\\\\projekty\\\\orderPRO\\\\.paul\\\\codebase\\\\architecture.md","cwd":"/c/visual studio code/projekty/orderPRO"}
|
||||
{"ts":"2026-05-12T12:47:36Z","tool":"Write","file":"C:\\\\visual studio code\\\\projekty\\\\orderPRO\\\\.paul\\\\phases\\\\119-reimport-total-paid-protection\\\\119-01-SUMMARY.md","cwd":"/c/visual studio code/projekty/orderPRO"}
|
||||
{"ts":"2026-05-12T12:47:49Z","tool":"Edit","file":"C:\\\\visual studio code\\\\projekty\\\\orderPRO\\\\.paul\\\\STATE.md","cwd":"/c/visual studio code/projekty/orderPRO"}
|
||||
{"ts":"2026-05-12T12:54:10Z","tool":"Write","file":"C:\\\\visual studio code\\\\projekty\\\\orderPRO\\\\.paul\\\\phases\\\\119-reimport-total-paid-protection\\\\119-01-SUMMARY.md","cwd":"/c/visual studio code/projekty/orderPRO"}
|
||||
{"ts":"2026-05-12T12:54:18Z","tool":"Bash","cmd":"mkdir -p .paul/changelog 2>&1; ls .paul/changelog/2026-05-12.md 2>&1\",\"description\":\"Check changelog\"},\"tool_response\":{\"stdout\":\".paul/changelog/2026-05-12.md\",\"stderr\":\"\",\"interrupte","cwd":"/c/visual studio code/projekty/orderPRO"}
|
||||
{"ts":"2026-05-12T12:54:36Z","tool":"Edit","file":"C:\\\\visual studio code\\\\projekty\\\\orderPRO\\\\.paul\\\\changelog\\\\2026-05-12.md","cwd":"/c/visual studio code/projekty/orderPRO"}
|
||||
{"ts":"2026-05-12T12:54:43Z","tool":"Edit","file":"C:\\\\visual studio code\\\\projekty\\\\orderPRO\\\\.paul\\\\changelog\\\\2026-05-12.md","cwd":"/c/visual studio code/projekty/orderPRO"}
|
||||
{"ts":"2026-05-12T12:54:56Z","tool":"Edit","file":"C:\\\\visual studio code\\\\projekty\\\\orderPRO\\\\.paul\\\\STATE.md","cwd":"/c/visual studio code/projekty/orderPRO"}
|
||||
{"ts":"2026-05-12T12:55:05Z","tool":"Edit","file":"C:\\\\visual studio code\\\\projekty\\\\orderPRO\\\\.paul\\\\STATE.md","cwd":"/c/visual studio code/projekty/orderPRO"}
|
||||
{"ts":"2026-05-12T12:55:12Z","tool":"Edit","file":"C:\\\\visual studio code\\\\projekty\\\\orderPRO\\\\.paul\\\\ROADMAP.md","cwd":"/c/visual studio code/projekty/orderPRO"}
|
||||
{"ts":"2026-05-12T12:55:29Z","tool":"Edit","file":"C:\\\\visual studio code\\\\projekty\\\\orderPRO\\\\.paul\\\\PROJECT.md","cwd":"/c/visual studio code/projekty/orderPRO"}
|
||||
{"ts":"2026-05-12T12:56:37Z","tool":"Bash","cmd":"git log --oneline -5\",\"description\":\"Check commit style\"},\"tool_response\":{\"stdout\":\"bcbb35b feat(117): smsplanet integration settings\\n09f9ca7 docs(paul): record phase 116 transition\\nbc2","cwd":"/c/visual studio code/projekty/orderPRO"}
|
||||
{"ts":"2026-05-12T12:56:50Z","tool":"Bash","cmd":"git add src/Modules/Orders/OrderImportRepository.php tests/Unit/OrderImportRepositoryTest.php .paul/phases/119-reimport-total-paid-protection/ && git status --short\",\"description\":\"Stage Phase 119","cwd":"/c/visual studio code/projekty/orderPRO"}
|
||||
{"ts":"2026-05-12T12:57:05Z","tool":"Bash","cmd":"git commit -m \"$(cat <<'EOF'\\nfeat(119): protect total_paid from re-import overwrite\\n\\nOrderImportRepository::updateOrderDelta() przechodzi na dynamic SET builder.\\ntotal_paid jest dolaczane do ","cwd":"/c/visual studio code/projekty/orderPRO"}
|
||||
{"ts":"2026-05-12T13:01:41Z","tool":"Bash","cmd":"mkdir -p \".paul/phases/120-alert-component-unification\"\",\"description\":\"Create phase directory\"},\"tool_response\":{\"stdout\":\"\",\"stderr\":\"\",\"interrupted\":false,\"isImage\":false,\"noO","cwd":"/c/visual studio code/projekty/orderPRO"}
|
||||
{"ts":"2026-05-12T13:03:24Z","tool":"Write","file":"C:\\\\visual studio code\\\\projekty\\\\orderPRO\\\\.paul\\\\phases\\\\120-alert-component-unification\\\\120-01-PLAN.md","cwd":"/c/visual studio code/projekty/orderPRO"}
|
||||
{"ts":"2026-05-12T13:03:37Z","tool":"Edit","file":"C:\\\\visual studio code\\\\projekty\\\\orderPRO\\\\.paul\\\\STATE.md","cwd":"/c/visual studio code/projekty/orderPRO"}
|
||||
{"ts":"2026-05-12T13:03:43Z","tool":"Edit","file":"C:\\\\visual studio code\\\\projekty\\\\orderPRO\\\\.paul\\\\ROADMAP.md","cwd":"/c/visual studio code/projekty/orderPRO"}
|
||||
{"ts":"2026-05-12T13:07:33Z","tool":"Edit","file":"C:\\\\visual studio code\\\\projekty\\\\orderPRO\\\\resources\\\\scss\\\\shared\\\\_ui-components.scss","cwd":"/c/visual studio code/projekty/orderPRO"}
|
||||
{"ts":"2026-05-12T13:07:51Z","tool":"Write","file":"C:\\\\visual studio code\\\\projekty\\\\orderPRO\\\\resources\\\\views\\\\components\\\\alert.php","cwd":"/c/visual studio code/projekty/orderPRO"}
|
||||
{"ts":"2026-05-12T13:07:58Z","tool":"Write","file":"C:\\\\visual studio code\\\\projekty\\\\orderPRO\\\\public\\\\assets\\\\js\\\\modules\\\\alert-dismiss.js","cwd":"/c/visual studio code/projekty/orderPRO"}
|
||||
{"ts":"2026-05-12T13:08:18Z","tool":"Edit","file":"C:\\\\visual studio code\\\\projekty\\\\orderPRO\\\\resources\\\\views\\\\layouts\\\\app.php","cwd":"/c/visual studio code/projekty/orderPRO"}
|
||||
{"ts":"2026-05-12T13:08:25Z","tool":"Edit","file":"C:\\\\visual studio code\\\\projekty\\\\orderPRO\\\\resources\\\\views\\\\layouts\\\\app.php","cwd":"/c/visual studio code/projekty/orderPRO"}
|
||||
{"ts":"2026-05-12T13:08:36Z","tool":"Edit","file":"C:\\\\visual studio code\\\\projekty\\\\orderPRO\\\\resources\\\\views\\\\layouts\\\\auth.php","cwd":"/c/visual studio code/projekty/orderPRO"}
|
||||
{"ts":"2026-05-12T13:08:48Z","tool":"Edit","file":"C:\\\\visual studio code\\\\projekty\\\\orderPRO\\\\resources\\\\views\\\\layouts\\\\public.php","cwd":"/c/visual studio code/projekty/orderPRO"}
|
||||
{"ts":"2026-05-12T13:09:04Z","tool":"Write","file":"C:\\\\visual studio code\\\\projekty\\\\orderPRO\\\\src\\\\Core\\\\Support\\\\Flash.php","cwd":"/c/visual studio code/projekty/orderPRO"}
|
||||
{"ts":"2026-05-12T13:09:30Z","tool":"Edit","file":"C:\\\\visual studio code\\\\projekty\\\\orderPRO\\\\resources\\\\views\\\\settings\\\\fakturownia.php","cwd":"/c/visual studio code/projekty/orderPRO"}
|
||||
{"ts":"2026-05-12T13:10:09Z","tool":"Edit","file":"C:\\\\visual studio code\\\\projekty\\\\orderPRO\\\\resources\\\\views\\\\automation\\\\index.php","cwd":"/c/visual studio code/projekty/orderPRO"}
|
||||
{"ts":"2026-05-12T13:10:20Z","tool":"Edit","file":"C:\\\\visual studio code\\\\projekty\\\\orderPRO\\\\resources\\\\views\\\\auth\\\\login.php","cwd":"/c/visual studio code/projekty/orderPRO"}
|
||||
{"ts":"2026-05-12T13:10:42Z","tool":"Edit","file":"C:\\\\visual studio code\\\\projekty\\\\orderPRO\\\\resources\\\\views\\\\accounting\\\\invoice_form.php","cwd":"/c/visual studio code/projekty/orderPRO"}
|
||||
{"ts":"2026-05-12T13:10:57Z","tool":"Edit","file":"C:\\\\visual studio code\\\\projekty\\\\orderPRO\\\\resources\\\\views\\\\automation\\\\form.php","cwd":"/c/visual studio code/projekty/orderPRO"}
|
||||
{"ts":"2026-05-12T13:11:13Z","tool":"Edit","file":"C:\\\\visual studio code\\\\projekty\\\\orderPRO\\\\resources\\\\views\\\\orders\\\\list.php","cwd":"/c/visual studio code/projekty/orderPRO"}
|
||||
{"ts":"2026-05-12T13:11:29Z","tool":"Edit","file":"C:\\\\visual studio code\\\\projekty\\\\orderPRO\\\\resources\\\\views\\\\users\\\\index.php","cwd":"/c/visual studio code/projekty/orderPRO"}
|
||||
{"ts":"2026-05-12T13:11:50Z","tool":"Edit","file":"C:\\\\visual studio code\\\\projekty\\\\orderPRO\\\\resources\\\\views\\\\orders\\\\receipt-create.php","cwd":"/c/visual studio code/projekty/orderPRO"}
|
||||
{"ts":"2026-05-12T13:12:02Z","tool":"Edit","file":"C:\\\\visual studio code\\\\projekty\\\\orderPRO\\\\resources\\\\views\\\\settings\\\\accounting-invoice-edit.php","cwd":"/c/visual studio code/projekty/orderPRO"}
|
||||
{"ts":"2026-05-12T13:12:13Z","tool":"Edit","file":"C:\\\\visual studio code\\\\projekty\\\\orderPRO\\\\resources\\\\views\\\\settings\\\\accounting-receipts.php","cwd":"/c/visual studio code/projekty/orderPRO"}
|
||||
{"ts":"2026-05-12T13:12:32Z","tool":"Edit","file":"C:\\\\visual studio code\\\\projekty\\\\orderPRO\\\\resources\\\\views\\\\settings\\\\accounting-invoices.php","cwd":"/c/visual studio code/projekty/orderPRO"}
|
||||
{"ts":"2026-05-12T13:12:50Z","tool":"Edit","file":"C:\\\\visual studio code\\\\projekty\\\\orderPRO\\\\resources\\\\views\\\\settings\\\\accounting-receipt-edit.php","cwd":"/c/visual studio code/projekty/orderPRO"}
|
||||
{"ts":"2026-05-12T13:13:01Z","tool":"Edit","file":"C:\\\\visual studio code\\\\projekty\\\\orderPRO\\\\resources\\\\views\\\\settings\\\\accounting.php","cwd":"/c/visual studio code/projekty/orderPRO"}
|
||||
{"ts":"2026-05-12T13:13:31Z","tool":"Edit","file":"C:\\\\visual studio code\\\\projekty\\\\orderPRO\\\\resources\\\\views\\\\statistics\\\\orders.php","cwd":"/c/visual studio code/projekty/orderPRO"}
|
||||
{"ts":"2026-05-12T13:13:42Z","tool":"Edit","file":"C:\\\\visual studio code\\\\projekty\\\\orderPRO\\\\resources\\\\views\\\\settings\\\\apaczka.php","cwd":"/c/visual studio code/projekty/orderPRO"}
|
||||
{"ts":"2026-05-12T13:13:57Z","tool":"Edit","file":"C:\\\\visual studio code\\\\projekty\\\\orderPRO\\\\resources\\\\views\\\\settings\\\\allegro.php","cwd":"/c/visual studio code/projekty/orderPRO"}
|
||||
{"ts":"2026-05-12T13:14:07Z","tool":"Edit","file":"C:\\\\visual studio code\\\\projekty\\\\orderPRO\\\\resources\\\\views\\\\settings\\\\allegro.php","cwd":"/c/visual studio code/projekty/orderPRO"}
|
||||
{"ts":"2026-05-12T13:14:18Z","tool":"Edit","file":"C:\\\\visual studio code\\\\projekty\\\\orderPRO\\\\resources\\\\views\\\\settings\\\\company.php","cwd":"/c/visual studio code/projekty/orderPRO"}
|
||||
{"ts":"2026-05-12T13:14:31Z","tool":"Edit","file":"C:\\\\visual studio code\\\\projekty\\\\orderPRO\\\\resources\\\\views\\\\settings\\\\cron.php","cwd":"/c/visual studio code/projekty/orderPRO"}
|
||||
{"ts":"2026-05-12T13:14:43Z","tool":"Edit","file":"C:\\\\visual studio code\\\\projekty\\\\orderPRO\\\\resources\\\\views\\\\settings\\\\database.php","cwd":"/c/visual studio code/projekty/orderPRO"}
|
||||
{"ts":"2026-05-12T13:14:52Z","tool":"Edit","file":"C:\\\\visual studio code\\\\projekty\\\\orderPRO\\\\resources\\\\views\\\\settings\\\\database.php","cwd":"/c/visual studio code/projekty/orderPRO"}
|
||||
{"ts":"2026-05-12T13:15:06Z","tool":"Edit","file":"C:\\\\visual studio code\\\\projekty\\\\orderPRO\\\\resources\\\\views\\\\settings\\\\delivery-status-form.php","cwd":"/c/visual studio code/projekty/orderPRO"}
|
||||
{"ts":"2026-05-12T13:15:19Z","tool":"Edit","file":"C:\\\\visual studio code\\\\projekty\\\\orderPRO\\\\resources\\\\views\\\\settings\\\\delivery-statuses.php","cwd":"/c/visual studio code/projekty/orderPRO"}
|
||||
{"ts":"2026-05-12T13:15:30Z","tool":"Edit","file":"C:\\\\visual studio code\\\\projekty\\\\orderPRO\\\\resources\\\\views\\\\settings\\\\email-templates-form.php","cwd":"/c/visual studio code/projekty/orderPRO"}
|
||||
{"ts":"2026-05-12T13:15:42Z","tool":"Edit","file":"C:\\\\visual studio code\\\\projekty\\\\orderPRO\\\\resources\\\\views\\\\settings\\\\email-mailboxes.php","cwd":"/c/visual studio code/projekty/orderPRO"}
|
||||
{"ts":"2026-05-12T13:16:00Z","tool":"Edit","file":"C:\\\\visual studio code\\\\projekty\\\\orderPRO\\\\resources\\\\views\\\\settings\\\\hostedsms.php","cwd":"/c/visual studio code/projekty/orderPRO"}
|
||||
{"ts":"2026-05-12T13:16:15Z","tool":"Edit","file":"C:\\\\visual studio code\\\\projekty\\\\orderPRO\\\\resources\\\\views\\\\settings\\\\smsplanet.php","cwd":"/c/visual studio code/projekty/orderPRO"}
|
||||
{"ts":"2026-05-12T13:16:26Z","tool":"Edit","file":"C:\\\\visual studio code\\\\projekty\\\\orderPRO\\\\resources\\\\views\\\\settings\\\\inpost.php","cwd":"/c/visual studio code/projekty/orderPRO"}
|
||||
{"ts":"2026-05-12T13:16:36Z","tool":"Edit","file":"C:\\\\visual studio code\\\\projekty\\\\orderPRO\\\\resources\\\\views\\\\settings\\\\project-mappings.php","cwd":"/c/visual studio code/projekty/orderPRO"}
|
||||
{"ts":"2026-05-12T13:16:52Z","tool":"Edit","file":"C:\\\\visual studio code\\\\projekty\\\\orderPRO\\\\resources\\\\views\\\\settings\\\\printing.php","cwd":"/c/visual studio code/projekty/orderPRO"}
|
||||
{"ts":"2026-05-12T13:17:03Z","tool":"Edit","file":"C:\\\\visual studio code\\\\projekty\\\\orderPRO\\\\resources\\\\views\\\\settings\\\\statuses.php","cwd":"/c/visual studio code/projekty/orderPRO"}
|
||||
{"ts":"2026-05-12T13:17:13Z","tool":"Edit","file":"C:\\\\visual studio code\\\\projekty\\\\orderPRO\\\\resources\\\\views\\\\settings\\\\statuses.php","cwd":"/c/visual studio code/projekty/orderPRO"}
|
||||
{"ts":"2026-05-12T13:17:25Z","tool":"Edit","file":"C:\\\\visual studio code\\\\projekty\\\\orderPRO\\\\resources\\\\views\\\\settings\\\\integrations.php","cwd":"/c/visual studio code/projekty/orderPRO"}
|
||||
{"ts":"2026-05-12T13:17:36Z","tool":"Edit","file":"C:\\\\visual studio code\\\\projekty\\\\orderPRO\\\\resources\\\\views\\\\settings\\\\shoppro.php","cwd":"/c/visual studio code/projekty/orderPRO"}
|
||||
{"ts":"2026-05-12T13:17:48Z","tool":"Edit","file":"C:\\\\visual studio code\\\\projekty\\\\orderPRO\\\\resources\\\\views\\\\settings\\\\shoppro.php","cwd":"/c/visual studio code/projekty/orderPRO"}
|
||||
{"ts":"2026-05-12T13:17:59Z","tool":"Edit","file":"C:\\\\visual studio code\\\\projekty\\\\orderPRO\\\\resources\\\\views\\\\settings\\\\email-templates.php","cwd":"/c/visual studio code/projekty/orderPRO"}
|
||||
{"ts":"2026-05-12T13:18:50Z","tool":"Edit","file":"C:\\\\visual studio code\\\\projekty\\\\orderPRO\\\\resources\\\\views\\\\orders\\\\show.php","cwd":"/c/visual studio code/projekty/orderPRO"}
|
||||
{"ts":"2026-05-12T13:19:02Z","tool":"Edit","file":"C:\\\\visual studio code\\\\projekty\\\\orderPRO\\\\resources\\\\views\\\\shipments\\\\prepare.php","cwd":"/c/visual studio code/projekty/orderPRO"}
|
||||
{"ts":"2026-05-12T13:19:13Z","tool":"Edit","file":"C:\\\\visual studio code\\\\projekty\\\\orderPRO\\\\resources\\\\views\\\\shipments\\\\prepare.php","cwd":"/c/visual studio code/projekty/orderPRO"}
|
||||
{"ts":"2026-05-12T13:19:19Z","tool":"Edit","file":"C:\\\\visual studio code\\\\projekty\\\\orderPRO\\\\resources\\\\views\\\\shipments\\\\prepare.php","cwd":"/c/visual studio code/projekty/orderPRO"}
|
||||
{"ts":"2026-05-12T13:19:26Z","tool":"Edit","file":"C:\\\\visual studio code\\\\projekty\\\\orderPRO\\\\resources\\\\views\\\\shipments\\\\prepare.php","cwd":"/c/visual studio code/projekty/orderPRO"}
|
||||
{"ts":"2026-05-12T13:20:52Z","tool":"Edit","file":"C:\\\\visual studio code\\\\projekty\\\\orderPRO\\\\.paul\\\\codebase\\\\architecture.md","cwd":"/c/visual studio code/projekty/orderPRO"}
|
||||
{"ts":"2026-05-12T13:21:20Z","tool":"Edit","file":"C:\\\\visual studio code\\\\projekty\\\\orderPRO\\\\.paul\\\\codebase\\\\tech_changelog.md","cwd":"/c/visual studio code/projekty/orderPRO"}
|
||||
{"ts":"2026-05-12T13:21:32Z","tool":"Edit","file":"C:\\\\visual studio code\\\\projekty\\\\orderPRO\\\\.paul\\\\STATE.md","cwd":"/c/visual studio code/projekty/orderPRO"}
|
||||
{"ts":"2026-05-12T16:44:12Z","tool":"Write","file":"C:\\\\visual studio code\\\\projekty\\\\orderPRO\\\\.paul\\\\phases\\\\120-alert-component-unification\\\\120-01-SUMMARY.md","cwd":"/c/visual studio code/projekty/orderPRO"}
|
||||
{"ts":"2026-05-12T16:44:41Z","tool":"Edit","file":"C:\\\\visual studio code\\\\projekty\\\\orderPRO\\\\.paul\\\\changelog\\\\2026-05-12.md","cwd":"/c/visual studio code/projekty/orderPRO"}
|
||||
{"ts":"2026-05-12T16:44:52Z","tool":"Edit","file":"C:\\\\visual studio code\\\\projekty\\\\orderPRO\\\\.paul\\\\changelog\\\\2026-05-12.md","cwd":"/c/visual studio code/projekty/orderPRO"}
|
||||
{"ts":"2026-05-12T16:45:06Z","tool":"Edit","file":"C:\\\\visual studio code\\\\projekty\\\\orderPRO\\\\.paul\\\\STATE.md","cwd":"/c/visual studio code/projekty/orderPRO"}
|
||||
{"ts":"2026-05-12T16:45:12Z","tool":"Edit","file":"C:\\\\visual studio code\\\\projekty\\\\orderPRO\\\\.paul\\\\ROADMAP.md","cwd":"/c/visual studio code/projekty/orderPRO"}
|
||||
{"ts":"2026-05-12T16:46:20Z","tool":"Edit","file":"C:\\\\visual studio code\\\\projekty\\\\orderPRO\\\\.paul\\\\PROJECT.md","cwd":"/c/visual studio code/projekty/orderPRO"}
|
||||
{"ts":"2026-05-12T16:46:40Z","tool":"Edit","file":"C:\\\\visual studio code\\\\projekty\\\\orderPRO\\\\.paul\\\\PROJECT.md","cwd":"/c/visual studio code/projekty/orderPRO"}
|
||||
{"ts":"2026-05-12T16:46:46Z","tool":"Edit","file":"C:\\\\visual studio code\\\\projekty\\\\orderPRO\\\\.paul\\\\PROJECT.md","cwd":"/c/visual studio code/projekty/orderPRO"}
|
||||
{"ts":"2026-05-12T16:46:53Z","tool":"Edit","file":"C:\\\\visual studio code\\\\projekty\\\\orderPRO\\\\.paul\\\\PROJECT.md","cwd":"/c/visual studio code/projekty/orderPRO"}
|
||||
{"ts":"2026-05-12T16:47:00Z","tool":"Bash","cmd":"cd \"C:/visual studio code/projekty/orderPRO\" && git status --short 2>&1 | head -80\",\"description\":\"Git status before commit\"},\"tool_response\":{\"stdout\":\" M .claude/settings.local.json\\n M","cwd":"/c/visual studio code/projekty/orderPRO"}
|
||||
{"ts":"2026-05-12T16:47:29Z","tool":"Bash","cmd":"cd \"C:/visual studio code/projekty/orderPRO\" && git add \\\\\\n .paul/PROJECT.md \\\\\\n .paul/ROADMAP.md \\\\\\n .paul/STATE.md \\\\\\n .paul/changelog/2026-05-12.md \\\\\\n .paul/codebase/arc","cwd":"/c/visual studio code/projekty/orderPRO"}
|
||||
{"ts":"2026-05-12T16:47:42Z","tool":"Bash","cmd":"cd \"C:/visual studio code/projekty/orderPRO\" && git commit -m \"$(cat <<'EOF'\\nfeat(120): alert component unification\\n\\nPhase 120 - Plan 01:\\n- Reusable PHP alert component (resources/views/com","cwd":"/c/visual studio code/projekty/orderPRO"}
|
||||
{"ts":"2026-05-12T16:47:49Z","tool":"Bash","cmd":"cd \"C:/visual studio code/projekty/orderPRO\" && git log -1 --oneline\",\"description\":\"Verify commit\"},\"tool_response\":{\"stdout\":\"933dfcc feat(120): alert component unification\",\"stderr\":","cwd":"/c/visual studio code/projekty/orderPRO"}
|
||||
{"ts":"2026-05-12T16:47:55Z","tool":"Edit","file":"C:\\\\visual studio code\\\\projekty\\\\orderPRO\\\\.paul\\\\STATE.md","cwd":"/c/visual studio code/projekty/orderPRO"}
|
||||
{"ts":"2026-05-12T18:34:44Z","tool":"Edit","file":"c:\\\\visual studio code\\\\projekty\\\\orderPRO\\\\.paul\\\\changelog\\\\2026-05-12.md","cwd":"/c/visual studio code/projekty/orderPRO"}
|
||||
{"ts":"2026-05-12T18:34:58Z","tool":"Edit","file":"c:\\\\visual studio code\\\\projekty\\\\orderPRO\\\\.paul\\\\STATE.md","cwd":"/c/visual studio code/projekty/orderPRO"}
|
||||
{"ts":"2026-05-12T18:35:04Z","tool":"Edit","file":"c:\\\\visual studio code\\\\projekty\\\\orderPRO\\\\.paul\\\\STATE.md","cwd":"/c/visual studio code/projekty/orderPRO"}
|
||||
{"ts":"2026-05-12T18:35:10Z","tool":"Edit","file":"c:\\\\visual studio code\\\\projekty\\\\orderPRO\\\\.paul\\\\ROADMAP.md","cwd":"/c/visual studio code/projekty/orderPRO"}
|
||||
{"ts":"2026-05-12T18:35:17Z","tool":"Edit","file":"c:\\\\visual studio code\\\\projekty\\\\orderPRO\\\\.paul\\\\ROADMAP.md","cwd":"/c/visual studio code/projekty/orderPRO"}
|
||||
{"ts":"2026-05-12T18:35:53Z","tool":"Bash","cmd":"git status --short\",\"description\":\"Git status\"},\"tool_response\":{\"stdout\":\" M .claude/settings.local.json\\n M .paul/ROADMAP.md\\n M .paul/STATE.md\\n M .paul/changelog/2026-05-12.md\\n M .p","cwd":"/c/visual studio code/projekty/orderPRO"}
|
||||
{"ts":"2026-05-12T18:35:53Z","tool":"Bash","cmd":"git log --oneline -10\",\"description\":\"Recent commits\"},\"tool_response\":{\"stdout\":\"933dfcc feat(120): alert component unification\\n3a2c419 feat(119): protect total_paid from re-import overwr","cwd":"/c/visual studio code/projekty/orderPRO"}
|
||||
{"ts":"2026-05-12T18:37:11Z","tool":"Bash","cmd":"git add \\\\\\n database/migrations/20260512_000109_fakturownia_single_instance.sql \\\\\\n src/Modules/Settings/FakturowniaIntegrationController.php \\\\\\n src/Modules/Settings/FakturowniaIntegra","cwd":"/c/visual studio code/projekty/orderPRO"}
|
||||
{"ts":"2026-05-12T18:37:20Z","tool":"Bash","cmd":"git commit -m \"$(cat <<'EOF'\\nfeat(118): fakturownia single instance\\n\\nPhase 118 complete:\\n- migration 20260512_000109 adds single global Fakturownia settings row\\n- FakturowniaIntegrationRepo","cwd":"/c/visual studio code/projekty/orderPRO"}
|
||||
@@ -0,0 +1,236 @@
|
||||
---
|
||||
phase: 121-smsplanet-conversation-notifications
|
||||
plan: 01
|
||||
type: execute
|
||||
wave: 1
|
||||
depends_on: []
|
||||
files_modified:
|
||||
- database/migrations/20260512_000110_smsplanet_conversation_notifications.sql
|
||||
- src/Modules/Settings/SmsplanetIntegrationRepository.php
|
||||
- src/Modules/Settings/SmsplanetIntegrationController.php
|
||||
- src/Modules/Settings/SmsplanetApiClient.php
|
||||
- src/Modules/Sms/SmsMessageRepository.php
|
||||
- src/Modules/Sms/SmsConversationService.php
|
||||
- src/Modules/Sms/SmsplanetWebhookController.php
|
||||
- src/Modules/Notifications/NotificationRepository.php
|
||||
- src/Modules/Notifications/NotificationController.php
|
||||
- src/Modules/Notifications/NotificationApiController.php
|
||||
- src/Modules/Orders/OrdersController.php
|
||||
- routes/web.php
|
||||
- resources/views/settings/smsplanet.php
|
||||
- resources/views/orders/show.php
|
||||
- resources/views/notifications/index.php
|
||||
- resources/views/layouts/app.php
|
||||
- resources/lang/pl.php
|
||||
- resources/scss/app.scss
|
||||
- public/assets/css/app.css
|
||||
- public/assets/js/modules/notifications.js
|
||||
- DOCS/DB_SCHEMA.md
|
||||
- DOCS/ARCHITECTURE.md
|
||||
- DOCS/TECH_CHANGELOG.md
|
||||
autonomous: true
|
||||
delegation: auto
|
||||
---
|
||||
|
||||
<objective>
|
||||
## Goal
|
||||
Wdrozyc dwukierunkowa komunikacje SMSPLANET w orderPRO: konfiguracje wysylki z nadpisu albo numeru 2WAY, odbior webhookow przychodzacych SMS, historie rozmowy SMS w zamowieniu oraz globalne centrum powiadomien z pollingiem i powiadomieniami przegladarki.
|
||||
|
||||
## Purpose
|
||||
Operator ma prowadzic rozmowe SMS z klientem bez opuszczania szczegolow zamowienia, a nowe odpowiedzi klientow maja byc widoczne globalnie jako notyfikacje.
|
||||
|
||||
## Output
|
||||
Nowa migracja DB, backend SMS/notifications, publiczny webhook SMSPLANET, nowa zakladka SMS w zamowieniu, topbar z licznikiem notyfikacji, podstrona historii notyfikacji oraz dokumentacja techniczna.
|
||||
</objective>
|
||||
|
||||
<context>
|
||||
<clarifications>
|
||||
- **Dopasowanie** - Przy wiadomosci przychodzacej z SMSPLANET dopasowujemy ja do najnowszego zamowienia po numerze telefonu klienta, czy wymagamy kodu zamowienia?
|
||||
-> Odpowiedz: Do ostatniego zamowienia.
|
||||
- **Widocznosc** - Historia SMS ma byc nowa zakladka w szczegolach zamowienia czy czesc obecnej zakladki Historia?
|
||||
-> Odpowiedz: Nowa zakladka.
|
||||
- **Polling** - Czy powiadomienia moga dzialac przez polling, czy wymagany jest realtime transport?
|
||||
-> Odpowiedz: Moze byc polling.
|
||||
- **Webhook** - Czy wdrazamy weryfikacje podpisu SMSPLANET Signature od razu?
|
||||
-> Odpowiedz: Na razie bez podpisu.
|
||||
</clarifications>
|
||||
|
||||
## Project Context
|
||||
@.paul/PROJECT.md
|
||||
@.paul/ROADMAP.md
|
||||
@.paul/STATE.md
|
||||
@.paul/codebase/architecture.md
|
||||
@.paul/codebase/db_schema.md
|
||||
@AGENTS.md
|
||||
@DOCS/ARCHITECTURE.md
|
||||
@DOCS/DB_SCHEMA.md
|
||||
|
||||
## Source Files
|
||||
@database/migrations/20260512_000108_create_smsplanet_integration_settings.sql
|
||||
@src/Modules/Settings/SmsplanetApiClient.php
|
||||
@src/Modules/Settings/SmsplanetIntegrationRepository.php
|
||||
@src/Modules/Settings/SmsplanetIntegrationController.php
|
||||
@src/Modules/Orders/OrdersController.php
|
||||
@src/Modules/Orders/OrdersRepository.php
|
||||
@resources/views/settings/smsplanet.php
|
||||
@resources/views/orders/show.php
|
||||
@resources/views/layouts/app.php
|
||||
@resources/lang/pl.php
|
||||
@routes/web.php
|
||||
</context>
|
||||
|
||||
<skills>
|
||||
## Required Skills (from SPECIAL-FLOWS.md)
|
||||
|
||||
| Skill | Priority | When to Invoke | Loaded? |
|
||||
|-------|----------|----------------|---------|
|
||||
| sonar-scanner CLI | required | After APPLY, before UNIFY | not loaded |
|
||||
|
||||
## Skill Invocation Checklist
|
||||
- [ ] Run `sonar-scanner` after implementation, then record any new issues in `DOCS/todo.md` according to `.paul/SPECIAL-FLOWS.md`.
|
||||
</skills>
|
||||
|
||||
<acceptance_criteria>
|
||||
|
||||
## AC-1: SMSPLANET Sender Mode
|
||||
```gherkin
|
||||
Given SMSPLANET integration settings are opened
|
||||
When operator selects sender mode "nadpis" or "numer 2WAY" and saves settings
|
||||
Then orderPRO stores the selected mode, preserves the configured text sender and stores the 2WAY phone number separately
|
||||
```
|
||||
|
||||
## AC-2: Outgoing SMS Uses Selected Sender
|
||||
```gherkin
|
||||
Given SMSPLANET is active and complete
|
||||
When operator sends a test SMS or sends SMS from an order conversation
|
||||
Then API payload uses `from` from the selected sender mode and no hardcoded test override remains
|
||||
```
|
||||
|
||||
## AC-3: Incoming SMS Webhook
|
||||
```gherkin
|
||||
Given SMSPLANET posts an incoming SMS webhook without signature
|
||||
When orderPRO receives sender phone, recipient phone and message body
|
||||
Then it stores the inbound message, matches it to the latest order with that customer phone, and returns a 2xx response
|
||||
```
|
||||
|
||||
## AC-4: Order SMS Conversation
|
||||
```gherkin
|
||||
Given an order has inbound or outbound SMS messages
|
||||
When operator opens the order detail page
|
||||
Then a separate SMS tab shows the chronological conversation and lets the operator send another SMS to the customer
|
||||
```
|
||||
|
||||
## AC-5: Notification Center
|
||||
```gherkin
|
||||
Given an inbound SMS is received
|
||||
When operator is logged in
|
||||
Then the topbar notification icon shows an unread count, the notification center lists the event, and the notification links to the matched order SMS tab
|
||||
```
|
||||
|
||||
## AC-6: Browser Notifications
|
||||
```gherkin
|
||||
Given browser notifications are allowed
|
||||
When notification polling detects a new unread inbound SMS notification
|
||||
Then the browser displays a native notification with concise SMS context and clicking it opens the target order
|
||||
```
|
||||
|
||||
</acceptance_criteria>
|
||||
|
||||
<tasks>
|
||||
|
||||
<task type="auto">
|
||||
<name>Task 1: Add SMS and notification persistence plus backend services</name>
|
||||
<files>database/migrations/20260512_000110_smsplanet_conversation_notifications.sql, src/Modules/Settings/SmsplanetIntegrationRepository.php, src/Modules/Settings/SmsplanetApiClient.php, src/Modules/Sms/SmsMessageRepository.php, src/Modules/Sms/SmsConversationService.php, src/Modules/Sms/SmsplanetWebhookController.php, src/Modules/Notifications/NotificationRepository.php, routes/web.php, DOCS/DB_SCHEMA.md, DOCS/ARCHITECTURE.md, DOCS/TECH_CHANGELOG.md</files>
|
||||
<action>
|
||||
Create schema and backend foundation:
|
||||
- Alter `smsplanet_integration_settings` with `sender_mode` (`text`/`phone`) and `sender_phone`; keep existing `sender` as text sender/nadpis.
|
||||
- Create `sms_messages` with direction (`inbound`/`outbound`), provider (`smsplanet`), nullable `order_id`, `from_phone`, `to_phone`, `body`, `message_id`, `status`, `raw_payload_json`, `created_by`, timestamps and indexes for `(order_id, created_at)`, normalized phone matching, and provider message id.
|
||||
- Create `notifications` with type, title, body, target_url, related_order_id, related_sms_message_id, read_at, created_at and indexes for unread polling.
|
||||
- Implement SMS repository/service methods for sending outbound SMS, storing outbound result, storing inbound webhook payload, normalizing PL/international phone variants, and matching inbound SMS to the latest order whose customer/delivery/invoice phone matches.
|
||||
- Implement notification repository create/list/unread/mark-read methods.
|
||||
- Add public POST webhook route `/webhooks/smsplanet/inbound` without auth middleware and without CSRF; no signature verification in this plan, but store raw payload and isolate parsing in a method so signature validation can be added later.
|
||||
- Use PDO prepared statements only; do not concatenate user values into SQL.
|
||||
- Remove the temporary `TEST_SENDER_OVERRIDE` from `SmsplanetIntegrationController`.
|
||||
</action>
|
||||
<verify>`C:\xampp\php\php.exe -l` on all new/changed PHP files; inspect migration for valid MySQL syntax; manually POST a sample webhook and confirm 2xx plus inserted `sms_messages` and `notifications` rows.</verify>
|
||||
<done>AC-1, AC-2, AC-3 and AC-5 persistence/backend behavior satisfied.</done>
|
||||
</task>
|
||||
|
||||
<task type="auto">
|
||||
<name>Task 2: Add SMS settings UI and order conversation tab</name>
|
||||
<files>src/Modules/Settings/SmsplanetIntegrationController.php, src/Modules/Orders/OrdersController.php, resources/views/settings/smsplanet.php, resources/views/orders/show.php, resources/lang/pl.php, resources/scss/app.scss, public/assets/css/app.css, routes/web.php, DOCS/ARCHITECTURE.md, DOCS/TECH_CHANGELOG.md</files>
|
||||
<action>
|
||||
Build user-facing SMS controls:
|
||||
- Extend SMSPLANET settings form with a compact sender mode selector: text sender/nadpis or 2WAY number. Show both fields clearly and validate the required field based on selected mode.
|
||||
- Add order-level POST endpoint `/orders/{id}/sms/send` protected by auth and CSRF. It sends via SMSPLANET, stores the outbound message and redirects back to the order SMS tab with flash result.
|
||||
- Inject SMS messages into `OrdersController::show()` and render a separate `SMS` tab in `resources/views/orders/show.php`.
|
||||
- Conversation tab must show oldest-to-newest bubbles for inbound/outbound messages, timestamps, status/messageId for outbound, and a compact textarea form for sending a new message to the customer's phone.
|
||||
- Use existing alert component/Flash patterns and escape all output with `$e()`.
|
||||
- Put all styles in SCSS, then rebuild `public/assets/css/app.css`.
|
||||
</action>
|
||||
<verify>`C:\xampp\php\php.exe -l` on changed PHP files; `npm run build:css`; open order detail and verify SMS tab renders empty state, existing messages, and send form without layout overlap.</verify>
|
||||
<done>AC-1, AC-2 and AC-4 satisfied in UI.</done>
|
||||
</task>
|
||||
|
||||
<task type="auto">
|
||||
<name>Task 3: Add global notification center with polling and browser notifications</name>
|
||||
<files>src/Modules/Notifications/NotificationController.php, src/Modules/Notifications/NotificationApiController.php, routes/web.php, resources/views/notifications/index.php, resources/views/layouts/app.php, public/assets/js/modules/notifications.js, resources/scss/app.scss, public/assets/css/app.css, resources/lang/pl.php, DOCS/ARCHITECTURE.md, DOCS/TECH_CHANGELOG.md</files>
|
||||
<action>
|
||||
Implement notification UX:
|
||||
- Add topbar notification icon/button with unread badge and link to `/notifications`.
|
||||
- Add authenticated `/notifications` page with paginated notification history, unread/read state, mark-read action and links to target URLs.
|
||||
- Add authenticated polling API endpoint, e.g. `/api/notifications/unread`, returning unread count and recent unread notification payloads.
|
||||
- Add optional mark-read API endpoint for clicked/opened notifications.
|
||||
- Implement `public/assets/js/modules/notifications.js` polling every 30 seconds, badge update, Notification API permission prompt on first user interaction with notification UI, and native browser notification for newly seen unread items when permission is granted.
|
||||
- Browser notification click should navigate to the notification target URL, usually `/orders/{id}?tab=sms`.
|
||||
- Keep JS progressive: page must work without browser notification permission.
|
||||
</action>
|
||||
<verify>`C:\xampp\php\php.exe -l` on notification controllers; `npm run build:css`; manually create an unread notification row and confirm topbar badge, `/notifications` page, polling update and browser notification behavior.</verify>
|
||||
<done>AC-5 and AC-6 satisfied.</done>
|
||||
</task>
|
||||
|
||||
</tasks>
|
||||
|
||||
<boundaries>
|
||||
|
||||
## DO NOT CHANGE
|
||||
- Do not add runtime usage of `DB_HOST_REMOTE`; runtime continues to use `DB_HOST`.
|
||||
- Do not add native `alert()` or `confirm()` in views or JS; use existing alert module if confirmation/feedback is needed.
|
||||
- Do not inline CSS in views; use `resources/scss/app.scss` and rebuild compiled CSS.
|
||||
- Do not change existing email, invoice, receipt, shipment or automation contracts except for adding notification links where needed.
|
||||
- Do not implement SMSPLANET webhook signature validation in this plan.
|
||||
|
||||
## SCOPE LIMITS
|
||||
- Scope is SMSPLANET only; HostedSMS inbound replies remain out of scope.
|
||||
- Matching inbound SMS is by latest order phone only; no order-code parser in message body.
|
||||
- Notification transport is polling only; no WebSocket/SSE.
|
||||
- No automation event for incoming SMS in this plan.
|
||||
- No multi-user notification recipient model; global unread/read notifications are acceptable for this first version unless existing auth/user model makes per-user read state trivial.
|
||||
|
||||
</boundaries>
|
||||
|
||||
<verification>
|
||||
Before declaring plan complete:
|
||||
- [ ] `C:\xampp\php\php.exe -l` passes for all changed PHP files.
|
||||
- [ ] `npm run build:css` completes and compiled CSS is updated.
|
||||
- [ ] Migration applies on local MySQL when available.
|
||||
- [ ] SMSPLANET settings save both sender modes and send test uses selected sender.
|
||||
- [ ] Sample inbound webhook stores SMS and creates notification.
|
||||
- [ ] Order detail SMS tab shows conversation and sends outbound SMS.
|
||||
- [ ] Notification badge/page/polling/browser notification work in a browser that granted notification permission.
|
||||
- [ ] `DOCS/DB_SCHEMA.md`, `DOCS/ARCHITECTURE.md`, `DOCS/TECH_CHANGELOG.md` updated.
|
||||
- [ ] `sonar-scanner` attempted after APPLY; new issues recorded per `.paul/SPECIAL-FLOWS.md`.
|
||||
</verification>
|
||||
|
||||
<success_criteria>
|
||||
- Operator can configure SMSPLANET to send from nadpis or 2WAY number.
|
||||
- Operator can receive SMSPLANET inbound replies into the latest matching order by phone.
|
||||
- Operator can chat with customer from a dedicated SMS tab in order details.
|
||||
- Inbound SMS creates a visible global notification and optional browser notification.
|
||||
- No hardcoded test sender remains in production flow.
|
||||
- All verification checks pass or environment-specific gaps are documented in SUMMARY.md.
|
||||
</success_criteria>
|
||||
|
||||
<output>
|
||||
After completion, create `.paul/phases/121-smsplanet-conversation-notifications/121-01-SUMMARY.md`.
|
||||
</output>
|
||||
@@ -0,0 +1,182 @@
|
||||
---
|
||||
phase: 121-smsplanet-conversation-notifications
|
||||
plan: 01
|
||||
subsystem: sms
|
||||
tags: [smsplanet, sms, webhook, notifications, polling]
|
||||
|
||||
requires:
|
||||
- phase: 117-smsplanet-integration-settings
|
||||
provides: SMSPLANET credentials, API client and settings page
|
||||
provides:
|
||||
- SMSPLANET inbound webhook
|
||||
- Order SMS conversation tab
|
||||
- Global notification center and unread polling API
|
||||
- SMSPLANET sender mode selection for text sender or 2WAY number
|
||||
affects: [smsplanet, orders, notifications]
|
||||
|
||||
tech-stack:
|
||||
added: []
|
||||
patterns: [PDO repositories, manual route wiring, vanilla JS polling]
|
||||
|
||||
key-files:
|
||||
created:
|
||||
- database/migrations/20260512_000110_smsplanet_conversation_notifications.sql
|
||||
- src/Modules/Sms/SmsMessageRepository.php
|
||||
- src/Modules/Sms/SmsConversationService.php
|
||||
- src/Modules/Sms/SmsplanetWebhookController.php
|
||||
- src/Modules/Notifications/NotificationRepository.php
|
||||
- src/Modules/Notifications/NotificationController.php
|
||||
- src/Modules/Notifications/NotificationApiController.php
|
||||
- resources/views/notifications/index.php
|
||||
- public/assets/js/modules/notifications.js
|
||||
modified:
|
||||
- src/Modules/Settings/SmsplanetIntegrationRepository.php
|
||||
- src/Modules/Settings/SmsplanetIntegrationController.php
|
||||
- src/Modules/Orders/OrdersController.php
|
||||
- routes/web.php
|
||||
- resources/views/settings/smsplanet.php
|
||||
- resources/views/orders/show.php
|
||||
- resources/views/layouts/app.php
|
||||
- resources/lang/pl.php
|
||||
- resources/scss/app.scss
|
||||
- public/assets/css/app.css
|
||||
- DOCS/DB_SCHEMA.md
|
||||
- DOCS/ARCHITECTURE.md
|
||||
- DOCS/TECH_CHANGELOG.md
|
||||
|
||||
key-decisions:
|
||||
- "Inbound SMS matching uses the latest order by normalized phone number."
|
||||
- "Webhook signature validation remains out of scope for this phase."
|
||||
- "Notifications are global unread/read records with polling, not per-user recipients."
|
||||
|
||||
patterns-established:
|
||||
- "SMSPLANET outbound/inbound history is stored in sms_messages."
|
||||
- "Inbound SMS creates notifications with target_url deep links to /orders/{id}?tab=sms."
|
||||
|
||||
duration: ~65min
|
||||
started: 2026-05-12T19:20:00+02:00
|
||||
completed: 2026-05-12T20:35:00+02:00
|
||||
---
|
||||
|
||||
# Phase 121 Plan 01: SMSPLANET Conversation + Notifications Summary
|
||||
|
||||
Dwukierunkowa komunikacja SMSPLANET została podłączona do zamówień, a przychodzące SMS tworzą globalne powiadomienia.
|
||||
|
||||
## Performance
|
||||
|
||||
| Metric | Value |
|
||||
|--------|-------|
|
||||
| Duration | ~65min |
|
||||
| Started | 2026-05-12 19:20 |
|
||||
| Completed | 2026-05-12 20:35 |
|
||||
| Tasks | 3 completed |
|
||||
| Files modified | 20+ |
|
||||
|
||||
## Acceptance Criteria Results
|
||||
|
||||
| Criterion | Status | Notes |
|
||||
|-----------|--------|-------|
|
||||
| AC-1: SMSPLANET Sender Mode | Pass | Settings store `sender_mode`, text sender and 2WAY phone separately. |
|
||||
| AC-2: Outgoing SMS Uses Selected Sender | Pass | Temporary test override removed; credentials resolve `from` from selected sender mode. |
|
||||
| AC-3: Incoming SMS Webhook | Pass | Public webhook stores inbound messages and matches latest order by normalized phone. |
|
||||
| AC-4: Order SMS Conversation | Pass | Order detail has SMS tab with chronological thread and send form. |
|
||||
| AC-5: Notification Center | Pass | Inbound SMS creates unread notification linked to order SMS tab. |
|
||||
| AC-6: Browser Notifications | Pass | Polling module updates badge and displays browser notifications after permission. |
|
||||
|
||||
## Accomplishments
|
||||
|
||||
- Added SMSPLANET conversation persistence in `sms_messages`.
|
||||
- Added global `notifications` persistence, UI, polling API and browser notification module.
|
||||
- Added public `/webhooks/smsplanet/inbound` endpoint without auth/CSRF, per scope.
|
||||
- Added SMS tab and `/orders/{id}/sms/send` flow for order-level SMS replies.
|
||||
- Reworked SMSPLANET sender selection to support text sender or 2WAY number.
|
||||
|
||||
## Verification Results
|
||||
|
||||
| Check | Result |
|
||||
|-------|--------|
|
||||
| `C:\xampp\php\php.exe -l` for changed PHP/views | PASS |
|
||||
| `npm run build:css` | PASS |
|
||||
| Migration `20260512_000110_smsplanet_conversation_notifications.sql` | PASS via technical `DB_HOST_REMOTE` |
|
||||
| `sonar-scanner` | BLOCKED: CLI not available in PATH |
|
||||
| Manual webhook/order/browser checks | PENDING: requires browser session and live SMSPLANET callback/test |
|
||||
|
||||
## Files Created/Modified
|
||||
|
||||
| File | Change | Purpose |
|
||||
|------|--------|---------|
|
||||
| `database/migrations/20260512_000110_smsplanet_conversation_notifications.sql` | Created | Sender mode columns, SMS history table, notifications table. |
|
||||
| `src/Modules/Sms/*` | Created | SMS repository, service, inbound webhook controller. |
|
||||
| `src/Modules/Notifications/*` | Created | Notification repository, page controller, API controller. |
|
||||
| `src/Modules/Settings/SmsplanetIntegrationRepository.php` | Modified | Stores sender mode/2WAY phone and resolves API sender. |
|
||||
| `src/Modules/Orders/OrdersController.php` | Modified | Loads SMS history and sends outbound SMS from order. |
|
||||
| `routes/web.php` | Modified | Wires SMS, webhook and notification routes/services. |
|
||||
| `resources/views/settings/smsplanet.php` | Modified | Adds sender mode and 2WAY number controls. |
|
||||
| `resources/views/orders/show.php` | Modified | Adds SMS tab and send form. |
|
||||
| `resources/views/notifications/index.php` | Created | Notification center. |
|
||||
| `resources/views/layouts/app.php` | Modified | Adds topbar notification badge and JS module. |
|
||||
| `resources/scss/app.scss`, `public/assets/css/app.css` | Modified | Styles for SMS thread, notifications and topbar badge. |
|
||||
| `DOCS/*`, `.paul/codebase/*` | Modified | Technical documentation updated. |
|
||||
|
||||
## Decisions Made
|
||||
|
||||
| Decision | Rationale | Impact |
|
||||
|----------|-----------|--------|
|
||||
| Match inbound SMS to latest order by phone | User clarified this during planning. | No order-code parser needed. |
|
||||
| No webhook signature validation | User clarified "na razie bez podpisu". | Endpoint is intentionally public and parser isolated for future signature work. |
|
||||
| Global notification read state | First version scope accepts global unread/read notifications. | No per-user notification recipient model yet. |
|
||||
| Use polling | User clarified polling is acceptable. | No WebSocket/SSE infrastructure. |
|
||||
|
||||
## Deviations from Plan
|
||||
|
||||
### Summary
|
||||
|
||||
| Type | Count | Impact |
|
||||
|------|-------|--------|
|
||||
| Auto-fixed | 2 | Migration made compatible with real DB and idempotent after partial first attempt. |
|
||||
| Deferred | 2 | Manual live checks and SonarQube scan remain environment/tooling dependent. |
|
||||
|
||||
### Auto-fixed Issues
|
||||
|
||||
**1. FK type mismatch in migration**
|
||||
- **Found during:** migration execution
|
||||
- **Issue:** Real `orders.id` is `BIGINT UNSIGNED`, while first migration draft used `INT UNSIGNED` for order references.
|
||||
- **Fix:** Changed `sms_messages.order_id` and `notifications.related_order_id` to `BIGINT UNSIGNED`.
|
||||
- **Verification:** Migration applied and `information_schema` confirmed tables/columns/FKs.
|
||||
|
||||
**2. Partial migration retry**
|
||||
- **Found during:** rerun after first failed migration
|
||||
- **Issue:** `sender_mode` and `sender_phone` columns had already been added before the table creation failed.
|
||||
- **Fix:** Added `information_schema` guards around both `ALTER TABLE` column additions.
|
||||
- **Verification:** Migration reran successfully.
|
||||
|
||||
### Deferred Items
|
||||
|
||||
- Manual live verification of SMSPLANET webhook, order SMS tab, notification polling and browser notification behavior.
|
||||
- SonarQube scan because `sonar-scanner` is not available in PATH.
|
||||
- Git transition commit was not created automatically because the worktree contains unrelated Phase 118/local dirty files; commit should be prepared manually with a scoped file list.
|
||||
|
||||
## Issues Encountered
|
||||
|
||||
| Issue | Resolution |
|
||||
|-------|------------|
|
||||
| Local MySQL refused connection during first verification | Used technical `DB_HOST_REMOTE` for manual migration operation, per project rules for agent-only DB work. |
|
||||
| `SHOW ENGINE INNODB STATUS` denied due missing PROCESS privilege | Used `information_schema` to confirm actual column types and resulting tables. |
|
||||
| `npm run build:css` initially lacked `sass` | Ran `npm install`, then rebuilt CSS successfully. |
|
||||
|
||||
## Next Phase Readiness
|
||||
|
||||
**Ready:**
|
||||
- SMSPLANET settings, send flow, webhook, SMS tab and notification center can be extended.
|
||||
- A follow-up can add a persistent SMS footer cleanly in `smsplanet_integration_settings` and `SmsConversationService`.
|
||||
|
||||
**Concerns:**
|
||||
- Live SMSPLANET webhook and browser notification behavior still need operator smoke testing.
|
||||
- `sonar-scanner` remains unavailable in PATH.
|
||||
|
||||
**Blockers:**
|
||||
- None for planning the SMS footer follow-up.
|
||||
|
||||
---
|
||||
*Phase: 121-smsplanet-conversation-notifications, Plan: 01*
|
||||
*Completed: 2026-05-12*
|
||||
210
.paul/phases/122-smsplanet-default-sms-footer/122-01-PLAN.md
Normal file
210
.paul/phases/122-smsplanet-default-sms-footer/122-01-PLAN.md
Normal file
@@ -0,0 +1,210 @@
|
||||
---
|
||||
phase: 122-smsplanet-default-sms-footer
|
||||
plan: 01
|
||||
type: execute
|
||||
wave: 1
|
||||
depends_on: []
|
||||
files_modified:
|
||||
- database/migrations/20260512_000111_smsplanet_default_footer.sql
|
||||
- src/Modules/Settings/SmsplanetIntegrationRepository.php
|
||||
- src/Modules/Settings/SmsplanetIntegrationController.php
|
||||
- src/Modules/Sms/SmsConversationService.php
|
||||
- resources/views/settings/smsplanet.php
|
||||
- resources/views/orders/show.php
|
||||
- resources/lang/pl.php
|
||||
- resources/scss/app.scss
|
||||
- public/assets/css/app.css
|
||||
- DOCS/DB_SCHEMA.md
|
||||
- DOCS/ARCHITECTURE.md
|
||||
- DOCS/TECH_CHANGELOG.md
|
||||
autonomous: true
|
||||
delegation: off
|
||||
---
|
||||
|
||||
<objective>
|
||||
## Goal
|
||||
Dodać w konfiguracji SMSPLANET opcjonalną stałą stopkę, która jest automatycznie dopisywana do każdego SMS wychodzącego przez SMSPLANET.
|
||||
|
||||
## Purpose
|
||||
Operator ma ustawić jeden wspólny podpis/stopkę firmy bez ręcznego kopiowania jej w każdej wiadomości SMS z zamówienia lub testu integracji.
|
||||
|
||||
## Output
|
||||
Nowa migracja DB, rozszerzona konfiguracja SMSPLANET, dopinanie stopki w backendzie wysyłki, widoczna informacja w UI zamówienia oraz aktualizacja dokumentacji.
|
||||
</objective>
|
||||
|
||||
<context>
|
||||
<clarifications>
|
||||
- **Doprecyzowania** — Czy plan wymaga dodatkowych pytań przed utworzeniem?
|
||||
→ Odpowiedź: Nie. Przyjęte założenia: stopka jest opcjonalna; pusta wartość wyłącza funkcję; stopka dotyczy testowych SMS i SMS z zamówienia; finalna treść po dopięciu stopki musi mieścić się w limicie 918 znaków; w historii rozmowy zapisujemy finalną treść wysłaną do SMSPLANET.
|
||||
</clarifications>
|
||||
|
||||
## Project Context
|
||||
@.paul/PROJECT.md
|
||||
@.paul/ROADMAP.md
|
||||
@.paul/STATE.md
|
||||
@.paul/codebase/architecture.md
|
||||
@.paul/codebase/db_schema.md
|
||||
@AGENTS.md
|
||||
@DOCS/ARCHITECTURE.md
|
||||
@DOCS/DB_SCHEMA.md
|
||||
@.paul/phases/121-smsplanet-conversation-notifications/121-01-SUMMARY.md
|
||||
|
||||
## Source Files
|
||||
@src/Modules/Settings/SmsplanetIntegrationRepository.php
|
||||
@src/Modules/Settings/SmsplanetIntegrationController.php
|
||||
@src/Modules/Settings/SmsplanetApiClient.php
|
||||
@src/Modules/Sms/SmsConversationService.php
|
||||
@resources/views/settings/smsplanet.php
|
||||
@resources/views/orders/show.php
|
||||
@resources/lang/pl.php
|
||||
@resources/scss/app.scss
|
||||
</context>
|
||||
|
||||
<skills>
|
||||
## Required Skills (from SPECIAL-FLOWS.md)
|
||||
|
||||
| Skill | Priority | When to Invoke | Loaded? |
|
||||
|-------|----------|----------------|---------|
|
||||
| sonar-scanner CLI | required | After APPLY, before UNIFY | not loaded |
|
||||
|
||||
## Skill Invocation Checklist
|
||||
- [ ] Run `sonar-scanner` after implementation, then record any new issues in `DOCS/todo.md` according to `.paul/SPECIAL-FLOWS.md`.
|
||||
</skills>
|
||||
|
||||
<acceptance_criteria>
|
||||
|
||||
## AC-1: Footer Configuration
|
||||
```gherkin
|
||||
Given SMSPLANET integration settings are opened
|
||||
When operator enters a default SMS footer and saves settings
|
||||
Then orderPRO stores the footer separately from sender/auth settings and shows it again on reload
|
||||
```
|
||||
|
||||
## AC-2: Footer Applied to Test SMS
|
||||
```gherkin
|
||||
Given a default SMS footer is configured
|
||||
When operator sends a test SMS from SMSPLANET settings
|
||||
Then the payload sent to SMSPLANET contains the test message with the footer appended exactly once
|
||||
```
|
||||
|
||||
## AC-3: Footer Applied to Order SMS
|
||||
```gherkin
|
||||
Given a default SMS footer is configured
|
||||
When operator sends SMS from the order conversation tab
|
||||
Then SMSPLANET receives the message body with the footer appended and sms_messages stores the final sent body
|
||||
```
|
||||
|
||||
## AC-4: Empty Footer Does Nothing
|
||||
```gherkin
|
||||
Given the default SMS footer is empty
|
||||
When operator sends a test SMS or an order SMS
|
||||
Then the outgoing message body remains unchanged
|
||||
```
|
||||
|
||||
## AC-5: Length Validation Uses Final Body
|
||||
```gherkin
|
||||
Given message text plus configured footer would exceed 918 characters
|
||||
When operator tries to send SMS
|
||||
Then orderPRO rejects the send with a clear validation error before calling SMSPLANET
|
||||
```
|
||||
|
||||
</acceptance_criteria>
|
||||
|
||||
<tasks>
|
||||
|
||||
<task type="auto">
|
||||
<name>Task 1: Persist SMSPLANET default footer</name>
|
||||
<files>database/migrations/20260512_000111_smsplanet_default_footer.sql, src/Modules/Settings/SmsplanetIntegrationRepository.php, src/Modules/Settings/SmsplanetIntegrationController.php, DOCS/DB_SCHEMA.md, DOCS/ARCHITECTURE.md, DOCS/TECH_CHANGELOG.md</files>
|
||||
<action>
|
||||
Add persistence and settings contract:
|
||||
- Create migration adding nullable `default_footer` TEXT column to `smsplanet_integration_settings`.
|
||||
- Make migration idempotent using `information_schema.COLUMNS`, because Phase 121 showed migrations may be retried after partial execution.
|
||||
- Extend `SmsplanetIntegrationRepository::getSettings()` and `getCredentials()` to expose trimmed `default_footer`.
|
||||
- Extend `saveSettings()` to accept and store `default_footer`; normalize line endings, trim surrounding whitespace, allow empty/null.
|
||||
- Validate footer length conservatively (max 300 characters) so the setting cannot consume the whole SMS limit.
|
||||
- Extend `SmsplanetIntegrationController::save()` to pass the new field.
|
||||
- Update DB/architecture/changelog docs with the new column and footer behavior.
|
||||
- Use PDO prepared statements only.
|
||||
</action>
|
||||
<verify>`C:\xampp\php\php.exe -l` on changed PHP files; inspect migration for valid MySQL syntax and idempotent guards.</verify>
|
||||
<done>AC-1 persistence/contract satisfied.</done>
|
||||
</task>
|
||||
|
||||
<task type="auto">
|
||||
<name>Task 2: Apply footer to every outbound SMSPLANET send</name>
|
||||
<files>src/Modules/Settings/SmsplanetIntegrationController.php, src/Modules/Sms/SmsConversationService.php, resources/views/orders/show.php, DOCS/ARCHITECTURE.md, DOCS/TECH_CHANGELOG.md</files>
|
||||
<action>
|
||||
Apply footer in backend send paths:
|
||||
- Add a small private method in `SmsConversationService` to build the final outbound body from user text plus `credentials['default_footer']`.
|
||||
- Use a blank line separator between message body and footer when footer is non-empty.
|
||||
- Ensure footer is appended exactly once per send operation; do not mutate the stored configuration value.
|
||||
- Validate the final message body length against the existing 918-character limit before calling `SmsplanetApiClient`.
|
||||
- Store the final sent body in `sms_messages.body`, because conversation history should match what customer received.
|
||||
- Apply the same final-body logic in `SmsplanetIntegrationController::test()` before `sendSms()`, with a shared helper if practical without over-abstracting.
|
||||
- Show a compact note in the order SMS form that a configured footer will be added automatically; do not inline CSS.
|
||||
</action>
|
||||
<verify>`C:\xampp\php\php.exe -l` on changed PHP files/views; manually review that SMSPLANET API calls receive final body and no double footer path exists.</verify>
|
||||
<done>AC-2, AC-3, AC-4 and AC-5 satisfied.</done>
|
||||
</task>
|
||||
|
||||
<task type="auto">
|
||||
<name>Task 3: Add footer UI and rebuild styles</name>
|
||||
<files>resources/views/settings/smsplanet.php, resources/lang/pl.php, resources/scss/app.scss, public/assets/css/app.css, DOCS/TECH_CHANGELOG.md</files>
|
||||
<action>
|
||||
Add compact UI for footer:
|
||||
- Add textarea in SMSPLANET settings form labeled as default SMS footer.
|
||||
- Keep UI compact and aligned with the existing two-column settings layout.
|
||||
- Add hint explaining that footer is appended to test SMS and order conversation SMS.
|
||||
- Escape all output with `$e()`.
|
||||
- Put any needed layout styling in `resources/scss/app.scss`; do not add inline styles.
|
||||
- Rebuild `public/assets/css/app.css`.
|
||||
</action>
|
||||
<verify>`C:\xampp\php\php.exe -l resources/views/settings/smsplanet.php resources/lang/pl.php`; `npm run build:css`.</verify>
|
||||
<done>AC-1 UI satisfied and compiled CSS updated.</done>
|
||||
</task>
|
||||
|
||||
</tasks>
|
||||
|
||||
<boundaries>
|
||||
|
||||
## DO NOT CHANGE
|
||||
- Do not modify runtime DB host handling; `DB_HOST_REMOTE` remains agent-only for manual DB operations.
|
||||
- Do not alter inbound webhook parsing or phone/order matching except if needed to keep PHP syntax valid.
|
||||
- Do not add native `alert()` or `confirm()`.
|
||||
- Do not inline CSS in views.
|
||||
- Do not add a new SMS provider or change HostedSMS behavior.
|
||||
- Do not implement per-order/per-user footer overrides in this plan.
|
||||
|
||||
## SCOPE LIMITS
|
||||
- Footer is global SMSPLANET-only configuration.
|
||||
- Footer applies only to outbound SMSPLANET sends from settings test and order conversation.
|
||||
- No UI preview/counter is required in this plan unless it is trivial and does not expand scope.
|
||||
- No automatic migration run on production is required during planning; APPLY should attempt migration when environment is available.
|
||||
|
||||
</boundaries>
|
||||
|
||||
<verification>
|
||||
Before declaring plan complete:
|
||||
- [ ] `C:\xampp\php\php.exe -l` passes for changed PHP files and PHP views.
|
||||
- [ ] `npm run build:css` completes.
|
||||
- [ ] Migration applies when DB is available.
|
||||
- [ ] SMSPLANET settings save and reload footer.
|
||||
- [ ] Test SMS uses message plus footer.
|
||||
- [ ] Order SMS uses message plus footer and stores final body.
|
||||
- [ ] Empty footer leaves body unchanged.
|
||||
- [ ] Over-limit final body is rejected before SMSPLANET API call.
|
||||
- [ ] `DOCS/DB_SCHEMA.md`, `DOCS/ARCHITECTURE.md`, `DOCS/TECH_CHANGELOG.md` updated.
|
||||
- [ ] `sonar-scanner` attempted after APPLY; new issues recorded per `.paul/SPECIAL-FLOWS.md` if scan runs.
|
||||
</verification>
|
||||
|
||||
<success_criteria>
|
||||
- Operator can configure one persistent SMSPLANET default footer.
|
||||
- Every outbound SMSPLANET message includes the footer exactly once when configured.
|
||||
- History stores the actual final sent SMS body.
|
||||
- Existing sender mode, inbound webhook and notification behavior remain unchanged.
|
||||
- Verification passes or environment-specific gaps are documented in SUMMARY.md.
|
||||
</success_criteria>
|
||||
|
||||
<output>
|
||||
After completion, create `.paul/phases/122-smsplanet-default-sms-footer/122-01-SUMMARY.md`.
|
||||
</output>
|
||||
@@ -0,0 +1,50 @@
|
||||
---
|
||||
phase: 122-smsplanet-default-sms-footer
|
||||
plan: 01
|
||||
status: applied
|
||||
applied_at: 2026-05-12 21:25:00
|
||||
---
|
||||
|
||||
# APPLY Summary - Phase 122 Plan 01
|
||||
|
||||
## Tasks Completed
|
||||
|
||||
- Task 1: Persist SMSPLANET default footer - PASS
|
||||
- Task 2: Apply footer to every outbound SMSPLANET send - PASS
|
||||
- Task 3: Add footer UI and rebuild styles - PASS
|
||||
|
||||
## Implemented
|
||||
|
||||
- Added `database/migrations/20260512_000111_smsplanet_default_footer.sql` with idempotent `information_schema.COLUMNS` guard for `smsplanet_integration_settings.default_footer`.
|
||||
- Extended `SmsplanetIntegrationRepository` to expose, validate, normalize, persist, and return `default_footer`.
|
||||
- Extended SMSPLANET settings save/test flow so test SMS uses final body with footer and validates the final body against 918 characters.
|
||||
- Extended `SmsConversationService` so order SMS uses final body with footer, stores final body in `sms_messages.body`, and rejects over-limit final body before API call.
|
||||
- Added SMSPLANET settings textarea and compact order SMS note when a footer is configured.
|
||||
- Updated `DOCS/DB_SCHEMA.md`, `DOCS/ARCHITECTURE.md`, `DOCS/TECH_CHANGELOG.md`, and PAUL codebase docs.
|
||||
|
||||
## Verification
|
||||
|
||||
- `C:\xampp\php\php.exe -l src/Modules/Settings/SmsplanetIntegrationRepository.php` - PASS
|
||||
- `C:\xampp\php\php.exe -l src/Modules/Settings/SmsplanetIntegrationController.php` - PASS
|
||||
- `C:\xampp\php\php.exe -l src/Modules/Sms/SmsConversationService.php` - PASS
|
||||
- `C:\xampp\php\php.exe -l src/Modules/Orders/OrdersController.php` - PASS
|
||||
- `C:\xampp\php\php.exe -l resources/views/settings/smsplanet.php` - PASS
|
||||
- `C:\xampp\php\php.exe -l resources/views/orders/show.php` - PASS
|
||||
- `C:\xampp\php\php.exe -l resources/lang/pl.php` - PASS
|
||||
- `npm run build:css` - PASS
|
||||
- `C:\xampp\php\php.exe bin\migrate.php` with standard `DB_HOST=localhost` - FAIL, local MySQL refused connection.
|
||||
- Manual migration run with process-only `DB_HOST=DB_HOST_REMOTE` - PASS, `[ok] 20260512_000111_smsplanet_default_footer.sql`.
|
||||
- `information_schema.COLUMNS` check for `default_footer` - PASS (`TEXT`, nullable).
|
||||
- `sonar-scanner` - FAIL, CLI not available in PATH.
|
||||
|
||||
## Manual Gaps
|
||||
|
||||
- Real SMSPLANET test send with configured footer not executed in browser.
|
||||
- Order conversation SMS send with configured footer not executed in browser.
|
||||
- Empty-footer send path not manually exercised in browser.
|
||||
- Over-limit final body rejection reviewed in code but not manually triggered through UI.
|
||||
|
||||
## Deviations
|
||||
|
||||
- No functional deviation from plan.
|
||||
- SonarQube scan could not run because `sonar-scanner` is not installed or not in PATH.
|
||||
@@ -19,7 +19,7 @@ HTTP Request
|
||||
| Layer | Location | Responsibility |
|
||||
|-------|----------|----------------|
|
||||
| Entry | `public/index.php` | Bootstrap only |
|
||||
| Routes | `routes/web.php` (581 lines) | All ~80 routes; manual DI wiring |
|
||||
| Routes | `routes/web.php` | All routes; manual DI wiring |
|
||||
| Core | `src/Core/` (25 files) | Framework infrastructure |
|
||||
| Controllers | `src/Modules/*/Controller.php` | Request parsing → response |
|
||||
| Services | `src/Modules/*/Service.php` | Business logic |
|
||||
@@ -40,6 +40,8 @@ HTTP Request
|
||||
| **Email** | 3 | `EmailSendingService`, `VariableResolver`, `AttachmentGenerator` | Template-based email with PDF attachments |
|
||||
| **Automation** | 6 | `AutomationService` (834 LOC), `AutomationRepository`, `AutomationExecutionLogRepository` | Event→condition→action rules, email triggers |
|
||||
| **Settings** | 51+ | Integration controllers, OAuth clients, API clients, mappers | Allegro/shopPRO/Apaczka/InPost config, status mappings |
|
||||
| **Sms** | 3 | `SmsMessageRepository`, `SmsConversationService`, `SmsplanetWebhookController` | SMSPLANET outbound order SMS, inbound webhook parsing, order matching |
|
||||
| **Notifications** | 3 | `NotificationRepository`, `NotificationController`, `NotificationApiController` | Global notification history, unread polling API, mark-read actions |
|
||||
| **Cron** | 12 | `CronRepository`, `CronHandlerFactory`, handler classes | Scheduled imports, syncs, token refresh |
|
||||
| **Printing** | 4 | `PrintApiController`, `PrintJobRepository`, `ApiKeyMiddleware` | REST API for Windows print client |
|
||||
| **Statistics** | 3 | `OrdersStatisticsController`, `OrdersStatisticsRepository`, `statistics-summary-charts.js` | Daily order statistics and monthly summary charts |
|
||||
@@ -61,6 +63,12 @@ HTTP Request
|
||||
- Chart 2 displays monthly gross order values per selected integration plus a `Razem` line.
|
||||
- The PHP view keeps table fallbacks under both charts, so the data remains visible if JavaScript fails.
|
||||
|
||||
### Notifications (`public/assets/js/modules/notifications.js`)
|
||||
- Loaded globally from `resources/views/layouts/app.php`; activates only when the topbar notification button exists.
|
||||
- Polls `/api/notifications/unread` every 30 seconds and updates the unread badge.
|
||||
- Requests browser Notification API permission only after user interaction with the notification button.
|
||||
- Shows native browser notifications for newly seen unread items when permission is granted; click navigates to `target_url`.
|
||||
|
||||
## Key Data Flows
|
||||
|
||||
### Order Lifecycle
|
||||
@@ -83,6 +91,13 @@ HTTP Request
|
||||
1. **Generate** — `ReceiptController::store()` → `ReceiptService::generateReceipt()` → `ReceiptRepository::insert()` + Dompdf PDF
|
||||
2. **Email** — `EmailSendingService::send()` → `VariableResolver::resolve()` → `AttachmentGenerator::generatePdf()` → PHPMailer SMTP
|
||||
|
||||
### SMSPLANET Conversation
|
||||
1. **Settings** — `/settings/integrations/smsplanet` stores auth, text sender, `sender_mode`, optional 2WAY `sender_phone`, and optional global `default_footer`.
|
||||
2. **Outbound from order** — `/orders/{id}/sms/send` → `OrdersController::sendSms()` → `SmsConversationService::sendFromOrder()` appends `default_footer` when configured, validates the final body against 918 characters, sends through `SmsplanetApiClient::sendSms()`, and stores the final sent body in `sms_messages`.
|
||||
3. **Inbound webhook** — public `/webhooks/smsplanet/inbound` accepts SMSPLANET 2WAY `POST application/x-www-form-urlencoded` with `message=<JSON>`, plus fallback POST/GET payloads → `SmsplanetWebhookController::inbound()` → `SmsConversationService::receiveSmsplanetWebhook()`; successful 2WAY receipt returns plain `OK`.
|
||||
4. **Order matching** — inbound sender phone is normalized and matched to the latest order by `order_addresses.phone`.
|
||||
5. **Notification** — inbound SMS creates `notifications.type='sms_inbound'` with a target URL to the order SMS tab when an order was matched.
|
||||
|
||||
### Automation Rules
|
||||
1. **Setup** — `AutomationController` → `AutomationRepository::insertRule()`
|
||||
2. **Trigger** — `AutomationService::executeForOrder()` → evaluates trigger (`order_status_changed`, `order_status_aged`) → runs action (send email, update status)
|
||||
@@ -174,7 +189,7 @@ tests/
|
||||
- Zarzadza pojedynczym rekordem `smsplanet_integration_settings` (`id=1`) i bazowym wpisem `integrations` typu `smsplanet`.
|
||||
- Obsluguje dwie metody autoryzacji: Bearer token oraz `key` + `password`.
|
||||
- Szyfruje token, klucz API i haslo przez `IntegrationSecretCipher`; formularz widzi tylko flagi `has_api_token`, `has_api_key` i `has_api_password`.
|
||||
- Udostepnia `getCredentials()` tylko dla kompletnej i aktywnej konfiguracji testowej wysylki SMS.
|
||||
- Udostepnia `getCredentials()` tylko dla kompletnej i aktywnej konfiguracji testowej wysylki SMS, razem z opcjonalna `default_footer`.
|
||||
|
||||
### SmsplanetApiClient (`src/Modules/Settings/SmsplanetApiClient.php`)
|
||||
- Wykonuje `POST https://api2.smsplanet.pl/sms` jako `application/x-www-form-urlencoded`.
|
||||
@@ -185,10 +200,35 @@ tests/
|
||||
### SmsplanetIntegrationController (`src/Modules/Settings/SmsplanetIntegrationController.php`)
|
||||
- Endpointy: `GET /settings/integrations/smsplanet`, `POST /settings/integrations/smsplanet/save`, `POST /settings/integrations/smsplanet/test`.
|
||||
- `test` realnie wysyla SMS z edytowalna trescia i zapisuje wynik w `integrations.last_test_*`.
|
||||
- Testowa wysylka dopisuje `default_footer` przed wywolaniem SMSPLANET i waliduje finalna tresc w limicie 918 znakow.
|
||||
|
||||
### IntegrationsHubController
|
||||
- Dodaje wiersz SMSPLANET do `/settings/integrations` ze statusem konfiguracji, sekretu, aktywnosci i ostatniego testu.
|
||||
|
||||
## Phase 118 - Fakturownia Single Instance
|
||||
|
||||
### FakturowniaIntegrationRepository (`src/Modules/Settings/FakturowniaIntegrationRepository.php`)
|
||||
- Zarzadza pojedynczym globalnym rekordem `fakturownia_integration_settings` (`id=1`) i jednym bazowym wpisem `integrations.type='fakturownia'`.
|
||||
- `getSettings()` zwraca dane formularza, flagi `has_api_token`, aktywnosc i wynik ostatniego testu.
|
||||
- `saveSettings()` aktualizuje globalna konfiguracje; pusty `api_token` zachowuje zapisany sekret.
|
||||
- `findAll()` zostaje jako kompatybilny wrapper zwracajacy liste z jednym elementem dla starszych wywolan.
|
||||
- `getIntegrationId()` jest zrodlem prawdy dla `invoice_configs.integration_id` przy delegacji faktur.
|
||||
|
||||
### FakturowniaIntegrationController
|
||||
- Endpointy aktywne: `GET /settings/integrations/fakturownia`, `POST /settings/integrations/fakturownia/save`, `POST /settings/integrations/fakturownia/test`.
|
||||
- Legacy `/new` i `/edit` przekierowuja na globalna konfiguracje; delete z UI nie jest oferowany.
|
||||
- Widok `resources/views/settings/fakturownia.php` pokazuje jeden formularz konfiguracji oraz panel testu polaczenia.
|
||||
|
||||
### InvoiceConfigRepository + InvoiceConfigController
|
||||
- Przy `is_delegated=1` zapis konfiguracji ignoruje wieloinstancyjny wybor konta i ustawia `integration_id` na globalny Fakturownia id.
|
||||
- Kolumna `invoice_configs.integration_id` zostaje dla kompatybilnosci z `InvoiceService` i historia wystawionych faktur.
|
||||
- Widok konfiguracji faktury pokazuje status globalnej Fakturowni zamiast selecta kont.
|
||||
|
||||
### Migration 20260512_000109
|
||||
- Wybiera aktywna instancje Fakturowni jako zachowana; fallback: najczesciej uzywana w `invoice_configs`, potem najnizsze id.
|
||||
- Przepina delegowane `invoice_configs.integration_id` na zachowana instancje i zeruje `integration_id` dla lokalnych konfiguracji.
|
||||
- Usuwa nadmiarowe rekordy `fakturownia_integration_settings` i `integrations.type='fakturownia'` po przepieciu zaleznosci.
|
||||
|
||||
## Phase 108 — Delivery Status Management
|
||||
|
||||
### DeliveryStatusRepository (`src/Modules/Shipments/DeliveryStatusRepository.php`)
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# Database Schema
|
||||
|
||||
**Updated:** 2026-05-12 | **Total tables:** 60 | **Engine:** InnoDB | **Charset:** utf8mb4_unicode_ci
|
||||
**Updated:** 2026-05-12 | **Total tables:** 62 | **Engine:** InnoDB | **Charset:** utf8mb4_unicode_ci
|
||||
|
||||
---
|
||||
|
||||
@@ -243,7 +243,7 @@ UNIQUE: `(integration_id, external_product_id, external_variant_id)`
|
||||
**orders** — Imported orders from sales channels
|
||||
| Column | Type | Nullable | Notes |
|
||||
|--------|------|----------|-------|
|
||||
| `id` | INT UNSIGNED | NO | PK |
|
||||
| `id` | BIGINT UNSIGNED | NO | PK |
|
||||
| `internal_order_number` | VARCHAR(11) | YES | UNIQUE, auto-assigned |
|
||||
| `integration_id` | INT UNSIGNED | NO | FK → integrations(id) CASCADE |
|
||||
| `external_order_id` | VARCHAR(64) | NO | |
|
||||
@@ -558,6 +558,23 @@ UNIQUE: `(type, name)`
|
||||
|
||||
---
|
||||
|
||||
**fakturownia_integration_settings** - Fakturownia account credentials (Phase 118; fixed 1 row)
|
||||
| Column | Type | Nullable | Notes |
|
||||
|--------|------|----------|-------|
|
||||
| `id` | INT UNSIGNED | NO | PK, always 1 after `20260512_000109_fakturownia_single_instance.sql` |
|
||||
| `integration_id` | INT UNSIGNED | NO | UNIQUE, FK -> integrations(id) CASCADE; single `integrations.type='fakturownia'` row |
|
||||
| `account_prefix` | VARCHAR(64) | NO | Subdomain: `{prefix}.fakturownia.pl` |
|
||||
| `api_token_encrypted` | TEXT | YES | AES-encrypted via `IntegrationSecretCipher` |
|
||||
| `department_id` | VARCHAR(64) | YES | Optional Fakturownia department |
|
||||
| `default_kind` | VARCHAR(32) | NO | DEFAULT `vat` |
|
||||
| `default_payment_to_days` | TINYINT UNSIGNED | NO | DEFAULT 7 |
|
||||
| `created_at` | DATETIME | NO | |
|
||||
| `updated_at` | DATETIME | NO | |
|
||||
|
||||
UNIQUE: `(integration_id)` - one global Fakturownia settings row. Phase 118 migration keeps the active Fakturownia integration, rewires delegated `invoice_configs.integration_id` to it, and removes extra Fakturownia integration rows.
|
||||
|
||||
---
|
||||
|
||||
**hostedsms_integration_settings** - HostedSMS account credentials (Phase 116; fixed 1 row)
|
||||
| Column | Type | Nullable | Notes |
|
||||
|--------|------|----------|-------|
|
||||
@@ -583,9 +600,12 @@ UNIQUE: `(integration_id)` - one global HostedSMS settings row.
|
||||
| `api_token_encrypted` | TEXT | YES | AES-encrypted Bearer token via `IntegrationSecretCipher` |
|
||||
| `api_key_encrypted` | TEXT | YES | AES-encrypted API key via `IntegrationSecretCipher` |
|
||||
| `api_password_encrypted` | TEXT | YES | AES-encrypted API password via `IntegrationSecretCipher` |
|
||||
| `sender` | VARCHAR(32) | YES | SMSPLANET `from` sender |
|
||||
| `sender` | VARCHAR(32) | YES | Text sender / nadpis |
|
||||
| `sender_mode` | VARCHAR(16) | NO | DEFAULT `text`; `text` uses `sender`, `phone` uses `sender_phone` |
|
||||
| `sender_phone` | VARCHAR(32) | YES | SMSPLANET 2WAY phone number |
|
||||
| `clear_polish` | TINYINT(1) | NO | DEFAULT 0 |
|
||||
| `transactional` | TINYINT(1) | NO | DEFAULT 0 |
|
||||
| `default_footer` | TEXT | YES | Optional global footer appended to SMSPLANET test and order SMS |
|
||||
| `created_at` | DATETIME | NO | |
|
||||
| `updated_at` | DATETIME | NO | |
|
||||
|
||||
@@ -593,6 +613,44 @@ UNIQUE: `(integration_id)` - one global SMSPLANET settings row.
|
||||
|
||||
---
|
||||
|
||||
**sms_messages** - SMSPLANET inbound/outbound conversation history (Phase 121)
|
||||
| Column | Type | Nullable | Notes |
|
||||
|--------|------|----------|-------|
|
||||
| `id` | BIGINT UNSIGNED | NO | PK |
|
||||
| `direction` | VARCHAR(16) | NO | `inbound` or `outbound` |
|
||||
| `provider` | VARCHAR(32) | NO | DEFAULT `smsplanet` |
|
||||
| `order_id` | BIGINT UNSIGNED | YES | FK -> orders(id) SET NULL |
|
||||
| `from_phone` | VARCHAR(64) | NO | Original sender value |
|
||||
| `from_phone_normalized` | VARCHAR(32) | NO | Digits-only sender for matching/indexes |
|
||||
| `to_phone` | VARCHAR(64) | NO | Original recipient value |
|
||||
| `to_phone_normalized` | VARCHAR(32) | NO | Digits-only recipient for matching/indexes |
|
||||
| `body` | TEXT | NO | SMS body |
|
||||
| `message_id` | VARCHAR(128) | YES | Provider message id |
|
||||
| `status` | VARCHAR(32) | NO | `received`, `sent`, `failed` |
|
||||
| `raw_payload_json` | JSON | YES | Webhook payload or send result snapshot |
|
||||
| `created_by` | INT UNSIGNED | YES | FK -> users(id) SET NULL |
|
||||
| `created_at` | DATETIME | NO | |
|
||||
| `updated_at` | DATETIME | NO | |
|
||||
|
||||
Indexes: `sms_messages_order_created_idx`, `sms_messages_from_normalized_idx`, `sms_messages_to_normalized_idx`, `sms_messages_provider_message_idx`
|
||||
|
||||
**notifications** - Global operator notification center (Phase 121)
|
||||
| Column | Type | Nullable | Notes |
|
||||
|--------|------|----------|-------|
|
||||
| `id` | BIGINT UNSIGNED | NO | PK |
|
||||
| `type` | VARCHAR(64) | NO | e.g. `sms_inbound` |
|
||||
| `title` | VARCHAR(190) | NO | |
|
||||
| `body` | VARCHAR(500) | NO | Concise notification text |
|
||||
| `target_url` | VARCHAR(500) | YES | Deep link, usually `/orders/{id}?tab=sms` |
|
||||
| `related_order_id` | BIGINT UNSIGNED | YES | FK -> orders(id) SET NULL |
|
||||
| `related_sms_message_id` | BIGINT UNSIGNED | YES | FK -> sms_messages(id) SET NULL |
|
||||
| `read_at` | DATETIME | YES | NULL means unread |
|
||||
| `created_at` | DATETIME | NO | |
|
||||
|
||||
Indexes: `notifications_unread_created_idx`, `notifications_order_idx`, `notifications_sms_message_idx`
|
||||
|
||||
---
|
||||
|
||||
## Accounting / Receipts
|
||||
|
||||
**receipt_configs** — Receipt generation configurations
|
||||
@@ -640,6 +698,60 @@ UNIQUE: `(config_id, year, month)`
|
||||
|
||||
---
|
||||
|
||||
## Invoices
|
||||
|
||||
**invoice_configs** - Invoice generation configurations
|
||||
| Column | Type | Nullable | Notes |
|
||||
|--------|------|----------|-------|
|
||||
| `id` | BIGINT UNSIGNED | NO | PK |
|
||||
| `name` | VARCHAR(128) | NO | |
|
||||
| `integration_id` | INT UNSIGNED | YES | FK -> integrations(id) SET NULL; delegated configs always point to the single global Fakturownia row |
|
||||
| `is_delegated` | TINYINT(1) | NO | DEFAULT 0 |
|
||||
| `is_active` | TINYINT(1) | NO | DEFAULT 1 |
|
||||
| `number_format` | VARCHAR(64) | NO | DEFAULT `FV/%N/%M/%Y` |
|
||||
| `numbering_type` | ENUM('monthly','yearly') | NO | DEFAULT `monthly` |
|
||||
| `sale_date_source` | ENUM('order_date','payment_date','issue_date') | NO | DEFAULT `issue_date` |
|
||||
| `order_reference` | ENUM('none','orderpro','integration') | NO | DEFAULT `none` |
|
||||
| `payment_to_days` | TINYINT UNSIGNED | NO | DEFAULT 7 |
|
||||
| `default_kind` | VARCHAR(32) | NO | DEFAULT `vat` |
|
||||
| `created_at` | DATETIME | NO | |
|
||||
| `updated_at` | DATETIME | NO | |
|
||||
|
||||
**invoices** - Generated invoices
|
||||
| Column | Type | Nullable | Notes |
|
||||
|--------|------|----------|-------|
|
||||
| `id` | BIGINT UNSIGNED | NO | PK |
|
||||
| `order_id` | BIGINT UNSIGNED | NO | FK -> orders(id) CASCADE |
|
||||
| `config_id` | INT UNSIGNED | NO | FK -> invoice_configs(id) RESTRICT |
|
||||
| `invoice_number` | VARCHAR(64) | NO | UNIQUE |
|
||||
| `issue_date` | DATETIME | NO | |
|
||||
| `sale_date` | DATETIME | NO | |
|
||||
| `payment_due_date` | DATETIME | YES | |
|
||||
| `seller_data_json` | JSON | NO | Snapshot of company data at issue time |
|
||||
| `buyer_data_json` | JSON | YES | |
|
||||
| `items_json` | JSON | NO | |
|
||||
| `total_net` | DECIMAL(12,2) | NO | DEFAULT 0.00 |
|
||||
| `total_gross` | DECIMAL(12,2) | NO | DEFAULT 0.00 |
|
||||
| `order_reference_value` | VARCHAR(128) | YES | |
|
||||
| `external_invoice_id` | VARCHAR(128) | YES | Fakturownia invoice id for delegated invoices |
|
||||
| `external_pdf_url` | VARCHAR(500) | YES | Fakturownia PDF URL for delegated invoices |
|
||||
| `kind` | VARCHAR(32) | NO | DEFAULT `vat` |
|
||||
| `created_by` | INT UNSIGNED | YES | |
|
||||
| `created_at` | DATETIME | NO | |
|
||||
|
||||
**invoice_number_counters** - Sequential numbering per config/period
|
||||
| Column | Type | Nullable | Notes |
|
||||
|--------|------|----------|-------|
|
||||
| `id` | INT UNSIGNED | NO | PK |
|
||||
| `config_id` | INT UNSIGNED | NO | FK -> invoice_configs(id) CASCADE |
|
||||
| `year` | SMALLINT UNSIGNED | NO | |
|
||||
| `month` | TINYINT UNSIGNED | YES | NULL for yearly numbering |
|
||||
| `last_number` | INT UNSIGNED | NO | DEFAULT 0 |
|
||||
|
||||
UNIQUE: `(config_id, year, month)`
|
||||
|
||||
---
|
||||
|
||||
## Email
|
||||
|
||||
**email_mailboxes** — SMTP mailbox configurations
|
||||
|
||||
@@ -1,5 +1,75 @@
|
||||
# Technical Changelog
|
||||
|
||||
## 2026-05-12 - SMSPLANET Inbound Webhook Fix
|
||||
|
||||
**Co zrobiono:**
|
||||
- Poprawiono dopasowanie przychodzacych SMSPLANET po telefonie: `SmsMessageRepository::findLatestOrderIdByPhones()` nie odwoluje sie juz do nieistniejacej w produkcyjnej bazie kolumny `orders.buyer_phone`, tylko do `order_addresses.phone`.
|
||||
- Dodano obsluge `GET /webhooks/smsplanet/inbound` obok POST, bo sekcja odbierania SMS 2WAY w dokumentacji SMSPLANET opisuje przekierowanie na URL bez jednoznacznego kontraktu metody.
|
||||
- `SmsplanetWebhookController` obsluguje format 2WAY `POST application/x-www-form-urlencoded` z parametrem `message=<JSON>`, scala JSON z body z parametrami requestu takze wtedy, gdy URL ma query string, i po sukcesie zwraca plain text `OK`.
|
||||
|
||||
**Dlaczego:**
|
||||
- Publiczny endpoint byl osiagalny jako POST, ale odpowiedz SMS nie mogla zostac zapisana przez blad SQL `Unknown column 'o.buyer_phone'`. GET na ten sam URL zwracal 404.
|
||||
|
||||
**BREAKING / migracja:**
|
||||
- Brak migracji. Zmiana usuwa bledne zalozenie o schemacie produkcyjnej tabeli `orders`.
|
||||
|
||||
## 2026-05-12 - Phase 122 Plan 01: SMSPLANET Default SMS Footer
|
||||
|
||||
**Co zrobiono:**
|
||||
- Dodano migracje `20260512_000111_smsplanet_default_footer.sql` z kolumna `smsplanet_integration_settings.default_footer`.
|
||||
- Rozszerzono konfiguracje SMSPLANET o opcjonalna stopke SMS z limitem 300 znakow i zapisem oddzielnym od danych autoryzacji/nadawcy.
|
||||
- Testowa wysylka SMSPLANET oraz wysylka SMS z zamowienia dopinaja stopke przez pusta linie, waliduja finalna tresc w limicie 918 znakow i nie wywoluja API przy przekroczeniu limitu.
|
||||
- Historia `sms_messages.body` zapisuje finalna tresc wyslana do SMSPLANET, czyli razem ze stopka, gdy jest skonfigurowana.
|
||||
- Widok rozmowy SMS w zamowieniu pokazuje kompaktowa informacje, ze stopka zostanie dodana automatycznie.
|
||||
|
||||
**Dlaczego:**
|
||||
- Operator ma utrzymywac jeden wspolny podpis firmy bez recznego kopiowania go do kazdej wiadomosci SMS.
|
||||
|
||||
**BREAKING / migracja:**
|
||||
- Brak. Pusta stopka zachowuje dotychczasowe tresci SMS bez zmian.
|
||||
|
||||
## 2026-05-12 - Phase 121 Plan 01: SMSPLANET Conversation + Notifications
|
||||
|
||||
**Co zrobiono:**
|
||||
- Dodano migracje `20260512_000110_smsplanet_conversation_notifications.sql` z tabelami `sms_messages`, `notifications` oraz polami `sender_mode` i `sender_phone` w `smsplanet_integration_settings`.
|
||||
- Rozszerzono SMSPLANET o wybor nadawcy: nadpis albo numer 2WAY, bez tymczasowego override testowego numeru.
|
||||
- Dodano publiczny webhook `/webhooks/smsplanet/inbound`, zapis przychodzacych SMS, dopasowanie do ostatniego zamowienia po telefonie i tworzenie globalnego powiadomienia.
|
||||
- Dodano zakladke SMS w szczegolach zamowienia z historia rozmowy i formularzem wysylki.
|
||||
- Dodano centrum powiadomien `/notifications`, API pollingu `/api/notifications/unread`, badge w topbarze i progresywne powiadomienia przegladarki.
|
||||
- Poprawiono migracje po pierwszej probie na bazie: rzeczywiste `orders.id` ma typ `BIGINT UNSIGNED`, wiec `sms_messages.order_id` i `notifications.related_order_id` tez musza miec `BIGINT UNSIGNED`.
|
||||
|
||||
**Dlaczego:**
|
||||
- Operator ma prowadzic dwukierunkowa rozmowe SMSPLANET bez opuszczania zamowienia, a nowe odpowiedzi klientow maja byc widoczne globalnie.
|
||||
|
||||
**BREAKING / migracja:**
|
||||
- Brak. Webhook SMSPLANET w tej fazie celowo nie weryfikuje podpisu.
|
||||
|
||||
## 2026-05-12 - SMSPLANET Test Sender Override
|
||||
|
||||
**Co zrobiono:**
|
||||
- Tymczasowo ustawiono testowa wysylke SMSPLANET na `from=48532963363` w `SmsplanetIntegrationController::test()`.
|
||||
- Zapis konfiguracji SMSPLANET pozostaje bez zmian; override dotyczy tylko endpointu `/settings/integrations/smsplanet/test`.
|
||||
|
||||
**Dlaczego:**
|
||||
- Operator sprawdza odbior odpowiedzi SMS z numeru 2WAY zamiast tekstowego nadpisu.
|
||||
|
||||
## 2026-05-12 - Phase 118 Plan 01: Fakturownia Single Instance
|
||||
|
||||
**Co zrobiono:**
|
||||
- Dodano migracje `20260512_000109_fakturownia_single_instance.sql`, ktora wybiera aktywna instancje Fakturowni, przepina delegowane `invoice_configs.integration_id` na jeden globalny rekord i usuwa nadmiarowe konta Fakturowni po przepieciu zaleznosci.
|
||||
- Przebudowano `FakturowniaIntegrationRepository` na model jednej globalnej konfiguracji (`getSettings()`, `saveSettings()`, `getIntegrationId()`, `getCredentials()`), z kompatybilnym `findAll()` zwracajacym jeden element.
|
||||
- Uproszczono `FakturowniaIntegrationController` i widok `/settings/integrations/fakturownia` do pojedynczego formularza konfiguracji i testu polaczenia.
|
||||
- Hub integracji pokazuje Fakturownie jako jedna instancje, bez licznika kont.
|
||||
- Zapis delegowanej konfiguracji faktury ustawia `invoice_configs.integration_id` na globalny rekord Fakturowni; UI konfiguracji faktury nie pokazuje juz selecta kont.
|
||||
- Zaktualizowano `DOCS/DB_SCHEMA.md` i `DOCS/ARCHITECTURE.md` o kontrakt pojedynczej Fakturowni.
|
||||
|
||||
**Dlaczego:**
|
||||
- Operator chce obslugiwac Fakturownie tak jak HostedSMS/SMSPLANET: jedna konfiguracja globalna zamiast wielu instancji.
|
||||
- Zachowanie `invoice_configs.integration_id` ogranicza ryzyko regresji w `InvoiceService` i historii faktur, a jednoczesnie usuwa wieloinstancyjny wybor z UI.
|
||||
|
||||
**BREAKING / migracja:**
|
||||
- Po migracji nie ma juz wielu kont Fakturowni w UI. Jesli baza miala wiele rekordow `integrations.type='fakturownia'`, zachowany zostaje aktywny rekord (fallback: uzywany przez konfiguracje faktur, potem najnizsze id), a pozostale sa usuwane.
|
||||
|
||||
## 2026-05-12 - Phase 117 Plan 01: SMSPLANET Integration Settings + Test SMS
|
||||
|
||||
**Co zrobiono:**
|
||||
|
||||
@@ -0,0 +1,76 @@
|
||||
SET @smsplanet_sender_mode_exists = (
|
||||
SELECT COUNT(*)
|
||||
FROM information_schema.COLUMNS
|
||||
WHERE TABLE_SCHEMA = DATABASE()
|
||||
AND TABLE_NAME = 'smsplanet_integration_settings'
|
||||
AND COLUMN_NAME = 'sender_mode'
|
||||
);
|
||||
SET @sql = IF(@smsplanet_sender_mode_exists = 0,
|
||||
'ALTER TABLE smsplanet_integration_settings ADD COLUMN sender_mode VARCHAR(16) NOT NULL DEFAULT ''text'' AFTER sender',
|
||||
'SELECT ''sender_mode already exists''');
|
||||
PREPARE stmt FROM @sql;
|
||||
EXECUTE stmt;
|
||||
DEALLOCATE PREPARE stmt;
|
||||
|
||||
SET @smsplanet_sender_phone_exists = (
|
||||
SELECT COUNT(*)
|
||||
FROM information_schema.COLUMNS
|
||||
WHERE TABLE_SCHEMA = DATABASE()
|
||||
AND TABLE_NAME = 'smsplanet_integration_settings'
|
||||
AND COLUMN_NAME = 'sender_phone'
|
||||
);
|
||||
SET @sql = IF(@smsplanet_sender_phone_exists = 0,
|
||||
'ALTER TABLE smsplanet_integration_settings ADD COLUMN sender_phone VARCHAR(32) NULL AFTER sender_mode',
|
||||
'SELECT ''sender_phone already exists''');
|
||||
PREPARE stmt FROM @sql;
|
||||
EXECUTE stmt;
|
||||
DEALLOCATE PREPARE stmt;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS sms_messages (
|
||||
id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
|
||||
direction VARCHAR(16) NOT NULL,
|
||||
provider VARCHAR(32) NOT NULL DEFAULT 'smsplanet',
|
||||
order_id BIGINT UNSIGNED NULL,
|
||||
from_phone VARCHAR(64) NOT NULL,
|
||||
from_phone_normalized VARCHAR(32) NOT NULL,
|
||||
to_phone VARCHAR(64) NOT NULL,
|
||||
to_phone_normalized VARCHAR(32) NOT NULL,
|
||||
body TEXT NOT NULL,
|
||||
message_id VARCHAR(128) NULL,
|
||||
status VARCHAR(32) NOT NULL DEFAULT 'received',
|
||||
raw_payload_json JSON NULL,
|
||||
created_by INT UNSIGNED NULL,
|
||||
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||
KEY sms_messages_order_created_idx (order_id, created_at),
|
||||
KEY sms_messages_from_normalized_idx (from_phone_normalized),
|
||||
KEY sms_messages_to_normalized_idx (to_phone_normalized),
|
||||
KEY sms_messages_provider_message_idx (provider, message_id),
|
||||
CONSTRAINT sms_messages_order_fk
|
||||
FOREIGN KEY (order_id) REFERENCES orders(id)
|
||||
ON DELETE SET NULL ON UPDATE CASCADE,
|
||||
CONSTRAINT sms_messages_user_fk
|
||||
FOREIGN KEY (created_by) REFERENCES users(id)
|
||||
ON DELETE SET NULL ON UPDATE CASCADE
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS notifications (
|
||||
id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
|
||||
type VARCHAR(64) NOT NULL,
|
||||
title VARCHAR(190) NOT NULL,
|
||||
body VARCHAR(500) NOT NULL,
|
||||
target_url VARCHAR(500) NULL,
|
||||
related_order_id BIGINT UNSIGNED NULL,
|
||||
related_sms_message_id BIGINT UNSIGNED NULL,
|
||||
read_at DATETIME NULL,
|
||||
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
KEY notifications_unread_created_idx (read_at, created_at),
|
||||
KEY notifications_order_idx (related_order_id),
|
||||
KEY notifications_sms_message_idx (related_sms_message_id),
|
||||
CONSTRAINT notifications_order_fk
|
||||
FOREIGN KEY (related_order_id) REFERENCES orders(id)
|
||||
ON DELETE SET NULL ON UPDATE CASCADE,
|
||||
CONSTRAINT notifications_sms_message_fk
|
||||
FOREIGN KEY (related_sms_message_id) REFERENCES sms_messages(id)
|
||||
ON DELETE SET NULL ON UPDATE CASCADE
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||
@@ -0,0 +1,13 @@
|
||||
SET @smsplanet_default_footer_exists = (
|
||||
SELECT COUNT(*)
|
||||
FROM information_schema.COLUMNS
|
||||
WHERE TABLE_SCHEMA = DATABASE()
|
||||
AND TABLE_NAME = 'smsplanet_integration_settings'
|
||||
AND COLUMN_NAME = 'default_footer'
|
||||
);
|
||||
SET @sql = IF(@smsplanet_default_footer_exists = 0,
|
||||
'ALTER TABLE smsplanet_integration_settings ADD COLUMN default_footer TEXT NULL AFTER transactional',
|
||||
'SELECT ''default_footer already exists''');
|
||||
PREPARE stmt FROM @sql;
|
||||
EXECUTE stmt;
|
||||
DEALLOCATE PREPARE stmt;
|
||||
File diff suppressed because one or more lines are too long
81
public/assets/js/modules/notifications.js
Normal file
81
public/assets/js/modules/notifications.js
Normal file
@@ -0,0 +1,81 @@
|
||||
(function () {
|
||||
var badge = document.getElementById('js-notification-badge');
|
||||
var button = document.getElementById('js-notification-button');
|
||||
if (!badge || !button || typeof fetch !== 'function') return;
|
||||
|
||||
var seenKey = 'orderproSeenNotificationIds';
|
||||
var seenIds = loadSeenIds();
|
||||
var permissionAsked = false;
|
||||
|
||||
function loadSeenIds() {
|
||||
try {
|
||||
return JSON.parse(localStorage.getItem(seenKey) || '[]').map(String);
|
||||
} catch (e) {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
function saveSeenIds() {
|
||||
try {
|
||||
localStorage.setItem(seenKey, JSON.stringify(seenIds.slice(-100)));
|
||||
} catch (e) {}
|
||||
}
|
||||
|
||||
function updateBadge(count) {
|
||||
var value = Math.max(0, Number(count) || 0);
|
||||
badge.textContent = value > 99 ? '99+' : String(value);
|
||||
badge.hidden = value === 0;
|
||||
}
|
||||
|
||||
function requestPermission() {
|
||||
if (permissionAsked || !('Notification' in window) || Notification.permission !== 'default') {
|
||||
return Promise.resolve();
|
||||
}
|
||||
permissionAsked = true;
|
||||
return Notification.requestPermission().catch(function () {});
|
||||
}
|
||||
|
||||
function showBrowserNotification(item) {
|
||||
var id = String(item.id || '');
|
||||
if (id === '' || seenIds.indexOf(id) !== -1) return;
|
||||
|
||||
seenIds.push(id);
|
||||
saveSeenIds();
|
||||
if (!('Notification' in window) || Notification.permission !== 'granted') return;
|
||||
|
||||
var nativeNotification = new Notification(item.title || 'orderPRO', {
|
||||
body: item.body || '',
|
||||
tag: 'orderpro-notification-' + id
|
||||
});
|
||||
nativeNotification.onclick = function () {
|
||||
window.focus();
|
||||
if (item.target_url) {
|
||||
window.location.href = item.target_url;
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
function poll() {
|
||||
fetch('/api/notifications/unread', { credentials: 'same-origin' })
|
||||
.then(function (response) { return response.ok ? response.json() : null; })
|
||||
.then(function (data) {
|
||||
if (!data || !data.ok) return;
|
||||
updateBadge(data.count);
|
||||
if (Array.isArray(data.items)) {
|
||||
data.items.slice().reverse().forEach(showBrowserNotification);
|
||||
}
|
||||
})
|
||||
.catch(function () {});
|
||||
}
|
||||
|
||||
button.addEventListener('click', function (event) {
|
||||
if ('Notification' in window && Notification.permission === 'default') {
|
||||
event.preventDefault();
|
||||
requestPermission().finally(function () {
|
||||
window.location.href = button.href;
|
||||
});
|
||||
}
|
||||
});
|
||||
poll();
|
||||
window.setInterval(poll, 30000);
|
||||
})();
|
||||
@@ -41,6 +41,16 @@ return [
|
||||
'accounting_section' => 'Ksiegowosc',
|
||||
'project_mapping' => 'Mapowanie projektow',
|
||||
],
|
||||
'notifications' => [
|
||||
'title' => 'Powiadomienia',
|
||||
'description' => 'Historia zdarzen wymagajacych uwagi operatora.',
|
||||
'empty' => 'Brak powiadomien.',
|
||||
'actions' => [
|
||||
'open' => 'Otworz',
|
||||
'mark_read' => 'Oznacz jako przeczytane',
|
||||
'mark_all_read' => 'Oznacz wszystkie jako przeczytane',
|
||||
],
|
||||
],
|
||||
'marketplace' => [
|
||||
'title' => 'Marketplace',
|
||||
'description' => 'Aktywne integracje i powiazane oferty marketplace.',
|
||||
@@ -155,6 +165,17 @@ return [
|
||||
'shipments' => 'Przesylki',
|
||||
'payments' => 'Platnosci',
|
||||
'documents' => 'Dokumenty powiazane',
|
||||
'sms' => 'SMS',
|
||||
],
|
||||
'sms' => [
|
||||
'title' => 'Rozmowa SMS',
|
||||
'empty' => 'Brak wiadomosci SMS dla tego zamowienia.',
|
||||
'inbound' => 'Klient',
|
||||
'outbound' => 'Operator',
|
||||
'phone' => 'Numer klienta',
|
||||
'message' => 'Tresc SMS',
|
||||
'footer_note' => 'Skonfigurowana stopka SMSPLANET zostanie dodana automatycznie.',
|
||||
'send' => 'Wyslij SMS',
|
||||
],
|
||||
'items_title' => 'Pozycje',
|
||||
'item_name' => 'Nazwa',
|
||||
@@ -783,6 +804,9 @@ return [
|
||||
'api_key' => 'Klucz API',
|
||||
'api_password' => 'Haslo API',
|
||||
'sender' => 'Pole nadawcy / from',
|
||||
'sender_mode' => 'Tryb nadawcy',
|
||||
'sender_phone' => 'Numer 2WAY',
|
||||
'default_footer' => 'Domyslna stopka SMS',
|
||||
'options' => 'Opcje wysylki',
|
||||
'clear_polish' => 'Zamien polskie znaki na odpowiedniki GSM',
|
||||
'transactional' => 'Wysylka kanalem transakcyjnym',
|
||||
@@ -802,9 +826,17 @@ return [
|
||||
'saved' => 'Haslo API jest zapisane. Pozostaw pole puste, aby nie zmieniac.',
|
||||
'missing' => 'Brak zapisanego hasla API.',
|
||||
],
|
||||
'sender_modes' => [
|
||||
'text' => 'Nadpis',
|
||||
'phone' => 'Numer 2WAY',
|
||||
],
|
||||
'hints' => [
|
||||
'auth_method' => 'SMSPLANET zaleca token Bearer, ale API obsluguje tez klucz i haslo.',
|
||||
'sender' => 'Pole nadawcy musi byc dostepne na koncie SMSPLANET albo miec wartosc testowa dopuszczona przez provider.',
|
||||
'sender_mode' => 'Tryb decyduje, ktora wartosc trafi do pola from w SMSPLANET.',
|
||||
'sender_phone' => 'Numer 2WAY uzywany do dwukierunkowej rozmowy SMS.',
|
||||
'default_footer' => 'Opcjonalna stopka dopisywana do testowych SMS i SMS z rozmowy w zamowieniu. Limit: 300 znakow.',
|
||||
'test_footer' => 'Do testowego SMS zostanie dodana zapisana stopka.',
|
||||
],
|
||||
'status' => [
|
||||
'secret' => 'Sekret API',
|
||||
|
||||
@@ -2126,6 +2126,137 @@ details[open] > .order-statuses-side__title .order-statuses-side__arrow {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.order-sms-head,
|
||||
.notifications-page__head {
|
||||
align-items: center;
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.order-sms-thread {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.order-sms-bubble {
|
||||
background: #f8fafc;
|
||||
border: 1px solid #e2e8f0;
|
||||
border-radius: 8px;
|
||||
max-width: 76%;
|
||||
padding: 8px 10px;
|
||||
}
|
||||
|
||||
.order-sms-bubble--outbound {
|
||||
align-self: flex-end;
|
||||
background: #ecfdf5;
|
||||
border-color: #bbf7d0;
|
||||
}
|
||||
|
||||
.order-sms-bubble--inbound {
|
||||
align-self: flex-start;
|
||||
}
|
||||
|
||||
.order-sms-bubble__meta {
|
||||
color: #64748b;
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
font-size: 12px;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.order-sms-bubble__body {
|
||||
color: #0f172a;
|
||||
font-size: 14px;
|
||||
line-height: 1.35;
|
||||
margin: 4px 0;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.order-sms-form {
|
||||
display: grid;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.topbar-notifications {
|
||||
align-items: center;
|
||||
border: 1px solid #dbe4ef;
|
||||
border-radius: 8px;
|
||||
color: #334155;
|
||||
display: inline-flex;
|
||||
height: 34px;
|
||||
justify-content: center;
|
||||
position: relative;
|
||||
width: 38px;
|
||||
}
|
||||
|
||||
.topbar-notifications:hover {
|
||||
background: #f8fafc;
|
||||
color: #0f172a;
|
||||
}
|
||||
|
||||
.topbar-notifications__badge {
|
||||
align-items: center;
|
||||
background: #dc2626;
|
||||
border-radius: 999px;
|
||||
color: #fff;
|
||||
display: inline-flex;
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
height: 18px;
|
||||
justify-content: center;
|
||||
min-width: 18px;
|
||||
padding: 0 5px;
|
||||
position: absolute;
|
||||
right: -6px;
|
||||
top: -6px;
|
||||
}
|
||||
|
||||
.notifications-list {
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.notification-row {
|
||||
align-items: center;
|
||||
border: 1px solid #e2e8f0;
|
||||
border-radius: 8px;
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
justify-content: space-between;
|
||||
padding: 10px 12px;
|
||||
}
|
||||
|
||||
.notification-row--unread {
|
||||
background: #f8fafc;
|
||||
border-color: #93c5fd;
|
||||
}
|
||||
|
||||
.notification-row__title {
|
||||
color: #0f172a;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.notification-row__body {
|
||||
color: #334155;
|
||||
margin-top: 3px;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.notification-row__meta {
|
||||
color: #64748b;
|
||||
font-size: 12px;
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
.notification-row__actions {
|
||||
align-items: center;
|
||||
display: flex;
|
||||
flex-shrink: 0;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.manual-tracking-form {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
@@ -2753,6 +2884,26 @@ details[open] > .order-statuses-side__title .order-statuses-side__arrow {
|
||||
accent-color: var(--c-action-primary);
|
||||
}
|
||||
|
||||
.smsplanet-sender-phone-field {
|
||||
grid-column: 2;
|
||||
}
|
||||
|
||||
.smsplanet-default-footer-field {
|
||||
grid-column: 1 / -1;
|
||||
}
|
||||
|
||||
.smsplanet-default-footer-field textarea {
|
||||
min-height: 78px;
|
||||
resize: vertical;
|
||||
}
|
||||
|
||||
.order-sms-footer-note {
|
||||
color: #64748b;
|
||||
display: block;
|
||||
font-size: 12px;
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
// Hamburger button (hidden on desktop)
|
||||
.topbar__hamburger {
|
||||
display: none;
|
||||
@@ -2934,6 +3085,14 @@ body.no-scroll {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.smsplanet-sender-phone-field {
|
||||
grid-column: 1;
|
||||
}
|
||||
|
||||
.smsplanet-default-footer-field {
|
||||
grid-column: 1;
|
||||
}
|
||||
|
||||
.card {
|
||||
padding: 12px;
|
||||
}
|
||||
|
||||
@@ -177,6 +177,13 @@
|
||||
<div>
|
||||
<strong><?= $e((string) (($user['name'] ?? '') !== '' ? $user['name'] : ($user['email'] ?? ''))) ?></strong>
|
||||
</div>
|
||||
<a class="topbar-notifications" href="/notifications" id="js-notification-button" title="<?= $e($t('notifications.title')) ?>" aria-label="<?= $e($t('notifications.title')) ?>">
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.9" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
|
||||
<path d="M18 8a6 6 0 10-12 0c0 7-3 7-3 9h18c0-2-3-2-3-9"/>
|
||||
<path d="M13.73 21a2 2 0 01-3.46 0"/>
|
||||
</svg>
|
||||
<span class="topbar-notifications__badge" id="js-notification-badge" hidden>0</span>
|
||||
</a>
|
||||
<form action="/logout" method="post">
|
||||
<input type="hidden" name="_token" value="<?= $e($csrfToken ?? '') ?>">
|
||||
<button type="submit" class="btn btn--secondary"><?= $e($t('actions.logout')) ?></button>
|
||||
@@ -206,6 +213,7 @@
|
||||
<script src="/assets/js/modules/invoice-requested-toggle.js?ver=<?= filemtime(dirname(__DIR__, 3) . '/public/assets/js/modules/invoice-requested-toggle.js') ?: 0 ?>"></script>
|
||||
<script src="/assets/js/modules/confirm-delete.js?ver=<?= filemtime(dirname(__DIR__, 3) . '/public/assets/js/modules/confirm-delete.js') ?: 0 ?>"></script>
|
||||
<script src="/assets/js/modules/alert-dismiss.js?ver=<?= filemtime(dirname(__DIR__, 3) . '/public/assets/js/modules/alert-dismiss.js') ?: 0 ?>"></script>
|
||||
<script src="/assets/js/modules/notifications.js?ver=<?= filemtime(dirname(__DIR__, 3) . '/public/assets/js/modules/notifications.js') ?: 0 ?>"></script>
|
||||
<script src="https://cdn.jsdelivr.net/npm/chart.js@4.4.8/dist/chart.umd.min.js"></script>
|
||||
<script src="/assets/js/modules/statistics-summary-charts.js?ver=<?= filemtime(dirname(__DIR__, 3) . '/public/assets/js/modules/statistics-summary-charts.js') ?: 0 ?>"></script>
|
||||
<script>
|
||||
|
||||
67
resources/views/notifications/index.php
Normal file
67
resources/views/notifications/index.php
Normal file
@@ -0,0 +1,67 @@
|
||||
<?php
|
||||
$items = is_array($notifications ?? null) ? $notifications : [];
|
||||
$pageData = is_array($pagination ?? null) ? $pagination : ['page' => 1, 'per_page' => 30, 'total' => 0];
|
||||
$page = max(1, (int) ($pageData['page'] ?? 1));
|
||||
$perPage = max(1, (int) ($pageData['per_page'] ?? 30));
|
||||
$total = max(0, (int) ($pageData['total'] ?? 0));
|
||||
$totalPages = max(1, (int) ceil($total / $perPage));
|
||||
?>
|
||||
|
||||
<section class="card notifications-page">
|
||||
<div class="notifications-page__head">
|
||||
<div>
|
||||
<h2 class="section-title"><?= $e($t('notifications.title')) ?></h2>
|
||||
<p class="muted mt-8"><?= $e($t('notifications.description')) ?></p>
|
||||
</div>
|
||||
<?php if ((int) ($unreadCount ?? 0) > 0): ?>
|
||||
<form method="post" action="/notifications/mark-read">
|
||||
<input type="hidden" name="_token" value="<?= $e((string) ($csrfToken ?? '')) ?>">
|
||||
<button class="btn btn--secondary btn--sm" type="submit"><?= $e($t('notifications.actions.mark_all_read')) ?></button>
|
||||
</form>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
|
||||
<div class="notifications-list mt-16">
|
||||
<?php if ($items === []): ?>
|
||||
<div class="muted"><?= $e($t('notifications.empty')) ?></div>
|
||||
<?php endif; ?>
|
||||
<?php foreach ($items as $notification): ?>
|
||||
<?php
|
||||
$id = (int) ($notification['id'] ?? 0);
|
||||
$targetUrl = trim((string) ($notification['target_url'] ?? ''));
|
||||
$isUnread = trim((string) ($notification['read_at'] ?? '')) === '';
|
||||
?>
|
||||
<article class="notification-row<?= $isUnread ? ' notification-row--unread' : '' ?>">
|
||||
<div class="notification-row__main">
|
||||
<div class="notification-row__title"><?= $e((string) ($notification['title'] ?? '')) ?></div>
|
||||
<div class="notification-row__body"><?= $e((string) ($notification['body'] ?? '')) ?></div>
|
||||
<div class="notification-row__meta"><?= $e((string) ($notification['created_at'] ?? '')) ?></div>
|
||||
</div>
|
||||
<div class="notification-row__actions">
|
||||
<?php if ($targetUrl !== ''): ?>
|
||||
<a class="btn btn--secondary btn--sm" href="<?= $e($targetUrl) ?>"><?= $e($t('notifications.actions.open')) ?></a>
|
||||
<?php endif; ?>
|
||||
<?php if ($isUnread): ?>
|
||||
<form method="post" action="/notifications/mark-read">
|
||||
<input type="hidden" name="_token" value="<?= $e((string) ($csrfToken ?? '')) ?>">
|
||||
<input type="hidden" name="id" value="<?= $e((string) $id) ?>">
|
||||
<button class="btn btn--secondary btn--sm" type="submit"><?= $e($t('notifications.actions.mark_read')) ?></button>
|
||||
</form>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
</article>
|
||||
<?php endforeach; ?>
|
||||
</div>
|
||||
|
||||
<?php if ($totalPages > 1): ?>
|
||||
<div class="pagination mt-16">
|
||||
<?php if ($page > 1): ?>
|
||||
<a class="btn btn--secondary btn--sm" href="/notifications?page=<?= $page - 1 ?>">←</a>
|
||||
<?php endif; ?>
|
||||
<span class="muted"><?= $e((string) $page) ?>/<?= $e((string) $totalPages) ?></span>
|
||||
<?php if ($page < $totalPages): ?>
|
||||
<a class="btn btn--secondary btn--sm" href="/notifications?page=<?= $page + 1 ?>">→</a>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
</section>
|
||||
@@ -14,6 +14,9 @@ $invoiceConfigsList = is_array($invoiceConfigs ?? null) ? $invoiceConfigs : [];
|
||||
$invoiceRequestedFlag = (int) ($orderRow['invoice_requested'] ?? 0) === 1;
|
||||
$emailTemplatesList = is_array($emailTemplates ?? null) ? $emailTemplates : [];
|
||||
$emailMailboxesList = is_array($emailMailboxes ?? null) ? $emailMailboxes : [];
|
||||
$smsMessagesList = is_array($smsMessages ?? null) ? $smsMessages : [];
|
||||
$smsPhoneValue = trim((string) ($smsPhone ?? ''));
|
||||
$smsDefaultFooterConfigured = (bool) ($smsDefaultFooterConfigured ?? false);
|
||||
$historyList = is_array($history ?? null) ? $history : [];
|
||||
$activityLogList = is_array($activityLog ?? null) ? $activityLog : [];
|
||||
$statusPanelList = is_array($statusPanel ?? null) ? $statusPanel : [];
|
||||
@@ -181,6 +184,7 @@ foreach ($addressesList as $address) {
|
||||
<button type="button" class="order-details-tab" data-order-tab-target="shipments"><?= $e($t('orders.details.tabs.shipments')) ?> (<?= $e((string) (count($shipmentsList) + count($packagesList))) ?>)</button>
|
||||
<button type="button" class="order-details-tab" data-order-tab-target="payments"><?= $e($t('orders.details.tabs.payments')) ?> (<?= $e((string) count($paymentsList)) ?>)</button>
|
||||
<button type="button" class="order-details-tab" data-order-tab-target="documents"><?= $e($t('orders.details.tabs.documents')) ?> (<?= $e((string) (count($documentsList) + count($receiptsList))) ?>)</button>
|
||||
<button type="button" class="order-details-tab" data-order-tab-target="sms"><?= $e($t('orders.details.tabs.sms')) ?> (<?= $e((string) count($smsMessagesList)) ?>)</button>
|
||||
</section>
|
||||
|
||||
<div class="order-tab-panel is-active" data-order-tab-panel="details">
|
||||
@@ -973,6 +977,62 @@ foreach ($addressesList as $address) {
|
||||
<?php endif; ?>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<div class="order-tab-panel" data-order-tab-panel="sms">
|
||||
<section class="card mt-16">
|
||||
<div class="order-sms-head">
|
||||
<h3 class="section-title"><?= $e($t('orders.details.sms.title')) ?></h3>
|
||||
<?php if ($smsPhoneValue !== ''): ?>
|
||||
<span class="badge badge--muted"><?= $e($smsPhoneValue) ?></span>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
|
||||
<div class="order-sms-thread mt-12">
|
||||
<?php if ($smsMessagesList === []): ?>
|
||||
<div class="muted"><?= $e($t('orders.details.sms.empty')) ?></div>
|
||||
<?php endif; ?>
|
||||
<?php foreach ($smsMessagesList as $smsMessage): ?>
|
||||
<?php
|
||||
$direction = (string) ($smsMessage['direction'] ?? '');
|
||||
$isOutbound = $direction === 'outbound';
|
||||
$messageId = trim((string) ($smsMessage['message_id'] ?? ''));
|
||||
$status = trim((string) ($smsMessage['status'] ?? ''));
|
||||
?>
|
||||
<article class="order-sms-bubble<?= $isOutbound ? ' order-sms-bubble--outbound' : ' order-sms-bubble--inbound' ?>">
|
||||
<div class="order-sms-bubble__meta">
|
||||
<span><?= $e($isOutbound ? $t('orders.details.sms.outbound') : $t('orders.details.sms.inbound')) ?></span>
|
||||
<span><?= $e((string) ($smsMessage['created_at'] ?? '')) ?></span>
|
||||
</div>
|
||||
<div class="order-sms-bubble__body"><?= nl2br($e((string) ($smsMessage['body'] ?? ''))) ?></div>
|
||||
<?php if ($isOutbound && ($status !== '' || $messageId !== '')): ?>
|
||||
<div class="order-sms-bubble__meta">
|
||||
<?php if ($status !== ''): ?><span><?= $e($status) ?></span><?php endif; ?>
|
||||
<?php if ($messageId !== ''): ?><span>ID: <?= $e($messageId) ?></span><?php endif; ?>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
</article>
|
||||
<?php endforeach; ?>
|
||||
</div>
|
||||
|
||||
<form class="order-sms-form mt-16" method="post" action="/orders/<?= $e((string) ($orderId ?? 0)) ?>/sms/send">
|
||||
<input type="hidden" name="_token" value="<?= $e((string) ($csrfToken ?? '')) ?>">
|
||||
<label class="form-field">
|
||||
<span class="field-label"><?= $e($t('orders.details.sms.phone')) ?></span>
|
||||
<input class="form-control" type="tel" name="phone" inputmode="tel" value="<?= $e($smsPhoneValue) ?>" required>
|
||||
</label>
|
||||
<label class="form-field">
|
||||
<span class="field-label"><?= $e($t('orders.details.sms.message')) ?></span>
|
||||
<textarea class="form-control" name="message" rows="3" maxlength="918" required></textarea>
|
||||
<?php if ($smsDefaultFooterConfigured): ?>
|
||||
<span class="order-sms-footer-note"><?= $e($t('orders.details.sms.footer_note')) ?></span>
|
||||
<?php endif; ?>
|
||||
</label>
|
||||
<div class="form-actions">
|
||||
<button type="submit" class="btn btn--primary"><?= $e($t('orders.details.sms.send')) ?></button>
|
||||
</div>
|
||||
</form>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
@@ -1002,7 +1062,9 @@ foreach ($addressesList as $address) {
|
||||
});
|
||||
});
|
||||
|
||||
var forceTab = <?= json_encode($flashSuccessMsg !== '' && strpos($flashSuccessMsg, 'Przesylka') !== false ? 'shipments' : '') ?>;
|
||||
var queryTab = '';
|
||||
try { queryTab = new URLSearchParams(window.location.search).get('tab') || ''; } catch (e) {}
|
||||
var forceTab = queryTab || <?= json_encode($flashSuccessMsg !== '' && strpos($flashSuccessMsg, 'Przesylka') !== false ? 'shipments' : '') ?>;
|
||||
var savedTab = null;
|
||||
try { savedTab = localStorage.getItem(storageKey); } catch (e) {}
|
||||
setActiveTab(forceTab || savedTab || 'details');
|
||||
|
||||
@@ -2,6 +2,9 @@
|
||||
$settings = is_array($settings ?? null) ? $settings : [];
|
||||
$authMethod = (string) ($settings['auth_method'] ?? 'token');
|
||||
$sender = trim((string) ($settings['sender'] ?? ''));
|
||||
$senderMode = (string) ($settings['sender_mode'] ?? 'text');
|
||||
$senderPhone = trim((string) ($settings['sender_phone'] ?? ''));
|
||||
$defaultFooter = trim((string) ($settings['default_footer'] ?? ''));
|
||||
$hasApiToken = (bool) ($settings['has_api_token'] ?? false);
|
||||
$hasApiKey = (bool) ($settings['has_api_key'] ?? false);
|
||||
$hasApiPassword = (bool) ($settings['has_api_password'] ?? false);
|
||||
@@ -88,6 +91,33 @@ if (str_starts_with($lastTestMessage, 'messageId:')) {
|
||||
<span class="muted"><?= $e($t('settings.smsplanet.hints.sender')) ?></span>
|
||||
</label>
|
||||
|
||||
<fieldset class="integration-settings-checkboxes">
|
||||
<legend class="field-label"><?= $e($t('settings.smsplanet.fields.sender_mode')) ?></legend>
|
||||
<div class="integration-settings-checkboxes__list">
|
||||
<label class="integration-settings-checkboxes__item">
|
||||
<input type="radio" name="sender_mode" value="text"<?= $senderMode !== 'phone' ? ' checked' : '' ?>>
|
||||
<span><?= $e($t('settings.smsplanet.sender_modes.text')) ?></span>
|
||||
</label>
|
||||
<label class="integration-settings-checkboxes__item">
|
||||
<input type="radio" name="sender_mode" value="phone"<?= $senderMode === 'phone' ? ' checked' : '' ?>>
|
||||
<span><?= $e($t('settings.smsplanet.sender_modes.phone')) ?></span>
|
||||
</label>
|
||||
</div>
|
||||
<span class="muted"><?= $e($t('settings.smsplanet.hints.sender_mode')) ?></span>
|
||||
</fieldset>
|
||||
|
||||
<label class="form-field smsplanet-sender-phone-field">
|
||||
<span class="field-label"><?= $e($t('settings.smsplanet.fields.sender_phone')) ?></span>
|
||||
<input class="form-control" type="tel" name="sender_phone" inputmode="tel" maxlength="32" value="<?= $e($senderPhone) ?>" placeholder="48600111222">
|
||||
<span class="muted"><?= $e($t('settings.smsplanet.hints.sender_phone')) ?></span>
|
||||
</label>
|
||||
|
||||
<label class="form-field smsplanet-default-footer-field">
|
||||
<span class="field-label"><?= $e($t('settings.smsplanet.fields.default_footer')) ?></span>
|
||||
<textarea class="form-control" name="default_footer" rows="3" maxlength="300"><?= $e($defaultFooter) ?></textarea>
|
||||
<span class="muted"><?= $e($t('settings.smsplanet.hints.default_footer')) ?></span>
|
||||
</label>
|
||||
|
||||
<fieldset class="integration-settings-checkboxes">
|
||||
<legend class="field-label"><?= $e($t('settings.smsplanet.fields.options')) ?></legend>
|
||||
<div class="integration-settings-checkboxes__list">
|
||||
@@ -127,6 +157,9 @@ if (str_starts_with($lastTestMessage, 'messageId:')) {
|
||||
<label class="form-field">
|
||||
<span class="field-label"><?= $e($t('settings.smsplanet.fields.test_message')) ?></span>
|
||||
<textarea class="form-control" name="message" rows="4" maxlength="918" required>Test orderPRO SMSPLANET</textarea>
|
||||
<?php if ($defaultFooter !== ''): ?>
|
||||
<span class="muted"><?= $e($t('settings.smsplanet.hints.test_footer')) ?></span>
|
||||
<?php endif; ?>
|
||||
</label>
|
||||
|
||||
<div class="form-actions mt-16">
|
||||
|
||||
@@ -95,6 +95,12 @@ use App\Modules\Settings\PrintSettingsController;
|
||||
use App\Modules\Settings\ProjectMappingController;
|
||||
use App\Modules\Settings\ProjectMappingRepository;
|
||||
use App\Modules\Info\InfoController;
|
||||
use App\Modules\Notifications\NotificationApiController;
|
||||
use App\Modules\Notifications\NotificationController;
|
||||
use App\Modules\Notifications\NotificationRepository;
|
||||
use App\Modules\Sms\SmsConversationService;
|
||||
use App\Modules\Sms\SmsMessageRepository;
|
||||
use App\Modules\Sms\SmsplanetWebhookController;
|
||||
use App\Modules\Users\UsersController;
|
||||
|
||||
return static function (Application $app): void {
|
||||
@@ -220,6 +226,17 @@ return static function (Application $app): void {
|
||||
new SmsplanetApiClient(),
|
||||
new IntegrationsRepository($app->db())
|
||||
);
|
||||
$notificationRepository = new NotificationRepository($app->db());
|
||||
$smsMessageRepository = new SmsMessageRepository($app->db());
|
||||
$smsConversationService = new SmsConversationService(
|
||||
$smsMessageRepository,
|
||||
$smsplanetIntegrationRepository,
|
||||
new SmsplanetApiClient(),
|
||||
$notificationRepository
|
||||
);
|
||||
$smsplanetWebhookController = new SmsplanetWebhookController($smsConversationService);
|
||||
$notificationController = new NotificationController($template, $translator, $auth, $notificationRepository);
|
||||
$notificationApiController = new NotificationApiController($notificationRepository);
|
||||
$integrationsHubController = new IntegrationsHubController(
|
||||
$template,
|
||||
$translator,
|
||||
@@ -271,7 +288,7 @@ return static function (Application $app): void {
|
||||
$auth,
|
||||
$receiptConfigRepository
|
||||
);
|
||||
$invoiceConfigRepository = new InvoiceConfigRepository($app->db());
|
||||
$invoiceConfigRepository = new InvoiceConfigRepository($app->db(), $fakturowniaIntegrationRepository);
|
||||
$invoiceConfigController = new InvoiceConfigController(
|
||||
$template,
|
||||
$translator,
|
||||
@@ -369,7 +386,7 @@ return static function (Application $app): void {
|
||||
$allegroDeliveryMappingController
|
||||
);
|
||||
$printJobRepository = new PrintJobRepository($app->db());
|
||||
$ordersController = new OrdersController($template, $translator, $auth, $app->orders(), $shipmentPackageRepositoryForOrders, $receiptRepository, $receiptConfigRepository, $emailSendingService, $emailTemplateRepository, $emailMailboxRepository, $app->basePath('storage'), $printJobRepository, $shopproIntegrationsRepository, $automationService, $invoiceRepository, $invoiceConfigRepository);
|
||||
$ordersController = new OrdersController($template, $translator, $auth, $app->orders(), $shipmentPackageRepositoryForOrders, $receiptRepository, $receiptConfigRepository, $emailSendingService, $emailTemplateRepository, $emailMailboxRepository, $app->basePath('storage'), $printJobRepository, $shopproIntegrationsRepository, $automationService, $invoiceRepository, $invoiceConfigRepository, $smsMessageRepository, $smsConversationService);
|
||||
$ordersStatisticsController = new OrdersStatisticsController(
|
||||
$template,
|
||||
$translator,
|
||||
@@ -511,6 +528,8 @@ return static function (Application $app): void {
|
||||
]));
|
||||
$router->get('/cron', $publicCronHandler);
|
||||
$router->get('/cron/{tokenValue}', $publicCronHandler);
|
||||
$router->post('/webhooks/smsplanet/inbound', [$smsplanetWebhookController, 'inbound']);
|
||||
$router->get('/webhooks/smsplanet/inbound', [$smsplanetWebhookController, 'inbound']);
|
||||
|
||||
$router->get('/', static function (Request $request) use ($auth): Response {
|
||||
return $auth->check()
|
||||
@@ -527,9 +546,14 @@ return static function (Application $app): void {
|
||||
$router->get('/orders/list', [$ordersController, 'index'], [$authMiddleware]);
|
||||
$router->get('/statistics/summary', [$ordersStatisticsController, 'summary'], [$authMiddleware]);
|
||||
$router->get('/statistics/orders', [$ordersStatisticsController, 'index'], [$authMiddleware]);
|
||||
$router->get('/notifications', [$notificationController, 'index'], [$authMiddleware]);
|
||||
$router->post('/notifications/mark-read', [$notificationController, 'markRead'], [$authMiddleware]);
|
||||
$router->get('/api/notifications/unread', [$notificationApiController, 'unread'], [$authMiddleware]);
|
||||
$router->post('/api/notifications/mark-read', [$notificationApiController, 'markRead'], [$authMiddleware]);
|
||||
$router->get('/orders/{id}', [$ordersController, 'show'], [$authMiddleware]);
|
||||
$router->post('/orders/{id}/status', [$ordersController, 'updateStatus'], [$authMiddleware]);
|
||||
$router->post('/orders/{id}/details/update', [$ordersController, 'updateDetails'], [$authMiddleware]);
|
||||
$router->post('/orders/{id}/sms/send', [$ordersController, 'sendSms'], [$authMiddleware]);
|
||||
$router->post('/orders/{id}/send-email', [$ordersController, 'sendEmail'], [$authMiddleware]);
|
||||
$router->post('/orders/{id}/email-preview', [$ordersController, 'emailPreview'], [$authMiddleware]);
|
||||
$router->get('/api/orders/search', [$ordersController, 'quickSearch'], [$authMiddleware]);
|
||||
@@ -574,7 +598,6 @@ return static function (Application $app): void {
|
||||
$router->get('/settings/integrations/fakturownia/edit', [$fakturowniaIntegrationController, 'edit'], [$authMiddleware]);
|
||||
$router->post('/settings/integrations/fakturownia/save', [$fakturowniaIntegrationController, 'save'], [$authMiddleware]);
|
||||
$router->post('/settings/integrations/fakturownia/test', [$fakturowniaIntegrationController, 'test'], [$authMiddleware]);
|
||||
$router->post('/settings/integrations/fakturownia/delete', [$fakturowniaIntegrationController, 'delete'], [$authMiddleware]);
|
||||
$router->get('/settings/integrations/hostedsms', [$hostedSmsIntegrationController, 'index'], [$authMiddleware]);
|
||||
$router->post('/settings/integrations/hostedsms/save', [$hostedSmsIntegrationController, 'save'], [$authMiddleware]);
|
||||
$router->post('/settings/integrations/hostedsms/test', [$hostedSmsIntegrationController, 'test'], [$authMiddleware]);
|
||||
|
||||
40
src/Modules/Notifications/NotificationApiController.php
Normal file
40
src/Modules/Notifications/NotificationApiController.php
Normal file
@@ -0,0 +1,40 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Modules\Notifications;
|
||||
|
||||
use App\Core\Http\Request;
|
||||
use App\Core\Http\Response;
|
||||
|
||||
final class NotificationApiController
|
||||
{
|
||||
public function __construct(private readonly NotificationRepository $repository)
|
||||
{
|
||||
}
|
||||
|
||||
public function unread(Request $request): Response
|
||||
{
|
||||
return Response::json([
|
||||
'ok' => true,
|
||||
'count' => $this->repository->unreadCount(),
|
||||
'items' => array_map(
|
||||
static fn (array $row): array => [
|
||||
'id' => (int) ($row['id'] ?? 0),
|
||||
'type' => (string) ($row['type'] ?? ''),
|
||||
'title' => (string) ($row['title'] ?? ''),
|
||||
'body' => (string) ($row['body'] ?? ''),
|
||||
'target_url' => (string) ($row['target_url'] ?? ''),
|
||||
'created_at' => (string) ($row['created_at'] ?? ''),
|
||||
],
|
||||
$this->repository->recentUnread(10)
|
||||
),
|
||||
]);
|
||||
}
|
||||
|
||||
public function markRead(Request $request): Response
|
||||
{
|
||||
$this->repository->markRead(max(0, (int) $request->input('id', 0)));
|
||||
|
||||
return Response::json(['ok' => true, 'count' => $this->repository->unreadCount()]);
|
||||
}
|
||||
}
|
||||
58
src/Modules/Notifications/NotificationController.php
Normal file
58
src/Modules/Notifications/NotificationController.php
Normal file
@@ -0,0 +1,58 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Modules\Notifications;
|
||||
|
||||
use App\Core\Http\Request;
|
||||
use App\Core\Http\Response;
|
||||
use App\Core\I18n\Translator;
|
||||
use App\Core\Security\Csrf;
|
||||
use App\Core\Support\Flash;
|
||||
use App\Core\View\Template;
|
||||
use App\Modules\Auth\AuthService;
|
||||
|
||||
final class NotificationController
|
||||
{
|
||||
public function __construct(
|
||||
private readonly Template $template,
|
||||
private readonly Translator $translator,
|
||||
private readonly AuthService $auth,
|
||||
private readonly NotificationRepository $repository
|
||||
) {
|
||||
}
|
||||
|
||||
public function index(Request $request): Response
|
||||
{
|
||||
$page = max(1, (int) $request->input('page', 1));
|
||||
$result = $this->repository->paginate($page, 30);
|
||||
|
||||
$html = $this->template->render('notifications/index', [
|
||||
'title' => $this->translator->get('notifications.title'),
|
||||
'activeMenu' => 'notifications',
|
||||
'user' => $this->auth->user(),
|
||||
'csrfToken' => Csrf::token(),
|
||||
'notifications' => $result['items'],
|
||||
'pagination' => $result,
|
||||
'unreadCount' => $this->repository->unreadCount(),
|
||||
], 'layouts/app');
|
||||
|
||||
return Response::html($html);
|
||||
}
|
||||
|
||||
public function markRead(Request $request): Response
|
||||
{
|
||||
if (!Csrf::validate((string) $request->input('_token', ''))) {
|
||||
Flash::push('danger', $this->translator->get('auth.errors.csrf_expired'));
|
||||
return Response::redirect('/notifications');
|
||||
}
|
||||
|
||||
$id = max(0, (int) $request->input('id', 0));
|
||||
if ($id > 0) {
|
||||
$this->repository->markRead($id);
|
||||
} else {
|
||||
$this->repository->markAllRead();
|
||||
}
|
||||
|
||||
return Response::redirect('/notifications');
|
||||
}
|
||||
}
|
||||
127
src/Modules/Notifications/NotificationRepository.php
Normal file
127
src/Modules/Notifications/NotificationRepository.php
Normal file
@@ -0,0 +1,127 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Modules\Notifications;
|
||||
|
||||
use PDO;
|
||||
|
||||
final class NotificationRepository
|
||||
{
|
||||
public function __construct(private readonly PDO $pdo)
|
||||
{
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $data
|
||||
*/
|
||||
public function create(array $data): int
|
||||
{
|
||||
$statement = $this->pdo->prepare(
|
||||
'INSERT INTO notifications (
|
||||
type, title, body, target_url, related_order_id, related_sms_message_id, created_at
|
||||
) VALUES (
|
||||
:type, :title, :body, :target_url, :related_order_id, :related_sms_message_id, NOW()
|
||||
)'
|
||||
);
|
||||
$statement->execute([
|
||||
'type' => substr(trim((string) ($data['type'] ?? 'info')), 0, 64),
|
||||
'title' => substr(trim((string) ($data['title'] ?? 'Powiadomienie')), 0, 190),
|
||||
'body' => substr(trim((string) ($data['body'] ?? '')), 0, 500),
|
||||
'target_url' => $this->nullableString((string) ($data['target_url'] ?? '')),
|
||||
'related_order_id' => $this->nullableInt($data['related_order_id'] ?? null),
|
||||
'related_sms_message_id' => $this->nullableInt($data['related_sms_message_id'] ?? null),
|
||||
]);
|
||||
|
||||
return (int) $this->pdo->lastInsertId();
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{items: array<int, array<string, mixed>>, total: int, page: int, per_page: int}
|
||||
*/
|
||||
public function paginate(int $page = 1, int $perPage = 30): array
|
||||
{
|
||||
$safePage = max(1, $page);
|
||||
$safePerPage = max(1, min(100, $perPage));
|
||||
$offset = ($safePage - 1) * $safePerPage;
|
||||
|
||||
$total = (int) $this->pdo->query('SELECT COUNT(*) FROM notifications')->fetchColumn();
|
||||
$statement = $this->pdo->prepare(
|
||||
'SELECT *
|
||||
FROM notifications
|
||||
ORDER BY created_at DESC, id DESC
|
||||
LIMIT :limit OFFSET :offset'
|
||||
);
|
||||
$statement->bindValue('limit', $safePerPage, PDO::PARAM_INT);
|
||||
$statement->bindValue('offset', $offset, PDO::PARAM_INT);
|
||||
$statement->execute();
|
||||
$items = $statement->fetchAll(PDO::FETCH_ASSOC);
|
||||
|
||||
return [
|
||||
'items' => is_array($items) ? $items : [],
|
||||
'total' => $total,
|
||||
'page' => $safePage,
|
||||
'per_page' => $safePerPage,
|
||||
];
|
||||
}
|
||||
|
||||
public function unreadCount(): int
|
||||
{
|
||||
$statement = $this->pdo->prepare('SELECT COUNT(*) FROM notifications WHERE read_at IS NULL');
|
||||
$statement->execute();
|
||||
|
||||
return (int) $statement->fetchColumn();
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<int, array<string, mixed>>
|
||||
*/
|
||||
public function recentUnread(int $limit = 10): array
|
||||
{
|
||||
$safeLimit = max(1, min(50, $limit));
|
||||
$statement = $this->pdo->prepare(
|
||||
'SELECT *
|
||||
FROM notifications
|
||||
WHERE read_at IS NULL
|
||||
ORDER BY created_at DESC, id DESC
|
||||
LIMIT :limit'
|
||||
);
|
||||
$statement->bindValue('limit', $safeLimit, PDO::PARAM_INT);
|
||||
$statement->execute();
|
||||
$rows = $statement->fetchAll(PDO::FETCH_ASSOC);
|
||||
|
||||
return is_array($rows) ? $rows : [];
|
||||
}
|
||||
|
||||
public function markRead(int $id): void
|
||||
{
|
||||
if ($id <= 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
$statement = $this->pdo->prepare(
|
||||
'UPDATE notifications
|
||||
SET read_at = COALESCE(read_at, NOW())
|
||||
WHERE id = :id'
|
||||
);
|
||||
$statement->execute(['id' => $id]);
|
||||
}
|
||||
|
||||
public function markAllRead(): void
|
||||
{
|
||||
$this->pdo->prepare('UPDATE notifications SET read_at = NOW() WHERE read_at IS NULL')->execute();
|
||||
}
|
||||
|
||||
private function nullableString(string $value): ?string
|
||||
{
|
||||
$trimmed = trim($value);
|
||||
|
||||
return $trimmed === '' ? null : $trimmed;
|
||||
}
|
||||
|
||||
private function nullableInt(mixed $value): ?int
|
||||
{
|
||||
$intValue = (int) $value;
|
||||
|
||||
return $intValue > 0 ? $intValue : null;
|
||||
}
|
||||
}
|
||||
@@ -22,6 +22,9 @@ use App\Modules\Automation\AutomationService;
|
||||
use App\Modules\Settings\ShopproApiClient;
|
||||
use App\Modules\Settings\ShopproIntegrationsRepository;
|
||||
use App\Modules\Shipments\ShipmentPackageRepository;
|
||||
use App\Modules\Sms\SmsConversationService;
|
||||
use App\Modules\Sms\SmsMessageRepository;
|
||||
use Throwable;
|
||||
|
||||
final class OrdersController
|
||||
{
|
||||
@@ -41,7 +44,9 @@ final class OrdersController
|
||||
private readonly ?ShopproIntegrationsRepository $shopproIntegrations = null,
|
||||
private readonly ?AutomationService $automation = null,
|
||||
private readonly ?InvoiceRepository $invoiceRepo = null,
|
||||
private readonly ?InvoiceConfigRepository $invoiceConfigRepo = null
|
||||
private readonly ?InvoiceConfigRepository $invoiceConfigRepo = null,
|
||||
private readonly ?SmsMessageRepository $smsMessages = null,
|
||||
private readonly ?SmsConversationService $smsConversation = null
|
||||
) {
|
||||
}
|
||||
|
||||
@@ -247,6 +252,9 @@ final class OrdersController
|
||||
$flashError = (string) Flash::get('order.error', '');
|
||||
|
||||
$customerRiskInfo = $this->buildCustomerRiskInfo($order, $orderId);
|
||||
$smsMessages = $this->smsMessages !== null ? $this->smsMessages->findByOrderId($orderId) : [];
|
||||
$smsPhone = $this->resolveSmsPhone($order, $addresses);
|
||||
$smsDefaultFooterConfigured = $this->smsConversation !== null && $this->smsConversation->hasDefaultFooter();
|
||||
|
||||
$html = $this->template->render('orders/show', [
|
||||
'title' => $this->translator->get('orders.details.title') . ' #' . $orderId,
|
||||
@@ -279,11 +287,50 @@ final class OrdersController
|
||||
'emailTemplates' => $emailTemplates,
|
||||
'emailMailboxes' => $emailMailboxes,
|
||||
'customerRiskInfo' => $customerRiskInfo,
|
||||
'smsMessages' => $smsMessages,
|
||||
'smsPhone' => $smsPhone,
|
||||
'smsDefaultFooterConfigured' => $smsDefaultFooterConfigured,
|
||||
], 'layouts/app');
|
||||
|
||||
return Response::html($html);
|
||||
}
|
||||
|
||||
public function sendSms(Request $request): Response
|
||||
{
|
||||
$orderId = max(0, (int) $request->input('id', 0));
|
||||
$redirectTo = '/orders/' . $orderId . '?tab=sms';
|
||||
if (!Csrf::validate((string) $request->input('_token', ''))) {
|
||||
Flash::set('order.error', $this->translator->get('auth.errors.csrf_expired'));
|
||||
return Response::redirect($redirectTo);
|
||||
}
|
||||
|
||||
if ($orderId <= 0 || $this->smsConversation === null) {
|
||||
Flash::set('order.error', 'Modul SMS nie jest dostepny.');
|
||||
return Response::redirect($redirectTo);
|
||||
}
|
||||
|
||||
try {
|
||||
$user = $this->auth->user();
|
||||
$userId = is_array($user) ? (int) ($user['id'] ?? 0) : 0;
|
||||
$result = $this->smsConversation->sendFromOrder(
|
||||
$orderId,
|
||||
(string) $request->input('phone', ''),
|
||||
(string) $request->input('message', ''),
|
||||
$userId > 0 ? $userId : null
|
||||
);
|
||||
|
||||
if ($result['ok']) {
|
||||
Flash::set('order.success', 'SMS zostal wyslany.');
|
||||
} else {
|
||||
Flash::set('order.error', 'Nie udalo sie wyslac SMS: ' . $result['message']);
|
||||
}
|
||||
} catch (Throwable $exception) {
|
||||
Flash::set('order.error', 'Nie udalo sie wyslac SMS: ' . $exception->getMessage());
|
||||
}
|
||||
|
||||
return Response::redirect($redirectTo);
|
||||
}
|
||||
|
||||
/**
|
||||
* Sklada informacje o historii zwrotow klienta biezacego zamowienia.
|
||||
*
|
||||
@@ -315,6 +362,32 @@ final class OrdersController
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $order
|
||||
* @param array<int, array<string, mixed>> $addresses
|
||||
*/
|
||||
private function resolveSmsPhone(array $order, array $addresses): string
|
||||
{
|
||||
$buyerPhone = trim((string) ($order['buyer_phone'] ?? ''));
|
||||
if ($buyerPhone !== '') {
|
||||
return $buyerPhone;
|
||||
}
|
||||
|
||||
foreach (['customer', 'delivery', 'invoice'] as $wantedType) {
|
||||
foreach ($addresses as $address) {
|
||||
if ((string) ($address['address_type'] ?? '') !== $wantedType) {
|
||||
continue;
|
||||
}
|
||||
$phone = trim((string) ($address['phone'] ?? ''));
|
||||
if ($phone !== '') {
|
||||
return $phone;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return '';
|
||||
}
|
||||
|
||||
private function composeCustomerRiskText(int $count, string $email, string $phone, string $name): string
|
||||
{
|
||||
if ($count <= 0) {
|
||||
|
||||
@@ -16,6 +16,8 @@ use Throwable;
|
||||
|
||||
final class SmsplanetIntegrationController
|
||||
{
|
||||
private const MAX_SMS_LENGTH = 918;
|
||||
|
||||
public function __construct(
|
||||
private readonly Template $template,
|
||||
private readonly Translator $translator,
|
||||
@@ -58,6 +60,9 @@ final class SmsplanetIntegrationController
|
||||
'api_key' => (string) $request->input('api_key', ''),
|
||||
'api_password' => (string) $request->input('api_password', ''),
|
||||
'sender' => (string) $request->input('sender', ''),
|
||||
'sender_mode' => (string) $request->input('sender_mode', ''),
|
||||
'sender_phone' => (string) $request->input('sender_phone', ''),
|
||||
'default_footer' => (string) $request->input('default_footer', ''),
|
||||
'clear_polish' => $request->input('clear_polish', ''),
|
||||
'transactional' => $request->input('transactional', ''),
|
||||
'is_active' => $request->input('is_active', ''),
|
||||
@@ -89,7 +94,10 @@ final class SmsplanetIntegrationController
|
||||
throw new IntegrationConfigException('Najpierw zapisz kompletna i aktywna konfiguracje SMSPLANET.');
|
||||
}
|
||||
|
||||
$result = $this->apiClient->sendSms($credentials, $phone, $message);
|
||||
$finalMessage = $this->buildFinalMessage($message, (string) ($credentials['default_footer'] ?? ''));
|
||||
$this->validateFinalMessageLength($finalMessage);
|
||||
|
||||
$result = $this->apiClient->sendSms($credentials, $phone, $finalMessage);
|
||||
$this->integrations->updateTestResult(
|
||||
$credentials['integration_id'],
|
||||
$result['ok'] ? 'ok' : 'fail',
|
||||
@@ -136,10 +144,36 @@ final class SmsplanetIntegrationController
|
||||
if ($message === '') {
|
||||
throw new IntegrationConfigException('Podaj tresc testowego SMS.');
|
||||
}
|
||||
if (strlen($message) > 918) {
|
||||
if ($this->messageLength($message) > self::MAX_SMS_LENGTH) {
|
||||
throw new IntegrationConfigException('Tresc testowego SMS nie moze przekraczac 918 znakow.');
|
||||
}
|
||||
|
||||
return $message;
|
||||
}
|
||||
|
||||
private function buildFinalMessage(string $message, string $defaultFooter): string
|
||||
{
|
||||
$footer = trim(str_replace(["\r\n", "\r"], "\n", $defaultFooter));
|
||||
if ($footer === '') {
|
||||
return $message;
|
||||
}
|
||||
|
||||
return rtrim($message) . "\n\n" . $footer;
|
||||
}
|
||||
|
||||
private function validateFinalMessageLength(string $message): void
|
||||
{
|
||||
if ($this->messageLength($message) > self::MAX_SMS_LENGTH) {
|
||||
throw new IntegrationConfigException('Tresc testowego SMS ze stopka nie moze przekraczac 918 znakow.');
|
||||
}
|
||||
}
|
||||
|
||||
private function messageLength(string $value): int
|
||||
{
|
||||
if (function_exists('mb_strlen')) {
|
||||
return mb_strlen($value, 'UTF-8');
|
||||
}
|
||||
|
||||
return strlen($value);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -15,6 +15,9 @@ final class SmsplanetIntegrationRepository
|
||||
private const INTEGRATION_BASE_URL = 'https://api2.smsplanet.pl/sms';
|
||||
private const AUTH_TOKEN = 'token';
|
||||
private const AUTH_KEY_PASSWORD = 'key_password';
|
||||
private const SENDER_MODE_TEXT = 'text';
|
||||
private const SENDER_MODE_PHONE = 'phone';
|
||||
private const MAX_DEFAULT_FOOTER_LENGTH = 300;
|
||||
|
||||
private readonly IntegrationsRepository $integrations;
|
||||
private readonly IntegrationSecretCipher $cipher;
|
||||
@@ -41,6 +44,9 @@ final class SmsplanetIntegrationRepository
|
||||
'integration_id' => $integrationId,
|
||||
'auth_method' => $this->normalizeAuthMethod((string) ($row['auth_method'] ?? '')),
|
||||
'sender' => trim((string) ($row['sender'] ?? '')),
|
||||
'sender_mode' => $this->normalizeSenderMode((string) ($row['sender_mode'] ?? '')),
|
||||
'sender_phone' => trim((string) ($row['sender_phone'] ?? '')),
|
||||
'default_footer' => $this->normalizeDefaultFooter((string) ($row['default_footer'] ?? '')),
|
||||
'clear_polish' => !empty($row['clear_polish']),
|
||||
'transactional' => !empty($row['transactional']),
|
||||
'has_api_token' => $this->hasEncryptedValue($row['api_token_encrypted'] ?? null),
|
||||
@@ -66,12 +72,25 @@ final class SmsplanetIntegrationRepository
|
||||
|
||||
$authMethod = $this->normalizeAuthMethod((string) ($payload['auth_method'] ?? ''));
|
||||
$sender = $this->validateSender((string) ($payload['sender'] ?? ''));
|
||||
$senderMode = $this->normalizeSenderMode((string) ($payload['sender_mode'] ?? ''));
|
||||
$senderPhone = $this->validateSenderPhone((string) ($payload['sender_phone'] ?? ''), $senderMode);
|
||||
$defaultFooter = $this->validateDefaultFooter((string) ($payload['default_footer'] ?? ''));
|
||||
$tokenEncrypted = $this->resolveTokenEncrypted($row, (string) ($payload['api_token'] ?? ''));
|
||||
$keyEncrypted = $this->resolveKeyEncrypted($row, (string) ($payload['api_key'] ?? ''));
|
||||
$passwordEncrypted = $this->resolvePasswordEncrypted($row, (string) ($payload['api_password'] ?? ''));
|
||||
|
||||
$this->validateCredentials($authMethod, $tokenEncrypted, $keyEncrypted, $passwordEncrypted);
|
||||
$this->updateSettingsRow($authMethod, $tokenEncrypted, $keyEncrypted, $passwordEncrypted, $sender, $payload);
|
||||
$this->updateSettingsRow(
|
||||
$authMethod,
|
||||
$tokenEncrypted,
|
||||
$keyEncrypted,
|
||||
$passwordEncrypted,
|
||||
$sender,
|
||||
$senderMode,
|
||||
$senderPhone,
|
||||
$defaultFooter,
|
||||
$payload
|
||||
);
|
||||
$this->updateIntegrationActive($integrationId, !empty($payload['is_active']));
|
||||
}
|
||||
|
||||
@@ -83,6 +102,9 @@ final class SmsplanetIntegrationRepository
|
||||
* api_key: string,
|
||||
* api_password: string,
|
||||
* sender: string,
|
||||
* sender_mode: string,
|
||||
* sender_phone: string,
|
||||
* default_footer: string,
|
||||
* clear_polish: bool,
|
||||
* transactional: bool
|
||||
* }|null
|
||||
@@ -99,11 +121,14 @@ final class SmsplanetIntegrationRepository
|
||||
|
||||
$authMethod = $this->normalizeAuthMethod((string) ($row['auth_method'] ?? ''));
|
||||
$sender = trim((string) ($row['sender'] ?? ''));
|
||||
$senderMode = $this->normalizeSenderMode((string) ($row['sender_mode'] ?? ''));
|
||||
$senderPhone = trim((string) ($row['sender_phone'] ?? ''));
|
||||
$apiSender = $senderMode === self::SENDER_MODE_PHONE ? $senderPhone : $sender;
|
||||
$apiToken = $this->decryptValue((string) ($row['api_token_encrypted'] ?? ''));
|
||||
$apiKey = $this->decryptValue((string) ($row['api_key_encrypted'] ?? ''));
|
||||
$apiPassword = $this->decryptValue((string) ($row['api_password_encrypted'] ?? ''));
|
||||
|
||||
if (!$this->hasCompleteCredentials($authMethod, $sender, $apiToken, $apiKey, $apiPassword)) {
|
||||
if (!$this->hasCompleteCredentials($authMethod, $apiSender, $apiToken, $apiKey, $apiPassword)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -113,7 +138,10 @@ final class SmsplanetIntegrationRepository
|
||||
'api_token' => $apiToken,
|
||||
'api_key' => $apiKey,
|
||||
'api_password' => $apiPassword,
|
||||
'sender' => $sender,
|
||||
'sender' => $apiSender,
|
||||
'sender_mode' => $senderMode,
|
||||
'sender_phone' => $senderPhone,
|
||||
'default_footer' => $this->normalizeDefaultFooter((string) ($row['default_footer'] ?? '')),
|
||||
'clear_polish' => !empty($row['clear_polish']),
|
||||
'transactional' => !empty($row['transactional']),
|
||||
];
|
||||
@@ -175,6 +203,11 @@ final class SmsplanetIntegrationRepository
|
||||
return $value === self::AUTH_KEY_PASSWORD ? self::AUTH_KEY_PASSWORD : self::AUTH_TOKEN;
|
||||
}
|
||||
|
||||
private function normalizeSenderMode(string $value): string
|
||||
{
|
||||
return $value === self::SENDER_MODE_PHONE ? self::SENDER_MODE_PHONE : self::SENDER_MODE_TEXT;
|
||||
}
|
||||
|
||||
private function validateSender(string $value): string
|
||||
{
|
||||
$sender = trim($value);
|
||||
@@ -185,6 +218,38 @@ final class SmsplanetIntegrationRepository
|
||||
return $sender;
|
||||
}
|
||||
|
||||
private function validateSenderPhone(string $value, string $senderMode): ?string
|
||||
{
|
||||
$phone = preg_replace('/[\s+\-()]/', '', trim($value)) ?? '';
|
||||
if ($senderMode !== self::SENDER_MODE_PHONE) {
|
||||
return $phone !== '' ? substr($phone, 0, 32) : null;
|
||||
}
|
||||
|
||||
if (preg_match('/^\d{8,15}$/', $phone) !== 1) {
|
||||
throw new IntegrationConfigException('Podaj numer 2WAY w formacie 600111222 albo 48600111222.');
|
||||
}
|
||||
|
||||
return $phone;
|
||||
}
|
||||
|
||||
private function normalizeDefaultFooter(string $value): string
|
||||
{
|
||||
return trim(str_replace(["\r\n", "\r"], "\n", $value));
|
||||
}
|
||||
|
||||
private function validateDefaultFooter(string $value): ?string
|
||||
{
|
||||
$footer = $this->normalizeDefaultFooter($value);
|
||||
if ($footer === '') {
|
||||
return null;
|
||||
}
|
||||
if ($this->messageLength($footer) > self::MAX_DEFAULT_FOOTER_LENGTH) {
|
||||
throw new IntegrationConfigException('Stopka SMSPLANET nie moze przekraczac 300 znakow.');
|
||||
}
|
||||
|
||||
return $footer;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $row
|
||||
*/
|
||||
@@ -255,6 +320,9 @@ final class SmsplanetIntegrationRepository
|
||||
?string $keyEncrypted,
|
||||
?string $passwordEncrypted,
|
||||
string $sender,
|
||||
string $senderMode,
|
||||
?string $senderPhone,
|
||||
?string $defaultFooter,
|
||||
array $payload
|
||||
): void {
|
||||
$statement = $this->pdo->prepare(
|
||||
@@ -264,6 +332,9 @@ final class SmsplanetIntegrationRepository
|
||||
api_key_encrypted = :api_key_encrypted,
|
||||
api_password_encrypted = :api_password_encrypted,
|
||||
sender = :sender,
|
||||
sender_mode = :sender_mode,
|
||||
sender_phone = :sender_phone,
|
||||
default_footer = :default_footer,
|
||||
clear_polish = :clear_polish,
|
||||
transactional = :transactional,
|
||||
updated_at = NOW()
|
||||
@@ -275,6 +346,9 @@ final class SmsplanetIntegrationRepository
|
||||
'api_key_encrypted' => $keyEncrypted,
|
||||
'api_password_encrypted' => $passwordEncrypted,
|
||||
'sender' => $sender,
|
||||
'sender_mode' => $senderMode,
|
||||
'sender_phone' => $senderPhone,
|
||||
'default_footer' => $defaultFooter,
|
||||
'clear_polish' => !empty($payload['clear_polish']) ? 1 : 0,
|
||||
'transactional' => !empty($payload['transactional']) ? 1 : 0,
|
||||
]);
|
||||
@@ -327,4 +401,13 @@ final class SmsplanetIntegrationRepository
|
||||
|
||||
return $apiKey !== '' && $apiPassword !== '';
|
||||
}
|
||||
|
||||
private function messageLength(string $value): int
|
||||
{
|
||||
if (function_exists('mb_strlen')) {
|
||||
return mb_strlen($value, 'UTF-8');
|
||||
}
|
||||
|
||||
return strlen($value);
|
||||
}
|
||||
}
|
||||
|
||||
198
src/Modules/Sms/SmsConversationService.php
Normal file
198
src/Modules/Sms/SmsConversationService.php
Normal file
@@ -0,0 +1,198 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Modules\Sms;
|
||||
|
||||
use App\Core\Exceptions\IntegrationConfigException;
|
||||
use App\Modules\Notifications\NotificationRepository;
|
||||
use App\Modules\Settings\SmsplanetApiClient;
|
||||
use App\Modules\Settings\SmsplanetIntegrationRepository;
|
||||
|
||||
final class SmsConversationService
|
||||
{
|
||||
private const MAX_SMS_LENGTH = 918;
|
||||
|
||||
public function __construct(
|
||||
private readonly SmsMessageRepository $messages,
|
||||
private readonly SmsplanetIntegrationRepository $settings,
|
||||
private readonly SmsplanetApiClient $apiClient,
|
||||
private readonly NotificationRepository $notifications
|
||||
) {
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{ok: bool, message: string, message_id: string}
|
||||
*/
|
||||
public function sendFromOrder(int $orderId, string $toPhone, string $body, ?int $userId): array
|
||||
{
|
||||
$phone = $this->normalizePhone($toPhone);
|
||||
$message = trim($body);
|
||||
if ($orderId <= 0 || $phone === '' || $message === '') {
|
||||
throw new IntegrationConfigException('Podaj numer telefonu i tresc SMS.');
|
||||
}
|
||||
$credentials = $this->settings->getCredentials();
|
||||
if ($credentials === null) {
|
||||
throw new IntegrationConfigException('Konfiguracja SMSPLANET jest niekompletna albo nieaktywna.');
|
||||
}
|
||||
|
||||
$finalMessage = $this->buildFinalOutboundBody($message, (string) ($credentials['default_footer'] ?? ''));
|
||||
if ($this->messageLength($finalMessage) > self::MAX_SMS_LENGTH) {
|
||||
throw new IntegrationConfigException('Tresc SMS ze stopka nie moze przekraczac 918 znakow.');
|
||||
}
|
||||
|
||||
$from = trim((string) ($credentials['sender'] ?? ''));
|
||||
$result = $this->apiClient->sendSms($credentials, $phone, $finalMessage);
|
||||
$this->messages->insert([
|
||||
'direction' => 'outbound',
|
||||
'provider' => 'smsplanet',
|
||||
'order_id' => $orderId,
|
||||
'from_phone' => $from,
|
||||
'from_phone_normalized' => $this->normalizePhone($from),
|
||||
'to_phone' => $phone,
|
||||
'to_phone_normalized' => $this->normalizePhone($phone),
|
||||
'body' => $finalMessage,
|
||||
'message_id' => (string) $result['message_id'],
|
||||
'status' => $result['ok'] ? 'sent' : 'failed',
|
||||
'raw_payload' => $result,
|
||||
'created_by' => $userId,
|
||||
]);
|
||||
|
||||
return [
|
||||
'ok' => (bool) $result['ok'],
|
||||
'message' => (string) $result['message'],
|
||||
'message_id' => (string) $result['message_id'],
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $payload
|
||||
* @return array{message_id: int, order_id: ?int}
|
||||
*/
|
||||
public function receiveSmsplanetWebhook(array $payload): array
|
||||
{
|
||||
$parsed = $this->parseSmsplanetPayload($payload);
|
||||
$fromNormalized = $this->normalizePhone($parsed['from_phone']);
|
||||
$toNormalized = $this->normalizePhone($parsed['to_phone']);
|
||||
$orderId = $this->messages->findLatestOrderIdByPhones($this->phoneLookupVariants($fromNormalized));
|
||||
|
||||
$messageId = $this->messages->insert([
|
||||
'direction' => 'inbound',
|
||||
'provider' => 'smsplanet',
|
||||
'order_id' => $orderId,
|
||||
'from_phone' => $parsed['from_phone'],
|
||||
'from_phone_normalized' => $fromNormalized,
|
||||
'to_phone' => $parsed['to_phone'],
|
||||
'to_phone_normalized' => $toNormalized,
|
||||
'body' => $parsed['body'],
|
||||
'message_id' => $parsed['message_id'],
|
||||
'status' => 'received',
|
||||
'raw_payload' => $payload,
|
||||
]);
|
||||
|
||||
$this->notifications->create([
|
||||
'type' => 'sms_inbound',
|
||||
'title' => 'Nowy SMS od klienta',
|
||||
'body' => $this->notificationBody($parsed['from_phone'], $parsed['body']),
|
||||
'target_url' => $orderId !== null ? '/orders/' . $orderId . '?tab=sms' : '/notifications',
|
||||
'related_order_id' => $orderId,
|
||||
'related_sms_message_id' => $messageId,
|
||||
]);
|
||||
|
||||
return ['message_id' => $messageId, 'order_id' => $orderId];
|
||||
}
|
||||
|
||||
public function normalizePhone(string $phone): string
|
||||
{
|
||||
return preg_replace('/[^0-9]+/', '', trim($phone)) ?? '';
|
||||
}
|
||||
|
||||
public function hasDefaultFooter(): bool
|
||||
{
|
||||
$settings = $this->settings->getSettings();
|
||||
|
||||
return trim((string) ($settings['default_footer'] ?? '')) !== '';
|
||||
}
|
||||
|
||||
private function buildFinalOutboundBody(string $message, string $defaultFooter): string
|
||||
{
|
||||
$footer = trim(str_replace(["\r\n", "\r"], "\n", $defaultFooter));
|
||||
if ($footer === '') {
|
||||
return $message;
|
||||
}
|
||||
|
||||
return rtrim($message) . "\n\n" . $footer;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{from_phone: string, to_phone: string, body: string, message_id: string}
|
||||
*/
|
||||
private function parseSmsplanetPayload(array $payload): array
|
||||
{
|
||||
$from = $this->firstPayloadValue($payload, ['from', 'sender', 'msisdn', 'phone', 'originator']);
|
||||
$to = $this->firstPayloadValue($payload, ['to', 'recipient', 'receiver', 'number']);
|
||||
$body = $this->firstPayloadValue($payload, ['msg', 'message', 'body', 'text', 'content']);
|
||||
if ($from === '' || $body === '') {
|
||||
throw new IntegrationConfigException('Webhook SMSPLANET nie zawiera numeru nadawcy albo tresci.');
|
||||
}
|
||||
|
||||
return [
|
||||
'from_phone' => $from,
|
||||
'to_phone' => $to,
|
||||
'body' => $body,
|
||||
'message_id' => $this->firstPayloadValue($payload, ['messageId', 'message_id', 'id', 'smsId']),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $payload
|
||||
* @param array<int, string> $keys
|
||||
*/
|
||||
private function firstPayloadValue(array $payload, array $keys): string
|
||||
{
|
||||
foreach ($keys as $key) {
|
||||
if (array_key_exists($key, $payload)) {
|
||||
return trim((string) $payload[$key]);
|
||||
}
|
||||
}
|
||||
|
||||
return '';
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<int, string>
|
||||
*/
|
||||
private function phoneLookupVariants(string $normalized): array
|
||||
{
|
||||
if ($normalized === '') {
|
||||
return [];
|
||||
}
|
||||
|
||||
$variants = [$normalized];
|
||||
if (str_starts_with($normalized, '48') && strlen($normalized) === 11) {
|
||||
$variants[] = substr($normalized, 2);
|
||||
} elseif (strlen($normalized) === 9) {
|
||||
$variants[] = '48' . $normalized;
|
||||
}
|
||||
|
||||
return $variants;
|
||||
}
|
||||
|
||||
private function notificationBody(string $fromPhone, string $body): string
|
||||
{
|
||||
$shortBody = trim(preg_replace('/\s+/', ' ', $body) ?? $body);
|
||||
if (strlen($shortBody) > 140) {
|
||||
$shortBody = substr($shortBody, 0, 137) . '...';
|
||||
}
|
||||
|
||||
return trim($fromPhone . ': ' . $shortBody);
|
||||
}
|
||||
|
||||
private function messageLength(string $value): int
|
||||
{
|
||||
if (function_exists('mb_strlen')) {
|
||||
return mb_strlen($value, 'UTF-8');
|
||||
}
|
||||
|
||||
return strlen($value);
|
||||
}
|
||||
}
|
||||
130
src/Modules/Sms/SmsMessageRepository.php
Normal file
130
src/Modules/Sms/SmsMessageRepository.php
Normal file
@@ -0,0 +1,130 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Modules\Sms;
|
||||
|
||||
use PDO;
|
||||
|
||||
final class SmsMessageRepository
|
||||
{
|
||||
public function __construct(private readonly PDO $pdo)
|
||||
{
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $data
|
||||
*/
|
||||
public function insert(array $data): int
|
||||
{
|
||||
$statement = $this->pdo->prepare(
|
||||
'INSERT INTO sms_messages (
|
||||
direction, provider, order_id, from_phone, from_phone_normalized,
|
||||
to_phone, to_phone_normalized, body, message_id, status,
|
||||
raw_payload_json, created_by, created_at, updated_at
|
||||
) VALUES (
|
||||
:direction, :provider, :order_id, :from_phone, :from_phone_normalized,
|
||||
:to_phone, :to_phone_normalized, :body, :message_id, :status,
|
||||
:raw_payload_json, :created_by, NOW(), NOW()
|
||||
)'
|
||||
);
|
||||
$statement->execute([
|
||||
'direction' => (string) $data['direction'],
|
||||
'provider' => (string) ($data['provider'] ?? 'smsplanet'),
|
||||
'order_id' => $this->nullableInt($data['order_id'] ?? null),
|
||||
'from_phone' => (string) $data['from_phone'],
|
||||
'from_phone_normalized' => (string) $data['from_phone_normalized'],
|
||||
'to_phone' => (string) $data['to_phone'],
|
||||
'to_phone_normalized' => (string) $data['to_phone_normalized'],
|
||||
'body' => (string) $data['body'],
|
||||
'message_id' => $this->nullableString((string) ($data['message_id'] ?? '')),
|
||||
'status' => substr((string) ($data['status'] ?? 'received'), 0, 32),
|
||||
'raw_payload_json' => $this->jsonOrNull($data['raw_payload'] ?? null),
|
||||
'created_by' => $this->nullableInt($data['created_by'] ?? null),
|
||||
]);
|
||||
|
||||
return (int) $this->pdo->lastInsertId();
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<int, array<string, mixed>>
|
||||
*/
|
||||
public function findByOrderId(int $orderId): array
|
||||
{
|
||||
if ($orderId <= 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$statement = $this->pdo->prepare(
|
||||
'SELECT *
|
||||
FROM sms_messages
|
||||
WHERE order_id = :order_id
|
||||
ORDER BY created_at ASC, id ASC'
|
||||
);
|
||||
$statement->execute(['order_id' => $orderId]);
|
||||
$rows = $statement->fetchAll(PDO::FETCH_ASSOC);
|
||||
|
||||
return is_array($rows) ? $rows : [];
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<int, string> $phones
|
||||
*/
|
||||
public function findLatestOrderIdByPhones(array $phones): ?int
|
||||
{
|
||||
$variants = array_values(array_unique(array_filter($phones, static fn (string $value): bool => $value !== '')));
|
||||
if ($variants === []) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$addressPlaceholders = [];
|
||||
$params = [];
|
||||
foreach ($variants as $index => $phone) {
|
||||
$addressKey = 'address_phone' . $index;
|
||||
$addressPlaceholders[] = ':' . $addressKey;
|
||||
$params[$addressKey] = $phone;
|
||||
}
|
||||
|
||||
$statement = $this->pdo->prepare(
|
||||
'SELECT o.id
|
||||
FROM orders o
|
||||
WHERE EXISTS (
|
||||
SELECT 1
|
||||
FROM order_addresses a
|
||||
WHERE a.order_id = o.id
|
||||
AND REGEXP_REPLACE(COALESCE(a.phone, ""), "[^0-9]+", "") IN (' . implode(',', $addressPlaceholders) . ')
|
||||
)
|
||||
ORDER BY COALESCE(o.ordered_at, o.source_created_at, o.created_at) DESC, o.id DESC
|
||||
LIMIT 1'
|
||||
);
|
||||
$statement->execute($params);
|
||||
$value = $statement->fetchColumn();
|
||||
$orderId = (int) $value;
|
||||
|
||||
return $orderId > 0 ? $orderId : null;
|
||||
}
|
||||
|
||||
private function nullableString(string $value): ?string
|
||||
{
|
||||
$trimmed = trim($value);
|
||||
|
||||
return $trimmed === '' ? null : $trimmed;
|
||||
}
|
||||
|
||||
private function nullableInt(mixed $value): ?int
|
||||
{
|
||||
$intValue = (int) $value;
|
||||
|
||||
return $intValue > 0 ? $intValue : null;
|
||||
}
|
||||
|
||||
private function jsonOrNull(mixed $value): ?string
|
||||
{
|
||||
if ($value === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$encoded = json_encode($value, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
|
||||
|
||||
return is_string($encoded) ? $encoded : null;
|
||||
}
|
||||
}
|
||||
71
src/Modules/Sms/SmsplanetWebhookController.php
Normal file
71
src/Modules/Sms/SmsplanetWebhookController.php
Normal file
@@ -0,0 +1,71 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Modules\Sms;
|
||||
|
||||
use App\Core\Http\Request;
|
||||
use App\Core\Http\Response;
|
||||
use Throwable;
|
||||
|
||||
final class SmsplanetWebhookController
|
||||
{
|
||||
public function __construct(private readonly SmsConversationService $service)
|
||||
{
|
||||
}
|
||||
|
||||
public function inbound(Request $request): Response
|
||||
{
|
||||
$payload = $this->payloadFromRequest($request);
|
||||
|
||||
try {
|
||||
$this->service->receiveSmsplanetWebhook($payload);
|
||||
|
||||
return Response::html('OK');
|
||||
} catch (Throwable $exception) {
|
||||
return Response::json([
|
||||
'ok' => false,
|
||||
'message' => $exception->getMessage(),
|
||||
], 422);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
private function payloadFromRequest(Request $request): array
|
||||
{
|
||||
$payload = $request->all();
|
||||
$message = $this->decodeMessagePayload($payload['message'] ?? null);
|
||||
if ($message !== null) {
|
||||
$payload = array_merge($payload, $message);
|
||||
}
|
||||
|
||||
$rawBody = file_get_contents('php://input');
|
||||
if (is_string($rawBody) && trim($rawBody) !== '') {
|
||||
$decoded = json_decode($rawBody, true);
|
||||
if (is_array($decoded)) {
|
||||
$payload = array_merge($payload, $decoded);
|
||||
}
|
||||
}
|
||||
|
||||
if (is_string($rawBody) && trim($rawBody) !== '') {
|
||||
$payload['_raw_body'] = $rawBody;
|
||||
}
|
||||
|
||||
return $payload;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, mixed>|null
|
||||
*/
|
||||
private function decodeMessagePayload(mixed $message): ?array
|
||||
{
|
||||
if (!is_string($message) || trim($message) === '') {
|
||||
return null;
|
||||
}
|
||||
|
||||
$decoded = json_decode($message, true);
|
||||
|
||||
return is_array($decoded) ? $decoded : null;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user