mirror of
https://github.com/soconnor0919/hristudio.git
synced 2026-03-24 03:37:51 -04:00
feat: Introduce dedicated participant, experiment, and trial detail/edit pages, enable MinIO, and refactor dashboard navigation.
This commit is contained in:
@@ -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,
|
||||
})),
|
||||
}));
|
||||
}),
|
||||
|
||||
|
||||
146
src/server/api/routers/files.ts
Normal file
146
src/server/api/routers/files.ts
Normal 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.
|
||||
}
|
||||
}),
|
||||
});
|
||||
@@ -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 };
|
||||
}),
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user