mirror of
https://github.com/soconnor0919/hristudio.git
synced 2025-12-11 14:44:44 -05:00
483 lines
12 KiB
TypeScript
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());
|
|
}),
|
|
});
|