Compare commits

291 Commits

Author SHA1 Message Date
Codex Agent
0e32baee05 Tighten dev switcher collapse and add bottom nav spacing
Some checks are pending
linter / quality (push) Waiting to run
tests / ci (push) Waiting to run
tests / ui (push) Waiting to run
2026-02-04 09:02:00 +01:00
Codex Agent
cffe8e6cd4 Fix filter pill sizing for admin filters
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (push) Has been cancelled
tests / ui (push) Has been cancelled
2026-02-04 08:55:45 +01:00
Codex Agent
93ae23fd0a Improve active states for admin tabs and filters
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (push) Has been cancelled
tests / ui (push) Has been cancelled
2026-02-04 08:44:50 +01:00
Codex Agent
eecb1a5b85 Adopt Tamagui defaults for tabs and filters
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (push) Has been cancelled
tests / ui (push) Has been cancelled
2026-02-04 08:29:50 +01:00
Codex Agent
0535f63b40 Align admin theme with Tamagui v2
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (push) Has been cancelled
tests / ui (push) Has been cancelled
2026-02-03 22:40:16 +01:00
Codex Agent
4f910b2d2a Refine guest v2 gallery empty states
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (push) Has been cancelled
tests / ui (push) Has been cancelled
2026-02-03 22:16:02 +01:00
Codex Agent
bcf5f0eb20 Overlay guest v2 preview actions
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (push) Has been cancelled
tests / ui (push) Has been cancelled
2026-02-03 22:10:35 +01:00
Codex Agent
e5aca40b05 Mock capture preview in guest v2
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (push) Has been cancelled
tests / ui (push) Has been cancelled
2026-02-03 22:06:06 +01:00
Codex Agent
9d406e6afa Tune guest v2 capture size
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (push) Has been cancelled
tests / ui (push) Has been cancelled
2026-02-03 22:02:47 +01:00
Codex Agent
effe6d2390 Adjust guest v2 fab sizing
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (push) Has been cancelled
tests / ui (push) Has been cancelled
2026-02-03 21:58:10 +01:00
Codex Agent
b97f5de101 Group guest v2 fab controls
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (push) Has been cancelled
tests / ui (push) Has been cancelled
2026-02-03 21:56:07 +01:00
Codex Agent
fbb6fd4404 Cluster guest v2 capture controls
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (push) Has been cancelled
tests / ui (push) Has been cancelled
2026-02-03 21:49:23 +01:00
Codex Agent
92727bd727 Revamp guest v2 upload camera controls
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (push) Has been cancelled
tests / ui (push) Has been cancelled
2026-02-03 21:45:30 +01:00
Codex Agent
878f2a6365 Add mock preview transition query param
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (push) Has been cancelled
tests / ui (push) Has been cancelled
2026-02-03 21:38:10 +01:00
Codex Agent
c29a13ca15 Remove guest v2 switch camera action
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (push) Has been cancelled
tests / ui (push) Has been cancelled
2026-02-03 21:31:39 +01:00
Codex Agent
7f493d522f Remove switch camera action from dock
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (push) Has been cancelled
tests / ui (push) Has been cancelled
2026-02-03 21:24:27 +01:00
Codex Agent
fa1846d188 Polish upload review success actions
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (push) Has been cancelled
tests / ui (push) Has been cancelled
2026-02-03 21:20:00 +01:00
Codex Agent
12485b4d07 Refine guest upload camera UI
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (push) Has been cancelled
tests / ui (push) Has been cancelled
2026-02-03 21:13:09 +01:00
Codex Agent
6062b4201b Update guest v2 home and tasks experience
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (push) Has been cancelled
tests / ui (push) Has been cancelled
2026-02-03 18:59:30 +01:00
Codex Agent
298a8375b6 Update guest v2 branding and theming
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (push) Has been cancelled
tests / ui (push) Has been cancelled
2026-02-03 15:18:44 +01:00
Codex Agent
10c99de1e2 Migrate billing from Paddle to Lemon Squeezy
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (push) Has been cancelled
tests / ui (push) Has been cancelled
2026-02-03 10:59:54 +01:00
Codex Agent
c96a73d884 Animate admin user menu sheet
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (push) Has been cancelled
tests / ui (push) Has been cancelled
2026-02-02 16:26:11 +01:00
Codex Agent
7c6e14ffe2 upgrade to tamagui v2 and guest pwa overhaul 2026-02-02 13:01:20 +01:00
Codex Agent
2e78f3ab8d Update marketing packages and checkout copy
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (push) Has been cancelled
tests / ui (push) Has been cancelled
2026-02-01 13:04:11 +01:00
Codex Agent
386d0004ed Modernize guest PWA header and homepage
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (push) Has been cancelled
tests / ui (push) Has been cancelled
2026-01-31 23:15:44 +01:00
Codex Agent
e233cddcc8 Publish Livewire assets in Docker build
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (push) Has been cancelled
tests / ui (push) Has been cancelled
2026-01-31 22:10:11 +01:00
Codex Agent
e39ddd2143 Adjust branding defaults and tenant presets
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (push) Has been cancelled
tests / ui (push) Has been cancelled
2026-01-30 18:15:52 +01:00
Codex Agent
b1f9f7cee0 Fix TypeScript typecheck errors
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (push) Has been cancelled
tests / ui (push) Has been cancelled
2026-01-30 15:56:06 +01:00
Codex Agent
916b204688 Harden tenant admin auth and photo moderation
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (push) Has been cancelled
tests / ui (push) Has been cancelled
2026-01-30 14:53:51 +01:00
Codex Agent
d45cb6a087 Stop tracking beads runtime file
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (push) Has been cancelled
tests / ui (push) Has been cancelled
2026-01-30 14:09:55 +01:00
Codex Agent
f574ffaf38 Remove legacy registration page assets
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (push) Has been cancelled
tests / ui (push) Has been cancelled
2026-01-30 13:54:35 +01:00
Codex Agent
b866179521 Remove missing doc from docs sidebar
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (push) Has been cancelled
tests / ui (push) Has been cancelled
2026-01-30 13:26:30 +01:00
Codex Agent
3ba784154b Share CSRF headers across guest uploads
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (push) Has been cancelled
tests / ui (push) Has been cancelled
2026-01-30 13:10:19 +01:00
Codex Agent
96aaea23e4 Fix guest upload queue endpoint
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (push) Has been cancelled
tests / ui (push) Has been cancelled
2026-01-30 13:06:26 +01:00
Codex Agent
4b1785fb85 Update beads tracker
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (push) Has been cancelled
tests / ui (push) Has been cancelled
2026-01-30 13:01:29 +01:00
Codex Agent
8aba034344 Respect cache-control in guest API cache 2026-01-30 13:00:19 +01:00
Codex Agent
19425c0f62 Document dynamic security review checklists
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (push) Has been cancelled
tests / ui (push) Has been cancelled
2026-01-30 12:27:15 +01:00
Codex Agent
1443ff0d3a Add marketing hreflang tests and docs
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (push) Has been cancelled
tests / ui (push) Has been cancelled
2026-01-30 11:52:44 +01:00
Codex Agent
e48ec3c564 Add storage checksum env defaults 2026-01-30 11:52:20 +01:00
Codex Agent
eeffe4c6f1 Add checksum validation for archived media
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (push) Has been cancelled
tests / ui (push) Has been cancelled
2026-01-30 11:29:40 +01:00
Codex Agent
9a8305d986 Add Uptime Kuma monitoring template
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (push) Has been cancelled
tests / ui (push) Has been cancelled
2026-01-30 11:12:15 +01:00
Codex Agent
6ca0b50403 Make queue health widget full width
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (push) Has been cancelled
tests / ui (push) Has been cancelled
2026-01-29 11:25:21 +01:00
Codex Agent
ce7da1ff66 Read Dokploy environments for composes
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (push) Has been cancelled
tests / ui (push) Has been cancelled
2026-01-29 11:17:41 +01:00
Codex Agent
87f348462b Load Dokploy project details for compose data
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (push) Has been cancelled
tests / ui (push) Has been cancelled
2026-01-29 11:00:53 +01:00
Codex Agent
dba0cd5882 Handle Dokploy project composes in widget
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (push) Has been cancelled
tests / ui (push) Has been cancelled
2026-01-29 10:45:21 +01:00
Codex Agent
78af7838bf Use Dokploy projects in dashboard widget
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (push) Has been cancelled
tests / ui (push) Has been cancelled
2026-01-29 10:40:10 +01:00
Codex Agent
b8bb7926c0 Expand support API contract coverage
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (push) Has been cancelled
tests / ui (push) Has been cancelled
2026-01-29 07:42:53 +01:00
Codex Agent
6e4656946c Expand support API integration tests and add load script
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (push) Has been cancelled
tests / ui (push) Has been cancelled
2026-01-28 21:49:16 +01:00
Codex Agent
c94fbe4ab8 Require current password on profile password change
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (push) Has been cancelled
tests / ui (push) Has been cancelled
2026-01-28 21:34:27 +01:00
Codex Agent
9ccf079a3a Register support API token widget
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (push) Has been cancelled
tests / ui (push) Has been cancelled
2026-01-28 21:30:49 +01:00
Codex Agent
e0e9723b11 Add support API token management to profile
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (push) Has been cancelled
tests / ui (push) Has been cancelled
2026-01-28 21:24:37 +01:00
Codex Agent
0d2759b0d4 Fix support API audit logging
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (push) Has been cancelled
tests / ui (push) Has been cancelled
2026-01-28 21:02:25 +01:00
Codex Agent
f0e8cee850 Expand support API validation for writable resources
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (push) Has been cancelled
tests / ui (push) Has been cancelled
2026-01-28 20:46:12 +01:00
Codex Agent
981df2ee45 Add support API validation rules
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (push) Has been cancelled
tests / ui (push) Has been cancelled
2026-01-28 19:42:28 +01:00
Codex Agent
6bc1d86009 Tighten support API resource mutations
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (push) Has been cancelled
tests / ui (push) Has been cancelled
2026-01-28 18:34:12 +01:00
Codex Agent
53a6500e6a Add support API scaffold
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (push) Has been cancelled
tests / ui (push) Has been cancelled
2026-01-28 13:52:47 +01:00
Codex Agent
75c4dbd1f0 Add spacing between tabs and packages
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (push) Has been cancelled
tests / ui (push) Has been cancelled
2026-01-25 15:53:00 +01:00
Codex Agent
5d48b804a5 Move packages tabs further up
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (push) Has been cancelled
tests / ui (push) Has been cancelled
2026-01-25 15:50:41 +01:00
Codex Agent
80dca9fe67 Adjust packages tabs label and spacing
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (push) Has been cancelled
tests / ui (push) Has been cancelled
2026-01-25 15:47:52 +01:00
Codex Agent
78bd3c9267 Allow superadmin to bypass onboarding billing
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (push) Has been cancelled
tests / ui (push) Has been cancelled
2026-01-25 00:05:34 +01:00
Codex Agent
c4ac38e41a Relax style-src-elem to allow inline
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (push) Has been cancelled
tests / ui (push) Has been cancelled
2026-01-24 23:41:53 +01:00
Codex Agent
84e253b61c Allow inline style tags and remove Bunny font
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (push) Has been cancelled
tests / ui (push) Has been cancelled
2026-01-24 23:34:10 +01:00
Codex Agent
8414305ea3 Fix CSP style-src-elem allowlist
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (push) Has been cancelled
tests / ui (push) Has been cancelled
2026-01-24 23:16:23 +01:00
Codex Agent
694ce218c9 Adjust packages tabs spacing
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (push) Has been cancelled
tests / ui (push) Has been cancelled
2026-01-24 22:59:12 +01:00
Codex Agent
ec98086e23 Refine packages hero and translations
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (push) Has been cancelled
tests / ui (push) Has been cancelled
2026-01-24 22:55:14 +01:00
Codex Agent
d87d22fa22 Redesign marketing packages layout
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (push) Has been cancelled
tests / ui (push) Has been cancelled
2026-01-24 22:30:03 +01:00
Codex Agent
a21321bb3c Allow inline style elements for event-admin CSP
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (push) Has been cancelled
tests / ui (push) Has been cancelled
2026-01-24 21:16:31 +01:00
Codex Agent
7a91e40bb3 Allow inline style elements for event-admin CSP
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (push) Has been cancelled
tests / ui (push) Has been cancelled
2026-01-24 21:02:33 +01:00
Codex Agent
71604c6e41 Fix CSP nonce timing for admin styles
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (push) Has been cancelled
tests / ui (push) Has been cancelled
2026-01-24 20:54:23 +01:00
Codex Agent
2b4d9e9411 Add CSP nonce for Tamagui styles
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (push) Has been cancelled
tests / ui (push) Has been cancelled
2026-01-24 20:38:36 +01:00
Codex Agent
35d8c94c11 Update Dokploy compose for prod/staging
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (push) Has been cancelled
tests / ui (push) Has been cancelled
2026-01-24 11:12:53 +01:00
Codex Agent
ce43cac145 Fix foldable background layout
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (push) Has been cancelled
tests / ui (push) Has been cancelled
2026-01-24 10:41:24 +01:00
Codex Agent
b11f010938 refactor(checkout): wrap auth step buttons in shadcn tabs
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (push) Has been cancelled
tests / ui (push) Has been cancelled
2026-01-24 09:50:06 +01:00
Codex Agent
e3b356e810 Enable foldable background presets
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (push) Has been cancelled
tests / ui (push) Has been cancelled
2026-01-24 09:02:52 +01:00
Codex Agent
6bd75b0788 Add more invite background presets
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (push) Has been cancelled
tests / ui (push) Has been cancelled
2026-01-23 22:54:54 +01:00
Codex Agent
14bb375674 Add from-disk rebuild for font manifest
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (push) Has been cancelled
tests / ui (push) Has been cancelled
2026-01-23 21:46:09 +01:00
Codex Agent
a33bf0e3a4 Scope social login callbacks per flow
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (push) Has been cancelled
tests / ui (push) Has been cancelled
2026-01-23 20:38:22 +01:00
Codex Agent
1241f5092e Remove Google helper badge in checkout auth
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (push) Has been cancelled
tests / ui (push) Has been cancelled
2026-01-23 20:24:43 +01:00
Codex Agent
73728f6baf Add Facebook social login
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (push) Has been cancelled
tests / ui (push) Has been cancelled
2026-01-23 20:19:15 +01:00
Codex Agent
db90b9af2e Fix pagination totals for zero counts
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (push) Has been cancelled
tests / ui (push) Has been cancelled
2026-01-23 17:33:18 +01:00
Codex Agent
7dd8bc4c91 Fix tenant admin Google OAuth redirect
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (push) Has been cancelled
tests / ui (push) Has been cancelled
2026-01-23 17:25:12 +01:00
Codex Agent
ee6fb7a5bb Fix event naming and checklist labels
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (push) Has been cancelled
tests / ui (push) Has been cancelled
2026-01-23 17:13:10 +01:00
Codex Agent
1c4c93c547 Simplify guest language selector
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (push) Has been cancelled
tests / ui (push) Has been cancelled
2026-01-23 16:27:48 +01:00
Codex Agent
bdb1789a10 Add guest analytics consent nudge
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (push) Has been cancelled
tests / ui (push) Has been cancelled
2026-01-23 16:20:14 +01:00
Codex Agent
4bf0d5052c Add honeypot protection to contact forms
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (push) Has been cancelled
tests / ui (push) Has been cancelled
2026-01-23 15:38:34 +01:00
Codex Agent
d629b745c4 Add Google login to checkout login form
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (push) Has been cancelled
tests / ui (push) Has been cancelled
2026-01-23 14:17:12 +01:00
Codex Agent
72dd1409e8 Add Google login to mobile admin PWA
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (push) Has been cancelled
tests / ui (push) Has been cancelled
2026-01-23 13:59:14 +01:00
Codex Agent
2729c3c713 Add spacing around KPI separator
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (push) Has been cancelled
tests / ui (push) Has been cancelled
2026-01-23 13:40:46 +01:00
Codex Agent
4135deb110 Update dashboard live show KPI label (DE)
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (push) Has been cancelled
tests / ui (push) Has been cancelled
2026-01-23 13:34:00 +01:00
Codex Agent
fda97b3c05 Update dashboard KPIs for live show and auto-approval
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (push) Has been cancelled
tests / ui (push) Has been cancelled
2026-01-23 13:31:50 +01:00
Codex Agent
55608c311d Add dashboard action colors and admin help translations
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (push) Has been cancelled
tests / ui (push) Has been cancelled
2026-01-23 13:14:33 +01:00
Codex Agent
ead80025fc Update admin theme palette and heading font
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (push) Has been cancelled
tests / ui (push) Has been cancelled
2026-01-23 12:36:57 +01:00
Codex Agent
d000d9b456 Record bd issue activity
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (push) Has been cancelled
tests / ui (push) Has been cancelled
2026-01-23 12:25:09 +01:00
Codex Agent
ebfcc090d6 Fix admin PWA status badge contrast 2026-01-23 12:24:09 +01:00
Codex Agent
49c4f9ad7d Tweak German admin copy
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (push) Has been cancelled
tests / ui (push) Has been cancelled
2026-01-23 11:10:32 +01:00
Codex Agent
0089a14204 Replace KPI/tenant wording in admin UI and help
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (push) Has been cancelled
tests / ui (push) Has been cancelled
2026-01-23 10:55:24 +01:00
Codex Agent
0eb3b85f06 Add tenant PWA help articles and links
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (push) Has been cancelled
tests / ui (push) Has been cancelled
2026-01-23 10:29:20 +01:00
Codex Agent
db0fdc58a1 Ensure help lists render as lists
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (push) Has been cancelled
tests / ui (push) Has been cancelled
2026-01-23 10:13:39 +01:00
Codex Agent
0db0ddf3c4 Add related help titles and fix umlauts
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (push) Has been cancelled
tests / ui (push) Has been cancelled
2026-01-23 10:05:29 +01:00
Codex Agent
df5e8204fa Add admin FAQ help article
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (push) Has been cancelled
tests / ui (push) Has been cancelled
2026-01-23 09:53:10 +01:00
Codex Agent
6f7bf818dd Add control room help article and move ops docs
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (push) Has been cancelled
tests / ui (push) Has been cancelled
2026-01-23 09:47:58 +01:00
Codex Agent
b3ea522e31 Remove ops-only help articles
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (push) Has been cancelled
tests / ui (push) Has been cancelled
2026-01-23 09:42:10 +01:00
Codex Agent
b267ae2c15 Refresh admin help articles for event PWA
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (push) Has been cancelled
tests / ui (push) Has been cancelled
2026-01-23 09:35:26 +01:00
Codex Agent
4706b21d22 Acknowledge bd 0.49 upgrade
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (push) Has been cancelled
tests / ui (push) Has been cancelled
2026-01-23 09:26:26 +01:00
Codex Agent
96e65ffc0b Acknowledge bd version
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (push) Has been cancelled
tests / ui (push) Has been cancelled
2026-01-23 09:22:47 +01:00
Codex Agent
348834250a Sync bd issues
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (push) Has been cancelled
tests / ui (push) Has been cancelled
2026-01-23 09:21:00 +01:00
Codex Agent
31a5148263 Update help system issue status
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (push) Has been cancelled
tests / ui (push) Has been cancelled
2026-01-23 09:20:07 +01:00
Codex Agent
35f28fd48d Add contextual help links to admin pages 2026-01-23 09:18:46 +01:00
Codex Agent
53a90fec33 Sync bd issues
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (push) Has been cancelled
tests / ui (push) Has been cancelled
2026-01-23 08:56:44 +01:00
Codex Agent
e1a2850768 Add admin help center entry points 2026-01-23 08:55:37 +01:00
Codex Agent
03ee16bb87 Read admin theme colors from CSS vars
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (push) Has been cancelled
tests / ui (push) Has been cancelled
2026-01-22 22:31:18 +01:00
Codex Agent
1313135020 Fix admin theme dark fallbacks
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (push) Has been cancelled
tests / ui (push) Has been cancelled
2026-01-22 22:14:18 +01:00
Codex Agent
85f2c42fc5 Improve admin mobile dark mode contrast
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (push) Has been cancelled
tests / ui (push) Has been cancelled
2026-01-22 22:02:45 +01:00
Codex Agent
6318aec3cb Replace checklist badge with check icon
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (push) Has been cancelled
tests / ui (push) Has been cancelled
2026-01-22 21:26:24 +01:00
Codex Agent
056d864f80 Compact tasks toggle and title
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (push) Has been cancelled
tests / ui (push) Has been cancelled
2026-01-22 21:15:55 +01:00
Codex Agent
ef88342bd0 Polish tasks hero and dialog
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (push) Has been cancelled
tests / ui (push) Has been cancelled
2026-01-22 21:10:21 +01:00
Codex Agent
d76b26b7ad Style collection import CTA
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (push) Has been cancelled
tests / ui (push) Has been cancelled
2026-01-22 21:03:41 +01:00
Codex Agent
c1dfbaa51e Tighten tasks tab controls
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (push) Has been cancelled
tests / ui (push) Has been cancelled
2026-01-22 20:59:25 +01:00
Codex Agent
32644eb41e Restructure event tasks layout
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (push) Has been cancelled
tests / ui (push) Has been cancelled
2026-01-22 20:50:38 +01:00
Codex Agent
db5fea9f2a Refactor event tasks tabs
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (push) Has been cancelled
tests / ui (push) Has been cancelled
2026-01-22 20:33:02 +01:00
Codex Agent
fba9714ede Switch tasks quick nav to tabs
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (push) Has been cancelled
tests / ui (push) Has been cancelled
2026-01-22 19:48:22 +01:00
Codex Agent
cebc1d1ec5 Simplify hero toggles
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (push) Has been cancelled
tests / ui (push) Has been cancelled
2026-01-22 17:34:42 +01:00
Codex Agent
5aa79b587d Add hero quick settings toggles
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (push) Has been cancelled
tests / ui (push) Has been cancelled
2026-01-22 17:30:51 +01:00
Codex Agent
2e089f7f77 Unify setup status block
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (push) Has been cancelled
tests / ui (push) Has been cancelled
2026-01-22 17:17:10 +01:00
Codex Agent
fd52f8e13d Tighten KPI card layout
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (push) Has been cancelled
tests / ui (push) Has been cancelled
2026-01-22 17:05:42 +01:00
Codex Agent
8ac38cf264 Adjust KPI strip layout
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (push) Has been cancelled
tests / ui (push) Has been cancelled
2026-01-22 17:01:54 +01:00
Codex Agent
66193a6461 Compact dashboard overview
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (push) Has been cancelled
tests / ui (push) Has been cancelled
2026-01-22 16:51:05 +01:00
Codex Agent
64c9d7357a Embed quick actions header
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (push) Has been cancelled
tests / ui (push) Has been cancelled
2026-01-22 16:31:46 +01:00
Codex Agent
8aa2efdd9a Refine dashboard overview layout
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (push) Has been cancelled
tests / ui (push) Has been cancelled
2026-01-22 16:24:48 +01:00
Codex Agent
4f3503e3f4 Refactor mobile dashboard layout
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (push) Has been cancelled
tests / ui (push) Has been cancelled
2026-01-22 16:13:22 +01:00
Codex Agent
4235eda49a Fix guest PWA dark mode contrast
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (push) Has been cancelled
tests / ui (push) Has been cancelled
2026-01-22 15:47:26 +01:00
Codex Agent
ad0e8b7923 Allow longer blog post excerpts
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (push) Has been cancelled
tests / ui (push) Has been cancelled
2026-01-22 15:10:50 +01:00
Codex Agent
446eb15c6b Fix blog post image upload storage
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (push) Has been cancelled
tests / ui (push) Has been cancelled
2026-01-22 15:03:05 +01:00
Codex Agent
02a24877f7 chore: shift blog post published_at dates 3 weeks into the future
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (push) Has been cancelled
tests / ui (push) Has been cancelled
2026-01-22 14:47:08 +01:00
Codex Agent
f016004b2b Update gallery retention copy
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (push) Has been cancelled
tests / ui (push) Has been cancelled
2026-01-22 14:26:20 +01:00
Codex Agent
a0248d976b Refine photobooth timeline label and video note
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (push) Has been cancelled
tests / ui (push) Has been cancelled
2026-01-22 14:23:53 +01:00
Codex Agent
99a880854a Adjust photobooth timeline step
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (push) Has been cancelled
tests / ui (push) Has been cancelled
2026-01-22 14:16:52 +01:00
Codex Agent
a3747138a4 Expand photobooth info on how-it-works
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (push) Has been cancelled
tests / ui (push) Has been cancelled
2026-01-22 14:09:20 +01:00
Codex Agent
287cc8a532 Refine photobooth wording and add FAQ
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (push) Has been cancelled
tests / ui (push) Has been cancelled
2026-01-22 14:00:21 +01:00
Codex Agent
191f39cf5b Add photobooth connect marketing copy
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (push) Has been cancelled
tests / ui (push) Has been cancelled
2026-01-22 13:54:29 +01:00
Codex Agent
543b3015ca UI: Change PWA header icon backgrounds to primary color
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (push) Has been cancelled
tests / ui (push) Has been cancelled
2026-01-22 13:37:33 +01:00
Codex Agent
9d3c866562 Fix: Add missing 'text' variable to EventControlRoomPage theme destructuring
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (push) Has been cancelled
tests / ui (push) Has been cancelled
2026-01-22 13:35:09 +01:00
Codex Agent
911880f1a0 Refactor: Update Tenant PWA headers and tabs to use Playfair Display and Tamagui components
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (push) Has been cancelled
tests / ui (push) Has been cancelled
2026-01-22 13:29:56 +01:00
Codex Agent
b9d91c8f40 Improve marketing language switcher
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (push) Has been cancelled
tests / ui (push) Has been cancelled
2026-01-22 09:07:46 +01:00
Codex Agent
23193a3452 Adjust package CTA split and label
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (push) Has been cancelled
tests / ui (push) Has been cancelled
2026-01-22 08:48:33 +01:00
Codex Agent
da6f95aead Add order CTA links on packages overview
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (push) Has been cancelled
tests / ui (push) Has been cancelled
2026-01-21 22:07:46 +01:00
Codex Agent
2f9a700e00 Fix guest demo UX and enforce guest limits
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (push) Has been cancelled
tests / ui (push) Has been cancelled
2026-01-21 21:35:40 +01:00
Codex Agent
50cc4e76df Add marketing motion reveals to blog and occasions
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (push) Has been cancelled
tests / ui (push) Has been cancelled
2026-01-21 15:22:39 +01:00
Codex Agent
941931934f Update beads issues
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (push) Has been cancelled
tests / ui (push) Has been cancelled
2026-01-21 12:55:55 +01:00
Codex Agent
9b245e9c51 Update marketing packages testimonials and demo 2026-01-21 12:48:34 +01:00
Codex Agent
b9708d5174 Enhance Event admin UI and fix translations
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (push) Has been cancelled
tests / ui (push) Has been cancelled
- Remove default_locale and primary_join_token columns from event list
- Add read-only join link field to event edit form
- Add missing translations for used/remaining photos and join link
- Fix array-to-string conversion error in join link modal
2026-01-21 11:20:22 +01:00
Codex Agent
a038594130 Widen marketing demo frame
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (push) Has been cancelled
tests / ui (push) Has been cancelled
2026-01-21 10:58:48 +01:00
Codex Agent
9bab5f6c89 Use marketing demo flag for demo page
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (push) Has been cancelled
tests / ui (push) Has been cancelled
2026-01-21 10:55:21 +01:00
Codex Agent
ebab856137 Fix event package display and add missing translations
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (push) Has been cancelled
tests / ui (push) Has been cancelled
- Correct Event->eventPackage relationship to HasOne
- Add missing translations for event packages and table columns
2026-01-21 10:50:51 +01:00
Codex Agent
fa33e7cbcf Fix Event & EventType resource issues and apply formatting
- Fix EventType deletion error handling (constraint violations)
- Fix Event update error (package_id column missing)
- Fix Event Type dropdown options (JSON display issue)
- Fix EventPackagesRelationManager query error
- Add missing translations for deletion errors
- Apply Pint formatting
2026-01-21 10:34:06 +01:00
Codex Agent
198fbf6751 Hide add FAB at task limit
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (push) Has been cancelled
tests / ui (push) Has been cancelled
2026-01-21 10:30:12 +01:00
Codex Agent
246e54f970 Update task mode UI details
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (push) Has been cancelled
tests / ui (push) Has been cancelled
2026-01-21 10:19:34 +01:00
Codex Agent
1c5412e82c Enforce task limits and update event form
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (push) Has been cancelled
tests / ui (push) Has been cancelled
2026-01-21 09:49:30 +01:00
Codex Agent
0b1430e64d Refine control room filter pill styling
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (push) Has been cancelled
tests / ui (push) Has been cancelled
2026-01-20 16:43:03 +01:00
Codex Agent
52c2aa0e9b Update control room filter pill styling
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (push) Has been cancelled
tests / ui (push) Has been cancelled
2026-01-20 16:20:54 +01:00
Codex Agent
dd459aa381 Replace control room filters with count bar
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (push) Has been cancelled
tests / ui (push) Has been cancelled
2026-01-20 16:12:29 +01:00
Codex Agent
02ec14a0d3 Collapse upload settings by default
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (push) Has been cancelled
tests / ui (push) Has been cancelled
2026-01-20 16:01:33 +01:00
Codex Agent
e490f9995c Refine control room upload settings UI defaults
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (push) Has been cancelled
tests / ui (push) Has been cancelled
2026-01-20 15:59:11 +01:00
Codex Agent
5e5b69f655 Add control room automations and uploader overrides
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (push) Has been cancelled
tests / ui (push) Has been cancelled
2026-01-20 15:49:04 +01:00
Codex Agent
e5e74febbd Shrink control room photo actions
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (push) Has been cancelled
tests / ui (push) Has been cancelled
2026-01-20 14:02:49 +01:00
Codex Agent
5674ed99f1 Add compact control room photo grid
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (push) Has been cancelled
tests / ui (push) Has been cancelled
2026-01-20 13:53:53 +01:00
Codex Agent
6ab24e65a1 Refine event status filter styling
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (push) Has been cancelled
tests / ui (push) Has been cancelled
2026-01-20 13:29:43 +01:00
Codex Agent
d7ba1880dc Integrate status filters into event list
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (push) Has been cancelled
tests / ui (push) Has been cancelled
2026-01-20 13:25:21 +01:00
Codex Agent
9d8f01d294 Refresh event overview list UI
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (push) Has been cancelled
tests / ui (push) Has been cancelled
2026-01-20 13:21:39 +01:00
Codex Agent
f88aa40315 Clarify watermark features across packages
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (push) Has been cancelled
tests / ui (push) Has been cancelled
2026-01-20 13:10:49 +01:00
Codex Agent
cb5d5a2870 Gate event create FAB by package quota
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (push) Has been cancelled
tests / ui (push) Has been cancelled
2026-01-20 12:54:16 +01:00
Codex Agent
e28eb9a90b Fix event search filtering
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (push) Has been cancelled
tests / ui (push) Has been cancelled
2026-01-20 12:41:14 +01:00
Codex Agent
3c2ebdbc0e Fix sticky tasks toolbar layout
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (push) Has been cancelled
tests / ui (push) Has been cancelled
2026-01-20 11:37:20 +01:00
Codex Agent
a916bf8c4d Compact tasks hero and harden sticky toolbar
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (push) Has been cancelled
tests / ui (push) Has been cancelled
2026-01-20 10:59:58 +01:00
Codex Agent
7a71efedd1 Fix sticky task search bar
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (push) Has been cancelled
tests / ui (push) Has been cancelled
2026-01-20 09:04:48 +01:00
Codex Agent
e1221e0466 Clarify photo task wording in admin UI
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (push) Has been cancelled
tests / ui (push) Has been cancelled
2026-01-20 08:49:34 +01:00
Codex Agent
508c8201fa Update photo task labels and filters
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (push) Has been cancelled
tests / ui (push) Has been cancelled
2026-01-20 08:30:40 +01:00
Codex Agent
750acb0bec Allow task attach search across global tasks
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (push) Has been cancelled
tests / ui (push) Has been cancelled
2026-01-19 21:42:09 +01:00
Codex Agent
42f6178b6d Fix task collection attach relation
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (push) Has been cancelled
tests / ui (push) Has been cancelled
2026-01-19 21:33:38 +01:00
Codex Agent
802e360c8e Use full pages for task collections
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (push) Has been cancelled
tests / ui (push) Has been cancelled
2026-01-19 21:26:30 +01:00
Codex Agent
7030e8b5b9 Add superadmin task collections resource
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (push) Has been cancelled
tests / ui (push) Has been cancelled
2026-01-19 21:19:37 +01:00
Codex Agent
b61507ea04 Hochzeitsaufgaben auf 44 reduziert und Spezialthemenpakete vorbereitet. 2026-01-19 19:45:48 +01:00
Codex Agent
dfaf21898a chore: sync bd issues
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (push) Has been cancelled
tests / ui (push) Has been cancelled
2026-01-19 18:50:51 +01:00
Codex Agent
fbd48afbd6 feat: add task multi-select on long-press
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (push) Has been cancelled
tests / ui (push) Has been cancelled
2026-01-19 18:49:40 +01:00
Codex Agent
6f6d8901ec Route /api requests to Laravel in nginx
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (push) Has been cancelled
tests / ui (push) Has been cancelled
2026-01-19 14:27:31 +01:00
Codex Agent
d4ab9a3a20 Adjust watermark permissions and transparency
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (push) Has been cancelled
tests / ui (push) Has been cancelled
2026-01-19 13:45:43 +01:00
Codex Agent
fbff2afa3e Update admin PWA events, branding, and packages
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (push) Has been cancelled
tests / ui (push) Has been cancelled
2026-01-19 11:35:38 +01:00
Codex Agent
926bc7d070 feat(admin-pwa): add floating action button to event form
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (push) Has been cancelled
tests / ui (push) Has been cancelled
2026-01-18 11:33:09 +01:00
Codex Agent
f1f552ad2d fix(admin-pwa): fix location saving and dashboard refresh delay
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (push) Has been cancelled
tests / ui (push) Has been cancelled
2026-01-18 11:14:42 +01:00
Codex Agent
4219daba25 feat(admin-pwa): modernize dashboard KPI section with unified glass strip
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (push) Has been cancelled
tests / ui (push) Has been cancelled
2026-01-18 11:02:04 +01:00
Codex Agent
1e821a2fb4 refactor(dashboard): refine setup checklist UI
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (push) Has been cancelled
tests / ui (push) Has been cancelled
- Removed progress bar from hero for cleaner look
- Made setup checklist collapsible (auto-collapsed when complete)
- Improved checklist item styling with active/inactive states
2026-01-18 10:08:39 +01:00
Codex Agent
48d4716ab1 feat(dashboard): implement transparent setup roadmap and fix translations
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (push) Has been cancelled
tests / ui (push) Has been cancelled
- Added SetupChecklist component for clear progress visualization
- Refactored LifecycleHero to show readiness state
- Fixed remaining untranslated keys in tool grid and readiness hook
2026-01-18 10:02:59 +01:00
Codex Agent
45f0cea264 feat(mobile): implement event switcher sheet in header
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (push) Has been cancelled
tests / ui (push) Has been cancelled
- Replaced direct navigation with a bottom sheet for event switching
- Created reusable EventSwitcherSheet component
- Preserves context when switching events
2026-01-17 19:17:19 +01:00
Codex Agent
9d7990fe71 fix(dashboard): correct translation keys for tasks, settings, analytics
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (push) Has been cancelled
tests / ui (push) Has been cancelled
- Updated grid item labels to use valid i18next keys
- Ensured consistent German localization for all dashboard widgets
2026-01-17 18:29:01 +01:00
Codex Agent
0c5939e541 fix(dashboard): resolve missing translations and refine alert styling
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (push) Has been cancelled
tests / ui (push) Has been cancelled
- Updated useEventReadiness hook to use 'Bearbeiten' instead of untranslated string
- Fixed 'guestsBlocked' literal appearing in alerts by passing translator correctly
- Refined limit warning styles to respect danger tone
- Localized pulse strip labels (Fotos, Gäste) properly
2026-01-17 18:06:14 +01:00
Codex Agent
e7e095cec9 fix(theme): correct text color mapping for light/dark modes
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (push) Has been cancelled
tests / ui (push) Has been cancelled
- Updated useAdminTheme to derive muted/subtle colors from theme.color using alpha
- Fixed issue where muted text was invisible in light mode
- Updated global gradients to match new Slate palette
2026-01-17 16:39:22 +01:00
Codex Agent
d905ba8e6c fix(admin): refine dashboard translations and label mapping
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (push) Has been cancelled
tests / ui (push) Has been cancelled
- Mapped 'Photobooth' and 'Guests' grid items to correct translation keys
- Localized pulse strip labels (Fotos, Gäste)
- Updated readiness hook to use translated CTAs
2026-01-17 16:35:30 +01:00
Codex Agent
40bed1e44e feat(admin): modernize tenant admin PWA with cockpit layout and slate theme
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (push) Has been cancelled
tests / ui (push) Has been cancelled
- Replaced rainbow grid with phase-aware cockpit layout
- Implemented smart lifecycle hero with readiness logic
- Introduced dark command bar header with context pill and search placeholder
- Updated global Tamagui theme to slate/indigo palette
- Refined bottom navigation with minimalist spotlight style
2026-01-17 14:46:19 +01:00
Codex Agent
7e77dd2931 Refresh mobile dashboard and header
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (push) Has been cancelled
tests / ui (push) Has been cancelled
2026-01-16 22:06:41 +01:00
Codex Agent
b316beb522 Allow partial event updates
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (push) Has been cancelled
tests / ui (push) Has been cancelled
2026-01-16 15:12:03 +01:00
Codex Agent
6d3f4f36e8 Update tasks toggle copy 2026-01-16 15:06:48 +01:00
Codex Agent
9e4ea3dafb Add tasks toggle card 2026-01-16 14:58:24 +01:00
Codex Agent
1517eb8631 Add tasks setup nudge and prompt
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (push) Has been cancelled
tests / ui (push) Has been cancelled
2026-01-16 14:41:09 +01:00
Codex Agent
9a4ece33bf Refresh event list after create
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (push) Has been cancelled
tests / ui (push) Has been cancelled
2026-01-16 14:25:52 +01:00
Codex Agent
30c653913d Show endcustomer event allowance on dashboard
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (push) Has been cancelled
tests / ui (push) Has been cancelled
2026-01-16 14:17:27 +01:00
Codex Agent
4c37f874bd Preserve null remaining_events in package normalization
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (push) Has been cancelled
tests / ui (push) Has been cancelled
2026-01-16 14:11:44 +01:00
Codex Agent
05fdda811b Avoid billing redirect for endcustomer packages
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (push) Has been cancelled
tests / ui (push) Has been cancelled
2026-01-16 14:05:20 +01:00
Codex Agent
eeeca0eed5 Show event-per-purchase for endcustomer packages
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (push) Has been cancelled
tests / ui (push) Has been cancelled
2026-01-16 14:00:12 +01:00
Codex Agent
fa6a5678f0 Set starter event quota in package seeder
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (push) Has been cancelled
tests / ui (push) Has been cancelled
2026-01-16 13:56:03 +01:00
Codex Agent
63956087a4 Fix demo starter package seeding
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (push) Has been cancelled
tests / ui (push) Has been cancelled
2026-01-16 13:52:32 +01:00
Codex Agent
a3f153de6f Allow dashboard access with active package
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (push) Has been cancelled
tests / ui (push) Has been cancelled
2026-01-16 13:44:58 +01:00
Codex Agent
8d729c6a86 Fix dashboard empty state permissions
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (push) Has been cancelled
tests / ui (push) Has been cancelled
2026-01-16 13:39:27 +01:00
Codex Agent
7ad43a3661 Sync bd issues
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (push) Has been cancelled
tests / ui (push) Has been cancelled
2026-01-16 13:36:29 +01:00
Codex Agent
7aa0a4c847 Enforce tenant member permissions
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (push) Has been cancelled
tests / ui (push) Has been cancelled
2026-01-16 13:33:36 +01:00
Codex Agent
df60be826d Sync beads issues
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (push) Has been cancelled
tests / ui (push) Has been cancelled
2026-01-16 12:15:38 +01:00
Codex Agent
918bff08aa Fix auth translations and admin PWA UI
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (push) Has been cancelled
tests / ui (push) Has been cancelled
2026-01-16 12:14:53 +01:00
Codex Agent
292c8f0b26 Refine admin PWA layout and tamagui usage
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (push) Has been cancelled
tests / ui (push) Has been cancelled
2026-01-15 22:24:10 +01:00
Codex Agent
11018f273d chore: sync bd issues
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (push) Has been cancelled
tests / ui (push) Has been cancelled
2026-01-15 19:54:53 +01:00
Codex Agent
7e32d8f706 feat: update package copy and admin control room 2026-01-15 19:54:04 +01:00
Codex Agent
ad829ae509 Update partner packages, copy, and demo switcher
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (push) Has been cancelled
tests / ui (push) Has been cancelled
2026-01-15 17:33:36 +01:00
Codex Agent
2f93271d94 Route billing upgrade CTA to package shop
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (push) Has been cancelled
tests / ui (push) Has been cancelled
2026-01-15 10:28:18 +01:00
Codex Agent
62255dc9e7 Add missing branding watermark translations
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (push) Has been cancelled
tests / ui (push) Has been cancelled
2026-01-15 10:21:17 +01:00
Codex Agent
738659112d Add upgrade CTAs for branding and watermarks
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (push) Has been cancelled
tests / ui (push) Has been cancelled
2026-01-15 10:17:05 +01:00
Codex Agent
89d9b656de Add watermark tier labels to marketing translations
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (push) Has been cancelled
tests / ui (push) Has been cancelled
2026-01-15 09:58:02 +01:00
Codex Agent
5d0ae0faa5 Customize watermark labels in package comparison
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (push) Has been cancelled
tests / ui (push) Has been cancelled
2026-01-15 09:54:47 +01:00
Codex Agent
2ecd417b55 Enable watermarks for premium package
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (push) Has been cancelled
tests / ui (push) Has been cancelled
2026-01-15 09:48:44 +01:00
Codex Agent
3755213010 Align demo seed branding defaults
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (push) Has been cancelled
tests / ui (push) Has been cancelled
2026-01-15 09:38:24 +01:00
Codex Agent
9cb236f123 Update default branding palette for tenants and guests
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (push) Has been cancelled
tests / ui (push) Has been cancelled
2026-01-15 09:32:51 +01:00
Codex Agent
10232cf40e Adjust default branding accent color
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (push) Has been cancelled
tests / ui (push) Has been cancelled
2026-01-15 09:23:34 +01:00
Codex Agent
3ce6507268 Collapse branding controls on default mode
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (push) Has been cancelled
tests / ui (push) Has been cancelled
2026-01-15 09:17:06 +01:00
Codex Agent
a39295a0f0 Fix branding translations in locale overrides
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (push) Has been cancelled
tests / ui (push) Has been cancelled
2026-01-15 09:06:49 +01:00
Codex Agent
5dc69fb187 Adopt Tamagui sliders in admin
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (push) Has been cancelled
tests / ui (push) Has been cancelled
2026-01-15 09:01:43 +01:00
Codex Agent
92b341bdcd Use Tamagui slider for branding controls
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (push) Has been cancelled
tests / ui (push) Has been cancelled
2026-01-15 08:58:09 +01:00
Codex Agent
725a7a29b3 Refine branding labels and access checks
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (push) Has been cancelled
tests / ui (push) Has been cancelled
2026-01-15 08:51:06 +01:00
Codex Agent
8634d16359 Expand branding controls and logo upload
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (push) Has been cancelled
tests / ui (push) Has been cancelled
2026-01-15 08:42:20 +01:00
Codex Agent
81446b37c3 Wire guest branding theme 2026-01-15 08:06:21 +01:00
Codex Agent
33e46b448d Match gallery preview filters and tiles to gallery
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (push) Has been cancelled
tests / ui (push) Has been cancelled
2026-01-14 16:07:29 +01:00
Codex Agent
289ef70e53 Remove gallery route padding
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (push) Has been cancelled
tests / ui (push) Has been cancelled
2026-01-14 15:58:40 +01:00
Codex Agent
d0559bf8c9 Align gallery layout with achievements structure
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (push) Has been cancelled
tests / ui (push) Has been cancelled
2026-01-14 15:50:48 +01:00
Codex Agent
0ef4b32bf6 Match gallery layout to achievements spacing
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (push) Has been cancelled
tests / ui (push) Has been cancelled
2026-01-14 15:44:32 +01:00
Codex Agent
3612c97e86 Tighten gallery spacing and add filter dividers
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (push) Has been cancelled
tests / ui (push) Has been cancelled
2026-01-14 15:08:28 +01:00
Codex Agent
c0510581c6 Tighten gallery filters and badge placement
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (push) Has been cancelled
tests / ui (push) Has been cancelled
2026-01-14 14:04:31 +01:00
Codex Agent
1ffd3e3b9d Fix gallery section closing tag
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (push) Has been cancelled
tests / ui (push) Has been cancelled
2026-01-14 13:56:00 +01:00
Codex Agent
e05ee3b186 Unify gallery header and grid section
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (push) Has been cancelled
tests / ui (push) Has been cancelled
2026-01-14 13:53:35 +01:00
Codex Agent
cf7b2e563a Unify gallery layout and reduce image overlays
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (push) Has been cancelled
tests / ui (push) Has been cancelled
2026-01-14 12:40:55 +01:00
Codex Agent
719afb6920 Refresh gallery layout and tile styling
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (push) Has been cancelled
tests / ui (push) Has been cancelled
2026-01-14 11:48:40 +01:00
Codex Agent
83c58358a1 Show photobooth filter only when enabled
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (push) Has been cancelled
tests / ui (push) Has been cancelled
2026-01-14 11:45:29 +01:00
Codex Agent
2b888078a0 Modernize gallery UI and fix nav motion
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (push) Has been cancelled
tests / ui (push) Has been cancelled
2026-01-14 11:42:12 +01:00
Codex Agent
2f584162d6 Avoid hidden gallery content on tab navigation
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (push) Has been cancelled
tests / ui (push) Has been cancelled
2026-01-14 11:36:02 +01:00
Codex Agent
0833ea6b36 Skip hidden initial motion on achievements tab nav
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (push) Has been cancelled
tests / ui (push) Has been cancelled
2026-01-14 11:32:54 +01:00
Codex Agent
5bdc15d399 Tune guest route transition animations
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (push) Has been cancelled
tests / ui (push) Has been cancelled
2026-01-14 11:30:03 +01:00
Codex Agent
693540f609 Avoid task page hidden animation on tab navigation
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (push) Has been cancelled
tests / ui (push) Has been cancelled
2026-01-14 11:25:43 +01:00
Codex Agent
c0193c9581 Deduplicate guest tasks list and restore header icon
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (push) Has been cancelled
tests / ui (push) Has been cancelled
2026-01-14 11:17:35 +01:00
Codex Agent
03c7b20cae Improve guest help routing and loading
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (push) Has been cancelled
tests / ui (push) Has been cancelled
2026-01-14 09:00:12 +01:00
Codex Agent
3a78c4f2c0 Ensure help sync creates cache directory
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (push) Has been cancelled
tests / ui (push) Has been cancelled
2026-01-14 08:17:49 +01:00
Codex Agent
fa333deed9 Ensure storage subdirs exist on boot 2026-01-13 22:49:47 +01:00
Codex Agent
a733df6221 Add symfony/yaml for help sync
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (push) Has been cancelled
tests / ui (push) Has been cancelled
2026-01-13 22:38:09 +01:00
Codex Agent
5ee1baa7e2 Fix forwarded host/port for signed URLs
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (push) Has been cancelled
tests / ui (push) Has been cancelled
2026-01-13 22:30:10 +01:00
Codex Agent
2f19752199 chore: sync beads
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (push) Has been cancelled
tests / ui (push) Has been cancelled
2026-01-13 21:33:26 +01:00
Codex Agent
7dd7ec14a4 chore: sync beads 2026-01-13 21:32:39 +01:00
Codex Agent
d9568be579 Fix proxy headers and help sync boot
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (push) Has been cancelled
tests / ui (push) Has been cancelled
2026-01-13 21:31:46 +01:00
Codex Agent
9cf6e9d94d Add photobooth email translations
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (push) Has been cancelled
tests / ui (push) Has been cancelled
2026-01-13 16:40:00 +01:00
Codex Agent
a23ce0c86f Set locale on photobooth mail
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (push) Has been cancelled
tests / ui (push) Has been cancelled
2026-01-13 16:24:29 +01:00
Codex Agent
9efea136bd Normalize photobooth mail locale
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (push) Has been cancelled
tests / ui (push) Has been cancelled
2026-01-13 15:37:26 +01:00
Codex Agent
7a6f489b8b Add tenant admin account edit page
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (push) Has been cancelled
tests / ui (push) Has been cancelled
2026-01-13 15:09:25 +01:00
Codex Agent
cc11e024f0 Add photobooth folder presets
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (push) Has been cancelled
tests / ui (push) Has been cancelled
2026-01-13 12:00:39 +01:00
Codex Agent
2089251a92 Extend uploader profiles, filters, and diagnostics
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (push) Has been cancelled
tests / ui (push) Has been cancelled
2026-01-13 11:26:04 +01:00
Codex Agent
53094b8d36 Add filters, throttling, and connection test
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (push) Has been cancelled
tests / ui (push) Has been cancelled
2026-01-13 11:15:57 +01:00
Codex Agent
0c33c1ddc1 Persist upload queue and uploaded cache
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (push) Has been cancelled
tests / ui (push) Has been cancelled
2026-01-13 11:12:26 +01:00
Codex Agent
ce0b7c951a Update beads issues for uploader epic
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (push) Has been cancelled
tests / ui (push) Has been cancelled
2026-01-13 11:08:39 +01:00
Codex Agent
fbbbbdac4c Add upload retries and richer errors 2026-01-13 11:08:26 +01:00
Codex Agent
94d0713ec0 Add manual uploader credentials fields
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (push) Has been cancelled
tests / ui (push) Has been cancelled
2026-01-13 10:56:33 +01:00
Codex Agent
3e36354916 Restructure photobooth page flow
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (push) Has been cancelled
tests / ui (push) Has been cancelled
2026-01-13 10:52:50 +01:00
Codex Agent
24a1319cc2 Add photobooth uploader download email
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (push) Has been cancelled
tests / ui (push) Has been cancelled
2026-01-13 09:59:39 +01:00
Codex Agent
b1250c6246 Collapse photobooth credentials
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (push) Has been cancelled
tests / ui (push) Has been cancelled
2026-01-13 08:59:24 +01:00
Codex Agent
fd7a3c846a Add uploader downloads for Windows macOS Linux
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (push) Has been cancelled
tests / ui (push) Has been cancelled
2026-01-13 08:49:08 +01:00
Codex Agent
1ca7545f86 Add photobooth uploader build service
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (push) Has been cancelled
tests / ui (push) Has been cancelled
2026-01-13 08:37:26 +01:00
Codex Agent
9f4a202d2b Add Windows app icon
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (push) Has been cancelled
tests / ui (push) Has been cancelled
2026-01-13 08:12:11 +01:00
Codex Agent
fe0525e678 Fix uploader header layout
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (push) Has been cancelled
tests / ui (push) Has been cancelled
2026-01-12 21:26:17 +01:00
Codex Agent
d62efdb55c Refresh uploader UI styling
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (push) Has been cancelled
tests / ui (push) Has been cancelled
2026-01-12 21:15:55 +01:00
Codex Agent
be722f6e37 Remember uploader window size
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (push) Has been cancelled
tests / ui (push) Has been cancelled
2026-01-12 21:03:27 +01:00
Codex Agent
898ac9ff0e Add uploader advanced settings and live status
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (push) Has been cancelled
tests / ui (push) Has been cancelled
2026-01-12 20:50:39 +01:00
Codex Agent
c8d1ac7971 Improve uploader client connection and diagnostics
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (push) Has been cancelled
tests / ui (push) Has been cancelled
2026-01-12 20:40:40 +01:00
Codex Agent
3ee23f3a66 Add uploader branding
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (push) Has been cancelled
tests / ui (push) Has been cancelled
2026-01-12 20:28:49 +01:00
Codex Agent
993c351832 Remove response format from uploader UI
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (push) Has been cancelled
tests / ui (push) Has been cancelled
2026-01-12 20:22:45 +01:00
Codex Agent
2444a62a4d Show connect code expiry time
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (push) Has been cancelled
tests / ui (push) Has been cancelled
2026-01-12 20:14:42 +01:00
Codex Agent
e52720a3cb Rename photobooth upload endpoint
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (push) Has been cancelled
tests / ui (push) Has been cancelled
2026-01-12 20:05:09 +01:00
Codex Agent
93bed358ba Remove sparkbooth option from photobooth UI
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (push) Has been cancelled
tests / ui (push) Has been cancelled
2026-01-12 19:50:30 +01:00
Codex Agent
a16bd9c498 Relabel photobooth uploader mode
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (push) Has been cancelled
tests / ui (push) Has been cancelled
2026-01-12 18:46:41 +01:00
Codex Agent
e32b1fa45a Add photobooth connect code UI
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (push) Has been cancelled
tests / ui (push) Has been cancelled
2026-01-12 17:59:35 +01:00
Codex Agent
6edc890e01 Configure beads sync branch and ignore artifacts
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (push) Has been cancelled
tests / ui (push) Has been cancelled
2026-01-12 17:46:39 +01:00
917 changed files with 523855 additions and 456807 deletions

6
.beads/.gitignore vendored
View File

@@ -11,6 +11,12 @@ daemon.log
daemon.pid daemon.pid
bd.sock bd.sock
sync-state.json sync-state.json
.sync.lock
last-touched
sync_base.jsonl
.sync.lock
last-touched
sync_base.jsonl
# Local version tracking (prevents upgrade notification spam after git ops) # Local version tracking (prevents upgrade notification spam after git ops)
.local_version .local_version

View File

@@ -42,7 +42,7 @@
# This setting persists across clones (unlike database config which is gitignored). # This setting persists across clones (unlike database config which is gitignored).
# Can also use BEADS_SYNC_BRANCH env var for local override. # Can also use BEADS_SYNC_BRANCH env var for local override.
# If not set, bd sync will require you to run 'bd config set sync.branch <branch>'. # If not set, bd sync will require you to run 'bd config set sync.branch <branch>'.
# sync-branch: "beads-sync" sync-branch: "beads-sync"
# Multi-repo configuration (experimental - bd-307) # Multi-repo configuration (experimental - bd-307)
# Allows hydrating from multiple repositories and routing writes to the correct JSONL # Allows hydrating from multiple repositories and routing writes to the correct JSONL

View File

@@ -1 +0,0 @@
fotospiel-app-29r

View File

@@ -1,4 +1,5 @@
{ {
"database": "beads.db", "database": "beads.db",
"jsonl_export": "issues.jsonl" "jsonl_export": "issues.jsonl",
"last_bd_version": "0.49.0"
} }

View File

@@ -97,6 +97,11 @@ GOOGLE_CLIENT_ID=
GOOGLE_CLIENT_SECRET= GOOGLE_CLIENT_SECRET=
GOOGLE_REDIRECT_URI=${APP_URL}/checkout/auth/google/callback GOOGLE_REDIRECT_URI=${APP_URL}/checkout/auth/google/callback
# Facebook OAuth (Checkout comfort login)
FACEBOOK_CLIENT_ID=
FACEBOOK_CLIENT_SECRET=
FACEBOOK_REDIRECT_URI=${APP_URL}/checkout/auth/facebook/callback
VITE_APP_NAME="${APP_NAME}" VITE_APP_NAME="${APP_NAME}"
VITE_ENABLE_TENANT_SWITCHER=false VITE_ENABLE_TENANT_SWITCHER=false
REVENUECAT_WEBHOOK_SECRET= REVENUECAT_WEBHOOK_SECRET=
@@ -112,14 +117,22 @@ PAYPAL_CLIENT_ID=
PAYPAL_SECRET= PAYPAL_SECRET=
PAYPAL_SANDBOX=true PAYPAL_SANDBOX=true
# Paddle Billing # Lemon Squeezy Billing
PADDLE_SANDBOX=true LEMONSQUEEZY_STORE_ID=284860
PADDLE_API_KEY= LEMONSQUEEZY_API_KEY=
PADDLE_CLIENT_ID= LEMONSQUEEZY_WEBHOOK_SECRET=
PADDLE_WEBHOOK_SECRET= LEMONSQUEEZY_WEBHOOK_EVENTS=
PADDLE_PUBLIC_KEY= LEMONSQUEEZY_TEST_MODE=false
PADDLE_BASE_URL= LEMONSQUEEZY_BASE_URL=https://api.lemonsqueezy.com/v1
PADDLE_CONSOLE_URL= LEMONSQUEEZY_GIFT_VARIANT_STARTER=
LEMONSQUEEZY_GIFT_VARIANT_STARTER_USD=
LEMONSQUEEZY_GIFT_VARIANT_STARTER_GBP=
LEMONSQUEEZY_GIFT_VARIANT_STANDARD=
LEMONSQUEEZY_GIFT_VARIANT_STANDARD_USD=
LEMONSQUEEZY_GIFT_VARIANT_STANDARD_GBP=
LEMONSQUEEZY_GIFT_VARIANT_PREMIUM=
LEMONSQUEEZY_GIFT_VARIANT_PREMIUM_USD=
LEMONSQUEEZY_GIFT_VARIANT_PREMIUM_GBP=
# Sanctum / SPA auth # Sanctum / SPA auth
SANCTUM_STATEFUL_DOMAINS=localhost,localhost:3000 SANCTUM_STATEFUL_DOMAINS=localhost,localhost:3000
@@ -187,5 +200,9 @@ STORAGE_QUEUE_PENDING_EVENT_MINUTES=8
STORAGE_QUEUE_FAILED_EVENT_THRESHOLD=2 STORAGE_QUEUE_FAILED_EVENT_THRESHOLD=2
STORAGE_QUEUE_FAILED_EVENT_MINUTES=30 STORAGE_QUEUE_FAILED_EVENT_MINUTES=30
STORAGE_QUEUE_GUEST_ALERT_TTL=30 STORAGE_QUEUE_GUEST_ALERT_TTL=30
STORAGE_CHECKSUM_VALIDATION=true
STORAGE_CHECKSUM_ALERT_WINDOW_MINUTES=60
STORAGE_CHECKSUM_WARNING=1
STORAGE_CHECKSUM_CRITICAL=5

1
.gitignore vendored
View File

@@ -12,6 +12,7 @@ fotospiel-tenant-app
/resources/js/wayfinder /resources/js/wayfinder
/storage/*.key /storage/*.key
/storage/pail /storage/pail
/C:\\wwwroot\\fotospiel-app\\storage\\app/
/vendor /vendor
/clients/photobooth-uploader/**/bin /clients/photobooth-uploader/**/bin
/clients/photobooth-uploader/**/obj /clients/photobooth-uploader/**/obj

View File

@@ -337,8 +337,8 @@ Tokens are design system values that can be referenced using the `$` prefix.
### Color Tokens ### Color Tokens
- `accent`: #FFB6C1 - `accent`: #3D5AFE
- `accentSoft`: #FFE5EC - `accentSoft`: #E8ECFF
- `blue10Dark`: hsl(209, 100%, 60.6%) - `blue10Dark`: hsl(209, 100%, 60.6%)
- `blue10Light`: hsl(208, 100%, 47.3%) - `blue10Light`: hsl(208, 100%, 47.3%)
- `blue11Dark`: hsl(210, 100%, 66.1%) - `blue11Dark`: hsl(210, 100%, 66.1%)
@@ -363,8 +363,8 @@ Tokens are design system values that can be referenced using the `$` prefix.
- `blue8Light`: hsl(206, 81.9%, 65.3%) - `blue8Light`: hsl(206, 81.9%, 65.3%)
- `blue9Dark`: hsl(206, 100%, 50.0%) - `blue9Dark`: hsl(206, 100%, 50.0%)
- `blue9Light`: hsl(206, 100%, 50.0%) - `blue9Light`: hsl(206, 100%, 50.0%)
- `border`: #F2E4DA - `border`: #F3D6C9
- `danger`: #E04848 - `danger`: #EF4444
- `gray10Dark`: hsl(0, 0%, 49.4%) - `gray10Dark`: hsl(0, 0%, 49.4%)
- `gray10Light`: hsl(0, 0%, 52.3%) - `gray10Light`: hsl(0, 0%, 52.3%)
- `gray11Dark`: hsl(0, 0%, 62.8%) - `gray11Dark`: hsl(0, 0%, 62.8%)
@@ -413,7 +413,7 @@ Tokens are design system values that can be referenced using the `$` prefix.
- `green8Light`: hsl(151, 40.2%, 54.1%) - `green8Light`: hsl(151, 40.2%, 54.1%)
- `green9Dark`: hsl(151, 55.0%, 41.5%) - `green9Dark`: hsl(151, 55.0%, 41.5%)
- `green9Light`: hsl(151, 55.0%, 41.5%) - `green9Light`: hsl(151, 55.0%, 41.5%)
- `muted`: #F4ECE8 - `muted`: #FFF6F0
- `orange10Dark`: hsl(24, 100%, 58.5%) - `orange10Dark`: hsl(24, 100%, 58.5%)
- `orange10Light`: hsl(24, 100%, 46.5%) - `orange10Light`: hsl(24, 100%, 46.5%)
- `orange11Dark`: hsl(24, 100%, 62.2%) - `orange11Dark`: hsl(24, 100%, 62.2%)
@@ -462,7 +462,7 @@ Tokens are design system values that can be referenced using the `$` prefix.
- `pink8Light`: hsl(323, 60.3%, 72.4%) - `pink8Light`: hsl(323, 60.3%, 72.4%)
- `pink9Dark`: hsl(322, 65.0%, 54.5%) - `pink9Dark`: hsl(322, 65.0%, 54.5%)
- `pink9Light`: hsl(322, 65.0%, 54.5%) - `pink9Light`: hsl(322, 65.0%, 54.5%)
- `primary`: #FF5A5F - `primary`: #FF5C5C
- `purple10Dark`: hsl(273, 57.3%, 59.1%) - `purple10Dark`: hsl(273, 57.3%, 59.1%)
- `purple10Light`: hsl(272, 46.8%, 50.3%) - `purple10Light`: hsl(272, 46.8%, 50.3%)
- `purple11Dark`: hsl(275, 80.0%, 71.0%) - `purple11Dark`: hsl(275, 80.0%, 71.0%)
@@ -511,10 +511,10 @@ Tokens are design system values that can be referenced using the `$` prefix.
- `red8Light`: hsl(359, 69.5%, 74.3%) - `red8Light`: hsl(359, 69.5%, 74.3%)
- `red9Dark`: hsl(358, 75.0%, 59.0%) - `red9Dark`: hsl(358, 75.0%, 59.0%)
- `red9Light`: hsl(358, 75.0%, 59.0%) - `red9Light`: hsl(358, 75.0%, 59.0%)
- `success`: #06D6A0 - `success`: #22C55E
- `surface`: #ffffff - `surface`: #ffffff
- `text`: #1F2937 - `text`: #0B132B
- `warning`: #F5C542 - `warning`: #FBBF24
- `yellow10Dark`: hsl(54, 100%, 68.0%) - `yellow10Dark`: hsl(54, 100%, 68.0%)
- `yellow10Light`: hsl(50, 100%, 48.5%) - `yellow10Light`: hsl(50, 100%, 48.5%)
- `yellow11Dark`: hsl(48, 100%, 47.0%) - `yellow11Dark`: hsl(48, 100%, 47.0%)

File diff suppressed because it is too large Load Diff

File diff suppressed because one or more lines are too long

File diff suppressed because it is too large Load Diff

View File

@@ -118,319 +118,8 @@ var isWindowDefined = typeof window < "u";
var isClient = isWeb && isWindowDefined; var isClient = isWeb && isWindowDefined;
var isChrome = typeof navigator < "u" && /Chrome/.test(navigator.userAgent || ""); var isChrome = typeof navigator < "u" && /Chrome/.test(navigator.userAgent || "");
var isWebTouchable = isClient && ("ontouchstart" in window || navigator.maxTouchPoints > 0); var isWebTouchable = isClient && ("ontouchstart" in window || navigator.maxTouchPoints > 0);
var isAndroid = false;
var isIos = process.env.TEST_NATIVE_PLATFORM === "ios"; var isIos = process.env.TEST_NATIVE_PLATFORM === "ios";
// node_modules/@tamagui/helpers/dist/esm/validStyleProps.mjs
var textColors = {
color: true,
textDecorationColor: true,
textShadowColor: true
};
var tokenCategories = {
radius: {
borderRadius: true,
borderTopLeftRadius: true,
borderTopRightRadius: true,
borderBottomLeftRadius: true,
borderBottomRightRadius: true,
// logical
borderStartStartRadius: true,
borderStartEndRadius: true,
borderEndStartRadius: true,
borderEndEndRadius: true
},
size: {
width: true,
height: true,
minWidth: true,
minHeight: true,
maxWidth: true,
maxHeight: true,
blockSize: true,
minBlockSize: true,
maxBlockSize: true,
inlineSize: true,
minInlineSize: true,
maxInlineSize: true
},
zIndex: {
zIndex: true
},
color: {
backgroundColor: true,
borderColor: true,
borderBlockStartColor: true,
borderBlockEndColor: true,
borderBlockColor: true,
borderBottomColor: true,
borderInlineColor: true,
borderInlineStartColor: true,
borderInlineEndColor: true,
borderTopColor: true,
borderLeftColor: true,
borderRightColor: true,
borderEndColor: true,
borderStartColor: true,
shadowColor: true,
...textColors,
outlineColor: true,
caretColor: true
}
};
var stylePropsUnitless = {
WebkitLineClamp: true,
animationIterationCount: true,
aspectRatio: true,
borderImageOutset: true,
borderImageSlice: true,
borderImageWidth: true,
columnCount: true,
flex: true,
flexGrow: true,
flexOrder: true,
flexPositive: true,
flexShrink: true,
flexNegative: true,
fontWeight: true,
gridRow: true,
gridRowEnd: true,
gridRowGap: true,
gridRowStart: true,
gridColumn: true,
gridColumnEnd: true,
gridColumnGap: true,
gridColumnStart: true,
gridTemplateColumns: true,
gridTemplateAreas: true,
lineClamp: true,
opacity: true,
order: true,
orphans: true,
tabSize: true,
widows: true,
zIndex: true,
zoom: true,
scale: true,
scaleX: true,
scaleY: true,
scaleZ: true,
shadowOpacity: true
};
var stylePropsTransform = {
x: true,
y: true,
scale: true,
perspective: true,
scaleX: true,
scaleY: true,
skewX: true,
skewY: true,
matrix: true,
rotate: true,
rotateY: true,
rotateX: true,
rotateZ: true
};
var stylePropsView = {
backfaceVisibility: true,
borderBottomEndRadius: true,
borderBottomStartRadius: true,
borderBottomWidth: true,
borderLeftWidth: true,
borderRightWidth: true,
borderBlockWidth: true,
borderBlockEndWidth: true,
borderBlockStartWidth: true,
borderInlineWidth: true,
borderInlineEndWidth: true,
borderInlineStartWidth: true,
borderStyle: true,
borderBlockStyle: true,
borderBlockEndStyle: true,
borderBlockStartStyle: true,
borderInlineStyle: true,
borderInlineEndStyle: true,
borderInlineStartStyle: true,
borderTopEndRadius: true,
borderTopStartRadius: true,
borderTopWidth: true,
borderWidth: true,
transform: true,
transformOrigin: true,
alignContent: true,
alignItems: true,
alignSelf: true,
borderEndWidth: true,
borderStartWidth: true,
bottom: true,
display: true,
end: true,
flexBasis: true,
flexDirection: true,
flexWrap: true,
gap: true,
columnGap: true,
rowGap: true,
justifyContent: true,
left: true,
margin: true,
marginBlock: true,
marginBlockEnd: true,
marginBlockStart: true,
marginInline: true,
marginInlineStart: true,
marginInlineEnd: true,
marginBottom: true,
marginEnd: true,
marginHorizontal: true,
marginLeft: true,
marginRight: true,
marginStart: true,
marginTop: true,
marginVertical: true,
overflow: true,
padding: true,
paddingBottom: true,
paddingInline: true,
paddingBlock: true,
paddingBlockStart: true,
paddingInlineEnd: true,
paddingInlineStart: true,
paddingEnd: true,
paddingHorizontal: true,
paddingLeft: true,
paddingRight: true,
paddingStart: true,
paddingTop: true,
paddingVertical: true,
position: true,
right: true,
start: true,
top: true,
inset: true,
insetBlock: true,
insetBlockEnd: true,
insetBlockStart: true,
insetInline: true,
insetInlineEnd: true,
insetInlineStart: true,
direction: true,
shadowOffset: true,
shadowRadius: true,
...tokenCategories.color,
...tokenCategories.radius,
...tokenCategories.size,
...tokenCategories.radius,
...stylePropsTransform,
...stylePropsUnitless,
boxShadow: true,
filter: true,
// RN 0.77+ style props (set REACT_NATIVE_PRE_77=1 for older RN)
...!process.env.REACT_NATIVE_PRE_77 && {
boxSizing: true,
mixBlendMode: true,
outlineColor: true,
outlineSpread: true,
outlineStyle: true,
outlineWidth: true
},
// RN doesn't support specific border styles per-edge
transition: true,
textWrap: true,
backdropFilter: true,
WebkitBackdropFilter: true,
background: true,
backgroundAttachment: true,
backgroundBlendMode: true,
backgroundClip: true,
backgroundColor: true,
backgroundImage: true,
backgroundOrigin: true,
backgroundPosition: true,
backgroundRepeat: true,
backgroundSize: true,
borderBottomStyle: true,
borderImage: true,
borderLeftStyle: true,
borderRightStyle: true,
borderTopStyle: true,
caretColor: true,
clipPath: true,
contain: true,
containerType: true,
content: true,
cursor: true,
float: true,
mask: true,
maskBorder: true,
maskBorderMode: true,
maskBorderOutset: true,
maskBorderRepeat: true,
maskBorderSlice: true,
maskBorderSource: true,
maskBorderWidth: true,
maskClip: true,
maskComposite: true,
maskImage: true,
maskMode: true,
maskOrigin: true,
maskPosition: true,
maskRepeat: true,
maskSize: true,
maskType: true,
objectFit: true,
objectPosition: true,
outlineOffset: true,
overflowBlock: true,
overflowInline: true,
overflowX: true,
overflowY: true,
pointerEvents: true,
scrollbarWidth: true,
textEmphasis: true,
touchAction: true,
transformStyle: true,
userSelect: true,
willChange: true,
...isAndroid ? {
elevationAndroid: true
} : {}
};
var stylePropsFont = {
fontFamily: true,
fontSize: true,
fontStyle: true,
fontWeight: true,
fontVariant: true,
letterSpacing: true,
lineHeight: true,
textTransform: true
};
var stylePropsTextOnly = {
...stylePropsFont,
textAlign: true,
textDecorationLine: true,
textDecorationStyle: true,
...textColors,
textShadowOffset: true,
textShadowRadius: true,
userSelect: true,
selectable: true,
verticalAlign: true,
whiteSpace: true,
wordWrap: true,
textOverflow: true,
textDecorationDistance: true,
cursor: true,
WebkitLineClamp: true,
WebkitBoxOrient: true
};
var stylePropsText = {
...stylePropsView,
...stylePropsTextOnly
};
// node_modules/@tamagui/helpers/dist/esm/withStaticProperties.mjs // node_modules/@tamagui/helpers/dist/esm/withStaticProperties.mjs
var import_react2 = __toESM(require("react"), 1); var import_react2 = __toESM(require("react"), 1);
var Decorated = Symbol(); var Decorated = Symbol();
@@ -755,7 +444,10 @@ var SizableText2 = (0, import_web4.styled)(import_web4.Text, {
} }
}); });
SizableText2.staticConfig.variants.fontFamily = { SizableText2.staticConfig.variants.fontFamily = {
"...": /* @__PURE__ */ __name((_val, extras) => { "...": /* @__PURE__ */ __name((val, extras) => {
if (val === "inherit") return {
fontFamily: "inherit"
};
const sizeProp = extras.props.size, fontSizeProp = extras.props.fontSize, size = sizeProp === "$true" && fontSizeProp ? fontSizeProp : extras.props.size || "$true"; const sizeProp = extras.props.size, fontSizeProp = extras.props.fontSize, size = sizeProp === "$true" && fontSizeProp ? fontSizeProp : extras.props.size || "$true";
return getFontSized(size, extras); return getFontSized(size, extras);
}, "...") }, "...")

View File

@@ -112,7 +112,10 @@ var SizableText2 = (0, import_web2.styled)(import_web2.Text, {
} }
}); });
SizableText2.staticConfig.variants.fontFamily = { SizableText2.staticConfig.variants.fontFamily = {
"...": /* @__PURE__ */ __name((_val, extras) => { "...": /* @__PURE__ */ __name((val, extras) => {
if (val === "inherit") return {
fontFamily: "inherit"
};
const sizeProp = extras.props.size, fontSizeProp = extras.props.fontSize, size = sizeProp === "$true" && fontSizeProp ? fontSizeProp : extras.props.size || "$true"; const sizeProp = extras.props.size, fontSizeProp = extras.props.fontSize, size = sizeProp === "$true" && fontSizeProp ? fontSizeProp : extras.props.size || "$true";
return getFontSized(size, extras); return getFontSized(size, extras);
}, "...") }, "...")

220
AGENTS.md
View File

@@ -27,8 +27,8 @@ This repository hosts a multi-tenant event photo platform (Laravel 12, PHP 8.3,
- Languages/Frameworks: PHP 8.2+ (Laravel 12), TypeScript/JavaScript (React 19/Vite 7/Tailwind 4), Filament 4. - Languages/Frameworks: PHP 8.2+ (Laravel 12), TypeScript/JavaScript (React 19/Vite 7/Tailwind 4), Filament 4.
- Dev Commands: composer, npm, vite, artisan, PHPUnit, Pint/ESLint, Docker/Compose (for dev), Playwright, Vitest, TypeScript. - Dev Commands: composer, npm, vite, artisan, PHPUnit, Pint/ESLint, Docker/Compose (for dev), Playwright, Vitest, TypeScript.
- Libraries: simplesoftwareio/simple-qrcode for server-side QR generation; Paddle API client (custom service) for payments; dompdf for PDF generation; spatie/laravel-translatable for i18n; minishlink/web-push for web push; firebase/php-jwt for JWT; Sentry (Laravel + Vite); Stripe (PHP + JS); Tamagui (design system); i18next (frontend i18n); vite-plugin-pwa for PWA builds. - Libraries: simplesoftwareio/simple-qrcode for server-side QR generation; Lemon Squeezy API client (custom service) for payments; dompdf for PDF generation; spatie/laravel-translatable for i18n; minishlink/web-push for web push; firebase/php-jwt for JWT; Sentry (Laravel + Vite); Stripe (PHP + JS); Tamagui (design system); i18next (frontend i18n); vite-plugin-pwa for PWA builds.
- Payment Systems: Paddle (subscriptions and one-time payments), RevenueCat (mobile app subscriptions), Stripe (legacy/integration use). - Payment Systems: Lemon Squeezy (subscriptions and one-time payments), RevenueCat (mobile app subscriptions), Stripe (legacy/integration use).
- PWA Technologies: React 19, Vite 7, Capacitor (iOS), Trusted Web Activity (Android), Service Workers, Background Sync. - PWA Technologies: React 19, Vite 7, Capacitor (iOS), Trusted Web Activity (Android), Service Workers, Background Sync.
## Repo Structure (high-level) ## Repo Structure (high-level)
@@ -38,6 +38,9 @@ This repository hosts a multi-tenant event photo platform (Laravel 12, PHP 8.3,
- resources/js/admin/ — Tenant Admin PWA source (React 19, Capacitor/TWA ready). - resources/js/admin/ — Tenant Admin PWA source (React 19, Capacitor/TWA ready).
- resources/js/pages/ — Inertia pages (React). - resources/js/pages/ — Inertia pages (React).
- docs/archive/README.md — historical PRP context. - docs/archive/README.md — historical PRP context.
- Marketing frontend language files:
- Source translations: `resources/lang/{de,en}/marketing.php` and `resources/lang/{de,en}/marketing.json`.
- Runtime i18next JSON served to the frontend: `public/lang/{de,en}/marketing.json` (must stay in sync with the source files).
## Standard Workflows ## Standard Workflows
- Coding tasks (Codegen Agent): - Coding tasks (Codegen Agent):
@@ -58,7 +61,7 @@ This repository hosts a multi-tenant event photo platform (Laravel 12, PHP 8.3,
#### Billing & Packages #### Billing & Packages
- package:check-status — check event package status. - package:check-status — check event package status.
- packages:migrate-legacy — migrate legacy package purchases. - packages:migrate-legacy — migrate legacy package purchases.
- paddle:sync-packages — sync packages with Paddle (push/pull/queue/dry-run). - lemonsqueezy:sync-packages — sync packages with Lemon Squeezy (push/pull/queue/dry-run).
- coupons:export — export coupon redemptions. - coupons:export — export coupon redemptions.
- checkout:send-reminders — send abandoned checkout reminders (dry-run supported). - checkout:send-reminders — send abandoned checkout reminders (dry-run supported).
@@ -93,7 +96,7 @@ This repository hosts a multi-tenant event photo platform (Laravel 12, PHP 8.3,
- metrics:package-limits — inspect/reset package limit metrics (routes/console.php). - metrics:package-limits — inspect/reset package limit metrics (routes/console.php).
- inspire — inspiring quote (routes/console.php). - inspire — inspiring quote (routes/console.php).
- Public APIs for Guest PWA: stats/photos endpoints with ETag; likes; uploads; see docs/archive/prp/03-api.md. - Public APIs for Guest PWA: stats/photos endpoints with ETag; likes; uploads; see docs/archive/prp/03-api.md.
- Payment Integration: Paddle webhooks, RevenueCat mobile subscriptions. - Payment Integration: Lemon Squeezy webhooks, RevenueCat mobile subscriptions.
## PWA Architecture ## PWA Architecture
- Guest PWA: Offline-first photo sharing app for event attendees (installable, background sync, no account required). - Guest PWA: Offline-first photo sharing app for event attendees (installable, background sync, no account required).
@@ -129,7 +132,7 @@ The Laravel Boost guidelines are specifically curated by Laravel maintainers for
## Foundational Context ## Foundational Context
This application is a Laravel application and its main Laravel ecosystems package & versions are below. You are an expert with them all. Ensure you abide by these specific packages & versions. This application is a Laravel application and its main Laravel ecosystems package & versions are below. You are an expert with them all. Ensure you abide by these specific packages & versions.
- php - 8.3.24 - php - 8.3.6
- filament/filament (FILAMENT) - v4 - filament/filament (FILAMENT) - v4
- inertiajs/inertia-laravel (INERTIA) - v2 - inertiajs/inertia-laravel (INERTIA) - v2
- laravel/framework (LARAVEL) - v12 - laravel/framework (LARAVEL) - v12
@@ -151,7 +154,7 @@ This application is a Laravel application and its main Laravel ecosystems packag
- prettier (PRETTIER) - v3 - prettier (PRETTIER) - v3
## Conventions ## Conventions
- You must follow all existing code conventions used in this application. When creating or editing a file, check sibling files for the correct structure, approach, naming. - You must follow all existing code conventions used in this application. When creating or editing a file, check sibling files for the correct structure, approach, and naming.
- Use descriptive names for variables and methods. For example, `isRegisteredForDiscounts`, not `discount()`. - Use descriptive names for variables and methods. For example, `isRegisteredForDiscounts`, not `discount()`.
- Check for existing components to reuse before writing a new one. - Check for existing components to reuse before writing a new one.
@@ -159,7 +162,7 @@ This application is a Laravel application and its main Laravel ecosystems packag
- Do not create verification scripts or tinker when tests cover that functionality and prove it works. Unit and feature tests are more important. - Do not create verification scripts or tinker when tests cover that functionality and prove it works. Unit and feature tests are more important.
## Application Structure & Architecture ## Application Structure & Architecture
- Stick to existing directory structure - don't create new base folders without approval. - Stick to existing directory structure; don't create new base folders without approval.
- Do not change the application's dependencies without approval. - Do not change the application's dependencies without approval.
## Frontend Bundling ## Frontend Bundling
@@ -171,17 +174,16 @@ This application is a Laravel application and its main Laravel ecosystems packag
## Documentation Files ## Documentation Files
- You must only create documentation files if explicitly requested by the user. - You must only create documentation files if explicitly requested by the user.
=== boost rules === === boost rules ===
## Laravel Boost ## Laravel Boost
- Laravel Boost is an MCP server that comes with powerful tools designed specifically for this application. Use them. - Laravel Boost is an MCP server that comes with powerful tools designed specifically for this application. Use them.
## Artisan ## Artisan
- Use the `list-artisan-commands` tool when you need to call an Artisan command to double check the available parameters. - Use the `list-artisan-commands` tool when you need to call an Artisan command to double-check the available parameters.
## URLs ## URLs
- Whenever you share a project URL with the user you should use the `get-absolute-url` tool to ensure you're using the correct scheme, domain / IP, and port. - Whenever you share a project URL with the user, you should use the `get-absolute-url` tool to ensure you're using the correct scheme, domain/IP, and port.
## Tinker / Debugging ## Tinker / Debugging
- You should use the `tinker` tool when you need to execute PHP to debug code or query Eloquent models directly. - You should use the `tinker` tool when you need to execute PHP to debug code or query Eloquent models directly.
@@ -192,22 +194,21 @@ This application is a Laravel application and its main Laravel ecosystems packag
- Only recent browser logs will be useful - ignore old logs. - Only recent browser logs will be useful - ignore old logs.
## Searching Documentation (Critically Important) ## Searching Documentation (Critically Important)
- Boost comes with a powerful `search-docs` tool you should use before any other approaches. This tool automatically passes a list of installed packages and their versions to the remote Boost API, so it returns only version-specific documentation specific for the user's circumstance. You should pass an array of packages to filter on if you know you need docs for particular packages. - Boost comes with a powerful `search-docs` tool you should use before any other approaches when dealing with Laravel or Laravel ecosystem packages. This tool automatically passes a list of installed packages and their versions to the remote Boost API, so it returns only version-specific documentation for the user's circumstance. You should pass an array of packages to filter on if you know you need docs for particular packages.
- The 'search-docs' tool is perfect for all Laravel related packages, including Laravel, Inertia, Livewire, Filament, Tailwind, Pest, Nova, Nightwatch, etc. - The `search-docs` tool is perfect for all Laravel-related packages, including Laravel, Inertia, Livewire, Filament, Tailwind, Pest, Nova, Nightwatch, etc.
- You must use this tool to search for Laravel-ecosystem documentation before falling back to other approaches. - You must use this tool to search for Laravel ecosystem documentation before falling back to other approaches.
- Search the documentation before making code changes to ensure we are taking the correct approach. - Search the documentation before making code changes to ensure we are taking the correct approach.
- Use multiple, broad, simple, topic based queries to start. For example: `['rate limiting', 'routing rate limiting', 'routing']`. - Use multiple, broad, simple, topic-based queries to start. For example: `['rate limiting', 'routing rate limiting', 'routing']`.
- Do not add package names to queries - package information is already shared. For example, use `test resource table`, not `filament 4 test resource table`. - Do not add package names to queries; package information is already shared. For example, use `test resource table`, not `filament 4 test resource table`.
### Available Search Syntax ### Available Search Syntax
- You can and should pass multiple queries at once. The most relevant results will be returned first. - You can and should pass multiple queries at once. The most relevant results will be returned first.
1. Simple Word Searches with auto-stemming - query=authentication - finds 'authenticate' and 'auth' 1. Simple Word Searches with auto-stemming - query=authentication - finds 'authenticate' and 'auth'.
2. Multiple Words (AND Logic) - query=rate limit - finds knowledge containing both "rate" AND "limit" 2. Multiple Words (AND Logic) - query=rate limit - finds knowledge containing both "rate" AND "limit".
3. Quoted Phrases (Exact Position) - query="infinite scroll" - Words must be adjacent and in that order 3. Quoted Phrases (Exact Position) - query="infinite scroll" - words must be adjacent and in that order.
4. Mixed Queries - query=middleware "rate limit" - "middleware" AND exact phrase "rate limit" 4. Mixed Queries - query=middleware "rate limit" - "middleware" AND exact phrase "rate limit".
5. Multiple Queries - queries=["authentication", "middleware"] - ANY of these terms 5. Multiple Queries - queries=["authentication", "middleware"] - ANY of these terms.
=== php rules === === php rules ===
@@ -218,7 +219,7 @@ This application is a Laravel application and its main Laravel ecosystems packag
### Constructors ### Constructors
- Use PHP 8 constructor property promotion in `__construct()`. - Use PHP 8 constructor property promotion in `__construct()`.
- <code-snippet>public function __construct(public GitHub $github) { }</code-snippet> - <code-snippet>public function __construct(public GitHub $github) { }</code-snippet>
- Do not allow empty `__construct()` methods with zero parameters. - Do not allow empty `__construct()` methods with zero parameters unless the constructor is private.
### Type Declarations ### Type Declarations
- Always use explicit return type declarations for methods and functions. - Always use explicit return type declarations for methods and functions.
@@ -232,7 +233,7 @@ protected function isAccessible(User $user, ?string $path = null): bool
</code-snippet> </code-snippet>
## Comments ## Comments
- Prefer PHPDoc blocks over comments. Never use comments within the code itself unless there is something _very_ complex going on. - Prefer PHPDoc blocks over inline comments. Never use comments within the code itself unless there is something very complex going on.
## PHPDoc Blocks ## PHPDoc Blocks
- Add useful array shape type definitions for arrays when appropriate. - Add useful array shape type definitions for arrays when appropriate.
@@ -240,32 +241,22 @@ protected function isAccessible(User $user, ?string $path = null): bool
## Enums ## Enums
- Typically, keys in an Enum should be TitleCase. For example: `FavoritePerson`, `BestLake`, `Monthly`. - Typically, keys in an Enum should be TitleCase. For example: `FavoritePerson`, `BestLake`, `Monthly`.
=== herd rules ===
## Laravel Herd
- The application is served by Laravel Herd and will be available at: https?://[kebab-case-project-dir].test. Use the `get-absolute-url` tool to generate URLs for the user to ensure valid URLs.
- You must not run any commands to make the site available via HTTP(s). It is _always_ available through Laravel Herd.
=== tests rules === === tests rules ===
## Test Enforcement ## Test Enforcement
- Every change must be programmatically tested. Write a new test or update an existing test, then run the affected tests to make sure they pass. - Every change must be programmatically tested. Write a new test or update an existing test, then run the affected tests to make sure they pass.
- Run the minimum number of tests needed to ensure code quality and speed. Use `php artisan test` with a specific filename or filter. - Run the minimum number of tests needed to ensure code quality and speed. Use `php artisan test --compact` with a specific filename or filter.
=== inertia-laravel/core rules === === inertia-laravel/core rules ===
## Inertia Core ## Inertia
- Inertia.js components should be placed in the `resources/js/pages` directory unless specified differently in the JS bundler (vite.config.js). - Inertia.js components should be placed in the `resources/js/Pages` directory unless specified differently in the JS bundler (`vite.config.js`).
- Use `Inertia::render()` for server-side routing instead of traditional Blade views. - Use `Inertia::render()` for server-side routing instead of traditional Blade views.
- Use `search-docs` for accurate guidance on all things Inertia. - Use the `search-docs` tool for accurate guidance on all things Inertia.
<code-snippet lang="php" name="Inertia::render Example"> <code-snippet name="Inertia Render Example" lang="php">
// routes/web.php example // routes/web.php example
Route::get('/users', function () { Route::get('/users', function () {
return Inertia::render('Users/Index', [ return Inertia::render('Users/Index', [
@@ -274,28 +265,26 @@ Route::get('/users', function () {
}); });
</code-snippet> </code-snippet>
=== inertia-laravel/v2 rules === === inertia-laravel/v2 rules ===
## Inertia v2 ## Inertia v2
- Make use of all Inertia features from v1 & v2. Check the documentation before making any changes to ensure we are taking the correct approach. - Make use of all Inertia features from v1 and v2. Check the documentation before making any changes to ensure we are taking the correct approach.
### Inertia v2 New Features ### Inertia v2 New Features
- Polling - Deferred props.
- Prefetching - Infinite scrolling using merging props and `WhenVisible`.
- Deferred props - Lazy loading data on scroll.
- Infinite scrolling using merging props and `WhenVisible` - Polling.
- Lazy loading data on scroll - Prefetching.
### Deferred Props & Empty States ### Deferred Props & Empty States
- When using deferred props on the frontend, you should add a nice empty state with pulsing / animated skeleton. - When using deferred props on the frontend, you should add a nice empty state with pulsing/animated skeleton.
### Inertia Form General Guidance ### Inertia Form General Guidance
- The recommended way to build forms when using Inertia is with the `<Form>` component - a useful example is below. Use `search-docs` with a query of `form component` for guidance. - The recommended way to build forms when using Inertia is with the `<Form>` component - a useful example is below. Use the `search-docs` tool with a query of `form component` for guidance.
- Forms can also be built using the `useForm` helper for more programmatic control, or to follow existing conventions. Use `search-docs` with a query of `useForm helper` for guidance. - Forms can also be built using the `useForm` helper for more programmatic control, or to follow existing conventions. Use the `search-docs` tool with a query of `useForm helper` for guidance.
- `resetOnError`, `resetOnSuccess`, and `setDefaultsOnSuccess` are available on the `<Form>` component. Use `search-docs` with a query of 'form component resetting' for guidance. - `resetOnError`, `resetOnSuccess`, and `setDefaultsOnSuccess` are available on the `<Form>` component. Use the `search-docs` tool with a query of `form component resetting` for guidance.
=== laravel/core rules === === laravel/core rules ===
@@ -307,7 +296,7 @@ Route::get('/users', function () {
### Database ### Database
- Always use proper Eloquent relationship methods with return type hints. Prefer relationship methods over raw queries or manual joins. - Always use proper Eloquent relationship methods with return type hints. Prefer relationship methods over raw queries or manual joins.
- Use Eloquent models and relationships before suggesting raw database queries - Use Eloquent models and relationships before suggesting raw database queries.
- Avoid `DB::`; prefer `Model::query()`. Generate code that leverages Laravel's ORM capabilities rather than bypassing them. - Avoid `DB::`; prefer `Model::query()`. Generate code that leverages Laravel's ORM capabilities rather than bypassing them.
- Generate code that prevents N+1 query problems by using eager loading. - Generate code that prevents N+1 query problems by using eager loading.
- Use Laravel's query builder for very complex database operations. - Use Laravel's query builder for very complex database operations.
@@ -342,52 +331,56 @@ Route::get('/users', function () {
### Vite Error ### Vite Error
- If you receive an "Illuminate\Foundation\ViteException: Unable to locate file in Vite manifest" error, you can run `npm run build` or ask the user to run `npm run dev` or `composer run dev`. - If you receive an "Illuminate\Foundation\ViteException: Unable to locate file in Vite manifest" error, you can run `npm run build` or ask the user to run `npm run dev` or `composer run dev`.
=== laravel/v12 rules === === laravel/v12 rules ===
## Laravel 12 ## Laravel 12
- Use the `search-docs` tool to get version specific documentation. - Use the `search-docs` tool to get version-specific documentation.
- This project upgraded from Laravel 10 without migrating to the new streamlined Laravel file structure. - This project upgraded from Laravel 10 without migrating to the new streamlined Laravel file structure.
- This is **perfectly fine** and recommended by Laravel. Follow the existing structure from Laravel 10. We do not to need migrate to the new Laravel structure unless the user explicitly requests that. - This is **perfectly fine** and recommended by Laravel. Follow the existing structure from Laravel 10. We do not need to migrate to the new Laravel structure unless the user explicitly requests it.
### Laravel 10 Structure
- Middleware typically lives in `app/Http/Middleware/` and service providers in `app/Providers/`.
- There is no `bootstrap/app.php` application configuration in a Laravel 10 structure:
- Middleware registration happens in `app/Http/Kernel.php`
- Exception handling is in `app/Exceptions/Handler.php`
- Console commands and schedule register in `app/Console/Kernel.php`
- Rate limits likely exist in `RouteServiceProvider` or `app/Http/Kernel.php`
### Database ### Database
- When modifying a column, the migration must include all of the attributes that were previously defined on the column. Otherwise, they will be dropped and lost. - When modifying a column, the migration must include all of the attributes that were previously defined on the column. Otherwise, they will be dropped and lost.
- Laravel 11 allows limiting eagerly loaded records natively, without external packages: `$query->latest()->limit(10);`. - Laravel 12 allows limiting eagerly loaded records natively, without external packages: `$query->latest()->limit(10);`.
### Models ### Models
- Casts can and likely should be set in a `casts()` method on a model rather than the `$casts` property. Follow existing conventions from other models. - Casts can and likely should be set in a `casts()` method on a model rather than the `$casts` property. Follow existing conventions from other models.
=== wayfinder/core rules === === wayfinder/core rules ===
## Laravel Wayfinder ## Laravel Wayfinder
Wayfinder generates TypeScript functions and types for Laravel controllers and routes which you can import into your client side code. It provides type safety and automatic synchronization between backend routes and frontend code. Wayfinder generates TypeScript functions and types for Laravel controllers and routes which you can import into your client-side code. It provides type safety and automatic synchronization between backend routes and frontend code.
### Development Guidelines ### Development Guidelines
- Always use `search-docs` to check wayfinder correct usage before implementing any features. - Always use the `search-docs` tool to check Wayfinder correct usage before implementing any features.
- Always Prefer named imports for tree-shaking (e.g., `import { show } from '@/actions/...'`) - Always prefer named imports for tree-shaking (e.g., `import { show } from '@/actions/...'`).
- Avoid default controller imports (prevents tree-shaking) - Avoid default controller imports (prevents tree-shaking).
- Run `php artisan wayfinder:generate` after route changes if Vite plugin isn't installed - Run `php artisan wayfinder:generate` after route changes if Vite plugin isn't installed.
### Feature Overview ### Feature Overview
- Form Support: Use `.form()` with `--with-form` flag for HTML form attributes — `<form {...store.form()}>``action="/posts" method="post"` - Form Support: Use `.form()` with `--with-form` flag for HTML form attributes — `<form {...store.form()}>``action="/posts" method="post"`.
- HTTP Methods: Call `.get()`, `.post()`, `.patch()`, `.put()`, `.delete()` for specific methods — `show.head(1)``{ url: "/posts/1", method: "head" }` - HTTP Methods: Call `.get()`, `.post()`, `.patch()`, `.put()`, `.delete()` for specific methods — `show.head(1)``{ url: "/posts/1", method: "head" }`.
- Invokable Controllers: Import and invoke directly as functions. For example, `import StorePost from '@/actions/.../StorePostController'; StorePost()` - Invokable Controllers: Import and invoke directly as functions. For example, `import StorePost from '@/actions/.../StorePostController'; StorePost()`.
- Named Routes: Import from `@/routes/` for non-controller routes. For example, `import { show } from '@/routes/post'; show(1)` for route name `post.show` - Named Routes: Import from `@/routes/` for non-controller routes. For example, `import { show } from '@/routes/post'; show(1)` for route name `post.show`.
- Parameter Binding: Detects route keys (e.g., `{post:slug}`) and accepts matching object properties — `show("my-post")` or `show({ slug: "my-post" })` - Parameter Binding: Detects route keys (e.g., `{post:slug}`) and accepts matching object properties — `show("my-post")` or `show({ slug: "my-post" })`.
- Query Merging: Use `mergeQuery` to merge with `window.location.search`, set values to `null` to remove — `show(1, { mergeQuery: { page: 2, sort: null } })` - Query Merging: Use `mergeQuery` to merge with `window.location.search`, set values to `null` to remove — `show(1, { mergeQuery: { page: 2, sort: null } })`.
- Query Parameters: Pass `{ query: {...} }` in options to append params — `show(1, { query: { page: 1 } })``"/posts/1?page=1"` - Query Parameters: Pass `{ query: {...} }` in options to append params — `show(1, { query: { page: 1 } })``"/posts/1?page=1"`.
- Route Objects: Functions return `{ url, method }` shaped objects — `show(1)``{ url: "/posts/1", method: "get" }` - Route Objects: Functions return `{ url, method }` shaped objects — `show(1)``{ url: "/posts/1", method: "get" }`.
- URL Extraction: Use `.url()` to get URL string — `show.url(1)``"/posts/1"` - URL Extraction: Use `.url()` to get URL string — `show.url(1)``"/posts/1"`.
### Example Usage ### Example Usage
<code-snippet name="Wayfinder Basic Usage" lang="typescript"> <code-snippet name="Wayfinder Basic Usage" lang="typescript">
// Import controller methods (tree-shakable) // Import controller methods (tree-shakable)...
import { show, store, update } from '@/actions/App/Http/Controllers/PostController' import { show, store, update } from '@/actions/App/Http/Controllers/PostController'
// Get route object with URL and method... // Get route object with URL and method...
@@ -405,7 +398,6 @@ Wayfinder generates TypeScript functions and types for Laravel controllers and r
postShow(1) // { url: "/posts/1", method: "get" } postShow(1) // { url: "/posts/1", method: "get" }
</code-snippet> </code-snippet>
### Wayfinder + Inertia ### Wayfinder + Inertia
If your application uses the `<Form>` component from Inertia, you can use Wayfinder to generate form action and method automatically. If your application uses the `<Form>` component from Inertia, you can use Wayfinder to generate form action and method automatically.
<code-snippet name="Wayfinder Form Component (React)" lang="typescript"> <code-snippet name="Wayfinder Form Component (React)" lang="typescript">
@@ -414,14 +406,14 @@ If your application uses the `<Form>` component from Inertia, you can use Wayfin
</code-snippet> </code-snippet>
=== livewire/core rules === === livewire/core rules ===
## Livewire Core ## Livewire
- Use the `search-docs` tool to find exact version specific documentation for how to write Livewire & Livewire tests.
- Use the `php artisan make:livewire [Posts\CreatePost]` artisan command to create new components - Use the `search-docs` tool to find exact version-specific documentation for how to write Livewire and Livewire tests.
- Use the `php artisan make:livewire [Posts\CreatePost]` Artisan command to create new components.
- State should live on the server, with the UI reflecting it. - State should live on the server, with the UI reflecting it.
- All Livewire requests hit the Laravel backend, they're like regular HTTP requests. Always validate form data, and run authorization checks in Livewire actions. - All Livewire requests hit the Laravel backend; they're like regular HTTP requests. Always validate form data and run authorization checks in Livewire actions.
## Livewire Best Practices ## Livewire Best Practices
- Livewire components require a single root element. - Livewire components require a single root element.
@@ -438,15 +430,14 @@ If your application uses the `<Form>` component from Inertia, you can use Wayfin
- Prefer lifecycle hooks like `mount()`, `updatedFoo()` for initialization and reactive side effects: - Prefer lifecycle hooks like `mount()`, `updatedFoo()` for initialization and reactive side effects:
<code-snippet name="Lifecycle hook examples" lang="php"> <code-snippet name="Lifecycle Hook Examples" lang="php">
public function mount(User $user) { $this->user = $user; } public function mount(User $user) { $this->user = $user; }
public function updatedSearch() { $this->resetPage(); } public function updatedSearch() { $this->resetPage(); }
</code-snippet> </code-snippet>
## Testing Livewire ## Testing Livewire
<code-snippet name="Example Livewire component test" lang="php"> <code-snippet name="Example Livewire Component Test" lang="php">
Livewire::test(Counter::class) Livewire::test(Counter::class)
->assertSet('count', 0) ->assertSet('count', 0)
->call('increment') ->call('increment')
@@ -455,19 +446,17 @@ If your application uses the `<Form>` component from Inertia, you can use Wayfin
->assertStatus(200); ->assertStatus(200);
</code-snippet> </code-snippet>
<code-snippet name="Testing Livewire Component Exists on Page" lang="php">
<code-snippet name="Testing a Livewire component exists within a page" lang="php"> $this->get('/posts/create')
$this->get('/posts/create') ->assertSeeLivewire(CreatePost::class);
->assertSeeLivewire(CreatePost::class); </code-snippet>
</code-snippet>
=== livewire/v3 rules === === livewire/v3 rules ===
## Livewire 3 ## Livewire 3
### Key Changes From Livewire 2 ### Key Changes From Livewire 2
- These things changed in Livewire 2, but may not have been updated in this application. Verify this application's setup to ensure you conform with application conventions. - These things changed in Livewire 3, but may not have been updated in this application. Verify this application's setup to ensure you conform with application conventions.
- Use `wire:model.live` for real-time updates, `wire:model` is now deferred by default. - Use `wire:model.live` for real-time updates, `wire:model` is now deferred by default.
- Components now use the `App\Livewire` namespace (not `App\Http\Livewire`). - Components now use the `App\Livewire` namespace (not `App\Http\Livewire`).
- Use `$this->dispatch()` to dispatch events (not `emit` or `dispatchBrowserEvent`). - Use `$this->dispatch()` to dispatch events (not `emit` or `dispatchBrowserEvent`).
@@ -477,13 +466,13 @@ If your application uses the `<Form>` component from Inertia, you can use Wayfin
- `wire:show`, `wire:transition`, `wire:cloak`, `wire:offline`, `wire:target` are available for use. Use the documentation to find usage examples. - `wire:show`, `wire:transition`, `wire:cloak`, `wire:offline`, `wire:target` are available for use. Use the documentation to find usage examples.
### Alpine ### Alpine
- Alpine is now included with Livewire, don't manually include Alpine.js. - Alpine is now included with Livewire; don't manually include Alpine.js.
- Plugins included with Alpine: persist, intersect, collapse, and focus. - Plugins included with Alpine: persist, intersect, collapse, and focus.
### Lifecycle Hooks ### Lifecycle Hooks
- You can listen for `livewire:init` to hook into Livewire initialization, and `fail.status === 419` for the page expiring: - You can listen for `livewire:init` to hook into Livewire initialization, and `fail.status === 419` for the page expiring:
<code-snippet name="livewire:load example" lang="js"> <code-snippet name="Livewire Init Hook Example" lang="js">
document.addEventListener('livewire:init', function () { document.addEventListener('livewire:init', function () {
Livewire.hook('request', ({ fail }) => { Livewire.hook('request', ({ fail }) => {
if (fail && fail.status === 419) { if (fail && fail.status === 419) {
@@ -497,7 +486,6 @@ document.addEventListener('livewire:init', function () {
}); });
</code-snippet> </code-snippet>
=== pint/core rules === === pint/core rules ===
## Laravel Pint Code Formatter ## Laravel Pint Code Formatter
@@ -505,24 +493,22 @@ document.addEventListener('livewire:init', function () {
- You must run `vendor/bin/pint --dirty` before finalizing changes to ensure your code matches the project's expected style. - You must run `vendor/bin/pint --dirty` before finalizing changes to ensure your code matches the project's expected style.
- Do not run `vendor/bin/pint --test`, simply run `vendor/bin/pint` to fix any formatting issues. - Do not run `vendor/bin/pint --test`, simply run `vendor/bin/pint` to fix any formatting issues.
=== phpunit/core rules === === phpunit/core rules ===
## PHPUnit Core ## PHPUnit
- This application uses PHPUnit for testing. All tests must be written as PHPUnit classes. Use `php artisan make:test --phpunit {name}` to create a new test. - This application uses PHPUnit for testing. All tests must be written as PHPUnit classes. Use `php artisan make:test --phpunit {name}` to create a new test.
- If you see a test using "Pest", convert it to PHPUnit. - If you see a test using "Pest", convert it to PHPUnit.
- Every time a test has been updated, run that singular test. - Every time a test has been updated, run that singular test.
- When the tests relating to your feature are passing, ask the user if they would like to also run the entire test suite to make sure everything is still passing. - When the tests relating to your feature are passing, ask the user if they would like to also run the entire test suite to make sure everything is still passing.
- Tests should test all of the happy paths, failure paths, and weird paths. - Tests should test all of the happy paths, failure paths, and weird paths.
- You must not remove any tests or test files from the tests directory without approval. These are not temporary or helper files, these are core to the application. - You must not remove any tests or test files from the tests directory without approval. These are not temporary or helper files; these are core to the application.
### Running Tests ### Running Tests
- Run the minimal number of tests, using an appropriate filter, before finalizing. - Run the minimal number of tests, using an appropriate filter, before finalizing.
- To run all tests: `php artisan test`. - To run all tests: `php artisan test --compact`.
- To run all tests in a file: `php artisan test tests/Feature/ExampleTest.php`. - To run all tests in a file: `php artisan test --compact tests/Feature/ExampleTest.php`.
- To filter on a particular test name: `php artisan test --filter=testName` (recommended after making a change to a related file). - To filter on a particular test name: `php artisan test --compact --filter=testName` (recommended after making a change to a related file).
=== inertia-react/core rules === === inertia-react/core rules ===
@@ -537,10 +523,9 @@ import { Link } from '@inertiajs/react'
</code-snippet> </code-snippet>
=== inertia-react/v2/forms rules === === inertia-react/v2/forms rules ===
## Inertia + React Forms ## Inertia v2 + React Forms
<code-snippet name="`<Form>` Component Example" lang="react"> <code-snippet name="`<Form>` Component Example" lang="react">
@@ -575,39 +560,37 @@ export default () => (
</code-snippet> </code-snippet>
=== tailwindcss/core rules === === tailwindcss/core rules ===
## Tailwind Core ## Tailwind CSS
- Use Tailwind CSS classes to style HTML, check and use existing tailwind conventions within the project before writing your own. - Use Tailwind CSS classes to style HTML; check and use existing Tailwind conventions within the project before writing your own.
- Offer to extract repeated patterns into components that match the project's conventions (i.e. Blade, JSX, Vue, etc..) - Offer to extract repeated patterns into components that match the project's conventions (i.e. Blade, JSX, Vue, etc.).
- Think through class placement, order, priority, and defaults - remove redundant classes, add classes to parent or child carefully to limit repetition, group elements logically - Think through class placement, order, priority, and defaults. Remove redundant classes, add classes to parent or child carefully to limit repetition, and group elements logically.
- You can use the `search-docs` tool to get exact examples from the official documentation when needed. - You can use the `search-docs` tool to get exact examples from the official documentation when needed.
### Spacing ### Spacing
- When listing items, use gap utilities for spacing, don't use margins. - When listing items, use gap utilities for spacing; don't use margins.
<code-snippet name="Valid Flex Gap Spacing Example" lang="html">
<div class="flex gap-8">
<div>Superior</div>
<div>Michigan</div>
<div>Erie</div>
</div>
</code-snippet>
<code-snippet name="Valid Flex Gap Spacing Example" lang="html">
<div class="flex gap-8">
<div>Superior</div>
<div>Michigan</div>
<div>Erie</div>
</div>
</code-snippet>
### Dark Mode ### Dark Mode
- If existing pages and components support dark mode, new pages and components must support dark mode in a similar way, typically using `dark:`. - If existing pages and components support dark mode, new pages and components must support dark mode in a similar way, typically using `dark:`.
=== tailwindcss/v4 rules === === tailwindcss/v4 rules ===
## Tailwind 4 ## Tailwind CSS 4
- Always use Tailwind CSS v4 - do not use the deprecated utilities. - Always use Tailwind CSS v4; do not use the deprecated utilities.
- `corePlugins` is not supported in Tailwind v4. - `corePlugins` is not supported in Tailwind v4.
- In Tailwind v4, configuration is CSS-first using the `@theme` directive — no separate `tailwind.config.js` file is needed. - In Tailwind v4, configuration is CSS-first using the `@theme` directive — no separate `tailwind.config.js` file is needed.
<code-snippet name="Extending Theme in CSS" lang="css"> <code-snippet name="Extending Theme in CSS" lang="css">
@theme { @theme {
--color-brand: oklch(0.72 0.11 178); --color-brand: oklch(0.72 0.11 178);
@@ -623,9 +606,8 @@ export default () => (
+ @import "tailwindcss"; + @import "tailwindcss";
</code-snippet> </code-snippet>
### Replaced Utilities ### Replaced Utilities
- Tailwind v4 removed deprecated utilities. Do not use the deprecated option - use the replacement. - Tailwind v4 removed deprecated utilities. Do not use the deprecated option; use the replacement.
- Opacity values are still numeric. - Opacity values are still numeric.
| Deprecated | Replacement | | Deprecated | Replacement |

View File

@@ -100,6 +100,8 @@ COPY . .
COPY --from=vendor /var/www/html/vendor ./vendor COPY --from=vendor /var/www/html/vendor ./vendor
COPY --from=node_builder /var/www/html/public/build ./public/build COPY --from=node_builder /var/www/html/public/build ./public/build
RUN php artisan vendor:publish --tag=livewire:assets --force --no-interaction
RUN php artisan config:clear \ RUN php artisan config:clear \
&& php artisan config:cache \ && php artisan config:cache \
&& php artisan route:clear \ && php artisan route:clear \

View File

@@ -7,7 +7,6 @@ use App\Models\Tenant;
use App\Models\User; use App\Models\User;
use Illuminate\Console\Attributes\AsCommand; use Illuminate\Console\Attributes\AsCommand;
use Illuminate\Console\Command; use Illuminate\Console\Command;
use Illuminate\Support\Facades\DB;
#[AsCommand(name: 'tenant:attach-demo-event')] #[AsCommand(name: 'tenant:attach-demo-event')]
class AttachDemoEvent extends Command class AttachDemoEvent extends Command
@@ -25,10 +24,12 @@ class AttachDemoEvent extends Command
{ {
if (! \Illuminate\Support\Facades\Schema::hasTable('events')) { if (! \Illuminate\Support\Facades\Schema::hasTable('events')) {
$this->error("Table 'events' does not exist. Run: php artisan migrate"); $this->error("Table 'events' does not exist. Run: php artisan migrate");
return self::FAILURE; return self::FAILURE;
} }
if (! \Illuminate\Support\Facades\Schema::hasColumn('events', 'tenant_id')) { if (! \Illuminate\Support\Facades\Schema::hasColumn('events', 'tenant_id')) {
$this->error("Column 'events.tenant_id' does not exist. Add it and rerun. Suggested: create a migration to add a nullable foreignId to tenants."); $this->error("Column 'events.tenant_id' does not exist. Add it and rerun. Suggested: create a migration to add a nullable foreignId to tenants.");
return self::FAILURE; return self::FAILURE;
} }
$tenant = null; $tenant = null;
@@ -45,6 +46,7 @@ class AttachDemoEvent extends Command
} }
if (! $tenant) { if (! $tenant) {
$this->error('Tenant not found. Provide --tenant-slug or a user with tenant_id via --tenant-email.'); $this->error('Tenant not found. Provide --tenant-slug or a user with tenant_id via --tenant-email.');
return self::FAILURE; return self::FAILURE;
} }
@@ -67,12 +69,14 @@ class AttachDemoEvent extends Command
if (! $event) { if (! $event) {
$this->error('Event not found. Provide --event-id or --event-slug.'); $this->error('Event not found. Provide --event-id or --event-slug.');
return self::FAILURE; return self::FAILURE;
} }
// Idempotent update // Idempotent update
if ((int) $event->tenant_id === (int) $tenant->id) { if ((int) $event->tenant_id === (int) $tenant->id) {
$this->info("Event #{$event->id} already attached to tenant #{$tenant->id} ({$tenant->slug})."); $this->info("Event #{$event->id} already attached to tenant #{$tenant->id} ({$tenant->slug}).");
return self::SUCCESS; return self::SUCCESS;
} }
@@ -80,6 +84,7 @@ class AttachDemoEvent extends Command
$event->save(); $event->save();
$this->info("Attached event #{$event->id} ({$event->slug}) to tenant #{$tenant->id} ({$tenant->slug})."); $this->info("Attached event #{$event->id} ({$event->slug}) to tenant #{$tenant->id} ({$tenant->slug}).");
return self::SUCCESS; return self::SUCCESS;
} }
} }

View File

@@ -10,22 +10,27 @@ use Illuminate\Support\Facades\Storage;
class BackfillThumbnails extends Command class BackfillThumbnails extends Command
{ {
protected $signature = 'media:backfill-thumbnails {--limit=500}'; protected $signature = 'media:backfill-thumbnails {--limit=500}';
protected $description = 'Generate thumbnails for photos missing thumbnail_path or where thumbnail equals original.'; protected $description = 'Generate thumbnails for photos missing thumbnail_path or where thumbnail equals original.';
public function handle(): int public function handle(): int
{ {
$limit = (int) $this->option('limit'); $limit = (int) $this->option('limit');
$rows = DB::table('photos') $rows = DB::table('photos')
->select(['id','event_id','file_path','thumbnail_path']) ->select(['id', 'event_id', 'file_path', 'thumbnail_path'])
->orderBy('id') ->orderBy('id')
->limit($limit) ->limit($limit)
->get(); ->get();
$count = 0; $count = 0;
foreach ($rows as $r) { foreach ($rows as $r) {
$orig = $this->relativeFromUrl((string)$r->file_path); $orig = $this->relativeFromUrl((string) $r->file_path);
$thumb = (string)($r->thumbnail_path ?? ''); $thumb = (string) ($r->thumbnail_path ?? '');
if ($thumb && $thumb !== $r->file_path) continue; // already set to different thumb if ($thumb && $thumb !== $r->file_path) {
if (! $orig) continue; continue;
} // already set to different thumb
if (! $orig) {
continue;
}
$baseName = pathinfo($orig, PATHINFO_FILENAME); $baseName = pathinfo($orig, PATHINFO_FILENAME);
$destRel = "events/{$r->event_id}/photos/thumbs/{$baseName}_thumb.jpg"; $destRel = "events/{$r->event_id}/photos/thumbs/{$baseName}_thumb.jpg";
$made = ImageHelper::makeThumbnailOnDisk('public', $orig, $destRel, 640, 82); $made = ImageHelper::makeThumbnailOnDisk('public', $orig, $destRel, 640, 82);
@@ -39,6 +44,7 @@ class BackfillThumbnails extends Command
} }
} }
$this->info("Done. Thumbnails generated: {$count}"); $this->info("Done. Thumbnails generated: {$count}");
return self::SUCCESS; return self::SUCCESS;
} }
@@ -49,6 +55,7 @@ class BackfillThumbnails extends Command
if (str_starts_with($p, '/storage/')) { if (str_starts_with($p, '/storage/')) {
return substr($p, strlen('/storage/')); return substr($p, strlen('/storage/'));
} }
return null; return null;
} }
} }

View File

@@ -0,0 +1,130 @@
<?php
namespace App\Console\Commands;
use App\Services\LemonSqueezy\LemonSqueezyClient;
use Illuminate\Console\Command;
use Illuminate\Support\Arr;
use Illuminate\Support\Facades\Log;
class LemonSqueezyRegisterWebhooks extends Command
{
/**
* The name and signature of the console command.
*
* @var string
*/
protected $signature = 'lemonsqueezy:webhooks:register
{--url= : Destination URL for Lemon Squeezy webhooks}
{--events=* : Override event types to subscribe}
{--secret= : Override the webhook signing secret}
{--test-mode : Register the webhook in test mode}
{--dry-run : Output payload without creating the destination}';
/**
* The console command description.
*
* @var string
*/
protected $description = 'Register Lemon Squeezy webhook notification settings.';
/**
* Execute the console command.
*/
public function handle(LemonSqueezyClient $client): int
{
$destination = (string) ($this->option('url') ?: $this->defaultWebhookUrl());
if ($destination === '') {
$this->error('Webhook destination URL is required. Use --url=...');
return self::FAILURE;
}
$events = collect((array) $this->option('events'))
->filter()
->map(fn ($event) => trim((string) $event))
->filter()
->values()
->all();
if ($events === []) {
$events = config('lemonsqueezy.webhook_events', []);
}
if ($events === [] || ! is_array($events)) {
$this->error('No webhook events configured. Set config(lemonsqueezy.webhook_events) or pass --events.');
return self::FAILURE;
}
$secret = (string) ($this->option('secret') ?: config('lemonsqueezy.webhook_secret'));
if ($secret === '') {
$this->error('Webhook signing secret is required. Set LEMONSQUEEZY_WEBHOOK_SECRET or pass --secret.');
return self::FAILURE;
}
$storeId = (string) config('lemonsqueezy.store_id');
if ($storeId === '') {
$this->error('Lemon Squeezy store id is required. Set LEMONSQUEEZY_STORE_ID.');
return self::FAILURE;
}
$testMode = (bool) $this->option('test-mode') || (bool) config('lemonsqueezy.test_mode', false);
$attributes = array_filter([
'url' => $destination,
'events' => $events,
'secret' => $secret,
'test_mode' => $testMode ? true : null,
], static fn ($value) => $value !== null && $value !== '');
$payload = [
'data' => [
'type' => 'webhooks',
'attributes' => $attributes,
'relationships' => [
'store' => [
'data' => [
'type' => 'stores',
'id' => $storeId,
],
],
],
],
];
if ((bool) $this->option('dry-run')) {
$this->line(json_encode($payload, JSON_PRETTY_PRINT));
return self::SUCCESS;
}
$response = $client->post('/webhooks', $payload);
$data = Arr::get($response, 'data', $response);
$id = Arr::get($data, 'id');
Log::channel('lemonsqueezy-sync')->info('Lemon Squeezy webhook registered', [
'webhook_id' => $id,
'destination' => $destination,
'test_mode' => $testMode,
]);
$this->info('Lemon Squeezy webhook registered.');
if ($id) {
$this->line('ID: '.$id);
}
return self::SUCCESS;
}
protected function defaultWebhookUrl(): string
{
$base = rtrim((string) config('app.url'), '/');
return $base !== '' ? $base.'/lemonsqueezy/webhook' : '';
}
}

View File

@@ -2,23 +2,23 @@
namespace App\Console\Commands; namespace App\Console\Commands;
use App\Jobs\PullPackageFromPaddle; use App\Jobs\PullPackageFromLemonSqueezy;
use App\Jobs\SyncPackageToPaddle; use App\Jobs\SyncPackageToLemonSqueezy;
use App\Models\Package; use App\Models\Package;
use Illuminate\Console\Command; use Illuminate\Console\Command;
use Illuminate\Support\Collection; use Illuminate\Support\Collection;
use Illuminate\Support\Str; use Illuminate\Support\Str;
class PaddleSyncPackages extends Command class LemonSqueezySyncPackages extends Command
{ {
protected $signature = 'paddle:sync-packages protected $signature = 'lemonsqueezy:sync-packages
{--package=* : Limit sync to the given package IDs or slugs} {--package=* : Limit sync to the given package IDs or slugs}
{--dry-run : Generate payload snapshots without calling Paddle} {--dry-run : Generate payload snapshots without calling Lemon Squeezy}
{--pull : Fetch remote Paddle state instead of pushing local changes} {--pull : Fetch remote Lemon Squeezy state instead of pushing local changes}
{--allow-unmapped : Allow sync when packages are missing Paddle product/price IDs} {--allow-unmapped : Allow sync when packages are missing Lemon Squeezy product/variant IDs}
{--queue : Dispatch jobs onto the queue instead of running synchronously}'; {--queue : Dispatch jobs onto the queue instead of running synchronously}';
protected $description = 'Synchronise local packages with Paddle products and prices.'; protected $description = 'Synchronise local packages with Lemon Squeezy products and variants.';
public function handle(): int public function handle(): int
{ {
@@ -52,7 +52,7 @@ class PaddleSyncPackages extends Command
}); });
$this->info(sprintf( $this->info(sprintf(
'Queued %d package %s for Paddle %s.', 'Queued %d package %s for Lemon Squeezy %s.',
$packages->count(), $packages->count(),
Str::plural('entry', $packages->count()), Str::plural('entry', $packages->count()),
$pull ? 'pull' : 'sync' $pull ? 'pull' : 'sync'
@@ -97,22 +97,22 @@ class PaddleSyncPackages extends Command
protected function guardUnmappedPackages(Collection $packages): bool protected function guardUnmappedPackages(Collection $packages): bool
{ {
$unmapped = $packages->filter(fn (Package $package) => blank($package->paddle_product_id) || blank($package->paddle_price_id)); $unmapped = $packages->filter(fn (Package $package) => blank($package->lemonsqueezy_product_id) || blank($package->lemonsqueezy_variant_id));
if ($unmapped->isEmpty()) { if ($unmapped->isEmpty()) {
return true; return true;
} }
$this->error('Unmapped Paddle package IDs detected. Resolve legacy mappings or pass --allow-unmapped.'); $this->error('Unmapped Lemon Squeezy package IDs detected. Resolve mappings or pass --allow-unmapped.');
$this->table( $this->table(
['ID', 'Slug', 'Missing'], ['ID', 'Slug', 'Missing'],
$unmapped->map(function (Package $package): array { $unmapped->map(function (Package $package): array {
$missing = []; $missing = [];
if (blank($package->paddle_product_id)) { if (blank($package->lemonsqueezy_product_id)) {
$missing[] = 'product_id'; $missing[] = 'product_id';
} }
if (blank($package->paddle_price_id)) { if (blank($package->lemonsqueezy_variant_id)) {
$missing[] = 'price_id'; $missing[] = 'variant_id';
} }
return [ return [
@@ -133,26 +133,26 @@ class PaddleSyncPackages extends Command
]; ];
if ($queue) { if ($queue) {
SyncPackageToPaddle::dispatch($package->id, $context); SyncPackageToLemonSqueezy::dispatch($package->id, $context);
$this->line(sprintf('> queued sync for package #%d (%s)', $package->id, $package->slug)); $this->line(sprintf('> queued sync for package #%d (%s)', $package->id, $package->slug));
return; return;
} }
SyncPackageToPaddle::dispatchSync($package->id, $context); SyncPackageToLemonSqueezy::dispatchSync($package->id, $context);
$this->line(sprintf('> synced package #%d (%s)', $package->id, $package->slug)); $this->line(sprintf('> synced package #%d (%s)', $package->id, $package->slug));
} }
protected function dispatchPullJob(Package $package, bool $queue): void protected function dispatchPullJob(Package $package, bool $queue): void
{ {
if ($queue) { if ($queue) {
PullPackageFromPaddle::dispatch($package->id); PullPackageFromLemonSqueezy::dispatch($package->id);
$this->line(sprintf('> queued pull for package #%d (%s)', $package->id, $package->slug)); $this->line(sprintf('> queued pull for package #%d (%s)', $package->id, $package->slug));
return; return;
} }
PullPackageFromPaddle::dispatchSync($package->id); PullPackageFromLemonSqueezy::dispatchSync($package->id);
$this->line(sprintf('> pulled package #%d (%s)', $package->id, $package->slug)); $this->line(sprintf('> pulled package #%d (%s)', $package->id, $package->slug));
} }
} }

View File

@@ -4,15 +4,15 @@ namespace App\Console\Commands;
use App\Models\PackagePurchase; use App\Models\PackagePurchase;
use App\Models\Tenant; use App\Models\Tenant;
use App\Models\User;
use App\Models\TenantPackage; use App\Models\TenantPackage;
use App\Models\User;
use Illuminate\Console\Command; use Illuminate\Console\Command;
use Illuminate\Support\Facades\Hash; use Illuminate\Support\Facades\Hash;
use Illuminate\Support\Facades\DB;
class MigrateLegacyPurchases extends Command class MigrateLegacyPurchases extends Command
{ {
protected $signature = 'packages:migrate-legacy'; protected $signature = 'packages:migrate-legacy';
protected $description = 'Migrate legacy purchases to new system with temp tenants'; protected $description = 'Migrate legacy purchases to new system with temp tenants';
public function handle() public function handle()
@@ -21,19 +21,20 @@ class MigrateLegacyPurchases extends Command
if ($legacyPurchases->isEmpty()) { if ($legacyPurchases->isEmpty()) {
$this->info('No legacy purchases found.'); $this->info('No legacy purchases found.');
return 0; return 0;
} }
$this->info("Found {$legacyPurchases->count()} legacy purchases."); $this->info("Found {$legacyPurchases->count()} legacy purchases.");
foreach ($legacyPurchases as $purchase) { foreach ($legacyPurchases as $purchase) {
if (!$purchase->user_id) { if (! $purchase->user_id) {
// Create temp user if no user // Create temp user if no user
$tempUser = User::create([ $tempUser = User::create([
'name' => 'Legacy User ' . $purchase->id, 'name' => 'Legacy User '.$purchase->id,
'email' => 'legacy' . $purchase->id . '@fotospiel.local', 'email' => 'legacy'.$purchase->id.'@fotospiel.local',
'password' => Hash::make('legacy'), 'password' => Hash::make('legacy'),
'username' => 'legacy' . $purchase->id, 'username' => 'legacy'.$purchase->id,
'first_name' => 'Legacy', 'first_name' => 'Legacy',
'last_name' => 'User', 'last_name' => 'User',
'address' => 'Legacy Address', 'address' => 'Legacy Address',
@@ -43,7 +44,7 @@ class MigrateLegacyPurchases extends Command
$tempTenant = Tenant::create([ $tempTenant = Tenant::create([
'user_id' => $tempUser->id, 'user_id' => $tempUser->id,
'name' => 'Legacy Tenant ' . $purchase->id, 'name' => 'Legacy Tenant '.$purchase->id,
'status' => 'active', 'status' => 'active',
]); ]);
@@ -73,6 +74,7 @@ class MigrateLegacyPurchases extends Command
} }
$this->info('Legacy migration completed.'); $this->info('Legacy migration completed.');
return 0; return 0;
} }
} }

View File

@@ -46,6 +46,12 @@ class MonitorStorageCommand extends Command
$assetStats = $this->buildAssetStatistics(); $assetStats = $this->buildAssetStatistics();
$thresholds = $this->capacityThresholds(); $thresholds = $this->capacityThresholds();
$checksumConfig = $this->checksumAlertConfig();
$checksumWindowMinutes = $checksumConfig['window_minutes'];
$checksumThresholds = $checksumConfig['thresholds'];
$checksumMismatches = $checksumConfig['enabled'] && $checksumWindowMinutes > 0
? $this->checksumMismatchCounts($checksumWindowMinutes)
: [];
$alerts = []; $alerts = [];
$snapshotTargets = []; $snapshotTargets = [];
@@ -78,6 +84,7 @@ class MonitorStorageCommand extends Command
]; ];
} }
$targetChecksumMismatches = $checksumMismatches[$target->id] ?? 0;
$snapshotTargets[] = [ $snapshotTargets[] = [
'id' => $target->id, 'id' => $target->id,
'key' => $target->key, 'key' => $target->key,
@@ -85,13 +92,35 @@ class MonitorStorageCommand extends Command
'is_hot' => (bool) $target->is_hot, 'is_hot' => (bool) $target->is_hot,
'capacity' => $capacity, 'capacity' => $capacity,
'assets' => $assets, 'assets' => $assets,
'checksum_mismatches' => [
'count' => $targetChecksumMismatches,
'window_minutes' => $checksumWindowMinutes,
],
]; ];
} }
if ($checksumConfig['enabled'] && $checksumWindowMinutes > 0) {
$totalMismatches = array_sum($checksumMismatches);
$checksumSeverity = $this->determineChecksumSeverity($totalMismatches, $checksumThresholds);
if ($checksumSeverity !== 'ok') {
$alerts[] = [
'type' => 'checksum_mismatch',
'severity' => $checksumSeverity,
'count' => $totalMismatches,
'window_minutes' => $checksumWindowMinutes,
];
}
}
$snapshot = [ $snapshot = [
'generated_at' => now()->toIso8601String(), 'generated_at' => now()->toIso8601String(),
'targets' => $snapshotTargets, 'targets' => $snapshotTargets,
'alerts' => $alerts, 'alerts' => $alerts,
'checksum' => [
'window_minutes' => $checksumWindowMinutes,
'mismatch_total' => array_sum($checksumMismatches),
],
]; ];
$ttlMinutes = max(1, (int) config('storage-monitor.monitor.cache_minutes', 15)); $ttlMinutes = max(1, (int) config('storage-monitor.monitor.cache_minutes', 15));
@@ -191,4 +220,62 @@ class MonitorStorageCommand extends Command
return 'ok'; return 'ok';
} }
private function checksumAlertConfig(): array
{
$enabled = (bool) config('storage-monitor.checksum_validation.enabled', true);
$windowMinutes = max(0, (int) config('storage-monitor.checksum_validation.alert_window_minutes', 60));
$warning = (int) config('storage-monitor.checksum_validation.thresholds.warning', 1);
$critical = (int) config('storage-monitor.checksum_validation.thresholds.critical', 5);
if ($warning > $critical && $critical > 0) {
[$warning, $critical] = [$critical, $warning];
}
return [
'enabled' => $enabled,
'window_minutes' => $windowMinutes,
'thresholds' => [
'warning' => $warning,
'critical' => $critical,
],
];
}
private function checksumMismatchCounts(int $windowMinutes): array
{
$query = EventMediaAsset::query()
->selectRaw('media_storage_target_id, COUNT(*) as total_count')
->where('status', 'failed')
->where('meta->checksum_status', 'mismatch');
if ($windowMinutes > 0) {
$query->where('updated_at', '>=', now()->subMinutes($windowMinutes));
}
return $query->groupBy('media_storage_target_id')
->get()
->mapWithKeys(fn ($row) => [(int) $row->media_storage_target_id => (int) $row->total_count])
->all();
}
private function determineChecksumSeverity(int $count, array $thresholds): string
{
$warning = (int) ($thresholds['warning'] ?? 1);
$critical = (int) ($thresholds['critical'] ?? 5);
if ($count <= 0) {
return 'ok';
}
if ($critical > 0 && $count >= $critical) {
return 'critical';
}
if ($warning > 0 && $count >= $warning) {
return 'warning';
}
return 'ok';
}
} }

View File

@@ -1,132 +0,0 @@
<?php
namespace App\Console\Commands;
use App\Services\Paddle\PaddleClient;
use Illuminate\Console\Command;
use Illuminate\Support\Arr;
use Illuminate\Support\Facades\Log;
class PaddleRegisterWebhooks extends Command
{
/**
* The name and signature of the console command.
*
* @var string
*/
protected $signature = 'paddle:webhooks:register
{--url= : Destination URL for Paddle webhooks}
{--description= : Description for the webhook destination}
{--events=* : Override event types to subscribe}
{--traffic-source=all : platform|simulation|all}
{--include-sensitive : Include sensitive fields in webhook payloads}
{--show-secret : Output the endpoint secret key}
{--dry-run : Output payload without creating the destination}';
/**
* The console command description.
*
* @var string
*/
protected $description = 'Register Paddle webhook notification settings.';
/**
* Execute the console command.
*/
public function handle(PaddleClient $client): int
{
$destination = (string) ($this->option('url') ?: $this->defaultWebhookUrl());
if ($destination === '') {
$this->error('Webhook destination URL is required. Use --url=...');
return self::FAILURE;
}
$events = collect((array) $this->option('events'))
->filter()
->map(fn ($event) => trim((string) $event))
->filter()
->values()
->all();
if ($events === []) {
$events = config('paddle.webhook_events', []);
}
if ($events === [] || ! is_array($events)) {
$this->error('No webhook events configured. Set config(paddle.webhook_events) or pass --events.');
return self::FAILURE;
}
$trafficSource = (string) $this->option('traffic-source');
$allowedSources = ['platform', 'simulation', 'all'];
if (! in_array($trafficSource, $allowedSources, true)) {
$this->error(sprintf('Invalid traffic source. Use one of: %s', implode(', ', $allowedSources)));
return self::FAILURE;
}
$payload = [
'type' => 'url',
'destination' => $destination,
'description' => $this->resolveDescription(),
'subscribed_events' => $events,
'traffic_source' => $trafficSource,
'include_sensitive_fields' => (bool) $this->option('include-sensitive'),
];
if ((bool) $this->option('dry-run')) {
$this->line(json_encode($payload, JSON_PRETTY_PRINT));
return self::SUCCESS;
}
$response = $client->post('/notification-settings', $payload);
$data = Arr::get($response, 'data', $response);
$id = Arr::get($data, 'id');
$secret = Arr::get($data, 'endpoint_secret_key');
Log::channel('paddle-sync')->info('Paddle webhook registered', [
'notification_setting_id' => $id,
'destination' => $destination,
'traffic_source' => $trafficSource,
]);
$this->info('Paddle webhook registered.');
if ($id) {
$this->line('ID: '.$id);
}
if ($secret && $this->option('show-secret')) {
$this->line('Secret: '.$secret);
} elseif ($secret) {
$this->line('Secret returned (hidden). Use --show-secret to display.');
}
return self::SUCCESS;
}
protected function defaultWebhookUrl(): string
{
$base = rtrim((string) config('app.url'), '/');
return $base !== '' ? $base.'/paddle/webhook' : '';
}
protected function resolveDescription(): string
{
$description = (string) $this->option('description');
if ($description !== '') {
return $description;
}
$environment = (string) config('paddle.environment', 'production');
return sprintf('Fotospiel Paddle webhooks (%s)', $environment);
}
}

View File

@@ -26,7 +26,7 @@ class SeedDemoSwitcherTenants extends Command
{ {
protected $signature = 'demo:seed-switcher {--with-photos : Download sample photos from Pexels} {--photos-per-event=18 : Target photos per event when downloading} {--cleanup : Remove demo switcher tenants/events/photos instead of seeding}'; protected $signature = 'demo:seed-switcher {--with-photos : Download sample photos from Pexels} {--photos-per-event=18 : Target photos per event when downloading} {--cleanup : Remove demo switcher tenants/events/photos instead of seeding}';
protected $description = 'Seeds demo tenants used by the DevTenantSwitcher (endcustomer + reseller profiles)'; protected $description = 'Seeds demo tenants used by the DevTenantSwitcher (endcustomer + partner profiles)';
public function __construct(private EventStorageManager $eventStorageManager) public function __construct(private EventStorageManager $eventStorageManager)
{ {
@@ -52,7 +52,7 @@ class SeedDemoSwitcherTenants extends Command
DB::transaction(function () use ($packages, $eventTypes) { DB::transaction(function () use ($packages, $eventTypes) {
$this->seedCustomerStandardEmpty($packages, $eventTypes); $this->seedCustomerStandardEmpty($packages, $eventTypes);
$this->seedCustomerStarterWedding($packages, $eventTypes); $this->seedCustomerStandardWedding($packages, $eventTypes);
$this->seedResellerActive($packages, $eventTypes); $this->seedResellerActive($packages, $eventTypes);
$this->seedResellerFull($packages, $eventTypes); $this->seedResellerFull($packages, $eventTypes);
}); });
@@ -129,7 +129,7 @@ class SeedDemoSwitcherTenants extends Command
$slugs = [ $slugs = [
'starter' => 'Starter', 'starter' => 'Starter',
'standard' => 'Standard', 'standard' => 'Standard',
's-small-reseller' => 'Reseller S', 's-small-reseller' => 'Partner Start',
]; ];
$packages = []; $packages = [];
@@ -165,10 +165,10 @@ class SeedDemoSwitcherTenants extends Command
{ {
$tenant = $this->upsertTenant( $tenant = $this->upsertTenant(
slug: 'demo-standard-empty', slug: 'demo-standard-empty',
name: 'Demo Standard (ohne Event)', name: 'Demo Starter (ohne Event)',
contactEmail: 'standard-empty@demo.fotospiel', contactEmail: 'standard-empty@demo.fotospiel',
attributes: [ attributes: [
'subscription_tier' => 'standard', 'subscription_tier' => 'starter',
'subscription_status' => 'active', 'subscription_status' => 'active',
], ],
); );
@@ -176,9 +176,9 @@ class SeedDemoSwitcherTenants extends Command
$this->upsertAdmin($tenant, 'standard-empty@demo.fotospiel'); $this->upsertAdmin($tenant, 'standard-empty@demo.fotospiel');
TenantPackage::updateOrCreate( TenantPackage::updateOrCreate(
['tenant_id' => $tenant->id, 'package_id' => $packages['standard']->id], ['tenant_id' => $tenant->id, 'package_id' => $packages['starter']->id],
[ [
'price' => $packages['standard']->price, 'price' => $packages['starter']->price,
'purchased_at' => Carbon::now()->subDays(1), 'purchased_at' => Carbon::now()->subDays(1),
'expires_at' => Carbon::now()->addMonths(12), 'expires_at' => Carbon::now()->addMonths(12),
'used_events' => 0, 'used_events' => 0,
@@ -186,17 +186,17 @@ class SeedDemoSwitcherTenants extends Command
] ]
); );
$this->comment('Seeded Standard tenant without events.'); $this->comment('Seeded Starter tenant without events.');
} }
private function seedCustomerStarterWedding(array $packages, array $eventTypes): void private function seedCustomerStandardWedding(array $packages, array $eventTypes): void
{ {
$tenant = $this->upsertTenant( $tenant = $this->upsertTenant(
slug: 'demo-starter-wedding', slug: 'demo-starter-wedding',
name: 'Demo Starter Wedding', name: 'Demo Standard Wedding',
contactEmail: 'starter-wedding@demo.fotospiel', contactEmail: 'starter-wedding@demo.fotospiel',
attributes: [ attributes: [
'subscription_tier' => 'starter', 'subscription_tier' => 'standard',
'subscription_status' => 'active', 'subscription_status' => 'active',
], ],
); );
@@ -209,7 +209,7 @@ class SeedDemoSwitcherTenants extends Command
'price' => $packages['standard']->price, 'price' => $packages['standard']->price,
'purchased_at' => Carbon::now()->subDays(1), 'purchased_at' => Carbon::now()->subDays(1),
'expires_at' => Carbon::now()->addMonths(12), 'expires_at' => Carbon::now()->addMonths(12),
'used_events' => 0, 'used_events' => 1,
'active' => true, 'active' => true,
] ]
); );
@@ -232,17 +232,18 @@ class SeedDemoSwitcherTenants extends Command
private function seedResellerActive(array $packages, array $eventTypes): void private function seedResellerActive(array $packages, array $eventTypes): void
{ {
$eventPackage = $this->resolveIncludedPackage($packages['s-small-reseller'], $packages);
$tenant = $this->upsertTenant( $tenant = $this->upsertTenant(
slug: 'demo-reseller-active', slug: 'demo-reseller-active',
name: 'Demo Reseller Active', name: 'Demo Partner Active',
contactEmail: 'reseller-active@demo.fotospiel', contactEmail: 'partner-active@demo.fotospiel',
attributes: [ attributes: [
'subscription_tier' => 'reseller', 'subscription_tier' => 'reseller',
'subscription_status' => 'active', 'subscription_status' => 'active',
], ],
); );
$this->upsertAdmin($tenant, 'reseller-active@demo.fotospiel'); $this->upsertAdmin($tenant, 'partner-active@demo.fotospiel');
TenantPackage::updateOrCreate( TenantPackage::updateOrCreate(
['tenant_id' => $tenant->id, 'package_id' => $packages['s-small-reseller']->id], ['tenant_id' => $tenant->id, 'package_id' => $packages['s-small-reseller']->id],
@@ -279,7 +280,7 @@ class SeedDemoSwitcherTenants extends Command
foreach ($events as $index => $config) { foreach ($events as $index => $config) {
$event = $this->upsertEvent( $event = $this->upsertEvent(
tenant: $tenant, tenant: $tenant,
package: $packages['standard'], package: $eventPackage,
eventType: $config['type'], eventType: $config['type'],
attributes: [ attributes: [
'name' => $config['name'], 'name' => $config['name'],
@@ -296,17 +297,18 @@ class SeedDemoSwitcherTenants extends Command
private function seedResellerFull(array $packages, array $eventTypes): void private function seedResellerFull(array $packages, array $eventTypes): void
{ {
$eventPackage = $this->resolveIncludedPackage($packages['s-small-reseller'], $packages);
$tenant = $this->upsertTenant( $tenant = $this->upsertTenant(
slug: 'demo-reseller-full', slug: 'demo-reseller-full',
name: 'Demo Reseller Voll', name: 'Demo Partner Voll',
contactEmail: 'reseller-full@demo.fotospiel', contactEmail: 'partner-full@demo.fotospiel',
attributes: [ attributes: [
'subscription_tier' => 'reseller', 'subscription_tier' => 'reseller',
'subscription_status' => 'active', 'subscription_status' => 'active',
], ],
); );
$this->upsertAdmin($tenant, 'reseller-full@demo.fotospiel'); $this->upsertAdmin($tenant, 'partner-full@demo.fotospiel');
TenantPackage::updateOrCreate( TenantPackage::updateOrCreate(
['tenant_id' => $tenant->id, 'package_id' => $packages['s-small-reseller']->id], ['tenant_id' => $tenant->id, 'package_id' => $packages['s-small-reseller']->id],
@@ -330,7 +332,7 @@ class SeedDemoSwitcherTenants extends Command
foreach ($eventConfigs as $index => $config) { foreach ($eventConfigs as $index => $config) {
$event = $this->upsertEvent( $event = $this->upsertEvent(
tenant: $tenant, tenant: $tenant,
package: $packages['standard'], package: $eventPackage,
eventType: $config['type'], eventType: $config['type'],
attributes: [ attributes: [
'name' => $config['name'], 'name' => $config['name'],
@@ -357,8 +359,8 @@ class SeedDemoSwitcherTenants extends Command
'settings' => [ 'settings' => [
'branding' => [ 'branding' => [
'logo_url' => null, 'logo_url' => null,
'primary_color' => '#1D4ED8', 'primary_color' => '#FF5A5F',
'secondary_color' => '#0F172A', 'secondary_color' => '#FFF8F5',
'font_family' => 'Inter, sans-serif', 'font_family' => 'Inter, sans-serif',
], ],
'features' => [ 'features' => [
@@ -435,6 +437,19 @@ class SeedDemoSwitcherTenants extends Command
return $event; return $event;
} }
private function resolveIncludedPackage(Package $resellerPackage, array $packages): Package
{
$includedSlug = $resellerPackage->included_package_slug;
if ($includedSlug && isset($packages[$includedSlug])) {
return $packages[$includedSlug];
}
$fallback = $packages['starter'] ?? $packages['standard'] ?? null;
return $fallback ?? $resellerPackage;
}
private function fallbackEventType(): ?EventType private function fallbackEventType(): ?EventType
{ {
$fallback = EventType::first(); $fallback = EventType::first();

View File

@@ -62,7 +62,7 @@ class SendAbandonedCheckoutReminders extends Command
if ($this->shouldSendReminder($checkout, $stage)) { if ($this->shouldSendReminder($checkout, $stage)) {
$resumeUrl = $this->generateResumeUrl($checkout); $resumeUrl = $this->generateResumeUrl($checkout);
if (!$isDryRun) { if (! $isDryRun) {
$mailLocale = $checkout->user->preferred_locale ?? config('app.locale'); $mailLocale = $checkout->user->preferred_locale ?? config('app.locale');
Mail::to($checkout->user) Mail::to($checkout->user)
@@ -86,8 +86,8 @@ class SendAbandonedCheckoutReminders extends Command
$totalProcessed++; $totalProcessed++;
} }
} catch (Throwable $e) { } catch (Throwable $e) {
Log::error("Failed to send {$stage} reminder for checkout {$checkout->id}: " . $e->getMessage()); Log::error("Failed to send {$stage} reminder for checkout {$checkout->id}: ".$e->getMessage());
$this->error(" ❌ Failed to process checkout {$checkout->id}: " . $e->getMessage()); $this->error(" ❌ Failed to process checkout {$checkout->id}: ".$e->getMessage());
} }
} }
} }
@@ -98,7 +98,7 @@ class SendAbandonedCheckoutReminders extends Command
->count(); ->count();
if ($oldCheckouts > 0) { if ($oldCheckouts > 0) {
if (!$isDryRun) { if (! $isDryRun) {
AbandonedCheckoutModel::where('abandoned_at', '<', now()->subDays(30)) AbandonedCheckoutModel::where('abandoned_at', '<', now()->subDays(30))
->where('converted', false) ->where('converted', false)
->delete(); ->delete();
@@ -108,10 +108,10 @@ class SendAbandonedCheckoutReminders extends Command
} }
} }
$this->info("✅ Reminder process completed!"); $this->info('✅ Reminder process completed!');
$this->info(" Processed: {$totalProcessed} checkouts"); $this->info(" Processed: {$totalProcessed} checkouts");
if (!$isDryRun) { if (! $isDryRun) {
$this->info(" Sent: {$totalSent} reminder emails"); $this->info(" Sent: {$totalSent} reminder emails");
} else { } else {
$this->info(" Would send: {$totalSent} reminder emails"); $this->info(" Would send: {$totalSent} reminder emails");
@@ -131,12 +131,12 @@ class SendAbandonedCheckoutReminders extends Command
} }
// User existiert noch? // User existiert noch?
if (!$checkout->user) { if (! $checkout->user) {
return false; return false;
} }
// Package existiert noch? // Package existiert noch?
if (!$checkout->package) { if (! $checkout->package) {
return false; return false;
} }

View File

@@ -12,7 +12,7 @@ use Illuminate\Support\Str;
class SyncGoogleFonts extends Command class SyncGoogleFonts extends Command
{ {
protected $signature = 'fonts:sync-google {--count=50 : Number of popular fonts to fetch} {--weights=400,700 : Comma separated numeric font weights to download} {--italic : Also download italic variants where available} {--force : Re-download files even if they exist} {--path= : Optional custom output directory (defaults to public/fonts/google)} {--family= : Download specific family name(s), comma separated (case-insensitive)} {--category= : Filter by category, comma separated (e.g. sans-serif,serif)} {--prune : Remove local font families not included in this sync} {--dry-run : Show what would be downloaded without writing files}'; protected $signature = 'fonts:sync-google {--count=50 : Number of popular fonts to fetch} {--weights=400,700 : Comma separated numeric font weights to download} {--italic : Also download italic variants where available} {--force : Re-download files even if they exist} {--path= : Optional custom output directory (defaults to public/fonts/google)} {--family= : Download specific family name(s), comma separated (case-insensitive)} {--category= : Filter by category, comma separated (e.g. sans-serif,serif)} {--prune : Remove local font families not included in this sync} {--dry-run : Show what would be downloaded without writing files} {--from-disk : Rebuild manifest + CSS from existing font files without downloading}';
protected $description = 'Download the most popular Google Fonts to the local public/fonts/google directory and generate a manifest + CSS file.'; protected $description = 'Download the most popular Google Fonts to the local public/fonts/google directory and generate a manifest + CSS file.';
@@ -20,6 +20,17 @@ class SyncGoogleFonts extends Command
public function handle(): int public function handle(): int
{ {
$dryRun = (bool) $this->option('dry-run');
$fromDisk = (bool) $this->option('from-disk');
$pathOption = $this->option('path');
$basePath = $pathOption
? (Str::startsWith($pathOption, DIRECTORY_SEPARATOR) ? $pathOption : base_path($pathOption))
: public_path('fonts/google');
if ($fromDisk) {
return $this->syncFromDisk($basePath, $dryRun);
}
$apiKey = config('services.google_fonts.key'); $apiKey = config('services.google_fonts.key');
if (! $apiKey) { if (! $apiKey) {
@@ -32,16 +43,10 @@ class SyncGoogleFonts extends Command
$weights = $this->prepareWeights($this->option('weights')); $weights = $this->prepareWeights($this->option('weights'));
$includeItalic = (bool) $this->option('italic'); $includeItalic = (bool) $this->option('italic');
$force = (bool) $this->option('force'); $force = (bool) $this->option('force');
$dryRun = (bool) $this->option('dry-run');
$families = $this->normalizeFamilyOption($this->option('family')); $families = $this->normalizeFamilyOption($this->option('family'));
$categories = $this->prepareCategories($this->option('category')); $categories = $this->prepareCategories($this->option('category'));
$prune = (bool) $this->option('prune'); $prune = (bool) $this->option('prune');
$pathOption = $this->option('path');
$basePath = $pathOption
? (Str::startsWith($pathOption, DIRECTORY_SEPARATOR) ? $pathOption : base_path($pathOption))
: public_path('fonts/google');
if (count($families)) { if (count($families)) {
$label = count($families) > 1 ? 'families' : 'family'; $label = count($families) > 1 ? 'families' : 'family';
$this->info(sprintf('Fetching Google Font %s "%s" (weights: %s, italic: %s)...', $label, implode(', ', $families), implode(', ', $weights), $includeItalic ? 'yes' : 'no')); $this->info(sprintf('Fetching Google Font %s "%s" (weights: %s, italic: %s)...', $label, implode(', ', $families), implode(', ', $weights), $includeItalic ? 'yes' : 'no'));
@@ -206,6 +211,204 @@ class SyncGoogleFonts extends Command
return self::SUCCESS; return self::SUCCESS;
} }
private function syncFromDisk(string $basePath, bool $dryRun): int
{
if (! File::isDirectory($basePath)) {
$this->error(sprintf('Font directory not found: %s', $basePath));
return self::FAILURE;
}
if ($this->option('prune')) {
$this->warn('Ignoring --prune when rebuilding from disk.');
}
$fonts = $this->buildManifestFromDisk($basePath);
if (! count($fonts)) {
$this->warn('No fonts found on disk.');
}
if ($dryRun) {
$this->info(sprintf('Dry run complete: %d font families would be written to %s', count($fonts), $basePath));
return self::SUCCESS;
}
$this->writeManifest($basePath, $fonts);
$this->writeCss($basePath, $fonts);
Cache::forget('fonts:manifest');
$this->info(sprintf('Rebuilt manifest for %d font families from %s', count($fonts), $basePath));
return self::SUCCESS;
}
/**
* @return array<int, array<string, mixed>>
*/
private function buildManifestFromDisk(string $basePath): array
{
$directories = File::directories($basePath);
$fonts = [];
foreach ($directories as $dir) {
$slug = basename($dir);
$files = collect(File::files($dir))
->filter(function (\SplFileInfo $file) {
$extension = strtolower($file->getExtension());
return in_array($extension, ['woff2', 'woff', 'otf', 'ttf'], true);
})
->values();
if (! $files->count()) {
continue;
}
$variantsByKey = [];
foreach ($files as $file) {
$filename = $file->getFilename();
$extension = strtolower($file->getExtension());
$style = $this->extractStyleFromFilename($filename);
$weight = $this->extractWeightFromFilename($filename);
$variantKey = $this->buildVariantKey($weight, $style);
$priority = $this->extensionPriority($extension);
$relativePath = sprintf('/fonts/google/%s/%s', $slug, $filename);
$existing = $variantsByKey[$variantKey] ?? null;
if ($existing && ($existing['priority'] ?? 0) >= $priority) {
continue;
}
$variantsByKey[$variantKey] = [
'variant' => $variantKey,
'weight' => $weight,
'style' => $style,
'url' => $relativePath,
'priority' => $priority,
];
}
if (! count($variantsByKey)) {
continue;
}
$variants = array_values(array_map(function (array $variant) {
unset($variant['priority']);
return $variant;
}, $variantsByKey));
usort($variants, function (array $left, array $right) {
$weightCompare = ($left['weight'] ?? 400) <=> ($right['weight'] ?? 400);
if ($weightCompare !== 0) {
return $weightCompare;
}
return strcmp((string) ($left['style'] ?? 'normal'), (string) ($right['style'] ?? 'normal'));
});
$fonts[] = [
'family' => $this->familyFromSlug($slug),
'slug' => $slug,
'category' => null,
'variants' => $variants,
];
}
usort($fonts, fn (array $left, array $right) => strcmp((string) $left['family'], (string) $right['family']));
return $fonts;
}
private function familyFromSlug(string $slug): string
{
$parts = array_filter(explode('-', $slug), fn ($part) => $part !== '');
$words = array_map(function (string $part) {
if (is_numeric($part)) {
return $part;
}
if (strlen($part) <= 3) {
return strtoupper($part);
}
return ucfirst(strtolower($part));
}, $parts);
return trim(implode(' ', $words));
}
private function extractStyleFromFilename(string $filename): string
{
$lower = strtolower($filename);
return str_contains($lower, 'italic') || str_contains($lower, 'oblique') ? 'italic' : 'normal';
}
private function extractWeightFromFilename(string $filename): int
{
if (preg_match('/(?:^|[^0-9])(100|200|300|400|500|600|700|800|900)(?:[^0-9]|$)/', $filename, $matches)) {
return (int) $matches[1];
}
$lower = strtolower($filename);
$weightMap = [
'thin' => 100,
'extralight' => 200,
'ultralight' => 200,
'light' => 300,
'regular' => 400,
'book' => 400,
'medium' => 500,
'semibold' => 600,
'demibold' => 600,
'bold' => 700,
'extrabold' => 800,
'ultrabold' => 800,
'black' => 900,
'heavy' => 900,
];
foreach ($weightMap as $label => $weight) {
if (str_contains($lower, $label)) {
return $weight;
}
}
return 400;
}
private function buildVariantKey(int $weight, string $style): string
{
if ($weight === 400 && $style === 'normal') {
return 'regular';
}
if ($weight === 400 && $style === 'italic') {
return 'italic';
}
if ($style === 'italic') {
return $weight.'italic';
}
return (string) $weight;
}
private function extensionPriority(string $extension): int
{
return match ($extension) {
'woff2' => 4,
'woff' => 3,
'otf' => 2,
'ttf' => 1,
default => 0,
};
}
/** /**
* @return array<int, string> * @return array<int, string>
*/ */

View File

@@ -5,8 +5,6 @@ namespace App\Exports;
use App\Models\EventPurchase; use App\Models\EventPurchase;
use Filament\Actions\Exports\Exporter; use Filament\Actions\Exports\Exporter;
use Filament\Actions\Exports\Models\Export; use Filament\Actions\Exports\Models\Export;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Support\Collection;
class EventPurchaseExporter extends Exporter class EventPurchaseExporter extends Exporter
{ {
@@ -28,7 +26,6 @@ class EventPurchaseExporter extends Exporter
]; ];
} }
public static function getCompletedNotificationBody(Export $export): string public static function getCompletedNotificationBody(Export $export): string
{ {
$body = "Your Event Purchases export has completed and is ready for download. {$export->successful_rows} purchases were exported."; $body = "Your Event Purchases export has completed and is ready for download. {$export->successful_rows} purchases were exported.";

View File

@@ -79,9 +79,10 @@ class PostResource extends Resource
->label('Inhalt') ->label('Inhalt')
->required() ->required()
->columnSpanFull(), ->columnSpanFull(),
TextInput::make('excerpt.de') Textarea::make('excerpt.de')
->label('Auszug') ->label('Auszug')
->maxLength(255), ->maxLength(65535)
->columnSpanFull(),
TextInput::make('meta_title.de') TextInput::make('meta_title.de')
->label('Meta-Titel') ->label('Meta-Titel')
->maxLength(255), ->maxLength(255),
@@ -99,9 +100,10 @@ class PostResource extends Resource
MarkdownEditor::make('content.en') MarkdownEditor::make('content.en')
->label('Inhalt') ->label('Inhalt')
->columnSpanFull(), ->columnSpanFull(),
TextInput::make('excerpt.en') Textarea::make('excerpt.en')
->label('Auszug') ->label('Auszug')
->maxLength(255), ->maxLength(65535)
->columnSpanFull(),
TextInput::make('meta_title.en') TextInput::make('meta_title.en')
->label('Meta-Titel') ->label('Meta-Titel')
->maxLength(255), ->maxLength(255),
@@ -121,9 +123,10 @@ class PostResource extends Resource
->unique(BlogPost::class, 'slug', ignoreRecord: true) ->unique(BlogPost::class, 'slug', ignoreRecord: true)
->maxLength(255) ->maxLength(255)
->columnSpanFull(), ->columnSpanFull(),
FileUpload::make('featured_image') FileUpload::make('banner')
->label('Featured Image') ->label('Featured Image')
->image() ->image()
->disk('public')
->directory('blog') ->directory('blog')
->visibility('public'), ->visibility('public'),
Select::make('blog_category_id') Select::make('blog_category_id')

View File

@@ -0,0 +1,16 @@
<?php
namespace App\Filament\Clusters\DailyOps\Resources\TenantLemonSqueezyHealths\Pages;
use App\Filament\Clusters\DailyOps\Resources\TenantLemonSqueezyHealths\TenantLemonSqueezyHealthResource;
use Filament\Resources\Pages\ListRecords;
class ListTenantLemonSqueezyHealths extends ListRecords
{
protected static string $resource = TenantLemonSqueezyHealthResource::class;
protected function getHeaderActions(): array
{
return [];
}
}

View File

@@ -1,8 +1,8 @@
<?php <?php
namespace App\Filament\Clusters\DailyOps\Resources\TenantPaddleHealths\Tables; namespace App\Filament\Clusters\DailyOps\Resources\TenantLemonSqueezyHealths\Tables;
use App\Filament\Clusters\DailyOps\Resources\TenantPaddleHealths\TenantPaddleHealthResource; use App\Filament\Clusters\DailyOps\Resources\TenantLemonSqueezyHealths\TenantLemonSqueezyHealthResource;
use App\Models\CheckoutSession; use App\Models\CheckoutSession;
use App\Models\Tenant; use App\Models\Tenant;
use Filament\Forms\Components\TextInput; use Filament\Forms\Components\TextInput;
@@ -15,7 +15,7 @@ use Illuminate\Database\Eloquent\Builder;
use Illuminate\Support\Carbon; use Illuminate\Support\Carbon;
use Illuminate\Support\Str; use Illuminate\Support\Str;
class TenantPaddleHealthTable class TenantLemonSqueezyHealthTable
{ {
private const FAILED_SYNC_STATUSES = ['failed', 'pull-failed']; private const FAILED_SYNC_STATUSES = ['failed', 'pull-failed'];
@@ -35,8 +35,8 @@ class TenantPaddleHealthTable
->label(__('admin.tenants.fields.contact_email')) ->label(__('admin.tenants.fields.contact_email'))
->searchable() ->searchable()
->toggleable(isToggledHiddenByDefault: true), ->toggleable(isToggledHiddenByDefault: true),
TextColumn::make('paddle_customer_id') TextColumn::make('lemonsqueezy_customer_id')
->label('Paddle customer') ->label('Lemon Squeezy customer')
->toggleable(isToggledHiddenByDefault: true) ->toggleable(isToggledHiddenByDefault: true)
->copyable() ->copyable()
->formatStateUsing(fn (?string $state) => $state ?: '—'), ->formatStateUsing(fn (?string $state) => $state ?: '—'),
@@ -56,27 +56,27 @@ class TenantPaddleHealthTable
->badge() ->badge()
->color(fn (string $state) => $state === '—' ? 'gray' : 'success') ->color(fn (string $state) => $state === '—' ? 'gray' : 'success')
->toggleable(isToggledHiddenByDefault: true), ->toggleable(isToggledHiddenByDefault: true),
TextColumn::make('paddle_subscription_id') TextColumn::make('lemonsqueezy_subscription_id')
->label('Paddle subscription') ->label('Lemon Squeezy subscription')
->toggleable(isToggledHiddenByDefault: true) ->toggleable(isToggledHiddenByDefault: true)
->copyable() ->copyable()
->getStateUsing(fn (Tenant $record) => $record->activeResellerPackage?->paddle_subscription_id) ->getStateUsing(fn (Tenant $record) => $record->activeResellerPackage?->lemonsqueezy_subscription_id)
->formatStateUsing(fn (?string $state) => $state ?: '—'), ->formatStateUsing(fn (?string $state) => $state ?: '—'),
IconColumn::make('missing_paddle_subscription') IconColumn::make('missing_lemonsqueezy_subscription')
->label('Missing Paddle subscription') ->label('Missing Lemon Squeezy subscription')
->boolean() ->boolean()
->getStateUsing(fn (Tenant $record) => self::missingPaddleSubscription($record)), ->getStateUsing(fn (Tenant $record) => self::missingLemonSqueezySubscription($record)),
IconColumn::make('status_mismatch') IconColumn::make('status_mismatch')
->label('Status mismatch') ->label('Status mismatch')
->boolean() ->boolean()
->getStateUsing(fn (Tenant $record) => self::hasStatusMismatch($record)), ->getStateUsing(fn (Tenant $record) => self::hasStatusMismatch($record)),
TextColumn::make('paddle_customer_duplicates') TextColumn::make('lemonsqueezy_customer_duplicates')
->label('Paddle duplicates') ->label('Lemon Squeezy duplicates')
->sortable() ->sortable()
->toggleable(isToggledHiddenByDefault: true) ->toggleable(isToggledHiddenByDefault: true)
->formatStateUsing(fn (?int $state) => $state && $state > 1 ? (string) $state : '—'), ->formatStateUsing(fn (?int $state) => $state && $state > 1 ? (string) $state : '—'),
TextColumn::make('paddle_sync_status') TextColumn::make('lemonsqueezy_sync_status')
->label('Paddle sync') ->label('Lemon Squeezy sync')
->badge() ->badge()
->color(fn (?string $state) => match ($state) { ->color(fn (?string $state) => match ($state) {
'synced' => 'success', 'synced' => 'success',
@@ -87,101 +87,101 @@ class TenantPaddleHealthTable
default => 'gray', default => 'gray',
}) })
->formatStateUsing(fn (?string $state) => $state ? Str::headline($state) : '—') ->formatStateUsing(fn (?string $state) => $state ? Str::headline($state) : '—')
->getStateUsing(fn (Tenant $record) => $record->activeResellerPackage?->package?->paddle_sync_status) ->getStateUsing(fn (Tenant $record) => $record->activeResellerPackage?->package?->lemonsqueezy_sync_status)
->toggleable(isToggledHiddenByDefault: true), ->toggleable(isToggledHiddenByDefault: true),
TextColumn::make('paddle_synced_at') TextColumn::make('lemonsqueezy_synced_at')
->label('Paddle synced') ->label('Lemon Squeezy synced')
->badge() ->badge()
->color(fn ($state) => self::syncAgeColor($state)) ->color(fn ($state) => self::syncAgeColor($state))
->formatStateUsing(fn ($state) => $state?->diffForHumans() ?? '—') ->formatStateUsing(fn ($state) => $state?->diffForHumans() ?? '—')
->getStateUsing(fn (Tenant $record) => $record->activeResellerPackage?->package?->paddle_synced_at), ->getStateUsing(fn (Tenant $record) => $record->activeResellerPackage?->package?->lemonsqueezy_synced_at),
TextColumn::make('last_paddle_transaction_at') TextColumn::make('last_lemonsqueezy_transaction_at')
->label('Last Paddle tx') ->label('Last Lemon Squeezy tx')
->badge() ->badge()
->color(fn (?Carbon $state) => self::transactionAgeColor($state)) ->color(fn (?Carbon $state) => self::transactionAgeColor($state))
->getStateUsing(fn (Tenant $record) => $record->last_paddle_transaction_at ->getStateUsing(fn (Tenant $record) => $record->last_lemonsqueezy_transaction_at
? Carbon::parse($record->last_paddle_transaction_at) ? Carbon::parse($record->last_lemonsqueezy_transaction_at)
: null) : null)
->formatStateUsing(fn (?Carbon $state) => $state?->diffForHumans() ?? '—') ->formatStateUsing(fn (?Carbon $state) => $state?->diffForHumans() ?? '—')
->toggleable(isToggledHiddenByDefault: true), ->toggleable(isToggledHiddenByDefault: true),
TextColumn::make('paddle_transaction_count_window') TextColumn::make('lemonsqueezy_transaction_count_window')
->label('Paddle tx (30d)') ->label('Lemon Squeezy tx (30d)')
->default('0') ->default('0')
->sortable() ->sortable()
->toggleable(), ->toggleable(),
TextColumn::make('paddle_transaction_total_window') TextColumn::make('lemonsqueezy_transaction_total_window')
->label('Paddle total (30d)') ->label('Lemon Squeezy total (30d)')
->default(0) ->default(0)
->money('EUR') ->money('EUR')
->sortable() ->sortable()
->toggleable(), ->toggleable(),
TextColumn::make('paddle_refund_count_window') TextColumn::make('lemonsqueezy_refund_count_window')
->label('Refunds (30d)') ->label('Refunds (30d)')
->badge() ->badge()
->color(fn (?int $state) => $state && $state > 0 ? 'danger' : 'gray') ->color(fn (?int $state) => $state && $state > 0 ? 'danger' : 'gray')
->default('0') ->default('0')
->sortable() ->sortable()
->toggleable(isToggledHiddenByDefault: true), ->toggleable(isToggledHiddenByDefault: true),
TextColumn::make('paddle_refund_total_window') TextColumn::make('lemonsqueezy_refund_total_window')
->label('Refund total (30d)') ->label('Refund total (30d)')
->default(0) ->default(0)
->money('EUR') ->money('EUR')
->sortable() ->sortable()
->toggleable(isToggledHiddenByDefault: true), ->toggleable(isToggledHiddenByDefault: true),
TextColumn::make('paddle_checkout_requires_action_count') TextColumn::make('lemonsqueezy_checkout_requires_action_count')
->label('Checkout action required') ->label('Checkout action required')
->badge() ->badge()
->color(fn (?int $state) => $state && $state > 0 ? 'warning' : 'gray') ->color(fn (?int $state) => $state && $state > 0 ? 'warning' : 'gray')
->default('0') ->default('0')
->sortable() ->sortable()
->toggleable(isToggledHiddenByDefault: true), ->toggleable(isToggledHiddenByDefault: true),
TextColumn::make('paddle_checkout_processing_count') TextColumn::make('lemonsqueezy_checkout_processing_count')
->label('Checkout processing') ->label('Checkout processing')
->badge() ->badge()
->color(fn (?int $state) => $state && $state > 0 ? 'warning' : 'gray') ->color(fn (?int $state) => $state && $state > 0 ? 'warning' : 'gray')
->default('0') ->default('0')
->sortable() ->sortable()
->toggleable(isToggledHiddenByDefault: true), ->toggleable(isToggledHiddenByDefault: true),
TextColumn::make('paddle_checkout_expired_count') TextColumn::make('lemonsqueezy_checkout_expired_count')
->label('Checkout expired') ->label('Checkout expired')
->badge() ->badge()
->color(fn (?int $state) => $state && $state > 0 ? 'danger' : 'gray') ->color(fn (?int $state) => $state && $state > 0 ? 'danger' : 'gray')
->default('0') ->default('0')
->sortable() ->sortable()
->toggleable(isToggledHiddenByDefault: true), ->toggleable(isToggledHiddenByDefault: true),
TextColumn::make('paddle_transaction_count') TextColumn::make('lemonsqueezy_transaction_count')
->label('Paddle tx (all)') ->label('Lemon Squeezy tx (all)')
->default('0') ->default('0')
->sortable() ->sortable()
->toggleable(isToggledHiddenByDefault: true), ->toggleable(isToggledHiddenByDefault: true),
TextColumn::make('paddle_transaction_total') TextColumn::make('lemonsqueezy_transaction_total')
->label('Paddle total (all)') ->label('Lemon Squeezy total (all)')
->default(0) ->default(0)
->money('EUR') ->money('EUR')
->sortable() ->sortable()
->toggleable(isToggledHiddenByDefault: true), ->toggleable(isToggledHiddenByDefault: true),
]) ])
->filters([ ->filters([
Filter::make('missing_paddle_customer') Filter::make('missing_lemonsqueezy_customer')
->label('Missing Paddle customer') ->label('Missing Lemon Squeezy customer')
->indicator('Missing Paddle customer') ->indicator('Missing Lemon Squeezy customer')
->query(fn (Builder $query) => $query->whereNull('paddle_customer_id')), ->query(fn (Builder $query) => $query->whereNull('lemonsqueezy_customer_id')),
Filter::make('missing_paddle_subscription') Filter::make('missing_lemonsqueezy_subscription')
->label('Missing Paddle subscription') ->label('Missing Lemon Squeezy subscription')
->indicator('Missing Paddle subscription') ->indicator('Missing Lemon Squeezy subscription')
->query(fn (Builder $query) => $query->whereHas('activeResellerPackage', fn (Builder $query) => $query ->query(fn (Builder $query) => $query->whereHas('activeResellerPackage', fn (Builder $query) => $query
->where('active', true) ->where('active', true)
->whereNull('paddle_subscription_id'))), ->whereNull('lemonsqueezy_subscription_id'))),
Filter::make('duplicate_paddle_customer') Filter::make('duplicate_lemonsqueezy_customer')
->label('Duplicate Paddle customer') ->label('Duplicate Lemon Squeezy customer')
->indicator('Duplicate Paddle customer') ->indicator('Duplicate Lemon Squeezy customer')
->query(fn (Builder $query) => $query ->query(fn (Builder $query) => $query
->whereNotNull('paddle_customer_id') ->whereNotNull('lemonsqueezy_customer_id')
->whereIn('paddle_customer_id', function ($subquery) { ->whereIn('lemonsqueezy_customer_id', function ($subquery) {
$subquery->select('paddle_customer_id') $subquery->select('lemonsqueezy_customer_id')
->from('tenants') ->from('tenants')
->whereNotNull('paddle_customer_id') ->whereNotNull('lemonsqueezy_customer_id')
->groupBy('paddle_customer_id') ->groupBy('lemonsqueezy_customer_id')
->havingRaw('count(*) > 1'); ->havingRaw('count(*) > 1');
})), })),
Filter::make('status_mismatch') Filter::make('status_mismatch')
@@ -205,39 +205,39 @@ class TenantPaddleHealthTable
->where('is_suspended', false) ->where('is_suspended', false)
->whereNull('pending_deletion_at') ->whereNull('pending_deletion_at')
->whereNull('anonymized_at')), ->whereNull('anonymized_at')),
Filter::make('paddle_sync_failed') Filter::make('lemonsqueezy_sync_failed')
->label('Paddle sync failed') ->label('Lemon Squeezy sync failed')
->indicator('Paddle sync failed') ->indicator('Lemon Squeezy sync failed')
->query(fn (Builder $query) => $query->whereHas('activeResellerPackage.package', fn (Builder $query) => $query ->query(fn (Builder $query) => $query->whereHas('activeResellerPackage.package', fn (Builder $query) => $query
->whereIn('paddle_sync_status', self::FAILED_SYNC_STATUSES))), ->whereIn('lemonsqueezy_sync_status', self::FAILED_SYNC_STATUSES))),
Filter::make('paddle_sync_stale') Filter::make('lemonsqueezy_sync_stale')
->label('Paddle sync stale') ->label('Lemon Squeezy sync stale')
->indicator('Paddle sync stale') ->indicator('Lemon Squeezy sync stale')
->query(fn (Builder $query) => $query->whereHas('activeResellerPackage.package', fn (Builder $query) => $query ->query(fn (Builder $query) => $query->whereHas('activeResellerPackage.package', fn (Builder $query) => $query
->whereNotNull('paddle_synced_at') ->whereNotNull('lemonsqueezy_synced_at')
->where('paddle_synced_at', '<', now()->subDays(TenantPaddleHealthResource::STALE_SYNC_DAYS)))), ->where('lemonsqueezy_synced_at', '<', now()->subDays(TenantLemonSqueezyHealthResource::STALE_SYNC_DAYS)))),
Filter::make('paddle_sync_missing') Filter::make('lemonsqueezy_sync_missing')
->label('Missing Paddle sync timestamp') ->label('Missing Lemon Squeezy sync timestamp')
->indicator('Missing Paddle sync timestamp') ->indicator('Missing Lemon Squeezy sync timestamp')
->query(fn (Builder $query) => $query->whereHas('activeResellerPackage.package', fn (Builder $query) => $query ->query(fn (Builder $query) => $query->whereHas('activeResellerPackage.package', fn (Builder $query) => $query
->whereNull('paddle_synced_at'))), ->whereNull('lemonsqueezy_synced_at'))),
Filter::make('paddle_transaction_stale') Filter::make('lemonsqueezy_transaction_stale')
->label('Stale Paddle transactions') ->label('Stale Lemon Squeezy transactions')
->indicator('Stale Paddle transactions') ->indicator('Stale Lemon Squeezy transactions')
->query(function (Builder $query): Builder { ->query(function (Builder $query): Builder {
$cutoff = now()->subDays(TenantPaddleHealthResource::TRANSACTION_WINDOW_DAYS); $cutoff = now()->subDays(TenantLemonSqueezyHealthResource::TRANSACTION_WINDOW_DAYS);
return $query return $query
->whereHas('purchases', fn (Builder $query) => $query->where('provider', 'paddle')) ->whereHas('purchases', fn (Builder $query) => $query->where('provider', 'lemonsqueezy'))
->whereDoesntHave('purchases', fn (Builder $query) => $query ->whereDoesntHave('purchases', fn (Builder $query) => $query
->where('provider', 'paddle') ->where('provider', 'lemonsqueezy')
->where('purchased_at', '>=', $cutoff)); ->where('purchased_at', '>=', $cutoff));
}), }),
Filter::make('checkout_attention') Filter::make('checkout_attention')
->label('Checkout attention') ->label('Checkout attention')
->indicator('Checkout attention') ->indicator('Checkout attention')
->query(fn (Builder $query) => $query->whereHas('checkoutSessions', function (Builder $query) { ->query(fn (Builder $query) => $query->whereHas('checkoutSessions', function (Builder $query) {
$query->where('provider', 'paddle') $query->where('provider', 'lemonsqueezy')
->where(function (Builder $query) { ->where(function (Builder $query) {
$query->whereIn('status', [ $query->whereIn('status', [
CheckoutSession::STATUS_REQUIRES_CUSTOMER_ACTION, CheckoutSession::STATUS_REQUIRES_CUSTOMER_ACTION,
@@ -274,10 +274,10 @@ class TenantPaddleHealthTable
return $query; return $query;
} }
$cutoff = now()->subDays(TenantPaddleHealthResource::TRANSACTION_WINDOW_DAYS); $cutoff = now()->subDays(TenantLemonSqueezyHealthResource::TRANSACTION_WINDOW_DAYS);
return $query->whereHas('purchases', fn (Builder $query) => $query return $query->whereHas('purchases', fn (Builder $query) => $query
->where('provider', 'paddle') ->where('provider', 'lemonsqueezy')
->where('refunded', true) ->where('refunded', true)
->where('purchased_at', '>=', $cutoff), '>=', $min); ->where('purchased_at', '>=', $cutoff), '>=', $min);
}), }),
@@ -314,11 +314,11 @@ class TenantPaddleHealthTable
return false; return false;
} }
private static function missingPaddleSubscription(Tenant $record): bool private static function missingLemonSqueezySubscription(Tenant $record): bool
{ {
$package = $record->activeResellerPackage; $package = $record->activeResellerPackage;
return $package && $package->active && ! $package->paddle_subscription_id; return $package && $package->active && ! $package->lemonsqueezy_subscription_id;
} }
private static function applyStatusMismatchFilter(Builder $query): Builder private static function applyStatusMismatchFilter(Builder $query): Builder
@@ -344,7 +344,7 @@ class TenantPaddleHealthTable
return 'gray'; return 'gray';
} }
if ($state->lt(now()->subDays(TenantPaddleHealthResource::STALE_SYNC_DAYS))) { if ($state->lt(now()->subDays(TenantLemonSqueezyHealthResource::STALE_SYNC_DAYS))) {
return 'danger'; return 'danger';
} }
@@ -357,7 +357,7 @@ class TenantPaddleHealthTable
return 'gray'; return 'gray';
} }
if ($state->lt(now()->subDays(TenantPaddleHealthResource::TRANSACTION_WINDOW_DAYS))) { if ($state->lt(now()->subDays(TenantLemonSqueezyHealthResource::TRANSACTION_WINDOW_DAYS))) {
return 'danger'; return 'danger';
} }

View File

@@ -1,10 +1,10 @@
<?php <?php
namespace App\Filament\Clusters\DailyOps\Resources\TenantPaddleHealths; namespace App\Filament\Clusters\DailyOps\Resources\TenantLemonSqueezyHealths;
use App\Filament\Clusters\DailyOps\DailyOpsCluster; use App\Filament\Clusters\DailyOps\DailyOpsCluster;
use App\Filament\Clusters\DailyOps\Resources\TenantPaddleHealths\Pages\ListTenantPaddleHealths; use App\Filament\Clusters\DailyOps\Resources\TenantLemonSqueezyHealths\Pages\ListTenantLemonSqueezyHealths;
use App\Filament\Clusters\DailyOps\Resources\TenantPaddleHealths\Tables\TenantPaddleHealthTable; use App\Filament\Clusters\DailyOps\Resources\TenantLemonSqueezyHealths\Tables\TenantLemonSqueezyHealthTable;
use App\Models\CheckoutSession; use App\Models\CheckoutSession;
use App\Models\Tenant; use App\Models\Tenant;
use BackedEnum; use BackedEnum;
@@ -13,7 +13,7 @@ use Filament\Tables\Table;
use Illuminate\Database\Eloquent\Builder; use Illuminate\Database\Eloquent\Builder;
use UnitEnum; use UnitEnum;
class TenantPaddleHealthResource extends Resource class TenantLemonSqueezyHealthResource extends Resource
{ {
public const STALE_SYNC_DAYS = 30; public const STALE_SYNC_DAYS = 30;
@@ -25,13 +25,13 @@ class TenantPaddleHealthResource extends Resource
protected static ?string $cluster = DailyOpsCluster::class; protected static ?string $cluster = DailyOpsCluster::class;
protected static ?string $slug = 'paddle-health'; protected static ?string $slug = 'lemonsqueezy-health';
protected static ?int $navigationSort = 20; protected static ?int $navigationSort = 20;
public static function table(Table $table): Table public static function table(Table $table): Table
{ {
return TenantPaddleHealthTable::configure($table); return TenantLemonSqueezyHealthTable::configure($table);
} }
public static function canCreate(): bool public static function canCreate(): bool
@@ -41,7 +41,7 @@ class TenantPaddleHealthResource extends Resource
public static function getNavigationLabel(): string public static function getNavigationLabel(): string
{ {
return __('admin.paddle_health.navigation.label'); return __('admin.lemonsqueezy_health.navigation.label');
} }
public static function getNavigationGroup(): UnitEnum|string|null public static function getNavigationGroup(): UnitEnum|string|null
@@ -57,31 +57,31 @@ class TenantPaddleHealthResource extends Resource
->with(['activeResellerPackage.package']) ->with(['activeResellerPackage.package'])
->withExists('activeResellerPackage as has_active_reseller_package') ->withExists('activeResellerPackage as has_active_reseller_package')
->addSelect([ ->addSelect([
'paddle_customer_duplicates' => Tenant::query() 'lemonsqueezy_customer_duplicates' => Tenant::query()
->selectRaw('count(*)') ->selectRaw('count(*)')
->whereColumn('paddle_customer_id', 'tenants.paddle_customer_id') ->whereColumn('lemonsqueezy_customer_id', 'tenants.lemonsqueezy_customer_id')
->whereNotNull('paddle_customer_id'), ->whereNotNull('lemonsqueezy_customer_id'),
]) ])
->withCount([ ->withCount([
'purchases as paddle_transaction_count' => fn (Builder $query) => $query 'purchases as lemonsqueezy_transaction_count' => fn (Builder $query) => $query
->where('provider', 'paddle') ->where('provider', 'lemonsqueezy')
->where('refunded', false), ->where('refunded', false),
'purchases as paddle_transaction_count_window' => fn (Builder $query) => $query 'purchases as lemonsqueezy_transaction_count_window' => fn (Builder $query) => $query
->where('provider', 'paddle') ->where('provider', 'lemonsqueezy')
->where('refunded', false) ->where('refunded', false)
->where('purchased_at', '>=', $windowStart), ->where('purchased_at', '>=', $windowStart),
'purchases as paddle_refund_count_window' => fn (Builder $query) => $query 'purchases as lemonsqueezy_refund_count_window' => fn (Builder $query) => $query
->where('provider', 'paddle') ->where('provider', 'lemonsqueezy')
->where('refunded', true) ->where('refunded', true)
->where('purchased_at', '>=', $windowStart), ->where('purchased_at', '>=', $windowStart),
'checkoutSessions as paddle_checkout_requires_action_count' => fn (Builder $query) => $query 'checkoutSessions as lemonsqueezy_checkout_requires_action_count' => fn (Builder $query) => $query
->where('provider', CheckoutSession::PROVIDER_PADDLE) ->where('provider', CheckoutSession::PROVIDER_LEMONSQUEEZY)
->where('status', CheckoutSession::STATUS_REQUIRES_CUSTOMER_ACTION), ->where('status', CheckoutSession::STATUS_REQUIRES_CUSTOMER_ACTION),
'checkoutSessions as paddle_checkout_processing_count' => fn (Builder $query) => $query 'checkoutSessions as lemonsqueezy_checkout_processing_count' => fn (Builder $query) => $query
->where('provider', CheckoutSession::PROVIDER_PADDLE) ->where('provider', CheckoutSession::PROVIDER_LEMONSQUEEZY)
->where('status', CheckoutSession::STATUS_PROCESSING), ->where('status', CheckoutSession::STATUS_PROCESSING),
'checkoutSessions as paddle_checkout_expired_count' => fn (Builder $query) => $query 'checkoutSessions as lemonsqueezy_checkout_expired_count' => fn (Builder $query) => $query
->where('provider', CheckoutSession::PROVIDER_PADDLE) ->where('provider', CheckoutSession::PROVIDER_LEMONSQUEEZY)
->whereNotIn('status', [ ->whereNotIn('status', [
CheckoutSession::STATUS_COMPLETED, CheckoutSession::STATUS_COMPLETED,
CheckoutSession::STATUS_CANCELLED, CheckoutSession::STATUS_CANCELLED,
@@ -90,32 +90,32 @@ class TenantPaddleHealthResource extends Resource
->where('expires_at', '<', now()), ->where('expires_at', '<', now()),
]) ])
->withSum([ ->withSum([
'purchases as paddle_transaction_total' => fn (Builder $query) => $query 'purchases as lemonsqueezy_transaction_total' => fn (Builder $query) => $query
->where('provider', 'paddle') ->where('provider', 'lemonsqueezy')
->where('refunded', false), ->where('refunded', false),
], 'price') ], 'price')
->withSum([ ->withSum([
'purchases as paddle_transaction_total_window' => fn (Builder $query) => $query 'purchases as lemonsqueezy_transaction_total_window' => fn (Builder $query) => $query
->where('provider', 'paddle') ->where('provider', 'lemonsqueezy')
->where('refunded', false) ->where('refunded', false)
->where('purchased_at', '>=', $windowStart), ->where('purchased_at', '>=', $windowStart),
], 'price') ], 'price')
->withSum([ ->withSum([
'purchases as paddle_refund_total_window' => fn (Builder $query) => $query 'purchases as lemonsqueezy_refund_total_window' => fn (Builder $query) => $query
->where('provider', 'paddle') ->where('provider', 'lemonsqueezy')
->where('refunded', true) ->where('refunded', true)
->where('purchased_at', '>=', $windowStart), ->where('purchased_at', '>=', $windowStart),
], 'price') ], 'price')
->withMax([ ->withMax([
'purchases as last_paddle_transaction_at' => fn (Builder $query) => $query 'purchases as last_lemonsqueezy_transaction_at' => fn (Builder $query) => $query
->where('provider', 'paddle'), ->where('provider', 'lemonsqueezy'),
], 'purchased_at'); ], 'purchased_at');
} }
public static function getPages(): array public static function getPages(): array
{ {
return [ return [
'index' => ListTenantPaddleHealths::route('/'), 'index' => ListTenantLemonSqueezyHealths::route('/'),
]; ];
} }
} }

View File

@@ -1,16 +0,0 @@
<?php
namespace App\Filament\Clusters\DailyOps\Resources\TenantPaddleHealths\Pages;
use App\Filament\Clusters\DailyOps\Resources\TenantPaddleHealths\TenantPaddleHealthResource;
use Filament\Resources\Pages\ListRecords;
class ListTenantPaddleHealths extends ListRecords
{
protected static string $resource = TenantPaddleHealthResource::class;
protected function getHeaderActions(): array
{
return [];
}
}

View File

@@ -0,0 +1,16 @@
<?php
namespace App\Filament\Clusters\WeeklyOps\Resources\TaskCollections\Pages;
use App\Filament\Clusters\WeeklyOps\Resources\TaskCollections\TaskCollectionResource;
use App\Filament\Resources\Pages\AuditedCreateRecord;
class CreateTaskCollection extends AuditedCreateRecord
{
protected static string $resource = TaskCollectionResource::class;
protected function mutateFormDataBeforeCreate(array $data): array
{
return TaskCollectionResource::normalizeData($data);
}
}

View File

@@ -0,0 +1,30 @@
<?php
namespace App\Filament\Clusters\WeeklyOps\Resources\TaskCollections\Pages;
use App\Filament\Clusters\WeeklyOps\Resources\TaskCollections\TaskCollectionResource;
use App\Filament\Resources\Pages\AuditedEditRecord;
use App\Services\Audit\SuperAdminAuditLogger;
use Filament\Actions;
class EditTaskCollection extends AuditedEditRecord
{
protected static string $resource = TaskCollectionResource::class;
protected function getHeaderActions(): array
{
return [
Actions\DeleteAction::make()
->after(fn ($record) => app(SuperAdminAuditLogger::class)->recordModelMutation(
'deleted',
$record,
source: static::class
)),
];
}
protected function mutateFormDataBeforeSave(array $data): array
{
return TaskCollectionResource::normalizeData($data, $this->record);
}
}

View File

@@ -0,0 +1,19 @@
<?php
namespace App\Filament\Clusters\WeeklyOps\Resources\TaskCollections\Pages;
use App\Filament\Clusters\WeeklyOps\Resources\TaskCollections\TaskCollectionResource;
use Filament\Actions;
use Filament\Resources\Pages\ListRecords;
class ListTaskCollections extends ListRecords
{
protected static string $resource = TaskCollectionResource::class;
protected function getHeaderActions(): array
{
return [
Actions\CreateAction::make(),
];
}
}

View File

@@ -0,0 +1,127 @@
<?php
namespace App\Filament\Clusters\WeeklyOps\Resources\TaskCollections\RelationManagers;
use App\Models\Task;
use Filament\Actions\AttachAction;
use Filament\Actions\BulkActionGroup;
use Filament\Actions\DetachAction;
use Filament\Actions\DetachBulkAction;
use Filament\Resources\RelationManagers\RelationManager;
use Filament\Tables\Columns\IconColumn;
use Filament\Tables\Columns\TextColumn;
use Filament\Tables\Table;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Collection;
use Illuminate\Support\Arr;
class TasksRelationManager extends RelationManager
{
protected static string $relationship = 'tasks';
protected static ?string $inverseRelationship = 'taskCollections';
public function table(Table $table): Table
{
return $table
->columns([
TextColumn::make('title')
->label(__('admin.tasks.table.title'))
->getStateUsing(fn (Task $record) => $this->formatTaskTitle($record->title))
->searchable(['title->de', 'title->en'])
->limit(60),
TextColumn::make('emotion.name')
->label(__('admin.tasks.fields.emotion'))
->getStateUsing(function (Task $record) {
$value = optional($record->emotion)->name;
if (is_array($value)) {
$locale = app()->getLocale();
return $value[$locale] ?? ($value['de'] ?? ($value['en'] ?? ''));
}
return (string) ($value ?? '');
})
->sortable(),
TextColumn::make('difficulty')
->label(__('admin.tasks.fields.difficulty.label'))
->badge(),
IconColumn::make('is_active')
->label(__('admin.tasks.table.is_active'))
->boolean(),
TextColumn::make('sort_order')
->label(__('admin.tasks.table.sort_order'))
->sortable(),
])
->headerActions([
AttachAction::make()
->recordTitle(fn (Task $record) => $this->formatTaskTitle($record->title))
->recordSelectOptionsQuery(fn (Builder $query): Builder => $query->whereNull('tenant_id'))
->multiple()
->after(function (array $data): void {
$collection = $this->getOwnerRecord();
$recordIds = Arr::wrap($data['recordId'] ?? []);
if ($recordIds === []) {
return;
}
$collection->reassignTasks($recordIds);
}),
])
->recordActions([
DetachAction::make()
->after(function (?Task $record): void {
if (! $record) {
return;
}
$collectionId = $this->getOwnerRecord()->getKey();
if ($record->collection_id === $collectionId) {
$record->update(['collection_id' => null]);
}
}),
])
->toolbarActions([
BulkActionGroup::make([
DetachBulkAction::make()
->after(function (Collection $records): void {
$collectionId = $this->getOwnerRecord()->getKey();
$ids = $records
->filter(fn (Task $record) => $record->collection_id === $collectionId)
->pluck('id')
->all();
if ($ids === []) {
return;
}
Task::query()
->whereIn('id', $ids)
->update(['collection_id' => null]);
}),
]),
]);
}
/**
* @param array<string, string>|string|null $value
*/
protected function formatTaskTitle(array|string|null $value): string
{
if (is_array($value)) {
$locale = app()->getLocale();
return $value[$locale]
?? ($value['de'] ?? ($value['en'] ?? Arr::first($value) ?? ''));
}
if (is_string($value)) {
return $value;
}
return '';
}
}

View File

@@ -0,0 +1,280 @@
<?php
namespace App\Filament\Clusters\WeeklyOps\Resources\TaskCollections;
use App\Filament\Clusters\WeeklyOps\Resources\TaskCollections\Pages\CreateTaskCollection;
use App\Filament\Clusters\WeeklyOps\Resources\TaskCollections\Pages\EditTaskCollection;
use App\Filament\Clusters\WeeklyOps\Resources\TaskCollections\Pages\ListTaskCollections;
use App\Filament\Clusters\WeeklyOps\Resources\TaskCollections\RelationManagers\TasksRelationManager;
use App\Filament\Clusters\WeeklyOps\WeeklyOpsCluster;
use App\Models\EventType;
use App\Models\TaskCollection;
use App\Services\Audit\SuperAdminAuditLogger;
use BackedEnum;
use Filament\Actions;
use Filament\Forms\Components\MarkdownEditor;
use Filament\Forms\Components\Select;
use Filament\Forms\Components\TextInput;
use Filament\Forms\Components\Toggle;
use Filament\Resources\Resource;
use Filament\Schemas\Components\Tabs as SchemaTabs;
use Filament\Schemas\Components\Tabs\Tab as SchemaTab;
use Filament\Schemas\Schema;
use Filament\Tables\Columns\IconColumn;
use Filament\Tables\Columns\TextColumn;
use Filament\Tables\Filters\SelectFilter;
use Filament\Tables\Table;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Collection;
use Illuminate\Support\Arr;
use Illuminate\Support\Str;
use UnitEnum;
class TaskCollectionResource extends Resource
{
protected static ?string $model = TaskCollection::class;
protected static BackedEnum|string|null $navigationIcon = 'heroicon-o-rectangle-stack';
protected static ?string $cluster = WeeklyOpsCluster::class;
protected static ?string $recordTitleAttribute = 'name';
protected static ?int $navigationSort = 31;
public static function form(Schema $schema): Schema
{
return $schema
->schema([
TextInput::make('slug')
->label(__('admin.common.slug'))
->maxLength(255)
->unique(ignoreRecord: true)
->required(),
Select::make('event_type_id')
->relationship('eventType', 'name')
->getOptionLabelFromRecordUsing(fn (EventType $record) => is_array($record->name) ? ($record->name['de'] ?? $record->name['en'] ?? __('admin.common.unnamed')) : $record->name)
->searchable()
->preload()
->label(__('admin.task_collections.fields.event_type_optional')),
SchemaTabs::make('content_tabs')
->label(__('admin.task_collections.fields.content_localization'))
->tabs([
SchemaTab::make(__('admin.common.german'))
->icon('heroicon-o-language')
->schema([
TextInput::make('name_translations.de')
->label(__('admin.task_collections.fields.name_de'))
->required(),
MarkdownEditor::make('description_translations.de')
->label(__('admin.task_collections.fields.description_de'))
->columnSpanFull(),
]),
SchemaTab::make(__('admin.common.english'))
->icon('heroicon-o-language')
->schema([
TextInput::make('name_translations.en')
->label(__('admin.task_collections.fields.name_en'))
->required(),
MarkdownEditor::make('description_translations.en')
->label(__('admin.task_collections.fields.description_en'))
->columnSpanFull(),
]),
])
->columnSpanFull(),
Toggle::make('is_default')
->label(__('admin.task_collections.fields.is_default'))
->default(false),
TextInput::make('position')
->label(__('admin.task_collections.fields.position'))
->numeric()
->default(0),
])
->columns(2);
}
public static function table(Table $table): Table
{
return $table
->columns([
TextColumn::make('id')
->label('#')
->sortable(),
TextColumn::make('name')
->label(__('admin.task_collections.table.name'))
->getStateUsing(fn (TaskCollection $record) => static::formatTranslation($record->name_translations))
->searchable(['name_translations->de', 'name_translations->en'])
->limit(60),
TextColumn::make('eventType.name')
->label(__('admin.task_collections.table.event_type'))
->getStateUsing(function (TaskCollection $record) {
$value = optional($record->eventType)->name;
if (is_array($value)) {
$locale = app()->getLocale();
return $value[$locale] ?? ($value['de'] ?? ($value['en'] ?? ''));
}
return (string) ($value ?? '');
})
->toggleable(),
TextColumn::make('slug')
->label(__('admin.task_collections.table.slug'))
->toggleable()
->searchable(),
IconColumn::make('is_default')
->label(__('admin.task_collections.table.is_default'))
->boolean(),
TextColumn::make('position')
->label(__('admin.task_collections.table.position'))
->sortable(),
TextColumn::make('tasks_count')
->label(__('admin.task_collections.table.tasks'))
->sortable(),
TextColumn::make('events_count')
->label(__('admin.task_collections.table.events'))
->sortable(),
])
->filters([
SelectFilter::make('event_type_id')
->label(__('admin.task_collections.table.event_type'))
->relationship(
'eventType',
'name',
fn (Builder $query): Builder => $query->orderBy('name->de')
)
->getOptionLabelFromRecordUsing(fn (EventType $record) => is_array($record->name) ? ($record->name['de'] ?? $record->name['en'] ?? __('admin.common.unnamed')) : $record->name),
SelectFilter::make('is_default')
->label(__('admin.task_collections.table.is_default'))
->options([
'1' => __('admin.common.yes'),
'0' => __('admin.common.no'),
]),
])
->recordActions([
Actions\EditAction::make()
->mutateDataUsing(fn (array $data, TaskCollection $record): array => static::normalizeData($data, $record))
->after(fn (array $data, TaskCollection $record) => app(SuperAdminAuditLogger::class)->recordModelMutation(
'updated',
$record,
SuperAdminAuditLogger::fieldsMetadata($data),
static::class
)),
Actions\DeleteAction::make()
->after(fn (TaskCollection $record) => app(SuperAdminAuditLogger::class)->recordModelMutation(
'deleted',
$record,
source: static::class
)),
])
->bulkActions([
Actions\DeleteBulkAction::make()
->after(function (Collection $records): void {
$logger = app(SuperAdminAuditLogger::class);
foreach ($records as $record) {
$logger->recordModelMutation(
'deleted',
$record,
source: static::class
);
}
}),
]);
}
public static function getNavigationLabel(): string
{
return __('admin.task_collections.menu');
}
public static function getNavigationGroup(): UnitEnum|string|null
{
return __('admin.nav.curation');
}
public static function getEloquentQuery(): Builder
{
return parent::getEloquentQuery()
->whereNull('tenant_id')
->with('eventType')
->withCount(['tasks', 'events']);
}
/**
* @param array<string, mixed> $data
*/
public static function normalizeData(array $data, ?TaskCollection $record = null): array
{
$data['tenant_id'] = null;
$data['slug'] = static::resolveSlug($data, $record);
return $data;
}
/**
* @param array<string, mixed> $data
*/
protected static function resolveSlug(array $data, ?TaskCollection $record = null): string
{
$rawSlug = trim((string) ($data['slug'] ?? ''));
$translations = Arr::wrap($data['name_translations'] ?? []);
$fallbackName = (string) ($translations['en'] ?? $translations['de'] ?? '');
$base = $rawSlug !== '' ? $rawSlug : $fallbackName;
$slugBase = Str::slug($base) ?: 'collection';
$query = TaskCollection::query()->where('slug', $slugBase);
if ($record) {
$query->whereKeyNot($record->getKey());
}
if (! $query->exists()) {
return $slugBase;
}
do {
$candidate = $slugBase.'-'.Str::random(4);
$candidateQuery = TaskCollection::query()->where('slug', $candidate);
if ($record) {
$candidateQuery->whereKeyNot($record->getKey());
}
} while ($candidateQuery->exists());
return $candidate;
}
/**
* @param array<string, string>|null $translations
*/
protected static function formatTranslation(?array $translations): string
{
if (! is_array($translations)) {
return '';
}
$locale = app()->getLocale();
return $translations[$locale]
?? ($translations['de'] ?? ($translations['en'] ?? Arr::first($translations) ?? ''));
}
public static function getPages(): array
{
return [
'index' => ListTaskCollections::route('/'),
'create' => CreateTaskCollection::route('/create'),
'edit' => EditTaskCollection::route('/{record}/edit'),
];
}
public static function getRelations(): array
{
return [
TasksRelationManager::class,
];
}
}

View File

@@ -4,7 +4,7 @@ namespace App\Filament\Resources\Coupons\Pages;
use App\Filament\Resources\Coupons\CouponResource; use App\Filament\Resources\Coupons\CouponResource;
use App\Filament\Resources\Pages\AuditedCreateRecord; use App\Filament\Resources\Pages\AuditedCreateRecord;
use App\Jobs\SyncCouponToPaddle; use App\Jobs\SyncCouponToLemonSqueezy;
class CreateCoupon extends AuditedCreateRecord class CreateCoupon extends AuditedCreateRecord
{ {
@@ -14,6 +14,6 @@ class CreateCoupon extends AuditedCreateRecord
{ {
parent::afterCreate(); parent::afterCreate();
SyncCouponToPaddle::dispatch($this->record); SyncCouponToLemonSqueezy::dispatch($this->record);
} }
} }

View File

@@ -4,7 +4,7 @@ namespace App\Filament\Resources\Coupons\Pages;
use App\Filament\Resources\Coupons\CouponResource; use App\Filament\Resources\Coupons\CouponResource;
use App\Filament\Resources\Pages\AuditedEditRecord; use App\Filament\Resources\Pages\AuditedEditRecord;
use App\Jobs\SyncCouponToPaddle; use App\Jobs\SyncCouponToLemonSqueezy;
use App\Services\Audit\SuperAdminAuditLogger; use App\Services\Audit\SuperAdminAuditLogger;
use Filament\Actions\DeleteAction; use Filament\Actions\DeleteAction;
use Filament\Actions\ForceDeleteAction; use Filament\Actions\ForceDeleteAction;
@@ -27,7 +27,7 @@ class EditCoupon extends AuditedEditRecord
source: static::class source: static::class
); );
SyncCouponToPaddle::dispatch($record, true); SyncCouponToLemonSqueezy::dispatch($record, true);
}), }),
ForceDeleteAction::make() ForceDeleteAction::make()
->after(fn ($record) => app(SuperAdminAuditLogger::class)->recordModelMutation( ->after(fn ($record) => app(SuperAdminAuditLogger::class)->recordModelMutation(
@@ -48,6 +48,6 @@ class EditCoupon extends AuditedEditRecord
{ {
parent::afterSave(); parent::afterSave();
SyncCouponToPaddle::dispatch($this->record); SyncCouponToLemonSqueezy::dispatch($this->record);
} }
} }

View File

@@ -21,7 +21,7 @@ class RedemptionsRelationManager extends RelationManager
public function table(Table $table): Table public function table(Table $table): Table
{ {
return $table return $table
->recordTitleAttribute('paddle_transaction_id') ->recordTitleAttribute('lemonsqueezy_order_id')
->columns([ ->columns([
TextColumn::make('tenant.name') TextColumn::make('tenant.name')
->label(__('Tenant')) ->label(__('Tenant'))
@@ -65,7 +65,7 @@ class RedemptionsRelationManager extends RelationManager
'failed' => 'danger', 'failed' => 'danger',
default => 'warning', default => 'warning',
}), }),
TextColumn::make('paddle_transaction_id') TextColumn::make('lemonsqueezy_order_id')
->label(__('Transaction')) ->label(__('Transaction'))
->copyable() ->copyable()
->toggleable(isToggledHiddenByDefault: true), ->toggleable(isToggledHiddenByDefault: true),

View File

@@ -123,22 +123,22 @@ class CouponForm
->nullable() ->nullable()
->columnSpanFull(), ->columnSpanFull(),
]), ]),
Section::make(__('Paddle sync')) Section::make(__('Lemon Squeezy sync'))
->columns(2) ->columns(2)
->schema([ ->schema([
Select::make('paddle_mode') Select::make('lemonsqueezy_mode')
->label(__('Paddle mode')) ->label(__('Lemon Squeezy mode'))
->options([ ->options([
'standard' => __('Standard'), 'standard' => __('Standard'),
'custom' => __('Custom (one-off)'), 'custom' => __('Custom (one-off)'),
]) ])
->default('standard'), ->default('standard'),
Placeholder::make('paddle_discount_id') Placeholder::make('lemonsqueezy_discount_id')
->label(__('Paddle Discount ID')) ->label(__('Lemon Squeezy Discount ID'))
->content(fn ($record) => $record?->paddle_discount_id ?? '—'), ->content(fn ($record) => $record?->lemonsqueezy_discount_id ?? '—'),
Placeholder::make('paddle_last_synced_at') Placeholder::make('lemonsqueezy_last_synced_at')
->label(__('Last synced')) ->label(__('Last synced'))
->content(fn ($record) => $record?->paddle_last_synced_at?->diffForHumans() ?? '—'), ->content(fn ($record) => $record?->lemonsqueezy_last_synced_at?->diffForHumans() ?? '—'),
Placeholder::make('redemptions_count') Placeholder::make('redemptions_count')
->label(__('Total redemptions')) ->label(__('Total redemptions'))
->content(fn ($record) => number_format($record?->redemptions_count ?? 0)), ->content(fn ($record) => number_format($record?->redemptions_count ?? 0)),

View File

@@ -63,17 +63,17 @@ class CouponInfolist
TextEntry::make('description')->label(__('Description'))->columnSpanFull(), TextEntry::make('description')->label(__('Description'))->columnSpanFull(),
KeyValueEntry::make('metadata')->label(__('Metadata'))->columnSpanFull(), KeyValueEntry::make('metadata')->label(__('Metadata'))->columnSpanFull(),
]), ]),
Section::make(__('Paddle')) Section::make(__('Lemon Squeezy'))
->columns(3) ->columns(3)
->schema([ ->schema([
TextEntry::make('paddle_discount_id') TextEntry::make('lemonsqueezy_discount_id')
->label(__('Discount ID')) ->label(__('Discount ID'))
->copyable() ->copyable()
->placeholder('—'), ->placeholder('—'),
TextEntry::make('paddle_last_synced_at') TextEntry::make('lemonsqueezy_last_synced_at')
->label(__('Last synced')) ->label(__('Last synced'))
->state(fn ($record) => $record?->paddle_last_synced_at?->diffForHumans() ?? '—'), ->state(fn ($record) => $record?->lemonsqueezy_last_synced_at?->diffForHumans() ?? '—'),
TextEntry::make('paddle_mode') TextEntry::make('lemonsqueezy_mode')
->label(__('Mode')) ->label(__('Mode'))
->badge() ->badge()
->placeholder('standard'), ->placeholder('standard'),

View File

@@ -4,7 +4,7 @@ namespace App\Filament\Resources\Coupons\Tables;
use App\Enums\CouponStatus; use App\Enums\CouponStatus;
use App\Enums\CouponType; use App\Enums\CouponType;
use App\Jobs\SyncCouponToPaddle; use App\Jobs\SyncCouponToLemonSqueezy;
use App\Services\Audit\SuperAdminAuditLogger; use App\Services\Audit\SuperAdminAuditLogger;
use Filament\Actions\Action; use Filament\Actions\Action;
use Filament\Actions\BulkActionGroup; use Filament\Actions\BulkActionGroup;
@@ -105,9 +105,9 @@ class CouponsTable
static::class static::class
)), )),
Action::make('sync') Action::make('sync')
->label(__('Sync to Paddle')) ->label(__('Sync to Lemon Squeezy'))
->icon('heroicon-m-arrow-path') ->icon('heroicon-m-arrow-path')
->action(fn ($record) => SyncCouponToPaddle::dispatch($record)) ->action(fn ($record) => SyncCouponToLemonSqueezy::dispatch($record))
->requiresConfirmation(), ->requiresConfirmation(),
]) ])
->toolbarActions([ ->toolbarActions([

View File

@@ -13,7 +13,9 @@ use Illuminate\Support\Facades\Storage;
class ImportEmotions extends Page class ImportEmotions extends Page
{ {
protected static string $resource = EmotionResource::class; protected static string $resource = EmotionResource::class;
protected string $view = 'filament.resources.emotion-resource.pages.import-emotions'; protected string $view = 'filament.resources.emotion-resource.pages.import-emotions';
protected ?string $heading = null; protected ?string $heading = null;
public ?string $file = null; public ?string $file = null;
@@ -36,6 +38,7 @@ class ImportEmotions extends Page
$path = $this->form->getState()['file'] ?? null; $path = $this->form->getState()['file'] ?? null;
if (! $path || ! Storage::disk('public')->exists($path)) { if (! $path || ! Storage::disk('public')->exists($path)) {
Notification::make()->danger()->title(__('admin.notifications.file_not_found'))->send(); Notification::make()->danger()->title(__('admin.notifications.file_not_found'))->send();
return; return;
} }

View File

@@ -60,19 +60,32 @@ class EventResource extends Resource
->required() ->required()
->unique(ignoreRecord: true) ->unique(ignoreRecord: true)
->maxLength(255), ->maxLength(255),
TextInput::make('join_link_display')
->label(__('admin.events.fields.join_link'))
->afterStateHydrated(function (TextInput $component, ?Event $record) {
if (! $record) {
return;
}
$token = $record->joinTokens()->latest()->first();
$component->state($token ? url('/e/'.$token->token) : '-');
})
->readOnly()
->dehydrated(false)
->visibleOn('edit'),
DatePicker::make('date') DatePicker::make('date')
->label(__('admin.events.fields.date')) ->label(__('admin.events.fields.date'))
->required(), ->required(),
Select::make('event_type_id') Select::make('event_type_id')
->label(__('admin.events.fields.type')) ->label(__('admin.events.fields.type'))
->options(EventType::query()->pluck('name', 'id')) ->options(fn () => EventType::all()->pluck('name.de', 'id'))
->searchable(), ->searchable(),
Select::make('package_id') Select::make('package_id')
->label(__('admin.events.fields.package')) ->label(__('admin.events.fields.package'))
->options(\App\Models\Package::query()->where('type', 'endcustomer')->pluck('name', 'id')) ->options(\App\Models\Package::query()->where('type', 'endcustomer')->pluck('name', 'id'))
->searchable() ->searchable()
->preload() ->preload()
->required(), ->required()
->visibleOn('create'),
TextInput::make('default_locale') TextInput::make('default_locale')
->label(__('admin.events.fields.default_locale')) ->label(__('admin.events.fields.default_locale'))
->default('de') ->default('de')
@@ -96,13 +109,13 @@ class EventResource extends Resource
->columns([ ->columns([
Tables\Columns\TextColumn::make('id')->sortable(), Tables\Columns\TextColumn::make('id')->sortable(),
Tables\Columns\TextColumn::make('tenant.name')->label(__('admin.events.table.tenant'))->searchable(), Tables\Columns\TextColumn::make('tenant.name')->label(__('admin.events.table.tenant'))->searchable(),
Tables\Columns\TextColumn::make('name.de') Tables\Columns\TextColumn::make('name')
->label(__('admin.events.fields.name')) ->label(__('admin.events.fields.name'))
->formatStateUsing(fn (mixed $state): string => static::formatEventName($state))
->limit(30), ->limit(30),
Tables\Columns\TextColumn::make('slug')->searchable(), Tables\Columns\TextColumn::make('slug')->searchable(),
Tables\Columns\TextColumn::make('date')->date(), Tables\Columns\TextColumn::make('date')->date(),
Tables\Columns\IconColumn::make('is_active')->boolean(), Tables\Columns\IconColumn::make('is_active')->boolean(),
Tables\Columns\TextColumn::make('default_locale'),
Tables\Columns\TextColumn::make('eventPackage.package.name') Tables\Columns\TextColumn::make('eventPackage.package.name')
->label(__('admin.events.table.package')) ->label(__('admin.events.table.package'))
->badge() ->badge()
@@ -115,22 +128,6 @@ class EventResource extends Resource
->badge() ->badge()
->color(fn ($state) => $state < 1 ? 'danger' : 'success') ->color(fn ($state) => $state < 1 ? 'danger' : 'success')
->getStateUsing(fn ($record) => $record->eventPackage?->remaining_photos ?? 0), ->getStateUsing(fn ($record) => $record->eventPackage?->remaining_photos ?? 0),
Tables\Columns\TextColumn::make('primary_join_token')
->label(__('admin.events.table.join'))
->getStateUsing(function ($record) {
$token = $record->joinTokens()->latest()->first();
return $token ? url('/e/'.$token->token) : __('admin.events.table.no_join_tokens');
})
->description(function ($record) {
$total = $record->joinTokens()->count();
return $total > 0
? __('admin.events.table.join_tokens_total', ['count' => $total])
: __('admin.events.table.join_tokens_missing');
})
->copyable()
->copyMessage(__('admin.events.messages.join_link_copied')),
Tables\Columns\TextColumn::make('created_at')->since(), Tables\Columns\TextColumn::make('created_at')->since(),
]) ])
->filters([]) ->filters([])
@@ -282,6 +279,30 @@ class EventResource extends Resource
]; ];
} }
/**
* @param array<string, mixed>|string|null $name
*/
private static function formatEventName(mixed $name): string
{
if (is_array($name)) {
$candidates = [
$name['de'] ?? null,
$name['en'] ?? null,
reset($name) ?: null,
];
foreach ($candidates as $candidate) {
if (is_string($candidate) && $candidate !== '') {
return $candidate;
}
}
return '';
}
return is_string($name) ? $name : '';
}
public static function getPages(): array public static function getPages(): array
{ {
return [ return [

View File

@@ -8,4 +8,25 @@ use App\Filament\Resources\Pages\AuditedCreateRecord;
class CreateEvent extends AuditedCreateRecord class CreateEvent extends AuditedCreateRecord
{ {
protected static string $resource = EventResource::class; protected static string $resource = EventResource::class;
public ?int $packageId = null;
protected function mutateFormDataBeforeCreate(array $data): array
{
$this->packageId = $data['package_id'] ?? null;
unset($data['package_id']);
return $data;
}
protected function afterCreate(): void
{
if ($this->packageId) {
$this->record->eventPackages()->create([
'package_id' => $this->packageId,
]);
}
parent::afterCreate();
}
} }

View File

@@ -19,7 +19,6 @@ use Filament\Tables\Table;
use Illuminate\Database\Eloquent\Builder; use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Collection; use Illuminate\Database\Eloquent\Collection;
use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\Relation;
class EventPackagesRelationManager extends RelationManager class EventPackagesRelationManager extends RelationManager
{ {
@@ -59,6 +58,7 @@ class EventPackagesRelationManager extends RelationManager
public function table(Table $table): Table public function table(Table $table): Table
{ {
return $table return $table
->modifyQueryUsing(fn (Builder $query) => $query->with('package'))
->recordTitleAttribute('package.name') ->recordTitleAttribute('package.name')
->columns([ ->columns([
TextColumn::make('package.name') TextColumn::make('package.name')
@@ -147,9 +147,4 @@ class EventPackagesRelationManager extends RelationManager
{ {
return __('admin.events.relation_managers.event_packages.title'); return __('admin.events.relation_managers.event_packages.title');
} }
public function getTableQuery(): Builder|Relation
{
return parent::getTableQuery()->with('package');
}
} }

View File

@@ -113,18 +113,64 @@ class EventTypeResource extends Resource
SuperAdminAuditLogger::fieldsMetadata($data), SuperAdminAuditLogger::fieldsMetadata($data),
static::class static::class
)), )),
Actions\DeleteAction::make()
->action(function (EventType $record, Actions\DeleteAction $action) {
try {
$record->delete();
} catch (\Exception $e) {
$isConstraint = ($e instanceof \Illuminate\Database\QueryException && ($e->getCode() == 23000 || ($e->errorInfo[0] ?? '') == 23000));
if ($isConstraint) {
\Filament\Notifications\Notification::make()
->title(__('admin.common.error'))
->body(__('admin.event_types.messages.delete_constraint_error'))
->danger()
->send();
$action->halt();
}
throw $e;
}
})
->after(fn (EventType $record) => app(SuperAdminAuditLogger::class)->recordModelMutation(
'deleted',
$record,
source: static::class
)),
]) ])
->bulkActions([ ->bulkActions([
Actions\DeleteBulkAction::make() Actions\DeleteBulkAction::make()
->after(function (Collection $records): void { ->action(function (Collection $records, Actions\DeleteBulkAction $action) {
$logger = app(SuperAdminAuditLogger::class); $logger = app(SuperAdminAuditLogger::class);
$deletedCount = 0;
$failedCount = 0;
foreach ($records as $record) { foreach ($records as $record) {
$logger->recordModelMutation( try {
'deleted', $record->delete();
$record, $logger->recordModelMutation('deleted', $record, source: static::class);
source: static::class $deletedCount++;
); } catch (\Exception $e) {
$isConstraint = ($e instanceof \Illuminate\Database\QueryException && ($e->getCode() == 23000 || ($e->errorInfo[0] ?? '') == 23000));
if ($isConstraint) {
$failedCount++;
} else {
throw $e;
}
}
}
if ($failedCount > 0) {
\Filament\Notifications\Notification::make()
->title(__('admin.common.error'))
->body(__('admin.event_types.messages.delete_constraint_error')." ($failedCount failed, $deletedCount deleted)")
->danger()
->send();
if ($deletedCount === 0) {
$action->halt();
}
} }
}), }),
]); ]);

View File

@@ -63,8 +63,8 @@ class GiftVoucherResource extends Resource
->label('Empfänger') ->label('Empfänger')
->toggleable() ->toggleable()
->searchable(), ->searchable(),
TextColumn::make('paddle_transaction_id') TextColumn::make('lemonsqueezy_order_id')
->label('Paddle Tx') ->label('Lemon Squeezy Order')
->toggleable() ->toggleable()
->copyable() ->copyable()
->wrap(), ->wrap(),

View File

@@ -46,24 +46,27 @@ class ListGiftVouchers extends ListRecords
]) ])
->action(function (array $data, GiftVoucherService $service): void { ->action(function (array $data, GiftVoucherService $service): void {
$payload = [ $payload = [
'id' => null, 'meta' => [
'metadata' => [ 'custom_data' => [
'type' => 'gift_voucher', 'type' => 'gift_voucher',
'purchaser_email' => $data['purchaser_email'], 'purchaser_email' => $data['purchaser_email'],
'recipient_email' => $data['recipient_email'] ?? null, 'recipient_email' => $data['recipient_email'] ?? null,
'recipient_name' => $data['recipient_name'] ?? null, 'recipient_name' => $data['recipient_name'] ?? null,
'message' => $data['message'] ?? null, 'message' => $data['message'] ?? null,
'gift_code' => $data['code'] ?? null, 'gift_code' => $data['code'] ?? null,
],
], ],
'currency_code' => $data['currency'] ?? 'EUR', 'data' => [
'totals' => [ 'id' => 'manual_'.Str::uuid(),
'grand_total' => [ 'attributes' => [
'amount' => (float) $data['amount'], 'currency' => $data['currency'] ?? 'EUR',
'total' => (float) $data['amount'] * 100,
'user_email' => $data['purchaser_email'],
], ],
], ],
]; ];
$voucher = $service->issueFromPaddle($payload); $voucher = $service->issueFromLemonSqueezy($payload);
app(SuperAdminAuditLogger::class)->recordModelMutation( app(SuperAdminAuditLogger::class)->recordModelMutation(
'issued', 'issued',

View File

@@ -17,4 +17,3 @@ class ListMediaStorageTargets extends ListRecords
]; ];
} }
} }

View File

@@ -4,7 +4,7 @@ namespace App\Filament\Resources;
use App\Filament\Clusters\WeeklyOps\WeeklyOpsCluster; use App\Filament\Clusters\WeeklyOps\WeeklyOpsCluster;
use App\Filament\Resources\PackageAddonResource\Pages; use App\Filament\Resources\PackageAddonResource\Pages;
use App\Jobs\SyncPackageAddonToPaddle; use App\Jobs\SyncPackageAddonToLemonSqueezy;
use App\Models\PackageAddon; use App\Models\PackageAddon;
use App\Services\Audit\SuperAdminAuditLogger; use App\Services\Audit\SuperAdminAuditLogger;
use Filament\Actions; use Filament\Actions;
@@ -50,9 +50,9 @@ class PackageAddonResource extends Resource
->required() ->required()
->unique(ignoreRecord: true) ->unique(ignoreRecord: true)
->maxLength(191), ->maxLength(191),
TextInput::make('price_id') TextInput::make('variant_id')
->label('Paddle Preis-ID') ->label('Lemon Squeezy Variant-ID')
->helperText('Paddle Billing Preis-ID für dieses Add-on') ->helperText('Variant-ID aus Lemon Squeezy für dieses Add-on')
->maxLength(191), ->maxLength(191),
TextInput::make('sort') TextInput::make('sort')
->label('Sortierung') ->label('Sortierung')
@@ -96,8 +96,8 @@ class PackageAddonResource extends Resource
->label('Schlüssel') ->label('Schlüssel')
->copyable() ->copyable()
->sortable(), ->sortable(),
TextColumn::make('price_id') TextColumn::make('variant_id')
->label('Paddle Preis-ID') ->label('Lemon Squeezy Variant-ID')
->toggleable() ->toggleable()
->copyable(), ->copyable(),
TextColumn::make('extra_photos')->label('Fotos +'), TextColumn::make('extra_photos')->label('Fotos +'),
@@ -120,16 +120,16 @@ class PackageAddonResource extends Resource
->label('Aktiv'), ->label('Aktiv'),
]) ])
->actions([ ->actions([
Actions\Action::make('syncPaddle') Actions\Action::make('syncLemonSqueezy')
->label('Mit Paddle synchronisieren') ->label('Mit Lemon Squeezy synchronisieren')
->icon('heroicon-o-cloud-arrow-up') ->icon('heroicon-o-cloud-arrow-up')
->action(function (PackageAddon $record) { ->action(function (PackageAddon $record) {
SyncPackageAddonToPaddle::dispatch($record->id); SyncPackageAddonToLemonSqueezy::dispatch($record->id);
Notification::make() Notification::make()
->success() ->success()
->title('Paddle-Sync gestartet') ->title('Lemon Squeezy-Sync gestartet')
->body('Das Add-on wird im Hintergrund mit Paddle abgeglichen.') ->body('Das Add-on wird im Hintergrund mit Lemon Squeezy abgeglichen.')
->send(); ->send();
}), }),
Actions\EditAction::make() Actions\EditAction::make()

View File

@@ -4,8 +4,8 @@ namespace App\Filament\Resources;
use App\Filament\Clusters\WeeklyOps\WeeklyOpsCluster; use App\Filament\Clusters\WeeklyOps\WeeklyOpsCluster;
use App\Filament\Resources\PackageResource\Pages; use App\Filament\Resources\PackageResource\Pages;
use App\Jobs\PullPackageFromPaddle; use App\Jobs\PullPackageFromLemonSqueezy;
use App\Jobs\SyncPackageToPaddle; use App\Jobs\SyncPackageToLemonSqueezy;
use App\Models\Package; use App\Models\Package;
use App\Services\Audit\SuperAdminAuditLogger; use App\Services\Audit\SuperAdminAuditLogger;
use BackedEnum; use BackedEnum;
@@ -143,7 +143,7 @@ class PackageResource extends Resource
->nullable() ->nullable()
->visible(fn ($get) => $get('type') === 'reseller'), ->visible(fn ($get) => $get('type') === 'reseller'),
Toggle::make('watermark_allowed') Toggle::make('watermark_allowed')
->label('Wasserzeichen erlaubt') ->label('Eigenes Wasserzeichen erlaubt')
->default(true), ->default(true),
Toggle::make('branding_allowed') Toggle::make('branding_allowed')
->label('Eigenes Branding erlaubt') ->label('Eigenes Branding erlaubt')
@@ -172,31 +172,31 @@ class PackageResource extends Resource
->columnSpanFull() ->columnSpanFull()
->default([]), ->default([]),
]), ]),
Section::make('Paddle Billing') Section::make('Lemon Squeezy Billing')
->columns(2) ->columns(2)
->schema([ ->schema([
TextInput::make('paddle_product_id') TextInput::make('lemonsqueezy_product_id')
->label('Paddle Produkt-ID') ->label('Lemon Squeezy Produkt-ID')
->maxLength(191) ->maxLength(191)
->helperText('Produkt aus Paddle Billing. Leer lassen, wenn noch nicht synchronisiert.') ->helperText('Produkt aus Lemon Squeezy. Leer lassen, wenn noch nicht synchronisiert.')
->placeholder('nicht verknüpft'), ->placeholder('nicht verknüpft'),
TextInput::make('paddle_price_id') TextInput::make('lemonsqueezy_variant_id')
->label('Paddle Preis-ID') ->label('Lemon Squeezy Variant-ID')
->maxLength(191) ->maxLength(191)
->helperText('Preis-ID aus Paddle Billing, verknüpft mit diesem Paket.') ->helperText('Variant-ID aus Lemon Squeezy, verknüpft mit diesem Paket.')
->placeholder('nicht verknüpft'), ->placeholder('nicht verknüpft'),
Placeholder::make('paddle_sync_status') Placeholder::make('lemonsqueezy_sync_status')
->label('Sync-Status') ->label('Sync-Status')
->content(fn (?Package $record) => $record?->paddle_sync_status ? Str::headline($record->paddle_sync_status) : '') ->content(fn (?Package $record) => $record?->lemonsqueezy_sync_status ? Str::headline($record->lemonsqueezy_sync_status) : '')
->columnSpanFull(), ->columnSpanFull(),
Placeholder::make('paddle_synced_at') Placeholder::make('lemonsqueezy_synced_at')
->label('Zuletzt synchronisiert') ->label('Zuletzt synchronisiert')
->content(fn (?Package $record) => $record?->paddle_synced_at ? $record->paddle_synced_at->diffForHumans() : '') ->content(fn (?Package $record) => $record?->lemonsqueezy_synced_at ? $record->lemonsqueezy_synced_at->diffForHumans() : '')
->columnSpanFull(), ->columnSpanFull(),
Placeholder::make('paddle_sync_error') Placeholder::make('lemonsqueezy_sync_error')
->label('Letzter Fehler') ->label('Letzter Fehler')
->content(fn (?Package $record) => $record?->paddle_sync_error_message ?? '') ->content(fn (?Package $record) => $record?->lemonsqueezy_sync_error_message ?? '')
->visible(fn (?Package $record) => filled($record?->paddle_sync_error_message)) ->visible(fn (?Package $record) => filled($record?->lemonsqueezy_sync_error_message))
->columnSpanFull(), ->columnSpanFull(),
]), ]),
]); ]);
@@ -263,15 +263,15 @@ class PackageResource extends Resource
->label('Features') ->label('Features')
->wrap() ->wrap()
->formatStateUsing(fn ($state) => static::formatFeaturesForDisplay($state)), ->formatStateUsing(fn ($state) => static::formatFeaturesForDisplay($state)),
TextColumn::make('paddle_product_id') TextColumn::make('lemonsqueezy_product_id')
->label('Paddle Produkt') ->label('Lemon Squeezy Produkt')
->toggleable(isToggledHiddenByDefault: true) ->toggleable(isToggledHiddenByDefault: true)
->formatStateUsing(fn ($state) => $state ?: '-'), ->formatStateUsing(fn ($state) => $state ?: '-'),
TextColumn::make('paddle_price_id') TextColumn::make('lemonsqueezy_variant_id')
->label('Paddle Preis') ->label('Lemon Squeezy Variant')
->toggleable(isToggledHiddenByDefault: true) ->toggleable(isToggledHiddenByDefault: true)
->formatStateUsing(fn ($state) => $state ?: '-'), ->formatStateUsing(fn ($state) => $state ?: '-'),
BadgeColumn::make('paddle_sync_status') BadgeColumn::make('lemonsqueezy_sync_status')
->label('Sync-Status') ->label('Sync-Status')
->colors([ ->colors([
'success' => 'synced', 'success' => 'synced',
@@ -281,13 +281,13 @@ class PackageResource extends Resource
]) ])
->formatStateUsing(fn ($state) => $state ? Str::headline($state) : null) ->formatStateUsing(fn ($state) => $state ? Str::headline($state) : null)
->toggleable(isToggledHiddenByDefault: true), ->toggleable(isToggledHiddenByDefault: true),
TextColumn::make('paddle_synced_at') TextColumn::make('lemonsqueezy_synced_at')
->label('Sync am') ->label('Sync am')
->dateTime() ->dateTime()
->toggleable(isToggledHiddenByDefault: true), ->toggleable(isToggledHiddenByDefault: true),
TextColumn::make('paddle_sync_error_message') TextColumn::make('lemonsqueezy_sync_error_message')
->label('Sync-Fehler') ->label('Sync-Fehler')
->getStateUsing(fn (Package $record) => $record->paddle_sync_error_message) ->getStateUsing(fn (Package $record) => $record->lemonsqueezy_sync_error_message)
->wrap() ->wrap()
->toggleable(isToggledHiddenByDefault: true), ->toggleable(isToggledHiddenByDefault: true),
]) ])
@@ -301,43 +301,43 @@ class PackageResource extends Resource
TrashedFilter::make(), TrashedFilter::make(),
]) ])
->actions([ ->actions([
Actions\Action::make('syncPaddle') Actions\Action::make('syncLemonSqueezy')
->label('Mit Paddle abgleichen') ->label('Mit Lemon Squeezy abgleichen')
->icon('heroicon-o-cloud-arrow-up') ->icon('heroicon-o-cloud-arrow-up')
->color('success') ->color('success')
->requiresConfirmation() ->requiresConfirmation()
->disabled(fn (Package $record) => $record->paddle_sync_status === 'syncing') ->disabled(fn (Package $record) => $record->lemonsqueezy_sync_status === 'syncing')
->action(function (Package $record) { ->action(function (Package $record) {
SyncPackageToPaddle::dispatch($record->id); SyncPackageToLemonSqueezy::dispatch($record->id);
Notification::make() Notification::make()
->success() ->success()
->title('Paddle-Sync gestartet') ->title('Lemon Squeezy-Sync gestartet')
->body('Das Paket wird im Hintergrund mit Paddle abgeglichen.') ->body('Das Paket wird im Hintergrund mit Lemon Squeezy abgeglichen.')
->send(); ->send();
}), }),
Actions\Action::make('linkPaddle') Actions\Action::make('linkLemonSqueezy')
->label('Paddle verknüpfen') ->label('Lemon Squeezy verknüpfen')
->icon('heroicon-o-link') ->icon('heroicon-o-link')
->color('info') ->color('info')
->form([ ->form([
TextInput::make('paddle_product_id') TextInput::make('lemonsqueezy_product_id')
->label('Paddle Produkt-ID') ->label('Lemon Squeezy Produkt-ID')
->required() ->required()
->maxLength(191), ->maxLength(191),
TextInput::make('paddle_price_id') TextInput::make('lemonsqueezy_variant_id')
->label('Paddle Preis-ID') ->label('Lemon Squeezy Variant-ID')
->required() ->required()
->maxLength(191), ->maxLength(191),
]) ])
->fillForm(fn (Package $record) => [ ->fillForm(fn (Package $record) => [
'paddle_product_id' => $record->paddle_product_id, 'lemonsqueezy_product_id' => $record->lemonsqueezy_product_id,
'paddle_price_id' => $record->paddle_price_id, 'lemonsqueezy_variant_id' => $record->lemonsqueezy_variant_id,
]) ])
->action(function (Package $record, array $data): void { ->action(function (Package $record, array $data): void {
$record->linkPaddleIds($data['paddle_product_id'], $data['paddle_price_id']); $record->linkLemonSqueezyIds($data['lemonsqueezy_product_id'], $data['lemonsqueezy_variant_id']);
PullPackageFromPaddle::dispatch($record->id); PullPackageFromLemonSqueezy::dispatch($record->id);
app(SuperAdminAuditLogger::class)->recordModelMutation( app(SuperAdminAuditLogger::class)->recordModelMutation(
'linked', 'linked',
@@ -348,22 +348,22 @@ class PackageResource extends Resource
Notification::make() Notification::make()
->success() ->success()
->title('Paddle-Verknüpfung gespeichert') ->title('Lemon Squeezy-Verknüpfung gespeichert')
->body('Die IDs wurden gespeichert und ein Pull wurde angestoßen.') ->body('Die IDs wurden gespeichert und ein Pull wurde angestoßen.')
->send(); ->send();
}), }),
Actions\Action::make('pullPaddle') Actions\Action::make('pullLemonSqueezy')
->label('Status von Paddle holen') ->label('Status von Lemon Squeezy holen')
->icon('heroicon-o-cloud-arrow-down') ->icon('heroicon-o-cloud-arrow-down')
->disabled(fn (Package $record) => ! $record->paddle_product_id && ! $record->paddle_price_id) ->disabled(fn (Package $record) => ! $record->lemonsqueezy_product_id && ! $record->lemonsqueezy_variant_id)
->requiresConfirmation() ->requiresConfirmation()
->action(function (Package $record) { ->action(function (Package $record) {
PullPackageFromPaddle::dispatch($record->id); PullPackageFromLemonSqueezy::dispatch($record->id);
Notification::make() Notification::make()
->info() ->info()
->title('Paddle-Abgleich angefordert') ->title('Lemon Squeezy-Abgleich angefordert')
->body('Der aktuelle Stand aus Paddle wird geladen und hier hinterlegt.') ->body('Der aktuelle Stand aus Lemon Squeezy wird geladen und hier hinterlegt.')
->send(); ->send();
}), }),
ViewAction::make(), ViewAction::make(),

View File

@@ -14,4 +14,3 @@ class ListPurchaseHistories extends ListRecords
return []; return [];
} }
} }

View File

@@ -14,4 +14,3 @@ class ViewPurchaseHistory extends ViewRecord
return []; return [];
} }
} }

View File

@@ -8,7 +8,7 @@ use App\Models\PackagePurchase;
use App\Notifications\Customer\RefundReceipt; use App\Notifications\Customer\RefundReceipt;
use App\Notifications\Ops\RefundProcessed; use App\Notifications\Ops\RefundProcessed;
use App\Services\Audit\SuperAdminAuditLogger; use App\Services\Audit\SuperAdminAuditLogger;
use App\Services\Paddle\PaddleTransactionService; use App\Services\LemonSqueezy\LemonSqueezyOrderService;
use BackedEnum; use BackedEnum;
use Filament\Actions\Action; use Filament\Actions\Action;
use Filament\Actions\BulkActionGroup; use Filament\Actions\BulkActionGroup;
@@ -203,15 +203,15 @@ class PurchaseResource extends Resource
$refundSuccess = true; $refundSuccess = true;
$errorMessage = null; $errorMessage = null;
if ($record->provider === 'paddle' && $record->provider_id) { if ($record->provider === 'lemonsqueezy' && $record->provider_id) {
try { try {
/** @var PaddleTransactionService $paddle */ /** @var LemonSqueezyOrderService $lemonsqueezy */
$paddle = App::make(PaddleTransactionService::class); $lemonsqueezy = App::make(LemonSqueezyOrderService::class);
$paddle->refund($record->provider_id, ['reason' => $reason]); $lemonsqueezy->refund($record->provider_id, ['reason' => $reason]);
} catch (\Throwable $exception) { } catch (\Throwable $exception) {
$refundSuccess = false; $refundSuccess = false;
$errorMessage = $exception->getMessage(); $errorMessage = $exception->getMessage();
Log::warning('Paddle refund failed', [ Log::warning('Lemon Squeezy refund failed', [
'purchase_id' => $record->id, 'purchase_id' => $record->id,
'provider_id' => $record->provider_id, 'provider_id' => $record->provider_id,
'error' => $exception->getMessage(), 'error' => $exception->getMessage(),

View File

@@ -35,7 +35,7 @@ class ViewPurchase extends ViewRecord
->visible(fn ($record): bool => ! $record->refunded) ->visible(fn ($record): bool => ! $record->refunded)
->action(function ($record) { ->action(function ($record) {
$record->update(['refunded' => true]); $record->update(['refunded' => true]);
// TODO: Call Paddle API for actual refund // TODO: Call Lemon Squeezy API for actual refund
app(SuperAdminAuditLogger::class)->record( app(SuperAdminAuditLogger::class)->record(
'purchase.refunded', 'purchase.refunded',

View File

@@ -8,6 +8,7 @@ use App\Filament\Resources\TenantResource\RelationManagers\PackagePurchasesRelat
use App\Filament\Resources\TenantResource\RelationManagers\TenantPackagesRelationManager; use App\Filament\Resources\TenantResource\RelationManagers\TenantPackagesRelationManager;
use App\Filament\Resources\TenantResource\Schemas\TenantInfolist; use App\Filament\Resources\TenantResource\Schemas\TenantInfolist;
use App\Jobs\AnonymizeAccount; use App\Jobs\AnonymizeAccount;
use App\Models\Package;
use App\Models\Tenant; use App\Models\Tenant;
use App\Notifications\InactiveTenantDeletionWarning; use App\Notifications\InactiveTenantDeletionWarning;
use App\Services\Audit\SuperAdminAuditLogger; use App\Services\Audit\SuperAdminAuditLogger;
@@ -72,10 +73,10 @@ class TenantResource extends Resource
->email() ->email()
->required() ->required()
->maxLength(255), ->maxLength(255),
TextInput::make('paddle_customer_id') TextInput::make('lemonsqueezy_customer_id')
->label('Paddle Customer ID') ->label('Lemon Squeezy Customer ID')
->maxLength(191) ->maxLength(191)
->helperText('Verknuepfung mit Paddle Billing Kundenkonto.') ->helperText('Verknüpfung mit Lemon Squeezy Kundenkonto.')
->nullable(), ->nullable(),
TextInput::make('total_revenue') TextInput::make('total_revenue')
->label(__('admin.tenants.fields.total_revenue')) ->label(__('admin.tenants.fields.total_revenue'))
@@ -134,8 +135,8 @@ class TenantResource extends Resource
->getStateUsing(fn (Tenant $record) => $record->user?->full_name ?? 'Unbekannt'), ->getStateUsing(fn (Tenant $record) => $record->user?->full_name ?? 'Unbekannt'),
Tables\Columns\TextColumn::make('slug')->searchable(), Tables\Columns\TextColumn::make('slug')->searchable(),
Tables\Columns\TextColumn::make('contact_email'), Tables\Columns\TextColumn::make('contact_email'),
Tables\Columns\TextColumn::make('paddle_customer_id') Tables\Columns\TextColumn::make('lemonsqueezy_customer_id')
->label('Paddle Customer') ->label('Lemon Squeezy Customer')
->toggleable(isToggledHiddenByDefault: true) ->toggleable(isToggledHiddenByDefault: true)
->formatStateUsing(fn ($state) => $state ?: '-'), ->formatStateUsing(fn ($state) => $state ?: '-'),
Tables\Columns\TextColumn::make('active_reseller_package_id') Tables\Columns\TextColumn::make('active_reseller_package_id')
@@ -205,11 +206,13 @@ class TenantResource extends Resource
Forms\Components\Textarea::make('reason')->label('Grund')->rows(3), Forms\Components\Textarea::make('reason')->label('Grund')->rows(3),
]) ])
->action(function (Tenant $record, array $data) { ->action(function (Tenant $record, array $data) {
$package = Package::query()->find($data['package_id']);
\App\Models\TenantPackage::create([ \App\Models\TenantPackage::create([
'tenant_id' => $record->id, 'tenant_id' => $record->id,
'package_id' => $data['package_id'], 'package_id' => $data['package_id'],
'expires_at' => $data['expires_at'], 'expires_at' => $data['expires_at'],
'active' => true, 'active' => true,
'price' => $package?->price ?? 0,
'reason' => $data['reason'] ?? null, 'reason' => $data['reason'] ?? null,
]); ]);
\App\Models\PackagePurchase::create([ \App\Models\PackagePurchase::create([

View File

@@ -44,7 +44,7 @@ class PackagePurchasesRelationManager extends RelationManager
Select::make('provider') Select::make('provider')
->label('Anbieter') ->label('Anbieter')
->options([ ->options([
'paddle' => 'Paddle', 'lemonsqueezy' => 'Lemon Squeezy',
'manual' => 'Manuell', 'manual' => 'Manuell',
'free' => 'Kostenlos', 'free' => 'Kostenlos',
]) ])
@@ -89,7 +89,7 @@ class PackagePurchasesRelationManager extends RelationManager
TextColumn::make('provider') TextColumn::make('provider')
->badge() ->badge()
->color(fn (string $state): string => match ($state) { ->color(fn (string $state): string => match ($state) {
'paddle' => 'success', 'lemonsqueezy' => 'success',
'manual' => 'gray', 'manual' => 'gray',
'free' => 'success', 'free' => 'success',
default => 'gray', default => 'gray',
@@ -116,7 +116,7 @@ class PackagePurchasesRelationManager extends RelationManager
]), ]),
SelectFilter::make('provider') SelectFilter::make('provider')
->options([ ->options([
'paddle' => 'Paddle', 'lemonsqueezy' => 'Lemon Squeezy',
'manual' => 'Manuell', 'manual' => 'Manuell',
'free' => 'Kostenlos', 'free' => 'Kostenlos',
]), ]),

View File

@@ -40,10 +40,10 @@ class TenantPackagesRelationManager extends RelationManager
DateTimePicker::make('expires_at') DateTimePicker::make('expires_at')
->label('Ablaufdatum') ->label('Ablaufdatum')
->required(), ->required(),
TextInput::make('paddle_subscription_id') TextInput::make('lemonsqueezy_subscription_id')
->label('Paddle Subscription ID') ->label('Lemon Squeezy Subscription ID')
->maxLength(191) ->maxLength(191)
->helperText('Abonnement-ID aus Paddle Billing.') ->helperText('Abonnement-ID aus Lemon Squeezy.')
->nullable(), ->nullable(),
Toggle::make('active') Toggle::make('active')
->label('Aktiv'), ->label('Aktiv'),
@@ -75,8 +75,8 @@ class TenantPackagesRelationManager extends RelationManager
TextColumn::make('expires_at') TextColumn::make('expires_at')
->dateTime() ->dateTime()
->sortable(), ->sortable(),
TextColumn::make('paddle_subscription_id') TextColumn::make('lemonsqueezy_subscription_id')
->label('Paddle Subscription') ->label('Lemon Squeezy Subscription')
->toggleable(isToggledHiddenByDefault: true) ->toggleable(isToggledHiddenByDefault: true)
->formatStateUsing(fn ($state) => $state ?: '-'), ->formatStateUsing(fn ($state) => $state ?: '-'),
IconColumn::make('active') IconColumn::make('active')

View File

@@ -22,8 +22,8 @@ class TenantInfolist
TextEntry::make('user.full_name') TextEntry::make('user.full_name')
->label(__('admin.tenants.fields.owner')) ->label(__('admin.tenants.fields.owner'))
->state(fn (Tenant $record) => $record->user?->full_name ?? '—'), ->state(fn (Tenant $record) => $record->user?->full_name ?? '—'),
TextEntry::make('paddle_customer_id') TextEntry::make('lemonsqueezy_customer_id')
->label('Paddle Customer ID') ->label('Lemon Squeezy Customer ID')
->placeholder('—'), ->placeholder('—'),
TextEntry::make('total_revenue') TextEntry::make('total_revenue')
->label(__('admin.tenants.fields.total_revenue')) ->label(__('admin.tenants.fields.total_revenue'))

View File

@@ -3,39 +3,78 @@
namespace App\Filament\SuperAdmin\Pages\Auth; namespace App\Filament\SuperAdmin\Pages\Auth;
use Filament\Auth\Pages\EditProfile as BaseEditProfile; use Filament\Auth\Pages\EditProfile as BaseEditProfile;
use Filament\Forms\Components\TextInput; use Filament\Facades\Filament;
use Filament\Forms\Components\Select; use Filament\Forms\Components\Select;
use Filament\Forms\Components\TextInput;
use Filament\Schemas\Components\Component;
use Filament\Schemas\Components\Livewire;
use Filament\Schemas\Components\Section;
use Filament\Schemas\Components\Utilities\Get;
use Filament\Schemas\Schema; use Filament\Schemas\Schema;
use Illuminate\Support\Facades\Log;
class EditProfile extends BaseEditProfile class EditProfile extends BaseEditProfile
{ {
public function mount(): void protected function getPasswordConfirmationFormComponent(): Component
{ {
Log::info('EditProfile class loaded for superadmin'); return TextInput::make('passwordConfirmation')
parent::mount(); ->label(__('filament-panels::auth/pages/edit-profile.form.password_confirmation.label'))
->validationAttribute(__('filament-panels::auth/pages/edit-profile.form.password_confirmation.validation_attribute'))
->password()
->autocomplete('new-password')
->revealable(filament()->arePasswordsRevealable())
->required()
->visible(fn (Get $get): bool => filled($get('password')))
->dehydrated(false);
}
protected function getCurrentPasswordFormComponent(): Component
{
return TextInput::make('currentPassword')
->label(__('filament-panels::auth/pages/edit-profile.form.current_password.label'))
->validationAttribute(__('filament-panels::auth/pages/edit-profile.form.current_password.validation_attribute'))
->belowContent(__('filament-panels::auth/pages/edit-profile.form.current_password.below_content'))
->password()
->autocomplete('current-password')
->currentPassword(guard: Filament::getAuthGuard())
->revealable(filament()->arePasswordsRevealable())
->required()
->visible(fn (Get $get): bool => filled($get('password')))
->dehydrated(false);
} }
public function form(Schema $schema): Schema public function form(Schema $schema): Schema
{ {
return $schema return $schema
->schema([ ->schema([
$this->getNameFormComponent(), Section::make('Profile')
$this->getEmailFormComponent(), ->schema([
TextInput::make('username') $this->getNameFormComponent(),
->required() $this->getEmailFormComponent(),
->unique(ignoreRecord: true) TextInput::make('username')
->maxLength(255), ->required()
Select::make('preferred_locale') ->unique(ignoreRecord: true)
->options([ ->maxLength(255),
'de' => 'Deutsch', Select::make('preferred_locale')
'en' => 'English', ->options([
'de' => 'Deutsch',
'en' => 'English',
])
->default('de')
->required(),
]) ])
->default('de') ->columns(2),
->required(), Section::make('Security')
$this->getPasswordFormComponent(), ->schema([
$this->getPasswordConfirmationFormComponent(), $this->getPasswordFormComponent(),
$this->getCurrentPasswordFormComponent(), $this->getPasswordConfirmationFormComponent(),
$this->getCurrentPasswordFormComponent(),
])
->columns(1),
Section::make('Support API Tokens')
->description('Manage bearer tokens for external support tooling.')
->schema([
Livewire::make('support-api-token-manager'),
]),
]); ]);
} }
} }

View File

@@ -27,7 +27,7 @@ class WatermarkSettingsPage extends Page
return __('admin.nav.branding'); return __('admin.nav.branding');
} }
public ?string $asset = null; public $asset = [];
public string $position = 'bottom-right'; public string $position = 'bottom-right';
@@ -46,7 +46,7 @@ class WatermarkSettingsPage extends Page
$settings = WatermarkSetting::query()->first(); $settings = WatermarkSetting::query()->first();
if ($settings) { if ($settings) {
$this->asset = $settings->asset; $this->asset = $settings->asset ? [$settings->asset] : [];
$this->position = $settings->position; $this->position = $settings->position;
$this->opacity = (float) $settings->opacity; $this->opacity = (float) $settings->opacity;
$this->scale = (float) $settings->scale; $this->scale = (float) $settings->scale;
@@ -119,8 +119,14 @@ class WatermarkSettingsPage extends Page
{ {
$this->validate(); $this->validate();
$state = $this->form->getState();
$asset = $state['asset'] ?? $this->asset;
if (is_array($asset)) {
$asset = $asset[0] ?? null;
}
$settings = WatermarkSetting::query()->firstOrNew([]); $settings = WatermarkSetting::query()->firstOrNew([]);
$settings->asset = $this->asset; $settings->asset = $asset;
$settings->position = $this->position; $settings->position = $this->position;
$settings->opacity = $this->opacity; $settings->opacity = $this->opacity;
$settings->scale = $this->scale; $settings->scale = $this->scale;

View File

@@ -0,0 +1,280 @@
<?php
namespace App\Filament\SuperAdmin\Widgets;
use App\Models\User;
use App\Services\Audit\SuperAdminAuditLogger;
use Filament\Actions\Action;
use Filament\Facades\Filament;
use Filament\Forms\Components\CheckboxList;
use Filament\Forms\Components\DateTimePicker;
use Filament\Forms\Components\TextInput;
use Filament\Notifications\Notification;
use Filament\Tables;
use Filament\Tables\Table;
use Filament\Widgets\TableWidget;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Support\Carbon;
use Laravel\Sanctum\NewAccessToken;
use Laravel\Sanctum\PersonalAccessToken;
class SupportApiTokenManager extends TableWidget
{
protected static bool $isDiscovered = false;
protected int|string|array $columnSpan = 'full';
public function table(Table $table): Table
{
return $table
->heading('Support API Tokens')
->query(fn (): Builder => $this->getTokenQuery())
->defaultSort('created_at', 'desc')
->columns([
Tables\Columns\TextColumn::make('name')
->label('Name')
->sortable()
->searchable(),
Tables\Columns\TextColumn::make('abilities')
->label('Abilities')
->formatStateUsing(fn ($state): string => $this->formatAbilities($state))
->wrap(),
Tables\Columns\TextColumn::make('last_used_at')
->label('Last used')
->since()
->placeholder('—'),
Tables\Columns\TextColumn::make('expires_at')
->label('Expires')
->dateTime('Y-m-d H:i')
->placeholder('—'),
Tables\Columns\TextColumn::make('created_at')
->label('Created')
->since(),
])
->headerActions([
Action::make('create_support_token')
->label('Create token')
->icon('heroicon-o-key')
->form([
TextInput::make('name')
->label('Token name')
->default($this->defaultTokenName())
->required()
->maxLength(255)
->helperText('Existing tokens with the same name will be revoked.'),
CheckboxList::make('abilities')
->label('Abilities')
->options($this->abilityOptions())
->columns(2)
->required()
->default($this->defaultAbilities()),
DateTimePicker::make('expires_at')
->label('Expires at')
->displayFormat('Y-m-d H:i')
->seconds(false),
])
->action(function (array $data): void {
$user = $this->getUser();
if (! $user) {
return;
}
$name = $this->normalizeTokenName($data['name'] ?? null);
$abilities = $this->normalizeAbilities($data['abilities'] ?? []);
$expiresAt = $this->normalizeExpiresAt($data['expires_at'] ?? null);
$user->tokens()->where('name', $name)->delete();
$token = $user->createToken($name, $abilities, $expiresAt);
$this->recordTokenCreated($token, $abilities, $user);
Notification::make()
->success()
->title('Token created')
->body('Copy this token now. It will not be shown again: '.$token->plainTextToken)
->persistent()
->send();
}),
])
->actions([
Action::make('revoke')
->label('Revoke')
->icon('heroicon-o-trash')
->color('danger')
->requiresConfirmation()
->visible(fn (PersonalAccessToken $record): bool => $this->ownsToken($record))
->action(function (PersonalAccessToken $record): void {
if (! $this->ownsToken($record)) {
return;
}
app(SuperAdminAuditLogger::class)->record(
'support-api-token.revoked',
$record,
['fields' => ['name', 'abilities', 'expires_at']],
actor: $this->getUser(),
source: static::class
);
$record->delete();
Notification::make()
->success()
->title('Token revoked')
->send();
}),
])
->emptyStateHeading('No support API tokens')
->emptyStateDescription('Create a token for external support tooling.');
}
private function getTokenQuery(): Builder
{
$user = $this->getUser();
if (! $user) {
return PersonalAccessToken::query()->whereRaw('1 = 0');
}
return PersonalAccessToken::query()
->where('tokenable_id', $user->getKey())
->where('tokenable_type', $user->getMorphClass());
}
private function getUser(): ?User
{
$user = Filament::auth()->user();
return $user instanceof User ? $user : null;
}
private function formatAbilities(mixed $state): string
{
if (is_array($state)) {
return implode(', ', $state);
}
if (is_string($state)) {
return $state;
}
return '';
}
/**
* @return array<int, string>
*/
private function defaultAbilities(): array
{
$abilities = config('support-api.token.default_abilities', []);
if (! is_array($abilities)) {
return ['support-admin'];
}
$abilities = array_values(array_filter($abilities, fn ($ability) => is_string($ability) && $ability !== ''));
if (! in_array('support-admin', $abilities, true)) {
$abilities[] = 'support-admin';
}
return array_values(array_unique($abilities));
}
/**
* @return array<string, string>
*/
private function abilityOptions(): array
{
$options = [];
foreach ($this->defaultAbilities() as $ability) {
$options[$ability] = $ability;
}
return $options;
}
/**
* @param array<int, string> $abilities
* @return array<int, string>
*/
private function normalizeAbilities(array $abilities): array
{
$allowed = $this->defaultAbilities();
$filtered = array_values(array_intersect($abilities, $allowed));
if (! in_array('support-admin', $filtered, true)) {
$filtered[] = 'support-admin';
}
sort($filtered);
return $filtered;
}
private function defaultTokenName(): string
{
$name = config('support-api.token.name');
if (is_string($name) && $name !== '') {
return $name;
}
return 'support-api';
}
private function normalizeTokenName(?string $name): string
{
$name = $name ? trim($name) : '';
return $name !== '' ? $name : $this->defaultTokenName();
}
private function normalizeExpiresAt(mixed $expiresAt): ?Carbon
{
if ($expiresAt instanceof Carbon) {
return $expiresAt;
}
if ($expiresAt instanceof \DateTimeInterface) {
return Carbon::instance($expiresAt);
}
if (is_string($expiresAt) && $expiresAt !== '') {
return Carbon::parse($expiresAt);
}
return null;
}
private function recordTokenCreated(NewAccessToken $token, array $abilities, User $user): void
{
$actionLog = app(SuperAdminAuditLogger::class);
$actionLog->record(
'support-api-token.created',
$token->accessToken,
[
'fields' => ['name', 'abilities', 'expires_at'],
'abilities' => $abilities,
],
actor: $user,
source: static::class
);
}
private function ownsToken(PersonalAccessToken $token): bool
{
$user = $this->getUser();
if (! $user) {
return false;
}
return (int) $token->tokenable_id === (int) $user->getKey()
&& $token->tokenable_type === $user->getMorphClass();
}
}

View File

@@ -14,11 +14,88 @@ class DokployPlatformHealth extends Widget
protected function getViewData(): array protected function getViewData(): array
{ {
$projects = $this->loadProjects();
return [ return [
'composes' => $this->loadComposes(), 'projects' => $projects,
'composes' => empty($projects) ? $this->loadComposes() : [],
]; ];
} }
protected function loadProjects(): array
{
$client = app(DokployClient::class);
$projectMap = config('dokploy.projects', []);
$results = [];
if (empty($projectMap)) {
return [];
}
foreach ($projectMap as $label => $projectId) {
$project = [];
$projectIdString = (string) $projectId;
try {
$project = $client->project($projectIdString);
} catch (\Throwable $exception) {
$project = [];
}
if (empty($project)) {
$project = $client->findProject($projectIdString) ?? [];
$resolvedProjectId = Arr::get($project, 'projectId');
if ($resolvedProjectId) {
try {
$project = $client->project((string) $resolvedProjectId);
} catch (\Throwable $exception) {
$project = $project;
}
}
}
if (! $project) {
$results[] = [
'label' => ucfirst((string) $label),
'project_id' => $projectIdString,
'name' => $projectIdString,
'status' => 'unreachable',
'error' => "Project {$projectIdString} not found.",
'applications' => [],
'services' => [],
'composes' => [],
'updated_at' => null,
];
continue;
}
$environments = $this->extractEnvironments($project);
$applications = $this->formatEnvironmentApplications($environments, $client);
$composes = $this->formatEnvironmentComposes($environments, $client);
$services = $this->formatEnvironmentServices($environments);
$results[] = [
'label' => ucfirst((string) $label),
'project_id' => Arr::get($project, 'projectId', $projectIdString),
'name' => Arr::get($project, 'name') ?? Arr::get($project, 'projectName') ?? $projectIdString,
'description' => Arr::get($project, 'description'),
'status' => $this->deriveProjectStatus($applications, $services, $composes),
'applications' => $applications,
'composes' => $composes,
'services' => $services,
'updated_at' => Arr::get($project, 'updatedAt') ?? Arr::get($project, 'createdAt'),
'applications_count' => count($applications),
'composes_count' => count($composes),
'services_count' => count($services),
];
}
return $results;
}
protected function loadComposes(): array protected function loadComposes(): array
{ {
$client = app(DokployClient::class); $client = app(DokployClient::class);
@@ -62,7 +139,7 @@ class DokployPlatformHealth extends Widget
'label' => 'Dokploy', 'label' => 'Dokploy',
'compose_id' => '-', 'compose_id' => '-',
'status' => 'unconfigured', 'status' => 'unconfigured',
'error' => 'Set DOKPLOY_COMPOSE_IDS in .env to enable monitoring.', 'error' => 'Set DOKPLOY_PROJECT_IDS or DOKPLOY_COMPOSE_IDS in .env to enable monitoring.',
], ],
]; ];
} }
@@ -70,6 +147,252 @@ class DokployPlatformHealth extends Widget
return $results; return $results;
} }
protected function extractEnvironments(array $project): array
{
$environments = Arr::get($project, 'environments', []);
if (is_array($environments) && ! empty($environments)) {
return $environments;
}
return [[
'name' => Arr::get($project, 'name'),
'applications' => Arr::get($project, 'applications', []),
'compose' => Arr::get($project, 'compose', []),
'mysql' => Arr::get($project, 'mysql', []),
'postgres' => Arr::get($project, 'postgres', []),
'mariadb' => Arr::get($project, 'mariadb', []),
'mongo' => Arr::get($project, 'mongo', []),
'redis' => Arr::get($project, 'redis', []),
]];
}
protected function formatEnvironmentApplications(array $environments, DokployClient $client): array
{
return collect($environments)
->flatMap(function (array $environment) use ($client) {
$applications = Arr::get($environment, 'applications', []);
$environmentName = Arr::get($environment, 'name');
return $this->formatApplications(is_array($applications) ? $applications : [], $client, $environmentName);
})
->values()
->all();
}
protected function formatEnvironmentComposes(array $environments, DokployClient $client): array
{
return collect($environments)
->flatMap(function (array $environment) use ($client) {
$composes = Arr::get($environment, 'compose', []);
$environmentName = Arr::get($environment, 'name');
return collect(is_array($composes) ? $composes : [])
->map(function (array $compose) use ($client, $environmentName) {
$composeId = Arr::get($compose, 'composeId') ?? Arr::get($compose, 'id');
$statusPayload = [];
$deployments = [];
if ($composeId) {
try {
$statusPayload = $client->composeStatus($composeId);
$deployments = $client->composeDeployments($composeId, 1);
} catch (\Throwable $exception) {
$statusPayload = [];
$deployments = [];
}
}
$composeDetails = Arr::get($statusPayload, 'compose', []);
return [
'id' => $composeId,
'name' => Arr::get($compose, 'name')
?? Arr::get($compose, 'appName')
?? Arr::get($composeDetails, 'name')
?? Arr::get($composeDetails, 'appName')
?? $composeId,
'status' => Arr::get($compose, 'composeStatus')
?? Arr::get($compose, 'status')
?? Arr::get($composeDetails, 'composeStatus')
?? Arr::get($composeDetails, 'status')
?? 'unknown',
'environment' => $environmentName,
'last_deploy' => Arr::get($deployments, '0.createdAt')
?? Arr::get($deployments, '0.created_at')
?? Arr::get($compose, 'updatedAt')
?? Arr::get($composeDetails, 'updatedAt'),
'services' => $this->formatServices(Arr::get($statusPayload, 'services', [])),
];
})
->filter(fn (array $compose) => filled($compose['name']))
->values()
->all();
})
->values()
->all();
}
protected function formatEnvironmentServices(array $environments): array
{
return collect($environments)
->flatMap(function (array $environment) {
$environmentName = Arr::get($environment, 'name');
return collect([
...$this->normalizeServiceList((array) Arr::get($environment, 'compose', []), 'compose', 'composeId', 'composeStatus', $environmentName),
...$this->normalizeServiceList((array) Arr::get($environment, 'mysql', []), 'mysql', 'mysqlId', 'applicationStatus', $environmentName),
...$this->normalizeServiceList((array) Arr::get($environment, 'postgres', []), 'postgres', 'postgresId', 'applicationStatus', $environmentName),
...$this->normalizeServiceList((array) Arr::get($environment, 'mariadb', []), 'mariadb', 'mariadbId', 'applicationStatus', $environmentName),
...$this->normalizeServiceList((array) Arr::get($environment, 'mongo', []), 'mongo', 'mongoId', 'applicationStatus', $environmentName),
...$this->normalizeServiceList((array) Arr::get($environment, 'redis', []), 'redis', 'redisId', 'applicationStatus', $environmentName),
]);
})
->filter(fn (array $service) => filled($service['name']))
->values()
->all();
}
protected function formatApplications(array $applications, DokployClient $client, ?string $environment = null): array
{
return collect($applications)
->map(function (array $application) use ($client, $environment) {
$applicationId = $this->extractApplicationId($application);
$statusPayload = [];
if ($applicationId) {
try {
$statusPayload = $client->applicationStatus($applicationId);
} catch (\Throwable $exception) {
$statusPayload = [];
}
}
$applicationDetails = Arr::get($statusPayload, 'application', []);
$monitoring = Arr::get($statusPayload, 'monitoring', []);
$status = Arr::get($application, 'applicationStatus')
?? Arr::get($application, 'status')
?? Arr::get($applicationDetails, 'applicationStatus')
?? Arr::get($applicationDetails, 'status')
?? 'unknown';
return [
'id' => $applicationId ?? Arr::get($application, 'id'),
'name' => Arr::get($application, 'name')
?? Arr::get($application, 'appName')
?? Arr::get($applicationDetails, 'name')
?? Arr::get($applicationDetails, 'appName')
?? $applicationId,
'status' => $status,
'repository' => Arr::get($application, 'repository')
?? Arr::get($applicationDetails, 'repository')
?? Arr::get($application, 'repo')
?? Arr::get($applicationDetails, 'repo'),
'branch' => Arr::get($application, 'branch')
?? Arr::get($applicationDetails, 'branch')
?? Arr::get($application, 'gitBranch')
?? Arr::get($applicationDetails, 'gitBranch'),
'url' => Arr::get($application, 'url')
?? Arr::get($applicationDetails, 'url')
?? Arr::get($application, 'domain')
?? Arr::get($applicationDetails, 'domain'),
'server' => Arr::get($application, 'serverName')
?? Arr::get($applicationDetails, 'serverName')
?? Arr::get($application, 'server'),
'environment' => $environment,
'last_deploy' => Arr::get($application, 'lastDeploymentAt')
?? Arr::get($applicationDetails, 'lastDeploymentAt')
?? Arr::get($application, 'updatedAt')
?? Arr::get($applicationDetails, 'updatedAt')
?? Arr::get($application, 'createdAt'),
'monitoring' => $this->formatMonitoring($monitoring),
];
})
->filter(fn (array $application) => filled($application['name']))
->values()
->all();
}
protected function extractApplicationId(array $application): ?string
{
return Arr::get($application, 'applicationId')
?? Arr::get($application, 'appId')
?? Arr::get($application, 'id');
}
protected function normalizeServiceList(array $services, string $type, string $idKey, string $statusKey, ?string $environment = null): array
{
return collect($services)
->map(function (array $service) use ($type, $idKey, $statusKey, $environment) {
return [
'type' => $type,
'id' => Arr::get($service, $idKey) ?? Arr::get($service, 'id'),
'name' => Arr::get($service, 'name') ?? Arr::get($service, 'appName') ?? Arr::get($service, 'serviceName'),
'status' => Arr::get($service, $statusKey) ?? Arr::get($service, 'status') ?? Arr::get($service, 'composeStatus', 'unknown'),
'version' => Arr::get($service, 'dockerImage') ?? Arr::get($service, 'image'),
'external_port' => Arr::get($service, 'externalPort'),
'environment' => $environment,
];
})
->values()
->all();
}
protected function formatMonitoring(array $monitoring): array
{
$metrics = [];
$allowed = [
'cpuPercent' => 'CPU',
'cpu' => 'CPU',
'memoryPercent' => 'Memory',
'memory' => 'Memory',
'uptime' => 'Uptime',
];
foreach ($allowed as $key => $label) {
$value = Arr::get($monitoring, $key);
if (filled($value) && ! is_array($value)) {
$metrics[] = [
'label' => $label,
'value' => $value,
];
}
}
return $metrics;
}
protected function deriveProjectStatus(array $applications, array $services, array $composes): string
{
$statuses = collect($applications)
->pluck('status')
->merge(collect($services)->pluck('status'))
->merge(collect($composes)->pluck('status'))
->filter()
->map(fn ($status) => strtolower((string) $status))
->values();
if ($statuses->contains(fn ($status) => in_array($status, ['error', 'failed', 'unreachable', 'unhealthy'], true))) {
return 'error';
}
if ($statuses->contains(fn ($status) => in_array($status, ['deploying', 'pending', 'starting'], true))) {
return 'deploying';
}
if ($statuses->contains(fn ($status) => in_array($status, ['stopped', 'inactive', 'paused'], true))) {
return 'warning';
}
if ($statuses->contains(fn ($status) => in_array($status, ['done', 'running', 'healthy'], true))) {
return 'done';
}
return 'unknown';
}
protected function formatServices(array $services): array protected function formatServices(array $services): array
{ {
return collect($services) return collect($services)

View File

@@ -4,8 +4,8 @@ namespace App\Filament\Widgets;
use Filament\Widgets\StatsOverviewWidget as BaseWidget; use Filament\Widgets\StatsOverviewWidget as BaseWidget;
use Filament\Widgets\StatsOverviewWidget\Stat; use Filament\Widgets\StatsOverviewWidget\Stat;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Carbon; use Illuminate\Support\Carbon;
use Illuminate\Support\Facades\DB;
class PlatformStatsWidget extends BaseWidget class PlatformStatsWidget extends BaseWidget
{ {

View File

@@ -12,6 +12,8 @@ class QueueHealthWidget extends Widget
protected ?string $pollingInterval = '60s'; protected ?string $pollingInterval = '60s';
protected int|string|array $columnSpan = 'full';
protected function getViewData(): array protected function getViewData(): array
{ {
$snapshot = Cache::get('storage:queue-health:last'); $snapshot = Cache::get('storage:queue-health:last');

View File

@@ -7,7 +7,6 @@ use Filament\Widgets\LineChartWidget;
class RevenueTrendWidget extends LineChartWidget class RevenueTrendWidget extends LineChartWidget
{ {
protected static ?int $sort = 1; protected static ?int $sort = 1;
protected int|string|array $columnSpan = 'full'; protected int|string|array $columnSpan = 'full';

View File

@@ -2,9 +2,9 @@
namespace App\Filament\Widgets; namespace App\Filament\Widgets;
use App\Models\Tenant;
use Filament\Tables; use Filament\Tables;
use Filament\Widgets\TableWidget as BaseWidget; use Filament\Widgets\TableWidget as BaseWidget;
use App\Models\Tenant;
class TopTenantsByUploads extends BaseWidget class TopTenantsByUploads extends BaseWidget
{ {
@@ -14,6 +14,7 @@ class TopTenantsByUploads extends BaseWidget
{ {
return __('admin.widgets.top_tenants_by_uploads.heading'); return __('admin.widgets.top_tenants_by_uploads.heading');
} }
protected ?string $pollingInterval = '60s'; protected ?string $pollingInterval = '60s';
public function table(Tables\Table $table): Tables\Table public function table(Tables\Table $table): Tables\Table
@@ -33,4 +34,3 @@ class TopTenantsByUploads extends BaseWidget
->paginated(false); ->paginated(false);
} }
} }

View File

@@ -2,8 +2,8 @@
namespace App\Http\Controllers\Admin; namespace App\Http\Controllers\Admin;
use Illuminate\Routing\Controller as BaseController;
use Illuminate\Http\Request; use Illuminate\Http\Request;
use Illuminate\Routing\Controller as BaseController;
use SimpleSoftwareIO\QrCode\Facades\QrCode; use SimpleSoftwareIO\QrCode\Facades\QrCode;
class QrController extends BaseController class QrController extends BaseController
@@ -15,7 +15,7 @@ class QrController extends BaseController
return response('missing data', 400); return response('missing data', 400);
} }
$png = QrCode::format('png')->size(300)->generate($data); $png = QrCode::format('png')->size(300)->generate($data);
return response($png, 200, ['Content-Type' => 'image/png']); return response($png, 200, ['Content-Type' => 'image/png']);
} }
} }

View File

@@ -41,6 +41,7 @@ use Illuminate\Support\Facades\Schema;
use Illuminate\Support\Facades\Storage; use Illuminate\Support\Facades\Storage;
use Illuminate\Support\Facades\URL; use Illuminate\Support\Facades\URL;
use Illuminate\Support\Str; use Illuminate\Support\Str;
use SimpleSoftwareIO\QrCode\Facades\QrCode;
use Symfony\Component\HttpFoundation\Response; use Symfony\Component\HttpFoundation\Response;
class EventPublicController extends BaseController class EventPublicController extends BaseController
@@ -185,6 +186,57 @@ class EventPublicController extends BaseController
); );
} }
$deviceId = (string) $request->header('X-Device-Id', $request->input('device_id', ''));
$deviceId = $deviceId !== '' ? $deviceId : null;
if ($event->id ?? null) {
$eventModel = Event::with(['tenant', 'eventPackage.package', 'eventPackages.package'])->find($event->id);
if ($eventModel && $eventModel->tenant) {
$eventPackage = $this->packageLimitEvaluator->resolveEventPackageForPhotoUpload(
$eventModel->tenant,
$eventModel->id,
$eventModel
);
$maxGuests = $eventPackage?->effectiveGuestLimit();
if ($eventPackage && $maxGuests !== null) {
$grace = (int) config('package-limits.guest_grace', 10);
$hardLimit = $maxGuests + max(0, $grace);
$usedGuests = (int) $eventPackage->used_guests;
$isReturningGuest = $this->joinTokenService->hasSeenGuest($eventModel->id, $deviceId, $request->ip());
if ($usedGuests >= $hardLimit && ! $isReturningGuest) {
$this->recordTokenEvent(
$joinToken,
$request,
'guest_limit_exceeded',
[
'event_id' => $eventModel->id,
'used' => $usedGuests,
'limit' => $maxGuests,
'hard_limit' => $hardLimit,
],
$token,
Response::HTTP_PAYMENT_REQUIRED
);
return ApiError::response(
'guest_limit_exceeded',
__('api.packages.guest_limit_exceeded.title'),
__('api.packages.guest_limit_exceeded.message'),
Response::HTTP_PAYMENT_REQUIRED,
[
'event_id' => $eventModel->id,
'used' => $usedGuests,
'limit' => $maxGuests,
'hard_limit' => $hardLimit,
]
);
}
}
}
}
RateLimiter::clear($rateLimiterKey); RateLimiter::clear($rateLimiterKey);
if (isset($event->status)) { if (isset($event->status)) {
@@ -1003,6 +1055,7 @@ class EventPublicController extends BaseController
* heading_font: ?string, * heading_font: ?string,
* body_font: ?string, * body_font: ?string,
* font_size: string, * font_size: string,
* welcome_message: ?string,
* logo_url: ?string, * logo_url: ?string,
* logo_mode: string, * logo_mode: string,
* logo_value: ?string, * logo_value: ?string,
@@ -1025,10 +1078,10 @@ class EventPublicController extends BaseController
private function resolveBrandingPayload(Event $event): array private function resolveBrandingPayload(Event $event): array
{ {
$defaults = [ $defaults = [
'primary' => '#f43f5e', 'primary' => '#FF5A5F',
'secondary' => '#fb7185', 'secondary' => '#FFF8F5',
'background' => '#ffffff', 'background' => '#FFF8F5',
'surface' => '#ffffff', 'surface' => '#FFF8F5',
'font' => null, 'font' => null,
'size' => 'm', 'size' => 'm',
'logo_position' => 'left', 'logo_position' => 'left',
@@ -1042,12 +1095,8 @@ class EventPublicController extends BaseController
$brandingAllowed = $this->determineBrandingAllowed($event); $brandingAllowed = $this->determineBrandingAllowed($event);
$eventBranding = $brandingAllowed ? Arr::get($event->settings, 'branding', []) : []; $eventBranding = $brandingAllowed ? Arr::get($event->settings, 'branding', []) : [];
$tenantBranding = $brandingAllowed ? Arr::get($event->tenant?->settings, 'branding', []) : [];
$useDefault = (bool) Arr::get($eventBranding, 'use_default_branding', Arr::get($eventBranding, 'use_default', false)); $useDefault = (bool) Arr::get($eventBranding, 'use_default_branding', Arr::get($eventBranding, 'use_default', false));
$sources = $brandingAllowed $sources = $brandingAllowed ? [$eventBranding] : [[]];
? ($useDefault ? [$tenantBranding] : [$eventBranding, $tenantBranding])
: [[]];
$primary = $this->normalizeHexColor( $primary = $this->normalizeHexColor(
$this->firstStringFromSources($sources, ['palette.primary', 'primary_color']), $this->firstStringFromSources($sources, ['palette.primary', 'primary_color']),
@@ -1070,6 +1119,7 @@ class EventPublicController extends BaseController
$bodyFont = $this->firstStringFromSources($sources, ['typography.body', 'body_font', 'font_family']); $bodyFont = $this->firstStringFromSources($sources, ['typography.body', 'body_font', 'font_family']);
$fontSize = $this->firstStringFromSources($sources, ['typography.size', 'font_size']) ?? $defaults['size']; $fontSize = $this->firstStringFromSources($sources, ['typography.size', 'font_size']) ?? $defaults['size'];
$fontSize = in_array($fontSize, ['s', 'm', 'l'], true) ? $fontSize : $defaults['size']; $fontSize = in_array($fontSize, ['s', 'm', 'l'], true) ? $fontSize : $defaults['size'];
$welcomeMessage = $this->firstStringFromSources($sources, ['welcome_message', 'welcomeMessage']);
$logoMode = $this->firstStringFromSources($sources, ['logo.mode', 'logo_mode']); $logoMode = $this->firstStringFromSources($sources, ['logo.mode', 'logo_mode']);
if (! in_array($logoMode, ['emoticon', 'upload'], true)) { if (! in_array($logoMode, ['emoticon', 'upload'], true)) {
@@ -1131,6 +1181,7 @@ class EventPublicController extends BaseController
'heading_font' => $headingFont, 'heading_font' => $headingFont,
'body_font' => $bodyFont, 'body_font' => $bodyFont,
'font_size' => $fontSize, 'font_size' => $fontSize,
'welcome_message' => $welcomeMessage,
'logo_url' => $logoMode === 'upload' ? $logoValue : null, 'logo_url' => $logoMode === 'upload' ? $logoValue : null,
'logo_mode' => $logoMode, 'logo_mode' => $logoMode,
'logo_value' => $logoValue, 'logo_value' => $logoValue,
@@ -1298,7 +1349,7 @@ class EventPublicController extends BaseController
); );
} }
$diskName = config('filesystems.default', 'public'); $diskName = 'public';
try { try {
$storage = Storage::disk($diskName); $storage = Storage::disk($diskName);
@@ -1687,6 +1738,7 @@ class EventPublicController extends BaseController
'name' => $event->name, 'name' => $event->name,
'city' => $event->city, 'city' => $event->city,
] : null, ] : null,
'branding' => $event ? $this->resolveBrandingPayload($event) : null,
])->header('Cache-Control', 'no-store'); ])->header('Cache-Control', 'no-store');
} }
@@ -1906,7 +1958,9 @@ class EventPublicController extends BaseController
$policy = $this->guestPolicy(); $policy = $this->guestPolicy();
if ($joinToken) { if ($joinToken) {
$this->joinTokenService->incrementUsage($joinToken); $deviceId = (string) $request->header('X-Device-Id', $request->input('device_id', ''));
$deviceId = $deviceId !== '' ? $deviceId : null;
$this->joinTokenService->incrementUsage($joinToken, $deviceId, $request->ip());
} }
$demoReadOnly = (bool) Arr::get($joinToken?->metadata ?? [], 'demo_read_only', false); $demoReadOnly = (bool) Arr::get($joinToken?->metadata ?? [], 'demo_read_only', false);
@@ -1931,6 +1985,47 @@ class EventPublicController extends BaseController
])->header('Cache-Control', 'no-store'); ])->header('Cache-Control', 'no-store');
} }
public function qr(Request $request, string $token): JsonResponse
{
$result = $this->resolvePublishedEvent($request, $token, ['id']);
if ($result instanceof JsonResponse) {
return $result;
}
[, $joinToken] = $result;
$joinTokenValue = $joinToken->token ?? $token;
$qrCodeUrl = $joinTokenValue ? url('/e/'.$joinTokenValue) : null;
$qrCodeDataUrl = null;
if ($qrCodeUrl) {
$requestedSize = (int) $request->query('size', 360);
$size = max(120, min($requestedSize, 640));
try {
$png = QrCode::format('png')
->size($size)
->margin(1)
->errorCorrection('M')
->generate($qrCodeUrl);
$pngBinary = (string) $png;
if ($pngBinary !== '') {
$qrCodeDataUrl = 'data:image/png;base64,'.base64_encode($pngBinary);
}
} catch (\Throwable $exception) {
report($exception);
}
}
return response()->json([
'url' => $qrCodeUrl,
'qr_code_data_url' => $qrCodeDataUrl,
])->header('Cache-Control', 'no-store');
}
public function package(Request $request, string $token) public function package(Request $request, string $token)
{ {
$result = $this->resolvePublishedEvent($request, $token, ['id']); $result = $this->resolvePublishedEvent($request, $token, ['id']);
@@ -2547,6 +2642,15 @@ class EventPublicController extends BaseController
->distinct('guest_name') ->distinct('guest_name')
->count('guest_name'); ->count('guest_name');
$guestCount = DB::table('photos')
->where('event_id', $eventId)
->distinct('guest_name')
->count('guest_name');
$likesCount = (int) DB::table('photos')
->where('event_id', $eventId)
->sum('likes_count');
// Tasks solved as number of photos linked to a task (proxy metric). // Tasks solved as number of photos linked to a task (proxy metric).
$tasksSolved = $engagementMode === 'photo_only' $tasksSolved = $engagementMode === 'photo_only'
? 0 ? 0
@@ -2557,6 +2661,8 @@ class EventPublicController extends BaseController
$payload = [ $payload = [
'online_guests' => $onlineGuests, 'online_guests' => $onlineGuests,
'tasks_solved' => $tasksSolved, 'tasks_solved' => $tasksSolved,
'guest_count' => $guestCount,
'likes_count' => $likesCount,
'latest_photo_at' => $latestPhotoAt, 'latest_photo_at' => $latestPhotoAt,
'engagement_mode' => $engagementMode, 'engagement_mode' => $engagementMode,
]; ];
@@ -2921,6 +3027,12 @@ class EventPublicController extends BaseController
$policy = $this->guestPolicy(); $policy = $this->guestPolicy();
$uploadVisibility = Arr::get($eventModel->settings ?? [], 'guest_upload_visibility', $policy->guest_upload_visibility); $uploadVisibility = Arr::get($eventModel->settings ?? [], 'guest_upload_visibility', $policy->guest_upload_visibility);
$autoApproveUploads = $uploadVisibility === 'immediate'; $autoApproveUploads = $uploadVisibility === 'immediate';
$controlRoom = Arr::get($eventModel->settings ?? [], 'control_room', []);
$controlRoom = is_array($controlRoom) ? $controlRoom : [];
$autoAddApprovedToLiveSetting = (bool) Arr::get($controlRoom, 'auto_add_approved_to_live', true);
$trustedUploaders = Arr::get($controlRoom, 'trusted_uploaders', []);
$forceReviewUploaders = Arr::get($controlRoom, 'force_review_uploaders', []);
$autoAddApprovedToLiveDefault = $autoAddApprovedToLiveSetting || $autoApproveUploads;
$tenantModel = $eventModel->tenant; $tenantModel = $eventModel->tenant;
@@ -2953,6 +3065,34 @@ class EventPublicController extends BaseController
->resolveEventPackageForPhotoUpload($tenantModel, $eventId, $eventModel); ->resolveEventPackageForPhotoUpload($tenantModel, $eventId, $eventModel);
$deviceId = $this->resolveDeviceIdentifier($request); $deviceId = $this->resolveDeviceIdentifier($request);
$deviceHasRule = static function (array $entries, string $deviceId): bool {
foreach ($entries as $entry) {
if (! is_array($entry)) {
continue;
}
$candidate = $entry['device_id'] ?? null;
if (is_string($candidate) && $candidate === $deviceId) {
return true;
}
}
return false;
};
$deviceHasRules = $deviceId !== 'anonymous';
$isForceReviewUploader = $deviceHasRules && is_array($forceReviewUploaders)
? $deviceHasRule($forceReviewUploaders, $deviceId)
: false;
$isTrustedUploader = $deviceHasRules && is_array($trustedUploaders)
? $deviceHasRule($trustedUploaders, $deviceId)
: false;
if ($isForceReviewUploader) {
$autoApproveUploads = false;
} elseif ($isTrustedUploader) {
$autoApproveUploads = true;
}
$autoAddApprovedToLive = $autoAddApprovedToLiveDefault && $autoApproveUploads;
$deviceLimit = max(0, (int) ($policy->per_device_upload_limit ?? 50)); $deviceLimit = max(0, (int) ($policy->per_device_upload_limit ?? 50));
$deviceCount = DB::table('photos')->where('event_id', $eventId)->where('guest_name', $deviceId)->count(); $deviceCount = DB::table('photos')->where('event_id', $eventId)->where('guest_name', $deviceId)->count();
@@ -3037,10 +3177,21 @@ class EventPublicController extends BaseController
$liveApprovedAt = null; $liveApprovedAt = null;
$liveReviewedAt = null; $liveReviewedAt = null;
$liveStatus = PhotoLiveStatus::NONE->value; $liveStatus = PhotoLiveStatus::NONE->value;
$securityMeta = $isForceReviewUploader
? [
'manual_review' => true,
'manual_review_reason' => 'force_review_device',
]
: null;
$securityMetaValue = $securityMeta ? json_encode($securityMeta) : null;
if ($liveOptIn) { if ($liveOptIn || $autoAddApprovedToLive) {
$liveSubmittedAt = now(); $liveSubmittedAt = now();
if ($liveModerationMode === 'off') { if ($autoAddApprovedToLive) {
$liveStatus = PhotoLiveStatus::APPROVED->value;
$liveApprovedAt = $liveSubmittedAt;
$liveReviewedAt = $liveSubmittedAt;
} elseif ($liveModerationMode === 'off') {
$liveStatus = PhotoLiveStatus::APPROVED->value; $liveStatus = PhotoLiveStatus::APPROVED->value;
$liveApprovedAt = $liveSubmittedAt; $liveApprovedAt = $liveSubmittedAt;
$liveReviewedAt = $liveSubmittedAt; $liveReviewedAt = $liveSubmittedAt;
@@ -3048,6 +3199,12 @@ class EventPublicController extends BaseController
$liveStatus = PhotoLiveStatus::PENDING->value; $liveStatus = PhotoLiveStatus::PENDING->value;
} }
} }
if ($isForceReviewUploader) {
$liveStatus = PhotoLiveStatus::REJECTED->value;
$liveSubmittedAt = null;
$liveApprovedAt = null;
$liveReviewedAt = now();
}
$photoId = DB::table('photos')->insertGetId([ $photoId = DB::table('photos')->insertGetId([
'event_id' => $eventId, 'event_id' => $eventId,
@@ -3071,6 +3228,7 @@ class EventPublicController extends BaseController
'emotion_id' => $this->resolveEmotionId($validated, $eventId), 'emotion_id' => $this->resolveEmotionId($validated, $eventId),
'is_featured' => 0, 'is_featured' => 0,
'metadata' => null, 'metadata' => null,
'security_meta' => $securityMetaValue,
'created_at' => now(), 'created_at' => now(),
'updated_at' => now(), 'updated_at' => now(),
]); ]);

View File

@@ -26,11 +26,11 @@ class LegalController extends BaseController
'allow_unsafe_links' => false, 'allow_unsafe_links' => false,
]); ]);
$environment->addExtension(new CommonMarkCoreExtension()); $environment->addExtension(new CommonMarkCoreExtension);
$environment->addExtension(new TableExtension()); $environment->addExtension(new TableExtension);
$environment->addExtension(new AutolinkExtension()); $environment->addExtension(new AutolinkExtension);
$environment->addExtension(new StrikethroughExtension()); $environment->addExtension(new StrikethroughExtension);
$environment->addExtension(new TaskListExtension()); $environment->addExtension(new TaskListExtension);
$this->markdown = new MarkdownConverter($environment); $this->markdown = new MarkdownConverter($environment);
} }

View File

@@ -24,7 +24,7 @@ class CouponPreviewController extends Controller
$package = Package::findOrFail($data['package_id']); $package = Package::findOrFail($data['package_id']);
if (! $package->paddle_price_id) { if (! $package->lemonsqueezy_variant_id) {
throw ValidationException::withMessages([ throw ValidationException::withMessages([
'code' => __('marketing.coupon.errors.package_not_configured'), 'code' => __('marketing.coupon.errors.package_not_configured'),
]); ]);

View File

@@ -36,7 +36,7 @@ class GiftVoucherCheckoutController extends Controller
if (! $checkout['checkout_url']) { if (! $checkout['checkout_url']) {
throw ValidationException::withMessages([ throw ValidationException::withMessages([
'tier_key' => __('Unable to create Paddle checkout.'), 'tier_key' => __('Unable to create Lemon Squeezy checkout.'),
]); ]);
} }
@@ -46,19 +46,19 @@ class GiftVoucherCheckoutController extends Controller
public function show(Request $request): JsonResponse public function show(Request $request): JsonResponse
{ {
$data = $request->validate([ $data = $request->validate([
'checkout_id' => ['nullable', 'string', 'required_without_all:transaction_id,code'], 'checkout_id' => ['nullable', 'string', 'required_without_all:order_id,code'],
'transaction_id' => ['nullable', 'string', 'required_without_all:checkout_id,code'], 'order_id' => ['nullable', 'string', 'required_without_all:checkout_id,code'],
'code' => ['nullable', 'string', 'required_without_all:checkout_id,transaction_id'], 'code' => ['nullable', 'string', 'required_without_all:checkout_id,order_id'],
]); ]);
$voucherQuery = GiftVoucher::query(); $voucherQuery = GiftVoucher::query();
if (! empty($data['checkout_id'])) { if (! empty($data['checkout_id'])) {
$voucherQuery->where('paddle_checkout_id', $data['checkout_id']); $voucherQuery->where('lemonsqueezy_checkout_id', $data['checkout_id']);
} }
if (! empty($data['transaction_id'])) { if (! empty($data['order_id'])) {
$voucherQuery->orWhere('paddle_transaction_id', $data['transaction_id']); $voucherQuery->orWhere('lemonsqueezy_order_id', $data['order_id']);
} }
if (! empty($data['code'])) { if (! empty($data['code'])) {

View File

@@ -9,7 +9,7 @@ use App\Models\Package;
use App\Models\PackagePurchase; use App\Models\PackagePurchase;
use App\Models\TenantPackage; use App\Models\TenantPackage;
use App\Services\Checkout\CheckoutSessionService; use App\Services\Checkout\CheckoutSessionService;
use App\Services\Paddle\PaddleCheckoutService; use App\Services\LemonSqueezy\LemonSqueezyCheckoutService;
use Illuminate\Http\JsonResponse; use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request; use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\DB;
@@ -18,7 +18,7 @@ use Illuminate\Validation\ValidationException;
class PackageController extends Controller class PackageController extends Controller
{ {
public function __construct( public function __construct(
private readonly PaddleCheckoutService $paddleCheckout, private readonly LemonSqueezyCheckoutService $lemonsqueezyCheckout,
private readonly CheckoutSessionService $sessions, private readonly CheckoutSessionService $sessions,
) {} ) {}
@@ -53,7 +53,7 @@ class PackageController extends Controller
$request->validate([ $request->validate([
'package_id' => 'required|exists:packages,id', 'package_id' => 'required|exists:packages,id',
'type' => 'required|in:endcustomer,reseller', 'type' => 'required|in:endcustomer,reseller',
'payment_method' => 'required|in:paddle', 'payment_method' => 'required|in:lemonsqueezy',
'event_id' => 'nullable|exists:events,id', // For endcustomer 'event_id' => 'nullable|exists:events,id', // For endcustomer
'success_url' => 'nullable|url', 'success_url' => 'nullable|url',
'return_url' => 'nullable|url', 'return_url' => 'nullable|url',
@@ -79,7 +79,7 @@ class PackageController extends Controller
{ {
$request->validate([ $request->validate([
'package_id' => 'required|exists:packages,id', 'package_id' => 'required|exists:packages,id',
'paddle_transaction_id' => 'required|string', 'lemonsqueezy_order_id' => 'required|string',
]); ]);
$package = Package::findOrFail($request->package_id); $package = Package::findOrFail($request->package_id);
@@ -89,14 +89,14 @@ class PackageController extends Controller
throw ValidationException::withMessages(['tenant' => 'Tenant not found.']); throw ValidationException::withMessages(['tenant' => 'Tenant not found.']);
} }
$provider = 'paddle'; $provider = 'lemonsqueezy';
DB::transaction(function () use ($request, $package, $tenant, $provider) { DB::transaction(function () use ($request, $package, $tenant, $provider) {
PackagePurchase::create([ PackagePurchase::create([
'tenant_id' => $tenant->id, 'tenant_id' => $tenant->id,
'package_id' => $package->id, 'package_id' => $package->id,
'provider' => $provider, 'provider' => $provider,
'provider_id' => $request->input('paddle_transaction_id'), 'provider_id' => $request->input('lemonsqueezy_order_id'),
'price' => $package->price, 'price' => $package->price,
'type' => 'endcustomer_event', 'type' => 'endcustomer_event',
'purchased_at' => now(), 'purchased_at' => now(),
@@ -161,7 +161,7 @@ class PackageController extends Controller
], 201); ], 201);
} }
public function createPaddleCheckout(Request $request): JsonResponse public function createLemonSqueezyCheckout(Request $request): JsonResponse
{ {
$request->validate([ $request->validate([
'package_id' => 'required|exists:packages,id', 'package_id' => 'required|exists:packages,id',
@@ -181,15 +181,15 @@ class PackageController extends Controller
throw ValidationException::withMessages(['user' => 'User context missing.']); throw ValidationException::withMessages(['user' => 'User context missing.']);
} }
if (! $package->paddle_price_id) { if (! $package->lemonsqueezy_variant_id) {
throw ValidationException::withMessages(['package_id' => 'Package is not linked to a Paddle price.']); throw ValidationException::withMessages(['package_id' => 'Package is not linked to a Lemon Squeezy variant.']);
} }
$session = $this->sessions->createOrResume($user, $package, [ $session = $this->sessions->createOrResume($user, $package, [
'tenant' => $tenant, 'tenant' => $tenant,
]); ]);
$this->sessions->selectProvider($session, CheckoutSession::PROVIDER_PADDLE); $this->sessions->selectProvider($session, CheckoutSession::PROVIDER_LEMONSQUEEZY);
$now = now(); $now = now();
@@ -211,14 +211,14 @@ class PackageController extends Controller
], ],
]; ];
$checkout = $this->paddleCheckout->createCheckout($tenant, $package, $payload); $checkout = $this->lemonsqueezyCheckout->createCheckout($tenant, $package, $payload);
$session->forceFill([ $session->forceFill([
'paddle_checkout_id' => $checkout['id'] ?? $session->paddle_checkout_id, 'lemonsqueezy_checkout_id' => $checkout['id'] ?? $session->lemonsqueezy_checkout_id,
'provider_metadata' => array_merge($session->provider_metadata ?? [], array_filter([ 'provider_metadata' => array_merge($session->provider_metadata ?? [], array_filter([
'paddle_checkout_id' => $checkout['id'] ?? null, 'lemonsqueezy_checkout_id' => $checkout['id'] ?? null,
'paddle_checkout_url' => $checkout['checkout_url'] ?? null, 'lemonsqueezy_checkout_url' => $checkout['checkout_url'] ?? null,
'paddle_expires_at' => $checkout['expires_at'] ?? null, 'lemonsqueezy_expires_at' => $checkout['expires_at'] ?? null,
])), ])),
])->save(); ])->save();
@@ -239,7 +239,7 @@ class PackageController extends Controller
} }
} }
$checkoutUrl = data_get($session->provider_metadata ?? [], 'paddle_checkout_url'); $checkoutUrl = data_get($session->provider_metadata ?? [], 'lemonsqueezy_checkout_url');
return response()->json([ return response()->json([
'status' => $session->status, 'status' => $session->status,
@@ -277,13 +277,13 @@ class PackageController extends Controller
'purchased_at' => now(), 'purchased_at' => now(),
]); ]);
} else { } else {
// Reseller subscription // Partner / reseller Event-Kontingent package
\App\Models\TenantPackage::create([ \App\Models\TenantPackage::create([
'tenant_id' => $tenant->id, 'tenant_id' => $tenant->id,
'package_id' => $package->id, 'package_id' => $package->id,
'price' => $package->price, 'price' => $package->price,
'purchased_at' => now(), 'purchased_at' => now(),
'expires_at' => now()->addYear(), 'expires_at' => null,
'active' => true, 'active' => true,
]); ]);
} }
@@ -297,11 +297,11 @@ class PackageController extends Controller
private function handlePaidPurchase(Request $request, Package $package, $tenant): JsonResponse private function handlePaidPurchase(Request $request, Package $package, $tenant): JsonResponse
{ {
if (! $package->paddle_price_id) { if (! $package->lemonsqueezy_variant_id) {
throw ValidationException::withMessages(['package_id' => 'Package is not linked to a Paddle price.']); throw ValidationException::withMessages(['package_id' => 'Package is not linked to a Lemon Squeezy variant.']);
} }
$checkout = $this->paddleCheckout->createCheckout($tenant, $package, [ $checkout = $this->lemonsqueezyCheckout->createCheckout($tenant, $package, [
'success_url' => $request->input('success_url'), 'success_url' => $request->input('success_url'),
'return_url' => $request->input('return_url'), 'return_url' => $request->input('return_url'),
'metadata' => array_filter([ 'metadata' => array_filter([

View File

@@ -4,6 +4,7 @@ namespace App\Http\Controllers\Api;
use App\Http\Controllers\Controller; use App\Http\Controllers\Controller;
use App\Http\Requests\Photobooth\PhotoboothConnectRedeemRequest; use App\Http\Requests\Photobooth\PhotoboothConnectRedeemRequest;
use App\Models\Event;
use App\Services\Photobooth\PhotoboothConnectCodeService; use App\Services\Photobooth\PhotoboothConnectCodeService;
use Illuminate\Http\JsonResponse; use Illuminate\Http\JsonResponse;
@@ -33,7 +34,8 @@ class PhotoboothConnectController extends Controller
return response()->json([ return response()->json([
'data' => [ 'data' => [
'upload_url' => route('api.v1.photobooth.sparkbooth.upload'), 'event_name' => $this->resolveEventName($event),
'upload_url' => route('api.v1.photobooth.upload'),
'username' => $setting->username, 'username' => $setting->username,
'password' => $setting->password, 'password' => $setting->password,
'expires_at' => optional($setting->expires_at)->toIso8601String(), 'expires_at' => optional($setting->expires_at)->toIso8601String(),
@@ -42,4 +44,27 @@ class PhotoboothConnectController extends Controller
], ],
]); ]);
} }
private function resolveEventName(?Event $event): ?string
{
if (! $event) {
return null;
}
$name = $event->name;
if (is_string($name) && trim($name) !== '') {
return $name;
}
if (is_array($name)) {
foreach ($name as $value) {
if (is_string($value) && trim($value) !== '') {
return $value;
}
}
}
return $event->slug ?: null;
}
} }

View File

@@ -0,0 +1,53 @@
<?php
namespace App\Http\Controllers\Api\Support;
use App\Http\Controllers\Controller;
use App\Http\Requests\Support\SupportGuestPolicyRequest;
use App\Models\GuestPolicySetting;
use App\Services\Audit\SuperAdminAuditLogger;
use App\Support\SupportApiAuthorizer;
use Illuminate\Http\JsonResponse;
class SupportGuestPolicyController extends Controller
{
public function show(): JsonResponse
{
if ($response = SupportApiAuthorizer::authorizeAbilities(request(), ['support:settings'], 'settings')) {
return $response;
}
$settings = GuestPolicySetting::current();
return response()->json([
'data' => $settings,
]);
}
public function update(SupportGuestPolicyRequest $request): JsonResponse
{
if ($response = SupportApiAuthorizer::authorizeAbilities($request, ['support:settings'], 'settings')) {
return $response;
}
$settings = GuestPolicySetting::query()->firstOrNew(['id' => 1]);
$settings->fill($request->validated());
$settings->save();
$changed = $settings->getChanges();
if ($changed !== []) {
app(SuperAdminAuditLogger::class)->record(
'guest_policy.updated',
$settings,
SuperAdminAuditLogger::fieldsMetadata(array_keys($changed)),
source: static::class
);
}
return response()->json([
'data' => $settings->refresh(),
]);
}
}

View File

@@ -0,0 +1,401 @@
<?php
namespace App\Http\Controllers\Api\Support;
use App\Enums\DataExportScope;
use App\Http\Controllers\Controller;
use App\Http\Requests\Support\Resources\SupportResourceFormRequest;
use App\Http\Requests\Support\SupportResourceRequest;
use App\Jobs\GenerateDataExport;
use App\Models\DataExport;
use App\Services\Audit\SuperAdminAuditLogger;
use App\Support\ApiError;
use App\Support\SupportApiAuthorizer;
use App\Support\SupportApiRegistry;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Arr;
use Illuminate\Support\Facades\Schema;
use Illuminate\Support\Facades\Validator;
class SupportResourceController extends Controller
{
public function index(Request $request, string $resource): JsonResponse
{
if ($response = SupportApiAuthorizer::authorizeResource($request, $resource, 'read')) {
return $response;
}
$config = SupportApiRegistry::get($resource);
if (! $config) {
return $this->resourceNotFoundResponse($resource);
}
$modelClass = $config['model'];
/** @var Builder $query */
$query = $modelClass::query();
$relations = SupportApiRegistry::withRelations($resource);
if ($relations !== []) {
$query->with($relations);
}
$this->applySearch($request, $query, $resource);
$this->applySorting($request, $query, $resource);
$perPage = $this->resolvePerPage($request);
$paginator = $query->paginate($perPage);
return response()->json([
'data' => $paginator->items(),
'meta' => [
'page' => $paginator->currentPage(),
'per_page' => $paginator->perPage(),
'total' => $paginator->total(),
'last_page' => $paginator->lastPage(),
],
]);
}
public function show(Request $request, string $resource, string $record): JsonResponse
{
if ($response = SupportApiAuthorizer::authorizeResource($request, $resource, 'read')) {
return $response;
}
$model = $this->resolveRecord($resource, $record);
if (! $model) {
return $this->resourceNotFoundResponse($resource, $record);
}
return response()->json([
'data' => $model,
]);
}
public function store(SupportResourceRequest $request, string $resource): JsonResponse
{
if ($response = SupportApiAuthorizer::authorizeAnyAbility($request, SupportApiRegistry::abilitiesFor($resource, 'write'), 'write')) {
return $response;
}
if (! SupportApiRegistry::allowsMutation($resource, 'create')) {
return $this->mutationNotAllowedResponse($resource, 'create');
}
$config = SupportApiRegistry::get($resource);
if (! $config) {
return $this->resourceNotFoundResponse($resource);
}
$modelClass = $config['model'];
/** @var Model $model */
$model = new $modelClass;
$payload = $this->validatedPayload($request, $resource, 'create', $model);
if ($payload instanceof JsonResponse) {
return $payload;
}
if ($payload === []) {
return $this->emptyPayloadResponse($resource);
}
if ($resource === 'data-exports') {
$payload = $this->normalizeDataExportPayload($request, $payload);
}
$record = $modelClass::query()->create($payload);
app(SuperAdminAuditLogger::class)->record(
SupportApiRegistry::auditAction($resource, 'created'),
$record,
SuperAdminAuditLogger::fieldsMetadata($payload),
actor: $request->user(),
source: static::class
);
if ($resource === 'data-exports') {
GenerateDataExport::dispatch($record->id);
}
return response()->json([
'data' => $record,
], 201);
}
public function update(SupportResourceRequest $request, string $resource, string $record): JsonResponse
{
if ($response = SupportApiAuthorizer::authorizeAnyAbility($request, SupportApiRegistry::abilitiesFor($resource, 'write'), 'write')) {
return $response;
}
if (! SupportApiRegistry::allowsMutation($resource, 'update')) {
return $this->mutationNotAllowedResponse($resource, 'update');
}
$model = $this->resolveRecord($resource, $record);
if (! $model) {
return $this->resourceNotFoundResponse($resource, $record);
}
$payload = $this->validatedPayload($request, $resource, 'update', $model);
if ($payload instanceof JsonResponse) {
return $payload;
}
if ($payload === []) {
return $this->emptyPayloadResponse($resource);
}
$model->fill($payload);
$model->save();
app(SuperAdminAuditLogger::class)->record(
SupportApiRegistry::auditAction($resource, 'updated'),
$model,
SuperAdminAuditLogger::fieldsMetadata($payload),
actor: $request->user(),
source: static::class
);
return response()->json([
'data' => $model->refresh(),
]);
}
public function destroy(Request $request, string $resource, string $record): JsonResponse
{
if ($response = SupportApiAuthorizer::authorizeAnyAbility($request, SupportApiRegistry::abilitiesFor($resource, 'write'), 'write')) {
return $response;
}
if (! SupportApiRegistry::allowsMutation($resource, 'delete')) {
return $this->mutationNotAllowedResponse($resource, 'delete');
}
$model = $this->resolveRecord($resource, $record);
if (! $model) {
return $this->resourceNotFoundResponse($resource, $record);
}
$model->delete();
app(SuperAdminAuditLogger::class)->record(
SupportApiRegistry::auditAction($resource, 'deleted'),
$model,
SuperAdminAuditLogger::fieldsMetadata([]),
actor: $request->user(),
source: static::class
);
return response()->json(['ok' => true]);
}
private function resolveRecord(string $resource, string $record): ?Model
{
$config = SupportApiRegistry::get($resource);
if (! $config) {
return null;
}
$modelClass = $config['model'];
$query = $modelClass::query();
if (is_numeric($record)) {
return $query->find($record);
}
$keyName = (new $modelClass)->getKeyName();
return $query->where($keyName, $record)->first();
}
private function validatedPayload(SupportResourceRequest $request, string $resource, string $action, Model $model): array|JsonResponse
{
$payload = $request->validated('data');
if (! is_array($payload)) {
return [];
}
$validationClass = SupportApiRegistry::validationClass($resource, $action);
if ($validationClass && is_subclass_of($validationClass, SupportResourceFormRequest::class)) {
$allowedFields = $validationClass::allowedFields($action);
if ($allowedFields !== []) {
$unexpected = array_diff(array_keys($payload), $allowedFields);
if ($unexpected !== []) {
return $this->invalidFieldResponse($resource, $unexpected);
}
}
$rules = $validationClass::rulesFor($action, $model);
if ($rules !== []) {
$payload = Validator::make($payload, $rules)->validate();
}
if ($allowedFields !== []) {
$payload = Arr::only($payload, $allowedFields);
}
}
$fillable = $model->getFillable();
if ($fillable === [] && method_exists($model, 'getGuarded') && $model->getGuarded() !== ['*']) {
$columns = Schema::getColumnListing($model->getTable());
return Arr::only($payload, $columns);
}
if ($fillable === []) {
return [];
}
return Arr::only($payload, $fillable);
}
private function applySearch(Request $request, Builder $query, string $resource): void
{
$term = $request->string('search')->trim()->value();
if ($term === '') {
return;
}
$fields = SupportApiRegistry::searchFields($resource);
if ($fields === []) {
return;
}
$columns = Schema::getColumnListing($query->getModel()->getTable());
$fields = array_values(array_intersect($fields, $columns));
if ($fields === []) {
return;
}
$query->where(function (Builder $builder) use ($fields, $term): void {
foreach ($fields as $field) {
if ($field === 'id' && is_numeric($term)) {
$builder->orWhere($field, (int) $term);
} else {
$builder->orWhere($field, 'like', "%{$term}%");
}
}
});
}
private function applySorting(Request $request, Builder $query, string $resource): void
{
$sort = $request->string('sort')->trim()->value();
if ($sort === '') {
return;
}
$direction = 'asc';
$field = $sort;
if (str_starts_with($sort, '-')) {
$direction = 'desc';
$field = ltrim($sort, '-');
}
$allowed = SupportApiRegistry::searchFields($resource);
$allowed[] = 'id';
$columns = Schema::getColumnListing($query->getModel()->getTable());
$allowed = array_values(array_intersect($allowed, $columns));
if (! in_array($field, $allowed, true)) {
return;
}
$query->orderBy($field, $direction);
}
private function resolvePerPage(Request $request): int
{
$default = (int) config('support-api.pagination.default_per_page', 50);
$max = (int) config('support-api.pagination.max_per_page', 200);
$perPage = (int) $request->input('per_page', $default);
if ($perPage < 1) {
$perPage = $default;
}
return min($perPage, $max);
}
private function mutationNotAllowedResponse(string $resource, string $action): JsonResponse
{
return ApiError::response(
'support_mutation_not_allowed',
'Mutation Not Allowed',
"{$resource} does not allow {$action} operations in support API.",
403
);
}
private function emptyPayloadResponse(string $resource): JsonResponse
{
return ApiError::response(
'support_invalid_payload',
'Invalid Payload',
"No mutable fields provided for {$resource}.",
422
);
}
private function invalidFieldResponse(string $resource, array $fields): JsonResponse
{
return ApiError::response(
'support_invalid_fields',
'Invalid Fields',
"Unsupported fields provided for {$resource}.",
422,
[
'fields' => array_values($fields),
]
);
}
private function resourceNotFoundResponse(string $resource, ?string $record = null): JsonResponse
{
$message = $record
? "{$resource} record not found."
: "Support resource {$resource} is not registered.";
return ApiError::response(
'support_resource_not_found',
'Not Found',
$message,
404
);
}
private function normalizeDataExportPayload(Request $request, array $payload): array
{
$payload['user_id'] = $request->user()?->id;
$payload['status'] = DataExport::STATUS_PENDING;
if (($payload['scope'] ?? null) !== DataExportScope::EVENT->value) {
$payload['event_id'] = null;
}
return $payload;
}
}

View File

@@ -0,0 +1,411 @@
<?php
namespace App\Http\Controllers\Api\Support;
use App\Http\Controllers\Controller;
use App\Http\Requests\Support\Tenant\SupportTenantAddPackageRequest;
use App\Http\Requests\Support\Tenant\SupportTenantScheduleDeletionRequest;
use App\Http\Requests\Support\Tenant\SupportTenantSetGracePeriodRequest;
use App\Http\Requests\Support\Tenant\SupportTenantUpdateLimitsRequest;
use App\Http\Requests\Support\Tenant\SupportTenantUpdateSubscriptionRequest;
use App\Jobs\AnonymizeAccount;
use App\Models\Package;
use App\Models\PackagePurchase;
use App\Models\Tenant;
use App\Models\TenantPackage;
use App\Notifications\InactiveTenantDeletionWarning;
use App\Services\Audit\SuperAdminAuditLogger;
use App\Services\Tenant\TenantLifecycleLogger;
use App\Support\SupportApiAuthorizer;
use Carbon\Carbon;
use Filament\Notifications\Notification;
use Illuminate\Http\JsonResponse;
use Illuminate\Support\Facades\Notification as NotificationFacade;
class SupportTenantActionsController extends Controller
{
public function activate(Tenant $tenant): JsonResponse
{
if ($response = $this->authorizeAction('tenants', 'actions')) {
return $response;
}
$updated = $tenant->update(['is_active' => true]);
app(TenantLifecycleLogger::class)->record(
$tenant,
'activated',
actor: auth()->user()
);
app(SuperAdminAuditLogger::class)->record(
'tenant.activated',
$tenant,
SuperAdminAuditLogger::fieldsMetadata(['is_active']),
source: static::class
);
return response()->json(['ok' => $updated]);
}
public function deactivate(Tenant $tenant): JsonResponse
{
if ($response = $this->authorizeAction('tenants', 'actions')) {
return $response;
}
$updated = $tenant->update(['is_active' => false]);
app(TenantLifecycleLogger::class)->record(
$tenant,
'deactivated',
actor: auth()->user()
);
app(SuperAdminAuditLogger::class)->record(
'tenant.deactivated',
$tenant,
SuperAdminAuditLogger::fieldsMetadata(['is_active']),
source: static::class
);
return response()->json(['ok' => $updated]);
}
public function suspend(Tenant $tenant): JsonResponse
{
if ($response = $this->authorizeAction('tenants', 'actions')) {
return $response;
}
$updated = $tenant->update(['is_suspended' => true]);
app(TenantLifecycleLogger::class)->record(
$tenant,
'suspended',
actor: auth()->user()
);
app(SuperAdminAuditLogger::class)->record(
'tenant.suspended',
$tenant,
SuperAdminAuditLogger::fieldsMetadata(['is_suspended']),
source: static::class
);
return response()->json(['ok' => $updated]);
}
public function unsuspend(Tenant $tenant): JsonResponse
{
if ($response = $this->authorizeAction('tenants', 'actions')) {
return $response;
}
$updated = $tenant->update(['is_suspended' => false]);
app(TenantLifecycleLogger::class)->record(
$tenant,
'unsuspended',
actor: auth()->user()
);
app(SuperAdminAuditLogger::class)->record(
'tenant.unsuspended',
$tenant,
SuperAdminAuditLogger::fieldsMetadata(['is_suspended']),
source: static::class
);
return response()->json(['ok' => $updated]);
}
public function scheduleDeletion(SupportTenantScheduleDeletionRequest $request, Tenant $tenant): JsonResponse
{
if ($response = $this->authorizeAction('tenants', 'actions')) {
return $response;
}
$plannedDeletion = Carbon::parse($request->string('pending_deletion_at')->value());
$update = [
'pending_deletion_at' => $plannedDeletion,
];
if ($request->boolean('send_warning', true)) {
$email = $tenant->contact_email
?? $tenant->email
?? $tenant->user?->email;
if ($email) {
NotificationFacade::route('mail', $email)
->notify(new InactiveTenantDeletionWarning($tenant, $plannedDeletion));
$update['deletion_warning_sent_at'] = now();
} else {
Notification::make()
->danger()
->title(__('admin.tenants.actions.send_warning_missing_title'))
->body(__('admin.tenants.actions.send_warning_missing_body'))
->send();
}
}
$tenant->forceFill($update)->save();
app(TenantLifecycleLogger::class)->record(
$tenant,
'deletion_scheduled',
[
'pending_deletion_at' => $plannedDeletion->toDateTimeString(),
'send_warning' => $request->boolean('send_warning', true),
],
auth()->user()
);
app(SuperAdminAuditLogger::class)->record(
'tenant.deletion_scheduled',
$tenant,
SuperAdminAuditLogger::fieldsMetadata($request->validated()),
source: static::class
);
return response()->json(['ok' => true]);
}
public function cancelDeletion(Tenant $tenant): JsonResponse
{
if ($response = $this->authorizeAction('tenants', 'actions')) {
return $response;
}
$previous = $tenant->pending_deletion_at?->toDateTimeString();
$tenant->forceFill([
'pending_deletion_at' => null,
'deletion_warning_sent_at' => null,
])->save();
app(TenantLifecycleLogger::class)->record(
$tenant,
'deletion_cancelled',
['pending_deletion_at' => $previous],
auth()->user()
);
app(SuperAdminAuditLogger::class)->record(
'tenant.deletion_cancelled',
$tenant,
SuperAdminAuditLogger::fieldsMetadata(['pending_deletion_at', 'deletion_warning_sent_at']),
source: static::class
);
return response()->json(['ok' => true]);
}
public function anonymize(Tenant $tenant): JsonResponse
{
if ($response = $this->authorizeAction('tenants', 'actions')) {
return $response;
}
AnonymizeAccount::dispatch(null, $tenant->id);
app(TenantLifecycleLogger::class)->record(
$tenant,
'anonymize_requested',
actor: auth()->user()
);
app(SuperAdminAuditLogger::class)->record(
'tenant.anonymize_requested',
$tenant,
SuperAdminAuditLogger::fieldsMetadata([]),
source: static::class
);
return response()->json(['ok' => true]);
}
public function addPackage(SupportTenantAddPackageRequest $request, Tenant $tenant): JsonResponse
{
if ($response = $this->authorizeAction('tenants', 'actions')) {
return $response;
}
$package = Package::query()->find($request->integer('package_id'));
TenantPackage::query()->create([
'tenant_id' => $tenant->id,
'package_id' => $request->integer('package_id'),
'expires_at' => $request->date('expires_at'),
'active' => true,
'price' => $package?->price ?? 0,
'reason' => $request->string('reason')->value(),
]);
PackagePurchase::query()->create([
'tenant_id' => $tenant->id,
'package_id' => $request->integer('package_id'),
'provider' => 'manual',
'provider_id' => 'manual',
'type' => 'reseller_subscription',
'price' => 0,
'metadata' => ['reason' => $request->string('reason')->value() ?: 'manual assignment'],
]);
app(SuperAdminAuditLogger::class)->record(
'tenant.package_added',
$tenant,
SuperAdminAuditLogger::fieldsMetadata($request->validated()),
source: static::class
);
return response()->json(['ok' => true]);
}
public function updateLimits(SupportTenantUpdateLimitsRequest $request, Tenant $tenant): JsonResponse
{
if ($response = $this->authorizeAction('tenants', 'actions')) {
return $response;
}
$before = [
'max_photos_per_event' => $tenant->max_photos_per_event,
'max_storage_mb' => $tenant->max_storage_mb,
];
$tenant->forceFill([
'max_photos_per_event' => $request->integer('max_photos_per_event'),
'max_storage_mb' => $request->integer('max_storage_mb'),
])->save();
$after = [
'max_photos_per_event' => $tenant->max_photos_per_event,
'max_storage_mb' => $tenant->max_storage_mb,
];
app(TenantLifecycleLogger::class)->record(
$tenant,
'limits_updated',
[
'before' => $before,
'after' => $after,
'note' => $request->string('note')->value(),
],
auth()->user()
);
app(SuperAdminAuditLogger::class)->record(
'tenant.limits_updated',
$tenant,
SuperAdminAuditLogger::fieldsMetadata($request->validated()),
source: static::class
);
return response()->json(['ok' => true]);
}
public function updateSubscriptionExpiresAt(SupportTenantUpdateSubscriptionRequest $request, Tenant $tenant): JsonResponse
{
if ($response = $this->authorizeAction('tenants', 'actions')) {
return $response;
}
$before = [
'subscription_expires_at' => optional($tenant->subscription_expires_at)->toDateTimeString(),
];
$tenant->forceFill([
'subscription_expires_at' => $request->date('subscription_expires_at'),
])->save();
$after = [
'subscription_expires_at' => optional($tenant->subscription_expires_at)->toDateTimeString(),
];
app(TenantLifecycleLogger::class)->record(
$tenant,
'subscription_expires_at_updated',
[
'before' => $before,
'after' => $after,
'note' => $request->string('note')->value(),
],
auth()->user()
);
app(SuperAdminAuditLogger::class)->record(
'tenant.subscription_expires_at_updated',
$tenant,
SuperAdminAuditLogger::fieldsMetadata($request->validated()),
source: static::class
);
return response()->json(['ok' => true]);
}
public function setGracePeriod(SupportTenantSetGracePeriodRequest $request, Tenant $tenant): JsonResponse
{
if ($response = $this->authorizeAction('tenants', 'actions')) {
return $response;
}
$tenant->forceFill([
'grace_period_ends_at' => $request->date('grace_period_ends_at'),
])->save();
app(TenantLifecycleLogger::class)->record(
$tenant,
'grace_period_set',
[
'grace_period_ends_at' => optional($tenant->grace_period_ends_at)->toDateTimeString(),
'note' => $request->string('note')->value(),
],
auth()->user()
);
app(SuperAdminAuditLogger::class)->record(
'tenant.grace_period_set',
$tenant,
SuperAdminAuditLogger::fieldsMetadata($request->validated()),
source: static::class
);
return response()->json(['ok' => true]);
}
public function clearGracePeriod(Tenant $tenant): JsonResponse
{
if ($response = $this->authorizeAction('tenants', 'actions')) {
return $response;
}
$previous = $tenant->grace_period_ends_at?->toDateTimeString();
$tenant->forceFill([
'grace_period_ends_at' => null,
])->save();
app(TenantLifecycleLogger::class)->record(
$tenant,
'grace_period_cleared',
[
'grace_period_ends_at' => $previous,
],
auth()->user()
);
app(SuperAdminAuditLogger::class)->record(
'tenant.grace_period_cleared',
$tenant,
SuperAdminAuditLogger::fieldsMetadata(['grace_period_ends_at']),
source: static::class
);
return response()->json(['ok' => true]);
}
private function authorizeAction(string $resource, string $action): ?JsonResponse
{
return SupportApiAuthorizer::authorizeResource(request(), $resource, $action);
}
}

View File

@@ -0,0 +1,103 @@
<?php
namespace App\Http\Controllers\Api\Support;
use App\Http\Controllers\Controller;
use App\Http\Requests\Support\SupportTokenRequest;
use App\Models\User;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Arr;
use Illuminate\Support\Facades\Hash;
use Illuminate\Validation\ValidationException;
class SupportTokenController extends Controller
{
public function store(SupportTokenRequest $request): JsonResponse
{
$credentials = $request->credentials();
$query = User::query();
if (isset($credentials['email'])) {
$query->where('email', $credentials['email']);
}
if (isset($credentials['username'])) {
$query->where('username', $credentials['username']);
}
/** @var User|null $user */
$user = $query->first();
if (! $user || ! Hash::check($credentials['password'], (string) $user->password)) {
throw ValidationException::withMessages([
'login' => [trans('auth.failed')],
]);
}
if (! $user->isSuperAdmin()) {
throw ValidationException::withMessages([
'login' => [trans('auth.not_authorized')],
]);
}
$tokenConfig = config('support-api.token');
$defaultAbilities = $tokenConfig['default_abilities'] ?? [];
$abilities = $credentials['abilities'] ?? $defaultAbilities;
if ($abilities !== $defaultAbilities) {
$abilities = array_values(array_intersect($abilities, $defaultAbilities));
}
if (! in_array('support-admin', $abilities, true)) {
$abilities[] = 'support-admin';
}
$tokenName = (string) ($tokenConfig['name'] ?? 'support-api');
$user->tokens()->where('name', $tokenName)->delete();
$token = $user->createToken($tokenName, $abilities);
return response()->json([
'token' => $token->plainTextToken,
'token_type' => 'Bearer',
'abilities' => $abilities,
'user' => Arr::only($user->toArray(), [
'id',
'email',
'name',
'role',
'tenant_id',
]),
]);
}
public function destroy(Request $request): JsonResponse
{
$token = $request->user()?->currentAccessToken();
if ($token) {
$token->delete();
}
return response()->json(['ok' => true]);
}
public function me(Request $request): JsonResponse
{
$user = $request->user();
return response()->json([
'user' => $user ? Arr::only($user->toArray(), [
'id',
'name',
'email',
'role',
'tenant_id',
]) : null,
'abilities' => $user?->currentAccessToken()?->abilities ?? [],
]);
}
}

View File

@@ -0,0 +1,52 @@
<?php
namespace App\Http\Controllers\Api\Support;
use App\Http\Controllers\Controller;
use App\Http\Requests\Support\SupportWatermarkSettingsRequest;
use App\Models\WatermarkSetting;
use App\Services\Audit\SuperAdminAuditLogger;
use App\Support\SupportApiAuthorizer;
use Illuminate\Http\JsonResponse;
class SupportWatermarkSettingsController extends Controller
{
public function show(): JsonResponse
{
if ($response = SupportApiAuthorizer::authorizeAbilities(request(), ['support:settings'], 'settings')) {
return $response;
}
$settings = WatermarkSetting::query()->first();
return response()->json([
'data' => $settings,
]);
}
public function update(SupportWatermarkSettingsRequest $request): JsonResponse
{
if ($response = SupportApiAuthorizer::authorizeAbilities($request, ['support:settings'], 'settings')) {
return $response;
}
$settings = WatermarkSetting::query()->firstOrNew([]);
$settings->fill($request->validated());
$settings->save();
$changed = $settings->getChanges();
if ($changed !== []) {
app(SuperAdminAuditLogger::class)->record(
'watermark_settings.updated',
$settings,
SuperAdminAuditLogger::fieldsMetadata(array_keys($changed)),
source: static::class
);
}
return response()->json([
'data' => $settings->refresh(),
]);
}
}

View File

@@ -13,7 +13,7 @@ class EventAddonCatalogController extends Controller
public function index(): JsonResponse public function index(): JsonResponse
{ {
$addons = collect($this->catalog->all()) $addons = collect($this->catalog->all())
->filter(fn (array $addon) => ! empty($addon['price_id'])) ->filter(fn (array $addon) => ! empty($addon['variant_id']))
->map(fn (array $addon, string $key) => array_merge($addon, ['key' => $key])) ->map(fn (array $addon, string $key) => array_merge($addon, ['key' => $key]))
->values() ->values()
->all(); ->all();

View File

@@ -7,7 +7,6 @@ use App\Models\Event;
use App\Services\Analytics\EventAnalyticsService; use App\Services\Analytics\EventAnalyticsService;
use Illuminate\Http\JsonResponse; use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request; use Illuminate\Http\Request;
use Illuminate\Support\Arr;
class EventAnalyticsController extends Controller class EventAnalyticsController extends Controller
{ {
@@ -26,10 +25,10 @@ class EventAnalyticsController extends Controller
$hasAccess = in_array('advanced_analytics', $packageFeatures, true); $hasAccess = in_array('advanced_analytics', $packageFeatures, true);
if (!$hasAccess) { if (! $hasAccess) {
return response()->json([ return response()->json([
'message' => 'This feature is only available in the Premium package.', 'message' => 'This feature is only available in the Premium package.',
'code' => 'feature_locked' 'code' => 'feature_locked',
], 403); ], 403);
} }

View File

@@ -19,6 +19,8 @@ use App\Models\Tenant;
use App\Models\User; use App\Models\User;
use App\Services\EventJoinTokenService; use App\Services\EventJoinTokenService;
use App\Support\ApiError; use App\Support\ApiError;
use App\Support\TenantMemberPermissions;
use App\Support\WatermarkConfigResolver;
use Illuminate\Http\JsonResponse; use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request; use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\AnonymousResourceCollection; use Illuminate\Http\Resources\Json\AnonymousResourceCollection;
@@ -83,6 +85,8 @@ class EventController extends Controller
public function store(EventStoreRequest $request): JsonResponse public function store(EventStoreRequest $request): JsonResponse
{ {
TenantMemberPermissions::ensureTenantPermission($request, 'events:manage');
$tenant = $request->attributes->get('tenant'); $tenant = $request->attributes->get('tenant');
if (! $tenant instanceof Tenant) { if (! $tenant instanceof Tenant) {
$tenantId = $request->attributes->get('tenant_id'); $tenantId = $request->attributes->get('tenant_id');
@@ -99,6 +103,9 @@ class EventController extends Controller
$requestedPackageId = $isSuperAdmin ? $request->integer('package_id') : null; $requestedPackageId = $isSuperAdmin ? $request->integer('package_id') : null;
unset($validated['package_id']); unset($validated['package_id']);
$requestedServiceSlug = $request->input('service_package_slug');
$requestedServiceSlug = is_string($requestedServiceSlug) && $requestedServiceSlug !== '' ? $requestedServiceSlug : null;
unset($validated['service_package_slug']);
$tenantPackage = $tenant->tenantPackages() $tenantPackage = $tenant->tenantPackages()
->with('package') ->with('package')
@@ -116,6 +123,18 @@ class EventController extends Controller
$package = $this->resolveOwnerPackage(); $package = $this->resolveOwnerPackage();
} }
$billingTenantPackage = null;
if (! $package) {
$billingTenantPackage = $requestedServiceSlug
? $tenant->getActiveResellerPackageFor($requestedServiceSlug)
: $tenant->getActiveResellerPackage();
if ($billingTenantPackage && $billingTenantPackage->package) {
$package = $billingTenantPackage->package;
$requestedServiceSlug = $requestedServiceSlug ?: $package->included_package_slug;
}
}
if (! $package && $tenantPackage) { if (! $package && $tenantPackage) {
$package = $tenantPackage->package ?? Package::query()->find($tenantPackage->package_id); $package = $tenantPackage->package ?? Package::query()->find($tenantPackage->package_id);
} }
@@ -126,6 +145,11 @@ class EventController extends Controller
]); ]);
} }
$billingIsReseller = $package->isReseller();
$eventServicePackage = $billingIsReseller
? $this->resolveResellerEventPackageForSlug($requestedServiceSlug ?: $package->included_package_slug)
: $package;
$requiresWaiver = $package->isEndcustomer(); $requiresWaiver = $package->isEndcustomer();
$latestPurchase = $requiresWaiver ? $this->resolveLatestPackagePurchase($tenant, $package) : null; $latestPurchase = $requiresWaiver ? $this->resolveLatestPackagePurchase($tenant, $package) : null;
$existingWaiver = $latestPurchase ? data_get($latestPurchase->metadata, 'consents.digital_content_waiver_at') : null; $existingWaiver = $latestPurchase ? data_get($latestPurchase->metadata, 'consents.digital_content_waiver_at') : null;
@@ -137,11 +161,13 @@ class EventController extends Controller
]); ]);
} }
$resolvedName = $this->resolveEventNameString($validated['name']);
$eventData = array_merge($validated, [ $eventData = array_merge($validated, [
'tenant_id' => $tenantId, 'tenant_id' => $tenantId,
'status' => $validated['status'] ?? 'draft', 'status' => $validated['status'] ?? 'draft',
'slug' => $this->generateUniqueSlug($validated['name'], $tenantId), 'slug' => $this->generateUniqueSlug($resolvedName, $tenantId),
]); ]);
$eventData['name'] = $this->normalizeEventName($validated['name']);
if (isset($eventData['event_date'])) { if (isset($eventData['event_date'])) {
$eventData['date'] = $eventData['event_date']; $eventData['date'] = $eventData['event_date'];
@@ -161,8 +187,8 @@ class EventController extends Controller
unset($eventData['features']); unset($eventData['features']);
} }
$settings['branding_allowed'] = $package->branding_allowed !== false; $settings['branding_allowed'] = $eventServicePackage->branding_allowed !== false;
$settings['watermark_allowed'] = $package->watermark_allowed !== false; $settings['watermark_allowed'] = $eventServicePackage->watermark_allowed !== false;
$eventData['settings'] = $settings; $eventData['settings'] = $settings;
@@ -190,21 +216,23 @@ class EventController extends Controller
$eventData = Arr::only($eventData, $allowed); $eventData = Arr::only($eventData, $allowed);
$event = DB::transaction(function () use ($tenant, $eventData, $package, $isSuperAdmin) { $event = DB::transaction(function () use ($tenant, $eventData, $eventServicePackage, $billingIsReseller, $isSuperAdmin) {
$event = Event::create($eventData); $event = Event::create($eventData);
EventPackage::create([ EventPackage::create([
'event_id' => $event->id, 'event_id' => $event->id,
'package_id' => $package->id, 'package_id' => $eventServicePackage->id,
'purchased_price' => $package->price, 'purchased_price' => $billingIsReseller ? 0 : $eventServicePackage->price,
'purchased_at' => now(), 'purchased_at' => now(),
'gallery_expires_at' => $package->gallery_days ? now()->addDays($package->gallery_days) : null, 'gallery_expires_at' => $eventServicePackage->gallery_days
? now()->addDays($eventServicePackage->gallery_days)
: null,
]); ]);
if ($package->isReseller() && ! $isSuperAdmin) { if ($billingIsReseller && ! $isSuperAdmin) {
$note = sprintf('Event #%d created (%s)', $event->id, $event->name); $note = sprintf('Event #%d created (%s)', $event->id, $this->resolveEventNameString($event->name));
if (! $tenant->consumeEventAllowance(1, 'event.create', $note)) { if (! $tenant->consumeEventAllowanceFor($eventServicePackage->slug, 1, 'event.create', $note)) {
throw new HttpException(402, 'Insufficient package allowance.'); throw new HttpException(402, 'Insufficient package allowance.');
} }
} }
@@ -227,6 +255,47 @@ class EventController extends Controller
], 201); ], 201);
} }
private function resolveResellerDefaultEventPackage(): Package
{
return $this->resolveResellerEventPackageForSlug('standard');
}
private function resolveResellerEventPackageForSlug(?string $slug): Package
{
if (is_string($slug) && $slug !== '') {
$match = Package::query()
->where('type', 'endcustomer')
->where('slug', $slug)
->first();
if ($match) {
return $match;
}
}
$default = Package::query()
->where('type', 'endcustomer')
->where('slug', 'standard')
->first();
if ($default) {
return $default;
}
$fallback = Package::query()
->where('type', 'endcustomer')
->orderBy('price')
->first();
if (! $fallback) {
throw ValidationException::withMessages([
'package_id' => __('Aktuell ist kein Endkunden-Paket verfügbar. Bitte kontaktiere den Support.'),
]);
}
return $fallback;
}
private function resolveLatestPackagePurchase(Tenant $tenant, Package $package): ?PackagePurchase private function resolveLatestPackagePurchase(Tenant $tenant, Package $package): ?PackagePurchase
{ {
return PackagePurchase::query() return PackagePurchase::query()
@@ -320,16 +389,30 @@ class EventController extends Controller
); );
} }
TenantMemberPermissions::ensureEventPermission($request, $event, 'events:manage');
$validated = $request->validated(); $validated = $request->validated();
$nameProvided = array_key_exists('name', $validated);
$validated = array_merge([
'name' => $event->name,
'event_type_id' => $event->event_type_id,
'event_date' => $event->date?->toDateString(),
'status' => $event->status,
], $validated);
if (isset($validated['event_date'])) { if (isset($validated['event_date'])) {
$validated['date'] = $validated['event_date']; $validated['date'] = $validated['event_date'];
unset($validated['event_date']); unset($validated['event_date']);
} }
if ($validated['name'] !== $event->name) { $currentName = $this->resolveEventNameString($event->name);
$validated['slug'] = $this->generateUniqueSlug($validated['name'], $tenantId, $event->id); $nextName = $this->resolveEventNameString($validated['name']);
if ($nameProvided && $nextName !== $currentName) {
$validated['slug'] = $this->generateUniqueSlug($nextName, $tenantId, $event->id);
} }
$validated['name'] = $this->normalizeEventName($validated['name']);
foreach (['password', 'password_confirmation', 'password_protected'] as $unused) { foreach (['password', 'password_confirmation', 'password_protected'] as $unused) {
unset($validated[$unused]); unset($validated[$unused]);
@@ -338,6 +421,7 @@ class EventController extends Controller
$package = $event->eventPackage?->package; $package = $event->eventPackage?->package;
$brandingAllowed = optional($package)->branding_allowed !== false; $brandingAllowed = optional($package)->branding_allowed !== false;
$watermarkAllowed = optional($package)->watermark_allowed !== false; $watermarkAllowed = optional($package)->watermark_allowed !== false;
$watermarkRemovalAllowed = WatermarkConfigResolver::determineRemovalAllowed($event);
if (isset($validated['settings']) && is_array($validated['settings'])) { if (isset($validated['settings']) && is_array($validated['settings'])) {
$validated['settings'] = array_merge($event->settings ?? [], $validated['settings']); $validated['settings'] = array_merge($event->settings ?? [], $validated['settings']);
@@ -347,32 +431,37 @@ class EventController extends Controller
$validated['settings']['branding_allowed'] = $brandingAllowed; $validated['settings']['branding_allowed'] = $brandingAllowed;
$validated['settings']['watermark_allowed'] = $watermarkAllowed; $validated['settings']['watermark_allowed'] = $watermarkAllowed;
$validated['settings']['watermark_removal_allowed'] = $watermarkRemovalAllowed;
$settings = $validated['settings']; $settings = $validated['settings'];
$branding = Arr::get($settings, 'branding', []);
$watermark = Arr::get($settings, 'watermark', []); $watermark = Arr::get($settings, 'watermark', []);
$existingWatermark = is_array($watermark) ? $watermark : []; $existingWatermark = is_array($watermark) ? $watermark : [];
if (is_array($branding)) {
$settings['branding'] = $this->normalizeBrandingSettings($branding, $event, $brandingAllowed);
}
if (is_array($watermark)) { if (is_array($watermark)) {
$mode = $watermark['mode'] ?? 'base'; $mode = $watermark['mode'] ?? 'base';
$policy = $watermarkAllowed ? 'basic' : 'none';
if (! $watermarkAllowed) { if (! $watermarkAllowed) {
$mode = 'off'; $mode = 'base';
} elseif (! $brandingAllowed) { } elseif (! $brandingAllowed) {
$mode = 'base'; $mode = 'base';
} elseif ($mode === 'off' && $policy === 'basic') { } elseif ($mode === 'off' && ! $watermarkRemovalAllowed) {
$mode = 'base'; $mode = 'base';
} }
$assetPath = $watermark['asset'] ?? null; $assetPath = $watermark['asset'] ?? null;
$assetDataUrl = $watermark['asset_data_url'] ?? null; $assetDataUrl = $watermark['asset_data_url'] ?? null;
if (! $watermarkAllowed) { if (! $watermarkAllowed || $mode === 'off') {
$assetPath = null; $assetPath = null;
} }
if ($assetDataUrl && $mode === 'custom' && $brandingAllowed) { if ($assetDataUrl && $mode === 'custom' && $brandingAllowed) {
if (! preg_match('/^data:image\\/(png|webp|jpe?g);base64,(.+)$/i', $assetDataUrl, $matches)) { if (! preg_match('/^data:image\\/(png|webp|jpe?g|svg\\+xml);base64,(.+)$/i', $assetDataUrl, $matches)) {
throw ValidationException::withMessages([ throw ValidationException::withMessages([
'settings.watermark.asset_data_url' => __('Ungültiges Wasserzeichen-Bild.'), 'settings.watermark.asset_data_url' => __('Ungültiges Wasserzeichen-Bild.'),
]); ]);
@@ -392,7 +481,12 @@ class EventController extends Controller
]); ]);
} }
$extension = str_starts_with(strtolower($matches[1]), 'jp') ? 'jpg' : strtolower($matches[1]); $mime = strtolower($matches[1]);
$extension = match (true) {
str_starts_with($mime, 'jp') => 'jpg',
str_starts_with($mime, 'svg') => 'svg',
default => $mime,
};
$path = sprintf('branding/watermarks/event-%s.%s', $event->id, $extension); $path = sprintf('branding/watermarks/event-%s.%s', $event->id, $extension);
Storage::disk('public')->put($path, $decoded); Storage::disk('public')->put($path, $decoded);
$assetPath = $path; $assetPath = $path;
@@ -442,6 +536,68 @@ class EventController extends Controller
]); ]);
} }
/**
* @param array<string, mixed> $branding
* @return array<string, mixed>
*/
private function normalizeBrandingSettings(array $branding, Event $event, bool $brandingAllowed): array
{
$logoDataUrl = $branding['logo_data_url'] ?? null;
if (! $brandingAllowed) {
unset($branding['logo_data_url']);
return $branding;
}
if (! is_string($logoDataUrl) || trim($logoDataUrl) === '') {
unset($branding['logo_data_url']);
return $branding;
}
if (! preg_match('/^data:image\\/(png|webp|jpe?g);base64,(.+)$/i', $logoDataUrl, $matches)) {
throw ValidationException::withMessages([
'settings.branding.logo_data_url' => __('Ungültiges Branding-Logo.'),
]);
}
$decoded = base64_decode($matches[2], true);
if ($decoded === false) {
throw ValidationException::withMessages([
'settings.branding.logo_data_url' => __('Branding-Logo konnte nicht gelesen werden.'),
]);
}
if (strlen($decoded) > 1024 * 1024) { // 1 MB
throw ValidationException::withMessages([
'settings.branding.logo_data_url' => __('Branding-Logo ist zu groß (max. 1 MB).'),
]);
}
$extension = str_starts_with(strtolower($matches[1]), 'jp') ? 'jpg' : strtolower($matches[1]);
$path = sprintf('branding/logos/event-%s.%s', $event->id, $extension);
Storage::disk('public')->put($path, $decoded);
$branding['logo_url'] = $path;
$branding['logo_mode'] = 'upload';
$branding['logo_value'] = $path;
$logo = $branding['logo'] ?? [];
if (! is_array($logo)) {
$logo = [];
}
$logo['mode'] = 'upload';
$logo['value'] = $path;
$branding['logo'] = $logo;
unset($branding['logo_data_url']);
return $branding;
}
public function destroy(Request $request, Event $event): JsonResponse public function destroy(Request $request, Event $event): JsonResponse
{ {
$tenantId = $request->attributes->get('tenant_id'); $tenantId = $request->attributes->get('tenant_id');
@@ -456,6 +612,8 @@ class EventController extends Controller
); );
} }
TenantMemberPermissions::ensureEventPermission($request, $event, 'events:manage');
$event->delete(); $event->delete();
return response()->json([ return response()->json([
@@ -783,6 +941,45 @@ class EventController extends Controller
return $slug; return $slug;
} }
/**
* @param array<string, mixed>|string|null $name
* @return array<string, mixed>
*/
private function normalizeEventName(mixed $name): array
{
if (is_array($name)) {
return $name;
}
$value = is_string($name) ? trim($name) : '';
return ['de' => $value];
}
/**
* @param array<string, mixed>|string|null $name
*/
private function resolveEventNameString(mixed $name): string
{
if (is_array($name)) {
$candidates = [
$name['de'] ?? null,
$name['en'] ?? null,
reset($name) ?: null,
];
foreach ($candidates as $candidate) {
if (is_string($candidate) && $candidate !== '') {
return $candidate;
}
}
return '';
}
return is_string($name) ? $name : '';
}
public function search(Request $request): AnonymousResourceCollection public function search(Request $request): AnonymousResourceCollection
{ {
$tenantId = $request->attributes->get('tenant_id'); $tenantId = $request->attributes->get('tenant_id');

View File

@@ -11,6 +11,7 @@ use App\Models\Event;
use App\Models\GuestNotification; use App\Models\GuestNotification;
use App\Models\GuestPolicySetting; use App\Models\GuestPolicySetting;
use App\Services\GuestNotificationService; use App\Services\GuestNotificationService;
use App\Support\TenantMemberPermissions;
use Illuminate\Http\JsonResponse; use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request; use Illuminate\Http\Request;
use Illuminate\Validation\ValidationException; use Illuminate\Validation\ValidationException;
@@ -23,6 +24,7 @@ class EventGuestNotificationController extends Controller
public function index(Request $request, Event $event): JsonResponse public function index(Request $request, Event $event): JsonResponse
{ {
$this->assertEventTenant($request, $event); $this->assertEventTenant($request, $event);
TenantMemberPermissions::ensureEventPermission($request, $event, 'guest-notifications:manage');
$limit = max(1, min(100, (int) $request->integer('limit', 25))); $limit = max(1, min(100, (int) $request->integer('limit', 25)));
@@ -38,6 +40,7 @@ class EventGuestNotificationController extends Controller
public function store(BroadcastGuestNotificationRequest $request, Event $event): JsonResponse public function store(BroadcastGuestNotificationRequest $request, Event $event): JsonResponse
{ {
$this->assertEventTenant($request, $event); $this->assertEventTenant($request, $event);
TenantMemberPermissions::ensureEventPermission($request, $event, 'guest-notifications:manage');
$data = $request->validated(); $data = $request->validated();

View File

@@ -7,6 +7,7 @@ use App\Http\Resources\Tenant\EventJoinTokenResource;
use App\Models\Event; use App\Models\Event;
use App\Models\EventJoinToken; use App\Models\EventJoinToken;
use App\Services\EventJoinTokenService; use App\Services\EventJoinTokenService;
use App\Support\TenantMemberPermissions;
use Illuminate\Http\JsonResponse; use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request; use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\AnonymousResourceCollection; use Illuminate\Http\Resources\Json\AnonymousResourceCollection;
@@ -19,7 +20,7 @@ class EventJoinTokenController extends Controller
public function index(Request $request, Event $event): AnonymousResourceCollection public function index(Request $request, Event $event): AnonymousResourceCollection
{ {
$this->authorizeEvent($request, $event); $this->authorizeEvent($request, $event, 'join-tokens:manage');
$tokens = $event->joinTokens() $tokens = $event->joinTokens()
->orderByDesc('created_at') ->orderByDesc('created_at')
@@ -30,7 +31,7 @@ class EventJoinTokenController extends Controller
public function store(Request $request, Event $event): JsonResponse public function store(Request $request, Event $event): JsonResponse
{ {
$this->authorizeEvent($request, $event); $this->authorizeEvent($request, $event, 'join-tokens:manage');
$validated = $this->validatePayload($request); $validated = $this->validatePayload($request);
@@ -45,7 +46,7 @@ class EventJoinTokenController extends Controller
public function update(Request $request, Event $event, EventJoinToken $joinToken): EventJoinTokenResource public function update(Request $request, Event $event, EventJoinToken $joinToken): EventJoinTokenResource
{ {
$this->authorizeEvent($request, $event); $this->authorizeEvent($request, $event, 'join-tokens:manage');
if ($joinToken->event_id !== $event->id) { if ($joinToken->event_id !== $event->id) {
abort(404); abort(404);
@@ -89,7 +90,7 @@ class EventJoinTokenController extends Controller
public function destroy(Request $request, Event $event, EventJoinToken $joinToken): EventJoinTokenResource public function destroy(Request $request, Event $event, EventJoinToken $joinToken): EventJoinTokenResource
{ {
$this->authorizeEvent($request, $event); $this->authorizeEvent($request, $event, 'join-tokens:manage');
if ($joinToken->event_id !== $event->id) { if ($joinToken->event_id !== $event->id) {
abort(404); abort(404);
@@ -101,13 +102,17 @@ class EventJoinTokenController extends Controller
return new EventJoinTokenResource($token); return new EventJoinTokenResource($token);
} }
private function authorizeEvent(Request $request, Event $event): void private function authorizeEvent(Request $request, Event $event, ?string $permission = null): void
{ {
$tenantId = $request->attributes->get('tenant_id'); $tenantId = $request->attributes->get('tenant_id');
if ($event->tenant_id !== $tenantId) { if ($event->tenant_id !== $tenantId) {
abort(404, 'Event not found'); abort(404, 'Event not found');
} }
if ($permission) {
TenantMemberPermissions::ensureEventPermission($request, $event, $permission);
}
} }
private function validatePayload(Request $request, bool $partial = false): array private function validatePayload(Request $request, bool $partial = false): array

View File

@@ -6,6 +6,7 @@ use App\Http\Controllers\Controller;
use App\Models\Event; use App\Models\Event;
use App\Models\EventJoinToken; use App\Models\EventJoinToken;
use App\Support\JoinTokenLayoutRegistry; use App\Support\JoinTokenLayoutRegistry;
use App\Support\TenantMemberPermissions;
use Dompdf\Dompdf; use Dompdf\Dompdf;
use Dompdf\Options; use Dompdf\Options;
use Illuminate\Http\Request; use Illuminate\Http\Request;
@@ -21,13 +22,21 @@ class EventJoinTokenLayoutController extends Controller
*/ */
private const BACKGROUND_PRESETS = [ private const BACKGROUND_PRESETS = [
'bg-blue-floral' => 'storage/layouts/backgrounds-portrait/bg-blue-floral.png', 'bg-blue-floral' => 'storage/layouts/backgrounds-portrait/bg-blue-floral.png',
'bg-artdeco' => 'storage/layouts/backgrounds-portrait/bg-artdeco.png',
'bg-eukalyptus-floral' => 'storage/layouts/backgrounds-portrait/bg-eukalyptus-floral.png',
'bg-eukalyptus-rahmen' => 'storage/layouts/backgrounds-portrait/bg-eukalyptus-rahmen.png',
'bg-eukalyptus' => 'storage/layouts/backgrounds-portrait/bg-eukalyptus.png',
'bg-goldframe' => 'storage/layouts/backgrounds-portrait/bg-goldframe.png', 'bg-goldframe' => 'storage/layouts/backgrounds-portrait/bg-goldframe.png',
'bg-jugendstil' => 'storage/layouts/backgrounds-portrait/bg-jugendstil.png',
'bg-kornblumen' => 'storage/layouts/backgrounds-portrait/bg-kornblumen.png',
'bg-kornblumen2' => 'storage/layouts/backgrounds-portrait/bg-kornblumen2.png',
'gr-green-floral' => 'storage/layouts/backgrounds-portrait/gr-green-floral.png', 'gr-green-floral' => 'storage/layouts/backgrounds-portrait/gr-green-floral.png',
]; ];
public function index(Request $request, Event $event, EventJoinToken $joinToken) public function index(Request $request, Event $event, EventJoinToken $joinToken)
{ {
$this->ensureBelongsToEvent($event, $joinToken); $this->ensureBelongsToEvent($event, $joinToken);
TenantMemberPermissions::ensureEventPermission($request, $event, 'join-tokens:manage');
$layouts = JoinTokenLayoutRegistry::toResponse(function (string $layoutId, string $format) use ($event, $joinToken) { $layouts = JoinTokenLayoutRegistry::toResponse(function (string $layoutId, string $format) use ($event, $joinToken) {
return route('api.v1.tenant.events.join-tokens.layouts.download', [ return route('api.v1.tenant.events.join-tokens.layouts.download', [
@@ -46,6 +55,7 @@ class EventJoinTokenLayoutController extends Controller
public function download(Request $request, Event $event, EventJoinToken $joinToken, string $layout, string $format) public function download(Request $request, Event $event, EventJoinToken $joinToken, string $layout, string $format)
{ {
$this->ensureBelongsToEvent($event, $joinToken); $this->ensureBelongsToEvent($event, $joinToken);
TenantMemberPermissions::ensureEventPermission($request, $event, 'join-tokens:manage');
$layoutConfig = JoinTokenLayoutRegistry::find($layout); $layoutConfig = JoinTokenLayoutRegistry::find($layout);

View File

@@ -9,6 +9,7 @@ use App\Models\Event;
use App\Models\EventMember; use App\Models\EventMember;
use App\Models\Tenant; use App\Models\Tenant;
use App\Models\User; use App\Models\User;
use App\Support\TenantMemberPermissions;
use Illuminate\Contracts\Pagination\LengthAwarePaginator; use Illuminate\Contracts\Pagination\LengthAwarePaginator;
use Illuminate\Http\JsonResponse; use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request; use Illuminate\Http\Request;
@@ -22,6 +23,7 @@ class EventMemberController extends Controller
public function index(Request $request, Event $event): JsonResponse public function index(Request $request, Event $event): JsonResponse
{ {
$this->assertEventTenant($request, $event); $this->assertEventTenant($request, $event);
TenantMemberPermissions::ensureEventPermission($request, $event, 'members:manage');
/** @var LengthAwarePaginator $members */ /** @var LengthAwarePaginator $members */
$members = $event->members() $members = $event->members()
@@ -34,6 +36,7 @@ class EventMemberController extends Controller
public function store(EventMemberInviteRequest $request, Event $event): JsonResponse public function store(EventMemberInviteRequest $request, Event $event): JsonResponse
{ {
$this->assertEventTenant($request, $event); $this->assertEventTenant($request, $event);
TenantMemberPermissions::ensureEventPermission($request, $event, 'members:manage');
$data = $request->validated(); $data = $request->validated();
$tenant = $this->resolveTenantFromRequest($request); $tenant = $this->resolveTenantFromRequest($request);
@@ -92,6 +95,7 @@ class EventMemberController extends Controller
public function destroy(Request $request, Event $event, EventMember $member): JsonResponse public function destroy(Request $request, Event $event, EventMember $member): JsonResponse
{ {
$this->assertEventTenant($request, $event); $this->assertEventTenant($request, $event);
TenantMemberPermissions::ensureEventPermission($request, $event, 'members:manage');
if ((int) $member->event_id !== (int) $event->id) { if ((int) $member->event_id !== (int) $event->id) {
throw ValidationException::withMessages([ throw ValidationException::withMessages([

View File

@@ -112,4 +112,3 @@ class FontController extends Controller
return $fonts; return $fonts;
} }
} }

View File

@@ -4,6 +4,7 @@ namespace App\Http\Controllers\Api\Tenant;
use App\Http\Controllers\Controller; use App\Http\Controllers\Controller;
use App\Models\Event; use App\Models\Event;
use App\Support\TenantMemberPermissions;
use Illuminate\Http\JsonResponse; use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request; use Illuminate\Http\Request;
use SimpleSoftwareIO\QrCode\Facades\QrCode; use SimpleSoftwareIO\QrCode\Facades\QrCode;
@@ -13,6 +14,7 @@ class LiveShowLinkController extends Controller
public function show(Request $request, Event $event): JsonResponse public function show(Request $request, Event $event): JsonResponse
{ {
$this->authorizeEvent($request, $event); $this->authorizeEvent($request, $event);
TenantMemberPermissions::ensureEventPermission($request, $event, 'live-show:manage');
$token = $event->ensureLiveShowToken(); $token = $event->ensureLiveShowToken();
@@ -24,6 +26,7 @@ class LiveShowLinkController extends Controller
public function rotate(Request $request, Event $event): JsonResponse public function rotate(Request $request, Event $event): JsonResponse
{ {
$this->authorizeEvent($request, $event); $this->authorizeEvent($request, $event);
TenantMemberPermissions::ensureEventPermission($request, $event, 'live-show:manage');
$token = $event->rotateLiveShowToken(); $token = $event->rotateLiveShowToken();

View File

@@ -10,6 +10,7 @@ use App\Http\Resources\Tenant\PhotoResource;
use App\Models\Event; use App\Models\Event;
use App\Models\Photo; use App\Models\Photo;
use App\Support\ApiError; use App\Support\ApiError;
use App\Support\TenantMemberPermissions;
use Illuminate\Http\JsonResponse; use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request; use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\AnonymousResourceCollection; use Illuminate\Http\Resources\Json\AnonymousResourceCollection;
@@ -23,6 +24,7 @@ class LiveShowPhotoController extends Controller
$event = Event::where('slug', $eventSlug) $event = Event::where('slug', $eventSlug)
->where('tenant_id', $tenantId) ->where('tenant_id', $tenantId)
->firstOrFail(); ->firstOrFail();
TenantMemberPermissions::ensureEventPermission($request, $event, 'photos:moderate');
$liveStatus = $request->string('live_status', 'pending')->toString(); $liveStatus = $request->string('live_status', 'pending')->toString();
$perPage = (int) $request->input('per_page', 20); $perPage = (int) $request->input('per_page', 20);
@@ -51,6 +53,7 @@ class LiveShowPhotoController extends Controller
$event = Event::where('slug', $eventSlug) $event = Event::where('slug', $eventSlug)
->where('tenant_id', $tenantId) ->where('tenant_id', $tenantId)
->firstOrFail(); ->firstOrFail();
TenantMemberPermissions::ensureEventPermission($request, $event, 'photos:moderate');
if ($photo->event_id !== $event->id) { if ($photo->event_id !== $event->id) {
return ApiError::response( return ApiError::response(
@@ -94,6 +97,7 @@ class LiveShowPhotoController extends Controller
$event = Event::where('slug', $eventSlug) $event = Event::where('slug', $eventSlug)
->where('tenant_id', $tenantId) ->where('tenant_id', $tenantId)
->firstOrFail(); ->firstOrFail();
TenantMemberPermissions::ensureEventPermission($request, $event, 'photos:moderate');
if ($photo->event_id !== $event->id) { if ($photo->event_id !== $event->id) {
return ApiError::response( return ApiError::response(
@@ -146,6 +150,7 @@ class LiveShowPhotoController extends Controller
$event = Event::where('slug', $eventSlug) $event = Event::where('slug', $eventSlug)
->where('tenant_id', $tenantId) ->where('tenant_id', $tenantId)
->firstOrFail(); ->firstOrFail();
TenantMemberPermissions::ensureEventPermission($request, $event, 'photos:moderate');
if ($photo->event_id !== $event->id) { if ($photo->event_id !== $event->id) {
return ApiError::response( return ApiError::response(
@@ -173,6 +178,7 @@ class LiveShowPhotoController extends Controller
$event = Event::where('slug', $eventSlug) $event = Event::where('slug', $eventSlug)
->where('tenant_id', $tenantId) ->where('tenant_id', $tenantId)
->firstOrFail(); ->firstOrFail();
TenantMemberPermissions::ensureEventPermission($request, $event, 'photos:moderate');
if ($photo->event_id !== $event->id) { if ($photo->event_id !== $event->id) {
return ApiError::response( return ApiError::response(

View File

@@ -14,11 +14,14 @@ use App\Services\Packages\PackageUsageTracker;
use App\Services\Storage\EventStorageManager; use App\Services\Storage\EventStorageManager;
use App\Support\ApiError; use App\Support\ApiError;
use App\Support\ImageHelper; use App\Support\ImageHelper;
use App\Support\TenantMemberPermissions;
use App\Support\UploadStream; use App\Support\UploadStream;
use App\Support\WatermarkConfigResolver; use App\Support\WatermarkConfigResolver;
use Illuminate\Http\JsonResponse; use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request; use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\AnonymousResourceCollection; use Illuminate\Http\Resources\Json\AnonymousResourceCollection;
use Illuminate\Http\UploadedFile;
use Illuminate\Support\Arr;
use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Log; use Illuminate\Support\Facades\Log;
use Illuminate\Support\Facades\Storage; use Illuminate\Support\Facades\Storage;
@@ -113,6 +116,7 @@ class PhotoController extends Controller
$event = Event::where('slug', $eventSlug) $event = Event::where('slug', $eventSlug)
->where('tenant_id', $tenantId) ->where('tenant_id', $tenantId)
->firstOrFail(); ->firstOrFail();
TenantMemberPermissions::ensureEventPermission($request, $event, 'photos:moderate');
if ($photo->event_id !== $event->id) { if ($photo->event_id !== $event->id) {
return ApiError::response( return ApiError::response(
@@ -130,6 +134,11 @@ class PhotoController extends Controller
$photo->status = $validated['visible'] ? 'approved' : 'hidden'; $photo->status = $validated['visible'] ? 'approved' : 'hidden';
$photo->save(); $photo->save();
$autoRemoveLiveOnHide = (bool) Arr::get($event->settings ?? [], 'control_room.auto_remove_live_on_hide', true);
if ($autoRemoveLiveOnHide && ! $validated['visible']) {
$photo->rejectForLiveShow($request->user(), 'hidden');
}
$photo->load('event')->loadCount('likes'); $photo->load('event')->loadCount('likes');
return response()->json([ return response()->json([
@@ -314,7 +323,7 @@ class PhotoController extends Controller
$disk = $this->eventStorageManager->getHotDiskForEvent($event); $disk = $this->eventStorageManager->getHotDiskForEvent($event);
// Generate unique filename // Generate unique filename
$extension = $file->getClientOriginalExtension(); $extension = $this->resolvePhotoExtension($file);
$filename = Str::uuid().'.'.$extension; $filename = Str::uuid().'.'.$extension;
$path = "events/{$eventSlug}/photos/{$filename}"; $path = "events/{$eventSlug}/photos/{$filename}";
@@ -524,19 +533,17 @@ class PhotoController extends Controller
'alt_text' => ['sometimes', 'string', 'max:255'], 'alt_text' => ['sometimes', 'string', 'max:255'],
]); ]);
// Only tenant admins can moderate if (isset($validated['status'])) {
if (isset($validated['status']) && ! $this->tokenHasScope($request, 'tenant-admin')) { TenantMemberPermissions::ensureEventPermission($request, $event, 'photos:moderate');
return ApiError::response(
'insufficient_scope',
'Insufficient Scopes',
'You are not allowed to moderate photos for this event.',
Response::HTTP_FORBIDDEN,
['required_scope' => 'tenant-admin']
);
} }
$photo->update($validated); $photo->update($validated);
$autoRemoveLiveOnHide = (bool) Arr::get($event->settings ?? [], 'control_room.auto_remove_live_on_hide', true);
if ($autoRemoveLiveOnHide && ($validated['status'] ?? null) === 'rejected') {
$photo->rejectForLiveShow($request->user());
}
if ($validated['status'] ?? null === 'approved') { if ($validated['status'] ?? null === 'approved') {
$photo->load('event')->loadCount('likes'); $photo->load('event')->loadCount('likes');
// Trigger event for new photo notification // Trigger event for new photo notification
@@ -558,6 +565,7 @@ class PhotoController extends Controller
$event = Event::where('slug', $eventSlug) $event = Event::where('slug', $eventSlug)
->where('tenant_id', $tenantId) ->where('tenant_id', $tenantId)
->firstOrFail(); ->firstOrFail();
TenantMemberPermissions::ensureEventPermission($request, $event, 'photos:moderate');
if ($photo->event_id !== $event->id) { if ($photo->event_id !== $event->id) {
return ApiError::response( return ApiError::response(
@@ -634,6 +642,7 @@ class PhotoController extends Controller
$event = Event::where('slug', $eventSlug) $event = Event::where('slug', $eventSlug)
->where('tenant_id', $tenantId) ->where('tenant_id', $tenantId)
->firstOrFail(); ->firstOrFail();
TenantMemberPermissions::ensureEventPermission($request, $event, 'photos:moderate');
if ($photo->event_id !== $event->id) { if ($photo->event_id !== $event->id) {
return ApiError::response( return ApiError::response(
@@ -657,6 +666,7 @@ class PhotoController extends Controller
$event = Event::where('slug', $eventSlug) $event = Event::where('slug', $eventSlug)
->where('tenant_id', $tenantId) ->where('tenant_id', $tenantId)
->firstOrFail(); ->firstOrFail();
TenantMemberPermissions::ensureEventPermission($request, $event, 'photos:moderate');
if ($photo->event_id !== $event->id) { if ($photo->event_id !== $event->id) {
return ApiError::response( return ApiError::response(
@@ -680,6 +690,7 @@ class PhotoController extends Controller
$event = Event::where('slug', $eventSlug) $event = Event::where('slug', $eventSlug)
->where('tenant_id', $tenantId) ->where('tenant_id', $tenantId)
->firstOrFail(); ->firstOrFail();
TenantMemberPermissions::ensureEventPermission($request, $event, 'photos:moderate');
$request->validate([ $request->validate([
'photo_ids' => 'required|array', 'photo_ids' => 'required|array',
@@ -725,6 +736,7 @@ class PhotoController extends Controller
$event = Event::where('slug', $eventSlug) $event = Event::where('slug', $eventSlug)
->where('tenant_id', $tenantId) ->where('tenant_id', $tenantId)
->firstOrFail(); ->firstOrFail();
TenantMemberPermissions::ensureEventPermission($request, $event, 'photos:moderate');
$request->validate([ $request->validate([
'photo_ids' => 'required|array', 'photo_ids' => 'required|array',
@@ -770,6 +782,7 @@ class PhotoController extends Controller
$event = Event::where('slug', $eventSlug) $event = Event::where('slug', $eventSlug)
->where('tenant_id', $tenantId) ->where('tenant_id', $tenantId)
->firstOrFail(); ->firstOrFail();
TenantMemberPermissions::ensureEventPermission($request, $event, 'photos:moderate');
$photos = Photo::where('event_id', $event->id) $photos = Photo::where('event_id', $event->id)
->where('status', 'pending') ->where('status', 'pending')
@@ -1034,4 +1047,23 @@ class PhotoController extends Controller
return array_values(array_unique(array_filter($candidates))); return array_values(array_unique(array_filter($candidates)));
} }
private function resolvePhotoExtension(UploadedFile $file): string
{
$extension = strtolower((string) $file->extension());
if (! in_array($extension, ['jpg', 'jpeg', 'png', 'webp'], true)) {
$extension = strtolower((string) $file->getClientOriginalExtension());
}
if (! in_array($extension, ['jpg', 'jpeg', 'png', 'webp'], true)) {
$extension = match ($file->getMimeType()) {
'image/png' => 'png',
'image/webp' => 'webp',
default => 'jpg',
};
}
return $extension === 'jpeg' ? 'jpg' : $extension;
}
} }

View File

@@ -3,12 +3,17 @@
namespace App\Http\Controllers\Api\Tenant; namespace App\Http\Controllers\Api\Tenant;
use App\Http\Controllers\Controller; use App\Http\Controllers\Controller;
use App\Http\Requests\Photobooth\PhotoboothSendUploaderDownloadRequest;
use App\Http\Resources\Tenant\PhotoboothStatusResource; use App\Http\Resources\Tenant\PhotoboothStatusResource;
use App\Mail\PhotoboothUploaderDownload;
use App\Models\Event; use App\Models\Event;
use App\Models\PhotoboothSetting; use App\Models\PhotoboothSetting;
use App\Services\Photobooth\PhotoboothProvisioner; use App\Services\Photobooth\PhotoboothProvisioner;
use App\Support\LocaleConfig;
use Illuminate\Http\JsonResponse; use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request; use Illuminate\Http\Request;
use Illuminate\Support\Facades\Mail;
use Illuminate\Validation\ValidationException;
class PhotoboothController extends Controller class PhotoboothController extends Controller
{ {
@@ -69,6 +74,39 @@ class PhotoboothController extends Controller
]); ]);
} }
public function sendUploaderDownloadEmail(PhotoboothSendUploaderDownloadRequest $request, Event $event): JsonResponse
{
$this->assertEventBelongsToTenant($request, $event);
$user = $request->user();
if (! $user || ! $user->email) {
throw ValidationException::withMessages([
'email' => __('No email address is configured for this account.'),
]);
}
$locale = LocaleConfig::canonicalize($user->preferred_locale ?: app()->getLocale());
$eventName = $this->resolveEventName($event, $locale);
$recipientName = $user->fullName ?? $user->name ?? $user->email;
$mail = (new PhotoboothUploaderDownload(
recipientName: $recipientName,
eventName: $eventName,
links: [
'windows' => url('/downloads/PhotoboothUploader-win-x64.exe'),
'macos' => url('/downloads/PhotoboothUploader-macos-x64'),
'linux' => url('/downloads/PhotoboothUploader-linux-x64'),
],
))->locale($locale);
Mail::to($user->email)->queue($mail);
return response()->json([
'message' => __('Download links sent via email.'),
]);
}
protected function resource(Event $event): PhotoboothStatusResource protected function resource(Event $event): PhotoboothStatusResource
{ {
return PhotoboothStatusResource::make([ return PhotoboothStatusResource::make([
@@ -92,4 +130,30 @@ class PhotoboothController extends Controller
return in_array($mode, ['sparkbooth', 'ftp'], true) ? $mode : 'ftp'; return in_array($mode, ['sparkbooth', 'ftp'], true) ? $mode : 'ftp';
} }
protected function resolveEventName(Event $event, ?string $locale = null): string
{
$name = $event->name;
if (is_string($name) && trim($name) !== '') {
return $name;
}
if (is_array($name)) {
$locale = $locale ?: app()->getLocale();
$localized = $name[$locale] ?? null;
if (is_string($localized) && trim($localized) !== '') {
return $localized;
}
foreach ($name as $value) {
if (is_string($value) && trim($value) !== '') {
return $value;
}
}
}
return $event->slug ?: __('emails.photobooth_uploader.event_fallback');
}
} }

View File

@@ -113,8 +113,8 @@ class SettingsController extends Controller
$defaultSettings = [ $defaultSettings = [
'branding' => [ 'branding' => [
'logo_url' => null, 'logo_url' => null,
'primary_color' => '#3B82F6', 'primary_color' => '#FF5A5F',
'secondary_color' => '#1F2937', 'secondary_color' => '#FFF8F5',
'font_family' => 'Inter, sans-serif', 'font_family' => 'Inter, sans-serif',
], ],
'features' => [ 'features' => [

View File

@@ -110,6 +110,7 @@ class TaskCollectionController extends Controller
), ),
'created_task_ids' => $result['created_task_ids'], 'created_task_ids' => $result['created_task_ids'],
'attached_task_ids' => $result['attached_task_ids'], 'attached_task_ids' => $result['attached_task_ids'],
'skipped_task_ids' => $result['skipped_task_ids'],
]); ]);
} }

View File

@@ -10,7 +10,9 @@ use App\Models\Event;
use App\Models\Task; use App\Models\Task;
use App\Models\TaskCollection; use App\Models\TaskCollection;
use App\Models\Tenant; use App\Models\Tenant;
use App\Services\Packages\PackageLimitEvaluator;
use App\Support\ApiError; use App\Support\ApiError;
use App\Support\TenantMemberPermissions;
use App\Support\TenantRequestResolver; use App\Support\TenantRequestResolver;
use Illuminate\Http\JsonResponse; use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request; use Illuminate\Http\Request;
@@ -19,6 +21,8 @@ use Symfony\Component\HttpFoundation\Response;
class TaskController extends Controller class TaskController extends Controller
{ {
public function __construct(private readonly PackageLimitEvaluator $packageLimitEvaluator) {}
/** /**
* Display a listing of the tenant's tasks. * Display a listing of the tenant's tasks.
*/ */
@@ -66,6 +70,8 @@ class TaskController extends Controller
*/ */
public function store(TaskStoreRequest $request): JsonResponse public function store(TaskStoreRequest $request): JsonResponse
{ {
TenantMemberPermissions::ensureTenantPermission($request, 'tasks:manage');
$tenant = $this->currentTenant($request); $tenant = $this->currentTenant($request);
$collectionId = $request->input('collection_id'); $collectionId = $request->input('collection_id');
$collection = $collectionId ? $this->resolveAccessibleCollection($request, $collectionId) : null; $collection = $collectionId ? $this->resolveAccessibleCollection($request, $collectionId) : null;
@@ -107,6 +113,8 @@ class TaskController extends Controller
*/ */
public function update(TaskUpdateRequest $request, Task $task): JsonResponse public function update(TaskUpdateRequest $request, Task $task): JsonResponse
{ {
TenantMemberPermissions::ensureTenantPermission($request, 'tasks:manage');
$tenant = $this->currentTenant($request); $tenant = $this->currentTenant($request);
if ($task->tenant_id !== $tenant->id) { if ($task->tenant_id !== $tenant->id) {
@@ -138,6 +146,8 @@ class TaskController extends Controller
*/ */
public function destroy(Request $request, Task $task): JsonResponse public function destroy(Request $request, Task $task): JsonResponse
{ {
TenantMemberPermissions::ensureTenantPermission($request, 'tasks:manage');
if ($task->tenant_id !== $this->currentTenant($request)->id) { if ($task->tenant_id !== $this->currentTenant($request)->id) {
abort(404, 'Task nicht gefunden.'); abort(404, 'Task nicht gefunden.');
} }
@@ -154,7 +164,10 @@ class TaskController extends Controller
*/ */
public function assignToEvent(Request $request, Task $task, Event $event): JsonResponse public function assignToEvent(Request $request, Task $task, Event $event): JsonResponse
{ {
$tenantId = $this->currentTenant($request)->id; TenantMemberPermissions::ensureEventPermission($request, $event, 'tasks:manage');
$tenant = $this->currentTenant($request);
$tenantId = $tenant->id;
if (($task->tenant_id && $task->tenant_id !== $tenantId) || $event->tenant_id !== $tenantId) { if (($task->tenant_id && $task->tenant_id !== $tenantId) || $event->tenant_id !== $tenantId) {
abort(404); abort(404);
@@ -164,6 +177,11 @@ class TaskController extends Controller
return response()->json(['message' => 'Task ist bereits diesem Event zugewiesen.'], 409); return response()->json(['message' => 'Task ist bereits diesem Event zugewiesen.'], 409);
} }
$limitStatus = $this->resolveTaskLimitStatus($event, $tenant);
if ($limitStatus['remaining'] !== null && $limitStatus['remaining'] <= 0) {
return $this->taskLimitExceededResponse($event, $limitStatus);
}
$task->assignedEvents()->attach($event->id); $task->assignedEvents()->attach($event->id);
return response()->json([ return response()->json([
@@ -176,7 +194,10 @@ class TaskController extends Controller
*/ */
public function bulkAssignToEvent(Request $request, Event $event): JsonResponse public function bulkAssignToEvent(Request $request, Event $event): JsonResponse
{ {
$tenantId = $this->currentTenant($request)->id; TenantMemberPermissions::ensureEventPermission($request, $event, 'tasks:manage');
$tenant = $this->currentTenant($request);
$tenantId = $tenant->id;
if ($event->tenant_id !== $tenantId) { if ($event->tenant_id !== $tenantId) {
abort(404); abort(404);
@@ -192,12 +213,27 @@ class TaskController extends Controller
); );
} }
$taskIds = array_values(array_unique(array_map('intval', $taskIds)));
$tasks = Task::whereIn('id', $taskIds) $tasks = Task::whereIn('id', $taskIds)
->where(function ($query) use ($tenantId) { ->where(function ($query) use ($tenantId) {
$query->whereNull('tenant_id')->orWhere('tenant_id', $tenantId); $query->whereNull('tenant_id')->orWhere('tenant_id', $tenantId);
}) })
->get(); ->get();
$assignedIds = $event->tasks()
->whereIn('tasks.id', $taskIds)
->pluck('tasks.id')
->all();
$pendingIds = array_values(array_diff($taskIds, $assignedIds));
$limitStatus = $this->resolveTaskLimitStatus($event, $tenant);
if (
$limitStatus['remaining'] !== null
&& $pendingIds !== []
&& $limitStatus['remaining'] < count($pendingIds)
) {
return $this->taskLimitExceededResponse($event, $limitStatus);
}
$attached = 0; $attached = 0;
foreach ($tasks as $task) { foreach ($tasks as $task) {
if (! $task->assignedEvents()->where('event_id', $event->id)->exists()) { if (! $task->assignedEvents()->where('event_id', $event->id)->exists()) {
@@ -230,6 +266,8 @@ class TaskController extends Controller
public function bulkDetachFromEvent(Request $request, Event $event): JsonResponse public function bulkDetachFromEvent(Request $request, Event $event): JsonResponse
{ {
TenantMemberPermissions::ensureEventPermission($request, $event, 'tasks:manage');
$tenantId = $this->currentTenant($request)->id; $tenantId = $this->currentTenant($request)->id;
if ($event->tenant_id !== $tenantId) { if ($event->tenant_id !== $tenantId) {
@@ -256,6 +294,8 @@ class TaskController extends Controller
public function reorderForEvent(Request $request, Event $event): JsonResponse public function reorderForEvent(Request $request, Event $event): JsonResponse
{ {
TenantMemberPermissions::ensureEventPermission($request, $event, 'tasks:manage');
$tenantId = $this->currentTenant($request)->id; $tenantId = $this->currentTenant($request)->id;
if ($event->tenant_id !== $tenantId) { if ($event->tenant_id !== $tenantId) {
@@ -315,6 +355,52 @@ class TaskController extends Controller
return TenantRequestResolver::resolve($request); return TenantRequestResolver::resolve($request);
} }
/**
* @return array{limit: ?int, used: int, remaining: ?int, package_id: ?int}
*/
protected function resolveTaskLimitStatus(Event $event, Tenant $tenant): array
{
$event->loadMissing(['eventPackage.package', 'eventPackages.package']);
$eventPackage = $this->packageLimitEvaluator->resolveEventPackageForPhotoUpload(
$tenant,
$event->id,
$event
);
$limit = $eventPackage?->effectiveLimits()['max_tasks'] ?? null;
$used = $event->tasks()->count();
$remaining = $limit === null ? null : max(0, (int) $limit - $used);
return [
'limit' => $limit === null ? null : (int) $limit,
'used' => $used,
'remaining' => $remaining,
'package_id' => $eventPackage?->package_id,
];
}
/**
* @param array{limit: ?int, used: int, remaining: ?int, package_id: ?int} $limitStatus
*/
protected function taskLimitExceededResponse(Event $event, array $limitStatus): JsonResponse
{
return ApiError::response(
'task_limit_exceeded',
__('api.packages.task_limit_exceeded.title'),
__('api.packages.task_limit_exceeded.message'),
Response::HTTP_PAYMENT_REQUIRED,
[
'scope' => 'tasks',
'used' => $limitStatus['used'],
'limit' => $limitStatus['limit'],
'remaining' => $limitStatus['remaining'] ?? 0,
'event_id' => $event->id,
'package_id' => $limitStatus['package_id'],
]
);
}
protected function prepareTaskPayload(array $data, int $tenantId, ?Task $original = null): array protected function prepareTaskPayload(array $data, int $tenantId, ?Task $original = null): array
{ {
if (array_key_exists('title', $data)) { if (array_key_exists('title', $data)) {

View File

@@ -4,10 +4,9 @@ namespace App\Http\Controllers\Api;
use App\Http\Controllers\Controller; use App\Http\Controllers\Controller;
use App\Models\EventPackageAddon; use App\Models\EventPackageAddon;
use App\Services\Paddle\Exceptions\PaddleException; use App\Services\LemonSqueezy\Exceptions\LemonSqueezyException;
use App\Services\Paddle\PaddleCustomerPortalService; use App\Services\LemonSqueezy\LemonSqueezyOrderService;
use App\Services\Paddle\PaddleCustomerService; use App\Services\LemonSqueezy\LemonSqueezySubscriptionService;
use App\Services\Paddle\PaddleTransactionService;
use Illuminate\Http\JsonResponse; use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request; use Illuminate\Http\Request;
use Illuminate\Support\Arr; use Illuminate\Support\Arr;
@@ -16,9 +15,8 @@ use Illuminate\Support\Facades\Log;
class TenantBillingController extends Controller class TenantBillingController extends Controller
{ {
public function __construct( public function __construct(
private readonly PaddleTransactionService $paddleTransactions, private readonly LemonSqueezyOrderService $orders,
private readonly PaddleCustomerService $paddleCustomers, private readonly LemonSqueezySubscriptionService $subscriptions,
private readonly PaddleCustomerPortalService $portalSessions,
) {} ) {}
public function transactions(Request $request): JsonResponse public function transactions(Request $request): JsonResponse
@@ -32,20 +30,15 @@ class TenantBillingController extends Controller
], 404); ], 404);
} }
if (! $tenant->paddle_customer_id) { if (! $tenant->lemonsqueezy_customer_id) {
try { return response()->json([
$this->paddleCustomers->ensureCustomerId($tenant); 'data' => [],
} catch (\Throwable $exception) { 'meta' => [
Log::warning('Failed to resolve Paddle customer for tenant', [ 'next' => null,
'tenant_id' => $tenant->id, 'previous' => null,
'error' => $exception->getMessage(), 'has_more' => false,
]); ],
]);
return response()->json([
'data' => [],
'message' => 'Failed to resolve Paddle customer.',
], 502);
}
} }
$cursor = $request->query('cursor'); $cursor = $request->query('cursor');
@@ -60,16 +53,16 @@ class TenantBillingController extends Controller
} }
try { try {
$result = $this->paddleTransactions->listForCustomer($tenant->paddle_customer_id, $query); $result = $this->orders->listForCustomer($tenant->lemonsqueezy_customer_id, $query);
} catch (\Throwable $exception) { } catch (\Throwable $exception) {
Log::warning('Failed to load Paddle transactions', [ Log::warning('Failed to load Lemon Squeezy transactions', [
'tenant_id' => $tenant->id, 'tenant_id' => $tenant->id,
'error' => $exception->getMessage(), 'error' => $exception->getMessage(),
]); ]);
return response()->json([ return response()->json([
'data' => [], 'data' => [],
'message' => 'Failed to load Paddle transactions.', 'message' => 'Failed to load Lemon Squeezy transactions.',
], 502); ], 502);
} }
@@ -143,68 +136,64 @@ class TenantBillingController extends Controller
], 404); ], 404);
} }
$customerId = null; $subscriptionId = null;
try { try {
$customerId = $this->paddleCustomers->ensureCustomerId($tenant); $subscriptionId = $tenant->getActiveResellerPackage()?->lemonsqueezy_subscription_id;
if (! $subscriptionId) {
return response()->json([
'message' => 'No active subscription found.',
], 404);
}
Log::debug('Creating Paddle customer portal session', [ Log::debug('Fetching Lemon Squeezy subscription portal URL', [
'tenant_id' => $tenant->id, 'tenant_id' => $tenant->id,
'paddle_customer_id' => $customerId, 'lemonsqueezy_subscription_id' => $subscriptionId,
'paddle_environment' => config('paddle.environment'),
'paddle_base_url' => config('paddle.base_url'),
]); ]);
$session = $this->portalSessions->createSession($customerId); $subscription = $this->subscriptions->retrieve($subscriptionId);
} catch (\Throwable $exception) { } catch (\Throwable $exception) {
$context = [ $context = [
'tenant_id' => $tenant->id, 'tenant_id' => $tenant->id,
'paddle_customer_id' => $customerId ?? $tenant->paddle_customer_id, 'lemonsqueezy_customer_id' => $tenant->lemonsqueezy_customer_id,
'lemonsqueezy_subscription_id' => $subscriptionId ?? null,
'error' => $exception->getMessage(), 'error' => $exception->getMessage(),
'paddle_environment' => config('paddle.environment'),
'paddle_base_url' => config('paddle.base_url'),
]; ];
if ($exception instanceof PaddleException) { if ($exception instanceof LemonSqueezyException) {
$context['paddle_status'] = $exception->status(); $context['lemonsqueezy_status'] = $exception->status();
$context['paddle_error_code'] = Arr::get($exception->context(), 'error.code'); $context['lemonsqueezy_error'] = Arr::get($exception->context(), 'errors.0');
$context['paddle_error_message'] = Arr::get($exception->context(), 'error.message'); $context['lemonsqueezy_errors'] = Arr::get($exception->context(), 'errors');
$context['paddle_error_detail'] = Arr::get($exception->context(), 'error.detail'); $context['lemonsqueezy_request_id'] = Arr::get($exception->context(), 'meta.request_id');
$context['paddle_error_doc_url'] = Arr::get($exception->context(), 'error.documentation_url');
$context['paddle_request_id'] = Arr::get($exception->context(), 'meta.request_id');
$context['paddle_errors'] = Arr::get($exception->context(), 'error.errors');
} }
Log::warning('Failed to create Paddle customer portal session', [ Log::warning('Failed to fetch Lemon Squeezy subscription portal URL', [
...$context, ...$context,
]); ]);
return response()->json([ return response()->json([
'message' => 'Failed to create Paddle customer portal session.', 'message' => 'Failed to fetch Lemon Squeezy subscription portal URL.',
], 502); ], 502);
} }
$url = Arr::get($session, 'data.urls.general.overview') $url = $this->subscriptions->portalUrl($subscription)
?? Arr::get($session, 'data.urls.general') ?? $this->subscriptions->updatePaymentMethodUrl($subscription);
?? Arr::get($session, 'urls.general.overview')
?? Arr::get($session, 'urls.general');
if (! $url) { if (! $url) {
$sessionData = Arr::get($session, 'data'); $sessionData = Arr::get($subscription, 'data');
$sessionUrls = Arr::get($session, 'data.urls') ?? Arr::get($session, 'urls'); $sessionUrls = Arr::get($subscription, 'attributes.urls');
Log::warning('Paddle customer portal session missing URL', [ Log::warning('Lemon Squeezy subscription missing portal URL', [
'tenant_id' => $tenant->id, 'tenant_id' => $tenant->id,
'paddle_customer_id' => $customerId ?? $tenant->paddle_customer_id, 'lemonsqueezy_customer_id' => $tenant->lemonsqueezy_customer_id,
'paddle_environment' => config('paddle.environment'), 'lemonsqueezy_subscription_id' => $subscriptionId ?? null,
'paddle_base_url' => config('paddle.base_url'), 'subscription_keys' => array_keys($subscription),
'session_keys' => array_keys($session),
'session_data_keys' => is_array($sessionData) ? array_keys($sessionData) : null, 'session_data_keys' => is_array($sessionData) ? array_keys($sessionData) : null,
'session_url_keys' => is_array($sessionUrls) ? array_keys($sessionUrls) : null, 'session_url_keys' => is_array($sessionUrls) ? array_keys($sessionUrls) : null,
]); ]);
return response()->json([ return response()->json([
'message' => 'Paddle customer portal session missing URL.', 'message' => 'Lemon Squeezy subscription missing portal URL.',
], 502); ], 502);
} }

View File

@@ -39,7 +39,9 @@ class TenantPackageController extends Controller
$activePackage = $tenant->activeResellerPackage?->load('package'); $activePackage = $tenant->activeResellerPackage?->load('package');
if ($activePackage instanceof TenantPackage) { if (! ($activePackage instanceof TenantPackage)) {
$activePackage = $packages->firstWhere('active', true);
} else {
$this->hydratePackageSnapshot($activePackage, $usageEventPackage); $this->hydratePackageSnapshot($activePackage, $usageEventPackage);
} }
@@ -60,6 +62,7 @@ class TenantPackageController extends Controller
$pkg?->limits ?? [], $pkg?->limits ?? [],
$this->buildUsageSnapshot($eventPackage), $this->buildUsageSnapshot($eventPackage),
[ [
'included_package_slug' => $pkg?->included_package_slug,
'branding_allowed' => $pkg?->branding_allowed, 'branding_allowed' => $pkg?->branding_allowed,
'watermark_allowed' => $pkg?->watermark_allowed, 'watermark_allowed' => $pkg?->watermark_allowed,
'features' => $pkg?->features ?? [], 'features' => $pkg?->features ?? [],

View File

@@ -47,6 +47,15 @@ class AuthenticatedSessionController extends Controller
$user = Auth::user(); $user = Auth::user();
if ($user && $user->email_verified_at === null) { if ($user && $user->email_verified_at === null) {
$intended = $request->session()->get('url.intended');
$intended = is_string($intended) ? trim($intended) : null;
if ($this->isVerificationLink($intended)) {
$request->session()->forget('url.intended');
return Inertia::location($intended);
}
return Inertia::location(route('verification.notice')); return Inertia::location(route('verification.notice'));
} }
@@ -116,6 +125,29 @@ class AuthenticatedSessionController extends Controller
); );
} }
private function isVerificationLink(?string $target): bool
{
if (! is_string($target) || trim($target) === '') {
return false;
}
$path = trim($target);
if (str_starts_with($path, '/verify-email/')) {
return true;
}
$parsed = parse_url($path);
if ($parsed === false) {
return false;
}
$path = $parsed['path'] ?? '';
return $path !== '' && str_starts_with($path, '/verify-email/');
}
private function decodeReturnTo(string $value, Request $request): ?string private function decodeReturnTo(string $value, Request $request): ?string
{ {
$candidate = $this->decodeBase64Url($value) ?? $value; $candidate = $this->decodeBase64Url($value) ?? $value;
@@ -125,6 +157,10 @@ class AuthenticatedSessionController extends Controller
return null; return null;
} }
if (str_starts_with($candidate, '//')) {
return null;
}
if (str_starts_with($candidate, '/')) { if (str_starts_with($candidate, '/')) {
return $candidate; return $candidate;
} }
@@ -138,7 +174,7 @@ class AuthenticatedSessionController extends Controller
$appHost = parse_url($request->getSchemeAndHttpHost(), PHP_URL_HOST); $appHost = parse_url($request->getSchemeAndHttpHost(), PHP_URL_HOST);
if ($appHost && ! Str::endsWith($targetHost, $appHost)) { if (! $appHost || ! $this->isAllowedReturnHost($targetHost, $appHost)) {
return null; return null;
} }
@@ -190,7 +226,7 @@ class AuthenticatedSessionController extends Controller
$scheme = $parsed['scheme'] ?? null; $scheme = $parsed['scheme'] ?? null;
$requestHost = parse_url($request->getSchemeAndHttpHost(), PHP_URL_HOST); $requestHost = parse_url($request->getSchemeAndHttpHost(), PHP_URL_HOST);
if ($scheme && $host && $requestHost && ! Str::endsWith($host, $requestHost)) { if ($scheme && $host && $requestHost && ! $this->isAllowedReturnHost($host, $requestHost)) {
return '/event-admin/dashboard'; return '/event-admin/dashboard';
} }
@@ -233,6 +269,15 @@ class AuthenticatedSessionController extends Controller
return $decoded; return $decoded;
} }
private function isAllowedReturnHost(string $targetHost, string $appHost): bool
{
if ($targetHost === $appHost) {
return true;
}
return Str::endsWith($targetHost, '.'.$appHost);
}
private function rememberTenantAdminTarget(Request $request, ?string $target): void private function rememberTenantAdminTarget(Request $request, ?string $target): void
{ {
$user = Auth::user(); $user = Auth::user();

View File

@@ -15,8 +15,8 @@ use App\Models\Tenant;
use App\Models\User; use App\Models\User;
use App\Services\Checkout\CheckoutAssignmentService; use App\Services\Checkout\CheckoutAssignmentService;
use App\Services\Checkout\CheckoutSessionService; use App\Services\Checkout\CheckoutSessionService;
use App\Services\Paddle\Exceptions\PaddleException; use App\Services\LemonSqueezy\Exceptions\LemonSqueezyException;
use App\Services\Paddle\PaddleTransactionService; use App\Services\LemonSqueezy\LemonSqueezyOrderService;
use App\Support\CheckoutRequestContext; use App\Support\CheckoutRequestContext;
use App\Support\CheckoutRoutes; use App\Support\CheckoutRoutes;
use App\Support\Concerns\PresentsPackages; use App\Support\Concerns\PresentsPackages;
@@ -48,6 +48,9 @@ class CheckoutController extends Controller
$googleStatus = session()->pull('checkout_google_status'); $googleStatus = session()->pull('checkout_google_status');
$googleError = session()->pull('checkout_google_error'); $googleError = session()->pull('checkout_google_error');
$googleProfile = session()->pull('checkout_google_profile'); $googleProfile = session()->pull('checkout_google_profile');
$facebookStatus = session()->pull('checkout_facebook_status');
$facebookError = session()->pull('checkout_facebook_error');
$facebookProfile = session()->pull('checkout_facebook_profile');
$packageOptions = Package::orderBy('price')->get() $packageOptions = Package::orderBy('price')->get()
->map(fn (Package $pkg) => $this->presentPackage($pkg)) ->map(fn (Package $pkg) => $this->presentPackage($pkg))
@@ -66,9 +69,14 @@ class CheckoutController extends Controller
'error' => $googleError, 'error' => $googleError,
'profile' => $googleProfile, 'profile' => $googleProfile,
], ],
'paddle' => [ 'facebookAuth' => [
'environment' => config('paddle.environment'), 'status' => $facebookStatus,
'client_token' => config('paddle.client_token'), 'error' => $facebookError,
'profile' => $facebookProfile,
],
'lemonsqueezy' => [
'store_id' => config('lemonsqueezy.store_id'),
'test_mode' => config('lemonsqueezy.test_mode', false),
], ],
]); ]);
} }
@@ -108,8 +116,8 @@ class CheckoutController extends Controller
'settings' => json_encode([ 'settings' => json_encode([
'branding' => [ 'branding' => [
'logo_url' => null, 'logo_url' => null,
'primary_color' => '#3B82F6', 'primary_color' => '#FF5A5F',
'secondary_color' => '#1F2937', 'secondary_color' => '#FFF8F5',
'font_family' => 'Inter, sans-serif', 'font_family' => 'Inter, sans-serif',
], ],
'features' => [ 'features' => [
@@ -263,9 +271,9 @@ class CheckoutController extends Controller
CheckoutSession $session, CheckoutSession $session,
CheckoutSessionService $sessions, CheckoutSessionService $sessions,
CheckoutAssignmentService $assignment, CheckoutAssignmentService $assignment,
PaddleTransactionService $transactions, LemonSqueezyOrderService $orders,
): JsonResponse { ): JsonResponse {
$this->attemptPaddleRecovery($session, $sessions, $assignment, $transactions); $this->attemptLemonSqueezyRecovery($session, $sessions, $assignment, $orders);
$session->refresh(); $session->refresh();
@@ -280,56 +288,56 @@ class CheckoutController extends Controller
CheckoutSession $session, CheckoutSession $session,
CheckoutSessionService $sessions, CheckoutSessionService $sessions,
CheckoutAssignmentService $assignment, CheckoutAssignmentService $assignment,
PaddleTransactionService $transactions, LemonSqueezyOrderService $orders,
): JsonResponse { ): JsonResponse {
$validated = $request->validated(); $validated = $request->validated();
$transactionId = $validated['transaction_id'] ?? null; $orderId = $validated['order_id'] ?? null;
$checkoutId = $validated['checkout_id'] ?? null; $checkoutId = $validated['checkout_id'] ?? null;
$metadata = $session->provider_metadata ?? []; $metadata = $session->provider_metadata ?? [];
$metadataUpdated = false; $metadataUpdated = false;
if ($transactionId) { if ($orderId) {
$session->paddle_transaction_id = $transactionId; $session->lemonsqueezy_order_id = $orderId;
$metadata['paddle_transaction_id'] = $transactionId; $metadata['lemonsqueezy_order_id'] = $orderId;
$metadataUpdated = true; $metadataUpdated = true;
} }
if ($checkoutId) { if ($checkoutId) {
$metadata['paddle_checkout_id'] = $checkoutId; $metadata['lemonsqueezy_checkout_id'] = $checkoutId;
$metadataUpdated = true; $metadataUpdated = true;
} }
if ($metadataUpdated) { if ($metadataUpdated) {
$metadata['paddle_client_event_at'] = now()->toIso8601String(); $metadata['lemonsqueezy_client_event_at'] = now()->toIso8601String();
$session->provider_metadata = $metadata; $session->provider_metadata = $metadata;
$session->save(); $session->save();
} }
if (app()->environment('local') if (app()->environment('local')
&& $session->provider === CheckoutSession::PROVIDER_PADDLE && $session->provider === CheckoutSession::PROVIDER_LEMONSQUEEZY
&& ! in_array($session->status, [ && ! in_array($session->status, [
CheckoutSession::STATUS_COMPLETED, CheckoutSession::STATUS_COMPLETED,
CheckoutSession::STATUS_FAILED, CheckoutSession::STATUS_FAILED,
CheckoutSession::STATUS_CANCELLED, CheckoutSession::STATUS_CANCELLED,
], true) ], true)
&& ($transactionId || $checkoutId) && ($orderId || $checkoutId)
) { ) {
$sessions->markProcessing($session, array_filter([ $sessions->markProcessing($session, array_filter([
'paddle_status' => 'completed', 'lemonsqueezy_status' => 'paid',
'paddle_transaction_id' => $transactionId, 'lemonsqueezy_order_id' => $orderId,
'paddle_local_confirmed_at' => now()->toIso8601String(), 'lemonsqueezy_local_confirmed_at' => now()->toIso8601String(),
])); ]));
$assignment->finalise($session, [ $assignment->finalise($session, [
'source' => 'paddle_local', 'source' => 'lemonsqueezy_local',
'provider' => CheckoutSession::PROVIDER_PADDLE, 'provider' => CheckoutSession::PROVIDER_LEMONSQUEEZY,
'provider_reference' => $transactionId ?? $checkoutId, 'provider_reference' => $orderId ?? $checkoutId,
]); ]);
$sessions->markCompleted($session); $sessions->markCompleted($session);
} else { } else {
$this->attemptPaddleRecovery($session, $sessions, $assignment, $transactions); $this->attemptLemonSqueezyRecovery($session, $sessions, $assignment, $orders);
} }
$session->refresh(); $session->refresh();
@@ -411,13 +419,13 @@ class CheckoutController extends Controller
return $price <= 0; return $price <= 0;
} }
private function attemptPaddleRecovery( private function attemptLemonSqueezyRecovery(
CheckoutSession $session, CheckoutSession $session,
CheckoutSessionService $sessions, CheckoutSessionService $sessions,
CheckoutAssignmentService $assignment, CheckoutAssignmentService $assignment,
PaddleTransactionService $transactions LemonSqueezyOrderService $orders
): void { ): void {
if ($session->provider !== CheckoutSession::PROVIDER_PADDLE) { if ($session->provider !== CheckoutSession::PROVIDER_LEMONSQUEEZY) {
return; return;
} }
@@ -430,7 +438,7 @@ class CheckoutController extends Controller
} }
$metadata = $session->provider_metadata ?? []; $metadata = $session->provider_metadata ?? [];
$lastPollAt = $metadata['paddle_poll_at'] ?? null; $lastPollAt = $metadata['lemonsqueezy_poll_at'] ?? null;
$now = now(); $now = now();
if ($lastPollAt) { if ($lastPollAt) {
@@ -444,39 +452,31 @@ class CheckoutController extends Controller
} }
} }
$checkoutId = $metadata['paddle_checkout_id'] ?? $session->paddle_checkout_id ?? null; $checkoutId = $metadata['lemonsqueezy_checkout_id'] ?? $session->lemonsqueezy_checkout_id ?? null;
$transactionId = $metadata['paddle_transaction_id'] ?? $session->paddle_transaction_id ?? null; $orderId = $metadata['lemonsqueezy_order_id'] ?? $session->lemonsqueezy_order_id ?? null;
if (! $checkoutId && ! $transactionId) { if (! $checkoutId && ! $orderId) {
Log::info('[Checkout] Paddle recovery missing checkout reference, falling back to custom data scan', [ Log::info('[Checkout] Lemon Squeezy recovery missing checkout reference', [
'session_id' => $session->id, 'session_id' => $session->id,
]); ]);
} }
$metadata['paddle_poll_at'] = $now->toIso8601String(); $metadata['lemonsqueezy_poll_at'] = $now->toIso8601String();
$session->forceFill([ $session->forceFill([
'provider_metadata' => $metadata, 'provider_metadata' => $metadata,
])->save(); ])->save();
try { try {
$transaction = $transactionId ? $transactions->retrieve($transactionId) : null; $order = $orderId ? $orders->retrieve($orderId) : null;
if (! $transaction && $checkoutId) { if (! $order && $checkoutId) {
$transaction = $transactions->findByCheckoutId($checkoutId); $order = $orders->findByCheckoutId($checkoutId);
} }
} catch (LemonSqueezyException $exception) {
if (! $transaction) { Log::warning('[Checkout] Lemon Squeezy recovery failed', [
$transaction = $transactions->findByCustomData([
'checkout_session_id' => $session->id,
'package_id' => (string) $session->package_id,
'tenant_id' => (string) $session->tenant_id,
]);
}
} catch (PaddleException $exception) {
Log::warning('[Checkout] Paddle recovery failed', [
'session_id' => $session->id, 'session_id' => $session->id,
'checkout_id' => $checkoutId, 'checkout_id' => $checkoutId,
'transaction_id' => $transactionId, 'order_id' => $orderId,
'status' => $exception->status(), 'status' => $exception->status(),
'message' => $exception->getMessage(), 'message' => $exception->getMessage(),
'context' => $exception->context(), 'context' => $exception->context(),
@@ -484,77 +484,77 @@ class CheckoutController extends Controller
return; return;
} catch (\Throwable $exception) { } catch (\Throwable $exception) {
Log::warning('[Checkout] Paddle recovery failed', [ Log::warning('[Checkout] Lemon Squeezy recovery failed', [
'session_id' => $session->id, 'session_id' => $session->id,
'checkout_id' => $checkoutId, 'checkout_id' => $checkoutId,
'transaction_id' => $transactionId, 'order_id' => $orderId,
'message' => $exception->getMessage(), 'message' => $exception->getMessage(),
]); ]);
return; return;
} }
if (! $transaction) { if (! $order) {
Log::info('[Checkout] Paddle recovery: transaction not found', [ Log::info('[Checkout] Lemon Squeezy recovery: order not found', [
'session_id' => $session->id, 'session_id' => $session->id,
'checkout_id' => $checkoutId, 'checkout_id' => $checkoutId,
'transaction_id' => $transactionId, 'order_id' => $orderId,
]); ]);
return; return;
} }
$status = strtolower((string) ($transaction['status'] ?? '')); $status = strtolower((string) data_get($order, 'attributes.status', ''));
$transactionId = $transactionId ?: ($transaction['id'] ?? null); $resolvedOrderId = $orderId ?: data_get($order, 'id');
if ($transactionId && $session->paddle_transaction_id !== $transactionId) { if ($resolvedOrderId && $session->lemonsqueezy_order_id !== $resolvedOrderId) {
$session->forceFill([ $session->forceFill([
'paddle_transaction_id' => $transactionId, 'lemonsqueezy_order_id' => $resolvedOrderId,
])->save(); ])->save();
} }
if ($status === 'completed') { if (in_array($status, ['paid', 'completed'], true)) {
$sessions->markProcessing($session, [ $sessions->markProcessing($session, [
'paddle_status' => $status, 'lemonsqueezy_status' => $status,
'paddle_transaction_id' => $transactionId, 'lemonsqueezy_order_id' => $resolvedOrderId,
'paddle_recovered_at' => $now->toIso8601String(), 'lemonsqueezy_recovered_at' => $now->toIso8601String(),
]); ]);
$assignment->finalise($session, [ $assignment->finalise($session, [
'source' => 'paddle_poll', 'source' => 'lemonsqueezy_poll',
'provider' => CheckoutSession::PROVIDER_PADDLE, 'provider' => CheckoutSession::PROVIDER_LEMONSQUEEZY,
'provider_reference' => $transactionId, 'provider_reference' => $resolvedOrderId,
'payload' => $transaction, 'payload' => $order,
]); ]);
$sessions->markCompleted($session, $now); $sessions->markCompleted($session, $now);
Log::info('[Checkout] Paddle session recovered via API', [ Log::info('[Checkout] Lemon Squeezy session recovered via API', [
'session_id' => $session->id, 'session_id' => $session->id,
'checkout_id' => $checkoutId, 'checkout_id' => $checkoutId,
'transaction_id' => $transactionId, 'order_id' => $resolvedOrderId,
]); ]);
return; return;
} }
if (in_array($status, ['failed', 'cancelled', 'canceled'], true)) { if (in_array($status, ['failed', 'cancelled', 'canceled', 'refunded', 'voided'], true)) {
$sessions->markFailed($session, 'paddle_'.$status); $sessions->markFailed($session, 'lemonsqueezy_'.$status);
Log::info('[Checkout] Paddle transaction failed', [ Log::info('[Checkout] Lemon Squeezy order failed', [
'session_id' => $session->id, 'session_id' => $session->id,
'checkout_id' => $checkoutId, 'checkout_id' => $checkoutId,
'transaction_id' => $transactionId, 'order_id' => $resolvedOrderId,
'status' => $status, 'status' => $status,
]); ]);
return; return;
} }
Log::info('[Checkout] Paddle transaction pending', [ Log::info('[Checkout] Lemon Squeezy order pending', [
'session_id' => $session->id, 'session_id' => $session->id,
'checkout_id' => $checkoutId, 'checkout_id' => $checkoutId,
'transaction_id' => $transactionId, 'order_id' => $resolvedOrderId,
'status' => $status, 'status' => $status,
]); ]);
} }

View File

@@ -0,0 +1,217 @@
<?php
namespace App\Http\Controllers;
use App\Models\Package;
use App\Models\Tenant;
use App\Models\User;
use App\Support\CheckoutRoutes;
use App\Support\LocaleConfig;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Str;
use Laravel\Socialite\Facades\Socialite;
use Symfony\Component\HttpFoundation\RedirectResponse;
class CheckoutFacebookController extends Controller
{
private const SESSION_KEY = 'checkout_facebook_payload';
public function redirect(Request $request): RedirectResponse
{
$validated = $request->validate([
'package_id' => ['required', 'exists:packages,id'],
'locale' => ['nullable', 'string'],
]);
$payload = [
'package_id' => (int) $validated['package_id'],
'locale' => $validated['locale'] ?? app()->getLocale(),
];
$request->session()->put(self::SESSION_KEY, $payload);
$request->session()->put('selected_package_id', $payload['package_id']);
return Socialite::driver('facebook')
->redirectUrl(route('checkout.facebook.callback'))
->scopes(['email'])
->fields(['name', 'email', 'first_name', 'last_name'])
->redirect();
}
public function callback(Request $request): RedirectResponse
{
$payload = $request->session()->get(self::SESSION_KEY, []);
$packageId = $payload['package_id'] ?? null;
$locale = $payload['locale'] ?? null;
try {
$facebookUser = Socialite::driver('facebook')->user();
} catch (\Throwable $e) {
Log::warning('Facebook checkout login failed', ['message' => $e->getMessage()]);
$this->flashError($request, __('checkout.facebook_error_fallback'));
return $this->redirectBackToWizard($packageId, $locale);
}
$email = $facebookUser->getEmail();
if (! $email) {
$this->flashError($request, __('checkout.facebook_missing_email'));
return $this->redirectBackToWizard($packageId, $locale);
}
$raw = $facebookUser->getRaw();
$givenName = $raw['first_name'] ?? null;
$familyName = $raw['last_name'] ?? null;
$request->session()->put('checkout_facebook_profile', array_filter([
'email' => $email,
'name' => $facebookUser->getName(),
'given_name' => $givenName,
'family_name' => $familyName,
'avatar' => $facebookUser->getAvatar(),
'locale' => $raw['locale'] ?? null,
]));
$existing = User::where('email', $email)->first();
if (! $existing) {
$request->session()->put('checkout_facebook_profile', array_filter([
'email' => $email,
'name' => $facebookUser->getName(),
'given_name' => $givenName,
'family_name' => $familyName,
'avatar' => $facebookUser->getAvatar(),
'locale' => $raw['locale'] ?? null,
]));
$request->session()->put('checkout_facebook_status', 'prefill');
return $this->redirectBackToWizard($packageId, $locale);
}
$user = DB::transaction(function () use ($existing, $facebookUser, $email) {
$existing->forceFill([
'name' => $facebookUser->getName() ?: $existing->name,
'pending_purchase' => true,
'email_verified_at' => $existing->email_verified_at ?? now(),
])->save();
if (! $existing->tenant) {
$this->createTenantForUser($existing, $facebookUser->getName(), $email);
}
return $existing->fresh();
});
if (! $user->tenant) {
$this->createTenantForUser($user, $facebookUser->getName(), $email);
}
Auth::login($user, true);
$request->session()->regenerate();
$request->session()->forget(self::SESSION_KEY);
$request->session()->forget('checkout_facebook_profile');
$request->session()->put('checkout_facebook_status', 'signin');
if ($packageId) {
$this->ensurePackageAttached($user, (int) $packageId);
}
return $this->redirectBackToWizard($packageId, $locale);
}
private function createTenantForUser(User $user, ?string $displayName, string $email): Tenant
{
$tenantName = trim($displayName ?: Str::before($email, '@')) ?: 'Fotospiel Tenant';
$slugBase = Str::slug($tenantName) ?: 'tenant';
$slug = $slugBase;
$counter = 1;
while (Tenant::where('slug', $slug)->exists()) {
$slug = $slugBase.'-'.$counter;
$counter++;
}
$tenant = Tenant::create([
'user_id' => $user->id,
'name' => $tenantName,
'slug' => $slug,
'email' => $email,
'contact_email' => $email,
'is_active' => true,
'is_suspended' => false,
'subscription_tier' => 'free',
'subscription_status' => 'free',
'subscription_expires_at' => null,
'settings' => json_encode([
'branding' => [
'logo_url' => null,
'primary_color' => '#FF5A5F',
'secondary_color' => '#FFF8F5',
'font_family' => 'Inter, sans-serif',
],
'features' => [
'photo_likes_enabled' => false,
'event_checklist' => false,
'custom_domain' => false,
'advanced_analytics' => false,
],
'custom_domain' => null,
'contact_email' => $email,
'event_default_type' => 'general',
]),
]);
$user->forceFill(['tenant_id' => $tenant->id])->save();
return $tenant;
}
private function ensurePackageAttached(User $user, int $packageId): void
{
$tenant = $user->tenant;
if (! $tenant) {
return;
}
$package = Package::find($packageId);
if (! $package) {
return;
}
if ($tenant->packages()->where('package_id', $packageId)->exists()) {
return;
}
$tenant->packages()->attach($packageId, [
'price' => $package->price,
'purchased_at' => now(),
'expires_at' => now()->addYear(),
'active' => $package->price <= 0,
]);
}
private function redirectBackToWizard(?int $packageId, ?string $locale = null): RedirectResponse
{
if ($packageId) {
return redirect()->to(CheckoutRoutes::wizardUrl($packageId, $locale));
}
$firstPackageId = Package::query()->orderBy('price')->value('id');
if ($firstPackageId) {
return redirect()->to(CheckoutRoutes::wizardUrl($firstPackageId, $locale));
}
return redirect()->route('packages', [
'locale' => LocaleConfig::canonicalize($locale ?? app()->getLocale()),
]);
}
private function flashError(Request $request, string $message): void
{
$request->session()->flash('checkout_facebook_error', $message);
}
}

View File

@@ -35,6 +35,7 @@ class CheckoutGoogleController extends Controller
$request->session()->put('selected_package_id', $payload['package_id']); $request->session()->put('selected_package_id', $payload['package_id']);
return Socialite::driver('google') return Socialite::driver('google')
->redirectUrl(route('checkout.google.callback'))
->scopes(['email', 'profile']) ->scopes(['email', 'profile'])
->with(['prompt' => 'select_account']) ->with(['prompt' => 'select_account'])
->redirect(); ->redirect();
@@ -146,8 +147,8 @@ class CheckoutGoogleController extends Controller
'settings' => json_encode([ 'settings' => json_encode([
'branding' => [ 'branding' => [
'logo_url' => null, 'logo_url' => null,
'primary_color' => '#3B82F6', 'primary_color' => '#FF5A5F',
'secondary_color' => '#1F2937', 'secondary_color' => '#FFF8F5',
'font_family' => 'Inter, sans-serif', 'font_family' => 'Inter, sans-serif',
], ],
'features' => [ 'features' => [

Some files were not shown because too many files have changed in this diff Show More