Add control room automations and uploader overrides
This commit is contained in:
@@ -2921,6 +2921,12 @@ class EventPublicController extends BaseController
|
|||||||
$policy = $this->guestPolicy();
|
$policy = $this->guestPolicy();
|
||||||
$uploadVisibility = Arr::get($eventModel->settings ?? [], 'guest_upload_visibility', $policy->guest_upload_visibility);
|
$uploadVisibility = Arr::get($eventModel->settings ?? [], 'guest_upload_visibility', $policy->guest_upload_visibility);
|
||||||
$autoApproveUploads = $uploadVisibility === 'immediate';
|
$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;
|
$tenantModel = $eventModel->tenant;
|
||||||
|
|
||||||
@@ -2953,6 +2959,34 @@ class EventPublicController extends BaseController
|
|||||||
->resolveEventPackageForPhotoUpload($tenantModel, $eventId, $eventModel);
|
->resolveEventPackageForPhotoUpload($tenantModel, $eventId, $eventModel);
|
||||||
|
|
||||||
$deviceId = $this->resolveDeviceIdentifier($request);
|
$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));
|
$deviceLimit = max(0, (int) ($policy->per_device_upload_limit ?? 50));
|
||||||
$deviceCount = DB::table('photos')->where('event_id', $eventId)->where('guest_name', $deviceId)->count();
|
$deviceCount = DB::table('photos')->where('event_id', $eventId)->where('guest_name', $deviceId)->count();
|
||||||
@@ -3037,10 +3071,21 @@ class EventPublicController extends BaseController
|
|||||||
$liveApprovedAt = null;
|
$liveApprovedAt = null;
|
||||||
$liveReviewedAt = null;
|
$liveReviewedAt = null;
|
||||||
$liveStatus = PhotoLiveStatus::NONE->value;
|
$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();
|
$liveSubmittedAt = now();
|
||||||
if ($liveModerationMode === 'off') {
|
if ($autoAddApprovedToLive) {
|
||||||
|
$liveStatus = PhotoLiveStatus::APPROVED->value;
|
||||||
|
$liveApprovedAt = $liveSubmittedAt;
|
||||||
|
$liveReviewedAt = $liveSubmittedAt;
|
||||||
|
} elseif ($liveModerationMode === 'off') {
|
||||||
$liveStatus = PhotoLiveStatus::APPROVED->value;
|
$liveStatus = PhotoLiveStatus::APPROVED->value;
|
||||||
$liveApprovedAt = $liveSubmittedAt;
|
$liveApprovedAt = $liveSubmittedAt;
|
||||||
$liveReviewedAt = $liveSubmittedAt;
|
$liveReviewedAt = $liveSubmittedAt;
|
||||||
@@ -3048,6 +3093,12 @@ class EventPublicController extends BaseController
|
|||||||
$liveStatus = PhotoLiveStatus::PENDING->value;
|
$liveStatus = PhotoLiveStatus::PENDING->value;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if ($isForceReviewUploader) {
|
||||||
|
$liveStatus = PhotoLiveStatus::REJECTED->value;
|
||||||
|
$liveSubmittedAt = null;
|
||||||
|
$liveApprovedAt = null;
|
||||||
|
$liveReviewedAt = now();
|
||||||
|
}
|
||||||
|
|
||||||
$photoId = DB::table('photos')->insertGetId([
|
$photoId = DB::table('photos')->insertGetId([
|
||||||
'event_id' => $eventId,
|
'event_id' => $eventId,
|
||||||
@@ -3071,6 +3122,7 @@ class EventPublicController extends BaseController
|
|||||||
'emotion_id' => $this->resolveEmotionId($validated, $eventId),
|
'emotion_id' => $this->resolveEmotionId($validated, $eventId),
|
||||||
'is_featured' => 0,
|
'is_featured' => 0,
|
||||||
'metadata' => null,
|
'metadata' => null,
|
||||||
|
'security_meta' => $securityMetaValue,
|
||||||
'created_at' => now(),
|
'created_at' => now(),
|
||||||
'updated_at' => now(),
|
'updated_at' => now(),
|
||||||
]);
|
]);
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ use App\Support\WatermarkConfigResolver;
|
|||||||
use Illuminate\Http\JsonResponse;
|
use Illuminate\Http\JsonResponse;
|
||||||
use Illuminate\Http\Request;
|
use Illuminate\Http\Request;
|
||||||
use Illuminate\Http\Resources\Json\AnonymousResourceCollection;
|
use Illuminate\Http\Resources\Json\AnonymousResourceCollection;
|
||||||
|
use Illuminate\Support\Arr;
|
||||||
use Illuminate\Support\Facades\DB;
|
use Illuminate\Support\Facades\DB;
|
||||||
use Illuminate\Support\Facades\Log;
|
use Illuminate\Support\Facades\Log;
|
||||||
use Illuminate\Support\Facades\Storage;
|
use Illuminate\Support\Facades\Storage;
|
||||||
@@ -131,6 +132,11 @@ class PhotoController extends Controller
|
|||||||
|
|
||||||
$photo->status = $validated['visible'] ? 'approved' : 'hidden';
|
$photo->status = $validated['visible'] ? 'approved' : 'hidden';
|
||||||
$photo->save();
|
$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');
|
$photo->load('event')->loadCount('likes');
|
||||||
|
|
||||||
return response()->json([
|
return response()->json([
|
||||||
@@ -531,6 +537,11 @@ class PhotoController extends Controller
|
|||||||
|
|
||||||
$photo->update($validated);
|
$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') {
|
if ($validated['status'] ?? null === 'approved') {
|
||||||
$photo->load('event')->loadCount('likes');
|
$photo->load('event')->loadCount('likes');
|
||||||
// Trigger event for new photo notification
|
// Trigger event for new photo notification
|
||||||
|
|||||||
@@ -73,6 +73,16 @@ class EventStoreRequest extends FormRequest
|
|||||||
])],
|
])],
|
||||||
'settings.live_show.effect_intensity' => ['nullable', 'integer', 'min:0', 'max:100'],
|
'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.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' => ['nullable', 'array'],
|
||||||
'settings.watermark.mode' => ['nullable', Rule::in(['base', 'custom', 'off'])],
|
'settings.watermark.mode' => ['nullable', Rule::in(['base', 'custom', 'off'])],
|
||||||
'settings.watermark.asset' => ['nullable', 'string', 'max:500'],
|
'settings.watermark.asset' => ['nullable', 'string', 'max:500'],
|
||||||
|
|||||||
@@ -41,6 +41,7 @@ class PhotoResource extends JsonResource
|
|||||||
'is_liked' => false,
|
'is_liked' => false,
|
||||||
'uploaded_at' => $this->created_at->toISOString(),
|
'uploaded_at' => $this->created_at->toISOString(),
|
||||||
'uploader_name' => $this->guest_name ?? null,
|
'uploader_name' => $this->guest_name ?? null,
|
||||||
|
'created_by_device_id' => $showSensitive ? $this->created_by_device_id : null,
|
||||||
'ingest_source' => $this->ingest_source,
|
'ingest_source' => $this->ingest_source,
|
||||||
'event' => [
|
'event' => [
|
||||||
'id' => $this->event->id,
|
'id' => $this->event->id,
|
||||||
|
|||||||
@@ -30,6 +30,7 @@ class ProcessPhotoSecurityScan implements ShouldQueue
|
|||||||
|
|
||||||
if (! $photo) {
|
if (! $photo) {
|
||||||
Log::warning('[PhotoSecurity] Skipping missing photo', ['photo_id' => $this->photoId]);
|
Log::warning('[PhotoSecurity] Skipping missing photo', ['photo_id' => $this->photoId]);
|
||||||
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -66,6 +67,7 @@ class ProcessPhotoSecurityScan implements ShouldQueue
|
|||||||
}
|
}
|
||||||
|
|
||||||
$existingMeta = $photo->security_meta ?? [];
|
$existingMeta = $photo->security_meta ?? [];
|
||||||
|
$requiresManualReview = is_array($existingMeta) && ($existingMeta['manual_review'] ?? false);
|
||||||
|
|
||||||
$update = [
|
$update = [
|
||||||
'security_scan_status' => $status,
|
'security_scan_status' => $status,
|
||||||
@@ -74,7 +76,7 @@ class ProcessPhotoSecurityScan implements ShouldQueue
|
|||||||
'security_meta' => array_merge(is_array($existingMeta) ? $existingMeta : [], $metadata),
|
'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';
|
$update['status'] = 'approved';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -73,6 +73,19 @@ export type LiveShowSettings = {
|
|||||||
background_mode?: 'blur_last' | 'gradient' | 'solid' | 'brand';
|
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 = {
|
export type LiveShowLink = {
|
||||||
token: string;
|
token: string;
|
||||||
url: string;
|
url: string;
|
||||||
@@ -101,6 +114,7 @@ export type TenantEvent = {
|
|||||||
engagement_mode?: 'tasks' | 'photo_only';
|
engagement_mode?: 'tasks' | 'photo_only';
|
||||||
guest_upload_visibility?: 'review' | 'immediate';
|
guest_upload_visibility?: 'review' | 'immediate';
|
||||||
live_show?: LiveShowSettings;
|
live_show?: LiveShowSettings;
|
||||||
|
control_room?: ControlRoomSettings;
|
||||||
watermark?: WatermarkSettings;
|
watermark?: WatermarkSettings;
|
||||||
watermark_allowed?: boolean | null;
|
watermark_allowed?: boolean | null;
|
||||||
watermark_removal_allowed?: boolean | null;
|
watermark_removal_allowed?: boolean | null;
|
||||||
@@ -164,6 +178,7 @@ export type TenantPhoto = {
|
|||||||
likes_count: number;
|
likes_count: number;
|
||||||
uploaded_at: string;
|
uploaded_at: string;
|
||||||
uploader_name: string | null;
|
uploader_name: string | null;
|
||||||
|
created_by_device_id?: string | null;
|
||||||
ingest_source?: string | null;
|
ingest_source?: string | null;
|
||||||
caption?: string | null;
|
caption?: string | null;
|
||||||
};
|
};
|
||||||
@@ -809,6 +824,7 @@ type EventSavePayload = {
|
|||||||
accepted_waiver?: boolean;
|
accepted_waiver?: boolean;
|
||||||
settings?: Record<string, unknown> & {
|
settings?: Record<string, unknown> & {
|
||||||
live_show?: LiveShowSettings;
|
live_show?: LiveShowSettings;
|
||||||
|
control_room?: ControlRoomSettings;
|
||||||
watermark?: WatermarkSettings;
|
watermark?: WatermarkSettings;
|
||||||
watermark_serve_originals?: boolean | null;
|
watermark_serve_originals?: boolean | null;
|
||||||
};
|
};
|
||||||
@@ -1022,6 +1038,7 @@ function normalizePhoto(photo: TenantPhoto): TenantPhoto {
|
|||||||
likes_count: Number(photo.likes_count ?? 0),
|
likes_count: Number(photo.likes_count ?? 0),
|
||||||
uploaded_at: photo.uploaded_at,
|
uploaded_at: photo.uploaded_at,
|
||||||
uploader_name: photo.uploader_name ?? null,
|
uploader_name: photo.uploader_name ?? null,
|
||||||
|
created_by_device_id: photo.created_by_device_id ?? null,
|
||||||
caption: photo.caption ?? null,
|
caption: photo.caption ?? null,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2482,6 +2482,34 @@
|
|||||||
"moderation": "Moderation",
|
"moderation": "Moderation",
|
||||||
"live": "Live-Show"
|
"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.",
|
"emptyModeration": "Keine Uploads passen zu diesem Filter.",
|
||||||
"emptyLive": "Keine Fotos für die Live-Show in der Warteschlange."
|
"emptyLive": "Keine Fotos für die Live-Show in der Warteschlange."
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -2484,6 +2484,34 @@
|
|||||||
"moderation": "Moderation",
|
"moderation": "Moderation",
|
||||||
"live": "Live Show"
|
"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.",
|
"emptyModeration": "No uploads match this filter.",
|
||||||
"emptyLive": "No photos waiting for Live Show."
|
"emptyLive": "No photos waiting for Live Show."
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import { Check, Eye, EyeOff, Image as ImageIcon, RefreshCcw, Settings, Sparkles
|
|||||||
import { YStack, XStack } from '@tamagui/stacks';
|
import { YStack, XStack } from '@tamagui/stacks';
|
||||||
import { SizableText as Text } from '@tamagui/text';
|
import { SizableText as Text } from '@tamagui/text';
|
||||||
import { Pressable } from '@tamagui/react-native-web-lite';
|
import { Pressable } from '@tamagui/react-native-web-lite';
|
||||||
|
import { Switch } from '@tamagui/switch';
|
||||||
import { MobileShell, HeaderActionButton } from './components/MobileShell';
|
import { MobileShell, HeaderActionButton } from './components/MobileShell';
|
||||||
import { MobileCard, CTAButton, SkeletonCard } from './components/Primitives';
|
import { MobileCard, CTAButton, SkeletonCard } from './components/Primitives';
|
||||||
import { MobileField, MobileSelect } from './components/FormControls';
|
import { MobileField, MobileSelect } from './components/FormControls';
|
||||||
@@ -13,6 +14,8 @@ import {
|
|||||||
approveAndLiveShowPhoto,
|
approveAndLiveShowPhoto,
|
||||||
approveLiveShowPhoto,
|
approveLiveShowPhoto,
|
||||||
clearLiveShowPhoto,
|
clearLiveShowPhoto,
|
||||||
|
ControlRoomSettings,
|
||||||
|
ControlRoomUploaderRule,
|
||||||
createEventAddonCheckout,
|
createEventAddonCheckout,
|
||||||
EventAddonCatalogItem,
|
EventAddonCatalogItem,
|
||||||
EventLimitSummary,
|
EventLimitSummary,
|
||||||
@@ -27,6 +30,7 @@ import {
|
|||||||
TenantPhoto,
|
TenantPhoto,
|
||||||
unfeaturePhoto,
|
unfeaturePhoto,
|
||||||
updatePhotoStatus,
|
updatePhotoStatus,
|
||||||
|
updateEvent,
|
||||||
updatePhotoVisibility,
|
updatePhotoVisibility,
|
||||||
} from '../api';
|
} from '../api';
|
||||||
import { isAuthError } from '../auth/tokens';
|
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() {
|
export default function MobileEventControlRoomPage() {
|
||||||
const { slug: slugParam } = useParams<{ slug?: string }>();
|
const { slug: slugParam } = useParams<{ slug?: string }>();
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const location = useLocation();
|
const location = useLocation();
|
||||||
const { t } = useTranslation('management');
|
const { t } = useTranslation('management');
|
||||||
const { activeEvent, selectEvent } = useEventContext();
|
const { activeEvent, selectEvent, refetch } = useEventContext();
|
||||||
const { user } = useAuth();
|
const { user } = useAuth();
|
||||||
const isMember = user?.role === 'member';
|
const isMember = user?.role === 'member';
|
||||||
const slug = slugParam ?? activeEvent?.slug ?? null;
|
const slug = slugParam ?? activeEvent?.slug ?? null;
|
||||||
const online = useOnlineStatus();
|
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 [activeTab, setActiveTab] = React.useState<'moderation' | 'live'>('moderation');
|
||||||
|
|
||||||
const [moderationPhotos, setModerationPhotos] = React.useState<TenantPhoto[]>([]);
|
const [moderationPhotos, setModerationPhotos] = React.useState<TenantPhoto[]>([]);
|
||||||
@@ -287,6 +301,23 @@ export default function MobileEventControlRoomPage() {
|
|||||||
const [liveError, setLiveError] = React.useState<string | null>(null);
|
const [liveError, setLiveError] = React.useState<string | null>(null);
|
||||||
const [liveBusyId, setLiveBusyId] = React.useState<number | 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 [queuedActions, setQueuedActions] = React.useState<PhotoModerationAction[]>(() => loadPhotoQueue());
|
||||||
const [syncingQueue, setSyncingQueue] = React.useState(false);
|
const [syncingQueue, setSyncingQueue] = React.useState(false);
|
||||||
const syncingQueueRef = React.useRef(false);
|
const syncingQueueRef = React.useRef(false);
|
||||||
@@ -297,12 +328,152 @@ export default function MobileEventControlRoomPage() {
|
|||||||
const infoBg = accentSoft;
|
const infoBg = accentSoft;
|
||||||
const infoBorder = accent;
|
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(() => {
|
React.useEffect(() => {
|
||||||
if (slugParam && activeEvent?.slug !== slugParam) {
|
if (slugParam && activeEvent?.slug !== slugParam) {
|
||||||
selectEvent(slugParam);
|
selectEvent(slugParam);
|
||||||
}
|
}
|
||||||
}, [slugParam, activeEvent?.slug, selectEvent]);
|
}, [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 () => {
|
const ensureSlug = React.useCallback(async () => {
|
||||||
if (slug) {
|
if (slug) {
|
||||||
return slug;
|
return slug;
|
||||||
@@ -506,6 +677,23 @@ export default function MobileEventControlRoomPage() {
|
|||||||
[applyOptimisticUpdate, slug, t],
|
[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 () => {
|
const syncQueuedActions = React.useCallback(async () => {
|
||||||
if (!online || syncingQueueRef.current) {
|
if (!online || syncingQueueRef.current) {
|
||||||
return;
|
return;
|
||||||
@@ -566,7 +754,13 @@ export default function MobileEventControlRoomPage() {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const shouldAutoApproveHighlight = action === 'feature' && autoApproveHighlights && photo.status === 'pending';
|
||||||
|
|
||||||
if (!online) {
|
if (!online) {
|
||||||
|
if (shouldAutoApproveHighlight) {
|
||||||
|
enqueueModerationActions(['approve', 'feature'], photo.id);
|
||||||
|
return;
|
||||||
|
}
|
||||||
enqueueModerationAction(action, photo.id);
|
enqueueModerationAction(action, photo.id);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -576,12 +770,21 @@ export default function MobileEventControlRoomPage() {
|
|||||||
try {
|
try {
|
||||||
let updated: TenantPhoto;
|
let updated: TenantPhoto;
|
||||||
if (action === 'approve') {
|
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') {
|
} else if (action === 'hide') {
|
||||||
updated = await updatePhotoVisibility(slug, photo.id, true);
|
updated = await updatePhotoVisibility(slug, photo.id, true);
|
||||||
} else if (action === 'show') {
|
} else if (action === 'show') {
|
||||||
updated = await updatePhotoVisibility(slug, photo.id, false);
|
updated = await updatePhotoVisibility(slug, photo.id, false);
|
||||||
} else if (action === 'feature') {
|
} 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);
|
updated = await featurePhoto(slug, photo.id);
|
||||||
} else {
|
} else {
|
||||||
updated = await unfeaturePhoto(slug, photo.id);
|
updated = await unfeaturePhoto(slug, photo.id);
|
||||||
@@ -605,7 +808,16 @@ export default function MobileEventControlRoomPage() {
|
|||||||
setModerationBusyId(null);
|
setModerationBusyId(null);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[enqueueModerationAction, online, slug, t, updatePhotoInCollections],
|
[
|
||||||
|
autoAddApprovedToLive,
|
||||||
|
autoApproveHighlights,
|
||||||
|
enqueueModerationAction,
|
||||||
|
enqueueModerationActions,
|
||||||
|
online,
|
||||||
|
slug,
|
||||||
|
t,
|
||||||
|
updatePhotoInCollections,
|
||||||
|
],
|
||||||
);
|
);
|
||||||
|
|
||||||
function resolveScopeAndAddonKey(scopeOrKey: 'photos' | 'gallery' | 'guests' | string) {
|
function resolveScopeAndAddonKey(scopeOrKey: 'photos' | 'gallery' | 'guests' | string) {
|
||||||
@@ -805,6 +1017,254 @@ export default function MobileEventControlRoomPage() {
|
|||||||
))}
|
))}
|
||||||
</XStack>
|
</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' ? (
|
{activeTab === 'moderation' ? (
|
||||||
<YStack space="$2">
|
<YStack space="$2">
|
||||||
{queuedEventCount > 0 ? (
|
{queuedEventCount > 0 ? (
|
||||||
|
|||||||
@@ -4,6 +4,8 @@ import { render, screen } from '@testing-library/react';
|
|||||||
|
|
||||||
const navigateMock = vi.fn();
|
const navigateMock = vi.fn();
|
||||||
const selectEventMock = 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', () => ({
|
vi.mock('react-router-dom', () => ({
|
||||||
useNavigate: () => navigateMock,
|
useNavigate: () => navigateMock,
|
||||||
@@ -36,8 +38,9 @@ vi.mock('../../auth/context', () => ({
|
|||||||
|
|
||||||
vi.mock('../../context/EventContext', () => ({
|
vi.mock('../../context/EventContext', () => ({
|
||||||
useEventContext: () => ({
|
useEventContext: () => ({
|
||||||
activeEvent: { slug: 'demo-event' },
|
activeEvent: activeEventMock,
|
||||||
selectEvent: selectEventMock,
|
selectEvent: selectEventMock,
|
||||||
|
refetch: refetchMock,
|
||||||
}),
|
}),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
@@ -98,6 +101,13 @@ vi.mock('@tamagui/text', () => ({
|
|||||||
SizableText: ({ children }: { children: React.ReactNode }) => <span>{children}</span>,
|
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', () => ({
|
vi.mock('@tamagui/react-native-web-lite', () => ({
|
||||||
Pressable: ({
|
Pressable: ({
|
||||||
children,
|
children,
|
||||||
@@ -143,6 +153,7 @@ vi.mock('../../api', () => ({
|
|||||||
approveAndLiveShowPhoto: vi.fn(),
|
approveAndLiveShowPhoto: vi.fn(),
|
||||||
approveLiveShowPhoto: vi.fn(),
|
approveLiveShowPhoto: vi.fn(),
|
||||||
clearLiveShowPhoto: vi.fn(),
|
clearLiveShowPhoto: vi.fn(),
|
||||||
|
updateEvent: vi.fn(),
|
||||||
createEventAddonCheckout: vi.fn(),
|
createEventAddonCheckout: vi.fn(),
|
||||||
featurePhoto: vi.fn(),
|
featurePhoto: vi.fn(),
|
||||||
getAddonCatalog: vi.fn().mockResolvedValue([]),
|
getAddonCatalog: vi.fn().mockResolvedValue([]),
|
||||||
|
|||||||
@@ -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
|
public function test_guest_can_access_stats_using_join_token(): void
|
||||||
{
|
{
|
||||||
$event = $this->createPublishedEvent();
|
$event = $this->createPublishedEvent();
|
||||||
@@ -67,33 +96,9 @@ class GuestJoinTokenFlowTest extends TestCase
|
|||||||
Storage::fake('public');
|
Storage::fake('public');
|
||||||
|
|
||||||
$event = $this->createPublishedEvent();
|
$event = $this->createPublishedEvent();
|
||||||
$package = Package::factory()->endcustomer()->create([
|
$this->seedGuestUploadPrerequisites($event);
|
||||||
'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,
|
|
||||||
]);
|
|
||||||
$token = $this->tokenService->createToken($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);
|
$file = UploadedFile::fake()->image('example.jpg', 1200, 800);
|
||||||
|
|
||||||
$response = $this->withHeader('X-Device-Id', 'token-device')
|
$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
|
public function test_guest_event_response_includes_demo_read_only_flag(): void
|
||||||
{
|
{
|
||||||
$event = $this->createPublishedEvent();
|
$event = $this->createPublishedEvent();
|
||||||
|
|||||||
Reference in New Issue
Block a user