add receipts support
This commit is contained in:
@@ -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,
|
||||
};
|
||||
}),
|
||||
});
|
||||
@@ -94,6 +94,16 @@ export const emailRouter = createTRPCRouter({
|
||||
| "minimal"
|
||||
| undefined,
|
||||
pdfAccentColor: settings?.pdfAccentColor,
|
||||
pdfFontFamily: settings?.pdfFontFamily as
|
||||
| "sans"
|
||||
| "serif"
|
||||
| "mono"
|
||||
| undefined,
|
||||
pdfNumericFontFamily: settings?.pdfNumericFontFamily as
|
||||
| "sans"
|
||||
| "serif"
|
||||
| "mono"
|
||||
| undefined,
|
||||
pdfFooterText: settings?.pdfFooterText,
|
||||
pdfShowLogo: settings?.pdfShowLogo,
|
||||
pdfShowPageNumbers: settings?.pdfShowPageNumbers,
|
||||
|
||||
@@ -1,9 +1,24 @@
|
||||
import { z } from "zod";
|
||||
import { eq, and, desc } from "drizzle-orm";
|
||||
import { createTRPCRouter, protectedProcedure } from "../trpc";
|
||||
import { expenses, clients, businesses, invoices } from "~/server/db/schema";
|
||||
import {
|
||||
expenses,
|
||||
clients,
|
||||
invoices,
|
||||
expenseReceipts,
|
||||
} from "~/server/db/schema";
|
||||
import { TRPCError } from "@trpc/server";
|
||||
import { EXPENSE_CATEGORIES } from "~/lib/expense-categories";
|
||||
import {
|
||||
resolveBusinessForExpense,
|
||||
verifyBusinessAccess,
|
||||
} from "~/server/api/lib/business-access";
|
||||
import {
|
||||
deleteObject,
|
||||
isAllowedReceiptMime,
|
||||
putObject,
|
||||
RECEIPT_MAX_BYTES,
|
||||
} from "~/lib/object-storage";
|
||||
|
||||
export { EXPENSE_CATEGORIES };
|
||||
|
||||
@@ -26,14 +41,108 @@ const updateExpenseSchema = createExpenseSchema.partial().extend({
|
||||
id: z.string(),
|
||||
});
|
||||
|
||||
export const expensesRouter = createTRPCRouter({
|
||||
getAll: protectedProcedure.query(async ({ ctx }) => {
|
||||
return await ctx.db.query.expenses.findMany({
|
||||
where: eq(expenses.createdById, ctx.session.user.id),
|
||||
with: { client: true, business: true, invoice: true },
|
||||
orderBy: [desc(expenses.date)],
|
||||
async function verifyClientAccess(
|
||||
ctx: { db: typeof import("~/server/db").db; session: { user: { id: string } } },
|
||||
clientId: string,
|
||||
) {
|
||||
const client = await ctx.db.query.clients.findFirst({
|
||||
where: and(
|
||||
eq(clients.id, clientId),
|
||||
eq(clients.createdById, ctx.session.user.id),
|
||||
),
|
||||
});
|
||||
if (!client) {
|
||||
throw new TRPCError({
|
||||
code: "FORBIDDEN",
|
||||
message: "Client not found",
|
||||
});
|
||||
}),
|
||||
}
|
||||
return client;
|
||||
}
|
||||
|
||||
async function verifyInvoiceAccess(
|
||||
ctx: { db: typeof import("~/server/db").db; session: { user: { id: string } } },
|
||||
invoiceId: string,
|
||||
) {
|
||||
const invoice = await ctx.db.query.invoices.findFirst({
|
||||
where: and(
|
||||
eq(invoices.id, invoiceId),
|
||||
eq(invoices.createdById, ctx.session.user.id),
|
||||
),
|
||||
});
|
||||
if (!invoice) {
|
||||
throw new TRPCError({
|
||||
code: "FORBIDDEN",
|
||||
message: "Invoice not found",
|
||||
});
|
||||
}
|
||||
return invoice;
|
||||
}
|
||||
|
||||
async function resolveExpenseBusinessId(
|
||||
ctx: { db: typeof import("~/server/db").db; session: { user: { id: string } } },
|
||||
businessId: string | null,
|
||||
invoice?: { businessId: string | null } | null,
|
||||
) {
|
||||
const explicitBusinessId =
|
||||
businessId && businessId.trim() !== "" ? businessId : null;
|
||||
const inheritedBusinessId =
|
||||
!explicitBusinessId && invoice?.businessId ? invoice.businessId : null;
|
||||
|
||||
const resolved = await resolveBusinessForExpense(
|
||||
ctx,
|
||||
explicitBusinessId ?? inheritedBusinessId,
|
||||
);
|
||||
return resolved?.id ?? null;
|
||||
}
|
||||
|
||||
async function getOwnedExpense(
|
||||
ctx: { db: typeof import("~/server/db").db; session: { user: { id: string } } },
|
||||
expenseId: string,
|
||||
) {
|
||||
const expense = await ctx.db.query.expenses.findFirst({
|
||||
where: and(
|
||||
eq(expenses.id, expenseId),
|
||||
eq(expenses.createdById, ctx.session.user.id),
|
||||
),
|
||||
});
|
||||
if (!expense) {
|
||||
throw new TRPCError({
|
||||
code: "NOT_FOUND",
|
||||
message: "Expense not found",
|
||||
});
|
||||
}
|
||||
return expense;
|
||||
}
|
||||
|
||||
export const expensesRouter = createTRPCRouter({
|
||||
getAll: protectedProcedure
|
||||
.input(
|
||||
z
|
||||
.object({
|
||||
businessId: z.string().optional(),
|
||||
})
|
||||
.optional(),
|
||||
)
|
||||
.query(async ({ ctx, input }) => {
|
||||
const conditions = [eq(expenses.createdById, ctx.session.user.id)];
|
||||
|
||||
if (input?.businessId) {
|
||||
await verifyBusinessAccess(ctx, input.businessId);
|
||||
conditions.push(eq(expenses.businessId, input.businessId));
|
||||
}
|
||||
|
||||
return await ctx.db.query.expenses.findMany({
|
||||
where: and(...conditions),
|
||||
with: {
|
||||
client: true,
|
||||
business: true,
|
||||
invoice: true,
|
||||
receipts: true,
|
||||
},
|
||||
orderBy: [desc(expenses.date)],
|
||||
});
|
||||
}),
|
||||
|
||||
getById: protectedProcedure
|
||||
.input(z.object({ id: z.string() }))
|
||||
@@ -43,7 +152,12 @@ export const expensesRouter = createTRPCRouter({
|
||||
eq(expenses.id, input.id),
|
||||
eq(expenses.createdById, ctx.session.user.id),
|
||||
),
|
||||
with: { client: true, business: true, invoice: true },
|
||||
with: {
|
||||
client: true,
|
||||
business: true,
|
||||
invoice: true,
|
||||
receipts: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (!expense) {
|
||||
@@ -69,50 +183,26 @@ export const expensesRouter = createTRPCRouter({
|
||||
};
|
||||
|
||||
if (clean.clientId) {
|
||||
const client = await ctx.db.query.clients.findFirst({
|
||||
where: and(
|
||||
eq(clients.id, clean.clientId),
|
||||
eq(clients.createdById, ctx.session.user.id),
|
||||
),
|
||||
});
|
||||
if (!client)
|
||||
throw new TRPCError({
|
||||
code: "FORBIDDEN",
|
||||
message: "Client not found",
|
||||
});
|
||||
await verifyClientAccess(ctx, clean.clientId);
|
||||
}
|
||||
|
||||
if (clean.businessId) {
|
||||
const business = await ctx.db.query.businesses.findFirst({
|
||||
where: and(
|
||||
eq(businesses.id, clean.businessId),
|
||||
eq(businesses.createdById, ctx.session.user.id),
|
||||
),
|
||||
});
|
||||
if (!business)
|
||||
throw new TRPCError({
|
||||
code: "FORBIDDEN",
|
||||
message: "Business not found",
|
||||
});
|
||||
}
|
||||
const invoice = clean.invoiceId
|
||||
? await verifyInvoiceAccess(ctx, clean.invoiceId)
|
||||
: null;
|
||||
|
||||
if (clean.invoiceId) {
|
||||
const invoice = await ctx.db.query.invoices.findFirst({
|
||||
where: and(
|
||||
eq(invoices.id, clean.invoiceId),
|
||||
eq(invoices.createdById, ctx.session.user.id),
|
||||
),
|
||||
});
|
||||
if (!invoice)
|
||||
throw new TRPCError({
|
||||
code: "FORBIDDEN",
|
||||
message: "Invoice not found",
|
||||
});
|
||||
}
|
||||
const businessId = await resolveExpenseBusinessId(
|
||||
ctx,
|
||||
clean.businessId,
|
||||
invoice,
|
||||
);
|
||||
|
||||
const [expense] = await ctx.db
|
||||
.insert(expenses)
|
||||
.values({ ...clean, createdById: ctx.session.user.id })
|
||||
.values({
|
||||
...clean,
|
||||
businessId,
|
||||
createdById: ctx.session.user.id,
|
||||
})
|
||||
.returning();
|
||||
|
||||
return expense;
|
||||
@@ -137,17 +227,56 @@ export const expensesRouter = createTRPCRouter({
|
||||
});
|
||||
}
|
||||
|
||||
const clean = {
|
||||
...data,
|
||||
clientId: data.clientId?.trim() ?? null,
|
||||
businessId: data.businessId?.trim() ?? null,
|
||||
invoiceId: data.invoiceId?.trim() ?? null,
|
||||
category: data.category?.trim() ?? null,
|
||||
notes: data.notes?.trim() ?? null,
|
||||
updatedAt: new Date(),
|
||||
};
|
||||
const updates: Record<string, unknown> = { updatedAt: new Date() };
|
||||
|
||||
await ctx.db.update(expenses).set(clean).where(eq(expenses.id, id));
|
||||
if (data.date !== undefined) updates.date = data.date;
|
||||
if (data.description !== undefined) updates.description = data.description;
|
||||
if (data.amount !== undefined) updates.amount = data.amount;
|
||||
if (data.currency !== undefined) updates.currency = data.currency;
|
||||
if (data.billable !== undefined) updates.billable = data.billable;
|
||||
if (data.reimbursable !== undefined) updates.reimbursable = data.reimbursable;
|
||||
if (data.taxDeductible !== undefined) updates.taxDeductible = data.taxDeductible;
|
||||
if (data.category !== undefined) {
|
||||
updates.category = data.category?.trim() ?? null;
|
||||
}
|
||||
if (data.notes !== undefined) {
|
||||
updates.notes = data.notes?.trim() ?? null;
|
||||
}
|
||||
|
||||
const nextClientId =
|
||||
data.clientId !== undefined ? data.clientId?.trim() || null : existing.clientId;
|
||||
if (data.clientId !== undefined) {
|
||||
if (nextClientId) await verifyClientAccess(ctx, nextClientId);
|
||||
updates.clientId = nextClientId;
|
||||
}
|
||||
|
||||
const nextInvoiceId =
|
||||
data.invoiceId !== undefined
|
||||
? data.invoiceId?.trim() || null
|
||||
: existing.invoiceId;
|
||||
let invoice = null;
|
||||
if (data.invoiceId !== undefined) {
|
||||
invoice = nextInvoiceId
|
||||
? await verifyInvoiceAccess(ctx, nextInvoiceId)
|
||||
: null;
|
||||
updates.invoiceId = nextInvoiceId;
|
||||
} else if (nextInvoiceId) {
|
||||
invoice = await verifyInvoiceAccess(ctx, nextInvoiceId);
|
||||
}
|
||||
|
||||
if (data.businessId !== undefined || data.invoiceId !== undefined) {
|
||||
const nextBusinessInput =
|
||||
data.businessId !== undefined
|
||||
? data.businessId?.trim() || null
|
||||
: existing.businessId;
|
||||
updates.businessId = await resolveExpenseBusinessId(
|
||||
ctx,
|
||||
nextBusinessInput,
|
||||
invoice,
|
||||
);
|
||||
}
|
||||
|
||||
await ctx.db.update(expenses).set(updates).where(eq(expenses.id, id));
|
||||
|
||||
return { success: true };
|
||||
}),
|
||||
@@ -160,6 +289,7 @@ export const expensesRouter = createTRPCRouter({
|
||||
eq(expenses.id, input.id),
|
||||
eq(expenses.createdById, ctx.session.user.id),
|
||||
),
|
||||
with: { receipts: true },
|
||||
});
|
||||
|
||||
if (!existing) {
|
||||
@@ -169,8 +299,101 @@ export const expensesRouter = createTRPCRouter({
|
||||
});
|
||||
}
|
||||
|
||||
await Promise.all(
|
||||
existing.receipts.map((receipt) => deleteObject(receipt.storageKey)),
|
||||
);
|
||||
|
||||
await ctx.db.delete(expenses).where(eq(expenses.id, input.id));
|
||||
|
||||
return { success: true };
|
||||
}),
|
||||
|
||||
listReceipts: protectedProcedure
|
||||
.input(z.object({ expenseId: z.string() }))
|
||||
.query(async ({ ctx, input }) => {
|
||||
await getOwnedExpense(ctx, input.expenseId);
|
||||
|
||||
return ctx.db.query.expenseReceipts.findMany({
|
||||
where: eq(expenseReceipts.expenseId, input.expenseId),
|
||||
orderBy: [desc(expenseReceipts.createdAt)],
|
||||
});
|
||||
}),
|
||||
|
||||
uploadReceipt: protectedProcedure
|
||||
.input(
|
||||
z.object({
|
||||
expenseId: z.string(),
|
||||
filename: z.string().min(1).max(255),
|
||||
mimeType: z.string().min(1).max(100),
|
||||
data: z.string().min(1),
|
||||
}),
|
||||
)
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
await getOwnedExpense(ctx, input.expenseId);
|
||||
|
||||
if (!isAllowedReceiptMime(input.mimeType)) {
|
||||
throw new TRPCError({
|
||||
code: "BAD_REQUEST",
|
||||
message: "Only images and PDF files are allowed",
|
||||
});
|
||||
}
|
||||
|
||||
const body = Buffer.from(input.data, "base64");
|
||||
if (body.length === 0) {
|
||||
throw new TRPCError({
|
||||
code: "BAD_REQUEST",
|
||||
message: "File is empty",
|
||||
});
|
||||
}
|
||||
if (body.length > RECEIPT_MAX_BYTES) {
|
||||
throw new TRPCError({
|
||||
code: "BAD_REQUEST",
|
||||
message: "File exceeds 10MB limit",
|
||||
});
|
||||
}
|
||||
|
||||
const safeName = input.filename.replace(/[^a-zA-Z0-9._-]/g, "_");
|
||||
const storageKey = `receipts/${ctx.session.user.id}/${input.expenseId}/${crypto.randomUUID()}-${safeName}`;
|
||||
|
||||
await putObject(storageKey, body, input.mimeType);
|
||||
|
||||
const [receipt] = await ctx.db
|
||||
.insert(expenseReceipts)
|
||||
.values({
|
||||
expenseId: input.expenseId,
|
||||
storageKey,
|
||||
originalFilename: input.filename,
|
||||
mimeType: input.mimeType,
|
||||
sizeBytes: body.length,
|
||||
})
|
||||
.returning();
|
||||
|
||||
return receipt;
|
||||
}),
|
||||
|
||||
deleteReceipt: protectedProcedure
|
||||
.input(z.object({ id: z.string() }))
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
const receipt = await ctx.db.query.expenseReceipts.findFirst({
|
||||
where: eq(expenseReceipts.id, input.id),
|
||||
with: { expense: true },
|
||||
});
|
||||
|
||||
if (
|
||||
!receipt ||
|
||||
receipt.expense.createdById !== ctx.session.user.id
|
||||
) {
|
||||
throw new TRPCError({
|
||||
code: "NOT_FOUND",
|
||||
message: "Receipt not found",
|
||||
});
|
||||
}
|
||||
|
||||
await deleteObject(receipt.storageKey);
|
||||
await ctx.db
|
||||
.delete(expenseReceipts)
|
||||
.where(eq(expenseReceipts.id, input.id));
|
||||
|
||||
return { success: true };
|
||||
}),
|
||||
});
|
||||
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
platformSettings,
|
||||
} from "~/server/db/schema";
|
||||
import { TRPCError } from "@trpc/server";
|
||||
import { calculateLineItemAmount } from "~/lib/invoice-line-item";
|
||||
import { generateInvoicePDFBlob } from "~/lib/pdf-export";
|
||||
import { defaultDueDate, generateInvoiceNumber } from "~/lib/draft-invoice";
|
||||
import { Resend } from "resend";
|
||||
@@ -22,12 +23,29 @@ type InvoiceRouterContext = {
|
||||
session: { user: { id: string } };
|
||||
};
|
||||
|
||||
const invoiceItemSchema = z.object({
|
||||
date: z.date(),
|
||||
description: z.string().min(1, "Description is required"),
|
||||
hours: z.number().min(0, "Hours must be positive"),
|
||||
rate: z.number().min(0, "Rate must be positive"),
|
||||
});
|
||||
const invoiceItemSchema = z
|
||||
.object({
|
||||
date: z.date(),
|
||||
description: z.string().min(1, "Description is required"),
|
||||
hours: z.number().min(0, "Hours must be positive"),
|
||||
rate: z.number().min(0, "Rate must be positive"),
|
||||
})
|
||||
.superRefine((item, ctx) => {
|
||||
if (item.hours === 0 && item.rate <= 0) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
message: "Fixed line items need an amount greater than zero",
|
||||
path: ["rate"],
|
||||
});
|
||||
}
|
||||
if (item.hours > 0 && item.rate <= 0) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
message: "Hourly line items need a rate greater than zero",
|
||||
path: ["rate"],
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
const createInvoiceSchema = z.object({
|
||||
invoiceNumber: z.string().min(1, "Invoice number is required"),
|
||||
@@ -162,7 +180,10 @@ const calculateInvoiceTotal = (
|
||||
items: Array<z.infer<typeof invoiceItemSchema>>,
|
||||
taxRate: number,
|
||||
) => {
|
||||
const subtotal = items.reduce((sum, item) => sum + item.hours * item.rate, 0);
|
||||
const subtotal = items.reduce(
|
||||
(sum, item) => sum + calculateLineItemAmount(item.hours, item.rate),
|
||||
0,
|
||||
);
|
||||
const taxAmount = (subtotal * taxRate) / 100;
|
||||
return subtotal + taxAmount;
|
||||
};
|
||||
@@ -445,7 +466,7 @@ export const invoicesRouter = createTRPCRouter({
|
||||
items.map((item, idx) => ({
|
||||
...item,
|
||||
invoiceId: invoice.id,
|
||||
amount: item.hours * item.rate,
|
||||
amount: calculateLineItemAmount(item.hours, item.rate),
|
||||
position: idx,
|
||||
})),
|
||||
);
|
||||
@@ -563,7 +584,7 @@ export const invoicesRouter = createTRPCRouter({
|
||||
items.map((item, idx) => ({
|
||||
...item,
|
||||
invoiceId: id,
|
||||
amount: item.hours * item.rate,
|
||||
amount: calculateLineItemAmount(item.hours, item.rate),
|
||||
position: idx,
|
||||
})),
|
||||
);
|
||||
@@ -863,7 +884,7 @@ export const invoicesRouter = createTRPCRouter({
|
||||
dbItems.map((item, idx) => ({
|
||||
...item,
|
||||
invoiceId: invoice.id,
|
||||
amount: item.hours * item.rate,
|
||||
amount: calculateLineItemAmount(item.hours, item.rate),
|
||||
position: idx,
|
||||
})),
|
||||
);
|
||||
@@ -935,7 +956,7 @@ export const invoicesRouter = createTRPCRouter({
|
||||
description: item.description,
|
||||
hours: item.hours,
|
||||
rate: item.rate,
|
||||
amount: item.hours * item.rate,
|
||||
amount: calculateLineItemAmount(item.hours, item.rate),
|
||||
})),
|
||||
},
|
||||
{
|
||||
@@ -944,6 +965,16 @@ export const invoicesRouter = createTRPCRouter({
|
||||
| "minimal"
|
||||
| undefined,
|
||||
pdfAccentColor: settings?.pdfAccentColor,
|
||||
pdfFontFamily: settings?.pdfFontFamily as
|
||||
| "sans"
|
||||
| "serif"
|
||||
| "mono"
|
||||
| undefined,
|
||||
pdfNumericFontFamily: settings?.pdfNumericFontFamily as
|
||||
| "sans"
|
||||
| "serif"
|
||||
| "mono"
|
||||
| undefined,
|
||||
pdfFooterText: settings?.pdfFooterText,
|
||||
pdfShowLogo: settings?.pdfShowLogo,
|
||||
pdfShowPageNumbers: settings?.pdfShowPageNumbers,
|
||||
|
||||
@@ -1,12 +1,14 @@
|
||||
import { z } from "zod";
|
||||
import { and, eq, isNull } from "drizzle-orm";
|
||||
import { and, count, eq, isNull } from "drizzle-orm";
|
||||
import { TRPCError } from "@trpc/server";
|
||||
import bcrypt from "bcryptjs";
|
||||
import { logAuditEvent } from "~/lib/audit-log";
|
||||
import {
|
||||
createTRPCRouter,
|
||||
protectedProcedure,
|
||||
publicProcedure,
|
||||
} from "~/server/api/trpc";
|
||||
import { requireAdmin } from "~/server/api/require-admin";
|
||||
import {
|
||||
accounts,
|
||||
users,
|
||||
@@ -26,24 +28,10 @@ import {
|
||||
colorModeSchema,
|
||||
defaultColorMode,
|
||||
defaultPdfSettings,
|
||||
pdfFontFamilySchema,
|
||||
pdfTemplateSchema,
|
||||
type ColorMode,
|
||||
} from "~/lib/branding";
|
||||
import type { db as database } from "~/server/db";
|
||||
|
||||
async function requireAdmin(ctx: {
|
||||
db: typeof database;
|
||||
session: { user: { id: string } };
|
||||
}) {
|
||||
const user = await ctx.db.query.users.findFirst({
|
||||
where: eq(users.id, ctx.session.user.id),
|
||||
columns: { role: true },
|
||||
});
|
||||
|
||||
if (user?.role !== "admin") {
|
||||
throw new TRPCError({ code: "FORBIDDEN" });
|
||||
}
|
||||
}
|
||||
|
||||
function resolveBusinessId(
|
||||
refs: { businessName?: string; businessNickname?: string },
|
||||
@@ -237,10 +225,50 @@ export const settingsRouter = createTRPCRouter({
|
||||
)
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
await requireAdmin(ctx);
|
||||
|
||||
const existing = await ctx.db.query.users.findFirst({
|
||||
where: eq(users.id, input.userId),
|
||||
columns: { role: true },
|
||||
});
|
||||
|
||||
if (!existing) {
|
||||
throw new TRPCError({ code: "NOT_FOUND", message: "User not found" });
|
||||
}
|
||||
|
||||
if (existing.role === input.role) {
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
if (existing.role === "admin" && input.role === "user") {
|
||||
const [adminCount] = await ctx.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",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
await ctx.db
|
||||
.update(users)
|
||||
.set({ role: input.role })
|
||||
.where(eq(users.id, input.userId));
|
||||
|
||||
await logAuditEvent({
|
||||
actorUserId: ctx.session.user.id,
|
||||
action: "user.role_updated",
|
||||
targetType: "user",
|
||||
targetId: input.userId,
|
||||
metadata: {
|
||||
previousRole: existing.role,
|
||||
newRole: input.role,
|
||||
},
|
||||
});
|
||||
|
||||
return { success: true };
|
||||
}),
|
||||
|
||||
@@ -382,6 +410,12 @@ export const settingsRouter = createTRPCRouter({
|
||||
defaultPdfSettings.pdfTemplate,
|
||||
pdfAccentColor:
|
||||
settings?.pdfAccentColor ?? defaultPdfSettings.pdfAccentColor,
|
||||
pdfFontFamily:
|
||||
(settings?.pdfFontFamily as "sans" | "serif" | "mono" | null) ??
|
||||
defaultPdfSettings.pdfFontFamily,
|
||||
pdfNumericFontFamily:
|
||||
(settings?.pdfNumericFontFamily as "sans" | "serif" | "mono" | null) ??
|
||||
defaultPdfSettings.pdfNumericFontFamily,
|
||||
pdfFooterText:
|
||||
settings?.pdfFooterText ?? defaultPdfSettings.pdfFooterText,
|
||||
pdfShowLogo: settings?.pdfShowLogo ?? defaultPdfSettings.pdfShowLogo,
|
||||
@@ -395,6 +429,8 @@ export const settingsRouter = createTRPCRouter({
|
||||
z.object({
|
||||
pdfTemplate: pdfTemplateSchema.optional(),
|
||||
pdfAccentColor: z.string().min(4).max(50).optional(),
|
||||
pdfFontFamily: pdfFontFamilySchema.optional(),
|
||||
pdfNumericFontFamily: pdfFontFamilySchema.optional(),
|
||||
pdfFooterText: z.string().min(1).max(120).optional(),
|
||||
pdfShowLogo: z.boolean().optional(),
|
||||
pdfShowPageNumbers: z.boolean().optional(),
|
||||
@@ -409,6 +445,11 @@ export const settingsRouter = createTRPCRouter({
|
||||
pdfTemplate: input.pdfTemplate ?? defaultPdfSettings.pdfTemplate,
|
||||
pdfAccentColor:
|
||||
input.pdfAccentColor ?? defaultPdfSettings.pdfAccentColor,
|
||||
pdfFontFamily:
|
||||
input.pdfFontFamily ?? defaultPdfSettings.pdfFontFamily,
|
||||
pdfNumericFontFamily:
|
||||
input.pdfNumericFontFamily ??
|
||||
defaultPdfSettings.pdfNumericFontFamily,
|
||||
pdfFooterText:
|
||||
input.pdfFooterText ?? defaultPdfSettings.pdfFooterText,
|
||||
pdfShowLogo: input.pdfShowLogo ?? defaultPdfSettings.pdfShowLogo,
|
||||
@@ -422,6 +463,12 @@ export const settingsRouter = createTRPCRouter({
|
||||
...(input.pdfAccentColor && {
|
||||
pdfAccentColor: input.pdfAccentColor,
|
||||
}),
|
||||
...(input.pdfFontFamily && {
|
||||
pdfFontFamily: input.pdfFontFamily,
|
||||
}),
|
||||
...(input.pdfNumericFontFamily && {
|
||||
pdfNumericFontFamily: input.pdfNumericFontFamily,
|
||||
}),
|
||||
...(input.pdfFooterText && { pdfFooterText: input.pdfFooterText }),
|
||||
...(input.pdfShowLogo !== undefined && {
|
||||
pdfShowLogo: input.pdfShowLogo,
|
||||
@@ -433,10 +480,20 @@ export const settingsRouter = createTRPCRouter({
|
||||
},
|
||||
});
|
||||
|
||||
await logAuditEvent({
|
||||
actorUserId: ctx.session.user.id,
|
||||
action: "platform.pdf_settings_updated",
|
||||
targetType: "platform",
|
||||
targetId: "global",
|
||||
metadata: {
|
||||
changedFields: Object.keys(input).filter(
|
||||
(key) => input[key as keyof typeof input] !== undefined,
|
||||
),
|
||||
},
|
||||
});
|
||||
|
||||
return { success: true };
|
||||
}),
|
||||
|
||||
// Update user profile
|
||||
updateProfile: protectedProcedure
|
||||
.input(
|
||||
z.object({
|
||||
|
||||
Reference in New Issue
Block a user