mirror of
https://github.com/soconnor0919/hristudio.git
synced 2025-12-11 22:54:45 -05:00
Refactor API routes and enhance documentation; add collaboration features and user role management. Update environment example and improve error handling in authentication.
This commit is contained in:
551
src/server/api/routers/admin.ts
Normal file
551
src/server/api/routers/admin.ts
Normal file
@@ -0,0 +1,551 @@
|
||||
import { z } from "zod";
|
||||
import { eq, and, desc, gte, lte, inArray, count, type SQL } from "drizzle-orm";
|
||||
import { TRPCError } from "@trpc/server";
|
||||
import { createTRPCRouter, protectedProcedure } from "~/server/api/trpc";
|
||||
import type { db } from "~/server/db";
|
||||
import {
|
||||
users,
|
||||
studies,
|
||||
trials,
|
||||
experiments,
|
||||
participants,
|
||||
userSystemRoles,
|
||||
systemSettings,
|
||||
auditLogs,
|
||||
mediaCaptures,
|
||||
annotations,
|
||||
} from "~/server/db/schema";
|
||||
|
||||
// Helper function to check if user has system admin access
|
||||
async function checkSystemAdminAccess(database: typeof db, userId: string) {
|
||||
const adminRole = await database
|
||||
.select()
|
||||
.from(userSystemRoles)
|
||||
.where(
|
||||
and(
|
||||
eq(userSystemRoles.userId, userId),
|
||||
eq(userSystemRoles.role, "administrator"),
|
||||
),
|
||||
)
|
||||
.limit(1);
|
||||
|
||||
if (!adminRole[0]) {
|
||||
throw new TRPCError({
|
||||
code: "FORBIDDEN",
|
||||
message: "System administrator access required",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export const adminRouter = createTRPCRouter({
|
||||
getSystemStats: protectedProcedure
|
||||
.input(
|
||||
z.object({
|
||||
dateRange: z
|
||||
.object({
|
||||
startDate: z.date(),
|
||||
endDate: z.date(),
|
||||
})
|
||||
.optional(),
|
||||
}),
|
||||
)
|
||||
.query(async ({ ctx, input }) => {
|
||||
const { db: database } = ctx;
|
||||
const userId = ctx.session.user.id;
|
||||
|
||||
await checkSystemAdminAccess(database, userId);
|
||||
|
||||
const dateConditions = [];
|
||||
if (input.dateRange) {
|
||||
dateConditions.push(
|
||||
gte(users.createdAt, input.dateRange.startDate),
|
||||
lte(users.createdAt, input.dateRange.endDate),
|
||||
);
|
||||
}
|
||||
|
||||
// Get user statistics
|
||||
const totalUsersResult = await database
|
||||
.select({ count: count() })
|
||||
.from(users);
|
||||
const totalUsers = totalUsersResult[0]?.count ?? 0;
|
||||
|
||||
const newUsersResult = input.dateRange
|
||||
? await database
|
||||
.select({ count: count() })
|
||||
.from(users)
|
||||
.where(and(...dateConditions))
|
||||
: [];
|
||||
const newUsers = newUsersResult[0]?.count ?? 0;
|
||||
|
||||
// Get study statistics
|
||||
const totalStudiesResult = await database
|
||||
.select({ count: count() })
|
||||
.from(studies);
|
||||
const totalStudies = totalStudiesResult[0]?.count ?? 0;
|
||||
|
||||
const activeStudiesResult = await database
|
||||
.select({ count: count() })
|
||||
.from(studies)
|
||||
.where(eq(studies.status, "active"));
|
||||
const activeStudies = activeStudiesResult[0]?.count ?? 0;
|
||||
|
||||
// Get experiment statistics
|
||||
const totalExperimentsResult = await database
|
||||
.select({ count: count() })
|
||||
.from(experiments);
|
||||
const totalExperiments = totalExperimentsResult[0]?.count ?? 0;
|
||||
|
||||
// Get trial statistics
|
||||
const totalTrialsResult = await database
|
||||
.select({ count: count() })
|
||||
.from(trials);
|
||||
const totalTrials = totalTrialsResult[0]?.count ?? 0;
|
||||
|
||||
const completedTrialsResult = await database
|
||||
.select({ count: count() })
|
||||
.from(trials)
|
||||
.where(eq(trials.status, "completed"));
|
||||
const completedTrials = completedTrialsResult[0]?.count ?? 0;
|
||||
|
||||
const runningTrialsResult = await database
|
||||
.select({ count: count() })
|
||||
.from(trials)
|
||||
.where(eq(trials.status, "in_progress"));
|
||||
const runningTrials = runningTrialsResult[0]?.count ?? 0;
|
||||
|
||||
// Get participant statistics
|
||||
const totalParticipantsResult = await database
|
||||
.select({ count: count() })
|
||||
.from(participants);
|
||||
const totalParticipants = totalParticipantsResult[0]?.count ?? 0;
|
||||
|
||||
// Get storage statistics
|
||||
const totalMediaFilesResult = await database
|
||||
.select({ count: count() })
|
||||
.from(mediaCaptures);
|
||||
const totalMediaFiles = totalMediaFilesResult[0]?.count ?? 0;
|
||||
|
||||
const totalStorageSizeResult = await database
|
||||
.select({ totalSize: mediaCaptures.fileSize })
|
||||
.from(mediaCaptures);
|
||||
|
||||
const storageUsed = totalStorageSizeResult.reduce(
|
||||
(sum, file) => sum + (file.totalSize ?? 0),
|
||||
0,
|
||||
);
|
||||
|
||||
// Get annotation statistics
|
||||
const totalAnnotationsResult = await database
|
||||
.select({ count: count() })
|
||||
.from(annotations);
|
||||
const totalAnnotations = totalAnnotationsResult[0]?.count ?? 0;
|
||||
|
||||
return {
|
||||
users: {
|
||||
total: totalUsers,
|
||||
new: newUsers,
|
||||
active: totalUsers, // Users with recent activity
|
||||
},
|
||||
studies: {
|
||||
total: totalStudies,
|
||||
active: activeStudies,
|
||||
inactive: totalStudies - activeStudies,
|
||||
},
|
||||
experiments: {
|
||||
total: totalExperiments,
|
||||
},
|
||||
trials: {
|
||||
total: totalTrials,
|
||||
completed: completedTrials,
|
||||
running: runningTrials,
|
||||
scheduled: totalTrials - completedTrials - runningTrials,
|
||||
},
|
||||
participants: {
|
||||
total: totalParticipants,
|
||||
},
|
||||
storage: {
|
||||
totalFiles: totalMediaFiles,
|
||||
totalSize: storageUsed,
|
||||
averageFileSize:
|
||||
totalMediaFiles > 0 ? storageUsed / totalMediaFiles : 0,
|
||||
},
|
||||
annotations: {
|
||||
total: totalAnnotations,
|
||||
},
|
||||
system: {
|
||||
uptime: process.uptime(),
|
||||
nodeVersion: process.version,
|
||||
platform: process.platform,
|
||||
memory: process.memoryUsage(),
|
||||
},
|
||||
};
|
||||
}),
|
||||
|
||||
getSystemSettings: protectedProcedure.query(async ({ ctx }) => {
|
||||
const { db: database } = ctx;
|
||||
const userId = ctx.session.user.id;
|
||||
|
||||
await checkSystemAdminAccess(database, userId);
|
||||
|
||||
const settings = await database
|
||||
.select()
|
||||
.from(systemSettings)
|
||||
.orderBy(systemSettings.key);
|
||||
|
||||
// Convert to key-value object
|
||||
const settingsObj: Record<string, unknown> = {};
|
||||
settings.forEach((setting) => {
|
||||
settingsObj[setting.key] = setting.value;
|
||||
});
|
||||
|
||||
return settingsObj;
|
||||
}),
|
||||
|
||||
updateSystemSettings: protectedProcedure
|
||||
.input(
|
||||
z.object({
|
||||
settings: z.record(z.string(), z.unknown()),
|
||||
}),
|
||||
)
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
const { db: database } = ctx;
|
||||
const userId = ctx.session.user.id;
|
||||
|
||||
await checkSystemAdminAccess(database, userId);
|
||||
|
||||
const updatedSettings = [];
|
||||
|
||||
for (const [key, value] of Object.entries(input.settings)) {
|
||||
// Check if setting exists
|
||||
const existingSetting = await database
|
||||
.select()
|
||||
.from(systemSettings)
|
||||
.where(eq(systemSettings.key, key))
|
||||
.limit(1);
|
||||
|
||||
if (existingSetting[0]) {
|
||||
// Update existing setting
|
||||
const updatedResults = await database
|
||||
.update(systemSettings)
|
||||
.set({
|
||||
value,
|
||||
updatedAt: new Date(),
|
||||
updatedBy: userId,
|
||||
})
|
||||
.where(eq(systemSettings.key, key))
|
||||
.returning();
|
||||
const updated = updatedResults[0];
|
||||
if (updated) {
|
||||
updatedSettings.push(updated);
|
||||
}
|
||||
} else {
|
||||
// Create new setting
|
||||
const createdResults = await database
|
||||
.insert(systemSettings)
|
||||
.values({
|
||||
key,
|
||||
value,
|
||||
updatedBy: userId,
|
||||
})
|
||||
.returning();
|
||||
const created = createdResults[0];
|
||||
if (created) {
|
||||
updatedSettings.push(created);
|
||||
}
|
||||
}
|
||||
|
||||
// Log the setting change
|
||||
await database.insert(auditLogs).values({
|
||||
userId,
|
||||
action: existingSetting[0]
|
||||
? "UPDATE_SYSTEM_SETTING"
|
||||
: "CREATE_SYSTEM_SETTING",
|
||||
resourceType: "system_setting",
|
||||
resourceId: key,
|
||||
changes: {
|
||||
key,
|
||||
oldValue: existingSetting[0]?.value,
|
||||
newValue: value,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
updatedSettings,
|
||||
};
|
||||
}),
|
||||
|
||||
getAuditLog: protectedProcedure
|
||||
.input(
|
||||
z.object({
|
||||
userId: z.string().optional(),
|
||||
action: z.string().optional(),
|
||||
resourceType: z.string().optional(),
|
||||
resourceId: z.string().optional(),
|
||||
dateRange: z
|
||||
.object({
|
||||
startDate: z.date(),
|
||||
endDate: z.date(),
|
||||
})
|
||||
.optional(),
|
||||
limit: z.number().min(1).max(1000).default(100),
|
||||
offset: z.number().min(0).default(0),
|
||||
}),
|
||||
)
|
||||
.query(async ({ ctx, input }) => {
|
||||
const { db: database } = ctx;
|
||||
const userId = ctx.session.user.id;
|
||||
|
||||
await checkSystemAdminAccess(database, userId);
|
||||
|
||||
const conditions: SQL[] = [];
|
||||
|
||||
if (input.userId) {
|
||||
conditions.push(eq(auditLogs.userId, input.userId));
|
||||
}
|
||||
if (input.action) {
|
||||
conditions.push(eq(auditLogs.action, input.action));
|
||||
}
|
||||
if (input.resourceType) {
|
||||
conditions.push(eq(auditLogs.resourceType, input.resourceType));
|
||||
}
|
||||
if (input.resourceId) {
|
||||
conditions.push(eq(auditLogs.resourceId, input.resourceId));
|
||||
}
|
||||
if (input.dateRange) {
|
||||
conditions.push(
|
||||
gte(auditLogs.createdAt, input.dateRange.startDate),
|
||||
lte(auditLogs.createdAt, input.dateRange.endDate),
|
||||
);
|
||||
}
|
||||
|
||||
const logs = await database
|
||||
.select()
|
||||
.from(auditLogs)
|
||||
.innerJoin(users, eq(auditLogs.userId, users.id))
|
||||
.where(conditions.length > 0 ? and(...conditions) : undefined)
|
||||
.orderBy(desc(auditLogs.createdAt))
|
||||
.limit(input.limit)
|
||||
.offset(input.offset);
|
||||
|
||||
return logs;
|
||||
}),
|
||||
|
||||
createBackup: protectedProcedure
|
||||
.input(
|
||||
z.object({
|
||||
includeMediaFiles: z.boolean().default(false),
|
||||
description: z.string().optional(),
|
||||
}),
|
||||
)
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
const { db: database } = ctx;
|
||||
const userId = ctx.session.user.id;
|
||||
|
||||
await checkSystemAdminAccess(database, userId);
|
||||
|
||||
// Log the backup request
|
||||
await database.insert(auditLogs).values({
|
||||
userId,
|
||||
action: "CREATE_BACKUP",
|
||||
resourceType: "system",
|
||||
resourceId: "backup",
|
||||
changes: {
|
||||
includeMediaFiles: input.includeMediaFiles,
|
||||
description: input.description,
|
||||
},
|
||||
});
|
||||
|
||||
// TODO: Implement actual backup logic
|
||||
// This would typically involve:
|
||||
// 1. Creating a database dump
|
||||
// 2. Optionally backing up media files from R2
|
||||
// 3. Compressing the backup
|
||||
// 4. Storing it in a secure location
|
||||
// 5. Returning backup metadata
|
||||
|
||||
// For now, return a mock response
|
||||
const backupId = `backup-${Date.now()}`;
|
||||
const estimatedSize = input.includeMediaFiles ? "2.5GB" : "250MB";
|
||||
|
||||
return {
|
||||
backupId,
|
||||
status: "initiated",
|
||||
estimatedSize,
|
||||
estimatedCompletionTime: new Date(Date.now() + 15 * 60 * 1000), // 15 minutes
|
||||
includeMediaFiles: input.includeMediaFiles,
|
||||
description: input.description,
|
||||
createdBy: userId,
|
||||
createdAt: new Date(),
|
||||
};
|
||||
}),
|
||||
|
||||
getBackupStatus: protectedProcedure
|
||||
.input(
|
||||
z.object({
|
||||
backupId: z.string(),
|
||||
}),
|
||||
)
|
||||
.query(async ({ ctx, input }) => {
|
||||
const { db: database } = ctx;
|
||||
const userId = ctx.session.user.id;
|
||||
|
||||
await checkSystemAdminAccess(database, userId);
|
||||
|
||||
// TODO: Implement actual backup status checking
|
||||
// This would query a backup jobs table or external service
|
||||
|
||||
// Mock response
|
||||
return {
|
||||
backupId: input.backupId,
|
||||
status: "completed",
|
||||
progress: 100,
|
||||
fileSize: "245MB",
|
||||
downloadUrl: `https://mock-backup-storage.com/backups/${input.backupId}.tar.gz`,
|
||||
expiresAt: new Date(Date.now() + 7 * 24 * 60 * 60 * 1000), // 7 days
|
||||
createdAt: new Date(Date.now() - 15 * 60 * 1000), // 15 minutes ago
|
||||
completedAt: new Date(),
|
||||
};
|
||||
}),
|
||||
|
||||
getUserManagement: protectedProcedure
|
||||
.input(
|
||||
z.object({
|
||||
search: z.string().optional(),
|
||||
role: z.string().optional(),
|
||||
status: z.enum(["active", "inactive"]).optional(),
|
||||
limit: z.number().min(1).max(100).default(50),
|
||||
offset: z.number().min(0).default(0),
|
||||
}),
|
||||
)
|
||||
.query(async ({ ctx, input }) => {
|
||||
const { db: database } = ctx;
|
||||
const userId = ctx.session.user.id;
|
||||
|
||||
await checkSystemAdminAccess(database, userId);
|
||||
|
||||
const conditions: SQL[] = [];
|
||||
|
||||
// TODO: Add search functionality when implemented
|
||||
// if (input.search) {
|
||||
// conditions.push(
|
||||
// or(
|
||||
// ilike(users.name, `%${input.search}%`),
|
||||
// ilike(users.email, `%${input.search}%`)
|
||||
// )
|
||||
// );
|
||||
// }
|
||||
|
||||
const userList = await database
|
||||
.select({
|
||||
id: users.id,
|
||||
name: users.name,
|
||||
email: users.email,
|
||||
emailVerified: users.emailVerified,
|
||||
image: users.image,
|
||||
createdAt: users.createdAt,
|
||||
updatedAt: users.updatedAt,
|
||||
})
|
||||
.from(users)
|
||||
.where(conditions.length > 0 ? and(...conditions) : undefined)
|
||||
.orderBy(desc(users.createdAt))
|
||||
.limit(input.limit)
|
||||
.offset(input.offset);
|
||||
|
||||
// Get system roles for each user
|
||||
const userIds = userList.map((u) => u.id);
|
||||
const systemRoles =
|
||||
userIds.length > 0
|
||||
? await database
|
||||
.select()
|
||||
.from(userSystemRoles)
|
||||
.where(inArray(userSystemRoles.userId, userIds))
|
||||
: [];
|
||||
|
||||
// Combine user data with roles
|
||||
const usersWithRoles = userList.map((user) => ({
|
||||
...user,
|
||||
systemRoles: systemRoles
|
||||
.filter((role) => role.userId === user.id)
|
||||
.map((role) => role.role),
|
||||
}));
|
||||
|
||||
return usersWithRoles;
|
||||
}),
|
||||
|
||||
updateUserSystemRole: protectedProcedure
|
||||
.input(
|
||||
z.object({
|
||||
targetUserId: z.string(),
|
||||
role: z.enum(["administrator", "researcher"]),
|
||||
action: z.enum(["grant", "revoke"]),
|
||||
}),
|
||||
)
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
const { db: database } = ctx;
|
||||
const userId = ctx.session.user.id;
|
||||
|
||||
await checkSystemAdminAccess(database, userId);
|
||||
|
||||
// Prevent self-modification of admin role
|
||||
if (input.targetUserId === userId && input.role === "administrator") {
|
||||
throw new TRPCError({
|
||||
code: "BAD_REQUEST",
|
||||
message: "Cannot modify your own admin role",
|
||||
});
|
||||
}
|
||||
|
||||
if (input.action === "grant") {
|
||||
// Check if role already exists
|
||||
const existingRole = await database
|
||||
.select()
|
||||
.from(userSystemRoles)
|
||||
.where(
|
||||
and(
|
||||
eq(userSystemRoles.userId, input.targetUserId),
|
||||
eq(userSystemRoles.role, input.role),
|
||||
),
|
||||
)
|
||||
.limit(1);
|
||||
|
||||
if (existingRole[0]) {
|
||||
throw new TRPCError({
|
||||
code: "CONFLICT",
|
||||
message: "User already has this role",
|
||||
});
|
||||
}
|
||||
|
||||
await database.insert(userSystemRoles).values({
|
||||
userId: input.targetUserId,
|
||||
role: input.role,
|
||||
grantedBy: userId,
|
||||
});
|
||||
} else {
|
||||
// Revoke role
|
||||
await database
|
||||
.delete(userSystemRoles)
|
||||
.where(
|
||||
and(
|
||||
eq(userSystemRoles.userId, input.targetUserId),
|
||||
eq(userSystemRoles.role, input.role),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
// Log the role change
|
||||
await database.insert(auditLogs).values({
|
||||
userId,
|
||||
action:
|
||||
input.action === "grant" ? "GRANT_SYSTEM_ROLE" : "REVOKE_SYSTEM_ROLE",
|
||||
resourceType: "user",
|
||||
resourceId: input.targetUserId,
|
||||
changes: {
|
||||
role: input.role,
|
||||
action: input.action,
|
||||
},
|
||||
});
|
||||
|
||||
return { success: true };
|
||||
}),
|
||||
});
|
||||
555
src/server/api/routers/analytics.ts
Normal file
555
src/server/api/routers/analytics.ts
Normal file
@@ -0,0 +1,555 @@
|
||||
import { z } from "zod";
|
||||
import { eq, and, desc, asc, gte, lte, inArray, type SQL } from "drizzle-orm";
|
||||
import { TRPCError } from "@trpc/server";
|
||||
import { createTRPCRouter, protectedProcedure } from "~/server/api/trpc";
|
||||
import {
|
||||
annotations,
|
||||
exportJobs,
|
||||
trials,
|
||||
experiments,
|
||||
studyMembers,
|
||||
exportStatusEnum,
|
||||
} from "~/server/db/schema";
|
||||
import type { db } from "~/server/db";
|
||||
|
||||
// Helper function to check if user has access to trial for analytics operations
|
||||
async function checkTrialAccess(
|
||||
database: typeof db,
|
||||
userId: string,
|
||||
trialId: string,
|
||||
requiredRoles: ("owner" | "researcher" | "wizard" | "observer")[] = [
|
||||
"owner",
|
||||
"researcher",
|
||||
"wizard",
|
||||
],
|
||||
) {
|
||||
const trial = await database
|
||||
.select({
|
||||
id: trials.id,
|
||||
experimentId: trials.experimentId,
|
||||
studyId: experiments.studyId,
|
||||
})
|
||||
.from(trials)
|
||||
.innerJoin(experiments, eq(trials.experimentId, experiments.id))
|
||||
.where(eq(trials.id, trialId))
|
||||
.limit(1);
|
||||
|
||||
if (!trial[0]) {
|
||||
throw new TRPCError({
|
||||
code: "NOT_FOUND",
|
||||
message: "Trial not found",
|
||||
});
|
||||
}
|
||||
|
||||
const membership = await database
|
||||
.select()
|
||||
.from(studyMembers)
|
||||
.where(
|
||||
and(
|
||||
eq(studyMembers.studyId, trial[0].studyId),
|
||||
eq(studyMembers.userId, userId),
|
||||
inArray(studyMembers.role, requiredRoles),
|
||||
),
|
||||
)
|
||||
.limit(1);
|
||||
|
||||
if (!membership[0]) {
|
||||
throw new TRPCError({
|
||||
code: "FORBIDDEN",
|
||||
message: "Insufficient permissions to access this trial",
|
||||
});
|
||||
}
|
||||
|
||||
return trial[0];
|
||||
}
|
||||
|
||||
// Helper function to check study access for analytics
|
||||
async function checkStudyAccess(
|
||||
database: typeof db,
|
||||
userId: string,
|
||||
studyId: string,
|
||||
requiredRoles: ("owner" | "researcher" | "wizard" | "observer")[] = [
|
||||
"owner",
|
||||
"researcher",
|
||||
],
|
||||
) {
|
||||
const membership = await database
|
||||
.select()
|
||||
.from(studyMembers)
|
||||
.where(
|
||||
and(
|
||||
eq(studyMembers.studyId, studyId),
|
||||
eq(studyMembers.userId, userId),
|
||||
inArray(studyMembers.role, requiredRoles),
|
||||
),
|
||||
)
|
||||
.limit(1);
|
||||
|
||||
if (!membership[0]) {
|
||||
throw new TRPCError({
|
||||
code: "FORBIDDEN",
|
||||
message: "Insufficient permissions to access this study",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
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(),
|
||||
}),
|
||||
)
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
const { db } = ctx;
|
||||
const userId = ctx.session.user.id;
|
||||
|
||||
await checkTrialAccess(db, userId, input.trialId);
|
||||
|
||||
const annotationResults = await db
|
||||
.insert(annotations)
|
||||
.values({
|
||||
trialId: input.trialId,
|
||||
annotatorId: userId,
|
||||
timestampStart: input.startTime,
|
||||
timestampEnd: input.endTime,
|
||||
category: input.category,
|
||||
label: input.label,
|
||||
description: input.description,
|
||||
tags: input.tags,
|
||||
metadata: input.metadata,
|
||||
})
|
||||
.returning();
|
||||
|
||||
const annotation = annotationResults[0];
|
||||
if (!annotation) {
|
||||
throw new TRPCError({
|
||||
code: "INTERNAL_SERVER_ERROR",
|
||||
message: "Failed to create annotation",
|
||||
});
|
||||
}
|
||||
|
||||
return annotation;
|
||||
}),
|
||||
|
||||
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(),
|
||||
}),
|
||||
)
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
const { db } = ctx;
|
||||
const userId = ctx.session.user.id;
|
||||
|
||||
// Get annotation to check access
|
||||
const existingAnnotation = await db
|
||||
.select({
|
||||
id: annotations.id,
|
||||
trialId: annotations.trialId,
|
||||
annotatorId: annotations.annotatorId,
|
||||
})
|
||||
.from(annotations)
|
||||
.where(eq(annotations.id, input.id))
|
||||
.limit(1);
|
||||
|
||||
if (!existingAnnotation[0]) {
|
||||
throw new TRPCError({
|
||||
code: "NOT_FOUND",
|
||||
message: "Annotation not found",
|
||||
});
|
||||
}
|
||||
|
||||
// Check trial access
|
||||
await checkTrialAccess(db, userId, existingAnnotation[0].trialId);
|
||||
|
||||
// Only allow annotation creator or study owners/researchers to edit
|
||||
if (existingAnnotation[0].annotatorId !== userId) {
|
||||
await checkTrialAccess(db, userId, existingAnnotation[0].trialId, [
|
||||
"owner",
|
||||
"researcher",
|
||||
]);
|
||||
}
|
||||
|
||||
const updateData: {
|
||||
updatedAt: Date;
|
||||
timestampStart?: Date;
|
||||
timestampEnd?: Date;
|
||||
category?: string;
|
||||
label?: string;
|
||||
description?: string;
|
||||
tags?: string[];
|
||||
metadata?: Record<string, unknown>;
|
||||
} = {
|
||||
updatedAt: new Date(),
|
||||
};
|
||||
|
||||
if (input.startTime !== undefined)
|
||||
updateData.timestampStart = input.startTime;
|
||||
if (input.endTime !== undefined) updateData.timestampEnd = input.endTime;
|
||||
if (input.category !== undefined) updateData.category = input.category;
|
||||
if (input.label !== undefined) updateData.label = input.label;
|
||||
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>;
|
||||
|
||||
const annotationResults = await db
|
||||
.update(annotations)
|
||||
.set(updateData)
|
||||
.where(eq(annotations.id, input.id))
|
||||
.returning();
|
||||
|
||||
const annotation = annotationResults[0];
|
||||
if (!annotation) {
|
||||
throw new TRPCError({
|
||||
code: "INTERNAL_SERVER_ERROR",
|
||||
message: "Failed to update annotation",
|
||||
});
|
||||
}
|
||||
|
||||
return annotation;
|
||||
}),
|
||||
|
||||
deleteAnnotation: protectedProcedure
|
||||
.input(
|
||||
z.object({
|
||||
id: z.string(),
|
||||
}),
|
||||
)
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
const { db } = ctx;
|
||||
const userId = ctx.session.user.id;
|
||||
|
||||
// Get annotation to check access
|
||||
const existingAnnotation = await db
|
||||
.select({
|
||||
id: annotations.id,
|
||||
trialId: annotations.trialId,
|
||||
annotatorId: annotations.annotatorId,
|
||||
})
|
||||
.from(annotations)
|
||||
.where(eq(annotations.id, input.id))
|
||||
.limit(1);
|
||||
|
||||
if (!existingAnnotation[0]) {
|
||||
throw new TRPCError({
|
||||
code: "NOT_FOUND",
|
||||
message: "Annotation not found",
|
||||
});
|
||||
}
|
||||
|
||||
// Check trial access
|
||||
await checkTrialAccess(db, userId, existingAnnotation[0].trialId);
|
||||
|
||||
// Only allow annotation creator or study owners/researchers to delete
|
||||
if (existingAnnotation[0].annotatorId !== userId) {
|
||||
await checkTrialAccess(db, userId, existingAnnotation[0].trialId, [
|
||||
"owner",
|
||||
"researcher",
|
||||
]);
|
||||
}
|
||||
|
||||
await db.delete(annotations).where(eq(annotations.id, input.id));
|
||||
|
||||
return { success: true };
|
||||
}),
|
||||
|
||||
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),
|
||||
}),
|
||||
)
|
||||
.query(async ({ ctx, input }) => {
|
||||
const { db } = ctx;
|
||||
const userId = ctx.session.user.id;
|
||||
|
||||
await checkTrialAccess(db, userId, input.trialId);
|
||||
|
||||
const conditions: SQL[] = [eq(annotations.trialId, input.trialId)];
|
||||
|
||||
if (input.category) {
|
||||
conditions.push(eq(annotations.category, input.category));
|
||||
}
|
||||
if (input.annotatorId) {
|
||||
conditions.push(eq(annotations.annotatorId, input.annotatorId));
|
||||
}
|
||||
if (input.startTime !== undefined) {
|
||||
conditions.push(gte(annotations.timestampStart, input.startTime));
|
||||
}
|
||||
if (input.endTime !== undefined) {
|
||||
conditions.push(lte(annotations.timestampEnd, input.endTime));
|
||||
}
|
||||
|
||||
const rawResults = await db
|
||||
.select()
|
||||
.from(annotations)
|
||||
.where(and(...conditions))
|
||||
.orderBy(asc(annotations.timestampStart))
|
||||
.limit(input.limit)
|
||||
.offset(input.offset);
|
||||
|
||||
// Map to expected output format
|
||||
const results = rawResults.map((annotation) => ({
|
||||
id: annotation.id,
|
||||
trialId: annotation.trialId,
|
||||
annotatorId: annotation.annotatorId,
|
||||
startTime: annotation.timestampStart,
|
||||
endTime: annotation.timestampEnd,
|
||||
category: annotation.category,
|
||||
label: annotation.label,
|
||||
description: annotation.description,
|
||||
tags: annotation.tags as string[],
|
||||
metadata: annotation.metadata,
|
||||
createdAt: annotation.createdAt,
|
||||
updatedAt: annotation.updatedAt,
|
||||
}));
|
||||
|
||||
// Filter by tags if provided
|
||||
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 results;
|
||||
}),
|
||||
|
||||
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(),
|
||||
}),
|
||||
)
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
const { db } = ctx;
|
||||
const userId = ctx.session.user.id;
|
||||
|
||||
await checkStudyAccess(db, userId, input.studyId);
|
||||
|
||||
// Create export job
|
||||
const exportJobResults = await db
|
||||
.insert(exportJobs)
|
||||
.values({
|
||||
studyId: input.studyId,
|
||||
requestedBy: userId,
|
||||
exportType: input.exportType,
|
||||
format: input.format,
|
||||
filters: input.filters,
|
||||
status: "pending",
|
||||
})
|
||||
.returning();
|
||||
|
||||
const exportJob = exportJobResults[0];
|
||||
if (!exportJob) {
|
||||
throw new TRPCError({
|
||||
code: "INTERNAL_SERVER_ERROR",
|
||||
message: "Failed to create export job",
|
||||
});
|
||||
}
|
||||
|
||||
// TODO: Trigger background job to process export
|
||||
// This would typically be handled by a queue system like Bull/BullMQ
|
||||
// For now, we'll simulate the process
|
||||
|
||||
// Simulate processing time
|
||||
// Capture variables for setTimeout closure
|
||||
const jobId = exportJob.id;
|
||||
const studyId = input.studyId;
|
||||
const format = input.format;
|
||||
const database = db;
|
||||
|
||||
setTimeout(() => {
|
||||
// Mock file generation
|
||||
const fileName = `study-${studyId}-export-${Date.now()}.${format}`;
|
||||
const fileUrl = `https://mock-r2-bucket.com/exports/${fileName}`;
|
||||
|
||||
database
|
||||
.update(exportJobs)
|
||||
.set({
|
||||
status: "completed",
|
||||
storagePath: fileUrl,
|
||||
completedAt: new Date(),
|
||||
})
|
||||
.where(eq(exportJobs.id, jobId))
|
||||
.then(() => {
|
||||
// Success handled
|
||||
})
|
||||
.catch((error: unknown) => {
|
||||
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
|
||||
});
|
||||
});
|
||||
}, 5000); // 5 second delay
|
||||
|
||||
return {
|
||||
jobId: exportJob.id,
|
||||
status: exportJob.status,
|
||||
estimatedCompletionTime: new Date(Date.now() + 30000), // 30 seconds
|
||||
};
|
||||
}),
|
||||
|
||||
getExportStatus: protectedProcedure
|
||||
.input(
|
||||
z.object({
|
||||
jobId: z.string(),
|
||||
}),
|
||||
)
|
||||
.query(async ({ ctx, input }) => {
|
||||
const { db } = ctx;
|
||||
const userId = ctx.session.user.id;
|
||||
|
||||
const exportJob = await db
|
||||
.select({
|
||||
id: exportJobs.id,
|
||||
studyId: exportJobs.studyId,
|
||||
requestedBy: exportJobs.requestedBy,
|
||||
exportType: exportJobs.exportType,
|
||||
format: exportJobs.format,
|
||||
status: exportJobs.status,
|
||||
storagePath: exportJobs.storagePath,
|
||||
errorMessage: exportJobs.errorMessage,
|
||||
filters: exportJobs.filters,
|
||||
createdAt: exportJobs.createdAt,
|
||||
completedAt: exportJobs.completedAt,
|
||||
})
|
||||
.from(exportJobs)
|
||||
.where(eq(exportJobs.id, input.jobId))
|
||||
.limit(1);
|
||||
|
||||
if (!exportJob[0]) {
|
||||
throw new TRPCError({
|
||||
code: "NOT_FOUND",
|
||||
message: "Export job not found",
|
||||
});
|
||||
}
|
||||
|
||||
// Check user has access to the study
|
||||
await checkStudyAccess(db, userId, exportJob[0].studyId);
|
||||
|
||||
return exportJob[0];
|
||||
}),
|
||||
|
||||
getExportHistory: protectedProcedure
|
||||
.input(
|
||||
z.object({
|
||||
studyId: z.string(),
|
||||
status: z.enum(exportStatusEnum.enumValues).optional(),
|
||||
limit: z.number().min(1).max(100).default(20),
|
||||
offset: z.number().min(0).default(0),
|
||||
}),
|
||||
)
|
||||
.query(async ({ ctx, input }) => {
|
||||
const { db } = ctx;
|
||||
const userId = ctx.session.user.id;
|
||||
|
||||
await checkStudyAccess(db, userId, input.studyId);
|
||||
|
||||
const conditions: SQL[] = [eq(exportJobs.studyId, input.studyId)];
|
||||
|
||||
if (input.status) {
|
||||
conditions.push(eq(exportJobs.status, input.status));
|
||||
}
|
||||
|
||||
const results = await db
|
||||
.select()
|
||||
.from(exportJobs)
|
||||
.where(and(...conditions))
|
||||
.orderBy(desc(exportJobs.createdAt))
|
||||
.limit(input.limit)
|
||||
.offset(input.offset);
|
||||
|
||||
return results;
|
||||
}),
|
||||
|
||||
getTrialStatistics: protectedProcedure
|
||||
.input(
|
||||
z.object({
|
||||
studyId: z.string(),
|
||||
experimentId: z.string().optional(),
|
||||
}),
|
||||
)
|
||||
.query(async ({ ctx, input }) => {
|
||||
const { db } = ctx;
|
||||
const userId = ctx.session.user.id;
|
||||
|
||||
await checkStudyAccess(db, userId, input.studyId);
|
||||
|
||||
// Get trial statistics
|
||||
const conditions: SQL[] = [eq(experiments.studyId, input.studyId)];
|
||||
if (input.experimentId) {
|
||||
conditions.push(eq(trials.experimentId, input.experimentId));
|
||||
}
|
||||
|
||||
const trialStats = await db
|
||||
.select({
|
||||
trial: trials,
|
||||
experiment: experiments,
|
||||
})
|
||||
.from(trials)
|
||||
.innerJoin(experiments, eq(trials.experimentId, experiments.id))
|
||||
.where(and(...conditions));
|
||||
|
||||
// Calculate statistics
|
||||
const stats = {
|
||||
totalTrials: trialStats.length,
|
||||
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,
|
||||
avgDuration: 0,
|
||||
totalDuration: 0,
|
||||
};
|
||||
|
||||
const completedTrials = trialStats.filter(
|
||||
(t) => t.trial.status === "completed" && t.trial.duration !== null,
|
||||
);
|
||||
|
||||
if (completedTrials.length > 0) {
|
||||
const durations = completedTrials.map((t) => t.trial.duration!);
|
||||
stats.totalDuration = durations.reduce((sum, d) => sum + d, 0);
|
||||
stats.avgDuration = stats.totalDuration / durations.length;
|
||||
}
|
||||
|
||||
return stats;
|
||||
}),
|
||||
});
|
||||
@@ -3,7 +3,11 @@ import bcrypt from "bcryptjs";
|
||||
import { TRPCError } from "@trpc/server";
|
||||
import { eq } from "drizzle-orm";
|
||||
|
||||
import { createTRPCRouter, publicProcedure } from "~/server/api/trpc";
|
||||
import {
|
||||
createTRPCRouter,
|
||||
publicProcedure,
|
||||
protectedProcedure,
|
||||
} from "~/server/api/trpc";
|
||||
import { users } from "~/server/db/schema";
|
||||
|
||||
export const authRouter = createTRPCRouter({
|
||||
@@ -35,7 +39,7 @@ export const authRouter = createTRPCRouter({
|
||||
|
||||
try {
|
||||
// Create user
|
||||
const [newUser] = await ctx.db
|
||||
const newUsers = await ctx.db
|
||||
.insert(users)
|
||||
.values({
|
||||
name,
|
||||
@@ -46,14 +50,66 @@ export const authRouter = createTRPCRouter({
|
||||
id: users.id,
|
||||
name: users.name,
|
||||
email: users.email,
|
||||
createdAt: users.createdAt,
|
||||
});
|
||||
|
||||
const newUser = newUsers[0];
|
||||
if (!newUser) {
|
||||
throw new TRPCError({
|
||||
code: "INTERNAL_SERVER_ERROR",
|
||||
message: "Failed to create user",
|
||||
});
|
||||
}
|
||||
|
||||
return newUser;
|
||||
} catch (error) {
|
||||
} catch (error: unknown) {
|
||||
throw new TRPCError({
|
||||
code: "INTERNAL_SERVER_ERROR",
|
||||
message: "Failed to create user",
|
||||
message:
|
||||
error instanceof Error ? error.message : "Failed to create user",
|
||||
});
|
||||
}
|
||||
}),
|
||||
|
||||
logout: protectedProcedure.mutation(async ({ ctx: _ctx }) => {
|
||||
// Note: Actual logout is handled by NextAuth.js
|
||||
// This endpoint is for any additional cleanup if needed
|
||||
return { success: true };
|
||||
}),
|
||||
|
||||
me: protectedProcedure.query(async ({ ctx }) => {
|
||||
const userId = ctx.session.user.id;
|
||||
|
||||
const user = await ctx.db.query.users.findFirst({
|
||||
where: eq(users.id, userId),
|
||||
with: {
|
||||
systemRoles: {
|
||||
with: {
|
||||
grantedByUser: {
|
||||
columns: {
|
||||
id: true,
|
||||
name: true,
|
||||
email: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
columns: {
|
||||
password: false, // Exclude password from response
|
||||
},
|
||||
});
|
||||
|
||||
if (!user) {
|
||||
throw new TRPCError({
|
||||
code: "NOT_FOUND",
|
||||
message: "User not found",
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
...user,
|
||||
roles: user.systemRoles.map((sr) => sr.role),
|
||||
};
|
||||
}),
|
||||
});
|
||||
|
||||
604
src/server/api/routers/collaboration.ts
Normal file
604
src/server/api/routers/collaboration.ts
Normal file
@@ -0,0 +1,604 @@
|
||||
import { z } from "zod";
|
||||
import { eq, and, desc, inArray, isNull } from "drizzle-orm";
|
||||
import { TRPCError } from "@trpc/server";
|
||||
import { createTRPCRouter, protectedProcedure } from "~/server/api/trpc";
|
||||
import {
|
||||
comments,
|
||||
attachments,
|
||||
sharedResources,
|
||||
experiments,
|
||||
trials,
|
||||
studyMembers,
|
||||
} from "~/server/db/schema";
|
||||
import type { db } from "~/server/db";
|
||||
|
||||
// Helper function to check if user has access to a resource
|
||||
async function checkResourceAccess(
|
||||
database: typeof db,
|
||||
userId: string,
|
||||
resourceType: string,
|
||||
resourceId: string,
|
||||
requiredRoles: ("owner" | "researcher" | "wizard" | "observer")[] = [
|
||||
"owner",
|
||||
"researcher",
|
||||
"wizard",
|
||||
],
|
||||
) {
|
||||
let studyId: string | undefined;
|
||||
|
||||
switch (resourceType) {
|
||||
case "study":
|
||||
studyId = resourceId;
|
||||
break;
|
||||
case "experiment":
|
||||
const experiment = await database
|
||||
.select({ studyId: experiments.studyId })
|
||||
.from(experiments)
|
||||
.where(eq(experiments.id, resourceId))
|
||||
.limit(1);
|
||||
if (!experiment[0]) {
|
||||
throw new TRPCError({
|
||||
code: "NOT_FOUND",
|
||||
message: "Experiment not found",
|
||||
});
|
||||
}
|
||||
studyId = experiment[0].studyId;
|
||||
break;
|
||||
case "trial":
|
||||
const trial = await database
|
||||
.select({ studyId: experiments.studyId })
|
||||
.from(trials)
|
||||
.innerJoin(experiments, eq(trials.experimentId, experiments.id))
|
||||
.where(eq(trials.id, resourceId))
|
||||
.limit(1);
|
||||
if (!trial[0]) {
|
||||
throw new TRPCError({
|
||||
code: "NOT_FOUND",
|
||||
message: "Trial not found",
|
||||
});
|
||||
}
|
||||
studyId = trial[0].studyId;
|
||||
break;
|
||||
default:
|
||||
throw new TRPCError({
|
||||
code: "BAD_REQUEST",
|
||||
message: "Invalid resource type",
|
||||
});
|
||||
}
|
||||
|
||||
if (!studyId) {
|
||||
throw new TRPCError({
|
||||
code: "NOT_FOUND",
|
||||
message: "Resource not found",
|
||||
});
|
||||
}
|
||||
|
||||
const membership = await database
|
||||
.select()
|
||||
.from(studyMembers)
|
||||
.where(
|
||||
and(
|
||||
eq(studyMembers.studyId, studyId),
|
||||
eq(studyMembers.userId, userId),
|
||||
inArray(studyMembers.role, requiredRoles),
|
||||
),
|
||||
)
|
||||
.limit(1);
|
||||
|
||||
if (!membership[0]) {
|
||||
throw new TRPCError({
|
||||
code: "FORBIDDEN",
|
||||
message: "Insufficient permissions to access this resource",
|
||||
});
|
||||
}
|
||||
|
||||
return studyId;
|
||||
}
|
||||
|
||||
// Helper function to generate presigned upload URL for attachments
|
||||
async function generateAttachmentUploadUrl(
|
||||
fileName: string,
|
||||
contentType: string,
|
||||
studyId: string,
|
||||
): Promise<{ uploadUrl: string; fileUrl: string }> {
|
||||
// TODO: Implement actual R2 presigned URL generation for attachments
|
||||
const key = `studies/${studyId}/attachments/${Date.now()}-${fileName}`;
|
||||
|
||||
// Mock implementation - replace with actual R2 integration
|
||||
return {
|
||||
uploadUrl: `https://mock-r2-bucket.com/upload/${key}`,
|
||||
fileUrl: `https://mock-r2-bucket.com/files/${key}`,
|
||||
};
|
||||
}
|
||||
|
||||
export const collaborationRouter = createTRPCRouter({
|
||||
createComment: protectedProcedure
|
||||
.input(
|
||||
z.object({
|
||||
resourceType: z.enum(["study", "experiment", "trial"]),
|
||||
resourceId: z.string(),
|
||||
content: z.string().min(1).max(5000),
|
||||
parentId: z.string().optional(), // For threaded comments
|
||||
metadata: z.any().optional(),
|
||||
}),
|
||||
)
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
const { db } = ctx;
|
||||
const userId = ctx.session.user.id;
|
||||
|
||||
// Check access to the resource
|
||||
await checkResourceAccess(
|
||||
db,
|
||||
userId,
|
||||
input.resourceType,
|
||||
input.resourceId,
|
||||
);
|
||||
|
||||
// If this is a reply, verify parent comment exists and belongs to same resource
|
||||
if (input.parentId) {
|
||||
const parentComment = await db
|
||||
.select({
|
||||
id: comments.id,
|
||||
resourceType: comments.resourceType,
|
||||
resourceId: comments.resourceId,
|
||||
})
|
||||
.from(comments)
|
||||
.where(eq(comments.id, input.parentId))
|
||||
.limit(1);
|
||||
|
||||
if (!parentComment[0]) {
|
||||
throw new TRPCError({
|
||||
code: "NOT_FOUND",
|
||||
message: "Parent comment not found",
|
||||
});
|
||||
}
|
||||
|
||||
if (
|
||||
parentComment[0].resourceType !== input.resourceType ||
|
||||
parentComment[0].resourceId !== input.resourceId
|
||||
) {
|
||||
throw new TRPCError({
|
||||
code: "BAD_REQUEST",
|
||||
message: "Parent comment does not belong to the same resource",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const commentResults = await db
|
||||
.insert(comments)
|
||||
.values({
|
||||
resourceType: input.resourceType,
|
||||
resourceId: input.resourceId,
|
||||
authorId: userId,
|
||||
content: input.content,
|
||||
parentId: input.parentId,
|
||||
metadata: input.metadata,
|
||||
})
|
||||
.returning();
|
||||
|
||||
const comment = commentResults[0];
|
||||
if (!comment) {
|
||||
throw new TRPCError({
|
||||
code: "INTERNAL_SERVER_ERROR",
|
||||
message: "Failed to create comment",
|
||||
});
|
||||
}
|
||||
|
||||
return comment;
|
||||
}),
|
||||
|
||||
getComments: protectedProcedure
|
||||
.input(
|
||||
z.object({
|
||||
resourceType: z.enum(["study", "experiment", "trial"]),
|
||||
resourceId: z.string(),
|
||||
parentId: z.string().optional(), // Get replies to a specific comment
|
||||
includeReplies: z.boolean().default(true),
|
||||
limit: z.number().min(1).max(100).default(50),
|
||||
offset: z.number().min(0).default(0),
|
||||
}),
|
||||
)
|
||||
.query(async ({ ctx, input }) => {
|
||||
const { db } = ctx;
|
||||
const userId = ctx.session.user.id;
|
||||
|
||||
// Check access to the resource
|
||||
await checkResourceAccess(
|
||||
db,
|
||||
userId,
|
||||
input.resourceType,
|
||||
input.resourceId,
|
||||
);
|
||||
|
||||
const conditions = [
|
||||
eq(comments.resourceType, input.resourceType),
|
||||
eq(comments.resourceId, input.resourceId),
|
||||
];
|
||||
|
||||
if (input.parentId) {
|
||||
conditions.push(eq(comments.parentId, input.parentId));
|
||||
} else if (!input.includeReplies) {
|
||||
// Only get top-level comments
|
||||
conditions.push(isNull(comments.parentId));
|
||||
}
|
||||
|
||||
const results = await db
|
||||
.select({
|
||||
id: comments.id,
|
||||
resourceType: comments.resourceType,
|
||||
resourceId: comments.resourceId,
|
||||
authorId: comments.authorId,
|
||||
content: comments.content,
|
||||
parentId: comments.parentId,
|
||||
metadata: comments.metadata,
|
||||
createdAt: comments.createdAt,
|
||||
updatedAt: comments.updatedAt,
|
||||
})
|
||||
.from(comments)
|
||||
.where(and(...conditions))
|
||||
.orderBy(desc(comments.createdAt))
|
||||
.limit(input.limit)
|
||||
.offset(input.offset);
|
||||
|
||||
return results;
|
||||
}),
|
||||
|
||||
deleteComment: protectedProcedure
|
||||
.input(
|
||||
z.object({
|
||||
id: z.string(),
|
||||
}),
|
||||
)
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
const { db } = ctx;
|
||||
const userId = ctx.session.user.id;
|
||||
|
||||
// Get the comment to check ownership and resource access
|
||||
const comment = await db
|
||||
.select({
|
||||
id: comments.id,
|
||||
resourceType: comments.resourceType,
|
||||
resourceId: comments.resourceId,
|
||||
authorId: comments.authorId,
|
||||
})
|
||||
.from(comments)
|
||||
.where(eq(comments.id, input.id))
|
||||
.limit(1);
|
||||
|
||||
if (!comment[0]) {
|
||||
throw new TRPCError({
|
||||
code: "NOT_FOUND",
|
||||
message: "Comment not found",
|
||||
});
|
||||
}
|
||||
|
||||
// Check access to the resource
|
||||
await checkResourceAccess(
|
||||
db,
|
||||
userId,
|
||||
comment[0].resourceType,
|
||||
comment[0].resourceId,
|
||||
);
|
||||
|
||||
// Only allow comment author or study owners/researchers to delete
|
||||
if (comment[0].authorId !== userId) {
|
||||
await checkResourceAccess(
|
||||
db,
|
||||
userId,
|
||||
comment[0].resourceType,
|
||||
comment[0].resourceId,
|
||||
["owner", "researcher"],
|
||||
);
|
||||
}
|
||||
|
||||
// Soft delete by updating content (preserve for audit trail)
|
||||
await db
|
||||
.update(comments)
|
||||
.set({
|
||||
content: "[Comment deleted]",
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
.where(eq(comments.id, input.id))
|
||||
.returning();
|
||||
|
||||
return { success: true };
|
||||
}),
|
||||
|
||||
uploadAttachment: protectedProcedure
|
||||
.input(
|
||||
z.object({
|
||||
resourceType: z.enum(["study", "experiment", "trial"]),
|
||||
resourceId: z.string(),
|
||||
fileName: z.string(),
|
||||
fileSize: z.number().min(1),
|
||||
contentType: z.string(),
|
||||
description: z.string().optional(),
|
||||
}),
|
||||
)
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
const { db } = ctx;
|
||||
const userId = ctx.session.user.id;
|
||||
|
||||
// Check access to the resource
|
||||
const studyId = await checkResourceAccess(
|
||||
db,
|
||||
userId,
|
||||
input.resourceType,
|
||||
input.resourceId,
|
||||
);
|
||||
|
||||
// Generate presigned upload URL
|
||||
const { uploadUrl, fileUrl } = await generateAttachmentUploadUrl(
|
||||
input.fileName,
|
||||
input.contentType,
|
||||
studyId,
|
||||
);
|
||||
|
||||
// Create attachment record
|
||||
const attachmentResults = await db
|
||||
.insert(attachments)
|
||||
.values({
|
||||
resourceType: input.resourceType,
|
||||
resourceId: input.resourceId,
|
||||
fileName: input.fileName,
|
||||
fileSize: input.fileSize,
|
||||
filePath: fileUrl,
|
||||
contentType: input.contentType,
|
||||
description: input.description,
|
||||
uploadedBy: userId,
|
||||
})
|
||||
.returning();
|
||||
|
||||
const attachment = attachmentResults[0];
|
||||
if (!attachment) {
|
||||
throw new TRPCError({
|
||||
code: "INTERNAL_SERVER_ERROR",
|
||||
message: "Failed to create attachment",
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
attachment,
|
||||
uploadUrl,
|
||||
};
|
||||
}),
|
||||
|
||||
getAttachments: protectedProcedure
|
||||
.input(
|
||||
z.object({
|
||||
resourceType: z.enum(["study", "experiment", "trial"]),
|
||||
resourceId: z.string(),
|
||||
limit: z.number().min(1).max(100).default(50),
|
||||
offset: z.number().min(0).default(0),
|
||||
}),
|
||||
)
|
||||
.query(async ({ ctx, input }) => {
|
||||
const { db } = ctx;
|
||||
const userId = ctx.session.user.id;
|
||||
|
||||
// Check access to the resource
|
||||
await checkResourceAccess(
|
||||
db,
|
||||
userId,
|
||||
input.resourceType,
|
||||
input.resourceId,
|
||||
);
|
||||
|
||||
const results = await db
|
||||
.select({
|
||||
id: attachments.id,
|
||||
resourceType: attachments.resourceType,
|
||||
resourceId: attachments.resourceId,
|
||||
fileName: attachments.fileName,
|
||||
fileSize: attachments.fileSize,
|
||||
filePath: attachments.filePath,
|
||||
contentType: attachments.contentType,
|
||||
description: attachments.description,
|
||||
uploadedBy: attachments.uploadedBy,
|
||||
createdAt: attachments.createdAt,
|
||||
})
|
||||
.from(attachments)
|
||||
.where(
|
||||
and(
|
||||
eq(attachments.resourceType, input.resourceType),
|
||||
eq(attachments.resourceId, input.resourceId),
|
||||
),
|
||||
)
|
||||
.orderBy(desc(attachments.createdAt))
|
||||
.limit(input.limit)
|
||||
.offset(input.offset);
|
||||
|
||||
return results;
|
||||
}),
|
||||
|
||||
// Token-based sharing functionality
|
||||
createShareLink: protectedProcedure
|
||||
.input(
|
||||
z.object({
|
||||
resourceType: z.enum(["study", "experiment", "trial"]),
|
||||
resourceId: z.string(),
|
||||
permissions: z.array(z.enum(["read", "comment", "annotate"])).default(["read"]),
|
||||
expiresAt: z.date().optional(),
|
||||
description: z.string().optional(),
|
||||
}),
|
||||
)
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
const { db } = ctx;
|
||||
const userId = ctx.session.user.id;
|
||||
|
||||
// Check access to the resource (only owners and researchers can share)
|
||||
const studyId = await checkResourceAccess(
|
||||
db,
|
||||
userId,
|
||||
input.resourceType,
|
||||
input.resourceId,
|
||||
["owner", "researcher"],
|
||||
);
|
||||
|
||||
// Generate a unique share token
|
||||
const shareToken = `share_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`;
|
||||
|
||||
const sharedResourceResults = await db
|
||||
.insert(sharedResources)
|
||||
.values({
|
||||
studyId: studyId,
|
||||
resourceType: input.resourceType,
|
||||
resourceId: input.resourceId,
|
||||
sharedBy: userId,
|
||||
shareToken: shareToken,
|
||||
permissions: input.permissions,
|
||||
expiresAt: input.expiresAt,
|
||||
})
|
||||
.returning();
|
||||
|
||||
const sharedResource = sharedResourceResults[0];
|
||||
if (!sharedResource) {
|
||||
throw new TRPCError({
|
||||
code: "INTERNAL_SERVER_ERROR",
|
||||
message: "Failed to create shared resource",
|
||||
});
|
||||
}
|
||||
|
||||
// Generate the share URL
|
||||
const shareUrl = `${process.env.NEXT_PUBLIC_APP_URL}/shared/${shareToken}`;
|
||||
|
||||
return {
|
||||
...sharedResource,
|
||||
shareUrl,
|
||||
};
|
||||
}),
|
||||
|
||||
getSharedResources: protectedProcedure
|
||||
.input(
|
||||
z.object({
|
||||
limit: z.number().min(1).max(100).default(50),
|
||||
offset: z.number().min(0).default(0),
|
||||
}),
|
||||
)
|
||||
.query(async ({ ctx, input }) => {
|
||||
const { db } = ctx;
|
||||
const userId = ctx.session.user.id;
|
||||
|
||||
// Get resources shared by the current user
|
||||
const results = await db
|
||||
.select({
|
||||
id: sharedResources.id,
|
||||
studyId: sharedResources.studyId,
|
||||
resourceType: sharedResources.resourceType,
|
||||
resourceId: sharedResources.resourceId,
|
||||
shareToken: sharedResources.shareToken,
|
||||
permissions: sharedResources.permissions,
|
||||
expiresAt: sharedResources.expiresAt,
|
||||
accessCount: sharedResources.accessCount,
|
||||
createdAt: sharedResources.createdAt,
|
||||
})
|
||||
.from(sharedResources)
|
||||
.where(eq(sharedResources.sharedBy, userId))
|
||||
.orderBy(desc(sharedResources.createdAt))
|
||||
.limit(input.limit)
|
||||
.offset(input.offset);
|
||||
|
||||
// Add share URLs to the results
|
||||
return results.map((resource) => ({
|
||||
...resource,
|
||||
shareUrl: `${process.env.NEXT_PUBLIC_APP_URL}/shared/${resource.shareToken}`,
|
||||
}));
|
||||
}),
|
||||
|
||||
revokeShare: protectedProcedure
|
||||
.input(
|
||||
z.object({
|
||||
shareId: z.string(),
|
||||
}),
|
||||
)
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
const { db } = ctx;
|
||||
const userId = ctx.session.user.id;
|
||||
|
||||
// Check if the share exists and belongs to the user
|
||||
const share = await db
|
||||
.select({
|
||||
id: sharedResources.id,
|
||||
sharedBy: sharedResources.sharedBy,
|
||||
})
|
||||
.from(sharedResources)
|
||||
.where(eq(sharedResources.id, input.shareId))
|
||||
.limit(1);
|
||||
|
||||
if (!share[0]) {
|
||||
throw new TRPCError({
|
||||
code: "NOT_FOUND",
|
||||
message: "Share not found",
|
||||
});
|
||||
}
|
||||
|
||||
if (share[0].sharedBy !== userId) {
|
||||
throw new TRPCError({
|
||||
code: "FORBIDDEN",
|
||||
message: "You can only revoke your own shares",
|
||||
});
|
||||
}
|
||||
|
||||
// Delete the share
|
||||
await db.delete(sharedResources).where(eq(sharedResources.id, input.shareId));
|
||||
|
||||
return { success: true };
|
||||
}),
|
||||
|
||||
// Public endpoint for accessing shared resources (no authentication required)
|
||||
accessSharedResource: protectedProcedure
|
||||
.input(
|
||||
z.object({
|
||||
shareToken: z.string(),
|
||||
}),
|
||||
)
|
||||
.query(async ({ ctx, input }) => {
|
||||
const { db } = ctx;
|
||||
|
||||
// Find the shared resource
|
||||
const sharedResource = await db
|
||||
.select({
|
||||
id: sharedResources.id,
|
||||
studyId: sharedResources.studyId,
|
||||
resourceType: sharedResources.resourceType,
|
||||
resourceId: sharedResources.resourceId,
|
||||
permissions: sharedResources.permissions,
|
||||
expiresAt: sharedResources.expiresAt,
|
||||
accessCount: sharedResources.accessCount,
|
||||
})
|
||||
.from(sharedResources)
|
||||
.where(eq(sharedResources.shareToken, input.shareToken))
|
||||
.limit(1);
|
||||
|
||||
if (!sharedResource[0]) {
|
||||
throw new TRPCError({
|
||||
code: "NOT_FOUND",
|
||||
message: "Share link not found or has expired",
|
||||
});
|
||||
}
|
||||
|
||||
// Check if the share has expired
|
||||
if (sharedResource[0].expiresAt && sharedResource[0].expiresAt < new Date()) {
|
||||
throw new TRPCError({
|
||||
code: "NOT_FOUND",
|
||||
message: "Share link has expired",
|
||||
});
|
||||
}
|
||||
|
||||
// Increment access count
|
||||
await db
|
||||
.update(sharedResources)
|
||||
.set({
|
||||
accessCount: sharedResource[0].accessCount + 1,
|
||||
})
|
||||
.where(eq(sharedResources.id, sharedResource[0].id));
|
||||
|
||||
return {
|
||||
resourceType: sharedResource[0].resourceType,
|
||||
resourceId: sharedResource[0].resourceId,
|
||||
permissions: sharedResource[0].permissions,
|
||||
// Note: The actual resource data would be fetched based on resourceType and resourceId
|
||||
// This is just the metadata about the share
|
||||
};
|
||||
}),
|
||||
});
|
||||
1018
src/server/api/routers/experiments.ts
Normal file
1018
src/server/api/routers/experiments.ts
Normal file
File diff suppressed because it is too large
Load Diff
420
src/server/api/routers/media.ts
Normal file
420
src/server/api/routers/media.ts
Normal file
@@ -0,0 +1,420 @@
|
||||
import { TRPCError } from "@trpc/server";
|
||||
import { and, asc, desc, eq, gte, inArray, lte, type SQL } from "drizzle-orm";
|
||||
import { z } from "zod";
|
||||
import { createTRPCRouter, protectedProcedure } from "~/server/api/trpc";
|
||||
import type { db } from "~/server/db";
|
||||
import {
|
||||
experiments,
|
||||
mediaCaptures,
|
||||
sensorData,
|
||||
studyMembers,
|
||||
trials
|
||||
} from "~/server/db/schema";
|
||||
|
||||
// Helper function to check if user has access to trial for media operations
|
||||
async function checkTrialAccess(
|
||||
database: typeof db,
|
||||
userId: string,
|
||||
trialId: string,
|
||||
requiredRoles: string[] = ["owner", "researcher", "wizard"],
|
||||
) {
|
||||
const trial = await database
|
||||
.select({
|
||||
id: trials.id,
|
||||
experimentId: trials.experimentId,
|
||||
studyId: experiments.studyId,
|
||||
})
|
||||
.from(trials)
|
||||
.innerJoin(experiments, eq(trials.experimentId, experiments.id))
|
||||
.where(eq(trials.id, trialId))
|
||||
.limit(1);
|
||||
|
||||
if (!trial[0]) {
|
||||
throw new TRPCError({
|
||||
code: "NOT_FOUND",
|
||||
message: "Trial not found",
|
||||
});
|
||||
}
|
||||
|
||||
const membership = await database
|
||||
.select()
|
||||
.from(studyMembers)
|
||||
.where(
|
||||
and(
|
||||
eq(studyMembers.studyId, trial[0].studyId),
|
||||
eq(studyMembers.userId, userId),
|
||||
inArray(studyMembers.role, requiredRoles as ("owner" | "researcher" | "wizard" | "observer")[]),
|
||||
),
|
||||
)
|
||||
.limit(1);
|
||||
|
||||
if (!membership[0]) {
|
||||
throw new TRPCError({
|
||||
code: "FORBIDDEN",
|
||||
message: "Insufficient permissions to access this trial",
|
||||
});
|
||||
}
|
||||
|
||||
return trial[0];
|
||||
}
|
||||
|
||||
// Helper function to generate presigned upload URL for R2
|
||||
async function generatePresignedUploadUrl(
|
||||
fileName: string,
|
||||
contentType: string,
|
||||
studyId: string,
|
||||
): Promise<{ uploadUrl: string; fileUrl: string }> {
|
||||
// TODO: Implement actual R2 presigned URL generation
|
||||
// This would use AWS SDK or similar to generate presigned URLs for Cloudflare R2
|
||||
|
||||
const key = `studies/${studyId}/media/${Date.now()}-${fileName}`;
|
||||
|
||||
// Mock implementation - replace with actual R2 integration
|
||||
return {
|
||||
uploadUrl: `https://mock-r2-bucket.com/upload/${key}`,
|
||||
fileUrl: `https://mock-r2-bucket.com/files/${key}`,
|
||||
};
|
||||
}
|
||||
|
||||
export const mediaRouter = createTRPCRouter({
|
||||
uploadVideo: protectedProcedure
|
||||
.input(
|
||||
z.object({
|
||||
trialId: z.string(),
|
||||
fileName: z.string(),
|
||||
fileSize: z.number().min(1),
|
||||
contentType: z.string(),
|
||||
duration: z.number().optional(),
|
||||
metadata: z.any().optional(),
|
||||
}),
|
||||
)
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
const { db } = ctx;
|
||||
const userId = ctx.session.user.id;
|
||||
|
||||
const trial = await checkTrialAccess(db, userId, input.trialId);
|
||||
|
||||
// Validate content type
|
||||
if (!input.contentType.startsWith("video/")) {
|
||||
throw new TRPCError({
|
||||
code: "BAD_REQUEST",
|
||||
message: "Invalid content type for video upload",
|
||||
});
|
||||
}
|
||||
|
||||
// Generate presigned upload URL
|
||||
const { uploadUrl, fileUrl } = await generatePresignedUploadUrl(
|
||||
input.fileName,
|
||||
input.contentType,
|
||||
trial.studyId,
|
||||
);
|
||||
|
||||
// Create media capture record
|
||||
const mediaCaptureResults = await db
|
||||
.insert(mediaCaptures)
|
||||
.values({
|
||||
trialId: input.trialId,
|
||||
mediaType: "video",
|
||||
storagePath: fileUrl,
|
||||
fileSize: input.fileSize,
|
||||
duration: input.duration,
|
||||
metadata: input.metadata,
|
||||
})
|
||||
.returning();
|
||||
|
||||
const mediaCapture = mediaCaptureResults[0];
|
||||
if (!mediaCapture) {
|
||||
throw new TRPCError({
|
||||
code: "INTERNAL_SERVER_ERROR",
|
||||
message: "Failed to create media capture record",
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
mediaCapture,
|
||||
uploadUrl,
|
||||
};
|
||||
}),
|
||||
|
||||
uploadAudio: protectedProcedure
|
||||
.input(
|
||||
z.object({
|
||||
trialId: z.string(),
|
||||
fileName: z.string(),
|
||||
fileSize: z.number().min(1),
|
||||
contentType: z.string(),
|
||||
duration: z.number().optional(),
|
||||
metadata: z.any().optional(),
|
||||
}),
|
||||
)
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
const { db } = ctx;
|
||||
const userId = ctx.session.user.id;
|
||||
|
||||
const trial = await checkTrialAccess(db, userId, input.trialId);
|
||||
|
||||
// Validate content type
|
||||
if (!input.contentType.startsWith("audio/")) {
|
||||
throw new TRPCError({
|
||||
code: "BAD_REQUEST",
|
||||
message: "Invalid content type for audio upload",
|
||||
});
|
||||
}
|
||||
|
||||
// Generate presigned upload URL
|
||||
const { uploadUrl, fileUrl } = await generatePresignedUploadUrl(
|
||||
input.fileName,
|
||||
input.contentType,
|
||||
trial.studyId,
|
||||
);
|
||||
|
||||
// Create media capture record
|
||||
const mediaCaptureResults2 = await db
|
||||
.insert(mediaCaptures)
|
||||
.values({
|
||||
trialId: input.trialId,
|
||||
mediaType: "audio",
|
||||
storagePath: fileUrl,
|
||||
fileSize: input.fileSize,
|
||||
format: input.contentType,
|
||||
duration: input.duration,
|
||||
metadata: input.metadata,
|
||||
})
|
||||
.returning();
|
||||
|
||||
const mediaCapture = mediaCaptureResults2[0];
|
||||
if (!mediaCapture) {
|
||||
throw new TRPCError({
|
||||
code: "INTERNAL_SERVER_ERROR",
|
||||
message: "Failed to create media capture record",
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
mediaCapture,
|
||||
uploadUrl,
|
||||
};
|
||||
}),
|
||||
|
||||
list: protectedProcedure
|
||||
.input(
|
||||
z.object({
|
||||
trialId: z.string().optional(),
|
||||
studyId: z.string().optional(),
|
||||
type: z.enum(["video", "audio", "image"]).optional(),
|
||||
limit: z.number().min(1).max(100).default(50),
|
||||
offset: z.number().min(0).default(0),
|
||||
}),
|
||||
)
|
||||
.query(async ({ ctx, input }) => {
|
||||
const { db } = ctx;
|
||||
const userId = ctx.session.user.id;
|
||||
|
||||
const conditions: SQL[] = [];
|
||||
|
||||
if (input.trialId) {
|
||||
await checkTrialAccess(db, userId, input.trialId);
|
||||
conditions.push(eq(mediaCaptures.trialId, input.trialId));
|
||||
}
|
||||
|
||||
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")[]),
|
||||
...conditions,
|
||||
);
|
||||
|
||||
const results = await db
|
||||
.select({
|
||||
id: mediaCaptures.id,
|
||||
trialId: mediaCaptures.trialId,
|
||||
mediaType: mediaCaptures.mediaType,
|
||||
storagePath: mediaCaptures.storagePath,
|
||||
fileSize: mediaCaptures.fileSize,
|
||||
format: mediaCaptures.format,
|
||||
duration: mediaCaptures.duration,
|
||||
metadata: mediaCaptures.metadata,
|
||||
createdAt: mediaCaptures.createdAt,
|
||||
trial: {
|
||||
id: trials.id,
|
||||
experimentId: trials.experimentId,
|
||||
},
|
||||
})
|
||||
.from(mediaCaptures)
|
||||
.innerJoin(trials, eq(mediaCaptures.trialId, trials.id))
|
||||
.innerJoin(experiments, eq(trials.experimentId, experiments.id))
|
||||
.innerJoin(studyMembers, eq(studyMembers.studyId, experiments.studyId))
|
||||
.where(whereClause)
|
||||
.orderBy(desc(mediaCaptures.createdAt))
|
||||
.limit(input.limit)
|
||||
.offset(input.offset);
|
||||
|
||||
return results;
|
||||
}),
|
||||
|
||||
getUrl: protectedProcedure
|
||||
.input(
|
||||
z.object({
|
||||
id: z.string(),
|
||||
}),
|
||||
)
|
||||
.query(async ({ ctx, input }) => {
|
||||
const { db } = ctx;
|
||||
const userId = ctx.session.user.id;
|
||||
|
||||
const media = await db
|
||||
.select({
|
||||
id: mediaCaptures.id,
|
||||
trialId: mediaCaptures.trialId,
|
||||
storagePath: mediaCaptures.storagePath,
|
||||
format: mediaCaptures.format,
|
||||
})
|
||||
.from(mediaCaptures)
|
||||
.where(eq(mediaCaptures.id, input.id))
|
||||
.limit(1);
|
||||
|
||||
if (!media[0]) {
|
||||
throw new TRPCError({
|
||||
code: "NOT_FOUND",
|
||||
message: "Media file not found",
|
||||
});
|
||||
}
|
||||
|
||||
// Check access through trial
|
||||
await checkTrialAccess(db, userId, media[0].trialId);
|
||||
|
||||
// TODO: Generate presigned download URL for R2
|
||||
// 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',
|
||||
expiresAt: new Date(Date.now() + 60 * 60 * 1000), // 1 hour
|
||||
};
|
||||
}),
|
||||
|
||||
delete: protectedProcedure
|
||||
.input(
|
||||
z.object({
|
||||
id: z.string(),
|
||||
}),
|
||||
)
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
const { db } = ctx;
|
||||
const userId = ctx.session.user.id;
|
||||
|
||||
const media = await db
|
||||
.select({
|
||||
id: mediaCaptures.id,
|
||||
trialId: mediaCaptures.trialId,
|
||||
storagePath: mediaCaptures.storagePath,
|
||||
})
|
||||
.from(mediaCaptures)
|
||||
.where(eq(mediaCaptures.id, input.id))
|
||||
.limit(1);
|
||||
|
||||
if (!media[0]) {
|
||||
throw new TRPCError({
|
||||
code: "NOT_FOUND",
|
||||
message: "Media file not found",
|
||||
});
|
||||
}
|
||||
|
||||
// Check access through trial (only researchers and owners can delete)
|
||||
await checkTrialAccess(db, userId, media[0].trialId, [
|
||||
"owner",
|
||||
"researcher",
|
||||
]);
|
||||
|
||||
// Delete from database
|
||||
await db.delete(mediaCaptures).where(eq(mediaCaptures.id, input.id));
|
||||
|
||||
// TODO: Delete from R2 storage
|
||||
// await deleteFromR2(media[0].storagePath);
|
||||
|
||||
return { success: true };
|
||||
}),
|
||||
|
||||
// Sensor data recording and querying
|
||||
sensorData: createTRPCRouter({
|
||||
record: protectedProcedure
|
||||
.input(
|
||||
z.object({
|
||||
trialId: z.string(),
|
||||
sensorType: z.string(),
|
||||
timestamp: z.date(),
|
||||
data: z.any(),
|
||||
metadata: z.any().optional(),
|
||||
}),
|
||||
)
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
const { db } = ctx;
|
||||
const userId = ctx.session.user.id;
|
||||
|
||||
await checkTrialAccess(db, userId, input.trialId);
|
||||
|
||||
const sensorRecordResults = await db
|
||||
.insert(sensorData)
|
||||
.values({
|
||||
trialId: input.trialId,
|
||||
sensorType: input.sensorType,
|
||||
timestamp: input.timestamp,
|
||||
data: input.data,
|
||||
})
|
||||
.returning();
|
||||
|
||||
const sensorRecord = sensorRecordResults[0];
|
||||
if (!sensorRecord) {
|
||||
throw new TRPCError({
|
||||
code: "INTERNAL_SERVER_ERROR",
|
||||
message: "Failed to create sensor data record",
|
||||
});
|
||||
}
|
||||
|
||||
return sensorRecord;
|
||||
}),
|
||||
|
||||
query: protectedProcedure
|
||||
.input(
|
||||
z.object({
|
||||
trialId: z.string(),
|
||||
sensorType: z.string().optional(),
|
||||
startTime: z.date().optional(),
|
||||
endTime: z.date().optional(),
|
||||
limit: z.number().min(1).max(10000).default(1000),
|
||||
offset: z.number().min(0).default(0),
|
||||
}),
|
||||
)
|
||||
.query(async ({ ctx, input }) => {
|
||||
const { db } = ctx;
|
||||
const userId = ctx.session.user.id;
|
||||
|
||||
await checkTrialAccess(db, userId, input.trialId);
|
||||
|
||||
const conditions = [eq(sensorData.trialId, input.trialId)];
|
||||
|
||||
if (input.sensorType) {
|
||||
conditions.push(eq(sensorData.sensorType, input.sensorType));
|
||||
}
|
||||
if (input.startTime) {
|
||||
conditions.push(gte(sensorData.timestamp, input.startTime));
|
||||
}
|
||||
if (input.endTime) {
|
||||
conditions.push(lte(sensorData.timestamp, input.endTime));
|
||||
}
|
||||
|
||||
const results = await db
|
||||
.select()
|
||||
.from(sensorData)
|
||||
.where(and(...conditions))
|
||||
.orderBy(asc(sensorData.timestamp))
|
||||
.limit(input.limit)
|
||||
.offset(input.offset);
|
||||
|
||||
return results;
|
||||
}),
|
||||
}),
|
||||
});
|
||||
636
src/server/api/routers/participants.ts
Normal file
636
src/server/api/routers/participants.ts
Normal file
@@ -0,0 +1,636 @@
|
||||
import { z } from "zod";
|
||||
import { TRPCError } from "@trpc/server";
|
||||
import { and, count, eq, desc, ilike, or } from "drizzle-orm";
|
||||
|
||||
import { createTRPCRouter, protectedProcedure } from "~/server/api/trpc";
|
||||
import type { db } from "~/server/db";
|
||||
import {
|
||||
participants,
|
||||
participantConsents,
|
||||
consentForms,
|
||||
studyMembers,
|
||||
activityLogs,
|
||||
trials,
|
||||
} from "~/server/db/schema";
|
||||
|
||||
// Helper function to check study access
|
||||
async function checkStudyAccess(
|
||||
database: typeof db,
|
||||
userId: string,
|
||||
studyId: string,
|
||||
requiredRole?: string[],
|
||||
) {
|
||||
const membership = await database.query.studyMembers.findFirst({
|
||||
where: and(
|
||||
eq(studyMembers.studyId, studyId),
|
||||
eq(studyMembers.userId, userId),
|
||||
),
|
||||
});
|
||||
|
||||
if (!membership) {
|
||||
throw new TRPCError({
|
||||
code: "FORBIDDEN",
|
||||
message: "You don't have access to this study",
|
||||
});
|
||||
}
|
||||
|
||||
if (requiredRole && !requiredRole.includes(membership.role)) {
|
||||
throw new TRPCError({
|
||||
code: "FORBIDDEN",
|
||||
message: "You don't have permission to perform this action",
|
||||
});
|
||||
}
|
||||
|
||||
return membership;
|
||||
}
|
||||
|
||||
export const participantsRouter = createTRPCRouter({
|
||||
list: protectedProcedure
|
||||
.input(
|
||||
z.object({
|
||||
studyId: z.string().uuid(),
|
||||
page: z.number().min(1).default(1),
|
||||
limit: z.number().min(1).max(100).default(20),
|
||||
search: z.string().optional(),
|
||||
}),
|
||||
)
|
||||
.query(async ({ ctx, input }) => {
|
||||
const { studyId, page, limit, search } = input;
|
||||
const offset = (page - 1) * limit;
|
||||
const userId = ctx.session.user.id;
|
||||
|
||||
// Check study access
|
||||
await checkStudyAccess(ctx.db, userId, studyId);
|
||||
|
||||
// Build where conditions
|
||||
const conditions = [eq(participants.studyId, studyId)];
|
||||
|
||||
if (search) {
|
||||
conditions.push(
|
||||
or(
|
||||
ilike(participants.participantCode, `%${search}%`),
|
||||
ilike(participants.name, `%${search}%`),
|
||||
ilike(participants.email, `%${search}%`),
|
||||
)!,
|
||||
);
|
||||
}
|
||||
|
||||
const whereClause = and(...conditions);
|
||||
|
||||
// Get participants with consent info
|
||||
const participantsList = await ctx.db.query.participants.findMany({
|
||||
where: whereClause,
|
||||
with: {
|
||||
consents: {
|
||||
with: {
|
||||
consentForm: {
|
||||
columns: {
|
||||
id: true,
|
||||
title: true,
|
||||
version: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
orderBy: [desc(participantConsents.signedAt)],
|
||||
},
|
||||
trials: {
|
||||
columns: {
|
||||
id: true,
|
||||
status: true,
|
||||
scheduledAt: true,
|
||||
completedAt: true,
|
||||
},
|
||||
orderBy: [desc(trials.scheduledAt)],
|
||||
},
|
||||
},
|
||||
columns: {
|
||||
// Exclude sensitive data from list view
|
||||
demographics: false,
|
||||
notes: false,
|
||||
},
|
||||
limit,
|
||||
offset,
|
||||
orderBy: [desc(participants.createdAt)],
|
||||
});
|
||||
|
||||
// Get total count
|
||||
const totalCountResult = await ctx.db
|
||||
.select({ count: count() })
|
||||
.from(participants)
|
||||
.where(whereClause);
|
||||
|
||||
const totalCount = totalCountResult[0]?.count ?? 0;
|
||||
|
||||
return {
|
||||
participants: participantsList.map((participant) => ({
|
||||
...participant,
|
||||
trialCount: participant.trials.length,
|
||||
hasConsent: participant.consents.length > 0,
|
||||
latestConsent: participant.consents[0] ?? null,
|
||||
})),
|
||||
pagination: {
|
||||
page,
|
||||
limit,
|
||||
total: totalCount,
|
||||
pages: Math.ceil(totalCount / limit),
|
||||
},
|
||||
};
|
||||
}),
|
||||
|
||||
get: protectedProcedure
|
||||
.input(z.object({ id: z.string().uuid() }))
|
||||
.query(async ({ ctx, input }) => {
|
||||
const userId = ctx.session.user.id;
|
||||
|
||||
const participant = await ctx.db.query.participants.findFirst({
|
||||
where: eq(participants.id, input.id),
|
||||
with: {
|
||||
study: {
|
||||
columns: {
|
||||
id: true,
|
||||
name: true,
|
||||
},
|
||||
},
|
||||
consents: {
|
||||
with: {
|
||||
consentForm: true,
|
||||
},
|
||||
orderBy: [desc(participantConsents.signedAt)],
|
||||
},
|
||||
trials: {
|
||||
with: {
|
||||
experiment: {
|
||||
columns: {
|
||||
id: true,
|
||||
name: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
orderBy: [desc(trials.scheduledAt)],
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (!participant) {
|
||||
throw new TRPCError({
|
||||
code: "NOT_FOUND",
|
||||
message: "Participant not found",
|
||||
});
|
||||
}
|
||||
|
||||
// Check study access
|
||||
await checkStudyAccess(ctx.db, userId, participant.studyId);
|
||||
|
||||
return participant;
|
||||
}),
|
||||
|
||||
create: protectedProcedure
|
||||
.input(
|
||||
z.object({
|
||||
studyId: z.string().uuid(),
|
||||
participantCode: z.string().min(1).max(50),
|
||||
email: z.string().email().optional(),
|
||||
name: z.string().max(255).optional(),
|
||||
demographics: z.any().optional(),
|
||||
}),
|
||||
)
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
const userId = ctx.session.user.id;
|
||||
|
||||
// Check study access with researcher permission
|
||||
await checkStudyAccess(ctx.db, userId, input.studyId, [
|
||||
"owner",
|
||||
"researcher",
|
||||
]);
|
||||
|
||||
// Check if participant code already exists in this study
|
||||
const existingParticipant = await ctx.db.query.participants.findFirst({
|
||||
where: and(
|
||||
eq(participants.studyId, input.studyId),
|
||||
eq(participants.participantCode, input.participantCode),
|
||||
),
|
||||
});
|
||||
|
||||
if (existingParticipant) {
|
||||
throw new TRPCError({
|
||||
code: "CONFLICT",
|
||||
message: "Participant code already exists in this study",
|
||||
});
|
||||
}
|
||||
|
||||
// Check if email already exists in this study (if provided)
|
||||
if (input.email) {
|
||||
const existingEmail = await ctx.db.query.participants.findFirst({
|
||||
where: and(
|
||||
eq(participants.studyId, input.studyId),
|
||||
eq(participants.email, input.email),
|
||||
),
|
||||
});
|
||||
|
||||
if (existingEmail) {
|
||||
throw new TRPCError({
|
||||
code: "CONFLICT",
|
||||
message: "Email already registered for this study",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const [newParticipant] = await ctx.db
|
||||
.insert(participants)
|
||||
.values({
|
||||
studyId: input.studyId,
|
||||
participantCode: input.participantCode,
|
||||
email: input.email,
|
||||
name: input.name,
|
||||
demographics: input.demographics ?? {},
|
||||
})
|
||||
.returning();
|
||||
|
||||
if (!newParticipant) {
|
||||
throw new TRPCError({
|
||||
code: "INTERNAL_SERVER_ERROR",
|
||||
message: "Failed to create participant",
|
||||
});
|
||||
}
|
||||
|
||||
// Log activity
|
||||
await ctx.db.insert(activityLogs).values({
|
||||
studyId: input.studyId,
|
||||
userId,
|
||||
action: "participant_created",
|
||||
description: `Created participant "${input.participantCode}"`,
|
||||
resourceType: "participant",
|
||||
resourceId: newParticipant.id,
|
||||
});
|
||||
|
||||
return newParticipant;
|
||||
}),
|
||||
|
||||
update: protectedProcedure
|
||||
.input(
|
||||
z.object({
|
||||
id: z.string().uuid(),
|
||||
participantCode: z.string().min(1).max(50).optional(),
|
||||
email: z.string().email().optional(),
|
||||
name: z.string().max(255).optional(),
|
||||
demographics: z.any().optional(),
|
||||
notes: z.string().optional(),
|
||||
}),
|
||||
)
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
const { id, ...updateData } = input;
|
||||
const userId = ctx.session.user.id;
|
||||
|
||||
// Get participant to check study access
|
||||
const participant = await ctx.db.query.participants.findFirst({
|
||||
where: eq(participants.id, id),
|
||||
});
|
||||
|
||||
if (!participant) {
|
||||
throw new TRPCError({
|
||||
code: "NOT_FOUND",
|
||||
message: "Participant not found",
|
||||
});
|
||||
}
|
||||
|
||||
// Check study access with researcher permission
|
||||
await checkStudyAccess(ctx.db, userId, participant.studyId, [
|
||||
"owner",
|
||||
"researcher",
|
||||
]);
|
||||
|
||||
// Check if participant code already exists (if being updated)
|
||||
if (
|
||||
updateData.participantCode &&
|
||||
updateData.participantCode !== participant.participantCode
|
||||
) {
|
||||
const existingParticipant = await ctx.db.query.participants.findFirst({
|
||||
where: and(
|
||||
eq(participants.studyId, participant.studyId),
|
||||
eq(participants.participantCode, updateData.participantCode),
|
||||
),
|
||||
});
|
||||
|
||||
if (existingParticipant) {
|
||||
throw new TRPCError({
|
||||
code: "CONFLICT",
|
||||
message: "Participant code already exists in this study",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Check if email already exists (if being updated)
|
||||
if (updateData.email && updateData.email !== participant.email) {
|
||||
const existingEmail = await ctx.db.query.participants.findFirst({
|
||||
where: and(
|
||||
eq(participants.studyId, participant.studyId),
|
||||
eq(participants.email, updateData.email),
|
||||
),
|
||||
});
|
||||
|
||||
if (existingEmail) {
|
||||
throw new TRPCError({
|
||||
code: "CONFLICT",
|
||||
message: "Email already registered for this study",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const [updatedParticipant] = await ctx.db
|
||||
.update(participants)
|
||||
.set({
|
||||
...updateData,
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
.where(eq(participants.id, id))
|
||||
.returning();
|
||||
|
||||
if (!updatedParticipant) {
|
||||
throw new TRPCError({
|
||||
code: "INTERNAL_SERVER_ERROR",
|
||||
message: "Failed to update participant",
|
||||
});
|
||||
}
|
||||
|
||||
// Log activity
|
||||
await ctx.db.insert(activityLogs).values({
|
||||
studyId: participant.studyId,
|
||||
userId,
|
||||
action: "participant_updated",
|
||||
description: `Updated participant "${participant.participantCode}"`,
|
||||
resourceType: "participant",
|
||||
resourceId: id,
|
||||
});
|
||||
|
||||
return updatedParticipant;
|
||||
}),
|
||||
|
||||
delete: protectedProcedure
|
||||
.input(z.object({ id: z.string().uuid() }))
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
const userId = ctx.session.user.id;
|
||||
|
||||
// Get participant to check study access
|
||||
const participant = await ctx.db.query.participants.findFirst({
|
||||
where: eq(participants.id, input.id),
|
||||
with: {
|
||||
trials: {
|
||||
columns: {
|
||||
id: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (!participant) {
|
||||
throw new TRPCError({
|
||||
code: "NOT_FOUND",
|
||||
message: "Participant not found",
|
||||
});
|
||||
}
|
||||
|
||||
// Check study access with researcher permission
|
||||
await checkStudyAccess(ctx.db, userId, participant.studyId, [
|
||||
"owner",
|
||||
"researcher",
|
||||
]);
|
||||
|
||||
// Check if participant has any trials
|
||||
if (participant.trials.length > 0) {
|
||||
throw new TRPCError({
|
||||
code: "BAD_REQUEST",
|
||||
message:
|
||||
"Cannot delete participant with existing trials. Archive the participant instead.",
|
||||
});
|
||||
}
|
||||
|
||||
// Delete participant (this will cascade to consent records)
|
||||
await ctx.db.delete(participants).where(eq(participants.id, input.id));
|
||||
|
||||
// Log activity
|
||||
await ctx.db.insert(activityLogs).values({
|
||||
studyId: participant.studyId,
|
||||
userId,
|
||||
action: "participant_deleted",
|
||||
description: `Deleted participant "${participant.participantCode}"`,
|
||||
resourceType: "participant",
|
||||
resourceId: input.id,
|
||||
});
|
||||
|
||||
return { success: true };
|
||||
}),
|
||||
|
||||
recordConsent: protectedProcedure
|
||||
.input(
|
||||
z.object({
|
||||
participantId: z.string().uuid(),
|
||||
consentFormId: z.string().uuid(),
|
||||
signatureData: z.string().optional(),
|
||||
ipAddress: z.string().optional(),
|
||||
}),
|
||||
)
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
const { participantId, consentFormId, signatureData, ipAddress } = input;
|
||||
const userId = ctx.session.user.id;
|
||||
|
||||
// Get participant to check study access
|
||||
const participant = await ctx.db.query.participants.findFirst({
|
||||
where: eq(participants.id, participantId),
|
||||
});
|
||||
|
||||
if (!participant) {
|
||||
throw new TRPCError({
|
||||
code: "NOT_FOUND",
|
||||
message: "Participant not found",
|
||||
});
|
||||
}
|
||||
|
||||
// Check study access with researcher/wizard permission
|
||||
await checkStudyAccess(ctx.db, userId, participant.studyId, [
|
||||
"owner",
|
||||
"researcher",
|
||||
"wizard",
|
||||
]);
|
||||
|
||||
// Verify consent form exists and belongs to the study
|
||||
const consentForm = await ctx.db.query.consentForms.findFirst({
|
||||
where: eq(consentForms.id, consentFormId),
|
||||
});
|
||||
|
||||
if (!consentForm) {
|
||||
throw new TRPCError({
|
||||
code: "NOT_FOUND",
|
||||
message: "Consent form not found",
|
||||
});
|
||||
}
|
||||
|
||||
if (consentForm.studyId !== participant.studyId) {
|
||||
throw new TRPCError({
|
||||
code: "BAD_REQUEST",
|
||||
message: "Consent form doesn't belong to this study",
|
||||
});
|
||||
}
|
||||
|
||||
// Check if consent already exists
|
||||
const existingConsent = await ctx.db.query.participantConsents.findFirst({
|
||||
where: and(
|
||||
eq(participantConsents.participantId, participantId),
|
||||
eq(participantConsents.consentFormId, consentFormId),
|
||||
),
|
||||
});
|
||||
|
||||
if (existingConsent) {
|
||||
throw new TRPCError({
|
||||
code: "CONFLICT",
|
||||
message: "Consent already recorded for this form",
|
||||
});
|
||||
}
|
||||
|
||||
// Record consent
|
||||
const [newConsent] = await ctx.db
|
||||
.insert(participantConsents)
|
||||
.values({
|
||||
participantId,
|
||||
consentFormId,
|
||||
signatureData,
|
||||
ipAddress,
|
||||
})
|
||||
.returning();
|
||||
|
||||
if (!newConsent) {
|
||||
throw new TRPCError({
|
||||
code: "INTERNAL_SERVER_ERROR",
|
||||
message: "Failed to record consent",
|
||||
});
|
||||
}
|
||||
|
||||
// Update participant consent status
|
||||
await ctx.db
|
||||
.update(participants)
|
||||
.set({
|
||||
consentGiven: true,
|
||||
consentDate: new Date(),
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
.where(eq(participants.id, participantId));
|
||||
|
||||
// Log activity
|
||||
await ctx.db.insert(activityLogs).values({
|
||||
studyId: participant.studyId,
|
||||
userId,
|
||||
action: "consent_recorded",
|
||||
description: `Recorded consent for participant "${participant.participantCode}"`,
|
||||
resourceType: "participant",
|
||||
resourceId: participantId,
|
||||
});
|
||||
|
||||
return newConsent;
|
||||
}),
|
||||
|
||||
revokeConsent: protectedProcedure
|
||||
.input(
|
||||
z.object({
|
||||
participantId: z.string().uuid(),
|
||||
consentFormId: z.string().uuid(),
|
||||
reason: z.string().optional(),
|
||||
}),
|
||||
)
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
const { participantId, consentFormId, reason } = input;
|
||||
const userId = ctx.session.user.id;
|
||||
|
||||
// Get participant to check study access
|
||||
const participant = await ctx.db.query.participants.findFirst({
|
||||
where: eq(participants.id, participantId),
|
||||
});
|
||||
|
||||
if (!participant) {
|
||||
throw new TRPCError({
|
||||
code: "NOT_FOUND",
|
||||
message: "Participant not found",
|
||||
});
|
||||
}
|
||||
|
||||
// Check study access with researcher permission
|
||||
await checkStudyAccess(ctx.db, userId, participant.studyId, [
|
||||
"owner",
|
||||
"researcher",
|
||||
]);
|
||||
|
||||
// Check if consent exists
|
||||
const existingConsent = await ctx.db.query.participantConsents.findFirst({
|
||||
where: and(
|
||||
eq(participantConsents.participantId, participantId),
|
||||
eq(participantConsents.consentFormId, consentFormId),
|
||||
),
|
||||
});
|
||||
|
||||
if (!existingConsent) {
|
||||
throw new TRPCError({
|
||||
code: "NOT_FOUND",
|
||||
message: "Consent record not found",
|
||||
});
|
||||
}
|
||||
|
||||
// Remove consent record
|
||||
await ctx.db
|
||||
.delete(participantConsents)
|
||||
.where(eq(participantConsents.id, existingConsent.id));
|
||||
|
||||
// Check if participant has any other consents
|
||||
const remainingConsents = await ctx.db.query.participantConsents.findMany(
|
||||
{
|
||||
where: eq(participantConsents.participantId, participantId),
|
||||
},
|
||||
);
|
||||
|
||||
// Update participant consent status if no consents remain
|
||||
if (remainingConsents.length === 0) {
|
||||
await ctx.db
|
||||
.update(participants)
|
||||
.set({
|
||||
consentGiven: false,
|
||||
consentDate: null,
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
.where(eq(participants.id, participantId));
|
||||
}
|
||||
|
||||
// Log activity
|
||||
await ctx.db.insert(activityLogs).values({
|
||||
studyId: participant.studyId,
|
||||
userId,
|
||||
action: "consent_revoked",
|
||||
description: `Revoked consent for participant "${participant.participantCode}"${reason ? ` - Reason: ${reason}` : ""}`,
|
||||
resourceType: "participant",
|
||||
resourceId: participantId,
|
||||
});
|
||||
|
||||
return { success: true };
|
||||
}),
|
||||
|
||||
getConsentForms: protectedProcedure
|
||||
.input(z.object({ studyId: z.string().uuid() }))
|
||||
.query(async ({ ctx, input }) => {
|
||||
const userId = ctx.session.user.id;
|
||||
|
||||
// Check study access
|
||||
await checkStudyAccess(ctx.db, userId, input.studyId);
|
||||
|
||||
const forms = await ctx.db.query.consentForms.findMany({
|
||||
where: eq(consentForms.studyId, input.studyId),
|
||||
with: {
|
||||
createdBy: {
|
||||
columns: {
|
||||
id: true,
|
||||
name: true,
|
||||
email: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
orderBy: [desc(consentForms.createdAt)],
|
||||
});
|
||||
|
||||
return forms;
|
||||
}),
|
||||
});
|
||||
438
src/server/api/routers/robots.ts
Normal file
438
src/server/api/routers/robots.ts
Normal file
@@ -0,0 +1,438 @@
|
||||
import { z } from "zod";
|
||||
import { eq, and, desc, inArray, type SQL } from "drizzle-orm";
|
||||
import { TRPCError } from "@trpc/server";
|
||||
import { createTRPCRouter, protectedProcedure } from "~/server/api/trpc";
|
||||
import type { db } from "~/server/db";
|
||||
import {
|
||||
robots,
|
||||
plugins,
|
||||
studyPlugins,
|
||||
studyMembers,
|
||||
communicationProtocolEnum,
|
||||
pluginStatusEnum,
|
||||
} from "~/server/db/schema";
|
||||
|
||||
// Helper function to check if user has study access for robot operations
|
||||
async function checkStudyAccess(
|
||||
database: typeof db,
|
||||
userId: string,
|
||||
studyId: string,
|
||||
requiredRoles: string[] = ["owner", "researcher"],
|
||||
) {
|
||||
const membership = await database
|
||||
.select()
|
||||
.from(studyMembers)
|
||||
.where(
|
||||
and(
|
||||
eq(studyMembers.studyId, studyId),
|
||||
eq(studyMembers.userId, userId),
|
||||
inArray(studyMembers.role, requiredRoles as Array<"owner" | "researcher" | "wizard" | "observer">),
|
||||
),
|
||||
)
|
||||
.limit(1);
|
||||
|
||||
if (!membership[0]) {
|
||||
throw new TRPCError({
|
||||
code: "FORBIDDEN",
|
||||
message: "Insufficient permissions to access this study",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export const robotsRouter = createTRPCRouter({
|
||||
list: protectedProcedure
|
||||
.input(
|
||||
z.object({
|
||||
protocol: z.enum(communicationProtocolEnum.enumValues).optional(),
|
||||
limit: z.number().min(1).max(100).default(50),
|
||||
offset: z.number().min(0).default(0),
|
||||
}),
|
||||
)
|
||||
.query(async ({ ctx, input }) => {
|
||||
const { db } = ctx;
|
||||
|
||||
const conditions: SQL[] = [];
|
||||
|
||||
if (input.protocol) {
|
||||
conditions.push(eq(robots.communicationProtocol, input.protocol));
|
||||
}
|
||||
|
||||
const query = db
|
||||
.select({
|
||||
id: robots.id,
|
||||
name: robots.name,
|
||||
manufacturer: robots.manufacturer,
|
||||
model: robots.model,
|
||||
description: robots.description,
|
||||
capabilities: robots.capabilities,
|
||||
communicationProtocol: robots.communicationProtocol,
|
||||
createdAt: robots.createdAt,
|
||||
updatedAt: robots.updatedAt,
|
||||
})
|
||||
.from(robots);
|
||||
|
||||
const results = await (
|
||||
conditions.length > 0
|
||||
? query.where(and(...conditions))
|
||||
: query
|
||||
)
|
||||
.orderBy(desc(robots.updatedAt))
|
||||
.limit(input.limit)
|
||||
.offset(input.offset);
|
||||
|
||||
return results;
|
||||
}),
|
||||
|
||||
get: protectedProcedure
|
||||
.input(
|
||||
z.object({
|
||||
id: z.string(),
|
||||
}),
|
||||
)
|
||||
.query(async ({ ctx, input }) => {
|
||||
const { db } = ctx;
|
||||
|
||||
const robot = await db
|
||||
.select({
|
||||
id: robots.id,
|
||||
name: robots.name,
|
||||
manufacturer: robots.manufacturer,
|
||||
model: robots.model,
|
||||
description: robots.description,
|
||||
capabilities: robots.capabilities,
|
||||
communicationProtocol: robots.communicationProtocol,
|
||||
createdAt: robots.createdAt,
|
||||
updatedAt: robots.updatedAt,
|
||||
})
|
||||
.from(robots)
|
||||
.where(eq(robots.id, input.id))
|
||||
.limit(1);
|
||||
|
||||
if (!robot[0]) {
|
||||
throw new TRPCError({
|
||||
code: "NOT_FOUND",
|
||||
message: "Robot not found",
|
||||
});
|
||||
}
|
||||
|
||||
return robot[0];
|
||||
}),
|
||||
|
||||
create: protectedProcedure
|
||||
.input(
|
||||
z.object({
|
||||
name: z.string().min(1).max(255),
|
||||
manufacturer: z.string().max(255).optional(),
|
||||
model: z.string().max(255).optional(),
|
||||
description: z.string().optional(),
|
||||
capabilities: z.array(z.unknown()).optional(),
|
||||
communicationProtocol: z
|
||||
.enum(communicationProtocolEnum.enumValues)
|
||||
.optional(),
|
||||
}),
|
||||
)
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
const { db } = ctx;
|
||||
|
||||
const insertedRobots = await db
|
||||
.insert(robots)
|
||||
.values({
|
||||
name: input.name,
|
||||
manufacturer: input.manufacturer,
|
||||
model: input.model,
|
||||
description: input.description,
|
||||
capabilities: input.capabilities ?? [],
|
||||
communicationProtocol: input.communicationProtocol,
|
||||
})
|
||||
.returning();
|
||||
|
||||
const robot = insertedRobots[0];
|
||||
if (!robot) {
|
||||
throw new TRPCError({
|
||||
code: "INTERNAL_SERVER_ERROR",
|
||||
message: "Failed to create robot",
|
||||
});
|
||||
}
|
||||
|
||||
return robot;
|
||||
}),
|
||||
|
||||
update: protectedProcedure
|
||||
.input(
|
||||
z.object({
|
||||
id: z.string(),
|
||||
name: z.string().min(1).max(255).optional(),
|
||||
manufacturer: z.string().max(255).optional(),
|
||||
model: z.string().max(255).optional(),
|
||||
description: z.string().optional(),
|
||||
capabilities: z.array(z.unknown()).optional(),
|
||||
communicationProtocol: z
|
||||
.enum(communicationProtocolEnum.enumValues)
|
||||
.optional(),
|
||||
}),
|
||||
)
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
const { db } = ctx;
|
||||
|
||||
const updatedRobots = await db
|
||||
.update(robots)
|
||||
.set({
|
||||
name: input.name,
|
||||
manufacturer: input.manufacturer,
|
||||
model: input.model,
|
||||
description: input.description,
|
||||
capabilities: input.capabilities,
|
||||
communicationProtocol: input.communicationProtocol,
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
.where(eq(robots.id, input.id))
|
||||
.returning();
|
||||
|
||||
const robot = updatedRobots[0];
|
||||
if (!robot) {
|
||||
throw new TRPCError({
|
||||
code: "NOT_FOUND",
|
||||
message: "Robot not found",
|
||||
});
|
||||
}
|
||||
|
||||
return robot;
|
||||
}),
|
||||
|
||||
delete: protectedProcedure
|
||||
.input(
|
||||
z.object({
|
||||
id: z.string(),
|
||||
}),
|
||||
)
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
const { db } = ctx;
|
||||
|
||||
const deletedRobots = await db
|
||||
.delete(robots)
|
||||
.where(eq(robots.id, input.id))
|
||||
.returning();
|
||||
|
||||
if (!deletedRobots[0]) {
|
||||
throw new TRPCError({
|
||||
code: "NOT_FOUND",
|
||||
message: "Robot not found",
|
||||
});
|
||||
}
|
||||
|
||||
return { success: true };
|
||||
}),
|
||||
|
||||
// Plugin management routes
|
||||
plugins: createTRPCRouter({
|
||||
list: protectedProcedure
|
||||
.input(
|
||||
z.object({
|
||||
robotId: z.string().optional(),
|
||||
status: z.enum(pluginStatusEnum.enumValues).optional(),
|
||||
limit: z.number().min(1).max(100).default(50),
|
||||
offset: z.number().min(0).default(0),
|
||||
}),
|
||||
)
|
||||
.query(async ({ ctx, input }) => {
|
||||
const { db } = ctx;
|
||||
|
||||
const conditions: SQL[] = [];
|
||||
|
||||
if (input.robotId) {
|
||||
conditions.push(eq(plugins.robotId, input.robotId));
|
||||
}
|
||||
|
||||
if (input.status) {
|
||||
conditions.push(eq(plugins.status, input.status));
|
||||
}
|
||||
|
||||
const query = db
|
||||
.select({
|
||||
id: plugins.id,
|
||||
robotId: plugins.robotId,
|
||||
name: plugins.name,
|
||||
version: plugins.version,
|
||||
description: plugins.description,
|
||||
author: plugins.author,
|
||||
repositoryUrl: plugins.repositoryUrl,
|
||||
trustLevel: plugins.trustLevel,
|
||||
status: plugins.status,
|
||||
createdAt: plugins.createdAt,
|
||||
updatedAt: plugins.updatedAt,
|
||||
})
|
||||
.from(plugins);
|
||||
|
||||
const results = await (
|
||||
conditions.length > 0 ? query.where(and(...conditions)) : query
|
||||
)
|
||||
.orderBy(desc(plugins.updatedAt))
|
||||
.limit(input.limit)
|
||||
.offset(input.offset);
|
||||
|
||||
return results;
|
||||
}),
|
||||
|
||||
get: protectedProcedure
|
||||
.input(
|
||||
z.object({
|
||||
id: z.string(),
|
||||
}),
|
||||
)
|
||||
.query(async ({ ctx, input }) => {
|
||||
const { db } = ctx;
|
||||
|
||||
const pluginResults = await db
|
||||
.select()
|
||||
.from(plugins)
|
||||
.where(eq(plugins.id, input.id))
|
||||
.limit(1);
|
||||
|
||||
const plugin = pluginResults[0];
|
||||
|
||||
if (!plugin) {
|
||||
throw new TRPCError({
|
||||
code: "NOT_FOUND",
|
||||
message: "Plugin not found",
|
||||
});
|
||||
}
|
||||
|
||||
return plugin;
|
||||
}),
|
||||
|
||||
install: protectedProcedure
|
||||
.input(
|
||||
z.object({
|
||||
studyId: z.string(),
|
||||
pluginId: z.string(),
|
||||
configuration: z.any().optional(),
|
||||
}),
|
||||
)
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
const { db } = ctx;
|
||||
const userId = ctx.session.user.id;
|
||||
|
||||
await checkStudyAccess(db, userId, input.studyId, [
|
||||
"owner",
|
||||
"researcher",
|
||||
]);
|
||||
|
||||
// Check if plugin exists
|
||||
const plugin = await db
|
||||
.select()
|
||||
.from(plugins)
|
||||
.where(eq(plugins.id, input.pluginId))
|
||||
.limit(1);
|
||||
|
||||
if (!plugin[0]) {
|
||||
throw new TRPCError({
|
||||
code: "NOT_FOUND",
|
||||
message: "Plugin not found",
|
||||
});
|
||||
}
|
||||
|
||||
// Check if plugin is already installed
|
||||
const existing = await db
|
||||
.select()
|
||||
.from(studyPlugins)
|
||||
.where(
|
||||
and(
|
||||
eq(studyPlugins.studyId, input.studyId),
|
||||
eq(studyPlugins.pluginId, input.pluginId),
|
||||
),
|
||||
)
|
||||
.limit(1);
|
||||
|
||||
if (existing[0]) {
|
||||
throw new TRPCError({
|
||||
code: "CONFLICT",
|
||||
message: "Plugin already installed for this study",
|
||||
});
|
||||
}
|
||||
|
||||
const installations = await db
|
||||
.insert(studyPlugins)
|
||||
.values({
|
||||
studyId: input.studyId,
|
||||
pluginId: input.pluginId,
|
||||
configuration: input.configuration ?? {},
|
||||
installedBy: userId,
|
||||
})
|
||||
.returning();
|
||||
|
||||
const installation = installations[0];
|
||||
if (!installation) {
|
||||
throw new TRPCError({
|
||||
code: "INTERNAL_SERVER_ERROR",
|
||||
message: "Failed to install plugin",
|
||||
});
|
||||
}
|
||||
|
||||
return installation;
|
||||
}),
|
||||
|
||||
uninstall: protectedProcedure
|
||||
.input(
|
||||
z.object({
|
||||
studyId: z.string(),
|
||||
pluginId: z.string(),
|
||||
}),
|
||||
)
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
const { db } = ctx;
|
||||
const userId = ctx.session.user.id;
|
||||
|
||||
await checkStudyAccess(db, userId, input.studyId, [
|
||||
"owner",
|
||||
"researcher",
|
||||
]);
|
||||
|
||||
const result = await db
|
||||
.delete(studyPlugins)
|
||||
.where(
|
||||
and(
|
||||
eq(studyPlugins.studyId, input.studyId),
|
||||
eq(studyPlugins.pluginId, input.pluginId),
|
||||
),
|
||||
)
|
||||
.returning();
|
||||
|
||||
if (!result[0]) {
|
||||
throw new TRPCError({
|
||||
code: "NOT_FOUND",
|
||||
message: "Plugin installation not found",
|
||||
});
|
||||
}
|
||||
|
||||
return { success: true };
|
||||
}),
|
||||
|
||||
getActions: protectedProcedure
|
||||
.input(
|
||||
z.object({
|
||||
pluginId: z.string(),
|
||||
}),
|
||||
)
|
||||
.query(async ({ ctx, input }) => {
|
||||
const { db } = ctx;
|
||||
|
||||
const plugin = await db
|
||||
.select({
|
||||
id: plugins.id,
|
||||
actionDefinitions: plugins.actionDefinitions,
|
||||
})
|
||||
.from(plugins)
|
||||
.where(eq(plugins.id, input.pluginId))
|
||||
.limit(1);
|
||||
|
||||
if (!plugin[0]) {
|
||||
throw new TRPCError({
|
||||
code: "NOT_FOUND",
|
||||
message: "Plugin not found",
|
||||
});
|
||||
}
|
||||
|
||||
return plugin[0].actionDefinitions ?? [];
|
||||
}),
|
||||
}),
|
||||
});
|
||||
652
src/server/api/routers/studies.ts
Normal file
652
src/server/api/routers/studies.ts
Normal file
@@ -0,0 +1,652 @@
|
||||
import { z } from "zod";
|
||||
import { TRPCError } from "@trpc/server";
|
||||
import { and, count, eq, ilike, or, desc, isNull, inArray } from "drizzle-orm";
|
||||
|
||||
import { createTRPCRouter, protectedProcedure } from "~/server/api/trpc";
|
||||
import {
|
||||
studies,
|
||||
studyMembers,
|
||||
studyStatusEnum,
|
||||
studyMemberRoleEnum,
|
||||
users,
|
||||
activityLogs,
|
||||
userSystemRoles,
|
||||
} from "~/server/db/schema";
|
||||
|
||||
export const studiesRouter = createTRPCRouter({
|
||||
list: protectedProcedure
|
||||
.input(
|
||||
z.object({
|
||||
page: z.number().min(1).default(1),
|
||||
limit: z.number().min(1).max(100).default(20),
|
||||
search: z.string().optional(),
|
||||
status: z.enum(studyStatusEnum.enumValues).optional(),
|
||||
memberOnly: z.boolean().default(true), // Only show studies user is member of
|
||||
}),
|
||||
)
|
||||
.query(async ({ ctx, input }) => {
|
||||
const { page, limit, search, status, memberOnly } = input;
|
||||
const offset = (page - 1) * limit;
|
||||
const userId = ctx.session.user.id;
|
||||
|
||||
// Build where conditions
|
||||
const conditions = [isNull(studies.deletedAt)];
|
||||
|
||||
if (search) {
|
||||
conditions.push(
|
||||
or(
|
||||
ilike(studies.name, `%${search}%`),
|
||||
ilike(studies.description, `%${search}%`),
|
||||
ilike(studies.institution, `%${search}%`),
|
||||
)!,
|
||||
);
|
||||
}
|
||||
|
||||
if (status) {
|
||||
conditions.push(eq(studies.status, status));
|
||||
}
|
||||
|
||||
const whereClause = and(...conditions);
|
||||
|
||||
// Check if user is admin (can see all studies)
|
||||
const isAdmin = await ctx.db.query.userSystemRoles.findFirst({
|
||||
where: and(
|
||||
eq(userSystemRoles.userId, userId),
|
||||
eq(userSystemRoles.role, "administrator"),
|
||||
),
|
||||
});
|
||||
|
||||
let studiesQuery;
|
||||
|
||||
if (isAdmin && !memberOnly) {
|
||||
// Admin can see all studies
|
||||
studiesQuery = ctx.db.query.studies.findMany({
|
||||
where: whereClause,
|
||||
with: {
|
||||
createdBy: {
|
||||
columns: {
|
||||
id: true,
|
||||
name: true,
|
||||
email: true,
|
||||
},
|
||||
},
|
||||
members: {
|
||||
with: {
|
||||
user: {
|
||||
columns: {
|
||||
id: true,
|
||||
name: true,
|
||||
email: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
experiments: {
|
||||
columns: {
|
||||
id: true,
|
||||
},
|
||||
},
|
||||
participants: {
|
||||
columns: {
|
||||
id: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
limit,
|
||||
offset,
|
||||
orderBy: [desc(studies.updatedAt)],
|
||||
});
|
||||
} else {
|
||||
// Regular users see only studies they're members of
|
||||
// First get study IDs user is member of
|
||||
const userStudyMemberships = await ctx.db.query.studyMembers.findMany({
|
||||
where: eq(studyMembers.userId, userId),
|
||||
columns: {
|
||||
studyId: true,
|
||||
},
|
||||
});
|
||||
|
||||
const userStudyIds = userStudyMemberships.map((m) => m.studyId);
|
||||
|
||||
if (userStudyIds.length === 0) {
|
||||
studiesQuery = Promise.resolve([]);
|
||||
} else {
|
||||
studiesQuery = ctx.db.query.studies.findMany({
|
||||
where: and(whereClause, inArray(studies.id, userStudyIds)),
|
||||
with: {
|
||||
createdBy: {
|
||||
columns: {
|
||||
id: true,
|
||||
name: true,
|
||||
email: true,
|
||||
},
|
||||
},
|
||||
members: {
|
||||
with: {
|
||||
user: {
|
||||
columns: {
|
||||
id: true,
|
||||
name: true,
|
||||
email: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
limit,
|
||||
offset,
|
||||
orderBy: [desc(studies.updatedAt)],
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Get total count
|
||||
const countQuery = ctx.db
|
||||
.select({ count: count() })
|
||||
.from(studies)
|
||||
.where(whereClause);
|
||||
|
||||
const [studiesList, totalCountResult] = await Promise.all([
|
||||
studiesQuery,
|
||||
countQuery,
|
||||
]);
|
||||
|
||||
const totalCount = totalCountResult[0]?.count ?? 0;
|
||||
|
||||
return {
|
||||
studies: studiesList,
|
||||
pagination: {
|
||||
page,
|
||||
limit,
|
||||
total: totalCount,
|
||||
pages: Math.ceil(totalCount / limit),
|
||||
},
|
||||
};
|
||||
}),
|
||||
|
||||
get: protectedProcedure
|
||||
.input(z.object({ id: z.string().uuid() }))
|
||||
.query(async ({ ctx, input }) => {
|
||||
const userId = ctx.session.user.id;
|
||||
|
||||
const study = await ctx.db.query.studies.findFirst({
|
||||
where: eq(studies.id, input.id),
|
||||
with: {
|
||||
createdBy: {
|
||||
columns: {
|
||||
id: true,
|
||||
name: true,
|
||||
email: true,
|
||||
},
|
||||
},
|
||||
members: {
|
||||
with: {
|
||||
user: {
|
||||
columns: {
|
||||
id: true,
|
||||
name: true,
|
||||
email: true,
|
||||
},
|
||||
},
|
||||
invitedBy: {
|
||||
columns: {
|
||||
id: true,
|
||||
name: true,
|
||||
email: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
experiments: {
|
||||
columns: {
|
||||
id: true,
|
||||
name: true,
|
||||
status: true,
|
||||
createdAt: true,
|
||||
},
|
||||
},
|
||||
participants: {
|
||||
columns: {
|
||||
id: true,
|
||||
participantCode: true,
|
||||
consentGiven: true,
|
||||
createdAt: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (!study) {
|
||||
throw new TRPCError({
|
||||
code: "NOT_FOUND",
|
||||
message: "Study not found",
|
||||
});
|
||||
}
|
||||
|
||||
// Check if user has access to this study
|
||||
const userMembership = study.members.find((m) => m.userId === userId);
|
||||
const isAdmin = await ctx.db.query.userSystemRoles.findFirst({
|
||||
where: and(
|
||||
eq(userSystemRoles.userId, userId),
|
||||
eq(userSystemRoles.role, "administrator"),
|
||||
),
|
||||
});
|
||||
|
||||
if (!userMembership && !isAdmin) {
|
||||
throw new TRPCError({
|
||||
code: "FORBIDDEN",
|
||||
message: "You don't have access to this study",
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
...study,
|
||||
userRole: userMembership?.role,
|
||||
};
|
||||
}),
|
||||
|
||||
create: protectedProcedure
|
||||
.input(
|
||||
z.object({
|
||||
name: z.string().min(1).max(255),
|
||||
description: z.string().optional(),
|
||||
institution: z.string().max(255).optional(),
|
||||
irbProtocol: z.string().max(100).optional(),
|
||||
}),
|
||||
)
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
const userId = ctx.session.user.id;
|
||||
|
||||
const [newStudy] = await ctx.db
|
||||
.insert(studies)
|
||||
.values({
|
||||
...input,
|
||||
createdBy: userId,
|
||||
})
|
||||
.returning();
|
||||
|
||||
if (!newStudy) {
|
||||
throw new TRPCError({
|
||||
code: "INTERNAL_SERVER_ERROR",
|
||||
message: "Failed to create study",
|
||||
});
|
||||
}
|
||||
|
||||
// Add creator as owner
|
||||
await ctx.db.insert(studyMembers).values({
|
||||
studyId: newStudy.id,
|
||||
userId,
|
||||
role: "owner",
|
||||
});
|
||||
|
||||
// Log activity
|
||||
await ctx.db.insert(activityLogs).values({
|
||||
studyId: newStudy.id,
|
||||
userId,
|
||||
action: "study_created",
|
||||
description: `Created study "${newStudy.name}"`,
|
||||
});
|
||||
|
||||
return newStudy;
|
||||
}),
|
||||
|
||||
update: protectedProcedure
|
||||
.input(
|
||||
z.object({
|
||||
id: z.string().uuid(),
|
||||
name: z.string().min(1).max(255).optional(),
|
||||
description: z.string().optional(),
|
||||
institution: z.string().max(255).optional(),
|
||||
irbProtocol: z.string().max(100).optional(),
|
||||
status: z.enum(studyStatusEnum.enumValues).optional(),
|
||||
settings: z.any().optional(),
|
||||
}),
|
||||
)
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
const { id, ...updateData } = input;
|
||||
const userId = ctx.session.user.id;
|
||||
|
||||
// Check if user has permission to update this study
|
||||
const membership = await ctx.db.query.studyMembers.findFirst({
|
||||
where: and(
|
||||
eq(studyMembers.studyId, id),
|
||||
eq(studyMembers.userId, userId),
|
||||
),
|
||||
});
|
||||
|
||||
if (!membership || !["owner", "researcher"].includes(membership.role)) {
|
||||
throw new TRPCError({
|
||||
code: "FORBIDDEN",
|
||||
message: "You don't have permission to update this study",
|
||||
});
|
||||
}
|
||||
|
||||
const [updatedStudy] = await ctx.db
|
||||
.update(studies)
|
||||
.set({
|
||||
...updateData,
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
.where(eq(studies.id, id))
|
||||
.returning();
|
||||
|
||||
if (!updatedStudy) {
|
||||
throw new TRPCError({
|
||||
code: "NOT_FOUND",
|
||||
message: "Study not found",
|
||||
});
|
||||
}
|
||||
|
||||
// Log activity
|
||||
await ctx.db.insert(activityLogs).values({
|
||||
studyId: id,
|
||||
userId,
|
||||
action: "study_updated",
|
||||
description: `Updated study "${updatedStudy.name}"`,
|
||||
});
|
||||
|
||||
return updatedStudy;
|
||||
}),
|
||||
|
||||
delete: protectedProcedure
|
||||
.input(z.object({ id: z.string().uuid() }))
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
const userId = ctx.session.user.id;
|
||||
|
||||
// Check if user is owner of the study
|
||||
const membership = await ctx.db.query.studyMembers.findFirst({
|
||||
where: and(
|
||||
eq(studyMembers.studyId, input.id),
|
||||
eq(studyMembers.userId, userId),
|
||||
eq(studyMembers.role, "owner"),
|
||||
),
|
||||
});
|
||||
|
||||
if (!membership) {
|
||||
throw new TRPCError({
|
||||
code: "FORBIDDEN",
|
||||
message: "Only study owners can delete studies",
|
||||
});
|
||||
}
|
||||
|
||||
// Soft delete the study
|
||||
await ctx.db
|
||||
.update(studies)
|
||||
.set({
|
||||
deletedAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
.where(eq(studies.id, input.id));
|
||||
|
||||
// Log activity
|
||||
await ctx.db.insert(activityLogs).values({
|
||||
studyId: input.id,
|
||||
userId,
|
||||
action: "study_deleted",
|
||||
description: "Study deleted",
|
||||
});
|
||||
|
||||
return { success: true };
|
||||
}),
|
||||
|
||||
addMember: protectedProcedure
|
||||
.input(
|
||||
z.object({
|
||||
studyId: z.string().uuid(),
|
||||
email: z.string().email(),
|
||||
role: z.enum(studyMemberRoleEnum.enumValues),
|
||||
}),
|
||||
)
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
const { studyId, email, role } = input;
|
||||
const userId = ctx.session.user.id;
|
||||
|
||||
// Check if current user has permission to add members
|
||||
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 add members to this study",
|
||||
});
|
||||
}
|
||||
|
||||
// Find user by email
|
||||
const targetUser = await ctx.db.query.users.findFirst({
|
||||
where: eq(users.email, email),
|
||||
});
|
||||
|
||||
if (!targetUser) {
|
||||
throw new TRPCError({
|
||||
code: "NOT_FOUND",
|
||||
message: "User not found",
|
||||
});
|
||||
}
|
||||
|
||||
// Check if user is already a member
|
||||
const existingMembership = await ctx.db.query.studyMembers.findFirst({
|
||||
where: and(
|
||||
eq(studyMembers.studyId, studyId),
|
||||
eq(studyMembers.userId, targetUser.id),
|
||||
),
|
||||
});
|
||||
|
||||
if (existingMembership) {
|
||||
throw new TRPCError({
|
||||
code: "CONFLICT",
|
||||
message: "User is already a member of this study",
|
||||
});
|
||||
}
|
||||
|
||||
// Add member
|
||||
const [newMember] = await ctx.db
|
||||
.insert(studyMembers)
|
||||
.values({
|
||||
studyId,
|
||||
userId: targetUser.id,
|
||||
role,
|
||||
invitedBy: userId,
|
||||
})
|
||||
.returning();
|
||||
|
||||
// Log activity
|
||||
await ctx.db.insert(activityLogs).values({
|
||||
studyId,
|
||||
userId,
|
||||
action: "member_added",
|
||||
description: `Added ${targetUser.name ?? targetUser.email} as ${role}`,
|
||||
});
|
||||
|
||||
return newMember;
|
||||
}),
|
||||
|
||||
removeMember: protectedProcedure
|
||||
.input(
|
||||
z.object({
|
||||
studyId: z.string().uuid(),
|
||||
memberId: z.string().uuid(),
|
||||
}),
|
||||
)
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
const { studyId, memberId } = input;
|
||||
const userId = ctx.session.user.id;
|
||||
|
||||
// Check if current user has permission to remove members
|
||||
const membership = await ctx.db.query.studyMembers.findFirst({
|
||||
where: and(
|
||||
eq(studyMembers.studyId, studyId),
|
||||
eq(studyMembers.userId, userId),
|
||||
),
|
||||
});
|
||||
|
||||
if (!membership || membership.role !== "owner") {
|
||||
throw new TRPCError({
|
||||
code: "FORBIDDEN",
|
||||
message: "Only study owners can remove members",
|
||||
});
|
||||
}
|
||||
|
||||
// Get member info for logging
|
||||
const memberToRemove = await ctx.db.query.studyMembers.findFirst({
|
||||
where: eq(studyMembers.id, memberId),
|
||||
with: {
|
||||
user: {
|
||||
columns: {
|
||||
name: true,
|
||||
email: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (!memberToRemove || memberToRemove.studyId !== studyId) {
|
||||
throw new TRPCError({
|
||||
code: "NOT_FOUND",
|
||||
message: "Member not found",
|
||||
});
|
||||
}
|
||||
|
||||
// Prevent removing the last owner
|
||||
if (memberToRemove.role === "owner") {
|
||||
const ownerCount = await ctx.db
|
||||
.select({ count: count() })
|
||||
.from(studyMembers)
|
||||
.where(
|
||||
and(
|
||||
eq(studyMembers.studyId, studyId),
|
||||
eq(studyMembers.role, "owner"),
|
||||
),
|
||||
);
|
||||
|
||||
if ((ownerCount[0]?.count ?? 0) <= 1) {
|
||||
throw new TRPCError({
|
||||
code: "BAD_REQUEST",
|
||||
message: "Cannot remove the last owner of the study",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Remove member
|
||||
await ctx.db.delete(studyMembers).where(eq(studyMembers.id, memberId));
|
||||
|
||||
// Log activity
|
||||
await ctx.db.insert(activityLogs).values({
|
||||
studyId,
|
||||
userId,
|
||||
action: "member_removed",
|
||||
description: `Removed ${memberToRemove.user?.name ?? memberToRemove.user?.email ?? 'Unknown user'}`,
|
||||
});
|
||||
|
||||
return { success: true };
|
||||
}),
|
||||
|
||||
getMembers: protectedProcedure
|
||||
.input(z.object({ studyId: z.string().uuid() }))
|
||||
.query(async ({ ctx, input }) => {
|
||||
const userId = ctx.session.user.id;
|
||||
|
||||
// Check if user has access to this study
|
||||
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 members = await ctx.db.query.studyMembers.findMany({
|
||||
where: eq(studyMembers.studyId, input.studyId),
|
||||
with: {
|
||||
user: {
|
||||
columns: {
|
||||
id: true,
|
||||
name: true,
|
||||
email: true,
|
||||
image: true,
|
||||
},
|
||||
},
|
||||
invitedBy: {
|
||||
columns: {
|
||||
id: true,
|
||||
name: true,
|
||||
email: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
orderBy: [desc(studyMembers.joinedAt)],
|
||||
});
|
||||
|
||||
return members;
|
||||
}),
|
||||
|
||||
getActivity: protectedProcedure
|
||||
.input(
|
||||
z.object({
|
||||
studyId: z.string().uuid(),
|
||||
page: z.number().min(1).default(1),
|
||||
limit: z.number().min(1).max(100).default(20),
|
||||
}),
|
||||
)
|
||||
.query(async ({ ctx, input }) => {
|
||||
const { studyId, page, limit } = input;
|
||||
const offset = (page - 1) * limit;
|
||||
const userId = ctx.session.user.id;
|
||||
|
||||
// Check if user has access to this study
|
||||
const membership = await ctx.db.query.studyMembers.findFirst({
|
||||
where: and(
|
||||
eq(studyMembers.studyId, studyId),
|
||||
eq(studyMembers.userId, userId),
|
||||
),
|
||||
});
|
||||
|
||||
if (!membership) {
|
||||
throw new TRPCError({
|
||||
code: "FORBIDDEN",
|
||||
message: "You don't have access to this study",
|
||||
});
|
||||
}
|
||||
|
||||
const activities = await ctx.db.query.activityLogs.findMany({
|
||||
where: eq(activityLogs.studyId, studyId),
|
||||
with: {
|
||||
user: {
|
||||
columns: {
|
||||
id: true,
|
||||
name: true,
|
||||
email: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
limit,
|
||||
offset,
|
||||
orderBy: [desc(activityLogs.createdAt)],
|
||||
});
|
||||
|
||||
const totalCount = await ctx.db
|
||||
.select({ count: count() })
|
||||
.from(activityLogs)
|
||||
.where(eq(activityLogs.studyId, studyId));
|
||||
|
||||
return {
|
||||
activities,
|
||||
pagination: {
|
||||
page,
|
||||
limit,
|
||||
total: totalCount[0]?.count ?? 0,
|
||||
pages: Math.ceil((totalCount[0]?.count ?? 0) / limit),
|
||||
},
|
||||
};
|
||||
}),
|
||||
});
|
||||
537
src/server/api/routers/trials.ts
Normal file
537
src/server/api/routers/trials.ts
Normal file
@@ -0,0 +1,537 @@
|
||||
import { z } from "zod";
|
||||
import { eq, and, desc, asc, gte, lte, inArray, type SQL } from "drizzle-orm";
|
||||
import { TRPCError } from "@trpc/server";
|
||||
import { createTRPCRouter, protectedProcedure } from "~/server/api/trpc";
|
||||
import {
|
||||
trials,
|
||||
trialEvents,
|
||||
wizardInterventions,
|
||||
participants,
|
||||
experiments,
|
||||
studyMembers,
|
||||
trialStatusEnum,
|
||||
} from "~/server/db/schema";
|
||||
import type { db } from "~/server/db";
|
||||
|
||||
// Helper function to check if user has access to trial
|
||||
async function checkTrialAccess(
|
||||
database: typeof db,
|
||||
userId: string,
|
||||
trialId: string,
|
||||
requiredRoles: ("owner" | "researcher" | "wizard" | "observer")[] = [
|
||||
"owner",
|
||||
"researcher",
|
||||
"wizard",
|
||||
],
|
||||
) {
|
||||
const trial = await database
|
||||
.select({
|
||||
id: trials.id,
|
||||
experimentId: trials.experimentId,
|
||||
studyId: experiments.studyId,
|
||||
})
|
||||
.from(trials)
|
||||
.innerJoin(experiments, eq(trials.experimentId, experiments.id))
|
||||
.where(eq(trials.id, trialId))
|
||||
.limit(1);
|
||||
|
||||
if (!trial[0]) {
|
||||
throw new TRPCError({
|
||||
code: "NOT_FOUND",
|
||||
message: "Trial not found",
|
||||
});
|
||||
}
|
||||
|
||||
const membership = await database
|
||||
.select()
|
||||
.from(studyMembers)
|
||||
.where(
|
||||
and(
|
||||
eq(studyMembers.studyId, trial[0].studyId),
|
||||
eq(studyMembers.userId, userId),
|
||||
inArray(studyMembers.role, requiredRoles),
|
||||
),
|
||||
)
|
||||
.limit(1);
|
||||
|
||||
if (!membership[0]) {
|
||||
throw new TRPCError({
|
||||
code: "FORBIDDEN",
|
||||
message: "Insufficient permissions to access this trial",
|
||||
});
|
||||
}
|
||||
|
||||
return trial[0];
|
||||
}
|
||||
|
||||
export const trialsRouter = createTRPCRouter({
|
||||
list: protectedProcedure
|
||||
.input(
|
||||
z.object({
|
||||
studyId: z.string().optional(),
|
||||
experimentId: z.string().optional(),
|
||||
participantId: z.string().optional(),
|
||||
status: z.enum(trialStatusEnum.enumValues).optional(),
|
||||
limit: z.number().min(1).max(100).default(50),
|
||||
offset: z.number().min(0).default(0),
|
||||
}),
|
||||
)
|
||||
.query(async ({ ctx, input }) => {
|
||||
const { db } = ctx;
|
||||
const userId = ctx.session.user.id;
|
||||
|
||||
// Build query conditions
|
||||
const conditions: SQL[] = [];
|
||||
|
||||
if (input.studyId) {
|
||||
conditions.push(eq(experiments.studyId, input.studyId));
|
||||
}
|
||||
if (input.experimentId) {
|
||||
conditions.push(eq(trials.experimentId, input.experimentId));
|
||||
}
|
||||
if (input.participantId) {
|
||||
conditions.push(eq(trials.participantId, input.participantId));
|
||||
}
|
||||
if (input.status) {
|
||||
conditions.push(eq(trials.status, input.status));
|
||||
}
|
||||
|
||||
const query = db
|
||||
.select({
|
||||
id: trials.id,
|
||||
participantId: trials.participantId,
|
||||
experimentId: trials.experimentId,
|
||||
status: trials.status,
|
||||
startedAt: trials.startedAt,
|
||||
completedAt: trials.completedAt,
|
||||
duration: trials.duration,
|
||||
notes: trials.notes,
|
||||
createdAt: trials.createdAt,
|
||||
updatedAt: trials.updatedAt,
|
||||
experiment: {
|
||||
id: experiments.id,
|
||||
name: experiments.name,
|
||||
studyId: experiments.studyId,
|
||||
},
|
||||
participant: {
|
||||
id: participants.id,
|
||||
participantCode: participants.participantCode,
|
||||
},
|
||||
})
|
||||
.from(trials)
|
||||
.innerJoin(experiments, eq(trials.experimentId, experiments.id))
|
||||
.innerJoin(participants, eq(trials.participantId, participants.id))
|
||||
.innerJoin(studyMembers, eq(studyMembers.studyId, experiments.studyId))
|
||||
.where(
|
||||
and(
|
||||
eq(studyMembers.userId, userId),
|
||||
inArray(studyMembers.role, ["owner", "researcher", "wizard"]),
|
||||
...conditions,
|
||||
),
|
||||
)
|
||||
.orderBy(desc(trials.createdAt))
|
||||
.limit(input.limit)
|
||||
.offset(input.offset);
|
||||
|
||||
return await query;
|
||||
}),
|
||||
|
||||
get: protectedProcedure
|
||||
.input(
|
||||
z.object({
|
||||
id: z.string(),
|
||||
}),
|
||||
)
|
||||
.query(async ({ ctx, input }) => {
|
||||
const { db } = ctx;
|
||||
const userId = ctx.session.user.id;
|
||||
|
||||
await checkTrialAccess(db, userId, input.id);
|
||||
|
||||
const trial = await db
|
||||
.select({
|
||||
id: trials.id,
|
||||
participantId: trials.participantId,
|
||||
experimentId: trials.experimentId,
|
||||
status: trials.status,
|
||||
startedAt: trials.startedAt,
|
||||
completedAt: trials.completedAt,
|
||||
duration: trials.duration,
|
||||
notes: trials.notes,
|
||||
metadata: trials.metadata,
|
||||
createdAt: trials.createdAt,
|
||||
updatedAt: trials.updatedAt,
|
||||
experiment: {
|
||||
id: experiments.id,
|
||||
name: experiments.name,
|
||||
description: experiments.description,
|
||||
studyId: experiments.studyId,
|
||||
},
|
||||
participant: {
|
||||
id: participants.id,
|
||||
participantCode: participants.participantCode,
|
||||
demographics: participants.demographics,
|
||||
},
|
||||
})
|
||||
.from(trials)
|
||||
.innerJoin(experiments, eq(trials.experimentId, experiments.id))
|
||||
.innerJoin(participants, eq(trials.participantId, participants.id))
|
||||
.where(eq(trials.id, input.id))
|
||||
.limit(1);
|
||||
|
||||
if (!trial[0]) {
|
||||
throw new TRPCError({
|
||||
code: "NOT_FOUND",
|
||||
message: "Trial not found",
|
||||
});
|
||||
}
|
||||
|
||||
return trial[0];
|
||||
}),
|
||||
|
||||
create: protectedProcedure
|
||||
.input(
|
||||
z.object({
|
||||
participantId: z.string(),
|
||||
experimentId: z.string(),
|
||||
notes: z.string().optional(),
|
||||
metadata: z.any().optional(),
|
||||
}),
|
||||
)
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
const { db } = ctx;
|
||||
const userId = ctx.session.user.id;
|
||||
|
||||
// Check if experiment exists and user has access
|
||||
const experiment = await db
|
||||
.select({
|
||||
id: experiments.id,
|
||||
studyId: experiments.studyId,
|
||||
})
|
||||
.from(experiments)
|
||||
.where(eq(experiments.id, input.experimentId))
|
||||
.limit(1);
|
||||
|
||||
if (!experiment[0]) {
|
||||
throw new TRPCError({
|
||||
code: "NOT_FOUND",
|
||||
message: "Experiment not found",
|
||||
});
|
||||
}
|
||||
|
||||
// Check user access
|
||||
const membership = await db
|
||||
.select()
|
||||
.from(studyMembers)
|
||||
.where(
|
||||
and(
|
||||
eq(studyMembers.studyId, experiment[0].studyId),
|
||||
eq(studyMembers.userId, userId),
|
||||
inArray(studyMembers.role, ["owner", "researcher"]),
|
||||
),
|
||||
)
|
||||
.limit(1);
|
||||
|
||||
if (!membership[0]) {
|
||||
throw new TRPCError({
|
||||
code: "FORBIDDEN",
|
||||
message: "Insufficient permissions to create trial",
|
||||
});
|
||||
}
|
||||
|
||||
// Check if participant exists
|
||||
const participant = await db
|
||||
.select()
|
||||
.from(participants)
|
||||
.where(eq(participants.id, input.participantId))
|
||||
.limit(1);
|
||||
|
||||
if (!participant[0]) {
|
||||
throw new TRPCError({
|
||||
code: "NOT_FOUND",
|
||||
message: "Participant not found",
|
||||
});
|
||||
}
|
||||
|
||||
// Create trial
|
||||
const [trial] = await db
|
||||
.insert(trials)
|
||||
.values({
|
||||
participantId: input.participantId,
|
||||
experimentId: input.experimentId,
|
||||
status: "scheduled",
|
||||
notes: input.notes,
|
||||
metadata: input.metadata,
|
||||
})
|
||||
.returning();
|
||||
|
||||
return trial;
|
||||
}),
|
||||
|
||||
update: protectedProcedure
|
||||
.input(
|
||||
z.object({
|
||||
id: z.string(),
|
||||
notes: z.string().optional(),
|
||||
metadata: z.any().optional(),
|
||||
}),
|
||||
)
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
const { db } = ctx;
|
||||
const userId = ctx.session.user.id;
|
||||
|
||||
await checkTrialAccess(db, userId, input.id);
|
||||
|
||||
const [trial] = await db
|
||||
.update(trials)
|
||||
.set({
|
||||
notes: input.notes,
|
||||
metadata: input.metadata,
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
.where(eq(trials.id, input.id))
|
||||
.returning();
|
||||
|
||||
return trial;
|
||||
}),
|
||||
|
||||
start: 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",
|
||||
]);
|
||||
|
||||
// Get current trial status
|
||||
const currentTrial = await db
|
||||
.select()
|
||||
.from(trials)
|
||||
.where(eq(trials.id, input.id))
|
||||
.limit(1);
|
||||
|
||||
if (!currentTrial[0]) {
|
||||
throw new TRPCError({
|
||||
code: "NOT_FOUND",
|
||||
message: "Trial not found",
|
||||
});
|
||||
}
|
||||
|
||||
if (currentTrial[0].status !== "scheduled") {
|
||||
throw new TRPCError({
|
||||
code: "BAD_REQUEST",
|
||||
message: "Trial can only be started from scheduled status",
|
||||
});
|
||||
}
|
||||
|
||||
// Start trial
|
||||
const [trial] = await db
|
||||
.update(trials)
|
||||
.set({
|
||||
status: "in_progress",
|
||||
startedAt: new Date(),
|
||||
})
|
||||
.where(eq(trials.id, input.id))
|
||||
.returning();
|
||||
|
||||
// Log trial start event
|
||||
await db.insert(trialEvents).values({
|
||||
trialId: input.id,
|
||||
eventType: "trial_started",
|
||||
timestamp: new Date(),
|
||||
data: { userId },
|
||||
});
|
||||
|
||||
return trial;
|
||||
}),
|
||||
|
||||
complete: protectedProcedure
|
||||
.input(
|
||||
z.object({
|
||||
id: z.string(),
|
||||
notes: z.string().optional(),
|
||||
}),
|
||||
)
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
const { db } = ctx;
|
||||
const userId = ctx.session.user.id;
|
||||
|
||||
await checkTrialAccess(db, userId, input.id, [
|
||||
"owner",
|
||||
"researcher",
|
||||
"wizard",
|
||||
]);
|
||||
|
||||
const [trial] = await db
|
||||
.update(trials)
|
||||
.set({
|
||||
status: "completed",
|
||||
completedAt: new Date(),
|
||||
notes: input.notes,
|
||||
})
|
||||
.where(eq(trials.id, input.id))
|
||||
.returning();
|
||||
|
||||
// Log trial completion event
|
||||
await db.insert(trialEvents).values({
|
||||
trialId: input.id,
|
||||
eventType: "trial_completed",
|
||||
timestamp: new Date(),
|
||||
data: { userId, notes: input.notes },
|
||||
});
|
||||
|
||||
return trial;
|
||||
}),
|
||||
|
||||
abort: protectedProcedure
|
||||
.input(
|
||||
z.object({
|
||||
id: z.string(),
|
||||
reason: z.string().optional(),
|
||||
}),
|
||||
)
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
const { db } = ctx;
|
||||
const userId = ctx.session.user.id;
|
||||
|
||||
await checkTrialAccess(db, userId, input.id, [
|
||||
"owner",
|
||||
"researcher",
|
||||
"wizard",
|
||||
]);
|
||||
|
||||
const [trial] = await db
|
||||
.update(trials)
|
||||
.set({
|
||||
status: "aborted",
|
||||
completedAt: new Date(),
|
||||
})
|
||||
.where(eq(trials.id, input.id))
|
||||
.returning();
|
||||
|
||||
// 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;
|
||||
}),
|
||||
|
||||
logEvent: protectedProcedure
|
||||
.input(
|
||||
z.object({
|
||||
trialId: z.string(),
|
||||
type: z.string(),
|
||||
data: z.any().optional(),
|
||||
timestamp: z.date().optional(),
|
||||
}),
|
||||
)
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
const { db } = ctx;
|
||||
const userId = ctx.session.user.id;
|
||||
|
||||
await checkTrialAccess(db, userId, input.trialId, [
|
||||
"owner",
|
||||
"researcher",
|
||||
"wizard",
|
||||
]);
|
||||
|
||||
const [event] = await db
|
||||
.insert(trialEvents)
|
||||
.values({
|
||||
trialId: input.trialId,
|
||||
eventType: input.type,
|
||||
timestamp: input.timestamp ?? new Date(),
|
||||
data: input.data,
|
||||
})
|
||||
.returning();
|
||||
|
||||
return event;
|
||||
}),
|
||||
|
||||
addIntervention: protectedProcedure
|
||||
.input(
|
||||
z.object({
|
||||
trialId: z.string(),
|
||||
type: z.string(),
|
||||
description: z.string(),
|
||||
timestamp: z.date().optional(),
|
||||
data: z.any().optional(),
|
||||
}),
|
||||
)
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
const { db } = ctx;
|
||||
const userId = ctx.session.user.id;
|
||||
|
||||
await checkTrialAccess(db, userId, input.trialId, [
|
||||
"owner",
|
||||
"researcher",
|
||||
"wizard",
|
||||
]);
|
||||
|
||||
const [intervention] = await db
|
||||
.insert(wizardInterventions)
|
||||
.values({
|
||||
trialId: input.trialId,
|
||||
wizardId: userId,
|
||||
interventionType: input.type,
|
||||
description: input.description,
|
||||
timestamp: input.timestamp ?? new Date(),
|
||||
parameters: input.data,
|
||||
})
|
||||
.returning();
|
||||
|
||||
return intervention;
|
||||
}),
|
||||
|
||||
getEvents: protectedProcedure
|
||||
.input(
|
||||
z.object({
|
||||
trialId: z.string(),
|
||||
type: z.string().optional(),
|
||||
startTime: z.date().optional(),
|
||||
endTime: z.date().optional(),
|
||||
limit: z.number().min(1).max(1000).default(100),
|
||||
offset: z.number().min(0).default(0),
|
||||
}),
|
||||
)
|
||||
.query(async ({ ctx, input }) => {
|
||||
const { db } = ctx;
|
||||
const userId = ctx.session.user.id;
|
||||
|
||||
await checkTrialAccess(db, userId, input.trialId);
|
||||
|
||||
const conditions = [eq(trialEvents.trialId, input.trialId)];
|
||||
|
||||
if (input.type) {
|
||||
conditions.push(eq(trialEvents.eventType, input.type));
|
||||
}
|
||||
if (input.startTime) {
|
||||
conditions.push(gte(trialEvents.timestamp, input.startTime));
|
||||
}
|
||||
if (input.endTime) {
|
||||
conditions.push(lte(trialEvents.timestamp, input.endTime));
|
||||
}
|
||||
|
||||
const events = await db
|
||||
.select()
|
||||
.from(trialEvents)
|
||||
.where(and(...conditions))
|
||||
.orderBy(asc(trialEvents.timestamp))
|
||||
.limit(input.limit)
|
||||
.offset(input.offset);
|
||||
|
||||
return events;
|
||||
}),
|
||||
});
|
||||
364
src/server/api/routers/users.ts
Normal file
364
src/server/api/routers/users.ts
Normal file
@@ -0,0 +1,364 @@
|
||||
import { TRPCError } from "@trpc/server";
|
||||
import { and, count, eq, ilike, or, type SQL } from "drizzle-orm";
|
||||
import { z } from "zod";
|
||||
|
||||
import {
|
||||
adminProcedure,
|
||||
createTRPCRouter,
|
||||
protectedProcedure,
|
||||
} from "~/server/api/trpc";
|
||||
import { systemRoleEnum, users, userSystemRoles } from "~/server/db/schema";
|
||||
|
||||
export const usersRouter = createTRPCRouter({
|
||||
list: adminProcedure
|
||||
.input(
|
||||
z.object({
|
||||
page: z.number().min(1).default(1),
|
||||
limit: z.number().min(1).max(100).default(20),
|
||||
search: z.string().optional(),
|
||||
role: z.enum(systemRoleEnum.enumValues).optional(),
|
||||
}),
|
||||
)
|
||||
.query(async ({ ctx, input }) => {
|
||||
const { page, limit, search, role } = input;
|
||||
const offset = (page - 1) * limit;
|
||||
|
||||
// Build where conditions
|
||||
const conditions: SQL[] = [];
|
||||
|
||||
if (search) {
|
||||
conditions.push(
|
||||
or(
|
||||
ilike(users.name, `%${search}%`),
|
||||
ilike(users.email, `%${search}%`),
|
||||
)!,
|
||||
);
|
||||
}
|
||||
|
||||
const whereClause =
|
||||
conditions.length > 0 ? and(...conditions) : undefined;
|
||||
|
||||
// Get users with their roles
|
||||
const usersQuery = ctx.db.query.users.findMany({
|
||||
where: whereClause,
|
||||
with: {
|
||||
systemRoles: true,
|
||||
},
|
||||
columns: {
|
||||
password: false, // Exclude password
|
||||
},
|
||||
limit,
|
||||
offset,
|
||||
orderBy: (users, { asc }) => [asc(users.createdAt)],
|
||||
});
|
||||
|
||||
// Get total count
|
||||
const countQuery = ctx.db
|
||||
.select({ count: count() })
|
||||
.from(users)
|
||||
.where(whereClause);
|
||||
|
||||
const [usersList, totalCountResult] = await Promise.all([
|
||||
usersQuery,
|
||||
countQuery,
|
||||
]);
|
||||
|
||||
const totalCount = totalCountResult[0]?.count ?? 0;
|
||||
|
||||
// Filter by role if specified
|
||||
let filteredUsers = usersList;
|
||||
if (role) {
|
||||
filteredUsers = usersList.filter((user) =>
|
||||
user.systemRoles.some((sr) => sr.role === role),
|
||||
);
|
||||
}
|
||||
|
||||
return {
|
||||
users: filteredUsers.map((user) => ({
|
||||
...user,
|
||||
roles: user.systemRoles.map((sr) => sr.role),
|
||||
})),
|
||||
pagination: {
|
||||
page,
|
||||
limit,
|
||||
total: totalCount,
|
||||
pages: Math.ceil(totalCount / limit),
|
||||
},
|
||||
};
|
||||
}),
|
||||
|
||||
get: protectedProcedure
|
||||
.input(z.object({ id: z.string().uuid() }))
|
||||
.query(async ({ ctx, input }) => {
|
||||
const user = await ctx.db.query.users.findFirst({
|
||||
where: eq(users.id, input.id),
|
||||
with: {
|
||||
systemRoles: {
|
||||
with: {
|
||||
grantedByUser: {
|
||||
columns: {
|
||||
id: true,
|
||||
name: true,
|
||||
email: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
columns: {
|
||||
password: false, // Exclude password
|
||||
},
|
||||
});
|
||||
|
||||
if (!user) {
|
||||
throw new TRPCError({
|
||||
code: "NOT_FOUND",
|
||||
message: "User not found",
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
...user,
|
||||
roles: user.systemRoles.map((sr) => ({
|
||||
role: sr.role,
|
||||
grantedAt: sr.grantedAt,
|
||||
grantedBy: sr.grantedByUser,
|
||||
})),
|
||||
};
|
||||
}),
|
||||
|
||||
update: protectedProcedure
|
||||
.input(
|
||||
z.object({
|
||||
id: z.string().uuid(),
|
||||
name: z.string().min(1).max(255).optional(),
|
||||
email: z.string().email().optional(),
|
||||
image: z.string().url().optional(),
|
||||
}),
|
||||
)
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
const { id, ...updateData } = input;
|
||||
const currentUserId = ctx.session.user.id;
|
||||
|
||||
// Check if user is updating their own profile or is an admin
|
||||
const isAdmin = await ctx.db.query.userSystemRoles.findFirst({
|
||||
where: and(
|
||||
eq(userSystemRoles.userId, currentUserId),
|
||||
eq(userSystemRoles.role, "administrator"),
|
||||
),
|
||||
});
|
||||
|
||||
if (id !== currentUserId && !isAdmin) {
|
||||
throw new TRPCError({
|
||||
code: "FORBIDDEN",
|
||||
message: "You can only update your own profile",
|
||||
});
|
||||
}
|
||||
|
||||
// Check if user exists
|
||||
const existingUser = await ctx.db.query.users.findFirst({
|
||||
where: eq(users.id, id),
|
||||
});
|
||||
|
||||
if (!existingUser) {
|
||||
throw new TRPCError({
|
||||
code: "NOT_FOUND",
|
||||
message: "User not found",
|
||||
});
|
||||
}
|
||||
|
||||
// Check if email is already taken by another user
|
||||
if (updateData.email && updateData.email !== existingUser.email) {
|
||||
const emailExists = await ctx.db.query.users.findFirst({
|
||||
where: and(eq(users.email, updateData.email), eq(users.id, id)),
|
||||
});
|
||||
|
||||
if (emailExists) {
|
||||
throw new TRPCError({
|
||||
code: "CONFLICT",
|
||||
message: "Email is already taken",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const [updatedUser] = await ctx.db
|
||||
.update(users)
|
||||
.set({
|
||||
...updateData,
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
.where(eq(users.id, id))
|
||||
.returning({
|
||||
id: users.id,
|
||||
name: users.name,
|
||||
email: users.email,
|
||||
image: users.image,
|
||||
updatedAt: users.updatedAt,
|
||||
});
|
||||
|
||||
return updatedUser;
|
||||
}),
|
||||
|
||||
assignRole: adminProcedure
|
||||
.input(
|
||||
z.object({
|
||||
userId: z.string().uuid(),
|
||||
role: z.enum(systemRoleEnum.enumValues),
|
||||
}),
|
||||
)
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
const { userId, role } = input;
|
||||
const currentUserId = ctx.session.user.id;
|
||||
|
||||
// Check if target user exists
|
||||
const targetUser = await ctx.db.query.users.findFirst({
|
||||
where: eq(users.id, userId),
|
||||
});
|
||||
|
||||
if (!targetUser) {
|
||||
throw new TRPCError({
|
||||
code: "NOT_FOUND",
|
||||
message: "User not found",
|
||||
});
|
||||
}
|
||||
|
||||
// Check if role assignment already exists
|
||||
const existingRole = await ctx.db.query.userSystemRoles.findFirst({
|
||||
where: and(
|
||||
eq(userSystemRoles.userId, userId),
|
||||
eq(userSystemRoles.role, role),
|
||||
),
|
||||
});
|
||||
|
||||
if (existingRole) {
|
||||
throw new TRPCError({
|
||||
code: "CONFLICT",
|
||||
message: "User already has this role",
|
||||
});
|
||||
}
|
||||
|
||||
// Assign the role
|
||||
const [newRole] = await ctx.db
|
||||
.insert(userSystemRoles)
|
||||
.values({
|
||||
userId,
|
||||
role,
|
||||
grantedBy: currentUserId,
|
||||
})
|
||||
.returning();
|
||||
|
||||
return newRole;
|
||||
}),
|
||||
|
||||
removeRole: adminProcedure
|
||||
.input(
|
||||
z.object({
|
||||
userId: z.string().uuid(),
|
||||
role: z.enum(systemRoleEnum.enumValues),
|
||||
}),
|
||||
)
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
const { userId, role } = input;
|
||||
|
||||
// Check if role assignment exists
|
||||
const existingRole = await ctx.db.query.userSystemRoles.findFirst({
|
||||
where: and(
|
||||
eq(userSystemRoles.userId, userId),
|
||||
eq(userSystemRoles.role, role),
|
||||
),
|
||||
});
|
||||
|
||||
if (!existingRole) {
|
||||
throw new TRPCError({
|
||||
code: "NOT_FOUND",
|
||||
message: "User does not have this role",
|
||||
});
|
||||
}
|
||||
|
||||
// Remove the role
|
||||
await ctx.db
|
||||
.delete(userSystemRoles)
|
||||
.where(
|
||||
and(
|
||||
eq(userSystemRoles.userId, userId),
|
||||
eq(userSystemRoles.role, role),
|
||||
),
|
||||
);
|
||||
|
||||
return { success: true };
|
||||
}),
|
||||
|
||||
delete: adminProcedure
|
||||
.input(z.object({ id: z.string().uuid() }))
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
const { id } = input;
|
||||
const currentUserId = ctx.session.user.id;
|
||||
|
||||
// Prevent self-deletion
|
||||
if (id === currentUserId) {
|
||||
throw new TRPCError({
|
||||
code: "BAD_REQUEST",
|
||||
message: "You cannot delete your own account",
|
||||
});
|
||||
}
|
||||
|
||||
// Check if user exists
|
||||
const existingUser = await ctx.db.query.users.findFirst({
|
||||
where: eq(users.id, id),
|
||||
});
|
||||
|
||||
if (!existingUser) {
|
||||
throw new TRPCError({
|
||||
code: "NOT_FOUND",
|
||||
message: "User not found",
|
||||
});
|
||||
}
|
||||
|
||||
// Soft delete the user
|
||||
await ctx.db
|
||||
.update(users)
|
||||
.set({
|
||||
deletedAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
.where(eq(users.id, id));
|
||||
|
||||
return { success: true };
|
||||
}),
|
||||
|
||||
restore: adminProcedure
|
||||
.input(z.object({ id: z.string().uuid() }))
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
const { id } = input;
|
||||
|
||||
// Check if user exists and is deleted
|
||||
const existingUser = await ctx.db.query.users.findFirst({
|
||||
where: eq(users.id, id),
|
||||
});
|
||||
|
||||
if (!existingUser) {
|
||||
throw new TRPCError({
|
||||
code: "NOT_FOUND",
|
||||
message: "User not found",
|
||||
});
|
||||
}
|
||||
|
||||
if (!existingUser.deletedAt) {
|
||||
throw new TRPCError({
|
||||
code: "BAD_REQUEST",
|
||||
message: "User is not deleted",
|
||||
});
|
||||
}
|
||||
|
||||
// Restore the user
|
||||
await ctx.db
|
||||
.update(users)
|
||||
.set({
|
||||
deletedAt: null,
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
.where(eq(users.id, id));
|
||||
|
||||
return { success: true };
|
||||
}),
|
||||
});
|
||||
Reference in New Issue
Block a user