diff --git a/src/pages/AdminDashboard.tsx b/src/pages/AdminDashboard.tsx index f74012a..d659853 100644 --- a/src/pages/AdminDashboard.tsx +++ b/src/pages/AdminDashboard.tsx @@ -37,6 +37,86 @@ 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; +}; + +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 [newChallenge, setNewChallenge] = useState({ title: '', @@ -66,14 +146,149 @@ export function AdminDashboard() { const [loadingSubmissions, setLoadingSubmissions] = useState(true); const [userProfiles, setUserProfiles] = useState<{[key: string]: any}>({}); + 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, + } = await fetchReviewableSubmissions(); // Add debugging console.log('Admin Dashboard - Submissions query result:', { submissions, subError }); @@ -105,19 +320,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,16 +363,81 @@ 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 + const { data: authData, error: authError } = await supabase.auth.getUser(); + const reviewerId = authData.user?.id; + + if (authError || !reviewerId) { + 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) { + toast({ + title: "Failed to approve submission", + description: latestSubmissionError?.message || 'Submission not found.', + variant: "destructive", + }); + return; + } + + if (latestSubmission.status === APPROVED_SUBMISSION_STATUS) { + toast({ + title: "Submission already approved", + description: "This submission was already approved in another session.", + }); + await refreshPendingSubmissions(); + return; + } + + if (!isReviewableSubmissionStatus(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) { + 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) { toast({ title: "Failed to approve submission", @@ -144,40 +446,37 @@ 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) { 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.`; + 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, @@ -197,10 +496,55 @@ export function AdminDashboard() { }; const handleRejectSubmission = async (submissionId: string) => { - const { error } = await supabase + const { data: authData, error: authError } = await supabase.auth.getUser(); + const reviewerId = authData.user?.id; + + if (authError || !reviewerId) { + 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) { + toast({ + title: "Failed to reject submission", + description: latestSubmissionError?.message || 'Submission not found.', + variant: "destructive", + }); + return; + } + + if (!isReviewableSubmissionStatus(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) { toast({ title: "Failed to reject submission", @@ -209,6 +553,17 @@ export function AdminDashboard() { }); return; } + + if (!rejectedSubmission) { + 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.", @@ -221,16 +576,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); } @@ -860,21 +1207,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