288 lines
7.9 KiB
TypeScript
288 lines
7.9 KiB
TypeScript
import { z } from "zod";
|
|
import { and, count, desc, eq, gte, ilike, ne, or, sql } from "drizzle-orm";
|
|
import { TRPCError } from "@trpc/server";
|
|
import { logAuditEvent } from "~/lib/audit-log";
|
|
import { sendPasswordResetForUser } from "~/lib/password-reset";
|
|
import { createTRPCRouter, protectedProcedure } from "~/server/api/trpc";
|
|
import { requireAdmin } from "~/server/api/require-admin";
|
|
import {
|
|
auditLog,
|
|
businesses,
|
|
clients,
|
|
invoices,
|
|
sessions,
|
|
timeEntries,
|
|
users,
|
|
} from "~/server/db/schema";
|
|
|
|
const ACTIVE_USER_DAYS = 30;
|
|
|
|
async function assertNotLastAdmin(
|
|
db: Parameters<typeof requireAdmin>[0]["db"],
|
|
userId: string,
|
|
newRole: "user" | "admin",
|
|
) {
|
|
if (newRole === "admin") return;
|
|
|
|
const target = await db.query.users.findFirst({
|
|
where: eq(users.id, userId),
|
|
columns: { role: true },
|
|
});
|
|
|
|
if (target?.role !== "admin") return;
|
|
|
|
const [adminCount] = await db
|
|
.select({ count: count() })
|
|
.from(users)
|
|
.where(eq(users.role, "admin"));
|
|
|
|
if ((adminCount?.count ?? 0) <= 1) {
|
|
throw new TRPCError({
|
|
code: "BAD_REQUEST",
|
|
message: "Cannot remove the last administrator",
|
|
});
|
|
}
|
|
}
|
|
|
|
export const adminRouter = createTRPCRouter({
|
|
getStats: protectedProcedure.query(async ({ ctx }) => {
|
|
await requireAdmin(ctx);
|
|
|
|
const activeSince = new Date();
|
|
activeSince.setDate(activeSince.getDate() - ACTIVE_USER_DAYS);
|
|
|
|
const [
|
|
[totalUsersRow],
|
|
[activeUsersRow],
|
|
[totalInvoicesRow],
|
|
[totalBusinessesRow],
|
|
[totalClientsRow],
|
|
[totalTimeEntriesRow],
|
|
[adminCountRow],
|
|
] = await Promise.all([
|
|
ctx.db.select({ count: count() }).from(users),
|
|
ctx.db
|
|
.select({ count: sql<number>`count(distinct ${sessions.userId})::int` })
|
|
.from(sessions)
|
|
.where(gte(sessions.updatedAt, activeSince)),
|
|
ctx.db.select({ count: count() }).from(invoices),
|
|
ctx.db.select({ count: count() }).from(businesses),
|
|
ctx.db.select({ count: count() }).from(clients),
|
|
ctx.db.select({ count: count() }).from(timeEntries),
|
|
ctx.db
|
|
.select({ count: count() })
|
|
.from(users)
|
|
.where(eq(users.role, "admin")),
|
|
]);
|
|
|
|
return {
|
|
totalUsers: totalUsersRow?.count ?? 0,
|
|
activeUsers: activeUsersRow?.count ?? 0,
|
|
totalInvoices: totalInvoicesRow?.count ?? 0,
|
|
totalBusinesses: totalBusinessesRow?.count ?? 0,
|
|
totalClients: totalClientsRow?.count ?? 0,
|
|
totalTimeEntries: totalTimeEntriesRow?.count ?? 0,
|
|
adminCount: adminCountRow?.count ?? 0,
|
|
activeUserWindowDays: ACTIVE_USER_DAYS,
|
|
};
|
|
}),
|
|
|
|
listUsers: protectedProcedure
|
|
.input(
|
|
z.object({
|
|
search: z.string().optional(),
|
|
offset: z.number().int().min(0).default(0),
|
|
limit: z.number().int().min(1).max(100).default(25),
|
|
}),
|
|
)
|
|
.query(async ({ ctx, input }) => {
|
|
await requireAdmin(ctx);
|
|
|
|
const search = input.search?.trim();
|
|
const whereClause = search
|
|
? or(
|
|
ilike(users.name, `%${search}%`),
|
|
ilike(users.email, `%${search}%`),
|
|
)
|
|
: undefined;
|
|
|
|
const [items, [totalRow]] = await Promise.all([
|
|
ctx.db.query.users.findMany({
|
|
where: whereClause,
|
|
columns: {
|
|
id: true,
|
|
name: true,
|
|
email: true,
|
|
role: true,
|
|
emailVerified: true,
|
|
createdAt: true,
|
|
updatedAt: true,
|
|
},
|
|
orderBy: (usersTable, { asc }) => [asc(usersTable.createdAt)],
|
|
offset: input.offset,
|
|
limit: input.limit,
|
|
}),
|
|
ctx.db
|
|
.select({ count: count() })
|
|
.from(users)
|
|
.where(whereClause),
|
|
]);
|
|
|
|
return {
|
|
items,
|
|
total: totalRow?.count ?? 0,
|
|
offset: input.offset,
|
|
limit: input.limit,
|
|
};
|
|
}),
|
|
|
|
updateUser: protectedProcedure
|
|
.input(
|
|
z.object({
|
|
userId: z.string().min(1),
|
|
name: z.string().min(1, "Name is required"),
|
|
email: z.string().email("Invalid email"),
|
|
role: z.enum(["user", "admin"]),
|
|
}),
|
|
)
|
|
.mutation(async ({ ctx, input }) => {
|
|
await requireAdmin(ctx);
|
|
|
|
const existing = await ctx.db.query.users.findFirst({
|
|
where: eq(users.id, input.userId),
|
|
columns: { id: true, name: true, email: true, role: true },
|
|
});
|
|
|
|
if (!existing) {
|
|
throw new TRPCError({ code: "NOT_FOUND", message: "User not found" });
|
|
}
|
|
|
|
const normalizedEmail = input.email.toLowerCase();
|
|
if (normalizedEmail !== existing.email) {
|
|
const emailTaken = await ctx.db.query.users.findFirst({
|
|
where: and(
|
|
eq(users.email, normalizedEmail),
|
|
ne(users.id, input.userId),
|
|
),
|
|
columns: { id: true },
|
|
});
|
|
|
|
if (emailTaken) {
|
|
throw new TRPCError({
|
|
code: "CONFLICT",
|
|
message: "Email is already in use",
|
|
});
|
|
}
|
|
}
|
|
|
|
await assertNotLastAdmin(ctx.db, input.userId, input.role);
|
|
|
|
const changedFields: string[] = [];
|
|
if (existing.name !== input.name) changedFields.push("name");
|
|
if (existing.email !== normalizedEmail) changedFields.push("email");
|
|
if (existing.role !== input.role) changedFields.push("role");
|
|
|
|
if (changedFields.length === 0) {
|
|
return { success: true };
|
|
}
|
|
|
|
await ctx.db
|
|
.update(users)
|
|
.set({
|
|
name: input.name,
|
|
email: normalizedEmail,
|
|
role: input.role,
|
|
})
|
|
.where(eq(users.id, input.userId));
|
|
|
|
await logAuditEvent({
|
|
actorUserId: ctx.session.user.id,
|
|
action:
|
|
changedFields.includes("role") && changedFields.length === 1
|
|
? "user.role_updated"
|
|
: "user.profile_updated",
|
|
targetType: "user",
|
|
targetId: input.userId,
|
|
metadata: {
|
|
changedFields,
|
|
...(changedFields.includes("role") && {
|
|
previousRole: existing.role,
|
|
newRole: input.role,
|
|
}),
|
|
},
|
|
});
|
|
|
|
return { success: true };
|
|
}),
|
|
|
|
sendPasswordReset: protectedProcedure
|
|
.input(z.object({ userId: z.string().min(1) }))
|
|
.mutation(async ({ ctx, input }) => {
|
|
await requireAdmin(ctx);
|
|
|
|
const user = await ctx.db.query.users.findFirst({
|
|
where: eq(users.id, input.userId),
|
|
columns: { id: true },
|
|
});
|
|
|
|
if (!user) {
|
|
throw new TRPCError({ code: "NOT_FOUND", message: "User not found" });
|
|
}
|
|
|
|
const result = await sendPasswordResetForUser(input.userId);
|
|
|
|
await logAuditEvent({
|
|
actorUserId: ctx.session.user.id,
|
|
action: "user.password_reset_sent",
|
|
targetType: "user",
|
|
targetId: input.userId,
|
|
metadata: { emailSent: result.emailSent },
|
|
});
|
|
|
|
return {
|
|
success: result.success,
|
|
emailSent: result.emailSent,
|
|
};
|
|
}),
|
|
|
|
listAuditLog: protectedProcedure
|
|
.input(
|
|
z.object({
|
|
offset: z.number().int().min(0).default(0),
|
|
limit: z.number().int().min(1).max(100).default(25),
|
|
}),
|
|
)
|
|
.query(async ({ ctx, input }) => {
|
|
await requireAdmin(ctx);
|
|
|
|
const [entries, [totalRow]] = await Promise.all([
|
|
ctx.db.query.auditLog.findMany({
|
|
orderBy: [desc(auditLog.createdAt)],
|
|
offset: input.offset,
|
|
limit: input.limit,
|
|
with: {
|
|
actor: {
|
|
columns: { id: true, name: true },
|
|
},
|
|
},
|
|
}),
|
|
ctx.db.select({ count: count() }).from(auditLog),
|
|
]);
|
|
|
|
return {
|
|
items: entries.map((entry) => ({
|
|
id: entry.id,
|
|
action: entry.action,
|
|
targetType: entry.targetType,
|
|
targetId: entry.targetId,
|
|
metadata: entry.metadata,
|
|
createdAt: entry.createdAt,
|
|
actor: entry.actor,
|
|
})),
|
|
total: totalRow?.count ?? 0,
|
|
offset: input.offset,
|
|
limit: input.limit,
|
|
};
|
|
}),
|
|
});
|