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:
2025-07-18 16:34:25 -04:00
parent 2dcd2a2832
commit 28ac7dd9e0
23 changed files with 7439 additions and 157 deletions

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

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

View File

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

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

File diff suppressed because it is too large Load Diff

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

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

View 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 ?? [];
}),
}),
});

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

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

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