Compare commits

...

141 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
Codex Agent
8f13465415 Implement tenant announcements and audit log fixes
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (push) Has been cancelled
tests / ui (push) Has been cancelled
2026-01-02 14:19:46 +01:00
Codex Agent
412ecbe691 Implement superadmin audit log for mutations
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (push) Has been cancelled
tests / ui (push) Has been cancelled
2026-01-02 11:57:49 +01:00
Codex Agent
8b4950c79d Refine ops health widget layout
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (push) Has been cancelled
tests / ui (push) Has been cancelled
2026-01-01 21:35:22 +01:00
Codex Agent
2fc8232d57 Add superadmin ops health dashboard
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (push) Has been cancelled
tests / ui (push) Has been cancelled
2026-01-01 21:07:33 +01:00
Codex Agent
6ca3c03179 Fix watermark settings form schema
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (push) Has been cancelled
tests / ui (push) Has been cancelled
2026-01-01 20:42:03 +01:00
Codex Agent
cc25c2b506 Fix guest policy form schema
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (push) Has been cancelled
tests / ui (push) Has been cancelled
2026-01-01 20:38:29 +01:00
Codex Agent
c180b37760 Add guest policy settings
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (push) Has been cancelled
tests / ui (push) Has been cancelled
2026-01-01 20:25:39 +01:00
Codex Agent
25d464215e Document superadmin control surface
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (push) Has been cancelled
tests / ui (push) Has been cancelled
2026-01-01 19:53:05 +01:00
Codex Agent
da06db2d3b Add tenant lifecycle view and limit controls
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (push) Has been cancelled
tests / ui (push) Has been cancelled
2026-01-01 19:36:51 +01:00
Codex Agent
117250879b Add superadmin moderation queues
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (push) Has been cancelled
tests / ui (push) Has been cancelled
2026-01-01 18:52:32 +01:00
Codex Agent
4fbd0815a4 Add bd wrapper to bypass fork protection
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (push) Has been cancelled
tests / ui (push) Has been cancelled
2026-01-01 17:40:31 +01:00
Codex Agent
fdd707de67 bd sync: 2026-01-01 17:24:36
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (push) Has been cancelled
tests / ui (push) Has been cancelled
2026-01-01 17:24:37 +01:00
544 changed files with 27737 additions and 35199 deletions

File diff suppressed because one or more lines are too long

View File

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

12
.gitignore vendored
View File

@@ -13,6 +13,8 @@ fotospiel-tenant-app
/storage/*.key /storage/*.key
/storage/pail /storage/pail
/vendor /vendor
/clients/photobooth-uploader/**/bin
/clients/photobooth-uploader/**/obj
.env .env
.env.backup .env.backup
.env.production .env.production
@@ -23,11 +25,9 @@ Homestead.yaml
npm-debug.log npm-debug.log
yarn-error.log yarn-error.log
/auth.json /auth.json
/.fleet
/.idea
/.nova
/.vscode /.vscode
/.zed
tools/git-askpass.ps1
podman-compose.dev.yml
test-results 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\Console\Concerns\InteractsWithCacheLocks;
use App\Jobs\ArchiveEventMediaAssets; use App\Jobs\ArchiveEventMediaAssets;
use App\Models\Event; use App\Models\Event;
use App\Services\Compliance\RetentionOverrideService;
use Illuminate\Console\Command; use Illuminate\Console\Command;
use Illuminate\Contracts\Cache\Lock; use Illuminate\Contracts\Cache\Lock;
use Illuminate\Support\Facades\Log; use Illuminate\Support\Facades\Log;
@@ -37,6 +38,7 @@ class DispatchStorageArchiveCommand extends Command
$maxDispatch = max(1, (int) config('storage-monitor.archive.max_dispatch', 100)); $maxDispatch = max(1, (int) config('storage-monitor.archive.max_dispatch', 100));
$eventId = $this->option('event'); $eventId = $this->option('event');
$dispatched = 0; $dispatched = 0;
$overrides = app(RetentionOverrideService::class);
try { try {
$query = Event::query() $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) { foreach ($events as $event) {
if ($dispatched >= $maxDispatch) { if ($dispatched >= $maxDispatch) {
return false; return false;
} }
if ($overrides->eventOnHold($event)) {
continue;
}
$eventLock = $this->acquireCommandLock('storage:archive-event-'.$event->id, $eventLockTtl); $eventLock = $this->acquireCommandLock('storage:archive-event-'.$event->id, $eventLockTtl);
if ($eventLock === false) { if ($eventLock === false) {
Log::channel('storage-jobs')->info('Archive dispatch skipped due to in-flight lock', [ Log::channel('storage-jobs')->info('Archive dispatch skipped due to in-flight lock', [

View File

@@ -0,0 +1,40 @@
<?php
namespace App\Console\Commands;
use App\Services\TenantAnnouncements\TenantAnnouncementService;
use Illuminate\Console\Command;
class DispatchTenantAnnouncements extends Command
{
/**
* The name and signature of the console command.
*
* @var string
*/
protected $signature = 'tenant-announcements:dispatch';
/**
* The console command description.
*
* @var string
*/
protected $description = 'Dispatch scheduled tenant announcements and queue email notifications';
/**
* Execute the console command.
*/
public function handle(TenantAnnouncementService $service): int
{
$result = $service->process();
$this->info(sprintf(
'Announcements: %d activated, %d archived, %d emails queued.',
$result['activated'],
$result['archived'],
$result['queued'],
));
return Command::SUCCESS;
}
}

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} {--package=* : Limit sync to the given package IDs or slugs}
{--dry-run : Generate payload snapshots without calling Paddle} {--dry-run : Generate payload snapshots without calling Paddle}
{--pull : Fetch remote Paddle state instead of pushing local changes} {--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}'; {--queue : Dispatch jobs onto the queue instead of running synchronously}';
protected $description = 'Synchronise local packages with Paddle products and prices.'; protected $description = 'Synchronise local packages with Paddle products and prices.';
@@ -32,6 +33,13 @@ class PaddleSyncPackages extends Command
$dryRun = (bool) $this->option('dry-run'); $dryRun = (bool) $this->option('dry-run');
$pull = (bool) $this->option('pull'); $pull = (bool) $this->option('pull');
$queue = (bool) $this->option('queue'); $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) { $packages->each(function (Package $package) use ($dryRun, $pull, $queue) {
if ($pull) { if ($pull) {
@@ -82,6 +90,42 @@ class PaddleSyncPackages extends Command
return $query->orderByDesc('id')->get(); 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 protected function dispatchSyncJob(Package $package, bool $dryRun, bool $queue): void
{ {
$context = [ $context = [

View File

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

View File

@@ -203,9 +203,20 @@ class SeedDemoSwitcherTenants extends Command
$this->upsertAdmin($tenant, 'starter-wedding@demo.fotospiel'); $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( $event = $this->upsertEvent(
tenant: $tenant, tenant: $tenant,
package: $packages['starter'], package: $packages['standard'],
eventType: $eventTypes['wedding'] ?? null, eventType: $eventTypes['wedding'] ?? null,
attributes: [ attributes: [
'name' => ['de' => 'Hochzeit Mia & Jonas', 'en' => 'Wedding Mia & Jonas'], '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,19 @@
<?php
namespace App\Enums;
enum TenantAnnouncementAudience: string
{
case ALL = 'all';
case TENANTS = 'tenants';
case SEGMENTS = 'segments';
public function label(): string
{
return match ($this) {
self::ALL => __('Alle Tenants'),
self::TENANTS => __('Ausgewählte Tenants'),
self::SEGMENTS => __('Segmente'),
};
}
}

View File

@@ -0,0 +1,19 @@
<?php
namespace App\Enums;
enum TenantAnnouncementDeliveryStatus: string
{
case QUEUED = 'queued';
case FAILED = 'failed';
case SKIPPED = 'skipped';
public function label(): string
{
return match ($this) {
self::QUEUED => __('Warteschlange'),
self::FAILED => __('Fehlgeschlagen'),
self::SKIPPED => __('Übersprungen'),
};
}
}

View File

@@ -0,0 +1,17 @@
<?php
namespace App\Enums;
enum TenantAnnouncementSegment: string
{
case ACTIVE_PACKAGE = 'active_package';
case ACTIVE_STATUS = 'active_status';
public function label(): string
{
return match ($this) {
self::ACTIVE_PACKAGE => __('Aktives Paket'),
self::ACTIVE_STATUS => __('Aktiv (nicht gesperrt/gelöscht)'),
};
}
}

View File

@@ -0,0 +1,21 @@
<?php
namespace App\Enums;
enum TenantAnnouncementStatus: string
{
case DRAFT = 'draft';
case SCHEDULED = 'scheduled';
case ACTIVE = 'active';
case ARCHIVED = 'archived';
public function label(): string
{
return match ($this) {
self::DRAFT => __('Entwurf'),
self::SCHEDULED => __('Geplant'),
self::ACTIVE => __('Aktiv'),
self::ARCHIVED => __('Archiviert'),
};
}
}

View File

@@ -6,6 +6,7 @@ use App\Filament\Blog\Resources\CategoryResource\Pages;
use App\Filament\Blog\Traits\HasContentEditor; use App\Filament\Blog\Traits\HasContentEditor;
use App\Filament\Clusters\RareAdmin\RareAdminCluster; use App\Filament\Clusters\RareAdmin\RareAdminCluster;
use App\Models\BlogCategory; use App\Models\BlogCategory;
use App\Services\Audit\SuperAdminAuditLogger;
use Filament\Actions\BulkActionGroup; use Filament\Actions\BulkActionGroup;
use Filament\Actions\DeleteBulkAction; use Filament\Actions\DeleteBulkAction;
use Filament\Actions\EditAction; use Filament\Actions\EditAction;
@@ -24,6 +25,7 @@ use Filament\Tables\Columns\IconColumn;
use Filament\Tables\Columns\TextColumn; use Filament\Tables\Columns\TextColumn;
use Filament\Tables\Table; use Filament\Tables\Table;
use Illuminate\Database\Eloquent\Builder; use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Collection;
use Illuminate\Database\Eloquent\SoftDeletingScope; use Illuminate\Database\Eloquent\SoftDeletingScope;
use Illuminate\Support\Str; use Illuminate\Support\Str;
@@ -116,38 +118,25 @@ class CategoryResource extends Resource
$data['description_de'] = $descArray['de'] ?? ''; $data['description_de'] = $descArray['de'] ?? '';
$data['description_en'] = $descArray['en'] ?? ''; $data['description_en'] = $descArray['en'] ?? '';
\Illuminate\Support\Facades\Log::info('BeforeFill Description Extraction:', [
'descJson' => $descJson,
'descArray' => $descArray,
'description_de' => $data['description_de'],
'description_en' => $data['description_en'],
]);
return $data; return $data;
} }
public static function mutateFormDataBeforeCreate(array $data): array public static function mutateFormDataBeforeCreate(array $data): array
{ {
\Illuminate\Support\Facades\Log::info('mutateFormDataBeforeCreate Input Data:', ['data' => $data]);
$nameData = [ $nameData = [
'de' => $data['name_de'] ?? '', 'de' => $data['name_de'] ?? '',
'en' => $data['name_en'] ?? '', 'en' => $data['name_en'] ?? '',
]; ];
$data['name'] = json_encode($nameData); $data['name'] = json_encode($nameData);
\Illuminate\Support\Facades\Log::info('mutateFormDataBeforeCreate Name JSON:', ['name' => $nameData]);
$descData = [ $descData = [
'de' => $data['description_de'] ?? '', 'de' => $data['description_de'] ?? '',
'en' => $data['description_en'] ?? '', 'en' => $data['description_en'] ?? '',
]; ];
$data['description'] = json_encode($descData); $data['description'] = json_encode($descData);
\Illuminate\Support\Facades\Log::info('mutateFormDataBeforeCreate Description JSON:', ['description' => $descData]);
unset($data['name_de'], $data['name_en'], $data['description_de'], $data['description_en']); unset($data['name_de'], $data['name_en'], $data['description_de'], $data['description_en']);
\Illuminate\Support\Facades\Log::info('mutateFormDataBeforeCreate Final Data:', $data);
return $data; return $data;
} }
@@ -185,11 +174,28 @@ class CategoryResource extends Resource
// //
]) ])
->actions([ ->actions([
EditAction::make(), EditAction::make()
->after(fn (array $data, BlogCategory $record) => app(SuperAdminAuditLogger::class)->recordModelMutation(
'updated',
$record,
SuperAdminAuditLogger::fieldsMetadata($data),
static::class
)),
]) ])
->bulkActions([ ->bulkActions([
BulkActionGroup::make([ BulkActionGroup::make([
DeleteBulkAction::make(), DeleteBulkAction::make()
->after(function (Collection $records): void {
$logger = app(SuperAdminAuditLogger::class);
foreach ($records as $record) {
$logger->recordModelMutation(
'deleted',
$record,
source: static::class
);
}
}),
]), ]),
]); ]);
} }

View File

@@ -3,9 +3,9 @@
namespace App\Filament\Blog\Resources\CategoryResource\Pages; namespace App\Filament\Blog\Resources\CategoryResource\Pages;
use App\Filament\Blog\Resources\CategoryResource; use App\Filament\Blog\Resources\CategoryResource;
use Filament\Resources\Pages\CreateRecord; use App\Filament\Resources\Pages\AuditedCreateRecord;
class CreateCategory extends CreateRecord class CreateCategory extends AuditedCreateRecord
{ {
protected static string $resource = CategoryResource::class; protected static string $resource = CategoryResource::class;

View File

@@ -3,17 +3,23 @@
namespace App\Filament\Blog\Resources\CategoryResource\Pages; namespace App\Filament\Blog\Resources\CategoryResource\Pages;
use App\Filament\Blog\Resources\CategoryResource; use App\Filament\Blog\Resources\CategoryResource;
use App\Filament\Resources\Pages\AuditedEditRecord;
use App\Services\Audit\SuperAdminAuditLogger;
use Filament\Actions; use Filament\Actions;
use Filament\Resources\Pages\EditRecord;
class EditCategory extends EditRecord class EditCategory extends AuditedEditRecord
{ {
protected static string $resource = CategoryResource::class; protected static string $resource = CategoryResource::class;
protected function getHeaderActions(): array protected function getHeaderActions(): array
{ {
return [ return [
Actions\DeleteAction::make(), Actions\DeleteAction::make()
->after(fn ($record) => app(SuperAdminAuditLogger::class)->recordModelMutation(
'deleted',
$record,
source: static::class
)),
]; ];
} }
@@ -24,7 +30,7 @@ class EditCategory extends EditRecord
'description_de' => 'nullable|string', 'description_de' => 'nullable|string',
'name_en' => 'nullable|string|max:255', 'name_en' => 'nullable|string|max:255',
'description_en' => 'nullable|string', 'description_en' => 'nullable|string',
'slug' => 'required|string|max:255|unique:blog_categories,slug,' . $this->record->id, 'slug' => 'required|string|max:255|unique:blog_categories,slug,'.$this->record->id,
'is_visible' => 'boolean', 'is_visible' => 'boolean',
]; ];
} }
@@ -40,12 +46,13 @@ class EditCategory extends EditRecord
public function save(bool $shouldRedirect = true, bool $shouldSendSavedNotification = true): void public function save(bool $shouldRedirect = true, bool $shouldSendSavedNotification = true): void
{ {
$state = $this->form->getState(); $state = $this->form->getState();
\Illuminate\Support\Facades\Log::info('EditCategory Save - Full State:', $state);
$data = $state['data'] ?? $state; $data = $state['data'] ?? $state;
$data = \App\Filament\Blog\Resources\CategoryResource::mutateFormDataBeforeSave($data); $data = \App\Filament\Blog\Resources\CategoryResource::mutateFormDataBeforeSave($data);
$this->record->update($data); $this->record->update($data);
parent::afterSave();
} }
} }

View File

@@ -7,6 +7,7 @@ use App\Filament\Blog\Traits\HasContentEditor;
use App\Filament\Clusters\RareAdmin\RareAdminCluster; use App\Filament\Clusters\RareAdmin\RareAdminCluster;
use App\Models\BlogCategory; use App\Models\BlogCategory;
use App\Models\BlogPost; use App\Models\BlogPost;
use App\Services\Audit\SuperAdminAuditLogger;
use Filament\Actions\BulkActionGroup; use Filament\Actions\BulkActionGroup;
use Filament\Actions\DeleteAction; use Filament\Actions\DeleteAction;
use Filament\Actions\DeleteBulkAction; use Filament\Actions\DeleteBulkAction;
@@ -29,6 +30,7 @@ use Filament\Tables\Columns\TextColumn;
use Filament\Tables\Filters\TernaryFilter; use Filament\Tables\Filters\TernaryFilter;
use Filament\Tables\Table; use Filament\Tables\Table;
use Illuminate\Database\Eloquent\Builder; use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Collection;
use Illuminate\Database\Eloquent\SoftDeletingScope; use Illuminate\Database\Eloquent\SoftDeletingScope;
use Illuminate\Support\Str; use Illuminate\Support\Str;
@@ -243,11 +245,27 @@ class PostResource extends Resource
->actions([ ->actions([
DeleteAction::make() DeleteAction::make()
->icon('heroicon-o-trash') ->icon('heroicon-o-trash')
->label(''), ->label('')
->after(fn (BlogPost $record) => app(SuperAdminAuditLogger::class)->recordModelMutation(
'deleted',
$record,
source: static::class
)),
]) ])
->bulkActions([ ->bulkActions([
BulkActionGroup::make([ BulkActionGroup::make([
DeleteBulkAction::make(), DeleteBulkAction::make()
->after(function (Collection $records): void {
$logger = app(SuperAdminAuditLogger::class);
foreach ($records as $record) {
$logger->recordModelMutation(
'deleted',
$record,
source: static::class
);
}
}),
]), ]),
]); ]);
} }

View File

@@ -3,9 +3,9 @@
namespace App\Filament\Blog\Resources\PostResource\Pages; namespace App\Filament\Blog\Resources\PostResource\Pages;
use App\Filament\Blog\Resources\PostResource; use App\Filament\Blog\Resources\PostResource;
use Filament\Resources\Pages\CreateRecord; use App\Filament\Resources\Pages\AuditedCreateRecord;
class CreatePost extends CreateRecord class CreatePost extends AuditedCreateRecord
{ {
protected static string $resource = PostResource::class; protected static string $resource = PostResource::class;
} }

View File

@@ -3,10 +3,11 @@
namespace App\Filament\Blog\Resources\PostResource\Pages; namespace App\Filament\Blog\Resources\PostResource\Pages;
use App\Filament\Blog\Resources\PostResource; use App\Filament\Blog\Resources\PostResource;
use App\Filament\Resources\Pages\AuditedEditRecord;
use App\Services\Audit\SuperAdminAuditLogger;
use Filament\Actions; use Filament\Actions;
use Filament\Resources\Pages\EditRecord;
class EditPost extends EditRecord class EditPost extends AuditedEditRecord
{ {
protected static string $resource = PostResource::class; protected static string $resource = PostResource::class;
@@ -14,7 +15,12 @@ class EditPost extends EditRecord
{ {
return [ return [
Actions\ViewAction::make(), Actions\ViewAction::make(),
Actions\DeleteAction::make(), Actions\DeleteAction::make()
->after(fn ($record) => app(SuperAdminAuditLogger::class)->recordModelMutation(
'deleted',
$record,
source: static::class
)),
]; ];
} }

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,11 @@
<?php
namespace App\Filament\Clusters\DailyOps\Resources\Photos\Pages;
use App\Filament\Clusters\DailyOps\Resources\Photos\PhotoResource;
use Filament\Resources\Pages\CreateRecord;
class CreatePhoto extends CreateRecord
{
protected static string $resource = PhotoResource::class;
}

View File

@@ -0,0 +1,27 @@
<?php
namespace App\Filament\Clusters\DailyOps\Resources\Photos\Pages;
use App\Filament\Clusters\DailyOps\Resources\Photos\PhotoResource;
use App\Filament\Resources\Pages\AuditedEditRecord;
use App\Services\Audit\SuperAdminAuditLogger;
use Filament\Actions\DeleteAction;
use Filament\Actions\ViewAction;
class EditPhoto extends AuditedEditRecord
{
protected static string $resource = PhotoResource::class;
protected function getHeaderActions(): array
{
return [
ViewAction::make(),
DeleteAction::make()
->after(fn ($record) => app(SuperAdminAuditLogger::class)->recordModelMutation(
'deleted',
$record,
source: static::class
)),
];
}
}

View File

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

View File

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

View File

@@ -0,0 +1,84 @@
<?php
namespace App\Filament\Clusters\DailyOps\Resources\Photos;
use App\Filament\Clusters\DailyOps\DailyOpsCluster;
use App\Filament\Clusters\DailyOps\Resources\Photos\Pages\ListPhotos;
use App\Filament\Clusters\DailyOps\Resources\Photos\Pages\ViewPhoto;
use App\Filament\Clusters\DailyOps\Resources\Photos\Schemas\PhotoForm;
use App\Filament\Clusters\DailyOps\Resources\Photos\Schemas\PhotoInfolist;
use App\Filament\Clusters\DailyOps\Resources\Photos\Tables\PhotosTable;
use App\Models\Photo;
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 PhotoResource extends Resource
{
protected static ?string $model = Photo::class;
protected static string|BackedEnum|null $navigationIcon = Heroicon::OutlinedShieldCheck;
protected static ?string $cluster = DailyOpsCluster::class;
protected static ?string $slug = 'moderation-queue';
protected static ?string $recordTitleAttribute = 'id';
protected static ?int $navigationSort = 20;
public static function canCreate(): bool
{
return false;
}
public static function form(Schema $schema): Schema
{
return PhotoForm::configure($schema);
}
public static function infolist(Schema $schema): Schema
{
return PhotoInfolist::configure($schema);
}
public static function table(Table $table): Table
{
return PhotosTable::configure($table);
}
public static function getEloquentQuery(): Builder
{
return parent::getEloquentQuery()
->with(['event.tenant', 'moderator']);
}
public static function getNavigationLabel(): string
{
return __('admin.moderation.navigation.label');
}
public static function getNavigationGroup(): UnitEnum|string|null
{
return __('admin.nav.curation');
}
public static function getRelations(): array
{
return [
//
];
}
public static function getPages(): array
{
return [
'index' => ListPhotos::route('/'),
'view' => ViewPhoto::route('/{record}'),
];
}
}

View File

@@ -0,0 +1,74 @@
<?php
namespace App\Filament\Clusters\DailyOps\Resources\Photos\Schemas;
use Filament\Forms\Components\DateTimePicker;
use Filament\Forms\Components\Select;
use Filament\Forms\Components\Textarea;
use Filament\Forms\Components\TextInput;
use Filament\Forms\Components\Toggle;
use Filament\Schemas\Schema;
class PhotoForm
{
public static function configure(Schema $schema): Schema
{
return $schema
->components([
Select::make('event_id')
->relationship('event', 'name')
->required(),
Select::make('emotion_id')
->relationship('emotion', 'name'),
Select::make('task_id')
->relationship('task', 'title'),
TextInput::make('guest_name')
->required(),
TextInput::make('file_path')
->required(),
TextInput::make('thumbnail_path')
->required(),
TextInput::make('likes_count')
->required()
->numeric()
->default(0),
Toggle::make('is_featured')
->required(),
Textarea::make('metadata')
->columnSpanFull(),
TextInput::make('tenant_id')
->numeric(),
Select::make('media_asset_id')
->relationship('mediaAsset', 'id'),
TextInput::make('security_scan_status')
->required()
->default('pending'),
Textarea::make('security_scan_message')
->columnSpanFull(),
DateTimePicker::make('security_scanned_at'),
Textarea::make('security_meta')
->columnSpanFull(),
TextInput::make('ingest_source')
->required()
->default('guest_pwa'),
TextInput::make('filename'),
TextInput::make('original_name'),
TextInput::make('mime_type'),
TextInput::make('size')
->numeric(),
TextInput::make('width')
->numeric(),
TextInput::make('height')
->numeric(),
TextInput::make('status')
->required()
->default('pending'),
TextInput::make('uploader_id')
->numeric(),
TextInput::make('ip_address'),
Textarea::make('user_agent')
->columnSpanFull(),
TextInput::make('created_by_device_id'),
]);
}
}

View File

@@ -0,0 +1,109 @@
<?php
namespace App\Filament\Clusters\DailyOps\Resources\Photos\Schemas;
use App\Models\Photo;
use Filament\Infolists\Components\ImageEntry;
use Filament\Infolists\Components\TextEntry;
use Filament\Schemas\Components\Section;
use Filament\Schemas\Schema;
class PhotoInfolist
{
public static function configure(Schema $schema): Schema
{
return $schema
->components([
Section::make(__('admin.moderation.sections.photo'))
->columns(3)
->schema([
ImageEntry::make('thumbnail_path')
->label(__('admin.moderation.fields.photo'))
->disk('public')
->visibility('public')
->getStateUsing(fn (Photo $record) => $record->thumbnail_path ?: $record->file_path)
->columnSpanFull(),
TextEntry::make('event.name')
->label(__('admin.moderation.fields.event'))
->placeholder('—'),
TextEntry::make('event.tenant.name')
->label(__('admin.moderation.fields.tenant'))
->placeholder('—'),
TextEntry::make('guest_name')
->label(__('admin.moderation.fields.uploader'))
->placeholder('—'),
TextEntry::make('created_at')
->label(__('admin.moderation.fields.uploaded_at'))
->since()
->placeholder('—'),
TextEntry::make('ingest_source')
->label(__('admin.moderation.fields.ingest_source'))
->formatStateUsing(fn (?string $state) => match ($state) {
Photo::SOURCE_GUEST_PWA => __('admin.moderation.ingest_sources.guest_pwa'),
Photo::SOURCE_TENANT_ADMIN => __('admin.moderation.ingest_sources.tenant_admin'),
Photo::SOURCE_PHOTOBOOTH => __('admin.moderation.ingest_sources.photobooth'),
Photo::SOURCE_SPARKBOOTH => __('admin.moderation.ingest_sources.sparkbooth'),
Photo::SOURCE_UNKNOWN => __('admin.moderation.ingest_sources.unknown'),
default => '—',
}),
]),
Section::make(__('admin.moderation.sections.moderation'))
->columns(2)
->schema([
TextEntry::make('status')
->label(__('admin.moderation.fields.status'))
->badge()
->color(fn (?string $state) => match ($state) {
'approved' => 'success',
'rejected' => 'danger',
'hidden' => 'gray',
default => 'warning',
})
->formatStateUsing(fn (?string $state) => match ($state) {
'pending' => __('admin.moderation.status.pending'),
'approved' => __('admin.moderation.status.approved'),
'rejected' => __('admin.moderation.status.rejected'),
'hidden' => __('admin.moderation.status.hidden'),
default => '—',
}),
TextEntry::make('moderator.name')
->label(__('admin.moderation.fields.moderated_by'))
->placeholder('—'),
TextEntry::make('moderated_at')
->label(__('admin.moderation.fields.moderated_at'))
->dateTime()
->placeholder('—'),
TextEntry::make('moderation_notes')
->label(__('admin.moderation.fields.moderation_notes'))
->placeholder('—')
->columnSpanFull(),
TextEntry::make('security_scan_status')
->label(__('admin.moderation.fields.security_scan_status'))
->badge()
->color(fn (?string $state) => match ($state) {
'clean', 'skipped', 'stripped' => 'success',
'infected' => 'danger',
'error' => 'warning',
default => 'gray',
})
->formatStateUsing(fn (?string $state) => match ($state) {
'pending' => __('admin.moderation.security_scan.pending'),
'clean' => __('admin.moderation.security_scan.clean'),
'infected' => __('admin.moderation.security_scan.infected'),
'skipped' => __('admin.moderation.security_scan.skipped'),
'stripped' => __('admin.moderation.security_scan.stripped'),
'error' => __('admin.moderation.security_scan.error'),
default => '—',
}),
TextEntry::make('security_scan_message')
->label(__('admin.moderation.fields.security_scan_message'))
->placeholder('—')
->columnSpanFull(),
TextEntry::make('security_scanned_at')
->label(__('admin.moderation.fields.security_scanned_at'))
->dateTime()
->placeholder('—'),
]),
]);
}
}

View File

@@ -0,0 +1,292 @@
<?php
namespace App\Filament\Clusters\DailyOps\Resources\Photos\Tables;
use App\Models\Event;
use App\Models\Photo;
use App\Models\Tenant;
use App\Services\Audit\SuperAdminAuditLogger;
use Filament\Actions\Action;
use Filament\Actions\BulkAction;
use Filament\Actions\BulkActionGroup;
use Filament\Actions\ViewAction;
use Filament\Facades\Filament;
use Filament\Forms\Components\DatePicker;
use Filament\Forms\Components\Textarea;
use Filament\Support\Icons\Heroicon;
use Filament\Tables;
use Filament\Tables\Columns\ImageColumn;
use Filament\Tables\Filters\Filter;
use Filament\Tables\Filters\SelectFilter;
use Filament\Tables\Table;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Collection;
class PhotosTable
{
public static function configure(Table $table): Table
{
return $table
->defaultSort('created_at', 'desc')
->columns([
ImageColumn::make('thumbnail_path')
->label(__('admin.moderation.table.photo'))
->disk('public')
->visibility('public')
->circular()
->getStateUsing(fn (Photo $record) => $record->thumbnail_path ?: $record->file_path),
Tables\Columns\TextColumn::make('event.name')
->label(__('admin.moderation.table.event'))
->searchable()
->limit(30),
Tables\Columns\TextColumn::make('event.tenant.name')
->label(__('admin.moderation.table.tenant'))
->limit(30),
Tables\Columns\TextColumn::make('guest_name')
->label(__('admin.moderation.table.uploader'))
->searchable()
->limit(20),
Tables\Columns\TextColumn::make('status')
->label(__('admin.moderation.table.status'))
->badge()
->color(fn (?string $state) => match ($state) {
'approved' => 'success',
'rejected' => 'danger',
'hidden' => 'gray',
default => 'warning',
})
->formatStateUsing(fn (?string $state) => self::statusLabels()[$state] ?? '—')
->sortable(),
Tables\Columns\TextColumn::make('security_scan_status')
->label(__('admin.moderation.table.security_scan'))
->badge()
->color(fn (?string $state) => match ($state) {
'clean', 'skipped', 'stripped' => 'success',
'infected' => 'danger',
'error' => 'warning',
default => 'gray',
})
->formatStateUsing(fn (?string $state) => self::securityScanLabels()[$state] ?? '—')
->toggleable(),
Tables\Columns\TextColumn::make('ingest_source')
->label(__('admin.moderation.table.ingest_source'))
->badge()
->color('gray')
->formatStateUsing(fn (?string $state) => self::ingestSourceLabels()[$state] ?? '—')
->toggleable(),
Tables\Columns\TextColumn::make('created_at')
->label(__('admin.moderation.table.uploaded_at'))
->since()
->sortable(),
Tables\Columns\TextColumn::make('moderator.name')
->label(__('admin.moderation.table.moderated_by'))
->placeholder('—')
->toggleable(isToggledHiddenByDefault: true),
Tables\Columns\TextColumn::make('moderated_at')
->label(__('admin.moderation.table.moderated_at'))
->since()
->placeholder('—')
->toggleable(isToggledHiddenByDefault: true),
])
->filters([
SelectFilter::make('status')
->label(__('admin.moderation.filters.status'))
->options(self::statusLabels())
->default('pending'),
SelectFilter::make('ingest_source')
->label(__('admin.moderation.filters.ingest_source'))
->options(self::ingestSourceLabels())
->default(Photo::SOURCE_GUEST_PWA),
SelectFilter::make('security_scan_status')
->label(__('admin.moderation.filters.security_scan_status'))
->options(self::securityScanLabels()),
SelectFilter::make('tenant_id')
->label(__('admin.common.tenant'))
->options(fn () => Tenant::query()->orderBy('name')->pluck('name', 'id')->toArray())
->searchable(),
SelectFilter::make('event_id')
->label(__('admin.common.event'))
->options(fn () => Event::query()->orderBy('name')->pluck('name', 'id')->toArray())
->searchable(),
Filter::make('created_at')
->label(__('admin.moderation.filters.uploaded_at'))
->form([
DatePicker::make('from')->label(__('admin.common.from')),
DatePicker::make('until')->label(__('admin.common.until')),
])
->query(function (Builder $query, array $data): Builder {
return $query
->when(
$data['from'] ?? null,
fn (Builder $query, string $date): Builder => $query->whereDate('created_at', '>=', $date)
)
->when(
$data['until'] ?? null,
fn (Builder $query, string $date): Builder => $query->whereDate('created_at', '<=', $date)
);
}),
])
->recordActions([
ViewAction::make(),
Action::make('approve')
->label(__('admin.moderation.actions.approve'))
->color('success')
->icon(Heroicon::OutlinedCheckCircle)
->visible(fn (Photo $record) => $record->status === 'pending')
->form([
self::moderationNotesField(false),
])
->requiresConfirmation()
->action(fn (Photo $record, array $data) => self::applyModeration($record, 'approved', $data['moderation_notes'] ?? null)),
Action::make('reject')
->label(__('admin.moderation.actions.reject'))
->color('danger')
->icon(Heroicon::OutlinedXCircle)
->visible(fn (Photo $record) => $record->status === 'pending')
->form([
self::moderationNotesField(true),
])
->requiresConfirmation()
->action(fn (Photo $record, array $data) => self::applyModeration($record, 'rejected', $data['moderation_notes'] ?? null)),
Action::make('hide')
->label(__('admin.moderation.actions.hide'))
->color('gray')
->icon(Heroicon::OutlinedEyeSlash)
->visible(fn (Photo $record) => $record->status !== 'hidden')
->form([
self::moderationNotesField(false),
])
->requiresConfirmation()
->action(fn (Photo $record, array $data) => self::applyModeration($record, 'hidden', $data['moderation_notes'] ?? null)),
])
->toolbarActions([
BulkActionGroup::make([
BulkAction::make('approve')
->label(__('admin.moderation.actions.approve_selected'))
->icon(Heroicon::OutlinedCheckCircle)
->color('success')
->form([
self::moderationNotesField(false),
])
->requiresConfirmation()
->action(fn (Collection $records, array $data) => self::applyModerationToRecords($records, 'approved', $data['moderation_notes'] ?? null)),
BulkAction::make('reject')
->label(__('admin.moderation.actions.reject_selected'))
->icon(Heroicon::OutlinedXCircle)
->color('danger')
->form([
self::moderationNotesField(true),
])
->requiresConfirmation()
->action(fn (Collection $records, array $data) => self::applyModerationToRecords($records, 'rejected', $data['moderation_notes'] ?? null)),
BulkAction::make('hide')
->label(__('admin.moderation.actions.hide_selected'))
->icon(Heroicon::OutlinedEyeSlash)
->color('gray')
->form([
self::moderationNotesField(false),
])
->requiresConfirmation()
->action(fn (Collection $records, array $data) => self::applyModerationToRecords($records, 'hidden', $data['moderation_notes'] ?? null)),
]),
]);
}
private static function moderationNotesField(bool $required): Textarea
{
return Textarea::make('moderation_notes')
->label(__('admin.moderation.fields.moderation_notes'))
->maxLength(1000)
->rows(3)
->required($required);
}
private static function applyModeration(Photo $record, string $status, ?string $notes): void
{
$record->update([
'status' => $status,
'moderation_notes' => $notes,
'moderated_at' => now(),
'moderated_by' => Filament::auth()->id(),
]);
app(SuperAdminAuditLogger::class)->record(
'photo.'.$status,
$record,
SuperAdminAuditLogger::fieldsMetadata([
'status',
'moderation_notes',
'moderated_at',
'moderated_by',
]),
source: self::class
);
}
private static function applyModerationToRecords(Collection $records, string $status, ?string $notes): int
{
$moderatedAt = now();
$moderatedBy = Filament::auth()->id();
$updated = Photo::query()
->whereIn('id', $records->pluck('id'))
->where('status', 'pending')
->update([
'status' => $status,
'moderation_notes' => $notes,
'moderated_at' => $moderatedAt,
'moderated_by' => $moderatedBy,
]);
$logger = app(SuperAdminAuditLogger::class);
foreach ($records as $record) {
$logger->record(
'photo.'.$status,
$record,
SuperAdminAuditLogger::fieldsMetadata([
'status',
'moderation_notes',
'moderated_at',
'moderated_by',
]),
source: self::class
);
}
return $updated;
}
private static function statusLabels(): array
{
return [
'pending' => __('admin.moderation.status.pending'),
'approved' => __('admin.moderation.status.approved'),
'rejected' => __('admin.moderation.status.rejected'),
'hidden' => __('admin.moderation.status.hidden'),
];
}
private static function ingestSourceLabels(): array
{
return [
Photo::SOURCE_GUEST_PWA => __('admin.moderation.ingest_sources.guest_pwa'),
Photo::SOURCE_TENANT_ADMIN => __('admin.moderation.ingest_sources.tenant_admin'),
Photo::SOURCE_PHOTOBOOTH => __('admin.moderation.ingest_sources.photobooth'),
Photo::SOURCE_SPARKBOOTH => __('admin.moderation.ingest_sources.sparkbooth'),
Photo::SOURCE_UNKNOWN => __('admin.moderation.ingest_sources.unknown'),
];
}
private static function securityScanLabels(): array
{
return [
'pending' => __('admin.moderation.security_scan.pending'),
'clean' => __('admin.moderation.security_scan.clean'),
'infected' => __('admin.moderation.security_scan.infected'),
'skipped' => __('admin.moderation.security_scan.skipped'),
'stripped' => __('admin.moderation.security_scan.stripped'),
'error' => __('admin.moderation.security_scan.error'),
];
}
}

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

@@ -0,0 +1,16 @@
<?php
namespace App\Filament\Clusters\RareAdmin\Resources\SuperAdminActionLogs\Pages;
use App\Filament\Clusters\RareAdmin\Resources\SuperAdminActionLogs\SuperAdminActionLogResource;
use Filament\Resources\Pages\ManageRecords;
class ManageSuperAdminActionLogs extends ManageRecords
{
protected static string $resource = SuperAdminActionLogResource::class;
protected function getHeaderActions(): array
{
return [];
}
}

View File

@@ -0,0 +1,123 @@
<?php
namespace App\Filament\Clusters\RareAdmin\Resources\SuperAdminActionLogs;
use App\Filament\Clusters\RareAdmin\RareAdminCluster;
use App\Filament\Clusters\RareAdmin\Resources\SuperAdminActionLogs\Pages\ManageSuperAdminActionLogs;
use App\Models\SuperAdminActionLog;
use BackedEnum;
use Filament\Resources\Resource;
use Filament\Schemas\Schema;
use Filament\Support\Icons\Heroicon;
use Filament\Tables\Columns\TextColumn;
use Filament\Tables\Table;
use UnitEnum;
class SuperAdminActionLogResource extends Resource
{
protected static ?string $model = SuperAdminActionLog::class;
protected static ?string $cluster = RareAdminCluster::class;
protected static string|BackedEnum|null $navigationIcon = Heroicon::OutlinedClipboardDocumentList;
protected static ?int $navigationSort = 95;
protected static ?string $recordTitleAttribute = 'action';
public static function getNavigationGroup(): UnitEnum|string|null
{
return __('admin.nav.infrastructure');
}
public static function getNavigationLabel(): string
{
return 'Audit log';
}
public static function canCreate(): bool
{
return false;
}
public static function canEdit($record): bool
{
return false;
}
public static function canDelete($record): bool
{
return false;
}
public static function canDeleteAny(): bool
{
return false;
}
public static function form(Schema $schema): Schema
{
return $schema
->components([
//
]);
}
public static function table(Table $table): Table
{
return $table
->recordTitleAttribute('action')
->columns([
TextColumn::make('action')
->badge()
->searchable()
->sortable(),
TextColumn::make('actor.fullName')
->label('Actor')
->sortable()
->searchable(),
TextColumn::make('subject_type')
->label('Subject')
->formatStateUsing(fn (?string $state) => $state ? class_basename($state) : '—')
->searchable()
->toggleable(),
TextColumn::make('subject_id')
->label('Subject ID')
->sortable()
->toggleable(),
TextColumn::make('metadata')
->label('Fields')
->formatStateUsing(function ($state): string {
if (! is_array($state)) {
return '—';
}
$fields = $state['fields'] ?? [];
return $fields ? implode(', ', $fields) : '—';
})
->toggleable(isToggledHiddenByDefault: true)
->wrap(),
TextColumn::make('source')
->label('Source')
->limit(40)
->toggleable(isToggledHiddenByDefault: true),
TextColumn::make('occurred_at')
->label('Timestamp')
->dateTime()
->sortable(),
])
->filters([
//
])
->recordActions([])
->toolbarActions([]);
}
public static function getPages(): array
{
return [
'index' => ManageSuperAdminActionLogs::route('/'),
];
}
}

View File

@@ -3,15 +3,17 @@
namespace App\Filament\Resources\Coupons\Pages; namespace App\Filament\Resources\Coupons\Pages;
use App\Filament\Resources\Coupons\CouponResource; use App\Filament\Resources\Coupons\CouponResource;
use App\Filament\Resources\Pages\AuditedCreateRecord;
use App\Jobs\SyncCouponToPaddle; use App\Jobs\SyncCouponToPaddle;
use Filament\Resources\Pages\CreateRecord;
class CreateCoupon extends CreateRecord class CreateCoupon extends AuditedCreateRecord
{ {
protected static string $resource = CouponResource::class; protected static string $resource = CouponResource::class;
protected function afterCreate(): void protected function afterCreate(): void
{ {
parent::afterCreate();
SyncCouponToPaddle::dispatch($this->record); SyncCouponToPaddle::dispatch($this->record);
} }
} }

View File

@@ -3,14 +3,15 @@
namespace App\Filament\Resources\Coupons\Pages; namespace App\Filament\Resources\Coupons\Pages;
use App\Filament\Resources\Coupons\CouponResource; use App\Filament\Resources\Coupons\CouponResource;
use App\Filament\Resources\Pages\AuditedEditRecord;
use App\Jobs\SyncCouponToPaddle; use App\Jobs\SyncCouponToPaddle;
use App\Services\Audit\SuperAdminAuditLogger;
use Filament\Actions\DeleteAction; use Filament\Actions\DeleteAction;
use Filament\Actions\ForceDeleteAction; use Filament\Actions\ForceDeleteAction;
use Filament\Actions\RestoreAction; use Filament\Actions\RestoreAction;
use Filament\Actions\ViewAction; use Filament\Actions\ViewAction;
use Filament\Resources\Pages\EditRecord;
class EditCoupon extends EditRecord class EditCoupon extends AuditedEditRecord
{ {
protected static string $resource = CouponResource::class; protected static string $resource = CouponResource::class;
@@ -19,14 +20,34 @@ class EditCoupon extends EditRecord
return [ return [
ViewAction::make(), ViewAction::make(),
DeleteAction::make() DeleteAction::make()
->after(fn ($record) => SyncCouponToPaddle::dispatch($record, true)), ->after(function ($record): void {
ForceDeleteAction::make(), app(SuperAdminAuditLogger::class)->recordModelMutation(
RestoreAction::make(), 'deleted',
$record,
source: static::class
);
SyncCouponToPaddle::dispatch($record, true);
}),
ForceDeleteAction::make()
->after(fn ($record) => app(SuperAdminAuditLogger::class)->recordModelMutation(
'force_deleted',
$record,
source: static::class
)),
RestoreAction::make()
->after(fn ($record) => app(SuperAdminAuditLogger::class)->recordModelMutation(
'restored',
$record,
source: static::class
)),
]; ];
} }
protected function afterSave(): void protected function afterSave(): void
{ {
parent::afterSave();
SyncCouponToPaddle::dispatch($this->record); SyncCouponToPaddle::dispatch($this->record);
} }
} }

View File

@@ -3,6 +3,7 @@
namespace App\Filament\Resources\Coupons\Pages; namespace App\Filament\Resources\Coupons\Pages;
use App\Filament\Resources\Coupons\CouponResource; use App\Filament\Resources\Coupons\CouponResource;
use App\Services\Audit\SuperAdminAuditLogger;
use Filament\Actions\EditAction; use Filament\Actions\EditAction;
use Filament\Resources\Pages\ViewRecord; use Filament\Resources\Pages\ViewRecord;
@@ -13,7 +14,13 @@ class ViewCoupon extends ViewRecord
protected function getHeaderActions(): array protected function getHeaderActions(): array
{ {
return [ return [
EditAction::make(), EditAction::make()
->after(fn (array $data, $record) => app(SuperAdminAuditLogger::class)->recordModelMutation(
'updated',
$record,
SuperAdminAuditLogger::fieldsMetadata($data),
static::class
)),
]; ];
} }
} }

View File

@@ -7,6 +7,7 @@ use Filament\Schemas\Schema;
use Filament\Tables\Columns\TextColumn; use Filament\Tables\Columns\TextColumn;
use Filament\Tables\Filters\SelectFilter; use Filament\Tables\Filters\SelectFilter;
use Filament\Tables\Table; use Filament\Tables\Table;
use Illuminate\Support\Str;
class RedemptionsRelationManager extends RelationManager class RedemptionsRelationManager extends RelationManager
{ {
@@ -25,6 +26,30 @@ class RedemptionsRelationManager extends RelationManager
TextColumn::make('tenant.name') TextColumn::make('tenant.name')
->label(__('Tenant')) ->label(__('Tenant'))
->searchable(), ->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') TextColumn::make('user.name')
->label(__('User')) ->label(__('User'))
->toggleable(isToggledHiddenByDefault: true), ->toggleable(isToggledHiddenByDefault: true),
@@ -69,4 +94,30 @@ class RedemptionsRelationManager extends RelationManager
->recordActions([]) ->recordActions([])
->toolbarActions([]); ->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; namespace App\Filament\Resources\Coupons\Schemas;
use App\Enums\CouponStatus;
use App\Enums\CouponType;
use Filament\Infolists\Components\KeyValueEntry; use Filament\Infolists\Components\KeyValueEntry;
use Filament\Infolists\Components\Section;
use Filament\Infolists\Components\TextEntry; use Filament\Infolists\Components\TextEntry;
use Filament\Schemas\Components\Section;
use Filament\Schemas\Schema; use Filament\Schemas\Schema;
use Illuminate\Support\Str; use Illuminate\Support\Str;
@@ -22,11 +24,11 @@ class CouponInfolist
TextEntry::make('status') TextEntry::make('status')
->label(__('Status')) ->label(__('Status'))
->badge() ->badge()
->formatStateUsing(fn ($state) => Str::headline($state)), ->formatStateUsing(fn ($state) => static::formatEnumState($state)),
TextEntry::make('type') TextEntry::make('type')
->label(__('Discount type')) ->label(__('Discount type'))
->badge() ->badge()
->formatStateUsing(fn ($state) => Str::headline($state)), ->formatStateUsing(fn ($state) => static::formatEnumState($state)),
TextEntry::make('amount') TextEntry::make('amount')
->label(__('Amount')) ->label(__('Amount'))
->formatStateUsing(fn ($state, $record) => $record?->type?->value === 'percentage' ->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

@@ -5,6 +5,7 @@ namespace App\Filament\Resources\Coupons\Tables;
use App\Enums\CouponStatus; use App\Enums\CouponStatus;
use App\Enums\CouponType; use App\Enums\CouponType;
use App\Jobs\SyncCouponToPaddle; use App\Jobs\SyncCouponToPaddle;
use App\Services\Audit\SuperAdminAuditLogger;
use Filament\Actions\Action; use Filament\Actions\Action;
use Filament\Actions\BulkActionGroup; use Filament\Actions\BulkActionGroup;
use Filament\Actions\DeleteBulkAction; use Filament\Actions\DeleteBulkAction;
@@ -18,6 +19,7 @@ use Filament\Tables\Filters\SelectFilter;
use Filament\Tables\Filters\TernaryFilter; use Filament\Tables\Filters\TernaryFilter;
use Filament\Tables\Filters\TrashedFilter; use Filament\Tables\Filters\TrashedFilter;
use Filament\Tables\Table; use Filament\Tables\Table;
use Illuminate\Database\Eloquent\Collection;
use Illuminate\Support\Str; use Illuminate\Support\Str;
class CouponsTable class CouponsTable
@@ -38,7 +40,7 @@ class CouponsTable
TextColumn::make('type') TextColumn::make('type')
->label(__('Type')) ->label(__('Type'))
->badge() ->badge()
->formatStateUsing(fn ($state) => Str::headline($state)) ->formatStateUsing(fn ($state) => static::formatEnumState($state))
->sortable(), ->sortable(),
TextColumn::make('amount') TextColumn::make('amount')
->label(__('Amount')) ->label(__('Amount'))
@@ -57,7 +59,7 @@ class CouponsTable
->label(__('Status')) ->label(__('Status'))
->badge() ->badge()
->sortable() ->sortable()
->formatStateUsing(fn ($state) => Str::headline($state)), ->formatStateUsing(fn ($state) => static::formatEnumState($state)),
TextColumn::make('starts_at') TextColumn::make('starts_at')
->label(__('Starts')) ->label(__('Starts'))
->date() ->date()
@@ -95,7 +97,13 @@ class CouponsTable
]) ])
->recordActions([ ->recordActions([
ViewAction::make(), ViewAction::make(),
EditAction::make(), EditAction::make()
->after(fn (array $data, $record) => app(SuperAdminAuditLogger::class)->recordModelMutation(
'updated',
$record,
SuperAdminAuditLogger::fieldsMetadata($data),
static::class
)),
Action::make('sync') Action::make('sync')
->label(__('Sync to Paddle')) ->label(__('Sync to Paddle'))
->icon('heroicon-m-arrow-path') ->icon('heroicon-m-arrow-path')
@@ -104,10 +112,60 @@ class CouponsTable
]) ])
->toolbarActions([ ->toolbarActions([
BulkActionGroup::make([ BulkActionGroup::make([
DeleteBulkAction::make(), DeleteBulkAction::make()
ForceDeleteBulkAction::make(), ->after(function (Collection $records): void {
RestoreBulkAction::make(), $logger = app(SuperAdminAuditLogger::class);
foreach ($records as $record) {
$logger->recordModelMutation(
'deleted',
$record,
source: static::class
);
}
}),
ForceDeleteBulkAction::make()
->after(function (Collection $records): void {
$logger = app(SuperAdminAuditLogger::class);
foreach ($records as $record) {
$logger->recordModelMutation(
'force_deleted',
$record,
source: static::class
);
}
}),
RestoreBulkAction::make()
->after(function (Collection $records): void {
$logger = app(SuperAdminAuditLogger::class);
foreach ($records as $record) {
$logger->recordModelMutation(
'restored',
$record,
source: static::class
);
}
}),
]), ]),
]); ]);
} }
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

@@ -5,6 +5,7 @@ namespace App\Filament\Resources;
use App\Filament\Clusters\WeeklyOps\WeeklyOpsCluster; use App\Filament\Clusters\WeeklyOps\WeeklyOpsCluster;
use App\Filament\Resources\EmotionResource\Pages; use App\Filament\Resources\EmotionResource\Pages;
use App\Models\Emotion; use App\Models\Emotion;
use App\Services\Audit\SuperAdminAuditLogger;
use BackedEnum; use BackedEnum;
use Filament\Actions; use Filament\Actions;
use Filament\Forms\Components\MarkdownEditor; use Filament\Forms\Components\MarkdownEditor;
@@ -17,6 +18,7 @@ use Filament\Schemas\Components\Tabs\Tab as SchemaTab;
use Filament\Schemas\Schema; use Filament\Schemas\Schema;
use Filament\Tables; use Filament\Tables;
use Filament\Tables\Table; use Filament\Tables\Table;
use Illuminate\Database\Eloquent\Collection;
use UnitEnum; use UnitEnum;
class EmotionResource extends Resource class EmotionResource extends Resource
@@ -116,10 +118,27 @@ class EmotionResource extends Resource
]) ])
->filters([]) ->filters([])
->actions([ ->actions([
Actions\EditAction::make(), Actions\EditAction::make()
->after(fn (array $data, Emotion $record) => app(SuperAdminAuditLogger::class)->recordModelMutation(
'updated',
$record,
SuperAdminAuditLogger::fieldsMetadata($data),
static::class
)),
]) ])
->bulkActions([ ->bulkActions([
Actions\DeleteBulkAction::make(), Actions\DeleteBulkAction::make()
->after(function (Collection $records): void {
$logger = app(SuperAdminAuditLogger::class);
foreach ($records as $record) {
$logger->recordModelMutation(
'deleted',
$record,
source: static::class
);
}
}),
]); ]);
} }

View File

@@ -3,6 +3,7 @@
namespace App\Filament\Resources\EmotionResource\Pages; namespace App\Filament\Resources\EmotionResource\Pages;
use App\Filament\Resources\EmotionResource; use App\Filament\Resources\EmotionResource;
use App\Services\Audit\SuperAdminAuditLogger;
use Filament\Actions; use Filament\Actions;
use Filament\Resources\Pages\ManageRecords; use Filament\Resources\Pages\ManageRecords;
@@ -13,7 +14,13 @@ class ManageEmotions extends ManageRecords
protected function getHeaderActions(): array protected function getHeaderActions(): array
{ {
return [ return [
Actions\CreateAction::make(), Actions\CreateAction::make()
->after(fn (array $data, $record) => app(SuperAdminAuditLogger::class)->recordModelMutation(
'created',
$record,
SuperAdminAuditLogger::fieldsMetadata($data),
static::class
)),
Actions\Action::make('import') Actions\Action::make('import')
->label(__('admin.common.import_csv')) ->label(__('admin.common.import_csv'))
->icon('heroicon-o-arrow-up-tray') ->icon('heroicon-o-arrow-up-tray')

View File

@@ -6,6 +6,7 @@ use App\Exports\EventPurchaseExporter;
use App\Filament\Clusters\WeeklyOps\WeeklyOpsCluster; use App\Filament\Clusters\WeeklyOps\WeeklyOpsCluster;
use App\Filament\Resources\EventPurchaseResource\Pages; use App\Filament\Resources\EventPurchaseResource\Pages;
use App\Models\EventPurchase; use App\Models\EventPurchase;
use App\Services\Audit\SuperAdminAuditLogger;
use Filament\Actions\Action; use Filament\Actions\Action;
use Filament\Actions\BulkActionGroup; use Filament\Actions\BulkActionGroup;
use Filament\Actions\DeleteBulkAction; use Filament\Actions\DeleteBulkAction;
@@ -23,6 +24,7 @@ use Filament\Tables\Filters\SelectFilter;
use Filament\Tables\Filters\TernaryFilter; use Filament\Tables\Filters\TernaryFilter;
use Filament\Tables\Table; use Filament\Tables\Table;
use Illuminate\Database\Eloquent\Builder; use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Collection;
use Illuminate\Support\Facades\Log; use Illuminate\Support\Facades\Log;
class EventPurchaseResource extends Resource class EventPurchaseResource extends Resource
@@ -174,11 +176,29 @@ class EventPurchaseResource extends Resource
->action(function (EventPurchase $record) { ->action(function (EventPurchase $record) {
$record->update(['refunded_at' => now()]); $record->update(['refunded_at' => now()]);
Log::info('Refund processed for purchase ID: '.$record->id); Log::info('Refund processed for purchase ID: '.$record->id);
app(SuperAdminAuditLogger::class)->record(
'event_purchase.refunded',
$record,
SuperAdminAuditLogger::fieldsMetadata(['refunded_at']),
source: static::class
);
}), }),
]) ])
->bulkActions([ ->bulkActions([
BulkActionGroup::make([ BulkActionGroup::make([
DeleteBulkAction::make(), DeleteBulkAction::make()
->after(function (Collection $records): void {
$logger = app(SuperAdminAuditLogger::class);
foreach ($records as $record) {
$logger->recordModelMutation(
'deleted',
$record,
source: static::class
);
}
}),
ExportBulkAction::make() ExportBulkAction::make()
->label('Export CSV') ->label('Export CSV')
->exporter(EventPurchaseExporter::class), ->exporter(EventPurchaseExporter::class),

View File

@@ -3,9 +3,9 @@
namespace App\Filament\Resources\EventPurchaseResource\Pages; namespace App\Filament\Resources\EventPurchaseResource\Pages;
use App\Filament\Resources\EventPurchaseResource; use App\Filament\Resources\EventPurchaseResource;
use Filament\Resources\Pages\CreateRecord; use App\Filament\Resources\Pages\AuditedCreateRecord;
class CreateEventPurchase extends CreateRecord class CreateEventPurchase extends AuditedCreateRecord
{ {
protected static string $resource = EventPurchaseResource::class; protected static string $resource = EventPurchaseResource::class;
} }

View File

@@ -3,10 +3,11 @@
namespace App\Filament\Resources\EventPurchaseResource\Pages; namespace App\Filament\Resources\EventPurchaseResource\Pages;
use App\Filament\Resources\EventPurchaseResource; use App\Filament\Resources\EventPurchaseResource;
use App\Filament\Resources\Pages\AuditedEditRecord;
use App\Services\Audit\SuperAdminAuditLogger;
use Filament\Actions; use Filament\Actions;
use Filament\Resources\Pages\EditRecord;
class EditEventPurchase extends EditRecord class EditEventPurchase extends AuditedEditRecord
{ {
protected static string $resource = EventPurchaseResource::class; protected static string $resource = EventPurchaseResource::class;
@@ -14,7 +15,12 @@ class EditEventPurchase extends EditRecord
{ {
return [ return [
Actions\ViewAction::make(), Actions\ViewAction::make(),
Actions\DeleteAction::make(), Actions\DeleteAction::make()
->after(fn ($record) => app(SuperAdminAuditLogger::class)->recordModelMutation(
'deleted',
$record,
source: static::class
)),
]; ];
} }
} }

View File

@@ -3,6 +3,7 @@
namespace App\Filament\Resources\EventPurchaseResource\Pages; namespace App\Filament\Resources\EventPurchaseResource\Pages;
use App\Filament\Resources\EventPurchaseResource; use App\Filament\Resources\EventPurchaseResource;
use App\Services\Audit\SuperAdminAuditLogger;
use Filament\Actions; use Filament\Actions;
use Filament\Resources\Pages\ViewRecord; use Filament\Resources\Pages\ViewRecord;
@@ -13,7 +14,13 @@ class ViewEventPurchase extends ViewRecord
protected function getHeaderActions(): array protected function getHeaderActions(): array
{ {
return [ return [
Actions\EditAction::make(), Actions\EditAction::make()
->after(fn (array $data, $record) => app(SuperAdminAuditLogger::class)->recordModelMutation(
'updated',
$record,
SuperAdminAuditLogger::fieldsMetadata($data),
static::class
)),
]; ];
} }
} }

View File

@@ -9,6 +9,7 @@ use App\Models\Event;
use App\Models\EventJoinTokenEvent; use App\Models\EventJoinTokenEvent;
use App\Models\EventType; use App\Models\EventType;
use App\Models\Tenant; use App\Models\Tenant;
use App\Services\Audit\SuperAdminAuditLogger;
use App\Support\JoinTokenLayoutRegistry; use App\Support\JoinTokenLayoutRegistry;
use BackedEnum; use BackedEnum;
use Carbon\Carbon; use Carbon\Carbon;
@@ -22,6 +23,7 @@ use Filament\Resources\Resource;
use Filament\Schemas\Schema; use Filament\Schemas\Schema;
use Filament\Tables; use Filament\Tables;
use Filament\Tables\Table; use Filament\Tables\Table;
use Illuminate\Database\Eloquent\Collection;
use UnitEnum; use UnitEnum;
class EventResource extends Resource class EventResource extends Resource
@@ -133,11 +135,26 @@ class EventResource extends Resource
]) ])
->filters([]) ->filters([])
->actions([ ->actions([
Actions\EditAction::make(), Actions\EditAction::make()
->after(fn (array $data, Event $record) => app(SuperAdminAuditLogger::class)->recordModelMutation(
'updated',
$record,
SuperAdminAuditLogger::fieldsMetadata($data),
static::class
)),
Actions\Action::make('toggle') Actions\Action::make('toggle')
->label(__('admin.events.actions.toggle_active')) ->label(__('admin.events.actions.toggle_active'))
->icon('heroicon-o-power') ->icon('heroicon-o-power')
->action(fn ($record) => $record->update(['is_active' => ! $record->is_active])), ->action(function (Event $record): void {
$record->update(['is_active' => ! $record->is_active]);
app(SuperAdminAuditLogger::class)->record(
'event.toggled',
$record,
SuperAdminAuditLogger::fieldsMetadata(['is_active']),
source: static::class
);
}),
Actions\Action::make('download_photos') Actions\Action::make('download_photos')
->label(__('admin.events.actions.download_photos')) ->label(__('admin.events.actions.download_photos'))
->icon('heroicon-o-arrow-down-tray') ->icon('heroicon-o-arrow-down-tray')
@@ -243,7 +260,18 @@ class EventResource extends Resource
}), }),
]) ])
->bulkActions([ ->bulkActions([
Actions\DeleteBulkAction::make(), Actions\DeleteBulkAction::make()
->after(function (Collection $records): void {
$logger = app(SuperAdminAuditLogger::class);
foreach ($records as $record) {
$logger->recordModelMutation(
'deleted',
$record,
source: static::class
);
}
}),
]); ]);
} }

View File

@@ -3,9 +3,9 @@
namespace App\Filament\Resources\EventResource\Pages; namespace App\Filament\Resources\EventResource\Pages;
use App\Filament\Resources\EventResource; use App\Filament\Resources\EventResource;
use Filament\Resources\Pages\CreateRecord; use App\Filament\Resources\Pages\AuditedCreateRecord;
class CreateEvent extends CreateRecord class CreateEvent extends AuditedCreateRecord
{ {
protected static string $resource = EventResource::class; protected static string $resource = EventResource::class;
} }

View File

@@ -3,9 +3,9 @@
namespace App\Filament\Resources\EventResource\Pages; namespace App\Filament\Resources\EventResource\Pages;
use App\Filament\Resources\EventResource; use App\Filament\Resources\EventResource;
use Filament\Resources\Pages\EditRecord; use App\Filament\Resources\Pages\AuditedEditRecord;
class EditEvent extends EditRecord class EditEvent extends AuditedEditRecord
{ {
protected static string $resource = EventResource::class; protected static string $resource = EventResource::class;
} }

View File

@@ -3,27 +3,41 @@
namespace App\Filament\Resources\EventResource\Pages; namespace App\Filament\Resources\EventResource\Pages;
use App\Filament\Resources\EventResource; use App\Filament\Resources\EventResource;
use App\Services\Audit\SuperAdminAuditLogger;
use Filament\Forms; use Filament\Forms;
use Filament\Notifications\Notification; use Filament\Notifications\Notification;
use Filament\Resources\Pages\Concerns\InteractsWithRecord;
use Filament\Resources\Pages\Page; use Filament\Resources\Pages\Page;
use Filament\Schemas\Components\Section;
use Filament\Schemas\Schema;
use Illuminate\Support\Arr; use Illuminate\Support\Arr;
class ManageWatermark extends Page class ManageWatermark extends Page
{ {
use InteractsWithRecord;
protected static string $resource = EventResource::class; protected static string $resource = EventResource::class;
protected string $view = 'filament.resources.event-resource.pages.manage-watermark'; protected string $view = 'filament.resources.event-resource.pages.manage-watermark';
public ?string $watermark_mode = 'base'; public ?string $watermark_mode = 'base';
public ?string $watermark_asset = null; public ?string $watermark_asset = null;
public string $watermark_position = 'bottom-right'; public string $watermark_position = 'bottom-right';
public float $watermark_opacity = 0.25; public float $watermark_opacity = 0.25;
public float $watermark_scale = 0.2; public float $watermark_scale = 0.2;
public int $watermark_padding = 16; public int $watermark_padding = 16;
public bool $serve_originals = false; public bool $serve_originals = false;
public function mount(): void public function mount(int|string $record): void
{ {
$this->record = $this->resolveRecord($record);
$event = $this->record; $event = $this->record;
$settings = $event->settings ?? []; $settings = $event->settings ?? [];
$watermark = Arr::get($settings, 'watermark', []); $watermark = Arr::get($settings, 'watermark', []);
@@ -37,13 +51,10 @@ class ManageWatermark extends Page
$this->serve_originals = (bool) Arr::get($settings, 'watermark_serve_originals', false); $this->serve_originals = (bool) Arr::get($settings, 'watermark_serve_originals', false);
} }
protected function getForms(): array public function form(Schema $schema): Schema
{ {
return [ return $schema->schema([
'form' => $this->form( Section::make(__('filament-watermark.heading'))
$this->makeForm()
->schema([
Forms\Components\Fieldset::make(__('filament-watermark.heading'))
->schema([ ->schema([
Forms\Components\Select::make('watermark_mode') Forms\Components\Select::make('watermark_mode')
->label(__('filament-watermark.mode.label')) ->label(__('filament-watermark.mode.label'))
@@ -95,9 +106,7 @@ class ManageWatermark extends Page
->default(false), ->default(false),
]) ])
->columns(2), ->columns(2),
]) ]);
),
];
} }
public function save(): void public function save(): void
@@ -133,6 +142,17 @@ class ManageWatermark extends Page
$event->forceFill(['settings' => $settings])->save(); $event->forceFill(['settings' => $settings])->save();
$changed = array_diff(array_keys($event->getChanges()), ['updated_at']);
if ($changed !== []) {
app(SuperAdminAuditLogger::class)->record(
'event.watermark_updated',
$event,
SuperAdminAuditLogger::fieldsMetadata($changed),
source: static::class
);
}
Notification::make() Notification::make()
->title(__('filament-watermark.saved')) ->title(__('filament-watermark.saved'))
->success() ->success()

View File

@@ -3,21 +3,21 @@
namespace App\Filament\Resources\EventResource\RelationManagers; namespace App\Filament\Resources\EventResource\RelationManagers;
use App\Models\EventPackage; use App\Models\EventPackage;
use App\Services\Audit\SuperAdminAuditLogger;
use Filament\Actions\BulkActionGroup; use Filament\Actions\BulkActionGroup;
use Filament\Actions\CreateAction; use Filament\Actions\CreateAction;
use Filament\Actions\DeleteAction; use Filament\Actions\DeleteAction;
use Filament\Actions\DeleteBulkAction; use Filament\Actions\DeleteBulkAction;
use Filament\Actions\EditAction; use Filament\Actions\EditAction;
use Filament\Forms;
use Filament\Forms\Components\DateTimePicker; use Filament\Forms\Components\DateTimePicker;
use Filament\Forms\Components\Select; use Filament\Forms\Components\Select;
use Filament\Forms\Components\TextInput; use Filament\Forms\Components\TextInput;
use Filament\Resources\RelationManagers\RelationManager; use Filament\Resources\RelationManagers\RelationManager;
use Filament\Schemas\Schema; use Filament\Schemas\Schema;
use Filament\Tables;
use Filament\Tables\Columns\TextColumn; use Filament\Tables\Columns\TextColumn;
use Filament\Tables\Table; use Filament\Tables\Table;
use Illuminate\Database\Eloquent\Builder; use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Collection;
use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\Relation; use Illuminate\Database\Eloquent\Relations\Relation;
@@ -93,15 +93,43 @@ class EventPackagesRelationManager extends RelationManager
]) ])
->filters([]) ->filters([])
->headerActions([ ->headerActions([
CreateAction::make(), CreateAction::make()
->after(fn (array $data, EventPackage $record) => app(SuperAdminAuditLogger::class)->recordModelMutation(
'created',
$record,
SuperAdminAuditLogger::fieldsMetadata($data),
static::class
)),
]) ])
->actions([ ->actions([
EditAction::make(), EditAction::make()
DeleteAction::make(), ->after(fn (array $data, EventPackage $record) => app(SuperAdminAuditLogger::class)->recordModelMutation(
'updated',
$record,
SuperAdminAuditLogger::fieldsMetadata($data),
static::class
)),
DeleteAction::make()
->after(fn (EventPackage $record) => app(SuperAdminAuditLogger::class)->recordModelMutation(
'deleted',
$record,
source: static::class
)),
]) ])
->bulkActions([ ->bulkActions([
BulkActionGroup::make([ BulkActionGroup::make([
DeleteBulkAction::make(), DeleteBulkAction::make()
->after(function (Collection $records): void {
$logger = app(SuperAdminAuditLogger::class);
foreach ($records as $record) {
$logger->recordModelMutation(
'deleted',
$record,
source: static::class
);
}
}),
]), ]),
]); ]);
} }

View File

@@ -5,6 +5,7 @@ namespace App\Filament\Resources;
use App\Filament\Clusters\WeeklyOps\WeeklyOpsCluster; use App\Filament\Clusters\WeeklyOps\WeeklyOpsCluster;
use App\Filament\Resources\EventTypeResource\Pages; use App\Filament\Resources\EventTypeResource\Pages;
use App\Models\EventType; use App\Models\EventType;
use App\Services\Audit\SuperAdminAuditLogger;
use BackedEnum; use BackedEnum;
use Filament\Actions; use Filament\Actions;
use Filament\Forms\Components\KeyValue; use Filament\Forms\Components\KeyValue;
@@ -16,6 +17,7 @@ use Filament\Schemas\Components\Tabs\Tab as SchemaTab;
use Filament\Schemas\Schema; use Filament\Schemas\Schema;
use Filament\Tables; use Filament\Tables;
use Filament\Tables\Table; use Filament\Tables\Table;
use Illuminate\Database\Eloquent\Collection;
use UnitEnum; use UnitEnum;
class EventTypeResource extends Resource class EventTypeResource extends Resource
@@ -104,10 +106,27 @@ class EventTypeResource extends Resource
]) ])
->filters([]) ->filters([])
->actions([ ->actions([
Actions\EditAction::make(), Actions\EditAction::make()
->after(fn (array $data, EventType $record) => app(SuperAdminAuditLogger::class)->recordModelMutation(
'updated',
$record,
SuperAdminAuditLogger::fieldsMetadata($data),
static::class
)),
]) ])
->bulkActions([ ->bulkActions([
Actions\DeleteBulkAction::make(), Actions\DeleteBulkAction::make()
->after(function (Collection $records): void {
$logger = app(SuperAdminAuditLogger::class);
foreach ($records as $record) {
$logger->recordModelMutation(
'deleted',
$record,
source: static::class
);
}
}),
]); ]);
} }

View File

@@ -3,6 +3,7 @@
namespace App\Filament\Resources\EventTypeResource\Pages; namespace App\Filament\Resources\EventTypeResource\Pages;
use App\Filament\Resources\EventTypeResource; use App\Filament\Resources\EventTypeResource;
use App\Services\Audit\SuperAdminAuditLogger;
use Filament\Actions; use Filament\Actions;
use Filament\Resources\Pages\ManageRecords; use Filament\Resources\Pages\ManageRecords;
@@ -13,7 +14,13 @@ class ManageEventTypes extends ManageRecords
protected function getHeaderActions(): array protected function getHeaderActions(): array
{ {
return [ return [
Actions\CreateAction::make(), Actions\CreateAction::make()
->after(fn (array $data, $record) => app(SuperAdminAuditLogger::class)->recordModelMutation(
'created',
$record,
SuperAdminAuditLogger::fieldsMetadata($data),
static::class
)),
]; ];
} }
} }

View File

@@ -5,6 +5,7 @@ namespace App\Filament\Resources;
use App\Filament\Clusters\WeeklyOps\WeeklyOpsCluster; use App\Filament\Clusters\WeeklyOps\WeeklyOpsCluster;
use App\Filament\Resources\GiftVoucherResource\Pages; use App\Filament\Resources\GiftVoucherResource\Pages;
use App\Models\GiftVoucher; use App\Models\GiftVoucher;
use App\Services\Audit\SuperAdminAuditLogger;
use App\Services\GiftVouchers\GiftVoucherService; use App\Services\GiftVouchers\GiftVoucherService;
use BackedEnum; use BackedEnum;
use Carbon\Carbon; use Carbon\Carbon;
@@ -97,6 +98,13 @@ class GiftVoucherResource extends Resource
->visible(fn (GiftVoucher $record): bool => $record->canBeRefunded()) ->visible(fn (GiftVoucher $record): bool => $record->canBeRefunded())
->action(function (GiftVoucher $record, GiftVoucherService $service): void { ->action(function (GiftVoucher $record, GiftVoucherService $service): void {
$service->refund($record, 'customer_request'); $service->refund($record, 'customer_request');
app(SuperAdminAuditLogger::class)->record(
'gift_voucher.refunded',
$record,
SuperAdminAuditLogger::fieldsMetadata(['status', 'refunded_at']),
source: static::class
);
}) })
->successNotificationTitle('Gutschein erstattet'), ->successNotificationTitle('Gutschein erstattet'),
Action::make('resend') Action::make('resend')
@@ -118,6 +126,13 @@ class GiftVoucherResource extends Resource
$record, $record,
Carbon::parse($data['recipient_delivery_scheduled_at']) Carbon::parse($data['recipient_delivery_scheduled_at'])
); );
app(SuperAdminAuditLogger::class)->record(
'gift_voucher.delivery_scheduled',
$record,
SuperAdminAuditLogger::fieldsMetadata($data),
source: static::class
);
}) })
->visible(fn (GiftVoucher $record): bool => ! empty($record->recipient_email)), ->visible(fn (GiftVoucher $record): bool => ! empty($record->recipient_email)),
Action::make('mark_redeemed') Action::make('mark_redeemed')
@@ -136,6 +151,13 @@ class GiftVoucherResource extends Resource
'manual_marked' => true, 'manual_marked' => true,
]), ]),
])->save(); ])->save();
app(SuperAdminAuditLogger::class)->record(
'gift_voucher.marked_redeemed',
$record,
SuperAdminAuditLogger::fieldsMetadata(['status', 'redeemed_at', 'metadata']),
source: static::class
);
}) })
->successNotificationTitle('Als eingelöst markiert'), ->successNotificationTitle('Als eingelöst markiert'),
]); ]);

View File

@@ -3,11 +3,12 @@
namespace App\Filament\Resources\GiftVoucherResource\Pages; namespace App\Filament\Resources\GiftVoucherResource\Pages;
use App\Filament\Resources\GiftVoucherResource; use App\Filament\Resources\GiftVoucherResource;
use App\Services\Audit\SuperAdminAuditLogger;
use App\Services\GiftVouchers\GiftVoucherService; use App\Services\GiftVouchers\GiftVoucherService;
use Filament\Actions\Action; use Filament\Actions\Action;
use Filament\Resources\Pages\ListRecords;
use Filament\Forms\Components\TextInput;
use Filament\Forms\Components\Placeholder; use Filament\Forms\Components\Placeholder;
use Filament\Forms\Components\TextInput;
use Filament\Resources\Pages\ListRecords;
use Illuminate\Support\Str; use Illuminate\Support\Str;
class ListGiftVouchers extends ListRecords class ListGiftVouchers extends ListRecords
@@ -62,7 +63,20 @@ class ListGiftVouchers extends ListRecords
], ],
]; ];
$service->issueFromPaddle($payload); $voucher = $service->issueFromPaddle($payload);
app(SuperAdminAuditLogger::class)->recordModelMutation(
'issued',
$voucher,
SuperAdminAuditLogger::fieldsMetadata([
'amount',
'currency',
'status',
'expires_at',
'coupon_id',
]),
source: static::class
);
}) })
->modalHeading('Geschenkgutschein ausstellen'), ->modalHeading('Geschenkgutschein ausstellen'),
]; ];

View File

@@ -5,6 +5,7 @@ namespace App\Filament\Resources;
use App\Filament\Clusters\RareAdmin\RareAdminCluster; use App\Filament\Clusters\RareAdmin\RareAdminCluster;
use App\Filament\Resources\LegalPageResource\Pages; use App\Filament\Resources\LegalPageResource\Pages;
use App\Models\LegalPage; use App\Models\LegalPage;
use App\Services\Audit\SuperAdminAuditLogger;
use BackedEnum; use BackedEnum;
use Filament\Actions; use Filament\Actions;
use Filament\Forms\Components\DatePicker; use Filament\Forms\Components\DatePicker;
@@ -18,6 +19,7 @@ use Filament\Schemas\Components\Tabs\Tab as SchemaTab;
use Filament\Schemas\Schema; use Filament\Schemas\Schema;
use Filament\Tables; use Filament\Tables;
use Filament\Tables\Table; use Filament\Tables\Table;
use Illuminate\Database\Eloquent\Collection;
use UnitEnum; use UnitEnum;
class LegalPageResource extends Resource class LegalPageResource extends Resource
@@ -99,10 +101,27 @@ class LegalPageResource extends Resource
]) ])
->filters([]) ->filters([])
->actions([ ->actions([
Actions\EditAction::make(), Actions\EditAction::make()
->after(fn (array $data, LegalPage $record) => app(SuperAdminAuditLogger::class)->recordModelMutation(
'updated',
$record,
SuperAdminAuditLogger::fieldsMetadata($data),
static::class
)),
]) ])
->bulkActions([ ->bulkActions([
Actions\DeleteBulkAction::make(), Actions\DeleteBulkAction::make()
->after(function (Collection $records): void {
$logger = app(SuperAdminAuditLogger::class);
foreach ($records as $record) {
$logger->recordModelMutation(
'deleted',
$record,
source: static::class
);
}
}),
]); ]);
} }

View File

@@ -3,9 +3,9 @@
namespace App\Filament\Resources\LegalPageResource\Pages; namespace App\Filament\Resources\LegalPageResource\Pages;
use App\Filament\Resources\LegalPageResource; use App\Filament\Resources\LegalPageResource;
use Filament\Resources\Pages\EditRecord; use App\Filament\Resources\Pages\AuditedEditRecord;
class EditLegalPage extends EditRecord class EditLegalPage extends AuditedEditRecord
{ {
protected static string $resource = LegalPageResource::class; protected static string $resource = LegalPageResource::class;
} }

View File

@@ -5,6 +5,7 @@ namespace App\Filament\Resources;
use App\Filament\Clusters\RareAdmin\RareAdminCluster; use App\Filament\Clusters\RareAdmin\RareAdminCluster;
use App\Filament\Resources\MediaStorageTargetResource\Pages; use App\Filament\Resources\MediaStorageTargetResource\Pages;
use App\Models\MediaStorageTarget; use App\Models\MediaStorageTarget;
use App\Services\Audit\SuperAdminAuditLogger;
use BackedEnum; use BackedEnum;
use Filament\Actions; use Filament\Actions;
use Filament\Forms\Components\KeyValue; use Filament\Forms\Components\KeyValue;
@@ -15,6 +16,7 @@ use Filament\Resources\Resource;
use Filament\Schemas\Schema; use Filament\Schemas\Schema;
use Filament\Tables; use Filament\Tables;
use Filament\Tables\Table; use Filament\Tables\Table;
use Illuminate\Database\Eloquent\Collection;
use UnitEnum; use UnitEnum;
class MediaStorageTargetResource extends Resource class MediaStorageTargetResource extends Resource
@@ -115,10 +117,27 @@ class MediaStorageTargetResource extends Resource
]) ])
->filters([]) ->filters([])
->actions([ ->actions([
Actions\EditAction::make(), Actions\EditAction::make()
->after(fn (array $data, MediaStorageTarget $record) => app(SuperAdminAuditLogger::class)->recordModelMutation(
'updated',
$record,
SuperAdminAuditLogger::fieldsMetadata($data),
static::class
)),
]) ])
->bulkActions([ ->bulkActions([
Actions\DeleteBulkAction::make(), Actions\DeleteBulkAction::make()
->after(function (Collection $records): void {
$logger = app(SuperAdminAuditLogger::class);
foreach ($records as $record) {
$logger->recordModelMutation(
'deleted',
$record,
source: static::class
);
}
}),
]); ]);
} }

View File

@@ -3,10 +3,9 @@
namespace App\Filament\Resources\MediaStorageTargetResource\Pages; namespace App\Filament\Resources\MediaStorageTargetResource\Pages;
use App\Filament\Resources\MediaStorageTargetResource; use App\Filament\Resources\MediaStorageTargetResource;
use Filament\Resources\Pages\CreateRecord; use App\Filament\Resources\Pages\AuditedCreateRecord;
class CreateMediaStorageTarget extends CreateRecord class CreateMediaStorageTarget extends AuditedCreateRecord
{ {
protected static string $resource = MediaStorageTargetResource::class; protected static string $resource = MediaStorageTargetResource::class;
} }

View File

@@ -3,18 +3,23 @@
namespace App\Filament\Resources\MediaStorageTargetResource\Pages; namespace App\Filament\Resources\MediaStorageTargetResource\Pages;
use App\Filament\Resources\MediaStorageTargetResource; use App\Filament\Resources\MediaStorageTargetResource;
use App\Filament\Resources\Pages\AuditedEditRecord;
use App\Services\Audit\SuperAdminAuditLogger;
use Filament\Actions; use Filament\Actions;
use Filament\Resources\Pages\EditRecord;
class EditMediaStorageTarget extends EditRecord class EditMediaStorageTarget extends AuditedEditRecord
{ {
protected static string $resource = MediaStorageTargetResource::class; protected static string $resource = MediaStorageTargetResource::class;
protected function getHeaderActions(): array protected function getHeaderActions(): array
{ {
return [ return [
Actions\DeleteAction::make(), Actions\DeleteAction::make()
->after(fn ($record) => app(SuperAdminAuditLogger::class)->recordModelMutation(
'deleted',
$record,
source: static::class
)),
]; ];
} }
} }

View File

@@ -6,6 +6,7 @@ use App\Filament\Clusters\WeeklyOps\WeeklyOpsCluster;
use App\Filament\Resources\PackageAddonResource\Pages; use App\Filament\Resources\PackageAddonResource\Pages;
use App\Jobs\SyncPackageAddonToPaddle; use App\Jobs\SyncPackageAddonToPaddle;
use App\Models\PackageAddon; use App\Models\PackageAddon;
use App\Services\Audit\SuperAdminAuditLogger;
use Filament\Actions; use Filament\Actions;
use Filament\Forms\Components\TextInput; use Filament\Forms\Components\TextInput;
use Filament\Forms\Components\Toggle; use Filament\Forms\Components\Toggle;
@@ -17,6 +18,7 @@ use Filament\Tables;
use Filament\Tables\Columns\BadgeColumn; use Filament\Tables\Columns\BadgeColumn;
use Filament\Tables\Columns\TextColumn; use Filament\Tables\Columns\TextColumn;
use Filament\Tables\Table; use Filament\Tables\Table;
use Illuminate\Database\Eloquent\Collection;
class PackageAddonResource extends Resource class PackageAddonResource extends Resource
{ {
@@ -130,11 +132,28 @@ class PackageAddonResource extends Resource
->body('Das Add-on wird im Hintergrund mit Paddle abgeglichen.') ->body('Das Add-on wird im Hintergrund mit Paddle abgeglichen.')
->send(); ->send();
}), }),
Actions\EditAction::make(), Actions\EditAction::make()
->after(fn (array $data, PackageAddon $record) => app(SuperAdminAuditLogger::class)->recordModelMutation(
'updated',
$record,
SuperAdminAuditLogger::fieldsMetadata($data),
static::class
)),
]) ])
->bulkActions([ ->bulkActions([
Actions\BulkActionGroup::make([ Actions\BulkActionGroup::make([
Actions\DeleteBulkAction::make(), Actions\DeleteBulkAction::make()
->after(function (Collection $records): void {
$logger = app(SuperAdminAuditLogger::class);
foreach ($records as $record) {
$logger->recordModelMutation(
'deleted',
$record,
source: static::class
);
}
}),
]), ]),
]); ]);
} }

View File

@@ -3,9 +3,9 @@
namespace App\Filament\Resources\PackageAddonResource\Pages; namespace App\Filament\Resources\PackageAddonResource\Pages;
use App\Filament\Resources\PackageAddonResource; use App\Filament\Resources\PackageAddonResource;
use Filament\Resources\Pages\CreateRecord; use App\Filament\Resources\Pages\AuditedCreateRecord;
class CreatePackageAddon extends CreateRecord class CreatePackageAddon extends AuditedCreateRecord
{ {
protected static string $resource = PackageAddonResource::class; protected static string $resource = PackageAddonResource::class;
} }

View File

@@ -3,9 +3,9 @@
namespace App\Filament\Resources\PackageAddonResource\Pages; namespace App\Filament\Resources\PackageAddonResource\Pages;
use App\Filament\Resources\PackageAddonResource; use App\Filament\Resources\PackageAddonResource;
use Filament\Resources\Pages\EditRecord; use App\Filament\Resources\Pages\AuditedEditRecord;
class EditPackageAddon extends EditRecord class EditPackageAddon extends AuditedEditRecord
{ {
protected static string $resource = PackageAddonResource::class; protected static string $resource = PackageAddonResource::class;

View File

@@ -7,6 +7,7 @@ use App\Filament\Resources\PackageResource\Pages;
use App\Jobs\PullPackageFromPaddle; use App\Jobs\PullPackageFromPaddle;
use App\Jobs\SyncPackageToPaddle; use App\Jobs\SyncPackageToPaddle;
use App\Models\Package; use App\Models\Package;
use App\Services\Audit\SuperAdminAuditLogger;
use BackedEnum; use BackedEnum;
use Filament\Actions; use Filament\Actions;
use Filament\Actions\BulkActionGroup; use Filament\Actions\BulkActionGroup;
@@ -37,6 +38,7 @@ use Filament\Tables\Columns\TextColumn;
use Filament\Tables\Filters\TrashedFilter; use Filament\Tables\Filters\TrashedFilter;
use Filament\Tables\Table; use Filament\Tables\Table;
use Illuminate\Database\Eloquent\Builder; use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Collection;
use Illuminate\Database\Eloquent\SoftDeletingScope; use Illuminate\Database\Eloquent\SoftDeletingScope;
use Illuminate\Support\Str; use Illuminate\Support\Str;
use Illuminate\Validation\Rules\Unique; use Illuminate\Validation\Rules\Unique;
@@ -191,6 +193,11 @@ class PackageResource extends Resource
->label('Zuletzt synchronisiert') ->label('Zuletzt synchronisiert')
->content(fn (?Package $record) => $record?->paddle_synced_at ? $record->paddle_synced_at->diffForHumans() : '') ->content(fn (?Package $record) => $record?->paddle_synced_at ? $record->paddle_synced_at->diffForHumans() : '')
->columnSpanFull(), ->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(),
]), ]),
]); ]);
} }
@@ -269,7 +276,7 @@ class PackageResource extends Resource
->colors([ ->colors([
'success' => 'synced', 'success' => 'synced',
'warning' => 'syncing', 'warning' => 'syncing',
'info' => 'dry-run', 'info' => ['dry-run', 'linked', 'pulled'],
'danger' => ['failed', 'pull-failed'], 'danger' => ['failed', 'pull-failed'],
]) ])
->formatStateUsing(fn ($state) => $state ? Str::headline($state) : null) ->formatStateUsing(fn ($state) => $state ? Str::headline($state) : null)
@@ -278,6 +285,11 @@ class PackageResource extends Resource
->label('Sync am') ->label('Sync am')
->dateTime() ->dateTime()
->toggleable(isToggledHiddenByDefault: true), ->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([ ->filters([
Tables\Filters\SelectFilter::make('type') Tables\Filters\SelectFilter::make('type')
@@ -304,6 +316,42 @@ class PackageResource extends Resource
->body('Das Paket wird im Hintergrund mit Paddle abgeglichen.') ->body('Das Paket wird im Hintergrund mit Paddle abgeglichen.')
->send(); ->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') Actions\Action::make('pullPaddle')
->label('Status von Paddle holen') ->label('Status von Paddle holen')
->icon('heroicon-o-cloud-arrow-down') ->icon('heroicon-o-cloud-arrow-down')
@@ -319,20 +367,75 @@ class PackageResource extends Resource
->send(); ->send();
}), }),
ViewAction::make(), ViewAction::make(),
EditAction::make(), EditAction::make()
->after(fn (array $data, Package $record) => app(SuperAdminAuditLogger::class)->recordModelMutation(
'updated',
$record,
SuperAdminAuditLogger::fieldsMetadata($data),
static::class
)),
DeleteAction::make() DeleteAction::make()
->visible(fn (Package $record) => ! $record->trashed()), ->visible(fn (Package $record) => ! $record->trashed())
->after(fn (Package $record) => app(SuperAdminAuditLogger::class)->recordModelMutation(
'deleted',
$record,
source: static::class
)),
RestoreAction::make() RestoreAction::make()
->visible(fn (Package $record) => $record->trashed()), ->visible(fn (Package $record) => $record->trashed())
->after(fn (Package $record) => app(SuperAdminAuditLogger::class)->recordModelMutation(
'restored',
$record,
source: static::class
)),
ForceDeleteAction::make() ForceDeleteAction::make()
->visible(fn (Package $record) => $record->trashed()) ->visible(fn (Package $record) => $record->trashed())
->requiresConfirmation(), ->requiresConfirmation()
->after(fn (Package $record) => app(SuperAdminAuditLogger::class)->recordModelMutation(
'force_deleted',
$record,
source: static::class
)),
]) ])
->bulkActions([ ->bulkActions([
BulkActionGroup::make([ BulkActionGroup::make([
DeleteBulkAction::make(), DeleteBulkAction::make()
RestoreBulkAction::make(), ->after(function (Collection $records): void {
ForceDeleteBulkAction::make()->requiresConfirmation(), $logger = app(SuperAdminAuditLogger::class);
foreach ($records as $record) {
$logger->recordModelMutation(
'deleted',
$record,
source: static::class
);
}
}),
RestoreBulkAction::make()
->after(function (Collection $records): void {
$logger = app(SuperAdminAuditLogger::class);
foreach ($records as $record) {
$logger->recordModelMutation(
'restored',
$record,
source: static::class
);
}
}),
ForceDeleteBulkAction::make()
->requiresConfirmation()
->after(function (Collection $records): void {
$logger = app(SuperAdminAuditLogger::class);
foreach ($records as $record) {
$logger->recordModelMutation(
'force_deleted',
$record,
source: static::class
);
}
}),
]), ]),
]); ]);
} }

View File

@@ -3,9 +3,9 @@
namespace App\Filament\Resources\PackageResource\Pages; namespace App\Filament\Resources\PackageResource\Pages;
use App\Filament\Resources\PackageResource; use App\Filament\Resources\PackageResource;
use Filament\Resources\Pages\CreateRecord; use App\Filament\Resources\Pages\AuditedCreateRecord;
class CreatePackage extends CreateRecord class CreatePackage extends AuditedCreateRecord
{ {
protected static string $resource = PackageResource::class; protected static string $resource = PackageResource::class;
} }

View File

@@ -3,10 +3,11 @@
namespace App\Filament\Resources\PackageResource\Pages; namespace App\Filament\Resources\PackageResource\Pages;
use App\Filament\Resources\PackageResource; use App\Filament\Resources\PackageResource;
use App\Filament\Resources\Pages\AuditedEditRecord;
use App\Services\Audit\SuperAdminAuditLogger;
use Filament\Actions; use Filament\Actions;
use Filament\Resources\Pages\EditRecord;
class EditPackage extends EditRecord class EditPackage extends AuditedEditRecord
{ {
protected static string $resource = PackageResource::class; protected static string $resource = PackageResource::class;
@@ -14,7 +15,12 @@ class EditPackage extends EditRecord
{ {
return [ return [
Actions\ViewAction::make(), Actions\ViewAction::make(),
Actions\DeleteAction::make(), Actions\DeleteAction::make()
->after(fn ($record) => app(SuperAdminAuditLogger::class)->recordModelMutation(
'deleted',
$record,
source: static::class
)),
]; ];
} }
} }

View File

@@ -0,0 +1,19 @@
<?php
namespace App\Filament\Resources\Pages;
use App\Services\Audit\SuperAdminAuditLogger;
use Filament\Resources\Pages\CreateRecord;
class AuditedCreateRecord extends CreateRecord
{
protected function afterCreate(): void
{
app(SuperAdminAuditLogger::class)->recordModelMutation(
'created',
$this->record,
SuperAdminAuditLogger::fieldsMetadata($this->form->getState()),
static::class
);
}
}

View File

@@ -0,0 +1,25 @@
<?php
namespace App\Filament\Resources\Pages;
use App\Services\Audit\SuperAdminAuditLogger;
use Filament\Resources\Pages\EditRecord;
class AuditedEditRecord extends EditRecord
{
protected function afterSave(): void
{
$changed = array_keys($this->record->getChanges());
if ($changed === []) {
return;
}
app(SuperAdminAuditLogger::class)->recordModelMutation(
'updated',
$this->record,
SuperAdminAuditLogger::fieldsMetadata($changed ?: $this->form->getState()),
static::class
);
}
}

View File

@@ -6,6 +6,7 @@ use App\Filament\Clusters\DailyOps\DailyOpsCluster;
use App\Filament\Resources\PhotoResource\Pages; use App\Filament\Resources\PhotoResource\Pages;
use App\Models\Event; use App\Models\Event;
use App\Models\Photo; use App\Models\Photo;
use App\Services\Audit\SuperAdminAuditLogger;
use BackedEnum; use BackedEnum;
use Filament\Actions; use Filament\Actions;
use Filament\Forms\Components\FileUpload; use Filament\Forms\Components\FileUpload;
@@ -16,6 +17,7 @@ use Filament\Resources\Resource;
use Filament\Schemas\Schema; use Filament\Schemas\Schema;
use Filament\Tables; use Filament\Tables;
use Filament\Tables\Table; use Filament\Tables\Table;
use Illuminate\Database\Eloquent\Collection;
use UnitEnum; use UnitEnum;
class PhotoResource extends Resource class PhotoResource extends Resource
@@ -78,29 +80,95 @@ class PhotoResource extends Resource
]) ])
->filters([]) ->filters([])
->actions([ ->actions([
Actions\EditAction::make(), Actions\EditAction::make()
->after(fn (array $data, Photo $record) => app(SuperAdminAuditLogger::class)->recordModelMutation(
'updated',
$record,
SuperAdminAuditLogger::fieldsMetadata($data),
static::class
)),
Actions\Action::make('feature') Actions\Action::make('feature')
->label(__('admin.photos.actions.feature')) ->label(__('admin.photos.actions.feature'))
->visible(fn (Photo $record) => ! $record->is_featured) ->visible(fn (Photo $record) => ! $record->is_featured)
->action(fn (Photo $record) => $record->update(['is_featured' => true])) ->action(function (Photo $record): void {
$record->update(['is_featured' => true]);
app(SuperAdminAuditLogger::class)->record(
'photo.featured',
$record,
SuperAdminAuditLogger::fieldsMetadata(['is_featured']),
source: static::class
);
})
->icon('heroicon-o-star'), ->icon('heroicon-o-star'),
Actions\Action::make('unfeature') Actions\Action::make('unfeature')
->label(__('admin.photos.actions.unfeature')) ->label(__('admin.photos.actions.unfeature'))
->visible(fn (Photo $record) => $record->is_featured) ->visible(fn (Photo $record) => $record->is_featured)
->action(fn (Photo $record) => $record->update(['is_featured' => false])) ->action(function (Photo $record): void {
$record->update(['is_featured' => false]);
app(SuperAdminAuditLogger::class)->record(
'photo.unfeatured',
$record,
SuperAdminAuditLogger::fieldsMetadata(['is_featured']),
source: static::class
);
})
->icon('heroicon-o-star'), ->icon('heroicon-o-star'),
Actions\DeleteAction::make(), Actions\DeleteAction::make()
->after(fn (Photo $record) => app(SuperAdminAuditLogger::class)->recordModelMutation(
'deleted',
$record,
source: static::class
)),
]) ])
->bulkActions([ ->bulkActions([
Actions\BulkAction::make('feature') Actions\BulkAction::make('feature')
->label(__('admin.photos.actions.feature_selected')) ->label(__('admin.photos.actions.feature_selected'))
->icon('heroicon-o-star') ->icon('heroicon-o-star')
->action(fn ($records) => $records->each->update(['is_featured' => true])), ->action(function ($records): void {
$records->each->update(['is_featured' => true]);
$logger = app(SuperAdminAuditLogger::class);
foreach ($records as $record) {
$logger->record(
'photo.featured',
$record,
SuperAdminAuditLogger::fieldsMetadata(['is_featured']),
source: static::class
);
}
}),
Actions\BulkAction::make('unfeature') Actions\BulkAction::make('unfeature')
->label(__('admin.photos.actions.unfeature_selected')) ->label(__('admin.photos.actions.unfeature_selected'))
->icon('heroicon-o-star') ->icon('heroicon-o-star')
->action(fn ($records) => $records->each->update(['is_featured' => false])), ->action(function ($records): void {
Actions\DeleteBulkAction::make(), $records->each->update(['is_featured' => false]);
$logger = app(SuperAdminAuditLogger::class);
foreach ($records as $record) {
$logger->record(
'photo.unfeatured',
$record,
SuperAdminAuditLogger::fieldsMetadata(['is_featured']),
source: static::class
);
}
}),
Actions\DeleteBulkAction::make()
->after(function (Collection $records): void {
$logger = app(SuperAdminAuditLogger::class);
foreach ($records as $record) {
$logger->recordModelMutation(
'deleted',
$record,
source: static::class
);
}
}),
]); ]);
} }

View File

@@ -2,10 +2,10 @@
namespace App\Filament\Resources\PhotoResource\Pages; namespace App\Filament\Resources\PhotoResource\Pages;
use App\Filament\Resources\Pages\AuditedEditRecord;
use App\Filament\Resources\PhotoResource; use App\Filament\Resources\PhotoResource;
use Filament\Resources\Pages\EditRecord;
class EditPhoto extends EditRecord class EditPhoto extends AuditedEditRecord
{ {
protected static string $resource = PhotoResource::class; protected static string $resource = PhotoResource::class;
} }

View File

@@ -2,10 +2,10 @@
namespace App\Filament\Resources\PhotoboothSettings\Pages; namespace App\Filament\Resources\PhotoboothSettings\Pages;
use App\Filament\Resources\Pages\AuditedEditRecord;
use App\Filament\Resources\PhotoboothSettings\PhotoboothSettingResource; use App\Filament\Resources\PhotoboothSettings\PhotoboothSettingResource;
use Filament\Resources\Pages\EditRecord;
class EditPhotoboothSetting extends EditRecord class EditPhotoboothSetting extends AuditedEditRecord
{ {
protected static string $resource = PhotoboothSettingResource::class; protected static string $resource = PhotoboothSettingResource::class;

View File

@@ -2,6 +2,7 @@
namespace App\Filament\Resources\PhotoboothSettings\Tables; namespace App\Filament\Resources\PhotoboothSettings\Tables;
use App\Services\Audit\SuperAdminAuditLogger;
use Filament\Actions\EditAction; use Filament\Actions\EditAction;
use Filament\Tables; use Filament\Tables;
use Filament\Tables\Table; use Filament\Tables\Table;
@@ -29,7 +30,13 @@ class PhotoboothSettingsTable
->label(__('Aktualisiert')), ->label(__('Aktualisiert')),
]) ])
->recordActions([ ->recordActions([
EditAction::make(), EditAction::make()
->after(fn (array $data, $record) => app(SuperAdminAuditLogger::class)->recordModelMutation(
'updated',
$record,
SuperAdminAuditLogger::fieldsMetadata($data),
static::class
)),
]) ])
->headerActions([]) ->headerActions([])
->bulkActions([]); ->bulkActions([]);

View File

@@ -7,6 +7,7 @@ use App\Filament\Resources\PurchaseResource\Pages;
use App\Models\PackagePurchase; use App\Models\PackagePurchase;
use App\Notifications\Customer\RefundReceipt; use App\Notifications\Customer\RefundReceipt;
use App\Notifications\Ops\RefundProcessed; use App\Notifications\Ops\RefundProcessed;
use App\Services\Audit\SuperAdminAuditLogger;
use App\Services\Paddle\PaddleTransactionService; use App\Services\Paddle\PaddleTransactionService;
use BackedEnum; use BackedEnum;
use Filament\Actions\Action; use Filament\Actions\Action;
@@ -27,6 +28,7 @@ use Filament\Tables\Filters\Filter;
use Filament\Tables\Filters\SelectFilter; use Filament\Tables\Filters\SelectFilter;
use Filament\Tables\Table; use Filament\Tables\Table;
use Illuminate\Database\Eloquent\Builder; use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Collection;
use Illuminate\Support\Facades\App; use Illuminate\Support\Facades\App;
use Illuminate\Support\Facades\Log; use Illuminate\Support\Facades\Log;
use Illuminate\Support\Facades\Notification; use Illuminate\Support\Facades\Notification;
@@ -178,7 +180,13 @@ class PurchaseResource extends Resource
]) ])
->actions([ ->actions([
ViewAction::make(), ViewAction::make(),
EditAction::make(), EditAction::make()
->after(fn (array $data, PackagePurchase $record) => app(SuperAdminAuditLogger::class)->recordModelMutation(
'updated',
$record,
SuperAdminAuditLogger::fieldsMetadata($data),
static::class
)),
Action::make('refund') Action::make('refund')
->label('Refund') ->label('Refund')
->color('danger') ->color('danger')
@@ -234,11 +242,29 @@ class PurchaseResource extends Resource
if ($opsEmail) { if ($opsEmail) {
Notification::route('mail', $opsEmail)->notify(new RefundProcessed($record, $refundSuccess, $reason, $errorMessage)); Notification::route('mail', $opsEmail)->notify(new RefundProcessed($record, $refundSuccess, $reason, $errorMessage));
} }
app(SuperAdminAuditLogger::class)->record(
'purchase.refunded',
$record,
SuperAdminAuditLogger::fieldsMetadata(['refunded', 'metadata']),
source: static::class
);
}), }),
]) ])
->bulkActions([ ->bulkActions([
BulkActionGroup::make([ BulkActionGroup::make([
DeleteBulkAction::make(), DeleteBulkAction::make()
->after(function (Collection $records): void {
$logger = app(SuperAdminAuditLogger::class);
foreach ($records as $record) {
$logger->recordModelMutation(
'deleted',
$record,
source: static::class
);
}
}),
]), ]),
]) ])
->emptyStateHeading('No Purchases Found') ->emptyStateHeading('No Purchases Found')

View File

@@ -2,10 +2,10 @@
namespace App\Filament\Resources\PurchaseResource\Pages; namespace App\Filament\Resources\PurchaseResource\Pages;
use App\Filament\Resources\Pages\AuditedCreateRecord;
use App\Filament\Resources\PurchaseResource; use App\Filament\Resources\PurchaseResource;
use Filament\Resources\Pages\CreateRecord;
class CreatePurchase extends CreateRecord class CreatePurchase extends AuditedCreateRecord
{ {
protected static string $resource = PurchaseResource::class; protected static string $resource = PurchaseResource::class;
} }

View File

@@ -2,11 +2,12 @@
namespace App\Filament\Resources\PurchaseResource\Pages; namespace App\Filament\Resources\PurchaseResource\Pages;
use App\Filament\Resources\Pages\AuditedEditRecord;
use App\Filament\Resources\PurchaseResource; use App\Filament\Resources\PurchaseResource;
use App\Services\Audit\SuperAdminAuditLogger;
use Filament\Actions; use Filament\Actions;
use Filament\Resources\Pages\EditRecord;
class EditPurchase extends EditRecord class EditPurchase extends AuditedEditRecord
{ {
protected static string $resource = PurchaseResource::class; protected static string $resource = PurchaseResource::class;
@@ -14,7 +15,12 @@ class EditPurchase extends EditRecord
{ {
return [ return [
Actions\ViewAction::make(), Actions\ViewAction::make(),
Actions\DeleteAction::make(), Actions\DeleteAction::make()
->after(fn ($record) => app(SuperAdminAuditLogger::class)->recordModelMutation(
'deleted',
$record,
source: static::class
)),
]; ];
} }
} }

View File

@@ -3,6 +3,7 @@
namespace App\Filament\Resources\PurchaseResource\Pages; namespace App\Filament\Resources\PurchaseResource\Pages;
use App\Filament\Resources\PurchaseResource; use App\Filament\Resources\PurchaseResource;
use App\Services\Audit\SuperAdminAuditLogger;
use Filament\Actions; use Filament\Actions;
use Filament\Resources\Pages\ViewRecord; use Filament\Resources\Pages\ViewRecord;
@@ -13,8 +14,19 @@ class ViewPurchase extends ViewRecord
protected function getHeaderActions(): array protected function getHeaderActions(): array
{ {
return [ return [
Actions\EditAction::make(), Actions\EditAction::make()
Actions\DeleteAction::make(), ->after(fn (array $data, $record) => app(SuperAdminAuditLogger::class)->recordModelMutation(
'updated',
$record,
SuperAdminAuditLogger::fieldsMetadata($data),
static::class
)),
Actions\DeleteAction::make()
->after(fn ($record) => app(SuperAdminAuditLogger::class)->recordModelMutation(
'deleted',
$record,
source: static::class
)),
Actions\Action::make('refund') Actions\Action::make('refund')
->label('Refund') ->label('Refund')
->color('danger') ->color('danger')
@@ -24,6 +36,13 @@ class ViewPurchase extends ViewRecord
->action(function ($record) { ->action(function ($record) {
$record->update(['refunded' => true]); $record->update(['refunded' => true]);
// TODO: Call Paddle API for actual refund // TODO: Call Paddle API for actual refund
app(SuperAdminAuditLogger::class)->record(
'purchase.refunded',
$record,
SuperAdminAuditLogger::fieldsMetadata(['refunded']),
source: static::class
);
}), }),
]; ];
} }

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

@@ -5,6 +5,7 @@ namespace App\Filament\Resources;
use App\Filament\Clusters\WeeklyOps\WeeklyOpsCluster; use App\Filament\Clusters\WeeklyOps\WeeklyOpsCluster;
use App\Filament\Resources\TaskResource\Pages; use App\Filament\Resources\TaskResource\Pages;
use App\Models\Task; use App\Models\Task;
use App\Services\Audit\SuperAdminAuditLogger;
use BackedEnum; use BackedEnum;
use Filament\Actions; use Filament\Actions;
use Filament\Forms\Components\MarkdownEditor; use Filament\Forms\Components\MarkdownEditor;
@@ -17,6 +18,7 @@ use Filament\Schemas\Components\Tabs\Tab as SchemaTab;
use Filament\Schemas\Schema; use Filament\Schemas\Schema;
use Filament\Tables; use Filament\Tables;
use Filament\Tables\Table; use Filament\Tables\Table;
use Illuminate\Database\Eloquent\Collection;
use UnitEnum; use UnitEnum;
class TaskResource extends Resource class TaskResource extends Resource
@@ -163,11 +165,33 @@ class TaskResource extends Resource
]) ])
->filters([]) ->filters([])
->actions([ ->actions([
Actions\EditAction::make(), Actions\EditAction::make()
Actions\DeleteAction::make(), ->after(fn (array $data, Task $record) => app(SuperAdminAuditLogger::class)->recordModelMutation(
'updated',
$record,
SuperAdminAuditLogger::fieldsMetadata($data),
static::class
)),
Actions\DeleteAction::make()
->after(fn (Task $record) => app(SuperAdminAuditLogger::class)->recordModelMutation(
'deleted',
$record,
source: static::class
)),
]) ])
->bulkActions([ ->bulkActions([
Actions\DeleteBulkAction::make(), Actions\DeleteBulkAction::make()
->after(function (Collection $records): void {
$logger = app(SuperAdminAuditLogger::class);
foreach ($records as $record) {
$logger->recordModelMutation(
'deleted',
$record,
source: static::class
);
}
}),
]); ]);
} }

View File

@@ -4,6 +4,7 @@ namespace App\Filament\Resources\TaskResource\Pages;
use App\Filament\Resources\TaskResource; use App\Filament\Resources\TaskResource;
use App\Models\Task; use App\Models\Task;
use App\Services\Audit\SuperAdminAuditLogger;
use Filament\Forms\Components\FileUpload; use Filament\Forms\Components\FileUpload;
use Filament\Forms\Form; use Filament\Forms\Form;
use Filament\Notifications\Notification; use Filament\Notifications\Notification;
@@ -14,7 +15,9 @@ use Illuminate\Support\Facades\Storage;
class ImportTasks extends Page class ImportTasks extends Page
{ {
protected static string $resource = TaskResource::class; protected static string $resource = TaskResource::class;
protected string $view = 'filament.resources.task-resource.pages.import-tasks'; protected string $view = 'filament.resources.task-resource.pages.import-tasks';
protected ?string $heading = null; protected ?string $heading = null;
public ?string $file = null; public ?string $file = null;
@@ -35,8 +38,9 @@ class ImportTasks extends Page
$this->validate(); $this->validate();
$path = $this->form->getState()['file'] ?? null; $path = $this->form->getState()['file'] ?? null;
if (!$path || !Storage::disk('public')->exists($path)) { if (! $path || ! Storage::disk('public')->exists($path)) {
Notification::make()->danger()->title(__('admin.notifications.file_not_found'))->send(); Notification::make()->danger()->title(__('admin.notifications.file_not_found'))->send();
return; return;
} }
@@ -58,14 +62,14 @@ class ImportTasks extends Page
private function importTasksCsv(string $file): array private function importTasksCsv(string $file): array
{ {
$handle = fopen($file, 'r'); $handle = fopen($file, 'r');
if (!$handle) { if (! $handle) {
return [0, 0]; return [0, 0];
} }
$ok = 0; $ok = 0;
$fail = 0; $fail = 0;
$headers = fgetcsv($handle, 0, ','); $headers = fgetcsv($handle, 0, ',');
if (!$headers) { if (! $headers) {
return [0, 0]; return [0, 0];
} }
@@ -87,7 +91,7 @@ class ImportTasks extends Page
$emotionId = DB::table('emotions')->where('name->en', $emotionNameEn)->value('id'); $emotionId = DB::table('emotions')->where('name->en', $emotionNameEn)->value('id');
} }
if (!$emotionId) { if (! $emotionId) {
throw new \Exception('Emotion not found.'); throw new \Exception('Emotion not found.');
} }
@@ -97,7 +101,7 @@ class ImportTasks extends Page
$eventTypeId = DB::table('event_types')->where('slug', $eventTypeSlug)->value('id'); $eventTypeId = DB::table('event_types')->where('slug', $eventTypeSlug)->value('id');
} }
Task::create([ $task = Task::create([
'emotion_id' => $emotionId, 'emotion_id' => $emotionId,
'event_type_id' => $eventTypeId, 'event_type_id' => $eventTypeId,
'title' => [ 'title' => [
@@ -113,10 +117,17 @@ class ImportTasks extends Page
'de' => $row[$map['example_text_de']] ?? null, 'de' => $row[$map['example_text_de']] ?? null,
'en' => $row[$map['example_text_en']] ?? null, 'en' => $row[$map['example_text_en']] ?? null,
], ],
'sort_order' => (int)($row[$map['sort_order']] ?? 0), 'sort_order' => (int) ($row[$map['sort_order']] ?? 0),
'is_active' => (int)($row[$map['is_active']] ?? 1) ? 1 : 0, 'is_active' => (int) ($row[$map['is_active']] ?? 1) ? 1 : 0,
]); ]);
app(SuperAdminAuditLogger::class)->recordModelMutation(
'created',
$task,
SuperAdminAuditLogger::fieldsMetadata($task->getChanges()),
source: static::class
);
$ok++; $ok++;
}); });
} catch (\Throwable $e) { } catch (\Throwable $e) {
@@ -125,6 +136,7 @@ class ImportTasks extends Page
} }
fclose($handle); fclose($handle);
return [$ok, $fail]; return [$ok, $fail];
} }
} }

View File

@@ -3,6 +3,7 @@
namespace App\Filament\Resources\TaskResource\Pages; namespace App\Filament\Resources\TaskResource\Pages;
use App\Filament\Resources\TaskResource; use App\Filament\Resources\TaskResource;
use App\Services\Audit\SuperAdminAuditLogger;
use Filament\Actions; use Filament\Actions;
use Filament\Resources\Pages\ManageRecords; use Filament\Resources\Pages\ManageRecords;
@@ -13,7 +14,13 @@ class ManageTasks extends ManageRecords
protected function getHeaderActions(): array protected function getHeaderActions(): array
{ {
return [ return [
Actions\CreateAction::make(), Actions\CreateAction::make()
->after(fn (array $data, $record) => app(SuperAdminAuditLogger::class)->recordModelMutation(
'created',
$record,
SuperAdminAuditLogger::fieldsMetadata($data),
static::class
)),
Actions\Action::make('import') Actions\Action::make('import')
->label(__('admin.common.import_csv')) ->label(__('admin.common.import_csv'))
->icon('heroicon-o-arrow-up-tray') ->icon('heroicon-o-arrow-up-tray')

View File

@@ -0,0 +1,256 @@
<?php
namespace App\Filament\Resources;
use App\Enums\TenantAnnouncementAudience;
use App\Enums\TenantAnnouncementSegment;
use App\Enums\TenantAnnouncementStatus;
use App\Filament\Clusters\RareAdmin\RareAdminCluster;
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;
use Filament\Forms\Components\Textarea;
use Filament\Forms\Components\TextInput;
use Filament\Forms\Components\Toggle;
use Filament\Resources\Resource;
use Filament\Schemas\Components\Section;
use Filament\Schemas\Components\Utilities\Get;
use Filament\Schemas\Schema;
use Filament\Tables;
use Filament\Tables\Table;
use Illuminate\Database\Eloquent\Collection;
use Illuminate\Support\Facades\Auth;
use UnitEnum;
class TenantAnnouncementResource extends Resource
{
protected static ?string $model = TenantAnnouncement::class;
protected static ?string $cluster = RareAdminCluster::class;
protected static string|BackedEnum|null $navigationIcon = 'heroicon-o-megaphone';
protected static ?string $recordTitleAttribute = 'title';
protected static ?int $navigationSort = 70;
public static function getNavigationGroup(): UnitEnum|string|null
{
return __('admin.nav.platform');
}
public static function form(Schema $schema): Schema
{
$statusOptions = collect(TenantAnnouncementStatus::cases())
->mapWithKeys(fn (TenantAnnouncementStatus $status) => [$status->value => $status->label()])
->all();
$audienceOptions = collect(TenantAnnouncementAudience::cases())
->mapWithKeys(fn (TenantAnnouncementAudience $audience) => [$audience->value => $audience->label()])
->all();
$segmentOptions = collect(TenantAnnouncementSegment::cases())
->mapWithKeys(fn (TenantAnnouncementSegment $segment) => [$segment->value => $segment->label()])
->all();
return $schema
->schema([
Section::make('Inhalt')
->schema([
TextInput::make('title')
->label('Titel')
->required()
->maxLength(160),
Textarea::make('body')
->label('Text')
->rows(6)
->required()
->columnSpanFull(),
TextInput::make('cta_label')
->label('CTA-Label')
->maxLength(160),
TextInput::make('cta_url')
->label('CTA-Link')
->maxLength(255)
->url()
->nullable(),
])
->columns(2),
Section::make('Zielgruppe')
->schema([
Select::make('audience')
->label('Zielgruppe')
->options($audienceOptions)
->default(TenantAnnouncementAudience::ALL->value)
->live()
->required(),
Select::make('tenants')
->label('Mandanten')
->relationship('tenants', 'name')
->multiple()
->preload()
->searchable()
->visible(fn (Get $get): bool => $get('audience') === TenantAnnouncementAudience::TENANTS->value)
->dehydrated(fn (Get $get): bool => $get('audience') === TenantAnnouncementAudience::TENANTS->value)
->required(fn (Get $get): bool => $get('audience') === TenantAnnouncementAudience::TENANTS->value)
->columnSpanFull(),
CheckboxList::make('segments')
->label('Segmente')
->options($segmentOptions)
->columns(2)
->default([])
->visible(fn (Get $get): bool => $get('audience') === TenantAnnouncementAudience::SEGMENTS->value)
->dehydrated(fn (Get $get): bool => $get('audience') === TenantAnnouncementAudience::SEGMENTS->value)
->required(fn (Get $get): bool => $get('audience') === TenantAnnouncementAudience::SEGMENTS->value)
->columnSpanFull(),
Toggle::make('email_enabled')
->label('E-Mail versenden')
->default(true),
])
->columns(2),
Section::make('Zeitplan')
->schema([
Select::make('status')
->label('Status')
->options($statusOptions)
->default(TenantAnnouncementStatus::DRAFT->value)
->live()
->required(),
DateTimePicker::make('starts_at')
->label('Startet am')
->seconds(false)
->nullable()
->required(fn (Get $get): bool => $get('status') === TenantAnnouncementStatus::SCHEDULED->value),
DateTimePicker::make('ends_at')
->label('Endet am')
->seconds(false)
->nullable(),
])
->columns(2),
])
->columns(1);
}
public static function table(Table $table): Table
{
$statusOptions = collect(TenantAnnouncementStatus::cases())
->mapWithKeys(fn (TenantAnnouncementStatus $status) => [$status->value => $status->label()])
->all();
$audienceOptions = collect(TenantAnnouncementAudience::cases())
->mapWithKeys(fn (TenantAnnouncementAudience $audience) => [$audience->value => $audience->label()])
->all();
return $table
->columns([
Tables\Columns\TextColumn::make('title')
->label('Titel')
->searchable()
->sortable()
->limit(50),
Tables\Columns\TextColumn::make('status')
->label('Status')
->badge()
->formatStateUsing(function ($state): string {
if ($state instanceof TenantAnnouncementStatus) {
return $state->label();
}
return TenantAnnouncementStatus::tryFrom((string) $state)?->label() ?? (string) $state;
})
->sortable(),
Tables\Columns\TextColumn::make('audience')
->label('Zielgruppe')
->badge()
->formatStateUsing(function ($state): string {
if ($state instanceof TenantAnnouncementAudience) {
return $state->label();
}
return TenantAnnouncementAudience::tryFrom((string) $state)?->label() ?? (string) $state;
})
->sortable(),
Tables\Columns\IconColumn::make('email_enabled')
->label('E-Mail')
->boolean(),
Tables\Columns\TextColumn::make('starts_at')
->label('Start')
->dateTime()
->sortable(),
Tables\Columns\TextColumn::make('ends_at')
->label('Ende')
->dateTime()
->sortable()
->toggleable(isToggledHiddenByDefault: true),
Tables\Columns\TextColumn::make('updated_at')
->label('Aktualisiert')
->since()
->sortable()
->toggleable(isToggledHiddenByDefault: true),
])
->filters([
Tables\Filters\SelectFilter::make('status')
->label('Status')
->options($statusOptions),
Tables\Filters\SelectFilter::make('audience')
->label('Zielgruppe')
->options($audienceOptions),
])
->actions([
Actions\EditAction::make()
->after(fn (array $data, TenantAnnouncement $record) => app(SuperAdminAuditLogger::class)->recordModelMutation(
'updated',
$record,
SuperAdminAuditLogger::fieldsMetadata($data),
static::class
)),
])
->bulkActions([
Actions\DeleteBulkAction::make()
->after(function (Collection $records): void {
$logger = app(SuperAdminAuditLogger::class);
foreach ($records as $record) {
$logger->recordModelMutation(
'deleted',
$record,
source: static::class
);
}
}),
]);
}
public static function getPages(): array
{
return [
'index' => Pages\ListTenantAnnouncements::route('/'),
'create' => Pages\CreateTenantAnnouncement::route('/create'),
'edit' => Pages\EditTenantAnnouncement::route('/{record}/edit'),
];
}
public static function mutateFormDataBeforeCreate(array $data): array
{
if ($userId = Auth::id()) {
$data['created_by'] = $userId;
$data['updated_by'] = $userId;
}
return $data;
}
public static function mutateFormDataBeforeSave(array $data): array
{
if ($userId = Auth::id()) {
$data['updated_by'] = $userId;
}
return $data;
}
}

View File

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

View File

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

View File

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

View File

@@ -14,6 +14,7 @@ use Filament\Resources\Resource;
use Filament\Schemas\Schema; use Filament\Schemas\Schema;
use Filament\Support\Icons\Heroicon; use Filament\Support\Icons\Heroicon;
use Filament\Tables\Table; use Filament\Tables\Table;
use Illuminate\Database\Eloquent\Builder;
use UnitEnum; use UnitEnum;
class TenantFeedbackResource extends Resource class TenantFeedbackResource extends Resource
@@ -26,7 +27,7 @@ class TenantFeedbackResource extends Resource
protected static UnitEnum|string|null $navigationGroup = null; protected static UnitEnum|string|null $navigationGroup = null;
protected static ?int $navigationSort = 120; protected static ?int $navigationSort = 30;
public static function canCreate(): bool public static function canCreate(): bool
{ {
@@ -48,11 +49,22 @@ class TenantFeedbackResource extends Resource
return TenantFeedbackTable::configure($table); return TenantFeedbackTable::configure($table);
} }
public static function getNavigationLabel(): string
{
return __('admin.feedback.navigation.label');
}
public static function getNavigationGroup(): UnitEnum|string|null public static function getNavigationGroup(): UnitEnum|string|null
{ {
return __('admin.nav.feedback_support'); return __('admin.nav.feedback_support');
} }
public static function getEloquentQuery(): Builder
{
return parent::getEloquentQuery()
->with(['tenant', 'event', 'moderator']);
}
public static function getRelations(): array public static function getRelations(): array
{ {
return []; return [];

View File

@@ -3,6 +3,7 @@
namespace App\Filament\Resources\TenantFeedbackResource\Pages; namespace App\Filament\Resources\TenantFeedbackResource\Pages;
use App\Filament\Resources\TenantFeedbackResource; use App\Filament\Resources\TenantFeedbackResource;
use App\Services\Audit\SuperAdminAuditLogger;
use Filament\Actions\DeleteAction; use Filament\Actions\DeleteAction;
use Filament\Resources\Pages\ViewRecord; use Filament\Resources\Pages\ViewRecord;
@@ -13,7 +14,12 @@ class ViewTenantFeedback extends ViewRecord
protected function getHeaderActions(): array protected function getHeaderActions(): array
{ {
return [ return [
DeleteAction::make(), DeleteAction::make()
->after(fn ($record) => app(SuperAdminAuditLogger::class)->recordModelMutation(
'deleted',
$record,
source: static::class
)),
]; ];
} }
} }

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