Add control room automations and uploader overrides
This commit is contained in:
@@ -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(),
|
||||
]);
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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'],
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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';
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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."
|
||||
},
|
||||
|
||||
@@ -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."
|
||||
},
|
||||
|
||||
@@ -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 ? (
|
||||
|
||||
@@ -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([]),
|
||||
|
||||
@@ -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();
|
||||
|
||||
Reference in New Issue
Block a user