import { TRPCError } from "@trpc/server"; import { and, asc, count, desc, eq, gte, inArray, lte, sql, type SQL, } from "drizzle-orm"; import { z } from "zod"; import { createTRPCRouter, protectedProcedure } from "~/server/api/trpc"; import type { db } from "~/server/db"; import { experiments, participants, studyMembers, trialEvents, trials, trialStatusEnum, wizardInterventions, mediaCaptures, users, } from "~/server/db/schema"; // Helper function to check if user has access to trial async function checkTrialAccess( database: typeof db, userId: string, trialId: string, requiredRoles: ("owner" | "researcher" | "wizard" | "observer")[] = [ "owner", "researcher", "wizard", ], ) { const trial = await database .select({ id: trials.id, experimentId: trials.experimentId, studyId: experiments.studyId, }) .from(trials) .innerJoin(experiments, eq(trials.experimentId, experiments.id)) .where(eq(trials.id, trialId)) .limit(1); if (!trial[0]) { throw new TRPCError({ code: "NOT_FOUND", message: "Trial not found", }); } const membership = await database .select() .from(studyMembers) .where( and( eq(studyMembers.studyId, trial[0].studyId), eq(studyMembers.userId, userId), inArray(studyMembers.role, requiredRoles), ), ) .limit(1); if (!membership[0]) { throw new TRPCError({ code: "FORBIDDEN", message: "Insufficient permissions to access this trial", }); } return trial[0]; } export const trialsRouter = createTRPCRouter({ list: protectedProcedure .input( z.object({ studyId: z.string().optional(), experimentId: z.string().optional(), participantId: z.string().optional(), status: z.enum(trialStatusEnum.enumValues).optional(), limit: z.number().min(1).max(100).default(50), offset: z.number().min(0).default(0), }), ) .query(async ({ ctx, input }) => { const { db } = ctx; const userId = ctx.session.user.id; // Build query conditions const conditions: SQL[] = []; if (input.studyId) { conditions.push(eq(experiments.studyId, input.studyId)); } if (input.experimentId) { conditions.push(eq(trials.experimentId, input.experimentId)); } if (input.participantId) { conditions.push(eq(trials.participantId, input.participantId)); } if (input.status) { conditions.push(eq(trials.status, input.status)); } const query = db .select({ id: trials.id, participantId: trials.participantId, experimentId: trials.experimentId, status: trials.status, sessionNumber: trials.sessionNumber, scheduledAt: trials.scheduledAt, startedAt: trials.startedAt, completedAt: trials.completedAt, duration: trials.duration, notes: trials.notes, createdAt: trials.createdAt, updatedAt: trials.updatedAt, experiment: { id: experiments.id, name: experiments.name, studyId: experiments.studyId, }, participant: { id: participants.id, participantCode: participants.participantCode, }, wizard: { id: users.id, name: users.name, email: users.email, }, userRole: studyMembers.role, }) .from(trials) .innerJoin(experiments, eq(trials.experimentId, experiments.id)) .innerJoin(participants, eq(trials.participantId, participants.id)) .leftJoin(users, eq(users.id, trials.wizardId)) .innerJoin(studyMembers, eq(studyMembers.studyId, experiments.studyId)) .where(and(eq(studyMembers.userId, userId), ...conditions)) .orderBy(desc(trials.createdAt)) .limit(input.limit) .offset(input.offset); const results = await query; // Aggregate event & media counts (batched) const trialIds = results.map((r) => r.id); const eventCountMap = new Map(); const mediaCountMap = new Map(); const latestEventAtMap = new Map(); // Hoisted map for latest event timestamps so it is in scope after aggregation block // (removed redeclaration of latestEventAtMap; now hoisted above) if (trialIds.length > 0) { const eventCounts = await db .select({ trialId: trialEvents.trialId, count: count(), latest: sql`max(${trialEvents.timestamp})`.as("latest"), }) .from(trialEvents) .where(inArray(trialEvents.trialId, trialIds)) .groupBy(trialEvents.trialId); eventCounts.forEach((ec) => { eventCountMap.set(ec.trialId, Number(ec.count) || 0); if (ec.latest) { latestEventAtMap.set(ec.trialId, ec.latest as Date); } }); const mediaCounts = await db .select({ trialId: mediaCaptures.trialId, count: count(), }) .from(mediaCaptures) .where(inArray(mediaCaptures.trialId, trialIds)) .groupBy(mediaCaptures.trialId); mediaCounts.forEach((mc) => { mediaCountMap.set(mc.trialId, Number(mc.count) || 0); }); } // Add permission flags & counts return results.map((trial) => ({ ...trial, eventCount: eventCountMap.get(trial.id) ?? 0, mediaCount: mediaCountMap.get(trial.id) ?? 0, latestEventAt: latestEventAtMap.get(trial.id) ?? null, canAccess: ["owner", "researcher", "wizard"].includes(trial.userRole), })); }), get: protectedProcedure .input( z.object({ id: z.string(), }), ) .query(async ({ ctx, input }) => { const { db } = ctx; const userId = ctx.session.user.id; await checkTrialAccess(db, userId, input.id); const trial = await db .select({ id: trials.id, participantId: trials.participantId, experimentId: trials.experimentId, wizardId: trials.wizardId, sessionNumber: trials.sessionNumber, status: trials.status, scheduledAt: trials.scheduledAt, startedAt: trials.startedAt, completedAt: trials.completedAt, duration: trials.duration, notes: trials.notes, metadata: trials.metadata, createdAt: trials.createdAt, updatedAt: trials.updatedAt, experiment: { id: experiments.id, name: experiments.name, description: experiments.description, studyId: experiments.studyId, }, participant: { id: participants.id, participantCode: participants.participantCode, demographics: participants.demographics, }, }) .from(trials) .innerJoin(experiments, eq(trials.experimentId, experiments.id)) .innerJoin(participants, eq(trials.participantId, participants.id)) .where(eq(trials.id, input.id)) .limit(1); if (!trial[0]) { throw new TRPCError({ code: "NOT_FOUND", message: "Trial not found", }); } return trial[0]; }), create: protectedProcedure .input( z.object({ participantId: z.string(), experimentId: z.string(), scheduledAt: z.date().optional(), wizardId: z.string().optional(), sessionNumber: z.number().optional(), notes: z.string().optional(), metadata: z.any().optional(), }), ) .mutation(async ({ ctx, input }) => { const { db } = ctx; const userId = ctx.session.user.id; // Check if experiment exists and user has access const experiment = await db .select({ id: experiments.id, studyId: experiments.studyId, }) .from(experiments) .where(eq(experiments.id, input.experimentId)) .limit(1); if (!experiment[0]) { throw new TRPCError({ code: "NOT_FOUND", message: "Experiment not found", }); } // Check user access const membership = await db .select() .from(studyMembers) .where( and( eq(studyMembers.studyId, experiment[0].studyId), eq(studyMembers.userId, userId), inArray(studyMembers.role, ["owner", "researcher"]), ), ) .limit(1); if (!membership[0]) { throw new TRPCError({ code: "FORBIDDEN", message: "Insufficient permissions to create trial", }); } // Check if participant exists const participant = await db .select() .from(participants) .where(eq(participants.id, input.participantId)) .limit(1); if (!participant[0]) { throw new TRPCError({ code: "NOT_FOUND", message: "Participant not found", }); } // Create trial const [trial] = await db .insert(trials) .values({ participantId: input.participantId, experimentId: input.experimentId, scheduledAt: input.scheduledAt, wizardId: input.wizardId, sessionNumber: input.sessionNumber ?? 1, status: "scheduled", notes: input.notes, metadata: input.metadata, }) .returning(); return trial; }), update: protectedProcedure .input( z.object({ id: z.string(), scheduledAt: z.date().optional(), wizardId: z.string().optional(), sessionNumber: z.number().optional(), notes: z.string().optional(), metadata: z.any().optional(), }), ) .mutation(async ({ ctx, input }) => { const { db } = ctx; const userId = ctx.session.user.id; await checkTrialAccess(db, userId, input.id); const [trial] = await db .update(trials) .set({ scheduledAt: input.scheduledAt, wizardId: input.wizardId, sessionNumber: input.sessionNumber, notes: input.notes, metadata: input.metadata, updatedAt: sql`CURRENT_TIMESTAMP`, }) .where(eq(trials.id, input.id)) .returning(); return trial; }), start: protectedProcedure .input( z.object({ id: z.string(), }), ) .mutation(async ({ ctx, input }) => { const { db } = ctx; const userId = ctx.session.user.id; await checkTrialAccess(db, userId, input.id, [ "owner", "researcher", "wizard", ]); // Get current trial status const currentTrial = await db .select() .from(trials) .where(eq(trials.id, input.id)) .limit(1); if (!currentTrial[0]) { throw new TRPCError({ code: "NOT_FOUND", message: "Trial not found", }); } if (currentTrial[0].status !== "scheduled") { throw new TRPCError({ code: "BAD_REQUEST", message: "Trial can only be started from scheduled status", }); } // Start trial const [trial] = await db .update(trials) .set({ status: "in_progress", startedAt: new Date(), }) .where(eq(trials.id, input.id)) .returning(); // Log trial start event await db.insert(trialEvents).values({ trialId: input.id, eventType: "trial_started", timestamp: new Date(), data: { userId }, }); return trial; }), complete: protectedProcedure .input( z.object({ id: z.string(), notes: z.string().optional(), }), ) .mutation(async ({ ctx, input }) => { const { db } = ctx; const userId = ctx.session.user.id; await checkTrialAccess(db, userId, input.id, [ "owner", "researcher", "wizard", ]); const [trial] = await db .update(trials) .set({ status: "completed", completedAt: new Date(), notes: input.notes, }) .where(eq(trials.id, input.id)) .returning(); // Log trial completion event await db.insert(trialEvents).values({ trialId: input.id, eventType: "trial_completed", timestamp: new Date(), data: { userId, notes: input.notes }, }); return trial; }), abort: protectedProcedure .input( z.object({ id: z.string(), reason: z.string().optional(), }), ) .mutation(async ({ ctx, input }) => { const { db } = ctx; const userId = ctx.session.user.id; await checkTrialAccess(db, userId, input.id, [ "owner", "researcher", "wizard", ]); const [trial] = await db .update(trials) .set({ status: "aborted", completedAt: new Date(), }) .where(eq(trials.id, input.id)) .returning(); // Log trial abort event await db.insert(trialEvents).values({ trialId: input.id, eventType: "trial_aborted", timestamp: new Date(), data: { userId, reason: input.reason }, }); return trial; }), logEvent: protectedProcedure .input( z.object({ trialId: z.string(), type: z.string(), data: z.any().optional(), timestamp: z.date().optional(), }), ) .mutation(async ({ ctx, input }) => { const { db } = ctx; const userId = ctx.session.user.id; await checkTrialAccess(db, userId, input.trialId, [ "owner", "researcher", "wizard", ]); const [event] = await db .insert(trialEvents) .values({ trialId: input.trialId, eventType: input.type, timestamp: input.timestamp ?? new Date(), data: input.data, }) .returning(); return event; }), addIntervention: protectedProcedure .input( z.object({ trialId: z.string(), type: z.string(), description: z.string(), timestamp: z.date().optional(), data: z.any().optional(), }), ) .mutation(async ({ ctx, input }) => { const { db } = ctx; const userId = ctx.session.user.id; await checkTrialAccess(db, userId, input.trialId, [ "owner", "researcher", "wizard", ]); const [intervention] = await db .insert(wizardInterventions) .values({ trialId: input.trialId, wizardId: userId, interventionType: input.type, description: input.description, timestamp: input.timestamp ?? new Date(), parameters: input.data, }) .returning(); return intervention; }), getEvents: protectedProcedure .input( z.object({ trialId: z.string(), type: z.string().optional(), startTime: z.date().optional(), endTime: z.date().optional(), limit: z.number().min(1).max(1000).default(100), offset: z.number().min(0).default(0), }), ) .query(async ({ ctx, input }) => { const { db } = ctx; const userId = ctx.session.user.id; await checkTrialAccess(db, userId, input.trialId); const conditions = [eq(trialEvents.trialId, input.trialId)]; if (input.type) { conditions.push(eq(trialEvents.eventType, input.type)); } if (input.startTime) { conditions.push(gte(trialEvents.timestamp, input.startTime)); } if (input.endTime) { conditions.push(lte(trialEvents.timestamp, input.endTime)); } const events = await db .select() .from(trialEvents) .where(and(...conditions)) .orderBy(asc(trialEvents.timestamp)) .limit(input.limit) .offset(input.offset); return events; }), getUserTrials: protectedProcedure .input( z.object({ page: z.number().min(1).default(1), limit: z.number().min(1).max(100).default(20), status: z.enum(trialStatusEnum.enumValues).optional(), studyId: z.string().optional(), }), ) .query(async ({ ctx, input }) => { const { page, limit, status, studyId } = input; const offset = (page - 1) * limit; const userId = ctx.session.user.id; // Get all studies user is a member of with roles const userStudies = await ctx.db.query.studyMembers.findMany({ where: eq(studyMembers.userId, userId), columns: { studyId: true, role: true, }, }); let studyIds = userStudies.map((membership) => membership.studyId); const userStudyRoles = new Map( userStudies.map((membership) => [membership.studyId, membership.role]), ); // If studyId is provided, filter to just that study (if user has access) if (studyId) { if (!studyIds.includes(studyId)) { throw new TRPCError({ code: "FORBIDDEN", message: "You don't have access to this study", }); } studyIds = [studyId]; } if (studyIds.length === 0) { return { trials: [], pagination: { page, limit, total: 0, pages: 0, }, }; } // Build where conditions with study filtering const conditions = []; if (status) { conditions.push(eq(trials.status, status)); } // Get trials from experiments in user's studies using SQL join const userTrials = await ctx.db .select({ trial: trials, experiment: { id: experiments.id, name: experiments.name, studyId: experiments.studyId, }, }) .from(trials) .innerJoin(experiments, eq(trials.experimentId, experiments.id)) .where( and( inArray(experiments.studyId, studyIds), ...(conditions.length > 0 ? conditions : []), ), ) .limit(limit) .offset(offset) .orderBy(desc(trials.scheduledAt)); // Get full trial data with relations for the filtered trials const trialIds = userTrials.map((row) => row.trial.id); const filteredTrials = trialIds.length > 0 ? await ctx.db.query.trials.findMany({ where: inArray(trials.id, trialIds), with: { experiment: { with: { study: { columns: { id: true, name: true, }, }, }, columns: { id: true, name: true, studyId: true, }, }, participant: { columns: { id: true, participantCode: true, email: true, name: true, }, }, wizard: { columns: { id: true, name: true, email: true, }, }, events: { columns: { id: true, }, }, mediaCaptures: { columns: { id: true, }, }, }, orderBy: [desc(trials.scheduledAt)], }) : []; // Get total count const whereConditions = [inArray(experiments.studyId, studyIds)]; if (status) { whereConditions.push(eq(trials.status, status)); } const totalCountResult = await ctx.db .select({ count: count() }) .from(trials) .innerJoin(experiments, eq(trials.experimentId, experiments.id)) .where(and(...whereConditions)); const totalCount = totalCountResult[0]?.count ?? 0; // Transform data to include counts and permission information const transformedTrials = filteredTrials.map((trial) => { const userRole = userStudyRoles.get(trial.experiment.studyId); const canAccess = userRole && ["owner", "researcher", "wizard"].includes(userRole); return { ...trial, _count: { events: trial.events?.length ?? 0, mediaCaptures: trial.mediaCaptures?.length ?? 0, }, userRole, canAccess, }; }); return { trials: transformedTrials, pagination: { page, limit, total: totalCount, pages: Math.ceil(totalCount / limit), }, }; }), });