feat: Implement trial event logging, archiving, experiment soft deletion, and new analytics/event data tables.

This commit is contained in:
2026-02-10 16:14:31 -05:00
parent 0f535f6887
commit a8c868ad3f
17 changed files with 1356 additions and 567 deletions

View File

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

View File

@@ -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();

View File

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