Add control room automations and uploader overrides
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (push) Has been cancelled
tests / ui (push) Has been cancelled

This commit is contained in:
Codex Agent
2026-01-20 15:49:04 +01:00
parent e5e74febbd
commit 5e5b69f655
11 changed files with 738 additions and 33 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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<string, unknown> & {
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,
};
}

View File

@@ -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."
},

View File

@@ -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."
},

View File

@@ -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<TenantPhoto[]>([]);
@@ -287,6 +301,23 @@ export default function MobileEventControlRoomPage() {
const [liveError, setLiveError] = React.useState<string | null>(null);
const [liveBusyId, setLiveBusyId] = React.useState<number | null>(null);
const [controlRoomSettings, setControlRoomSettings] = React.useState<ControlRoomSettings>({
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<PhotoModerationAction[]>(() => 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<string, { deviceId: string; label: string }>();
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() {
))}
</XStack>
<MobileCard>
<YStack space="$2">
<Text fontSize="$sm" fontWeight="800" color={text}>
{t('controlRoom.automation.title', 'Automation')}
</Text>
<MobileField
label={t('controlRoom.automation.autoApproveHighlights.label', 'Auto-approve highlights')}
hint={t(
'controlRoom.automation.autoApproveHighlights.help',
'When you highlight a pending photo it will be approved automatically.',
)}
>
<Switch
size="$3"
checked={autoApproveHighlights}
disabled={controlRoomSaving}
onCheckedChange={(checked) =>
saveControlRoomSettings({
...controlRoomSettings,
auto_approve_highlights: Boolean(checked),
})
}
aria-label={t('controlRoom.automation.autoApproveHighlights.label', 'Auto-approve highlights')}
>
<Switch.Thumb />
</Switch>
</MobileField>
<MobileField
label={t('controlRoom.automation.autoAddApproved.label', 'Auto-add approved to Live Show')}
hint={
isImmediateUploads
? t(
'controlRoom.automation.autoAddApproved.locked',
'Enabled because uploads are visible immediately.',
)
: t(
'controlRoom.automation.autoAddApproved.help',
'Approved or highlighted photos go straight to the Live Show.',
)
}
>
<Switch
size="$3"
checked={autoAddApprovedToLive}
disabled={controlRoomSaving || isImmediateUploads}
onCheckedChange={(checked) => {
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')}
>
<Switch.Thumb />
</Switch>
</MobileField>
<MobileField
label={t('controlRoom.automation.autoRemoveLive.label', 'Auto-remove from Live Show')}
hint={t(
'controlRoom.automation.autoRemoveLive.help',
'Hidden or rejected photos are removed from the Live Show automatically.',
)}
>
<Switch
size="$3"
checked={autoRemoveLiveOnHide}
disabled={controlRoomSaving}
onCheckedChange={(checked) =>
saveControlRoomSettings({
...controlRoomSettings,
auto_remove_live_on_hide: Boolean(checked),
})
}
aria-label={t('controlRoom.automation.autoRemoveLive.label', 'Auto-remove from Live Show')}
>
<Switch.Thumb />
</Switch>
</MobileField>
</YStack>
</MobileCard>
<MobileCard>
<YStack space="$2">
<Text fontSize="$sm" fontWeight="800" color={text}>
{t('controlRoom.automation.uploaders.title', 'Uploader overrides')}
</Text>
<Text fontSize="$xs" color={muted}>
{t(
'controlRoom.automation.uploaders.subtitle',
'Pick devices from recent uploads to always approve or always review their photos.',
)}
</Text>
<MobileField
label={t('controlRoom.automation.uploaders.trustedLabel', 'Always approve')}
hint={t(
'controlRoom.automation.uploaders.trustedHelp',
'Uploads from these devices skip the approval queue.',
)}
>
<XStack alignItems="center" space="$2">
<MobileSelect
value={trustedUploaderSelection}
onChange={(event) => setTrustedUploaderSelection(event.target.value)}
style={{ flex: 1 }}
>
<option value="">{t('common.select', 'Select')}</option>
{uploaderOptions.map((option) => (
<option key={option.deviceId} value={option.deviceId}>
{option.display}
</option>
))}
</MobileSelect>
<CTAButton
label={t('controlRoom.automation.uploaders.add', 'Add')}
onPress={() => addUploaderRule('trusted')}
tone="ghost"
fullWidth={false}
disabled={controlRoomSaving || !trustedUploaderSelection}
style={{ width: 100 }}
/>
</XStack>
{trustedUploaders.length ? (
<YStack space="$2">
{trustedUploaders.map((rule) => (
<XStack
key={`trusted-${rule.device_id}`}
alignItems="center"
justifyContent="space-between"
padding="$2"
borderRadius={14}
borderWidth={1}
borderColor={border}
backgroundColor={surfaceMuted}
>
<YStack space="$0.5">
<Text fontSize="$sm" fontWeight="700" color={text}>
{rule.label ?? t('common.anonymous', 'Anonymous')}
</Text>
<Text fontSize="$xs" color={muted}>
{formatDeviceId(rule.device_id)}
</Text>
</YStack>
<Pressable
onPress={() => 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),
}}
>
<Text fontSize="$xs" fontWeight="700" color={danger}>
{t('controlRoom.automation.uploaders.remove', 'Remove')}
</Text>
</Pressable>
</XStack>
))}
</YStack>
) : (
<Text fontSize="$xs" color={muted}>
{t('controlRoom.automation.uploaders.empty', 'No overrides yet.')}
</Text>
)}
</MobileField>
<MobileField
label={t('controlRoom.automation.uploaders.forceLabel', 'Always review')}
hint={t(
'controlRoom.automation.uploaders.forceHelp',
'Uploads from these devices always need approval.',
)}
>
<XStack alignItems="center" space="$2">
<MobileSelect
value={forceReviewSelection}
onChange={(event) => setForceReviewSelection(event.target.value)}
style={{ flex: 1 }}
>
<option value="">{t('common.select', 'Select')}</option>
{uploaderOptions.map((option) => (
<option key={option.deviceId} value={option.deviceId}>
{option.display}
</option>
))}
</MobileSelect>
<CTAButton
label={t('controlRoom.automation.uploaders.add', 'Add')}
onPress={() => addUploaderRule('force')}
tone="ghost"
fullWidth={false}
disabled={controlRoomSaving || !forceReviewSelection}
style={{ width: 100 }}
/>
</XStack>
{forceReviewUploaders.length ? (
<YStack space="$2">
{forceReviewUploaders.map((rule) => (
<XStack
key={`force-${rule.device_id}`}
alignItems="center"
justifyContent="space-between"
padding="$2"
borderRadius={14}
borderWidth={1}
borderColor={border}
backgroundColor={surfaceMuted}
>
<YStack space="$0.5">
<Text fontSize="$sm" fontWeight="700" color={text}>
{rule.label ?? t('common.anonymous', 'Anonymous')}
</Text>
<Text fontSize="$xs" color={muted}>
{formatDeviceId(rule.device_id)}
</Text>
</YStack>
<Pressable
onPress={() => 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),
}}
>
<Text fontSize="$xs" fontWeight="700" color={danger}>
{t('controlRoom.automation.uploaders.remove', 'Remove')}
</Text>
</Pressable>
</XStack>
))}
</YStack>
) : (
<Text fontSize="$xs" color={muted}>
{t('controlRoom.automation.uploaders.empty', 'No overrides yet.')}
</Text>
)}
</MobileField>
</YStack>
</MobileCard>
{activeTab === 'moderation' ? (
<YStack space="$2">
{queuedEventCount > 0 ? (

View File

@@ -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 }) => <span>{children}</span>,
}));
vi.mock('@tamagui/switch', () => ({
Switch: Object.assign(
({ children }: { children: React.ReactNode }) => <div>{children}</div>,
{ Thumb: () => <div /> },
),
}));
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([]),

View File

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