From 650c81856f3985937cb6776f52af28ec23f9ef9d Mon Sep 17 00:00:00 2001
From: jjbrunton
Date: Thu, 27 Aug 2026 22:02:06 +0100
Subject: [PATCH] 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.
---
.../src/components/content/ResponseAnswer.tsx | 124 ++++++++++++++-
apps/admin/src/lib/questionResponses.test.ts | 16 ++
apps/admin/src/lib/questionResponses.ts | 23 +++
.../src/pages/activity/UserActivityPage.tsx | 3 +
apps/admin/src/pages/users/UserDetailPage.tsx | 8 +-
.../functions/admin-response-media/helpers.ts | 40 +++++
.../functions/admin-response-media/index.ts | 144 ++++++++++++++++++
.../tests/admin-response-media.test.ts | 30 ++++
8 files changed, 385 insertions(+), 3 deletions(-)
create mode 100644 apps/supabase/functions/admin-response-media/helpers.ts
create mode 100644 apps/supabase/functions/admin-response-media/index.ts
create mode 100644 apps/supabase/tests/admin-response-media.test.ts
diff --git a/apps/admin/src/components/content/ResponseAnswer.tsx b/apps/admin/src/components/content/ResponseAnswer.tsx
index 7ceb005..6f877ab 100644
--- a/apps/admin/src/components/content/ResponseAnswer.tsx
+++ b/apps/admin/src/components/content/ResponseAnswer.tsx
@@ -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}
)}
+ {(resolvedType === 'photo' || resolvedType === 'audio') && responseId && (
+
+ )}
);
}
+
+function ProtectedResponseMedia({
+ responseId,
+ mediaType,
+ label,
+}: {
+ responseId: string;
+ mediaType: 'photo' | 'audio';
+ label: string;
+}) {
+ const { session } = useAuth();
+ const [url, setUrl] = useState(null);
+ const [error, setError] = useState(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 (
+
+ {mediaType === 'photo' ? : }
+ {error}
+
+ );
+ }
+
+ if (!url) {
+ return ;
+ }
+
+ if (mediaType === 'photo') {
+ return (
+
+ );
+ }
+
+ return (
+
+ );
+}
diff --git a/apps/admin/src/lib/questionResponses.test.ts b/apps/admin/src/lib/questionResponses.test.ts
index 65007b9..4797301 100644
--- a/apps/admin/src/lib/questionResponses.test.ts
+++ b/apps/admin/src/lib/questionResponses.test.ts
@@ -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' });
+ });
});
diff --git a/apps/admin/src/lib/questionResponses.ts b/apps/admin/src/lib/questionResponses.ts
index 915fdb8..b0a3aa6 100644
--- a/apps/admin/src/lib/questionResponses.ts
+++ b/apps/admin/src/lib/questionResponses.ts
@@ -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 };
}
diff --git a/apps/admin/src/pages/activity/UserActivityPage.tsx b/apps/admin/src/pages/activity/UserActivityPage.tsx
index dce6ce2..ffacec2 100644
--- a/apps/admin/src/pages/activity/UserActivityPage.tsx
+++ b/apps/admin/src/pages/activity/UserActivityPage.tsx
@@ -524,8 +524,11 @@ export function UserActivityPage() {
diff --git a/apps/admin/src/pages/users/UserDetailPage.tsx b/apps/admin/src/pages/users/UserDetailPage.tsx
index 1437c71..3894080 100644
--- a/apps/admin/src/pages/users/UserDetailPage.tsx
+++ b/apps/admin/src/pages/users/UserDetailPage.tsx
@@ -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() {
Enabled Packs ({enabledPacks.length})
Live Draw
- {canViewResponses && {responses.length === 0 ? No responses yet
: QuestionPackResponseDate{responses.map((r) => ({r.question.pack?.id ? ({r.question.text}) : ({r.question.text})}{r.question.pack?.id ? ({r.question.pack.name}) : ({r.question.pack?.name || '—'})}{r.created_at ? format(new Date(r.created_at), 'MMM d, yyyy') : '—'}))}
{ setResponsesPage(1); setResponsesPageSize(size); }} /> }}
+ {canViewResponses && {responses.length === 0 ? No responses yet
: QuestionPackResponseDate{responses.map((r) => ({r.question.pack?.id ? ({r.question.text}) : ({r.question.text})}{r.question.pack?.id ? ({r.question.pack.name}) : ({r.question.pack?.name || '—'})}{r.created_at ? format(new Date(r.created_at), 'MMM d, yyyy') : '—'}))}
{ setResponsesPage(1); setResponsesPageSize(size); }} /> }}
{canViewMatches && {matches.length === 0 ? No matches yet
: {matches.map((match) => (
{matchTypeLabels[match.match_type]}{match.is_new && New}
{match.question?.text || 'Unknown question'}{match.message_count} message{match.message_count !== 1 ? 's' : ''}
{canViewChats &&
}
))}
{ setMatchesPage(1); setMatchesPageSize(size); }} /> }}
{canViewMedia && {mediaMessages.length === 0 ? No media uploaded
: {mediaMessages.map((message) => (
{message.media_type === 'video' ? (
@@ -974,8 +977,11 @@ export function UserDetailPage() {
diff --git a/apps/supabase/functions/admin-response-media/helpers.ts b/apps/supabase/functions/admin-response-media/helpers.ts
new file mode 100644
index 0000000..94dfb5d
--- /dev/null
+++ b/apps/supabase/functions/admin-response-media/helpers.ts
@@ -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
;
+ 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;
+}
diff --git a/apps/supabase/functions/admin-response-media/index.ts b/apps/supabase/functions/admin-response-media/index.ts
new file mode 100644
index 0000000..bfa7708
--- /dev/null
+++ b/apps/supabase/functions/admin-response-media/index.ts
@@ -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,
+ 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,
+ 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");
+ }
+}
diff --git a/apps/supabase/tests/admin-response-media.test.ts b/apps/supabase/tests/admin-response-media.test.ts
new file mode 100644
index 0000000..503441d
--- /dev/null
+++ b/apps/supabase/tests/admin-response-media.test.ts
@@ -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,
+ );
+});