mirror of
https://github.com/soconnor0919/hristudio.git
synced 2026-03-23 19:27:51 -04:00
feat: Implement digital signatures for participant consent and introduce study forms management.
This commit is contained in:
@@ -4,7 +4,12 @@ import { z } from "zod";
|
||||
import { createTRPCRouter, protectedProcedure } from "~/server/api/trpc";
|
||||
import type { db } from "~/server/db";
|
||||
import {
|
||||
annotations, experiments, exportJobs, exportStatusEnum, studyMembers, trials
|
||||
annotations,
|
||||
experiments,
|
||||
exportJobs,
|
||||
exportStatusEnum,
|
||||
studyMembers,
|
||||
trials,
|
||||
} from "~/server/db/schema";
|
||||
|
||||
// Helper function to check if user has access to trial for analytics operations
|
||||
@@ -91,16 +96,16 @@ async function checkStudyAccess(
|
||||
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(),
|
||||
}),
|
||||
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;
|
||||
@@ -136,16 +141,16 @@ export const analyticsRouter = createTRPCRouter({
|
||||
|
||||
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(),
|
||||
}),
|
||||
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;
|
||||
@@ -201,7 +206,8 @@ export const analyticsRouter = createTRPCRouter({
|
||||
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>;
|
||||
if (input.metadata !== undefined)
|
||||
updateData.metadata = input.metadata as Record<string, unknown>;
|
||||
|
||||
const annotationResults = await db
|
||||
.update(annotations)
|
||||
@@ -266,16 +272,16 @@ export const analyticsRouter = createTRPCRouter({
|
||||
|
||||
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),
|
||||
}),
|
||||
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;
|
||||
@@ -326,9 +332,7 @@ export const analyticsRouter = createTRPCRouter({
|
||||
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 input.tags!.some((tag) => annotation.tags.includes(tag));
|
||||
});
|
||||
}
|
||||
|
||||
@@ -337,12 +341,12 @@ export const analyticsRouter = createTRPCRouter({
|
||||
|
||||
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(),
|
||||
}),
|
||||
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;
|
||||
@@ -399,15 +403,15 @@ export const analyticsRouter = createTRPCRouter({
|
||||
// Success handled
|
||||
})
|
||||
.catch((error: unknown) => {
|
||||
database
|
||||
.update(exportJobs)
|
||||
.set({
|
||||
status: "failed",
|
||||
errorMessage:
|
||||
error instanceof Error
|
||||
? error.message
|
||||
: "Export processing failed",
|
||||
})
|
||||
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
|
||||
@@ -526,11 +530,14 @@ export const analyticsRouter = createTRPCRouter({
|
||||
// Calculate statistics
|
||||
const stats = {
|
||||
totalTrials: trialStats.length,
|
||||
completedTrials: trialStats.filter((t) => t.trial.status === "completed")
|
||||
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,
|
||||
runningTrials: trialStats.filter((t) => t.trial.status === "in_progress")
|
||||
.length,
|
||||
abortedTrials: trialStats.filter((t) => t.trial.status === "aborted").length,
|
||||
avgDuration: 0,
|
||||
totalDuration: 0,
|
||||
};
|
||||
|
||||
@@ -4,7 +4,9 @@ import { eq } from "drizzle-orm";
|
||||
import { z } from "zod";
|
||||
|
||||
import {
|
||||
createTRPCRouter, protectedProcedure, publicProcedure
|
||||
createTRPCRouter,
|
||||
protectedProcedure,
|
||||
publicProcedure,
|
||||
} from "~/server/api/trpc";
|
||||
import { users } from "~/server/db/schema";
|
||||
|
||||
|
||||
@@ -4,7 +4,12 @@ import { z } from "zod";
|
||||
import { createTRPCRouter, protectedProcedure } from "~/server/api/trpc";
|
||||
import type { db } from "~/server/db";
|
||||
import {
|
||||
attachments, comments, experiments, sharedResources, studyMembers, trials
|
||||
attachments,
|
||||
comments,
|
||||
experiments,
|
||||
sharedResources,
|
||||
studyMembers,
|
||||
trials,
|
||||
} from "~/server/db/schema";
|
||||
|
||||
// Helper function to check if user has access to a resource
|
||||
@@ -412,7 +417,9 @@ export const collaborationRouter = createTRPCRouter({
|
||||
z.object({
|
||||
resourceType: z.enum(["study", "experiment", "trial"]),
|
||||
resourceId: z.string(),
|
||||
permissions: z.array(z.enum(["read", "comment", "annotate"])).default(["read"]),
|
||||
permissions: z
|
||||
.array(z.enum(["read", "comment", "annotate"]))
|
||||
.default(["read"]),
|
||||
expiresAt: z.date().optional(),
|
||||
description: z.string().optional(),
|
||||
}),
|
||||
@@ -535,7 +542,9 @@ export const collaborationRouter = createTRPCRouter({
|
||||
}
|
||||
|
||||
// Delete the share
|
||||
await db.delete(sharedResources).where(eq(sharedResources.id, input.shareId));
|
||||
await db
|
||||
.delete(sharedResources)
|
||||
.where(eq(sharedResources.id, input.shareId));
|
||||
|
||||
return { success: true };
|
||||
}),
|
||||
@@ -573,7 +582,10 @@ export const collaborationRouter = createTRPCRouter({
|
||||
}
|
||||
|
||||
// Check if the share has expired
|
||||
if (sharedResource[0].expiresAt && sharedResource[0].expiresAt < new Date()) {
|
||||
if (
|
||||
sharedResource[0].expiresAt &&
|
||||
sharedResource[0].expiresAt < new Date()
|
||||
) {
|
||||
throw new TRPCError({
|
||||
code: "NOT_FOUND",
|
||||
message: "Share link has expired",
|
||||
|
||||
@@ -41,19 +41,25 @@ export const dashboardRouter = createTRPCRouter({
|
||||
// Build where conditions
|
||||
const whereConditions = input.studyId
|
||||
? and(
|
||||
eq(experiments.studyId, input.studyId),
|
||||
inArray(
|
||||
trialEvents.eventType,
|
||||
['trial_started', 'trial_completed', 'intervention', 'error', 'annotation']
|
||||
eq(experiments.studyId, input.studyId),
|
||||
inArray(trialEvents.eventType, [
|
||||
"trial_started",
|
||||
"trial_completed",
|
||||
"intervention",
|
||||
"error",
|
||||
"annotation",
|
||||
]),
|
||||
)
|
||||
)
|
||||
: and(
|
||||
inArray(experiments.studyId, studyIds),
|
||||
inArray(
|
||||
trialEvents.eventType,
|
||||
['trial_started', 'trial_completed', 'intervention', 'error', 'annotation']
|
||||
)
|
||||
);
|
||||
inArray(experiments.studyId, studyIds),
|
||||
inArray(trialEvents.eventType, [
|
||||
"trial_started",
|
||||
"trial_completed",
|
||||
"intervention",
|
||||
"error",
|
||||
"annotation",
|
||||
]),
|
||||
);
|
||||
|
||||
// Get recent interesting trial events
|
||||
const activities = await ctx.db
|
||||
@@ -93,7 +99,12 @@ export const dashboardRouter = createTRPCRouter({
|
||||
title: title,
|
||||
description: description,
|
||||
time: activity.timestamp,
|
||||
status: activity.type === "error" ? "error" : activity.type === "trial_completed" ? "success" : "info" as const,
|
||||
status:
|
||||
activity.type === "error"
|
||||
? "error"
|
||||
: activity.type === "trial_completed"
|
||||
? "success"
|
||||
: ("info" as const),
|
||||
data: activity.data,
|
||||
trialId: activity.trialId,
|
||||
};
|
||||
@@ -120,8 +131,14 @@ export const dashboardRouter = createTRPCRouter({
|
||||
if (studyIds.length === 0) return [];
|
||||
|
||||
const whereConditions = input.studyId
|
||||
? and(eq(experiments.studyId, input.studyId), eq(trials.status, "in_progress"))
|
||||
: and(inArray(experiments.studyId, studyIds), eq(trials.status, "in_progress"));
|
||||
? and(
|
||||
eq(experiments.studyId, input.studyId),
|
||||
eq(trials.status, "in_progress"),
|
||||
)
|
||||
: and(
|
||||
inArray(experiments.studyId, studyIds),
|
||||
eq(trials.status, "in_progress"),
|
||||
);
|
||||
|
||||
const live = await ctx.db
|
||||
.select({
|
||||
@@ -154,10 +171,10 @@ export const dashboardRouter = createTRPCRouter({
|
||||
// Build where conditions
|
||||
const whereConditions = input.studyId
|
||||
? and(
|
||||
eq(studyMembers.userId, userId),
|
||||
eq(studies.status, "active"),
|
||||
eq(studies.id, input.studyId),
|
||||
)
|
||||
eq(studyMembers.userId, userId),
|
||||
eq(studies.status, "active"),
|
||||
eq(studies.id, input.studyId),
|
||||
)
|
||||
: and(eq(studyMembers.userId, userId), eq(studies.status, "active"));
|
||||
|
||||
// Get studies the user has access to with participant counts
|
||||
@@ -183,19 +200,19 @@ export const dashboardRouter = createTRPCRouter({
|
||||
const trialCounts =
|
||||
studyIds.length > 0
|
||||
? await ctx.db
|
||||
.select({
|
||||
studyId: experiments.studyId,
|
||||
completedTrials: count(trials.id),
|
||||
})
|
||||
.from(experiments)
|
||||
.innerJoin(trials, eq(experiments.id, trials.experimentId))
|
||||
.where(
|
||||
and(
|
||||
inArray(experiments.studyId, studyIds),
|
||||
eq(trials.status, "completed"),
|
||||
),
|
||||
)
|
||||
.groupBy(experiments.studyId)
|
||||
.select({
|
||||
studyId: experiments.studyId,
|
||||
completedTrials: count(trials.id),
|
||||
})
|
||||
.from(experiments)
|
||||
.innerJoin(trials, eq(experiments.id, trials.experimentId))
|
||||
.where(
|
||||
and(
|
||||
inArray(experiments.studyId, studyIds),
|
||||
eq(trials.status, "completed"),
|
||||
),
|
||||
)
|
||||
.groupBy(experiments.studyId)
|
||||
: [];
|
||||
|
||||
const trialCountMap = new Map(
|
||||
@@ -211,9 +228,9 @@ export const dashboardRouter = createTRPCRouter({
|
||||
const progress =
|
||||
totalParticipants > 0
|
||||
? Math.min(
|
||||
100,
|
||||
Math.round((completedTrials / totalParticipants) * 100),
|
||||
)
|
||||
100,
|
||||
Math.round((completedTrials / totalParticipants) * 100),
|
||||
)
|
||||
: 0;
|
||||
|
||||
return {
|
||||
@@ -396,10 +413,10 @@ export const dashboardRouter = createTRPCRouter({
|
||||
return {
|
||||
user: user
|
||||
? {
|
||||
id: user.id,
|
||||
email: user.email,
|
||||
name: user.name,
|
||||
}
|
||||
id: user.id,
|
||||
email: user.email,
|
||||
name: user.name,
|
||||
}
|
||||
: null,
|
||||
systemRoles: systemRoles.map((r) => r.role),
|
||||
studyMemberships: studyMemberships.map((m) => ({
|
||||
|
||||
@@ -369,24 +369,24 @@ export const experimentsRouter = createTRPCRouter({
|
||||
|
||||
const executionGraphSummary = stepsArray
|
||||
? {
|
||||
steps: stepsArray.length,
|
||||
actions: stepsArray.reduce((total, step) => {
|
||||
const acts = step.actions;
|
||||
return (
|
||||
total +
|
||||
(Array.isArray(acts)
|
||||
? acts.reduce(
|
||||
(aTotal, a) =>
|
||||
aTotal +
|
||||
(Array.isArray(a?.actions) ? a.actions.length : 0),
|
||||
0,
|
||||
)
|
||||
: 0)
|
||||
);
|
||||
}, 0),
|
||||
generatedAt: eg?.generatedAt ?? null,
|
||||
version: eg?.version ?? null,
|
||||
}
|
||||
steps: stepsArray.length,
|
||||
actions: stepsArray.reduce((total, step) => {
|
||||
const acts = step.actions;
|
||||
return (
|
||||
total +
|
||||
(Array.isArray(acts)
|
||||
? acts.reduce(
|
||||
(aTotal, a) =>
|
||||
aTotal +
|
||||
(Array.isArray(a?.actions) ? a.actions.length : 0),
|
||||
0,
|
||||
)
|
||||
: 0)
|
||||
);
|
||||
}, 0),
|
||||
generatedAt: eg?.generatedAt ?? null,
|
||||
version: eg?.version ?? null,
|
||||
}
|
||||
: null;
|
||||
|
||||
const convertedSteps = convertDatabaseToSteps(experiment.steps);
|
||||
@@ -490,9 +490,8 @@ export const experimentsRouter = createTRPCRouter({
|
||||
"researcher",
|
||||
]);
|
||||
|
||||
const { parseVisualDesignSteps } = await import(
|
||||
"~/lib/experiment-designer/visual-design-guard"
|
||||
);
|
||||
const { parseVisualDesignSteps } =
|
||||
await import("~/lib/experiment-designer/visual-design-guard");
|
||||
const { steps: guardedSteps, issues } = parseVisualDesignSteps(
|
||||
visualDesign.steps,
|
||||
);
|
||||
@@ -523,7 +522,8 @@ export const experimentsRouter = createTRPCRouter({
|
||||
return {
|
||||
valid: false,
|
||||
issues: [
|
||||
`Compilation failed: ${err instanceof Error ? err.message : "Unknown error"
|
||||
`Compilation failed: ${
|
||||
err instanceof Error ? err.message : "Unknown error"
|
||||
}`,
|
||||
],
|
||||
pluginDependencies: [],
|
||||
@@ -552,13 +552,13 @@ export const experimentsRouter = createTRPCRouter({
|
||||
integrityHash: compiledGraph?.hash ?? null,
|
||||
compiled: compiledGraph
|
||||
? {
|
||||
steps: compiledGraph.steps.length,
|
||||
actions: compiledGraph.steps.reduce(
|
||||
(acc, s) => acc + s.actions.length,
|
||||
0,
|
||||
),
|
||||
transportSummary: summarizeTransports(compiledGraph.steps),
|
||||
}
|
||||
steps: compiledGraph.steps.length,
|
||||
actions: compiledGraph.steps.reduce(
|
||||
(acc, s) => acc + s.actions.length,
|
||||
0,
|
||||
),
|
||||
transportSummary: summarizeTransports(compiledGraph.steps),
|
||||
}
|
||||
: null,
|
||||
};
|
||||
}),
|
||||
@@ -581,7 +581,11 @@ export const experimentsRouter = createTRPCRouter({
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
const { id, createSteps, compileExecution, ...updateData } = input;
|
||||
const userId = ctx.session.user.id;
|
||||
console.log("[DEBUG] experiments.update called", { id, visualDesign: updateData.visualDesign, createSteps });
|
||||
console.log("[DEBUG] experiments.update called", {
|
||||
id,
|
||||
visualDesign: updateData.visualDesign,
|
||||
createSteps,
|
||||
});
|
||||
|
||||
// Get experiment to check study access
|
||||
const experiment = await ctx.db.query.experiments.findFirst({
|
||||
@@ -610,9 +614,8 @@ export const experimentsRouter = createTRPCRouter({
|
||||
if (createSteps && updateData.visualDesign?.steps) {
|
||||
try {
|
||||
// Parse & normalize steps using visual design guard
|
||||
const { parseVisualDesignSteps } = await import(
|
||||
"~/lib/experiment-designer/visual-design-guard"
|
||||
);
|
||||
const { parseVisualDesignSteps } =
|
||||
await import("~/lib/experiment-designer/visual-design-guard");
|
||||
const { steps: guardedSteps, issues } = parseVisualDesignSteps(
|
||||
updateData.visualDesign.steps,
|
||||
);
|
||||
@@ -649,10 +652,11 @@ export const experimentsRouter = createTRPCRouter({
|
||||
} catch (compileErr) {
|
||||
throw new TRPCError({
|
||||
code: "BAD_REQUEST",
|
||||
message: `Execution graph compilation failed: ${compileErr instanceof Error
|
||||
? compileErr.message
|
||||
: "Unknown error"
|
||||
}`,
|
||||
message: `Execution graph compilation failed: ${
|
||||
compileErr instanceof Error
|
||||
? compileErr.message
|
||||
: "Unknown error"
|
||||
}`,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -746,13 +750,17 @@ export const experimentsRouter = createTRPCRouter({
|
||||
|
||||
const updatedExperiment = updatedExperimentResults[0];
|
||||
if (!updatedExperiment) {
|
||||
console.error("[DEBUG] Failed to update experiment - no result returned");
|
||||
console.error(
|
||||
"[DEBUG] Failed to update experiment - no result returned",
|
||||
);
|
||||
throw new TRPCError({
|
||||
code: "INTERNAL_SERVER_ERROR",
|
||||
message: "Failed to update experiment",
|
||||
});
|
||||
}
|
||||
console.log("[DEBUG] Experiment updated successfully", { updatedAt: updatedExperiment.updatedAt });
|
||||
console.log("[DEBUG] Experiment updated successfully", {
|
||||
updatedAt: updatedExperiment.updatedAt,
|
||||
});
|
||||
|
||||
// Log activity
|
||||
await ctx.db.insert(activityLogs).values({
|
||||
|
||||
@@ -11,136 +11,150 @@ import { eq, desc } from "drizzle-orm";
|
||||
const minioUrl = new URL(env.MINIO_ENDPOINT ?? "http://localhost:9000");
|
||||
|
||||
const minioClient = new Minio.Client({
|
||||
endPoint: minioUrl.hostname,
|
||||
port: parseInt(minioUrl.port) || 9000,
|
||||
useSSL: minioUrl.protocol === "https:",
|
||||
accessKey: env.MINIO_ACCESS_KEY ?? "minioadmin",
|
||||
secretKey: env.MINIO_SECRET_KEY ?? "minioadmin",
|
||||
endPoint: minioUrl.hostname,
|
||||
port: parseInt(minioUrl.port) || 9000,
|
||||
useSSL: minioUrl.protocol === "https:",
|
||||
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);
|
||||
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}`;
|
||||
// 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
|
||||
);
|
||||
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",
|
||||
});
|
||||
}
|
||||
}),
|
||||
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",
|
||||
});
|
||||
}
|
||||
}),
|
||||
// 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,
|
||||
});
|
||||
}),
|
||||
// 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
|
||||
}
|
||||
});
|
||||
}),
|
||||
// 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),
|
||||
});
|
||||
// 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" });
|
||||
}
|
||||
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 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.
|
||||
}
|
||||
}),
|
||||
// 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.
|
||||
}
|
||||
}),
|
||||
});
|
||||
|
||||
@@ -4,11 +4,11 @@ import { z } from "zod";
|
||||
import { createTRPCRouter, protectedProcedure } from "~/server/api/trpc";
|
||||
import type { db } from "~/server/db";
|
||||
import {
|
||||
experiments,
|
||||
mediaCaptures,
|
||||
sensorData,
|
||||
studyMembers,
|
||||
trials
|
||||
experiments,
|
||||
mediaCaptures,
|
||||
sensorData,
|
||||
studyMembers,
|
||||
trials,
|
||||
} from "~/server/db/schema";
|
||||
|
||||
// Helper function to check if user has access to trial for media operations
|
||||
@@ -43,7 +43,10 @@ async function checkTrialAccess(
|
||||
and(
|
||||
eq(studyMembers.studyId, trial[0].studyId),
|
||||
eq(studyMembers.userId, userId),
|
||||
inArray(studyMembers.role, requiredRoles as ("owner" | "researcher" | "wizard" | "observer")[]),
|
||||
inArray(
|
||||
studyMembers.role,
|
||||
requiredRoles as ("owner" | "researcher" | "wizard" | "observer")[],
|
||||
),
|
||||
),
|
||||
)
|
||||
.limit(1);
|
||||
@@ -217,13 +220,18 @@ export const mediaRouter = createTRPCRouter({
|
||||
conditions.push(eq(mediaCaptures.trialId, input.trialId));
|
||||
}
|
||||
|
||||
if (input.type) {
|
||||
conditions.push(eq(mediaCaptures.mediaType, input.type));
|
||||
}
|
||||
if (input.type) {
|
||||
conditions.push(eq(mediaCaptures.mediaType, input.type));
|
||||
}
|
||||
|
||||
const whereClause = and(
|
||||
eq(studyMembers.userId, userId),
|
||||
inArray(studyMembers.role, ["owner", "researcher", "wizard"] as ("owner" | "researcher" | "wizard" | "observer")[]),
|
||||
inArray(studyMembers.role, ["owner", "researcher", "wizard"] as (
|
||||
| "owner"
|
||||
| "researcher"
|
||||
| "wizard"
|
||||
| "observer"
|
||||
)[]),
|
||||
...conditions,
|
||||
);
|
||||
|
||||
@@ -290,8 +298,8 @@ export const mediaRouter = createTRPCRouter({
|
||||
// For now, return the stored file path
|
||||
return {
|
||||
url: media[0].storagePath,
|
||||
fileName: media[0].storagePath.split('/').pop() ?? 'unknown',
|
||||
contentType: media[0].format ?? 'application/octet-stream',
|
||||
fileName: media[0].storagePath.split("/").pop() ?? "unknown",
|
||||
contentType: media[0].format ?? "application/octet-stream",
|
||||
expiresAt: new Date(Date.now() + 60 * 60 * 1000), // 1 hour
|
||||
};
|
||||
}),
|
||||
@@ -346,8 +354,8 @@ export const mediaRouter = createTRPCRouter({
|
||||
trialId: z.string(),
|
||||
sensorType: z.string(),
|
||||
timestamp: z.date(),
|
||||
data: z.any(),
|
||||
metadata: z.any().optional(),
|
||||
data: z.any(),
|
||||
metadata: z.any().optional(),
|
||||
}),
|
||||
)
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
|
||||
@@ -5,7 +5,12 @@ import { z } from "zod";
|
||||
import { createTRPCRouter, protectedProcedure } from "~/server/api/trpc";
|
||||
import type { db } from "~/server/db";
|
||||
import {
|
||||
activityLogs, consentForms, participantConsents, participants, studyMembers, trials
|
||||
activityLogs,
|
||||
consentForms,
|
||||
participantConsents,
|
||||
participants,
|
||||
studyMembers,
|
||||
trials,
|
||||
} from "~/server/db/schema";
|
||||
import { getUploadUrl, validateFile } from "~/lib/storage/minio";
|
||||
|
||||
@@ -133,6 +138,24 @@ export const participantsRouter = createTRPCRouter({
|
||||
};
|
||||
}),
|
||||
|
||||
getNextCode: protectedProcedure
|
||||
.input(z.object({ studyId: z.string().uuid() }))
|
||||
.query(async ({ ctx, input }) => {
|
||||
const { studyId } = input;
|
||||
const userId = ctx.session.user.id;
|
||||
|
||||
await checkStudyAccess(ctx.db, userId, studyId);
|
||||
|
||||
const totalCountResult = await ctx.db
|
||||
.select({ count: count() })
|
||||
.from(participants)
|
||||
.where(eq(participants.studyId, studyId));
|
||||
|
||||
const totalCount = totalCountResult[0]?.count ?? 0;
|
||||
|
||||
return `P${totalCount.toString().padStart(2, "0")}`;
|
||||
}),
|
||||
|
||||
get: protectedProcedure
|
||||
.input(z.object({ id: z.string().uuid() }))
|
||||
.query(async ({ ctx, input }) => {
|
||||
@@ -185,7 +208,7 @@ export const participantsRouter = createTRPCRouter({
|
||||
z.object({
|
||||
studyId: z.string().uuid(),
|
||||
participantCode: z.string().min(1).max(50),
|
||||
email: z.string().email().optional(),
|
||||
email: z.string().email().optional().or(z.literal("")),
|
||||
name: z.string().max(255).optional(),
|
||||
demographics: z.any().optional(),
|
||||
}),
|
||||
@@ -267,7 +290,7 @@ export const participantsRouter = createTRPCRouter({
|
||||
z.object({
|
||||
id: z.string().uuid(),
|
||||
participantCode: z.string().min(1).max(50).optional(),
|
||||
email: z.string().email().optional(),
|
||||
email: z.string().email().optional().or(z.literal("")),
|
||||
name: z.string().max(255).optional(),
|
||||
demographics: z.any().optional(),
|
||||
notes: z.string().optional(),
|
||||
@@ -424,14 +447,18 @@ export const participantsRouter = createTRPCRouter({
|
||||
filename: z.string(),
|
||||
contentType: z.string(),
|
||||
size: z.number().max(10 * 1024 * 1024), // 10MB limit
|
||||
})
|
||||
}),
|
||||
)
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
const { studyId, participantId, filename, contentType, size } = input;
|
||||
const userId = ctx.session.user.id;
|
||||
|
||||
// Check study access with researcher permission
|
||||
await checkStudyAccess(ctx.db, userId, studyId, ["owner", "researcher", "wizard"]);
|
||||
await checkStudyAccess(ctx.db, userId, studyId, [
|
||||
"owner",
|
||||
"researcher",
|
||||
"wizard",
|
||||
]);
|
||||
|
||||
// Validate file type
|
||||
const allowedTypes = ["pdf", "png", "jpg", "jpeg"];
|
||||
@@ -463,7 +490,13 @@ export const participantsRouter = createTRPCRouter({
|
||||
}),
|
||||
)
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
const { participantId, consentFormId, signatureData, ipAddress, storagePath } = input;
|
||||
const {
|
||||
participantId,
|
||||
consentFormId,
|
||||
signatureData,
|
||||
ipAddress,
|
||||
storagePath,
|
||||
} = input;
|
||||
const userId = ctx.session.user.id;
|
||||
|
||||
// Get participant to check study access
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
|
||||
import { z } from "zod";
|
||||
import { createTRPCRouter, protectedProcedure } from "~/server/api/trpc";
|
||||
import { s3Client } from "~/server/storage";
|
||||
@@ -10,62 +9,62 @@ import { db } from "~/server/db";
|
||||
import { mediaCaptures } from "~/server/db/schema";
|
||||
|
||||
export const storageRouter = createTRPCRouter({
|
||||
getUploadPresignedUrl: protectedProcedure
|
||||
.input(
|
||||
z.object({
|
||||
filename: z.string(),
|
||||
contentType: z.string(),
|
||||
})
|
||||
)
|
||||
.mutation(async ({ input }) => {
|
||||
const bucket = env.MINIO_BUCKET_NAME ?? "hristudio-data";
|
||||
const key = input.filename;
|
||||
getUploadPresignedUrl: protectedProcedure
|
||||
.input(
|
||||
z.object({
|
||||
filename: z.string(),
|
||||
contentType: z.string(),
|
||||
}),
|
||||
)
|
||||
.mutation(async ({ input }) => {
|
||||
const bucket = env.MINIO_BUCKET_NAME ?? "hristudio-data";
|
||||
const key = input.filename;
|
||||
|
||||
try {
|
||||
const command = new PutObjectCommand({
|
||||
Bucket: bucket,
|
||||
Key: key,
|
||||
ContentType: input.contentType,
|
||||
});
|
||||
try {
|
||||
const command = new PutObjectCommand({
|
||||
Bucket: bucket,
|
||||
Key: key,
|
||||
ContentType: input.contentType,
|
||||
});
|
||||
|
||||
const url = await getSignedUrl(s3Client, command, { expiresIn: 3600 });
|
||||
const url = await getSignedUrl(s3Client, command, { expiresIn: 3600 });
|
||||
|
||||
return {
|
||||
url,
|
||||
key,
|
||||
bucket,
|
||||
};
|
||||
} catch (error) {
|
||||
console.error("Error generating presigned URL:", error);
|
||||
throw new TRPCError({
|
||||
code: "INTERNAL_SERVER_ERROR",
|
||||
message: "Failed to generate upload URL",
|
||||
});
|
||||
}
|
||||
}),
|
||||
saveRecording: protectedProcedure
|
||||
.input(
|
||||
z.object({
|
||||
trialId: z.string(),
|
||||
storagePath: z.string(),
|
||||
fileSize: z.number().optional(),
|
||||
format: z.string().optional(),
|
||||
mediaType: z.enum(["video", "audio", "image"]).default("video"),
|
||||
})
|
||||
)
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
const { db } = ctx;
|
||||
return {
|
||||
url,
|
||||
key,
|
||||
bucket,
|
||||
};
|
||||
} catch (error) {
|
||||
console.error("Error generating presigned URL:", error);
|
||||
throw new TRPCError({
|
||||
code: "INTERNAL_SERVER_ERROR",
|
||||
message: "Failed to generate upload URL",
|
||||
});
|
||||
}
|
||||
}),
|
||||
saveRecording: protectedProcedure
|
||||
.input(
|
||||
z.object({
|
||||
trialId: z.string(),
|
||||
storagePath: z.string(),
|
||||
fileSize: z.number().optional(),
|
||||
format: z.string().optional(),
|
||||
mediaType: z.enum(["video", "audio", "image"]).default("video"),
|
||||
}),
|
||||
)
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
const { db } = ctx;
|
||||
|
||||
await db.insert(mediaCaptures).values({
|
||||
trialId: input.trialId,
|
||||
mediaType: input.mediaType,
|
||||
storagePath: input.storagePath,
|
||||
fileSize: input.fileSize,
|
||||
format: input.format,
|
||||
startTimestamp: new Date(), // Approximate
|
||||
// metadata: { uploadedBy: ctx.session.user.id }
|
||||
});
|
||||
await db.insert(mediaCaptures).values({
|
||||
trialId: input.trialId,
|
||||
mediaType: input.mediaType,
|
||||
storagePath: input.storagePath,
|
||||
fileSize: input.fileSize,
|
||||
format: input.format,
|
||||
startTimestamp: new Date(), // Approximate
|
||||
// metadata: { uploadedBy: ctx.session.user.id }
|
||||
});
|
||||
|
||||
return { success: true };
|
||||
}),
|
||||
return { success: true };
|
||||
}),
|
||||
});
|
||||
|
||||
@@ -13,6 +13,7 @@ import {
|
||||
studyStatusEnum,
|
||||
users,
|
||||
userSystemRoles,
|
||||
consentForms,
|
||||
} from "~/server/db/schema";
|
||||
|
||||
export const studiesRouter = createTRPCRouter({
|
||||
@@ -606,6 +607,180 @@ export const studiesRouter = createTRPCRouter({
|
||||
return members;
|
||||
}),
|
||||
|
||||
getActiveConsentForm: protectedProcedure
|
||||
.input(z.object({ studyId: z.string().uuid() }))
|
||||
.query(async ({ ctx, input }) => {
|
||||
const userId = ctx.session.user.id;
|
||||
|
||||
// Check access
|
||||
const membership = await ctx.db.query.studyMembers.findFirst({
|
||||
where: and(
|
||||
eq(studyMembers.studyId, input.studyId),
|
||||
eq(studyMembers.userId, userId),
|
||||
),
|
||||
});
|
||||
|
||||
if (!membership) {
|
||||
throw new TRPCError({
|
||||
code: "FORBIDDEN",
|
||||
message: "You don't have access to this study",
|
||||
});
|
||||
}
|
||||
|
||||
const activeForm = await ctx.db.query.consentForms.findFirst({
|
||||
where: and(
|
||||
eq(consentForms.studyId, input.studyId),
|
||||
eq(consentForms.active, true),
|
||||
),
|
||||
orderBy: [desc(consentForms.version)],
|
||||
});
|
||||
|
||||
return activeForm;
|
||||
}),
|
||||
|
||||
generateConsentForm: protectedProcedure
|
||||
.input(z.object({ studyId: z.string().uuid() }))
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
const userId = ctx.session.user.id;
|
||||
const { studyId } = input;
|
||||
|
||||
// Check access
|
||||
const membership = await ctx.db.query.studyMembers.findFirst({
|
||||
where: and(
|
||||
eq(studyMembers.studyId, studyId),
|
||||
eq(studyMembers.userId, userId),
|
||||
),
|
||||
});
|
||||
|
||||
if (!membership || !["owner", "researcher"].includes(membership.role)) {
|
||||
throw new TRPCError({
|
||||
code: "FORBIDDEN",
|
||||
message:
|
||||
"You don't have permission to generate consent forms for this study",
|
||||
});
|
||||
}
|
||||
|
||||
// Fetch study info
|
||||
const study = await ctx.db.query.studies.findFirst({
|
||||
where: eq(studies.id, studyId),
|
||||
with: {
|
||||
createdBy: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (!study) {
|
||||
throw new TRPCError({ code: "NOT_FOUND", message: "Study not found" });
|
||||
}
|
||||
|
||||
// Deactivate existing
|
||||
await ctx.db
|
||||
.update(consentForms)
|
||||
.set({ active: false })
|
||||
.where(eq(consentForms.studyId, studyId));
|
||||
|
||||
// Get latest version
|
||||
const latestForm = await ctx.db.query.consentForms.findFirst({
|
||||
where: eq(consentForms.studyId, studyId),
|
||||
orderBy: [desc(consentForms.version)],
|
||||
});
|
||||
const newVersion = (latestForm?.version ?? 0) + 1;
|
||||
|
||||
const mdContent = `# Informed Consent Form\n\n**Study Title**: ${study.name}\n${study.institution ? `**Institution**: ${study.institution}\n` : ""}${study.irbProtocol ? `**IRB Protocol Number**: ${study.irbProtocol}\n` : ""}**Principal Investigator**: ${study.createdBy.name ?? study.createdBy.email}\n\n## Introduction\nYou are invited to participate in a research study. Before you agree, please read this document carefully. It explains the purpose, procedures, risks, and benefits of the study.\n\n## Purpose of the Study\nThe main goal of this research is to evaluate human-robot interaction using the HRIStudio platform. \n\n## Procedures\nIf you agree to participate, you will be interacting with a robotic system or simulation interface. We will be recording your actions, choices, and interactions with the system.\n\n## Risks and Benefits\nThere are no expected risks beyond those encountered in everyday laptop/computer use. Your participation will help improve human-robot interaction technologies.\n\n## Confidentiality\nYour identity will be kept confidential. Any data collected will be anonymized before publication or presentation.\n\n**Participant**: {{PARTICIPANT_NAME}} ({{PARTICIPANT_CODE}})\n\n## Voluntary Participation\nYour participation is completely voluntary. You may withdraw from the study at any time without penalty.\n\n## Statement of Consent\nI have read the above information. I understand the procedures, risks, and benefits of the study. I understand my participation is voluntary and I can withdraw at any time.\n\n\n| Participant Signature | Date |\n| :--- | :--- |\n| {{SIGNATURE_IMAGE}} | {{DATE}} |\n\n\n| Researcher Signature | Date |\n| :--- | :--- |\n| | |\n`;
|
||||
|
||||
const [newForm] = await ctx.db
|
||||
.insert(consentForms)
|
||||
.values({
|
||||
studyId,
|
||||
version: newVersion,
|
||||
title: `Consent Form v${newVersion}`,
|
||||
content: mdContent,
|
||||
active: true,
|
||||
createdBy: userId,
|
||||
})
|
||||
.returning();
|
||||
|
||||
if (!newForm) {
|
||||
throw new TRPCError({
|
||||
code: "INTERNAL_SERVER_ERROR",
|
||||
message: "Failed to create new consent form",
|
||||
});
|
||||
}
|
||||
|
||||
await ctx.db.insert(activityLogs).values({
|
||||
studyId,
|
||||
userId,
|
||||
action: "consent_form_generated",
|
||||
description: `Generated boilerplate consent form v${newVersion}`,
|
||||
});
|
||||
|
||||
return newForm;
|
||||
}),
|
||||
|
||||
updateConsentForm: protectedProcedure
|
||||
.input(z.object({ studyId: z.string().uuid(), content: z.string() }))
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
const userId = ctx.session.user.id;
|
||||
const { studyId, content } = input;
|
||||
|
||||
// Check access
|
||||
const membership = await ctx.db.query.studyMembers.findFirst({
|
||||
where: and(
|
||||
eq(studyMembers.studyId, studyId),
|
||||
eq(studyMembers.userId, userId),
|
||||
),
|
||||
});
|
||||
|
||||
if (!membership || !["owner", "researcher"].includes(membership.role)) {
|
||||
throw new TRPCError({
|
||||
code: "FORBIDDEN",
|
||||
message:
|
||||
"You don't have permission to modify consent forms for this study",
|
||||
});
|
||||
}
|
||||
|
||||
// Deactivate existing
|
||||
await ctx.db
|
||||
.update(consentForms)
|
||||
.set({ active: false })
|
||||
.where(eq(consentForms.studyId, studyId));
|
||||
|
||||
// Get latest version
|
||||
const latestForm = await ctx.db.query.consentForms.findFirst({
|
||||
where: eq(consentForms.studyId, studyId),
|
||||
orderBy: [desc(consentForms.version)],
|
||||
});
|
||||
|
||||
const newVersion = (latestForm?.version ?? 0) + 1;
|
||||
|
||||
const [newForm] = await ctx.db
|
||||
.insert(consentForms)
|
||||
.values({
|
||||
studyId,
|
||||
version: newVersion,
|
||||
title: `Consent Form v${newVersion}`,
|
||||
content,
|
||||
active: true,
|
||||
createdBy: userId,
|
||||
})
|
||||
.returning();
|
||||
|
||||
if (!newForm) {
|
||||
throw new TRPCError({
|
||||
code: "INTERNAL_SERVER_ERROR",
|
||||
message: "Failed to save consent form",
|
||||
});
|
||||
}
|
||||
|
||||
await ctx.db.insert(activityLogs).values({
|
||||
studyId,
|
||||
userId,
|
||||
action: "consent_form_updated",
|
||||
description: `Updated consent form to v${newVersion}`,
|
||||
});
|
||||
|
||||
return newForm;
|
||||
}),
|
||||
|
||||
getActivity: protectedProcedure
|
||||
.input(
|
||||
z.object({
|
||||
|
||||
@@ -285,29 +285,37 @@ export const trialsRouter = createTRPCRouter({
|
||||
...trial[0],
|
||||
eventCount: eventCount[0]?.count ?? 0,
|
||||
mediaCount: media.length,
|
||||
media: await Promise.all(media.map(async (m) => {
|
||||
let url = "";
|
||||
try {
|
||||
// Generate Presigned GET URL
|
||||
const command = new GetObjectCommand({
|
||||
Bucket: env.MINIO_BUCKET_NAME ?? "hristudio-data",
|
||||
Key: m.storagePath,
|
||||
});
|
||||
url = await getSignedUrl(s3Client, command, { expiresIn: 3600 });
|
||||
} catch (e) {
|
||||
console.error("Failed to sign URL for media", m.id, e);
|
||||
}
|
||||
return {
|
||||
...m,
|
||||
url, // Add the signed URL to the response
|
||||
contentType: m.format === 'webm' ? 'video/webm'
|
||||
: m.format === 'mp4' ? 'video/mp4'
|
||||
: m.format === 'mkv' ? 'video/x-matroska'
|
||||
: m.storagePath.endsWith('.webm') ? 'video/webm'
|
||||
: m.storagePath.endsWith('.mp4') ? 'video/mp4'
|
||||
: 'application/octet-stream', // Infer or store content type
|
||||
};
|
||||
})),
|
||||
media: await Promise.all(
|
||||
media.map(async (m) => {
|
||||
let url = "";
|
||||
try {
|
||||
// Generate Presigned GET URL
|
||||
const command = new GetObjectCommand({
|
||||
Bucket: env.MINIO_BUCKET_NAME ?? "hristudio-data",
|
||||
Key: m.storagePath,
|
||||
});
|
||||
url = await getSignedUrl(s3Client, command, { expiresIn: 3600 });
|
||||
} catch (e) {
|
||||
console.error("Failed to sign URL for media", m.id, e);
|
||||
}
|
||||
return {
|
||||
...m,
|
||||
url, // Add the signed URL to the response
|
||||
contentType:
|
||||
m.format === "webm"
|
||||
? "video/webm"
|
||||
: m.format === "mp4"
|
||||
? "video/mp4"
|
||||
: m.format === "mkv"
|
||||
? "video/x-matroska"
|
||||
: m.storagePath.endsWith(".webm")
|
||||
? "video/webm"
|
||||
: m.storagePath.endsWith(".mp4")
|
||||
? "video/mp4"
|
||||
: "application/octet-stream", // Infer or store content type
|
||||
};
|
||||
}),
|
||||
),
|
||||
};
|
||||
}),
|
||||
|
||||
@@ -610,7 +618,9 @@ export const trialsRouter = createTRPCRouter({
|
||||
|
||||
let durationSeconds = null;
|
||||
if (currentTrial?.startedAt) {
|
||||
durationSeconds = Math.floor((new Date().getTime() - currentTrial.startedAt.getTime()) / 1000);
|
||||
durationSeconds = Math.floor(
|
||||
(new Date().getTime() - currentTrial.startedAt.getTime()) / 1000,
|
||||
);
|
||||
}
|
||||
|
||||
const [trial] = await db
|
||||
@@ -913,7 +923,7 @@ export const trialsRouter = createTRPCRouter({
|
||||
if (annotation) {
|
||||
await db.insert(trialEvents).values({
|
||||
trialId: input.trialId,
|
||||
eventType: `annotation_${input.category || 'note'}`,
|
||||
eventType: `annotation_${input.category || "note"}`,
|
||||
timestamp: input.timestampStart ?? new Date(),
|
||||
data: {
|
||||
annotationId: annotation.id,
|
||||
@@ -1054,51 +1064,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,
|
||||
},
|
||||
},
|
||||
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,
|
||||
},
|
||||
},
|
||||
},
|
||||
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)],
|
||||
})
|
||||
orderBy: [desc(trials.scheduledAt)],
|
||||
})
|
||||
: [];
|
||||
|
||||
// Get total count
|
||||
@@ -1232,8 +1242,12 @@ export const trialsRouter = createTRPCRouter({
|
||||
});
|
||||
|
||||
// Also set a generic "last_wizard_response" if response field exists
|
||||
if ('response' in input.data) {
|
||||
executionEngine.setVariable(input.trialId, "last_wizard_response", input.data.response);
|
||||
if ("response" in input.data) {
|
||||
executionEngine.setVariable(
|
||||
input.trialId,
|
||||
"last_wizard_response",
|
||||
input.data.response,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -95,7 +95,7 @@ export const authConfig: NextAuthConfig = {
|
||||
return token;
|
||||
},
|
||||
session: async ({ session, token }) => {
|
||||
if (token.id && typeof token.id === 'string') {
|
||||
if (token.id && typeof token.id === "string") {
|
||||
// Fetch user roles from database
|
||||
const userWithRoles = await db.query.users.findFirst({
|
||||
where: eq(users.id, token.id),
|
||||
|
||||
@@ -59,11 +59,12 @@ export function isWizard(session: Session | null): boolean {
|
||||
/**
|
||||
* Check if the current user has any of the specified roles
|
||||
*/
|
||||
export function hasAnyRole(session: Session | null, roles: SystemRole[]): boolean {
|
||||
export function hasAnyRole(
|
||||
session: Session | null,
|
||||
roles: SystemRole[],
|
||||
): boolean {
|
||||
if (!session?.user?.roles) return false;
|
||||
return session.user.roles.some((userRole) =>
|
||||
roles.includes(userRole.role)
|
||||
);
|
||||
return session.user.roles.some((userRole) => roles.includes(userRole.role));
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -118,13 +119,13 @@ export async function getUserRoles(userId: string) {
|
||||
export async function grantRole(
|
||||
userId: string,
|
||||
role: SystemRole,
|
||||
grantedBy: string
|
||||
grantedBy: string,
|
||||
) {
|
||||
// Check if user already has this role
|
||||
const existingRole = await db.query.userSystemRoles.findFirst({
|
||||
where: and(
|
||||
eq(userSystemRoles.userId, userId),
|
||||
eq(userSystemRoles.role, role)
|
||||
eq(userSystemRoles.role, role),
|
||||
),
|
||||
});
|
||||
|
||||
@@ -152,10 +153,7 @@ export async function revokeRole(userId: string, role: SystemRole) {
|
||||
const deletedRole = await db
|
||||
.delete(userSystemRoles)
|
||||
.where(
|
||||
and(
|
||||
eq(userSystemRoles.userId, userId),
|
||||
eq(userSystemRoles.role, role)
|
||||
)
|
||||
and(eq(userSystemRoles.userId, userId), eq(userSystemRoles.role, role)),
|
||||
)
|
||||
.returning();
|
||||
|
||||
@@ -171,7 +169,7 @@ export async function revokeRole(userId: string, role: SystemRole) {
|
||||
*/
|
||||
export function canAccessResource(
|
||||
session: Session | null,
|
||||
resourceOwnerId: string
|
||||
resourceOwnerId: string,
|
||||
): boolean {
|
||||
if (!session?.user) return false;
|
||||
|
||||
@@ -220,7 +218,12 @@ export function getAvailableRoles(): Array<{
|
||||
label: string;
|
||||
description: string;
|
||||
}> {
|
||||
const roles: SystemRole[] = ["administrator", "researcher", "wizard", "observer"];
|
||||
const roles: SystemRole[] = [
|
||||
"administrator",
|
||||
"researcher",
|
||||
"wizard",
|
||||
"observer",
|
||||
];
|
||||
|
||||
return roles.map((role) => ({
|
||||
value: role,
|
||||
|
||||
@@ -1230,7 +1230,6 @@ export const systemSettingsRelations = relations(systemSettings, ({ one }) => ({
|
||||
}),
|
||||
}));
|
||||
|
||||
|
||||
export const auditLogsRelations = relations(auditLogs, ({ one }) => ({
|
||||
user: one(users, { fields: [auditLogs.userId], references: [users.id] }),
|
||||
}));
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
|
||||
import { describe, it, expect, beforeEach, mock } from "bun:test";
|
||||
import { TrialExecutionEngine } from "~/server/services/trial-execution";
|
||||
import type { StepDefinition } from "~/server/services/trial-execution";
|
||||
@@ -7,26 +6,28 @@ import type { StepDefinition } from "~/server/services/trial-execution";
|
||||
const mockQueryExecutor = mock(() => Promise.resolve([]));
|
||||
|
||||
const mockBuilder = new Proxy({} as any, {
|
||||
get: (target, prop) => {
|
||||
if (prop === 'then') {
|
||||
return (onfulfilled: any, onrejected: any) => mockQueryExecutor().then(onfulfilled, onrejected);
|
||||
}
|
||||
// Return self for any chainable method
|
||||
return () => mockBuilder;
|
||||
get: (target, prop) => {
|
||||
if (prop === "then") {
|
||||
return (onfulfilled: any, onrejected: any) =>
|
||||
mockQueryExecutor().then(onfulfilled, onrejected);
|
||||
}
|
||||
// Return self for any chainable method
|
||||
return () => mockBuilder;
|
||||
},
|
||||
});
|
||||
|
||||
const mockDb = {
|
||||
select: mock(() => mockBuilder),
|
||||
update: mock(() => mockBuilder),
|
||||
insert: mock(() => mockBuilder),
|
||||
delete: mock(() => mockBuilder),
|
||||
// Helper to mock return values easily
|
||||
__setNextResult: (value: any) => mockQueryExecutor.mockResolvedValueOnce(value),
|
||||
__reset: () => {
|
||||
mockQueryExecutor.mockClear();
|
||||
mockQueryExecutor.mockResolvedValue([]); // Default empty
|
||||
}
|
||||
select: mock(() => mockBuilder),
|
||||
update: mock(() => mockBuilder),
|
||||
insert: mock(() => mockBuilder),
|
||||
delete: mock(() => mockBuilder),
|
||||
// Helper to mock return values easily
|
||||
__setNextResult: (value: any) =>
|
||||
mockQueryExecutor.mockResolvedValueOnce(value),
|
||||
__reset: () => {
|
||||
mockQueryExecutor.mockClear();
|
||||
mockQueryExecutor.mockResolvedValue([]); // Default empty
|
||||
},
|
||||
} as any;
|
||||
|
||||
// Mock Data
|
||||
@@ -34,46 +35,48 @@ const mockTrialId = "trial-123";
|
||||
const mockExpId = "exp-123";
|
||||
|
||||
const mockStep: StepDefinition = {
|
||||
id: "step-1",
|
||||
name: "Test Step",
|
||||
type: "sequential",
|
||||
orderIndex: 0,
|
||||
actions: [],
|
||||
condition: undefined
|
||||
id: "step-1",
|
||||
name: "Test Step",
|
||||
type: "sequential",
|
||||
orderIndex: 0,
|
||||
actions: [],
|
||||
condition: undefined,
|
||||
};
|
||||
|
||||
describe("TrialExecutionEngine", () => {
|
||||
let engine: TrialExecutionEngine;
|
||||
let engine: TrialExecutionEngine;
|
||||
|
||||
beforeEach(() => {
|
||||
mockDb.__reset();
|
||||
engine = new TrialExecutionEngine(mockDb);
|
||||
});
|
||||
beforeEach(() => {
|
||||
mockDb.__reset();
|
||||
engine = new TrialExecutionEngine(mockDb);
|
||||
});
|
||||
|
||||
it("should initialize a trial context", async () => {
|
||||
// 1. Fetch Trial
|
||||
mockDb.__setNextResult([{
|
||||
id: mockTrialId,
|
||||
experimentId: mockExpId,
|
||||
status: "scheduled",
|
||||
participantId: "p1"
|
||||
}]);
|
||||
it("should initialize a trial context", async () => {
|
||||
// 1. Fetch Trial
|
||||
mockDb.__setNextResult([
|
||||
{
|
||||
id: mockTrialId,
|
||||
experimentId: mockExpId,
|
||||
status: "scheduled",
|
||||
participantId: "p1",
|
||||
},
|
||||
]);
|
||||
|
||||
// 2. Fetch Steps
|
||||
mockDb.__setNextResult([]); // Return empty steps for this test
|
||||
// 2. Fetch Steps
|
||||
mockDb.__setNextResult([]); // Return empty steps for this test
|
||||
|
||||
const context = await engine.initializeTrial(mockTrialId);
|
||||
const context = await engine.initializeTrial(mockTrialId);
|
||||
|
||||
expect(context.trialId).toBe(mockTrialId);
|
||||
expect(context.currentStepIndex).toBe(0);
|
||||
});
|
||||
expect(context.trialId).toBe(mockTrialId);
|
||||
expect(context.currentStepIndex).toBe(0);
|
||||
});
|
||||
|
||||
it("should fail to initialize non-existent trial", async () => {
|
||||
mockDb.__setNextResult([]); // No trial found
|
||||
it("should fail to initialize non-existent trial", async () => {
|
||||
mockDb.__setNextResult([]); // No trial found
|
||||
|
||||
const promise = engine.initializeTrial("bad-id");
|
||||
// Since we are mocking, we need to ensure the promise rejects as expected
|
||||
// The engine throws "Trial bad-id not found"
|
||||
expect(promise).rejects.toThrow("not found");
|
||||
});
|
||||
const promise = engine.initializeTrial("bad-id");
|
||||
// Since we are mocking, we need to ensure the promise rejects as expected
|
||||
// The engine throws "Trial bad-id not found"
|
||||
expect(promise).rejects.toThrow("not found");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -175,8 +175,12 @@ export class TrialExecutionEngine {
|
||||
description: step.description || undefined,
|
||||
type: step.type,
|
||||
orderIndex: step.orderIndex,
|
||||
condition: typeof step.conditions === 'string' ? step.conditions : undefined,
|
||||
conditions: typeof step.conditions === 'object' ? (step.conditions as Record<string, any>) : undefined,
|
||||
condition:
|
||||
typeof step.conditions === "string" ? step.conditions : undefined,
|
||||
conditions:
|
||||
typeof step.conditions === "object"
|
||||
? (step.conditions as Record<string, any>)
|
||||
: undefined,
|
||||
actions: actionDefinitions,
|
||||
});
|
||||
}
|
||||
@@ -443,7 +447,10 @@ export class TrialExecutionEngine {
|
||||
|
||||
default:
|
||||
// Check if it's a robot action (contains plugin prefix)
|
||||
if (action.type.includes(".") && !action.type.startsWith("hristudio-")) {
|
||||
if (
|
||||
action.type.includes(".") &&
|
||||
!action.type.startsWith("hristudio-")
|
||||
) {
|
||||
return await this.executeRobotAction(trialId, action);
|
||||
}
|
||||
|
||||
@@ -455,7 +462,7 @@ export class TrialExecutionEngine {
|
||||
data: {
|
||||
message: `Action type '${action.type}' not implemented yet`,
|
||||
parameters: action.parameters,
|
||||
localHandler: true // Indicate this fell through to default local handler
|
||||
localHandler: true, // Indicate this fell through to default local handler
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -469,13 +476,18 @@ export class TrialExecutionEngine {
|
||||
): Promise<ActionExecutionResult> {
|
||||
const rawDuration = action.parameters.duration;
|
||||
// Duration is in SECONDS per definition, default to 1s
|
||||
const durationSeconds = typeof rawDuration === 'string'
|
||||
? parseFloat(rawDuration)
|
||||
: (typeof rawDuration === 'number' ? rawDuration : 1);
|
||||
const durationSeconds =
|
||||
typeof rawDuration === "string"
|
||||
? parseFloat(rawDuration)
|
||||
: typeof rawDuration === "number"
|
||||
? rawDuration
|
||||
: 1;
|
||||
|
||||
const durationMs = durationSeconds * 1000;
|
||||
|
||||
console.log(`[TrialExecution] Executing wait action: ${action.id}, rawDuration: ${rawDuration}, parsedSeconds: ${durationSeconds}, ms: ${durationMs}`);
|
||||
console.log(
|
||||
`[TrialExecution] Executing wait action: ${action.id}, rawDuration: ${rawDuration}, parsedSeconds: ${durationSeconds}, ms: ${durationMs}`,
|
||||
);
|
||||
|
||||
return new Promise((resolve) => {
|
||||
setTimeout(() => {
|
||||
@@ -549,7 +561,9 @@ export class TrialExecutionEngine {
|
||||
// Parse plugin.action format
|
||||
const [pluginName, actionId] = action.type.split(".");
|
||||
|
||||
console.log(`[TrialExecution] Parsed action: pluginName=${pluginName}, actionId=${actionId}`);
|
||||
console.log(
|
||||
`[TrialExecution] Parsed action: pluginName=${pluginName}, actionId=${actionId}`,
|
||||
);
|
||||
|
||||
if (!pluginName || !actionId) {
|
||||
throw new Error(
|
||||
@@ -563,8 +577,12 @@ export class TrialExecutionEngine {
|
||||
throw new Error(`Plugin '${pluginName}' not found`);
|
||||
}
|
||||
|
||||
console.log(`[TrialExecution] Plugin loaded: ${plugin.name} (ID: ${plugin.id})`);
|
||||
console.log(`[TrialExecution] Available actions: ${plugin.actions?.map((a: any) => a.id).join(", ")}`);
|
||||
console.log(
|
||||
`[TrialExecution] Plugin loaded: ${plugin.name} (ID: ${plugin.id})`,
|
||||
);
|
||||
console.log(
|
||||
`[TrialExecution] Available actions: ${plugin.actions?.map((a: any) => a.id).join(", ")}`,
|
||||
);
|
||||
|
||||
// Find action definition in plugin
|
||||
const actionDefinition = plugin.actions?.find(
|
||||
@@ -889,47 +907,73 @@ export class TrialExecutionEngine {
|
||||
|
||||
// Check for branching conditions
|
||||
if (currentStep.conditions) {
|
||||
const { variable, options, nextStepId: unconditionalNextId } = currentStep.conditions as any;
|
||||
const {
|
||||
variable,
|
||||
options,
|
||||
nextStepId: unconditionalNextId,
|
||||
} = currentStep.conditions as any;
|
||||
|
||||
if (options) {
|
||||
// Default to "last_wizard_response" if variable not specified, for backward compatibility
|
||||
const variableName = variable || "last_wizard_response";
|
||||
const variableValue = context.variables[variableName];
|
||||
|
||||
console.log(`[TrialExecution] Checking branch condition for step ${currentStep.id}: variable=${variableName}, value=${variableValue}`);
|
||||
console.log(
|
||||
`[TrialExecution] Checking branch condition for step ${currentStep.id}: variable=${variableName}, value=${variableValue}`,
|
||||
);
|
||||
|
||||
if (variableValue !== undefined) {
|
||||
// Find matching option
|
||||
// option.value matches variableValue (e.g., label string)
|
||||
const matchedOption = options.find((opt: any) => opt.value === variableValue || opt.label === variableValue);
|
||||
const matchedOption = options.find(
|
||||
(opt: any) =>
|
||||
opt.value === variableValue || opt.label === variableValue,
|
||||
);
|
||||
|
||||
if (matchedOption) {
|
||||
if (matchedOption.nextStepId) {
|
||||
// Find step by ID
|
||||
const targetStepIndex = steps.findIndex(s => s.id === matchedOption.nextStepId);
|
||||
const targetStepIndex = steps.findIndex(
|
||||
(s) => s.id === matchedOption.nextStepId,
|
||||
);
|
||||
if (targetStepIndex !== -1) {
|
||||
nextStepIndex = targetStepIndex;
|
||||
console.log(`[TrialExecution] Taking branch to step ID ${matchedOption.nextStepId} (Index ${nextStepIndex})`);
|
||||
console.log(
|
||||
`[TrialExecution] Taking branch to step ID ${matchedOption.nextStepId} (Index ${nextStepIndex})`,
|
||||
);
|
||||
} else {
|
||||
console.warn(`[TrialExecution] Branch target step ID ${matchedOption.nextStepId} not found`);
|
||||
console.warn(
|
||||
`[TrialExecution] Branch target step ID ${matchedOption.nextStepId} not found`,
|
||||
);
|
||||
}
|
||||
} else if (matchedOption.nextStepIndex !== undefined) {
|
||||
// Fallback to relative/absolute index if ID not present (legacy)
|
||||
nextStepIndex = matchedOption.nextStepIndex;
|
||||
console.log(`[TrialExecution] Taking branch to index ${nextStepIndex}`);
|
||||
console.log(
|
||||
`[TrialExecution] Taking branch to index ${nextStepIndex}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Check for unconditional jump if no branch was taken
|
||||
if (nextStepIndex === context.currentStepIndex + 1 && unconditionalNextId) {
|
||||
const targetStepIndex = steps.findIndex(s => s.id === unconditionalNextId);
|
||||
if (
|
||||
nextStepIndex === context.currentStepIndex + 1 &&
|
||||
unconditionalNextId
|
||||
) {
|
||||
const targetStepIndex = steps.findIndex(
|
||||
(s) => s.id === unconditionalNextId,
|
||||
);
|
||||
if (targetStepIndex !== -1) {
|
||||
nextStepIndex = targetStepIndex;
|
||||
console.log(`[TrialExecution] Taking unconditional jump to step ID ${unconditionalNextId} (Index ${nextStepIndex})`);
|
||||
console.log(
|
||||
`[TrialExecution] Taking unconditional jump to step ID ${unconditionalNextId} (Index ${nextStepIndex})`,
|
||||
);
|
||||
} else {
|
||||
console.warn(`[TrialExecution] Unconditional jump target step ID ${unconditionalNextId} not found`);
|
||||
console.warn(
|
||||
`[TrialExecution] Unconditional jump target step ID ${unconditionalNextId} not found`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -939,7 +983,7 @@ export class TrialExecutionEngine {
|
||||
await this.logTrialEvent(trialId, "step_transition", {
|
||||
fromStepIndex: previousStepIndex,
|
||||
toStepIndex: context.currentStepIndex,
|
||||
reason: nextStepIndex !== previousStepIndex + 1 ? "branch" : "sequence"
|
||||
reason: nextStepIndex !== previousStepIndex + 1 ? "branch" : "sequence",
|
||||
});
|
||||
|
||||
// Check if we've completed all steps
|
||||
@@ -1151,7 +1195,9 @@ export class TrialExecutionEngine {
|
||||
action: ActionDefinition,
|
||||
): Promise<ActionExecutionResult> {
|
||||
const startTime = Date.now();
|
||||
const children = action.parameters.children as ActionDefinition[] | undefined;
|
||||
const children = action.parameters.children as
|
||||
| ActionDefinition[]
|
||||
| undefined;
|
||||
|
||||
if (!children || !Array.isArray(children) || children.length === 0) {
|
||||
return {
|
||||
@@ -1219,7 +1265,9 @@ export class TrialExecutionEngine {
|
||||
action: ActionDefinition,
|
||||
): Promise<ActionExecutionResult> {
|
||||
const startTime = Date.now();
|
||||
const children = action.parameters.children as ActionDefinition[] | undefined;
|
||||
const children = action.parameters.children as
|
||||
| ActionDefinition[]
|
||||
| undefined;
|
||||
|
||||
if (!children || !Array.isArray(children) || children.length === 0) {
|
||||
return {
|
||||
@@ -1240,7 +1288,7 @@ export class TrialExecutionEngine {
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
actionName: childAction.name,
|
||||
},
|
||||
}))
|
||||
})),
|
||||
);
|
||||
|
||||
const results = await Promise.all(promises);
|
||||
@@ -1269,7 +1317,9 @@ export class TrialExecutionEngine {
|
||||
action: ActionDefinition,
|
||||
): Promise<ActionExecutionResult> {
|
||||
const startTime = Date.now();
|
||||
const children = action.parameters.children as ActionDefinition[] | undefined;
|
||||
const children = action.parameters.children as
|
||||
| ActionDefinition[]
|
||||
| undefined;
|
||||
const iterations = (action.parameters.iterations as number) || 1;
|
||||
|
||||
if (!children || !Array.isArray(children) || children.length === 0) {
|
||||
@@ -1379,7 +1429,10 @@ export class TrialExecutionEngine {
|
||||
data: {
|
||||
message: "Branch action presented to wizard",
|
||||
optionsCount: options.length,
|
||||
options: options.map(opt => ({ label: opt.label, nextStepId: opt.nextStepId })),
|
||||
options: options.map((opt) => ({
|
||||
label: opt.label,
|
||||
nextStepId: opt.nextStepId,
|
||||
})),
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -2,19 +2,19 @@ import { S3Client } from "@aws-sdk/client-s3";
|
||||
import { env } from "~/env";
|
||||
|
||||
const globalForS3 = globalThis as unknown as {
|
||||
s3Client: S3Client | undefined;
|
||||
s3Client: S3Client | undefined;
|
||||
};
|
||||
|
||||
export const s3Client =
|
||||
globalForS3.s3Client ??
|
||||
new S3Client({
|
||||
region: env.MINIO_REGION ?? "us-east-1",
|
||||
endpoint: env.MINIO_ENDPOINT,
|
||||
credentials: {
|
||||
accessKeyId: env.MINIO_ACCESS_KEY ?? "minioadmin",
|
||||
secretAccessKey: env.MINIO_SECRET_KEY ?? "minioadmin",
|
||||
},
|
||||
forcePathStyle: true, // Needed for MinIO
|
||||
});
|
||||
globalForS3.s3Client ??
|
||||
new S3Client({
|
||||
region: env.MINIO_REGION ?? "us-east-1",
|
||||
endpoint: env.MINIO_ENDPOINT,
|
||||
credentials: {
|
||||
accessKeyId: env.MINIO_ACCESS_KEY ?? "minioadmin",
|
||||
secretAccessKey: env.MINIO_SECRET_KEY ?? "minioadmin",
|
||||
},
|
||||
forcePathStyle: true, // Needed for MinIO
|
||||
});
|
||||
|
||||
if (env.NODE_ENV !== "production") globalForS3.s3Client = s3Client;
|
||||
|
||||
Reference in New Issue
Block a user