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,
+ );
+});