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 { 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<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;
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user