Compare commits

...

161 Commits

Author SHA1 Message Date
Codex Agent
c96a73d884 Animate admin user menu sheet
Some checks are pending
linter / quality (push) Waiting to run
tests / ci (push) Waiting to run
tests / ui (push) Waiting to run
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
596 changed files with 498284 additions and 441686 deletions

View File

@@ -17,6 +17,7 @@
{"id":"fotospiel-app-38f","title":"Paddle catalog sync: surface last sync error/log context in admin","status":"closed","priority":2,"issue_type":"task","created_at":"2026-01-01T15:59:14.865414785+01:00","created_by":"soeren","updated_at":"2026-01-02T21:16:09.109922491+01:00","closed_at":"2026-01-02T21:16:09.109922491+01:00","close_reason":"Completed"}
{"id":"fotospiel-app-3ut","title":"SEC-API-03 Synthetic monitoring + alert config","status":"open","priority":2,"issue_type":"task","created_at":"2026-01-01T15:52:46.793875724+01:00","created_by":"soeren","updated_at":"2026-01-01T15:52:46.793875724+01:00"}
{"id":"fotospiel-app-3xa","title":"Security review: event admin code audit (policies, PKCE, file handling)","status":"open","priority":2,"issue_type":"task","created_at":"2026-01-01T16:05:20.115675149+01:00","created_by":"soeren","updated_at":"2026-01-01T16:05:20.115675149+01:00"}
{"id":"fotospiel-app-43mp","title":"Help-System für Event Admin PWA planen","notes":"Context help links wired into priority admin pages.","status":"in_progress","priority":2,"issue_type":"task","owner":"codex-agent@example.com","created_at":"2026-01-23T08:21:47.812129626+01:00","created_by":"Codex Agent","updated_at":"2026-01-23T09:19:45.828239299+01:00"}
{"id":"fotospiel-app-4ar","title":"SEC-BILL-03 Failed capture notifications + ledger hook","status":"open","priority":2,"issue_type":"task","created_at":"2026-01-01T15:54:33.266516715+01:00","created_by":"soeren","updated_at":"2026-01-01T15:54:33.266516715+01:00"}
{"id":"fotospiel-app-4en","title":"Add translations for Mobile Package Shop","description":"The new MobilePackageShopPage.tsx uses translation keys like 'shop.title', 'shop.legal.agb', etc. Ensure these are added to the management.json files for de and en.","status":"closed","priority":2,"issue_type":"task","created_at":"2026-01-06T18:05:50.469751088+01:00","created_by":"soeren","updated_at":"2026-01-06T18:14:19.984343737+01:00","closed_at":"2026-01-06T18:14:19.984346372+01:00"}
{"id":"fotospiel-app-4i4","title":"Security review: map roles/data","status":"closed","priority":2,"issue_type":"task","created_at":"2026-01-01T16:02:58.370301875+01:00","created_by":"soeren","updated_at":"2026-01-01T16:03:03.997327414+01:00","closed_at":"2026-01-01T16:03:03.997327414+01:00","close_reason":"Completed in codebase (verified)"}
@@ -37,6 +38,7 @@
{"id":"fotospiel-app-5ie","title":"Help docs: Live Show how-to + recommended hardware (DE/EN)","status":"closed","priority":2,"issue_type":"task","created_at":"2026-01-05T11:12:05.973844187+01:00","created_by":"soeren","updated_at":"2026-01-05T19:42:44.39939087+01:00","closed_at":"2026-01-05T19:42:44.39939087+01:00","close_reason":"Closed","dependencies":[{"issue_id":"fotospiel-app-5ie","depends_on_id":"fotospiel-app-vro","type":"blocks","created_at":"2026-01-05T11:13:54.925412888+01:00","created_by":"soeren"},{"issue_id":"fotospiel-app-5ie","depends_on_id":"fotospiel-app-539","type":"blocks","created_at":"2026-01-05T11:14:03.257649076+01:00","created_by":"soeren"}]}
{"id":"fotospiel-app-5iy","title":"Security review: confirm env/header defaults","status":"closed","priority":2,"issue_type":"task","created_at":"2026-01-01T16:03:20.808188183+01:00","created_by":"soeren","updated_at":"2026-01-01T16:03:26.388002115+01:00","closed_at":"2026-01-01T16:03:26.388002115+01:00","close_reason":"Completed in codebase (verified)"}
{"id":"fotospiel-app-5s3","title":"Localized SEO: canonical/hreflang tags + localized navigation","status":"closed","priority":2,"issue_type":"task","created_at":"2026-01-01T16:02:03.909947355+01:00","created_by":"soeren","updated_at":"2026-01-01T16:02:09.550647107+01:00","closed_at":"2026-01-01T16:02:09.550647107+01:00","close_reason":"Completed in codebase (verified)"}
{"id":"fotospiel-app-5veo","title":"Investigate vite build timeout","status":"open","priority":2,"issue_type":"task","owner":"codex-agent@example.com","created_at":"2026-01-21T12:49:14.166622473+01:00","created_by":"Codex Agent","updated_at":"2026-01-21T12:49:14.166622473+01:00"}
{"id":"fotospiel-app-5zl","title":"Ensure checkout step 3 requires login for Paddle checkout","description":"Problem: Paddle checkout on step 3 fails when user is not logged in. Step 3 must enforce authentication before initializing Paddle checkout.\\n\\nSuggestions:\\n- Protect step 3 route/controller with auth middleware and redirect to login with intended return URL.\\n- Gate step 3 UI/CTA on auth state; show inline login prompt and disable Paddle until authenticated.\\n- Require auth in backend endpoint that creates Paddle transaction/session; return 401 and send user to login.\\n- Optionally preflight at end of step 2 to prompt login before advancing.","status":"closed","priority":2,"issue_type":"task","created_at":"2026-01-04T12:31:43.215017311+01:00","created_by":"soeren","updated_at":"2026-01-04T12:42:45.088723058+01:00","closed_at":"2026-01-04T12:42:45.088723058+01:00","close_reason":"Closed"}
{"id":"fotospiel-app-64l","title":"SEC-FE-01 CSP nonce/hashing rollout","status":"closed","priority":2,"issue_type":"task","created_at":"2026-01-01T15:54:47.607047443+01:00","created_by":"soeren","updated_at":"2026-01-01T15:55:56.477104351+01:00","closed_at":"2026-01-01T15:55:56.477104351+01:00","close_reason":"Completed in codebase (verified) - duplicate of fotospiel-app-zli"}
{"id":"fotospiel-app-6dp","title":"Coupon ops enhancements (redemption service, preview endpoint, widget, export)","status":"closed","priority":2,"issue_type":"task","created_at":"2026-01-01T16:09:09.275919717+01:00","created_by":"soeren","updated_at":"2026-01-01T16:09:14.882264149+01:00","closed_at":"2026-01-01T16:09:14.882264149+01:00","close_reason":"Completed in codebase (verified)"}

View File

@@ -1 +0,0 @@
fotospiel-app-spq8

View File

@@ -1,4 +1,5 @@
{
"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_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_ENABLE_TENANT_SWITCHER=false
REVENUECAT_WEBHOOK_SECRET=
@@ -187,5 +192,9 @@ STORAGE_QUEUE_PENDING_EVENT_MINUTES=8
STORAGE_QUEUE_FAILED_EVENT_THRESHOLD=2
STORAGE_QUEUE_FAILED_EVENT_MINUTES=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

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 isChrome = typeof navigator < "u" && /Chrome/.test(navigator.userAgent || "");
var isWebTouchable = isClient && ("ontouchstart" in window || navigator.maxTouchPoints > 0);
var isAndroid = false;
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
var import_react2 = __toESM(require("react"), 1);
var Decorated = Symbol();
@@ -755,7 +444,10 @@ var SizableText2 = (0, import_web4.styled)(import_web4.Text, {
}
});
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";
return getFontSized(size, extras);
}, "...")

View File

@@ -112,7 +112,10 @@ var SizableText2 = (0, import_web2.styled)(import_web2.Text, {
}
});
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";
return getFontSized(size, extras);
}, "...")

View File

@@ -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/pages/ — Inertia pages (React).
- 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
- Coding tasks (Codegen Agent):

View File

@@ -100,6 +100,8 @@ COPY . .
COPY --from=vendor /var/www/html/vendor ./vendor
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 \
&& php artisan config:cache \
&& php artisan route:clear \

View File

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

View File

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

View File

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

View File

@@ -46,6 +46,12 @@ class MonitorStorageCommand extends Command
$assetStats = $this->buildAssetStatistics();
$thresholds = $this->capacityThresholds();
$checksumConfig = $this->checksumAlertConfig();
$checksumWindowMinutes = $checksumConfig['window_minutes'];
$checksumThresholds = $checksumConfig['thresholds'];
$checksumMismatches = $checksumConfig['enabled'] && $checksumWindowMinutes > 0
? $this->checksumMismatchCounts($checksumWindowMinutes)
: [];
$alerts = [];
$snapshotTargets = [];
@@ -78,6 +84,7 @@ class MonitorStorageCommand extends Command
];
}
$targetChecksumMismatches = $checksumMismatches[$target->id] ?? 0;
$snapshotTargets[] = [
'id' => $target->id,
'key' => $target->key,
@@ -85,13 +92,35 @@ class MonitorStorageCommand extends Command
'is_hot' => (bool) $target->is_hot,
'capacity' => $capacity,
'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 = [
'generated_at' => now()->toIso8601String(),
'targets' => $snapshotTargets,
'alerts' => $alerts,
'checksum' => [
'window_minutes' => $checksumWindowMinutes,
'mismatch_total' => array_sum($checksumMismatches),
],
];
$ttlMinutes = max(1, (int) config('storage-monitor.monitor.cache_minutes', 15));
@@ -191,4 +220,62 @@ class MonitorStorageCommand extends Command
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

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

View File

@@ -12,7 +12,7 @@ use Illuminate\Support\Str;
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.';
@@ -20,6 +20,17 @@ class SyncGoogleFonts extends Command
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');
if (! $apiKey) {
@@ -32,16 +43,10 @@ class SyncGoogleFonts extends Command
$weights = $this->prepareWeights($this->option('weights'));
$includeItalic = (bool) $this->option('italic');
$force = (bool) $this->option('force');
$dryRun = (bool) $this->option('dry-run');
$families = $this->normalizeFamilyOption($this->option('family'));
$categories = $this->prepareCategories($this->option('category'));
$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)) {
$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'));
@@ -206,6 +211,204 @@ class SyncGoogleFonts extends Command
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>
*/

View File

@@ -6,4 +6,4 @@ enum PackageType: string
{
case ENDCUSTOMER = 'endcustomer';
case RESELLER = 'reseller';
}
}

View File

@@ -5,8 +5,6 @@ namespace App\Exports;
use App\Models\EventPurchase;
use Filament\Actions\Exports\Exporter;
use Filament\Actions\Exports\Models\Export;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Support\Collection;
class EventPurchaseExporter extends Exporter
{
@@ -28,11 +26,10 @@ class EventPurchaseExporter extends Exporter
];
}
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.";
return $body;
}
}
}

View File

@@ -16,4 +16,4 @@ class ListCategories extends ListRecords
Actions\CreateAction::make(),
];
}
}
}

View File

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

View File

@@ -16,4 +16,4 @@ class ListPosts extends ListRecords
Actions\CreateAction::make(),
];
}
}
}

View File

@@ -8,4 +8,4 @@ use Filament\Resources\Pages\ViewRecord;
class ViewPost extends ViewRecord
{
protected static string $resource = PostResource::class;
}
}

View File

@@ -26,4 +26,4 @@ trait HasContentEditor
'h3',
]));
}
}
}

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

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

View File

@@ -16,4 +16,4 @@ class ListEventPurchases extends ListRecords
Actions\CreateAction::make(),
];
}
}
}

View File

@@ -60,19 +60,32 @@ class EventResource extends Resource
->required()
->unique(ignoreRecord: true)
->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')
->label(__('admin.events.fields.date'))
->required(),
Select::make('event_type_id')
->label(__('admin.events.fields.type'))
->options(EventType::query()->pluck('name', 'id'))
->options(fn () => EventType::all()->pluck('name.de', 'id'))
->searchable(),
Select::make('package_id')
->label(__('admin.events.fields.package'))
->options(\App\Models\Package::query()->where('type', 'endcustomer')->pluck('name', 'id'))
->searchable()
->preload()
->required(),
->required()
->visibleOn('create'),
TextInput::make('default_locale')
->label(__('admin.events.fields.default_locale'))
->default('de')
@@ -96,13 +109,13 @@ class EventResource extends Resource
->columns([
Tables\Columns\TextColumn::make('id')->sortable(),
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'))
->formatStateUsing(fn (mixed $state): string => static::formatEventName($state))
->limit(30),
Tables\Columns\TextColumn::make('slug')->searchable(),
Tables\Columns\TextColumn::make('date')->date(),
Tables\Columns\IconColumn::make('is_active')->boolean(),
Tables\Columns\TextColumn::make('default_locale'),
Tables\Columns\TextColumn::make('eventPackage.package.name')
->label(__('admin.events.table.package'))
->badge()
@@ -115,22 +128,6 @@ class EventResource extends Resource
->badge()
->color(fn ($state) => $state < 1 ? 'danger' : 'success')
->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(),
])
->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
{
return [

View File

@@ -8,4 +8,25 @@ use App\Filament\Resources\Pages\AuditedCreateRecord;
class CreateEvent extends AuditedCreateRecord
{
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\Collection;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\Relation;
class EventPackagesRelationManager extends RelationManager
{
@@ -59,6 +58,7 @@ class EventPackagesRelationManager extends RelationManager
public function table(Table $table): Table
{
return $table
->modifyQueryUsing(fn (Builder $query) => $query->with('package'))
->recordTitleAttribute('package.name')
->columns([
TextColumn::make('package.name')
@@ -147,9 +147,4 @@ class EventPackagesRelationManager extends RelationManager
{
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),
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([
Actions\DeleteBulkAction::make()
->after(function (Collection $records): void {
->action(function (Collection $records, Actions\DeleteBulkAction $action) {
$logger = app(SuperAdminAuditLogger::class);
$deletedCount = 0;
$failedCount = 0;
foreach ($records as $record) {
$logger->recordModelMutation(
'deleted',
$record,
source: static::class
);
try {
$record->delete();
$logger->recordModelMutation('deleted', $record, 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

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

View File

@@ -16,4 +16,4 @@ class ListPackages extends ListRecords
Actions\CreateAction::make(),
];
}
}
}

View File

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

View File

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

View File

@@ -16,4 +16,4 @@ class ListPurchases extends ListRecords
Actions\CreateAction::make(),
];
}
}
}

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\Schemas\TenantInfolist;
use App\Jobs\AnonymizeAccount;
use App\Models\Package;
use App\Models\Tenant;
use App\Notifications\InactiveTenantDeletionWarning;
use App\Services\Audit\SuperAdminAuditLogger;
@@ -205,11 +206,13 @@ class TenantResource extends Resource
Forms\Components\Textarea::make('reason')->label('Grund')->rows(3),
])
->action(function (Tenant $record, array $data) {
$package = Package::query()->find($data['package_id']);
\App\Models\TenantPackage::create([
'tenant_id' => $record->id,
'package_id' => $data['package_id'],
'expires_at' => $data['expires_at'],
'active' => true,
'price' => $package?->price ?? 0,
'reason' => $data['reason'] ?? null,
]);
\App\Models\PackagePurchase::create([

View File

@@ -3,39 +3,78 @@
namespace App\Filament\SuperAdmin\Pages\Auth;
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\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 Illuminate\Support\Facades\Log;
class EditProfile extends BaseEditProfile
{
public function mount(): void
protected function getPasswordConfirmationFormComponent(): Component
{
Log::info('EditProfile class loaded for superadmin');
parent::mount();
return TextInput::make('passwordConfirmation')
->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
{
return $schema
->schema([
$this->getNameFormComponent(),
$this->getEmailFormComponent(),
TextInput::make('username')
->required()
->unique(ignoreRecord: true)
->maxLength(255),
Select::make('preferred_locale')
->options([
'de' => 'Deutsch',
'en' => 'English',
Section::make('Profile')
->schema([
$this->getNameFormComponent(),
$this->getEmailFormComponent(),
TextInput::make('username')
->required()
->unique(ignoreRecord: true)
->maxLength(255),
Select::make('preferred_locale')
->options([
'de' => 'Deutsch',
'en' => 'English',
])
->default('de')
->required(),
])
->default('de')
->required(),
$this->getPasswordFormComponent(),
$this->getPasswordConfirmationFormComponent(),
$this->getCurrentPasswordFormComponent(),
->columns(2),
Section::make('Security')
->schema([
$this->getPasswordFormComponent(),
$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

@@ -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
{
$projects = $this->loadProjects();
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
{
$client = app(DokployClient::class);
@@ -62,7 +139,7 @@ class DokployPlatformHealth extends Widget
'label' => 'Dokploy',
'compose_id' => '-',
'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;
}
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
{
return collect($services)

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -185,6 +185,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);
if (isset($event->status)) {
@@ -1042,12 +1093,8 @@ class EventPublicController extends BaseController
$brandingAllowed = $this->determineBrandingAllowed($event);
$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));
$sources = $brandingAllowed
? ($useDefault ? [$tenantBranding] : [$eventBranding, $tenantBranding])
: [[]];
$sources = $brandingAllowed ? [$eventBranding] : [[]];
$primary = $this->normalizeHexColor(
$this->firstStringFromSources($sources, ['palette.primary', 'primary_color']),
@@ -1906,7 +1953,9 @@ class EventPublicController extends BaseController
$policy = $this->guestPolicy();
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);
@@ -2921,6 +2970,12 @@ class EventPublicController extends BaseController
$policy = $this->guestPolicy();
$uploadVisibility = Arr::get($eventModel->settings ?? [], 'guest_upload_visibility', $policy->guest_upload_visibility);
$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;
@@ -2953,6 +3008,34 @@ class EventPublicController extends BaseController
->resolveEventPackageForPhotoUpload($tenantModel, $eventId, $eventModel);
$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));
$deviceCount = DB::table('photos')->where('event_id', $eventId)->where('guest_name', $deviceId)->count();
@@ -3037,10 +3120,21 @@ class EventPublicController extends BaseController
$liveApprovedAt = null;
$liveReviewedAt = null;
$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();
if ($liveModerationMode === 'off') {
if ($autoAddApprovedToLive) {
$liveStatus = PhotoLiveStatus::APPROVED->value;
$liveApprovedAt = $liveSubmittedAt;
$liveReviewedAt = $liveSubmittedAt;
} elseif ($liveModerationMode === 'off') {
$liveStatus = PhotoLiveStatus::APPROVED->value;
$liveApprovedAt = $liveSubmittedAt;
$liveReviewedAt = $liveSubmittedAt;
@@ -3048,6 +3142,12 @@ class EventPublicController extends BaseController
$liveStatus = PhotoLiveStatus::PENDING->value;
}
}
if ($isForceReviewUploader) {
$liveStatus = PhotoLiveStatus::REJECTED->value;
$liveSubmittedAt = null;
$liveApprovedAt = null;
$liveReviewedAt = now();
}
$photoId = DB::table('photos')->insertGetId([
'event_id' => $eventId,
@@ -3071,6 +3171,7 @@ class EventPublicController extends BaseController
'emotion_id' => $this->resolveEmotionId($validated, $eventId),
'is_featured' => 0,
'metadata' => null,
'security_meta' => $securityMetaValue,
'created_at' => now(),
'updated_at' => now(),
]);

View File

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

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

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

View File

@@ -161,11 +161,13 @@ class EventController extends Controller
]);
}
$resolvedName = $this->resolveEventNameString($validated['name']);
$eventData = array_merge($validated, [
'tenant_id' => $tenantId,
'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'])) {
$eventData['date'] = $eventData['event_date'];
@@ -228,7 +230,7 @@ class EventController extends Controller
]);
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->consumeEventAllowanceFor($eventServicePackage->slug, 1, 'event.create', $note)) {
throw new HttpException(402, 'Insufficient package allowance.');
@@ -404,9 +406,13 @@ class EventController extends Controller
unset($validated['event_date']);
}
if ($nameProvided && $validated['name'] !== $event->name) {
$validated['slug'] = $this->generateUniqueSlug($validated['name'], $tenantId, $event->id);
$currentName = $this->resolveEventNameString($event->name);
$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) {
unset($validated[$unused]);
@@ -935,6 +941,45 @@ class EventController extends Controller
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
{
$tenantId = $request->attributes->get('tenant_id');

View File

@@ -22,7 +22,14 @@ class EventJoinTokenLayoutController extends Controller
*/
private const BACKGROUND_PRESETS = [
'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-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',
];

View File

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

View File

@@ -20,6 +20,8 @@ use App\Support\WatermarkConfigResolver;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\AnonymousResourceCollection;
use Illuminate\Http\UploadedFile;
use Illuminate\Support\Arr;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Facades\Storage;
@@ -114,6 +116,7 @@ class PhotoController extends Controller
$event = Event::where('slug', $eventSlug)
->where('tenant_id', $tenantId)
->firstOrFail();
TenantMemberPermissions::ensureEventPermission($request, $event, 'photos:moderate');
if ($photo->event_id !== $event->id) {
return ApiError::response(
@@ -131,6 +134,11 @@ class PhotoController extends Controller
$photo->status = $validated['visible'] ? 'approved' : 'hidden';
$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');
return response()->json([
@@ -315,7 +323,7 @@ class PhotoController extends Controller
$disk = $this->eventStorageManager->getHotDiskForEvent($event);
// Generate unique filename
$extension = $file->getClientOriginalExtension();
$extension = $this->resolvePhotoExtension($file);
$filename = Str::uuid().'.'.$extension;
$path = "events/{$eventSlug}/photos/{$filename}";
@@ -531,6 +539,11 @@ class PhotoController extends Controller
$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') {
$photo->load('event')->loadCount('likes');
// Trigger event for new photo notification
@@ -552,6 +565,7 @@ class PhotoController extends Controller
$event = Event::where('slug', $eventSlug)
->where('tenant_id', $tenantId)
->firstOrFail();
TenantMemberPermissions::ensureEventPermission($request, $event, 'photos:moderate');
if ($photo->event_id !== $event->id) {
return ApiError::response(
@@ -768,6 +782,7 @@ class PhotoController extends Controller
$event = Event::where('slug', $eventSlug)
->where('tenant_id', $tenantId)
->firstOrFail();
TenantMemberPermissions::ensureEventPermission($request, $event, 'photos:moderate');
$photos = Photo::where('event_id', $event->id)
->where('status', 'pending')
@@ -1032,4 +1047,23 @@ class PhotoController extends Controller
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

@@ -91,14 +91,14 @@ class PhotoboothController extends Controller
$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);
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);

View File

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

View File

@@ -10,6 +10,7 @@ use App\Models\Event;
use App\Models\Task;
use App\Models\TaskCollection;
use App\Models\Tenant;
use App\Services\Packages\PackageLimitEvaluator;
use App\Support\ApiError;
use App\Support\TenantMemberPermissions;
use App\Support\TenantRequestResolver;
@@ -20,6 +21,8 @@ use Symfony\Component\HttpFoundation\Response;
class TaskController extends Controller
{
public function __construct(private readonly PackageLimitEvaluator $packageLimitEvaluator) {}
/**
* Display a listing of the tenant's tasks.
*/
@@ -163,7 +166,8 @@ class TaskController extends Controller
{
TenantMemberPermissions::ensureEventPermission($request, $event, 'tasks:manage');
$tenantId = $this->currentTenant($request)->id;
$tenant = $this->currentTenant($request);
$tenantId = $tenant->id;
if (($task->tenant_id && $task->tenant_id !== $tenantId) || $event->tenant_id !== $tenantId) {
abort(404);
@@ -173,6 +177,11 @@ class TaskController extends Controller
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);
return response()->json([
@@ -187,7 +196,8 @@ class TaskController extends Controller
{
TenantMemberPermissions::ensureEventPermission($request, $event, 'tasks:manage');
$tenantId = $this->currentTenant($request)->id;
$tenant = $this->currentTenant($request);
$tenantId = $tenant->id;
if ($event->tenant_id !== $tenantId) {
abort(404);
@@ -203,12 +213,27 @@ class TaskController extends Controller
);
}
$taskIds = array_values(array_unique(array_map('intval', $taskIds)));
$tasks = Task::whereIn('id', $taskIds)
->where(function ($query) use ($tenantId) {
$query->whereNull('tenant_id')->orWhere('tenant_id', $tenantId);
})
->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;
foreach ($tasks as $task) {
if (! $task->assignedEvents()->where('event_id', $event->id)->exists()) {
@@ -330,6 +355,52 @@ class TaskController extends Controller
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
{
if (array_key_exists('title', $data)) {

View File

@@ -157,6 +157,10 @@ class AuthenticatedSessionController extends Controller
return null;
}
if (str_starts_with($candidate, '//')) {
return null;
}
if (str_starts_with($candidate, '/')) {
return $candidate;
}
@@ -170,7 +174,7 @@ class AuthenticatedSessionController extends Controller
$appHost = parse_url($request->getSchemeAndHttpHost(), PHP_URL_HOST);
if ($appHost && ! Str::endsWith($targetHost, $appHost)) {
if (! $appHost || ! $this->isAllowedReturnHost($targetHost, $appHost)) {
return null;
}
@@ -222,7 +226,7 @@ class AuthenticatedSessionController extends Controller
$scheme = $parsed['scheme'] ?? null;
$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';
}
@@ -265,6 +269,15 @@ class AuthenticatedSessionController extends Controller
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
{
$user = Auth::user();

View File

@@ -48,6 +48,9 @@ class CheckoutController extends Controller
$googleStatus = session()->pull('checkout_google_status');
$googleError = session()->pull('checkout_google_error');
$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()
->map(fn (Package $pkg) => $this->presentPackage($pkg))
@@ -66,6 +69,11 @@ class CheckoutController extends Controller
'error' => $googleError,
'profile' => $googleProfile,
],
'facebookAuth' => [
'status' => $facebookStatus,
'error' => $facebookError,
'profile' => $facebookProfile,
],
'paddle' => [
'environment' => config('paddle.environment'),
'client_token' => config('paddle.client_token'),

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']);
return Socialite::driver('google')
->redirectUrl(route('checkout.google.callback'))
->scopes(['email', 'profile'])
->with(['prompt' => 'select_account'])
->redirect();

View File

@@ -69,7 +69,7 @@ class LegalPageController extends Controller
$effectiveFrom = optional($page->effective_from);
return Inertia::render('legal/Show', [
'seoTitle' => $title . ' - ' . config('app.name', 'Fotospiel'),
'seoTitle' => $title.' - '.config('app.name', 'Fotospiel'),
'title' => $title,
'content' => $this->convertMarkdownToHtml($bodyMarkdown),
'effectiveFrom' => $effectiveFrom ? $effectiveFrom->toDateString() : null,
@@ -112,11 +112,11 @@ class LegalPageController extends Controller
'allow_unsafe_links' => false,
]);
$environment->addExtension(new CommonMarkCoreExtension());
$environment->addExtension(new TableExtension());
$environment->addExtension(new AutolinkExtension());
$environment->addExtension(new StrikethroughExtension());
$environment->addExtension(new TaskListExtension());
$environment->addExtension(new CommonMarkCoreExtension);
$environment->addExtension(new TableExtension);
$environment->addExtension(new AutolinkExtension);
$environment->addExtension(new StrikethroughExtension);
$environment->addExtension(new TaskListExtension);
$converter = new MarkdownConverter($environment);

View File

@@ -64,7 +64,6 @@ class MarketingController extends Controller
'name' => 'required|string|max:255',
'email' => 'required|email|max:255',
'message' => 'required|string|max:1000',
'nickname' => 'present|size:0',
]);
$locale = app()->getLocale();
@@ -409,10 +408,17 @@ class MarketingController extends Controller
public function demo()
{
$joinToken = optional(Event::firstWhere('slug', 'demo-wedding-2025'))
?->joinTokens()
$event = Event::query()
->where('settings->marketing_demo', true)
->latest('id')
->first();
$joinToken = null;
if ($event) {
$joinToken = $event->joinTokens()
->latest('id')
->first();
}
$demoToken = null;

View File

@@ -35,7 +35,7 @@ class EventPhotoArchiveController extends Controller
abort(404, 'No approved photos available for this event.');
}
$zip = new ZipArchive();
$zip = new ZipArchive;
$tempPath = tempnam(sys_get_temp_dir(), 'fotospiel-photos-');
if ($tempPath === false || $zip->open($tempPath, ZipArchive::CREATE | ZipArchive::OVERWRITE) !== true) {
@@ -129,4 +129,3 @@ class EventPhotoArchiveController extends Controller
return false;
}
}

View File

@@ -0,0 +1,129 @@
<?php
namespace App\Http\Controllers;
use App\Models\User;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Str;
use Laravel\Socialite\Facades\Socialite;
use Symfony\Component\HttpFoundation\RedirectResponse;
use Throwable;
class TenantAdminFacebookController extends Controller
{
public function redirect(Request $request): RedirectResponse
{
$returnTo = $request->query('return_to');
if (is_string($returnTo) && $returnTo !== '') {
$request->session()->put('tenant_oauth_return_to', $returnTo);
}
return Socialite::driver('facebook')
->redirectUrl(route('tenant.admin.facebook.callback'))
->scopes(['email'])
->fields(['name', 'email', 'first_name', 'last_name'])
->redirect();
}
public function callback(Request $request): RedirectResponse
{
try {
$facebookUser = Socialite::driver('facebook')->user();
} catch (Throwable $exception) {
Log::warning('Tenant admin Facebook sign-in failed', [
'message' => $exception->getMessage(),
]);
return $this->sendBackWithError($request, 'facebook_failed', 'Unable to complete Facebook sign-in.');
}
$email = $facebookUser->getEmail();
if (! $email) {
return $this->sendBackWithError($request, 'facebook_failed', 'Facebook account did not provide an email address.');
}
/** @var User|null $user */
$user = User::query()->where('email', $email)->first();
if (! $user || ! in_array($user->role, ['tenant_admin', 'super_admin', 'superadmin'], true)) {
return $this->sendBackWithError($request, 'facebook_no_match', 'No tenant admin account is linked to this Facebook address.');
}
$user->forceFill([
'name' => $facebookUser->getName() ?: $user->name,
'email_verified_at' => $user->email_verified_at ?? now(),
])->save();
Auth::login($user, true);
$request->session()->regenerate();
$request->session()->forget('url.intended');
$returnTo = $request->session()->pull('tenant_oauth_return_to');
if (is_string($returnTo)) {
$decoded = $this->decodeReturnTo($returnTo, $request);
if ($decoded) {
return redirect()->to($decoded);
}
}
$fallback = $request->session()->pull('tenant_admin.return_to');
if (is_string($fallback) && str_starts_with($fallback, '/event-admin')) {
return redirect()->to($fallback);
}
return redirect()->to('/event-admin/dashboard');
}
private function sendBackWithError(Request $request, string $code, string $message): RedirectResponse
{
$query = [
'error' => $code,
'error_description' => $message,
];
if ($request->session()->has('tenant_oauth_return_to')) {
$query['return_to'] = $request->session()->get('tenant_oauth_return_to');
}
return redirect()->route('tenant.admin.login', $query);
}
private function decodeReturnTo(string $encoded, Request $request): ?string
{
$padded = str_pad($encoded, strlen($encoded) + ((4 - (strlen($encoded) % 4)) % 4), '=');
$normalized = strtr($padded, '-_', '+/');
$decoded = base64_decode($normalized);
if (! is_string($decoded) || $decoded === '') {
return null;
}
if (str_starts_with($decoded, '//')) {
return null;
}
if (str_starts_with($decoded, '/')) {
return $decoded;
}
$targetHost = parse_url($decoded, PHP_URL_HOST);
$appHost = parse_url($request->getSchemeAndHttpHost(), PHP_URL_HOST);
if (! $targetHost || ! $appHost || ! $this->isAllowedReturnHost($targetHost, $appHost)) {
return null;
}
return $decoded;
}
private function isAllowedReturnHost(string $targetHost, string $appHost): bool
{
if ($targetHost === $appHost) {
return true;
}
return Str::endsWith($targetHost, '.'.$appHost);
}
}

View File

@@ -21,6 +21,7 @@ class TenantAdminGoogleController extends Controller
}
return Socialite::driver('google')
->redirectUrl(route('tenant.admin.google.callback'))
->scopes(['openid', 'profile', 'email'])
->with(['prompt' => 'select_account'])
->redirect();
@@ -57,6 +58,7 @@ class TenantAdminGoogleController extends Controller
Auth::login($user, true);
$request->session()->regenerate();
$request->session()->forget('url.intended');
$returnTo = $request->session()->pull('tenant_oauth_return_to');
if (is_string($returnTo)) {
@@ -66,7 +68,12 @@ class TenantAdminGoogleController extends Controller
}
}
return redirect()->intended('/event-admin/dashboard');
$fallback = $request->session()->pull('tenant_admin.return_to');
if (is_string($fallback) && str_starts_with($fallback, '/event-admin')) {
return redirect()->to($fallback);
}
return redirect()->to('/event-admin/dashboard');
}
private function sendBackWithError(Request $request, string $code, string $message): RedirectResponse
@@ -93,13 +100,30 @@ class TenantAdminGoogleController extends Controller
return null;
}
if (str_starts_with($decoded, '//')) {
return null;
}
if (str_starts_with($decoded, '/')) {
return $decoded;
}
$targetHost = parse_url($decoded, PHP_URL_HOST);
$appHost = parse_url($request->getSchemeAndHttpHost(), PHP_URL_HOST);
if ($targetHost && $appHost && ! Str::endsWith($targetHost, $appHost)) {
if (! $targetHost || ! $appHost || ! $this->isAllowedReturnHost($targetHost, $appHost)) {
return null;
}
return $decoded;
}
private function isAllowedReturnHost(string $targetHost, string $appHost): bool
{
if ($targetHost === $appHost) {
return true;
}
return Str::endsWith($targetHost, '.'.$appHost);
}
}

View File

@@ -118,11 +118,18 @@ class ContentSecurityPolicy
$styleSources[] = 'data:';
$connectSources[] = 'https:';
$fontSources[] = 'https:';
$styleElemSources = array_values(array_filter(
$styleSources,
static fn (string $source): bool => ! str_starts_with($source, "'nonce-")
));
$styleElemSources = array_unique(array_merge($styleElemSources, ["'unsafe-inline'"]));
$directives = [
'default-src' => ["'self'"],
'script-src' => array_unique($scriptSources),
'style-src' => array_unique($styleSources),
'style-src-elem' => $styleElemSources,
'style-src-attr' => ["'unsafe-inline'"],
'img-src' => array_unique($imgSources),
'font-src' => array_unique($fontSources),
'connect-src' => array_unique($connectSources),

View File

@@ -0,0 +1,66 @@
<?php
namespace App\Http\Middleware;
use App\Support\ApiError;
use Closure;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth;
use Laravel\Sanctum\PersonalAccessToken;
use Symfony\Component\HttpFoundation\Response;
class EnsureSupportToken
{
/**
* Handle an incoming request.
*/
public function handle(Request $request, Closure $next): JsonResponse|Response
{
$user = $request->user();
if (! $user) {
return $this->unauthorizedResponse('Unauthenticated request.');
}
$accessToken = $user->currentAccessToken();
if (! $accessToken instanceof PersonalAccessToken) {
return $this->unauthorizedResponse('Missing personal access token context.');
}
if (! $user->isSuperAdmin()) {
return $this->forbiddenResponse('Only super administrators may access support APIs.');
}
if (! $accessToken->can('support-admin') && ! $accessToken->can('super-admin')) {
return $this->forbiddenResponse('Access token does not include the support-admin ability.');
}
$request->attributes->set('support_token_id', $accessToken->id);
Auth::shouldUse('sanctum');
return $next($request);
}
private function unauthorizedResponse(string $message): JsonResponse
{
return ApiError::response(
'unauthenticated',
'Unauthenticated',
$message,
Response::HTTP_UNAUTHORIZED
);
}
private function forbiddenResponse(string $message): JsonResponse
{
return ApiError::response(
'support_forbidden',
'Forbidden',
$message,
Response::HTTP_FORBIDDEN
);
}
}

View File

@@ -6,6 +6,7 @@ use App\Support\LocaleConfig;
use Illuminate\Foundation\Inspiring;
use Illuminate\Http\Request;
use Inertia\Middleware;
use Spatie\Honeypot\Honeypot;
class HandleInertiaRequests extends Middleware
{
@@ -67,6 +68,7 @@ class HandleInertiaRequests extends Middleware
'error' => fn () => $request->session()->get('error'),
'verification' => fn () => $request->session()->get('verification'),
],
'honeypot' => fn () => new Honeypot(config('honeypot')),
];
}
}

View File

@@ -21,7 +21,7 @@ class SetLocale
$sessionLocale = Session::get('locale', 'de');
// Fallback to Accept-Language header if no session
if (!in_array($sessionLocale, $supportedLocales)) {
if (! in_array($sessionLocale, $supportedLocales)) {
$acceptLanguage = $request->header('Accept-Language', 'de');
$localeFromHeader = substr($acceptLanguage, 0, 2);
$sessionLocale = in_array($localeFromHeader, $supportedLocales) ? $localeFromHeader : 'de';

View File

@@ -2,11 +2,11 @@
namespace App\Http\Middleware;
use App\Support\LocaleConfig;
use Closure;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\App;
use Illuminate\Support\Facades\Session;
use App\Support\LocaleConfig;
class SetLocaleFromRequest
{

View File

@@ -19,4 +19,3 @@ class SetLocaleFromUser
return $next($request);
}
}

View File

@@ -57,8 +57,3 @@ class ProfileUpdateRequest extends FormRequest
];
}
}

View File

@@ -0,0 +1,71 @@
<?php
namespace App\Http\Requests\Support\Resources;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Validation\Rule;
class SupportBlogPostResourceRequest extends SupportResourceFormRequest
{
public static function rulesFor(string $action, ?Model $model = null): array
{
$postId = $model?->getKey();
$rules = [
'blog_category_id' => ['sometimes', 'integer', 'exists:blog_categories,id'],
'slug' => [
'sometimes',
'string',
'max:255',
Rule::unique('blog_posts', 'slug')->ignore($postId),
],
'banner' => ['sometimes', 'nullable', 'string', 'max:255'],
'published_at' => ['sometimes', 'nullable', 'date'],
'is_published' => ['sometimes', 'boolean'],
'title' => ['sometimes', 'array'],
'title.de' => ['required_with:title', 'string', 'max:255'],
'title.en' => ['nullable', 'string', 'max:255'],
'content' => ['sometimes', 'array'],
'content.de' => ['required_with:content', 'string'],
'content.en' => ['nullable', 'string'],
'excerpt' => ['sometimes', 'array'],
'excerpt.de' => ['nullable', 'string'],
'excerpt.en' => ['nullable', 'string'],
'meta_title' => ['sometimes', 'array'],
'meta_title.de' => ['nullable', 'string', 'max:255'],
'meta_title.en' => ['nullable', 'string', 'max:255'],
'meta_description' => ['sometimes', 'array'],
'meta_description.de' => ['nullable', 'string'],
'meta_description.en' => ['nullable', 'string'],
'translations' => ['sometimes', 'array'],
];
if ($action === 'create') {
$rules['blog_category_id'] = ['required', 'integer', 'exists:blog_categories,id'];
$rules['slug'] = ['required', 'string', 'max:255', Rule::unique('blog_posts', 'slug')];
$rules['title'] = ['required', 'array'];
$rules['title.de'] = ['required', 'string', 'max:255'];
$rules['content'] = ['required', 'array'];
$rules['content.de'] = ['required', 'string'];
}
return $rules;
}
public static function allowedFields(string $action): array
{
return [
'blog_category_id',
'slug',
'banner',
'published_at',
'is_published',
'title',
'content',
'excerpt',
'meta_title',
'meta_description',
'translations',
];
}
}

View File

@@ -0,0 +1,32 @@
<?php
namespace App\Http\Requests\Support\Resources;
use App\Enums\DataExportScope;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Validation\Rule;
class SupportDataExportResourceRequest extends SupportResourceFormRequest
{
public static function rulesFor(string $action, ?Model $model = null): array
{
$scopeValues = array_map(static fn (DataExportScope $scope): string => $scope->value, DataExportScope::cases());
return [
'scope' => ['required', 'string', Rule::in($scopeValues)],
'tenant_id' => ['required', 'integer', 'exists:tenants,id'],
'event_id' => ['nullable', 'integer', 'exists:events,id', 'required_if:scope,event'],
'include_media' => ['sometimes', 'boolean'],
];
}
public static function allowedFields(string $action): array
{
return [
'scope',
'tenant_id',
'event_id',
'include_media',
];
}
}

View File

@@ -0,0 +1,46 @@
<?php
namespace App\Http\Requests\Support\Resources;
use Illuminate\Database\Eloquent\Model;
class SupportEmotionResourceRequest extends SupportResourceFormRequest
{
public static function rulesFor(string $action, ?Model $model = null): array
{
$rules = [
'name' => ['sometimes', 'array'],
'name.de' => ['required_with:name', 'string', 'max:255'],
'name.en' => ['required_with:name', 'string', 'max:255'],
'description' => ['sometimes', 'array'],
'description.de' => ['nullable', 'string'],
'description.en' => ['nullable', 'string'],
'icon' => ['sometimes', 'nullable', 'string', 'max:50'],
'color' => ['sometimes', 'nullable', 'string', 'max:7'],
'sort_order' => ['sometimes', 'integer', 'min:0'],
'is_active' => ['sometimes', 'boolean'],
'tenant_id' => ['sometimes', 'nullable', 'integer', 'exists:tenants,id'],
];
if ($action === 'create') {
$rules['name'] = ['required', 'array'];
$rules['name.de'] = ['required', 'string', 'max:255'];
$rules['name.en'] = ['required', 'string', 'max:255'];
}
return $rules;
}
public static function allowedFields(string $action): array
{
return [
'name',
'description',
'icon',
'color',
'sort_order',
'is_active',
'tenant_id',
];
}
}

View File

@@ -0,0 +1,74 @@
<?php
namespace App\Http\Requests\Support\Resources;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Validation\Rule;
class SupportEventResourceRequest extends SupportResourceFormRequest
{
public static function rulesFor(string $action, ?Model $model = null): array
{
$eventId = $model?->getKey();
$rules = [
'name' => ['sometimes', 'array'],
'name.de' => ['required_with:name', 'string', 'max:255'],
'name.en' => ['nullable', 'string', 'max:255'],
'description' => ['sometimes', 'array'],
'description.de' => ['nullable', 'string'],
'description.en' => ['nullable', 'string'],
'slug' => [
'sometimes',
'string',
'max:255',
Rule::unique('events', 'slug')->ignore($eventId),
],
'date' => ['sometimes', 'date'],
'location' => ['sometimes', 'nullable', 'string', 'max:255'],
'max_participants' => ['sometimes', 'nullable', 'integer', 'min:0'],
'event_type_id' => ['sometimes', 'nullable', 'integer', 'exists:event_types,id'],
'default_locale' => ['sometimes', 'string', 'max:5'],
'is_active' => ['sometimes', 'boolean'],
'status' => ['sometimes', 'string', Rule::in(['draft', 'published', 'archived'])],
'settings' => ['sometimes', 'array'],
'join_link_enabled' => ['sometimes', 'boolean'],
'photo_upload_enabled' => ['sometimes', 'boolean'],
'task_checklist_enabled' => ['sometimes', 'boolean'],
];
if ($action === 'create') {
$rules['name'] = ['required', 'array'];
$rules['name.de'] = ['required', 'string', 'max:255'];
$rules['slug'] = [
'required',
'string',
'max:255',
Rule::unique('events', 'slug'),
];
$rules['date'] = ['required', 'date'];
}
return $rules;
}
public static function allowedFields(string $action): array
{
return [
'name',
'description',
'slug',
'date',
'location',
'max_participants',
'event_type_id',
'default_locale',
'is_active',
'status',
'settings',
'join_link_enabled',
'photo_upload_enabled',
'task_checklist_enabled',
];
}
}

View File

@@ -0,0 +1,31 @@
<?php
namespace App\Http\Requests\Support\Resources;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Validation\Rule;
class SupportPhotoResourceRequest extends SupportResourceFormRequest
{
public static function rulesFor(string $action, ?Model $model = null): array
{
return [
'status' => ['sometimes', 'string', Rule::in(['pending', 'approved', 'rejected', 'hidden'])],
'moderation_notes' => ['nullable', 'required_if:status,rejected', 'string', 'max:1000'],
'is_featured' => ['sometimes', 'boolean'],
'emotion_id' => ['sometimes', 'nullable', 'integer', 'exists:emotions,id'],
'task_id' => ['sometimes', 'nullable', 'integer', 'exists:tasks,id'],
];
}
public static function allowedFields(string $action): array
{
return [
'status',
'moderation_notes',
'is_featured',
'emotion_id',
'task_id',
];
}
}

View File

@@ -0,0 +1,34 @@
<?php
namespace App\Http\Requests\Support\Resources;
use Illuminate\Database\Eloquent\Model;
class SupportPhotoboothSettingResourceRequest extends SupportResourceFormRequest
{
public static function rulesFor(string $action, ?Model $model = null): array
{
return [
'ftp_port' => ['sometimes', 'integer', 'min:1', 'max:65535'],
'rate_limit_per_minute' => ['sometimes', 'integer', 'min:1', 'max:200'],
'expiry_grace_days' => ['sometimes', 'integer', 'min:0', 'max:14'],
'require_ftps' => ['sometimes', 'boolean'],
'allowed_ip_ranges' => ['sometimes', 'array'],
'control_service_base_url' => ['sometimes', 'nullable', 'string', 'max:191'],
'control_service_token_identifier' => ['sometimes', 'nullable', 'string', 'max:191'],
];
}
public static function allowedFields(string $action): array
{
return [
'ftp_port',
'rate_limit_per_minute',
'expiry_grace_days',
'require_ftps',
'allowed_ip_ranges',
'control_service_base_url',
'control_service_token_identifier',
];
}
}

View File

@@ -0,0 +1,38 @@
<?php
namespace App\Http\Requests\Support\Resources;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Foundation\Http\FormRequest;
abstract class SupportResourceFormRequest extends FormRequest
{
public function authorize(): bool
{
return true;
}
/**
* @return array<string, mixed>
*/
public static function rulesFor(string $action, ?Model $model = null): array
{
return [];
}
/**
* @return array<int, string>
*/
public static function allowedFields(string $action): array
{
return [];
}
/**
* @return array<string, mixed>
*/
public function rules(): array
{
return static::rulesFor('update', null);
}
}

View File

@@ -0,0 +1,52 @@
<?php
namespace App\Http\Requests\Support\Resources;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Validation\Rule;
class SupportTaskResourceRequest extends SupportResourceFormRequest
{
public static function rulesFor(string $action, ?Model $model = null): array
{
$rules = [
'emotion_id' => ['sometimes', 'integer', 'exists:emotions,id'],
'event_type_id' => ['sometimes', 'nullable', 'integer', 'exists:event_types,id'],
'title' => ['sometimes', 'array'],
'title.de' => ['required_with:title', 'string', 'max:255'],
'title.en' => ['required_with:title', 'string', 'max:255'],
'description' => ['sometimes', 'array'],
'description.de' => ['nullable', 'string'],
'description.en' => ['nullable', 'string'],
'example_text' => ['sometimes', 'array'],
'example_text.de' => ['nullable', 'string'],
'example_text.en' => ['nullable', 'string'],
'difficulty' => ['sometimes', 'string', Rule::in(['easy', 'medium', 'hard'])],
'sort_order' => ['sometimes', 'integer', 'min:0'],
'is_active' => ['sometimes', 'boolean'],
];
if ($action === 'create') {
$rules['emotion_id'] = ['required', 'integer', 'exists:emotions,id'];
$rules['title'] = ['required', 'array'];
$rules['title.de'] = ['required', 'string', 'max:255'];
$rules['title.en'] = ['required', 'string', 'max:255'];
}
return $rules;
}
public static function allowedFields(string $action): array
{
return [
'emotion_id',
'event_type_id',
'title',
'description',
'example_text',
'difficulty',
'sort_order',
'is_active',
];
}
}

View File

@@ -0,0 +1,29 @@
<?php
namespace App\Http\Requests\Support\Resources;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Validation\Rule;
class SupportTenantFeedbackResourceRequest extends SupportResourceFormRequest
{
public static function rulesFor(string $action, ?Model $model = null): array
{
return [
'status' => [
'sometimes',
'string',
Rule::in(['pending', 'resolved', 'hidden', 'deleted']),
],
'moderation_notes' => ['sometimes', 'nullable', 'string', 'max:1000'],
];
}
public static function allowedFields(string $action): array
{
return [
'status',
'moderation_notes',
];
}
}

View File

@@ -0,0 +1,40 @@
<?php
namespace App\Http\Requests\Support\Resources;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Validation\Rule;
class SupportTenantResourceRequest extends SupportResourceFormRequest
{
public static function rulesFor(string $action, ?Model $model = null): array
{
$tenantId = $model?->getKey();
return [
'slug' => [
'sometimes',
'string',
'max:255',
Rule::unique('tenants', 'slug')->ignore($tenantId),
],
'contact_email' => ['sometimes', 'email', 'max:255'],
'paddle_customer_id' => ['sometimes', 'nullable', 'string', 'max:191'],
'is_active' => ['sometimes', 'boolean'],
'is_suspended' => ['sometimes', 'boolean'],
'features' => ['sometimes', 'array'],
];
}
public static function allowedFields(string $action): array
{
return [
'slug',
'contact_email',
'paddle_customer_id',
'is_active',
'is_suspended',
'features',
];
}
}

View File

@@ -0,0 +1,47 @@
<?php
namespace App\Http\Requests\Support\Resources;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Validation\Rule;
class SupportUserResourceRequest extends SupportResourceFormRequest
{
public static function rulesFor(string $action, ?Model $model = null): array
{
$userId = $model?->getKey();
return [
'first_name' => ['sometimes', 'string', 'max:255'],
'last_name' => ['sometimes', 'string', 'max:255'],
'username' => [
'sometimes',
'string',
'max:255',
Rule::unique('users', 'username')->ignore($userId),
],
'email' => [
'sometimes',
'email',
'max:255',
Rule::unique('users', 'email')->ignore($userId),
],
'address' => ['sometimes', 'string', 'max:1000'],
'phone' => ['sometimes', 'string', 'max:50'],
'preferred_locale' => ['sometimes', 'string', 'max:10'],
];
}
public static function allowedFields(string $action): array
{
return [
'first_name',
'last_name',
'username',
'email',
'address',
'phone',
'preferred_locale',
];
}
}

View File

@@ -0,0 +1,36 @@
<?php
namespace App\Http\Requests\Support;
use Illuminate\Contracts\Validation\ValidationRule;
use Illuminate\Foundation\Http\FormRequest;
class SupportGuestPolicyRequest extends FormRequest
{
public function authorize(): bool
{
return true;
}
/**
* @return array<string, ValidationRule|array<mixed>|string>
*/
public function rules(): array
{
return [
'guest_downloads_enabled' => ['sometimes', 'boolean'],
'guest_sharing_enabled' => ['sometimes', 'boolean'],
'guest_upload_visibility' => ['sometimes', 'string'],
'per_device_upload_limit' => ['sometimes', 'integer', 'min:0'],
'join_token_failure_limit' => ['sometimes', 'integer', 'min:1'],
'join_token_failure_decay_minutes' => ['sometimes', 'integer', 'min:1'],
'join_token_access_limit' => ['sometimes', 'integer', 'min:0'],
'join_token_access_decay_minutes' => ['sometimes', 'integer', 'min:1'],
'join_token_download_limit' => ['sometimes', 'integer', 'min:0'],
'join_token_download_decay_minutes' => ['sometimes', 'integer', 'min:1'],
'join_token_ttl_hours' => ['sometimes', 'integer', 'min:0'],
'share_link_ttl_hours' => ['sometimes', 'integer', 'min:1'],
'guest_notification_ttl_hours' => ['nullable', 'integer', 'min:1'],
];
}
}

View File

@@ -0,0 +1,24 @@
<?php
namespace App\Http\Requests\Support;
use Illuminate\Contracts\Validation\ValidationRule;
use Illuminate\Foundation\Http\FormRequest;
class SupportResourceRequest extends FormRequest
{
public function authorize(): bool
{
return true;
}
/**
* @return array<string, ValidationRule|array<mixed>|string>
*/
public function rules(): array
{
return [
'data' => ['required', 'array'],
];
}
}

View File

@@ -0,0 +1,52 @@
<?php
namespace App\Http\Requests\Support;
use Illuminate\Contracts\Validation\ValidationRule;
use Illuminate\Foundation\Http\FormRequest;
class SupportTokenRequest extends FormRequest
{
public function authorize(): bool
{
return true;
}
/**
* @return array<string, ValidationRule|array<mixed>|string>
*/
public function rules(): array
{
return [
'login' => ['required', 'string'],
'password' => ['required', 'string'],
'abilities' => ['sometimes', 'array'],
'abilities.*' => ['string'],
];
}
/**
* @return array{email?: string, username?: string, password: string, abilities?: array<int, string>}
*/
public function credentials(): array
{
$login = $this->string('login')->trim()->value();
$credentials = [
'password' => $this->string('password')->value(),
];
if (filter_var($login, FILTER_VALIDATE_EMAIL)) {
$credentials['email'] = $login;
} else {
$credentials['username'] = $login;
}
$abilities = $this->input('abilities');
if (is_array($abilities) && $abilities !== []) {
$credentials['abilities'] = array_values(array_filter($abilities, 'is_string'));
}
return $credentials;
}
}

View File

@@ -0,0 +1,30 @@
<?php
namespace App\Http\Requests\Support;
use Illuminate\Contracts\Validation\ValidationRule;
use Illuminate\Foundation\Http\FormRequest;
class SupportWatermarkSettingsRequest extends FormRequest
{
public function authorize(): bool
{
return true;
}
/**
* @return array<string, ValidationRule|array<mixed>|string>
*/
public function rules(): array
{
return [
'asset' => ['sometimes', 'string'],
'position' => ['sometimes', 'string'],
'opacity' => ['sometimes', 'numeric', 'min:0', 'max:1'],
'scale' => ['sometimes', 'numeric', 'min:0.05', 'max:1'],
'padding' => ['sometimes', 'integer', 'min:0'],
'offset_x' => ['sometimes', 'integer', 'min:-500', 'max:500'],
'offset_y' => ['sometimes', 'integer', 'min:-500', 'max:500'],
];
}
}

View File

@@ -0,0 +1,26 @@
<?php
namespace App\Http\Requests\Support\Tenant;
use Illuminate\Contracts\Validation\ValidationRule;
use Illuminate\Foundation\Http\FormRequest;
class SupportTenantAddPackageRequest extends FormRequest
{
public function authorize(): bool
{
return true;
}
/**
* @return array<string, ValidationRule|array<mixed>|string>
*/
public function rules(): array
{
return [
'package_id' => ['required', 'integer'],
'expires_at' => ['nullable', 'date'],
'reason' => ['nullable', 'string'],
];
}
}

View File

@@ -0,0 +1,25 @@
<?php
namespace App\Http\Requests\Support\Tenant;
use Illuminate\Contracts\Validation\ValidationRule;
use Illuminate\Foundation\Http\FormRequest;
class SupportTenantScheduleDeletionRequest extends FormRequest
{
public function authorize(): bool
{
return true;
}
/**
* @return array<string, ValidationRule|array<mixed>|string>
*/
public function rules(): array
{
return [
'pending_deletion_at' => ['required', 'date', 'after:now'],
'send_warning' => ['sometimes', 'boolean'],
];
}
}

View File

@@ -0,0 +1,25 @@
<?php
namespace App\Http\Requests\Support\Tenant;
use Illuminate\Contracts\Validation\ValidationRule;
use Illuminate\Foundation\Http\FormRequest;
class SupportTenantSetGracePeriodRequest extends FormRequest
{
public function authorize(): bool
{
return true;
}
/**
* @return array<string, ValidationRule|array<mixed>|string>
*/
public function rules(): array
{
return [
'grace_period_ends_at' => ['required', 'date', 'after_or_equal:now'],
'note' => ['nullable', 'string'],
];
}
}

View File

@@ -0,0 +1,26 @@
<?php
namespace App\Http\Requests\Support\Tenant;
use Illuminate\Contracts\Validation\ValidationRule;
use Illuminate\Foundation\Http\FormRequest;
class SupportTenantUpdateLimitsRequest extends FormRequest
{
public function authorize(): bool
{
return true;
}
/**
* @return array<string, ValidationRule|array<mixed>|string>
*/
public function rules(): array
{
return [
'max_photos_per_event' => ['required', 'integer', 'min:0'],
'max_storage_mb' => ['required', 'integer', 'min:0'],
'note' => ['nullable', 'string'],
];
}
}

View File

@@ -0,0 +1,25 @@
<?php
namespace App\Http\Requests\Support\Tenant;
use Illuminate\Contracts\Validation\ValidationRule;
use Illuminate\Foundation\Http\FormRequest;
class SupportTenantUpdateSubscriptionRequest extends FormRequest
{
public function authorize(): bool
{
return true;
}
/**
* @return array<string, ValidationRule|array<mixed>|string>
*/
public function rules(): array
{
return [
'subscription_expires_at' => ['nullable', 'date'],
'note' => ['nullable', 'string'],
];
}
}

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