import type { APIRoute } from "astro"; import { db } from "../../../../db"; import { invoices, invoiceItems, timeEntries, members, timeEntryTags, tags, } from "../../../../db/schema"; import { eq, and, gte, lte, isNull, isNotNull, inArray, sql, desc, } from "drizzle-orm"; import { nanoid } from "nanoid"; export const POST: APIRoute = async ({ request, params, locals, redirect }) => { const user = locals.user; if (!user) { return new Response("Unauthorized", { status: 401 }); } const { id } = params; if (!id) { return new Response("Invoice ID is required", { status: 400 }); } const formData = await request.formData(); const startDateStr = formData.get("startDate") as string; const endDateStr = formData.get("endDate") as string; const groupByDay = formData.get("groupByDay") === "on"; if (!startDateStr || !endDateStr) { return new Response("Start date and end date are required", { status: 400, }); } const startDate = new Date(startDateStr); const endDate = new Date(endDateStr); // Set end date to end of day endDate.setHours(23, 59, 59, 999); const invoice = await db.select().from(invoices).where(eq(invoices.id, id)).get(); if (!invoice) { return new Response("Invoice not found", { status: 404 }); } const membership = await db .select() .from(members) .where( and( eq(members.userId, user.id), eq(members.organizationId, invoice.organizationId) ) ) .get(); if (!membership) { return new Response("Not authorized", { status: 403 }); } if (invoice.status !== "draft") { return new Response("Can only import time into draft invoices", { status: 400 }); } const entries = await db .select({ entry: timeEntries, tag: tags, }) .from(timeEntries) .leftJoin(timeEntryTags, eq(timeEntries.id, timeEntryTags.timeEntryId)) .leftJoin(tags, eq(timeEntryTags.tagId, tags.id)) .where( and( eq(timeEntries.organizationId, invoice.organizationId), eq(timeEntries.clientId, invoice.clientId), isNull(timeEntries.invoiceId), isNotNull(timeEntries.endTime), gte(timeEntries.startTime, startDate), lte(timeEntries.startTime, endDate) ) ) .orderBy(desc(timeEntries.startTime)); const processedEntries = new Map< string, { entry: typeof timeEntries.$inferSelect; rates: number[]; tagNames: string[]; } >(); for (const { entry, tag } of entries) { if (!processedEntries.has(entry.id)) { processedEntries.set(entry.id, { entry, rates: [], tagNames: [], }); } const current = processedEntries.get(entry.id)!; if (tag) { if (tag.rate && tag.rate > 0) { current.rates.push(tag.rate); } current.tagNames.push(tag.name); } } const newItems: { id: string; invoiceId: string; description: string; quantity: number; unitPrice: number; amount: number; }[] = []; const entryIdsToUpdate: string[] = []; if (groupByDay) { // Group by YYYY-MM-DD const days = new Map< string, { date: string; totalDuration: number; // milliseconds totalAmount: number; // cents entries: string[]; // ids } >(); for (const { entry, rates } of processedEntries.values()) { if (!entry.endTime) continue; const dateKey = entry.startTime.toISOString().split("T")[0]; const duration = entry.endTime.getTime() - entry.startTime.getTime(); const hours = duration / (1000 * 60 * 60); // Determine rate: max of tags, or 0 const rate = rates.length > 0 ? Math.max(...rates) : 0; const amount = Math.round(hours * rate); if (!days.has(dateKey)) { days.set(dateKey, { date: dateKey, totalDuration: 0, totalAmount: 0, entries: [], }); } const day = days.get(dateKey)!; day.totalDuration += duration; day.totalAmount += amount; day.entries.push(entry.id); entryIdsToUpdate.push(entry.id); } for (const day of days.values()) { const hours = day.totalDuration / (1000 * 60 * 60); // Avoid division by zero const unitPrice = hours > 0 ? Math.round(day.totalAmount / hours) : 0; newItems.push({ id: nanoid(), invoiceId: invoice.id, description: `Time entries for ${day.date} (${day.entries.length} entries)`, quantity: parseFloat(hours.toFixed(2)), unitPrice, amount: day.totalAmount, }); } } else { // Individual items for (const { entry, rates, tagNames } of processedEntries.values()) { if (!entry.endTime) continue; const duration = entry.endTime.getTime() - entry.startTime.getTime(); const hours = duration / (1000 * 60 * 60); // Determine rate: max of tags, or 0 const rate = rates.length > 0 ? Math.max(...rates) : 0; const amount = Math.round(hours * rate); let description = entry.description || "Time Entry"; const dateStr = entry.startTime.toLocaleDateString(); description = `[${dateStr}] ${description}`; if (tagNames.length > 0) { description += ` (${tagNames.join(", ")})`; } newItems.push({ id: nanoid(), invoiceId: invoice.id, description, quantity: parseFloat(hours.toFixed(2)), unitPrice: rate, amount, }); entryIdsToUpdate.push(entry.id); } } if (newItems.length === 0) { return redirect(`/dashboard/invoices/${id}?error=no-entries`); } // Transaction-like operations try { await db.insert(invoiceItems).values(newItems); if (entryIdsToUpdate.length > 0) { await db .update(timeEntries) .set({ invoiceId: invoice.id }) .where(inArray(timeEntries.id, entryIdsToUpdate)); } const allItems = await db .select() .from(invoiceItems) .where(eq(invoiceItems.invoiceId, invoice.id)); const subtotal = allItems.reduce((sum, item) => sum + item.amount, 0); let discountAmount = 0; if (invoice.discountType === "percentage") { discountAmount = Math.round(subtotal * ((invoice.discountValue || 0) / 100)); } else { discountAmount = Math.round((invoice.discountValue || 0) * 100); if (invoice.discountValue && invoice.discountValue > 0) { discountAmount = Math.round((invoice.discountValue || 0) * 100); } } const taxableAmount = Math.max(0, subtotal - discountAmount); const taxAmount = Math.round(taxableAmount * ((invoice.taxRate || 0) / 100)); const total = subtotal - discountAmount + taxAmount; await db .update(invoices) .set({ subtotal, discountAmount, taxAmount, total, }) .where(eq(invoices.id, invoice.id)); return redirect(`/dashboard/invoices/${id}?success=imported`); } catch (error) { console.error("Error importing time entries:", error); return new Response("Failed to import time entries", { status: 500 }); } };