Compare commits

..

176 Commits

Author SHA1 Message Date
Codex Agent
198fbf6751 Hide add FAB at task limit
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (push) Has been cancelled
tests / ui (push) Has been cancelled
2026-01-21 10:30:12 +01:00
Codex Agent
246e54f970 Update task mode UI details
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (push) Has been cancelled
tests / ui (push) Has been cancelled
2026-01-21 10:19:34 +01:00
Codex Agent
1c5412e82c Enforce task limits and update event form
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (push) Has been cancelled
tests / ui (push) Has been cancelled
2026-01-21 09:49:30 +01:00
Codex Agent
0b1430e64d Refine control room filter pill styling
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (push) Has been cancelled
tests / ui (push) Has been cancelled
2026-01-20 16:43:03 +01:00
Codex Agent
52c2aa0e9b Update control room filter pill styling
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (push) Has been cancelled
tests / ui (push) Has been cancelled
2026-01-20 16:20:54 +01:00
Codex Agent
dd459aa381 Replace control room filters with count bar
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (push) Has been cancelled
tests / ui (push) Has been cancelled
2026-01-20 16:12:29 +01:00
Codex Agent
02ec14a0d3 Collapse upload settings by default
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (push) Has been cancelled
tests / ui (push) Has been cancelled
2026-01-20 16:01:33 +01:00
Codex Agent
e490f9995c Refine control room upload settings UI defaults
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (push) Has been cancelled
tests / ui (push) Has been cancelled
2026-01-20 15:59:11 +01:00
Codex Agent
5e5b69f655 Add control room automations and uploader overrides
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (push) Has been cancelled
tests / ui (push) Has been cancelled
2026-01-20 15:49:04 +01:00
Codex Agent
e5e74febbd Shrink control room photo actions
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (push) Has been cancelled
tests / ui (push) Has been cancelled
2026-01-20 14:02:49 +01:00
Codex Agent
5674ed99f1 Add compact control room photo grid
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (push) Has been cancelled
tests / ui (push) Has been cancelled
2026-01-20 13:53:53 +01:00
Codex Agent
6ab24e65a1 Refine event status filter styling
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (push) Has been cancelled
tests / ui (push) Has been cancelled
2026-01-20 13:29:43 +01:00
Codex Agent
d7ba1880dc Integrate status filters into event list
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (push) Has been cancelled
tests / ui (push) Has been cancelled
2026-01-20 13:25:21 +01:00
Codex Agent
9d8f01d294 Refresh event overview list UI
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (push) Has been cancelled
tests / ui (push) Has been cancelled
2026-01-20 13:21:39 +01:00
Codex Agent
f88aa40315 Clarify watermark features across packages
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (push) Has been cancelled
tests / ui (push) Has been cancelled
2026-01-20 13:10:49 +01:00
Codex Agent
cb5d5a2870 Gate event create FAB by package quota
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (push) Has been cancelled
tests / ui (push) Has been cancelled
2026-01-20 12:54:16 +01:00
Codex Agent
e28eb9a90b Fix event search filtering
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (push) Has been cancelled
tests / ui (push) Has been cancelled
2026-01-20 12:41:14 +01:00
Codex Agent
3c2ebdbc0e Fix sticky tasks toolbar layout
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (push) Has been cancelled
tests / ui (push) Has been cancelled
2026-01-20 11:37:20 +01:00
Codex Agent
a916bf8c4d Compact tasks hero and harden sticky toolbar
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (push) Has been cancelled
tests / ui (push) Has been cancelled
2026-01-20 10:59:58 +01:00
Codex Agent
7a71efedd1 Fix sticky task search bar
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (push) Has been cancelled
tests / ui (push) Has been cancelled
2026-01-20 09:04:48 +01:00
Codex Agent
e1221e0466 Clarify photo task wording in admin UI
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (push) Has been cancelled
tests / ui (push) Has been cancelled
2026-01-20 08:49:34 +01:00
Codex Agent
508c8201fa Update photo task labels and filters
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (push) Has been cancelled
tests / ui (push) Has been cancelled
2026-01-20 08:30:40 +01:00
Codex Agent
750acb0bec Allow task attach search across global tasks
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (push) Has been cancelled
tests / ui (push) Has been cancelled
2026-01-19 21:42:09 +01:00
Codex Agent
42f6178b6d Fix task collection attach relation
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (push) Has been cancelled
tests / ui (push) Has been cancelled
2026-01-19 21:33:38 +01:00
Codex Agent
802e360c8e Use full pages for task collections
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (push) Has been cancelled
tests / ui (push) Has been cancelled
2026-01-19 21:26:30 +01:00
Codex Agent
7030e8b5b9 Add superadmin task collections resource
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (push) Has been cancelled
tests / ui (push) Has been cancelled
2026-01-19 21:19:37 +01:00
Codex Agent
b61507ea04 Hochzeitsaufgaben auf 44 reduziert und Spezialthemenpakete vorbereitet. 2026-01-19 19:45:48 +01:00
Codex Agent
dfaf21898a chore: sync bd issues
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (push) Has been cancelled
tests / ui (push) Has been cancelled
2026-01-19 18:50:51 +01:00
Codex Agent
fbd48afbd6 feat: add task multi-select on long-press
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (push) Has been cancelled
tests / ui (push) Has been cancelled
2026-01-19 18:49:40 +01:00
Codex Agent
6f6d8901ec Route /api requests to Laravel in nginx
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (push) Has been cancelled
tests / ui (push) Has been cancelled
2026-01-19 14:27:31 +01:00
Codex Agent
d4ab9a3a20 Adjust watermark permissions and transparency
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (push) Has been cancelled
tests / ui (push) Has been cancelled
2026-01-19 13:45:43 +01:00
Codex Agent
fbff2afa3e Update admin PWA events, branding, and packages
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (push) Has been cancelled
tests / ui (push) Has been cancelled
2026-01-19 11:35:38 +01:00
Codex Agent
926bc7d070 feat(admin-pwa): add floating action button to event form
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (push) Has been cancelled
tests / ui (push) Has been cancelled
2026-01-18 11:33:09 +01:00
Codex Agent
f1f552ad2d fix(admin-pwa): fix location saving and dashboard refresh delay
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (push) Has been cancelled
tests / ui (push) Has been cancelled
2026-01-18 11:14:42 +01:00
Codex Agent
4219daba25 feat(admin-pwa): modernize dashboard KPI section with unified glass strip
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (push) Has been cancelled
tests / ui (push) Has been cancelled
2026-01-18 11:02:04 +01:00
Codex Agent
1e821a2fb4 refactor(dashboard): refine setup checklist UI
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (push) Has been cancelled
tests / ui (push) Has been cancelled
- Removed progress bar from hero for cleaner look
- Made setup checklist collapsible (auto-collapsed when complete)
- Improved checklist item styling with active/inactive states
2026-01-18 10:08:39 +01:00
Codex Agent
48d4716ab1 feat(dashboard): implement transparent setup roadmap and fix translations
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (push) Has been cancelled
tests / ui (push) Has been cancelled
- Added SetupChecklist component for clear progress visualization
- Refactored LifecycleHero to show readiness state
- Fixed remaining untranslated keys in tool grid and readiness hook
2026-01-18 10:02:59 +01:00
Codex Agent
45f0cea264 feat(mobile): implement event switcher sheet in header
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (push) Has been cancelled
tests / ui (push) Has been cancelled
- Replaced direct navigation with a bottom sheet for event switching
- Created reusable EventSwitcherSheet component
- Preserves context when switching events
2026-01-17 19:17:19 +01:00
Codex Agent
9d7990fe71 fix(dashboard): correct translation keys for tasks, settings, analytics
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (push) Has been cancelled
tests / ui (push) Has been cancelled
- Updated grid item labels to use valid i18next keys
- Ensured consistent German localization for all dashboard widgets
2026-01-17 18:29:01 +01:00
Codex Agent
0c5939e541 fix(dashboard): resolve missing translations and refine alert styling
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (push) Has been cancelled
tests / ui (push) Has been cancelled
- Updated useEventReadiness hook to use 'Bearbeiten' instead of untranslated string
- Fixed 'guestsBlocked' literal appearing in alerts by passing translator correctly
- Refined limit warning styles to respect danger tone
- Localized pulse strip labels (Fotos, Gäste) properly
2026-01-17 18:06:14 +01:00
Codex Agent
e7e095cec9 fix(theme): correct text color mapping for light/dark modes
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (push) Has been cancelled
tests / ui (push) Has been cancelled
- Updated useAdminTheme to derive muted/subtle colors from theme.color using alpha
- Fixed issue where muted text was invisible in light mode
- Updated global gradients to match new Slate palette
2026-01-17 16:39:22 +01:00
Codex Agent
d905ba8e6c fix(admin): refine dashboard translations and label mapping
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (push) Has been cancelled
tests / ui (push) Has been cancelled
- Mapped 'Photobooth' and 'Guests' grid items to correct translation keys
- Localized pulse strip labels (Fotos, Gäste)
- Updated readiness hook to use translated CTAs
2026-01-17 16:35:30 +01:00
Codex Agent
40bed1e44e feat(admin): modernize tenant admin PWA with cockpit layout and slate theme
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (push) Has been cancelled
tests / ui (push) Has been cancelled
- Replaced rainbow grid with phase-aware cockpit layout
- Implemented smart lifecycle hero with readiness logic
- Introduced dark command bar header with context pill and search placeholder
- Updated global Tamagui theme to slate/indigo palette
- Refined bottom navigation with minimalist spotlight style
2026-01-17 14:46:19 +01:00
Codex Agent
7e77dd2931 Refresh mobile dashboard and header
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (push) Has been cancelled
tests / ui (push) Has been cancelled
2026-01-16 22:06:41 +01:00
Codex Agent
b316beb522 Allow partial event updates
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (push) Has been cancelled
tests / ui (push) Has been cancelled
2026-01-16 15:12:03 +01:00
Codex Agent
6d3f4f36e8 Update tasks toggle copy 2026-01-16 15:06:48 +01:00
Codex Agent
9e4ea3dafb Add tasks toggle card 2026-01-16 14:58:24 +01:00
Codex Agent
1517eb8631 Add tasks setup nudge and prompt
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (push) Has been cancelled
tests / ui (push) Has been cancelled
2026-01-16 14:41:09 +01:00
Codex Agent
9a4ece33bf Refresh event list after create
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (push) Has been cancelled
tests / ui (push) Has been cancelled
2026-01-16 14:25:52 +01:00
Codex Agent
30c653913d Show endcustomer event allowance on dashboard
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (push) Has been cancelled
tests / ui (push) Has been cancelled
2026-01-16 14:17:27 +01:00
Codex Agent
4c37f874bd Preserve null remaining_events in package normalization
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (push) Has been cancelled
tests / ui (push) Has been cancelled
2026-01-16 14:11:44 +01:00
Codex Agent
05fdda811b Avoid billing redirect for endcustomer packages
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (push) Has been cancelled
tests / ui (push) Has been cancelled
2026-01-16 14:05:20 +01:00
Codex Agent
eeeca0eed5 Show event-per-purchase for endcustomer packages
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (push) Has been cancelled
tests / ui (push) Has been cancelled
2026-01-16 14:00:12 +01:00
Codex Agent
fa6a5678f0 Set starter event quota in package seeder
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (push) Has been cancelled
tests / ui (push) Has been cancelled
2026-01-16 13:56:03 +01:00
Codex Agent
63956087a4 Fix demo starter package seeding
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (push) Has been cancelled
tests / ui (push) Has been cancelled
2026-01-16 13:52:32 +01:00
Codex Agent
a3f153de6f Allow dashboard access with active package
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (push) Has been cancelled
tests / ui (push) Has been cancelled
2026-01-16 13:44:58 +01:00
Codex Agent
8d729c6a86 Fix dashboard empty state permissions
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (push) Has been cancelled
tests / ui (push) Has been cancelled
2026-01-16 13:39:27 +01:00
Codex Agent
7ad43a3661 Sync bd issues
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (push) Has been cancelled
tests / ui (push) Has been cancelled
2026-01-16 13:36:29 +01:00
Codex Agent
7aa0a4c847 Enforce tenant member permissions
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (push) Has been cancelled
tests / ui (push) Has been cancelled
2026-01-16 13:33:36 +01:00
Codex Agent
df60be826d Sync beads issues
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (push) Has been cancelled
tests / ui (push) Has been cancelled
2026-01-16 12:15:38 +01:00
Codex Agent
918bff08aa Fix auth translations and admin PWA UI
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (push) Has been cancelled
tests / ui (push) Has been cancelled
2026-01-16 12:14:53 +01:00
Codex Agent
292c8f0b26 Refine admin PWA layout and tamagui usage
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (push) Has been cancelled
tests / ui (push) Has been cancelled
2026-01-15 22:24:10 +01:00
Codex Agent
11018f273d chore: sync bd issues
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (push) Has been cancelled
tests / ui (push) Has been cancelled
2026-01-15 19:54:53 +01:00
Codex Agent
7e32d8f706 feat: update package copy and admin control room 2026-01-15 19:54:04 +01:00
Codex Agent
ad829ae509 Update partner packages, copy, and demo switcher
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (push) Has been cancelled
tests / ui (push) Has been cancelled
2026-01-15 17:33:36 +01:00
Codex Agent
2f93271d94 Route billing upgrade CTA to package shop
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (push) Has been cancelled
tests / ui (push) Has been cancelled
2026-01-15 10:28:18 +01:00
Codex Agent
62255dc9e7 Add missing branding watermark translations
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (push) Has been cancelled
tests / ui (push) Has been cancelled
2026-01-15 10:21:17 +01:00
Codex Agent
738659112d Add upgrade CTAs for branding and watermarks
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (push) Has been cancelled
tests / ui (push) Has been cancelled
2026-01-15 10:17:05 +01:00
Codex Agent
89d9b656de Add watermark tier labels to marketing translations
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (push) Has been cancelled
tests / ui (push) Has been cancelled
2026-01-15 09:58:02 +01:00
Codex Agent
5d0ae0faa5 Customize watermark labels in package comparison
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (push) Has been cancelled
tests / ui (push) Has been cancelled
2026-01-15 09:54:47 +01:00
Codex Agent
2ecd417b55 Enable watermarks for premium package
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (push) Has been cancelled
tests / ui (push) Has been cancelled
2026-01-15 09:48:44 +01:00
Codex Agent
3755213010 Align demo seed branding defaults
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (push) Has been cancelled
tests / ui (push) Has been cancelled
2026-01-15 09:38:24 +01:00
Codex Agent
9cb236f123 Update default branding palette for tenants and guests
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (push) Has been cancelled
tests / ui (push) Has been cancelled
2026-01-15 09:32:51 +01:00
Codex Agent
10232cf40e Adjust default branding accent color
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (push) Has been cancelled
tests / ui (push) Has been cancelled
2026-01-15 09:23:34 +01:00
Codex Agent
3ce6507268 Collapse branding controls on default mode
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (push) Has been cancelled
tests / ui (push) Has been cancelled
2026-01-15 09:17:06 +01:00
Codex Agent
a39295a0f0 Fix branding translations in locale overrides
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (push) Has been cancelled
tests / ui (push) Has been cancelled
2026-01-15 09:06:49 +01:00
Codex Agent
5dc69fb187 Adopt Tamagui sliders in admin
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (push) Has been cancelled
tests / ui (push) Has been cancelled
2026-01-15 09:01:43 +01:00
Codex Agent
92b341bdcd Use Tamagui slider for branding controls
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (push) Has been cancelled
tests / ui (push) Has been cancelled
2026-01-15 08:58:09 +01:00
Codex Agent
725a7a29b3 Refine branding labels and access checks
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (push) Has been cancelled
tests / ui (push) Has been cancelled
2026-01-15 08:51:06 +01:00
Codex Agent
8634d16359 Expand branding controls and logo upload
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (push) Has been cancelled
tests / ui (push) Has been cancelled
2026-01-15 08:42:20 +01:00
Codex Agent
81446b37c3 Wire guest branding theme 2026-01-15 08:06:21 +01:00
Codex Agent
33e46b448d Match gallery preview filters and tiles to gallery
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (push) Has been cancelled
tests / ui (push) Has been cancelled
2026-01-14 16:07:29 +01:00
Codex Agent
289ef70e53 Remove gallery route padding
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (push) Has been cancelled
tests / ui (push) Has been cancelled
2026-01-14 15:58:40 +01:00
Codex Agent
d0559bf8c9 Align gallery layout with achievements structure
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (push) Has been cancelled
tests / ui (push) Has been cancelled
2026-01-14 15:50:48 +01:00
Codex Agent
0ef4b32bf6 Match gallery layout to achievements spacing
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (push) Has been cancelled
tests / ui (push) Has been cancelled
2026-01-14 15:44:32 +01:00
Codex Agent
3612c97e86 Tighten gallery spacing and add filter dividers
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (push) Has been cancelled
tests / ui (push) Has been cancelled
2026-01-14 15:08:28 +01:00
Codex Agent
c0510581c6 Tighten gallery filters and badge placement
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (push) Has been cancelled
tests / ui (push) Has been cancelled
2026-01-14 14:04:31 +01:00
Codex Agent
1ffd3e3b9d Fix gallery section closing tag
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (push) Has been cancelled
tests / ui (push) Has been cancelled
2026-01-14 13:56:00 +01:00
Codex Agent
e05ee3b186 Unify gallery header and grid section
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (push) Has been cancelled
tests / ui (push) Has been cancelled
2026-01-14 13:53:35 +01:00
Codex Agent
cf7b2e563a Unify gallery layout and reduce image overlays
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (push) Has been cancelled
tests / ui (push) Has been cancelled
2026-01-14 12:40:55 +01:00
Codex Agent
719afb6920 Refresh gallery layout and tile styling
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (push) Has been cancelled
tests / ui (push) Has been cancelled
2026-01-14 11:48:40 +01:00
Codex Agent
83c58358a1 Show photobooth filter only when enabled
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (push) Has been cancelled
tests / ui (push) Has been cancelled
2026-01-14 11:45:29 +01:00
Codex Agent
2b888078a0 Modernize gallery UI and fix nav motion
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (push) Has been cancelled
tests / ui (push) Has been cancelled
2026-01-14 11:42:12 +01:00
Codex Agent
2f584162d6 Avoid hidden gallery content on tab navigation
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (push) Has been cancelled
tests / ui (push) Has been cancelled
2026-01-14 11:36:02 +01:00
Codex Agent
0833ea6b36 Skip hidden initial motion on achievements tab nav
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (push) Has been cancelled
tests / ui (push) Has been cancelled
2026-01-14 11:32:54 +01:00
Codex Agent
5bdc15d399 Tune guest route transition animations
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (push) Has been cancelled
tests / ui (push) Has been cancelled
2026-01-14 11:30:03 +01:00
Codex Agent
693540f609 Avoid task page hidden animation on tab navigation
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (push) Has been cancelled
tests / ui (push) Has been cancelled
2026-01-14 11:25:43 +01:00
Codex Agent
c0193c9581 Deduplicate guest tasks list and restore header icon
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (push) Has been cancelled
tests / ui (push) Has been cancelled
2026-01-14 11:17:35 +01:00
Codex Agent
03c7b20cae Improve guest help routing and loading
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (push) Has been cancelled
tests / ui (push) Has been cancelled
2026-01-14 09:00:12 +01:00
Codex Agent
3a78c4f2c0 Ensure help sync creates cache directory
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (push) Has been cancelled
tests / ui (push) Has been cancelled
2026-01-14 08:17:49 +01:00
Codex Agent
fa333deed9 Ensure storage subdirs exist on boot 2026-01-13 22:49:47 +01:00
Codex Agent
a733df6221 Add symfony/yaml for help sync
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (push) Has been cancelled
tests / ui (push) Has been cancelled
2026-01-13 22:38:09 +01:00
Codex Agent
5ee1baa7e2 Fix forwarded host/port for signed URLs
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (push) Has been cancelled
tests / ui (push) Has been cancelled
2026-01-13 22:30:10 +01:00
Codex Agent
2f19752199 chore: sync beads
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (push) Has been cancelled
tests / ui (push) Has been cancelled
2026-01-13 21:33:26 +01:00
Codex Agent
7dd7ec14a4 chore: sync beads 2026-01-13 21:32:39 +01:00
Codex Agent
d9568be579 Fix proxy headers and help sync boot
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (push) Has been cancelled
tests / ui (push) Has been cancelled
2026-01-13 21:31:46 +01:00
Codex Agent
9cf6e9d94d Add photobooth email translations
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (push) Has been cancelled
tests / ui (push) Has been cancelled
2026-01-13 16:40:00 +01:00
Codex Agent
a23ce0c86f Set locale on photobooth mail
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (push) Has been cancelled
tests / ui (push) Has been cancelled
2026-01-13 16:24:29 +01:00
Codex Agent
9efea136bd Normalize photobooth mail locale
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (push) Has been cancelled
tests / ui (push) Has been cancelled
2026-01-13 15:37:26 +01:00
Codex Agent
7a6f489b8b Add tenant admin account edit page
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (push) Has been cancelled
tests / ui (push) Has been cancelled
2026-01-13 15:09:25 +01:00
Codex Agent
cc11e024f0 Add photobooth folder presets
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (push) Has been cancelled
tests / ui (push) Has been cancelled
2026-01-13 12:00:39 +01:00
Codex Agent
2089251a92 Extend uploader profiles, filters, and diagnostics
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (push) Has been cancelled
tests / ui (push) Has been cancelled
2026-01-13 11:26:04 +01:00
Codex Agent
53094b8d36 Add filters, throttling, and connection test
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (push) Has been cancelled
tests / ui (push) Has been cancelled
2026-01-13 11:15:57 +01:00
Codex Agent
0c33c1ddc1 Persist upload queue and uploaded cache
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (push) Has been cancelled
tests / ui (push) Has been cancelled
2026-01-13 11:12:26 +01:00
Codex Agent
ce0b7c951a Update beads issues for uploader epic
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (push) Has been cancelled
tests / ui (push) Has been cancelled
2026-01-13 11:08:39 +01:00
Codex Agent
fbbbbdac4c Add upload retries and richer errors 2026-01-13 11:08:26 +01:00
Codex Agent
94d0713ec0 Add manual uploader credentials fields
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (push) Has been cancelled
tests / ui (push) Has been cancelled
2026-01-13 10:56:33 +01:00
Codex Agent
3e36354916 Restructure photobooth page flow
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (push) Has been cancelled
tests / ui (push) Has been cancelled
2026-01-13 10:52:50 +01:00
Codex Agent
24a1319cc2 Add photobooth uploader download email
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (push) Has been cancelled
tests / ui (push) Has been cancelled
2026-01-13 09:59:39 +01:00
Codex Agent
b1250c6246 Collapse photobooth credentials
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (push) Has been cancelled
tests / ui (push) Has been cancelled
2026-01-13 08:59:24 +01:00
Codex Agent
fd7a3c846a Add uploader downloads for Windows macOS Linux
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (push) Has been cancelled
tests / ui (push) Has been cancelled
2026-01-13 08:49:08 +01:00
Codex Agent
1ca7545f86 Add photobooth uploader build service
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (push) Has been cancelled
tests / ui (push) Has been cancelled
2026-01-13 08:37:26 +01:00
Codex Agent
9f4a202d2b Add Windows app icon
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (push) Has been cancelled
tests / ui (push) Has been cancelled
2026-01-13 08:12:11 +01:00
Codex Agent
fe0525e678 Fix uploader header layout
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (push) Has been cancelled
tests / ui (push) Has been cancelled
2026-01-12 21:26:17 +01:00
Codex Agent
d62efdb55c Refresh uploader UI styling
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (push) Has been cancelled
tests / ui (push) Has been cancelled
2026-01-12 21:15:55 +01:00
Codex Agent
be722f6e37 Remember uploader window size
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (push) Has been cancelled
tests / ui (push) Has been cancelled
2026-01-12 21:03:27 +01:00
Codex Agent
898ac9ff0e Add uploader advanced settings and live status
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (push) Has been cancelled
tests / ui (push) Has been cancelled
2026-01-12 20:50:39 +01:00
Codex Agent
c8d1ac7971 Improve uploader client connection and diagnostics
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (push) Has been cancelled
tests / ui (push) Has been cancelled
2026-01-12 20:40:40 +01:00
Codex Agent
3ee23f3a66 Add uploader branding
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (push) Has been cancelled
tests / ui (push) Has been cancelled
2026-01-12 20:28:49 +01:00
Codex Agent
993c351832 Remove response format from uploader UI
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (push) Has been cancelled
tests / ui (push) Has been cancelled
2026-01-12 20:22:45 +01:00
Codex Agent
2444a62a4d Show connect code expiry time
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (push) Has been cancelled
tests / ui (push) Has been cancelled
2026-01-12 20:14:42 +01:00
Codex Agent
e52720a3cb Rename photobooth upload endpoint
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (push) Has been cancelled
tests / ui (push) Has been cancelled
2026-01-12 20:05:09 +01:00
Codex Agent
93bed358ba Remove sparkbooth option from photobooth UI
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (push) Has been cancelled
tests / ui (push) Has been cancelled
2026-01-12 19:50:30 +01:00
Codex Agent
a16bd9c498 Relabel photobooth uploader mode
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (push) Has been cancelled
tests / ui (push) Has been cancelled
2026-01-12 18:46:41 +01:00
Codex Agent
e32b1fa45a Add photobooth connect code UI
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (push) Has been cancelled
tests / ui (push) Has been cancelled
2026-01-12 17:59:35 +01:00
Codex Agent
6edc890e01 Configure beads sync branch and ignore artifacts
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (push) Has been cancelled
tests / ui (push) Has been cancelled
2026-01-12 17:46:39 +01:00
Codex Agent
e4100f7800 Polish uploader UI and queue handling
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (push) Has been cancelled
tests / ui (push) Has been cancelled
2026-01-12 17:35:05 +01:00
Codex Agent
7786e3d134 Switch photobooth uploader to Avalonia
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (push) Has been cancelled
tests / ui (push) Has been cancelled
2026-01-12 17:26:45 +01:00
Codex Agent
30f3d148bb bd sync: 2026-01-12 17:24:05
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (push) Has been cancelled
tests / ui (push) Has been cancelled
2026-01-12 17:24:05 +01:00
Codex Agent
1970c259ed Restore photobooth uploader files after sync 2026-01-12 17:23:34 +01:00
Codex Agent
dc5c80cda4 bd sync: 2026-01-12 17:21:15
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (push) Has been cancelled
tests / ui (push) Has been cancelled
2026-01-12 17:21:16 +01:00
Codex Agent
75a9bcee12 Migrate photobooth uploader to Avalonia 2026-01-12 17:20:35 +01:00
Codex Agent
6fe363640f Reapply photobooth uploader changes after sync
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (push) Has been cancelled
tests / ui (push) Has been cancelled
2026-01-12 17:10:47 +01:00
Codex Agent
3df0542013 bd sync: 2026-01-12 17:10:05
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (push) Has been cancelled
tests / ui (push) Has been cancelled
2026-01-12 17:10:05 +01:00
Codex Agent
4f4a527010 Reapply photobooth uploader changes 2026-01-12 17:09:37 +01:00
Codex Agent
e69c94ad20 bd sync: 2026-01-12 17:07:55
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (push) Has been cancelled
tests / ui (push) Has been cancelled
2026-01-12 17:07:55 +01:00
Codex Agent
5afa96251b Fix WinUI build settings for linux tooling 2026-01-12 17:07:28 +01:00
Codex Agent
24f053d4c4 Add photobooth connect codes and uploader pipeline
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (push) Has been cancelled
tests / ui (push) Has been cancelled
2026-01-12 17:02:50 +01:00
Codex Agent
ec360ed860 bd sync: 2026-01-12 17:02:15
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (push) Has been cancelled
tests / ui (push) Has been cancelled
2026-01-12 17:02:15 +01:00
Codex Agent
83e78d7c66 Update backend photobooth connect API 2026-01-12 16:59:49 +01:00
Codex Agent
9b1c5bf978 bd sync: 2026-01-12 16:57:37
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (push) Has been cancelled
tests / ui (push) Has been cancelled
2026-01-12 16:57:37 +01:00
Codex Agent
fb23a0a2f3 Add photobooth connect codes and uploader scaffold 2026-01-12 16:56:51 +01:00
Codex Agent
2287e7f32c Fix tenant photo moderation and guest updates
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (push) Has been cancelled
tests / ui (push) Has been cancelled
2026-01-12 14:35:57 +01:00
Codex Agent
cceed361b7 feat: add checkout action banner
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (push) Has been cancelled
tests / ui (push) Has been cancelled
2026-01-12 13:35:43 +01:00
Codex Agent
02363792c8 feat: poll checkout status and show failures
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (push) Has been cancelled
tests / ui (push) Has been cancelled
2026-01-12 13:31:30 +01:00
Codex Agent
e93a00f0fc fix: block non-upgrade package selection
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (push) Has been cancelled
tests / ui (push) Has been cancelled
2026-01-12 12:40:18 +01:00
Codex Agent
c1be7dd1ef fix: add package feature labels
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (push) Has been cancelled
tests / ui (push) Has been cancelled
2026-01-12 12:32:43 +01:00
Codex Agent
f01a0e823b fix: handle array package features
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (push) Has been cancelled
tests / ui (push) Has been cancelled
2026-01-12 12:29:19 +01:00
Codex Agent
915aede66e feat: add package comparison view
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (push) Has been cancelled
tests / ui (push) Has been cancelled
2026-01-12 12:25:35 +01:00
Codex Agent
b854e3feaa Show billing activation banner
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (push) Has been cancelled
tests / ui (push) Has been cancelled
2026-01-12 12:07:37 +01:00
Codex Agent
4bcaef53f7 Redirect checkout to billing with status
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (push) Has been cancelled
tests / ui (push) Has been cancelled
2026-01-12 11:49:10 +01:00
Codex Agent
8f1d3a3eb6 Disallow downgrades in package shop
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (push) Has been cancelled
tests / ui (push) Has been cancelled
2026-01-12 11:45:12 +01:00
Codex Agent
ab2cf3e023 Highlight upgrades in package shop
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (push) Has been cancelled
tests / ui (push) Has been cancelled
2026-01-12 11:38:16 +01:00
Codex Agent
ce0ab269c9 Cap analytics timeframe label
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (push) Has been cancelled
tests / ui (push) Has been cancelled
2026-01-12 11:30:00 +01:00
Codex Agent
dce24bb86a Compute analytics timeframe dynamically
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (push) Has been cancelled
tests / ui (push) Has been cancelled
2026-01-12 11:12:50 +01:00
Codex Agent
03bf178d61 Enhance analytics snapshot and empty states
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (push) Has been cancelled
tests / ui (push) Has been cancelled
2026-01-12 11:07:23 +01:00
Codex Agent
8ebaf6c31d Refine analytics page and i18n
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (push) Has been cancelled
tests / ui (push) Has been cancelled
2026-01-12 11:03:55 +01:00
Codex Agent
1b6dc63ec6 Clamp package summary remaining counts
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (push) Has been cancelled
tests / ui (push) Has been cancelled
2026-01-12 10:50:35 +01:00
Codex Agent
accc63f4a2 Add pending test files
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (push) Has been cancelled
tests / ui (push) Has been cancelled
2026-01-12 10:46:18 +01:00
Codex Agent
59e318e7b9 Ignore beads sync artifacts
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (push) Has been cancelled
tests / ui (push) Has been cancelled
2026-01-12 10:42:58 +01:00
Codex Agent
3de1d3deab Misc unrelated updates
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (push) Has been cancelled
tests / ui (push) Has been cancelled
2026-01-12 10:31:31 +01:00
Codex Agent
e9afbeb028 Unify admin home with event overview 2026-01-12 10:31:05 +01:00
Codex Agent
3e2b63f71f Paddle Coupon Sync prüft nun zuerst, ob der Discount schon existiert. 2026-01-08 13:36:58 +01:00
Codex Agent
cff014ede5 fix(i18n): restore missing translations and enable Suspense loading
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (push) Has been cancelled
tests / ui (push) Has been cancelled
2026-01-07 20:50:09 +01:00
Codex Agent
8c5d3b93d5 feat: improve mobile navigation with tap-to-reset and history filtering
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (push) Has been cancelled
tests / ui (push) Has been cancelled
2026-01-07 15:14:31 +01:00
Codex Agent
22cb7ed7ce fix: resolve typescript and build errors across admin and guest apps
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (push) Has been cancelled
tests / ui (push) Has been cancelled
2026-01-07 13:25:30 +01:00
1065 changed files with 464770 additions and 517588 deletions

View File

@@ -17,7 +17,6 @@
{"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)"}
@@ -38,7 +37,6 @@
{"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)"}

1
.beads/last-touched Normal file
View File

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

View File

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

View File

@@ -97,11 +97,6 @@ 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=
@@ -117,22 +112,14 @@ PAYPAL_CLIENT_ID=
PAYPAL_SECRET=
PAYPAL_SANDBOX=true
# Lemon Squeezy Billing
LEMONSQUEEZY_STORE_ID=284860
LEMONSQUEEZY_API_KEY=
LEMONSQUEEZY_WEBHOOK_SECRET=
LEMONSQUEEZY_WEBHOOK_EVENTS=
LEMONSQUEEZY_TEST_MODE=false
LEMONSQUEEZY_BASE_URL=https://api.lemonsqueezy.com/v1
LEMONSQUEEZY_GIFT_VARIANT_STARTER=
LEMONSQUEEZY_GIFT_VARIANT_STARTER_USD=
LEMONSQUEEZY_GIFT_VARIANT_STARTER_GBP=
LEMONSQUEEZY_GIFT_VARIANT_STANDARD=
LEMONSQUEEZY_GIFT_VARIANT_STANDARD_USD=
LEMONSQUEEZY_GIFT_VARIANT_STANDARD_GBP=
LEMONSQUEEZY_GIFT_VARIANT_PREMIUM=
LEMONSQUEEZY_GIFT_VARIANT_PREMIUM_USD=
LEMONSQUEEZY_GIFT_VARIANT_PREMIUM_GBP=
# Paddle Billing
PADDLE_SANDBOX=true
PADDLE_API_KEY=
PADDLE_CLIENT_ID=
PADDLE_WEBHOOK_SECRET=
PADDLE_PUBLIC_KEY=
PADDLE_BASE_URL=
PADDLE_CONSOLE_URL=
# Sanctum / SPA auth
SANCTUM_STATEFUL_DOMAINS=localhost,localhost:3000
@@ -200,9 +187,5 @@ 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,8 +118,319 @@ 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();
@@ -444,10 +755,7 @@ var SizableText2 = (0, import_web4.styled)(import_web4.Text, {
}
});
SizableText2.staticConfig.variants.fontFamily = {
"...": /* @__PURE__ */ __name((val, extras) => {
if (val === "inherit") return {
fontFamily: "inherit"
};
"...": /* @__PURE__ */ __name((_val, extras) => {
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,10 +112,7 @@ var SizableText2 = (0, import_web2.styled)(import_web2.Text, {
}
});
SizableText2.staticConfig.variants.fontFamily = {
"...": /* @__PURE__ */ __name((val, extras) => {
if (val === "inherit") return {
fontFamily: "inherit"
};
"...": /* @__PURE__ */ __name((_val, extras) => {
const sizeProp = extras.props.size, fontSizeProp = extras.props.fontSize, size = sizeProp === "$true" && fontSizeProp ? fontSizeProp : extras.props.size || "$true";
return getFontSized(size, extras);
}, "...")

139
AGENTS.md
View File

@@ -27,8 +27,8 @@ This repository hosts a multi-tenant event photo platform (Laravel 12, PHP 8.3,
- Languages/Frameworks: PHP 8.2+ (Laravel 12), TypeScript/JavaScript (React 19/Vite 7/Tailwind 4), Filament 4.
- Dev Commands: composer, npm, vite, artisan, PHPUnit, Pint/ESLint, Docker/Compose (for dev), Playwright, Vitest, TypeScript.
- Libraries: simplesoftwareio/simple-qrcode for server-side QR generation; Lemon Squeezy API client (custom service) for payments; dompdf for PDF generation; spatie/laravel-translatable for i18n; minishlink/web-push for web push; firebase/php-jwt for JWT; Sentry (Laravel + Vite); Stripe (PHP + JS); Tamagui (design system); i18next (frontend i18n); vite-plugin-pwa for PWA builds.
- Payment Systems: Lemon Squeezy (subscriptions and one-time payments), RevenueCat (mobile app subscriptions), Stripe (legacy/integration use).
- Libraries: simplesoftwareio/simple-qrcode for server-side QR generation; Paddle API client (custom service) for payments; dompdf for PDF generation; spatie/laravel-translatable for i18n; minishlink/web-push for web push; firebase/php-jwt for JWT; Sentry (Laravel + Vite); Stripe (PHP + JS); Tamagui (design system); i18next (frontend i18n); vite-plugin-pwa for PWA builds.
- Payment Systems: Paddle (subscriptions and one-time payments), RevenueCat (mobile app subscriptions), Stripe (legacy/integration use).
- PWA Technologies: React 19, Vite 7, Capacitor (iOS), Trusted Web Activity (Android), Service Workers, Background Sync.
## Repo Structure (high-level)
@@ -38,9 +38,6 @@ 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):
@@ -61,7 +58,7 @@ This repository hosts a multi-tenant event photo platform (Laravel 12, PHP 8.3,
#### Billing & Packages
- package:check-status — check event package status.
- packages:migrate-legacy — migrate legacy package purchases.
- lemonsqueezy:sync-packages — sync packages with Lemon Squeezy (push/pull/queue/dry-run).
- paddle:sync-packages — sync packages with Paddle (push/pull/queue/dry-run).
- coupons:export — export coupon redemptions.
- checkout:send-reminders — send abandoned checkout reminders (dry-run supported).
@@ -96,7 +93,7 @@ This repository hosts a multi-tenant event photo platform (Laravel 12, PHP 8.3,
- metrics:package-limits — inspect/reset package limit metrics (routes/console.php).
- inspire — inspiring quote (routes/console.php).
- Public APIs for Guest PWA: stats/photos endpoints with ETag; likes; uploads; see docs/archive/prp/03-api.md.
- Payment Integration: Lemon Squeezy webhooks, RevenueCat mobile subscriptions.
- Payment Integration: Paddle webhooks, RevenueCat mobile subscriptions.
## PWA Architecture
- Guest PWA: Offline-first photo sharing app for event attendees (installable, background sync, no account required).
@@ -623,134 +620,6 @@ export default () => (
| overflow-ellipsis | text-ellipsis |
| decoration-slice | box-decoration-slice |
| decoration-clone | box-decoration-clone |
=== filament/filament rules ===
## Filament
- Filament is used by this application. Follow existing conventions for how and where it's implemented.
- Filament is a Server-Driven UI (SDUI) framework for Laravel that lets you define user interfaces in PHP using structured configuration objects. Built on Livewire, Alpine.js, and Tailwind CSS.
- Use the `search-docs` tool for official documentation on Artisan commands, code examples, testing, relationships, and idiomatic practices.
### Artisan
- Use Filament-specific Artisan commands to create files. Find them with `list-artisan-commands` or `php artisan --help`.
- Inspect required options and always pass `--no-interaction`.
### Patterns
Use static `make()` methods to initialize components. Most configuration methods accept a `Closure` for dynamic values.
Use `Get $get` to read other form field values for conditional logic:
<code-snippet name="Conditional form field" lang="php">
use Filament\Forms\Components\Select;
use Filament\Forms\Components\TextInput;
use Filament\Schemas\Components\Utilities\Get;
Select::make('type')
->options(CompanyType::class)
->required()
->live(),
TextInput::make('company_name')
->required()
->visible(fn (Get $get): bool => $get('type') === 'business'),
</code-snippet>
Use `state()` with a `Closure` to compute derived column values:
<code-snippet name="Computed table column" lang="php">
use Filament\Tables\Columns\TextColumn;
TextColumn::make('full_name')
->state(fn (User $record): string => "{$record->first_name} {$record->last_name}"),
</code-snippet>
Actions encapsulate a button with optional modal form and logic:
<code-snippet name="Action with modal form" lang="php">
use Filament\Actions\Action;
use Filament\Forms\Components\TextInput;
Action::make('updateEmail')
->form([
TextInput::make('email')->email()->required(),
])
->action(fn (array $data, User $record): void => $record->update($data)),
</code-snippet>
### Testing
Authenticate before testing panel functionality. Filament uses Livewire, so use `livewire()` or `Livewire::test()`:
<code-snippet name="Filament Table Test" lang="php">
livewire(ListUsers::class)
->assertCanSeeTableRecords($users)
->searchTable($users->first()->name)
->assertCanSeeTableRecords($users->take(1))
->assertCanNotSeeTableRecords($users->skip(1));
</code-snippet>
<code-snippet name="Filament Create Resource Test" lang="php">
livewire(CreateUser::class)
->fillForm([
'name' => 'Test',
'email' => 'test@example.com',
])
->call('create')
->assertNotified()
->assertRedirect();
assertDatabaseHas(User::class, [
'name' => 'Test',
'email' => 'test@example.com',
]);
</code-snippet>
<code-snippet name="Testing Validation" lang="php">
livewire(CreateUser::class)
->fillForm([
'name' => null,
'email' => 'invalid-email',
])
->call('create')
->assertHasFormErrors([
'name' => 'required',
'email' => 'email',
])
->assertNotNotified();
</code-snippet>
<code-snippet name="Calling Actions" lang="php">
use Filament\Actions\DeleteAction;
use Filament\Actions\Testing\TestAction;
livewire(EditUser::class, ['record' => $user->id])
->callAction(DeleteAction::class)
->assertNotified()
->assertRedirect();
livewire(ListUsers::class)
->callAction(TestAction::make('promote')->table($user), [
'role' => 'admin',
])
->assertNotified();
</code-snippet>
### Common Mistakes
**Commonly Incorrect Namespaces:**
- Form fields (TextInput, Select, etc.): `Filament\Forms\Components\`
- Infolist entries (for read-only views) (TextEntry, IconEntry, etc.): `Filament\Forms\Components\`
- Layout components (Grid, Section, Fieldset, Tabs, Wizard, etc.): `Filament\Schemas\Components\`
- Schema utilities (Get, Set, etc.): `Filament\Schemas\Components\Utilities\`
- Actions: `Filament\Actions\` (no `Filament\Tables\Actions\` etc.)
- Icons: `Filament\Support\Icons\Heroicon` enum (e.g., `Heroicon::PencilSquare`)
**Recent breaking changes to Filament:**
- File visibility is `private` by default. Use `->visibility('public')` for public access.
- `Grid`, `Section`, and `Fieldset` no longer span all columns by default.
</laravel-boost-guidelines>
## Issue Tracking

View File

@@ -100,8 +100,6 @@ 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 \

1
GEMINI.md Symbolic link
View File

@@ -0,0 +1 @@
/mnt/c/wwwroot/fotospiel-app/AGENTS.md

View File

@@ -1,144 +0,0 @@
<?php
namespace App\Console\Commands;
use App\Models\AiEditOutput;
use App\Models\AiEditRequest;
use App\Services\AiEditing\AiEditOutputStorageService;
use Illuminate\Console\Command;
use Illuminate\Database\Eloquent\Builder;
class AiEditsBackfillStorageCommand extends Command
{
protected $signature = 'ai-edits:backfill-storage
{--request-id= : Restrict backfill to one AI edit request id}
{--limit=200 : Maximum outputs to process}
{--pretend : Dry run without writing changes}';
protected $description = 'Backfill local storage paths for AI outputs that only have provider URLs.';
public function __construct(private readonly AiEditOutputStorageService $outputStorage)
{
parent::__construct();
}
public function handle(): int
{
$limit = max(1, (int) $this->option('limit'));
$requestId = $this->normalizeRequestId($this->option('request-id'));
$pretend = (bool) $this->option('pretend');
$query = AiEditOutput::query()
->with('request')
->whereNotNull('provider_url')
->where(function (Builder $builder): void {
$builder
->whereNull('storage_path')
->orWhere('storage_path', '');
})
->orderBy('id');
if ($requestId !== null) {
$query->where('request_id', $requestId);
}
$candidateCount = (clone $query)->count();
$outputs = $query->limit($limit)->get();
if ($outputs->isEmpty()) {
$this->info('No AI outputs require storage backfill.');
return self::SUCCESS;
}
$this->line(sprintf(
'AI output backfill candidates: %d (processing up to %d).',
$candidateCount,
$limit
));
if ($pretend) {
$this->table(
['Output ID', 'Request ID', 'Provider URL'],
$outputs->map(static fn (AiEditOutput $output): array => [
(string) $output->id,
(string) $output->request_id,
(string) $output->provider_url,
])->all()
);
$this->info('Pretend mode enabled. No records were changed.');
return self::SUCCESS;
}
$processed = 0;
$stored = 0;
$failed = 0;
foreach ($outputs as $output) {
$processed++;
$request = $output->request;
if (! $request instanceof AiEditRequest) {
$failed++;
$this->warn(sprintf('Output %d skipped: missing request relation.', $output->id));
continue;
}
$persisted = $this->outputStorage->persist($request, [
'provider_url' => $output->provider_url,
'provider_asset_id' => $output->provider_asset_id,
'storage_disk' => $output->storage_disk,
'storage_path' => $output->storage_path,
'mime_type' => $output->mime_type,
'width' => $output->width,
'height' => $output->height,
'bytes' => $output->bytes,
'checksum' => $output->checksum,
'metadata' => $output->metadata,
]);
$output->forceFill([
'provider_url' => $persisted['provider_url'] ?? $output->provider_url,
'storage_disk' => $persisted['storage_disk'] ?? $output->storage_disk,
'storage_path' => $persisted['storage_path'] ?? $output->storage_path,
'mime_type' => $persisted['mime_type'] ?? $output->mime_type,
'width' => array_key_exists('width', $persisted) ? $persisted['width'] : $output->width,
'height' => array_key_exists('height', $persisted) ? $persisted['height'] : $output->height,
'bytes' => array_key_exists('bytes', $persisted) ? $persisted['bytes'] : $output->bytes,
'checksum' => $persisted['checksum'] ?? $output->checksum,
'metadata' => is_array($persisted['metadata'] ?? null) ? $persisted['metadata'] : $output->metadata,
])->save();
$storagePath = trim((string) ($output->storage_path ?? ''));
if ($storagePath !== '') {
$stored++;
} else {
$failed++;
$this->warn(sprintf('Output %d could not be persisted locally.', $output->id));
}
}
$this->info(sprintf(
'AI output backfill complete: processed=%d stored=%d failed=%d.',
$processed,
$stored,
$failed
));
return self::SUCCESS;
}
private function normalizeRequestId(mixed $value): ?int
{
if (! is_numeric($value)) {
return null;
}
$requestId = (int) $value;
return $requestId > 0 ? $requestId : null;
}
}

View File

@@ -1,69 +0,0 @@
<?php
namespace App\Console\Commands;
use App\Models\AiEditRequest;
use App\Models\AiUsageLedger;
use Illuminate\Console\Command;
use Illuminate\Database\Eloquent\Builder;
class AiEditsPruneCommand extends Command
{
protected $signature = 'ai-edits:prune
{--request-days= : Override AI request retention days}
{--ledger-days= : Override usage ledger retention days}
{--pretend : Report counts without deleting data}';
protected $description = 'Prune stale AI edit requests and usage ledgers based on retention settings.';
public function handle(): int
{
$requestRetentionDays = max(1, (int) ($this->option('request-days') ?: config('ai-editing.retention.request_days', 90)));
$ledgerRetentionDays = max(1, (int) ($this->option('ledger-days') ?: config('ai-editing.retention.usage_ledger_days', 365)));
$pretend = (bool) $this->option('pretend');
$requestCutoff = now()->subDays($requestRetentionDays);
$ledgerCutoff = now()->subDays($ledgerRetentionDays);
$requestQuery = AiEditRequest::query()
->where(function (Builder $query) use ($requestCutoff): void {
$query->where(function (Builder $completedQuery) use ($requestCutoff): void {
$completedQuery->whereNotNull('completed_at')
->where('completed_at', '<=', $requestCutoff);
})->orWhere(function (Builder $expiredQuery): void {
$expiredQuery->whereNotNull('expires_at')
->where('expires_at', '<=', now());
});
});
$ledgerQuery = AiUsageLedger::query()
->where('recorded_at', '<=', $ledgerCutoff);
$requestCount = (clone $requestQuery)->count();
$ledgerCount = (clone $ledgerQuery)->count();
$this->line(sprintf(
'AI prune candidates -> requests: %d (<= %s), ledgers: %d (<= %s)',
$requestCount,
$requestCutoff->toDateString(),
$ledgerCount,
$ledgerCutoff->toDateString()
));
if ($pretend) {
$this->info('Pretend mode enabled. No records were deleted.');
return self::SUCCESS;
}
$deletedRequests = $requestQuery->delete();
$deletedLedgers = $ledgerQuery->delete();
$this->info(sprintf(
'Pruned AI data -> requests: %d, ledgers: %d.',
$deletedRequests,
$deletedLedgers
));
return self::SUCCESS;
}
}

View File

@@ -1,179 +0,0 @@
<?php
namespace App\Console\Commands;
use App\Jobs\PollAiEditRequest;
use App\Jobs\ProcessAiEditRequest;
use App\Models\AiEditRequest;
use App\Services\AiEditing\AiEditingRuntimeConfig;
use Illuminate\Console\Command;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Support\Collection;
class AiEditsRecoverStuckCommand extends Command
{
protected $signature = 'ai-edits:recover-stuck
{--minutes=30 : Minimum age in minutes for queued/processing requests}
{--requeue : Re-dispatch stuck requests back to the queue}
{--fail : Mark stuck requests as failed}';
protected $description = 'Inspect stuck AI edit requests and optionally recover them by requeueing or failing.';
public function __construct(private readonly AiEditingRuntimeConfig $runtimeConfig)
{
parent::__construct();
}
public function handle(): int
{
$minutes = max(1, (int) $this->option('minutes'));
$shouldRequeue = (bool) $this->option('requeue');
$shouldFail = (bool) $this->option('fail');
if ($shouldRequeue && $shouldFail) {
$this->error('Use either --requeue or --fail, not both.');
return self::FAILURE;
}
$cutoff = now()->subMinutes($minutes);
$requests = AiEditRequest::query()
->with([
'event:id,slug,name',
'providerRuns' => function (HasMany $query): void {
$query->select(['id', 'request_id', 'provider_task_id', 'attempt'])
->orderByDesc('attempt');
},
])
->whereIn('status', [AiEditRequest::STATUS_QUEUED, AiEditRequest::STATUS_PROCESSING])
->where(function (Builder $query) use ($cutoff): void {
$query
->where(function (Builder $queuedQuery) use ($cutoff): void {
$queuedQuery->whereNull('started_at')
->whereNotNull('queued_at')
->where('queued_at', '<=', $cutoff);
})
->orWhere(function (Builder $processingQuery) use ($cutoff): void {
$processingQuery->whereNotNull('started_at')
->where('started_at', '<=', $cutoff);
})
->orWhere(function (Builder $fallbackQuery) use ($cutoff): void {
$fallbackQuery->whereNull('queued_at')
->whereNull('started_at')
->where('updated_at', '<=', $cutoff);
});
})
->orderBy('updated_at')
->get();
if ($requests->isEmpty()) {
$this->info(sprintf('No stuck AI edit requests older than %d minute(s).', $minutes));
return self::SUCCESS;
}
$this->table(
['ID', 'Event', 'Status', 'Queued/Started', 'Latest task'],
$requests->map(function (AiEditRequest $request): array {
$latestTaskId = $this->latestProviderTaskId($request) ?? '-';
$eventLabel = (string) ($request->event?->name ?: $request->event?->slug ?: $request->event_id);
$ageSource = $request->started_at ?: $request->queued_at ?: $request->updated_at;
return [
(string) $request->id,
$eventLabel,
$request->status,
$ageSource?->toIso8601String() ?? '-',
$latestTaskId,
];
})->all()
);
if (! $shouldRequeue && ! $shouldFail) {
$this->info('Dry-run only. Use --requeue to dispatch recovery jobs or --fail to terminate stuck requests.');
return self::SUCCESS;
}
if ($shouldFail) {
$count = $this->markAsFailed($requests);
$this->info(sprintf('Marked %d AI edit request(s) as failed.', $count));
return self::SUCCESS;
}
[$processDispatches, $pollDispatches] = $this->requeueRequests($requests);
$this->info(sprintf(
'Recovered %d stuck AI edit request(s): %d process dispatch(es), %d poll dispatch(es).',
$processDispatches + $pollDispatches,
$processDispatches,
$pollDispatches
));
return self::SUCCESS;
}
/**
* @return array{0:int,1:int}
*/
private function requeueRequests(Collection $requests): array
{
$queueName = $this->runtimeConfig->queueName();
$processDispatches = 0;
$pollDispatches = 0;
foreach ($requests as $request) {
if ($request->status === AiEditRequest::STATUS_QUEUED) {
ProcessAiEditRequest::dispatch($request->id)->onQueue($queueName);
$processDispatches++;
continue;
}
$providerTaskId = $this->latestProviderTaskId($request);
if ($providerTaskId !== null) {
PollAiEditRequest::dispatch($request->id, $providerTaskId, 1)->onQueue($queueName);
$pollDispatches++;
continue;
}
ProcessAiEditRequest::dispatch($request->id)->onQueue($queueName);
$processDispatches++;
}
return [$processDispatches, $pollDispatches];
}
private function markAsFailed(Collection $requests): int
{
$updated = 0;
$now = now();
foreach ($requests as $request) {
$request->forceFill([
'status' => AiEditRequest::STATUS_FAILED,
'failure_code' => 'operator_recovery_marked_failed',
'failure_message' => 'Marked as failed by ai-edits:recover-stuck.',
'completed_at' => $now,
])->save();
$updated++;
}
return $updated;
}
private function latestProviderTaskId(AiEditRequest $request): ?string
{
foreach ($request->providerRuns as $run) {
$taskId = trim((string) ($run->provider_task_id ?? ''));
if ($taskId !== '') {
return $taskId;
}
}
return null;
}
}

View File

@@ -7,6 +7,7 @@ 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
@@ -24,12 +25,10 @@ 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;
@@ -46,7 +45,6 @@ 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;
}
@@ -69,14 +67,12 @@ 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;
}
@@ -84,7 +80,6 @@ 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,27 +10,22 @@ 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);
@@ -44,7 +39,6 @@ class BackfillThumbnails extends Command
}
}
$this->info("Done. Thumbnails generated: {$count}");
return self::SUCCESS;
}
@@ -55,7 +49,6 @@ class BackfillThumbnails extends Command
if (str_starts_with($p, '/storage/')) {
return substr($p, strlen('/storage/'));
}
return null;
}
}

View File

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

View File

@@ -4,15 +4,15 @@ namespace App\Console\Commands;
use App\Models\PackagePurchase;
use App\Models\Tenant;
use App\Models\TenantPackage;
use App\Models\User;
use App\Models\TenantPackage;
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,20 +21,19 @@ 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',
@@ -44,7 +43,7 @@ class MigrateLegacyPurchases extends Command
$tempTenant = Tenant::create([
'user_id' => $tempUser->id,
'name' => 'Legacy Tenant '.$purchase->id,
'name' => 'Legacy Tenant ' . $purchase->id,
'status' => 'active',
]);
@@ -74,7 +73,6 @@ class MigrateLegacyPurchases extends Command
}
$this->info('Legacy migration completed.');
return 0;
}
}
}

View File

@@ -46,12 +46,6 @@ 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 = [];
@@ -84,7 +78,6 @@ class MonitorStorageCommand extends Command
];
}
$targetChecksumMismatches = $checksumMismatches[$target->id] ?? 0;
$snapshotTargets[] = [
'id' => $target->id,
'key' => $target->key,
@@ -92,35 +85,13 @@ 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));
@@ -220,62 +191,4 @@ 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

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

View File

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

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} {--from-disk : Rebuild manifest + CSS from existing font files without downloading}';
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 $description = 'Download the most popular Google Fonts to the local public/fonts/google directory and generate a manifest + CSS file.';
@@ -20,17 +20,6 @@ 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) {
@@ -43,10 +32,16 @@ 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'));
@@ -211,204 +206,6 @@ 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,6 +5,8 @@ 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
{
@@ -26,10 +28,11 @@ 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,10 +79,9 @@ class PostResource extends Resource
->label('Inhalt')
->required()
->columnSpanFull(),
Textarea::make('excerpt.de')
TextInput::make('excerpt.de')
->label('Auszug')
->maxLength(65535)
->columnSpanFull(),
->maxLength(255),
TextInput::make('meta_title.de')
->label('Meta-Titel')
->maxLength(255),
@@ -100,10 +99,9 @@ class PostResource extends Resource
MarkdownEditor::make('content.en')
->label('Inhalt')
->columnSpanFull(),
Textarea::make('excerpt.en')
TextInput::make('excerpt.en')
->label('Auszug')
->maxLength(65535)
->columnSpanFull(),
->maxLength(255),
TextInput::make('meta_title.en')
->label('Meta-Titel')
->maxLength(255),
@@ -123,10 +121,9 @@ class PostResource extends Resource
->unique(BlogPost::class, 'slug', ignoreRecord: true)
->maxLength(255)
->columnSpanFull(),
FileUpload::make('banner')
FileUpload::make('featured_image')
->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

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

View File

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

View File

@@ -1,8 +1,8 @@
<?php
namespace App\Filament\Clusters\DailyOps\Resources\TenantCheckoutHealths\Tables;
namespace App\Filament\Clusters\DailyOps\Resources\TenantPaddleHealths\Tables;
use App\Filament\Clusters\DailyOps\Resources\TenantCheckoutHealths\TenantCheckoutHealthResource;
use App\Filament\Clusters\DailyOps\Resources\TenantPaddleHealths\TenantPaddleHealthResource;
use App\Models\CheckoutSession;
use App\Models\Tenant;
use Filament\Forms\Components\TextInput;
@@ -13,9 +13,12 @@ use Filament\Tables\Filters\SelectFilter;
use Filament\Tables\Table;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Support\Carbon;
use Illuminate\Support\Str;
class TenantCheckoutHealthTable
class TenantPaddleHealthTable
{
private const FAILED_SYNC_STATUSES = ['failed', 'pull-failed'];
public static function configure(Table $table): Table
{
return $table
@@ -32,6 +35,11 @@ class TenantCheckoutHealthTable
->label(__('admin.tenants.fields.contact_email'))
->searchable()
->toggleable(isToggledHiddenByDefault: true),
TextColumn::make('paddle_customer_id')
->label('Paddle customer')
->toggleable(isToggledHiddenByDefault: true)
->copyable()
->formatStateUsing(fn (?string $state) => $state ?: '—'),
TextColumn::make('subscription_status')
->label('Subscription')
->badge()
@@ -48,77 +56,134 @@ class TenantCheckoutHealthTable
->badge()
->color(fn (string $state) => $state === '—' ? 'gray' : 'success')
->toggleable(isToggledHiddenByDefault: true),
TextColumn::make('paddle_subscription_id')
->label('Paddle subscription')
->toggleable(isToggledHiddenByDefault: true)
->copyable()
->getStateUsing(fn (Tenant $record) => $record->activeResellerPackage?->paddle_subscription_id)
->formatStateUsing(fn (?string $state) => $state ?: '—'),
IconColumn::make('missing_paddle_subscription')
->label('Missing Paddle subscription')
->boolean()
->getStateUsing(fn (Tenant $record) => self::missingPaddleSubscription($record)),
IconColumn::make('status_mismatch')
->label('Status mismatch')
->boolean()
->getStateUsing(fn (Tenant $record) => self::hasStatusMismatch($record)),
TextColumn::make('last_checkout_transaction_at')
->label('Last transaction')
TextColumn::make('paddle_customer_duplicates')
->label('Paddle duplicates')
->sortable()
->toggleable(isToggledHiddenByDefault: true)
->formatStateUsing(fn (?int $state) => $state && $state > 1 ? (string) $state : '—'),
TextColumn::make('paddle_sync_status')
->label('Paddle sync')
->badge()
->color(fn (?string $state) => match ($state) {
'synced' => 'success',
'syncing' => 'warning',
'pulled' => 'info',
'dry-run' => 'gray',
'failed', 'pull-failed' => 'danger',
default => 'gray',
})
->formatStateUsing(fn (?string $state) => $state ? Str::headline($state) : '—')
->getStateUsing(fn (Tenant $record) => $record->activeResellerPackage?->package?->paddle_sync_status)
->toggleable(isToggledHiddenByDefault: true),
TextColumn::make('paddle_synced_at')
->label('Paddle synced')
->badge()
->color(fn ($state) => self::syncAgeColor($state))
->formatStateUsing(fn ($state) => $state?->diffForHumans() ?? '—')
->getStateUsing(fn (Tenant $record) => $record->activeResellerPackage?->package?->paddle_synced_at),
TextColumn::make('last_paddle_transaction_at')
->label('Last Paddle tx')
->badge()
->color(fn (?Carbon $state) => self::transactionAgeColor($state))
->getStateUsing(fn (Tenant $record) => $record->last_checkout_transaction_at
? Carbon::parse($record->last_checkout_transaction_at)
->getStateUsing(fn (Tenant $record) => $record->last_paddle_transaction_at
? Carbon::parse($record->last_paddle_transaction_at)
: null)
->formatStateUsing(fn (?Carbon $state) => $state?->diffForHumans() ?? '—')
->toggleable(isToggledHiddenByDefault: true),
TextColumn::make('checkout_transaction_count_window')
->label('Transactions (30d)')
TextColumn::make('paddle_transaction_count_window')
->label('Paddle tx (30d)')
->default('0')
->sortable()
->toggleable(),
TextColumn::make('checkout_transaction_total_window')
->label('Total (30d)')
TextColumn::make('paddle_transaction_total_window')
->label('Paddle total (30d)')
->default(0)
->money('EUR')
->sortable()
->toggleable(),
TextColumn::make('checkout_refund_count_window')
TextColumn::make('paddle_refund_count_window')
->label('Refunds (30d)')
->badge()
->color(fn (?int $state) => $state && $state > 0 ? 'danger' : 'gray')
->default('0')
->sortable()
->toggleable(isToggledHiddenByDefault: true),
TextColumn::make('checkout_refund_total_window')
TextColumn::make('paddle_refund_total_window')
->label('Refund total (30d)')
->default(0)
->money('EUR')
->sortable()
->toggleable(isToggledHiddenByDefault: true),
TextColumn::make('checkout_requires_action_count')
TextColumn::make('paddle_checkout_requires_action_count')
->label('Checkout action required')
->badge()
->color(fn (?int $state) => $state && $state > 0 ? 'warning' : 'gray')
->default('0')
->sortable()
->toggleable(isToggledHiddenByDefault: true),
TextColumn::make('checkout_processing_count')
TextColumn::make('paddle_checkout_processing_count')
->label('Checkout processing')
->badge()
->color(fn (?int $state) => $state && $state > 0 ? 'warning' : 'gray')
->default('0')
->sortable()
->toggleable(isToggledHiddenByDefault: true),
TextColumn::make('checkout_expired_count')
TextColumn::make('paddle_checkout_expired_count')
->label('Checkout expired')
->badge()
->color(fn (?int $state) => $state && $state > 0 ? 'danger' : 'gray')
->default('0')
->sortable()
->toggleable(isToggledHiddenByDefault: true),
TextColumn::make('checkout_transaction_count')
->label('Transactions (all)')
TextColumn::make('paddle_transaction_count')
->label('Paddle tx (all)')
->default('0')
->sortable()
->toggleable(isToggledHiddenByDefault: true),
TextColumn::make('checkout_transaction_total')
->label('Total (all)')
TextColumn::make('paddle_transaction_total')
->label('Paddle total (all)')
->default(0)
->money('EUR')
->sortable()
->toggleable(isToggledHiddenByDefault: true),
])
->filters([
Filter::make('missing_paddle_customer')
->label('Missing Paddle customer')
->indicator('Missing Paddle customer')
->query(fn (Builder $query) => $query->whereNull('paddle_customer_id')),
Filter::make('missing_paddle_subscription')
->label('Missing Paddle subscription')
->indicator('Missing Paddle subscription')
->query(fn (Builder $query) => $query->whereHas('activeResellerPackage', fn (Builder $query) => $query
->where('active', true)
->whereNull('paddle_subscription_id'))),
Filter::make('duplicate_paddle_customer')
->label('Duplicate Paddle customer')
->indicator('Duplicate Paddle customer')
->query(fn (Builder $query) => $query
->whereNotNull('paddle_customer_id')
->whereIn('paddle_customer_id', function ($subquery) {
$subquery->select('paddle_customer_id')
->from('tenants')
->whereNotNull('paddle_customer_id')
->groupBy('paddle_customer_id')
->havingRaw('count(*) > 1');
})),
Filter::make('status_mismatch')
->label('Status mismatch')
->indicator('Status mismatch')
@@ -140,24 +205,39 @@ class TenantCheckoutHealthTable
->where('is_suspended', false)
->whereNull('pending_deletion_at')
->whereNull('anonymized_at')),
Filter::make('checkout_transaction_stale')
->label('Stale transactions')
->indicator('Stale transactions')
Filter::make('paddle_sync_failed')
->label('Paddle sync failed')
->indicator('Paddle sync failed')
->query(fn (Builder $query) => $query->whereHas('activeResellerPackage.package', fn (Builder $query) => $query
->whereIn('paddle_sync_status', self::FAILED_SYNC_STATUSES))),
Filter::make('paddle_sync_stale')
->label('Paddle sync stale')
->indicator('Paddle sync stale')
->query(fn (Builder $query) => $query->whereHas('activeResellerPackage.package', fn (Builder $query) => $query
->whereNotNull('paddle_synced_at')
->where('paddle_synced_at', '<', now()->subDays(TenantPaddleHealthResource::STALE_SYNC_DAYS)))),
Filter::make('paddle_sync_missing')
->label('Missing Paddle sync timestamp')
->indicator('Missing Paddle sync timestamp')
->query(fn (Builder $query) => $query->whereHas('activeResellerPackage.package', fn (Builder $query) => $query
->whereNull('paddle_synced_at'))),
Filter::make('paddle_transaction_stale')
->label('Stale Paddle transactions')
->indicator('Stale Paddle transactions')
->query(function (Builder $query): Builder {
$cutoff = now()->subDays(TenantCheckoutHealthResource::TRANSACTION_WINDOW_DAYS);
$provider = TenantCheckoutHealthResource::provider();
$cutoff = now()->subDays(TenantPaddleHealthResource::TRANSACTION_WINDOW_DAYS);
return $query
->whereHas('purchases', fn (Builder $query) => $query->where('provider', $provider))
->whereHas('purchases', fn (Builder $query) => $query->where('provider', 'paddle'))
->whereDoesntHave('purchases', fn (Builder $query) => $query
->where('provider', $provider)
->where('provider', 'paddle')
->where('purchased_at', '>=', $cutoff));
}),
Filter::make('checkout_attention')
->label('Checkout attention')
->indicator('Checkout attention')
->query(fn (Builder $query) => $query->whereHas('checkoutSessions', function (Builder $query) {
$query->where('provider', TenantCheckoutHealthResource::provider())
$query->where('provider', 'paddle')
->where(function (Builder $query) {
$query->whereIn('status', [
CheckoutSession::STATUS_REQUIRES_CUSTOMER_ACTION,
@@ -194,11 +274,10 @@ class TenantCheckoutHealthTable
return $query;
}
$cutoff = now()->subDays(TenantCheckoutHealthResource::TRANSACTION_WINDOW_DAYS);
$provider = TenantCheckoutHealthResource::provider();
$cutoff = now()->subDays(TenantPaddleHealthResource::TRANSACTION_WINDOW_DAYS);
return $query->whereHas('purchases', fn (Builder $query) => $query
->where('provider', $provider)
->where('provider', 'paddle')
->where('refunded', true)
->where('purchased_at', '>=', $cutoff), '>=', $min);
}),
@@ -235,6 +314,13 @@ class TenantCheckoutHealthTable
return false;
}
private static function missingPaddleSubscription(Tenant $record): bool
{
$package = $record->activeResellerPackage;
return $package && $package->active && ! $package->paddle_subscription_id;
}
private static function applyStatusMismatchFilter(Builder $query): Builder
{
return $query->where(function (Builder $query) {
@@ -252,13 +338,26 @@ class TenantCheckoutHealthTable
});
}
private static function syncAgeColor($state): string
{
if (! $state) {
return 'gray';
}
if ($state->lt(now()->subDays(TenantPaddleHealthResource::STALE_SYNC_DAYS))) {
return 'danger';
}
return 'success';
}
private static function transactionAgeColor(?Carbon $state): string
{
if (! $state) {
return 'gray';
}
if ($state->lt(now()->subDays(TenantCheckoutHealthResource::TRANSACTION_WINDOW_DAYS))) {
if ($state->lt(now()->subDays(TenantPaddleHealthResource::TRANSACTION_WINDOW_DAYS))) {
return 'danger';
}

View File

@@ -1,10 +1,10 @@
<?php
namespace App\Filament\Clusters\DailyOps\Resources\TenantCheckoutHealths;
namespace App\Filament\Clusters\DailyOps\Resources\TenantPaddleHealths;
use App\Filament\Clusters\DailyOps\DailyOpsCluster;
use App\Filament\Clusters\DailyOps\Resources\TenantCheckoutHealths\Pages\ListTenantCheckoutHealths;
use App\Filament\Clusters\DailyOps\Resources\TenantCheckoutHealths\Tables\TenantCheckoutHealthTable;
use App\Filament\Clusters\DailyOps\Resources\TenantPaddleHealths\Pages\ListTenantPaddleHealths;
use App\Filament\Clusters\DailyOps\Resources\TenantPaddleHealths\Tables\TenantPaddleHealthTable;
use App\Models\CheckoutSession;
use App\Models\Tenant;
use BackedEnum;
@@ -13,11 +13,11 @@ use Filament\Tables\Table;
use Illuminate\Database\Eloquent\Builder;
use UnitEnum;
class TenantCheckoutHealthResource extends Resource
class TenantPaddleHealthResource extends Resource
{
public const TRANSACTION_WINDOW_DAYS = 30;
public const STALE_SYNC_DAYS = 30;
public const DEFAULT_PROVIDER = CheckoutSession::PROVIDER_PAYPAL;
public const TRANSACTION_WINDOW_DAYS = 30;
protected static ?string $model = Tenant::class;
@@ -25,13 +25,13 @@ class TenantCheckoutHealthResource extends Resource
protected static ?string $cluster = DailyOpsCluster::class;
protected static ?string $slug = 'checkout-health';
protected static ?string $slug = 'paddle-health';
protected static ?int $navigationSort = 20;
public static function table(Table $table): Table
{
return TenantCheckoutHealthTable::configure($table);
return TenantPaddleHealthTable::configure($table);
}
public static function canCreate(): bool
@@ -41,7 +41,7 @@ class TenantCheckoutHealthResource extends Resource
public static function getNavigationLabel(): string
{
return __('admin.checkout_health.navigation.label');
return __('admin.paddle_health.navigation.label');
}
public static function getNavigationGroup(): UnitEnum|string|null
@@ -51,32 +51,37 @@ class TenantCheckoutHealthResource extends Resource
public static function getEloquentQuery(): Builder
{
$provider = static::provider();
$windowStart = now()->subDays(self::TRANSACTION_WINDOW_DAYS);
return parent::getEloquentQuery()
->with(['activeResellerPackage.package'])
->withExists('activeResellerPackage as has_active_reseller_package')
->addSelect([
'paddle_customer_duplicates' => Tenant::query()
->selectRaw('count(*)')
->whereColumn('paddle_customer_id', 'tenants.paddle_customer_id')
->whereNotNull('paddle_customer_id'),
])
->withCount([
'purchases as checkout_transaction_count' => fn (Builder $query) => $query
->where('provider', $provider)
'purchases as paddle_transaction_count' => fn (Builder $query) => $query
->where('provider', 'paddle')
->where('refunded', false),
'purchases as checkout_transaction_count_window' => fn (Builder $query) => $query
->where('provider', $provider)
'purchases as paddle_transaction_count_window' => fn (Builder $query) => $query
->where('provider', 'paddle')
->where('refunded', false)
->where('purchased_at', '>=', $windowStart),
'purchases as checkout_refund_count_window' => fn (Builder $query) => $query
->where('provider', $provider)
'purchases as paddle_refund_count_window' => fn (Builder $query) => $query
->where('provider', 'paddle')
->where('refunded', true)
->where('purchased_at', '>=', $windowStart),
'checkoutSessions as checkout_requires_action_count' => fn (Builder $query) => $query
->where('provider', $provider)
'checkoutSessions as paddle_checkout_requires_action_count' => fn (Builder $query) => $query
->where('provider', CheckoutSession::PROVIDER_PADDLE)
->where('status', CheckoutSession::STATUS_REQUIRES_CUSTOMER_ACTION),
'checkoutSessions as checkout_processing_count' => fn (Builder $query) => $query
->where('provider', $provider)
'checkoutSessions as paddle_checkout_processing_count' => fn (Builder $query) => $query
->where('provider', CheckoutSession::PROVIDER_PADDLE)
->where('status', CheckoutSession::STATUS_PROCESSING),
'checkoutSessions as checkout_expired_count' => fn (Builder $query) => $query
->where('provider', $provider)
'checkoutSessions as paddle_checkout_expired_count' => fn (Builder $query) => $query
->where('provider', CheckoutSession::PROVIDER_PADDLE)
->whereNotIn('status', [
CheckoutSession::STATUS_COMPLETED,
CheckoutSession::STATUS_CANCELLED,
@@ -85,37 +90,32 @@ class TenantCheckoutHealthResource extends Resource
->where('expires_at', '<', now()),
])
->withSum([
'purchases as checkout_transaction_total' => fn (Builder $query) => $query
->where('provider', $provider)
'purchases as paddle_transaction_total' => fn (Builder $query) => $query
->where('provider', 'paddle')
->where('refunded', false),
], 'price')
->withSum([
'purchases as checkout_transaction_total_window' => fn (Builder $query) => $query
->where('provider', $provider)
'purchases as paddle_transaction_total_window' => fn (Builder $query) => $query
->where('provider', 'paddle')
->where('refunded', false)
->where('purchased_at', '>=', $windowStart),
], 'price')
->withSum([
'purchases as checkout_refund_total_window' => fn (Builder $query) => $query
->where('provider', $provider)
'purchases as paddle_refund_total_window' => fn (Builder $query) => $query
->where('provider', 'paddle')
->where('refunded', true)
->where('purchased_at', '>=', $windowStart),
], 'price')
->withMax([
'purchases as last_checkout_transaction_at' => fn (Builder $query) => $query
->where('provider', $provider),
'purchases as last_paddle_transaction_at' => fn (Builder $query) => $query
->where('provider', 'paddle'),
], 'purchased_at');
}
public static function getPages(): array
{
return [
'index' => ListTenantCheckoutHealths::route('/'),
'index' => ListTenantPaddleHealths::route('/'),
];
}
public static function provider(): string
{
return (string) config('checkout.default_provider', self::DEFAULT_PROVIDER);
}
}

View File

@@ -1,163 +0,0 @@
<?php
namespace App\Filament\Resources\AiStyles;
use App\Filament\Clusters\RareAdmin\RareAdminCluster;
use App\Filament\Resources\AiStyles\Pages\ManageAiStyles;
use App\Models\AiStyle;
use App\Services\Audit\SuperAdminAuditLogger;
use BackedEnum;
use Filament\Actions;
use Filament\Forms\Components\KeyValue;
use Filament\Forms\Components\Select;
use Filament\Forms\Components\Textarea;
use Filament\Forms\Components\TextInput;
use Filament\Forms\Components\Toggle;
use Filament\Resources\Resource;
use Filament\Schemas\Components\Section;
use Filament\Schemas\Schema;
use Filament\Tables;
use Filament\Tables\Table;
use UnitEnum;
class AiStyleResource extends Resource
{
protected static ?string $model = AiStyle::class;
protected static ?string $cluster = RareAdminCluster::class;
protected static BackedEnum|string|null $navigationIcon = 'heroicon-o-paint-brush';
protected static UnitEnum|string|null $navigationGroup = null;
protected static ?int $navigationSort = 31;
public static function getNavigationGroup(): UnitEnum|string|null
{
return __('admin.nav.platform');
}
public static function getNavigationLabel(): string
{
return 'AI Styles';
}
public static function form(Schema $schema): Schema
{
return $schema->schema([
Section::make('Style Basics')
->schema([
TextInput::make('key')
->required()
->maxLength(120)
->unique(ignoreRecord: true),
TextInput::make('name')
->required()
->maxLength(120),
TextInput::make('version')
->numeric()
->default(1)
->disabled()
->dehydrated(false)
->helperText('Auto-increments when core style configuration changes.'),
TextInput::make('category')
->maxLength(50),
TextInput::make('sort')
->numeric()
->default(0)
->required(),
Toggle::make('is_active')
->default(true),
Toggle::make('is_premium')
->default(false),
Toggle::make('requires_source_image')
->default(true),
])
->columns(3),
Section::make('Provider Binding')
->schema([
Select::make('provider')
->options([
'runware' => 'runware.ai',
])
->required()
->default('runware'),
TextInput::make('provider_model')
->maxLength(120),
])
->columns(2),
Section::make('Prompts')
->schema([
Textarea::make('description')
->rows(2),
Textarea::make('prompt_template')
->rows(5),
Textarea::make('negative_prompt_template')
->rows(4),
]),
Section::make('Metadata')
->schema([
KeyValue::make('metadata')
->nullable(),
]),
]);
}
public static function table(Table $table): Table
{
return $table
->defaultSort('sort')
->columns([
Tables\Columns\TextColumn::make('key')
->searchable()
->copyable(),
Tables\Columns\TextColumn::make('name')
->searchable(),
Tables\Columns\TextColumn::make('version')
->sortable()
->toggleable(),
Tables\Columns\TextColumn::make('provider')
->badge(),
Tables\Columns\TextColumn::make('provider_model')
->toggleable(),
Tables\Columns\IconColumn::make('is_active')
->boolean(),
Tables\Columns\IconColumn::make('is_premium')
->boolean(),
Tables\Columns\TextColumn::make('sort')
->sortable(),
Tables\Columns\TextColumn::make('updated_at')
->since()
->toggleable(),
])
->filters([
Tables\Filters\TernaryFilter::make('is_active'),
Tables\Filters\TernaryFilter::make('is_premium'),
])
->actions([
Actions\EditAction::make()
->after(fn (array $data, AiStyle $record) => app(SuperAdminAuditLogger::class)->recordModelMutation(
'updated',
$record,
SuperAdminAuditLogger::fieldsMetadata(array_keys($data)),
static::class
)),
Actions\DeleteAction::make()
->after(fn (AiStyle $record) => app(SuperAdminAuditLogger::class)->recordModelMutation(
'deleted',
$record,
source: static::class
)),
])
->bulkActions([
Actions\DeleteBulkAction::make(),
]);
}
public static function getPages(): array
{
return [
'index' => ManageAiStyles::route('/'),
];
}
}

View File

@@ -1,26 +0,0 @@
<?php
namespace App\Filament\Resources\AiStyles\Pages;
use App\Filament\Resources\AiStyles\AiStyleResource;
use App\Services\Audit\SuperAdminAuditLogger;
use Filament\Actions;
use Filament\Resources\Pages\ManageRecords;
class ManageAiStyles extends ManageRecords
{
protected static string $resource = AiStyleResource::class;
protected function getHeaderActions(): array
{
return [
Actions\CreateAction::make()
->after(fn (array $data, $record) => app(SuperAdminAuditLogger::class)->recordModelMutation(
'created',
$record,
SuperAdminAuditLogger::fieldsMetadata(array_keys($data)),
static::class
)),
];
}
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -13,9 +13,7 @@ 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;
@@ -38,7 +36,6 @@ 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

@@ -6,29 +6,24 @@ use App\Filament\Clusters\DailyOps\DailyOpsCluster;
use App\Filament\Resources\EventResource\Pages;
use App\Filament\Resources\EventResource\RelationManagers\EventPackagesRelationManager;
use App\Models\Event;
use App\Models\EventJoinToken;
use App\Models\EventJoinTokenEvent;
use App\Models\EventType;
use App\Models\Tenant;
use App\Services\Audit\SuperAdminAuditLogger;
use App\Services\EventJoinTokenService;
use App\Support\JoinTokenLayoutRegistry;
use BackedEnum;
use Carbon\Carbon;
use Filament\Actions;
use Filament\Forms\Components\DatePicker;
use Filament\Forms\Components\DateTimePicker;
use Filament\Forms\Components\KeyValue;
use Filament\Forms\Components\Select;
use Filament\Forms\Components\TextInput;
use Filament\Forms\Components\Toggle;
use Filament\Notifications\Notification;
use Filament\Resources\Resource;
use Filament\Schemas\Schema;
use Filament\Tables;
use Filament\Tables\Table;
use Illuminate\Database\Eloquent\Collection;
use Illuminate\Support\Arr;
use UnitEnum;
class EventResource extends Resource
@@ -65,32 +60,19 @@ 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(fn () => EventType::all()->pluck('name.de', 'id'))
->options(EventType::query()->pluck('name', '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()
->visibleOn('create'),
->required(),
TextInput::make('default_locale')
->label(__('admin.events.fields.default_locale'))
->default('de')
@@ -98,10 +80,6 @@ class EventResource extends Resource
Toggle::make('is_active')
->label(__('admin.events.fields.is_active'))
->default(true),
Toggle::make('settings.marketing_demo')
->label(__('admin.events.fields.marketing_demo'))
->helperText(__('admin.events.fields.marketing_demo_help'))
->default(false),
KeyValue::make('settings')
->label(__('admin.events.fields.settings'))
->keyLabel(__('admin.common.key'))
@@ -118,13 +96,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')
Tables\Columns\TextColumn::make('name.de')
->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()
@@ -137,6 +115,22 @@ 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([])
@@ -173,161 +167,7 @@ class EventResource extends Resource
->modalHeading(__('admin.events.modal.join_link_heading'))
->modalSubmitActionLabel(__('admin.common.close'))
->modalWidth('xl')
->registerModalActions([
Actions\Action::make('extend_join_token_expiry')
->label(__('admin.events.join_link.extend_expiry'))
->icon('heroicon-o-clock')
->color('warning')
->size('xs')
->modalHeading(function (Actions\Action $action, Event $record): string {
$token = static::resolveJoinTokenFromAction($record, $action);
return $token
? __('admin.events.join_link.extend_expiry_heading', [
'label' => $token->label ?: __('admin.events.join_link.token_default', ['id' => $token->id]),
])
: __('admin.events.join_link.extend_expiry_heading_fallback');
})
->schema(function (Event $record): array {
$minimumExpiry = app(EventJoinTokenService::class)->minimumExpiryForEvent($record);
$rules = [
'date',
'after:now',
];
if ($minimumExpiry) {
$rules[] = 'after_or_equal:'.$minimumExpiry->toDateTimeString();
}
return [
DateTimePicker::make('expires_at')
->label(__('admin.events.join_link.extend_expiry_label'))
->required()
->seconds(false)
->rules($rules)
->helperText($minimumExpiry
? __('admin.events.join_link.extend_expiry_min', [
'date' => $minimumExpiry->isoFormat('LLL'),
])
: null),
];
})
->fillForm(function (Actions\Action $action, Event $record): array {
$token = static::resolveJoinTokenFromAction($record, $action);
if (! $token) {
return [];
}
return [
'expires_at' => $token->expires_at,
];
})
->action(function (array $data, Actions\Action $action, Event $record): void {
$token = static::resolveJoinTokenFromAction($record, $action);
if (! $token) {
Notification::make()
->title(__('admin.events.join_link.extend_expiry_missing'))
->danger()
->send();
return;
}
$expiresAt = $data['expires_at'] ?? null;
if (! $expiresAt) {
Notification::make()
->title(__('admin.events.join_link.extend_expiry_missing_date'))
->danger()
->send();
return;
}
$resolvedExpiry = $expiresAt instanceof Carbon
? $expiresAt
: Carbon::parse($expiresAt);
$token->forceFill([
'expires_at' => $resolvedExpiry,
])->save();
app(SuperAdminAuditLogger::class)->recordModelMutation(
'updated',
$token,
source: static::class
);
Notification::make()
->title(__('admin.events.join_link.extend_expiry_success'))
->success()
->send();
}),
Actions\Action::make('set_demo_read_only')
->label(__('admin.events.join_link.demo_read_only_action'))
->icon('heroicon-o-lock-closed')
->color('gray')
->size('xs')
->modalHeading(function (Actions\Action $action, Event $record): string {
$token = static::resolveJoinTokenFromAction($record, $action);
return $token
? __('admin.events.join_link.demo_read_only_heading', [
'label' => $token->label ?: __('admin.events.join_link.token_default', ['id' => $token->id]),
])
: __('admin.events.join_link.demo_read_only_heading_fallback');
})
->schema([
Toggle::make('demo_read_only')
->label(__('admin.events.join_link.demo_read_only_label'))
->helperText(__('admin.events.join_link.demo_read_only_help')),
])
->fillForm(function (Actions\Action $action, Event $record): array {
$token = static::resolveJoinTokenFromAction($record, $action);
return [
'demo_read_only' => (bool) Arr::get($token?->metadata ?? [], 'demo_read_only', false),
];
})
->action(function (array $data, Actions\Action $action, Event $record): void {
$token = static::resolveJoinTokenFromAction($record, $action);
if (! $token) {
Notification::make()
->title(__('admin.events.join_link.demo_read_only_missing'))
->danger()
->send();
return;
}
$metadata = is_array($token->metadata) ? $token->metadata : [];
$enabled = (bool) ($data['demo_read_only'] ?? false);
if ($enabled) {
$metadata['demo_read_only'] = true;
} else {
unset($metadata['demo_read_only']);
}
$token->metadata = empty($metadata) ? null : $metadata;
$token->save();
app(SuperAdminAuditLogger::class)->recordModelMutation(
'updated',
$token,
source: static::class
);
Notification::make()
->title(__('admin.events.join_link.demo_read_only_success'))
->success()
->send();
}),
])
->modalContent(function (Actions\Action $action, $record) {
->modalContent(function ($record) {
$tokens = $record->joinTokens()
->orderByDesc('created_at')
->get();
@@ -397,7 +237,6 @@ class EventResource extends Resource
'expires_at' => optional($token->expires_at)->toIso8601String(),
'revoked_at' => optional($token->revoked_at)->toIso8601String(),
'is_active' => $token->isActive(),
'demo_read_only' => (bool) Arr::get($token->metadata ?? [], 'demo_read_only', false),
'created_at' => optional($token->created_at)->toIso8601String(),
'layouts' => $layouts,
'layouts_url' => route('api.v1.tenant.events.join-tokens.layouts.index', [
@@ -417,7 +256,6 @@ class EventResource extends Resource
return view('filament.events.join-link', [
'event' => $record,
'tokens' => $tokens,
'action' => $action,
]);
}),
])
@@ -444,43 +282,6 @@ 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 : '';
}
private static function resolveJoinTokenFromAction(Event $record, Actions\Action $action): ?EventJoinToken
{
$tokenId = $action->getArguments()['token_id'] ?? null;
if (! $tokenId) {
return null;
}
return $record->joinTokens()
->whereKey($tokenId)
->first();
}
public static function getPages(): array
{
return [

View File

@@ -8,25 +8,4 @@ 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,6 +19,7 @@ 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
{
@@ -58,7 +59,6 @@ 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,4 +147,9 @@ 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,64 +113,18 @@ 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()
->action(function (Collection $records, Actions\DeleteBulkAction $action) {
->after(function (Collection $records): void {
$logger = app(SuperAdminAuditLogger::class);
$deletedCount = 0;
$failedCount = 0;
foreach ($records as $record) {
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();
}
$logger->recordModelMutation(
'deleted',
$record,
source: static::class
);
}
}),
]);

View File

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

View File

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

View File

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

View File

@@ -4,21 +4,15 @@ namespace App\Filament\Resources;
use App\Filament\Clusters\WeeklyOps\WeeklyOpsCluster;
use App\Filament\Resources\PackageAddonResource\Pages;
use App\Jobs\SyncPackageAddonToLemonSqueezy;
use App\Models\CheckoutSession;
use App\Jobs\SyncPackageAddonToPaddle;
use App\Models\PackageAddon;
use App\Services\Audit\SuperAdminAuditLogger;
use Filament\Actions;
use Filament\Forms\Components\DateTimePicker;
use Filament\Forms\Components\Placeholder;
use Filament\Forms\Components\Select;
use Filament\Forms\Components\TagsInput;
use Filament\Forms\Components\TextInput;
use Filament\Forms\Components\Toggle;
use Filament\Notifications\Notification;
use Filament\Resources\Resource;
use Filament\Schemas\Components\Section;
use Filament\Schemas\Components\Utilities\Get;
use Filament\Schemas\Schema;
use Filament\Tables;
use Filament\Tables\Columns\BadgeColumn;
@@ -56,18 +50,10 @@ class PackageAddonResource extends Resource
->required()
->unique(ignoreRecord: true)
->maxLength(191),
TextInput::make('variant_id')
->label('Lemon Squeezy Variant-ID')
->helperText('Variant-ID aus Lemon Squeezy für dieses Add-on')
->required(fn (Get $get): bool => (bool) $get('active') && ! is_numeric($get('metadata.price_eur')))
TextInput::make('price_id')
->label('Paddle Preis-ID')
->helperText('Paddle Billing Preis-ID für dieses Add-on')
->maxLength(191),
TextInput::make('metadata.price_eur')
->label('PayPal Preis (EUR)')
->helperText('Für PayPal-Checkout erforderlich (z. B. 9.90).')
->numeric()
->step(0.01)
->minValue(0.01)
->required(fn (Get $get): bool => (bool) $get('active') && blank($get('variant_id'))),
TextInput::make('sort')
->label('Sortierung')
->numeric()
@@ -75,23 +61,6 @@ class PackageAddonResource extends Resource
Toggle::make('active')
->label('Aktiv')
->default(true),
Placeholder::make('sellable_state')
->label('Verfügbarkeits-Check')
->content(function (Get $get): string {
$isActive = (bool) $get('active');
$hasVariant = filled($get('variant_id'));
$hasPayPalPrice = is_numeric($get('metadata.price_eur'));
if (! $isActive) {
return 'Inaktiv';
}
if (! $hasVariant && ! $hasPayPalPrice) {
return 'Nicht verkäuflich: Variant-ID oder PayPal Preis fehlt.';
}
return 'Verkäuflich';
}),
]),
Section::make('Limits-Inkremente')
->columns(3)
@@ -112,30 +81,6 @@ class PackageAddonResource extends Resource
->minValue(0)
->default(0),
]),
Section::make('Feature-Entitlements')
->columns(2)
->schema([
Select::make('metadata.scope')
->label('Scope')
->options([
'photos' => 'Fotos',
'guests' => 'Gäste',
'gallery' => 'Galerie',
'feature' => 'Feature',
'bundle' => 'Bundle',
])
->native(false)
->searchable(),
TagsInput::make('metadata.entitlements.features')
->label('Freigeschaltete Features')
->helperText('Feature-Keys für Freischaltungen, z. B. ai_styling')
->placeholder('z. B. ai_styling')
->columnSpanFull(),
DateTimePicker::make('metadata.entitlements.expires_at')
->label('Entitlement gültig bis')
->seconds(false)
->nullable(),
]),
]);
}
@@ -151,33 +96,10 @@ class PackageAddonResource extends Resource
->label('Schlüssel')
->copyable()
->sortable(),
TextColumn::make('variant_id')
->label('Lemon Squeezy Variant-ID')
TextColumn::make('price_id')
->label('Paddle Preis-ID')
->toggleable()
->copyable(),
TextColumn::make('metadata.price_eur')
->label('PayPal Preis (EUR)')
->formatStateUsing(fn (mixed $state): string => is_numeric($state) ? number_format((float) $state, 2, ',', '.').' €' : '—')
->toggleable(),
TextColumn::make('metadata.scope')
->label('Scope')
->badge()
->toggleable(),
TextColumn::make('metadata.entitlements.features')
->label('Features')
->formatStateUsing(function (mixed $state): string {
if (! is_array($state)) {
return '—';
}
$features = array_values(array_filter(array_map(
static fn (mixed $feature): string => trim((string) $feature),
$state,
)));
return $features === [] ? '—' : implode(', ', $features);
})
->toggleable(),
TextColumn::make('extra_photos')->label('Fotos +'),
TextColumn::make('extra_guests')->label('Gäste +'),
TextColumn::make('extra_gallery_days')->label('Galerietage +'),
@@ -188,14 +110,6 @@ class PackageAddonResource extends Resource
'danger' => false,
])
->formatStateUsing(fn (bool $state) => $state ? 'Aktiv' : 'Inaktiv'),
BadgeColumn::make('sellability')
->label('Checkout')
->state(fn (PackageAddon $record): string => static::sellabilityLabel($record))
->colors([
'success' => fn (string $state): bool => $state === 'Verkäuflich',
'warning' => fn (string $state): bool => $state === 'Unvollständig',
'gray' => fn (string $state): bool => $state === 'Inaktiv',
]),
TextColumn::make('sort')
->label('Sort')
->sortable()
@@ -206,16 +120,16 @@ class PackageAddonResource extends Resource
->label('Aktiv'),
])
->actions([
Actions\Action::make('syncLemonSqueezy')
->label('Mit Lemon Squeezy synchronisieren')
Actions\Action::make('syncPaddle')
->label('Mit Paddle synchronisieren')
->icon('heroicon-o-cloud-arrow-up')
->action(function (PackageAddon $record) {
SyncPackageAddonToLemonSqueezy::dispatch($record->id);
SyncPackageAddonToPaddle::dispatch($record->id);
Notification::make()
->success()
->title('Lemon Squeezy-Sync gestartet')
->body('Das Add-on wird im Hintergrund mit Lemon Squeezy abgeglichen.')
->title('Paddle-Sync gestartet')
->body('Das Add-on wird im Hintergrund mit Paddle abgeglichen.')
->send();
}),
Actions\EditAction::make()
@@ -252,21 +166,4 @@ class PackageAddonResource extends Resource
'edit' => Pages\EditPackageAddon::route('/{record}/edit'),
];
}
protected static function sellabilityLabel(PackageAddon $record): string
{
if (! $record->active) {
return 'Inaktiv';
}
return $record->isSellableForProvider(static::addonProvider()) ? 'Verkäuflich' : 'Unvollständig';
}
protected static function addonProvider(): string
{
return (string) (
config('package-addons.provider')
?? config('checkout.default_provider', CheckoutSession::PROVIDER_PAYPAL)
);
}
}

View File

@@ -4,9 +4,12 @@ namespace App\Filament\Resources;
use App\Filament\Clusters\WeeklyOps\WeeklyOpsCluster;
use App\Filament\Resources\PackageResource\Pages;
use App\Jobs\PullPackageFromPaddle;
use App\Jobs\SyncPackageToPaddle;
use App\Models\Package;
use App\Services\Audit\SuperAdminAuditLogger;
use BackedEnum;
use Filament\Actions;
use Filament\Actions\BulkActionGroup;
use Filament\Actions\DeleteAction;
use Filament\Actions\DeleteBulkAction;
@@ -23,6 +26,7 @@ use Filament\Forms\Components\Repeater;
use Filament\Forms\Components\Select;
use Filament\Forms\Components\TextInput;
use Filament\Forms\Components\Toggle;
use Filament\Notifications\Notification;
use Filament\Resources\Resource;
use Filament\Schemas\Components\Section;
use Filament\Schemas\Components\Tabs as SchemaTabs;
@@ -168,31 +172,31 @@ class PackageResource extends Resource
->columnSpanFull()
->default([]),
]),
Section::make('Lemon Squeezy Billing')
Section::make('Paddle Billing')
->columns(2)
->schema([
TextInput::make('lemonsqueezy_product_id')
->label('Lemon Squeezy Produkt-ID')
TextInput::make('paddle_product_id')
->label('Paddle Produkt-ID')
->maxLength(191)
->helperText('Produkt aus Lemon Squeezy. Leer lassen, wenn noch nicht synchronisiert.')
->helperText('Produkt aus Paddle Billing. Leer lassen, wenn noch nicht synchronisiert.')
->placeholder('nicht verknüpft'),
TextInput::make('lemonsqueezy_variant_id')
->label('Lemon Squeezy Variant-ID')
TextInput::make('paddle_price_id')
->label('Paddle Preis-ID')
->maxLength(191)
->helperText('Variant-ID aus Lemon Squeezy, verknüpft mit diesem Paket.')
->helperText('Preis-ID aus Paddle Billing, verknüpft mit diesem Paket.')
->placeholder('nicht verknüpft'),
Placeholder::make('lemonsqueezy_sync_status')
Placeholder::make('paddle_sync_status')
->label('Sync-Status')
->content(fn (?Package $record) => $record?->lemonsqueezy_sync_status ? Str::headline($record->lemonsqueezy_sync_status) : '')
->content(fn (?Package $record) => $record?->paddle_sync_status ? Str::headline($record->paddle_sync_status) : '')
->columnSpanFull(),
Placeholder::make('lemonsqueezy_synced_at')
Placeholder::make('paddle_synced_at')
->label('Zuletzt synchronisiert')
->content(fn (?Package $record) => $record?->lemonsqueezy_synced_at ? $record->lemonsqueezy_synced_at->diffForHumans() : '')
->content(fn (?Package $record) => $record?->paddle_synced_at ? $record->paddle_synced_at->diffForHumans() : '')
->columnSpanFull(),
Placeholder::make('lemonsqueezy_sync_error')
Placeholder::make('paddle_sync_error')
->label('Letzter Fehler')
->content(fn (?Package $record) => $record?->lemonsqueezy_sync_error_message ?? '')
->visible(fn (?Package $record) => filled($record?->lemonsqueezy_sync_error_message))
->content(fn (?Package $record) => $record?->paddle_sync_error_message ?? '')
->visible(fn (?Package $record) => filled($record?->paddle_sync_error_message))
->columnSpanFull(),
]),
]);
@@ -259,15 +263,15 @@ class PackageResource extends Resource
->label('Features')
->wrap()
->formatStateUsing(fn ($state) => static::formatFeaturesForDisplay($state)),
TextColumn::make('lemonsqueezy_product_id')
->label('Lemon Squeezy Produkt')
TextColumn::make('paddle_product_id')
->label('Paddle Produkt')
->toggleable(isToggledHiddenByDefault: true)
->formatStateUsing(fn ($state) => $state ?: '-'),
TextColumn::make('lemonsqueezy_variant_id')
->label('Lemon Squeezy Variant')
TextColumn::make('paddle_price_id')
->label('Paddle Preis')
->toggleable(isToggledHiddenByDefault: true)
->formatStateUsing(fn ($state) => $state ?: '-'),
BadgeColumn::make('lemonsqueezy_sync_status')
BadgeColumn::make('paddle_sync_status')
->label('Sync-Status')
->colors([
'success' => 'synced',
@@ -277,13 +281,13 @@ class PackageResource extends Resource
])
->formatStateUsing(fn ($state) => $state ? Str::headline($state) : null)
->toggleable(isToggledHiddenByDefault: true),
TextColumn::make('lemonsqueezy_synced_at')
TextColumn::make('paddle_synced_at')
->label('Sync am')
->dateTime()
->toggleable(isToggledHiddenByDefault: true),
TextColumn::make('lemonsqueezy_sync_error_message')
TextColumn::make('paddle_sync_error_message')
->label('Sync-Fehler')
->getStateUsing(fn (Package $record) => $record->lemonsqueezy_sync_error_message)
->getStateUsing(fn (Package $record) => $record->paddle_sync_error_message)
->wrap()
->toggleable(isToggledHiddenByDefault: true),
])
@@ -297,6 +301,71 @@ class PackageResource extends Resource
TrashedFilter::make(),
])
->actions([
Actions\Action::make('syncPaddle')
->label('Mit Paddle abgleichen')
->icon('heroicon-o-cloud-arrow-up')
->color('success')
->requiresConfirmation()
->disabled(fn (Package $record) => $record->paddle_sync_status === 'syncing')
->action(function (Package $record) {
SyncPackageToPaddle::dispatch($record->id);
Notification::make()
->success()
->title('Paddle-Sync gestartet')
->body('Das Paket wird im Hintergrund mit Paddle abgeglichen.')
->send();
}),
Actions\Action::make('linkPaddle')
->label('Paddle verknüpfen')
->icon('heroicon-o-link')
->color('info')
->form([
TextInput::make('paddle_product_id')
->label('Paddle Produkt-ID')
->required()
->maxLength(191),
TextInput::make('paddle_price_id')
->label('Paddle Preis-ID')
->required()
->maxLength(191),
])
->fillForm(fn (Package $record) => [
'paddle_product_id' => $record->paddle_product_id,
'paddle_price_id' => $record->paddle_price_id,
])
->action(function (Package $record, array $data): void {
$record->linkPaddleIds($data['paddle_product_id'], $data['paddle_price_id']);
PullPackageFromPaddle::dispatch($record->id);
app(SuperAdminAuditLogger::class)->recordModelMutation(
'linked',
$record,
SuperAdminAuditLogger::fieldsMetadata($data),
static::class
);
Notification::make()
->success()
->title('Paddle-Verknüpfung gespeichert')
->body('Die IDs wurden gespeichert und ein Pull wurde angestoßen.')
->send();
}),
Actions\Action::make('pullPaddle')
->label('Status von Paddle holen')
->icon('heroicon-o-cloud-arrow-down')
->disabled(fn (Package $record) => ! $record->paddle_product_id && ! $record->paddle_price_id)
->requiresConfirmation()
->action(function (Package $record) {
PullPackageFromPaddle::dispatch($record->id);
Notification::make()
->info()
->title('Paddle-Abgleich angefordert')
->body('Der aktuelle Stand aus Paddle wird geladen und hier hinterlegt.')
->send();
}),
ViewAction::make(),
EditAction::make()
->after(fn (array $data, Package $record) => app(SuperAdminAuditLogger::class)->recordModelMutation(
@@ -396,7 +465,6 @@ class PackageResource extends Resource
'unlimited_sharing' => 'Unbegrenztes Teilen',
'no_watermark' => 'Kein Wasserzeichen',
'custom_branding' => 'Eigenes Branding',
'ai_styling' => 'AI-Styling',
'custom_tasks' => 'Eigene Aufgaben',
'reseller_dashboard' => 'Reseller-Dashboard',
'advanced_analytics' => 'Erweiterte Analytics',

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -8,7 +8,6 @@ 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;
@@ -73,10 +72,10 @@ class TenantResource extends Resource
->email()
->required()
->maxLength(255),
TextInput::make('lemonsqueezy_customer_id')
->label('Lemon Squeezy Customer ID')
TextInput::make('paddle_customer_id')
->label('Paddle Customer ID')
->maxLength(191)
->helperText('Verknüpfung mit Lemon Squeezy Kundenkonto.')
->helperText('Verknuepfung mit Paddle Billing Kundenkonto.')
->nullable(),
TextInput::make('total_revenue')
->label(__('admin.tenants.fields.total_revenue'))
@@ -135,8 +134,8 @@ class TenantResource extends Resource
->getStateUsing(fn (Tenant $record) => $record->user?->full_name ?? 'Unbekannt'),
Tables\Columns\TextColumn::make('slug')->searchable(),
Tables\Columns\TextColumn::make('contact_email'),
Tables\Columns\TextColumn::make('lemonsqueezy_customer_id')
->label('Lemon Squeezy Customer')
Tables\Columns\TextColumn::make('paddle_customer_id')
->label('Paddle Customer')
->toggleable(isToggledHiddenByDefault: true)
->formatStateUsing(fn ($state) => $state ?: '-'),
Tables\Columns\TextColumn::make('active_reseller_package_id')
@@ -206,13 +205,11 @@ 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

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

View File

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

View File

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

View File

@@ -1,177 +0,0 @@
<?php
namespace App\Filament\SuperAdmin\Pages;
use App\Filament\Clusters\RareAdmin\RareAdminCluster;
use App\Models\AiEditingSetting;
use App\Services\Audit\SuperAdminAuditLogger;
use Filament\Forms;
use Filament\Notifications\Notification;
use Filament\Pages\Page;
use Filament\Schemas\Components\Section;
use Filament\Schemas\Schema;
class AiEditingSettingsPage extends Page
{
protected static null|string|\BackedEnum $navigationIcon = 'heroicon-o-sparkles';
protected static ?string $cluster = RareAdminCluster::class;
protected string $view = 'filament.super-admin.pages.ai-editing-settings-page';
protected static null|string|\UnitEnum $navigationGroup = null;
protected static ?int $navigationSort = 30;
public static function getNavigationGroup(): \UnitEnum|string|null
{
return __('admin.nav.platform');
}
public static function getNavigationLabel(): string
{
return 'AI Editing Settings';
}
public bool $is_enabled = true;
public string $default_provider = 'runware';
public ?string $fallback_provider = null;
public string $runware_mode = 'live';
public bool $queue_auto_dispatch = false;
public string $queue_name = 'default';
public int $queue_max_polls = 6;
/**
* @var array<int, string>
*/
public array $blocked_terms = [];
public ?string $status_message = null;
public function mount(): void
{
$settings = AiEditingSetting::current();
$this->is_enabled = (bool) $settings->is_enabled;
$this->default_provider = (string) ($settings->default_provider ?: 'runware');
$this->fallback_provider = $settings->fallback_provider ? (string) $settings->fallback_provider : null;
$this->runware_mode = (string) ($settings->runware_mode ?: 'live');
$this->queue_auto_dispatch = (bool) $settings->queue_auto_dispatch;
$this->queue_name = (string) ($settings->queue_name ?: 'default');
$this->queue_max_polls = max(1, (int) ($settings->queue_max_polls ?: 6));
$this->blocked_terms = array_values(array_filter(array_map(
static fn (mixed $term): string => trim((string) $term),
(array) $settings->blocked_terms
)));
$this->status_message = $settings->status_message ? (string) $settings->status_message : null;
}
public function form(Schema $schema): Schema
{
return $schema->schema([
Section::make('Global Availability')
->schema([
Forms\Components\Toggle::make('is_enabled')
->label('Enable AI editing globally'),
Forms\Components\Textarea::make('status_message')
->label('Disabled message')
->maxLength(255)
->rows(2)
->helperText('Shown to guest and tenant clients when the feature is disabled.')
->nullable(),
]),
Section::make('Provider')
->schema([
Forms\Components\Select::make('default_provider')
->label('Default provider')
->options([
'runware' => 'runware.ai',
])
->required(),
Forms\Components\TextInput::make('fallback_provider')
->label('Fallback provider')
->maxLength(40)
->helperText('Reserved for provider failover.'),
Forms\Components\Select::make('runware_mode')
->label('Runware mode')
->options([
'live' => 'Live API',
'fake' => 'Fake mode (internal testing)',
])
->required(),
])
->columns(2),
Section::make('Queue Orchestration')
->schema([
Forms\Components\Toggle::make('queue_auto_dispatch')
->label('Auto-dispatch jobs after request creation'),
Forms\Components\TextInput::make('queue_name')
->label('Queue name')
->required()
->maxLength(60),
Forms\Components\TextInput::make('queue_max_polls')
->label('Max provider polls')
->numeric()
->minValue(1)
->maxValue(50)
->required(),
])
->columns(2),
Section::make('Prompt Safety')
->schema([
Forms\Components\TagsInput::make('blocked_terms')
->label('Blocked prompt terms')
->helperText('Case-insensitive term match before queue dispatch.')
->placeholder('Add blocked term'),
]),
]);
}
public function save(): void
{
$this->validate();
$settings = AiEditingSetting::query()->firstOrNew(['id' => 1]);
$settings->is_enabled = $this->is_enabled;
$settings->default_provider = $this->default_provider;
$settings->fallback_provider = $this->nullableString($this->fallback_provider);
$settings->runware_mode = $this->runware_mode;
$settings->queue_auto_dispatch = $this->queue_auto_dispatch;
$settings->queue_name = $this->queue_name;
$settings->queue_max_polls = max(1, $this->queue_max_polls);
$settings->blocked_terms = array_values(array_filter(array_map(
static fn (mixed $term): string => trim((string) $term),
$this->blocked_terms
)));
$settings->status_message = $this->nullableString($this->status_message);
$settings->save();
$changed = $settings->getChanges();
if ($changed !== []) {
app(SuperAdminAuditLogger::class)->record(
'ai_editing.settings_updated',
$settings,
SuperAdminAuditLogger::fieldsMetadata(array_keys($changed)),
source: static::class
);
}
Notification::make()
->title('AI editing settings saved.')
->success()
->send();
}
private function nullableString(?string $value): ?string
{
$trimmed = trim((string) $value);
return $trimmed !== '' ? $trimmed : null;
}
}

View File

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

View File

@@ -1,280 +0,0 @@
<?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,88 +14,11 @@ class DokployPlatformHealth extends Widget
protected function getViewData(): array
{
$projects = $this->loadProjects();
return [
'projects' => $projects,
'composes' => empty($projects) ? $this->loadComposes() : [],
'composes' => $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);
@@ -139,7 +62,7 @@ class DokployPlatformHealth extends Widget
'label' => 'Dokploy',
'compose_id' => '-',
'status' => 'unconfigured',
'error' => 'Set DOKPLOY_PROJECT_IDS or DOKPLOY_COMPOSE_IDS in .env to enable monitoring.',
'error' => 'Set DOKPLOY_COMPOSE_IDS in .env to enable monitoring.',
],
];
}
@@ -147,252 +70,6 @@ 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\Carbon;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Carbon;
class PlatformStatsWidget extends BaseWidget
{

View File

@@ -12,8 +12,6 @@ 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,6 +7,7 @@ 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,7 +14,6 @@ class TopTenantsByUploads extends BaseWidget
{
return __('admin.widgets.top_tenants_by_uploads.heading');
}
protected ?string $pollingInterval = '60s';
public function table(Tables\Table $table): Tables\Table
@@ -34,3 +33,4 @@ class TopTenantsByUploads extends BaseWidget
->paginated(false);
}
}

View File

@@ -2,8 +2,8 @@
namespace App\Http\Controllers\Admin;
use Illuminate\Http\Request;
use Illuminate\Routing\Controller as BaseController;
use Illuminate\Http\Request;
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

@@ -1,549 +0,0 @@
<?php
namespace App\Http\Controllers\Api;
use App\Http\Requests\Api\GuestAiEditStoreRequest;
use App\Jobs\ProcessAiEditRequest;
use App\Models\AiEditRequest;
use App\Models\AiStyle;
use App\Models\Event;
use App\Models\Photo;
use App\Services\AiEditing\AiBudgetGuardService;
use App\Services\AiEditing\AiEditingRuntimeConfig;
use App\Services\AiEditing\AiStyleAccessService;
use App\Services\AiEditing\AiStylingEntitlementService;
use App\Services\AiEditing\EventAiEditingPolicyService;
use App\Services\AiEditing\Safety\AiAbuseEscalationService;
use App\Services\AiEditing\Safety\AiSafetyPolicyService;
use App\Services\EventJoinTokenService;
use App\Support\ApiError;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Routing\Controller as BaseController;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Facades\Storage;
use Illuminate\Support\Str;
use Symfony\Component\HttpFoundation\Response;
class EventPublicAiEditController extends BaseController
{
public function __construct(
private readonly EventJoinTokenService $joinTokenService,
private readonly AiSafetyPolicyService $safetyPolicy,
private readonly AiEditingRuntimeConfig $runtimeConfig,
private readonly AiBudgetGuardService $budgetGuard,
private readonly AiStylingEntitlementService $entitlements,
private readonly EventAiEditingPolicyService $eventPolicy,
private readonly AiStyleAccessService $styleAccess,
private readonly AiAbuseEscalationService $abuseEscalation,
) {}
public function store(GuestAiEditStoreRequest $request, string $token, int $photo): JsonResponse
{
$event = $this->resolvePublishedEvent($token);
if ($event instanceof JsonResponse) {
return $event;
}
$photoModel = Photo::query()
->whereKey($photo)
->where('event_id', $event->id)
->first();
if (! $photoModel) {
return ApiError::response(
'photo_not_found',
'Photo not found',
'The specified photo could not be located for this event.',
Response::HTTP_NOT_FOUND
);
}
if ($photoModel->status !== 'approved') {
return ApiError::response(
'photo_not_eligible',
'Photo not eligible',
'Only approved photos can be used for AI edits.',
Response::HTTP_UNPROCESSABLE_ENTITY
);
}
if (! $this->runtimeConfig->isEnabled()) {
return ApiError::response(
'feature_disabled',
'Feature disabled',
$this->runtimeConfig->statusMessage() ?: 'AI editing is currently disabled by platform policy.',
Response::HTTP_FORBIDDEN
);
}
$entitlement = $this->entitlements->resolveForEvent($event);
if (! $entitlement['allowed']) {
return ApiError::response(
'feature_locked',
'Feature locked',
$this->entitlements->lockedMessage(),
Response::HTTP_FORBIDDEN,
[
'required_feature' => $entitlement['required_feature'],
'addon_keys' => $entitlement['addon_keys'],
]
);
}
$policy = $this->eventPolicy->resolve($event);
if (! $policy['enabled']) {
return ApiError::response(
'event_feature_disabled',
'Feature disabled for this event',
$policy['policy_message'] ?? 'AI editing is disabled for this event.',
Response::HTTP_FORBIDDEN
);
}
$budgetDecision = $this->budgetGuard->evaluateForEvent($event);
if (! $budgetDecision['allowed']) {
return ApiError::response(
$budgetDecision['reason_code'] ?? 'budget_hard_cap_reached',
'Budget limit reached',
$budgetDecision['message'] ?? 'The AI editing budget for this billing period has been exhausted.',
Response::HTTP_FORBIDDEN,
[
'budget' => $budgetDecision['budget'],
]
);
}
$style = $this->resolveStyleByKey($request->input('style_key'));
if ($request->filled('style_key') && ! $style) {
return ApiError::response(
'style_not_found',
'Style not found',
'The selected style is not available.',
Response::HTTP_UNPROCESSABLE_ENTITY
);
}
if (
$style
&& (! $this->eventPolicy->isStyleAllowed($event, $style) || ! $this->styleAccess->canUseStyle($event, $style))
) {
return ApiError::response(
'style_not_allowed',
'Style not allowed',
$policy['policy_message'] ?? 'This style is not allowed for this event.',
Response::HTTP_UNPROCESSABLE_ENTITY,
[
'allowed_style_keys' => $policy['allowed_style_keys'],
]
);
}
$prompt = (string) ($request->input('prompt') ?: $style?->prompt_template ?: '');
$negativePrompt = (string) ($request->input('negative_prompt') ?: $style?->negative_prompt_template ?: '');
$providerModel = $request->input('provider_model') ?: $style?->provider_model;
$safetyDecision = $this->safetyPolicy->evaluatePrompt($prompt, $negativePrompt);
$deviceId = $this->normalizeOptionalString((string) $request->header('X-Device-Id', ''));
$sessionId = $this->normalizeOptionalString((string) $request->input('session_id', ''));
$scopeKey = $this->normalizeOptionalString($deviceId ?: $sessionId) ?: 'guest';
$abuseSignal = null;
$safetyReasons = $safetyDecision->reasonCodes;
if ($safetyDecision->blocked) {
$abuseSignal = $this->abuseEscalation->recordPromptBlock(
(int) $event->tenant_id,
(int) $event->id,
$scopeKey
);
if (($abuseSignal['escalated'] ?? false) && ! in_array(AiAbuseEscalationService::REASON_CODE, $safetyReasons, true)) {
$safetyReasons[] = AiAbuseEscalationService::REASON_CODE;
}
}
$metadata = (array) $request->input('metadata', []);
if (is_array($abuseSignal)) {
$metadata['abuse'] = $abuseSignal;
}
$metadata['budget'] = $budgetDecision['budget'];
$idempotencyKey = $this->resolveIdempotencyKey(
$request->input('idempotency_key'),
$request->header('X-Idempotency-Key'),
$photoModel,
$style,
$prompt,
$deviceId,
$sessionId
);
$attributes = [
'event_id' => $event->id,
'photo_id' => $photoModel->id,
'style_id' => $style?->id,
'provider' => $this->runtimeConfig->defaultProvider(),
'provider_model' => $providerModel,
'status' => $safetyDecision->blocked ? AiEditRequest::STATUS_BLOCKED : AiEditRequest::STATUS_QUEUED,
'safety_state' => $safetyDecision->state,
'prompt' => $prompt,
'negative_prompt' => $negativePrompt,
'input_image_path' => $photoModel->file_path,
'requested_by_device_id' => $deviceId,
'requested_by_session_id' => $sessionId,
'safety_reasons' => $safetyReasons,
'failure_code' => $safetyDecision->failureCode,
'failure_message' => $safetyDecision->failureMessage,
'queued_at' => now(),
'completed_at' => $safetyDecision->blocked ? now() : null,
'metadata' => $metadata,
];
$editRequest = AiEditRequest::query()->firstOrCreate(
['tenant_id' => $event->tenant_id, 'idempotency_key' => $idempotencyKey],
$attributes
);
if (! $editRequest->wasRecentlyCreated && $this->isIdempotencyConflict(
$editRequest,
$event,
$photoModel,
$style?->id,
$prompt,
$negativePrompt,
$providerModel,
$deviceId,
$sessionId
)) {
return ApiError::response(
'idempotency_conflict',
'Idempotency conflict',
'The provided idempotency key is already in use for another request.',
Response::HTTP_CONFLICT
);
}
if (
$editRequest->wasRecentlyCreated
&& ! $safetyDecision->blocked
&& $this->runtimeConfig->queueAutoDispatch()
) {
ProcessAiEditRequest::dispatch($editRequest->id)
->onQueue($this->runtimeConfig->queueName());
}
return response()->json([
'message' => $editRequest->wasRecentlyCreated ? 'AI edit request queued' : 'AI edit request already exists',
'duplicate' => ! $editRequest->wasRecentlyCreated,
'data' => $this->serializeRequest($editRequest->fresh(['style', 'outputs'])),
], $editRequest->wasRecentlyCreated ? Response::HTTP_CREATED : Response::HTTP_OK);
}
public function show(Request $request, string $token, int $requestId): JsonResponse
{
$event = $this->resolvePublishedEvent($token);
if ($event instanceof JsonResponse) {
return $event;
}
$editRequest = AiEditRequest::query()
->with(['style', 'outputs'])
->whereKey($requestId)
->where('event_id', $event->id)
->first();
if (! $editRequest) {
return ApiError::response(
'edit_request_not_found',
'Edit request not found',
'The specified AI edit request could not be located for this event.',
Response::HTTP_NOT_FOUND
);
}
$deviceId = $this->normalizeOptionalString((string) $request->header('X-Device-Id', ''));
if ($editRequest->requested_by_device_id && $deviceId && $editRequest->requested_by_device_id !== $deviceId) {
return ApiError::response(
'forbidden_request_scope',
'Forbidden',
'This AI edit request belongs to another device.',
Response::HTTP_FORBIDDEN
);
}
return response()->json([
'data' => $this->serializeRequest($editRequest),
]);
}
public function styles(Request $request, string $token): JsonResponse
{
$event = $this->resolvePublishedEvent($token);
if ($event instanceof JsonResponse) {
return $event;
}
if (! $this->runtimeConfig->isEnabled()) {
return ApiError::response(
'feature_disabled',
'Feature disabled',
$this->runtimeConfig->statusMessage() ?: 'AI editing is currently disabled by platform policy.',
Response::HTTP_FORBIDDEN
);
}
$entitlement = $this->entitlements->resolveForEvent($event);
if (! $entitlement['allowed']) {
return ApiError::response(
'feature_locked',
'Feature locked',
$this->entitlements->lockedMessage(),
Response::HTTP_FORBIDDEN,
[
'required_feature' => $entitlement['required_feature'],
'addon_keys' => $entitlement['addon_keys'],
]
);
}
$policy = $this->eventPolicy->resolve($event);
if (! $policy['enabled']) {
return ApiError::response(
'event_feature_disabled',
'Feature disabled for this event',
$policy['policy_message'] ?? 'AI editing is disabled for this event.',
Response::HTTP_FORBIDDEN
);
}
$styles = $this->eventPolicy->filterStyles(
$event,
AiStyle::query()
->where('is_active', true)
->orderBy('sort')
->orderBy('id')
->get()
);
$styles = $this->styleAccess->filterStylesForEvent($event, $styles);
return response()->json([
'data' => $styles->map(fn (AiStyle $style) => $this->serializeStyle($style))->values(),
'meta' => [
'required_feature' => $entitlement['required_feature'],
'addon_keys' => $entitlement['addon_keys'],
'allow_custom_prompt' => $policy['allow_custom_prompt'],
'allowed_style_keys' => $policy['allowed_style_keys'],
'policy_message' => $policy['policy_message'],
],
]);
}
private function resolvePublishedEvent(string $token): Event|JsonResponse
{
$joinToken = $this->joinTokenService->findActiveToken($token);
if (! $joinToken) {
return ApiError::response(
'invalid_token',
'Invalid token',
'The provided event token is invalid or expired.',
Response::HTTP_NOT_FOUND
);
}
$event = Event::query()
->whereKey($joinToken->event_id)
->where('status', 'published')
->first();
if (! $event) {
return ApiError::response(
'event_not_public',
'Event not public',
'This event is not publicly accessible.',
Response::HTTP_FORBIDDEN
);
}
return $event;
}
private function resolveStyleByKey(?string $styleKey): ?AiStyle
{
$key = $this->normalizeOptionalString((string) ($styleKey ?? ''));
if (! $key) {
return null;
}
return AiStyle::query()
->where('key', $key)
->where('is_active', true)
->first();
}
private function normalizeOptionalString(?string $value): ?string
{
if ($value === null) {
return null;
}
$trimmed = trim($value);
return $trimmed !== '' ? $trimmed : null;
}
private function resolveIdempotencyKey(
mixed $bodyKey,
mixed $headerKey,
Photo $photo,
?AiStyle $style,
string $prompt,
?string $deviceId,
?string $sessionId
): string {
$candidate = $this->normalizeOptionalString((string) ($bodyKey ?: $headerKey ?: ''));
if ($candidate) {
return Str::limit($candidate, 120, '');
}
return substr(hash('sha256', implode('|', [
(string) $photo->event_id,
(string) $photo->id,
(string) ($style?->id ?? ''),
trim($prompt),
(string) ($deviceId ?? ''),
(string) ($sessionId ?? ''),
])), 0, 120);
}
private function isIdempotencyConflict(
AiEditRequest $request,
Event $event,
Photo $photo,
?int $styleId,
string $prompt,
string $negativePrompt,
?string $providerModel,
?string $deviceId,
?string $sessionId
): bool {
if ($request->event_id !== $event->id || $request->photo_id !== $photo->id) {
return true;
}
if ((int) ($request->style_id ?? 0) !== (int) ($styleId ?? 0)) {
return true;
}
if ($this->normalizeOptionalString($request->prompt) !== $this->normalizeOptionalString($prompt)) {
return true;
}
if ($this->normalizeOptionalString($request->negative_prompt) !== $this->normalizeOptionalString($negativePrompt)) {
return true;
}
if ($this->normalizeOptionalString($request->provider_model) !== $this->normalizeOptionalString($providerModel)) {
return true;
}
if ($this->normalizeOptionalString($request->requested_by_device_id) !== $this->normalizeOptionalString($deviceId)) {
return true;
}
return $this->normalizeOptionalString($request->requested_by_session_id) !== $this->normalizeOptionalString($sessionId);
}
private function serializeStyle(AiStyle $style): array
{
return [
'id' => $style->id,
'key' => $style->key,
'name' => $style->name,
'version' => $style->version,
'category' => $style->category,
'description' => $style->description,
'provider' => $style->provider,
'provider_model' => $style->provider_model,
'requires_source_image' => $style->requires_source_image,
'is_premium' => $style->is_premium,
'metadata' => $style->metadata ?? [],
];
}
private function serializeRequest(AiEditRequest $request): array
{
return [
'id' => $request->id,
'event_id' => $request->event_id,
'photo_id' => $request->photo_id,
'style' => $request->style ? [
'id' => $request->style->id,
'key' => $request->style->key,
'name' => $request->style->name,
] : null,
'provider' => $request->provider,
'provider_model' => $request->provider_model,
'status' => $request->status,
'safety_state' => $request->safety_state,
'safety_reasons' => $request->safety_reasons ?? [],
'failure_code' => $request->failure_code,
'failure_message' => $request->failure_message,
'queued_at' => $request->queued_at?->toIso8601String(),
'started_at' => $request->started_at?->toIso8601String(),
'completed_at' => $request->completed_at?->toIso8601String(),
'outputs' => $request->outputs->map(fn ($output) => [
'id' => $output->id,
'storage_disk' => $output->storage_disk,
'storage_path' => $output->storage_path,
'provider_url' => $output->provider_url,
'url' => $this->resolveOutputUrl(
$output->storage_disk,
$output->storage_path,
$output->provider_url
),
'mime_type' => $output->mime_type,
'width' => $output->width,
'height' => $output->height,
'is_primary' => $output->is_primary,
'safety_state' => $output->safety_state,
'safety_reasons' => $output->safety_reasons ?? [],
'generated_at' => $output->generated_at?->toIso8601String(),
])->values(),
];
}
private function resolveOutputUrl(?string $storageDisk, ?string $storagePath, ?string $providerUrl): ?string
{
$resolvedStoragePath = $this->normalizeOptionalString($storagePath);
if ($resolvedStoragePath !== null) {
if (Str::startsWith($resolvedStoragePath, ['http://', 'https://'])) {
return $resolvedStoragePath;
}
$disk = $this->resolveStorageDisk($storageDisk);
try {
return Storage::disk($disk)->url($resolvedStoragePath);
} catch (\Throwable $exception) {
Log::debug('Falling back to raw AI output storage path', [
'disk' => $disk,
'path' => $resolvedStoragePath,
'error' => $exception->getMessage(),
]);
return '/'.ltrim($resolvedStoragePath, '/');
}
}
return $this->normalizeOptionalString($providerUrl);
}
private function resolveStorageDisk(?string $disk): string
{
$candidate = trim((string) ($disk ?: config('filesystems.default', 'public')));
if ($candidate === '' || ! config("filesystems.disks.{$candidate}")) {
return (string) config('filesystems.default', 'public');
}
return $candidate;
}
}

View File

@@ -16,9 +16,6 @@ use App\Models\GuestNotification;
use App\Models\GuestPolicySetting;
use App\Models\Photo;
use App\Models\PhotoShareLink;
use App\Services\AiEditing\AiEditingRuntimeConfig;
use App\Services\AiEditing\AiStylingEntitlementService;
use App\Services\AiEditing\EventAiEditingPolicyService;
use App\Services\Analytics\JoinTokenAnalyticsRecorder;
use App\Services\EventJoinTokenService;
use App\Services\EventTasksCacheService;
@@ -44,7 +41,6 @@ use Illuminate\Support\Facades\Schema;
use Illuminate\Support\Facades\Storage;
use Illuminate\Support\Facades\URL;
use Illuminate\Support\Str;
use SimpleSoftwareIO\QrCode\Facades\QrCode;
use Symfony\Component\HttpFoundation\Response;
class EventPublicController extends BaseController
@@ -64,9 +60,6 @@ class EventPublicController extends BaseController
private readonly EventTasksCacheService $eventTasksCache,
private readonly GuestNotificationService $guestNotificationService,
private readonly PushSubscriptionService $pushSubscriptions,
private readonly AiEditingRuntimeConfig $aiEditingRuntimeConfig,
private readonly AiStylingEntitlementService $aiStylingEntitlements,
private readonly EventAiEditingPolicyService $eventAiEditingPolicy,
) {}
/**
@@ -192,57 +185,6 @@ 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)) {
@@ -1061,7 +1003,6 @@ class EventPublicController extends BaseController
* heading_font: ?string,
* body_font: ?string,
* font_size: string,
* welcome_message: ?string,
* logo_url: ?string,
* logo_mode: string,
* logo_value: ?string,
@@ -1101,8 +1042,12 @@ 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 ? [$eventBranding] : [[]];
$sources = $brandingAllowed
? ($useDefault ? [$tenantBranding] : [$eventBranding, $tenantBranding])
: [[]];
$primary = $this->normalizeHexColor(
$this->firstStringFromSources($sources, ['palette.primary', 'primary_color']),
@@ -1125,7 +1070,6 @@ class EventPublicController extends BaseController
$bodyFont = $this->firstStringFromSources($sources, ['typography.body', 'body_font', 'font_family']);
$fontSize = $this->firstStringFromSources($sources, ['typography.size', 'font_size']) ?? $defaults['size'];
$fontSize = in_array($fontSize, ['s', 'm', 'l'], true) ? $fontSize : $defaults['size'];
$welcomeMessage = $this->firstStringFromSources($sources, ['welcome_message', 'welcomeMessage']);
$logoMode = $this->firstStringFromSources($sources, ['logo.mode', 'logo_mode']);
if (! in_array($logoMode, ['emoticon', 'upload'], true)) {
@@ -1187,7 +1131,6 @@ class EventPublicController extends BaseController
'heading_font' => $headingFont,
'body_font' => $bodyFont,
'font_size' => $fontSize,
'welcome_message' => $welcomeMessage,
'logo_url' => $logoMode === 'upload' ? $logoValue : null,
'logo_mode' => $logoMode,
'logo_value' => $logoValue,
@@ -1470,7 +1413,8 @@ class EventPublicController extends BaseController
[
'slug' => $shareLink->slug,
'variant' => $variant,
]
],
absolute: false
);
}
@@ -1743,7 +1687,6 @@ class EventPublicController extends BaseController
'name' => $event->name,
'city' => $event->city,
] : null,
'branding' => $event ? $this->resolveBrandingPayload($event) : null,
])->header('Cache-Control', 'no-store');
}
@@ -1959,18 +1902,11 @@ class EventPublicController extends BaseController
$engagementMode = $settings['engagement_mode'] ?? 'tasks';
$liveShowSettings = Arr::get($settings, 'live_show', []);
$liveShowSettings = is_array($liveShowSettings) ? $liveShowSettings : [];
$aiStylingEntitlement = $this->aiStylingEntitlements->resolveForEvent($event);
$aiEditingPolicy = $this->eventAiEditingPolicy->resolve($event);
$aiStylingAvailable = $this->aiEditingRuntimeConfig->isEnabled()
&& (bool) $aiStylingEntitlement['allowed']
&& (bool) $aiEditingPolicy['enabled'];
$event->loadMissing('photoboothSetting');
$policy = $this->guestPolicy();
if ($joinToken) {
$deviceId = (string) $request->header('X-Device-Id', $request->input('device_id', ''));
$deviceId = $deviceId !== '' ? $deviceId : null;
$this->joinTokenService->incrementUsage($joinToken, $deviceId, $request->ip());
$this->joinTokenService->incrementUsage($joinToken);
}
$demoReadOnly = (bool) Arr::get($joinToken?->metadata ?? [], 'demo_read_only', false);
@@ -1991,58 +1927,10 @@ class EventPublicController extends BaseController
'live_show' => [
'moderation_mode' => $liveShowSettings['moderation_mode'] ?? 'manual',
],
'capabilities' => [
'ai_styling' => $aiStylingAvailable,
'ai_styling_granted_by' => $aiStylingEntitlement['granted_by'],
'ai_styling_required_feature' => $aiStylingEntitlement['required_feature'],
'ai_styling_addon_keys' => $aiStylingEntitlement['addon_keys'],
'ai_styling_event_enabled' => (bool) $aiEditingPolicy['enabled'],
],
'engagement_mode' => $engagementMode,
])->header('Cache-Control', 'no-store');
}
public function qr(Request $request, string $token): JsonResponse
{
$result = $this->resolvePublishedEvent($request, $token, ['id']);
if ($result instanceof JsonResponse) {
return $result;
}
[, $joinToken] = $result;
$joinTokenValue = $joinToken->token ?? $token;
$qrCodeUrl = $joinTokenValue ? url('/e/'.$joinTokenValue) : null;
$qrCodeDataUrl = null;
if ($qrCodeUrl) {
$requestedSize = (int) $request->query('size', 360);
$size = max(120, min($requestedSize, 640));
try {
$png = QrCode::format('png')
->size($size)
->margin(1)
->errorCorrection('M')
->generate($qrCodeUrl);
$pngBinary = (string) $png;
if ($pngBinary !== '') {
$qrCodeDataUrl = 'data:image/png;base64,'.base64_encode($pngBinary);
}
} catch (\Throwable $exception) {
report($exception);
}
}
return response()->json([
'url' => $qrCodeUrl,
'qr_code_data_url' => $qrCodeDataUrl,
])->header('Cache-Control', 'no-store');
}
public function package(Request $request, string $token)
{
$result = $this->resolvePublishedEvent($request, $token, ['id']);
@@ -2659,15 +2547,6 @@ class EventPublicController extends BaseController
->distinct('guest_name')
->count('guest_name');
$guestCount = DB::table('photos')
->where('event_id', $eventId)
->distinct('guest_name')
->count('guest_name');
$likesCount = (int) DB::table('photos')
->where('event_id', $eventId)
->sum('likes_count');
// Tasks solved as number of photos linked to a task (proxy metric).
$tasksSolved = $engagementMode === 'photo_only'
? 0
@@ -2678,8 +2557,6 @@ class EventPublicController extends BaseController
$payload = [
'online_guests' => $onlineGuests,
'tasks_solved' => $tasksSolved,
'guest_count' => $guestCount,
'likes_count' => $likesCount,
'latest_photo_at' => $latestPhotoAt,
'engagement_mode' => $engagementMode,
];
@@ -2865,14 +2742,12 @@ class EventPublicController extends BaseController
[$locale] = $this->resolveGuestLocale($request, $event);
$fallbacks = $this->localeFallbackChain($locale, $event->default_locale ?? null);
$deviceId = $this->normalizeGuestIdentifier((string) $request->header('X-Device-Id', ''));
$deviceId = $deviceId !== '' ? $deviceId : 'anon';
$deviceId = (string) $request->header('X-Device-Id', 'anon');
$filter = $request->query('filter');
$since = $request->query('since');
$query = DB::table('photos')
->leftJoin('tasks', 'photos.task_id', '=', 'tasks.id')
->leftJoin('emotions', 'photos.emotion_id', '=', 'emotions.id')
->select([
'photos.id',
'photos.file_path',
@@ -2881,14 +2756,9 @@ class EventPublicController extends BaseController
'photos.emotion_id',
'photos.task_id',
'photos.guest_name',
'photos.created_by_device_id',
'photos.created_at',
'photos.ingest_source',
'tasks.title as task_title',
'emotions.name as emotion_name',
'emotions.icon as emotion_icon',
'emotions.color as emotion_color',
'emotions.id as emotion_lookup_id',
])
->where('photos.event_id', $eventId)
->where('photos.status', 'approved')
@@ -2899,16 +2769,13 @@ class EventPublicController extends BaseController
if ($filter === 'photobooth') {
$query->whereIn('photos.ingest_source', [Photo::SOURCE_PHOTOBOOTH, Photo::SOURCE_SPARKBOOTH]);
} elseif ($filter === 'myphotos' && $deviceId !== 'anon') {
$query->where(function ($inner) use ($deviceId) {
$inner->where('created_by_device_id', $deviceId)
->orWhere('guest_name', $deviceId);
});
$query->where('guest_name', $deviceId);
}
if ($since) {
$query->where('photos.created_at', '>', $since);
}
$rows = $query->get()->map(function ($r) use ($fallbacks, $token, $deviceId) {
$rows = $query->get()->map(function ($r) use ($fallbacks, $token) {
$r->file_path = $this->makeSignedGalleryAssetUrlForId($token, (int) $r->id, 'full')
?? $this->resolveSignedFallbackUrl((string) ($r->file_path ?? ''));
$r->thumbnail_path = $this->makeSignedGalleryAssetUrlForId($token, (int) $r->id, 'thumbnail')
@@ -2919,25 +2786,7 @@ class EventPublicController extends BaseController
$r->task_title = $this->firstLocalizedValue($r->task_title, $fallbacks, 'Unbenannte Aufgabe');
}
$emotion = null;
if ($r->emotion_id) {
$emotionName = $this->firstLocalizedValue($r->emotion_name, $fallbacks, '');
if ($emotionName !== '') {
$emotion = [
'id' => (int) ($r->emotion_lookup_id ?? $r->emotion_id),
'name' => $emotionName,
'icon' => $r->emotion_icon ?: null,
'color' => $r->emotion_color ?: null,
];
}
}
$r->emotion = $emotion;
$r->ingest_source = $r->ingest_source ?? Photo::SOURCE_UNKNOWN;
$createdBy = $r->created_by_device_id ? $this->normalizeGuestIdentifier((string) $r->created_by_device_id) : '';
$r->is_mine = $deviceId !== 'anon'
&& $deviceId !== ''
&& (($createdBy !== '' && $createdBy === $deviceId) || ($createdBy === '' && (string) $r->guest_name === $deviceId));
return $r;
});
@@ -3030,159 +2879,6 @@ class EventPublicController extends BaseController
return response()->json(['liked' => true, 'likes_count' => $count]);
}
public function unlike(Request $request, int $id)
{
$deviceId = (string) $request->header('X-Device-Id', 'anon');
$deviceId = substr(preg_replace('/[^a-zA-Z0-9_-]/', '', $deviceId), 0, 64);
if ($deviceId === '') {
$deviceId = 'anon';
}
$photo = DB::table('photos')
->join('events', 'photos.event_id', '=', 'events.id')
->where('photos.id', $id)
->where('events.status', 'published')
->first(['photos.id', 'photos.event_id']);
if (! $photo) {
return ApiError::response(
'photo_not_found',
'Photo Not Found',
'Photo not found or event not public.',
Response::HTTP_NOT_FOUND,
['photo_id' => $id]
);
}
$exists = DB::table('photo_likes')->where('photo_id', $id)->where('guest_name', $deviceId)->exists();
if (! $exists) {
$count = (int) DB::table('photos')->where('id', $id)->value('likes_count');
return response()->json(['liked' => false, 'likes_count' => $count]);
}
DB::beginTransaction();
try {
DB::table('photo_likes')->where('photo_id', $id)->where('guest_name', $deviceId)->delete();
DB::table('photos')->where('id', $id)->update([
'likes_count' => DB::raw('case when likes_count > 0 then likes_count - 1 else 0 end'),
'updated_at' => now(),
]);
DB::commit();
} catch (\Throwable $e) {
DB::rollBack();
Log::warning('unlike failed', ['error' => $e->getMessage()]);
}
$count = (int) DB::table('photos')->where('id', $id)->value('likes_count');
return response()->json(['liked' => false, 'likes_count' => $count]);
}
public function destroyPhoto(Request $request, string $token, Photo $photo): JsonResponse
{
$result = $this->resolvePublishedEvent($request, $token, ['id']);
if ($result instanceof JsonResponse) {
return $result;
}
[$event] = $result;
$deviceId = $this->resolveDeviceIdentifier($request);
if ($deviceId === 'anonymous') {
return ApiError::response(
'photo_delete_forbidden',
'Delete Not Allowed',
'This photo cannot be deleted from this device.',
Response::HTTP_FORBIDDEN,
['photo_id' => $photo->id]
);
}
if ($photo->event_id !== (int) $event->id) {
return ApiError::response(
'photo_not_found',
'Photo Not Found',
'Photo not found or event not public.',
Response::HTTP_NOT_FOUND,
['photo_id' => $photo->id]
);
}
$ownerId = $photo->created_by_device_id
? $this->normalizeGuestIdentifier((string) $photo->created_by_device_id)
: '';
$guestName = is_string($photo->guest_name) ? $photo->guest_name : '';
$isOwner = $ownerId !== ''
? $ownerId === $deviceId
: ($guestName !== '' && $guestName === $deviceId);
if (! $isOwner) {
return ApiError::response(
'photo_delete_forbidden',
'Delete Not Allowed',
'This photo cannot be deleted from this device.',
Response::HTTP_FORBIDDEN,
['photo_id' => $photo->id]
);
}
$eventModel = Event::with(['eventPackage.package'])->find((int) $event->id);
$assets = EventMediaAsset::where('photo_id', $photo->id)->get();
foreach ($assets as $asset) {
if (! is_string($asset->path) || $asset->path === '') {
continue;
}
try {
Storage::disk($asset->disk)->delete($asset->path);
} catch (\Throwable $e) {
Log::warning('Failed to delete guest photo asset from storage', [
'asset_id' => $asset->id,
'disk' => $asset->disk,
'path' => $asset->path,
'error' => $e->getMessage(),
]);
}
}
if ($assets->isEmpty() && $eventModel) {
$fallbackDisk = $this->eventStorageManager->getHotDiskForEvent($eventModel);
$paths = array_values(array_filter([
is_string($photo->path ?? null) ? $photo->path : null,
is_string($photo->thumbnail_path ?? null) ? $photo->thumbnail_path : null,
is_string($photo->file_path ?? null) ? $photo->file_path : null,
]));
if (! empty($paths)) {
Storage::disk($fallbackDisk)->delete($paths);
}
}
DB::transaction(function () use ($photo, $assets) {
$photo->likes()->delete();
PhotoShareLink::where('photo_id', $photo->id)->delete();
if ($assets->isNotEmpty()) {
EventMediaAsset::whereIn('id', $assets->pluck('id'))->delete();
}
$photo->delete();
});
$eventPackage = $eventModel?->eventPackage;
if ($eventPackage && $eventPackage->package) {
$previousUsed = (int) $eventPackage->used_photos;
if ($previousUsed > 0) {
$eventPackage->decrement('used_photos');
$eventPackage->refresh();
$this->packageUsageTracker->recordPhotoUsage($eventPackage, $previousUsed, -1);
}
}
return response()->json([
'message' => 'Photo deleted successfully',
'photo_id' => $photo->id,
]);
}
public function upload(Request $request, string $token)
{
$result = $this->resolvePublishedEvent($request, $token, ['id']);

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

@@ -212,10 +212,6 @@ class LiveShowController extends BaseController
return Event::query()
->where('live_show_token', $token)
->where(function (Builder $query) {
$query->whereNull('live_show_token_expires_at')
->orWhere('live_show_token_expires_at', '>=', now());
})
->first();
}

View File

@@ -24,6 +24,12 @@ class CouponPreviewController extends Controller
$package = Package::findOrFail($data['package_id']);
if (! $package->paddle_price_id) {
throw ValidationException::withMessages([
'code' => __('marketing.coupon.errors.package_not_configured'),
]);
}
$tenant = Auth::user()?->tenant;
try {

View File

@@ -36,7 +36,7 @@ class GiftVoucherCheckoutController extends Controller
if (! $checkout['checkout_url']) {
throw ValidationException::withMessages([
'tier_key' => __('Unable to create checkout.'),
'tier_key' => __('Unable to create Paddle checkout.'),
]);
}
@@ -46,43 +46,24 @@ class GiftVoucherCheckoutController extends Controller
public function show(Request $request): JsonResponse
{
$data = $request->validate([
'checkout_id' => ['nullable', 'string', 'required_without_all:order_id,code'],
'order_id' => ['nullable', 'string', 'required_without_all:checkout_id,code'],
'code' => ['nullable', 'string', 'required_without_all:checkout_id,order_id'],
'checkout_id' => ['nullable', 'string', 'required_without_all:transaction_id,code'],
'transaction_id' => ['nullable', 'string', 'required_without_all:checkout_id,code'],
'code' => ['nullable', 'string', 'required_without_all:checkout_id,transaction_id'],
]);
$voucherQuery = GiftVoucher::query()
->where('status', '!=', GiftVoucher::STATUS_PENDING)
->where(function ($query) use ($data) {
$hasCondition = false;
$voucherQuery = GiftVoucher::query();
if (! empty($data['checkout_id'])) {
$query->where(function ($inner) use ($data) {
$inner->where('lemonsqueezy_checkout_id', $data['checkout_id'])
->orWhere('paypal_order_id', $data['checkout_id']);
});
if (! empty($data['checkout_id'])) {
$voucherQuery->where('paddle_checkout_id', $data['checkout_id']);
}
$hasCondition = true;
}
if (! empty($data['transaction_id'])) {
$voucherQuery->orWhere('paddle_transaction_id', $data['transaction_id']);
}
if (! empty($data['order_id'])) {
$method = $hasCondition ? 'orWhere' : 'where';
$query->{$method}(function ($inner) use ($data) {
$inner->where('lemonsqueezy_order_id', $data['order_id'])
->orWhere('paypal_capture_id', $data['order_id'])
->orWhere('paypal_order_id', $data['order_id']);
});
$hasCondition = true;
}
if (! empty($data['code'])) {
$method = $hasCondition ? 'orWhere' : 'where';
$query->{$method}('code', strtoupper($data['code']));
}
});
if (! empty($data['code'])) {
$voucherQuery->orWhere('code', strtoupper($data['code']));
}
$voucher = $voucherQuery->latest()->firstOrFail();

View File

@@ -9,40 +9,37 @@ use App\Models\Package;
use App\Models\PackagePurchase;
use App\Models\TenantPackage;
use App\Services\Checkout\CheckoutSessionService;
use App\Services\PayPal\Exceptions\PayPalException;
use App\Services\PayPal\PayPalOrderService;
use App\Services\Paddle\PaddleCheckoutService;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Log;
use Illuminate\Validation\ValidationException;
class PackageController extends Controller
{
public function __construct(
private readonly PayPalOrderService $paypalOrders,
private readonly PaddleCheckoutService $paddleCheckout,
private readonly CheckoutSessionService $sessions,
) {}
public function index(Request $request): JsonResponse
{
$type = $request->query('type', 'endcustomer');
$provider = strtolower((string) config('checkout.default_provider', CheckoutSession::PROVIDER_PAYPAL));
$packages = Package::where('type', $type)
->orderBy('price')
->get();
$packages->each(function ($package) use ($provider) {
$packages->each(function ($package) {
if (is_string($package->features)) {
$decoded = json_decode($package->features, true);
$package->features = is_array($decoded) ? $decoded : [];
} elseif (! is_array($package->features)) {
$package->features = [];
return;
}
$package->setAttribute('checkout_provider', $provider);
$package->setAttribute('can_checkout', $this->canCheckoutPackage($package, $provider));
if (! is_array($package->features)) {
$package->features = [];
}
});
return response()->json([
@@ -56,7 +53,7 @@ class PackageController extends Controller
$request->validate([
'package_id' => 'required|exists:packages,id',
'type' => 'required|in:endcustomer,reseller',
'payment_method' => 'required|in:paypal',
'payment_method' => 'required|in:paddle',
'event_id' => 'nullable|exists:events,id', // For endcustomer
'success_url' => 'nullable|url',
'return_url' => 'nullable|url',
@@ -82,7 +79,7 @@ class PackageController extends Controller
{
$request->validate([
'package_id' => 'required|exists:packages,id',
'paypal_order_id' => 'required|string',
'paddle_transaction_id' => 'required|string',
]);
$package = Package::findOrFail($request->package_id);
@@ -92,14 +89,14 @@ class PackageController extends Controller
throw ValidationException::withMessages(['tenant' => 'Tenant not found.']);
}
$provider = 'paypal';
$provider = 'paddle';
DB::transaction(function () use ($request, $package, $tenant, $provider) {
PackagePurchase::create([
'tenant_id' => $tenant->id,
'package_id' => $package->id,
'provider' => $provider,
'provider_id' => $request->input('paypal_order_id'),
'provider_id' => $request->input('paddle_transaction_id'),
'price' => $package->price,
'type' => 'endcustomer_event',
'purchased_at' => now(),
@@ -164,14 +161,12 @@ class PackageController extends Controller
], 201);
}
public function createPayPalCheckout(Request $request): JsonResponse
public function createPaddleCheckout(Request $request): JsonResponse
{
$request->validate([
'package_id' => 'required|exists:packages,id',
'success_url' => 'nullable|url',
'return_url' => 'nullable|url',
'cancel_url' => 'nullable|url',
'locale' => 'nullable|string|max:10',
]);
$package = Package::findOrFail($request->integer('package_id'));
@@ -186,11 +181,15 @@ class PackageController extends Controller
throw ValidationException::withMessages(['user' => 'User context missing.']);
}
if (! $package->paddle_price_id) {
throw ValidationException::withMessages(['package_id' => 'Package is not linked to a Paddle price.']);
}
$session = $this->sessions->createOrResume($user, $package, [
'tenant' => $tenant,
]);
$this->sessions->selectProvider($session, CheckoutSession::PROVIDER_PAYPAL);
$this->sessions->selectProvider($session, CheckoutSession::PROVIDER_PADDLE);
$now = now();
@@ -202,56 +201,30 @@ class PackageController extends Controller
'legal_version' => config('app.legal_version', $now->toDateString()),
])->save();
$successUrl = $request->input('success_url') ?? $request->input('return_url');
$cancelUrl = $request->input('cancel_url') ?? $request->input('return_url');
$paypalReturnUrl = route('paypal.return', absolute: true);
$payload = [
'success_url' => $request->input('success_url'),
'return_url' => $request->input('return_url'),
'metadata' => [
'checkout_session_id' => $session->id,
'legal_version' => $session->legal_version,
'accepted_terms' => true,
],
];
try {
$order = $this->paypalOrders->createOrder($session, $package, [
'return_url' => $paypalReturnUrl,
'cancel_url' => $paypalReturnUrl,
'locale' => $request->input('locale'),
'request_id' => $session->id,
]);
} catch (PayPalException $exception) {
Log::warning('PayPal order creation failed (tenant)', [
'tenant_id' => $tenant->id,
'package_id' => $package->id,
'session_id' => $session->id,
'message' => $exception->getMessage(),
'status' => $exception->status(),
]);
throw ValidationException::withMessages(['paypal' => 'PayPal checkout could not be created.']);
}
$orderId = $order['id'] ?? null;
if (! is_string($orderId) || $orderId === '') {
throw ValidationException::withMessages(['paypal' => 'PayPal order ID missing.']);
}
$approveUrl = $this->paypalOrders->resolveApproveUrl($order);
$checkout = $this->paddleCheckout->createCheckout($tenant, $package, $payload);
$session->forceFill([
'paypal_order_id' => $orderId,
'paddle_checkout_id' => $checkout['id'] ?? $session->paddle_checkout_id,
'provider_metadata' => array_merge($session->provider_metadata ?? [], array_filter([
'paypal_order_id' => $orderId,
'paypal_status' => $order['status'] ?? null,
'paypal_approve_url' => $approveUrl,
'paypal_success_url' => $successUrl,
'paypal_cancel_url' => $cancelUrl,
'paypal_created_at' => now()->toIso8601String(),
'paddle_checkout_id' => $checkout['id'] ?? null,
'paddle_checkout_url' => $checkout['checkout_url'] ?? null,
'paddle_expires_at' => $checkout['expires_at'] ?? null,
])),
])->save();
$this->sessions->markRequiresCustomerAction($session, 'paypal_approval');
return response()->json([
'order_id' => $orderId,
'approve_url' => $approveUrl,
'status' => $order['status'] ?? null,
return response()->json(array_merge($checkout, [
'checkout_session_id' => $session->id,
]);
]));
}
public function checkoutSessionStatus(CheckoutSessionStatusRequest $request, CheckoutSession $session): JsonResponse
@@ -266,9 +239,7 @@ class PackageController extends Controller
}
}
$checkoutUrl = $session->provider === CheckoutSession::PROVIDER_PAYPAL
? data_get($session->provider_metadata ?? [], 'paypal_approve_url')
: data_get($session->provider_metadata ?? [], 'lemonsqueezy_checkout_url');
$checkoutUrl = data_get($session->provider_metadata ?? [], 'paddle_checkout_url');
return response()->json([
'status' => $session->status,
@@ -326,57 +297,19 @@ class PackageController extends Controller
private function handlePaidPurchase(Request $request, Package $package, $tenant): JsonResponse
{
$successUrl = $request->input('success_url') ?? $request->input('return_url');
$cancelUrl = $request->input('cancel_url') ?? $request->input('return_url');
$paypalReturnUrl = route('paypal.return', absolute: true);
try {
$session = $this->sessions->createOrResume($request->user(), $package, [
'tenant' => $tenant,
]);
$this->sessions->selectProvider($session, CheckoutSession::PROVIDER_PAYPAL);
$order = $this->paypalOrders->createOrder($session, $package, [
'return_url' => $paypalReturnUrl,
'cancel_url' => $paypalReturnUrl,
'locale' => $request->input('locale'),
'request_id' => $session->id,
]);
} catch (PayPalException $exception) {
Log::warning('PayPal order creation failed (purchase)', [
'tenant_id' => $tenant->id,
'package_id' => $package->id,
'message' => $exception->getMessage(),
'status' => $exception->status(),
]);
throw ValidationException::withMessages(['paypal' => 'PayPal checkout could not be created.']);
if (! $package->paddle_price_id) {
throw ValidationException::withMessages(['package_id' => 'Package is not linked to a Paddle price.']);
}
$orderId = $order['id'] ?? null;
if (! is_string($orderId) || $orderId === '') {
throw ValidationException::withMessages(['paypal' => 'PayPal order ID missing.']);
}
return response()->json([
'order_id' => $orderId,
'approve_url' => $this->paypalOrders->resolveApproveUrl($order),
'status' => $order['status'] ?? null,
'return_url' => $successUrl,
'cancel_url' => $cancelUrl,
$checkout = $this->paddleCheckout->createCheckout($tenant, $package, [
'success_url' => $request->input('success_url'),
'return_url' => $request->input('return_url'),
'metadata' => array_filter([
'type' => $request->input('type'),
'event_id' => $request->input('event_id'),
]),
]);
}
private function canCheckoutPackage(Package $package, string $provider): bool
{
if ((float) $package->price <= 0) {
return true;
}
if ($provider === CheckoutSession::PROVIDER_LEMONSQUEEZY) {
return filled($package->lemonsqueezy_variant_id);
}
return true;
return response()->json($checkout);
}
}

View File

@@ -1,53 +0,0 @@
<?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

@@ -1,401 +0,0 @@
<?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

@@ -1,411 +0,0 @@
<?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

@@ -1,103 +0,0 @@
<?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

@@ -1,52 +0,0 @@
<?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

@@ -1,618 +0,0 @@
<?php
namespace App\Http\Controllers\Api\Tenant;
use App\Http\Controllers\Controller;
use App\Http\Requests\Tenant\AiEditIndexRequest;
use App\Http\Requests\Tenant\AiEditStoreRequest;
use App\Jobs\ProcessAiEditRequest;
use App\Models\AiEditRequest;
use App\Models\AiProviderRun;
use App\Models\AiStyle;
use App\Models\AiUsageLedger;
use App\Models\Event;
use App\Models\Photo;
use App\Services\AiEditing\AiBudgetGuardService;
use App\Services\AiEditing\AiEditingRuntimeConfig;
use App\Services\AiEditing\AiStyleAccessService;
use App\Services\AiEditing\AiStylingEntitlementService;
use App\Services\AiEditing\EventAiEditingPolicyService;
use App\Services\AiEditing\Safety\AiAbuseEscalationService;
use App\Services\AiEditing\Safety\AiSafetyPolicyService;
use App\Support\ApiError;
use App\Support\TenantMemberPermissions;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Facades\Storage;
use Illuminate\Support\Str;
use Symfony\Component\HttpFoundation\Response;
class AiEditController extends Controller
{
public function __construct(
private readonly AiSafetyPolicyService $safetyPolicy,
private readonly AiEditingRuntimeConfig $runtimeConfig,
private readonly AiBudgetGuardService $budgetGuard,
private readonly AiStylingEntitlementService $entitlements,
private readonly EventAiEditingPolicyService $eventPolicy,
private readonly AiStyleAccessService $styleAccess,
private readonly AiAbuseEscalationService $abuseEscalation,
) {}
public function index(AiEditIndexRequest $request, string $eventSlug): JsonResponse
{
$event = $this->resolveTenantEventOrFail($request, $eventSlug);
TenantMemberPermissions::ensureEventPermission($request, $event, 'photos:upload');
$perPage = (int) $request->input('per_page', 20);
$status = (string) $request->input('status', '');
$safetyState = (string) $request->input('safety_state', '');
$query = AiEditRequest::query()
->with(['style', 'outputs'])
->where('event_id', $event->id)
->orderByDesc('created_at');
if ($status !== '') {
$query->where('status', $status);
}
if ($safetyState !== '') {
$query->where('safety_state', $safetyState);
}
$requests = $query->paginate($perPage);
return response()->json([
'data' => collect($requests->items())->map(fn (AiEditRequest $item) => $this->serializeRequest($item))->values(),
'meta' => [
'current_page' => $requests->currentPage(),
'per_page' => $requests->perPage(),
'total' => $requests->total(),
'last_page' => $requests->lastPage(),
],
]);
}
public function styles(Request $request, string $eventSlug): JsonResponse
{
$event = $this->resolveTenantEventOrFail($request, $eventSlug);
TenantMemberPermissions::ensureEventPermission($request, $event, 'photos:upload');
if (! $this->runtimeConfig->isEnabled()) {
return ApiError::response(
'feature_disabled',
'Feature disabled',
$this->runtimeConfig->statusMessage() ?: 'AI editing is currently disabled by platform policy.',
Response::HTTP_FORBIDDEN
);
}
$entitlement = $this->entitlements->resolveForEvent($event);
if (! $entitlement['allowed']) {
return ApiError::response(
'feature_locked',
'Feature locked',
$this->entitlements->lockedMessage(),
Response::HTTP_FORBIDDEN,
[
'required_feature' => $entitlement['required_feature'],
'addon_keys' => $entitlement['addon_keys'],
]
);
}
$styles = AiStyle::query()
->where('is_active', true)
->orderBy('sort')
->orderBy('id')
->get();
$policy = $this->eventPolicy->resolve($event);
$styles = $this->eventPolicy->filterStyles($event, $styles);
$styles = $this->styleAccess->filterStylesForEvent($event, $styles);
return response()->json([
'data' => $styles->map(fn (AiStyle $style) => $this->serializeStyle($style))->values(),
'meta' => [
'required_feature' => $entitlement['required_feature'],
'addon_keys' => $entitlement['addon_keys'],
'event_enabled' => $policy['enabled'],
'allow_custom_prompt' => $policy['allow_custom_prompt'],
'allowed_style_keys' => $policy['allowed_style_keys'],
'policy_message' => $policy['policy_message'],
],
]);
}
public function summary(Request $request, string $eventSlug): JsonResponse
{
$event = $this->resolveTenantEventOrFail($request, $eventSlug);
TenantMemberPermissions::ensureEventPermission($request, $event, 'photos:upload');
$periodStart = now()->startOfMonth();
$periodEnd = now()->endOfMonth();
$baseQuery = AiEditRequest::query()->where('event_id', $event->id);
$statusCounts = (clone $baseQuery)
->select('status', DB::raw('count(*) as aggregate'))
->groupBy('status')
->pluck('aggregate', 'status')
->map(fn (mixed $value): int => (int) $value)
->all();
$safetyCounts = (clone $baseQuery)
->select('safety_state', DB::raw('count(*) as aggregate'))
->groupBy('safety_state')
->pluck('aggregate', 'safety_state')
->map(fn (mixed $value): int => (int) $value)
->all();
$lastRequestedAt = (clone $baseQuery)->max('created_at');
$total = array_sum($statusCounts);
$failedTotal = (int) (($statusCounts[AiEditRequest::STATUS_FAILED] ?? 0) + ($statusCounts[AiEditRequest::STATUS_BLOCKED] ?? 0));
$moderationBlockedTotal = (int) ($statusCounts[AiEditRequest::STATUS_BLOCKED] ?? 0);
$usageQuery = AiUsageLedger::query()
->where('tenant_id', $event->tenant_id)
->where('event_id', $event->id)
->where('recorded_at', '>=', $periodStart)
->where('recorded_at', '<=', $periodEnd);
$spendUsd = (float) ((clone $usageQuery)->where('entry_type', AiUsageLedger::TYPE_DEBIT)->sum('amount_usd') ?: 0.0);
$debitCount = (int) ((clone $usageQuery)->where('entry_type', AiUsageLedger::TYPE_DEBIT)->count());
$providerRunQuery = AiProviderRun::query()
->whereHas('request', fn ($query) => $query->where('event_id', $event->id))
->where('created_at', '>=', $periodStart)
->where('created_at', '<=', $periodEnd);
$providerRunTotal = (int) (clone $providerRunQuery)->count();
$providerRunFailed = (int) (clone $providerRunQuery)->where('status', AiProviderRun::STATUS_FAILED)->count();
$averageProviderLatencyMs = (int) round((float) ((clone $providerRunQuery)->whereNotNull('duration_ms')->avg('duration_ms') ?: 0.0));
$failureRate = $total > 0 ? ($failedTotal / $total) : 0.0;
$moderationHitRate = $total > 0 ? ($moderationBlockedTotal / $total) : 0.0;
$providerFailureRate = $providerRunTotal > 0 ? ($providerRunFailed / $providerRunTotal) : 0.0;
$failureRateThreshold = (float) config('ai-editing.observability.failure_rate_alert_threshold', 0.35);
$latencyWarningThresholdMs = max(500, (int) config('ai-editing.observability.latency_warning_ms', 15000));
$budgetDecision = $this->budgetGuard->evaluateForEvent($event);
return response()->json([
'data' => [
'event_id' => $event->id,
'total' => $total,
'status_counts' => $statusCounts,
'safety_counts' => $safetyCounts,
'failed_total' => $failedTotal,
'last_requested_at' => $lastRequestedAt ? (string) \Illuminate\Support\Carbon::parse($lastRequestedAt)->toIso8601String() : null,
'usage' => [
'period_start' => $periodStart->toDateString(),
'period_end' => $periodEnd->toDateString(),
'debit_count' => $debitCount,
'spend_usd' => round($spendUsd, 5),
],
'observability' => [
'failure_rate' => round($failureRate, 5),
'moderation_hit_rate' => round($moderationHitRate, 5),
'provider_runs_total' => $providerRunTotal,
'provider_runs_failed' => $providerRunFailed,
'provider_failure_rate' => round($providerFailureRate, 5),
'avg_provider_latency_ms' => $averageProviderLatencyMs,
'alerts' => [
'failure_rate_threshold_reached' => $failureRate >= $failureRateThreshold && $total >= max(1, (int) config('ai-editing.observability.failure_rate_min_samples', 10)),
'latency_threshold_reached' => $averageProviderLatencyMs >= $latencyWarningThresholdMs,
],
],
'budget' => $budgetDecision['budget'],
],
]);
}
public function store(AiEditStoreRequest $request, string $eventSlug): JsonResponse
{
$event = $this->resolveTenantEventOrFail($request, $eventSlug);
TenantMemberPermissions::ensureEventPermission($request, $event, 'photos:upload');
$photo = Photo::query()
->whereKey((int) $request->input('photo_id'))
->where('event_id', $event->id)
->first();
if (! $photo) {
return ApiError::response(
'photo_not_found',
'Photo not found',
'The specified photo could not be located for this event.',
Response::HTTP_NOT_FOUND
);
}
$style = $this->resolveStyle($request->input('style_id'), $request->input('style_key'));
if (! $style) {
return ApiError::response(
'style_not_found',
'Style not found',
'The selected style is not available.',
Response::HTTP_UNPROCESSABLE_ENTITY
);
}
if (! $this->runtimeConfig->isEnabled()) {
return ApiError::response(
'feature_disabled',
'Feature disabled',
$this->runtimeConfig->statusMessage() ?: 'AI editing is currently disabled by platform policy.',
Response::HTTP_FORBIDDEN
);
}
$entitlement = $this->entitlements->resolveForEvent($event);
if (! $entitlement['allowed']) {
return ApiError::response(
'feature_locked',
'Feature locked',
$this->entitlements->lockedMessage(),
Response::HTTP_FORBIDDEN,
[
'required_feature' => $entitlement['required_feature'],
'addon_keys' => $entitlement['addon_keys'],
]
);
}
$policy = $this->eventPolicy->resolve($event);
if (! $policy['enabled']) {
return ApiError::response(
'event_feature_disabled',
'Feature disabled for this event',
$policy['policy_message'] ?? 'AI editing is disabled for this event.',
Response::HTTP_FORBIDDEN
);
}
$budgetDecision = $this->budgetGuard->evaluateForEvent($event);
if (! $budgetDecision['allowed']) {
return ApiError::response(
$budgetDecision['reason_code'] ?? 'budget_hard_cap_reached',
'Budget limit reached',
$budgetDecision['message'] ?? 'The AI editing budget for this billing period has been exhausted.',
Response::HTTP_FORBIDDEN,
[
'budget' => $budgetDecision['budget'],
]
);
}
if (! $this->eventPolicy->isStyleAllowed($event, $style) || ! $this->styleAccess->canUseStyle($event, $style)) {
return ApiError::response(
'style_not_allowed',
'Style not allowed',
$policy['policy_message'] ?? 'This style is not allowed for this event.',
Response::HTTP_UNPROCESSABLE_ENTITY,
[
'allowed_style_keys' => $policy['allowed_style_keys'],
]
);
}
$prompt = (string) ($request->input('prompt') ?: $style->prompt_template ?: '');
$negativePrompt = (string) ($request->input('negative_prompt') ?: $style->negative_prompt_template ?: '');
$providerModel = $request->input('provider_model') ?: $style->provider_model;
$safetyDecision = $this->safetyPolicy->evaluatePrompt($prompt, $negativePrompt);
$requestedByUserId = $request->user()?->id;
$scopeKey = $this->normalizeUserId($requestedByUserId) ?: 'tenant-user';
$abuseSignal = null;
$safetyReasons = $safetyDecision->reasonCodes;
if ($safetyDecision->blocked) {
$abuseSignal = $this->abuseEscalation->recordPromptBlock(
(int) $event->tenant_id,
(int) $event->id,
$scopeKey
);
if (($abuseSignal['escalated'] ?? false) && ! in_array(AiAbuseEscalationService::REASON_CODE, $safetyReasons, true)) {
$safetyReasons[] = AiAbuseEscalationService::REASON_CODE;
}
}
$metadata = (array) $request->input('metadata', []);
if (is_array($abuseSignal)) {
$metadata['abuse'] = $abuseSignal;
}
$metadata['budget'] = $budgetDecision['budget'];
$idempotencyKey = $this->resolveIdempotencyKey(
$request->input('idempotency_key'),
$request->header('X-Idempotency-Key'),
$event,
$photo,
$style,
$prompt,
$requestedByUserId
);
$editRequest = AiEditRequest::query()->firstOrCreate(
['tenant_id' => $event->tenant_id, 'idempotency_key' => $idempotencyKey],
[
'event_id' => $event->id,
'photo_id' => $photo->id,
'style_id' => $style->id,
'requested_by_user_id' => $requestedByUserId,
'provider' => $this->runtimeConfig->defaultProvider(),
'provider_model' => $providerModel,
'status' => $safetyDecision->blocked ? AiEditRequest::STATUS_BLOCKED : AiEditRequest::STATUS_QUEUED,
'safety_state' => $safetyDecision->state,
'prompt' => $prompt,
'negative_prompt' => $negativePrompt,
'input_image_path' => $photo->file_path,
'idempotency_key' => $idempotencyKey,
'safety_reasons' => $safetyReasons,
'failure_code' => $safetyDecision->failureCode,
'failure_message' => $safetyDecision->failureMessage,
'queued_at' => now(),
'completed_at' => $safetyDecision->blocked ? now() : null,
'metadata' => $metadata,
]
);
if (! $editRequest->wasRecentlyCreated && $this->isIdempotencyConflict(
$editRequest,
$event,
$photo,
$style,
$prompt,
$negativePrompt,
$providerModel,
$requestedByUserId
)) {
return ApiError::response(
'idempotency_conflict',
'Idempotency conflict',
'The provided idempotency key is already in use for another request.',
Response::HTTP_CONFLICT
);
}
if (
$editRequest->wasRecentlyCreated
&& ! $safetyDecision->blocked
&& $this->runtimeConfig->queueAutoDispatch()
) {
ProcessAiEditRequest::dispatch($editRequest->id)
->onQueue($this->runtimeConfig->queueName());
}
return response()->json([
'message' => $editRequest->wasRecentlyCreated ? 'AI edit request queued' : 'AI edit request already exists',
'duplicate' => ! $editRequest->wasRecentlyCreated,
'data' => $this->serializeRequest($editRequest->fresh(['style', 'outputs'])),
], $editRequest->wasRecentlyCreated ? Response::HTTP_CREATED : Response::HTTP_OK);
}
public function show(Request $request, string $eventSlug, int $aiEditRequest): JsonResponse
{
$event = $this->resolveTenantEventOrFail($request, $eventSlug);
TenantMemberPermissions::ensureEventPermission($request, $event, 'photos:upload');
$editRequest = AiEditRequest::query()
->with(['style', 'outputs'])
->whereKey($aiEditRequest)
->where('event_id', $event->id)
->first();
if (! $editRequest) {
return ApiError::response(
'edit_request_not_found',
'Edit request not found',
'The specified AI edit request could not be located for this event.',
Response::HTTP_NOT_FOUND
);
}
return response()->json([
'data' => $this->serializeRequest($editRequest),
]);
}
private function resolveTenantEventOrFail(Request $request, string $eventSlug): Event
{
$tenantId = $request->attributes->get('tenant_id');
return Event::query()
->where('slug', $eventSlug)
->where('tenant_id', $tenantId)
->firstOrFail();
}
private function resolveStyle(mixed $styleId, mixed $styleKey): ?AiStyle
{
if ($styleId !== null) {
return AiStyle::query()
->whereKey((int) $styleId)
->where('is_active', true)
->first();
}
$key = trim((string) ($styleKey ?? ''));
if ($key === '') {
return null;
}
return AiStyle::query()
->where('key', $key)
->where('is_active', true)
->first();
}
private function resolveIdempotencyKey(
mixed $bodyKey,
mixed $headerKey,
Event $event,
Photo $photo,
AiStyle $style,
string $prompt,
mixed $requestedByUserId
): string {
$candidate = trim((string) ($bodyKey ?: $headerKey ?: ''));
if ($candidate !== '') {
return Str::limit($candidate, 120, '');
}
return substr(hash('sha256', implode('|', [
(string) $event->id,
(string) $photo->id,
(string) $style->id,
trim($prompt),
(string) ($this->normalizeUserId($requestedByUserId)),
])), 0, 120);
}
private function normalizeUserId(mixed $userId): ?string
{
if (! is_int($userId) && ! is_string($userId)) {
return null;
}
$value = trim((string) $userId);
return $value !== '' ? $value : null;
}
private function normalizeOptionalString(?string $value): ?string
{
if ($value === null) {
return null;
}
$trimmed = trim($value);
return $trimmed !== '' ? $trimmed : null;
}
private function isIdempotencyConflict(
AiEditRequest $request,
Event $event,
Photo $photo,
AiStyle $style,
string $prompt,
string $negativePrompt,
?string $providerModel,
mixed $requestedByUserId
): bool {
if ($request->event_id !== $event->id || $request->photo_id !== $photo->id) {
return true;
}
if ((int) ($request->style_id ?? 0) !== (int) $style->id) {
return true;
}
if ($this->normalizeOptionalString($request->prompt) !== $this->normalizeOptionalString($prompt)) {
return true;
}
if ($this->normalizeOptionalString($request->negative_prompt) !== $this->normalizeOptionalString($negativePrompt)) {
return true;
}
if ($this->normalizeOptionalString($request->provider_model) !== $this->normalizeOptionalString($providerModel)) {
return true;
}
return $this->normalizeUserId($request->requested_by_user_id) !== $this->normalizeUserId($requestedByUserId);
}
private function serializeStyle(AiStyle $style): array
{
return [
'id' => $style->id,
'key' => $style->key,
'name' => $style->name,
'version' => $style->version,
'category' => $style->category,
'description' => $style->description,
'provider' => $style->provider,
'provider_model' => $style->provider_model,
'requires_source_image' => $style->requires_source_image,
'is_premium' => $style->is_premium,
'metadata' => $style->metadata ?? [],
];
}
private function serializeRequest(AiEditRequest $request): array
{
return [
'id' => $request->id,
'event_id' => $request->event_id,
'photo_id' => $request->photo_id,
'style' => $request->style ? [
'id' => $request->style->id,
'key' => $request->style->key,
'name' => $request->style->name,
] : null,
'provider' => $request->provider,
'provider_model' => $request->provider_model,
'status' => $request->status,
'safety_state' => $request->safety_state,
'safety_reasons' => $request->safety_reasons ?? [],
'failure_code' => $request->failure_code,
'failure_message' => $request->failure_message,
'queued_at' => $request->queued_at?->toIso8601String(),
'started_at' => $request->started_at?->toIso8601String(),
'completed_at' => $request->completed_at?->toIso8601String(),
'outputs' => $request->outputs->map(fn ($output) => [
'id' => $output->id,
'storage_disk' => $output->storage_disk,
'storage_path' => $output->storage_path,
'provider_url' => $output->provider_url,
'url' => $this->resolveOutputUrl(
$output->storage_disk,
$output->storage_path,
$output->provider_url
),
'mime_type' => $output->mime_type,
'width' => $output->width,
'height' => $output->height,
'is_primary' => $output->is_primary,
'safety_state' => $output->safety_state,
'safety_reasons' => $output->safety_reasons ?? [],
'generated_at' => $output->generated_at?->toIso8601String(),
])->values(),
];
}
private function resolveOutputUrl(?string $storageDisk, ?string $storagePath, ?string $providerUrl): ?string
{
$resolvedStoragePath = $this->normalizeOptionalString($storagePath);
if ($resolvedStoragePath !== null) {
if (Str::startsWith($resolvedStoragePath, ['http://', 'https://'])) {
return $resolvedStoragePath;
}
$disk = $this->resolveStorageDisk($storageDisk);
try {
return Storage::disk($disk)->url($resolvedStoragePath);
} catch (\Throwable $exception) {
Log::debug('Falling back to raw AI output storage path', [
'disk' => $disk,
'path' => $resolvedStoragePath,
'error' => $exception->getMessage(),
]);
return '/'.ltrim($resolvedStoragePath, '/');
}
}
return $this->normalizeOptionalString($providerUrl);
}
private function resolveStorageDisk(?string $disk): string
{
$candidate = trim((string) ($disk ?: config('filesystems.default', 'public')));
if ($candidate === '' || ! config("filesystems.disks.{$candidate}")) {
return (string) config('filesystems.default', 'public');
}
return $candidate;
}
}

View File

@@ -3,7 +3,6 @@
namespace App\Http\Controllers\Api\Tenant;
use App\Http\Controllers\Controller;
use App\Models\CheckoutSession;
use App\Services\Addons\EventAddonCatalog;
use Illuminate\Http\JsonResponse;
@@ -13,25 +12,9 @@ class EventAddonCatalogController extends Controller
public function index(): JsonResponse
{
$provider = config('package-addons.provider')
?? config('checkout.default_provider', CheckoutSession::PROVIDER_PAYPAL);
$addons = collect($this->catalog->all())
->map(function (array $addon, string $key) use ($provider): array {
$priceId = $provider === CheckoutSession::PROVIDER_PAYPAL
? ($addon['price'] ?? null ? 'paypal' : null)
: ($addon['variant_id'] ?? null);
return [
'key' => $key,
'label' => $addon['label'] ?? null,
'price_id' => $priceId,
'increments' => $addon['increments'] ?? [],
'price' => $addon['price'] ?? null,
'currency' => $addon['currency'] ?? 'EUR',
];
})
->filter(fn (array $addon) => ! empty($addon['price_id']))
->map(fn (array $addon, string $key) => array_merge($addon, ['key' => $key]))
->values()
->all();

View File

@@ -4,10 +4,13 @@ namespace App\Http\Controllers\Api\Tenant;
use App\Http\Controllers\Controller;
use App\Http\Requests\Tenant\EventAddonCheckoutRequest;
use App\Http\Requests\Tenant\EventAddonRequest;
use App\Http\Resources\Tenant\EventResource;
use App\Models\Event;
use App\Services\Addons\EventAddonCheckoutService;
use App\Support\ApiError;
use Illuminate\Http\JsonResponse;
use Illuminate\Support\Carbon;
class EventAddonController extends Controller
{
@@ -48,4 +51,64 @@ class EventAddonController extends Controller
'expires_at' => $checkout['expires_at'] ?? null,
]);
}
public function apply(EventAddonRequest $request, Event $event): JsonResponse
{
$tenantId = $request->attributes->get('tenant_id');
if ($event->tenant_id !== $tenantId) {
return ApiError::response(
'event_not_found',
'Event not accessible',
__('Das Event konnte nicht gefunden werden.'),
404,
['event_slug' => $event->slug ?? null]
);
}
$eventPackage = $event->eventPackage;
if (! $eventPackage && method_exists($event, 'eventPackages')) {
$eventPackage = $event->eventPackages()
->with('package')
->orderByDesc('purchased_at')
->orderByDesc('created_at')
->first();
}
if (! $eventPackage) {
return ApiError::response(
'event_package_missing',
'Event package missing',
__('Kein Paket ist diesem Event zugeordnet.'),
409,
['event_slug' => $event->slug ?? null]
);
}
$data = $request->validated();
$eventPackage->fill([
'extra_photos' => ($eventPackage->extra_photos ?? 0) + (int) ($data['extra_photos'] ?? 0),
'extra_guests' => ($eventPackage->extra_guests ?? 0) + (int) ($data['extra_guests'] ?? 0),
'extra_gallery_days' => ($eventPackage->extra_gallery_days ?? 0) + (int) ($data['extend_gallery_days'] ?? 0),
]);
if (isset($data['extend_gallery_days'])) {
$base = $eventPackage->gallery_expires_at ?? Carbon::now();
$eventPackage->gallery_expires_at = $base->copy()->addDays((int) $data['extend_gallery_days']);
}
$eventPackage->save();
$event->load([
'eventPackage.package',
'eventPackages.package',
]);
return response()->json([
'message' => __('Add-ons applied successfully.'),
'data' => new EventResource($event),
]);
}
}

View File

@@ -7,6 +7,7 @@ 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
{
@@ -22,13 +23,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

@@ -110,14 +110,7 @@ class EventController extends Controller
$tenantPackage = $tenant->tenantPackages()
->with('package')
->where('active', true)
->where(function ($query) {
$query->whereNull('expires_at')->orWhere('expires_at', '>', now());
})
->whereHas('package', fn ($query) => $query->withTrashed()->where('type', 'endcustomer'))
->withCount('eventPackages')
->orderBy('event_packages_count')
->orderByDesc('purchased_at')
->orderByDesc('id')
->first();
$package = null;
@@ -156,7 +149,6 @@ class EventController extends Controller
$eventServicePackage = $billingIsReseller
? $this->resolveResellerEventPackageForSlug($requestedServiceSlug ?: $package->included_package_slug)
: $package;
$sourceTenantPackage = $billingIsReseller ? $billingTenantPackage : $tenantPackage;
$requiresWaiver = $package->isEndcustomer();
$latestPurchase = $requiresWaiver ? $this->resolveLatestPackagePurchase($tenant, $package) : null;
@@ -169,13 +161,11 @@ class EventController extends Controller
]);
}
$resolvedName = $this->resolveEventNameString($validated['name']);
$eventData = array_merge($validated, [
'tenant_id' => $tenantId,
'status' => $validated['status'] ?? 'draft',
'slug' => $this->generateUniqueSlug($resolvedName, $tenantId),
'slug' => $this->generateUniqueSlug($validated['name'], $tenantId),
]);
$eventData['name'] = $this->normalizeEventName($validated['name']);
if (isset($eventData['event_date'])) {
$eventData['date'] = $eventData['event_date'];
@@ -224,13 +214,12 @@ class EventController extends Controller
$eventData = Arr::only($eventData, $allowed);
$event = DB::transaction(function () use ($tenant, $eventData, $eventServicePackage, $billingIsReseller, $isSuperAdmin, $sourceTenantPackage) {
$event = DB::transaction(function () use ($tenant, $eventData, $eventServicePackage, $billingIsReseller, $isSuperAdmin) {
$event = Event::create($eventData);
EventPackage::create([
'event_id' => $event->id,
'package_id' => $eventServicePackage->id,
'tenant_package_id' => $sourceTenantPackage?->id,
'purchased_price' => $billingIsReseller ? 0 : $eventServicePackage->price,
'purchased_at' => now(),
'gallery_expires_at' => $eventServicePackage->gallery_days
@@ -239,7 +228,7 @@ class EventController extends Controller
]);
if ($billingIsReseller && ! $isSuperAdmin) {
$note = sprintf('Event #%d created (%s)', $event->id, $this->resolveEventNameString($event->name));
$note = sprintf('Event #%d created (%s)', $event->id, $event->name);
if (! $tenant->consumeEventAllowanceFor($eventServicePackage->slug, 1, 'event.create', $note)) {
throw new HttpException(402, 'Insufficient package allowance.');
@@ -256,13 +245,11 @@ class EventController extends Controller
$tenant->refresh();
$event->load(['eventType', 'tenant', 'eventPackages.package', 'eventPackages.addons']);
$activeResellerPackage = $tenant->getActiveResellerPackage();
return response()->json([
'message' => 'Event created successfully',
'data' => new EventResource($event),
'package' => $event->eventPackage ? $event->eventPackage->package->name : 'None',
'remaining_events' => $activeResellerPackage?->remaining_events ?? 0,
'remaining_events' => $tenant->activeResellerPackage ? $tenant->activeResellerPackage->remaining_events : 0,
], 201);
}
@@ -417,13 +404,9 @@ class EventController extends Controller
unset($validated['event_date']);
}
$currentName = $this->resolveEventNameString($event->name);
$nextName = $this->resolveEventNameString($validated['name']);
if ($nameProvided && $nextName !== $currentName) {
$validated['slug'] = $this->generateUniqueSlug($nextName, $tenantId, $event->id);
if ($nameProvided && $validated['name'] !== $event->name) {
$validated['slug'] = $this->generateUniqueSlug($validated['name'], $tenantId, $event->id);
}
$validated['name'] = $this->normalizeEventName($validated['name']);
foreach (['password', 'password_confirmation', 'password_protected'] as $unused) {
unset($validated[$unused]);
@@ -893,16 +876,9 @@ class EventController extends Controller
);
}
$minimumExpiry = $this->joinTokenService->minimumExpiryForEvent($event);
$expiresAtRules = ['nullable', 'date', 'after:now'];
if ($minimumExpiry) {
$expiresAtRules[] = 'after_or_equal:'.$minimumExpiry->toDateTimeString();
}
$validated = $request->validate([
'label' => ['nullable', 'string', 'max:255'],
'expires_at' => $expiresAtRules,
'expires_at' => ['nullable', 'date', 'after:now'],
'usage_limit' => ['nullable', 'integer', 'min:1'],
]);
@@ -959,45 +935,6 @@ 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

@@ -33,7 +33,7 @@ class EventJoinTokenController extends Controller
{
$this->authorizeEvent($request, $event, 'join-tokens:manage');
$validated = $this->validatePayload($request, $event);
$validated = $this->validatePayload($request);
$token = $this->joinTokenService->createToken($event, array_merge($validated, [
'created_by' => Auth::id(),
@@ -52,7 +52,7 @@ class EventJoinTokenController extends Controller
abort(404);
}
$validated = $this->validatePayload($request, $event, true);
$validated = $this->validatePayload($request, true);
$payload = [];
@@ -115,18 +115,11 @@ class EventJoinTokenController extends Controller
}
}
private function validatePayload(Request $request, Event $event, bool $partial = false): array
private function validatePayload(Request $request, bool $partial = false): array
{
$minimumExpiry = $this->joinTokenService->minimumExpiryForEvent($event);
$expiresAtRules = [$partial ? 'nullable' : 'sometimes', 'nullable', 'date', 'after:now'];
if ($minimumExpiry) {
$expiresAtRules[] = 'after_or_equal:'.$minimumExpiry->toDateTimeString();
}
$rules = [
'label' => [$partial ? 'nullable' : 'sometimes', 'string', 'max:255'],
'expires_at' => $expiresAtRules,
'expires_at' => [$partial ? 'nullable' : 'sometimes', 'nullable', 'date', 'after:now'],
'usage_limit' => [$partial ? 'nullable' : 'sometimes', 'nullable', 'integer', 'min:1'],
'metadata' => [$partial ? 'nullable' : 'sometimes', 'nullable', 'array'],
'metadata.layout_customization' => ['nullable', 'array'],

View File

@@ -22,14 +22,7 @@ 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,3 +112,4 @@ class FontController extends Controller
return $fonts;
}
}

View File

@@ -53,7 +53,6 @@ class LiveShowLinkController extends Controller
'url' => $url,
'qr_code_data_url' => $this->buildQrCodeDataUrl($url),
'rotated_at' => $event->live_show_token_rotated_at?->toIso8601String(),
'expires_at' => $event->live_show_token_expires_at?->toIso8601String(),
];
}

View File

@@ -20,7 +20,6 @@ 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;
@@ -116,7 +115,6 @@ 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(
@@ -323,7 +321,7 @@ class PhotoController extends Controller
$disk = $this->eventStorageManager->getHotDiskForEvent($event);
// Generate unique filename
$extension = $this->resolvePhotoExtension($file);
$extension = $file->getClientOriginalExtension();
$filename = Str::uuid().'.'.$extension;
$path = "events/{$eventSlug}/photos/{$filename}";
@@ -565,7 +563,6 @@ 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(
@@ -782,7 +779,6 @@ 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')
@@ -1047,23 +1043,4 @@ 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

@@ -119,6 +119,8 @@ class TenantAdminTokenController extends Controller
], 404);
}
$tenant->loadMissing('activeResellerPackage');
$user = $request->user();
$abilities = $user?->currentAccessToken()?->abilities ?? [];
@@ -129,7 +131,7 @@ class TenantAdminTokenController extends Controller
$fullName = trim($first.' '.$last) ?: null;
}
$activePackage = $tenant->getActiveResellerPackage();
$activePackage = $tenant->activeResellerPackage;
return response()->json([
'id' => $tenant->id,

View File

@@ -3,27 +3,22 @@
namespace App\Http\Controllers\Api;
use App\Http\Controllers\Controller;
use App\Http\Requests\Tenant\BillingAddonHistoryRequest;
use App\Models\Event;
use App\Models\EventPackageAddon;
use App\Models\PackagePurchase;
use App\Services\Addons\EventAddonCatalog;
use App\Services\LemonSqueezy\Exceptions\LemonSqueezyException;
use App\Services\LemonSqueezy\LemonSqueezySubscriptionService;
use Dompdf\Dompdf;
use Dompdf\Options;
use App\Services\Paddle\Exceptions\PaddleException;
use App\Services\Paddle\PaddleCustomerPortalService;
use App\Services\Paddle\PaddleCustomerService;
use App\Services\Paddle\PaddleTransactionService;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Arr;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Str;
use Symfony\Component\HttpFoundation\Response;
class TenantBillingController extends Controller
{
public function __construct(
private readonly LemonSqueezySubscriptionService $subscriptions,
private readonly EventAddonCatalog $addonCatalog,
private readonly PaddleTransactionService $paddleTransactions,
private readonly PaddleCustomerService $paddleCustomers,
private readonly PaddleCustomerPortalService $portalSessions,
) {}
public function transactions(Request $request): JsonResponse
@@ -37,49 +32,54 @@ class TenantBillingController extends Controller
], 404);
}
$perPage = max(1, min((int) $request->query('per_page', 25), 100));
$page = max(1, (int) $request->query('page', 1));
$locale = $request->user()?->preferred_locale ?? app()->getLocale();
if (! $tenant->paddle_customer_id) {
try {
$this->paddleCustomers->ensureCustomerId($tenant);
} catch (\Throwable $exception) {
Log::warning('Failed to resolve Paddle customer for tenant', [
'tenant_id' => $tenant->id,
'error' => $exception->getMessage(),
]);
$paginator = PackagePurchase::query()
->where('tenant_id', $tenant->id)
->with(['package'])
->orderByDesc('purchased_at')
->orderByDesc('id')
->paginate($perPage, ['*'], 'page', $page);
return response()->json([
'data' => [],
'message' => 'Failed to resolve Paddle customer.',
], 502);
}
}
$data = $paginator->getCollection()->map(function (PackagePurchase $purchase) use ($locale) {
$totals = $this->resolvePurchaseTotals($purchase);
$transactionId = $purchase->provider_id ? (string) $purchase->provider_id : (string) $purchase->getKey();
$cursor = $request->query('cursor');
$perPage = (int) $request->query('per_page', 25);
return [
'id' => $purchase->getKey(),
'status' => $purchase->refunded ? 'refunded' : 'completed',
'amount' => $totals['total'],
'currency' => $totals['currency'],
'tax' => $totals['tax'],
'provider' => $purchase->provider ?? 'paypal',
'provider_id' => $transactionId,
'package_name' => $this->resolvePackageName($purchase, $locale),
'purchased_at' => $purchase->purchased_at?->toIso8601String(),
'receipt_url' => route('api.v1.tenant.billing.transactions.receipt', [
'purchase' => $purchase->getKey(),
], absolute: false),
];
})->values();
$query = [
'per_page' => max(1, min($perPage, 100)),
];
if ($cursor) {
$query['after'] = $cursor;
}
try {
$result = $this->paddleTransactions->listForCustomer($tenant->paddle_customer_id, $query);
} catch (\Throwable $exception) {
Log::warning('Failed to load Paddle transactions', [
'tenant_id' => $tenant->id,
'error' => $exception->getMessage(),
]);
return response()->json([
'data' => [],
'message' => 'Failed to load Paddle transactions.',
], 502);
}
return response()->json([
'data' => $data,
'meta' => [
'current_page' => $paginator->currentPage(),
'last_page' => $paginator->lastPage(),
'per_page' => $paginator->perPage(),
'total' => $paginator->total(),
],
'data' => $result['data'],
'meta' => $result['meta'],
]);
}
public function addons(BillingAddonHistoryRequest $request): JsonResponse
public function addons(Request $request): JsonResponse
{
$tenant = $request->attributes->get('tenant');
@@ -90,63 +90,21 @@ class TenantBillingController extends Controller
], 404);
}
$perPage = max(1, min((int) $request->validated('per_page', 25), 100));
$page = max(1, (int) $request->validated('page', 1));
$eventId = $request->validated('event_id');
$eventSlug = $request->validated('event_slug');
$status = $request->validated('status');
$perPage = max(1, min((int) $request->query('per_page', 25), 100));
$page = max(1, (int) $request->query('page', 1));
$scopeEvent = null;
if ($eventId !== null || $eventSlug !== null) {
$scopeEventQuery = Event::query()
->where('tenant_id', $tenant->id);
if ($eventId !== null) {
$scopeEventQuery->whereKey((int) $eventId);
} elseif (is_string($eventSlug) && trim($eventSlug) !== '') {
$scopeEventQuery->where('slug', $eventSlug);
}
$scopeEvent = $scopeEventQuery->first();
if (! $scopeEvent) {
return response()->json([
'data' => [],
'message' => 'Event scope not found.',
], 404);
}
}
$query = EventPackageAddon::query()
$paginator = EventPackageAddon::query()
->where('tenant_id', $tenant->id)
->with(['event:id,name,slug']);
if ($scopeEvent) {
$query->where('event_id', $scopeEvent->id);
}
if (is_string($status) && $status !== '') {
$query->where('status', $status);
}
$paginator = $query
->with(['event:id,name,slug'])
->orderByDesc('purchased_at')
->orderByDesc('created_at')
->paginate($perPage, ['*'], 'page', $page);
$addonLabels = collect($this->addonCatalog->all())
->mapWithKeys(fn (array $addon, string $key): array => [$key => $addon['label'] ?? null])
->all();
$data = $paginator->getCollection()->map(function (EventPackageAddon $addon) use ($addonLabels) {
$label = $addon->metadata['label']
?? ($addonLabels[$addon->addon_key] ?? null)
?? $addon->addon_key;
$data = $paginator->getCollection()->map(function (EventPackageAddon $addon) {
return [
'id' => $addon->id,
'addon_key' => $addon->addon_key,
'label' => $label,
'label' => $addon->metadata['label'] ?? null,
'quantity' => (int) ($addon->quantity ?? 1),
'status' => $addon->status,
'amount' => $addon->amount !== null ? (float) $addon->amount : null,
@@ -171,17 +129,6 @@ class TenantBillingController extends Controller
'last_page' => $paginator->lastPage(),
'per_page' => $paginator->perPage(),
'total' => $paginator->total(),
'scope' => $scopeEvent ? [
'type' => 'event',
'event' => [
'id' => $scopeEvent->id,
'slug' => $scopeEvent->slug,
'name' => $scopeEvent->name,
],
] : [
'type' => 'tenant',
'event' => null,
],
],
]);
}
@@ -196,64 +143,68 @@ class TenantBillingController extends Controller
], 404);
}
$subscriptionId = null;
$customerId = null;
try {
$subscriptionId = $tenant->getActiveResellerPackage()?->lemonsqueezy_subscription_id;
if (! $subscriptionId) {
return response()->json([
'message' => 'No active subscription found.',
], 404);
}
$customerId = $this->paddleCustomers->ensureCustomerId($tenant);
Log::debug('Fetching Lemon Squeezy subscription portal URL', [
Log::debug('Creating Paddle customer portal session', [
'tenant_id' => $tenant->id,
'lemonsqueezy_subscription_id' => $subscriptionId,
'paddle_customer_id' => $customerId,
'paddle_environment' => config('paddle.environment'),
'paddle_base_url' => config('paddle.base_url'),
]);
$subscription = $this->subscriptions->retrieve($subscriptionId);
$session = $this->portalSessions->createSession($customerId);
} catch (\Throwable $exception) {
$context = [
'tenant_id' => $tenant->id,
'lemonsqueezy_customer_id' => $tenant->lemonsqueezy_customer_id,
'lemonsqueezy_subscription_id' => $subscriptionId ?? null,
'paddle_customer_id' => $customerId ?? $tenant->paddle_customer_id,
'error' => $exception->getMessage(),
'paddle_environment' => config('paddle.environment'),
'paddle_base_url' => config('paddle.base_url'),
];
if ($exception instanceof LemonSqueezyException) {
$context['lemonsqueezy_status'] = $exception->status();
$context['lemonsqueezy_error'] = Arr::get($exception->context(), 'errors.0');
$context['lemonsqueezy_errors'] = Arr::get($exception->context(), 'errors');
$context['lemonsqueezy_request_id'] = Arr::get($exception->context(), 'meta.request_id');
if ($exception instanceof PaddleException) {
$context['paddle_status'] = $exception->status();
$context['paddle_error_code'] = Arr::get($exception->context(), 'error.code');
$context['paddle_error_message'] = Arr::get($exception->context(), 'error.message');
$context['paddle_error_detail'] = Arr::get($exception->context(), 'error.detail');
$context['paddle_error_doc_url'] = Arr::get($exception->context(), 'error.documentation_url');
$context['paddle_request_id'] = Arr::get($exception->context(), 'meta.request_id');
$context['paddle_errors'] = Arr::get($exception->context(), 'error.errors');
}
Log::warning('Failed to fetch Lemon Squeezy subscription portal URL', [
Log::warning('Failed to create Paddle customer portal session', [
...$context,
]);
return response()->json([
'message' => 'Failed to fetch Lemon Squeezy subscription portal URL.',
'message' => 'Failed to create Paddle customer portal session.',
], 502);
}
$url = $this->subscriptions->portalUrl($subscription)
?? $this->subscriptions->updatePaymentMethodUrl($subscription);
$url = Arr::get($session, 'data.urls.general.overview')
?? Arr::get($session, 'data.urls.general')
?? Arr::get($session, 'urls.general.overview')
?? Arr::get($session, 'urls.general');
if (! $url) {
$sessionData = Arr::get($subscription, 'data');
$sessionUrls = Arr::get($subscription, 'attributes.urls');
$sessionData = Arr::get($session, 'data');
$sessionUrls = Arr::get($session, 'data.urls') ?? Arr::get($session, 'urls');
Log::warning('Lemon Squeezy subscription missing portal URL', [
Log::warning('Paddle customer portal session missing URL', [
'tenant_id' => $tenant->id,
'lemonsqueezy_customer_id' => $tenant->lemonsqueezy_customer_id,
'lemonsqueezy_subscription_id' => $subscriptionId ?? null,
'subscription_keys' => array_keys($subscription),
'paddle_customer_id' => $customerId ?? $tenant->paddle_customer_id,
'paddle_environment' => config('paddle.environment'),
'paddle_base_url' => config('paddle.base_url'),
'session_keys' => array_keys($session),
'session_data_keys' => is_array($sessionData) ? array_keys($sessionData) : null,
'session_url_keys' => is_array($sessionUrls) ? array_keys($sessionUrls) : null,
]);
return response()->json([
'message' => 'Lemon Squeezy subscription missing portal URL.',
'message' => 'Paddle customer portal session missing URL.',
], 502);
}
@@ -261,184 +212,4 @@ class TenantBillingController extends Controller
'url' => $url,
]);
}
public function receipt(Request $request, PackagePurchase $purchase): Response
{
$tenant = $request->attributes->get('tenant');
if (! $tenant || (int) $purchase->tenant_id !== (int) $tenant->id) {
abort(404);
}
$purchase->loadMissing(['tenant.user', 'package']);
$locale = $request->user()?->preferred_locale ?? app()->getLocale();
app()->setLocale($locale);
$totals = $this->resolvePurchaseTotals($purchase);
$currency = $totals['currency'];
$total = $totals['total'];
$tax = $totals['tax'];
$buyer = $purchase->tenant?->user;
$buyerName = $buyer?->full_name ?? $buyer?->name ?? $buyer?->email ?? '';
$buyerEmail = $buyer?->email ?? '';
$buyerAddress = $buyer?->address ?? '';
$packageName = $this->resolvePackageName($purchase, $locale);
$packageTypeLabel = $this->resolvePackageTypeLabel($purchase->package?->type);
$providerLabel = $this->resolveProviderLabel($purchase->provider);
$purchaseDate = $this->formatDate($purchase->purchased_at, $locale);
$amountFormatted = $this->formatCurrency($total, $currency, $locale);
$taxFormatted = $tax !== null ? $this->formatCurrency($tax, $currency, $locale) : null;
$totalFormatted = $amountFormatted;
$html = view('billing.receipt', [
'receiptNumber' => (string) $purchase->getKey(),
'purchaseDate' => $purchaseDate,
'packageName' => $packageName,
'packageTypeLabel' => $packageTypeLabel,
'providerLabel' => $providerLabel,
'orderId' => $purchase->provider_id ?? $purchase->getKey(),
'buyerName' => $buyerName,
'buyerEmail' => $buyerEmail,
'buyerAddress' => $buyerAddress,
'amountFormatted' => $amountFormatted,
'taxFormatted' => $taxFormatted,
'totalFormatted' => $totalFormatted,
'currency' => $currency,
'companyName' => config('app.name', 'Fotospiel'),
'companyEmail' => config('mail.from.address', 'info@fotospiel.app'),
])->render();
$options = new Options;
$options->set('isHtml5ParserEnabled', true);
$options->set('isRemoteEnabled', true);
$options->set('defaultFont', 'Helvetica');
$dompdf = new Dompdf($options);
$dompdf->setPaper('A4', 'portrait');
$dompdf->loadHtml($html, 'UTF-8');
$dompdf->render();
$pdfBinary = $dompdf->output();
$filenameStem = Str::slug($packageName ?: 'receipt');
return response($pdfBinary)
->header('Content-Type', 'application/pdf')
->header('Content-Disposition', 'inline; filename="receipt-'.$filenameStem.'.pdf"');
}
/**
* @return array{currency: string, total: float, tax: float|null}
*/
private function resolvePurchaseTotals(PackagePurchase $purchase): array
{
$metadata = $purchase->metadata ?? [];
$totals = $metadata['paypal_totals'] ?? $metadata['lemonsqueezy_totals'] ?? [];
$currency = $totals['currency']
?? $metadata['currency']
?? $purchase->package?->currency
?? 'EUR';
$total = array_key_exists('total', $totals)
? (float) $totals['total']
: (float) $purchase->price;
$tax = array_key_exists('tax', $totals) ? (float) $totals['tax'] : null;
return [
'currency' => strtoupper((string) $currency),
'total' => round($total, 2),
'tax' => $tax !== null ? round($tax, 2) : null,
];
}
private function resolvePackageName(PackagePurchase $purchase, string $locale): string
{
$package = $purchase->package;
if (! $package) {
return '';
}
$localized = $package->getNameForLocale($locale);
return $localized ?: (string) $package->name;
}
private function resolveProviderLabel(?string $provider): string
{
$provider = $provider ?: 'paypal';
$labelKey = 'emails.purchase.provider.'.$provider;
$label = __($labelKey);
if ($label === $labelKey) {
return ucfirst($provider);
}
return $label;
}
private function resolvePackageTypeLabel(?string $type): string
{
$type = $type ?: 'endcustomer';
$labelKey = 'emails.purchase.package_type.'.$type;
$label = __($labelKey);
if ($label === $labelKey) {
return ucfirst($type);
}
return $label;
}
private function formatCurrency(float $amount, string $currency, string $locale): string
{
$formatter = class_exists(\NumberFormatter::class)
? new \NumberFormatter($this->mapLocale($locale), \NumberFormatter::CURRENCY)
: null;
if ($formatter) {
$formatted = $formatter->formatCurrency($amount, $currency);
if ($formatted !== false) {
return $formatted;
}
}
$symbol = match (strtoupper($currency)) {
'EUR' => '€',
'USD' => '$',
default => strtoupper($currency).' ',
};
return $symbol.number_format($amount, 2, ',', '.');
}
private function formatDate(?\Carbon\CarbonInterface $date, string $locale): string
{
if (! $date) {
return '';
}
$localized = $date->locale($locale);
if (str_starts_with($locale, 'en')) {
return $localized->translatedFormat('F j, Y');
}
return $localized->translatedFormat('d. F Y');
}
private function mapLocale(string $locale): string
{
$normalized = strtolower(str_replace('_', '-', $locale));
return match (true) {
str_starts_with($normalized, 'de') => 'de_DE',
str_starts_with($normalized, 'en') => 'en_US',
default => 'de_DE',
};
}
}

View File

@@ -31,21 +31,18 @@ class TenantPackageController extends Controller
->get();
$usageEventPackage = $this->resolveUsageEventPackage($tenant->id);
$linkedEventPackages = $this->resolveLinkedEventPackages($tenant->id, $packages->pluck('id')->all());
$packages->each(function (TenantPackage $package) use ($usageEventPackage, $linkedEventPackages): void {
$packages->each(function (TenantPackage $package) use ($usageEventPackage): void {
$eventPackage = $package->active ? $usageEventPackage : null;
$this->hydratePackageSnapshot($package, $eventPackage);
$this->attachUsageEvents($package, $linkedEventPackages);
});
$activePackage = $tenant->getActiveResellerPackage();
$activePackage = $tenant->activeResellerPackage?->load('package');
if (! ($activePackage instanceof TenantPackage)) {
$activePackage = $packages->firstWhere('active', true);
} else {
$this->hydratePackageSnapshot($activePackage, $usageEventPackage);
$this->attachUsageEvents($activePackage, $linkedEventPackages);
}
return response()->json([
@@ -55,79 +52,6 @@ class TenantPackageController extends Controller
]);
}
/**
* @param array<int, int> $tenantPackageIds
* @return array<int, array{current: ?EventPackage, last: ?EventPackage, count: int}>
*/
private function resolveLinkedEventPackages(int $tenantId, array $tenantPackageIds): array
{
if ($tenantPackageIds === []) {
return [];
}
$eventPackages = EventPackage::query()
->whereIn('tenant_package_id', $tenantPackageIds)
->whereHas('event', fn ($query) => $query->where('tenant_id', $tenantId))
->with(['event:id,slug,name,date,status'])
->orderByDesc('purchased_at')
->orderByDesc('created_at')
->get()
->groupBy('tenant_package_id');
$result = [];
foreach ($eventPackages as $tenantPackageId => $groupedPackages) {
$current = $groupedPackages
->first(function (EventPackage $eventPackage) {
return $eventPackage->gallery_expires_at && $eventPackage->gallery_expires_at->isFuture();
});
$result[(int) $tenantPackageId] = [
'current' => $current,
'last' => $groupedPackages->first(),
'count' => $groupedPackages->count(),
];
}
return $result;
}
/**
* @param array<int, array{current: ?EventPackage, last: ?EventPackage, count: int}> $linkedEventPackages
*/
private function attachUsageEvents(TenantPackage $package, array $linkedEventPackages): void
{
$usage = $linkedEventPackages[$package->id] ?? null;
if (! $usage) {
$package->linked_events_count = 0;
$package->current_event = null;
$package->last_event = null;
return;
}
$package->linked_events_count = $usage['count'];
$package->current_event = $this->formatLinkedEvent($usage['current']);
$package->last_event = $this->formatLinkedEvent($usage['last']);
}
private function formatLinkedEvent(?EventPackage $eventPackage): ?array
{
if (! $eventPackage || ! $eventPackage->event) {
return null;
}
return [
'id' => $eventPackage->event->id,
'slug' => $eventPackage->event->slug,
'name' => $eventPackage->event->name,
'status' => $eventPackage->event->status,
'event_date' => $eventPackage->event->date?->toIso8601String(),
'linked_at' => $eventPackage->purchased_at?->toIso8601String(),
];
}
private function hydratePackageSnapshot(TenantPackage $package, ?EventPackage $eventPackage = null): void
{
$pkg = $package->package;

View File

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

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