Compare commits
176 Commits
main
...
198fbf6751
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
198fbf6751 | ||
|
|
246e54f970 | ||
|
|
1c5412e82c | ||
|
|
0b1430e64d | ||
|
|
52c2aa0e9b | ||
|
|
dd459aa381 | ||
|
|
02ec14a0d3 | ||
|
|
e490f9995c | ||
|
|
5e5b69f655 | ||
|
|
e5e74febbd | ||
|
|
5674ed99f1 | ||
|
|
6ab24e65a1 | ||
|
|
d7ba1880dc | ||
|
|
9d8f01d294 | ||
|
|
f88aa40315 | ||
|
|
cb5d5a2870 | ||
|
|
e28eb9a90b | ||
|
|
3c2ebdbc0e | ||
|
|
a916bf8c4d | ||
|
|
7a71efedd1 | ||
|
|
e1221e0466 | ||
|
|
508c8201fa | ||
|
|
750acb0bec | ||
|
|
42f6178b6d | ||
|
|
802e360c8e | ||
|
|
7030e8b5b9 | ||
|
|
b61507ea04 | ||
|
|
dfaf21898a | ||
|
|
fbd48afbd6 | ||
|
|
6f6d8901ec | ||
|
|
d4ab9a3a20 | ||
|
|
fbff2afa3e | ||
|
|
926bc7d070 | ||
|
|
f1f552ad2d | ||
|
|
4219daba25 | ||
|
|
1e821a2fb4 | ||
|
|
48d4716ab1 | ||
|
|
45f0cea264 | ||
|
|
9d7990fe71 | ||
|
|
0c5939e541 | ||
|
|
e7e095cec9 | ||
|
|
d905ba8e6c | ||
|
|
40bed1e44e | ||
|
|
7e77dd2931 | ||
|
|
b316beb522 | ||
|
|
6d3f4f36e8 | ||
|
|
9e4ea3dafb | ||
|
|
1517eb8631 | ||
|
|
9a4ece33bf | ||
|
|
30c653913d | ||
|
|
4c37f874bd | ||
|
|
05fdda811b | ||
|
|
eeeca0eed5 | ||
|
|
fa6a5678f0 | ||
|
|
63956087a4 | ||
|
|
a3f153de6f | ||
|
|
8d729c6a86 | ||
|
|
7ad43a3661 | ||
|
|
7aa0a4c847 | ||
|
|
df60be826d | ||
|
|
918bff08aa | ||
|
|
292c8f0b26 | ||
|
|
11018f273d | ||
|
|
7e32d8f706 | ||
|
|
ad829ae509 | ||
|
|
2f93271d94 | ||
|
|
62255dc9e7 | ||
|
|
738659112d | ||
|
|
89d9b656de | ||
|
|
5d0ae0faa5 | ||
|
|
2ecd417b55 | ||
|
|
3755213010 | ||
|
|
9cb236f123 | ||
|
|
10232cf40e | ||
|
|
3ce6507268 | ||
|
|
a39295a0f0 | ||
|
|
5dc69fb187 | ||
|
|
92b341bdcd | ||
|
|
725a7a29b3 | ||
|
|
8634d16359 | ||
|
|
81446b37c3 | ||
|
|
33e46b448d | ||
|
|
289ef70e53 | ||
|
|
d0559bf8c9 | ||
|
|
0ef4b32bf6 | ||
|
|
3612c97e86 | ||
|
|
c0510581c6 | ||
|
|
1ffd3e3b9d | ||
|
|
e05ee3b186 | ||
|
|
cf7b2e563a | ||
|
|
719afb6920 | ||
|
|
83c58358a1 | ||
|
|
2b888078a0 | ||
|
|
2f584162d6 | ||
|
|
0833ea6b36 | ||
|
|
5bdc15d399 | ||
|
|
693540f609 | ||
|
|
c0193c9581 | ||
|
|
03c7b20cae | ||
|
|
3a78c4f2c0 | ||
|
|
fa333deed9 | ||
|
|
a733df6221 | ||
|
|
5ee1baa7e2 | ||
|
|
2f19752199 | ||
|
|
7dd7ec14a4 | ||
|
|
d9568be579 | ||
|
|
9cf6e9d94d | ||
|
|
a23ce0c86f | ||
|
|
9efea136bd | ||
|
|
7a6f489b8b | ||
|
|
cc11e024f0 | ||
|
|
2089251a92 | ||
|
|
53094b8d36 | ||
|
|
0c33c1ddc1 | ||
|
|
ce0b7c951a | ||
|
|
fbbbbdac4c | ||
|
|
94d0713ec0 | ||
|
|
3e36354916 | ||
|
|
24a1319cc2 | ||
|
|
b1250c6246 | ||
|
|
fd7a3c846a | ||
|
|
1ca7545f86 | ||
|
|
9f4a202d2b | ||
|
|
fe0525e678 | ||
|
|
d62efdb55c | ||
|
|
be722f6e37 | ||
|
|
898ac9ff0e | ||
|
|
c8d1ac7971 | ||
|
|
3ee23f3a66 | ||
|
|
993c351832 | ||
|
|
2444a62a4d | ||
|
|
e52720a3cb | ||
|
|
93bed358ba | ||
|
|
a16bd9c498 | ||
|
|
e32b1fa45a | ||
|
|
6edc890e01 | ||
|
|
e4100f7800 | ||
|
|
7786e3d134 | ||
|
|
30f3d148bb | ||
|
|
1970c259ed | ||
|
|
dc5c80cda4 | ||
|
|
75a9bcee12 | ||
|
|
6fe363640f | ||
|
|
3df0542013 | ||
|
|
4f4a527010 | ||
|
|
e69c94ad20 | ||
|
|
5afa96251b | ||
|
|
24f053d4c4 | ||
|
|
ec360ed860 | ||
|
|
83e78d7c66 | ||
|
|
9b1c5bf978 | ||
|
|
fb23a0a2f3 | ||
|
|
2287e7f32c | ||
|
|
cceed361b7 | ||
|
|
02363792c8 | ||
|
|
e93a00f0fc | ||
|
|
c1be7dd1ef | ||
|
|
f01a0e823b | ||
|
|
915aede66e | ||
|
|
b854e3feaa | ||
|
|
4bcaef53f7 | ||
|
|
8f1d3a3eb6 | ||
|
|
ab2cf3e023 | ||
|
|
ce0ab269c9 | ||
|
|
dce24bb86a | ||
|
|
03bf178d61 | ||
|
|
8ebaf6c31d | ||
|
|
1b6dc63ec6 | ||
|
|
accc63f4a2 | ||
|
|
59e318e7b9 | ||
|
|
3de1d3deab | ||
|
|
e9afbeb028 | ||
|
|
3e2b63f71f | ||
|
|
cff014ede5 | ||
|
|
8c5d3b93d5 | ||
|
|
22cb7ed7ce |
@@ -17,7 +17,6 @@
|
|||||||
{"id":"fotospiel-app-38f","title":"Paddle catalog sync: surface last sync error/log context in admin","status":"closed","priority":2,"issue_type":"task","created_at":"2026-01-01T15:59:14.865414785+01:00","created_by":"soeren","updated_at":"2026-01-02T21:16:09.109922491+01:00","closed_at":"2026-01-02T21:16:09.109922491+01:00","close_reason":"Completed"}
|
{"id":"fotospiel-app-38f","title":"Paddle catalog sync: surface last sync error/log context in admin","status":"closed","priority":2,"issue_type":"task","created_at":"2026-01-01T15:59:14.865414785+01:00","created_by":"soeren","updated_at":"2026-01-02T21:16:09.109922491+01:00","closed_at":"2026-01-02T21:16:09.109922491+01:00","close_reason":"Completed"}
|
||||||
{"id":"fotospiel-app-3ut","title":"SEC-API-03 Synthetic monitoring + alert config","status":"open","priority":2,"issue_type":"task","created_at":"2026-01-01T15:52:46.793875724+01:00","created_by":"soeren","updated_at":"2026-01-01T15:52:46.793875724+01:00"}
|
{"id":"fotospiel-app-3ut","title":"SEC-API-03 Synthetic monitoring + alert config","status":"open","priority":2,"issue_type":"task","created_at":"2026-01-01T15:52:46.793875724+01:00","created_by":"soeren","updated_at":"2026-01-01T15:52:46.793875724+01:00"}
|
||||||
{"id":"fotospiel-app-3xa","title":"Security review: event admin code audit (policies, PKCE, file handling)","status":"open","priority":2,"issue_type":"task","created_at":"2026-01-01T16:05:20.115675149+01:00","created_by":"soeren","updated_at":"2026-01-01T16:05:20.115675149+01:00"}
|
{"id":"fotospiel-app-3xa","title":"Security review: event admin code audit (policies, PKCE, file handling)","status":"open","priority":2,"issue_type":"task","created_at":"2026-01-01T16:05:20.115675149+01:00","created_by":"soeren","updated_at":"2026-01-01T16:05:20.115675149+01:00"}
|
||||||
{"id":"fotospiel-app-43mp","title":"Help-System für Event Admin PWA planen","notes":"Context help links wired into priority admin pages.","status":"in_progress","priority":2,"issue_type":"task","owner":"codex-agent@example.com","created_at":"2026-01-23T08:21:47.812129626+01:00","created_by":"Codex Agent","updated_at":"2026-01-23T09:19:45.828239299+01:00"}
|
|
||||||
{"id":"fotospiel-app-4ar","title":"SEC-BILL-03 Failed capture notifications + ledger hook","status":"open","priority":2,"issue_type":"task","created_at":"2026-01-01T15:54:33.266516715+01:00","created_by":"soeren","updated_at":"2026-01-01T15:54:33.266516715+01:00"}
|
{"id":"fotospiel-app-4ar","title":"SEC-BILL-03 Failed capture notifications + ledger hook","status":"open","priority":2,"issue_type":"task","created_at":"2026-01-01T15:54:33.266516715+01:00","created_by":"soeren","updated_at":"2026-01-01T15:54:33.266516715+01:00"}
|
||||||
{"id":"fotospiel-app-4en","title":"Add translations for Mobile Package Shop","description":"The new MobilePackageShopPage.tsx uses translation keys like 'shop.title', 'shop.legal.agb', etc. Ensure these are added to the management.json files for de and en.","status":"closed","priority":2,"issue_type":"task","created_at":"2026-01-06T18:05:50.469751088+01:00","created_by":"soeren","updated_at":"2026-01-06T18:14:19.984343737+01:00","closed_at":"2026-01-06T18:14:19.984346372+01:00"}
|
{"id":"fotospiel-app-4en","title":"Add translations for Mobile Package Shop","description":"The new MobilePackageShopPage.tsx uses translation keys like 'shop.title', 'shop.legal.agb', etc. Ensure these are added to the management.json files for de and en.","status":"closed","priority":2,"issue_type":"task","created_at":"2026-01-06T18:05:50.469751088+01:00","created_by":"soeren","updated_at":"2026-01-06T18:14:19.984343737+01:00","closed_at":"2026-01-06T18:14:19.984346372+01:00"}
|
||||||
{"id":"fotospiel-app-4i4","title":"Security review: map roles/data","status":"closed","priority":2,"issue_type":"task","created_at":"2026-01-01T16:02:58.370301875+01:00","created_by":"soeren","updated_at":"2026-01-01T16:03:03.997327414+01:00","closed_at":"2026-01-01T16:03:03.997327414+01:00","close_reason":"Completed in codebase (verified)"}
|
{"id":"fotospiel-app-4i4","title":"Security review: map roles/data","status":"closed","priority":2,"issue_type":"task","created_at":"2026-01-01T16:02:58.370301875+01:00","created_by":"soeren","updated_at":"2026-01-01T16:03:03.997327414+01:00","closed_at":"2026-01-01T16:03:03.997327414+01:00","close_reason":"Completed in codebase (verified)"}
|
||||||
@@ -38,7 +37,6 @@
|
|||||||
{"id":"fotospiel-app-5ie","title":"Help docs: Live Show how-to + recommended hardware (DE/EN)","status":"closed","priority":2,"issue_type":"task","created_at":"2026-01-05T11:12:05.973844187+01:00","created_by":"soeren","updated_at":"2026-01-05T19:42:44.39939087+01:00","closed_at":"2026-01-05T19:42:44.39939087+01:00","close_reason":"Closed","dependencies":[{"issue_id":"fotospiel-app-5ie","depends_on_id":"fotospiel-app-vro","type":"blocks","created_at":"2026-01-05T11:13:54.925412888+01:00","created_by":"soeren"},{"issue_id":"fotospiel-app-5ie","depends_on_id":"fotospiel-app-539","type":"blocks","created_at":"2026-01-05T11:14:03.257649076+01:00","created_by":"soeren"}]}
|
{"id":"fotospiel-app-5ie","title":"Help docs: Live Show how-to + recommended hardware (DE/EN)","status":"closed","priority":2,"issue_type":"task","created_at":"2026-01-05T11:12:05.973844187+01:00","created_by":"soeren","updated_at":"2026-01-05T19:42:44.39939087+01:00","closed_at":"2026-01-05T19:42:44.39939087+01:00","close_reason":"Closed","dependencies":[{"issue_id":"fotospiel-app-5ie","depends_on_id":"fotospiel-app-vro","type":"blocks","created_at":"2026-01-05T11:13:54.925412888+01:00","created_by":"soeren"},{"issue_id":"fotospiel-app-5ie","depends_on_id":"fotospiel-app-539","type":"blocks","created_at":"2026-01-05T11:14:03.257649076+01:00","created_by":"soeren"}]}
|
||||||
{"id":"fotospiel-app-5iy","title":"Security review: confirm env/header defaults","status":"closed","priority":2,"issue_type":"task","created_at":"2026-01-01T16:03:20.808188183+01:00","created_by":"soeren","updated_at":"2026-01-01T16:03:26.388002115+01:00","closed_at":"2026-01-01T16:03:26.388002115+01:00","close_reason":"Completed in codebase (verified)"}
|
{"id":"fotospiel-app-5iy","title":"Security review: confirm env/header defaults","status":"closed","priority":2,"issue_type":"task","created_at":"2026-01-01T16:03:20.808188183+01:00","created_by":"soeren","updated_at":"2026-01-01T16:03:26.388002115+01:00","closed_at":"2026-01-01T16:03:26.388002115+01:00","close_reason":"Completed in codebase (verified)"}
|
||||||
{"id":"fotospiel-app-5s3","title":"Localized SEO: canonical/hreflang tags + localized navigation","status":"closed","priority":2,"issue_type":"task","created_at":"2026-01-01T16:02:03.909947355+01:00","created_by":"soeren","updated_at":"2026-01-01T16:02:09.550647107+01:00","closed_at":"2026-01-01T16:02:09.550647107+01:00","close_reason":"Completed in codebase (verified)"}
|
{"id":"fotospiel-app-5s3","title":"Localized SEO: canonical/hreflang tags + localized navigation","status":"closed","priority":2,"issue_type":"task","created_at":"2026-01-01T16:02:03.909947355+01:00","created_by":"soeren","updated_at":"2026-01-01T16:02:09.550647107+01:00","closed_at":"2026-01-01T16:02:09.550647107+01:00","close_reason":"Completed in codebase (verified)"}
|
||||||
{"id":"fotospiel-app-5veo","title":"Investigate vite build timeout","status":"open","priority":2,"issue_type":"task","owner":"codex-agent@example.com","created_at":"2026-01-21T12:49:14.166622473+01:00","created_by":"Codex Agent","updated_at":"2026-01-21T12:49:14.166622473+01:00"}
|
|
||||||
{"id":"fotospiel-app-5zl","title":"Ensure checkout step 3 requires login for Paddle checkout","description":"Problem: Paddle checkout on step 3 fails when user is not logged in. Step 3 must enforce authentication before initializing Paddle checkout.\\n\\nSuggestions:\\n- Protect step 3 route/controller with auth middleware and redirect to login with intended return URL.\\n- Gate step 3 UI/CTA on auth state; show inline login prompt and disable Paddle until authenticated.\\n- Require auth in backend endpoint that creates Paddle transaction/session; return 401 and send user to login.\\n- Optionally preflight at end of step 2 to prompt login before advancing.","status":"closed","priority":2,"issue_type":"task","created_at":"2026-01-04T12:31:43.215017311+01:00","created_by":"soeren","updated_at":"2026-01-04T12:42:45.088723058+01:00","closed_at":"2026-01-04T12:42:45.088723058+01:00","close_reason":"Closed"}
|
{"id":"fotospiel-app-5zl","title":"Ensure checkout step 3 requires login for Paddle checkout","description":"Problem: Paddle checkout on step 3 fails when user is not logged in. Step 3 must enforce authentication before initializing Paddle checkout.\\n\\nSuggestions:\\n- Protect step 3 route/controller with auth middleware and redirect to login with intended return URL.\\n- Gate step 3 UI/CTA on auth state; show inline login prompt and disable Paddle until authenticated.\\n- Require auth in backend endpoint that creates Paddle transaction/session; return 401 and send user to login.\\n- Optionally preflight at end of step 2 to prompt login before advancing.","status":"closed","priority":2,"issue_type":"task","created_at":"2026-01-04T12:31:43.215017311+01:00","created_by":"soeren","updated_at":"2026-01-04T12:42:45.088723058+01:00","closed_at":"2026-01-04T12:42:45.088723058+01:00","close_reason":"Closed"}
|
||||||
{"id":"fotospiel-app-64l","title":"SEC-FE-01 CSP nonce/hashing rollout","status":"closed","priority":2,"issue_type":"task","created_at":"2026-01-01T15:54:47.607047443+01:00","created_by":"soeren","updated_at":"2026-01-01T15:55:56.477104351+01:00","closed_at":"2026-01-01T15:55:56.477104351+01:00","close_reason":"Completed in codebase (verified) - duplicate of fotospiel-app-zli"}
|
{"id":"fotospiel-app-64l","title":"SEC-FE-01 CSP nonce/hashing rollout","status":"closed","priority":2,"issue_type":"task","created_at":"2026-01-01T15:54:47.607047443+01:00","created_by":"soeren","updated_at":"2026-01-01T15:55:56.477104351+01:00","closed_at":"2026-01-01T15:55:56.477104351+01:00","close_reason":"Completed in codebase (verified) - duplicate of fotospiel-app-zli"}
|
||||||
{"id":"fotospiel-app-6dp","title":"Coupon ops enhancements (redemption service, preview endpoint, widget, export)","status":"closed","priority":2,"issue_type":"task","created_at":"2026-01-01T16:09:09.275919717+01:00","created_by":"soeren","updated_at":"2026-01-01T16:09:14.882264149+01:00","closed_at":"2026-01-01T16:09:14.882264149+01:00","close_reason":"Completed in codebase (verified)"}
|
{"id":"fotospiel-app-6dp","title":"Coupon ops enhancements (redemption service, preview endpoint, widget, export)","status":"closed","priority":2,"issue_type":"task","created_at":"2026-01-01T16:09:09.275919717+01:00","created_by":"soeren","updated_at":"2026-01-01T16:09:14.882264149+01:00","closed_at":"2026-01-01T16:09:14.882264149+01:00","close_reason":"Completed in codebase (verified)"}
|
||||||
|
|||||||
1
.beads/last-touched
Normal file
1
.beads/last-touched
Normal file
@@ -0,0 +1 @@
|
|||||||
|
fotospiel-app-spq8
|
||||||
@@ -1,5 +1,4 @@
|
|||||||
{
|
{
|
||||||
"database": "beads.db",
|
"database": "beads.db",
|
||||||
"jsonl_export": "issues.jsonl",
|
"jsonl_export": "issues.jsonl"
|
||||||
"last_bd_version": "0.49.0"
|
|
||||||
}
|
}
|
||||||
33
.env.example
33
.env.example
@@ -97,11 +97,6 @@ GOOGLE_CLIENT_ID=
|
|||||||
GOOGLE_CLIENT_SECRET=
|
GOOGLE_CLIENT_SECRET=
|
||||||
GOOGLE_REDIRECT_URI=${APP_URL}/checkout/auth/google/callback
|
GOOGLE_REDIRECT_URI=${APP_URL}/checkout/auth/google/callback
|
||||||
|
|
||||||
# Facebook OAuth (Checkout comfort login)
|
|
||||||
FACEBOOK_CLIENT_ID=
|
|
||||||
FACEBOOK_CLIENT_SECRET=
|
|
||||||
FACEBOOK_REDIRECT_URI=${APP_URL}/checkout/auth/facebook/callback
|
|
||||||
|
|
||||||
VITE_APP_NAME="${APP_NAME}"
|
VITE_APP_NAME="${APP_NAME}"
|
||||||
VITE_ENABLE_TENANT_SWITCHER=false
|
VITE_ENABLE_TENANT_SWITCHER=false
|
||||||
REVENUECAT_WEBHOOK_SECRET=
|
REVENUECAT_WEBHOOK_SECRET=
|
||||||
@@ -117,22 +112,14 @@ PAYPAL_CLIENT_ID=
|
|||||||
PAYPAL_SECRET=
|
PAYPAL_SECRET=
|
||||||
PAYPAL_SANDBOX=true
|
PAYPAL_SANDBOX=true
|
||||||
|
|
||||||
# Lemon Squeezy Billing
|
# Paddle Billing
|
||||||
LEMONSQUEEZY_STORE_ID=284860
|
PADDLE_SANDBOX=true
|
||||||
LEMONSQUEEZY_API_KEY=
|
PADDLE_API_KEY=
|
||||||
LEMONSQUEEZY_WEBHOOK_SECRET=
|
PADDLE_CLIENT_ID=
|
||||||
LEMONSQUEEZY_WEBHOOK_EVENTS=
|
PADDLE_WEBHOOK_SECRET=
|
||||||
LEMONSQUEEZY_TEST_MODE=false
|
PADDLE_PUBLIC_KEY=
|
||||||
LEMONSQUEEZY_BASE_URL=https://api.lemonsqueezy.com/v1
|
PADDLE_BASE_URL=
|
||||||
LEMONSQUEEZY_GIFT_VARIANT_STARTER=
|
PADDLE_CONSOLE_URL=
|
||||||
LEMONSQUEEZY_GIFT_VARIANT_STARTER_USD=
|
|
||||||
LEMONSQUEEZY_GIFT_VARIANT_STARTER_GBP=
|
|
||||||
LEMONSQUEEZY_GIFT_VARIANT_STANDARD=
|
|
||||||
LEMONSQUEEZY_GIFT_VARIANT_STANDARD_USD=
|
|
||||||
LEMONSQUEEZY_GIFT_VARIANT_STANDARD_GBP=
|
|
||||||
LEMONSQUEEZY_GIFT_VARIANT_PREMIUM=
|
|
||||||
LEMONSQUEEZY_GIFT_VARIANT_PREMIUM_USD=
|
|
||||||
LEMONSQUEEZY_GIFT_VARIANT_PREMIUM_GBP=
|
|
||||||
|
|
||||||
# Sanctum / SPA auth
|
# Sanctum / SPA auth
|
||||||
SANCTUM_STATEFUL_DOMAINS=localhost,localhost:3000
|
SANCTUM_STATEFUL_DOMAINS=localhost,localhost:3000
|
||||||
@@ -200,9 +187,5 @@ STORAGE_QUEUE_PENDING_EVENT_MINUTES=8
|
|||||||
STORAGE_QUEUE_FAILED_EVENT_THRESHOLD=2
|
STORAGE_QUEUE_FAILED_EVENT_THRESHOLD=2
|
||||||
STORAGE_QUEUE_FAILED_EVENT_MINUTES=30
|
STORAGE_QUEUE_FAILED_EVENT_MINUTES=30
|
||||||
STORAGE_QUEUE_GUEST_ALERT_TTL=30
|
STORAGE_QUEUE_GUEST_ALERT_TTL=30
|
||||||
STORAGE_CHECKSUM_VALIDATION=true
|
|
||||||
STORAGE_CHECKSUM_ALERT_WINDOW_MINUTES=60
|
|
||||||
STORAGE_CHECKSUM_WARNING=1
|
|
||||||
STORAGE_CHECKSUM_CRITICAL=5
|
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because one or more lines are too long
857230
.tamagui/tamagui.config.json
857230
.tamagui/tamagui.config.json
File diff suppressed because it is too large
Load Diff
@@ -118,8 +118,319 @@ var isWindowDefined = typeof window < "u";
|
|||||||
var isClient = isWeb && isWindowDefined;
|
var isClient = isWeb && isWindowDefined;
|
||||||
var isChrome = typeof navigator < "u" && /Chrome/.test(navigator.userAgent || "");
|
var isChrome = typeof navigator < "u" && /Chrome/.test(navigator.userAgent || "");
|
||||||
var isWebTouchable = isClient && ("ontouchstart" in window || navigator.maxTouchPoints > 0);
|
var isWebTouchable = isClient && ("ontouchstart" in window || navigator.maxTouchPoints > 0);
|
||||||
|
var isAndroid = false;
|
||||||
var isIos = process.env.TEST_NATIVE_PLATFORM === "ios";
|
var isIos = process.env.TEST_NATIVE_PLATFORM === "ios";
|
||||||
|
|
||||||
|
// node_modules/@tamagui/helpers/dist/esm/validStyleProps.mjs
|
||||||
|
var textColors = {
|
||||||
|
color: true,
|
||||||
|
textDecorationColor: true,
|
||||||
|
textShadowColor: true
|
||||||
|
};
|
||||||
|
var tokenCategories = {
|
||||||
|
radius: {
|
||||||
|
borderRadius: true,
|
||||||
|
borderTopLeftRadius: true,
|
||||||
|
borderTopRightRadius: true,
|
||||||
|
borderBottomLeftRadius: true,
|
||||||
|
borderBottomRightRadius: true,
|
||||||
|
// logical
|
||||||
|
borderStartStartRadius: true,
|
||||||
|
borderStartEndRadius: true,
|
||||||
|
borderEndStartRadius: true,
|
||||||
|
borderEndEndRadius: true
|
||||||
|
},
|
||||||
|
size: {
|
||||||
|
width: true,
|
||||||
|
height: true,
|
||||||
|
minWidth: true,
|
||||||
|
minHeight: true,
|
||||||
|
maxWidth: true,
|
||||||
|
maxHeight: true,
|
||||||
|
blockSize: true,
|
||||||
|
minBlockSize: true,
|
||||||
|
maxBlockSize: true,
|
||||||
|
inlineSize: true,
|
||||||
|
minInlineSize: true,
|
||||||
|
maxInlineSize: true
|
||||||
|
},
|
||||||
|
zIndex: {
|
||||||
|
zIndex: true
|
||||||
|
},
|
||||||
|
color: {
|
||||||
|
backgroundColor: true,
|
||||||
|
borderColor: true,
|
||||||
|
borderBlockStartColor: true,
|
||||||
|
borderBlockEndColor: true,
|
||||||
|
borderBlockColor: true,
|
||||||
|
borderBottomColor: true,
|
||||||
|
borderInlineColor: true,
|
||||||
|
borderInlineStartColor: true,
|
||||||
|
borderInlineEndColor: true,
|
||||||
|
borderTopColor: true,
|
||||||
|
borderLeftColor: true,
|
||||||
|
borderRightColor: true,
|
||||||
|
borderEndColor: true,
|
||||||
|
borderStartColor: true,
|
||||||
|
shadowColor: true,
|
||||||
|
...textColors,
|
||||||
|
outlineColor: true,
|
||||||
|
caretColor: true
|
||||||
|
}
|
||||||
|
};
|
||||||
|
var stylePropsUnitless = {
|
||||||
|
WebkitLineClamp: true,
|
||||||
|
animationIterationCount: true,
|
||||||
|
aspectRatio: true,
|
||||||
|
borderImageOutset: true,
|
||||||
|
borderImageSlice: true,
|
||||||
|
borderImageWidth: true,
|
||||||
|
columnCount: true,
|
||||||
|
flex: true,
|
||||||
|
flexGrow: true,
|
||||||
|
flexOrder: true,
|
||||||
|
flexPositive: true,
|
||||||
|
flexShrink: true,
|
||||||
|
flexNegative: true,
|
||||||
|
fontWeight: true,
|
||||||
|
gridRow: true,
|
||||||
|
gridRowEnd: true,
|
||||||
|
gridRowGap: true,
|
||||||
|
gridRowStart: true,
|
||||||
|
gridColumn: true,
|
||||||
|
gridColumnEnd: true,
|
||||||
|
gridColumnGap: true,
|
||||||
|
gridColumnStart: true,
|
||||||
|
gridTemplateColumns: true,
|
||||||
|
gridTemplateAreas: true,
|
||||||
|
lineClamp: true,
|
||||||
|
opacity: true,
|
||||||
|
order: true,
|
||||||
|
orphans: true,
|
||||||
|
tabSize: true,
|
||||||
|
widows: true,
|
||||||
|
zIndex: true,
|
||||||
|
zoom: true,
|
||||||
|
scale: true,
|
||||||
|
scaleX: true,
|
||||||
|
scaleY: true,
|
||||||
|
scaleZ: true,
|
||||||
|
shadowOpacity: true
|
||||||
|
};
|
||||||
|
var stylePropsTransform = {
|
||||||
|
x: true,
|
||||||
|
y: true,
|
||||||
|
scale: true,
|
||||||
|
perspective: true,
|
||||||
|
scaleX: true,
|
||||||
|
scaleY: true,
|
||||||
|
skewX: true,
|
||||||
|
skewY: true,
|
||||||
|
matrix: true,
|
||||||
|
rotate: true,
|
||||||
|
rotateY: true,
|
||||||
|
rotateX: true,
|
||||||
|
rotateZ: true
|
||||||
|
};
|
||||||
|
var stylePropsView = {
|
||||||
|
backfaceVisibility: true,
|
||||||
|
borderBottomEndRadius: true,
|
||||||
|
borderBottomStartRadius: true,
|
||||||
|
borderBottomWidth: true,
|
||||||
|
borderLeftWidth: true,
|
||||||
|
borderRightWidth: true,
|
||||||
|
borderBlockWidth: true,
|
||||||
|
borderBlockEndWidth: true,
|
||||||
|
borderBlockStartWidth: true,
|
||||||
|
borderInlineWidth: true,
|
||||||
|
borderInlineEndWidth: true,
|
||||||
|
borderInlineStartWidth: true,
|
||||||
|
borderStyle: true,
|
||||||
|
borderBlockStyle: true,
|
||||||
|
borderBlockEndStyle: true,
|
||||||
|
borderBlockStartStyle: true,
|
||||||
|
borderInlineStyle: true,
|
||||||
|
borderInlineEndStyle: true,
|
||||||
|
borderInlineStartStyle: true,
|
||||||
|
borderTopEndRadius: true,
|
||||||
|
borderTopStartRadius: true,
|
||||||
|
borderTopWidth: true,
|
||||||
|
borderWidth: true,
|
||||||
|
transform: true,
|
||||||
|
transformOrigin: true,
|
||||||
|
alignContent: true,
|
||||||
|
alignItems: true,
|
||||||
|
alignSelf: true,
|
||||||
|
borderEndWidth: true,
|
||||||
|
borderStartWidth: true,
|
||||||
|
bottom: true,
|
||||||
|
display: true,
|
||||||
|
end: true,
|
||||||
|
flexBasis: true,
|
||||||
|
flexDirection: true,
|
||||||
|
flexWrap: true,
|
||||||
|
gap: true,
|
||||||
|
columnGap: true,
|
||||||
|
rowGap: true,
|
||||||
|
justifyContent: true,
|
||||||
|
left: true,
|
||||||
|
margin: true,
|
||||||
|
marginBlock: true,
|
||||||
|
marginBlockEnd: true,
|
||||||
|
marginBlockStart: true,
|
||||||
|
marginInline: true,
|
||||||
|
marginInlineStart: true,
|
||||||
|
marginInlineEnd: true,
|
||||||
|
marginBottom: true,
|
||||||
|
marginEnd: true,
|
||||||
|
marginHorizontal: true,
|
||||||
|
marginLeft: true,
|
||||||
|
marginRight: true,
|
||||||
|
marginStart: true,
|
||||||
|
marginTop: true,
|
||||||
|
marginVertical: true,
|
||||||
|
overflow: true,
|
||||||
|
padding: true,
|
||||||
|
paddingBottom: true,
|
||||||
|
paddingInline: true,
|
||||||
|
paddingBlock: true,
|
||||||
|
paddingBlockStart: true,
|
||||||
|
paddingInlineEnd: true,
|
||||||
|
paddingInlineStart: true,
|
||||||
|
paddingEnd: true,
|
||||||
|
paddingHorizontal: true,
|
||||||
|
paddingLeft: true,
|
||||||
|
paddingRight: true,
|
||||||
|
paddingStart: true,
|
||||||
|
paddingTop: true,
|
||||||
|
paddingVertical: true,
|
||||||
|
position: true,
|
||||||
|
right: true,
|
||||||
|
start: true,
|
||||||
|
top: true,
|
||||||
|
inset: true,
|
||||||
|
insetBlock: true,
|
||||||
|
insetBlockEnd: true,
|
||||||
|
insetBlockStart: true,
|
||||||
|
insetInline: true,
|
||||||
|
insetInlineEnd: true,
|
||||||
|
insetInlineStart: true,
|
||||||
|
direction: true,
|
||||||
|
shadowOffset: true,
|
||||||
|
shadowRadius: true,
|
||||||
|
...tokenCategories.color,
|
||||||
|
...tokenCategories.radius,
|
||||||
|
...tokenCategories.size,
|
||||||
|
...tokenCategories.radius,
|
||||||
|
...stylePropsTransform,
|
||||||
|
...stylePropsUnitless,
|
||||||
|
boxShadow: true,
|
||||||
|
filter: true,
|
||||||
|
// RN 0.77+ style props (set REACT_NATIVE_PRE_77=1 for older RN)
|
||||||
|
...!process.env.REACT_NATIVE_PRE_77 && {
|
||||||
|
boxSizing: true,
|
||||||
|
mixBlendMode: true,
|
||||||
|
outlineColor: true,
|
||||||
|
outlineSpread: true,
|
||||||
|
outlineStyle: true,
|
||||||
|
outlineWidth: true
|
||||||
|
},
|
||||||
|
// RN doesn't support specific border styles per-edge
|
||||||
|
transition: true,
|
||||||
|
textWrap: true,
|
||||||
|
backdropFilter: true,
|
||||||
|
WebkitBackdropFilter: true,
|
||||||
|
background: true,
|
||||||
|
backgroundAttachment: true,
|
||||||
|
backgroundBlendMode: true,
|
||||||
|
backgroundClip: true,
|
||||||
|
backgroundColor: true,
|
||||||
|
backgroundImage: true,
|
||||||
|
backgroundOrigin: true,
|
||||||
|
backgroundPosition: true,
|
||||||
|
backgroundRepeat: true,
|
||||||
|
backgroundSize: true,
|
||||||
|
borderBottomStyle: true,
|
||||||
|
borderImage: true,
|
||||||
|
borderLeftStyle: true,
|
||||||
|
borderRightStyle: true,
|
||||||
|
borderTopStyle: true,
|
||||||
|
caretColor: true,
|
||||||
|
clipPath: true,
|
||||||
|
contain: true,
|
||||||
|
containerType: true,
|
||||||
|
content: true,
|
||||||
|
cursor: true,
|
||||||
|
float: true,
|
||||||
|
mask: true,
|
||||||
|
maskBorder: true,
|
||||||
|
maskBorderMode: true,
|
||||||
|
maskBorderOutset: true,
|
||||||
|
maskBorderRepeat: true,
|
||||||
|
maskBorderSlice: true,
|
||||||
|
maskBorderSource: true,
|
||||||
|
maskBorderWidth: true,
|
||||||
|
maskClip: true,
|
||||||
|
maskComposite: true,
|
||||||
|
maskImage: true,
|
||||||
|
maskMode: true,
|
||||||
|
maskOrigin: true,
|
||||||
|
maskPosition: true,
|
||||||
|
maskRepeat: true,
|
||||||
|
maskSize: true,
|
||||||
|
maskType: true,
|
||||||
|
objectFit: true,
|
||||||
|
objectPosition: true,
|
||||||
|
outlineOffset: true,
|
||||||
|
overflowBlock: true,
|
||||||
|
overflowInline: true,
|
||||||
|
overflowX: true,
|
||||||
|
overflowY: true,
|
||||||
|
pointerEvents: true,
|
||||||
|
scrollbarWidth: true,
|
||||||
|
textEmphasis: true,
|
||||||
|
touchAction: true,
|
||||||
|
transformStyle: true,
|
||||||
|
userSelect: true,
|
||||||
|
willChange: true,
|
||||||
|
...isAndroid ? {
|
||||||
|
elevationAndroid: true
|
||||||
|
} : {}
|
||||||
|
};
|
||||||
|
var stylePropsFont = {
|
||||||
|
fontFamily: true,
|
||||||
|
fontSize: true,
|
||||||
|
fontStyle: true,
|
||||||
|
fontWeight: true,
|
||||||
|
fontVariant: true,
|
||||||
|
letterSpacing: true,
|
||||||
|
lineHeight: true,
|
||||||
|
textTransform: true
|
||||||
|
};
|
||||||
|
var stylePropsTextOnly = {
|
||||||
|
...stylePropsFont,
|
||||||
|
textAlign: true,
|
||||||
|
textDecorationLine: true,
|
||||||
|
textDecorationStyle: true,
|
||||||
|
...textColors,
|
||||||
|
textShadowOffset: true,
|
||||||
|
textShadowRadius: true,
|
||||||
|
userSelect: true,
|
||||||
|
selectable: true,
|
||||||
|
verticalAlign: true,
|
||||||
|
whiteSpace: true,
|
||||||
|
wordWrap: true,
|
||||||
|
textOverflow: true,
|
||||||
|
textDecorationDistance: true,
|
||||||
|
cursor: true,
|
||||||
|
WebkitLineClamp: true,
|
||||||
|
WebkitBoxOrient: true
|
||||||
|
};
|
||||||
|
var stylePropsText = {
|
||||||
|
...stylePropsView,
|
||||||
|
...stylePropsTextOnly
|
||||||
|
};
|
||||||
|
|
||||||
// node_modules/@tamagui/helpers/dist/esm/withStaticProperties.mjs
|
// node_modules/@tamagui/helpers/dist/esm/withStaticProperties.mjs
|
||||||
var import_react2 = __toESM(require("react"), 1);
|
var import_react2 = __toESM(require("react"), 1);
|
||||||
var Decorated = Symbol();
|
var Decorated = Symbol();
|
||||||
@@ -444,10 +755,7 @@ var SizableText2 = (0, import_web4.styled)(import_web4.Text, {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
SizableText2.staticConfig.variants.fontFamily = {
|
SizableText2.staticConfig.variants.fontFamily = {
|
||||||
"...": /* @__PURE__ */ __name((val, extras) => {
|
"...": /* @__PURE__ */ __name((_val, extras) => {
|
||||||
if (val === "inherit") return {
|
|
||||||
fontFamily: "inherit"
|
|
||||||
};
|
|
||||||
const sizeProp = extras.props.size, fontSizeProp = extras.props.fontSize, size = sizeProp === "$true" && fontSizeProp ? fontSizeProp : extras.props.size || "$true";
|
const sizeProp = extras.props.size, fontSizeProp = extras.props.fontSize, size = sizeProp === "$true" && fontSizeProp ? fontSizeProp : extras.props.size || "$true";
|
||||||
return getFontSized(size, extras);
|
return getFontSized(size, extras);
|
||||||
}, "...")
|
}, "...")
|
||||||
|
|||||||
@@ -112,10 +112,7 @@ var SizableText2 = (0, import_web2.styled)(import_web2.Text, {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
SizableText2.staticConfig.variants.fontFamily = {
|
SizableText2.staticConfig.variants.fontFamily = {
|
||||||
"...": /* @__PURE__ */ __name((val, extras) => {
|
"...": /* @__PURE__ */ __name((_val, extras) => {
|
||||||
if (val === "inherit") return {
|
|
||||||
fontFamily: "inherit"
|
|
||||||
};
|
|
||||||
const sizeProp = extras.props.size, fontSizeProp = extras.props.fontSize, size = sizeProp === "$true" && fontSizeProp ? fontSizeProp : extras.props.size || "$true";
|
const sizeProp = extras.props.size, fontSizeProp = extras.props.fontSize, size = sizeProp === "$true" && fontSizeProp ? fontSizeProp : extras.props.size || "$true";
|
||||||
return getFontSized(size, extras);
|
return getFontSized(size, extras);
|
||||||
}, "...")
|
}, "...")
|
||||||
|
|||||||
139
AGENTS.md
139
AGENTS.md
@@ -27,8 +27,8 @@ This repository hosts a multi-tenant event photo platform (Laravel 12, PHP 8.3,
|
|||||||
- Languages/Frameworks: PHP 8.2+ (Laravel 12), TypeScript/JavaScript (React 19/Vite 7/Tailwind 4), Filament 4.
|
- Languages/Frameworks: PHP 8.2+ (Laravel 12), TypeScript/JavaScript (React 19/Vite 7/Tailwind 4), Filament 4.
|
||||||
- Dev Commands: composer, npm, vite, artisan, PHPUnit, Pint/ESLint, Docker/Compose (for dev), Playwright, Vitest, TypeScript.
|
- Dev Commands: composer, npm, vite, artisan, PHPUnit, Pint/ESLint, Docker/Compose (for dev), Playwright, Vitest, TypeScript.
|
||||||
|
|
||||||
- Libraries: simplesoftwareio/simple-qrcode for server-side QR generation; Lemon Squeezy API client (custom service) for payments; dompdf for PDF generation; spatie/laravel-translatable for i18n; minishlink/web-push for web push; firebase/php-jwt for JWT; Sentry (Laravel + Vite); Stripe (PHP + JS); Tamagui (design system); i18next (frontend i18n); vite-plugin-pwa for PWA builds.
|
- Libraries: simplesoftwareio/simple-qrcode for server-side QR generation; Paddle API client (custom service) for payments; dompdf for PDF generation; spatie/laravel-translatable for i18n; minishlink/web-push for web push; firebase/php-jwt for JWT; Sentry (Laravel + Vite); Stripe (PHP + JS); Tamagui (design system); i18next (frontend i18n); vite-plugin-pwa for PWA builds.
|
||||||
- Payment Systems: Lemon Squeezy (subscriptions and one-time payments), RevenueCat (mobile app subscriptions), Stripe (legacy/integration use).
|
- Payment Systems: Paddle (subscriptions and one-time payments), RevenueCat (mobile app subscriptions), Stripe (legacy/integration use).
|
||||||
- PWA Technologies: React 19, Vite 7, Capacitor (iOS), Trusted Web Activity (Android), Service Workers, Background Sync.
|
- PWA Technologies: React 19, Vite 7, Capacitor (iOS), Trusted Web Activity (Android), Service Workers, Background Sync.
|
||||||
|
|
||||||
## Repo Structure (high-level)
|
## Repo Structure (high-level)
|
||||||
@@ -38,9 +38,6 @@ This repository hosts a multi-tenant event photo platform (Laravel 12, PHP 8.3,
|
|||||||
- resources/js/admin/ — Tenant Admin PWA source (React 19, Capacitor/TWA ready).
|
- resources/js/admin/ — Tenant Admin PWA source (React 19, Capacitor/TWA ready).
|
||||||
- resources/js/pages/ — Inertia pages (React).
|
- resources/js/pages/ — Inertia pages (React).
|
||||||
- docs/archive/README.md — historical PRP context.
|
- docs/archive/README.md — historical PRP context.
|
||||||
- Marketing frontend language files:
|
|
||||||
- Source translations: `resources/lang/{de,en}/marketing.php` and `resources/lang/{de,en}/marketing.json`.
|
|
||||||
- Runtime i18next JSON served to the frontend: `public/lang/{de,en}/marketing.json` (must stay in sync with the source files).
|
|
||||||
|
|
||||||
## Standard Workflows
|
## Standard Workflows
|
||||||
- Coding tasks (Codegen Agent):
|
- Coding tasks (Codegen Agent):
|
||||||
@@ -61,7 +58,7 @@ This repository hosts a multi-tenant event photo platform (Laravel 12, PHP 8.3,
|
|||||||
#### Billing & Packages
|
#### Billing & Packages
|
||||||
- package:check-status — check event package status.
|
- package:check-status — check event package status.
|
||||||
- packages:migrate-legacy — migrate legacy package purchases.
|
- packages:migrate-legacy — migrate legacy package purchases.
|
||||||
- lemonsqueezy:sync-packages — sync packages with Lemon Squeezy (push/pull/queue/dry-run).
|
- paddle:sync-packages — sync packages with Paddle (push/pull/queue/dry-run).
|
||||||
- coupons:export — export coupon redemptions.
|
- coupons:export — export coupon redemptions.
|
||||||
- checkout:send-reminders — send abandoned checkout reminders (dry-run supported).
|
- checkout:send-reminders — send abandoned checkout reminders (dry-run supported).
|
||||||
|
|
||||||
@@ -96,7 +93,7 @@ This repository hosts a multi-tenant event photo platform (Laravel 12, PHP 8.3,
|
|||||||
- metrics:package-limits — inspect/reset package limit metrics (routes/console.php).
|
- metrics:package-limits — inspect/reset package limit metrics (routes/console.php).
|
||||||
- inspire — inspiring quote (routes/console.php).
|
- inspire — inspiring quote (routes/console.php).
|
||||||
- Public APIs for Guest PWA: stats/photos endpoints with ETag; likes; uploads; see docs/archive/prp/03-api.md.
|
- Public APIs for Guest PWA: stats/photos endpoints with ETag; likes; uploads; see docs/archive/prp/03-api.md.
|
||||||
- Payment Integration: Lemon Squeezy webhooks, RevenueCat mobile subscriptions.
|
- Payment Integration: Paddle webhooks, RevenueCat mobile subscriptions.
|
||||||
|
|
||||||
## PWA Architecture
|
## PWA Architecture
|
||||||
- Guest PWA: Offline-first photo sharing app for event attendees (installable, background sync, no account required).
|
- Guest PWA: Offline-first photo sharing app for event attendees (installable, background sync, no account required).
|
||||||
@@ -623,134 +620,6 @@ export default () => (
|
|||||||
| overflow-ellipsis | text-ellipsis |
|
| overflow-ellipsis | text-ellipsis |
|
||||||
| decoration-slice | box-decoration-slice |
|
| decoration-slice | box-decoration-slice |
|
||||||
| decoration-clone | box-decoration-clone |
|
| decoration-clone | box-decoration-clone |
|
||||||
|
|
||||||
=== filament/filament rules ===
|
|
||||||
|
|
||||||
## Filament
|
|
||||||
|
|
||||||
- Filament is used by this application. Follow existing conventions for how and where it's implemented.
|
|
||||||
- Filament is a Server-Driven UI (SDUI) framework for Laravel that lets you define user interfaces in PHP using structured configuration objects. Built on Livewire, Alpine.js, and Tailwind CSS.
|
|
||||||
- Use the `search-docs` tool for official documentation on Artisan commands, code examples, testing, relationships, and idiomatic practices.
|
|
||||||
|
|
||||||
### Artisan
|
|
||||||
|
|
||||||
- Use Filament-specific Artisan commands to create files. Find them with `list-artisan-commands` or `php artisan --help`.
|
|
||||||
- Inspect required options and always pass `--no-interaction`.
|
|
||||||
|
|
||||||
### Patterns
|
|
||||||
|
|
||||||
Use static `make()` methods to initialize components. Most configuration methods accept a `Closure` for dynamic values.
|
|
||||||
|
|
||||||
Use `Get $get` to read other form field values for conditional logic:
|
|
||||||
|
|
||||||
<code-snippet name="Conditional form field" lang="php">
|
|
||||||
use Filament\Forms\Components\Select;
|
|
||||||
use Filament\Forms\Components\TextInput;
|
|
||||||
use Filament\Schemas\Components\Utilities\Get;
|
|
||||||
|
|
||||||
Select::make('type')
|
|
||||||
->options(CompanyType::class)
|
|
||||||
->required()
|
|
||||||
->live(),
|
|
||||||
|
|
||||||
TextInput::make('company_name')
|
|
||||||
->required()
|
|
||||||
->visible(fn (Get $get): bool => $get('type') === 'business'),
|
|
||||||
</code-snippet>
|
|
||||||
|
|
||||||
Use `state()` with a `Closure` to compute derived column values:
|
|
||||||
|
|
||||||
<code-snippet name="Computed table column" lang="php">
|
|
||||||
use Filament\Tables\Columns\TextColumn;
|
|
||||||
|
|
||||||
TextColumn::make('full_name')
|
|
||||||
->state(fn (User $record): string => "{$record->first_name} {$record->last_name}"),
|
|
||||||
</code-snippet>
|
|
||||||
|
|
||||||
Actions encapsulate a button with optional modal form and logic:
|
|
||||||
|
|
||||||
<code-snippet name="Action with modal form" lang="php">
|
|
||||||
use Filament\Actions\Action;
|
|
||||||
use Filament\Forms\Components\TextInput;
|
|
||||||
|
|
||||||
Action::make('updateEmail')
|
|
||||||
->form([
|
|
||||||
TextInput::make('email')->email()->required(),
|
|
||||||
])
|
|
||||||
->action(fn (array $data, User $record): void => $record->update($data)),
|
|
||||||
</code-snippet>
|
|
||||||
|
|
||||||
### Testing
|
|
||||||
|
|
||||||
Authenticate before testing panel functionality. Filament uses Livewire, so use `livewire()` or `Livewire::test()`:
|
|
||||||
|
|
||||||
<code-snippet name="Filament Table Test" lang="php">
|
|
||||||
livewire(ListUsers::class)
|
|
||||||
->assertCanSeeTableRecords($users)
|
|
||||||
->searchTable($users->first()->name)
|
|
||||||
->assertCanSeeTableRecords($users->take(1))
|
|
||||||
->assertCanNotSeeTableRecords($users->skip(1));
|
|
||||||
</code-snippet>
|
|
||||||
|
|
||||||
<code-snippet name="Filament Create Resource Test" lang="php">
|
|
||||||
livewire(CreateUser::class)
|
|
||||||
->fillForm([
|
|
||||||
'name' => 'Test',
|
|
||||||
'email' => 'test@example.com',
|
|
||||||
])
|
|
||||||
->call('create')
|
|
||||||
->assertNotified()
|
|
||||||
->assertRedirect();
|
|
||||||
|
|
||||||
assertDatabaseHas(User::class, [
|
|
||||||
'name' => 'Test',
|
|
||||||
'email' => 'test@example.com',
|
|
||||||
]);
|
|
||||||
</code-snippet>
|
|
||||||
|
|
||||||
<code-snippet name="Testing Validation" lang="php">
|
|
||||||
livewire(CreateUser::class)
|
|
||||||
->fillForm([
|
|
||||||
'name' => null,
|
|
||||||
'email' => 'invalid-email',
|
|
||||||
])
|
|
||||||
->call('create')
|
|
||||||
->assertHasFormErrors([
|
|
||||||
'name' => 'required',
|
|
||||||
'email' => 'email',
|
|
||||||
])
|
|
||||||
->assertNotNotified();
|
|
||||||
</code-snippet>
|
|
||||||
|
|
||||||
<code-snippet name="Calling Actions" lang="php">
|
|
||||||
use Filament\Actions\DeleteAction;
|
|
||||||
use Filament\Actions\Testing\TestAction;
|
|
||||||
|
|
||||||
livewire(EditUser::class, ['record' => $user->id])
|
|
||||||
->callAction(DeleteAction::class)
|
|
||||||
->assertNotified()
|
|
||||||
->assertRedirect();
|
|
||||||
|
|
||||||
livewire(ListUsers::class)
|
|
||||||
->callAction(TestAction::make('promote')->table($user), [
|
|
||||||
'role' => 'admin',
|
|
||||||
])
|
|
||||||
->assertNotified();
|
|
||||||
</code-snippet>
|
|
||||||
|
|
||||||
### Common Mistakes
|
|
||||||
|
|
||||||
**Commonly Incorrect Namespaces:**
|
|
||||||
- Form fields (TextInput, Select, etc.): `Filament\Forms\Components\`
|
|
||||||
- Infolist entries (for read-only views) (TextEntry, IconEntry, etc.): `Filament\Forms\Components\`
|
|
||||||
- Layout components (Grid, Section, Fieldset, Tabs, Wizard, etc.): `Filament\Schemas\Components\`
|
|
||||||
- Schema utilities (Get, Set, etc.): `Filament\Schemas\Components\Utilities\`
|
|
||||||
- Actions: `Filament\Actions\` (no `Filament\Tables\Actions\` etc.)
|
|
||||||
- Icons: `Filament\Support\Icons\Heroicon` enum (e.g., `Heroicon::PencilSquare`)
|
|
||||||
|
|
||||||
**Recent breaking changes to Filament:**
|
|
||||||
- File visibility is `private` by default. Use `->visibility('public')` for public access.
|
|
||||||
- `Grid`, `Section`, and `Fieldset` no longer span all columns by default.
|
|
||||||
</laravel-boost-guidelines>
|
</laravel-boost-guidelines>
|
||||||
|
|
||||||
## Issue Tracking
|
## Issue Tracking
|
||||||
|
|||||||
@@ -100,8 +100,6 @@ COPY . .
|
|||||||
COPY --from=vendor /var/www/html/vendor ./vendor
|
COPY --from=vendor /var/www/html/vendor ./vendor
|
||||||
COPY --from=node_builder /var/www/html/public/build ./public/build
|
COPY --from=node_builder /var/www/html/public/build ./public/build
|
||||||
|
|
||||||
RUN php artisan vendor:publish --tag=livewire:assets --force --no-interaction
|
|
||||||
|
|
||||||
RUN php artisan config:clear \
|
RUN php artisan config:clear \
|
||||||
&& php artisan config:cache \
|
&& php artisan config:cache \
|
||||||
&& php artisan route:clear \
|
&& php artisan route:clear \
|
||||||
|
|||||||
@@ -1,144 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
namespace App\Console\Commands;
|
|
||||||
|
|
||||||
use App\Models\AiEditOutput;
|
|
||||||
use App\Models\AiEditRequest;
|
|
||||||
use App\Services\AiEditing\AiEditOutputStorageService;
|
|
||||||
use Illuminate\Console\Command;
|
|
||||||
use Illuminate\Database\Eloquent\Builder;
|
|
||||||
|
|
||||||
class AiEditsBackfillStorageCommand extends Command
|
|
||||||
{
|
|
||||||
protected $signature = 'ai-edits:backfill-storage
|
|
||||||
{--request-id= : Restrict backfill to one AI edit request id}
|
|
||||||
{--limit=200 : Maximum outputs to process}
|
|
||||||
{--pretend : Dry run without writing changes}';
|
|
||||||
|
|
||||||
protected $description = 'Backfill local storage paths for AI outputs that only have provider URLs.';
|
|
||||||
|
|
||||||
public function __construct(private readonly AiEditOutputStorageService $outputStorage)
|
|
||||||
{
|
|
||||||
parent::__construct();
|
|
||||||
}
|
|
||||||
|
|
||||||
public function handle(): int
|
|
||||||
{
|
|
||||||
$limit = max(1, (int) $this->option('limit'));
|
|
||||||
$requestId = $this->normalizeRequestId($this->option('request-id'));
|
|
||||||
$pretend = (bool) $this->option('pretend');
|
|
||||||
|
|
||||||
$query = AiEditOutput::query()
|
|
||||||
->with('request')
|
|
||||||
->whereNotNull('provider_url')
|
|
||||||
->where(function (Builder $builder): void {
|
|
||||||
$builder
|
|
||||||
->whereNull('storage_path')
|
|
||||||
->orWhere('storage_path', '');
|
|
||||||
})
|
|
||||||
->orderBy('id');
|
|
||||||
|
|
||||||
if ($requestId !== null) {
|
|
||||||
$query->where('request_id', $requestId);
|
|
||||||
}
|
|
||||||
|
|
||||||
$candidateCount = (clone $query)->count();
|
|
||||||
$outputs = $query->limit($limit)->get();
|
|
||||||
|
|
||||||
if ($outputs->isEmpty()) {
|
|
||||||
$this->info('No AI outputs require storage backfill.');
|
|
||||||
|
|
||||||
return self::SUCCESS;
|
|
||||||
}
|
|
||||||
|
|
||||||
$this->line(sprintf(
|
|
||||||
'AI output backfill candidates: %d (processing up to %d).',
|
|
||||||
$candidateCount,
|
|
||||||
$limit
|
|
||||||
));
|
|
||||||
|
|
||||||
if ($pretend) {
|
|
||||||
$this->table(
|
|
||||||
['Output ID', 'Request ID', 'Provider URL'],
|
|
||||||
$outputs->map(static fn (AiEditOutput $output): array => [
|
|
||||||
(string) $output->id,
|
|
||||||
(string) $output->request_id,
|
|
||||||
(string) $output->provider_url,
|
|
||||||
])->all()
|
|
||||||
);
|
|
||||||
|
|
||||||
$this->info('Pretend mode enabled. No records were changed.');
|
|
||||||
|
|
||||||
return self::SUCCESS;
|
|
||||||
}
|
|
||||||
|
|
||||||
$processed = 0;
|
|
||||||
$stored = 0;
|
|
||||||
$failed = 0;
|
|
||||||
|
|
||||||
foreach ($outputs as $output) {
|
|
||||||
$processed++;
|
|
||||||
|
|
||||||
$request = $output->request;
|
|
||||||
if (! $request instanceof AiEditRequest) {
|
|
||||||
$failed++;
|
|
||||||
$this->warn(sprintf('Output %d skipped: missing request relation.', $output->id));
|
|
||||||
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
$persisted = $this->outputStorage->persist($request, [
|
|
||||||
'provider_url' => $output->provider_url,
|
|
||||||
'provider_asset_id' => $output->provider_asset_id,
|
|
||||||
'storage_disk' => $output->storage_disk,
|
|
||||||
'storage_path' => $output->storage_path,
|
|
||||||
'mime_type' => $output->mime_type,
|
|
||||||
'width' => $output->width,
|
|
||||||
'height' => $output->height,
|
|
||||||
'bytes' => $output->bytes,
|
|
||||||
'checksum' => $output->checksum,
|
|
||||||
'metadata' => $output->metadata,
|
|
||||||
]);
|
|
||||||
|
|
||||||
$output->forceFill([
|
|
||||||
'provider_url' => $persisted['provider_url'] ?? $output->provider_url,
|
|
||||||
'storage_disk' => $persisted['storage_disk'] ?? $output->storage_disk,
|
|
||||||
'storage_path' => $persisted['storage_path'] ?? $output->storage_path,
|
|
||||||
'mime_type' => $persisted['mime_type'] ?? $output->mime_type,
|
|
||||||
'width' => array_key_exists('width', $persisted) ? $persisted['width'] : $output->width,
|
|
||||||
'height' => array_key_exists('height', $persisted) ? $persisted['height'] : $output->height,
|
|
||||||
'bytes' => array_key_exists('bytes', $persisted) ? $persisted['bytes'] : $output->bytes,
|
|
||||||
'checksum' => $persisted['checksum'] ?? $output->checksum,
|
|
||||||
'metadata' => is_array($persisted['metadata'] ?? null) ? $persisted['metadata'] : $output->metadata,
|
|
||||||
])->save();
|
|
||||||
|
|
||||||
$storagePath = trim((string) ($output->storage_path ?? ''));
|
|
||||||
if ($storagePath !== '') {
|
|
||||||
$stored++;
|
|
||||||
} else {
|
|
||||||
$failed++;
|
|
||||||
$this->warn(sprintf('Output %d could not be persisted locally.', $output->id));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
$this->info(sprintf(
|
|
||||||
'AI output backfill complete: processed=%d stored=%d failed=%d.',
|
|
||||||
$processed,
|
|
||||||
$stored,
|
|
||||||
$failed
|
|
||||||
));
|
|
||||||
|
|
||||||
return self::SUCCESS;
|
|
||||||
}
|
|
||||||
|
|
||||||
private function normalizeRequestId(mixed $value): ?int
|
|
||||||
{
|
|
||||||
if (! is_numeric($value)) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
$requestId = (int) $value;
|
|
||||||
|
|
||||||
return $requestId > 0 ? $requestId : null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,69 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
namespace App\Console\Commands;
|
|
||||||
|
|
||||||
use App\Models\AiEditRequest;
|
|
||||||
use App\Models\AiUsageLedger;
|
|
||||||
use Illuminate\Console\Command;
|
|
||||||
use Illuminate\Database\Eloquent\Builder;
|
|
||||||
|
|
||||||
class AiEditsPruneCommand extends Command
|
|
||||||
{
|
|
||||||
protected $signature = 'ai-edits:prune
|
|
||||||
{--request-days= : Override AI request retention days}
|
|
||||||
{--ledger-days= : Override usage ledger retention days}
|
|
||||||
{--pretend : Report counts without deleting data}';
|
|
||||||
|
|
||||||
protected $description = 'Prune stale AI edit requests and usage ledgers based on retention settings.';
|
|
||||||
|
|
||||||
public function handle(): int
|
|
||||||
{
|
|
||||||
$requestRetentionDays = max(1, (int) ($this->option('request-days') ?: config('ai-editing.retention.request_days', 90)));
|
|
||||||
$ledgerRetentionDays = max(1, (int) ($this->option('ledger-days') ?: config('ai-editing.retention.usage_ledger_days', 365)));
|
|
||||||
$pretend = (bool) $this->option('pretend');
|
|
||||||
|
|
||||||
$requestCutoff = now()->subDays($requestRetentionDays);
|
|
||||||
$ledgerCutoff = now()->subDays($ledgerRetentionDays);
|
|
||||||
|
|
||||||
$requestQuery = AiEditRequest::query()
|
|
||||||
->where(function (Builder $query) use ($requestCutoff): void {
|
|
||||||
$query->where(function (Builder $completedQuery) use ($requestCutoff): void {
|
|
||||||
$completedQuery->whereNotNull('completed_at')
|
|
||||||
->where('completed_at', '<=', $requestCutoff);
|
|
||||||
})->orWhere(function (Builder $expiredQuery): void {
|
|
||||||
$expiredQuery->whereNotNull('expires_at')
|
|
||||||
->where('expires_at', '<=', now());
|
|
||||||
});
|
|
||||||
});
|
|
||||||
$ledgerQuery = AiUsageLedger::query()
|
|
||||||
->where('recorded_at', '<=', $ledgerCutoff);
|
|
||||||
|
|
||||||
$requestCount = (clone $requestQuery)->count();
|
|
||||||
$ledgerCount = (clone $ledgerQuery)->count();
|
|
||||||
|
|
||||||
$this->line(sprintf(
|
|
||||||
'AI prune candidates -> requests: %d (<= %s), ledgers: %d (<= %s)',
|
|
||||||
$requestCount,
|
|
||||||
$requestCutoff->toDateString(),
|
|
||||||
$ledgerCount,
|
|
||||||
$ledgerCutoff->toDateString()
|
|
||||||
));
|
|
||||||
|
|
||||||
if ($pretend) {
|
|
||||||
$this->info('Pretend mode enabled. No records were deleted.');
|
|
||||||
|
|
||||||
return self::SUCCESS;
|
|
||||||
}
|
|
||||||
|
|
||||||
$deletedRequests = $requestQuery->delete();
|
|
||||||
$deletedLedgers = $ledgerQuery->delete();
|
|
||||||
|
|
||||||
$this->info(sprintf(
|
|
||||||
'Pruned AI data -> requests: %d, ledgers: %d.',
|
|
||||||
$deletedRequests,
|
|
||||||
$deletedLedgers
|
|
||||||
));
|
|
||||||
|
|
||||||
return self::SUCCESS;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,179 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
namespace App\Console\Commands;
|
|
||||||
|
|
||||||
use App\Jobs\PollAiEditRequest;
|
|
||||||
use App\Jobs\ProcessAiEditRequest;
|
|
||||||
use App\Models\AiEditRequest;
|
|
||||||
use App\Services\AiEditing\AiEditingRuntimeConfig;
|
|
||||||
use Illuminate\Console\Command;
|
|
||||||
use Illuminate\Database\Eloquent\Builder;
|
|
||||||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
|
||||||
use Illuminate\Support\Collection;
|
|
||||||
|
|
||||||
class AiEditsRecoverStuckCommand extends Command
|
|
||||||
{
|
|
||||||
protected $signature = 'ai-edits:recover-stuck
|
|
||||||
{--minutes=30 : Minimum age in minutes for queued/processing requests}
|
|
||||||
{--requeue : Re-dispatch stuck requests back to the queue}
|
|
||||||
{--fail : Mark stuck requests as failed}';
|
|
||||||
|
|
||||||
protected $description = 'Inspect stuck AI edit requests and optionally recover them by requeueing or failing.';
|
|
||||||
|
|
||||||
public function __construct(private readonly AiEditingRuntimeConfig $runtimeConfig)
|
|
||||||
{
|
|
||||||
parent::__construct();
|
|
||||||
}
|
|
||||||
|
|
||||||
public function handle(): int
|
|
||||||
{
|
|
||||||
$minutes = max(1, (int) $this->option('minutes'));
|
|
||||||
$shouldRequeue = (bool) $this->option('requeue');
|
|
||||||
$shouldFail = (bool) $this->option('fail');
|
|
||||||
|
|
||||||
if ($shouldRequeue && $shouldFail) {
|
|
||||||
$this->error('Use either --requeue or --fail, not both.');
|
|
||||||
|
|
||||||
return self::FAILURE;
|
|
||||||
}
|
|
||||||
|
|
||||||
$cutoff = now()->subMinutes($minutes);
|
|
||||||
$requests = AiEditRequest::query()
|
|
||||||
->with([
|
|
||||||
'event:id,slug,name',
|
|
||||||
'providerRuns' => function (HasMany $query): void {
|
|
||||||
$query->select(['id', 'request_id', 'provider_task_id', 'attempt'])
|
|
||||||
->orderByDesc('attempt');
|
|
||||||
},
|
|
||||||
])
|
|
||||||
->whereIn('status', [AiEditRequest::STATUS_QUEUED, AiEditRequest::STATUS_PROCESSING])
|
|
||||||
->where(function (Builder $query) use ($cutoff): void {
|
|
||||||
$query
|
|
||||||
->where(function (Builder $queuedQuery) use ($cutoff): void {
|
|
||||||
$queuedQuery->whereNull('started_at')
|
|
||||||
->whereNotNull('queued_at')
|
|
||||||
->where('queued_at', '<=', $cutoff);
|
|
||||||
})
|
|
||||||
->orWhere(function (Builder $processingQuery) use ($cutoff): void {
|
|
||||||
$processingQuery->whereNotNull('started_at')
|
|
||||||
->where('started_at', '<=', $cutoff);
|
|
||||||
})
|
|
||||||
->orWhere(function (Builder $fallbackQuery) use ($cutoff): void {
|
|
||||||
$fallbackQuery->whereNull('queued_at')
|
|
||||||
->whereNull('started_at')
|
|
||||||
->where('updated_at', '<=', $cutoff);
|
|
||||||
});
|
|
||||||
})
|
|
||||||
->orderBy('updated_at')
|
|
||||||
->get();
|
|
||||||
|
|
||||||
if ($requests->isEmpty()) {
|
|
||||||
$this->info(sprintf('No stuck AI edit requests older than %d minute(s).', $minutes));
|
|
||||||
|
|
||||||
return self::SUCCESS;
|
|
||||||
}
|
|
||||||
|
|
||||||
$this->table(
|
|
||||||
['ID', 'Event', 'Status', 'Queued/Started', 'Latest task'],
|
|
||||||
$requests->map(function (AiEditRequest $request): array {
|
|
||||||
$latestTaskId = $this->latestProviderTaskId($request) ?? '-';
|
|
||||||
$eventLabel = (string) ($request->event?->name ?: $request->event?->slug ?: $request->event_id);
|
|
||||||
$ageSource = $request->started_at ?: $request->queued_at ?: $request->updated_at;
|
|
||||||
|
|
||||||
return [
|
|
||||||
(string) $request->id,
|
|
||||||
$eventLabel,
|
|
||||||
$request->status,
|
|
||||||
$ageSource?->toIso8601String() ?? '-',
|
|
||||||
$latestTaskId,
|
|
||||||
];
|
|
||||||
})->all()
|
|
||||||
);
|
|
||||||
|
|
||||||
if (! $shouldRequeue && ! $shouldFail) {
|
|
||||||
$this->info('Dry-run only. Use --requeue to dispatch recovery jobs or --fail to terminate stuck requests.');
|
|
||||||
|
|
||||||
return self::SUCCESS;
|
|
||||||
}
|
|
||||||
|
|
||||||
if ($shouldFail) {
|
|
||||||
$count = $this->markAsFailed($requests);
|
|
||||||
$this->info(sprintf('Marked %d AI edit request(s) as failed.', $count));
|
|
||||||
|
|
||||||
return self::SUCCESS;
|
|
||||||
}
|
|
||||||
|
|
||||||
[$processDispatches, $pollDispatches] = $this->requeueRequests($requests);
|
|
||||||
$this->info(sprintf(
|
|
||||||
'Recovered %d stuck AI edit request(s): %d process dispatch(es), %d poll dispatch(es).',
|
|
||||||
$processDispatches + $pollDispatches,
|
|
||||||
$processDispatches,
|
|
||||||
$pollDispatches
|
|
||||||
));
|
|
||||||
|
|
||||||
return self::SUCCESS;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @return array{0:int,1:int}
|
|
||||||
*/
|
|
||||||
private function requeueRequests(Collection $requests): array
|
|
||||||
{
|
|
||||||
$queueName = $this->runtimeConfig->queueName();
|
|
||||||
$processDispatches = 0;
|
|
||||||
$pollDispatches = 0;
|
|
||||||
|
|
||||||
foreach ($requests as $request) {
|
|
||||||
if ($request->status === AiEditRequest::STATUS_QUEUED) {
|
|
||||||
ProcessAiEditRequest::dispatch($request->id)->onQueue($queueName);
|
|
||||||
$processDispatches++;
|
|
||||||
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
$providerTaskId = $this->latestProviderTaskId($request);
|
|
||||||
if ($providerTaskId !== null) {
|
|
||||||
PollAiEditRequest::dispatch($request->id, $providerTaskId, 1)->onQueue($queueName);
|
|
||||||
$pollDispatches++;
|
|
||||||
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
ProcessAiEditRequest::dispatch($request->id)->onQueue($queueName);
|
|
||||||
$processDispatches++;
|
|
||||||
}
|
|
||||||
|
|
||||||
return [$processDispatches, $pollDispatches];
|
|
||||||
}
|
|
||||||
|
|
||||||
private function markAsFailed(Collection $requests): int
|
|
||||||
{
|
|
||||||
$updated = 0;
|
|
||||||
$now = now();
|
|
||||||
|
|
||||||
foreach ($requests as $request) {
|
|
||||||
$request->forceFill([
|
|
||||||
'status' => AiEditRequest::STATUS_FAILED,
|
|
||||||
'failure_code' => 'operator_recovery_marked_failed',
|
|
||||||
'failure_message' => 'Marked as failed by ai-edits:recover-stuck.',
|
|
||||||
'completed_at' => $now,
|
|
||||||
])->save();
|
|
||||||
|
|
||||||
$updated++;
|
|
||||||
}
|
|
||||||
|
|
||||||
return $updated;
|
|
||||||
}
|
|
||||||
|
|
||||||
private function latestProviderTaskId(AiEditRequest $request): ?string
|
|
||||||
{
|
|
||||||
foreach ($request->providerRuns as $run) {
|
|
||||||
$taskId = trim((string) ($run->provider_task_id ?? ''));
|
|
||||||
if ($taskId !== '') {
|
|
||||||
return $taskId;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -7,6 +7,7 @@ use App\Models\Tenant;
|
|||||||
use App\Models\User;
|
use App\Models\User;
|
||||||
use Illuminate\Console\Attributes\AsCommand;
|
use Illuminate\Console\Attributes\AsCommand;
|
||||||
use Illuminate\Console\Command;
|
use Illuminate\Console\Command;
|
||||||
|
use Illuminate\Support\Facades\DB;
|
||||||
|
|
||||||
#[AsCommand(name: 'tenant:attach-demo-event')]
|
#[AsCommand(name: 'tenant:attach-demo-event')]
|
||||||
class AttachDemoEvent extends Command
|
class AttachDemoEvent extends Command
|
||||||
@@ -24,12 +25,10 @@ class AttachDemoEvent extends Command
|
|||||||
{
|
{
|
||||||
if (! \Illuminate\Support\Facades\Schema::hasTable('events')) {
|
if (! \Illuminate\Support\Facades\Schema::hasTable('events')) {
|
||||||
$this->error("Table 'events' does not exist. Run: php artisan migrate");
|
$this->error("Table 'events' does not exist. Run: php artisan migrate");
|
||||||
|
|
||||||
return self::FAILURE;
|
return self::FAILURE;
|
||||||
}
|
}
|
||||||
if (! \Illuminate\Support\Facades\Schema::hasColumn('events', 'tenant_id')) {
|
if (! \Illuminate\Support\Facades\Schema::hasColumn('events', 'tenant_id')) {
|
||||||
$this->error("Column 'events.tenant_id' does not exist. Add it and rerun. Suggested: create a migration to add a nullable foreignId to tenants.");
|
$this->error("Column 'events.tenant_id' does not exist. Add it and rerun. Suggested: create a migration to add a nullable foreignId to tenants.");
|
||||||
|
|
||||||
return self::FAILURE;
|
return self::FAILURE;
|
||||||
}
|
}
|
||||||
$tenant = null;
|
$tenant = null;
|
||||||
@@ -46,7 +45,6 @@ class AttachDemoEvent extends Command
|
|||||||
}
|
}
|
||||||
if (! $tenant) {
|
if (! $tenant) {
|
||||||
$this->error('Tenant not found. Provide --tenant-slug or a user with tenant_id via --tenant-email.');
|
$this->error('Tenant not found. Provide --tenant-slug or a user with tenant_id via --tenant-email.');
|
||||||
|
|
||||||
return self::FAILURE;
|
return self::FAILURE;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -69,14 +67,12 @@ class AttachDemoEvent extends Command
|
|||||||
|
|
||||||
if (! $event) {
|
if (! $event) {
|
||||||
$this->error('Event not found. Provide --event-id or --event-slug.');
|
$this->error('Event not found. Provide --event-id or --event-slug.');
|
||||||
|
|
||||||
return self::FAILURE;
|
return self::FAILURE;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Idempotent update
|
// Idempotent update
|
||||||
if ((int) $event->tenant_id === (int) $tenant->id) {
|
if ((int) $event->tenant_id === (int) $tenant->id) {
|
||||||
$this->info("Event #{$event->id} already attached to tenant #{$tenant->id} ({$tenant->slug}).");
|
$this->info("Event #{$event->id} already attached to tenant #{$tenant->id} ({$tenant->slug}).");
|
||||||
|
|
||||||
return self::SUCCESS;
|
return self::SUCCESS;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -84,7 +80,6 @@ class AttachDemoEvent extends Command
|
|||||||
$event->save();
|
$event->save();
|
||||||
|
|
||||||
$this->info("Attached event #{$event->id} ({$event->slug}) to tenant #{$tenant->id} ({$tenant->slug}).");
|
$this->info("Attached event #{$event->id} ({$event->slug}) to tenant #{$tenant->id} ({$tenant->slug}).");
|
||||||
|
|
||||||
return self::SUCCESS;
|
return self::SUCCESS;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,27 +10,22 @@ use Illuminate\Support\Facades\Storage;
|
|||||||
class BackfillThumbnails extends Command
|
class BackfillThumbnails extends Command
|
||||||
{
|
{
|
||||||
protected $signature = 'media:backfill-thumbnails {--limit=500}';
|
protected $signature = 'media:backfill-thumbnails {--limit=500}';
|
||||||
|
|
||||||
protected $description = 'Generate thumbnails for photos missing thumbnail_path or where thumbnail equals original.';
|
protected $description = 'Generate thumbnails for photos missing thumbnail_path or where thumbnail equals original.';
|
||||||
|
|
||||||
public function handle(): int
|
public function handle(): int
|
||||||
{
|
{
|
||||||
$limit = (int) $this->option('limit');
|
$limit = (int) $this->option('limit');
|
||||||
$rows = DB::table('photos')
|
$rows = DB::table('photos')
|
||||||
->select(['id', 'event_id', 'file_path', 'thumbnail_path'])
|
->select(['id','event_id','file_path','thumbnail_path'])
|
||||||
->orderBy('id')
|
->orderBy('id')
|
||||||
->limit($limit)
|
->limit($limit)
|
||||||
->get();
|
->get();
|
||||||
$count = 0;
|
$count = 0;
|
||||||
foreach ($rows as $r) {
|
foreach ($rows as $r) {
|
||||||
$orig = $this->relativeFromUrl((string) $r->file_path);
|
$orig = $this->relativeFromUrl((string)$r->file_path);
|
||||||
$thumb = (string) ($r->thumbnail_path ?? '');
|
$thumb = (string)($r->thumbnail_path ?? '');
|
||||||
if ($thumb && $thumb !== $r->file_path) {
|
if ($thumb && $thumb !== $r->file_path) continue; // already set to different thumb
|
||||||
continue;
|
if (! $orig) continue;
|
||||||
} // already set to different thumb
|
|
||||||
if (! $orig) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
$baseName = pathinfo($orig, PATHINFO_FILENAME);
|
$baseName = pathinfo($orig, PATHINFO_FILENAME);
|
||||||
$destRel = "events/{$r->event_id}/photos/thumbs/{$baseName}_thumb.jpg";
|
$destRel = "events/{$r->event_id}/photos/thumbs/{$baseName}_thumb.jpg";
|
||||||
$made = ImageHelper::makeThumbnailOnDisk('public', $orig, $destRel, 640, 82);
|
$made = ImageHelper::makeThumbnailOnDisk('public', $orig, $destRel, 640, 82);
|
||||||
@@ -44,7 +39,6 @@ class BackfillThumbnails extends Command
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
$this->info("Done. Thumbnails generated: {$count}");
|
$this->info("Done. Thumbnails generated: {$count}");
|
||||||
|
|
||||||
return self::SUCCESS;
|
return self::SUCCESS;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -55,7 +49,6 @@ class BackfillThumbnails extends Command
|
|||||||
if (str_starts_with($p, '/storage/')) {
|
if (str_starts_with($p, '/storage/')) {
|
||||||
return substr($p, strlen('/storage/'));
|
return substr($p, strlen('/storage/'));
|
||||||
}
|
}
|
||||||
|
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,130 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
namespace App\Console\Commands;
|
|
||||||
|
|
||||||
use App\Services\LemonSqueezy\LemonSqueezyClient;
|
|
||||||
use Illuminate\Console\Command;
|
|
||||||
use Illuminate\Support\Arr;
|
|
||||||
use Illuminate\Support\Facades\Log;
|
|
||||||
|
|
||||||
class LemonSqueezyRegisterWebhooks extends Command
|
|
||||||
{
|
|
||||||
/**
|
|
||||||
* The name and signature of the console command.
|
|
||||||
*
|
|
||||||
* @var string
|
|
||||||
*/
|
|
||||||
protected $signature = 'lemonsqueezy:webhooks:register
|
|
||||||
{--url= : Destination URL for Lemon Squeezy webhooks}
|
|
||||||
{--events=* : Override event types to subscribe}
|
|
||||||
{--secret= : Override the webhook signing secret}
|
|
||||||
{--test-mode : Register the webhook in test mode}
|
|
||||||
{--dry-run : Output payload without creating the destination}';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* The console command description.
|
|
||||||
*
|
|
||||||
* @var string
|
|
||||||
*/
|
|
||||||
protected $description = 'Register Lemon Squeezy webhook notification settings.';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Execute the console command.
|
|
||||||
*/
|
|
||||||
public function handle(LemonSqueezyClient $client): int
|
|
||||||
{
|
|
||||||
$destination = (string) ($this->option('url') ?: $this->defaultWebhookUrl());
|
|
||||||
|
|
||||||
if ($destination === '') {
|
|
||||||
$this->error('Webhook destination URL is required. Use --url=...');
|
|
||||||
|
|
||||||
return self::FAILURE;
|
|
||||||
}
|
|
||||||
|
|
||||||
$events = collect((array) $this->option('events'))
|
|
||||||
->filter()
|
|
||||||
->map(fn ($event) => trim((string) $event))
|
|
||||||
->filter()
|
|
||||||
->values()
|
|
||||||
->all();
|
|
||||||
|
|
||||||
if ($events === []) {
|
|
||||||
$events = config('lemonsqueezy.webhook_events', []);
|
|
||||||
}
|
|
||||||
|
|
||||||
if ($events === [] || ! is_array($events)) {
|
|
||||||
$this->error('No webhook events configured. Set config(lemonsqueezy.webhook_events) or pass --events.');
|
|
||||||
|
|
||||||
return self::FAILURE;
|
|
||||||
}
|
|
||||||
|
|
||||||
$secret = (string) ($this->option('secret') ?: config('lemonsqueezy.webhook_secret'));
|
|
||||||
if ($secret === '') {
|
|
||||||
$this->error('Webhook signing secret is required. Set LEMONSQUEEZY_WEBHOOK_SECRET or pass --secret.');
|
|
||||||
|
|
||||||
return self::FAILURE;
|
|
||||||
}
|
|
||||||
|
|
||||||
$storeId = (string) config('lemonsqueezy.store_id');
|
|
||||||
if ($storeId === '') {
|
|
||||||
$this->error('Lemon Squeezy store id is required. Set LEMONSQUEEZY_STORE_ID.');
|
|
||||||
|
|
||||||
return self::FAILURE;
|
|
||||||
}
|
|
||||||
|
|
||||||
$testMode = (bool) $this->option('test-mode') || (bool) config('lemonsqueezy.test_mode', false);
|
|
||||||
|
|
||||||
$attributes = array_filter([
|
|
||||||
'url' => $destination,
|
|
||||||
'events' => $events,
|
|
||||||
'secret' => $secret,
|
|
||||||
'test_mode' => $testMode ? true : null,
|
|
||||||
], static fn ($value) => $value !== null && $value !== '');
|
|
||||||
|
|
||||||
$payload = [
|
|
||||||
'data' => [
|
|
||||||
'type' => 'webhooks',
|
|
||||||
'attributes' => $attributes,
|
|
||||||
'relationships' => [
|
|
||||||
'store' => [
|
|
||||||
'data' => [
|
|
||||||
'type' => 'stores',
|
|
||||||
'id' => $storeId,
|
|
||||||
],
|
|
||||||
],
|
|
||||||
],
|
|
||||||
],
|
|
||||||
];
|
|
||||||
|
|
||||||
if ((bool) $this->option('dry-run')) {
|
|
||||||
$this->line(json_encode($payload, JSON_PRETTY_PRINT));
|
|
||||||
|
|
||||||
return self::SUCCESS;
|
|
||||||
}
|
|
||||||
|
|
||||||
$response = $client->post('/webhooks', $payload);
|
|
||||||
$data = Arr::get($response, 'data', $response);
|
|
||||||
$id = Arr::get($data, 'id');
|
|
||||||
|
|
||||||
Log::channel('lemonsqueezy-sync')->info('Lemon Squeezy webhook registered', [
|
|
||||||
'webhook_id' => $id,
|
|
||||||
'destination' => $destination,
|
|
||||||
'test_mode' => $testMode,
|
|
||||||
]);
|
|
||||||
|
|
||||||
$this->info('Lemon Squeezy webhook registered.');
|
|
||||||
|
|
||||||
if ($id) {
|
|
||||||
$this->line('ID: '.$id);
|
|
||||||
}
|
|
||||||
|
|
||||||
return self::SUCCESS;
|
|
||||||
}
|
|
||||||
|
|
||||||
protected function defaultWebhookUrl(): string
|
|
||||||
{
|
|
||||||
$base = rtrim((string) config('app.url'), '/');
|
|
||||||
|
|
||||||
return $base !== '' ? $base.'/lemonsqueezy/webhook' : '';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -4,15 +4,15 @@ namespace App\Console\Commands;
|
|||||||
|
|
||||||
use App\Models\PackagePurchase;
|
use App\Models\PackagePurchase;
|
||||||
use App\Models\Tenant;
|
use App\Models\Tenant;
|
||||||
use App\Models\TenantPackage;
|
|
||||||
use App\Models\User;
|
use App\Models\User;
|
||||||
|
use App\Models\TenantPackage;
|
||||||
use Illuminate\Console\Command;
|
use Illuminate\Console\Command;
|
||||||
use Illuminate\Support\Facades\Hash;
|
use Illuminate\Support\Facades\Hash;
|
||||||
|
use Illuminate\Support\Facades\DB;
|
||||||
|
|
||||||
class MigrateLegacyPurchases extends Command
|
class MigrateLegacyPurchases extends Command
|
||||||
{
|
{
|
||||||
protected $signature = 'packages:migrate-legacy';
|
protected $signature = 'packages:migrate-legacy';
|
||||||
|
|
||||||
protected $description = 'Migrate legacy purchases to new system with temp tenants';
|
protected $description = 'Migrate legacy purchases to new system with temp tenants';
|
||||||
|
|
||||||
public function handle()
|
public function handle()
|
||||||
@@ -21,20 +21,19 @@ class MigrateLegacyPurchases extends Command
|
|||||||
|
|
||||||
if ($legacyPurchases->isEmpty()) {
|
if ($legacyPurchases->isEmpty()) {
|
||||||
$this->info('No legacy purchases found.');
|
$this->info('No legacy purchases found.');
|
||||||
|
|
||||||
return 0;
|
return 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
$this->info("Found {$legacyPurchases->count()} legacy purchases.");
|
$this->info("Found {$legacyPurchases->count()} legacy purchases.");
|
||||||
|
|
||||||
foreach ($legacyPurchases as $purchase) {
|
foreach ($legacyPurchases as $purchase) {
|
||||||
if (! $purchase->user_id) {
|
if (!$purchase->user_id) {
|
||||||
// Create temp user if no user
|
// Create temp user if no user
|
||||||
$tempUser = User::create([
|
$tempUser = User::create([
|
||||||
'name' => 'Legacy User '.$purchase->id,
|
'name' => 'Legacy User ' . $purchase->id,
|
||||||
'email' => 'legacy'.$purchase->id.'@fotospiel.local',
|
'email' => 'legacy' . $purchase->id . '@fotospiel.local',
|
||||||
'password' => Hash::make('legacy'),
|
'password' => Hash::make('legacy'),
|
||||||
'username' => 'legacy'.$purchase->id,
|
'username' => 'legacy' . $purchase->id,
|
||||||
'first_name' => 'Legacy',
|
'first_name' => 'Legacy',
|
||||||
'last_name' => 'User',
|
'last_name' => 'User',
|
||||||
'address' => 'Legacy Address',
|
'address' => 'Legacy Address',
|
||||||
@@ -44,7 +43,7 @@ class MigrateLegacyPurchases extends Command
|
|||||||
|
|
||||||
$tempTenant = Tenant::create([
|
$tempTenant = Tenant::create([
|
||||||
'user_id' => $tempUser->id,
|
'user_id' => $tempUser->id,
|
||||||
'name' => 'Legacy Tenant '.$purchase->id,
|
'name' => 'Legacy Tenant ' . $purchase->id,
|
||||||
'status' => 'active',
|
'status' => 'active',
|
||||||
]);
|
]);
|
||||||
|
|
||||||
@@ -74,7 +73,6 @@ class MigrateLegacyPurchases extends Command
|
|||||||
}
|
}
|
||||||
|
|
||||||
$this->info('Legacy migration completed.');
|
$this->info('Legacy migration completed.');
|
||||||
|
|
||||||
return 0;
|
return 0;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -46,12 +46,6 @@ class MonitorStorageCommand extends Command
|
|||||||
|
|
||||||
$assetStats = $this->buildAssetStatistics();
|
$assetStats = $this->buildAssetStatistics();
|
||||||
$thresholds = $this->capacityThresholds();
|
$thresholds = $this->capacityThresholds();
|
||||||
$checksumConfig = $this->checksumAlertConfig();
|
|
||||||
$checksumWindowMinutes = $checksumConfig['window_minutes'];
|
|
||||||
$checksumThresholds = $checksumConfig['thresholds'];
|
|
||||||
$checksumMismatches = $checksumConfig['enabled'] && $checksumWindowMinutes > 0
|
|
||||||
? $this->checksumMismatchCounts($checksumWindowMinutes)
|
|
||||||
: [];
|
|
||||||
$alerts = [];
|
$alerts = [];
|
||||||
$snapshotTargets = [];
|
$snapshotTargets = [];
|
||||||
|
|
||||||
@@ -84,7 +78,6 @@ class MonitorStorageCommand extends Command
|
|||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
$targetChecksumMismatches = $checksumMismatches[$target->id] ?? 0;
|
|
||||||
$snapshotTargets[] = [
|
$snapshotTargets[] = [
|
||||||
'id' => $target->id,
|
'id' => $target->id,
|
||||||
'key' => $target->key,
|
'key' => $target->key,
|
||||||
@@ -92,35 +85,13 @@ class MonitorStorageCommand extends Command
|
|||||||
'is_hot' => (bool) $target->is_hot,
|
'is_hot' => (bool) $target->is_hot,
|
||||||
'capacity' => $capacity,
|
'capacity' => $capacity,
|
||||||
'assets' => $assets,
|
'assets' => $assets,
|
||||||
'checksum_mismatches' => [
|
|
||||||
'count' => $targetChecksumMismatches,
|
|
||||||
'window_minutes' => $checksumWindowMinutes,
|
|
||||||
],
|
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
if ($checksumConfig['enabled'] && $checksumWindowMinutes > 0) {
|
|
||||||
$totalMismatches = array_sum($checksumMismatches);
|
|
||||||
$checksumSeverity = $this->determineChecksumSeverity($totalMismatches, $checksumThresholds);
|
|
||||||
|
|
||||||
if ($checksumSeverity !== 'ok') {
|
|
||||||
$alerts[] = [
|
|
||||||
'type' => 'checksum_mismatch',
|
|
||||||
'severity' => $checksumSeverity,
|
|
||||||
'count' => $totalMismatches,
|
|
||||||
'window_minutes' => $checksumWindowMinutes,
|
|
||||||
];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
$snapshot = [
|
$snapshot = [
|
||||||
'generated_at' => now()->toIso8601String(),
|
'generated_at' => now()->toIso8601String(),
|
||||||
'targets' => $snapshotTargets,
|
'targets' => $snapshotTargets,
|
||||||
'alerts' => $alerts,
|
'alerts' => $alerts,
|
||||||
'checksum' => [
|
|
||||||
'window_minutes' => $checksumWindowMinutes,
|
|
||||||
'mismatch_total' => array_sum($checksumMismatches),
|
|
||||||
],
|
|
||||||
];
|
];
|
||||||
|
|
||||||
$ttlMinutes = max(1, (int) config('storage-monitor.monitor.cache_minutes', 15));
|
$ttlMinutes = max(1, (int) config('storage-monitor.monitor.cache_minutes', 15));
|
||||||
@@ -220,62 +191,4 @@ class MonitorStorageCommand extends Command
|
|||||||
|
|
||||||
return 'ok';
|
return 'ok';
|
||||||
}
|
}
|
||||||
|
|
||||||
private function checksumAlertConfig(): array
|
|
||||||
{
|
|
||||||
$enabled = (bool) config('storage-monitor.checksum_validation.enabled', true);
|
|
||||||
$windowMinutes = max(0, (int) config('storage-monitor.checksum_validation.alert_window_minutes', 60));
|
|
||||||
$warning = (int) config('storage-monitor.checksum_validation.thresholds.warning', 1);
|
|
||||||
$critical = (int) config('storage-monitor.checksum_validation.thresholds.critical', 5);
|
|
||||||
|
|
||||||
if ($warning > $critical && $critical > 0) {
|
|
||||||
[$warning, $critical] = [$critical, $warning];
|
|
||||||
}
|
|
||||||
|
|
||||||
return [
|
|
||||||
'enabled' => $enabled,
|
|
||||||
'window_minutes' => $windowMinutes,
|
|
||||||
'thresholds' => [
|
|
||||||
'warning' => $warning,
|
|
||||||
'critical' => $critical,
|
|
||||||
],
|
|
||||||
];
|
|
||||||
}
|
|
||||||
|
|
||||||
private function checksumMismatchCounts(int $windowMinutes): array
|
|
||||||
{
|
|
||||||
$query = EventMediaAsset::query()
|
|
||||||
->selectRaw('media_storage_target_id, COUNT(*) as total_count')
|
|
||||||
->where('status', 'failed')
|
|
||||||
->where('meta->checksum_status', 'mismatch');
|
|
||||||
|
|
||||||
if ($windowMinutes > 0) {
|
|
||||||
$query->where('updated_at', '>=', now()->subMinutes($windowMinutes));
|
|
||||||
}
|
|
||||||
|
|
||||||
return $query->groupBy('media_storage_target_id')
|
|
||||||
->get()
|
|
||||||
->mapWithKeys(fn ($row) => [(int) $row->media_storage_target_id => (int) $row->total_count])
|
|
||||||
->all();
|
|
||||||
}
|
|
||||||
|
|
||||||
private function determineChecksumSeverity(int $count, array $thresholds): string
|
|
||||||
{
|
|
||||||
$warning = (int) ($thresholds['warning'] ?? 1);
|
|
||||||
$critical = (int) ($thresholds['critical'] ?? 5);
|
|
||||||
|
|
||||||
if ($count <= 0) {
|
|
||||||
return 'ok';
|
|
||||||
}
|
|
||||||
|
|
||||||
if ($critical > 0 && $count >= $critical) {
|
|
||||||
return 'critical';
|
|
||||||
}
|
|
||||||
|
|
||||||
if ($warning > 0 && $count >= $warning) {
|
|
||||||
return 'warning';
|
|
||||||
}
|
|
||||||
|
|
||||||
return 'ok';
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
132
app/Console/Commands/PaddleRegisterWebhooks.php
Normal file
132
app/Console/Commands/PaddleRegisterWebhooks.php
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2,23 +2,23 @@
|
|||||||
|
|
||||||
namespace App\Console\Commands;
|
namespace App\Console\Commands;
|
||||||
|
|
||||||
use App\Jobs\PullPackageFromLemonSqueezy;
|
use App\Jobs\PullPackageFromPaddle;
|
||||||
use App\Jobs\SyncPackageToLemonSqueezy;
|
use App\Jobs\SyncPackageToPaddle;
|
||||||
use App\Models\Package;
|
use App\Models\Package;
|
||||||
use Illuminate\Console\Command;
|
use Illuminate\Console\Command;
|
||||||
use Illuminate\Support\Collection;
|
use Illuminate\Support\Collection;
|
||||||
use Illuminate\Support\Str;
|
use Illuminate\Support\Str;
|
||||||
|
|
||||||
class LemonSqueezySyncPackages extends Command
|
class PaddleSyncPackages extends Command
|
||||||
{
|
{
|
||||||
protected $signature = 'lemonsqueezy:sync-packages
|
protected $signature = 'paddle:sync-packages
|
||||||
{--package=* : Limit sync to the given package IDs or slugs}
|
{--package=* : Limit sync to the given package IDs or slugs}
|
||||||
{--dry-run : Generate payload snapshots without calling Lemon Squeezy}
|
{--dry-run : Generate payload snapshots without calling Paddle}
|
||||||
{--pull : Fetch remote Lemon Squeezy state instead of pushing local changes}
|
{--pull : Fetch remote Paddle state instead of pushing local changes}
|
||||||
{--allow-unmapped : Allow sync when packages are missing Lemon Squeezy product/variant IDs}
|
{--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 Lemon Squeezy products and variants.';
|
protected $description = 'Synchronise local packages with Paddle products and prices.';
|
||||||
|
|
||||||
public function handle(): int
|
public function handle(): int
|
||||||
{
|
{
|
||||||
@@ -52,7 +52,7 @@ class LemonSqueezySyncPackages extends Command
|
|||||||
});
|
});
|
||||||
|
|
||||||
$this->info(sprintf(
|
$this->info(sprintf(
|
||||||
'Queued %d package %s for Lemon Squeezy %s.',
|
'Queued %d package %s for Paddle %s.',
|
||||||
$packages->count(),
|
$packages->count(),
|
||||||
Str::plural('entry', $packages->count()),
|
Str::plural('entry', $packages->count()),
|
||||||
$pull ? 'pull' : 'sync'
|
$pull ? 'pull' : 'sync'
|
||||||
@@ -97,22 +97,22 @@ class LemonSqueezySyncPackages extends Command
|
|||||||
|
|
||||||
protected function guardUnmappedPackages(Collection $packages): bool
|
protected function guardUnmappedPackages(Collection $packages): bool
|
||||||
{
|
{
|
||||||
$unmapped = $packages->filter(fn (Package $package) => blank($package->lemonsqueezy_product_id) || blank($package->lemonsqueezy_variant_id));
|
$unmapped = $packages->filter(fn (Package $package) => blank($package->paddle_product_id) || blank($package->paddle_price_id));
|
||||||
|
|
||||||
if ($unmapped->isEmpty()) {
|
if ($unmapped->isEmpty()) {
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
$this->error('Unmapped Lemon Squeezy package IDs detected. Resolve mappings or pass --allow-unmapped.');
|
$this->error('Unmapped Paddle package IDs detected. Resolve legacy mappings or pass --allow-unmapped.');
|
||||||
$this->table(
|
$this->table(
|
||||||
['ID', 'Slug', 'Missing'],
|
['ID', 'Slug', 'Missing'],
|
||||||
$unmapped->map(function (Package $package): array {
|
$unmapped->map(function (Package $package): array {
|
||||||
$missing = [];
|
$missing = [];
|
||||||
if (blank($package->lemonsqueezy_product_id)) {
|
if (blank($package->paddle_product_id)) {
|
||||||
$missing[] = 'product_id';
|
$missing[] = 'product_id';
|
||||||
}
|
}
|
||||||
if (blank($package->lemonsqueezy_variant_id)) {
|
if (blank($package->paddle_price_id)) {
|
||||||
$missing[] = 'variant_id';
|
$missing[] = 'price_id';
|
||||||
}
|
}
|
||||||
|
|
||||||
return [
|
return [
|
||||||
@@ -133,26 +133,26 @@ class LemonSqueezySyncPackages extends Command
|
|||||||
];
|
];
|
||||||
|
|
||||||
if ($queue) {
|
if ($queue) {
|
||||||
SyncPackageToLemonSqueezy::dispatch($package->id, $context);
|
SyncPackageToPaddle::dispatch($package->id, $context);
|
||||||
$this->line(sprintf('> queued sync for package #%d (%s)', $package->id, $package->slug));
|
$this->line(sprintf('> queued sync for package #%d (%s)', $package->id, $package->slug));
|
||||||
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
SyncPackageToLemonSqueezy::dispatchSync($package->id, $context);
|
SyncPackageToPaddle::dispatchSync($package->id, $context);
|
||||||
$this->line(sprintf('> synced package #%d (%s)', $package->id, $package->slug));
|
$this->line(sprintf('> synced package #%d (%s)', $package->id, $package->slug));
|
||||||
}
|
}
|
||||||
|
|
||||||
protected function dispatchPullJob(Package $package, bool $queue): void
|
protected function dispatchPullJob(Package $package, bool $queue): void
|
||||||
{
|
{
|
||||||
if ($queue) {
|
if ($queue) {
|
||||||
PullPackageFromLemonSqueezy::dispatch($package->id);
|
PullPackageFromPaddle::dispatch($package->id);
|
||||||
$this->line(sprintf('> queued pull for package #%d (%s)', $package->id, $package->slug));
|
$this->line(sprintf('> queued pull for package #%d (%s)', $package->id, $package->slug));
|
||||||
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
PullPackageFromLemonSqueezy::dispatchSync($package->id);
|
PullPackageFromPaddle::dispatchSync($package->id);
|
||||||
$this->line(sprintf('> pulled package #%d (%s)', $package->id, $package->slug));
|
$this->line(sprintf('> pulled package #%d (%s)', $package->id, $package->slug));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -62,7 +62,7 @@ class SendAbandonedCheckoutReminders extends Command
|
|||||||
if ($this->shouldSendReminder($checkout, $stage)) {
|
if ($this->shouldSendReminder($checkout, $stage)) {
|
||||||
$resumeUrl = $this->generateResumeUrl($checkout);
|
$resumeUrl = $this->generateResumeUrl($checkout);
|
||||||
|
|
||||||
if (! $isDryRun) {
|
if (!$isDryRun) {
|
||||||
$mailLocale = $checkout->user->preferred_locale ?? config('app.locale');
|
$mailLocale = $checkout->user->preferred_locale ?? config('app.locale');
|
||||||
|
|
||||||
Mail::to($checkout->user)
|
Mail::to($checkout->user)
|
||||||
@@ -86,8 +86,8 @@ class SendAbandonedCheckoutReminders extends Command
|
|||||||
$totalProcessed++;
|
$totalProcessed++;
|
||||||
}
|
}
|
||||||
} catch (Throwable $e) {
|
} catch (Throwable $e) {
|
||||||
Log::error("Failed to send {$stage} reminder for checkout {$checkout->id}: ".$e->getMessage());
|
Log::error("Failed to send {$stage} reminder for checkout {$checkout->id}: " . $e->getMessage());
|
||||||
$this->error(" ❌ Failed to process checkout {$checkout->id}: ".$e->getMessage());
|
$this->error(" ❌ Failed to process checkout {$checkout->id}: " . $e->getMessage());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -98,7 +98,7 @@ class SendAbandonedCheckoutReminders extends Command
|
|||||||
->count();
|
->count();
|
||||||
|
|
||||||
if ($oldCheckouts > 0) {
|
if ($oldCheckouts > 0) {
|
||||||
if (! $isDryRun) {
|
if (!$isDryRun) {
|
||||||
AbandonedCheckoutModel::where('abandoned_at', '<', now()->subDays(30))
|
AbandonedCheckoutModel::where('abandoned_at', '<', now()->subDays(30))
|
||||||
->where('converted', false)
|
->where('converted', false)
|
||||||
->delete();
|
->delete();
|
||||||
@@ -108,10 +108,10 @@ class SendAbandonedCheckoutReminders extends Command
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
$this->info('✅ Reminder process completed!');
|
$this->info("✅ Reminder process completed!");
|
||||||
$this->info(" Processed: {$totalProcessed} checkouts");
|
$this->info(" Processed: {$totalProcessed} checkouts");
|
||||||
|
|
||||||
if (! $isDryRun) {
|
if (!$isDryRun) {
|
||||||
$this->info(" Sent: {$totalSent} reminder emails");
|
$this->info(" Sent: {$totalSent} reminder emails");
|
||||||
} else {
|
} else {
|
||||||
$this->info(" Would send: {$totalSent} reminder emails");
|
$this->info(" Would send: {$totalSent} reminder emails");
|
||||||
@@ -131,12 +131,12 @@ class SendAbandonedCheckoutReminders extends Command
|
|||||||
}
|
}
|
||||||
|
|
||||||
// User existiert noch?
|
// User existiert noch?
|
||||||
if (! $checkout->user) {
|
if (!$checkout->user) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Package existiert noch?
|
// Package existiert noch?
|
||||||
if (! $checkout->package) {
|
if (!$checkout->package) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ use Illuminate\Support\Str;
|
|||||||
|
|
||||||
class SyncGoogleFonts extends Command
|
class SyncGoogleFonts extends Command
|
||||||
{
|
{
|
||||||
protected $signature = 'fonts:sync-google {--count=50 : Number of popular fonts to fetch} {--weights=400,700 : Comma separated numeric font weights to download} {--italic : Also download italic variants where available} {--force : Re-download files even if they exist} {--path= : Optional custom output directory (defaults to public/fonts/google)} {--family= : Download specific family name(s), comma separated (case-insensitive)} {--category= : Filter by category, comma separated (e.g. sans-serif,serif)} {--prune : Remove local font families not included in this sync} {--dry-run : Show what would be downloaded without writing files} {--from-disk : Rebuild manifest + CSS from existing font files without downloading}';
|
protected $signature = 'fonts:sync-google {--count=50 : Number of popular fonts to fetch} {--weights=400,700 : Comma separated numeric font weights to download} {--italic : Also download italic variants where available} {--force : Re-download files even if they exist} {--path= : Optional custom output directory (defaults to public/fonts/google)} {--family= : Download specific family name(s), comma separated (case-insensitive)} {--category= : Filter by category, comma separated (e.g. sans-serif,serif)} {--prune : Remove local font families not included in this sync} {--dry-run : Show what would be downloaded without writing files}';
|
||||||
|
|
||||||
protected $description = 'Download the most popular Google Fonts to the local public/fonts/google directory and generate a manifest + CSS file.';
|
protected $description = 'Download the most popular Google Fonts to the local public/fonts/google directory and generate a manifest + CSS file.';
|
||||||
|
|
||||||
@@ -20,17 +20,6 @@ class SyncGoogleFonts extends Command
|
|||||||
|
|
||||||
public function handle(): int
|
public function handle(): int
|
||||||
{
|
{
|
||||||
$dryRun = (bool) $this->option('dry-run');
|
|
||||||
$fromDisk = (bool) $this->option('from-disk');
|
|
||||||
$pathOption = $this->option('path');
|
|
||||||
$basePath = $pathOption
|
|
||||||
? (Str::startsWith($pathOption, DIRECTORY_SEPARATOR) ? $pathOption : base_path($pathOption))
|
|
||||||
: public_path('fonts/google');
|
|
||||||
|
|
||||||
if ($fromDisk) {
|
|
||||||
return $this->syncFromDisk($basePath, $dryRun);
|
|
||||||
}
|
|
||||||
|
|
||||||
$apiKey = config('services.google_fonts.key');
|
$apiKey = config('services.google_fonts.key');
|
||||||
|
|
||||||
if (! $apiKey) {
|
if (! $apiKey) {
|
||||||
@@ -43,10 +32,16 @@ class SyncGoogleFonts extends Command
|
|||||||
$weights = $this->prepareWeights($this->option('weights'));
|
$weights = $this->prepareWeights($this->option('weights'));
|
||||||
$includeItalic = (bool) $this->option('italic');
|
$includeItalic = (bool) $this->option('italic');
|
||||||
$force = (bool) $this->option('force');
|
$force = (bool) $this->option('force');
|
||||||
|
$dryRun = (bool) $this->option('dry-run');
|
||||||
$families = $this->normalizeFamilyOption($this->option('family'));
|
$families = $this->normalizeFamilyOption($this->option('family'));
|
||||||
$categories = $this->prepareCategories($this->option('category'));
|
$categories = $this->prepareCategories($this->option('category'));
|
||||||
$prune = (bool) $this->option('prune');
|
$prune = (bool) $this->option('prune');
|
||||||
|
|
||||||
|
$pathOption = $this->option('path');
|
||||||
|
$basePath = $pathOption
|
||||||
|
? (Str::startsWith($pathOption, DIRECTORY_SEPARATOR) ? $pathOption : base_path($pathOption))
|
||||||
|
: public_path('fonts/google');
|
||||||
|
|
||||||
if (count($families)) {
|
if (count($families)) {
|
||||||
$label = count($families) > 1 ? 'families' : 'family';
|
$label = count($families) > 1 ? 'families' : 'family';
|
||||||
$this->info(sprintf('Fetching Google Font %s "%s" (weights: %s, italic: %s)...', $label, implode(', ', $families), implode(', ', $weights), $includeItalic ? 'yes' : 'no'));
|
$this->info(sprintf('Fetching Google Font %s "%s" (weights: %s, italic: %s)...', $label, implode(', ', $families), implode(', ', $weights), $includeItalic ? 'yes' : 'no'));
|
||||||
@@ -211,204 +206,6 @@ class SyncGoogleFonts extends Command
|
|||||||
return self::SUCCESS;
|
return self::SUCCESS;
|
||||||
}
|
}
|
||||||
|
|
||||||
private function syncFromDisk(string $basePath, bool $dryRun): int
|
|
||||||
{
|
|
||||||
if (! File::isDirectory($basePath)) {
|
|
||||||
$this->error(sprintf('Font directory not found: %s', $basePath));
|
|
||||||
|
|
||||||
return self::FAILURE;
|
|
||||||
}
|
|
||||||
|
|
||||||
if ($this->option('prune')) {
|
|
||||||
$this->warn('Ignoring --prune when rebuilding from disk.');
|
|
||||||
}
|
|
||||||
|
|
||||||
$fonts = $this->buildManifestFromDisk($basePath);
|
|
||||||
|
|
||||||
if (! count($fonts)) {
|
|
||||||
$this->warn('No fonts found on disk.');
|
|
||||||
}
|
|
||||||
|
|
||||||
if ($dryRun) {
|
|
||||||
$this->info(sprintf('Dry run complete: %d font families would be written to %s', count($fonts), $basePath));
|
|
||||||
|
|
||||||
return self::SUCCESS;
|
|
||||||
}
|
|
||||||
|
|
||||||
$this->writeManifest($basePath, $fonts);
|
|
||||||
$this->writeCss($basePath, $fonts);
|
|
||||||
Cache::forget('fonts:manifest');
|
|
||||||
|
|
||||||
$this->info(sprintf('Rebuilt manifest for %d font families from %s', count($fonts), $basePath));
|
|
||||||
|
|
||||||
return self::SUCCESS;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @return array<int, array<string, mixed>>
|
|
||||||
*/
|
|
||||||
private function buildManifestFromDisk(string $basePath): array
|
|
||||||
{
|
|
||||||
$directories = File::directories($basePath);
|
|
||||||
$fonts = [];
|
|
||||||
|
|
||||||
foreach ($directories as $dir) {
|
|
||||||
$slug = basename($dir);
|
|
||||||
$files = collect(File::files($dir))
|
|
||||||
->filter(function (\SplFileInfo $file) {
|
|
||||||
$extension = strtolower($file->getExtension());
|
|
||||||
|
|
||||||
return in_array($extension, ['woff2', 'woff', 'otf', 'ttf'], true);
|
|
||||||
})
|
|
||||||
->values();
|
|
||||||
|
|
||||||
if (! $files->count()) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
$variantsByKey = [];
|
|
||||||
foreach ($files as $file) {
|
|
||||||
$filename = $file->getFilename();
|
|
||||||
$extension = strtolower($file->getExtension());
|
|
||||||
$style = $this->extractStyleFromFilename($filename);
|
|
||||||
$weight = $this->extractWeightFromFilename($filename);
|
|
||||||
$variantKey = $this->buildVariantKey($weight, $style);
|
|
||||||
$priority = $this->extensionPriority($extension);
|
|
||||||
$relativePath = sprintf('/fonts/google/%s/%s', $slug, $filename);
|
|
||||||
|
|
||||||
$existing = $variantsByKey[$variantKey] ?? null;
|
|
||||||
if ($existing && ($existing['priority'] ?? 0) >= $priority) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
$variantsByKey[$variantKey] = [
|
|
||||||
'variant' => $variantKey,
|
|
||||||
'weight' => $weight,
|
|
||||||
'style' => $style,
|
|
||||||
'url' => $relativePath,
|
|
||||||
'priority' => $priority,
|
|
||||||
];
|
|
||||||
}
|
|
||||||
|
|
||||||
if (! count($variantsByKey)) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
$variants = array_values(array_map(function (array $variant) {
|
|
||||||
unset($variant['priority']);
|
|
||||||
|
|
||||||
return $variant;
|
|
||||||
}, $variantsByKey));
|
|
||||||
|
|
||||||
usort($variants, function (array $left, array $right) {
|
|
||||||
$weightCompare = ($left['weight'] ?? 400) <=> ($right['weight'] ?? 400);
|
|
||||||
if ($weightCompare !== 0) {
|
|
||||||
return $weightCompare;
|
|
||||||
}
|
|
||||||
|
|
||||||
return strcmp((string) ($left['style'] ?? 'normal'), (string) ($right['style'] ?? 'normal'));
|
|
||||||
});
|
|
||||||
|
|
||||||
$fonts[] = [
|
|
||||||
'family' => $this->familyFromSlug($slug),
|
|
||||||
'slug' => $slug,
|
|
||||||
'category' => null,
|
|
||||||
'variants' => $variants,
|
|
||||||
];
|
|
||||||
}
|
|
||||||
|
|
||||||
usort($fonts, fn (array $left, array $right) => strcmp((string) $left['family'], (string) $right['family']));
|
|
||||||
|
|
||||||
return $fonts;
|
|
||||||
}
|
|
||||||
|
|
||||||
private function familyFromSlug(string $slug): string
|
|
||||||
{
|
|
||||||
$parts = array_filter(explode('-', $slug), fn ($part) => $part !== '');
|
|
||||||
|
|
||||||
$words = array_map(function (string $part) {
|
|
||||||
if (is_numeric($part)) {
|
|
||||||
return $part;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (strlen($part) <= 3) {
|
|
||||||
return strtoupper($part);
|
|
||||||
}
|
|
||||||
|
|
||||||
return ucfirst(strtolower($part));
|
|
||||||
}, $parts);
|
|
||||||
|
|
||||||
return trim(implode(' ', $words));
|
|
||||||
}
|
|
||||||
|
|
||||||
private function extractStyleFromFilename(string $filename): string
|
|
||||||
{
|
|
||||||
$lower = strtolower($filename);
|
|
||||||
|
|
||||||
return str_contains($lower, 'italic') || str_contains($lower, 'oblique') ? 'italic' : 'normal';
|
|
||||||
}
|
|
||||||
|
|
||||||
private function extractWeightFromFilename(string $filename): int
|
|
||||||
{
|
|
||||||
if (preg_match('/(?:^|[^0-9])(100|200|300|400|500|600|700|800|900)(?:[^0-9]|$)/', $filename, $matches)) {
|
|
||||||
return (int) $matches[1];
|
|
||||||
}
|
|
||||||
|
|
||||||
$lower = strtolower($filename);
|
|
||||||
$weightMap = [
|
|
||||||
'thin' => 100,
|
|
||||||
'extralight' => 200,
|
|
||||||
'ultralight' => 200,
|
|
||||||
'light' => 300,
|
|
||||||
'regular' => 400,
|
|
||||||
'book' => 400,
|
|
||||||
'medium' => 500,
|
|
||||||
'semibold' => 600,
|
|
||||||
'demibold' => 600,
|
|
||||||
'bold' => 700,
|
|
||||||
'extrabold' => 800,
|
|
||||||
'ultrabold' => 800,
|
|
||||||
'black' => 900,
|
|
||||||
'heavy' => 900,
|
|
||||||
];
|
|
||||||
|
|
||||||
foreach ($weightMap as $label => $weight) {
|
|
||||||
if (str_contains($lower, $label)) {
|
|
||||||
return $weight;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return 400;
|
|
||||||
}
|
|
||||||
|
|
||||||
private function buildVariantKey(int $weight, string $style): string
|
|
||||||
{
|
|
||||||
if ($weight === 400 && $style === 'normal') {
|
|
||||||
return 'regular';
|
|
||||||
}
|
|
||||||
|
|
||||||
if ($weight === 400 && $style === 'italic') {
|
|
||||||
return 'italic';
|
|
||||||
}
|
|
||||||
|
|
||||||
if ($style === 'italic') {
|
|
||||||
return $weight.'italic';
|
|
||||||
}
|
|
||||||
|
|
||||||
return (string) $weight;
|
|
||||||
}
|
|
||||||
|
|
||||||
private function extensionPriority(string $extension): int
|
|
||||||
{
|
|
||||||
return match ($extension) {
|
|
||||||
'woff2' => 4,
|
|
||||||
'woff' => 3,
|
|
||||||
'otf' => 2,
|
|
||||||
'ttf' => 1,
|
|
||||||
default => 0,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @return array<int, string>
|
* @return array<int, string>
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -5,6 +5,8 @@ namespace App\Exports;
|
|||||||
use App\Models\EventPurchase;
|
use App\Models\EventPurchase;
|
||||||
use Filament\Actions\Exports\Exporter;
|
use Filament\Actions\Exports\Exporter;
|
||||||
use Filament\Actions\Exports\Models\Export;
|
use Filament\Actions\Exports\Models\Export;
|
||||||
|
use Illuminate\Database\Eloquent\Builder;
|
||||||
|
use Illuminate\Support\Collection;
|
||||||
|
|
||||||
class EventPurchaseExporter extends Exporter
|
class EventPurchaseExporter extends Exporter
|
||||||
{
|
{
|
||||||
@@ -26,6 +28,7 @@ class EventPurchaseExporter extends Exporter
|
|||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
public static function getCompletedNotificationBody(Export $export): string
|
public static function getCompletedNotificationBody(Export $export): string
|
||||||
{
|
{
|
||||||
$body = "Your Event Purchases export has completed and is ready for download. {$export->successful_rows} purchases were exported.";
|
$body = "Your Event Purchases export has completed and is ready for download. {$export->successful_rows} purchases were exported.";
|
||||||
|
|||||||
@@ -79,10 +79,9 @@ class PostResource extends Resource
|
|||||||
->label('Inhalt')
|
->label('Inhalt')
|
||||||
->required()
|
->required()
|
||||||
->columnSpanFull(),
|
->columnSpanFull(),
|
||||||
Textarea::make('excerpt.de')
|
TextInput::make('excerpt.de')
|
||||||
->label('Auszug')
|
->label('Auszug')
|
||||||
->maxLength(65535)
|
->maxLength(255),
|
||||||
->columnSpanFull(),
|
|
||||||
TextInput::make('meta_title.de')
|
TextInput::make('meta_title.de')
|
||||||
->label('Meta-Titel')
|
->label('Meta-Titel')
|
||||||
->maxLength(255),
|
->maxLength(255),
|
||||||
@@ -100,10 +99,9 @@ class PostResource extends Resource
|
|||||||
MarkdownEditor::make('content.en')
|
MarkdownEditor::make('content.en')
|
||||||
->label('Inhalt')
|
->label('Inhalt')
|
||||||
->columnSpanFull(),
|
->columnSpanFull(),
|
||||||
Textarea::make('excerpt.en')
|
TextInput::make('excerpt.en')
|
||||||
->label('Auszug')
|
->label('Auszug')
|
||||||
->maxLength(65535)
|
->maxLength(255),
|
||||||
->columnSpanFull(),
|
|
||||||
TextInput::make('meta_title.en')
|
TextInput::make('meta_title.en')
|
||||||
->label('Meta-Titel')
|
->label('Meta-Titel')
|
||||||
->maxLength(255),
|
->maxLength(255),
|
||||||
@@ -123,10 +121,9 @@ class PostResource extends Resource
|
|||||||
->unique(BlogPost::class, 'slug', ignoreRecord: true)
|
->unique(BlogPost::class, 'slug', ignoreRecord: true)
|
||||||
->maxLength(255)
|
->maxLength(255)
|
||||||
->columnSpanFull(),
|
->columnSpanFull(),
|
||||||
FileUpload::make('banner')
|
FileUpload::make('featured_image')
|
||||||
->label('Featured Image')
|
->label('Featured Image')
|
||||||
->image()
|
->image()
|
||||||
->disk('public')
|
|
||||||
->directory('blog')
|
->directory('blog')
|
||||||
->visibility('public'),
|
->visibility('public'),
|
||||||
Select::make('blog_category_id')
|
Select::make('blog_category_id')
|
||||||
|
|||||||
@@ -1,16 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
namespace App\Filament\Clusters\DailyOps\Resources\TenantCheckoutHealths\Pages;
|
|
||||||
|
|
||||||
use App\Filament\Clusters\DailyOps\Resources\TenantCheckoutHealths\TenantCheckoutHealthResource;
|
|
||||||
use Filament\Resources\Pages\ListRecords;
|
|
||||||
|
|
||||||
class ListTenantCheckoutHealths extends ListRecords
|
|
||||||
{
|
|
||||||
protected static string $resource = TenantCheckoutHealthResource::class;
|
|
||||||
|
|
||||||
protected function getHeaderActions(): array
|
|
||||||
{
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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 [];
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,8 +1,8 @@
|
|||||||
<?php
|
<?php
|
||||||
|
|
||||||
namespace App\Filament\Clusters\DailyOps\Resources\TenantCheckoutHealths\Tables;
|
namespace App\Filament\Clusters\DailyOps\Resources\TenantPaddleHealths\Tables;
|
||||||
|
|
||||||
use App\Filament\Clusters\DailyOps\Resources\TenantCheckoutHealths\TenantCheckoutHealthResource;
|
use App\Filament\Clusters\DailyOps\Resources\TenantPaddleHealths\TenantPaddleHealthResource;
|
||||||
use App\Models\CheckoutSession;
|
use App\Models\CheckoutSession;
|
||||||
use App\Models\Tenant;
|
use App\Models\Tenant;
|
||||||
use Filament\Forms\Components\TextInput;
|
use Filament\Forms\Components\TextInput;
|
||||||
@@ -13,9 +13,12 @@ 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\Support\Carbon;
|
use Illuminate\Support\Carbon;
|
||||||
|
use Illuminate\Support\Str;
|
||||||
|
|
||||||
class TenantCheckoutHealthTable
|
class TenantPaddleHealthTable
|
||||||
{
|
{
|
||||||
|
private const FAILED_SYNC_STATUSES = ['failed', 'pull-failed'];
|
||||||
|
|
||||||
public static function configure(Table $table): Table
|
public static function configure(Table $table): Table
|
||||||
{
|
{
|
||||||
return $table
|
return $table
|
||||||
@@ -32,6 +35,11 @@ class TenantCheckoutHealthTable
|
|||||||
->label(__('admin.tenants.fields.contact_email'))
|
->label(__('admin.tenants.fields.contact_email'))
|
||||||
->searchable()
|
->searchable()
|
||||||
->toggleable(isToggledHiddenByDefault: true),
|
->toggleable(isToggledHiddenByDefault: true),
|
||||||
|
TextColumn::make('paddle_customer_id')
|
||||||
|
->label('Paddle customer')
|
||||||
|
->toggleable(isToggledHiddenByDefault: true)
|
||||||
|
->copyable()
|
||||||
|
->formatStateUsing(fn (?string $state) => $state ?: '—'),
|
||||||
TextColumn::make('subscription_status')
|
TextColumn::make('subscription_status')
|
||||||
->label('Subscription')
|
->label('Subscription')
|
||||||
->badge()
|
->badge()
|
||||||
@@ -48,77 +56,134 @@ class TenantCheckoutHealthTable
|
|||||||
->badge()
|
->badge()
|
||||||
->color(fn (string $state) => $state === '—' ? 'gray' : 'success')
|
->color(fn (string $state) => $state === '—' ? 'gray' : 'success')
|
||||||
->toggleable(isToggledHiddenByDefault: true),
|
->toggleable(isToggledHiddenByDefault: true),
|
||||||
|
TextColumn::make('paddle_subscription_id')
|
||||||
|
->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')
|
IconColumn::make('status_mismatch')
|
||||||
->label('Status mismatch')
|
->label('Status mismatch')
|
||||||
->boolean()
|
->boolean()
|
||||||
->getStateUsing(fn (Tenant $record) => self::hasStatusMismatch($record)),
|
->getStateUsing(fn (Tenant $record) => self::hasStatusMismatch($record)),
|
||||||
TextColumn::make('last_checkout_transaction_at')
|
TextColumn::make('paddle_customer_duplicates')
|
||||||
->label('Last transaction')
|
->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()
|
->badge()
|
||||||
->color(fn (?Carbon $state) => self::transactionAgeColor($state))
|
->color(fn (?Carbon $state) => self::transactionAgeColor($state))
|
||||||
->getStateUsing(fn (Tenant $record) => $record->last_checkout_transaction_at
|
->getStateUsing(fn (Tenant $record) => $record->last_paddle_transaction_at
|
||||||
? Carbon::parse($record->last_checkout_transaction_at)
|
? Carbon::parse($record->last_paddle_transaction_at)
|
||||||
: null)
|
: null)
|
||||||
->formatStateUsing(fn (?Carbon $state) => $state?->diffForHumans() ?? '—')
|
->formatStateUsing(fn (?Carbon $state) => $state?->diffForHumans() ?? '—')
|
||||||
->toggleable(isToggledHiddenByDefault: true),
|
->toggleable(isToggledHiddenByDefault: true),
|
||||||
TextColumn::make('checkout_transaction_count_window')
|
TextColumn::make('paddle_transaction_count_window')
|
||||||
->label('Transactions (30d)')
|
->label('Paddle tx (30d)')
|
||||||
->default('0')
|
->default('0')
|
||||||
->sortable()
|
->sortable()
|
||||||
->toggleable(),
|
->toggleable(),
|
||||||
TextColumn::make('checkout_transaction_total_window')
|
TextColumn::make('paddle_transaction_total_window')
|
||||||
->label('Total (30d)')
|
->label('Paddle total (30d)')
|
||||||
->default(0)
|
->default(0)
|
||||||
->money('EUR')
|
->money('EUR')
|
||||||
->sortable()
|
->sortable()
|
||||||
->toggleable(),
|
->toggleable(),
|
||||||
TextColumn::make('checkout_refund_count_window')
|
TextColumn::make('paddle_refund_count_window')
|
||||||
->label('Refunds (30d)')
|
->label('Refunds (30d)')
|
||||||
->badge()
|
->badge()
|
||||||
->color(fn (?int $state) => $state && $state > 0 ? 'danger' : 'gray')
|
->color(fn (?int $state) => $state && $state > 0 ? 'danger' : 'gray')
|
||||||
->default('0')
|
->default('0')
|
||||||
->sortable()
|
->sortable()
|
||||||
->toggleable(isToggledHiddenByDefault: true),
|
->toggleable(isToggledHiddenByDefault: true),
|
||||||
TextColumn::make('checkout_refund_total_window')
|
TextColumn::make('paddle_refund_total_window')
|
||||||
->label('Refund total (30d)')
|
->label('Refund total (30d)')
|
||||||
->default(0)
|
->default(0)
|
||||||
->money('EUR')
|
->money('EUR')
|
||||||
->sortable()
|
->sortable()
|
||||||
->toggleable(isToggledHiddenByDefault: true),
|
->toggleable(isToggledHiddenByDefault: true),
|
||||||
TextColumn::make('checkout_requires_action_count')
|
TextColumn::make('paddle_checkout_requires_action_count')
|
||||||
->label('Checkout action required')
|
->label('Checkout action required')
|
||||||
->badge()
|
->badge()
|
||||||
->color(fn (?int $state) => $state && $state > 0 ? 'warning' : 'gray')
|
->color(fn (?int $state) => $state && $state > 0 ? 'warning' : 'gray')
|
||||||
->default('0')
|
->default('0')
|
||||||
->sortable()
|
->sortable()
|
||||||
->toggleable(isToggledHiddenByDefault: true),
|
->toggleable(isToggledHiddenByDefault: true),
|
||||||
TextColumn::make('checkout_processing_count')
|
TextColumn::make('paddle_checkout_processing_count')
|
||||||
->label('Checkout processing')
|
->label('Checkout processing')
|
||||||
->badge()
|
->badge()
|
||||||
->color(fn (?int $state) => $state && $state > 0 ? 'warning' : 'gray')
|
->color(fn (?int $state) => $state && $state > 0 ? 'warning' : 'gray')
|
||||||
->default('0')
|
->default('0')
|
||||||
->sortable()
|
->sortable()
|
||||||
->toggleable(isToggledHiddenByDefault: true),
|
->toggleable(isToggledHiddenByDefault: true),
|
||||||
TextColumn::make('checkout_expired_count')
|
TextColumn::make('paddle_checkout_expired_count')
|
||||||
->label('Checkout expired')
|
->label('Checkout expired')
|
||||||
->badge()
|
->badge()
|
||||||
->color(fn (?int $state) => $state && $state > 0 ? 'danger' : 'gray')
|
->color(fn (?int $state) => $state && $state > 0 ? 'danger' : 'gray')
|
||||||
->default('0')
|
->default('0')
|
||||||
->sortable()
|
->sortable()
|
||||||
->toggleable(isToggledHiddenByDefault: true),
|
->toggleable(isToggledHiddenByDefault: true),
|
||||||
TextColumn::make('checkout_transaction_count')
|
TextColumn::make('paddle_transaction_count')
|
||||||
->label('Transactions (all)')
|
->label('Paddle tx (all)')
|
||||||
->default('0')
|
->default('0')
|
||||||
->sortable()
|
->sortable()
|
||||||
->toggleable(isToggledHiddenByDefault: true),
|
->toggleable(isToggledHiddenByDefault: true),
|
||||||
TextColumn::make('checkout_transaction_total')
|
TextColumn::make('paddle_transaction_total')
|
||||||
->label('Total (all)')
|
->label('Paddle total (all)')
|
||||||
->default(0)
|
->default(0)
|
||||||
->money('EUR')
|
->money('EUR')
|
||||||
->sortable()
|
->sortable()
|
||||||
->toggleable(isToggledHiddenByDefault: true),
|
->toggleable(isToggledHiddenByDefault: true),
|
||||||
])
|
])
|
||||||
->filters([
|
->filters([
|
||||||
|
Filter::make('missing_paddle_customer')
|
||||||
|
->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')
|
Filter::make('status_mismatch')
|
||||||
->label('Status mismatch')
|
->label('Status mismatch')
|
||||||
->indicator('Status mismatch')
|
->indicator('Status mismatch')
|
||||||
@@ -140,24 +205,39 @@ class TenantCheckoutHealthTable
|
|||||||
->where('is_suspended', false)
|
->where('is_suspended', false)
|
||||||
->whereNull('pending_deletion_at')
|
->whereNull('pending_deletion_at')
|
||||||
->whereNull('anonymized_at')),
|
->whereNull('anonymized_at')),
|
||||||
Filter::make('checkout_transaction_stale')
|
Filter::make('paddle_sync_failed')
|
||||||
->label('Stale transactions')
|
->label('Paddle sync failed')
|
||||||
->indicator('Stale transactions')
|
->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 {
|
->query(function (Builder $query): Builder {
|
||||||
$cutoff = now()->subDays(TenantCheckoutHealthResource::TRANSACTION_WINDOW_DAYS);
|
$cutoff = now()->subDays(TenantPaddleHealthResource::TRANSACTION_WINDOW_DAYS);
|
||||||
$provider = TenantCheckoutHealthResource::provider();
|
|
||||||
|
|
||||||
return $query
|
return $query
|
||||||
->whereHas('purchases', fn (Builder $query) => $query->where('provider', $provider))
|
->whereHas('purchases', fn (Builder $query) => $query->where('provider', 'paddle'))
|
||||||
->whereDoesntHave('purchases', fn (Builder $query) => $query
|
->whereDoesntHave('purchases', fn (Builder $query) => $query
|
||||||
->where('provider', $provider)
|
->where('provider', 'paddle')
|
||||||
->where('purchased_at', '>=', $cutoff));
|
->where('purchased_at', '>=', $cutoff));
|
||||||
}),
|
}),
|
||||||
Filter::make('checkout_attention')
|
Filter::make('checkout_attention')
|
||||||
->label('Checkout attention')
|
->label('Checkout attention')
|
||||||
->indicator('Checkout attention')
|
->indicator('Checkout attention')
|
||||||
->query(fn (Builder $query) => $query->whereHas('checkoutSessions', function (Builder $query) {
|
->query(fn (Builder $query) => $query->whereHas('checkoutSessions', function (Builder $query) {
|
||||||
$query->where('provider', TenantCheckoutHealthResource::provider())
|
$query->where('provider', 'paddle')
|
||||||
->where(function (Builder $query) {
|
->where(function (Builder $query) {
|
||||||
$query->whereIn('status', [
|
$query->whereIn('status', [
|
||||||
CheckoutSession::STATUS_REQUIRES_CUSTOMER_ACTION,
|
CheckoutSession::STATUS_REQUIRES_CUSTOMER_ACTION,
|
||||||
@@ -194,11 +274,10 @@ class TenantCheckoutHealthTable
|
|||||||
return $query;
|
return $query;
|
||||||
}
|
}
|
||||||
|
|
||||||
$cutoff = now()->subDays(TenantCheckoutHealthResource::TRANSACTION_WINDOW_DAYS);
|
$cutoff = now()->subDays(TenantPaddleHealthResource::TRANSACTION_WINDOW_DAYS);
|
||||||
$provider = TenantCheckoutHealthResource::provider();
|
|
||||||
|
|
||||||
return $query->whereHas('purchases', fn (Builder $query) => $query
|
return $query->whereHas('purchases', fn (Builder $query) => $query
|
||||||
->where('provider', $provider)
|
->where('provider', 'paddle')
|
||||||
->where('refunded', true)
|
->where('refunded', true)
|
||||||
->where('purchased_at', '>=', $cutoff), '>=', $min);
|
->where('purchased_at', '>=', $cutoff), '>=', $min);
|
||||||
}),
|
}),
|
||||||
@@ -235,6 +314,13 @@ class TenantCheckoutHealthTable
|
|||||||
return false;
|
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
|
private static function applyStatusMismatchFilter(Builder $query): Builder
|
||||||
{
|
{
|
||||||
return $query->where(function (Builder $query) {
|
return $query->where(function (Builder $query) {
|
||||||
@@ -252,13 +338,26 @@ class TenantCheckoutHealthTable
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static function syncAgeColor($state): string
|
||||||
|
{
|
||||||
|
if (! $state) {
|
||||||
|
return 'gray';
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($state->lt(now()->subDays(TenantPaddleHealthResource::STALE_SYNC_DAYS))) {
|
||||||
|
return 'danger';
|
||||||
|
}
|
||||||
|
|
||||||
|
return 'success';
|
||||||
|
}
|
||||||
|
|
||||||
private static function transactionAgeColor(?Carbon $state): string
|
private static function transactionAgeColor(?Carbon $state): string
|
||||||
{
|
{
|
||||||
if (! $state) {
|
if (! $state) {
|
||||||
return 'gray';
|
return 'gray';
|
||||||
}
|
}
|
||||||
|
|
||||||
if ($state->lt(now()->subDays(TenantCheckoutHealthResource::TRANSACTION_WINDOW_DAYS))) {
|
if ($state->lt(now()->subDays(TenantPaddleHealthResource::TRANSACTION_WINDOW_DAYS))) {
|
||||||
return 'danger';
|
return 'danger';
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1,10 +1,10 @@
|
|||||||
<?php
|
<?php
|
||||||
|
|
||||||
namespace App\Filament\Clusters\DailyOps\Resources\TenantCheckoutHealths;
|
namespace App\Filament\Clusters\DailyOps\Resources\TenantPaddleHealths;
|
||||||
|
|
||||||
use App\Filament\Clusters\DailyOps\DailyOpsCluster;
|
use App\Filament\Clusters\DailyOps\DailyOpsCluster;
|
||||||
use App\Filament\Clusters\DailyOps\Resources\TenantCheckoutHealths\Pages\ListTenantCheckoutHealths;
|
use App\Filament\Clusters\DailyOps\Resources\TenantPaddleHealths\Pages\ListTenantPaddleHealths;
|
||||||
use App\Filament\Clusters\DailyOps\Resources\TenantCheckoutHealths\Tables\TenantCheckoutHealthTable;
|
use App\Filament\Clusters\DailyOps\Resources\TenantPaddleHealths\Tables\TenantPaddleHealthTable;
|
||||||
use App\Models\CheckoutSession;
|
use App\Models\CheckoutSession;
|
||||||
use App\Models\Tenant;
|
use App\Models\Tenant;
|
||||||
use BackedEnum;
|
use BackedEnum;
|
||||||
@@ -13,11 +13,11 @@ use Filament\Tables\Table;
|
|||||||
use Illuminate\Database\Eloquent\Builder;
|
use Illuminate\Database\Eloquent\Builder;
|
||||||
use UnitEnum;
|
use UnitEnum;
|
||||||
|
|
||||||
class TenantCheckoutHealthResource extends Resource
|
class TenantPaddleHealthResource extends Resource
|
||||||
{
|
{
|
||||||
public const TRANSACTION_WINDOW_DAYS = 30;
|
public const STALE_SYNC_DAYS = 30;
|
||||||
|
|
||||||
public const DEFAULT_PROVIDER = CheckoutSession::PROVIDER_PAYPAL;
|
public const TRANSACTION_WINDOW_DAYS = 30;
|
||||||
|
|
||||||
protected static ?string $model = Tenant::class;
|
protected static ?string $model = Tenant::class;
|
||||||
|
|
||||||
@@ -25,13 +25,13 @@ class TenantCheckoutHealthResource extends Resource
|
|||||||
|
|
||||||
protected static ?string $cluster = DailyOpsCluster::class;
|
protected static ?string $cluster = DailyOpsCluster::class;
|
||||||
|
|
||||||
protected static ?string $slug = 'checkout-health';
|
protected static ?string $slug = 'paddle-health';
|
||||||
|
|
||||||
protected static ?int $navigationSort = 20;
|
protected static ?int $navigationSort = 20;
|
||||||
|
|
||||||
public static function table(Table $table): Table
|
public static function table(Table $table): Table
|
||||||
{
|
{
|
||||||
return TenantCheckoutHealthTable::configure($table);
|
return TenantPaddleHealthTable::configure($table);
|
||||||
}
|
}
|
||||||
|
|
||||||
public static function canCreate(): bool
|
public static function canCreate(): bool
|
||||||
@@ -41,7 +41,7 @@ class TenantCheckoutHealthResource extends Resource
|
|||||||
|
|
||||||
public static function getNavigationLabel(): string
|
public static function getNavigationLabel(): string
|
||||||
{
|
{
|
||||||
return __('admin.checkout_health.navigation.label');
|
return __('admin.paddle_health.navigation.label');
|
||||||
}
|
}
|
||||||
|
|
||||||
public static function getNavigationGroup(): UnitEnum|string|null
|
public static function getNavigationGroup(): UnitEnum|string|null
|
||||||
@@ -51,32 +51,37 @@ class TenantCheckoutHealthResource extends Resource
|
|||||||
|
|
||||||
public static function getEloquentQuery(): Builder
|
public static function getEloquentQuery(): Builder
|
||||||
{
|
{
|
||||||
$provider = static::provider();
|
|
||||||
$windowStart = now()->subDays(self::TRANSACTION_WINDOW_DAYS);
|
$windowStart = now()->subDays(self::TRANSACTION_WINDOW_DAYS);
|
||||||
|
|
||||||
return parent::getEloquentQuery()
|
return parent::getEloquentQuery()
|
||||||
->with(['activeResellerPackage.package'])
|
->with(['activeResellerPackage.package'])
|
||||||
->withExists('activeResellerPackage as has_active_reseller_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([
|
->withCount([
|
||||||
'purchases as checkout_transaction_count' => fn (Builder $query) => $query
|
'purchases as paddle_transaction_count' => fn (Builder $query) => $query
|
||||||
->where('provider', $provider)
|
->where('provider', 'paddle')
|
||||||
->where('refunded', false),
|
->where('refunded', false),
|
||||||
'purchases as checkout_transaction_count_window' => fn (Builder $query) => $query
|
'purchases as paddle_transaction_count_window' => fn (Builder $query) => $query
|
||||||
->where('provider', $provider)
|
->where('provider', 'paddle')
|
||||||
->where('refunded', false)
|
->where('refunded', false)
|
||||||
->where('purchased_at', '>=', $windowStart),
|
->where('purchased_at', '>=', $windowStart),
|
||||||
'purchases as checkout_refund_count_window' => fn (Builder $query) => $query
|
'purchases as paddle_refund_count_window' => fn (Builder $query) => $query
|
||||||
->where('provider', $provider)
|
->where('provider', 'paddle')
|
||||||
->where('refunded', true)
|
->where('refunded', true)
|
||||||
->where('purchased_at', '>=', $windowStart),
|
->where('purchased_at', '>=', $windowStart),
|
||||||
'checkoutSessions as checkout_requires_action_count' => fn (Builder $query) => $query
|
'checkoutSessions as paddle_checkout_requires_action_count' => fn (Builder $query) => $query
|
||||||
->where('provider', $provider)
|
->where('provider', CheckoutSession::PROVIDER_PADDLE)
|
||||||
->where('status', CheckoutSession::STATUS_REQUIRES_CUSTOMER_ACTION),
|
->where('status', CheckoutSession::STATUS_REQUIRES_CUSTOMER_ACTION),
|
||||||
'checkoutSessions as checkout_processing_count' => fn (Builder $query) => $query
|
'checkoutSessions as paddle_checkout_processing_count' => fn (Builder $query) => $query
|
||||||
->where('provider', $provider)
|
->where('provider', CheckoutSession::PROVIDER_PADDLE)
|
||||||
->where('status', CheckoutSession::STATUS_PROCESSING),
|
->where('status', CheckoutSession::STATUS_PROCESSING),
|
||||||
'checkoutSessions as checkout_expired_count' => fn (Builder $query) => $query
|
'checkoutSessions as paddle_checkout_expired_count' => fn (Builder $query) => $query
|
||||||
->where('provider', $provider)
|
->where('provider', CheckoutSession::PROVIDER_PADDLE)
|
||||||
->whereNotIn('status', [
|
->whereNotIn('status', [
|
||||||
CheckoutSession::STATUS_COMPLETED,
|
CheckoutSession::STATUS_COMPLETED,
|
||||||
CheckoutSession::STATUS_CANCELLED,
|
CheckoutSession::STATUS_CANCELLED,
|
||||||
@@ -85,37 +90,32 @@ class TenantCheckoutHealthResource extends Resource
|
|||||||
->where('expires_at', '<', now()),
|
->where('expires_at', '<', now()),
|
||||||
])
|
])
|
||||||
->withSum([
|
->withSum([
|
||||||
'purchases as checkout_transaction_total' => fn (Builder $query) => $query
|
'purchases as paddle_transaction_total' => fn (Builder $query) => $query
|
||||||
->where('provider', $provider)
|
->where('provider', 'paddle')
|
||||||
->where('refunded', false),
|
->where('refunded', false),
|
||||||
], 'price')
|
], 'price')
|
||||||
->withSum([
|
->withSum([
|
||||||
'purchases as checkout_transaction_total_window' => fn (Builder $query) => $query
|
'purchases as paddle_transaction_total_window' => fn (Builder $query) => $query
|
||||||
->where('provider', $provider)
|
->where('provider', 'paddle')
|
||||||
->where('refunded', false)
|
->where('refunded', false)
|
||||||
->where('purchased_at', '>=', $windowStart),
|
->where('purchased_at', '>=', $windowStart),
|
||||||
], 'price')
|
], 'price')
|
||||||
->withSum([
|
->withSum([
|
||||||
'purchases as checkout_refund_total_window' => fn (Builder $query) => $query
|
'purchases as paddle_refund_total_window' => fn (Builder $query) => $query
|
||||||
->where('provider', $provider)
|
->where('provider', 'paddle')
|
||||||
->where('refunded', true)
|
->where('refunded', true)
|
||||||
->where('purchased_at', '>=', $windowStart),
|
->where('purchased_at', '>=', $windowStart),
|
||||||
], 'price')
|
], 'price')
|
||||||
->withMax([
|
->withMax([
|
||||||
'purchases as last_checkout_transaction_at' => fn (Builder $query) => $query
|
'purchases as last_paddle_transaction_at' => fn (Builder $query) => $query
|
||||||
->where('provider', $provider),
|
->where('provider', 'paddle'),
|
||||||
], 'purchased_at');
|
], 'purchased_at');
|
||||||
}
|
}
|
||||||
|
|
||||||
public static function getPages(): array
|
public static function getPages(): array
|
||||||
{
|
{
|
||||||
return [
|
return [
|
||||||
'index' => ListTenantCheckoutHealths::route('/'),
|
'index' => ListTenantPaddleHealths::route('/'),
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
public static function provider(): string
|
|
||||||
{
|
|
||||||
return (string) config('checkout.default_provider', self::DEFAULT_PROVIDER);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
@@ -1,305 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
namespace App\Filament\Resources\AiStyles;
|
|
||||||
|
|
||||||
use App\Filament\Clusters\RareAdmin\RareAdminCluster;
|
|
||||||
use App\Filament\Resources\AiStyles\Pages\ManageAiStyles;
|
|
||||||
use App\Models\AiStyle;
|
|
||||||
use App\Services\AiEditing\RunwareModelSearchService;
|
|
||||||
use App\Services\Audit\SuperAdminAuditLogger;
|
|
||||||
use BackedEnum;
|
|
||||||
use Filament\Actions;
|
|
||||||
use Filament\Forms\Components\KeyValue;
|
|
||||||
use Filament\Forms\Components\Select;
|
|
||||||
use Filament\Forms\Components\Textarea;
|
|
||||||
use Filament\Forms\Components\TextInput;
|
|
||||||
use Filament\Forms\Components\Toggle;
|
|
||||||
use Filament\Resources\Resource;
|
|
||||||
use Filament\Schemas\Components\Section;
|
|
||||||
use Filament\Schemas\Components\Utilities\Get;
|
|
||||||
use Filament\Schemas\Components\Utilities\Set;
|
|
||||||
use Filament\Schemas\Schema;
|
|
||||||
use Filament\Tables;
|
|
||||||
use Filament\Tables\Table;
|
|
||||||
use UnitEnum;
|
|
||||||
|
|
||||||
class AiStyleResource extends Resource
|
|
||||||
{
|
|
||||||
protected static ?string $model = AiStyle::class;
|
|
||||||
|
|
||||||
protected static ?string $cluster = RareAdminCluster::class;
|
|
||||||
|
|
||||||
protected static BackedEnum|string|null $navigationIcon = 'heroicon-o-paint-brush';
|
|
||||||
|
|
||||||
protected static UnitEnum|string|null $navigationGroup = null;
|
|
||||||
|
|
||||||
protected static ?int $navigationSort = 31;
|
|
||||||
|
|
||||||
public static function getNavigationGroup(): UnitEnum|string|null
|
|
||||||
{
|
|
||||||
return __('admin.nav.platform');
|
|
||||||
}
|
|
||||||
|
|
||||||
public static function getNavigationLabel(): string
|
|
||||||
{
|
|
||||||
return 'AI Styles';
|
|
||||||
}
|
|
||||||
|
|
||||||
public static function form(Schema $schema): Schema
|
|
||||||
{
|
|
||||||
return $schema->schema([
|
|
||||||
Section::make('Style Basics')
|
|
||||||
->schema([
|
|
||||||
TextInput::make('key')
|
|
||||||
->required()
|
|
||||||
->maxLength(120)
|
|
||||||
->unique(ignoreRecord: true),
|
|
||||||
TextInput::make('name')
|
|
||||||
->required()
|
|
||||||
->maxLength(120),
|
|
||||||
TextInput::make('version')
|
|
||||||
->numeric()
|
|
||||||
->default(1)
|
|
||||||
->disabled()
|
|
||||||
->dehydrated(false)
|
|
||||||
->helperText('Auto-increments when core style configuration changes.'),
|
|
||||||
TextInput::make('category')
|
|
||||||
->maxLength(50),
|
|
||||||
TextInput::make('sort')
|
|
||||||
->numeric()
|
|
||||||
->default(0)
|
|
||||||
->required(),
|
|
||||||
Toggle::make('is_active')
|
|
||||||
->default(true),
|
|
||||||
Toggle::make('is_premium')
|
|
||||||
->default(false),
|
|
||||||
Toggle::make('requires_source_image')
|
|
||||||
->default(true),
|
|
||||||
])
|
|
||||||
->columns(3),
|
|
||||||
Section::make('Provider Binding')
|
|
||||||
->schema([
|
|
||||||
Select::make('provider')
|
|
||||||
->options([
|
|
||||||
'runware' => 'runware.ai',
|
|
||||||
])
|
|
||||||
->required()
|
|
||||||
->default('runware'),
|
|
||||||
Select::make('provider_model')
|
|
||||||
->label('Runware model (AIR)')
|
|
||||||
->searchable()
|
|
||||||
->getSearchResultsUsing(static fn (string $search): array => app(RunwareModelSearchService::class)->searchOptions($search))
|
|
||||||
->getOptionLabelUsing(static fn (mixed $value): ?string => app(RunwareModelSearchService::class)->labelForModel($value))
|
|
||||||
->helperText('Start typing to search models from runware.ai.')
|
|
||||||
->native(false)
|
|
||||||
->live()
|
|
||||||
->afterStateUpdated(static function (Set $set, ?string $state): void {
|
|
||||||
self::applySelectedRunwareModel($set, $state);
|
|
||||||
}),
|
|
||||||
])
|
|
||||||
->columns(2),
|
|
||||||
Section::make('Runware Generation')
|
|
||||||
->schema([
|
|
||||||
TextInput::make('metadata.runware.generation.width')
|
|
||||||
->label('Width')
|
|
||||||
->numeric()
|
|
||||||
->minValue(64)
|
|
||||||
->maxValue(4096)
|
|
||||||
->step(64)
|
|
||||||
->helperText(static fn (Get $get): ?string => self::dimensionConstraintHint($get, 'width')),
|
|
||||||
TextInput::make('metadata.runware.generation.height')
|
|
||||||
->label('Height')
|
|
||||||
->numeric()
|
|
||||||
->minValue(64)
|
|
||||||
->maxValue(4096)
|
|
||||||
->step(64)
|
|
||||||
->helperText(static fn (Get $get): ?string => self::dimensionConstraintHint($get, 'height')),
|
|
||||||
TextInput::make('metadata.runware.generation.steps')
|
|
||||||
->label('Steps')
|
|
||||||
->numeric()
|
|
||||||
->minValue(1)
|
|
||||||
->maxValue(150)
|
|
||||||
->helperText(static fn (Get $get): ?string => self::rangeConstraintHint($get, 'steps')),
|
|
||||||
TextInput::make('metadata.runware.generation.cfg_scale')
|
|
||||||
->label('CFG Scale')
|
|
||||||
->numeric()
|
|
||||||
->minValue(0)
|
|
||||||
->maxValue(30)
|
|
||||||
->step(0.1)
|
|
||||||
->helperText(static fn (Get $get): ?string => self::rangeConstraintHint($get, 'cfg_scale')),
|
|
||||||
TextInput::make('metadata.runware.generation.strength')
|
|
||||||
->label('Strength')
|
|
||||||
->numeric()
|
|
||||||
->minValue(0)
|
|
||||||
->maxValue(1)
|
|
||||||
->step(0.01)
|
|
||||||
->helperText(static fn (Get $get): ?string => self::rangeConstraintHint($get, 'strength')),
|
|
||||||
Select::make('metadata.runware.generation.output_format')
|
|
||||||
->label('Output format')
|
|
||||||
->options([
|
|
||||||
'JPG' => 'JPG',
|
|
||||||
'PNG' => 'PNG',
|
|
||||||
'WEBP' => 'WEBP',
|
|
||||||
])
|
|
||||||
->default('JPG')
|
|
||||||
->native(false),
|
|
||||||
Select::make('metadata.runware.generation.delivery_method')
|
|
||||||
->label('Delivery method')
|
|
||||||
->options([
|
|
||||||
'async' => 'async (queue + poll)',
|
|
||||||
'sync' => 'sync',
|
|
||||||
])
|
|
||||||
->default('async')
|
|
||||||
->native(false),
|
|
||||||
])
|
|
||||||
->columns(3),
|
|
||||||
Section::make('Prompts')
|
|
||||||
->schema([
|
|
||||||
Textarea::make('description')
|
|
||||||
->rows(2),
|
|
||||||
Textarea::make('prompt_template')
|
|
||||||
->rows(5),
|
|
||||||
Textarea::make('negative_prompt_template')
|
|
||||||
->rows(4),
|
|
||||||
]),
|
|
||||||
Section::make('Metadata')
|
|
||||||
->schema([
|
|
||||||
KeyValue::make('metadata')
|
|
||||||
->nullable(),
|
|
||||||
]),
|
|
||||||
]);
|
|
||||||
}
|
|
||||||
|
|
||||||
public static function table(Table $table): Table
|
|
||||||
{
|
|
||||||
return $table
|
|
||||||
->defaultSort('sort')
|
|
||||||
->columns([
|
|
||||||
Tables\Columns\TextColumn::make('key')
|
|
||||||
->searchable()
|
|
||||||
->copyable(),
|
|
||||||
Tables\Columns\TextColumn::make('name')
|
|
||||||
->searchable(),
|
|
||||||
Tables\Columns\TextColumn::make('version')
|
|
||||||
->sortable()
|
|
||||||
->toggleable(),
|
|
||||||
Tables\Columns\TextColumn::make('provider')
|
|
||||||
->badge(),
|
|
||||||
Tables\Columns\TextColumn::make('provider_model')
|
|
||||||
->toggleable(),
|
|
||||||
Tables\Columns\IconColumn::make('is_active')
|
|
||||||
->boolean(),
|
|
||||||
Tables\Columns\IconColumn::make('is_premium')
|
|
||||||
->boolean(),
|
|
||||||
Tables\Columns\TextColumn::make('sort')
|
|
||||||
->sortable(),
|
|
||||||
Tables\Columns\TextColumn::make('updated_at')
|
|
||||||
->since()
|
|
||||||
->toggleable(),
|
|
||||||
])
|
|
||||||
->filters([
|
|
||||||
Tables\Filters\TernaryFilter::make('is_active'),
|
|
||||||
Tables\Filters\TernaryFilter::make('is_premium'),
|
|
||||||
])
|
|
||||||
->actions([
|
|
||||||
Actions\EditAction::make()
|
|
||||||
->after(fn (array $data, AiStyle $record) => app(SuperAdminAuditLogger::class)->recordModelMutation(
|
|
||||||
'updated',
|
|
||||||
$record,
|
|
||||||
SuperAdminAuditLogger::fieldsMetadata(array_keys($data)),
|
|
||||||
static::class
|
|
||||||
)),
|
|
||||||
Actions\DeleteAction::make()
|
|
||||||
->after(fn (AiStyle $record) => app(SuperAdminAuditLogger::class)->recordModelMutation(
|
|
||||||
'deleted',
|
|
||||||
$record,
|
|
||||||
source: static::class
|
|
||||||
)),
|
|
||||||
])
|
|
||||||
->bulkActions([
|
|
||||||
Actions\DeleteBulkAction::make(),
|
|
||||||
]);
|
|
||||||
}
|
|
||||||
|
|
||||||
public static function getPages(): array
|
|
||||||
{
|
|
||||||
return [
|
|
||||||
'index' => ManageAiStyles::route('/'),
|
|
||||||
];
|
|
||||||
}
|
|
||||||
|
|
||||||
private static function applySelectedRunwareModel(Set $set, ?string $air): void
|
|
||||||
{
|
|
||||||
if (! is_string($air) || trim($air) === '') {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
$model = app(RunwareModelSearchService::class)->findByAir($air);
|
|
||||||
if (! is_array($model)) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
$set('metadata.runware.model.air', $model['air']);
|
|
||||||
$set('metadata.runware.model.name', $model['name']);
|
|
||||||
$set('metadata.runware.model.architecture', $model['architecture']);
|
|
||||||
$set('metadata.runware.model.category', $model['category']);
|
|
||||||
|
|
||||||
foreach ((array) ($model['constraints'] ?? []) as $key => $value) {
|
|
||||||
$set("metadata.runware.constraints.{$key}", $value);
|
|
||||||
}
|
|
||||||
|
|
||||||
self::setIfNumeric($set, 'metadata.runware.generation.width', $model['defaults']['width'] ?? null);
|
|
||||||
self::setIfNumeric($set, 'metadata.runware.generation.height', $model['defaults']['height'] ?? null);
|
|
||||||
self::setIfNumeric($set, 'metadata.runware.generation.steps', $model['defaults']['steps'] ?? null);
|
|
||||||
self::setIfNumeric($set, 'metadata.runware.generation.cfg_scale', $model['defaults']['cfg_scale'] ?? null);
|
|
||||||
}
|
|
||||||
|
|
||||||
private static function setIfNumeric(Set $set, string $path, mixed $value): void
|
|
||||||
{
|
|
||||||
if (is_numeric($value)) {
|
|
||||||
$set($path, $value);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private static function dimensionConstraintHint(Get $get, string $dimension): ?string
|
|
||||||
{
|
|
||||||
$min = $get("metadata.runware.constraints.min_{$dimension}");
|
|
||||||
$max = $get("metadata.runware.constraints.max_{$dimension}");
|
|
||||||
$step = $get("metadata.runware.constraints.{$dimension}_step");
|
|
||||||
|
|
||||||
if (! is_numeric($min) && ! is_numeric($max) && ! is_numeric($step)) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
$parts = [];
|
|
||||||
if (is_numeric($min) || is_numeric($max)) {
|
|
||||||
$parts[] = sprintf(
|
|
||||||
'Model range: %s - %s',
|
|
||||||
is_numeric($min) ? (string) (int) $min : '?',
|
|
||||||
is_numeric($max) ? (string) (int) $max : '?'
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (is_numeric($step) && (int) $step > 0) {
|
|
||||||
$parts[] = sprintf('Step: %d', (int) $step);
|
|
||||||
}
|
|
||||||
|
|
||||||
return $parts !== [] ? implode(' | ', $parts) : null;
|
|
||||||
}
|
|
||||||
|
|
||||||
private static function rangeConstraintHint(Get $get, string $field): ?string
|
|
||||||
{
|
|
||||||
$min = $get("metadata.runware.constraints.min_{$field}");
|
|
||||||
$max = $get("metadata.runware.constraints.max_{$field}");
|
|
||||||
|
|
||||||
if (! is_numeric($min) && ! is_numeric($max)) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
return sprintf(
|
|
||||||
'Model range: %s - %s',
|
|
||||||
is_numeric($min) ? trim((string) $min) : '?',
|
|
||||||
is_numeric($max) ? trim((string) $max) : '?'
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,26 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
namespace App\Filament\Resources\AiStyles\Pages;
|
|
||||||
|
|
||||||
use App\Filament\Resources\AiStyles\AiStyleResource;
|
|
||||||
use App\Services\Audit\SuperAdminAuditLogger;
|
|
||||||
use Filament\Actions;
|
|
||||||
use Filament\Resources\Pages\ManageRecords;
|
|
||||||
|
|
||||||
class ManageAiStyles extends ManageRecords
|
|
||||||
{
|
|
||||||
protected static string $resource = AiStyleResource::class;
|
|
||||||
|
|
||||||
protected function getHeaderActions(): array
|
|
||||||
{
|
|
||||||
return [
|
|
||||||
Actions\CreateAction::make()
|
|
||||||
->after(fn (array $data, $record) => app(SuperAdminAuditLogger::class)->recordModelMutation(
|
|
||||||
'created',
|
|
||||||
$record,
|
|
||||||
SuperAdminAuditLogger::fieldsMetadata(array_keys($data)),
|
|
||||||
static::class
|
|
||||||
)),
|
|
||||||
];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -4,7 +4,7 @@ namespace App\Filament\Resources\Coupons\Pages;
|
|||||||
|
|
||||||
use App\Filament\Resources\Coupons\CouponResource;
|
use App\Filament\Resources\Coupons\CouponResource;
|
||||||
use App\Filament\Resources\Pages\AuditedCreateRecord;
|
use App\Filament\Resources\Pages\AuditedCreateRecord;
|
||||||
use App\Jobs\SyncCouponToLemonSqueezy;
|
use App\Jobs\SyncCouponToPaddle;
|
||||||
|
|
||||||
class CreateCoupon extends AuditedCreateRecord
|
class CreateCoupon extends AuditedCreateRecord
|
||||||
{
|
{
|
||||||
@@ -14,6 +14,6 @@ class CreateCoupon extends AuditedCreateRecord
|
|||||||
{
|
{
|
||||||
parent::afterCreate();
|
parent::afterCreate();
|
||||||
|
|
||||||
SyncCouponToLemonSqueezy::dispatch($this->record);
|
SyncCouponToPaddle::dispatch($this->record);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ namespace App\Filament\Resources\Coupons\Pages;
|
|||||||
|
|
||||||
use App\Filament\Resources\Coupons\CouponResource;
|
use App\Filament\Resources\Coupons\CouponResource;
|
||||||
use App\Filament\Resources\Pages\AuditedEditRecord;
|
use App\Filament\Resources\Pages\AuditedEditRecord;
|
||||||
use App\Jobs\SyncCouponToLemonSqueezy;
|
use App\Jobs\SyncCouponToPaddle;
|
||||||
use App\Services\Audit\SuperAdminAuditLogger;
|
use App\Services\Audit\SuperAdminAuditLogger;
|
||||||
use Filament\Actions\DeleteAction;
|
use Filament\Actions\DeleteAction;
|
||||||
use Filament\Actions\ForceDeleteAction;
|
use Filament\Actions\ForceDeleteAction;
|
||||||
@@ -27,7 +27,7 @@ class EditCoupon extends AuditedEditRecord
|
|||||||
source: static::class
|
source: static::class
|
||||||
);
|
);
|
||||||
|
|
||||||
SyncCouponToLemonSqueezy::dispatch($record, true);
|
SyncCouponToPaddle::dispatch($record, true);
|
||||||
}),
|
}),
|
||||||
ForceDeleteAction::make()
|
ForceDeleteAction::make()
|
||||||
->after(fn ($record) => app(SuperAdminAuditLogger::class)->recordModelMutation(
|
->after(fn ($record) => app(SuperAdminAuditLogger::class)->recordModelMutation(
|
||||||
@@ -48,6 +48,6 @@ class EditCoupon extends AuditedEditRecord
|
|||||||
{
|
{
|
||||||
parent::afterSave();
|
parent::afterSave();
|
||||||
|
|
||||||
SyncCouponToLemonSqueezy::dispatch($this->record);
|
SyncCouponToPaddle::dispatch($this->record);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -21,7 +21,7 @@ class RedemptionsRelationManager extends RelationManager
|
|||||||
public function table(Table $table): Table
|
public function table(Table $table): Table
|
||||||
{
|
{
|
||||||
return $table
|
return $table
|
||||||
->recordTitleAttribute('lemonsqueezy_order_id')
|
->recordTitleAttribute('paddle_transaction_id')
|
||||||
->columns([
|
->columns([
|
||||||
TextColumn::make('tenant.name')
|
TextColumn::make('tenant.name')
|
||||||
->label(__('Tenant'))
|
->label(__('Tenant'))
|
||||||
@@ -65,7 +65,7 @@ class RedemptionsRelationManager extends RelationManager
|
|||||||
'failed' => 'danger',
|
'failed' => 'danger',
|
||||||
default => 'warning',
|
default => 'warning',
|
||||||
}),
|
}),
|
||||||
TextColumn::make('lemonsqueezy_order_id')
|
TextColumn::make('paddle_transaction_id')
|
||||||
->label(__('Transaction'))
|
->label(__('Transaction'))
|
||||||
->copyable()
|
->copyable()
|
||||||
->toggleable(isToggledHiddenByDefault: true),
|
->toggleable(isToggledHiddenByDefault: true),
|
||||||
|
|||||||
@@ -123,22 +123,22 @@ class CouponForm
|
|||||||
->nullable()
|
->nullable()
|
||||||
->columnSpanFull(),
|
->columnSpanFull(),
|
||||||
]),
|
]),
|
||||||
Section::make(__('Lemon Squeezy sync'))
|
Section::make(__('Paddle sync'))
|
||||||
->columns(2)
|
->columns(2)
|
||||||
->schema([
|
->schema([
|
||||||
Select::make('lemonsqueezy_mode')
|
Select::make('paddle_mode')
|
||||||
->label(__('Lemon Squeezy mode'))
|
->label(__('Paddle mode'))
|
||||||
->options([
|
->options([
|
||||||
'standard' => __('Standard'),
|
'standard' => __('Standard'),
|
||||||
'custom' => __('Custom (one-off)'),
|
'custom' => __('Custom (one-off)'),
|
||||||
])
|
])
|
||||||
->default('standard'),
|
->default('standard'),
|
||||||
Placeholder::make('lemonsqueezy_discount_id')
|
Placeholder::make('paddle_discount_id')
|
||||||
->label(__('Lemon Squeezy Discount ID'))
|
->label(__('Paddle Discount ID'))
|
||||||
->content(fn ($record) => $record?->lemonsqueezy_discount_id ?? '—'),
|
->content(fn ($record) => $record?->paddle_discount_id ?? '—'),
|
||||||
Placeholder::make('lemonsqueezy_last_synced_at')
|
Placeholder::make('paddle_last_synced_at')
|
||||||
->label(__('Last synced'))
|
->label(__('Last synced'))
|
||||||
->content(fn ($record) => $record?->lemonsqueezy_last_synced_at?->diffForHumans() ?? '—'),
|
->content(fn ($record) => $record?->paddle_last_synced_at?->diffForHumans() ?? '—'),
|
||||||
Placeholder::make('redemptions_count')
|
Placeholder::make('redemptions_count')
|
||||||
->label(__('Total redemptions'))
|
->label(__('Total redemptions'))
|
||||||
->content(fn ($record) => number_format($record?->redemptions_count ?? 0)),
|
->content(fn ($record) => number_format($record?->redemptions_count ?? 0)),
|
||||||
|
|||||||
@@ -63,17 +63,17 @@ class CouponInfolist
|
|||||||
TextEntry::make('description')->label(__('Description'))->columnSpanFull(),
|
TextEntry::make('description')->label(__('Description'))->columnSpanFull(),
|
||||||
KeyValueEntry::make('metadata')->label(__('Metadata'))->columnSpanFull(),
|
KeyValueEntry::make('metadata')->label(__('Metadata'))->columnSpanFull(),
|
||||||
]),
|
]),
|
||||||
Section::make(__('Lemon Squeezy'))
|
Section::make(__('Paddle'))
|
||||||
->columns(3)
|
->columns(3)
|
||||||
->schema([
|
->schema([
|
||||||
TextEntry::make('lemonsqueezy_discount_id')
|
TextEntry::make('paddle_discount_id')
|
||||||
->label(__('Discount ID'))
|
->label(__('Discount ID'))
|
||||||
->copyable()
|
->copyable()
|
||||||
->placeholder('—'),
|
->placeholder('—'),
|
||||||
TextEntry::make('lemonsqueezy_last_synced_at')
|
TextEntry::make('paddle_last_synced_at')
|
||||||
->label(__('Last synced'))
|
->label(__('Last synced'))
|
||||||
->state(fn ($record) => $record?->lemonsqueezy_last_synced_at?->diffForHumans() ?? '—'),
|
->state(fn ($record) => $record?->paddle_last_synced_at?->diffForHumans() ?? '—'),
|
||||||
TextEntry::make('lemonsqueezy_mode')
|
TextEntry::make('paddle_mode')
|
||||||
->label(__('Mode'))
|
->label(__('Mode'))
|
||||||
->badge()
|
->badge()
|
||||||
->placeholder('standard'),
|
->placeholder('standard'),
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ namespace App\Filament\Resources\Coupons\Tables;
|
|||||||
|
|
||||||
use App\Enums\CouponStatus;
|
use App\Enums\CouponStatus;
|
||||||
use App\Enums\CouponType;
|
use App\Enums\CouponType;
|
||||||
use App\Jobs\SyncCouponToLemonSqueezy;
|
use App\Jobs\SyncCouponToPaddle;
|
||||||
use App\Services\Audit\SuperAdminAuditLogger;
|
use App\Services\Audit\SuperAdminAuditLogger;
|
||||||
use Filament\Actions\Action;
|
use Filament\Actions\Action;
|
||||||
use Filament\Actions\BulkActionGroup;
|
use Filament\Actions\BulkActionGroup;
|
||||||
@@ -105,9 +105,9 @@ class CouponsTable
|
|||||||
static::class
|
static::class
|
||||||
)),
|
)),
|
||||||
Action::make('sync')
|
Action::make('sync')
|
||||||
->label(__('Sync to Lemon Squeezy'))
|
->label(__('Sync to Paddle'))
|
||||||
->icon('heroicon-m-arrow-path')
|
->icon('heroicon-m-arrow-path')
|
||||||
->action(fn ($record) => SyncCouponToLemonSqueezy::dispatch($record))
|
->action(fn ($record) => SyncCouponToPaddle::dispatch($record))
|
||||||
->requiresConfirmation(),
|
->requiresConfirmation(),
|
||||||
])
|
])
|
||||||
->toolbarActions([
|
->toolbarActions([
|
||||||
|
|||||||
@@ -13,9 +13,7 @@ use Illuminate\Support\Facades\Storage;
|
|||||||
class ImportEmotions extends Page
|
class ImportEmotions extends Page
|
||||||
{
|
{
|
||||||
protected static string $resource = EmotionResource::class;
|
protected static string $resource = EmotionResource::class;
|
||||||
|
|
||||||
protected string $view = 'filament.resources.emotion-resource.pages.import-emotions';
|
protected string $view = 'filament.resources.emotion-resource.pages.import-emotions';
|
||||||
|
|
||||||
protected ?string $heading = null;
|
protected ?string $heading = null;
|
||||||
|
|
||||||
public ?string $file = null;
|
public ?string $file = null;
|
||||||
@@ -38,7 +36,6 @@ class ImportEmotions extends Page
|
|||||||
$path = $this->form->getState()['file'] ?? null;
|
$path = $this->form->getState()['file'] ?? null;
|
||||||
if (! $path || ! Storage::disk('public')->exists($path)) {
|
if (! $path || ! Storage::disk('public')->exists($path)) {
|
||||||
Notification::make()->danger()->title(__('admin.notifications.file_not_found'))->send();
|
Notification::make()->danger()->title(__('admin.notifications.file_not_found'))->send();
|
||||||
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -6,29 +6,24 @@ use App\Filament\Clusters\DailyOps\DailyOpsCluster;
|
|||||||
use App\Filament\Resources\EventResource\Pages;
|
use App\Filament\Resources\EventResource\Pages;
|
||||||
use App\Filament\Resources\EventResource\RelationManagers\EventPackagesRelationManager;
|
use App\Filament\Resources\EventResource\RelationManagers\EventPackagesRelationManager;
|
||||||
use App\Models\Event;
|
use App\Models\Event;
|
||||||
use App\Models\EventJoinToken;
|
|
||||||
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\Services\Audit\SuperAdminAuditLogger;
|
||||||
use App\Services\EventJoinTokenService;
|
|
||||||
use App\Support\JoinTokenLayoutRegistry;
|
use App\Support\JoinTokenLayoutRegistry;
|
||||||
use BackedEnum;
|
use BackedEnum;
|
||||||
use Carbon\Carbon;
|
use Carbon\Carbon;
|
||||||
use Filament\Actions;
|
use Filament\Actions;
|
||||||
use Filament\Forms\Components\DatePicker;
|
use Filament\Forms\Components\DatePicker;
|
||||||
use Filament\Forms\Components\DateTimePicker;
|
|
||||||
use Filament\Forms\Components\KeyValue;
|
use Filament\Forms\Components\KeyValue;
|
||||||
use Filament\Forms\Components\Select;
|
use Filament\Forms\Components\Select;
|
||||||
use Filament\Forms\Components\TextInput;
|
use Filament\Forms\Components\TextInput;
|
||||||
use Filament\Forms\Components\Toggle;
|
use Filament\Forms\Components\Toggle;
|
||||||
use Filament\Notifications\Notification;
|
|
||||||
use Filament\Resources\Resource;
|
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 Illuminate\Database\Eloquent\Collection;
|
||||||
use Illuminate\Support\Arr;
|
|
||||||
use UnitEnum;
|
use UnitEnum;
|
||||||
|
|
||||||
class EventResource extends Resource
|
class EventResource extends Resource
|
||||||
@@ -65,32 +60,19 @@ class EventResource extends Resource
|
|||||||
->required()
|
->required()
|
||||||
->unique(ignoreRecord: true)
|
->unique(ignoreRecord: true)
|
||||||
->maxLength(255),
|
->maxLength(255),
|
||||||
TextInput::make('join_link_display')
|
|
||||||
->label(__('admin.events.fields.join_link'))
|
|
||||||
->afterStateHydrated(function (TextInput $component, ?Event $record) {
|
|
||||||
if (! $record) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
$token = $record->joinTokens()->latest()->first();
|
|
||||||
$component->state($token ? url('/e/'.$token->token) : '-');
|
|
||||||
})
|
|
||||||
->readOnly()
|
|
||||||
->dehydrated(false)
|
|
||||||
->visibleOn('edit'),
|
|
||||||
DatePicker::make('date')
|
DatePicker::make('date')
|
||||||
->label(__('admin.events.fields.date'))
|
->label(__('admin.events.fields.date'))
|
||||||
->required(),
|
->required(),
|
||||||
Select::make('event_type_id')
|
Select::make('event_type_id')
|
||||||
->label(__('admin.events.fields.type'))
|
->label(__('admin.events.fields.type'))
|
||||||
->options(fn () => EventType::all()->pluck('name.de', 'id'))
|
->options(EventType::query()->pluck('name', 'id'))
|
||||||
->searchable(),
|
->searchable(),
|
||||||
Select::make('package_id')
|
Select::make('package_id')
|
||||||
->label(__('admin.events.fields.package'))
|
->label(__('admin.events.fields.package'))
|
||||||
->options(\App\Models\Package::query()->where('type', 'endcustomer')->pluck('name', 'id'))
|
->options(\App\Models\Package::query()->where('type', 'endcustomer')->pluck('name', 'id'))
|
||||||
->searchable()
|
->searchable()
|
||||||
->preload()
|
->preload()
|
||||||
->required()
|
->required(),
|
||||||
->visibleOn('create'),
|
|
||||||
TextInput::make('default_locale')
|
TextInput::make('default_locale')
|
||||||
->label(__('admin.events.fields.default_locale'))
|
->label(__('admin.events.fields.default_locale'))
|
||||||
->default('de')
|
->default('de')
|
||||||
@@ -98,10 +80,6 @@ class EventResource extends Resource
|
|||||||
Toggle::make('is_active')
|
Toggle::make('is_active')
|
||||||
->label(__('admin.events.fields.is_active'))
|
->label(__('admin.events.fields.is_active'))
|
||||||
->default(true),
|
->default(true),
|
||||||
Toggle::make('settings.marketing_demo')
|
|
||||||
->label(__('admin.events.fields.marketing_demo'))
|
|
||||||
->helperText(__('admin.events.fields.marketing_demo_help'))
|
|
||||||
->default(false),
|
|
||||||
KeyValue::make('settings')
|
KeyValue::make('settings')
|
||||||
->label(__('admin.events.fields.settings'))
|
->label(__('admin.events.fields.settings'))
|
||||||
->keyLabel(__('admin.common.key'))
|
->keyLabel(__('admin.common.key'))
|
||||||
@@ -118,13 +96,13 @@ class EventResource extends Resource
|
|||||||
->columns([
|
->columns([
|
||||||
Tables\Columns\TextColumn::make('id')->sortable(),
|
Tables\Columns\TextColumn::make('id')->sortable(),
|
||||||
Tables\Columns\TextColumn::make('tenant.name')->label(__('admin.events.table.tenant'))->searchable(),
|
Tables\Columns\TextColumn::make('tenant.name')->label(__('admin.events.table.tenant'))->searchable(),
|
||||||
Tables\Columns\TextColumn::make('name')
|
Tables\Columns\TextColumn::make('name.de')
|
||||||
->label(__('admin.events.fields.name'))
|
->label(__('admin.events.fields.name'))
|
||||||
->formatStateUsing(fn (mixed $state): string => static::formatEventName($state))
|
|
||||||
->limit(30),
|
->limit(30),
|
||||||
Tables\Columns\TextColumn::make('slug')->searchable(),
|
Tables\Columns\TextColumn::make('slug')->searchable(),
|
||||||
Tables\Columns\TextColumn::make('date')->date(),
|
Tables\Columns\TextColumn::make('date')->date(),
|
||||||
Tables\Columns\IconColumn::make('is_active')->boolean(),
|
Tables\Columns\IconColumn::make('is_active')->boolean(),
|
||||||
|
Tables\Columns\TextColumn::make('default_locale'),
|
||||||
Tables\Columns\TextColumn::make('eventPackage.package.name')
|
Tables\Columns\TextColumn::make('eventPackage.package.name')
|
||||||
->label(__('admin.events.table.package'))
|
->label(__('admin.events.table.package'))
|
||||||
->badge()
|
->badge()
|
||||||
@@ -137,6 +115,22 @@ class EventResource extends Resource
|
|||||||
->badge()
|
->badge()
|
||||||
->color(fn ($state) => $state < 1 ? 'danger' : 'success')
|
->color(fn ($state) => $state < 1 ? 'danger' : 'success')
|
||||||
->getStateUsing(fn ($record) => $record->eventPackage?->remaining_photos ?? 0),
|
->getStateUsing(fn ($record) => $record->eventPackage?->remaining_photos ?? 0),
|
||||||
|
Tables\Columns\TextColumn::make('primary_join_token')
|
||||||
|
->label(__('admin.events.table.join'))
|
||||||
|
->getStateUsing(function ($record) {
|
||||||
|
$token = $record->joinTokens()->latest()->first();
|
||||||
|
|
||||||
|
return $token ? url('/e/'.$token->token) : __('admin.events.table.no_join_tokens');
|
||||||
|
})
|
||||||
|
->description(function ($record) {
|
||||||
|
$total = $record->joinTokens()->count();
|
||||||
|
|
||||||
|
return $total > 0
|
||||||
|
? __('admin.events.table.join_tokens_total', ['count' => $total])
|
||||||
|
: __('admin.events.table.join_tokens_missing');
|
||||||
|
})
|
||||||
|
->copyable()
|
||||||
|
->copyMessage(__('admin.events.messages.join_link_copied')),
|
||||||
Tables\Columns\TextColumn::make('created_at')->since(),
|
Tables\Columns\TextColumn::make('created_at')->since(),
|
||||||
])
|
])
|
||||||
->filters([])
|
->filters([])
|
||||||
@@ -173,161 +167,7 @@ class EventResource extends Resource
|
|||||||
->modalHeading(__('admin.events.modal.join_link_heading'))
|
->modalHeading(__('admin.events.modal.join_link_heading'))
|
||||||
->modalSubmitActionLabel(__('admin.common.close'))
|
->modalSubmitActionLabel(__('admin.common.close'))
|
||||||
->modalWidth('xl')
|
->modalWidth('xl')
|
||||||
->registerModalActions([
|
->modalContent(function ($record) {
|
||||||
Actions\Action::make('extend_join_token_expiry')
|
|
||||||
->label(__('admin.events.join_link.extend_expiry'))
|
|
||||||
->icon('heroicon-o-clock')
|
|
||||||
->color('warning')
|
|
||||||
->size('xs')
|
|
||||||
->modalHeading(function (Actions\Action $action, Event $record): string {
|
|
||||||
$token = static::resolveJoinTokenFromAction($record, $action);
|
|
||||||
|
|
||||||
return $token
|
|
||||||
? __('admin.events.join_link.extend_expiry_heading', [
|
|
||||||
'label' => $token->label ?: __('admin.events.join_link.token_default', ['id' => $token->id]),
|
|
||||||
])
|
|
||||||
: __('admin.events.join_link.extend_expiry_heading_fallback');
|
|
||||||
})
|
|
||||||
->schema(function (Event $record): array {
|
|
||||||
$minimumExpiry = app(EventJoinTokenService::class)->minimumExpiryForEvent($record);
|
|
||||||
$rules = [
|
|
||||||
'date',
|
|
||||||
'after:now',
|
|
||||||
];
|
|
||||||
|
|
||||||
if ($minimumExpiry) {
|
|
||||||
$rules[] = 'after_or_equal:'.$minimumExpiry->toDateTimeString();
|
|
||||||
}
|
|
||||||
|
|
||||||
return [
|
|
||||||
DateTimePicker::make('expires_at')
|
|
||||||
->label(__('admin.events.join_link.extend_expiry_label'))
|
|
||||||
->required()
|
|
||||||
->seconds(false)
|
|
||||||
->rules($rules)
|
|
||||||
->helperText($minimumExpiry
|
|
||||||
? __('admin.events.join_link.extend_expiry_min', [
|
|
||||||
'date' => $minimumExpiry->isoFormat('LLL'),
|
|
||||||
])
|
|
||||||
: null),
|
|
||||||
];
|
|
||||||
})
|
|
||||||
->fillForm(function (Actions\Action $action, Event $record): array {
|
|
||||||
$token = static::resolveJoinTokenFromAction($record, $action);
|
|
||||||
|
|
||||||
if (! $token) {
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
|
|
||||||
return [
|
|
||||||
'expires_at' => $token->expires_at,
|
|
||||||
];
|
|
||||||
})
|
|
||||||
->action(function (array $data, Actions\Action $action, Event $record): void {
|
|
||||||
$token = static::resolveJoinTokenFromAction($record, $action);
|
|
||||||
|
|
||||||
if (! $token) {
|
|
||||||
Notification::make()
|
|
||||||
->title(__('admin.events.join_link.extend_expiry_missing'))
|
|
||||||
->danger()
|
|
||||||
->send();
|
|
||||||
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
$expiresAt = $data['expires_at'] ?? null;
|
|
||||||
|
|
||||||
if (! $expiresAt) {
|
|
||||||
Notification::make()
|
|
||||||
->title(__('admin.events.join_link.extend_expiry_missing_date'))
|
|
||||||
->danger()
|
|
||||||
->send();
|
|
||||||
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
$resolvedExpiry = $expiresAt instanceof Carbon
|
|
||||||
? $expiresAt
|
|
||||||
: Carbon::parse($expiresAt);
|
|
||||||
|
|
||||||
$token->forceFill([
|
|
||||||
'expires_at' => $resolvedExpiry,
|
|
||||||
])->save();
|
|
||||||
|
|
||||||
app(SuperAdminAuditLogger::class)->recordModelMutation(
|
|
||||||
'updated',
|
|
||||||
$token,
|
|
||||||
source: static::class
|
|
||||||
);
|
|
||||||
|
|
||||||
Notification::make()
|
|
||||||
->title(__('admin.events.join_link.extend_expiry_success'))
|
|
||||||
->success()
|
|
||||||
->send();
|
|
||||||
}),
|
|
||||||
Actions\Action::make('set_demo_read_only')
|
|
||||||
->label(__('admin.events.join_link.demo_read_only_action'))
|
|
||||||
->icon('heroicon-o-lock-closed')
|
|
||||||
->color('gray')
|
|
||||||
->size('xs')
|
|
||||||
->modalHeading(function (Actions\Action $action, Event $record): string {
|
|
||||||
$token = static::resolveJoinTokenFromAction($record, $action);
|
|
||||||
|
|
||||||
return $token
|
|
||||||
? __('admin.events.join_link.demo_read_only_heading', [
|
|
||||||
'label' => $token->label ?: __('admin.events.join_link.token_default', ['id' => $token->id]),
|
|
||||||
])
|
|
||||||
: __('admin.events.join_link.demo_read_only_heading_fallback');
|
|
||||||
})
|
|
||||||
->schema([
|
|
||||||
Toggle::make('demo_read_only')
|
|
||||||
->label(__('admin.events.join_link.demo_read_only_label'))
|
|
||||||
->helperText(__('admin.events.join_link.demo_read_only_help')),
|
|
||||||
])
|
|
||||||
->fillForm(function (Actions\Action $action, Event $record): array {
|
|
||||||
$token = static::resolveJoinTokenFromAction($record, $action);
|
|
||||||
|
|
||||||
return [
|
|
||||||
'demo_read_only' => (bool) Arr::get($token?->metadata ?? [], 'demo_read_only', false),
|
|
||||||
];
|
|
||||||
})
|
|
||||||
->action(function (array $data, Actions\Action $action, Event $record): void {
|
|
||||||
$token = static::resolveJoinTokenFromAction($record, $action);
|
|
||||||
|
|
||||||
if (! $token) {
|
|
||||||
Notification::make()
|
|
||||||
->title(__('admin.events.join_link.demo_read_only_missing'))
|
|
||||||
->danger()
|
|
||||||
->send();
|
|
||||||
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
$metadata = is_array($token->metadata) ? $token->metadata : [];
|
|
||||||
$enabled = (bool) ($data['demo_read_only'] ?? false);
|
|
||||||
|
|
||||||
if ($enabled) {
|
|
||||||
$metadata['demo_read_only'] = true;
|
|
||||||
} else {
|
|
||||||
unset($metadata['demo_read_only']);
|
|
||||||
}
|
|
||||||
|
|
||||||
$token->metadata = empty($metadata) ? null : $metadata;
|
|
||||||
$token->save();
|
|
||||||
|
|
||||||
app(SuperAdminAuditLogger::class)->recordModelMutation(
|
|
||||||
'updated',
|
|
||||||
$token,
|
|
||||||
source: static::class
|
|
||||||
);
|
|
||||||
|
|
||||||
Notification::make()
|
|
||||||
->title(__('admin.events.join_link.demo_read_only_success'))
|
|
||||||
->success()
|
|
||||||
->send();
|
|
||||||
}),
|
|
||||||
])
|
|
||||||
->modalContent(function (Actions\Action $action, $record) {
|
|
||||||
$tokens = $record->joinTokens()
|
$tokens = $record->joinTokens()
|
||||||
->orderByDesc('created_at')
|
->orderByDesc('created_at')
|
||||||
->get();
|
->get();
|
||||||
@@ -397,7 +237,6 @@ class EventResource extends Resource
|
|||||||
'expires_at' => optional($token->expires_at)->toIso8601String(),
|
'expires_at' => optional($token->expires_at)->toIso8601String(),
|
||||||
'revoked_at' => optional($token->revoked_at)->toIso8601String(),
|
'revoked_at' => optional($token->revoked_at)->toIso8601String(),
|
||||||
'is_active' => $token->isActive(),
|
'is_active' => $token->isActive(),
|
||||||
'demo_read_only' => (bool) Arr::get($token->metadata ?? [], 'demo_read_only', false),
|
|
||||||
'created_at' => optional($token->created_at)->toIso8601String(),
|
'created_at' => optional($token->created_at)->toIso8601String(),
|
||||||
'layouts' => $layouts,
|
'layouts' => $layouts,
|
||||||
'layouts_url' => route('api.v1.tenant.events.join-tokens.layouts.index', [
|
'layouts_url' => route('api.v1.tenant.events.join-tokens.layouts.index', [
|
||||||
@@ -417,7 +256,6 @@ class EventResource extends Resource
|
|||||||
return view('filament.events.join-link', [
|
return view('filament.events.join-link', [
|
||||||
'event' => $record,
|
'event' => $record,
|
||||||
'tokens' => $tokens,
|
'tokens' => $tokens,
|
||||||
'action' => $action,
|
|
||||||
]);
|
]);
|
||||||
}),
|
}),
|
||||||
])
|
])
|
||||||
@@ -444,43 +282,6 @@ class EventResource extends Resource
|
|||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* @param array<string, mixed>|string|null $name
|
|
||||||
*/
|
|
||||||
private static function formatEventName(mixed $name): string
|
|
||||||
{
|
|
||||||
if (is_array($name)) {
|
|
||||||
$candidates = [
|
|
||||||
$name['de'] ?? null,
|
|
||||||
$name['en'] ?? null,
|
|
||||||
reset($name) ?: null,
|
|
||||||
];
|
|
||||||
|
|
||||||
foreach ($candidates as $candidate) {
|
|
||||||
if (is_string($candidate) && $candidate !== '') {
|
|
||||||
return $candidate;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return '';
|
|
||||||
}
|
|
||||||
|
|
||||||
return is_string($name) ? $name : '';
|
|
||||||
}
|
|
||||||
|
|
||||||
private static function resolveJoinTokenFromAction(Event $record, Actions\Action $action): ?EventJoinToken
|
|
||||||
{
|
|
||||||
$tokenId = $action->getArguments()['token_id'] ?? null;
|
|
||||||
|
|
||||||
if (! $tokenId) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
return $record->joinTokens()
|
|
||||||
->whereKey($tokenId)
|
|
||||||
->first();
|
|
||||||
}
|
|
||||||
|
|
||||||
public static function getPages(): array
|
public static function getPages(): array
|
||||||
{
|
{
|
||||||
return [
|
return [
|
||||||
|
|||||||
@@ -8,25 +8,4 @@ use App\Filament\Resources\Pages\AuditedCreateRecord;
|
|||||||
class CreateEvent extends AuditedCreateRecord
|
class CreateEvent extends AuditedCreateRecord
|
||||||
{
|
{
|
||||||
protected static string $resource = EventResource::class;
|
protected static string $resource = EventResource::class;
|
||||||
|
|
||||||
public ?int $packageId = null;
|
|
||||||
|
|
||||||
protected function mutateFormDataBeforeCreate(array $data): array
|
|
||||||
{
|
|
||||||
$this->packageId = $data['package_id'] ?? null;
|
|
||||||
unset($data['package_id']);
|
|
||||||
|
|
||||||
return $data;
|
|
||||||
}
|
|
||||||
|
|
||||||
protected function afterCreate(): void
|
|
||||||
{
|
|
||||||
if ($this->packageId) {
|
|
||||||
$this->record->eventPackages()->create([
|
|
||||||
'package_id' => $this->packageId,
|
|
||||||
]);
|
|
||||||
}
|
|
||||||
|
|
||||||
parent::afterCreate();
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ use Filament\Tables\Table;
|
|||||||
use Illuminate\Database\Eloquent\Builder;
|
use Illuminate\Database\Eloquent\Builder;
|
||||||
use Illuminate\Database\Eloquent\Collection;
|
use Illuminate\Database\Eloquent\Collection;
|
||||||
use Illuminate\Database\Eloquent\Model;
|
use Illuminate\Database\Eloquent\Model;
|
||||||
|
use Illuminate\Database\Eloquent\Relations\Relation;
|
||||||
|
|
||||||
class EventPackagesRelationManager extends RelationManager
|
class EventPackagesRelationManager extends RelationManager
|
||||||
{
|
{
|
||||||
@@ -58,7 +59,6 @@ class EventPackagesRelationManager extends RelationManager
|
|||||||
public function table(Table $table): Table
|
public function table(Table $table): Table
|
||||||
{
|
{
|
||||||
return $table
|
return $table
|
||||||
->modifyQueryUsing(fn (Builder $query) => $query->with('package'))
|
|
||||||
->recordTitleAttribute('package.name')
|
->recordTitleAttribute('package.name')
|
||||||
->columns([
|
->columns([
|
||||||
TextColumn::make('package.name')
|
TextColumn::make('package.name')
|
||||||
@@ -147,4 +147,9 @@ class EventPackagesRelationManager extends RelationManager
|
|||||||
{
|
{
|
||||||
return __('admin.events.relation_managers.event_packages.title');
|
return __('admin.events.relation_managers.event_packages.title');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function getTableQuery(): Builder|Relation
|
||||||
|
{
|
||||||
|
return parent::getTableQuery()->with('package');
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -113,64 +113,18 @@ class EventTypeResource extends Resource
|
|||||||
SuperAdminAuditLogger::fieldsMetadata($data),
|
SuperAdminAuditLogger::fieldsMetadata($data),
|
||||||
static::class
|
static::class
|
||||||
)),
|
)),
|
||||||
Actions\DeleteAction::make()
|
|
||||||
->action(function (EventType $record, Actions\DeleteAction $action) {
|
|
||||||
try {
|
|
||||||
$record->delete();
|
|
||||||
} catch (\Exception $e) {
|
|
||||||
$isConstraint = ($e instanceof \Illuminate\Database\QueryException && ($e->getCode() == 23000 || ($e->errorInfo[0] ?? '') == 23000));
|
|
||||||
|
|
||||||
if ($isConstraint) {
|
|
||||||
\Filament\Notifications\Notification::make()
|
|
||||||
->title(__('admin.common.error'))
|
|
||||||
->body(__('admin.event_types.messages.delete_constraint_error'))
|
|
||||||
->danger()
|
|
||||||
->send();
|
|
||||||
|
|
||||||
$action->halt();
|
|
||||||
}
|
|
||||||
|
|
||||||
throw $e;
|
|
||||||
}
|
|
||||||
})
|
|
||||||
->after(fn (EventType $record) => app(SuperAdminAuditLogger::class)->recordModelMutation(
|
|
||||||
'deleted',
|
|
||||||
$record,
|
|
||||||
source: static::class
|
|
||||||
)),
|
|
||||||
])
|
])
|
||||||
->bulkActions([
|
->bulkActions([
|
||||||
Actions\DeleteBulkAction::make()
|
Actions\DeleteBulkAction::make()
|
||||||
->action(function (Collection $records, Actions\DeleteBulkAction $action) {
|
->after(function (Collection $records): void {
|
||||||
$logger = app(SuperAdminAuditLogger::class);
|
$logger = app(SuperAdminAuditLogger::class);
|
||||||
$deletedCount = 0;
|
|
||||||
$failedCount = 0;
|
|
||||||
|
|
||||||
foreach ($records as $record) {
|
foreach ($records as $record) {
|
||||||
try {
|
$logger->recordModelMutation(
|
||||||
$record->delete();
|
'deleted',
|
||||||
$logger->recordModelMutation('deleted', $record, source: static::class);
|
$record,
|
||||||
$deletedCount++;
|
source: static::class
|
||||||
} catch (\Exception $e) {
|
);
|
||||||
$isConstraint = ($e instanceof \Illuminate\Database\QueryException && ($e->getCode() == 23000 || ($e->errorInfo[0] ?? '') == 23000));
|
|
||||||
if ($isConstraint) {
|
|
||||||
$failedCount++;
|
|
||||||
} else {
|
|
||||||
throw $e;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if ($failedCount > 0) {
|
|
||||||
\Filament\Notifications\Notification::make()
|
|
||||||
->title(__('admin.common.error'))
|
|
||||||
->body(__('admin.event_types.messages.delete_constraint_error')." ($failedCount failed, $deletedCount deleted)")
|
|
||||||
->danger()
|
|
||||||
->send();
|
|
||||||
|
|
||||||
if ($deletedCount === 0) {
|
|
||||||
$action->halt();
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}),
|
}),
|
||||||
]);
|
]);
|
||||||
|
|||||||
@@ -63,8 +63,8 @@ class GiftVoucherResource extends Resource
|
|||||||
->label('Empfänger')
|
->label('Empfänger')
|
||||||
->toggleable()
|
->toggleable()
|
||||||
->searchable(),
|
->searchable(),
|
||||||
TextColumn::make('lemonsqueezy_order_id')
|
TextColumn::make('paddle_transaction_id')
|
||||||
->label('Lemon Squeezy Order')
|
->label('Paddle Tx')
|
||||||
->toggleable()
|
->toggleable()
|
||||||
->copyable()
|
->copyable()
|
||||||
->wrap(),
|
->wrap(),
|
||||||
|
|||||||
@@ -46,27 +46,24 @@ class ListGiftVouchers extends ListRecords
|
|||||||
])
|
])
|
||||||
->action(function (array $data, GiftVoucherService $service): void {
|
->action(function (array $data, GiftVoucherService $service): void {
|
||||||
$payload = [
|
$payload = [
|
||||||
'meta' => [
|
'id' => null,
|
||||||
'custom_data' => [
|
'metadata' => [
|
||||||
'type' => 'gift_voucher',
|
'type' => 'gift_voucher',
|
||||||
'purchaser_email' => $data['purchaser_email'],
|
'purchaser_email' => $data['purchaser_email'],
|
||||||
'recipient_email' => $data['recipient_email'] ?? null,
|
'recipient_email' => $data['recipient_email'] ?? null,
|
||||||
'recipient_name' => $data['recipient_name'] ?? null,
|
'recipient_name' => $data['recipient_name'] ?? null,
|
||||||
'message' => $data['message'] ?? null,
|
'message' => $data['message'] ?? null,
|
||||||
'gift_code' => $data['code'] ?? null,
|
'gift_code' => $data['code'] ?? null,
|
||||||
],
|
|
||||||
],
|
],
|
||||||
'data' => [
|
'currency_code' => $data['currency'] ?? 'EUR',
|
||||||
'id' => 'manual_'.Str::uuid(),
|
'totals' => [
|
||||||
'attributes' => [
|
'grand_total' => [
|
||||||
'currency' => $data['currency'] ?? 'EUR',
|
'amount' => (float) $data['amount'],
|
||||||
'total' => (float) $data['amount'] * 100,
|
|
||||||
'user_email' => $data['purchaser_email'],
|
|
||||||
],
|
],
|
||||||
],
|
],
|
||||||
];
|
];
|
||||||
|
|
||||||
$voucher = $service->issueFromLemonSqueezy($payload);
|
$voucher = $service->issueFromPaddle($payload);
|
||||||
|
|
||||||
app(SuperAdminAuditLogger::class)->recordModelMutation(
|
app(SuperAdminAuditLogger::class)->recordModelMutation(
|
||||||
'issued',
|
'issued',
|
||||||
|
|||||||
@@ -17,3 +17,4 @@ class ListMediaStorageTargets extends ListRecords
|
|||||||
];
|
];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -4,21 +4,15 @@ namespace App\Filament\Resources;
|
|||||||
|
|
||||||
use App\Filament\Clusters\WeeklyOps\WeeklyOpsCluster;
|
use App\Filament\Clusters\WeeklyOps\WeeklyOpsCluster;
|
||||||
use App\Filament\Resources\PackageAddonResource\Pages;
|
use App\Filament\Resources\PackageAddonResource\Pages;
|
||||||
use App\Jobs\SyncPackageAddonToLemonSqueezy;
|
use App\Jobs\SyncPackageAddonToPaddle;
|
||||||
use App\Models\CheckoutSession;
|
|
||||||
use App\Models\PackageAddon;
|
use App\Models\PackageAddon;
|
||||||
use App\Services\Audit\SuperAdminAuditLogger;
|
use App\Services\Audit\SuperAdminAuditLogger;
|
||||||
use Filament\Actions;
|
use Filament\Actions;
|
||||||
use Filament\Forms\Components\DateTimePicker;
|
|
||||||
use Filament\Forms\Components\Placeholder;
|
|
||||||
use Filament\Forms\Components\Select;
|
|
||||||
use Filament\Forms\Components\TagsInput;
|
|
||||||
use Filament\Forms\Components\TextInput;
|
use Filament\Forms\Components\TextInput;
|
||||||
use Filament\Forms\Components\Toggle;
|
use Filament\Forms\Components\Toggle;
|
||||||
use Filament\Notifications\Notification;
|
use Filament\Notifications\Notification;
|
||||||
use Filament\Resources\Resource;
|
use Filament\Resources\Resource;
|
||||||
use Filament\Schemas\Components\Section;
|
use Filament\Schemas\Components\Section;
|
||||||
use Filament\Schemas\Components\Utilities\Get;
|
|
||||||
use Filament\Schemas\Schema;
|
use Filament\Schemas\Schema;
|
||||||
use Filament\Tables;
|
use Filament\Tables;
|
||||||
use Filament\Tables\Columns\BadgeColumn;
|
use Filament\Tables\Columns\BadgeColumn;
|
||||||
@@ -56,18 +50,10 @@ class PackageAddonResource extends Resource
|
|||||||
->required()
|
->required()
|
||||||
->unique(ignoreRecord: true)
|
->unique(ignoreRecord: true)
|
||||||
->maxLength(191),
|
->maxLength(191),
|
||||||
TextInput::make('variant_id')
|
TextInput::make('price_id')
|
||||||
->label('Lemon Squeezy Variant-ID')
|
->label('Paddle Preis-ID')
|
||||||
->helperText('Variant-ID aus Lemon Squeezy für dieses Add-on')
|
->helperText('Paddle Billing Preis-ID für dieses Add-on')
|
||||||
->required(fn (Get $get): bool => (bool) $get('active') && ! is_numeric($get('metadata.price_eur')))
|
|
||||||
->maxLength(191),
|
->maxLength(191),
|
||||||
TextInput::make('metadata.price_eur')
|
|
||||||
->label('PayPal Preis (EUR)')
|
|
||||||
->helperText('Für PayPal-Checkout erforderlich (z. B. 9.90).')
|
|
||||||
->numeric()
|
|
||||||
->step(0.01)
|
|
||||||
->minValue(0.01)
|
|
||||||
->required(fn (Get $get): bool => (bool) $get('active') && blank($get('variant_id'))),
|
|
||||||
TextInput::make('sort')
|
TextInput::make('sort')
|
||||||
->label('Sortierung')
|
->label('Sortierung')
|
||||||
->numeric()
|
->numeric()
|
||||||
@@ -75,23 +61,6 @@ class PackageAddonResource extends Resource
|
|||||||
Toggle::make('active')
|
Toggle::make('active')
|
||||||
->label('Aktiv')
|
->label('Aktiv')
|
||||||
->default(true),
|
->default(true),
|
||||||
Placeholder::make('sellable_state')
|
|
||||||
->label('Verfügbarkeits-Check')
|
|
||||||
->content(function (Get $get): string {
|
|
||||||
$isActive = (bool) $get('active');
|
|
||||||
$hasVariant = filled($get('variant_id'));
|
|
||||||
$hasPayPalPrice = is_numeric($get('metadata.price_eur'));
|
|
||||||
|
|
||||||
if (! $isActive) {
|
|
||||||
return 'Inaktiv';
|
|
||||||
}
|
|
||||||
|
|
||||||
if (! $hasVariant && ! $hasPayPalPrice) {
|
|
||||||
return 'Nicht verkäuflich: Variant-ID oder PayPal Preis fehlt.';
|
|
||||||
}
|
|
||||||
|
|
||||||
return 'Verkäuflich';
|
|
||||||
}),
|
|
||||||
]),
|
]),
|
||||||
Section::make('Limits-Inkremente')
|
Section::make('Limits-Inkremente')
|
||||||
->columns(3)
|
->columns(3)
|
||||||
@@ -112,30 +81,6 @@ class PackageAddonResource extends Resource
|
|||||||
->minValue(0)
|
->minValue(0)
|
||||||
->default(0),
|
->default(0),
|
||||||
]),
|
]),
|
||||||
Section::make('Feature-Entitlements')
|
|
||||||
->columns(2)
|
|
||||||
->schema([
|
|
||||||
Select::make('metadata.scope')
|
|
||||||
->label('Scope')
|
|
||||||
->options([
|
|
||||||
'photos' => 'Fotos',
|
|
||||||
'guests' => 'Gäste',
|
|
||||||
'gallery' => 'Galerie',
|
|
||||||
'feature' => 'Feature',
|
|
||||||
'bundle' => 'Bundle',
|
|
||||||
])
|
|
||||||
->native(false)
|
|
||||||
->searchable(),
|
|
||||||
TagsInput::make('metadata.entitlements.features')
|
|
||||||
->label('Freigeschaltete Features')
|
|
||||||
->helperText('Feature-Keys für Freischaltungen, z. B. ai_styling')
|
|
||||||
->placeholder('z. B. ai_styling')
|
|
||||||
->columnSpanFull(),
|
|
||||||
DateTimePicker::make('metadata.entitlements.expires_at')
|
|
||||||
->label('Entitlement gültig bis')
|
|
||||||
->seconds(false)
|
|
||||||
->nullable(),
|
|
||||||
]),
|
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -151,33 +96,10 @@ class PackageAddonResource extends Resource
|
|||||||
->label('Schlüssel')
|
->label('Schlüssel')
|
||||||
->copyable()
|
->copyable()
|
||||||
->sortable(),
|
->sortable(),
|
||||||
TextColumn::make('variant_id')
|
TextColumn::make('price_id')
|
||||||
->label('Lemon Squeezy Variant-ID')
|
->label('Paddle Preis-ID')
|
||||||
->toggleable()
|
->toggleable()
|
||||||
->copyable(),
|
->copyable(),
|
||||||
TextColumn::make('metadata.price_eur')
|
|
||||||
->label('PayPal Preis (EUR)')
|
|
||||||
->formatStateUsing(fn (mixed $state): string => is_numeric($state) ? number_format((float) $state, 2, ',', '.').' €' : '—')
|
|
||||||
->toggleable(),
|
|
||||||
TextColumn::make('metadata.scope')
|
|
||||||
->label('Scope')
|
|
||||||
->badge()
|
|
||||||
->toggleable(),
|
|
||||||
TextColumn::make('metadata.entitlements.features')
|
|
||||||
->label('Features')
|
|
||||||
->formatStateUsing(function (mixed $state): string {
|
|
||||||
if (! is_array($state)) {
|
|
||||||
return '—';
|
|
||||||
}
|
|
||||||
|
|
||||||
$features = array_values(array_filter(array_map(
|
|
||||||
static fn (mixed $feature): string => trim((string) $feature),
|
|
||||||
$state,
|
|
||||||
)));
|
|
||||||
|
|
||||||
return $features === [] ? '—' : implode(', ', $features);
|
|
||||||
})
|
|
||||||
->toggleable(),
|
|
||||||
TextColumn::make('extra_photos')->label('Fotos +'),
|
TextColumn::make('extra_photos')->label('Fotos +'),
|
||||||
TextColumn::make('extra_guests')->label('Gäste +'),
|
TextColumn::make('extra_guests')->label('Gäste +'),
|
||||||
TextColumn::make('extra_gallery_days')->label('Galerietage +'),
|
TextColumn::make('extra_gallery_days')->label('Galerietage +'),
|
||||||
@@ -188,14 +110,6 @@ class PackageAddonResource extends Resource
|
|||||||
'danger' => false,
|
'danger' => false,
|
||||||
])
|
])
|
||||||
->formatStateUsing(fn (bool $state) => $state ? 'Aktiv' : 'Inaktiv'),
|
->formatStateUsing(fn (bool $state) => $state ? 'Aktiv' : 'Inaktiv'),
|
||||||
BadgeColumn::make('sellability')
|
|
||||||
->label('Checkout')
|
|
||||||
->state(fn (PackageAddon $record): string => static::sellabilityLabel($record))
|
|
||||||
->colors([
|
|
||||||
'success' => fn (string $state): bool => $state === 'Verkäuflich',
|
|
||||||
'warning' => fn (string $state): bool => $state === 'Unvollständig',
|
|
||||||
'gray' => fn (string $state): bool => $state === 'Inaktiv',
|
|
||||||
]),
|
|
||||||
TextColumn::make('sort')
|
TextColumn::make('sort')
|
||||||
->label('Sort')
|
->label('Sort')
|
||||||
->sortable()
|
->sortable()
|
||||||
@@ -206,16 +120,16 @@ class PackageAddonResource extends Resource
|
|||||||
->label('Aktiv'),
|
->label('Aktiv'),
|
||||||
])
|
])
|
||||||
->actions([
|
->actions([
|
||||||
Actions\Action::make('syncLemonSqueezy')
|
Actions\Action::make('syncPaddle')
|
||||||
->label('Mit Lemon Squeezy synchronisieren')
|
->label('Mit Paddle synchronisieren')
|
||||||
->icon('heroicon-o-cloud-arrow-up')
|
->icon('heroicon-o-cloud-arrow-up')
|
||||||
->action(function (PackageAddon $record) {
|
->action(function (PackageAddon $record) {
|
||||||
SyncPackageAddonToLemonSqueezy::dispatch($record->id);
|
SyncPackageAddonToPaddle::dispatch($record->id);
|
||||||
|
|
||||||
Notification::make()
|
Notification::make()
|
||||||
->success()
|
->success()
|
||||||
->title('Lemon Squeezy-Sync gestartet')
|
->title('Paddle-Sync gestartet')
|
||||||
->body('Das Add-on wird im Hintergrund mit Lemon Squeezy abgeglichen.')
|
->body('Das Add-on wird im Hintergrund mit Paddle abgeglichen.')
|
||||||
->send();
|
->send();
|
||||||
}),
|
}),
|
||||||
Actions\EditAction::make()
|
Actions\EditAction::make()
|
||||||
@@ -252,21 +166,4 @@ class PackageAddonResource extends Resource
|
|||||||
'edit' => Pages\EditPackageAddon::route('/{record}/edit'),
|
'edit' => Pages\EditPackageAddon::route('/{record}/edit'),
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
protected static function sellabilityLabel(PackageAddon $record): string
|
|
||||||
{
|
|
||||||
if (! $record->active) {
|
|
||||||
return 'Inaktiv';
|
|
||||||
}
|
|
||||||
|
|
||||||
return $record->isSellableForProvider(static::addonProvider()) ? 'Verkäuflich' : 'Unvollständig';
|
|
||||||
}
|
|
||||||
|
|
||||||
protected static function addonProvider(): string
|
|
||||||
{
|
|
||||||
return (string) (
|
|
||||||
config('package-addons.provider')
|
|
||||||
?? config('checkout.default_provider', CheckoutSession::PROVIDER_PAYPAL)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,9 +4,12 @@ namespace App\Filament\Resources;
|
|||||||
|
|
||||||
use App\Filament\Clusters\WeeklyOps\WeeklyOpsCluster;
|
use App\Filament\Clusters\WeeklyOps\WeeklyOpsCluster;
|
||||||
use App\Filament\Resources\PackageResource\Pages;
|
use App\Filament\Resources\PackageResource\Pages;
|
||||||
|
use App\Jobs\PullPackageFromPaddle;
|
||||||
|
use App\Jobs\SyncPackageToPaddle;
|
||||||
use App\Models\Package;
|
use App\Models\Package;
|
||||||
use App\Services\Audit\SuperAdminAuditLogger;
|
use App\Services\Audit\SuperAdminAuditLogger;
|
||||||
use BackedEnum;
|
use BackedEnum;
|
||||||
|
use Filament\Actions;
|
||||||
use Filament\Actions\BulkActionGroup;
|
use Filament\Actions\BulkActionGroup;
|
||||||
use Filament\Actions\DeleteAction;
|
use Filament\Actions\DeleteAction;
|
||||||
use Filament\Actions\DeleteBulkAction;
|
use Filament\Actions\DeleteBulkAction;
|
||||||
@@ -23,6 +26,7 @@ use Filament\Forms\Components\Repeater;
|
|||||||
use Filament\Forms\Components\Select;
|
use Filament\Forms\Components\Select;
|
||||||
use Filament\Forms\Components\TextInput;
|
use Filament\Forms\Components\TextInput;
|
||||||
use Filament\Forms\Components\Toggle;
|
use Filament\Forms\Components\Toggle;
|
||||||
|
use Filament\Notifications\Notification;
|
||||||
use Filament\Resources\Resource;
|
use Filament\Resources\Resource;
|
||||||
use Filament\Schemas\Components\Section;
|
use Filament\Schemas\Components\Section;
|
||||||
use Filament\Schemas\Components\Tabs as SchemaTabs;
|
use Filament\Schemas\Components\Tabs as SchemaTabs;
|
||||||
@@ -168,31 +172,31 @@ class PackageResource extends Resource
|
|||||||
->columnSpanFull()
|
->columnSpanFull()
|
||||||
->default([]),
|
->default([]),
|
||||||
]),
|
]),
|
||||||
Section::make('Lemon Squeezy Billing')
|
Section::make('Paddle Billing')
|
||||||
->columns(2)
|
->columns(2)
|
||||||
->schema([
|
->schema([
|
||||||
TextInput::make('lemonsqueezy_product_id')
|
TextInput::make('paddle_product_id')
|
||||||
->label('Lemon Squeezy Produkt-ID')
|
->label('Paddle Produkt-ID')
|
||||||
->maxLength(191)
|
->maxLength(191)
|
||||||
->helperText('Produkt aus Lemon Squeezy. Leer lassen, wenn noch nicht synchronisiert.')
|
->helperText('Produkt aus Paddle Billing. Leer lassen, wenn noch nicht synchronisiert.')
|
||||||
->placeholder('nicht verknüpft'),
|
->placeholder('nicht verknüpft'),
|
||||||
TextInput::make('lemonsqueezy_variant_id')
|
TextInput::make('paddle_price_id')
|
||||||
->label('Lemon Squeezy Variant-ID')
|
->label('Paddle Preis-ID')
|
||||||
->maxLength(191)
|
->maxLength(191)
|
||||||
->helperText('Variant-ID aus Lemon Squeezy, verknüpft mit diesem Paket.')
|
->helperText('Preis-ID aus Paddle Billing, verknüpft mit diesem Paket.')
|
||||||
->placeholder('nicht verknüpft'),
|
->placeholder('nicht verknüpft'),
|
||||||
Placeholder::make('lemonsqueezy_sync_status')
|
Placeholder::make('paddle_sync_status')
|
||||||
->label('Sync-Status')
|
->label('Sync-Status')
|
||||||
->content(fn (?Package $record) => $record?->lemonsqueezy_sync_status ? Str::headline($record->lemonsqueezy_sync_status) : '–')
|
->content(fn (?Package $record) => $record?->paddle_sync_status ? Str::headline($record->paddle_sync_status) : '–')
|
||||||
->columnSpanFull(),
|
->columnSpanFull(),
|
||||||
Placeholder::make('lemonsqueezy_synced_at')
|
Placeholder::make('paddle_synced_at')
|
||||||
->label('Zuletzt synchronisiert')
|
->label('Zuletzt synchronisiert')
|
||||||
->content(fn (?Package $record) => $record?->lemonsqueezy_synced_at ? $record->lemonsqueezy_synced_at->diffForHumans() : '–')
|
->content(fn (?Package $record) => $record?->paddle_synced_at ? $record->paddle_synced_at->diffForHumans() : '–')
|
||||||
->columnSpanFull(),
|
->columnSpanFull(),
|
||||||
Placeholder::make('lemonsqueezy_sync_error')
|
Placeholder::make('paddle_sync_error')
|
||||||
->label('Letzter Fehler')
|
->label('Letzter Fehler')
|
||||||
->content(fn (?Package $record) => $record?->lemonsqueezy_sync_error_message ?? '–')
|
->content(fn (?Package $record) => $record?->paddle_sync_error_message ?? '–')
|
||||||
->visible(fn (?Package $record) => filled($record?->lemonsqueezy_sync_error_message))
|
->visible(fn (?Package $record) => filled($record?->paddle_sync_error_message))
|
||||||
->columnSpanFull(),
|
->columnSpanFull(),
|
||||||
]),
|
]),
|
||||||
]);
|
]);
|
||||||
@@ -259,15 +263,15 @@ class PackageResource extends Resource
|
|||||||
->label('Features')
|
->label('Features')
|
||||||
->wrap()
|
->wrap()
|
||||||
->formatStateUsing(fn ($state) => static::formatFeaturesForDisplay($state)),
|
->formatStateUsing(fn ($state) => static::formatFeaturesForDisplay($state)),
|
||||||
TextColumn::make('lemonsqueezy_product_id')
|
TextColumn::make('paddle_product_id')
|
||||||
->label('Lemon Squeezy Produkt')
|
->label('Paddle Produkt')
|
||||||
->toggleable(isToggledHiddenByDefault: true)
|
->toggleable(isToggledHiddenByDefault: true)
|
||||||
->formatStateUsing(fn ($state) => $state ?: '-'),
|
->formatStateUsing(fn ($state) => $state ?: '-'),
|
||||||
TextColumn::make('lemonsqueezy_variant_id')
|
TextColumn::make('paddle_price_id')
|
||||||
->label('Lemon Squeezy Variant')
|
->label('Paddle Preis')
|
||||||
->toggleable(isToggledHiddenByDefault: true)
|
->toggleable(isToggledHiddenByDefault: true)
|
||||||
->formatStateUsing(fn ($state) => $state ?: '-'),
|
->formatStateUsing(fn ($state) => $state ?: '-'),
|
||||||
BadgeColumn::make('lemonsqueezy_sync_status')
|
BadgeColumn::make('paddle_sync_status')
|
||||||
->label('Sync-Status')
|
->label('Sync-Status')
|
||||||
->colors([
|
->colors([
|
||||||
'success' => 'synced',
|
'success' => 'synced',
|
||||||
@@ -277,13 +281,13 @@ class PackageResource extends Resource
|
|||||||
])
|
])
|
||||||
->formatStateUsing(fn ($state) => $state ? Str::headline($state) : null)
|
->formatStateUsing(fn ($state) => $state ? Str::headline($state) : null)
|
||||||
->toggleable(isToggledHiddenByDefault: true),
|
->toggleable(isToggledHiddenByDefault: true),
|
||||||
TextColumn::make('lemonsqueezy_synced_at')
|
TextColumn::make('paddle_synced_at')
|
||||||
->label('Sync am')
|
->label('Sync am')
|
||||||
->dateTime()
|
->dateTime()
|
||||||
->toggleable(isToggledHiddenByDefault: true),
|
->toggleable(isToggledHiddenByDefault: true),
|
||||||
TextColumn::make('lemonsqueezy_sync_error_message')
|
TextColumn::make('paddle_sync_error_message')
|
||||||
->label('Sync-Fehler')
|
->label('Sync-Fehler')
|
||||||
->getStateUsing(fn (Package $record) => $record->lemonsqueezy_sync_error_message)
|
->getStateUsing(fn (Package $record) => $record->paddle_sync_error_message)
|
||||||
->wrap()
|
->wrap()
|
||||||
->toggleable(isToggledHiddenByDefault: true),
|
->toggleable(isToggledHiddenByDefault: true),
|
||||||
])
|
])
|
||||||
@@ -297,6 +301,71 @@ class PackageResource extends Resource
|
|||||||
TrashedFilter::make(),
|
TrashedFilter::make(),
|
||||||
])
|
])
|
||||||
->actions([
|
->actions([
|
||||||
|
Actions\Action::make('syncPaddle')
|
||||||
|
->label('Mit Paddle abgleichen')
|
||||||
|
->icon('heroicon-o-cloud-arrow-up')
|
||||||
|
->color('success')
|
||||||
|
->requiresConfirmation()
|
||||||
|
->disabled(fn (Package $record) => $record->paddle_sync_status === 'syncing')
|
||||||
|
->action(function (Package $record) {
|
||||||
|
SyncPackageToPaddle::dispatch($record->id);
|
||||||
|
|
||||||
|
Notification::make()
|
||||||
|
->success()
|
||||||
|
->title('Paddle-Sync gestartet')
|
||||||
|
->body('Das Paket wird im Hintergrund mit Paddle abgeglichen.')
|
||||||
|
->send();
|
||||||
|
}),
|
||||||
|
Actions\Action::make('linkPaddle')
|
||||||
|
->label('Paddle verknüpfen')
|
||||||
|
->icon('heroicon-o-link')
|
||||||
|
->color('info')
|
||||||
|
->form([
|
||||||
|
TextInput::make('paddle_product_id')
|
||||||
|
->label('Paddle Produkt-ID')
|
||||||
|
->required()
|
||||||
|
->maxLength(191),
|
||||||
|
TextInput::make('paddle_price_id')
|
||||||
|
->label('Paddle Preis-ID')
|
||||||
|
->required()
|
||||||
|
->maxLength(191),
|
||||||
|
])
|
||||||
|
->fillForm(fn (Package $record) => [
|
||||||
|
'paddle_product_id' => $record->paddle_product_id,
|
||||||
|
'paddle_price_id' => $record->paddle_price_id,
|
||||||
|
])
|
||||||
|
->action(function (Package $record, array $data): void {
|
||||||
|
$record->linkPaddleIds($data['paddle_product_id'], $data['paddle_price_id']);
|
||||||
|
|
||||||
|
PullPackageFromPaddle::dispatch($record->id);
|
||||||
|
|
||||||
|
app(SuperAdminAuditLogger::class)->recordModelMutation(
|
||||||
|
'linked',
|
||||||
|
$record,
|
||||||
|
SuperAdminAuditLogger::fieldsMetadata($data),
|
||||||
|
static::class
|
||||||
|
);
|
||||||
|
|
||||||
|
Notification::make()
|
||||||
|
->success()
|
||||||
|
->title('Paddle-Verknüpfung gespeichert')
|
||||||
|
->body('Die IDs wurden gespeichert und ein Pull wurde angestoßen.')
|
||||||
|
->send();
|
||||||
|
}),
|
||||||
|
Actions\Action::make('pullPaddle')
|
||||||
|
->label('Status von Paddle holen')
|
||||||
|
->icon('heroicon-o-cloud-arrow-down')
|
||||||
|
->disabled(fn (Package $record) => ! $record->paddle_product_id && ! $record->paddle_price_id)
|
||||||
|
->requiresConfirmation()
|
||||||
|
->action(function (Package $record) {
|
||||||
|
PullPackageFromPaddle::dispatch($record->id);
|
||||||
|
|
||||||
|
Notification::make()
|
||||||
|
->info()
|
||||||
|
->title('Paddle-Abgleich angefordert')
|
||||||
|
->body('Der aktuelle Stand aus Paddle wird geladen und hier hinterlegt.')
|
||||||
|
->send();
|
||||||
|
}),
|
||||||
ViewAction::make(),
|
ViewAction::make(),
|
||||||
EditAction::make()
|
EditAction::make()
|
||||||
->after(fn (array $data, Package $record) => app(SuperAdminAuditLogger::class)->recordModelMutation(
|
->after(fn (array $data, Package $record) => app(SuperAdminAuditLogger::class)->recordModelMutation(
|
||||||
@@ -396,7 +465,6 @@ class PackageResource extends Resource
|
|||||||
'unlimited_sharing' => 'Unbegrenztes Teilen',
|
'unlimited_sharing' => 'Unbegrenztes Teilen',
|
||||||
'no_watermark' => 'Kein Wasserzeichen',
|
'no_watermark' => 'Kein Wasserzeichen',
|
||||||
'custom_branding' => 'Eigenes Branding',
|
'custom_branding' => 'Eigenes Branding',
|
||||||
'ai_styling' => 'AI-Styling',
|
|
||||||
'custom_tasks' => 'Eigene Aufgaben',
|
'custom_tasks' => 'Eigene Aufgaben',
|
||||||
'reseller_dashboard' => 'Reseller-Dashboard',
|
'reseller_dashboard' => 'Reseller-Dashboard',
|
||||||
'advanced_analytics' => 'Erweiterte Analytics',
|
'advanced_analytics' => 'Erweiterte Analytics',
|
||||||
|
|||||||
@@ -14,3 +14,4 @@ class ListPurchaseHistories extends ListRecords
|
|||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -14,3 +14,4 @@ class ViewPurchaseHistory extends ViewRecord
|
|||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ use App\Models\PackagePurchase;
|
|||||||
use App\Notifications\Customer\RefundReceipt;
|
use App\Notifications\Customer\RefundReceipt;
|
||||||
use App\Notifications\Ops\RefundProcessed;
|
use App\Notifications\Ops\RefundProcessed;
|
||||||
use App\Services\Audit\SuperAdminAuditLogger;
|
use App\Services\Audit\SuperAdminAuditLogger;
|
||||||
use App\Services\LemonSqueezy\LemonSqueezyOrderService;
|
use App\Services\Paddle\PaddleTransactionService;
|
||||||
use BackedEnum;
|
use BackedEnum;
|
||||||
use Filament\Actions\Action;
|
use Filament\Actions\Action;
|
||||||
use Filament\Actions\BulkActionGroup;
|
use Filament\Actions\BulkActionGroup;
|
||||||
@@ -203,15 +203,15 @@ class PurchaseResource extends Resource
|
|||||||
$refundSuccess = true;
|
$refundSuccess = true;
|
||||||
$errorMessage = null;
|
$errorMessage = null;
|
||||||
|
|
||||||
if ($record->provider === 'lemonsqueezy' && $record->provider_id) {
|
if ($record->provider === 'paddle' && $record->provider_id) {
|
||||||
try {
|
try {
|
||||||
/** @var LemonSqueezyOrderService $lemonsqueezy */
|
/** @var PaddleTransactionService $paddle */
|
||||||
$lemonsqueezy = App::make(LemonSqueezyOrderService::class);
|
$paddle = App::make(PaddleTransactionService::class);
|
||||||
$lemonsqueezy->refund($record->provider_id, ['reason' => $reason]);
|
$paddle->refund($record->provider_id, ['reason' => $reason]);
|
||||||
} catch (\Throwable $exception) {
|
} catch (\Throwable $exception) {
|
||||||
$refundSuccess = false;
|
$refundSuccess = false;
|
||||||
$errorMessage = $exception->getMessage();
|
$errorMessage = $exception->getMessage();
|
||||||
Log::warning('Lemon Squeezy refund failed', [
|
Log::warning('Paddle refund failed', [
|
||||||
'purchase_id' => $record->id,
|
'purchase_id' => $record->id,
|
||||||
'provider_id' => $record->provider_id,
|
'provider_id' => $record->provider_id,
|
||||||
'error' => $exception->getMessage(),
|
'error' => $exception->getMessage(),
|
||||||
|
|||||||
@@ -35,7 +35,7 @@ class ViewPurchase extends ViewRecord
|
|||||||
->visible(fn ($record): bool => ! $record->refunded)
|
->visible(fn ($record): bool => ! $record->refunded)
|
||||||
->action(function ($record) {
|
->action(function ($record) {
|
||||||
$record->update(['refunded' => true]);
|
$record->update(['refunded' => true]);
|
||||||
// TODO: Call Lemon Squeezy API for actual refund
|
// TODO: Call Paddle API for actual refund
|
||||||
|
|
||||||
app(SuperAdminAuditLogger::class)->record(
|
app(SuperAdminAuditLogger::class)->record(
|
||||||
'purchase.refunded',
|
'purchase.refunded',
|
||||||
|
|||||||
@@ -56,7 +56,7 @@ class TenantFeedbackResource extends Resource
|
|||||||
|
|
||||||
public static function getNavigationGroup(): UnitEnum|string|null
|
public static function getNavigationGroup(): UnitEnum|string|null
|
||||||
{
|
{
|
||||||
return __('admin.nav.infrastructure');
|
return __('admin.nav.feedback_support');
|
||||||
}
|
}
|
||||||
|
|
||||||
public static function getEloquentQuery(): Builder
|
public static function getEloquentQuery(): Builder
|
||||||
|
|||||||
@@ -8,7 +8,6 @@ use App\Filament\Resources\TenantResource\RelationManagers\PackagePurchasesRelat
|
|||||||
use App\Filament\Resources\TenantResource\RelationManagers\TenantPackagesRelationManager;
|
use App\Filament\Resources\TenantResource\RelationManagers\TenantPackagesRelationManager;
|
||||||
use App\Filament\Resources\TenantResource\Schemas\TenantInfolist;
|
use App\Filament\Resources\TenantResource\Schemas\TenantInfolist;
|
||||||
use App\Jobs\AnonymizeAccount;
|
use App\Jobs\AnonymizeAccount;
|
||||||
use App\Models\Package;
|
|
||||||
use App\Models\Tenant;
|
use App\Models\Tenant;
|
||||||
use App\Notifications\InactiveTenantDeletionWarning;
|
use App\Notifications\InactiveTenantDeletionWarning;
|
||||||
use App\Services\Audit\SuperAdminAuditLogger;
|
use App\Services\Audit\SuperAdminAuditLogger;
|
||||||
@@ -73,10 +72,10 @@ class TenantResource extends Resource
|
|||||||
->email()
|
->email()
|
||||||
->required()
|
->required()
|
||||||
->maxLength(255),
|
->maxLength(255),
|
||||||
TextInput::make('lemonsqueezy_customer_id')
|
TextInput::make('paddle_customer_id')
|
||||||
->label('Lemon Squeezy Customer ID')
|
->label('Paddle Customer ID')
|
||||||
->maxLength(191)
|
->maxLength(191)
|
||||||
->helperText('Verknüpfung mit Lemon Squeezy Kundenkonto.')
|
->helperText('Verknuepfung mit Paddle Billing Kundenkonto.')
|
||||||
->nullable(),
|
->nullable(),
|
||||||
TextInput::make('total_revenue')
|
TextInput::make('total_revenue')
|
||||||
->label(__('admin.tenants.fields.total_revenue'))
|
->label(__('admin.tenants.fields.total_revenue'))
|
||||||
@@ -135,8 +134,8 @@ class TenantResource extends Resource
|
|||||||
->getStateUsing(fn (Tenant $record) => $record->user?->full_name ?? 'Unbekannt'),
|
->getStateUsing(fn (Tenant $record) => $record->user?->full_name ?? 'Unbekannt'),
|
||||||
Tables\Columns\TextColumn::make('slug')->searchable(),
|
Tables\Columns\TextColumn::make('slug')->searchable(),
|
||||||
Tables\Columns\TextColumn::make('contact_email'),
|
Tables\Columns\TextColumn::make('contact_email'),
|
||||||
Tables\Columns\TextColumn::make('lemonsqueezy_customer_id')
|
Tables\Columns\TextColumn::make('paddle_customer_id')
|
||||||
->label('Lemon Squeezy Customer')
|
->label('Paddle Customer')
|
||||||
->toggleable(isToggledHiddenByDefault: true)
|
->toggleable(isToggledHiddenByDefault: true)
|
||||||
->formatStateUsing(fn ($state) => $state ?: '-'),
|
->formatStateUsing(fn ($state) => $state ?: '-'),
|
||||||
Tables\Columns\TextColumn::make('active_reseller_package_id')
|
Tables\Columns\TextColumn::make('active_reseller_package_id')
|
||||||
@@ -206,13 +205,11 @@ class TenantResource extends Resource
|
|||||||
Forms\Components\Textarea::make('reason')->label('Grund')->rows(3),
|
Forms\Components\Textarea::make('reason')->label('Grund')->rows(3),
|
||||||
])
|
])
|
||||||
->action(function (Tenant $record, array $data) {
|
->action(function (Tenant $record, array $data) {
|
||||||
$package = Package::query()->find($data['package_id']);
|
|
||||||
\App\Models\TenantPackage::create([
|
\App\Models\TenantPackage::create([
|
||||||
'tenant_id' => $record->id,
|
'tenant_id' => $record->id,
|
||||||
'package_id' => $data['package_id'],
|
'package_id' => $data['package_id'],
|
||||||
'expires_at' => $data['expires_at'],
|
'expires_at' => $data['expires_at'],
|
||||||
'active' => true,
|
'active' => true,
|
||||||
'price' => $package?->price ?? 0,
|
|
||||||
'reason' => $data['reason'] ?? null,
|
'reason' => $data['reason'] ?? null,
|
||||||
]);
|
]);
|
||||||
\App\Models\PackagePurchase::create([
|
\App\Models\PackagePurchase::create([
|
||||||
|
|||||||
@@ -44,7 +44,7 @@ class PackagePurchasesRelationManager extends RelationManager
|
|||||||
Select::make('provider')
|
Select::make('provider')
|
||||||
->label('Anbieter')
|
->label('Anbieter')
|
||||||
->options([
|
->options([
|
||||||
'lemonsqueezy' => 'Lemon Squeezy',
|
'paddle' => 'Paddle',
|
||||||
'manual' => 'Manuell',
|
'manual' => 'Manuell',
|
||||||
'free' => 'Kostenlos',
|
'free' => 'Kostenlos',
|
||||||
])
|
])
|
||||||
@@ -89,7 +89,7 @@ class PackagePurchasesRelationManager extends RelationManager
|
|||||||
TextColumn::make('provider')
|
TextColumn::make('provider')
|
||||||
->badge()
|
->badge()
|
||||||
->color(fn (string $state): string => match ($state) {
|
->color(fn (string $state): string => match ($state) {
|
||||||
'lemonsqueezy' => 'success',
|
'paddle' => 'success',
|
||||||
'manual' => 'gray',
|
'manual' => 'gray',
|
||||||
'free' => 'success',
|
'free' => 'success',
|
||||||
default => 'gray',
|
default => 'gray',
|
||||||
@@ -116,7 +116,7 @@ class PackagePurchasesRelationManager extends RelationManager
|
|||||||
]),
|
]),
|
||||||
SelectFilter::make('provider')
|
SelectFilter::make('provider')
|
||||||
->options([
|
->options([
|
||||||
'lemonsqueezy' => 'Lemon Squeezy',
|
'paddle' => 'Paddle',
|
||||||
'manual' => 'Manuell',
|
'manual' => 'Manuell',
|
||||||
'free' => 'Kostenlos',
|
'free' => 'Kostenlos',
|
||||||
]),
|
]),
|
||||||
|
|||||||
@@ -40,10 +40,10 @@ class TenantPackagesRelationManager extends RelationManager
|
|||||||
DateTimePicker::make('expires_at')
|
DateTimePicker::make('expires_at')
|
||||||
->label('Ablaufdatum')
|
->label('Ablaufdatum')
|
||||||
->required(),
|
->required(),
|
||||||
TextInput::make('lemonsqueezy_subscription_id')
|
TextInput::make('paddle_subscription_id')
|
||||||
->label('Lemon Squeezy Subscription ID')
|
->label('Paddle Subscription ID')
|
||||||
->maxLength(191)
|
->maxLength(191)
|
||||||
->helperText('Abonnement-ID aus Lemon Squeezy.')
|
->helperText('Abonnement-ID aus Paddle Billing.')
|
||||||
->nullable(),
|
->nullable(),
|
||||||
Toggle::make('active')
|
Toggle::make('active')
|
||||||
->label('Aktiv'),
|
->label('Aktiv'),
|
||||||
@@ -75,8 +75,8 @@ class TenantPackagesRelationManager extends RelationManager
|
|||||||
TextColumn::make('expires_at')
|
TextColumn::make('expires_at')
|
||||||
->dateTime()
|
->dateTime()
|
||||||
->sortable(),
|
->sortable(),
|
||||||
TextColumn::make('lemonsqueezy_subscription_id')
|
TextColumn::make('paddle_subscription_id')
|
||||||
->label('Lemon Squeezy Subscription')
|
->label('Paddle Subscription')
|
||||||
->toggleable(isToggledHiddenByDefault: true)
|
->toggleable(isToggledHiddenByDefault: true)
|
||||||
->formatStateUsing(fn ($state) => $state ?: '-'),
|
->formatStateUsing(fn ($state) => $state ?: '-'),
|
||||||
IconColumn::make('active')
|
IconColumn::make('active')
|
||||||
|
|||||||
@@ -22,8 +22,8 @@ class TenantInfolist
|
|||||||
TextEntry::make('user.full_name')
|
TextEntry::make('user.full_name')
|
||||||
->label(__('admin.tenants.fields.owner'))
|
->label(__('admin.tenants.fields.owner'))
|
||||||
->state(fn (Tenant $record) => $record->user?->full_name ?? '—'),
|
->state(fn (Tenant $record) => $record->user?->full_name ?? '—'),
|
||||||
TextEntry::make('lemonsqueezy_customer_id')
|
TextEntry::make('paddle_customer_id')
|
||||||
->label('Lemon Squeezy Customer ID')
|
->label('Paddle Customer ID')
|
||||||
->placeholder('—'),
|
->placeholder('—'),
|
||||||
TextEntry::make('total_revenue')
|
TextEntry::make('total_revenue')
|
||||||
->label(__('admin.tenants.fields.total_revenue'))
|
->label(__('admin.tenants.fields.total_revenue'))
|
||||||
|
|||||||
@@ -1,177 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
namespace App\Filament\SuperAdmin\Pages;
|
|
||||||
|
|
||||||
use App\Filament\Clusters\RareAdmin\RareAdminCluster;
|
|
||||||
use App\Models\AiEditingSetting;
|
|
||||||
use App\Services\Audit\SuperAdminAuditLogger;
|
|
||||||
use Filament\Forms;
|
|
||||||
use Filament\Notifications\Notification;
|
|
||||||
use Filament\Pages\Page;
|
|
||||||
use Filament\Schemas\Components\Section;
|
|
||||||
use Filament\Schemas\Schema;
|
|
||||||
|
|
||||||
class AiEditingSettingsPage extends Page
|
|
||||||
{
|
|
||||||
protected static null|string|\BackedEnum $navigationIcon = 'heroicon-o-sparkles';
|
|
||||||
|
|
||||||
protected static ?string $cluster = RareAdminCluster::class;
|
|
||||||
|
|
||||||
protected string $view = 'filament.super-admin.pages.ai-editing-settings-page';
|
|
||||||
|
|
||||||
protected static null|string|\UnitEnum $navigationGroup = null;
|
|
||||||
|
|
||||||
protected static ?int $navigationSort = 30;
|
|
||||||
|
|
||||||
public static function getNavigationGroup(): \UnitEnum|string|null
|
|
||||||
{
|
|
||||||
return __('admin.nav.platform');
|
|
||||||
}
|
|
||||||
|
|
||||||
public static function getNavigationLabel(): string
|
|
||||||
{
|
|
||||||
return 'AI Editing Settings';
|
|
||||||
}
|
|
||||||
|
|
||||||
public bool $is_enabled = true;
|
|
||||||
|
|
||||||
public string $default_provider = 'runware';
|
|
||||||
|
|
||||||
public ?string $fallback_provider = null;
|
|
||||||
|
|
||||||
public string $runware_mode = 'live';
|
|
||||||
|
|
||||||
public bool $queue_auto_dispatch = false;
|
|
||||||
|
|
||||||
public string $queue_name = 'default';
|
|
||||||
|
|
||||||
public int $queue_max_polls = 6;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @var array<int, string>
|
|
||||||
*/
|
|
||||||
public array $blocked_terms = [];
|
|
||||||
|
|
||||||
public ?string $status_message = null;
|
|
||||||
|
|
||||||
public function mount(): void
|
|
||||||
{
|
|
||||||
$settings = AiEditingSetting::current();
|
|
||||||
|
|
||||||
$this->is_enabled = (bool) $settings->is_enabled;
|
|
||||||
$this->default_provider = (string) ($settings->default_provider ?: 'runware');
|
|
||||||
$this->fallback_provider = $settings->fallback_provider ? (string) $settings->fallback_provider : null;
|
|
||||||
$this->runware_mode = (string) ($settings->runware_mode ?: 'live');
|
|
||||||
$this->queue_auto_dispatch = (bool) $settings->queue_auto_dispatch;
|
|
||||||
$this->queue_name = (string) ($settings->queue_name ?: 'default');
|
|
||||||
$this->queue_max_polls = max(1, (int) ($settings->queue_max_polls ?: 6));
|
|
||||||
$this->blocked_terms = array_values(array_filter(array_map(
|
|
||||||
static fn (mixed $term): string => trim((string) $term),
|
|
||||||
(array) $settings->blocked_terms
|
|
||||||
)));
|
|
||||||
$this->status_message = $settings->status_message ? (string) $settings->status_message : null;
|
|
||||||
}
|
|
||||||
|
|
||||||
public function form(Schema $schema): Schema
|
|
||||||
{
|
|
||||||
return $schema->schema([
|
|
||||||
Section::make('Global Availability')
|
|
||||||
->schema([
|
|
||||||
Forms\Components\Toggle::make('is_enabled')
|
|
||||||
->label('Enable AI editing globally'),
|
|
||||||
Forms\Components\Textarea::make('status_message')
|
|
||||||
->label('Disabled message')
|
|
||||||
->maxLength(255)
|
|
||||||
->rows(2)
|
|
||||||
->helperText('Shown to guest and tenant clients when the feature is disabled.')
|
|
||||||
->nullable(),
|
|
||||||
]),
|
|
||||||
Section::make('Provider')
|
|
||||||
->schema([
|
|
||||||
Forms\Components\Select::make('default_provider')
|
|
||||||
->label('Default provider')
|
|
||||||
->options([
|
|
||||||
'runware' => 'runware.ai',
|
|
||||||
])
|
|
||||||
->required(),
|
|
||||||
Forms\Components\TextInput::make('fallback_provider')
|
|
||||||
->label('Fallback provider')
|
|
||||||
->maxLength(40)
|
|
||||||
->helperText('Reserved for provider failover.'),
|
|
||||||
Forms\Components\Select::make('runware_mode')
|
|
||||||
->label('Runware mode')
|
|
||||||
->options([
|
|
||||||
'live' => 'Live API',
|
|
||||||
'fake' => 'Fake mode (internal testing)',
|
|
||||||
])
|
|
||||||
->required(),
|
|
||||||
])
|
|
||||||
->columns(2),
|
|
||||||
Section::make('Queue Orchestration')
|
|
||||||
->schema([
|
|
||||||
Forms\Components\Toggle::make('queue_auto_dispatch')
|
|
||||||
->label('Auto-dispatch jobs after request creation'),
|
|
||||||
Forms\Components\TextInput::make('queue_name')
|
|
||||||
->label('Queue name')
|
|
||||||
->required()
|
|
||||||
->maxLength(60),
|
|
||||||
Forms\Components\TextInput::make('queue_max_polls')
|
|
||||||
->label('Max provider polls')
|
|
||||||
->numeric()
|
|
||||||
->minValue(1)
|
|
||||||
->maxValue(50)
|
|
||||||
->required(),
|
|
||||||
])
|
|
||||||
->columns(2),
|
|
||||||
Section::make('Prompt Safety')
|
|
||||||
->schema([
|
|
||||||
Forms\Components\TagsInput::make('blocked_terms')
|
|
||||||
->label('Blocked prompt terms')
|
|
||||||
->helperText('Case-insensitive term match before queue dispatch.')
|
|
||||||
->placeholder('Add blocked term'),
|
|
||||||
]),
|
|
||||||
]);
|
|
||||||
}
|
|
||||||
|
|
||||||
public function save(): void
|
|
||||||
{
|
|
||||||
$this->validate();
|
|
||||||
|
|
||||||
$settings = AiEditingSetting::query()->firstOrNew(['id' => 1]);
|
|
||||||
$settings->is_enabled = $this->is_enabled;
|
|
||||||
$settings->default_provider = $this->default_provider;
|
|
||||||
$settings->fallback_provider = $this->nullableString($this->fallback_provider);
|
|
||||||
$settings->runware_mode = $this->runware_mode;
|
|
||||||
$settings->queue_auto_dispatch = $this->queue_auto_dispatch;
|
|
||||||
$settings->queue_name = $this->queue_name;
|
|
||||||
$settings->queue_max_polls = max(1, $this->queue_max_polls);
|
|
||||||
$settings->blocked_terms = array_values(array_filter(array_map(
|
|
||||||
static fn (mixed $term): string => trim((string) $term),
|
|
||||||
$this->blocked_terms
|
|
||||||
)));
|
|
||||||
$settings->status_message = $this->nullableString($this->status_message);
|
|
||||||
$settings->save();
|
|
||||||
|
|
||||||
$changed = $settings->getChanges();
|
|
||||||
if ($changed !== []) {
|
|
||||||
app(SuperAdminAuditLogger::class)->record(
|
|
||||||
'ai_editing.settings_updated',
|
|
||||||
$settings,
|
|
||||||
SuperAdminAuditLogger::fieldsMetadata(array_keys($changed)),
|
|
||||||
source: static::class
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
Notification::make()
|
|
||||||
->title('AI editing settings saved.')
|
|
||||||
->success()
|
|
||||||
->send();
|
|
||||||
}
|
|
||||||
|
|
||||||
private function nullableString(?string $value): ?string
|
|
||||||
{
|
|
||||||
$trimmed = trim((string) $value);
|
|
||||||
|
|
||||||
return $trimmed !== '' ? $trimmed : null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -3,78 +3,39 @@
|
|||||||
namespace App\Filament\SuperAdmin\Pages\Auth;
|
namespace App\Filament\SuperAdmin\Pages\Auth;
|
||||||
|
|
||||||
use Filament\Auth\Pages\EditProfile as BaseEditProfile;
|
use Filament\Auth\Pages\EditProfile as BaseEditProfile;
|
||||||
use Filament\Facades\Filament;
|
|
||||||
use Filament\Forms\Components\Select;
|
|
||||||
use Filament\Forms\Components\TextInput;
|
use Filament\Forms\Components\TextInput;
|
||||||
use Filament\Schemas\Components\Component;
|
use Filament\Forms\Components\Select;
|
||||||
use Filament\Schemas\Components\Livewire;
|
|
||||||
use Filament\Schemas\Components\Section;
|
|
||||||
use Filament\Schemas\Components\Utilities\Get;
|
|
||||||
use Filament\Schemas\Schema;
|
use Filament\Schemas\Schema;
|
||||||
|
use Illuminate\Support\Facades\Log;
|
||||||
|
|
||||||
class EditProfile extends BaseEditProfile
|
class EditProfile extends BaseEditProfile
|
||||||
{
|
{
|
||||||
protected function getPasswordConfirmationFormComponent(): Component
|
public function mount(): void
|
||||||
{
|
{
|
||||||
return TextInput::make('passwordConfirmation')
|
Log::info('EditProfile class loaded for superadmin');
|
||||||
->label(__('filament-panels::auth/pages/edit-profile.form.password_confirmation.label'))
|
parent::mount();
|
||||||
->validationAttribute(__('filament-panels::auth/pages/edit-profile.form.password_confirmation.validation_attribute'))
|
|
||||||
->password()
|
|
||||||
->autocomplete('new-password')
|
|
||||||
->revealable(filament()->arePasswordsRevealable())
|
|
||||||
->required()
|
|
||||||
->visible(fn (Get $get): bool => filled($get('password')))
|
|
||||||
->dehydrated(false);
|
|
||||||
}
|
|
||||||
|
|
||||||
protected function getCurrentPasswordFormComponent(): Component
|
|
||||||
{
|
|
||||||
return TextInput::make('currentPassword')
|
|
||||||
->label(__('filament-panels::auth/pages/edit-profile.form.current_password.label'))
|
|
||||||
->validationAttribute(__('filament-panels::auth/pages/edit-profile.form.current_password.validation_attribute'))
|
|
||||||
->belowContent(__('filament-panels::auth/pages/edit-profile.form.current_password.below_content'))
|
|
||||||
->password()
|
|
||||||
->autocomplete('current-password')
|
|
||||||
->currentPassword(guard: Filament::getAuthGuard())
|
|
||||||
->revealable(filament()->arePasswordsRevealable())
|
|
||||||
->required()
|
|
||||||
->visible(fn (Get $get): bool => filled($get('password')))
|
|
||||||
->dehydrated(false);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public function form(Schema $schema): Schema
|
public function form(Schema $schema): Schema
|
||||||
{
|
{
|
||||||
return $schema
|
return $schema
|
||||||
->schema([
|
->schema([
|
||||||
Section::make('Profile')
|
$this->getNameFormComponent(),
|
||||||
->schema([
|
$this->getEmailFormComponent(),
|
||||||
$this->getNameFormComponent(),
|
TextInput::make('username')
|
||||||
$this->getEmailFormComponent(),
|
->required()
|
||||||
TextInput::make('username')
|
->unique(ignoreRecord: true)
|
||||||
->required()
|
->maxLength(255),
|
||||||
->unique(ignoreRecord: true)
|
Select::make('preferred_locale')
|
||||||
->maxLength(255),
|
->options([
|
||||||
Select::make('preferred_locale')
|
'de' => 'Deutsch',
|
||||||
->options([
|
'en' => 'English',
|
||||||
'de' => 'Deutsch',
|
|
||||||
'en' => 'English',
|
|
||||||
])
|
|
||||||
->default('de')
|
|
||||||
->required(),
|
|
||||||
])
|
])
|
||||||
->columns(2),
|
->default('de')
|
||||||
Section::make('Security')
|
->required(),
|
||||||
->schema([
|
$this->getPasswordFormComponent(),
|
||||||
$this->getPasswordFormComponent(),
|
$this->getPasswordConfirmationFormComponent(),
|
||||||
$this->getPasswordConfirmationFormComponent(),
|
$this->getCurrentPasswordFormComponent(),
|
||||||
$this->getCurrentPasswordFormComponent(),
|
|
||||||
])
|
|
||||||
->columns(1),
|
|
||||||
Section::make('Support API Tokens')
|
|
||||||
->description('Manage bearer tokens for external support tooling.')
|
|
||||||
->schema([
|
|
||||||
Livewire::make('support-api-token-manager'),
|
|
||||||
]),
|
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1,42 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
namespace App\Filament\SuperAdmin\Pages;
|
|
||||||
|
|
||||||
use App\Filament\Clusters\RareAdmin\RareAdminCluster;
|
|
||||||
use BackedEnum;
|
|
||||||
use Filament\Pages\Page;
|
|
||||||
use UnitEnum;
|
|
||||||
|
|
||||||
class InternalDocsPage extends Page
|
|
||||||
{
|
|
||||||
protected static ?string $cluster = RareAdminCluster::class;
|
|
||||||
|
|
||||||
protected static null|string|BackedEnum $navigationIcon = 'heroicon-o-book-open';
|
|
||||||
|
|
||||||
protected static null|string|UnitEnum $navigationGroup = null;
|
|
||||||
|
|
||||||
protected static ?int $navigationSort = 18;
|
|
||||||
|
|
||||||
public static function getNavigationLabel(): string
|
|
||||||
{
|
|
||||||
return __('admin.nav.internal_docs');
|
|
||||||
}
|
|
||||||
|
|
||||||
public static function getNavigationGroup(): UnitEnum|string|null
|
|
||||||
{
|
|
||||||
return __('admin.nav.infrastructure');
|
|
||||||
}
|
|
||||||
|
|
||||||
public static function getNavigationUrl(): string
|
|
||||||
{
|
|
||||||
return url('/super-admin/docs');
|
|
||||||
}
|
|
||||||
|
|
||||||
public static function getNavigationItemActiveRoutePattern(): string|array
|
|
||||||
{
|
|
||||||
return [
|
|
||||||
static::getRouteName(),
|
|
||||||
'filament.superadmin-kb.*',
|
|
||||||
];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,280 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
namespace App\Filament\SuperAdmin\Widgets;
|
|
||||||
|
|
||||||
use App\Models\User;
|
|
||||||
use App\Services\Audit\SuperAdminAuditLogger;
|
|
||||||
use Filament\Actions\Action;
|
|
||||||
use Filament\Facades\Filament;
|
|
||||||
use Filament\Forms\Components\CheckboxList;
|
|
||||||
use Filament\Forms\Components\DateTimePicker;
|
|
||||||
use Filament\Forms\Components\TextInput;
|
|
||||||
use Filament\Notifications\Notification;
|
|
||||||
use Filament\Tables;
|
|
||||||
use Filament\Tables\Table;
|
|
||||||
use Filament\Widgets\TableWidget;
|
|
||||||
use Illuminate\Database\Eloquent\Builder;
|
|
||||||
use Illuminate\Support\Carbon;
|
|
||||||
use Laravel\Sanctum\NewAccessToken;
|
|
||||||
use Laravel\Sanctum\PersonalAccessToken;
|
|
||||||
|
|
||||||
class SupportApiTokenManager extends TableWidget
|
|
||||||
{
|
|
||||||
protected static bool $isDiscovered = false;
|
|
||||||
|
|
||||||
protected int|string|array $columnSpan = 'full';
|
|
||||||
|
|
||||||
public function table(Table $table): Table
|
|
||||||
{
|
|
||||||
return $table
|
|
||||||
->heading('Support API Tokens')
|
|
||||||
->query(fn (): Builder => $this->getTokenQuery())
|
|
||||||
->defaultSort('created_at', 'desc')
|
|
||||||
->columns([
|
|
||||||
Tables\Columns\TextColumn::make('name')
|
|
||||||
->label('Name')
|
|
||||||
->sortable()
|
|
||||||
->searchable(),
|
|
||||||
Tables\Columns\TextColumn::make('abilities')
|
|
||||||
->label('Abilities')
|
|
||||||
->formatStateUsing(fn ($state): string => $this->formatAbilities($state))
|
|
||||||
->wrap(),
|
|
||||||
Tables\Columns\TextColumn::make('last_used_at')
|
|
||||||
->label('Last used')
|
|
||||||
->since()
|
|
||||||
->placeholder('—'),
|
|
||||||
Tables\Columns\TextColumn::make('expires_at')
|
|
||||||
->label('Expires')
|
|
||||||
->dateTime('Y-m-d H:i')
|
|
||||||
->placeholder('—'),
|
|
||||||
Tables\Columns\TextColumn::make('created_at')
|
|
||||||
->label('Created')
|
|
||||||
->since(),
|
|
||||||
])
|
|
||||||
->headerActions([
|
|
||||||
Action::make('create_support_token')
|
|
||||||
->label('Create token')
|
|
||||||
->icon('heroicon-o-key')
|
|
||||||
->form([
|
|
||||||
TextInput::make('name')
|
|
||||||
->label('Token name')
|
|
||||||
->default($this->defaultTokenName())
|
|
||||||
->required()
|
|
||||||
->maxLength(255)
|
|
||||||
->helperText('Existing tokens with the same name will be revoked.'),
|
|
||||||
CheckboxList::make('abilities')
|
|
||||||
->label('Abilities')
|
|
||||||
->options($this->abilityOptions())
|
|
||||||
->columns(2)
|
|
||||||
->required()
|
|
||||||
->default($this->defaultAbilities()),
|
|
||||||
DateTimePicker::make('expires_at')
|
|
||||||
->label('Expires at')
|
|
||||||
->displayFormat('Y-m-d H:i')
|
|
||||||
->seconds(false),
|
|
||||||
])
|
|
||||||
->action(function (array $data): void {
|
|
||||||
$user = $this->getUser();
|
|
||||||
|
|
||||||
if (! $user) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
$name = $this->normalizeTokenName($data['name'] ?? null);
|
|
||||||
$abilities = $this->normalizeAbilities($data['abilities'] ?? []);
|
|
||||||
$expiresAt = $this->normalizeExpiresAt($data['expires_at'] ?? null);
|
|
||||||
|
|
||||||
$user->tokens()->where('name', $name)->delete();
|
|
||||||
|
|
||||||
$token = $user->createToken($name, $abilities, $expiresAt);
|
|
||||||
|
|
||||||
$this->recordTokenCreated($token, $abilities, $user);
|
|
||||||
|
|
||||||
Notification::make()
|
|
||||||
->success()
|
|
||||||
->title('Token created')
|
|
||||||
->body('Copy this token now. It will not be shown again: '.$token->plainTextToken)
|
|
||||||
->persistent()
|
|
||||||
->send();
|
|
||||||
}),
|
|
||||||
])
|
|
||||||
->actions([
|
|
||||||
Action::make('revoke')
|
|
||||||
->label('Revoke')
|
|
||||||
->icon('heroicon-o-trash')
|
|
||||||
->color('danger')
|
|
||||||
->requiresConfirmation()
|
|
||||||
->visible(fn (PersonalAccessToken $record): bool => $this->ownsToken($record))
|
|
||||||
->action(function (PersonalAccessToken $record): void {
|
|
||||||
if (! $this->ownsToken($record)) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
app(SuperAdminAuditLogger::class)->record(
|
|
||||||
'support-api-token.revoked',
|
|
||||||
$record,
|
|
||||||
['fields' => ['name', 'abilities', 'expires_at']],
|
|
||||||
actor: $this->getUser(),
|
|
||||||
source: static::class
|
|
||||||
);
|
|
||||||
|
|
||||||
$record->delete();
|
|
||||||
|
|
||||||
Notification::make()
|
|
||||||
->success()
|
|
||||||
->title('Token revoked')
|
|
||||||
->send();
|
|
||||||
}),
|
|
||||||
])
|
|
||||||
->emptyStateHeading('No support API tokens')
|
|
||||||
->emptyStateDescription('Create a token for external support tooling.');
|
|
||||||
}
|
|
||||||
|
|
||||||
private function getTokenQuery(): Builder
|
|
||||||
{
|
|
||||||
$user = $this->getUser();
|
|
||||||
|
|
||||||
if (! $user) {
|
|
||||||
return PersonalAccessToken::query()->whereRaw('1 = 0');
|
|
||||||
}
|
|
||||||
|
|
||||||
return PersonalAccessToken::query()
|
|
||||||
->where('tokenable_id', $user->getKey())
|
|
||||||
->where('tokenable_type', $user->getMorphClass());
|
|
||||||
}
|
|
||||||
|
|
||||||
private function getUser(): ?User
|
|
||||||
{
|
|
||||||
$user = Filament::auth()->user();
|
|
||||||
|
|
||||||
return $user instanceof User ? $user : null;
|
|
||||||
}
|
|
||||||
|
|
||||||
private function formatAbilities(mixed $state): string
|
|
||||||
{
|
|
||||||
if (is_array($state)) {
|
|
||||||
return implode(', ', $state);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (is_string($state)) {
|
|
||||||
return $state;
|
|
||||||
}
|
|
||||||
|
|
||||||
return '';
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @return array<int, string>
|
|
||||||
*/
|
|
||||||
private function defaultAbilities(): array
|
|
||||||
{
|
|
||||||
$abilities = config('support-api.token.default_abilities', []);
|
|
||||||
|
|
||||||
if (! is_array($abilities)) {
|
|
||||||
return ['support-admin'];
|
|
||||||
}
|
|
||||||
|
|
||||||
$abilities = array_values(array_filter($abilities, fn ($ability) => is_string($ability) && $ability !== ''));
|
|
||||||
|
|
||||||
if (! in_array('support-admin', $abilities, true)) {
|
|
||||||
$abilities[] = 'support-admin';
|
|
||||||
}
|
|
||||||
|
|
||||||
return array_values(array_unique($abilities));
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @return array<string, string>
|
|
||||||
*/
|
|
||||||
private function abilityOptions(): array
|
|
||||||
{
|
|
||||||
$options = [];
|
|
||||||
|
|
||||||
foreach ($this->defaultAbilities() as $ability) {
|
|
||||||
$options[$ability] = $ability;
|
|
||||||
}
|
|
||||||
|
|
||||||
return $options;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @param array<int, string> $abilities
|
|
||||||
* @return array<int, string>
|
|
||||||
*/
|
|
||||||
private function normalizeAbilities(array $abilities): array
|
|
||||||
{
|
|
||||||
$allowed = $this->defaultAbilities();
|
|
||||||
$filtered = array_values(array_intersect($abilities, $allowed));
|
|
||||||
|
|
||||||
if (! in_array('support-admin', $filtered, true)) {
|
|
||||||
$filtered[] = 'support-admin';
|
|
||||||
}
|
|
||||||
|
|
||||||
sort($filtered);
|
|
||||||
|
|
||||||
return $filtered;
|
|
||||||
}
|
|
||||||
|
|
||||||
private function defaultTokenName(): string
|
|
||||||
{
|
|
||||||
$name = config('support-api.token.name');
|
|
||||||
|
|
||||||
if (is_string($name) && $name !== '') {
|
|
||||||
return $name;
|
|
||||||
}
|
|
||||||
|
|
||||||
return 'support-api';
|
|
||||||
}
|
|
||||||
|
|
||||||
private function normalizeTokenName(?string $name): string
|
|
||||||
{
|
|
||||||
$name = $name ? trim($name) : '';
|
|
||||||
|
|
||||||
return $name !== '' ? $name : $this->defaultTokenName();
|
|
||||||
}
|
|
||||||
|
|
||||||
private function normalizeExpiresAt(mixed $expiresAt): ?Carbon
|
|
||||||
{
|
|
||||||
if ($expiresAt instanceof Carbon) {
|
|
||||||
return $expiresAt;
|
|
||||||
}
|
|
||||||
|
|
||||||
if ($expiresAt instanceof \DateTimeInterface) {
|
|
||||||
return Carbon::instance($expiresAt);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (is_string($expiresAt) && $expiresAt !== '') {
|
|
||||||
return Carbon::parse($expiresAt);
|
|
||||||
}
|
|
||||||
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
private function recordTokenCreated(NewAccessToken $token, array $abilities, User $user): void
|
|
||||||
{
|
|
||||||
$actionLog = app(SuperAdminAuditLogger::class);
|
|
||||||
|
|
||||||
$actionLog->record(
|
|
||||||
'support-api-token.created',
|
|
||||||
$token->accessToken,
|
|
||||||
[
|
|
||||||
'fields' => ['name', 'abilities', 'expires_at'],
|
|
||||||
'abilities' => $abilities,
|
|
||||||
],
|
|
||||||
actor: $user,
|
|
||||||
source: static::class
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
private function ownsToken(PersonalAccessToken $token): bool
|
|
||||||
{
|
|
||||||
$user = $this->getUser();
|
|
||||||
|
|
||||||
if (! $user) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
return (int) $token->tokenable_id === (int) $user->getKey()
|
|
||||||
&& $token->tokenable_type === $user->getMorphClass();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -14,88 +14,11 @@ class DokployPlatformHealth extends Widget
|
|||||||
|
|
||||||
protected function getViewData(): array
|
protected function getViewData(): array
|
||||||
{
|
{
|
||||||
$projects = $this->loadProjects();
|
|
||||||
|
|
||||||
return [
|
return [
|
||||||
'projects' => $projects,
|
'composes' => $this->loadComposes(),
|
||||||
'composes' => empty($projects) ? $this->loadComposes() : [],
|
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
protected function loadProjects(): array
|
|
||||||
{
|
|
||||||
$client = app(DokployClient::class);
|
|
||||||
$projectMap = config('dokploy.projects', []);
|
|
||||||
$results = [];
|
|
||||||
|
|
||||||
if (empty($projectMap)) {
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
|
|
||||||
foreach ($projectMap as $label => $projectId) {
|
|
||||||
$project = [];
|
|
||||||
$projectIdString = (string) $projectId;
|
|
||||||
|
|
||||||
try {
|
|
||||||
$project = $client->project($projectIdString);
|
|
||||||
} catch (\Throwable $exception) {
|
|
||||||
$project = [];
|
|
||||||
}
|
|
||||||
|
|
||||||
if (empty($project)) {
|
|
||||||
$project = $client->findProject($projectIdString) ?? [];
|
|
||||||
|
|
||||||
$resolvedProjectId = Arr::get($project, 'projectId');
|
|
||||||
|
|
||||||
if ($resolvedProjectId) {
|
|
||||||
try {
|
|
||||||
$project = $client->project((string) $resolvedProjectId);
|
|
||||||
} catch (\Throwable $exception) {
|
|
||||||
$project = $project;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (! $project) {
|
|
||||||
$results[] = [
|
|
||||||
'label' => ucfirst((string) $label),
|
|
||||||
'project_id' => $projectIdString,
|
|
||||||
'name' => $projectIdString,
|
|
||||||
'status' => 'unreachable',
|
|
||||||
'error' => "Project {$projectIdString} not found.",
|
|
||||||
'applications' => [],
|
|
||||||
'services' => [],
|
|
||||||
'composes' => [],
|
|
||||||
'updated_at' => null,
|
|
||||||
];
|
|
||||||
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
$environments = $this->extractEnvironments($project);
|
|
||||||
$applications = $this->formatEnvironmentApplications($environments, $client);
|
|
||||||
$composes = $this->formatEnvironmentComposes($environments, $client);
|
|
||||||
$services = $this->formatEnvironmentServices($environments);
|
|
||||||
|
|
||||||
$results[] = [
|
|
||||||
'label' => ucfirst((string) $label),
|
|
||||||
'project_id' => Arr::get($project, 'projectId', $projectIdString),
|
|
||||||
'name' => Arr::get($project, 'name') ?? Arr::get($project, 'projectName') ?? $projectIdString,
|
|
||||||
'description' => Arr::get($project, 'description'),
|
|
||||||
'status' => $this->deriveProjectStatus($applications, $services, $composes),
|
|
||||||
'applications' => $applications,
|
|
||||||
'composes' => $composes,
|
|
||||||
'services' => $services,
|
|
||||||
'updated_at' => Arr::get($project, 'updatedAt') ?? Arr::get($project, 'createdAt'),
|
|
||||||
'applications_count' => count($applications),
|
|
||||||
'composes_count' => count($composes),
|
|
||||||
'services_count' => count($services),
|
|
||||||
];
|
|
||||||
}
|
|
||||||
|
|
||||||
return $results;
|
|
||||||
}
|
|
||||||
|
|
||||||
protected function loadComposes(): array
|
protected function loadComposes(): array
|
||||||
{
|
{
|
||||||
$client = app(DokployClient::class);
|
$client = app(DokployClient::class);
|
||||||
@@ -139,7 +62,7 @@ class DokployPlatformHealth extends Widget
|
|||||||
'label' => 'Dokploy',
|
'label' => 'Dokploy',
|
||||||
'compose_id' => '-',
|
'compose_id' => '-',
|
||||||
'status' => 'unconfigured',
|
'status' => 'unconfigured',
|
||||||
'error' => 'Set DOKPLOY_PROJECT_IDS or DOKPLOY_COMPOSE_IDS in .env to enable monitoring.',
|
'error' => 'Set DOKPLOY_COMPOSE_IDS in .env to enable monitoring.',
|
||||||
],
|
],
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
@@ -147,252 +70,6 @@ class DokployPlatformHealth extends Widget
|
|||||||
return $results;
|
return $results;
|
||||||
}
|
}
|
||||||
|
|
||||||
protected function extractEnvironments(array $project): array
|
|
||||||
{
|
|
||||||
$environments = Arr::get($project, 'environments', []);
|
|
||||||
|
|
||||||
if (is_array($environments) && ! empty($environments)) {
|
|
||||||
return $environments;
|
|
||||||
}
|
|
||||||
|
|
||||||
return [[
|
|
||||||
'name' => Arr::get($project, 'name'),
|
|
||||||
'applications' => Arr::get($project, 'applications', []),
|
|
||||||
'compose' => Arr::get($project, 'compose', []),
|
|
||||||
'mysql' => Arr::get($project, 'mysql', []),
|
|
||||||
'postgres' => Arr::get($project, 'postgres', []),
|
|
||||||
'mariadb' => Arr::get($project, 'mariadb', []),
|
|
||||||
'mongo' => Arr::get($project, 'mongo', []),
|
|
||||||
'redis' => Arr::get($project, 'redis', []),
|
|
||||||
]];
|
|
||||||
}
|
|
||||||
|
|
||||||
protected function formatEnvironmentApplications(array $environments, DokployClient $client): array
|
|
||||||
{
|
|
||||||
return collect($environments)
|
|
||||||
->flatMap(function (array $environment) use ($client) {
|
|
||||||
$applications = Arr::get($environment, 'applications', []);
|
|
||||||
$environmentName = Arr::get($environment, 'name');
|
|
||||||
|
|
||||||
return $this->formatApplications(is_array($applications) ? $applications : [], $client, $environmentName);
|
|
||||||
})
|
|
||||||
->values()
|
|
||||||
->all();
|
|
||||||
}
|
|
||||||
|
|
||||||
protected function formatEnvironmentComposes(array $environments, DokployClient $client): array
|
|
||||||
{
|
|
||||||
return collect($environments)
|
|
||||||
->flatMap(function (array $environment) use ($client) {
|
|
||||||
$composes = Arr::get($environment, 'compose', []);
|
|
||||||
$environmentName = Arr::get($environment, 'name');
|
|
||||||
|
|
||||||
return collect(is_array($composes) ? $composes : [])
|
|
||||||
->map(function (array $compose) use ($client, $environmentName) {
|
|
||||||
$composeId = Arr::get($compose, 'composeId') ?? Arr::get($compose, 'id');
|
|
||||||
$statusPayload = [];
|
|
||||||
$deployments = [];
|
|
||||||
|
|
||||||
if ($composeId) {
|
|
||||||
try {
|
|
||||||
$statusPayload = $client->composeStatus($composeId);
|
|
||||||
$deployments = $client->composeDeployments($composeId, 1);
|
|
||||||
} catch (\Throwable $exception) {
|
|
||||||
$statusPayload = [];
|
|
||||||
$deployments = [];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
$composeDetails = Arr::get($statusPayload, 'compose', []);
|
|
||||||
|
|
||||||
return [
|
|
||||||
'id' => $composeId,
|
|
||||||
'name' => Arr::get($compose, 'name')
|
|
||||||
?? Arr::get($compose, 'appName')
|
|
||||||
?? Arr::get($composeDetails, 'name')
|
|
||||||
?? Arr::get($composeDetails, 'appName')
|
|
||||||
?? $composeId,
|
|
||||||
'status' => Arr::get($compose, 'composeStatus')
|
|
||||||
?? Arr::get($compose, 'status')
|
|
||||||
?? Arr::get($composeDetails, 'composeStatus')
|
|
||||||
?? Arr::get($composeDetails, 'status')
|
|
||||||
?? 'unknown',
|
|
||||||
'environment' => $environmentName,
|
|
||||||
'last_deploy' => Arr::get($deployments, '0.createdAt')
|
|
||||||
?? Arr::get($deployments, '0.created_at')
|
|
||||||
?? Arr::get($compose, 'updatedAt')
|
|
||||||
?? Arr::get($composeDetails, 'updatedAt'),
|
|
||||||
'services' => $this->formatServices(Arr::get($statusPayload, 'services', [])),
|
|
||||||
];
|
|
||||||
})
|
|
||||||
->filter(fn (array $compose) => filled($compose['name']))
|
|
||||||
->values()
|
|
||||||
->all();
|
|
||||||
})
|
|
||||||
->values()
|
|
||||||
->all();
|
|
||||||
}
|
|
||||||
|
|
||||||
protected function formatEnvironmentServices(array $environments): array
|
|
||||||
{
|
|
||||||
return collect($environments)
|
|
||||||
->flatMap(function (array $environment) {
|
|
||||||
$environmentName = Arr::get($environment, 'name');
|
|
||||||
|
|
||||||
return collect([
|
|
||||||
...$this->normalizeServiceList((array) Arr::get($environment, 'compose', []), 'compose', 'composeId', 'composeStatus', $environmentName),
|
|
||||||
...$this->normalizeServiceList((array) Arr::get($environment, 'mysql', []), 'mysql', 'mysqlId', 'applicationStatus', $environmentName),
|
|
||||||
...$this->normalizeServiceList((array) Arr::get($environment, 'postgres', []), 'postgres', 'postgresId', 'applicationStatus', $environmentName),
|
|
||||||
...$this->normalizeServiceList((array) Arr::get($environment, 'mariadb', []), 'mariadb', 'mariadbId', 'applicationStatus', $environmentName),
|
|
||||||
...$this->normalizeServiceList((array) Arr::get($environment, 'mongo', []), 'mongo', 'mongoId', 'applicationStatus', $environmentName),
|
|
||||||
...$this->normalizeServiceList((array) Arr::get($environment, 'redis', []), 'redis', 'redisId', 'applicationStatus', $environmentName),
|
|
||||||
]);
|
|
||||||
})
|
|
||||||
->filter(fn (array $service) => filled($service['name']))
|
|
||||||
->values()
|
|
||||||
->all();
|
|
||||||
}
|
|
||||||
|
|
||||||
protected function formatApplications(array $applications, DokployClient $client, ?string $environment = null): array
|
|
||||||
{
|
|
||||||
return collect($applications)
|
|
||||||
->map(function (array $application) use ($client, $environment) {
|
|
||||||
$applicationId = $this->extractApplicationId($application);
|
|
||||||
$statusPayload = [];
|
|
||||||
|
|
||||||
if ($applicationId) {
|
|
||||||
try {
|
|
||||||
$statusPayload = $client->applicationStatus($applicationId);
|
|
||||||
} catch (\Throwable $exception) {
|
|
||||||
$statusPayload = [];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
$applicationDetails = Arr::get($statusPayload, 'application', []);
|
|
||||||
$monitoring = Arr::get($statusPayload, 'monitoring', []);
|
|
||||||
|
|
||||||
$status = Arr::get($application, 'applicationStatus')
|
|
||||||
?? Arr::get($application, 'status')
|
|
||||||
?? Arr::get($applicationDetails, 'applicationStatus')
|
|
||||||
?? Arr::get($applicationDetails, 'status')
|
|
||||||
?? 'unknown';
|
|
||||||
|
|
||||||
return [
|
|
||||||
'id' => $applicationId ?? Arr::get($application, 'id'),
|
|
||||||
'name' => Arr::get($application, 'name')
|
|
||||||
?? Arr::get($application, 'appName')
|
|
||||||
?? Arr::get($applicationDetails, 'name')
|
|
||||||
?? Arr::get($applicationDetails, 'appName')
|
|
||||||
?? $applicationId,
|
|
||||||
'status' => $status,
|
|
||||||
'repository' => Arr::get($application, 'repository')
|
|
||||||
?? Arr::get($applicationDetails, 'repository')
|
|
||||||
?? Arr::get($application, 'repo')
|
|
||||||
?? Arr::get($applicationDetails, 'repo'),
|
|
||||||
'branch' => Arr::get($application, 'branch')
|
|
||||||
?? Arr::get($applicationDetails, 'branch')
|
|
||||||
?? Arr::get($application, 'gitBranch')
|
|
||||||
?? Arr::get($applicationDetails, 'gitBranch'),
|
|
||||||
'url' => Arr::get($application, 'url')
|
|
||||||
?? Arr::get($applicationDetails, 'url')
|
|
||||||
?? Arr::get($application, 'domain')
|
|
||||||
?? Arr::get($applicationDetails, 'domain'),
|
|
||||||
'server' => Arr::get($application, 'serverName')
|
|
||||||
?? Arr::get($applicationDetails, 'serverName')
|
|
||||||
?? Arr::get($application, 'server'),
|
|
||||||
'environment' => $environment,
|
|
||||||
'last_deploy' => Arr::get($application, 'lastDeploymentAt')
|
|
||||||
?? Arr::get($applicationDetails, 'lastDeploymentAt')
|
|
||||||
?? Arr::get($application, 'updatedAt')
|
|
||||||
?? Arr::get($applicationDetails, 'updatedAt')
|
|
||||||
?? Arr::get($application, 'createdAt'),
|
|
||||||
'monitoring' => $this->formatMonitoring($monitoring),
|
|
||||||
];
|
|
||||||
})
|
|
||||||
->filter(fn (array $application) => filled($application['name']))
|
|
||||||
->values()
|
|
||||||
->all();
|
|
||||||
}
|
|
||||||
|
|
||||||
protected function extractApplicationId(array $application): ?string
|
|
||||||
{
|
|
||||||
return Arr::get($application, 'applicationId')
|
|
||||||
?? Arr::get($application, 'appId')
|
|
||||||
?? Arr::get($application, 'id');
|
|
||||||
}
|
|
||||||
|
|
||||||
protected function normalizeServiceList(array $services, string $type, string $idKey, string $statusKey, ?string $environment = null): array
|
|
||||||
{
|
|
||||||
return collect($services)
|
|
||||||
->map(function (array $service) use ($type, $idKey, $statusKey, $environment) {
|
|
||||||
return [
|
|
||||||
'type' => $type,
|
|
||||||
'id' => Arr::get($service, $idKey) ?? Arr::get($service, 'id'),
|
|
||||||
'name' => Arr::get($service, 'name') ?? Arr::get($service, 'appName') ?? Arr::get($service, 'serviceName'),
|
|
||||||
'status' => Arr::get($service, $statusKey) ?? Arr::get($service, 'status') ?? Arr::get($service, 'composeStatus', 'unknown'),
|
|
||||||
'version' => Arr::get($service, 'dockerImage') ?? Arr::get($service, 'image'),
|
|
||||||
'external_port' => Arr::get($service, 'externalPort'),
|
|
||||||
'environment' => $environment,
|
|
||||||
];
|
|
||||||
})
|
|
||||||
->values()
|
|
||||||
->all();
|
|
||||||
}
|
|
||||||
|
|
||||||
protected function formatMonitoring(array $monitoring): array
|
|
||||||
{
|
|
||||||
$metrics = [];
|
|
||||||
$allowed = [
|
|
||||||
'cpuPercent' => 'CPU',
|
|
||||||
'cpu' => 'CPU',
|
|
||||||
'memoryPercent' => 'Memory',
|
|
||||||
'memory' => 'Memory',
|
|
||||||
'uptime' => 'Uptime',
|
|
||||||
];
|
|
||||||
|
|
||||||
foreach ($allowed as $key => $label) {
|
|
||||||
$value = Arr::get($monitoring, $key);
|
|
||||||
|
|
||||||
if (filled($value) && ! is_array($value)) {
|
|
||||||
$metrics[] = [
|
|
||||||
'label' => $label,
|
|
||||||
'value' => $value,
|
|
||||||
];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return $metrics;
|
|
||||||
}
|
|
||||||
|
|
||||||
protected function deriveProjectStatus(array $applications, array $services, array $composes): string
|
|
||||||
{
|
|
||||||
$statuses = collect($applications)
|
|
||||||
->pluck('status')
|
|
||||||
->merge(collect($services)->pluck('status'))
|
|
||||||
->merge(collect($composes)->pluck('status'))
|
|
||||||
->filter()
|
|
||||||
->map(fn ($status) => strtolower((string) $status))
|
|
||||||
->values();
|
|
||||||
|
|
||||||
if ($statuses->contains(fn ($status) => in_array($status, ['error', 'failed', 'unreachable', 'unhealthy'], true))) {
|
|
||||||
return 'error';
|
|
||||||
}
|
|
||||||
|
|
||||||
if ($statuses->contains(fn ($status) => in_array($status, ['deploying', 'pending', 'starting'], true))) {
|
|
||||||
return 'deploying';
|
|
||||||
}
|
|
||||||
|
|
||||||
if ($statuses->contains(fn ($status) => in_array($status, ['stopped', 'inactive', 'paused'], true))) {
|
|
||||||
return 'warning';
|
|
||||||
}
|
|
||||||
|
|
||||||
if ($statuses->contains(fn ($status) => in_array($status, ['done', 'running', 'healthy'], true))) {
|
|
||||||
return 'done';
|
|
||||||
}
|
|
||||||
|
|
||||||
return 'unknown';
|
|
||||||
}
|
|
||||||
|
|
||||||
protected function formatServices(array $services): array
|
protected function formatServices(array $services): array
|
||||||
{
|
{
|
||||||
return collect($services)
|
return collect($services)
|
||||||
|
|||||||
@@ -4,8 +4,8 @@ namespace App\Filament\Widgets;
|
|||||||
|
|
||||||
use Filament\Widgets\StatsOverviewWidget as BaseWidget;
|
use Filament\Widgets\StatsOverviewWidget as BaseWidget;
|
||||||
use Filament\Widgets\StatsOverviewWidget\Stat;
|
use Filament\Widgets\StatsOverviewWidget\Stat;
|
||||||
use Illuminate\Support\Carbon;
|
|
||||||
use Illuminate\Support\Facades\DB;
|
use Illuminate\Support\Facades\DB;
|
||||||
|
use Illuminate\Support\Carbon;
|
||||||
|
|
||||||
class PlatformStatsWidget extends BaseWidget
|
class PlatformStatsWidget extends BaseWidget
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -12,8 +12,6 @@ class QueueHealthWidget extends Widget
|
|||||||
|
|
||||||
protected ?string $pollingInterval = '60s';
|
protected ?string $pollingInterval = '60s';
|
||||||
|
|
||||||
protected int|string|array $columnSpan = 'full';
|
|
||||||
|
|
||||||
protected function getViewData(): array
|
protected function getViewData(): array
|
||||||
{
|
{
|
||||||
$snapshot = Cache::get('storage:queue-health:last');
|
$snapshot = Cache::get('storage:queue-health:last');
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ use Filament\Widgets\LineChartWidget;
|
|||||||
|
|
||||||
class RevenueTrendWidget extends LineChartWidget
|
class RevenueTrendWidget extends LineChartWidget
|
||||||
{
|
{
|
||||||
|
|
||||||
protected static ?int $sort = 1;
|
protected static ?int $sort = 1;
|
||||||
|
|
||||||
protected int|string|array $columnSpan = 'full';
|
protected int|string|array $columnSpan = 'full';
|
||||||
|
|||||||
@@ -2,9 +2,9 @@
|
|||||||
|
|
||||||
namespace App\Filament\Widgets;
|
namespace App\Filament\Widgets;
|
||||||
|
|
||||||
use App\Models\Tenant;
|
|
||||||
use Filament\Tables;
|
use Filament\Tables;
|
||||||
use Filament\Widgets\TableWidget as BaseWidget;
|
use Filament\Widgets\TableWidget as BaseWidget;
|
||||||
|
use App\Models\Tenant;
|
||||||
|
|
||||||
class TopTenantsByUploads extends BaseWidget
|
class TopTenantsByUploads extends BaseWidget
|
||||||
{
|
{
|
||||||
@@ -14,7 +14,6 @@ class TopTenantsByUploads extends BaseWidget
|
|||||||
{
|
{
|
||||||
return __('admin.widgets.top_tenants_by_uploads.heading');
|
return __('admin.widgets.top_tenants_by_uploads.heading');
|
||||||
}
|
}
|
||||||
|
|
||||||
protected ?string $pollingInterval = '60s';
|
protected ?string $pollingInterval = '60s';
|
||||||
|
|
||||||
public function table(Tables\Table $table): Tables\Table
|
public function table(Tables\Table $table): Tables\Table
|
||||||
@@ -34,3 +33,4 @@ class TopTenantsByUploads extends BaseWidget
|
|||||||
->paginated(false);
|
->paginated(false);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -2,8 +2,8 @@
|
|||||||
|
|
||||||
namespace App\Http\Controllers\Admin;
|
namespace App\Http\Controllers\Admin;
|
||||||
|
|
||||||
use Illuminate\Http\Request;
|
|
||||||
use Illuminate\Routing\Controller as BaseController;
|
use Illuminate\Routing\Controller as BaseController;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
use SimpleSoftwareIO\QrCode\Facades\QrCode;
|
use SimpleSoftwareIO\QrCode\Facades\QrCode;
|
||||||
|
|
||||||
class QrController extends BaseController
|
class QrController extends BaseController
|
||||||
@@ -15,7 +15,7 @@ class QrController extends BaseController
|
|||||||
return response('missing data', 400);
|
return response('missing data', 400);
|
||||||
}
|
}
|
||||||
$png = QrCode::format('png')->size(300)->generate($data);
|
$png = QrCode::format('png')->size(300)->generate($data);
|
||||||
|
|
||||||
return response($png, 200, ['Content-Type' => 'image/png']);
|
return response($png, 200, ['Content-Type' => 'image/png']);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,556 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
namespace App\Http\Controllers\Api;
|
|
||||||
|
|
||||||
use App\Http\Requests\Api\GuestAiEditStoreRequest;
|
|
||||||
use App\Jobs\ProcessAiEditRequest;
|
|
||||||
use App\Models\AiEditRequest;
|
|
||||||
use App\Models\AiStyle;
|
|
||||||
use App\Models\Event;
|
|
||||||
use App\Models\Photo;
|
|
||||||
use App\Services\AiEditing\AiBudgetGuardService;
|
|
||||||
use App\Services\AiEditing\AiEditingRuntimeConfig;
|
|
||||||
use App\Services\AiEditing\AiStyleAccessService;
|
|
||||||
use App\Services\AiEditing\AiStylingEntitlementService;
|
|
||||||
use App\Services\AiEditing\EventAiEditingPolicyService;
|
|
||||||
use App\Services\AiEditing\Safety\AiAbuseEscalationService;
|
|
||||||
use App\Services\AiEditing\Safety\AiSafetyPolicyService;
|
|
||||||
use App\Services\EventJoinTokenService;
|
|
||||||
use App\Support\ApiError;
|
|
||||||
use Illuminate\Http\JsonResponse;
|
|
||||||
use Illuminate\Http\Request;
|
|
||||||
use Illuminate\Routing\Controller as BaseController;
|
|
||||||
use Illuminate\Support\Arr;
|
|
||||||
use Illuminate\Support\Facades\Log;
|
|
||||||
use Illuminate\Support\Facades\Storage;
|
|
||||||
use Illuminate\Support\Str;
|
|
||||||
use Symfony\Component\HttpFoundation\Response;
|
|
||||||
|
|
||||||
class EventPublicAiEditController extends BaseController
|
|
||||||
{
|
|
||||||
public function __construct(
|
|
||||||
private readonly EventJoinTokenService $joinTokenService,
|
|
||||||
private readonly AiSafetyPolicyService $safetyPolicy,
|
|
||||||
private readonly AiEditingRuntimeConfig $runtimeConfig,
|
|
||||||
private readonly AiBudgetGuardService $budgetGuard,
|
|
||||||
private readonly AiStylingEntitlementService $entitlements,
|
|
||||||
private readonly EventAiEditingPolicyService $eventPolicy,
|
|
||||||
private readonly AiStyleAccessService $styleAccess,
|
|
||||||
private readonly AiAbuseEscalationService $abuseEscalation,
|
|
||||||
) {}
|
|
||||||
|
|
||||||
public function store(GuestAiEditStoreRequest $request, string $token, int $photo): JsonResponse
|
|
||||||
{
|
|
||||||
$event = $this->resolvePublishedEvent($token);
|
|
||||||
if ($event instanceof JsonResponse) {
|
|
||||||
return $event;
|
|
||||||
}
|
|
||||||
|
|
||||||
$photoModel = Photo::query()
|
|
||||||
->whereKey($photo)
|
|
||||||
->where('event_id', $event->id)
|
|
||||||
->first();
|
|
||||||
|
|
||||||
if (! $photoModel) {
|
|
||||||
return ApiError::response(
|
|
||||||
'photo_not_found',
|
|
||||||
'Photo not found',
|
|
||||||
'The specified photo could not be located for this event.',
|
|
||||||
Response::HTTP_NOT_FOUND
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if ($photoModel->status !== 'approved') {
|
|
||||||
return ApiError::response(
|
|
||||||
'photo_not_eligible',
|
|
||||||
'Photo not eligible',
|
|
||||||
'Only approved photos can be used for AI edits.',
|
|
||||||
Response::HTTP_UNPROCESSABLE_ENTITY
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (! $this->runtimeConfig->isEnabled()) {
|
|
||||||
return ApiError::response(
|
|
||||||
'feature_disabled',
|
|
||||||
'Feature disabled',
|
|
||||||
$this->runtimeConfig->statusMessage() ?: 'AI editing is currently disabled by platform policy.',
|
|
||||||
Response::HTTP_FORBIDDEN
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
$entitlement = $this->entitlements->resolveForEvent($event);
|
|
||||||
if (! $entitlement['allowed']) {
|
|
||||||
return ApiError::response(
|
|
||||||
'feature_locked',
|
|
||||||
'Feature locked',
|
|
||||||
$this->entitlements->lockedMessage(),
|
|
||||||
Response::HTTP_FORBIDDEN,
|
|
||||||
[
|
|
||||||
'required_feature' => $entitlement['required_feature'],
|
|
||||||
'addon_keys' => $entitlement['addon_keys'],
|
|
||||||
]
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
$policy = $this->eventPolicy->resolve($event);
|
|
||||||
if (! $policy['enabled']) {
|
|
||||||
return ApiError::response(
|
|
||||||
'event_feature_disabled',
|
|
||||||
'Feature disabled for this event',
|
|
||||||
$policy['policy_message'] ?? 'AI editing is disabled for this event.',
|
|
||||||
Response::HTTP_FORBIDDEN
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
$budgetDecision = $this->budgetGuard->evaluateForEvent($event);
|
|
||||||
if (! $budgetDecision['allowed']) {
|
|
||||||
return ApiError::response(
|
|
||||||
$budgetDecision['reason_code'] ?? 'budget_hard_cap_reached',
|
|
||||||
'Budget limit reached',
|
|
||||||
$budgetDecision['message'] ?? 'The AI editing budget for this billing period has been exhausted.',
|
|
||||||
Response::HTTP_FORBIDDEN,
|
|
||||||
[
|
|
||||||
'budget' => $budgetDecision['budget'],
|
|
||||||
]
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
$style = $this->resolveStyleByKey($request->input('style_key'));
|
|
||||||
if ($request->filled('style_key') && ! $style) {
|
|
||||||
return ApiError::response(
|
|
||||||
'style_not_found',
|
|
||||||
'Style not found',
|
|
||||||
'The selected style is not available.',
|
|
||||||
Response::HTTP_UNPROCESSABLE_ENTITY
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (
|
|
||||||
$style
|
|
||||||
&& (! $this->eventPolicy->isStyleAllowed($event, $style) || ! $this->styleAccess->canUseStyle($event, $style))
|
|
||||||
) {
|
|
||||||
return ApiError::response(
|
|
||||||
'style_not_allowed',
|
|
||||||
'Style not allowed',
|
|
||||||
$policy['policy_message'] ?? 'This style is not allowed for this event.',
|
|
||||||
Response::HTTP_UNPROCESSABLE_ENTITY,
|
|
||||||
[
|
|
||||||
'allowed_style_keys' => $policy['allowed_style_keys'],
|
|
||||||
]
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
$prompt = (string) ($request->input('prompt') ?: $style?->prompt_template ?: '');
|
|
||||||
$negativePrompt = (string) ($request->input('negative_prompt') ?: $style?->negative_prompt_template ?: '');
|
|
||||||
$providerModel = $request->input('provider_model') ?: $style?->provider_model;
|
|
||||||
$safetyDecision = $this->safetyPolicy->evaluatePrompt($prompt, $negativePrompt);
|
|
||||||
$deviceId = $this->normalizeOptionalString((string) $request->header('X-Device-Id', ''));
|
|
||||||
$sessionId = $this->normalizeOptionalString((string) $request->input('session_id', ''));
|
|
||||||
$scopeKey = $this->normalizeOptionalString($deviceId ?: $sessionId) ?: 'guest';
|
|
||||||
$abuseSignal = null;
|
|
||||||
$safetyReasons = $safetyDecision->reasonCodes;
|
|
||||||
if ($safetyDecision->blocked) {
|
|
||||||
$abuseSignal = $this->abuseEscalation->recordPromptBlock(
|
|
||||||
(int) $event->tenant_id,
|
|
||||||
(int) $event->id,
|
|
||||||
$scopeKey
|
|
||||||
);
|
|
||||||
if (($abuseSignal['escalated'] ?? false) && ! in_array(AiAbuseEscalationService::REASON_CODE, $safetyReasons, true)) {
|
|
||||||
$safetyReasons[] = AiAbuseEscalationService::REASON_CODE;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
$metadata = (array) $request->input('metadata', []);
|
|
||||||
$styleMetadata = is_array($style?->metadata) ? $style->metadata : [];
|
|
||||||
$styleRunwareMetadata = Arr::get($styleMetadata, 'runware');
|
|
||||||
if (is_array($styleRunwareMetadata)) {
|
|
||||||
$metadata['runware'] = $styleRunwareMetadata;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (is_array($abuseSignal)) {
|
|
||||||
$metadata['abuse'] = $abuseSignal;
|
|
||||||
}
|
|
||||||
$metadata['budget'] = $budgetDecision['budget'];
|
|
||||||
|
|
||||||
$idempotencyKey = $this->resolveIdempotencyKey(
|
|
||||||
$request->input('idempotency_key'),
|
|
||||||
$request->header('X-Idempotency-Key'),
|
|
||||||
$photoModel,
|
|
||||||
$style,
|
|
||||||
$prompt,
|
|
||||||
$deviceId,
|
|
||||||
$sessionId
|
|
||||||
);
|
|
||||||
|
|
||||||
$attributes = [
|
|
||||||
'event_id' => $event->id,
|
|
||||||
'photo_id' => $photoModel->id,
|
|
||||||
'style_id' => $style?->id,
|
|
||||||
'provider' => $this->runtimeConfig->defaultProvider(),
|
|
||||||
'provider_model' => $providerModel,
|
|
||||||
'status' => $safetyDecision->blocked ? AiEditRequest::STATUS_BLOCKED : AiEditRequest::STATUS_QUEUED,
|
|
||||||
'safety_state' => $safetyDecision->state,
|
|
||||||
'prompt' => $prompt,
|
|
||||||
'negative_prompt' => $negativePrompt,
|
|
||||||
'input_image_path' => $photoModel->file_path,
|
|
||||||
'requested_by_device_id' => $deviceId,
|
|
||||||
'requested_by_session_id' => $sessionId,
|
|
||||||
'safety_reasons' => $safetyReasons,
|
|
||||||
'failure_code' => $safetyDecision->failureCode,
|
|
||||||
'failure_message' => $safetyDecision->failureMessage,
|
|
||||||
'queued_at' => now(),
|
|
||||||
'completed_at' => $safetyDecision->blocked ? now() : null,
|
|
||||||
'metadata' => $metadata,
|
|
||||||
];
|
|
||||||
|
|
||||||
$editRequest = AiEditRequest::query()->firstOrCreate(
|
|
||||||
['tenant_id' => $event->tenant_id, 'idempotency_key' => $idempotencyKey],
|
|
||||||
$attributes
|
|
||||||
);
|
|
||||||
|
|
||||||
if (! $editRequest->wasRecentlyCreated && $this->isIdempotencyConflict(
|
|
||||||
$editRequest,
|
|
||||||
$event,
|
|
||||||
$photoModel,
|
|
||||||
$style?->id,
|
|
||||||
$prompt,
|
|
||||||
$negativePrompt,
|
|
||||||
$providerModel,
|
|
||||||
$deviceId,
|
|
||||||
$sessionId
|
|
||||||
)) {
|
|
||||||
return ApiError::response(
|
|
||||||
'idempotency_conflict',
|
|
||||||
'Idempotency conflict',
|
|
||||||
'The provided idempotency key is already in use for another request.',
|
|
||||||
Response::HTTP_CONFLICT
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (
|
|
||||||
$editRequest->wasRecentlyCreated
|
|
||||||
&& ! $safetyDecision->blocked
|
|
||||||
&& $this->runtimeConfig->queueAutoDispatch()
|
|
||||||
) {
|
|
||||||
ProcessAiEditRequest::dispatch($editRequest->id)
|
|
||||||
->onQueue($this->runtimeConfig->queueName());
|
|
||||||
}
|
|
||||||
|
|
||||||
return response()->json([
|
|
||||||
'message' => $editRequest->wasRecentlyCreated ? 'AI edit request queued' : 'AI edit request already exists',
|
|
||||||
'duplicate' => ! $editRequest->wasRecentlyCreated,
|
|
||||||
'data' => $this->serializeRequest($editRequest->fresh(['style', 'outputs'])),
|
|
||||||
], $editRequest->wasRecentlyCreated ? Response::HTTP_CREATED : Response::HTTP_OK);
|
|
||||||
}
|
|
||||||
|
|
||||||
public function show(Request $request, string $token, int $requestId): JsonResponse
|
|
||||||
{
|
|
||||||
$event = $this->resolvePublishedEvent($token);
|
|
||||||
if ($event instanceof JsonResponse) {
|
|
||||||
return $event;
|
|
||||||
}
|
|
||||||
|
|
||||||
$editRequest = AiEditRequest::query()
|
|
||||||
->with(['style', 'outputs'])
|
|
||||||
->whereKey($requestId)
|
|
||||||
->where('event_id', $event->id)
|
|
||||||
->first();
|
|
||||||
|
|
||||||
if (! $editRequest) {
|
|
||||||
return ApiError::response(
|
|
||||||
'edit_request_not_found',
|
|
||||||
'Edit request not found',
|
|
||||||
'The specified AI edit request could not be located for this event.',
|
|
||||||
Response::HTTP_NOT_FOUND
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
$deviceId = $this->normalizeOptionalString((string) $request->header('X-Device-Id', ''));
|
|
||||||
if ($editRequest->requested_by_device_id && $deviceId && $editRequest->requested_by_device_id !== $deviceId) {
|
|
||||||
return ApiError::response(
|
|
||||||
'forbidden_request_scope',
|
|
||||||
'Forbidden',
|
|
||||||
'This AI edit request belongs to another device.',
|
|
||||||
Response::HTTP_FORBIDDEN
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return response()->json([
|
|
||||||
'data' => $this->serializeRequest($editRequest),
|
|
||||||
]);
|
|
||||||
}
|
|
||||||
|
|
||||||
public function styles(Request $request, string $token): JsonResponse
|
|
||||||
{
|
|
||||||
$event = $this->resolvePublishedEvent($token);
|
|
||||||
if ($event instanceof JsonResponse) {
|
|
||||||
return $event;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (! $this->runtimeConfig->isEnabled()) {
|
|
||||||
return ApiError::response(
|
|
||||||
'feature_disabled',
|
|
||||||
'Feature disabled',
|
|
||||||
$this->runtimeConfig->statusMessage() ?: 'AI editing is currently disabled by platform policy.',
|
|
||||||
Response::HTTP_FORBIDDEN
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
$entitlement = $this->entitlements->resolveForEvent($event);
|
|
||||||
if (! $entitlement['allowed']) {
|
|
||||||
return ApiError::response(
|
|
||||||
'feature_locked',
|
|
||||||
'Feature locked',
|
|
||||||
$this->entitlements->lockedMessage(),
|
|
||||||
Response::HTTP_FORBIDDEN,
|
|
||||||
[
|
|
||||||
'required_feature' => $entitlement['required_feature'],
|
|
||||||
'addon_keys' => $entitlement['addon_keys'],
|
|
||||||
]
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
$policy = $this->eventPolicy->resolve($event);
|
|
||||||
if (! $policy['enabled']) {
|
|
||||||
return ApiError::response(
|
|
||||||
'event_feature_disabled',
|
|
||||||
'Feature disabled for this event',
|
|
||||||
$policy['policy_message'] ?? 'AI editing is disabled for this event.',
|
|
||||||
Response::HTTP_FORBIDDEN
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
$styles = $this->eventPolicy->filterStyles(
|
|
||||||
$event,
|
|
||||||
AiStyle::query()
|
|
||||||
->where('is_active', true)
|
|
||||||
->orderBy('sort')
|
|
||||||
->orderBy('id')
|
|
||||||
->get()
|
|
||||||
);
|
|
||||||
$styles = $this->styleAccess->filterStylesForEvent($event, $styles);
|
|
||||||
|
|
||||||
return response()->json([
|
|
||||||
'data' => $styles->map(fn (AiStyle $style) => $this->serializeStyle($style))->values(),
|
|
||||||
'meta' => [
|
|
||||||
'required_feature' => $entitlement['required_feature'],
|
|
||||||
'addon_keys' => $entitlement['addon_keys'],
|
|
||||||
'allow_custom_prompt' => $policy['allow_custom_prompt'],
|
|
||||||
'allowed_style_keys' => $policy['allowed_style_keys'],
|
|
||||||
'policy_message' => $policy['policy_message'],
|
|
||||||
],
|
|
||||||
]);
|
|
||||||
}
|
|
||||||
|
|
||||||
private function resolvePublishedEvent(string $token): Event|JsonResponse
|
|
||||||
{
|
|
||||||
$joinToken = $this->joinTokenService->findActiveToken($token);
|
|
||||||
|
|
||||||
if (! $joinToken) {
|
|
||||||
return ApiError::response(
|
|
||||||
'invalid_token',
|
|
||||||
'Invalid token',
|
|
||||||
'The provided event token is invalid or expired.',
|
|
||||||
Response::HTTP_NOT_FOUND
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
$event = Event::query()
|
|
||||||
->whereKey($joinToken->event_id)
|
|
||||||
->where('status', 'published')
|
|
||||||
->first();
|
|
||||||
|
|
||||||
if (! $event) {
|
|
||||||
return ApiError::response(
|
|
||||||
'event_not_public',
|
|
||||||
'Event not public',
|
|
||||||
'This event is not publicly accessible.',
|
|
||||||
Response::HTTP_FORBIDDEN
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return $event;
|
|
||||||
}
|
|
||||||
|
|
||||||
private function resolveStyleByKey(?string $styleKey): ?AiStyle
|
|
||||||
{
|
|
||||||
$key = $this->normalizeOptionalString((string) ($styleKey ?? ''));
|
|
||||||
if (! $key) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
return AiStyle::query()
|
|
||||||
->where('key', $key)
|
|
||||||
->where('is_active', true)
|
|
||||||
->first();
|
|
||||||
}
|
|
||||||
|
|
||||||
private function normalizeOptionalString(?string $value): ?string
|
|
||||||
{
|
|
||||||
if ($value === null) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
$trimmed = trim($value);
|
|
||||||
|
|
||||||
return $trimmed !== '' ? $trimmed : null;
|
|
||||||
}
|
|
||||||
|
|
||||||
private function resolveIdempotencyKey(
|
|
||||||
mixed $bodyKey,
|
|
||||||
mixed $headerKey,
|
|
||||||
Photo $photo,
|
|
||||||
?AiStyle $style,
|
|
||||||
string $prompt,
|
|
||||||
?string $deviceId,
|
|
||||||
?string $sessionId
|
|
||||||
): string {
|
|
||||||
$candidate = $this->normalizeOptionalString((string) ($bodyKey ?: $headerKey ?: ''));
|
|
||||||
if ($candidate) {
|
|
||||||
return Str::limit($candidate, 120, '');
|
|
||||||
}
|
|
||||||
|
|
||||||
return substr(hash('sha256', implode('|', [
|
|
||||||
(string) $photo->event_id,
|
|
||||||
(string) $photo->id,
|
|
||||||
(string) ($style?->id ?? ''),
|
|
||||||
trim($prompt),
|
|
||||||
(string) ($deviceId ?? ''),
|
|
||||||
(string) ($sessionId ?? ''),
|
|
||||||
])), 0, 120);
|
|
||||||
}
|
|
||||||
|
|
||||||
private function isIdempotencyConflict(
|
|
||||||
AiEditRequest $request,
|
|
||||||
Event $event,
|
|
||||||
Photo $photo,
|
|
||||||
?int $styleId,
|
|
||||||
string $prompt,
|
|
||||||
string $negativePrompt,
|
|
||||||
?string $providerModel,
|
|
||||||
?string $deviceId,
|
|
||||||
?string $sessionId
|
|
||||||
): bool {
|
|
||||||
if ($request->event_id !== $event->id || $request->photo_id !== $photo->id) {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
if ((int) ($request->style_id ?? 0) !== (int) ($styleId ?? 0)) {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
if ($this->normalizeOptionalString($request->prompt) !== $this->normalizeOptionalString($prompt)) {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
if ($this->normalizeOptionalString($request->negative_prompt) !== $this->normalizeOptionalString($negativePrompt)) {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
if ($this->normalizeOptionalString($request->provider_model) !== $this->normalizeOptionalString($providerModel)) {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
if ($this->normalizeOptionalString($request->requested_by_device_id) !== $this->normalizeOptionalString($deviceId)) {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
return $this->normalizeOptionalString($request->requested_by_session_id) !== $this->normalizeOptionalString($sessionId);
|
|
||||||
}
|
|
||||||
|
|
||||||
private function serializeStyle(AiStyle $style): array
|
|
||||||
{
|
|
||||||
return [
|
|
||||||
'id' => $style->id,
|
|
||||||
'key' => $style->key,
|
|
||||||
'name' => $style->name,
|
|
||||||
'version' => $style->version,
|
|
||||||
'category' => $style->category,
|
|
||||||
'description' => $style->description,
|
|
||||||
'provider' => $style->provider,
|
|
||||||
'provider_model' => $style->provider_model,
|
|
||||||
'requires_source_image' => $style->requires_source_image,
|
|
||||||
'is_premium' => $style->is_premium,
|
|
||||||
'metadata' => $style->metadata ?? [],
|
|
||||||
];
|
|
||||||
}
|
|
||||||
|
|
||||||
private function serializeRequest(AiEditRequest $request): array
|
|
||||||
{
|
|
||||||
return [
|
|
||||||
'id' => $request->id,
|
|
||||||
'event_id' => $request->event_id,
|
|
||||||
'photo_id' => $request->photo_id,
|
|
||||||
'style' => $request->style ? [
|
|
||||||
'id' => $request->style->id,
|
|
||||||
'key' => $request->style->key,
|
|
||||||
'name' => $request->style->name,
|
|
||||||
] : null,
|
|
||||||
'provider' => $request->provider,
|
|
||||||
'provider_model' => $request->provider_model,
|
|
||||||
'status' => $request->status,
|
|
||||||
'safety_state' => $request->safety_state,
|
|
||||||
'safety_reasons' => $request->safety_reasons ?? [],
|
|
||||||
'failure_code' => $request->failure_code,
|
|
||||||
'failure_message' => $request->failure_message,
|
|
||||||
'queued_at' => $request->queued_at?->toIso8601String(),
|
|
||||||
'started_at' => $request->started_at?->toIso8601String(),
|
|
||||||
'completed_at' => $request->completed_at?->toIso8601String(),
|
|
||||||
'outputs' => $request->outputs->map(fn ($output) => [
|
|
||||||
'id' => $output->id,
|
|
||||||
'storage_disk' => $output->storage_disk,
|
|
||||||
'storage_path' => $output->storage_path,
|
|
||||||
'provider_url' => $output->provider_url,
|
|
||||||
'url' => $this->resolveOutputUrl(
|
|
||||||
$output->storage_disk,
|
|
||||||
$output->storage_path,
|
|
||||||
$output->provider_url
|
|
||||||
),
|
|
||||||
'mime_type' => $output->mime_type,
|
|
||||||
'width' => $output->width,
|
|
||||||
'height' => $output->height,
|
|
||||||
'is_primary' => $output->is_primary,
|
|
||||||
'safety_state' => $output->safety_state,
|
|
||||||
'safety_reasons' => $output->safety_reasons ?? [],
|
|
||||||
'generated_at' => $output->generated_at?->toIso8601String(),
|
|
||||||
])->values(),
|
|
||||||
];
|
|
||||||
}
|
|
||||||
|
|
||||||
private function resolveOutputUrl(?string $storageDisk, ?string $storagePath, ?string $providerUrl): ?string
|
|
||||||
{
|
|
||||||
$resolvedStoragePath = $this->normalizeOptionalString($storagePath);
|
|
||||||
if ($resolvedStoragePath !== null) {
|
|
||||||
if (Str::startsWith($resolvedStoragePath, ['http://', 'https://'])) {
|
|
||||||
return $resolvedStoragePath;
|
|
||||||
}
|
|
||||||
|
|
||||||
$disk = $this->resolveStorageDisk($storageDisk);
|
|
||||||
|
|
||||||
try {
|
|
||||||
return Storage::disk($disk)->url($resolvedStoragePath);
|
|
||||||
} catch (\Throwable $exception) {
|
|
||||||
Log::debug('Falling back to raw AI output storage path', [
|
|
||||||
'disk' => $disk,
|
|
||||||
'path' => $resolvedStoragePath,
|
|
||||||
'error' => $exception->getMessage(),
|
|
||||||
]);
|
|
||||||
|
|
||||||
return '/'.ltrim($resolvedStoragePath, '/');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return $this->normalizeOptionalString($providerUrl);
|
|
||||||
}
|
|
||||||
|
|
||||||
private function resolveStorageDisk(?string $disk): string
|
|
||||||
{
|
|
||||||
$candidate = trim((string) ($disk ?: config('filesystems.default', 'public')));
|
|
||||||
|
|
||||||
if ($candidate === '' || ! config("filesystems.disks.{$candidate}")) {
|
|
||||||
return (string) config('filesystems.default', 'public');
|
|
||||||
}
|
|
||||||
|
|
||||||
return $candidate;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -16,9 +16,6 @@ use App\Models\GuestNotification;
|
|||||||
use App\Models\GuestPolicySetting;
|
use App\Models\GuestPolicySetting;
|
||||||
use App\Models\Photo;
|
use App\Models\Photo;
|
||||||
use App\Models\PhotoShareLink;
|
use App\Models\PhotoShareLink;
|
||||||
use App\Services\AiEditing\AiEditingRuntimeConfig;
|
|
||||||
use App\Services\AiEditing\AiStylingEntitlementService;
|
|
||||||
use App\Services\AiEditing\EventAiEditingPolicyService;
|
|
||||||
use App\Services\Analytics\JoinTokenAnalyticsRecorder;
|
use App\Services\Analytics\JoinTokenAnalyticsRecorder;
|
||||||
use App\Services\EventJoinTokenService;
|
use App\Services\EventJoinTokenService;
|
||||||
use App\Services\EventTasksCacheService;
|
use App\Services\EventTasksCacheService;
|
||||||
@@ -44,7 +41,6 @@ use Illuminate\Support\Facades\Schema;
|
|||||||
use Illuminate\Support\Facades\Storage;
|
use Illuminate\Support\Facades\Storage;
|
||||||
use Illuminate\Support\Facades\URL;
|
use Illuminate\Support\Facades\URL;
|
||||||
use Illuminate\Support\Str;
|
use Illuminate\Support\Str;
|
||||||
use SimpleSoftwareIO\QrCode\Facades\QrCode;
|
|
||||||
use Symfony\Component\HttpFoundation\Response;
|
use Symfony\Component\HttpFoundation\Response;
|
||||||
|
|
||||||
class EventPublicController extends BaseController
|
class EventPublicController extends BaseController
|
||||||
@@ -53,10 +49,6 @@ class EventPublicController extends BaseController
|
|||||||
|
|
||||||
private const BRANDING_SIGNED_TTL_SECONDS = 3600;
|
private const BRANDING_SIGNED_TTL_SECONDS = 3600;
|
||||||
|
|
||||||
private const PREVIEW_MAX_EDGE = 1920;
|
|
||||||
|
|
||||||
private const PREVIEW_QUALITY = 86;
|
|
||||||
|
|
||||||
private ?GuestPolicySetting $guestPolicy = null;
|
private ?GuestPolicySetting $guestPolicy = null;
|
||||||
|
|
||||||
public function __construct(
|
public function __construct(
|
||||||
@@ -68,9 +60,6 @@ class EventPublicController extends BaseController
|
|||||||
private readonly EventTasksCacheService $eventTasksCache,
|
private readonly EventTasksCacheService $eventTasksCache,
|
||||||
private readonly GuestNotificationService $guestNotificationService,
|
private readonly GuestNotificationService $guestNotificationService,
|
||||||
private readonly PushSubscriptionService $pushSubscriptions,
|
private readonly PushSubscriptionService $pushSubscriptions,
|
||||||
private readonly AiEditingRuntimeConfig $aiEditingRuntimeConfig,
|
|
||||||
private readonly AiStylingEntitlementService $aiStylingEntitlements,
|
|
||||||
private readonly EventAiEditingPolicyService $eventAiEditingPolicy,
|
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -196,57 +185,6 @@ class EventPublicController extends BaseController
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
$deviceId = (string) $request->header('X-Device-Id', $request->input('device_id', ''));
|
|
||||||
$deviceId = $deviceId !== '' ? $deviceId : null;
|
|
||||||
|
|
||||||
if ($event->id ?? null) {
|
|
||||||
$eventModel = Event::with(['tenant', 'eventPackage.package', 'eventPackages.package'])->find($event->id);
|
|
||||||
if ($eventModel && $eventModel->tenant) {
|
|
||||||
$eventPackage = $this->packageLimitEvaluator->resolveEventPackageForPhotoUpload(
|
|
||||||
$eventModel->tenant,
|
|
||||||
$eventModel->id,
|
|
||||||
$eventModel
|
|
||||||
);
|
|
||||||
$maxGuests = $eventPackage?->effectiveGuestLimit();
|
|
||||||
|
|
||||||
if ($eventPackage && $maxGuests !== null) {
|
|
||||||
$grace = (int) config('package-limits.guest_grace', 10);
|
|
||||||
$hardLimit = $maxGuests + max(0, $grace);
|
|
||||||
$usedGuests = (int) $eventPackage->used_guests;
|
|
||||||
$isReturningGuest = $this->joinTokenService->hasSeenGuest($eventModel->id, $deviceId, $request->ip());
|
|
||||||
|
|
||||||
if ($usedGuests >= $hardLimit && ! $isReturningGuest) {
|
|
||||||
$this->recordTokenEvent(
|
|
||||||
$joinToken,
|
|
||||||
$request,
|
|
||||||
'guest_limit_exceeded',
|
|
||||||
[
|
|
||||||
'event_id' => $eventModel->id,
|
|
||||||
'used' => $usedGuests,
|
|
||||||
'limit' => $maxGuests,
|
|
||||||
'hard_limit' => $hardLimit,
|
|
||||||
],
|
|
||||||
$token,
|
|
||||||
Response::HTTP_PAYMENT_REQUIRED
|
|
||||||
);
|
|
||||||
|
|
||||||
return ApiError::response(
|
|
||||||
'guest_limit_exceeded',
|
|
||||||
__('api.packages.guest_limit_exceeded.title'),
|
|
||||||
__('api.packages.guest_limit_exceeded.message'),
|
|
||||||
Response::HTTP_PAYMENT_REQUIRED,
|
|
||||||
[
|
|
||||||
'event_id' => $eventModel->id,
|
|
||||||
'used' => $usedGuests,
|
|
||||||
'limit' => $maxGuests,
|
|
||||||
'hard_limit' => $hardLimit,
|
|
||||||
]
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
RateLimiter::clear($rateLimiterKey);
|
RateLimiter::clear($rateLimiterKey);
|
||||||
|
|
||||||
if (isset($event->status)) {
|
if (isset($event->status)) {
|
||||||
@@ -1065,7 +1003,6 @@ class EventPublicController extends BaseController
|
|||||||
* heading_font: ?string,
|
* heading_font: ?string,
|
||||||
* body_font: ?string,
|
* body_font: ?string,
|
||||||
* font_size: string,
|
* font_size: string,
|
||||||
* welcome_message: ?string,
|
|
||||||
* logo_url: ?string,
|
* logo_url: ?string,
|
||||||
* logo_mode: string,
|
* logo_mode: string,
|
||||||
* logo_value: ?string,
|
* logo_value: ?string,
|
||||||
@@ -1105,8 +1042,12 @@ class EventPublicController extends BaseController
|
|||||||
$brandingAllowed = $this->determineBrandingAllowed($event);
|
$brandingAllowed = $this->determineBrandingAllowed($event);
|
||||||
|
|
||||||
$eventBranding = $brandingAllowed ? Arr::get($event->settings, 'branding', []) : [];
|
$eventBranding = $brandingAllowed ? Arr::get($event->settings, 'branding', []) : [];
|
||||||
|
$tenantBranding = $brandingAllowed ? Arr::get($event->tenant?->settings, 'branding', []) : [];
|
||||||
|
|
||||||
$useDefault = (bool) Arr::get($eventBranding, 'use_default_branding', Arr::get($eventBranding, 'use_default', false));
|
$useDefault = (bool) Arr::get($eventBranding, 'use_default_branding', Arr::get($eventBranding, 'use_default', false));
|
||||||
$sources = $brandingAllowed ? [$eventBranding] : [[]];
|
$sources = $brandingAllowed
|
||||||
|
? ($useDefault ? [$tenantBranding] : [$eventBranding, $tenantBranding])
|
||||||
|
: [[]];
|
||||||
|
|
||||||
$primary = $this->normalizeHexColor(
|
$primary = $this->normalizeHexColor(
|
||||||
$this->firstStringFromSources($sources, ['palette.primary', 'primary_color']),
|
$this->firstStringFromSources($sources, ['palette.primary', 'primary_color']),
|
||||||
@@ -1129,7 +1070,6 @@ class EventPublicController extends BaseController
|
|||||||
$bodyFont = $this->firstStringFromSources($sources, ['typography.body', 'body_font', 'font_family']);
|
$bodyFont = $this->firstStringFromSources($sources, ['typography.body', 'body_font', 'font_family']);
|
||||||
$fontSize = $this->firstStringFromSources($sources, ['typography.size', 'font_size']) ?? $defaults['size'];
|
$fontSize = $this->firstStringFromSources($sources, ['typography.size', 'font_size']) ?? $defaults['size'];
|
||||||
$fontSize = in_array($fontSize, ['s', 'm', 'l'], true) ? $fontSize : $defaults['size'];
|
$fontSize = in_array($fontSize, ['s', 'm', 'l'], true) ? $fontSize : $defaults['size'];
|
||||||
$welcomeMessage = $this->firstStringFromSources($sources, ['welcome_message', 'welcomeMessage']);
|
|
||||||
|
|
||||||
$logoMode = $this->firstStringFromSources($sources, ['logo.mode', 'logo_mode']);
|
$logoMode = $this->firstStringFromSources($sources, ['logo.mode', 'logo_mode']);
|
||||||
if (! in_array($logoMode, ['emoticon', 'upload'], true)) {
|
if (! in_array($logoMode, ['emoticon', 'upload'], true)) {
|
||||||
@@ -1191,7 +1131,6 @@ class EventPublicController extends BaseController
|
|||||||
'heading_font' => $headingFont,
|
'heading_font' => $headingFont,
|
||||||
'body_font' => $bodyFont,
|
'body_font' => $bodyFont,
|
||||||
'font_size' => $fontSize,
|
'font_size' => $fontSize,
|
||||||
'welcome_message' => $welcomeMessage,
|
|
||||||
'logo_url' => $logoMode === 'upload' ? $logoValue : null,
|
'logo_url' => $logoMode === 'upload' ? $logoValue : null,
|
||||||
'logo_mode' => $logoMode,
|
'logo_mode' => $logoMode,
|
||||||
'logo_value' => $logoValue,
|
'logo_value' => $logoValue,
|
||||||
@@ -1455,34 +1394,17 @@ class EventPublicController extends BaseController
|
|||||||
}
|
}
|
||||||
|
|
||||||
private function makeSignedGalleryDownloadUrl(string $token, Photo $photo): string
|
private function makeSignedGalleryDownloadUrl(string $token, Photo $photo): string
|
||||||
{
|
|
||||||
return $this->makeSignedGalleryDownloadUrlForId($token, (int) $photo->id);
|
|
||||||
}
|
|
||||||
|
|
||||||
private function makeSignedGalleryDownloadUrlForId(string $token, int $photoId): string
|
|
||||||
{
|
{
|
||||||
return URL::temporarySignedRoute(
|
return URL::temporarySignedRoute(
|
||||||
'api.v1.gallery.photos.download',
|
'api.v1.gallery.photos.download',
|
||||||
now()->addSeconds(self::SIGNED_URL_TTL_SECONDS),
|
now()->addSeconds(self::SIGNED_URL_TTL_SECONDS),
|
||||||
[
|
[
|
||||||
'token' => $token,
|
'token' => $token,
|
||||||
'photo' => $photoId,
|
'photo' => $photo->id,
|
||||||
]
|
]
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
private function galleryDownloadVariantPreference(Event $event): array
|
|
||||||
{
|
|
||||||
$settings = is_array($event->settings) ? $event->settings : [];
|
|
||||||
$configuredVariant = Arr::get($settings, 'guest_download_variant', 'preview');
|
|
||||||
|
|
||||||
if ($configuredVariant === 'original') {
|
|
||||||
return ['original'];
|
|
||||||
}
|
|
||||||
|
|
||||||
return ['preview', 'original'];
|
|
||||||
}
|
|
||||||
|
|
||||||
private function makeShareAssetUrl(PhotoShareLink $shareLink, string $variant): string
|
private function makeShareAssetUrl(PhotoShareLink $shareLink, string $variant): string
|
||||||
{
|
{
|
||||||
return URL::temporarySignedRoute(
|
return URL::temporarySignedRoute(
|
||||||
@@ -1491,7 +1413,8 @@ class EventPublicController extends BaseController
|
|||||||
[
|
[
|
||||||
'slug' => $shareLink->slug,
|
'slug' => $shareLink->slug,
|
||||||
'variant' => $variant,
|
'variant' => $variant,
|
||||||
]
|
],
|
||||||
|
absolute: false
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1764,7 +1687,6 @@ class EventPublicController extends BaseController
|
|||||||
'name' => $event->name,
|
'name' => $event->name,
|
||||||
'city' => $event->city,
|
'city' => $event->city,
|
||||||
] : null,
|
] : null,
|
||||||
'branding' => $event ? $this->resolveBrandingPayload($event) : null,
|
|
||||||
])->header('Cache-Control', 'no-store');
|
])->header('Cache-Control', 'no-store');
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1928,12 +1850,7 @@ class EventPublicController extends BaseController
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return $this->streamGalleryPhoto(
|
return $this->streamGalleryPhoto($event, $record, ['original'], 'attachment');
|
||||||
$event,
|
|
||||||
$record,
|
|
||||||
$this->galleryDownloadVariantPreference($event),
|
|
||||||
'attachment'
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public function event(Request $request, string $token)
|
public function event(Request $request, string $token)
|
||||||
@@ -1985,18 +1902,11 @@ class EventPublicController extends BaseController
|
|||||||
$engagementMode = $settings['engagement_mode'] ?? 'tasks';
|
$engagementMode = $settings['engagement_mode'] ?? 'tasks';
|
||||||
$liveShowSettings = Arr::get($settings, 'live_show', []);
|
$liveShowSettings = Arr::get($settings, 'live_show', []);
|
||||||
$liveShowSettings = is_array($liveShowSettings) ? $liveShowSettings : [];
|
$liveShowSettings = is_array($liveShowSettings) ? $liveShowSettings : [];
|
||||||
$aiStylingEntitlement = $this->aiStylingEntitlements->resolveForEvent($event);
|
|
||||||
$aiEditingPolicy = $this->eventAiEditingPolicy->resolve($event);
|
|
||||||
$aiStylingAvailable = $this->aiEditingRuntimeConfig->isEnabled()
|
|
||||||
&& (bool) $aiStylingEntitlement['allowed']
|
|
||||||
&& (bool) $aiEditingPolicy['enabled'];
|
|
||||||
$event->loadMissing('photoboothSetting');
|
$event->loadMissing('photoboothSetting');
|
||||||
$policy = $this->guestPolicy();
|
$policy = $this->guestPolicy();
|
||||||
|
|
||||||
if ($joinToken) {
|
if ($joinToken) {
|
||||||
$deviceId = (string) $request->header('X-Device-Id', $request->input('device_id', ''));
|
$this->joinTokenService->incrementUsage($joinToken);
|
||||||
$deviceId = $deviceId !== '' ? $deviceId : null;
|
|
||||||
$this->joinTokenService->incrementUsage($joinToken, $deviceId, $request->ip());
|
|
||||||
}
|
}
|
||||||
|
|
||||||
$demoReadOnly = (bool) Arr::get($joinToken?->metadata ?? [], 'demo_read_only', false);
|
$demoReadOnly = (bool) Arr::get($joinToken?->metadata ?? [], 'demo_read_only', false);
|
||||||
@@ -2017,58 +1927,10 @@ class EventPublicController extends BaseController
|
|||||||
'live_show' => [
|
'live_show' => [
|
||||||
'moderation_mode' => $liveShowSettings['moderation_mode'] ?? 'manual',
|
'moderation_mode' => $liveShowSettings['moderation_mode'] ?? 'manual',
|
||||||
],
|
],
|
||||||
'capabilities' => [
|
|
||||||
'ai_styling' => $aiStylingAvailable,
|
|
||||||
'ai_styling_granted_by' => $aiStylingEntitlement['granted_by'],
|
|
||||||
'ai_styling_required_feature' => $aiStylingEntitlement['required_feature'],
|
|
||||||
'ai_styling_addon_keys' => $aiStylingEntitlement['addon_keys'],
|
|
||||||
'ai_styling_event_enabled' => (bool) $aiEditingPolicy['enabled'],
|
|
||||||
],
|
|
||||||
'engagement_mode' => $engagementMode,
|
'engagement_mode' => $engagementMode,
|
||||||
])->header('Cache-Control', 'no-store');
|
])->header('Cache-Control', 'no-store');
|
||||||
}
|
}
|
||||||
|
|
||||||
public function qr(Request $request, string $token): JsonResponse
|
|
||||||
{
|
|
||||||
$result = $this->resolvePublishedEvent($request, $token, ['id']);
|
|
||||||
|
|
||||||
if ($result instanceof JsonResponse) {
|
|
||||||
return $result;
|
|
||||||
}
|
|
||||||
|
|
||||||
[, $joinToken] = $result;
|
|
||||||
|
|
||||||
$joinTokenValue = $joinToken->token ?? $token;
|
|
||||||
$qrCodeUrl = $joinTokenValue ? url('/e/'.$joinTokenValue) : null;
|
|
||||||
$qrCodeDataUrl = null;
|
|
||||||
|
|
||||||
if ($qrCodeUrl) {
|
|
||||||
$requestedSize = (int) $request->query('size', 360);
|
|
||||||
$size = max(120, min($requestedSize, 640));
|
|
||||||
|
|
||||||
try {
|
|
||||||
$png = QrCode::format('png')
|
|
||||||
->size($size)
|
|
||||||
->margin(1)
|
|
||||||
->errorCorrection('M')
|
|
||||||
->generate($qrCodeUrl);
|
|
||||||
|
|
||||||
$pngBinary = (string) $png;
|
|
||||||
|
|
||||||
if ($pngBinary !== '') {
|
|
||||||
$qrCodeDataUrl = 'data:image/png;base64,'.base64_encode($pngBinary);
|
|
||||||
}
|
|
||||||
} catch (\Throwable $exception) {
|
|
||||||
report($exception);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return response()->json([
|
|
||||||
'url' => $qrCodeUrl,
|
|
||||||
'qr_code_data_url' => $qrCodeDataUrl,
|
|
||||||
])->header('Cache-Control', 'no-store');
|
|
||||||
}
|
|
||||||
|
|
||||||
public function package(Request $request, string $token)
|
public function package(Request $request, string $token)
|
||||||
{
|
{
|
||||||
$result = $this->resolvePublishedEvent($request, $token, ['id']);
|
$result = $this->resolvePublishedEvent($request, $token, ['id']);
|
||||||
@@ -2245,15 +2107,6 @@ class EventPublicController extends BaseController
|
|||||||
$disk = $asset?->disk ?? $record->mediaAsset?->disk;
|
$disk = $asset?->disk ?? $record->mediaAsset?->disk;
|
||||||
$path = $watermarked?->path ?? $asset?->path ?? ($record->thumbnail_path ?: $record->file_path);
|
$path = $watermarked?->path ?? $asset?->path ?? ($record->thumbnail_path ?: $record->file_path);
|
||||||
$mime = $watermarked?->mime_type ?? $asset?->mime_type ?? 'image/jpeg';
|
$mime = $watermarked?->mime_type ?? $asset?->mime_type ?? 'image/jpeg';
|
||||||
} elseif ($variant === 'preview') {
|
|
||||||
$asset = EventMediaAsset::where('photo_id', $record->id)->where('variant', 'preview')->first();
|
|
||||||
$watermarked = $preferOriginals
|
|
||||||
? null
|
|
||||||
: EventMediaAsset::where('photo_id', $record->id)->where('variant', 'watermarked_preview')->first();
|
|
||||||
$fallbackAsset = $record->mediaAsset ?? EventMediaAsset::where('photo_id', $record->id)->where('variant', 'original')->first();
|
|
||||||
$disk = $watermarked?->disk ?? $asset?->disk ?? $fallbackAsset?->disk;
|
|
||||||
$path = $watermarked?->path ?? $asset?->path ?? $fallbackAsset?->path ?? ($record->file_path ?? null);
|
|
||||||
$mime = $watermarked?->mime_type ?? $asset?->mime_type ?? $fallbackAsset?->mime_type ?? ($record->mime_type ?? 'image/jpeg');
|
|
||||||
} else {
|
} else {
|
||||||
$watermarked = $preferOriginals
|
$watermarked = $preferOriginals
|
||||||
? null
|
? null
|
||||||
@@ -2694,15 +2547,6 @@ class EventPublicController extends BaseController
|
|||||||
->distinct('guest_name')
|
->distinct('guest_name')
|
||||||
->count('guest_name');
|
->count('guest_name');
|
||||||
|
|
||||||
$guestCount = DB::table('photos')
|
|
||||||
->where('event_id', $eventId)
|
|
||||||
->distinct('guest_name')
|
|
||||||
->count('guest_name');
|
|
||||||
|
|
||||||
$likesCount = (int) DB::table('photos')
|
|
||||||
->where('event_id', $eventId)
|
|
||||||
->sum('likes_count');
|
|
||||||
|
|
||||||
// Tasks solved as number of photos linked to a task (proxy metric).
|
// Tasks solved as number of photos linked to a task (proxy metric).
|
||||||
$tasksSolved = $engagementMode === 'photo_only'
|
$tasksSolved = $engagementMode === 'photo_only'
|
||||||
? 0
|
? 0
|
||||||
@@ -2713,8 +2557,6 @@ class EventPublicController extends BaseController
|
|||||||
$payload = [
|
$payload = [
|
||||||
'online_guests' => $onlineGuests,
|
'online_guests' => $onlineGuests,
|
||||||
'tasks_solved' => $tasksSolved,
|
'tasks_solved' => $tasksSolved,
|
||||||
'guest_count' => $guestCount,
|
|
||||||
'likes_count' => $likesCount,
|
|
||||||
'latest_photo_at' => $latestPhotoAt,
|
'latest_photo_at' => $latestPhotoAt,
|
||||||
'engagement_mode' => $engagementMode,
|
'engagement_mode' => $engagementMode,
|
||||||
];
|
];
|
||||||
@@ -2900,14 +2742,12 @@ class EventPublicController extends BaseController
|
|||||||
[$locale] = $this->resolveGuestLocale($request, $event);
|
[$locale] = $this->resolveGuestLocale($request, $event);
|
||||||
$fallbacks = $this->localeFallbackChain($locale, $event->default_locale ?? null);
|
$fallbacks = $this->localeFallbackChain($locale, $event->default_locale ?? null);
|
||||||
|
|
||||||
$deviceId = $this->normalizeGuestIdentifier((string) $request->header('X-Device-Id', ''));
|
$deviceId = (string) $request->header('X-Device-Id', 'anon');
|
||||||
$deviceId = $deviceId !== '' ? $deviceId : 'anon';
|
|
||||||
$filter = $request->query('filter');
|
$filter = $request->query('filter');
|
||||||
|
|
||||||
$since = $request->query('since');
|
$since = $request->query('since');
|
||||||
$query = DB::table('photos')
|
$query = DB::table('photos')
|
||||||
->leftJoin('tasks', 'photos.task_id', '=', 'tasks.id')
|
->leftJoin('tasks', 'photos.task_id', '=', 'tasks.id')
|
||||||
->leftJoin('emotions', 'photos.emotion_id', '=', 'emotions.id')
|
|
||||||
->select([
|
->select([
|
||||||
'photos.id',
|
'photos.id',
|
||||||
'photos.file_path',
|
'photos.file_path',
|
||||||
@@ -2916,14 +2756,9 @@ class EventPublicController extends BaseController
|
|||||||
'photos.emotion_id',
|
'photos.emotion_id',
|
||||||
'photos.task_id',
|
'photos.task_id',
|
||||||
'photos.guest_name',
|
'photos.guest_name',
|
||||||
'photos.created_by_device_id',
|
|
||||||
'photos.created_at',
|
'photos.created_at',
|
||||||
'photos.ingest_source',
|
'photos.ingest_source',
|
||||||
'tasks.title as task_title',
|
'tasks.title as task_title',
|
||||||
'emotions.name as emotion_name',
|
|
||||||
'emotions.icon as emotion_icon',
|
|
||||||
'emotions.color as emotion_color',
|
|
||||||
'emotions.id as emotion_lookup_id',
|
|
||||||
])
|
])
|
||||||
->where('photos.event_id', $eventId)
|
->where('photos.event_id', $eventId)
|
||||||
->where('photos.status', 'approved')
|
->where('photos.status', 'approved')
|
||||||
@@ -2934,50 +2769,24 @@ class EventPublicController extends BaseController
|
|||||||
if ($filter === 'photobooth') {
|
if ($filter === 'photobooth') {
|
||||||
$query->whereIn('photos.ingest_source', [Photo::SOURCE_PHOTOBOOTH, Photo::SOURCE_SPARKBOOTH]);
|
$query->whereIn('photos.ingest_source', [Photo::SOURCE_PHOTOBOOTH, Photo::SOURCE_SPARKBOOTH]);
|
||||||
} elseif ($filter === 'myphotos' && $deviceId !== 'anon') {
|
} elseif ($filter === 'myphotos' && $deviceId !== 'anon') {
|
||||||
$query->where(function ($inner) use ($deviceId) {
|
$query->where('guest_name', $deviceId);
|
||||||
$inner->where('created_by_device_id', $deviceId)
|
|
||||||
->orWhere('guest_name', $deviceId);
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if ($since) {
|
if ($since) {
|
||||||
$query->where('photos.created_at', '>', $since);
|
$query->where('photos.created_at', '>', $since);
|
||||||
}
|
}
|
||||||
$rows = $query->get()->map(function ($r) use ($fallbacks, $token, $deviceId) {
|
$rows = $query->get()->map(function ($r) use ($fallbacks, $token) {
|
||||||
$fullUrl = $this->makeSignedGalleryAssetUrlForId($token, (int) $r->id, 'full')
|
$r->file_path = $this->makeSignedGalleryAssetUrlForId($token, (int) $r->id, 'full')
|
||||||
?? $this->resolveSignedFallbackUrl((string) ($r->file_path ?? ''));
|
?? $this->resolveSignedFallbackUrl((string) ($r->file_path ?? ''));
|
||||||
$thumbnailUrl = $this->makeSignedGalleryAssetUrlForId($token, (int) $r->id, 'thumbnail')
|
$r->thumbnail_path = $this->makeSignedGalleryAssetUrlForId($token, (int) $r->id, 'thumbnail')
|
||||||
?? $this->resolveSignedFallbackUrl((string) ($r->thumbnail_path ?? ''));
|
?? $this->resolveSignedFallbackUrl((string) ($r->thumbnail_path ?? ''));
|
||||||
$r->file_path = $fullUrl;
|
|
||||||
$r->thumbnail_path = $thumbnailUrl;
|
|
||||||
$r->full_url = $fullUrl;
|
|
||||||
$r->thumbnail_url = $thumbnailUrl;
|
|
||||||
$r->download_url = $this->makeSignedGalleryDownloadUrlForId($token, (int) $r->id);
|
|
||||||
|
|
||||||
// Localize task title if present
|
// Localize task title if present
|
||||||
if ($r->task_title) {
|
if ($r->task_title) {
|
||||||
$r->task_title = $this->firstLocalizedValue($r->task_title, $fallbacks, 'Unbenannte Aufgabe');
|
$r->task_title = $this->firstLocalizedValue($r->task_title, $fallbacks, 'Unbenannte Aufgabe');
|
||||||
}
|
}
|
||||||
|
|
||||||
$emotion = null;
|
|
||||||
if ($r->emotion_id) {
|
|
||||||
$emotionName = $this->firstLocalizedValue($r->emotion_name, $fallbacks, '');
|
|
||||||
if ($emotionName !== '') {
|
|
||||||
$emotion = [
|
|
||||||
'id' => (int) ($r->emotion_lookup_id ?? $r->emotion_id),
|
|
||||||
'name' => $emotionName,
|
|
||||||
'icon' => $r->emotion_icon ?: null,
|
|
||||||
'color' => $r->emotion_color ?: null,
|
|
||||||
];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
$r->emotion = $emotion;
|
|
||||||
|
|
||||||
$r->ingest_source = $r->ingest_source ?? Photo::SOURCE_UNKNOWN;
|
$r->ingest_source = $r->ingest_source ?? Photo::SOURCE_UNKNOWN;
|
||||||
$createdBy = $r->created_by_device_id ? $this->normalizeGuestIdentifier((string) $r->created_by_device_id) : '';
|
|
||||||
$r->is_mine = $deviceId !== 'anon'
|
|
||||||
&& $deviceId !== ''
|
|
||||||
&& (($createdBy !== '' && $createdBy === $deviceId) || ($createdBy === '' && (string) $r->guest_name === $deviceId));
|
|
||||||
|
|
||||||
return $r;
|
return $r;
|
||||||
});
|
});
|
||||||
@@ -3070,159 +2879,6 @@ class EventPublicController extends BaseController
|
|||||||
return response()->json(['liked' => true, 'likes_count' => $count]);
|
return response()->json(['liked' => true, 'likes_count' => $count]);
|
||||||
}
|
}
|
||||||
|
|
||||||
public function unlike(Request $request, int $id)
|
|
||||||
{
|
|
||||||
$deviceId = (string) $request->header('X-Device-Id', 'anon');
|
|
||||||
$deviceId = substr(preg_replace('/[^a-zA-Z0-9_-]/', '', $deviceId), 0, 64);
|
|
||||||
if ($deviceId === '') {
|
|
||||||
$deviceId = 'anon';
|
|
||||||
}
|
|
||||||
|
|
||||||
$photo = DB::table('photos')
|
|
||||||
->join('events', 'photos.event_id', '=', 'events.id')
|
|
||||||
->where('photos.id', $id)
|
|
||||||
->where('events.status', 'published')
|
|
||||||
->first(['photos.id', 'photos.event_id']);
|
|
||||||
if (! $photo) {
|
|
||||||
return ApiError::response(
|
|
||||||
'photo_not_found',
|
|
||||||
'Photo Not Found',
|
|
||||||
'Photo not found or event not public.',
|
|
||||||
Response::HTTP_NOT_FOUND,
|
|
||||||
['photo_id' => $id]
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
$exists = DB::table('photo_likes')->where('photo_id', $id)->where('guest_name', $deviceId)->exists();
|
|
||||||
if (! $exists) {
|
|
||||||
$count = (int) DB::table('photos')->where('id', $id)->value('likes_count');
|
|
||||||
|
|
||||||
return response()->json(['liked' => false, 'likes_count' => $count]);
|
|
||||||
}
|
|
||||||
|
|
||||||
DB::beginTransaction();
|
|
||||||
try {
|
|
||||||
DB::table('photo_likes')->where('photo_id', $id)->where('guest_name', $deviceId)->delete();
|
|
||||||
DB::table('photos')->where('id', $id)->update([
|
|
||||||
'likes_count' => DB::raw('case when likes_count > 0 then likes_count - 1 else 0 end'),
|
|
||||||
'updated_at' => now(),
|
|
||||||
]);
|
|
||||||
DB::commit();
|
|
||||||
} catch (\Throwable $e) {
|
|
||||||
DB::rollBack();
|
|
||||||
Log::warning('unlike failed', ['error' => $e->getMessage()]);
|
|
||||||
}
|
|
||||||
|
|
||||||
$count = (int) DB::table('photos')->where('id', $id)->value('likes_count');
|
|
||||||
|
|
||||||
return response()->json(['liked' => false, 'likes_count' => $count]);
|
|
||||||
}
|
|
||||||
|
|
||||||
public function destroyPhoto(Request $request, string $token, Photo $photo): JsonResponse
|
|
||||||
{
|
|
||||||
$result = $this->resolvePublishedEvent($request, $token, ['id']);
|
|
||||||
|
|
||||||
if ($result instanceof JsonResponse) {
|
|
||||||
return $result;
|
|
||||||
}
|
|
||||||
|
|
||||||
[$event] = $result;
|
|
||||||
$deviceId = $this->resolveDeviceIdentifier($request);
|
|
||||||
|
|
||||||
if ($deviceId === 'anonymous') {
|
|
||||||
return ApiError::response(
|
|
||||||
'photo_delete_forbidden',
|
|
||||||
'Delete Not Allowed',
|
|
||||||
'This photo cannot be deleted from this device.',
|
|
||||||
Response::HTTP_FORBIDDEN,
|
|
||||||
['photo_id' => $photo->id]
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if ($photo->event_id !== (int) $event->id) {
|
|
||||||
return ApiError::response(
|
|
||||||
'photo_not_found',
|
|
||||||
'Photo Not Found',
|
|
||||||
'Photo not found or event not public.',
|
|
||||||
Response::HTTP_NOT_FOUND,
|
|
||||||
['photo_id' => $photo->id]
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
$ownerId = $photo->created_by_device_id
|
|
||||||
? $this->normalizeGuestIdentifier((string) $photo->created_by_device_id)
|
|
||||||
: '';
|
|
||||||
$guestName = is_string($photo->guest_name) ? $photo->guest_name : '';
|
|
||||||
$isOwner = $ownerId !== ''
|
|
||||||
? $ownerId === $deviceId
|
|
||||||
: ($guestName !== '' && $guestName === $deviceId);
|
|
||||||
|
|
||||||
if (! $isOwner) {
|
|
||||||
return ApiError::response(
|
|
||||||
'photo_delete_forbidden',
|
|
||||||
'Delete Not Allowed',
|
|
||||||
'This photo cannot be deleted from this device.',
|
|
||||||
Response::HTTP_FORBIDDEN,
|
|
||||||
['photo_id' => $photo->id]
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
$eventModel = Event::with(['eventPackage.package'])->find((int) $event->id);
|
|
||||||
$assets = EventMediaAsset::where('photo_id', $photo->id)->get();
|
|
||||||
|
|
||||||
foreach ($assets as $asset) {
|
|
||||||
if (! is_string($asset->path) || $asset->path === '') {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
try {
|
|
||||||
Storage::disk($asset->disk)->delete($asset->path);
|
|
||||||
} catch (\Throwable $e) {
|
|
||||||
Log::warning('Failed to delete guest photo asset from storage', [
|
|
||||||
'asset_id' => $asset->id,
|
|
||||||
'disk' => $asset->disk,
|
|
||||||
'path' => $asset->path,
|
|
||||||
'error' => $e->getMessage(),
|
|
||||||
]);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if ($assets->isEmpty() && $eventModel) {
|
|
||||||
$fallbackDisk = $this->eventStorageManager->getHotDiskForEvent($eventModel);
|
|
||||||
$paths = array_values(array_filter([
|
|
||||||
is_string($photo->path ?? null) ? $photo->path : null,
|
|
||||||
is_string($photo->thumbnail_path ?? null) ? $photo->thumbnail_path : null,
|
|
||||||
is_string($photo->file_path ?? null) ? $photo->file_path : null,
|
|
||||||
]));
|
|
||||||
if (! empty($paths)) {
|
|
||||||
Storage::disk($fallbackDisk)->delete($paths);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
DB::transaction(function () use ($photo, $assets) {
|
|
||||||
$photo->likes()->delete();
|
|
||||||
PhotoShareLink::where('photo_id', $photo->id)->delete();
|
|
||||||
if ($assets->isNotEmpty()) {
|
|
||||||
EventMediaAsset::whereIn('id', $assets->pluck('id'))->delete();
|
|
||||||
}
|
|
||||||
$photo->delete();
|
|
||||||
});
|
|
||||||
|
|
||||||
$eventPackage = $eventModel?->eventPackage;
|
|
||||||
if ($eventPackage && $eventPackage->package) {
|
|
||||||
$previousUsed = (int) $eventPackage->used_photos;
|
|
||||||
if ($previousUsed > 0) {
|
|
||||||
$eventPackage->decrement('used_photos');
|
|
||||||
$eventPackage->refresh();
|
|
||||||
$this->packageUsageTracker->recordPhotoUsage($eventPackage, $previousUsed, -1);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return response()->json([
|
|
||||||
'message' => 'Photo deleted successfully',
|
|
||||||
'photo_id' => $photo->id,
|
|
||||||
]);
|
|
||||||
}
|
|
||||||
|
|
||||||
public function upload(Request $request, string $token)
|
public function upload(Request $request, string $token)
|
||||||
{
|
{
|
||||||
$result = $this->resolvePublishedEvent($request, $token, ['id']);
|
$result = $this->resolvePublishedEvent($request, $token, ['id']);
|
||||||
@@ -3386,19 +3042,10 @@ class EventPublicController extends BaseController
|
|||||||
$thumbUrl = $thumbPath
|
$thumbUrl = $thumbPath
|
||||||
? $this->resolveDiskUrl($disk, $thumbPath)
|
? $this->resolveDiskUrl($disk, $thumbPath)
|
||||||
: $this->resolveDiskUrl($disk, $path);
|
: $this->resolveDiskUrl($disk, $path);
|
||||||
$previewRel = "events/{$eventId}/photos/previews/{$baseName}_preview.jpg";
|
|
||||||
$previewPath = ImageHelper::makeThumbnailOnDisk(
|
|
||||||
$disk,
|
|
||||||
$path,
|
|
||||||
$previewRel,
|
|
||||||
self::PREVIEW_MAX_EDGE,
|
|
||||||
self::PREVIEW_QUALITY
|
|
||||||
);
|
|
||||||
|
|
||||||
// Create watermarked copies (non-destructive).
|
// Create watermarked copies (non-destructive).
|
||||||
$watermarkedPath = $path;
|
$watermarkedPath = $path;
|
||||||
$watermarkedThumb = $thumbPath ?: $path;
|
$watermarkedThumb = $thumbPath ?: $path;
|
||||||
$watermarkedPreview = $previewPath ?: $path;
|
|
||||||
if ($watermarkConfig['type'] !== 'none' && ! empty($watermarkConfig['asset']) && ! ($watermarkConfig['serve_originals'] ?? false)) {
|
if ($watermarkConfig['type'] !== 'none' && ! empty($watermarkConfig['asset']) && ! ($watermarkConfig['serve_originals'] ?? false)) {
|
||||||
$watermarkedPath = ImageHelper::copyWithWatermark($disk, $path, "events/{$eventId}/photos/watermarked/{$baseName}.{$file->getClientOriginalExtension()}", $watermarkConfig) ?? $path;
|
$watermarkedPath = ImageHelper::copyWithWatermark($disk, $path, "events/{$eventId}/photos/watermarked/{$baseName}.{$file->getClientOriginalExtension()}", $watermarkConfig) ?? $path;
|
||||||
if ($thumbPath) {
|
if ($thumbPath) {
|
||||||
@@ -3411,17 +3058,6 @@ class EventPublicController extends BaseController
|
|||||||
} else {
|
} else {
|
||||||
$watermarkedThumb = $watermarkedPath;
|
$watermarkedThumb = $watermarkedPath;
|
||||||
}
|
}
|
||||||
|
|
||||||
if ($previewPath) {
|
|
||||||
$watermarkedPreview = ImageHelper::copyWithWatermark(
|
|
||||||
$disk,
|
|
||||||
$previewPath,
|
|
||||||
"events/{$eventId}/photos/watermarked/{$baseName}_preview.jpg",
|
|
||||||
$watermarkConfig
|
|
||||||
) ?? $previewPath;
|
|
||||||
} else {
|
|
||||||
$watermarkedPreview = $watermarkedPath;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
$url = $this->resolveDiskUrl($disk, $watermarkedPath);
|
$url = $this->resolveDiskUrl($disk, $watermarkedPath);
|
||||||
@@ -3535,23 +3171,6 @@ class EventPublicController extends BaseController
|
|||||||
],
|
],
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
if ($previewPath) {
|
|
||||||
$this->eventStorageManager->recordAsset($eventModel, $disk, $previewPath, [
|
|
||||||
'variant' => 'preview',
|
|
||||||
'mime_type' => 'image/jpeg',
|
|
||||||
'status' => 'hot',
|
|
||||||
'processed_at' => now(),
|
|
||||||
'photo_id' => $photoId,
|
|
||||||
'size_bytes' => Storage::disk($disk)->exists($previewPath)
|
|
||||||
? Storage::disk($disk)->size($previewPath)
|
|
||||||
: null,
|
|
||||||
'meta' => [
|
|
||||||
'source_variant_id' => $asset->id,
|
|
||||||
],
|
|
||||||
]);
|
|
||||||
}
|
|
||||||
|
|
||||||
if ($watermarkedThumb !== $thumbPath) {
|
if ($watermarkedThumb !== $thumbPath) {
|
||||||
$this->eventStorageManager->recordAsset($eventModel, $disk, $watermarkedThumb, [
|
$this->eventStorageManager->recordAsset($eventModel, $disk, $watermarkedThumb, [
|
||||||
'variant' => 'watermarked_thumbnail',
|
'variant' => 'watermarked_thumbnail',
|
||||||
@@ -3568,22 +3187,6 @@ class EventPublicController extends BaseController
|
|||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
if ($watermarkedPreview !== $previewPath) {
|
|
||||||
$this->eventStorageManager->recordAsset($eventModel, $disk, $watermarkedPreview, [
|
|
||||||
'variant' => 'watermarked_preview',
|
|
||||||
'mime_type' => 'image/jpeg',
|
|
||||||
'status' => 'hot',
|
|
||||||
'processed_at' => now(),
|
|
||||||
'photo_id' => $photoId,
|
|
||||||
'size_bytes' => Storage::disk($disk)->exists($watermarkedPreview)
|
|
||||||
? Storage::disk($disk)->size($watermarkedPreview)
|
|
||||||
: null,
|
|
||||||
'meta' => [
|
|
||||||
'source_variant_id' => $watermarkedAsset?->id ?? $asset->id,
|
|
||||||
],
|
|
||||||
]);
|
|
||||||
}
|
|
||||||
|
|
||||||
DB::table('photos')
|
DB::table('photos')
|
||||||
->where('id', $photoId)
|
->where('id', $photoId)
|
||||||
->update(['media_asset_id' => $asset->id]);
|
->update(['media_asset_id' => $asset->id]);
|
||||||
|
|||||||
@@ -26,11 +26,11 @@ class LegalController extends BaseController
|
|||||||
'allow_unsafe_links' => false,
|
'allow_unsafe_links' => false,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
$environment->addExtension(new CommonMarkCoreExtension);
|
$environment->addExtension(new CommonMarkCoreExtension());
|
||||||
$environment->addExtension(new TableExtension);
|
$environment->addExtension(new TableExtension());
|
||||||
$environment->addExtension(new AutolinkExtension);
|
$environment->addExtension(new AutolinkExtension());
|
||||||
$environment->addExtension(new StrikethroughExtension);
|
$environment->addExtension(new StrikethroughExtension());
|
||||||
$environment->addExtension(new TaskListExtension);
|
$environment->addExtension(new TaskListExtension());
|
||||||
|
|
||||||
$this->markdown = new MarkdownConverter($environment);
|
$this->markdown = new MarkdownConverter($environment);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -212,10 +212,6 @@ class LiveShowController extends BaseController
|
|||||||
|
|
||||||
return Event::query()
|
return Event::query()
|
||||||
->where('live_show_token', $token)
|
->where('live_show_token', $token)
|
||||||
->where(function (Builder $query) {
|
|
||||||
$query->whereNull('live_show_token_expires_at')
|
|
||||||
->orWhere('live_show_token_expires_at', '>=', now());
|
|
||||||
})
|
|
||||||
->first();
|
->first();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -24,6 +24,12 @@ class CouponPreviewController extends Controller
|
|||||||
|
|
||||||
$package = Package::findOrFail($data['package_id']);
|
$package = Package::findOrFail($data['package_id']);
|
||||||
|
|
||||||
|
if (! $package->paddle_price_id) {
|
||||||
|
throw ValidationException::withMessages([
|
||||||
|
'code' => __('marketing.coupon.errors.package_not_configured'),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
$tenant = Auth::user()?->tenant;
|
$tenant = Auth::user()?->tenant;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -36,7 +36,7 @@ class GiftVoucherCheckoutController extends Controller
|
|||||||
|
|
||||||
if (! $checkout['checkout_url']) {
|
if (! $checkout['checkout_url']) {
|
||||||
throw ValidationException::withMessages([
|
throw ValidationException::withMessages([
|
||||||
'tier_key' => __('Unable to create checkout.'),
|
'tier_key' => __('Unable to create Paddle checkout.'),
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -46,43 +46,24 @@ class GiftVoucherCheckoutController extends Controller
|
|||||||
public function show(Request $request): JsonResponse
|
public function show(Request $request): JsonResponse
|
||||||
{
|
{
|
||||||
$data = $request->validate([
|
$data = $request->validate([
|
||||||
'checkout_id' => ['nullable', 'string', 'required_without_all:order_id,code'],
|
'checkout_id' => ['nullable', 'string', 'required_without_all:transaction_id,code'],
|
||||||
'order_id' => ['nullable', 'string', 'required_without_all:checkout_id,code'],
|
'transaction_id' => ['nullable', 'string', 'required_without_all:checkout_id,code'],
|
||||||
'code' => ['nullable', 'string', 'required_without_all:checkout_id,order_id'],
|
'code' => ['nullable', 'string', 'required_without_all:checkout_id,transaction_id'],
|
||||||
]);
|
]);
|
||||||
|
|
||||||
$voucherQuery = GiftVoucher::query()
|
$voucherQuery = GiftVoucher::query();
|
||||||
->where('status', '!=', GiftVoucher::STATUS_PENDING)
|
|
||||||
->where(function ($query) use ($data) {
|
|
||||||
$hasCondition = false;
|
|
||||||
|
|
||||||
if (! empty($data['checkout_id'])) {
|
if (! empty($data['checkout_id'])) {
|
||||||
$query->where(function ($inner) use ($data) {
|
$voucherQuery->where('paddle_checkout_id', $data['checkout_id']);
|
||||||
$inner->where('lemonsqueezy_checkout_id', $data['checkout_id'])
|
}
|
||||||
->orWhere('paypal_order_id', $data['checkout_id']);
|
|
||||||
});
|
|
||||||
|
|
||||||
$hasCondition = true;
|
if (! empty($data['transaction_id'])) {
|
||||||
}
|
$voucherQuery->orWhere('paddle_transaction_id', $data['transaction_id']);
|
||||||
|
}
|
||||||
|
|
||||||
if (! empty($data['order_id'])) {
|
if (! empty($data['code'])) {
|
||||||
$method = $hasCondition ? 'orWhere' : 'where';
|
$voucherQuery->orWhere('code', strtoupper($data['code']));
|
||||||
|
}
|
||||||
$query->{$method}(function ($inner) use ($data) {
|
|
||||||
$inner->where('lemonsqueezy_order_id', $data['order_id'])
|
|
||||||
->orWhere('paypal_capture_id', $data['order_id'])
|
|
||||||
->orWhere('paypal_order_id', $data['order_id']);
|
|
||||||
});
|
|
||||||
|
|
||||||
$hasCondition = true;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (! empty($data['code'])) {
|
|
||||||
$method = $hasCondition ? 'orWhere' : 'where';
|
|
||||||
|
|
||||||
$query->{$method}('code', strtoupper($data['code']));
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
$voucher = $voucherQuery->latest()->firstOrFail();
|
$voucher = $voucherQuery->latest()->firstOrFail();
|
||||||
|
|
||||||
|
|||||||
@@ -9,40 +9,37 @@ use App\Models\Package;
|
|||||||
use App\Models\PackagePurchase;
|
use App\Models\PackagePurchase;
|
||||||
use App\Models\TenantPackage;
|
use App\Models\TenantPackage;
|
||||||
use App\Services\Checkout\CheckoutSessionService;
|
use App\Services\Checkout\CheckoutSessionService;
|
||||||
use App\Services\PayPal\Exceptions\PayPalException;
|
use App\Services\Paddle\PaddleCheckoutService;
|
||||||
use App\Services\PayPal\PayPalOrderService;
|
|
||||||
use Illuminate\Http\JsonResponse;
|
use Illuminate\Http\JsonResponse;
|
||||||
use Illuminate\Http\Request;
|
use Illuminate\Http\Request;
|
||||||
use Illuminate\Support\Facades\DB;
|
use Illuminate\Support\Facades\DB;
|
||||||
use Illuminate\Support\Facades\Log;
|
|
||||||
use Illuminate\Validation\ValidationException;
|
use Illuminate\Validation\ValidationException;
|
||||||
|
|
||||||
class PackageController extends Controller
|
class PackageController extends Controller
|
||||||
{
|
{
|
||||||
public function __construct(
|
public function __construct(
|
||||||
private readonly PayPalOrderService $paypalOrders,
|
private readonly PaddleCheckoutService $paddleCheckout,
|
||||||
private readonly CheckoutSessionService $sessions,
|
private readonly CheckoutSessionService $sessions,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
public function index(Request $request): JsonResponse
|
public function index(Request $request): JsonResponse
|
||||||
{
|
{
|
||||||
$type = $request->query('type', 'endcustomer');
|
$type = $request->query('type', 'endcustomer');
|
||||||
$provider = strtolower((string) config('checkout.default_provider', CheckoutSession::PROVIDER_PAYPAL));
|
|
||||||
$packages = Package::where('type', $type)
|
$packages = Package::where('type', $type)
|
||||||
->orderBy('price')
|
->orderBy('price')
|
||||||
->get();
|
->get();
|
||||||
|
|
||||||
$packages->each(function ($package) use ($provider) {
|
$packages->each(function ($package) {
|
||||||
if (is_string($package->features)) {
|
if (is_string($package->features)) {
|
||||||
$decoded = json_decode($package->features, true);
|
$decoded = json_decode($package->features, true);
|
||||||
$package->features = is_array($decoded) ? $decoded : [];
|
$package->features = is_array($decoded) ? $decoded : [];
|
||||||
|
|
||||||
} elseif (! is_array($package->features)) {
|
return;
|
||||||
$package->features = [];
|
|
||||||
}
|
}
|
||||||
|
|
||||||
$package->setAttribute('checkout_provider', $provider);
|
if (! is_array($package->features)) {
|
||||||
$package->setAttribute('can_checkout', $this->canCheckoutPackage($package, $provider));
|
$package->features = [];
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
return response()->json([
|
return response()->json([
|
||||||
@@ -56,7 +53,7 @@ class PackageController extends Controller
|
|||||||
$request->validate([
|
$request->validate([
|
||||||
'package_id' => 'required|exists:packages,id',
|
'package_id' => 'required|exists:packages,id',
|
||||||
'type' => 'required|in:endcustomer,reseller',
|
'type' => 'required|in:endcustomer,reseller',
|
||||||
'payment_method' => 'required|in:paypal',
|
'payment_method' => 'required|in:paddle',
|
||||||
'event_id' => 'nullable|exists:events,id', // For endcustomer
|
'event_id' => 'nullable|exists:events,id', // For endcustomer
|
||||||
'success_url' => 'nullable|url',
|
'success_url' => 'nullable|url',
|
||||||
'return_url' => 'nullable|url',
|
'return_url' => 'nullable|url',
|
||||||
@@ -82,7 +79,7 @@ class PackageController extends Controller
|
|||||||
{
|
{
|
||||||
$request->validate([
|
$request->validate([
|
||||||
'package_id' => 'required|exists:packages,id',
|
'package_id' => 'required|exists:packages,id',
|
||||||
'paypal_order_id' => 'required|string',
|
'paddle_transaction_id' => 'required|string',
|
||||||
]);
|
]);
|
||||||
|
|
||||||
$package = Package::findOrFail($request->package_id);
|
$package = Package::findOrFail($request->package_id);
|
||||||
@@ -92,14 +89,14 @@ class PackageController extends Controller
|
|||||||
throw ValidationException::withMessages(['tenant' => 'Tenant not found.']);
|
throw ValidationException::withMessages(['tenant' => 'Tenant not found.']);
|
||||||
}
|
}
|
||||||
|
|
||||||
$provider = 'paypal';
|
$provider = 'paddle';
|
||||||
|
|
||||||
DB::transaction(function () use ($request, $package, $tenant, $provider) {
|
DB::transaction(function () use ($request, $package, $tenant, $provider) {
|
||||||
PackagePurchase::create([
|
PackagePurchase::create([
|
||||||
'tenant_id' => $tenant->id,
|
'tenant_id' => $tenant->id,
|
||||||
'package_id' => $package->id,
|
'package_id' => $package->id,
|
||||||
'provider' => $provider,
|
'provider' => $provider,
|
||||||
'provider_id' => $request->input('paypal_order_id'),
|
'provider_id' => $request->input('paddle_transaction_id'),
|
||||||
'price' => $package->price,
|
'price' => $package->price,
|
||||||
'type' => 'endcustomer_event',
|
'type' => 'endcustomer_event',
|
||||||
'purchased_at' => now(),
|
'purchased_at' => now(),
|
||||||
@@ -164,14 +161,12 @@ class PackageController extends Controller
|
|||||||
], 201);
|
], 201);
|
||||||
}
|
}
|
||||||
|
|
||||||
public function createPayPalCheckout(Request $request): JsonResponse
|
public function createPaddleCheckout(Request $request): JsonResponse
|
||||||
{
|
{
|
||||||
$request->validate([
|
$request->validate([
|
||||||
'package_id' => 'required|exists:packages,id',
|
'package_id' => 'required|exists:packages,id',
|
||||||
'success_url' => 'nullable|url',
|
'success_url' => 'nullable|url',
|
||||||
'return_url' => 'nullable|url',
|
'return_url' => 'nullable|url',
|
||||||
'cancel_url' => 'nullable|url',
|
|
||||||
'locale' => 'nullable|string|max:10',
|
|
||||||
]);
|
]);
|
||||||
|
|
||||||
$package = Package::findOrFail($request->integer('package_id'));
|
$package = Package::findOrFail($request->integer('package_id'));
|
||||||
@@ -186,11 +181,15 @@ class PackageController extends Controller
|
|||||||
throw ValidationException::withMessages(['user' => 'User context missing.']);
|
throw ValidationException::withMessages(['user' => 'User context missing.']);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (! $package->paddle_price_id) {
|
||||||
|
throw ValidationException::withMessages(['package_id' => 'Package is not linked to a Paddle price.']);
|
||||||
|
}
|
||||||
|
|
||||||
$session = $this->sessions->createOrResume($user, $package, [
|
$session = $this->sessions->createOrResume($user, $package, [
|
||||||
'tenant' => $tenant,
|
'tenant' => $tenant,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
$this->sessions->selectProvider($session, CheckoutSession::PROVIDER_PAYPAL);
|
$this->sessions->selectProvider($session, CheckoutSession::PROVIDER_PADDLE);
|
||||||
|
|
||||||
$now = now();
|
$now = now();
|
||||||
|
|
||||||
@@ -202,56 +201,30 @@ class PackageController extends Controller
|
|||||||
'legal_version' => config('app.legal_version', $now->toDateString()),
|
'legal_version' => config('app.legal_version', $now->toDateString()),
|
||||||
])->save();
|
])->save();
|
||||||
|
|
||||||
$successUrl = $request->input('success_url') ?? $request->input('return_url');
|
$payload = [
|
||||||
$cancelUrl = $request->input('cancel_url') ?? $request->input('return_url');
|
'success_url' => $request->input('success_url'),
|
||||||
$paypalReturnUrl = route('paypal.return', absolute: true);
|
'return_url' => $request->input('return_url'),
|
||||||
|
'metadata' => [
|
||||||
|
'checkout_session_id' => $session->id,
|
||||||
|
'legal_version' => $session->legal_version,
|
||||||
|
'accepted_terms' => true,
|
||||||
|
],
|
||||||
|
];
|
||||||
|
|
||||||
try {
|
$checkout = $this->paddleCheckout->createCheckout($tenant, $package, $payload);
|
||||||
$order = $this->paypalOrders->createOrder($session, $package, [
|
|
||||||
'return_url' => $paypalReturnUrl,
|
|
||||||
'cancel_url' => $paypalReturnUrl,
|
|
||||||
'locale' => $request->input('locale'),
|
|
||||||
'request_id' => $session->id,
|
|
||||||
]);
|
|
||||||
} catch (PayPalException $exception) {
|
|
||||||
Log::warning('PayPal order creation failed (tenant)', [
|
|
||||||
'tenant_id' => $tenant->id,
|
|
||||||
'package_id' => $package->id,
|
|
||||||
'session_id' => $session->id,
|
|
||||||
'message' => $exception->getMessage(),
|
|
||||||
'status' => $exception->status(),
|
|
||||||
]);
|
|
||||||
|
|
||||||
throw ValidationException::withMessages(['paypal' => 'PayPal checkout could not be created.']);
|
|
||||||
}
|
|
||||||
|
|
||||||
$orderId = $order['id'] ?? null;
|
|
||||||
if (! is_string($orderId) || $orderId === '') {
|
|
||||||
throw ValidationException::withMessages(['paypal' => 'PayPal order ID missing.']);
|
|
||||||
}
|
|
||||||
|
|
||||||
$approveUrl = $this->paypalOrders->resolveApproveUrl($order);
|
|
||||||
|
|
||||||
$session->forceFill([
|
$session->forceFill([
|
||||||
'paypal_order_id' => $orderId,
|
'paddle_checkout_id' => $checkout['id'] ?? $session->paddle_checkout_id,
|
||||||
'provider_metadata' => array_merge($session->provider_metadata ?? [], array_filter([
|
'provider_metadata' => array_merge($session->provider_metadata ?? [], array_filter([
|
||||||
'paypal_order_id' => $orderId,
|
'paddle_checkout_id' => $checkout['id'] ?? null,
|
||||||
'paypal_status' => $order['status'] ?? null,
|
'paddle_checkout_url' => $checkout['checkout_url'] ?? null,
|
||||||
'paypal_approve_url' => $approveUrl,
|
'paddle_expires_at' => $checkout['expires_at'] ?? null,
|
||||||
'paypal_success_url' => $successUrl,
|
|
||||||
'paypal_cancel_url' => $cancelUrl,
|
|
||||||
'paypal_created_at' => now()->toIso8601String(),
|
|
||||||
])),
|
])),
|
||||||
])->save();
|
])->save();
|
||||||
|
|
||||||
$this->sessions->markRequiresCustomerAction($session, 'paypal_approval');
|
return response()->json(array_merge($checkout, [
|
||||||
|
|
||||||
return response()->json([
|
|
||||||
'order_id' => $orderId,
|
|
||||||
'approve_url' => $approveUrl,
|
|
||||||
'status' => $order['status'] ?? null,
|
|
||||||
'checkout_session_id' => $session->id,
|
'checkout_session_id' => $session->id,
|
||||||
]);
|
]));
|
||||||
}
|
}
|
||||||
|
|
||||||
public function checkoutSessionStatus(CheckoutSessionStatusRequest $request, CheckoutSession $session): JsonResponse
|
public function checkoutSessionStatus(CheckoutSessionStatusRequest $request, CheckoutSession $session): JsonResponse
|
||||||
@@ -266,9 +239,7 @@ class PackageController extends Controller
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
$checkoutUrl = $session->provider === CheckoutSession::PROVIDER_PAYPAL
|
$checkoutUrl = data_get($session->provider_metadata ?? [], 'paddle_checkout_url');
|
||||||
? data_get($session->provider_metadata ?? [], 'paypal_approve_url')
|
|
||||||
: data_get($session->provider_metadata ?? [], 'lemonsqueezy_checkout_url');
|
|
||||||
|
|
||||||
return response()->json([
|
return response()->json([
|
||||||
'status' => $session->status,
|
'status' => $session->status,
|
||||||
@@ -326,57 +297,19 @@ class PackageController extends Controller
|
|||||||
|
|
||||||
private function handlePaidPurchase(Request $request, Package $package, $tenant): JsonResponse
|
private function handlePaidPurchase(Request $request, Package $package, $tenant): JsonResponse
|
||||||
{
|
{
|
||||||
$successUrl = $request->input('success_url') ?? $request->input('return_url');
|
if (! $package->paddle_price_id) {
|
||||||
$cancelUrl = $request->input('cancel_url') ?? $request->input('return_url');
|
throw ValidationException::withMessages(['package_id' => 'Package is not linked to a Paddle price.']);
|
||||||
$paypalReturnUrl = route('paypal.return', absolute: true);
|
|
||||||
|
|
||||||
try {
|
|
||||||
$session = $this->sessions->createOrResume($request->user(), $package, [
|
|
||||||
'tenant' => $tenant,
|
|
||||||
]);
|
|
||||||
$this->sessions->selectProvider($session, CheckoutSession::PROVIDER_PAYPAL);
|
|
||||||
|
|
||||||
$order = $this->paypalOrders->createOrder($session, $package, [
|
|
||||||
'return_url' => $paypalReturnUrl,
|
|
||||||
'cancel_url' => $paypalReturnUrl,
|
|
||||||
'locale' => $request->input('locale'),
|
|
||||||
'request_id' => $session->id,
|
|
||||||
]);
|
|
||||||
} catch (PayPalException $exception) {
|
|
||||||
Log::warning('PayPal order creation failed (purchase)', [
|
|
||||||
'tenant_id' => $tenant->id,
|
|
||||||
'package_id' => $package->id,
|
|
||||||
'message' => $exception->getMessage(),
|
|
||||||
'status' => $exception->status(),
|
|
||||||
]);
|
|
||||||
|
|
||||||
throw ValidationException::withMessages(['paypal' => 'PayPal checkout could not be created.']);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
$orderId = $order['id'] ?? null;
|
$checkout = $this->paddleCheckout->createCheckout($tenant, $package, [
|
||||||
if (! is_string($orderId) || $orderId === '') {
|
'success_url' => $request->input('success_url'),
|
||||||
throw ValidationException::withMessages(['paypal' => 'PayPal order ID missing.']);
|
'return_url' => $request->input('return_url'),
|
||||||
}
|
'metadata' => array_filter([
|
||||||
|
'type' => $request->input('type'),
|
||||||
return response()->json([
|
'event_id' => $request->input('event_id'),
|
||||||
'order_id' => $orderId,
|
]),
|
||||||
'approve_url' => $this->paypalOrders->resolveApproveUrl($order),
|
|
||||||
'status' => $order['status'] ?? null,
|
|
||||||
'return_url' => $successUrl,
|
|
||||||
'cancel_url' => $cancelUrl,
|
|
||||||
]);
|
]);
|
||||||
}
|
|
||||||
|
|
||||||
private function canCheckoutPackage(Package $package, string $provider): bool
|
return response()->json($checkout);
|
||||||
{
|
|
||||||
if ((float) $package->price <= 0) {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
if ($provider === CheckoutSession::PROVIDER_LEMONSQUEEZY) {
|
|
||||||
return filled($package->lemonsqueezy_variant_id);
|
|
||||||
}
|
|
||||||
|
|
||||||
return true;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,53 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
namespace App\Http\Controllers\Api\Support;
|
|
||||||
|
|
||||||
use App\Http\Controllers\Controller;
|
|
||||||
use App\Http\Requests\Support\SupportGuestPolicyRequest;
|
|
||||||
use App\Models\GuestPolicySetting;
|
|
||||||
use App\Services\Audit\SuperAdminAuditLogger;
|
|
||||||
use App\Support\SupportApiAuthorizer;
|
|
||||||
use Illuminate\Http\JsonResponse;
|
|
||||||
|
|
||||||
class SupportGuestPolicyController extends Controller
|
|
||||||
{
|
|
||||||
public function show(): JsonResponse
|
|
||||||
{
|
|
||||||
if ($response = SupportApiAuthorizer::authorizeAbilities(request(), ['support:settings'], 'settings')) {
|
|
||||||
return $response;
|
|
||||||
}
|
|
||||||
|
|
||||||
$settings = GuestPolicySetting::current();
|
|
||||||
|
|
||||||
return response()->json([
|
|
||||||
'data' => $settings,
|
|
||||||
]);
|
|
||||||
}
|
|
||||||
|
|
||||||
public function update(SupportGuestPolicyRequest $request): JsonResponse
|
|
||||||
{
|
|
||||||
if ($response = SupportApiAuthorizer::authorizeAbilities($request, ['support:settings'], 'settings')) {
|
|
||||||
return $response;
|
|
||||||
}
|
|
||||||
|
|
||||||
$settings = GuestPolicySetting::query()->firstOrNew(['id' => 1]);
|
|
||||||
|
|
||||||
$settings->fill($request->validated());
|
|
||||||
$settings->save();
|
|
||||||
|
|
||||||
$changed = $settings->getChanges();
|
|
||||||
|
|
||||||
if ($changed !== []) {
|
|
||||||
app(SuperAdminAuditLogger::class)->record(
|
|
||||||
'guest_policy.updated',
|
|
||||||
$settings,
|
|
||||||
SuperAdminAuditLogger::fieldsMetadata(array_keys($changed)),
|
|
||||||
source: static::class
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return response()->json([
|
|
||||||
'data' => $settings->refresh(),
|
|
||||||
]);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,401 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
namespace App\Http\Controllers\Api\Support;
|
|
||||||
|
|
||||||
use App\Enums\DataExportScope;
|
|
||||||
use App\Http\Controllers\Controller;
|
|
||||||
use App\Http\Requests\Support\Resources\SupportResourceFormRequest;
|
|
||||||
use App\Http\Requests\Support\SupportResourceRequest;
|
|
||||||
use App\Jobs\GenerateDataExport;
|
|
||||||
use App\Models\DataExport;
|
|
||||||
use App\Services\Audit\SuperAdminAuditLogger;
|
|
||||||
use App\Support\ApiError;
|
|
||||||
use App\Support\SupportApiAuthorizer;
|
|
||||||
use App\Support\SupportApiRegistry;
|
|
||||||
use Illuminate\Database\Eloquent\Builder;
|
|
||||||
use Illuminate\Database\Eloquent\Model;
|
|
||||||
use Illuminate\Http\JsonResponse;
|
|
||||||
use Illuminate\Http\Request;
|
|
||||||
use Illuminate\Support\Arr;
|
|
||||||
use Illuminate\Support\Facades\Schema;
|
|
||||||
use Illuminate\Support\Facades\Validator;
|
|
||||||
|
|
||||||
class SupportResourceController extends Controller
|
|
||||||
{
|
|
||||||
public function index(Request $request, string $resource): JsonResponse
|
|
||||||
{
|
|
||||||
if ($response = SupportApiAuthorizer::authorizeResource($request, $resource, 'read')) {
|
|
||||||
return $response;
|
|
||||||
}
|
|
||||||
|
|
||||||
$config = SupportApiRegistry::get($resource);
|
|
||||||
if (! $config) {
|
|
||||||
return $this->resourceNotFoundResponse($resource);
|
|
||||||
}
|
|
||||||
|
|
||||||
$modelClass = $config['model'];
|
|
||||||
/** @var Builder $query */
|
|
||||||
$query = $modelClass::query();
|
|
||||||
|
|
||||||
$relations = SupportApiRegistry::withRelations($resource);
|
|
||||||
if ($relations !== []) {
|
|
||||||
$query->with($relations);
|
|
||||||
}
|
|
||||||
|
|
||||||
$this->applySearch($request, $query, $resource);
|
|
||||||
$this->applySorting($request, $query, $resource);
|
|
||||||
|
|
||||||
$perPage = $this->resolvePerPage($request);
|
|
||||||
$paginator = $query->paginate($perPage);
|
|
||||||
|
|
||||||
return response()->json([
|
|
||||||
'data' => $paginator->items(),
|
|
||||||
'meta' => [
|
|
||||||
'page' => $paginator->currentPage(),
|
|
||||||
'per_page' => $paginator->perPage(),
|
|
||||||
'total' => $paginator->total(),
|
|
||||||
'last_page' => $paginator->lastPage(),
|
|
||||||
],
|
|
||||||
]);
|
|
||||||
}
|
|
||||||
|
|
||||||
public function show(Request $request, string $resource, string $record): JsonResponse
|
|
||||||
{
|
|
||||||
if ($response = SupportApiAuthorizer::authorizeResource($request, $resource, 'read')) {
|
|
||||||
return $response;
|
|
||||||
}
|
|
||||||
|
|
||||||
$model = $this->resolveRecord($resource, $record);
|
|
||||||
|
|
||||||
if (! $model) {
|
|
||||||
return $this->resourceNotFoundResponse($resource, $record);
|
|
||||||
}
|
|
||||||
|
|
||||||
return response()->json([
|
|
||||||
'data' => $model,
|
|
||||||
]);
|
|
||||||
}
|
|
||||||
|
|
||||||
public function store(SupportResourceRequest $request, string $resource): JsonResponse
|
|
||||||
{
|
|
||||||
if ($response = SupportApiAuthorizer::authorizeAnyAbility($request, SupportApiRegistry::abilitiesFor($resource, 'write'), 'write')) {
|
|
||||||
return $response;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (! SupportApiRegistry::allowsMutation($resource, 'create')) {
|
|
||||||
return $this->mutationNotAllowedResponse($resource, 'create');
|
|
||||||
}
|
|
||||||
|
|
||||||
$config = SupportApiRegistry::get($resource);
|
|
||||||
if (! $config) {
|
|
||||||
return $this->resourceNotFoundResponse($resource);
|
|
||||||
}
|
|
||||||
|
|
||||||
$modelClass = $config['model'];
|
|
||||||
/** @var Model $model */
|
|
||||||
$model = new $modelClass;
|
|
||||||
|
|
||||||
$payload = $this->validatedPayload($request, $resource, 'create', $model);
|
|
||||||
|
|
||||||
if ($payload instanceof JsonResponse) {
|
|
||||||
return $payload;
|
|
||||||
}
|
|
||||||
|
|
||||||
if ($payload === []) {
|
|
||||||
return $this->emptyPayloadResponse($resource);
|
|
||||||
}
|
|
||||||
|
|
||||||
if ($resource === 'data-exports') {
|
|
||||||
$payload = $this->normalizeDataExportPayload($request, $payload);
|
|
||||||
}
|
|
||||||
|
|
||||||
$record = $modelClass::query()->create($payload);
|
|
||||||
|
|
||||||
app(SuperAdminAuditLogger::class)->record(
|
|
||||||
SupportApiRegistry::auditAction($resource, 'created'),
|
|
||||||
$record,
|
|
||||||
SuperAdminAuditLogger::fieldsMetadata($payload),
|
|
||||||
actor: $request->user(),
|
|
||||||
source: static::class
|
|
||||||
);
|
|
||||||
|
|
||||||
if ($resource === 'data-exports') {
|
|
||||||
GenerateDataExport::dispatch($record->id);
|
|
||||||
}
|
|
||||||
|
|
||||||
return response()->json([
|
|
||||||
'data' => $record,
|
|
||||||
], 201);
|
|
||||||
}
|
|
||||||
|
|
||||||
public function update(SupportResourceRequest $request, string $resource, string $record): JsonResponse
|
|
||||||
{
|
|
||||||
if ($response = SupportApiAuthorizer::authorizeAnyAbility($request, SupportApiRegistry::abilitiesFor($resource, 'write'), 'write')) {
|
|
||||||
return $response;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (! SupportApiRegistry::allowsMutation($resource, 'update')) {
|
|
||||||
return $this->mutationNotAllowedResponse($resource, 'update');
|
|
||||||
}
|
|
||||||
|
|
||||||
$model = $this->resolveRecord($resource, $record);
|
|
||||||
|
|
||||||
if (! $model) {
|
|
||||||
return $this->resourceNotFoundResponse($resource, $record);
|
|
||||||
}
|
|
||||||
|
|
||||||
$payload = $this->validatedPayload($request, $resource, 'update', $model);
|
|
||||||
|
|
||||||
if ($payload instanceof JsonResponse) {
|
|
||||||
return $payload;
|
|
||||||
}
|
|
||||||
|
|
||||||
if ($payload === []) {
|
|
||||||
return $this->emptyPayloadResponse($resource);
|
|
||||||
}
|
|
||||||
|
|
||||||
$model->fill($payload);
|
|
||||||
$model->save();
|
|
||||||
|
|
||||||
app(SuperAdminAuditLogger::class)->record(
|
|
||||||
SupportApiRegistry::auditAction($resource, 'updated'),
|
|
||||||
$model,
|
|
||||||
SuperAdminAuditLogger::fieldsMetadata($payload),
|
|
||||||
actor: $request->user(),
|
|
||||||
source: static::class
|
|
||||||
);
|
|
||||||
|
|
||||||
return response()->json([
|
|
||||||
'data' => $model->refresh(),
|
|
||||||
]);
|
|
||||||
}
|
|
||||||
|
|
||||||
public function destroy(Request $request, string $resource, string $record): JsonResponse
|
|
||||||
{
|
|
||||||
if ($response = SupportApiAuthorizer::authorizeAnyAbility($request, SupportApiRegistry::abilitiesFor($resource, 'write'), 'write')) {
|
|
||||||
return $response;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (! SupportApiRegistry::allowsMutation($resource, 'delete')) {
|
|
||||||
return $this->mutationNotAllowedResponse($resource, 'delete');
|
|
||||||
}
|
|
||||||
|
|
||||||
$model = $this->resolveRecord($resource, $record);
|
|
||||||
|
|
||||||
if (! $model) {
|
|
||||||
return $this->resourceNotFoundResponse($resource, $record);
|
|
||||||
}
|
|
||||||
|
|
||||||
$model->delete();
|
|
||||||
|
|
||||||
app(SuperAdminAuditLogger::class)->record(
|
|
||||||
SupportApiRegistry::auditAction($resource, 'deleted'),
|
|
||||||
$model,
|
|
||||||
SuperAdminAuditLogger::fieldsMetadata([]),
|
|
||||||
actor: $request->user(),
|
|
||||||
source: static::class
|
|
||||||
);
|
|
||||||
|
|
||||||
return response()->json(['ok' => true]);
|
|
||||||
}
|
|
||||||
|
|
||||||
private function resolveRecord(string $resource, string $record): ?Model
|
|
||||||
{
|
|
||||||
$config = SupportApiRegistry::get($resource);
|
|
||||||
|
|
||||||
if (! $config) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
$modelClass = $config['model'];
|
|
||||||
|
|
||||||
$query = $modelClass::query();
|
|
||||||
|
|
||||||
if (is_numeric($record)) {
|
|
||||||
return $query->find($record);
|
|
||||||
}
|
|
||||||
|
|
||||||
$keyName = (new $modelClass)->getKeyName();
|
|
||||||
|
|
||||||
return $query->where($keyName, $record)->first();
|
|
||||||
}
|
|
||||||
|
|
||||||
private function validatedPayload(SupportResourceRequest $request, string $resource, string $action, Model $model): array|JsonResponse
|
|
||||||
{
|
|
||||||
$payload = $request->validated('data');
|
|
||||||
|
|
||||||
if (! is_array($payload)) {
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
|
|
||||||
$validationClass = SupportApiRegistry::validationClass($resource, $action);
|
|
||||||
|
|
||||||
if ($validationClass && is_subclass_of($validationClass, SupportResourceFormRequest::class)) {
|
|
||||||
$allowedFields = $validationClass::allowedFields($action);
|
|
||||||
|
|
||||||
if ($allowedFields !== []) {
|
|
||||||
$unexpected = array_diff(array_keys($payload), $allowedFields);
|
|
||||||
if ($unexpected !== []) {
|
|
||||||
return $this->invalidFieldResponse($resource, $unexpected);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
$rules = $validationClass::rulesFor($action, $model);
|
|
||||||
if ($rules !== []) {
|
|
||||||
$payload = Validator::make($payload, $rules)->validate();
|
|
||||||
}
|
|
||||||
|
|
||||||
if ($allowedFields !== []) {
|
|
||||||
$payload = Arr::only($payload, $allowedFields);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
$fillable = $model->getFillable();
|
|
||||||
|
|
||||||
if ($fillable === [] && method_exists($model, 'getGuarded') && $model->getGuarded() !== ['*']) {
|
|
||||||
$columns = Schema::getColumnListing($model->getTable());
|
|
||||||
|
|
||||||
return Arr::only($payload, $columns);
|
|
||||||
}
|
|
||||||
|
|
||||||
if ($fillable === []) {
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
|
|
||||||
return Arr::only($payload, $fillable);
|
|
||||||
}
|
|
||||||
|
|
||||||
private function applySearch(Request $request, Builder $query, string $resource): void
|
|
||||||
{
|
|
||||||
$term = $request->string('search')->trim()->value();
|
|
||||||
|
|
||||||
if ($term === '') {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
$fields = SupportApiRegistry::searchFields($resource);
|
|
||||||
|
|
||||||
if ($fields === []) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
$columns = Schema::getColumnListing($query->getModel()->getTable());
|
|
||||||
$fields = array_values(array_intersect($fields, $columns));
|
|
||||||
|
|
||||||
if ($fields === []) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
$query->where(function (Builder $builder) use ($fields, $term): void {
|
|
||||||
foreach ($fields as $field) {
|
|
||||||
if ($field === 'id' && is_numeric($term)) {
|
|
||||||
$builder->orWhere($field, (int) $term);
|
|
||||||
} else {
|
|
||||||
$builder->orWhere($field, 'like', "%{$term}%");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
private function applySorting(Request $request, Builder $query, string $resource): void
|
|
||||||
{
|
|
||||||
$sort = $request->string('sort')->trim()->value();
|
|
||||||
|
|
||||||
if ($sort === '') {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
$direction = 'asc';
|
|
||||||
$field = $sort;
|
|
||||||
|
|
||||||
if (str_starts_with($sort, '-')) {
|
|
||||||
$direction = 'desc';
|
|
||||||
$field = ltrim($sort, '-');
|
|
||||||
}
|
|
||||||
|
|
||||||
$allowed = SupportApiRegistry::searchFields($resource);
|
|
||||||
$allowed[] = 'id';
|
|
||||||
|
|
||||||
$columns = Schema::getColumnListing($query->getModel()->getTable());
|
|
||||||
$allowed = array_values(array_intersect($allowed, $columns));
|
|
||||||
|
|
||||||
if (! in_array($field, $allowed, true)) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
$query->orderBy($field, $direction);
|
|
||||||
}
|
|
||||||
|
|
||||||
private function resolvePerPage(Request $request): int
|
|
||||||
{
|
|
||||||
$default = (int) config('support-api.pagination.default_per_page', 50);
|
|
||||||
$max = (int) config('support-api.pagination.max_per_page', 200);
|
|
||||||
|
|
||||||
$perPage = (int) $request->input('per_page', $default);
|
|
||||||
|
|
||||||
if ($perPage < 1) {
|
|
||||||
$perPage = $default;
|
|
||||||
}
|
|
||||||
|
|
||||||
return min($perPage, $max);
|
|
||||||
}
|
|
||||||
|
|
||||||
private function mutationNotAllowedResponse(string $resource, string $action): JsonResponse
|
|
||||||
{
|
|
||||||
return ApiError::response(
|
|
||||||
'support_mutation_not_allowed',
|
|
||||||
'Mutation Not Allowed',
|
|
||||||
"{$resource} does not allow {$action} operations in support API.",
|
|
||||||
403
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
private function emptyPayloadResponse(string $resource): JsonResponse
|
|
||||||
{
|
|
||||||
return ApiError::response(
|
|
||||||
'support_invalid_payload',
|
|
||||||
'Invalid Payload',
|
|
||||||
"No mutable fields provided for {$resource}.",
|
|
||||||
422
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
private function invalidFieldResponse(string $resource, array $fields): JsonResponse
|
|
||||||
{
|
|
||||||
return ApiError::response(
|
|
||||||
'support_invalid_fields',
|
|
||||||
'Invalid Fields',
|
|
||||||
"Unsupported fields provided for {$resource}.",
|
|
||||||
422,
|
|
||||||
[
|
|
||||||
'fields' => array_values($fields),
|
|
||||||
]
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
private function resourceNotFoundResponse(string $resource, ?string $record = null): JsonResponse
|
|
||||||
{
|
|
||||||
$message = $record
|
|
||||||
? "{$resource} record not found."
|
|
||||||
: "Support resource {$resource} is not registered.";
|
|
||||||
|
|
||||||
return ApiError::response(
|
|
||||||
'support_resource_not_found',
|
|
||||||
'Not Found',
|
|
||||||
$message,
|
|
||||||
404
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
private function normalizeDataExportPayload(Request $request, array $payload): array
|
|
||||||
{
|
|
||||||
$payload['user_id'] = $request->user()?->id;
|
|
||||||
$payload['status'] = DataExport::STATUS_PENDING;
|
|
||||||
|
|
||||||
if (($payload['scope'] ?? null) !== DataExportScope::EVENT->value) {
|
|
||||||
$payload['event_id'] = null;
|
|
||||||
}
|
|
||||||
|
|
||||||
return $payload;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,411 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
namespace App\Http\Controllers\Api\Support;
|
|
||||||
|
|
||||||
use App\Http\Controllers\Controller;
|
|
||||||
use App\Http\Requests\Support\Tenant\SupportTenantAddPackageRequest;
|
|
||||||
use App\Http\Requests\Support\Tenant\SupportTenantScheduleDeletionRequest;
|
|
||||||
use App\Http\Requests\Support\Tenant\SupportTenantSetGracePeriodRequest;
|
|
||||||
use App\Http\Requests\Support\Tenant\SupportTenantUpdateLimitsRequest;
|
|
||||||
use App\Http\Requests\Support\Tenant\SupportTenantUpdateSubscriptionRequest;
|
|
||||||
use App\Jobs\AnonymizeAccount;
|
|
||||||
use App\Models\Package;
|
|
||||||
use App\Models\PackagePurchase;
|
|
||||||
use App\Models\Tenant;
|
|
||||||
use App\Models\TenantPackage;
|
|
||||||
use App\Notifications\InactiveTenantDeletionWarning;
|
|
||||||
use App\Services\Audit\SuperAdminAuditLogger;
|
|
||||||
use App\Services\Tenant\TenantLifecycleLogger;
|
|
||||||
use App\Support\SupportApiAuthorizer;
|
|
||||||
use Carbon\Carbon;
|
|
||||||
use Filament\Notifications\Notification;
|
|
||||||
use Illuminate\Http\JsonResponse;
|
|
||||||
use Illuminate\Support\Facades\Notification as NotificationFacade;
|
|
||||||
|
|
||||||
class SupportTenantActionsController extends Controller
|
|
||||||
{
|
|
||||||
public function activate(Tenant $tenant): JsonResponse
|
|
||||||
{
|
|
||||||
if ($response = $this->authorizeAction('tenants', 'actions')) {
|
|
||||||
return $response;
|
|
||||||
}
|
|
||||||
|
|
||||||
$updated = $tenant->update(['is_active' => true]);
|
|
||||||
|
|
||||||
app(TenantLifecycleLogger::class)->record(
|
|
||||||
$tenant,
|
|
||||||
'activated',
|
|
||||||
actor: auth()->user()
|
|
||||||
);
|
|
||||||
|
|
||||||
app(SuperAdminAuditLogger::class)->record(
|
|
||||||
'tenant.activated',
|
|
||||||
$tenant,
|
|
||||||
SuperAdminAuditLogger::fieldsMetadata(['is_active']),
|
|
||||||
source: static::class
|
|
||||||
);
|
|
||||||
|
|
||||||
return response()->json(['ok' => $updated]);
|
|
||||||
}
|
|
||||||
|
|
||||||
public function deactivate(Tenant $tenant): JsonResponse
|
|
||||||
{
|
|
||||||
if ($response = $this->authorizeAction('tenants', 'actions')) {
|
|
||||||
return $response;
|
|
||||||
}
|
|
||||||
|
|
||||||
$updated = $tenant->update(['is_active' => false]);
|
|
||||||
|
|
||||||
app(TenantLifecycleLogger::class)->record(
|
|
||||||
$tenant,
|
|
||||||
'deactivated',
|
|
||||||
actor: auth()->user()
|
|
||||||
);
|
|
||||||
|
|
||||||
app(SuperAdminAuditLogger::class)->record(
|
|
||||||
'tenant.deactivated',
|
|
||||||
$tenant,
|
|
||||||
SuperAdminAuditLogger::fieldsMetadata(['is_active']),
|
|
||||||
source: static::class
|
|
||||||
);
|
|
||||||
|
|
||||||
return response()->json(['ok' => $updated]);
|
|
||||||
}
|
|
||||||
|
|
||||||
public function suspend(Tenant $tenant): JsonResponse
|
|
||||||
{
|
|
||||||
if ($response = $this->authorizeAction('tenants', 'actions')) {
|
|
||||||
return $response;
|
|
||||||
}
|
|
||||||
|
|
||||||
$updated = $tenant->update(['is_suspended' => true]);
|
|
||||||
|
|
||||||
app(TenantLifecycleLogger::class)->record(
|
|
||||||
$tenant,
|
|
||||||
'suspended',
|
|
||||||
actor: auth()->user()
|
|
||||||
);
|
|
||||||
|
|
||||||
app(SuperAdminAuditLogger::class)->record(
|
|
||||||
'tenant.suspended',
|
|
||||||
$tenant,
|
|
||||||
SuperAdminAuditLogger::fieldsMetadata(['is_suspended']),
|
|
||||||
source: static::class
|
|
||||||
);
|
|
||||||
|
|
||||||
return response()->json(['ok' => $updated]);
|
|
||||||
}
|
|
||||||
|
|
||||||
public function unsuspend(Tenant $tenant): JsonResponse
|
|
||||||
{
|
|
||||||
if ($response = $this->authorizeAction('tenants', 'actions')) {
|
|
||||||
return $response;
|
|
||||||
}
|
|
||||||
|
|
||||||
$updated = $tenant->update(['is_suspended' => false]);
|
|
||||||
|
|
||||||
app(TenantLifecycleLogger::class)->record(
|
|
||||||
$tenant,
|
|
||||||
'unsuspended',
|
|
||||||
actor: auth()->user()
|
|
||||||
);
|
|
||||||
|
|
||||||
app(SuperAdminAuditLogger::class)->record(
|
|
||||||
'tenant.unsuspended',
|
|
||||||
$tenant,
|
|
||||||
SuperAdminAuditLogger::fieldsMetadata(['is_suspended']),
|
|
||||||
source: static::class
|
|
||||||
);
|
|
||||||
|
|
||||||
return response()->json(['ok' => $updated]);
|
|
||||||
}
|
|
||||||
|
|
||||||
public function scheduleDeletion(SupportTenantScheduleDeletionRequest $request, Tenant $tenant): JsonResponse
|
|
||||||
{
|
|
||||||
if ($response = $this->authorizeAction('tenants', 'actions')) {
|
|
||||||
return $response;
|
|
||||||
}
|
|
||||||
|
|
||||||
$plannedDeletion = Carbon::parse($request->string('pending_deletion_at')->value());
|
|
||||||
$update = [
|
|
||||||
'pending_deletion_at' => $plannedDeletion,
|
|
||||||
];
|
|
||||||
|
|
||||||
if ($request->boolean('send_warning', true)) {
|
|
||||||
$email = $tenant->contact_email
|
|
||||||
?? $tenant->email
|
|
||||||
?? $tenant->user?->email;
|
|
||||||
|
|
||||||
if ($email) {
|
|
||||||
NotificationFacade::route('mail', $email)
|
|
||||||
->notify(new InactiveTenantDeletionWarning($tenant, $plannedDeletion));
|
|
||||||
$update['deletion_warning_sent_at'] = now();
|
|
||||||
} else {
|
|
||||||
Notification::make()
|
|
||||||
->danger()
|
|
||||||
->title(__('admin.tenants.actions.send_warning_missing_title'))
|
|
||||||
->body(__('admin.tenants.actions.send_warning_missing_body'))
|
|
||||||
->send();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
$tenant->forceFill($update)->save();
|
|
||||||
|
|
||||||
app(TenantLifecycleLogger::class)->record(
|
|
||||||
$tenant,
|
|
||||||
'deletion_scheduled',
|
|
||||||
[
|
|
||||||
'pending_deletion_at' => $plannedDeletion->toDateTimeString(),
|
|
||||||
'send_warning' => $request->boolean('send_warning', true),
|
|
||||||
],
|
|
||||||
auth()->user()
|
|
||||||
);
|
|
||||||
|
|
||||||
app(SuperAdminAuditLogger::class)->record(
|
|
||||||
'tenant.deletion_scheduled',
|
|
||||||
$tenant,
|
|
||||||
SuperAdminAuditLogger::fieldsMetadata($request->validated()),
|
|
||||||
source: static::class
|
|
||||||
);
|
|
||||||
|
|
||||||
return response()->json(['ok' => true]);
|
|
||||||
}
|
|
||||||
|
|
||||||
public function cancelDeletion(Tenant $tenant): JsonResponse
|
|
||||||
{
|
|
||||||
if ($response = $this->authorizeAction('tenants', 'actions')) {
|
|
||||||
return $response;
|
|
||||||
}
|
|
||||||
|
|
||||||
$previous = $tenant->pending_deletion_at?->toDateTimeString();
|
|
||||||
|
|
||||||
$tenant->forceFill([
|
|
||||||
'pending_deletion_at' => null,
|
|
||||||
'deletion_warning_sent_at' => null,
|
|
||||||
])->save();
|
|
||||||
|
|
||||||
app(TenantLifecycleLogger::class)->record(
|
|
||||||
$tenant,
|
|
||||||
'deletion_cancelled',
|
|
||||||
['pending_deletion_at' => $previous],
|
|
||||||
auth()->user()
|
|
||||||
);
|
|
||||||
|
|
||||||
app(SuperAdminAuditLogger::class)->record(
|
|
||||||
'tenant.deletion_cancelled',
|
|
||||||
$tenant,
|
|
||||||
SuperAdminAuditLogger::fieldsMetadata(['pending_deletion_at', 'deletion_warning_sent_at']),
|
|
||||||
source: static::class
|
|
||||||
);
|
|
||||||
|
|
||||||
return response()->json(['ok' => true]);
|
|
||||||
}
|
|
||||||
|
|
||||||
public function anonymize(Tenant $tenant): JsonResponse
|
|
||||||
{
|
|
||||||
if ($response = $this->authorizeAction('tenants', 'actions')) {
|
|
||||||
return $response;
|
|
||||||
}
|
|
||||||
|
|
||||||
AnonymizeAccount::dispatch(null, $tenant->id);
|
|
||||||
|
|
||||||
app(TenantLifecycleLogger::class)->record(
|
|
||||||
$tenant,
|
|
||||||
'anonymize_requested',
|
|
||||||
actor: auth()->user()
|
|
||||||
);
|
|
||||||
|
|
||||||
app(SuperAdminAuditLogger::class)->record(
|
|
||||||
'tenant.anonymize_requested',
|
|
||||||
$tenant,
|
|
||||||
SuperAdminAuditLogger::fieldsMetadata([]),
|
|
||||||
source: static::class
|
|
||||||
);
|
|
||||||
|
|
||||||
return response()->json(['ok' => true]);
|
|
||||||
}
|
|
||||||
|
|
||||||
public function addPackage(SupportTenantAddPackageRequest $request, Tenant $tenant): JsonResponse
|
|
||||||
{
|
|
||||||
if ($response = $this->authorizeAction('tenants', 'actions')) {
|
|
||||||
return $response;
|
|
||||||
}
|
|
||||||
|
|
||||||
$package = Package::query()->find($request->integer('package_id'));
|
|
||||||
|
|
||||||
TenantPackage::query()->create([
|
|
||||||
'tenant_id' => $tenant->id,
|
|
||||||
'package_id' => $request->integer('package_id'),
|
|
||||||
'expires_at' => $request->date('expires_at'),
|
|
||||||
'active' => true,
|
|
||||||
'price' => $package?->price ?? 0,
|
|
||||||
'reason' => $request->string('reason')->value(),
|
|
||||||
]);
|
|
||||||
|
|
||||||
PackagePurchase::query()->create([
|
|
||||||
'tenant_id' => $tenant->id,
|
|
||||||
'package_id' => $request->integer('package_id'),
|
|
||||||
'provider' => 'manual',
|
|
||||||
'provider_id' => 'manual',
|
|
||||||
'type' => 'reseller_subscription',
|
|
||||||
'price' => 0,
|
|
||||||
'metadata' => ['reason' => $request->string('reason')->value() ?: 'manual assignment'],
|
|
||||||
]);
|
|
||||||
|
|
||||||
app(SuperAdminAuditLogger::class)->record(
|
|
||||||
'tenant.package_added',
|
|
||||||
$tenant,
|
|
||||||
SuperAdminAuditLogger::fieldsMetadata($request->validated()),
|
|
||||||
source: static::class
|
|
||||||
);
|
|
||||||
|
|
||||||
return response()->json(['ok' => true]);
|
|
||||||
}
|
|
||||||
|
|
||||||
public function updateLimits(SupportTenantUpdateLimitsRequest $request, Tenant $tenant): JsonResponse
|
|
||||||
{
|
|
||||||
if ($response = $this->authorizeAction('tenants', 'actions')) {
|
|
||||||
return $response;
|
|
||||||
}
|
|
||||||
|
|
||||||
$before = [
|
|
||||||
'max_photos_per_event' => $tenant->max_photos_per_event,
|
|
||||||
'max_storage_mb' => $tenant->max_storage_mb,
|
|
||||||
];
|
|
||||||
|
|
||||||
$tenant->forceFill([
|
|
||||||
'max_photos_per_event' => $request->integer('max_photos_per_event'),
|
|
||||||
'max_storage_mb' => $request->integer('max_storage_mb'),
|
|
||||||
])->save();
|
|
||||||
|
|
||||||
$after = [
|
|
||||||
'max_photos_per_event' => $tenant->max_photos_per_event,
|
|
||||||
'max_storage_mb' => $tenant->max_storage_mb,
|
|
||||||
];
|
|
||||||
|
|
||||||
app(TenantLifecycleLogger::class)->record(
|
|
||||||
$tenant,
|
|
||||||
'limits_updated',
|
|
||||||
[
|
|
||||||
'before' => $before,
|
|
||||||
'after' => $after,
|
|
||||||
'note' => $request->string('note')->value(),
|
|
||||||
],
|
|
||||||
auth()->user()
|
|
||||||
);
|
|
||||||
|
|
||||||
app(SuperAdminAuditLogger::class)->record(
|
|
||||||
'tenant.limits_updated',
|
|
||||||
$tenant,
|
|
||||||
SuperAdminAuditLogger::fieldsMetadata($request->validated()),
|
|
||||||
source: static::class
|
|
||||||
);
|
|
||||||
|
|
||||||
return response()->json(['ok' => true]);
|
|
||||||
}
|
|
||||||
|
|
||||||
public function updateSubscriptionExpiresAt(SupportTenantUpdateSubscriptionRequest $request, Tenant $tenant): JsonResponse
|
|
||||||
{
|
|
||||||
if ($response = $this->authorizeAction('tenants', 'actions')) {
|
|
||||||
return $response;
|
|
||||||
}
|
|
||||||
|
|
||||||
$before = [
|
|
||||||
'subscription_expires_at' => optional($tenant->subscription_expires_at)->toDateTimeString(),
|
|
||||||
];
|
|
||||||
|
|
||||||
$tenant->forceFill([
|
|
||||||
'subscription_expires_at' => $request->date('subscription_expires_at'),
|
|
||||||
])->save();
|
|
||||||
|
|
||||||
$after = [
|
|
||||||
'subscription_expires_at' => optional($tenant->subscription_expires_at)->toDateTimeString(),
|
|
||||||
];
|
|
||||||
|
|
||||||
app(TenantLifecycleLogger::class)->record(
|
|
||||||
$tenant,
|
|
||||||
'subscription_expires_at_updated',
|
|
||||||
[
|
|
||||||
'before' => $before,
|
|
||||||
'after' => $after,
|
|
||||||
'note' => $request->string('note')->value(),
|
|
||||||
],
|
|
||||||
auth()->user()
|
|
||||||
);
|
|
||||||
|
|
||||||
app(SuperAdminAuditLogger::class)->record(
|
|
||||||
'tenant.subscription_expires_at_updated',
|
|
||||||
$tenant,
|
|
||||||
SuperAdminAuditLogger::fieldsMetadata($request->validated()),
|
|
||||||
source: static::class
|
|
||||||
);
|
|
||||||
|
|
||||||
return response()->json(['ok' => true]);
|
|
||||||
}
|
|
||||||
|
|
||||||
public function setGracePeriod(SupportTenantSetGracePeriodRequest $request, Tenant $tenant): JsonResponse
|
|
||||||
{
|
|
||||||
if ($response = $this->authorizeAction('tenants', 'actions')) {
|
|
||||||
return $response;
|
|
||||||
}
|
|
||||||
|
|
||||||
$tenant->forceFill([
|
|
||||||
'grace_period_ends_at' => $request->date('grace_period_ends_at'),
|
|
||||||
])->save();
|
|
||||||
|
|
||||||
app(TenantLifecycleLogger::class)->record(
|
|
||||||
$tenant,
|
|
||||||
'grace_period_set',
|
|
||||||
[
|
|
||||||
'grace_period_ends_at' => optional($tenant->grace_period_ends_at)->toDateTimeString(),
|
|
||||||
'note' => $request->string('note')->value(),
|
|
||||||
],
|
|
||||||
auth()->user()
|
|
||||||
);
|
|
||||||
|
|
||||||
app(SuperAdminAuditLogger::class)->record(
|
|
||||||
'tenant.grace_period_set',
|
|
||||||
$tenant,
|
|
||||||
SuperAdminAuditLogger::fieldsMetadata($request->validated()),
|
|
||||||
source: static::class
|
|
||||||
);
|
|
||||||
|
|
||||||
return response()->json(['ok' => true]);
|
|
||||||
}
|
|
||||||
|
|
||||||
public function clearGracePeriod(Tenant $tenant): JsonResponse
|
|
||||||
{
|
|
||||||
if ($response = $this->authorizeAction('tenants', 'actions')) {
|
|
||||||
return $response;
|
|
||||||
}
|
|
||||||
|
|
||||||
$previous = $tenant->grace_period_ends_at?->toDateTimeString();
|
|
||||||
|
|
||||||
$tenant->forceFill([
|
|
||||||
'grace_period_ends_at' => null,
|
|
||||||
])->save();
|
|
||||||
|
|
||||||
app(TenantLifecycleLogger::class)->record(
|
|
||||||
$tenant,
|
|
||||||
'grace_period_cleared',
|
|
||||||
[
|
|
||||||
'grace_period_ends_at' => $previous,
|
|
||||||
],
|
|
||||||
auth()->user()
|
|
||||||
);
|
|
||||||
|
|
||||||
app(SuperAdminAuditLogger::class)->record(
|
|
||||||
'tenant.grace_period_cleared',
|
|
||||||
$tenant,
|
|
||||||
SuperAdminAuditLogger::fieldsMetadata(['grace_period_ends_at']),
|
|
||||||
source: static::class
|
|
||||||
);
|
|
||||||
|
|
||||||
return response()->json(['ok' => true]);
|
|
||||||
}
|
|
||||||
|
|
||||||
private function authorizeAction(string $resource, string $action): ?JsonResponse
|
|
||||||
{
|
|
||||||
return SupportApiAuthorizer::authorizeResource(request(), $resource, $action);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,103 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
namespace App\Http\Controllers\Api\Support;
|
|
||||||
|
|
||||||
use App\Http\Controllers\Controller;
|
|
||||||
use App\Http\Requests\Support\SupportTokenRequest;
|
|
||||||
use App\Models\User;
|
|
||||||
use Illuminate\Http\JsonResponse;
|
|
||||||
use Illuminate\Http\Request;
|
|
||||||
use Illuminate\Support\Arr;
|
|
||||||
use Illuminate\Support\Facades\Hash;
|
|
||||||
use Illuminate\Validation\ValidationException;
|
|
||||||
|
|
||||||
class SupportTokenController extends Controller
|
|
||||||
{
|
|
||||||
public function store(SupportTokenRequest $request): JsonResponse
|
|
||||||
{
|
|
||||||
$credentials = $request->credentials();
|
|
||||||
|
|
||||||
$query = User::query();
|
|
||||||
|
|
||||||
if (isset($credentials['email'])) {
|
|
||||||
$query->where('email', $credentials['email']);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (isset($credentials['username'])) {
|
|
||||||
$query->where('username', $credentials['username']);
|
|
||||||
}
|
|
||||||
|
|
||||||
/** @var User|null $user */
|
|
||||||
$user = $query->first();
|
|
||||||
|
|
||||||
if (! $user || ! Hash::check($credentials['password'], (string) $user->password)) {
|
|
||||||
throw ValidationException::withMessages([
|
|
||||||
'login' => [trans('auth.failed')],
|
|
||||||
]);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (! $user->isSuperAdmin()) {
|
|
||||||
throw ValidationException::withMessages([
|
|
||||||
'login' => [trans('auth.not_authorized')],
|
|
||||||
]);
|
|
||||||
}
|
|
||||||
|
|
||||||
$tokenConfig = config('support-api.token');
|
|
||||||
$defaultAbilities = $tokenConfig['default_abilities'] ?? [];
|
|
||||||
$abilities = $credentials['abilities'] ?? $defaultAbilities;
|
|
||||||
|
|
||||||
if ($abilities !== $defaultAbilities) {
|
|
||||||
$abilities = array_values(array_intersect($abilities, $defaultAbilities));
|
|
||||||
}
|
|
||||||
|
|
||||||
if (! in_array('support-admin', $abilities, true)) {
|
|
||||||
$abilities[] = 'support-admin';
|
|
||||||
}
|
|
||||||
|
|
||||||
$tokenName = (string) ($tokenConfig['name'] ?? 'support-api');
|
|
||||||
|
|
||||||
$user->tokens()->where('name', $tokenName)->delete();
|
|
||||||
|
|
||||||
$token = $user->createToken($tokenName, $abilities);
|
|
||||||
|
|
||||||
return response()->json([
|
|
||||||
'token' => $token->plainTextToken,
|
|
||||||
'token_type' => 'Bearer',
|
|
||||||
'abilities' => $abilities,
|
|
||||||
'user' => Arr::only($user->toArray(), [
|
|
||||||
'id',
|
|
||||||
'email',
|
|
||||||
'name',
|
|
||||||
'role',
|
|
||||||
'tenant_id',
|
|
||||||
]),
|
|
||||||
]);
|
|
||||||
}
|
|
||||||
|
|
||||||
public function destroy(Request $request): JsonResponse
|
|
||||||
{
|
|
||||||
$token = $request->user()?->currentAccessToken();
|
|
||||||
|
|
||||||
if ($token) {
|
|
||||||
$token->delete();
|
|
||||||
}
|
|
||||||
|
|
||||||
return response()->json(['ok' => true]);
|
|
||||||
}
|
|
||||||
|
|
||||||
public function me(Request $request): JsonResponse
|
|
||||||
{
|
|
||||||
$user = $request->user();
|
|
||||||
|
|
||||||
return response()->json([
|
|
||||||
'user' => $user ? Arr::only($user->toArray(), [
|
|
||||||
'id',
|
|
||||||
'name',
|
|
||||||
'email',
|
|
||||||
'role',
|
|
||||||
'tenant_id',
|
|
||||||
]) : null,
|
|
||||||
'abilities' => $user?->currentAccessToken()?->abilities ?? [],
|
|
||||||
]);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,52 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
namespace App\Http\Controllers\Api\Support;
|
|
||||||
|
|
||||||
use App\Http\Controllers\Controller;
|
|
||||||
use App\Http\Requests\Support\SupportWatermarkSettingsRequest;
|
|
||||||
use App\Models\WatermarkSetting;
|
|
||||||
use App\Services\Audit\SuperAdminAuditLogger;
|
|
||||||
use App\Support\SupportApiAuthorizer;
|
|
||||||
use Illuminate\Http\JsonResponse;
|
|
||||||
|
|
||||||
class SupportWatermarkSettingsController extends Controller
|
|
||||||
{
|
|
||||||
public function show(): JsonResponse
|
|
||||||
{
|
|
||||||
if ($response = SupportApiAuthorizer::authorizeAbilities(request(), ['support:settings'], 'settings')) {
|
|
||||||
return $response;
|
|
||||||
}
|
|
||||||
|
|
||||||
$settings = WatermarkSetting::query()->first();
|
|
||||||
|
|
||||||
return response()->json([
|
|
||||||
'data' => $settings,
|
|
||||||
]);
|
|
||||||
}
|
|
||||||
|
|
||||||
public function update(SupportWatermarkSettingsRequest $request): JsonResponse
|
|
||||||
{
|
|
||||||
if ($response = SupportApiAuthorizer::authorizeAbilities($request, ['support:settings'], 'settings')) {
|
|
||||||
return $response;
|
|
||||||
}
|
|
||||||
|
|
||||||
$settings = WatermarkSetting::query()->firstOrNew([]);
|
|
||||||
$settings->fill($request->validated());
|
|
||||||
$settings->save();
|
|
||||||
|
|
||||||
$changed = $settings->getChanges();
|
|
||||||
|
|
||||||
if ($changed !== []) {
|
|
||||||
app(SuperAdminAuditLogger::class)->record(
|
|
||||||
'watermark_settings.updated',
|
|
||||||
$settings,
|
|
||||||
SuperAdminAuditLogger::fieldsMetadata(array_keys($changed)),
|
|
||||||
source: static::class
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return response()->json([
|
|
||||||
'data' => $settings->refresh(),
|
|
||||||
]);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,625 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
namespace App\Http\Controllers\Api\Tenant;
|
|
||||||
|
|
||||||
use App\Http\Controllers\Controller;
|
|
||||||
use App\Http\Requests\Tenant\AiEditIndexRequest;
|
|
||||||
use App\Http\Requests\Tenant\AiEditStoreRequest;
|
|
||||||
use App\Jobs\ProcessAiEditRequest;
|
|
||||||
use App\Models\AiEditRequest;
|
|
||||||
use App\Models\AiProviderRun;
|
|
||||||
use App\Models\AiStyle;
|
|
||||||
use App\Models\AiUsageLedger;
|
|
||||||
use App\Models\Event;
|
|
||||||
use App\Models\Photo;
|
|
||||||
use App\Services\AiEditing\AiBudgetGuardService;
|
|
||||||
use App\Services\AiEditing\AiEditingRuntimeConfig;
|
|
||||||
use App\Services\AiEditing\AiStyleAccessService;
|
|
||||||
use App\Services\AiEditing\AiStylingEntitlementService;
|
|
||||||
use App\Services\AiEditing\EventAiEditingPolicyService;
|
|
||||||
use App\Services\AiEditing\Safety\AiAbuseEscalationService;
|
|
||||||
use App\Services\AiEditing\Safety\AiSafetyPolicyService;
|
|
||||||
use App\Support\ApiError;
|
|
||||||
use App\Support\TenantMemberPermissions;
|
|
||||||
use Illuminate\Http\JsonResponse;
|
|
||||||
use Illuminate\Http\Request;
|
|
||||||
use Illuminate\Support\Arr;
|
|
||||||
use Illuminate\Support\Facades\DB;
|
|
||||||
use Illuminate\Support\Facades\Log;
|
|
||||||
use Illuminate\Support\Facades\Storage;
|
|
||||||
use Illuminate\Support\Str;
|
|
||||||
use Symfony\Component\HttpFoundation\Response;
|
|
||||||
|
|
||||||
class AiEditController extends Controller
|
|
||||||
{
|
|
||||||
public function __construct(
|
|
||||||
private readonly AiSafetyPolicyService $safetyPolicy,
|
|
||||||
private readonly AiEditingRuntimeConfig $runtimeConfig,
|
|
||||||
private readonly AiBudgetGuardService $budgetGuard,
|
|
||||||
private readonly AiStylingEntitlementService $entitlements,
|
|
||||||
private readonly EventAiEditingPolicyService $eventPolicy,
|
|
||||||
private readonly AiStyleAccessService $styleAccess,
|
|
||||||
private readonly AiAbuseEscalationService $abuseEscalation,
|
|
||||||
) {}
|
|
||||||
|
|
||||||
public function index(AiEditIndexRequest $request, string $eventSlug): JsonResponse
|
|
||||||
{
|
|
||||||
$event = $this->resolveTenantEventOrFail($request, $eventSlug);
|
|
||||||
TenantMemberPermissions::ensureEventPermission($request, $event, 'photos:upload');
|
|
||||||
|
|
||||||
$perPage = (int) $request->input('per_page', 20);
|
|
||||||
$status = (string) $request->input('status', '');
|
|
||||||
$safetyState = (string) $request->input('safety_state', '');
|
|
||||||
|
|
||||||
$query = AiEditRequest::query()
|
|
||||||
->with(['style', 'outputs'])
|
|
||||||
->where('event_id', $event->id)
|
|
||||||
->orderByDesc('created_at');
|
|
||||||
|
|
||||||
if ($status !== '') {
|
|
||||||
$query->where('status', $status);
|
|
||||||
}
|
|
||||||
|
|
||||||
if ($safetyState !== '') {
|
|
||||||
$query->where('safety_state', $safetyState);
|
|
||||||
}
|
|
||||||
|
|
||||||
$requests = $query->paginate($perPage);
|
|
||||||
|
|
||||||
return response()->json([
|
|
||||||
'data' => collect($requests->items())->map(fn (AiEditRequest $item) => $this->serializeRequest($item))->values(),
|
|
||||||
'meta' => [
|
|
||||||
'current_page' => $requests->currentPage(),
|
|
||||||
'per_page' => $requests->perPage(),
|
|
||||||
'total' => $requests->total(),
|
|
||||||
'last_page' => $requests->lastPage(),
|
|
||||||
],
|
|
||||||
]);
|
|
||||||
}
|
|
||||||
|
|
||||||
public function styles(Request $request, string $eventSlug): JsonResponse
|
|
||||||
{
|
|
||||||
$event = $this->resolveTenantEventOrFail($request, $eventSlug);
|
|
||||||
TenantMemberPermissions::ensureEventPermission($request, $event, 'photos:upload');
|
|
||||||
|
|
||||||
if (! $this->runtimeConfig->isEnabled()) {
|
|
||||||
return ApiError::response(
|
|
||||||
'feature_disabled',
|
|
||||||
'Feature disabled',
|
|
||||||
$this->runtimeConfig->statusMessage() ?: 'AI editing is currently disabled by platform policy.',
|
|
||||||
Response::HTTP_FORBIDDEN
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
$entitlement = $this->entitlements->resolveForEvent($event);
|
|
||||||
if (! $entitlement['allowed']) {
|
|
||||||
return ApiError::response(
|
|
||||||
'feature_locked',
|
|
||||||
'Feature locked',
|
|
||||||
$this->entitlements->lockedMessage(),
|
|
||||||
Response::HTTP_FORBIDDEN,
|
|
||||||
[
|
|
||||||
'required_feature' => $entitlement['required_feature'],
|
|
||||||
'addon_keys' => $entitlement['addon_keys'],
|
|
||||||
]
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
$styles = AiStyle::query()
|
|
||||||
->where('is_active', true)
|
|
||||||
->orderBy('sort')
|
|
||||||
->orderBy('id')
|
|
||||||
->get();
|
|
||||||
$policy = $this->eventPolicy->resolve($event);
|
|
||||||
$styles = $this->eventPolicy->filterStyles($event, $styles);
|
|
||||||
$styles = $this->styleAccess->filterStylesForEvent($event, $styles);
|
|
||||||
|
|
||||||
return response()->json([
|
|
||||||
'data' => $styles->map(fn (AiStyle $style) => $this->serializeStyle($style))->values(),
|
|
||||||
'meta' => [
|
|
||||||
'required_feature' => $entitlement['required_feature'],
|
|
||||||
'addon_keys' => $entitlement['addon_keys'],
|
|
||||||
'event_enabled' => $policy['enabled'],
|
|
||||||
'allow_custom_prompt' => $policy['allow_custom_prompt'],
|
|
||||||
'allowed_style_keys' => $policy['allowed_style_keys'],
|
|
||||||
'policy_message' => $policy['policy_message'],
|
|
||||||
],
|
|
||||||
]);
|
|
||||||
}
|
|
||||||
|
|
||||||
public function summary(Request $request, string $eventSlug): JsonResponse
|
|
||||||
{
|
|
||||||
$event = $this->resolveTenantEventOrFail($request, $eventSlug);
|
|
||||||
TenantMemberPermissions::ensureEventPermission($request, $event, 'photos:upload');
|
|
||||||
|
|
||||||
$periodStart = now()->startOfMonth();
|
|
||||||
$periodEnd = now()->endOfMonth();
|
|
||||||
$baseQuery = AiEditRequest::query()->where('event_id', $event->id);
|
|
||||||
$statusCounts = (clone $baseQuery)
|
|
||||||
->select('status', DB::raw('count(*) as aggregate'))
|
|
||||||
->groupBy('status')
|
|
||||||
->pluck('aggregate', 'status')
|
|
||||||
->map(fn (mixed $value): int => (int) $value)
|
|
||||||
->all();
|
|
||||||
$safetyCounts = (clone $baseQuery)
|
|
||||||
->select('safety_state', DB::raw('count(*) as aggregate'))
|
|
||||||
->groupBy('safety_state')
|
|
||||||
->pluck('aggregate', 'safety_state')
|
|
||||||
->map(fn (mixed $value): int => (int) $value)
|
|
||||||
->all();
|
|
||||||
|
|
||||||
$lastRequestedAt = (clone $baseQuery)->max('created_at');
|
|
||||||
$total = array_sum($statusCounts);
|
|
||||||
$failedTotal = (int) (($statusCounts[AiEditRequest::STATUS_FAILED] ?? 0) + ($statusCounts[AiEditRequest::STATUS_BLOCKED] ?? 0));
|
|
||||||
$moderationBlockedTotal = (int) ($statusCounts[AiEditRequest::STATUS_BLOCKED] ?? 0);
|
|
||||||
|
|
||||||
$usageQuery = AiUsageLedger::query()
|
|
||||||
->where('tenant_id', $event->tenant_id)
|
|
||||||
->where('event_id', $event->id)
|
|
||||||
->where('recorded_at', '>=', $periodStart)
|
|
||||||
->where('recorded_at', '<=', $periodEnd);
|
|
||||||
$spendUsd = (float) ((clone $usageQuery)->where('entry_type', AiUsageLedger::TYPE_DEBIT)->sum('amount_usd') ?: 0.0);
|
|
||||||
$debitCount = (int) ((clone $usageQuery)->where('entry_type', AiUsageLedger::TYPE_DEBIT)->count());
|
|
||||||
|
|
||||||
$providerRunQuery = AiProviderRun::query()
|
|
||||||
->whereHas('request', fn ($query) => $query->where('event_id', $event->id))
|
|
||||||
->where('created_at', '>=', $periodStart)
|
|
||||||
->where('created_at', '<=', $periodEnd);
|
|
||||||
$providerRunTotal = (int) (clone $providerRunQuery)->count();
|
|
||||||
$providerRunFailed = (int) (clone $providerRunQuery)->where('status', AiProviderRun::STATUS_FAILED)->count();
|
|
||||||
$averageProviderLatencyMs = (int) round((float) ((clone $providerRunQuery)->whereNotNull('duration_ms')->avg('duration_ms') ?: 0.0));
|
|
||||||
|
|
||||||
$failureRate = $total > 0 ? ($failedTotal / $total) : 0.0;
|
|
||||||
$moderationHitRate = $total > 0 ? ($moderationBlockedTotal / $total) : 0.0;
|
|
||||||
$providerFailureRate = $providerRunTotal > 0 ? ($providerRunFailed / $providerRunTotal) : 0.0;
|
|
||||||
|
|
||||||
$failureRateThreshold = (float) config('ai-editing.observability.failure_rate_alert_threshold', 0.35);
|
|
||||||
$latencyWarningThresholdMs = max(500, (int) config('ai-editing.observability.latency_warning_ms', 15000));
|
|
||||||
$budgetDecision = $this->budgetGuard->evaluateForEvent($event);
|
|
||||||
|
|
||||||
return response()->json([
|
|
||||||
'data' => [
|
|
||||||
'event_id' => $event->id,
|
|
||||||
'total' => $total,
|
|
||||||
'status_counts' => $statusCounts,
|
|
||||||
'safety_counts' => $safetyCounts,
|
|
||||||
'failed_total' => $failedTotal,
|
|
||||||
'last_requested_at' => $lastRequestedAt ? (string) \Illuminate\Support\Carbon::parse($lastRequestedAt)->toIso8601String() : null,
|
|
||||||
'usage' => [
|
|
||||||
'period_start' => $periodStart->toDateString(),
|
|
||||||
'period_end' => $periodEnd->toDateString(),
|
|
||||||
'debit_count' => $debitCount,
|
|
||||||
'spend_usd' => round($spendUsd, 5),
|
|
||||||
],
|
|
||||||
'observability' => [
|
|
||||||
'failure_rate' => round($failureRate, 5),
|
|
||||||
'moderation_hit_rate' => round($moderationHitRate, 5),
|
|
||||||
'provider_runs_total' => $providerRunTotal,
|
|
||||||
'provider_runs_failed' => $providerRunFailed,
|
|
||||||
'provider_failure_rate' => round($providerFailureRate, 5),
|
|
||||||
'avg_provider_latency_ms' => $averageProviderLatencyMs,
|
|
||||||
'alerts' => [
|
|
||||||
'failure_rate_threshold_reached' => $failureRate >= $failureRateThreshold && $total >= max(1, (int) config('ai-editing.observability.failure_rate_min_samples', 10)),
|
|
||||||
'latency_threshold_reached' => $averageProviderLatencyMs >= $latencyWarningThresholdMs,
|
|
||||||
],
|
|
||||||
],
|
|
||||||
'budget' => $budgetDecision['budget'],
|
|
||||||
],
|
|
||||||
]);
|
|
||||||
}
|
|
||||||
|
|
||||||
public function store(AiEditStoreRequest $request, string $eventSlug): JsonResponse
|
|
||||||
{
|
|
||||||
$event = $this->resolveTenantEventOrFail($request, $eventSlug);
|
|
||||||
TenantMemberPermissions::ensureEventPermission($request, $event, 'photos:upload');
|
|
||||||
|
|
||||||
$photo = Photo::query()
|
|
||||||
->whereKey((int) $request->input('photo_id'))
|
|
||||||
->where('event_id', $event->id)
|
|
||||||
->first();
|
|
||||||
|
|
||||||
if (! $photo) {
|
|
||||||
return ApiError::response(
|
|
||||||
'photo_not_found',
|
|
||||||
'Photo not found',
|
|
||||||
'The specified photo could not be located for this event.',
|
|
||||||
Response::HTTP_NOT_FOUND
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
$style = $this->resolveStyle($request->input('style_id'), $request->input('style_key'));
|
|
||||||
if (! $style) {
|
|
||||||
return ApiError::response(
|
|
||||||
'style_not_found',
|
|
||||||
'Style not found',
|
|
||||||
'The selected style is not available.',
|
|
||||||
Response::HTTP_UNPROCESSABLE_ENTITY
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (! $this->runtimeConfig->isEnabled()) {
|
|
||||||
return ApiError::response(
|
|
||||||
'feature_disabled',
|
|
||||||
'Feature disabled',
|
|
||||||
$this->runtimeConfig->statusMessage() ?: 'AI editing is currently disabled by platform policy.',
|
|
||||||
Response::HTTP_FORBIDDEN
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
$entitlement = $this->entitlements->resolveForEvent($event);
|
|
||||||
if (! $entitlement['allowed']) {
|
|
||||||
return ApiError::response(
|
|
||||||
'feature_locked',
|
|
||||||
'Feature locked',
|
|
||||||
$this->entitlements->lockedMessage(),
|
|
||||||
Response::HTTP_FORBIDDEN,
|
|
||||||
[
|
|
||||||
'required_feature' => $entitlement['required_feature'],
|
|
||||||
'addon_keys' => $entitlement['addon_keys'],
|
|
||||||
]
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
$policy = $this->eventPolicy->resolve($event);
|
|
||||||
if (! $policy['enabled']) {
|
|
||||||
return ApiError::response(
|
|
||||||
'event_feature_disabled',
|
|
||||||
'Feature disabled for this event',
|
|
||||||
$policy['policy_message'] ?? 'AI editing is disabled for this event.',
|
|
||||||
Response::HTTP_FORBIDDEN
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
$budgetDecision = $this->budgetGuard->evaluateForEvent($event);
|
|
||||||
if (! $budgetDecision['allowed']) {
|
|
||||||
return ApiError::response(
|
|
||||||
$budgetDecision['reason_code'] ?? 'budget_hard_cap_reached',
|
|
||||||
'Budget limit reached',
|
|
||||||
$budgetDecision['message'] ?? 'The AI editing budget for this billing period has been exhausted.',
|
|
||||||
Response::HTTP_FORBIDDEN,
|
|
||||||
[
|
|
||||||
'budget' => $budgetDecision['budget'],
|
|
||||||
]
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (! $this->eventPolicy->isStyleAllowed($event, $style) || ! $this->styleAccess->canUseStyle($event, $style)) {
|
|
||||||
return ApiError::response(
|
|
||||||
'style_not_allowed',
|
|
||||||
'Style not allowed',
|
|
||||||
$policy['policy_message'] ?? 'This style is not allowed for this event.',
|
|
||||||
Response::HTTP_UNPROCESSABLE_ENTITY,
|
|
||||||
[
|
|
||||||
'allowed_style_keys' => $policy['allowed_style_keys'],
|
|
||||||
]
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
$prompt = (string) ($request->input('prompt') ?: $style->prompt_template ?: '');
|
|
||||||
$negativePrompt = (string) ($request->input('negative_prompt') ?: $style->negative_prompt_template ?: '');
|
|
||||||
$providerModel = $request->input('provider_model') ?: $style->provider_model;
|
|
||||||
$safetyDecision = $this->safetyPolicy->evaluatePrompt($prompt, $negativePrompt);
|
|
||||||
$requestedByUserId = $request->user()?->id;
|
|
||||||
$scopeKey = $this->normalizeUserId($requestedByUserId) ?: 'tenant-user';
|
|
||||||
$abuseSignal = null;
|
|
||||||
$safetyReasons = $safetyDecision->reasonCodes;
|
|
||||||
if ($safetyDecision->blocked) {
|
|
||||||
$abuseSignal = $this->abuseEscalation->recordPromptBlock(
|
|
||||||
(int) $event->tenant_id,
|
|
||||||
(int) $event->id,
|
|
||||||
$scopeKey
|
|
||||||
);
|
|
||||||
if (($abuseSignal['escalated'] ?? false) && ! in_array(AiAbuseEscalationService::REASON_CODE, $safetyReasons, true)) {
|
|
||||||
$safetyReasons[] = AiAbuseEscalationService::REASON_CODE;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
$metadata = (array) $request->input('metadata', []);
|
|
||||||
$styleMetadata = is_array($style->metadata) ? $style->metadata : [];
|
|
||||||
$styleRunwareMetadata = Arr::get($styleMetadata, 'runware');
|
|
||||||
if (is_array($styleRunwareMetadata)) {
|
|
||||||
$metadata['runware'] = $styleRunwareMetadata;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (is_array($abuseSignal)) {
|
|
||||||
$metadata['abuse'] = $abuseSignal;
|
|
||||||
}
|
|
||||||
$metadata['budget'] = $budgetDecision['budget'];
|
|
||||||
|
|
||||||
$idempotencyKey = $this->resolveIdempotencyKey(
|
|
||||||
$request->input('idempotency_key'),
|
|
||||||
$request->header('X-Idempotency-Key'),
|
|
||||||
$event,
|
|
||||||
$photo,
|
|
||||||
$style,
|
|
||||||
$prompt,
|
|
||||||
$requestedByUserId
|
|
||||||
);
|
|
||||||
|
|
||||||
$editRequest = AiEditRequest::query()->firstOrCreate(
|
|
||||||
['tenant_id' => $event->tenant_id, 'idempotency_key' => $idempotencyKey],
|
|
||||||
[
|
|
||||||
'event_id' => $event->id,
|
|
||||||
'photo_id' => $photo->id,
|
|
||||||
'style_id' => $style->id,
|
|
||||||
'requested_by_user_id' => $requestedByUserId,
|
|
||||||
'provider' => $this->runtimeConfig->defaultProvider(),
|
|
||||||
'provider_model' => $providerModel,
|
|
||||||
'status' => $safetyDecision->blocked ? AiEditRequest::STATUS_BLOCKED : AiEditRequest::STATUS_QUEUED,
|
|
||||||
'safety_state' => $safetyDecision->state,
|
|
||||||
'prompt' => $prompt,
|
|
||||||
'negative_prompt' => $negativePrompt,
|
|
||||||
'input_image_path' => $photo->file_path,
|
|
||||||
'idempotency_key' => $idempotencyKey,
|
|
||||||
'safety_reasons' => $safetyReasons,
|
|
||||||
'failure_code' => $safetyDecision->failureCode,
|
|
||||||
'failure_message' => $safetyDecision->failureMessage,
|
|
||||||
'queued_at' => now(),
|
|
||||||
'completed_at' => $safetyDecision->blocked ? now() : null,
|
|
||||||
'metadata' => $metadata,
|
|
||||||
]
|
|
||||||
);
|
|
||||||
|
|
||||||
if (! $editRequest->wasRecentlyCreated && $this->isIdempotencyConflict(
|
|
||||||
$editRequest,
|
|
||||||
$event,
|
|
||||||
$photo,
|
|
||||||
$style,
|
|
||||||
$prompt,
|
|
||||||
$negativePrompt,
|
|
||||||
$providerModel,
|
|
||||||
$requestedByUserId
|
|
||||||
)) {
|
|
||||||
return ApiError::response(
|
|
||||||
'idempotency_conflict',
|
|
||||||
'Idempotency conflict',
|
|
||||||
'The provided idempotency key is already in use for another request.',
|
|
||||||
Response::HTTP_CONFLICT
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (
|
|
||||||
$editRequest->wasRecentlyCreated
|
|
||||||
&& ! $safetyDecision->blocked
|
|
||||||
&& $this->runtimeConfig->queueAutoDispatch()
|
|
||||||
) {
|
|
||||||
ProcessAiEditRequest::dispatch($editRequest->id)
|
|
||||||
->onQueue($this->runtimeConfig->queueName());
|
|
||||||
}
|
|
||||||
|
|
||||||
return response()->json([
|
|
||||||
'message' => $editRequest->wasRecentlyCreated ? 'AI edit request queued' : 'AI edit request already exists',
|
|
||||||
'duplicate' => ! $editRequest->wasRecentlyCreated,
|
|
||||||
'data' => $this->serializeRequest($editRequest->fresh(['style', 'outputs'])),
|
|
||||||
], $editRequest->wasRecentlyCreated ? Response::HTTP_CREATED : Response::HTTP_OK);
|
|
||||||
}
|
|
||||||
|
|
||||||
public function show(Request $request, string $eventSlug, int $aiEditRequest): JsonResponse
|
|
||||||
{
|
|
||||||
$event = $this->resolveTenantEventOrFail($request, $eventSlug);
|
|
||||||
TenantMemberPermissions::ensureEventPermission($request, $event, 'photos:upload');
|
|
||||||
|
|
||||||
$editRequest = AiEditRequest::query()
|
|
||||||
->with(['style', 'outputs'])
|
|
||||||
->whereKey($aiEditRequest)
|
|
||||||
->where('event_id', $event->id)
|
|
||||||
->first();
|
|
||||||
|
|
||||||
if (! $editRequest) {
|
|
||||||
return ApiError::response(
|
|
||||||
'edit_request_not_found',
|
|
||||||
'Edit request not found',
|
|
||||||
'The specified AI edit request could not be located for this event.',
|
|
||||||
Response::HTTP_NOT_FOUND
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return response()->json([
|
|
||||||
'data' => $this->serializeRequest($editRequest),
|
|
||||||
]);
|
|
||||||
}
|
|
||||||
|
|
||||||
private function resolveTenantEventOrFail(Request $request, string $eventSlug): Event
|
|
||||||
{
|
|
||||||
$tenantId = $request->attributes->get('tenant_id');
|
|
||||||
|
|
||||||
return Event::query()
|
|
||||||
->where('slug', $eventSlug)
|
|
||||||
->where('tenant_id', $tenantId)
|
|
||||||
->firstOrFail();
|
|
||||||
}
|
|
||||||
|
|
||||||
private function resolveStyle(mixed $styleId, mixed $styleKey): ?AiStyle
|
|
||||||
{
|
|
||||||
if ($styleId !== null) {
|
|
||||||
return AiStyle::query()
|
|
||||||
->whereKey((int) $styleId)
|
|
||||||
->where('is_active', true)
|
|
||||||
->first();
|
|
||||||
}
|
|
||||||
|
|
||||||
$key = trim((string) ($styleKey ?? ''));
|
|
||||||
if ($key === '') {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
return AiStyle::query()
|
|
||||||
->where('key', $key)
|
|
||||||
->where('is_active', true)
|
|
||||||
->first();
|
|
||||||
}
|
|
||||||
|
|
||||||
private function resolveIdempotencyKey(
|
|
||||||
mixed $bodyKey,
|
|
||||||
mixed $headerKey,
|
|
||||||
Event $event,
|
|
||||||
Photo $photo,
|
|
||||||
AiStyle $style,
|
|
||||||
string $prompt,
|
|
||||||
mixed $requestedByUserId
|
|
||||||
): string {
|
|
||||||
$candidate = trim((string) ($bodyKey ?: $headerKey ?: ''));
|
|
||||||
if ($candidate !== '') {
|
|
||||||
return Str::limit($candidate, 120, '');
|
|
||||||
}
|
|
||||||
|
|
||||||
return substr(hash('sha256', implode('|', [
|
|
||||||
(string) $event->id,
|
|
||||||
(string) $photo->id,
|
|
||||||
(string) $style->id,
|
|
||||||
trim($prompt),
|
|
||||||
(string) ($this->normalizeUserId($requestedByUserId)),
|
|
||||||
])), 0, 120);
|
|
||||||
}
|
|
||||||
|
|
||||||
private function normalizeUserId(mixed $userId): ?string
|
|
||||||
{
|
|
||||||
if (! is_int($userId) && ! is_string($userId)) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
$value = trim((string) $userId);
|
|
||||||
|
|
||||||
return $value !== '' ? $value : null;
|
|
||||||
}
|
|
||||||
|
|
||||||
private function normalizeOptionalString(?string $value): ?string
|
|
||||||
{
|
|
||||||
if ($value === null) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
$trimmed = trim($value);
|
|
||||||
|
|
||||||
return $trimmed !== '' ? $trimmed : null;
|
|
||||||
}
|
|
||||||
|
|
||||||
private function isIdempotencyConflict(
|
|
||||||
AiEditRequest $request,
|
|
||||||
Event $event,
|
|
||||||
Photo $photo,
|
|
||||||
AiStyle $style,
|
|
||||||
string $prompt,
|
|
||||||
string $negativePrompt,
|
|
||||||
?string $providerModel,
|
|
||||||
mixed $requestedByUserId
|
|
||||||
): bool {
|
|
||||||
if ($request->event_id !== $event->id || $request->photo_id !== $photo->id) {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
if ((int) ($request->style_id ?? 0) !== (int) $style->id) {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
if ($this->normalizeOptionalString($request->prompt) !== $this->normalizeOptionalString($prompt)) {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
if ($this->normalizeOptionalString($request->negative_prompt) !== $this->normalizeOptionalString($negativePrompt)) {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
if ($this->normalizeOptionalString($request->provider_model) !== $this->normalizeOptionalString($providerModel)) {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
return $this->normalizeUserId($request->requested_by_user_id) !== $this->normalizeUserId($requestedByUserId);
|
|
||||||
}
|
|
||||||
|
|
||||||
private function serializeStyle(AiStyle $style): array
|
|
||||||
{
|
|
||||||
return [
|
|
||||||
'id' => $style->id,
|
|
||||||
'key' => $style->key,
|
|
||||||
'name' => $style->name,
|
|
||||||
'version' => $style->version,
|
|
||||||
'category' => $style->category,
|
|
||||||
'description' => $style->description,
|
|
||||||
'provider' => $style->provider,
|
|
||||||
'provider_model' => $style->provider_model,
|
|
||||||
'requires_source_image' => $style->requires_source_image,
|
|
||||||
'is_premium' => $style->is_premium,
|
|
||||||
'metadata' => $style->metadata ?? [],
|
|
||||||
];
|
|
||||||
}
|
|
||||||
|
|
||||||
private function serializeRequest(AiEditRequest $request): array
|
|
||||||
{
|
|
||||||
return [
|
|
||||||
'id' => $request->id,
|
|
||||||
'event_id' => $request->event_id,
|
|
||||||
'photo_id' => $request->photo_id,
|
|
||||||
'style' => $request->style ? [
|
|
||||||
'id' => $request->style->id,
|
|
||||||
'key' => $request->style->key,
|
|
||||||
'name' => $request->style->name,
|
|
||||||
] : null,
|
|
||||||
'provider' => $request->provider,
|
|
||||||
'provider_model' => $request->provider_model,
|
|
||||||
'status' => $request->status,
|
|
||||||
'safety_state' => $request->safety_state,
|
|
||||||
'safety_reasons' => $request->safety_reasons ?? [],
|
|
||||||
'failure_code' => $request->failure_code,
|
|
||||||
'failure_message' => $request->failure_message,
|
|
||||||
'queued_at' => $request->queued_at?->toIso8601String(),
|
|
||||||
'started_at' => $request->started_at?->toIso8601String(),
|
|
||||||
'completed_at' => $request->completed_at?->toIso8601String(),
|
|
||||||
'outputs' => $request->outputs->map(fn ($output) => [
|
|
||||||
'id' => $output->id,
|
|
||||||
'storage_disk' => $output->storage_disk,
|
|
||||||
'storage_path' => $output->storage_path,
|
|
||||||
'provider_url' => $output->provider_url,
|
|
||||||
'url' => $this->resolveOutputUrl(
|
|
||||||
$output->storage_disk,
|
|
||||||
$output->storage_path,
|
|
||||||
$output->provider_url
|
|
||||||
),
|
|
||||||
'mime_type' => $output->mime_type,
|
|
||||||
'width' => $output->width,
|
|
||||||
'height' => $output->height,
|
|
||||||
'is_primary' => $output->is_primary,
|
|
||||||
'safety_state' => $output->safety_state,
|
|
||||||
'safety_reasons' => $output->safety_reasons ?? [],
|
|
||||||
'generated_at' => $output->generated_at?->toIso8601String(),
|
|
||||||
])->values(),
|
|
||||||
];
|
|
||||||
}
|
|
||||||
|
|
||||||
private function resolveOutputUrl(?string $storageDisk, ?string $storagePath, ?string $providerUrl): ?string
|
|
||||||
{
|
|
||||||
$resolvedStoragePath = $this->normalizeOptionalString($storagePath);
|
|
||||||
if ($resolvedStoragePath !== null) {
|
|
||||||
if (Str::startsWith($resolvedStoragePath, ['http://', 'https://'])) {
|
|
||||||
return $resolvedStoragePath;
|
|
||||||
}
|
|
||||||
|
|
||||||
$disk = $this->resolveStorageDisk($storageDisk);
|
|
||||||
|
|
||||||
try {
|
|
||||||
return Storage::disk($disk)->url($resolvedStoragePath);
|
|
||||||
} catch (\Throwable $exception) {
|
|
||||||
Log::debug('Falling back to raw AI output storage path', [
|
|
||||||
'disk' => $disk,
|
|
||||||
'path' => $resolvedStoragePath,
|
|
||||||
'error' => $exception->getMessage(),
|
|
||||||
]);
|
|
||||||
|
|
||||||
return '/'.ltrim($resolvedStoragePath, '/');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return $this->normalizeOptionalString($providerUrl);
|
|
||||||
}
|
|
||||||
|
|
||||||
private function resolveStorageDisk(?string $disk): string
|
|
||||||
{
|
|
||||||
$candidate = trim((string) ($disk ?: config('filesystems.default', 'public')));
|
|
||||||
|
|
||||||
if ($candidate === '' || ! config("filesystems.disks.{$candidate}")) {
|
|
||||||
return (string) config('filesystems.default', 'public');
|
|
||||||
}
|
|
||||||
|
|
||||||
return $candidate;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -3,7 +3,6 @@
|
|||||||
namespace App\Http\Controllers\Api\Tenant;
|
namespace App\Http\Controllers\Api\Tenant;
|
||||||
|
|
||||||
use App\Http\Controllers\Controller;
|
use App\Http\Controllers\Controller;
|
||||||
use App\Models\CheckoutSession;
|
|
||||||
use App\Services\Addons\EventAddonCatalog;
|
use App\Services\Addons\EventAddonCatalog;
|
||||||
use Illuminate\Http\JsonResponse;
|
use Illuminate\Http\JsonResponse;
|
||||||
|
|
||||||
@@ -13,25 +12,9 @@ class EventAddonCatalogController extends Controller
|
|||||||
|
|
||||||
public function index(): JsonResponse
|
public function index(): JsonResponse
|
||||||
{
|
{
|
||||||
$provider = config('package-addons.provider')
|
|
||||||
?? config('checkout.default_provider', CheckoutSession::PROVIDER_PAYPAL);
|
|
||||||
|
|
||||||
$addons = collect($this->catalog->all())
|
$addons = collect($this->catalog->all())
|
||||||
->map(function (array $addon, string $key) use ($provider): array {
|
|
||||||
$priceId = $provider === CheckoutSession::PROVIDER_PAYPAL
|
|
||||||
? ($addon['price'] ?? null ? 'paypal' : null)
|
|
||||||
: ($addon['variant_id'] ?? null);
|
|
||||||
|
|
||||||
return [
|
|
||||||
'key' => $key,
|
|
||||||
'label' => $addon['label'] ?? null,
|
|
||||||
'price_id' => $priceId,
|
|
||||||
'increments' => $addon['increments'] ?? [],
|
|
||||||
'price' => $addon['price'] ?? null,
|
|
||||||
'currency' => $addon['currency'] ?? 'EUR',
|
|
||||||
];
|
|
||||||
})
|
|
||||||
->filter(fn (array $addon) => ! empty($addon['price_id']))
|
->filter(fn (array $addon) => ! empty($addon['price_id']))
|
||||||
|
->map(fn (array $addon, string $key) => array_merge($addon, ['key' => $key]))
|
||||||
->values()
|
->values()
|
||||||
->all();
|
->all();
|
||||||
|
|
||||||
|
|||||||
@@ -4,13 +4,13 @@ namespace App\Http\Controllers\Api\Tenant;
|
|||||||
|
|
||||||
use App\Http\Controllers\Controller;
|
use App\Http\Controllers\Controller;
|
||||||
use App\Http\Requests\Tenant\EventAddonCheckoutRequest;
|
use App\Http\Requests\Tenant\EventAddonCheckoutRequest;
|
||||||
use App\Http\Requests\Tenant\EventAddonPurchaseLookupRequest;
|
use App\Http\Requests\Tenant\EventAddonRequest;
|
||||||
|
use App\Http\Resources\Tenant\EventResource;
|
||||||
use App\Models\Event;
|
use App\Models\Event;
|
||||||
use App\Models\EventPackageAddon;
|
|
||||||
use App\Services\Addons\EventAddonCheckoutService;
|
use App\Services\Addons\EventAddonCheckoutService;
|
||||||
use App\Support\ApiError;
|
use App\Support\ApiError;
|
||||||
use Illuminate\Http\JsonResponse;
|
use Illuminate\Http\JsonResponse;
|
||||||
use Illuminate\Support\Arr;
|
use Illuminate\Support\Carbon;
|
||||||
|
|
||||||
class EventAddonController extends Controller
|
class EventAddonController extends Controller
|
||||||
{
|
{
|
||||||
@@ -52,7 +52,7 @@ class EventAddonController extends Controller
|
|||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
public function purchase(EventAddonPurchaseLookupRequest $request, Event $event): JsonResponse
|
public function apply(EventAddonRequest $request, Event $event): JsonResponse
|
||||||
{
|
{
|
||||||
$tenantId = $request->attributes->get('tenant_id');
|
$tenantId = $request->attributes->get('tenant_id');
|
||||||
|
|
||||||
@@ -66,85 +66,49 @@ class EventAddonController extends Controller
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
$validated = $request->validated();
|
$eventPackage = $event->eventPackage;
|
||||||
$addonIntent = trim((string) ($validated['addon_intent'] ?? ''));
|
|
||||||
$checkoutId = trim((string) ($validated['checkout_id'] ?? ''));
|
|
||||||
$addonKey = trim((string) ($validated['addon_key'] ?? ''));
|
|
||||||
|
|
||||||
$baseQuery = EventPackageAddon::query()
|
if (! $eventPackage && method_exists($event, 'eventPackages')) {
|
||||||
->where('tenant_id', $tenantId)
|
$eventPackage = $event->eventPackages()
|
||||||
->where('event_id', $event->id)
|
->with('package')
|
||||||
->with(['event:id,name,slug']);
|
|
||||||
|
|
||||||
$addon = null;
|
|
||||||
|
|
||||||
if ($addonIntent !== '') {
|
|
||||||
$addon = (clone $baseQuery)
|
|
||||||
->where('metadata->addon_intent', $addonIntent)
|
|
||||||
->orderByDesc('created_at')
|
|
||||||
->first();
|
|
||||||
}
|
|
||||||
|
|
||||||
if (! $addon && $checkoutId !== '') {
|
|
||||||
$addon = (clone $baseQuery)
|
|
||||||
->where('checkout_id', $checkoutId)
|
|
||||||
->orderByDesc('created_at')
|
|
||||||
->first();
|
|
||||||
}
|
|
||||||
|
|
||||||
if (! $addon && $addonKey !== '') {
|
|
||||||
$addon = (clone $baseQuery)
|
|
||||||
->where('addon_key', $addonKey)
|
|
||||||
->orderByRaw("case status when 'completed' then 0 when 'pending' then 1 else 2 end")
|
|
||||||
->orderByDesc('purchased_at')
|
->orderByDesc('purchased_at')
|
||||||
->orderByDesc('created_at')
|
->orderByDesc('created_at')
|
||||||
->first();
|
->first();
|
||||||
}
|
}
|
||||||
|
|
||||||
if (! $addon) {
|
if (! $eventPackage) {
|
||||||
$addon = (clone $baseQuery)
|
|
||||||
->orderByRaw("case status when 'completed' then 0 when 'pending' then 1 else 2 end")
|
|
||||||
->orderByDesc('purchased_at')
|
|
||||||
->orderByDesc('created_at')
|
|
||||||
->first();
|
|
||||||
}
|
|
||||||
|
|
||||||
if (! $addon) {
|
|
||||||
return ApiError::response(
|
return ApiError::response(
|
||||||
'addon_not_found',
|
'event_package_missing',
|
||||||
'Add-on purchase not found',
|
'Event package missing',
|
||||||
__('Der Add-on Kauf wurde nicht gefunden.'),
|
__('Kein Paket ist diesem Event zugeordnet.'),
|
||||||
404,
|
409,
|
||||||
['event_slug' => $event->slug ?? null]
|
['event_slug' => $event->slug ?? null]
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
$label = Arr::get($addon->metadata ?? [], 'label') ?? $addon->addon_key;
|
$data = $request->validated();
|
||||||
|
|
||||||
|
$eventPackage->fill([
|
||||||
|
'extra_photos' => ($eventPackage->extra_photos ?? 0) + (int) ($data['extra_photos'] ?? 0),
|
||||||
|
'extra_guests' => ($eventPackage->extra_guests ?? 0) + (int) ($data['extra_guests'] ?? 0),
|
||||||
|
'extra_gallery_days' => ($eventPackage->extra_gallery_days ?? 0) + (int) ($data['extend_gallery_days'] ?? 0),
|
||||||
|
]);
|
||||||
|
|
||||||
|
if (isset($data['extend_gallery_days'])) {
|
||||||
|
$base = $eventPackage->gallery_expires_at ?? Carbon::now();
|
||||||
|
$eventPackage->gallery_expires_at = $base->copy()->addDays((int) $data['extend_gallery_days']);
|
||||||
|
}
|
||||||
|
|
||||||
|
$eventPackage->save();
|
||||||
|
|
||||||
|
$event->load([
|
||||||
|
'eventPackage.package',
|
||||||
|
'eventPackages.package',
|
||||||
|
]);
|
||||||
|
|
||||||
return response()->json([
|
return response()->json([
|
||||||
'data' => [
|
'message' => __('Add-ons applied successfully.'),
|
||||||
'id' => $addon->id,
|
'data' => new EventResource($event),
|
||||||
'addon_key' => $addon->addon_key,
|
|
||||||
'label' => $label,
|
|
||||||
'quantity' => (int) ($addon->quantity ?? 1),
|
|
||||||
'status' => $addon->status,
|
|
||||||
'amount' => $addon->amount !== null ? (float) $addon->amount : null,
|
|
||||||
'currency' => $addon->currency,
|
|
||||||
'extra_photos' => (int) $addon->extra_photos,
|
|
||||||
'extra_guests' => (int) $addon->extra_guests,
|
|
||||||
'extra_gallery_days' => (int) $addon->extra_gallery_days,
|
|
||||||
'purchased_at' => $addon->purchased_at?->toIso8601String(),
|
|
||||||
'receipt_url' => Arr::get($addon->receipt_payload, 'receipt_url'),
|
|
||||||
'checkout_id' => $addon->checkout_id,
|
|
||||||
'transaction_id' => $addon->transaction_id,
|
|
||||||
'created_at' => $addon->created_at?->toIso8601String(),
|
|
||||||
'addon_intent' => Arr::get($addon->metadata ?? [], 'addon_intent'),
|
|
||||||
'event' => $addon->event ? [
|
|
||||||
'id' => $addon->event->id,
|
|
||||||
'slug' => $addon->event->slug,
|
|
||||||
'name' => $addon->event->name,
|
|
||||||
] : null,
|
|
||||||
],
|
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ use App\Models\Event;
|
|||||||
use App\Services\Analytics\EventAnalyticsService;
|
use App\Services\Analytics\EventAnalyticsService;
|
||||||
use Illuminate\Http\JsonResponse;
|
use Illuminate\Http\JsonResponse;
|
||||||
use Illuminate\Http\Request;
|
use Illuminate\Http\Request;
|
||||||
|
use Illuminate\Support\Arr;
|
||||||
|
|
||||||
class EventAnalyticsController extends Controller
|
class EventAnalyticsController extends Controller
|
||||||
{
|
{
|
||||||
@@ -25,10 +26,10 @@ class EventAnalyticsController extends Controller
|
|||||||
|
|
||||||
$hasAccess = in_array('advanced_analytics', $packageFeatures, true);
|
$hasAccess = in_array('advanced_analytics', $packageFeatures, true);
|
||||||
|
|
||||||
if (! $hasAccess) {
|
if (!$hasAccess) {
|
||||||
return response()->json([
|
return response()->json([
|
||||||
'message' => 'This feature is only available in the Premium package.',
|
'message' => 'This feature is only available in the Premium package.',
|
||||||
'code' => 'feature_locked',
|
'code' => 'feature_locked'
|
||||||
], 403);
|
], 403);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -110,14 +110,7 @@ class EventController extends Controller
|
|||||||
$tenantPackage = $tenant->tenantPackages()
|
$tenantPackage = $tenant->tenantPackages()
|
||||||
->with('package')
|
->with('package')
|
||||||
->where('active', true)
|
->where('active', true)
|
||||||
->where(function ($query) {
|
|
||||||
$query->whereNull('expires_at')->orWhere('expires_at', '>', now());
|
|
||||||
})
|
|
||||||
->whereHas('package', fn ($query) => $query->withTrashed()->where('type', 'endcustomer'))
|
|
||||||
->withCount('eventPackages')
|
|
||||||
->orderBy('event_packages_count')
|
|
||||||
->orderByDesc('purchased_at')
|
->orderByDesc('purchased_at')
|
||||||
->orderByDesc('id')
|
|
||||||
->first();
|
->first();
|
||||||
|
|
||||||
$package = null;
|
$package = null;
|
||||||
@@ -156,7 +149,6 @@ class EventController extends Controller
|
|||||||
$eventServicePackage = $billingIsReseller
|
$eventServicePackage = $billingIsReseller
|
||||||
? $this->resolveResellerEventPackageForSlug($requestedServiceSlug ?: $package->included_package_slug)
|
? $this->resolveResellerEventPackageForSlug($requestedServiceSlug ?: $package->included_package_slug)
|
||||||
: $package;
|
: $package;
|
||||||
$sourceTenantPackage = $billingIsReseller ? $billingTenantPackage : $tenantPackage;
|
|
||||||
|
|
||||||
$requiresWaiver = $package->isEndcustomer();
|
$requiresWaiver = $package->isEndcustomer();
|
||||||
$latestPurchase = $requiresWaiver ? $this->resolveLatestPackagePurchase($tenant, $package) : null;
|
$latestPurchase = $requiresWaiver ? $this->resolveLatestPackagePurchase($tenant, $package) : null;
|
||||||
@@ -169,13 +161,11 @@ class EventController extends Controller
|
|||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
$resolvedName = $this->resolveEventNameString($validated['name']);
|
|
||||||
$eventData = array_merge($validated, [
|
$eventData = array_merge($validated, [
|
||||||
'tenant_id' => $tenantId,
|
'tenant_id' => $tenantId,
|
||||||
'status' => $validated['status'] ?? 'draft',
|
'status' => $validated['status'] ?? 'draft',
|
||||||
'slug' => $this->generateUniqueSlug($resolvedName, $tenantId),
|
'slug' => $this->generateUniqueSlug($validated['name'], $tenantId),
|
||||||
]);
|
]);
|
||||||
$eventData['name'] = $this->normalizeEventName($validated['name']);
|
|
||||||
|
|
||||||
if (isset($eventData['event_date'])) {
|
if (isset($eventData['event_date'])) {
|
||||||
$eventData['date'] = $eventData['event_date'];
|
$eventData['date'] = $eventData['event_date'];
|
||||||
@@ -224,13 +214,12 @@ class EventController extends Controller
|
|||||||
|
|
||||||
$eventData = Arr::only($eventData, $allowed);
|
$eventData = Arr::only($eventData, $allowed);
|
||||||
|
|
||||||
$event = DB::transaction(function () use ($tenant, $eventData, $eventServicePackage, $billingIsReseller, $isSuperAdmin, $sourceTenantPackage) {
|
$event = DB::transaction(function () use ($tenant, $eventData, $eventServicePackage, $billingIsReseller, $isSuperAdmin) {
|
||||||
$event = Event::create($eventData);
|
$event = Event::create($eventData);
|
||||||
|
|
||||||
EventPackage::create([
|
EventPackage::create([
|
||||||
'event_id' => $event->id,
|
'event_id' => $event->id,
|
||||||
'package_id' => $eventServicePackage->id,
|
'package_id' => $eventServicePackage->id,
|
||||||
'tenant_package_id' => $sourceTenantPackage?->id,
|
|
||||||
'purchased_price' => $billingIsReseller ? 0 : $eventServicePackage->price,
|
'purchased_price' => $billingIsReseller ? 0 : $eventServicePackage->price,
|
||||||
'purchased_at' => now(),
|
'purchased_at' => now(),
|
||||||
'gallery_expires_at' => $eventServicePackage->gallery_days
|
'gallery_expires_at' => $eventServicePackage->gallery_days
|
||||||
@@ -239,7 +228,7 @@ class EventController extends Controller
|
|||||||
]);
|
]);
|
||||||
|
|
||||||
if ($billingIsReseller && ! $isSuperAdmin) {
|
if ($billingIsReseller && ! $isSuperAdmin) {
|
||||||
$note = sprintf('Event #%d created (%s)', $event->id, $this->resolveEventNameString($event->name));
|
$note = sprintf('Event #%d created (%s)', $event->id, $event->name);
|
||||||
|
|
||||||
if (! $tenant->consumeEventAllowanceFor($eventServicePackage->slug, 1, 'event.create', $note)) {
|
if (! $tenant->consumeEventAllowanceFor($eventServicePackage->slug, 1, 'event.create', $note)) {
|
||||||
throw new HttpException(402, 'Insufficient package allowance.');
|
throw new HttpException(402, 'Insufficient package allowance.');
|
||||||
@@ -256,13 +245,11 @@ class EventController extends Controller
|
|||||||
$tenant->refresh();
|
$tenant->refresh();
|
||||||
$event->load(['eventType', 'tenant', 'eventPackages.package', 'eventPackages.addons']);
|
$event->load(['eventType', 'tenant', 'eventPackages.package', 'eventPackages.addons']);
|
||||||
|
|
||||||
$activeResellerPackage = $tenant->getActiveResellerPackage();
|
|
||||||
|
|
||||||
return response()->json([
|
return response()->json([
|
||||||
'message' => 'Event created successfully',
|
'message' => 'Event created successfully',
|
||||||
'data' => new EventResource($event),
|
'data' => new EventResource($event),
|
||||||
'package' => $event->eventPackage ? $event->eventPackage->package->name : 'None',
|
'package' => $event->eventPackage ? $event->eventPackage->package->name : 'None',
|
||||||
'remaining_events' => $activeResellerPackage?->remaining_events ?? 0,
|
'remaining_events' => $tenant->activeResellerPackage ? $tenant->activeResellerPackage->remaining_events : 0,
|
||||||
], 201);
|
], 201);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -417,13 +404,9 @@ class EventController extends Controller
|
|||||||
unset($validated['event_date']);
|
unset($validated['event_date']);
|
||||||
}
|
}
|
||||||
|
|
||||||
$currentName = $this->resolveEventNameString($event->name);
|
if ($nameProvided && $validated['name'] !== $event->name) {
|
||||||
$nextName = $this->resolveEventNameString($validated['name']);
|
$validated['slug'] = $this->generateUniqueSlug($validated['name'], $tenantId, $event->id);
|
||||||
|
|
||||||
if ($nameProvided && $nextName !== $currentName) {
|
|
||||||
$validated['slug'] = $this->generateUniqueSlug($nextName, $tenantId, $event->id);
|
|
||||||
}
|
}
|
||||||
$validated['name'] = $this->normalizeEventName($validated['name']);
|
|
||||||
|
|
||||||
foreach (['password', 'password_confirmation', 'password_protected'] as $unused) {
|
foreach (['password', 'password_confirmation', 'password_protected'] as $unused) {
|
||||||
unset($validated[$unused]);
|
unset($validated[$unused]);
|
||||||
@@ -893,16 +876,9 @@ class EventController extends Controller
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
$minimumExpiry = $this->joinTokenService->minimumExpiryForEvent($event);
|
|
||||||
$expiresAtRules = ['nullable', 'date', 'after:now'];
|
|
||||||
|
|
||||||
if ($minimumExpiry) {
|
|
||||||
$expiresAtRules[] = 'after_or_equal:'.$minimumExpiry->toDateTimeString();
|
|
||||||
}
|
|
||||||
|
|
||||||
$validated = $request->validate([
|
$validated = $request->validate([
|
||||||
'label' => ['nullable', 'string', 'max:255'],
|
'label' => ['nullable', 'string', 'max:255'],
|
||||||
'expires_at' => $expiresAtRules,
|
'expires_at' => ['nullable', 'date', 'after:now'],
|
||||||
'usage_limit' => ['nullable', 'integer', 'min:1'],
|
'usage_limit' => ['nullable', 'integer', 'min:1'],
|
||||||
]);
|
]);
|
||||||
|
|
||||||
@@ -959,45 +935,6 @@ class EventController extends Controller
|
|||||||
return $slug;
|
return $slug;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* @param array<string, mixed>|string|null $name
|
|
||||||
* @return array<string, mixed>
|
|
||||||
*/
|
|
||||||
private function normalizeEventName(mixed $name): array
|
|
||||||
{
|
|
||||||
if (is_array($name)) {
|
|
||||||
return $name;
|
|
||||||
}
|
|
||||||
|
|
||||||
$value = is_string($name) ? trim($name) : '';
|
|
||||||
|
|
||||||
return ['de' => $value];
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @param array<string, mixed>|string|null $name
|
|
||||||
*/
|
|
||||||
private function resolveEventNameString(mixed $name): string
|
|
||||||
{
|
|
||||||
if (is_array($name)) {
|
|
||||||
$candidates = [
|
|
||||||
$name['de'] ?? null,
|
|
||||||
$name['en'] ?? null,
|
|
||||||
reset($name) ?: null,
|
|
||||||
];
|
|
||||||
|
|
||||||
foreach ($candidates as $candidate) {
|
|
||||||
if (is_string($candidate) && $candidate !== '') {
|
|
||||||
return $candidate;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return '';
|
|
||||||
}
|
|
||||||
|
|
||||||
return is_string($name) ? $name : '';
|
|
||||||
}
|
|
||||||
|
|
||||||
public function search(Request $request): AnonymousResourceCollection
|
public function search(Request $request): AnonymousResourceCollection
|
||||||
{
|
{
|
||||||
$tenantId = $request->attributes->get('tenant_id');
|
$tenantId = $request->attributes->get('tenant_id');
|
||||||
|
|||||||
@@ -33,7 +33,7 @@ class EventJoinTokenController extends Controller
|
|||||||
{
|
{
|
||||||
$this->authorizeEvent($request, $event, 'join-tokens:manage');
|
$this->authorizeEvent($request, $event, 'join-tokens:manage');
|
||||||
|
|
||||||
$validated = $this->validatePayload($request, $event);
|
$validated = $this->validatePayload($request);
|
||||||
|
|
||||||
$token = $this->joinTokenService->createToken($event, array_merge($validated, [
|
$token = $this->joinTokenService->createToken($event, array_merge($validated, [
|
||||||
'created_by' => Auth::id(),
|
'created_by' => Auth::id(),
|
||||||
@@ -52,7 +52,7 @@ class EventJoinTokenController extends Controller
|
|||||||
abort(404);
|
abort(404);
|
||||||
}
|
}
|
||||||
|
|
||||||
$validated = $this->validatePayload($request, $event, true);
|
$validated = $this->validatePayload($request, true);
|
||||||
|
|
||||||
$payload = [];
|
$payload = [];
|
||||||
|
|
||||||
@@ -115,18 +115,11 @@ class EventJoinTokenController extends Controller
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private function validatePayload(Request $request, Event $event, bool $partial = false): array
|
private function validatePayload(Request $request, bool $partial = false): array
|
||||||
{
|
{
|
||||||
$minimumExpiry = $this->joinTokenService->minimumExpiryForEvent($event);
|
|
||||||
$expiresAtRules = [$partial ? 'nullable' : 'sometimes', 'nullable', 'date', 'after:now'];
|
|
||||||
|
|
||||||
if ($minimumExpiry) {
|
|
||||||
$expiresAtRules[] = 'after_or_equal:'.$minimumExpiry->toDateTimeString();
|
|
||||||
}
|
|
||||||
|
|
||||||
$rules = [
|
$rules = [
|
||||||
'label' => [$partial ? 'nullable' : 'sometimes', 'string', 'max:255'],
|
'label' => [$partial ? 'nullable' : 'sometimes', 'string', 'max:255'],
|
||||||
'expires_at' => $expiresAtRules,
|
'expires_at' => [$partial ? 'nullable' : 'sometimes', 'nullable', 'date', 'after:now'],
|
||||||
'usage_limit' => [$partial ? 'nullable' : 'sometimes', 'nullable', 'integer', 'min:1'],
|
'usage_limit' => [$partial ? 'nullable' : 'sometimes', 'nullable', 'integer', 'min:1'],
|
||||||
'metadata' => [$partial ? 'nullable' : 'sometimes', 'nullable', 'array'],
|
'metadata' => [$partial ? 'nullable' : 'sometimes', 'nullable', 'array'],
|
||||||
'metadata.layout_customization' => ['nullable', 'array'],
|
'metadata.layout_customization' => ['nullable', 'array'],
|
||||||
|
|||||||
@@ -22,14 +22,7 @@ class EventJoinTokenLayoutController extends Controller
|
|||||||
*/
|
*/
|
||||||
private const BACKGROUND_PRESETS = [
|
private const BACKGROUND_PRESETS = [
|
||||||
'bg-blue-floral' => 'storage/layouts/backgrounds-portrait/bg-blue-floral.png',
|
'bg-blue-floral' => 'storage/layouts/backgrounds-portrait/bg-blue-floral.png',
|
||||||
'bg-artdeco' => 'storage/layouts/backgrounds-portrait/bg-artdeco.png',
|
|
||||||
'bg-eukalyptus-floral' => 'storage/layouts/backgrounds-portrait/bg-eukalyptus-floral.png',
|
|
||||||
'bg-eukalyptus-rahmen' => 'storage/layouts/backgrounds-portrait/bg-eukalyptus-rahmen.png',
|
|
||||||
'bg-eukalyptus' => 'storage/layouts/backgrounds-portrait/bg-eukalyptus.png',
|
|
||||||
'bg-goldframe' => 'storage/layouts/backgrounds-portrait/bg-goldframe.png',
|
'bg-goldframe' => 'storage/layouts/backgrounds-portrait/bg-goldframe.png',
|
||||||
'bg-jugendstil' => 'storage/layouts/backgrounds-portrait/bg-jugendstil.png',
|
|
||||||
'bg-kornblumen' => 'storage/layouts/backgrounds-portrait/bg-kornblumen.png',
|
|
||||||
'bg-kornblumen2' => 'storage/layouts/backgrounds-portrait/bg-kornblumen2.png',
|
|
||||||
'gr-green-floral' => 'storage/layouts/backgrounds-portrait/gr-green-floral.png',
|
'gr-green-floral' => 'storage/layouts/backgrounds-portrait/gr-green-floral.png',
|
||||||
];
|
];
|
||||||
|
|
||||||
|
|||||||
@@ -112,3 +112,4 @@ class FontController extends Controller
|
|||||||
return $fonts;
|
return $fonts;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -53,7 +53,6 @@ class LiveShowLinkController extends Controller
|
|||||||
'url' => $url,
|
'url' => $url,
|
||||||
'qr_code_data_url' => $this->buildQrCodeDataUrl($url),
|
'qr_code_data_url' => $this->buildQrCodeDataUrl($url),
|
||||||
'rotated_at' => $event->live_show_token_rotated_at?->toIso8601String(),
|
'rotated_at' => $event->live_show_token_rotated_at?->toIso8601String(),
|
||||||
'expires_at' => $event->live_show_token_expires_at?->toIso8601String(),
|
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -20,7 +20,6 @@ use App\Support\WatermarkConfigResolver;
|
|||||||
use Illuminate\Http\JsonResponse;
|
use Illuminate\Http\JsonResponse;
|
||||||
use Illuminate\Http\Request;
|
use Illuminate\Http\Request;
|
||||||
use Illuminate\Http\Resources\Json\AnonymousResourceCollection;
|
use Illuminate\Http\Resources\Json\AnonymousResourceCollection;
|
||||||
use Illuminate\Http\UploadedFile;
|
|
||||||
use Illuminate\Support\Arr;
|
use Illuminate\Support\Arr;
|
||||||
use Illuminate\Support\Facades\DB;
|
use Illuminate\Support\Facades\DB;
|
||||||
use Illuminate\Support\Facades\Log;
|
use Illuminate\Support\Facades\Log;
|
||||||
@@ -116,7 +115,6 @@ class PhotoController extends Controller
|
|||||||
$event = Event::where('slug', $eventSlug)
|
$event = Event::where('slug', $eventSlug)
|
||||||
->where('tenant_id', $tenantId)
|
->where('tenant_id', $tenantId)
|
||||||
->firstOrFail();
|
->firstOrFail();
|
||||||
TenantMemberPermissions::ensureEventPermission($request, $event, 'photos:moderate');
|
|
||||||
|
|
||||||
if ($photo->event_id !== $event->id) {
|
if ($photo->event_id !== $event->id) {
|
||||||
return ApiError::response(
|
return ApiError::response(
|
||||||
@@ -323,7 +321,7 @@ class PhotoController extends Controller
|
|||||||
$disk = $this->eventStorageManager->getHotDiskForEvent($event);
|
$disk = $this->eventStorageManager->getHotDiskForEvent($event);
|
||||||
|
|
||||||
// Generate unique filename
|
// Generate unique filename
|
||||||
$extension = $this->resolvePhotoExtension($file);
|
$extension = $file->getClientOriginalExtension();
|
||||||
$filename = Str::uuid().'.'.$extension;
|
$filename = Str::uuid().'.'.$extension;
|
||||||
$path = "events/{$eventSlug}/photos/{$filename}";
|
$path = "events/{$eventSlug}/photos/{$filename}";
|
||||||
|
|
||||||
@@ -565,7 +563,6 @@ class PhotoController extends Controller
|
|||||||
$event = Event::where('slug', $eventSlug)
|
$event = Event::where('slug', $eventSlug)
|
||||||
->where('tenant_id', $tenantId)
|
->where('tenant_id', $tenantId)
|
||||||
->firstOrFail();
|
->firstOrFail();
|
||||||
TenantMemberPermissions::ensureEventPermission($request, $event, 'photos:moderate');
|
|
||||||
|
|
||||||
if ($photo->event_id !== $event->id) {
|
if ($photo->event_id !== $event->id) {
|
||||||
return ApiError::response(
|
return ApiError::response(
|
||||||
@@ -782,7 +779,6 @@ class PhotoController extends Controller
|
|||||||
$event = Event::where('slug', $eventSlug)
|
$event = Event::where('slug', $eventSlug)
|
||||||
->where('tenant_id', $tenantId)
|
->where('tenant_id', $tenantId)
|
||||||
->firstOrFail();
|
->firstOrFail();
|
||||||
TenantMemberPermissions::ensureEventPermission($request, $event, 'photos:moderate');
|
|
||||||
|
|
||||||
$photos = Photo::where('event_id', $event->id)
|
$photos = Photo::where('event_id', $event->id)
|
||||||
->where('status', 'pending')
|
->where('status', 'pending')
|
||||||
@@ -1047,23 +1043,4 @@ class PhotoController extends Controller
|
|||||||
|
|
||||||
return array_values(array_unique(array_filter($candidates)));
|
return array_values(array_unique(array_filter($candidates)));
|
||||||
}
|
}
|
||||||
|
|
||||||
private function resolvePhotoExtension(UploadedFile $file): string
|
|
||||||
{
|
|
||||||
$extension = strtolower((string) $file->extension());
|
|
||||||
|
|
||||||
if (! in_array($extension, ['jpg', 'jpeg', 'png', 'webp'], true)) {
|
|
||||||
$extension = strtolower((string) $file->getClientOriginalExtension());
|
|
||||||
}
|
|
||||||
|
|
||||||
if (! in_array($extension, ['jpg', 'jpeg', 'png', 'webp'], true)) {
|
|
||||||
$extension = match ($file->getMimeType()) {
|
|
||||||
'image/png' => 'png',
|
|
||||||
'image/webp' => 'webp',
|
|
||||||
default => 'jpg',
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
return $extension === 'jpeg' ? 'jpg' : $extension;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -91,14 +91,14 @@ class PhotoboothController extends Controller
|
|||||||
$recipientName = $user->fullName ?? $user->name ?? $user->email;
|
$recipientName = $user->fullName ?? $user->name ?? $user->email;
|
||||||
|
|
||||||
$mail = (new PhotoboothUploaderDownload(
|
$mail = (new PhotoboothUploaderDownload(
|
||||||
recipientName: $recipientName,
|
recipientName: $recipientName,
|
||||||
eventName: $eventName,
|
eventName: $eventName,
|
||||||
links: [
|
links: [
|
||||||
'windows' => url('/downloads/PhotoboothUploader-win-x64.exe'),
|
'windows' => url('/downloads/PhotoboothUploader-win-x64.exe'),
|
||||||
'macos' => url('/downloads/PhotoboothUploader-macos-x64'),
|
'macos' => url('/downloads/PhotoboothUploader-macos-x64'),
|
||||||
'linux' => url('/downloads/PhotoboothUploader-linux-x64'),
|
'linux' => url('/downloads/PhotoboothUploader-linux-x64'),
|
||||||
],
|
],
|
||||||
))->locale($locale);
|
))->locale($locale);
|
||||||
|
|
||||||
Mail::to($user->email)->queue($mail);
|
Mail::to($user->email)->queue($mail);
|
||||||
|
|
||||||
|
|||||||
@@ -119,6 +119,8 @@ class TenantAdminTokenController extends Controller
|
|||||||
], 404);
|
], 404);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
$tenant->loadMissing('activeResellerPackage');
|
||||||
|
|
||||||
$user = $request->user();
|
$user = $request->user();
|
||||||
$abilities = $user?->currentAccessToken()?->abilities ?? [];
|
$abilities = $user?->currentAccessToken()?->abilities ?? [];
|
||||||
|
|
||||||
@@ -129,7 +131,7 @@ class TenantAdminTokenController extends Controller
|
|||||||
$fullName = trim($first.' '.$last) ?: null;
|
$fullName = trim($first.' '.$last) ?: null;
|
||||||
}
|
}
|
||||||
|
|
||||||
$activePackage = $tenant->getActiveResellerPackage();
|
$activePackage = $tenant->activeResellerPackage;
|
||||||
|
|
||||||
return response()->json([
|
return response()->json([
|
||||||
'id' => $tenant->id,
|
'id' => $tenant->id,
|
||||||
|
|||||||
@@ -3,27 +3,22 @@
|
|||||||
namespace App\Http\Controllers\Api;
|
namespace App\Http\Controllers\Api;
|
||||||
|
|
||||||
use App\Http\Controllers\Controller;
|
use App\Http\Controllers\Controller;
|
||||||
use App\Http\Requests\Tenant\BillingAddonHistoryRequest;
|
|
||||||
use App\Models\Event;
|
|
||||||
use App\Models\EventPackageAddon;
|
use App\Models\EventPackageAddon;
|
||||||
use App\Models\PackagePurchase;
|
use App\Services\Paddle\Exceptions\PaddleException;
|
||||||
use App\Services\Addons\EventAddonCatalog;
|
use App\Services\Paddle\PaddleCustomerPortalService;
|
||||||
use App\Services\LemonSqueezy\Exceptions\LemonSqueezyException;
|
use App\Services\Paddle\PaddleCustomerService;
|
||||||
use App\Services\LemonSqueezy\LemonSqueezySubscriptionService;
|
use App\Services\Paddle\PaddleTransactionService;
|
||||||
use Dompdf\Dompdf;
|
|
||||||
use Dompdf\Options;
|
|
||||||
use Illuminate\Http\JsonResponse;
|
use Illuminate\Http\JsonResponse;
|
||||||
use Illuminate\Http\Request;
|
use Illuminate\Http\Request;
|
||||||
use Illuminate\Support\Arr;
|
use Illuminate\Support\Arr;
|
||||||
use Illuminate\Support\Facades\Log;
|
use Illuminate\Support\Facades\Log;
|
||||||
use Illuminate\Support\Str;
|
|
||||||
use Symfony\Component\HttpFoundation\Response;
|
|
||||||
|
|
||||||
class TenantBillingController extends Controller
|
class TenantBillingController extends Controller
|
||||||
{
|
{
|
||||||
public function __construct(
|
public function __construct(
|
||||||
private readonly LemonSqueezySubscriptionService $subscriptions,
|
private readonly PaddleTransactionService $paddleTransactions,
|
||||||
private readonly EventAddonCatalog $addonCatalog,
|
private readonly PaddleCustomerService $paddleCustomers,
|
||||||
|
private readonly PaddleCustomerPortalService $portalSessions,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
public function transactions(Request $request): JsonResponse
|
public function transactions(Request $request): JsonResponse
|
||||||
@@ -37,49 +32,54 @@ class TenantBillingController extends Controller
|
|||||||
], 404);
|
], 404);
|
||||||
}
|
}
|
||||||
|
|
||||||
$perPage = max(1, min((int) $request->query('per_page', 25), 100));
|
if (! $tenant->paddle_customer_id) {
|
||||||
$page = max(1, (int) $request->query('page', 1));
|
try {
|
||||||
$locale = $request->user()?->preferred_locale ?? app()->getLocale();
|
$this->paddleCustomers->ensureCustomerId($tenant);
|
||||||
|
} catch (\Throwable $exception) {
|
||||||
|
Log::warning('Failed to resolve Paddle customer for tenant', [
|
||||||
|
'tenant_id' => $tenant->id,
|
||||||
|
'error' => $exception->getMessage(),
|
||||||
|
]);
|
||||||
|
|
||||||
$paginator = PackagePurchase::query()
|
return response()->json([
|
||||||
->where('tenant_id', $tenant->id)
|
'data' => [],
|
||||||
->with(['package'])
|
'message' => 'Failed to resolve Paddle customer.',
|
||||||
->orderByDesc('purchased_at')
|
], 502);
|
||||||
->orderByDesc('id')
|
}
|
||||||
->paginate($perPage, ['*'], 'page', $page);
|
}
|
||||||
|
|
||||||
$data = $paginator->getCollection()->map(function (PackagePurchase $purchase) use ($locale) {
|
$cursor = $request->query('cursor');
|
||||||
$totals = $this->resolvePurchaseTotals($purchase);
|
$perPage = (int) $request->query('per_page', 25);
|
||||||
$transactionId = $purchase->provider_id ? (string) $purchase->provider_id : (string) $purchase->getKey();
|
|
||||||
|
|
||||||
return [
|
$query = [
|
||||||
'id' => $purchase->getKey(),
|
'per_page' => max(1, min($perPage, 100)),
|
||||||
'status' => $purchase->refunded ? 'refunded' : 'completed',
|
];
|
||||||
'amount' => $totals['total'],
|
|
||||||
'currency' => $totals['currency'],
|
if ($cursor) {
|
||||||
'tax' => $totals['tax'],
|
$query['after'] = $cursor;
|
||||||
'provider' => $purchase->provider ?? 'paypal',
|
}
|
||||||
'provider_id' => $transactionId,
|
|
||||||
'package_name' => $this->resolvePackageName($purchase, $locale),
|
try {
|
||||||
'purchased_at' => $purchase->purchased_at?->toIso8601String(),
|
$result = $this->paddleTransactions->listForCustomer($tenant->paddle_customer_id, $query);
|
||||||
'receipt_url' => route('api.v1.tenant.billing.transactions.receipt', [
|
} catch (\Throwable $exception) {
|
||||||
'purchase' => $purchase->getKey(),
|
Log::warning('Failed to load Paddle transactions', [
|
||||||
], absolute: false),
|
'tenant_id' => $tenant->id,
|
||||||
];
|
'error' => $exception->getMessage(),
|
||||||
})->values();
|
]);
|
||||||
|
|
||||||
|
return response()->json([
|
||||||
|
'data' => [],
|
||||||
|
'message' => 'Failed to load Paddle transactions.',
|
||||||
|
], 502);
|
||||||
|
}
|
||||||
|
|
||||||
return response()->json([
|
return response()->json([
|
||||||
'data' => $data,
|
'data' => $result['data'],
|
||||||
'meta' => [
|
'meta' => $result['meta'],
|
||||||
'current_page' => $paginator->currentPage(),
|
|
||||||
'last_page' => $paginator->lastPage(),
|
|
||||||
'per_page' => $paginator->perPage(),
|
|
||||||
'total' => $paginator->total(),
|
|
||||||
],
|
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
public function addons(BillingAddonHistoryRequest $request): JsonResponse
|
public function addons(Request $request): JsonResponse
|
||||||
{
|
{
|
||||||
$tenant = $request->attributes->get('tenant');
|
$tenant = $request->attributes->get('tenant');
|
||||||
|
|
||||||
@@ -90,63 +90,21 @@ class TenantBillingController extends Controller
|
|||||||
], 404);
|
], 404);
|
||||||
}
|
}
|
||||||
|
|
||||||
$perPage = max(1, min((int) $request->validated('per_page', 25), 100));
|
$perPage = max(1, min((int) $request->query('per_page', 25), 100));
|
||||||
$page = max(1, (int) $request->validated('page', 1));
|
$page = max(1, (int) $request->query('page', 1));
|
||||||
$eventId = $request->validated('event_id');
|
|
||||||
$eventSlug = $request->validated('event_slug');
|
|
||||||
$status = $request->validated('status');
|
|
||||||
|
|
||||||
$scopeEvent = null;
|
$paginator = EventPackageAddon::query()
|
||||||
if ($eventId !== null || $eventSlug !== null) {
|
|
||||||
$scopeEventQuery = Event::query()
|
|
||||||
->where('tenant_id', $tenant->id);
|
|
||||||
|
|
||||||
if ($eventId !== null) {
|
|
||||||
$scopeEventQuery->whereKey((int) $eventId);
|
|
||||||
} elseif (is_string($eventSlug) && trim($eventSlug) !== '') {
|
|
||||||
$scopeEventQuery->where('slug', $eventSlug);
|
|
||||||
}
|
|
||||||
|
|
||||||
$scopeEvent = $scopeEventQuery->first();
|
|
||||||
|
|
||||||
if (! $scopeEvent) {
|
|
||||||
return response()->json([
|
|
||||||
'data' => [],
|
|
||||||
'message' => 'Event scope not found.',
|
|
||||||
], 404);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
$query = EventPackageAddon::query()
|
|
||||||
->where('tenant_id', $tenant->id)
|
->where('tenant_id', $tenant->id)
|
||||||
->with(['event:id,name,slug']);
|
->with(['event:id,name,slug'])
|
||||||
|
|
||||||
if ($scopeEvent) {
|
|
||||||
$query->where('event_id', $scopeEvent->id);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (is_string($status) && $status !== '') {
|
|
||||||
$query->where('status', $status);
|
|
||||||
}
|
|
||||||
|
|
||||||
$paginator = $query
|
|
||||||
->orderByDesc('purchased_at')
|
->orderByDesc('purchased_at')
|
||||||
->orderByDesc('created_at')
|
->orderByDesc('created_at')
|
||||||
->paginate($perPage, ['*'], 'page', $page);
|
->paginate($perPage, ['*'], 'page', $page);
|
||||||
|
|
||||||
$addonLabels = collect($this->addonCatalog->all())
|
$data = $paginator->getCollection()->map(function (EventPackageAddon $addon) {
|
||||||
->mapWithKeys(fn (array $addon, string $key): array => [$key => $addon['label'] ?? null])
|
|
||||||
->all();
|
|
||||||
|
|
||||||
$data = $paginator->getCollection()->map(function (EventPackageAddon $addon) use ($addonLabels) {
|
|
||||||
$label = $addon->metadata['label']
|
|
||||||
?? ($addonLabels[$addon->addon_key] ?? null)
|
|
||||||
?? $addon->addon_key;
|
|
||||||
|
|
||||||
return [
|
return [
|
||||||
'id' => $addon->id,
|
'id' => $addon->id,
|
||||||
'addon_key' => $addon->addon_key,
|
'addon_key' => $addon->addon_key,
|
||||||
'label' => $label,
|
'label' => $addon->metadata['label'] ?? null,
|
||||||
'quantity' => (int) ($addon->quantity ?? 1),
|
'quantity' => (int) ($addon->quantity ?? 1),
|
||||||
'status' => $addon->status,
|
'status' => $addon->status,
|
||||||
'amount' => $addon->amount !== null ? (float) $addon->amount : null,
|
'amount' => $addon->amount !== null ? (float) $addon->amount : null,
|
||||||
@@ -171,17 +129,6 @@ class TenantBillingController extends Controller
|
|||||||
'last_page' => $paginator->lastPage(),
|
'last_page' => $paginator->lastPage(),
|
||||||
'per_page' => $paginator->perPage(),
|
'per_page' => $paginator->perPage(),
|
||||||
'total' => $paginator->total(),
|
'total' => $paginator->total(),
|
||||||
'scope' => $scopeEvent ? [
|
|
||||||
'type' => 'event',
|
|
||||||
'event' => [
|
|
||||||
'id' => $scopeEvent->id,
|
|
||||||
'slug' => $scopeEvent->slug,
|
|
||||||
'name' => $scopeEvent->name,
|
|
||||||
],
|
|
||||||
] : [
|
|
||||||
'type' => 'tenant',
|
|
||||||
'event' => null,
|
|
||||||
],
|
|
||||||
],
|
],
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
@@ -196,64 +143,68 @@ class TenantBillingController extends Controller
|
|||||||
], 404);
|
], 404);
|
||||||
}
|
}
|
||||||
|
|
||||||
$subscriptionId = null;
|
$customerId = null;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
$subscriptionId = $tenant->getActiveResellerPackage()?->lemonsqueezy_subscription_id;
|
$customerId = $this->paddleCustomers->ensureCustomerId($tenant);
|
||||||
if (! $subscriptionId) {
|
|
||||||
return response()->json([
|
|
||||||
'message' => 'No active subscription found.',
|
|
||||||
], 404);
|
|
||||||
}
|
|
||||||
|
|
||||||
Log::debug('Fetching Lemon Squeezy subscription portal URL', [
|
Log::debug('Creating Paddle customer portal session', [
|
||||||
'tenant_id' => $tenant->id,
|
'tenant_id' => $tenant->id,
|
||||||
'lemonsqueezy_subscription_id' => $subscriptionId,
|
'paddle_customer_id' => $customerId,
|
||||||
|
'paddle_environment' => config('paddle.environment'),
|
||||||
|
'paddle_base_url' => config('paddle.base_url'),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
$subscription = $this->subscriptions->retrieve($subscriptionId);
|
$session = $this->portalSessions->createSession($customerId);
|
||||||
} catch (\Throwable $exception) {
|
} catch (\Throwable $exception) {
|
||||||
$context = [
|
$context = [
|
||||||
'tenant_id' => $tenant->id,
|
'tenant_id' => $tenant->id,
|
||||||
'lemonsqueezy_customer_id' => $tenant->lemonsqueezy_customer_id,
|
'paddle_customer_id' => $customerId ?? $tenant->paddle_customer_id,
|
||||||
'lemonsqueezy_subscription_id' => $subscriptionId ?? null,
|
|
||||||
'error' => $exception->getMessage(),
|
'error' => $exception->getMessage(),
|
||||||
|
'paddle_environment' => config('paddle.environment'),
|
||||||
|
'paddle_base_url' => config('paddle.base_url'),
|
||||||
];
|
];
|
||||||
|
|
||||||
if ($exception instanceof LemonSqueezyException) {
|
if ($exception instanceof PaddleException) {
|
||||||
$context['lemonsqueezy_status'] = $exception->status();
|
$context['paddle_status'] = $exception->status();
|
||||||
$context['lemonsqueezy_error'] = Arr::get($exception->context(), 'errors.0');
|
$context['paddle_error_code'] = Arr::get($exception->context(), 'error.code');
|
||||||
$context['lemonsqueezy_errors'] = Arr::get($exception->context(), 'errors');
|
$context['paddle_error_message'] = Arr::get($exception->context(), 'error.message');
|
||||||
$context['lemonsqueezy_request_id'] = Arr::get($exception->context(), 'meta.request_id');
|
$context['paddle_error_detail'] = Arr::get($exception->context(), 'error.detail');
|
||||||
|
$context['paddle_error_doc_url'] = Arr::get($exception->context(), 'error.documentation_url');
|
||||||
|
$context['paddle_request_id'] = Arr::get($exception->context(), 'meta.request_id');
|
||||||
|
$context['paddle_errors'] = Arr::get($exception->context(), 'error.errors');
|
||||||
}
|
}
|
||||||
|
|
||||||
Log::warning('Failed to fetch Lemon Squeezy subscription portal URL', [
|
Log::warning('Failed to create Paddle customer portal session', [
|
||||||
...$context,
|
...$context,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
return response()->json([
|
return response()->json([
|
||||||
'message' => 'Failed to fetch Lemon Squeezy subscription portal URL.',
|
'message' => 'Failed to create Paddle customer portal session.',
|
||||||
], 502);
|
], 502);
|
||||||
}
|
}
|
||||||
|
|
||||||
$url = $this->subscriptions->portalUrl($subscription)
|
$url = Arr::get($session, 'data.urls.general.overview')
|
||||||
?? $this->subscriptions->updatePaymentMethodUrl($subscription);
|
?? Arr::get($session, 'data.urls.general')
|
||||||
|
?? Arr::get($session, 'urls.general.overview')
|
||||||
|
?? Arr::get($session, 'urls.general');
|
||||||
|
|
||||||
if (! $url) {
|
if (! $url) {
|
||||||
$sessionData = Arr::get($subscription, 'data');
|
$sessionData = Arr::get($session, 'data');
|
||||||
$sessionUrls = Arr::get($subscription, 'attributes.urls');
|
$sessionUrls = Arr::get($session, 'data.urls') ?? Arr::get($session, 'urls');
|
||||||
|
|
||||||
Log::warning('Lemon Squeezy subscription missing portal URL', [
|
Log::warning('Paddle customer portal session missing URL', [
|
||||||
'tenant_id' => $tenant->id,
|
'tenant_id' => $tenant->id,
|
||||||
'lemonsqueezy_customer_id' => $tenant->lemonsqueezy_customer_id,
|
'paddle_customer_id' => $customerId ?? $tenant->paddle_customer_id,
|
||||||
'lemonsqueezy_subscription_id' => $subscriptionId ?? null,
|
'paddle_environment' => config('paddle.environment'),
|
||||||
'subscription_keys' => array_keys($subscription),
|
'paddle_base_url' => config('paddle.base_url'),
|
||||||
|
'session_keys' => array_keys($session),
|
||||||
'session_data_keys' => is_array($sessionData) ? array_keys($sessionData) : null,
|
'session_data_keys' => is_array($sessionData) ? array_keys($sessionData) : null,
|
||||||
'session_url_keys' => is_array($sessionUrls) ? array_keys($sessionUrls) : null,
|
'session_url_keys' => is_array($sessionUrls) ? array_keys($sessionUrls) : null,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
return response()->json([
|
return response()->json([
|
||||||
'message' => 'Lemon Squeezy subscription missing portal URL.',
|
'message' => 'Paddle customer portal session missing URL.',
|
||||||
], 502);
|
], 502);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -261,184 +212,4 @@ class TenantBillingController extends Controller
|
|||||||
'url' => $url,
|
'url' => $url,
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
public function receipt(Request $request, PackagePurchase $purchase): Response
|
|
||||||
{
|
|
||||||
$tenant = $request->attributes->get('tenant');
|
|
||||||
|
|
||||||
if (! $tenant || (int) $purchase->tenant_id !== (int) $tenant->id) {
|
|
||||||
abort(404);
|
|
||||||
}
|
|
||||||
|
|
||||||
$purchase->loadMissing(['tenant.user', 'package']);
|
|
||||||
|
|
||||||
$locale = $request->user()?->preferred_locale ?? app()->getLocale();
|
|
||||||
app()->setLocale($locale);
|
|
||||||
|
|
||||||
$totals = $this->resolvePurchaseTotals($purchase);
|
|
||||||
$currency = $totals['currency'];
|
|
||||||
$total = $totals['total'];
|
|
||||||
$tax = $totals['tax'];
|
|
||||||
|
|
||||||
$buyer = $purchase->tenant?->user;
|
|
||||||
$buyerName = $buyer?->full_name ?? $buyer?->name ?? $buyer?->email ?? '';
|
|
||||||
$buyerEmail = $buyer?->email ?? '';
|
|
||||||
$buyerAddress = $buyer?->address ?? '';
|
|
||||||
|
|
||||||
$packageName = $this->resolvePackageName($purchase, $locale);
|
|
||||||
$packageTypeLabel = $this->resolvePackageTypeLabel($purchase->package?->type);
|
|
||||||
$providerLabel = $this->resolveProviderLabel($purchase->provider);
|
|
||||||
|
|
||||||
$purchaseDate = $this->formatDate($purchase->purchased_at, $locale);
|
|
||||||
$amountFormatted = $this->formatCurrency($total, $currency, $locale);
|
|
||||||
$taxFormatted = $tax !== null ? $this->formatCurrency($tax, $currency, $locale) : null;
|
|
||||||
$totalFormatted = $amountFormatted;
|
|
||||||
|
|
||||||
$html = view('billing.receipt', [
|
|
||||||
'receiptNumber' => (string) $purchase->getKey(),
|
|
||||||
'purchaseDate' => $purchaseDate,
|
|
||||||
'packageName' => $packageName,
|
|
||||||
'packageTypeLabel' => $packageTypeLabel,
|
|
||||||
'providerLabel' => $providerLabel,
|
|
||||||
'orderId' => $purchase->provider_id ?? $purchase->getKey(),
|
|
||||||
'buyerName' => $buyerName,
|
|
||||||
'buyerEmail' => $buyerEmail,
|
|
||||||
'buyerAddress' => $buyerAddress,
|
|
||||||
'amountFormatted' => $amountFormatted,
|
|
||||||
'taxFormatted' => $taxFormatted,
|
|
||||||
'totalFormatted' => $totalFormatted,
|
|
||||||
'currency' => $currency,
|
|
||||||
'companyName' => config('app.name', 'Fotospiel'),
|
|
||||||
'companyEmail' => config('mail.from.address', 'info@fotospiel.app'),
|
|
||||||
])->render();
|
|
||||||
|
|
||||||
$options = new Options;
|
|
||||||
$options->set('isHtml5ParserEnabled', true);
|
|
||||||
$options->set('isRemoteEnabled', true);
|
|
||||||
$options->set('defaultFont', 'Helvetica');
|
|
||||||
|
|
||||||
$dompdf = new Dompdf($options);
|
|
||||||
$dompdf->setPaper('A4', 'portrait');
|
|
||||||
$dompdf->loadHtml($html, 'UTF-8');
|
|
||||||
$dompdf->render();
|
|
||||||
|
|
||||||
$pdfBinary = $dompdf->output();
|
|
||||||
$filenameStem = Str::slug($packageName ?: 'receipt');
|
|
||||||
|
|
||||||
return response($pdfBinary)
|
|
||||||
->header('Content-Type', 'application/pdf')
|
|
||||||
->header('Content-Disposition', 'inline; filename="receipt-'.$filenameStem.'.pdf"');
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @return array{currency: string, total: float, tax: float|null}
|
|
||||||
*/
|
|
||||||
private function resolvePurchaseTotals(PackagePurchase $purchase): array
|
|
||||||
{
|
|
||||||
$metadata = $purchase->metadata ?? [];
|
|
||||||
$totals = $metadata['paypal_totals'] ?? $metadata['lemonsqueezy_totals'] ?? [];
|
|
||||||
|
|
||||||
$currency = $totals['currency']
|
|
||||||
?? $metadata['currency']
|
|
||||||
?? $purchase->package?->currency
|
|
||||||
?? 'EUR';
|
|
||||||
|
|
||||||
$total = array_key_exists('total', $totals)
|
|
||||||
? (float) $totals['total']
|
|
||||||
: (float) $purchase->price;
|
|
||||||
|
|
||||||
$tax = array_key_exists('tax', $totals) ? (float) $totals['tax'] : null;
|
|
||||||
|
|
||||||
return [
|
|
||||||
'currency' => strtoupper((string) $currency),
|
|
||||||
'total' => round($total, 2),
|
|
||||||
'tax' => $tax !== null ? round($tax, 2) : null,
|
|
||||||
];
|
|
||||||
}
|
|
||||||
|
|
||||||
private function resolvePackageName(PackagePurchase $purchase, string $locale): string
|
|
||||||
{
|
|
||||||
$package = $purchase->package;
|
|
||||||
if (! $package) {
|
|
||||||
return '';
|
|
||||||
}
|
|
||||||
|
|
||||||
$localized = $package->getNameForLocale($locale);
|
|
||||||
|
|
||||||
return $localized ?: (string) $package->name;
|
|
||||||
}
|
|
||||||
|
|
||||||
private function resolveProviderLabel(?string $provider): string
|
|
||||||
{
|
|
||||||
$provider = $provider ?: 'paypal';
|
|
||||||
$labelKey = 'emails.purchase.provider.'.$provider;
|
|
||||||
$label = __($labelKey);
|
|
||||||
|
|
||||||
if ($label === $labelKey) {
|
|
||||||
return ucfirst($provider);
|
|
||||||
}
|
|
||||||
|
|
||||||
return $label;
|
|
||||||
}
|
|
||||||
|
|
||||||
private function resolvePackageTypeLabel(?string $type): string
|
|
||||||
{
|
|
||||||
$type = $type ?: 'endcustomer';
|
|
||||||
$labelKey = 'emails.purchase.package_type.'.$type;
|
|
||||||
$label = __($labelKey);
|
|
||||||
|
|
||||||
if ($label === $labelKey) {
|
|
||||||
return ucfirst($type);
|
|
||||||
}
|
|
||||||
|
|
||||||
return $label;
|
|
||||||
}
|
|
||||||
|
|
||||||
private function formatCurrency(float $amount, string $currency, string $locale): string
|
|
||||||
{
|
|
||||||
$formatter = class_exists(\NumberFormatter::class)
|
|
||||||
? new \NumberFormatter($this->mapLocale($locale), \NumberFormatter::CURRENCY)
|
|
||||||
: null;
|
|
||||||
|
|
||||||
if ($formatter) {
|
|
||||||
$formatted = $formatter->formatCurrency($amount, $currency);
|
|
||||||
if ($formatted !== false) {
|
|
||||||
return $formatted;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
$symbol = match (strtoupper($currency)) {
|
|
||||||
'EUR' => '€',
|
|
||||||
'USD' => '$',
|
|
||||||
default => strtoupper($currency).' ',
|
|
||||||
};
|
|
||||||
|
|
||||||
return $symbol.number_format($amount, 2, ',', '.');
|
|
||||||
}
|
|
||||||
|
|
||||||
private function formatDate(?\Carbon\CarbonInterface $date, string $locale): string
|
|
||||||
{
|
|
||||||
if (! $date) {
|
|
||||||
return '';
|
|
||||||
}
|
|
||||||
|
|
||||||
$localized = $date->locale($locale);
|
|
||||||
|
|
||||||
if (str_starts_with($locale, 'en')) {
|
|
||||||
return $localized->translatedFormat('F j, Y');
|
|
||||||
}
|
|
||||||
|
|
||||||
return $localized->translatedFormat('d. F Y');
|
|
||||||
}
|
|
||||||
|
|
||||||
private function mapLocale(string $locale): string
|
|
||||||
{
|
|
||||||
$normalized = strtolower(str_replace('_', '-', $locale));
|
|
||||||
|
|
||||||
return match (true) {
|
|
||||||
str_starts_with($normalized, 'de') => 'de_DE',
|
|
||||||
str_starts_with($normalized, 'en') => 'en_US',
|
|
||||||
default => 'de_DE',
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -31,21 +31,18 @@ class TenantPackageController extends Controller
|
|||||||
->get();
|
->get();
|
||||||
|
|
||||||
$usageEventPackage = $this->resolveUsageEventPackage($tenant->id);
|
$usageEventPackage = $this->resolveUsageEventPackage($tenant->id);
|
||||||
$linkedEventPackages = $this->resolveLinkedEventPackages($tenant->id, $packages->pluck('id')->all());
|
|
||||||
|
|
||||||
$packages->each(function (TenantPackage $package) use ($usageEventPackage, $linkedEventPackages): void {
|
$packages->each(function (TenantPackage $package) use ($usageEventPackage): void {
|
||||||
$eventPackage = $package->active ? $usageEventPackage : null;
|
$eventPackage = $package->active ? $usageEventPackage : null;
|
||||||
$this->hydratePackageSnapshot($package, $eventPackage);
|
$this->hydratePackageSnapshot($package, $eventPackage);
|
||||||
$this->attachUsageEvents($package, $linkedEventPackages);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
$activePackage = $tenant->getActiveResellerPackage();
|
$activePackage = $tenant->activeResellerPackage?->load('package');
|
||||||
|
|
||||||
if (! ($activePackage instanceof TenantPackage)) {
|
if (! ($activePackage instanceof TenantPackage)) {
|
||||||
$activePackage = $packages->firstWhere('active', true);
|
$activePackage = $packages->firstWhere('active', true);
|
||||||
} else {
|
} else {
|
||||||
$this->hydratePackageSnapshot($activePackage, $usageEventPackage);
|
$this->hydratePackageSnapshot($activePackage, $usageEventPackage);
|
||||||
$this->attachUsageEvents($activePackage, $linkedEventPackages);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return response()->json([
|
return response()->json([
|
||||||
@@ -55,79 +52,6 @@ class TenantPackageController extends Controller
|
|||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* @param array<int, int> $tenantPackageIds
|
|
||||||
* @return array<int, array{current: ?EventPackage, last: ?EventPackage, count: int}>
|
|
||||||
*/
|
|
||||||
private function resolveLinkedEventPackages(int $tenantId, array $tenantPackageIds): array
|
|
||||||
{
|
|
||||||
if ($tenantPackageIds === []) {
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
|
|
||||||
$eventPackages = EventPackage::query()
|
|
||||||
->whereIn('tenant_package_id', $tenantPackageIds)
|
|
||||||
->whereHas('event', fn ($query) => $query->where('tenant_id', $tenantId))
|
|
||||||
->with(['event:id,slug,name,date,status'])
|
|
||||||
->orderByDesc('purchased_at')
|
|
||||||
->orderByDesc('created_at')
|
|
||||||
->get()
|
|
||||||
->groupBy('tenant_package_id');
|
|
||||||
|
|
||||||
$result = [];
|
|
||||||
|
|
||||||
foreach ($eventPackages as $tenantPackageId => $groupedPackages) {
|
|
||||||
$current = $groupedPackages
|
|
||||||
->first(function (EventPackage $eventPackage) {
|
|
||||||
return $eventPackage->gallery_expires_at && $eventPackage->gallery_expires_at->isFuture();
|
|
||||||
});
|
|
||||||
|
|
||||||
$result[(int) $tenantPackageId] = [
|
|
||||||
'current' => $current,
|
|
||||||
'last' => $groupedPackages->first(),
|
|
||||||
'count' => $groupedPackages->count(),
|
|
||||||
];
|
|
||||||
}
|
|
||||||
|
|
||||||
return $result;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @param array<int, array{current: ?EventPackage, last: ?EventPackage, count: int}> $linkedEventPackages
|
|
||||||
*/
|
|
||||||
private function attachUsageEvents(TenantPackage $package, array $linkedEventPackages): void
|
|
||||||
{
|
|
||||||
$usage = $linkedEventPackages[$package->id] ?? null;
|
|
||||||
|
|
||||||
if (! $usage) {
|
|
||||||
$package->linked_events_count = 0;
|
|
||||||
$package->current_event = null;
|
|
||||||
$package->last_event = null;
|
|
||||||
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
$package->linked_events_count = $usage['count'];
|
|
||||||
$package->current_event = $this->formatLinkedEvent($usage['current']);
|
|
||||||
$package->last_event = $this->formatLinkedEvent($usage['last']);
|
|
||||||
}
|
|
||||||
|
|
||||||
private function formatLinkedEvent(?EventPackage $eventPackage): ?array
|
|
||||||
{
|
|
||||||
if (! $eventPackage || ! $eventPackage->event) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
return [
|
|
||||||
'id' => $eventPackage->event->id,
|
|
||||||
'slug' => $eventPackage->event->slug,
|
|
||||||
'name' => $eventPackage->event->name,
|
|
||||||
'status' => $eventPackage->event->status,
|
|
||||||
'event_date' => $eventPackage->event->date?->toIso8601String(),
|
|
||||||
'linked_at' => $eventPackage->purchased_at?->toIso8601String(),
|
|
||||||
];
|
|
||||||
}
|
|
||||||
|
|
||||||
private function hydratePackageSnapshot(TenantPackage $package, ?EventPackage $eventPackage = null): void
|
private function hydratePackageSnapshot(TenantPackage $package, ?EventPackage $eventPackage = null): void
|
||||||
{
|
{
|
||||||
$pkg = $package->package;
|
$pkg = $package->package;
|
||||||
|
|||||||
@@ -157,10 +157,6 @@ class AuthenticatedSessionController extends Controller
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (str_starts_with($candidate, '//')) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (str_starts_with($candidate, '/')) {
|
if (str_starts_with($candidate, '/')) {
|
||||||
return $candidate;
|
return $candidate;
|
||||||
}
|
}
|
||||||
@@ -174,7 +170,7 @@ class AuthenticatedSessionController extends Controller
|
|||||||
|
|
||||||
$appHost = parse_url($request->getSchemeAndHttpHost(), PHP_URL_HOST);
|
$appHost = parse_url($request->getSchemeAndHttpHost(), PHP_URL_HOST);
|
||||||
|
|
||||||
if (! $appHost || ! $this->isAllowedReturnHost($targetHost, $appHost)) {
|
if ($appHost && ! Str::endsWith($targetHost, $appHost)) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -226,7 +222,7 @@ class AuthenticatedSessionController extends Controller
|
|||||||
$scheme = $parsed['scheme'] ?? null;
|
$scheme = $parsed['scheme'] ?? null;
|
||||||
$requestHost = parse_url($request->getSchemeAndHttpHost(), PHP_URL_HOST);
|
$requestHost = parse_url($request->getSchemeAndHttpHost(), PHP_URL_HOST);
|
||||||
|
|
||||||
if ($scheme && $host && $requestHost && ! $this->isAllowedReturnHost($host, $requestHost)) {
|
if ($scheme && $host && $requestHost && ! Str::endsWith($host, $requestHost)) {
|
||||||
return '/event-admin/dashboard';
|
return '/event-admin/dashboard';
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -269,15 +265,6 @@ class AuthenticatedSessionController extends Controller
|
|||||||
return $decoded;
|
return $decoded;
|
||||||
}
|
}
|
||||||
|
|
||||||
private function isAllowedReturnHost(string $targetHost, string $appHost): bool
|
|
||||||
{
|
|
||||||
if ($targetHost === $appHost) {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
return Str::endsWith($targetHost, '.'.$appHost);
|
|
||||||
}
|
|
||||||
|
|
||||||
private function rememberTenantAdminTarget(Request $request, ?string $target): void
|
private function rememberTenantAdminTarget(Request $request, ?string $target): void
|
||||||
{
|
{
|
||||||
$user = Auth::user();
|
$user = Auth::user();
|
||||||
|
|||||||
@@ -15,8 +15,8 @@ use App\Models\Tenant;
|
|||||||
use App\Models\User;
|
use App\Models\User;
|
||||||
use App\Services\Checkout\CheckoutAssignmentService;
|
use App\Services\Checkout\CheckoutAssignmentService;
|
||||||
use App\Services\Checkout\CheckoutSessionService;
|
use App\Services\Checkout\CheckoutSessionService;
|
||||||
use App\Services\LemonSqueezy\Exceptions\LemonSqueezyException;
|
use App\Services\Paddle\Exceptions\PaddleException;
|
||||||
use App\Services\LemonSqueezy\LemonSqueezyOrderService;
|
use App\Services\Paddle\PaddleTransactionService;
|
||||||
use App\Support\CheckoutRequestContext;
|
use App\Support\CheckoutRequestContext;
|
||||||
use App\Support\CheckoutRoutes;
|
use App\Support\CheckoutRoutes;
|
||||||
use App\Support\Concerns\PresentsPackages;
|
use App\Support\Concerns\PresentsPackages;
|
||||||
@@ -48,9 +48,6 @@ class CheckoutController extends Controller
|
|||||||
$googleStatus = session()->pull('checkout_google_status');
|
$googleStatus = session()->pull('checkout_google_status');
|
||||||
$googleError = session()->pull('checkout_google_error');
|
$googleError = session()->pull('checkout_google_error');
|
||||||
$googleProfile = session()->pull('checkout_google_profile');
|
$googleProfile = session()->pull('checkout_google_profile');
|
||||||
$facebookStatus = session()->pull('checkout_facebook_status');
|
|
||||||
$facebookError = session()->pull('checkout_facebook_error');
|
|
||||||
$facebookProfile = session()->pull('checkout_facebook_profile');
|
|
||||||
|
|
||||||
$packageOptions = Package::orderBy('price')->get()
|
$packageOptions = Package::orderBy('price')->get()
|
||||||
->map(fn (Package $pkg) => $this->presentPackage($pkg))
|
->map(fn (Package $pkg) => $this->presentPackage($pkg))
|
||||||
@@ -69,16 +66,9 @@ class CheckoutController extends Controller
|
|||||||
'error' => $googleError,
|
'error' => $googleError,
|
||||||
'profile' => $googleProfile,
|
'profile' => $googleProfile,
|
||||||
],
|
],
|
||||||
'facebookAuth' => [
|
'paddle' => [
|
||||||
'status' => $facebookStatus,
|
'environment' => config('paddle.environment'),
|
||||||
'error' => $facebookError,
|
'client_token' => config('paddle.client_token'),
|
||||||
'profile' => $facebookProfile,
|
|
||||||
],
|
|
||||||
'paypal' => [
|
|
||||||
'client_id' => config('services.paypal.client_id'),
|
|
||||||
'currency' => config('checkout.currency', 'EUR'),
|
|
||||||
'intent' => 'capture',
|
|
||||||
'locale' => app()->getLocale(),
|
|
||||||
],
|
],
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
@@ -273,9 +263,9 @@ class CheckoutController extends Controller
|
|||||||
CheckoutSession $session,
|
CheckoutSession $session,
|
||||||
CheckoutSessionService $sessions,
|
CheckoutSessionService $sessions,
|
||||||
CheckoutAssignmentService $assignment,
|
CheckoutAssignmentService $assignment,
|
||||||
LemonSqueezyOrderService $orders,
|
PaddleTransactionService $transactions,
|
||||||
): JsonResponse {
|
): JsonResponse {
|
||||||
$this->attemptLemonSqueezyRecovery($session, $sessions, $assignment, $orders);
|
$this->attemptPaddleRecovery($session, $sessions, $assignment, $transactions);
|
||||||
|
|
||||||
$session->refresh();
|
$session->refresh();
|
||||||
|
|
||||||
@@ -290,56 +280,56 @@ class CheckoutController extends Controller
|
|||||||
CheckoutSession $session,
|
CheckoutSession $session,
|
||||||
CheckoutSessionService $sessions,
|
CheckoutSessionService $sessions,
|
||||||
CheckoutAssignmentService $assignment,
|
CheckoutAssignmentService $assignment,
|
||||||
LemonSqueezyOrderService $orders,
|
PaddleTransactionService $transactions,
|
||||||
): JsonResponse {
|
): JsonResponse {
|
||||||
$validated = $request->validated();
|
$validated = $request->validated();
|
||||||
$orderId = $validated['order_id'] ?? null;
|
$transactionId = $validated['transaction_id'] ?? null;
|
||||||
$checkoutId = $validated['checkout_id'] ?? null;
|
$checkoutId = $validated['checkout_id'] ?? null;
|
||||||
|
|
||||||
$metadata = $session->provider_metadata ?? [];
|
$metadata = $session->provider_metadata ?? [];
|
||||||
$metadataUpdated = false;
|
$metadataUpdated = false;
|
||||||
|
|
||||||
if ($orderId) {
|
if ($transactionId) {
|
||||||
$session->lemonsqueezy_order_id = $orderId;
|
$session->paddle_transaction_id = $transactionId;
|
||||||
$metadata['lemonsqueezy_order_id'] = $orderId;
|
$metadata['paddle_transaction_id'] = $transactionId;
|
||||||
$metadataUpdated = true;
|
$metadataUpdated = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
if ($checkoutId) {
|
if ($checkoutId) {
|
||||||
$metadata['lemonsqueezy_checkout_id'] = $checkoutId;
|
$metadata['paddle_checkout_id'] = $checkoutId;
|
||||||
$metadataUpdated = true;
|
$metadataUpdated = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
if ($metadataUpdated) {
|
if ($metadataUpdated) {
|
||||||
$metadata['lemonsqueezy_client_event_at'] = now()->toIso8601String();
|
$metadata['paddle_client_event_at'] = now()->toIso8601String();
|
||||||
$session->provider_metadata = $metadata;
|
$session->provider_metadata = $metadata;
|
||||||
$session->save();
|
$session->save();
|
||||||
}
|
}
|
||||||
|
|
||||||
if (app()->environment('local')
|
if (app()->environment('local')
|
||||||
&& $session->provider === CheckoutSession::PROVIDER_LEMONSQUEEZY
|
&& $session->provider === CheckoutSession::PROVIDER_PADDLE
|
||||||
&& ! in_array($session->status, [
|
&& ! in_array($session->status, [
|
||||||
CheckoutSession::STATUS_COMPLETED,
|
CheckoutSession::STATUS_COMPLETED,
|
||||||
CheckoutSession::STATUS_FAILED,
|
CheckoutSession::STATUS_FAILED,
|
||||||
CheckoutSession::STATUS_CANCELLED,
|
CheckoutSession::STATUS_CANCELLED,
|
||||||
], true)
|
], true)
|
||||||
&& ($orderId || $checkoutId)
|
&& ($transactionId || $checkoutId)
|
||||||
) {
|
) {
|
||||||
$sessions->markProcessing($session, array_filter([
|
$sessions->markProcessing($session, array_filter([
|
||||||
'lemonsqueezy_status' => 'paid',
|
'paddle_status' => 'completed',
|
||||||
'lemonsqueezy_order_id' => $orderId,
|
'paddle_transaction_id' => $transactionId,
|
||||||
'lemonsqueezy_local_confirmed_at' => now()->toIso8601String(),
|
'paddle_local_confirmed_at' => now()->toIso8601String(),
|
||||||
]));
|
]));
|
||||||
|
|
||||||
$assignment->finalise($session, [
|
$assignment->finalise($session, [
|
||||||
'source' => 'lemonsqueezy_local',
|
'source' => 'paddle_local',
|
||||||
'provider' => CheckoutSession::PROVIDER_LEMONSQUEEZY,
|
'provider' => CheckoutSession::PROVIDER_PADDLE,
|
||||||
'provider_reference' => $orderId ?? $checkoutId,
|
'provider_reference' => $transactionId ?? $checkoutId,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
$sessions->markCompleted($session);
|
$sessions->markCompleted($session);
|
||||||
} else {
|
} else {
|
||||||
$this->attemptLemonSqueezyRecovery($session, $sessions, $assignment, $orders);
|
$this->attemptPaddleRecovery($session, $sessions, $assignment, $transactions);
|
||||||
}
|
}
|
||||||
|
|
||||||
$session->refresh();
|
$session->refresh();
|
||||||
@@ -421,13 +411,13 @@ class CheckoutController extends Controller
|
|||||||
return $price <= 0;
|
return $price <= 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
private function attemptLemonSqueezyRecovery(
|
private function attemptPaddleRecovery(
|
||||||
CheckoutSession $session,
|
CheckoutSession $session,
|
||||||
CheckoutSessionService $sessions,
|
CheckoutSessionService $sessions,
|
||||||
CheckoutAssignmentService $assignment,
|
CheckoutAssignmentService $assignment,
|
||||||
LemonSqueezyOrderService $orders
|
PaddleTransactionService $transactions
|
||||||
): void {
|
): void {
|
||||||
if ($session->provider !== CheckoutSession::PROVIDER_LEMONSQUEEZY) {
|
if ($session->provider !== CheckoutSession::PROVIDER_PADDLE) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -440,7 +430,7 @@ class CheckoutController extends Controller
|
|||||||
}
|
}
|
||||||
|
|
||||||
$metadata = $session->provider_metadata ?? [];
|
$metadata = $session->provider_metadata ?? [];
|
||||||
$lastPollAt = $metadata['lemonsqueezy_poll_at'] ?? null;
|
$lastPollAt = $metadata['paddle_poll_at'] ?? null;
|
||||||
$now = now();
|
$now = now();
|
||||||
|
|
||||||
if ($lastPollAt) {
|
if ($lastPollAt) {
|
||||||
@@ -454,31 +444,39 @@ class CheckoutController extends Controller
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
$checkoutId = $metadata['lemonsqueezy_checkout_id'] ?? $session->lemonsqueezy_checkout_id ?? null;
|
$checkoutId = $metadata['paddle_checkout_id'] ?? $session->paddle_checkout_id ?? null;
|
||||||
$orderId = $metadata['lemonsqueezy_order_id'] ?? $session->lemonsqueezy_order_id ?? null;
|
$transactionId = $metadata['paddle_transaction_id'] ?? $session->paddle_transaction_id ?? null;
|
||||||
|
|
||||||
if (! $checkoutId && ! $orderId) {
|
if (! $checkoutId && ! $transactionId) {
|
||||||
Log::info('[Checkout] Lemon Squeezy recovery missing checkout reference', [
|
Log::info('[Checkout] Paddle recovery missing checkout reference, falling back to custom data scan', [
|
||||||
'session_id' => $session->id,
|
'session_id' => $session->id,
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
$metadata['lemonsqueezy_poll_at'] = $now->toIso8601String();
|
$metadata['paddle_poll_at'] = $now->toIso8601String();
|
||||||
$session->forceFill([
|
$session->forceFill([
|
||||||
'provider_metadata' => $metadata,
|
'provider_metadata' => $metadata,
|
||||||
])->save();
|
])->save();
|
||||||
|
|
||||||
try {
|
try {
|
||||||
$order = $orderId ? $orders->retrieve($orderId) : null;
|
$transaction = $transactionId ? $transactions->retrieve($transactionId) : null;
|
||||||
|
|
||||||
if (! $order && $checkoutId) {
|
if (! $transaction && $checkoutId) {
|
||||||
$order = $orders->findByCheckoutId($checkoutId);
|
$transaction = $transactions->findByCheckoutId($checkoutId);
|
||||||
}
|
}
|
||||||
} catch (LemonSqueezyException $exception) {
|
|
||||||
Log::warning('[Checkout] Lemon Squeezy recovery failed', [
|
if (! $transaction) {
|
||||||
|
$transaction = $transactions->findByCustomData([
|
||||||
|
'checkout_session_id' => $session->id,
|
||||||
|
'package_id' => (string) $session->package_id,
|
||||||
|
'tenant_id' => (string) $session->tenant_id,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
} catch (PaddleException $exception) {
|
||||||
|
Log::warning('[Checkout] Paddle recovery failed', [
|
||||||
'session_id' => $session->id,
|
'session_id' => $session->id,
|
||||||
'checkout_id' => $checkoutId,
|
'checkout_id' => $checkoutId,
|
||||||
'order_id' => $orderId,
|
'transaction_id' => $transactionId,
|
||||||
'status' => $exception->status(),
|
'status' => $exception->status(),
|
||||||
'message' => $exception->getMessage(),
|
'message' => $exception->getMessage(),
|
||||||
'context' => $exception->context(),
|
'context' => $exception->context(),
|
||||||
@@ -486,77 +484,77 @@ class CheckoutController extends Controller
|
|||||||
|
|
||||||
return;
|
return;
|
||||||
} catch (\Throwable $exception) {
|
} catch (\Throwable $exception) {
|
||||||
Log::warning('[Checkout] Lemon Squeezy recovery failed', [
|
Log::warning('[Checkout] Paddle recovery failed', [
|
||||||
'session_id' => $session->id,
|
'session_id' => $session->id,
|
||||||
'checkout_id' => $checkoutId,
|
'checkout_id' => $checkoutId,
|
||||||
'order_id' => $orderId,
|
'transaction_id' => $transactionId,
|
||||||
'message' => $exception->getMessage(),
|
'message' => $exception->getMessage(),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (! $order) {
|
if (! $transaction) {
|
||||||
Log::info('[Checkout] Lemon Squeezy recovery: order not found', [
|
Log::info('[Checkout] Paddle recovery: transaction not found', [
|
||||||
'session_id' => $session->id,
|
'session_id' => $session->id,
|
||||||
'checkout_id' => $checkoutId,
|
'checkout_id' => $checkoutId,
|
||||||
'order_id' => $orderId,
|
'transaction_id' => $transactionId,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
$status = strtolower((string) data_get($order, 'attributes.status', ''));
|
$status = strtolower((string) ($transaction['status'] ?? ''));
|
||||||
$resolvedOrderId = $orderId ?: data_get($order, 'id');
|
$transactionId = $transactionId ?: ($transaction['id'] ?? null);
|
||||||
|
|
||||||
if ($resolvedOrderId && $session->lemonsqueezy_order_id !== $resolvedOrderId) {
|
if ($transactionId && $session->paddle_transaction_id !== $transactionId) {
|
||||||
$session->forceFill([
|
$session->forceFill([
|
||||||
'lemonsqueezy_order_id' => $resolvedOrderId,
|
'paddle_transaction_id' => $transactionId,
|
||||||
])->save();
|
])->save();
|
||||||
}
|
}
|
||||||
|
|
||||||
if (in_array($status, ['paid', 'completed'], true)) {
|
if ($status === 'completed') {
|
||||||
$sessions->markProcessing($session, [
|
$sessions->markProcessing($session, [
|
||||||
'lemonsqueezy_status' => $status,
|
'paddle_status' => $status,
|
||||||
'lemonsqueezy_order_id' => $resolvedOrderId,
|
'paddle_transaction_id' => $transactionId,
|
||||||
'lemonsqueezy_recovered_at' => $now->toIso8601String(),
|
'paddle_recovered_at' => $now->toIso8601String(),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
$assignment->finalise($session, [
|
$assignment->finalise($session, [
|
||||||
'source' => 'lemonsqueezy_poll',
|
'source' => 'paddle_poll',
|
||||||
'provider' => CheckoutSession::PROVIDER_LEMONSQUEEZY,
|
'provider' => CheckoutSession::PROVIDER_PADDLE,
|
||||||
'provider_reference' => $resolvedOrderId,
|
'provider_reference' => $transactionId,
|
||||||
'payload' => $order,
|
'payload' => $transaction,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
$sessions->markCompleted($session, $now);
|
$sessions->markCompleted($session, $now);
|
||||||
|
|
||||||
Log::info('[Checkout] Lemon Squeezy session recovered via API', [
|
Log::info('[Checkout] Paddle session recovered via API', [
|
||||||
'session_id' => $session->id,
|
'session_id' => $session->id,
|
||||||
'checkout_id' => $checkoutId,
|
'checkout_id' => $checkoutId,
|
||||||
'order_id' => $resolvedOrderId,
|
'transaction_id' => $transactionId,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (in_array($status, ['failed', 'cancelled', 'canceled', 'refunded', 'voided'], true)) {
|
if (in_array($status, ['failed', 'cancelled', 'canceled'], true)) {
|
||||||
$sessions->markFailed($session, 'lemonsqueezy_'.$status);
|
$sessions->markFailed($session, 'paddle_'.$status);
|
||||||
|
|
||||||
Log::info('[Checkout] Lemon Squeezy order failed', [
|
Log::info('[Checkout] Paddle transaction failed', [
|
||||||
'session_id' => $session->id,
|
'session_id' => $session->id,
|
||||||
'checkout_id' => $checkoutId,
|
'checkout_id' => $checkoutId,
|
||||||
'order_id' => $resolvedOrderId,
|
'transaction_id' => $transactionId,
|
||||||
'status' => $status,
|
'status' => $status,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
Log::info('[Checkout] Lemon Squeezy order pending', [
|
Log::info('[Checkout] Paddle transaction pending', [
|
||||||
'session_id' => $session->id,
|
'session_id' => $session->id,
|
||||||
'checkout_id' => $checkoutId,
|
'checkout_id' => $checkoutId,
|
||||||
'order_id' => $resolvedOrderId,
|
'transaction_id' => $transactionId,
|
||||||
'status' => $status,
|
'status' => $status,
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,217 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
namespace App\Http\Controllers;
|
|
||||||
|
|
||||||
use App\Models\Package;
|
|
||||||
use App\Models\Tenant;
|
|
||||||
use App\Models\User;
|
|
||||||
use App\Support\CheckoutRoutes;
|
|
||||||
use App\Support\LocaleConfig;
|
|
||||||
use Illuminate\Http\Request;
|
|
||||||
use Illuminate\Support\Facades\Auth;
|
|
||||||
use Illuminate\Support\Facades\DB;
|
|
||||||
use Illuminate\Support\Facades\Log;
|
|
||||||
use Illuminate\Support\Str;
|
|
||||||
use Laravel\Socialite\Facades\Socialite;
|
|
||||||
use Symfony\Component\HttpFoundation\RedirectResponse;
|
|
||||||
|
|
||||||
class CheckoutFacebookController extends Controller
|
|
||||||
{
|
|
||||||
private const SESSION_KEY = 'checkout_facebook_payload';
|
|
||||||
|
|
||||||
public function redirect(Request $request): RedirectResponse
|
|
||||||
{
|
|
||||||
$validated = $request->validate([
|
|
||||||
'package_id' => ['required', 'exists:packages,id'],
|
|
||||||
'locale' => ['nullable', 'string'],
|
|
||||||
]);
|
|
||||||
|
|
||||||
$payload = [
|
|
||||||
'package_id' => (int) $validated['package_id'],
|
|
||||||
'locale' => $validated['locale'] ?? app()->getLocale(),
|
|
||||||
];
|
|
||||||
|
|
||||||
$request->session()->put(self::SESSION_KEY, $payload);
|
|
||||||
$request->session()->put('selected_package_id', $payload['package_id']);
|
|
||||||
|
|
||||||
return Socialite::driver('facebook')
|
|
||||||
->redirectUrl(route('checkout.facebook.callback'))
|
|
||||||
->scopes(['email'])
|
|
||||||
->fields(['name', 'email', 'first_name', 'last_name'])
|
|
||||||
->redirect();
|
|
||||||
}
|
|
||||||
|
|
||||||
public function callback(Request $request): RedirectResponse
|
|
||||||
{
|
|
||||||
$payload = $request->session()->get(self::SESSION_KEY, []);
|
|
||||||
$packageId = $payload['package_id'] ?? null;
|
|
||||||
$locale = $payload['locale'] ?? null;
|
|
||||||
|
|
||||||
try {
|
|
||||||
$facebookUser = Socialite::driver('facebook')->user();
|
|
||||||
} catch (\Throwable $e) {
|
|
||||||
Log::warning('Facebook checkout login failed', ['message' => $e->getMessage()]);
|
|
||||||
$this->flashError($request, __('checkout.facebook_error_fallback'));
|
|
||||||
|
|
||||||
return $this->redirectBackToWizard($packageId, $locale);
|
|
||||||
}
|
|
||||||
|
|
||||||
$email = $facebookUser->getEmail();
|
|
||||||
if (! $email) {
|
|
||||||
$this->flashError($request, __('checkout.facebook_missing_email'));
|
|
||||||
|
|
||||||
return $this->redirectBackToWizard($packageId, $locale);
|
|
||||||
}
|
|
||||||
|
|
||||||
$raw = $facebookUser->getRaw();
|
|
||||||
$givenName = $raw['first_name'] ?? null;
|
|
||||||
$familyName = $raw['last_name'] ?? null;
|
|
||||||
$request->session()->put('checkout_facebook_profile', array_filter([
|
|
||||||
'email' => $email,
|
|
||||||
'name' => $facebookUser->getName(),
|
|
||||||
'given_name' => $givenName,
|
|
||||||
'family_name' => $familyName,
|
|
||||||
'avatar' => $facebookUser->getAvatar(),
|
|
||||||
'locale' => $raw['locale'] ?? null,
|
|
||||||
]));
|
|
||||||
|
|
||||||
$existing = User::where('email', $email)->first();
|
|
||||||
|
|
||||||
if (! $existing) {
|
|
||||||
$request->session()->put('checkout_facebook_profile', array_filter([
|
|
||||||
'email' => $email,
|
|
||||||
'name' => $facebookUser->getName(),
|
|
||||||
'given_name' => $givenName,
|
|
||||||
'family_name' => $familyName,
|
|
||||||
'avatar' => $facebookUser->getAvatar(),
|
|
||||||
'locale' => $raw['locale'] ?? null,
|
|
||||||
]));
|
|
||||||
|
|
||||||
$request->session()->put('checkout_facebook_status', 'prefill');
|
|
||||||
|
|
||||||
return $this->redirectBackToWizard($packageId, $locale);
|
|
||||||
}
|
|
||||||
|
|
||||||
$user = DB::transaction(function () use ($existing, $facebookUser, $email) {
|
|
||||||
$existing->forceFill([
|
|
||||||
'name' => $facebookUser->getName() ?: $existing->name,
|
|
||||||
'pending_purchase' => true,
|
|
||||||
'email_verified_at' => $existing->email_verified_at ?? now(),
|
|
||||||
])->save();
|
|
||||||
|
|
||||||
if (! $existing->tenant) {
|
|
||||||
$this->createTenantForUser($existing, $facebookUser->getName(), $email);
|
|
||||||
}
|
|
||||||
|
|
||||||
return $existing->fresh();
|
|
||||||
});
|
|
||||||
|
|
||||||
if (! $user->tenant) {
|
|
||||||
$this->createTenantForUser($user, $facebookUser->getName(), $email);
|
|
||||||
}
|
|
||||||
|
|
||||||
Auth::login($user, true);
|
|
||||||
$request->session()->regenerate();
|
|
||||||
$request->session()->forget(self::SESSION_KEY);
|
|
||||||
$request->session()->forget('checkout_facebook_profile');
|
|
||||||
$request->session()->put('checkout_facebook_status', 'signin');
|
|
||||||
|
|
||||||
if ($packageId) {
|
|
||||||
$this->ensurePackageAttached($user, (int) $packageId);
|
|
||||||
}
|
|
||||||
|
|
||||||
return $this->redirectBackToWizard($packageId, $locale);
|
|
||||||
}
|
|
||||||
|
|
||||||
private function createTenantForUser(User $user, ?string $displayName, string $email): Tenant
|
|
||||||
{
|
|
||||||
$tenantName = trim($displayName ?: Str::before($email, '@')) ?: 'Fotospiel Tenant';
|
|
||||||
$slugBase = Str::slug($tenantName) ?: 'tenant';
|
|
||||||
$slug = $slugBase;
|
|
||||||
$counter = 1;
|
|
||||||
|
|
||||||
while (Tenant::where('slug', $slug)->exists()) {
|
|
||||||
$slug = $slugBase.'-'.$counter;
|
|
||||||
$counter++;
|
|
||||||
}
|
|
||||||
|
|
||||||
$tenant = Tenant::create([
|
|
||||||
'user_id' => $user->id,
|
|
||||||
'name' => $tenantName,
|
|
||||||
'slug' => $slug,
|
|
||||||
'email' => $email,
|
|
||||||
'contact_email' => $email,
|
|
||||||
'is_active' => true,
|
|
||||||
'is_suspended' => false,
|
|
||||||
'subscription_tier' => 'free',
|
|
||||||
'subscription_status' => 'free',
|
|
||||||
'subscription_expires_at' => null,
|
|
||||||
'settings' => json_encode([
|
|
||||||
'branding' => [
|
|
||||||
'logo_url' => null,
|
|
||||||
'primary_color' => '#FF5A5F',
|
|
||||||
'secondary_color' => '#FFF8F5',
|
|
||||||
'font_family' => 'Inter, sans-serif',
|
|
||||||
],
|
|
||||||
'features' => [
|
|
||||||
'photo_likes_enabled' => false,
|
|
||||||
'event_checklist' => false,
|
|
||||||
'custom_domain' => false,
|
|
||||||
'advanced_analytics' => false,
|
|
||||||
],
|
|
||||||
'custom_domain' => null,
|
|
||||||
'contact_email' => $email,
|
|
||||||
'event_default_type' => 'general',
|
|
||||||
]),
|
|
||||||
]);
|
|
||||||
|
|
||||||
$user->forceFill(['tenant_id' => $tenant->id])->save();
|
|
||||||
|
|
||||||
return $tenant;
|
|
||||||
}
|
|
||||||
|
|
||||||
private function ensurePackageAttached(User $user, int $packageId): void
|
|
||||||
{
|
|
||||||
$tenant = $user->tenant;
|
|
||||||
if (! $tenant) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
$package = Package::find($packageId);
|
|
||||||
if (! $package) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if ($tenant->packages()->where('package_id', $packageId)->exists()) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
$tenant->packages()->attach($packageId, [
|
|
||||||
'price' => $package->price,
|
|
||||||
'purchased_at' => now(),
|
|
||||||
'expires_at' => now()->addYear(),
|
|
||||||
'active' => $package->price <= 0,
|
|
||||||
]);
|
|
||||||
}
|
|
||||||
|
|
||||||
private function redirectBackToWizard(?int $packageId, ?string $locale = null): RedirectResponse
|
|
||||||
{
|
|
||||||
if ($packageId) {
|
|
||||||
return redirect()->to(CheckoutRoutes::wizardUrl($packageId, $locale));
|
|
||||||
}
|
|
||||||
|
|
||||||
$firstPackageId = Package::query()->orderBy('price')->value('id');
|
|
||||||
if ($firstPackageId) {
|
|
||||||
return redirect()->to(CheckoutRoutes::wizardUrl($firstPackageId, $locale));
|
|
||||||
}
|
|
||||||
|
|
||||||
return redirect()->route('packages', [
|
|
||||||
'locale' => LocaleConfig::canonicalize($locale ?? app()->getLocale()),
|
|
||||||
]);
|
|
||||||
}
|
|
||||||
|
|
||||||
private function flashError(Request $request, string $message): void
|
|
||||||
{
|
|
||||||
$request->session()->flash('checkout_facebook_error', $message);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -35,7 +35,6 @@ class CheckoutGoogleController extends Controller
|
|||||||
$request->session()->put('selected_package_id', $payload['package_id']);
|
$request->session()->put('selected_package_id', $payload['package_id']);
|
||||||
|
|
||||||
return Socialite::driver('google')
|
return Socialite::driver('google')
|
||||||
->redirectUrl(route('checkout.google.callback'))
|
|
||||||
->scopes(['email', 'profile'])
|
->scopes(['email', 'profile'])
|
||||||
->with(['prompt' => 'select_account'])
|
->with(['prompt' => 'select_account'])
|
||||||
->redirect();
|
->redirect();
|
||||||
|
|||||||
@@ -69,7 +69,7 @@ class LegalPageController extends Controller
|
|||||||
$effectiveFrom = optional($page->effective_from);
|
$effectiveFrom = optional($page->effective_from);
|
||||||
|
|
||||||
return Inertia::render('legal/Show', [
|
return Inertia::render('legal/Show', [
|
||||||
'seoTitle' => $title.' - '.config('app.name', 'Fotospiel'),
|
'seoTitle' => $title . ' - ' . config('app.name', 'Fotospiel'),
|
||||||
'title' => $title,
|
'title' => $title,
|
||||||
'content' => $this->convertMarkdownToHtml($bodyMarkdown),
|
'content' => $this->convertMarkdownToHtml($bodyMarkdown),
|
||||||
'effectiveFrom' => $effectiveFrom ? $effectiveFrom->toDateString() : null,
|
'effectiveFrom' => $effectiveFrom ? $effectiveFrom->toDateString() : null,
|
||||||
@@ -112,11 +112,11 @@ class LegalPageController extends Controller
|
|||||||
'allow_unsafe_links' => false,
|
'allow_unsafe_links' => false,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
$environment->addExtension(new CommonMarkCoreExtension);
|
$environment->addExtension(new CommonMarkCoreExtension());
|
||||||
$environment->addExtension(new TableExtension);
|
$environment->addExtension(new TableExtension());
|
||||||
$environment->addExtension(new AutolinkExtension);
|
$environment->addExtension(new AutolinkExtension());
|
||||||
$environment->addExtension(new StrikethroughExtension);
|
$environment->addExtension(new StrikethroughExtension());
|
||||||
$environment->addExtension(new TaskListExtension);
|
$environment->addExtension(new TaskListExtension());
|
||||||
|
|
||||||
$converter = new MarkdownConverter($environment);
|
$converter = new MarkdownConverter($environment);
|
||||||
|
|
||||||
|
|||||||
@@ -13,8 +13,7 @@ use App\Models\TenantPackage;
|
|||||||
use App\Services\Checkout\CheckoutSessionService;
|
use App\Services\Checkout\CheckoutSessionService;
|
||||||
use App\Services\Coupons\CouponService;
|
use App\Services\Coupons\CouponService;
|
||||||
use App\Services\GiftVouchers\GiftVoucherCheckoutService;
|
use App\Services\GiftVouchers\GiftVoucherCheckoutService;
|
||||||
use App\Services\PayPal\Exceptions\PayPalException;
|
use App\Services\Paddle\PaddleCheckoutService;
|
||||||
use App\Services\PayPal\PayPalOrderService;
|
|
||||||
use App\Support\CheckoutRequestContext;
|
use App\Support\CheckoutRequestContext;
|
||||||
use App\Support\CheckoutRoutes;
|
use App\Support\CheckoutRoutes;
|
||||||
use App\Support\Concerns\PresentsPackages;
|
use App\Support\Concerns\PresentsPackages;
|
||||||
@@ -42,7 +41,7 @@ class MarketingController extends Controller
|
|||||||
|
|
||||||
public function __construct(
|
public function __construct(
|
||||||
private readonly CheckoutSessionService $checkoutSessions,
|
private readonly CheckoutSessionService $checkoutSessions,
|
||||||
private readonly PayPalOrderService $paypalOrders,
|
private readonly PaddleCheckoutService $paddleCheckout,
|
||||||
private readonly CouponService $coupons,
|
private readonly CouponService $coupons,
|
||||||
private readonly GiftVoucherCheckoutService $giftVouchers,
|
private readonly GiftVoucherCheckoutService $giftVouchers,
|
||||||
) {}
|
) {}
|
||||||
@@ -65,6 +64,7 @@ class MarketingController extends Controller
|
|||||||
'name' => 'required|string|max:255',
|
'name' => 'required|string|max:255',
|
||||||
'email' => 'required|email|max:255',
|
'email' => 'required|email|max:255',
|
||||||
'message' => 'required|string|max:1000',
|
'message' => 'required|string|max:1000',
|
||||||
|
'nickname' => 'present|size:0',
|
||||||
]);
|
]);
|
||||||
|
|
||||||
$locale = app()->getLocale();
|
$locale = app()->getLocale();
|
||||||
@@ -195,6 +195,16 @@ class MarketingController extends Controller
|
|||||||
return redirect('/event-admin')->with('success', __('marketing.packages.free_assigned'));
|
return redirect('/event-admin')->with('success', __('marketing.packages.free_assigned'));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (! $package->paddle_price_id) {
|
||||||
|
Log::warning('Package missing Paddle price id', ['package_id' => $package->id]);
|
||||||
|
|
||||||
|
return redirect()->route('packages', [
|
||||||
|
'locale' => app()->getLocale(),
|
||||||
|
'highlight' => $package->slug,
|
||||||
|
])
|
||||||
|
->with('error', __('marketing.packages.paddle_not_configured'));
|
||||||
|
}
|
||||||
|
|
||||||
$session = $this->checkoutSessions->createOrResume($user, $package, array_merge(
|
$session = $this->checkoutSessions->createOrResume($user, $package, array_merge(
|
||||||
CheckoutRequestContext::fromRequest($request),
|
CheckoutRequestContext::fromRequest($request),
|
||||||
[
|
[
|
||||||
@@ -202,7 +212,7 @@ class MarketingController extends Controller
|
|||||||
]
|
]
|
||||||
));
|
));
|
||||||
|
|
||||||
$this->checkoutSessions->selectProvider($session, CheckoutSession::PROVIDER_PAYPAL);
|
$this->checkoutSessions->selectProvider($session, CheckoutSession::PROVIDER_PADDLE);
|
||||||
|
|
||||||
$now = now();
|
$now = now();
|
||||||
|
|
||||||
@@ -214,71 +224,52 @@ class MarketingController extends Controller
|
|||||||
'legal_version' => $this->resolveLegalVersion(),
|
'legal_version' => $this->resolveLegalVersion(),
|
||||||
])->save();
|
])->save();
|
||||||
|
|
||||||
|
$appliedDiscountId = null;
|
||||||
|
|
||||||
if ($couponCode) {
|
if ($couponCode) {
|
||||||
try {
|
try {
|
||||||
$preview = $this->coupons->preview($couponCode, $package, $tenant);
|
$preview = $this->coupons->preview($couponCode, $package, $tenant);
|
||||||
$this->checkoutSessions->applyCoupon($session, $preview['coupon'], $preview['pricing']);
|
$this->checkoutSessions->applyCoupon($session, $preview['coupon'], $preview['pricing']);
|
||||||
|
$appliedDiscountId = $preview['coupon']->paddle_discount_id;
|
||||||
$request->session()->forget('marketing.checkout.coupon');
|
$request->session()->forget('marketing.checkout.coupon');
|
||||||
} catch (ValidationException $exception) {
|
} catch (ValidationException $exception) {
|
||||||
$request->session()->flash('coupon_error', $exception->errors()['code'][0] ?? __('marketing.coupon.errors.generic'));
|
$request->session()->flash('coupon_error', $exception->errors()['code'][0] ?? __('marketing.coupon.errors.generic'));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
$successUrl = route('marketing.success', [
|
$checkout = $this->paddleCheckout->createCheckout($tenant, $package, [
|
||||||
'locale' => app()->getLocale(),
|
'success_url' => route('marketing.success', [
|
||||||
'packageId' => $package->id,
|
|
||||||
]);
|
|
||||||
$cancelUrl = route('packages', [
|
|
||||||
'locale' => app()->getLocale(),
|
|
||||||
'highlight' => $package->slug,
|
|
||||||
]);
|
|
||||||
|
|
||||||
try {
|
|
||||||
$checkout = $this->paypalOrders->createOrder($session, $package, [
|
|
||||||
'return_url' => route('paypal.return', absolute: true),
|
|
||||||
'cancel_url' => route('paypal.return', absolute: true),
|
|
||||||
'locale' => app()->getLocale(),
|
'locale' => app()->getLocale(),
|
||||||
'request_id' => $session->id,
|
'packageId' => $package->id,
|
||||||
]);
|
]),
|
||||||
} catch (PayPalException $exception) {
|
'return_url' => route('packages', [
|
||||||
Log::warning('PayPal checkout failed', [
|
'locale' => app()->getLocale(),
|
||||||
'package_id' => $package->id,
|
'highlight' => $package->slug,
|
||||||
'tenant_id' => $tenant->id,
|
]),
|
||||||
'message' => $exception->getMessage(),
|
'metadata' => [
|
||||||
'status' => $exception->status(),
|
'checkout_session_id' => $session->id,
|
||||||
]);
|
'coupon_code' => $couponCode,
|
||||||
|
'legal_version' => $session->legal_version,
|
||||||
throw ValidationException::withMessages([
|
'accepted_terms' => (bool) $session->accepted_terms_at,
|
||||||
'paypal' => __('marketing.packages.paypal_checkout_failed'),
|
'accepted_waiver' => $requiresWaiver && (bool) $session->digital_content_waiver_at,
|
||||||
]);
|
],
|
||||||
}
|
'discount_id' => $appliedDiscountId,
|
||||||
|
]);
|
||||||
$orderId = $checkout['id'] ?? null;
|
|
||||||
if (! is_string($orderId) || $orderId === '') {
|
|
||||||
throw ValidationException::withMessages([
|
|
||||||
'paypal' => __('marketing.packages.paypal_checkout_failed'),
|
|
||||||
]);
|
|
||||||
}
|
|
||||||
|
|
||||||
$redirectUrl = $this->paypalOrders->resolveApproveUrl($checkout);
|
|
||||||
|
|
||||||
$session->forceFill([
|
$session->forceFill([
|
||||||
'paypal_order_id' => $orderId,
|
'paddle_checkout_id' => $checkout['id'] ?? $session->paddle_checkout_id,
|
||||||
'provider_metadata' => array_merge($session->provider_metadata ?? [], array_filter([
|
'provider_metadata' => array_merge($session->provider_metadata ?? [], array_filter([
|
||||||
'paypal_order_id' => $orderId,
|
'paddle_checkout_id' => $checkout['id'] ?? null,
|
||||||
'paypal_status' => $checkout['status'] ?? null,
|
'paddle_checkout_url' => $checkout['checkout_url'] ?? null,
|
||||||
'paypal_approve_url' => $redirectUrl,
|
'paddle_expires_at' => $checkout['expires_at'] ?? null,
|
||||||
'paypal_success_url' => $successUrl,
|
|
||||||
'paypal_cancel_url' => $cancelUrl,
|
|
||||||
'paypal_created_at' => now()->toIso8601String(),
|
|
||||||
])),
|
])),
|
||||||
])->save();
|
])->save();
|
||||||
|
|
||||||
$this->checkoutSessions->markRequiresCustomerAction($session, 'paypal_approval');
|
$redirectUrl = $checkout['checkout_url'] ?? null;
|
||||||
|
|
||||||
if (! $redirectUrl) {
|
if (! $redirectUrl) {
|
||||||
throw ValidationException::withMessages([
|
throw ValidationException::withMessages([
|
||||||
'paypal' => __('marketing.packages.paypal_checkout_failed'),
|
'paddle' => __('marketing.packages.paddle_checkout_failed'),
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -418,25 +409,10 @@ class MarketingController extends Controller
|
|||||||
|
|
||||||
public function demo()
|
public function demo()
|
||||||
{
|
{
|
||||||
$event = Event::query()
|
$joinToken = optional(Event::firstWhere('slug', 'demo-wedding-2025'))
|
||||||
->where(function ($query) {
|
?->joinTokens()
|
||||||
$query
|
|
||||||
->where('settings->marketing_demo', true)
|
|
||||||
->orWhere('settings->marketing_demo', 'true')
|
|
||||||
->orWhere('settings->marketing_demo', '1')
|
|
||||||
->orWhere('settings->demo', true)
|
|
||||||
->orWhere('settings->demo', 'true')
|
|
||||||
->orWhere('settings->demo', '1');
|
|
||||||
})
|
|
||||||
->latest('id')
|
->latest('id')
|
||||||
->first();
|
->first();
|
||||||
$joinToken = null;
|
|
||||||
|
|
||||||
if ($event) {
|
|
||||||
$joinToken = $event->joinTokens()
|
|
||||||
->latest('id')
|
|
||||||
->first();
|
|
||||||
}
|
|
||||||
|
|
||||||
$demoToken = null;
|
$demoToken = null;
|
||||||
|
|
||||||
|
|||||||
@@ -2,27 +2,27 @@
|
|||||||
|
|
||||||
namespace App\Http\Controllers;
|
namespace App\Http\Controllers;
|
||||||
|
|
||||||
use App\Http\Requests\LemonSqueezy\LemonSqueezyCheckoutRequest;
|
use App\Http\Requests\Paddle\PaddleCheckoutRequest;
|
||||||
use App\Models\CheckoutSession;
|
use App\Models\CheckoutSession;
|
||||||
use App\Models\Package;
|
use App\Models\Package;
|
||||||
use App\Services\Checkout\CheckoutSessionService;
|
use App\Services\Checkout\CheckoutSessionService;
|
||||||
use App\Services\Coupons\CouponService;
|
use App\Services\Coupons\CouponService;
|
||||||
use App\Services\LemonSqueezy\LemonSqueezyCheckoutService;
|
use App\Services\Paddle\PaddleCheckoutService;
|
||||||
use App\Support\CheckoutRequestContext;
|
use App\Support\CheckoutRequestContext;
|
||||||
use Illuminate\Http\JsonResponse;
|
use Illuminate\Http\JsonResponse;
|
||||||
use Illuminate\Support\Facades\Auth;
|
use Illuminate\Support\Facades\Auth;
|
||||||
use Illuminate\Support\Str;
|
use Illuminate\Support\Str;
|
||||||
use Illuminate\Validation\ValidationException;
|
use Illuminate\Validation\ValidationException;
|
||||||
|
|
||||||
class LemonSqueezyCheckoutController extends Controller
|
class PaddleCheckoutController extends Controller
|
||||||
{
|
{
|
||||||
public function __construct(
|
public function __construct(
|
||||||
private readonly LemonSqueezyCheckoutService $checkout,
|
private readonly PaddleCheckoutService $checkout,
|
||||||
private readonly CheckoutSessionService $sessions,
|
private readonly CheckoutSessionService $sessions,
|
||||||
private readonly CouponService $coupons,
|
private readonly CouponService $coupons,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
public function create(LemonSqueezyCheckoutRequest $request): JsonResponse
|
public function create(PaddleCheckoutRequest $request): JsonResponse
|
||||||
{
|
{
|
||||||
$data = $request->validated();
|
$data = $request->validated();
|
||||||
|
|
||||||
@@ -35,8 +35,8 @@ class LemonSqueezyCheckoutController extends Controller
|
|||||||
|
|
||||||
$package = Package::findOrFail((int) $data['package_id']);
|
$package = Package::findOrFail((int) $data['package_id']);
|
||||||
|
|
||||||
if (! $package->lemonsqueezy_variant_id) {
|
if (! $package->paddle_price_id) {
|
||||||
throw ValidationException::withMessages(['package_id' => 'Package is not linked to a Lemon Squeezy variant.']);
|
throw ValidationException::withMessages(['package_id' => 'Package is not linked to a Paddle price.']);
|
||||||
}
|
}
|
||||||
|
|
||||||
$session = $this->sessions->createOrResume($user, $package, array_merge(
|
$session = $this->sessions->createOrResume($user, $package, array_merge(
|
||||||
@@ -46,7 +46,7 @@ class LemonSqueezyCheckoutController extends Controller
|
|||||||
]
|
]
|
||||||
));
|
));
|
||||||
|
|
||||||
$this->sessions->selectProvider($session, CheckoutSession::PROVIDER_LEMONSQUEEZY);
|
$this->sessions->selectProvider($session, CheckoutSession::PROVIDER_PADDLE);
|
||||||
|
|
||||||
$now = now();
|
$now = now();
|
||||||
|
|
||||||
@@ -59,18 +59,44 @@ class LemonSqueezyCheckoutController extends Controller
|
|||||||
])->save();
|
])->save();
|
||||||
|
|
||||||
$couponCode = Str::upper(trim((string) ($data['coupon_code'] ?? '')));
|
$couponCode = Str::upper(trim((string) ($data['coupon_code'] ?? '')));
|
||||||
|
$discountId = null;
|
||||||
|
|
||||||
if ($couponCode !== '') {
|
if ($couponCode !== '') {
|
||||||
$preview = $this->coupons->preview($couponCode, $package, $tenant);
|
$preview = $this->coupons->preview($couponCode, $package, $tenant);
|
||||||
$this->sessions->applyCoupon($session, $preview['coupon'], $preview['pricing']);
|
$this->sessions->applyCoupon($session, $preview['coupon'], $preview['pricing']);
|
||||||
|
$discountId = $preview['coupon']->paddle_discount_id;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (app()->environment('local')) {
|
if ($request->boolean('inline') && $discountId === null) {
|
||||||
$checkout = $this->simulateLocalCheckout($session, $package, $couponCode);
|
$metadata = array_merge($session->provider_metadata ?? [], [
|
||||||
|
'mode' => 'inline',
|
||||||
|
]);
|
||||||
|
|
||||||
return response()->json(array_merge($checkout, [
|
$session->forceFill([
|
||||||
|
'provider_metadata' => $metadata,
|
||||||
|
])->save();
|
||||||
|
|
||||||
|
return response()->json([
|
||||||
'checkout_session_id' => $session->id,
|
'checkout_session_id' => $session->id,
|
||||||
]));
|
'mode' => 'inline',
|
||||||
|
'items' => [
|
||||||
|
[
|
||||||
|
'priceId' => $package->paddle_price_id,
|
||||||
|
'quantity' => 1,
|
||||||
|
],
|
||||||
|
],
|
||||||
|
'custom_data' => [
|
||||||
|
'tenant_id' => (string) $tenant->id,
|
||||||
|
'package_id' => (string) $package->id,
|
||||||
|
'checkout_session_id' => (string) $session->id,
|
||||||
|
'legal_version' => $session->legal_version,
|
||||||
|
'accepted_terms' => '1',
|
||||||
|
],
|
||||||
|
'customer' => array_filter([
|
||||||
|
'email' => $user->email,
|
||||||
|
'name' => trim(($user->first_name ?? '').' '.($user->last_name ?? '')) ?: ($user->name ?? null),
|
||||||
|
]),
|
||||||
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
$checkout = $this->checkout->createCheckout($tenant, $package, [
|
$checkout = $this->checkout->createCheckout($tenant, $package, [
|
||||||
@@ -82,17 +108,15 @@ class LemonSqueezyCheckoutController extends Controller
|
|||||||
'legal_version' => $session->legal_version,
|
'legal_version' => $session->legal_version,
|
||||||
'accepted_terms' => true,
|
'accepted_terms' => true,
|
||||||
],
|
],
|
||||||
'discount_code' => $couponCode ?: null,
|
'discount_id' => $discountId,
|
||||||
'customer_email' => $user?->email,
|
|
||||||
'customer_name' => trim(($user?->first_name ?? '').' '.($user?->last_name ?? '')) ?: ($user?->name ?? null),
|
|
||||||
]);
|
]);
|
||||||
|
|
||||||
$session->forceFill([
|
$session->forceFill([
|
||||||
'lemonsqueezy_checkout_id' => $checkout['id'] ?? $session->lemonsqueezy_checkout_id,
|
'paddle_checkout_id' => $checkout['id'] ?? $session->paddle_checkout_id,
|
||||||
'provider_metadata' => array_merge($session->provider_metadata ?? [], array_filter([
|
'provider_metadata' => array_merge($session->provider_metadata ?? [], array_filter([
|
||||||
'lemonsqueezy_checkout_id' => $checkout['id'] ?? null,
|
'paddle_checkout_id' => $checkout['id'] ?? null,
|
||||||
'lemonsqueezy_checkout_url' => $checkout['checkout_url'] ?? null,
|
'paddle_checkout_url' => $checkout['checkout_url'] ?? null,
|
||||||
'lemonsqueezy_expires_at' => $checkout['expires_at'] ?? null,
|
'paddle_expires_at' => $checkout['expires_at'] ?? null,
|
||||||
])),
|
])),
|
||||||
])->save();
|
])->save();
|
||||||
|
|
||||||
@@ -101,36 +125,6 @@ class LemonSqueezyCheckoutController extends Controller
|
|||||||
]));
|
]));
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* @return array{checkout_url: string|null, id: string, order_id: string, simulated: bool}
|
|
||||||
*/
|
|
||||||
protected function simulateLocalCheckout(CheckoutSession $session, Package $package, string $couponCode): array
|
|
||||||
{
|
|
||||||
$checkoutId = 'chk_'.Str::uuid();
|
|
||||||
$orderId = 'order_'.Str::uuid();
|
|
||||||
|
|
||||||
$session->forceFill([
|
|
||||||
'lemonsqueezy_checkout_id' => $checkoutId,
|
|
||||||
'provider_metadata' => array_merge($session->provider_metadata ?? [], array_filter([
|
|
||||||
'lemonsqueezy_checkout_id' => $checkoutId,
|
|
||||||
'lemonsqueezy_order_id' => $orderId,
|
|
||||||
'lemonsqueezy_status' => 'paid',
|
|
||||||
'lemonsqueezy_local_simulated_at' => now()->toIso8601String(),
|
|
||||||
'coupon_code' => $couponCode ?: null,
|
|
||||||
])),
|
|
||||||
])->save();
|
|
||||||
|
|
||||||
return [
|
|
||||||
'checkout_url' => route('marketing.success', [
|
|
||||||
'locale' => app()->getLocale(),
|
|
||||||
'packageId' => $package->id,
|
|
||||||
], absolute: true),
|
|
||||||
'id' => $checkoutId,
|
|
||||||
'order_id' => $orderId,
|
|
||||||
'simulated' => true,
|
|
||||||
];
|
|
||||||
}
|
|
||||||
|
|
||||||
protected function resolveLegalVersion(): string
|
protected function resolveLegalVersion(): string
|
||||||
{
|
{
|
||||||
return config('app.legal_version', now()->toDateString());
|
return config('app.legal_version', now()->toDateString());
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user