Compare commits

...

129 Commits

Author SHA1 Message Date
Codex Agent
a9fa1546f7 bd sync: 2026-01-23 09:20:33 2026-01-23 09:20:34 +01:00
Codex Agent
7c6eee187c bd sync: 2026-01-23 08:56:22 2026-01-23 08:56:23 +01:00
Codex Agent
fbd46b8e5c bd sync: 2026-01-21 12:55:26 2026-01-21 12:55:26 +01:00
Codex Agent
886b336a08 bd sync: 2026-01-19 18:50:20 2026-01-19 18:50:20 +01:00
Codex Agent
02237735ec bd sync: 2026-01-18 11:02:27 2026-01-18 11:02:28 +01:00
Codex Agent
5e420a0dd8 bd sync: 2026-01-15 19:54:28 2026-01-15 19:54:28 +01:00
Codex Agent
2a55ae934f bd sync: 2026-01-13 11:04:44 2026-01-13 11:04:44 +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
Codex Agent
1ec4987b38 i18n: add translations for analytics and 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-06 21:22:24 +01:00
Codex Agent
6542ac66f1 chore: update beads for adaptive shop
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (push) Has been cancelled
tests / ui (push) Has been cancelled
2026-01-06 20:57:48 +01:00
Codex Agent
9bf4e8894f feat: make mobile package shop adaptive and inventory-aware
This commit includes:
- Updating navigation to pass ?feature=advanced_analytics context.
- Merging catalog with user inventory in MobilePackageShopPage.
- Implementing smart sorting (recommended first, then price).
- Adding highlighting and badges for recommended and active packages.
- Displaying remaining event counts on package cards.
2026-01-06 20:57:10 +01:00
Codex Agent
704683421f chore: extract more translations for adaptive shop 2026-01-06 20:56:29 +01:00
Codex Agent
9e9e04b97e fix: add missing activeTab prop to MobileShell in PackageShopPage
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (push) Has been cancelled
tests / ui (push) Has been cancelled
2026-01-06 19:02:08 +01:00
Codex Agent
59c463dbd3 chore: commit scanner changes
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (push) Has been cancelled
tests / ui (push) Has been cancelled
2026-01-06 18:18:18 +01:00
Codex Agent
8af2db2976 chore: update beads 2026-01-06 18:15:48 +01:00
Codex Agent
a22bff1879 chore: extract new translations for package shop 2026-01-06 18:13:45 +01:00
Codex Agent
5009697f7b feat: implement dedicated package shop page with legal confirmation
This commit:
- Creates MobilePackageShopPage.tsx for listing packages and handling checkout.
- Implements a confirmation screen with mandatory AGB and Withdrawal checkboxes.
- Registers the new route /mobile/billing/shop.
- Updates EventAnalyticsPage to link to the new shop page.
- Reverts previous inline upgrade logic in BillingPage.tsx.
2026-01-06 18:04:03 +01:00
Codex Agent
a8b9c3623a fix: remove duplicate createTenantPaddleCheckout definition in api.ts
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (push) Has been cancelled
tests / ui (push) Has been cancelled
2026-01-06 16:40:56 +01:00
Codex Agent
d5d53b563c chore: update beads
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (push) Has been cancelled
tests / ui (push) Has been cancelled
2026-01-06 16:36:44 +01:00
Codex Agent
c4fa0fc06e feat: implement upgrade to premium flow
This commit adds the ability for tenants to upgrade their package directly from the mobile billing page. It includes:
- New API function createTenantPaddleCheckout in api.ts
- Upgrade handler and UI in BillingPage.tsx
- Updated navigation in EventAnalyticsPage.tsx to link to the packages section of the billing page
2026-01-06 16:35:10 +01:00
Codex Agent
ee3e9737c4 feat: implement advanced analytics for mobile admin dashboard
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (push) Has been cancelled
tests / ui (push) Has been cancelled
This commit includes:
- Backend EventAnalyticsService and Controller
- API endpoint for event analytics
- Frontend EventAnalyticsPage with custom bar charts and top contributor lists
- Analytics shortcut on the dashboard
- Feature-lock upsell UI for non-premium users
2026-01-06 16:17:23 +01:00
Codex Agent
322cafa3c2 new package limits and features 2026-01-06 15:49:57 +01:00
Codex Agent
232302eb6f Improve package usage visibility
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (push) Has been cancelled
tests / ui (push) Has been cancelled
2026-01-06 14:17:27 +01:00
Codex Agent
ef1773d966 Fix package limits in tenant overview
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (push) Has been cancelled
tests / ui (push) Has been cancelled
2026-01-06 13:36:50 +01:00
Codex Agent
e3deec9741 Tweak zero remaining events copy
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (push) Has been cancelled
tests / ui (push) Has been cancelled
2026-01-06 13:23:20 +01:00
Codex Agent
10fbee4e6e Expand package limit and feature details
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (push) Has been cancelled
tests / ui (push) Has been cancelled
2026-01-06 13:20:19 +01:00
Codex Agent
4fe589f0e2 Add export progress hint
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (push) Has been cancelled
tests / ui (push) Has been cancelled
2026-01-06 13:03:05 +01:00
Codex Agent
a3538f6470 Fix data exports UI and scope format
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (push) Has been cancelled
tests / ui (push) Has been cancelled
2026-01-06 12:59:38 +01:00
Codex Agent
e82a10cb8b Adjust event defaults and nav width
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (push) Has been cancelled
tests / ui (push) Has been cancelled
2026-01-06 12:22:12 +01:00
Codex Agent
cc89cc667a Add package summary banner
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (push) Has been cancelled
tests / ui (push) Has been cancelled
2026-01-06 12:01:12 +01:00
Codex Agent
a796973861 Admin package summary sheet 2026-01-06 11:57:30 +01:00
Codex Agent
eba212a056 Login Page redesign 2026-01-06 11:56:54 +01:00
Codex Agent
54b3fa0d87 Add event-admin password reset flow
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (push) Has been cancelled
tests / ui (push) Has been cancelled
2026-01-06 11:02:09 +01:00
Codex Agent
51e8beb46c chore: add bd issue for Playwright checkout test
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (push) Has been cancelled
tests / ui (push) Has been cancelled
2026-01-06 09:55:37 +01:00
Codex Agent
33af04db1b Checkout: minimize registration data
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (push) Has been cancelled
tests / ui (push) Has been cancelled
2026-01-06 09:23:01 +01:00
Codex Agent
29c3c42134 chore: add bd issue for legacy registration cleanup
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (push) Has been cancelled
tests / ui (push) Has been cancelled
2026-01-06 08:39:27 +01:00
Codex Agent
f89f6d6223 Marketing: route registration to checkout 2026-01-06 08:36:55 +01:00
Codex Agent
34eb2b94b3 QR Print Page: add expiry notice 2026-01-06 08:35:48 +01:00
Codex Agent
88012c35bd Add join token TTL policy and Live Show link sharing
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (push) Has been cancelled
tests / ui (push) Has been cancelled
2026-01-05 21:11:36 +01:00
Codex Agent
3f3061a899 Add live show docs and smoke tests
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (push) Has been cancelled
tests / ui (push) Has been cancelled
2026-01-05 19:42:58 +01:00
Codex Agent
53eb560aa5 Add live show player playback and effects
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (push) Has been cancelled
tests / ui (push) Has been cancelled
2026-01-05 18:31:01 +01:00
Codex Agent
11dc0d77b4 Close fotospiel-app-exp
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (push) Has been cancelled
tests / ui (push) Has been cancelled
2026-01-05 15:31:19 +01:00
Codex Agent
35ef8f1586 Add guest Live Show opt-in toggle 2026-01-05 15:29:59 +01:00
Codex Agent
15be3b847c Close fotospiel-app-6zc
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (push) Has been cancelled
tests / ui (push) Has been cancelled
2026-01-05 15:03:09 +01:00
Codex Agent
7bbce79394 Add Live Show settings in admin app 2026-01-05 15:02:21 +01:00
Codex Agent
99186e8e2f Add approve-and-live action for Live Show
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (push) Has been cancelled
tests / ui (push) Has been cancelled
2026-01-05 14:16:27 +01:00
Codex Agent
148c075d58 bd sync: 2026-01-05 14:04:36
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (push) Has been cancelled
tests / ui (push) Has been cancelled
2026-01-05 14:04:39 +01:00
Codex Agent
e3b7271f69 Add live show moderation queue 2026-01-05 14:04:05 +01:00
Codex Agent
7802bed394 bd sync: 2026-01-05 13:09:47
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (push) Has been cancelled
tests / ui (push) Has been cancelled
2026-01-05 13:09:50 +01:00
Codex Agent
2abd1d113f Add live show realtime endpoints 2026-01-05 13:09:11 +01:00
Codex Agent
4718998e07 Live Show data model + workflow
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (push) Has been cancelled
tests / ui (push) Has been cancelled
2026-01-05 12:31:54 +01:00
Codex Agent
c07687102e bd sync: 2026-01-05 12:25:28
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (push) Has been cancelled
tests / ui (push) Has been cancelled
2026-01-05 12:25:33 +01:00
Codex Agent
8805c8264c Remove legacy tenant Filament panel
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (push) Has been cancelled
tests / ui (push) Has been cancelled
2026-01-04 18:47:36 +01:00
Codex Agent
15e19d4e8b Add join token analytics dashboard and align Filament views
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (push) Has been cancelled
tests / ui (push) Has been cancelled
2026-01-04 18:21:59 +01:00
Codex Agent
48b1cfde09 Close obsolete staging coupon issue
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (push) Has been cancelled
tests / ui (push) Has been cancelled
2026-01-04 16:23:54 +01:00
Codex Agent
540bb97f31 Close obsolete auth issues
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (push) Has been cancelled
tests / ui (push) Has been cancelled
2026-01-04 16:11:14 +01:00
Codex Agent
cbb010acca Guard checkout payment step behind login
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (push) Has been cancelled
tests / ui (push) Has been cancelled
2026-01-04 12:43:04 +01:00
Codex Agent
1afd49bd24 Fix app name phrasing and refresh blog seed dates
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (push) Has been cancelled
tests / ui (push) Has been cancelled
2026-01-04 12:20:19 +01:00
Codex Agent
fae5ec26fb Update legal privacy disclosures and dates
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (push) Has been cancelled
tests / ui (push) Has been cancelled
2026-01-04 11:17:04 +01:00
Codex Agent
103c8d4dfd Enable session auth for coupon preview API
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (push) Has been cancelled
tests / ui (push) Has been cancelled
2026-01-03 23:53:39 +01:00
Codex Agent
69fc869990 Capture Paddle sandbox inline network logs
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (push) Has been cancelled
tests / ui (push) Has been cancelled
2026-01-03 22:58:28 +01:00
Codex Agent
76c04f6873 Harden Paddle sandbox flow navigation
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (push) Has been cancelled
tests / ui (push) Has been cancelled
2026-01-03 22:52:15 +01:00
Codex Agent
6f5aa5e09b Improve Paddle sandbox inline readiness checks
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (push) Has been cancelled
tests / ui (push) Has been cancelled
2026-01-03 22:44:23 +01:00
Codex Agent
8764915fcd Handle inline Paddle checkout responses
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (push) Has been cancelled
tests / ui (push) Has been cancelled
2026-01-03 19:15:37 +01:00
Codex Agent
bd6a8b9c7c Allow timestamp placeholders for Paddle sandbox tenant email
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (push) Has been cancelled
tests / ui (push) Has been cancelled
2026-01-03 19:04:54 +01:00
Codex Agent
eb6c8857d1 Merge remote-tracking branch 'origin/main'
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (push) Has been cancelled
tests / ui (push) Has been cancelled
2026-01-03 17:07:30 +01:00
Codex Agent
a35808ac15 Update Playwright staging flows and Paddle sandbox checkout
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (push) Has been cancelled
tests / ui (push) Has been cancelled
2026-01-03 17:06:01 +01:00
Codex Agent
ef05822b70 Update Playwright staging flows and Paddle sandbox checkout 2026-01-03 17:04:59 +01:00
Codex Agent
4f1fbcc98b Fix Paddle coupon payload
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (push) Has been cancelled
tests / ui (push) Has been cancelled
2026-01-03 15:44:30 +01:00
Codex Agent
43b626cbfc Fix coupon infolist enum formatting
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (push) Has been cancelled
tests / ui (push) Has been cancelled
2026-01-03 15:27:20 +01:00
Codex Agent
3d0ff40382 Fix coupon enum formatting in Filament table
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (push) Has been cancelled
tests / ui (push) Has been cancelled
2026-01-03 15:23:37 +01:00
Codex Agent
f41578905f Update bd tracker
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (push) Has been cancelled
tests / ui (push) Has been cancelled
2026-01-03 15:13:34 +01:00
Codex Agent
08fe64b965 Fix marketing auth/checkout E2E selectors 2026-01-03 15:13:03 +01:00
Codex Agent
7ea34b3b20 Gate testing API for staging E2E
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (push) Has been cancelled
tests / ui (push) Has been cancelled
2026-01-03 15:00:33 +01:00
Codex Agent
030a00ba46 Update beads issues
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (push) Has been cancelled
tests / ui (push) Has been cancelled
2026-01-02 23:34:21 +01:00
Codex Agent
41ed682fbe Add coupon fraud context and analytics tracking
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (push) Has been cancelled
tests / ui (push) Has been cancelled
2026-01-02 23:31:26 +01:00
Codex Agent
75d862748b Add data export retry and cancel controls
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (push) Has been cancelled
tests / ui (push) Has been cancelled
2026-01-02 22:50:07 +01:00
Codex Agent
66bf9e4a8c Document Paddle cutover
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (push) Has been cancelled
tests / ui (push) Has been cancelled
2026-01-02 22:29:00 +01:00
Codex Agent
dfdbf09bf8 Register Paddle sandbox webhooks 2026-01-02 22:25:11 +01:00
Codex Agent
3c0e7afeb2 Link existing Paddle IDs 2026-01-02 22:24:52 +01:00
Codex Agent
bb67d68eba Add Paddle sync log channel 2026-01-02 22:02:36 +01:00
Codex Agent
0430f0b1cc Document Paddle sync recovery
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (push) Has been cancelled
tests / ui (push) Has been cancelled
2026-01-02 21:52:37 +01:00
Codex Agent
8b445ae998 Guard Paddle sync mapping
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (push) Has been cancelled
tests / ui (push) Has been cancelled
2026-01-02 21:47:14 +01:00
Codex Agent
77b7af13d4 Show Paddle sync errors 2026-01-02 21:16:23 +01:00
Codex Agent
3e9f09571b Stream tenant uploads
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (push) Has been cancelled
tests / ui (push) Has been cancelled
2026-01-02 20:51:52 +01:00
Codex Agent
eed7699549 Implement compliance exports and retention overrides
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (push) Has been cancelled
tests / ui (push) Has been cancelled
2026-01-02 20:13:45 +01:00
Codex Agent
5fd546c428 bd sync: 2026-01-02 18:46:27
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (push) Has been cancelled
tests / ui (push) Has been cancelled
2026-01-02 18:46:29 +01:00
Codex Agent
fc3e6715db Add integrations health monitoring
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (push) Has been cancelled
tests / ui (push) Has been cancelled
2026-01-02 18:35:12 +01:00
Codex Agent
9057a4cd15 Close fotospiel-app-kxe
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (push) Has been cancelled
tests / ui (push) Has been cancelled
2026-01-02 17:04:26 +01:00
Codex Agent
bc99929040 Add Paddle health ops metrics 2026-01-02 17:03:36 +01:00
409 changed files with 20756 additions and 34780 deletions

File diff suppressed because one or more lines are too long

View File

@@ -1 +1 @@
fotospiel-app-097
fotospiel-app-29r

12
.gitignore vendored
View File

@@ -13,6 +13,8 @@ fotospiel-tenant-app
/storage/*.key
/storage/pail
/vendor
/clients/photobooth-uploader/**/bin
/clients/photobooth-uploader/**/obj
.env
.env.backup
.env.production
@@ -23,11 +25,9 @@ Homestead.yaml
npm-debug.log
yarn-error.log
/auth.json
/.fleet
/.idea
/.nova
/.vscode
/.zed
tools/git-askpass.ps1
podman-compose.dev.yml
test-results
GEMINI.md
.beads/.sync.lock
.beads/daemon-error
.beads/sync_base.jsonl

File diff suppressed because it is too large Load Diff

1
GEMINI.md Symbolic link
View File

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

View File

@@ -5,6 +5,7 @@ namespace App\Console\Commands;
use App\Console\Concerns\InteractsWithCacheLocks;
use App\Jobs\ArchiveEventMediaAssets;
use App\Models\Event;
use App\Services\Compliance\RetentionOverrideService;
use Illuminate\Console\Command;
use Illuminate\Contracts\Cache\Lock;
use Illuminate\Support\Facades\Log;
@@ -37,6 +38,7 @@ class DispatchStorageArchiveCommand extends Command
$maxDispatch = max(1, (int) config('storage-monitor.archive.max_dispatch', 100));
$eventId = $this->option('event');
$dispatched = 0;
$overrides = app(RetentionOverrideService::class);
try {
$query = Event::query()
@@ -57,12 +59,16 @@ class DispatchStorageArchiveCommand extends Command
});
}
$query->chunkById($chunkSize, function ($events) use (&$dispatched, $maxDispatch, $eventLockTtl) {
$query->chunkById($chunkSize, function ($events) use (&$dispatched, $maxDispatch, $eventLockTtl, $overrides) {
foreach ($events as $event) {
if ($dispatched >= $maxDispatch) {
return false;
}
if ($overrides->eventOnHold($event)) {
continue;
}
$eventLock = $this->acquireCommandLock('storage:archive-event-'.$event->id, $eventLockTtl);
if ($eventLock === false) {
Log::channel('storage-jobs')->info('Archive dispatch skipped due to in-flight lock', [

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

@@ -15,6 +15,7 @@ class PaddleSyncPackages extends Command
{--package=* : Limit sync to the given package IDs or slugs}
{--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 Paddle products and prices.';
@@ -32,6 +33,13 @@ class PaddleSyncPackages extends Command
$dryRun = (bool) $this->option('dry-run');
$pull = (bool) $this->option('pull');
$queue = (bool) $this->option('queue');
$allowUnmapped = (bool) $this->option('allow-unmapped');
if (! $pull && ! $allowUnmapped && ! $this->hasPackageFilter()) {
if (! $this->guardUnmappedPackages($packages)) {
return self::FAILURE;
}
}
$packages->each(function (Package $package) use ($dryRun, $pull, $queue) {
if ($pull) {
@@ -82,6 +90,42 @@ class PaddleSyncPackages extends Command
return $query->orderByDesc('id')->get();
}
protected function hasPackageFilter(): bool
{
return collect((array) $this->option('package'))->filter()->isNotEmpty();
}
protected function guardUnmappedPackages(Collection $packages): bool
{
$unmapped = $packages->filter(fn (Package $package) => blank($package->paddle_product_id) || blank($package->paddle_price_id));
if ($unmapped->isEmpty()) {
return true;
}
$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->paddle_product_id)) {
$missing[] = 'product_id';
}
if (blank($package->paddle_price_id)) {
$missing[] = 'price_id';
}
return [
$package->id,
$package->slug,
implode(', ', $missing),
];
})->values()->all()
);
return false;
}
protected function dispatchSyncJob(Package $package, bool $dryRun, bool $queue): void
{
$context = [

View File

@@ -5,6 +5,7 @@ namespace App\Console\Commands;
use App\Jobs\AnonymizeAccount;
use App\Models\Tenant;
use App\Notifications\InactiveTenantDeletionWarning;
use App\Services\Compliance\RetentionOverrideService;
use Carbon\Carbon;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\Notification;
@@ -27,7 +28,13 @@ class ProcessTenantRetention extends Command
->withMax('purchases as last_purchase_activity', 'purchased_at')
->withMax('photos as last_photo_activity', 'created_at')
->chunkById(100, function ($tenants) use ($warningThreshold, $deletionThreshold) {
$overrides = app(RetentionOverrideService::class);
foreach ($tenants as $tenant) {
if ($overrides->tenantOnHold($tenant)) {
continue;
}
$lastActivity = $this->determineLastActivity($tenant);
if (! $lastActivity) {

View File

@@ -203,9 +203,20 @@ class SeedDemoSwitcherTenants extends Command
$this->upsertAdmin($tenant, 'starter-wedding@demo.fotospiel');
TenantPackage::updateOrCreate(
['tenant_id' => $tenant->id, 'package_id' => $packages['standard']->id],
[
'price' => $packages['standard']->price,
'purchased_at' => Carbon::now()->subDays(1),
'expires_at' => Carbon::now()->addMonths(12),
'used_events' => 0,
'active' => true,
]
);
$event = $this->upsertEvent(
tenant: $tenant,
package: $packages['starter'],
package: $packages['standard'],
eventType: $eventTypes['wedding'] ?? null,
attributes: [
'name' => ['de' => 'Hochzeit Mia & Jonas', 'en' => 'Wedding Mia & Jonas'],

View File

@@ -0,0 +1,19 @@
<?php
namespace App\Enums;
enum DataExportScope: string
{
case USER = 'user';
case TENANT = 'tenant';
case EVENT = 'event';
public function label(): string
{
return match ($this) {
self::USER => __('User'),
self::TENANT => __('Tenant'),
self::EVENT => __('Event'),
};
}
}

View File

@@ -0,0 +1,12 @@
<?php
namespace App\Enums;
enum PhotoLiveStatus: string
{
case NONE = 'none';
case PENDING = 'pending';
case APPROVED = 'approved';
case REJECTED = 'rejected';
case EXPIRED = 'expired';
}

View File

@@ -0,0 +1,17 @@
<?php
namespace App\Enums;
enum RetentionOverrideScope: string
{
case TENANT = 'tenant';
case EVENT = 'event';
public function label(): string
{
return match ($this) {
self::TENANT => __('Tenant'),
self::EVENT => __('Event'),
};
}
}

View File

@@ -0,0 +1,127 @@
<?php
namespace App\Filament\Clusters\DailyOps\Pages;
use App\Filament\Clusters\DailyOps\DailyOpsCluster;
use App\Filament\Widgets\JoinTokenOverviewWidget;
use App\Filament\Widgets\JoinTokenTopTokensWidget;
use App\Filament\Widgets\JoinTokenTrendWidget;
use App\Models\Event;
use BackedEnum;
use Filament\Forms\Components\Select;
use Filament\Pages\Dashboard;
use Filament\Pages\Dashboard\Concerns\HasFiltersForm;
use Filament\Schemas\Components\Section;
use Filament\Schemas\Schema;
use UnitEnum;
class JoinTokenAnalyticsDashboard extends Dashboard
{
use HasFiltersForm;
protected static ?string $cluster = DailyOpsCluster::class;
protected static string $routePath = 'join-token-analytics';
protected static null|string|BackedEnum $navigationIcon = 'heroicon-o-chart-bar';
protected static null|string|UnitEnum $navigationGroup = null;
protected static ?int $navigationSort = 12;
public static function getNavigationGroup(): UnitEnum|string|null
{
return __('admin.nav.security');
}
public static function getNavigationLabel(): string
{
return __('admin.join_token_analytics.navigation.label');
}
public function getHeading(): string
{
return __('admin.join_token_analytics.heading');
}
public function getSubheading(): ?string
{
return __('admin.join_token_analytics.subheading');
}
public function getColumns(): int|array
{
return 1;
}
public function getWidgets(): array
{
return [
JoinTokenOverviewWidget::class,
JoinTokenTrendWidget::class,
JoinTokenTopTokensWidget::class,
];
}
public function filtersForm(Schema $schema): Schema
{
return $schema
->components([
Section::make()
->schema([
Select::make('range')
->label(__('admin.join_token_analytics.filters.range'))
->options(trans('admin.join_token_analytics.filters.range_options'))
->default('24h')
->native(false),
Select::make('event_id')
->label(__('admin.join_token_analytics.filters.event'))
->placeholder(__('admin.join_token_analytics.filters.event_placeholder'))
->searchable()
->getSearchResultsUsing(fn (string $search): array => $this->searchEvents($search))
->getOptionLabelUsing(fn ($value): ?string => $this->resolveEventLabel($value))
->native(false),
])
->columns(2),
]);
}
private function searchEvents(string $search): array
{
return Event::query()
->with('tenant')
->when($search !== '', function ($query) use ($search) {
$query->where('slug', 'like', "%{$search}%")
->orWhere('name->de', 'like', "%{$search}%")
->orWhere('name->en', 'like', "%{$search}%");
})
->orderByDesc('date')
->limit(25)
->get()
->mapWithKeys(fn (Event $event) => [$event->id => $this->formatEventLabel($event)])
->all();
}
private function resolveEventLabel(mixed $value): ?string
{
if (! is_numeric($value)) {
return null;
}
$event = Event::query()
->with('tenant')
->find((int) $value);
return $event ? $this->formatEventLabel($event) : null;
}
private function formatEventLabel(Event $event): string
{
$locale = app()->getLocale();
$name = $event->name[$locale] ?? $event->name['de'] ?? $event->name['en'] ?? $event->slug ?? __('admin.common.unnamed');
$tenant = $event->tenant?->name ?? __('admin.common.unnamed');
$date = $event->date?->format('Y-m-d');
return $date ? "{$name} ({$tenant}) {$date}" : "{$name} ({$tenant})";
}
}

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

@@ -0,0 +1,366 @@
<?php
namespace App\Filament\Clusters\DailyOps\Resources\TenantPaddleHealths\Tables;
use App\Filament\Clusters\DailyOps\Resources\TenantPaddleHealths\TenantPaddleHealthResource;
use App\Models\CheckoutSession;
use App\Models\Tenant;
use Filament\Forms\Components\TextInput;
use Filament\Tables\Columns\IconColumn;
use Filament\Tables\Columns\TextColumn;
use Filament\Tables\Filters\Filter;
use Filament\Tables\Filters\SelectFilter;
use Filament\Tables\Table;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Support\Carbon;
use Illuminate\Support\Str;
class TenantPaddleHealthTable
{
private const FAILED_SYNC_STATUSES = ['failed', 'pull-failed'];
public static function configure(Table $table): Table
{
return $table
->columns([
TextColumn::make('name')
->label(__('admin.common.tenant'))
->searchable()
->sortable(),
TextColumn::make('slug')
->label(__('admin.common.slug'))
->searchable()
->toggleable(isToggledHiddenByDefault: true),
TextColumn::make('contact_email')
->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()
->color(fn (?string $state) => match ($state) {
'active' => 'success',
'suspended' => 'warning',
'expired' => 'danger',
'free' => 'gray',
default => 'gray',
}),
TextColumn::make('active_reseller_package')
->label('Active package')
->getStateUsing(fn (Tenant $record) => $record->activeResellerPackage?->package?->name ?? '—')
->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('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_paddle_transaction_at
? Carbon::parse($record->last_paddle_transaction_at)
: null)
->formatStateUsing(fn (?Carbon $state) => $state?->diffForHumans() ?? '—')
->toggleable(isToggledHiddenByDefault: true),
TextColumn::make('paddle_transaction_count_window')
->label('Paddle tx (30d)')
->default('0')
->sortable()
->toggleable(),
TextColumn::make('paddle_transaction_total_window')
->label('Paddle total (30d)')
->default(0)
->money('EUR')
->sortable()
->toggleable(),
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('paddle_refund_total_window')
->label('Refund total (30d)')
->default(0)
->money('EUR')
->sortable()
->toggleable(isToggledHiddenByDefault: true),
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('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('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('paddle_transaction_count')
->label('Paddle tx (all)')
->default('0')
->sortable()
->toggleable(isToggledHiddenByDefault: true),
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')
->query(fn (Builder $query) => self::applyStatusMismatchFilter($query)),
Filter::make('active_package')
->label('Active package')
->indicator('Active package')
->query(fn (Builder $query) => $query->whereHas('activeResellerPackage', function (Builder $query) {
$query->where('active', true)
->where(function (Builder $query) {
$query->whereNull('expires_at')
->orWhere('expires_at', '>=', now());
});
})),
Filter::make('not_suspended_or_deleted')
->label('Not suspended/deleted')
->indicator('Not suspended/deleted')
->query(fn (Builder $query) => $query
->where('is_suspended', false)
->whereNull('pending_deletion_at')
->whereNull('anonymized_at')),
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(TenantPaddleHealthResource::TRANSACTION_WINDOW_DAYS);
return $query
->whereHas('purchases', fn (Builder $query) => $query->where('provider', 'paddle'))
->whereDoesntHave('purchases', fn (Builder $query) => $query
->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', 'paddle')
->where(function (Builder $query) {
$query->whereIn('status', [
CheckoutSession::STATUS_REQUIRES_CUSTOMER_ACTION,
CheckoutSession::STATUS_PROCESSING,
])
->orWhere(function (Builder $query) {
$query->whereNotIn('status', [
CheckoutSession::STATUS_COMPLETED,
CheckoutSession::STATUS_CANCELLED,
])
->whereNotNull('expires_at')
->where('expires_at', '<', now());
});
});
})),
Filter::make('refund_spike')
->label('Refund spike (30d)')
->form([
TextInput::make('min_refunds')
->label('Minimum refunds')
->numeric()
->default(1)
->minValue(1),
])
->indicateUsing(function (array $data): ?string {
$min = (int) ($data['min_refunds'] ?? 0);
return $min > 0 ? "Refunds >= {$min} (30d)" : null;
})
->query(function (Builder $query, array $data): Builder {
$min = (int) ($data['min_refunds'] ?? 0);
if ($min < 1) {
return $query;
}
$cutoff = now()->subDays(TenantPaddleHealthResource::TRANSACTION_WINDOW_DAYS);
return $query->whereHas('purchases', fn (Builder $query) => $query
->where('provider', 'paddle')
->where('refunded', true)
->where('purchased_at', '>=', $cutoff), '>=', $min);
}),
SelectFilter::make('subscription_status')
->label('Subscription')
->options([
'active' => 'Active',
'suspended' => 'Suspended',
'expired' => 'Expired',
'free' => 'Free',
]),
])
->actions([]);
}
private static function hasStatusMismatch(Tenant $record): bool
{
$hasActivePackage = (bool) ($record->has_active_reseller_package ?? $record->activeResellerPackage);
$status = (string) ($record->subscription_status ?? '');
$expiresAt = $record->subscription_expires_at;
if ($status === 'active' && ! $hasActivePackage) {
return true;
}
if ($status !== 'active' && $hasActivePackage) {
return true;
}
if ($status === 'active' && $expiresAt && $expiresAt->isPast()) {
return true;
}
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) {
$query->where(function (Builder $query) {
$query->where('subscription_status', 'active')
->whereDoesntHave('activeResellerPackage');
})->orWhere(function (Builder $query) {
$query->where('subscription_status', '!=', 'active')
->whereHas('activeResellerPackage');
})->orWhere(function (Builder $query) {
$query->where('subscription_status', 'active')
->whereNotNull('subscription_expires_at')
->where('subscription_expires_at', '<', now());
});
});
}
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(TenantPaddleHealthResource::TRANSACTION_WINDOW_DAYS))) {
return 'danger';
}
return 'success';
}
}

View File

@@ -0,0 +1,121 @@
<?php
namespace App\Filament\Clusters\DailyOps\Resources\TenantPaddleHealths;
use App\Filament\Clusters\DailyOps\DailyOpsCluster;
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;
use Filament\Resources\Resource;
use Filament\Tables\Table;
use Illuminate\Database\Eloquent\Builder;
use UnitEnum;
class TenantPaddleHealthResource extends Resource
{
public const STALE_SYNC_DAYS = 30;
public const TRANSACTION_WINDOW_DAYS = 30;
protected static ?string $model = Tenant::class;
protected static string|BackedEnum|null $navigationIcon = 'heroicon-o-credit-card';
protected static ?string $cluster = DailyOpsCluster::class;
protected static ?string $slug = 'paddle-health';
protected static ?int $navigationSort = 20;
public static function table(Table $table): Table
{
return TenantPaddleHealthTable::configure($table);
}
public static function canCreate(): bool
{
return false;
}
public static function getNavigationLabel(): string
{
return __('admin.paddle_health.navigation.label');
}
public static function getNavigationGroup(): UnitEnum|string|null
{
return __('admin.nav.billing');
}
public static function getEloquentQuery(): Builder
{
$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 paddle_transaction_count' => fn (Builder $query) => $query
->where('provider', 'paddle')
->where('refunded', false),
'purchases as paddle_transaction_count_window' => fn (Builder $query) => $query
->where('provider', 'paddle')
->where('refunded', false)
->where('purchased_at', '>=', $windowStart),
'purchases as paddle_refund_count_window' => fn (Builder $query) => $query
->where('provider', 'paddle')
->where('refunded', true)
->where('purchased_at', '>=', $windowStart),
'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 paddle_checkout_processing_count' => fn (Builder $query) => $query
->where('provider', CheckoutSession::PROVIDER_PADDLE)
->where('status', CheckoutSession::STATUS_PROCESSING),
'checkoutSessions as paddle_checkout_expired_count' => fn (Builder $query) => $query
->where('provider', CheckoutSession::PROVIDER_PADDLE)
->whereNotIn('status', [
CheckoutSession::STATUS_COMPLETED,
CheckoutSession::STATUS_CANCELLED,
])
->whereNotNull('expires_at')
->where('expires_at', '<', now()),
])
->withSum([
'purchases as paddle_transaction_total' => fn (Builder $query) => $query
->where('provider', 'paddle')
->where('refunded', false),
], 'price')
->withSum([
'purchases as paddle_transaction_total_window' => fn (Builder $query) => $query
->where('provider', 'paddle')
->where('refunded', false)
->where('purchased_at', '>=', $windowStart),
], 'price')
->withSum([
'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_paddle_transaction_at' => fn (Builder $query) => $query
->where('provider', 'paddle'),
], 'purchased_at');
}
public static function getPages(): array
{
return [
'index' => ListTenantPaddleHealths::route('/'),
];
}
}

View File

@@ -7,6 +7,7 @@ use Filament\Schemas\Schema;
use Filament\Tables\Columns\TextColumn;
use Filament\Tables\Filters\SelectFilter;
use Filament\Tables\Table;
use Illuminate\Support\Str;
class RedemptionsRelationManager extends RelationManager
{
@@ -25,6 +26,30 @@ class RedemptionsRelationManager extends RelationManager
TextColumn::make('tenant.name')
->label(__('Tenant'))
->searchable(),
TextColumn::make('ip_address')
->label(__('IP'))
->copyable()
->toggleable(isToggledHiddenByDefault: true),
TextColumn::make('device_id')
->label(__('Device'))
->copyable()
->toggleable(isToggledHiddenByDefault: true),
TextColumn::make('user_agent')
->label(__('User agent'))
->toggleable(isToggledHiddenByDefault: true)
->wrap(),
TextColumn::make('fraud_ip')
->label(__('IP reputation'))
->badge()
->formatStateUsing(fn ($state, $record) => self::formatReputation(data_get($record->metadata, 'fraud.ip')))
->color(fn ($state, $record) => self::riskColor(data_get($record->metadata, 'fraud.ip.risk')))
->toggleable(isToggledHiddenByDefault: true),
TextColumn::make('fraud_device')
->label(__('Device reputation'))
->badge()
->formatStateUsing(fn ($state, $record) => self::formatReputation(data_get($record->metadata, 'fraud.device')))
->color(fn ($state, $record) => self::riskColor(data_get($record->metadata, 'fraud.device.risk')))
->toggleable(isToggledHiddenByDefault: true),
TextColumn::make('user.name')
->label(__('User'))
->toggleable(isToggledHiddenByDefault: true),
@@ -69,4 +94,30 @@ class RedemptionsRelationManager extends RelationManager
->recordActions([])
->toolbarActions([]);
}
/**
* @param array{risk?: string, recent_failed?: int, recent_total?: int}|null $snapshot
*/
private static function formatReputation(?array $snapshot): string
{
if (! $snapshot) {
return '—';
}
$risk = Str::headline($snapshot['risk'] ?? 'unknown');
$failed = (int) ($snapshot['recent_failed'] ?? 0);
$total = (int) ($snapshot['recent_total'] ?? 0);
return sprintf('%s (%d/%d)', $risk, $failed, $total);
}
private static function riskColor(?string $risk): string
{
return match ($risk) {
'high' => 'danger',
'medium' => 'warning',
'low' => 'success',
default => 'gray',
};
}
}

View File

@@ -2,9 +2,11 @@
namespace App\Filament\Resources\Coupons\Schemas;
use App\Enums\CouponStatus;
use App\Enums\CouponType;
use Filament\Infolists\Components\KeyValueEntry;
use Filament\Infolists\Components\Section;
use Filament\Infolists\Components\TextEntry;
use Filament\Schemas\Components\Section;
use Filament\Schemas\Schema;
use Illuminate\Support\Str;
@@ -22,11 +24,11 @@ class CouponInfolist
TextEntry::make('status')
->label(__('Status'))
->badge()
->formatStateUsing(fn ($state) => Str::headline($state)),
->formatStateUsing(fn ($state) => static::formatEnumState($state)),
TextEntry::make('type')
->label(__('Discount type'))
->badge()
->formatStateUsing(fn ($state) => Str::headline($state)),
->formatStateUsing(fn ($state) => static::formatEnumState($state)),
TextEntry::make('amount')
->label(__('Amount'))
->formatStateUsing(fn ($state, $record) => $record?->type?->value === 'percentage'
@@ -78,4 +80,21 @@ class CouponInfolist
]),
]);
}
public static function formatEnumState(mixed $state): string
{
if ($state instanceof CouponType || $state instanceof CouponStatus) {
return $state->label();
}
if ($state instanceof \BackedEnum) {
return Str::headline($state->value);
}
if (is_string($state)) {
return Str::headline($state);
}
return '';
}
}

View File

@@ -40,7 +40,7 @@ class CouponsTable
TextColumn::make('type')
->label(__('Type'))
->badge()
->formatStateUsing(fn ($state) => Str::headline($state))
->formatStateUsing(fn ($state) => static::formatEnumState($state))
->sortable(),
TextColumn::make('amount')
->label(__('Amount'))
@@ -59,7 +59,7 @@ class CouponsTable
->label(__('Status'))
->badge()
->sortable()
->formatStateUsing(fn ($state) => Str::headline($state)),
->formatStateUsing(fn ($state) => static::formatEnumState($state)),
TextColumn::make('starts_at')
->label(__('Starts'))
->date()
@@ -151,4 +151,21 @@ class CouponsTable
]),
]);
}
public static function formatEnumState(mixed $state): string
{
if ($state instanceof CouponType || $state instanceof CouponStatus) {
return $state->label();
}
if ($state instanceof \BackedEnum) {
return Str::headline($state->value);
}
if (is_string($state)) {
return Str::headline($state);
}
return '';
}
}

View File

@@ -0,0 +1,78 @@
<?php
namespace App\Filament\Resources;
use App\Filament\Clusters\RareAdmin\RareAdminCluster;
use App\Filament\Resources\DataExportResource\Pages\CreateDataExport;
use App\Filament\Resources\DataExportResource\Pages\ListDataExports;
use App\Filament\Resources\DataExportResource\Schemas\DataExportForm;
use App\Filament\Resources\DataExportResource\Tables\DataExportTable;
use App\Models\DataExport;
use BackedEnum;
use Filament\Resources\Resource;
use Filament\Schemas\Schema;
use Filament\Support\Icons\Heroicon;
use Filament\Tables\Table;
use Illuminate\Database\Eloquent\Builder;
use UnitEnum;
class DataExportResource extends Resource
{
protected static ?string $model = DataExport::class;
protected static ?string $cluster = RareAdminCluster::class;
protected static string|BackedEnum|null $navigationIcon = Heroicon::OutlinedArrowDownTray;
protected static UnitEnum|string|null $navigationGroup = null;
protected static ?int $navigationSort = 50;
public static function form(Schema $schema): Schema
{
return DataExportForm::configure($schema);
}
public static function table(Table $table): Table
{
return DataExportTable::configure($table);
}
public static function getNavigationLabel(): string
{
return __('admin.data_exports.navigation.label');
}
public static function getNavigationGroup(): UnitEnum|string|null
{
return __('admin.nav.platform');
}
public static function getEloquentQuery(): Builder
{
return parent::getEloquentQuery()->with(['tenant', 'event', 'user']);
}
public static function getPages(): array
{
return [
'index' => ListDataExports::route('/'),
'create' => CreateDataExport::route('/create'),
];
}
public static function canEdit($record): bool
{
return false;
}
public static function canDelete($record): bool
{
return false;
}
public static function canDeleteAny(): bool
{
return false;
}
}

View File

@@ -0,0 +1,34 @@
<?php
namespace App\Filament\Resources\DataExportResource\Pages;
use App\Enums\DataExportScope;
use App\Filament\Resources\DataExportResource;
use App\Filament\Resources\Pages\AuditedCreateRecord;
use App\Jobs\GenerateDataExport;
use App\Models\DataExport;
use Filament\Facades\Filament;
class CreateDataExport extends AuditedCreateRecord
{
protected static string $resource = DataExportResource::class;
protected function mutateFormDataBeforeCreate(array $data): array
{
$data['user_id'] = Filament::auth()->id();
$data['status'] = DataExport::STATUS_PENDING;
if (($data['scope'] ?? null) !== DataExportScope::EVENT->value) {
$data['event_id'] = null;
}
return $data;
}
protected function afterCreate(): void
{
parent::afterCreate();
GenerateDataExport::dispatch($this->record->id);
}
}

View File

@@ -0,0 +1,20 @@
<?php
namespace App\Filament\Resources\DataExportResource\Pages;
use App\Filament\Resources\DataExportResource;
use Filament\Actions\CreateAction;
use Filament\Resources\Pages\ListRecords;
class ListDataExports extends ListRecords
{
protected static string $resource = DataExportResource::class;
protected function getHeaderActions(): array
{
return [
CreateAction::make()
->label(__('admin.data_exports.actions.request')),
];
}
}

View File

@@ -0,0 +1,68 @@
<?php
namespace App\Filament\Resources\DataExportResource\Schemas;
use App\Enums\DataExportScope;
use App\Models\Event;
use App\Models\Tenant;
use Filament\Forms\Components\Select;
use Filament\Forms\Components\Toggle;
use Filament\Forms\Get;
use Filament\Schemas\Components\Section;
use Filament\Schemas\Schema;
class DataExportForm
{
public static function configure(Schema $schema): Schema
{
return $schema->schema([
Section::make(__('admin.data_exports.sections.request'))
->schema([
Select::make('scope')
->label(__('admin.data_exports.fields.scope'))
->options([
DataExportScope::TENANT->value => __('admin.data_exports.scope.tenant'),
DataExportScope::EVENT->value => __('admin.data_exports.scope.event'),
])
->default(DataExportScope::TENANT->value)
->live()
->required(),
Select::make('tenant_id')
->label(__('admin.data_exports.fields.tenant'))
->options(Tenant::query()->orderBy('name')->pluck('name', 'id'))
->searchable()
->preload()
->required()
->live(),
Select::make('event_id')
->label(__('admin.data_exports.fields.event'))
->options(function (Get $get): array {
$tenantId = $get('tenant_id');
if (! $tenantId) {
return [];
}
return Event::query()
->where('tenant_id', $tenantId)
->orderByDesc('date')
->get()
->mapWithKeys(function (Event $event): array {
$name = $event->name['de'] ?? $event->name['en'] ?? $event->slug;
return [$event->id => $name];
})
->all();
})
->searchable()
->preload()
->visible(fn (Get $get): bool => $get('scope') === DataExportScope::EVENT->value)
->required(fn (Get $get): bool => $get('scope') === DataExportScope::EVENT->value)
->dehydrated(fn (Get $get): bool => $get('scope') === DataExportScope::EVENT->value),
Toggle::make('include_media')
->label(__('admin.data_exports.fields.include_media'))
->helperText(__('admin.data_exports.help.include_media')),
])
->columns(2),
]);
}
}

View File

@@ -0,0 +1,139 @@
<?php
namespace App\Filament\Resources\DataExportResource\Tables;
use App\Enums\DataExportScope;
use App\Jobs\GenerateDataExport;
use App\Models\DataExport;
use App\Services\Audit\SuperAdminAuditLogger;
use Filament\Actions\Action;
use Filament\Tables\Columns\IconColumn;
use Filament\Tables\Columns\TextColumn;
use Filament\Tables\Filters\SelectFilter;
use Filament\Tables\Table;
use Illuminate\Support\Number;
class DataExportTable
{
public static function formatScope(DataExportScope|string|null $state): string
{
if ($state instanceof DataExportScope) {
$state = $state->value;
}
return $state ? __('admin.data_exports.scope.'.$state) : '—';
}
public static function configure(Table $table): Table
{
return $table
->columns([
TextColumn::make('id')
->label(__('admin.data_exports.fields.id'))
->sortable(),
TextColumn::make('tenant.name')
->label(__('admin.data_exports.fields.tenant'))
->searchable(),
TextColumn::make('event.slug')
->label(__('admin.data_exports.fields.event'))
->toggleable()
->placeholder('—'),
TextColumn::make('scope')
->label(__('admin.data_exports.fields.scope'))
->badge()
->formatStateUsing(fn (DataExportScope|string|null $state): string => self::formatScope($state)),
TextColumn::make('status')
->label(__('admin.data_exports.fields.status'))
->badge()
->formatStateUsing(fn (string $state) => __('admin.data_exports.status.'.$state))
->color(fn (string $state) => match ($state) {
DataExport::STATUS_READY => 'success',
DataExport::STATUS_FAILED => 'danger',
DataExport::STATUS_PROCESSING => 'warning',
DataExport::STATUS_CANCELED => 'gray',
default => 'gray',
}),
IconColumn::make('include_media')
->label(__('admin.data_exports.fields.include_media'))
->boolean(),
TextColumn::make('size_bytes')
->label(__('admin.data_exports.fields.size'))
->formatStateUsing(fn (?int $state) => $state ? Number::fileSize($state) : '—')
->toggleable(),
TextColumn::make('created_at')
->label(__('admin.data_exports.fields.created_at'))
->since()
->sortable(),
TextColumn::make('expires_at')
->label(__('admin.data_exports.fields.expires_at'))
->since()
->toggleable(),
])
->filters([
SelectFilter::make('scope')
->label(__('admin.data_exports.fields.scope'))
->options([
'tenant' => __('admin.data_exports.scope.tenant'),
'event' => __('admin.data_exports.scope.event'),
'user' => __('admin.data_exports.scope.user'),
]),
SelectFilter::make('status')
->label(__('admin.data_exports.fields.status'))
->options([
DataExport::STATUS_PENDING => __('admin.data_exports.status.pending'),
DataExport::STATUS_PROCESSING => __('admin.data_exports.status.processing'),
DataExport::STATUS_READY => __('admin.data_exports.status.ready'),
DataExport::STATUS_FAILED => __('admin.data_exports.status.failed'),
DataExport::STATUS_CANCELED => __('admin.data_exports.status.canceled'),
]),
])
->actions([
Action::make('download')
->label(__('admin.data_exports.actions.download'))
->icon('heroicon-o-arrow-down-tray')
->url(fn (DataExport $record) => route('superadmin.data-exports.download', $record))
->openUrlInNewTab()
->visible(fn (DataExport $record): bool => $record->isReady() && ! $record->hasExpired()),
Action::make('retry')
->label(__('admin.data_exports.actions.retry'))
->icon('heroicon-o-arrow-path')
->color('warning')
->requiresConfirmation()
->visible(fn (DataExport $record): bool => $record->canRetry())
->action(function (DataExport $record): void {
if (! $record->canRetry()) {
return;
}
$record->resetForRetry();
GenerateDataExport::dispatch($record->id);
app(SuperAdminAuditLogger::class)->recordModelMutation(
'updated',
$record,
source: self::class
);
}),
Action::make('cancel')
->label(__('admin.data_exports.actions.cancel'))
->icon('heroicon-o-x-circle')
->color('danger')
->requiresConfirmation()
->visible(fn (DataExport $record): bool => $record->canCancel())
->action(function (DataExport $record): void {
if (! $record->canCancel()) {
return;
}
$record->markCanceled();
app(SuperAdminAuditLogger::class)->recordModelMutation(
'updated',
$record,
source: self::class
);
}),
])
->bulkActions([]);
}
}

View File

@@ -193,6 +193,11 @@ class PackageResource extends Resource
->label('Zuletzt synchronisiert')
->content(fn (?Package $record) => $record?->paddle_synced_at ? $record->paddle_synced_at->diffForHumans() : '')
->columnSpanFull(),
Placeholder::make('paddle_sync_error')
->label('Letzter Fehler')
->content(fn (?Package $record) => $record?->paddle_sync_error_message ?? '')
->visible(fn (?Package $record) => filled($record?->paddle_sync_error_message))
->columnSpanFull(),
]),
]);
}
@@ -271,7 +276,7 @@ class PackageResource extends Resource
->colors([
'success' => 'synced',
'warning' => 'syncing',
'info' => 'dry-run',
'info' => ['dry-run', 'linked', 'pulled'],
'danger' => ['failed', 'pull-failed'],
])
->formatStateUsing(fn ($state) => $state ? Str::headline($state) : null)
@@ -280,6 +285,11 @@ class PackageResource extends Resource
->label('Sync am')
->dateTime()
->toggleable(isToggledHiddenByDefault: true),
TextColumn::make('paddle_sync_error_message')
->label('Sync-Fehler')
->getStateUsing(fn (Package $record) => $record->paddle_sync_error_message)
->wrap()
->toggleable(isToggledHiddenByDefault: true),
])
->filters([
Tables\Filters\SelectFilter::make('type')
@@ -306,6 +316,42 @@ class PackageResource extends Resource
->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')

View File

@@ -0,0 +1,75 @@
<?php
namespace App\Filament\Resources;
use App\Filament\Clusters\RareAdmin\RareAdminCluster;
use App\Filament\Resources\RetentionOverrideResource\Pages\CreateRetentionOverride;
use App\Filament\Resources\RetentionOverrideResource\Pages\EditRetentionOverride;
use App\Filament\Resources\RetentionOverrideResource\Pages\ListRetentionOverrides;
use App\Filament\Resources\RetentionOverrideResource\Schemas\RetentionOverrideForm;
use App\Filament\Resources\RetentionOverrideResource\Tables\RetentionOverrideTable;
use App\Models\RetentionOverride;
use BackedEnum;
use Filament\Resources\Resource;
use Filament\Schemas\Schema;
use Filament\Support\Icons\Heroicon;
use Filament\Tables\Table;
use Illuminate\Database\Eloquent\Builder;
use UnitEnum;
class RetentionOverrideResource extends Resource
{
protected static ?string $model = RetentionOverride::class;
protected static ?string $cluster = RareAdminCluster::class;
protected static string|BackedEnum|null $navigationIcon = Heroicon::OutlinedShieldExclamation;
protected static UnitEnum|string|null $navigationGroup = null;
protected static ?int $navigationSort = 55;
public static function form(Schema $schema): Schema
{
return RetentionOverrideForm::configure($schema);
}
public static function table(Table $table): Table
{
return RetentionOverrideTable::configure($table);
}
public static function getNavigationLabel(): string
{
return __('admin.retention_overrides.navigation.label');
}
public static function getNavigationGroup(): UnitEnum|string|null
{
return __('admin.nav.platform');
}
public static function getEloquentQuery(): Builder
{
return parent::getEloquentQuery()->with(['tenant', 'event', 'createdBy', 'releasedBy']);
}
public static function getPages(): array
{
return [
'index' => ListRetentionOverrides::route('/'),
'create' => CreateRetentionOverride::route('/create'),
'edit' => EditRetentionOverride::route('/{record}/edit'),
];
}
public static function canDelete($record): bool
{
return false;
}
public static function canDeleteAny(): bool
{
return false;
}
}

View File

@@ -0,0 +1,26 @@
<?php
namespace App\Filament\Resources\RetentionOverrideResource\Pages;
use App\Enums\RetentionOverrideScope;
use App\Filament\Resources\Pages\AuditedCreateRecord;
use App\Filament\Resources\RetentionOverrideResource;
use Filament\Facades\Filament;
class CreateRetentionOverride extends AuditedCreateRecord
{
protected static string $resource = RetentionOverrideResource::class;
protected function mutateFormDataBeforeCreate(array $data): array
{
$data['created_by_id'] = Filament::auth()->id();
$data['released_at'] = null;
$data['released_by_id'] = null;
if (($data['scope'] ?? null) !== RetentionOverrideScope::EVENT->value) {
$data['event_id'] = null;
}
return $data;
}
}

View File

@@ -0,0 +1,44 @@
<?php
namespace App\Filament\Resources\RetentionOverrideResource\Pages;
use App\Filament\Resources\Pages\AuditedEditRecord;
use App\Filament\Resources\RetentionOverrideResource;
use App\Models\RetentionOverride;
use App\Services\Audit\SuperAdminAuditLogger;
use Filament\Actions;
use Filament\Facades\Filament;
class EditRetentionOverride extends AuditedEditRecord
{
protected static string $resource = RetentionOverrideResource::class;
protected function getHeaderActions(): array
{
return [
Actions\Action::make('release')
->label(__('admin.retention_overrides.actions.release'))
->icon('heroicon-o-check-circle')
->color('success')
->requiresConfirmation()
->visible(fn () => $this->record instanceof RetentionOverride && $this->record->released_at === null)
->action(function (): void {
if (! ($this->record instanceof RetentionOverride) || $this->record->released_at !== null) {
return;
}
$this->record->forceFill([
'released_at' => now(),
'released_by_id' => Filament::auth()->id(),
])->save();
app(SuperAdminAuditLogger::class)->recordModelMutation(
'updated',
$this->record,
SuperAdminAuditLogger::fieldsMetadata(['released_at', 'released_by_id']),
static::class
);
}),
];
}
}

View File

@@ -0,0 +1,20 @@
<?php
namespace App\Filament\Resources\RetentionOverrideResource\Pages;
use App\Filament\Resources\RetentionOverrideResource;
use Filament\Actions\CreateAction;
use Filament\Resources\Pages\ListRecords;
class ListRetentionOverrides extends ListRecords
{
protected static string $resource = RetentionOverrideResource::class;
protected function getHeaderActions(): array
{
return [
CreateAction::make()
->label(__('admin.retention_overrides.actions.request')),
];
}
}

View File

@@ -0,0 +1,98 @@
<?php
namespace App\Filament\Resources\RetentionOverrideResource\Schemas;
use App\Enums\RetentionOverrideScope;
use App\Models\Event;
use App\Models\RetentionOverride;
use App\Models\Tenant;
use Filament\Forms\Components\Placeholder;
use Filament\Forms\Components\Section;
use Filament\Forms\Components\Select;
use Filament\Forms\Components\Textarea;
use Filament\Forms\Components\TextInput;
use Filament\Forms\Get;
use Filament\Schemas\Schema;
class RetentionOverrideForm
{
public static function configure(Schema $schema): Schema
{
return $schema->components([
Section::make(__('admin.retention_overrides.sections.override'))
->schema([
Select::make('scope')
->label(__('admin.retention_overrides.fields.scope'))
->options([
RetentionOverrideScope::TENANT->value => __('admin.retention_overrides.scope.tenant'),
RetentionOverrideScope::EVENT->value => __('admin.retention_overrides.scope.event'),
])
->default(RetentionOverrideScope::TENANT->value)
->required()
->live()
->disabled(fn (?RetentionOverride $record) => $record?->released_at !== null),
Select::make('tenant_id')
->label(__('admin.retention_overrides.fields.tenant'))
->options(Tenant::query()->orderBy('name')->pluck('name', 'id'))
->searchable()
->preload()
->required()
->live()
->disabled(fn (?RetentionOverride $record) => $record?->released_at !== null),
Select::make('event_id')
->label(__('admin.retention_overrides.fields.event'))
->options(function (Get $get): array {
$tenantId = $get('tenant_id');
if (! $tenantId) {
return [];
}
return Event::query()
->where('tenant_id', $tenantId)
->orderByDesc('date')
->get()
->mapWithKeys(function (Event $event): array {
$name = $event->name['de'] ?? $event->name['en'] ?? $event->slug;
return [$event->id => $name];
})
->all();
})
->searchable()
->preload()
->visible(fn (Get $get): bool => $get('scope') === RetentionOverrideScope::EVENT->value)
->required(fn (Get $get): bool => $get('scope') === RetentionOverrideScope::EVENT->value)
->dehydrated(fn (Get $get): bool => $get('scope') === RetentionOverrideScope::EVENT->value)
->disabled(fn (?RetentionOverride $record) => $record?->released_at !== null),
TextInput::make('reason')
->label(__('admin.retention_overrides.fields.reason'))
->maxLength(200)
->required()
->disabled(fn (?RetentionOverride $record) => $record?->released_at !== null),
Textarea::make('note')
->label(__('admin.retention_overrides.fields.note'))
->rows(3)
->maxLength(2000)
->columnSpanFull()
->disabled(fn (?RetentionOverride $record) => $record?->released_at !== null),
])
->columns(2),
Section::make(__('admin.retention_overrides.sections.status'))
->schema([
Placeholder::make('created_by_id')
->label(__('admin.retention_overrides.fields.created_by'))
->content(fn (?RetentionOverride $record) => $record?->createdBy?->name ?? '—'),
Placeholder::make('created_at')
->label(__('admin.retention_overrides.fields.created_at'))
->content(fn (?RetentionOverride $record) => $record?->created_at?->diffForHumans() ?? '—'),
Placeholder::make('released_by_id')
->label(__('admin.retention_overrides.fields.released_by'))
->content(fn (?RetentionOverride $record) => $record?->releasedBy?->name ?? '—'),
Placeholder::make('released_at')
->label(__('admin.retention_overrides.fields.released_at'))
->content(fn (?RetentionOverride $record) => $record?->released_at?->diffForHumans() ?? '—'),
])
->columns(2),
]);
}
}

View File

@@ -0,0 +1,111 @@
<?php
namespace App\Filament\Resources\RetentionOverrideResource\Tables;
use App\Models\RetentionOverride;
use App\Services\Audit\SuperAdminAuditLogger;
use Filament\Actions\Action;
use Filament\Facades\Filament;
use Filament\Tables\Columns\TextColumn;
use Filament\Tables\Filters\SelectFilter;
use Filament\Tables\Table;
use Illuminate\Database\Eloquent\Builder;
class RetentionOverrideTable
{
public static function configure(Table $table): Table
{
return $table
->defaultSort('created_at', 'desc')
->columns([
TextColumn::make('id')
->label(__('admin.retention_overrides.fields.id'))
->sortable(),
TextColumn::make('scope')
->label(__('admin.retention_overrides.fields.scope'))
->badge()
->formatStateUsing(fn (?string $state) => $state ? __('admin.retention_overrides.scope.'.$state) : '—'),
TextColumn::make('tenant.name')
->label(__('admin.retention_overrides.fields.tenant'))
->searchable(),
TextColumn::make('event.slug')
->label(__('admin.retention_overrides.fields.event'))
->toggleable()
->placeholder('—'),
TextColumn::make('reason')
->label(__('admin.retention_overrides.fields.reason'))
->limit(40)
->searchable(),
TextColumn::make('status')
->label(__('admin.retention_overrides.fields.status'))
->state(fn (RetentionOverride $record) => $record->released_at ? 'released' : 'active')
->badge()
->formatStateUsing(fn (string $state) => __('admin.retention_overrides.status.'.$state))
->color(fn (string $state) => $state === 'released' ? 'gray' : 'success'),
TextColumn::make('createdBy.name')
->label(__('admin.retention_overrides.fields.created_by'))
->toggleable()
->placeholder('—'),
TextColumn::make('created_at')
->label(__('admin.retention_overrides.fields.created_at'))
->since()
->sortable(),
TextColumn::make('releasedBy.name')
->label(__('admin.retention_overrides.fields.released_by'))
->toggleable(isToggledHiddenByDefault: true)
->placeholder('—'),
TextColumn::make('released_at')
->label(__('admin.retention_overrides.fields.released_at'))
->since()
->toggleable(isToggledHiddenByDefault: true)
->placeholder('—'),
])
->filters([
SelectFilter::make('scope')
->label(__('admin.retention_overrides.fields.scope'))
->options([
'tenant' => __('admin.retention_overrides.scope.tenant'),
'event' => __('admin.retention_overrides.scope.event'),
]),
SelectFilter::make('status')
->label(__('admin.retention_overrides.fields.status'))
->options([
'active' => __('admin.retention_overrides.status.active'),
'released' => __('admin.retention_overrides.status.released'),
])
->query(function (Builder $query, array $data): Builder {
return match ($data['value'] ?? null) {
'active' => $query->whereNull('released_at'),
'released' => $query->whereNotNull('released_at'),
default => $query,
};
}),
])
->actions([
Action::make('release')
->label(__('admin.retention_overrides.actions.release'))
->icon('heroicon-o-check-circle')
->color('success')
->requiresConfirmation()
->visible(fn (RetentionOverride $record): bool => $record->released_at === null)
->action(function (RetentionOverride $record): void {
if ($record->released_at !== null) {
return;
}
$record->forceFill([
'released_at' => now(),
'released_by_id' => Filament::auth()->id(),
])->save();
app(SuperAdminAuditLogger::class)->recordModelMutation(
'updated',
$record,
SuperAdminAuditLogger::fieldsMetadata(['released_at', 'released_by_id']),
static::class
);
}),
])
->bulkActions([]);
}
}

View File

@@ -10,6 +10,7 @@ use App\Filament\Resources\TenantAnnouncementResource\Pages;
use App\Models\TenantAnnouncement;
use App\Services\Audit\SuperAdminAuditLogger;
use BackedEnum;
use Filament\Actions;
use Filament\Forms\Components\CheckboxList;
use Filament\Forms\Components\DateTimePicker;
use Filament\Forms\Components\Select;
@@ -201,7 +202,7 @@ class TenantAnnouncementResource extends Resource
->options($audienceOptions),
])
->actions([
Tables\Actions\EditAction::make()
Actions\EditAction::make()
->after(fn (array $data, TenantAnnouncement $record) => app(SuperAdminAuditLogger::class)->recordModelMutation(
'updated',
$record,
@@ -210,7 +211,7 @@ class TenantAnnouncementResource extends Resource
)),
])
->bulkActions([
Tables\Actions\DeleteBulkAction::make()
Actions\DeleteBulkAction::make()
->after(function (Collection $records): void {
$logger = app(SuperAdminAuditLogger::class);

View File

@@ -40,7 +40,7 @@ class Login extends BaseLogin implements HasForms
}
// SuperAdmin-spezifisch: Prüfe auf SuperAdmin-Rolle, keine Tenant-Prüfung
if ($user->role !== 'super_admin') {
if (! $user->isSuperAdmin()) {
$authGuard->logout();
throw ValidationException::withMessages([

View File

@@ -45,14 +45,16 @@ class GuestPolicySettingsPage extends Page
public int $join_token_failure_decay_minutes = 5;
public int $join_token_access_limit = 120;
public int $join_token_access_limit = 300;
public int $join_token_access_decay_minutes = 1;
public int $join_token_download_limit = 60;
public int $join_token_download_limit = 120;
public int $join_token_download_decay_minutes = 1;
public int $join_token_ttl_hours = 168;
public int $share_link_ttl_hours = 48;
public ?int $guest_notification_ttl_hours = null;
@@ -67,10 +69,11 @@ class GuestPolicySettingsPage extends Page
$this->per_device_upload_limit = (int) ($settings->per_device_upload_limit ?? 50);
$this->join_token_failure_limit = (int) ($settings->join_token_failure_limit ?? 10);
$this->join_token_failure_decay_minutes = (int) ($settings->join_token_failure_decay_minutes ?? 5);
$this->join_token_access_limit = (int) ($settings->join_token_access_limit ?? 120);
$this->join_token_access_limit = (int) ($settings->join_token_access_limit ?? 300);
$this->join_token_access_decay_minutes = (int) ($settings->join_token_access_decay_minutes ?? 1);
$this->join_token_download_limit = (int) ($settings->join_token_download_limit ?? 60);
$this->join_token_download_limit = (int) ($settings->join_token_download_limit ?? 120);
$this->join_token_download_decay_minutes = (int) ($settings->join_token_download_decay_minutes ?? 1);
$this->join_token_ttl_hours = (int) ($settings->join_token_ttl_hours ?? 168);
$this->share_link_ttl_hours = (int) ($settings->share_link_ttl_hours ?? 48);
$this->guest_notification_ttl_hours = $settings->guest_notification_ttl_hours;
}
@@ -130,6 +133,11 @@ class GuestPolicySettingsPage extends Page
->columns(2),
Section::make(__('admin.guest_policy.sections.retention'))
->schema([
Forms\Components\TextInput::make('join_token_ttl_hours')
->label(__('admin.guest_policy.fields.join_token_ttl_hours'))
->numeric()
->minValue(0)
->helperText(__('admin.guest_policy.help.join_token_ttl')),
Forms\Components\TextInput::make('share_link_ttl_hours')
->label(__('admin.guest_policy.fields.share_link_ttl_hours'))
->numeric()
@@ -160,6 +168,7 @@ class GuestPolicySettingsPage extends Page
$settings->join_token_access_decay_minutes = (int) $this->join_token_access_decay_minutes;
$settings->join_token_download_limit = (int) $this->join_token_download_limit;
$settings->join_token_download_decay_minutes = (int) $this->join_token_download_decay_minutes;
$settings->join_token_ttl_hours = (int) $this->join_token_ttl_hours;
$settings->share_link_ttl_hours = (int) $this->share_link_ttl_hours;
$settings->guest_notification_ttl_hours = $this->guest_notification_ttl_hours;
$settings->save();

View File

@@ -0,0 +1,39 @@
<?php
namespace App\Filament\SuperAdmin\Pages;
use App\Filament\Clusters\DailyOps\DailyOpsCluster;
use App\Filament\Widgets\IntegrationsHealthWidget;
use BackedEnum;
use Filament\Pages\Page;
use UnitEnum;
class IntegrationsHealthDashboard extends Page
{
protected string $view = 'filament.super-admin.pages.integrations-health-dashboard';
protected static ?string $cluster = DailyOpsCluster::class;
protected static null|string|BackedEnum $navigationIcon = 'heroicon-o-link';
protected static null|string|UnitEnum $navigationGroup = null;
protected static ?int $navigationSort = 15;
public static function getNavigationGroup(): UnitEnum|string|null
{
return __('admin.nav.infrastructure');
}
public static function getNavigationLabel(): string
{
return __('admin.integrations_health.navigation.label');
}
protected function getHeaderWidgets(): array
{
return [
IntegrationsHealthWidget::class,
];
}
}

View File

@@ -1,187 +0,0 @@
<?php
namespace App\Filament\Tenant\Pages;
use App\Models\Event;
use App\Models\EventJoinToken;
use App\Services\EventJoinTokenService;
use App\Support\JoinTokenLayoutRegistry;
use App\Support\TenantOnboardingState;
use BackedEnum;
use Filament\Notifications\Notification;
use Filament\Pages\Page;
use Illuminate\Support\Arr;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\URL;
class InviteStudio extends Page
{
protected static BackedEnum|string|null $navigationIcon = 'heroicon-o-qr-code';
protected string $view = 'filament.tenant.pages.invite-studio';
protected static ?string $navigationLabel = 'Einladungen & QR';
protected static ?string $slug = 'invite-studio';
protected static ?string $title = 'Einladungen & QR-Codes';
protected static ?int $navigationSort = 50;
public ?int $selectedEventId = null;
public string $tokenLabel = '';
public array $tokens = [];
public array $layouts = [];
protected static bool $shouldRegisterNavigation = true;
public function mount(): void
{
$tenant = TenantOnboardingState::tenant();
abort_if(! $tenant, 403);
if (! TenantOnboardingState::completed($tenant)) {
$this->redirect(TenantOnboarding::getUrl());
return;
}
$firstEventId = $tenant->events()->orderBy('date')->value('id');
$this->selectedEventId = $firstEventId;
$this->layouts = $this->buildLayouts();
if ($this->selectedEventId) {
$this->loadEventContext();
}
}
public static function shouldRegisterNavigation(): bool
{
return TenantOnboardingState::completed();
}
public function updatedSelectedEventId(): void
{
$this->loadEventContext();
}
public function createInvite(EventJoinTokenService $service): void
{
$this->validate([
'selectedEventId' => ['required', 'exists:events,id'],
'tokenLabel' => ['nullable', 'string', 'max:120'],
]);
$tenant = TenantOnboardingState::tenant();
abort_if(! $tenant, 403);
$event = $tenant->events()->whereKey($this->selectedEventId)->first();
if (! $event) {
Notification::make()
->title('Event konnte nicht gefunden werden')
->danger()
->send();
return;
}
$label = $this->tokenLabel ?: 'Einladung '.now()->format('d.m.');
$layoutPreference = Arr::get($tenant->settings ?? [], 'branding.preferred_invite_layout');
$service->createToken($event, [
'label' => $label,
'metadata' => [
'preferred_layout' => $layoutPreference,
],
'created_by' => auth()->id(),
]);
$this->tokenLabel = '';
$this->loadEventContext();
Notification::make()
->title('Neuer Einladungslink erstellt')
->success()
->send();
}
protected function loadEventContext(): void
{
$tenant = TenantOnboardingState::tenant();
if (! $tenant || ! $this->selectedEventId) {
$this->tokens = [];
return;
}
$event = $tenant->events()->whereKey($this->selectedEventId)->first();
if (! $event) {
$this->tokens = [];
return;
}
$this->tokens = $event->joinTokens()
->orderByDesc('created_at')
->get()
->map(fn (EventJoinToken $token) => $this->mapToken($event, $token))
->toArray();
}
protected function mapToken(Event $event, EventJoinToken $token): array
{
$downloadUrls = JoinTokenLayoutRegistry::toResponse(function (string $layoutId, string $format) use ($event, $token) {
return route('api.v1.tenant.events.join-tokens.layouts.download', [
'event' => $event->slug,
'joinToken' => $token->getKey(),
'layout' => $layoutId,
'format' => $format,
]);
});
return [
'id' => $token->getKey(),
'label' => $token->label ?? 'Einladungslink',
'url' => URL::to('/e/'.$token->token),
'created_at' => optional($token->created_at)->format('d.m.Y H:i'),
'usage_count' => $token->usage_count,
'usage_limit' => $token->usage_limit,
'active' => $token->isActive(),
'downloads' => $downloadUrls,
];
}
protected function buildLayouts(): array
{
return collect(JoinTokenLayoutRegistry::all())
->map(fn (array $layout) => [
'id' => $layout['id'],
'name' => $layout['name'],
'subtitle' => $layout['subtitle'] ?? '',
'description' => $layout['description'] ?? '',
])
->toArray();
}
public function getEventsProperty(): Collection
{
$tenant = TenantOnboardingState::tenant();
if (! $tenant) {
return collect();
}
return $tenant->events()->orderBy('date')->get();
}
}

View File

@@ -1,311 +0,0 @@
<?php
namespace App\Filament\Tenant\Pages;
use App\Filament\Tenant\Resources\EventResource;
use App\Models\Event;
use App\Models\EventType;
use App\Models\TaskCollection;
use App\Services\EventJoinTokenService;
use App\Services\Tenant\TaskCollectionImportService;
use App\Support\JoinTokenLayoutRegistry;
use App\Support\TenantOnboardingState;
use BackedEnum;
use Filament\Notifications\Notification;
use Filament\Pages\Page;
use Illuminate\Database\Eloquent\Collection as EloquentCollection;
use Illuminate\Support\Arr;
use Illuminate\Support\Carbon;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Str;
use Throwable;
use UnitEnum;
class TenantOnboarding extends Page
{
protected static BackedEnum|string|null $navigationIcon = 'heroicon-o-sparkles';
protected string $view = 'filament.tenant.pages.onboarding';
protected static ?string $navigationLabel = 'Willkommen';
protected static ?string $slug = 'willkommen';
protected static ?string $title = 'Euer Start mit Fotospiel';
protected static UnitEnum|string|null $navigationGroup = null;
public string $step = 'intro';
public array $status = [];
public array $inviteDownloads = [];
public array $selectedPackages = [];
public string $eventName = '';
public ?string $eventDate = null;
public ?int $eventTypeId = null;
public ?string $palette = null;
public ?string $inviteLayout = null;
public bool $isProcessing = false;
protected static bool $shouldRegisterNavigation = true;
public function mount(): void
{
$tenant = TenantOnboardingState::tenant();
abort_if(! $tenant, 403);
$this->status = TenantOnboardingState::status($tenant);
if (TenantOnboardingState::completed($tenant)) {
$this->redirect(EventResource::getUrl());
return;
}
$this->eventDate = Carbon::now()->addWeeks(2)->format('Y-m-d');
$this->eventTypeId = $this->getDefaultEventTypeId();
}
public static function shouldRegisterNavigation(): bool
{
$tenant = TenantOnboardingState::tenant();
return ! TenantOnboardingState::completed($tenant);
}
public function start(): void
{
$this->step = 'packages';
}
public function savePackages(): void
{
$this->validate([
'selectedPackages' => ['required', 'array', 'min:1'],
'selectedPackages.*' => ['integer', 'exists:task_collections,id'],
], [
'selectedPackages.required' => 'Bitte wählt mindestens ein Aufgabenpaket aus.',
]);
$this->step = 'event';
}
public function saveEvent(): void
{
$this->validate([
'eventName' => ['required', 'string', 'max:255'],
'eventDate' => ['required', 'date'],
'eventTypeId' => ['required', 'exists:event_types,id'],
]);
$this->step = 'palette';
}
public function savePalette(): void
{
$this->validate([
'palette' => ['required', 'string'],
]);
$this->step = 'invite';
}
public function finish(
TaskCollectionImportService $importService,
EventJoinTokenService $joinTokenService
): void {
$this->validate([
'inviteLayout' => ['required', 'string'],
], [
'inviteLayout.required' => 'Bitte wählt ein Layout aus.',
]);
$tenant = TenantOnboardingState::tenant();
abort_if(! $tenant, 403);
$this->isProcessing = true;
try {
DB::transaction(function () use ($tenant, $importService, $joinTokenService) {
$event = $this->createEvent($tenant);
$this->importPackages($importService, $this->selectedPackages, $event);
$token = $joinTokenService->createToken($event, [
'label' => 'Fotospiel Einladung',
'metadata' => [
'preferred_layout' => $this->inviteLayout,
],
]);
$settings = $tenant->settings ?? [];
Arr::set($settings, 'branding.palette', $this->palette);
Arr::set($settings, 'branding.primary_event_id', $event->id);
Arr::set($settings, 'branding.preferred_invite_layout', $this->inviteLayout);
$tenant->forceFill(['settings' => $settings])->save();
TenantOnboardingState::markCompleted($tenant, [
'primary_event_id' => $event->id,
'selected_packages' => $this->selectedPackages,
'qr_layout' => $this->inviteLayout,
]);
$this->inviteDownloads = $this->buildInviteDownloads($event, $token);
$this->status = TenantOnboardingState::status($tenant);
Notification::make()
->title('Euer Setup ist bereit!')
->body('Wir haben euer Event erstellt, Aufgaben importiert und euren Einladungslink vorbereitet.')
->success()
->send();
$this->redirect(EventResource::getUrl('view', ['record' => $event]));
});
} catch (Throwable $exception) {
report($exception);
Notification::make()
->title('Setup konnte nicht abgeschlossen werden')
->body('Bitte prüft eure Eingaben oder versucht es später erneut.')
->danger()
->send();
} finally {
$this->isProcessing = false;
}
}
protected function createEvent($tenant): Event
{
$slugBase = Str::slug($this->eventName) ?: 'event';
do {
$slug = Str::of($slugBase)->append('-', Str::random(6))->lower();
} while (Event::where('slug', $slug)->exists());
return Event::create([
'tenant_id' => $tenant->id,
'name' => [
app()->getLocale() => $this->eventName,
'de' => $this->eventName,
],
'description' => null,
'date' => $this->eventDate,
'slug' => (string) $slug,
'event_type_id' => $this->eventTypeId,
'is_active' => true,
'default_locale' => app()->getLocale(),
'status' => 'draft',
'settings' => [
'appearance' => [
'palette' => $this->palette,
],
],
]);
}
protected function importPackages(
TaskCollectionImportService $importService,
array $packageIds,
Event $event
): void {
if (empty($packageIds)) {
return;
}
/** @var EloquentCollection<TaskCollection> $collections */
$collections = TaskCollection::query()
->whereIn('id', $packageIds)
->get();
$collections->each(function (TaskCollection $collection) use ($importService, $event) {
$importService->import($collection, $event);
});
}
protected function buildInviteDownloads(Event $event, $token): array
{
return JoinTokenLayoutRegistry::toResponse(function (string $layoutId, string $format) use ($event, $token) {
return route('api.v1.tenant.events.join-tokens.layouts.download', [
'event' => $event->slug,
'joinToken' => $token->getKey(),
'layout' => $layoutId,
'format' => $format,
]);
});
}
public function getPackageListProperty(): array
{
return TaskCollection::query()
->whereNull('tenant_id')
->orderBy('position')
->get()
->map(fn (TaskCollection $collection) => [
'id' => $collection->getKey(),
'name' => $collection->name,
'description' => $collection->description,
])
->toArray();
}
public function getEventTypeOptionsProperty(): array
{
return EventType::query()
->orderBy('name->'.app()->getLocale())
->get()
->mapWithKeys(function (EventType $type) {
$name = $type->name[app()->getLocale()] ?? $type->name['de'] ?? Arr::first($type->name);
return [$type->getKey() => $name];
})
->toArray();
}
public function getPaletteOptionsProperty(): array
{
return [
'romance' => [
'label' => 'Rosé & Gold',
'description' => 'Warme Rosé-Töne mit goldenen Akzenten romantisch und elegant.',
],
'sunset' => [
'label' => 'Sonnenuntergang',
'description' => 'Leuchtende Orange- und Pink-Verläufe für lebhafte Partys.',
],
'evergreen' => [
'label' => 'Evergreen',
'description' => 'Sanfte Grüntöne und Naturakzente für Boho- & Outdoor-Events.',
],
'midnight' => [
'label' => 'Midnight',
'description' => 'Tiefes Navy und Flieder perfekt für elegante Abendveranstaltungen.',
],
];
}
public function getLayoutOptionsProperty(): array
{
return collect(JoinTokenLayoutRegistry::all())
->map(fn ($layout) => [
'id' => $layout['id'],
'name' => $layout['name'],
'subtitle' => $layout['subtitle'] ?? '',
'description' => $layout['description'] ?? '',
])
->toArray();
}
protected function getDefaultEventTypeId(): ?int
{
return EventType::query()->orderBy('name->'.app()->getLocale())->value('id');
}
}

View File

@@ -1,264 +0,0 @@
<?php
namespace App\Filament\Tenant\Resources;
use App\Filament\Tenant\Resources\EventResource\Pages;
use App\Filament\Tenant\Resources\EventResource\RelationManagers\EventPackagesRelationManager;
use App\Models\Event;
use App\Models\EventJoinTokenEvent;
use App\Models\EventType;
use App\Support\JoinTokenLayoutRegistry;
use App\Support\TenantOnboardingState;
use BackedEnum;
use Carbon\Carbon;
use Filament\Actions;
use Filament\Forms\Components\DatePicker;
use Filament\Forms\Components\Hidden;
use Filament\Forms\Components\KeyValue;
use Filament\Forms\Components\Select;
use Filament\Forms\Components\TextInput;
use Filament\Forms\Components\Toggle;
use Filament\Resources\Resource;
use Filament\Schemas\Schema;
use Filament\Tables;
use Filament\Tables\Table;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Support\Facades\Auth;
use UnitEnum;
class EventResource extends Resource
{
protected static ?string $model = Event::class;
protected static BackedEnum|string|null $navigationIcon = 'heroicon-o-calendar';
protected static UnitEnum|string|null $navigationGroup = null;
public static function getNavigationGroup(): UnitEnum|string|null
{
return __('admin.nav.platform');
}
protected static ?int $navigationSort = 20;
public static function shouldRegisterNavigation(): bool
{
return TenantOnboardingState::completed();
}
public static function form(Schema $form): Schema
{
$tenantId = Auth::user()?->tenant_id;
return $form->schema([
Hidden::make('tenant_id')
->default($tenantId)
->dehydrated(),
TextInput::make('name')
->label(__('admin.events.fields.name'))
->required()
->maxLength(255),
TextInput::make('slug')
->label(__('admin.events.fields.slug'))
->required()
->unique(ignoreRecord: true)
->maxLength(255),
DatePicker::make('date')
->label(__('admin.events.fields.date'))
->required(),
Select::make('event_type_id')
->label(__('admin.events.fields.type'))
->options(EventType::all()->pluck('name', 'id'))
->searchable(),
Select::make('package_id')
->label(__('admin.events.fields.package'))
->options(\App\Models\Package::where('type', 'endcustomer')->pluck('name', 'id'))
->searchable()
->preload()
->required(),
TextInput::make('default_locale')
->label(__('admin.events.fields.default_locale'))
->default('de')
->maxLength(5),
Toggle::make('is_active')
->label(__('admin.events.fields.is_active'))
->default(true),
KeyValue::make('settings')
->label(__('admin.events.fields.settings'))
->keyLabel(__('admin.common.key'))
->valueLabel(__('admin.common.value')),
])->columns(2);
}
public static function table(Table $table): Table
{
return $table
->columns([
Tables\Columns\TextColumn::make('id')->sortable(),
Tables\Columns\TextColumn::make('eventPackage.package.name')
->label(__('admin.events.table.package'))
->badge()
->color('success'),
Tables\Columns\TextColumn::make('name')->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.used_photos')
->label(__('admin.events.table.used_photos'))
->badge(),
Tables\Columns\TextColumn::make('eventPackage.remaining_photos')
->label(__('admin.events.table.remaining_photos'))
->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()->orderByDesc('created_at')->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(),
])
->modifyQueryUsing(function (Builder $query) {
if ($tenantId = Auth::user()?->tenant_id) {
$query->where('tenant_id', $tenantId);
}
})
->filters([])
->actions([
Actions\EditAction::make(),
Actions\Action::make('toggle')
->label(__('admin.events.actions.toggle_active'))
->icon('heroicon-o-power')
->action(fn ($record) => $record->update(['is_active' => ! $record->is_active])),
Actions\Action::make('join_tokens')
->label(__('admin.events.actions.join_link_qr'))
->icon('heroicon-o-qr-code')
->modalHeading(__('admin.events.modal.join_link_heading'))
->modalSubmitActionLabel(__('admin.common.close'))
->modalWidth('xl')
->modalContent(function ($record) {
$tokens = $record->joinTokens()
->orderByDesc('created_at')
->get();
if ($tokens->isEmpty()) {
return view('filament.events.join-link', [
'event' => $record,
'tokens' => collect(),
]);
}
$tokenIds = $tokens->pluck('id');
$now = now();
$totals = EventJoinTokenEvent::query()
->selectRaw('event_join_token_id, event_type, COUNT(*) as total')
->whereIn('event_join_token_id', $tokenIds)
->groupBy('event_join_token_id', 'event_type')
->get()
->groupBy('event_join_token_id');
$recent24h = EventJoinTokenEvent::query()
->selectRaw('event_join_token_id, COUNT(*) as total')
->whereIn('event_join_token_id', $tokenIds)
->where('occurred_at', '>=', $now->copy()->subHours(24))
->groupBy('event_join_token_id')
->pluck('total', 'event_join_token_id');
$lastSeen = EventJoinTokenEvent::query()
->whereIn('event_join_token_id', $tokenIds)
->selectRaw('event_join_token_id, MAX(occurred_at) as last_at')
->groupBy('event_join_token_id')
->pluck('last_at', 'event_join_token_id');
$tokens = $tokens->map(function ($token) use ($record, $totals, $recent24h, $lastSeen) {
$layouts = JoinTokenLayoutRegistry::toResponse(function (string $layoutId, string $format) use ($record, $token) {
return route('api.v1.tenant.events.join-tokens.layouts.download', [
'event' => $record->slug,
'joinToken' => $token->getKey(),
'layout' => $layoutId,
'format' => $format,
]);
});
$analyticsGroup = $totals->get($token->id, collect());
$analytics = $analyticsGroup->mapWithKeys(function ($row) {
return [$row->event_type => (int) $row->total];
});
$successCount = (int) ($analytics['access_granted'] ?? 0) + (int) ($analytics['gallery_access_granted'] ?? 0);
$failureCount = (int) ($analytics['invalid_token'] ?? 0)
+ (int) ($analytics['token_expired'] ?? 0)
+ (int) ($analytics['token_revoked'] ?? 0)
+ (int) ($analytics['token_rate_limited'] ?? 0)
+ (int) ($analytics['event_not_public'] ?? 0)
+ (int) ($analytics['gallery_expired'] ?? 0);
$lastSeenAt = $lastSeen->get($token->id);
return [
'id' => $token->id,
'label' => $token->label,
'token' => $token->token,
'url' => url('/e/'.$token->token),
'usage_limit' => $token->usage_limit,
'usage_count' => $token->usage_count,
'expires_at' => optional($token->expires_at)->toIso8601String(),
'revoked_at' => optional($token->revoked_at)->toIso8601String(),
'is_active' => $token->isActive(),
'created_at' => optional($token->created_at)->toIso8601String(),
'layouts' => $layouts,
'layouts_url' => route('api.v1.tenant.events.join-tokens.layouts.index', [
'event' => $record->slug,
'joinToken' => $token->getKey(),
]),
'analytics' => [
'success_total' => $successCount,
'failure_total' => $failureCount,
'rate_limited_total' => (int) ($analytics['token_rate_limited'] ?? 0),
'recent_24h' => (int) $recent24h->get($token->id, 0),
'last_seen_at' => $lastSeenAt ? Carbon::parse($lastSeenAt)->toIso8601String() : null,
],
];
});
return view('filament.events.join-link', [
'event' => $record,
'tokens' => $tokens,
]);
}),
])
->bulkActions([
Actions\DeleteBulkAction::make(),
]);
}
public static function getPages(): array
{
return [
'index' => Pages\ListEvents::route('/'),
'create' => Pages\CreateEvent::route('/create'),
'view' => Pages\ViewEvent::route('/{record}'),
'edit' => Pages\EditEvent::route('/{record}/edit'),
];
}
public static function getRelations(): array
{
return [
EventPackagesRelationManager::class,
];
}
}

View File

@@ -1,11 +0,0 @@
<?php
namespace App\Filament\Tenant\Resources\EventResource\Pages;
use App\Filament\Tenant\Resources\EventResource;
use Filament\Resources\Pages\CreateRecord;
class CreateEvent extends CreateRecord
{
protected static string $resource = EventResource::class;
}

View File

@@ -1,11 +0,0 @@
<?php
namespace App\Filament\Tenant\Resources\EventResource\Pages;
use App\Filament\Tenant\Resources\EventResource;
use Filament\Resources\Pages\EditRecord;
class EditEvent extends EditRecord
{
protected static string $resource = EventResource::class;
}

View File

@@ -1,11 +0,0 @@
<?php
namespace App\Filament\Tenant\Resources\EventResource\Pages;
use App\Filament\Tenant\Resources\EventResource;
use Filament\Resources\Pages\ListRecords;
class ListEvents extends ListRecords
{
protected static string $resource = EventResource::class;
}

View File

@@ -1,11 +0,0 @@
<?php
namespace App\Filament\Tenant\Resources\EventResource\Pages;
use App\Filament\Tenant\Resources\EventResource;
use Filament\Resources\Pages\ViewRecord;
class ViewEvent extends ViewRecord
{
protected static string $resource = EventResource::class;
}

View File

@@ -1,129 +0,0 @@
<?php
namespace App\Filament\Tenant\Resources\EventResource\RelationManagers;
use Filament\Forms;
use Filament\Forms\Form;
use Filament\Resources\RelationManagers\RelationManager;
use Filament\Tables;
use Filament\Tables\Table;
use Filament\Actions\CreateAction;
use Filament\Actions\EditAction;
use Filament\Actions\DeleteAction;
use Filament\Actions\BulkActionGroup;
use Filament\Actions\DeleteBulkAction;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\Relation;
use App\Models\EventPackage;
use Filament\Actions\Action;
use Filament\Forms\Components\Select;
use Filament\Forms\Components\TextInput;
use Filament\Forms\Components\DateTimePicker;
use Filament\Tables\Columns\TextColumn;
use Filament\Schemas\Schema;
class EventPackagesRelationManager extends RelationManager
{
protected static string $relationship = 'eventPackages';
public function form(Schema $schema): Schema
{
return $schema->schema([
Select::make('package_id')
->label('Package')
->relationship('package', 'name')
->searchable()
->preload()
->required(),
TextInput::make('purchased_price')
->label('Kaufpreis')
->prefix('€')
->numeric()
->step(0.01)
->required(),
TextInput::make('used_photos')
->label('Verwendete Fotos')
->numeric()
->default(0)
->readOnly(),
TextInput::make('used_guests')
->label('Verwendete Gäste')
->numeric()
->default(0)
->readOnly(),
]);
}
public function table(Table $table): Table
{
return $table
->recordTitleAttribute('package.name')
->columns([
TextColumn::make('package.name')
->label('Package')
->badge()
->color('success'),
TextColumn::make('used_photos')
->label('Verwendete Fotos')
->badge(),
TextColumn::make('remaining_photos')
->label('Verbleibende Fotos')
->badge()
->color(fn ($state) => $state < 1 ? 'danger' : 'success')
->getStateUsing(fn (EventPackage $record) => $record->remaining_photos),
TextColumn::make('used_guests')
->label('Verwendete Gäste')
->badge(),
TextColumn::make('remaining_guests')
->label('Verbleibende Gäste')
->badge()
->color(fn ($state) => $state < 1 ? 'danger' : 'success')
->getStateUsing(fn (EventPackage $record) => $record->remaining_guests),
TextColumn::make('expires_at')
->label('Ablauf')
->dateTime()
->badge()
->color(fn ($state) => $state && $state->isPast() ? 'danger' : 'success'),
TextColumn::make('price')
->label('Preis')
->money('EUR')
->sortable(),
])
->filters([
//
])
->headerActions([
CreateAction::make(),
])
->actions([
EditAction::make(),
DeleteAction::make(),
])
->bulkActions([
BulkActionGroup::make([
DeleteBulkAction::make(),
]),
]);
}
public function getRelationExistenceQuery(
Builder $query,
string $relationshipName,
?string $ownerKeyName,
mixed $ownerKeyValue,
): Builder {
return $query;
}
public static function getTitle(Model $ownerRecord, string $pageClass): string
{
return __('admin.events.relation_managers.event_packages.title');
}
public function getTableQuery(): Builder | Relation
{
return parent::getTableQuery()
->with('package');
}
}

View File

@@ -1,126 +0,0 @@
<?php
namespace App\Filament\Tenant\Resources;
use App\Filament\Tenant\Resources\PhotoResource\Pages;
use App\Models\Photo;
use App\Models\Event;
use App\Support\TenantOnboardingState;
use Filament\Resources\Resource;
use Filament\Tables;
use Filament\Tables\Table;
use Filament\Actions;
use Filament\Forms;
use Filament\Forms\Form;
use Filament\Schemas\Schema;
use Filament\Forms\Components\TextInput;
use Filament\Forms\Components\Select;
use Filament\Forms\Components\Toggle;
use Filament\Forms\Components\FileUpload;
use Filament\Forms\Components\KeyValue;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Support\Facades\Auth;
use UnitEnum;
use BackedEnum;
class PhotoResource extends Resource
{
protected static ?string $model = Photo::class;
protected static BackedEnum|string|null $navigationIcon = 'heroicon-o-photo';
protected static UnitEnum|string|null $navigationGroup = null;
public static function getNavigationGroup(): UnitEnum|string|null
{
return __('admin.nav.content');
}
protected static ?int $navigationSort = 30;
public static function shouldRegisterNavigation(): bool
{
return TenantOnboardingState::completed();
}
public static function form(Schema $form): Schema
{
$tenantId = Auth::user()?->tenant_id;
return $form->schema([
Select::make('event_id')
->label(__('admin.photos.fields.event'))
->options(
Event::query()
->when($tenantId, fn ($query) => $query->where('tenant_id', $tenantId))
->pluck('name', 'id')
)
->searchable()
->required(),
FileUpload::make('file_path')
->label(__('admin.photos.fields.photo'))
->image() // enable FilePond image preview
->disk('public')
->directory('photos')
->visibility('public')
->required(),
Toggle::make('is_featured')
->label(__('admin.photos.fields.is_featured'))
->default(false),
KeyValue::make('metadata')
->label(__('admin.photos.fields.metadata'))
->keyLabel(__('admin.common.key'))
->valueLabel(__('admin.common.value')),
])->columns(2);
}
public static function table(Table $table): Table
{
return $table
->columns([
Tables\Columns\ImageColumn::make('file_path')->label(__('admin.photos.table.photo'))->disk('public')->visibility('public'),
Tables\Columns\TextColumn::make('id')->sortable(),
Tables\Columns\TextColumn::make('event.name')->label(__('admin.photos.table.event'))->searchable(),
Tables\Columns\TextColumn::make('likes_count')->label(__('admin.photos.table.likes')),
Tables\Columns\IconColumn::make('is_featured')->boolean(),
Tables\Columns\TextColumn::make('created_at')->since(),
])
->modifyQueryUsing(function (Builder $query) {
if ($tenantId = Auth::user()?->tenant_id) {
$query->whereHas('event', fn (Builder $eventQuery) => $eventQuery->where('tenant_id', $tenantId));
}
})
->filters([])
->actions([
Actions\EditAction::make(),
Actions\Action::make('feature')
->label(__('admin.photos.actions.feature'))
->visible(fn($record) => !$record->is_featured)
->action(fn($record) => $record->update(['is_featured' => true]))
->icon('heroicon-o-star'),
Actions\Action::make('unfeature')
->label(__('admin.photos.actions.unfeature'))
->visible(fn($record) => $record->is_featured)
->action(fn($record) => $record->update(['is_featured' => false]))
->icon('heroicon-o-star'),
Actions\DeleteAction::make(),
])
->bulkActions([
Actions\BulkAction::make('feature')
->label(__('admin.photos.actions.feature_selected'))
->icon('heroicon-o-star')
->action(fn($records) => $records->each->update(['is_featured' => true])),
Actions\BulkAction::make('unfeature')
->label(__('admin.photos.actions.unfeature_selected'))
->icon('heroicon-o-star')
->action(fn($records) => $records->each->update(['is_featured' => false])),
Actions\DeleteBulkAction::make(),
]);
}
public static function getPages(): array
{
return [
'index' => Pages\ListPhotos::route('/'),
'view' => Pages\ViewPhoto::route('/{record}'),
'edit' => Pages\EditPhoto::route('/{record}/edit'),
];
}
}

View File

@@ -1,11 +0,0 @@
<?php
namespace App\Filament\Tenant\Resources\PhotoResource\Pages;
use App\Filament\Tenant\Resources\PhotoResource;
use Filament\Resources\Pages\EditRecord;
class EditPhoto extends EditRecord
{
protected static string $resource = PhotoResource::class;
}

View File

@@ -1,11 +0,0 @@
<?php
namespace App\Filament\Tenant\Resources\PhotoResource\Pages;
use App\Filament\Tenant\Resources\PhotoResource;
use Filament\Resources\Pages\ListRecords;
class ListPhotos extends ListRecords
{
protected static string $resource = PhotoResource::class;
}

View File

@@ -1,11 +0,0 @@
<?php
namespace App\Filament\Tenant\Resources\PhotoResource\Pages;
use App\Filament\Tenant\Resources\PhotoResource;
use Filament\Resources\Pages\ViewRecord;
class ViewPhoto extends ViewRecord
{
protected static string $resource = PhotoResource::class;
}

View File

@@ -1,242 +0,0 @@
<?php
namespace App\Filament\Tenant\Resources;
use App\Filament\Tenant\Resources\TaskCollectionResource\Pages;
use App\Models\Event;
use App\Models\EventType;
use App\Models\TaskCollection;
use App\Services\Tenant\TaskCollectionImportService;
use Filament\Facades\Filament;
use Filament\Schemas\Components\Section;
use Filament\Forms\Components\Select;
use Filament\Forms\Components\TextInput;
use Filament\Forms\Components\Textarea;
use Filament\Schemas\Schema;
use Filament\Resources\Resource;
use Filament\Tables;
use Filament\Actions;
use Filament\Tables\Columns\BadgeColumn;
use Filament\Tables\Columns\IconColumn;
use Filament\Tables\Columns\TextColumn;
use Filament\Tables\Filters\SelectFilter;
use Filament\Tables\Table;
use Filament\Notifications\Notification;
use Illuminate\Support\Str;
use BackedEnum;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Model;
use App\Support\TenantOnboardingState;
class TaskCollectionResource extends Resource
{
protected static ?string $model = TaskCollection::class;
protected static BackedEnum|string|null $navigationIcon = 'heroicon-o-folder';
protected static ?int $navigationSort = 50;
public static function shouldRegisterNavigation(): bool
{
return TenantOnboardingState::completed();
}
public static function getNavigationGroup(): string
{
return __('admin.nav.library');
}
public static function form(Schema $schema): Schema
{
$tenantId = auth()->user()?->tenant_id;
return $schema->components([
Section::make(__('Task Collection Details'))
->schema([
TextInput::make('name_translations.de')
->label(__('Name (DE)'))
->required()
->maxLength(255)
->disabled(fn (?TaskCollection $record) => $record?->tenant_id !== $tenantId && $record !== null),
TextInput::make('name_translations.en')
->label(__('Name (EN)'))
->maxLength(255)
->disabled(fn (?TaskCollection $record) => $record?->tenant_id !== $tenantId && $record !== null),
Select::make('event_type_id')
->label(__('Event Type'))
->options(fn () => EventType::orderBy('name->' . app()->getLocale())
->get()
->mapWithKeys(function (EventType $type) {
$name = $type->name[app()->getLocale()] ?? $type->name['de'] ?? reset($type->name);
return [$type->id => $name];
})->toArray())
->searchable()
->required()
->disabled(fn (?TaskCollection $record) => $record?->tenant_id !== $tenantId && $record !== null),
Textarea::make('description_translations.de')
->label(__('Description (DE)'))
->rows(3)
->disabled(fn (?TaskCollection $record) => $record?->tenant_id !== $tenantId && $record !== null),
Textarea::make('description_translations.en')
->label(__('Description (EN)'))
->rows(3)
->disabled(fn (?TaskCollection $record) => $record?->tenant_id !== $tenantId && $record !== null),
])->columns(2),
]);
}
public static function table(Table $table): Table
{
return $table
->columns([
TextColumn::make('name')
->label(__('Name'))
->searchable(['name_translations->de', 'name_translations->en'])
->sortable(),
BadgeColumn::make('eventType.name')
->label(__('Event Type'))
->color('info'),
IconColumn::make('tenant_id')
->label(__('Scope'))
->boolean()
->trueIcon('heroicon-o-user-group')
->falseIcon('heroicon-o-globe-alt')
->state(fn (TaskCollection $record) => $record->tenant_id !== null)
->tooltip(fn (TaskCollection $record) => $record->tenant_id ? __('Tenant-only') : __('Global template')),
TextColumn::make('tasks_count')
->label(__('Tasks'))
->counts('tasks')
->sortable(),
])
->filters([
SelectFilter::make('event_type_id')
->label(__('Event Type'))
->relationship('eventType', 'name->' . app()->getLocale()),
SelectFilter::make('scope')
->options([
'global' => __('Global template'),
'tenant' => __('Tenant-owned'),
])
->query(function ($query, $value) {
$tenantId = auth()->user()?->tenant_id;
if ($value === 'global') {
$query->whereNull('tenant_id');
}
if ($value === 'tenant') {
$query->where('tenant_id', $tenantId);
}
}),
])
->actions([
\Filament\Actions\Action::make('import')
->label(__('Import to Event'))
->icon('heroicon-o-cloud-arrow-down')
->form([
Select::make('event_slug')
->label(__('Select Event'))
->options(function () {
$tenantId = auth()->user()?->tenant_id;
return Event::where('tenant_id', $tenantId)
->orderBy('date', 'desc')
->get()
->mapWithKeys(function (Event $event) {
$name = $event->name[app()->getLocale()] ?? $event->name['de'] ?? reset($event->name);
return [
$event->slug => sprintf('%s (%s)', $name, $event->date?->format('d.m.Y')),
];
})->toArray();
})
->required()
->searchable(),
])
->action(function (TaskCollection $record, array $data) {
$event = Event::where('slug', $data['event_slug'])
->where('tenant_id', auth()->user()?->tenant_id)
->firstOrFail();
/** @var TaskCollectionImportService $service */
$service = app(TaskCollectionImportService::class);
$service->import($record, $event);
Notification::make()
->title(__('Task collection imported'))
->body(__('The collection :name has been imported.', ['name' => $record->name]))
->success()
->send();
}),
Actions\EditAction::make()
->label(__('Edit'))
->visible(fn (TaskCollection $record) => $record->tenant_id === auth()->user()?->tenant_id),
])
->headerActions([
Actions\CreateAction::make()
->label(__('Create Task Collection'))
->mutateFormDataUsing(function (array $data) {
$tenantId = auth()->user()?->tenant_id;
$data['tenant_id'] = $tenantId;
$data['slug'] = static::generateSlug($data['name_translations']['en'] ?? $data['name_translations']['de'] ?? 'collection', $tenantId);
return $data;
}),
])
->bulkActions([
Actions\DeleteBulkAction::make()
->visible(fn () => false),
]);
}
public static function getPages(): array
{
return [
'index' => Pages\ListTaskCollections::route('/'),
'create' => Pages\CreateTaskCollection::route('/create'),
'edit' => Pages\EditTaskCollection::route('/{record}/edit'),
];
}
public static function getEloquentQuery(): Builder
{
$tenantId = auth()->user()?->tenant_id;
return parent::getEloquentQuery()
->forTenant($tenantId)
->with('eventType')
->withCount('tasks');
}
public static function getGloballySearchableAttributes(): array
{
return ['name_translations->de', 'name_translations->en'];
}
public static function generateSlug(string $base, int $tenantId): string
{
$slugBase = Str::slug($base) ?: 'collection';
do {
$candidate = $slugBase . '-' . $tenantId . '-' . Str::random(4);
} while (TaskCollection::where('slug', $candidate)->exists());
return $candidate;
}
public static function scopeEloquentQueryToTenant(Builder $query, ?Model $tenant): Builder
{
$tenant ??= Filament::getTenant();
if (! $tenant) {
return $query;
}
return $query->where(function (Builder $innerQuery) use ($tenant) {
$innerQuery->whereNull('tenant_id')
->orWhere('tenant_id', $tenant->getKey());
});
}
}

View File

@@ -1,26 +0,0 @@
<?php
namespace App\Filament\Tenant\Resources\TaskCollectionResource\Pages;
use App\Filament\Tenant\Resources\TaskCollectionResource;
use Filament\Resources\Pages\CreateRecord;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Str;
class CreateTaskCollection extends CreateRecord
{
protected static string $resource = TaskCollectionResource::class;
protected function mutateFormDataBeforeCreate(array $data): array
{
$tenantId = Auth::user()?->tenant_id;
$data['tenant_id'] = $tenantId;
$data['slug'] = TaskCollectionResource::generateSlug(
$data['name_translations']['en'] ?? $data['name_translations']['de'] ?? 'collection',
$tenantId
);
return $data;
}
}

View File

@@ -1,23 +0,0 @@
<?php
namespace App\Filament\Tenant\Resources\TaskCollectionResource\Pages;
use App\Filament\Tenant\Resources\TaskCollectionResource;
use Filament\Resources\Pages\EditRecord;
use Illuminate\Support\Facades\Auth;
class EditTaskCollection extends EditRecord
{
protected static string $resource = TaskCollectionResource::class;
protected function authorizeAccess(): void
{
parent::authorizeAccess();
$record = $this->getRecord();
if ($record->tenant_id !== Auth::user()?->tenant_id) {
abort(403);
}
}
}

View File

@@ -1,11 +0,0 @@
<?php
namespace App\Filament\Tenant\Resources\TaskCollectionResource\Pages;
use App\Filament\Tenant\Resources\TaskCollectionResource;
use Filament\Resources\Pages\ListRecords;
class ListTaskCollections extends ListRecords
{
protected static string $resource = TaskCollectionResource::class;
}

View File

@@ -1,206 +0,0 @@
<?php
namespace App\Filament\Tenant\Resources;
use App\Filament\Tenant\Resources\TaskResource\Pages;
use App\Models\Event;
use App\Models\Task;
use App\Support\TenantOnboardingState;
use BackedEnum;
use Filament\Facades\Filament;
use Filament\Forms\Components\MarkdownEditor;
use Filament\Forms\Components\Select;
use Filament\Schemas\Components\Tabs as SchemaTabs;
use Filament\Schemas\Components\Tabs\Tab as SchemaTab;
use Filament\Forms\Components\TextInput;
use Filament\Forms\Components\Toggle;
use Filament\Forms\Form;
use Filament\Resources\Resource;
use Filament\Schemas\Schema;
use Filament\Tables;
use Filament\Tables\Table;
use Filament\Actions;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Support\Facades\Auth;
use Illuminate\Database\Eloquent\Model;
use UnitEnum;
class TaskResource extends Resource
{
protected static ?string $model = Task::class;
protected static BackedEnum|string|null $navigationIcon = 'heroicon-o-clipboard-document-check';
protected static ?int $navigationSort = 40;
public static function shouldRegisterNavigation(): bool
{
return TenantOnboardingState::completed();
}
public static function getNavigationGroup(): UnitEnum|string|null
{
return __('admin.nav.library');
}
public static function form(Schema $form): Schema
{
$tenantId = Auth::user()?->tenant_id;
return $form->schema([
Select::make('emotion_id')
->relationship('emotion', 'name')
->required()
->searchable()
->preload(),
Select::make('event_type_id')
->relationship('eventType', 'name')
->searchable()
->preload()
->label(__('admin.tasks.fields.event_type_optional')),
SchemaTabs::make('content_tabs')
->label(__('admin.tasks.fields.content_localization'))
->tabs([
SchemaTab::make(__('admin.common.german'))
->icon('heroicon-o-language')
->schema([
TextInput::make('title.de')
->label(__('admin.tasks.fields.title_de'))
->required(),
MarkdownEditor::make('description.de')
->label(__('admin.tasks.fields.description_de'))
->columnSpanFull(),
MarkdownEditor::make('example_text.de')
->label(__('admin.tasks.fields.example_de'))
->columnSpanFull(),
]),
SchemaTab::make(__('admin.common.english'))
->icon('heroicon-o-language')
->schema([
TextInput::make('title.en')
->label(__('admin.tasks.fields.title_en'))
->required(),
MarkdownEditor::make('description.en')
->label(__('admin.tasks.fields.description_en'))
->columnSpanFull(),
MarkdownEditor::make('example_text.en')
->label(__('admin.tasks.fields.example_en'))
->columnSpanFull(),
]),
])
->columnSpanFull(),
Select::make('difficulty')
->label(__('admin.tasks.fields.difficulty.label'))
->options([
'easy' => __('admin.tasks.fields.difficulty.easy'),
'medium' => __('admin.tasks.fields.difficulty.medium'),
'hard' => __('admin.tasks.fields.difficulty.hard'),
])
->default('easy'),
TextInput::make('sort_order')
->numeric()
->default(0),
Toggle::make('is_active')
->default(true),
Select::make('assigned_events')
->label(__('admin.tasks.fields.events'))
->multiple()
->relationship(
'assignedEvents',
'name',
fn (Builder $query) => $tenantId
? $query->where('tenant_id', $tenantId)
: $query
)
->searchable()
->preload()
->getOptionLabelFromRecordUsing(fn (Event $record) => $record->name)
->helperText(__('admin.tasks.fields.events_helper')),
])->columns(2);
}
public static function table(Table $table): Table
{
$tenantId = Auth::user()?->tenant_id;
return $table
->columns([
Tables\Columns\TextColumn::make('id')
->label('#')
->sortable(),
Tables\Columns\TextColumn::make('title')
->label(__('admin.tasks.table.title'))
->getStateUsing(function ($record) {
$value = $record->title;
if (is_array($value)) {
$loc = app()->getLocale();
return $value[$loc] ?? ($value['de'] ?? ($value['en'] ?? ''));
}
return (string) $value;
})
->limit(60)
->searchable(['title->de', 'title->en']),
Tables\Columns\TextColumn::make('emotion.name')
->label(__('admin.tasks.fields.emotion'))
->toggleable(),
Tables\Columns\TextColumn::make('eventType.name')
->label(__('admin.tasks.fields.event_type'))
->toggleable(),
Tables\Columns\TextColumn::make('assignedEvents.name')
->label(__('admin.tasks.table.events'))
->badge()
->separator(', ')
->limitList(2),
Tables\Columns\TextColumn::make('difficulty')
->label(__('admin.tasks.fields.difficulty.label'))
->badge(),
Tables\Columns\IconColumn::make('is_active')
->label(__('admin.tasks.table.is_active'))
->boolean(),
Tables\Columns\TextColumn::make('sort_order')
->label(__('admin.tasks.table.sort_order'))
->sortable(),
])
->filters([])
->actions([
Actions\EditAction::make(),
Actions\DeleteAction::make(),
])
->bulkActions([
Actions\DeleteBulkAction::make(),
])
->modifyQueryUsing(function (Builder $query) use ($tenantId) {
if (! $tenantId) {
return $query;
}
$query->forTenant($tenantId);
return $query;
});
}
public static function getPages(): array
{
return [
'index' => Pages\ListTasks::route('/'),
'create' => Pages\CreateTask::route('/create'),
'edit' => Pages\EditTask::route('/{record}/edit'),
];
}
public static function scopeEloquentQueryToTenant(Builder $query, ?Model $tenant): Builder
{
$tenant ??= Filament::getTenant();
if (! $tenant) {
return $query;
}
return $query->where(function (Builder $innerQuery) use ($tenant) {
$innerQuery->whereNull('tenant_id')
->orWhere('tenant_id', $tenant->getKey());
});
}
}

View File

@@ -1,11 +0,0 @@
<?php
namespace App\Filament\Tenant\Resources\TaskResource\Pages;
use App\Filament\Tenant\Resources\TaskResource;
use Filament\Resources\Pages\CreateRecord;
class CreateTask extends CreateRecord
{
protected static string $resource = TaskResource::class;
}

View File

@@ -1,19 +0,0 @@
<?php
namespace App\Filament\Tenant\Resources\TaskResource\Pages;
use App\Filament\Tenant\Resources\TaskResource;
use Filament\Actions;
use Filament\Resources\Pages\EditRecord;
class EditTask extends EditRecord
{
protected static string $resource = TaskResource::class;
protected function getHeaderActions(): array
{
return [
Actions\DeleteAction::make(),
];
}
}

View File

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

View File

@@ -1,46 +0,0 @@
<?php
namespace App\Filament\Tenant\Widgets;
use Filament\Tables;
use Filament\Widgets\TableWidget as BaseWidget;
use Illuminate\Support\Carbon;
use App\Models\Event;
class EventsActiveToday extends BaseWidget
{
protected static ?string $heading = null;
public function getHeading()
{
return __('admin.widgets.events_active_today.heading');
}
protected ?string $pollingInterval = '60s';
public function table(Tables\Table $table): Tables\Table
{
$today = Carbon::today()->toDateString();
return $table
->query(
Event::query()
->where('is_active', true)
->whereDate('date', '<=', $today)
->withCount([
'photos as uploads_today' => function ($q) use ($today) {
$q->whereDate('created_at', $today);
},
])
->orderByDesc('date')
->limit(10)
)
->columns([
Tables\Columns\TextColumn::make('id')->label(__('admin.common.hash'))->width('60px'),
Tables\Columns\TextColumn::make('slug')->label(__('admin.common.slug'))->searchable(),
Tables\Columns\TextColumn::make('date')->date(),
Tables\Columns\TextColumn::make('uploads_today')->label(__('admin.common.uploads_today'))->numeric(),
])
->paginated(false);
}
}

View File

@@ -1,48 +0,0 @@
<?php
namespace App\Filament\Tenant\Widgets;
use Filament\Tables;
use Filament\Widgets\TableWidget as BaseWidget;
use App\Models\Photo;
use Filament\Actions;
class RecentPhotosTable extends BaseWidget
{
protected static ?string $heading = null;
public function getHeading()
{
return __('admin.widgets.recent_uploads.heading');
}
protected int|string|array $columnSpan = 'full';
public function table(Tables\Table $table): Tables\Table
{
return $table
->query(
Photo::query()
->orderByDesc('created_at')
->limit(10)
)
->columns([
Tables\Columns\ImageColumn::make('thumbnail_path')->label(__('admin.common.thumb'))->circular(),
Tables\Columns\TextColumn::make('id')->label(__('admin.common.hash')),
Tables\Columns\TextColumn::make('event_id')->label(__('admin.common.event')),
Tables\Columns\TextColumn::make('likes_count')->label(__('admin.common.likes')),
Tables\Columns\TextColumn::make('created_at')->since(),
])
->actions([
Actions\Action::make('feature')
->label(__('admin.photos.actions.feature'))
->visible(fn(Photo $record) => ! (bool)($record->is_featured ?? 0))
->action(fn(Photo $record) => $record->update(['is_featured' => 1])),
Actions\Action::make('unfeature')
->label(__('admin.photos.actions.unfeature'))
->visible(fn(Photo $record) => (bool)($record->is_featured ?? 0))
->action(fn(Photo $record) => $record->update(['is_featured' => 0])),
])
->paginated(false);
}
}

View File

@@ -1,56 +0,0 @@
<?php
namespace App\Filament\Tenant\Widgets;
use Filament\Widgets\ChartWidget;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Carbon;
class UploadsPerDayChart extends ChartWidget
{
protected ?string $heading = null;
protected ?string $maxHeight = '220px';
protected ?string $pollingInterval = '60s';
protected function getData(): array
{
// Build last 14 days labels
$labels = [];
$start = Carbon::now()->startOfDay()->subDays(13);
for ($i = 0; $i < 14; $i++) {
$labels[] = $start->copy()->addDays($i)->format('Y-m-d');
}
// SQLite-friendly group by date
$rows = DB::table('photos')
->selectRaw("strftime('%Y-%m-%d', created_at) as d, count(*) as c")
->where('created_at', '>=', $start)
->groupBy('d')
->orderBy('d')
->get();
$map = collect($rows)->keyBy('d');
$data = array_map(fn ($d) => (int) ($map[$d]->c ?? 0), $labels);
return [
'labels' => $labels,
'datasets' => [
[
'label' => __('admin.common.uploads'),
'data' => $data,
'borderColor' => '#f59e0b',
'backgroundColor' => 'rgba(245, 158, 11, 0.2)',
'tension' => 0.3,
],
],
];
}
protected function getType(): string
{
return 'line';
}
public function getHeading(): string|\Illuminate\Contracts\Support\Htmlable|null
{
return __('admin.widgets.uploads_per_day.heading');
}
}

View File

@@ -0,0 +1,22 @@
<?php
namespace App\Filament\Widgets;
use App\Services\Integrations\IntegrationHealthService;
use Filament\Widgets\Widget;
class IntegrationsHealthWidget extends Widget
{
protected string $view = 'filament.widgets.integrations-health';
protected ?string $pollingInterval = '60s';
protected function getViewData(): array
{
$health = app(IntegrationHealthService::class);
return [
'providers' => $health->providers(),
];
}
}

View File

@@ -0,0 +1,147 @@
<?php
namespace App\Filament\Widgets;
use App\Models\Event;
use App\Models\EventJoinTokenEvent;
use Filament\Widgets\Concerns\InteractsWithPageFilters;
use Filament\Widgets\StatsOverviewWidget;
use Filament\Widgets\StatsOverviewWidget\Stat;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Support\Carbon;
class JoinTokenOverviewWidget extends StatsOverviewWidget
{
use InteractsWithPageFilters;
protected static ?int $sort = 1;
protected int|string|array $columnSpan = 'full';
private const SUCCESS_EVENTS = [
'access_granted',
'gallery_access_granted',
];
private const RATE_LIMIT_EVENTS = [
'token_rate_limited',
'access_rate_limited',
'download_rate_limited',
];
private const FAILURE_EVENTS = [
'invalid_token',
'token_expired',
'token_revoked',
'event_not_public',
'gallery_expired',
'token_rate_limited',
'access_rate_limited',
'download_rate_limited',
];
private const UPLOAD_EVENTS = [
'upload_completed',
];
protected function getStats(): array
{
$filters = $this->resolveFilters();
$totals = $this->totalsByEventType($filters);
$success = $this->sumTotals($totals, self::SUCCESS_EVENTS);
$failures = $this->sumTotals($totals, self::FAILURE_EVENTS);
$rateLimited = $this->sumTotals($totals, self::RATE_LIMIT_EVENTS);
$uploads = $this->sumTotals($totals, self::UPLOAD_EVENTS);
$ratio = $success > 0 ? round(($failures / $success) * 100, 1) : null;
$ratioLabel = $ratio !== null ? "{$ratio}%" : __('admin.join_token_analytics.stats.no_data');
return [
Stat::make(__('admin.join_token_analytics.stats.success'), number_format($success))
->color('success'),
Stat::make(__('admin.join_token_analytics.stats.failures'), number_format($failures))
->color($failures > 0 ? 'danger' : 'success'),
Stat::make(__('admin.join_token_analytics.stats.rate_limited'), number_format($rateLimited))
->color($rateLimited > 0 ? 'warning' : 'success'),
Stat::make(__('admin.join_token_analytics.stats.uploads'), number_format($uploads))
->color('primary'),
Stat::make(__('admin.join_token_analytics.stats.failure_ratio'), $ratioLabel)
->color($ratio !== null && $ratio >= 50 ? 'danger' : 'warning'),
];
}
private function totalsByEventType(array $filters): array
{
return $this->baseQuery($filters)
->selectRaw('event_type, COUNT(*) as total')
->groupBy('event_type')
->get()
->mapWithKeys(fn (EventJoinTokenEvent $event) => [$event->event_type => (int) $event->total])
->all();
}
private function sumTotals(array $totals, array $types): int
{
$sum = 0;
foreach ($types as $type) {
$sum += (int) ($totals[$type] ?? 0);
}
return $sum;
}
private function baseQuery(array $filters): Builder
{
$query = EventJoinTokenEvent::query()
->whereBetween('occurred_at', [$filters['start'], $filters['end']]);
if ($filters['event_id']) {
$query->where('event_id', $filters['event_id']);
}
return $query;
}
private function resolveFilters(): array
{
$eventId = $this->pageFilters['event_id'] ?? null;
$eventId = is_numeric($eventId) ? (int) $eventId : null;
$range = is_string($this->pageFilters['range'] ?? null) ? $this->pageFilters['range'] : '24h';
[$start, $end] = $this->resolveWindow($range, $eventId);
return [
'event_id' => $eventId,
'range' => $range,
'start' => $start,
'end' => $end,
];
}
private function resolveWindow(string $range, ?int $eventId): array
{
$now = now();
$start = match ($range) {
'2h' => $now->copy()->subHours(2),
'6h' => $now->copy()->subHours(6),
'12h' => $now->copy()->subHours(12),
'7d' => $now->copy()->subDays(7),
default => $now->copy()->subHours(24),
};
$end = $now;
if ($range === 'event_day' && $eventId) {
$eventDate = Event::query()->whereKey($eventId)->value('date');
if ($eventDate) {
$eventDay = Carbon::parse($eventDate);
$start = $eventDay->copy()->startOfDay();
$end = $eventDay->copy()->endOfDay();
}
}
return [$start, $end];
}
}

View File

@@ -0,0 +1,191 @@
<?php
namespace App\Filament\Widgets;
use App\Filament\Resources\EventResource;
use App\Models\Event;
use App\Models\EventJoinToken;
use Filament\Tables;
use Filament\Widgets\Concerns\InteractsWithPageFilters;
use Filament\Widgets\TableWidget as BaseWidget;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Support\Carbon;
class JoinTokenTopTokensWidget extends BaseWidget
{
use InteractsWithPageFilters;
protected static ?int $sort = 3;
protected int|string|array $columnSpan = 'full';
protected static ?string $heading = null;
public function getHeading(): ?string
{
return __('admin.join_token_analytics.table.heading');
}
public function table(Tables\Table $table): Tables\Table
{
$filters = $this->resolveFilters();
return $table
->query(fn (): Builder => $this->buildQuery($filters))
->columns([
Tables\Columns\TextColumn::make('token_preview')
->label(__('admin.join_token_analytics.table.token'))
->copyable()
->copyMessage(__('admin.events.messages.join_link_copied')),
Tables\Columns\TextColumn::make('event_label')
->label(__('admin.join_token_analytics.table.event'))
->getStateUsing(fn (EventJoinToken $record) => $this->formatEventLabel($record->event))
->url(fn (EventJoinToken $record) => $record->event
? EventResource::getUrl('view', ['record' => $record->event])
: null)
->openUrlInNewTab(),
Tables\Columns\TextColumn::make('tenant_label')
->label(__('admin.join_token_analytics.table.tenant'))
->getStateUsing(fn (EventJoinToken $record) => $record->event?->tenant?->name ?? __('admin.common.unnamed')),
Tables\Columns\TextColumn::make('success_total')
->label(__('admin.join_token_analytics.table.success'))
->numeric(),
Tables\Columns\TextColumn::make('failure_total')
->label(__('admin.join_token_analytics.table.failures'))
->numeric(),
Tables\Columns\TextColumn::make('rate_limited_total')
->label(__('admin.join_token_analytics.table.rate_limited'))
->numeric(),
Tables\Columns\TextColumn::make('upload_total')
->label(__('admin.join_token_analytics.table.uploads'))
->numeric(),
Tables\Columns\TextColumn::make('last_seen_at')
->label(__('admin.join_token_analytics.table.last_seen'))
->since()
->placeholder('—'),
])
->paginated(false);
}
private function buildQuery(array $filters): Builder
{
$query = EventJoinToken::query()
->with(['event.tenant'])
->when($filters['event_id'], fn (Builder $builder, int $eventId) => $builder->where('event_id', $eventId))
->withCount([
'analytics as success_total' => function (Builder $builder) use ($filters) {
$this->applyAnalyticsFilters($builder, $filters)
->whereIn('event_type', self::SUCCESS_EVENTS);
},
'analytics as failure_total' => function (Builder $builder) use ($filters) {
$this->applyAnalyticsFilters($builder, $filters)
->whereIn('event_type', self::FAILURE_EVENTS);
},
'analytics as rate_limited_total' => function (Builder $builder) use ($filters) {
$this->applyAnalyticsFilters($builder, $filters)
->whereIn('event_type', self::RATE_LIMIT_EVENTS);
},
'analytics as upload_total' => function (Builder $builder) use ($filters) {
$this->applyAnalyticsFilters($builder, $filters)
->whereIn('event_type', self::UPLOAD_EVENTS);
},
])
->withMax([
'analytics as last_seen_at' => function (Builder $builder) use ($filters) {
$this->applyAnalyticsFilters($builder, $filters);
},
], 'occurred_at')
->orderByDesc('failure_total')
->orderByDesc('rate_limited_total')
->orderByDesc('success_total')
->limit(10);
return $query;
}
private function applyAnalyticsFilters(Builder $query, array $filters): Builder
{
return $query->whereBetween('occurred_at', [$filters['start'], $filters['end']]);
}
private function resolveFilters(): array
{
$eventId = $this->pageFilters['event_id'] ?? null;
$eventId = is_numeric($eventId) ? (int) $eventId : null;
$range = is_string($this->pageFilters['range'] ?? null) ? $this->pageFilters['range'] : '24h';
[$start, $end] = $this->resolveWindow($range, $eventId);
return [
'event_id' => $eventId,
'range' => $range,
'start' => $start,
'end' => $end,
];
}
private function resolveWindow(string $range, ?int $eventId): array
{
$now = now();
$start = match ($range) {
'2h' => $now->copy()->subHours(2),
'6h' => $now->copy()->subHours(6),
'12h' => $now->copy()->subHours(12),
'7d' => $now->copy()->subDays(7),
default => $now->copy()->subHours(24),
};
$end = $now;
if ($range === 'event_day' && $eventId) {
$eventDate = Event::query()->whereKey($eventId)->value('date');
if ($eventDate) {
$eventDay = Carbon::parse($eventDate);
$start = $eventDay->copy()->startOfDay();
$end = $eventDay->copy()->endOfDay();
}
}
return [$start, $end];
}
private function formatEventLabel(?Event $event): string
{
if (! $event) {
return __('admin.common.unnamed');
}
$locale = app()->getLocale();
$name = $event->name[$locale] ?? $event->name['de'] ?? $event->name['en'] ?? $event->slug ?? __('admin.common.unnamed');
$tenant = $event->tenant?->name ?? __('admin.common.unnamed');
$date = $event->date?->format('Y-m-d');
return $date ? "{$name} ({$tenant}) {$date}" : "{$name} ({$tenant})";
}
private const SUCCESS_EVENTS = [
'access_granted',
'gallery_access_granted',
];
private const RATE_LIMIT_EVENTS = [
'token_rate_limited',
'access_rate_limited',
'download_rate_limited',
];
private const FAILURE_EVENTS = [
'invalid_token',
'token_expired',
'token_revoked',
'event_not_public',
'gallery_expired',
'token_rate_limited',
'access_rate_limited',
'download_rate_limited',
];
private const UPLOAD_EVENTS = [
'upload_completed',
];
}

View File

@@ -0,0 +1,178 @@
<?php
namespace App\Filament\Widgets;
use App\Models\Event;
use App\Models\EventJoinTokenEvent;
use Carbon\CarbonPeriod;
use Filament\Widgets\ChartWidget;
use Filament\Widgets\Concerns\InteractsWithPageFilters;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Support\Carbon;
class JoinTokenTrendWidget extends ChartWidget
{
use InteractsWithPageFilters;
protected static ?int $sort = 2;
protected int|string|array $columnSpan = 'full';
protected function getData(): array
{
$filters = $this->resolveFilters();
$events = $this->baseQuery($filters)->get(['event_type', 'occurred_at']);
$hourly = $filters['start']->diffInHours($filters['end']) <= 48;
$bucketFormat = $hourly ? 'Y-m-d H:00' : 'Y-m-d';
$labelFormat = $hourly ? 'M d H:00' : 'M d';
$periodStart = $hourly ? $filters['start']->copy()->startOfHour() : $filters['start']->copy()->startOfDay();
$period = CarbonPeriod::create($periodStart, $hourly ? '1 hour' : '1 day', $filters['end']);
$grouped = $events->groupBy(fn (EventJoinTokenEvent $event) => $event->occurred_at?->format($bucketFormat));
$labels = [];
$success = [];
$failures = [];
$rateLimited = [];
$uploads = [];
foreach ($period as $point) {
$key = $point->format($bucketFormat);
$bucket = $grouped->get($key, collect());
$labels[] = $point->translatedFormat($labelFormat);
$success[] = $bucket->whereIn('event_type', self::SUCCESS_EVENTS)->count();
$failures[] = $bucket->whereIn('event_type', self::FAILURE_EVENTS)->count();
$rateLimited[] = $bucket->whereIn('event_type', self::RATE_LIMIT_EVENTS)->count();
$uploads[] = $bucket->whereIn('event_type', self::UPLOAD_EVENTS)->count();
}
return [
'datasets' => [
[
'label' => __('admin.join_token_analytics.trend.success'),
'data' => $success,
'borderColor' => '#16a34a',
'backgroundColor' => 'rgba(22, 163, 74, 0.2)',
'tension' => 0.35,
'fill' => false,
],
[
'label' => __('admin.join_token_analytics.trend.failures'),
'data' => $failures,
'borderColor' => '#dc2626',
'backgroundColor' => 'rgba(220, 38, 38, 0.2)',
'tension' => 0.35,
'fill' => false,
],
[
'label' => __('admin.join_token_analytics.trend.rate_limited'),
'data' => $rateLimited,
'borderColor' => '#f59e0b',
'backgroundColor' => 'rgba(245, 158, 11, 0.2)',
'tension' => 0.35,
'fill' => false,
],
[
'label' => __('admin.join_token_analytics.trend.uploads'),
'data' => $uploads,
'borderColor' => '#2563eb',
'backgroundColor' => 'rgba(37, 99, 235, 0.2)',
'tension' => 0.35,
'fill' => false,
],
],
'labels' => $labels,
];
}
protected function getType(): string
{
return 'line';
}
public function getHeading(): ?string
{
return __('admin.join_token_analytics.trend.heading');
}
private function baseQuery(array $filters): Builder
{
$query = EventJoinTokenEvent::query()
->whereBetween('occurred_at', [$filters['start'], $filters['end']]);
if ($filters['event_id']) {
$query->where('event_id', $filters['event_id']);
}
return $query;
}
private function resolveFilters(): array
{
$eventId = $this->pageFilters['event_id'] ?? null;
$eventId = is_numeric($eventId) ? (int) $eventId : null;
$range = is_string($this->pageFilters['range'] ?? null) ? $this->pageFilters['range'] : '24h';
[$start, $end] = $this->resolveWindow($range, $eventId);
return [
'event_id' => $eventId,
'range' => $range,
'start' => $start,
'end' => $end,
];
}
private function resolveWindow(string $range, ?int $eventId): array
{
$now = now();
$start = match ($range) {
'2h' => $now->copy()->subHours(2),
'6h' => $now->copy()->subHours(6),
'12h' => $now->copy()->subHours(12),
'7d' => $now->copy()->subDays(7),
default => $now->copy()->subHours(24),
};
$end = $now;
if ($range === 'event_day' && $eventId) {
$eventDate = Event::query()->whereKey($eventId)->value('date');
if ($eventDate) {
$eventDay = Carbon::parse($eventDate);
$start = $eventDay->copy()->startOfDay();
$end = $eventDay->copy()->endOfDay();
}
}
return [$start, $end];
}
private const SUCCESS_EVENTS = [
'access_granted',
'gallery_access_granted',
];
private const RATE_LIMIT_EVENTS = [
'token_rate_limited',
'access_rate_limited',
'download_rate_limited',
];
private const FAILURE_EVENTS = [
'invalid_token',
'token_expired',
'token_revoked',
'event_not_public',
'gallery_expired',
'token_rate_limited',
'access_rate_limited',
'download_rate_limited',
];
private const UPLOAD_EVENTS = [
'upload_completed',
];
}

View File

@@ -6,6 +6,7 @@ use App\Enums\GuestNotificationAudience;
use App\Enums\GuestNotificationDeliveryStatus;
use App\Enums\GuestNotificationState;
use App\Enums\GuestNotificationType;
use App\Enums\PhotoLiveStatus;
use App\Events\GuestPhotoUploaded;
use App\Jobs\ProcessPhotoSecurityScan;
use App\Models\Event;
@@ -1899,6 +1900,8 @@ class EventPublicController extends BaseController
$branding = $this->buildGalleryBranding($event);
$settings = $this->normalizeSettings($event->settings ?? []);
$engagementMode = $settings['engagement_mode'] ?? 'tasks';
$liveShowSettings = Arr::get($settings, 'live_show', []);
$liveShowSettings = is_array($liveShowSettings) ? $liveShowSettings : [];
$event->loadMissing('photoboothSetting');
$policy = $this->guestPolicy();
@@ -1921,6 +1924,9 @@ class EventPublicController extends BaseController
'photobooth_enabled' => (bool) ($event->photoboothSetting?->enabled),
'branding' => $branding,
'guest_upload_visibility' => Arr::get($event->settings ?? [], 'guest_upload_visibility', $policy->guest_upload_visibility),
'live_show' => [
'moderation_mode' => $liveShowSettings['moderation_mode'] ?? 'manual',
],
'engagement_mode' => $engagementMode,
])->header('Cache-Control', 'no-store');
}
@@ -2987,6 +2993,7 @@ class EventPublicController extends BaseController
'emotion_slug' => ['nullable', 'string'],
'task_id' => ['nullable', 'integer'],
'guest_name' => ['nullable', 'string', 'max:255'],
'live_show_opt_in' => ['nullable', 'boolean'],
]);
$file = $validated['photo'];
@@ -3022,6 +3029,26 @@ class EventPublicController extends BaseController
$url = $this->resolveDiskUrl($disk, $watermarkedPath);
$thumbUrl = $this->resolveDiskUrl($disk, $watermarkedThumb);
$liveShowSettings = Arr::get($eventModel->settings ?? [], 'live_show', []);
$liveShowSettings = is_array($liveShowSettings) ? $liveShowSettings : [];
$liveModerationMode = $liveShowSettings['moderation_mode'] ?? 'manual';
$liveOptIn = $request->boolean('live_show_opt_in');
$liveSubmittedAt = null;
$liveApprovedAt = null;
$liveReviewedAt = null;
$liveStatus = PhotoLiveStatus::NONE->value;
if ($liveOptIn) {
$liveSubmittedAt = now();
if ($liveModerationMode === 'off') {
$liveStatus = PhotoLiveStatus::APPROVED->value;
$liveApprovedAt = $liveSubmittedAt;
$liveReviewedAt = $liveSubmittedAt;
} else {
$liveStatus = PhotoLiveStatus::PENDING->value;
}
}
$photoId = DB::table('photos')->insertGetId([
'event_id' => $eventId,
'tenant_id' => $tenantModel->id,
@@ -3033,6 +3060,12 @@ class EventPublicController extends BaseController
'likes_count' => 0,
'ingest_source' => Photo::SOURCE_GUEST_PWA,
'status' => $autoApproveUploads ? 'approved' : 'pending',
'live_status' => $liveStatus,
'live_submitted_at' => $liveSubmittedAt,
'live_approved_at' => $liveApprovedAt,
'live_reviewed_at' => $liveReviewedAt,
'live_reviewed_by' => null,
'live_rejection_reason' => null,
// Handle emotion_id: prefer explicit ID, fallback to slug lookup, then default
'emotion_id' => $this->resolveEmotionId($validated, $eventId),

View File

@@ -0,0 +1,348 @@
<?php
namespace App\Http\Controllers\Api;
use App\Enums\PhotoLiveStatus;
use App\Models\Event;
use App\Models\Photo;
use Carbon\CarbonImmutable;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Http\StreamedEvent;
use Illuminate\Routing\Controller as BaseController;
use Illuminate\Support\Arr;
use Symfony\Component\HttpFoundation\Response;
class LiveShowController extends BaseController
{
private const DEFAULT_RETENTION_HOURS = 12;
private const DEFAULT_LIMIT = 50;
private const MAX_LIMIT = 200;
private const STREAM_TICK_SECONDS = 2;
private const STREAM_MAX_SECONDS = 60;
private const STREAM_PING_SECONDS = 10;
public function state(Request $request, string $token): JsonResponse
{
$event = $this->resolveEvent($token);
if (! $event) {
return $this->notFound();
}
$settings = $this->liveShowSettings($event);
$settingsVersion = $this->settingsVersion($settings);
$limit = $this->resolveLimit($request);
$photos = $this->baseLiveShowQuery($event, $settings)
->orderByDesc('live_approved_at')
->orderByDesc('id')
->limit($limit)
->get()
->reverse()
->values();
$cursor = $this->buildCursor($photos->last());
return response()->json([
'event' => [
'id' => $event->id,
'slug' => $event->slug,
'name' => $event->name,
'default_locale' => $event->default_locale,
],
'settings' => $settings,
'settings_version' => $settingsVersion,
'photos' => $photos->map(fn (Photo $photo) => $this->serializePhoto($photo)),
'cursor' => $cursor,
]);
}
public function updates(Request $request, string $token): JsonResponse
{
$event = $this->resolveEvent($token);
if (! $event) {
return $this->notFound();
}
$cursor = $this->parseCursor($request);
if ($cursor === null) {
return response()->json([
'error' => 'invalid_cursor',
], Response::HTTP_UNPROCESSABLE_ENTITY);
}
$settings = $this->liveShowSettings($event);
$settingsVersion = $this->settingsVersion($settings);
$limit = $this->resolveLimit($request);
$query = $this->baseLiveShowQuery($event, $settings);
$this->applyCursor($query, $cursor);
$photos = $query
->orderBy('live_approved_at')
->orderBy('id')
->limit($limit)
->get();
$nextCursor = $this->buildCursor($photos->last());
$requestedSettingsVersion = (string) $request->query('settings_version', '');
$includeSettings = $requestedSettingsVersion === '' || $requestedSettingsVersion !== $settingsVersion;
return response()->json([
'settings' => $includeSettings ? $settings : null,
'settings_version' => $settingsVersion,
'photos' => $photos->map(fn (Photo $photo) => $this->serializePhoto($photo)),
'cursor' => $nextCursor ?? $cursor,
]);
}
public function stream(Request $request, string $token): Response
{
$event = $this->resolveEvent($token);
if (! $event) {
return $this->notFound();
}
$cursor = $this->parseCursor($request);
if ($cursor === null) {
return response()->json([
'error' => 'invalid_cursor',
], Response::HTTP_UNPROCESSABLE_ENTITY);
}
$settings = $this->liveShowSettings($event);
$settingsVersion = $this->settingsVersion($settings);
$requestedSettingsVersion = (string) $request->query('settings_version', '');
$lastSettingsCheck = CarbonImmutable::now();
$lastPingAt = CarbonImmutable::now();
$startedAt = CarbonImmutable::now();
return response()->eventStream(function () use ($event, $cursor, $settings, $settingsVersion, $requestedSettingsVersion, $startedAt, $lastPingAt, $lastSettingsCheck) {
$lastApprovedAt = $cursor['approved_at'];
$lastId = $cursor['id'];
$currentSettingsVersion = $requestedSettingsVersion !== '' ? $requestedSettingsVersion : $settingsVersion;
$currentSettings = $settings;
while (CarbonImmutable::now()->diffInSeconds($startedAt) < self::STREAM_MAX_SECONDS) {
if (connection_aborted()) {
break;
}
$now = CarbonImmutable::now();
if ($now->diffInSeconds($lastSettingsCheck) >= self::STREAM_PING_SECONDS) {
$event->refresh();
$currentSettings = $this->liveShowSettings($event);
$newSettingsVersion = $this->settingsVersion($currentSettings);
if ($newSettingsVersion !== $currentSettingsVersion) {
$currentSettingsVersion = $newSettingsVersion;
yield new StreamedEvent(
event: 'settings.updated',
data: [
'settings' => $currentSettings,
'settings_version' => $currentSettingsVersion,
]
);
}
$lastSettingsCheck = $now;
}
$query = $this->baseLiveShowQuery($event, $currentSettings);
$this->applyCursor($query, [
'approved_at' => $lastApprovedAt,
'id' => $lastId,
]);
$updates = $query
->orderBy('live_approved_at')
->orderBy('id')
->limit(self::MAX_LIMIT)
->get();
foreach ($updates as $photo) {
$payload = $this->serializePhoto($photo);
$lastApprovedAt = $photo->live_approved_at;
$lastId = $photo->id;
yield new StreamedEvent(
event: 'photo.approved',
data: [
'photo' => $payload,
'cursor' => $this->buildCursor($photo),
]
);
}
if ($now->diffInSeconds($lastPingAt) >= self::STREAM_PING_SECONDS) {
$lastPingAt = $now;
yield new StreamedEvent(
event: 'ping',
data: [
'time' => $now->toAtomString(),
]
);
}
sleep(self::STREAM_TICK_SECONDS);
}
}, Response::HTTP_OK, [
'Cache-Control' => 'no-cache',
'X-Accel-Buffering' => 'no',
]);
}
private function resolveEvent(string $token): ?Event
{
if ($token === '') {
return null;
}
return Event::query()
->where('live_show_token', $token)
->first();
}
private function liveShowSettings(Event $event): array
{
$settings = is_array($event->settings) ? $event->settings : [];
$liveShow = Arr::get($settings, 'live_show', []);
return array_merge([
'retention_window_hours' => self::DEFAULT_RETENTION_HOURS,
'moderation_mode' => 'manual',
'playback_mode' => 'newest_first',
'pace_mode' => 'auto',
'fixed_interval_seconds' => 8,
'layout_mode' => 'single',
'effect_preset' => 'film_cut',
'effect_intensity' => 70,
'background_mode' => 'blur_last',
], is_array($liveShow) ? $liveShow : []);
}
private function settingsVersion(array $settings): string
{
return sha1(json_encode($settings));
}
private function resolveLimit(Request $request): int
{
$limit = (int) $request->query('limit', self::DEFAULT_LIMIT);
if ($limit <= 0) {
return self::DEFAULT_LIMIT;
}
return min($limit, self::MAX_LIMIT);
}
private function baseLiveShowQuery(Event $event, array $settings): Builder
{
$query = Photo::query()
->where('event_id', $event->id)
->where('live_status', PhotoLiveStatus::APPROVED->value)
->where('status', 'approved')
->whereNotNull('live_approved_at');
$retention = (int) ($settings['retention_window_hours'] ?? self::DEFAULT_RETENTION_HOURS);
if ($retention > 0) {
$query->where('live_approved_at', '>=', now()->subHours($retention));
}
return $query;
}
private function applyCursor(Builder $query, array $cursor): void
{
$afterAt = $cursor['approved_at'];
$afterId = $cursor['id'];
if (! $afterAt) {
return;
}
if ($afterId <= 0) {
$query->where('live_approved_at', '>', $afterAt);
return;
}
$query->where(function (Builder $inner) use ($afterAt, $afterId) {
$inner->where('live_approved_at', '>', $afterAt)
->orWhere(function (Builder $tie) use ($afterAt, $afterId) {
$tie->where('live_approved_at', $afterAt)
->where('id', '>', $afterId);
});
});
}
private function serializePhoto(Photo $photo): array
{
return [
'id' => $photo->id,
'full_url' => $photo->file_path,
'thumb_url' => $photo->thumbnail_path,
'approved_at' => $photo->live_approved_at?->toAtomString(),
'width' => $photo->width,
'height' => $photo->height,
'is_featured' => (bool) $photo->is_featured,
'live_priority' => (int) ($photo->live_priority ?? 0),
];
}
private function buildCursor(?Photo $photo): ?array
{
if (! $photo || ! $photo->live_approved_at) {
return null;
}
return [
'approved_at' => $photo->live_approved_at,
'id' => $photo->id,
];
}
private function parseCursor(Request $request): ?array
{
$afterAt = trim((string) $request->query('after_approved_at', ''));
$afterId = (int) $request->query('after_id', 0);
if ($afterAt === '' && $afterId === 0) {
return [
'approved_at' => null,
'id' => 0,
];
}
try {
$approvedAt = CarbonImmutable::parse($afterAt);
} catch (\Throwable) {
return null;
}
return [
'approved_at' => $approvedAt,
'id' => max(0, $afterId),
];
}
private function notFound(): JsonResponse
{
return response()->json([
'error' => 'live_show_not_found',
], Response::HTTP_NOT_FOUND);
}
}

View File

@@ -3,9 +3,12 @@
namespace App\Http\Controllers\Api;
use App\Http\Controllers\Controller;
use App\Http\Requests\Checkout\CheckoutSessionStatusRequest;
use App\Models\CheckoutSession;
use App\Models\Package;
use App\Models\PackagePurchase;
use App\Models\TenantPackage;
use App\Services\Checkout\CheckoutSessionService;
use App\Services\Paddle\PaddleCheckoutService;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
@@ -14,7 +17,10 @@ use Illuminate\Validation\ValidationException;
class PackageController extends Controller
{
public function __construct(private readonly PaddleCheckoutService $paddleCheckout) {}
public function __construct(
private readonly PaddleCheckoutService $paddleCheckout,
private readonly CheckoutSessionService $sessions,
) {}
public function index(Request $request): JsonResponse
{
@@ -165,23 +171,82 @@ class PackageController extends Controller
$package = Package::findOrFail($request->integer('package_id'));
$tenant = $request->attributes->get('tenant');
$user = $request->user();
if (! $tenant) {
throw ValidationException::withMessages(['tenant' => 'Tenant context missing.']);
}
if (! $user) {
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_PADDLE);
$now = now();
$session->forceFill([
'accepted_terms_at' => $now,
'accepted_privacy_at' => $now,
'accepted_withdrawal_notice_at' => $now,
'digital_content_waiver_at' => null,
'legal_version' => config('app.legal_version', $now->toDateString()),
])->save();
$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,
],
];
$checkout = $this->paddleCheckout->createCheckout($tenant, $package, $payload);
return response()->json($checkout);
$session->forceFill([
'paddle_checkout_id' => $checkout['id'] ?? $session->paddle_checkout_id,
'provider_metadata' => array_merge($session->provider_metadata ?? [], array_filter([
'paddle_checkout_id' => $checkout['id'] ?? null,
'paddle_checkout_url' => $checkout['checkout_url'] ?? null,
'paddle_expires_at' => $checkout['expires_at'] ?? null,
])),
])->save();
return response()->json(array_merge($checkout, [
'checkout_session_id' => $session->id,
]));
}
public function checkoutSessionStatus(CheckoutSessionStatusRequest $request, CheckoutSession $session): JsonResponse
{
$history = $session->status_history ?? [];
$reason = null;
foreach (array_reverse($history) as $entry) {
if (($entry['status'] ?? null) === $session->status) {
$reason = $entry['reason'] ?? null;
break;
}
}
$checkoutUrl = data_get($session->provider_metadata ?? [], 'paddle_checkout_url');
return response()->json([
'status' => $session->status,
'completed_at' => optional($session->completed_at)->toIso8601String(),
'reason' => $reason,
'checkout_url' => is_string($checkoutUrl) ? $checkoutUrl : null,
]);
}
private function handleFreePurchase(Request $request, Package $package, $tenant): JsonResponse

View File

@@ -0,0 +1,45 @@
<?php
namespace App\Http\Controllers\Api;
use App\Http\Controllers\Controller;
use App\Http\Requests\Photobooth\PhotoboothConnectRedeemRequest;
use App\Services\Photobooth\PhotoboothConnectCodeService;
use Illuminate\Http\JsonResponse;
class PhotoboothConnectController extends Controller
{
public function __construct(private readonly PhotoboothConnectCodeService $service) {}
public function store(PhotoboothConnectRedeemRequest $request): JsonResponse
{
$record = $this->service->redeem($request->input('code'));
if (! $record) {
return response()->json([
'message' => __('Ungültiger oder abgelaufener Verbindungscode.'),
], 422);
}
$record->loadMissing('event.photoboothSetting');
$event = $record->event;
$setting = $event?->photoboothSetting;
if (! $event || ! $setting || ! $setting->enabled || $setting->mode !== 'sparkbooth') {
return response()->json([
'message' => __('Photobooth ist nicht im Sparkbooth-Modus aktiv.'),
], 409);
}
return response()->json([
'data' => [
'upload_url' => route('api.v1.photobooth.sparkbooth.upload'),
'username' => $setting->username,
'password' => $setting->password,
'expires_at' => optional($setting->expires_at)->toIso8601String(),
'response_format' => ($setting->metadata ?? [])['sparkbooth_response_format']
?? config('photobooth.sparkbooth.response_format', 'json'),
],
]);
}
}

View File

@@ -0,0 +1,190 @@
<?php
namespace App\Http\Controllers\Api\Tenant;
use App\Http\Controllers\Controller;
use App\Http\Requests\Tenant\DataExportStoreRequest;
use App\Jobs\GenerateDataExport;
use App\Models\DataExport;
use App\Models\Event;
use App\Models\Tenant;
use App\Support\ApiError;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Storage;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\HttpFoundation\StreamedResponse;
class DataExportController extends Controller
{
public function index(Request $request): JsonResponse
{
$tenant = $this->resolveTenant($request);
$exports = DataExport::query()
->with('event')
->where('tenant_id', $tenant->id)
->whereIn('scope', ['tenant', 'event'])
->latest()
->limit(10)
->get()
->map(fn (DataExport $export) => [
'id' => $export->id,
'scope' => $export->scope?->value ?? $export->scope,
'status' => $export->status,
'include_media' => (bool) $export->include_media,
'size_bytes' => $export->size_bytes,
'created_at' => optional($export->created_at)->toIso8601String(),
'expires_at' => optional($export->expires_at)->toIso8601String(),
'download_url' => $export->isReady() && ! $export->hasExpired()
? route('api.v1.tenant.exports.download', $export)
: null,
'error_message' => $export->error_message,
'event' => $export->event ? [
'id' => $export->event->id,
'slug' => $export->event->slug,
'name' => $export->event->name,
] : null,
]);
return response()->json([
'data' => $exports,
]);
}
public function store(DataExportStoreRequest $request): JsonResponse
{
$tenant = $this->resolveTenant($request);
$user = $request->user();
if (! $user) {
return ApiError::response(
'export_user_missing',
'Export user missing',
'Unable to determine the requesting user.',
Response::HTTP_UNAUTHORIZED
);
}
$payload = $request->validated();
$scope = $payload['scope'];
$event = null;
if ($scope === 'event') {
$event = Event::query()
->where('tenant_id', $tenant->id)
->find($payload['event_id']);
if (! $event) {
return ApiError::response(
'export_event_missing',
'Event not found',
'The selected event does not exist for this tenant.',
Response::HTTP_NOT_FOUND
);
}
}
$hasInProgress = DataExport::query()
->where('tenant_id', $tenant->id)
->whereIn('status', [DataExport::STATUS_PENDING, DataExport::STATUS_PROCESSING])
->exists();
if ($hasInProgress) {
return ApiError::response(
'export_in_progress',
'Export already in progress',
'Please wait for the current export to finish before requesting another.',
Response::HTTP_CONFLICT
);
}
$export = DataExport::query()->create([
'user_id' => $user->id,
'tenant_id' => $tenant->id,
'event_id' => $event?->id,
'scope' => $scope,
'include_media' => (bool) ($payload['include_media'] ?? false),
'status' => DataExport::STATUS_PENDING,
]);
GenerateDataExport::dispatch($export->id);
return response()->json([
'message' => 'Export started.',
'data' => [
'id' => $export->id,
'scope' => $export->scope?->value ?? $export->scope,
'status' => $export->status,
'include_media' => (bool) $export->include_media,
'created_at' => optional($export->created_at)->toIso8601String(),
],
], Response::HTTP_ACCEPTED);
}
public function download(Request $request, DataExport $export): StreamedResponse|JsonResponse
{
$tenant = $this->resolveTenant($request);
if ((int) $export->tenant_id !== (int) $tenant->id) {
return ApiError::response(
'export_not_found',
'Export not found',
'The requested export is not available for this tenant.',
Response::HTTP_NOT_FOUND
);
}
if (! $export->isReady() || $export->hasExpired() || ! $export->path) {
return ApiError::response(
'export_not_ready',
'Export not ready',
'The export is not ready or has expired.',
Response::HTTP_BAD_REQUEST
);
}
$disk = 'local';
if (! Storage::disk($disk)->exists($export->path)) {
return ApiError::response(
'export_missing',
'Export not found',
'The export archive could not be located.',
Response::HTTP_NOT_FOUND
);
}
return Storage::disk($disk)->download(
$export->path,
sprintf('fotospiel-data-export-%s.zip', $export->created_at?->format('Ymd') ?? now()->format('Ymd')),
[
'Cache-Control' => 'private, no-store',
]
);
}
private function resolveTenant(Request $request): Tenant
{
$tenant = $request->attributes->get('tenant');
if ($tenant instanceof Tenant) {
return $tenant;
}
$tenantId = $request->attributes->get('tenant_id')
?? $request->attributes->get('current_tenant_id')
?? $request->user()?->tenant_id;
if ($tenantId) {
$tenant = Tenant::query()->find($tenantId);
if ($tenant) {
$request->attributes->set('tenant', $tenant);
return $tenant;
}
}
abort(401, 'Tenant context missing.');
}
}

View File

@@ -0,0 +1,46 @@
<?php
namespace App\Http\Controllers\Api\Tenant;
use App\Http\Controllers\Controller;
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
{
public function __construct(
private readonly EventAnalyticsService $analyticsService
) {}
public function show(Request $request, Event $event): JsonResponse
{
// Check if package has advanced_analytics feature
$packageFeatures = $event->eventPackage?->package?->features ?? [];
// Handle array or JSON string features
if (is_string($packageFeatures)) {
$packageFeatures = json_decode($packageFeatures, true) ?? [];
}
$hasAccess = in_array('advanced_analytics', $packageFeatures, true);
if (!$hasAccess) {
return response()->json([
'message' => 'This feature is only available in the Premium package.',
'code' => 'feature_locked'
], 403);
}
$timeline = $this->analyticsService->getTimeline($event);
$contributors = $this->analyticsService->getTopContributors($event);
$tasks = $this->analyticsService->getTaskStats($event);
return response()->json([
'timeline' => $timeline,
'contributors' => $contributors,
'tasks' => $tasks,
]);
}
}

View File

@@ -16,6 +16,7 @@ use App\Models\Package;
use App\Models\PackagePurchase;
use App\Models\Photo;
use App\Models\Tenant;
use App\Models\User;
use App\Services\EventJoinTokenService;
use App\Support\ApiError;
use Illuminate\Http\JsonResponse;
@@ -88,12 +89,15 @@ class EventController extends Controller
$tenant = Tenant::findOrFail($tenantId);
}
$actor = $request->user();
$isSuperAdmin = $actor instanceof User && $actor->isSuperAdmin();
// Package check is now handled by middleware
$validated = $request->validated();
$tenantId = $tenant->id;
$requestedPackageId = $validated['package_id'] ?? null;
$requestedPackageId = $isSuperAdmin ? $request->integer('package_id') : null;
unset($validated['package_id']);
$tenantPackage = $tenant->tenantPackages()
@@ -108,6 +112,10 @@ class EventController extends Controller
$package = Package::query()->find($requestedPackageId);
}
if (! $package && $isSuperAdmin) {
$package = $this->resolveOwnerPackage();
}
if (! $package && $tenantPackage) {
$package = $tenantPackage->package ?? Package::query()->find($tenantPackage->package_id);
}
@@ -121,7 +129,7 @@ class EventController extends Controller
$requiresWaiver = $package->isEndcustomer();
$latestPurchase = $requiresWaiver ? $this->resolveLatestPackagePurchase($tenant, $package) : null;
$existingWaiver = $latestPurchase ? data_get($latestPurchase->metadata, 'consents.digital_content_waiver_at') : null;
$needsWaiver = $requiresWaiver && ! $existingWaiver;
$needsWaiver = ! $isSuperAdmin && $requiresWaiver && ! $existingWaiver;
if ($needsWaiver && ! $request->boolean('accepted_waiver')) {
throw ValidationException::withMessages([
@@ -182,7 +190,7 @@ class EventController extends Controller
$eventData = Arr::only($eventData, $allowed);
$event = DB::transaction(function () use ($tenant, $eventData, $package) {
$event = DB::transaction(function () use ($tenant, $eventData, $package, $isSuperAdmin) {
$event = Event::create($eventData);
EventPackage::create([
@@ -193,7 +201,7 @@ class EventController extends Controller
'gallery_expires_at' => $package->gallery_days ? now()->addDays($package->gallery_days) : null,
]);
if ($package->isReseller()) {
if ($package->isReseller() && ! $isSuperAdmin) {
$note = sprintf('Event #%d created (%s)', $event->id, $event->name);
if (! $tenant->consumeEventAllowance(1, 'event.create', $note)) {
@@ -229,6 +237,15 @@ class EventController extends Controller
->first();
}
private function resolveOwnerPackage(): ?Package
{
$ownerPackage = Package::query()
->where('slug', 'pro')
->first();
return $ownerPackage ?? Package::query()->find(3);
}
private function recordEventStartWaiver(Tenant $tenant, Package $package, ?PackagePurchase $purchase): void
{
$timestamp = now();

View File

@@ -135,7 +135,7 @@ class EventMemberController extends Controller
$user->password = Hash::make(Str::random(32));
}
if ($user->tenant_id && (int) $user->tenant_id !== (int) $tenant->id && $user->role !== 'super_admin') {
if ($user->tenant_id && (int) $user->tenant_id !== (int) $tenant->id && ! $user->isSuperAdmin()) {
throw ValidationException::withMessages([
'email' => __('Dieser Benutzer ist einem anderen Mandanten zugeordnet.'),
]);
@@ -143,9 +143,9 @@ class EventMemberController extends Controller
$user->tenant_id = $tenant->id;
if ($role === 'tenant_admin' && $user->role !== 'super_admin') {
if ($role === 'tenant_admin' && ! $user->isSuperAdmin()) {
$user->role = 'tenant_admin';
} elseif (! in_array($user->role, ['tenant_admin', 'super_admin'], true)) {
} elseif (! in_array($user->role, ['tenant_admin', 'super_admin', 'superadmin'], true)) {
$user->role = 'member';
}

View File

@@ -0,0 +1,135 @@
<?php
namespace App\Http\Controllers\Api\Tenant;
use App\Http\Controllers\Controller;
use App\Models\Event;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use SimpleSoftwareIO\QrCode\Facades\QrCode;
class LiveShowLinkController extends Controller
{
public function show(Request $request, Event $event): JsonResponse
{
$this->authorizeEvent($request, $event);
$token = $event->ensureLiveShowToken();
return response()->json([
'data' => $this->buildPayload($event, $token),
]);
}
public function rotate(Request $request, Event $event): JsonResponse
{
$this->authorizeEvent($request, $event);
$token = $event->rotateLiveShowToken();
return response()->json([
'data' => $this->buildPayload($event, $token),
]);
}
private function authorizeEvent(Request $request, Event $event): void
{
$tenantId = $request->attributes->get('tenant_id');
if ($event->tenant_id !== $tenantId) {
abort(404, 'Event not found');
}
}
private function buildPayload(Event $event, string $token): array
{
$url = $this->buildLiveShowUrl($event, $token);
return [
'token' => $token,
'url' => $url,
'qr_code_data_url' => $this->buildQrCodeDataUrl($url),
'rotated_at' => $event->live_show_token_rotated_at?->toIso8601String(),
];
}
private function buildLiveShowUrl(Event $event, string $token): string
{
$baseUrl = $this->resolveBaseUrl($event);
return rtrim($baseUrl, '/').'/show/'.$token;
}
private function resolveBaseUrl(Event $event): string
{
$settings = is_array($event->settings) ? $event->settings : [];
$customDomain = $settings['custom_domain'] ?? null;
if (is_string($customDomain) && $customDomain !== '') {
return sprintf('%s://%s', $this->resolveScheme(), $customDomain);
}
$publicUrl = $settings['public_url'] ?? null;
if (is_string($publicUrl) && $publicUrl !== '') {
$parsed = parse_url($publicUrl);
$host = is_array($parsed) ? ($parsed['host'] ?? null) : null;
if (is_string($host) && $host !== '') {
$scheme = $parsed['scheme'] ?? $this->resolveScheme();
$port = $parsed['port'] ?? null;
$base = $scheme.'://'.$host;
if ($port) {
$base .= ':'.$port;
}
return $base;
}
}
return (string) config('app.url');
}
private function resolveScheme(): string
{
$appUrl = config('app.url');
if (is_string($appUrl)) {
$scheme = parse_url($appUrl, PHP_URL_SCHEME);
if (is_string($scheme) && $scheme !== '') {
return $scheme;
}
}
return 'https';
}
private function buildQrCodeDataUrl(string $url): ?string
{
if ($url === '') {
return null;
}
try {
$png = QrCode::format('png')
->size(360)
->margin(1)
->errorCorrection('M')
->generate($url);
$pngBinary = (string) $png;
if ($pngBinary === '') {
return null;
}
return 'data:image/png;base64,'.base64_encode($pngBinary);
} catch (\Throwable $exception) {
report($exception);
}
return null;
}
}

View File

@@ -0,0 +1,195 @@
<?php
namespace App\Http\Controllers\Api\Tenant;
use App\Http\Controllers\Controller;
use App\Http\Requests\Tenant\LiveShowApproveRequest;
use App\Http\Requests\Tenant\LiveShowQueueRequest;
use App\Http\Requests\Tenant\LiveShowRejectRequest;
use App\Http\Resources\Tenant\PhotoResource;
use App\Models\Event;
use App\Models\Photo;
use App\Support\ApiError;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\AnonymousResourceCollection;
use Symfony\Component\HttpFoundation\Response;
class LiveShowPhotoController extends Controller
{
public function index(LiveShowQueueRequest $request, string $eventSlug): AnonymousResourceCollection
{
$tenantId = $request->attributes->get('tenant_id');
$event = Event::where('slug', $eventSlug)
->where('tenant_id', $tenantId)
->firstOrFail();
$liveStatus = $request->string('live_status', 'pending')->toString();
$perPage = (int) $request->input('per_page', 20);
$perPage = max(1, min($perPage, 50));
$query = Photo::query()
->where('event_id', $event->id)
->with('event')
->withCount('likes');
if ($liveStatus !== '' && $liveStatus !== 'all') {
$query->where('live_status', $liveStatus);
}
$photos = $query
->orderByDesc('live_submitted_at')
->orderByDesc('created_at')
->paginate($perPage);
return PhotoResource::collection($photos);
}
public function approve(LiveShowApproveRequest $request, string $eventSlug, Photo $photo): JsonResponse
{
$tenantId = $request->attributes->get('tenant_id');
$event = Event::where('slug', $eventSlug)
->where('tenant_id', $tenantId)
->firstOrFail();
if ($photo->event_id !== $event->id) {
return ApiError::response(
'photo_not_found',
'Photo not found',
'The specified photo could not be located for this event.',
Response::HTTP_NOT_FOUND,
['photo_id' => $photo->id]
);
}
if ($photo->status !== 'approved') {
return ApiError::response(
'photo_not_approved',
'Photo not approved',
'Only approved photos can be added to the Live Show.',
Response::HTTP_UNPROCESSABLE_ENTITY,
['photo_id' => $photo->id]
);
}
$photo->approveForLiveShow($request->user());
if ($request->filled('priority')) {
$photo->forceFill([
'live_priority' => $request->integer('priority'),
])->save();
}
$photo->refresh()->load('event')->loadCount('likes');
return response()->json([
'message' => 'Photo approved for Live Show',
'data' => new PhotoResource($photo),
]);
}
public function approveAndLive(LiveShowApproveRequest $request, string $eventSlug, Photo $photo): JsonResponse
{
$tenantId = $request->attributes->get('tenant_id');
$event = Event::where('slug', $eventSlug)
->where('tenant_id', $tenantId)
->firstOrFail();
if ($photo->event_id !== $event->id) {
return ApiError::response(
'photo_not_found',
'Photo not found',
'The specified photo could not be located for this event.',
Response::HTTP_NOT_FOUND,
['photo_id' => $photo->id]
);
}
if (in_array($photo->status, ['rejected', 'hidden'], true)) {
return ApiError::response(
'photo_not_eligible',
'Photo not eligible',
'Rejected or hidden photos cannot be approved for Live Show.',
Response::HTTP_UNPROCESSABLE_ENTITY,
['photo_id' => $photo->id]
);
}
if ($photo->status !== 'approved') {
$photo->forceFill([
'status' => 'approved',
'moderated_at' => now(),
'moderated_by' => $request->user()?->id,
'moderation_notes' => null,
])->save();
}
$photo->approveForLiveShow($request->user());
if ($request->filled('priority')) {
$photo->forceFill([
'live_priority' => $request->integer('priority'),
])->save();
}
$photo->refresh()->load('event')->loadCount('likes');
return response()->json([
'message' => 'Photo approved and added to Live Show',
'data' => new PhotoResource($photo),
]);
}
public function reject(LiveShowRejectRequest $request, string $eventSlug, Photo $photo): JsonResponse
{
$tenantId = $request->attributes->get('tenant_id');
$event = Event::where('slug', $eventSlug)
->where('tenant_id', $tenantId)
->firstOrFail();
if ($photo->event_id !== $event->id) {
return ApiError::response(
'photo_not_found',
'Photo not found',
'The specified photo could not be located for this event.',
Response::HTTP_NOT_FOUND,
['photo_id' => $photo->id]
);
}
$reason = $request->string('reason')->toString();
$photo->rejectForLiveShow($request->user(), $reason !== '' ? $reason : null);
$photo->refresh()->load('event')->loadCount('likes');
return response()->json([
'message' => 'Photo rejected for Live Show',
'data' => new PhotoResource($photo),
]);
}
public function clear(Request $request, string $eventSlug, Photo $photo): JsonResponse
{
$tenantId = $request->attributes->get('tenant_id');
$event = Event::where('slug', $eventSlug)
->where('tenant_id', $tenantId)
->firstOrFail();
if ($photo->event_id !== $event->id) {
return ApiError::response(
'photo_not_found',
'Photo not found',
'The specified photo could not be located for this event.',
Response::HTTP_NOT_FOUND,
['photo_id' => $photo->id]
);
}
$photo->clearFromLiveShow($request->user());
$photo->refresh()->load('event')->loadCount('likes');
return response()->json([
'message' => 'Photo removed from Live Show',
'data' => new PhotoResource($photo),
]);
}
}

View File

@@ -34,6 +34,8 @@ class OnboardingController extends Controller
'admin_app_opened_at' => Arr::get($settings, 'onboarding.admin_app_opened_at'),
'primary_event_id' => Arr::get($settings, 'onboarding.primary_event_id'),
'selected_packages' => Arr::get($settings, 'onboarding.selected_packages'),
'summary_seen_package_id' => Arr::get($settings, 'onboarding.summary_seen_package_id'),
'summary_seen_at' => Arr::get($settings, 'onboarding.summary_seen_at'),
'dismissed_at' => Arr::get($settings, 'onboarding.dismissed_at'),
'completed_at' => Arr::get($settings, 'onboarding.completed_at'),
'branding_completed' => (bool) ($status['palette'] ?? false),
@@ -86,6 +88,11 @@ class OnboardingController extends Controller
Arr::set($settings, 'onboarding.invite_created_at', Carbon::now()->toIso8601String());
break;
case 'summary_seen':
Arr::set($settings, 'onboarding.summary_seen_package_id', Arr::get($meta, 'package_id'));
Arr::set($settings, 'onboarding.summary_seen_at', Carbon::now()->toIso8601String());
break;
case 'dismissed':
Arr::set($settings, 'onboarding.dismissed_at', Carbon::now()->toIso8601String());
break;

View File

@@ -14,6 +14,7 @@ use App\Services\Packages\PackageUsageTracker;
use App\Services\Storage\EventStorageManager;
use App\Support\ApiError;
use App\Support\ImageHelper;
use App\Support\UploadStream;
use App\Support\WatermarkConfigResolver;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
@@ -318,7 +319,7 @@ class PhotoController extends Controller
$path = "events/{$eventSlug}/photos/{$filename}";
// Store original file
Storage::disk($disk)->put($path, file_get_contents($file->getRealPath()));
UploadStream::putUploadedFile($disk, $path, $file);
// Generate thumbnail
$thumbnailPath = "events/{$eventSlug}/thumbnails/{$filename}";
@@ -354,6 +355,7 @@ class PhotoController extends Controller
$photoAttributes = [
'event_id' => $event->id,
'guest_name' => Photo::SOURCE_TENANT_ADMIN,
'original_name' => $file->getClientOriginalName(),
'mime_type' => $file->getMimeType(),
'size' => $file->getSize(),
@@ -523,13 +525,13 @@ class PhotoController extends Controller
]);
// Only tenant admins can moderate
if (isset($validated['status']) && ! $this->tokenHasScope($request, 'tenant:write')) {
if (isset($validated['status']) && ! $this->tokenHasScope($request, 'tenant-admin')) {
return ApiError::response(
'insufficient_scope',
'Insufficient Scopes',
'You are not allowed to moderate photos for this event.',
Response::HTTP_FORBIDDEN,
['required_scope' => 'tenant:write']
['required_scope' => 'tenant-admin']
);
}
@@ -821,6 +823,11 @@ class PhotoController extends Controller
private function tokenHasScope(Request $request, string $scope): bool
{
$accessToken = $request->user()?->currentAccessToken();
if ($accessToken && $accessToken->can($scope)) {
return true;
}
$scopes = $request->user()->scopes ?? ($request->attributes->get('decoded_token')['scopes'] ?? []);
if (! is_array($scopes)) {
@@ -904,7 +911,7 @@ class PhotoController extends Controller
$path = "events/{$eventSlug}/photos/{$filename}";
// Store file
Storage::disk($disk)->put($path, file_get_contents($file->getRealPath()));
UploadStream::putUploadedFile($disk, $path, $file);
// Generate thumbnail
$thumbnailPath = "events/{$eventSlug}/thumbnails/{$filename}";
@@ -915,6 +922,7 @@ class PhotoController extends Controller
$photoAttributes = [
'event_id' => $event->id,
'guest_name' => Photo::SOURCE_TENANT_ADMIN,
'original_name' => $request->original_name,
'mime_type' => $file->getMimeType(),
'size' => $file->getSize(),

View File

@@ -0,0 +1,47 @@
<?php
namespace App\Http\Controllers\Api\Tenant;
use App\Http\Controllers\Controller;
use App\Http\Requests\Tenant\PhotoboothConnectCodeStoreRequest;
use App\Models\Event;
use App\Services\Photobooth\PhotoboothConnectCodeService;
use Illuminate\Http\JsonResponse;
class PhotoboothConnectCodeController extends Controller
{
public function __construct(private readonly PhotoboothConnectCodeService $service) {}
public function store(PhotoboothConnectCodeStoreRequest $request, Event $event): JsonResponse
{
$this->assertEventBelongsToTenant($request, $event);
$event->loadMissing('photoboothSetting');
$setting = $event->photoboothSetting;
if (! $setting || ! $setting->enabled || $setting->mode !== 'sparkbooth') {
return response()->json([
'message' => __('Photobooth muss im Sparkbooth-Modus aktiviert sein.'),
], 409);
}
$expiresInMinutes = $request->input('expires_in_minutes');
$result = $this->service->create($event, $expiresInMinutes ? (int) $expiresInMinutes : null);
return response()->json([
'data' => [
'code' => $result['code'],
'expires_at' => $result['expires_at']->toIso8601String(),
],
]);
}
protected function assertEventBelongsToTenant(PhotoboothConnectCodeStoreRequest $request, Event $event): void
{
$tenantId = (int) $request->attributes->get('tenant_id');
if ($tenantId !== (int) $event->tenant_id) {
abort(403, 'Event gehört nicht zu diesem Tenant.');
}
}
}

View File

@@ -193,11 +193,11 @@ class TenantAdminTokenController extends Controller
$abilities[] = 'tenant:'.$user->tenant_id;
}
if (in_array($user->role, ['tenant_admin', 'admin', 'super_admin'], true)) {
if (in_array($user->role, ['tenant_admin', 'admin', 'super_admin', 'superadmin'], true)) {
$abilities[] = 'tenant-admin';
}
if ($user->role === 'super_admin') {
if ($user->isSuperAdmin()) {
$abilities[] = 'super-admin';
}
@@ -219,7 +219,7 @@ class TenantAdminTokenController extends Controller
private function ensureUserCanAccessPanel(User $user): void
{
if (in_array($user->role, ['tenant_admin', 'admin', 'super_admin'], true)) {
if (in_array($user->role, ['tenant_admin', 'admin', 'super_admin', 'superadmin'], true)) {
return;
}

View File

@@ -9,8 +9,8 @@ use App\Models\User;
use App\Notifications\TenantFeedbackSubmitted;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Validation\Rule;
use Illuminate\Support\Facades\Notification;
use Illuminate\Validation\Rule;
class TenantFeedbackController extends Controller
{
@@ -56,7 +56,7 @@ class TenantFeedbackController extends Controller
]);
$recipients = User::query()
->where('role', 'super_admin')
->whereIn('role', ['super_admin', 'superadmin'])
->whereNotNull('email')
->get();

View File

@@ -0,0 +1,109 @@
<?php
namespace App\Http\Controllers\Api\TenantAuth;
use App\Http\Controllers\Controller;
use App\Http\Requests\Auth\TenantAdminForgotPasswordRequest;
use App\Http\Requests\Auth\TenantAdminResetPasswordRequest;
use App\Models\EventMember;
use App\Models\User;
use Illuminate\Auth\Events\PasswordReset;
use Illuminate\Http\JsonResponse;
use Illuminate\Support\Facades\Hash;
use Illuminate\Support\Facades\Password;
use Illuminate\Support\Str;
use Illuminate\Validation\ValidationException;
class TenantAdminPasswordResetController extends Controller
{
public function requestLink(TenantAdminForgotPasswordRequest $request): JsonResponse
{
$email = $request->string('email')->trim()->value();
$user = User::query()->where('email', $email)->first();
if (! $user || ! $this->canAccessEventAdmin($user)) {
return $this->genericSuccessResponse();
}
Password::sendResetLink([
'email' => $email,
]);
return $this->genericSuccessResponse();
}
public function reset(TenantAdminResetPasswordRequest $request): JsonResponse
{
$status = Password::reset(
$request->only('email', 'password', 'password_confirmation', 'token'),
function (User $user) use ($request) {
$this->ensureUserCanReset($user);
$user->forceFill([
'password' => Hash::make($request->string('password')->value()),
'remember_token' => Str::random(60),
])->save();
event(new PasswordReset($user));
}
);
if ($status === Password::PasswordReset) {
return response()->json([
'status' => __($status),
]);
}
throw ValidationException::withMessages([
'email' => [__($status)],
]);
}
private function genericSuccessResponse(): JsonResponse
{
return response()->json([
'status' => __('passwords.sent'),
]);
}
private function ensureUserCanReset(User $user): void
{
if ($this->canAccessEventAdmin($user)) {
return;
}
throw ValidationException::withMessages([
'email' => [trans('auth.not_authorized')],
]);
}
private function canAccessEventAdmin(User $user): bool
{
if (in_array($user->role, ['tenant_admin', 'admin', 'super_admin', 'superadmin'], true)) {
return true;
}
if ($user->role === 'member' && $this->userHasCollaboratorMembership($user)) {
return true;
}
return false;
}
private function userHasCollaboratorMembership(User $user): bool
{
if (! $user->tenant_id) {
return false;
}
return EventMember::query()
->where('tenant_id', $user->tenant_id)
->where(function ($query) use ($user) {
$query->where('user_id', $user->id)
->orWhere('email', $user->email);
})
->whereIn('status', ['active', 'invited'])
->exists();
}
}

View File

@@ -3,6 +3,7 @@
namespace App\Http\Controllers\Api;
use App\Http\Controllers\Controller;
use App\Models\EventPackage;
use App\Models\TenantPackage;
use App\Support\ApiError;
use Illuminate\Http\JsonResponse;
@@ -29,23 +30,108 @@ class TenantPackageController extends Controller
->orderBy('created_at', 'desc')
->get();
$packages->each(function ($package) {
$pkg = $package->package;
$package->remaining_events = $pkg->max_events_per_year - $package->used_events;
$package->package_limits = array_merge(
$pkg->limits,
[
'branding_allowed' => $pkg->branding_allowed,
'watermark_allowed' => $pkg->watermark_allowed,
'features' => $pkg->features,
]
);
$usageEventPackage = $this->resolveUsageEventPackage($tenant->id);
$packages->each(function (TenantPackage $package) use ($usageEventPackage): void {
$eventPackage = $package->active ? $usageEventPackage : null;
$this->hydratePackageSnapshot($package, $eventPackage);
});
$activePackage = $tenant->activeResellerPackage?->load('package');
if ($activePackage instanceof TenantPackage) {
$this->hydratePackageSnapshot($activePackage, $usageEventPackage);
}
return response()->json([
'data' => $packages,
'active_package' => $tenant->activeResellerPackage ? $tenant->activeResellerPackage->load('package') : null,
'active_package' => $activePackage,
'message' => 'Tenant packages loaded successfully.',
]);
}
private function hydratePackageSnapshot(TenantPackage $package, ?EventPackage $eventPackage = null): void
{
$pkg = $package->package;
$maxEvents = $pkg?->max_events_per_year;
$package->remaining_events = $maxEvents === null ? null : max($maxEvents - $package->used_events, 0);
$package->package_limits = array_merge(
$pkg?->limits ?? [],
$this->buildUsageSnapshot($eventPackage),
[
'branding_allowed' => $pkg?->branding_allowed,
'watermark_allowed' => $pkg?->watermark_allowed,
'features' => $pkg?->features ?? [],
]
);
}
/**
* @return Collection<int, EventPackage>
*/
private function resolveUsageEventPackage(int $tenantId): ?EventPackage
{
$baseQuery = EventPackage::query()
->whereHas('event', fn ($query) => $query->where('tenant_id', $tenantId))
->with('package')
->orderByDesc('purchased_at')
->orderByDesc('created_at');
$activeEventPackage = (clone $baseQuery)
->whereNotNull('gallery_expires_at')
->where('gallery_expires_at', '>=', now())
->first();
return $activeEventPackage ?? $baseQuery->first();
}
private function buildUsageSnapshot(?EventPackage $eventPackage): array
{
if (! $eventPackage) {
return [];
}
$limits = $eventPackage->effectiveLimits();
$maxPhotos = $this->normalizeLimit($limits['max_photos'] ?? null);
$maxGuests = $this->normalizeLimit($limits['max_guests'] ?? null);
$galleryDays = $this->normalizeLimit($limits['gallery_days'] ?? null);
$usedPhotos = (int) $eventPackage->used_photos;
$usedGuests = (int) $eventPackage->used_guests;
$remainingPhotos = $maxPhotos === null ? null : max(0, $maxPhotos - $usedPhotos);
$remainingGuests = $maxGuests === null ? null : max(0, $maxGuests - $usedGuests);
$remainingGalleryDays = null;
$usedGalleryDays = null;
if ($galleryDays !== null && $eventPackage->gallery_expires_at) {
$remainingGalleryDays = max(0, now()->diffInDays($eventPackage->gallery_expires_at, false));
$usedGalleryDays = max(0, $galleryDays - $remainingGalleryDays);
}
return array_filter([
'used_photos' => $maxPhotos === null ? null : $usedPhotos,
'remaining_photos' => $remainingPhotos,
'used_guests' => $maxGuests === null ? null : $usedGuests,
'remaining_guests' => $remainingGuests,
'used_gallery_days' => $usedGalleryDays,
'remaining_gallery_days' => $remainingGalleryDays,
], static fn ($value) => $value !== null);
}
private function normalizeLimit(?int $value): ?int
{
if ($value === null) {
return null;
}
$value = (int) $value;
if ($value <= 0) {
return null;
}
return $value;
}
}

View File

@@ -155,8 +155,8 @@ class AuthenticatedSessionController extends Controller
}
// Super admins go to Filament superadmin panel
if ($user && $user->role === 'super_admin') {
return '/admin';
if ($user && $user->isSuperAdmin()) {
return '/super-admin';
}
// Tenant admins go to their PWA dashboard

View File

@@ -3,153 +3,47 @@
namespace App\Http\Controllers\Auth;
use App\Http\Controllers\Controller;
use App\Models\Tenant;
use App\Models\User;
use Illuminate\Auth\Events\Registered;
use App\Support\LocaleConfig;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\App;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\Hash;
use Illuminate\Support\Facades\Mail;
use Illuminate\Support\Str;
use Illuminate\Validation\Rules;
use Inertia\Inertia;
use Inertia\Response;
class RegisteredUserController extends Controller
{
/**
* Show the registration page.
*/
public function create(Request $request): Response
public function create(Request $request): RedirectResponse
{
$package = $request->query('package_id') ? \App\Models\Package::find($request->query('package_id')) : null;
return Inertia::render('auth/register', [
'package' => $package,
]);
return $this->redirectToPackages($request);
}
/**
* Handle an incoming registration request.
*
* @throws \Illuminate\Validation\ValidationException
*/
public function store(Request $request)
public function store(Request $request): RedirectResponse|JsonResponse
{
$fullName = trim($request->first_name.' '.$request->last_name);
$validated = $request->validate([
'username' => ['required', 'string', 'max:255', 'unique:'.User::class],
'email' => ['required', 'string', 'lowercase', 'email', 'max:255', 'unique:'.User::class],
'password' => ['required', 'confirmed', Rules\Password::defaults()],
'first_name' => ['required', 'string', 'max:255'],
'last_name' => ['required', 'string', 'max:255'],
'address' => ['required', 'string', 'max:500'],
'phone' => ['required', 'string', 'max:20'],
'privacy_consent' => ['accepted'],
'package_id' => ['nullable', 'exists:packages,id'],
]);
$shouldAutoVerify = App::environment('local');
$user = User::create([
'username' => $validated['username'],
'email' => $validated['email'],
'first_name' => $validated['first_name'],
'last_name' => $validated['last_name'],
'address' => $validated['address'],
'phone' => $validated['phone'],
'password' => Hash::make($validated['password']),
'privacy_consent_at' => now(), // Neues Feld für Consent (füge Migration hinzu, falls nötig)
'role' => 'user',
]);
if ($shouldAutoVerify) {
$user->forceFill(['email_verified_at' => now()])->save();
if ($request->expectsJson()) {
return response()->json([
'message' => 'Registration is only available during checkout.',
], 410);
}
$tenant = Tenant::create([
'user_id' => $user->id,
'name' => $fullName,
'slug' => Str::slug($fullName.'-'.now()->timestamp),
'email' => $request->email,
'contact_email' => $request->email,
'is_active' => true,
'is_suspended' => false,
'subscription_tier' => 'free',
'subscription_expires_at' => null,
'settings' => json_encode([
'branding' => [
'logo_url' => null,
'primary_color' => '#3B82F6',
'secondary_color' => '#1F2937',
'font_family' => 'Inter, sans-serif',
],
'features' => [
'photo_likes_enabled' => false,
'event_checklist' => false,
'custom_domain' => false,
'advanced_analytics' => false,
],
'custom_domain' => null,
'contact_email' => $request->email,
'event_default_type' => 'general',
]),
]);
return $this->redirectToPackages($request);
}
if (! $user->tenant_id) {
$user->forceFill(['tenant_id' => $tenant->id])->save();
private function redirectToPackages(Request $request): RedirectResponse
{
$preferredLocale = $request->session()->get('preferred_locale')
?? $request->getPreferredLanguage(LocaleConfig::normalized());
$locale = LocaleConfig::canonicalize($request->route('locale') ?? $preferredLocale);
$packageId = $request->input('package_id');
$routeParams = ['locale' => $locale];
if ($packageId) {
$routeParams['package_id'] = $packageId;
}
event(new Registered($user));
// Send Welcome Email
Mail::to($user)
->locale($user->preferred_locale ?? app()->getLocale())
->send(new \App\Mail\Welcome($user));
if ($request->filled('package_id')) {
$package = \App\Models\Package::find($request->package_id);
if ($package && $package->price == 0) {
// Assign free package
\App\Models\TenantPackage::create([
'tenant_id' => $tenant->id,
'package_id' => $package->id,
'active' => true,
'price' => 0,
]);
\App\Models\PackagePurchase::create([
'tenant_id' => $tenant->id,
'package_id' => $package->id,
'type' => $package->type === 'endcustomer' ? 'endcustomer_event' : 'reseller_subscription',
'price' => 0,
'purchased_at' => now(),
'provider' => 'free',
'provider_id' => 'free',
]);
$tenant->update(['subscription_status' => 'active']);
$user->update(['role' => 'tenant_admin']);
Auth::login($user);
} elseif ($package) {
// Redirect to buy for paid package
return redirect()->route('buy.packages', [
'locale' => session('preferred_locale', app()->getLocale()),
'packageId' => $package->id,
]);
}
}
Auth::login($user);
if ($shouldAutoVerify) {
return Inertia::location(route('dashboard'));
}
session()->flash('status', 'registration-success');
return Inertia::location(route('verification.notice'));
return redirect()->route('packages', $routeParams);
}
}

View File

@@ -17,6 +17,7 @@ use App\Services\Checkout\CheckoutAssignmentService;
use App\Services\Checkout\CheckoutSessionService;
use App\Services\Paddle\Exceptions\PaddleException;
use App\Services\Paddle\PaddleTransactionService;
use App\Support\CheckoutRequestContext;
use App\Support\CheckoutRoutes;
use App\Support\Concerns\PresentsPackages;
use Illuminate\Http\JsonResponse;
@@ -81,12 +82,12 @@ class CheckoutController extends Controller
// User erstellen
$user = User::create([
'email' => $validated['email'],
'username' => $validated['username'],
'username' => Str::lower($validated['email']),
'first_name' => $validated['first_name'],
'last_name' => $validated['last_name'],
'name' => trim($validated['first_name'].' '.$validated['last_name']),
'address' => $validated['address'],
'phone' => $validated['phone'],
'address' => $validated['address'] ?? null,
'phone' => $validated['phone'] ?? null,
'preferred_locale' => $validated['locale'] ?? null,
'role' => 'user',
'password' => Hash::make($validated['password']),
@@ -226,10 +227,13 @@ class CheckoutController extends Controller
], 422);
}
$session = $sessions->createOrResume($user, $package, [
'tenant' => $user->tenant,
'locale' => $validated['locale'] ?? null,
]);
$session = $sessions->createOrResume($user, $package, array_merge(
CheckoutRequestContext::fromRequest($request),
[
'tenant' => $user->tenant,
'locale' => $validated['locale'] ?? null,
]
));
$sessions->selectProvider($session, CheckoutSession::PROVIDER_FREE);

View File

@@ -14,6 +14,8 @@ use App\Services\Checkout\CheckoutSessionService;
use App\Services\Coupons\CouponService;
use App\Services\GiftVouchers\GiftVoucherCheckoutService;
use App\Services\Paddle\PaddleCheckoutService;
use App\Support\CheckoutRequestContext;
use App\Support\CheckoutRoutes;
use App\Support\Concerns\PresentsPackages;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth;
@@ -150,7 +152,7 @@ class MarketingController extends Controller
$couponCode = $this->rememberCouponFromRequest($request, $package);
if (! Auth::check()) {
return redirect()->route('register', ['package_id' => $package->id, 'coupon' => $couponCode])
return redirect()->to(CheckoutRoutes::wizardUrl($package->id, $locale))
->with('message', __('marketing.packages.register_required'));
}
@@ -203,9 +205,12 @@ class MarketingController extends Controller
->with('error', __('marketing.packages.paddle_not_configured'));
}
$session = $this->checkoutSessions->createOrResume($user, $package, [
'tenant' => $tenant,
]);
$session = $this->checkoutSessions->createOrResume($user, $package, array_merge(
CheckoutRequestContext::fromRequest($request),
[
'tenant' => $tenant,
]
));
$this->checkoutSessions->selectProvider($session, CheckoutSession::PROVIDER_PADDLE);

View File

@@ -8,6 +8,7 @@ use App\Models\Package;
use App\Services\Checkout\CheckoutSessionService;
use App\Services\Coupons\CouponService;
use App\Services\Paddle\PaddleCheckoutService;
use App\Support\CheckoutRequestContext;
use Illuminate\Http\JsonResponse;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Str;
@@ -38,9 +39,12 @@ class PaddleCheckoutController extends Controller
throw ValidationException::withMessages(['package_id' => 'Package is not linked to a Paddle price.']);
}
$session = $this->sessions->createOrResume($user, $package, [
'tenant' => $tenant,
]);
$session = $this->sessions->createOrResume($user, $package, array_merge(
CheckoutRequestContext::fromRequest($request),
[
'tenant' => $tenant,
]
));
$this->sessions->selectProvider($session, CheckoutSession::PROVIDER_PADDLE);

View File

@@ -4,6 +4,7 @@ namespace App\Http\Controllers;
use App\Services\Addons\EventAddonWebhookService;
use App\Services\Checkout\CheckoutWebhookService;
use App\Services\Integrations\IntegrationWebhookRecorder;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Log;
@@ -14,6 +15,7 @@ class PaddleWebhookController extends Controller
public function __construct(
private readonly CheckoutWebhookService $webhooks,
private readonly EventAddonWebhookService $addonWebhooks,
private readonly IntegrationWebhookRecorder $recorder,
) {}
public function handle(Request $request): JsonResponse
@@ -32,6 +34,12 @@ class PaddleWebhookController extends Controller
}
$eventType = $payload['event_type'] ?? null;
$eventId = $payload['event_id'] ?? $payload['id'] ?? data_get($payload, 'data.id');
$webhookEvent = $this->recorder->recordReceived(
'paddle',
$eventId ? (string) $eventId : null,
$eventType ? (string) $eventType : null,
);
$handled = false;
$this->logDev('Paddle webhook received', [
@@ -53,6 +61,9 @@ class PaddleWebhookController extends Controller
]);
$statusCode = $handled ? Response::HTTP_OK : Response::HTTP_ACCEPTED;
$handled
? $this->recorder->markProcessed($webhookEvent, ['handled' => true])
: $this->recorder->markIgnored($webhookEvent, ['handled' => false]);
return response()->json([
'status' => $handled ? 'processed' : 'ignored',
@@ -68,6 +79,10 @@ class PaddleWebhookController extends Controller
$this->logDev('Paddle webhook error payload', $this->reducePayload($request->json()->all()));
if (isset($webhookEvent)) {
$this->recorder->markFailed($webhookEvent, $exception->getMessage());
}
return response()->json(['status' => 'error'], Response::HTTP_INTERNAL_SERVER_ERROR);
}
}

View File

@@ -2,6 +2,7 @@
namespace App\Http\Controllers;
use App\Enums\DataExportScope;
use App\Models\DataExport;
use Illuminate\Contracts\Auth\MustVerifyEmail;
use Illuminate\Http\Request;
@@ -45,6 +46,7 @@ class ProfileController extends Controller
->all();
$recentExports = $user->dataExports()
->where('scope', DataExportScope::USER->value)
->latest()
->limit(5)
->get()
@@ -61,6 +63,7 @@ class ProfileController extends Controller
]);
$pendingExport = $user->dataExports()
->where('scope', DataExportScope::USER->value)
->whereIn('status', [
DataExport::STATUS_PENDING,
DataExport::STATUS_PROCESSING,
@@ -68,6 +71,7 @@ class ProfileController extends Controller
->exists();
$lastReadyExport = $user->dataExports()
->where('scope', DataExportScope::USER->value)
->where('status', DataExport::STATUS_READY)
->latest('created_at')
->first();

View File

@@ -2,6 +2,7 @@
namespace App\Http\Controllers;
use App\Enums\DataExportScope;
use App\Jobs\GenerateDataExport;
use App\Models\DataExport;
use Illuminate\Http\RedirectResponse;
@@ -17,6 +18,7 @@ class ProfileDataExportController extends Controller
abort_unless($user, 403);
$hasRecentExport = $user->dataExports()
->where('scope', DataExportScope::USER->value)
->whereIn('status', [DataExport::STATUS_PENDING, DataExport::STATUS_PROCESSING])
->exists();
@@ -25,6 +27,7 @@ class ProfileDataExportController extends Controller
}
$recentReadyExport = $user->dataExports()
->where('scope', DataExportScope::USER->value)
->where('status', DataExport::STATUS_READY)
->where('created_at', '>=', now()->subDay())
->exists();
@@ -36,6 +39,8 @@ class ProfileDataExportController extends Controller
$export = $user->dataExports()->create([
'tenant_id' => $user->tenant_id,
'status' => DataExport::STATUS_PENDING,
'scope' => DataExportScope::USER->value,
'include_media' => false,
]);
GenerateDataExport::dispatch($export->id);

View File

@@ -3,6 +3,7 @@
namespace App\Http\Controllers;
use App\Jobs\ProcessRevenueCatWebhook;
use App\Services\Integrations\IntegrationWebhookRecorder;
use App\Support\ApiError;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
@@ -11,6 +12,8 @@ use Symfony\Component\HttpFoundation\Response;
class RevenueCatWebhookController extends Controller
{
public function __construct(private readonly IntegrationWebhookRecorder $recorder) {}
public function handle(Request $request): JsonResponse
{
$secret = (string) config('services.revenuecat.webhook', '');
@@ -61,9 +64,18 @@ class RevenueCatWebhookController extends Controller
);
}
$eventId = (string) $request->header('X-Event-Id', '');
$eventType = data_get($decoded, 'event.type');
$webhookEvent = $this->recorder->recordReceived(
'revenuecat',
$eventId !== '' ? $eventId : null,
is_string($eventType) && $eventType !== '' ? $eventType : null,
);
ProcessRevenueCatWebhook::dispatch(
$decoded,
(string) $request->header('X-Event-Id', '')
$eventId,
$webhookEvent->id,
);
return response()->json(['status' => 'accepted'], 202);

View File

@@ -0,0 +1,32 @@
<?php
namespace App\Http\Controllers\SuperAdmin;
use App\Http\Controllers\Controller;
use App\Models\DataExport;
use Illuminate\Support\Facades\Storage;
use Symfony\Component\HttpFoundation\StreamedResponse;
class DataExportController extends Controller
{
public function download(DataExport $export): StreamedResponse
{
if (! $export->isReady() || $export->hasExpired() || ! $export->path) {
abort(404);
}
$disk = 'local';
if (! Storage::disk($disk)->exists($export->path)) {
abort(404);
}
return Storage::disk($disk)->download(
$export->path,
sprintf('fotospiel-data-export-%s.zip', $export->created_at?->format('Ymd') ?? now()->format('Ymd')),
[
'Cache-Control' => 'private, no-store',
]
);
}
}

View File

@@ -12,7 +12,7 @@ class TenantAdminAuthController extends Controller
$user = Auth::user();
// Allow only tenant_admin and super_admin
if ($user && in_array($user->role, ['tenant_admin', 'super_admin'])) {
if ($user && in_array($user->role, ['tenant_admin', 'super_admin', 'superadmin'], true)) {
return view('admin');
}

View File

@@ -46,7 +46,7 @@ class TenantAdminGoogleController extends Controller
/** @var User|null $user */
$user = User::query()->where('email', $email)->first();
if (! $user || ! in_array($user->role, ['tenant_admin', 'super_admin'], true)) {
if (! $user || ! in_array($user->role, ['tenant_admin', 'super_admin', 'superadmin'], true)) {
return $this->sendBackWithError($request, 'google_no_match', 'No tenant admin account is linked to this Google address.');
}

View File

@@ -13,7 +13,7 @@ class TestCheckoutController extends Controller
{
public function latest(Request $request): JsonResponse
{
abort_unless(app()->environment(['local', 'testing']), 404);
abort_unless(config('e2e.testing_enabled'), 404);
$validated = $request->validate([
'email' => ['nullable', 'string', 'email'],
@@ -66,7 +66,7 @@ class TestCheckoutController extends Controller
CheckoutWebhookService $webhooks,
CheckoutSession $session
): JsonResponse {
abort_unless(app()->environment(['local', 'testing']), 404);
abort_unless(config('e2e.testing_enabled'), 404);
$validated = $request->validate([
'event_type' => ['nullable', 'string'],

View File

@@ -16,7 +16,7 @@ class TestCouponController extends Controller
{
public function store(Request $request): JsonResponse
{
abort_unless(app()->environment(['local', 'testing']), 404);
abort_unless(config('e2e.testing_enabled'), 404);
$payload = $request->input('coupons');
$definitions = collect(is_array($payload) ? $payload : [])

View File

@@ -15,7 +15,7 @@ class TestEventController extends Controller
{
public function joinToken(Request $request, EventJoinTokenService $tokens): JsonResponse
{
abort_unless(app()->environment(['local', 'testing']), 404);
abort_unless(config('e2e.testing_enabled'), 404);
$validated = $request->validate([
'event_id' => ['nullable', 'integer'],

View File

@@ -20,7 +20,7 @@ class TestGuestEventController extends Controller
{
public function store(Request $request, EventJoinTokenService $joinTokens): JsonResponse
{
abort_unless(app()->environment(['local', 'testing']), 404);
abort_unless(config('e2e.testing_enabled'), 404);
$validated = $request->validate([
'slug' => ['nullable', 'string', 'max:100'],

View File

@@ -10,7 +10,7 @@ class TestMailboxController extends Controller
{
public function index(): JsonResponse
{
abort_unless(app()->environment(['local', 'testing']), 404);
abort_unless(config('e2e.testing_enabled'), 404);
return response()->json([
'data' => Mailbox::all(),
@@ -19,7 +19,7 @@ class TestMailboxController extends Controller
public function destroy(): JsonResponse
{
abort_unless(app()->environment(['local', 'testing']), 404);
abort_unless(config('e2e.testing_enabled'), 404);
Mailbox::flush();

View File

@@ -2,6 +2,7 @@
namespace App\Http\Middleware;
use App\Support\CheckoutRoutes;
use Illuminate\Auth\Middleware\Authenticate as Middleware;
use Illuminate\Http\Request;
@@ -14,7 +15,10 @@ class Authenticate extends Middleware
}
if ($request->routeIs('buy.packages') && $request->route('packageId')) {
return route('register', ['package_id' => $request->route('packageId')]);
return CheckoutRoutes::wizardUrl(
$request->route('packageId'),
$request->route('locale')
);
}
return route('login');

View File

@@ -3,6 +3,7 @@
namespace App\Http\Middleware;
use App\Models\Tenant;
use App\Models\User;
use App\Services\Packages\PackageLimitEvaluator;
use App\Support\ApiError;
use Closure;
@@ -26,7 +27,7 @@ class CreditCheckMiddleware
]);
}
if ($this->requiresCredits($request)) {
if ($this->requiresCredits($request) && ! $this->shouldBypassCreditCheck($request, $tenant)) {
$violation = $this->limitEvaluator->assessEventCreation($tenant);
if ($violation !== null) {
@@ -43,6 +44,24 @@ class CreditCheckMiddleware
return $next($request);
}
private function shouldBypassCreditCheck(Request $request, Tenant $tenant): bool
{
$user = $request->user();
if (! $user instanceof User) {
return false;
}
if (! $user->isSuperAdmin()) {
return false;
}
if (! $user->tenant_id) {
return false;
}
return (int) $user->tenant_id === (int) $tenant->id;
}
private function requiresCredits(Request $request): bool
{
return $request->isMethod('post')

View File

@@ -0,0 +1,24 @@
<?php
namespace App\Http\Middleware;
use Closure;
use Illuminate\Http\Request;
use Symfony\Component\HttpFoundation\Response;
class EnsureE2ETestingAccess
{
public function handle(Request $request, Closure $next): Response
{
if (! config('e2e.testing_enabled')) {
abort(404);
}
$token = config('e2e.testing_token');
if ($token && $request->header('X-Testing-Token') !== $token) {
abort(404);
}
return $next($request);
}
}

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