mirror of
https://github.com/soconnor0919/hristudio.git
synced 2026-03-23 19:27:51 -04:00
feat: Implement trial event logging, archiving, experiment soft deletion, and new analytics/event data tables.
This commit is contained in:
@@ -1,6 +1,6 @@
|
||||
import { TRPCError } from "@trpc/server";
|
||||
import { randomUUID } from "crypto";
|
||||
import { and, asc, count, desc, eq, inArray, sql } from "drizzle-orm";
|
||||
import { and, asc, count, desc, eq, inArray, isNull, sql } from "drizzle-orm";
|
||||
import { z } from "zod";
|
||||
|
||||
import { createTRPCRouter, protectedProcedure } from "~/server/api/trpc";
|
||||
@@ -87,7 +87,10 @@ export const experimentsRouter = createTRPCRouter({
|
||||
// Check study access
|
||||
await checkStudyAccess(ctx.db, userId, studyId);
|
||||
|
||||
const conditions = [eq(experiments.studyId, studyId)];
|
||||
const conditions = [
|
||||
eq(experiments.studyId, studyId),
|
||||
isNull(experiments.deletedAt),
|
||||
];
|
||||
if (status) {
|
||||
conditions.push(eq(experiments.status, status));
|
||||
}
|
||||
@@ -224,7 +227,10 @@ export const experimentsRouter = createTRPCRouter({
|
||||
}
|
||||
|
||||
// Build where conditions
|
||||
const conditions = [inArray(experiments.studyId, studyIds)];
|
||||
const conditions = [
|
||||
inArray(experiments.studyId, studyIds),
|
||||
isNull(experiments.deletedAt),
|
||||
];
|
||||
|
||||
if (status) {
|
||||
conditions.push(eq(experiments.status, status));
|
||||
|
||||
@@ -5,8 +5,9 @@ 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";
|
||||
|
||||
// Helper function to check study access
|
||||
async function checkStudyAccess(
|
||||
@@ -415,6 +416,42 @@ export const participantsRouter = createTRPCRouter({
|
||||
return { success: true };
|
||||
}),
|
||||
|
||||
getConsentUploadUrl: protectedProcedure
|
||||
.input(
|
||||
z.object({
|
||||
studyId: z.string().uuid(),
|
||||
participantId: z.string().uuid(),
|
||||
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"]);
|
||||
|
||||
// Validate file type
|
||||
const allowedTypes = ["pdf", "png", "jpg", "jpeg"];
|
||||
const validation = validateFile(filename, size, allowedTypes);
|
||||
if (!validation.valid) {
|
||||
throw new TRPCError({
|
||||
code: "BAD_REQUEST",
|
||||
message: validation.error,
|
||||
});
|
||||
}
|
||||
|
||||
// Generate key: studies/{studyId}/participants/{participantId}/consent/{timestamp}-{filename}
|
||||
const key = `studies/${studyId}/participants/${participantId}/consent/${Date.now()}-${filename.replace(/[^a-zA-Z0-9.-]/g, "_")}`;
|
||||
|
||||
// Generate presigned URL
|
||||
const url = await getUploadUrl(key, contentType);
|
||||
|
||||
return { url, key };
|
||||
}),
|
||||
|
||||
recordConsent: protectedProcedure
|
||||
.input(
|
||||
z.object({
|
||||
@@ -422,10 +459,11 @@ export const participantsRouter = createTRPCRouter({
|
||||
consentFormId: z.string().uuid(),
|
||||
signatureData: z.string().optional(),
|
||||
ipAddress: z.string().optional(),
|
||||
storagePath: z.string().optional(),
|
||||
}),
|
||||
)
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
const { participantId, consentFormId, signatureData, ipAddress } = input;
|
||||
const { participantId, consentFormId, signatureData, ipAddress, storagePath } = input;
|
||||
const userId = ctx.session.user.id;
|
||||
|
||||
// Get participant to check study access
|
||||
@@ -489,6 +527,7 @@ export const participantsRouter = createTRPCRouter({
|
||||
consentFormId,
|
||||
signatureData,
|
||||
ipAddress,
|
||||
storagePath,
|
||||
})
|
||||
.returning();
|
||||
|
||||
|
||||
@@ -34,6 +34,7 @@ import { s3Client } from "~/server/storage";
|
||||
import { GetObjectCommand } from "@aws-sdk/client-s3";
|
||||
import { getSignedUrl } from "@aws-sdk/s3-request-presigner";
|
||||
import { env } from "~/env";
|
||||
import { uploadFile } from "~/lib/storage/minio";
|
||||
|
||||
// Helper function to check if user has access to trial
|
||||
async function checkTrialAccess(
|
||||
@@ -542,6 +543,14 @@ export const trialsRouter = createTRPCRouter({
|
||||
});
|
||||
}
|
||||
|
||||
// Log trial start event
|
||||
await db.insert(trialEvents).values({
|
||||
trialId: input.id,
|
||||
eventType: "trial_started",
|
||||
timestamp: new Date(),
|
||||
data: { userId },
|
||||
});
|
||||
|
||||
return trial[0];
|
||||
}),
|
||||
|
||||
@@ -625,9 +634,136 @@ export const trialsRouter = createTRPCRouter({
|
||||
});
|
||||
}
|
||||
|
||||
// Log trial abort event
|
||||
await db.insert(trialEvents).values({
|
||||
trialId: input.id,
|
||||
eventType: "trial_aborted",
|
||||
timestamp: new Date(),
|
||||
data: { userId, reason: input.reason },
|
||||
});
|
||||
|
||||
return trial[0];
|
||||
}),
|
||||
|
||||
pause: protectedProcedure
|
||||
.input(
|
||||
z.object({
|
||||
id: z.string(),
|
||||
}),
|
||||
)
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
const { db } = ctx;
|
||||
const userId = ctx.session.user.id;
|
||||
|
||||
await checkTrialAccess(db, userId, input.id, [
|
||||
"owner",
|
||||
"researcher",
|
||||
"wizard",
|
||||
]);
|
||||
|
||||
// Log trial paused event
|
||||
await db.insert(trialEvents).values({
|
||||
trialId: input.id,
|
||||
eventType: "trial_paused",
|
||||
timestamp: new Date(),
|
||||
data: { userId },
|
||||
});
|
||||
|
||||
return { success: true };
|
||||
}),
|
||||
|
||||
archive: protectedProcedure
|
||||
.input(
|
||||
z.object({
|
||||
id: z.string(),
|
||||
}),
|
||||
)
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
const { db } = ctx;
|
||||
const userId = ctx.session.user.id;
|
||||
|
||||
const trial = await checkTrialAccess(db, userId, input.id, [
|
||||
"owner",
|
||||
"researcher",
|
||||
"wizard",
|
||||
]);
|
||||
|
||||
// 1. Fetch full trial data
|
||||
const trialData = await db.query.trials.findFirst({
|
||||
where: eq(trials.id, input.id),
|
||||
with: {
|
||||
experiment: true,
|
||||
participant: true,
|
||||
wizard: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (!trialData) {
|
||||
throw new TRPCError({
|
||||
code: "NOT_FOUND",
|
||||
message: "Trial data not found",
|
||||
});
|
||||
}
|
||||
|
||||
// 2. Fetch all events
|
||||
const events = await db
|
||||
.select()
|
||||
.from(trialEvents)
|
||||
.where(eq(trialEvents.trialId, input.id))
|
||||
.orderBy(asc(trialEvents.timestamp));
|
||||
|
||||
// 3. Fetch all interventions
|
||||
const interventions = await db
|
||||
.select()
|
||||
.from(wizardInterventions)
|
||||
.where(eq(wizardInterventions.trialId, input.id))
|
||||
.orderBy(asc(wizardInterventions.timestamp));
|
||||
|
||||
// 4. Construct Archive Object
|
||||
const archiveObject = {
|
||||
trial: trialData,
|
||||
events,
|
||||
interventions,
|
||||
archivedAt: new Date().toISOString(),
|
||||
archivedBy: userId,
|
||||
};
|
||||
|
||||
// 5. Upload to MinIO
|
||||
const filename = `archive-${input.id}-${Date.now()}.json`;
|
||||
const key = `trials/${input.id}/${filename}`;
|
||||
|
||||
try {
|
||||
const uploadResult = await uploadFile({
|
||||
key,
|
||||
body: JSON.stringify(archiveObject, null, 2),
|
||||
contentType: "application/json",
|
||||
});
|
||||
|
||||
// 6. Update Trial Metadata with Archive URL/Key
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const currentMetadata = (trialData.metadata as any) || {};
|
||||
await db
|
||||
.update(trials)
|
||||
.set({
|
||||
metadata: {
|
||||
...currentMetadata,
|
||||
archiveKey: uploadResult.key,
|
||||
archiveUrl: uploadResult.url,
|
||||
archivedAt: new Date(),
|
||||
},
|
||||
})
|
||||
.where(eq(trials.id, input.id));
|
||||
|
||||
return { success: true, url: uploadResult.url };
|
||||
} catch (error) {
|
||||
console.error("Failed to archive trial:", error);
|
||||
throw new TRPCError({
|
||||
code: "INTERNAL_SERVER_ERROR",
|
||||
message: "Failed to upload archive to storage",
|
||||
});
|
||||
}
|
||||
}),
|
||||
|
||||
logEvent: protectedProcedure
|
||||
.input(
|
||||
z.object({
|
||||
|
||||
Reference in New Issue
Block a user