Compare commits
129 Commits
main
...
1517eb8631
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
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
|
daemon.pid
|
||||||
bd.sock
|
bd.sock
|
||||||
sync-state.json
|
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 tracking (prevents upgrade notification spam after git ops)
|
||||||
.local_version
|
.local_version
|
||||||
|
|||||||
@@ -42,7 +42,7 @@
|
|||||||
# This setting persists across clones (unlike database config which is gitignored).
|
# This setting persists across clones (unlike database config which is gitignored).
|
||||||
# Can also use BEADS_SYNC_BRANCH env var for local override.
|
# 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>'.
|
# 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)
|
# Multi-repo configuration (experimental - bd-307)
|
||||||
# Allows hydrating from multiple repositories and routing writes to the correct JSONL
|
# Allows hydrating from multiple repositories and routing writes to the correct JSONL
|
||||||
@@ -59,4 +59,4 @@
|
|||||||
# - linear.url
|
# - linear.url
|
||||||
# - linear.api-key
|
# - linear.api-key
|
||||||
# - github.org
|
# - 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-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-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-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-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-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"}
|
{"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-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-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-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-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-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"}]}
|
{"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-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-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-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-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-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-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-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-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-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"}
|
{"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-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-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-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-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-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-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-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-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"}
|
{"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-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-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-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-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-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"}
|
{"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-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-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-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-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-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"}
|
{"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-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-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-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-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-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"}
|
{"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-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-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-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-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-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-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-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-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-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-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)"}
|
{"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-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-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-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-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-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"}
|
{"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/*.key
|
||||||
/storage/pail
|
/storage/pail
|
||||||
/vendor
|
/vendor
|
||||||
|
/clients/photobooth-uploader/**/bin
|
||||||
|
/clients/photobooth-uploader/**/obj
|
||||||
.env
|
.env
|
||||||
.env.backup
|
.env.backup
|
||||||
.env.production
|
.env.production
|
||||||
@@ -23,11 +25,9 @@ Homestead.yaml
|
|||||||
npm-debug.log
|
npm-debug.log
|
||||||
yarn-error.log
|
yarn-error.log
|
||||||
/auth.json
|
/auth.json
|
||||||
/.fleet
|
|
||||||
/.idea
|
|
||||||
/.nova
|
|
||||||
/.vscode
|
/.vscode
|
||||||
/.zed
|
|
||||||
tools/git-askpass.ps1
|
|
||||||
podman-compose.dev.yml
|
|
||||||
test-results
|
test-results
|
||||||
|
GEMINI.md
|
||||||
|
.beads/.sync.lock
|
||||||
|
.beads/daemon-error
|
||||||
|
.beads/sync_base.jsonl
|
||||||
|
|||||||
@@ -337,8 +337,8 @@ Tokens are design system values that can be referenced using the `$` prefix.
|
|||||||
|
|
||||||
### Color Tokens
|
### Color Tokens
|
||||||
|
|
||||||
- `accent`: #FFB6C1
|
- `accent`: #3D5AFE
|
||||||
- `accentSoft`: #FFE5EC
|
- `accentSoft`: #E8ECFF
|
||||||
- `blue10Dark`: hsl(209, 100%, 60.6%)
|
- `blue10Dark`: hsl(209, 100%, 60.6%)
|
||||||
- `blue10Light`: hsl(208, 100%, 47.3%)
|
- `blue10Light`: hsl(208, 100%, 47.3%)
|
||||||
- `blue11Dark`: hsl(210, 100%, 66.1%)
|
- `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%)
|
- `blue8Light`: hsl(206, 81.9%, 65.3%)
|
||||||
- `blue9Dark`: hsl(206, 100%, 50.0%)
|
- `blue9Dark`: hsl(206, 100%, 50.0%)
|
||||||
- `blue9Light`: hsl(206, 100%, 50.0%)
|
- `blue9Light`: hsl(206, 100%, 50.0%)
|
||||||
- `border`: #F2E4DA
|
- `border`: #F3D6C9
|
||||||
- `danger`: #E04848
|
- `danger`: #EF4444
|
||||||
- `gray10Dark`: hsl(0, 0%, 49.4%)
|
- `gray10Dark`: hsl(0, 0%, 49.4%)
|
||||||
- `gray10Light`: hsl(0, 0%, 52.3%)
|
- `gray10Light`: hsl(0, 0%, 52.3%)
|
||||||
- `gray11Dark`: hsl(0, 0%, 62.8%)
|
- `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%)
|
- `green8Light`: hsl(151, 40.2%, 54.1%)
|
||||||
- `green9Dark`: hsl(151, 55.0%, 41.5%)
|
- `green9Dark`: hsl(151, 55.0%, 41.5%)
|
||||||
- `green9Light`: hsl(151, 55.0%, 41.5%)
|
- `green9Light`: hsl(151, 55.0%, 41.5%)
|
||||||
- `muted`: #F4ECE8
|
- `muted`: #FFF6F0
|
||||||
- `orange10Dark`: hsl(24, 100%, 58.5%)
|
- `orange10Dark`: hsl(24, 100%, 58.5%)
|
||||||
- `orange10Light`: hsl(24, 100%, 46.5%)
|
- `orange10Light`: hsl(24, 100%, 46.5%)
|
||||||
- `orange11Dark`: hsl(24, 100%, 62.2%)
|
- `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%)
|
- `pink8Light`: hsl(323, 60.3%, 72.4%)
|
||||||
- `pink9Dark`: hsl(322, 65.0%, 54.5%)
|
- `pink9Dark`: hsl(322, 65.0%, 54.5%)
|
||||||
- `pink9Light`: hsl(322, 65.0%, 54.5%)
|
- `pink9Light`: hsl(322, 65.0%, 54.5%)
|
||||||
- `primary`: #FF5A5F
|
- `primary`: #FF5C5C
|
||||||
- `purple10Dark`: hsl(273, 57.3%, 59.1%)
|
- `purple10Dark`: hsl(273, 57.3%, 59.1%)
|
||||||
- `purple10Light`: hsl(272, 46.8%, 50.3%)
|
- `purple10Light`: hsl(272, 46.8%, 50.3%)
|
||||||
- `purple11Dark`: hsl(275, 80.0%, 71.0%)
|
- `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%)
|
- `red8Light`: hsl(359, 69.5%, 74.3%)
|
||||||
- `red9Dark`: hsl(358, 75.0%, 59.0%)
|
- `red9Dark`: hsl(358, 75.0%, 59.0%)
|
||||||
- `red9Light`: hsl(358, 75.0%, 59.0%)
|
- `red9Light`: hsl(358, 75.0%, 59.0%)
|
||||||
- `success`: #06D6A0
|
- `success`: #22C55E
|
||||||
- `surface`: #ffffff
|
- `surface`: #ffffff
|
||||||
- `text`: #1F2937
|
- `text`: #0B132B
|
||||||
- `warning`: #F5C542
|
- `warning`: #FBBF24
|
||||||
- `yellow10Dark`: hsl(54, 100%, 68.0%)
|
- `yellow10Dark`: hsl(54, 100%, 68.0%)
|
||||||
- `yellow10Light`: hsl(50, 100%, 48.5%)
|
- `yellow10Light`: hsl(50, 100%, 48.5%)
|
||||||
- `yellow11Dark`: hsl(48, 100%, 47.0%)
|
- `yellow11Dark`: hsl(48, 100%, 47.0%)
|
||||||
|
|||||||
@@ -4160,16 +4160,16 @@ var tokens3 = {
|
|||||||
...tokens2,
|
...tokens2,
|
||||||
color: {
|
color: {
|
||||||
...tokens2.color,
|
...tokens2.color,
|
||||||
primary: "#FF5A5F",
|
primary: "#FF5C5C",
|
||||||
accent: "#FFB6C1",
|
accent: "#3D5AFE",
|
||||||
accentSoft: "#FFE5EC",
|
accentSoft: "#E8ECFF",
|
||||||
success: "#06D6A0",
|
success: "#22C55E",
|
||||||
warning: "#F5C542",
|
warning: "#FBBF24",
|
||||||
danger: "#E04848",
|
danger: "#EF4444",
|
||||||
surface: "#ffffff",
|
surface: "#ffffff",
|
||||||
muted: "#F4ECE8",
|
muted: "#FFF6F0",
|
||||||
border: "#F2E4DA",
|
border: "#F3D6C9",
|
||||||
text: "#1F2937"
|
text: "#0B132B"
|
||||||
},
|
},
|
||||||
radius: {
|
radius: {
|
||||||
...tokens2.radius,
|
...tokens2.radius,
|
||||||
@@ -4188,53 +4188,53 @@ var themes3 = {
|
|||||||
...themes2.light,
|
...themes2.light,
|
||||||
primary: tokens3.color.primary,
|
primary: tokens3.color.primary,
|
||||||
accent: tokens3.color.accent,
|
accent: tokens3.color.accent,
|
||||||
background: "#FFF8F5",
|
background: "#FFF1E8",
|
||||||
backgroundHover: "#FFF1EC",
|
backgroundHover: "#FFE8DD",
|
||||||
backgroundPress: "#FFE7E0",
|
backgroundPress: "#FFE1D2",
|
||||||
backgroundStrong: tokens3.color.surface,
|
backgroundStrong: tokens3.color.surface,
|
||||||
backgroundTransparent: "rgba(255, 248, 245, 0)",
|
backgroundTransparent: "rgba(255, 241, 232, 0)",
|
||||||
color: tokens3.color.text,
|
color: tokens3.color.text,
|
||||||
colorHover: "#111827",
|
colorHover: "#091024",
|
||||||
colorPress: "#0F172A",
|
colorPress: "#091024",
|
||||||
colorFocus: "#0F172A",
|
colorFocus: "#091024",
|
||||||
borderColor: tokens3.color.border,
|
borderColor: tokens3.color.border,
|
||||||
borderColorHover: "#EAD5C9",
|
borderColorHover: "#EBCABA",
|
||||||
borderColorPress: "#E0C9BC",
|
borderColorPress: "#E1BFAE",
|
||||||
shadowColor: "rgba(31, 41, 55, 0.12)",
|
shadowColor: "rgba(11, 19, 43, 0.16)",
|
||||||
shadowColorPress: "rgba(31, 41, 55, 0.16)",
|
shadowColorPress: "rgba(11, 19, 43, 0.2)",
|
||||||
shadowColorFocus: "rgba(31, 41, 55, 0.18)",
|
shadowColorFocus: "rgba(11, 19, 43, 0.24)",
|
||||||
surface: tokens3.color.surface,
|
surface: tokens3.color.surface,
|
||||||
muted: tokens3.color.muted,
|
muted: tokens3.color.muted,
|
||||||
blue3: tokens3.color.accentSoft,
|
blue3: tokens3.color.accentSoft,
|
||||||
blue6: tokens3.color.accent,
|
blue6: tokens3.color.accent,
|
||||||
blue10: tokens3.color.primary,
|
blue10: tokens3.color.primary,
|
||||||
blue11: "#C2413B"
|
blue11: "#1E36F1"
|
||||||
},
|
},
|
||||||
dark: {
|
dark: {
|
||||||
...themes2.dark,
|
...themes2.dark,
|
||||||
primary: tokens3.color.primary,
|
primary: tokens3.color.primary,
|
||||||
accent: tokens3.color.accent,
|
accent: tokens3.color.accent,
|
||||||
background: "#171219",
|
background: "#0B132B",
|
||||||
backgroundHover: "#1F1A23",
|
backgroundHover: "#101A36",
|
||||||
backgroundPress: "#26212B",
|
backgroundPress: "#132142",
|
||||||
backgroundStrong: "#1F1A23",
|
backgroundStrong: "#101A36",
|
||||||
backgroundTransparent: "rgba(23, 18, 25, 0)",
|
backgroundTransparent: "rgba(11, 19, 43, 0)",
|
||||||
color: "#F8F6F2",
|
color: "#F8FAFF",
|
||||||
colorHover: "#FFFFFF",
|
colorHover: "#FFFFFF",
|
||||||
colorPress: "#FDF8F5",
|
colorPress: "#F2F6FF",
|
||||||
colorFocus: "#FFFFFF",
|
colorFocus: "#FFFFFF",
|
||||||
borderColor: "#2C2531",
|
borderColor: "#1F2A4A",
|
||||||
borderColorHover: "#3A3240",
|
borderColorHover: "#29345A",
|
||||||
borderColorPress: "#443C4A",
|
borderColorPress: "#313D67",
|
||||||
shadowColor: "rgba(0, 0, 0, 0.55)",
|
shadowColor: "rgba(0, 0, 0, 0.55)",
|
||||||
shadowColorPress: "rgba(0, 0, 0, 0.65)",
|
shadowColorPress: "rgba(0, 0, 0, 0.65)",
|
||||||
shadowColorFocus: "rgba(0, 0, 0, 0.6)",
|
shadowColorFocus: "rgba(0, 0, 0, 0.6)",
|
||||||
surface: "#1F1A23",
|
surface: "#0F1B36",
|
||||||
muted: "#241E28",
|
muted: "#121F3D",
|
||||||
blue3: "#2B1D23",
|
blue3: "#1B2550",
|
||||||
blue6: "#5A2D34",
|
blue6: "#3D5AFE",
|
||||||
blue10: "#FF7A7F",
|
blue10: "#FF5C5C",
|
||||||
blue11: "#FFB3B6"
|
blue11: "#FF8A8A"
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
var sharedWeights = {
|
var sharedWeights = {
|
||||||
@@ -4254,12 +4254,12 @@ var fonts2 = {
|
|||||||
},
|
},
|
||||||
heading: {
|
heading: {
|
||||||
...defaultConfig.fonts.heading,
|
...defaultConfig.fonts.heading,
|
||||||
family: "Manrope",
|
family: "Archivo Black",
|
||||||
weight: sharedWeights
|
weight: sharedWeights
|
||||||
},
|
},
|
||||||
display: {
|
display: {
|
||||||
...defaultConfig.fonts.heading,
|
...defaultConfig.fonts.heading,
|
||||||
family: "Fraunces",
|
family: "Archivo Black",
|
||||||
weight: sharedWeights
|
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 $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)
|
public function __construct(private EventStorageManager $eventStorageManager)
|
||||||
{
|
{
|
||||||
@@ -129,7 +129,7 @@ class SeedDemoSwitcherTenants extends Command
|
|||||||
$slugs = [
|
$slugs = [
|
||||||
'starter' => 'Starter',
|
'starter' => 'Starter',
|
||||||
'standard' => 'Standard',
|
'standard' => 'Standard',
|
||||||
's-small-reseller' => 'Reseller S',
|
's-small-reseller' => 'Partner Start',
|
||||||
];
|
];
|
||||||
|
|
||||||
$packages = [];
|
$packages = [];
|
||||||
@@ -165,10 +165,10 @@ class SeedDemoSwitcherTenants extends Command
|
|||||||
{
|
{
|
||||||
$tenant = $this->upsertTenant(
|
$tenant = $this->upsertTenant(
|
||||||
slug: 'demo-standard-empty',
|
slug: 'demo-standard-empty',
|
||||||
name: 'Demo Standard (ohne Event)',
|
name: 'Demo Starter (ohne Event)',
|
||||||
contactEmail: 'standard-empty@demo.fotospiel',
|
contactEmail: 'standard-empty@demo.fotospiel',
|
||||||
attributes: [
|
attributes: [
|
||||||
'subscription_tier' => 'standard',
|
'subscription_tier' => 'starter',
|
||||||
'subscription_status' => 'active',
|
'subscription_status' => 'active',
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
@@ -176,9 +176,9 @@ class SeedDemoSwitcherTenants extends Command
|
|||||||
$this->upsertAdmin($tenant, 'standard-empty@demo.fotospiel');
|
$this->upsertAdmin($tenant, 'standard-empty@demo.fotospiel');
|
||||||
|
|
||||||
TenantPackage::updateOrCreate(
|
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),
|
'purchased_at' => Carbon::now()->subDays(1),
|
||||||
'expires_at' => Carbon::now()->addMonths(12),
|
'expires_at' => Carbon::now()->addMonths(12),
|
||||||
'used_events' => 0,
|
'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
|
private function seedCustomerStarterWedding(array $packages, array $eventTypes): void
|
||||||
@@ -204,19 +204,19 @@ class SeedDemoSwitcherTenants extends Command
|
|||||||
$this->upsertAdmin($tenant, 'starter-wedding@demo.fotospiel');
|
$this->upsertAdmin($tenant, 'starter-wedding@demo.fotospiel');
|
||||||
|
|
||||||
TenantPackage::updateOrCreate(
|
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),
|
'purchased_at' => Carbon::now()->subDays(1),
|
||||||
'expires_at' => Carbon::now()->addMonths(12),
|
'expires_at' => Carbon::now()->addMonths(12),
|
||||||
'used_events' => 0,
|
'used_events' => 1,
|
||||||
'active' => true,
|
'active' => true,
|
||||||
]
|
]
|
||||||
);
|
);
|
||||||
|
|
||||||
$event = $this->upsertEvent(
|
$event = $this->upsertEvent(
|
||||||
tenant: $tenant,
|
tenant: $tenant,
|
||||||
package: $packages['standard'],
|
package: $packages['starter'],
|
||||||
eventType: $eventTypes['wedding'] ?? null,
|
eventType: $eventTypes['wedding'] ?? null,
|
||||||
attributes: [
|
attributes: [
|
||||||
'name' => ['de' => 'Hochzeit Mia & Jonas', 'en' => 'Wedding Mia & Jonas'],
|
'name' => ['de' => 'Hochzeit Mia & Jonas', 'en' => 'Wedding Mia & Jonas'],
|
||||||
@@ -232,17 +232,18 @@ class SeedDemoSwitcherTenants extends Command
|
|||||||
|
|
||||||
private function seedResellerActive(array $packages, array $eventTypes): void
|
private function seedResellerActive(array $packages, array $eventTypes): void
|
||||||
{
|
{
|
||||||
|
$eventPackage = $this->resolveIncludedPackage($packages['s-small-reseller'], $packages);
|
||||||
$tenant = $this->upsertTenant(
|
$tenant = $this->upsertTenant(
|
||||||
slug: 'demo-reseller-active',
|
slug: 'demo-reseller-active',
|
||||||
name: 'Demo Reseller Active',
|
name: 'Demo Partner Active',
|
||||||
contactEmail: 'reseller-active@demo.fotospiel',
|
contactEmail: 'partner-active@demo.fotospiel',
|
||||||
attributes: [
|
attributes: [
|
||||||
'subscription_tier' => 'reseller',
|
'subscription_tier' => 'reseller',
|
||||||
'subscription_status' => 'active',
|
'subscription_status' => 'active',
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
|
|
||||||
$this->upsertAdmin($tenant, 'reseller-active@demo.fotospiel');
|
$this->upsertAdmin($tenant, 'partner-active@demo.fotospiel');
|
||||||
|
|
||||||
TenantPackage::updateOrCreate(
|
TenantPackage::updateOrCreate(
|
||||||
['tenant_id' => $tenant->id, 'package_id' => $packages['s-small-reseller']->id],
|
['tenant_id' => $tenant->id, 'package_id' => $packages['s-small-reseller']->id],
|
||||||
@@ -279,7 +280,7 @@ class SeedDemoSwitcherTenants extends Command
|
|||||||
foreach ($events as $index => $config) {
|
foreach ($events as $index => $config) {
|
||||||
$event = $this->upsertEvent(
|
$event = $this->upsertEvent(
|
||||||
tenant: $tenant,
|
tenant: $tenant,
|
||||||
package: $packages['standard'],
|
package: $eventPackage,
|
||||||
eventType: $config['type'],
|
eventType: $config['type'],
|
||||||
attributes: [
|
attributes: [
|
||||||
'name' => $config['name'],
|
'name' => $config['name'],
|
||||||
@@ -296,17 +297,18 @@ class SeedDemoSwitcherTenants extends Command
|
|||||||
|
|
||||||
private function seedResellerFull(array $packages, array $eventTypes): void
|
private function seedResellerFull(array $packages, array $eventTypes): void
|
||||||
{
|
{
|
||||||
|
$eventPackage = $this->resolveIncludedPackage($packages['s-small-reseller'], $packages);
|
||||||
$tenant = $this->upsertTenant(
|
$tenant = $this->upsertTenant(
|
||||||
slug: 'demo-reseller-full',
|
slug: 'demo-reseller-full',
|
||||||
name: 'Demo Reseller Voll',
|
name: 'Demo Partner Voll',
|
||||||
contactEmail: 'reseller-full@demo.fotospiel',
|
contactEmail: 'partner-full@demo.fotospiel',
|
||||||
attributes: [
|
attributes: [
|
||||||
'subscription_tier' => 'reseller',
|
'subscription_tier' => 'reseller',
|
||||||
'subscription_status' => 'active',
|
'subscription_status' => 'active',
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
|
|
||||||
$this->upsertAdmin($tenant, 'reseller-full@demo.fotospiel');
|
$this->upsertAdmin($tenant, 'partner-full@demo.fotospiel');
|
||||||
|
|
||||||
TenantPackage::updateOrCreate(
|
TenantPackage::updateOrCreate(
|
||||||
['tenant_id' => $tenant->id, 'package_id' => $packages['s-small-reseller']->id],
|
['tenant_id' => $tenant->id, 'package_id' => $packages['s-small-reseller']->id],
|
||||||
@@ -330,7 +332,7 @@ class SeedDemoSwitcherTenants extends Command
|
|||||||
foreach ($eventConfigs as $index => $config) {
|
foreach ($eventConfigs as $index => $config) {
|
||||||
$event = $this->upsertEvent(
|
$event = $this->upsertEvent(
|
||||||
tenant: $tenant,
|
tenant: $tenant,
|
||||||
package: $packages['standard'],
|
package: $eventPackage,
|
||||||
eventType: $config['type'],
|
eventType: $config['type'],
|
||||||
attributes: [
|
attributes: [
|
||||||
'name' => $config['name'],
|
'name' => $config['name'],
|
||||||
@@ -357,8 +359,8 @@ class SeedDemoSwitcherTenants extends Command
|
|||||||
'settings' => [
|
'settings' => [
|
||||||
'branding' => [
|
'branding' => [
|
||||||
'logo_url' => null,
|
'logo_url' => null,
|
||||||
'primary_color' => '#1D4ED8',
|
'primary_color' => '#FF5A5F',
|
||||||
'secondary_color' => '#0F172A',
|
'secondary_color' => '#FFF8F5',
|
||||||
'font_family' => 'Inter, sans-serif',
|
'font_family' => 'Inter, sans-serif',
|
||||||
],
|
],
|
||||||
'features' => [
|
'features' => [
|
||||||
@@ -435,6 +437,19 @@ class SeedDemoSwitcherTenants extends Command
|
|||||||
return $event;
|
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
|
private function fallbackEventType(): ?EventType
|
||||||
{
|
{
|
||||||
$fallback = EventType::first();
|
$fallback = EventType::first();
|
||||||
|
|||||||
@@ -40,7 +40,7 @@ class Login extends BaseLogin implements HasForms
|
|||||||
}
|
}
|
||||||
|
|
||||||
// SuperAdmin-spezifisch: Prüfe auf SuperAdmin-Rolle, keine Tenant-Prüfung
|
// SuperAdmin-spezifisch: Prüfe auf SuperAdmin-Rolle, keine Tenant-Prüfung
|
||||||
if ($user->role !== 'super_admin') {
|
if (! $user->isSuperAdmin()) {
|
||||||
$authGuard->logout();
|
$authGuard->logout();
|
||||||
|
|
||||||
throw ValidationException::withMessages([
|
throw ValidationException::withMessages([
|
||||||
|
|||||||
@@ -45,11 +45,11 @@ class GuestPolicySettingsPage extends Page
|
|||||||
|
|
||||||
public int $join_token_failure_decay_minutes = 5;
|
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_access_decay_minutes = 1;
|
||||||
|
|
||||||
public int $join_token_download_limit = 60;
|
public int $join_token_download_limit = 120;
|
||||||
|
|
||||||
public int $join_token_download_decay_minutes = 1;
|
public int $join_token_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->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_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_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_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_download_decay_minutes = (int) ($settings->join_token_download_decay_minutes ?? 1);
|
||||||
$this->join_token_ttl_hours = (int) ($settings->join_token_ttl_hours ?? 168);
|
$this->join_token_ttl_hours = (int) ($settings->join_token_ttl_hours ?? 168);
|
||||||
$this->share_link_ttl_hours = (int) ($settings->share_link_ttl_hours ?? 48);
|
$this->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
|
private function resolveBrandingPayload(Event $event): array
|
||||||
{
|
{
|
||||||
$defaults = [
|
$defaults = [
|
||||||
'primary' => '#f43f5e',
|
'primary' => '#FF5A5F',
|
||||||
'secondary' => '#fb7185',
|
'secondary' => '#FFF8F5',
|
||||||
'background' => '#ffffff',
|
'background' => '#FFF8F5',
|
||||||
'surface' => '#ffffff',
|
'surface' => '#FFF8F5',
|
||||||
'font' => null,
|
'font' => null,
|
||||||
'size' => 'm',
|
'size' => 'm',
|
||||||
'logo_position' => 'left',
|
'logo_position' => 'left',
|
||||||
|
|||||||
@@ -3,9 +3,12 @@
|
|||||||
namespace App\Http\Controllers\Api;
|
namespace App\Http\Controllers\Api;
|
||||||
|
|
||||||
use App\Http\Controllers\Controller;
|
use App\Http\Controllers\Controller;
|
||||||
|
use App\Http\Requests\Checkout\CheckoutSessionStatusRequest;
|
||||||
|
use App\Models\CheckoutSession;
|
||||||
use App\Models\Package;
|
use App\Models\Package;
|
||||||
use App\Models\PackagePurchase;
|
use App\Models\PackagePurchase;
|
||||||
use App\Models\TenantPackage;
|
use App\Models\TenantPackage;
|
||||||
|
use App\Services\Checkout\CheckoutSessionService;
|
||||||
use App\Services\Paddle\PaddleCheckoutService;
|
use App\Services\Paddle\PaddleCheckoutService;
|
||||||
use Illuminate\Http\JsonResponse;
|
use Illuminate\Http\JsonResponse;
|
||||||
use Illuminate\Http\Request;
|
use Illuminate\Http\Request;
|
||||||
@@ -14,7 +17,10 @@ use Illuminate\Validation\ValidationException;
|
|||||||
|
|
||||||
class PackageController extends Controller
|
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
|
public function index(Request $request): JsonResponse
|
||||||
{
|
{
|
||||||
@@ -165,23 +171,82 @@ class PackageController extends Controller
|
|||||||
|
|
||||||
$package = Package::findOrFail($request->integer('package_id'));
|
$package = Package::findOrFail($request->integer('package_id'));
|
||||||
$tenant = $request->attributes->get('tenant');
|
$tenant = $request->attributes->get('tenant');
|
||||||
|
$user = $request->user();
|
||||||
|
|
||||||
if (! $tenant) {
|
if (! $tenant) {
|
||||||
throw ValidationException::withMessages(['tenant' => 'Tenant context missing.']);
|
throw ValidationException::withMessages(['tenant' => 'Tenant context missing.']);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (! $user) {
|
||||||
|
throw ValidationException::withMessages(['user' => 'User context missing.']);
|
||||||
|
}
|
||||||
|
|
||||||
if (! $package->paddle_price_id) {
|
if (! $package->paddle_price_id) {
|
||||||
throw ValidationException::withMessages(['package_id' => 'Package is not linked to a Paddle price.']);
|
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 = [
|
$payload = [
|
||||||
'success_url' => $request->input('success_url'),
|
'success_url' => $request->input('success_url'),
|
||||||
'return_url' => $request->input('return_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);
|
$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
|
private function handleFreePurchase(Request $request, Package $package, $tenant): JsonResponse
|
||||||
@@ -212,13 +277,13 @@ class PackageController extends Controller
|
|||||||
'purchased_at' => now(),
|
'purchased_at' => now(),
|
||||||
]);
|
]);
|
||||||
} else {
|
} else {
|
||||||
// Reseller subscription
|
// Partner / reseller Event-Kontingent package
|
||||||
\App\Models\TenantPackage::create([
|
\App\Models\TenantPackage::create([
|
||||||
'tenant_id' => $tenant->id,
|
'tenant_id' => $tenant->id,
|
||||||
'package_id' => $package->id,
|
'package_id' => $package->id,
|
||||||
'price' => $package->price,
|
'price' => $package->price,
|
||||||
'purchased_at' => now(),
|
'purchased_at' => now(),
|
||||||
'expires_at' => now()->addYear(),
|
'expires_at' => null,
|
||||||
'active' => true,
|
'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\PackagePurchase;
|
||||||
use App\Models\Photo;
|
use App\Models\Photo;
|
||||||
use App\Models\Tenant;
|
use App\Models\Tenant;
|
||||||
|
use App\Models\User;
|
||||||
use App\Services\EventJoinTokenService;
|
use App\Services\EventJoinTokenService;
|
||||||
use App\Support\ApiError;
|
use App\Support\ApiError;
|
||||||
|
use App\Support\TenantMemberPermissions;
|
||||||
use Illuminate\Http\JsonResponse;
|
use Illuminate\Http\JsonResponse;
|
||||||
use Illuminate\Http\Request;
|
use Illuminate\Http\Request;
|
||||||
use Illuminate\Http\Resources\Json\AnonymousResourceCollection;
|
use Illuminate\Http\Resources\Json\AnonymousResourceCollection;
|
||||||
@@ -82,19 +84,27 @@ class EventController extends Controller
|
|||||||
|
|
||||||
public function store(EventStoreRequest $request): JsonResponse
|
public function store(EventStoreRequest $request): JsonResponse
|
||||||
{
|
{
|
||||||
|
TenantMemberPermissions::ensureTenantPermission($request, 'events:manage');
|
||||||
|
|
||||||
$tenant = $request->attributes->get('tenant');
|
$tenant = $request->attributes->get('tenant');
|
||||||
if (! $tenant instanceof Tenant) {
|
if (! $tenant instanceof Tenant) {
|
||||||
$tenantId = $request->attributes->get('tenant_id');
|
$tenantId = $request->attributes->get('tenant_id');
|
||||||
$tenant = Tenant::findOrFail($tenantId);
|
$tenant = Tenant::findOrFail($tenantId);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
$actor = $request->user();
|
||||||
|
$isSuperAdmin = $actor instanceof User && $actor->isSuperAdmin();
|
||||||
|
|
||||||
// Package check is now handled by middleware
|
// Package check is now handled by middleware
|
||||||
|
|
||||||
$validated = $request->validated();
|
$validated = $request->validated();
|
||||||
$tenantId = $tenant->id;
|
$tenantId = $tenant->id;
|
||||||
|
|
||||||
$requestedPackageId = $validated['package_id'] ?? null;
|
$requestedPackageId = $isSuperAdmin ? $request->integer('package_id') : null;
|
||||||
unset($validated['package_id']);
|
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()
|
$tenantPackage = $tenant->tenantPackages()
|
||||||
->with('package')
|
->with('package')
|
||||||
@@ -108,6 +118,22 @@ class EventController extends Controller
|
|||||||
$package = Package::query()->find($requestedPackageId);
|
$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) {
|
if (! $package && $tenantPackage) {
|
||||||
$package = $tenantPackage->package ?? Package::query()->find($tenantPackage->package_id);
|
$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();
|
$requiresWaiver = $package->isEndcustomer();
|
||||||
$latestPurchase = $requiresWaiver ? $this->resolveLatestPackagePurchase($tenant, $package) : null;
|
$latestPurchase = $requiresWaiver ? $this->resolveLatestPackagePurchase($tenant, $package) : null;
|
||||||
$existingWaiver = $latestPurchase ? data_get($latestPurchase->metadata, 'consents.digital_content_waiver_at') : 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')) {
|
if ($needsWaiver && ! $request->boolean('accepted_waiver')) {
|
||||||
throw ValidationException::withMessages([
|
throw ValidationException::withMessages([
|
||||||
@@ -153,8 +184,8 @@ class EventController extends Controller
|
|||||||
unset($eventData['features']);
|
unset($eventData['features']);
|
||||||
}
|
}
|
||||||
|
|
||||||
$settings['branding_allowed'] = $package->branding_allowed !== false;
|
$settings['branding_allowed'] = $eventServicePackage->branding_allowed !== false;
|
||||||
$settings['watermark_allowed'] = $package->watermark_allowed !== false;
|
$settings['watermark_allowed'] = $eventServicePackage->watermark_allowed !== false;
|
||||||
|
|
||||||
$eventData['settings'] = $settings;
|
$eventData['settings'] = $settings;
|
||||||
|
|
||||||
@@ -182,21 +213,23 @@ class EventController extends Controller
|
|||||||
|
|
||||||
$eventData = Arr::only($eventData, $allowed);
|
$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);
|
$event = Event::create($eventData);
|
||||||
|
|
||||||
EventPackage::create([
|
EventPackage::create([
|
||||||
'event_id' => $event->id,
|
'event_id' => $event->id,
|
||||||
'package_id' => $package->id,
|
'package_id' => $eventServicePackage->id,
|
||||||
'purchased_price' => $package->price,
|
'purchased_price' => $billingIsReseller ? 0 : $eventServicePackage->price,
|
||||||
'purchased_at' => now(),
|
'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);
|
$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.');
|
throw new HttpException(402, 'Insufficient package allowance.');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -219,6 +252,47 @@ class EventController extends Controller
|
|||||||
], 201);
|
], 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
|
private function resolveLatestPackagePurchase(Tenant $tenant, Package $package): ?PackagePurchase
|
||||||
{
|
{
|
||||||
return PackagePurchase::query()
|
return PackagePurchase::query()
|
||||||
@@ -229,6 +303,15 @@ class EventController extends Controller
|
|||||||
->first();
|
->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
|
private function recordEventStartWaiver(Tenant $tenant, Package $package, ?PackagePurchase $purchase): void
|
||||||
{
|
{
|
||||||
$timestamp = now();
|
$timestamp = now();
|
||||||
@@ -303,6 +386,8 @@ class EventController extends Controller
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
TenantMemberPermissions::ensureEventPermission($request, $event, 'events:manage');
|
||||||
|
|
||||||
$validated = $request->validated();
|
$validated = $request->validated();
|
||||||
|
|
||||||
if (isset($validated['event_date'])) {
|
if (isset($validated['event_date'])) {
|
||||||
@@ -332,9 +417,14 @@ class EventController extends Controller
|
|||||||
$validated['settings']['watermark_allowed'] = $watermarkAllowed;
|
$validated['settings']['watermark_allowed'] = $watermarkAllowed;
|
||||||
|
|
||||||
$settings = $validated['settings'];
|
$settings = $validated['settings'];
|
||||||
|
$branding = Arr::get($settings, 'branding', []);
|
||||||
$watermark = Arr::get($settings, 'watermark', []);
|
$watermark = Arr::get($settings, 'watermark', []);
|
||||||
$existingWatermark = is_array($watermark) ? $watermark : [];
|
$existingWatermark = is_array($watermark) ? $watermark : [];
|
||||||
|
|
||||||
|
if (is_array($branding)) {
|
||||||
|
$settings['branding'] = $this->normalizeBrandingSettings($branding, $event, $brandingAllowed);
|
||||||
|
}
|
||||||
|
|
||||||
if (is_array($watermark)) {
|
if (is_array($watermark)) {
|
||||||
$mode = $watermark['mode'] ?? 'base';
|
$mode = $watermark['mode'] ?? 'base';
|
||||||
$policy = $watermarkAllowed ? 'basic' : 'none';
|
$policy = $watermarkAllowed ? 'basic' : 'none';
|
||||||
@@ -425,6 +515,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
|
public function destroy(Request $request, Event $event): JsonResponse
|
||||||
{
|
{
|
||||||
$tenantId = $request->attributes->get('tenant_id');
|
$tenantId = $request->attributes->get('tenant_id');
|
||||||
@@ -439,6 +591,8 @@ class EventController extends Controller
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
TenantMemberPermissions::ensureEventPermission($request, $event, 'events:manage');
|
||||||
|
|
||||||
$event->delete();
|
$event->delete();
|
||||||
|
|
||||||
return response()->json([
|
return response()->json([
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ use App\Models\Event;
|
|||||||
use App\Models\GuestNotification;
|
use App\Models\GuestNotification;
|
||||||
use App\Models\GuestPolicySetting;
|
use App\Models\GuestPolicySetting;
|
||||||
use App\Services\GuestNotificationService;
|
use App\Services\GuestNotificationService;
|
||||||
|
use App\Support\TenantMemberPermissions;
|
||||||
use Illuminate\Http\JsonResponse;
|
use Illuminate\Http\JsonResponse;
|
||||||
use Illuminate\Http\Request;
|
use Illuminate\Http\Request;
|
||||||
use Illuminate\Validation\ValidationException;
|
use Illuminate\Validation\ValidationException;
|
||||||
@@ -23,6 +24,7 @@ class EventGuestNotificationController extends Controller
|
|||||||
public function index(Request $request, Event $event): JsonResponse
|
public function index(Request $request, Event $event): JsonResponse
|
||||||
{
|
{
|
||||||
$this->assertEventTenant($request, $event);
|
$this->assertEventTenant($request, $event);
|
||||||
|
TenantMemberPermissions::ensureEventPermission($request, $event, 'guest-notifications:manage');
|
||||||
|
|
||||||
$limit = max(1, min(100, (int) $request->integer('limit', 25)));
|
$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
|
public function store(BroadcastGuestNotificationRequest $request, Event $event): JsonResponse
|
||||||
{
|
{
|
||||||
$this->assertEventTenant($request, $event);
|
$this->assertEventTenant($request, $event);
|
||||||
|
TenantMemberPermissions::ensureEventPermission($request, $event, 'guest-notifications:manage');
|
||||||
|
|
||||||
$data = $request->validated();
|
$data = $request->validated();
|
||||||
|
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ use App\Http\Resources\Tenant\EventJoinTokenResource;
|
|||||||
use App\Models\Event;
|
use App\Models\Event;
|
||||||
use App\Models\EventJoinToken;
|
use App\Models\EventJoinToken;
|
||||||
use App\Services\EventJoinTokenService;
|
use App\Services\EventJoinTokenService;
|
||||||
|
use App\Support\TenantMemberPermissions;
|
||||||
use Illuminate\Http\JsonResponse;
|
use Illuminate\Http\JsonResponse;
|
||||||
use Illuminate\Http\Request;
|
use Illuminate\Http\Request;
|
||||||
use Illuminate\Http\Resources\Json\AnonymousResourceCollection;
|
use Illuminate\Http\Resources\Json\AnonymousResourceCollection;
|
||||||
@@ -19,7 +20,7 @@ class EventJoinTokenController extends Controller
|
|||||||
|
|
||||||
public function index(Request $request, Event $event): AnonymousResourceCollection
|
public function index(Request $request, Event $event): AnonymousResourceCollection
|
||||||
{
|
{
|
||||||
$this->authorizeEvent($request, $event);
|
$this->authorizeEvent($request, $event, 'join-tokens:manage');
|
||||||
|
|
||||||
$tokens = $event->joinTokens()
|
$tokens = $event->joinTokens()
|
||||||
->orderByDesc('created_at')
|
->orderByDesc('created_at')
|
||||||
@@ -30,7 +31,7 @@ class EventJoinTokenController extends Controller
|
|||||||
|
|
||||||
public function store(Request $request, Event $event): JsonResponse
|
public function store(Request $request, Event $event): JsonResponse
|
||||||
{
|
{
|
||||||
$this->authorizeEvent($request, $event);
|
$this->authorizeEvent($request, $event, 'join-tokens:manage');
|
||||||
|
|
||||||
$validated = $this->validatePayload($request);
|
$validated = $this->validatePayload($request);
|
||||||
|
|
||||||
@@ -45,7 +46,7 @@ class EventJoinTokenController extends Controller
|
|||||||
|
|
||||||
public function update(Request $request, Event $event, EventJoinToken $joinToken): EventJoinTokenResource
|
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) {
|
if ($joinToken->event_id !== $event->id) {
|
||||||
abort(404);
|
abort(404);
|
||||||
@@ -89,7 +90,7 @@ class EventJoinTokenController extends Controller
|
|||||||
|
|
||||||
public function destroy(Request $request, Event $event, EventJoinToken $joinToken): EventJoinTokenResource
|
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) {
|
if ($joinToken->event_id !== $event->id) {
|
||||||
abort(404);
|
abort(404);
|
||||||
@@ -101,13 +102,17 @@ class EventJoinTokenController extends Controller
|
|||||||
return new EventJoinTokenResource($token);
|
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');
|
$tenantId = $request->attributes->get('tenant_id');
|
||||||
|
|
||||||
if ($event->tenant_id !== $tenantId) {
|
if ($event->tenant_id !== $tenantId) {
|
||||||
abort(404, 'Event not found');
|
abort(404, 'Event not found');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if ($permission) {
|
||||||
|
TenantMemberPermissions::ensureEventPermission($request, $event, $permission);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private function validatePayload(Request $request, bool $partial = false): array
|
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\Event;
|
||||||
use App\Models\EventJoinToken;
|
use App\Models\EventJoinToken;
|
||||||
use App\Support\JoinTokenLayoutRegistry;
|
use App\Support\JoinTokenLayoutRegistry;
|
||||||
|
use App\Support\TenantMemberPermissions;
|
||||||
use Dompdf\Dompdf;
|
use Dompdf\Dompdf;
|
||||||
use Dompdf\Options;
|
use Dompdf\Options;
|
||||||
use Illuminate\Http\Request;
|
use Illuminate\Http\Request;
|
||||||
@@ -28,6 +29,7 @@ class EventJoinTokenLayoutController extends Controller
|
|||||||
public function index(Request $request, Event $event, EventJoinToken $joinToken)
|
public function index(Request $request, Event $event, EventJoinToken $joinToken)
|
||||||
{
|
{
|
||||||
$this->ensureBelongsToEvent($event, $joinToken);
|
$this->ensureBelongsToEvent($event, $joinToken);
|
||||||
|
TenantMemberPermissions::ensureEventPermission($request, $event, 'join-tokens:manage');
|
||||||
|
|
||||||
$layouts = JoinTokenLayoutRegistry::toResponse(function (string $layoutId, string $format) use ($event, $joinToken) {
|
$layouts = JoinTokenLayoutRegistry::toResponse(function (string $layoutId, string $format) use ($event, $joinToken) {
|
||||||
return route('api.v1.tenant.events.join-tokens.layouts.download', [
|
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)
|
public function download(Request $request, Event $event, EventJoinToken $joinToken, string $layout, string $format)
|
||||||
{
|
{
|
||||||
$this->ensureBelongsToEvent($event, $joinToken);
|
$this->ensureBelongsToEvent($event, $joinToken);
|
||||||
|
TenantMemberPermissions::ensureEventPermission($request, $event, 'join-tokens:manage');
|
||||||
|
|
||||||
$layoutConfig = JoinTokenLayoutRegistry::find($layout);
|
$layoutConfig = JoinTokenLayoutRegistry::find($layout);
|
||||||
|
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ use App\Models\Event;
|
|||||||
use App\Models\EventMember;
|
use App\Models\EventMember;
|
||||||
use App\Models\Tenant;
|
use App\Models\Tenant;
|
||||||
use App\Models\User;
|
use App\Models\User;
|
||||||
|
use App\Support\TenantMemberPermissions;
|
||||||
use Illuminate\Contracts\Pagination\LengthAwarePaginator;
|
use Illuminate\Contracts\Pagination\LengthAwarePaginator;
|
||||||
use Illuminate\Http\JsonResponse;
|
use Illuminate\Http\JsonResponse;
|
||||||
use Illuminate\Http\Request;
|
use Illuminate\Http\Request;
|
||||||
@@ -22,6 +23,7 @@ class EventMemberController extends Controller
|
|||||||
public function index(Request $request, Event $event): JsonResponse
|
public function index(Request $request, Event $event): JsonResponse
|
||||||
{
|
{
|
||||||
$this->assertEventTenant($request, $event);
|
$this->assertEventTenant($request, $event);
|
||||||
|
TenantMemberPermissions::ensureEventPermission($request, $event, 'members:manage');
|
||||||
|
|
||||||
/** @var LengthAwarePaginator $members */
|
/** @var LengthAwarePaginator $members */
|
||||||
$members = $event->members()
|
$members = $event->members()
|
||||||
@@ -34,6 +36,7 @@ class EventMemberController extends Controller
|
|||||||
public function store(EventMemberInviteRequest $request, Event $event): JsonResponse
|
public function store(EventMemberInviteRequest $request, Event $event): JsonResponse
|
||||||
{
|
{
|
||||||
$this->assertEventTenant($request, $event);
|
$this->assertEventTenant($request, $event);
|
||||||
|
TenantMemberPermissions::ensureEventPermission($request, $event, 'members:manage');
|
||||||
|
|
||||||
$data = $request->validated();
|
$data = $request->validated();
|
||||||
$tenant = $this->resolveTenantFromRequest($request);
|
$tenant = $this->resolveTenantFromRequest($request);
|
||||||
@@ -92,6 +95,7 @@ class EventMemberController extends Controller
|
|||||||
public function destroy(Request $request, Event $event, EventMember $member): JsonResponse
|
public function destroy(Request $request, Event $event, EventMember $member): JsonResponse
|
||||||
{
|
{
|
||||||
$this->assertEventTenant($request, $event);
|
$this->assertEventTenant($request, $event);
|
||||||
|
TenantMemberPermissions::ensureEventPermission($request, $event, 'members:manage');
|
||||||
|
|
||||||
if ((int) $member->event_id !== (int) $event->id) {
|
if ((int) $member->event_id !== (int) $event->id) {
|
||||||
throw ValidationException::withMessages([
|
throw ValidationException::withMessages([
|
||||||
@@ -135,7 +139,7 @@ class EventMemberController extends Controller
|
|||||||
$user->password = Hash::make(Str::random(32));
|
$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([
|
throw ValidationException::withMessages([
|
||||||
'email' => __('Dieser Benutzer ist einem anderen Mandanten zugeordnet.'),
|
'email' => __('Dieser Benutzer ist einem anderen Mandanten zugeordnet.'),
|
||||||
]);
|
]);
|
||||||
@@ -143,9 +147,9 @@ class EventMemberController extends Controller
|
|||||||
|
|
||||||
$user->tenant_id = $tenant->id;
|
$user->tenant_id = $tenant->id;
|
||||||
|
|
||||||
if ($role === 'tenant_admin' && $user->role !== 'super_admin') {
|
if ($role === 'tenant_admin' && ! $user->isSuperAdmin()) {
|
||||||
$user->role = 'tenant_admin';
|
$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';
|
$user->role = 'member';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ namespace App\Http\Controllers\Api\Tenant;
|
|||||||
|
|
||||||
use App\Http\Controllers\Controller;
|
use App\Http\Controllers\Controller;
|
||||||
use App\Models\Event;
|
use App\Models\Event;
|
||||||
|
use App\Support\TenantMemberPermissions;
|
||||||
use Illuminate\Http\JsonResponse;
|
use Illuminate\Http\JsonResponse;
|
||||||
use Illuminate\Http\Request;
|
use Illuminate\Http\Request;
|
||||||
use SimpleSoftwareIO\QrCode\Facades\QrCode;
|
use SimpleSoftwareIO\QrCode\Facades\QrCode;
|
||||||
@@ -13,6 +14,7 @@ class LiveShowLinkController extends Controller
|
|||||||
public function show(Request $request, Event $event): JsonResponse
|
public function show(Request $request, Event $event): JsonResponse
|
||||||
{
|
{
|
||||||
$this->authorizeEvent($request, $event);
|
$this->authorizeEvent($request, $event);
|
||||||
|
TenantMemberPermissions::ensureEventPermission($request, $event, 'live-show:manage');
|
||||||
|
|
||||||
$token = $event->ensureLiveShowToken();
|
$token = $event->ensureLiveShowToken();
|
||||||
|
|
||||||
@@ -24,6 +26,7 @@ class LiveShowLinkController extends Controller
|
|||||||
public function rotate(Request $request, Event $event): JsonResponse
|
public function rotate(Request $request, Event $event): JsonResponse
|
||||||
{
|
{
|
||||||
$this->authorizeEvent($request, $event);
|
$this->authorizeEvent($request, $event);
|
||||||
|
TenantMemberPermissions::ensureEventPermission($request, $event, 'live-show:manage');
|
||||||
|
|
||||||
$token = $event->rotateLiveShowToken();
|
$token = $event->rotateLiveShowToken();
|
||||||
|
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ use App\Http\Resources\Tenant\PhotoResource;
|
|||||||
use App\Models\Event;
|
use App\Models\Event;
|
||||||
use App\Models\Photo;
|
use App\Models\Photo;
|
||||||
use App\Support\ApiError;
|
use App\Support\ApiError;
|
||||||
|
use App\Support\TenantMemberPermissions;
|
||||||
use Illuminate\Http\JsonResponse;
|
use Illuminate\Http\JsonResponse;
|
||||||
use Illuminate\Http\Request;
|
use Illuminate\Http\Request;
|
||||||
use Illuminate\Http\Resources\Json\AnonymousResourceCollection;
|
use Illuminate\Http\Resources\Json\AnonymousResourceCollection;
|
||||||
@@ -23,6 +24,7 @@ class LiveShowPhotoController extends Controller
|
|||||||
$event = Event::where('slug', $eventSlug)
|
$event = Event::where('slug', $eventSlug)
|
||||||
->where('tenant_id', $tenantId)
|
->where('tenant_id', $tenantId)
|
||||||
->firstOrFail();
|
->firstOrFail();
|
||||||
|
TenantMemberPermissions::ensureEventPermission($request, $event, 'photos:moderate');
|
||||||
|
|
||||||
$liveStatus = $request->string('live_status', 'pending')->toString();
|
$liveStatus = $request->string('live_status', 'pending')->toString();
|
||||||
$perPage = (int) $request->input('per_page', 20);
|
$perPage = (int) $request->input('per_page', 20);
|
||||||
@@ -51,6 +53,7 @@ class LiveShowPhotoController extends Controller
|
|||||||
$event = Event::where('slug', $eventSlug)
|
$event = Event::where('slug', $eventSlug)
|
||||||
->where('tenant_id', $tenantId)
|
->where('tenant_id', $tenantId)
|
||||||
->firstOrFail();
|
->firstOrFail();
|
||||||
|
TenantMemberPermissions::ensureEventPermission($request, $event, 'photos:moderate');
|
||||||
|
|
||||||
if ($photo->event_id !== $event->id) {
|
if ($photo->event_id !== $event->id) {
|
||||||
return ApiError::response(
|
return ApiError::response(
|
||||||
@@ -94,6 +97,7 @@ class LiveShowPhotoController extends Controller
|
|||||||
$event = Event::where('slug', $eventSlug)
|
$event = Event::where('slug', $eventSlug)
|
||||||
->where('tenant_id', $tenantId)
|
->where('tenant_id', $tenantId)
|
||||||
->firstOrFail();
|
->firstOrFail();
|
||||||
|
TenantMemberPermissions::ensureEventPermission($request, $event, 'photos:moderate');
|
||||||
|
|
||||||
if ($photo->event_id !== $event->id) {
|
if ($photo->event_id !== $event->id) {
|
||||||
return ApiError::response(
|
return ApiError::response(
|
||||||
@@ -146,6 +150,7 @@ class LiveShowPhotoController extends Controller
|
|||||||
$event = Event::where('slug', $eventSlug)
|
$event = Event::where('slug', $eventSlug)
|
||||||
->where('tenant_id', $tenantId)
|
->where('tenant_id', $tenantId)
|
||||||
->firstOrFail();
|
->firstOrFail();
|
||||||
|
TenantMemberPermissions::ensureEventPermission($request, $event, 'photos:moderate');
|
||||||
|
|
||||||
if ($photo->event_id !== $event->id) {
|
if ($photo->event_id !== $event->id) {
|
||||||
return ApiError::response(
|
return ApiError::response(
|
||||||
@@ -173,6 +178,7 @@ class LiveShowPhotoController extends Controller
|
|||||||
$event = Event::where('slug', $eventSlug)
|
$event = Event::where('slug', $eventSlug)
|
||||||
->where('tenant_id', $tenantId)
|
->where('tenant_id', $tenantId)
|
||||||
->firstOrFail();
|
->firstOrFail();
|
||||||
|
TenantMemberPermissions::ensureEventPermission($request, $event, 'photos:moderate');
|
||||||
|
|
||||||
if ($photo->event_id !== $event->id) {
|
if ($photo->event_id !== $event->id) {
|
||||||
return ApiError::response(
|
return ApiError::response(
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ use App\Services\Packages\PackageUsageTracker;
|
|||||||
use App\Services\Storage\EventStorageManager;
|
use App\Services\Storage\EventStorageManager;
|
||||||
use App\Support\ApiError;
|
use App\Support\ApiError;
|
||||||
use App\Support\ImageHelper;
|
use App\Support\ImageHelper;
|
||||||
|
use App\Support\TenantMemberPermissions;
|
||||||
use App\Support\UploadStream;
|
use App\Support\UploadStream;
|
||||||
use App\Support\WatermarkConfigResolver;
|
use App\Support\WatermarkConfigResolver;
|
||||||
use Illuminate\Http\JsonResponse;
|
use Illuminate\Http\JsonResponse;
|
||||||
@@ -524,15 +525,8 @@ class PhotoController extends Controller
|
|||||||
'alt_text' => ['sometimes', 'string', 'max:255'],
|
'alt_text' => ['sometimes', 'string', 'max:255'],
|
||||||
]);
|
]);
|
||||||
|
|
||||||
// Only tenant admins can moderate
|
if (isset($validated['status'])) {
|
||||||
if (isset($validated['status']) && ! $this->tokenHasScope($request, 'tenant:write')) {
|
TenantMemberPermissions::ensureEventPermission($request, $event, 'photos:moderate');
|
||||||
return ApiError::response(
|
|
||||||
'insufficient_scope',
|
|
||||||
'Insufficient Scopes',
|
|
||||||
'You are not allowed to moderate photos for this event.',
|
|
||||||
Response::HTTP_FORBIDDEN,
|
|
||||||
['required_scope' => 'tenant:write']
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
$photo->update($validated);
|
$photo->update($validated);
|
||||||
@@ -634,6 +628,7 @@ class PhotoController extends Controller
|
|||||||
$event = Event::where('slug', $eventSlug)
|
$event = Event::where('slug', $eventSlug)
|
||||||
->where('tenant_id', $tenantId)
|
->where('tenant_id', $tenantId)
|
||||||
->firstOrFail();
|
->firstOrFail();
|
||||||
|
TenantMemberPermissions::ensureEventPermission($request, $event, 'photos:moderate');
|
||||||
|
|
||||||
if ($photo->event_id !== $event->id) {
|
if ($photo->event_id !== $event->id) {
|
||||||
return ApiError::response(
|
return ApiError::response(
|
||||||
@@ -657,6 +652,7 @@ class PhotoController extends Controller
|
|||||||
$event = Event::where('slug', $eventSlug)
|
$event = Event::where('slug', $eventSlug)
|
||||||
->where('tenant_id', $tenantId)
|
->where('tenant_id', $tenantId)
|
||||||
->firstOrFail();
|
->firstOrFail();
|
||||||
|
TenantMemberPermissions::ensureEventPermission($request, $event, 'photos:moderate');
|
||||||
|
|
||||||
if ($photo->event_id !== $event->id) {
|
if ($photo->event_id !== $event->id) {
|
||||||
return ApiError::response(
|
return ApiError::response(
|
||||||
@@ -680,6 +676,7 @@ class PhotoController extends Controller
|
|||||||
$event = Event::where('slug', $eventSlug)
|
$event = Event::where('slug', $eventSlug)
|
||||||
->where('tenant_id', $tenantId)
|
->where('tenant_id', $tenantId)
|
||||||
->firstOrFail();
|
->firstOrFail();
|
||||||
|
TenantMemberPermissions::ensureEventPermission($request, $event, 'photos:moderate');
|
||||||
|
|
||||||
$request->validate([
|
$request->validate([
|
||||||
'photo_ids' => 'required|array',
|
'photo_ids' => 'required|array',
|
||||||
@@ -725,6 +722,7 @@ class PhotoController extends Controller
|
|||||||
$event = Event::where('slug', $eventSlug)
|
$event = Event::where('slug', $eventSlug)
|
||||||
->where('tenant_id', $tenantId)
|
->where('tenant_id', $tenantId)
|
||||||
->firstOrFail();
|
->firstOrFail();
|
||||||
|
TenantMemberPermissions::ensureEventPermission($request, $event, 'photos:moderate');
|
||||||
|
|
||||||
$request->validate([
|
$request->validate([
|
||||||
'photo_ids' => 'required|array',
|
'photo_ids' => 'required|array',
|
||||||
@@ -823,6 +821,11 @@ class PhotoController extends Controller
|
|||||||
|
|
||||||
private function tokenHasScope(Request $request, string $scope): bool
|
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'] ?? []);
|
$scopes = $request->user()->scopes ?? ($request->attributes->get('decoded_token')['scopes'] ?? []);
|
||||||
|
|
||||||
if (! is_array($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;
|
namespace App\Http\Controllers\Api\Tenant;
|
||||||
|
|
||||||
use App\Http\Controllers\Controller;
|
use App\Http\Controllers\Controller;
|
||||||
|
use App\Http\Requests\Photobooth\PhotoboothSendUploaderDownloadRequest;
|
||||||
use App\Http\Resources\Tenant\PhotoboothStatusResource;
|
use App\Http\Resources\Tenant\PhotoboothStatusResource;
|
||||||
|
use App\Mail\PhotoboothUploaderDownload;
|
||||||
use App\Models\Event;
|
use App\Models\Event;
|
||||||
use App\Models\PhotoboothSetting;
|
use App\Models\PhotoboothSetting;
|
||||||
use App\Services\Photobooth\PhotoboothProvisioner;
|
use App\Services\Photobooth\PhotoboothProvisioner;
|
||||||
|
use App\Support\LocaleConfig;
|
||||||
use Illuminate\Http\JsonResponse;
|
use Illuminate\Http\JsonResponse;
|
||||||
use Illuminate\Http\Request;
|
use Illuminate\Http\Request;
|
||||||
|
use Illuminate\Support\Facades\Mail;
|
||||||
|
use Illuminate\Validation\ValidationException;
|
||||||
|
|
||||||
class PhotoboothController extends Controller
|
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
|
protected function resource(Event $event): PhotoboothStatusResource
|
||||||
{
|
{
|
||||||
return PhotoboothStatusResource::make([
|
return PhotoboothStatusResource::make([
|
||||||
@@ -92,4 +130,30 @@ class PhotoboothController extends Controller
|
|||||||
|
|
||||||
return in_array($mode, ['sparkbooth', 'ftp'], true) ? $mode : 'ftp';
|
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 = [
|
$defaultSettings = [
|
||||||
'branding' => [
|
'branding' => [
|
||||||
'logo_url' => null,
|
'logo_url' => null,
|
||||||
'primary_color' => '#3B82F6',
|
'primary_color' => '#FF5A5F',
|
||||||
'secondary_color' => '#1F2937',
|
'secondary_color' => '#FFF8F5',
|
||||||
'font_family' => 'Inter, sans-serif',
|
'font_family' => 'Inter, sans-serif',
|
||||||
],
|
],
|
||||||
'features' => [
|
'features' => [
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ use App\Models\Task;
|
|||||||
use App\Models\TaskCollection;
|
use App\Models\TaskCollection;
|
||||||
use App\Models\Tenant;
|
use App\Models\Tenant;
|
||||||
use App\Support\ApiError;
|
use App\Support\ApiError;
|
||||||
|
use App\Support\TenantMemberPermissions;
|
||||||
use App\Support\TenantRequestResolver;
|
use App\Support\TenantRequestResolver;
|
||||||
use Illuminate\Http\JsonResponse;
|
use Illuminate\Http\JsonResponse;
|
||||||
use Illuminate\Http\Request;
|
use Illuminate\Http\Request;
|
||||||
@@ -66,6 +67,8 @@ class TaskController extends Controller
|
|||||||
*/
|
*/
|
||||||
public function store(TaskStoreRequest $request): JsonResponse
|
public function store(TaskStoreRequest $request): JsonResponse
|
||||||
{
|
{
|
||||||
|
TenantMemberPermissions::ensureTenantPermission($request, 'tasks:manage');
|
||||||
|
|
||||||
$tenant = $this->currentTenant($request);
|
$tenant = $this->currentTenant($request);
|
||||||
$collectionId = $request->input('collection_id');
|
$collectionId = $request->input('collection_id');
|
||||||
$collection = $collectionId ? $this->resolveAccessibleCollection($request, $collectionId) : null;
|
$collection = $collectionId ? $this->resolveAccessibleCollection($request, $collectionId) : null;
|
||||||
@@ -107,6 +110,8 @@ class TaskController extends Controller
|
|||||||
*/
|
*/
|
||||||
public function update(TaskUpdateRequest $request, Task $task): JsonResponse
|
public function update(TaskUpdateRequest $request, Task $task): JsonResponse
|
||||||
{
|
{
|
||||||
|
TenantMemberPermissions::ensureTenantPermission($request, 'tasks:manage');
|
||||||
|
|
||||||
$tenant = $this->currentTenant($request);
|
$tenant = $this->currentTenant($request);
|
||||||
|
|
||||||
if ($task->tenant_id !== $tenant->id) {
|
if ($task->tenant_id !== $tenant->id) {
|
||||||
@@ -138,6 +143,8 @@ class TaskController extends Controller
|
|||||||
*/
|
*/
|
||||||
public function destroy(Request $request, Task $task): JsonResponse
|
public function destroy(Request $request, Task $task): JsonResponse
|
||||||
{
|
{
|
||||||
|
TenantMemberPermissions::ensureTenantPermission($request, 'tasks:manage');
|
||||||
|
|
||||||
if ($task->tenant_id !== $this->currentTenant($request)->id) {
|
if ($task->tenant_id !== $this->currentTenant($request)->id) {
|
||||||
abort(404, 'Task nicht gefunden.');
|
abort(404, 'Task nicht gefunden.');
|
||||||
}
|
}
|
||||||
@@ -154,6 +161,8 @@ class TaskController extends Controller
|
|||||||
*/
|
*/
|
||||||
public function assignToEvent(Request $request, Task $task, Event $event): JsonResponse
|
public function assignToEvent(Request $request, Task $task, Event $event): JsonResponse
|
||||||
{
|
{
|
||||||
|
TenantMemberPermissions::ensureEventPermission($request, $event, 'tasks:manage');
|
||||||
|
|
||||||
$tenantId = $this->currentTenant($request)->id;
|
$tenantId = $this->currentTenant($request)->id;
|
||||||
|
|
||||||
if (($task->tenant_id && $task->tenant_id !== $tenantId) || $event->tenant_id !== $tenantId) {
|
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
|
public function bulkAssignToEvent(Request $request, Event $event): JsonResponse
|
||||||
{
|
{
|
||||||
|
TenantMemberPermissions::ensureEventPermission($request, $event, 'tasks:manage');
|
||||||
|
|
||||||
$tenantId = $this->currentTenant($request)->id;
|
$tenantId = $this->currentTenant($request)->id;
|
||||||
|
|
||||||
if ($event->tenant_id !== $tenantId) {
|
if ($event->tenant_id !== $tenantId) {
|
||||||
@@ -230,6 +241,8 @@ class TaskController extends Controller
|
|||||||
|
|
||||||
public function bulkDetachFromEvent(Request $request, Event $event): JsonResponse
|
public function bulkDetachFromEvent(Request $request, Event $event): JsonResponse
|
||||||
{
|
{
|
||||||
|
TenantMemberPermissions::ensureEventPermission($request, $event, 'tasks:manage');
|
||||||
|
|
||||||
$tenantId = $this->currentTenant($request)->id;
|
$tenantId = $this->currentTenant($request)->id;
|
||||||
|
|
||||||
if ($event->tenant_id !== $tenantId) {
|
if ($event->tenant_id !== $tenantId) {
|
||||||
@@ -256,6 +269,8 @@ class TaskController extends Controller
|
|||||||
|
|
||||||
public function reorderForEvent(Request $request, Event $event): JsonResponse
|
public function reorderForEvent(Request $request, Event $event): JsonResponse
|
||||||
{
|
{
|
||||||
|
TenantMemberPermissions::ensureEventPermission($request, $event, 'tasks:manage');
|
||||||
|
|
||||||
$tenantId = $this->currentTenant($request)->id;
|
$tenantId = $this->currentTenant($request)->id;
|
||||||
|
|
||||||
if ($event->tenant_id !== $tenantId) {
|
if ($event->tenant_id !== $tenantId) {
|
||||||
|
|||||||
@@ -193,11 +193,11 @@ class TenantAdminTokenController extends Controller
|
|||||||
$abilities[] = 'tenant:'.$user->tenant_id;
|
$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';
|
$abilities[] = 'tenant-admin';
|
||||||
}
|
}
|
||||||
|
|
||||||
if ($user->role === 'super_admin') {
|
if ($user->isSuperAdmin()) {
|
||||||
$abilities[] = 'super-admin';
|
$abilities[] = 'super-admin';
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -219,7 +219,7 @@ class TenantAdminTokenController extends Controller
|
|||||||
|
|
||||||
private function ensureUserCanAccessPanel(User $user): void
|
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;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -9,8 +9,8 @@ use App\Models\User;
|
|||||||
use App\Notifications\TenantFeedbackSubmitted;
|
use App\Notifications\TenantFeedbackSubmitted;
|
||||||
use Illuminate\Http\JsonResponse;
|
use Illuminate\Http\JsonResponse;
|
||||||
use Illuminate\Http\Request;
|
use Illuminate\Http\Request;
|
||||||
use Illuminate\Validation\Rule;
|
|
||||||
use Illuminate\Support\Facades\Notification;
|
use Illuminate\Support\Facades\Notification;
|
||||||
|
use Illuminate\Validation\Rule;
|
||||||
|
|
||||||
class TenantFeedbackController extends Controller
|
class TenantFeedbackController extends Controller
|
||||||
{
|
{
|
||||||
@@ -56,7 +56,7 @@ class TenantFeedbackController extends Controller
|
|||||||
]);
|
]);
|
||||||
|
|
||||||
$recipients = User::query()
|
$recipients = User::query()
|
||||||
->where('role', 'super_admin')
|
->whereIn('role', ['super_admin', 'superadmin'])
|
||||||
->whereNotNull('email')
|
->whereNotNull('email')
|
||||||
->get();
|
->get();
|
||||||
|
|
||||||
|
|||||||
@@ -80,7 +80,7 @@ class TenantAdminPasswordResetController extends Controller
|
|||||||
|
|
||||||
private function canAccessEventAdmin(User $user): bool
|
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;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -60,6 +60,7 @@ class TenantPackageController extends Controller
|
|||||||
$pkg?->limits ?? [],
|
$pkg?->limits ?? [],
|
||||||
$this->buildUsageSnapshot($eventPackage),
|
$this->buildUsageSnapshot($eventPackage),
|
||||||
[
|
[
|
||||||
|
'included_package_slug' => $pkg?->included_package_slug,
|
||||||
'branding_allowed' => $pkg?->branding_allowed,
|
'branding_allowed' => $pkg?->branding_allowed,
|
||||||
'watermark_allowed' => $pkg?->watermark_allowed,
|
'watermark_allowed' => $pkg?->watermark_allowed,
|
||||||
'features' => $pkg?->features ?? [],
|
'features' => $pkg?->features ?? [],
|
||||||
|
|||||||
@@ -47,6 +47,15 @@ class AuthenticatedSessionController extends Controller
|
|||||||
|
|
||||||
$user = Auth::user();
|
$user = Auth::user();
|
||||||
if ($user && $user->email_verified_at === null) {
|
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'));
|
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
|
private function decodeReturnTo(string $value, Request $request): ?string
|
||||||
{
|
{
|
||||||
$candidate = $this->decodeBase64Url($value) ?? $value;
|
$candidate = $this->decodeBase64Url($value) ?? $value;
|
||||||
@@ -155,7 +187,7 @@ class AuthenticatedSessionController extends Controller
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Super admins go to Filament superadmin panel
|
// Super admins go to Filament superadmin panel
|
||||||
if ($user && $user->role === 'super_admin') {
|
if ($user && $user->isSuperAdmin()) {
|
||||||
return '/super-admin';
|
return '/super-admin';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -108,8 +108,8 @@ class CheckoutController extends Controller
|
|||||||
'settings' => json_encode([
|
'settings' => json_encode([
|
||||||
'branding' => [
|
'branding' => [
|
||||||
'logo_url' => null,
|
'logo_url' => null,
|
||||||
'primary_color' => '#3B82F6',
|
'primary_color' => '#FF5A5F',
|
||||||
'secondary_color' => '#1F2937',
|
'secondary_color' => '#FFF8F5',
|
||||||
'font_family' => 'Inter, sans-serif',
|
'font_family' => 'Inter, sans-serif',
|
||||||
],
|
],
|
||||||
'features' => [
|
'features' => [
|
||||||
|
|||||||
@@ -146,8 +146,8 @@ class CheckoutGoogleController extends Controller
|
|||||||
'settings' => json_encode([
|
'settings' => json_encode([
|
||||||
'branding' => [
|
'branding' => [
|
||||||
'logo_url' => null,
|
'logo_url' => null,
|
||||||
'primary_color' => '#3B82F6',
|
'primary_color' => '#FF5A5F',
|
||||||
'secondary_color' => '#1F2937',
|
'secondary_color' => '#FFF8F5',
|
||||||
'font_family' => 'Inter, sans-serif',
|
'font_family' => 'Inter, sans-serif',
|
||||||
],
|
],
|
||||||
'features' => [
|
'features' => [
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ class TenantAdminAuthController extends Controller
|
|||||||
$user = Auth::user();
|
$user = Auth::user();
|
||||||
|
|
||||||
// Allow only tenant_admin and super_admin
|
// 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');
|
return view('admin');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -46,7 +46,7 @@ class TenantAdminGoogleController extends Controller
|
|||||||
/** @var User|null $user */
|
/** @var User|null $user */
|
||||||
$user = User::query()->where('email', $email)->first();
|
$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.');
|
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()),
|
'date' => ($validated['date'] ?? Carbon::now()->addWeeks(2)->toDateString()),
|
||||||
'settings' => [
|
'settings' => [
|
||||||
'branding' => [
|
'branding' => [
|
||||||
'primary_color' => '#f43f5e',
|
'primary_color' => '#FF5A5F',
|
||||||
'secondary_color' => '#fb7185',
|
'secondary_color' => '#FFF8F5',
|
||||||
'font_family' => 'Inter, sans-serif',
|
'font_family' => 'Inter, sans-serif',
|
||||||
],
|
],
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
namespace App\Http\Middleware;
|
namespace App\Http\Middleware;
|
||||||
|
|
||||||
use App\Models\Tenant;
|
use App\Models\Tenant;
|
||||||
|
use App\Models\User;
|
||||||
use App\Services\Packages\PackageLimitEvaluator;
|
use App\Services\Packages\PackageLimitEvaluator;
|
||||||
use App\Support\ApiError;
|
use App\Support\ApiError;
|
||||||
use Closure;
|
use Closure;
|
||||||
@@ -26,8 +27,13 @@ class CreditCheckMiddleware
|
|||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
if ($this->requiresCredits($request)) {
|
if ($this->requiresCredits($request) && ! $this->shouldBypassCreditCheck($request, $tenant)) {
|
||||||
$violation = $this->limitEvaluator->assessEventCreation($tenant);
|
$includedSlug = $request->input('service_package_slug');
|
||||||
|
|
||||||
|
$violation = $this->limitEvaluator->assessEventCreation(
|
||||||
|
$tenant,
|
||||||
|
is_string($includedSlug) && $includedSlug !== '' ? $includedSlug : null
|
||||||
|
);
|
||||||
|
|
||||||
if ($violation !== null) {
|
if ($violation !== null) {
|
||||||
return ApiError::response(
|
return ApiError::response(
|
||||||
@@ -43,6 +49,24 @@ class CreditCheckMiddleware
|
|||||||
return $next($request);
|
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
|
private function requiresCredits(Request $request): bool
|
||||||
{
|
{
|
||||||
return $request->isMethod('post')
|
return $request->isMethod('post')
|
||||||
|
|||||||
@@ -42,7 +42,7 @@ class EnsureTenantAdminToken
|
|||||||
/** @var Tenant|null $tenant */
|
/** @var Tenant|null $tenant */
|
||||||
$tenant = $user->tenant;
|
$tenant = $user->tenant;
|
||||||
|
|
||||||
if (! $tenant && $user->role === 'super_admin') {
|
if (! $tenant && $user->isSuperAdmin()) {
|
||||||
$requestedTenantId = $this->resolveRequestedTenantId($request);
|
$requestedTenantId = $this->resolveRequestedTenantId($request);
|
||||||
|
|
||||||
if ($requestedTenantId !== null) {
|
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.');
|
return $this->forbiddenResponse('Tenant context missing for user.');
|
||||||
}
|
}
|
||||||
|
|
||||||
if ($tenant) {
|
if ($tenant) {
|
||||||
$request->attributes->set('tenant_id', $tenant->id);
|
$request->attributes->set('tenant_id', $tenant->id);
|
||||||
$request->attributes->set('tenant', $tenant);
|
$request->attributes->set('tenant', $tenant);
|
||||||
} elseif ($user->role === 'super_admin') {
|
} elseif ($user->isSuperAdmin()) {
|
||||||
$requestedTenantId = $this->resolveRequestedTenantId($request);
|
$requestedTenantId = $this->resolveRequestedTenantId($request);
|
||||||
if ($requestedTenantId !== null) {
|
if ($requestedTenantId !== null) {
|
||||||
$request->attributes->set('tenant_id', $requestedTenantId);
|
$request->attributes->set('tenant_id', $requestedTenantId);
|
||||||
@@ -96,7 +96,7 @@ class EnsureTenantAdminToken
|
|||||||
*/
|
*/
|
||||||
protected function allowedRoles(): array
|
protected function allowedRoles(): array
|
||||||
{
|
{
|
||||||
return ['tenant_admin', 'super_admin', 'admin'];
|
return ['tenant_admin', 'super_admin', 'superadmin', 'admin'];
|
||||||
}
|
}
|
||||||
|
|
||||||
protected function forbiddenRoleMessage(): string
|
protected function forbiddenRoleMessage(): string
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ class EnsureTenantCollaboratorToken extends EnsureTenantAdminToken
|
|||||||
{
|
{
|
||||||
protected function allowedRoles(): array
|
protected function allowedRoles(): array
|
||||||
{
|
{
|
||||||
return ['tenant_admin', 'super_admin', 'admin', 'member'];
|
return ['tenant_admin', 'super_admin', 'superadmin', 'admin', 'member'];
|
||||||
}
|
}
|
||||||
|
|
||||||
protected function forbiddenRoleMessage(): string
|
protected function forbiddenRoleMessage(): string
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
namespace App\Http\Middleware;
|
namespace App\Http\Middleware;
|
||||||
|
|
||||||
use App\Models\Tenant;
|
use App\Models\Tenant;
|
||||||
|
use App\Models\User;
|
||||||
use App\Services\Packages\PackageLimitEvaluator;
|
use App\Services\Packages\PackageLimitEvaluator;
|
||||||
use App\Support\ApiError;
|
use App\Support\ApiError;
|
||||||
use Closure;
|
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);
|
$violation = $this->detectViolation($request, $tenant);
|
||||||
|
|
||||||
if ($violation !== null) {
|
if ($violation !== null) {
|
||||||
@@ -43,6 +44,24 @@ class PackageMiddleware
|
|||||||
return $next($request);
|
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
|
private function requiresPackageCheck(Request $request): bool
|
||||||
{
|
{
|
||||||
return $request->isMethod('post') && (
|
return $request->isMethod('post') && (
|
||||||
@@ -54,7 +73,12 @@ class PackageMiddleware
|
|||||||
private function detectViolation(Request $request, Tenant $tenant): ?array
|
private function detectViolation(Request $request, Tenant $tenant): ?array
|
||||||
{
|
{
|
||||||
if ($request->routeIs('api.v1.tenant.events.store')) {
|
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')) {
|
if ($request->routeIs('api.v1.tenant.events.photos.store')) {
|
||||||
|
|||||||
@@ -112,7 +112,7 @@ class RedirectIfAuthenticated extends BaseMiddleware
|
|||||||
return '/event-admin/dashboard';
|
return '/event-admin/dashboard';
|
||||||
}
|
}
|
||||||
|
|
||||||
if ($user && $user->role === 'super_admin') {
|
if ($user && $user->isSuperAdmin()) {
|
||||||
return '/super-admin';
|
return '/super-admin';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -4,9 +4,9 @@ namespace App\Http\Middleware;
|
|||||||
|
|
||||||
use Closure;
|
use Closure;
|
||||||
use Illuminate\Http\Request;
|
use Illuminate\Http\Request;
|
||||||
use Symfony\Component\HttpFoundation\Response;
|
|
||||||
use Illuminate\Support\Facades\Auth;
|
use Illuminate\Support\Facades\Auth;
|
||||||
use Illuminate\Support\Facades\Log;
|
use Illuminate\Support\Facades\Log;
|
||||||
|
use Symfony\Component\HttpFoundation\Response;
|
||||||
|
|
||||||
class SuperAdminAuth
|
class SuperAdminAuth
|
||||||
{
|
{
|
||||||
@@ -21,17 +21,17 @@ class SuperAdminAuth
|
|||||||
return $next($request);
|
return $next($request);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!Auth::check()) {
|
if (! Auth::check()) {
|
||||||
abort(403, 'Nicht angemeldet.');
|
abort(403, 'Nicht angemeldet.');
|
||||||
}
|
}
|
||||||
|
|
||||||
$user = Auth::user();
|
$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') {
|
if (! $user->isSuperAdmin()) {
|
||||||
abort(403, 'Zugriff nur für SuperAdmin. User ID: ' . $user->id . ', Role: ' . $user->role);
|
abort(403, 'Zugriff nur für SuperAdmin. User ID: '.$user->id.', Role: '.$user->role);
|
||||||
}
|
}
|
||||||
|
|
||||||
return $next($request);
|
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 [];
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -30,6 +30,13 @@ class EventStoreRequest extends FormRequest
|
|||||||
'event_date' => ['required', 'date', 'after_or_equal:today'],
|
'event_date' => ['required', 'date', 'after_or_equal:today'],
|
||||||
'location' => ['nullable', 'string', 'max:255'],
|
'location' => ['nullable', 'string', 'max:255'],
|
||||||
'event_type_id' => ['required', 'exists:event_types,id'],
|
'event_type_id' => ['required', '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'],
|
'max_participants' => ['nullable', 'integer', 'min:1', 'max:10000'],
|
||||||
'public_url' => ['nullable', 'url', 'max:500'],
|
'public_url' => ['nullable', 'url', 'max:500'],
|
||||||
'custom_domain' => ['nullable', 'string', 'max:255'],
|
'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;
|
namespace App\Http\Resources\Tenant;
|
||||||
|
|
||||||
use App\Services\Packages\PackageLimitEvaluator;
|
use App\Services\Packages\PackageLimitEvaluator;
|
||||||
|
use App\Support\TenantMemberPermissions;
|
||||||
use Illuminate\Http\Request;
|
use Illuminate\Http\Request;
|
||||||
use Illuminate\Http\Resources\Json\JsonResource;
|
use Illuminate\Http\Resources\Json\JsonResource;
|
||||||
use Illuminate\Http\Resources\MissingValue;
|
use Illuminate\Http\Resources\MissingValue;
|
||||||
@@ -18,6 +19,12 @@ class EventResource extends JsonResource
|
|||||||
$showSensitive = $this->tenant_id === $tenantId;
|
$showSensitive = $this->tenant_id === $tenantId;
|
||||||
$settings = is_array($this->settings) ? $this->settings : [];
|
$settings = is_array($this->settings) ? $this->settings : [];
|
||||||
$eventPackage = null;
|
$eventPackage = null;
|
||||||
|
$memberPermissions = null;
|
||||||
|
|
||||||
|
$user = $request->user();
|
||||||
|
if ($user && $user->role === 'member') {
|
||||||
|
$memberPermissions = TenantMemberPermissions::resolveEventPermissions($request, $this->resource);
|
||||||
|
}
|
||||||
|
|
||||||
if ($this->relationLoaded('eventPackages')) {
|
if ($this->relationLoaded('eventPackages')) {
|
||||||
$related = $this->getRelation('eventPackages');
|
$related = $this->getRelation('eventPackages');
|
||||||
@@ -86,6 +93,7 @@ class EventResource extends JsonResource
|
|||||||
? $limitEvaluator->summarizeEventPackage($eventPackage)
|
? $limitEvaluator->summarizeEventPackage($eventPackage)
|
||||||
: null,
|
: null,
|
||||||
'addons' => $eventPackage ? $this->formatAddons($eventPackage) : [],
|
'addons' => $eventPackage ? $this->formatAddons($eventPackage) : [],
|
||||||
|
'member_permissions' => $memberPermissions,
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -47,7 +47,7 @@ class PhotoboothStatusResource extends JsonResource
|
|||||||
'password' => $password,
|
'password' => $password,
|
||||||
'path' => $eventSetting?->path,
|
'path' => $eventSetting?->path,
|
||||||
'ftp_url' => $isSparkbooth ? null : $this->buildFtpUrl($eventSetting, $settings, $password),
|
'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(),
|
'expires_at' => optional($activeExpires)->toIso8601String(),
|
||||||
'rate_limit_per_minute' => (int) $settings->rate_limit_per_minute,
|
'rate_limit_per_minute' => (int) $settings->rate_limit_per_minute,
|
||||||
'ftp' => [
|
'ftp' => [
|
||||||
@@ -62,7 +62,7 @@ class PhotoboothStatusResource extends JsonResource
|
|||||||
'username' => $mode === 'sparkbooth' ? $eventSetting?->username : null,
|
'username' => $mode === 'sparkbooth' ? $eventSetting?->username : null,
|
||||||
'password' => $mode === 'sparkbooth' ? $password : null,
|
'password' => $mode === 'sparkbooth' ? $password : null,
|
||||||
'expires_at' => $mode === 'sparkbooth' ? optional($eventSetting?->expires_at)->toIso8601String() : 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'),
|
'response_format' => ($eventSetting?->metadata ?? [])['sparkbooth_response_format'] ?? config('photobooth.sparkbooth.response_format', 'json'),
|
||||||
'metrics' => $sparkMetrics,
|
'metrics' => $sparkMetrics,
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -5,11 +5,19 @@ namespace App\Listeners\GuestNotifications;
|
|||||||
use App\Enums\GuestNotificationAudience;
|
use App\Enums\GuestNotificationAudience;
|
||||||
use App\Enums\GuestNotificationType;
|
use App\Enums\GuestNotificationType;
|
||||||
use App\Events\GuestPhotoUploaded;
|
use App\Events\GuestPhotoUploaded;
|
||||||
|
use App\Models\GuestNotification;
|
||||||
use App\Models\Photo;
|
use App\Models\Photo;
|
||||||
use App\Services\GuestNotificationService;
|
use App\Services\GuestNotificationService;
|
||||||
|
use Illuminate\Support\Carbon;
|
||||||
|
|
||||||
class SendPhotoUploadedNotification
|
class SendPhotoUploadedNotification
|
||||||
{
|
{
|
||||||
|
private const DEDUPE_WINDOW_SECONDS = 30;
|
||||||
|
|
||||||
|
private const GROUP_WINDOW_MINUTES = 10;
|
||||||
|
|
||||||
|
private const MAX_GROUP_PHOTOS = 6;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @param int[] $milestones
|
* @param int[] $milestones
|
||||||
*/
|
*/
|
||||||
@@ -25,7 +33,20 @@ class SendPhotoUploadedNotification
|
|||||||
? sprintf('%s hat gerade ein Foto gemacht 🎉', $guestLabel)
|
? sprintf('%s hat gerade ein Foto gemacht 🎉', $guestLabel)
|
||||||
: 'Es gibt neue Fotos!';
|
: '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,
|
$event->event,
|
||||||
GuestNotificationType::PHOTO_ACTIVITY,
|
GuestNotificationType::PHOTO_ACTIVITY,
|
||||||
$title,
|
$title,
|
||||||
@@ -34,11 +55,15 @@ class SendPhotoUploadedNotification
|
|||||||
'audience_scope' => GuestNotificationAudience::ALL,
|
'audience_scope' => GuestNotificationAudience::ALL,
|
||||||
'payload' => [
|
'payload' => [
|
||||||
'photo_id' => $event->photoId,
|
'photo_id' => $event->photoId,
|
||||||
|
'photo_ids' => [$event->photoId],
|
||||||
|
'count' => 1,
|
||||||
],
|
],
|
||||||
'expires_at' => now()->addHours(3),
|
'expires_at' => now()->addHours(3),
|
||||||
]
|
]
|
||||||
);
|
);
|
||||||
|
|
||||||
|
$this->markUploaderRead($notification, $event->guestIdentifier);
|
||||||
|
|
||||||
$this->maybeCreateMilestoneNotification($event, $guestLabel);
|
$this->maybeCreateMilestoneNotification($event, $guestLabel);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -87,4 +112,94 @@ class SendPhotoUploadedNotification
|
|||||||
|
|
||||||
return $guestIdentifier;
|
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,
|
'per_device_upload_limit' => 50,
|
||||||
'join_token_failure_limit' => (int) config('join_tokens.failure_limit', 10),
|
'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_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_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_download_decay_minutes' => (int) config('join_tokens.download_decay_minutes', 1),
|
||||||
'join_token_ttl_hours' => 168,
|
'join_token_ttl_hours' => 168,
|
||||||
'share_link_ttl_hours' => (int) config('share-links.ttl_hours', 48),
|
'share_link_ttl_hours' => (int) config('share-links.ttl_hours', 48),
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ class Package extends Model
|
|||||||
'name_translations',
|
'name_translations',
|
||||||
'slug',
|
'slug',
|
||||||
'type',
|
'type',
|
||||||
|
'included_package_slug',
|
||||||
'price',
|
'price',
|
||||||
'max_photos',
|
'max_photos',
|
||||||
'max_guests',
|
'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
|
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
|
public function notificationLogs(): HasMany
|
||||||
@@ -151,6 +158,13 @@ class Tenant extends Model
|
|||||||
return false;
|
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
|
public function consumeEventAllowance(int $amount = 1, string $reason = 'event.create', ?string $note = null): bool
|
||||||
{
|
{
|
||||||
$package = $this->getActiveResellerPackage();
|
$package = $this->getActiveResellerPackage();
|
||||||
@@ -183,13 +197,68 @@ class Tenant extends Model
|
|||||||
return false;
|
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
|
public function getActiveResellerPackage(): ?TenantPackage
|
||||||
{
|
{
|
||||||
return $this->activeResellerPackage()
|
return $this->activeResellerPackage()->with('package')->first();
|
||||||
->whereHas('package', fn ($query) => $query->withTrashed()->where('type', 'reseller'))
|
}
|
||||||
|
|
||||||
|
public function getActiveResellerPackageFor(?string $includedPackageSlug): ?TenantPackage
|
||||||
|
{
|
||||||
|
$query = $this->tenantPackages()
|
||||||
|
->with('package')
|
||||||
->where('active', true)
|
->where('active', true)
|
||||||
->orderByDesc('expires_at')
|
->where(function ($query) {
|
||||||
->first();
|
$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
|
public function activeSubscription(): Attribute
|
||||||
|
|||||||
@@ -66,18 +66,30 @@ class TenantPackage extends Model
|
|||||||
return false;
|
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;
|
return $this->used_events < $maxEvents;
|
||||||
}
|
}
|
||||||
|
|
||||||
public function getRemainingEventsAttribute(): int
|
public function getRemainingEventsAttribute(): ?int
|
||||||
{
|
{
|
||||||
if (! $this->package->isReseller()) {
|
if (! $this->package->isReseller()) {
|
||||||
return 0;
|
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);
|
return max(0, $max - $this->used_events);
|
||||||
}
|
}
|
||||||
@@ -94,9 +106,7 @@ class TenantPackage extends Model
|
|||||||
$package = $tenantPackage->package;
|
$package = $tenantPackage->package;
|
||||||
|
|
||||||
if ($package && $package->isReseller()) {
|
if ($package && $package->isReseller()) {
|
||||||
if (! $tenantPackage->expires_at) {
|
// Reseller packages represent prepaid Event-Kontingente and should not expire by default.
|
||||||
$tenantPackage->expires_at = now()->addYear();
|
|
||||||
}
|
|
||||||
} elseif (! $tenantPackage->expires_at) {
|
} elseif (! $tenantPackage->expires_at) {
|
||||||
$tenantPackage->expires_at = now()->addYear();
|
$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.
|
* Retrieve the user by the given credentials.
|
||||||
*/
|
*/
|
||||||
@@ -127,12 +137,12 @@ class User extends Authenticatable implements FilamentHasTenants, FilamentUser,
|
|||||||
|
|
||||||
public function canAccessPanel(Panel $panel): bool
|
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 false;
|
||||||
}
|
}
|
||||||
|
|
||||||
return match ($panel->getId()) {
|
return match ($panel->getId()) {
|
||||||
'superadmin' => $this->role === 'super_admin',
|
'superadmin' => $this->isSuperAdmin(),
|
||||||
'admin' => $this->role === 'tenant_admin',
|
'admin' => $this->role === 'tenant_admin',
|
||||||
default => false,
|
default => false,
|
||||||
};
|
};
|
||||||
@@ -140,7 +150,7 @@ class User extends Authenticatable implements FilamentHasTenants, FilamentUser,
|
|||||||
|
|
||||||
public function canAccessTenant(Model $tenant): bool
|
public function canAccessTenant(Model $tenant): bool
|
||||||
{
|
{
|
||||||
if ($this->role === 'super_admin') {
|
if ($this->isSuperAdmin()) {
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -155,7 +165,7 @@ class User extends Authenticatable implements FilamentHasTenants, FilamentUser,
|
|||||||
|
|
||||||
public function getTenants(Panel $panel): array|Collection
|
public function getTenants(Panel $panel): array|Collection
|
||||||
{
|
{
|
||||||
if ($this->role === 'super_admin') {
|
if ($this->isSuperAdmin()) {
|
||||||
return Tenant::query()->orderBy('name')->get();
|
return Tenant::query()->orderBy('name')->get();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -12,12 +12,11 @@ class PurchaseHistoryPolicy
|
|||||||
|
|
||||||
public function viewAny(User $user): bool
|
public function viewAny(User $user): bool
|
||||||
{
|
{
|
||||||
return $user->role === 'super_admin';
|
return $user->isSuperAdmin();
|
||||||
}
|
}
|
||||||
|
|
||||||
public function view(User $user, PurchaseHistory $purchaseHistory): bool
|
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
|
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
|
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
|
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
|
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
|
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'));
|
$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) {
|
RateLimiter::for('tenant-auth', function (Request $request) {
|
||||||
|
|||||||
@@ -46,7 +46,7 @@ class AuthServiceProvider extends ServiceProvider
|
|||||||
});
|
});
|
||||||
|
|
||||||
Gate::before(function (User $user): ?bool {
|
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
|
private function shouldLog(?User $actor): bool
|
||||||
{
|
{
|
||||||
if (! $actor || $actor->role !== 'super_admin') {
|
if (! $actor || ! $actor->isSuperAdmin()) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -94,18 +94,34 @@ class CheckoutAssignmentService
|
|||||||
]
|
]
|
||||||
);
|
);
|
||||||
|
|
||||||
$tenantPackage = TenantPackage::updateOrCreate(
|
if ($package->type === 'reseller') {
|
||||||
[
|
$tenantPackage = null;
|
||||||
'tenant_id' => $tenant->id,
|
|
||||||
'package_id' => $package->id,
|
if ($purchase->wasRecentlyCreated) {
|
||||||
],
|
$tenantPackage = TenantPackage::create([
|
||||||
[
|
'tenant_id' => $tenant->id,
|
||||||
'price' => round($price, 2),
|
'package_id' => $package->id,
|
||||||
'active' => true,
|
'price' => round($price, 2),
|
||||||
'purchased_at' => now(),
|
'active' => true,
|
||||||
'expires_at' => $this->resolveExpiry($package, $tenant),
|
'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') {
|
if ($package->type !== 'reseller') {
|
||||||
$tenant->forceFill([
|
$tenant->forceFill([
|
||||||
@@ -188,11 +204,7 @@ class CheckoutAssignmentService
|
|||||||
protected function resolveExpiry(Package $package, Tenant $tenant)
|
protected function resolveExpiry(Package $package, Tenant $tenant)
|
||||||
{
|
{
|
||||||
if ($package->type === 'reseller') {
|
if ($package->type === 'reseller') {
|
||||||
$hasActive = TenantPackage::where('tenant_id', $tenant->id)
|
return null;
|
||||||
->where('active', true)
|
|
||||||
->exists();
|
|
||||||
|
|
||||||
return $hasActive ? now()->addYear() : now()->addDays(14);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return now()->addYear();
|
return now()->addYear();
|
||||||
|
|||||||
@@ -126,6 +126,36 @@ class GuestNotificationService
|
|||||||
return null;
|
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');
|
$cta = Arr::get($payload, 'cta');
|
||||||
if (is_array($cta)) {
|
if (is_array($cta)) {
|
||||||
$cta = [
|
$cta = [
|
||||||
@@ -142,6 +172,9 @@ class GuestNotificationService
|
|||||||
|
|
||||||
$clean = array_filter([
|
$clean = array_filter([
|
||||||
'cta' => $cta,
|
'cta' => $cta,
|
||||||
|
'photo_id' => $photoId,
|
||||||
|
'photo_ids' => $photoIds,
|
||||||
|
'count' => $count,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
return $clean === [] ? null : $clean;
|
return $clean === [] ? null : $clean;
|
||||||
|
|||||||
@@ -77,6 +77,8 @@ class HelpSyncService
|
|||||||
foreach ($articles->groupBy(fn ($article) => $article['audience'].'::'.$article['locale']) as $key => $group) {
|
foreach ($articles->groupBy(fn ($article) => $article['audience'].'::'.$article['locale']) as $key => $group) {
|
||||||
[$audience, $locale] = explode('::', $key);
|
[$audience, $locale] = explode('::', $key);
|
||||||
$path = sprintf('%s/%s/%s/articles.json', $compiledPath, $audience, $locale);
|
$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));
|
Storage::disk($disk)->put($path, $group->values()->toJson(JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE));
|
||||||
Cache::forget($this->cacheKey($audience, $locale));
|
Cache::forget($this->cacheKey($audience, $locale));
|
||||||
$written[$audience][$locale] = $group->count();
|
$written[$audience][$locale] = $group->count();
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ class PackageLimitEvaluator
|
|||||||
{
|
{
|
||||||
public function __construct(private readonly TenantUsageService $tenantUsageService) {}
|
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()
|
$hasEndcustomerPackage = $tenant->tenantPackages()
|
||||||
->where('active', true)
|
->where('active', true)
|
||||||
@@ -22,17 +22,66 @@ class PackageLimitEvaluator
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
if ($tenant->hasEventAllowance()) {
|
if ($tenant->hasEventAllowanceFor($includedPackageSlug)) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
$package = $tenant->getActiveResellerPackage();
|
$package = $tenant->getActiveResellerPackageFor($includedPackageSlug);
|
||||||
|
|
||||||
if (! $package) {
|
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 [
|
return [
|
||||||
'code' => 'event_limit_missing',
|
'code' => 'event_limit_missing',
|
||||||
'title' => 'No package assigned',
|
'title' => __('api.packages.event_limit_missing.title'),
|
||||||
'message' => 'Assign a package or addon to create events.',
|
'message' => __('api.packages.event_limit_missing.message'),
|
||||||
'status' => 402,
|
'status' => 402,
|
||||||
'meta' => [
|
'meta' => [
|
||||||
'scope' => 'events',
|
'scope' => 'events',
|
||||||
@@ -49,8 +98,8 @@ class PackageLimitEvaluator
|
|||||||
|
|
||||||
return [
|
return [
|
||||||
'code' => 'event_limit_exceeded',
|
'code' => 'event_limit_exceeded',
|
||||||
'title' => 'Event quota reached',
|
'title' => __('api.packages.event_limit_exceeded.title'),
|
||||||
'message' => 'Your current package has no remaining event slots. Please upgrade or renew your subscription.',
|
'message' => __('api.packages.event_limit_exceeded.message'),
|
||||||
'status' => 402,
|
'status' => 402,
|
||||||
'meta' => [
|
'meta' => [
|
||||||
'scope' => 'events',
|
'scope' => 'events',
|
||||||
@@ -74,8 +123,8 @@ class PackageLimitEvaluator
|
|||||||
if (! $event) {
|
if (! $event) {
|
||||||
return [
|
return [
|
||||||
'code' => 'event_not_found',
|
'code' => 'event_not_found',
|
||||||
'title' => 'Event not accessible',
|
'title' => __('api.packages.event_not_found.title'),
|
||||||
'message' => 'The selected event could not be found or belongs to another tenant.',
|
'message' => __('api.packages.event_not_found.message'),
|
||||||
'status' => 404,
|
'status' => 404,
|
||||||
'meta' => [
|
'meta' => [
|
||||||
'scope' => 'photos',
|
'scope' => 'photos',
|
||||||
@@ -87,8 +136,8 @@ class PackageLimitEvaluator
|
|||||||
if (! $eventPackage || ! $eventPackage->package) {
|
if (! $eventPackage || ! $eventPackage->package) {
|
||||||
return [
|
return [
|
||||||
'code' => 'event_package_missing',
|
'code' => 'event_package_missing',
|
||||||
'title' => 'Event package missing',
|
'title' => __('api.packages.event_package_missing.title'),
|
||||||
'message' => 'No package is attached to this event. Assign a package to enable uploads.',
|
'message' => __('api.packages.event_package_missing.message'),
|
||||||
'status' => 409,
|
'status' => 409,
|
||||||
'meta' => [
|
'meta' => [
|
||||||
'scope' => 'photos',
|
'scope' => 'photos',
|
||||||
@@ -102,8 +151,8 @@ class PackageLimitEvaluator
|
|||||||
if ($maxPhotos !== null && $eventPackage->used_photos >= $maxPhotos) {
|
if ($maxPhotos !== null && $eventPackage->used_photos >= $maxPhotos) {
|
||||||
return [
|
return [
|
||||||
'code' => 'photo_limit_exceeded',
|
'code' => 'photo_limit_exceeded',
|
||||||
'title' => 'Photo upload limit reached',
|
'title' => __('api.packages.photo_limit_exceeded.title'),
|
||||||
'message' => 'This event has reached its photo allowance. Upgrade the event package to accept more uploads.',
|
'message' => __('api.packages.photo_limit_exceeded.message'),
|
||||||
'status' => 402,
|
'status' => 402,
|
||||||
'meta' => [
|
'meta' => [
|
||||||
'scope' => 'photos',
|
'scope' => 'photos',
|
||||||
@@ -122,8 +171,8 @@ class PackageLimitEvaluator
|
|||||||
if ($eventPackage->used_photos >= $tenantPhotoLimit) {
|
if ($eventPackage->used_photos >= $tenantPhotoLimit) {
|
||||||
return [
|
return [
|
||||||
'code' => 'tenant_photo_limit_exceeded',
|
'code' => 'tenant_photo_limit_exceeded',
|
||||||
'title' => 'Tenant photo limit reached',
|
'title' => __('api.packages.tenant_photo_limit_exceeded.title'),
|
||||||
'message' => 'This tenant has reached its photo allowance for the event.',
|
'message' => __('api.packages.tenant_photo_limit_exceeded.message'),
|
||||||
'status' => 402,
|
'status' => 402,
|
||||||
'meta' => [
|
'meta' => [
|
||||||
'scope' => 'photos',
|
'scope' => 'photos',
|
||||||
@@ -146,8 +195,8 @@ class PackageLimitEvaluator
|
|||||||
if ($projectedBytes >= $storageLimitBytes) {
|
if ($projectedBytes >= $storageLimitBytes) {
|
||||||
return [
|
return [
|
||||||
'code' => 'tenant_storage_limit_exceeded',
|
'code' => 'tenant_storage_limit_exceeded',
|
||||||
'title' => 'Tenant storage limit reached',
|
'title' => __('api.packages.tenant_storage_limit_exceeded.title'),
|
||||||
'message' => 'This tenant has reached its storage allowance.',
|
'message' => __('api.packages.tenant_storage_limit_exceeded.message'),
|
||||||
'status' => 402,
|
'status' => 402,
|
||||||
'meta' => [
|
'meta' => [
|
||||||
'scope' => 'storage',
|
'scope' => 'storage',
|
||||||
|
|||||||
@@ -4,7 +4,6 @@ namespace App\Services\Packages;
|
|||||||
|
|
||||||
use App\Events\Packages\TenantPackageEventLimitReached;
|
use App\Events\Packages\TenantPackageEventLimitReached;
|
||||||
use App\Events\Packages\TenantPackageEventThresholdReached;
|
use App\Events\Packages\TenantPackageEventThresholdReached;
|
||||||
use App\Models\Tenant;
|
|
||||||
use App\Models\TenantPackage;
|
use App\Models\TenantPackage;
|
||||||
use Illuminate\Contracts\Events\Dispatcher;
|
use Illuminate\Contracts\Events\Dispatcher;
|
||||||
|
|
||||||
@@ -63,6 +62,12 @@ class TenantUsageTracker
|
|||||||
}
|
}
|
||||||
|
|
||||||
$this->dispatcher->dispatch(new TenantPackageEventLimitReached($tenantPackage, $limit));
|
$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
|
public function createDiscount(Coupon $coupon): array
|
||||||
{
|
{
|
||||||
|
$existing = $this->findExistingDiscount($coupon->code);
|
||||||
|
if ($existing !== null) {
|
||||||
|
return $existing;
|
||||||
|
}
|
||||||
|
|
||||||
$payload = $this->buildDiscountPayload($coupon);
|
$payload = $this->buildDiscountPayload($coupon);
|
||||||
|
|
||||||
$response = $this->client->post('/discounts', $payload);
|
$response = $this->client->post('/discounts', $payload);
|
||||||
@@ -82,6 +87,35 @@ class PaddleDiscountService
|
|||||||
return Arr::get($response, 'data', $response);
|
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>
|
* @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,
|
'name' => $name,
|
||||||
'slug' => $package->slug,
|
'slug' => $package->slug,
|
||||||
'type' => $package->type,
|
'type' => $package->type,
|
||||||
|
'included_package_slug' => $package->included_package_slug,
|
||||||
'price' => $package->price,
|
'price' => $package->price,
|
||||||
'paddle_product_id' => $package->paddle_product_id,
|
'paddle_product_id' => $package->paddle_product_id,
|
||||||
'paddle_price_id' => $package->paddle_price_id,
|
'paddle_price_id' => $package->paddle_price_id,
|
||||||
|
|||||||
@@ -24,15 +24,15 @@ class TenantAuth
|
|||||||
}
|
}
|
||||||
|
|
||||||
$user = $request->user();
|
$user = $request->user();
|
||||||
if ($user && in_array($user->role, ['tenant_admin', 'admin', 'super_admin', 'member'], true)) {
|
if ($user && in_array($user->role, ['tenant_admin', 'admin', 'super_admin', 'superadmin', 'member'], true)) {
|
||||||
if ($user->role !== 'super_admin' || (int) $user->tenant_id === (int) $tenantId) {
|
if (! $user->isSuperAdmin() || (int) $user->tenant_id === (int) $tenantId) {
|
||||||
return $user;
|
return $user;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
$user = User::query()
|
$user = User::query()
|
||||||
->where('tenant_id', $tenantId)
|
->where('tenant_id', $tenantId)
|
||||||
->whereIn('role', ['tenant_admin', 'admin', 'member'])
|
->whereIn('role', ['tenant_admin', 'admin', 'super_admin', 'superadmin', 'member'])
|
||||||
->orderByDesc('email_verified_at')
|
->orderByDesc('email_verified_at')
|
||||||
->orderBy('id')
|
->orderBy('id')
|
||||||
->first();
|
->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",
|
"simplesoftwareio/simple-qrcode": "^4.2",
|
||||||
"spatie/laravel-translatable": "^6.11",
|
"spatie/laravel-translatable": "^6.11",
|
||||||
"staudenmeir/belongs-to-through": "^2.17",
|
"staudenmeir/belongs-to-through": "^2.17",
|
||||||
"stripe/stripe-php": "*"
|
"stripe/stripe-php": "*",
|
||||||
|
"symfony/yaml": "^7.0"
|
||||||
},
|
},
|
||||||
"require-dev": {
|
"require-dev": {
|
||||||
"fakerphp/faker": "^1.23",
|
"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",
|
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
|
||||||
"This file is @generated automatically"
|
"This file is @generated automatically"
|
||||||
],
|
],
|
||||||
"content-hash": "c1a772e5fe6f8d5c92fdbbea232f9f78",
|
"content-hash": "5e1d60e650853d6113b01e1adaf49d65",
|
||||||
"packages": [
|
"packages": [
|
||||||
{
|
{
|
||||||
"name": "anourvalar/eloquent-serialize",
|
"name": "anourvalar/eloquent-serialize",
|
||||||
@@ -10043,6 +10043,82 @@
|
|||||||
],
|
],
|
||||||
"time": "2025-10-27T20:36:44+00:00"
|
"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",
|
"name": "tijsverkoyen/css-to-inline-styles",
|
||||||
"version": "v2.3.0",
|
"version": "v2.3.0",
|
||||||
@@ -12852,82 +12928,6 @@
|
|||||||
],
|
],
|
||||||
"time": "2024-10-20T05:08:20+00:00"
|
"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",
|
"name": "theseer/tokenizer",
|
||||||
"version": "1.3.1",
|
"version": "1.3.1",
|
||||||
|
|||||||
@@ -4,9 +4,9 @@ return [
|
|||||||
'failure_limit' => (int) env('JOIN_TOKEN_FAILURE_LIMIT', 10),
|
'failure_limit' => (int) env('JOIN_TOKEN_FAILURE_LIMIT', 10),
|
||||||
'failure_decay_minutes' => (int) env('JOIN_TOKEN_FAILURE_DECAY', 5),
|
'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),
|
'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),
|
'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)),
|
'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'),
|
'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([
|
'settings' => json_encode([
|
||||||
'branding' => [
|
'branding' => [
|
||||||
'logo_url' => null,
|
'logo_url' => null,
|
||||||
'primary_color' => '#3B82F6',
|
'primary_color' => '#FF5A5F',
|
||||||
'secondary_color' => '#1F2937',
|
'secondary_color' => '#FFF8F5',
|
||||||
'font_family' => 'Inter, sans-serif',
|
'font_family' => 'Inter, sans-serif',
|
||||||
],
|
],
|
||||||
'features' => [
|
'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'],
|
'collection_slugs' => ['wedding-classics-2025'],
|
||||||
'task_slug_prefix' => 'wedding-',
|
'task_slug_prefix' => 'wedding-',
|
||||||
'branding' => [
|
'branding' => [
|
||||||
'primary_color' => '#f43f5e',
|
'primary_color' => '#FF5A5F',
|
||||||
'secondary_color' => '#fb7185',
|
'secondary_color' => '#FFF8F5',
|
||||||
'background_color' => '#fff7f4',
|
'background_color' => '#FFF8F5',
|
||||||
'font_family' => 'Playfair Display, serif',
|
'font_family' => 'Playfair Display, serif',
|
||||||
],
|
],
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -76,8 +76,8 @@ class DemoTenantSeeder extends Seeder
|
|||||||
'contact_email' => $user->email,
|
'contact_email' => $user->email,
|
||||||
'branding' => [
|
'branding' => [
|
||||||
'logo_url' => null,
|
'logo_url' => null,
|
||||||
'primary_color' => '#f43f5e',
|
'primary_color' => '#FF5A5F',
|
||||||
'secondary_color' => '#1f2937',
|
'secondary_color' => '#FFF8F5',
|
||||||
],
|
],
|
||||||
],
|
],
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -2,9 +2,9 @@
|
|||||||
|
|
||||||
namespace Database\Seeders;
|
namespace Database\Seeders;
|
||||||
|
|
||||||
use Illuminate\Database\Seeder;
|
|
||||||
use App\Models\Package;
|
|
||||||
use App\Enums\PackageType;
|
use App\Enums\PackageType;
|
||||||
|
use App\Models\Package;
|
||||||
|
use Illuminate\Database\Seeder;
|
||||||
|
|
||||||
class PackageSeeder extends Seeder
|
class PackageSeeder extends Seeder
|
||||||
{
|
{
|
||||||
@@ -14,7 +14,7 @@ class PackageSeeder extends Seeder
|
|||||||
public function run(): void
|
public function run(): void
|
||||||
{
|
{
|
||||||
$packages = [
|
$packages = [
|
||||||
|
|
||||||
[
|
[
|
||||||
'slug' => 'starter',
|
'slug' => 'starter',
|
||||||
'name' => 'Starter',
|
'name' => 'Starter',
|
||||||
@@ -28,12 +28,13 @@ class PackageSeeder extends Seeder
|
|||||||
'max_guests' => 100,
|
'max_guests' => 100,
|
||||||
'gallery_days' => 180,
|
'gallery_days' => 180,
|
||||||
'max_tasks' => 30,
|
'max_tasks' => 30,
|
||||||
|
'max_events_per_year' => 1,
|
||||||
'watermark_allowed' => true,
|
'watermark_allowed' => true,
|
||||||
'branding_allowed' => false,
|
'branding_allowed' => false,
|
||||||
'features' => ['basic_uploads', 'limited_sharing', 'custom_tasks'],
|
'features' => ['basic_uploads', 'limited_sharing', 'custom_tasks'],
|
||||||
'paddle_product_id' => 'pro_01k8jcxx2g1vj9snqbga4283ej',
|
'paddle_product_id' => 'pro_01k8jcxx2g1vj9snqbga4283ej',
|
||||||
'paddle_price_id' => 'pri_01k8jcxx8qktxvqzzv0nkjjj27',
|
'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.
|
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,
|
TEXT,
|
||||||
'description_translations' => [
|
'description_translations' => [
|
||||||
@@ -61,12 +62,12 @@ TEXT,
|
|||||||
'max_guests' => 250,
|
'max_guests' => 250,
|
||||||
'gallery_days' => 365,
|
'gallery_days' => 365,
|
||||||
'max_tasks' => 100,
|
'max_tasks' => 100,
|
||||||
'watermark_allowed' => false,
|
'watermark_allowed' => true,
|
||||||
'branding_allowed' => true,
|
'branding_allowed' => true,
|
||||||
'features' => ['basic_uploads', 'unlimited_sharing', 'custom_branding', 'custom_tasks', 'live_slideshow'],
|
'features' => ['basic_uploads', 'unlimited_sharing', 'custom_branding', 'custom_tasks', 'live_slideshow'],
|
||||||
'paddle_product_id' => 'pro_01k8jcxwjv4ne8vf9pvd9bye3j',
|
'paddle_product_id' => 'pro_01k8jcxwjv4ne8vf9pvd9bye3j',
|
||||||
'paddle_price_id' => 'pri_01k8jcxws51pze5xc3vj2ea0yc',
|
'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.
|
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,
|
TEXT,
|
||||||
'description_translations' => [
|
'description_translations' => [
|
||||||
@@ -99,12 +100,12 @@ TEXT,
|
|||||||
'features' => ['basic_uploads', 'unlimited_sharing', 'custom_branding', 'custom_tasks', 'live_slideshow', 'advanced_analytics', 'priority_support'],
|
'features' => ['basic_uploads', 'unlimited_sharing', 'custom_branding', 'custom_tasks', 'live_slideshow', 'advanced_analytics', 'priority_support'],
|
||||||
'paddle_product_id' => 'pro_01k8jcxvwp38gay6jj2akjg76s',
|
'paddle_product_id' => 'pro_01k8jcxvwp38gay6jj2akjg76s',
|
||||||
'paddle_price_id' => 'pri_01k8jcxw5sap4r306wcvc0ephy',
|
'paddle_price_id' => 'pri_01k8jcxw5sap4r306wcvc0ephy',
|
||||||
'description' => <<<TEXT
|
'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.
|
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,
|
TEXT,
|
||||||
'description_translations' => [
|
'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.',
|
'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—no watermark, live slideshow and premium support included.',
|
'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' => [
|
'description_table' => [
|
||||||
['title' => 'Fotos', 'value' => '{{max_photos}}'],
|
['title' => 'Fotos', 'value' => '{{max_photos}}'],
|
||||||
@@ -116,111 +117,149 @@ TEXT,
|
|||||||
],
|
],
|
||||||
[
|
[
|
||||||
'slug' => 's-small-reseller',
|
'slug' => 's-small-reseller',
|
||||||
'name' => 'Reseller S',
|
'name' => 'Partner Start',
|
||||||
'name_translations' => [
|
'name_translations' => [
|
||||||
'de' => 'Reseller S',
|
'de' => 'Partner Start',
|
||||||
'en' => 'Reseller S',
|
'en' => 'Partner Start',
|
||||||
],
|
],
|
||||||
'type' => PackageType::RESELLER,
|
'type' => PackageType::RESELLER,
|
||||||
|
'included_package_slug' => 'starter',
|
||||||
'price' => 149.00,
|
'price' => 149.00,
|
||||||
'max_photos' => 1000,
|
'max_photos' => null,
|
||||||
'max_guests' => null,
|
'max_guests' => null,
|
||||||
'gallery_days' => 30,
|
'gallery_days' => null,
|
||||||
'max_tasks' => null,
|
'max_tasks' => null,
|
||||||
'watermark_allowed' => true,
|
'watermark_allowed' => true,
|
||||||
'branding_allowed' => true,
|
'branding_allowed' => true,
|
||||||
'max_events_per_year' => 5,
|
'max_events_per_year' => 5,
|
||||||
'expires_after' => now()->copy()->addYear(),
|
'expires_after' => null,
|
||||||
'features' => ['reseller_dashboard', 'custom_branding', 'priority_support'],
|
'features' => ['reseller_dashboard', 'custom_branding', 'priority_support'],
|
||||||
'paddle_product_id' => 'pro_01k8jcxvax48mhmwsfydw8ha9y',
|
'paddle_product_id' => 'pro_01k8jcxvax48mhmwsfydw8ha9y',
|
||||||
'paddle_price_id' => 'pri_01k8jcxvhe0bfasg9gg1rw70sy',
|
'paddle_price_id' => 'pri_01k8jcxvhe0bfasg9gg1rw70sy',
|
||||||
'description' => <<<TEXT
|
'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.
|
Event-Kontingent für Partner / Agenturen: {{max_events_per_year}} Events auf Starter‑Niveau. Empfohlen innerhalb von 24 Monaten zu nutzen.
|
||||||
TEXT,
|
TEXT,
|
||||||
'description_translations' => [
|
'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.',
|
'de' => 'Event-Kontingent für Partner / Agenturen: {{max_events_per_year}} Events auf Starter‑Niveau. Empfohlen innerhalb von 24 Monaten zu nutzen.',
|
||||||
'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.',
|
'en' => 'Event-Kontingent for Partner / Agencies: {{max_events_per_year}} events at Starter level. Recommended to use within 24 months.',
|
||||||
],
|
],
|
||||||
'description_table' => [
|
'description_table' => [
|
||||||
['title' => 'Events/Jahr', 'value' => '{{max_events_per_year}} Events'],
|
['title' => 'Events', 'value' => '{{max_events_per_year}} Events'],
|
||||||
['title' => 'Galerie', 'value' => '{{gallery_duration}}'],
|
['title' => 'Inklusive Event-Level', 'value' => 'Starter'],
|
||||||
['title' => 'Branding', 'value' => 'Logo & Farben pro Event'],
|
['title' => 'Empfehlung', 'value' => 'Empfohlen innerhalb von 24 Monaten zu nutzen.'],
|
||||||
],
|
],
|
||||||
],
|
],
|
||||||
[
|
[
|
||||||
'slug' => 'm-medium-reseller',
|
'slug' => 'm-medium-reseller',
|
||||||
'name' => 'Reseller M',
|
'name' => 'Partner Standard',
|
||||||
'name_translations' => [
|
'name_translations' => [
|
||||||
'de' => 'Reseller M',
|
'de' => 'Partner Standard',
|
||||||
'en' => 'Reseller M',
|
'en' => 'Partner Standard',
|
||||||
],
|
],
|
||||||
'type' => PackageType::RESELLER,
|
'type' => PackageType::RESELLER,
|
||||||
|
'included_package_slug' => 'standard',
|
||||||
'price' => 349.00,
|
'price' => 349.00,
|
||||||
'max_photos' => 1500,
|
'max_photos' => null,
|
||||||
'max_guests' => null,
|
'max_guests' => null,
|
||||||
'gallery_days' => 60,
|
'gallery_days' => null,
|
||||||
'max_tasks' => null,
|
'max_tasks' => null,
|
||||||
'watermark_allowed' => true,
|
'watermark_allowed' => true,
|
||||||
'branding_allowed' => true,
|
'branding_allowed' => true,
|
||||||
'max_events_per_year' => 15,
|
'max_events_per_year' => 15,
|
||||||
'expires_after' => now()->copy()->addYear(),
|
'expires_after' => null,
|
||||||
'features' => ['reseller_dashboard', 'custom_branding', 'priority_support', 'advanced_reporting'],
|
'features' => ['reseller_dashboard', 'custom_branding', 'priority_support', 'advanced_reporting'],
|
||||||
'paddle_product_id' => 'pro_01k8jcxtrxw7jsew52jnax901q',
|
'paddle_product_id' => 'pro_01k8jcxtrxw7jsew52jnax901q',
|
||||||
'paddle_price_id' => 'pri_01k8jcxv06nsgy8ym8mnfrfm5v',
|
'paddle_price_id' => 'pri_01k8jcxv06nsgy8ym8mnfrfm5v',
|
||||||
'description' => <<<TEXT
|
'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.
|
Event-Kontingent für Partner / Agenturen: {{max_events_per_year}} Events auf Standard‑Niveau. Empfohlen innerhalb von 24 Monaten zu nutzen.
|
||||||
TEXT,
|
TEXT,
|
||||||
'description_translations' => [
|
'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.',
|
'de' => 'Event-Kontingent für Partner / Agenturen: {{max_events_per_year}} Events auf Standard‑Niveau. Empfohlen innerhalb von 24 Monaten zu nutzen.',
|
||||||
'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.',
|
'en' => 'Event-Kontingent for Partner / Agencies: {{max_events_per_year}} events at Standard level. Recommended to use within 24 months.',
|
||||||
],
|
],
|
||||||
'description_table' => [
|
'description_table' => [
|
||||||
['title' => 'Events/Jahr', 'value' => '{{max_events_per_year}} Events'],
|
['title' => 'Events', 'value' => '{{max_events_per_year}} Events'],
|
||||||
['title' => 'Galerie', 'value' => '{{gallery_duration}}'],
|
['title' => 'Inklusive Event-Level', 'value' => 'Standard'],
|
||||||
['title' => 'Reporting', 'value' => 'Erweiterte Auswertungen'],
|
['title' => 'Empfehlung', 'value' => 'Empfohlen innerhalb von 24 Monaten zu nutzen.'],
|
||||||
],
|
],
|
||||||
],
|
],
|
||||||
[
|
[
|
||||||
'slug' => 'l-large-reseller',
|
'slug' => 'l-large-reseller',
|
||||||
'name' => 'Reseller L',
|
'name' => 'Partner Premium',
|
||||||
'name_translations' => [
|
'name_translations' => [
|
||||||
'de' => 'Reseller L',
|
'de' => 'Partner Premium',
|
||||||
'en' => 'Reseller L',
|
'en' => 'Partner Premium',
|
||||||
],
|
],
|
||||||
'type' => PackageType::RESELLER,
|
'type' => PackageType::RESELLER,
|
||||||
'price' => 699.00,
|
'included_package_slug' => 'pro',
|
||||||
'max_photos' => 3000,
|
'price' => 1999.00,
|
||||||
|
'max_photos' => null,
|
||||||
'max_guests' => null,
|
'max_guests' => null,
|
||||||
'gallery_days' => 90,
|
'gallery_days' => null,
|
||||||
'max_tasks' => null,
|
'max_tasks' => null,
|
||||||
'watermark_allowed' => false,
|
'watermark_allowed' => false,
|
||||||
'branding_allowed' => true,
|
'branding_allowed' => true,
|
||||||
'max_events_per_year' => 40,
|
'max_events_per_year' => 35,
|
||||||
'expires_after' => now()->copy()->addYear(),
|
'expires_after' => null,
|
||||||
'features' => ['reseller_dashboard', 'custom_branding', 'priority_support', 'advanced_reporting', 'live_slideshow'],
|
'features' => ['reseller_dashboard', 'custom_branding', 'priority_support', 'advanced_reporting', 'live_slideshow'],
|
||||||
'paddle_product_id' => 'pro_01k8jcxt7gc6g6ddavmq65txzz',
|
'paddle_product_id' => 'pro_01k8jcxt7gc6g6ddavmq65txzz',
|
||||||
'paddle_price_id' => 'pri_01k8jcxtfa07gvq43kpvpe0t8z',
|
'paddle_price_id' => 'pri_01k8jcxtfa07gvq43kpvpe0t8z',
|
||||||
'description' => <<<TEXT
|
'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.
|
Event-Kontingent für Partner / Agenturen: {{max_events_per_year}} Events auf Premium‑Niveau. Empfohlen innerhalb von 24 Monaten zu nutzen.
|
||||||
TEXT,
|
TEXT,
|
||||||
'description_translations' => [
|
'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.',
|
'de' => 'Event-Kontingent für Partner / Agenturen: {{max_events_per_year}} Events auf Premium‑Niveau. Empfohlen innerhalb von 24 Monaten zu nutzen.',
|
||||||
'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.',
|
'en' => 'Event-Kontingent for Partner / Agencies: {{max_events_per_year}} events at Premium level. Recommended to use within 24 months.',
|
||||||
],
|
],
|
||||||
'description_table' => [
|
'description_table' => [
|
||||||
['title' => 'Events/Jahr', 'value' => '{{max_events_per_year}} Events'],
|
['title' => 'Events', 'value' => '{{max_events_per_year}} Events'],
|
||||||
['title' => 'Branding', 'value' => 'White-Label & eigene Domains'],
|
['title' => 'Inklusive Event-Level', 'value' => 'Premium'],
|
||||||
['title' => 'Extras', 'value' => 'Live-Slideshow & Premium-Features'],
|
['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',
|
'slug' => 'studio-annual',
|
||||||
'name' => 'Studio Jahrespaket',
|
'name' => 'Partner Jahreskontingent (24 Events)',
|
||||||
'name_translations' => [
|
'name_translations' => [
|
||||||
'de' => 'Studio Jahrespaket',
|
'de' => 'Partner Jahreskontingent (24 Events)',
|
||||||
'en' => 'Studio Annual',
|
'en' => 'Partner annual kontingent (24 events)',
|
||||||
],
|
],
|
||||||
'type' => PackageType::RESELLER,
|
'type' => PackageType::RESELLER,
|
||||||
|
'included_package_slug' => 'standard',
|
||||||
'price' => 1299.00,
|
'price' => 1299.00,
|
||||||
'max_photos' => null,
|
'max_photos' => null,
|
||||||
'max_guests' => null,
|
'max_guests' => null,
|
||||||
@@ -230,42 +269,20 @@ TEXT,
|
|||||||
'branding_allowed' => false,
|
'branding_allowed' => false,
|
||||||
'max_events_per_year' => 24,
|
'max_events_per_year' => 24,
|
||||||
'expires_after' => null,
|
'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_product_id' => 'pro_01k8jct3gz9ks5mg6z61q6nrxb',
|
||||||
'paddle_price_id' => 'pri_01k8jcxsa8axwpjnybhjbcrb06',
|
'paddle_price_id' => 'pri_01k8jcxsa8axwpjnybhjbcrb06',
|
||||||
'description' => null,
|
'description' => <<<'TEXT'
|
||||||
'description_translations' => null,
|
Event-Kontingent für Partner / Agenturen: {{max_events_per_year}} Events auf Standard‑Niveau. Empfohlen innerhalb von 24 Monaten zu nutzen.
|
||||||
'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.
|
|
||||||
TEXT,
|
TEXT,
|
||||||
'description_translations' => [
|
'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.',
|
'de' => 'Event-Kontingent für Partner / Agenturen: {{max_events_per_year}} Events auf Standard‑Niveau. Empfohlen innerhalb von 24 Monaten zu nutzen.',
|
||||||
'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.',
|
'en' => 'Event-Kontingent for Partner / Agencies: {{max_events_per_year}} events at Standard level. Recommended to use within 24 months.',
|
||||||
],
|
],
|
||||||
'description_table' => [
|
'description_table' => [
|
||||||
['title' => 'Events/Jahr', 'value' => '{{max_events_per_year}} Events'],
|
['title' => 'Events', 'value' => '{{max_events_per_year}} Events'],
|
||||||
['title' => 'Branding', 'value' => 'Eigene Subdomain oder App'],
|
['title' => 'Inklusive Event-Level', 'value' => 'Standard'],
|
||||||
['title' => 'Support', 'value' => 'Persönliche Betreuung'],
|
['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