feat(admin): serve response media from standalone API

This commit is contained in:
jjbrunton committed 2026-08-28 14:54:07 +01:00
1 parent be20391788
commit 2dad7aa352
9 files changed
+128 -31

No files matched your search

@@ -3,8 +3,7 @@ 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 { getAdminResponseMedia } from '@/lib/adminApi';
import { formatAdminResponse, QUESTION_TYPE_LABELS, type AdminResponseData } from '@/lib/questionResponses';
interface ResponseAnswerProps {
@@ -62,38 +61,17 @@ function ProtectedResponseMedia({
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());
objectUrl = URL.createObjectURL(await getAdminResponseMedia(responseId));
if (!cancelled) setUrl(objectUrl);
} catch (loadError) {
if (!cancelled) {
@@ -108,7 +86,7 @@ function ProtectedResponseMedia({
cancelled = true;
if (objectUrl) URL.revokeObjectURL(objectUrl);
};
}, [responseId, session?.access_token]);
}, [responseId]);
if (error) {
return (
+16 -1
View File
@@ -6,7 +6,7 @@ vi.mock('@/config', () => ({
authClient: { auth: { getSession } },
}));
import { AdminApiError, adminRequest, queryAdminRows } from './adminApi';
import { AdminApiError, adminRequest, getAdminResponseMedia, queryAdminRows } from './adminApi';
describe('admin API transport', () => {
beforeEach(() => {
@@ -38,4 +38,19 @@ describe('admin API transport', () => {
}));
await expect(adminRequest('/v1/admin/me')).rejects.toThrow('Administrator access denied');
});
it('loads protected response media from the standalone API with hosted Auth', async () => {
const fetchMock = vi.spyOn(globalThis, 'fetch').mockResolvedValue(new Response('response-media', {
status: 200, headers: { 'content-type': 'image/jpeg' },
}));
const responseId = '11111111-1111-4111-8111-111111111111';
const blob = await getAdminResponseMedia(responseId);
expect(await blob.text()).toBe('response-media');
const [url, init] = fetchMock.mock.calls[0];
expect(url).toBe(`http://127.0.0.1:3003/v1/admin/responses/${responseId}/media`);
expect(new Headers(init?.headers).get('Authorization')).toBe('Bearer hosted-auth-token');
expect(init?.body).toBeUndefined();
});
});
+4
View File
@@ -48,6 +48,10 @@ export async function adminBinaryRequest(path: string, options: RequestOptions =
return response.blob();
}
export function getAdminResponseMedia(responseId: string): Promise<Blob> {
return adminBinaryRequest(`/v1/admin/responses/${encodeURIComponent(responseId)}/media`);
}
export type AdminFilter = { column: string; op: 'eq' | 'neq' | 'in' | 'is' | 'gte' | 'lte' | 'ilike'; value: unknown };
export type AdminQuery = {
filters?: AdminFilter[];
@@ -3,8 +3,8 @@ import { CONTENT_STATUS_LABELS } from '@/lib/contentReviewStatus';
const { update } = vi.hoisted(() => ({ update: vi.fn() }));
vi.mock('@/hooks/useAuditedSupabase', () => ({
auditedSupabase: { update },
vi.mock('@/hooks/useAuditedAdminData', () => ({
auditedAdminData: { update },
}));
import { updateContentReviewStatus } from './contentReviews';
+2 -2
View File
@@ -1,5 +1,5 @@
import type { ContentReviewStatus } from '@sauci/shared';
import { auditedSupabase } from '@/hooks/useAuditedSupabase';
import { auditedAdminData } from '@/hooks/useAuditedAdminData';
export { CONTENT_STATUS_LABELS } from '@/lib/contentReviewStatus';
export type ReviewableContentTable =
@@ -20,7 +20,7 @@ export async function updateContentReviewStatus(
throw new Error('A review reason is required');
}
const { error } = await auditedSupabase.update(table, id, {
const { error } = await auditedAdminData.update(table, id, {
content_status: status,
content_review_reason: normalizedReason,
});