import { db } from "@/db"; import { clients, payments, phases, tasks, deliverables, comments, documents, notes, time_entries, quote_items, service_catalog, } from "@/db/schema"; import { eq, inArray, asc, isNull, sql } from "drizzle-orm"; import type { Client, Phase, Task, Deliverable, Payment, Document, Note, Comment, ServiceCatalog, } from "@/db/schema"; export type ClientWithPayments = { id: string; name: string; brand_name: string; token: string; accepted_total: string; archived: boolean; created_at: Date; payments: Array<{ id: string; label: string; status: string; amount: string }>; activeTimerEntryId: string | null; activeTimerStartedAt: Date | null; totalTrackedSeconds: number; }; export async function getAllClientsWithPayments( includeArchived = false ): Promise { const allClients = await db .select() .from(clients) .orderBy(clients.created_at); const visible = includeArchived ? allClients : allClients.filter((c) => !c.archived); if (visible.length === 0) return []; const clientIds = visible.map((c) => c.id); const [allPayments, activeEntries, totals] = await Promise.all([ db.select().from(payments), // Running timer sessions (ended_at IS NULL) db .select({ id: time_entries.id, client_id: time_entries.client_id, started_at: time_entries.started_at, }) .from(time_entries) .where(isNull(time_entries.ended_at)), // Total tracked seconds per client (completed sessions) db .select({ client_id: time_entries.client_id, total: sql`coalesce(sum(${time_entries.duration_seconds}), 0)`, }) .from(time_entries) .where(inArray(time_entries.client_id, clientIds)) .groupBy(time_entries.client_id), ]); const totalMap = new Map(totals.map((r) => [r.client_id, parseInt(r.total)])); const activeMap = new Map( activeEntries.map((r) => [r.client_id, { id: r.id, started_at: r.started_at }]) ); return visible.map((c) => { const active = activeMap.get(c.id); return { id: c.id, name: c.name, brand_name: c.brand_name, token: c.token, accepted_total: c.accepted_total ?? "0", archived: c.archived ?? false, created_at: c.created_at, payments: allPayments .filter((p) => p.client_id === c.id) .map((p) => ({ id: p.id, label: p.label, status: p.status, amount: p.amount })), activeTimerEntryId: active?.id ?? null, activeTimerStartedAt: active?.started_at ?? null, totalTrackedSeconds: totalMap.get(c.id) ?? 0, }; }); } export async function getClientById(id: string) { const rows = await db.select().from(clients).where(eq(clients.id, id)).limit(1); return rows[0] ?? null; } // ── ClientFullDetail — used by /admin/clients/[id] workspace ───────────────── // quote_items NEVER exposed via client API — admin workspace query only export type QuoteItemWithLabel = { id: string; label: string; // COALESCE(service_catalog.name, quote_items.custom_label) custom_label: string | null; service_id: string | null; quantity: string; unit_price: string; // snapshotted — never joined back to service_catalog.unit_price subtotal: string; }; export type ClientFullDetail = { client: Client; phases: Array }>; payments: Payment[]; documents: Document[]; notes: Note[]; comments: Comment[]; quoteItems: QuoteItemWithLabel[]; activeServices: ServiceCatalog[]; }; export async function getClientFullDetail(id: string): Promise { const clientRows = await db.select().from(clients).where(eq(clients.id, id)).limit(1); if (clientRows.length === 0) return null; const client = clientRows[0]; const phasesRows = await db .select() .from(phases) .where(eq(phases.client_id, id)) .orderBy(asc(phases.sort_order)); const phaseIds = phasesRows.map((p) => p.id); const tasksRows = phaseIds.length === 0 ? [] : await db .select() .from(tasks) .where(inArray(tasks.phase_id, phaseIds)) .orderBy(asc(tasks.sort_order)); const taskIds = tasksRows.map((t) => t.id); const deliverablesRows = taskIds.length === 0 ? [] : await db.select().from(deliverables).where(inArray(deliverables.task_id, taskIds)); const paymentsRows = await db.select().from(payments).where(eq(payments.client_id, id)); const documentsRows = await db .select() .from(documents) .where(eq(documents.client_id, id)) .orderBy(asc(documents.created_at)); const notesRows = await db .select() .from(notes) .where(eq(notes.client_id, id)) .orderBy(asc(notes.created_at)); const allEntityIds = [id, ...taskIds, ...deliverablesRows.map((d) => d.id)]; const commentsRows = allEntityIds.length === 0 ? [] : await db .select() .from(comments) .where(inArray(comments.entity_id, allEntityIds)) .orderBy(asc(comments.created_at)); const phasesWithTasks = phasesRows.map((phase) => ({ ...phase, tasks: tasksRows .filter((t) => t.phase_id === phase.id) .map((task) => ({ ...task, deliverables: deliverablesRows.filter((d) => d.task_id === task.id), })), })); // quote_items NEVER exposed via client API — admin workspace query only const quoteItemRows: QuoteItemWithLabel[] = await db .select({ id: quote_items.id, label: sql`COALESCE(${service_catalog.name}, ${quote_items.custom_label})`, custom_label: quote_items.custom_label, service_id: quote_items.service_id, quantity: quote_items.quantity, unit_price: quote_items.unit_price, subtotal: quote_items.subtotal, }) .from(quote_items) .leftJoin(service_catalog, eq(quote_items.service_id, service_catalog.id)) .where(eq(quote_items.client_id, id)) .orderBy(asc(quote_items.id)); const activeServiceRows = await db .select() .from(service_catalog) .where(eq(service_catalog.active, true)) .orderBy(asc(service_catalog.name)); return { client, phases: phasesWithTasks, payments: paymentsRows, documents: documentsRows, notes: notesRows, comments: commentsRows, quoteItems: quoteItemRows, activeServices: activeServiceRows, }; } export async function getAllServices(): Promise { return db .select() .from(service_catalog) .orderBy(asc(service_catalog.name)); }