import { redirect, error } from '@sveltejs/kit'; import { db } from '$lib/server/db/index.js'; import { audioFile, rating, participantProgress, participant, overallRating } from '$lib/server/db/schema.js'; import { eq, and, isNull } from 'drizzle-orm'; export async function load({ params, url, cookies }) { const audioId = params.id; const token = url.searchParams.get('token'); if (!token) { throw error(400, 'Invalid or missing invite token'); } const participantId = cookies.get(`participant-${token}`); if (!participantId) { redirect(302, `/participate?token=${token}`); } // Verify participant exists and isn't soft-deleted const participants = await db .select() .from(participant) .where( and( eq(participant.id, participantId), isNull(participant.deletedAt) ) ); if (participants.length === 0) { redirect(302, `/participate?token=${token}`); } const audioFiles = await db.select({ id: audioFile.id, filename: audioFile.filename, contentType: audioFile.contentType, duration: audioFile.duration, fileSize: audioFile.fileSize, createdAt: audioFile.createdAt }) .from(audioFile) .where(and( eq(audioFile.id, audioId), isNull(audioFile.deletedAt) // Only show active audio files )); if (audioFiles.length === 0) { throw error(404, 'Audio file not found'); } const progressData = await db .select({ id: participantProgress.id, isCompleted: participantProgress.isCompleted, lastPosition: participantProgress.lastPosition, maxReachedTime: participantProgress.maxReachedTime, updatedAt: participantProgress.updatedAt }) .from(participantProgress) .where( and( eq(participantProgress.participantId, participantId), eq(participantProgress.audioFileId, audioId) ) ); const progress = progressData.length > 0 ? progressData[0] : null; const existingRatings = await db .select() .from(rating) .where(and( eq(rating.participantId, participantId), eq(rating.audioFileId, audioId), isNull(rating.deletedAt) )); return { audioFile: audioFiles[0], participantId, token, progress, existingRatings }; } export const actions = { saveRating: async ({ request }) => { const data = await request.formData(); const participantId = data.get('participantId'); const audioFileId = data.get('audioFileId'); const ratingHistoryStr = data.get('ratingHistory'); const finalValue = parseFloat(data.get('finalValue')); const maxReachedTime = parseFloat(data.get('maxReachedTime')) || 0; const currentPosition = parseFloat(data.get('currentPosition')) || 0; if (!participantId || !audioFileId || !ratingHistoryStr || isNaN(finalValue)) { return { error: 'Invalid rating data' }; } try { const ratingHistory = JSON.parse(ratingHistoryStr); console.log('Rating history length:', ratingHistory.length); console.log('Rating history:', ratingHistory); // Use a transaction to ensure atomicity await db.transaction(async (tx) => { // Soft delete any existing ratings for this participant and audio file (for redo functionality) await tx.update(rating) .set({ deletedAt: new Date() }) .where( and( eq(rating.participantId, participantId), eq(rating.audioFileId, audioFileId), isNull(rating.deletedAt) ) ); // Save single completed rating record with entire timeseries as JSON const ratingId = `${participantId}-${audioFileId}-${Date.now()}`; const finalTimestamp = ratingHistory[ratingHistory.length - 1]?.timestamp || 0; await tx.insert(rating).values({ id: ratingId, participantId, audioFileId, timestamp: finalTimestamp, value: finalValue, isCompleted: true, timeseriesData: JSON.stringify(ratingHistory), // Store entire timeseries as JSON createdAt: new Date(), deletedAt: null // Active rating }); }); // Save listening progress (only when rating is saved) const progressId = `${participantId}-${audioFileId}`; const existingProgress = await db .select() .from(participantProgress) .where( and( eq(participantProgress.participantId, participantId), eq(participantProgress.audioFileId, audioFileId) ) ); if (existingProgress.length > 0) { await db .update(participantProgress) .set({ lastPosition: currentPosition, maxReachedTime: maxReachedTime, isCompleted: true, // Mark as completed when rating is saved updatedAt: new Date() }) .where(eq(participantProgress.id, existingProgress[0].id)); } else { await db.insert(participantProgress).values({ id: progressId, participantId, audioFileId, lastPosition: currentPosition, maxReachedTime: maxReachedTime, isCompleted: true, updatedAt: new Date() }); } return { success: true }; } catch (error) { console.error('Error saving rating:', error); return { error: 'Failed to save rating' }; } }, deleteRating: async ({ request }) => { const data = await request.formData(); const participantId = data.get('participantId'); const audioFileId = data.get('audioFileId'); if (!participantId || !audioFileId) { return { error: 'Invalid request data' }; } try { // Soft delete the active rating for this participant and audio file await db.update(rating) .set({ deletedAt: new Date() }) .where( and( eq(rating.participantId, participantId), eq(rating.audioFileId, audioFileId), isNull(rating.deletedAt) ) ); return { success: true }; } catch (error) { console.error('Error deleting rating:', error); return { error: 'Failed to delete rating' }; } }, updateProgress: async ({ request }) => { const data = await request.formData(); const participantId = data.get('participantId'); const audioFileId = data.get('audioFileId'); const lastPosition = parseFloat(data.get('lastPosition')); const isCompleted = data.get('isCompleted') === 'true'; if (!participantId || !audioFileId || isNaN(lastPosition)) { return { error: 'Invalid progress data' }; } try { const progressId = `${participantId}-${audioFileId}`; const existingProgress = await db .select() .from(participantProgress) .where( and( eq(participantProgress.participantId, participantId), eq(participantProgress.audioFileId, audioFileId) ) ); if (existingProgress.length > 0) { await db .update(participantProgress) .set({ lastPosition, isCompleted, updatedAt: new Date() }) .where(eq(participantProgress.id, existingProgress[0].id)); } else { await db.insert(participantProgress).values({ id: progressId, participantId, audioFileId, isCompleted, lastPosition, updatedAt: new Date() }); } return { success: true }; } catch (error) { console.error('Error updating progress:', error); return { error: 'Failed to update progress' }; } }, saveOverallRating: async ({ request }) => { const data = await request.formData(); const participantId = data.get('participantId'); const audioFileId = data.get('audioFileId'); const overallRatingValue = parseFloat(data.get('overallRating')); if (!participantId || !audioFileId || isNaN(overallRatingValue)) { return { error: 'Invalid overall rating data' }; } try { const overallRatingId = `${participantId}-${audioFileId}-overall-${Date.now()}`; const now = new Date(); // Use a transaction to ensure atomicity (same pattern as saveRating) await db.transaction(async (tx) => { // Soft delete any existing overall ratings for this participant and audio file (for redo functionality) await tx.update(overallRating) .set({ deletedAt: now }) .where( and( eq(overallRating.participantId, participantId), eq(overallRating.audioFileId, audioFileId), isNull(overallRating.deletedAt) ) ); // Always insert new overall rating record await tx.insert(overallRating).values({ id: overallRatingId, participantId, audioFileId, value: overallRatingValue, createdAt: now, updatedAt: now, deletedAt: null }); }); return { success: true }; } catch (error) { console.error('Error saving overall rating:', error); return { error: 'Failed to save overall rating' }; } } };