import { eq, inArray, asc, desc, sql } from "drizzle-orm"; import { db } from "@/db"; import { clients, projects, phases, tasks, deliverables, payments, documents, notes, comments, project_offers, offer_micros, offer_micro_services, offer_services, offer_macros, offer_tier_services, services, clientTranscripts } from "@/db/schema"; /** * ClientView: Legacy shape used by ClientDashboard component. * Kept for backward compatibility — new code uses ProjectView. * Deliberately excludes: quote_items, service_catalog, service prices, payment amounts. */ export interface ClientView { client: { id: string; name: string; brand_name: string; brief: string; accepted_total: string; }; phases: Array<{ id: string; title: string; status: "upcoming" | "active" | "done"; sort_order: number; tasks: Array<{ id: string; title: string; description: string | null; status: "todo" | "in_progress" | "done"; sort_order: number; deliverables: Array<{ id: string; title: string; url: string | null; status: "pending" | "submitted" | "approved"; approved_at: string | null; }>; }>; progress_pct: number; }>; payments: Array<{ id: string; label: string; status: "da_saldare" | "inviata" | "saldato"; }>; documents: Array<{ id: string; label: string; url: string; }>; notes: Array<{ id: string; body: string; created_at: string; }>; global_progress_pct: number; activeOffers?: Array<{ id: string; public_name: string; offer_name: string; // offer_macros.public_name — macro-level name shown to client offer_type: string; // una_tantum | retainer cumulative_price: string; accepted_total: string | null; services: Array<{ name: string; description: string | null }>; }>; transcripts: Array<{ id: string; title: string | null; call_date: string; content: string; created_at: string; }>; } export interface ProjectView { project: { id: string; name: string; client_id: string; accepted_total: string; }; phases: Array<{ id: string; title: string; status: string; sort_order: number; tasks: Array<{ id: string; title: string; description: string | null; status: string; sort_order: number; deliverables: Array<{ id: string; title: string; url: string | null; status: string; approved_at: Date | null; task_id: string; }>; }>; progress_pct: number; }>; payments: Array<{ id: string; label: string; // amount intentionally excluded — client API never exposes payment amounts (CLAUDE.md + DASH-07) status: string; }>; documents: Array<{ id: string; label: string; url: string; created_at: Date; }>; notes: Array<{ id: string; body: string; created_at: Date; }>; comments: Array<{ id: string; entity_type: string; entity_id: string; author: string; body: string; created_at: Date; }>; global_progress_pct: number; activeOffers?: Array<{ id: string; public_name: string; // offer_micros.public_name — NEVER internal_name offer_name: string; // offer_macros.public_name — macro-level name shown to client offer_type: string; // una_tantum | retainer cumulative_price: string; // sum of assigned services.unit_price (new) or offer_services.price (legacy) accepted_total: string | null; services: Array<{ name: string; description: string | null }>; // included services list }>; transcripts: Array<{ id: string; title: string | null; call_date: string; content: string; created_at: Date; }>; } export interface ClientProjectSummary { client: { id: string; name: string; brand_name: string; token: string; slug: string | null; }; projects: Array<{ id: string; name: string; archived: boolean; }>; } /** * Resolves a token-or-slug to a client and returns the client's active projects. * Lookup order: slug first, then token — mirrors middleware order (D-06). */ export async function getClientWithProjectsByToken( tokenOrSlug: string ): Promise { try { // Try slug first let clientRows = await db .select({ id: clients.id, name: clients.name, brand_name: clients.brand_name, token: clients.token, slug: clients.slug, }) .from(clients) .where(eq(clients.slug, tokenOrSlug)) .limit(1); // Fall back to token if (clientRows.length === 0) { clientRows = await db .select({ id: clients.id, name: clients.name, brand_name: clients.brand_name, token: clients.token, slug: clients.slug, }) .from(clients) .where(eq(clients.token, tokenOrSlug)) .limit(1); } if (clientRows.length === 0) return null; const client = clientRows[0]; const projectRows = await db .select({ id: projects.id, name: projects.name, archived: projects.archived }) .from(projects) .where(eq(projects.client_id, client.id)) .orderBy(asc(projects.created_at)); const activeProjects = projectRows.filter((p) => !p.archived); return { client, projects: activeProjects }; } catch (err) { console.error("[client-view] getClientWithProjectsByToken error:", err); return null; } } /** * Returns full project data for the client dashboard. * CRITICAL: Does NOT include quote_items — client API never exposes them (CLAUDE.md constraint). * payments include status only, NOT amount or unit_price (DASH-07). */ export async function getProjectView(projectId: string): Promise { const projectRows = await db .select({ id: projects.id, name: projects.name, client_id: projects.client_id, accepted_total: projects.accepted_total, }) .from(projects) .where(eq(projects.id, projectId)) .limit(1); if (projectRows.length === 0) return null; const project = projectRows[0]; const phasesRows = await db .select() .from(phases) .where(eq(phases.project_id, projectId)) .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); // approved_at included — immutable audit trail (CLAUDE.md constraint LOCKED) const deliverablesRows = taskIds.length === 0 ? [] : await db .select({ id: deliverables.id, title: deliverables.title, url: deliverables.url, status: deliverables.status, approved_at: deliverables.approved_at, task_id: deliverables.task_id, }) .from(deliverables) .where(inArray(deliverables.task_id, taskIds)); // Payments — status only, NO amount (DASH-07 / CLAUDE.md) const paymentsRows = await db .select({ id: payments.id, label: payments.label, status: payments.status, // amount intentionally excluded — client API never exposes payment amounts }) .from(payments) .where(eq(payments.project_id, projectId)); const documentsRows = await db .select({ id: documents.id, label: documents.label, url: documents.url, created_at: documents.created_at, }) .from(documents) .where(eq(documents.project_id, projectId)) .orderBy(asc(documents.created_at)); const notesRows = await db .select({ id: notes.id, body: notes.body, created_at: notes.created_at }) .from(notes) .where(eq(notes.project_id, projectId)) .orderBy(asc(notes.created_at)); // Fetch active offers for this project (client-safe fields only — never internal_name) const projectOfferRows = await db .select({ id: project_offers.id, public_name: offer_micros.public_name, // EXPLICITLY public_name, never internal_name offer_name: offer_macros.public_name, // macro-level public name — NEVER internal_name offer_type: offer_macros.offer_type, // una_tantum | retainer accepted_total: project_offers.accepted_total, micro_id: project_offers.micro_id, }) .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, projectId)); // Cumulative price + service list per micro. // Primary: offer_tier_services → services (Phase 12 catalog). Fallback: legacy offer_micro_services → offer_services. const microIds = projectOfferRows.map((o) => o.micro_id); // New-style: services from offer_tier_services joined to unified services catalog const tierServiceRows = microIds.length === 0 ? [] : await db .select({ tier_id: offer_tier_services.tier_id, name: services.name, description: services.description, unit_price: services.unit_price, }) .from(offer_tier_services) .innerJoin(services, eq(offer_tier_services.service_id, services.id)) .where(inArray(offer_tier_services.tier_id, microIds)); // Group by tier_id for quick lookup const tierServicesMap = new Map>(); for (const row of tierServiceRows) { const existing = tierServicesMap.get(row.tier_id) ?? []; existing.push({ name: row.name, description: row.description, unit_price: row.unit_price }); tierServicesMap.set(row.tier_id, existing); } // Determine which micros have NO entries in the new table → need legacy fallback const microsNeedingLegacy = microIds.filter((id) => !tierServicesMap.has(id)); // Legacy fallback: offer_micro_services → offer_services (for old offers with no tier_services rows) const legacyPriceRows = microsNeedingLegacy.length === 0 ? [] : await db .select({ micro_id: offer_micro_services.micro_id, name: offer_services.name, price: offer_services.price, }) .from(offer_micro_services) .innerJoin(offer_services, eq(offer_micro_services.service_id, offer_services.id)) .where(inArray(offer_micro_services.micro_id, microsNeedingLegacy)); const legacyServicesMap = new Map>(); for (const row of legacyPriceRows) { const existing = legacyServicesMap.get(row.micro_id) ?? []; existing.push({ name: row.name, description: null, price: row.price }); legacyServicesMap.set(row.micro_id, existing); } const activeOffers = projectOfferRows.map((o) => { const newStyleServices = tierServicesMap.get(o.micro_id); const legacyServices = legacyServicesMap.get(o.micro_id); let cumulative_price: string; let servicesList: Array<{ name: string; description: string | null }>; if (newStyleServices && newStyleServices.length > 0) { // Use new catalog prices const total = newStyleServices.reduce((sum, s) => sum + parseFloat(s.unit_price ?? "0"), 0); cumulative_price = total.toFixed(2); servicesList = newStyleServices.map((s) => ({ name: s.name, description: s.description })); } else if (legacyServices && legacyServices.length > 0) { // Legacy fallback const total = legacyServices.reduce((sum, s) => sum + parseFloat(s.price ?? "0"), 0); cumulative_price = total.toFixed(2); servicesList = legacyServices.map((s) => ({ name: s.name, description: null })); } else { cumulative_price = "0"; servicesList = []; } return { id: o.id, public_name: o.public_name, offer_name: o.offer_name, offer_type: o.offer_type, cumulative_price, accepted_total: o.accepted_total ? String(o.accepted_total) : null, services: servicesList, }; }); // Transcripts linked to the project's client (post lead→client conversion) const transcriptsRows = await 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)); // Comments scoped to: general (client_id), phases, tasks, and deliverables of this project const allEntityIds = [ project.client_id, // general messages entity_id = client_id ...phaseIds, // phase-tagged messages ...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) => { const phaseTasks = tasksRows .filter((t) => t.phase_id === phase.id) .map((task) => ({ ...task, deliverables: deliverablesRows.filter((d) => d.task_id === task.id), })); const doneCount = phaseTasks.filter((t) => t.status === "done").length; const progress_pct = phaseTasks.length > 0 ? Math.round((doneCount / phaseTasks.length) * 100) : 0; return { ...phase, tasks: phaseTasks, progress_pct }; }); const doneTasks = tasksRows.filter((t) => t.status === "done").length; const global_progress_pct = tasksRows.length > 0 ? Math.round((doneTasks / tasksRows.length) * 100) : 0; return { project: { id: project.id, name: project.name, client_id: project.client_id, accepted_total: project.accepted_total ?? "0", }, phases: phasesWithTasks, payments: paymentsRows, documents: documentsRows, notes: notesRows, comments: commentsRows, global_progress_pct, activeOffers: activeOffers.length > 0 ? activeOffers : undefined, transcripts: transcriptsRows, }; }