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'}
-
-
-
-
Solution URL:
-
-
-
-
-
-
-
+
+
+ {challenge?.difficulty || 'Unknown'}
+
+
+ {submission.status === 'pending_review' ? 'Pending Review' : 'Pending'}
+
+ {submission.submission_url ? (
+
+ ) : (
+
+
Submission Details:
+
+ {submission.submission_type === 'onboarding_complete'
+ ? 'Onboarding completion submitted for admin review.'
+ : 'No external submission URL was provided for this submission.'}
+
+
+ )}
handleApproveSubmission(submission.id)}
@@ -1746,4 +2109,4 @@ export function AdminDashboard() {
);
-}
\ 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