Files
hristudio/src/server/api/routers/studies.ts

653 lines
17 KiB
TypeScript

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