Compare commits
132 Commits
main
...
b316beb522
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
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 |
6
.beads/.gitignore
vendored
6
.beads/.gitignore
vendored
@@ -11,6 +11,12 @@ daemon.log
|
||||
daemon.pid
|
||||
bd.sock
|
||||
sync-state.json
|
||||
.sync.lock
|
||||
last-touched
|
||||
sync_base.jsonl
|
||||
.sync.lock
|
||||
last-touched
|
||||
sync_base.jsonl
|
||||
|
||||
# Local version tracking (prevents upgrade notification spam after git ops)
|
||||
.local_version
|
||||
|
||||
@@ -42,7 +42,7 @@
|
||||
# This setting persists across clones (unlike database config which is gitignored).
|
||||
# Can also use BEADS_SYNC_BRANCH env var for local override.
|
||||
# If not set, bd sync will require you to run 'bd config set sync.branch <branch>'.
|
||||
# sync-branch: "beads-sync"
|
||||
sync-branch: "beads-sync"
|
||||
|
||||
# Multi-repo configuration (experimental - bd-307)
|
||||
# Allows hydrating from multiple repositories and routing writes to the correct JSONL
|
||||
@@ -59,4 +59,4 @@
|
||||
# - linear.url
|
||||
# - linear.api-key
|
||||
# - github.org
|
||||
# - github.repo
|
||||
# - github.repo
|
||||
@@ -9,6 +9,8 @@
|
||||
{"id":"fotospiel-app-1we","title":"Live Show: define trusted uploader rules \u0026 default retention window","description":"# Decision: Trusted uploader rules \u0026 default retention window\n\n## Context\nModeration is required for many events, but we also want a fast “auto-approve trusted sources” mode.\n\nWe currently track photo ingestion sources in `photos.ingest_source` (e.g. `tenant_admin`, `photobooth`, `sparkbooth`, `guest_pwa`). Guest uploads are token-based and do not have strong identity guarantees.\n\n## Definitions\n- **Trusted uploader**: uploads that can bypass Live Show manual moderation.\n- **Retention window**: time window for which approved photos remain eligible for rotation in the Live Show.\n\n## Options (trusted rules)\n### A) Trust by ingestion source only (recommended for V1)\nAuto-approve for Live Show only when `ingest_source` is one of:\n- `tenant_admin` (authenticated staff actions)\n- `photobooth` / `sparkbooth` (controlled integrations)\n\nAll `guest_pwa` uploads require manual approval when moderation is enabled.\n\n**Pros**\n- Harder to spoof; aligns with real security boundaries.\n- Simple to explain and operate.\n\n**Cons**\n- Guests never auto-approve; more moderator work.\n\n### B) Trust by guest device id (not recommended without stronger proof)\nUse `created_by_device_id` / `X-Device-Id` to whitelist devices.\n\n**Risk**\n- Device IDs are not cryptographically bound; a motivated guest could spoof the header.\n\nIf we want this later, we should introduce a **server-issued signed device token** (pairing flow) and validate it on upload.\n\n### C) Trust by invitation/QR (future)\nGuests who joined with a special “staff QR/pairing token” become trusted.\n\n## Recommended decision\nChoose **Option A** for V1.\n\n### Moderation mode semantics (proposed)\n- `off`: all photos with “submit to live show” become `approved` immediately *except* photos that are already flagged/removed by other moderation pipelines.\n- `manual`: all guest PWA photos become `pending`; trusted sources auto-approve.\n- `trusted_only`: same as manual, but UI copy emphasises that only booth/staff are automatic.\n\n## Retention window (defaults)\n### Recommendation\nDefault `retention_window_hours = 12` (configurable per event).\n\nRationale:\n- Keeps the “eligible set” bounded for performance.\n- Fits most event durations; avoids showing very old photos late in the night.\n\n### Notes\n- Even with a retention window, we can still show older photos via “curated” mode (e.g. featured/top-liked) if product wants.\n\n## Edge cases\n- **High-volume**: moderators may not keep up → allow temporary switch to “trusted_only” + announce to guests.\n- **Abuse**: if a trusted integration misbehaves, operator can disable trusted auto-approve.\n- **Reversal**: approving a previously rejected photo must be tracked with audit info (who/when).\n\n## Decision needed from product\n- Confirm the default retention window: 12h vs 6h vs “entire event”.\n- Confirm whether “trusted_only” should auto-approve `tenant_admin` uploads (recommended: yes).\n- Confirm whether guest auto-approve is desired in V1 (recommended: no, unless we build pairing).\n","acceptance_criteria":"- Trusted rules options listed, with security risk called out for device-id trust\\n- Clear V1 recommendation (trust by ingest_source only)\\n- Moderation mode semantics defined\\n- Default retention window recommendation + product decision questions","notes":"Decision: V1 trusted auto-approve uses ingest_source only (tenant_admin/photobooth/sparkbooth). Default retention_window_hours = 12.","status":"closed","priority":2,"issue_type":"task","created_at":"2026-01-05T11:43:32.455339503+01:00","created_by":"soeren","updated_at":"2026-01-05T12:06:45.973092473+01:00","closed_at":"2026-01-05T12:06:45.973092473+01:00","close_reason":"Closed","dependencies":[{"issue_id":"fotospiel-app-1we","depends_on_id":"fotospiel-app-vro","type":"blocks","created_at":"2026-01-05T11:44:02.062725386+01:00","created_by":"soeren"}]}
|
||||
{"id":"fotospiel-app-25q","title":"Security review: payments/webhooks code audit (signatures, idempotency, linkage)","status":"open","priority":2,"issue_type":"task","created_at":"2026-01-01T16:05:25.747336642+01:00","created_by":"soeren","updated_at":"2026-01-01T16:05:25.747336642+01:00"}
|
||||
{"id":"fotospiel-app-29o","title":"Paddle catalog sync: PackageResource sync status badges + timestamp","status":"closed","priority":2,"issue_type":"task","created_at":"2026-01-01T16:01:10.009385187+01:00","created_by":"soeren","updated_at":"2026-01-01T16:01:15.639525807+01:00","closed_at":"2026-01-01T16:01:15.639525807+01:00","close_reason":"Completed in codebase (verified)"}
|
||||
{"id":"fotospiel-app-29r","title":"Photobooth uploader: add watch-folder upload pipeline + persist creds","status":"closed","priority":2,"issue_type":"task","owner":"codex-agent@example.com","created_at":"2026-01-12T16:51:27.198056063+01:00","created_by":"Codex Agent","updated_at":"2026-01-12T17:07:04.06719869+01:00","closed_at":"2026-01-12T17:07:04.06719869+01:00","close_reason":"Closed"}
|
||||
{"id":"fotospiel-app-2b5","title":"Uploader: connect code expiry countdown","description":"Part of epic fotospiel-app-5aa. Show time-to-expiry for the active connect code in the client.","status":"open","priority":2,"issue_type":"task","owner":"codex-agent@example.com","created_at":"2026-01-13T11:04:05.74962406+01:00","created_by":"Codex Agent","updated_at":"2026-01-13T11:04:05.74962406+01:00"}
|
||||
{"id":"fotospiel-app-2hq","title":"Security review: marketing/API controller+validation review","status":"open","priority":2,"issue_type":"task","created_at":"2026-01-01T16:05:08.862737923+01:00","created_by":"soeren","updated_at":"2026-01-01T16:05:08.862737923+01:00"}
|
||||
{"id":"fotospiel-app-2yn","title":"Event-Admin: Reset link routing + notifications + tests","description":"Point password reset emails to event-admin reset page; add rate limiting and tests for the new flow.","status":"closed","priority":2,"issue_type":"task","created_at":"2026-01-06T10:45:09.279245468+01:00","created_by":"soeren","updated_at":"2026-01-06T11:01:49.083154811+01:00","closed_at":"2026-01-06T11:01:49.083154811+01:00","close_reason":"Closed"}
|
||||
{"id":"fotospiel-app-33m","title":"Security review checklist: Guest PWA dynamic tests","status":"open","priority":2,"issue_type":"task","created_at":"2026-01-01T16:04:40.730459361+01:00","created_by":"soeren","updated_at":"2026-01-01T16:04:40.730459361+01:00"}
|
||||
@@ -28,6 +30,7 @@
|
||||
{"id":"fotospiel-app-574","title":"Paddle catalog sync: extend PaddleClient tests/mocks for catalog endpoints","status":"closed","priority":2,"issue_type":"task","created_at":"2026-01-01T15:59:03.486301225+01:00","created_by":"soeren","updated_at":"2026-01-02T21:11:39.626820206+01:00","closed_at":"2026-01-02T21:11:39.626820206+01:00","close_reason":"Deprioritized"}
|
||||
{"id":"fotospiel-app-576","title":"Tenant admin onboarding: legacy asset audit + component inventory","status":"closed","priority":2,"issue_type":"task","created_at":"2026-01-01T16:07:59.996563146+01:00","created_by":"soeren","updated_at":"2026-01-01T16:08:05.599274641+01:00","closed_at":"2026-01-01T16:08:05.599274641+01:00","close_reason":"Completed in codebase (verified)"}
|
||||
{"id":"fotospiel-app-579","title":"Live Show: tests (backend + UI smoke)","status":"closed","priority":2,"issue_type":"task","created_at":"2026-01-05T11:11:57.246607374+01:00","created_by":"soeren","updated_at":"2026-01-05T19:37:35.590123482+01:00","closed_at":"2026-01-05T19:37:35.590123482+01:00","close_reason":"Closed","dependencies":[{"issue_id":"fotospiel-app-579","depends_on_id":"fotospiel-app-539","type":"blocks","created_at":"2026-01-05T11:13:27.729131522+01:00","created_by":"soeren"},{"issue_id":"fotospiel-app-579","depends_on_id":"fotospiel-app-xg5","type":"blocks","created_at":"2026-01-05T11:13:37.425191011+01:00","created_by":"soeren"},{"issue_id":"fotospiel-app-579","depends_on_id":"fotospiel-app-qne","type":"blocks","created_at":"2026-01-05T11:13:46.257175231+01:00","created_by":"soeren"}]}
|
||||
{"id":"fotospiel-app-5aa","title":"Photobooth uploader: reliability + UX upgrades","status":"open","priority":2,"issue_type":"epic","owner":"codex-agent@example.com","created_at":"2026-01-13T11:01:29.745168595+01:00","created_by":"Codex Agent","updated_at":"2026-01-13T11:01:29.745168595+01:00"}
|
||||
{"id":"fotospiel-app-5dl","title":"Paddle catalog sync: PaddleCatalogService scaffold","status":"closed","priority":2,"issue_type":"task","created_at":"2026-01-01T16:00:24.916655836+01:00","created_by":"soeren","updated_at":"2026-01-01T16:00:30.566084195+01:00","closed_at":"2026-01-01T16:00:30.566084195+01:00","close_reason":"Completed in codebase (verified)"}
|
||||
{"id":"fotospiel-app-5hk","title":"Fix staging coupon seed 500 for E2E","status":"closed","priority":2,"issue_type":"task","created_at":"2026-01-03T15:12:53.643644221+01:00","created_by":"soeren","updated_at":"2026-01-04T16:21:46.441797374+01:00","closed_at":"2026-01-04T16:21:46.441797374+01:00","close_reason":"Resolved elsewhere; staging coupon seed 500 no longer reproducible after recent backend changes."}
|
||||
{"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"}]}
|
||||
@@ -38,10 +41,14 @@
|
||||
{"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-6oj","title":"Security review: media pipeline code audit (AV/EXIF, signed URLs, storage separation)","status":"open","priority":2,"issue_type":"task","created_at":"2026-01-01T16:05:31.390878341+01:00","created_by":"soeren","updated_at":"2026-01-01T16:05:31.390878341+01:00"}
|
||||
{"id":"fotospiel-app-6yt","title":"Paddle migration: register sandbox webhooks + document events consumed","status":"closed","priority":2,"issue_type":"task","created_at":"2026-01-01T15:56:34.333714988+01:00","created_by":"soeren","updated_at":"2026-01-02T22:23:52.212191068+01:00","closed_at":"2026-01-02T22:23:52.212191068+01:00","close_reason":"Completed"}
|
||||
{"id":"fotospiel-app-6yz","title":"Uploader: activity log export","description":"Part of epic fotospiel-app-5aa. Add in-app log view and export/copy diagnostics for support.","status":"open","priority":2,"issue_type":"task","owner":"codex-agent@example.com","created_at":"2026-01-13T11:04:27.73767403+01:00","created_by":"Codex Agent","updated_at":"2026-01-13T11:04:27.73767403+01:00"}
|
||||
{"id":"fotospiel-app-6zc","title":"Live Show: Admin app settings \u0026 effect presets","status":"closed","priority":2,"issue_type":"feature","created_at":"2026-01-05T11:11:27.038815978+01:00","created_by":"soeren","updated_at":"2026-01-05T15:02:42.035082497+01:00","closed_at":"2026-01-05T15:02:42.035082497+01:00","close_reason":"Closed","dependencies":[{"issue_id":"fotospiel-app-6zc","depends_on_id":"fotospiel-app-vro","type":"blocks","created_at":"2026-01-05T11:12:50.048055484+01:00","created_by":"soeren"}]}
|
||||
{"id":"fotospiel-app-7bu","title":"Paddle migration: extend config/env handling for Paddle keys/webhook secrets","status":"closed","priority":2,"issue_type":"task","created_at":"2026-01-01T15:57:27.242854801+01:00","created_by":"soeren","updated_at":"2026-01-01T15:57:32.890355888+01:00","closed_at":"2026-01-01T15:57:32.890355888+01:00","close_reason":"Completed in codebase (verified)"}
|
||||
{"id":"fotospiel-app-7u1","title":"Paddle catalog sync: PaddlePackagePull job","status":"closed","priority":2,"issue_type":"task","created_at":"2026-01-01T16:00:47.468892178+01:00","created_by":"soeren","updated_at":"2026-01-01T16:00:53.126602817+01:00","closed_at":"2026-01-01T16:00:53.126602817+01:00","close_reason":"Completed in codebase (verified)"}
|
||||
{"id":"fotospiel-app-7uu","title":"Uploader: improve file readiness detection","description":"Part of epic fotospiel-app-5aa. Use size + last-write stabilization to avoid partial uploads.","status":"open","priority":2,"issue_type":"task","owner":"codex-agent@example.com","created_at":"2026-01-13T11:01:54.142231578+01:00","created_by":"Codex Agent","updated_at":"2026-01-13T11:01:54.142231578+01:00"}
|
||||
{"id":"fotospiel-app-7x1","title":"Uploader: response format manual override","description":"Part of epic fotospiel-app-5aa. Allow manual response format override when connect code doesn't set it.","status":"open","priority":2,"issue_type":"task","owner":"codex-agent@example.com","created_at":"2026-01-13T11:03:54.824613016+01:00","created_by":"Codex Agent","updated_at":"2026-01-13T11:03:54.824613016+01:00"}
|
||||
{"id":"fotospiel-app-83q","title":"Implement Advanced Analytics","description":"Full plan: Phase 1 (MVP) includes Activity Timeline, Top Contributors, and Task Stats. Phase 2 includes Engagement Funnel, Vibe Check, and PDF Export. See chat history for details.","status":"closed","priority":2,"issue_type":"task","created_at":"2026-01-06T15:40:08.826105426+01:00","created_by":"soeren","updated_at":"2026-01-06T16:15:17.722450844+01:00","closed_at":"2026-01-06T16:15:17.722455019+01:00"}
|
||||
{"id":"fotospiel-app-8ui","title":"Uploader: persist queue across restarts","description":"Part of epic fotospiel-app-5aa. Persist pending upload queue to disk (settings or local DB) so restarts don't lose files.","status":"open","priority":2,"issue_type":"task","owner":"codex-agent@example.com","created_at":"2026-01-13T11:01:42.213478619+01:00","created_by":"Codex Agent","updated_at":"2026-01-13T11:01:42.213478619+01:00"}
|
||||
{"id":"fotospiel-app-95m","title":"Paddle migration: admin catalog sync UI for packages","status":"closed","priority":2,"issue_type":"task","created_at":"2026-01-01T15:57:49.790409261+01:00","created_by":"soeren","updated_at":"2026-01-01T15:57:55.418180246+01:00","closed_at":"2026-01-01T15:57:55.418180246+01:00","close_reason":"Completed in codebase (verified)"}
|
||||
{"id":"fotospiel-app-99d","title":"Paddle migration: marketing checkout uses Paddle-hosted checkout","status":"closed","priority":2,"issue_type":"task","created_at":"2026-01-01T15:58:12.298063897+01:00","created_by":"soeren","updated_at":"2026-01-01T15:58:17.968032021+01:00","closed_at":"2026-01-01T15:58:17.968032021+01:00","close_reason":"Completed in codebase (verified)"}
|
||||
{"id":"fotospiel-app-99o","title":"Fix German welcome phrasing with article-safe app_name","status":"closed","priority":2,"issue_type":"task","created_at":"2026-01-04T11:50:17.410390085+01:00","created_by":"soeren","updated_at":"2026-01-04T12:19:55.741616753+01:00","closed_at":"2026-01-04T12:19:55.741616753+01:00","close_reason":"Closed"}
|
||||
@@ -62,9 +69,12 @@
|
||||
{"id":"fotospiel-app-bqm","title":"Paddle catalog sync: unit tests for service + jobs","status":"closed","priority":2,"issue_type":"task","created_at":"2026-01-01T16:01:22.090498843+01:00","created_by":"soeren","updated_at":"2026-01-01T16:01:27.71412122+01:00","closed_at":"2026-01-01T16:01:27.71412122+01:00","close_reason":"Completed in codebase (verified)"}
|
||||
{"id":"fotospiel-app-bxu","title":"Checkout refactor: Stripe/Paddle payment integration + webhooks","status":"closed","priority":2,"issue_type":"task","created_at":"2026-01-01T16:06:32.279485614+01:00","created_by":"soeren","updated_at":"2026-01-01T16:06:37.876950599+01:00","closed_at":"2026-01-01T16:06:37.876950599+01:00","close_reason":"Completed in codebase (verified)"}
|
||||
{"id":"fotospiel-app-bzb","title":"Paddle catalog sync: migration for paddle sync columns","status":"closed","priority":2,"issue_type":"task","created_at":"2026-01-01T16:00:02.362257158+01:00","created_by":"soeren","updated_at":"2026-01-01T16:00:08.018770606+01:00","closed_at":"2026-01-01T16:00:08.018770606+01:00","close_reason":"Completed in codebase (verified)"}
|
||||
{"id":"fotospiel-app-cht","title":"Uploader: disk space low warning","description":"Part of epic fotospiel-app-5aa. Highlight low disk space thresholds in UI.","status":"open","priority":2,"issue_type":"task","owner":"codex-agent@example.com","created_at":"2026-01-13T11:03:32.710631234+01:00","created_by":"Codex Agent","updated_at":"2026-01-13T11:03:32.710631234+01:00"}
|
||||
{"id":"fotospiel-app-ci5","title":"Paddle catalog sync: configure log channel/Slack hook for sync outcomes","status":"closed","priority":2,"issue_type":"task","created_at":"2026-01-01T15:59:20.543083527+01:00","created_by":"soeren","updated_at":"2026-01-02T22:02:15.857149244+01:00","closed_at":"2026-01-02T22:02:15.857149244+01:00","close_reason":"Completed"}
|
||||
{"id":"fotospiel-app-cwq","title":"Integrations health: unified Paddle/RevenueCat/webhook status dashboard","description":"Add a superadmin integrations health dashboard for Paddle/RevenueCat/webhooks.\nScope: show latest webhook processing status/lag, recent failures, retry backlog, and config presence (env set) without exposing secrets.\nInclude per-provider status badges and time-window filters, plus links to related logs/actions.\n","status":"closed","priority":2,"issue_type":"task","created_at":"2026-01-02T17:34:20.84661157+01:00","created_by":"soeren","updated_at":"2026-01-02T18:33:07.133704488+01:00","closed_at":"2026-01-02T18:33:07.133704488+01:00","close_reason":"Closed"}
|
||||
{"id":"fotospiel-app-d39","title":"Superadmin control surface spec and access matrix","description":"Define the minimal superadmin control surface, permissions, and mapping to tenant/guest responsibilities. Document scope and non-goals.","status":"closed","priority":2,"issue_type":"task","created_at":"2026-01-01T14:16:06.994379577+01:00","updated_at":"2026-01-01T14:20:43.080701114+01:00","closed_at":"2026-01-01T14:20:43.080701114+01:00"}
|
||||
{"id":"fotospiel-app-dar","title":"Uploader: retry policy for failed uploads","description":"Part of epic fotospiel-app-5aa. Auto-retry with backoff and retry limit before marking failed.","status":"open","priority":2,"issue_type":"task","owner":"codex-agent@example.com","created_at":"2026-01-13T11:03:00.808893045+01:00","created_by":"Codex Agent","updated_at":"2026-01-13T11:03:00.808893045+01:00"}
|
||||
{"id":"fotospiel-app-de7","title":"Re-run admin Playwright tests with valid E2E credentials","status":"open","priority":3,"issue_type":"task","owner":"codex-agent@example.com","created_at":"2026-01-15T19:53:26.674926731+01:00","created_by":"Codex Agent","updated_at":"2026-01-15T19:53:26.674926731+01:00"}
|
||||
{"id":"fotospiel-app-dl5","title":"SEC-API-01 Signed URL middleware + asset migration","status":"closed","priority":2,"issue_type":"task","created_at":"2026-01-01T15:52:24.24098702+01:00","created_by":"soeren","updated_at":"2026-01-01T15:52:29.8793891+01:00","closed_at":"2026-01-01T15:52:29.8793891+01:00","close_reason":"Completed in codebase (verified)"}
|
||||
{"id":"fotospiel-app-dm4","title":"SEC-BILL-01 Checkout session linkage + idempotency locks","status":"closed","priority":2,"issue_type":"task","created_at":"2026-01-01T15:53:26.350238207+01:00","created_by":"soeren","updated_at":"2026-01-01T15:53:31.997737421+01:00","closed_at":"2026-01-01T15:53:31.997737421+01:00","close_reason":"Completed in codebase (verified)"}
|
||||
{"id":"fotospiel-app-dmb","title":"Security review checklist: Event Admin dynamic tests","status":"open","priority":2,"issue_type":"task","created_at":"2026-01-01T16:04:46.359468828+01:00","created_by":"soeren","updated_at":"2026-01-01T16:04:46.359468828+01:00"}
|
||||
@@ -85,6 +95,7 @@
|
||||
{"id":"fotospiel-app-iyh","title":"Security review follow-ups: signed URL TTLs, guest asset throttles, CORS allowlist, logging hygiene","status":"open","priority":2,"issue_type":"task","created_at":"2026-01-01T16:05:42.642109576+01:00","created_by":"soeren","updated_at":"2026-01-01T16:05:42.642109576+01:00"}
|
||||
{"id":"fotospiel-app-jk4","title":"Checkout refactor: CheckoutController + marketing route alignment","status":"closed","priority":2,"issue_type":"task","created_at":"2026-01-01T16:06:21.088319132+01:00","created_by":"soeren","updated_at":"2026-01-01T16:06:26.663419594+01:00","closed_at":"2026-01-01T16:06:26.663419594+01:00","close_reason":"Completed in codebase (verified)"}
|
||||
{"id":"fotospiel-app-jqy","title":"Tenant admin onboarding: Playwright skeleton for welcome flow","status":"closed","priority":2,"issue_type":"task","created_at":"2026-01-01T16:08:11.226297707+01:00","created_by":"soeren","updated_at":"2026-01-01T16:08:16.827679424+01:00","closed_at":"2026-01-01T16:08:16.827679424+01:00","close_reason":"Completed in codebase (verified)"}
|
||||
{"id":"fotospiel-app-jy1","title":"Uploader: clear failed uploads UI","description":"Part of epic fotospiel-app-5aa. Add action to clear/reset failed items and counters.","status":"open","priority":2,"issue_type":"task","owner":"codex-agent@example.com","created_at":"2026-01-13T11:03:13.134661157+01:00","created_by":"Codex Agent","updated_at":"2026-01-13T11:03:13.134661157+01:00"}
|
||||
{"id":"fotospiel-app-ko0","title":"Security review checklist: Webhooks/Billing dynamic tests","status":"open","priority":2,"issue_type":"task","created_at":"2026-01-01T16:04:51.987093237+01:00","created_by":"soeren","updated_at":"2026-01-01T16:04:51.987093237+01:00"}
|
||||
{"id":"fotospiel-app-kry","title":"Paddle catalog sync: add DTO helpers for Paddle product/price responses","status":"closed","priority":2,"issue_type":"task","created_at":"2026-01-01T15:58:57.817750548+01:00","created_by":"soeren","updated_at":"2026-01-02T21:11:27.970220923+01:00","closed_at":"2026-01-02T21:11:27.970220923+01:00","close_reason":"Deprioritized"}
|
||||
{"id":"fotospiel-app-kso","title":"SEC-MS-02 Streaming upload refactor + tests","description":"Current state (code scan)\n- Guest uploads: App\\\\Http\\\\Controllers\\\\Api\\\\EventPublicController@upload uses Storage::disk()-\u003eputFile (stream-friendly) but still does watermark/thumbnail work inline.\n- Tenant admin uploads: App\\\\Http\\\\Controllers\\\\Api\\\\Tenant\\\\PhotoController@store and @uploadDirect use Storage::disk()-\u003eput($path, file_get_contents(...)) which loads entire file into memory.\n- Photobooth ingest already streams from import disk via readStream -\u003e Storage::disk()-\u003eput($path, $stream).\n- Presigned upload flow is stubbed to a local upload-direct endpoint; no true presigned S3 handling yet.\n- No tenant upload feature tests exist; guest upload tests exist and cover limits/security.\n\nGoal\n- Stream uploads to disk (avoid full in-memory buffers) for tenant-admin upload endpoints and keep behavior consistent across sources.\n\nPlan\n1) Introduce a small streaming upload helper/service\n - New service (e.g. App\\\\Services\\\\Storage\\\\UploadStreamService) that accepts UploadedFile + disk + destination path.\n - Use fopen on UploadedFile::getRealPath (or $file-\u003egetStream()) and Storage::disk($disk)-\u003eput($path, $stream) / writeStream.\n - Always close stream; return stored size and checksum (hash_file on stored path) for asset metadata.\n\n2) Refactor tenant upload endpoints to use streaming\n - Update PhotoController@store and @uploadDirect to use the helper instead of file_get_contents.\n - Use Storage::disk()-\u003eputFileAs (or helper) to preserve deterministic paths without buffering.\n - Keep existing validation, watermark, thumbnail, asset recording, and package usage logic.\n\n3) Optional consistency pass on guest upload\n - Consider routing EventPublicController@upload through the same helper for consistent storage + checksum handling, while keeping current validation/limits.\n\n4) Tests\n - Add Feature tests for tenant upload endpoints:\n - /api/v1/tenant/events/{slug}/photos (store) uploads a fake image and persists Photo + EventMediaAsset with expected path/size.\n - /api/v1/tenant/events/{slug}/upload-direct (presigned) uploads a fake image and stores asset + thumbnail.\n - Ensure existing guest upload tests still pass (no behavioral changes).\n\n5) Safety/ops\n - Verify streaming logic handles empty/invalid files gracefully and still reports errors via ApiError.\n - Keep request-time processing (thumb/watermark) unchanged for now; consider queuing in a follow-up if CPU spikes persist.","status":"closed","priority":2,"issue_type":"task","created_at":"2026-01-01T15:53:03.729137616+01:00","created_by":"soeren","updated_at":"2026-01-02T20:51:17.752365339+01:00","closed_at":"2026-01-02T20:51:17.752365339+01:00","close_reason":"Closed"}
|
||||
@@ -92,6 +103,8 @@
|
||||
{"id":"fotospiel-app-l3n","title":"Session changes 2025-09-08 (PRP split, PWA scaffolding, Filament resources, API)","status":"closed","priority":2,"issue_type":"task","created_at":"2026-01-01T16:10:18.204088457+01:00","created_by":"soeren","updated_at":"2026-01-01T16:10:23.815135505+01:00","closed_at":"2026-01-01T16:10:23.815135505+01:00","close_reason":"Completed in codebase (verified)"}
|
||||
{"id":"fotospiel-app-l6a","title":"Registration flow fixes: JSON redirect, error clearing, role handling","status":"closed","priority":2,"issue_type":"task","created_at":"2026-01-01T16:07:16.253760139+01:00","created_by":"soeren","updated_at":"2026-01-01T16:07:21.964843904+01:00","closed_at":"2026-01-01T16:07:21.964843904+01:00","close_reason":"Completed in codebase (verified)"}
|
||||
{"id":"fotospiel-app-l8q","title":"SEC-GT-02 Join-token analytics dashboard (Grafana)","description":"Logging + Filament summaries exist; Grafana dashboard still missing.","status":"open","priority":2,"issue_type":"task","created_at":"2026-01-01T15:52:12.920875329+01:00","created_by":"soeren","updated_at":"2026-01-01T15:52:12.920875329+01:00"}
|
||||
{"id":"fotospiel-app-lj6","title":"Uploader: folder health enhancements","description":"Part of epic fotospiel-app-5aa. Track last file seen, write permissions, and show clearer folder status.","status":"open","priority":2,"issue_type":"task","owner":"codex-agent@example.com","created_at":"2026-01-13T11:03:22.843330813+01:00","created_by":"Codex Agent","updated_at":"2026-01-13T11:03:22.843330813+01:00"}
|
||||
{"id":"fotospiel-app-llq","title":"Uploader: lock settings after connect","description":"Part of epic fotospiel-app-5aa. Prevent accidental changes to base URL/credentials unless explicitly unlocked.","status":"open","priority":2,"issue_type":"task","owner":"codex-agent@example.com","created_at":"2026-01-13T11:03:43.40971185+01:00","created_by":"Codex Agent","updated_at":"2026-01-13T11:03:43.40971185+01:00"}
|
||||
{"id":"fotospiel-app-ln3","title":"Paddle catalog sync: announce workflow change to admin users","status":"closed","priority":2,"issue_type":"task","created_at":"2026-01-01T15:59:49.021233635+01:00","created_by":"soeren","updated_at":"2026-01-02T21:11:09.349495631+01:00","closed_at":"2026-01-02T21:11:09.349495631+01:00","close_reason":"Deprioritized"}
|
||||
{"id":"fotospiel-app-lnb","title":"SEC-GT-01 Hash join tokens + data migration","status":"closed","priority":2,"issue_type":"task","created_at":"2026-01-01T15:52:01.658868778+01:00","created_by":"soeren","updated_at":"2026-01-01T15:52:07.314317124+01:00","closed_at":"2026-01-01T15:52:07.314317124+01:00","close_reason":"Completed in codebase (verified)"}
|
||||
{"id":"fotospiel-app-lnf","title":"Remove legacy registration page assets","status":"open","priority":2,"issue_type":"task","created_at":"2026-01-06T08:37:39.419274918+01:00","created_by":"soeren","updated_at":"2026-01-06T08:37:39.419274918+01:00"}
|
||||
@@ -101,6 +114,7 @@
|
||||
{"id":"fotospiel-app-ml7","title":"SEC-GT-03 Tighten gallery/photo rate limits + alerting","status":"open","priority":2,"issue_type":"task","created_at":"2026-01-01T15:52:18.593415508+01:00","created_by":"soeren","updated_at":"2026-01-01T15:52:18.593415508+01:00"}
|
||||
{"id":"fotospiel-app-mol","title":"Coupon ops: wire analytics into Matomo dashboard","status":"closed","priority":2,"issue_type":"task","created_at":"2026-01-01T16:09:27.722458747+01:00","created_by":"soeren","updated_at":"2026-01-02T23:28:18.178704873+01:00","closed_at":"2026-01-02T23:28:18.178704873+01:00","close_reason":"Closed"}
|
||||
{"id":"fotospiel-app-mpu","title":"Checkout refactor: test coverage + rollout notes","status":"closed","priority":2,"issue_type":"task","created_at":"2026-01-01T16:06:43.488302531+01:00","created_by":"soeren","updated_at":"2026-01-01T16:06:49.13645691+01:00","closed_at":"2026-01-01T16:06:49.13645691+01:00","close_reason":"Completed in codebase (verified)"}
|
||||
{"id":"fotospiel-app-mwi","title":"Uploader: duplicate detection / upload cache","description":"Part of epic fotospiel-app-5aa. Track uploaded files (path/hash) to avoid re-uploads after restart.","status":"open","priority":2,"issue_type":"task","owner":"codex-agent@example.com","created_at":"2026-01-13T11:02:06.432781468+01:00","created_by":"Codex Agent","updated_at":"2026-01-13T11:02:06.432781468+01:00"}
|
||||
{"id":"fotospiel-app-mx5","title":"Localized SEO: sitemap updated with locale alternates","status":"closed","priority":2,"issue_type":"task","created_at":"2026-01-01T16:02:15.177013722+01:00","created_by":"soeren","updated_at":"2026-01-01T16:02:20.812287917+01:00","closed_at":"2026-01-01T16:02:20.812287917+01:00","close_reason":"Completed in codebase (verified)"}
|
||||
{"id":"fotospiel-app-mxw","title":"Security review: configure env assumptions for dynamic testing","status":"open","priority":2,"issue_type":"task","created_at":"2026-01-01T16:04:29.498402235+01:00","created_by":"soeren","updated_at":"2026-01-01T16:04:29.498402235+01:00"}
|
||||
{"id":"fotospiel-app-n8q","title":"Paddle migration: draft production cutover procedure","status":"closed","priority":2,"issue_type":"task","created_at":"2026-01-01T15:56:51.427425262+01:00","created_by":"soeren","updated_at":"2026-01-02T22:28:41.469357437+01:00","closed_at":"2026-01-02T22:28:41.469357437+01:00","close_reason":"Completed"}
|
||||
@@ -118,11 +132,15 @@
|
||||
{"id":"fotospiel-app-qlj","title":"Paddle catalog sync: verify legacy packages mapped before auto-sync","status":"closed","priority":2,"issue_type":"task","created_at":"2026-01-01T15:59:43.333792314+01:00","created_by":"soeren","updated_at":"2026-01-02T21:46:52.797515024+01:00","closed_at":"2026-01-02T21:46:52.797515024+01:00","close_reason":"Completed"}
|
||||
{"id":"fotospiel-app-qne","title":"Live Show: realtime delivery channel (WS/SSE) + fallback polling","acceptance_criteria":"- Public Live Show endpoints exist for state, updates, and SSE stream\\n- Updates endpoint supports cursor (after_approved_at + after_id)\\n- SSE emits photo.approved and ping, with settings updates when version changes\\n- Feature tests cover state, updates, invalid token","notes":"Added LiveShowController with public endpoints: /api/v1/live-show/{token} (state), /updates (polling), /stream (SSE). Provides live-show settings (defaults + event.settings.live_show merge), settings_version hash, ordered approved photo feed with cursor. SSE emits photo.approved, settings.updated, ping. Added routes in routes/api.php. Added Photo live_status default. Tests: tests/Feature/LiveShowRealtimeTest.php. Ran Pint + test.","status":"closed","priority":1,"issue_type":"feature","created_at":"2026-01-05T11:11:06.028871737+01:00","created_by":"soeren","updated_at":"2026-01-05T13:08:33.936740582+01:00","closed_at":"2026-01-05T13:08:33.936740582+01:00","close_reason":"Closed","dependencies":[{"issue_id":"fotospiel-app-qne","depends_on_id":"fotospiel-app-t1k","type":"blocks","created_at":"2026-01-05T11:12:30.363982215+01:00","created_by":"soeren"}]}
|
||||
{"id":"fotospiel-app-qtn","title":"Security review kickoff mitigations (CORS allowlist, headers, upload hardening, signed URLs)","status":"closed","priority":2,"issue_type":"task","created_at":"2026-01-01T16:09:46.310873311+01:00","created_by":"soeren","updated_at":"2026-01-01T16:09:51.914359487+01:00","closed_at":"2026-01-01T16:09:51.914359487+01:00","close_reason":"Completed in codebase (verified)"}
|
||||
{"id":"fotospiel-app-rpv","title":"Uploader: connection test (no upload)","description":"Part of epic fotospiel-app-5aa. Add lightweight ping/test for upload URL + credentials.","status":"open","priority":2,"issue_type":"task","owner":"codex-agent@example.com","created_at":"2026-01-13T11:02:39.061938692+01:00","created_by":"Codex Agent","updated_at":"2026-01-13T11:02:39.061938692+01:00"}
|
||||
{"id":"fotospiel-app-sbs","title":"Compliance tools: data export + retention overrides","description":"GDPR-compliant export requests and retention override workflows for tenants/events.","status":"closed","priority":3,"issue_type":"feature","created_at":"2026-01-01T14:20:16.530289009+01:00","updated_at":"2026-01-02T20:13:31.704875591+01:00","closed_at":"2026-01-02T20:13:31.704875591+01:00","close_reason":"Closed"}
|
||||
{"id":"fotospiel-app-sdg","title":"Uploader: watch include/exclude patterns","description":"Part of epic fotospiel-app-5aa. Configurable file patterns (ignore tmp/preview) for watcher.","status":"open","priority":2,"issue_type":"task","owner":"codex-agent@example.com","created_at":"2026-01-13T11:02:17.188267106+01:00","created_by":"Codex Agent","updated_at":"2026-01-13T11:02:17.188267106+01:00"}
|
||||
{"id":"fotospiel-app-sju","title":"Live Show link sharing + QR in admin","description":"Expose Live Show link in Event Admin with copy/share/open actions and embedded QR (use simplesoftwareio/simple-qrcode, no external service). Add API endpoints for link fetch/rotate, admin UI card with rotate confirmation, and tests.","status":"open","priority":2,"issue_type":"task","created_at":"2026-01-05T20:00:25.427132538+01:00","created_by":"soeren","updated_at":"2026-01-05T20:00:25.427132538+01:00"}
|
||||
{"id":"fotospiel-app-swb","title":"Security review: replace public asset URLs with signed routes","status":"closed","priority":2,"issue_type":"task","created_at":"2026-01-01T16:04:05.610098299+01:00","created_by":"soeren","updated_at":"2026-01-01T16:04:11.215921463+01:00","closed_at":"2026-01-01T16:04:11.215921463+01:00","close_reason":"Completed in codebase (verified)"}
|
||||
{"id":"fotospiel-app-t1k","title":"Live Show: data model \u0026 status workflow (pending/approved/ready)","acceptance_criteria":"- DB migrations add event token + photo live fields + indexes\\n- Token generation supports rotation (no expiry)\\n- Photo live workflow methods set timestamps/reviewer consistently\\n- Feature test covers token + workflow","notes":"Implemented Live Show data model: events.live_show_token + live_show_token_rotated_at; photos.live_status + timestamps/reviewer/rejection fields + indexes. Added PhotoLiveStatus enum and Photo workflow methods (markLivePending/approveForLiveShow/rejectForLiveShow). Added Event helpers (ensureLiveShowToken/rotateLiveShowToken). Tests: tests/Feature/LiveShowDataModelTest.php.","status":"closed","priority":1,"issue_type":"feature","created_at":"2026-01-05T11:10:56.560421826+01:00","created_by":"soeren","updated_at":"2026-01-05T12:22:51.967913423+01:00","closed_at":"2026-01-05T12:22:51.967913423+01:00","close_reason":"Closed","dependencies":[{"issue_id":"fotospiel-app-t1k","depends_on_id":"fotospiel-app-vro","type":"blocks","created_at":"2026-01-05T11:12:20.345646244+01:00","created_by":"soeren"},{"issue_id":"fotospiel-app-t1k","depends_on_id":"fotospiel-app-h5d","type":"blocks","created_at":"2026-01-05T11:44:12.439413712+01:00","created_by":"soeren"},{"issue_id":"fotospiel-app-t1k","depends_on_id":"fotospiel-app-1eu","type":"blocks","created_at":"2026-01-05T11:44:22.588642567+01:00","created_by":"soeren"},{"issue_id":"fotospiel-app-t1k","depends_on_id":"fotospiel-app-1we","type":"blocks","created_at":"2026-01-05T11:44:31.775634827+01:00","created_by":"soeren"}]}
|
||||
{"id":"fotospiel-app-t2s","title":"Uploader: multiple event profiles","description":"Part of epic fotospiel-app-5aa. Save multiple event profiles and allow quick switching.","status":"open","priority":2,"issue_type":"task","owner":"codex-agent@example.com","created_at":"2026-01-13T11:04:18.20222112+01:00","created_by":"Codex Agent","updated_at":"2026-01-13T11:04:18.20222112+01:00"}
|
||||
{"id":"fotospiel-app-tqg","title":"Tenant admin onboarding: staging E2E validation","status":"open","priority":2,"issue_type":"task","created_at":"2026-01-01T16:08:57.448899354+01:00","created_by":"soeren","updated_at":"2026-01-01T16:08:57.448899354+01:00"}
|
||||
{"id":"fotospiel-app-tsb","title":"Uploader: upload throttling presets","description":"Part of epic fotospiel-app-5aa. Add optional delay/presets to smooth upload bursts.","status":"open","priority":2,"issue_type":"task","owner":"codex-agent@example.com","created_at":"2026-01-13T11:02:27.111436345+01:00","created_by":"Codex Agent","updated_at":"2026-01-13T11:02:27.111436345+01:00"}
|
||||
{"id":"fotospiel-app-ty9","title":"Security review: data classes \u0026 retention baseline","status":"closed","priority":2,"issue_type":"task","created_at":"2026-01-01T16:03:09.595870306+01:00","created_by":"soeren","updated_at":"2026-01-01T16:03:15.211042718+01:00","closed_at":"2026-01-01T16:03:15.211042718+01:00","close_reason":"Completed in codebase (verified)"}
|
||||
{"id":"fotospiel-app-tym","title":"Ops health dashboard (queues, storage, upload pipeline)","description":"Superadmin ops dashboard showing queue backlog, failed jobs, storage thresholds, and upload pipeline health.","notes":"Implemented Ops Health dashboard with storage+queue widgets, new translations, and navigation wiring.","status":"closed","priority":2,"issue_type":"feature","created_at":"2026-01-01T14:20:04.991351193+01:00","updated_at":"2026-01-02T17:34:10.326367902+01:00","closed_at":"2026-01-02T17:34:10.326367902+01:00","close_reason":"Closed"}
|
||||
{"id":"fotospiel-app-ugk","title":"Paddle catalog sync: feature test for artisan command","status":"closed","priority":2,"issue_type":"task","created_at":"2026-01-01T16:01:33.309716868+01:00","created_by":"soeren","updated_at":"2026-01-01T16:01:38.940407157+01:00","closed_at":"2026-01-01T16:01:38.940407157+01:00","close_reason":"Completed in codebase (verified)"}
|
||||
@@ -141,6 +159,7 @@
|
||||
{"id":"fotospiel-app-wku","title":"Security review: run dynamic testing harness (identities, DAST, fuzz uploads)","status":"open","priority":2,"issue_type":"task","created_at":"2026-01-01T16:05:37.008239379+01:00","created_by":"soeren","updated_at":"2026-01-01T16:05:37.008239379+01:00"}
|
||||
{"id":"fotospiel-app-xg5","title":"Live Show: Admin app moderation queue UI","acceptance_criteria":"- Dedicated Live Show moderation API endpoints exist for list + approve/reject/clear\\n- Admin mobile UI exposes Live Show queue with status filter and actions\\n- PhotoResource includes live_* fields for admin UI\\n- Feature tests cover list + approve/reject/clear workflows","notes":"Added dedicated Live Show moderation API (tenant admin): /events/{slug}/live-show/photos + approve/reject/clear actions. Added LiveShowPhotoController + FormRequests. PhotoResource now exposes live_* fields. Admin app: new Live Show queue page, route, and Event detail shortcut tile. Admin API updated with Live Show functions + types. Added translations (EN/DE) for Live Show queue UI. Tests: tests/Feature/LiveShowPhotoControllerTest.php.","status":"closed","priority":2,"issue_type":"feature","created_at":"2026-01-05T11:11:15.006484132+01:00","created_by":"soeren","updated_at":"2026-01-05T14:03:41.410176482+01:00","closed_at":"2026-01-05T14:03:41.410176482+01:00","close_reason":"Closed","dependencies":[{"issue_id":"fotospiel-app-xg5","depends_on_id":"fotospiel-app-t1k","type":"blocks","created_at":"2026-01-05T11:12:38.94145573+01:00","created_by":"soeren"}]}
|
||||
{"id":"fotospiel-app-xht","title":"Paddle migration: tenant ↔ Paddle customer sync + webhook handlers","status":"closed","priority":2,"issue_type":"task","created_at":"2026-01-01T15:58:01.028435913+01:00","created_by":"soeren","updated_at":"2026-01-01T15:58:06.685122343+01:00","closed_at":"2026-01-01T15:58:06.685122343+01:00","close_reason":"Completed in codebase (verified)"}
|
||||
{"id":"fotospiel-app-xik","title":"Uploader: richer error details","description":"Part of epic fotospiel-app-5aa. Surface HTTP status/body summary in last error and recent uploads.","status":"open","priority":2,"issue_type":"task","owner":"codex-agent@example.com","created_at":"2026-01-13T11:02:49.591107008+01:00","created_by":"Codex Agent","updated_at":"2026-01-13T11:02:49.591107008+01:00"}
|
||||
{"id":"fotospiel-app-y1f","title":"Compliance tools: superadmin data export + retention override UI","description":"Add superadmin compliance tools for data exports and retention overrides.\nScope: list export requests, status, expiry, and allow manual retry/cancel; add per-tenant/event retention override UI with audit logging.\nEnsure access is restricted to superadmins and no PII is exposed beyond existing export metadata.\n","status":"closed","priority":2,"issue_type":"task","created_at":"2026-01-02T17:34:29.825347299+01:00","created_by":"soeren","updated_at":"2026-01-02T22:49:53.586758621+01:00","closed_at":"2026-01-02T22:49:53.586758621+01:00","close_reason":"Closed"}
|
||||
{"id":"fotospiel-app-yii","title":"Implement 'Upgrade to Premium' flow for Analytics Upsell","description":"The Analytics page currently has an upsell screen for non-premium users. The 'Upgrade to Premium' button redirects to the billing page, but the actual upgrade/purchase flow needs to be fully implemented and verified to allow users to unlock the feature.","status":"closed","priority":2,"issue_type":"task","created_at":"2026-01-06T16:13:55.446495378+01:00","created_by":"soeren","updated_at":"2026-01-06T16:35:41.968964977+01:00","closed_at":"2026-01-06T16:35:41.968970147+01:00"}
|
||||
{"id":"fotospiel-app-z2k","title":"Ops health widget visual polish","description":"Replace Tailwind utility styling in ops health widget with Filament components and icon-driven layout.","notes":"Updated queue health widget layout to use Filament cards, badges, empty states, and grid utilities; added status strip and alert rail.","status":"closed","priority":3,"issue_type":"task","created_at":"2026-01-01T21:34:39.851728527+01:00","created_by":"soeren","updated_at":"2026-01-01T21:34:59.834597413+01:00","closed_at":"2026-01-01T21:34:59.834597413+01:00","close_reason":"completed"}
|
||||
|
||||
@@ -1 +1 @@
|
||||
fotospiel-app-9em
|
||||
fotospiel-app-de7
|
||||
|
||||
12
.gitignore
vendored
12
.gitignore
vendored
@@ -13,6 +13,8 @@ fotospiel-tenant-app
|
||||
/storage/*.key
|
||||
/storage/pail
|
||||
/vendor
|
||||
/clients/photobooth-uploader/**/bin
|
||||
/clients/photobooth-uploader/**/obj
|
||||
.env
|
||||
.env.backup
|
||||
.env.production
|
||||
@@ -23,11 +25,9 @@ Homestead.yaml
|
||||
npm-debug.log
|
||||
yarn-error.log
|
||||
/auth.json
|
||||
/.fleet
|
||||
/.idea
|
||||
/.nova
|
||||
/.vscode
|
||||
/.zed
|
||||
tools/git-askpass.ps1
|
||||
podman-compose.dev.yml
|
||||
test-results
|
||||
GEMINI.md
|
||||
.beads/.sync.lock
|
||||
.beads/daemon-error
|
||||
.beads/sync_base.jsonl
|
||||
|
||||
@@ -337,8 +337,8 @@ Tokens are design system values that can be referenced using the `$` prefix.
|
||||
|
||||
### Color Tokens
|
||||
|
||||
- `accent`: #FFB6C1
|
||||
- `accentSoft`: #FFE5EC
|
||||
- `accent`: #3D5AFE
|
||||
- `accentSoft`: #E8ECFF
|
||||
- `blue10Dark`: hsl(209, 100%, 60.6%)
|
||||
- `blue10Light`: hsl(208, 100%, 47.3%)
|
||||
- `blue11Dark`: hsl(210, 100%, 66.1%)
|
||||
@@ -363,8 +363,8 @@ Tokens are design system values that can be referenced using the `$` prefix.
|
||||
- `blue8Light`: hsl(206, 81.9%, 65.3%)
|
||||
- `blue9Dark`: hsl(206, 100%, 50.0%)
|
||||
- `blue9Light`: hsl(206, 100%, 50.0%)
|
||||
- `border`: #F2E4DA
|
||||
- `danger`: #E04848
|
||||
- `border`: #F3D6C9
|
||||
- `danger`: #EF4444
|
||||
- `gray10Dark`: hsl(0, 0%, 49.4%)
|
||||
- `gray10Light`: hsl(0, 0%, 52.3%)
|
||||
- `gray11Dark`: hsl(0, 0%, 62.8%)
|
||||
@@ -413,7 +413,7 @@ Tokens are design system values that can be referenced using the `$` prefix.
|
||||
- `green8Light`: hsl(151, 40.2%, 54.1%)
|
||||
- `green9Dark`: hsl(151, 55.0%, 41.5%)
|
||||
- `green9Light`: hsl(151, 55.0%, 41.5%)
|
||||
- `muted`: #F4ECE8
|
||||
- `muted`: #FFF6F0
|
||||
- `orange10Dark`: hsl(24, 100%, 58.5%)
|
||||
- `orange10Light`: hsl(24, 100%, 46.5%)
|
||||
- `orange11Dark`: hsl(24, 100%, 62.2%)
|
||||
@@ -462,7 +462,7 @@ Tokens are design system values that can be referenced using the `$` prefix.
|
||||
- `pink8Light`: hsl(323, 60.3%, 72.4%)
|
||||
- `pink9Dark`: hsl(322, 65.0%, 54.5%)
|
||||
- `pink9Light`: hsl(322, 65.0%, 54.5%)
|
||||
- `primary`: #FF5A5F
|
||||
- `primary`: #FF5C5C
|
||||
- `purple10Dark`: hsl(273, 57.3%, 59.1%)
|
||||
- `purple10Light`: hsl(272, 46.8%, 50.3%)
|
||||
- `purple11Dark`: hsl(275, 80.0%, 71.0%)
|
||||
@@ -511,10 +511,10 @@ Tokens are design system values that can be referenced using the `$` prefix.
|
||||
- `red8Light`: hsl(359, 69.5%, 74.3%)
|
||||
- `red9Dark`: hsl(358, 75.0%, 59.0%)
|
||||
- `red9Light`: hsl(358, 75.0%, 59.0%)
|
||||
- `success`: #06D6A0
|
||||
- `success`: #22C55E
|
||||
- `surface`: #ffffff
|
||||
- `text`: #1F2937
|
||||
- `warning`: #F5C542
|
||||
- `text`: #0B132B
|
||||
- `warning`: #FBBF24
|
||||
- `yellow10Dark`: hsl(54, 100%, 68.0%)
|
||||
- `yellow10Light`: hsl(50, 100%, 48.5%)
|
||||
- `yellow11Dark`: hsl(48, 100%, 47.0%)
|
||||
|
||||
@@ -4160,16 +4160,16 @@ var tokens3 = {
|
||||
...tokens2,
|
||||
color: {
|
||||
...tokens2.color,
|
||||
primary: "#FF5A5F",
|
||||
accent: "#FFB6C1",
|
||||
accentSoft: "#FFE5EC",
|
||||
success: "#06D6A0",
|
||||
warning: "#F5C542",
|
||||
danger: "#E04848",
|
||||
primary: "#FF5C5C",
|
||||
accent: "#3D5AFE",
|
||||
accentSoft: "#E8ECFF",
|
||||
success: "#22C55E",
|
||||
warning: "#FBBF24",
|
||||
danger: "#EF4444",
|
||||
surface: "#ffffff",
|
||||
muted: "#F4ECE8",
|
||||
border: "#F2E4DA",
|
||||
text: "#1F2937"
|
||||
muted: "#FFF6F0",
|
||||
border: "#F3D6C9",
|
||||
text: "#0B132B"
|
||||
},
|
||||
radius: {
|
||||
...tokens2.radius,
|
||||
@@ -4188,53 +4188,53 @@ var themes3 = {
|
||||
...themes2.light,
|
||||
primary: tokens3.color.primary,
|
||||
accent: tokens3.color.accent,
|
||||
background: "#FFF8F5",
|
||||
backgroundHover: "#FFF1EC",
|
||||
backgroundPress: "#FFE7E0",
|
||||
background: "#FFF1E8",
|
||||
backgroundHover: "#FFE8DD",
|
||||
backgroundPress: "#FFE1D2",
|
||||
backgroundStrong: tokens3.color.surface,
|
||||
backgroundTransparent: "rgba(255, 248, 245, 0)",
|
||||
backgroundTransparent: "rgba(255, 241, 232, 0)",
|
||||
color: tokens3.color.text,
|
||||
colorHover: "#111827",
|
||||
colorPress: "#0F172A",
|
||||
colorFocus: "#0F172A",
|
||||
colorHover: "#091024",
|
||||
colorPress: "#091024",
|
||||
colorFocus: "#091024",
|
||||
borderColor: tokens3.color.border,
|
||||
borderColorHover: "#EAD5C9",
|
||||
borderColorPress: "#E0C9BC",
|
||||
shadowColor: "rgba(31, 41, 55, 0.12)",
|
||||
shadowColorPress: "rgba(31, 41, 55, 0.16)",
|
||||
shadowColorFocus: "rgba(31, 41, 55, 0.18)",
|
||||
borderColorHover: "#EBCABA",
|
||||
borderColorPress: "#E1BFAE",
|
||||
shadowColor: "rgba(11, 19, 43, 0.16)",
|
||||
shadowColorPress: "rgba(11, 19, 43, 0.2)",
|
||||
shadowColorFocus: "rgba(11, 19, 43, 0.24)",
|
||||
surface: tokens3.color.surface,
|
||||
muted: tokens3.color.muted,
|
||||
blue3: tokens3.color.accentSoft,
|
||||
blue6: tokens3.color.accent,
|
||||
blue10: tokens3.color.primary,
|
||||
blue11: "#C2413B"
|
||||
blue11: "#1E36F1"
|
||||
},
|
||||
dark: {
|
||||
...themes2.dark,
|
||||
primary: tokens3.color.primary,
|
||||
accent: tokens3.color.accent,
|
||||
background: "#171219",
|
||||
backgroundHover: "#1F1A23",
|
||||
backgroundPress: "#26212B",
|
||||
backgroundStrong: "#1F1A23",
|
||||
backgroundTransparent: "rgba(23, 18, 25, 0)",
|
||||
color: "#F8F6F2",
|
||||
background: "#0B132B",
|
||||
backgroundHover: "#101A36",
|
||||
backgroundPress: "#132142",
|
||||
backgroundStrong: "#101A36",
|
||||
backgroundTransparent: "rgba(11, 19, 43, 0)",
|
||||
color: "#F8FAFF",
|
||||
colorHover: "#FFFFFF",
|
||||
colorPress: "#FDF8F5",
|
||||
colorPress: "#F2F6FF",
|
||||
colorFocus: "#FFFFFF",
|
||||
borderColor: "#2C2531",
|
||||
borderColorHover: "#3A3240",
|
||||
borderColorPress: "#443C4A",
|
||||
borderColor: "#1F2A4A",
|
||||
borderColorHover: "#29345A",
|
||||
borderColorPress: "#313D67",
|
||||
shadowColor: "rgba(0, 0, 0, 0.55)",
|
||||
shadowColorPress: "rgba(0, 0, 0, 0.65)",
|
||||
shadowColorFocus: "rgba(0, 0, 0, 0.6)",
|
||||
surface: "#1F1A23",
|
||||
muted: "#241E28",
|
||||
blue3: "#2B1D23",
|
||||
blue6: "#5A2D34",
|
||||
blue10: "#FF7A7F",
|
||||
blue11: "#FFB3B6"
|
||||
surface: "#0F1B36",
|
||||
muted: "#121F3D",
|
||||
blue3: "#1B2550",
|
||||
blue6: "#3D5AFE",
|
||||
blue10: "#FF5C5C",
|
||||
blue11: "#FF8A8A"
|
||||
}
|
||||
};
|
||||
var sharedWeights = {
|
||||
@@ -4254,12 +4254,12 @@ var fonts2 = {
|
||||
},
|
||||
heading: {
|
||||
...defaultConfig.fonts.heading,
|
||||
family: "Manrope",
|
||||
family: "Archivo Black",
|
||||
weight: sharedWeights
|
||||
},
|
||||
display: {
|
||||
...defaultConfig.fonts.heading,
|
||||
family: "Fraunces",
|
||||
family: "Archivo Black",
|
||||
weight: sharedWeights
|
||||
}
|
||||
};
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -26,7 +26,7 @@ class SeedDemoSwitcherTenants extends Command
|
||||
{
|
||||
protected $signature = 'demo:seed-switcher {--with-photos : Download sample photos from Pexels} {--photos-per-event=18 : Target photos per event when downloading} {--cleanup : Remove demo switcher tenants/events/photos instead of seeding}';
|
||||
|
||||
protected $description = 'Seeds demo tenants used by the DevTenantSwitcher (endcustomer + reseller profiles)';
|
||||
protected $description = 'Seeds demo tenants used by the DevTenantSwitcher (endcustomer + partner profiles)';
|
||||
|
||||
public function __construct(private EventStorageManager $eventStorageManager)
|
||||
{
|
||||
@@ -129,7 +129,7 @@ class SeedDemoSwitcherTenants extends Command
|
||||
$slugs = [
|
||||
'starter' => 'Starter',
|
||||
'standard' => 'Standard',
|
||||
's-small-reseller' => 'Reseller S',
|
||||
's-small-reseller' => 'Partner Start',
|
||||
];
|
||||
|
||||
$packages = [];
|
||||
@@ -165,10 +165,10 @@ class SeedDemoSwitcherTenants extends Command
|
||||
{
|
||||
$tenant = $this->upsertTenant(
|
||||
slug: 'demo-standard-empty',
|
||||
name: 'Demo Standard (ohne Event)',
|
||||
name: 'Demo Starter (ohne Event)',
|
||||
contactEmail: 'standard-empty@demo.fotospiel',
|
||||
attributes: [
|
||||
'subscription_tier' => 'standard',
|
||||
'subscription_tier' => 'starter',
|
||||
'subscription_status' => 'active',
|
||||
],
|
||||
);
|
||||
@@ -176,9 +176,9 @@ class SeedDemoSwitcherTenants extends Command
|
||||
$this->upsertAdmin($tenant, 'standard-empty@demo.fotospiel');
|
||||
|
||||
TenantPackage::updateOrCreate(
|
||||
['tenant_id' => $tenant->id, 'package_id' => $packages['standard']->id],
|
||||
['tenant_id' => $tenant->id, 'package_id' => $packages['starter']->id],
|
||||
[
|
||||
'price' => $packages['standard']->price,
|
||||
'price' => $packages['starter']->price,
|
||||
'purchased_at' => Carbon::now()->subDays(1),
|
||||
'expires_at' => Carbon::now()->addMonths(12),
|
||||
'used_events' => 0,
|
||||
@@ -186,7 +186,7 @@ class SeedDemoSwitcherTenants extends Command
|
||||
]
|
||||
);
|
||||
|
||||
$this->comment('Seeded Standard tenant without events.');
|
||||
$this->comment('Seeded Starter tenant without events.');
|
||||
}
|
||||
|
||||
private function seedCustomerStarterWedding(array $packages, array $eventTypes): void
|
||||
@@ -204,19 +204,19 @@ class SeedDemoSwitcherTenants extends Command
|
||||
$this->upsertAdmin($tenant, 'starter-wedding@demo.fotospiel');
|
||||
|
||||
TenantPackage::updateOrCreate(
|
||||
['tenant_id' => $tenant->id, 'package_id' => $packages['standard']->id],
|
||||
['tenant_id' => $tenant->id, 'package_id' => $packages['starter']->id],
|
||||
[
|
||||
'price' => $packages['standard']->price,
|
||||
'price' => $packages['starter']->price,
|
||||
'purchased_at' => Carbon::now()->subDays(1),
|
||||
'expires_at' => Carbon::now()->addMonths(12),
|
||||
'used_events' => 0,
|
||||
'used_events' => 1,
|
||||
'active' => true,
|
||||
]
|
||||
);
|
||||
|
||||
$event = $this->upsertEvent(
|
||||
tenant: $tenant,
|
||||
package: $packages['standard'],
|
||||
package: $packages['starter'],
|
||||
eventType: $eventTypes['wedding'] ?? null,
|
||||
attributes: [
|
||||
'name' => ['de' => 'Hochzeit Mia & Jonas', 'en' => 'Wedding Mia & Jonas'],
|
||||
@@ -232,17 +232,18 @@ class SeedDemoSwitcherTenants extends Command
|
||||
|
||||
private function seedResellerActive(array $packages, array $eventTypes): void
|
||||
{
|
||||
$eventPackage = $this->resolveIncludedPackage($packages['s-small-reseller'], $packages);
|
||||
$tenant = $this->upsertTenant(
|
||||
slug: 'demo-reseller-active',
|
||||
name: 'Demo Reseller Active',
|
||||
contactEmail: 'reseller-active@demo.fotospiel',
|
||||
name: 'Demo Partner Active',
|
||||
contactEmail: 'partner-active@demo.fotospiel',
|
||||
attributes: [
|
||||
'subscription_tier' => 'reseller',
|
||||
'subscription_status' => 'active',
|
||||
],
|
||||
);
|
||||
|
||||
$this->upsertAdmin($tenant, 'reseller-active@demo.fotospiel');
|
||||
$this->upsertAdmin($tenant, 'partner-active@demo.fotospiel');
|
||||
|
||||
TenantPackage::updateOrCreate(
|
||||
['tenant_id' => $tenant->id, 'package_id' => $packages['s-small-reseller']->id],
|
||||
@@ -279,7 +280,7 @@ class SeedDemoSwitcherTenants extends Command
|
||||
foreach ($events as $index => $config) {
|
||||
$event = $this->upsertEvent(
|
||||
tenant: $tenant,
|
||||
package: $packages['standard'],
|
||||
package: $eventPackage,
|
||||
eventType: $config['type'],
|
||||
attributes: [
|
||||
'name' => $config['name'],
|
||||
@@ -296,17 +297,18 @@ class SeedDemoSwitcherTenants extends Command
|
||||
|
||||
private function seedResellerFull(array $packages, array $eventTypes): void
|
||||
{
|
||||
$eventPackage = $this->resolveIncludedPackage($packages['s-small-reseller'], $packages);
|
||||
$tenant = $this->upsertTenant(
|
||||
slug: 'demo-reseller-full',
|
||||
name: 'Demo Reseller Voll',
|
||||
contactEmail: 'reseller-full@demo.fotospiel',
|
||||
name: 'Demo Partner Voll',
|
||||
contactEmail: 'partner-full@demo.fotospiel',
|
||||
attributes: [
|
||||
'subscription_tier' => 'reseller',
|
||||
'subscription_status' => 'active',
|
||||
],
|
||||
);
|
||||
|
||||
$this->upsertAdmin($tenant, 'reseller-full@demo.fotospiel');
|
||||
$this->upsertAdmin($tenant, 'partner-full@demo.fotospiel');
|
||||
|
||||
TenantPackage::updateOrCreate(
|
||||
['tenant_id' => $tenant->id, 'package_id' => $packages['s-small-reseller']->id],
|
||||
@@ -330,7 +332,7 @@ class SeedDemoSwitcherTenants extends Command
|
||||
foreach ($eventConfigs as $index => $config) {
|
||||
$event = $this->upsertEvent(
|
||||
tenant: $tenant,
|
||||
package: $packages['standard'],
|
||||
package: $eventPackage,
|
||||
eventType: $config['type'],
|
||||
attributes: [
|
||||
'name' => $config['name'],
|
||||
@@ -357,8 +359,8 @@ class SeedDemoSwitcherTenants extends Command
|
||||
'settings' => [
|
||||
'branding' => [
|
||||
'logo_url' => null,
|
||||
'primary_color' => '#1D4ED8',
|
||||
'secondary_color' => '#0F172A',
|
||||
'primary_color' => '#FF5A5F',
|
||||
'secondary_color' => '#FFF8F5',
|
||||
'font_family' => 'Inter, sans-serif',
|
||||
],
|
||||
'features' => [
|
||||
@@ -435,6 +437,19 @@ class SeedDemoSwitcherTenants extends Command
|
||||
return $event;
|
||||
}
|
||||
|
||||
private function resolveIncludedPackage(Package $resellerPackage, array $packages): Package
|
||||
{
|
||||
$includedSlug = $resellerPackage->included_package_slug;
|
||||
|
||||
if ($includedSlug && isset($packages[$includedSlug])) {
|
||||
return $packages[$includedSlug];
|
||||
}
|
||||
|
||||
$fallback = $packages['starter'] ?? $packages['standard'] ?? null;
|
||||
|
||||
return $fallback ?? $resellerPackage;
|
||||
}
|
||||
|
||||
private function fallbackEventType(): ?EventType
|
||||
{
|
||||
$fallback = EventType::first();
|
||||
|
||||
@@ -40,7 +40,7 @@ class Login extends BaseLogin implements HasForms
|
||||
}
|
||||
|
||||
// SuperAdmin-spezifisch: Prüfe auf SuperAdmin-Rolle, keine Tenant-Prüfung
|
||||
if ($user->role !== 'super_admin') {
|
||||
if (! $user->isSuperAdmin()) {
|
||||
$authGuard->logout();
|
||||
|
||||
throw ValidationException::withMessages([
|
||||
|
||||
@@ -45,11 +45,11 @@ class GuestPolicySettingsPage extends Page
|
||||
|
||||
public int $join_token_failure_decay_minutes = 5;
|
||||
|
||||
public int $join_token_access_limit = 120;
|
||||
public int $join_token_access_limit = 300;
|
||||
|
||||
public int $join_token_access_decay_minutes = 1;
|
||||
|
||||
public int $join_token_download_limit = 60;
|
||||
public int $join_token_download_limit = 120;
|
||||
|
||||
public int $join_token_download_decay_minutes = 1;
|
||||
|
||||
@@ -69,9 +69,9 @@ class GuestPolicySettingsPage extends Page
|
||||
$this->per_device_upload_limit = (int) ($settings->per_device_upload_limit ?? 50);
|
||||
$this->join_token_failure_limit = (int) ($settings->join_token_failure_limit ?? 10);
|
||||
$this->join_token_failure_decay_minutes = (int) ($settings->join_token_failure_decay_minutes ?? 5);
|
||||
$this->join_token_access_limit = (int) ($settings->join_token_access_limit ?? 120);
|
||||
$this->join_token_access_limit = (int) ($settings->join_token_access_limit ?? 300);
|
||||
$this->join_token_access_decay_minutes = (int) ($settings->join_token_access_decay_minutes ?? 1);
|
||||
$this->join_token_download_limit = (int) ($settings->join_token_download_limit ?? 60);
|
||||
$this->join_token_download_limit = (int) ($settings->join_token_download_limit ?? 120);
|
||||
$this->join_token_download_decay_minutes = (int) ($settings->join_token_download_decay_minutes ?? 1);
|
||||
$this->join_token_ttl_hours = (int) ($settings->join_token_ttl_hours ?? 168);
|
||||
$this->share_link_ttl_hours = (int) ($settings->share_link_ttl_hours ?? 48);
|
||||
|
||||
@@ -1025,10 +1025,10 @@ class EventPublicController extends BaseController
|
||||
private function resolveBrandingPayload(Event $event): array
|
||||
{
|
||||
$defaults = [
|
||||
'primary' => '#f43f5e',
|
||||
'secondary' => '#fb7185',
|
||||
'background' => '#ffffff',
|
||||
'surface' => '#ffffff',
|
||||
'primary' => '#FF5A5F',
|
||||
'secondary' => '#FFF8F5',
|
||||
'background' => '#FFF8F5',
|
||||
'surface' => '#FFF8F5',
|
||||
'font' => null,
|
||||
'size' => 'm',
|
||||
'logo_position' => 'left',
|
||||
|
||||
@@ -3,9 +3,12 @@
|
||||
namespace App\Http\Controllers\Api;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Http\Requests\Checkout\CheckoutSessionStatusRequest;
|
||||
use App\Models\CheckoutSession;
|
||||
use App\Models\Package;
|
||||
use App\Models\PackagePurchase;
|
||||
use App\Models\TenantPackage;
|
||||
use App\Services\Checkout\CheckoutSessionService;
|
||||
use App\Services\Paddle\PaddleCheckoutService;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
@@ -14,7 +17,10 @@ use Illuminate\Validation\ValidationException;
|
||||
|
||||
class PackageController extends Controller
|
||||
{
|
||||
public function __construct(private readonly PaddleCheckoutService $paddleCheckout) {}
|
||||
public function __construct(
|
||||
private readonly PaddleCheckoutService $paddleCheckout,
|
||||
private readonly CheckoutSessionService $sessions,
|
||||
) {}
|
||||
|
||||
public function index(Request $request): JsonResponse
|
||||
{
|
||||
@@ -165,23 +171,82 @@ class PackageController extends Controller
|
||||
|
||||
$package = Package::findOrFail($request->integer('package_id'));
|
||||
$tenant = $request->attributes->get('tenant');
|
||||
$user = $request->user();
|
||||
|
||||
if (! $tenant) {
|
||||
throw ValidationException::withMessages(['tenant' => 'Tenant context missing.']);
|
||||
}
|
||||
|
||||
if (! $user) {
|
||||
throw ValidationException::withMessages(['user' => 'User context missing.']);
|
||||
}
|
||||
|
||||
if (! $package->paddle_price_id) {
|
||||
throw ValidationException::withMessages(['package_id' => 'Package is not linked to a Paddle price.']);
|
||||
}
|
||||
|
||||
$session = $this->sessions->createOrResume($user, $package, [
|
||||
'tenant' => $tenant,
|
||||
]);
|
||||
|
||||
$this->sessions->selectProvider($session, CheckoutSession::PROVIDER_PADDLE);
|
||||
|
||||
$now = now();
|
||||
|
||||
$session->forceFill([
|
||||
'accepted_terms_at' => $now,
|
||||
'accepted_privacy_at' => $now,
|
||||
'accepted_withdrawal_notice_at' => $now,
|
||||
'digital_content_waiver_at' => null,
|
||||
'legal_version' => config('app.legal_version', $now->toDateString()),
|
||||
])->save();
|
||||
|
||||
$payload = [
|
||||
'success_url' => $request->input('success_url'),
|
||||
'return_url' => $request->input('return_url'),
|
||||
'metadata' => [
|
||||
'checkout_session_id' => $session->id,
|
||||
'legal_version' => $session->legal_version,
|
||||
'accepted_terms' => true,
|
||||
],
|
||||
];
|
||||
|
||||
$checkout = $this->paddleCheckout->createCheckout($tenant, $package, $payload);
|
||||
|
||||
return response()->json($checkout);
|
||||
$session->forceFill([
|
||||
'paddle_checkout_id' => $checkout['id'] ?? $session->paddle_checkout_id,
|
||||
'provider_metadata' => array_merge($session->provider_metadata ?? [], array_filter([
|
||||
'paddle_checkout_id' => $checkout['id'] ?? null,
|
||||
'paddle_checkout_url' => $checkout['checkout_url'] ?? null,
|
||||
'paddle_expires_at' => $checkout['expires_at'] ?? null,
|
||||
])),
|
||||
])->save();
|
||||
|
||||
return response()->json(array_merge($checkout, [
|
||||
'checkout_session_id' => $session->id,
|
||||
]));
|
||||
}
|
||||
|
||||
public function checkoutSessionStatus(CheckoutSessionStatusRequest $request, CheckoutSession $session): JsonResponse
|
||||
{
|
||||
$history = $session->status_history ?? [];
|
||||
$reason = null;
|
||||
|
||||
foreach (array_reverse($history) as $entry) {
|
||||
if (($entry['status'] ?? null) === $session->status) {
|
||||
$reason = $entry['reason'] ?? null;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
$checkoutUrl = data_get($session->provider_metadata ?? [], 'paddle_checkout_url');
|
||||
|
||||
return response()->json([
|
||||
'status' => $session->status,
|
||||
'completed_at' => optional($session->completed_at)->toIso8601String(),
|
||||
'reason' => $reason,
|
||||
'checkout_url' => is_string($checkoutUrl) ? $checkoutUrl : null,
|
||||
]);
|
||||
}
|
||||
|
||||
private function handleFreePurchase(Request $request, Package $package, $tenant): JsonResponse
|
||||
@@ -212,13 +277,13 @@ class PackageController extends Controller
|
||||
'purchased_at' => now(),
|
||||
]);
|
||||
} else {
|
||||
// Reseller subscription
|
||||
// Partner / reseller Event-Kontingent package
|
||||
\App\Models\TenantPackage::create([
|
||||
'tenant_id' => $tenant->id,
|
||||
'package_id' => $package->id,
|
||||
'price' => $package->price,
|
||||
'purchased_at' => now(),
|
||||
'expires_at' => now()->addYear(),
|
||||
'expires_at' => null,
|
||||
'active' => true,
|
||||
]);
|
||||
}
|
||||
|
||||
70
app/Http/Controllers/Api/PhotoboothConnectController.php
Normal file
70
app/Http/Controllers/Api/PhotoboothConnectController.php
Normal file
@@ -0,0 +1,70 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Api;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Http\Requests\Photobooth\PhotoboothConnectRedeemRequest;
|
||||
use App\Models\Event;
|
||||
use App\Services\Photobooth\PhotoboothConnectCodeService;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
|
||||
class PhotoboothConnectController extends Controller
|
||||
{
|
||||
public function __construct(private readonly PhotoboothConnectCodeService $service) {}
|
||||
|
||||
public function store(PhotoboothConnectRedeemRequest $request): JsonResponse
|
||||
{
|
||||
$record = $this->service->redeem($request->input('code'));
|
||||
|
||||
if (! $record) {
|
||||
return response()->json([
|
||||
'message' => __('Ungültiger oder abgelaufener Verbindungscode.'),
|
||||
], 422);
|
||||
}
|
||||
|
||||
$record->loadMissing('event.photoboothSetting');
|
||||
$event = $record->event;
|
||||
$setting = $event?->photoboothSetting;
|
||||
|
||||
if (! $event || ! $setting || ! $setting->enabled || $setting->mode !== 'sparkbooth') {
|
||||
return response()->json([
|
||||
'message' => __('Photobooth ist nicht im Sparkbooth-Modus aktiv.'),
|
||||
], 409);
|
||||
}
|
||||
|
||||
return response()->json([
|
||||
'data' => [
|
||||
'event_name' => $this->resolveEventName($event),
|
||||
'upload_url' => route('api.v1.photobooth.upload'),
|
||||
'username' => $setting->username,
|
||||
'password' => $setting->password,
|
||||
'expires_at' => optional($setting->expires_at)->toIso8601String(),
|
||||
'response_format' => ($setting->metadata ?? [])['sparkbooth_response_format']
|
||||
?? config('photobooth.sparkbooth.response_format', 'json'),
|
||||
],
|
||||
]);
|
||||
}
|
||||
|
||||
private function resolveEventName(?Event $event): ?string
|
||||
{
|
||||
if (! $event) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$name = $event->name;
|
||||
|
||||
if (is_string($name) && trim($name) !== '') {
|
||||
return $name;
|
||||
}
|
||||
|
||||
if (is_array($name)) {
|
||||
foreach ($name as $value) {
|
||||
if (is_string($value) && trim($value) !== '') {
|
||||
return $value;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return $event->slug ?: null;
|
||||
}
|
||||
}
|
||||
@@ -16,8 +16,10 @@ use App\Models\Package;
|
||||
use App\Models\PackagePurchase;
|
||||
use App\Models\Photo;
|
||||
use App\Models\Tenant;
|
||||
use App\Models\User;
|
||||
use App\Services\EventJoinTokenService;
|
||||
use App\Support\ApiError;
|
||||
use App\Support\TenantMemberPermissions;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Http\Resources\Json\AnonymousResourceCollection;
|
||||
@@ -82,19 +84,27 @@ class EventController extends Controller
|
||||
|
||||
public function store(EventStoreRequest $request): JsonResponse
|
||||
{
|
||||
TenantMemberPermissions::ensureTenantPermission($request, 'events:manage');
|
||||
|
||||
$tenant = $request->attributes->get('tenant');
|
||||
if (! $tenant instanceof Tenant) {
|
||||
$tenantId = $request->attributes->get('tenant_id');
|
||||
$tenant = Tenant::findOrFail($tenantId);
|
||||
}
|
||||
|
||||
$actor = $request->user();
|
||||
$isSuperAdmin = $actor instanceof User && $actor->isSuperAdmin();
|
||||
|
||||
// Package check is now handled by middleware
|
||||
|
||||
$validated = $request->validated();
|
||||
$tenantId = $tenant->id;
|
||||
|
||||
$requestedPackageId = $validated['package_id'] ?? null;
|
||||
$requestedPackageId = $isSuperAdmin ? $request->integer('package_id') : null;
|
||||
unset($validated['package_id']);
|
||||
$requestedServiceSlug = $request->input('service_package_slug');
|
||||
$requestedServiceSlug = is_string($requestedServiceSlug) && $requestedServiceSlug !== '' ? $requestedServiceSlug : null;
|
||||
unset($validated['service_package_slug']);
|
||||
|
||||
$tenantPackage = $tenant->tenantPackages()
|
||||
->with('package')
|
||||
@@ -108,6 +118,22 @@ class EventController extends Controller
|
||||
$package = Package::query()->find($requestedPackageId);
|
||||
}
|
||||
|
||||
if (! $package && $isSuperAdmin) {
|
||||
$package = $this->resolveOwnerPackage();
|
||||
}
|
||||
|
||||
$billingTenantPackage = null;
|
||||
if (! $package) {
|
||||
$billingTenantPackage = $requestedServiceSlug
|
||||
? $tenant->getActiveResellerPackageFor($requestedServiceSlug)
|
||||
: $tenant->getActiveResellerPackage();
|
||||
|
||||
if ($billingTenantPackage && $billingTenantPackage->package) {
|
||||
$package = $billingTenantPackage->package;
|
||||
$requestedServiceSlug = $requestedServiceSlug ?: $package->included_package_slug;
|
||||
}
|
||||
}
|
||||
|
||||
if (! $package && $tenantPackage) {
|
||||
$package = $tenantPackage->package ?? Package::query()->find($tenantPackage->package_id);
|
||||
}
|
||||
@@ -118,10 +144,15 @@ class EventController extends Controller
|
||||
]);
|
||||
}
|
||||
|
||||
$billingIsReseller = $package->isReseller();
|
||||
$eventServicePackage = $billingIsReseller
|
||||
? $this->resolveResellerEventPackageForSlug($requestedServiceSlug ?: $package->included_package_slug)
|
||||
: $package;
|
||||
|
||||
$requiresWaiver = $package->isEndcustomer();
|
||||
$latestPurchase = $requiresWaiver ? $this->resolveLatestPackagePurchase($tenant, $package) : null;
|
||||
$existingWaiver = $latestPurchase ? data_get($latestPurchase->metadata, 'consents.digital_content_waiver_at') : null;
|
||||
$needsWaiver = $requiresWaiver && ! $existingWaiver;
|
||||
$needsWaiver = ! $isSuperAdmin && $requiresWaiver && ! $existingWaiver;
|
||||
|
||||
if ($needsWaiver && ! $request->boolean('accepted_waiver')) {
|
||||
throw ValidationException::withMessages([
|
||||
@@ -153,8 +184,8 @@ class EventController extends Controller
|
||||
unset($eventData['features']);
|
||||
}
|
||||
|
||||
$settings['branding_allowed'] = $package->branding_allowed !== false;
|
||||
$settings['watermark_allowed'] = $package->watermark_allowed !== false;
|
||||
$settings['branding_allowed'] = $eventServicePackage->branding_allowed !== false;
|
||||
$settings['watermark_allowed'] = $eventServicePackage->watermark_allowed !== false;
|
||||
|
||||
$eventData['settings'] = $settings;
|
||||
|
||||
@@ -182,21 +213,23 @@ class EventController extends Controller
|
||||
|
||||
$eventData = Arr::only($eventData, $allowed);
|
||||
|
||||
$event = DB::transaction(function () use ($tenant, $eventData, $package) {
|
||||
$event = DB::transaction(function () use ($tenant, $eventData, $eventServicePackage, $billingIsReseller, $isSuperAdmin) {
|
||||
$event = Event::create($eventData);
|
||||
|
||||
EventPackage::create([
|
||||
'event_id' => $event->id,
|
||||
'package_id' => $package->id,
|
||||
'purchased_price' => $package->price,
|
||||
'package_id' => $eventServicePackage->id,
|
||||
'purchased_price' => $billingIsReseller ? 0 : $eventServicePackage->price,
|
||||
'purchased_at' => now(),
|
||||
'gallery_expires_at' => $package->gallery_days ? now()->addDays($package->gallery_days) : null,
|
||||
'gallery_expires_at' => $eventServicePackage->gallery_days
|
||||
? now()->addDays($eventServicePackage->gallery_days)
|
||||
: null,
|
||||
]);
|
||||
|
||||
if ($package->isReseller()) {
|
||||
if ($billingIsReseller && ! $isSuperAdmin) {
|
||||
$note = sprintf('Event #%d created (%s)', $event->id, $event->name);
|
||||
|
||||
if (! $tenant->consumeEventAllowance(1, 'event.create', $note)) {
|
||||
if (! $tenant->consumeEventAllowanceFor($eventServicePackage->slug, 1, 'event.create', $note)) {
|
||||
throw new HttpException(402, 'Insufficient package allowance.');
|
||||
}
|
||||
}
|
||||
@@ -219,6 +252,47 @@ class EventController extends Controller
|
||||
], 201);
|
||||
}
|
||||
|
||||
private function resolveResellerDefaultEventPackage(): Package
|
||||
{
|
||||
return $this->resolveResellerEventPackageForSlug('standard');
|
||||
}
|
||||
|
||||
private function resolveResellerEventPackageForSlug(?string $slug): Package
|
||||
{
|
||||
if (is_string($slug) && $slug !== '') {
|
||||
$match = Package::query()
|
||||
->where('type', 'endcustomer')
|
||||
->where('slug', $slug)
|
||||
->first();
|
||||
|
||||
if ($match) {
|
||||
return $match;
|
||||
}
|
||||
}
|
||||
|
||||
$default = Package::query()
|
||||
->where('type', 'endcustomer')
|
||||
->where('slug', 'standard')
|
||||
->first();
|
||||
|
||||
if ($default) {
|
||||
return $default;
|
||||
}
|
||||
|
||||
$fallback = Package::query()
|
||||
->where('type', 'endcustomer')
|
||||
->orderBy('price')
|
||||
->first();
|
||||
|
||||
if (! $fallback) {
|
||||
throw ValidationException::withMessages([
|
||||
'package_id' => __('Aktuell ist kein Endkunden-Paket verfügbar. Bitte kontaktiere den Support.'),
|
||||
]);
|
||||
}
|
||||
|
||||
return $fallback;
|
||||
}
|
||||
|
||||
private function resolveLatestPackagePurchase(Tenant $tenant, Package $package): ?PackagePurchase
|
||||
{
|
||||
return PackagePurchase::query()
|
||||
@@ -229,6 +303,15 @@ class EventController extends Controller
|
||||
->first();
|
||||
}
|
||||
|
||||
private function resolveOwnerPackage(): ?Package
|
||||
{
|
||||
$ownerPackage = Package::query()
|
||||
->where('slug', 'pro')
|
||||
->first();
|
||||
|
||||
return $ownerPackage ?? Package::query()->find(3);
|
||||
}
|
||||
|
||||
private function recordEventStartWaiver(Tenant $tenant, Package $package, ?PackagePurchase $purchase): void
|
||||
{
|
||||
$timestamp = now();
|
||||
@@ -303,14 +386,24 @@ class EventController extends Controller
|
||||
);
|
||||
}
|
||||
|
||||
TenantMemberPermissions::ensureEventPermission($request, $event, 'events:manage');
|
||||
|
||||
$validated = $request->validated();
|
||||
$nameProvided = array_key_exists('name', $validated);
|
||||
|
||||
$validated = array_merge([
|
||||
'name' => $event->name,
|
||||
'event_type_id' => $event->event_type_id,
|
||||
'event_date' => $event->date?->toDateString(),
|
||||
'status' => $event->status,
|
||||
], $validated);
|
||||
|
||||
if (isset($validated['event_date'])) {
|
||||
$validated['date'] = $validated['event_date'];
|
||||
unset($validated['event_date']);
|
||||
}
|
||||
|
||||
if ($validated['name'] !== $event->name) {
|
||||
if ($nameProvided && $validated['name'] !== $event->name) {
|
||||
$validated['slug'] = $this->generateUniqueSlug($validated['name'], $tenantId, $event->id);
|
||||
}
|
||||
|
||||
@@ -332,9 +425,14 @@ class EventController extends Controller
|
||||
$validated['settings']['watermark_allowed'] = $watermarkAllowed;
|
||||
|
||||
$settings = $validated['settings'];
|
||||
$branding = Arr::get($settings, 'branding', []);
|
||||
$watermark = Arr::get($settings, 'watermark', []);
|
||||
$existingWatermark = is_array($watermark) ? $watermark : [];
|
||||
|
||||
if (is_array($branding)) {
|
||||
$settings['branding'] = $this->normalizeBrandingSettings($branding, $event, $brandingAllowed);
|
||||
}
|
||||
|
||||
if (is_array($watermark)) {
|
||||
$mode = $watermark['mode'] ?? 'base';
|
||||
$policy = $watermarkAllowed ? 'basic' : 'none';
|
||||
@@ -425,6 +523,68 @@ class EventController extends Controller
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $branding
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
private function normalizeBrandingSettings(array $branding, Event $event, bool $brandingAllowed): array
|
||||
{
|
||||
$logoDataUrl = $branding['logo_data_url'] ?? null;
|
||||
|
||||
if (! $brandingAllowed) {
|
||||
unset($branding['logo_data_url']);
|
||||
|
||||
return $branding;
|
||||
}
|
||||
|
||||
if (! is_string($logoDataUrl) || trim($logoDataUrl) === '') {
|
||||
unset($branding['logo_data_url']);
|
||||
|
||||
return $branding;
|
||||
}
|
||||
|
||||
if (! preg_match('/^data:image\\/(png|webp|jpe?g);base64,(.+)$/i', $logoDataUrl, $matches)) {
|
||||
throw ValidationException::withMessages([
|
||||
'settings.branding.logo_data_url' => __('Ungültiges Branding-Logo.'),
|
||||
]);
|
||||
}
|
||||
|
||||
$decoded = base64_decode($matches[2], true);
|
||||
|
||||
if ($decoded === false) {
|
||||
throw ValidationException::withMessages([
|
||||
'settings.branding.logo_data_url' => __('Branding-Logo konnte nicht gelesen werden.'),
|
||||
]);
|
||||
}
|
||||
|
||||
if (strlen($decoded) > 1024 * 1024) { // 1 MB
|
||||
throw ValidationException::withMessages([
|
||||
'settings.branding.logo_data_url' => __('Branding-Logo ist zu groß (max. 1 MB).'),
|
||||
]);
|
||||
}
|
||||
|
||||
$extension = str_starts_with(strtolower($matches[1]), 'jp') ? 'jpg' : strtolower($matches[1]);
|
||||
$path = sprintf('branding/logos/event-%s.%s', $event->id, $extension);
|
||||
Storage::disk('public')->put($path, $decoded);
|
||||
|
||||
$branding['logo_url'] = $path;
|
||||
$branding['logo_mode'] = 'upload';
|
||||
$branding['logo_value'] = $path;
|
||||
|
||||
$logo = $branding['logo'] ?? [];
|
||||
if (! is_array($logo)) {
|
||||
$logo = [];
|
||||
}
|
||||
|
||||
$logo['mode'] = 'upload';
|
||||
$logo['value'] = $path;
|
||||
$branding['logo'] = $logo;
|
||||
|
||||
unset($branding['logo_data_url']);
|
||||
|
||||
return $branding;
|
||||
}
|
||||
|
||||
public function destroy(Request $request, Event $event): JsonResponse
|
||||
{
|
||||
$tenantId = $request->attributes->get('tenant_id');
|
||||
@@ -439,6 +599,8 @@ class EventController extends Controller
|
||||
);
|
||||
}
|
||||
|
||||
TenantMemberPermissions::ensureEventPermission($request, $event, 'events:manage');
|
||||
|
||||
$event->delete();
|
||||
|
||||
return response()->json([
|
||||
|
||||
@@ -11,6 +11,7 @@ use App\Models\Event;
|
||||
use App\Models\GuestNotification;
|
||||
use App\Models\GuestPolicySetting;
|
||||
use App\Services\GuestNotificationService;
|
||||
use App\Support\TenantMemberPermissions;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Validation\ValidationException;
|
||||
@@ -23,6 +24,7 @@ class EventGuestNotificationController extends Controller
|
||||
public function index(Request $request, Event $event): JsonResponse
|
||||
{
|
||||
$this->assertEventTenant($request, $event);
|
||||
TenantMemberPermissions::ensureEventPermission($request, $event, 'guest-notifications:manage');
|
||||
|
||||
$limit = max(1, min(100, (int) $request->integer('limit', 25)));
|
||||
|
||||
@@ -38,6 +40,7 @@ class EventGuestNotificationController extends Controller
|
||||
public function store(BroadcastGuestNotificationRequest $request, Event $event): JsonResponse
|
||||
{
|
||||
$this->assertEventTenant($request, $event);
|
||||
TenantMemberPermissions::ensureEventPermission($request, $event, 'guest-notifications:manage');
|
||||
|
||||
$data = $request->validated();
|
||||
|
||||
|
||||
@@ -7,6 +7,7 @@ use App\Http\Resources\Tenant\EventJoinTokenResource;
|
||||
use App\Models\Event;
|
||||
use App\Models\EventJoinToken;
|
||||
use App\Services\EventJoinTokenService;
|
||||
use App\Support\TenantMemberPermissions;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Http\Resources\Json\AnonymousResourceCollection;
|
||||
@@ -19,7 +20,7 @@ class EventJoinTokenController extends Controller
|
||||
|
||||
public function index(Request $request, Event $event): AnonymousResourceCollection
|
||||
{
|
||||
$this->authorizeEvent($request, $event);
|
||||
$this->authorizeEvent($request, $event, 'join-tokens:manage');
|
||||
|
||||
$tokens = $event->joinTokens()
|
||||
->orderByDesc('created_at')
|
||||
@@ -30,7 +31,7 @@ class EventJoinTokenController extends Controller
|
||||
|
||||
public function store(Request $request, Event $event): JsonResponse
|
||||
{
|
||||
$this->authorizeEvent($request, $event);
|
||||
$this->authorizeEvent($request, $event, 'join-tokens:manage');
|
||||
|
||||
$validated = $this->validatePayload($request);
|
||||
|
||||
@@ -45,7 +46,7 @@ class EventJoinTokenController extends Controller
|
||||
|
||||
public function update(Request $request, Event $event, EventJoinToken $joinToken): EventJoinTokenResource
|
||||
{
|
||||
$this->authorizeEvent($request, $event);
|
||||
$this->authorizeEvent($request, $event, 'join-tokens:manage');
|
||||
|
||||
if ($joinToken->event_id !== $event->id) {
|
||||
abort(404);
|
||||
@@ -89,7 +90,7 @@ class EventJoinTokenController extends Controller
|
||||
|
||||
public function destroy(Request $request, Event $event, EventJoinToken $joinToken): EventJoinTokenResource
|
||||
{
|
||||
$this->authorizeEvent($request, $event);
|
||||
$this->authorizeEvent($request, $event, 'join-tokens:manage');
|
||||
|
||||
if ($joinToken->event_id !== $event->id) {
|
||||
abort(404);
|
||||
@@ -101,13 +102,17 @@ class EventJoinTokenController extends Controller
|
||||
return new EventJoinTokenResource($token);
|
||||
}
|
||||
|
||||
private function authorizeEvent(Request $request, Event $event): void
|
||||
private function authorizeEvent(Request $request, Event $event, ?string $permission = null): void
|
||||
{
|
||||
$tenantId = $request->attributes->get('tenant_id');
|
||||
|
||||
if ($event->tenant_id !== $tenantId) {
|
||||
abort(404, 'Event not found');
|
||||
}
|
||||
|
||||
if ($permission) {
|
||||
TenantMemberPermissions::ensureEventPermission($request, $event, $permission);
|
||||
}
|
||||
}
|
||||
|
||||
private function validatePayload(Request $request, bool $partial = false): array
|
||||
|
||||
@@ -6,6 +6,7 @@ use App\Http\Controllers\Controller;
|
||||
use App\Models\Event;
|
||||
use App\Models\EventJoinToken;
|
||||
use App\Support\JoinTokenLayoutRegistry;
|
||||
use App\Support\TenantMemberPermissions;
|
||||
use Dompdf\Dompdf;
|
||||
use Dompdf\Options;
|
||||
use Illuminate\Http\Request;
|
||||
@@ -28,6 +29,7 @@ class EventJoinTokenLayoutController extends Controller
|
||||
public function index(Request $request, Event $event, EventJoinToken $joinToken)
|
||||
{
|
||||
$this->ensureBelongsToEvent($event, $joinToken);
|
||||
TenantMemberPermissions::ensureEventPermission($request, $event, 'join-tokens:manage');
|
||||
|
||||
$layouts = JoinTokenLayoutRegistry::toResponse(function (string $layoutId, string $format) use ($event, $joinToken) {
|
||||
return route('api.v1.tenant.events.join-tokens.layouts.download', [
|
||||
@@ -46,6 +48,7 @@ class EventJoinTokenLayoutController extends Controller
|
||||
public function download(Request $request, Event $event, EventJoinToken $joinToken, string $layout, string $format)
|
||||
{
|
||||
$this->ensureBelongsToEvent($event, $joinToken);
|
||||
TenantMemberPermissions::ensureEventPermission($request, $event, 'join-tokens:manage');
|
||||
|
||||
$layoutConfig = JoinTokenLayoutRegistry::find($layout);
|
||||
|
||||
|
||||
@@ -9,6 +9,7 @@ use App\Models\Event;
|
||||
use App\Models\EventMember;
|
||||
use App\Models\Tenant;
|
||||
use App\Models\User;
|
||||
use App\Support\TenantMemberPermissions;
|
||||
use Illuminate\Contracts\Pagination\LengthAwarePaginator;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
@@ -22,6 +23,7 @@ class EventMemberController extends Controller
|
||||
public function index(Request $request, Event $event): JsonResponse
|
||||
{
|
||||
$this->assertEventTenant($request, $event);
|
||||
TenantMemberPermissions::ensureEventPermission($request, $event, 'members:manage');
|
||||
|
||||
/** @var LengthAwarePaginator $members */
|
||||
$members = $event->members()
|
||||
@@ -34,6 +36,7 @@ class EventMemberController extends Controller
|
||||
public function store(EventMemberInviteRequest $request, Event $event): JsonResponse
|
||||
{
|
||||
$this->assertEventTenant($request, $event);
|
||||
TenantMemberPermissions::ensureEventPermission($request, $event, 'members:manage');
|
||||
|
||||
$data = $request->validated();
|
||||
$tenant = $this->resolveTenantFromRequest($request);
|
||||
@@ -92,6 +95,7 @@ class EventMemberController extends Controller
|
||||
public function destroy(Request $request, Event $event, EventMember $member): JsonResponse
|
||||
{
|
||||
$this->assertEventTenant($request, $event);
|
||||
TenantMemberPermissions::ensureEventPermission($request, $event, 'members:manage');
|
||||
|
||||
if ((int) $member->event_id !== (int) $event->id) {
|
||||
throw ValidationException::withMessages([
|
||||
@@ -135,7 +139,7 @@ class EventMemberController extends Controller
|
||||
$user->password = Hash::make(Str::random(32));
|
||||
}
|
||||
|
||||
if ($user->tenant_id && (int) $user->tenant_id !== (int) $tenant->id && $user->role !== 'super_admin') {
|
||||
if ($user->tenant_id && (int) $user->tenant_id !== (int) $tenant->id && ! $user->isSuperAdmin()) {
|
||||
throw ValidationException::withMessages([
|
||||
'email' => __('Dieser Benutzer ist einem anderen Mandanten zugeordnet.'),
|
||||
]);
|
||||
@@ -143,9 +147,9 @@ class EventMemberController extends Controller
|
||||
|
||||
$user->tenant_id = $tenant->id;
|
||||
|
||||
if ($role === 'tenant_admin' && $user->role !== 'super_admin') {
|
||||
if ($role === 'tenant_admin' && ! $user->isSuperAdmin()) {
|
||||
$user->role = 'tenant_admin';
|
||||
} elseif (! in_array($user->role, ['tenant_admin', 'super_admin'], true)) {
|
||||
} elseif (! in_array($user->role, ['tenant_admin', 'super_admin', 'superadmin'], true)) {
|
||||
$user->role = 'member';
|
||||
}
|
||||
|
||||
|
||||
@@ -4,6 +4,7 @@ namespace App\Http\Controllers\Api\Tenant;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\Event;
|
||||
use App\Support\TenantMemberPermissions;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use SimpleSoftwareIO\QrCode\Facades\QrCode;
|
||||
@@ -13,6 +14,7 @@ class LiveShowLinkController extends Controller
|
||||
public function show(Request $request, Event $event): JsonResponse
|
||||
{
|
||||
$this->authorizeEvent($request, $event);
|
||||
TenantMemberPermissions::ensureEventPermission($request, $event, 'live-show:manage');
|
||||
|
||||
$token = $event->ensureLiveShowToken();
|
||||
|
||||
@@ -24,6 +26,7 @@ class LiveShowLinkController extends Controller
|
||||
public function rotate(Request $request, Event $event): JsonResponse
|
||||
{
|
||||
$this->authorizeEvent($request, $event);
|
||||
TenantMemberPermissions::ensureEventPermission($request, $event, 'live-show:manage');
|
||||
|
||||
$token = $event->rotateLiveShowToken();
|
||||
|
||||
|
||||
@@ -10,6 +10,7 @@ use App\Http\Resources\Tenant\PhotoResource;
|
||||
use App\Models\Event;
|
||||
use App\Models\Photo;
|
||||
use App\Support\ApiError;
|
||||
use App\Support\TenantMemberPermissions;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Http\Resources\Json\AnonymousResourceCollection;
|
||||
@@ -23,6 +24,7 @@ class LiveShowPhotoController extends Controller
|
||||
$event = Event::where('slug', $eventSlug)
|
||||
->where('tenant_id', $tenantId)
|
||||
->firstOrFail();
|
||||
TenantMemberPermissions::ensureEventPermission($request, $event, 'photos:moderate');
|
||||
|
||||
$liveStatus = $request->string('live_status', 'pending')->toString();
|
||||
$perPage = (int) $request->input('per_page', 20);
|
||||
@@ -51,6 +53,7 @@ class LiveShowPhotoController extends Controller
|
||||
$event = Event::where('slug', $eventSlug)
|
||||
->where('tenant_id', $tenantId)
|
||||
->firstOrFail();
|
||||
TenantMemberPermissions::ensureEventPermission($request, $event, 'photos:moderate');
|
||||
|
||||
if ($photo->event_id !== $event->id) {
|
||||
return ApiError::response(
|
||||
@@ -94,6 +97,7 @@ class LiveShowPhotoController extends Controller
|
||||
$event = Event::where('slug', $eventSlug)
|
||||
->where('tenant_id', $tenantId)
|
||||
->firstOrFail();
|
||||
TenantMemberPermissions::ensureEventPermission($request, $event, 'photos:moderate');
|
||||
|
||||
if ($photo->event_id !== $event->id) {
|
||||
return ApiError::response(
|
||||
@@ -146,6 +150,7 @@ class LiveShowPhotoController extends Controller
|
||||
$event = Event::where('slug', $eventSlug)
|
||||
->where('tenant_id', $tenantId)
|
||||
->firstOrFail();
|
||||
TenantMemberPermissions::ensureEventPermission($request, $event, 'photos:moderate');
|
||||
|
||||
if ($photo->event_id !== $event->id) {
|
||||
return ApiError::response(
|
||||
@@ -173,6 +178,7 @@ class LiveShowPhotoController extends Controller
|
||||
$event = Event::where('slug', $eventSlug)
|
||||
->where('tenant_id', $tenantId)
|
||||
->firstOrFail();
|
||||
TenantMemberPermissions::ensureEventPermission($request, $event, 'photos:moderate');
|
||||
|
||||
if ($photo->event_id !== $event->id) {
|
||||
return ApiError::response(
|
||||
|
||||
@@ -14,6 +14,7 @@ use App\Services\Packages\PackageUsageTracker;
|
||||
use App\Services\Storage\EventStorageManager;
|
||||
use App\Support\ApiError;
|
||||
use App\Support\ImageHelper;
|
||||
use App\Support\TenantMemberPermissions;
|
||||
use App\Support\UploadStream;
|
||||
use App\Support\WatermarkConfigResolver;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
@@ -524,15 +525,8 @@ class PhotoController extends Controller
|
||||
'alt_text' => ['sometimes', 'string', 'max:255'],
|
||||
]);
|
||||
|
||||
// Only tenant admins can moderate
|
||||
if (isset($validated['status']) && ! $this->tokenHasScope($request, 'tenant:write')) {
|
||||
return ApiError::response(
|
||||
'insufficient_scope',
|
||||
'Insufficient Scopes',
|
||||
'You are not allowed to moderate photos for this event.',
|
||||
Response::HTTP_FORBIDDEN,
|
||||
['required_scope' => 'tenant:write']
|
||||
);
|
||||
if (isset($validated['status'])) {
|
||||
TenantMemberPermissions::ensureEventPermission($request, $event, 'photos:moderate');
|
||||
}
|
||||
|
||||
$photo->update($validated);
|
||||
@@ -634,6 +628,7 @@ class PhotoController extends Controller
|
||||
$event = Event::where('slug', $eventSlug)
|
||||
->where('tenant_id', $tenantId)
|
||||
->firstOrFail();
|
||||
TenantMemberPermissions::ensureEventPermission($request, $event, 'photos:moderate');
|
||||
|
||||
if ($photo->event_id !== $event->id) {
|
||||
return ApiError::response(
|
||||
@@ -657,6 +652,7 @@ class PhotoController extends Controller
|
||||
$event = Event::where('slug', $eventSlug)
|
||||
->where('tenant_id', $tenantId)
|
||||
->firstOrFail();
|
||||
TenantMemberPermissions::ensureEventPermission($request, $event, 'photos:moderate');
|
||||
|
||||
if ($photo->event_id !== $event->id) {
|
||||
return ApiError::response(
|
||||
@@ -680,6 +676,7 @@ class PhotoController extends Controller
|
||||
$event = Event::where('slug', $eventSlug)
|
||||
->where('tenant_id', $tenantId)
|
||||
->firstOrFail();
|
||||
TenantMemberPermissions::ensureEventPermission($request, $event, 'photos:moderate');
|
||||
|
||||
$request->validate([
|
||||
'photo_ids' => 'required|array',
|
||||
@@ -725,6 +722,7 @@ class PhotoController extends Controller
|
||||
$event = Event::where('slug', $eventSlug)
|
||||
->where('tenant_id', $tenantId)
|
||||
->firstOrFail();
|
||||
TenantMemberPermissions::ensureEventPermission($request, $event, 'photos:moderate');
|
||||
|
||||
$request->validate([
|
||||
'photo_ids' => 'required|array',
|
||||
@@ -823,6 +821,11 @@ class PhotoController extends Controller
|
||||
|
||||
private function tokenHasScope(Request $request, string $scope): bool
|
||||
{
|
||||
$accessToken = $request->user()?->currentAccessToken();
|
||||
if ($accessToken && $accessToken->can($scope)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
$scopes = $request->user()->scopes ?? ($request->attributes->get('decoded_token')['scopes'] ?? []);
|
||||
|
||||
if (! is_array($scopes)) {
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Api\Tenant;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Http\Requests\Tenant\PhotoboothConnectCodeStoreRequest;
|
||||
use App\Models\Event;
|
||||
use App\Services\Photobooth\PhotoboothConnectCodeService;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
|
||||
class PhotoboothConnectCodeController extends Controller
|
||||
{
|
||||
public function __construct(private readonly PhotoboothConnectCodeService $service) {}
|
||||
|
||||
public function store(PhotoboothConnectCodeStoreRequest $request, Event $event): JsonResponse
|
||||
{
|
||||
$this->assertEventBelongsToTenant($request, $event);
|
||||
|
||||
$event->loadMissing('photoboothSetting');
|
||||
$setting = $event->photoboothSetting;
|
||||
|
||||
if (! $setting || ! $setting->enabled || $setting->mode !== 'sparkbooth') {
|
||||
return response()->json([
|
||||
'message' => __('Photobooth muss im Sparkbooth-Modus aktiviert sein.'),
|
||||
], 409);
|
||||
}
|
||||
|
||||
$expiresInMinutes = $request->input('expires_in_minutes');
|
||||
$result = $this->service->create($event, $expiresInMinutes ? (int) $expiresInMinutes : null);
|
||||
|
||||
return response()->json([
|
||||
'data' => [
|
||||
'code' => $result['code'],
|
||||
'expires_at' => $result['expires_at']->toIso8601String(),
|
||||
],
|
||||
]);
|
||||
}
|
||||
|
||||
protected function assertEventBelongsToTenant(PhotoboothConnectCodeStoreRequest $request, Event $event): void
|
||||
{
|
||||
$tenantId = (int) $request->attributes->get('tenant_id');
|
||||
|
||||
if ($tenantId !== (int) $event->tenant_id) {
|
||||
abort(403, 'Event gehört nicht zu diesem Tenant.');
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -3,12 +3,17 @@
|
||||
namespace App\Http\Controllers\Api\Tenant;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Http\Requests\Photobooth\PhotoboothSendUploaderDownloadRequest;
|
||||
use App\Http\Resources\Tenant\PhotoboothStatusResource;
|
||||
use App\Mail\PhotoboothUploaderDownload;
|
||||
use App\Models\Event;
|
||||
use App\Models\PhotoboothSetting;
|
||||
use App\Services\Photobooth\PhotoboothProvisioner;
|
||||
use App\Support\LocaleConfig;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Mail;
|
||||
use Illuminate\Validation\ValidationException;
|
||||
|
||||
class PhotoboothController extends Controller
|
||||
{
|
||||
@@ -69,6 +74,39 @@ class PhotoboothController extends Controller
|
||||
]);
|
||||
}
|
||||
|
||||
public function sendUploaderDownloadEmail(PhotoboothSendUploaderDownloadRequest $request, Event $event): JsonResponse
|
||||
{
|
||||
$this->assertEventBelongsToTenant($request, $event);
|
||||
|
||||
$user = $request->user();
|
||||
|
||||
if (! $user || ! $user->email) {
|
||||
throw ValidationException::withMessages([
|
||||
'email' => __('No email address is configured for this account.'),
|
||||
]);
|
||||
}
|
||||
|
||||
$locale = LocaleConfig::canonicalize($user->preferred_locale ?: app()->getLocale());
|
||||
$eventName = $this->resolveEventName($event, $locale);
|
||||
$recipientName = $user->fullName ?? $user->name ?? $user->email;
|
||||
|
||||
$mail = (new PhotoboothUploaderDownload(
|
||||
recipientName: $recipientName,
|
||||
eventName: $eventName,
|
||||
links: [
|
||||
'windows' => url('/downloads/PhotoboothUploader-win-x64.exe'),
|
||||
'macos' => url('/downloads/PhotoboothUploader-macos-x64'),
|
||||
'linux' => url('/downloads/PhotoboothUploader-linux-x64'),
|
||||
],
|
||||
))->locale($locale);
|
||||
|
||||
Mail::to($user->email)->queue($mail);
|
||||
|
||||
return response()->json([
|
||||
'message' => __('Download links sent via email.'),
|
||||
]);
|
||||
}
|
||||
|
||||
protected function resource(Event $event): PhotoboothStatusResource
|
||||
{
|
||||
return PhotoboothStatusResource::make([
|
||||
@@ -92,4 +130,30 @@ class PhotoboothController extends Controller
|
||||
|
||||
return in_array($mode, ['sparkbooth', 'ftp'], true) ? $mode : 'ftp';
|
||||
}
|
||||
|
||||
protected function resolveEventName(Event $event, ?string $locale = null): string
|
||||
{
|
||||
$name = $event->name;
|
||||
|
||||
if (is_string($name) && trim($name) !== '') {
|
||||
return $name;
|
||||
}
|
||||
|
||||
if (is_array($name)) {
|
||||
$locale = $locale ?: app()->getLocale();
|
||||
$localized = $name[$locale] ?? null;
|
||||
|
||||
if (is_string($localized) && trim($localized) !== '') {
|
||||
return $localized;
|
||||
}
|
||||
|
||||
foreach ($name as $value) {
|
||||
if (is_string($value) && trim($value) !== '') {
|
||||
return $value;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return $event->slug ?: __('emails.photobooth_uploader.event_fallback');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -113,8 +113,8 @@ class SettingsController extends Controller
|
||||
$defaultSettings = [
|
||||
'branding' => [
|
||||
'logo_url' => null,
|
||||
'primary_color' => '#3B82F6',
|
||||
'secondary_color' => '#1F2937',
|
||||
'primary_color' => '#FF5A5F',
|
||||
'secondary_color' => '#FFF8F5',
|
||||
'font_family' => 'Inter, sans-serif',
|
||||
],
|
||||
'features' => [
|
||||
|
||||
@@ -11,6 +11,7 @@ use App\Models\Task;
|
||||
use App\Models\TaskCollection;
|
||||
use App\Models\Tenant;
|
||||
use App\Support\ApiError;
|
||||
use App\Support\TenantMemberPermissions;
|
||||
use App\Support\TenantRequestResolver;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
@@ -66,6 +67,8 @@ class TaskController extends Controller
|
||||
*/
|
||||
public function store(TaskStoreRequest $request): JsonResponse
|
||||
{
|
||||
TenantMemberPermissions::ensureTenantPermission($request, 'tasks:manage');
|
||||
|
||||
$tenant = $this->currentTenant($request);
|
||||
$collectionId = $request->input('collection_id');
|
||||
$collection = $collectionId ? $this->resolveAccessibleCollection($request, $collectionId) : null;
|
||||
@@ -107,6 +110,8 @@ class TaskController extends Controller
|
||||
*/
|
||||
public function update(TaskUpdateRequest $request, Task $task): JsonResponse
|
||||
{
|
||||
TenantMemberPermissions::ensureTenantPermission($request, 'tasks:manage');
|
||||
|
||||
$tenant = $this->currentTenant($request);
|
||||
|
||||
if ($task->tenant_id !== $tenant->id) {
|
||||
@@ -138,6 +143,8 @@ class TaskController extends Controller
|
||||
*/
|
||||
public function destroy(Request $request, Task $task): JsonResponse
|
||||
{
|
||||
TenantMemberPermissions::ensureTenantPermission($request, 'tasks:manage');
|
||||
|
||||
if ($task->tenant_id !== $this->currentTenant($request)->id) {
|
||||
abort(404, 'Task nicht gefunden.');
|
||||
}
|
||||
@@ -154,6 +161,8 @@ class TaskController extends Controller
|
||||
*/
|
||||
public function assignToEvent(Request $request, Task $task, Event $event): JsonResponse
|
||||
{
|
||||
TenantMemberPermissions::ensureEventPermission($request, $event, 'tasks:manage');
|
||||
|
||||
$tenantId = $this->currentTenant($request)->id;
|
||||
|
||||
if (($task->tenant_id && $task->tenant_id !== $tenantId) || $event->tenant_id !== $tenantId) {
|
||||
@@ -176,6 +185,8 @@ class TaskController extends Controller
|
||||
*/
|
||||
public function bulkAssignToEvent(Request $request, Event $event): JsonResponse
|
||||
{
|
||||
TenantMemberPermissions::ensureEventPermission($request, $event, 'tasks:manage');
|
||||
|
||||
$tenantId = $this->currentTenant($request)->id;
|
||||
|
||||
if ($event->tenant_id !== $tenantId) {
|
||||
@@ -230,6 +241,8 @@ class TaskController extends Controller
|
||||
|
||||
public function bulkDetachFromEvent(Request $request, Event $event): JsonResponse
|
||||
{
|
||||
TenantMemberPermissions::ensureEventPermission($request, $event, 'tasks:manage');
|
||||
|
||||
$tenantId = $this->currentTenant($request)->id;
|
||||
|
||||
if ($event->tenant_id !== $tenantId) {
|
||||
@@ -256,6 +269,8 @@ class TaskController extends Controller
|
||||
|
||||
public function reorderForEvent(Request $request, Event $event): JsonResponse
|
||||
{
|
||||
TenantMemberPermissions::ensureEventPermission($request, $event, 'tasks:manage');
|
||||
|
||||
$tenantId = $this->currentTenant($request)->id;
|
||||
|
||||
if ($event->tenant_id !== $tenantId) {
|
||||
|
||||
@@ -193,11 +193,11 @@ class TenantAdminTokenController extends Controller
|
||||
$abilities[] = 'tenant:'.$user->tenant_id;
|
||||
}
|
||||
|
||||
if (in_array($user->role, ['tenant_admin', 'admin', 'super_admin'], true)) {
|
||||
if (in_array($user->role, ['tenant_admin', 'admin', 'super_admin', 'superadmin'], true)) {
|
||||
$abilities[] = 'tenant-admin';
|
||||
}
|
||||
|
||||
if ($user->role === 'super_admin') {
|
||||
if ($user->isSuperAdmin()) {
|
||||
$abilities[] = 'super-admin';
|
||||
}
|
||||
|
||||
@@ -219,7 +219,7 @@ class TenantAdminTokenController extends Controller
|
||||
|
||||
private function ensureUserCanAccessPanel(User $user): void
|
||||
{
|
||||
if (in_array($user->role, ['tenant_admin', 'admin', 'super_admin'], true)) {
|
||||
if (in_array($user->role, ['tenant_admin', 'admin', 'super_admin', 'superadmin'], true)) {
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
@@ -9,8 +9,8 @@ use App\Models\User;
|
||||
use App\Notifications\TenantFeedbackSubmitted;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Validation\Rule;
|
||||
use Illuminate\Support\Facades\Notification;
|
||||
use Illuminate\Validation\Rule;
|
||||
|
||||
class TenantFeedbackController extends Controller
|
||||
{
|
||||
@@ -56,7 +56,7 @@ class TenantFeedbackController extends Controller
|
||||
]);
|
||||
|
||||
$recipients = User::query()
|
||||
->where('role', 'super_admin')
|
||||
->whereIn('role', ['super_admin', 'superadmin'])
|
||||
->whereNotNull('email')
|
||||
->get();
|
||||
|
||||
|
||||
@@ -80,7 +80,7 @@ class TenantAdminPasswordResetController extends Controller
|
||||
|
||||
private function canAccessEventAdmin(User $user): bool
|
||||
{
|
||||
if (in_array($user->role, ['tenant_admin', 'admin', 'super_admin'], true)) {
|
||||
if (in_array($user->role, ['tenant_admin', 'admin', 'super_admin', 'superadmin'], true)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
|
||||
@@ -60,6 +60,7 @@ class TenantPackageController extends Controller
|
||||
$pkg?->limits ?? [],
|
||||
$this->buildUsageSnapshot($eventPackage),
|
||||
[
|
||||
'included_package_slug' => $pkg?->included_package_slug,
|
||||
'branding_allowed' => $pkg?->branding_allowed,
|
||||
'watermark_allowed' => $pkg?->watermark_allowed,
|
||||
'features' => $pkg?->features ?? [],
|
||||
|
||||
@@ -47,6 +47,15 @@ class AuthenticatedSessionController extends Controller
|
||||
|
||||
$user = Auth::user();
|
||||
if ($user && $user->email_verified_at === null) {
|
||||
$intended = $request->session()->get('url.intended');
|
||||
$intended = is_string($intended) ? trim($intended) : null;
|
||||
|
||||
if ($this->isVerificationLink($intended)) {
|
||||
$request->session()->forget('url.intended');
|
||||
|
||||
return Inertia::location($intended);
|
||||
}
|
||||
|
||||
return Inertia::location(route('verification.notice'));
|
||||
}
|
||||
|
||||
@@ -116,6 +125,29 @@ class AuthenticatedSessionController extends Controller
|
||||
);
|
||||
}
|
||||
|
||||
private function isVerificationLink(?string $target): bool
|
||||
{
|
||||
if (! is_string($target) || trim($target) === '') {
|
||||
return false;
|
||||
}
|
||||
|
||||
$path = trim($target);
|
||||
|
||||
if (str_starts_with($path, '/verify-email/')) {
|
||||
return true;
|
||||
}
|
||||
|
||||
$parsed = parse_url($path);
|
||||
|
||||
if ($parsed === false) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$path = $parsed['path'] ?? '';
|
||||
|
||||
return $path !== '' && str_starts_with($path, '/verify-email/');
|
||||
}
|
||||
|
||||
private function decodeReturnTo(string $value, Request $request): ?string
|
||||
{
|
||||
$candidate = $this->decodeBase64Url($value) ?? $value;
|
||||
@@ -155,7 +187,7 @@ class AuthenticatedSessionController extends Controller
|
||||
}
|
||||
|
||||
// Super admins go to Filament superadmin panel
|
||||
if ($user && $user->role === 'super_admin') {
|
||||
if ($user && $user->isSuperAdmin()) {
|
||||
return '/super-admin';
|
||||
}
|
||||
|
||||
|
||||
@@ -108,8 +108,8 @@ class CheckoutController extends Controller
|
||||
'settings' => json_encode([
|
||||
'branding' => [
|
||||
'logo_url' => null,
|
||||
'primary_color' => '#3B82F6',
|
||||
'secondary_color' => '#1F2937',
|
||||
'primary_color' => '#FF5A5F',
|
||||
'secondary_color' => '#FFF8F5',
|
||||
'font_family' => 'Inter, sans-serif',
|
||||
],
|
||||
'features' => [
|
||||
|
||||
@@ -146,8 +146,8 @@ class CheckoutGoogleController extends Controller
|
||||
'settings' => json_encode([
|
||||
'branding' => [
|
||||
'logo_url' => null,
|
||||
'primary_color' => '#3B82F6',
|
||||
'secondary_color' => '#1F2937',
|
||||
'primary_color' => '#FF5A5F',
|
||||
'secondary_color' => '#FFF8F5',
|
||||
'font_family' => 'Inter, sans-serif',
|
||||
],
|
||||
'features' => [
|
||||
|
||||
@@ -12,7 +12,7 @@ class TenantAdminAuthController extends Controller
|
||||
$user = Auth::user();
|
||||
|
||||
// Allow only tenant_admin and super_admin
|
||||
if ($user && in_array($user->role, ['tenant_admin', 'super_admin'])) {
|
||||
if ($user && in_array($user->role, ['tenant_admin', 'super_admin', 'superadmin'], true)) {
|
||||
return view('admin');
|
||||
}
|
||||
|
||||
|
||||
@@ -46,7 +46,7 @@ class TenantAdminGoogleController extends Controller
|
||||
/** @var User|null $user */
|
||||
$user = User::query()->where('email', $email)->first();
|
||||
|
||||
if (! $user || ! in_array($user->role, ['tenant_admin', 'super_admin'], true)) {
|
||||
if (! $user || ! in_array($user->role, ['tenant_admin', 'super_admin', 'superadmin'], true)) {
|
||||
return $this->sendBackWithError($request, 'google_no_match', 'No tenant admin account is linked to this Google address.');
|
||||
}
|
||||
|
||||
|
||||
@@ -58,8 +58,8 @@ class TestGuestEventController extends Controller
|
||||
'date' => ($validated['date'] ?? Carbon::now()->addWeeks(2)->toDateString()),
|
||||
'settings' => [
|
||||
'branding' => [
|
||||
'primary_color' => '#f43f5e',
|
||||
'secondary_color' => '#fb7185',
|
||||
'primary_color' => '#FF5A5F',
|
||||
'secondary_color' => '#FFF8F5',
|
||||
'font_family' => 'Inter, sans-serif',
|
||||
],
|
||||
],
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
namespace App\Http\Middleware;
|
||||
|
||||
use App\Models\Tenant;
|
||||
use App\Models\User;
|
||||
use App\Services\Packages\PackageLimitEvaluator;
|
||||
use App\Support\ApiError;
|
||||
use Closure;
|
||||
@@ -26,8 +27,13 @@ class CreditCheckMiddleware
|
||||
]);
|
||||
}
|
||||
|
||||
if ($this->requiresCredits($request)) {
|
||||
$violation = $this->limitEvaluator->assessEventCreation($tenant);
|
||||
if ($this->requiresCredits($request) && ! $this->shouldBypassCreditCheck($request, $tenant)) {
|
||||
$includedSlug = $request->input('service_package_slug');
|
||||
|
||||
$violation = $this->limitEvaluator->assessEventCreation(
|
||||
$tenant,
|
||||
is_string($includedSlug) && $includedSlug !== '' ? $includedSlug : null
|
||||
);
|
||||
|
||||
if ($violation !== null) {
|
||||
return ApiError::response(
|
||||
@@ -43,6 +49,24 @@ class CreditCheckMiddleware
|
||||
return $next($request);
|
||||
}
|
||||
|
||||
private function shouldBypassCreditCheck(Request $request, Tenant $tenant): bool
|
||||
{
|
||||
$user = $request->user();
|
||||
if (! $user instanceof User) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (! $user->isSuperAdmin()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (! $user->tenant_id) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return (int) $user->tenant_id === (int) $tenant->id;
|
||||
}
|
||||
|
||||
private function requiresCredits(Request $request): bool
|
||||
{
|
||||
return $request->isMethod('post')
|
||||
|
||||
@@ -42,7 +42,7 @@ class EnsureTenantAdminToken
|
||||
/** @var Tenant|null $tenant */
|
||||
$tenant = $user->tenant;
|
||||
|
||||
if (! $tenant && $user->role === 'super_admin') {
|
||||
if (! $tenant && $user->isSuperAdmin()) {
|
||||
$requestedTenantId = $this->resolveRequestedTenantId($request);
|
||||
|
||||
if ($requestedTenantId !== null) {
|
||||
@@ -50,14 +50,14 @@ class EnsureTenantAdminToken
|
||||
}
|
||||
}
|
||||
|
||||
if (! $tenant && $user->role !== 'super_admin') {
|
||||
if (! $tenant && ! $user->isSuperAdmin()) {
|
||||
return $this->forbiddenResponse('Tenant context missing for user.');
|
||||
}
|
||||
|
||||
if ($tenant) {
|
||||
$request->attributes->set('tenant_id', $tenant->id);
|
||||
$request->attributes->set('tenant', $tenant);
|
||||
} elseif ($user->role === 'super_admin') {
|
||||
} elseif ($user->isSuperAdmin()) {
|
||||
$requestedTenantId = $this->resolveRequestedTenantId($request);
|
||||
if ($requestedTenantId !== null) {
|
||||
$request->attributes->set('tenant_id', $requestedTenantId);
|
||||
@@ -96,7 +96,7 @@ class EnsureTenantAdminToken
|
||||
*/
|
||||
protected function allowedRoles(): array
|
||||
{
|
||||
return ['tenant_admin', 'super_admin', 'admin'];
|
||||
return ['tenant_admin', 'super_admin', 'superadmin', 'admin'];
|
||||
}
|
||||
|
||||
protected function forbiddenRoleMessage(): string
|
||||
|
||||
@@ -9,7 +9,7 @@ class EnsureTenantCollaboratorToken extends EnsureTenantAdminToken
|
||||
{
|
||||
protected function allowedRoles(): array
|
||||
{
|
||||
return ['tenant_admin', 'super_admin', 'admin', 'member'];
|
||||
return ['tenant_admin', 'super_admin', 'superadmin', 'admin', 'member'];
|
||||
}
|
||||
|
||||
protected function forbiddenRoleMessage(): string
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
namespace App\Http\Middleware;
|
||||
|
||||
use App\Models\Tenant;
|
||||
use App\Models\User;
|
||||
use App\Services\Packages\PackageLimitEvaluator;
|
||||
use App\Support\ApiError;
|
||||
use Closure;
|
||||
@@ -26,7 +27,7 @@ class PackageMiddleware
|
||||
]);
|
||||
}
|
||||
|
||||
if ($this->requiresPackageCheck($request)) {
|
||||
if ($this->requiresPackageCheck($request) && ! $this->shouldBypassPackageCheck($request, $tenant)) {
|
||||
$violation = $this->detectViolation($request, $tenant);
|
||||
|
||||
if ($violation !== null) {
|
||||
@@ -43,6 +44,24 @@ class PackageMiddleware
|
||||
return $next($request);
|
||||
}
|
||||
|
||||
private function shouldBypassPackageCheck(Request $request, Tenant $tenant): bool
|
||||
{
|
||||
$user = $request->user();
|
||||
if (! $user instanceof User) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (! $user->isSuperAdmin()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (! $user->tenant_id) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return (int) $user->tenant_id === (int) $tenant->id;
|
||||
}
|
||||
|
||||
private function requiresPackageCheck(Request $request): bool
|
||||
{
|
||||
return $request->isMethod('post') && (
|
||||
@@ -54,7 +73,12 @@ class PackageMiddleware
|
||||
private function detectViolation(Request $request, Tenant $tenant): ?array
|
||||
{
|
||||
if ($request->routeIs('api.v1.tenant.events.store')) {
|
||||
return $this->limitEvaluator->assessEventCreation($tenant);
|
||||
$includedSlug = $request->input('service_package_slug');
|
||||
|
||||
return $this->limitEvaluator->assessEventCreation(
|
||||
$tenant,
|
||||
is_string($includedSlug) && $includedSlug !== '' ? $includedSlug : null
|
||||
);
|
||||
}
|
||||
|
||||
if ($request->routeIs('api.v1.tenant.events.photos.store')) {
|
||||
|
||||
@@ -112,7 +112,7 @@ class RedirectIfAuthenticated extends BaseMiddleware
|
||||
return '/event-admin/dashboard';
|
||||
}
|
||||
|
||||
if ($user && $user->role === 'super_admin') {
|
||||
if ($user && $user->isSuperAdmin()) {
|
||||
return '/super-admin';
|
||||
}
|
||||
|
||||
|
||||
@@ -4,9 +4,9 @@ namespace App\Http\Middleware;
|
||||
|
||||
use Closure;
|
||||
use Illuminate\Http\Request;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
use Illuminate\Support\Facades\Auth;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
|
||||
class SuperAdminAuth
|
||||
{
|
||||
@@ -21,17 +21,17 @@ class SuperAdminAuth
|
||||
return $next($request);
|
||||
}
|
||||
|
||||
if (!Auth::check()) {
|
||||
if (! Auth::check()) {
|
||||
abort(403, 'Nicht angemeldet.');
|
||||
}
|
||||
|
||||
$user = Auth::user();
|
||||
Log::info('SuperAdminAuth: User ID ' . $user->id . ', role: ' . $user->role);
|
||||
Log::info('SuperAdminAuth: User ID '.$user->id.', role: '.$user->role);
|
||||
|
||||
if ($user->role !== 'super_admin') {
|
||||
abort(403, 'Zugriff nur für SuperAdmin. User ID: ' . $user->id . ', Role: ' . $user->role);
|
||||
if (! $user->isSuperAdmin()) {
|
||||
abort(403, 'Zugriff nur für SuperAdmin. User ID: '.$user->id.', Role: '.$user->role);
|
||||
}
|
||||
|
||||
return $next($request);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Requests\Photobooth;
|
||||
|
||||
use Illuminate\Foundation\Http\FormRequest;
|
||||
|
||||
class PhotoboothConnectRedeemRequest extends FormRequest
|
||||
{
|
||||
/**
|
||||
* Determine if the user is authorized to make this request.
|
||||
*/
|
||||
public function authorize(): bool
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the validation rules that apply to the request.
|
||||
*
|
||||
* @return array<string, \Illuminate\Contracts\Validation\ValidationRule|array<mixed>|string>
|
||||
*/
|
||||
public function rules(): array
|
||||
{
|
||||
return [
|
||||
'code' => ['required', 'string', 'size:6', 'regex:/^\d{6}$/'],
|
||||
];
|
||||
}
|
||||
|
||||
protected function prepareForValidation(): void
|
||||
{
|
||||
$code = preg_replace('/\D+/', '', (string) $this->input('code'));
|
||||
|
||||
$this->merge([
|
||||
'code' => $code,
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Requests\Photobooth;
|
||||
|
||||
use Illuminate\Foundation\Http\FormRequest;
|
||||
|
||||
class PhotoboothSendUploaderDownloadRequest extends FormRequest
|
||||
{
|
||||
public function authorize(): bool
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
public function rules(): array
|
||||
{
|
||||
return [];
|
||||
}
|
||||
}
|
||||
@@ -23,13 +23,21 @@ class EventStoreRequest extends FormRequest
|
||||
public function rules(): array
|
||||
{
|
||||
$tenantId = request()->attributes->get('tenant_id');
|
||||
$creating = $this->isMethod('post');
|
||||
|
||||
return [
|
||||
'name' => ['required', 'string', 'max:255'],
|
||||
'name' => [$creating ? 'required' : 'sometimes', 'string', 'max:255'],
|
||||
'description' => ['nullable', 'string'],
|
||||
'event_date' => ['required', 'date', 'after_or_equal:today'],
|
||||
'event_date' => $creating ? ['required', 'date', 'after_or_equal:today'] : ['sometimes', 'date'],
|
||||
'location' => ['nullable', 'string', 'max:255'],
|
||||
'event_type_id' => ['required', 'exists:event_types,id'],
|
||||
'event_type_id' => [$creating ? 'required' : 'sometimes', 'exists:event_types,id'],
|
||||
'package_id' => ['nullable', 'integer', 'exists:packages,id'],
|
||||
'service_package_slug' => [
|
||||
'nullable',
|
||||
'string',
|
||||
'max:64',
|
||||
Rule::exists('packages', 'slug')->where('type', 'endcustomer'),
|
||||
],
|
||||
'max_participants' => ['nullable', 'integer', 'min:1', 'max:10000'],
|
||||
'public_url' => ['nullable', 'url', 'max:500'],
|
||||
'custom_domain' => ['nullable', 'string', 'max:255'],
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Requests\Tenant;
|
||||
|
||||
use Illuminate\Foundation\Http\FormRequest;
|
||||
|
||||
class PhotoboothConnectCodeStoreRequest extends FormRequest
|
||||
{
|
||||
/**
|
||||
* Determine if the user is authorized to make this request.
|
||||
*/
|
||||
public function authorize(): bool
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the validation rules that apply to the request.
|
||||
*
|
||||
* @return array<string, \Illuminate\Contracts\Validation\ValidationRule|array<mixed>|string>
|
||||
*/
|
||||
public function rules(): array
|
||||
{
|
||||
return [
|
||||
'expires_in_minutes' => ['nullable', 'integer', 'min:1', 'max:120'],
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -3,6 +3,7 @@
|
||||
namespace App\Http\Resources\Tenant;
|
||||
|
||||
use App\Services\Packages\PackageLimitEvaluator;
|
||||
use App\Support\TenantMemberPermissions;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Http\Resources\Json\JsonResource;
|
||||
use Illuminate\Http\Resources\MissingValue;
|
||||
@@ -18,6 +19,12 @@ class EventResource extends JsonResource
|
||||
$showSensitive = $this->tenant_id === $tenantId;
|
||||
$settings = is_array($this->settings) ? $this->settings : [];
|
||||
$eventPackage = null;
|
||||
$memberPermissions = null;
|
||||
|
||||
$user = $request->user();
|
||||
if ($user && $user->role === 'member') {
|
||||
$memberPermissions = TenantMemberPermissions::resolveEventPermissions($request, $this->resource);
|
||||
}
|
||||
|
||||
if ($this->relationLoaded('eventPackages')) {
|
||||
$related = $this->getRelation('eventPackages');
|
||||
@@ -86,6 +93,7 @@ class EventResource extends JsonResource
|
||||
? $limitEvaluator->summarizeEventPackage($eventPackage)
|
||||
: null,
|
||||
'addons' => $eventPackage ? $this->formatAddons($eventPackage) : [],
|
||||
'member_permissions' => $memberPermissions,
|
||||
];
|
||||
}
|
||||
|
||||
|
||||
@@ -47,7 +47,7 @@ class PhotoboothStatusResource extends JsonResource
|
||||
'password' => $password,
|
||||
'path' => $eventSetting?->path,
|
||||
'ftp_url' => $isSparkbooth ? null : $this->buildFtpUrl($eventSetting, $settings, $password),
|
||||
'upload_url' => $isSparkbooth ? route('api.v1.photobooth.sparkbooth.upload') : null,
|
||||
'upload_url' => $isSparkbooth ? route('api.v1.photobooth.upload') : null,
|
||||
'expires_at' => optional($activeExpires)->toIso8601String(),
|
||||
'rate_limit_per_minute' => (int) $settings->rate_limit_per_minute,
|
||||
'ftp' => [
|
||||
@@ -62,7 +62,7 @@ class PhotoboothStatusResource extends JsonResource
|
||||
'username' => $mode === 'sparkbooth' ? $eventSetting?->username : null,
|
||||
'password' => $mode === 'sparkbooth' ? $password : null,
|
||||
'expires_at' => $mode === 'sparkbooth' ? optional($eventSetting?->expires_at)->toIso8601String() : null,
|
||||
'upload_url' => route('api.v1.photobooth.sparkbooth.upload'),
|
||||
'upload_url' => route('api.v1.photobooth.upload'),
|
||||
'response_format' => ($eventSetting?->metadata ?? [])['sparkbooth_response_format'] ?? config('photobooth.sparkbooth.response_format', 'json'),
|
||||
'metrics' => $sparkMetrics,
|
||||
],
|
||||
|
||||
@@ -5,11 +5,19 @@ namespace App\Listeners\GuestNotifications;
|
||||
use App\Enums\GuestNotificationAudience;
|
||||
use App\Enums\GuestNotificationType;
|
||||
use App\Events\GuestPhotoUploaded;
|
||||
use App\Models\GuestNotification;
|
||||
use App\Models\Photo;
|
||||
use App\Services\GuestNotificationService;
|
||||
use Illuminate\Support\Carbon;
|
||||
|
||||
class SendPhotoUploadedNotification
|
||||
{
|
||||
private const DEDUPE_WINDOW_SECONDS = 30;
|
||||
|
||||
private const GROUP_WINDOW_MINUTES = 10;
|
||||
|
||||
private const MAX_GROUP_PHOTOS = 6;
|
||||
|
||||
/**
|
||||
* @param int[] $milestones
|
||||
*/
|
||||
@@ -25,7 +33,20 @@ class SendPhotoUploadedNotification
|
||||
? sprintf('%s hat gerade ein Foto gemacht 🎉', $guestLabel)
|
||||
: 'Es gibt neue Fotos!';
|
||||
|
||||
$this->notifications->createNotification(
|
||||
$recent = $this->findRecentPhotoNotification($event->event->id);
|
||||
if ($recent) {
|
||||
if ($this->shouldSkipDuplicate($recent, $event->photoId, $title)) {
|
||||
return;
|
||||
}
|
||||
|
||||
$notification = $this->updateGroupedNotification($recent, $event->photoId);
|
||||
$this->markUploaderRead($notification, $event->guestIdentifier);
|
||||
$this->maybeCreateMilestoneNotification($event, $guestLabel);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$notification = $this->notifications->createNotification(
|
||||
$event->event,
|
||||
GuestNotificationType::PHOTO_ACTIVITY,
|
||||
$title,
|
||||
@@ -34,11 +55,15 @@ class SendPhotoUploadedNotification
|
||||
'audience_scope' => GuestNotificationAudience::ALL,
|
||||
'payload' => [
|
||||
'photo_id' => $event->photoId,
|
||||
'photo_ids' => [$event->photoId],
|
||||
'count' => 1,
|
||||
],
|
||||
'expires_at' => now()->addHours(3),
|
||||
]
|
||||
);
|
||||
|
||||
$this->markUploaderRead($notification, $event->guestIdentifier);
|
||||
|
||||
$this->maybeCreateMilestoneNotification($event, $guestLabel);
|
||||
}
|
||||
|
||||
@@ -87,4 +112,94 @@ class SendPhotoUploadedNotification
|
||||
|
||||
return $guestIdentifier;
|
||||
}
|
||||
|
||||
private function findRecentPhotoNotification(int $eventId): ?GuestNotification
|
||||
{
|
||||
$cutoff = Carbon::now()->subMinutes(self::GROUP_WINDOW_MINUTES);
|
||||
|
||||
return GuestNotification::query()
|
||||
->where('event_id', $eventId)
|
||||
->where('type', GuestNotificationType::PHOTO_ACTIVITY)
|
||||
->active()
|
||||
->notExpired()
|
||||
->where('created_at', '>=', $cutoff)
|
||||
->orderByDesc('id')
|
||||
->first();
|
||||
}
|
||||
|
||||
private function shouldSkipDuplicate(GuestNotification $notification, int $photoId, string $title): bool
|
||||
{
|
||||
$payload = $notification->payload;
|
||||
if (is_array($payload)) {
|
||||
$payloadIds = array_filter(
|
||||
array_map(
|
||||
fn ($value) => is_numeric($value) ? (int) $value : null,
|
||||
(array) ($payload['photo_ids'] ?? [])
|
||||
),
|
||||
fn ($value) => $value !== null && $value > 0
|
||||
);
|
||||
if (in_array($photoId, $payloadIds, true)) {
|
||||
return true;
|
||||
}
|
||||
if (is_numeric($payload['photo_id'] ?? null) && (int) $payload['photo_id'] === $photoId) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
$cutoff = Carbon::now()->subSeconds(self::DEDUPE_WINDOW_SECONDS);
|
||||
if ($notification->created_at instanceof Carbon && $notification->created_at->greaterThanOrEqualTo($cutoff)) {
|
||||
return $notification->title === $title;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
private function updateGroupedNotification(GuestNotification $notification, int $photoId): GuestNotification
|
||||
{
|
||||
$payload = is_array($notification->payload) ? $notification->payload : [];
|
||||
$photoIds = array_filter(
|
||||
array_map(
|
||||
fn ($value) => is_numeric($value) ? (int) $value : null,
|
||||
(array) ($payload['photo_ids'] ?? [])
|
||||
),
|
||||
fn ($value) => $value !== null && $value > 0
|
||||
);
|
||||
$photoIds[] = $photoId;
|
||||
$photoIds = array_values(array_unique($photoIds));
|
||||
$photoIds = array_slice($photoIds, 0, self::MAX_GROUP_PHOTOS);
|
||||
|
||||
$existingCount = is_numeric($payload['count'] ?? null)
|
||||
? max(1, (int) $payload['count'])
|
||||
: max(1, count($photoIds) - 1);
|
||||
$newCount = $existingCount + 1;
|
||||
|
||||
$notification->forceFill([
|
||||
'title' => $this->buildGroupedTitle($newCount),
|
||||
'payload' => [
|
||||
'count' => $newCount,
|
||||
'photo_ids' => $photoIds,
|
||||
],
|
||||
])->save();
|
||||
|
||||
return $notification;
|
||||
}
|
||||
|
||||
private function buildGroupedTitle(int $count): string
|
||||
{
|
||||
if ($count <= 1) {
|
||||
return 'Es gibt neue Fotos!';
|
||||
}
|
||||
|
||||
return sprintf('Es gibt %d neue Fotos!', $count);
|
||||
}
|
||||
|
||||
private function markUploaderRead(GuestNotification $notification, string $guestIdentifier): void
|
||||
{
|
||||
$guestIdentifier = trim($guestIdentifier);
|
||||
if ($guestIdentifier === '' || $guestIdentifier === 'anonymous') {
|
||||
return;
|
||||
}
|
||||
|
||||
$this->notifications->markAsRead($notification, $guestIdentifier);
|
||||
}
|
||||
}
|
||||
|
||||
50
app/Mail/PhotoboothUploaderDownload.php
Normal file
50
app/Mail/PhotoboothUploaderDownload.php
Normal file
@@ -0,0 +1,50 @@
|
||||
<?php
|
||||
|
||||
namespace App\Mail;
|
||||
|
||||
use Illuminate\Bus\Queueable;
|
||||
use Illuminate\Contracts\Queue\ShouldQueue;
|
||||
use Illuminate\Mail\Mailable;
|
||||
use Illuminate\Mail\Mailables\Content;
|
||||
use Illuminate\Mail\Mailables\Envelope;
|
||||
use Illuminate\Queue\SerializesModels;
|
||||
|
||||
class PhotoboothUploaderDownload extends Mailable implements ShouldQueue
|
||||
{
|
||||
use Queueable, SerializesModels;
|
||||
|
||||
/**
|
||||
* @param array{windows:string, macos:string, linux:string} $links
|
||||
*/
|
||||
public function __construct(
|
||||
public string $recipientName,
|
||||
public string $eventName,
|
||||
public array $links,
|
||||
) {}
|
||||
|
||||
public function envelope(): Envelope
|
||||
{
|
||||
return new Envelope(
|
||||
subject: __('emails.photobooth_uploader.subject', [
|
||||
'event' => $this->eventName,
|
||||
]),
|
||||
);
|
||||
}
|
||||
|
||||
public function content(): Content
|
||||
{
|
||||
return new Content(
|
||||
view: 'emails.photobooth-uploader-download',
|
||||
with: [
|
||||
'recipientName' => $this->recipientName,
|
||||
'eventName' => $this->eventName,
|
||||
'links' => $this->links,
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
public function attachments(): array
|
||||
{
|
||||
return [];
|
||||
}
|
||||
}
|
||||
@@ -41,9 +41,9 @@ class GuestPolicySetting extends Model
|
||||
'per_device_upload_limit' => 50,
|
||||
'join_token_failure_limit' => (int) config('join_tokens.failure_limit', 10),
|
||||
'join_token_failure_decay_minutes' => (int) config('join_tokens.failure_decay_minutes', 5),
|
||||
'join_token_access_limit' => (int) config('join_tokens.access_limit', 120),
|
||||
'join_token_access_limit' => (int) config('join_tokens.access_limit', 300),
|
||||
'join_token_access_decay_minutes' => (int) config('join_tokens.access_decay_minutes', 1),
|
||||
'join_token_download_limit' => (int) config('join_tokens.download_limit', 60),
|
||||
'join_token_download_limit' => (int) config('join_tokens.download_limit', 120),
|
||||
'join_token_download_decay_minutes' => (int) config('join_tokens.download_decay_minutes', 1),
|
||||
'join_token_ttl_hours' => 168,
|
||||
'share_link_ttl_hours' => (int) config('share-links.ttl_hours', 48),
|
||||
|
||||
@@ -19,6 +19,7 @@ class Package extends Model
|
||||
'name_translations',
|
||||
'slug',
|
||||
'type',
|
||||
'included_package_slug',
|
||||
'price',
|
||||
'max_photos',
|
||||
'max_guests',
|
||||
|
||||
25
app/Models/PhotoboothConnectCode.php
Normal file
25
app/Models/PhotoboothConnectCode.php
Normal file
@@ -0,0 +1,25 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
|
||||
class PhotoboothConnectCode extends Model
|
||||
{
|
||||
/** @use HasFactory<\Database\Factories\PhotoboothConnectCodeFactory> */
|
||||
use HasFactory;
|
||||
|
||||
protected $guarded = [];
|
||||
|
||||
protected $casts = [
|
||||
'expires_at' => 'datetime',
|
||||
'redeemed_at' => 'datetime',
|
||||
];
|
||||
|
||||
public function event(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Event::class);
|
||||
}
|
||||
}
|
||||
@@ -100,7 +100,14 @@ class Tenant extends Model
|
||||
|
||||
public function activeResellerPackage(): HasOne
|
||||
{
|
||||
return $this->hasOne(TenantPackage::class)->where('active', true);
|
||||
return $this->hasOne(TenantPackage::class)
|
||||
->where('active', true)
|
||||
->where(function ($query) {
|
||||
$query->whereNull('expires_at')->orWhere('expires_at', '>', now());
|
||||
})
|
||||
->whereHas('package', fn ($query) => $query->withTrashed()->where('type', 'reseller'))
|
||||
->orderBy('purchased_at')
|
||||
->orderBy('id');
|
||||
}
|
||||
|
||||
public function notificationLogs(): HasMany
|
||||
@@ -151,6 +158,13 @@ class Tenant extends Model
|
||||
return false;
|
||||
}
|
||||
|
||||
public function hasEventAllowanceFor(?string $includedPackageSlug): bool
|
||||
{
|
||||
$package = $this->getActiveResellerPackageFor($includedPackageSlug);
|
||||
|
||||
return $package !== null && $package->canCreateEvent();
|
||||
}
|
||||
|
||||
public function consumeEventAllowance(int $amount = 1, string $reason = 'event.create', ?string $note = null): bool
|
||||
{
|
||||
$package = $this->getActiveResellerPackage();
|
||||
@@ -183,13 +197,68 @@ class Tenant extends Model
|
||||
return false;
|
||||
}
|
||||
|
||||
public function consumeEventAllowanceFor(?string $includedPackageSlug, int $amount = 1, string $reason = 'event.create', ?string $note = null): bool
|
||||
{
|
||||
$package = $this->getActiveResellerPackageFor($includedPackageSlug);
|
||||
|
||||
if ($package && $package->canCreateEvent()) {
|
||||
$previousUsed = (int) $package->used_events;
|
||||
$package->increment('used_events', $amount);
|
||||
$package->refresh();
|
||||
|
||||
app(\App\Services\Packages\TenantUsageTracker::class)->recordEventUsage(
|
||||
$package,
|
||||
$previousUsed,
|
||||
$amount
|
||||
);
|
||||
|
||||
Log::info('Tenant package usage recorded', [
|
||||
'tenant_id' => $this->id,
|
||||
'tenant_package_id' => $package->id,
|
||||
'used_events' => $package->used_events,
|
||||
'amount' => $amount,
|
||||
]);
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
Log::warning('Event allowance missing for tenant', [
|
||||
'tenant_id' => $this->id,
|
||||
'reason' => $reason,
|
||||
'included_package_slug' => $includedPackageSlug,
|
||||
]);
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
public function getActiveResellerPackage(): ?TenantPackage
|
||||
{
|
||||
return $this->activeResellerPackage()
|
||||
->whereHas('package', fn ($query) => $query->withTrashed()->where('type', 'reseller'))
|
||||
return $this->activeResellerPackage()->with('package')->first();
|
||||
}
|
||||
|
||||
public function getActiveResellerPackageFor(?string $includedPackageSlug): ?TenantPackage
|
||||
{
|
||||
$query = $this->tenantPackages()
|
||||
->with('package')
|
||||
->where('active', true)
|
||||
->orderByDesc('expires_at')
|
||||
->first();
|
||||
->where(function ($query) {
|
||||
$query->whereNull('expires_at')->orWhere('expires_at', '>', now());
|
||||
})
|
||||
->whereHas('package', fn ($query) => $query->withTrashed()->where('type', 'reseller'))
|
||||
->orderBy('purchased_at')
|
||||
->orderBy('id');
|
||||
|
||||
if (is_string($includedPackageSlug) && $includedPackageSlug !== '') {
|
||||
$query->whereHas('package', function ($query) use ($includedPackageSlug) {
|
||||
$query->where('included_package_slug', $includedPackageSlug);
|
||||
|
||||
if ($includedPackageSlug === 'standard') {
|
||||
$query->orWhereNull('included_package_slug');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
return $query->first();
|
||||
}
|
||||
|
||||
public function activeSubscription(): Attribute
|
||||
|
||||
@@ -66,18 +66,30 @@ class TenantPackage extends Model
|
||||
return false;
|
||||
}
|
||||
|
||||
$maxEvents = $this->package->max_events_per_year ?? 0;
|
||||
$maxEvents = $this->package->max_events_per_year;
|
||||
|
||||
if ($maxEvents === null) {
|
||||
return true;
|
||||
}
|
||||
|
||||
$maxEvents = max(0, (int) $maxEvents);
|
||||
|
||||
return $this->used_events < $maxEvents;
|
||||
}
|
||||
|
||||
public function getRemainingEventsAttribute(): int
|
||||
public function getRemainingEventsAttribute(): ?int
|
||||
{
|
||||
if (! $this->package->isReseller()) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
$max = $this->package->max_events_per_year ?? 0;
|
||||
$max = $this->package->max_events_per_year;
|
||||
|
||||
if ($max === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$max = max(0, (int) $max);
|
||||
|
||||
return max(0, $max - $this->used_events);
|
||||
}
|
||||
@@ -94,9 +106,7 @@ class TenantPackage extends Model
|
||||
$package = $tenantPackage->package;
|
||||
|
||||
if ($package && $package->isReseller()) {
|
||||
if (! $tenantPackage->expires_at) {
|
||||
$tenantPackage->expires_at = now()->addYear();
|
||||
}
|
||||
// Reseller packages represent prepaid Event-Kontingente and should not expire by default.
|
||||
} elseif (! $tenantPackage->expires_at) {
|
||||
$tenantPackage->expires_at = now()->addYear();
|
||||
}
|
||||
|
||||
@@ -69,6 +69,16 @@ class User extends Authenticatable implements FilamentHasTenants, FilamentUser,
|
||||
];
|
||||
}
|
||||
|
||||
public function isSuperAdmin(): bool
|
||||
{
|
||||
return self::isSuperAdminRole($this->role);
|
||||
}
|
||||
|
||||
public static function isSuperAdminRole(?string $role): bool
|
||||
{
|
||||
return in_array($role, ['super_admin', 'superadmin'], true);
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrieve the user by the given credentials.
|
||||
*/
|
||||
@@ -127,12 +137,12 @@ class User extends Authenticatable implements FilamentHasTenants, FilamentUser,
|
||||
|
||||
public function canAccessPanel(Panel $panel): bool
|
||||
{
|
||||
if (! $this->email_verified_at && $this->role !== 'super_admin') {
|
||||
if (! $this->email_verified_at && ! $this->isSuperAdmin()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return match ($panel->getId()) {
|
||||
'superadmin' => $this->role === 'super_admin',
|
||||
'superadmin' => $this->isSuperAdmin(),
|
||||
'admin' => $this->role === 'tenant_admin',
|
||||
default => false,
|
||||
};
|
||||
@@ -140,7 +150,7 @@ class User extends Authenticatable implements FilamentHasTenants, FilamentUser,
|
||||
|
||||
public function canAccessTenant(Model $tenant): bool
|
||||
{
|
||||
if ($this->role === 'super_admin') {
|
||||
if ($this->isSuperAdmin()) {
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -155,7 +165,7 @@ class User extends Authenticatable implements FilamentHasTenants, FilamentUser,
|
||||
|
||||
public function getTenants(Panel $panel): array|Collection
|
||||
{
|
||||
if ($this->role === 'super_admin') {
|
||||
if ($this->isSuperAdmin()) {
|
||||
return Tenant::query()->orderBy('name')->get();
|
||||
}
|
||||
|
||||
|
||||
@@ -12,12 +12,11 @@ class PurchaseHistoryPolicy
|
||||
|
||||
public function viewAny(User $user): bool
|
||||
{
|
||||
return $user->role === 'super_admin';
|
||||
return $user->isSuperAdmin();
|
||||
}
|
||||
|
||||
public function view(User $user, PurchaseHistory $purchaseHistory): bool
|
||||
{
|
||||
return $user->role === 'super_admin';
|
||||
return $user->isSuperAdmin();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -15,7 +15,7 @@ class TenantPolicy
|
||||
*/
|
||||
public function viewAny(User $user): bool
|
||||
{
|
||||
return $user->role === 'super_admin';
|
||||
return $user->isSuperAdmin();
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -35,7 +35,7 @@ class TenantPolicy
|
||||
*/
|
||||
public function create(User $user): bool
|
||||
{
|
||||
return $user->role === 'super_admin';
|
||||
return $user->isSuperAdmin();
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -43,7 +43,7 @@ class TenantPolicy
|
||||
*/
|
||||
public function update(User $user, Tenant $tenant): bool
|
||||
{
|
||||
return $user->role === 'super_admin';
|
||||
return $user->isSuperAdmin();
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -51,7 +51,7 @@ class TenantPolicy
|
||||
*/
|
||||
public function delete(User $user, Tenant $tenant): bool
|
||||
{
|
||||
return $user->role === 'super_admin';
|
||||
return $user->isSuperAdmin();
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -59,6 +59,6 @@ class TenantPolicy
|
||||
*/
|
||||
public function suspend(User $user, Tenant $tenant): bool
|
||||
{
|
||||
return $user->role === 'super_admin';
|
||||
return $user->isSuperAdmin();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -155,7 +155,15 @@ class AppServiceProvider extends ServiceProvider
|
||||
|
||||
$key = $tenantId ? 'tenant:'.$tenantId : ('ip:'.($request->ip() ?? 'unknown'));
|
||||
|
||||
return Limit::perMinute(100)->by($key);
|
||||
return Limit::perMinute(600)->by($key);
|
||||
});
|
||||
|
||||
RateLimiter::for('guest-api', function (Request $request) {
|
||||
return Limit::perMinute(300)->by('guest-api:'.($request->ip() ?? 'unknown'));
|
||||
});
|
||||
|
||||
RateLimiter::for('photobooth-connect', function (Request $request) {
|
||||
return Limit::perMinute(30)->by('photobooth-connect:'.($request->ip() ?? 'unknown'));
|
||||
});
|
||||
|
||||
RateLimiter::for('tenant-auth', function (Request $request) {
|
||||
|
||||
@@ -46,7 +46,7 @@ class AuthServiceProvider extends ServiceProvider
|
||||
});
|
||||
|
||||
Gate::before(function (User $user): ?bool {
|
||||
return $user->role === 'super_admin' ? true : null;
|
||||
return $user->isSuperAdmin() ? true : null;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -83,7 +83,7 @@ class SuperAdminAuditLogger
|
||||
|
||||
private function shouldLog(?User $actor): bool
|
||||
{
|
||||
if (! $actor || $actor->role !== 'super_admin') {
|
||||
if (! $actor || ! $actor->isSuperAdmin()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
|
||||
@@ -94,18 +94,34 @@ class CheckoutAssignmentService
|
||||
]
|
||||
);
|
||||
|
||||
$tenantPackage = TenantPackage::updateOrCreate(
|
||||
[
|
||||
'tenant_id' => $tenant->id,
|
||||
'package_id' => $package->id,
|
||||
],
|
||||
[
|
||||
'price' => round($price, 2),
|
||||
'active' => true,
|
||||
'purchased_at' => now(),
|
||||
'expires_at' => $this->resolveExpiry($package, $tenant),
|
||||
]
|
||||
);
|
||||
if ($package->type === 'reseller') {
|
||||
$tenantPackage = null;
|
||||
|
||||
if ($purchase->wasRecentlyCreated) {
|
||||
$tenantPackage = TenantPackage::create([
|
||||
'tenant_id' => $tenant->id,
|
||||
'package_id' => $package->id,
|
||||
'price' => round($price, 2),
|
||||
'active' => true,
|
||||
'purchased_at' => now(),
|
||||
'expires_at' => null,
|
||||
'used_events' => 0,
|
||||
]);
|
||||
}
|
||||
} else {
|
||||
$tenantPackage = TenantPackage::updateOrCreate(
|
||||
[
|
||||
'tenant_id' => $tenant->id,
|
||||
'package_id' => $package->id,
|
||||
],
|
||||
[
|
||||
'price' => round($price, 2),
|
||||
'active' => true,
|
||||
'purchased_at' => now(),
|
||||
'expires_at' => $this->resolveExpiry($package, $tenant),
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
if ($package->type !== 'reseller') {
|
||||
$tenant->forceFill([
|
||||
@@ -188,11 +204,7 @@ class CheckoutAssignmentService
|
||||
protected function resolveExpiry(Package $package, Tenant $tenant)
|
||||
{
|
||||
if ($package->type === 'reseller') {
|
||||
$hasActive = TenantPackage::where('tenant_id', $tenant->id)
|
||||
->where('active', true)
|
||||
->exists();
|
||||
|
||||
return $hasActive ? now()->addYear() : now()->addDays(14);
|
||||
return null;
|
||||
}
|
||||
|
||||
return now()->addYear();
|
||||
|
||||
@@ -126,6 +126,36 @@ class GuestNotificationService
|
||||
return null;
|
||||
}
|
||||
|
||||
$photoId = Arr::get($payload, 'photo_id');
|
||||
if (is_numeric($photoId)) {
|
||||
$photoId = max(1, (int) $photoId);
|
||||
} else {
|
||||
$photoId = null;
|
||||
}
|
||||
|
||||
$photoIds = Arr::get($payload, 'photo_ids');
|
||||
if (is_array($photoIds)) {
|
||||
$photoIds = array_values(array_unique(array_filter(array_map(function ($value) {
|
||||
if (! is_numeric($value)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$int = (int) $value;
|
||||
|
||||
return $int > 0 ? $int : null;
|
||||
}, $photoIds))));
|
||||
$photoIds = array_slice($photoIds, 0, 10);
|
||||
} else {
|
||||
$photoIds = [];
|
||||
}
|
||||
|
||||
$count = Arr::get($payload, 'count');
|
||||
if (is_numeric($count)) {
|
||||
$count = max(1, min(9999, (int) $count));
|
||||
} else {
|
||||
$count = null;
|
||||
}
|
||||
|
||||
$cta = Arr::get($payload, 'cta');
|
||||
if (is_array($cta)) {
|
||||
$cta = [
|
||||
@@ -142,6 +172,9 @@ class GuestNotificationService
|
||||
|
||||
$clean = array_filter([
|
||||
'cta' => $cta,
|
||||
'photo_id' => $photoId,
|
||||
'photo_ids' => $photoIds,
|
||||
'count' => $count,
|
||||
]);
|
||||
|
||||
return $clean === [] ? null : $clean;
|
||||
|
||||
@@ -77,6 +77,8 @@ class HelpSyncService
|
||||
foreach ($articles->groupBy(fn ($article) => $article['audience'].'::'.$article['locale']) as $key => $group) {
|
||||
[$audience, $locale] = explode('::', $key);
|
||||
$path = sprintf('%s/%s/%s/articles.json', $compiledPath, $audience, $locale);
|
||||
$directory = sprintf('%s/%s/%s', $compiledPath, $audience, $locale);
|
||||
Storage::disk($disk)->makeDirectory($directory);
|
||||
Storage::disk($disk)->put($path, $group->values()->toJson(JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE));
|
||||
Cache::forget($this->cacheKey($audience, $locale));
|
||||
$written[$audience][$locale] = $group->count();
|
||||
|
||||
@@ -11,7 +11,7 @@ class PackageLimitEvaluator
|
||||
{
|
||||
public function __construct(private readonly TenantUsageService $tenantUsageService) {}
|
||||
|
||||
public function assessEventCreation(Tenant $tenant): ?array
|
||||
public function assessEventCreation(Tenant $tenant, ?string $includedPackageSlug = null): ?array
|
||||
{
|
||||
$hasEndcustomerPackage = $tenant->tenantPackages()
|
||||
->where('active', true)
|
||||
@@ -22,17 +22,66 @@ class PackageLimitEvaluator
|
||||
return null;
|
||||
}
|
||||
|
||||
if ($tenant->hasEventAllowance()) {
|
||||
if ($tenant->hasEventAllowanceFor($includedPackageSlug)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$package = $tenant->getActiveResellerPackage();
|
||||
$package = $tenant->getActiveResellerPackageFor($includedPackageSlug);
|
||||
|
||||
if (! $package) {
|
||||
if ($includedPackageSlug) {
|
||||
$hasAnyActive = $tenant->tenantPackages()
|
||||
->where('active', true)
|
||||
->where(function ($query) {
|
||||
$query->whereNull('expires_at')->orWhere('expires_at', '>', now());
|
||||
})
|
||||
->whereHas('package', fn ($query) => $query->withTrashed()->where('type', 'reseller'))
|
||||
->exists();
|
||||
|
||||
if ($hasAnyActive) {
|
||||
return [
|
||||
'code' => 'event_tier_unavailable',
|
||||
'title' => __('api.packages.event_tier_unavailable.title'),
|
||||
'message' => __('api.packages.event_tier_unavailable.message'),
|
||||
'status' => 402,
|
||||
'meta' => [
|
||||
'scope' => 'events',
|
||||
'requested_tier' => $includedPackageSlug,
|
||||
],
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
$latestResellerPackage = $tenant->tenantPackages()
|
||||
->with('package')
|
||||
->whereHas('package', fn ($query) => $query->withTrashed()->where('type', 'reseller'))
|
||||
->orderByDesc('purchased_at')
|
||||
->orderByDesc('id')
|
||||
->first();
|
||||
|
||||
if ($latestResellerPackage && $latestResellerPackage->package) {
|
||||
$limit = $latestResellerPackage->package->max_events_per_year ?? 0;
|
||||
|
||||
return [
|
||||
'code' => 'event_limit_exceeded',
|
||||
'title' => __('api.packages.event_limit_exceeded.title'),
|
||||
'message' => __('api.packages.event_limit_exceeded.message'),
|
||||
'status' => 402,
|
||||
'meta' => [
|
||||
'scope' => 'events',
|
||||
'used' => (int) $latestResellerPackage->used_events,
|
||||
'limit' => $limit,
|
||||
'remaining' => max(0, $limit - $latestResellerPackage->used_events),
|
||||
'tenant_package_id' => $latestResellerPackage->id,
|
||||
'package_id' => $latestResellerPackage->package_id,
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
return [
|
||||
'code' => 'event_limit_missing',
|
||||
'title' => 'No package assigned',
|
||||
'message' => 'Assign a package or addon to create events.',
|
||||
'title' => __('api.packages.event_limit_missing.title'),
|
||||
'message' => __('api.packages.event_limit_missing.message'),
|
||||
'status' => 402,
|
||||
'meta' => [
|
||||
'scope' => 'events',
|
||||
@@ -49,8 +98,8 @@ class PackageLimitEvaluator
|
||||
|
||||
return [
|
||||
'code' => 'event_limit_exceeded',
|
||||
'title' => 'Event quota reached',
|
||||
'message' => 'Your current package has no remaining event slots. Please upgrade or renew your subscription.',
|
||||
'title' => __('api.packages.event_limit_exceeded.title'),
|
||||
'message' => __('api.packages.event_limit_exceeded.message'),
|
||||
'status' => 402,
|
||||
'meta' => [
|
||||
'scope' => 'events',
|
||||
@@ -74,8 +123,8 @@ class PackageLimitEvaluator
|
||||
if (! $event) {
|
||||
return [
|
||||
'code' => 'event_not_found',
|
||||
'title' => 'Event not accessible',
|
||||
'message' => 'The selected event could not be found or belongs to another tenant.',
|
||||
'title' => __('api.packages.event_not_found.title'),
|
||||
'message' => __('api.packages.event_not_found.message'),
|
||||
'status' => 404,
|
||||
'meta' => [
|
||||
'scope' => 'photos',
|
||||
@@ -87,8 +136,8 @@ class PackageLimitEvaluator
|
||||
if (! $eventPackage || ! $eventPackage->package) {
|
||||
return [
|
||||
'code' => 'event_package_missing',
|
||||
'title' => 'Event package missing',
|
||||
'message' => 'No package is attached to this event. Assign a package to enable uploads.',
|
||||
'title' => __('api.packages.event_package_missing.title'),
|
||||
'message' => __('api.packages.event_package_missing.message'),
|
||||
'status' => 409,
|
||||
'meta' => [
|
||||
'scope' => 'photos',
|
||||
@@ -102,8 +151,8 @@ class PackageLimitEvaluator
|
||||
if ($maxPhotos !== null && $eventPackage->used_photos >= $maxPhotos) {
|
||||
return [
|
||||
'code' => 'photo_limit_exceeded',
|
||||
'title' => 'Photo upload limit reached',
|
||||
'message' => 'This event has reached its photo allowance. Upgrade the event package to accept more uploads.',
|
||||
'title' => __('api.packages.photo_limit_exceeded.title'),
|
||||
'message' => __('api.packages.photo_limit_exceeded.message'),
|
||||
'status' => 402,
|
||||
'meta' => [
|
||||
'scope' => 'photos',
|
||||
@@ -122,8 +171,8 @@ class PackageLimitEvaluator
|
||||
if ($eventPackage->used_photos >= $tenantPhotoLimit) {
|
||||
return [
|
||||
'code' => 'tenant_photo_limit_exceeded',
|
||||
'title' => 'Tenant photo limit reached',
|
||||
'message' => 'This tenant has reached its photo allowance for the event.',
|
||||
'title' => __('api.packages.tenant_photo_limit_exceeded.title'),
|
||||
'message' => __('api.packages.tenant_photo_limit_exceeded.message'),
|
||||
'status' => 402,
|
||||
'meta' => [
|
||||
'scope' => 'photos',
|
||||
@@ -146,8 +195,8 @@ class PackageLimitEvaluator
|
||||
if ($projectedBytes >= $storageLimitBytes) {
|
||||
return [
|
||||
'code' => 'tenant_storage_limit_exceeded',
|
||||
'title' => 'Tenant storage limit reached',
|
||||
'message' => 'This tenant has reached its storage allowance.',
|
||||
'title' => __('api.packages.tenant_storage_limit_exceeded.title'),
|
||||
'message' => __('api.packages.tenant_storage_limit_exceeded.message'),
|
||||
'status' => 402,
|
||||
'meta' => [
|
||||
'scope' => 'storage',
|
||||
|
||||
@@ -4,7 +4,6 @@ namespace App\Services\Packages;
|
||||
|
||||
use App\Events\Packages\TenantPackageEventLimitReached;
|
||||
use App\Events\Packages\TenantPackageEventThresholdReached;
|
||||
use App\Models\Tenant;
|
||||
use App\Models\TenantPackage;
|
||||
use Illuminate\Contracts\Events\Dispatcher;
|
||||
|
||||
@@ -63,6 +62,12 @@ class TenantUsageTracker
|
||||
}
|
||||
|
||||
$this->dispatcher->dispatch(new TenantPackageEventLimitReached($tenantPackage, $limit));
|
||||
|
||||
if ($tenantPackage->active) {
|
||||
$tenantPackage->forceFill([
|
||||
'active' => false,
|
||||
])->save();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -17,6 +17,11 @@ class PaddleDiscountService
|
||||
*/
|
||||
public function createDiscount(Coupon $coupon): array
|
||||
{
|
||||
$existing = $this->findExistingDiscount($coupon->code);
|
||||
if ($existing !== null) {
|
||||
return $existing;
|
||||
}
|
||||
|
||||
$payload = $this->buildDiscountPayload($coupon);
|
||||
|
||||
$response = $this->client->post('/discounts', $payload);
|
||||
@@ -82,6 +87,35 @@ class PaddleDiscountService
|
||||
return Arr::get($response, 'data', $response);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, mixed>|null
|
||||
*/
|
||||
protected function findExistingDiscount(?string $code): ?array
|
||||
{
|
||||
$normalized = Str::upper(trim((string) $code));
|
||||
if ($normalized === '') {
|
||||
return null;
|
||||
}
|
||||
|
||||
$response = $this->client->get('/discounts', [
|
||||
'code' => $normalized,
|
||||
'per_page' => 1,
|
||||
]);
|
||||
|
||||
$items = Arr::get($response, 'data', []);
|
||||
if (! is_array($items) || $items === []) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$match = Collection::make($items)->first(static function ($item) use ($normalized) {
|
||||
$codeValue = Str::upper((string) Arr::get($item, 'code', ''));
|
||||
|
||||
return $codeValue === $normalized ? $item : null;
|
||||
});
|
||||
|
||||
return is_array($match) ? $match : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
|
||||
80
app/Services/Photobooth/PhotoboothConnectCodeService.php
Normal file
80
app/Services/Photobooth/PhotoboothConnectCodeService.php
Normal file
@@ -0,0 +1,80 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services\Photobooth;
|
||||
|
||||
use App\Models\Event;
|
||||
use App\Models\PhotoboothConnectCode;
|
||||
|
||||
class PhotoboothConnectCodeService
|
||||
{
|
||||
public function create(Event $event, ?int $expiresInMinutes = null): array
|
||||
{
|
||||
$length = (int) config('photobooth.connect_code.length', 6);
|
||||
$length = max(4, min(8, $length));
|
||||
|
||||
$expiresInMinutes = $expiresInMinutes ?: (int) config('photobooth.connect_code.expires_minutes', 10);
|
||||
$expiresInMinutes = max(1, min(120, $expiresInMinutes));
|
||||
|
||||
$code = null;
|
||||
$hash = null;
|
||||
$max = (10 ** $length) - 1;
|
||||
|
||||
for ($attempts = 0; $attempts < 5; $attempts++) {
|
||||
$candidate = str_pad((string) random_int(0, $max), $length, '0', STR_PAD_LEFT);
|
||||
$candidateHash = hash('sha256', $candidate);
|
||||
|
||||
$exists = PhotoboothConnectCode::query()
|
||||
->where('code_hash', $candidateHash)
|
||||
->whereNull('redeemed_at')
|
||||
->where('expires_at', '>=', now())
|
||||
->exists();
|
||||
|
||||
if (! $exists) {
|
||||
$code = $candidate;
|
||||
$hash = $candidateHash;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (! $code || ! $hash) {
|
||||
$code = str_pad((string) random_int(0, $max), $length, '0', STR_PAD_LEFT);
|
||||
$hash = hash('sha256', $code);
|
||||
}
|
||||
|
||||
$expiresAt = now()->addMinutes($expiresInMinutes);
|
||||
|
||||
$record = PhotoboothConnectCode::query()->create([
|
||||
'event_id' => $event->getKey(),
|
||||
'code_hash' => $hash,
|
||||
'expires_at' => $expiresAt,
|
||||
]);
|
||||
|
||||
return [
|
||||
'code' => $code,
|
||||
'record' => $record,
|
||||
'expires_at' => $expiresAt,
|
||||
];
|
||||
}
|
||||
|
||||
public function redeem(string $code): ?PhotoboothConnectCode
|
||||
{
|
||||
$hash = hash('sha256', $code);
|
||||
|
||||
/** @var PhotoboothConnectCode|null $record */
|
||||
$record = PhotoboothConnectCode::query()
|
||||
->where('code_hash', $hash)
|
||||
->whereNull('redeemed_at')
|
||||
->where('expires_at', '>=', now())
|
||||
->first();
|
||||
|
||||
if (! $record) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$record->forceFill([
|
||||
'redeemed_at' => now(),
|
||||
])->save();
|
||||
|
||||
return $record;
|
||||
}
|
||||
}
|
||||
@@ -43,6 +43,7 @@ trait PresentsPackages
|
||||
'name' => $name,
|
||||
'slug' => $package->slug,
|
||||
'type' => $package->type,
|
||||
'included_package_slug' => $package->included_package_slug,
|
||||
'price' => $package->price,
|
||||
'paddle_product_id' => $package->paddle_product_id,
|
||||
'paddle_price_id' => $package->paddle_price_id,
|
||||
|
||||
@@ -24,15 +24,15 @@ class TenantAuth
|
||||
}
|
||||
|
||||
$user = $request->user();
|
||||
if ($user && in_array($user->role, ['tenant_admin', 'admin', 'super_admin', 'member'], true)) {
|
||||
if ($user->role !== 'super_admin' || (int) $user->tenant_id === (int) $tenantId) {
|
||||
if ($user && in_array($user->role, ['tenant_admin', 'admin', 'super_admin', 'superadmin', 'member'], true)) {
|
||||
if (! $user->isSuperAdmin() || (int) $user->tenant_id === (int) $tenantId) {
|
||||
return $user;
|
||||
}
|
||||
}
|
||||
|
||||
$user = User::query()
|
||||
->where('tenant_id', $tenantId)
|
||||
->whereIn('role', ['tenant_admin', 'admin', 'member'])
|
||||
->whereIn('role', ['tenant_admin', 'admin', 'super_admin', 'superadmin', 'member'])
|
||||
->orderByDesc('email_verified_at')
|
||||
->orderBy('id')
|
||||
->first();
|
||||
|
||||
174
app/Support/TenantMemberPermissions.php
Normal file
174
app/Support/TenantMemberPermissions.php
Normal file
@@ -0,0 +1,174 @@
|
||||
<?php
|
||||
|
||||
namespace App\Support;
|
||||
|
||||
use App\Models\Event;
|
||||
use App\Models\EventMember;
|
||||
use App\Models\User;
|
||||
use Illuminate\Http\Exceptions\HttpResponseException;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Str;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
|
||||
class TenantMemberPermissions
|
||||
{
|
||||
/**
|
||||
* @return array<int, string>
|
||||
*/
|
||||
public static function resolveEventPermissions(Request $request, Event $event): array
|
||||
{
|
||||
$user = $request->user();
|
||||
|
||||
if (! $user instanceof User) {
|
||||
return [];
|
||||
}
|
||||
|
||||
if (self::isTenantAdmin($user)) {
|
||||
return ['*'];
|
||||
}
|
||||
|
||||
$member = self::resolveEventMember($user, $event);
|
||||
|
||||
if (! $member) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return self::normalizePermissions($member->permissions);
|
||||
}
|
||||
|
||||
public static function ensureEventPermission(Request $request, Event $event, string $permission): void
|
||||
{
|
||||
if (self::allowsPermission(self::resolveEventPermissions($request, $event), $permission)) {
|
||||
return;
|
||||
}
|
||||
|
||||
throw new HttpResponseException(ApiError::response(
|
||||
'insufficient_permission',
|
||||
'Insufficient permission',
|
||||
'You are not allowed to perform this action.',
|
||||
Response::HTTP_FORBIDDEN,
|
||||
['required_permission' => $permission]
|
||||
));
|
||||
}
|
||||
|
||||
public static function allowsEventPermission(Request $request, Event $event, string $permission): bool
|
||||
{
|
||||
return self::allowsPermission(self::resolveEventPermissions($request, $event), $permission);
|
||||
}
|
||||
|
||||
public static function ensureTenantPermission(Request $request, string $permission): void
|
||||
{
|
||||
$user = $request->user();
|
||||
|
||||
if (! $user instanceof User) {
|
||||
throw new HttpResponseException(ApiError::response(
|
||||
'unauthenticated',
|
||||
'Unauthenticated',
|
||||
'You must be authenticated to perform this action.',
|
||||
Response::HTTP_UNAUTHORIZED
|
||||
));
|
||||
}
|
||||
|
||||
if (self::isTenantAdmin($user)) {
|
||||
return;
|
||||
}
|
||||
|
||||
$permissions = self::resolveTenantMemberPermissions($user);
|
||||
|
||||
if (self::allowsPermission($permissions, $permission)) {
|
||||
return;
|
||||
}
|
||||
|
||||
throw new HttpResponseException(ApiError::response(
|
||||
'insufficient_permission',
|
||||
'Insufficient permission',
|
||||
'You are not allowed to perform this action.',
|
||||
Response::HTTP_FORBIDDEN,
|
||||
['required_permission' => $permission]
|
||||
));
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<int, string>
|
||||
*/
|
||||
private static function resolveTenantMemberPermissions(User $user): array
|
||||
{
|
||||
if (! $user->tenant_id) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$memberships = EventMember::query()
|
||||
->where('tenant_id', $user->tenant_id)
|
||||
->whereIn('status', ['active', 'invited'])
|
||||
->where(function ($query) use ($user) {
|
||||
$query->where('user_id', $user->id)
|
||||
->orWhere('email', $user->email);
|
||||
})
|
||||
->get(['permissions']);
|
||||
|
||||
$permissions = [];
|
||||
|
||||
foreach ($memberships as $member) {
|
||||
$permissions = array_merge($permissions, self::normalizePermissions($member->permissions));
|
||||
}
|
||||
|
||||
return array_values(array_unique($permissions));
|
||||
}
|
||||
|
||||
private static function isTenantAdmin(User $user): bool
|
||||
{
|
||||
return in_array($user->role, ['tenant_admin', 'admin', 'super_admin', 'superadmin'], true);
|
||||
}
|
||||
|
||||
private static function resolveEventMember(User $user, Event $event): ?EventMember
|
||||
{
|
||||
return EventMember::query()
|
||||
->where('tenant_id', $event->tenant_id)
|
||||
->where('event_id', $event->id)
|
||||
->whereIn('status', ['active', 'invited'])
|
||||
->where(function ($query) use ($user) {
|
||||
$query->where('user_id', $user->id)
|
||||
->orWhere('email', $user->email);
|
||||
})
|
||||
->first();
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<int, string>|string|null $permissions
|
||||
* @return array<int, string>
|
||||
*/
|
||||
private static function normalizePermissions(mixed $permissions): array
|
||||
{
|
||||
if (is_array($permissions)) {
|
||||
return array_values(array_filter(array_map('strval', $permissions)));
|
||||
}
|
||||
|
||||
if (is_string($permissions) && $permissions !== '') {
|
||||
return array_values(array_filter(array_map('trim', explode(',', $permissions))));
|
||||
}
|
||||
|
||||
return [];
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<int, string> $permissions
|
||||
*/
|
||||
private static function allowsPermission(array $permissions, string $permission): bool
|
||||
{
|
||||
foreach ($permissions as $entry) {
|
||||
if ($entry === '*' || $entry === $permission) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (Str::endsWith($entry, ':*')) {
|
||||
$prefix = Str::beforeLast($entry, '*');
|
||||
|
||||
if (Str::startsWith($permission, $prefix)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
}
|
||||
90
clients/photobooth-uploader/PhotoboothUploader/App.axaml
Normal file
90
clients/photobooth-uploader/PhotoboothUploader/App.axaml
Normal file
@@ -0,0 +1,90 @@
|
||||
<Application xmlns="https://github.com/avaloniaui"
|
||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||
x:Class="PhotoboothUploader.App"
|
||||
RequestedThemeVariant="Default">
|
||||
<!-- "Default" ThemeVariant follows system theme variant. "Dark" or "Light" are other available options. -->
|
||||
|
||||
<Application.Styles>
|
||||
<FluentTheme />
|
||||
<Style>
|
||||
<Style.Resources>
|
||||
<Color x:Key="BrandRose">#FFB6C1</Color>
|
||||
<Color x:Key="BrandRoseStrong">#FF69B4</Color>
|
||||
<Color x:Key="BrandRoseSoft">#FFE5EC</Color>
|
||||
<Color x:Key="BrandGold">#FFD700</Color>
|
||||
<Color x:Key="BrandSky">#87CEEB</Color>
|
||||
<Color x:Key="BrandSkySoft">#E0F5FF</Color>
|
||||
<Color x:Key="BrandNavy">#0F4C75</Color>
|
||||
<Color x:Key="BrandSlate">#1F2937</Color>
|
||||
<Color x:Key="BrandCream">#FFF8F5</Color>
|
||||
|
||||
<SolidColorBrush x:Key="TextPrimaryBrush" Color="{DynamicResource BrandSlate}" />
|
||||
<SolidColorBrush x:Key="TextMutedBrush" Color="#6B7280" />
|
||||
<SolidColorBrush x:Key="CardBorderBrush" Color="{DynamicResource BrandRoseSoft}" />
|
||||
<SolidColorBrush x:Key="CardBackgroundBrush" Color="#FFFFFF" />
|
||||
<SolidColorBrush x:Key="AccentBackgroundBrush" Color="{DynamicResource BrandSkySoft}" />
|
||||
<SolidColorBrush x:Key="InputBorderBrush" Color="{DynamicResource BrandRoseSoft}" />
|
||||
<SolidColorBrush x:Key="InputBackgroundBrush" Color="#FFFFFF" />
|
||||
<SolidColorBrush x:Key="PrimaryButtonBrush" Color="{DynamicResource BrandRoseStrong}" />
|
||||
<SolidColorBrush x:Key="PrimaryButtonTextBrush" Color="#FFFFFF" />
|
||||
<SolidColorBrush x:Key="SecondaryButtonBrush" Color="{DynamicResource BrandSky}" />
|
||||
<SolidColorBrush x:Key="SecondaryButtonTextBrush" Color="{DynamicResource BrandNavy}" />
|
||||
|
||||
<LinearGradientBrush x:Key="WindowBackgroundBrush" StartPoint="0,0" EndPoint="1,1">
|
||||
<GradientStop Color="{DynamicResource BrandCream}" Offset="0" />
|
||||
<GradientStop Color="{DynamicResource BrandRoseSoft}" Offset="0.5" />
|
||||
<GradientStop Color="{DynamicResource BrandSkySoft}" Offset="1" />
|
||||
</LinearGradientBrush>
|
||||
</Style.Resources>
|
||||
</Style>
|
||||
|
||||
<Style Selector="Window">
|
||||
<Setter Property="Background" Value="{DynamicResource WindowBackgroundBrush}" />
|
||||
<Setter Property="FontFamily" Value="Inter" />
|
||||
<Setter Property="Foreground" Value="{DynamicResource TextPrimaryBrush}" />
|
||||
</Style>
|
||||
|
||||
<Style Selector="TextBlock.title">
|
||||
<Setter Property="FontSize" Value="20" />
|
||||
<Setter Property="FontWeight" Value="SemiBold" />
|
||||
</Style>
|
||||
|
||||
<Style Selector="TextBlock.subtitle">
|
||||
<Setter Property="FontSize" Value="12" />
|
||||
<Setter Property="Foreground" Value="{DynamicResource TextMutedBrush}" />
|
||||
</Style>
|
||||
|
||||
<Style Selector="Border.card">
|
||||
<Setter Property="Background" Value="{DynamicResource CardBackgroundBrush}" />
|
||||
<Setter Property="BorderBrush" Value="{DynamicResource CardBorderBrush}" />
|
||||
<Setter Property="BorderThickness" Value="1" />
|
||||
<Setter Property="CornerRadius" Value="12" />
|
||||
</Style>
|
||||
|
||||
<Style Selector="Border.card.accent">
|
||||
<Setter Property="Background" Value="{DynamicResource AccentBackgroundBrush}" />
|
||||
</Style>
|
||||
|
||||
<Style Selector="TextBox">
|
||||
<Setter Property="BorderBrush" Value="{DynamicResource InputBorderBrush}" />
|
||||
<Setter Property="Background" Value="{DynamicResource InputBackgroundBrush}" />
|
||||
<Setter Property="CornerRadius" Value="8" />
|
||||
<Setter Property="Padding" Value="10,8" />
|
||||
</Style>
|
||||
|
||||
<Style Selector="Button">
|
||||
<Setter Property="CornerRadius" Value="8" />
|
||||
<Setter Property="Padding" Value="12,8" />
|
||||
</Style>
|
||||
|
||||
<Style Selector="Button.primary">
|
||||
<Setter Property="Background" Value="{DynamicResource PrimaryButtonBrush}" />
|
||||
<Setter Property="Foreground" Value="{DynamicResource PrimaryButtonTextBrush}" />
|
||||
</Style>
|
||||
|
||||
<Style Selector="Button.secondary">
|
||||
<Setter Property="Background" Value="{DynamicResource SecondaryButtonBrush}" />
|
||||
<Setter Property="Foreground" Value="{DynamicResource SecondaryButtonTextBrush}" />
|
||||
</Style>
|
||||
</Application.Styles>
|
||||
</Application>
|
||||
23
clients/photobooth-uploader/PhotoboothUploader/App.axaml.cs
Normal file
23
clients/photobooth-uploader/PhotoboothUploader/App.axaml.cs
Normal file
@@ -0,0 +1,23 @@
|
||||
using Avalonia;
|
||||
using Avalonia.Controls.ApplicationLifetimes;
|
||||
using Avalonia.Markup.Xaml;
|
||||
|
||||
namespace PhotoboothUploader;
|
||||
|
||||
public partial class App : Application
|
||||
{
|
||||
public override void Initialize()
|
||||
{
|
||||
AvaloniaXamlLoader.Load(this);
|
||||
}
|
||||
|
||||
public override void OnFrameworkInitializationCompleted()
|
||||
{
|
||||
if (ApplicationLifetime is IClassicDesktopStyleApplicationLifetime desktop)
|
||||
{
|
||||
desktop.MainWindow = new MainWindow();
|
||||
}
|
||||
|
||||
base.OnFrameworkInitializationCompleted();
|
||||
}
|
||||
}
|
||||
BIN
clients/photobooth-uploader/PhotoboothUploader/Assets/app.ico
Normal file
BIN
clients/photobooth-uploader/PhotoboothUploader/Assets/app.ico
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 4.1 KiB |
BIN
clients/photobooth-uploader/PhotoboothUploader/Assets/logo.png
Normal file
BIN
clients/photobooth-uploader/PhotoboothUploader/Assets/logo.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 31 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 67 B |
160
clients/photobooth-uploader/PhotoboothUploader/MainWindow.axaml
Normal file
160
clients/photobooth-uploader/PhotoboothUploader/MainWindow.axaml
Normal file
@@ -0,0 +1,160 @@
|
||||
<Window xmlns="https://github.com/avaloniaui"
|
||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
|
||||
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
|
||||
mc:Ignorable="d" d:DesignWidth="520" d:DesignHeight="360"
|
||||
x:Class="PhotoboothUploader.MainWindow"
|
||||
Width="560" Height="420"
|
||||
MinWidth="520" MinHeight="400"
|
||||
Title="Die Fotospiel.App - Photobooth Uploader">
|
||||
<Grid Margin="24,32,24,24" RowDefinitions="Auto,*">
|
||||
<StackPanel Grid.Row="0" Orientation="Horizontal" Spacing="12" VerticalAlignment="Center">
|
||||
<Border Width="40" Height="40" Classes="card accent" VerticalAlignment="Center" HorizontalAlignment="Left">
|
||||
<Image Source="avares://PhotoboothUploader/Assets/logo.png" Width="28" Height="28" HorizontalAlignment="Center" VerticalAlignment="Center" />
|
||||
</Border>
|
||||
<StackPanel Spacing="2">
|
||||
<TextBlock x:Name="TitleText"
|
||||
Text="Die Fotospiel.App - Photobooth Uploader"
|
||||
Classes="title"
|
||||
PointerPressed="TitleText_PointerPressed" />
|
||||
<TextBlock Text="Sicherer Upload der Fotobox-Fotos ins Event." Classes="subtitle" />
|
||||
</StackPanel>
|
||||
</StackPanel>
|
||||
|
||||
<Grid Grid.Row="1" ColumnDefinitions="*,16,*">
|
||||
<StackPanel Grid.Column="0" Spacing="16" MaxWidth="420">
|
||||
|
||||
<Border Padding="14" Classes="card">
|
||||
<StackPanel Spacing="6">
|
||||
<TextBlock Text="Schritte" FontWeight="SemiBold" />
|
||||
<TextBlock x:Name="StepCodeText" Text="1. Code eingeben" />
|
||||
<TextBlock x:Name="StepFolderText" Text="2. Upload-Ordner wählen" />
|
||||
<TextBlock x:Name="StepReadyText" Text="3. Upload läuft" />
|
||||
</StackPanel>
|
||||
</Border>
|
||||
|
||||
<Border Padding="14" Classes="card">
|
||||
<StackPanel Spacing="10">
|
||||
<TextBlock Text="Verbindungscode" FontWeight="SemiBold" />
|
||||
<TextBlock Text="Gib den 6-stelligen Verbindungscode ein." TextWrapping="Wrap" Classes="subtitle" />
|
||||
<TextBox x:Name="CodeBox" MaxLength="6" Watermark="123456" TextChanged="CodeBox_TextChanged" />
|
||||
<StackPanel Orientation="Horizontal" Spacing="8">
|
||||
<Button x:Name="ConnectButton" Content="Verbinden" Click="ConnectButton_Click" Classes="primary" />
|
||||
<Button x:Name="ReconnectButton" Content="Erneut verbinden" Click="ReconnectButton_Click" IsEnabled="False" Classes="secondary" />
|
||||
</StackPanel>
|
||||
</StackPanel>
|
||||
</Border>
|
||||
|
||||
<Border Padding="14" Classes="card">
|
||||
<StackPanel Spacing="8">
|
||||
<TextBlock Text="Upload-Ordner" FontWeight="SemiBold" />
|
||||
<TextBlock x:Name="FolderText" Text="Noch nicht ausgewählt." TextWrapping="Wrap" Classes="subtitle" />
|
||||
<StackPanel Orientation="Horizontal" Spacing="8">
|
||||
<Button x:Name="DslrBoothPresetButton" Content="DSLrBooth" Click="DslrBoothPresetButton_Click" Classes="secondary" IsVisible="False" />
|
||||
<Button x:Name="SparkboothPresetButton" Content="Sparkbooth" Click="SparkboothPresetButton_Click" Classes="secondary" IsVisible="False" />
|
||||
</StackPanel>
|
||||
<StackPanel Orientation="Horizontal" Spacing="8">
|
||||
<Button x:Name="PickFolderButton" Content="Ordner auswählen" Click="PickFolderButton_Click" IsEnabled="False" Classes="primary" />
|
||||
<Button x:Name="TestUploadButton" Content="Test-Upload senden" Click="TestUploadButton_Click" IsEnabled="False" Classes="secondary" />
|
||||
</StackPanel>
|
||||
</StackPanel>
|
||||
</Border>
|
||||
|
||||
<ToggleSwitch x:Name="QuietToggle" Content="Ruhiger Modus (nur Fehler anzeigen)" />
|
||||
|
||||
<Border x:Name="AdvancedPanel" Padding="12" Classes="card accent" IsVisible="False">
|
||||
<StackPanel Spacing="6">
|
||||
<TextBlock Text="Erweiterte Einstellungen" FontWeight="SemiBold" />
|
||||
<ToggleSwitch x:Name="SettingsUnlockToggle" Content="Einstellungen entsperren" Checked="SettingsUnlockToggle_Changed" Unchecked="SettingsUnlockToggle_Changed" />
|
||||
<TextBlock Text="Profile" />
|
||||
<ComboBox x:Name="ProfilesBox" />
|
||||
<StackPanel Orientation="Horizontal" Spacing="8">
|
||||
<Button x:Name="LoadProfileButton" Content="Profil laden" Click="LoadProfileButton_Click" Classes="secondary" />
|
||||
<Button x:Name="SaveProfileButton" Content="Profil speichern" Click="SaveProfileButton_Click" Classes="secondary" />
|
||||
</StackPanel>
|
||||
<TextBlock Text="Basis-URL" />
|
||||
<TextBox x:Name="BaseUrlBox" Watermark="https://fotospiel.app" />
|
||||
<TextBlock Text="Max. parallele Uploads" />
|
||||
<TextBox x:Name="MaxUploadsBox" Watermark="2" />
|
||||
<TextBlock Text="Upload-Tempo" />
|
||||
<ComboBox x:Name="UploadTempoBox" SelectedIndex="1">
|
||||
<ComboBoxItem Content="Schnell (ohne Pause)" />
|
||||
<ComboBoxItem Content="Normal" />
|
||||
<ComboBoxItem Content="Sanft (schont Netzwerk)" />
|
||||
</ComboBox>
|
||||
<TextBlock Text="Nur diese Dateien (optional)" />
|
||||
<TextBox x:Name="IncludePatternsBox" Watermark="*.jpg;*.jpeg;*.png" />
|
||||
<TextBlock Text="Dateien ausschliessen (optional)" />
|
||||
<TextBox x:Name="ExcludePatternsBox" Watermark="*_preview*;*.tmp" />
|
||||
<TextBlock Text="Antwort-Format (optional)" />
|
||||
<ComboBox x:Name="ResponseFormatBox" SelectedIndex="0">
|
||||
<ComboBoxItem Content="Auto" />
|
||||
<ComboBoxItem Content="JSON" />
|
||||
<ComboBoxItem Content="XML" />
|
||||
</ComboBox>
|
||||
<TextBlock Text="Manuelle Zugangsdaten (optional)" FontWeight="SemiBold" Margin="0,8,0,0" />
|
||||
<TextBlock Text="Diese Felder ueberschreiben den Verbindungscode." Classes="subtitle" TextWrapping="Wrap" />
|
||||
<TextBlock Text="Upload-URL" />
|
||||
<TextBox x:Name="ManualUploadUrlBox" Watermark="https://fotospiel.app/api/v1/photobooth/upload" />
|
||||
<TextBlock Text="Benutzername" />
|
||||
<TextBox x:Name="ManualUsernameBox" />
|
||||
<TextBlock Text="Passwort" />
|
||||
<TextBox x:Name="ManualPasswordBox" PasswordChar="•" />
|
||||
<Button x:Name="TestConnectionButton" Content="Verbindung testen" Click="TestConnectionButton_Click" Classes="secondary" />
|
||||
<Button x:Name="SaveAdvancedButton" Content="Speichern" Click="SaveAdvancedButton_Click" Classes="primary" />
|
||||
</StackPanel>
|
||||
</Border>
|
||||
</StackPanel>
|
||||
|
||||
<StackPanel Grid.Column="2" Spacing="16" MaxWidth="380" Margin="0,6,0,0">
|
||||
<Border Padding="14" Classes="card accent">
|
||||
<StackPanel Spacing="6">
|
||||
<TextBlock Text="Status" FontWeight="SemiBold" />
|
||||
<TextBlock x:Name="StatusText" Text="Nicht verbunden." TextWrapping="Wrap" />
|
||||
<TextBlock x:Name="LastUploadText" Text="Letzter Upload: —" />
|
||||
<TextBlock x:Name="QueueStatusText" Text="Warteschlange: 0 · Läuft: 0 · Fehlgeschlagen: 0" />
|
||||
<TextBlock x:Name="LiveStatusText" Text="Live: —" />
|
||||
</StackPanel>
|
||||
</Border>
|
||||
|
||||
<Border Padding="14" Classes="card">
|
||||
<StackPanel Spacing="6">
|
||||
<TextBlock Text="Details" FontWeight="SemiBold" />
|
||||
<TextBlock x:Name="EventNameText" Text="Event: —" TextWrapping="Wrap" />
|
||||
<TextBlock x:Name="BaseUrlText" Text="Basis-URL: —" TextWrapping="Wrap" />
|
||||
<TextBlock x:Name="VersionText" Text="App-Version: —" />
|
||||
<TextBlock x:Name="ConnectExpiryText" Text="Verbindungscode: —" TextWrapping="Wrap" />
|
||||
<TextBlock x:Name="FolderHealthText" Text="Ordner: —" TextWrapping="Wrap" />
|
||||
<TextBlock x:Name="DiskFreeText" Text="Freier Speicher: —" TextWrapping="Wrap" />
|
||||
<TextBlock x:Name="LastSeenText" Text="Letzte Datei: —" TextWrapping="Wrap" />
|
||||
<TextBlock x:Name="LastErrorText" Text="Letzter Fehler: —" TextWrapping="Wrap" />
|
||||
<Button x:Name="LogCopyButton" Content="Log kopieren" Click="LogCopyButton_Click" Classes="secondary" />
|
||||
</StackPanel>
|
||||
</Border>
|
||||
|
||||
<Border Padding="14" Classes="card">
|
||||
<StackPanel Spacing="8">
|
||||
<TextBlock Text="Letzte Uploads" FontWeight="SemiBold" />
|
||||
<ItemsControl x:Name="RecentUploadsList" ItemsSource="{Binding RecentUploads}">
|
||||
<ItemsControl.ItemTemplate>
|
||||
<DataTemplate>
|
||||
<Border Background="#14FFFFFF" Padding="10" CornerRadius="8" Margin="0,0,0,8">
|
||||
<Grid ColumnDefinitions="*,Auto" RowDefinitions="Auto,Auto">
|
||||
<TextBlock Grid.Column="0" Grid.Row="0" Text="{Binding FileName}" />
|
||||
<TextBlock Grid.Column="1" Grid.Row="0" Text="{Binding StatusLabel}" />
|
||||
<TextBlock Grid.Column="0" Grid.Row="1" Text="{Binding UpdatedLabel}" Opacity="0.7" FontSize="11" />
|
||||
</Grid>
|
||||
</Border>
|
||||
</DataTemplate>
|
||||
</ItemsControl.ItemTemplate>
|
||||
</ItemsControl>
|
||||
<StackPanel Orientation="Horizontal" Spacing="8">
|
||||
<Button x:Name="RetryFailedButton" Content="Fehlgeschlagene erneut senden" Click="RetryFailedButton_Click" IsEnabled="False" Classes="secondary" />
|
||||
<Button x:Name="ClearFailedButton" Content="Fehlerliste leeren" Click="ClearFailedButton_Click" IsEnabled="False" Classes="secondary" />
|
||||
</StackPanel>
|
||||
</StackPanel>
|
||||
</Border>
|
||||
</StackPanel>
|
||||
</Grid>
|
||||
</Grid>
|
||||
</Window>
|
||||
1350
clients/photobooth-uploader/PhotoboothUploader/MainWindow.axaml.cs
Normal file
1350
clients/photobooth-uploader/PhotoboothUploader/MainWindow.axaml.cs
Normal file
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,33 @@
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace PhotoboothUploader.Models;
|
||||
|
||||
public sealed class PhotoboothConnectResponse
|
||||
{
|
||||
[JsonPropertyName("data")]
|
||||
public PhotoboothConnectPayload? Data { get; set; }
|
||||
|
||||
[JsonPropertyName("message")]
|
||||
public string? Message { get; set; }
|
||||
}
|
||||
|
||||
public sealed class PhotoboothConnectPayload
|
||||
{
|
||||
[JsonPropertyName("event_name")]
|
||||
public string? EventName { get; set; }
|
||||
|
||||
[JsonPropertyName("upload_url")]
|
||||
public string? UploadUrl { get; set; }
|
||||
|
||||
[JsonPropertyName("username")]
|
||||
public string? Username { get; set; }
|
||||
|
||||
[JsonPropertyName("password")]
|
||||
public string? Password { get; set; }
|
||||
|
||||
[JsonPropertyName("expires_at")]
|
||||
public string? ExpiresAt { get; set; }
|
||||
|
||||
[JsonPropertyName("response_format")]
|
||||
public string? ResponseFormat { get; set; }
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
using System;
|
||||
|
||||
namespace PhotoboothUploader.Models;
|
||||
|
||||
public sealed class PhotoboothProfile
|
||||
{
|
||||
public string? Label { get; set; }
|
||||
public string? EventName { get; set; }
|
||||
public string? BaseUrl { get; set; }
|
||||
public string? UploadUrl { get; set; }
|
||||
public string? Username { get; set; }
|
||||
public string? Password { get; set; }
|
||||
public string? ResponseFormat { get; set; }
|
||||
public string? WatchFolder { get; set; }
|
||||
public string? IncludePatterns { get; set; }
|
||||
public string? ExcludePatterns { get; set; }
|
||||
public int MaxConcurrentUploads { get; set; } = 2;
|
||||
public int UploadDelayMs { get; set; } = 500;
|
||||
|
||||
public string DisplayName
|
||||
=> !string.IsNullOrWhiteSpace(Label)
|
||||
? Label
|
||||
: !string.IsNullOrWhiteSpace(EventName)
|
||||
? EventName
|
||||
: UploadUrl ?? BaseUrl ?? "Profil";
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
|
||||
namespace PhotoboothUploader.Models;
|
||||
|
||||
public sealed class PhotoboothSettings
|
||||
{
|
||||
public string? BaseUrl { get; set; }
|
||||
public string? EventName { get; set; }
|
||||
public string? UploadUrl { get; set; }
|
||||
public string? Username { get; set; }
|
||||
public string? Password { get; set; }
|
||||
public string? ResponseFormat { get; set; }
|
||||
public string? WatchFolder { get; set; }
|
||||
public string? IncludePatterns { get; set; }
|
||||
public string? ExcludePatterns { get; set; }
|
||||
public List<string> PendingUploads { get; set; } = new();
|
||||
public Dictionary<string, string> UploadedFiles { get; set; } = new(StringComparer.OrdinalIgnoreCase);
|
||||
public List<PhotoboothProfile> Profiles { get; set; } = new();
|
||||
public string? ConnectExpiresAt { get; set; }
|
||||
public string? LastSeenFile { get; set; }
|
||||
public string? LastSeenAt { get; set; }
|
||||
public string? LastError { get; set; }
|
||||
public string? LastErrorAt { get; set; }
|
||||
public int MaxConcurrentUploads { get; set; } = 2;
|
||||
public int UploadDelayMs { get; set; } = 500;
|
||||
public double WindowWidth { get; set; }
|
||||
public double WindowHeight { get; set; }
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
using System;
|
||||
using System.ComponentModel;
|
||||
using System.Runtime.CompilerServices;
|
||||
|
||||
namespace PhotoboothUploader.Models;
|
||||
|
||||
public enum UploadStatus
|
||||
{
|
||||
Queued,
|
||||
Uploading,
|
||||
Success,
|
||||
Failed,
|
||||
}
|
||||
|
||||
public sealed class UploadItem : INotifyPropertyChanged
|
||||
{
|
||||
private UploadStatus _status;
|
||||
private DateTimeOffset _updatedAt;
|
||||
|
||||
public UploadItem(string path)
|
||||
{
|
||||
Path = path;
|
||||
FileName = System.IO.Path.GetFileName(path);
|
||||
UpdatedAt = DateTimeOffset.Now;
|
||||
Status = UploadStatus.Queued;
|
||||
}
|
||||
|
||||
public string Path { get; }
|
||||
|
||||
public string FileName { get; }
|
||||
|
||||
public UploadStatus Status
|
||||
{
|
||||
get => _status;
|
||||
set
|
||||
{
|
||||
if (_status != value)
|
||||
{
|
||||
_status = value;
|
||||
UpdatedAt = DateTimeOffset.Now;
|
||||
OnPropertyChanged();
|
||||
OnPropertyChanged(nameof(StatusLabel));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public DateTimeOffset UpdatedAt
|
||||
{
|
||||
get => _updatedAt;
|
||||
private set
|
||||
{
|
||||
_updatedAt = value;
|
||||
OnPropertyChanged();
|
||||
OnPropertyChanged(nameof(UpdatedLabel));
|
||||
}
|
||||
}
|
||||
|
||||
public string StatusLabel => Status switch
|
||||
{
|
||||
UploadStatus.Uploading => "Upload läuft",
|
||||
UploadStatus.Success => "Fertig",
|
||||
UploadStatus.Failed => "Fehlgeschlagen",
|
||||
_ => "Wartet",
|
||||
};
|
||||
|
||||
public string UpdatedLabel => $"{UpdatedAt:HH:mm}";
|
||||
|
||||
public event PropertyChangedEventHandler? PropertyChanged;
|
||||
|
||||
private void OnPropertyChanged([CallerMemberName] string? propertyName = null)
|
||||
{
|
||||
PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(propertyName));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
<PropertyGroup>
|
||||
<OutputType>WinExe</OutputType>
|
||||
<TargetFramework>net9.0</TargetFramework>
|
||||
<Nullable>enable</Nullable>
|
||||
<ApplicationManifest>app.manifest</ApplicationManifest>
|
||||
<ApplicationIcon>Assets\app.ico</ApplicationIcon>
|
||||
<AvaloniaUseCompiledBindingsByDefault>false</AvaloniaUseCompiledBindingsByDefault>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Avalonia" Version="11.3.10" />
|
||||
<PackageReference Include="Avalonia.Desktop" Version="11.3.10" />
|
||||
<PackageReference Include="Avalonia.Themes.Fluent" Version="11.3.10" />
|
||||
<PackageReference Include="Avalonia.Fonts.Inter" Version="11.3.10" />
|
||||
<!--Condition below is needed to remove Avalonia.Diagnostics package from build output in Release configuration.-->
|
||||
<PackageReference Include="Avalonia.Diagnostics" Version="11.3.10">
|
||||
<IncludeAssets Condition="'$(Configuration)' != 'Debug'">None</IncludeAssets>
|
||||
<PrivateAssets Condition="'$(Configuration)' != 'Debug'">All</PrivateAssets>
|
||||
</PackageReference>
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<None Include="Assets\app.ico" />
|
||||
<AvaloniaResource Include="Assets\logo.png" />
|
||||
<AvaloniaResource Include="Assets\sample-upload.png" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
21
clients/photobooth-uploader/PhotoboothUploader/Program.cs
Normal file
21
clients/photobooth-uploader/PhotoboothUploader/Program.cs
Normal file
@@ -0,0 +1,21 @@
|
||||
using Avalonia;
|
||||
using System;
|
||||
|
||||
namespace PhotoboothUploader;
|
||||
|
||||
class Program
|
||||
{
|
||||
// Initialization code. Don't use any Avalonia, third-party APIs or any
|
||||
// SynchronizationContext-reliant code before AppMain is called: things aren't initialized
|
||||
// yet and stuff might break.
|
||||
[STAThread]
|
||||
public static void Main(string[] args) => BuildAvaloniaApp()
|
||||
.StartWithClassicDesktopLifetime(args);
|
||||
|
||||
// Avalonia configuration, don't remove; also used by visual designer.
|
||||
public static AppBuilder BuildAvaloniaApp()
|
||||
=> AppBuilder.Configure<App>()
|
||||
.UsePlatformDetect()
|
||||
.WithInterFont()
|
||||
.LogToTrace();
|
||||
}
|
||||
@@ -0,0 +1,122 @@
|
||||
using System;
|
||||
using System.Net;
|
||||
using System.Net.Http;
|
||||
using System.Net.Http.Headers;
|
||||
using System.Net.Http.Json;
|
||||
using System.Text.Json;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using PhotoboothUploader.Models;
|
||||
|
||||
namespace PhotoboothUploader.Services;
|
||||
|
||||
public sealed class PhotoboothConnectClient
|
||||
{
|
||||
private const int MaxRetries = 2;
|
||||
private static readonly TimeSpan DefaultTimeout = TimeSpan.FromSeconds(10);
|
||||
private readonly HttpClient _httpClient;
|
||||
private readonly JsonSerializerOptions _jsonOptions = new()
|
||||
{
|
||||
PropertyNameCaseInsensitive = true,
|
||||
};
|
||||
|
||||
public PhotoboothConnectClient(string baseUrl, string userAgent)
|
||||
{
|
||||
_httpClient = new HttpClient
|
||||
{
|
||||
BaseAddress = new Uri(baseUrl),
|
||||
Timeout = DefaultTimeout,
|
||||
};
|
||||
|
||||
_httpClient.DefaultRequestHeaders.Accept.Add(new MediaTypeWithQualityHeaderValue("application/json"));
|
||||
_httpClient.DefaultRequestHeaders.UserAgent.ParseAdd(userAgent);
|
||||
}
|
||||
|
||||
public async Task<PhotoboothConnectResponse> RedeemAsync(string code, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var request = new { code };
|
||||
|
||||
for (var attempt = 0; attempt <= MaxRetries; attempt++)
|
||||
{
|
||||
try
|
||||
{
|
||||
using var response = await _httpClient.PostAsJsonAsync("/api/v1/photobooth/connect", request, cancellationToken);
|
||||
var payload = await ReadPayloadAsync(response, cancellationToken);
|
||||
|
||||
if (response.IsSuccessStatusCode)
|
||||
{
|
||||
return payload ?? Fail(response.ReasonPhrase ?? "Verbindung fehlgeschlagen.");
|
||||
}
|
||||
|
||||
if (response.StatusCode is HttpStatusCode.UnprocessableEntity or HttpStatusCode.Conflict or HttpStatusCode.Unauthorized)
|
||||
{
|
||||
return payload ?? Fail(response.ReasonPhrase ?? "Verbindung fehlgeschlagen.");
|
||||
}
|
||||
|
||||
if (attempt < MaxRetries && IsTransientStatus(response.StatusCode))
|
||||
{
|
||||
await Task.Delay(GetRetryDelay(attempt), cancellationToken);
|
||||
continue;
|
||||
}
|
||||
|
||||
return payload ?? Fail(response.ReasonPhrase ?? "Verbindung fehlgeschlagen.");
|
||||
}
|
||||
catch (TaskCanceledException) when (!cancellationToken.IsCancellationRequested)
|
||||
{
|
||||
if (attempt < MaxRetries)
|
||||
{
|
||||
await Task.Delay(GetRetryDelay(attempt), cancellationToken);
|
||||
continue;
|
||||
}
|
||||
|
||||
return Fail("Zeitüberschreitung bei der Verbindung.");
|
||||
}
|
||||
catch (HttpRequestException)
|
||||
{
|
||||
if (attempt < MaxRetries)
|
||||
{
|
||||
await Task.Delay(GetRetryDelay(attempt), cancellationToken);
|
||||
continue;
|
||||
}
|
||||
|
||||
return Fail("Netzwerkfehler. Bitte Verbindung prüfen.");
|
||||
}
|
||||
catch (JsonException)
|
||||
{
|
||||
return Fail("Serverantwort konnte nicht gelesen werden.");
|
||||
}
|
||||
}
|
||||
|
||||
return Fail("Verbindung fehlgeschlagen.");
|
||||
}
|
||||
|
||||
private async Task<PhotoboothConnectResponse?> ReadPayloadAsync(HttpResponseMessage response, CancellationToken cancellationToken)
|
||||
{
|
||||
if (response.Content.Headers.ContentLength == 0)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
return await response.Content.ReadFromJsonAsync<PhotoboothConnectResponse>(_jsonOptions, cancellationToken);
|
||||
}
|
||||
|
||||
private static bool IsTransientStatus(HttpStatusCode statusCode)
|
||||
{
|
||||
return statusCode is HttpStatusCode.RequestTimeout or HttpStatusCode.TooManyRequests
|
||||
or HttpStatusCode.BadGateway or HttpStatusCode.ServiceUnavailable or HttpStatusCode.GatewayTimeout
|
||||
or HttpStatusCode.InternalServerError;
|
||||
}
|
||||
|
||||
private static TimeSpan GetRetryDelay(int attempt)
|
||||
{
|
||||
return TimeSpan.FromMilliseconds(500 * (attempt + 1));
|
||||
}
|
||||
|
||||
private static PhotoboothConnectResponse Fail(string message)
|
||||
{
|
||||
return new PhotoboothConnectResponse
|
||||
{
|
||||
Message = message,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
using System;
|
||||
using System.IO;
|
||||
using System.Text.Json;
|
||||
using PhotoboothUploader.Models;
|
||||
|
||||
namespace PhotoboothUploader.Services;
|
||||
|
||||
public sealed class SettingsStore
|
||||
{
|
||||
private readonly JsonSerializerOptions _options = new()
|
||||
{
|
||||
PropertyNameCaseInsensitive = true,
|
||||
WriteIndented = true,
|
||||
};
|
||||
|
||||
public string SettingsPath { get; }
|
||||
public string LogPath { get; }
|
||||
|
||||
public SettingsStore()
|
||||
{
|
||||
var basePath = Path.Combine(
|
||||
Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData),
|
||||
"Fotospiel",
|
||||
"PhotoboothUploader");
|
||||
|
||||
Directory.CreateDirectory(basePath);
|
||||
SettingsPath = Path.Combine(basePath, "settings.json");
|
||||
LogPath = Path.Combine(basePath, "uploader.log");
|
||||
}
|
||||
|
||||
public PhotoboothSettings Load()
|
||||
{
|
||||
if (!File.Exists(SettingsPath))
|
||||
{
|
||||
return new PhotoboothSettings();
|
||||
}
|
||||
|
||||
var json = File.ReadAllText(SettingsPath);
|
||||
return JsonSerializer.Deserialize<PhotoboothSettings>(json, _options) ?? new PhotoboothSettings();
|
||||
}
|
||||
|
||||
public void Save(PhotoboothSettings settings)
|
||||
{
|
||||
var json = JsonSerializer.Serialize(settings, _options);
|
||||
File.WriteAllText(SettingsPath, json);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,297 @@
|
||||
using System;
|
||||
using System.Collections.Concurrent;
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using System.Net.Http;
|
||||
using System.Net.Http.Headers;
|
||||
using System.Threading;
|
||||
using System.Threading.Channels;
|
||||
using System.Threading.Tasks;
|
||||
using PhotoboothUploader.Models;
|
||||
|
||||
namespace PhotoboothUploader.Services;
|
||||
|
||||
public sealed class UploadService
|
||||
{
|
||||
private static readonly TimeSpan DefaultTimeout = TimeSpan.FromSeconds(20);
|
||||
private static readonly TimeSpan RetryBaseDelay = TimeSpan.FromSeconds(2);
|
||||
private const int MaxRetries = 2;
|
||||
private readonly Channel<string> _queue = Channel.CreateUnbounded<string>();
|
||||
private readonly ConcurrentDictionary<string, byte> _pending = new(StringComparer.OrdinalIgnoreCase);
|
||||
private string _userAgent = "FotospielPhotoboothUploader";
|
||||
private CancellationTokenSource? _cts;
|
||||
private readonly List<Task> _workers = new();
|
||||
|
||||
public void Configure(string userAgent)
|
||||
{
|
||||
if (!string.IsNullOrWhiteSpace(userAgent))
|
||||
{
|
||||
_userAgent = userAgent;
|
||||
}
|
||||
}
|
||||
|
||||
public void Start(
|
||||
PhotoboothSettings settings,
|
||||
Action<string> onQueued,
|
||||
Action<string> onUploading,
|
||||
Action<string> onSuccess,
|
||||
Action<string, string> onFailure)
|
||||
{
|
||||
Stop();
|
||||
|
||||
_cts = new CancellationTokenSource();
|
||||
var workerCount = GetWorkerCount(settings);
|
||||
for (var i = 0; i < workerCount; i++)
|
||||
{
|
||||
_workers.Add(Task.Run(() => WorkerAsync(settings, onQueued, onUploading, onSuccess, onFailure, _cts.Token)));
|
||||
}
|
||||
}
|
||||
|
||||
public void Stop()
|
||||
{
|
||||
_cts?.Cancel();
|
||||
_cts = null;
|
||||
_pending.Clear();
|
||||
_workers.Clear();
|
||||
}
|
||||
|
||||
public void Enqueue(string path, Action<string> onQueued)
|
||||
{
|
||||
if (!_pending.TryAdd(path, 0))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
_queue.Writer.TryWrite(path);
|
||||
onQueued(path);
|
||||
}
|
||||
|
||||
private async Task WorkerAsync(
|
||||
PhotoboothSettings settings,
|
||||
Action<string> onQueued,
|
||||
Action<string> onUploading,
|
||||
Action<string> onSuccess,
|
||||
Action<string, string> onFailure,
|
||||
CancellationToken token)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(settings.UploadUrl))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
using var client = new HttpClient();
|
||||
client.Timeout = DefaultTimeout;
|
||||
client.DefaultRequestHeaders.UserAgent.ParseAdd(_userAgent);
|
||||
client.DefaultRequestHeaders.Accept.Add(new MediaTypeWithQualityHeaderValue("application/json"));
|
||||
|
||||
while (await _queue.Reader.WaitToReadAsync(token))
|
||||
{
|
||||
while (_queue.Reader.TryRead(out var path))
|
||||
{
|
||||
try
|
||||
{
|
||||
onUploading(path);
|
||||
var error = await UploadWithRetryAsync(client, settings, path, token);
|
||||
if (error is null)
|
||||
{
|
||||
onSuccess(path);
|
||||
}
|
||||
else
|
||||
{
|
||||
onFailure(path, error);
|
||||
}
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
return;
|
||||
}
|
||||
finally
|
||||
{
|
||||
_pending.TryRemove(path, out _);
|
||||
if (settings.UploadDelayMs > 0)
|
||||
{
|
||||
await Task.Delay(settings.UploadDelayMs, token);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static async Task<string?> UploadWithRetryAsync(
|
||||
HttpClient client,
|
||||
PhotoboothSettings settings,
|
||||
string path,
|
||||
CancellationToken token)
|
||||
{
|
||||
for (var attempt = 0; attempt <= MaxRetries; attempt++)
|
||||
{
|
||||
var attemptError = await UploadOnceAsync(client, settings, path, token);
|
||||
if (attemptError.Success)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
if (!attemptError.Retryable || attempt >= MaxRetries)
|
||||
{
|
||||
return attemptError.Error ?? "Upload fehlgeschlagen.";
|
||||
}
|
||||
|
||||
await Task.Delay(GetRetryDelay(attempt), token);
|
||||
}
|
||||
|
||||
return "Upload fehlgeschlagen.";
|
||||
}
|
||||
|
||||
private static async Task<UploadAttempt> UploadOnceAsync(
|
||||
HttpClient client,
|
||||
PhotoboothSettings settings,
|
||||
string path,
|
||||
CancellationToken token)
|
||||
{
|
||||
var readyError = await WaitForFileReadyAsync(path, token);
|
||||
if (readyError is not null)
|
||||
{
|
||||
return UploadAttempt.Fail(readyError, retryable: false);
|
||||
}
|
||||
|
||||
if (!File.Exists(path))
|
||||
{
|
||||
return UploadAttempt.Fail("Datei nicht gefunden.", retryable: false);
|
||||
}
|
||||
|
||||
using var content = new MultipartFormDataContent();
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(settings.Username))
|
||||
{
|
||||
content.Add(new StringContent(settings.Username), "username");
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(settings.Password))
|
||||
{
|
||||
content.Add(new StringContent(settings.Password), "password");
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(settings.ResponseFormat))
|
||||
{
|
||||
content.Add(new StringContent(settings.ResponseFormat), "format");
|
||||
}
|
||||
|
||||
var stream = File.OpenRead(path);
|
||||
var fileContent = new StreamContent(stream);
|
||||
fileContent.Headers.ContentType = new MediaTypeHeaderValue(ResolveContentType(path));
|
||||
content.Add(fileContent, "media", Path.GetFileName(path));
|
||||
|
||||
try
|
||||
{
|
||||
var response = await client.PostAsync(settings.UploadUrl, content, token);
|
||||
|
||||
if (response.IsSuccessStatusCode)
|
||||
{
|
||||
return UploadAttempt.Ok();
|
||||
}
|
||||
|
||||
var body = await ReadResponseBodyAsync(response, token);
|
||||
var status = $"{(int)response.StatusCode} {response.ReasonPhrase}".Trim();
|
||||
var message = string.IsNullOrWhiteSpace(body) ? status : $"{status} – {body}";
|
||||
return UploadAttempt.Fail(message, IsRetryableStatus(response.StatusCode));
|
||||
}
|
||||
catch (TaskCanceledException) when (!token.IsCancellationRequested)
|
||||
{
|
||||
return UploadAttempt.Fail("Zeitüberschreitung beim Upload.", retryable: true);
|
||||
}
|
||||
catch (HttpRequestException)
|
||||
{
|
||||
return UploadAttempt.Fail("Netzwerkfehler beim Upload.", retryable: true);
|
||||
}
|
||||
catch (IOException)
|
||||
{
|
||||
return UploadAttempt.Fail("Datei konnte nicht gelesen werden.", retryable: false);
|
||||
}
|
||||
}
|
||||
|
||||
private static async Task<string?> WaitForFileReadyAsync(string path, CancellationToken token)
|
||||
{
|
||||
var lastSize = -1L;
|
||||
|
||||
for (var attempts = 0; attempts < 10; attempts++)
|
||||
{
|
||||
token.ThrowIfCancellationRequested();
|
||||
|
||||
if (!File.Exists(path))
|
||||
{
|
||||
await Task.Delay(500, token);
|
||||
continue;
|
||||
}
|
||||
|
||||
var info = new FileInfo(path);
|
||||
var size = info.Length;
|
||||
|
||||
if (size > 0 && size == lastSize)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
lastSize = size;
|
||||
await Task.Delay(700, token);
|
||||
}
|
||||
|
||||
return "Datei ist noch in Bearbeitung.";
|
||||
}
|
||||
|
||||
private static string ResolveContentType(string path)
|
||||
{
|
||||
return Path.GetExtension(path)?.ToLowerInvariant() switch
|
||||
{
|
||||
".png" => "image/png",
|
||||
".webp" => "image/webp",
|
||||
_ => "image/jpeg",
|
||||
};
|
||||
}
|
||||
|
||||
private static bool IsRetryableStatus(System.Net.HttpStatusCode statusCode)
|
||||
{
|
||||
var numeric = (int)statusCode;
|
||||
return numeric >= 500 || statusCode is System.Net.HttpStatusCode.RequestTimeout or System.Net.HttpStatusCode.TooManyRequests;
|
||||
}
|
||||
|
||||
private static TimeSpan GetRetryDelay(int attempt)
|
||||
{
|
||||
var jitter = TimeSpan.FromMilliseconds(Random.Shared.Next(100, 350));
|
||||
return TimeSpan.FromMilliseconds(RetryBaseDelay.TotalMilliseconds * Math.Pow(2, attempt)) + jitter;
|
||||
}
|
||||
|
||||
private static async Task<string?> ReadResponseBodyAsync(HttpResponseMessage response, CancellationToken token)
|
||||
{
|
||||
if (response.Content is null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var body = await response.Content.ReadAsStringAsync(token);
|
||||
if (string.IsNullOrWhiteSpace(body))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
body = body.Trim();
|
||||
return body.Length > 200 ? body[..200] + "…" : body;
|
||||
}
|
||||
|
||||
private static int GetWorkerCount(PhotoboothSettings settings)
|
||||
{
|
||||
var count = settings.MaxConcurrentUploads;
|
||||
if (count < 1)
|
||||
{
|
||||
return 1;
|
||||
}
|
||||
|
||||
return count > 5 ? 5 : count;
|
||||
}
|
||||
|
||||
private readonly record struct UploadAttempt(bool Success, bool Retryable, string? Error)
|
||||
{
|
||||
public static UploadAttempt Ok() => new(true, false, null);
|
||||
|
||||
public static UploadAttempt Fail(string error, bool retryable) => new(false, retryable, error);
|
||||
}
|
||||
}
|
||||
18
clients/photobooth-uploader/PhotoboothUploader/app.manifest
Normal file
18
clients/photobooth-uploader/PhotoboothUploader/app.manifest
Normal file
@@ -0,0 +1,18 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<assembly manifestVersion="1.0" xmlns="urn:schemas-microsoft-com:asm.v1">
|
||||
<!-- This manifest is used on Windows only.
|
||||
Don't remove it as it might cause problems with window transparency and embedded controls.
|
||||
For more details visit https://learn.microsoft.com/en-us/windows/win32/sbscs/application-manifests -->
|
||||
<assemblyIdentity version="1.0.0.0" name="PhotoboothUploader.Desktop"/>
|
||||
|
||||
<compatibility xmlns="urn:schemas-microsoft-com:compatibility.v1">
|
||||
<application>
|
||||
<!-- A list of the Windows versions that this application has been tested on
|
||||
and is designed to work with. Uncomment the appropriate elements
|
||||
and Windows will automatically select the most compatible environment. -->
|
||||
|
||||
<!-- Windows 10 -->
|
||||
<supportedOS Id="{8e0f7a12-bfb3-4fe8-b9a5-48fd50a15a9a}" />
|
||||
</application>
|
||||
</compatibility>
|
||||
</assembly>
|
||||
@@ -24,7 +24,8 @@
|
||||
"simplesoftwareio/simple-qrcode": "^4.2",
|
||||
"spatie/laravel-translatable": "^6.11",
|
||||
"staudenmeir/belongs-to-through": "^2.17",
|
||||
"stripe/stripe-php": "*"
|
||||
"stripe/stripe-php": "*",
|
||||
"symfony/yaml": "^7.0"
|
||||
},
|
||||
"require-dev": {
|
||||
"fakerphp/faker": "^1.23",
|
||||
|
||||
154
composer.lock
generated
154
composer.lock
generated
@@ -4,7 +4,7 @@
|
||||
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
|
||||
"This file is @generated automatically"
|
||||
],
|
||||
"content-hash": "c1a772e5fe6f8d5c92fdbbea232f9f78",
|
||||
"content-hash": "5e1d60e650853d6113b01e1adaf49d65",
|
||||
"packages": [
|
||||
{
|
||||
"name": "anourvalar/eloquent-serialize",
|
||||
@@ -10043,6 +10043,82 @@
|
||||
],
|
||||
"time": "2025-10-27T20:36:44+00:00"
|
||||
},
|
||||
{
|
||||
"name": "symfony/yaml",
|
||||
"version": "v7.4.1",
|
||||
"source": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/symfony/yaml.git",
|
||||
"reference": "24dd4de28d2e3988b311751ac49e684d783e2345"
|
||||
},
|
||||
"dist": {
|
||||
"type": "zip",
|
||||
"url": "https://api.github.com/repos/symfony/yaml/zipball/24dd4de28d2e3988b311751ac49e684d783e2345",
|
||||
"reference": "24dd4de28d2e3988b311751ac49e684d783e2345",
|
||||
"shasum": ""
|
||||
},
|
||||
"require": {
|
||||
"php": ">=8.2",
|
||||
"symfony/deprecation-contracts": "^2.5|^3",
|
||||
"symfony/polyfill-ctype": "^1.8"
|
||||
},
|
||||
"conflict": {
|
||||
"symfony/console": "<6.4"
|
||||
},
|
||||
"require-dev": {
|
||||
"symfony/console": "^6.4|^7.0|^8.0"
|
||||
},
|
||||
"bin": [
|
||||
"Resources/bin/yaml-lint"
|
||||
],
|
||||
"type": "library",
|
||||
"autoload": {
|
||||
"psr-4": {
|
||||
"Symfony\\Component\\Yaml\\": ""
|
||||
},
|
||||
"exclude-from-classmap": [
|
||||
"/Tests/"
|
||||
]
|
||||
},
|
||||
"notification-url": "https://packagist.org/downloads/",
|
||||
"license": [
|
||||
"MIT"
|
||||
],
|
||||
"authors": [
|
||||
{
|
||||
"name": "Fabien Potencier",
|
||||
"email": "fabien@symfony.com"
|
||||
},
|
||||
{
|
||||
"name": "Symfony Community",
|
||||
"homepage": "https://symfony.com/contributors"
|
||||
}
|
||||
],
|
||||
"description": "Loads and dumps YAML files",
|
||||
"homepage": "https://symfony.com",
|
||||
"support": {
|
||||
"source": "https://github.com/symfony/yaml/tree/v7.4.1"
|
||||
},
|
||||
"funding": [
|
||||
{
|
||||
"url": "https://symfony.com/sponsor",
|
||||
"type": "custom"
|
||||
},
|
||||
{
|
||||
"url": "https://github.com/fabpot",
|
||||
"type": "github"
|
||||
},
|
||||
{
|
||||
"url": "https://github.com/nicolas-grekas",
|
||||
"type": "github"
|
||||
},
|
||||
{
|
||||
"url": "https://tidelift.com/funding/github/packagist/symfony/symfony",
|
||||
"type": "tidelift"
|
||||
}
|
||||
],
|
||||
"time": "2025-12-04T18:11:45+00:00"
|
||||
},
|
||||
{
|
||||
"name": "tijsverkoyen/css-to-inline-styles",
|
||||
"version": "v2.3.0",
|
||||
@@ -12852,82 +12928,6 @@
|
||||
],
|
||||
"time": "2024-10-20T05:08:20+00:00"
|
||||
},
|
||||
{
|
||||
"name": "symfony/yaml",
|
||||
"version": "v7.4.1",
|
||||
"source": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/symfony/yaml.git",
|
||||
"reference": "24dd4de28d2e3988b311751ac49e684d783e2345"
|
||||
},
|
||||
"dist": {
|
||||
"type": "zip",
|
||||
"url": "https://api.github.com/repos/symfony/yaml/zipball/24dd4de28d2e3988b311751ac49e684d783e2345",
|
||||
"reference": "24dd4de28d2e3988b311751ac49e684d783e2345",
|
||||
"shasum": ""
|
||||
},
|
||||
"require": {
|
||||
"php": ">=8.2",
|
||||
"symfony/deprecation-contracts": "^2.5|^3",
|
||||
"symfony/polyfill-ctype": "^1.8"
|
||||
},
|
||||
"conflict": {
|
||||
"symfony/console": "<6.4"
|
||||
},
|
||||
"require-dev": {
|
||||
"symfony/console": "^6.4|^7.0|^8.0"
|
||||
},
|
||||
"bin": [
|
||||
"Resources/bin/yaml-lint"
|
||||
],
|
||||
"type": "library",
|
||||
"autoload": {
|
||||
"psr-4": {
|
||||
"Symfony\\Component\\Yaml\\": ""
|
||||
},
|
||||
"exclude-from-classmap": [
|
||||
"/Tests/"
|
||||
]
|
||||
},
|
||||
"notification-url": "https://packagist.org/downloads/",
|
||||
"license": [
|
||||
"MIT"
|
||||
],
|
||||
"authors": [
|
||||
{
|
||||
"name": "Fabien Potencier",
|
||||
"email": "fabien@symfony.com"
|
||||
},
|
||||
{
|
||||
"name": "Symfony Community",
|
||||
"homepage": "https://symfony.com/contributors"
|
||||
}
|
||||
],
|
||||
"description": "Loads and dumps YAML files",
|
||||
"homepage": "https://symfony.com",
|
||||
"support": {
|
||||
"source": "https://github.com/symfony/yaml/tree/v7.4.1"
|
||||
},
|
||||
"funding": [
|
||||
{
|
||||
"url": "https://symfony.com/sponsor",
|
||||
"type": "custom"
|
||||
},
|
||||
{
|
||||
"url": "https://github.com/fabpot",
|
||||
"type": "github"
|
||||
},
|
||||
{
|
||||
"url": "https://github.com/nicolas-grekas",
|
||||
"type": "github"
|
||||
},
|
||||
{
|
||||
"url": "https://tidelift.com/funding/github/packagist/symfony/symfony",
|
||||
"type": "tidelift"
|
||||
}
|
||||
],
|
||||
"time": "2025-12-04T18:11:45+00:00"
|
||||
},
|
||||
{
|
||||
"name": "theseer/tokenizer",
|
||||
"version": "1.3.1",
|
||||
|
||||
@@ -4,9 +4,9 @@ return [
|
||||
'failure_limit' => (int) env('JOIN_TOKEN_FAILURE_LIMIT', 10),
|
||||
'failure_decay_minutes' => (int) env('JOIN_TOKEN_FAILURE_DECAY', 5),
|
||||
|
||||
'access_limit' => (int) env('JOIN_TOKEN_ACCESS_LIMIT', 120),
|
||||
'access_limit' => (int) env('JOIN_TOKEN_ACCESS_LIMIT', 300),
|
||||
'access_decay_minutes' => (int) env('JOIN_TOKEN_ACCESS_DECAY', 1),
|
||||
|
||||
'download_limit' => (int) env('JOIN_TOKEN_DOWNLOAD_LIMIT', 60),
|
||||
'download_limit' => (int) env('JOIN_TOKEN_DOWNLOAD_LIMIT', 120),
|
||||
'download_decay_minutes' => (int) env('JOIN_TOKEN_DOWNLOAD_DECAY', 1),
|
||||
];
|
||||
|
||||
@@ -34,4 +34,8 @@ return [
|
||||
'rate_limit_per_minute' => (int) env('SPARKBOOTH_RATE_LIMIT_PER_MINUTE', env('PHOTOBOOTH_RATE_LIMIT_PER_MINUTE', 20)),
|
||||
'response_format' => env('SPARKBOOTH_RESPONSE_FORMAT', 'json'),
|
||||
],
|
||||
'connect_code' => [
|
||||
'length' => (int) env('PHOTOBOOTH_CONNECT_CODE_LENGTH', 6),
|
||||
'expires_minutes' => (int) env('PHOTOBOOTH_CONNECT_CODE_EXPIRES_MINUTES', 10),
|
||||
],
|
||||
];
|
||||
|
||||
29
database/factories/PhotoboothConnectCodeFactory.php
Normal file
29
database/factories/PhotoboothConnectCodeFactory.php
Normal file
@@ -0,0 +1,29 @@
|
||||
<?php
|
||||
|
||||
namespace Database\Factories;
|
||||
|
||||
use App\Models\Event;
|
||||
use Illuminate\Database\Eloquent\Factories\Factory;
|
||||
|
||||
/**
|
||||
* @extends \Illuminate\Database\Eloquent\Factories\Factory<\App\Models\PhotoboothConnectCode>
|
||||
*/
|
||||
class PhotoboothConnectCodeFactory extends Factory
|
||||
{
|
||||
/**
|
||||
* Define the model's default state.
|
||||
*
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
public function definition(): array
|
||||
{
|
||||
$rawCode = str_pad((string) $this->faker->numberBetween(0, 999999), 6, '0', STR_PAD_LEFT);
|
||||
|
||||
return [
|
||||
'event_id' => Event::factory(),
|
||||
'code_hash' => hash('sha256', $rawCode),
|
||||
'expires_at' => now()->addMinutes(10),
|
||||
'redeemed_at' => null,
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -28,8 +28,8 @@ class TenantFactory extends Factory
|
||||
'settings' => json_encode([
|
||||
'branding' => [
|
||||
'logo_url' => null,
|
||||
'primary_color' => '#3B82F6',
|
||||
'secondary_color' => '#1F2937',
|
||||
'primary_color' => '#FF5A5F',
|
||||
'secondary_color' => '#FFF8F5',
|
||||
'font_family' => 'Inter, sans-serif',
|
||||
],
|
||||
'features' => [
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
/**
|
||||
* Run the migrations.
|
||||
*/
|
||||
public function up(): void
|
||||
{
|
||||
Schema::create('photobooth_connect_codes', function (Blueprint $table) {
|
||||
$table->id();
|
||||
$table->foreignId('event_id')->constrained()->cascadeOnDelete();
|
||||
$table->string('code_hash', 64)->unique();
|
||||
$table->timestamp('expires_at');
|
||||
$table->timestamp('redeemed_at')->nullable();
|
||||
$table->timestamps();
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Reverse the migrations.
|
||||
*/
|
||||
public function down(): void
|
||||
{
|
||||
Schema::dropIfExists('photobooth_connect_codes');
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,34 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
/**
|
||||
* Run the migrations.
|
||||
*/
|
||||
public function up(): void
|
||||
{
|
||||
Schema::table('packages', function (Blueprint $table) {
|
||||
if (! Schema::hasColumn('packages', 'included_package_slug')) {
|
||||
$table->string('included_package_slug')->nullable()->after('type');
|
||||
$table->index(['type', 'included_package_slug']);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Reverse the migrations.
|
||||
*/
|
||||
public function down(): void
|
||||
{
|
||||
Schema::table('packages', function (Blueprint $table) {
|
||||
if (Schema::hasColumn('packages', 'included_package_slug')) {
|
||||
$table->dropIndex(['type', 'included_package_slug']);
|
||||
$table->dropColumn('included_package_slug');
|
||||
}
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -46,9 +46,9 @@ class DemoEventSeeder extends Seeder
|
||||
'collection_slugs' => ['wedding-classics-2025'],
|
||||
'task_slug_prefix' => 'wedding-',
|
||||
'branding' => [
|
||||
'primary_color' => '#f43f5e',
|
||||
'secondary_color' => '#fb7185',
|
||||
'background_color' => '#fff7f4',
|
||||
'primary_color' => '#FF5A5F',
|
||||
'secondary_color' => '#FFF8F5',
|
||||
'background_color' => '#FFF8F5',
|
||||
'font_family' => 'Playfair Display, serif',
|
||||
],
|
||||
],
|
||||
|
||||
@@ -76,8 +76,8 @@ class DemoTenantSeeder extends Seeder
|
||||
'contact_email' => $user->email,
|
||||
'branding' => [
|
||||
'logo_url' => null,
|
||||
'primary_color' => '#f43f5e',
|
||||
'secondary_color' => '#1f2937',
|
||||
'primary_color' => '#FF5A5F',
|
||||
'secondary_color' => '#FFF8F5',
|
||||
],
|
||||
],
|
||||
]
|
||||
|
||||
@@ -2,9 +2,9 @@
|
||||
|
||||
namespace Database\Seeders;
|
||||
|
||||
use Illuminate\Database\Seeder;
|
||||
use App\Models\Package;
|
||||
use App\Enums\PackageType;
|
||||
use App\Models\Package;
|
||||
use Illuminate\Database\Seeder;
|
||||
|
||||
class PackageSeeder extends Seeder
|
||||
{
|
||||
@@ -14,7 +14,7 @@ class PackageSeeder extends Seeder
|
||||
public function run(): void
|
||||
{
|
||||
$packages = [
|
||||
|
||||
|
||||
[
|
||||
'slug' => 'starter',
|
||||
'name' => 'Starter',
|
||||
@@ -28,12 +28,13 @@ class PackageSeeder extends Seeder
|
||||
'max_guests' => 100,
|
||||
'gallery_days' => 180,
|
||||
'max_tasks' => 30,
|
||||
'max_events_per_year' => 1,
|
||||
'watermark_allowed' => true,
|
||||
'branding_allowed' => false,
|
||||
'features' => ['basic_uploads', 'limited_sharing', 'custom_tasks'],
|
||||
'paddle_product_id' => 'pro_01k8jcxx2g1vj9snqbga4283ej',
|
||||
'paddle_price_id' => 'pri_01k8jcxx8qktxvqzzv0nkjjj27',
|
||||
'description' => <<<TEXT
|
||||
'description' => <<<'TEXT'
|
||||
Ideal für Geburtstage, Gartenpartys oder Polterabende! {{max_guests}} Gäste teilen ihre besten Schnappschüsse, lösen {{max_tasks}} Fotoaufgaben und haben {{gallery_duration}} Zugriff auf die Online-Galerie. {{max_photos}} Bilder sind inklusive – genug Platz für jede Menge Lieblingsmomente.
|
||||
TEXT,
|
||||
'description_translations' => [
|
||||
@@ -61,12 +62,12 @@ TEXT,
|
||||
'max_guests' => 250,
|
||||
'gallery_days' => 365,
|
||||
'max_tasks' => 100,
|
||||
'watermark_allowed' => false,
|
||||
'watermark_allowed' => true,
|
||||
'branding_allowed' => true,
|
||||
'features' => ['basic_uploads', 'unlimited_sharing', 'custom_branding', 'custom_tasks', 'live_slideshow'],
|
||||
'paddle_product_id' => 'pro_01k8jcxwjv4ne8vf9pvd9bye3j',
|
||||
'paddle_price_id' => 'pri_01k8jcxws51pze5xc3vj2ea0yc',
|
||||
'description' => <<<TEXT
|
||||
'description' => <<<'TEXT'
|
||||
Das Rundum-Sorglos-Paket für Hochzeiten, Firmenfeiern oder Jubiläen. {{max_photos}} Bilder, {{max_guests}} Gäste und {{max_tasks}} Fotoaufgaben – dazu eine Galerie, die {{gallery_duration}} online bleibt. Eigenes Logo oder Wasserzeichen inklusive.
|
||||
TEXT,
|
||||
'description_translations' => [
|
||||
@@ -99,12 +100,12 @@ TEXT,
|
||||
'features' => ['basic_uploads', 'unlimited_sharing', 'custom_branding', 'custom_tasks', 'live_slideshow', 'advanced_analytics', 'priority_support'],
|
||||
'paddle_product_id' => 'pro_01k8jcxvwp38gay6jj2akjg76s',
|
||||
'paddle_price_id' => 'pri_01k8jcxw5sap4r306wcvc0ephy',
|
||||
'description' => <<<TEXT
|
||||
Das volle Erlebnis für alle, die keine Kompromisse machen wollen. {{max_photos}} Bilder, unbegrenzt viele Gäste, {{gallery_duration}} Galerie-Zugang und {{max_tasks}} Aufgaben – dazu kein Wasserzeichen, Live-Slideshow und Premium-Support.
|
||||
'description' => <<<'TEXT'
|
||||
Das volle Erlebnis für alle, die keine Kompromisse machen wollen. {{max_photos}} Bilder, unbegrenzt viele Gäste, {{gallery_duration}} Galerie-Zugang und {{max_tasks}} Aufgaben – dazu eigenes Wasserzeichen, Live-Slideshow und Premium-Support.
|
||||
TEXT,
|
||||
'description_translations' => [
|
||||
'de' => 'Das volle Erlebnis für alle, die keine Kompromisse machen wollen. {{max_photos}} Bilder, unbegrenzt viele Gäste, {{gallery_duration}} Galerie-Zugang und {{max_tasks}} Aufgaben – dazu kein Wasserzeichen, Live-Slideshow und Premium-Support.',
|
||||
'en' => 'The full experience for anyone who refuses to compromise. {{max_photos}} photos, unlimited guests, {{gallery_duration}} of gallery access and {{max_tasks}} challenges—no watermark, live slideshow and premium support included.',
|
||||
'de' => 'Das volle Erlebnis für alle, die keine Kompromisse machen wollen. {{max_photos}} Bilder, unbegrenzt viele Gäste, {{gallery_duration}} Galerie-Zugang und {{max_tasks}} Aufgaben – dazu eigenes Wasserzeichen, Live-Slideshow und Premium-Support.',
|
||||
'en' => 'The full experience for anyone who refuses to compromise. {{max_photos}} photos, unlimited guests, {{gallery_duration}} of gallery access and {{max_tasks}} challenges—custom watermark, live slideshow and premium support included.',
|
||||
],
|
||||
'description_table' => [
|
||||
['title' => 'Fotos', 'value' => '{{max_photos}}'],
|
||||
@@ -116,111 +117,149 @@ TEXT,
|
||||
],
|
||||
[
|
||||
'slug' => 's-small-reseller',
|
||||
'name' => 'Reseller S',
|
||||
'name' => 'Partner Start',
|
||||
'name_translations' => [
|
||||
'de' => 'Reseller S',
|
||||
'en' => 'Reseller S',
|
||||
'de' => 'Partner Start',
|
||||
'en' => 'Partner Start',
|
||||
],
|
||||
'type' => PackageType::RESELLER,
|
||||
'included_package_slug' => 'starter',
|
||||
'price' => 149.00,
|
||||
'max_photos' => 1000,
|
||||
'max_photos' => null,
|
||||
'max_guests' => null,
|
||||
'gallery_days' => 30,
|
||||
'gallery_days' => null,
|
||||
'max_tasks' => null,
|
||||
'watermark_allowed' => true,
|
||||
'branding_allowed' => true,
|
||||
'max_events_per_year' => 5,
|
||||
'expires_after' => now()->copy()->addYear(),
|
||||
'expires_after' => null,
|
||||
'features' => ['reseller_dashboard', 'custom_branding', 'priority_support'],
|
||||
'paddle_product_id' => 'pro_01k8jcxvax48mhmwsfydw8ha9y',
|
||||
'paddle_price_id' => 'pri_01k8jcxvhe0bfasg9gg1rw70sy',
|
||||
'description' => <<<TEXT
|
||||
Das perfekte Paket für Fotografen oder Planer, die erste Erfahrungen mit Fotospiel sammeln wollen. Enthalten sind {{max_events_per_year}} Events pro Jahr mit Standard-Leistung – Branding-Optionen inklusive.
|
||||
'description' => <<<'TEXT'
|
||||
Event-Kontingent für Partner / Agenturen: {{max_events_per_year}} Events auf Starter‑Niveau. Empfohlen innerhalb von 24 Monaten zu nutzen.
|
||||
TEXT,
|
||||
'description_translations' => [
|
||||
'de' => 'Das perfekte Paket für Fotografen oder Planer, die erste Erfahrungen mit Fotospiel sammeln wollen. Enthalten sind {{max_events_per_year}} Events pro Jahr mit Standard-Leistung – Branding-Optionen inklusive.',
|
||||
'en' => 'Perfect for photographers or planners getting started with Fotospiel. Includes {{max_events_per_year}} events per year with the standard feature set—branding options included.',
|
||||
'de' => 'Event-Kontingent für Partner / Agenturen: {{max_events_per_year}} Events auf Starter‑Niveau. Empfohlen innerhalb von 24 Monaten zu nutzen.',
|
||||
'en' => 'Event-Kontingent for Partner / Agencies: {{max_events_per_year}} events at Starter level. Recommended to use within 24 months.',
|
||||
],
|
||||
'description_table' => [
|
||||
['title' => 'Events/Jahr', 'value' => '{{max_events_per_year}} Events'],
|
||||
['title' => 'Galerie', 'value' => '{{gallery_duration}}'],
|
||||
['title' => 'Branding', 'value' => 'Logo & Farben pro Event'],
|
||||
['title' => 'Events', 'value' => '{{max_events_per_year}} Events'],
|
||||
['title' => 'Inklusive Event-Level', 'value' => 'Starter'],
|
||||
['title' => 'Empfehlung', 'value' => 'Empfohlen innerhalb von 24 Monaten zu nutzen.'],
|
||||
],
|
||||
],
|
||||
[
|
||||
'slug' => 'm-medium-reseller',
|
||||
'name' => 'Reseller M',
|
||||
'name' => 'Partner Standard',
|
||||
'name_translations' => [
|
||||
'de' => 'Reseller M',
|
||||
'en' => 'Reseller M',
|
||||
'de' => 'Partner Standard',
|
||||
'en' => 'Partner Standard',
|
||||
],
|
||||
'type' => PackageType::RESELLER,
|
||||
'included_package_slug' => 'standard',
|
||||
'price' => 349.00,
|
||||
'max_photos' => 1500,
|
||||
'max_photos' => null,
|
||||
'max_guests' => null,
|
||||
'gallery_days' => 60,
|
||||
'gallery_days' => null,
|
||||
'max_tasks' => null,
|
||||
'watermark_allowed' => true,
|
||||
'branding_allowed' => true,
|
||||
'max_events_per_year' => 15,
|
||||
'expires_after' => now()->copy()->addYear(),
|
||||
'expires_after' => null,
|
||||
'features' => ['reseller_dashboard', 'custom_branding', 'priority_support', 'advanced_reporting'],
|
||||
'paddle_product_id' => 'pro_01k8jcxtrxw7jsew52jnax901q',
|
||||
'paddle_price_id' => 'pri_01k8jcxv06nsgy8ym8mnfrfm5v',
|
||||
'description' => <<<TEXT
|
||||
Wenn du regelmäßig Hochzeiten, Firmenfeste oder private Events betreust, ist dieses Paket ideal. {{max_events_per_year}} Events pro Jahr mit Branding-Optionen, verlängerter Galerie-Laufzeit und Reporting inklusive.
|
||||
'description' => <<<'TEXT'
|
||||
Event-Kontingent für Partner / Agenturen: {{max_events_per_year}} Events auf Standard‑Niveau. Empfohlen innerhalb von 24 Monaten zu nutzen.
|
||||
TEXT,
|
||||
'description_translations' => [
|
||||
'de' => 'Wenn du regelmäßig Hochzeiten, Firmenfeste oder private Events betreust, ist dieses Paket ideal. {{max_events_per_year}} Events pro Jahr mit Branding-Optionen, verlängerter Galerie-Laufzeit und Reporting inklusive.',
|
||||
'en' => 'Designed for professionals who regularly support weddings, corporate events or private parties. {{max_events_per_year}} events per year with branding options, extended gallery runtime and reporting included.',
|
||||
'de' => 'Event-Kontingent für Partner / Agenturen: {{max_events_per_year}} Events auf Standard‑Niveau. Empfohlen innerhalb von 24 Monaten zu nutzen.',
|
||||
'en' => 'Event-Kontingent for Partner / Agencies: {{max_events_per_year}} events at Standard level. Recommended to use within 24 months.',
|
||||
],
|
||||
'description_table' => [
|
||||
['title' => 'Events/Jahr', 'value' => '{{max_events_per_year}} Events'],
|
||||
['title' => 'Galerie', 'value' => '{{gallery_duration}}'],
|
||||
['title' => 'Reporting', 'value' => 'Erweiterte Auswertungen'],
|
||||
['title' => 'Events', 'value' => '{{max_events_per_year}} Events'],
|
||||
['title' => 'Inklusive Event-Level', 'value' => 'Standard'],
|
||||
['title' => 'Empfehlung', 'value' => 'Empfohlen innerhalb von 24 Monaten zu nutzen.'],
|
||||
],
|
||||
],
|
||||
[
|
||||
'slug' => 'l-large-reseller',
|
||||
'name' => 'Reseller L',
|
||||
'name' => 'Partner Premium',
|
||||
'name_translations' => [
|
||||
'de' => 'Reseller L',
|
||||
'en' => 'Reseller L',
|
||||
'de' => 'Partner Premium',
|
||||
'en' => 'Partner Premium',
|
||||
],
|
||||
'type' => PackageType::RESELLER,
|
||||
'price' => 699.00,
|
||||
'max_photos' => 3000,
|
||||
'included_package_slug' => 'pro',
|
||||
'price' => 1999.00,
|
||||
'max_photos' => null,
|
||||
'max_guests' => null,
|
||||
'gallery_days' => 90,
|
||||
'gallery_days' => null,
|
||||
'max_tasks' => null,
|
||||
'watermark_allowed' => false,
|
||||
'branding_allowed' => true,
|
||||
'max_events_per_year' => 40,
|
||||
'expires_after' => now()->copy()->addYear(),
|
||||
'max_events_per_year' => 35,
|
||||
'expires_after' => null,
|
||||
'features' => ['reseller_dashboard', 'custom_branding', 'priority_support', 'advanced_reporting', 'live_slideshow'],
|
||||
'paddle_product_id' => 'pro_01k8jcxt7gc6g6ddavmq65txzz',
|
||||
'paddle_price_id' => 'pri_01k8jcxtfa07gvq43kpvpe0t8z',
|
||||
'description' => <<<TEXT
|
||||
Ideal für Agenturen, Fotografen oder Eventdienstleister mit vielen Veranstaltungen im Jahr. {{max_events_per_year}} Events inklusive, White-Label-Branding und alle Premium-Funktionen sorgen für maximale Flexibilität.
|
||||
'description' => <<<'TEXT'
|
||||
Event-Kontingent für Partner / Agenturen: {{max_events_per_year}} Events auf Premium‑Niveau. Empfohlen innerhalb von 24 Monaten zu nutzen.
|
||||
TEXT,
|
||||
'description_translations' => [
|
||||
'de' => 'Ideal für Agenturen, Fotografen oder Eventdienstleister mit vielen Veranstaltungen im Jahr. {{max_events_per_year}} Events inklusive, White-Label-Branding und alle Premium-Funktionen sorgen für maximale Flexibilität.',
|
||||
'en' => 'Ideal for agencies, photographers or event providers with a packed calendar. {{max_events_per_year}} events included, white-label branding and all premium features for maximum flexibility.',
|
||||
'de' => 'Event-Kontingent für Partner / Agenturen: {{max_events_per_year}} Events auf Premium‑Niveau. Empfohlen innerhalb von 24 Monaten zu nutzen.',
|
||||
'en' => 'Event-Kontingent for Partner / Agencies: {{max_events_per_year}} events at Premium level. Recommended to use within 24 months.',
|
||||
],
|
||||
'description_table' => [
|
||||
['title' => 'Events/Jahr', 'value' => '{{max_events_per_year}} Events'],
|
||||
['title' => 'Branding', 'value' => 'White-Label & eigene Domains'],
|
||||
['title' => 'Extras', 'value' => 'Live-Slideshow & Premium-Features'],
|
||||
['title' => 'Events', 'value' => '{{max_events_per_year}} Events'],
|
||||
['title' => 'Inklusive Event-Level', 'value' => 'Premium'],
|
||||
['title' => 'Empfehlung', 'value' => 'Empfohlen innerhalb von 24 Monaten zu nutzen.'],
|
||||
],
|
||||
],
|
||||
[
|
||||
'slug' => 'partner-premium-5',
|
||||
'name' => 'Partner Premium-Kontingent (5 Events)',
|
||||
'name_translations' => [
|
||||
'de' => 'Partner Premium-Kontingent (5 Events)',
|
||||
'en' => 'Partner Premium kontingent (5 events)',
|
||||
],
|
||||
'type' => PackageType::RESELLER,
|
||||
'included_package_slug' => 'pro',
|
||||
'price' => 549.00,
|
||||
'max_photos' => null,
|
||||
'max_guests' => null,
|
||||
'gallery_days' => null,
|
||||
'max_tasks' => null,
|
||||
'watermark_allowed' => false,
|
||||
'branding_allowed' => true,
|
||||
'max_events_per_year' => 5,
|
||||
'expires_after' => null,
|
||||
'features' => ['reseller_dashboard', 'custom_branding', 'priority_support'],
|
||||
'paddle_product_id' => 'pro_01kf16ttp0fph79j59x0z1cdqc',
|
||||
'paddle_price_id' => 'pri_01kf16v0v2z4hse5cxq5wnah4b',
|
||||
'description' => <<<'TEXT'
|
||||
Premium Event-Kontingent für Partner / Agenturen: {{max_events_per_year}} Events auf Premium‑Niveau. Empfohlen innerhalb von 24 Monaten zu nutzen.
|
||||
TEXT,
|
||||
'description_translations' => [
|
||||
'de' => 'Premium Event-Kontingent für Partner / Agenturen: {{max_events_per_year}} Events auf Premium‑Niveau. Empfohlen innerhalb von 24 Monaten zu nutzen.',
|
||||
'en' => 'Premium Event-Kontingent for Partner / Agencies: {{max_events_per_year}} events at Premium level. Recommended to use within 24 months.',
|
||||
],
|
||||
'description_table' => [
|
||||
['title' => 'Events', 'value' => '{{max_events_per_year}} Events'],
|
||||
['title' => 'Inklusive Event-Level', 'value' => 'Premium'],
|
||||
['title' => 'Empfehlung', 'value' => 'Empfohlen innerhalb von 24 Monaten zu nutzen.'],
|
||||
],
|
||||
],
|
||||
[
|
||||
'slug' => 'studio-annual',
|
||||
'name' => 'Studio Jahrespaket',
|
||||
'name' => 'Partner Jahreskontingent (24 Events)',
|
||||
'name_translations' => [
|
||||
'de' => 'Studio Jahrespaket',
|
||||
'en' => 'Studio Annual',
|
||||
'de' => 'Partner Jahreskontingent (24 Events)',
|
||||
'en' => 'Partner annual kontingent (24 events)',
|
||||
],
|
||||
'type' => PackageType::RESELLER,
|
||||
'included_package_slug' => 'standard',
|
||||
'price' => 1299.00,
|
||||
'max_photos' => null,
|
||||
'max_guests' => null,
|
||||
@@ -230,42 +269,20 @@ TEXT,
|
||||
'branding_allowed' => false,
|
||||
'max_events_per_year' => 24,
|
||||
'expires_after' => null,
|
||||
'features' => ['basic_uploads', 'unlimited_sharing', 'custom_branding'],
|
||||
'features' => ['reseller_dashboard', 'custom_branding', 'priority_support', 'advanced_reporting'],
|
||||
'paddle_product_id' => 'pro_01k8jct3gz9ks5mg6z61q6nrxb',
|
||||
'paddle_price_id' => 'pri_01k8jcxsa8axwpjnybhjbcrb06',
|
||||
'description' => null,
|
||||
'description_translations' => null,
|
||||
'description_table' => [],
|
||||
],
|
||||
[
|
||||
'slug' => 'enterprise-unlimited',
|
||||
'name' => 'Enterprise / Unlimited',
|
||||
'name_translations' => [
|
||||
'de' => 'Enterprise / Unlimited',
|
||||
'en' => 'Enterprise / Unlimited',
|
||||
],
|
||||
'type' => PackageType::RESELLER,
|
||||
'price' => 1999.00,
|
||||
'max_photos' => null,
|
||||
'max_guests' => null,
|
||||
'gallery_days' => null,
|
||||
'max_tasks' => null,
|
||||
'watermark_allowed' => false,
|
||||
'branding_allowed' => true,
|
||||
'max_events_per_year' => null,
|
||||
'expires_after' => now()->copy()->addYear(),
|
||||
'features' => ['reseller_dashboard', 'custom_branding', 'priority_support', 'advanced_reporting', 'live_slideshow', 'unlimited_sharing'],
|
||||
'description' => <<<TEXT
|
||||
Das Rundum-Paket für Unternehmen und Agenturen, die maximale Flexibilität brauchen. {{max_events_per_year}} Events, volles White-Label-Branding, eigene Subdomain oder App-Branding – alles individuell anpassbar, inklusive persönlicher Betreuung.
|
||||
'description' => <<<'TEXT'
|
||||
Event-Kontingent für Partner / Agenturen: {{max_events_per_year}} Events auf Standard‑Niveau. Empfohlen innerhalb von 24 Monaten zu nutzen.
|
||||
TEXT,
|
||||
'description_translations' => [
|
||||
'de' => 'Das Rundum-Paket für Unternehmen und Agenturen, die maximale Flexibilität brauchen. {{max_events_per_year}} Events, volles White-Label-Branding, eigene Subdomain oder App-Branding – alles individuell anpassbar, inklusive persönlicher Betreuung.',
|
||||
'en' => 'The all-round package for enterprises and agencies needing maximum flexibility. {{max_events_per_year}} events, full white-label branding, your own subdomain or app branding—fully customisable with dedicated support.',
|
||||
'de' => 'Event-Kontingent für Partner / Agenturen: {{max_events_per_year}} Events auf Standard‑Niveau. Empfohlen innerhalb von 24 Monaten zu nutzen.',
|
||||
'en' => 'Event-Kontingent for Partner / Agencies: {{max_events_per_year}} events at Standard level. Recommended to use within 24 months.',
|
||||
],
|
||||
'description_table' => [
|
||||
['title' => 'Events/Jahr', 'value' => '{{max_events_per_year}} Events'],
|
||||
['title' => 'Branding', 'value' => 'Eigene Subdomain oder App'],
|
||||
['title' => 'Support', 'value' => 'Persönliche Betreuung'],
|
||||
['title' => 'Events', 'value' => '{{max_events_per_year}} Events'],
|
||||
['title' => 'Inklusive Event-Level', 'value' => 'Standard'],
|
||||
['title' => 'Empfehlung', 'value' => 'Empfohlen innerhalb von 24 Monaten zu nutzen.'],
|
||||
],
|
||||
],
|
||||
];
|
||||
@@ -279,5 +296,7 @@ TEXT,
|
||||
])
|
||||
);
|
||||
}
|
||||
|
||||
Package::where('slug', 'enterprise-unlimited')->delete();
|
||||
}
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user