From 5e5b69f65564901615a4ec6f6fbe0d89ad39fb41 Mon Sep 17 00:00:00 2001 From: Codex Agent Date: Tue, 20 Jan 2026 15:49:04 +0100 Subject: [PATCH] Add control room automations and uploader overrides --- .../Controllers/Api/EventPublicController.php | 56 ++- .../Api/Tenant/PhotoController.php | 11 + .../Requests/Tenant/EventStoreRequest.php | 10 + app/Http/Resources/Tenant/PhotoResource.php | 1 + app/Jobs/ProcessPhotoSecurityScan.php | 4 +- resources/js/admin/api.ts | 17 + .../js/admin/i18n/locales/de/management.json | 28 ++ .../js/admin/i18n/locales/en/management.json | 28 ++ .../js/admin/mobile/EventControlRoomPage.tsx | 468 +++++++++++++++++- .../__tests__/EventControlRoomPage.test.tsx | 13 +- tests/Feature/GuestJoinTokenFlowTest.php | 135 ++++- 11 files changed, 738 insertions(+), 33 deletions(-) diff --git a/app/Http/Controllers/Api/EventPublicController.php b/app/Http/Controllers/Api/EventPublicController.php index 690a423..22b74cb 100644 --- a/app/Http/Controllers/Api/EventPublicController.php +++ b/app/Http/Controllers/Api/EventPublicController.php @@ -2921,6 +2921,12 @@ class EventPublicController extends BaseController $policy = $this->guestPolicy(); $uploadVisibility = Arr::get($eventModel->settings ?? [], 'guest_upload_visibility', $policy->guest_upload_visibility); $autoApproveUploads = $uploadVisibility === 'immediate'; + $controlRoom = Arr::get($eventModel->settings ?? [], 'control_room', []); + $controlRoom = is_array($controlRoom) ? $controlRoom : []; + $autoAddApprovedToLiveSetting = (bool) Arr::get($controlRoom, 'auto_add_approved_to_live', false); + $trustedUploaders = Arr::get($controlRoom, 'trusted_uploaders', []); + $forceReviewUploaders = Arr::get($controlRoom, 'force_review_uploaders', []); + $autoAddApprovedToLiveDefault = $autoAddApprovedToLiveSetting || $autoApproveUploads; $tenantModel = $eventModel->tenant; @@ -2953,6 +2959,34 @@ class EventPublicController extends BaseController ->resolveEventPackageForPhotoUpload($tenantModel, $eventId, $eventModel); $deviceId = $this->resolveDeviceIdentifier($request); + $deviceHasRule = static function (array $entries, string $deviceId): bool { + foreach ($entries as $entry) { + if (! is_array($entry)) { + continue; + } + $candidate = $entry['device_id'] ?? null; + if (is_string($candidate) && $candidate === $deviceId) { + return true; + } + } + + return false; + }; + $deviceHasRules = $deviceId !== 'anonymous'; + $isForceReviewUploader = $deviceHasRules && is_array($forceReviewUploaders) + ? $deviceHasRule($forceReviewUploaders, $deviceId) + : false; + $isTrustedUploader = $deviceHasRules && is_array($trustedUploaders) + ? $deviceHasRule($trustedUploaders, $deviceId) + : false; + + if ($isForceReviewUploader) { + $autoApproveUploads = false; + } elseif ($isTrustedUploader) { + $autoApproveUploads = true; + } + + $autoAddApprovedToLive = $autoAddApprovedToLiveDefault && $autoApproveUploads; $deviceLimit = max(0, (int) ($policy->per_device_upload_limit ?? 50)); $deviceCount = DB::table('photos')->where('event_id', $eventId)->where('guest_name', $deviceId)->count(); @@ -3037,10 +3071,21 @@ class EventPublicController extends BaseController $liveApprovedAt = null; $liveReviewedAt = null; $liveStatus = PhotoLiveStatus::NONE->value; + $securityMeta = $isForceReviewUploader + ? [ + 'manual_review' => true, + 'manual_review_reason' => 'force_review_device', + ] + : null; + $securityMetaValue = $securityMeta ? json_encode($securityMeta) : null; - if ($liveOptIn) { + if ($liveOptIn || $autoAddApprovedToLive) { $liveSubmittedAt = now(); - if ($liveModerationMode === 'off') { + if ($autoAddApprovedToLive) { + $liveStatus = PhotoLiveStatus::APPROVED->value; + $liveApprovedAt = $liveSubmittedAt; + $liveReviewedAt = $liveSubmittedAt; + } elseif ($liveModerationMode === 'off') { $liveStatus = PhotoLiveStatus::APPROVED->value; $liveApprovedAt = $liveSubmittedAt; $liveReviewedAt = $liveSubmittedAt; @@ -3048,6 +3093,12 @@ class EventPublicController extends BaseController $liveStatus = PhotoLiveStatus::PENDING->value; } } + if ($isForceReviewUploader) { + $liveStatus = PhotoLiveStatus::REJECTED->value; + $liveSubmittedAt = null; + $liveApprovedAt = null; + $liveReviewedAt = now(); + } $photoId = DB::table('photos')->insertGetId([ 'event_id' => $eventId, @@ -3071,6 +3122,7 @@ class EventPublicController extends BaseController 'emotion_id' => $this->resolveEmotionId($validated, $eventId), 'is_featured' => 0, 'metadata' => null, + 'security_meta' => $securityMetaValue, 'created_at' => now(), 'updated_at' => now(), ]); diff --git a/app/Http/Controllers/Api/Tenant/PhotoController.php b/app/Http/Controllers/Api/Tenant/PhotoController.php index 61e959e..4d979fc 100644 --- a/app/Http/Controllers/Api/Tenant/PhotoController.php +++ b/app/Http/Controllers/Api/Tenant/PhotoController.php @@ -20,6 +20,7 @@ use App\Support\WatermarkConfigResolver; use Illuminate\Http\JsonResponse; use Illuminate\Http\Request; use Illuminate\Http\Resources\Json\AnonymousResourceCollection; +use Illuminate\Support\Arr; use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\Log; use Illuminate\Support\Facades\Storage; @@ -131,6 +132,11 @@ class PhotoController extends Controller $photo->status = $validated['visible'] ? 'approved' : 'hidden'; $photo->save(); + + $autoRemoveLiveOnHide = (bool) Arr::get($event->settings ?? [], 'control_room.auto_remove_live_on_hide', false); + if ($autoRemoveLiveOnHide && ! $validated['visible']) { + $photo->rejectForLiveShow($request->user(), 'hidden'); + } $photo->load('event')->loadCount('likes'); return response()->json([ @@ -531,6 +537,11 @@ class PhotoController extends Controller $photo->update($validated); + $autoRemoveLiveOnHide = (bool) Arr::get($event->settings ?? [], 'control_room.auto_remove_live_on_hide', false); + if ($autoRemoveLiveOnHide && ($validated['status'] ?? null) === 'rejected') { + $photo->rejectForLiveShow($request->user()); + } + if ($validated['status'] ?? null === 'approved') { $photo->load('event')->loadCount('likes'); // Trigger event for new photo notification diff --git a/app/Http/Requests/Tenant/EventStoreRequest.php b/app/Http/Requests/Tenant/EventStoreRequest.php index 85c2208..d0410ae 100644 --- a/app/Http/Requests/Tenant/EventStoreRequest.php +++ b/app/Http/Requests/Tenant/EventStoreRequest.php @@ -73,6 +73,16 @@ class EventStoreRequest extends FormRequest ])], 'settings.live_show.effect_intensity' => ['nullable', 'integer', 'min:0', 'max:100'], 'settings.live_show.background_mode' => ['nullable', Rule::in(['blur_last', 'gradient', 'solid', 'brand'])], + 'settings.control_room' => ['nullable', 'array'], + 'settings.control_room.auto_approve_highlights' => ['nullable', 'boolean'], + 'settings.control_room.auto_add_approved_to_live' => ['nullable', 'boolean'], + 'settings.control_room.auto_remove_live_on_hide' => ['nullable', 'boolean'], + 'settings.control_room.trusted_uploaders' => ['nullable', 'array'], + 'settings.control_room.trusted_uploaders.*.device_id' => ['required', 'string', 'max:120'], + 'settings.control_room.trusted_uploaders.*.label' => ['nullable', 'string', 'max:80'], + 'settings.control_room.force_review_uploaders' => ['nullable', 'array'], + 'settings.control_room.force_review_uploaders.*.device_id' => ['required', 'string', 'max:120'], + 'settings.control_room.force_review_uploaders.*.label' => ['nullable', 'string', 'max:80'], 'settings.watermark' => ['nullable', 'array'], 'settings.watermark.mode' => ['nullable', Rule::in(['base', 'custom', 'off'])], 'settings.watermark.asset' => ['nullable', 'string', 'max:500'], diff --git a/app/Http/Resources/Tenant/PhotoResource.php b/app/Http/Resources/Tenant/PhotoResource.php index afeeb8f..ddcb75b 100644 --- a/app/Http/Resources/Tenant/PhotoResource.php +++ b/app/Http/Resources/Tenant/PhotoResource.php @@ -41,6 +41,7 @@ class PhotoResource extends JsonResource 'is_liked' => false, 'uploaded_at' => $this->created_at->toISOString(), 'uploader_name' => $this->guest_name ?? null, + 'created_by_device_id' => $showSensitive ? $this->created_by_device_id : null, 'ingest_source' => $this->ingest_source, 'event' => [ 'id' => $this->event->id, diff --git a/app/Jobs/ProcessPhotoSecurityScan.php b/app/Jobs/ProcessPhotoSecurityScan.php index f3c31b6..678e505 100644 --- a/app/Jobs/ProcessPhotoSecurityScan.php +++ b/app/Jobs/ProcessPhotoSecurityScan.php @@ -30,6 +30,7 @@ class ProcessPhotoSecurityScan implements ShouldQueue if (! $photo) { Log::warning('[PhotoSecurity] Skipping missing photo', ['photo_id' => $this->photoId]); + return; } @@ -66,6 +67,7 @@ class ProcessPhotoSecurityScan implements ShouldQueue } $existingMeta = $photo->security_meta ?? []; + $requiresManualReview = is_array($existingMeta) && ($existingMeta['manual_review'] ?? false); $update = [ 'security_scan_status' => $status, @@ -74,7 +76,7 @@ class ProcessPhotoSecurityScan implements ShouldQueue 'security_meta' => array_merge(is_array($existingMeta) ? $existingMeta : [], $metadata), ]; - if (in_array($status, ['clean', 'skipped'], true) && $photo->status === 'pending') { + if (in_array($status, ['clean', 'skipped'], true) && $photo->status === 'pending' && ! $requiresManualReview) { $update['status'] = 'approved'; } diff --git a/resources/js/admin/api.ts b/resources/js/admin/api.ts index bf386ca..a3a7194 100644 --- a/resources/js/admin/api.ts +++ b/resources/js/admin/api.ts @@ -73,6 +73,19 @@ export type LiveShowSettings = { background_mode?: 'blur_last' | 'gradient' | 'solid' | 'brand'; }; +export type ControlRoomUploaderRule = { + device_id: string; + label?: string | null; +}; + +export type ControlRoomSettings = { + auto_approve_highlights?: boolean; + auto_add_approved_to_live?: boolean; + auto_remove_live_on_hide?: boolean; + trusted_uploaders?: ControlRoomUploaderRule[]; + force_review_uploaders?: ControlRoomUploaderRule[]; +}; + export type LiveShowLink = { token: string; url: string; @@ -101,6 +114,7 @@ export type TenantEvent = { engagement_mode?: 'tasks' | 'photo_only'; guest_upload_visibility?: 'review' | 'immediate'; live_show?: LiveShowSettings; + control_room?: ControlRoomSettings; watermark?: WatermarkSettings; watermark_allowed?: boolean | null; watermark_removal_allowed?: boolean | null; @@ -164,6 +178,7 @@ export type TenantPhoto = { likes_count: number; uploaded_at: string; uploader_name: string | null; + created_by_device_id?: string | null; ingest_source?: string | null; caption?: string | null; }; @@ -809,6 +824,7 @@ type EventSavePayload = { accepted_waiver?: boolean; settings?: Record & { live_show?: LiveShowSettings; + control_room?: ControlRoomSettings; watermark?: WatermarkSettings; watermark_serve_originals?: boolean | null; }; @@ -1022,6 +1038,7 @@ function normalizePhoto(photo: TenantPhoto): TenantPhoto { likes_count: Number(photo.likes_count ?? 0), uploaded_at: photo.uploaded_at, uploader_name: photo.uploader_name ?? null, + created_by_device_id: photo.created_by_device_id ?? null, caption: photo.caption ?? null, }; } diff --git a/resources/js/admin/i18n/locales/de/management.json b/resources/js/admin/i18n/locales/de/management.json index 5b1af7a..7db4ac4 100644 --- a/resources/js/admin/i18n/locales/de/management.json +++ b/resources/js/admin/i18n/locales/de/management.json @@ -2482,6 +2482,34 @@ "moderation": "Moderation", "live": "Live-Show" }, + "automation": { + "title": "Automatik", + "autoApproveHighlights": { + "label": "Highlights automatisch freigeben", + "help": "Wenn du ein ausstehendes Foto highlightest, wird es automatisch freigegeben." + }, + "autoAddApproved": { + "label": "Freigegebene automatisch in die Live-Show", + "help": "Freigegebene oder gehighlightete Fotos gehen direkt in die Live-Show.", + "locked": "Aktiv, weil Uploads sofort sichtbar sind." + }, + "autoRemoveLive": { + "label": "Aus der Live-Show entfernen", + "help": "Versteckte oder abgelehnte Fotos werden automatisch aus der Live-Show entfernt." + }, + "uploaders": { + "title": "Uploader-Ausnahmen", + "subtitle": "Wähle Geräte aus den letzten Uploads, die immer freigegeben oder immer geprüft werden sollen.", + "trustedLabel": "Immer freigeben", + "trustedHelp": "Uploads von diesen Geräten überspringen die Prüfung.", + "forceLabel": "Immer prüfen", + "forceHelp": "Uploads von diesen Geräten müssen immer geprüft werden.", + "add": "Hinzufügen", + "remove": "Entfernen", + "empty": "Noch keine Ausnahmen." + }, + "saveFailed": "Automatik-Einstellungen konnten nicht gespeichert werden." + }, "emptyModeration": "Keine Uploads passen zu diesem Filter.", "emptyLive": "Keine Fotos für die Live-Show in der Warteschlange." }, diff --git a/resources/js/admin/i18n/locales/en/management.json b/resources/js/admin/i18n/locales/en/management.json index 5d565c3..bb4014d 100644 --- a/resources/js/admin/i18n/locales/en/management.json +++ b/resources/js/admin/i18n/locales/en/management.json @@ -2484,6 +2484,34 @@ "moderation": "Moderation", "live": "Live Show" }, + "automation": { + "title": "Automation", + "autoApproveHighlights": { + "label": "Auto-approve highlights", + "help": "When you highlight a pending photo it will be approved automatically." + }, + "autoAddApproved": { + "label": "Auto-add approved to Live Show", + "help": "Approved or highlighted photos go straight to the Live Show.", + "locked": "Enabled because uploads are visible immediately." + }, + "autoRemoveLive": { + "label": "Auto-remove from Live Show", + "help": "Hidden or rejected photos are removed from the Live Show automatically." + }, + "uploaders": { + "title": "Uploader overrides", + "subtitle": "Pick devices from recent uploads to always approve or always review their photos.", + "trustedLabel": "Always approve", + "trustedHelp": "Uploads from these devices skip the approval queue.", + "forceLabel": "Always review", + "forceHelp": "Uploads from these devices always need approval.", + "add": "Add", + "remove": "Remove", + "empty": "No overrides yet." + }, + "saveFailed": "Automation settings could not be saved." + }, "emptyModeration": "No uploads match this filter.", "emptyLive": "No photos waiting for Live Show." }, diff --git a/resources/js/admin/mobile/EventControlRoomPage.tsx b/resources/js/admin/mobile/EventControlRoomPage.tsx index 0a313f0..c3accdc 100644 --- a/resources/js/admin/mobile/EventControlRoomPage.tsx +++ b/resources/js/admin/mobile/EventControlRoomPage.tsx @@ -5,6 +5,7 @@ import { Check, Eye, EyeOff, Image as ImageIcon, RefreshCcw, Settings, Sparkles import { YStack, XStack } from '@tamagui/stacks'; import { SizableText as Text } from '@tamagui/text'; import { Pressable } from '@tamagui/react-native-web-lite'; +import { Switch } from '@tamagui/switch'; import { MobileShell, HeaderActionButton } from './components/MobileShell'; import { MobileCard, CTAButton, SkeletonCard } from './components/Primitives'; import { MobileField, MobileSelect } from './components/FormControls'; @@ -13,6 +14,8 @@ import { approveAndLiveShowPhoto, approveLiveShowPhoto, clearLiveShowPhoto, + ControlRoomSettings, + ControlRoomUploaderRule, createEventAddonCheckout, EventAddonCatalogItem, EventLimitSummary, @@ -27,6 +30,7 @@ import { TenantPhoto, unfeaturePhoto, updatePhotoStatus, + updateEvent, updatePhotoVisibility, } from '../api'; import { isAuthError } from '../auth/tokens'; @@ -252,17 +256,27 @@ function PhotoStatusTag({ label }: { label: string }) { ); } +function formatDeviceId(deviceId: string): string { + if (!deviceId) { + return ''; + } + if (deviceId.length <= 10) { + return deviceId; + } + return `${deviceId.slice(0, 4)}...${deviceId.slice(-4)}`; +} + export default function MobileEventControlRoomPage() { const { slug: slugParam } = useParams<{ slug?: string }>(); const navigate = useNavigate(); const location = useLocation(); const { t } = useTranslation('management'); - const { activeEvent, selectEvent } = useEventContext(); + const { activeEvent, selectEvent, refetch } = useEventContext(); const { user } = useAuth(); const isMember = user?.role === 'member'; const slug = slugParam ?? activeEvent?.slug ?? null; const online = useOnlineStatus(); - const { textStrong, text, muted, border, accentSoft, accent, danger, primary } = useAdminTheme(); + const { textStrong, text, muted, border, accentSoft, accent, danger, primary, surfaceMuted } = useAdminTheme(); const [activeTab, setActiveTab] = React.useState<'moderation' | 'live'>('moderation'); const [moderationPhotos, setModerationPhotos] = React.useState([]); @@ -287,6 +301,23 @@ export default function MobileEventControlRoomPage() { const [liveError, setLiveError] = React.useState(null); const [liveBusyId, setLiveBusyId] = React.useState(null); + const [controlRoomSettings, setControlRoomSettings] = React.useState({ + auto_approve_highlights: false, + auto_add_approved_to_live: false, + auto_remove_live_on_hide: false, + trusted_uploaders: [], + force_review_uploaders: [], + }); + const [controlRoomSaving, setControlRoomSaving] = React.useState(false); + const [trustedUploaderSelection, setTrustedUploaderSelection] = React.useState(''); + const [forceReviewSelection, setForceReviewSelection] = React.useState(''); + const isImmediateUploads = (activeEvent?.settings?.guest_upload_visibility ?? 'review') === 'immediate'; + const autoApproveHighlights = Boolean(controlRoomSettings.auto_approve_highlights); + const autoAddApprovedToLive = Boolean(controlRoomSettings.auto_add_approved_to_live) || isImmediateUploads; + const autoRemoveLiveOnHide = Boolean(controlRoomSettings.auto_remove_live_on_hide); + const trustedUploaders = controlRoomSettings.trusted_uploaders ?? []; + const forceReviewUploaders = controlRoomSettings.force_review_uploaders ?? []; + const [queuedActions, setQueuedActions] = React.useState(() => loadPhotoQueue()); const [syncingQueue, setSyncingQueue] = React.useState(false); const syncingQueueRef = React.useRef(false); @@ -297,12 +328,152 @@ export default function MobileEventControlRoomPage() { const infoBg = accentSoft; const infoBorder = accent; + const saveControlRoomSettings = React.useCallback( + async (nextSettings: ControlRoomSettings) => { + if (!slug) { + return; + } + + const previous = controlRoomSettings; + setControlRoomSettings(nextSettings); + setControlRoomSaving(true); + + try { + await updateEvent(slug, { + settings: { + control_room: nextSettings, + }, + }); + refetch(); + } catch (err) { + setControlRoomSettings(previous); + toast.error( + getApiErrorMessage( + err, + t('controlRoom.automation.saveFailed', 'Automation settings could not be saved.') + ) + ); + } finally { + setControlRoomSaving(false); + } + }, + [controlRoomSettings, refetch, slug, t], + ); + + const uploaderOptions = React.useMemo(() => { + const options = new Map(); + const addPhoto = (photo: TenantPhoto) => { + const deviceId = photo.created_by_device_id ?? ''; + if (!deviceId) { + return; + } + if (options.has(deviceId)) { + return; + } + options.set(deviceId, { + deviceId, + label: photo.uploader_name ?? t('common.anonymous', 'Anonymous'), + }); + }; + + moderationPhotos.forEach(addPhoto); + livePhotos.forEach(addPhoto); + + return Array.from(options.values()).map((entry) => ({ + ...entry, + display: `${entry.label} • ${formatDeviceId(entry.deviceId)}`, + })); + }, [livePhotos, moderationPhotos, t]); + + const mergeUploaderRule = (list: ControlRoomUploaderRule[], rule: ControlRoomUploaderRule) => { + if (list.some((entry) => entry.device_id === rule.device_id)) { + return list; + } + return [...list, rule]; + }; + + const addUploaderRule = React.useCallback( + (target: 'trusted' | 'force') => { + const selectedId = target === 'trusted' ? trustedUploaderSelection : forceReviewSelection; + if (!selectedId) { + return; + } + + const option = uploaderOptions.find((entry) => entry.deviceId === selectedId); + const rule: ControlRoomUploaderRule = { + device_id: selectedId, + label: option?.label ?? t('common.anonymous', 'Anonymous'), + }; + + const currentTrusted = controlRoomSettings.trusted_uploaders ?? []; + const currentForceReview = controlRoomSettings.force_review_uploaders ?? []; + + const nextTrusted = + target === 'trusted' + ? mergeUploaderRule(currentTrusted, rule) + : currentTrusted.filter((entry) => entry.device_id !== selectedId); + const nextForceReview = + target === 'force' + ? mergeUploaderRule(currentForceReview, rule) + : currentForceReview.filter((entry) => entry.device_id !== selectedId); + + saveControlRoomSettings({ + ...controlRoomSettings, + trusted_uploaders: nextTrusted, + force_review_uploaders: nextForceReview, + }); + setTrustedUploaderSelection(''); + setForceReviewSelection(''); + }, + [ + controlRoomSettings, + forceReviewSelection, + saveControlRoomSettings, + t, + trustedUploaderSelection, + uploaderOptions, + ], + ); + + const removeUploaderRule = React.useCallback( + (target: 'trusted' | 'force', deviceId: string) => { + const nextTrusted = + target === 'trusted' + ? (controlRoomSettings.trusted_uploaders ?? []).filter((entry) => entry.device_id !== deviceId) + : controlRoomSettings.trusted_uploaders ?? []; + const nextForceReview = + target === 'force' + ? (controlRoomSettings.force_review_uploaders ?? []).filter((entry) => entry.device_id !== deviceId) + : controlRoomSettings.force_review_uploaders ?? []; + + saveControlRoomSettings({ + ...controlRoomSettings, + trusted_uploaders: nextTrusted, + force_review_uploaders: nextForceReview, + }); + }, + [controlRoomSettings, saveControlRoomSettings], + ); + React.useEffect(() => { if (slugParam && activeEvent?.slug !== slugParam) { selectEvent(slugParam); } }, [slugParam, activeEvent?.slug, selectEvent]); + React.useEffect(() => { + const settings = (activeEvent?.settings?.control_room ?? {}) as ControlRoomSettings; + setControlRoomSettings({ + auto_approve_highlights: Boolean(settings.auto_approve_highlights), + auto_add_approved_to_live: Boolean(settings.auto_add_approved_to_live), + auto_remove_live_on_hide: Boolean(settings.auto_remove_live_on_hide), + trusted_uploaders: Array.isArray(settings.trusted_uploaders) ? settings.trusted_uploaders : [], + force_review_uploaders: Array.isArray(settings.force_review_uploaders) ? settings.force_review_uploaders : [], + }); + setTrustedUploaderSelection(''); + setForceReviewSelection(''); + }, [activeEvent?.settings?.control_room, activeEvent?.slug]); + const ensureSlug = React.useCallback(async () => { if (slug) { return slug; @@ -506,6 +677,23 @@ export default function MobileEventControlRoomPage() { [applyOptimisticUpdate, slug, t], ); + const enqueueModerationActions = React.useCallback( + (actions: PhotoModerationAction['action'][], photoId: number) => { + if (!slug) { + return; + } + let nextQueue: PhotoModerationAction[] = []; + actions.forEach((action) => { + nextQueue = enqueuePhotoAction({ eventSlug: slug, photoId, action }); + applyOptimisticUpdate(photoId, action); + }); + setQueuedActions(nextQueue); + toast.success(t('mobilePhotos.queued', 'Action saved. Syncs when you are back online.')); + triggerHaptic('selection'); + }, + [applyOptimisticUpdate, slug, t], + ); + const syncQueuedActions = React.useCallback(async () => { if (!online || syncingQueueRef.current) { return; @@ -566,7 +754,13 @@ export default function MobileEventControlRoomPage() { return; } + const shouldAutoApproveHighlight = action === 'feature' && autoApproveHighlights && photo.status === 'pending'; + if (!online) { + if (shouldAutoApproveHighlight) { + enqueueModerationActions(['approve', 'feature'], photo.id); + return; + } enqueueModerationAction(action, photo.id); return; } @@ -576,12 +770,21 @@ export default function MobileEventControlRoomPage() { try { let updated: TenantPhoto; if (action === 'approve') { - updated = await updatePhotoStatus(slug, photo.id, 'approved'); + updated = autoAddApprovedToLive + ? await approveAndLiveShowPhoto(slug, photo.id) + : await updatePhotoStatus(slug, photo.id, 'approved'); } else if (action === 'hide') { updated = await updatePhotoVisibility(slug, photo.id, true); } else if (action === 'show') { updated = await updatePhotoVisibility(slug, photo.id, false); } else if (action === 'feature') { + if (shouldAutoApproveHighlight) { + if (autoAddApprovedToLive) { + await approveAndLiveShowPhoto(slug, photo.id); + } else { + await updatePhotoStatus(slug, photo.id, 'approved'); + } + } updated = await featurePhoto(slug, photo.id); } else { updated = await unfeaturePhoto(slug, photo.id); @@ -605,7 +808,16 @@ export default function MobileEventControlRoomPage() { setModerationBusyId(null); } }, - [enqueueModerationAction, online, slug, t, updatePhotoInCollections], + [ + autoAddApprovedToLive, + autoApproveHighlights, + enqueueModerationAction, + enqueueModerationActions, + online, + slug, + t, + updatePhotoInCollections, + ], ); function resolveScopeAndAddonKey(scopeOrKey: 'photos' | 'gallery' | 'guests' | string) { @@ -805,6 +1017,254 @@ export default function MobileEventControlRoomPage() { ))} + + + + {t('controlRoom.automation.title', 'Automation')} + + + + saveControlRoomSettings({ + ...controlRoomSettings, + auto_approve_highlights: Boolean(checked), + }) + } + aria-label={t('controlRoom.automation.autoApproveHighlights.label', 'Auto-approve highlights')} + > + + + + + { + if (isImmediateUploads) { + return; + } + saveControlRoomSettings({ + ...controlRoomSettings, + auto_add_approved_to_live: Boolean(checked), + }); + }} + aria-label={t('controlRoom.automation.autoAddApproved.label', 'Auto-add approved to Live Show')} + > + + + + + + saveControlRoomSettings({ + ...controlRoomSettings, + auto_remove_live_on_hide: Boolean(checked), + }) + } + aria-label={t('controlRoom.automation.autoRemoveLive.label', 'Auto-remove from Live Show')} + > + + + + + + + + + + {t('controlRoom.automation.uploaders.title', 'Uploader overrides')} + + + {t( + 'controlRoom.automation.uploaders.subtitle', + 'Pick devices from recent uploads to always approve or always review their photos.', + )} + + + + + setTrustedUploaderSelection(event.target.value)} + style={{ flex: 1 }} + > + + {uploaderOptions.map((option) => ( + + ))} + + addUploaderRule('trusted')} + tone="ghost" + fullWidth={false} + disabled={controlRoomSaving || !trustedUploaderSelection} + style={{ width: 100 }} + /> + + {trustedUploaders.length ? ( + + {trustedUploaders.map((rule) => ( + + + + {rule.label ?? t('common.anonymous', 'Anonymous')} + + + {formatDeviceId(rule.device_id)} + + + removeUploaderRule('trusted', rule.device_id)} + disabled={controlRoomSaving} + aria-label={t('controlRoom.automation.uploaders.remove', 'Remove')} + style={{ + paddingHorizontal: 12, + paddingVertical: 6, + borderRadius: 999, + backgroundColor: withAlpha(danger, 0.12), + }} + > + + {t('controlRoom.automation.uploaders.remove', 'Remove')} + + + + ))} + + ) : ( + + {t('controlRoom.automation.uploaders.empty', 'No overrides yet.')} + + )} + + + + + setForceReviewSelection(event.target.value)} + style={{ flex: 1 }} + > + + {uploaderOptions.map((option) => ( + + ))} + + addUploaderRule('force')} + tone="ghost" + fullWidth={false} + disabled={controlRoomSaving || !forceReviewSelection} + style={{ width: 100 }} + /> + + {forceReviewUploaders.length ? ( + + {forceReviewUploaders.map((rule) => ( + + + + {rule.label ?? t('common.anonymous', 'Anonymous')} + + + {formatDeviceId(rule.device_id)} + + + removeUploaderRule('force', rule.device_id)} + disabled={controlRoomSaving} + aria-label={t('controlRoom.automation.uploaders.remove', 'Remove')} + style={{ + paddingHorizontal: 12, + paddingVertical: 6, + borderRadius: 999, + backgroundColor: withAlpha(danger, 0.12), + }} + > + + {t('controlRoom.automation.uploaders.remove', 'Remove')} + + + + ))} + + ) : ( + + {t('controlRoom.automation.uploaders.empty', 'No overrides yet.')} + + )} + + + + {activeTab === 'moderation' ? ( {queuedEventCount > 0 ? ( diff --git a/resources/js/admin/mobile/__tests__/EventControlRoomPage.test.tsx b/resources/js/admin/mobile/__tests__/EventControlRoomPage.test.tsx index bc83ce5..b5c80dd 100644 --- a/resources/js/admin/mobile/__tests__/EventControlRoomPage.test.tsx +++ b/resources/js/admin/mobile/__tests__/EventControlRoomPage.test.tsx @@ -4,6 +4,8 @@ import { render, screen } from '@testing-library/react'; const navigateMock = vi.fn(); const selectEventMock = vi.fn(); +const refetchMock = vi.fn(); +const activeEventMock = { slug: 'demo-event', settings: { guest_upload_visibility: 'review', control_room: {} } }; vi.mock('react-router-dom', () => ({ useNavigate: () => navigateMock, @@ -36,8 +38,9 @@ vi.mock('../../auth/context', () => ({ vi.mock('../../context/EventContext', () => ({ useEventContext: () => ({ - activeEvent: { slug: 'demo-event' }, + activeEvent: activeEventMock, selectEvent: selectEventMock, + refetch: refetchMock, }), })); @@ -98,6 +101,13 @@ vi.mock('@tamagui/text', () => ({ SizableText: ({ children }: { children: React.ReactNode }) => {children}, })); +vi.mock('@tamagui/switch', () => ({ + Switch: Object.assign( + ({ children }: { children: React.ReactNode }) =>
{children}
, + { Thumb: () =>
}, + ), +})); + vi.mock('@tamagui/react-native-web-lite', () => ({ Pressable: ({ children, @@ -143,6 +153,7 @@ vi.mock('../../api', () => ({ approveAndLiveShowPhoto: vi.fn(), approveLiveShowPhoto: vi.fn(), clearLiveShowPhoto: vi.fn(), + updateEvent: vi.fn(), createEventAddonCheckout: vi.fn(), featurePhoto: vi.fn(), getAddonCatalog: vi.fn().mockResolvedValue([]), diff --git a/tests/Feature/GuestJoinTokenFlowTest.php b/tests/Feature/GuestJoinTokenFlowTest.php index f037b9c..bae300d 100644 --- a/tests/Feature/GuestJoinTokenFlowTest.php +++ b/tests/Feature/GuestJoinTokenFlowTest.php @@ -41,6 +41,35 @@ class GuestJoinTokenFlowTest extends TestCase ]); } + private function seedGuestUploadPrerequisites(Event $event): void + { + $package = Package::factory()->endcustomer()->create([ + 'max_photos' => 100, + ]); + EventPackage::create([ + 'event_id' => $event->id, + 'package_id' => $package->id, + 'purchased_price' => $package->price, + 'purchased_at' => now(), + 'used_photos' => 0, + 'used_guests' => 0, + ]); + MediaStorageTarget::create([ + 'key' => 'public', + 'name' => 'Public', + 'driver' => 'local', + 'is_hot' => true, + 'is_default' => true, + 'is_active' => true, + ]); + + Mockery::mock('alias:App\Support\ImageHelper') + ->shouldReceive('makeThumbnailOnDisk') + ->andReturn("events/{$event->id}/photos/thumbs/generated_thumb.jpg") + ->shouldReceive('copyWithWatermark') + ->andReturnNull(); + } + public function test_guest_can_access_stats_using_join_token(): void { $event = $this->createPublishedEvent(); @@ -67,33 +96,9 @@ class GuestJoinTokenFlowTest extends TestCase Storage::fake('public'); $event = $this->createPublishedEvent(); - $package = Package::factory()->endcustomer()->create([ - 'max_photos' => 100, - ]); - EventPackage::create([ - 'event_id' => $event->id, - 'package_id' => $package->id, - 'purchased_price' => $package->price, - 'purchased_at' => now(), - 'used_photos' => 0, - 'used_guests' => 0, - ]); - MediaStorageTarget::create([ - 'key' => 'public', - 'name' => 'Public', - 'driver' => 'local', - 'is_hot' => true, - 'is_default' => true, - 'is_active' => true, - ]); + $this->seedGuestUploadPrerequisites($event); $token = $this->tokenService->createToken($event); - Mockery::mock('alias:App\Support\ImageHelper') - ->shouldReceive('makeThumbnailOnDisk') - ->andReturn("events/{$event->id}/photos/thumbs/generated_thumb.jpg") - ->shouldReceive('copyWithWatermark') - ->andReturnNull(); - $file = UploadedFile::fake()->image('example.jpg', 1200, 800); $response = $this->withHeader('X-Device-Id', 'token-device') @@ -125,6 +130,86 @@ class GuestJoinTokenFlowTest extends TestCase } } + public function test_force_review_uploader_overrides_immediate_visibility(): void + { + Storage::fake('public'); + + $event = $this->createPublishedEvent(); + $event->update([ + 'settings' => [ + 'guest_upload_visibility' => 'immediate', + 'control_room' => [ + 'auto_add_approved_to_live' => true, + 'force_review_uploaders' => [ + [ + 'device_id' => 'blocked-device', + 'label' => 'Blocked', + ], + ], + ], + ], + ]); + $this->seedGuestUploadPrerequisites($event); + $token = $this->tokenService->createToken($event); + + $file = UploadedFile::fake()->image('example.jpg', 1200, 800); + + $response = $this->withHeader('X-Device-Id', 'blocked-device') + ->postJson("/api/v1/events/{$token->token}/upload", [ + 'photo' => $file, + 'live_show_opt_in' => true, + ]); + + $response->assertCreated() + ->assertJsonPath('status', 'pending'); + + $photo = Photo::first(); + $this->assertNotNull($photo); + $this->assertSame('pending', $photo->status); + $this->assertSame(PhotoLiveStatus::REJECTED, $photo->live_status); + $this->assertSame('blocked-device', $photo->created_by_device_id); + } + + public function test_trusted_uploader_auto_approves_and_adds_to_live_show(): void + { + Storage::fake('public'); + + $event = $this->createPublishedEvent(); + $event->update([ + 'settings' => [ + 'guest_upload_visibility' => 'review', + 'control_room' => [ + 'auto_add_approved_to_live' => true, + 'trusted_uploaders' => [ + [ + 'device_id' => 'trusted-device', + 'label' => 'VIP', + ], + ], + ], + ], + ]); + $this->seedGuestUploadPrerequisites($event); + $token = $this->tokenService->createToken($event); + + $file = UploadedFile::fake()->image('example.jpg', 1200, 800); + + $response = $this->withHeader('X-Device-Id', 'trusted-device') + ->postJson("/api/v1/events/{$token->token}/upload", [ + 'photo' => $file, + ]); + + $response->assertCreated() + ->assertJsonPath('status', 'approved'); + + $photo = Photo::first(); + $this->assertNotNull($photo); + $this->assertSame('approved', $photo->status); + $this->assertSame(PhotoLiveStatus::APPROVED, $photo->live_status); + $this->assertNotNull($photo->live_submitted_at); + $this->assertNotNull($photo->live_approved_at); + } + public function test_guest_event_response_includes_demo_read_only_flag(): void { $event = $this->createPublishedEvent();