feat: Implement digital signatures for participant consent and introduce study forms management.

This commit is contained in:
2026-03-02 10:51:20 -05:00
parent 61af467cc8
commit 0051946bde
172 changed files with 12612 additions and 9461 deletions

View File

@@ -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,
};

View File

@@ -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";

View File

@@ -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",

View File

@@ -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) => ({

View File

@@ -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({

View File

@@ -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.
}
}),
});

View File

@@ -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 }) => {

View File

@@ -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

View File

@@ -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 };
}),
});

View File

@@ -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({

View File

@@ -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,
);
}
}