diff --git a/src/app/actions/participants.ts b/src/app/actions/participants.ts index e3f0b7b..a94dae5 100644 --- a/src/app/actions/participants.ts +++ b/src/app/actions/participants.ts @@ -4,11 +4,13 @@ import db from '@/lib/db'; import { participant } from '@/lib/model/participant'; import { annotation } from '@/lib/model/annotation'; import { experiment } from '@/lib/model/experiment'; +import { dataset } from '@/lib/model/dataset'; import { dataset_entry } from '@/lib/model/dataset_entry'; import { user } from '@/lib/model/auth-schema'; import { and, eq, count } from 'drizzle-orm'; import { requireAdmin } from '@/lib/auth'; import { isParticipantCalibrationDone, getDialectScoresFromCalibration } from './calibration-scoring'; +import JSZip from 'jszip'; export interface ParticipantListItem { id: number; @@ -133,8 +135,18 @@ export async function getParticipantDetail( // Get participant const participants = await db - .select() + .select({ + id: participant.id, + experimentId: participant.experimentId, + userId: participant.userId, + email: user.email, + calibrationAnswers: participant.calibrationAnswers, + onboardingAnswers: participant.onboardingAnswers, + createdAt: participant.createdAt, + updatedAt: participant.updatedAt, + }) .from(participant) + .leftJoin(user, eq(participant.userId, user.id)) .where( and( eq(participant.experimentId, experimentId), @@ -177,6 +189,7 @@ export async function getParticipantDetail( id: p.id, experimentId: p.experimentId!, userId: p.userId, + email: p.email || 'Unknown', completedOnboarding: p.onboardingAnswers !== null && p.onboardingAnswers !== undefined, completedCalibration, onboardingAnswers: p.onboardingAnswers || {}, @@ -242,3 +255,198 @@ export async function getParticipantCalibrationScores( throw error; } } + +/** + * Export participant data as JSON + * Includes participant info, onboarding, calibration, calibration scores, and annotations + */ +export async function exportParticipantDataAsJson( + experimentId: number, + userId: string +): Promise<{ + jsonData: string; + filename: string; +}> { + const result = await requireAdmin(); + if (!result.authenticated || !result.admin) { + throw new Error('Unauthorized'); + } + + try { + // Get participant detail which includes all basic info and annotations + const participantDetail = await getParticipantDetail(experimentId, userId); + + // Get calibration scores if calibration is completed + let calibrationScores: Record | null = null; + if (participantDetail.completedCalibration) { + try { + calibrationScores = await getParticipantCalibrationScores(experimentId, userId); + } catch (err) { + console.warn('Could not fetch calibration scores:', err); + } + } + + // Create comprehensive export object + const exportData = { + exportedAt: new Date().toISOString(), + participant: { + id: participantDetail.id, + userId: participantDetail.userId, + experimentId: participantDetail.experimentId, + createdAt: participantDetail.createdAt, + updatedAt: participantDetail.updatedAt, + }, + progress: { + completedOnboarding: participantDetail.completedOnboarding, + completedCalibration: participantDetail.completedCalibration, + totalAnnotations: participantDetail.annotationCount, + }, + onboarding: { + completed: participantDetail.completedOnboarding, + answers: participantDetail.onboardingAnswers, + }, + calibration: { + completed: participantDetail.completedCalibration, + answers: participantDetail.calibrationAnswers, + scores: calibrationScores, + }, + annotations: participantDetail.annotations.map((ann) => ({ + id: ann.id, + externalId: ann.externalId, + fileName: ann.fileName, + datasetId: ann.datasetId, + rating: ann.rating, + dialectLabel: ann.dialectLabel, + createdAt: ann.createdAt, + })), + }; + + // Convert to JSON string with pretty formatting + const jsonData = JSON.stringify(exportData, null, 2); + + // Generate filename with timestamp + const timestamp = new Date().toISOString().slice(0, 10); + const filename = `participant-${userId}-${timestamp}.json`; + + return { + jsonData, + filename, + }; + } catch (error) { + console.error('Error exporting participant data:', error); + throw error; + } +} + +/** + * Export all participant data from an experiment as a ZIP file + * Returns a base64-encoded ZIP archive and a filename + */ +export async function exportAllParticipantDataAsZip(experimentId: number): Promise<{ + base64Data: string; + filename: string; +}> { + const result = await requireAdmin(); + if (!result.authenticated || !result.admin) { + throw new Error('Unauthorized'); + } + + try { + // Verify experiment exists + const exp = await db + .select() + .from(experiment) + .where(eq(experiment.id, experimentId)) + .limit(1); + + if (!exp || exp.length === 0) { + throw new Error('Experiment not found'); + } + + // Get all participants for this experiment + const participants = await getParticipantsList(experimentId); + + if (participants.length === 0) { + throw new Error('No participants found in this experiment'); + } + + // Create ZIP archive + const zip = new JSZip(); + const participantsFolder = zip.folder('participants'); + + if (!participantsFolder) { + throw new Error('Failed to create ZIP folder structure'); + } + + // Export each participant's data + for (const p of participants) { + try { + const { jsonData, filename } = await exportParticipantDataAsJson(experimentId, p.userId); + participantsFolder.file(filename, jsonData); + } catch (err) { + console.warn(`Failed to export data for participant ${p.userId}:`, err); + // Continue with next participant if one fails + } + } + + // Fetch dataset information + const datasetInfo = await db + .select() + .from(dataset) + .where(eq(dataset.id, exp[0].datasetId)) + .limit(1); + + // Count total entries in the dataset + const totalEntries = await db + .select({ count: count().as('count') }) + .from(dataset_entry) + .where(eq(dataset_entry.datasetId, exp[0].datasetId)); + + // Create summary file with metadata + const summary = { + exportedAt: new Date().toISOString(), + experimentId: experimentId, + experimentName: exp[0].name, + dataset: datasetInfo.length > 0 ? { + id: datasetInfo[0].id, + name: datasetInfo[0].name, + description: datasetInfo[0].description, + totalEntries: totalEntries[0]?.count || 0, + createdAt: datasetInfo[0].createdAt, + } : null, + totalParticipants: participants.length, + participants: participants.map((p) => ({ + userId: p.userId, + email: p.email, + joinedAt: p.createdAt, + completedOnboarding: p.completedOnboarding, + completedCalibration: p.completedCalibration, + annotationCount: p.annotationCount, + })), + }; + + zip.file( + 'SUMMARY.json', + JSON.stringify(summary, null, 2), + ); + + // Generate ZIP buffer + const buffer = await zip.generateAsync({ type: 'nodebuffer' }); + + // Generate filename with timestamp + const timestamp = new Date().toISOString().slice(0, 10); + const sanitizedExperimentName = exp[0].name.replace(/[^a-z0-9]/gi, '_').toLowerCase(); + const filename = `experiment-${sanitizedExperimentName}-${timestamp}.zip`; + + // Convert buffer to base64 string for client transfer + const base64Data = buffer.toString('base64'); + + return { + base64Data, + filename, + }; + } catch (error) { + console.error('Error exporting all participant data:', error); + throw error; + } +} diff --git a/src/app/admin/experiments/[id]/page.tsx b/src/app/admin/experiments/[id]/page.tsx index 13a6e4d..cf3ac46 100644 --- a/src/app/admin/experiments/[id]/page.tsx +++ b/src/app/admin/experiments/[id]/page.tsx @@ -8,6 +8,7 @@ import ClearExperimentDataButton from '@/components/ClearExperimentDataButton'; import UploadCalibrationModal from '@/components/UploadCalibrationModal'; import CalibrationListModal from '@/components/CalibrationListModal'; import ExperimentStatistics from '@/components/ExperimentStatistics'; +import ExportAllParticipantDataButton from '@/components/ExportAllParticipantDataButton'; import { requireAdmin } from '@/lib/auth'; import { redirect } from 'next/navigation'; @@ -71,10 +72,11 @@ export default async function ExperimentPage({ params }: ExperimentPageProps) {
View Participants + diff --git a/src/components/ExportAllParticipantDataButton.tsx b/src/components/ExportAllParticipantDataButton.tsx new file mode 100644 index 0000000..293ee24 --- /dev/null +++ b/src/components/ExportAllParticipantDataButton.tsx @@ -0,0 +1,58 @@ +'use client'; + +import { exportAllParticipantDataAsZip } from '@/app/actions/participants'; +import { useState } from 'react'; + +interface ExportAllParticipantDataButtonProps { + experimentId: number; +} + +export default function ExportAllParticipantDataButton({ + experimentId, +}: ExportAllParticipantDataButtonProps) { + const [isLoading, setIsLoading] = useState(false); + const [error, setError] = useState(null); + + const handleExport = async () => { + setIsLoading(true); + setError(null); + + try { + const { base64Data, filename } = await exportAllParticipantDataAsZip(experimentId); + + // Convert base64 back to Blob + const binaryString = atob(base64Data); + const bytes = new Uint8Array(binaryString.length); + for (let i = 0; i < binaryString.length; i++) { + bytes[i] = binaryString.charCodeAt(i); + } + const blob = new Blob([bytes], { type: 'application/zip' }); + const url = URL.createObjectURL(blob); + const link = document.createElement('a'); + link.href = url; + link.download = filename; + document.body.appendChild(link); + link.click(); + document.body.removeChild(link); + URL.revokeObjectURL(url); + } catch (err) { + setError(err instanceof Error ? err.message : 'Failed to export data'); + console.error('Export error:', err); + } finally { + setIsLoading(false); + } + }; + + return ( +
+ + {error &&
{error}
} +
+ ); +} diff --git a/src/components/ExportParticipantDataButton.tsx b/src/components/ExportParticipantDataButton.tsx new file mode 100644 index 0000000..c08145c --- /dev/null +++ b/src/components/ExportParticipantDataButton.tsx @@ -0,0 +1,55 @@ +'use client'; + +import { exportParticipantDataAsJson } from '@/app/actions/participants'; +import { useState } from 'react'; + +interface ExportParticipantDataButtonProps { + experimentId: number; + userId: string; +} + +export default function ExportParticipantDataButton({ + experimentId, + userId, +}: ExportParticipantDataButtonProps) { + const [isLoading, setIsLoading] = useState(false); + const [error, setError] = useState(null); + + const handleExport = async () => { + setIsLoading(true); + setError(null); + + try { + const { jsonData, filename } = await exportParticipantDataAsJson(experimentId, userId); + + // Create blob and trigger download + const blob = new Blob([jsonData], { type: 'application/json' }); + const url = URL.createObjectURL(blob); + const link = document.createElement('a'); + link.href = url; + link.download = filename; + document.body.appendChild(link); + link.click(); + document.body.removeChild(link); + URL.revokeObjectURL(url); + } catch (err) { + setError(err instanceof Error ? err.message : 'Failed to export data'); + console.error('Export error:', err); + } finally { + setIsLoading(false); + } + }; + + return ( +
+ + {error &&
{error}
} +
+ ); +} diff --git a/src/components/ParticipantDetailView.tsx b/src/components/ParticipantDetailView.tsx index 3fd8b45..b483f49 100644 --- a/src/components/ParticipantDetailView.tsx +++ b/src/components/ParticipantDetailView.tsx @@ -4,6 +4,7 @@ import { ParticipantDetail, getParticipantCalibrationScores } from '@/app/action import { useEffect, useState } from 'react'; import CalibrationScoresDisplay from './CalibrationScoresDisplay'; import AudioPlayer from './AudioPlayer'; +import ExportParticipantDataButton from './ExportParticipantDataButton'; interface ParticipantDetailViewProps { participant: ParticipantDetail; @@ -44,7 +45,10 @@ export default function ParticipantDetailView({ participant, experimentId }: Par
{/* Header Card */}
-

{participant.userId}

+
+

{participant.userId}

+ +
Status