mirror of
https://github.com/soconnor0919/hristudio.git
synced 2025-12-11 14:44:44 -05:00
Refactor API routes and enhance documentation; add collaboration features and user role management. Update environment example and improve error handling in authentication.
This commit is contained in:
364
src/server/api/routers/users.ts
Normal file
364
src/server/api/routers/users.ts
Normal file
@@ -0,0 +1,364 @@
|
||||
import { TRPCError } from "@trpc/server";
|
||||
import { and, count, eq, ilike, or, type SQL } from "drizzle-orm";
|
||||
import { z } from "zod";
|
||||
|
||||
import {
|
||||
adminProcedure,
|
||||
createTRPCRouter,
|
||||
protectedProcedure,
|
||||
} from "~/server/api/trpc";
|
||||
import { systemRoleEnum, users, userSystemRoles } from "~/server/db/schema";
|
||||
|
||||
export const usersRouter = createTRPCRouter({
|
||||
list: adminProcedure
|
||||
.input(
|
||||
z.object({
|
||||
page: z.number().min(1).default(1),
|
||||
limit: z.number().min(1).max(100).default(20),
|
||||
search: z.string().optional(),
|
||||
role: z.enum(systemRoleEnum.enumValues).optional(),
|
||||
}),
|
||||
)
|
||||
.query(async ({ ctx, input }) => {
|
||||
const { page, limit, search, role } = input;
|
||||
const offset = (page - 1) * limit;
|
||||
|
||||
// Build where conditions
|
||||
const conditions: SQL[] = [];
|
||||
|
||||
if (search) {
|
||||
conditions.push(
|
||||
or(
|
||||
ilike(users.name, `%${search}%`),
|
||||
ilike(users.email, `%${search}%`),
|
||||
)!,
|
||||
);
|
||||
}
|
||||
|
||||
const whereClause =
|
||||
conditions.length > 0 ? and(...conditions) : undefined;
|
||||
|
||||
// Get users with their roles
|
||||
const usersQuery = ctx.db.query.users.findMany({
|
||||
where: whereClause,
|
||||
with: {
|
||||
systemRoles: true,
|
||||
},
|
||||
columns: {
|
||||
password: false, // Exclude password
|
||||
},
|
||||
limit,
|
||||
offset,
|
||||
orderBy: (users, { asc }) => [asc(users.createdAt)],
|
||||
});
|
||||
|
||||
// Get total count
|
||||
const countQuery = ctx.db
|
||||
.select({ count: count() })
|
||||
.from(users)
|
||||
.where(whereClause);
|
||||
|
||||
const [usersList, totalCountResult] = await Promise.all([
|
||||
usersQuery,
|
||||
countQuery,
|
||||
]);
|
||||
|
||||
const totalCount = totalCountResult[0]?.count ?? 0;
|
||||
|
||||
// Filter by role if specified
|
||||
let filteredUsers = usersList;
|
||||
if (role) {
|
||||
filteredUsers = usersList.filter((user) =>
|
||||
user.systemRoles.some((sr) => sr.role === role),
|
||||
);
|
||||
}
|
||||
|
||||
return {
|
||||
users: filteredUsers.map((user) => ({
|
||||
...user,
|
||||
roles: user.systemRoles.map((sr) => sr.role),
|
||||
})),
|
||||
pagination: {
|
||||
page,
|
||||
limit,
|
||||
total: totalCount,
|
||||
pages: Math.ceil(totalCount / limit),
|
||||
},
|
||||
};
|
||||
}),
|
||||
|
||||
get: protectedProcedure
|
||||
.input(z.object({ id: z.string().uuid() }))
|
||||
.query(async ({ ctx, input }) => {
|
||||
const user = await ctx.db.query.users.findFirst({
|
||||
where: eq(users.id, input.id),
|
||||
with: {
|
||||
systemRoles: {
|
||||
with: {
|
||||
grantedByUser: {
|
||||
columns: {
|
||||
id: true,
|
||||
name: true,
|
||||
email: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
columns: {
|
||||
password: false, // Exclude password
|
||||
},
|
||||
});
|
||||
|
||||
if (!user) {
|
||||
throw new TRPCError({
|
||||
code: "NOT_FOUND",
|
||||
message: "User not found",
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
...user,
|
||||
roles: user.systemRoles.map((sr) => ({
|
||||
role: sr.role,
|
||||
grantedAt: sr.grantedAt,
|
||||
grantedBy: sr.grantedByUser,
|
||||
})),
|
||||
};
|
||||
}),
|
||||
|
||||
update: protectedProcedure
|
||||
.input(
|
||||
z.object({
|
||||
id: z.string().uuid(),
|
||||
name: z.string().min(1).max(255).optional(),
|
||||
email: z.string().email().optional(),
|
||||
image: z.string().url().optional(),
|
||||
}),
|
||||
)
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
const { id, ...updateData } = input;
|
||||
const currentUserId = ctx.session.user.id;
|
||||
|
||||
// Check if user is updating their own profile or is an admin
|
||||
const isAdmin = await ctx.db.query.userSystemRoles.findFirst({
|
||||
where: and(
|
||||
eq(userSystemRoles.userId, currentUserId),
|
||||
eq(userSystemRoles.role, "administrator"),
|
||||
),
|
||||
});
|
||||
|
||||
if (id !== currentUserId && !isAdmin) {
|
||||
throw new TRPCError({
|
||||
code: "FORBIDDEN",
|
||||
message: "You can only update your own profile",
|
||||
});
|
||||
}
|
||||
|
||||
// Check if user exists
|
||||
const existingUser = await ctx.db.query.users.findFirst({
|
||||
where: eq(users.id, id),
|
||||
});
|
||||
|
||||
if (!existingUser) {
|
||||
throw new TRPCError({
|
||||
code: "NOT_FOUND",
|
||||
message: "User not found",
|
||||
});
|
||||
}
|
||||
|
||||
// Check if email is already taken by another user
|
||||
if (updateData.email && updateData.email !== existingUser.email) {
|
||||
const emailExists = await ctx.db.query.users.findFirst({
|
||||
where: and(eq(users.email, updateData.email), eq(users.id, id)),
|
||||
});
|
||||
|
||||
if (emailExists) {
|
||||
throw new TRPCError({
|
||||
code: "CONFLICT",
|
||||
message: "Email is already taken",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const [updatedUser] = await ctx.db
|
||||
.update(users)
|
||||
.set({
|
||||
...updateData,
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
.where(eq(users.id, id))
|
||||
.returning({
|
||||
id: users.id,
|
||||
name: users.name,
|
||||
email: users.email,
|
||||
image: users.image,
|
||||
updatedAt: users.updatedAt,
|
||||
});
|
||||
|
||||
return updatedUser;
|
||||
}),
|
||||
|
||||
assignRole: adminProcedure
|
||||
.input(
|
||||
z.object({
|
||||
userId: z.string().uuid(),
|
||||
role: z.enum(systemRoleEnum.enumValues),
|
||||
}),
|
||||
)
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
const { userId, role } = input;
|
||||
const currentUserId = ctx.session.user.id;
|
||||
|
||||
// Check if target user exists
|
||||
const targetUser = await ctx.db.query.users.findFirst({
|
||||
where: eq(users.id, userId),
|
||||
});
|
||||
|
||||
if (!targetUser) {
|
||||
throw new TRPCError({
|
||||
code: "NOT_FOUND",
|
||||
message: "User not found",
|
||||
});
|
||||
}
|
||||
|
||||
// Check if role assignment already exists
|
||||
const existingRole = await ctx.db.query.userSystemRoles.findFirst({
|
||||
where: and(
|
||||
eq(userSystemRoles.userId, userId),
|
||||
eq(userSystemRoles.role, role),
|
||||
),
|
||||
});
|
||||
|
||||
if (existingRole) {
|
||||
throw new TRPCError({
|
||||
code: "CONFLICT",
|
||||
message: "User already has this role",
|
||||
});
|
||||
}
|
||||
|
||||
// Assign the role
|
||||
const [newRole] = await ctx.db
|
||||
.insert(userSystemRoles)
|
||||
.values({
|
||||
userId,
|
||||
role,
|
||||
grantedBy: currentUserId,
|
||||
})
|
||||
.returning();
|
||||
|
||||
return newRole;
|
||||
}),
|
||||
|
||||
removeRole: adminProcedure
|
||||
.input(
|
||||
z.object({
|
||||
userId: z.string().uuid(),
|
||||
role: z.enum(systemRoleEnum.enumValues),
|
||||
}),
|
||||
)
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
const { userId, role } = input;
|
||||
|
||||
// Check if role assignment exists
|
||||
const existingRole = await ctx.db.query.userSystemRoles.findFirst({
|
||||
where: and(
|
||||
eq(userSystemRoles.userId, userId),
|
||||
eq(userSystemRoles.role, role),
|
||||
),
|
||||
});
|
||||
|
||||
if (!existingRole) {
|
||||
throw new TRPCError({
|
||||
code: "NOT_FOUND",
|
||||
message: "User does not have this role",
|
||||
});
|
||||
}
|
||||
|
||||
// Remove the role
|
||||
await ctx.db
|
||||
.delete(userSystemRoles)
|
||||
.where(
|
||||
and(
|
||||
eq(userSystemRoles.userId, userId),
|
||||
eq(userSystemRoles.role, role),
|
||||
),
|
||||
);
|
||||
|
||||
return { success: true };
|
||||
}),
|
||||
|
||||
delete: adminProcedure
|
||||
.input(z.object({ id: z.string().uuid() }))
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
const { id } = input;
|
||||
const currentUserId = ctx.session.user.id;
|
||||
|
||||
// Prevent self-deletion
|
||||
if (id === currentUserId) {
|
||||
throw new TRPCError({
|
||||
code: "BAD_REQUEST",
|
||||
message: "You cannot delete your own account",
|
||||
});
|
||||
}
|
||||
|
||||
// Check if user exists
|
||||
const existingUser = await ctx.db.query.users.findFirst({
|
||||
where: eq(users.id, id),
|
||||
});
|
||||
|
||||
if (!existingUser) {
|
||||
throw new TRPCError({
|
||||
code: "NOT_FOUND",
|
||||
message: "User not found",
|
||||
});
|
||||
}
|
||||
|
||||
// Soft delete the user
|
||||
await ctx.db
|
||||
.update(users)
|
||||
.set({
|
||||
deletedAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
.where(eq(users.id, id));
|
||||
|
||||
return { success: true };
|
||||
}),
|
||||
|
||||
restore: adminProcedure
|
||||
.input(z.object({ id: z.string().uuid() }))
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
const { id } = input;
|
||||
|
||||
// Check if user exists and is deleted
|
||||
const existingUser = await ctx.db.query.users.findFirst({
|
||||
where: eq(users.id, id),
|
||||
});
|
||||
|
||||
if (!existingUser) {
|
||||
throw new TRPCError({
|
||||
code: "NOT_FOUND",
|
||||
message: "User not found",
|
||||
});
|
||||
}
|
||||
|
||||
if (!existingUser.deletedAt) {
|
||||
throw new TRPCError({
|
||||
code: "BAD_REQUEST",
|
||||
message: "User is not deleted",
|
||||
});
|
||||
}
|
||||
|
||||
// Restore the user
|
||||
await ctx.db
|
||||
.update(users)
|
||||
.set({
|
||||
deletedAt: null,
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
.where(eq(users.id, id));
|
||||
|
||||
return { success: true };
|
||||
}),
|
||||
});
|
||||
Reference in New Issue
Block a user