feat: Introduce dedicated participant, experiment, and trial detail/edit pages, enable MinIO, and refactor dashboard navigation.

This commit is contained in:
2025-12-11 20:04:52 -05:00
parent 5be4ff0372
commit d83c02759a
45 changed files with 4123 additions and 1455 deletions

View File

@@ -4,6 +4,7 @@ import { authRouter } from "~/server/api/routers/auth";
import { collaborationRouter } from "~/server/api/routers/collaboration";
import { dashboardRouter } from "~/server/api/routers/dashboard";
import { experimentsRouter } from "~/server/api/routers/experiments";
import { filesRouter } from "~/server/api/routers/files";
import { mediaRouter } from "~/server/api/routers/media";
import { participantsRouter } from "~/server/api/routers/participants";
import { robotsRouter } from "~/server/api/routers/robots";
@@ -25,6 +26,7 @@ export const appRouter = createTRPCRouter({
participants: participantsRouter,
trials: trialsRouter,
robots: robotsRouter,
files: filesRouter,
media: mediaRouter,
analytics: analyticsRouter,
collaboration: collaborationRouter,

View File

@@ -1542,6 +1542,15 @@ export const experimentsRouter = createTRPCRouter({
parameters: step.conditions as Record<string, unknown>,
parentId: undefined, // Not supported in current schema
children: [], // TODO: implement hierarchical steps if needed
actions: step.actions.map((action) => ({
id: action.id,
name: action.name,
description: action.description,
type: action.type,
order: action.orderIndex,
parameters: action.parameters as Record<string, unknown>,
pluginId: action.pluginId,
})),
}));
}),

View File

@@ -0,0 +1,146 @@
import { z } from "zod";
import { createTRPCRouter, protectedProcedure } from "~/server/api/trpc";
import { participantDocuments } from "~/server/db/schema";
import { TRPCError } from "@trpc/server";
import { env } from "~/env";
import * as Minio from "minio";
import { uuid } from "drizzle-orm/pg-core";
import { eq, desc } from "drizzle-orm";
// Initialize MinIO client
// Note: In production, ensure these ENV vars are set.
// For development with docker-compose, we use localhost:9000
const minioClient = new Minio.Client({
endPoint: (env.MINIO_ENDPOINT ?? "localhost").split(":")[0] ?? "localhost",
port: parseInt((env.MINIO_ENDPOINT ?? "9000").split(":")[1] ?? "9000"),
useSSL: false, // Default to false for local dev; adjust for prod
accessKey: env.MINIO_ACCESS_KEY ?? "minioadmin",
secretKey: env.MINIO_SECRET_KEY ?? "minioadmin",
});
const BUCKET_NAME = env.MINIO_BUCKET_NAME ?? "hristudio-assets";
// Ensure bucket exists on startup (best effort)
const ensureBucket = async () => {
try {
const exists = await minioClient.bucketExists(BUCKET_NAME);
if (!exists) {
await minioClient.makeBucket(BUCKET_NAME, env.MINIO_REGION ?? "us-east-1");
// Set public policy if needed? For now, keep private and use presigned URLs.
}
} catch (e) {
console.error("Error ensuring MinIO bucket exists:", e);
}
}
void ensureBucket(); // Fire and forget on load
export const filesRouter = createTRPCRouter({
// Get a presigned URL for uploading a file
getPresignedUrl: protectedProcedure
.input(z.object({
filename: z.string(),
contentType: z.string(),
participantId: z.string(),
}))
.mutation(async ({ input }) => {
const fileExtension = input.filename.split(".").pop();
const uniqueFilename = `${input.participantId}/${crypto.randomUUID()}.${fileExtension}`;
try {
const presignedUrl = await minioClient.presignedPutObject(
BUCKET_NAME,
uniqueFilename,
60 * 5 // 5 minutes expiry
);
return {
url: presignedUrl,
storagePath: uniqueFilename, // Pass this back to client to save in DB after upload
};
} catch (error) {
console.error("Error generating presigned URL:", error);
throw new TRPCError({
code: "INTERNAL_SERVER_ERROR",
message: "Failed to generate upload URL",
});
}
}),
// Get a presigned URL for downloading/viewing a file
getDownloadUrl: protectedProcedure
.input(z.object({
storagePath: z.string(),
}))
.query(async ({ input }) => {
try {
const url = await minioClient.presignedGetObject(
BUCKET_NAME,
input.storagePath,
60 * 60 // 1 hour
);
return { url };
} catch (error) {
throw new TRPCError({
code: "NOT_FOUND",
message: "File not found or storage error",
});
}
}),
// Record a successful upload in the database
registerUpload: protectedProcedure
.input(z.object({
participantId: z.string(),
name: z.string(),
type: z.string().optional(),
storagePath: z.string(),
fileSize: z.number().optional(),
}))
.mutation(async ({ ctx, input }) => {
await ctx.db.insert(participantDocuments).values({
participantId: input.participantId,
name: input.name,
type: input.type,
storagePath: input.storagePath,
fileSize: input.fileSize,
uploadedBy: ctx.session.user.id,
});
}),
// List documents for a participant
listParticipantDocuments: protectedProcedure
.input(z.object({ participantId: z.string() }))
.query(async ({ ctx, input }) => {
return await ctx.db.query.participantDocuments.findMany({
where: eq(participantDocuments.participantId, input.participantId),
orderBy: [desc(participantDocuments.createdAt)],
with: {
// Optional: join with uploader info if needed
}
});
}),
// Delete a document
deleteDocument: protectedProcedure
.input(z.object({ id: z.string() }))
.mutation(async ({ ctx, input }) => {
const doc = await ctx.db.query.participantDocuments.findFirst({
where: eq(participantDocuments.id, input.id),
});
if (!doc) {
throw new TRPCError({ code: "NOT_FOUND", message: "Document not found" });
}
// Delete from database
await ctx.db.delete(participantDocuments).where(eq(participantDocuments.id, input.id));
// Delete from MinIO (fire and forget or await)
try {
await minioClient.removeObject(BUCKET_NAME, doc.storagePath);
} catch (e) {
console.error("Failed to delete object from S3:", e);
// We still consider the operation successful for the user as the DB record is gone.
}
}),
});

View File

@@ -24,6 +24,7 @@ import {
wizardInterventions,
mediaCaptures,
users,
annotations,
} from "~/server/db/schema";
import {
TrialExecutionEngine,
@@ -263,7 +264,22 @@ export const trialsRouter = createTRPCRouter({
});
}
return trial[0];
// Fetch additional stats
const eventCount = await db
.select({ count: count() })
.from(trialEvents)
.where(eq(trialEvents.trialId, input.id));
const mediaCount = await db
.select({ count: count() })
.from(mediaCaptures)
.where(eq(mediaCaptures.trialId, input.id));
return {
...trial[0],
eventCount: eventCount[0]?.count ?? 0,
mediaCount: mediaCount[0]?.count ?? 0,
};
}),
create: protectedProcedure
@@ -384,6 +400,58 @@ export const trialsRouter = createTRPCRouter({
return trial;
}),
duplicate: 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 source trial
const sourceTrial = await db
.select()
.from(trials)
.where(eq(trials.id, input.id))
.limit(1);
if (!sourceTrial[0]) {
throw new TRPCError({
code: "NOT_FOUND",
message: "Source trial not found",
});
}
// Create new trial based on source
const [newTrial] = await db
.insert(trials)
.values({
experimentId: sourceTrial[0].experimentId,
participantId: sourceTrial[0].participantId,
// Scheduled for now + 1 hour by default, or null? Let's use null or source time?
// New duplicate usually implies "planning to run soon".
// I'll leave scheduledAt null or same as source if future?
// Let's set it to tomorrow by default to avoid confusion
scheduledAt: new Date(Date.now() + 24 * 60 * 60 * 1000),
wizardId: sourceTrial[0].wizardId,
sessionNumber: (sourceTrial[0].sessionNumber || 0) + 1, // Increment session
status: "scheduled",
notes: `Duplicate of trial ${sourceTrial[0].id}. ${sourceTrial[0].notes || ""}`,
metadata: sourceTrial[0].metadata,
})
.returning();
return newTrial;
}),
start: protectedProcedure
.input(
z.object({
@@ -414,10 +482,15 @@ export const trialsRouter = createTRPCRouter({
});
}
// Idempotency: If already in progress, return success
if (currentTrial[0].status === "in_progress") {
return currentTrial[0];
}
if (currentTrial[0].status !== "scheduled") {
throw new TRPCError({
code: "BAD_REQUEST",
message: "Trial can only be started from scheduled status",
message: `Trial is in ${currentTrial[0].status} status and cannot be started`,
});
}
@@ -599,6 +672,61 @@ export const trialsRouter = createTRPCRouter({
return intervention;
}),
addAnnotation: protectedProcedure
.input(
z.object({
trialId: z.string(),
category: z.string().optional(),
label: z.string().optional(),
description: z.string().optional(),
timestampStart: z.date().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, [
"owner",
"researcher",
"wizard",
]);
const [annotation] = await db
.insert(annotations)
.values({
trialId: input.trialId,
annotatorId: userId,
category: input.category,
label: input.label,
description: input.description,
timestampStart: input.timestampStart ?? new Date(),
tags: input.tags,
metadata: input.metadata,
})
.returning();
// Also create a trial event so it appears in the timeline
if (annotation) {
await db.insert(trialEvents).values({
trialId: input.trialId,
eventType: `annotation_${input.category || 'note'}`,
timestamp: input.timestampStart ?? new Date(),
data: {
annotationId: annotation.id,
description: input.description,
category: input.category,
label: input.label,
tags: input.tags,
},
});
}
return annotation;
}),
getEvents: protectedProcedure
.input(
z.object({
@@ -725,51 +853,51 @@ export const trialsRouter = createTRPCRouter({
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,
},
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,
},
columns: {
id: true,
name: true,
studyId: true,
},
},
orderBy: [desc(trials.scheduledAt)],
})
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
@@ -967,4 +1095,46 @@ export const trialsRouter = createTRPCRouter({
duration: result.duration,
};
}),
logRobotAction: protectedProcedure
.input(
z.object({
trialId: z.string(),
pluginName: z.string(),
actionId: z.string(),
parameters: z.record(z.string(), z.unknown()).optional().default({}),
duration: z.number().optional(),
result: z.any().optional(),
error: z.string().optional(),
}),
)
.mutation(async ({ ctx, input }) => {
const { db } = ctx;
const userId = ctx.session.user.id;
await checkTrialAccess(db, userId, input.trialId, [
"owner",
"researcher",
"wizard",
]);
await db.insert(trialEvents).values({
trialId: input.trialId,
eventType: "manual_robot_action",
data: {
userId,
pluginName: input.pluginName,
actionId: input.actionId,
parameters: input.parameters,
result: input.result,
duration: input.duration,
error: input.error,
executionMode: "websocket_client",
},
timestamp: new Date(),
createdBy: userId,
});
return { success: true };
}),
});