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

483 lines
12 KiB
TypeScript

import { TRPCError } from "@trpc/server";
import { and, count, eq, ilike, or, type SQL } from "drizzle-orm";
import { z } from "zod";
import bcrypt from "bcryptjs";
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;
}),
changePassword: protectedProcedure
.input(
z.object({
currentPassword: z.string().min(1, "Current password is required"),
newPassword: z
.string()
.min(6, "Password must be at least 6 characters"),
}),
)
.mutation(async ({ ctx, input }) => {
const { currentPassword, newPassword } = input;
const userId = ctx.session.user.id;
// Get current user with password
const user = await ctx.db.query.users.findFirst({
where: eq(users.id, userId),
});
if (!user?.password) {
throw new TRPCError({
code: "NOT_FOUND",
message: "User not found",
});
}
// Verify current password
const isValidPassword = await bcrypt.compare(
currentPassword,
user.password,
);
if (!isValidPassword) {
throw new TRPCError({
code: "UNAUTHORIZED",
message: "Current password is incorrect",
});
}
// Hash new password
const hashedNewPassword = await bcrypt.hash(newPassword, 12);
// Update password
await ctx.db
.update(users)
.set({
password: hashedNewPassword,
updatedAt: new Date(),
})
.where(eq(users.id, userId));
return { success: true };
}),
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 };
}),
getWizards: protectedProcedure.query(async ({ ctx }) => {
const userId = ctx.session.user.id;
// Get all studies user is a member of
const userStudies = await ctx.db.query.studyMembers.findMany({
where: eq(studyMembers.userId, userId),
columns: {
studyId: true,
},
});
const studyIds = userStudies.map((membership) => membership.studyId);
if (studyIds.length === 0) {
return [];
}
// Get all users who are members of the same studies and have wizard/researcher roles
const studyMembersWithUsers = await ctx.db.query.studyMembers.findMany({
where: inArray(studyMembers.studyId, studyIds),
with: {
user: {
with: {
systemRoles: true,
},
columns: {
id: true,
name: true,
email: true,
},
},
},
});
// Filter for users with wizard or researcher roles and deduplicate
const wizardUsers = new Map();
studyMembersWithUsers.forEach((member) => {
const user = member.user;
const hasWizardRole = user.systemRoles.some(
(role) =>
role.role === "wizard" ||
role.role === "researcher" ||
role.role === "administrator",
);
if (hasWizardRole && !wizardUsers.has(user.id)) {
wizardUsers.set(user.id, {
id: user.id,
name: user.name,
email: user.email,
role:
user.systemRoles.find(
(role) =>
role.role === "wizard" ||
role.role === "researcher" ||
role.role === "administrator",
)?.role || "wizard",
});
}
});
return Array.from(wizardUsers.values());
}),
});