add receipts support

This commit is contained in:
2026-06-26 23:07:43 -04:00
parent 85df7c4627
commit b9a9b813d2
55 changed files with 3239 additions and 441 deletions
+287
View File
@@ -0,0 +1,287 @@
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,
};
}),
});