Compare commits

...

41 Commits

Author SHA1 Message Date
Codex Agent
a9fa1546f7 bd sync: 2026-01-23 09:20:33 2026-01-23 09:20:34 +01:00
Codex Agent
7c6eee187c bd sync: 2026-01-23 08:56:22 2026-01-23 08:56:23 +01:00
Codex Agent
fbd46b8e5c bd sync: 2026-01-21 12:55:26 2026-01-21 12:55:26 +01:00
Codex Agent
886b336a08 bd sync: 2026-01-19 18:50:20 2026-01-19 18:50:20 +01:00
Codex Agent
02237735ec bd sync: 2026-01-18 11:02:27 2026-01-18 11:02:28 +01:00
Codex Agent
5e420a0dd8 bd sync: 2026-01-15 19:54:28 2026-01-15 19:54:28 +01:00
Codex Agent
2a55ae934f bd sync: 2026-01-13 11:04:44 2026-01-13 11:04:44 +01:00
Codex Agent
e4100f7800 Polish uploader UI and queue handling
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (push) Has been cancelled
tests / ui (push) Has been cancelled
2026-01-12 17:35:05 +01:00
Codex Agent
7786e3d134 Switch photobooth uploader to Avalonia
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (push) Has been cancelled
tests / ui (push) Has been cancelled
2026-01-12 17:26:45 +01:00
Codex Agent
30f3d148bb bd sync: 2026-01-12 17:24:05
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (push) Has been cancelled
tests / ui (push) Has been cancelled
2026-01-12 17:24:05 +01:00
Codex Agent
1970c259ed Restore photobooth uploader files after sync 2026-01-12 17:23:34 +01:00
Codex Agent
dc5c80cda4 bd sync: 2026-01-12 17:21:15
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (push) Has been cancelled
tests / ui (push) Has been cancelled
2026-01-12 17:21:16 +01:00
Codex Agent
75a9bcee12 Migrate photobooth uploader to Avalonia 2026-01-12 17:20:35 +01:00
Codex Agent
6fe363640f Reapply photobooth uploader changes after sync
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (push) Has been cancelled
tests / ui (push) Has been cancelled
2026-01-12 17:10:47 +01:00
Codex Agent
3df0542013 bd sync: 2026-01-12 17:10:05
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (push) Has been cancelled
tests / ui (push) Has been cancelled
2026-01-12 17:10:05 +01:00
Codex Agent
4f4a527010 Reapply photobooth uploader changes 2026-01-12 17:09:37 +01:00
Codex Agent
e69c94ad20 bd sync: 2026-01-12 17:07:55
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (push) Has been cancelled
tests / ui (push) Has been cancelled
2026-01-12 17:07:55 +01:00
Codex Agent
5afa96251b Fix WinUI build settings for linux tooling 2026-01-12 17:07:28 +01:00
Codex Agent
24f053d4c4 Add photobooth connect codes and uploader pipeline
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (push) Has been cancelled
tests / ui (push) Has been cancelled
2026-01-12 17:02:50 +01:00
Codex Agent
ec360ed860 bd sync: 2026-01-12 17:02:15
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (push) Has been cancelled
tests / ui (push) Has been cancelled
2026-01-12 17:02:15 +01:00
Codex Agent
83e78d7c66 Update backend photobooth connect API 2026-01-12 16:59:49 +01:00
Codex Agent
9b1c5bf978 bd sync: 2026-01-12 16:57:37
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (push) Has been cancelled
tests / ui (push) Has been cancelled
2026-01-12 16:57:37 +01:00
Codex Agent
fb23a0a2f3 Add photobooth connect codes and uploader scaffold 2026-01-12 16:56:51 +01:00
Codex Agent
2287e7f32c Fix tenant photo moderation and guest updates
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (push) Has been cancelled
tests / ui (push) Has been cancelled
2026-01-12 14:35:57 +01:00
Codex Agent
cceed361b7 feat: add checkout action banner
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (push) Has been cancelled
tests / ui (push) Has been cancelled
2026-01-12 13:35:43 +01:00
Codex Agent
02363792c8 feat: poll checkout status and show failures
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (push) Has been cancelled
tests / ui (push) Has been cancelled
2026-01-12 13:31:30 +01:00
Codex Agent
e93a00f0fc fix: block non-upgrade package selection
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (push) Has been cancelled
tests / ui (push) Has been cancelled
2026-01-12 12:40:18 +01:00
Codex Agent
c1be7dd1ef fix: add package feature labels
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (push) Has been cancelled
tests / ui (push) Has been cancelled
2026-01-12 12:32:43 +01:00
Codex Agent
f01a0e823b fix: handle array package features
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (push) Has been cancelled
tests / ui (push) Has been cancelled
2026-01-12 12:29:19 +01:00
Codex Agent
915aede66e feat: add package comparison view
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (push) Has been cancelled
tests / ui (push) Has been cancelled
2026-01-12 12:25:35 +01:00
Codex Agent
b854e3feaa Show billing activation banner
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (push) Has been cancelled
tests / ui (push) Has been cancelled
2026-01-12 12:07:37 +01:00
Codex Agent
4bcaef53f7 Redirect checkout to billing with status
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (push) Has been cancelled
tests / ui (push) Has been cancelled
2026-01-12 11:49:10 +01:00
Codex Agent
8f1d3a3eb6 Disallow downgrades in package shop
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (push) Has been cancelled
tests / ui (push) Has been cancelled
2026-01-12 11:45:12 +01:00
Codex Agent
ab2cf3e023 Highlight upgrades in package shop
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (push) Has been cancelled
tests / ui (push) Has been cancelled
2026-01-12 11:38:16 +01:00
Codex Agent
ce0ab269c9 Cap analytics timeframe label
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (push) Has been cancelled
tests / ui (push) Has been cancelled
2026-01-12 11:30:00 +01:00
Codex Agent
dce24bb86a Compute analytics timeframe dynamically
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (push) Has been cancelled
tests / ui (push) Has been cancelled
2026-01-12 11:12:50 +01:00
Codex Agent
03bf178d61 Enhance analytics snapshot and empty states
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (push) Has been cancelled
tests / ui (push) Has been cancelled
2026-01-12 11:07:23 +01:00
Codex Agent
8ebaf6c31d Refine analytics page and i18n
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (push) Has been cancelled
tests / ui (push) Has been cancelled
2026-01-12 11:03:55 +01:00
Codex Agent
1b6dc63ec6 Clamp package summary remaining counts
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (push) Has been cancelled
tests / ui (push) Has been cancelled
2026-01-12 10:50:35 +01:00
Codex Agent
accc63f4a2 Add pending test files
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (push) Has been cancelled
tests / ui (push) Has been cancelled
2026-01-12 10:46:18 +01:00
Codex Agent
59e318e7b9 Ignore beads sync artifacts
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (push) Has been cancelled
tests / ui (push) Has been cancelled
2026-01-12 10:42:58 +01:00
58 changed files with 3270 additions and 190 deletions

View File

@@ -9,16 +9,20 @@
{"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"}
{"id":"fotospiel-app-38f","title":"Paddle catalog sync: surface last sync error/log context in admin","status":"closed","priority":2,"issue_type":"task","created_at":"2026-01-01T15:59:14.865414785+01:00","created_by":"soeren","updated_at":"2026-01-02T21:16:09.109922491+01:00","closed_at":"2026-01-02T21:16:09.109922491+01:00","close_reason":"Completed"} {"id":"fotospiel-app-38f","title":"Paddle catalog sync: surface last sync error/log context in admin","status":"closed","priority":2,"issue_type":"task","created_at":"2026-01-01T15:59:14.865414785+01:00","created_by":"soeren","updated_at":"2026-01-02T21:16:09.109922491+01:00","closed_at":"2026-01-02T21:16:09.109922491+01:00","close_reason":"Completed"}
{"id":"fotospiel-app-3ut","title":"SEC-API-03 Synthetic monitoring + alert config","status":"open","priority":2,"issue_type":"task","created_at":"2026-01-01T15:52:46.793875724+01:00","created_by":"soeren","updated_at":"2026-01-01T15:52:46.793875724+01:00"} {"id":"fotospiel-app-3ut","title":"SEC-API-03 Synthetic monitoring + alert config","status":"open","priority":2,"issue_type":"task","created_at":"2026-01-01T15:52:46.793875724+01:00","created_by":"soeren","updated_at":"2026-01-01T15:52:46.793875724+01:00"}
{"id":"fotospiel-app-3xa","title":"Security review: event admin code audit (policies, PKCE, file handling)","status":"open","priority":2,"issue_type":"task","created_at":"2026-01-01T16:05:20.115675149+01:00","created_by":"soeren","updated_at":"2026-01-01T16:05:20.115675149+01:00"} {"id":"fotospiel-app-3xa","title":"Security review: event admin code audit (policies, PKCE, file handling)","status":"open","priority":2,"issue_type":"task","created_at":"2026-01-01T16:05:20.115675149+01:00","created_by":"soeren","updated_at":"2026-01-01T16:05:20.115675149+01:00"}
{"id":"fotospiel-app-43mp","title":"Help-System für Event Admin PWA planen","notes":"Context help links wired into priority admin pages.","status":"in_progress","priority":2,"issue_type":"task","owner":"codex-agent@example.com","created_at":"2026-01-23T08:21:47.812129626+01:00","created_by":"Codex Agent","updated_at":"2026-01-23T09:19:45.828239299+01:00"}
{"id":"fotospiel-app-4ar","title":"SEC-BILL-03 Failed capture notifications + ledger hook","status":"open","priority":2,"issue_type":"task","created_at":"2026-01-01T15:54:33.266516715+01:00","created_by":"soeren","updated_at":"2026-01-01T15:54:33.266516715+01:00"} {"id":"fotospiel-app-4ar","title":"SEC-BILL-03 Failed capture notifications + ledger hook","status":"open","priority":2,"issue_type":"task","created_at":"2026-01-01T15:54:33.266516715+01:00","created_by":"soeren","updated_at":"2026-01-01T15:54:33.266516715+01:00"}
{"id":"fotospiel-app-4en","title":"Add translations for Mobile Package Shop","description":"The new MobilePackageShopPage.tsx uses translation keys like 'shop.title', 'shop.legal.agb', etc. Ensure these are added to the management.json files for de and en.","status":"closed","priority":2,"issue_type":"task","created_at":"2026-01-06T18:05:50.469751088+01:00","created_by":"soeren","updated_at":"2026-01-06T18:14:19.984343737+01:00","closed_at":"2026-01-06T18:14:19.984346372+01:00"} {"id":"fotospiel-app-4en","title":"Add translations for Mobile Package Shop","description":"The new MobilePackageShopPage.tsx uses translation keys like 'shop.title', 'shop.legal.agb', etc. Ensure these are added to the management.json files for de and en.","status":"closed","priority":2,"issue_type":"task","created_at":"2026-01-06T18:05:50.469751088+01:00","created_by":"soeren","updated_at":"2026-01-06T18:14:19.984343737+01:00","closed_at":"2026-01-06T18:14:19.984346372+01:00"}
{"id":"fotospiel-app-4i4","title":"Security review: map roles/data","status":"closed","priority":2,"issue_type":"task","created_at":"2026-01-01T16:02:58.370301875+01:00","created_by":"soeren","updated_at":"2026-01-01T16:03:03.997327414+01:00","closed_at":"2026-01-01T16:03:03.997327414+01:00","close_reason":"Completed in codebase (verified)"} {"id":"fotospiel-app-4i4","title":"Security review: map roles/data","status":"closed","priority":2,"issue_type":"task","created_at":"2026-01-01T16:02:58.370301875+01:00","created_by":"soeren","updated_at":"2026-01-01T16:03:03.997327414+01:00","closed_at":"2026-01-01T16:03:03.997327414+01:00","close_reason":"Completed in codebase (verified)"}
{"id":"fotospiel-app-4zu","title":"SEC-IO-02 Refresh-token management UI + audit logs","status":"closed","priority":2,"issue_type":"task","created_at":"2026-01-01T15:51:50.24186222+01:00","created_by":"soeren","updated_at":"2026-01-04T16:10:39.752587431+01:00","closed_at":"2026-01-04T16:10:39.752587431+01:00","close_reason":"Obsolete: authentication now uses Sanctum PATs; OAuth/refresh-token tables removed and no refresh-token flow remains. See docs/archive/prp/13-backend-authentication.md and docs/archive/prp/marketing-checkout-payment-architecture.md."} {"id":"fotospiel-app-4zu","title":"SEC-IO-02 Refresh-token management UI + audit logs","status":"closed","priority":2,"issue_type":"task","created_at":"2026-01-01T15:51:50.24186222+01:00","created_by":"soeren","updated_at":"2026-01-04T16:10:39.752587431+01:00","closed_at":"2026-01-04T16:10:39.752587431+01:00","close_reason":"Obsolete: authentication now uses Sanctum PATs; OAuth/refresh-token tables removed and no refresh-token flow remains. See docs/archive/prp/13-backend-authentication.md and docs/archive/prp/marketing-checkout-payment-architecture.md."}
{"id":"fotospiel-app-4zy","title":"Refine Dashboard Translations","description":"Fix missing translations in the modern dashboard UI and use proper i18n keys for stats and status labels.","status":"open","priority":2,"issue_type":"task","owner":"codex-agent@example.com","created_at":"2026-01-17T16:35:14.464529363+01:00","created_by":"Codex Agent","updated_at":"2026-01-17T16:35:14.464529363+01:00"}
{"id":"fotospiel-app-539","title":"Live Show: public player view with effects engine","status":"closed","priority":1,"issue_type":"feature","created_at":"2026-01-05T11:11:36.821959901+01:00","created_by":"soeren","updated_at":"2026-01-05T18:30:13.318396255+01:00","closed_at":"2026-01-05T18:30:13.318396255+01:00","close_reason":"Closed","dependencies":[{"issue_id":"fotospiel-app-539","depends_on_id":"fotospiel-app-qne","type":"blocks","created_at":"2026-01-05T11:12:58.721858159+01:00","created_by":"soeren"},{"issue_id":"fotospiel-app-539","depends_on_id":"fotospiel-app-6zc","type":"blocks","created_at":"2026-01-05T11:13:07.289796993+01:00","created_by":"soeren"},{"issue_id":"fotospiel-app-539","depends_on_id":"fotospiel-app-h5d","type":"blocks","created_at":"2026-01-05T11:44:42.719445471+01:00","created_by":"soeren"}]} {"id":"fotospiel-app-539","title":"Live Show: public player view with effects engine","status":"closed","priority":1,"issue_type":"feature","created_at":"2026-01-05T11:11:36.821959901+01:00","created_by":"soeren","updated_at":"2026-01-05T18:30:13.318396255+01:00","closed_at":"2026-01-05T18:30:13.318396255+01:00","close_reason":"Closed","dependencies":[{"issue_id":"fotospiel-app-539","depends_on_id":"fotospiel-app-qne","type":"blocks","created_at":"2026-01-05T11:12:58.721858159+01:00","created_by":"soeren"},{"issue_id":"fotospiel-app-539","depends_on_id":"fotospiel-app-6zc","type":"blocks","created_at":"2026-01-05T11:13:07.289796993+01:00","created_by":"soeren"},{"issue_id":"fotospiel-app-539","depends_on_id":"fotospiel-app-h5d","type":"blocks","created_at":"2026-01-05T11:44:42.719445471+01:00","created_by":"soeren"}]}
{"id":"fotospiel-app-539.2","title":"Live Show player shell + routing + data layer","description":"Add /show/{token} route + guest player page shell, Live Show API client, SSE/polling subscription and state model.","status":"closed","priority":1,"issue_type":"task","created_at":"2026-01-05T15:57:41.587003393+01:00","created_by":"soeren","updated_at":"2026-01-05T16:44:39.577762479+01:00","closed_at":"2026-01-05T16:44:39.577762479+01:00","close_reason":"Closed","dependencies":[{"issue_id":"fotospiel-app-539.2","depends_on_id":"fotospiel-app-539","type":"parent-child","created_at":"2026-01-05T15:57:41.641767879+01:00","created_by":"soeren"}]} {"id":"fotospiel-app-539.2","title":"Live Show player shell + routing + data layer","description":"Add /show/{token} route + guest player page shell, Live Show API client, SSE/polling subscription and state model.","status":"closed","priority":1,"issue_type":"task","created_at":"2026-01-05T15:57:41.587003393+01:00","created_by":"soeren","updated_at":"2026-01-05T16:44:39.577762479+01:00","closed_at":"2026-01-05T16:44:39.577762479+01:00","close_reason":"Closed","dependencies":[{"issue_id":"fotospiel-app-539.2","depends_on_id":"fotospiel-app-539","type":"parent-child","created_at":"2026-01-05T15:57:41.641767879+01:00","created_by":"soeren"}]}
{"id":"fotospiel-app-539.3","title":"Live Show playback engine (queue, pacing, layouts)","description":"Implement player playback scheduler, queue management, and layout rendering for single/split/grid.","status":"closed","priority":1,"issue_type":"task","created_at":"2026-01-05T15:57:56.531080931+01:00","created_by":"soeren","updated_at":"2026-01-05T17:40:45.929168571+01:00","closed_at":"2026-01-05T17:40:45.929168571+01:00","close_reason":"Closed","dependencies":[{"issue_id":"fotospiel-app-539.3","depends_on_id":"fotospiel-app-539","type":"parent-child","created_at":"2026-01-05T15:57:56.631147026+01:00","created_by":"soeren"},{"issue_id":"fotospiel-app-539.3","depends_on_id":"fotospiel-app-539.2","type":"blocks","created_at":"2026-01-05T15:57:56.655278463+01:00","created_by":"soeren"}]} {"id":"fotospiel-app-539.3","title":"Live Show playback engine (queue, pacing, layouts)","description":"Implement player playback scheduler, queue management, and layout rendering for single/split/grid.","status":"closed","priority":1,"issue_type":"task","created_at":"2026-01-05T15:57:56.531080931+01:00","created_by":"soeren","updated_at":"2026-01-05T17:40:45.929168571+01:00","closed_at":"2026-01-05T17:40:45.929168571+01:00","close_reason":"Closed","dependencies":[{"issue_id":"fotospiel-app-539.3","depends_on_id":"fotospiel-app-539","type":"parent-child","created_at":"2026-01-05T15:57:56.631147026+01:00","created_by":"soeren"},{"issue_id":"fotospiel-app-539.3","depends_on_id":"fotospiel-app-539.2","type":"blocks","created_at":"2026-01-05T15:57:56.655278463+01:00","created_by":"soeren"}]}
@@ -28,20 +32,27 @@
{"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"}]}
{"id":"fotospiel-app-5iy","title":"Security review: confirm env/header defaults","status":"closed","priority":2,"issue_type":"task","created_at":"2026-01-01T16:03:20.808188183+01:00","created_by":"soeren","updated_at":"2026-01-01T16:03:26.388002115+01:00","closed_at":"2026-01-01T16:03:26.388002115+01:00","close_reason":"Completed in codebase (verified)"} {"id":"fotospiel-app-5iy","title":"Security review: confirm env/header defaults","status":"closed","priority":2,"issue_type":"task","created_at":"2026-01-01T16:03:20.808188183+01:00","created_by":"soeren","updated_at":"2026-01-01T16:03:26.388002115+01:00","closed_at":"2026-01-01T16:03:26.388002115+01:00","close_reason":"Completed in codebase (verified)"}
{"id":"fotospiel-app-5s3","title":"Localized SEO: canonical/hreflang tags + localized navigation","status":"closed","priority":2,"issue_type":"task","created_at":"2026-01-01T16:02:03.909947355+01:00","created_by":"soeren","updated_at":"2026-01-01T16:02:09.550647107+01:00","closed_at":"2026-01-01T16:02:09.550647107+01:00","close_reason":"Completed in codebase (verified)"} {"id":"fotospiel-app-5s3","title":"Localized SEO: canonical/hreflang tags + localized navigation","status":"closed","priority":2,"issue_type":"task","created_at":"2026-01-01T16:02:03.909947355+01:00","created_by":"soeren","updated_at":"2026-01-01T16:02:09.550647107+01:00","closed_at":"2026-01-01T16:02:09.550647107+01:00","close_reason":"Completed in codebase (verified)"}
{"id":"fotospiel-app-5veo","title":"Investigate vite build timeout","status":"open","priority":2,"issue_type":"task","owner":"codex-agent@example.com","created_at":"2026-01-21T12:49:14.166622473+01:00","created_by":"Codex Agent","updated_at":"2026-01-21T12:49:14.166622473+01:00"}
{"id":"fotospiel-app-5zl","title":"Ensure checkout step 3 requires login for Paddle checkout","description":"Problem: Paddle checkout on step 3 fails when user is not logged in. Step 3 must enforce authentication before initializing Paddle checkout.\\n\\nSuggestions:\\n- Protect step 3 route/controller with auth middleware and redirect to login with intended return URL.\\n- Gate step 3 UI/CTA on auth state; show inline login prompt and disable Paddle until authenticated.\\n- Require auth in backend endpoint that creates Paddle transaction/session; return 401 and send user to login.\\n- Optionally preflight at end of step 2 to prompt login before advancing.","status":"closed","priority":2,"issue_type":"task","created_at":"2026-01-04T12:31:43.215017311+01:00","created_by":"soeren","updated_at":"2026-01-04T12:42:45.088723058+01:00","closed_at":"2026-01-04T12:42:45.088723058+01:00","close_reason":"Closed"} {"id":"fotospiel-app-5zl","title":"Ensure checkout step 3 requires login for Paddle checkout","description":"Problem: Paddle checkout on step 3 fails when user is not logged in. Step 3 must enforce authentication before initializing Paddle checkout.\\n\\nSuggestions:\\n- Protect step 3 route/controller with auth middleware and redirect to login with intended return URL.\\n- Gate step 3 UI/CTA on auth state; show inline login prompt and disable Paddle until authenticated.\\n- Require auth in backend endpoint that creates Paddle transaction/session; return 401 and send user to login.\\n- Optionally preflight at end of step 2 to prompt login before advancing.","status":"closed","priority":2,"issue_type":"task","created_at":"2026-01-04T12:31:43.215017311+01:00","created_by":"soeren","updated_at":"2026-01-04T12:42:45.088723058+01:00","closed_at":"2026-01-04T12:42:45.088723058+01:00","close_reason":"Closed"}
{"id":"fotospiel-app-64l","title":"SEC-FE-01 CSP nonce/hashing rollout","status":"closed","priority":2,"issue_type":"task","created_at":"2026-01-01T15:54:47.607047443+01:00","created_by":"soeren","updated_at":"2026-01-01T15:55:56.477104351+01:00","closed_at":"2026-01-01T15:55:56.477104351+01:00","close_reason":"Completed in codebase (verified) - duplicate of fotospiel-app-zli"} {"id":"fotospiel-app-64l","title":"SEC-FE-01 CSP nonce/hashing rollout","status":"closed","priority":2,"issue_type":"task","created_at":"2026-01-01T15:54:47.607047443+01:00","created_by":"soeren","updated_at":"2026-01-01T15:55:56.477104351+01:00","closed_at":"2026-01-01T15:55:56.477104351+01:00","close_reason":"Completed in codebase (verified) - duplicate of fotospiel-app-zli"}
{"id":"fotospiel-app-6dp","title":"Coupon ops enhancements (redemption service, preview endpoint, widget, export)","status":"closed","priority":2,"issue_type":"task","created_at":"2026-01-01T16:09:09.275919717+01:00","created_by":"soeren","updated_at":"2026-01-01T16:09:14.882264149+01:00","closed_at":"2026-01-01T16:09:14.882264149+01:00","close_reason":"Completed in codebase (verified)"} {"id":"fotospiel-app-6dp","title":"Coupon ops enhancements (redemption service, preview endpoint, widget, export)","status":"closed","priority":2,"issue_type":"task","created_at":"2026-01-01T16:09:09.275919717+01:00","created_by":"soeren","updated_at":"2026-01-01T16:09:14.882264149+01:00","closed_at":"2026-01-01T16:09:14.882264149+01:00","close_reason":"Completed in codebase (verified)"}
{"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-8iw","title":"Modernize Tenant Admin PWA UI","status":"open","priority":1,"issue_type":"task","owner":"codex-agent@example.com","created_at":"2026-01-17T14:36:39.802617182+01:00","created_by":"Codex Agent","updated_at":"2026-01-17T14:36:39.802617182+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 +73,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 +99,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 +107,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 +118,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 +136,16 @@
{"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-spq8","title":"Eslint fails due to existing repo violations","status":"open","priority":2,"issue_type":"task","owner":"codex-agent@example.com","created_at":"2026-01-19T18:49:19.208323875+01:00","created_by":"Codex Agent","updated_at":"2026-01-19T18:49:19.208323875+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 +164,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"}

View File

@@ -1 +1 @@
fotospiel-app-9em fotospiel-app-29r

5
.gitignore vendored
View File

@@ -13,6 +13,8 @@ fotospiel-tenant-app
/storage/*.key /storage/*.key
/storage/pail /storage/pail
/vendor /vendor
/clients/photobooth-uploader/**/bin
/clients/photobooth-uploader/**/obj
.env .env
.env.backup .env.backup
.env.production .env.production
@@ -26,3 +28,6 @@ yarn-error.log
/.vscode /.vscode
test-results test-results
GEMINI.md GEMINI.md
.beads/.sync.lock
.beads/daemon-error
.beads/sync_base.jsonl

View File

@@ -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

View File

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

View File

@@ -525,13 +525,13 @@ class PhotoController extends Controller
]); ]);
// Only tenant admins can moderate // Only tenant admins can moderate
if (isset($validated['status']) && ! $this->tokenHasScope($request, 'tenant:write')) { if (isset($validated['status']) && ! $this->tokenHasScope($request, 'tenant-admin')) {
return ApiError::response( return ApiError::response(
'insufficient_scope', 'insufficient_scope',
'Insufficient Scopes', 'Insufficient Scopes',
'You are not allowed to moderate photos for this event.', 'You are not allowed to moderate photos for this event.',
Response::HTTP_FORBIDDEN, Response::HTTP_FORBIDDEN,
['required_scope' => 'tenant:write'] ['required_scope' => 'tenant-admin']
); );
} }
@@ -823,6 +823,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)) {

View File

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

View File

@@ -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,
]);
}
}

View File

@@ -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'],
];
}
}

View File

@@ -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);
}
} }

View 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);
}
}

View File

@@ -162,6 +162,10 @@ class AppServiceProvider extends ServiceProvider
return Limit::perMinute(300)->by('guest-api:'.($request->ip() ?? 'unknown')); 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) {
return Limit::perMinute(20)->by('tenant-auth:'.($request->ip() ?? 'unknown')); return Limit::perMinute(20)->by('tenant-auth:'.($request->ip() ?? 'unknown'));
}); });

View File

@@ -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;

View 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;
}
}

View File

@@ -0,0 +1,10 @@
<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 />
</Application.Styles>
</Application>

View 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();
}
}

View File

@@ -0,0 +1,63 @@
<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="520" Height="360"
Title="Fotospiel Photobooth Uploader">
<Grid Margin="24" ColumnDefinitions="*,8,*">
<StackPanel Grid.Column="0" Spacing="12" MaxWidth="420">
<TextBlock Text="Fotospiel Photobooth Uploader" FontSize="20" FontWeight="SemiBold" />
<Border Background="#1F000000" Padding="12" CornerRadius="8">
<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>
<TextBlock Text="Gib den 6-stelligen Verbindungscode ein." TextWrapping="Wrap" />
<TextBox x:Name="CodeBox" MaxLength="6" Watermark="123456" />
<Button x:Name="ConnectButton" Content="Verbinden" Click="ConnectButton_Click" />
<StackPanel Spacing="6">
<TextBlock Text="Upload-Ordner" FontWeight="SemiBold" />
<TextBlock x:Name="FolderText" Text="Noch nicht ausgewählt." TextWrapping="Wrap" />
<Button x:Name="PickFolderButton" Content="Ordner auswählen" Click="PickFolderButton_Click" IsEnabled="False" />
</StackPanel>
<ToggleSwitch x:Name="QuietToggle" Content="Ruhiger Modus (nur Fehler anzeigen)" />
</StackPanel>
<StackPanel Grid.Column="2" Spacing="12" MaxWidth="380">
<Border Background="#1F000000" Padding="12" CornerRadius="8">
<StackPanel Spacing="6">
<TextBlock Text="Status" FontWeight="SemiBold" />
<TextBlock x:Name="StatusText" Text="Nicht verbunden." TextWrapping="Wrap" />
<TextBlock x:Name="LastUploadText" Text="Letzter Upload: —" />
</StackPanel>
</Border>
<StackPanel Spacing="6">
<TextBlock Text="Letzte Uploads" FontWeight="SemiBold" />
<ItemsControl x:Name="RecentUploadsList" ItemsSource="{Binding RecentUploads}">
<ItemsControl.ItemTemplate>
<DataTemplate>
<Border Background="#14000000" Padding="8" CornerRadius="6" Margin="0,0,0,6">
<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>
<Button x:Name="RetryFailedButton" Content="Fehlgeschlagene erneut senden" Click="RetryFailedButton_Click" IsEnabled="False" />
</StackPanel>
</StackPanel>
</Grid>
</Window>

View File

@@ -0,0 +1,282 @@
using System;
using System.IO;
using System.Linq;
using System.Collections.Generic;
using System.Collections.ObjectModel;
using Avalonia.Controls;
using Avalonia.Interactivity;
using Avalonia.Platform.Storage;
using Avalonia.Threading;
using PhotoboothUploader.Models;
using PhotoboothUploader.Services;
namespace PhotoboothUploader;
public partial class MainWindow : Window
{
private const string DefaultBaseUrl = "https://fotospiel.app";
private PhotoboothConnectClient _client;
private readonly SettingsStore _settingsStore = new();
private readonly UploadService _uploadService = new();
private PhotoboothSettings _settings;
private FileSystemWatcher? _watcher;
private readonly Dictionary<string, UploadItem> _uploadsByPath = new(StringComparer.OrdinalIgnoreCase);
private readonly HashSet<string> _failedPaths = new(StringComparer.OrdinalIgnoreCase);
public ObservableCollection<UploadItem> RecentUploads { get; } = new();
public MainWindow()
{
InitializeComponent();
_settings = _settingsStore.Load();
_settings.BaseUrl ??= DefaultBaseUrl;
_client = new PhotoboothConnectClient(_settings.BaseUrl);
_settingsStore.Save(_settings);
DataContext = this;
ApplySettings();
}
private async void ConnectButton_Click(object? sender, RoutedEventArgs e)
{
var code = (CodeBox.Text ?? string.Empty).Trim();
if (code.Length != 6 || code.Any(ch => ch is < '0' or > '9'))
{
StatusText.Text = "Bitte einen gültigen 6-stelligen Code eingeben.";
return;
}
ConnectButton.IsEnabled = false;
StatusText.Text = "Verbinde...";
var response = await _client.RedeemAsync(code);
if (response.Data is null)
{
StatusText.Text = response.Message ?? "Verbindung fehlgeschlagen.";
ConnectButton.IsEnabled = true;
return;
}
_settings.UploadUrl = ResolveUploadUrl(response.Data.UploadUrl);
_settings.Username = response.Data.Username;
_settings.Password = response.Data.Password;
_settings.ResponseFormat = response.Data.ResponseFormat;
_settingsStore.Save(_settings);
StatusText.Text = "Verbunden. Upload bereit.";
PickFolderButton.IsEnabled = true;
StartUploadPipelineIfReady();
ConnectButton.IsEnabled = true;
}
private async void PickFolderButton_Click(object? sender, RoutedEventArgs e)
{
var options = new FolderPickerOpenOptions
{
Title = "Upload-Ordner auswählen",
AllowMultiple = false,
};
var folders = await StorageProvider.OpenFolderPickerAsync(options);
var folder = folders.FirstOrDefault();
var localPath = folder?.TryGetLocalPath();
if (string.IsNullOrWhiteSpace(localPath))
{
return;
}
_settings.WatchFolder = localPath;
_settingsStore.Save(_settings);
FolderText.Text = localPath;
StartUploadPipelineIfReady();
}
private void ApplySettings()
{
if (!string.IsNullOrWhiteSpace(_settings.WatchFolder))
{
FolderText.Text = _settings.WatchFolder;
}
if (!string.IsNullOrWhiteSpace(_settings.UploadUrl))
{
StatusText.Text = "Verbunden. Upload bereit.";
PickFolderButton.IsEnabled = true;
StartUploadPipelineIfReady();
}
UpdateSteps();
}
private void StartUploadPipelineIfReady()
{
if (string.IsNullOrWhiteSpace(_settings.UploadUrl) || string.IsNullOrWhiteSpace(_settings.WatchFolder))
{
UpdateSteps();
return;
}
_uploadService.Start(_settings, OnQueued, OnUploading, OnSuccess, OnFailure);
StartWatcher(_settings.WatchFolder);
UpdateSteps();
}
private void StartWatcher(string folder)
{
_watcher?.Dispose();
_watcher = new FileSystemWatcher(folder)
{
IncludeSubdirectories = false,
EnableRaisingEvents = true,
};
_watcher.Created += OnFileChanged;
_watcher.Changed += OnFileChanged;
_watcher.Renamed += OnFileRenamed;
}
private void OnFileChanged(object sender, FileSystemEventArgs e)
{
if (!IsSupportedImage(e.FullPath))
{
return;
}
_uploadService.Enqueue(e.FullPath, OnQueued);
}
private void OnFileRenamed(object sender, RenamedEventArgs e)
{
if (!IsSupportedImage(e.FullPath))
{
return;
}
_uploadService.Enqueue(e.FullPath, OnQueued);
}
private bool IsSupportedImage(string path)
{
var extension = Path.GetExtension(path)?.ToLowerInvariant();
return extension is ".jpg" or ".jpeg" or ".png" or ".webp";
}
private void UpdateStatus(string message)
{
Dispatcher.UIThread.Post(() => StatusText.Text = message);
}
private void OnQueued(string path)
{
UpdateUpload(path, UploadStatus.Queued);
UpdateStatusIfAllowed($"Wartet: {Path.GetFileName(path)}", false);
}
private void OnUploading(string path)
{
UpdateUpload(path, UploadStatus.Uploading);
UpdateStatusIfAllowed($"Upload läuft: {Path.GetFileName(path)}", false);
}
private void OnSuccess(string path)
{
_failedPaths.Remove(path);
UpdateUpload(path, UploadStatus.Success);
UpdateStatusIfAllowed($"Hochgeladen: {Path.GetFileName(path)}", false);
}
private void OnFailure(string path)
{
_failedPaths.Add(path);
UpdateUpload(path, UploadStatus.Failed);
UpdateStatusIfAllowed($"Upload fehlgeschlagen: {Path.GetFileName(path)}", true);
UpdateRetryButton();
}
private void UpdateUpload(string path, UploadStatus status)
{
Dispatcher.UIThread.Post(() =>
{
if (!_uploadsByPath.TryGetValue(path, out var item))
{
item = new UploadItem(path);
_uploadsByPath[path] = item;
RecentUploads.Insert(0, item);
}
item.Status = status;
LastUploadText.Text = status == UploadStatus.Success
? $"Letzter Upload: {item.UpdatedLabel}"
: LastUploadText.Text;
while (RecentUploads.Count > 3)
{
var last = RecentUploads[^1];
_uploadsByPath.Remove(last.Path);
RecentUploads.RemoveAt(RecentUploads.Count - 1);
}
UpdateRetryButton();
});
}
private void UpdateStatusIfAllowed(string message, bool important)
{
var quiet = QuietToggle?.IsChecked ?? false;
if (quiet && !important)
{
return;
}
UpdateStatus(message);
}
private void UpdateRetryButton()
{
RetryFailedButton.IsEnabled = _failedPaths.Count > 0;
}
private void RetryFailedButton_Click(object? sender, RoutedEventArgs e)
{
foreach (var path in _failedPaths.ToList())
{
_uploadService.Enqueue(path, OnQueued);
}
_failedPaths.Clear();
UpdateRetryButton();
}
private void UpdateSteps()
{
var hasCode = !string.IsNullOrWhiteSpace(_settings.UploadUrl);
var hasFolder = !string.IsNullOrWhiteSpace(_settings.WatchFolder);
var ready = hasCode && hasFolder;
StepCodeText.Text = hasCode ? "1. Code eingeben ✓" : "1. Code eingeben";
StepFolderText.Text = hasFolder ? "2. Upload-Ordner wählen ✓" : "2. Upload-Ordner wählen";
StepReadyText.Text = ready ? "3. Upload läuft ✓" : "3. Upload läuft";
}
private string? ResolveUploadUrl(string? uploadUrl)
{
if (string.IsNullOrWhiteSpace(uploadUrl))
{
return uploadUrl;
}
if (Uri.TryCreate(uploadUrl, UriKind.Absolute, out _))
{
return uploadUrl;
}
var baseUri = new Uri(_settings.BaseUrl ?? DefaultBaseUrl, UriKind.Absolute);
return new Uri(baseUri, uploadUrl).ToString();
}
}

View File

@@ -0,0 +1,30 @@
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("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; }
}

View File

@@ -0,0 +1,11 @@
namespace PhotoboothUploader.Models;
public sealed class PhotoboothSettings
{
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; }
}

View File

@@ -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));
}
}

View File

@@ -0,0 +1,21 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>WinExe</OutputType>
<TargetFramework>net9.0</TargetFramework>
<Nullable>enable</Nullable>
<ApplicationManifest>app.manifest</ApplicationManifest>
<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>
</Project>

View 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();
}

View File

@@ -0,0 +1,50 @@
using System;
using System.Net.Http;
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 readonly HttpClient _httpClient;
private readonly JsonSerializerOptions _jsonOptions = new()
{
PropertyNameCaseInsensitive = true,
};
public PhotoboothConnectClient(string baseUrl)
{
_httpClient = new HttpClient
{
BaseAddress = new Uri(baseUrl),
};
}
public async Task<PhotoboothConnectResponse> RedeemAsync(string code, CancellationToken cancellationToken = default)
{
var response = await _httpClient.PostAsJsonAsync("/api/v1/photobooth/connect", new { code }, cancellationToken);
var payload = await response.Content.ReadFromJsonAsync<PhotoboothConnectResponse>(_jsonOptions, cancellationToken);
if (payload is null)
{
return new PhotoboothConnectResponse
{
Message = response.ReasonPhrase ?? "Verbindung fehlgeschlagen.",
};
}
if (!response.IsSuccessStatusCode)
{
return new PhotoboothConnectResponse
{
Message = payload.Message ?? "Verbindung fehlgeschlagen.",
};
}
return payload;
}
}

View File

@@ -0,0 +1,45 @@
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 SettingsStore()
{
var basePath = Path.Combine(
Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData),
"Fotospiel",
"PhotoboothUploader");
Directory.CreateDirectory(basePath);
SettingsPath = Path.Combine(basePath, "settings.json");
}
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);
}
}

View File

@@ -0,0 +1,161 @@
using System;
using System.Collections.Concurrent;
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 readonly Channel<string> _queue = Channel.CreateUnbounded<string>();
private readonly ConcurrentDictionary<string, byte> _pending = new(StringComparer.OrdinalIgnoreCase);
private CancellationTokenSource? _cts;
public void Start(
PhotoboothSettings settings,
Action<string> onQueued,
Action<string> onUploading,
Action<string> onSuccess,
Action<string> onFailure)
{
Stop();
_cts = new CancellationTokenSource();
_ = Task.Run(() => WorkerAsync(settings, onQueued, onUploading, onSuccess, onFailure, _cts.Token));
}
public void Stop()
{
_cts?.Cancel();
_cts = null;
_pending.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> onFailure,
CancellationToken token)
{
if (string.IsNullOrWhiteSpace(settings.UploadUrl))
{
return;
}
using var client = new HttpClient();
while (await _queue.Reader.WaitToReadAsync(token))
{
while (_queue.Reader.TryRead(out var path))
{
try
{
onUploading(path);
await WaitForFileReadyAsync(path, token);
await UploadAsync(client, settings, path, token);
onSuccess(path);
}
catch (OperationCanceledException)
{
return;
}
catch
{
onFailure(path);
}
finally
{
_pending.TryRemove(path, out _);
}
}
}
}
private static async Task 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;
}
lastSize = size;
await Task.Delay(700, token);
}
}
private static async Task UploadAsync(HttpClient client, PhotoboothSettings settings, string path, CancellationToken token)
{
if (!File.Exists(path))
{
return;
}
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));
var response = await client.PostAsync(settings.UploadUrl, content, token);
response.EnsureSuccessStatusCode();
}
private static string ResolveContentType(string path)
{
return Path.GetExtension(path)?.ToLowerInvariant() switch
{
".png" => "image/png",
".webp" => "image/webp",
_ => "image/jpeg",
};
}
}

View 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>

View File

@@ -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),
],
]; ];

View 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,
];
}
}

View File

@@ -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');
}
};

View File

@@ -0,0 +1,16 @@
<?php
namespace Database\Seeders;
use Illuminate\Database\Seeder;
class PhotoboothConnectCodeSeeder extends Seeder
{
/**
* Run the database seeds.
*/
public function run(): void
{
//
}
}

View File

@@ -2458,7 +2458,7 @@ export async function getTenantPaddleTransactions(cursor?: string): Promise<{
export async function createTenantPaddleCheckout( export async function createTenantPaddleCheckout(
packageId: number, packageId: number,
urls?: { success_url?: string; return_url?: string } urls?: { success_url?: string; return_url?: string }
): Promise<{ checkout_url: string; id: string; expires_at?: string }> { ): Promise<{ checkout_url: string; id: string; expires_at?: string; checkout_session_id?: string }> {
const response = await authorizedFetch('/api/v1/tenant/packages/paddle-checkout', { const response = await authorizedFetch('/api/v1/tenant/packages/paddle-checkout', {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
@@ -2468,12 +2468,22 @@ export async function createTenantPaddleCheckout(
return_url: urls?.return_url, return_url: urls?.return_url,
}), }),
}); });
return await jsonOrThrow<{ checkout_url: string; id: string; expires_at?: string }>( return await jsonOrThrow<{ checkout_url: string; id: string; expires_at?: string; checkout_session_id?: string }>(
response, response,
'Failed to create checkout' 'Failed to create checkout'
); );
} }
export async function getTenantPackageCheckoutStatus(
checkoutSessionId: string,
): Promise<{ status: string; completed_at?: string | null; reason?: string | null; checkout_url?: string | null }> {
const response = await authorizedFetch(`/api/v1/tenant/packages/checkout-session/${checkoutSessionId}/status`);
return await jsonOrThrow<{ status: string; completed_at?: string | null; reason?: string | null; checkout_url?: string | null }>(
response,
'Failed to load checkout status'
);
}
export async function createTenantBillingPortalSession(): Promise<{ url: string }> { export async function createTenantBillingPortalSession(): Promise<{ url: string }> {
const response = await authorizedFetch('/api/v1/tenant/billing/portal', { const response = await authorizedFetch('/api/v1/tenant/billing/portal', {
method: 'POST', method: 'POST',

View File

@@ -34,6 +34,27 @@
"more": "Weitere Einträge konnten nicht geladen werden.", "more": "Weitere Einträge konnten nicht geladen werden.",
"portal": "Paddle-Portal konnte nicht geöffnet werden." "portal": "Paddle-Portal konnte nicht geöffnet werden."
}, },
"checkoutSuccess": "Checkout abgeschlossen. Dein Paket wird in Kürze aktiviert.",
"checkoutCancelled": "Checkout wurde abgebrochen.",
"checkoutActivated": "Dein Paket ist jetzt aktiv.",
"checkoutPendingTitle": "Paket wird aktiviert",
"checkoutPendingBody": "Das kann ein paar Minuten dauern. Wir aktualisieren den Status, sobald das Paket aktiv ist.",
"checkoutPendingBadge": "Ausstehend",
"checkoutPendingRefresh": "Aktualisieren",
"checkoutPendingDismiss": "Ausblenden",
"checkoutFailedTitle": "Checkout fehlgeschlagen",
"checkoutFailedBody": "Die Zahlung wurde nicht abgeschlossen. Du kannst es erneut versuchen oder den Support kontaktieren.",
"checkoutFailedBadge": "Fehlgeschlagen",
"checkoutFailedRetry": "Erneut versuchen",
"checkoutFailedDismiss": "Ausblenden",
"checkoutActionTitle": "Aktion erforderlich",
"checkoutActionBody": "Schließe die Zahlung ab, um das Paket zu aktivieren.",
"checkoutActionBadge": "Aktion nötig",
"checkoutActionButton": "Checkout fortsetzen",
"checkoutFailureReasons": {
"paddle_failed": "Die Zahlung wurde abgelehnt.",
"paddle_cancelled": "Der Checkout wurde abgebrochen."
},
"sections": { "sections": {
"invoices": { "invoices": {
"title": "Rechnungen & Zahlungen", "title": "Rechnungen & Zahlungen",
@@ -176,6 +197,8 @@
}, },
"common": { "common": {
"all": "Alle", "all": "Alle",
"anonymous": "Anonym",
"error": "Etwas ist schiefgelaufen",
"loadMore": "Mehr laden", "loadMore": "Mehr laden",
"processing": "Verarbeite …", "processing": "Verarbeite …",
"select": "Auswählen", "select": "Auswählen",
@@ -2875,16 +2898,25 @@
"analytics": { "analytics": {
"title": "Analytics", "title": "Analytics",
"upgradeAction": "Upgrade auf Premium", "upgradeAction": "Upgrade auf Premium",
"kpiTitle": "Event-Überblick",
"kpiUploads": "Uploads",
"kpiContributors": "Beitragende",
"kpiLikes": "Likes",
"activityTitle": "Aktivitäts-Zeitachse", "activityTitle": "Aktivitäts-Zeitachse",
"timeframe": "Letzte {{hours}} Stunden",
"timeframeHint": "Ältere Aktivität ausgeblendet",
"uploadsPerHour": "Uploads pro Stunde", "uploadsPerHour": "Uploads pro Stunde",
"noActivity": "Noch keine Uploads", "noActivity": "Noch keine Uploads",
"emptyActionShareQr": "QR-Code teilen",
"contributorsTitle": "Top-Beitragende", "contributorsTitle": "Top-Beitragende",
"likesCount": "{{count}} Likes", "likesCount": "{{count}} Likes",
"likesCount_one": "{{count}} Like", "likesCount_one": "{{count}} Like",
"likesCount_other": "{{count}} Likes", "likesCount_other": "{{count}} Likes",
"noContributors": "Noch keine Beitragenden", "noContributors": "Noch keine Beitragenden",
"emptyActionInvite": "Gäste einladen",
"tasksTitle": "Beliebte Aufgaben", "tasksTitle": "Beliebte Aufgaben",
"noTasks": "Noch keine Aufgabenaktivität", "noTasks": "Noch keine Aufgabenaktivität",
"emptyActionOpenTasks": "Aufgaben öffnen",
"lockedTitle": "Analytics freischalten", "lockedTitle": "Analytics freischalten",
"lockedBody": "Erhalte tiefe Einblicke in die Interaktionen deines Events mit dem Premium-Paket." "lockedBody": "Erhalte tiefe Einblicke in die Interaktionen deines Events mit dem Premium-Paket."
}, },
@@ -2893,6 +2925,26 @@
"subtitle": "Wähle ein Paket, um mehr Funktionen und Limits freizuschalten.", "subtitle": "Wähle ein Paket, um mehr Funktionen und Limits freizuschalten.",
"recommendationTitle": "Empfohlen für dich", "recommendationTitle": "Empfohlen für dich",
"recommendationBody": "Das hervorgehobene Paket enthält das gewünschte Feature.", "recommendationBody": "Das hervorgehobene Paket enthält das gewünschte Feature.",
"compare": {
"title": "Pakete vergleichen",
"helper": "Wische, um Pakete nebeneinander zu vergleichen.",
"toggleCards": "Karten",
"toggleCompare": "Vergleichen",
"headers": {
"plan": "Paket",
"price": "Preis"
},
"rows": {
"photos": "Fotos",
"guests": "Gäste",
"days": "Galerietage"
},
"values": {
"included": "Enthalten",
"notIncluded": "Nicht enthalten",
"unlimited": "Unbegrenzt"
}
},
"select": "Auswählen", "select": "Auswählen",
"manage": "Paket verwalten", "manage": "Paket verwalten",
"limits": { "limits": {
@@ -2906,7 +2958,13 @@
}, },
"features": { "features": {
"advanced_analytics": "Erweiterte Analytics", "advanced_analytics": "Erweiterte Analytics",
"basic_uploads": "Basis-Uploads",
"custom_branding": "Eigenes Branding", "custom_branding": "Eigenes Branding",
"custom_tasks": "Benutzerdefinierte Aufgaben",
"limited_sharing": "Begrenztes Teilen",
"live_slideshow": "Live-Slideshow",
"priority_support": "Priorisierter Support",
"unlimited_sharing": "Unbegrenztes Teilen",
"watermark_removal": "Kein Wasserzeichen" "watermark_removal": "Kein Wasserzeichen"
}, },
"status": { "status": {
@@ -2918,7 +2976,9 @@
}, },
"badges": { "badges": {
"recommended": "Empfohlen", "recommended": "Empfohlen",
"active": "Aktiv" "active": "Aktiv",
"upgrade": "Upgrade",
"downgrade": "Downgrade"
}, },
"confirmTitle": "Kauf bestätigen", "confirmTitle": "Kauf bestätigen",
"confirmSubtitle": "Du upgradest auf:", "confirmSubtitle": "Du upgradest auf:",
@@ -2931,6 +2991,7 @@
"payNow": "Jetzt zahlen", "payNow": "Jetzt zahlen",
"errors": { "errors": {
"checkout": "Checkout fehlgeschlagen" "checkout": "Checkout fehlgeschlagen"
} },
"selectDisabled": "Nicht verfügbar"
} }
} }

View File

@@ -34,6 +34,27 @@
"more": "Unable to load more entries.", "more": "Unable to load more entries.",
"portal": "Unable to open the Paddle portal." "portal": "Unable to open the Paddle portal."
}, },
"checkoutSuccess": "Checkout completed. Your package will activate shortly.",
"checkoutCancelled": "Checkout was cancelled.",
"checkoutActivated": "Your package is now active.",
"checkoutPendingTitle": "Activating your package",
"checkoutPendingBody": "This can take a few minutes. We will update this screen once the package is active.",
"checkoutPendingBadge": "Pending",
"checkoutPendingRefresh": "Refresh",
"checkoutPendingDismiss": "Dismiss",
"checkoutFailedTitle": "Checkout failed",
"checkoutFailedBody": "The payment did not complete. You can try again or contact support.",
"checkoutFailedBadge": "Failed",
"checkoutFailedRetry": "Try again",
"checkoutFailedDismiss": "Dismiss",
"checkoutActionTitle": "Action required",
"checkoutActionBody": "Complete your payment to activate the package.",
"checkoutActionBadge": "Action needed",
"checkoutActionButton": "Continue checkout",
"checkoutFailureReasons": {
"paddle_failed": "The payment was declined.",
"paddle_cancelled": "The checkout was cancelled."
},
"sections": { "sections": {
"invoices": { "invoices": {
"title": "Invoices & payments", "title": "Invoices & payments",
@@ -172,6 +193,8 @@
}, },
"common": { "common": {
"all": "All", "all": "All",
"anonymous": "Anonymous",
"error": "Something went wrong",
"loadMore": "Load more", "loadMore": "Load more",
"processing": "Processing…", "processing": "Processing…",
"select": "Select", "select": "Select",
@@ -2879,16 +2902,25 @@
"analytics": { "analytics": {
"title": "Analytics", "title": "Analytics",
"upgradeAction": "Upgrade to Premium", "upgradeAction": "Upgrade to Premium",
"kpiTitle": "Event snapshot",
"kpiUploads": "Uploads",
"kpiContributors": "Contributors",
"kpiLikes": "Likes",
"activityTitle": "Activity Timeline", "activityTitle": "Activity Timeline",
"timeframe": "Last {{hours}} hours",
"timeframeHint": "Older activity hidden",
"uploadsPerHour": "Uploads per hour", "uploadsPerHour": "Uploads per hour",
"noActivity": "No uploads yet", "noActivity": "No uploads yet",
"emptyActionShareQr": "Share your QR code",
"contributorsTitle": "Top Contributors", "contributorsTitle": "Top Contributors",
"likesCount": "{{count}} likes", "likesCount": "{{count}} likes",
"likesCount_one": "{{count}} like", "likesCount_one": "{{count}} like",
"likesCount_other": "{{count}} likes", "likesCount_other": "{{count}} likes",
"noContributors": "No contributors yet", "noContributors": "No contributors yet",
"emptyActionInvite": "Invite guests",
"tasksTitle": "Popular Tasks", "tasksTitle": "Popular Tasks",
"noTasks": "No task activity yet", "noTasks": "No task activity yet",
"emptyActionOpenTasks": "Open tasks",
"lockedTitle": "Unlock Analytics", "lockedTitle": "Unlock Analytics",
"lockedBody": "Get deep insights into your event engagement with the Premium package." "lockedBody": "Get deep insights into your event engagement with the Premium package."
}, },
@@ -2897,6 +2929,26 @@
"subtitle": "Choose a package to unlock more features and limits.", "subtitle": "Choose a package to unlock more features and limits.",
"recommendationTitle": "Recommended for you", "recommendationTitle": "Recommended for you",
"recommendationBody": "The highlighted package includes the feature you requested.", "recommendationBody": "The highlighted package includes the feature you requested.",
"compare": {
"title": "Compare plans",
"helper": "Swipe to compare packages side by side.",
"toggleCards": "Cards",
"toggleCompare": "Compare",
"headers": {
"plan": "Plan",
"price": "Price"
},
"rows": {
"photos": "Photos",
"guests": "Guests",
"days": "Gallery days"
},
"values": {
"included": "Included",
"notIncluded": "Not included",
"unlimited": "Unlimited"
}
},
"select": "Select", "select": "Select",
"manage": "Manage Plan", "manage": "Manage Plan",
"limits": { "limits": {
@@ -2910,7 +2962,13 @@
}, },
"features": { "features": {
"advanced_analytics": "Advanced Analytics", "advanced_analytics": "Advanced Analytics",
"basic_uploads": "Basic uploads",
"custom_branding": "Custom Branding", "custom_branding": "Custom Branding",
"custom_tasks": "Custom tasks",
"limited_sharing": "Limited sharing",
"live_slideshow": "Live slideshow",
"priority_support": "Priority support",
"unlimited_sharing": "Unlimited sharing",
"watermark_removal": "No Watermark" "watermark_removal": "No Watermark"
}, },
"status": { "status": {
@@ -2922,7 +2980,9 @@
}, },
"badges": { "badges": {
"recommended": "Recommended", "recommended": "Recommended",
"active": "Active" "active": "Active",
"upgrade": "Upgrade",
"downgrade": "Downgrade"
}, },
"confirmTitle": "Confirm Purchase", "confirmTitle": "Confirm Purchase",
"confirmSubtitle": "You are upgrading to:", "confirmSubtitle": "You are upgrading to:",
@@ -2935,6 +2995,7 @@
"payNow": "Pay Now", "payNow": "Pay Now",
"errors": { "errors": {
"checkout": "Checkout failed" "checkout": "Checkout failed"
} },
"selectDisabled": "Not available"
} }
} }

View File

@@ -12,6 +12,7 @@ import {
createTenantBillingPortalSession, createTenantBillingPortalSession,
getTenantPackagesOverview, getTenantPackagesOverview,
getTenantPaddleTransactions, getTenantPaddleTransactions,
getTenantPackageCheckoutStatus,
TenantPackageSummary, TenantPackageSummary,
PaddleTransactionSummary, PaddleTransactionSummary,
} from '../api'; } from '../api';
@@ -27,6 +28,14 @@ import {
getPackageFeatureLabel, getPackageFeatureLabel,
getPackageLimitEntries, getPackageLimitEntries,
} from './lib/packageSummary'; } from './lib/packageSummary';
import {
PendingCheckout,
loadPendingCheckout,
shouldClearPendingCheckout,
storePendingCheckout,
} from './lib/billingCheckout';
const CHECKOUT_POLL_INTERVAL_MS = 10000;
export default function MobileBillingPage() { export default function MobileBillingPage() {
const { t } = useTranslation('management'); const { t } = useTranslation('management');
@@ -40,6 +49,11 @@ export default function MobileBillingPage() {
const [loading, setLoading] = React.useState(true); const [loading, setLoading] = React.useState(true);
const [error, setError] = React.useState<string | null>(null); const [error, setError] = React.useState<string | null>(null);
const [portalBusy, setPortalBusy] = React.useState(false); const [portalBusy, setPortalBusy] = React.useState(false);
const [pendingCheckout, setPendingCheckout] = React.useState<PendingCheckout | null>(() => loadPendingCheckout());
const [checkoutStatus, setCheckoutStatus] = React.useState<string | null>(null);
const [checkoutStatusReason, setCheckoutStatusReason] = React.useState<string | null>(null);
const [checkoutActionUrl, setCheckoutActionUrl] = React.useState<string | null>(null);
const lastCheckoutStatusRef = React.useRef<string | null>(null);
const packagesRef = React.useRef<HTMLDivElement | null>(null); const packagesRef = React.useRef<HTMLDivElement | null>(null);
const invoicesRef = React.useRef<HTMLDivElement | null>(null); const invoicesRef = React.useRef<HTMLDivElement | null>(null);
const supportEmail = 'support@fotospiel.de'; const supportEmail = 'support@fotospiel.de';
@@ -95,6 +109,11 @@ export default function MobileBillingPage() {
} }
}, [portalBusy, t]); }, [portalBusy, t]);
const persistPendingCheckout = React.useCallback((next: PendingCheckout | null) => {
setPendingCheckout(next);
storePendingCheckout(next);
}, []);
React.useEffect(() => { React.useEffect(() => {
void load(); void load();
}, [load]); }, [load]);
@@ -108,6 +127,115 @@ export default function MobileBillingPage() {
} }
}, [location.hash, loading]); }, [location.hash, loading]);
React.useEffect(() => {
if (!location.search) {
return;
}
const params = new URLSearchParams(location.search);
const checkout = params.get('checkout');
const packageId = params.get('package_id');
if (!checkout) {
return;
}
if (checkout === 'success') {
const packageIdNumber = packageId ? Number(packageId) : null;
const existingSessionId = pendingCheckout?.checkoutSessionId ?? null;
const pendingEntry = {
packageId: Number.isFinite(packageIdNumber) ? packageIdNumber : null,
checkoutSessionId: existingSessionId,
startedAt: Date.now(),
};
persistPendingCheckout(pendingEntry);
toast.success(t('billing.checkoutSuccess', 'Checkout completed. Your package will activate shortly.'));
} else if (checkout === 'cancel') {
persistPendingCheckout(null);
toast(t('billing.checkoutCancelled', 'Checkout was cancelled.'));
}
params.delete('checkout');
params.delete('package_id');
navigate(
{
pathname: location.pathname,
search: params.toString(),
hash: location.hash,
},
{ replace: true },
);
}, [location.hash, location.pathname, location.search, navigate, pendingCheckout?.checkoutSessionId, persistPendingCheckout, t]);
React.useEffect(() => {
if (!pendingCheckout) {
return;
}
if (shouldClearPendingCheckout(pendingCheckout, activePackage?.package_id ?? null)) {
persistPendingCheckout(null);
}
}, [activePackage?.package_id, pendingCheckout, persistPendingCheckout]);
React.useEffect(() => {
if (!pendingCheckout?.checkoutSessionId) {
setCheckoutStatus(null);
setCheckoutStatusReason(null);
setCheckoutActionUrl(null);
lastCheckoutStatusRef.current = null;
return;
}
let active = true;
let intervalId: ReturnType<typeof setInterval> | null = null;
const poll = async () => {
try {
const result = await getTenantPackageCheckoutStatus(pendingCheckout.checkoutSessionId as string);
if (!active) {
return;
}
setCheckoutStatus(result.status);
setCheckoutStatusReason(result.reason ?? null);
setCheckoutActionUrl(typeof result.checkout_url === 'string' ? result.checkout_url : null);
const lastStatus = lastCheckoutStatusRef.current;
lastCheckoutStatusRef.current = result.status;
if (result.status === 'completed') {
persistPendingCheckout(null);
if (lastStatus !== 'completed') {
toast.success(t('billing.checkoutActivated', 'Your package is now active.'));
}
await load();
if (intervalId) {
clearInterval(intervalId);
}
return;
}
if (result.status === 'failed' || result.status === 'cancelled') {
if (intervalId) {
clearInterval(intervalId);
}
}
} catch {
if (!active) {
return;
}
}
};
void poll();
intervalId = setInterval(poll, CHECKOUT_POLL_INTERVAL_MS);
return () => {
active = false;
if (intervalId) {
clearInterval(intervalId);
}
};
}, [load, pendingCheckout?.checkoutSessionId, persistPendingCheckout, t]);
return ( return (
<MobileShell <MobileShell
activeTab="profile" activeTab="profile"
@@ -127,6 +255,109 @@ export default function MobileBillingPage() {
<CTAButton label={t('billing.actions.refresh', 'Refresh')} tone="ghost" onPress={load} /> <CTAButton label={t('billing.actions.refresh', 'Refresh')} tone="ghost" onPress={load} />
</MobileCard> </MobileCard>
) : null} ) : null}
{pendingCheckout && (checkoutStatus === 'failed' || checkoutStatus === 'cancelled') ? (
<MobileCard borderColor={danger} backgroundColor="$red1" space="$2">
<XStack alignItems="center" justifyContent="space-between">
<YStack space="$0.5" flex={1}>
<Text fontSize="$sm" fontWeight="800" color={danger}>
{t('billing.checkoutFailedTitle', 'Checkout failed')}
</Text>
<Text fontSize="$xs" color={muted}>
{t(
'billing.checkoutFailedBody',
'The payment did not complete. You can try again or contact support.'
)}
</Text>
{checkoutStatusReason ? (
<Text fontSize="$xs" color={muted}>
{t(`billing.checkoutFailureReasons.${checkoutStatusReason}`, checkoutStatusReason)}
</Text>
) : null}
</YStack>
<PillBadge tone="danger">
{t('billing.checkoutFailedBadge', 'Failed')}
</PillBadge>
</XStack>
<XStack space="$2">
<CTAButton
label={t('billing.checkoutFailedRetry', 'Try again')}
onPress={() => navigate(adminPath('/mobile/billing/shop'))}
fullWidth={false}
/>
<CTAButton
label={t('billing.checkoutFailedDismiss', 'Dismiss')}
tone="ghost"
onPress={() => persistPendingCheckout(null)}
fullWidth={false}
/>
</XStack>
</MobileCard>
) : null}
{pendingCheckout && checkoutStatus === 'requires_customer_action' ? (
<MobileCard borderColor={accentSoft} backgroundColor={accentSoft} space="$2">
<XStack alignItems="center" justifyContent="space-between">
<YStack space="$0.5" flex={1}>
<Text fontSize="$sm" fontWeight="800" color={textStrong}>
{t('billing.checkoutActionTitle', 'Action required')}
</Text>
<Text fontSize="$xs" color={muted}>
{t('billing.checkoutActionBody', 'Complete your payment to activate the package.')}
</Text>
</YStack>
<PillBadge tone="warning">
{t('billing.checkoutActionBadge', 'Action needed')}
</PillBadge>
</XStack>
<XStack space="$2">
<CTAButton
label={t('billing.checkoutActionButton', 'Continue checkout')}
onPress={() => {
if (checkoutActionUrl && typeof window !== 'undefined') {
window.open(checkoutActionUrl, '_blank', 'noopener');
return;
}
navigate(adminPath('/mobile/billing/shop'));
}}
fullWidth={false}
/>
<CTAButton
label={t('billing.checkoutFailedDismiss', 'Dismiss')}
tone="ghost"
onPress={() => persistPendingCheckout(null)}
fullWidth={false}
/>
</XStack>
</MobileCard>
) : null}
{pendingCheckout && checkoutStatus !== 'failed' && checkoutStatus !== 'cancelled' && checkoutStatus !== 'requires_customer_action' ? (
<MobileCard borderColor={accentSoft} backgroundColor={accentSoft} space="$2">
<XStack alignItems="center" justifyContent="space-between">
<YStack space="$0.5" flex={1}>
<Text fontSize="$sm" fontWeight="800" color={textStrong}>
{t('billing.checkoutPendingTitle', 'Activating your package')}
</Text>
<Text fontSize="$xs" color={muted}>
{t(
'billing.checkoutPendingBody',
'This can take a few minutes. We will update this screen once the package is active.'
)}
</Text>
</YStack>
<PillBadge tone="warning">
{t('billing.checkoutPendingBadge', 'Pending')}
</PillBadge>
</XStack>
<XStack space="$2">
<CTAButton label={t('billing.checkoutPendingRefresh', 'Refresh')} onPress={load} fullWidth={false} />
<CTAButton
label={t('billing.checkoutPendingDismiss', 'Dismiss')}
tone="ghost"
onPress={() => persistPendingCheckout(null)}
fullWidth={false}
/>
</XStack>
</MobileCard>
) : null}
<MobileCard space="$2" ref={packagesRef as any}> <MobileCard space="$2" ref={packagesRef as any}>
<XStack alignItems="center" space="$2"> <XStack alignItems="center" space="$2">
@@ -535,4 +766,4 @@ function formatDate(value: string | null | undefined): string {
const date = new Date(value); const date = new Date(value);
if (Number.isNaN(date.getTime())) return '—'; if (Number.isNaN(date.getTime())) return '—';
return date.toLocaleDateString(undefined, { day: '2-digit', month: 'short', year: 'numeric' }); return date.toLocaleDateString(undefined, { day: '2-digit', month: 'short', year: 'numeric' });
} }

View File

@@ -2,17 +2,18 @@ import React from 'react';
import { useParams, useNavigate } from 'react-router-dom'; import { useParams, useNavigate } from 'react-router-dom';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { useQuery } from '@tanstack/react-query'; import { useQuery } from '@tanstack/react-query';
import { BarChart2, TrendingUp, Users, ListTodo, Lock, Trophy, Calendar } from 'lucide-react'; import { TrendingUp, Users, ListTodo, Lock, Trophy } from 'lucide-react';
import { YStack, XStack } from '@tamagui/stacks'; import { YStack, XStack } from '@tamagui/stacks';
import { SizableText as Text } from '@tamagui/text'; import { SizableText as Text } from '@tamagui/text';
import { format, parseISO } from 'date-fns'; import { format, parseISO } from 'date-fns';
import { de, enGB } from 'date-fns/locale'; import { de, enGB } from 'date-fns/locale';
import { MobileShell } from './components/MobileShell'; import { MobileShell } from './components/MobileShell';
import { MobileCard, CTAButton, SkeletonCard } from './components/Primitives'; import { MobileCard, CTAButton, KpiTile, SkeletonCard } from './components/Primitives';
import { getEventAnalytics, EventAnalytics } from '../api'; import { getEventAnalytics, EventAnalytics } from '../api';
import { ApiError } from '../lib/apiError'; import { ApiError } from '../lib/apiError';
import { useAdminTheme } from './theme'; import { useAdminTheme } from './theme';
import { resolveMaxCount, resolveTimelineHours } from './lib/analytics';
import { adminPath } from '../constants'; import { adminPath } from '../constants';
export default function MobileEventAnalyticsPage() { export default function MobileEventAnalyticsPage() {
@@ -97,9 +98,17 @@ export default function MobileEventAnalyticsPage() {
const hasTimeline = timeline.length > 0; const hasTimeline = timeline.length > 0;
const hasContributors = contributors.length > 0; const hasContributors = contributors.length > 0;
const hasTasks = tasks.length > 0; const hasTasks = tasks.length > 0;
const fallbackHours = 12;
const rawTimelineHours = resolveTimelineHours(timeline.map((point) => point.timestamp), fallbackHours);
const timeframeHours = Math.min(rawTimelineHours, fallbackHours);
const isTimeframeCapped = rawTimelineHours > fallbackHours;
// Prepare chart data // Prepare chart data
const maxCount = Math.max(...timeline.map((p) => p.count), 1); const maxTimelineCount = resolveMaxCount(timeline.map((point) => point.count));
const maxTaskCount = resolveMaxCount(tasks.map((task) => task.count));
const totalUploads = timeline.reduce((total, point) => total + point.count, 0);
const totalLikes = contributors.reduce((total, contributor) => total + contributor.likes, 0);
const totalContributors = contributors.length;
return ( return (
<MobileShell <MobileShell
@@ -108,6 +117,28 @@ export default function MobileEventAnalyticsPage() {
onBack={() => navigate(-1)} onBack={() => navigate(-1)}
> >
<YStack space="$4"> <YStack space="$4">
<YStack space="$2">
<Text fontSize="$sm" fontWeight="800" color={textStrong}>
{t('analytics.kpiTitle', 'Event snapshot')}
</Text>
<XStack space="$2" flexWrap="wrap">
<KpiTile
icon={TrendingUp}
label={t('analytics.kpiUploads', 'Uploads')}
value={totalUploads}
/>
<KpiTile
icon={Users}
label={t('analytics.kpiContributors', 'Contributors')}
value={totalContributors}
/>
<KpiTile
icon={Trophy}
label={t('analytics.kpiLikes', 'Likes')}
value={totalLikes}
/>
</XStack>
</YStack>
{/* Activity Timeline */} {/* Activity Timeline */}
<MobileCard space="$3" borderColor={border} backgroundColor={surface}> <MobileCard space="$3" borderColor={border} backgroundColor={surface}>
<XStack alignItems="center" space="$2"> <XStack alignItems="center" space="$2">
@@ -116,12 +147,22 @@ export default function MobileEventAnalyticsPage() {
{t('analytics.activityTitle', 'Activity Timeline')} {t('analytics.activityTitle', 'Activity Timeline')}
</Text> </Text>
</XStack> </XStack>
<YStack space="$0.5">
<Text fontSize="$xs" color={muted}>
{t('analytics.timeframe', 'Last {{hours}} hours', { hours: timeframeHours })}
</Text>
{isTimeframeCapped ? (
<Text fontSize="$xs" color={muted}>
{t('analytics.timeframeHint', 'Older activity hidden')}
</Text>
) : null}
</YStack>
{hasTimeline ? ( {hasTimeline ? (
<YStack height={180} justifyContent="flex-end" space="$2"> <YStack height={180} justifyContent="flex-end" space="$2">
<XStack alignItems="flex-end" justifyContent="space-between" height={150} gap="$1"> <XStack alignItems="flex-end" justifyContent="space-between" height={150} gap="$1">
{timeline.map((point, index) => { {timeline.map((point, index) => {
const heightPercent = (point.count / maxCount) * 100; const heightPercent = (point.count / maxTimelineCount) * 100;
const date = parseISO(point.timestamp); const date = parseISO(point.timestamp);
// Show label every 3rd point or if few points // Show label every 3rd point or if few points
const showLabel = timeline.length < 8 || index % 3 === 0; const showLabel = timeline.length < 8 || index % 3 === 0;
@@ -138,7 +179,7 @@ export default function MobileEventAnalyticsPage() {
/> />
{showLabel && ( {showLabel && (
<Text fontSize={10} color={muted} numberOfLines={1}> <Text fontSize={10} color={muted} numberOfLines={1}>
{format(date, 'HH:mm')} {format(date, 'HH:mm', { locale: dateLocale })}
</Text> </Text>
)} )}
</YStack> </YStack>
@@ -150,7 +191,11 @@ export default function MobileEventAnalyticsPage() {
</Text> </Text>
</YStack> </YStack>
) : ( ) : (
<EmptyState message={t('analytics.noActivity', 'No uploads yet')} /> <EmptyState
message={t('analytics.noActivity', 'No uploads yet')}
actionLabel={t('analytics.emptyActionShareQr', 'Share your QR code')}
onAction={() => slug && navigate(adminPath(`/mobile/events/${slug}/qr`))}
/>
)} )}
</MobileCard> </MobileCard>
@@ -196,7 +241,11 @@ export default function MobileEventAnalyticsPage() {
))} ))}
</YStack> </YStack>
) : ( ) : (
<EmptyState message={t('analytics.noContributors', 'No contributors yet')} /> <EmptyState
message={t('analytics.noContributors', 'No contributors yet')}
actionLabel={t('analytics.emptyActionInvite', 'Invite guests')}
onAction={() => slug && navigate(adminPath(`/mobile/events/${slug}/members`))}
/>
)} )}
</MobileCard> </MobileCard>
@@ -212,7 +261,6 @@ export default function MobileEventAnalyticsPage() {
{hasTasks ? ( {hasTasks ? (
<YStack space="$3"> <YStack space="$3">
{tasks.map((task) => { {tasks.map((task) => {
const maxTaskCount = Math.max(...tasks.map(t => t.count), 1);
const percent = (task.count / maxTaskCount) * 100; const percent = (task.count / maxTaskCount) * 100;
return ( return (
<YStack key={task.task_id} space="$1"> <YStack key={task.task_id} space="$1">
@@ -237,7 +285,11 @@ export default function MobileEventAnalyticsPage() {
})} })}
</YStack> </YStack>
) : ( ) : (
<EmptyState message={t('analytics.noTasks', 'No task activity yet')} /> <EmptyState
message={t('analytics.noTasks', 'No task activity yet')}
actionLabel={t('analytics.emptyActionOpenTasks', 'Open tasks')}
onAction={() => slug && navigate(adminPath(`/mobile/events/${slug}/tasks`))}
/>
)} )}
</MobileCard> </MobileCard>
</YStack> </YStack>
@@ -245,13 +297,24 @@ export default function MobileEventAnalyticsPage() {
); );
} }
function EmptyState({ message }: { message: string }) { function EmptyState({
message,
actionLabel,
onAction,
}: {
message: string;
actionLabel?: string;
onAction?: () => void;
}) {
const { muted } = useAdminTheme(); const { muted } = useAdminTheme();
return ( return (
<YStack padding="$4" alignItems="center" justifyContent="center"> <YStack padding="$4" alignItems="center" justifyContent="center" space="$2">
<Text fontSize="$sm" color={muted}> <Text fontSize="$sm" color={muted}>
{message} {message}
</Text> </Text>
{actionLabel && onAction ? (
<CTAButton label={actionLabel} tone="ghost" fullWidth={false} onPress={onAction} />
) : null}
</YStack> </YStack>
); );
} }

View File

@@ -1,25 +1,31 @@
import React from 'react'; import React from 'react';
import { useNavigate, useLocation } from 'react-router-dom'; import { useNavigate, useLocation } from 'react-router-dom';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { Check, ChevronRight, ShieldCheck, ShoppingBag, Sparkles, Star } from 'lucide-react'; import { Check, ChevronRight, ShieldCheck, Sparkles, X } from 'lucide-react';
import { YStack, XStack } from '@tamagui/stacks'; import { YStack, XStack } from '@tamagui/stacks';
import { SizableText as Text } from '@tamagui/text'; import { SizableText as Text } from '@tamagui/text';
import { Checkbox } from '@tamagui/checkbox'; import { Checkbox } from '@tamagui/checkbox';
import toast from 'react-hot-toast';
import { MobileShell } from './components/MobileShell'; import { MobileShell } from './components/MobileShell';
import { MobileCard, CTAButton, PillBadge, SkeletonCard } from './components/Primitives'; import { MobileCard, CTAButton, PillBadge, SkeletonCard } from './components/Primitives';
import { useAdminTheme } from './theme'; import { useAdminTheme } from './theme';
import { getPackages, createTenantPaddleCheckout, Package, getTenantPackagesOverview, TenantPackageSummary } from '../api'; import { getPackages, Package, getTenantPackagesOverview, TenantPackageSummary } from '../api';
import { getApiErrorMessage } from '../lib/apiError';
import { useQuery } from '@tanstack/react-query'; import { useQuery } from '@tanstack/react-query';
import {
buildPackageComparisonRows,
classifyPackageChange,
getEnabledPackageFeatures,
selectRecommendedPackageId,
} from './lib/packageShop';
import { usePackageCheckout } from './hooks/usePackageCheckout';
export default function MobilePackageShopPage() { export default function MobilePackageShopPage() {
const { t } = useTranslation('management'); const { t } = useTranslation('management');
const navigate = useNavigate(); const navigate = useNavigate();
const location = useLocation(); const location = useLocation();
const { textStrong, muted, border, primary, surface, accentSoft, warningText } = useAdminTheme(); const { textStrong, muted, border, primary, accentSoft } = useAdminTheme();
const [selectedPackage, setSelectedPackage] = React.useState<Package | null>(null); const [selectedPackage, setSelectedPackage] = React.useState<Package | null>(null);
const [viewMode, setViewMode] = React.useState<'cards' | 'compare'>('cards');
// Extract recommended feature from URL // Extract recommended feature from URL
const searchParams = new URLSearchParams(location.search); const searchParams = new URLSearchParams(location.search);
@@ -57,19 +63,36 @@ export default function MobilePackageShopPage() {
); );
} }
const activePackageId = inventory?.activePackage?.package_id ?? null;
const activeCatalogPackage = (catalog ?? []).find((pkg) => pkg.id === activePackageId) ?? null;
const recommendedPackageId = selectRecommendedPackageId(catalog ?? [], recommendedFeature, activeCatalogPackage);
// Merge and sort packages // Merge and sort packages
const sortedPackages = [...(catalog || [])].sort((a, b) => { const sortedPackages = [...(catalog || [])].sort((a, b) => {
// 1. Recommended feature first if (recommendedPackageId) {
const aHasFeature = recommendedFeature && a.features?.[recommendedFeature]; if (a.id === recommendedPackageId && b.id !== recommendedPackageId) return -1;
const bHasFeature = recommendedFeature && b.features?.[recommendedFeature]; if (b.id === recommendedPackageId && a.id !== recommendedPackageId) return 1;
if (aHasFeature && !bHasFeature) return -1; }
if (!aHasFeature && bHasFeature) return 1;
// 2. Inventory status (Owned packages later if they are fully used, but usually we want to show active stuff)
// Actually, let's keep price sorting as secondary
return a.price - b.price; return a.price - b.price;
}); });
const packageEntries = sortedPackages.map((pkg) => {
const owned = inventory?.packages?.find((entry) => entry.package_id === pkg.id);
const isActive = inventory?.activePackage?.package_id === pkg.id;
const isRecommended = recommendedPackageId ? pkg.id === recommendedPackageId : false;
const { isUpgrade, isDowngrade } = classifyPackageChange(pkg, activeCatalogPackage);
return {
pkg,
owned,
isActive,
isRecommended,
isUpgrade,
isDowngrade,
};
});
return ( return (
<MobileShell title={t('shop.title', 'Upgrade Package')} onBack={() => navigate(-1)} activeTab="profile"> <MobileShell title={t('shop.title', 'Upgrade Package')} onBack={() => navigate(-1)} activeTab="profile">
<YStack space="$4"> <YStack space="$4">
@@ -93,23 +116,45 @@ export default function MobilePackageShopPage() {
</Text> </Text>
</YStack> </YStack>
<YStack space="$3"> {packageEntries.length > 1 ? (
{sortedPackages.map((pkg) => { <XStack space="$2" paddingHorizontal="$2">
const owned = inventory?.packages?.find(p => p.package_id === pkg.id); <CTAButton
const isActive = inventory?.activePackage?.package_id === pkg.id; label={t('shop.compare.toggleCards', 'Cards')}
const isRecommended = recommendedFeature && pkg.features?.[recommendedFeature]; tone={viewMode === 'cards' ? 'primary' : 'ghost'}
fullWidth={false}
onPress={() => setViewMode('cards')}
style={{ flex: 1 }}
/>
<CTAButton
label={t('shop.compare.toggleCompare', 'Compare')}
tone={viewMode === 'compare' ? 'primary' : 'ghost'}
fullWidth={false}
onPress={() => setViewMode('compare')}
style={{ flex: 1 }}
/>
</XStack>
) : null}
return ( <YStack space="$3">
{viewMode === 'compare' ? (
<PackageShopCompareView
entries={packageEntries}
onSelect={(pkg) => setSelectedPackage(pkg)}
/>
) : (
packageEntries.map((entry) => (
<PackageShopCard <PackageShopCard
key={pkg.id} key={entry.pkg.id}
pkg={pkg} pkg={entry.pkg}
owned={owned} owned={entry.owned}
isActive={isActive} isActive={entry.isActive}
isRecommended={isRecommended} isRecommended={entry.isRecommended}
onSelect={() => setSelectedPackage(pkg)} isUpgrade={entry.isUpgrade}
isDowngrade={entry.isDowngrade}
onSelect={() => setSelectedPackage(entry.pkg)}
/> />
); ))
})} )}
</YStack> </YStack>
</YStack> </YStack>
</MobileShell> </MobileShell>
@@ -121,34 +166,34 @@ function PackageShopCard({
owned, owned,
isActive, isActive,
isRecommended, isRecommended,
isUpgrade,
isDowngrade,
onSelect onSelect
}: { }: {
pkg: Package; pkg: Package;
owned?: TenantPackageSummary; owned?: TenantPackageSummary;
isActive?: boolean; isActive?: boolean;
isRecommended?: any; isRecommended?: any;
isUpgrade?: boolean;
isDowngrade?: boolean;
onSelect: () => void onSelect: () => void
}) { }) {
const { textStrong, muted, border, primary, accentSoft } = useAdminTheme(); const { textStrong, muted, border, primary, accentSoft } = useAdminTheme();
const { t } = useTranslation('management'); const { t } = useTranslation('management');
const hasRemainingEvents = owned && (owned.remaining_events === null || owned.remaining_events > 0); const statusLabel = getPackageStatusLabel({ t, isActive, owned });
const statusLabel = isActive const isSubdued = Boolean((isDowngrade || !isUpgrade) && !isActive);
? t('shop.status.active', 'Active Plan') const canSelect = canSelectPackage(isUpgrade, isActive);
: owned
? (owned.remaining_events !== null
? t('shop.status.remaining', '{{count}} Events left', { count: owned.remaining_events })
: t('shop.status.owned', 'Purchased'))
: null;
return ( return (
<MobileCard <MobileCard
onPress={onSelect} onPress={canSelect ? onSelect : undefined}
borderColor={isRecommended ? primary : (isActive ? '$green8' : border)} borderColor={isRecommended ? primary : (isActive ? '$green8' : border)}
borderWidth={isRecommended || isActive ? 2 : 1} borderWidth={isRecommended || isActive ? 2 : 1}
space="$3" space="$3"
pressStyle={{ backgroundColor: accentSoft }} pressStyle={canSelect ? { backgroundColor: accentSoft } : undefined}
backgroundColor={isActive ? '$green1' : undefined} backgroundColor={isActive ? '$green1' : undefined}
style={{ opacity: isSubdued ? 0.6 : 1 }}
> >
<XStack justifyContent="space-between" alignItems="flex-start"> <XStack justifyContent="space-between" alignItems="flex-start">
<YStack space="$1"> <YStack space="$1">
@@ -157,6 +202,8 @@ function PackageShopCard({
{pkg.name} {pkg.name}
</Text> </Text>
{isRecommended && <PillBadge tone="warning">{t('shop.badges.recommended', 'Recommended')}</PillBadge>} {isRecommended && <PillBadge tone="warning">{t('shop.badges.recommended', 'Recommended')}</PillBadge>}
{isUpgrade && !isActive ? <PillBadge tone="success">{t('shop.badges.upgrade', 'Upgrade')}</PillBadge> : null}
{isDowngrade && !isActive ? <PillBadge tone="muted">{t('shop.badges.downgrade', 'Downgrade')}</PillBadge> : null}
{isActive && <PillBadge tone="success">{t('shop.badges.active', 'Active')}</PillBadge>} {isActive && <PillBadge tone="success">{t('shop.badges.active', 'Active')}</PillBadge>}
</XStack> </XStack>
@@ -187,19 +234,25 @@ function PackageShopCard({
) : null} ) : null}
{/* Render specific feature if it was requested */} {/* Render specific feature if it was requested */}
{Object.entries(pkg.features || {}) {getEnabledPackageFeatures(pkg)
.filter(([key, val]) => val === true && (!pkg.max_photos || key !== 'photos')) .filter((key) => !pkg.max_photos || key !== 'photos')
.slice(0, 3) .slice(0, 3)
.map(([key]) => ( .map((key) => (
<FeatureRow key={key} label={t(`shop.features.${key}`, key)} /> <FeatureRow key={key} label={t(`shop.features.${key}`, key)} />
)) ))}
}
</YStack> </YStack>
<CTAButton <CTAButton
label={isActive ? t('shop.manage', 'Manage Plan') : t('shop.select', 'Select')} label={
onPress={onSelect} isActive
tone={isActive ? 'ghost' : 'primary'} ? t('shop.manage', 'Manage Plan')
: isUpgrade
? t('shop.select', 'Select')
: t('shop.selectDisabled', 'Not available')
}
onPress={canSelect ? onSelect : undefined}
tone={isActive || !isUpgrade ? 'ghost' : 'primary'}
disabled={!canSelect}
/> />
</MobileCard> </MobileCard>
); );
@@ -215,28 +268,224 @@ function FeatureRow({ label }: { label: string }) {
) )
} }
type PackageEntry = {
pkg: Package;
owned?: TenantPackageSummary;
isActive: boolean;
isRecommended: boolean;
isUpgrade: boolean;
isDowngrade: boolean;
};
function PackageShopCompareView({
entries,
onSelect,
}: {
entries: PackageEntry[];
onSelect: (pkg: Package) => void;
}) {
const { t } = useTranslation('management');
const { textStrong, muted, border, primary, accentSoft } = useAdminTheme();
const comparisonRows = buildPackageComparisonRows(entries.map((entry) => entry.pkg));
const labelWidth = 140;
const columnWidth = 150;
const rows = [
{ id: 'meta.plan', type: 'meta' as const, label: t('shop.compare.headers.plan', 'Plan') },
{ id: 'meta.price', type: 'meta' as const, label: t('shop.compare.headers.price', 'Price') },
...comparisonRows,
];
const renderRowLabel = (row: typeof rows[number]) => {
if (row.type === 'meta') {
return row.label;
}
if (row.type === 'limit') {
if (row.limitKey === 'max_photos') {
return t('shop.compare.rows.photos', 'Photos');
}
if (row.limitKey === 'max_guests') {
return t('shop.compare.rows.guests', 'Guests');
}
return t('shop.compare.rows.days', 'Gallery days');
}
return t(`shop.features.${row.featureKey}`, row.featureKey);
};
const formatLimitValue = (value: number | null) => {
if (value === null) {
return t('shop.compare.values.unlimited', 'Unlimited');
}
return new Intl.NumberFormat().format(value);
};
return (
<MobileCard space="$3" borderColor={border}>
<YStack space="$1">
<Text fontSize="$md" fontWeight="700" color={textStrong}>
{t('shop.compare.title', 'Compare plans')}
</Text>
<Text fontSize="$xs" color={muted}>
{t('shop.compare.helper', 'Swipe to compare packages side by side.')}
</Text>
</YStack>
<XStack style={{ overflowX: 'auto' }}>
<YStack space="$1.5" minWidth={labelWidth + columnWidth * entries.length}>
{rows.map((row) => (
<XStack key={row.id} borderBottomWidth={1} borderColor={border}>
<YStack
width={labelWidth}
paddingVertical="$2"
paddingRight="$3"
justifyContent="center"
>
<Text fontSize="$xs" fontWeight="700" color={muted}>
{renderRowLabel(row)}
</Text>
</YStack>
{entries.map((entry) => {
const cellBackground = entry.isRecommended ? accentSoft : entry.isActive ? '$green1' : undefined;
let content: React.ReactNode = null;
if (row.type === 'meta') {
if (row.id === 'meta.plan') {
const statusLabel = getPackageStatusLabel({ t, isActive: entry.isActive, owned: entry.owned });
content = (
<YStack space="$1">
<Text fontSize="$sm" fontWeight="800" color={textStrong}>
{entry.pkg.name}
</Text>
<XStack space="$1.5" flexWrap="wrap">
{entry.isRecommended ? (
<PillBadge tone="warning">{t('shop.badges.recommended', 'Recommended')}</PillBadge>
) : null}
{entry.isUpgrade && !entry.isActive ? (
<PillBadge tone="success">{t('shop.badges.upgrade', 'Upgrade')}</PillBadge>
) : null}
{entry.isDowngrade && !entry.isActive ? (
<PillBadge tone="muted">{t('shop.badges.downgrade', 'Downgrade')}</PillBadge>
) : null}
{entry.isActive ? <PillBadge tone="success">{t('shop.badges.active', 'Active')}</PillBadge> : null}
</XStack>
{statusLabel ? (
<Text fontSize="$xs" color={muted}>
{statusLabel}
</Text>
) : null}
</YStack>
);
} else if (row.id === 'meta.price') {
content = (
<Text fontSize="$sm" fontWeight="700" color={primary}>
{new Intl.NumberFormat(undefined, { style: 'currency', currency: 'EUR' }).format(entry.pkg.price)}
</Text>
);
}
} else if (row.type === 'limit') {
const value = entry.pkg[row.limitKey] ?? null;
content = (
<Text fontSize="$sm" fontWeight="600" color={textStrong}>
{formatLimitValue(value)}
</Text>
);
} else if (row.type === 'feature') {
const enabled = getEnabledPackageFeatures(entry.pkg).includes(row.featureKey);
content = (
<XStack alignItems="center" space="$1.5">
{enabled ? (
<Check size={16} color={primary} />
) : (
<X size={14} color={muted} />
)}
<Text fontSize="$sm" color={enabled ? textStrong : muted}>
{enabled ? t('shop.compare.values.included', 'Included') : t('shop.compare.values.notIncluded', 'Not included')}
</Text>
</XStack>
);
}
return (
<YStack
key={`${row.id}-${entry.pkg.id}`}
width={columnWidth}
paddingVertical="$2"
paddingHorizontal="$2"
justifyContent="center"
backgroundColor={cellBackground}
>
{content}
</YStack>
);
})}
</XStack>
))}
<XStack paddingTop="$2">
<YStack width={labelWidth} />
{entries.map((entry) => {
const canSelect = canSelectPackage(entry.isUpgrade, entry.isActive);
const label = entry.isActive
? t('shop.manage', 'Manage Plan')
: entry.isUpgrade
? t('shop.select', 'Select')
: t('shop.selectDisabled', 'Not available');
return (
<YStack key={`cta-${entry.pkg.id}`} width={columnWidth} paddingHorizontal="$2">
<CTAButton
label={label}
onPress={canSelect ? () => onSelect(entry.pkg) : undefined}
disabled={!canSelect}
tone={entry.isActive || entry.isDowngrade ? 'ghost' : 'primary'}
/>
</YStack>
);
})}
</XStack>
</YStack>
</XStack>
</MobileCard>
);
}
function getPackageStatusLabel({
t,
isActive,
owned,
}: {
t: (key: string, fallback?: string, options?: Record<string, unknown>) => string;
isActive?: boolean;
owned?: TenantPackageSummary;
}): string | null {
if (isActive) {
return t('shop.status.active', 'Active Plan');
}
if (owned) {
return owned.remaining_events !== null
? t('shop.status.remaining', '{{count}} Events left', { count: owned.remaining_events })
: t('shop.status.owned', 'Purchased');
}
return null;
}
function canSelectPackage(isUpgrade?: boolean, isActive?: boolean): boolean {
return Boolean(isActive || isUpgrade);
}
function CheckoutConfirmation({ pkg, onCancel }: { pkg: Package; onCancel: () => void }) { function CheckoutConfirmation({ pkg, onCancel }: { pkg: Package; onCancel: () => void }) {
const { t } = useTranslation('management'); const { t } = useTranslation('management');
const { textStrong, muted, border, primary, danger } = useAdminTheme(); const { textStrong, muted, border, primary } = useAdminTheme();
const [agbAccepted, setAgbAccepted] = React.useState(false); const [agbAccepted, setAgbAccepted] = React.useState(false);
const [withdrawalAccepted, setWithdrawalAccepted] = React.useState(false); const [withdrawalAccepted, setWithdrawalAccepted] = React.useState(false);
const [busy, setBusy] = React.useState(false); const { busy, startCheckout } = usePackageCheckout();
const canProceed = agbAccepted && withdrawalAccepted; const canProceed = agbAccepted && withdrawalAccepted;
const handleCheckout = async () => { const handleCheckout = async () => {
if (!canProceed || busy) return; if (!canProceed || busy) return;
setBusy(true); await startCheckout(pkg.id);
try {
const { checkout_url } = await createTenantPaddleCheckout(pkg.id, {
success_url: window.location.href,
return_url: window.location.href,
});
window.location.href = checkout_url;
} catch (err) {
toast.error(getApiErrorMessage(err, t('shop.errors.checkout', 'Checkout failed')));
setBusy(false);
}
}; };
return ( return (

View File

@@ -0,0 +1,33 @@
import { describe, expect, it } from 'vitest';
import { resolveMaxCount, resolveTimelineHours } from '../lib/analytics';
describe('resolveMaxCount', () => {
it('defaults to 1 for empty input', () => {
expect(resolveMaxCount([])).toBe(1);
});
it('returns the highest count', () => {
expect(resolveMaxCount([2, 5, 3])).toBe(5);
});
it('never returns less than 1', () => {
expect(resolveMaxCount([0])).toBe(1);
});
});
describe('resolveTimelineHours', () => {
it('uses fallback when data is missing', () => {
expect(resolveTimelineHours([], 12)).toBe(12);
});
it('calculates rounded hours from timestamps', () => {
const start = new Date('2024-01-01T10:00:00Z').toISOString();
const end = new Date('2024-01-01T21:00:00Z').toISOString();
expect(resolveTimelineHours([start, end], 12)).toBe(11);
});
it('never returns less than 1', () => {
const start = new Date('2024-01-01T10:00:00Z').toISOString();
expect(resolveTimelineHours([start, start], 12)).toBe(1);
});
});

View File

@@ -0,0 +1,42 @@
import { beforeEach, describe, expect, it } from 'vitest';
import {
CHECKOUT_STORAGE_KEY,
PENDING_CHECKOUT_TTL_MS,
isCheckoutExpired,
loadPendingCheckout,
shouldClearPendingCheckout,
storePendingCheckout,
} from '../lib/billingCheckout';
describe('billingCheckout helpers', () => {
beforeEach(() => {
sessionStorage.clear();
});
it('detects expired pending checkout', () => {
const pending = { packageId: 12, startedAt: 0 };
expect(isCheckoutExpired(pending, PENDING_CHECKOUT_TTL_MS + 1)).toBe(true);
});
it('keeps pending checkout when active package differs', () => {
const pending = { packageId: 12, startedAt: Date.now() };
expect(shouldClearPendingCheckout(pending, 18, pending.startedAt)).toBe(false);
});
it('clears pending checkout when active package matches', () => {
const now = Date.now();
const pending = { packageId: 12, startedAt: now };
expect(shouldClearPendingCheckout(pending, 12, now)).toBe(true);
});
it('stores and loads pending checkout from session storage', () => {
const pending = { packageId: 7, checkoutSessionId: 'sess_123', startedAt: Date.now() };
storePendingCheckout(pending);
expect(loadPendingCheckout(pending.startedAt)).toEqual(pending);
});
it('clears pending checkout storage', () => {
storePendingCheckout({ packageId: 7, checkoutSessionId: 'sess_123', startedAt: Date.now() });
storePendingCheckout(null);
expect(sessionStorage.getItem(CHECKOUT_STORAGE_KEY)).toBeNull();
});
});

View File

@@ -0,0 +1,83 @@
import { describe, expect, it } from 'vitest';
import {
buildPackageComparisonRows,
classifyPackageChange,
getEnabledPackageFeatures,
selectRecommendedPackageId,
} from '../lib/packageShop';
describe('classifyPackageChange', () => {
const active = {
id: 1,
price: 200,
max_photos: 100,
max_guests: 50,
gallery_days: 30,
features: { advanced_analytics: false },
} as any;
it('returns neutral when no active package', () => {
expect(classifyPackageChange(active, null)).toEqual({ isUpgrade: false, isDowngrade: false });
});
it('marks upgrade when candidate adds features', () => {
const candidate = { ...active, id: 2, price: 150, features: { advanced_analytics: true } } as any;
expect(classifyPackageChange(candidate, active)).toEqual({ isUpgrade: true, isDowngrade: false });
});
it('marks downgrade when candidate removes features or limits', () => {
const candidate = { ...active, id: 3, max_photos: 50, features: { advanced_analytics: false } } as any;
expect(classifyPackageChange(candidate, active)).toEqual({ isUpgrade: false, isDowngrade: true });
});
it('treats mixed changes as downgrade', () => {
const candidate = { ...active, id: 4, max_photos: 200, gallery_days: 10, features: { advanced_analytics: false } } as any;
expect(classifyPackageChange(candidate, active)).toEqual({ isUpgrade: false, isDowngrade: true });
});
});
describe('selectRecommendedPackageId', () => {
const packages = [
{ id: 1, price: 100, features: { advanced_analytics: false } },
{ id: 2, price: 150, features: { advanced_analytics: true } },
{ id: 3, price: 200, features: { advanced_analytics: true } },
] as any;
it('returns null when no feature is requested', () => {
expect(selectRecommendedPackageId(packages, null, 100)).toBeNull();
});
it('selects the cheapest upgrade with the feature', () => {
const active = { id: 10, price: 120, max_photos: 100, max_guests: 50, gallery_days: 30, features: {} } as any;
expect(selectRecommendedPackageId(packages, 'advanced_analytics', active)).toBe(2);
});
it('falls back to cheapest feature package if no upgrades exist', () => {
const active = { id: 10, price: 250, max_photos: 999, max_guests: 999, gallery_days: 365, features: { advanced_analytics: true } } as any;
expect(selectRecommendedPackageId(packages, 'advanced_analytics', active)).toBe(2);
});
});
describe('buildPackageComparisonRows', () => {
it('includes limit rows and enabled feature rows', () => {
const rows = buildPackageComparisonRows([
{ features: { advanced_analytics: true, custom_branding: false } },
{ features: { custom_branding: true, watermark_removal: true } },
] as any);
expect(rows.map((row) => row.id)).toEqual([
'limit.max_photos',
'limit.max_guests',
'limit.gallery_days',
'feature.advanced_analytics',
'feature.custom_branding',
'feature.watermark_removal',
]);
});
});
describe('getEnabledPackageFeatures', () => {
it('accepts array payloads', () => {
expect(getEnabledPackageFeatures({ features: ['custom_branding', ''] } as any)).toEqual(['custom_branding']);
});
});

View File

@@ -0,0 +1,143 @@
import React from 'react';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { act, render, screen } from '@testing-library/react';
import { MemoryRouter } from 'react-router-dom';
vi.mock('react-i18next', () => ({
useTranslation: () => ({ t: (key: string, fallback?: string) => fallback ?? key, i18n: { language: 'en-GB' } }),
}));
vi.mock('@tamagui/core', () => ({
useTheme: () => ({
background: { val: '#FFF8F5' },
surface: { val: '#ffffff' },
borderColor: { val: '#e5e7eb' },
color: { val: '#1f2937' },
gray: { val: '#6b7280' },
red10: { val: '#b91c1c' },
shadowColor: { val: 'rgba(0,0,0,0.12)' },
primary: { val: '#FF5A5F' },
}),
}));
vi.mock('@tamagui/stacks', () => ({
YStack: ({ children, ...props }: { children: React.ReactNode }) => <div {...props}>{children}</div>,
XStack: ({ children, ...props }: { children: React.ReactNode }) => <div {...props}>{children}</div>,
}));
vi.mock('@tamagui/text', () => ({
SizableText: ({ children, ...props }: { children: React.ReactNode }) => <span {...props}>{children}</span>,
}));
vi.mock('@tamagui/react-native-web-lite', () => ({
Pressable: ({ children, onPress, ...props }: { children: React.ReactNode; onPress?: () => void }) => (
<button type="button" onClick={onPress} {...props}>
{children}
</button>
),
}));
vi.mock('../BottomNav', () => ({
BottomNav: () => <div data-testid="bottom-nav" />,
NavKey: {},
}));
vi.mock('../../../context/EventContext', () => ({
useEventContext: () => ({
events: [],
activeEvent: { slug: 'event-1', name: 'Test Event', event_date: '2024-01-01', status: 'active', settings: {} },
hasMultipleEvents: false,
hasEvents: true,
selectEvent: vi.fn(),
}),
}));
vi.mock('../../hooks/useMobileNav', () => ({
useMobileNav: () => ({ go: vi.fn(), slug: 'event-1' }),
}));
vi.mock('../../hooks/useNotificationsBadge', () => ({
useNotificationsBadge: () => ({ count: 0 }),
}));
vi.mock('../../hooks/useOnlineStatus', () => ({
useOnlineStatus: () => true,
}));
vi.mock('../../../api', () => ({
getEvents: vi.fn().mockResolvedValue([]),
}));
vi.mock('../../lib/tabHistory', () => ({
setTabHistory: vi.fn(),
}));
vi.mock('../../lib/photoModerationQueue', () => ({
loadPhotoQueue: vi.fn(() => []),
}));
vi.mock('../../lib/queueStatus', () => ({
countQueuedPhotoActions: vi.fn(() => 0),
}));
vi.mock('../../theme', () => ({
useAdminTheme: () => ({
background: '#FFF8F5',
surface: '#ffffff',
border: '#e5e7eb',
text: '#1f2937',
muted: '#6b7280',
warningBg: '#fff7ed',
warningText: '#92400e',
primary: '#FF5A5F',
danger: '#b91c1c',
shadow: 'rgba(0,0,0,0.12)',
}),
}));
import { MobileShell } from '../MobileShell';
describe('MobileShell', () => {
beforeEach(() => {
window.matchMedia = vi.fn().mockReturnValue({
matches: false,
addEventListener: vi.fn(),
removeEventListener: vi.fn(),
});
});
it('renders quick QR as icon-only button', async () => {
await act(async () => {
render(
<MemoryRouter>
<MobileShell activeTab="home">
<div>Body</div>
</MobileShell>
</MemoryRouter>
);
});
expect(screen.getByLabelText('Quick QR')).toBeInTheDocument();
expect(screen.queryByText('Quick QR')).not.toBeInTheDocument();
});
it('hides the event context on compact headers', async () => {
window.matchMedia = vi.fn().mockReturnValue({
matches: true,
addEventListener: vi.fn(),
removeEventListener: vi.fn(),
});
await act(async () => {
render(
<MemoryRouter>
<MobileShell activeTab="home">
<div>Body</div>
</MobileShell>
</MemoryRouter>
);
});
expect(screen.queryByText('Test Event')).not.toBeInTheDocument();
});
});

View File

@@ -0,0 +1,59 @@
import React from 'react';
import { useTranslation } from 'react-i18next';
import toast from 'react-hot-toast';
import { createTenantPaddleCheckout } from '../../api';
import { adminPath } from '../../constants';
import { getApiErrorMessage } from '../../lib/apiError';
import { storePendingCheckout } from '../lib/billingCheckout';
export function usePackageCheckout(): {
busy: boolean;
startCheckout: (packageId: number) => Promise<void>;
} {
const { t } = useTranslation('management');
const [busy, setBusy] = React.useState(false);
const startCheckout = React.useCallback(
async (packageId: number) => {
if (busy) {
return;
}
setBusy(true);
try {
if (typeof window === 'undefined') {
throw new Error('Checkout is only available in the browser.');
}
const billingUrl = new URL(adminPath('/mobile/billing'), window.location.origin);
const successUrl = new URL(billingUrl);
successUrl.searchParams.set('checkout', 'success');
successUrl.searchParams.set('package_id', String(packageId));
const cancelUrl = new URL(billingUrl);
cancelUrl.searchParams.set('checkout', 'cancel');
cancelUrl.searchParams.set('package_id', String(packageId));
const { checkout_url, checkout_session_id } = await createTenantPaddleCheckout(packageId, {
success_url: successUrl.toString(),
return_url: cancelUrl.toString(),
});
if (checkout_session_id) {
storePendingCheckout({
packageId,
checkoutSessionId: checkout_session_id,
startedAt: Date.now(),
});
}
window.location.href = checkout_url;
} catch (err) {
toast.error(getApiErrorMessage(err, t('shop.errors.checkout', 'Checkout failed')));
setBusy(false);
}
},
[busy, t],
);
return { busy, startCheckout };
}

View File

@@ -0,0 +1,28 @@
export function resolveMaxCount(values: number[]): number {
if (!Array.isArray(values) || values.length === 0) {
return 1;
}
return Math.max(...values, 1);
}
export function resolveTimelineHours(timestamps: string[], fallbackHours = 12): number {
if (!Array.isArray(timestamps) || timestamps.length < 2) {
return fallbackHours;
}
const times = timestamps
.map((value) => new Date(value).getTime())
.filter((value) => Number.isFinite(value));
if (times.length < 2) {
return fallbackHours;
}
const min = Math.min(...times);
const max = Math.max(...times);
const diff = Math.max(0, max - min);
const hours = diff / (1000 * 60 * 60);
return Math.max(1, Math.round(hours));
}

View File

@@ -0,0 +1,82 @@
export type PendingCheckout = {
packageId: number | null;
checkoutSessionId?: string | null;
startedAt: number;
};
export const PENDING_CHECKOUT_TTL_MS = 1000 * 60 * 30;
export const CHECKOUT_STORAGE_KEY = 'admin.billing.checkout.pending.v1';
export function isCheckoutExpired(
pending: PendingCheckout,
now = Date.now(),
ttl = PENDING_CHECKOUT_TTL_MS,
): boolean {
return now - pending.startedAt > ttl;
}
export function loadPendingCheckout(
now = Date.now(),
ttl = PENDING_CHECKOUT_TTL_MS,
): PendingCheckout | null {
if (typeof window === 'undefined') {
return null;
}
try {
const raw = window.sessionStorage.getItem(CHECKOUT_STORAGE_KEY);
if (! raw) {
return null;
}
const parsed = JSON.parse(raw) as PendingCheckout;
if (typeof parsed?.startedAt !== 'number') {
return null;
}
const packageId =
typeof parsed.packageId === 'number' && Number.isFinite(parsed.packageId)
? parsed.packageId
: null;
const checkoutSessionId = typeof parsed.checkoutSessionId === 'string' ? parsed.checkoutSessionId : null;
if (now - parsed.startedAt > ttl) {
return null;
}
return {
packageId,
checkoutSessionId,
startedAt: parsed.startedAt,
};
} catch {
return null;
}
}
export function storePendingCheckout(next: PendingCheckout | null): void {
if (typeof window === 'undefined') {
return;
}
try {
if (! next) {
window.sessionStorage.removeItem(CHECKOUT_STORAGE_KEY);
} else {
window.sessionStorage.setItem(CHECKOUT_STORAGE_KEY, JSON.stringify(next));
}
} catch {
// Ignore storage errors.
}
}
export function shouldClearPendingCheckout(
pending: PendingCheckout,
activePackageId: number | null,
now = Date.now(),
ttl = PENDING_CHECKOUT_TTL_MS,
): boolean {
if (isCheckoutExpired(pending, now, ttl)) {
return true;
}
if (pending.packageId && activePackageId && pending.packageId === activePackageId) {
return true;
}
return false;
}

View File

@@ -0,0 +1,146 @@
import type { Package } from '../../api';
type PackageChange = {
isUpgrade: boolean;
isDowngrade: boolean;
};
export type PackageComparisonRow =
| {
id: string;
type: 'limit';
limitKey: 'max_photos' | 'max_guests' | 'gallery_days';
}
| {
id: string;
type: 'feature';
featureKey: string;
};
function normalizePackageFeatures(pkg: Package | null): string[] {
if (!pkg?.features) {
return [];
}
if (Array.isArray(pkg.features)) {
return pkg.features.filter((feature): feature is string => typeof feature === 'string' && feature.trim().length > 0);
}
if (typeof pkg.features === 'object') {
return Object.entries(pkg.features)
.filter(([, enabled]) => enabled)
.map(([key]) => key);
}
return [];
}
export function getEnabledPackageFeatures(pkg: Package): string[] {
return normalizePackageFeatures(pkg);
}
function collectFeatures(pkg: Package | null): Set<string> {
return new Set(normalizePackageFeatures(pkg));
}
function compareLimit(candidate: number | null, active: number | null): number {
if (active === null) {
return candidate === null ? 0 : -1;
}
if (candidate === null) {
return 1;
}
if (candidate > active) return 1;
if (candidate < active) return -1;
return 0;
}
export function classifyPackageChange(pkg: Package, active: Package | null): PackageChange {
if (!active) {
return { isUpgrade: false, isDowngrade: false };
}
const activeFeatures = collectFeatures(active);
const candidateFeatures = collectFeatures(pkg);
const hasFeatureUpgrade = Array.from(candidateFeatures).some((feature) => !activeFeatures.has(feature));
const hasFeatureDowngrade = Array.from(activeFeatures).some((feature) => !candidateFeatures.has(feature));
const limitKeys: Array<keyof Package> = ['max_photos', 'max_guests', 'gallery_days'];
let hasLimitUpgrade = false;
let hasLimitDowngrade = false;
limitKeys.forEach((key) => {
const candidateLimit = pkg[key] ?? null;
const activeLimit = active[key] ?? null;
const delta = compareLimit(candidateLimit, activeLimit);
if (delta > 0) {
hasLimitUpgrade = true;
} else if (delta < 0) {
hasLimitDowngrade = true;
}
});
const hasUpgrade = hasFeatureUpgrade || hasLimitUpgrade;
const hasDowngrade = hasFeatureDowngrade || hasLimitDowngrade;
if (hasUpgrade && !hasDowngrade) {
return { isUpgrade: true, isDowngrade: false };
}
if (hasDowngrade) {
return { isUpgrade: false, isDowngrade: true };
}
return { isUpgrade: false, isDowngrade: false };
}
export function selectRecommendedPackageId(
packages: Package[],
feature: string | null,
activePackage: Package | null
): number | null {
if (!feature) {
return null;
}
const candidates = packages.filter((pkg) => normalizePackageFeatures(pkg).includes(feature));
if (candidates.length === 0) {
return null;
}
const upgrades = candidates.filter((pkg) => classifyPackageChange(pkg, activePackage).isUpgrade);
const pool = upgrades.length ? upgrades : candidates;
const sorted = [...pool].sort((a, b) => a.price - b.price);
return sorted[0]?.id ?? null;
}
export function buildPackageComparisonRows(packages: Package[]): PackageComparisonRow[] {
const limitRows: PackageComparisonRow[] = [
{ id: 'limit.max_photos', type: 'limit', limitKey: 'max_photos' },
{ id: 'limit.max_guests', type: 'limit', limitKey: 'max_guests' },
{ id: 'limit.gallery_days', type: 'limit', limitKey: 'gallery_days' },
];
const featureKeys = new Set<string>();
packages.forEach((pkg) => {
normalizePackageFeatures(pkg).forEach((key) => {
if (key !== 'photos') {
featureKeys.add(key);
}
});
});
const featureRows = Array.from(featureKeys)
.sort((a, b) => a.localeCompare(b))
.map((featureKey) => ({
id: `feature.${featureKey}`,
type: 'feature' as const,
featureKey,
}));
return [...limitRows, ...featureRows];
}

View File

@@ -15,7 +15,8 @@ const t = (key: string, options?: Record<string, unknown> | string) => {
return template return template
.replace('{{used}}', String(options?.used ?? '{{used}}')) .replace('{{used}}', String(options?.used ?? '{{used}}'))
.replace('{{limit}}', String(options?.limit ?? '{{limit}}')) .replace('{{limit}}', String(options?.limit ?? '{{limit}}'))
.replace('{{remaining}}', String(options?.remaining ?? '{{remaining}}')); .replace('{{remaining}}', String(options?.remaining ?? '{{remaining}}'))
.replace('{{count}}', String(options?.count ?? '{{count}}'));
}; };
describe('packageSummary helpers', () => { describe('packageSummary helpers', () => {
@@ -53,6 +54,12 @@ describe('packageSummary helpers', () => {
expect(result[0].value).toBe('30 of 120 remaining'); expect(result[0].value).toBe('30 of 120 remaining');
}); });
it('falls back to remaining count when remaining exceeds limit', () => {
const result = getPackageLimitEntries({ max_photos: 120, remaining_photos: 180 }, t);
expect(result[0].value).toBe('Remaining 180');
});
it('formats event usage copy', () => { it('formats event usage copy', () => {
const result = formatEventUsage(3, 10, t); const result = formatEventUsage(3, 10, t);

View File

@@ -138,6 +138,12 @@ const formatLimitWithRemaining = (limit: number | null, remaining: number | null
if (remaining !== null && remaining >= 0) { if (remaining !== null && remaining >= 0) {
const normalizedRemaining = Number.isFinite(remaining) ? Math.max(0, Math.round(remaining)) : remaining; const normalizedRemaining = Number.isFinite(remaining) ? Math.max(0, Math.round(remaining)) : remaining;
if (normalizedRemaining > limit) {
return t('mobileBilling.usage.remaining', {
count: normalizedRemaining,
defaultValue: 'Remaining {{count}}',
});
}
return t('mobileBilling.usage.remainingOf', { return t('mobileBilling.usage.remainingOf', {
remaining: normalizedRemaining, remaining: normalizedRemaining,
limit, limit,

View File

@@ -27,7 +27,6 @@ import { SettingsSheet } from './settings-sheet';
import { useTranslation, type TranslateFn } from '../i18n/useTranslation'; import { useTranslation, type TranslateFn } from '../i18n/useTranslation';
import { DEFAULT_EVENT_BRANDING, useOptionalEventBranding } from '../context/EventBrandingContext'; import { DEFAULT_EVENT_BRANDING, useOptionalEventBranding } from '../context/EventBrandingContext';
import { useOptionalNotificationCenter, type NotificationCenterValue } from '../context/NotificationCenterContext'; import { useOptionalNotificationCenter, type NotificationCenterValue } from '../context/NotificationCenterContext';
import { useGuestTaskProgress, TASK_BADGE_TARGET } from '../hooks/useGuestTaskProgress';
import { usePushSubscription } from '../hooks/usePushSubscription'; import { usePushSubscription } from '../hooks/usePushSubscription';
import { getContrastingTextColor, relativeLuminance } from '../lib/color'; import { getContrastingTextColor, relativeLuminance } from '../lib/color';
import { isTaskModeEnabled } from '../lib/engagement'; import { isTaskModeEnabled } from '../lib/engagement';
@@ -151,7 +150,6 @@ export default function Header({ eventToken, title = '' }: { eventToken?: string
const { event, status } = useEventData(); const { event, status } = useEventData();
const notificationCenter = useOptionalNotificationCenter(); const notificationCenter = useOptionalNotificationCenter();
const [notificationsOpen, setNotificationsOpen] = React.useState(false); const [notificationsOpen, setNotificationsOpen] = React.useState(false);
const taskProgress = useGuestTaskProgress(eventToken);
const tasksEnabled = isTaskModeEnabled(event); const tasksEnabled = isTaskModeEnabled(event);
const panelRef = React.useRef<HTMLDivElement | null>(null); const panelRef = React.useRef<HTMLDivElement | null>(null);
const notificationButtonRef = React.useRef<HTMLButtonElement | null>(null); const notificationButtonRef = React.useRef<HTMLButtonElement | null>(null);
@@ -258,7 +256,6 @@ export default function Header({ eventToken, title = '' }: { eventToken?: string
onToggle={() => setNotificationsOpen((prev) => !prev)} onToggle={() => setNotificationsOpen((prev) => !prev)}
panelRef={panelRef} panelRef={panelRef}
buttonRef={notificationButtonRef} buttonRef={notificationButtonRef}
taskProgress={tasksEnabled && taskProgress?.hydrated ? taskProgress : undefined}
t={t} t={t}
/> />
)} )}
@@ -285,18 +282,14 @@ type NotificationButtonProps = {
onToggle: () => void; onToggle: () => void;
panelRef: React.RefObject<HTMLDivElement | null>; panelRef: React.RefObject<HTMLDivElement | null>;
buttonRef: React.RefObject<HTMLButtonElement | null>; buttonRef: React.RefObject<HTMLButtonElement | null>;
taskProgress?: ReturnType<typeof useGuestTaskProgress>;
t: TranslateFn; t: TranslateFn;
}; };
type PushState = ReturnType<typeof usePushSubscription>; type PushState = ReturnType<typeof usePushSubscription>;
function NotificationButton({ center, eventToken, open, onToggle, panelRef, buttonRef, taskProgress, t }: NotificationButtonProps) { function NotificationButton({ center, eventToken, open, onToggle, panelRef, buttonRef, t }: NotificationButtonProps) {
const badgeCount = center.unreadCount + center.pendingCount + center.queueCount; const badgeCount = center.unreadCount;
const progressRatio = taskProgress const [activeTab, setActiveTab] = React.useState<'unread' | 'all' | 'uploads'>(center.unreadCount > 0 ? 'unread' : 'all');
? Math.min(1, taskProgress.completedCount / TASK_BADGE_TARGET)
: 0;
const [activeTab, setActiveTab] = React.useState<'unread' | 'all' | 'status'>(center.unreadCount > 0 ? 'unread' : 'all');
const [scopeFilter, setScopeFilter] = React.useState<'all' | 'tips' | 'general'>('all'); const [scopeFilter, setScopeFilter] = React.useState<'all' | 'tips' | 'general'>('all');
const pushState = usePushSubscription(eventToken); const pushState = usePushSubscription(eventToken);
@@ -321,7 +314,7 @@ function NotificationButton({ center, eventToken, open, onToggle, panelRef, butt
case 'unread': case 'unread':
base = unreadNotifications; base = unreadNotifications;
break; break;
case 'status': case 'uploads':
base = uploadNotifications; base = uploadNotifications;
break; break;
default: default:
@@ -331,7 +324,7 @@ function NotificationButton({ center, eventToken, open, onToggle, panelRef, butt
}, [activeTab, center.notifications, unreadNotifications, uploadNotifications]); }, [activeTab, center.notifications, unreadNotifications, uploadNotifications]);
const scopedNotifications = React.useMemo(() => { const scopedNotifications = React.useMemo(() => {
if (scopeFilter === 'all') { if (activeTab === 'uploads' || scopeFilter === 'all') {
return filteredNotifications; return filteredNotifications;
} }
return filteredNotifications.filter((item) => { return filteredNotifications.filter((item) => {
@@ -365,10 +358,10 @@ function NotificationButton({ center, eventToken, open, onToggle, panelRef, butt
> >
<div className="flex items-start justify-between gap-3"> <div className="flex items-start justify-between gap-3">
<div> <div>
<p className="text-sm font-semibold text-slate-900">{t('header.notifications.title', 'Benachrichtigungen')}</p> <p className="text-sm font-semibold text-slate-900">{t('header.notifications.title', 'Updates')}</p>
<p className="text-xs text-slate-500"> <p className="text-xs text-slate-500">
{center.unreadCount > 0 {center.unreadCount > 0
? t('header.notifications.unread', { defaultValue: '{{count}} neu', count: center.unreadCount }) ? t('header.notifications.unread', { defaultValue: '{count} neu', count: center.unreadCount })
: t('header.notifications.allRead', 'Alles gelesen')} : t('header.notifications.allRead', 'Alles gelesen')}
</p> </p>
</div> </div>
@@ -384,67 +377,43 @@ function NotificationButton({ center, eventToken, open, onToggle, panelRef, butt
</div> </div>
<NotificationTabs <NotificationTabs
tabs={[ tabs={[
{ key: 'unread', label: t('header.notifications.tabUnread', 'Neu'), badge: unreadNotifications.length }, { key: 'unread', label: t('header.notifications.tabUnread', 'Nachrichten'), badge: unreadNotifications.length },
{ key: 'status', label: t('header.notifications.tabStatus', 'Uploads/Status'), badge: uploadNotifications.length }, { key: 'uploads', label: t('header.notifications.tabUploads', 'Uploads'), badge: uploadNotifications.length },
{ key: 'all', label: t('header.notifications.tabAll', 'Alle'), badge: center.notifications.length }, { key: 'all', label: t('header.notifications.tabAll', 'Alle Updates'), badge: center.notifications.length },
]} ]}
activeTab={activeTab} activeTab={activeTab}
onTabChange={(next) => setActiveTab(next as typeof activeTab)} onTabChange={(next) => setActiveTab(next as typeof activeTab)}
/> />
<div className="mt-3"> {activeTab !== 'uploads' && (
<div className="flex gap-2 overflow-x-auto text-xs whitespace-nowrap pb-1"> <div className="mt-3">
{( <div className="flex gap-2 overflow-x-auto text-xs whitespace-nowrap pb-1">
[ {(
{ key: 'all', label: t('header.notifications.scope.all', 'Alle') }, [
{ key: 'tips', label: t('header.notifications.scope.tips', 'Tipps & Achievements') }, { key: 'all', label: t('header.notifications.scope.all', 'Alle') },
{ key: 'general', label: t('header.notifications.scope.general', 'Allgemein') }, { key: 'tips', label: t('header.notifications.scope.tips', 'Tipps & Achievements') },
] as const { key: 'general', label: t('header.notifications.scope.general', 'Allgemein') },
).map((option) => ( ] as const
<button ).map((option) => (
key={option.key} <button
type="button" key={option.key}
onClick={() => { type="button"
setScopeFilter(option.key); onClick={() => {
center.setFilters({ scope: option.key }); setScopeFilter(option.key);
}} center.setFilters({ scope: option.key });
className={`rounded-full border px-3 py-1 font-semibold transition ${ }}
scopeFilter === option.key className={`rounded-full border px-3 py-1 font-semibold transition ${
? 'border-pink-200 bg-pink-50 text-pink-700' scopeFilter === option.key
: 'border-slate-200 bg-white text-slate-600 hover:border-pink-200 hover:text-pink-700' ? 'border-pink-200 bg-pink-50 text-pink-700'
}`} : 'border-slate-200 bg-white text-slate-600 hover:border-pink-200 hover:text-pink-700'
> }`}
{option.label} >
</button> {option.label}
))} </button>
))}
</div>
</div> </div>
</div> )}
<div className="mt-3 max-h-80 space-y-2 overflow-y-auto pr-1"> {activeTab === 'uploads' && (center.pendingCount > 0 || center.queueCount > 0) && (
{center.loading ? (
<NotificationSkeleton />
) : scopedNotifications.length === 0 ? (
<NotificationEmptyState
t={t}
message={
activeTab === 'unread'
? t('header.notifications.emptyUnread', 'Du bist auf dem neuesten Stand!')
: activeTab === 'status'
? t('header.notifications.emptyStatus', 'Keine Upload-Hinweise oder Wartungen aktiv.')
: undefined
}
/>
) : (
scopedNotifications.map((item) => (
<NotificationListItem
key={item.id}
item={item}
onMarkRead={() => center.markAsRead(item.id)}
onDismiss={() => center.dismiss(item.id)}
t={t}
/>
))
)}
</div>
{activeTab === 'status' && (
<div className="mt-3 space-y-2"> <div className="mt-3 space-y-2">
{center.pendingCount > 0 && ( {center.pendingCount > 0 && (
<div className="flex items-center justify-between rounded-xl bg-amber-50/90 px-3 py-2 text-xs text-amber-900"> <div className="flex items-center justify-between rounded-xl bg-amber-50/90 px-3 py-2 text-xs text-amber-900">
@@ -478,30 +447,32 @@ function NotificationButton({ center, eventToken, open, onToggle, panelRef, butt
)} )}
</div> </div>
)} )}
{taskProgress && ( <div className="mt-3 max-h-80 space-y-2 overflow-y-auto pr-1">
<div className="mt-3 rounded-2xl border border-slate-200 bg-slate-50/90 p-3"> {center.loading ? (
<div className="flex items-center justify-between"> <NotificationSkeleton />
<div> ) : scopedNotifications.length === 0 ? (
<p className="text-xs uppercase tracking-[0.3em] text-slate-400">{t('header.notifications.badgeLabel', 'Badge-Fortschritt')}</p> <NotificationEmptyState
<p className="text-lg font-semibold text-slate-900"> t={t}
{taskProgress.completedCount}/{TASK_BADGE_TARGET} message={
</p> activeTab === 'unread'
</div> ? t('header.notifications.emptyUnread', 'Du bist auf dem neuesten Stand!')
<Link : activeTab === 'uploads'
to={`/e/${encodeURIComponent(eventToken)}/tasks`} ? t('header.notifications.emptyStatus', 'Keine Upload-Hinweise oder Wartungen aktiv.')
className="rounded-full border border-slate-200 px-3 py-1 text-xs font-semibold text-pink-600 transition hover:border-pink-300" : undefined
> }
{t('header.notifications.tasksCta', 'Weiter')} />
</Link> ) : (
</div> scopedNotifications.map((item) => (
<div className="mt-3 h-1.5 w-full rounded-full bg-slate-100"> <NotificationListItem
<div key={item.id}
className="h-full rounded-full bg-pink-500" item={item}
style={{ width: `${progressRatio * 100}%` }} onMarkRead={() => center.markAsRead(item.id)}
onDismiss={() => center.dismiss(item.id)}
t={t}
/> />
</div> ))
</div> )}
)} </div>
<NotificationStatusBar <NotificationStatusBar
lastFetchedAt={center.lastFetchedAt} lastFetchedAt={center.lastFetchedAt}
isOffline={center.isOffline} isOffline={center.isOffline}

View File

@@ -38,7 +38,6 @@ vi.mock('../../context/NotificationCenterContext', () => ({
queueItems: [], queueItems: [],
queueCount: 0, queueCount: 0,
pendingCount: 0, pendingCount: 0,
totalCount: 0,
loading: false, loading: false,
pendingLoading: false, pendingLoading: false,
refresh: vi.fn(), refresh: vi.fn(),
@@ -97,10 +96,10 @@ describe('Header notifications toggle', () => {
const bellButton = screen.getByLabelText('Benachrichtigungen anzeigen'); const bellButton = screen.getByLabelText('Benachrichtigungen anzeigen');
fireEvent.click(bellButton); fireEvent.click(bellButton);
expect(screen.getByText('Benachrichtigungen')).toBeInTheDocument(); expect(screen.getByText('Updates')).toBeInTheDocument();
fireEvent.click(bellButton); fireEvent.click(bellButton);
expect(screen.queryByText('Benachrichtigungen')).not.toBeInTheDocument(); expect(screen.queryByText('Updates')).not.toBeInTheDocument();
}); });
}); });

View File

@@ -16,7 +16,6 @@ export type NotificationCenterValue = {
queueItems: QueueItem[]; queueItems: QueueItem[];
queueCount: number; queueCount: number;
pendingCount: number; pendingCount: number;
totalCount: number;
loading: boolean; loading: boolean;
pendingLoading: boolean; pendingLoading: boolean;
refresh: () => Promise<void>; refresh: () => Promise<void>;
@@ -264,11 +263,9 @@ export function NotificationCenterProvider({ eventToken, children }: { eventToke
}, [loadNotifications, refreshQueue, loadPendingUploads]); }, [loadNotifications, refreshQueue, loadPendingUploads]);
const loading = loadingNotifications || queueLoading || pendingLoading; const loading = loadingNotifications || queueLoading || pendingLoading;
const totalCount = unreadCount + queueCount + pendingCount;
React.useEffect(() => { React.useEffect(() => {
void updateAppBadge(totalCount); void updateAppBadge(unreadCount);
}, [totalCount]); }, [unreadCount]);
const value: NotificationCenterValue = { const value: NotificationCenterValue = {
notifications, notifications,
@@ -276,7 +273,6 @@ export function NotificationCenterProvider({ eventToken, children }: { eventToke
queueItems: items, queueItems: items,
queueCount, queueCount,
pendingCount, pendingCount,
totalCount,
loading, loading,
pendingLoading, pendingLoading,
refresh, refresh,

View File

@@ -42,7 +42,13 @@ export const messages: Record<LocaleCode, NestedMessages> = {
}, },
helpGallery: 'Hilfe zu Galerie & Teilen', helpGallery: 'Hilfe zu Galerie & Teilen',
notifications: { notifications: {
tabStatus: 'Upload-Status', title: 'Updates',
unread: '{count} neu',
allRead: 'Alles gelesen',
tabUnread: 'Nachrichten',
tabUploads: 'Uploads',
tabAll: 'Alle Updates',
emptyStatus: 'Keine Upload-Hinweise oder Wartungen aktiv.',
}, },
}, },
liveShowPlayer: { liveShowPlayer: {
@@ -774,7 +780,13 @@ export const messages: Record<LocaleCode, NestedMessages> = {
}, },
helpGallery: 'Help: Gallery & sharing', helpGallery: 'Help: Gallery & sharing',
notifications: { notifications: {
tabStatus: 'Upload status', title: 'Updates',
unread: '{count} new',
allRead: 'All read',
tabUnread: 'Messages',
tabUploads: 'Uploads',
tabAll: 'All updates',
emptyStatus: 'No upload status or maintenance active.',
}, },
}, },
liveShowPlayer: { liveShowPlayer: {

View File

@@ -6,6 +6,7 @@ use App\Http\Controllers\Api\LegalController;
use App\Http\Controllers\Api\LiveShowController; use App\Http\Controllers\Api\LiveShowController;
use App\Http\Controllers\Api\Marketing\CouponPreviewController; use App\Http\Controllers\Api\Marketing\CouponPreviewController;
use App\Http\Controllers\Api\PackageController; use App\Http\Controllers\Api\PackageController;
use App\Http\Controllers\Api\PhotoboothConnectController;
use App\Http\Controllers\Api\SparkboothUploadController; use App\Http\Controllers\Api\SparkboothUploadController;
use App\Http\Controllers\Api\Tenant\AdminPushSubscriptionController; use App\Http\Controllers\Api\Tenant\AdminPushSubscriptionController;
use App\Http\Controllers\Api\Tenant\DashboardController; use App\Http\Controllers\Api\Tenant\DashboardController;
@@ -24,6 +25,7 @@ use App\Http\Controllers\Api\Tenant\LiveShowLinkController;
use App\Http\Controllers\Api\Tenant\LiveShowPhotoController; use App\Http\Controllers\Api\Tenant\LiveShowPhotoController;
use App\Http\Controllers\Api\Tenant\NotificationLogController; use App\Http\Controllers\Api\Tenant\NotificationLogController;
use App\Http\Controllers\Api\Tenant\OnboardingController; use App\Http\Controllers\Api\Tenant\OnboardingController;
use App\Http\Controllers\Api\Tenant\PhotoboothConnectCodeController;
use App\Http\Controllers\Api\Tenant\PhotoboothController; use App\Http\Controllers\Api\Tenant\PhotoboothController;
use App\Http\Controllers\Api\Tenant\PhotoController; use App\Http\Controllers\Api\Tenant\PhotoController;
use App\Http\Controllers\Api\Tenant\ProfileController; use App\Http\Controllers\Api\Tenant\ProfileController;
@@ -153,6 +155,9 @@ Route::prefix('v1')->name('api.v1.')->group(function () {
Route::post('/photobooth/sparkbooth/upload', [SparkboothUploadController::class, 'store']) Route::post('/photobooth/sparkbooth/upload', [SparkboothUploadController::class, 'store'])
->name('photobooth.sparkbooth.upload'); ->name('photobooth.sparkbooth.upload');
Route::post('/photobooth/connect', [PhotoboothConnectController::class, 'store'])
->middleware('throttle:photobooth-connect')
->name('photobooth.connect');
Route::get('/tenant/events/{event:slug}/photos/{photo}/{variant}/asset', [PhotoController::class, 'asset']) Route::get('/tenant/events/{event:slug}/photos/{photo}/{variant}/asset', [PhotoController::class, 'asset'])
->whereNumber('photo') ->whereNumber('photo')
@@ -263,6 +268,8 @@ Route::prefix('v1')->name('api.v1.')->group(function () {
Route::post('/enable', [PhotoboothController::class, 'enable'])->name('tenant.events.photobooth.enable'); Route::post('/enable', [PhotoboothController::class, 'enable'])->name('tenant.events.photobooth.enable');
Route::post('/rotate', [PhotoboothController::class, 'rotate'])->name('tenant.events.photobooth.rotate'); Route::post('/rotate', [PhotoboothController::class, 'rotate'])->name('tenant.events.photobooth.rotate');
Route::post('/disable', [PhotoboothController::class, 'disable'])->name('tenant.events.photobooth.disable'); Route::post('/disable', [PhotoboothController::class, 'disable'])->name('tenant.events.photobooth.disable');
Route::post('/connect-codes', [PhotoboothConnectCodeController::class, 'store'])
->name('tenant.events.photobooth.connect-codes.store');
}); });
Route::get('members', [EventMemberController::class, 'index']) Route::get('members', [EventMemberController::class, 'index'])
@@ -353,6 +360,8 @@ Route::prefix('v1')->name('api.v1.')->group(function () {
Route::post('/complete', [PackageController::class, 'completePurchase'])->name('packages.complete'); Route::post('/complete', [PackageController::class, 'completePurchase'])->name('packages.complete');
Route::post('/free', [PackageController::class, 'assignFree'])->name('packages.free'); Route::post('/free', [PackageController::class, 'assignFree'])->name('packages.free');
Route::post('/paddle-checkout', [PackageController::class, 'createPaddleCheckout'])->name('packages.paddle-checkout'); Route::post('/paddle-checkout', [PackageController::class, 'createPaddleCheckout'])->name('packages.paddle-checkout');
Route::get('/checkout-session/{session}/status', [PackageController::class, 'checkoutSessionStatus'])
->name('packages.checkout-session.status');
}); });
Route::get('addons/catalog', [EventAddonCatalogController::class, 'index']) Route::get('addons/catalog', [EventAddonCatalogController::class, 'index'])

View File

@@ -0,0 +1,100 @@
<?php
namespace Tests\Feature\Photobooth;
use App\Models\Event;
use App\Models\EventPhotoboothSetting;
use App\Models\PhotoboothConnectCode;
use PHPUnit\Framework\Attributes\Test;
use Tests\Feature\Tenant\TenantTestCase;
class PhotoboothConnectCodeTest extends TenantTestCase
{
#[Test]
public function it_creates_a_connect_code_for_sparkbooth(): void
{
$event = Event::factory()->for($this->tenant)->create([
'slug' => 'connect-code-event',
]);
EventPhotoboothSetting::factory()
->for($event)
->activeSparkbooth()
->create([
'username' => 'pbconnect',
'password' => 'SECRET12',
]);
$response = $this->authenticatedRequest('POST', "/api/v1/tenant/events/{$event->slug}/photobooth/connect-codes");
$response->assertOk()
->assertJsonPath('data.code', fn ($value) => is_string($value) && strlen($value) === 6)
->assertJsonPath('data.expires_at', fn ($value) => is_string($value) && $value !== '');
$this->assertDatabaseCount('photobooth_connect_codes', 1);
}
#[Test]
public function it_redeems_a_connect_code_and_returns_upload_credentials(): void
{
$event = Event::factory()->for($this->tenant)->create([
'slug' => 'connect-code-redeem',
]);
EventPhotoboothSetting::factory()
->for($event)
->activeSparkbooth()
->create([
'username' => 'pbconnect',
'password' => 'SECRET12',
]);
$codeResponse = $this->authenticatedRequest('POST', "/api/v1/tenant/events/{$event->slug}/photobooth/connect-codes");
$codeResponse->assertOk();
$code = (string) $codeResponse->json('data.code');
$redeem = $this->postJson('/api/v1/photobooth/connect', [
'code' => $code,
]);
$redeem->assertOk()
->assertJsonPath('data.upload_url', fn ($value) => is_string($value) && $value !== '')
->assertJsonPath('data.username', 'pbconnect')
->assertJsonPath('data.password', 'SECRET12');
$this->assertDatabaseHas('photobooth_connect_codes', [
'event_id' => $event->id,
]);
}
#[Test]
public function it_rejects_expired_connect_codes(): void
{
$event = Event::factory()->for($this->tenant)->create([
'slug' => 'connect-code-expired',
]);
EventPhotoboothSetting::factory()
->for($event)
->activeSparkbooth()
->create([
'username' => 'pbconnect',
'password' => 'SECRET12',
]);
$code = '123456';
PhotoboothConnectCode::query()->create([
'event_id' => $event->id,
'code_hash' => hash('sha256', $code),
'expires_at' => now()->subMinute(),
]);
$response = $this->postJson('/api/v1/photobooth/connect', [
'code' => $code,
]);
$response->assertStatus(422);
}
}

View File

@@ -0,0 +1,26 @@
<?php
namespace Tests\Feature\Tenant;
use App\Models\Event;
use App\Models\Photo;
class PhotoModerationControllerTest extends TenantTestCase
{
public function test_tenant_admin_can_approve_photo(): void
{
$event = Event::factory()->for($this->tenant)->create([
'slug' => 'moderation-event',
]);
$photo = Photo::factory()->for($event)->create([
'status' => 'pending',
]);
$response = $this->authenticatedRequest('PATCH', "/api/v1/tenant/events/{$event->slug}/photos/{$photo->id}", [
'status' => 'approved',
]);
$response->assertOk();
$this->assertSame('approved', $photo->refresh()->status);
}
}

View File

@@ -0,0 +1,46 @@
<?php
namespace Tests\Feature\Tenant;
use App\Models\CheckoutSession;
use App\Models\Package;
use Illuminate\Support\Str;
class TenantCheckoutSessionStatusTest extends TenantTestCase
{
public function test_tenant_can_fetch_checkout_session_status(): void
{
$package = Package::factory()->create([
'price' => 129,
]);
$session = CheckoutSession::create([
'id' => (string) Str::uuid(),
'user_id' => $this->tenantUser->id,
'tenant_id' => $this->tenant->id,
'package_id' => $package->id,
'status' => CheckoutSession::STATUS_FAILED,
'provider' => CheckoutSession::PROVIDER_PADDLE,
'provider_metadata' => [
'paddle_checkout_url' => 'https://checkout.paddle.test/checkout/123',
],
'status_history' => [
[
'status' => CheckoutSession::STATUS_FAILED,
'reason' => 'paddle_failed',
'at' => now()->toIso8601String(),
],
],
]);
$response = $this->authenticatedRequest(
'GET',
"/api/v1/tenant/packages/checkout-session/{$session->id}/status"
);
$response->assertOk()
->assertJsonPath('status', CheckoutSession::STATUS_FAILED)
->assertJsonPath('reason', 'paddle_failed')
->assertJsonPath('checkout_url', 'https://checkout.paddle.test/checkout/123');
}
}

View File

@@ -29,7 +29,10 @@ class TenantPaddleCheckoutTest extends TenantTestCase
return $tenant->is($this->tenant) return $tenant->is($this->tenant)
&& $payloadPackage->is($package) && $payloadPackage->is($package)
&& array_key_exists('success_url', $payload) && array_key_exists('success_url', $payload)
&& array_key_exists('return_url', $payload); && array_key_exists('return_url', $payload)
&& array_key_exists('metadata', $payload)
&& is_array($payload['metadata'])
&& ! empty($payload['metadata']['checkout_session_id']);
}) })
->andReturn([ ->andReturn([
'checkout_url' => 'https://checkout.paddle.test/checkout/123', 'checkout_url' => 'https://checkout.paddle.test/checkout/123',
@@ -42,7 +45,8 @@ class TenantPaddleCheckoutTest extends TenantTestCase
]); ]);
$response->assertOk() $response->assertOk()
->assertJsonPath('checkout_url', 'https://checkout.paddle.test/checkout/123'); ->assertJsonPath('checkout_url', 'https://checkout.paddle.test/checkout/123')
->assertJsonStructure(['checkout_session_id']);
} }
public function test_paddle_checkout_requires_paddle_price_id(): void public function test_paddle_checkout_requires_paddle_price_id(): void

View File

@@ -0,0 +1,67 @@
<?php
namespace Tests\Unit;
use App\Models\GuestPolicySetting;
use Illuminate\Cache\RateLimiting\Limit;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\RateLimiter;
use Tests\TestCase;
class RateLimitConfigTest extends TestCase
{
use RefreshDatabase;
public function test_tenant_api_rate_limiter_allows_higher_throughput(): void
{
$request = Request::create('/api/v1/tenant/events', 'GET', [], [], [], [
'REMOTE_ADDR' => '10.0.0.1',
]);
$request->attributes->set('tenant_id', 42);
$limiter = RateLimiter::limiter('tenant-api');
$this->assertNotNull($limiter);
$limit = $limiter($request);
$this->assertInstanceOf(Limit::class, $limit);
$this->assertSame(600, $limit->maxAttempts);
}
public function test_guest_api_rate_limiter_allows_higher_throughput(): void
{
$request = Request::create('/api/v1/events/sample', 'GET', [], [], [], [
'REMOTE_ADDR' => '10.0.0.2',
]);
$limiter = RateLimiter::limiter('guest-api');
$this->assertNotNull($limiter);
$limit = $limiter($request);
$this->assertInstanceOf(Limit::class, $limit);
$this->assertSame(300, $limit->maxAttempts);
}
public function test_guest_policy_defaults_follow_join_token_limits(): void
{
$accessLimit = 300;
$downloadLimit = 120;
config([
'join_tokens.access_limit' => $accessLimit,
'join_tokens.download_limit' => $downloadLimit,
]);
GuestPolicySetting::query()->delete();
GuestPolicySetting::flushCache();
$settings = GuestPolicySetting::current();
$this->assertSame($accessLimit, $settings->join_token_access_limit);
$this->assertSame($downloadLimit, $settings->join_token_download_limit);
}
}

View File

@@ -0,0 +1,144 @@
<?php
namespace Tests\Unit;
use App\Enums\GuestNotificationType;
use App\Events\GuestPhotoUploaded;
use App\Listeners\GuestNotifications\SendPhotoUploadedNotification;
use App\Models\Event;
use App\Models\GuestNotification;
use App\Models\GuestNotificationReceipt;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Carbon;
use Tests\TestCase;
class SendPhotoUploadedNotificationTest extends TestCase
{
use RefreshDatabase;
public function test_it_dedupes_recent_photo_activity_notifications(): void
{
Carbon::setTestNow('2026-01-12 13:48:01');
$event = Event::factory()->create();
$listener = $this->app->make(SendPhotoUploadedNotification::class);
GuestNotification::factory()->create([
'tenant_id' => $event->tenant_id,
'event_id' => $event->id,
'type' => GuestNotificationType::PHOTO_ACTIVITY,
'title' => 'Fotospiel-Test hat gerade ein Foto gemacht 🎉',
'payload' => [
'photo_id' => 123,
'photo_ids' => [123],
'count' => 1,
],
'created_at' => now()->subSeconds(5),
'updated_at' => now()->subSeconds(5),
]);
$listener->handle(new GuestPhotoUploaded(
$event,
123,
'device-123',
'Fotospiel-Test'
));
$notification = GuestNotification::query()
->where('event_id', $event->id)
->where('type', GuestNotificationType::PHOTO_ACTIVITY)
->first();
$this->assertSame(1, GuestNotification::query()
->where('event_id', $event->id)
->where('type', GuestNotificationType::PHOTO_ACTIVITY)
->count());
$this->assertSame(1, (int) ($notification?->payload['count'] ?? 0));
}
public function test_it_groups_recent_photo_activity_notifications(): void
{
Carbon::setTestNow('2026-01-12 13:48:01');
$event = Event::factory()->create();
$listener = $this->app->make(SendPhotoUploadedNotification::class);
GuestNotification::factory()->create([
'tenant_id' => $event->tenant_id,
'event_id' => $event->id,
'type' => GuestNotificationType::PHOTO_ACTIVITY,
'title' => 'Fotospiel-Test hat gerade ein Foto gemacht 🎉',
'payload' => [
'photo_id' => 122,
'photo_ids' => [122],
'count' => 1,
],
'created_at' => now()->subMinutes(5),
'updated_at' => now()->subMinutes(5),
]);
$listener->handle(new GuestPhotoUploaded(
$event,
123,
'device-123',
'Fotospiel-Test'
));
$this->assertSame(1, GuestNotification::query()
->where('event_id', $event->id)
->where('type', GuestNotificationType::PHOTO_ACTIVITY)
->count());
$notification = GuestNotification::query()
->where('event_id', $event->id)
->where('type', GuestNotificationType::PHOTO_ACTIVITY)
->first();
$this->assertSame('Es gibt 2 neue Fotos!', $notification?->title);
$this->assertSame(2, (int) ($notification?->payload['count'] ?? 0));
$this->assertSame(1, GuestNotificationReceipt::query()
->where('guest_identifier', 'device-123')
->where('status', 'read')
->count());
}
public function test_it_creates_notification_outside_group_window(): void
{
Carbon::setTestNow('2026-01-12 13:48:01');
$event = Event::factory()->create();
$listener = $this->app->make(SendPhotoUploadedNotification::class);
GuestNotification::factory()->create([
'tenant_id' => $event->tenant_id,
'event_id' => $event->id,
'type' => GuestNotificationType::PHOTO_ACTIVITY,
'title' => 'Fotospiel-Test hat gerade ein Foto gemacht 🎉',
'payload' => [
'photo_id' => 122,
'photo_ids' => [122],
'count' => 1,
],
'created_at' => now()->subMinutes(20),
'updated_at' => now()->subMinutes(20),
]);
$listener->handle(new GuestPhotoUploaded(
$event,
123,
'device-123',
'Fotospiel-Test'
));
$this->assertSame(2, GuestNotification::query()
->where('event_id', $event->id)
->where('type', GuestNotificationType::PHOTO_ACTIVITY)
->count());
$this->assertSame(1, GuestNotificationReceipt::query()
->where('guest_identifier', 'device-123')
->where('status', 'read')
->count());
}
}