Refactor API routes and enhance documentation; add collaboration features and user role management. Update environment example and improve error handling in authentication.

This commit is contained in:
2025-07-18 16:34:25 -04:00
parent 2dcd2a2832
commit 28ac7dd9e0
23 changed files with 7439 additions and 157 deletions

View File

@@ -0,0 +1,555 @@
import { z } from "zod";
import { eq, and, desc, asc, gte, lte, inArray, type SQL } from "drizzle-orm";
import { TRPCError } from "@trpc/server";
import { createTRPCRouter, protectedProcedure } from "~/server/api/trpc";
import {
annotations,
exportJobs,
trials,
experiments,
studyMembers,
exportStatusEnum,
} from "~/server/db/schema";
import type { db } from "~/server/db";
// Helper function to check if user has access to trial for analytics operations
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];
}
// Helper function to check study access for analytics
async function checkStudyAccess(
database: typeof db,
userId: string,
studyId: string,
requiredRoles: ("owner" | "researcher" | "wizard" | "observer")[] = [
"owner",
"researcher",
],
) {
const membership = await database
.select()
.from(studyMembers)
.where(
and(
eq(studyMembers.studyId, 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 study",
});
}
}
export const analyticsRouter = createTRPCRouter({
createAnnotation: protectedProcedure
.input(
z.object({
trialId: z.string(),
startTime: z.date(),
endTime: z.date().optional(),
category: z.string(),
label: z.string(),
description: z.string().optional(),
tags: z.array(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.trialId);
const annotationResults = await db
.insert(annotations)
.values({
trialId: input.trialId,
annotatorId: userId,
timestampStart: input.startTime,
timestampEnd: input.endTime,
category: input.category,
label: input.label,
description: input.description,
tags: input.tags,
metadata: input.metadata,
})
.returning();
const annotation = annotationResults[0];
if (!annotation) {
throw new TRPCError({
code: "INTERNAL_SERVER_ERROR",
message: "Failed to create annotation",
});
}
return annotation;
}),
updateAnnotation: protectedProcedure
.input(
z.object({
id: z.string(),
startTime: z.date().optional(),
endTime: z.date().optional(),
category: z.string().optional(),
label: z.string().optional(),
description: z.string().optional(),
tags: z.array(z.string()).optional(),
metadata: z.any().optional(),
}),
)
.mutation(async ({ ctx, input }) => {
const { db } = ctx;
const userId = ctx.session.user.id;
// Get annotation to check access
const existingAnnotation = await db
.select({
id: annotations.id,
trialId: annotations.trialId,
annotatorId: annotations.annotatorId,
})
.from(annotations)
.where(eq(annotations.id, input.id))
.limit(1);
if (!existingAnnotation[0]) {
throw new TRPCError({
code: "NOT_FOUND",
message: "Annotation not found",
});
}
// Check trial access
await checkTrialAccess(db, userId, existingAnnotation[0].trialId);
// Only allow annotation creator or study owners/researchers to edit
if (existingAnnotation[0].annotatorId !== userId) {
await checkTrialAccess(db, userId, existingAnnotation[0].trialId, [
"owner",
"researcher",
]);
}
const updateData: {
updatedAt: Date;
timestampStart?: Date;
timestampEnd?: Date;
category?: string;
label?: string;
description?: string;
tags?: string[];
metadata?: Record<string, unknown>;
} = {
updatedAt: new Date(),
};
if (input.startTime !== undefined)
updateData.timestampStart = input.startTime;
if (input.endTime !== undefined) updateData.timestampEnd = input.endTime;
if (input.category !== undefined) updateData.category = input.category;
if (input.label !== undefined) updateData.label = input.label;
if (input.description !== undefined)
updateData.description = input.description;
if (input.tags !== undefined) updateData.tags = input.tags;
if (input.metadata !== undefined) updateData.metadata = input.metadata as Record<string, unknown>;
const annotationResults = await db
.update(annotations)
.set(updateData)
.where(eq(annotations.id, input.id))
.returning();
const annotation = annotationResults[0];
if (!annotation) {
throw new TRPCError({
code: "INTERNAL_SERVER_ERROR",
message: "Failed to update annotation",
});
}
return annotation;
}),
deleteAnnotation: protectedProcedure
.input(
z.object({
id: z.string(),
}),
)
.mutation(async ({ ctx, input }) => {
const { db } = ctx;
const userId = ctx.session.user.id;
// Get annotation to check access
const existingAnnotation = await db
.select({
id: annotations.id,
trialId: annotations.trialId,
annotatorId: annotations.annotatorId,
})
.from(annotations)
.where(eq(annotations.id, input.id))
.limit(1);
if (!existingAnnotation[0]) {
throw new TRPCError({
code: "NOT_FOUND",
message: "Annotation not found",
});
}
// Check trial access
await checkTrialAccess(db, userId, existingAnnotation[0].trialId);
// Only allow annotation creator or study owners/researchers to delete
if (existingAnnotation[0].annotatorId !== userId) {
await checkTrialAccess(db, userId, existingAnnotation[0].trialId, [
"owner",
"researcher",
]);
}
await db.delete(annotations).where(eq(annotations.id, input.id));
return { success: true };
}),
getAnnotations: protectedProcedure
.input(
z.object({
trialId: z.string(),
category: z.string().optional(),
annotatorId: z.string().optional(),
startTime: z.date().optional(),
endTime: z.date().optional(),
tags: z.array(z.string()).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: SQL[] = [eq(annotations.trialId, input.trialId)];
if (input.category) {
conditions.push(eq(annotations.category, input.category));
}
if (input.annotatorId) {
conditions.push(eq(annotations.annotatorId, input.annotatorId));
}
if (input.startTime !== undefined) {
conditions.push(gte(annotations.timestampStart, input.startTime));
}
if (input.endTime !== undefined) {
conditions.push(lte(annotations.timestampEnd, input.endTime));
}
const rawResults = await db
.select()
.from(annotations)
.where(and(...conditions))
.orderBy(asc(annotations.timestampStart))
.limit(input.limit)
.offset(input.offset);
// Map to expected output format
const results = rawResults.map((annotation) => ({
id: annotation.id,
trialId: annotation.trialId,
annotatorId: annotation.annotatorId,
startTime: annotation.timestampStart,
endTime: annotation.timestampEnd,
category: annotation.category,
label: annotation.label,
description: annotation.description,
tags: annotation.tags as string[],
metadata: annotation.metadata,
createdAt: annotation.createdAt,
updatedAt: annotation.updatedAt,
}));
// Filter by tags if provided
if (input.tags && input.tags.length > 0) {
return results.filter((annotation) => {
if (!annotation.tags || !Array.isArray(annotation.tags)) return false;
return input.tags!.some((tag) =>
annotation.tags.includes(tag),
);
});
}
return results;
}),
exportData: protectedProcedure
.input(
z.object({
studyId: z.string(),
exportType: z.enum(["full", "trials", "analysis", "media"]),
format: z.enum(["csv", "json", "xlsx"]),
filters: z.any().optional(),
}),
)
.mutation(async ({ ctx, input }) => {
const { db } = ctx;
const userId = ctx.session.user.id;
await checkStudyAccess(db, userId, input.studyId);
// Create export job
const exportJobResults = await db
.insert(exportJobs)
.values({
studyId: input.studyId,
requestedBy: userId,
exportType: input.exportType,
format: input.format,
filters: input.filters,
status: "pending",
})
.returning();
const exportJob = exportJobResults[0];
if (!exportJob) {
throw new TRPCError({
code: "INTERNAL_SERVER_ERROR",
message: "Failed to create export job",
});
}
// TODO: Trigger background job to process export
// This would typically be handled by a queue system like Bull/BullMQ
// For now, we'll simulate the process
// Simulate processing time
// Capture variables for setTimeout closure
const jobId = exportJob.id;
const studyId = input.studyId;
const format = input.format;
const database = db;
setTimeout(() => {
// Mock file generation
const fileName = `study-${studyId}-export-${Date.now()}.${format}`;
const fileUrl = `https://mock-r2-bucket.com/exports/${fileName}`;
database
.update(exportJobs)
.set({
status: "completed",
storagePath: fileUrl,
completedAt: new Date(),
})
.where(eq(exportJobs.id, jobId))
.then(() => {
// Success handled
})
.catch((error: unknown) => {
database
.update(exportJobs)
.set({
status: "failed",
errorMessage:
error instanceof Error
? error.message
: "Export processing failed",
})
.where(eq(exportJobs.id, jobId))
.catch(() => {
// Error handling the error update - ignore for now
});
});
}, 5000); // 5 second delay
return {
jobId: exportJob.id,
status: exportJob.status,
estimatedCompletionTime: new Date(Date.now() + 30000), // 30 seconds
};
}),
getExportStatus: protectedProcedure
.input(
z.object({
jobId: z.string(),
}),
)
.query(async ({ ctx, input }) => {
const { db } = ctx;
const userId = ctx.session.user.id;
const exportJob = await db
.select({
id: exportJobs.id,
studyId: exportJobs.studyId,
requestedBy: exportJobs.requestedBy,
exportType: exportJobs.exportType,
format: exportJobs.format,
status: exportJobs.status,
storagePath: exportJobs.storagePath,
errorMessage: exportJobs.errorMessage,
filters: exportJobs.filters,
createdAt: exportJobs.createdAt,
completedAt: exportJobs.completedAt,
})
.from(exportJobs)
.where(eq(exportJobs.id, input.jobId))
.limit(1);
if (!exportJob[0]) {
throw new TRPCError({
code: "NOT_FOUND",
message: "Export job not found",
});
}
// Check user has access to the study
await checkStudyAccess(db, userId, exportJob[0].studyId);
return exportJob[0];
}),
getExportHistory: protectedProcedure
.input(
z.object({
studyId: z.string(),
status: z.enum(exportStatusEnum.enumValues).optional(),
limit: z.number().min(1).max(100).default(20),
offset: z.number().min(0).default(0),
}),
)
.query(async ({ ctx, input }) => {
const { db } = ctx;
const userId = ctx.session.user.id;
await checkStudyAccess(db, userId, input.studyId);
const conditions: SQL[] = [eq(exportJobs.studyId, input.studyId)];
if (input.status) {
conditions.push(eq(exportJobs.status, input.status));
}
const results = await db
.select()
.from(exportJobs)
.where(and(...conditions))
.orderBy(desc(exportJobs.createdAt))
.limit(input.limit)
.offset(input.offset);
return results;
}),
getTrialStatistics: protectedProcedure
.input(
z.object({
studyId: z.string(),
experimentId: z.string().optional(),
}),
)
.query(async ({ ctx, input }) => {
const { db } = ctx;
const userId = ctx.session.user.id;
await checkStudyAccess(db, userId, input.studyId);
// Get trial statistics
const conditions: SQL[] = [eq(experiments.studyId, input.studyId)];
if (input.experimentId) {
conditions.push(eq(trials.experimentId, input.experimentId));
}
const trialStats = await db
.select({
trial: trials,
experiment: experiments,
})
.from(trials)
.innerJoin(experiments, eq(trials.experimentId, experiments.id))
.where(and(...conditions));
// Calculate statistics
const stats = {
totalTrials: trialStats.length,
completedTrials: trialStats.filter((t) => t.trial.status === "completed")
.length,
runningTrials: trialStats.filter((t) => t.trial.status === "in_progress")
.length,
abortedTrials: trialStats.filter((t) => t.trial.status === "aborted").length,
avgDuration: 0,
totalDuration: 0,
};
const completedTrials = trialStats.filter(
(t) => t.trial.status === "completed" && t.trial.duration !== null,
);
if (completedTrials.length > 0) {
const durations = completedTrials.map((t) => t.trial.duration!);
stats.totalDuration = durations.reduce((sum, d) => sum + d, 0);
stats.avgDuration = stats.totalDuration / durations.length;
}
return stats;
}),
});