feat(04-01): multi-project schema migration — projects, settings, FK pivot
- schema.ts: add projects table, settings kv table, slug on clients; migrate 6 FK from client_id to project_id (phases, payments, documents, notes, time_entries, quote_items); update all relations and TypeScript types - admin-queries.ts: fix getAllClientsWithPayments + getClientFullDetail to aggregate through projects; add getAllProjectsWithPayments, getProjectFullDetail, getClientWithProjects, ClientWithProjects type - settings.ts: new file — getSetting, updateSetting, getTargetHourlyRate, SETTINGS_KEYS - Fix all downstream files: actions.ts, quote-actions.ts, new/actions.ts, timer-actions.ts, approve/route.ts, comment/route.ts, TimerCell.tsx, analytics-queries.ts, client-view.ts, seed.ts - DB migration applied to Coolify Postgres (all test data cleared, schema rebuilt) Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
+363
-29
@@ -1,6 +1,7 @@
|
||||
import { db } from "@/db";
|
||||
import {
|
||||
clients,
|
||||
projects,
|
||||
payments,
|
||||
phases,
|
||||
tasks,
|
||||
@@ -11,10 +12,12 @@ import {
|
||||
time_entries,
|
||||
quote_items,
|
||||
service_catalog,
|
||||
settings,
|
||||
} from "@/db/schema";
|
||||
import { eq, inArray, asc, isNull, sql } from "drizzle-orm";
|
||||
import { eq, inArray, asc, isNull, sql, and } from "drizzle-orm";
|
||||
import type {
|
||||
Client,
|
||||
Project,
|
||||
Phase,
|
||||
Task,
|
||||
Deliverable,
|
||||
@@ -25,6 +28,8 @@ import type {
|
||||
ServiceCatalog,
|
||||
} from "@/db/schema";
|
||||
|
||||
// ── ClientWithPayments — used by /admin clients list ─────────────────────────
|
||||
|
||||
export type ClientWithPayments = {
|
||||
id: string;
|
||||
name: string;
|
||||
@@ -55,37 +60,79 @@ export async function getAllClientsWithPayments(
|
||||
|
||||
const clientIds = visible.map((c) => c.id);
|
||||
|
||||
const [allPayments, activeEntries, totals] = await Promise.all([
|
||||
db.select().from(payments),
|
||||
// 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 })
|
||||
.from(projects)
|
||||
.where(inArray(projects.client_id, clientIds));
|
||||
|
||||
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,
|
||||
accepted_total: c.accepted_total ?? "0",
|
||||
archived: c.archived ?? false,
|
||||
created_at: c.created_at,
|
||||
payments: [],
|
||||
activeTimerEntryId: null,
|
||||
activeTimerStartedAt: null,
|
||||
totalTrackedSeconds: 0,
|
||||
}));
|
||||
}
|
||||
|
||||
const [allPayments, activeEntries, totals] = await Promise.all([
|
||||
db.select().from(payments).where(inArray(payments.project_id, projectIds)),
|
||||
|
||||
// Running timer sessions (ended_at IS NULL)
|
||||
db
|
||||
.select({
|
||||
id: time_entries.id,
|
||||
client_id: time_entries.client_id,
|
||||
project_id: time_entries.project_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,
|
||||
project_id: time_entries.project_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),
|
||||
.where(inArray(time_entries.project_id, projectIds))
|
||||
.groupBy(time_entries.project_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 }])
|
||||
);
|
||||
// Aggregate project-scoped data back to client level
|
||||
const clientPaymentsMap = new Map<string, typeof allPayments>();
|
||||
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<string, { id: string; started_at: Date }>();
|
||||
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<string, number>();
|
||||
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 = activeMap.get(c.id);
|
||||
const active = clientTimerMap.get(c.id);
|
||||
const clientPayments = clientPaymentsMap.get(c.id) ?? [];
|
||||
return {
|
||||
id: c.id,
|
||||
name: c.name,
|
||||
@@ -94,12 +141,15 @@ export async function getAllClientsWithPayments(
|
||||
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 })),
|
||||
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: totalMap.get(c.id) ?? 0,
|
||||
totalTrackedSeconds: clientTotalsMap.get(c.id) ?? 0,
|
||||
};
|
||||
});
|
||||
}
|
||||
@@ -138,10 +188,36 @@ export async function getClientFullDetail(id: string): Promise<ClientFullDetail
|
||||
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(service_catalog)
|
||||
.where(eq(service_catalog.active, true))
|
||||
.orderBy(asc(service_catalog.name));
|
||||
|
||||
if (projectIds.length === 0) {
|
||||
return {
|
||||
client,
|
||||
phases: [],
|
||||
payments: [],
|
||||
documents: [],
|
||||
notes: [],
|
||||
comments: [],
|
||||
quoteItems: [],
|
||||
activeServices: activeServiceRows,
|
||||
};
|
||||
}
|
||||
|
||||
const phasesRows = await db
|
||||
.select()
|
||||
.from(phases)
|
||||
.where(eq(phases.client_id, id))
|
||||
.where(inArray(phases.project_id, projectIds))
|
||||
.orderBy(asc(phases.sort_order));
|
||||
|
||||
const phaseIds = phasesRows.map((p) => p.id);
|
||||
@@ -162,18 +238,21 @@ export async function getClientFullDetail(id: string): Promise<ClientFullDetail
|
||||
? []
|
||||
: 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 paymentsRows = await db
|
||||
.select()
|
||||
.from(payments)
|
||||
.where(inArray(payments.project_id, projectIds));
|
||||
|
||||
const documentsRows = await db
|
||||
.select()
|
||||
.from(documents)
|
||||
.where(eq(documents.client_id, id))
|
||||
.where(inArray(documents.project_id, projectIds))
|
||||
.orderBy(asc(documents.created_at));
|
||||
|
||||
const notesRows = await db
|
||||
.select()
|
||||
.from(notes)
|
||||
.where(eq(notes.client_id, id))
|
||||
.where(inArray(notes.project_id, projectIds))
|
||||
.orderBy(asc(notes.created_at));
|
||||
|
||||
const allEntityIds = [id, ...taskIds, ...deliverablesRows.map((d) => d.id)];
|
||||
@@ -209,15 +288,9 @@ export async function getClientFullDetail(id: string): Promise<ClientFullDetail
|
||||
})
|
||||
.from(quote_items)
|
||||
.leftJoin(service_catalog, eq(quote_items.service_id, service_catalog.id))
|
||||
.where(eq(quote_items.client_id, id))
|
||||
.where(inArray(quote_items.project_id, projectIds))
|
||||
.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,
|
||||
@@ -235,4 +308,265 @@ export async function getAllServices(): Promise<ServiceCatalog[]> {
|
||||
.select()
|
||||
.from(service_catalog)
|
||||
.orderBy(asc(service_catalog.name));
|
||||
}
|
||||
|
||||
// ── 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<ProjectWithPayments[]> {
|
||||
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<string>`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<Phase & { tasks: Array<Task & { deliverables: Deliverable[] }> }>;
|
||||
payments: Payment[];
|
||||
documents: Document[];
|
||||
notes: Note[];
|
||||
comments: Comment[];
|
||||
quoteItems: QuoteItemWithLabel[];
|
||||
activeServices: ServiceCatalog[];
|
||||
activeTimerEntryId: string | null;
|
||||
activeTimerStartedAt: Date | null;
|
||||
totalTrackedSeconds: number;
|
||||
};
|
||||
|
||||
export async function getProjectFullDetail(id: string): Promise<ProjectFullDetail | null> {
|
||||
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] =
|
||||
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<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.project_id, id))
|
||||
.orderBy(asc(quote_items.id)),
|
||||
db.select().from(service_catalog).where(eq(service_catalog.active, true)).orderBy(asc(service_catalog.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<string>`coalesce(sum(${time_entries.duration_seconds}), 0)` })
|
||||
.from(time_entries)
|
||||
.where(eq(time_entries.project_id, id)),
|
||||
]);
|
||||
|
||||
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),
|
||||
})),
|
||||
}));
|
||||
|
||||
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,
|
||||
};
|
||||
}
|
||||
|
||||
// ── 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<ClientWithProjects | null> {
|
||||
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,
|
||||
})),
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user