feat(admin): preview response media

Show who-likely choices in human terms and provide protected photo and audio previews in activity and user response views. Fetch media by response ID through an authenticated Edge Function that enforces the view_responses permission.
This commit is contained in:
jjbrunton committed 2026-08-27 22:02:06 +01:00
1 parent 7545289dd3
commit 650c81856f
8 files changed
+385 -3

No files matched your search

@@ -1,15 +1,30 @@
import { useEffect, useState } from 'react';
import type { AnswerType, QuestionType } from '@sauci/shared';
import { Badge } from '@/components/ui/badge';
import { Skeleton } from '@/components/ui/skeleton';
import { Image as ImageIcon, Music2 } from 'lucide-react';
import { useAuth } from '@/contexts/AuthContext';
import { supabaseConfig } from '@/config';
import { formatAdminResponse, QUESTION_TYPE_LABELS, type AdminResponseData } from '@/lib/questionResponses';
interface ResponseAnswerProps {
answer: AnswerType;
responseId?: string;
questionType?: QuestionType | null;
responseData?: AdminResponseData;
responderId?: string;
responderName?: string | null;
}
export function ResponseAnswer({ answer, questionType, responseData = null }: ResponseAnswerProps) {
const formatted = formatAdminResponse(questionType, answer, responseData);
export function ResponseAnswer({
answer,
responseId,
questionType,
responseData = null,
responderId,
responderName,
}: ResponseAnswerProps) {
const formatted = formatAdminResponse(questionType, answer, responseData, { responderId, responderName });
const resolvedType = questionType ?? 'swipe';
return (
@@ -27,6 +42,111 @@ export function ResponseAnswer({ answer, questionType, responseData = null }: Re
{formatted.detail}
</p>
)}
{(resolvedType === 'photo' || resolvedType === 'audio') && responseId && (
<ProtectedResponseMedia
responseId={responseId}
mediaType={resolvedType}
label={responderName || 'User response'}
/>
)}
</div>
);
}
function ProtectedResponseMedia({
responseId,
mediaType,
label,
}: {
responseId: string;
mediaType: 'photo' | 'audio';
label: string;
}) {
const { session } = useAuth();
const [url, setUrl] = useState<string | null>(null);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
if (!session?.access_token) {
setError('Sign in to load media');
return;
}
let cancelled = false;
let objectUrl: string | null = null;
const load = async () => {
try {
setError(null);
const response = await fetch(`${supabaseConfig.url}/functions/v1/admin-response-media`, {
method: 'POST',
headers: {
apikey: supabaseConfig.anonKey,
Authorization: `Bearer ${session.access_token}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({ responseId }),
});
if (!response.ok) {
const body = await response.json().catch(() => ({}));
throw new Error(body?.error || `Failed to load media (${response.status})`);
}
objectUrl = URL.createObjectURL(await response.blob());
if (!cancelled) setUrl(objectUrl);
} catch (loadError) {
if (!cancelled) {
setError(loadError instanceof Error ? loadError.message : 'Failed to load media');
}
}
};
load();
return () => {
cancelled = true;
if (objectUrl) URL.revokeObjectURL(objectUrl);
};
}, [responseId, session?.access_token]);
if (error) {
return (
<p className="flex items-center gap-1 text-xs text-destructive" role="alert">
{mediaType === 'photo' ? <ImageIcon className="h-3 w-3" /> : <Music2 className="h-3 w-3" />}
{error}
</p>
);
}
if (!url) {
return <Skeleton className={mediaType === 'photo' ? 'h-20 w-28 rounded-md' : 'h-10 w-64 rounded-md'} />;
}
if (mediaType === 'photo') {
return (
<button
type="button"
className="block overflow-hidden rounded-md border bg-muted focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
onClick={() => window.open(url, '_blank', 'noopener,noreferrer')}
aria-label={`Open ${label} photo response`}
>
<img
src={url}
alt={`${label} photo response`}
className="h-20 w-28 object-cover transition-transform hover:scale-105"
/>
</button>
);
}
return (
<audio
src={url}
controls
preload="metadata"
className="h-10 w-64 max-w-full"
aria-label={`Play ${label} audio response`}
/>
);
}
@@ -24,4 +24,20 @@ describe('admin question response formatting', () => {
it('labels non-swipe matches', () => {
expect(MATCH_TYPE_LABELS.both_answered).toBe('Both Answered');
});
it('shows who was chosen without exposing user identifiers', () => {
expect(formatAdminResponse(
'who_likely',
'yes',
{ type: 'who_likely', chosen_user_id: 'user-1' },
{ responderId: 'user-1', responderName: 'Alex' },
)).toEqual({ label: 'Chose themselves', detail: 'Alex chose themselves' });
expect(formatAdminResponse(
'who_likely',
'yes',
{ type: 'who_likely', chosen_user_id: 'user-2' },
{ responderId: 'user-1', responderName: 'Alex' },
)).toEqual({ label: 'Chose their partner', detail: 'Alex chose their partner' });
});
});
+23
View File
@@ -31,6 +31,7 @@ export function formatAdminResponse(
questionType: QuestionType | null | undefined,
answer: AnswerType,
responseData: AdminResponseData,
context?: { responderId?: string; responderName?: string | null },
): { label: string; detail: string | null } {
const resolvedType = resolveQuestionType(questionType);
@@ -63,5 +64,27 @@ export function formatAdminResponse(
return { label: 'Photo submitted', detail: null };
}
const chosenUserId = responseData && 'chosen_user_id' in responseData
&& typeof responseData.chosen_user_id === 'string'
? responseData.chosen_user_id
: null;
const choseSelf = chosenUserId && context?.responderId
? chosenUserId === context.responderId
: null;
if (choseSelf === true) {
return {
label: 'Chose themselves',
detail: context?.responderName ? `${context.responderName} chose themselves` : null,
};
}
if (choseSelf === false) {
return {
label: 'Chose their partner',
detail: context?.responderName ? `${context.responderName} chose their partner` : null,
};
}
return { label: 'Choice submitted', detail: null };
}
@@ -524,8 +524,11 @@ export function UserActivityPage() {
<TableCell>
<ResponseAnswer
answer={item.answer}
responseId={item.id}
questionType={item.question?.question_type}
responseData={item.response_data}
responderId={item.user_id}
responderName={item.profile?.name || item.profile?.email}
/>
</TableCell>
<TableCell className="text-muted-foreground text-sm whitespace-nowrap">
@@ -145,6 +145,7 @@ interface OutstandingQuestion {
name: string;
};
partner_response: {
id: string;
answer: AnswerType;
response_data: AdminResponseData;
created_at: string | null;
@@ -646,6 +647,7 @@ export function UserDetailPage() {
const { data: partnerResponses } = await supabase
.from('responses')
.select(`
id,
question_id,
answer,
response_data,
@@ -681,6 +683,7 @@ export function UserDetailPage() {
question_type: q.question_type ?? 'swipe',
pack: { id: q.pack?.id || '', name: q.pack?.name || '' },
partner_response: {
id: pr.id,
answer: pr.answer,
response_data: pr.response_data,
created_at: pr.created_at,
@@ -905,7 +908,7 @@ export function UserDetailPage() {
<TabsTrigger value="packs" disabled={!profile.couple_id}>Enabled Packs ({enabledPacks.length})</TabsTrigger>
<TabsTrigger value="livedraw" disabled={!profile.couple_id}>Live Draw</TabsTrigger>
</TabsList>
{canViewResponses && <TabsContent value="responses" className="mt-4">{responses.length === 0 ? <Card className="flex flex-col items-center justify-center py-12"><p className="text-muted-foreground">No responses yet</p></Card> : <div className="space-y-4"><div className="rounded-md border"><Table><TableHeader><TableRow><TableHead>Question</TableHead><TableHead>Pack</TableHead><TableHead className="w-64">Response</TableHead><TableHead className="w-32">Date</TableHead></TableRow></TableHeader><TableBody>{responses.map((r) => (<TableRow key={r.id}><TableCell className="max-w-md">{r.question.pack?.id ? (<Link to={`/packs/${r.question.pack.id}/questions`} className="line-clamp-2 text-primary hover:underline cursor-pointer">{r.question.text}</Link>) : (<span className="line-clamp-2">{r.question.text}</span>)}</TableCell><TableCell>{r.question.pack?.id ? (<Link to={`/packs/${r.question.pack.id}/questions`} className="text-primary hover:underline">{r.question.pack.name}</Link>) : (<span className="text-muted-foreground">{r.question.pack?.name || '—'}</span>)}</TableCell><TableCell><ResponseAnswer answer={r.answer} questionType={r.question.question_type} responseData={r.response_data} /></TableCell><TableCell className="text-muted-foreground text-sm">{r.created_at ? format(new Date(r.created_at), 'MMM d, yyyy') : '—'}</TableCell></TableRow>))}</TableBody></Table></div><PaginationControls page={responsesPage} pageSize={responsesPageSize} totalCount={responsesTotal} onPageChange={setResponsesPage} onPageSizeChange={(size) => { setResponsesPage(1); setResponsesPageSize(size); }} /></div>}</TabsContent>}
{canViewResponses && <TabsContent value="responses" className="mt-4">{responses.length === 0 ? <Card className="flex flex-col items-center justify-center py-12"><p className="text-muted-foreground">No responses yet</p></Card> : <div className="space-y-4"><div className="rounded-md border"><Table><TableHeader><TableRow><TableHead>Question</TableHead><TableHead>Pack</TableHead><TableHead className="w-64">Response</TableHead><TableHead className="w-32">Date</TableHead></TableRow></TableHeader><TableBody>{responses.map((r) => (<TableRow key={r.id}><TableCell className="max-w-md">{r.question.pack?.id ? (<Link to={`/packs/${r.question.pack.id}/questions`} className="line-clamp-2 text-primary hover:underline cursor-pointer">{r.question.text}</Link>) : (<span className="line-clamp-2">{r.question.text}</span>)}</TableCell><TableCell>{r.question.pack?.id ? (<Link to={`/packs/${r.question.pack.id}/questions`} className="text-primary hover:underline">{r.question.pack.name}</Link>) : (<span className="text-muted-foreground">{r.question.pack?.name || '—'}</span>)}</TableCell><TableCell><ResponseAnswer answer={r.answer} responseId={r.id} questionType={r.question.question_type} responseData={r.response_data} responderId={userId} responderName={profile.name || profile.email} /></TableCell><TableCell className="text-muted-foreground text-sm">{r.created_at ? format(new Date(r.created_at), 'MMM d, yyyy') : '—'}</TableCell></TableRow>))}</TableBody></Table></div><PaginationControls page={responsesPage} pageSize={responsesPageSize} totalCount={responsesTotal} onPageChange={setResponsesPage} onPageSizeChange={(size) => { setResponsesPage(1); setResponsesPageSize(size); }} /></div>}</TabsContent>}
{canViewMatches && <TabsContent value="matches" className="mt-4">{matches.length === 0 ? <Card className="flex flex-col items-center justify-center py-12"><p className="text-muted-foreground">No matches yet</p></Card> : <div className="space-y-4"><div className="grid gap-4 md:grid-cols-2">{matches.map((match) => (<Card key={match.id} className="hover:shadow-md transition-shadow"><CardHeader className="pb-2"><div className="flex items-start justify-between"><Badge variant="secondary">{matchTypeLabels[match.match_type]}</Badge>{match.is_new && <Badge variant="default" className="text-xs">New</Badge>}</div><CardDescription className="line-clamp-2 mt-2">{match.question?.text || 'Unknown question'}</CardDescription></CardHeader><CardContent><div className="flex items-center justify-between"><div className="flex items-center gap-1 text-sm text-muted-foreground"><MessageCircle className="h-4 w-4" />{match.message_count} message{match.message_count !== 1 ? 's' : ''}</div>{canViewChats && <Link to={`/users/${userId}/matches/${match.id}`}><Button variant="ghost" size="sm">View Chat<ChevronRight className="ml-1 h-4 w-4" /></Button></Link>}</div></CardContent></Card>))}</div><PaginationControls page={matchesPage} pageSize={matchesPageSize} totalCount={matchesTotal} onPageChange={setMatchesPage} onPageSizeChange={(size) => { setMatchesPage(1); setMatchesPageSize(size); }} /></div>}</TabsContent>}
{canViewMedia && <TabsContent value="media" className="mt-4">{mediaMessages.length === 0 ? <Card className="flex flex-col items-center justify-center py-12"><Image className="h-12 w-12 text-muted-foreground mb-4" /><p className="text-muted-foreground">No media uploaded</p></Card> : <div className="space-y-4"><div className="grid gap-4 grid-cols-2 md:grid-cols-3 lg:grid-cols-4">{mediaMessages.map((message) => (<Card key={message.id} className="overflow-hidden"><div className="aspect-square bg-muted relative">
{message.media_type === 'video' ? (
@@ -974,8 +977,11 @@ export function UserDetailPage() {
<div className="flex items-center gap-2">
<ResponseAnswer
answer={q.partner_response.answer}
responseId={q.partner_response.id}
questionType={q.question_type}
responseData={q.partner_response.response_data}
responderId={partner?.id}
responderName={partner?.name}
/>
</div>
</TableCell>
@@ -0,0 +1,40 @@
export type ResponseMediaDescriptor = {
type: "photo" | "audio";
path: string;
};
export function canViewResponseMedia(
role: unknown,
permissions: unknown,
): boolean {
return role === "super_admin" ||
(Array.isArray(permissions) && permissions.includes("view_responses"));
}
export function getResponseMediaDescriptor(
responseData: unknown,
): ResponseMediaDescriptor | null {
if (!responseData || typeof responseData !== "object") return null;
const data = responseData as Record<string, unknown>;
if (data.type !== "photo" && data.type !== "audio") return null;
if (typeof data.media_path !== "string" || !data.media_path.trim()) {
return null;
}
return {
type: data.type,
path: normalizeResponseMediaPath(data.media_path),
};
}
export function normalizeResponseMediaPath(mediaPath: string): string {
if (mediaPath.startsWith("http")) {
const parts = mediaPath.split("/response-media/");
if (parts.length > 1) {
return decodeURIComponent(parts[1].split("?")[0]);
}
}
return mediaPath;
}
@@ -0,0 +1,144 @@
// @ts-nocheck
import "jsr:@supabase/functions-js/edge-runtime.d.ts";
import { createClient } from "https://esm.sh/@supabase/supabase-js@2.45.0?target=deno";
import { canViewResponseMedia, getResponseMediaDescriptor } from "./helpers.ts";
const corsHeaders = {
"Access-Control-Allow-Origin": "*",
"Access-Control-Allow-Headers":
"authorization, x-client-info, apikey, content-type",
"Access-Control-Allow-Methods": "POST, OPTIONS",
};
Deno.serve(async (req) => {
if (req.method === "OPTIONS") {
return new Response("ok", { headers: corsHeaders });
}
if (req.method !== "POST") {
return json({ error: "Method not allowed" }, 405);
}
try {
const supabaseAdmin = createClient(
Deno.env.get("SUPABASE_URL")!,
Deno.env.get("SUPABASE_SERVICE_ROLE_KEY")!,
);
const userId = await getUserIdFromRequest(supabaseAdmin, req);
await assertResponseAccess(supabaseAdmin, userId);
const body = await req.json().catch(() => ({}));
const responseId = typeof body?.responseId === "string"
? body.responseId
: null;
if (!responseId) {
return json({ error: "Missing responseId" }, 400);
}
const { data: response, error: responseError } = await supabaseAdmin
.from("responses")
.select("id, response_data")
.eq("id", responseId)
.maybeSingle();
if (responseError || !response) {
return json({ error: "Response not found" }, 404);
}
const media = getResponseMediaDescriptor(response.response_data);
if (!media) {
return json({ error: "Response has no supported media" }, 400);
}
const { data: mediaBlob, error: downloadError } = await supabaseAdmin
.storage
.from("response-media")
.download(media.path);
if (downloadError || !mediaBlob) {
console.error(
"admin-response-media download failed",
downloadError?.message,
);
return json({ error: "Failed to load response media" }, 500);
}
const fallbackType = media.type === "photo" ? "image/jpeg" : "audio/mp4";
return new Response(mediaBlob, {
status: 200,
headers: {
...corsHeaders,
"Content-Type": mediaBlob.type || fallbackType,
"Cache-Control": "private, no-store",
"X-Content-Type-Options": "nosniff",
},
});
} catch (error) {
const message = error instanceof Error ? error.message : "Unknown error";
if (
message.includes("authorization header") ||
message.startsWith("Invalid token")
) {
return json({ error: "Unauthorized" }, 401);
}
if (message === "Access denied") {
return json({ error: "Forbidden" }, 403);
}
console.error("admin-response-media error", error);
return json({ error: "Internal server error" }, 500);
}
});
function json(body: unknown, status: number) {
return new Response(JSON.stringify(body), {
status,
headers: {
...corsHeaders,
"Content-Type": "application/json",
"Cache-Control": "no-store",
},
});
}
async function getUserIdFromRequest(
supabaseAdmin: ReturnType<typeof createClient>,
req: Request,
) {
const authHeader = req.headers.get("Authorization");
if (!authHeader) {
throw new Error("Missing authorization header");
}
const { data, error } = await supabaseAdmin.auth.getUser(
authHeader.replace("Bearer ", ""),
);
if (error || !data.user) {
throw new Error(`Invalid token: ${error?.message || "No user"}`);
}
return data.user.id;
}
async function assertResponseAccess(
supabaseAdmin: ReturnType<typeof createClient>,
userId: string,
) {
const { data, error } = await supabaseAdmin
.from("admin_users")
.select("role, permissions")
.eq("user_id", userId)
.maybeSingle();
if (error || !data) {
throw new Error("Access denied");
}
if (!canViewResponseMedia(data.role, data.permissions)) {
throw new Error("Access denied");
}
}
@@ -0,0 +1,30 @@
import { assertEquals } from "std/assert/mod.ts";
import {
canViewResponseMedia,
getResponseMediaDescriptor,
} from "../functions/admin-response-media/helpers.ts";
Deno.test("response media access requires the response permission", () => {
assertEquals(canViewResponseMedia("super_admin", null), true);
assertEquals(canViewResponseMedia("pack_creator", ["view_responses"]), true);
assertEquals(canViewResponseMedia("pack_creator", ["view_activity"]), false);
});
Deno.test("response media is derived from the stored response payload", () => {
assertEquals(
getResponseMediaDescriptor({
type: "photo",
media_path:
"https://example.supabase.co/storage/v1/object/sign/response-media/user/question.jpg?token=secret",
}),
{ type: "photo", path: "user/question.jpg" },
);
assertEquals(
getResponseMediaDescriptor({ type: "text_answer", text: "hello" }),
null,
);
assertEquals(
getResponseMediaDescriptor({ type: "audio", media_path: "" }),
null,
);
});