Compare commits
23 Commits
2287e7f32c
...
beads-sync
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a9fa1546f7 | ||
|
|
7c6eee187c | ||
|
|
fbd46b8e5c | ||
|
|
886b336a08 | ||
|
|
02237735ec | ||
|
|
5e420a0dd8 | ||
|
|
2a55ae934f | ||
|
|
e4100f7800 | ||
|
|
7786e3d134 | ||
|
|
30f3d148bb | ||
|
|
1970c259ed | ||
|
|
dc5c80cda4 | ||
|
|
75a9bcee12 | ||
|
|
6fe363640f | ||
|
|
3df0542013 | ||
|
|
4f4a527010 | ||
|
|
e69c94ad20 | ||
|
|
5afa96251b | ||
|
|
24f053d4c4 | ||
|
|
ec360ed860 | ||
|
|
83e78d7c66 | ||
|
|
9b1c5bf978 | ||
|
|
fb23a0a2f3 |
@@ -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-25q","title":"Security review: payments/webhooks code audit (signatures, idempotency, linkage)","status":"open","priority":2,"issue_type":"task","created_at":"2026-01-01T16:05:25.747336642+01:00","created_by":"soeren","updated_at":"2026-01-01T16:05:25.747336642+01:00"}
|
||||
{"id":"fotospiel-app-29o","title":"Paddle catalog sync: PackageResource sync status badges + timestamp","status":"closed","priority":2,"issue_type":"task","created_at":"2026-01-01T16:01:10.009385187+01:00","created_by":"soeren","updated_at":"2026-01-01T16:01:15.639525807+01:00","closed_at":"2026-01-01T16:01:15.639525807+01:00","close_reason":"Completed in codebase (verified)"}
|
||||
{"id":"fotospiel-app-29r","title":"Photobooth uploader: add watch-folder upload pipeline + persist creds","status":"closed","priority":2,"issue_type":"task","owner":"codex-agent@example.com","created_at":"2026-01-12T16:51:27.198056063+01:00","created_by":"Codex Agent","updated_at":"2026-01-12T17:07:04.06719869+01:00","closed_at":"2026-01-12T17:07:04.06719869+01:00","close_reason":"Closed"}
|
||||
{"id":"fotospiel-app-2b5","title":"Uploader: connect code expiry countdown","description":"Part of epic fotospiel-app-5aa. Show time-to-expiry for the active connect code in the client.","status":"open","priority":2,"issue_type":"task","owner":"codex-agent@example.com","created_at":"2026-01-13T11:04:05.74962406+01:00","created_by":"Codex Agent","updated_at":"2026-01-13T11:04:05.74962406+01:00"}
|
||||
{"id":"fotospiel-app-2hq","title":"Security review: marketing/API controller+validation review","status":"open","priority":2,"issue_type":"task","created_at":"2026-01-01T16:05:08.862737923+01:00","created_by":"soeren","updated_at":"2026-01-01T16:05:08.862737923+01:00"}
|
||||
{"id":"fotospiel-app-2yn","title":"Event-Admin: Reset link routing + notifications + tests","description":"Point password reset emails to event-admin reset page; add rate limiting and tests for the new flow.","status":"closed","priority":2,"issue_type":"task","created_at":"2026-01-06T10:45:09.279245468+01:00","created_by":"soeren","updated_at":"2026-01-06T11:01:49.083154811+01:00","closed_at":"2026-01-06T11:01:49.083154811+01:00","close_reason":"Closed"}
|
||||
{"id":"fotospiel-app-33m","title":"Security review checklist: Guest PWA dynamic tests","status":"open","priority":2,"issue_type":"task","created_at":"2026-01-01T16:04:40.730459361+01:00","created_by":"soeren","updated_at":"2026-01-01T16:04:40.730459361+01:00"}
|
||||
{"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-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-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-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.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"}]}
|
||||
@@ -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-576","title":"Tenant admin onboarding: legacy asset audit + component inventory","status":"closed","priority":2,"issue_type":"task","created_at":"2026-01-01T16:07:59.996563146+01:00","created_by":"soeren","updated_at":"2026-01-01T16:08:05.599274641+01:00","closed_at":"2026-01-01T16:08:05.599274641+01:00","close_reason":"Completed in codebase (verified)"}
|
||||
{"id":"fotospiel-app-579","title":"Live Show: tests (backend + UI smoke)","status":"closed","priority":2,"issue_type":"task","created_at":"2026-01-05T11:11:57.246607374+01:00","created_by":"soeren","updated_at":"2026-01-05T19:37:35.590123482+01:00","closed_at":"2026-01-05T19:37:35.590123482+01:00","close_reason":"Closed","dependencies":[{"issue_id":"fotospiel-app-579","depends_on_id":"fotospiel-app-539","type":"blocks","created_at":"2026-01-05T11:13:27.729131522+01:00","created_by":"soeren"},{"issue_id":"fotospiel-app-579","depends_on_id":"fotospiel-app-xg5","type":"blocks","created_at":"2026-01-05T11:13:37.425191011+01:00","created_by":"soeren"},{"issue_id":"fotospiel-app-579","depends_on_id":"fotospiel-app-qne","type":"blocks","created_at":"2026-01-05T11:13:46.257175231+01:00","created_by":"soeren"}]}
|
||||
{"id":"fotospiel-app-5aa","title":"Photobooth uploader: reliability + UX upgrades","status":"open","priority":2,"issue_type":"epic","owner":"codex-agent@example.com","created_at":"2026-01-13T11:01:29.745168595+01:00","created_by":"Codex Agent","updated_at":"2026-01-13T11:01:29.745168595+01:00"}
|
||||
{"id":"fotospiel-app-5dl","title":"Paddle catalog sync: PaddleCatalogService scaffold","status":"closed","priority":2,"issue_type":"task","created_at":"2026-01-01T16:00:24.916655836+01:00","created_by":"soeren","updated_at":"2026-01-01T16:00:30.566084195+01:00","closed_at":"2026-01-01T16:00:30.566084195+01:00","close_reason":"Completed in codebase (verified)"}
|
||||
{"id":"fotospiel-app-5hk","title":"Fix staging coupon seed 500 for E2E","status":"closed","priority":2,"issue_type":"task","created_at":"2026-01-03T15:12:53.643644221+01:00","created_by":"soeren","updated_at":"2026-01-04T16:21:46.441797374+01:00","closed_at":"2026-01-04T16:21:46.441797374+01:00","close_reason":"Resolved elsewhere; staging coupon seed 500 no longer reproducible after recent backend changes."}
|
||||
{"id":"fotospiel-app-5ie","title":"Help docs: Live Show how-to + recommended hardware (DE/EN)","status":"closed","priority":2,"issue_type":"task","created_at":"2026-01-05T11:12:05.973844187+01:00","created_by":"soeren","updated_at":"2026-01-05T19:42:44.39939087+01:00","closed_at":"2026-01-05T19:42:44.39939087+01:00","close_reason":"Closed","dependencies":[{"issue_id":"fotospiel-app-5ie","depends_on_id":"fotospiel-app-vro","type":"blocks","created_at":"2026-01-05T11:13:54.925412888+01:00","created_by":"soeren"},{"issue_id":"fotospiel-app-5ie","depends_on_id":"fotospiel-app-539","type":"blocks","created_at":"2026-01-05T11:14:03.257649076+01:00","created_by":"soeren"}]}
|
||||
{"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-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-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-6oj","title":"Security review: media pipeline code audit (AV/EXIF, signed URLs, storage separation)","status":"open","priority":2,"issue_type":"task","created_at":"2026-01-01T16:05:31.390878341+01:00","created_by":"soeren","updated_at":"2026-01-01T16:05:31.390878341+01:00"}
|
||||
{"id":"fotospiel-app-6yt","title":"Paddle migration: register sandbox webhooks + document events consumed","status":"closed","priority":2,"issue_type":"task","created_at":"2026-01-01T15:56:34.333714988+01:00","created_by":"soeren","updated_at":"2026-01-02T22:23:52.212191068+01:00","closed_at":"2026-01-02T22:23:52.212191068+01:00","close_reason":"Completed"}
|
||||
{"id":"fotospiel-app-6yz","title":"Uploader: activity log export","description":"Part of epic fotospiel-app-5aa. Add in-app log view and export/copy diagnostics for support.","status":"open","priority":2,"issue_type":"task","owner":"codex-agent@example.com","created_at":"2026-01-13T11:04:27.73767403+01:00","created_by":"Codex Agent","updated_at":"2026-01-13T11:04:27.73767403+01:00"}
|
||||
{"id":"fotospiel-app-6zc","title":"Live Show: Admin app settings \u0026 effect presets","status":"closed","priority":2,"issue_type":"feature","created_at":"2026-01-05T11:11:27.038815978+01:00","created_by":"soeren","updated_at":"2026-01-05T15:02:42.035082497+01:00","closed_at":"2026-01-05T15:02:42.035082497+01:00","close_reason":"Closed","dependencies":[{"issue_id":"fotospiel-app-6zc","depends_on_id":"fotospiel-app-vro","type":"blocks","created_at":"2026-01-05T11:12:50.048055484+01:00","created_by":"soeren"}]}
|
||||
{"id":"fotospiel-app-7bu","title":"Paddle migration: extend config/env handling for Paddle keys/webhook secrets","status":"closed","priority":2,"issue_type":"task","created_at":"2026-01-01T15:57:27.242854801+01:00","created_by":"soeren","updated_at":"2026-01-01T15:57:32.890355888+01:00","closed_at":"2026-01-01T15:57:32.890355888+01:00","close_reason":"Completed in codebase (verified)"}
|
||||
{"id":"fotospiel-app-7u1","title":"Paddle catalog sync: PaddlePackagePull job","status":"closed","priority":2,"issue_type":"task","created_at":"2026-01-01T16:00:47.468892178+01:00","created_by":"soeren","updated_at":"2026-01-01T16:00:53.126602817+01:00","closed_at":"2026-01-01T16:00:53.126602817+01:00","close_reason":"Completed in codebase (verified)"}
|
||||
{"id":"fotospiel-app-7uu","title":"Uploader: improve file readiness detection","description":"Part of epic fotospiel-app-5aa. Use size + last-write stabilization to avoid partial uploads.","status":"open","priority":2,"issue_type":"task","owner":"codex-agent@example.com","created_at":"2026-01-13T11:01:54.142231578+01:00","created_by":"Codex Agent","updated_at":"2026-01-13T11:01:54.142231578+01:00"}
|
||||
{"id":"fotospiel-app-7x1","title":"Uploader: response format manual override","description":"Part of epic fotospiel-app-5aa. Allow manual response format override when connect code doesn't set it.","status":"open","priority":2,"issue_type":"task","owner":"codex-agent@example.com","created_at":"2026-01-13T11:03:54.824613016+01:00","created_by":"Codex Agent","updated_at":"2026-01-13T11:03:54.824613016+01:00"}
|
||||
{"id":"fotospiel-app-83q","title":"Implement Advanced Analytics","description":"Full plan: Phase 1 (MVP) includes Activity Timeline, Top Contributors, and Task Stats. Phase 2 includes Engagement Funnel, Vibe Check, and PDF Export. See chat history for details.","status":"closed","priority":2,"issue_type":"task","created_at":"2026-01-06T15:40:08.826105426+01:00","created_by":"soeren","updated_at":"2026-01-06T16:15:17.722450844+01:00","closed_at":"2026-01-06T16:15:17.722455019+01:00"}
|
||||
{"id":"fotospiel-app-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-99d","title":"Paddle migration: marketing checkout uses Paddle-hosted checkout","status":"closed","priority":2,"issue_type":"task","created_at":"2026-01-01T15:58:12.298063897+01:00","created_by":"soeren","updated_at":"2026-01-01T15:58:17.968032021+01:00","closed_at":"2026-01-01T15:58:17.968032021+01:00","close_reason":"Completed in codebase (verified)"}
|
||||
{"id":"fotospiel-app-99o","title":"Fix German welcome phrasing with article-safe app_name","status":"closed","priority":2,"issue_type":"task","created_at":"2026-01-04T11:50:17.410390085+01:00","created_by":"soeren","updated_at":"2026-01-04T12:19:55.741616753+01:00","closed_at":"2026-01-04T12:19:55.741616753+01:00","close_reason":"Closed"}
|
||||
@@ -62,9 +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-bxu","title":"Checkout refactor: Stripe/Paddle payment integration + webhooks","status":"closed","priority":2,"issue_type":"task","created_at":"2026-01-01T16:06:32.279485614+01:00","created_by":"soeren","updated_at":"2026-01-01T16:06:37.876950599+01:00","closed_at":"2026-01-01T16:06:37.876950599+01:00","close_reason":"Completed in codebase (verified)"}
|
||||
{"id":"fotospiel-app-bzb","title":"Paddle catalog sync: migration for paddle sync columns","status":"closed","priority":2,"issue_type":"task","created_at":"2026-01-01T16:00:02.362257158+01:00","created_by":"soeren","updated_at":"2026-01-01T16:00:08.018770606+01:00","closed_at":"2026-01-01T16:00:08.018770606+01:00","close_reason":"Completed in codebase (verified)"}
|
||||
{"id":"fotospiel-app-cht","title":"Uploader: disk space low warning","description":"Part of epic fotospiel-app-5aa. Highlight low disk space thresholds in UI.","status":"open","priority":2,"issue_type":"task","owner":"codex-agent@example.com","created_at":"2026-01-13T11:03:32.710631234+01:00","created_by":"Codex Agent","updated_at":"2026-01-13T11:03:32.710631234+01:00"}
|
||||
{"id":"fotospiel-app-ci5","title":"Paddle catalog sync: configure log channel/Slack hook for sync outcomes","status":"closed","priority":2,"issue_type":"task","created_at":"2026-01-01T15:59:20.543083527+01:00","created_by":"soeren","updated_at":"2026-01-02T22:02:15.857149244+01:00","closed_at":"2026-01-02T22:02:15.857149244+01:00","close_reason":"Completed"}
|
||||
{"id":"fotospiel-app-cwq","title":"Integrations health: unified Paddle/RevenueCat/webhook status dashboard","description":"Add a superadmin integrations health dashboard for Paddle/RevenueCat/webhooks.\nScope: show latest webhook processing status/lag, recent failures, retry backlog, and config presence (env set) without exposing secrets.\nInclude per-provider status badges and time-window filters, plus links to related logs/actions.\n","status":"closed","priority":2,"issue_type":"task","created_at":"2026-01-02T17:34:20.84661157+01:00","created_by":"soeren","updated_at":"2026-01-02T18:33:07.133704488+01:00","closed_at":"2026-01-02T18:33:07.133704488+01:00","close_reason":"Closed"}
|
||||
{"id":"fotospiel-app-d39","title":"Superadmin control surface spec and access matrix","description":"Define the minimal superadmin control surface, permissions, and mapping to tenant/guest responsibilities. Document scope and non-goals.","status":"closed","priority":2,"issue_type":"task","created_at":"2026-01-01T14:16:06.994379577+01:00","updated_at":"2026-01-01T14:20:43.080701114+01:00","closed_at":"2026-01-01T14:20:43.080701114+01:00"}
|
||||
{"id":"fotospiel-app-dar","title":"Uploader: retry policy for failed uploads","description":"Part of epic fotospiel-app-5aa. Auto-retry with backoff and retry limit before marking failed.","status":"open","priority":2,"issue_type":"task","owner":"codex-agent@example.com","created_at":"2026-01-13T11:03:00.808893045+01:00","created_by":"Codex Agent","updated_at":"2026-01-13T11:03:00.808893045+01:00"}
|
||||
{"id":"fotospiel-app-de7","title":"Re-run admin Playwright tests with valid E2E credentials","status":"open","priority":3,"issue_type":"task","owner":"codex-agent@example.com","created_at":"2026-01-15T19:53:26.674926731+01:00","created_by":"Codex Agent","updated_at":"2026-01-15T19:53:26.674926731+01:00"}
|
||||
{"id":"fotospiel-app-dl5","title":"SEC-API-01 Signed URL middleware + asset migration","status":"closed","priority":2,"issue_type":"task","created_at":"2026-01-01T15:52:24.24098702+01:00","created_by":"soeren","updated_at":"2026-01-01T15:52:29.8793891+01:00","closed_at":"2026-01-01T15:52:29.8793891+01:00","close_reason":"Completed in codebase (verified)"}
|
||||
{"id":"fotospiel-app-dm4","title":"SEC-BILL-01 Checkout session linkage + idempotency locks","status":"closed","priority":2,"issue_type":"task","created_at":"2026-01-01T15:53:26.350238207+01:00","created_by":"soeren","updated_at":"2026-01-01T15:53:31.997737421+01:00","closed_at":"2026-01-01T15:53:31.997737421+01:00","close_reason":"Completed in codebase (verified)"}
|
||||
{"id":"fotospiel-app-dmb","title":"Security review checklist: Event Admin dynamic tests","status":"open","priority":2,"issue_type":"task","created_at":"2026-01-01T16:04:46.359468828+01:00","created_by":"soeren","updated_at":"2026-01-01T16:04:46.359468828+01:00"}
|
||||
@@ -85,6 +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-jk4","title":"Checkout refactor: CheckoutController + marketing route alignment","status":"closed","priority":2,"issue_type":"task","created_at":"2026-01-01T16:06:21.088319132+01:00","created_by":"soeren","updated_at":"2026-01-01T16:06:26.663419594+01:00","closed_at":"2026-01-01T16:06:26.663419594+01:00","close_reason":"Completed in codebase (verified)"}
|
||||
{"id":"fotospiel-app-jqy","title":"Tenant admin onboarding: Playwright skeleton for welcome flow","status":"closed","priority":2,"issue_type":"task","created_at":"2026-01-01T16:08:11.226297707+01:00","created_by":"soeren","updated_at":"2026-01-01T16:08:16.827679424+01:00","closed_at":"2026-01-01T16:08:16.827679424+01:00","close_reason":"Completed in codebase (verified)"}
|
||||
{"id":"fotospiel-app-jy1","title":"Uploader: clear failed uploads UI","description":"Part of epic fotospiel-app-5aa. Add action to clear/reset failed items and counters.","status":"open","priority":2,"issue_type":"task","owner":"codex-agent@example.com","created_at":"2026-01-13T11:03:13.134661157+01:00","created_by":"Codex Agent","updated_at":"2026-01-13T11:03:13.134661157+01:00"}
|
||||
{"id":"fotospiel-app-ko0","title":"Security review checklist: Webhooks/Billing dynamic tests","status":"open","priority":2,"issue_type":"task","created_at":"2026-01-01T16:04:51.987093237+01:00","created_by":"soeren","updated_at":"2026-01-01T16:04:51.987093237+01:00"}
|
||||
{"id":"fotospiel-app-kry","title":"Paddle catalog sync: add DTO helpers for Paddle product/price responses","status":"closed","priority":2,"issue_type":"task","created_at":"2026-01-01T15:58:57.817750548+01:00","created_by":"soeren","updated_at":"2026-01-02T21:11:27.970220923+01:00","closed_at":"2026-01-02T21:11:27.970220923+01:00","close_reason":"Deprioritized"}
|
||||
{"id":"fotospiel-app-kso","title":"SEC-MS-02 Streaming upload refactor + tests","description":"Current state (code scan)\n- Guest uploads: App\\\\Http\\\\Controllers\\\\Api\\\\EventPublicController@upload uses Storage::disk()-\u003eputFile (stream-friendly) but still does watermark/thumbnail work inline.\n- Tenant admin uploads: App\\\\Http\\\\Controllers\\\\Api\\\\Tenant\\\\PhotoController@store and @uploadDirect use Storage::disk()-\u003eput($path, file_get_contents(...)) which loads entire file into memory.\n- Photobooth ingest already streams from import disk via readStream -\u003e Storage::disk()-\u003eput($path, $stream).\n- Presigned upload flow is stubbed to a local upload-direct endpoint; no true presigned S3 handling yet.\n- No tenant upload feature tests exist; guest upload tests exist and cover limits/security.\n\nGoal\n- Stream uploads to disk (avoid full in-memory buffers) for tenant-admin upload endpoints and keep behavior consistent across sources.\n\nPlan\n1) Introduce a small streaming upload helper/service\n - New service (e.g. App\\\\Services\\\\Storage\\\\UploadStreamService) that accepts UploadedFile + disk + destination path.\n - Use fopen on UploadedFile::getRealPath (or $file-\u003egetStream()) and Storage::disk($disk)-\u003eput($path, $stream) / writeStream.\n - Always close stream; return stored size and checksum (hash_file on stored path) for asset metadata.\n\n2) Refactor tenant upload endpoints to use streaming\n - Update PhotoController@store and @uploadDirect to use the helper instead of file_get_contents.\n - Use Storage::disk()-\u003eputFileAs (or helper) to preserve deterministic paths without buffering.\n - Keep existing validation, watermark, thumbnail, asset recording, and package usage logic.\n\n3) Optional consistency pass on guest upload\n - Consider routing EventPublicController@upload through the same helper for consistent storage + checksum handling, while keeping current validation/limits.\n\n4) Tests\n - Add Feature tests for tenant upload endpoints:\n - /api/v1/tenant/events/{slug}/photos (store) uploads a fake image and persists Photo + EventMediaAsset with expected path/size.\n - /api/v1/tenant/events/{slug}/upload-direct (presigned) uploads a fake image and stores asset + thumbnail.\n - Ensure existing guest upload tests still pass (no behavioral changes).\n\n5) Safety/ops\n - Verify streaming logic handles empty/invalid files gracefully and still reports errors via ApiError.\n - Keep request-time processing (thumb/watermark) unchanged for now; consider queuing in a follow-up if CPU spikes persist.","status":"closed","priority":2,"issue_type":"task","created_at":"2026-01-01T15:53:03.729137616+01:00","created_by":"soeren","updated_at":"2026-01-02T20:51:17.752365339+01:00","closed_at":"2026-01-02T20:51:17.752365339+01:00","close_reason":"Closed"}
|
||||
@@ -92,6 +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-l6a","title":"Registration flow fixes: JSON redirect, error clearing, role handling","status":"closed","priority":2,"issue_type":"task","created_at":"2026-01-01T16:07:16.253760139+01:00","created_by":"soeren","updated_at":"2026-01-01T16:07:21.964843904+01:00","closed_at":"2026-01-01T16:07:21.964843904+01:00","close_reason":"Completed in codebase (verified)"}
|
||||
{"id":"fotospiel-app-l8q","title":"SEC-GT-02 Join-token analytics dashboard (Grafana)","description":"Logging + Filament summaries exist; Grafana dashboard still missing.","status":"open","priority":2,"issue_type":"task","created_at":"2026-01-01T15:52:12.920875329+01:00","created_by":"soeren","updated_at":"2026-01-01T15:52:12.920875329+01:00"}
|
||||
{"id":"fotospiel-app-lj6","title":"Uploader: folder health enhancements","description":"Part of epic fotospiel-app-5aa. Track last file seen, write permissions, and show clearer folder status.","status":"open","priority":2,"issue_type":"task","owner":"codex-agent@example.com","created_at":"2026-01-13T11:03:22.843330813+01:00","created_by":"Codex Agent","updated_at":"2026-01-13T11:03:22.843330813+01:00"}
|
||||
{"id":"fotospiel-app-llq","title":"Uploader: lock settings after connect","description":"Part of epic fotospiel-app-5aa. Prevent accidental changes to base URL/credentials unless explicitly unlocked.","status":"open","priority":2,"issue_type":"task","owner":"codex-agent@example.com","created_at":"2026-01-13T11:03:43.40971185+01:00","created_by":"Codex Agent","updated_at":"2026-01-13T11:03:43.40971185+01:00"}
|
||||
{"id":"fotospiel-app-ln3","title":"Paddle catalog sync: announce workflow change to admin users","status":"closed","priority":2,"issue_type":"task","created_at":"2026-01-01T15:59:49.021233635+01:00","created_by":"soeren","updated_at":"2026-01-02T21:11:09.349495631+01:00","closed_at":"2026-01-02T21:11:09.349495631+01:00","close_reason":"Deprioritized"}
|
||||
{"id":"fotospiel-app-lnb","title":"SEC-GT-01 Hash join tokens + data migration","status":"closed","priority":2,"issue_type":"task","created_at":"2026-01-01T15:52:01.658868778+01:00","created_by":"soeren","updated_at":"2026-01-01T15:52:07.314317124+01:00","closed_at":"2026-01-01T15:52:07.314317124+01:00","close_reason":"Completed in codebase (verified)"}
|
||||
{"id":"fotospiel-app-lnf","title":"Remove legacy registration page assets","status":"open","priority":2,"issue_type":"task","created_at":"2026-01-06T08:37:39.419274918+01:00","created_by":"soeren","updated_at":"2026-01-06T08:37:39.419274918+01:00"}
|
||||
@@ -101,6 +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-mol","title":"Coupon ops: wire analytics into Matomo dashboard","status":"closed","priority":2,"issue_type":"task","created_at":"2026-01-01T16:09:27.722458747+01:00","created_by":"soeren","updated_at":"2026-01-02T23:28:18.178704873+01:00","closed_at":"2026-01-02T23:28:18.178704873+01:00","close_reason":"Closed"}
|
||||
{"id":"fotospiel-app-mpu","title":"Checkout refactor: test coverage + rollout notes","status":"closed","priority":2,"issue_type":"task","created_at":"2026-01-01T16:06:43.488302531+01:00","created_by":"soeren","updated_at":"2026-01-01T16:06:49.13645691+01:00","closed_at":"2026-01-01T16:06:49.13645691+01:00","close_reason":"Completed in codebase (verified)"}
|
||||
{"id":"fotospiel-app-mwi","title":"Uploader: duplicate detection / upload cache","description":"Part of epic fotospiel-app-5aa. Track uploaded files (path/hash) to avoid re-uploads after restart.","status":"open","priority":2,"issue_type":"task","owner":"codex-agent@example.com","created_at":"2026-01-13T11:02:06.432781468+01:00","created_by":"Codex Agent","updated_at":"2026-01-13T11:02:06.432781468+01:00"}
|
||||
{"id":"fotospiel-app-mx5","title":"Localized SEO: sitemap updated with locale alternates","status":"closed","priority":2,"issue_type":"task","created_at":"2026-01-01T16:02:15.177013722+01:00","created_by":"soeren","updated_at":"2026-01-01T16:02:20.812287917+01:00","closed_at":"2026-01-01T16:02:20.812287917+01:00","close_reason":"Completed in codebase (verified)"}
|
||||
{"id":"fotospiel-app-mxw","title":"Security review: configure env assumptions for dynamic testing","status":"open","priority":2,"issue_type":"task","created_at":"2026-01-01T16:04:29.498402235+01:00","created_by":"soeren","updated_at":"2026-01-01T16:04:29.498402235+01:00"}
|
||||
{"id":"fotospiel-app-n8q","title":"Paddle migration: draft production cutover procedure","status":"closed","priority":2,"issue_type":"task","created_at":"2026-01-01T15:56:51.427425262+01:00","created_by":"soeren","updated_at":"2026-01-02T22:28:41.469357437+01:00","closed_at":"2026-01-02T22:28:41.469357437+01:00","close_reason":"Completed"}
|
||||
@@ -118,11 +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-qne","title":"Live Show: realtime delivery channel (WS/SSE) + fallback polling","acceptance_criteria":"- Public Live Show endpoints exist for state, updates, and SSE stream\\n- Updates endpoint supports cursor (after_approved_at + after_id)\\n- SSE emits photo.approved and ping, with settings updates when version changes\\n- Feature tests cover state, updates, invalid token","notes":"Added LiveShowController with public endpoints: /api/v1/live-show/{token} (state), /updates (polling), /stream (SSE). Provides live-show settings (defaults + event.settings.live_show merge), settings_version hash, ordered approved photo feed with cursor. SSE emits photo.approved, settings.updated, ping. Added routes in routes/api.php. Added Photo live_status default. Tests: tests/Feature/LiveShowRealtimeTest.php. Ran Pint + test.","status":"closed","priority":1,"issue_type":"feature","created_at":"2026-01-05T11:11:06.028871737+01:00","created_by":"soeren","updated_at":"2026-01-05T13:08:33.936740582+01:00","closed_at":"2026-01-05T13:08:33.936740582+01:00","close_reason":"Closed","dependencies":[{"issue_id":"fotospiel-app-qne","depends_on_id":"fotospiel-app-t1k","type":"blocks","created_at":"2026-01-05T11:12:30.363982215+01:00","created_by":"soeren"}]}
|
||||
{"id":"fotospiel-app-qtn","title":"Security review kickoff mitigations (CORS allowlist, headers, upload hardening, signed URLs)","status":"closed","priority":2,"issue_type":"task","created_at":"2026-01-01T16:09:46.310873311+01:00","created_by":"soeren","updated_at":"2026-01-01T16:09:51.914359487+01:00","closed_at":"2026-01-01T16:09:51.914359487+01:00","close_reason":"Completed in codebase (verified)"}
|
||||
{"id":"fotospiel-app-rpv","title":"Uploader: connection test (no upload)","description":"Part of epic fotospiel-app-5aa. Add lightweight ping/test for upload URL + credentials.","status":"open","priority":2,"issue_type":"task","owner":"codex-agent@example.com","created_at":"2026-01-13T11:02:39.061938692+01:00","created_by":"Codex Agent","updated_at":"2026-01-13T11:02:39.061938692+01:00"}
|
||||
{"id":"fotospiel-app-sbs","title":"Compliance tools: data export + retention overrides","description":"GDPR-compliant export requests and retention override workflows for tenants/events.","status":"closed","priority":3,"issue_type":"feature","created_at":"2026-01-01T14:20:16.530289009+01:00","updated_at":"2026-01-02T20:13:31.704875591+01:00","closed_at":"2026-01-02T20:13:31.704875591+01:00","close_reason":"Closed"}
|
||||
{"id":"fotospiel-app-sdg","title":"Uploader: watch include/exclude patterns","description":"Part of epic fotospiel-app-5aa. Configurable file patterns (ignore tmp/preview) for watcher.","status":"open","priority":2,"issue_type":"task","owner":"codex-agent@example.com","created_at":"2026-01-13T11:02:17.188267106+01:00","created_by":"Codex Agent","updated_at":"2026-01-13T11:02:17.188267106+01:00"}
|
||||
{"id":"fotospiel-app-sju","title":"Live Show link sharing + QR in admin","description":"Expose Live Show link in Event Admin with copy/share/open actions and embedded QR (use simplesoftwareio/simple-qrcode, no external service). Add API endpoints for link fetch/rotate, admin UI card with rotate confirmation, and tests.","status":"open","priority":2,"issue_type":"task","created_at":"2026-01-05T20:00:25.427132538+01:00","created_by":"soeren","updated_at":"2026-01-05T20:00:25.427132538+01:00"}
|
||||
{"id":"fotospiel-app-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-t1k","title":"Live Show: data model \u0026 status workflow (pending/approved/ready)","acceptance_criteria":"- DB migrations add event token + photo live fields + indexes\\n- Token generation supports rotation (no expiry)\\n- Photo live workflow methods set timestamps/reviewer consistently\\n- Feature test covers token + workflow","notes":"Implemented Live Show data model: events.live_show_token + live_show_token_rotated_at; photos.live_status + timestamps/reviewer/rejection fields + indexes. Added PhotoLiveStatus enum and Photo workflow methods (markLivePending/approveForLiveShow/rejectForLiveShow). Added Event helpers (ensureLiveShowToken/rotateLiveShowToken). Tests: tests/Feature/LiveShowDataModelTest.php.","status":"closed","priority":1,"issue_type":"feature","created_at":"2026-01-05T11:10:56.560421826+01:00","created_by":"soeren","updated_at":"2026-01-05T12:22:51.967913423+01:00","closed_at":"2026-01-05T12:22:51.967913423+01:00","close_reason":"Closed","dependencies":[{"issue_id":"fotospiel-app-t1k","depends_on_id":"fotospiel-app-vro","type":"blocks","created_at":"2026-01-05T11:12:20.345646244+01:00","created_by":"soeren"},{"issue_id":"fotospiel-app-t1k","depends_on_id":"fotospiel-app-h5d","type":"blocks","created_at":"2026-01-05T11:44:12.439413712+01:00","created_by":"soeren"},{"issue_id":"fotospiel-app-t1k","depends_on_id":"fotospiel-app-1eu","type":"blocks","created_at":"2026-01-05T11:44:22.588642567+01:00","created_by":"soeren"},{"issue_id":"fotospiel-app-t1k","depends_on_id":"fotospiel-app-1we","type":"blocks","created_at":"2026-01-05T11:44:31.775634827+01:00","created_by":"soeren"}]}
|
||||
{"id":"fotospiel-app-t2s","title":"Uploader: multiple event profiles","description":"Part of epic fotospiel-app-5aa. Save multiple event profiles and allow quick switching.","status":"open","priority":2,"issue_type":"task","owner":"codex-agent@example.com","created_at":"2026-01-13T11:04:18.20222112+01:00","created_by":"Codex Agent","updated_at":"2026-01-13T11:04:18.20222112+01:00"}
|
||||
{"id":"fotospiel-app-tqg","title":"Tenant admin onboarding: staging E2E validation","status":"open","priority":2,"issue_type":"task","created_at":"2026-01-01T16:08:57.448899354+01:00","created_by":"soeren","updated_at":"2026-01-01T16:08:57.448899354+01:00"}
|
||||
{"id":"fotospiel-app-tsb","title":"Uploader: upload throttling presets","description":"Part of epic fotospiel-app-5aa. Add optional delay/presets to smooth upload bursts.","status":"open","priority":2,"issue_type":"task","owner":"codex-agent@example.com","created_at":"2026-01-13T11:02:27.111436345+01:00","created_by":"Codex Agent","updated_at":"2026-01-13T11:02:27.111436345+01:00"}
|
||||
{"id":"fotospiel-app-ty9","title":"Security review: data classes \u0026 retention baseline","status":"closed","priority":2,"issue_type":"task","created_at":"2026-01-01T16:03:09.595870306+01:00","created_by":"soeren","updated_at":"2026-01-01T16:03:15.211042718+01:00","closed_at":"2026-01-01T16:03:15.211042718+01:00","close_reason":"Completed in codebase (verified)"}
|
||||
{"id":"fotospiel-app-tym","title":"Ops health dashboard (queues, storage, upload pipeline)","description":"Superadmin ops dashboard showing queue backlog, failed jobs, storage thresholds, and upload pipeline health.","notes":"Implemented Ops Health dashboard with storage+queue widgets, new translations, and navigation wiring.","status":"closed","priority":2,"issue_type":"feature","created_at":"2026-01-01T14:20:04.991351193+01:00","updated_at":"2026-01-02T17:34:10.326367902+01:00","closed_at":"2026-01-02T17:34:10.326367902+01:00","close_reason":"Closed"}
|
||||
{"id":"fotospiel-app-ugk","title":"Paddle catalog sync: feature test for artisan command","status":"closed","priority":2,"issue_type":"task","created_at":"2026-01-01T16:01:33.309716868+01:00","created_by":"soeren","updated_at":"2026-01-01T16:01:38.940407157+01:00","closed_at":"2026-01-01T16:01:38.940407157+01:00","close_reason":"Completed in codebase (verified)"}
|
||||
@@ -141,6 +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-xg5","title":"Live Show: Admin app moderation queue UI","acceptance_criteria":"- Dedicated Live Show moderation API endpoints exist for list + approve/reject/clear\\n- Admin mobile UI exposes Live Show queue with status filter and actions\\n- PhotoResource includes live_* fields for admin UI\\n- Feature tests cover list + approve/reject/clear workflows","notes":"Added dedicated Live Show moderation API (tenant admin): /events/{slug}/live-show/photos + approve/reject/clear actions. Added LiveShowPhotoController + FormRequests. PhotoResource now exposes live_* fields. Admin app: new Live Show queue page, route, and Event detail shortcut tile. Admin API updated with Live Show functions + types. Added translations (EN/DE) for Live Show queue UI. Tests: tests/Feature/LiveShowPhotoControllerTest.php.","status":"closed","priority":2,"issue_type":"feature","created_at":"2026-01-05T11:11:15.006484132+01:00","created_by":"soeren","updated_at":"2026-01-05T14:03:41.410176482+01:00","closed_at":"2026-01-05T14:03:41.410176482+01:00","close_reason":"Closed","dependencies":[{"issue_id":"fotospiel-app-xg5","depends_on_id":"fotospiel-app-t1k","type":"blocks","created_at":"2026-01-05T11:12:38.94145573+01:00","created_by":"soeren"}]}
|
||||
{"id":"fotospiel-app-xht","title":"Paddle migration: tenant ↔ Paddle customer sync + webhook handlers","status":"closed","priority":2,"issue_type":"task","created_at":"2026-01-01T15:58:01.028435913+01:00","created_by":"soeren","updated_at":"2026-01-01T15:58:06.685122343+01:00","closed_at":"2026-01-01T15:58:06.685122343+01:00","close_reason":"Completed in codebase (verified)"}
|
||||
{"id":"fotospiel-app-xik","title":"Uploader: richer error details","description":"Part of epic fotospiel-app-5aa. Surface HTTP status/body summary in last error and recent uploads.","status":"open","priority":2,"issue_type":"task","owner":"codex-agent@example.com","created_at":"2026-01-13T11:02:49.591107008+01:00","created_by":"Codex Agent","updated_at":"2026-01-13T11:02:49.591107008+01:00"}
|
||||
{"id":"fotospiel-app-y1f","title":"Compliance tools: superadmin data export + retention override UI","description":"Add superadmin compliance tools for data exports and retention overrides.\nScope: list export requests, status, expiry, and allow manual retry/cancel; add per-tenant/event retention override UI with audit logging.\nEnsure access is restricted to superadmins and no PII is exposed beyond existing export metadata.\n","status":"closed","priority":2,"issue_type":"task","created_at":"2026-01-02T17:34:29.825347299+01:00","created_by":"soeren","updated_at":"2026-01-02T22:49:53.586758621+01:00","closed_at":"2026-01-02T22:49:53.586758621+01:00","close_reason":"Closed"}
|
||||
{"id":"fotospiel-app-yii","title":"Implement 'Upgrade to Premium' flow for Analytics Upsell","description":"The Analytics page currently has an upsell screen for non-premium users. The 'Upgrade to Premium' button redirects to the billing page, but the actual upgrade/purchase flow needs to be fully implemented and verified to allow users to unlock the feature.","status":"closed","priority":2,"issue_type":"task","created_at":"2026-01-06T16:13:55.446495378+01:00","created_by":"soeren","updated_at":"2026-01-06T16:35:41.968964977+01:00","closed_at":"2026-01-06T16:35:41.968970147+01:00"}
|
||||
{"id":"fotospiel-app-z2k","title":"Ops health widget visual polish","description":"Replace Tailwind utility styling in ops health widget with Filament components and icon-driven layout.","notes":"Updated queue health widget layout to use Filament cards, badges, empty states, and grid utilities; added status strip and alert rail.","status":"closed","priority":3,"issue_type":"task","created_at":"2026-01-01T21:34:39.851728527+01:00","created_by":"soeren","updated_at":"2026-01-01T21:34:59.834597413+01:00","closed_at":"2026-01-01T21:34:59.834597413+01:00","close_reason":"completed"}
|
||||
|
||||
@@ -1 +1 @@
|
||||
fotospiel-app-9em
|
||||
fotospiel-app-29r
|
||||
|
||||
2
.gitignore
vendored
2
.gitignore
vendored
@@ -13,6 +13,8 @@ fotospiel-tenant-app
|
||||
/storage/*.key
|
||||
/storage/pail
|
||||
/vendor
|
||||
/clients/photobooth-uploader/**/bin
|
||||
/clients/photobooth-uploader/**/obj
|
||||
.env
|
||||
.env.backup
|
||||
.env.production
|
||||
|
||||
45
app/Http/Controllers/Api/PhotoboothConnectController.php
Normal file
45
app/Http/Controllers/Api/PhotoboothConnectController.php
Normal 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'),
|
||||
],
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -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.');
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Requests\Photobooth;
|
||||
|
||||
use Illuminate\Foundation\Http\FormRequest;
|
||||
|
||||
class PhotoboothConnectRedeemRequest extends FormRequest
|
||||
{
|
||||
/**
|
||||
* Determine if the user is authorized to make this request.
|
||||
*/
|
||||
public function authorize(): bool
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the validation rules that apply to the request.
|
||||
*
|
||||
* @return array<string, \Illuminate\Contracts\Validation\ValidationRule|array<mixed>|string>
|
||||
*/
|
||||
public function rules(): array
|
||||
{
|
||||
return [
|
||||
'code' => ['required', 'string', 'size:6', 'regex:/^\d{6}$/'],
|
||||
];
|
||||
}
|
||||
|
||||
protected function prepareForValidation(): void
|
||||
{
|
||||
$code = preg_replace('/\D+/', '', (string) $this->input('code'));
|
||||
|
||||
$this->merge([
|
||||
'code' => $code,
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,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'],
|
||||
];
|
||||
}
|
||||
}
|
||||
25
app/Models/PhotoboothConnectCode.php
Normal file
25
app/Models/PhotoboothConnectCode.php
Normal file
@@ -0,0 +1,25 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
|
||||
class PhotoboothConnectCode extends Model
|
||||
{
|
||||
/** @use HasFactory<\Database\Factories\PhotoboothConnectCodeFactory> */
|
||||
use HasFactory;
|
||||
|
||||
protected $guarded = [];
|
||||
|
||||
protected $casts = [
|
||||
'expires_at' => 'datetime',
|
||||
'redeemed_at' => 'datetime',
|
||||
];
|
||||
|
||||
public function event(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Event::class);
|
||||
}
|
||||
}
|
||||
@@ -162,6 +162,10 @@ class AppServiceProvider extends ServiceProvider
|
||||
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) {
|
||||
return Limit::perMinute(20)->by('tenant-auth:'.($request->ip() ?? 'unknown'));
|
||||
});
|
||||
|
||||
80
app/Services/Photobooth/PhotoboothConnectCodeService.php
Normal file
80
app/Services/Photobooth/PhotoboothConnectCodeService.php
Normal file
@@ -0,0 +1,80 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services\Photobooth;
|
||||
|
||||
use App\Models\Event;
|
||||
use App\Models\PhotoboothConnectCode;
|
||||
|
||||
class PhotoboothConnectCodeService
|
||||
{
|
||||
public function create(Event $event, ?int $expiresInMinutes = null): array
|
||||
{
|
||||
$length = (int) config('photobooth.connect_code.length', 6);
|
||||
$length = max(4, min(8, $length));
|
||||
|
||||
$expiresInMinutes = $expiresInMinutes ?: (int) config('photobooth.connect_code.expires_minutes', 10);
|
||||
$expiresInMinutes = max(1, min(120, $expiresInMinutes));
|
||||
|
||||
$code = null;
|
||||
$hash = null;
|
||||
$max = (10 ** $length) - 1;
|
||||
|
||||
for ($attempts = 0; $attempts < 5; $attempts++) {
|
||||
$candidate = str_pad((string) random_int(0, $max), $length, '0', STR_PAD_LEFT);
|
||||
$candidateHash = hash('sha256', $candidate);
|
||||
|
||||
$exists = PhotoboothConnectCode::query()
|
||||
->where('code_hash', $candidateHash)
|
||||
->whereNull('redeemed_at')
|
||||
->where('expires_at', '>=', now())
|
||||
->exists();
|
||||
|
||||
if (! $exists) {
|
||||
$code = $candidate;
|
||||
$hash = $candidateHash;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (! $code || ! $hash) {
|
||||
$code = str_pad((string) random_int(0, $max), $length, '0', STR_PAD_LEFT);
|
||||
$hash = hash('sha256', $code);
|
||||
}
|
||||
|
||||
$expiresAt = now()->addMinutes($expiresInMinutes);
|
||||
|
||||
$record = PhotoboothConnectCode::query()->create([
|
||||
'event_id' => $event->getKey(),
|
||||
'code_hash' => $hash,
|
||||
'expires_at' => $expiresAt,
|
||||
]);
|
||||
|
||||
return [
|
||||
'code' => $code,
|
||||
'record' => $record,
|
||||
'expires_at' => $expiresAt,
|
||||
];
|
||||
}
|
||||
|
||||
public function redeem(string $code): ?PhotoboothConnectCode
|
||||
{
|
||||
$hash = hash('sha256', $code);
|
||||
|
||||
/** @var PhotoboothConnectCode|null $record */
|
||||
$record = PhotoboothConnectCode::query()
|
||||
->where('code_hash', $hash)
|
||||
->whereNull('redeemed_at')
|
||||
->where('expires_at', '>=', now())
|
||||
->first();
|
||||
|
||||
if (! $record) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$record->forceFill([
|
||||
'redeemed_at' => now(),
|
||||
])->save();
|
||||
|
||||
return $record;
|
||||
}
|
||||
}
|
||||
10
clients/photobooth-uploader/PhotoboothUploader/App.axaml
Normal file
10
clients/photobooth-uploader/PhotoboothUploader/App.axaml
Normal 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>
|
||||
23
clients/photobooth-uploader/PhotoboothUploader/App.axaml.cs
Normal file
23
clients/photobooth-uploader/PhotoboothUploader/App.axaml.cs
Normal file
@@ -0,0 +1,23 @@
|
||||
using Avalonia;
|
||||
using Avalonia.Controls.ApplicationLifetimes;
|
||||
using Avalonia.Markup.Xaml;
|
||||
|
||||
namespace PhotoboothUploader;
|
||||
|
||||
public partial class App : Application
|
||||
{
|
||||
public override void Initialize()
|
||||
{
|
||||
AvaloniaXamlLoader.Load(this);
|
||||
}
|
||||
|
||||
public override void OnFrameworkInitializationCompleted()
|
||||
{
|
||||
if (ApplicationLifetime is IClassicDesktopStyleApplicationLifetime desktop)
|
||||
{
|
||||
desktop.MainWindow = new MainWindow();
|
||||
}
|
||||
|
||||
base.OnFrameworkInitializationCompleted();
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
using System;
|
||||
using System.ComponentModel;
|
||||
using System.Runtime.CompilerServices;
|
||||
|
||||
namespace PhotoboothUploader.Models;
|
||||
|
||||
public enum UploadStatus
|
||||
{
|
||||
Queued,
|
||||
Uploading,
|
||||
Success,
|
||||
Failed,
|
||||
}
|
||||
|
||||
public sealed class UploadItem : INotifyPropertyChanged
|
||||
{
|
||||
private UploadStatus _status;
|
||||
private DateTimeOffset _updatedAt;
|
||||
|
||||
public UploadItem(string path)
|
||||
{
|
||||
Path = path;
|
||||
FileName = System.IO.Path.GetFileName(path);
|
||||
UpdatedAt = DateTimeOffset.Now;
|
||||
Status = UploadStatus.Queued;
|
||||
}
|
||||
|
||||
public string Path { get; }
|
||||
|
||||
public string FileName { get; }
|
||||
|
||||
public UploadStatus Status
|
||||
{
|
||||
get => _status;
|
||||
set
|
||||
{
|
||||
if (_status != value)
|
||||
{
|
||||
_status = value;
|
||||
UpdatedAt = DateTimeOffset.Now;
|
||||
OnPropertyChanged();
|
||||
OnPropertyChanged(nameof(StatusLabel));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public DateTimeOffset UpdatedAt
|
||||
{
|
||||
get => _updatedAt;
|
||||
private set
|
||||
{
|
||||
_updatedAt = value;
|
||||
OnPropertyChanged();
|
||||
OnPropertyChanged(nameof(UpdatedLabel));
|
||||
}
|
||||
}
|
||||
|
||||
public string StatusLabel => Status switch
|
||||
{
|
||||
UploadStatus.Uploading => "Upload läuft",
|
||||
UploadStatus.Success => "Fertig",
|
||||
UploadStatus.Failed => "Fehlgeschlagen",
|
||||
_ => "Wartet",
|
||||
};
|
||||
|
||||
public string UpdatedLabel => $"{UpdatedAt:HH:mm}";
|
||||
|
||||
public event PropertyChangedEventHandler? PropertyChanged;
|
||||
|
||||
private void OnPropertyChanged([CallerMemberName] string? propertyName = null)
|
||||
{
|
||||
PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(propertyName));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,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>
|
||||
21
clients/photobooth-uploader/PhotoboothUploader/Program.cs
Normal file
21
clients/photobooth-uploader/PhotoboothUploader/Program.cs
Normal file
@@ -0,0 +1,21 @@
|
||||
using Avalonia;
|
||||
using System;
|
||||
|
||||
namespace PhotoboothUploader;
|
||||
|
||||
class Program
|
||||
{
|
||||
// Initialization code. Don't use any Avalonia, third-party APIs or any
|
||||
// SynchronizationContext-reliant code before AppMain is called: things aren't initialized
|
||||
// yet and stuff might break.
|
||||
[STAThread]
|
||||
public static void Main(string[] args) => BuildAvaloniaApp()
|
||||
.StartWithClassicDesktopLifetime(args);
|
||||
|
||||
// Avalonia configuration, don't remove; also used by visual designer.
|
||||
public static AppBuilder BuildAvaloniaApp()
|
||||
=> AppBuilder.Configure<App>()
|
||||
.UsePlatformDetect()
|
||||
.WithInterFont()
|
||||
.LogToTrace();
|
||||
}
|
||||
@@ -0,0 +1,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;
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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",
|
||||
};
|
||||
}
|
||||
}
|
||||
18
clients/photobooth-uploader/PhotoboothUploader/app.manifest
Normal file
18
clients/photobooth-uploader/PhotoboothUploader/app.manifest
Normal file
@@ -0,0 +1,18 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<assembly manifestVersion="1.0" xmlns="urn:schemas-microsoft-com:asm.v1">
|
||||
<!-- This manifest is used on Windows only.
|
||||
Don't remove it as it might cause problems with window transparency and embedded controls.
|
||||
For more details visit https://learn.microsoft.com/en-us/windows/win32/sbscs/application-manifests -->
|
||||
<assemblyIdentity version="1.0.0.0" name="PhotoboothUploader.Desktop"/>
|
||||
|
||||
<compatibility xmlns="urn:schemas-microsoft-com:compatibility.v1">
|
||||
<application>
|
||||
<!-- A list of the Windows versions that this application has been tested on
|
||||
and is designed to work with. Uncomment the appropriate elements
|
||||
and Windows will automatically select the most compatible environment. -->
|
||||
|
||||
<!-- Windows 10 -->
|
||||
<supportedOS Id="{8e0f7a12-bfb3-4fe8-b9a5-48fd50a15a9a}" />
|
||||
</application>
|
||||
</compatibility>
|
||||
</assembly>
|
||||
@@ -34,4 +34,8 @@ return [
|
||||
'rate_limit_per_minute' => (int) env('SPARKBOOTH_RATE_LIMIT_PER_MINUTE', env('PHOTOBOOTH_RATE_LIMIT_PER_MINUTE', 20)),
|
||||
'response_format' => env('SPARKBOOTH_RESPONSE_FORMAT', 'json'),
|
||||
],
|
||||
'connect_code' => [
|
||||
'length' => (int) env('PHOTOBOOTH_CONNECT_CODE_LENGTH', 6),
|
||||
'expires_minutes' => (int) env('PHOTOBOOTH_CONNECT_CODE_EXPIRES_MINUTES', 10),
|
||||
],
|
||||
];
|
||||
|
||||
29
database/factories/PhotoboothConnectCodeFactory.php
Normal file
29
database/factories/PhotoboothConnectCodeFactory.php
Normal file
@@ -0,0 +1,29 @@
|
||||
<?php
|
||||
|
||||
namespace Database\Factories;
|
||||
|
||||
use App\Models\Event;
|
||||
use Illuminate\Database\Eloquent\Factories\Factory;
|
||||
|
||||
/**
|
||||
* @extends \Illuminate\Database\Eloquent\Factories\Factory<\App\Models\PhotoboothConnectCode>
|
||||
*/
|
||||
class PhotoboothConnectCodeFactory extends Factory
|
||||
{
|
||||
/**
|
||||
* Define the model's default state.
|
||||
*
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
public function definition(): array
|
||||
{
|
||||
$rawCode = str_pad((string) $this->faker->numberBetween(0, 999999), 6, '0', STR_PAD_LEFT);
|
||||
|
||||
return [
|
||||
'event_id' => Event::factory(),
|
||||
'code_hash' => hash('sha256', $rawCode),
|
||||
'expires_at' => now()->addMinutes(10),
|
||||
'redeemed_at' => null,
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -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');
|
||||
}
|
||||
};
|
||||
16
database/seeders/PhotoboothConnectCodeSeeder.php
Normal file
16
database/seeders/PhotoboothConnectCodeSeeder.php
Normal 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
|
||||
{
|
||||
//
|
||||
}
|
||||
}
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 118 KiB |
@@ -6,6 +6,7 @@ use App\Http\Controllers\Api\LegalController;
|
||||
use App\Http\Controllers\Api\LiveShowController;
|
||||
use App\Http\Controllers\Api\Marketing\CouponPreviewController;
|
||||
use App\Http\Controllers\Api\PackageController;
|
||||
use App\Http\Controllers\Api\PhotoboothConnectController;
|
||||
use App\Http\Controllers\Api\SparkboothUploadController;
|
||||
use App\Http\Controllers\Api\Tenant\AdminPushSubscriptionController;
|
||||
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\NotificationLogController;
|
||||
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\PhotoController;
|
||||
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'])
|
||||
->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'])
|
||||
->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('/rotate', [PhotoboothController::class, 'rotate'])->name('tenant.events.photobooth.rotate');
|
||||
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'])
|
||||
|
||||
100
tests/Feature/Photobooth/PhotoboothConnectCodeTest.php
Normal file
100
tests/Feature/Photobooth/PhotoboothConnectCodeTest.php
Normal 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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user