feat: added initial export of participant/annotation-data
This commit is contained in:
@@ -4,11 +4,13 @@ import db from '@/lib/db';
|
|||||||
import { participant } from '@/lib/model/participant';
|
import { participant } from '@/lib/model/participant';
|
||||||
import { annotation } from '@/lib/model/annotation';
|
import { annotation } from '@/lib/model/annotation';
|
||||||
import { experiment } from '@/lib/model/experiment';
|
import { experiment } from '@/lib/model/experiment';
|
||||||
|
import { dataset } from '@/lib/model/dataset';
|
||||||
import { dataset_entry } from '@/lib/model/dataset_entry';
|
import { dataset_entry } from '@/lib/model/dataset_entry';
|
||||||
import { user } from '@/lib/model/auth-schema';
|
import { user } from '@/lib/model/auth-schema';
|
||||||
import { and, eq, count } from 'drizzle-orm';
|
import { and, eq, count } from 'drizzle-orm';
|
||||||
import { requireAdmin } from '@/lib/auth';
|
import { requireAdmin } from '@/lib/auth';
|
||||||
import { isParticipantCalibrationDone, getDialectScoresFromCalibration } from './calibration-scoring';
|
import { isParticipantCalibrationDone, getDialectScoresFromCalibration } from './calibration-scoring';
|
||||||
|
import JSZip from 'jszip';
|
||||||
|
|
||||||
export interface ParticipantListItem {
|
export interface ParticipantListItem {
|
||||||
id: number;
|
id: number;
|
||||||
@@ -133,8 +135,18 @@ export async function getParticipantDetail(
|
|||||||
|
|
||||||
// Get participant
|
// Get participant
|
||||||
const participants = await db
|
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)
|
.from(participant)
|
||||||
|
.leftJoin(user, eq(participant.userId, user.id))
|
||||||
.where(
|
.where(
|
||||||
and(
|
and(
|
||||||
eq(participant.experimentId, experimentId),
|
eq(participant.experimentId, experimentId),
|
||||||
@@ -177,6 +189,7 @@ export async function getParticipantDetail(
|
|||||||
id: p.id,
|
id: p.id,
|
||||||
experimentId: p.experimentId!,
|
experimentId: p.experimentId!,
|
||||||
userId: p.userId,
|
userId: p.userId,
|
||||||
|
email: p.email || 'Unknown',
|
||||||
completedOnboarding: p.onboardingAnswers !== null && p.onboardingAnswers !== undefined,
|
completedOnboarding: p.onboardingAnswers !== null && p.onboardingAnswers !== undefined,
|
||||||
completedCalibration,
|
completedCalibration,
|
||||||
onboardingAnswers: p.onboardingAnswers || {},
|
onboardingAnswers: p.onboardingAnswers || {},
|
||||||
@@ -242,3 +255,198 @@ export async function getParticipantCalibrationScores(
|
|||||||
throw error;
|
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<string, number> | 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import ClearExperimentDataButton from '@/components/ClearExperimentDataButton';
|
|||||||
import UploadCalibrationModal from '@/components/UploadCalibrationModal';
|
import UploadCalibrationModal from '@/components/UploadCalibrationModal';
|
||||||
import CalibrationListModal from '@/components/CalibrationListModal';
|
import CalibrationListModal from '@/components/CalibrationListModal';
|
||||||
import ExperimentStatistics from '@/components/ExperimentStatistics';
|
import ExperimentStatistics from '@/components/ExperimentStatistics';
|
||||||
|
import ExportAllParticipantDataButton from '@/components/ExportAllParticipantDataButton';
|
||||||
import { requireAdmin } from '@/lib/auth';
|
import { requireAdmin } from '@/lib/auth';
|
||||||
import { redirect } from 'next/navigation';
|
import { redirect } from 'next/navigation';
|
||||||
|
|
||||||
@@ -71,10 +72,11 @@ export default async function ExperimentPage({ params }: ExperimentPageProps) {
|
|||||||
<div className="flex gap-3 mb-6 flex-wrap">
|
<div className="flex gap-3 mb-6 flex-wrap">
|
||||||
<Link
|
<Link
|
||||||
href={`/admin/experiments/${experimentId}/participants`}
|
href={`/admin/experiments/${experimentId}/participants`}
|
||||||
className="px-4 py-2 bg-indigo-600 text-white rounded-lg hover:bg-indigo-700 font-medium transition-colors"
|
className="px-4 py-2 bg-indigo-600 text-white rounded-lg hover:bg-indigo-700 transition-colors"
|
||||||
>
|
>
|
||||||
View Participants
|
View Participants
|
||||||
</Link>
|
</Link>
|
||||||
|
<ExportAllParticipantDataButton experimentId={experimentId} />
|
||||||
<DeleteExperimentButton experimentId={experimentId} experimentName={exp.name} />
|
<DeleteExperimentButton experimentId={experimentId} experimentName={exp.name} />
|
||||||
<ClearExperimentDataButton experimentId={experimentId} experimentName={exp.name} />
|
<ClearExperimentDataButton experimentId={experimentId} experimentName={exp.name} />
|
||||||
<UploadCalibrationModal experimentId={experimentId} />
|
<UploadCalibrationModal experimentId={experimentId} />
|
||||||
|
|||||||
58
src/components/ExportAllParticipantDataButton.tsx
Normal file
58
src/components/ExportAllParticipantDataButton.tsx
Normal file
@@ -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<string | null>(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 (
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
<button
|
||||||
|
onClick={handleExport}
|
||||||
|
disabled={isLoading}
|
||||||
|
className="px-4 py-2 bg-green-600 text-white rounded-lg hover:bg-green-700 disabled:bg-gray-400 disabled:cursor-not-allowed transition-colors"
|
||||||
|
>
|
||||||
|
{isLoading ? 'Exporting...' : 'Export all Data as ZIP'}
|
||||||
|
</button>
|
||||||
|
{error && <div className="text-red-600 text-sm">{error}</div>}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
55
src/components/ExportParticipantDataButton.tsx
Normal file
55
src/components/ExportParticipantDataButton.tsx
Normal file
@@ -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<string | null>(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 (
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
<button
|
||||||
|
onClick={handleExport}
|
||||||
|
disabled={isLoading}
|
||||||
|
className="px-3 py-2 bg-blue-600 text-white rounded hover:bg-blue-700 disabled:bg-gray-400 disabled:cursor-not-allowed transition-colors text-sm font-medium"
|
||||||
|
>
|
||||||
|
{isLoading ? 'Exporting...' : 'Export as JSON'}
|
||||||
|
</button>
|
||||||
|
{error && <div className="text-red-600 text-sm">{error}</div>}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -4,6 +4,7 @@ import { ParticipantDetail, getParticipantCalibrationScores } from '@/app/action
|
|||||||
import { useEffect, useState } from 'react';
|
import { useEffect, useState } from 'react';
|
||||||
import CalibrationScoresDisplay from './CalibrationScoresDisplay';
|
import CalibrationScoresDisplay from './CalibrationScoresDisplay';
|
||||||
import AudioPlayer from './AudioPlayer';
|
import AudioPlayer from './AudioPlayer';
|
||||||
|
import ExportParticipantDataButton from './ExportParticipantDataButton';
|
||||||
|
|
||||||
interface ParticipantDetailViewProps {
|
interface ParticipantDetailViewProps {
|
||||||
participant: ParticipantDetail;
|
participant: ParticipantDetail;
|
||||||
@@ -44,7 +45,10 @@ export default function ParticipantDetailView({ participant, experimentId }: Par
|
|||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
{/* Header Card */}
|
{/* Header Card */}
|
||||||
<div className="bg-white rounded-lg shadow p-6">
|
<div className="bg-white rounded-lg shadow p-6">
|
||||||
<h2 className="text-2xl font-bold text-gray-900 mb-4">{participant.userId}</h2>
|
<div className="flex justify-between items-start mb-4">
|
||||||
|
<h2 className="text-2xl font-bold text-gray-900">{participant.userId}</h2>
|
||||||
|
<ExportParticipantDataButton experimentId={experimentId} userId={participant.userId} />
|
||||||
|
</div>
|
||||||
<div className="grid grid-cols-1 md:grid-cols-4 gap-4">
|
<div className="grid grid-cols-1 md:grid-cols-4 gap-4">
|
||||||
<div className="bg-blue-50 rounded p-4">
|
<div className="bg-blue-50 rounded p-4">
|
||||||
<div className="text-sm text-gray-600 mb-1">Status</div>
|
<div className="text-sm text-gray-600 mb-1">Status</div>
|
||||||
|
|||||||
Reference in New Issue
Block a user