Files
clienthub/src/lib/admin-queries.ts
T

238 lines
6.6 KiB
TypeScript

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<ClientWithPayments[]> {
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<string>`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<Phase & { tasks: Array<Task & { deliverables: Deliverable[] }> }>;
payments: Payment[];
documents: Document[];
notes: Note[];
comments: Comment[];
quoteItems: QuoteItemWithLabel[];
activeServices: ServiceCatalog[];
};
export async function getClientFullDetail(id: string): Promise<ClientFullDetail | null> {
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<string>`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<ServiceCatalog[]> {
return db
.select()
.from(service_catalog)
.orderBy(asc(service_catalog.name));
}