import { db } from "@/db"; import { clients, projects, payments, phases, tasks, deliverables, comments, documents, notes, time_entries, quote_items, service_catalog, services, settings, offer_micros, offer_macros, project_offers, offer_phases, offer_phase_services, quotes, leads, tags, clientTranscripts, } from "@/db/schema"; import { eq, inArray, asc, desc, isNull, sql, and } from "drizzle-orm"; import { getPool } from "@/lib/taxonomy"; import { LEAD_STAGES } from "@/lib/lead-validators"; import type { Client, Project, Phase, Task, Deliverable, Payment, Document, Note, Comment, ServiceCatalog, Service, OfferMicro, OfferMacro, ProjectOffer, OfferPhase, OfferPhaseService, Quote, Lead, } from "@/db/schema"; // ── ClientWithPayments — used by /admin clients list ───────────────────────── export type ClientWithPayments = { id: string; name: string; brand_name: string; token: string; slug: string | null; 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; projectBrands: string[]; ltv: string; }; 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); // Get all projects for these clients (payments/time_entries now scoped to project) const clientProjects = await db .select({ id: projects.id, client_id: projects.client_id, name: projects.name, accepted_total: projects.accepted_total, archived: projects.archived, }) .from(projects) .where(inArray(projects.client_id, clientIds)) .orderBy(asc(projects.created_at)); const projectIds = clientProjects.map((p) => p.id); const projectToClient = new Map(clientProjects.map((p) => [p.id, p.client_id])); if (projectIds.length === 0) { return visible.map((c) => ({ id: c.id, name: c.name, brand_name: c.brand_name, token: c.token, slug: c.slug ?? null, accepted_total: c.accepted_total ?? "0", archived: c.archived ?? false, created_at: c.created_at, payments: [], activeTimerEntryId: null, activeTimerStartedAt: null, totalTrackedSeconds: 0, projectBrands: [], ltv: "0.00", })); } const [allPayments, activeEntries, totals] = await Promise.all([ db.select().from(payments).where(inArray(payments.project_id, projectIds)), db .select({ id: time_entries.id, project_id: time_entries.project_id, started_at: time_entries.started_at, }) .from(time_entries) .where(isNull(time_entries.ended_at)), db .select({ project_id: time_entries.project_id, total: sql`coalesce(sum(${time_entries.duration_seconds}), 0)`, }) .from(time_entries) .where(inArray(time_entries.project_id, projectIds)) .groupBy(time_entries.project_id), ]); // Aggregate project-scoped data back to client level const clientPaymentsMap = new Map(); for (const payment of allPayments) { const clientId = projectToClient.get(payment.project_id); if (!clientId) continue; if (!clientPaymentsMap.has(clientId)) clientPaymentsMap.set(clientId, []); clientPaymentsMap.get(clientId)!.push(payment); } const clientTimerMap = new Map(); for (const entry of activeEntries) { const clientId = projectToClient.get(entry.project_id); if (!clientId || clientTimerMap.has(clientId)) continue; clientTimerMap.set(clientId, { id: entry.id, started_at: entry.started_at }); } const clientTotalsMap = new Map(); for (const row of totals) { const clientId = projectToClient.get(row.project_id); if (!clientId) continue; clientTotalsMap.set(clientId, (clientTotalsMap.get(clientId) ?? 0) + parseInt(row.total)); } return visible.map((c) => { const active = clientTimerMap.get(c.id); const clientPayments = clientPaymentsMap.get(c.id) ?? []; const clientProjectsList = clientProjects.filter((p) => p.client_id === c.id); const projectBrands = clientProjectsList .filter((p) => !p.archived) .map((p) => p.name); const ltv = clientProjectsList .reduce((sum, p) => sum + parseFloat(p.accepted_total ?? "0"), 0) .toFixed(2); return { id: c.id, name: c.name, brand_name: c.brand_name, token: c.token, slug: c.slug ?? null, accepted_total: c.accepted_total ?? "0", archived: c.archived ?? false, created_at: c.created_at, payments: clientPayments.map((p) => ({ id: p.id, label: p.label, status: p.status, amount: String(p.amount), })), activeTimerEntryId: active?.id ?? null, activeTimerStartedAt: active?.started_at ?? null, totalTrackedSeconds: clientTotalsMap.get(c.id) ?? 0, projectBrands, ltv, }; }); } export async function getClientById(id: string) { const rows = await db.select().from(clients).where(eq(clients.id, id)).limit(1); return rows[0] ?? null; } // ── ProjectOfferWithMicro — used by OffersTab ──────────────────────────────── export type ProjectOfferWithMicro = { id: string; project_id: string; micro_id: string; micro_internal_name: string; micro_public_name: string; micro_duration_months: number; tier_letter: string | null; macro_internal_name: string; macro_offer_type: string; start_date: Date; accepted_total: string | null; created_at: Date; }; // ── 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: Service[]; }; 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]; // Get all projects for this client (data is now project-scoped) const projectRows = await db .select({ id: projects.id }) .from(projects) .where(eq(projects.client_id, id)); const projectIds = projectRows.map((p) => p.id); const activeServiceRows = await db .select() .from(services) .where(eq(services.active, true)) .orderBy(asc(services.name)); if (projectIds.length === 0) { return { client, phases: [], payments: [], documents: [], notes: [], comments: [], quoteItems: [], activeServices: activeServiceRows, }; } const phasesRows = await db .select() .from(phases) .where(inArray(phases.project_id, projectIds)) .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(inArray(payments.project_id, projectIds)); const documentsRows = await db .select() .from(documents) .where(inArray(documents.project_id, projectIds)) .orderBy(asc(documents.created_at)); const notesRows = await db .select() .from(notes) .where(inArray(notes.project_id, projectIds)) .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(inArray(quote_items.project_id, projectIds)) .orderBy(asc(quote_items.id)); return { client, phases: phasesWithTasks, payments: paymentsRows, documents: documentsRows, notes: notesRows, comments: commentsRows, quoteItems: quoteItemRows, activeServices: activeServiceRows, }; } // ── ServiceWithTags — services + multi-select options (Phase 11 database-view) ─ // "tag" and "pacchetto" are multi-select pools stored in the polymorphic `tags` // table, distinguished by entity_type: "services" (tag) | "services.pacchetto". // "category" and "fase" are single-select values stored directly on the row. const TAG_ENTITY = "services"; const PACCHETTO_ENTITY = "services.pacchetto"; export type ServiceWithTags = Service & { tags: string[]; pacchetto: string[] }; export async function getAllServices(): Promise { const rows = await db .select({ id: services.id, name: services.name, description: services.description, unit_price: services.unit_price, category: services.category, fase: services.fase, active: services.active, migrated_from: services.migrated_from, migrated_id: services.migrated_id, created_at: services.created_at, tag_name: tags.name, tag_type: tags.entity_type, }) .from(services) .leftJoin( tags, and( eq(tags.entity_id, services.id), inArray(tags.entity_type, [TAG_ENTITY, PACCHETTO_ENTITY]) ) ) .orderBy(asc(services.name), asc(tags.name)); const serviceMap = new Map(); for (const row of rows) { const { tag_name, tag_type, ...serviceFields } = row; if (!serviceMap.has(row.id)) { serviceMap.set(row.id, { ...serviceFields, tags: [], pacchetto: [] }); } if (tag_name) { const target = serviceMap.get(row.id)!; if (tag_type === PACCHETTO_ENTITY) target.pacchetto.push(tag_name); else target.tags.push(tag_name); } } return Array.from(serviceMap.values()); } // ── Shared option pools for the catalog select fields (Notion-style dropdowns) ─ // Each pool is the set of distinct values currently in use, sorted alphabetically. export type CatalogFieldOptions = { tag: string[]; pacchetto: string[]; categoria: string[]; fase: string[]; }; export async function getCatalogFieldOptions(): Promise { // fase / Offerta (tag) / Pacchetto read from the centralized, persistent // taxonomy pools (src/lib/taxonomy.ts). `categoria` (services.category) is // hidden in the catalog UI and stays derived from in-use values. const [tag, pacchetto, fase, catRows] = await Promise.all([ getPool("service_offerta"), getPool("service_pacchetto"), getPool("service_fase"), db.selectDistinct({ value: services.category }).from(services), ]); const categoria = Array.from( new Set(catRows.map((r) => r.value).filter((v): v is string => !!v && v.trim().length > 0)) ).sort((a, b) => a.localeCompare(b, "it")); return { tag, pacchetto, categoria, fase }; } // ── ProjectWithPayments — used by /admin/projects list ─────────────────────── export type ProjectWithPayments = { id: string; name: string; client: { id: string; name: string; slug: string | null }; 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 getAllProjectsWithPayments( includeArchived = false ): Promise { const allProjects = await db .select({ id: projects.id, name: projects.name, client_id: projects.client_id, accepted_total: projects.accepted_total, archived: projects.archived, created_at: projects.created_at, }) .from(projects) .orderBy(projects.created_at); const visible = includeArchived ? allProjects : allProjects.filter((p) => !p.archived); if (visible.length === 0) return []; const projectIds = visible.map((p) => p.id); const clientIds = [...new Set(visible.map((p) => p.client_id))]; const [allPayments, activeEntries, totals, parentClients] = await Promise.all([ db .select() .from(payments) .where(inArray(payments.project_id, projectIds)), db .select({ id: time_entries.id, project_id: time_entries.project_id, started_at: time_entries.started_at, }) .from(time_entries) .where(isNull(time_entries.ended_at)), db .select({ project_id: time_entries.project_id, total: sql`coalesce(sum(${time_entries.duration_seconds}), 0)`, }) .from(time_entries) .where(inArray(time_entries.project_id, projectIds)) .groupBy(time_entries.project_id), db .select({ id: clients.id, name: clients.name, slug: clients.slug }) .from(clients) .where(inArray(clients.id, clientIds)), ]); return visible.map((project) => { const projectPayments = allPayments.filter((p) => p.project_id === project.id); const activeEntry = activeEntries.find((e) => e.project_id === project.id); const totalRow = totals.find((t) => t.project_id === project.id); const parentClient = parentClients.find((c) => c.id === project.client_id); return { id: project.id, name: project.name, client: parentClient ?? { id: project.client_id, name: "—", slug: null }, accepted_total: project.accepted_total ?? "0", archived: project.archived, created_at: project.created_at, payments: projectPayments.map((p) => ({ id: p.id, label: p.label, status: p.status, amount: String(p.amount), })), activeTimerEntryId: activeEntry?.id ?? null, activeTimerStartedAt: activeEntry?.started_at ?? null, totalTrackedSeconds: totalRow ? parseInt(totalRow.total) : 0, }; }); } // ── ProjectFullDetail — used by /admin/projects/[id] workspace ─────────────── export type ProjectFullDetail = { project: Project & { client: { id: string; name: string; brand_name: string; slug: string | null } }; phases: Array }>; payments: Payment[]; documents: Document[]; notes: Note[]; comments: Comment[]; quoteItems: QuoteItemWithLabel[]; activeServices: Service[]; activeTimerEntryId: string | null; activeTimerStartedAt: Date | null; totalTrackedSeconds: number; projectOffers: ProjectOfferWithMicro[]; /** Sum of accepted_total across all active project offers — used as default for payment plan */ offersAcceptedTotal: number; availableMicros: Array<{ id: string; internal_name: string; public_name: string; duration_months: number; tier_letter: string | null; public_price: string | null; macro_id: string; macro_internal_name: string; macro_category: string | null; macro_offer_type: string; }>; transcripts: Array<{ id: string; title: string | null; call_date: string; content: string; created_at: Date; }>; }; export async function getProjectFullDetail(id: string): Promise { const projectRows = await db .select() .from(projects) .where(eq(projects.id, id)) .limit(1); if (projectRows.length === 0) return null; const project = projectRows[0]; const clientRows = await db .select({ id: clients.id, name: clients.name, brand_name: clients.brand_name, slug: clients.slug }) .from(clients) .where(eq(clients.id, project.client_id)) .limit(1); const client = clientRows[0] ?? { id: project.client_id, name: "—", brand_name: "—", slug: null }; const phasesRows = await db .select() .from(phases) .where(eq(phases.project_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, documentsRows, notesRows, quoteItemRows, activeServiceRows, activeEntryRows, totalRes, projectOffersRows, availableMicrosRows, transcriptsRows] = await Promise.all([ db.select().from(payments).where(eq(payments.project_id, id)), db.select().from(documents).where(eq(documents.project_id, id)).orderBy(asc(documents.created_at)), db.select().from(notes).where(eq(notes.project_id, id)).orderBy(asc(notes.created_at)), 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.project_id, id)) .orderBy(asc(quote_items.id)), db.select().from(services).where(eq(services.active, true)).orderBy(asc(services.name)), db .select({ id: time_entries.id, started_at: time_entries.started_at }) .from(time_entries) .where(and(eq(time_entries.project_id, id), isNull(time_entries.ended_at))) .limit(1), db .select({ total: sql`coalesce(sum(${time_entries.duration_seconds}), 0)` }) .from(time_entries) .where(eq(time_entries.project_id, id)), // Query A: project offers for this project joined with micro + macro info db .select({ id: project_offers.id, project_id: project_offers.project_id, micro_id: project_offers.micro_id, micro_internal_name: offer_micros.internal_name, micro_public_name: offer_micros.public_name, micro_duration_months: offer_micros.duration_months, tier_letter: offer_micros.tier_letter, macro_internal_name: offer_macros.internal_name, macro_offer_type: offer_macros.offer_type, start_date: project_offers.start_date, accepted_total: project_offers.accepted_total, created_at: project_offers.created_at, }) .from(project_offers) .innerJoin(offer_micros, eq(project_offers.micro_id, offer_micros.id)) .innerJoin(offer_macros, eq(offer_micros.macro_id, offer_macros.id)) .where(eq(project_offers.project_id, id)) .orderBy(asc(project_offers.created_at)), // Query B: all tiers of non-archived offers (for the assignment dropdown). // ordered oldest-first per (macro, tier) so client-side dedup keeps the // oldest until the DB cleanup removes genuine duplicates. db .select({ id: offer_micros.id, internal_name: offer_micros.internal_name, public_name: offer_micros.public_name, duration_months: offer_micros.duration_months, tier_letter: offer_micros.tier_letter, public_price: offer_micros.public_price, macro_id: offer_micros.macro_id, macro_internal_name: offer_macros.internal_name, macro_category: offer_macros.category, macro_offer_type: offer_macros.offer_type, }) .from(offer_micros) .innerJoin(offer_macros, eq(offer_micros.macro_id, offer_macros.id)) .where(eq(offer_macros.is_archived, false)) .orderBy( asc(offer_macros.internal_name), asc(offer_micros.tier_letter), asc(offer_micros.id) ), // Query C: transcripts linked to the project's client db .select({ id: clientTranscripts.id, title: clientTranscripts.title, call_date: clientTranscripts.call_date, content: clientTranscripts.content, created_at: clientTranscripts.created_at, }) .from(clientTranscripts) .where(eq(clientTranscripts.client_id, project.client_id)) .orderBy(desc(clientTranscripts.call_date)), ]); 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), })), })); // Defensive dedup: until the DB cleanup removes genuine duplicate tiers, // keep one micro per (macro_id, tier_letter) for the assignment dropdown. const seenTier = new Set(); const dedupedMicros = availableMicrosRows.filter((m) => { const key = `${m.macro_id}::${m.tier_letter ?? `_null_${m.id}`}`; if (seenTier.has(key)) return false; seenTier.add(key); return true; }); const offersAcceptedTotal = (projectOffersRows as ProjectOfferWithMicro[]).reduce( (sum, o) => sum + (o.accepted_total ? parseFloat(String(o.accepted_total)) : 0), 0 ); return { project: { ...project, client } as ProjectFullDetail["project"], phases: phasesWithTasks, payments: paymentsRows, documents: documentsRows, notes: notesRows, comments: commentsRows, quoteItems: quoteItemRows as QuoteItemWithLabel[], activeServices: activeServiceRows, activeTimerEntryId: activeEntryRows[0]?.id ?? null, activeTimerStartedAt: activeEntryRows[0]?.started_at ?? null, totalTrackedSeconds: totalRes[0] ? parseInt(totalRes[0].total) : 0, projectOffers: projectOffersRows as ProjectOfferWithMicro[], offersAcceptedTotal, availableMicros: dedupedMicros, transcripts: transcriptsRows, }; } // ── Recent time entries — used by TimerTab for the manual-entry recent list ─── export type RecentTimeEntry = { id: string; started_at: Date; ended_at: Date | null; duration_seconds: number | null; }; export async function getRecentTimeEntries( projectId: string, limit = 5 ): Promise { // sql`ended_at IS NOT NULL` used to filter closed entries // (Drizzle's isNotNull is available in v0.28+ but we use sql for safety) return db .select({ id: time_entries.id, started_at: time_entries.started_at, ended_at: time_entries.ended_at, duration_seconds: time_entries.duration_seconds, }) .from(time_entries) .where(and(eq(time_entries.project_id, projectId), sql`${time_entries.ended_at} IS NOT NULL`)) .orderBy(desc(time_entries.started_at)) .limit(limit); } // ── ClientWithProjects — used by client detail showing project cards ────────── export type ClientWithProjects = Client & { projects: Array<{ id: string; name: string; accepted_total: string; archived: boolean; created_at: Date; }>; }; export async function getClientWithProjects(clientId: string): Promise { const clientRows = await db .select() .from(clients) .where(eq(clients.id, clientId)) .limit(1); if (clientRows.length === 0) return null; const client = clientRows[0]; const projectRows = await db .select() .from(projects) .where(eq(projects.client_id, clientId)) .orderBy(asc(projects.created_at)); return { ...client, projects: projectRows.map((p) => ({ id: p.id, name: p.name, accepted_total: p.accepted_total ?? "0", archived: p.archived, created_at: p.created_at, })), }; } // ── ClientActiveOfferSummary — used by /admin/clients/[id] offers section ───── export type ClientActiveOfferSummary = { offer_id: string; project_id: string; project_name: string; public_name: string; // offer_micros.public_name — NEVER internal_name accepted_total: string | null; }; export async function getClientActiveOffers(clientId: string): Promise { const projectRows = await db .select({ id: projects.id, name: projects.name }) .from(projects) .where(eq(projects.client_id, clientId)); if (projectRows.length === 0) return []; const projectIds = projectRows.map((p) => p.id); const projectNameMap = new Map(projectRows.map((p) => [p.id, p.name])); // Explicit column selection — public_name only, NEVER internal_name const rows = await db .select({ offer_id: project_offers.id, project_id: project_offers.project_id, public_name: offer_micros.public_name, accepted_total: project_offers.accepted_total, }) .from(project_offers) .innerJoin(offer_micros, eq(project_offers.micro_id, offer_micros.id)) .where(inArray(project_offers.project_id, projectIds)) .orderBy(asc(project_offers.created_at)); return rows.map((r) => ({ offer_id: r.offer_id, project_id: r.project_id, project_name: projectNameMap.get(r.project_id) ?? "—", public_name: r.public_name, accepted_total: r.accepted_total ? String(r.accepted_total) : null, })); } // ── Phase 8 Query Layer: Offer Phases & Quotes ────────────────────────────── /** * Get all phases for a given offer micro (ordered by sort_order) * Used by: Quote builder, admin phase editor */ export async function getOfferPhases(microId: string): Promise { return await db .select() .from(offer_phases) .where(eq(offer_phases.micro_id, microId)) .orderBy(asc(offer_phases.sort_order)); } /** * Get all unified services assigned to a phase * Used by: Quote builder, public quote page */ export async function getOfferPhaseServices(phaseId: string): Promise { const phaseServices = await db .select({ service_id: offer_phase_services.service_id, }) .from(offer_phase_services) .where(eq(offer_phase_services.phase_id, phaseId)); if (phaseServices.length === 0) return []; const serviceIds = phaseServices.map((ps) => ps.service_id); return await db .select() .from(services) .where(inArray(services.id, serviceIds)); } /** * Get full quote by ID with all relations (header + items) * Used by: Quote detail page */ export async function getQuoteById(quoteId: string): Promise { const quote = await db .select() .from(quotes) .where(eq(quotes.id, quoteId)); return quote.length > 0 ? quote[0] : null; } /** * Get quote by public token (for /quote/[token] routes) * Used by: Public quote page access validation */ export async function getQuoteByToken(token: string): Promise { const quote = await db .select() .from(quotes) .where(eq(quotes.token, token)); return quote.length > 0 ? quote[0] : null; } /** * Get all quotes for a client (ordered by created_at DESC) * Used by: /admin/clients/[id] detail page */ export async function getQuotesByClient(clientId: string): Promise { return await db .select() .from(quotes) .where(eq(quotes.client_id, clientId)) .orderBy(sql`${quotes.created_at} DESC`); } /** * Get all offer macros with their micros (ordered by sort_order) */ export async function getAllOfferMacrosWithMicros(): Promise< (OfferMacro & { micros: OfferMicro[] })[] > { const macros = await db .select() .from(offer_macros) .orderBy(asc(offer_macros.sort_order)); if (macros.length === 0) return []; const allMicros = await db.select().from(offer_micros); return macros.map((macro) => ({ ...macro, micros: allMicros .filter((micro) => micro.macro_id === macro.id) .sort((a, b) => a.sort_order - b.sort_order), })); } // ── LeadWithTags — leads + tags (Phase 14 database-view) ───────────────────── // Lead tags are stored in the polymorphic `tags` table, scoped by // entity_type="leads". `status` is a fixed enum (LEAD_STAGES), not a // dynamic pool — exposed via getLeadFieldOptions for the OptionSelect UI. const LEADS_TAG_ENTITY = "leads"; export type LeadWithTags = Lead & { tags: string[] }; export async function getLeadsWithTags(): Promise { const rows = await db .select({ id: leads.id, name: leads.name, email: leads.email, phone: leads.phone, company: leads.company, status: leads.status, last_contact_date: leads.last_contact_date, next_action: leads.next_action, next_action_date: leads.next_action_date, notes: leads.notes, archived: leads.archived, created_at: leads.created_at, updated_at: leads.updated_at, tag_name: tags.name, }) .from(leads) .leftJoin( tags, and( eq(tags.entity_id, leads.id), eq(tags.entity_type, LEADS_TAG_ENTITY) ) ) .where(eq(leads.archived, false)) // converted leads are archived → hidden from pipeline .orderBy(desc(leads.updated_at), asc(tags.name)); const leadMap = new Map(); for (const row of rows) { const { tag_name, ...leadFields } = row; if (!leadMap.has(row.id)) leadMap.set(row.id, { ...leadFields, tags: [] }); if (tag_name) leadMap.get(row.id)!.tags.push(tag_name); } return Array.from(leadMap.values()); } // Single lead by id WITH tags — does NOT filter archived (the detail page must // still show a converted/archived lead). export async function getLeadByIdWithTags(leadId: string): Promise { const rows = await db .select({ id: leads.id, name: leads.name, email: leads.email, phone: leads.phone, company: leads.company, status: leads.status, last_contact_date: leads.last_contact_date, next_action: leads.next_action, next_action_date: leads.next_action_date, notes: leads.notes, archived: leads.archived, created_at: leads.created_at, updated_at: leads.updated_at, tag_name: tags.name, }) .from(leads) .leftJoin( tags, and(eq(tags.entity_id, leads.id), eq(tags.entity_type, LEADS_TAG_ENTITY)) ) .where(eq(leads.id, leadId)); if (rows.length === 0) return null; let result: LeadWithTags | null = null; for (const row of rows) { const { tag_name, ...leadFields } = row; if (!result) result = { ...leadFields, tags: [] }; if (tag_name) result.tags.push(tag_name); } return result; } // If this lead was converted, returns the client id of the project created from // it (projects.created_from_lead_id), else null. export async function getClientIdFromLead(leadId: string): Promise { const [row] = await db .select({ client_id: projects.client_id }) .from(projects) .where(eq(projects.created_from_lead_id, leadId)) .limit(1); return row?.client_id ?? null; } export type LeadFieldOptions = { status: string[]; tags: string[] }; export async function getLeadFieldOptions(): Promise { const tagRows = await db .selectDistinct({ name: tags.name }) .from(tags) .where(eq(tags.entity_type, LEADS_TAG_ENTITY)); return { status: [...LEAD_STAGES], tags: tagRows .map((r) => r.name) .sort((a, b) => a.localeCompare(b, "it")), }; }