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 */}