feat: Introduce dedicated participant, experiment, and trial detail/edit pages, enable MinIO, and refactor dashboard navigation.

This commit is contained in:
2025-12-11 20:04:52 -05:00
parent 5be4ff0372
commit d83c02759a
45 changed files with 4123 additions and 1455 deletions

View File

@@ -0,0 +1,146 @@
import { z } from "zod";
import { createTRPCRouter, protectedProcedure } from "~/server/api/trpc";
import { participantDocuments } from "~/server/db/schema";
import { TRPCError } from "@trpc/server";
import { env } from "~/env";
import * as Minio from "minio";
import { uuid } from "drizzle-orm/pg-core";
import { eq, desc } from "drizzle-orm";
// Initialize MinIO client
// Note: In production, ensure these ENV vars are set.
// For development with docker-compose, we use localhost:9000
const minioClient = new Minio.Client({
endPoint: (env.MINIO_ENDPOINT ?? "localhost").split(":")[0] ?? "localhost",
port: parseInt((env.MINIO_ENDPOINT ?? "9000").split(":")[1] ?? "9000"),
useSSL: false, // Default to false for local dev; adjust for prod
accessKey: env.MINIO_ACCESS_KEY ?? "minioadmin",
secretKey: env.MINIO_SECRET_KEY ?? "minioadmin",
});
const BUCKET_NAME = env.MINIO_BUCKET_NAME ?? "hristudio-assets";
// Ensure bucket exists on startup (best effort)
const ensureBucket = async () => {
try {
const exists = await minioClient.bucketExists(BUCKET_NAME);
if (!exists) {
await minioClient.makeBucket(BUCKET_NAME, env.MINIO_REGION ?? "us-east-1");
// Set public policy if needed? For now, keep private and use presigned URLs.
}
} catch (e) {
console.error("Error ensuring MinIO bucket exists:", e);
}
}
void ensureBucket(); // Fire and forget on load
export const filesRouter = createTRPCRouter({
// Get a presigned URL for uploading a file
getPresignedUrl: protectedProcedure
.input(z.object({
filename: z.string(),
contentType: z.string(),
participantId: z.string(),
}))
.mutation(async ({ input }) => {
const fileExtension = input.filename.split(".").pop();
const uniqueFilename = `${input.participantId}/${crypto.randomUUID()}.${fileExtension}`;
try {
const presignedUrl = await minioClient.presignedPutObject(
BUCKET_NAME,
uniqueFilename,
60 * 5 // 5 minutes expiry
);
return {
url: presignedUrl,
storagePath: uniqueFilename, // Pass this back to client to save in DB after upload
};
} catch (error) {
console.error("Error generating presigned URL:", error);
throw new TRPCError({
code: "INTERNAL_SERVER_ERROR",
message: "Failed to generate upload URL",
});
}
}),
// Get a presigned URL for downloading/viewing a file
getDownloadUrl: protectedProcedure
.input(z.object({
storagePath: z.string(),
}))
.query(async ({ input }) => {
try {
const url = await minioClient.presignedGetObject(
BUCKET_NAME,
input.storagePath,
60 * 60 // 1 hour
);
return { url };
} catch (error) {
throw new TRPCError({
code: "NOT_FOUND",
message: "File not found or storage error",
});
}
}),
// Record a successful upload in the database
registerUpload: protectedProcedure
.input(z.object({
participantId: z.string(),
name: z.string(),
type: z.string().optional(),
storagePath: z.string(),
fileSize: z.number().optional(),
}))
.mutation(async ({ ctx, input }) => {
await ctx.db.insert(participantDocuments).values({
participantId: input.participantId,
name: input.name,
type: input.type,
storagePath: input.storagePath,
fileSize: input.fileSize,
uploadedBy: ctx.session.user.id,
});
}),
// List documents for a participant
listParticipantDocuments: protectedProcedure
.input(z.object({ participantId: z.string() }))
.query(async ({ ctx, input }) => {
return await ctx.db.query.participantDocuments.findMany({
where: eq(participantDocuments.participantId, input.participantId),
orderBy: [desc(participantDocuments.createdAt)],
with: {
// Optional: join with uploader info if needed
}
});
}),
// Delete a document
deleteDocument: protectedProcedure
.input(z.object({ id: z.string() }))
.mutation(async ({ ctx, input }) => {
const doc = await ctx.db.query.participantDocuments.findFirst({
where: eq(participantDocuments.id, input.id),
});
if (!doc) {
throw new TRPCError({ code: "NOT_FOUND", message: "Document not found" });
}
// Delete from database
await ctx.db.delete(participantDocuments).where(eq(participantDocuments.id, input.id));
// Delete from MinIO (fire and forget or await)
try {
await minioClient.removeObject(BUCKET_NAME, doc.storagePath);
} catch (e) {
console.error("Failed to delete object from S3:", e);
// We still consider the operation successful for the user as the DB record is gone.
}
}),
});