From 31588a5beb0678970dd45efc582a3ad6d50b340e Mon Sep 17 00:00:00 2001 From: kota1112 Date: Wed, 13 May 2026 02:15:14 +1000 Subject: [PATCH 1/2] fix: stabilize admin submission review flow for goal 6 --- src/pages/AdminDashboard.tsx | 649 ++++++++++++++++++++++++--- src/types/index.ts | 4 +- tests/GOAL6_MINIMAL_E2E_TEST_PLAN.md | 126 ++++++ 3 files changed, 714 insertions(+), 65 deletions(-) create mode 100644 tests/GOAL6_MINIMAL_E2E_TEST_PLAN.md diff --git a/src/pages/AdminDashboard.tsx b/src/pages/AdminDashboard.tsx index f74012a..7269b7b 100644 --- a/src/pages/AdminDashboard.tsx +++ b/src/pages/AdminDashboard.tsx @@ -7,7 +7,7 @@ import { Textarea } from '@/components/ui/textarea'; import { Badge } from '@/components/ui/badge'; import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'; import { toast } from '@/hooks/use-toast'; -import { supabase } from '@/lib/supabaseClient'; +import { supabase, supabaseUrl } from '@/lib/supabaseClient'; import { normalizeRequirements } from '@/lib/utils'; import { CheckCircle, @@ -37,7 +37,100 @@ import { } from '@/components/ui/dialog'; import { EditChallengeRequestModal } from '@/components/EditChallengeRequestModal'; +const REVIEWABLE_SUBMISSION_STATUSES = ['pending', 'pending_review'] as const; +const APPROVED_SUBMISSION_STATUS = 'approved'; +const REJECTED_SUBMISSION_STATUS = 'rejected'; +const LEGACY_REJECTED_SUBMISSION_STATUS = 'denied'; +const PENDING_SUBMISSION_STATUS = 'pending'; + +type ReviewableSubmissionStatus = (typeof REVIEWABLE_SUBMISSION_STATUSES)[number]; + +interface SubmissionReviewSnapshot { + id: string; + status: string; + user_id: string; + challenge_id: string; +} + +const isReviewableSubmissionStatus = (status: string): status is ReviewableSubmissionStatus => + REVIEWABLE_SUBMISSION_STATUSES.includes(status as ReviewableSubmissionStatus); + +const getErrorMessage = (error: unknown, fallback: string) => { + if (error instanceof Error && error.message) { + return error.message; + } + + if (typeof error === 'object' && error !== null && 'message' in error && typeof error.message === 'string') { + return error.message; + } + + return fallback; +}; + +interface SubmissionDebugInfo { + reviewableCount: number; + reviewableError: string | null; + reviewableStatuses: string[]; + reviewableFallbackReason: string | null; + recentStatuses: string[]; + recentStatusesError: string | null; + lastAction: string | null; + lastActionError: string | null; +} + +const isPendingReviewEnumError = (error: { message?: string } | null | undefined) => + Boolean( + error?.message?.includes('invalid input value for enum submission_status') && + error.message.includes('"pending_review"') + ); + +const buildSubmissionUpdatePayloads = ({ + statusCandidates, + reviewedAt, + reviewedBy, + xpEarned, +}: { + statusCandidates: string[]; + reviewedAt?: string; + reviewedBy?: string; + xpEarned?: number; +}) => { + const payloads: Array> = []; + + for (const status of statusCandidates) { + const fullPayload: Record = { status }; + + if (typeof xpEarned === 'number') { + fullPayload.xp_earned = xpEarned; + } + + if (reviewedAt) { + fullPayload.reviewed_at = reviewedAt; + } + + if (reviewedBy) { + fullPayload.reviewed_by = reviewedBy; + } + + payloads.push(fullPayload); + + const withoutXp = { ...fullPayload }; + delete withoutXp.xp_earned; + payloads.push(withoutXp); + + const statusOnly = { status }; + payloads.push(statusOnly); + } + + return payloads.filter((payload, index, arr) => { + const key = JSON.stringify(payload); + return arr.findIndex(candidate => JSON.stringify(candidate) === key) === index; + }); +}; + export function AdminDashboard() { + const supabaseProjectRef = new URL(supabaseUrl).host.split('.')[0]; + const [newChallenge, setNewChallenge] = useState({ title: '', description: '', @@ -65,18 +158,188 @@ export function AdminDashboard() { const [pathways, setPathways] = useState([]); const [loadingSubmissions, setLoadingSubmissions] = useState(true); const [userProfiles, setUserProfiles] = useState<{[key: string]: any}>({}); + const [submissionDebugInfo, setSubmissionDebugInfo] = useState({ + reviewableCount: 0, + reviewableError: null, + reviewableStatuses: [...REVIEWABLE_SUBMISSION_STATUSES], + reviewableFallbackReason: null, + recentStatuses: [], + recentStatusesError: null, + lastAction: null, + lastActionError: null, + }); + + const fetchReviewableSubmissions = async () => { + const baseQuery = () => supabase + .from('submissions') + .select('*') + .order('submitted_at', { ascending: false, nullsFirst: false }); + + const preferredResult = await baseQuery().in('status', [...REVIEWABLE_SUBMISSION_STATUSES]); + + if (!isPendingReviewEnumError(preferredResult.error)) { + return { + ...preferredResult, + reviewableStatuses: [...REVIEWABLE_SUBMISSION_STATUSES], + fallbackReason: null, + }; + } + + const fallbackResult = await baseQuery().eq('status', PENDING_SUBMISSION_STATUS); + + return { + ...fallbackResult, + reviewableStatuses: [PENDING_SUBMISSION_STATUS], + fallbackReason: 'The current database enum does not support "pending_review", so the admin list is using "pending" only.', + }; + }; + + const rollbackSubmissionReview = async (submission: SubmissionReviewSnapshot) => { + const { error } = await supabase + .from('submissions') + .update({ + status: submission.status, + }) + .eq('id', submission.id); + + return error; + }; + + const awardUserXp = async (userId: string, xpToAward: number) => { + for (let attempt = 0; attempt < 3; attempt += 1) { + const { data: userData, error: userFetchError } = await supabase + .from('users') + .select('total_xp') + .eq('id', userId) + .maybeSingle(); + + if (userFetchError || !userData) { + return { + error: userFetchError ?? new Error('User not found'), + }; + } + + const currentXp = userData.total_xp ?? 0; + const expectedXp = userData.total_xp; + let updateQuery = supabase + .from('users') + .update({ total_xp: currentXp + xpToAward }) + .eq('id', userId) + .select('id, total_xp'); + + updateQuery = expectedXp === null + ? updateQuery.is('total_xp', null) + : updateQuery.eq('total_xp', expectedXp); + + const { data: updatedUser, error: xpError } = await updateQuery.maybeSingle(); + + if (xpError) { + return { error: xpError }; + } + + if (updatedUser) { + return { data: updatedUser }; + } + } + + const { data: fallbackUserData, error: fallbackFetchError } = await supabase + .from('users') + .select('total_xp') + .eq('id', userId) + .maybeSingle(); + + if (fallbackFetchError || !fallbackUserData) { + return { + error: fallbackFetchError ?? new Error('User not found during XP fallback.'), + }; + } + + const { data: fallbackUpdatedUser, error: fallbackUpdateError } = await supabase + .from('users') + .update({ total_xp: (fallbackUserData.total_xp ?? 0) + xpToAward }) + .eq('id', userId) + .select('id, total_xp') + .maybeSingle(); + + if (fallbackUpdateError) { + return { error: fallbackUpdateError }; + } + + if (fallbackUpdatedUser) { + return { data: fallbackUpdatedUser }; + } + + return { + error: new Error('Failed to award XP after multiple retries.'), + }; + }; + + const updateSubmissionWithFallbacks = async ({ + submissionId, + currentStatus, + payloads, + }: { + submissionId: string; + currentStatus: string; + payloads: Array>; + }) => { + let lastError: { message: string } | null = null; + + for (const payload of payloads) { + const { data, error } = await supabase + .from('submissions') + .update(payload) + .eq('id', submissionId) + .eq('status', currentStatus) + .select('id') + .maybeSingle(); + + if (!error) { + return { data, error: null, payload }; + } + + lastError = error; + } + + return { data: null, error: lastError, payload: null }; + }; useEffect(() => { const fetchData = async () => { setLoadingSubmissions(true); // Fetch pending submissions - const { data: submissions, error: subError } = await supabase - .from('submissions') - .select('*') - .eq('status', 'pending'); + const { + data: submissions, + error: subError, + reviewableStatuses, + fallbackReason, + } = await fetchReviewableSubmissions(); // Add debugging console.log('Admin Dashboard - Submissions query result:', { submissions, subError }); + + const { data: recentSubmissionStatuses, error: recentStatusesError } = await supabase + .from('submissions') + .select('status') + .order('submitted_at', { ascending: false, nullsFirst: false }) + .limit(10); + + console.log('Admin Dashboard - Recent submission statuses:', { + recentSubmissionStatuses, + recentStatusesError, + }); + + setSubmissionDebugInfo({ + reviewableCount: submissions?.length ?? 0, + reviewableError: subError?.message ?? null, + reviewableStatuses, + reviewableFallbackReason: fallbackReason, + recentStatuses: (recentSubmissionStatuses || []) + .map((submission: { status: string | null }) => submission.status ?? 'null'), + recentStatusesError: recentStatusesError?.message ?? null, + lastAction: null, + lastActionError: null, + }); // Fetch all challenges (for lookup) const { data: challengesData, error: chalError } = await supabase @@ -105,19 +368,41 @@ export function AdminDashboard() { console.log('Admin Dashboard - Users query result:', { usersData, usersError }); console.log('Admin Dashboard - Pathways query result:', { pathwaysData, pathwaysError }); - if (!subError && !chalError && !reqError && !usersError && !pathwaysError && submissions && challengesData && requestsData && usersData && pathwaysData) { + if (submissions) { setPendingSubmissions(submissions); + } else { + setPendingSubmissions([]); + } + + if (challengesData) { setChallenges(challengesData); + } else { + setChallenges([]); + } + + if (requestsData) { setChallengeRequests(requestsData); + } else { + setChallengeRequests([]); + } + + if (pathwaysData) { setPathways(pathwaysData); - - // Create users lookup map + } else { + setPathways([]); + } + + if (usersData) { const usersMap: {[key: string]: any} = {}; usersData.forEach((user: any) => { usersMap[user.id] = user; }); setUserProfiles(usersMap); } else { + setUserProfiles({}); + } + + if (subError || chalError || reqError || usersError || pathwaysError) { console.error('Query errors:', { subError, chalError, reqError, usersError, pathwaysError }); } setLoadingSubmissions(false); @@ -126,17 +411,118 @@ export function AdminDashboard() { }, []); const handleApproveSubmission = async (submissionId: string) => { - // Find the submission and its challenge - const submission = pendingSubmissions.find((s: any) => s.id === submissionId); - if (!submission) return; - const challenge = challenges.find((c: any) => c.id === submission.challenge_id); - if (!challenge) return; - // Update submission status to approved - const { error: updateError } = await supabase + setSubmissionDebugInfo(prev => ({ + ...prev, + lastAction: `Approve started for ${submissionId}`, + lastActionError: null, + })); + + const { data: authData, error: authError } = await supabase.auth.getUser(); + const reviewerId = authData.user?.id; + + if (authError || !reviewerId) { + setSubmissionDebugInfo(prev => ({ + ...prev, + lastAction: `Approve failed for ${submissionId}`, + lastActionError: authError?.message || 'Admin user not found.', + })); + toast({ + title: "Failed to approve submission", + description: authError?.message || 'Admin user not found.', + variant: "destructive", + }); + return; + } + + const { data: latestSubmission, error: latestSubmissionError } = await supabase .from('submissions') - .update({ status: 'approved' }) - .eq('id', submissionId); + .select('id, status, user_id, challenge_id') + .eq('id', submissionId) + .maybeSingle(); + + if (latestSubmissionError || !latestSubmission) { + setSubmissionDebugInfo(prev => ({ + ...prev, + lastAction: `Approve failed for ${submissionId}`, + lastActionError: latestSubmissionError?.message || 'Submission not found.', + })); + toast({ + title: "Failed to approve submission", + description: latestSubmissionError?.message || 'Submission not found.', + variant: "destructive", + }); + return; + } + + if (latestSubmission.status === APPROVED_SUBMISSION_STATUS) { + setSubmissionDebugInfo(prev => ({ + ...prev, + lastAction: `Approve skipped for ${submissionId}`, + lastActionError: 'Submission already approved.', + })); + toast({ + title: "Submission already approved", + description: "This submission was already approved in another session.", + }); + await refreshPendingSubmissions(); + return; + } + + if (!isReviewableSubmissionStatus(latestSubmission.status)) { + setSubmissionDebugInfo(prev => ({ + ...prev, + lastAction: `Approve skipped for ${submissionId}`, + lastActionError: `Unexpected submission status: ${latestSubmission.status}`, + })); + toast({ + title: "Submission status changed", + description: `Review skipped because the submission is now "${latestSubmission.status}".`, + variant: "destructive", + }); + await refreshPendingSubmissions(); + return; + } + + const challenge = challenges.find((c: any) => c.id === latestSubmission.challenge_id); + if (!challenge) { + setSubmissionDebugInfo(prev => ({ + ...prev, + lastAction: `Approve failed for ${submissionId}`, + lastActionError: 'Challenge metadata not found for this submission.', + })); + toast({ + title: "Failed to approve submission", + description: 'Challenge metadata not found for this submission.', + variant: "destructive", + }); + return; + } + + const xpAwarded = challenge.xp_reward || 0; + const reviewedAt = new Date().toISOString(); + + const approvePayloads = buildSubmissionUpdatePayloads({ + statusCandidates: [APPROVED_SUBMISSION_STATUS], + reviewedAt, + reviewedBy: reviewerId, + xpEarned: xpAwarded, + }); + + const { + data: approvedSubmission, + error: updateError, + } = await updateSubmissionWithFallbacks({ + submissionId, + currentStatus: latestSubmission.status, + payloads: approvePayloads, + }); + if (updateError) { + setSubmissionDebugInfo(prev => ({ + ...prev, + lastAction: `Approve failed for ${submissionId}`, + lastActionError: `Submission update failed: ${updateError.message}`, + })); toast({ title: "Failed to approve submission", description: updateError.message, @@ -144,40 +530,48 @@ export function AdminDashboard() { }); return; } - // Add XP to user (two-step process) - // 1. Fetch user - const { data: userData, error: userFetchError } = await supabase - .from('users') - .select('total_xp') - .eq('id', submission.user_id) - .single(); - if (userFetchError || !userData) { + + if (!approvedSubmission) { + setSubmissionDebugInfo(prev => ({ + ...prev, + lastAction: `Approve skipped for ${submissionId}`, + lastActionError: 'Submission update returned no row.', + })); toast({ - title: "Failed to fetch user XP", - description: userFetchError?.message || 'User not found', + title: "Submission status changed", + description: "Approval skipped because the submission was updated by someone else.", variant: "destructive", }); + await refreshPendingSubmissions(); return; } - // 2. Update total_xp = user.total_xp + challenge.xp_reward - const newXP = (userData.total_xp || 0) + (challenge.xp_reward || 0); - const { error: xpError } = await supabase - .from('users') - .update({ total_xp: newXP }) - .eq('id', submission.user_id); + + const { error: xpError } = await awardUserXp(latestSubmission.user_id, xpAwarded); if (xpError) { + const rollbackError = await rollbackSubmissionReview(latestSubmission); + const xpErrorMessage = rollbackError + ? `${getErrorMessage(xpError, 'Unable to update user XP.')} Rollback also failed, so manual cleanup may be needed.` + : `${getErrorMessage(xpError, 'Unable to update user XP.')} Submission approval was rolled back.`; + + setSubmissionDebugInfo(prev => ({ + ...prev, + lastAction: `Approve failed for ${submissionId}`, + lastActionError: xpErrorMessage, + })); + toast({ title: "Failed to award XP", - description: xpError.message, + description: xpErrorMessage, variant: "destructive", }); + await refreshPendingSubmissions(); return; } // Log the interaction for recommendations const { error: interactionError } = await supabase.rpc('log_user_interaction', { - p_user_id: submission.user_id, - p_challenge_id: submission.challenge_id, + p_user_id: latestSubmission.user_id, + p_challenge_id: latestSubmission.challenge_id, p_action: 'completed', p_difficulty: challenge.difficulty, p_challenge_type: challenge.challenge_type, @@ -188,6 +582,14 @@ export function AdminDashboard() { console.error('Failed to log interaction:', interactionError); } + setSubmissionDebugInfo(prev => ({ + ...prev, + lastAction: `Approve succeeded for ${submissionId}`, + lastActionError: interactionError?.message + ? `XP awarded and submission approved. Interaction log failed: ${interactionError.message}` + : null, + })); + toast({ title: "Submission approved", description: "The submission has been approved and the user has been awarded XP.", @@ -197,11 +599,82 @@ export function AdminDashboard() { }; const handleRejectSubmission = async (submissionId: string) => { - const { error } = await supabase + setSubmissionDebugInfo(prev => ({ + ...prev, + lastAction: `Reject started for ${submissionId}`, + lastActionError: null, + })); + + const { data: authData, error: authError } = await supabase.auth.getUser(); + const reviewerId = authData.user?.id; + + if (authError || !reviewerId) { + setSubmissionDebugInfo(prev => ({ + ...prev, + lastAction: `Reject failed for ${submissionId}`, + lastActionError: authError?.message || 'Admin user not found.', + })); + toast({ + title: "Failed to reject submission", + description: authError?.message || 'Admin user not found.', + variant: "destructive", + }); + return; + } + + const { data: latestSubmission, error: latestSubmissionError } = await supabase .from('submissions') - .update({ status: 'denied' }) - .eq('id', submissionId); + .select('id, status') + .eq('id', submissionId) + .maybeSingle<{ id: string; status: string }>(); + + if (latestSubmissionError || !latestSubmission) { + setSubmissionDebugInfo(prev => ({ + ...prev, + lastAction: `Reject failed for ${submissionId}`, + lastActionError: latestSubmissionError?.message || 'Submission not found.', + })); + toast({ + title: "Failed to reject submission", + description: latestSubmissionError?.message || 'Submission not found.', + variant: "destructive", + }); + return; + } + + if (!isReviewableSubmissionStatus(latestSubmission.status)) { + setSubmissionDebugInfo(prev => ({ + ...prev, + lastAction: `Reject skipped for ${submissionId}`, + lastActionError: `Unexpected submission status: ${latestSubmission.status}`, + })); + toast({ + title: "Submission status changed", + description: `Reject skipped because the submission is now "${latestSubmission.status}".`, + variant: "destructive", + }); + await refreshPendingSubmissions(); + return; + } + + const rejectPayloads = buildSubmissionUpdatePayloads({ + statusCandidates: [REJECTED_SUBMISSION_STATUS, LEGACY_REJECTED_SUBMISSION_STATUS], + reviewedAt: new Date().toISOString(), + reviewedBy: reviewerId, + }); + + const { data: rejectedSubmission, error } = await updateSubmissionWithFallbacks({ + submissionId, + currentStatus: latestSubmission.status, + payloads: rejectPayloads, + }); + if (error) { + setSubmissionDebugInfo(prev => ({ + ...prev, + lastAction: `Reject failed for ${submissionId}`, + lastActionError: error.message, + })); toast({ title: "Failed to reject submission", description: error.message, @@ -209,11 +682,32 @@ export function AdminDashboard() { }); return; } + + if (!rejectedSubmission) { + setSubmissionDebugInfo(prev => ({ + ...prev, + lastAction: `Reject skipped for ${submissionId}`, + lastActionError: 'Submission update returned no row.', + })); + toast({ + title: "Submission status changed", + description: "Reject skipped because the submission was updated by someone else.", + variant: "destructive", + }); + await refreshPendingSubmissions(); + return; + } + toast({ title: "Submission rejected", description: "The submission has been rejected.", variant: "destructive", }); + setSubmissionDebugInfo(prev => ({ + ...prev, + lastAction: `Reject succeeded for ${submissionId}`, + lastActionError: null, + })); // Refresh pending submissions refreshPendingSubmissions(); }; @@ -221,16 +715,8 @@ export function AdminDashboard() { // Helper to refresh pending submissions const refreshPendingSubmissions = async () => { setLoadingSubmissions(true); - const { data: submissions, error: subError } = await supabase - .from('submissions') - .select(` - *, - users!submissions_user_id_fkey ( - id, - username - ) - `) - .eq('status', 'pending'); + const { data: submissions, error: subError } = await fetchReviewableSubmissions(); + if (!subError && submissions) { setPendingSubmissions(submissions); } @@ -762,6 +1248,27 @@ export function AdminDashboard() {

Manage challenges, submissions, and users

+ {import.meta.env.DEV && ( +
+
+ Connected Supabase: {supabaseProjectRef} +
+
+
Reviewable submissions fetched: {submissionDebugInfo.reviewableCount}
+
Reviewable query error: {submissionDebugInfo.reviewableError ?? 'none'}
+
Reviewable statuses in use: {submissionDebugInfo.reviewableStatuses.join(', ')}
+
Reviewable fallback: {submissionDebugInfo.reviewableFallbackReason ?? 'none'}
+
+ Recent submission statuses: {submissionDebugInfo.recentStatuses.length > 0 + ? submissionDebugInfo.recentStatuses.join(', ') + : 'none'} +
+
Recent statuses query error: {submissionDebugInfo.recentStatusesError ?? 'none'}
+
Last action: {submissionDebugInfo.lastAction ?? 'none'}
+
Last action error: {submissionDebugInfo.lastActionError ?? 'none'}
+
+
+ )} {/* Stats Cards */} @@ -860,21 +1367,37 @@ export function AdminDashboard() { {submission.submitted_at ? new Date(submission.submitted_at).toLocaleDateString() : ''}

- - {challenge?.difficulty || 'Unknown'} - - -
- -
- - +
+ + {challenge?.difficulty || 'Unknown'} + + + {submission.status === 'pending_review' ? 'Pending Review' : 'Pending'} +
+ {submission.submission_url ? ( +
+ +
+ + +
+
+ ) : ( +
+ +

+ {submission.submission_type === 'onboarding_complete' + ? 'Onboarding completion submitted for admin review.' + : 'No external submission URL was provided for this submission.'} +

+
+ )}
); -} \ No newline at end of file +} diff --git a/src/types/index.ts b/src/types/index.ts index 96e9812..6182fa7 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -42,7 +42,7 @@ export interface Submission { challengeId: string; userId: string; solutionUrl: string; - status: 'pending' | 'approved' | 'rejected'; + status: 'pending' | 'pending_review' | 'approved' | 'rejected'; feedback?: string; submittedAt: Date; reviewedAt?: Date; @@ -61,4 +61,4 @@ export interface AuthContextType { logout: () => void; isLoading: boolean; setUser: (user: User | null) => void; -} \ No newline at end of file +} diff --git a/tests/GOAL6_MINIMAL_E2E_TEST_PLAN.md b/tests/GOAL6_MINIMAL_E2E_TEST_PLAN.md new file mode 100644 index 0000000..5604524 --- /dev/null +++ b/tests/GOAL6_MINIMAL_E2E_TEST_PLAN.md @@ -0,0 +1,126 @@ +# Goal 6 Minimal E2E Test Plan + +## Scope + +This plan covers the Sprint 2 Goal 6 backend flow only: + +- challenge submission +- admin review +- XP update + +It intentionally excludes: + +- recommendations +- dashboard UI changes unrelated to submission review +- challenge/pathway linking + +## Preconditions + +- A regular test user account exists +- An admin account exists +- At least one published challenge with a known `xp_reward` exists +- The application can connect to the target Supabase project + +## Test Case 1: Approve awards XP + +### Objective + +Verify that approving a pending submission: + +- updates submission status to `approved` +- stores `xp_earned` +- stores `reviewed_at` and `reviewed_by` +- increments `users.total_xp` + +### Steps + +1. Log in as a regular user +2. Submit a valid challenge solution URL +3. Confirm a submission record is created with status `pending` +4. Log in as an admin +5. Open the pending submissions list +6. Approve the submission +7. Re-open the submission record in Supabase +8. Re-open the user record in Supabase + +### Expected Result + +- submission status is `approved` +- `xp_earned` matches challenge XP +- `reviewed_at` is populated +- `reviewed_by` is populated with the admin user id +- `users.total_xp` increases by the same XP amount +- the submission is removed from the pending list + +## Test Case 2: Reject does not award XP + +### Objective + +Verify that rejecting a pending submission: + +- updates submission status to `rejected` +- does not award XP +- stores review metadata + +### Steps + +1. Log in as a regular user +2. Submit another valid challenge solution URL +3. Confirm a submission record is created with status `pending` +4. Log in as an admin +5. Open the pending submissions list +6. Reject the submission +7. Re-open the submission record in Supabase +8. Re-open the user record in Supabase + +### Expected Result + +- submission status is `rejected` +- `xp_earned` remains `null` or unchanged from a pre-existing value +- `reviewed_at` is populated +- `reviewed_by` is populated with the admin user id +- `users.total_xp` does not change +- the submission is removed from the pending list + +## Test Case 3: Duplicate approval does not double-award XP + +### Objective + +Verify that the same submission cannot award XP twice. + +### Steps + +1. Create a fresh pending submission as a regular user +2. Approve it once as an admin +3. Capture the user `total_xp` +4. Attempt to approve the same submission again +5. Re-check the same submission and user record + +### Expected Result + +- the second approval is blocked or ignored +- submission remains `approved` +- `users.total_xp` does not increase a second time +- no duplicate XP is written + +## Database Verification Queries + +```sql +select id, user_id, challenge_id, status, xp_earned, reviewed_at, reviewed_by +from submissions +where user_id = '' +order by submitted_at desc; +``` + +```sql +select id, total_xp +from users +where id = ''; +``` + +## Known Gaps Before Full E2E Automation + +- `vitest` is not installed locally in this workspace +- `@testing-library/react` is not installed locally in this workspace +- There is no existing automated test coverage for `AdminDashboard` submission approval/rejection +- Full execution still depends on real Supabase data, test accounts, and admin access From 05c8b51550d04f29e93874f7c9793a9d44111526 Mon Sep 17 00:00:00 2001 From: kota1112 Date: Wed, 13 May 2026 02:38:36 +1000 Subject: [PATCH 2/2] fix: stabilize goal 6 admin review and xp update flow --- src/pages/AdminDashboard.tsx | 162 +---------------------------------- 1 file changed, 1 insertion(+), 161 deletions(-) diff --git a/src/pages/AdminDashboard.tsx b/src/pages/AdminDashboard.tsx index 7269b7b..d659853 100644 --- a/src/pages/AdminDashboard.tsx +++ b/src/pages/AdminDashboard.tsx @@ -7,7 +7,7 @@ import { Textarea } from '@/components/ui/textarea'; import { Badge } from '@/components/ui/badge'; import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'; import { toast } from '@/hooks/use-toast'; -import { supabase, supabaseUrl } from '@/lib/supabaseClient'; +import { supabase } from '@/lib/supabaseClient'; import { normalizeRequirements } from '@/lib/utils'; import { CheckCircle, @@ -67,17 +67,6 @@ const getErrorMessage = (error: unknown, fallback: string) => { return fallback; }; -interface SubmissionDebugInfo { - reviewableCount: number; - reviewableError: string | null; - reviewableStatuses: string[]; - reviewableFallbackReason: string | null; - recentStatuses: string[]; - recentStatusesError: string | null; - lastAction: string | null; - lastActionError: string | null; -} - const isPendingReviewEnumError = (error: { message?: string } | null | undefined) => Boolean( error?.message?.includes('invalid input value for enum submission_status') && @@ -129,8 +118,6 @@ const buildSubmissionUpdatePayloads = ({ }; export function AdminDashboard() { - const supabaseProjectRef = new URL(supabaseUrl).host.split('.')[0]; - const [newChallenge, setNewChallenge] = useState({ title: '', description: '', @@ -158,16 +145,6 @@ export function AdminDashboard() { const [pathways, setPathways] = useState([]); const [loadingSubmissions, setLoadingSubmissions] = useState(true); const [userProfiles, setUserProfiles] = useState<{[key: string]: any}>({}); - const [submissionDebugInfo, setSubmissionDebugInfo] = useState({ - reviewableCount: 0, - reviewableError: null, - reviewableStatuses: [...REVIEWABLE_SUBMISSION_STATUSES], - reviewableFallbackReason: null, - recentStatuses: [], - recentStatusesError: null, - lastAction: null, - lastActionError: null, - }); const fetchReviewableSubmissions = async () => { const baseQuery = () => supabase @@ -311,35 +288,10 @@ export function AdminDashboard() { const { data: submissions, error: subError, - reviewableStatuses, - fallbackReason, } = await fetchReviewableSubmissions(); // Add debugging console.log('Admin Dashboard - Submissions query result:', { submissions, subError }); - - const { data: recentSubmissionStatuses, error: recentStatusesError } = await supabase - .from('submissions') - .select('status') - .order('submitted_at', { ascending: false, nullsFirst: false }) - .limit(10); - - console.log('Admin Dashboard - Recent submission statuses:', { - recentSubmissionStatuses, - recentStatusesError, - }); - - setSubmissionDebugInfo({ - reviewableCount: submissions?.length ?? 0, - reviewableError: subError?.message ?? null, - reviewableStatuses, - reviewableFallbackReason: fallbackReason, - recentStatuses: (recentSubmissionStatuses || []) - .map((submission: { status: string | null }) => submission.status ?? 'null'), - recentStatusesError: recentStatusesError?.message ?? null, - lastAction: null, - lastActionError: null, - }); // Fetch all challenges (for lookup) const { data: challengesData, error: chalError } = await supabase @@ -411,21 +363,10 @@ export function AdminDashboard() { }, []); const handleApproveSubmission = async (submissionId: string) => { - setSubmissionDebugInfo(prev => ({ - ...prev, - lastAction: `Approve started for ${submissionId}`, - lastActionError: null, - })); - const { data: authData, error: authError } = await supabase.auth.getUser(); const reviewerId = authData.user?.id; if (authError || !reviewerId) { - setSubmissionDebugInfo(prev => ({ - ...prev, - lastAction: `Approve failed for ${submissionId}`, - lastActionError: authError?.message || 'Admin user not found.', - })); toast({ title: "Failed to approve submission", description: authError?.message || 'Admin user not found.', @@ -441,11 +382,6 @@ export function AdminDashboard() { .maybeSingle(); if (latestSubmissionError || !latestSubmission) { - setSubmissionDebugInfo(prev => ({ - ...prev, - lastAction: `Approve failed for ${submissionId}`, - lastActionError: latestSubmissionError?.message || 'Submission not found.', - })); toast({ title: "Failed to approve submission", description: latestSubmissionError?.message || 'Submission not found.', @@ -455,11 +391,6 @@ export function AdminDashboard() { } if (latestSubmission.status === APPROVED_SUBMISSION_STATUS) { - setSubmissionDebugInfo(prev => ({ - ...prev, - lastAction: `Approve skipped for ${submissionId}`, - lastActionError: 'Submission already approved.', - })); toast({ title: "Submission already approved", description: "This submission was already approved in another session.", @@ -469,11 +400,6 @@ export function AdminDashboard() { } if (!isReviewableSubmissionStatus(latestSubmission.status)) { - setSubmissionDebugInfo(prev => ({ - ...prev, - lastAction: `Approve skipped for ${submissionId}`, - lastActionError: `Unexpected submission status: ${latestSubmission.status}`, - })); toast({ title: "Submission status changed", description: `Review skipped because the submission is now "${latestSubmission.status}".`, @@ -485,11 +411,6 @@ export function AdminDashboard() { const challenge = challenges.find((c: any) => c.id === latestSubmission.challenge_id); if (!challenge) { - setSubmissionDebugInfo(prev => ({ - ...prev, - lastAction: `Approve failed for ${submissionId}`, - lastActionError: 'Challenge metadata not found for this submission.', - })); toast({ title: "Failed to approve submission", description: 'Challenge metadata not found for this submission.', @@ -518,11 +439,6 @@ export function AdminDashboard() { }); if (updateError) { - setSubmissionDebugInfo(prev => ({ - ...prev, - lastAction: `Approve failed for ${submissionId}`, - lastActionError: `Submission update failed: ${updateError.message}`, - })); toast({ title: "Failed to approve submission", description: updateError.message, @@ -532,11 +448,6 @@ export function AdminDashboard() { } if (!approvedSubmission) { - setSubmissionDebugInfo(prev => ({ - ...prev, - lastAction: `Approve skipped for ${submissionId}`, - lastActionError: 'Submission update returned no row.', - })); toast({ title: "Submission status changed", description: "Approval skipped because the submission was updated by someone else.", @@ -553,12 +464,6 @@ export function AdminDashboard() { ? `${getErrorMessage(xpError, 'Unable to update user XP.')} Rollback also failed, so manual cleanup may be needed.` : `${getErrorMessage(xpError, 'Unable to update user XP.')} Submission approval was rolled back.`; - setSubmissionDebugInfo(prev => ({ - ...prev, - lastAction: `Approve failed for ${submissionId}`, - lastActionError: xpErrorMessage, - })); - toast({ title: "Failed to award XP", description: xpErrorMessage, @@ -582,14 +487,6 @@ export function AdminDashboard() { console.error('Failed to log interaction:', interactionError); } - setSubmissionDebugInfo(prev => ({ - ...prev, - lastAction: `Approve succeeded for ${submissionId}`, - lastActionError: interactionError?.message - ? `XP awarded and submission approved. Interaction log failed: ${interactionError.message}` - : null, - })); - toast({ title: "Submission approved", description: "The submission has been approved and the user has been awarded XP.", @@ -599,21 +496,10 @@ export function AdminDashboard() { }; const handleRejectSubmission = async (submissionId: string) => { - setSubmissionDebugInfo(prev => ({ - ...prev, - lastAction: `Reject started for ${submissionId}`, - lastActionError: null, - })); - const { data: authData, error: authError } = await supabase.auth.getUser(); const reviewerId = authData.user?.id; if (authError || !reviewerId) { - setSubmissionDebugInfo(prev => ({ - ...prev, - lastAction: `Reject failed for ${submissionId}`, - lastActionError: authError?.message || 'Admin user not found.', - })); toast({ title: "Failed to reject submission", description: authError?.message || 'Admin user not found.', @@ -629,11 +515,6 @@ export function AdminDashboard() { .maybeSingle<{ id: string; status: string }>(); if (latestSubmissionError || !latestSubmission) { - setSubmissionDebugInfo(prev => ({ - ...prev, - lastAction: `Reject failed for ${submissionId}`, - lastActionError: latestSubmissionError?.message || 'Submission not found.', - })); toast({ title: "Failed to reject submission", description: latestSubmissionError?.message || 'Submission not found.', @@ -643,11 +524,6 @@ export function AdminDashboard() { } if (!isReviewableSubmissionStatus(latestSubmission.status)) { - setSubmissionDebugInfo(prev => ({ - ...prev, - lastAction: `Reject skipped for ${submissionId}`, - lastActionError: `Unexpected submission status: ${latestSubmission.status}`, - })); toast({ title: "Submission status changed", description: `Reject skipped because the submission is now "${latestSubmission.status}".`, @@ -670,11 +546,6 @@ export function AdminDashboard() { }); if (error) { - setSubmissionDebugInfo(prev => ({ - ...prev, - lastAction: `Reject failed for ${submissionId}`, - lastActionError: error.message, - })); toast({ title: "Failed to reject submission", description: error.message, @@ -684,11 +555,6 @@ export function AdminDashboard() { } if (!rejectedSubmission) { - setSubmissionDebugInfo(prev => ({ - ...prev, - lastAction: `Reject skipped for ${submissionId}`, - lastActionError: 'Submission update returned no row.', - })); toast({ title: "Submission status changed", description: "Reject skipped because the submission was updated by someone else.", @@ -703,11 +569,6 @@ export function AdminDashboard() { description: "The submission has been rejected.", variant: "destructive", }); - setSubmissionDebugInfo(prev => ({ - ...prev, - lastAction: `Reject succeeded for ${submissionId}`, - lastActionError: null, - })); // Refresh pending submissions refreshPendingSubmissions(); }; @@ -1248,27 +1109,6 @@ export function AdminDashboard() {

Manage challenges, submissions, and users

- {import.meta.env.DEV && ( -
-
- Connected Supabase: {supabaseProjectRef} -
-
-
Reviewable submissions fetched: {submissionDebugInfo.reviewableCount}
-
Reviewable query error: {submissionDebugInfo.reviewableError ?? 'none'}
-
Reviewable statuses in use: {submissionDebugInfo.reviewableStatuses.join(', ')}
-
Reviewable fallback: {submissionDebugInfo.reviewableFallbackReason ?? 'none'}
-
- Recent submission statuses: {submissionDebugInfo.recentStatuses.length > 0 - ? submissionDebugInfo.recentStatuses.join(', ') - : 'none'} -
-
Recent statuses query error: {submissionDebugInfo.recentStatusesError ?? 'none'}
-
Last action: {submissionDebugInfo.lastAction ?? 'none'}
-
Last action error: {submissionDebugInfo.lastActionError ?? 'none'}
-
-
- )} {/* Stats Cards */}