From 319c945fa7825546bf416542be5dbf2ebc5ddaad Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 19 Mar 2026 09:51:00 +0000 Subject: [PATCH 01/10] Extract withConfirm wrapper to eliminate confirm/plan boilerplate Every WRITE and RECEIPT tier command repeated the same pattern: if (!confirm) return needsConfirm(null, plan) else execute(). New shared/with-confirm.ts provides withConfirm() that encapsulates this branching. Applied to 9 command files: contacts add/edit/import, donations add/void, receipts generate/batch/void, orgs add, config set. https://claude.ai/code/session_01D3rGyrcsVT96ZepUP66rjT --- crm/src/config/set.ts | 51 +++--- crm/src/contacts/add.ts | 129 +++++++------- crm/src/contacts/edit.ts | 169 ++++++++++--------- crm/src/contacts/import.ts | 209 +++++++++++------------ crm/src/donations/add.ts | 161 +++++++++--------- crm/src/donations/receipt.ts | 251 +++++++++++++++------------- crm/src/donations/void-donation.ts | 93 ++++++----- crm/src/donations/void-receipt.ts | 74 ++++---- crm/src/orgs/add.ts | 127 +++++++------- crm/src/shared/index.ts | 1 + crm/src/shared/with-confirm.test.ts | 135 +++++++++++++++ crm/src/shared/with-confirm.ts | 31 ++++ 12 files changed, 813 insertions(+), 618 deletions(-) create mode 100644 crm/src/shared/with-confirm.test.ts create mode 100644 crm/src/shared/with-confirm.ts diff --git a/crm/src/config/set.ts b/crm/src/config/set.ts index c68d92280d7..193b8423170 100644 --- a/crm/src/config/set.ts +++ b/crm/src/config/set.ts @@ -1,6 +1,7 @@ import { eq } from "drizzle-orm"; import { connect, audit, performer, schema } from "../db/connection.js"; -import { ok, fail, needsConfirm, type CommandResult } from "../types.js"; +import { ok, fail, type CommandResult } from "../types.js"; +import { withConfirm } from "../shared/with-confirm.js"; import { VALID_KEYS, type ConfigData } from "./show.js"; // Map CLI snake_case keys to camelCase DB column names @@ -48,36 +49,38 @@ export async function configSet( value = stripped; } - if (!opts.confirm) { - return needsConfirm(null, { + return withConfirm<{ key: string; value: string }>({ + confirm: opts.confirm, + plan: () => ({ action: `Set config: ${key} = "${value}"`, details: { key, value }, tier: "write", confirmCommand: `nc-crm config set ${key} "${value}" --confirm`, - }); - } + }), + execute: async () => { + const db = connect(); - const db = connect(); + // Upsert: check if config row exists + const [existing] = await db.select().from(schema.receiptConfig).limit(1); + const oldValue = existing ? (existing as any)[colName] : null; - // Upsert: check if config row exists - const [existing] = await db.select().from(schema.receiptConfig).limit(1); - const oldValue = existing ? (existing as any)[colName] : null; + if (existing) { + await db.update(schema.receiptConfig) + .set({ [colName]: value, updatedAt: new Date() }) + .where(eq(schema.receiptConfig.id, 1)); + } else { + await db.insert(schema.receiptConfig).values({ id: 1, [colName]: value }); + } - if (existing) { - await db.update(schema.receiptConfig) - .set({ [colName]: value, updatedAt: new Date() }) - .where(eq(schema.receiptConfig.id, 1)); - } else { - await db.insert(schema.receiptConfig).values({ id: 1, [colName]: value }); - } + await audit(db, { + table: "receipt_config", + recordId: "1", + action: existing ? "UPDATE" : "INSERT", + changes: { [colName]: { old: oldValue, new: value } }, + by: performer(), + }); - await audit(db, { - table: "receipt_config", - recordId: "1", - action: existing ? "UPDATE" : "INSERT", - changes: { [colName]: { old: oldValue, new: value } }, - by: performer(), + return ok({ key, value }); + }, }); - - return ok({ key, value }); } diff --git a/crm/src/contacts/add.ts b/crm/src/contacts/add.ts index 649d5750c1d..5b0ee9394ab 100644 --- a/crm/src/contacts/add.ts +++ b/crm/src/contacts/add.ts @@ -1,6 +1,7 @@ import { eq } from "drizzle-orm"; import { connect, audit, performer, schema } from "../db/connection.js"; -import { ok, fail, needsConfirm, type CommandResult, type ContactRow } from "../types.js"; +import { ok, fail, type CommandResult, type ContactRow } from "../types.js"; +import { withConfirm } from "../shared/with-confirm.js"; interface AddOpts { email?: string; @@ -38,73 +39,75 @@ export async function contactsAdd( } } - // Without --confirm: output plan - if (!opts.confirm) { - const details: Record = { name: `${firstName} ${lastName}`, type: opts.type }; - if (opts.email) details.email = opts.email; - if (opts.phone) details.phone = opts.phone; - if (opts.suburb) details.suburb = opts.suburb; - if (opts.state) details.state = opts.state; - if (opts.tag?.length) details.tags = opts.tag.join(", "); + return withConfirm({ + confirm: opts.confirm, + plan: () => { + const details: Record = { name: `${firstName} ${lastName}`, type: opts.type }; + if (opts.email) details.email = opts.email; + if (opts.phone) details.phone = opts.phone; + if (opts.suburb) details.suburb = opts.suburb; + if (opts.state) details.state = opts.state; + if (opts.tag?.length) details.tags = opts.tag.join(", "); - const args = [`contacts add "${firstName}" "${lastName}"`]; - if (opts.email) args.push(`--email "${opts.email}"`); - if (opts.phone) args.push(`--phone "${opts.phone}"`); - if (opts.type !== "other") args.push(`--type ${opts.type}`); - if (opts.suburb) args.push(`--suburb "${opts.suburb}"`); - if (opts.state) args.push(`--state ${opts.state}`); - if (opts.postcode) args.push(`--postcode ${opts.postcode}`); - for (const t of opts.tag ?? []) args.push(`--tag "${t}"`); - args.push("--confirm"); + const args = [`contacts add "${firstName}" "${lastName}"`]; + if (opts.email) args.push(`--email "${opts.email}"`); + if (opts.phone) args.push(`--phone "${opts.phone}"`); + if (opts.type !== "other") args.push(`--type ${opts.type}`); + if (opts.suburb) args.push(`--suburb "${opts.suburb}"`); + if (opts.state) args.push(`--state ${opts.state}`); + if (opts.postcode) args.push(`--postcode ${opts.postcode}`); + for (const t of opts.tag ?? []) args.push(`--tag "${t}"`); + args.push("--confirm"); - return needsConfirm(null, { - action: `Add contact: ${firstName} ${lastName}`, - details, - tier: "write", - confirmCommand: `nc-crm ${args.join(" ")}`, - }); - } - - // With --confirm: execute - const [inserted] = await db - .insert(schema.contacts) - .values({ - firstName, - lastName, - email: opts.email, - phone: opts.phone, - addressLine1: opts.addressLine1, - addressLine2: opts.addressLine2, - suburb: opts.suburb, - state: opts.state, - postcode: opts.postcode, - contactType: opts.type, - notes: opts.notes, - }) - .returning(); + return { + action: `Add contact: ${firstName} ${lastName}`, + details, + tier: "write", + confirmCommand: `nc-crm ${args.join(" ")}`, + }; + }, + execute: async () => { + const [inserted] = await db + .insert(schema.contacts) + .values({ + firstName, + lastName, + email: opts.email, + phone: opts.phone, + addressLine1: opts.addressLine1, + addressLine2: opts.addressLine2, + suburb: opts.suburb, + state: opts.state, + postcode: opts.postcode, + contactType: opts.type, + notes: opts.notes, + }) + .returning(); - // Tags - if (opts.tag?.length) { - const tagRows = opts.tag.map((t) => { - const [key, value] = t.includes("=") ? t.split("=", 2) : [t, undefined]; - return { entityType: "contact" as const, entityId: inserted.id, key: key!, value }; - }); - await db.insert(schema.tags).values(tagRows); - } + // Tags + if (opts.tag?.length) { + const tagRows = opts.tag.map((t) => { + const [key, value] = t.includes("=") ? t.split("=", 2) : [t, undefined]; + return { entityType: "contact" as const, entityId: inserted.id, key: key!, value }; + }); + await db.insert(schema.tags).values(tagRows); + } - await audit(db, { table: "contacts", recordId: inserted.id, action: "INSERT", by: performer() }); + await audit(db, { table: "contacts", recordId: inserted.id, action: "INSERT", by: performer() }); - const result = ok({ - id: inserted.id.slice(0, 8), - name: `${inserted.firstName} ${inserted.lastName}`, - email: inserted.email, - type: inserted.contactType, - tags: (opts.tag ?? []).join(", "), - }); + const result = ok({ + id: inserted.id.slice(0, 8), + name: `${inserted.firstName} ${inserted.lastName}`, + email: inserted.email, + type: inserted.contactType, + tags: (opts.tag ?? []).join(", "), + }); - if (!inserted.email) { - result.hints.push("No email on file — you'll need one before generating DGR receipts."); - } + if (!inserted.email) { + result.hints.push("No email on file — you'll need one before generating DGR receipts."); + } - return result; + return result; + }, + }); } diff --git a/crm/src/contacts/edit.ts b/crm/src/contacts/edit.ts index 9296f546b80..7aaa41b0a17 100644 --- a/crm/src/contacts/edit.ts +++ b/crm/src/contacts/edit.ts @@ -1,6 +1,7 @@ import { eq, sql } from "drizzle-orm"; import { connect, audit, performer, schema } from "../db/connection.js"; -import { ok, fail, needsConfirm, type CommandResult, type ContactRow } from "../types.js"; +import { ok, fail, type CommandResult, type ContactRow } from "../types.js"; +import { withConfirm } from "../shared/with-confirm.js"; interface EditOpts { firstName?: string; @@ -94,92 +95,94 @@ export async function contactsEdit( } } - // Without --confirm: output plan - if (!opts.confirm) { - const details: Record = { - contact: `${contact.firstName} ${contact.lastName} (${contact.id.slice(0, 8)})`, - }; - for (const [field, change] of Object.entries(changes)) { - details[field] = `"${change.old ?? ""}" → "${change.new}"`; - } - if (opts.addTag?.length) details.addTags = opts.addTag.join(", "); - if (opts.removeTag?.length) details.removeTags = opts.removeTag.join(", "); - - const args = [`contacts edit ${idPrefix}`]; - for (const [optKey] of fieldMap) { - const v = opts[optKey]; - if (v !== undefined) { - const flag = optKey.replace(/([A-Z])/g, (m) => `-${m.toLowerCase()}`); - args.push(`--${flag} "${v}"`); + return withConfirm({ + confirm: opts.confirm, + plan: () => { + const details: Record = { + contact: `${contact.firstName} ${contact.lastName} (${contact.id.slice(0, 8)})`, + }; + for (const [field, change] of Object.entries(changes)) { + details[field] = `"${change.old ?? ""}" → "${change.new}"`; + } + if (opts.addTag?.length) details.addTags = opts.addTag.join(", "); + if (opts.removeTag?.length) details.removeTags = opts.removeTag.join(", "); + + const args = [`contacts edit ${idPrefix}`]; + for (const [optKey] of fieldMap) { + const v = opts[optKey]; + if (v !== undefined) { + const flag = optKey.replace(/([A-Z])/g, (m) => `-${m.toLowerCase()}`); + args.push(`--${flag} "${v}"`); + } + } + for (const t of opts.addTag ?? []) args.push(`--add-tag "${t}"`); + for (const t of opts.removeTag ?? []) args.push(`--remove-tag "${t}"`); + args.push("--confirm"); + + return { + action: `Edit contact: ${contact.firstName} ${contact.lastName}`, + details, + tier: "write", + confirmCommand: `nc-crm ${args.join(" ")}`, + }; + }, + execute: async () => { + if (hasFieldChanges) { + updates.updatedAt = new Date(); + await db.update(schema.contacts).set(updates).where(eq(schema.contacts.id, contact.id)); } - } - for (const t of opts.addTag ?? []) args.push(`--add-tag "${t}"`); - for (const t of opts.removeTag ?? []) args.push(`--remove-tag "${t}"`); - args.push("--confirm"); - - return needsConfirm(null, { - action: `Edit contact: ${contact.firstName} ${contact.lastName}`, - details, - tier: "write", - confirmCommand: `nc-crm ${args.join(" ")}`, - }); - } - - // With --confirm: execute - if (hasFieldChanges) { - updates.updatedAt = new Date(); - await db.update(schema.contacts).set(updates).where(eq(schema.contacts.id, contact.id)); - } - // Tag additions - if (opts.addTag?.length) { - for (const t of opts.addTag) { - const [key, value] = t.includes("=") ? t.split("=", 2) : [t, undefined]; - await db.insert(schema.tags).values({ - entityType: "contact", - entityId: contact.id, - key: key!, - value, - }).onConflictDoNothing(); - } - } + // Tag additions + if (opts.addTag?.length) { + for (const t of opts.addTag) { + const [key, value] = t.includes("=") ? t.split("=", 2) : [t, undefined]; + await db.insert(schema.tags).values({ + entityType: "contact", + entityId: contact.id, + key: key!, + value, + }).onConflictDoNothing(); + } + } - // Tag removals - if (opts.removeTag?.length) { - for (const t of opts.removeTag) { - const [key] = t.includes("=") ? t.split("=", 2) : [t]; - await db.delete(schema.tags).where( - sql`entity_type = 'contact' AND entity_id = ${contact.id} AND key = ${key}` - ); - } - } + // Tag removals + if (opts.removeTag?.length) { + for (const t of opts.removeTag) { + const [key] = t.includes("=") ? t.split("=", 2) : [t]; + await db.delete(schema.tags).where( + sql`entity_type = 'contact' AND entity_id = ${contact.id} AND key = ${key}` + ); + } + } - await audit(db, { - table: "contacts", - recordId: contact.id, - action: "UPDATE", - changes: { - ...changes, - ...(opts.addTag?.length ? { tagsAdded: { old: null, new: opts.addTag } } : {}), - ...(opts.removeTag?.length ? { tagsRemoved: { old: opts.removeTag, new: null } } : {}), + await audit(db, { + table: "contacts", + recordId: contact.id, + action: "UPDATE", + changes: { + ...changes, + ...(opts.addTag?.length ? { tagsAdded: { old: null, new: opts.addTag } } : {}), + ...(opts.removeTag?.length ? { tagsRemoved: { old: opts.removeTag, new: null } } : {}), + }, + by: performer(), + }); + + // Fetch updated tags + const allTags = await db + .select({ key: schema.tags.key, value: schema.tags.value }) + .from(schema.tags) + .where(sql`entity_type = 'contact' AND entity_id = ${contact.id}`); + + const updatedFirst = (updates.firstName as string) ?? contact.firstName; + const updatedLast = (updates.lastName as string) ?? contact.lastName; + + return ok({ + id: contact.id.slice(0, 8), + name: `${updatedFirst} ${updatedLast}`, + email: (updates.email as string) ?? contact.email, + type: (updates.contactType as string) ?? contact.contactType, + tags: allTags.map((t) => (t.value ? `${t.key}=${t.value}` : t.key)).join(", "), + }); }, - by: performer(), - }); - - // Fetch updated tags - const allTags = await db - .select({ key: schema.tags.key, value: schema.tags.value }) - .from(schema.tags) - .where(sql`entity_type = 'contact' AND entity_id = ${contact.id}`); - - const updatedFirst = (updates.firstName as string) ?? contact.firstName; - const updatedLast = (updates.lastName as string) ?? contact.lastName; - - return ok({ - id: contact.id.slice(0, 8), - name: `${updatedFirst} ${updatedLast}`, - email: (updates.email as string) ?? contact.email, - type: (updates.contactType as string) ?? contact.contactType, - tags: allTags.map((t) => (t.value ? `${t.key}=${t.value}` : t.key)).join(", "), }); } diff --git a/crm/src/contacts/import.ts b/crm/src/contacts/import.ts index 91b8ccd9770..9752590c232 100644 --- a/crm/src/contacts/import.ts +++ b/crm/src/contacts/import.ts @@ -2,7 +2,8 @@ import fs from "fs"; import { parse } from "csv-parse/sync"; import { eq } from "drizzle-orm"; import { connect, audit, performer, schema } from "../db/connection.js"; -import { ok, fail, needsConfirm, type CommandResult, type CommandPlan } from "../types.js"; +import { ok, fail, type CommandResult, type CommandPlan } from "../types.js"; +import { withConfirm } from "../shared/with-confirm.js"; export interface ImportResult { imported: number; @@ -269,114 +270,114 @@ export async function contactsImport( ? [...newRows, ...duplicateRows] : newRows; - // Without --confirm: output plan - if (!opts.confirm) { - const args = [`contacts import "${file}"`]; - if (opts.preset) args.push(`--preset ${opts.preset}`); - if (opts.map) for (const m of opts.map) args.push(`--map "${m}"`); - if (opts.onDuplicate) args.push(`--on-duplicate ${opts.onDuplicate}`); - if (opts.tag) for (const t of opts.tag) args.push(`--tag "${t}"`); - if (opts.type) args.push(`--type ${opts.type}`); - args.push("--confirm"); - - const plan: CommandPlan = { - action: `Import ${toImport.length} contacts from ${file.split("/").pop()}`, - details: { - totalRows: records.length, - valid: validRows.length, - duplicates: duplicateRows.length, - errors: errorRows.length, - willImport: toImport.length, - onDuplicate, - mappedColumns: Object.entries(columnMap).map(([k, v]) => `${k} → ${v}`).join(", "), - }, - tier: "write", - confirmCommand: `nc-crm ${args.join(" ")}`, - }; - - const result = needsConfirm(null, plan); - result.warnings = warnings; - return result; - } - - // With --confirm: execute in transaction - const contactType = opts.type ?? "other"; - const perf = performer(); - let imported = 0; - let skipped = 0; - let errors = 0; - - // Process in a single transaction-like batch - for (const row of toImport) { - try { - const isDuplicate = row.data.email && existingEmails.has(row.data.email.toLowerCase()); - - if (isDuplicate && onDuplicate === "update") { - const existingId = existingEmails.get(row.data.email!.toLowerCase())!; - const updateData: Record = { updatedAt: new Date() }; - if (row.data.firstName) updateData.firstName = row.data.firstName; - if (row.data.lastName) updateData.lastName = row.data.lastName; - if (row.data.phone) updateData.phone = row.data.phone; - if (row.data.addressLine1) updateData.addressLine1 = row.data.addressLine1; - if (row.data.addressLine2) updateData.addressLine2 = row.data.addressLine2; - if (row.data.suburb) updateData.suburb = row.data.suburb; - if (row.data.state) updateData.state = row.data.state; - if (row.data.postcode) updateData.postcode = row.data.postcode; - - await db.update(schema.contacts) - .set(updateData) - .where(eq(schema.contacts.id, existingId)); - await audit(db, { table: "contacts", recordId: existingId, action: "UPDATE", by: perf }); - imported++; - } else if (isDuplicate && onDuplicate === "skip") { - skipped++; - } else { - // Insert new contact - const [inserted] = await db.insert(schema.contacts).values({ - firstName: row.data.firstName ?? "", - lastName: row.data.lastName ?? "", - email: row.data.email || undefined, - phone: row.data.phone || undefined, - addressLine1: row.data.addressLine1 || undefined, - addressLine2: row.data.addressLine2 || undefined, - suburb: row.data.suburb || undefined, - state: row.data.state || undefined, - postcode: row.data.postcode || undefined, - contactType: row.data.contactType ?? contactType, - notes: row.data.notes || undefined, - }).returning(); - - // Tags - if (opts.tag?.length) { - const tagRows = opts.tag.map((t) => { - const [key, value] = t.includes("=") ? t.split("=", 2) : [t, undefined]; - return { entityType: "contact" as const, entityId: inserted.id, key: key!, value }; - }); - await db.insert(schema.tags).values(tagRows); + const importResult = await withConfirm({ + confirm: opts.confirm, + plan: () => { + const args = [`contacts import "${file}"`]; + if (opts.preset) args.push(`--preset ${opts.preset}`); + if (opts.map) for (const m of opts.map) args.push(`--map "${m}"`); + if (opts.onDuplicate) args.push(`--on-duplicate ${opts.onDuplicate}`); + if (opts.tag) for (const t of opts.tag) args.push(`--tag "${t}"`); + if (opts.type) args.push(`--type ${opts.type}`); + args.push("--confirm"); + + return { + action: `Import ${toImport.length} contacts from ${file.split("/").pop()}`, + details: { + totalRows: records.length, + valid: validRows.length, + duplicates: duplicateRows.length, + errors: errorRows.length, + willImport: toImport.length, + onDuplicate, + mappedColumns: Object.entries(columnMap).map(([k, v]) => `${k} → ${v}`).join(", "), + }, + tier: "write", + confirmCommand: `nc-crm ${args.join(" ")}`, + }; + }, + execute: async () => { + const contactType = opts.type ?? "other"; + const perf = performer(); + let imported = 0; + let skipped = 0; + let errors = 0; + + // Process in a single transaction-like batch + for (const row of toImport) { + try { + const isDuplicate = row.data.email && existingEmails.has(row.data.email.toLowerCase()); + + if (isDuplicate && onDuplicate === "update") { + const existingId = existingEmails.get(row.data.email!.toLowerCase())!; + const updateData: Record = { updatedAt: new Date() }; + if (row.data.firstName) updateData.firstName = row.data.firstName; + if (row.data.lastName) updateData.lastName = row.data.lastName; + if (row.data.phone) updateData.phone = row.data.phone; + if (row.data.addressLine1) updateData.addressLine1 = row.data.addressLine1; + if (row.data.addressLine2) updateData.addressLine2 = row.data.addressLine2; + if (row.data.suburb) updateData.suburb = row.data.suburb; + if (row.data.state) updateData.state = row.data.state; + if (row.data.postcode) updateData.postcode = row.data.postcode; + + await db.update(schema.contacts) + .set(updateData) + .where(eq(schema.contacts.id, existingId)); + await audit(db, { table: "contacts", recordId: existingId, action: "UPDATE", by: perf }); + imported++; + } else if (isDuplicate && onDuplicate === "skip") { + skipped++; + } else { + // Insert new contact + const [inserted] = await db.insert(schema.contacts).values({ + firstName: row.data.firstName ?? "", + lastName: row.data.lastName ?? "", + email: row.data.email || undefined, + phone: row.data.phone || undefined, + addressLine1: row.data.addressLine1 || undefined, + addressLine2: row.data.addressLine2 || undefined, + suburb: row.data.suburb || undefined, + state: row.data.state || undefined, + postcode: row.data.postcode || undefined, + contactType: row.data.contactType ?? contactType, + notes: row.data.notes || undefined, + }).returning(); + + // Tags + if (opts.tag?.length) { + const tagRows = opts.tag.map((t) => { + const [key, value] = t.includes("=") ? t.split("=", 2) : [t, undefined]; + return { entityType: "contact" as const, entityId: inserted.id, key: key!, value }; + }); + await db.insert(schema.tags).values(tagRows); + } + + await audit(db, { table: "contacts", recordId: inserted.id, action: "INSERT", by: perf }); + imported++; + } + } catch (err) { + errors++; + warnings.push(`Row ${row.rowNumber}: ${err instanceof Error ? err.message : String(err)}`); } - - await audit(db, { table: "contacts", recordId: inserted.id, action: "INSERT", by: perf }); - imported++; } - } catch (err) { - errors++; - warnings.push(`Row ${row.rowNumber}: ${err instanceof Error ? err.message : String(err)}`); - } - } - skipped += duplicateRows.length - (onDuplicate === "update" ? duplicateRows.length : 0); - if (onDuplicate === "skip") skipped = duplicateRows.length; + skipped += duplicateRows.length - (onDuplicate === "update" ? duplicateRows.length : 0); + if (onDuplicate === "skip") skipped = duplicateRows.length; - const result = ok({ imported, skipped, errors }, imported); - result.warnings = warnings; - if (imported > 0) { - result.hints.push(`${imported} contact${imported !== 1 ? "s" : ""} imported`); - } - if (skipped > 0) { - result.hints.push(`${skipped} duplicate${skipped !== 1 ? "s" : ""} skipped`); - } + const result = ok({ imported, skipped, errors }, imported); + if (imported > 0) { + result.hints.push(`${imported} contact${imported !== 1 ? "s" : ""} imported`); + } + if (skipped > 0) { + result.hints.push(`${skipped} duplicate${skipped !== 1 ? "s" : ""} skipped`); + } + + return result; + }, + }); - return result; + importResult.warnings = [...warnings, ...importResult.warnings]; + return importResult; } // Helper for raw SQL since we can't use tagged template here diff --git a/crm/src/donations/add.ts b/crm/src/donations/add.ts index 4474b67d3ca..1368012f82e 100644 --- a/crm/src/donations/add.ts +++ b/crm/src/donations/add.ts @@ -1,6 +1,7 @@ import { connect, audit, performer, schema } from "../db/connection.js"; -import { ok, fail, needsConfirm, type CommandResult, type Donation } from "../types.js"; +import { ok, fail, type CommandResult, type Donation } from "../types.js"; import { resolveContact } from "./resolve-contact.js"; +import { withConfirm } from "../shared/with-confirm.js"; interface AddOpts { contact?: string; @@ -52,84 +53,86 @@ export async function donationsAdd( contactName = `${contact.firstName} ${contact.lastName}`; } - // Without --confirm: output plan - if (!opts.confirm) { - const details: Record = { - amount: `$${amountFixed}`, - date: opts.date, - method, - fund: opts.fund, - dgrEligible: opts.dgr, - }; - if (contactName) details.contact = contactName; - if (opts.campaign) details.campaign = opts.campaign; - if (opts.reference) details.reference = opts.reference; - - const args = [`donations add "${amountFixed}"`]; - if (opts.contact) args.push(`--contact "${opts.contact}"`); - args.push(`--date ${opts.date}`); - if (method !== "other") args.push(`--method ${method}`); - if (opts.fund !== "general") args.push(`--fund "${opts.fund}"`); - if (opts.campaign) args.push(`--campaign "${opts.campaign}"`); - if (opts.reference) args.push(`--reference "${opts.reference}"`); - if (!opts.dgr) args.push("--no-dgr"); - if (opts.notes) args.push(`--notes "${opts.notes}"`); - args.push("--confirm"); - - return needsConfirm(null, { - action: `Record $${amountFixed} donation${contactName ? ` from ${contactName}` : ""}`, - details, - tier: "write", - confirmCommand: `nc-crm ${args.join(" ")}`, - }); - } - - // With --confirm: execute - const [inserted] = await db - .insert(schema.donations) - .values({ - contactId, - amount: amountFixed, - donationDate: opts.date, - method, - fund: opts.fund, - status: "received", - isDgrEligible: opts.dgr, - description: opts.notes, - reference: opts.reference, - campaign: opts.campaign, - }) - .returning(); - - await audit(db, { - table: "donations", - recordId: inserted.id, - action: "INSERT", - by: performer(), + return withConfirm({ + confirm: opts.confirm, + plan: () => { + const details: Record = { + amount: `$${amountFixed}`, + date: opts.date, + method, + fund: opts.fund, + dgrEligible: opts.dgr, + }; + if (contactName) details.contact = contactName; + if (opts.campaign) details.campaign = opts.campaign; + if (opts.reference) details.reference = opts.reference; + + const args = [`donations add "${amountFixed}"`]; + if (opts.contact) args.push(`--contact "${opts.contact}"`); + args.push(`--date ${opts.date}`); + if (method !== "other") args.push(`--method ${method}`); + if (opts.fund !== "general") args.push(`--fund "${opts.fund}"`); + if (opts.campaign) args.push(`--campaign "${opts.campaign}"`); + if (opts.reference) args.push(`--reference "${opts.reference}"`); + if (!opts.dgr) args.push("--no-dgr"); + if (opts.notes) args.push(`--notes "${opts.notes}"`); + args.push("--confirm"); + + return { + action: `Record $${amountFixed} donation${contactName ? ` from ${contactName}` : ""}`, + details, + tier: "write", + confirmCommand: `nc-crm ${args.join(" ")}`, + }; + }, + execute: async () => { + const [inserted] = await db + .insert(schema.donations) + .values({ + contactId, + amount: amountFixed, + donationDate: opts.date, + method, + fund: opts.fund, + status: "received", + isDgrEligible: opts.dgr, + description: opts.notes, + reference: opts.reference, + campaign: opts.campaign, + }) + .returning(); + + await audit(db, { + table: "donations", + recordId: inserted.id, + action: "INSERT", + by: performer(), + }); + + const result = ok({ + id: inserted.id.slice(0, 8), + contactId: contactId?.slice(0, 8) ?? null, + contactName, + amount: inserted.amount, + donationDate: inserted.donationDate, + method: inserted.method, + fund: inserted.fund, + status: inserted.status, + isDgrEligible: inserted.isDgrEligible, + campaign: inserted.campaign, + reference: inserted.reference, + receiptNumber: null, + }); + + if (!contactId) { + result.hints.push("No contact linked — link one before receipting: nc-crm donations edit --contact "); + } + if (opts.dgr && contactId) { + result.hints.push(`DGR-eligible. Generate receipt: nc-crm receipts generate ${inserted.id}`); + } + + return result; + }, }); - - const result = ok({ - id: inserted.id.slice(0, 8), - contactId: contactId?.slice(0, 8) ?? null, - contactName, - amount: inserted.amount, - donationDate: inserted.donationDate, - method: inserted.method, - fund: inserted.fund, - status: inserted.status, - isDgrEligible: inserted.isDgrEligible, - campaign: inserted.campaign, - reference: inserted.reference, - receiptNumber: null, - }); - - if (!contactId) { - result.hints.push("No contact linked — link one before receipting: nc-crm donations edit --contact "); - } - if (opts.dgr && contactId) { - result.hints.push(`DGR-eligible. Generate receipt: nc-crm receipts generate ${inserted.id}`); - } - - return result; } diff --git a/crm/src/donations/receipt.ts b/crm/src/donations/receipt.ts index d1cfd50ddd6..1b88f635105 100644 --- a/crm/src/donations/receipt.ts +++ b/crm/src/donations/receipt.ts @@ -3,7 +3,8 @@ import { createHash } from "node:crypto"; import { writeFile, mkdir } from "node:fs/promises"; import { join } from "node:path"; import { connect, audit, performer, schema } from "../db/connection.js"; -import { ok, fail, needsConfirm, type CommandResult, type ReceiptPlan, type ReceiptResult } from "../types.js"; +import { ok, fail, type CommandResult, type ReceiptPlan, type ReceiptResult } from "../types.js"; +import { withConfirm } from "../shared/with-confirm.js"; // --------------------------------------------------------------------------- // Receipt generation — RECEIPT tier @@ -50,23 +51,27 @@ export async function receiptsGenerate( const recipientName = `${contact.firstName} ${contact.lastName}`; - // --- Without --confirm: output plan --- + // --- Plan or execute --- + // For plan mode, peek at next receipt number + let planData: ReceiptPlan | undefined; if (!opts.confirm) { - // Peek at the next receipt number without consuming it const [seqPeek] = await db.execute(sql`SELECT last_value + 1 as next FROM nanoclaw_receipt_seq`) as any[]; const nextNum = parseInt(seqPeek?.next ?? "1", 10); - - const plan: ReceiptPlan = { + planData = { receiptNumber: nextNum, donorName: recipientName, amount: donation.amount, donationDate: donation.donationDate, email: contact.email, }; + } - return needsConfirm(plan, { - action: `Generate DGR receipt #${nextNum}`, + return withConfirm({ + confirm: opts.confirm, + planData, + plan: () => ({ + action: `Generate DGR receipt #${planData!.receiptNumber}`, details: { recipient: recipientName, amount: `$${donation.amount}`, @@ -76,91 +81,90 @@ export async function receiptsGenerate( }, tier: "receipt", confirmCommand: `nc-crm receipts generate ${donationId}${opts.send ? " --send" : ""} --confirm`, - }); - } - - // --- With --confirm: execute --- - - const recipientAddress = [contact.addressLine1, contact.addressLine2, contact.suburb, contact.state, contact.postcode] - .filter(Boolean).join(", "); - - // Allocate receipt number from PG sequence (NO CACHE, NO CYCLE) - const [seqResult] = await db.execute(sql`SELECT nextval('nanoclaw_receipt_seq') as num`) as any[]; - const receiptNumber = parseInt(seqResult.num, 10); - - // Generate PDF — deterministic, no LLM - const pdfPath = await generatePdf({ - receiptNumber, - recipientName, - recipientAddress, - amount: donation.amount, - donationDate: donation.donationDate, - config, - }); - - const pdfBuffer = await import("node:fs/promises").then((fs) => fs.readFile(pdfPath)); - const pdfHash = createHash("sha256").update(pdfBuffer).digest("hex"); - - // Insert receipt with snapshot - await db.insert(schema.receipts).values({ - receiptNumber, - donationId: donation.id, - recipientName, - recipientAddress, - amount: donation.amount, - donationDate: donation.donationDate, - dgrName: config.dgrName, - dgrAbn: config.abn, - pdfPath, - pdfHash, - isDuplicate: false, - isVoided: false, - createdBy: performer(), - }); - - // Update donation status - await db.update(schema.donations) - .set({ status: "receipted", updatedAt: new Date() }) - .where(eq(schema.donations.id, donation.id)); - - // Audit - await audit(db, { - table: "receipts", - recordId: donation.id, - action: "INSERT", - changes: { receiptNumber: { old: null, new: receiptNumber }, pdfHash: { old: null, new: pdfHash } }, - by: performer(), - }); - - // Email if requested - let emailSent = false; - if (opts.send && contact.email) { - try { - // TODO: SMTP send - emailSent = true; - await db.update(schema.donations).set({ status: "thanked", updatedAt: new Date() }).where(eq(schema.donations.id, donation.id)); - } catch { - // Email failure does NOT fail the receipt - } - } - - const result = ok({ - receiptNumber, - donationId: donation.id.slice(0, 8), - recipientName, - amount: donation.amount, - pdfPath, - emailSent, + }), + execute: async () => { + const recipientAddress = [contact.addressLine1, contact.addressLine2, contact.suburb, contact.state, contact.postcode] + .filter(Boolean).join(", "); + + // Allocate receipt number from PG sequence (NO CACHE, NO CYCLE) + const [seqResult] = await db.execute(sql`SELECT nextval('nanoclaw_receipt_seq') as num`) as any[]; + const receiptNumber = parseInt(seqResult.num, 10); + + // Generate PDF — deterministic, no LLM + const pdfPath = await generatePdf({ + receiptNumber, + recipientName, + recipientAddress, + amount: donation.amount, + donationDate: donation.donationDate, + config, + }); + + const pdfBuffer = await import("node:fs/promises").then((fs) => fs.readFile(pdfPath)); + const pdfHash = createHash("sha256").update(pdfBuffer).digest("hex"); + + // Insert receipt with snapshot + await db.insert(schema.receipts).values({ + receiptNumber, + donationId: donation.id, + recipientName, + recipientAddress, + amount: donation.amount, + donationDate: donation.donationDate, + dgrName: config.dgrName, + dgrAbn: config.abn, + pdfPath, + pdfHash, + isDuplicate: false, + isVoided: false, + createdBy: performer(), + }); + + // Update donation status + await db.update(schema.donations) + .set({ status: "receipted", updatedAt: new Date() }) + .where(eq(schema.donations.id, donation.id)); + + // Audit + await audit(db, { + table: "receipts", + recordId: donation.id, + action: "INSERT", + changes: { receiptNumber: { old: null, new: receiptNumber }, pdfHash: { old: null, new: pdfHash } }, + by: performer(), + }); + + // Email if requested + let emailSent = false; + if (opts.send && contact.email) { + try { + // TODO: SMTP send + emailSent = true; + await db.update(schema.donations).set({ status: "thanked", updatedAt: new Date() }).where(eq(schema.donations.id, donation.id)); + } catch { + // Email failure does NOT fail the receipt + } + } + + const result = ok({ + receiptNumber, + donationId: donation.id.slice(0, 8), + recipientName, + amount: donation.amount, + pdfPath, + emailSent, + }); + + if (opts.send && !contact.email) { + result.warnings.push(`${recipientName} has no email. Receipt saved to ${pdfPath}.`); + } + if (opts.send && contact.email && !emailSent) { + result.warnings.push(`Email send failed. Receipt saved to ${pdfPath}.`); + } + + return result; + }, }); - - if (opts.send && !contact.email) { - result.warnings.push(`${recipientName} has no email. Receipt saved to ${pdfPath}.`); - } - if (opts.send && contact.email && !emailSent) { - result.warnings.push(`Email send failed. Receipt saved to ${pdfPath}.`); - } - - return result; } // --------------------------------------------------------------------------- @@ -212,12 +216,13 @@ export async function receiptsBatch(opts: BatchOpts): Promise [c.id, c])); + // For plan mode, peek at next receipt number and build preview + let batchPlans: ReceiptPlan[] | undefined; if (!opts.confirm) { - // Output plan const [seqPeek] = await db.execute(sql`SELECT last_value + 1 as next FROM nanoclaw_receipt_seq`) as any[]; let nextNum = parseInt(seqPeek?.next ?? "1", 10); - const plans: ReceiptPlan[] = eligible.map((d) => { + batchPlans = eligible.map((d) => { const c = contactMap.get(d.contactId!)!; return { receiptNumber: nextNum++, @@ -227,35 +232,41 @@ export async function receiptsBatch(opts: BatchOpts): Promise sum + parseFloat(d.amount), 0); - - return needsConfirm(plans, { - action: `Generate ${plans.length} DGR receipts`, - details: { - count: plans.length, - receiptRange: `#${plans[0]!.receiptNumber}–#${plans[plans.length - 1]!.receiptNumber}`, - totalAmount: `$${totalAmount.toFixed(2)}`, - willEmail: opts.send ? `${plans.filter((p) => p.email).length} with email` : "no", - }, - tier: "receipt", - confirmCommand: `nc-crm receipts batch${opts.from ? ` --from ${opts.from}` : ""}${opts.to ? ` --to ${opts.to}` : ""}${opts.send ? " --send" : ""} --confirm`, - }); - } - - // Execute batch — sequential, stop on first failure - const results: ReceiptResult[] = []; - for (const d of eligible) { - const r = await receiptsGenerate(d.id, { send: opts.send, confirm: true }); - if (!r.ok) { - const partial = ok(results, results.length); - partial.warnings.push(`Stopped at donation ${d.id.slice(0, 8)}: ${r.warnings.join("; ")}. ${eligible.length - results.length} remaining.`); - return partial; - } - results.push(r.data as ReceiptResult); } - return ok(results, results.length); + return withConfirm({ + confirm: opts.confirm, + planData: batchPlans, + plan: () => { + const totalAmount = eligible.reduce((sum, d) => sum + parseFloat(d.amount), 0); + return { + action: `Generate ${batchPlans!.length} DGR receipts`, + details: { + count: batchPlans!.length, + receiptRange: `#${batchPlans![0]!.receiptNumber}–#${batchPlans![batchPlans!.length - 1]!.receiptNumber}`, + totalAmount: `$${totalAmount.toFixed(2)}`, + willEmail: opts.send ? `${batchPlans!.filter((p) => p.email).length} with email` : "no", + }, + tier: "receipt", + confirmCommand: `nc-crm receipts batch${opts.from ? ` --from ${opts.from}` : ""}${opts.to ? ` --to ${opts.to}` : ""}${opts.send ? " --send" : ""} --confirm`, + }; + }, + execute: async () => { + // Execute batch — sequential, stop on first failure + const results: ReceiptResult[] = []; + for (const d of eligible) { + const r = await receiptsGenerate(d.id, { send: opts.send, confirm: true }); + if (!r.ok) { + const partial = ok(results, results.length); + partial.warnings.push(`Stopped at donation ${d.id.slice(0, 8)}: ${r.warnings.join("; ")}. ${eligible.length - results.length} remaining.`); + return partial; + } + results.push(r.data as ReceiptResult); + } + + return ok(results, results.length); + }, + }); } // --------------------------------------------------------------------------- diff --git a/crm/src/donations/void-donation.ts b/crm/src/donations/void-donation.ts index 1bf733f2b1b..048f2ab3660 100644 --- a/crm/src/donations/void-donation.ts +++ b/crm/src/donations/void-donation.ts @@ -1,6 +1,7 @@ import { eq, sql } from "drizzle-orm"; import { connect, audit, performer, schema } from "../db/connection.js"; -import { ok, fail, needsConfirm, type CommandResult } from "../types.js"; +import { ok, fail, type CommandResult } from "../types.js"; +import { withConfirm } from "../shared/with-confirm.js"; interface VoidOpts { reason?: string; @@ -38,9 +39,9 @@ export async function donationsVoid( return fail(`Donation ${donation.id.slice(0, 8)} is already voided.`); } - // Without --confirm: output plan - if (!opts.confirm) { - return needsConfirm(null, { + return withConfirm<{ id: string; status: string }>({ + confirm: opts.confirm, + plan: () => ({ action: `Void donation ${donation.id.slice(0, 8)} ($${donation.amount})`, details: { id: donation.id.slice(0, 8), @@ -51,52 +52,52 @@ export async function donationsVoid( }, tier: "write", confirmCommand: `nc-crm donations void ${idPrefix} --reason "" --confirm`, - }); - } + }), + execute: async () => { + if (!opts.reason) { + return fail(`--reason is required when voiding a donation with --confirm.`); + } - // With --confirm: requires --reason - if (!opts.reason) { - return fail(`--reason is required when voiding a donation with --confirm.`); - } + // Check for receipt + const [receiptRow] = await db + .select({ + receiptNumber: schema.receipts.receiptNumber, + }) + .from(schema.receipts) + .where(eq(schema.receipts.donationId, donation.id)) + .limit(1); - // Check for receipt - const [receiptRow] = await db - .select({ - receiptNumber: schema.receipts.receiptNumber, - }) - .from(schema.receipts) - .where(eq(schema.receipts.donationId, donation.id)) - .limit(1); + // Execute void + await db + .update(schema.donations) + .set({ + status: "voided", + voidReason: opts.reason, + voidedAt: new Date(), + updatedAt: new Date(), + }) + .where(eq(schema.donations.id, donation.id)); - // Execute void - await db - .update(schema.donations) - .set({ - status: "voided", - voidReason: opts.reason, - voidedAt: new Date(), - updatedAt: new Date(), - }) - .where(eq(schema.donations.id, donation.id)); + await audit(db, { + table: "donations", + recordId: donation.id, + action: "UPDATE", + changes: { + status: { old: donation.status, new: "voided" }, + voidReason: { old: null, new: opts.reason }, + }, + by: performer(), + }); - await audit(db, { - table: "donations", - recordId: donation.id, - action: "UPDATE", - changes: { - status: { old: donation.status, new: "voided" }, - voidReason: { old: null, new: opts.reason }, - }, - by: performer(), - }); + const result = ok({ id: donation.id.slice(0, 8), status: "voided" }); - const result = ok({ id: donation.id.slice(0, 8), status: "voided" }); + if (receiptRow) { + result.warnings.push( + `This donation has receipt #${receiptRow.receiptNumber}. Void the receipt separately: nc-crm receipts void ${receiptRow.receiptNumber}` + ); + } - if (receiptRow) { - result.warnings.push( - `This donation has receipt #${receiptRow.receiptNumber}. Void the receipt separately: nc-crm receipts void ${receiptRow.receiptNumber}` - ); - } - - return result; + return result; + }, + }); } diff --git a/crm/src/donations/void-receipt.ts b/crm/src/donations/void-receipt.ts index 2badc18cd22..df951d71415 100644 --- a/crm/src/donations/void-receipt.ts +++ b/crm/src/donations/void-receipt.ts @@ -1,6 +1,7 @@ import { eq } from "drizzle-orm"; import { connect, audit, performer, schema } from "../db/connection.js"; -import { ok, fail, needsConfirm, type CommandResult } from "../types.js"; +import { ok, fail, type CommandResult } from "../types.js"; +import { withConfirm } from "../shared/with-confirm.js"; // --------------------------------------------------------------------------- // receipts void — RECEIPT tier @@ -45,9 +46,9 @@ export async function receiptsVoid( return fail(`Receipt #${num} is already voided.`); } - // --- Without --confirm: output plan --- - if (!opts.confirm) { - return needsConfirm(null, { + return withConfirm<{ receiptNumber: number; donationId: string; reason: string }>({ + confirm: opts.confirm, + plan: () => ({ action: `Void receipt #${num}`, details: { receiptNumber: receipt.receiptNumber, @@ -59,44 +60,43 @@ export async function receiptsVoid( }, tier: "receipt", confirmCommand: `nc-crm receipts void ${num} --reason "" --confirm`, - }); - } + }), + execute: async () => { + if (!opts.reason) { + return fail(`--reason is required when voiding a receipt.`); + } - // --- --reason is required for execution --- - if (!opts.reason) { - return fail(`--reason is required when voiding a receipt.`); - } + const now = new Date(); - // --- Execute void --- - const now = new Date(); + // Update receipt: set voided + await db + .update(schema.receipts) + .set({ isVoided: true, voidReason: opts.reason, voidedAt: now }) + .where(eq(schema.receipts.receiptNumber, num)); - // Update receipt: set voided - await db - .update(schema.receipts) - .set({ isVoided: true, voidReason: opts.reason, voidedAt: now }) - .where(eq(schema.receipts.receiptNumber, num)); + // Revert linked donation status to "received" + await db + .update(schema.donations) + .set({ status: "received", updatedAt: now }) + .where(eq(schema.donations.id, receipt.donationId)); - // Revert linked donation status to "received" - await db - .update(schema.donations) - .set({ status: "received", updatedAt: now }) - .where(eq(schema.donations.id, receipt.donationId)); + // Audit log + await audit(db, { + table: "receipts", + recordId: receipt.id, + action: "UPDATE", + changes: { + isVoided: { old: false, new: true }, + voidReason: { old: null, new: opts.reason }, + }, + by: performer(), + }); - // Audit log - await audit(db, { - table: "receipts", - recordId: receipt.id, - action: "UPDATE", - changes: { - isVoided: { old: false, new: true }, - voidReason: { old: null, new: opts.reason }, + return ok({ + receiptNumber: receipt.receiptNumber, + donationId: receipt.donationId.slice(0, 8), + reason: opts.reason, + }); }, - by: performer(), - }); - - return ok({ - receiptNumber: receipt.receiptNumber, - donationId: receipt.donationId.slice(0, 8), - reason: opts.reason, }); } diff --git a/crm/src/orgs/add.ts b/crm/src/orgs/add.ts index d67d9c33317..94bf387a512 100644 --- a/crm/src/orgs/add.ts +++ b/crm/src/orgs/add.ts @@ -1,6 +1,7 @@ import { eq } from "drizzle-orm"; import { connect, audit, performer, schema } from "../db/connection.js"; -import { ok, fail, needsConfirm, type CommandResult } from "../types.js"; +import { ok, fail, type CommandResult } from "../types.js"; +import { withConfirm } from "../shared/with-confirm.js"; interface OrgAddOpts { abn?: string; @@ -54,72 +55,74 @@ export async function orgsAdd( } } - // Without --confirm: output plan - if (!opts.confirm) { - const details: Record = { name, orgType: opts.orgType }; - if (abn) details.abn = abn; - if (opts.suburb) details.suburb = opts.suburb; - if (opts.state) details.state = opts.state; - if (opts.phone) details.phone = opts.phone; - if (opts.website) details.website = opts.website; - if (opts.tag?.length) details.tags = opts.tag.join(", "); + return withConfirm({ + confirm: opts.confirm, + plan: () => { + const details: Record = { name, orgType: opts.orgType }; + if (abn) details.abn = abn; + if (opts.suburb) details.suburb = opts.suburb; + if (opts.state) details.state = opts.state; + if (opts.phone) details.phone = opts.phone; + if (opts.website) details.website = opts.website; + if (opts.tag?.length) details.tags = opts.tag.join(", "); - const args = [`orgs add "${name}"`]; - args.push(`--org-type ${opts.orgType}`); - if (abn) args.push(`--abn ${abn}`); - if (opts.suburb) args.push(`--suburb "${opts.suburb}"`); - if (opts.state) args.push(`--state ${opts.state}`); - if (opts.postcode) args.push(`--postcode ${opts.postcode}`); - if (opts.phone) args.push(`--phone "${opts.phone}"`); - if (opts.website) args.push(`--website "${opts.website}"`); - for (const t of opts.tag ?? []) args.push(`--tag "${t}"`); - args.push("--confirm"); + const args = [`orgs add "${name}"`]; + args.push(`--org-type ${opts.orgType}`); + if (abn) args.push(`--abn ${abn}`); + if (opts.suburb) args.push(`--suburb "${opts.suburb}"`); + if (opts.state) args.push(`--state ${opts.state}`); + if (opts.postcode) args.push(`--postcode ${opts.postcode}`); + if (opts.phone) args.push(`--phone "${opts.phone}"`); + if (opts.website) args.push(`--website "${opts.website}"`); + for (const t of opts.tag ?? []) args.push(`--tag "${t}"`); + args.push("--confirm"); - return needsConfirm(null, { - action: `Add organisation: ${name}`, - details, - tier: "write", - confirmCommand: `nc-crm ${args.join(" ")}`, - }); - } + return { + action: `Add organisation: ${name}`, + details, + tier: "write", + confirmCommand: `nc-crm ${args.join(" ")}`, + }; + }, + execute: async () => { + const [inserted] = await db + .insert(schema.organisations) + .values({ + name, + orgType: opts.orgType, + abn: abn ?? null, + addressLine1: opts.addressLine1, + suburb: opts.suburb, + state: opts.state, + postcode: opts.postcode, + phone: opts.phone, + website: opts.website, + notes: opts.notes, + }) + .returning(); - // With --confirm: execute - const [inserted] = await db - .insert(schema.organisations) - .values({ - name, - orgType: opts.orgType, - abn: abn ?? null, - addressLine1: opts.addressLine1, - suburb: opts.suburb, - state: opts.state, - postcode: opts.postcode, - phone: opts.phone, - website: opts.website, - notes: opts.notes, - }) - .returning(); + // Tags + if (opts.tag?.length) { + const tagRows = opts.tag.map((t) => { + const [key, value] = t.includes("=") ? t.split("=", 2) : [t, undefined]; + return { entityType: "org" as const, entityId: inserted.id, key: key!, value }; + }); + await db.insert(schema.tags).values(tagRows); + } - // Tags - if (opts.tag?.length) { - const tagRows = opts.tag.map((t) => { - const [key, value] = t.includes("=") ? t.split("=", 2) : [t, undefined]; - return { entityType: "org" as const, entityId: inserted.id, key: key!, value }; - }); - await db.insert(schema.tags).values(tagRows); - } + await audit(db, { table: "organisations", recordId: inserted.id, action: "INSERT", by: performer() }); - await audit(db, { table: "organisations", recordId: inserted.id, action: "INSERT", by: performer() }); + const result = ok({ + id: inserted.id.slice(0, 8), + name: inserted.name, + orgType: inserted.orgType, + abn: inserted.abn ?? null, + tags: (opts.tag ?? []).join(", "), + }); - const result = ok({ - id: inserted.id.slice(0, 8), - name: inserted.name, - orgType: inserted.orgType, - abn: inserted.abn ?? null, - tags: (opts.tag ?? []).join(", "), - }); + result.hints.push(`Link contacts to this org: nc-crm contacts link ${inserted.id.slice(0, 8)}`); - result.hints.push(`Link contacts to this org: nc-crm contacts link ${inserted.id.slice(0, 8)}`); - - return result; + return result; + }, + }); } diff --git a/crm/src/shared/index.ts b/crm/src/shared/index.ts index 4b372ba06ee..10de621d712 100644 --- a/crm/src/shared/index.ts +++ b/crm/src/shared/index.ts @@ -12,3 +12,4 @@ export { makeIdempotencyKey, checkIdempotency, recordIdempotency } from "./idemp export { defaultFormat, formatTable, formatEnvelope } from "./format.js"; export { validatePipeline, type PipeContract, type StageResult, type ValidationResult } from "./compatibility.js"; export { createTraceSpan, formatSpanName, extractPipeMetadata, correlationId, emitTraceSpan, type PipeSpan, type PipeMetadata } from "./trace.js"; +export { withConfirm, type WithConfirmOpts } from "./with-confirm.js"; diff --git a/crm/src/shared/with-confirm.test.ts b/crm/src/shared/with-confirm.test.ts new file mode 100644 index 00000000000..e193be5f500 --- /dev/null +++ b/crm/src/shared/with-confirm.test.ts @@ -0,0 +1,135 @@ +import { describe, it, expect } from "vitest"; +import { withConfirm } from "./with-confirm.js"; +import type { CommandResult, CommandPlan, Tier } from "../types.js"; + +describe("withConfirm", () => { + it("returns plan when confirm is false", async () => { + const result = await withConfirm({ + confirm: false, + plan: () => ({ + action: "Add contact: Jane", + details: { name: "Jane Smith" }, + tier: "write" as Tier, + confirmCommand: 'nc-crm contacts add "Jane" "Smith" --confirm', + }), + execute: async () => ({ ok: true, data: { id: "abc" }, count: 1, warnings: [], hints: [] }), + }); + + expect(result.ok).toBe(true); + expect(result.plan).toBeDefined(); + expect(result.plan!.action).toBe("Add contact: Jane"); + expect(result.plan!.tier).toBe("write"); + expect(result.plan!.confirmCommand).toContain("--confirm"); + expect(result.data).toBeNull(); + }); + + it("executes and returns result when confirm is true", async () => { + const executed = { called: false }; + + const result = await withConfirm({ + confirm: true, + plan: () => ({ + action: "Add contact: Jane", + details: { name: "Jane Smith" }, + tier: "write" as Tier, + confirmCommand: 'nc-crm contacts add "Jane" "Smith" --confirm', + }), + execute: async () => { + executed.called = true; + return { ok: true, data: { id: "abc" }, count: 1, warnings: [], hints: [] }; + }, + }); + + expect(executed.called).toBe(true); + expect(result.ok).toBe(true); + expect(result.data).toEqual({ id: "abc" }); + expect(result.plan).toBeUndefined(); + }); + + it("does not call execute when confirm is false", async () => { + const executed = { called: false }; + + await withConfirm({ + confirm: false, + plan: () => ({ + action: "Test", + details: {}, + tier: "write" as Tier, + confirmCommand: "nc-crm test --confirm", + }), + execute: async () => { + executed.called = true; + return { ok: true, data: null, count: 0, warnings: [], hints: [] }; + }, + }); + + expect(executed.called).toBe(false); + }); + + it("does not call plan when confirm is true", async () => { + const planned = { called: false }; + + await withConfirm({ + confirm: true, + plan: () => { + planned.called = true; + return { + action: "Test", + details: {}, + tier: "write" as Tier, + confirmCommand: "nc-crm test --confirm", + }; + }, + execute: async () => ({ ok: true, data: null, count: 0, warnings: [], hints: [] }), + }); + + expect(planned.called).toBe(false); + }); + + it("passes planData through when confirm is false", async () => { + const result = await withConfirm({ + confirm: false, + planData: { preview: "receipt #44" }, + plan: () => ({ + action: "Generate receipt", + details: { amount: "$250" }, + tier: "receipt" as Tier, + confirmCommand: "nc-crm receipts generate abc --confirm", + }), + execute: async () => ({ ok: true, data: null, count: 0, warnings: [], hints: [] }), + }); + + expect(result.data).toEqual({ preview: "receipt #44" }); + }); + + it("works with receipt tier", async () => { + const result = await withConfirm({ + confirm: false, + plan: () => ({ + action: "Void receipt #44", + details: { receiptNumber: 44 }, + tier: "receipt" as Tier, + confirmCommand: "nc-crm receipts void 44 --reason test --confirm", + }), + execute: async () => ({ ok: true, data: null, count: 0, warnings: [], hints: [] }), + }); + + expect(result.plan!.tier).toBe("receipt"); + }); + + it("propagates execute errors", async () => { + const result = await withConfirm({ + confirm: true, + plan: () => ({ + action: "Test", + details: {}, + tier: "write" as Tier, + confirmCommand: "nc-crm test --confirm", + }), + execute: async () => ({ ok: false, data: null, count: 0, warnings: ["Something failed"], hints: [] }), + }); + + expect(result.ok).toBe(false); + expect(result.warnings).toContain("Something failed"); + }); +}); diff --git a/crm/src/shared/with-confirm.ts b/crm/src/shared/with-confirm.ts new file mode 100644 index 00000000000..8e2cb519f37 --- /dev/null +++ b/crm/src/shared/with-confirm.ts @@ -0,0 +1,31 @@ +/** + * Confirmation wrapper — eliminates the repeated confirm/plan pattern. + * + * Every WRITE and RECEIPT tier command follows this pattern: + * 1. Validate inputs (caller does this before calling withConfirm) + * 2. If !confirm → return plan + * 3. If confirm → execute + * + * This function encapsulates steps 2-3. + */ + +import { needsConfirm, type CommandResult, type CommandPlan } from "../types.js"; + +export interface WithConfirmOpts { + /** Whether --confirm was passed */ + confirm: boolean; + /** Data to include in the plan result (e.g. receipt preview). Defaults to null. */ + planData?: T; + /** Build the plan (only called when confirm=false) */ + plan: () => CommandPlan; + /** Execute the action (only called when confirm=true) */ + execute: () => Promise>; +} + +export async function withConfirm(opts: WithConfirmOpts): Promise> { + if (!opts.confirm) { + const plan = opts.plan(); + return needsConfirm(opts.planData ?? null, plan) as CommandResult; + } + return opts.execute() as Promise>; +} From 36f338e34790c1525d634b973d3c7ea65252ebbe Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 19 Mar 2026 09:54:09 +0000 Subject: [PATCH 02/10] Split cli.ts into domain-specific command modules MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit cli.ts (396 lines) → 23 lines + 10 command modules in commands/. Each domain (contacts, donations, receipts, reports, orgs, config, jobs, search, stubs) registers its own subcommands via a register*Commands() function. createProgram() composes them all. cli.ts now only calls createProgram().parseAsync(). https://claude.ai/code/session_01D3rGyrcsVT96ZepUP66rjT --- crm/src/cli.ts | 381 +----------------------------- crm/src/commands/commands.test.ts | 86 +++++++ crm/src/commands/config.ts | 18 ++ crm/src/commands/contacts.ts | 119 ++++++++++ crm/src/commands/donations.ts | 54 +++++ crm/src/commands/index.ts | 54 +++++ crm/src/commands/jobs.ts | 18 ++ crm/src/commands/orgs.ts | 27 +++ crm/src/commands/receipts.ts | 43 ++++ crm/src/commands/reports.ts | 46 ++++ crm/src/commands/search.ts | 15 ++ crm/src/commands/stubs.ts | 21 ++ 12 files changed, 505 insertions(+), 377 deletions(-) create mode 100644 crm/src/commands/commands.test.ts create mode 100644 crm/src/commands/config.ts create mode 100644 crm/src/commands/contacts.ts create mode 100644 crm/src/commands/donations.ts create mode 100644 crm/src/commands/index.ts create mode 100644 crm/src/commands/jobs.ts create mode 100644 crm/src/commands/orgs.ts create mode 100644 crm/src/commands/receipts.ts create mode 100644 crm/src/commands/reports.ts create mode 100644 crm/src/commands/search.ts create mode 100644 crm/src/commands/stubs.ts diff --git a/crm/src/cli.ts b/crm/src/cli.ts index b2d85494057..c58a4d17e57 100644 --- a/crm/src/cli.ts +++ b/crm/src/cli.ts @@ -1,29 +1,5 @@ #!/usr/bin/env node -import { Command } from "commander"; -import { output } from "./types.js"; -import { contactsAdd } from "./contacts/add.js"; -import { contactsList } from "./contacts/list.js"; -import { contactsSearch } from "./contacts/search.js"; -import { contactsShow } from "./contacts/show.js"; -import { contactsEdit } from "./contacts/edit.js"; -import { contactsHistory } from "./contacts/history.js"; -import { contactsImport } from "./contacts/import.js"; -import { donationsAdd } from "./donations/add.js"; -import { donationsList } from "./donations/list.js"; -import { receiptsGenerate, receiptsBatch } from "./donations/receipt.js"; -import { receiptsVoid } from "./donations/void-receipt.js"; -import { donationsShow } from "./donations/show.js"; -import { donationsVoid } from "./donations/void-donation.js"; -import { orgsAdd } from "./orgs/add.js"; -import { configShow } from "./config/show.js"; -import { configSet } from "./config/set.js"; -import { reportSummary } from "./reports/summary.js"; -import { reportUnreceipted } from "./reports/unreceipted.js"; -import { reportDeadlines } from "./reports/deadlines.js"; -import { universalSearch } from "./search/universal.js"; -import { jobsHistory } from "./jobs/history.js"; - // --------------------------------------------------------------------------- // nc-crm — CRM tools for NanoClaw // @@ -33,364 +9,15 @@ import { jobsHistory } from "./jobs/history.js"; // The --confirm flag enforces the bright line: // - Without it: write/receipt commands output a plan // - With it: they execute +// +// Command registration is split by domain in commands/*.ts // --------------------------------------------------------------------------- -const program = new Command(); -program - .name("nc-crm") - .description("Nonprofit CRM tools for NanoClaw") - .version("0.1.0") - .option("--format ", "Output: json (default) or table"); - -const fmt = (): "json" | "table" => { - const f = program.opts().format; - if (f === "table") return "table"; - return "json"; -}; - -// ========================================================================= -// UNIVERSAL SEARCH (Feature 3) -// ========================================================================= - -program - .command("search") - .description("Search across contacts, orgs, and donations (READ tier)") - .argument("", "Search query") - .option("--type ", "Filter: contact, org, donation", undefined) - .option("-n, --limit ", "Max results", "20") - .action(async (query, opts) => { - output(await universalSearch(query, { type: opts.type, limit: parseInt(opts.limit) }), fmt()); - }); - -// ========================================================================= -// CONTACTS -// ========================================================================= - -const contacts = program.command("contacts").description("Contact management"); - -contacts - .command("add") - .description("Add a new contact (WRITE tier)") - .argument("", "First name") - .argument("", "Last name") - .option("-e, --email ", "Email address") - .option("-p, --phone ", "Phone number") - .option("--address-line1 ", "Street address") - .option("--address-line2 ", "Address line 2") - .option("--suburb ", "Suburb") - .option("--state ", "State (VIC, NSW, QLD, SA, WA, TAS, NT, ACT)") - .option("--postcode ", "4-digit postcode") - .option("-t, --type ", "Type: donor, volunteer, client, board, other", "other") - .option("--tag ", "Tags (key or key=value, repeatable)") - .option("--notes ", "Notes") - .option("--confirm", "Execute (without this flag, outputs plan only)", false) - .action(async (firstName, lastName, opts) => { - output(await contactsAdd(firstName, lastName, opts), fmt()); - }); - -contacts - .command("list") - .description("List contacts with filters (READ tier)") - .option("-t, --type ", "Filter by type") - .option("--tag ", "Filter by tag (AND logic)") - .option("-s, --search ", "Full-text search") - .option("--state ", "Filter by state") - .option("-n, --limit ", "Max results", "50") - .option("--offset ", "Pagination offset", "0") - .option("--sort ", "Sort field (prefix - for desc)", "lastName") - .action(async (opts) => { - output(await contactsList({ ...opts, limit: parseInt(opts.limit), offset: parseInt(opts.offset) }), fmt()); - }); - -contacts - .command("search") - .description("Fuzzy search contacts and orgs (READ tier)") - .argument("", "Search query") - .option("--type ", "contact, org, or all", "all") - .option("-n, --limit ", "Max results", "20") - .action(async (query, opts) => { - output(await contactsSearch(query, { type: opts.type, limit: parseInt(opts.limit) }), fmt()); - }); - -contacts - .command("show") - .argument("", "Contact UUID prefix (8+ chars)") - .description("Show full contact details (READ tier)") - .action(async (id) => { - output(await contactsShow(id), fmt()); - }); - -contacts - .command("edit") - .argument("", "Contact UUID prefix (8+ chars)") - .description("Edit a contact (WRITE tier)") - .option("--first-name ", "First name") - .option("--last-name ", "Last name") - .option("-e, --email ", "Email address") - .option("-p, --phone ", "Phone number") - .option("--address-line1 ", "Street address") - .option("--address-line2 ", "Address line 2") - .option("--suburb ", "Suburb") - .option("--state ", "State (VIC, NSW, QLD, SA, WA, TAS, NT, ACT)") - .option("--postcode ", "4-digit postcode") - .option("-t, --type ", "Type: donor, volunteer, client, board, other") - .option("--notes ", "Notes") - .option("--add-tag ", "Add tags (key or key=value, repeatable)") - .option("--remove-tag ", "Remove tags by key (repeatable)") - .option("--confirm", "Execute (without this flag, outputs plan only)", false) - .action(async (id, opts) => { - output(await contactsEdit(id, opts), fmt()); - }); - -contacts - .command("history") - .argument("", "Contact UUID prefix (8+ chars)") - .description("Activity timeline for a contact (READ tier)") - .option("-n, --limit ", "Max events", "50") - .option("--from ", "Start date filter") - .option("--to ", "End date filter") - .action(async (id, opts) => { - output(await contactsHistory(id, { limit: parseInt(opts.limit), from: opts.from, to: opts.to }), fmt()); - }); - -contacts - .command("import") - .argument("", "CSV file path") - .description("Import contacts from CSV (WRITE tier)") - .option("--map ", "Column mapping: \"CSV Column=fieldName\" (repeatable)") - .option("--preset ", "Column preset: salesforce") - .option("--on-duplicate ", "skip, update, or error", "skip") - .option("--tag ", "Tags to apply to all imported contacts") - .option("-t, --type ", "Contact type for all imports") - .option("--confirm", "Execute (without this flag, outputs plan only)", false) - .action(async (file, opts) => { - output(await contactsImport(file, opts), fmt()); - }); - -contacts.command("delete").argument("").description("Delete a contact (WRITE)").option("--confirm").action(stub("contacts.delete")); -contacts.command("export").description("Export contacts to CSV (READ)").option("-o, --output ").action(stub("contacts.export")); -contacts.command("dedup").description("Find duplicate contacts (READ)").action(stub("contacts.dedup")); -contacts.command("merge").arguments(" ").description("Merge two contacts (WRITE)").option("--confirm").action(stub("contacts.merge")); -contacts.command("link").arguments(" ").description("Link contact to org (WRITE)").option("--role ").option("--confirm").action(stub("contacts.link")); - -// ========================================================================= -// ORGANISATIONS -// ========================================================================= - -const orgs = program.command("orgs").description("Organisation management"); -orgs.command("add").argument("").description("Add organisation (WRITE)") - .option("--abn ", "ABN (11 digits)") - .option("--org-type ", "Type: charity, government, corporate, community, other", "other") - .option("--address-line1 ", "Street address") - .option("--suburb ", "Suburb") - .option("--state ", "State (VIC, NSW, QLD, SA, WA, TAS, NT, ACT)") - .option("--postcode ", "4-digit postcode") - .option("--phone ", "Phone number") - .option("--website ", "Website URL") - .option("--notes ", "Notes") - .option("--tag ", "Tags (key or key=value, repeatable)") - .option("--confirm", "Execute (without this flag, outputs plan only)", false) - .action(async (name, opts) => { - output(await orgsAdd(name, opts), fmt()); - }); -orgs.command("list").description("List organisations (READ)").action(stub("orgs.list")); -orgs.command("show").argument("").description("Show organisation (READ)").action(stub("orgs.show")); - -// ========================================================================= -// DONATIONS -// ========================================================================= - -const don = program.command("donations").description("Donation management"); -don.command("add").argument("").description("Record a donation (WRITE)") - .option("-c, --contact ", "Contact name/email/ID") - .option("-d, --date ", "Donation date", today()) - .option("-m, --method ", "cash, cheque, eft, card, in_kind, other") - .option("--fund ", "Fund allocation", "general") - .option("--campaign ", "Campaign attribution") - .option("-r, --reference ", "External reference") - .option("--no-dgr", "Not DGR-eligible") - .option("--notes ") - .option("--confirm", "", false) - .action(async (amount, opts) => { - output(await donationsAdd(amount, opts), fmt()); - }); - -don.command("list").description("List donations (READ)") - .option("-c, --contact ") - .option("--from ") - .option("--to ") - .option("-m, --method ") - .option("--fund ") - .option("--campaign ") - .option("--status ") - .option("--unreceipted", "Only unreceipted DGR-eligible") - .option("-n, --limit ", "", "50") - .option("--sort ", "", "-donationDate") - .action(async (opts) => { - output(await donationsList({ ...opts, limit: parseInt(opts.limit) }), fmt()); - }); - -don.command("show").argument("").description("Show donation (READ)") - .action(async (id) => { - output(await donationsShow(id), fmt()); - }); -don.command("edit").argument("").description("Edit donation (WRITE)").option("--confirm").action(stub("donations.edit")); -don.command("void").argument("").description("Void a donation (WRITE)") - .option("--reason ", "Reason for voiding (required with --confirm)") - .option("--confirm", "Execute (without this flag, outputs plan only)", false) - .action(async (id, opts) => { - output(await donationsVoid(id, opts), fmt()); - }); - -// ========================================================================= -// RECEIPTS -// ========================================================================= +import { createProgram } from "./commands/index.js"; -const rec = program.command("receipts").description("DGR receipt management"); - -rec - .command("generate") - .description("Generate a DGR receipt (RECEIPT tier — requires --confirm)") - .argument("", "Donation UUID") - .option("--send", "Email receipt to donor", false) - .option("--confirm", "Execute (without this, outputs plan only)", false) - .action(async (donationId, opts) => { - output(await receiptsGenerate(donationId, opts), fmt()); - }); - -rec - .command("batch") - .description("Batch generate receipts (RECEIPT tier — requires --confirm)") - .option("--from ", "Start date") - .option("--to ", "End date") - .option("--fund ", "Filter by fund") - .option("--send", "Email receipts", false) - .option("--confirm", "Execute", false) - .action(async (opts) => { - output(await receiptsBatch(opts), fmt()); - }); - -rec - .command("void") - .argument("", "Receipt number to void") - .description("Void a receipt (RECEIPT tier — requires --confirm + --reason)") - .option("--reason ", "Reason for voiding (required with --confirm)") - .option("--confirm", "Execute (without this, outputs plan only)", false) - .action(async (receiptNumber, opts) => { - output(await receiptsVoid(receiptNumber, opts), fmt()); - }); -rec.command("reprint").argument("").description("Reprint receipt with DUPLICATE watermark (READ)").action(stub("receipts.reprint")); - -// ========================================================================= -// STATEMENTS -// ========================================================================= - -const stmts = program.command("statements").description("EOFY tax statements"); -stmts.command("generate").argument("").description("Generate EOFY statement (RECEIPT)") - .option("--fy ", "Financial year: 2025-2026") - .option("--send", "Email statement", false) - .option("--confirm", "", false) - .action(stub("statements.generate")); - -// ========================================================================= -// REPORTS -// ========================================================================= - -const reports = program.command("reports").description("Donation reports (READ tier)"); -reports.command("summary").description("Totals by method and fund") - .option("--from ", "Start date (default: current AU FY start)") - .option("--to ", "End date (default: current AU FY end)") - .option("--campaign ", "Filter by campaign") - .action(async (opts) => { - output(await reportSummary(opts), fmt()); - }); -reports.command("by-donor").description("Totals per donor").option("--from ").option("--to ").action(stub("reports.by-donor")); -reports.command("by-fund").description("Totals per fund").option("--from ").option("--to ").action(stub("reports.by-fund")); -reports.command("by-month").description("Monthly trend").option("--from ").option("--to ").action(stub("reports.by-month")); -reports.command("lapsed").description("Donors who gave in period A but not B") - .option("--gave-from ").option("--gave-to ") - .option("--not-from ").option("--not-to ") - .action(stub("reports.lapsed")); -reports.command("unreceipted").description("Unreceipted DGR-eligible donations") - .action(async () => { - output(await reportUnreceipted(), fmt()); - }); -reports.command("deadlines").description("Upcoming deadlines and action items (READ tier)") - .option("--days ", "Look-ahead window in days", "30") - .action(async (opts) => { - output(await reportDeadlines({ days: parseInt(opts.days) }), fmt()); - }); - -// ========================================================================= -// DEADLINES (top-level alias for reports deadlines) (Feature 4) -// ========================================================================= - -program - .command("deadlines") - .description("Upcoming deadlines and action items (READ tier)") - .option("--days ", "Look-ahead window in days", "30") - .action(async (opts) => { - output(await reportDeadlines({ days: parseInt(opts.days) }), fmt()); - }); - -// ========================================================================= -// RECURRING -// ========================================================================= - -const rec2 = program.command("recurring").description("Recurring donations"); -rec2.command("add").argument("").description("Create recurring schedule (WRITE)") - .option("-c, --contact ").option("--frequency ").option("--start-date ") - .option("--confirm").action(stub("recurring.add")); -rec2.command("list").description("List recurring (READ)").option("--status ").action(stub("recurring.list")); -rec2.command("cancel").argument("").description("Cancel (WRITE)").option("--confirm").action(stub("recurring.cancel")); - -// ========================================================================= -// JOBS (Feature 5) -// ========================================================================= - -const jobs = program.command("jobs").description("Scheduled job management"); -jobs - .command("history") - .description("View job run history (READ tier)") - .option("--task ", "Filter by task ID") - .option("--status ", "Filter: success or error") - .option("--from ", "Start date filter") - .option("-n, --limit ", "Max results", "50") - .action(async (opts) => { - output(await jobsHistory({ task: opts.task, status: opts.status, from: opts.from, limit: parseInt(opts.limit) }), fmt()); - }); - -// ========================================================================= -// CONFIG -// ========================================================================= - -const cfg = program.command("config").description("Receipt configuration"); -cfg.command("show").description("Show current config (READ)").action(async () => { - output(await configShow(), fmt()); -}); -cfg.command("set").arguments(" ").description("Set config value (WRITE)") - .option("--confirm", "Execute (without this, outputs plan only)", false) - .action(async (key, value, opts) => { - output(await configSet(key, value, opts), fmt()); - }); - -// ========================================================================= +const program = createProgram(); program.parseAsync().catch((err) => { console.error(JSON.stringify({ ok: false, data: null, count: 0, warnings: [err.message], hints: [] })); process.exit(1); }); - -// --------------------------------------------------------------------------- -// Helpers -// --------------------------------------------------------------------------- - -function today(): string { - return new Date().toISOString().split("T")[0]!; -} - -function stub(name: string) { - return async (..._args: unknown[]) => { - output({ ok: false, data: null, count: 0, warnings: [`${name} not yet implemented`], hints: [] }); - }; -} diff --git a/crm/src/commands/commands.test.ts b/crm/src/commands/commands.test.ts new file mode 100644 index 00000000000..32a7a929a2f --- /dev/null +++ b/crm/src/commands/commands.test.ts @@ -0,0 +1,86 @@ +import { describe, it, expect } from "vitest"; +import { createProgram } from "./index.js"; + +describe("CLI command registration", () => { + const program = createProgram(); + + function getSubcommands(parent: any): string[] { + return parent.commands.map((c: any) => c.name()); + } + + it("registers top-level command groups", () => { + const names = getSubcommands(program); + expect(names).toContain("search"); + expect(names).toContain("contacts"); + expect(names).toContain("orgs"); + expect(names).toContain("donations"); + expect(names).toContain("receipts"); + expect(names).toContain("statements"); + expect(names).toContain("reports"); + expect(names).toContain("recurring"); + expect(names).toContain("jobs"); + expect(names).toContain("config"); + expect(names).toContain("deadlines"); + }); + + it("registers contacts subcommands", () => { + const contacts = program.commands.find((c: any) => c.name() === "contacts"); + const subs = getSubcommands(contacts); + expect(subs).toContain("add"); + expect(subs).toContain("list"); + expect(subs).toContain("search"); + expect(subs).toContain("show"); + expect(subs).toContain("edit"); + expect(subs).toContain("history"); + expect(subs).toContain("import"); + expect(subs).toContain("delete"); + expect(subs).toContain("export"); + expect(subs).toContain("dedup"); + expect(subs).toContain("merge"); + expect(subs).toContain("link"); + }); + + it("registers donations subcommands", () => { + const donations = program.commands.find((c: any) => c.name() === "donations"); + const subs = getSubcommands(donations); + expect(subs).toContain("add"); + expect(subs).toContain("list"); + expect(subs).toContain("show"); + expect(subs).toContain("edit"); + expect(subs).toContain("void"); + }); + + it("registers receipts subcommands", () => { + const receipts = program.commands.find((c: any) => c.name() === "receipts"); + const subs = getSubcommands(receipts); + expect(subs).toContain("generate"); + expect(subs).toContain("batch"); + expect(subs).toContain("void"); + expect(subs).toContain("reprint"); + }); + + it("registers reports subcommands", () => { + const reports = program.commands.find((c: any) => c.name() === "reports"); + const subs = getSubcommands(reports); + expect(subs).toContain("summary"); + expect(subs).toContain("by-donor"); + expect(subs).toContain("by-fund"); + expect(subs).toContain("by-month"); + expect(subs).toContain("lapsed"); + expect(subs).toContain("unreceipted"); + expect(subs).toContain("deadlines"); + }); + + it("registers config subcommands", () => { + const config = program.commands.find((c: any) => c.name() === "config"); + const subs = getSubcommands(config); + expect(subs).toContain("show"); + expect(subs).toContain("set"); + }); + + it("registers jobs subcommands", () => { + const jobs = program.commands.find((c: any) => c.name() === "jobs"); + const subs = getSubcommands(jobs); + expect(subs).toContain("history"); + }); +}); diff --git a/crm/src/commands/config.ts b/crm/src/commands/config.ts new file mode 100644 index 00000000000..8d2e740f672 --- /dev/null +++ b/crm/src/commands/config.ts @@ -0,0 +1,18 @@ +import type { Command } from "commander"; +import { output } from "../types.js"; +import { configShow } from "../config/show.js"; +import { configSet } from "../config/set.js"; + +export function registerConfigCommands(program: Command, fmt: () => "json" | "table") { + const cfg = program.command("config").description("Receipt configuration"); + + cfg.command("show").description("Show current config (READ)").action(async () => { + output(await configShow(), fmt()); + }); + + cfg.command("set").arguments(" ").description("Set config value (WRITE)") + .option("--confirm", "Execute (without this, outputs plan only)", false) + .action(async (key, value, opts) => { + output(await configSet(key, value, opts), fmt()); + }); +} diff --git a/crm/src/commands/contacts.ts b/crm/src/commands/contacts.ts new file mode 100644 index 00000000000..3f75e921050 --- /dev/null +++ b/crm/src/commands/contacts.ts @@ -0,0 +1,119 @@ +import type { Command } from "commander"; +import { output } from "../types.js"; +import { contactsAdd } from "../contacts/add.js"; +import { contactsList } from "../contacts/list.js"; +import { contactsSearch } from "../contacts/search.js"; +import { contactsShow } from "../contacts/show.js"; +import { contactsEdit } from "../contacts/edit.js"; +import { contactsHistory } from "../contacts/history.js"; +import { contactsImport } from "../contacts/import.js"; +import { stub } from "./index.js"; + +export function registerContactsCommands(program: Command, fmt: () => "json" | "table") { + const contacts = program.command("contacts").description("Contact management"); + + contacts + .command("add") + .description("Add a new contact (WRITE tier)") + .argument("", "First name") + .argument("", "Last name") + .option("-e, --email ", "Email address") + .option("-p, --phone ", "Phone number") + .option("--address-line1 ", "Street address") + .option("--address-line2 ", "Address line 2") + .option("--suburb ", "Suburb") + .option("--state ", "State (VIC, NSW, QLD, SA, WA, TAS, NT, ACT)") + .option("--postcode ", "4-digit postcode") + .option("-t, --type ", "Type: donor, volunteer, client, board, other", "other") + .option("--tag ", "Tags (key or key=value, repeatable)") + .option("--notes ", "Notes") + .option("--confirm", "Execute (without this flag, outputs plan only)", false) + .action(async (firstName, lastName, opts) => { + output(await contactsAdd(firstName, lastName, opts), fmt()); + }); + + contacts + .command("list") + .description("List contacts with filters (READ tier)") + .option("-t, --type ", "Filter by type") + .option("--tag ", "Filter by tag (AND logic)") + .option("-s, --search ", "Full-text search") + .option("--state ", "Filter by state") + .option("-n, --limit ", "Max results", "50") + .option("--offset ", "Pagination offset", "0") + .option("--sort ", "Sort field (prefix - for desc)", "lastName") + .action(async (opts) => { + output(await contactsList({ ...opts, limit: parseInt(opts.limit), offset: parseInt(opts.offset) }), fmt()); + }); + + contacts + .command("search") + .description("Fuzzy search contacts and orgs (READ tier)") + .argument("", "Search query") + .option("--type ", "contact, org, or all", "all") + .option("-n, --limit ", "Max results", "20") + .action(async (query, opts) => { + output(await contactsSearch(query, { type: opts.type, limit: parseInt(opts.limit) }), fmt()); + }); + + contacts + .command("show") + .argument("", "Contact UUID prefix (8+ chars)") + .description("Show full contact details (READ tier)") + .action(async (id) => { + output(await contactsShow(id), fmt()); + }); + + contacts + .command("edit") + .argument("", "Contact UUID prefix (8+ chars)") + .description("Edit a contact (WRITE tier)") + .option("--first-name ", "First name") + .option("--last-name ", "Last name") + .option("-e, --email ", "Email address") + .option("-p, --phone ", "Phone number") + .option("--address-line1 ", "Street address") + .option("--address-line2 ", "Address line 2") + .option("--suburb ", "Suburb") + .option("--state ", "State (VIC, NSW, QLD, SA, WA, TAS, NT, ACT)") + .option("--postcode ", "4-digit postcode") + .option("-t, --type ", "Type: donor, volunteer, client, board, other") + .option("--notes ", "Notes") + .option("--add-tag ", "Add tags (key or key=value, repeatable)") + .option("--remove-tag ", "Remove tags by key (repeatable)") + .option("--confirm", "Execute (without this flag, outputs plan only)", false) + .action(async (id, opts) => { + output(await contactsEdit(id, opts), fmt()); + }); + + contacts + .command("history") + .argument("", "Contact UUID prefix (8+ chars)") + .description("Activity timeline for a contact (READ tier)") + .option("-n, --limit ", "Max events", "50") + .option("--from ", "Start date filter") + .option("--to ", "End date filter") + .action(async (id, opts) => { + output(await contactsHistory(id, { limit: parseInt(opts.limit), from: opts.from, to: opts.to }), fmt()); + }); + + contacts + .command("import") + .argument("", "CSV file path") + .description("Import contacts from CSV (WRITE tier)") + .option("--map ", "Column mapping: \"CSV Column=fieldName\" (repeatable)") + .option("--preset ", "Column preset: salesforce") + .option("--on-duplicate ", "skip, update, or error", "skip") + .option("--tag ", "Tags to apply to all imported contacts") + .option("-t, --type ", "Contact type for all imports") + .option("--confirm", "Execute (without this flag, outputs plan only)", false) + .action(async (file, opts) => { + output(await contactsImport(file, opts), fmt()); + }); + + contacts.command("delete").argument("").description("Delete a contact (WRITE)").option("--confirm").action(stub("contacts.delete")); + contacts.command("export").description("Export contacts to CSV (READ)").option("-o, --output ").action(stub("contacts.export")); + contacts.command("dedup").description("Find duplicate contacts (READ)").action(stub("contacts.dedup")); + contacts.command("merge").arguments(" ").description("Merge two contacts (WRITE)").option("--confirm").action(stub("contacts.merge")); + contacts.command("link").arguments(" ").description("Link contact to org (WRITE)").option("--role ").option("--confirm").action(stub("contacts.link")); +} diff --git a/crm/src/commands/donations.ts b/crm/src/commands/donations.ts new file mode 100644 index 00000000000..3b3b9f697ae --- /dev/null +++ b/crm/src/commands/donations.ts @@ -0,0 +1,54 @@ +import type { Command } from "commander"; +import { output } from "../types.js"; +import { donationsAdd } from "../donations/add.js"; +import { donationsList } from "../donations/list.js"; +import { donationsShow } from "../donations/show.js"; +import { donationsVoid } from "../donations/void-donation.js"; +import { stub, today } from "./index.js"; + +export function registerDonationsCommands(program: Command, fmt: () => "json" | "table") { + const don = program.command("donations").description("Donation management"); + + don.command("add").argument("").description("Record a donation (WRITE)") + .option("-c, --contact ", "Contact name/email/ID") + .option("-d, --date ", "Donation date", today()) + .option("-m, --method ", "cash, cheque, eft, card, in_kind, other") + .option("--fund ", "Fund allocation", "general") + .option("--campaign ", "Campaign attribution") + .option("-r, --reference ", "External reference") + .option("--no-dgr", "Not DGR-eligible") + .option("--notes ") + .option("--confirm", "", false) + .action(async (amount, opts) => { + output(await donationsAdd(amount, opts), fmt()); + }); + + don.command("list").description("List donations (READ)") + .option("-c, --contact ") + .option("--from ") + .option("--to ") + .option("-m, --method ") + .option("--fund ") + .option("--campaign ") + .option("--status ") + .option("--unreceipted", "Only unreceipted DGR-eligible") + .option("-n, --limit ", "", "50") + .option("--sort ", "", "-donationDate") + .action(async (opts) => { + output(await donationsList({ ...opts, limit: parseInt(opts.limit) }), fmt()); + }); + + don.command("show").argument("").description("Show donation (READ)") + .action(async (id) => { + output(await donationsShow(id), fmt()); + }); + + don.command("edit").argument("").description("Edit donation (WRITE)").option("--confirm").action(stub("donations.edit")); + + don.command("void").argument("").description("Void a donation (WRITE)") + .option("--reason ", "Reason for voiding (required with --confirm)") + .option("--confirm", "Execute (without this flag, outputs plan only)", false) + .action(async (id, opts) => { + output(await donationsVoid(id, opts), fmt()); + }); +} diff --git a/crm/src/commands/index.ts b/crm/src/commands/index.ts new file mode 100644 index 00000000000..6f3d8bd00e4 --- /dev/null +++ b/crm/src/commands/index.ts @@ -0,0 +1,54 @@ +/** + * CLI command registration — split by domain. + * + * createProgram() builds the full Commander program tree. + * cli.ts calls createProgram().parseAsync(). + */ + +import { Command } from "commander"; +import { registerContactsCommands } from "./contacts.js"; +import { registerDonationsCommands } from "./donations.js"; +import { registerReceiptsCommands } from "./receipts.js"; +import { registerReportsCommands } from "./reports.js"; +import { registerOrgsCommands } from "./orgs.js"; +import { registerConfigCommands } from "./config.js"; +import { registerJobsCommands } from "./jobs.js"; +import { registerSearchCommands } from "./search.js"; +import { registerStubCommands } from "./stubs.js"; + +export function createProgram(): Command { + const program = new Command(); + program + .name("nc-crm") + .description("Nonprofit CRM tools for NanoClaw") + .version("0.1.0") + .option("--format ", "Output: json (default) or table"); + + const fmt = (): "json" | "table" => { + const f = program.opts().format; + if (f === "table") return "table"; + return "json"; + }; + + registerSearchCommands(program, fmt); + registerContactsCommands(program, fmt); + registerOrgsCommands(program, fmt); + registerDonationsCommands(program, fmt); + registerReceiptsCommands(program, fmt); + registerReportsCommands(program, fmt); + registerConfigCommands(program, fmt); + registerJobsCommands(program, fmt); + registerStubCommands(program, fmt); + + return program; +} + +export function today(): string { + return new Date().toISOString().split("T")[0]!; +} + +export function stub(name: string) { + return async (..._args: unknown[]) => { + return { ok: false, data: null, count: 0, warnings: [`${name} not yet implemented`], hints: [] }; + }; +} diff --git a/crm/src/commands/jobs.ts b/crm/src/commands/jobs.ts new file mode 100644 index 00000000000..75e946c4386 --- /dev/null +++ b/crm/src/commands/jobs.ts @@ -0,0 +1,18 @@ +import type { Command } from "commander"; +import { output } from "../types.js"; +import { jobsHistory } from "../jobs/history.js"; + +export function registerJobsCommands(program: Command, fmt: () => "json" | "table") { + const jobs = program.command("jobs").description("Scheduled job management"); + + jobs + .command("history") + .description("View job run history (READ tier)") + .option("--task ", "Filter by task ID") + .option("--status ", "Filter: success or error") + .option("--from ", "Start date filter") + .option("-n, --limit ", "Max results", "50") + .action(async (opts) => { + output(await jobsHistory({ task: opts.task, status: opts.status, from: opts.from, limit: parseInt(opts.limit) }), fmt()); + }); +} diff --git a/crm/src/commands/orgs.ts b/crm/src/commands/orgs.ts new file mode 100644 index 00000000000..c7f6ae14a8d --- /dev/null +++ b/crm/src/commands/orgs.ts @@ -0,0 +1,27 @@ +import type { Command } from "commander"; +import { output } from "../types.js"; +import { orgsAdd } from "../orgs/add.js"; +import { stub } from "./index.js"; + +export function registerOrgsCommands(program: Command, fmt: () => "json" | "table") { + const orgs = program.command("orgs").description("Organisation management"); + + orgs.command("add").argument("").description("Add organisation (WRITE)") + .option("--abn ", "ABN (11 digits)") + .option("--org-type ", "Type: charity, government, corporate, community, other", "other") + .option("--address-line1 ", "Street address") + .option("--suburb ", "Suburb") + .option("--state ", "State (VIC, NSW, QLD, SA, WA, TAS, NT, ACT)") + .option("--postcode ", "4-digit postcode") + .option("--phone ", "Phone number") + .option("--website ", "Website URL") + .option("--notes ", "Notes") + .option("--tag ", "Tags (key or key=value, repeatable)") + .option("--confirm", "Execute (without this flag, outputs plan only)", false) + .action(async (name, opts) => { + output(await orgsAdd(name, opts), fmt()); + }); + + orgs.command("list").description("List organisations (READ)").action(stub("orgs.list")); + orgs.command("show").argument("").description("Show organisation (READ)").action(stub("orgs.show")); +} diff --git a/crm/src/commands/receipts.ts b/crm/src/commands/receipts.ts new file mode 100644 index 00000000000..4173879e441 --- /dev/null +++ b/crm/src/commands/receipts.ts @@ -0,0 +1,43 @@ +import type { Command } from "commander"; +import { output } from "../types.js"; +import { receiptsGenerate, receiptsBatch } from "../donations/receipt.js"; +import { receiptsVoid } from "../donations/void-receipt.js"; +import { stub } from "./index.js"; + +export function registerReceiptsCommands(program: Command, fmt: () => "json" | "table") { + const rec = program.command("receipts").description("DGR receipt management"); + + rec + .command("generate") + .description("Generate a DGR receipt (RECEIPT tier — requires --confirm)") + .argument("", "Donation UUID") + .option("--send", "Email receipt to donor", false) + .option("--confirm", "Execute (without this, outputs plan only)", false) + .action(async (donationId, opts) => { + output(await receiptsGenerate(donationId, opts), fmt()); + }); + + rec + .command("batch") + .description("Batch generate receipts (RECEIPT tier — requires --confirm)") + .option("--from ", "Start date") + .option("--to ", "End date") + .option("--fund ", "Filter by fund") + .option("--send", "Email receipts", false) + .option("--confirm", "Execute", false) + .action(async (opts) => { + output(await receiptsBatch(opts), fmt()); + }); + + rec + .command("void") + .argument("", "Receipt number to void") + .description("Void a receipt (RECEIPT tier — requires --confirm + --reason)") + .option("--reason ", "Reason for voiding (required with --confirm)") + .option("--confirm", "Execute (without this, outputs plan only)", false) + .action(async (receiptNumber, opts) => { + output(await receiptsVoid(receiptNumber, opts), fmt()); + }); + + rec.command("reprint").argument("").description("Reprint receipt with DUPLICATE watermark (READ)").action(stub("receipts.reprint")); +} diff --git a/crm/src/commands/reports.ts b/crm/src/commands/reports.ts new file mode 100644 index 00000000000..bc045968135 --- /dev/null +++ b/crm/src/commands/reports.ts @@ -0,0 +1,46 @@ +import type { Command } from "commander"; +import { output } from "../types.js"; +import { reportSummary } from "../reports/summary.js"; +import { reportUnreceipted } from "../reports/unreceipted.js"; +import { reportDeadlines } from "../reports/deadlines.js"; +import { stub } from "./index.js"; + +export function registerReportsCommands(program: Command, fmt: () => "json" | "table") { + const reports = program.command("reports").description("Donation reports (READ tier)"); + + reports.command("summary").description("Totals by method and fund") + .option("--from ", "Start date (default: current AU FY start)") + .option("--to ", "End date (default: current AU FY end)") + .option("--campaign ", "Filter by campaign") + .action(async (opts) => { + output(await reportSummary(opts), fmt()); + }); + + reports.command("by-donor").description("Totals per donor").option("--from ").option("--to ").action(stub("reports.by-donor")); + reports.command("by-fund").description("Totals per fund").option("--from ").option("--to ").action(stub("reports.by-fund")); + reports.command("by-month").description("Monthly trend").option("--from ").option("--to ").action(stub("reports.by-month")); + reports.command("lapsed").description("Donors who gave in period A but not B") + .option("--gave-from ").option("--gave-to ") + .option("--not-from ").option("--not-to ") + .action(stub("reports.lapsed")); + + reports.command("unreceipted").description("Unreceipted DGR-eligible donations") + .action(async () => { + output(await reportUnreceipted(), fmt()); + }); + + reports.command("deadlines").description("Upcoming deadlines and action items (READ tier)") + .option("--days ", "Look-ahead window in days", "30") + .action(async (opts) => { + output(await reportDeadlines({ days: parseInt(opts.days) }), fmt()); + }); + + // Top-level alias for reports deadlines + program + .command("deadlines") + .description("Upcoming deadlines and action items (READ tier)") + .option("--days ", "Look-ahead window in days", "30") + .action(async (opts) => { + output(await reportDeadlines({ days: parseInt(opts.days) }), fmt()); + }); +} diff --git a/crm/src/commands/search.ts b/crm/src/commands/search.ts new file mode 100644 index 00000000000..f17467717f1 --- /dev/null +++ b/crm/src/commands/search.ts @@ -0,0 +1,15 @@ +import type { Command } from "commander"; +import { output } from "../types.js"; +import { universalSearch } from "../search/universal.js"; + +export function registerSearchCommands(program: Command, fmt: () => "json" | "table") { + program + .command("search") + .description("Search across contacts, orgs, and donations (READ tier)") + .argument("", "Search query") + .option("--type ", "Filter: contact, org, donation", undefined) + .option("-n, --limit ", "Max results", "20") + .action(async (query, opts) => { + output(await universalSearch(query, { type: opts.type, limit: parseInt(opts.limit) }), fmt()); + }); +} diff --git a/crm/src/commands/stubs.ts b/crm/src/commands/stubs.ts new file mode 100644 index 00000000000..85d1f067717 --- /dev/null +++ b/crm/src/commands/stubs.ts @@ -0,0 +1,21 @@ +import type { Command } from "commander"; +import { output } from "../types.js"; +import { stub } from "./index.js"; + +export function registerStubCommands(program: Command, _fmt: () => "json" | "table") { + // Statements + const stmts = program.command("statements").description("EOFY tax statements"); + stmts.command("generate").argument("").description("Generate EOFY statement (RECEIPT)") + .option("--fy ", "Financial year: 2025-2026") + .option("--send", "Email statement", false) + .option("--confirm", "", false) + .action(stub("statements.generate")); + + // Recurring + const rec = program.command("recurring").description("Recurring donations"); + rec.command("add").argument("").description("Create recurring schedule (WRITE)") + .option("-c, --contact ").option("--frequency ").option("--start-date ") + .option("--confirm").action(stub("recurring.add")); + rec.command("list").description("List recurring (READ)").option("--status ").action(stub("recurring.list")); + rec.command("cancel").argument("").description("Cancel (WRITE)").option("--confirm").action(stub("recurring.cancel")); +} From a18e759c3072ba7729b8518bf0d769011b270722 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 19 Mar 2026 09:57:59 +0000 Subject: [PATCH 03/10] Replace `as any` type assertions with proper typed lookups - Add resolveSortColumn() helper to replace (schema.table as any)[field] in contacts/list.ts and donations/list.ts - Type condition arrays as SQL[] instead of any[] - Add PdfConfig interface for receipt PDF generation - Type raw SQL query results (sequence, search, summary) - Replace (existing as any)[colName] with typed access in config/set.ts Production code now has zero `as any` (remaining are in test helpers). https://claude.ai/code/session_01D3rGyrcsVT96ZepUP66rjT --- crm/src/config/set.ts | 2 +- crm/src/contacts/list.ts | 7 ++++--- crm/src/contacts/search.ts | 4 ++-- crm/src/db/sort.test.ts | 35 +++++++++++++++++++++++++++++++++++ crm/src/db/sort.ts | 25 +++++++++++++++++++++++++ crm/src/donations/list.ts | 7 ++++--- crm/src/donations/receipt.ts | 21 +++++++++++++++------ crm/src/reports/summary.ts | 10 +++++++--- 8 files changed, 93 insertions(+), 18 deletions(-) create mode 100644 crm/src/db/sort.test.ts create mode 100644 crm/src/db/sort.ts diff --git a/crm/src/config/set.ts b/crm/src/config/set.ts index 193b8423170..27b8893c197 100644 --- a/crm/src/config/set.ts +++ b/crm/src/config/set.ts @@ -62,7 +62,7 @@ export async function configSet( // Upsert: check if config row exists const [existing] = await db.select().from(schema.receiptConfig).limit(1); - const oldValue = existing ? (existing as any)[colName] : null; + const oldValue = existing ? existing[colName] : null; if (existing) { await db.update(schema.receiptConfig) diff --git a/crm/src/contacts/list.ts b/crm/src/contacts/list.ts index 43897233342..cd5cf6edb6f 100644 --- a/crm/src/contacts/list.ts +++ b/crm/src/contacts/list.ts @@ -1,6 +1,7 @@ -import { eq, and, sql, desc, asc } from "drizzle-orm"; +import { eq, and, sql, desc, asc, type SQL } from "drizzle-orm"; import { connect, schema } from "../db/connection.js"; import { ok, type CommandResult, type ContactRow } from "../types.js"; +import { resolveSortColumn } from "../db/sort.js"; interface ListOpts { type?: string; @@ -14,7 +15,7 @@ interface ListOpts { export async function contactsList(opts: ListOpts): Promise> { const db = connect(); - const conditions: any[] = [sql`${schema.contacts.mergedInto} IS NULL`]; + const conditions: SQL[] = [sql`${schema.contacts.mergedInto} IS NULL`]; if (opts.type) conditions.push(eq(schema.contacts.contactType, opts.type)); if (opts.state) conditions.push(eq(schema.contacts.state, opts.state)); @@ -47,7 +48,7 @@ export async function contactsList(opts: ListOpts): Promise 0.3 ORDER BY score DESC LIMIT ${opts.limit} `); - for (const r of rows as any[]) { + for (const r of rows as { id: string; name: string; score: string }[]) { results.push({ id: r.id.slice(0, 8), type: "org", name: r.name, email: null, score: parseFloat(r.score) }); } } diff --git a/crm/src/db/sort.test.ts b/crm/src/db/sort.test.ts new file mode 100644 index 00000000000..027dd98181c --- /dev/null +++ b/crm/src/db/sort.test.ts @@ -0,0 +1,35 @@ +import { describe, it, expect } from "vitest"; +import { resolveSortColumn } from "./sort.js"; +import * as schema from "./schema.js"; + +describe("resolveSortColumn", () => { + it("resolves a valid contact column", () => { + const col = resolveSortColumn(schema.contacts, "lastName"); + expect(col).toBe(schema.contacts.lastName); + }); + + it("resolves a valid contact column with different name", () => { + const col = resolveSortColumn(schema.contacts, "email"); + expect(col).toBe(schema.contacts.email); + }); + + it("returns fallback for unknown column", () => { + const col = resolveSortColumn(schema.contacts, "nonexistent", schema.contacts.lastName); + expect(col).toBe(schema.contacts.lastName); + }); + + it("resolves a valid donation column", () => { + const col = resolveSortColumn(schema.donations, "donationDate"); + expect(col).toBe(schema.donations.donationDate); + }); + + it("returns fallback for unknown donation column", () => { + const col = resolveSortColumn(schema.donations, "badField", schema.donations.donationDate); + expect(col).toBe(schema.donations.donationDate); + }); + + it("returns undefined when no fallback and column not found", () => { + const col = resolveSortColumn(schema.contacts, "nonexistent"); + expect(col).toBeUndefined(); + }); +}); diff --git a/crm/src/db/sort.ts b/crm/src/db/sort.ts new file mode 100644 index 00000000000..07524a04a4a --- /dev/null +++ b/crm/src/db/sort.ts @@ -0,0 +1,25 @@ +/** + * Type-safe sort column resolution for Drizzle tables. + * + * Replaces `(schema.table as any)[fieldName]` with a checked lookup. + */ + +import type { PgTableWithColumns, TableConfig } from "drizzle-orm/pg-core"; +import type { Column } from "drizzle-orm"; + +/** + * Resolve a user-provided sort field name to a Drizzle column. + * Returns the column if it exists on the table, otherwise the fallback. + */ +export function resolveSortColumn( + table: PgTableWithColumns, + field: string, + fallback?: Column, +): Column | undefined { + const columns = table as Record; + const col = columns[field]; + if (col && typeof col === "object" && "name" in col) { + return col as Column; + } + return fallback; +} diff --git a/crm/src/donations/list.ts b/crm/src/donations/list.ts index 3e65354f84b..95bcfa16163 100644 --- a/crm/src/donations/list.ts +++ b/crm/src/donations/list.ts @@ -1,7 +1,8 @@ -import { eq, and, sql, desc, asc } from "drizzle-orm"; +import { eq, and, sql, desc, asc, type SQL } from "drizzle-orm"; import { connect, schema } from "../db/connection.js"; import { ok, type CommandResult, type Donation } from "../types.js"; import { resolveContact } from "./resolve-contact.js"; +import { resolveSortColumn } from "../db/sort.js"; interface ListOpts { contact?: string; @@ -18,7 +19,7 @@ interface ListOpts { export async function donationsList(opts: ListOpts): Promise> { const db = connect(); - const conditions: any[] = []; + const conditions: SQL[] = []; // Exclude voided by default unless explicitly requested if (opts.status) { @@ -50,7 +51,7 @@ export async function donationsList(opts: ListOpts): Promise { @@ -273,13 +273,22 @@ export async function receiptsBatch(opts: BatchOpts): Promise { diff --git a/crm/src/reports/summary.ts b/crm/src/reports/summary.ts index f12a23b1fc4..820ee2299ed 100644 --- a/crm/src/reports/summary.ts +++ b/crm/src/reports/summary.ts @@ -52,7 +52,7 @@ export async function reportSummary(opts: SummaryOpts): Promise !r.receipts?.id).length; + // Drizzle leftJoin returns { donations: {...}, receipts: {...} | null } + const unreceiptedCount = unreceipted.filter((r) => { + const row = r as { receipts: { id: string } | null }; + return !row.receipts?.id; + }).length; if (unreceiptedCount > 0) { result.hints.push(`${unreceiptedCount} unreceipted DGR-eligible donation${unreceiptedCount !== 1 ? "s" : ""} — run \`nc-crm reports unreceipted\` for details`); } From 359d8e09626a468d6426419933497850ba0ed4a5 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 19 Mar 2026 10:31:56 +0000 Subject: [PATCH 04/10] Split container-runner.ts into focused modules - container-mounts.ts: buildVolumeMounts() + buildContainerArgs() (210 lines) - ipc-snapshots.ts: writeTasksSnapshot() + writeGroupsSnapshot() (70 lines) - container-runner.ts: runContainerAgent() stays, imports from new modules Re-exports from container-runner.ts preserve all existing import paths. https://claude.ai/code/session_01D3rGyrcsVT96ZepUP66rjT --- src/container-mounts.ts | 239 ++++++++++++++++++++++++++++++ src/container-runner.ts | 301 ++------------------------------------ src/ipc-snapshots.test.ts | 95 ++++++++++++ src/ipc-snapshots.ts | 72 +++++++++ 4 files changed, 417 insertions(+), 290 deletions(-) create mode 100644 src/container-mounts.ts create mode 100644 src/ipc-snapshots.test.ts create mode 100644 src/ipc-snapshots.ts diff --git a/src/container-mounts.ts b/src/container-mounts.ts new file mode 100644 index 00000000000..824887989b3 --- /dev/null +++ b/src/container-mounts.ts @@ -0,0 +1,239 @@ +/** + * Container volume mount and argument building. + * + * Extracted from container-runner.ts to keep the main runner focused on + * process lifecycle management. + */ +import fs from 'fs'; +import path from 'path'; + +import { + CONTAINER_IMAGE, + CREDENTIAL_PROXY_PORT, + DATA_DIR, + GROUPS_DIR, + TIMEZONE, +} from './config.js'; +import { resolveGroupFolderPath, resolveGroupIpcPath } from './group-folder.js'; +import { + CONTAINER_HOST_GATEWAY, + hostGatewayArgs, + readonlyMountArgs, +} from './container-runtime.js'; +import { detectAuthMode } from './credential-proxy.js'; +import { validateAdditionalMounts } from './mount-security.js'; +import { RegisteredGroup } from './types.js'; + +export interface VolumeMount { + hostPath: string; + containerPath: string; + readonly: boolean; +} + +export function buildVolumeMounts( + group: RegisteredGroup, + isMain: boolean, +): VolumeMount[] { + const mounts: VolumeMount[] = []; + const projectRoot = process.cwd(); + const groupDir = resolveGroupFolderPath(group.folder); + + if (isMain) { + // Main gets the project root read-only. Writable paths the agent needs + // (group folder, IPC, .claude/) are mounted separately below. + // Read-only prevents the agent from modifying host application code + // (src/, dist/, package.json, etc.) which would bypass the sandbox + // entirely on next restart. + mounts.push({ + hostPath: projectRoot, + containerPath: '/workspace/project', + readonly: true, + }); + + // Shadow .env so the agent cannot read secrets from the mounted project root. + // Credentials are injected by the credential proxy, never exposed to containers. + const envFile = path.join(projectRoot, '.env'); + if (fs.existsSync(envFile)) { + mounts.push({ + hostPath: '/dev/null', + containerPath: '/workspace/project/.env', + readonly: true, + }); + } + + // Main also gets its group folder as the working directory + mounts.push({ + hostPath: groupDir, + containerPath: '/workspace/group', + readonly: false, + }); + } else { + // Other groups only get their own folder + mounts.push({ + hostPath: groupDir, + containerPath: '/workspace/group', + readonly: false, + }); + + // Global memory directory (read-only for non-main) + // Only directory mounts are supported, not file mounts + const globalDir = path.join(GROUPS_DIR, 'global'); + if (fs.existsSync(globalDir)) { + mounts.push({ + hostPath: globalDir, + containerPath: '/workspace/global', + readonly: true, + }); + } + } + + // Per-group Claude sessions directory (isolated from other groups) + // Each group gets their own .claude/ to prevent cross-group session access + const groupSessionsDir = path.join( + DATA_DIR, + 'sessions', + group.folder, + '.claude', + ); + fs.mkdirSync(groupSessionsDir, { recursive: true }); + const settingsFile = path.join(groupSessionsDir, 'settings.json'); + if (!fs.existsSync(settingsFile)) { + fs.writeFileSync( + settingsFile, + JSON.stringify( + { + env: { + // Enable agent swarms (subagent orchestration) + // https://code.claude.com/docs/en/agent-teams#orchestrate-teams-of-claude-code-sessions + CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS: '1', + // Load CLAUDE.md from additional mounted directories + // https://code.claude.com/docs/en/memory#load-memory-from-additional-directories + CLAUDE_CODE_ADDITIONAL_DIRECTORIES_CLAUDE_MD: '1', + // Enable Claude's memory feature (persists user preferences between sessions) + // https://code.claude.com/docs/en/memory#manage-auto-memory + CLAUDE_CODE_DISABLE_AUTO_MEMORY: '0', + }, + }, + null, + 2, + ) + '\n', + ); + } + + // Sync skills from container/skills/ into each group's .claude/skills/ + const skillsSrc = path.join(process.cwd(), 'container', 'skills'); + const skillsDst = path.join(groupSessionsDir, 'skills'); + if (fs.existsSync(skillsSrc)) { + for (const skillDir of fs.readdirSync(skillsSrc)) { + const srcDir = path.join(skillsSrc, skillDir); + if (!fs.statSync(srcDir).isDirectory()) continue; + const dstDir = path.join(skillsDst, skillDir); + fs.cpSync(srcDir, dstDir, { recursive: true }); + } + } + mounts.push({ + hostPath: groupSessionsDir, + containerPath: '/home/node/.claude', + readonly: false, + }); + + // Per-group IPC namespace: each group gets its own IPC directory + // This prevents cross-group privilege escalation via IPC + const groupIpcDir = resolveGroupIpcPath(group.folder); + fs.mkdirSync(path.join(groupIpcDir, 'messages'), { recursive: true }); + fs.mkdirSync(path.join(groupIpcDir, 'tasks'), { recursive: true }); + fs.mkdirSync(path.join(groupIpcDir, 'input'), { recursive: true }); + mounts.push({ + hostPath: groupIpcDir, + containerPath: '/workspace/ipc', + readonly: false, + }); + + // Copy agent-runner source into a per-group writable location so agents + // can customize it (add tools, change behavior) without affecting other + // groups. Recompiled on container startup via entrypoint.sh. + const agentRunnerSrc = path.join( + projectRoot, + 'container', + 'agent-runner', + 'src', + ); + const groupAgentRunnerDir = path.join( + DATA_DIR, + 'sessions', + group.folder, + 'agent-runner-src', + ); + if (!fs.existsSync(groupAgentRunnerDir) && fs.existsSync(agentRunnerSrc)) { + fs.cpSync(agentRunnerSrc, groupAgentRunnerDir, { recursive: true }); + } + mounts.push({ + hostPath: groupAgentRunnerDir, + containerPath: '/app/src', + readonly: false, + }); + + // Additional mounts validated against external allowlist (tamper-proof from containers) + if (group.containerConfig?.additionalMounts) { + const validatedMounts = validateAdditionalMounts( + group.containerConfig.additionalMounts, + group.name, + isMain, + ); + mounts.push(...validatedMounts); + } + + return mounts; +} + +export function buildContainerArgs( + mounts: VolumeMount[], + containerName: string, +): string[] { + const args: string[] = ['run', '-i', '--rm', '--name', containerName]; + + // Pass host timezone so container's local time matches the user's + args.push('-e', `TZ=${TIMEZONE}`); + + // Route API traffic through the credential proxy (containers never see real secrets) + args.push( + '-e', + `ANTHROPIC_BASE_URL=http://${CONTAINER_HOST_GATEWAY}:${CREDENTIAL_PROXY_PORT}`, + ); + + // Mirror the host's auth method with a placeholder value. + // API key mode: SDK sends x-api-key, proxy replaces with real key. + // OAuth mode: SDK exchanges placeholder token for temp API key, + // proxy injects real OAuth token on that exchange request. + const authMode = detectAuthMode(); + if (authMode === 'api-key') { + args.push('-e', 'ANTHROPIC_API_KEY=placeholder'); + } else { + args.push('-e', 'CLAUDE_CODE_OAUTH_TOKEN=placeholder'); + } + + // Runtime-specific args for host gateway resolution + args.push(...hostGatewayArgs()); + + // Run as host user so bind-mounted files are accessible. + // Skip when running as root (uid 0), as the container's node user (uid 1000), + // or when getuid is unavailable (native Windows without WSL). + const hostUid = process.getuid?.(); + const hostGid = process.getgid?.(); + if (hostUid != null && hostUid !== 0 && hostUid !== 1000) { + args.push('--user', `${hostUid}:${hostGid}`); + args.push('-e', 'HOME=/home/node'); + } + + for (const mount of mounts) { + if (mount.readonly) { + args.push(...readonlyMountArgs(mount.hostPath, mount.containerPath)); + } else { + args.push('-v', `${mount.hostPath}:${mount.containerPath}`); + } + } + + args.push(CONTAINER_IMAGE); + + return args; +} diff --git a/src/container-runner.ts b/src/container-runner.ts index be6f356e0d0..e907f291790 100644 --- a/src/container-runner.ts +++ b/src/container-runner.ts @@ -1,33 +1,32 @@ /** * Container Runner for NanoClaw - * Spawns agent execution in containers and handles IPC + * Spawns agent execution in containers and handles process lifecycle. + * + * Volume mount building: ./container-mounts.ts + * IPC snapshot writing: ./ipc-snapshots.ts */ import { ChildProcess, exec, spawn } from 'child_process'; import fs from 'fs'; import path from 'path'; import { - CONTAINER_IMAGE, CONTAINER_MAX_OUTPUT_SIZE, CONTAINER_TIMEOUT, - CREDENTIAL_PROXY_PORT, - DATA_DIR, - GROUPS_DIR, IDLE_TIMEOUT, - TIMEZONE, } from './config.js'; -import { resolveGroupFolderPath, resolveGroupIpcPath } from './group-folder.js'; +import { resolveGroupFolderPath } from './group-folder.js'; import { logger } from './logger.js'; import { - CONTAINER_HOST_GATEWAY, CONTAINER_RUNTIME_BIN, - hostGatewayArgs, - readonlyMountArgs, stopContainer, } from './container-runtime.js'; -import { detectAuthMode } from './credential-proxy.js'; -import { validateAdditionalMounts } from './mount-security.js'; import { RegisteredGroup } from './types.js'; +import { buildVolumeMounts, buildContainerArgs } from './container-mounts.js'; + +// Re-export types and IPC snapshot functions for backwards compatibility +export type { VolumeMount } from './container-mounts.js'; +export { writeTasksSnapshot, writeGroupsSnapshot } from './ipc-snapshots.js'; +export type { AvailableGroup } from './ipc-snapshots.js'; // Sentinel markers for robust output parsing (must match agent-runner) const OUTPUT_START_MARKER = '---NANOCLAW_OUTPUT_START---'; @@ -50,220 +49,6 @@ export interface ContainerOutput { error?: string; } -interface VolumeMount { - hostPath: string; - containerPath: string; - readonly: boolean; -} - -function buildVolumeMounts( - group: RegisteredGroup, - isMain: boolean, -): VolumeMount[] { - const mounts: VolumeMount[] = []; - const projectRoot = process.cwd(); - const groupDir = resolveGroupFolderPath(group.folder); - - if (isMain) { - // Main gets the project root read-only. Writable paths the agent needs - // (group folder, IPC, .claude/) are mounted separately below. - // Read-only prevents the agent from modifying host application code - // (src/, dist/, package.json, etc.) which would bypass the sandbox - // entirely on next restart. - mounts.push({ - hostPath: projectRoot, - containerPath: '/workspace/project', - readonly: true, - }); - - // Shadow .env so the agent cannot read secrets from the mounted project root. - // Credentials are injected by the credential proxy, never exposed to containers. - const envFile = path.join(projectRoot, '.env'); - if (fs.existsSync(envFile)) { - mounts.push({ - hostPath: '/dev/null', - containerPath: '/workspace/project/.env', - readonly: true, - }); - } - - // Main also gets its group folder as the working directory - mounts.push({ - hostPath: groupDir, - containerPath: '/workspace/group', - readonly: false, - }); - } else { - // Other groups only get their own folder - mounts.push({ - hostPath: groupDir, - containerPath: '/workspace/group', - readonly: false, - }); - - // Global memory directory (read-only for non-main) - // Only directory mounts are supported, not file mounts - const globalDir = path.join(GROUPS_DIR, 'global'); - if (fs.existsSync(globalDir)) { - mounts.push({ - hostPath: globalDir, - containerPath: '/workspace/global', - readonly: true, - }); - } - } - - // Per-group Claude sessions directory (isolated from other groups) - // Each group gets their own .claude/ to prevent cross-group session access - const groupSessionsDir = path.join( - DATA_DIR, - 'sessions', - group.folder, - '.claude', - ); - fs.mkdirSync(groupSessionsDir, { recursive: true }); - const settingsFile = path.join(groupSessionsDir, 'settings.json'); - if (!fs.existsSync(settingsFile)) { - fs.writeFileSync( - settingsFile, - JSON.stringify( - { - env: { - // Enable agent swarms (subagent orchestration) - // https://code.claude.com/docs/en/agent-teams#orchestrate-teams-of-claude-code-sessions - CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS: '1', - // Load CLAUDE.md from additional mounted directories - // https://code.claude.com/docs/en/memory#load-memory-from-additional-directories - CLAUDE_CODE_ADDITIONAL_DIRECTORIES_CLAUDE_MD: '1', - // Enable Claude's memory feature (persists user preferences between sessions) - // https://code.claude.com/docs/en/memory#manage-auto-memory - CLAUDE_CODE_DISABLE_AUTO_MEMORY: '0', - }, - }, - null, - 2, - ) + '\n', - ); - } - - // Sync skills from container/skills/ into each group's .claude/skills/ - const skillsSrc = path.join(process.cwd(), 'container', 'skills'); - const skillsDst = path.join(groupSessionsDir, 'skills'); - if (fs.existsSync(skillsSrc)) { - for (const skillDir of fs.readdirSync(skillsSrc)) { - const srcDir = path.join(skillsSrc, skillDir); - if (!fs.statSync(srcDir).isDirectory()) continue; - const dstDir = path.join(skillsDst, skillDir); - fs.cpSync(srcDir, dstDir, { recursive: true }); - } - } - mounts.push({ - hostPath: groupSessionsDir, - containerPath: '/home/node/.claude', - readonly: false, - }); - - // Per-group IPC namespace: each group gets its own IPC directory - // This prevents cross-group privilege escalation via IPC - const groupIpcDir = resolveGroupIpcPath(group.folder); - fs.mkdirSync(path.join(groupIpcDir, 'messages'), { recursive: true }); - fs.mkdirSync(path.join(groupIpcDir, 'tasks'), { recursive: true }); - fs.mkdirSync(path.join(groupIpcDir, 'input'), { recursive: true }); - mounts.push({ - hostPath: groupIpcDir, - containerPath: '/workspace/ipc', - readonly: false, - }); - - // Copy agent-runner source into a per-group writable location so agents - // can customize it (add tools, change behavior) without affecting other - // groups. Recompiled on container startup via entrypoint.sh. - const agentRunnerSrc = path.join( - projectRoot, - 'container', - 'agent-runner', - 'src', - ); - const groupAgentRunnerDir = path.join( - DATA_DIR, - 'sessions', - group.folder, - 'agent-runner-src', - ); - if (!fs.existsSync(groupAgentRunnerDir) && fs.existsSync(agentRunnerSrc)) { - fs.cpSync(agentRunnerSrc, groupAgentRunnerDir, { recursive: true }); - } - mounts.push({ - hostPath: groupAgentRunnerDir, - containerPath: '/app/src', - readonly: false, - }); - - // Additional mounts validated against external allowlist (tamper-proof from containers) - if (group.containerConfig?.additionalMounts) { - const validatedMounts = validateAdditionalMounts( - group.containerConfig.additionalMounts, - group.name, - isMain, - ); - mounts.push(...validatedMounts); - } - - return mounts; -} - -function buildContainerArgs( - mounts: VolumeMount[], - containerName: string, -): string[] { - const args: string[] = ['run', '-i', '--rm', '--name', containerName]; - - // Pass host timezone so container's local time matches the user's - args.push('-e', `TZ=${TIMEZONE}`); - - // Route API traffic through the credential proxy (containers never see real secrets) - args.push( - '-e', - `ANTHROPIC_BASE_URL=http://${CONTAINER_HOST_GATEWAY}:${CREDENTIAL_PROXY_PORT}`, - ); - - // Mirror the host's auth method with a placeholder value. - // API key mode: SDK sends x-api-key, proxy replaces with real key. - // OAuth mode: SDK exchanges placeholder token for temp API key, - // proxy injects real OAuth token on that exchange request. - const authMode = detectAuthMode(); - if (authMode === 'api-key') { - args.push('-e', 'ANTHROPIC_API_KEY=placeholder'); - } else { - args.push('-e', 'CLAUDE_CODE_OAUTH_TOKEN=placeholder'); - } - - // Runtime-specific args for host gateway resolution - args.push(...hostGatewayArgs()); - - // Run as host user so bind-mounted files are accessible. - // Skip when running as root (uid 0), as the container's node user (uid 1000), - // or when getuid is unavailable (native Windows without WSL). - const hostUid = process.getuid?.(); - const hostGid = process.getgid?.(); - if (hostUid != null && hostUid !== 0 && hostUid !== 1000) { - args.push('--user', `${hostUid}:${hostGid}`); - args.push('-e', 'HOME=/home/node'); - } - - for (const mount of mounts) { - if (mount.readonly) { - args.push(...readonlyMountArgs(mount.hostPath, mount.containerPath)); - } else { - args.push('-v', `${mount.hostPath}:${mount.containerPath}`); - } - } - - args.push(CONTAINER_IMAGE); - - return args; -} - export async function runContainerAgent( group: RegisteredGroup, input: ContainerInput, @@ -641,67 +426,3 @@ export async function runContainerAgent( }); }); } - -export function writeTasksSnapshot( - groupFolder: string, - isMain: boolean, - tasks: Array<{ - id: string; - groupFolder: string; - prompt: string; - schedule_type: string; - schedule_value: string; - status: string; - next_run: string | null; - }>, -): void { - // Write filtered tasks to the group's IPC directory - const groupIpcDir = resolveGroupIpcPath(groupFolder); - fs.mkdirSync(groupIpcDir, { recursive: true }); - - // Main sees all tasks, others only see their own - const filteredTasks = isMain - ? tasks - : tasks.filter((t) => t.groupFolder === groupFolder); - - const tasksFile = path.join(groupIpcDir, 'current_tasks.json'); - fs.writeFileSync(tasksFile, JSON.stringify(filteredTasks, null, 2)); -} - -export interface AvailableGroup { - jid: string; - name: string; - lastActivity: string; - isRegistered: boolean; -} - -/** - * Write available groups snapshot for the container to read. - * Only main group can see all available groups (for activation). - * Non-main groups only see their own registration status. - */ -export function writeGroupsSnapshot( - groupFolder: string, - isMain: boolean, - groups: AvailableGroup[], - registeredJids: Set, -): void { - const groupIpcDir = resolveGroupIpcPath(groupFolder); - fs.mkdirSync(groupIpcDir, { recursive: true }); - - // Main sees all groups; others see nothing (they can't activate groups) - const visibleGroups = isMain ? groups : []; - - const groupsFile = path.join(groupIpcDir, 'available_groups.json'); - fs.writeFileSync( - groupsFile, - JSON.stringify( - { - groups: visibleGroups, - lastSync: new Date().toISOString(), - }, - null, - 2, - ), - ); -} diff --git a/src/ipc-snapshots.test.ts b/src/ipc-snapshots.test.ts new file mode 100644 index 00000000000..c319d8d754a --- /dev/null +++ b/src/ipc-snapshots.test.ts @@ -0,0 +1,95 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import fs from 'fs'; +import path from 'path'; + +// Mock config +vi.mock('./config.js', () => ({ + DATA_DIR: '/tmp/nanoclaw-test-data', + GROUPS_DIR: '/tmp/nanoclaw-test-groups', +})); + +// Mock group-folder +vi.mock('./group-folder.js', () => ({ + resolveGroupIpcPath: (folder: string) => `/tmp/nanoclaw-test-ipc/${folder}`, +})); + +// Mock fs +vi.mock('fs', async () => { + const actual = await vi.importActual('fs'); + return { + ...actual, + default: { + ...actual, + mkdirSync: vi.fn(), + writeFileSync: vi.fn(), + }, + }; +}); + +import { writeTasksSnapshot, writeGroupsSnapshot } from './ipc-snapshots.js'; + +describe('writeTasksSnapshot', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('writes all tasks for main group', () => { + const tasks = [ + { id: 't1', groupFolder: 'main', prompt: 'do X', schedule_type: 'once', schedule_value: '2024-01-01', status: 'active', next_run: null }, + { id: 't2', groupFolder: 'other', prompt: 'do Y', schedule_type: 'once', schedule_value: '2024-01-01', status: 'active', next_run: null }, + ]; + + writeTasksSnapshot('main', true, tasks); + + expect(fs.writeFileSync).toHaveBeenCalledWith( + expect.stringContaining('current_tasks.json'), + expect.stringContaining('"t1"'), + ); + // Main sees all tasks + const written = JSON.parse((fs.writeFileSync as any).mock.calls[0][1]); + expect(written).toHaveLength(2); + }); + + it('filters tasks for non-main group', () => { + const tasks = [ + { id: 't1', groupFolder: 'main', prompt: 'do X', schedule_type: 'once', schedule_value: '2024-01-01', status: 'active', next_run: null }, + { id: 't2', groupFolder: 'other', prompt: 'do Y', schedule_type: 'once', schedule_value: '2024-01-01', status: 'active', next_run: null }, + ]; + + writeTasksSnapshot('other', false, tasks); + + const written = JSON.parse((fs.writeFileSync as any).mock.calls[0][1]); + expect(written).toHaveLength(1); + expect(written[0].id).toBe('t2'); + }); +}); + +describe('writeGroupsSnapshot', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('writes all groups for main', () => { + const groups = [ + { jid: 'a@g.us', name: 'Group A', lastActivity: '2024-01-01', isRegistered: true }, + { jid: 'b@g.us', name: 'Group B', lastActivity: '2024-01-02', isRegistered: false }, + ]; + + writeGroupsSnapshot('main', true, groups, new Set(['a@g.us'])); + + const written = JSON.parse((fs.writeFileSync as any).mock.calls[0][1]); + expect(written.groups).toHaveLength(2); + expect(written.lastSync).toBeDefined(); + }); + + it('writes empty groups for non-main', () => { + const groups = [ + { jid: 'a@g.us', name: 'Group A', lastActivity: '2024-01-01', isRegistered: true }, + ]; + + writeGroupsSnapshot('other', false, groups, new Set(['a@g.us'])); + + const written = JSON.parse((fs.writeFileSync as any).mock.calls[0][1]); + expect(written.groups).toHaveLength(0); + }); +}); diff --git a/src/ipc-snapshots.ts b/src/ipc-snapshots.ts new file mode 100644 index 00000000000..faa2c1f6e89 --- /dev/null +++ b/src/ipc-snapshots.ts @@ -0,0 +1,72 @@ +/** + * IPC snapshot writers — write task and group data to per-group IPC directories + * so containers can read them. + */ +import fs from 'fs'; +import path from 'path'; + +import { resolveGroupIpcPath } from './group-folder.js'; + +export function writeTasksSnapshot( + groupFolder: string, + isMain: boolean, + tasks: Array<{ + id: string; + groupFolder: string; + prompt: string; + schedule_type: string; + schedule_value: string; + status: string; + next_run: string | null; + }>, +): void { + // Write filtered tasks to the group's IPC directory + const groupIpcDir = resolveGroupIpcPath(groupFolder); + fs.mkdirSync(groupIpcDir, { recursive: true }); + + // Main sees all tasks, others only see their own + const filteredTasks = isMain + ? tasks + : tasks.filter((t) => t.groupFolder === groupFolder); + + const tasksFile = path.join(groupIpcDir, 'current_tasks.json'); + fs.writeFileSync(tasksFile, JSON.stringify(filteredTasks, null, 2)); +} + +export interface AvailableGroup { + jid: string; + name: string; + lastActivity: string; + isRegistered: boolean; +} + +/** + * Write available groups snapshot for the container to read. + * Only main group can see all available groups (for activation). + * Non-main groups only see their own registration status. + */ +export function writeGroupsSnapshot( + groupFolder: string, + isMain: boolean, + groups: AvailableGroup[], + registeredJids: Set, +): void { + const groupIpcDir = resolveGroupIpcPath(groupFolder); + fs.mkdirSync(groupIpcDir, { recursive: true }); + + // Main sees all groups; others see nothing (they can't activate groups) + const visibleGroups = isMain ? groups : []; + + const groupsFile = path.join(groupIpcDir, 'available_groups.json'); + fs.writeFileSync( + groupsFile, + JSON.stringify( + { + groups: visibleGroups, + lastSync: new Date().toISOString(), + }, + null, + 2, + ), + ); +} From ce3dac5f2fc542ed1110d7055c7572fdfcb03961 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 19 Mar 2026 10:34:53 +0000 Subject: [PATCH 05/10] Split db.ts into domain-specific query modules - db-messages.ts: storeMessage, getNewMessages, getMessagesSince (110 lines) - db-tasks.ts: createTask, updateTask, deleteTask, getDueTasks, etc. (130 lines) - db-state.ts: chat metadata, router state, sessions, registered groups (250 lines) - db.ts: schema, init, migration, getDb() accessor + re-exports (240 lines) All existing imports from './db.js' continue to work via re-exports. https://claude.ai/code/session_01D3rGyrcsVT96ZepUP66rjT --- src/db-messages.ts | 117 ++++++++++ src/db-state.ts | 266 +++++++++++++++++++++ src/db-tasks.ts | 147 ++++++++++++ src/db.ts | 572 +++++++-------------------------------------- 4 files changed, 617 insertions(+), 485 deletions(-) create mode 100644 src/db-messages.ts create mode 100644 src/db-state.ts create mode 100644 src/db-tasks.ts diff --git a/src/db-messages.ts b/src/db-messages.ts new file mode 100644 index 00000000000..f3e5f0d426f --- /dev/null +++ b/src/db-messages.ts @@ -0,0 +1,117 @@ +/** + * Message storage and retrieval queries. + * Extracted from db.ts for modularity. + */ +import { getDb } from './db.js'; +import { NewMessage } from './types.js'; + +/** + * Store a message with full content. + * Only call this for registered groups where message history is needed. + */ +export function storeMessage(msg: NewMessage): void { + getDb() + .prepare( + `INSERT OR REPLACE INTO messages (id, chat_jid, sender, sender_name, content, timestamp, is_from_me, is_bot_message) VALUES (?, ?, ?, ?, ?, ?, ?, ?)`, + ) + .run( + msg.id, + msg.chat_jid, + msg.sender, + msg.sender_name, + msg.content, + msg.timestamp, + msg.is_from_me ? 1 : 0, + msg.is_bot_message ? 1 : 0, + ); +} + +/** + * Store a message directly. + */ +export function storeMessageDirect(msg: { + id: string; + chat_jid: string; + sender: string; + sender_name: string; + content: string; + timestamp: string; + is_from_me: boolean; + is_bot_message?: boolean; +}): void { + getDb() + .prepare( + `INSERT OR REPLACE INTO messages (id, chat_jid, sender, sender_name, content, timestamp, is_from_me, is_bot_message) VALUES (?, ?, ?, ?, ?, ?, ?, ?)`, + ) + .run( + msg.id, + msg.chat_jid, + msg.sender, + msg.sender_name, + msg.content, + msg.timestamp, + msg.is_from_me ? 1 : 0, + msg.is_bot_message ? 1 : 0, + ); +} + +export function getNewMessages( + jids: string[], + lastTimestamp: string, + botPrefix: string, + limit: number = 200, +): { messages: NewMessage[]; newTimestamp: string } { + if (jids.length === 0) return { messages: [], newTimestamp: lastTimestamp }; + + const placeholders = jids.map(() => '?').join(','); + // Filter bot messages using both the is_bot_message flag AND the content + // prefix as a backstop for messages written before the migration ran. + // Subquery takes the N most recent, outer query re-sorts chronologically. + const sql = ` + SELECT * FROM ( + SELECT id, chat_jid, sender, sender_name, content, timestamp, is_from_me + FROM messages + WHERE timestamp > ? AND chat_jid IN (${placeholders}) + AND is_bot_message = 0 AND content NOT LIKE ? + AND content != '' AND content IS NOT NULL + ORDER BY timestamp DESC + LIMIT ? + ) ORDER BY timestamp + `; + + const rows = getDb() + .prepare(sql) + .all(lastTimestamp, ...jids, `${botPrefix}:%`, limit) as NewMessage[]; + + let newTimestamp = lastTimestamp; + for (const row of rows) { + if (row.timestamp > newTimestamp) newTimestamp = row.timestamp; + } + + return { messages: rows, newTimestamp }; +} + +export function getMessagesSince( + chatJid: string, + sinceTimestamp: string, + botPrefix: string, + limit: number = 200, +): NewMessage[] { + // Filter bot messages using both the is_bot_message flag AND the content + // prefix as a backstop for messages written before the migration ran. + // Subquery takes the N most recent, outer query re-sorts chronologically. + const sql = ` + SELECT * FROM ( + SELECT id, chat_jid, sender, sender_name, content, timestamp, is_from_me + FROM messages + WHERE chat_jid = ? AND timestamp > ? + AND is_bot_message = 0 AND content NOT LIKE ? + AND content != '' AND content IS NOT NULL + ORDER BY timestamp DESC + LIMIT ? + ) ORDER BY timestamp + `; + return getDb() + .prepare(sql) + .all(chatJid, sinceTimestamp, `${botPrefix}:%`, limit) as NewMessage[]; +} diff --git a/src/db-state.ts b/src/db-state.ts new file mode 100644 index 00000000000..196a1a0ced4 --- /dev/null +++ b/src/db-state.ts @@ -0,0 +1,266 @@ +/** + * Router state, session, and registered group queries. + * Extracted from db.ts for modularity. + */ +import { getDb } from './db.js'; +import { isValidGroupFolder } from './group-folder.js'; +import { logger } from './logger.js'; +import { RegisteredGroup } from './types.js'; + +// --- Chat metadata --- + +/** + * Store chat metadata only (no message content). + * Used for all chats to enable group discovery without storing sensitive content. + */ +export function storeChatMetadata( + chatJid: string, + timestamp: string, + name?: string, + channel?: string, + isGroup?: boolean, +): void { + const ch = channel ?? null; + const group = isGroup === undefined ? null : isGroup ? 1 : 0; + + if (name) { + // Update with name, preserving existing timestamp if newer + getDb() + .prepare( + ` + INSERT INTO chats (jid, name, last_message_time, channel, is_group) VALUES (?, ?, ?, ?, ?) + ON CONFLICT(jid) DO UPDATE SET + name = excluded.name, + last_message_time = MAX(last_message_time, excluded.last_message_time), + channel = COALESCE(excluded.channel, channel), + is_group = COALESCE(excluded.is_group, is_group) + `, + ) + .run(chatJid, name, timestamp, ch, group); + } else { + // Update timestamp only, preserve existing name if any + getDb() + .prepare( + ` + INSERT INTO chats (jid, name, last_message_time, channel, is_group) VALUES (?, ?, ?, ?, ?) + ON CONFLICT(jid) DO UPDATE SET + last_message_time = MAX(last_message_time, excluded.last_message_time), + channel = COALESCE(excluded.channel, channel), + is_group = COALESCE(excluded.is_group, is_group) + `, + ) + .run(chatJid, chatJid, timestamp, ch, group); + } +} + +/** + * Update chat name without changing timestamp for existing chats. + * New chats get the current time as their initial timestamp. + * Used during group metadata sync. + */ +export function updateChatName(chatJid: string, name: string): void { + getDb() + .prepare( + ` + INSERT INTO chats (jid, name, last_message_time) VALUES (?, ?, ?) + ON CONFLICT(jid) DO UPDATE SET name = excluded.name + `, + ) + .run(chatJid, name, new Date().toISOString()); +} + +export interface ChatInfo { + jid: string; + name: string; + last_message_time: string; + channel: string; + is_group: number; +} + +/** + * Get all known chats, ordered by most recent activity. + */ +export function getAllChats(): ChatInfo[] { + return getDb() + .prepare( + ` + SELECT jid, name, last_message_time, channel, is_group + FROM chats + ORDER BY last_message_time DESC + `, + ) + .all() as ChatInfo[]; +} + +/** + * Get timestamp of last group metadata sync. + */ +export function getLastGroupSync(): string | null { + // Store sync time in a special chat entry + const row = getDb() + .prepare(`SELECT last_message_time FROM chats WHERE jid = '__group_sync__'`) + .get() as { last_message_time: string } | undefined; + return row?.last_message_time || null; +} + +/** + * Record that group metadata was synced. + */ +export function setLastGroupSync(): void { + const now = new Date().toISOString(); + getDb() + .prepare( + `INSERT OR REPLACE INTO chats (jid, name, last_message_time) VALUES ('__group_sync__', '__group_sync__', ?)`, + ) + .run(now); +} + +// --- Router state --- + +export function getRouterState(key: string): string | undefined { + const row = getDb() + .prepare('SELECT value FROM router_state WHERE key = ?') + .get(key) as { value: string } | undefined; + return row?.value; +} + +export function setRouterState(key: string, value: string): void { + getDb() + .prepare( + 'INSERT OR REPLACE INTO router_state (key, value) VALUES (?, ?)', + ) + .run(key, value); +} + +// --- Sessions --- + +export function getSession(groupFolder: string): string | undefined { + const row = getDb() + .prepare('SELECT session_id FROM sessions WHERE group_folder = ?') + .get(groupFolder) as { session_id: string } | undefined; + return row?.session_id; +} + +export function setSession(groupFolder: string, sessionId: string): void { + getDb() + .prepare( + 'INSERT OR REPLACE INTO sessions (group_folder, session_id) VALUES (?, ?)', + ) + .run(groupFolder, sessionId); +} + +export function getAllSessions(): Record { + const rows = getDb() + .prepare('SELECT group_folder, session_id FROM sessions') + .all() as Array<{ group_folder: string; session_id: string }>; + const result: Record = {}; + for (const row of rows) { + result[row.group_folder] = row.session_id; + } + return result; +} + +// --- Registered groups --- + +export function getRegisteredGroup( + jid: string, +): (RegisteredGroup & { jid: string }) | undefined { + const row = getDb() + .prepare('SELECT * FROM registered_groups WHERE jid = ?') + .get(jid) as + | { + jid: string; + name: string; + folder: string; + trigger_pattern: string; + added_at: string; + container_config: string | null; + requires_trigger: number | null; + is_main: number | null; + } + | undefined; + if (!row) return undefined; + if (!isValidGroupFolder(row.folder)) { + logger.warn( + { jid: row.jid, folder: row.folder }, + 'Skipping registered group with invalid folder', + ); + return undefined; + } + return { + jid: row.jid, + name: row.name, + folder: row.folder, + trigger: row.trigger_pattern, + added_at: row.added_at, + containerConfig: row.container_config + ? JSON.parse(row.container_config) + : undefined, + requiresTrigger: + row.requires_trigger === null ? undefined : row.requires_trigger === 1, + isMain: row.is_main === 1 ? true : undefined, + }; +} + +export function setRegisteredGroup(jid: string, group: RegisteredGroup): void { + if (!isValidGroupFolder(group.folder)) { + throw new Error(`Invalid group folder "${group.folder}" for JID ${jid}`); + } + getDb() + .prepare( + `INSERT OR REPLACE INTO registered_groups (jid, name, folder, trigger_pattern, added_at, container_config, requires_trigger, is_main) + VALUES (?, ?, ?, ?, ?, ?, ?, ?)`, + ) + .run( + jid, + group.name, + group.folder, + group.trigger, + group.added_at, + group.containerConfig ? JSON.stringify(group.containerConfig) : null, + group.requiresTrigger === undefined + ? 1 + : group.requiresTrigger + ? 1 + : 0, + group.isMain ? 1 : 0, + ); +} + +export function getAllRegisteredGroups(): Record { + const rows = getDb() + .prepare('SELECT * FROM registered_groups') + .all() as Array<{ + jid: string; + name: string; + folder: string; + trigger_pattern: string; + added_at: string; + container_config: string | null; + requires_trigger: number | null; + is_main: number | null; + }>; + const result: Record = {}; + for (const row of rows) { + if (!isValidGroupFolder(row.folder)) { + logger.warn( + { jid: row.jid, folder: row.folder }, + 'Skipping registered group with invalid folder', + ); + continue; + } + result[row.jid] = { + name: row.name, + folder: row.folder, + trigger: row.trigger_pattern, + added_at: row.added_at, + containerConfig: row.container_config + ? JSON.parse(row.container_config) + : undefined, + requiresTrigger: + row.requires_trigger === null ? undefined : row.requires_trigger === 1, + isMain: row.is_main === 1 ? true : undefined, + }; + } + return result; +} diff --git a/src/db-tasks.ts b/src/db-tasks.ts new file mode 100644 index 00000000000..c7288707487 --- /dev/null +++ b/src/db-tasks.ts @@ -0,0 +1,147 @@ +/** + * Scheduled task CRUD queries. + * Extracted from db.ts for modularity. + */ +import { getDb } from './db.js'; +import { ScheduledTask, TaskRunLog } from './types.js'; + +export function createTask( + task: Omit, +): void { + getDb() + .prepare( + ` + INSERT INTO scheduled_tasks (id, group_folder, chat_jid, prompt, schedule_type, schedule_value, context_mode, next_run, status, created_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + `, + ) + .run( + task.id, + task.group_folder, + task.chat_jid, + task.prompt, + task.schedule_type, + task.schedule_value, + task.context_mode || 'isolated', + task.next_run, + task.status, + task.created_at, + ); +} + +export function getTaskById(id: string): ScheduledTask | undefined { + return getDb() + .prepare('SELECT * FROM scheduled_tasks WHERE id = ?') + .get(id) as ScheduledTask | undefined; +} + +export function getTasksForGroup(groupFolder: string): ScheduledTask[] { + return getDb() + .prepare( + 'SELECT * FROM scheduled_tasks WHERE group_folder = ? ORDER BY created_at DESC', + ) + .all(groupFolder) as ScheduledTask[]; +} + +export function getAllTasks(): ScheduledTask[] { + return getDb() + .prepare('SELECT * FROM scheduled_tasks ORDER BY created_at DESC') + .all() as ScheduledTask[]; +} + +export function updateTask( + id: string, + updates: Partial< + Pick< + ScheduledTask, + 'prompt' | 'schedule_type' | 'schedule_value' | 'next_run' | 'status' + > + >, +): void { + const fields: string[] = []; + const values: unknown[] = []; + + if (updates.prompt !== undefined) { + fields.push('prompt = ?'); + values.push(updates.prompt); + } + if (updates.schedule_type !== undefined) { + fields.push('schedule_type = ?'); + values.push(updates.schedule_type); + } + if (updates.schedule_value !== undefined) { + fields.push('schedule_value = ?'); + values.push(updates.schedule_value); + } + if (updates.next_run !== undefined) { + fields.push('next_run = ?'); + values.push(updates.next_run); + } + if (updates.status !== undefined) { + fields.push('status = ?'); + values.push(updates.status); + } + + if (fields.length === 0) return; + + values.push(id); + getDb() + .prepare( + `UPDATE scheduled_tasks SET ${fields.join(', ')} WHERE id = ?`, + ) + .run(...values); +} + +export function deleteTask(id: string): void { + // Delete child records first (FK constraint) + getDb().prepare('DELETE FROM task_run_logs WHERE task_id = ?').run(id); + getDb().prepare('DELETE FROM scheduled_tasks WHERE id = ?').run(id); +} + +export function getDueTasks(): ScheduledTask[] { + const now = new Date().toISOString(); + return getDb() + .prepare( + ` + SELECT * FROM scheduled_tasks + WHERE status = 'active' AND next_run IS NOT NULL AND next_run <= ? + ORDER BY next_run + `, + ) + .all(now) as ScheduledTask[]; +} + +export function updateTaskAfterRun( + id: string, + nextRun: string | null, + lastResult: string, +): void { + const now = new Date().toISOString(); + getDb() + .prepare( + ` + UPDATE scheduled_tasks + SET next_run = ?, last_run = ?, last_result = ?, status = CASE WHEN ? IS NULL THEN 'completed' ELSE status END + WHERE id = ? + `, + ) + .run(nextRun, now, lastResult, nextRun, id); +} + +export function logTaskRun(log: TaskRunLog): void { + getDb() + .prepare( + ` + INSERT INTO task_run_logs (task_id, run_at, duration_ms, status, result, error) + VALUES (?, ?, ?, ?, ?, ?) + `, + ) + .run( + log.task_id, + log.run_at, + log.duration_ms, + log.status, + log.result, + log.error, + ); +} diff --git a/src/db.ts b/src/db.ts index 0896f418526..4a5165c29b8 100644 --- a/src/db.ts +++ b/src/db.ts @@ -1,3 +1,11 @@ +/** + * Database schema, initialization, and migration. + * + * Query functions are split by domain: + * - db-messages.ts: message storage and retrieval + * - db-tasks.ts: scheduled task CRUD + * - db-state.ts: chat metadata, router state, sessions, registered groups + */ import Database from 'better-sqlite3'; import fs from 'fs'; import path from 'path'; @@ -5,15 +13,15 @@ import path from 'path'; import { ASSISTANT_NAME, DATA_DIR, STORE_DIR } from './config.js'; import { isValidGroupFolder } from './group-folder.js'; import { logger } from './logger.js'; -import { - NewMessage, - RegisteredGroup, - ScheduledTask, - TaskRunLog, -} from './types.js'; +import { RegisteredGroup } from './types.js'; let db: Database.Database; +/** Get the database instance. Must call initDatabase() or _initTestDatabase() first. */ +export function getDb(): Database.Database { + return db; +} + function createSchema(database: Database.Database): void { database.exec(` CREATE TABLE IF NOT EXISTS chats ( @@ -158,483 +166,48 @@ export function _initTestDatabase(): void { createSchema(db); } -/** - * Store chat metadata only (no message content). - * Used for all chats to enable group discovery without storing sensitive content. - */ -export function storeChatMetadata( - chatJid: string, - timestamp: string, - name?: string, - channel?: string, - isGroup?: boolean, -): void { - const ch = channel ?? null; - const group = isGroup === undefined ? null : isGroup ? 1 : 0; - - if (name) { - // Update with name, preserving existing timestamp if newer - db.prepare( - ` - INSERT INTO chats (jid, name, last_message_time, channel, is_group) VALUES (?, ?, ?, ?, ?) - ON CONFLICT(jid) DO UPDATE SET - name = excluded.name, - last_message_time = MAX(last_message_time, excluded.last_message_time), - channel = COALESCE(excluded.channel, channel), - is_group = COALESCE(excluded.is_group, is_group) - `, - ).run(chatJid, name, timestamp, ch, group); - } else { - // Update timestamp only, preserve existing name if any - db.prepare( - ` - INSERT INTO chats (jid, name, last_message_time, channel, is_group) VALUES (?, ?, ?, ?, ?) - ON CONFLICT(jid) DO UPDATE SET - last_message_time = MAX(last_message_time, excluded.last_message_time), - channel = COALESCE(excluded.channel, channel), - is_group = COALESCE(excluded.is_group, is_group) - `, - ).run(chatJid, chatJid, timestamp, ch, group); - } -} - -/** - * Update chat name without changing timestamp for existing chats. - * New chats get the current time as their initial timestamp. - * Used during group metadata sync. - */ -export function updateChatName(chatJid: string, name: string): void { - db.prepare( - ` - INSERT INTO chats (jid, name, last_message_time) VALUES (?, ?, ?) - ON CONFLICT(jid) DO UPDATE SET name = excluded.name - `, - ).run(chatJid, name, new Date().toISOString()); -} - -export interface ChatInfo { - jid: string; - name: string; - last_message_time: string; - channel: string; - is_group: number; -} - -/** - * Get all known chats, ordered by most recent activity. - */ -export function getAllChats(): ChatInfo[] { - return db - .prepare( - ` - SELECT jid, name, last_message_time, channel, is_group - FROM chats - ORDER BY last_message_time DESC - `, - ) - .all() as ChatInfo[]; -} - -/** - * Get timestamp of last group metadata sync. - */ -export function getLastGroupSync(): string | null { - // Store sync time in a special chat entry - const row = db - .prepare(`SELECT last_message_time FROM chats WHERE jid = '__group_sync__'`) - .get() as { last_message_time: string } | undefined; - return row?.last_message_time || null; -} - -/** - * Record that group metadata was synced. - */ -export function setLastGroupSync(): void { - const now = new Date().toISOString(); - db.prepare( - `INSERT OR REPLACE INTO chats (jid, name, last_message_time) VALUES ('__group_sync__', '__group_sync__', ?)`, - ).run(now); -} - -/** - * Store a message with full content. - * Only call this for registered groups where message history is needed. - */ -export function storeMessage(msg: NewMessage): void { - db.prepare( - `INSERT OR REPLACE INTO messages (id, chat_jid, sender, sender_name, content, timestamp, is_from_me, is_bot_message) VALUES (?, ?, ?, ?, ?, ?, ?, ?)`, - ).run( - msg.id, - msg.chat_jid, - msg.sender, - msg.sender_name, - msg.content, - msg.timestamp, - msg.is_from_me ? 1 : 0, - msg.is_bot_message ? 1 : 0, - ); -} - -/** - * Store a message directly. - */ -export function storeMessageDirect(msg: { - id: string; - chat_jid: string; - sender: string; - sender_name: string; - content: string; - timestamp: string; - is_from_me: boolean; - is_bot_message?: boolean; -}): void { - db.prepare( - `INSERT OR REPLACE INTO messages (id, chat_jid, sender, sender_name, content, timestamp, is_from_me, is_bot_message) VALUES (?, ?, ?, ?, ?, ?, ?, ?)`, - ).run( - msg.id, - msg.chat_jid, - msg.sender, - msg.sender_name, - msg.content, - msg.timestamp, - msg.is_from_me ? 1 : 0, - msg.is_bot_message ? 1 : 0, - ); -} - -export function getNewMessages( - jids: string[], - lastTimestamp: string, - botPrefix: string, - limit: number = 200, -): { messages: NewMessage[]; newTimestamp: string } { - if (jids.length === 0) return { messages: [], newTimestamp: lastTimestamp }; - - const placeholders = jids.map(() => '?').join(','); - // Filter bot messages using both the is_bot_message flag AND the content - // prefix as a backstop for messages written before the migration ran. - // Subquery takes the N most recent, outer query re-sorts chronologically. - const sql = ` - SELECT * FROM ( - SELECT id, chat_jid, sender, sender_name, content, timestamp, is_from_me - FROM messages - WHERE timestamp > ? AND chat_jid IN (${placeholders}) - AND is_bot_message = 0 AND content NOT LIKE ? - AND content != '' AND content IS NOT NULL - ORDER BY timestamp DESC - LIMIT ? - ) ORDER BY timestamp - `; - - const rows = db - .prepare(sql) - .all(lastTimestamp, ...jids, `${botPrefix}:%`, limit) as NewMessage[]; - - let newTimestamp = lastTimestamp; - for (const row of rows) { - if (row.timestamp > newTimestamp) newTimestamp = row.timestamp; - } - - return { messages: rows, newTimestamp }; -} - -export function getMessagesSince( - chatJid: string, - sinceTimestamp: string, - botPrefix: string, - limit: number = 200, -): NewMessage[] { - // Filter bot messages using both the is_bot_message flag AND the content - // prefix as a backstop for messages written before the migration ran. - // Subquery takes the N most recent, outer query re-sorts chronologically. - const sql = ` - SELECT * FROM ( - SELECT id, chat_jid, sender, sender_name, content, timestamp, is_from_me - FROM messages - WHERE chat_jid = ? AND timestamp > ? - AND is_bot_message = 0 AND content NOT LIKE ? - AND content != '' AND content IS NOT NULL - ORDER BY timestamp DESC - LIMIT ? - ) ORDER BY timestamp - `; - return db - .prepare(sql) - .all(chatJid, sinceTimestamp, `${botPrefix}:%`, limit) as NewMessage[]; -} - -export function createTask( - task: Omit, -): void { - db.prepare( - ` - INSERT INTO scheduled_tasks (id, group_folder, chat_jid, prompt, schedule_type, schedule_value, context_mode, next_run, status, created_at) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?) - `, - ).run( - task.id, - task.group_folder, - task.chat_jid, - task.prompt, - task.schedule_type, - task.schedule_value, - task.context_mode || 'isolated', - task.next_run, - task.status, - task.created_at, - ); -} - -export function getTaskById(id: string): ScheduledTask | undefined { - return db.prepare('SELECT * FROM scheduled_tasks WHERE id = ?').get(id) as - | ScheduledTask - | undefined; -} - -export function getTasksForGroup(groupFolder: string): ScheduledTask[] { - return db - .prepare( - 'SELECT * FROM scheduled_tasks WHERE group_folder = ? ORDER BY created_at DESC', - ) - .all(groupFolder) as ScheduledTask[]; -} - -export function getAllTasks(): ScheduledTask[] { - return db - .prepare('SELECT * FROM scheduled_tasks ORDER BY created_at DESC') - .all() as ScheduledTask[]; -} - -export function updateTask( - id: string, - updates: Partial< - Pick< - ScheduledTask, - 'prompt' | 'schedule_type' | 'schedule_value' | 'next_run' | 'status' - > - >, -): void { - const fields: string[] = []; - const values: unknown[] = []; - - if (updates.prompt !== undefined) { - fields.push('prompt = ?'); - values.push(updates.prompt); - } - if (updates.schedule_type !== undefined) { - fields.push('schedule_type = ?'); - values.push(updates.schedule_type); - } - if (updates.schedule_value !== undefined) { - fields.push('schedule_value = ?'); - values.push(updates.schedule_value); - } - if (updates.next_run !== undefined) { - fields.push('next_run = ?'); - values.push(updates.next_run); - } - if (updates.status !== undefined) { - fields.push('status = ?'); - values.push(updates.status); - } - - if (fields.length === 0) return; - - values.push(id); - db.prepare( - `UPDATE scheduled_tasks SET ${fields.join(', ')} WHERE id = ?`, - ).run(...values); -} - -export function deleteTask(id: string): void { - // Delete child records first (FK constraint) - db.prepare('DELETE FROM task_run_logs WHERE task_id = ?').run(id); - db.prepare('DELETE FROM scheduled_tasks WHERE id = ?').run(id); -} - -export function getDueTasks(): ScheduledTask[] { - const now = new Date().toISOString(); - return db - .prepare( - ` - SELECT * FROM scheduled_tasks - WHERE status = 'active' AND next_run IS NOT NULL AND next_run <= ? - ORDER BY next_run - `, - ) - .all(now) as ScheduledTask[]; -} - -export function updateTaskAfterRun( - id: string, - nextRun: string | null, - lastResult: string, -): void { - const now = new Date().toISOString(); - db.prepare( - ` - UPDATE scheduled_tasks - SET next_run = ?, last_run = ?, last_result = ?, status = CASE WHEN ? IS NULL THEN 'completed' ELSE status END - WHERE id = ? - `, - ).run(nextRun, now, lastResult, nextRun, id); -} - -export function logTaskRun(log: TaskRunLog): void { - db.prepare( - ` - INSERT INTO task_run_logs (task_id, run_at, duration_ms, status, result, error) - VALUES (?, ?, ?, ?, ?, ?) - `, - ).run( - log.task_id, - log.run_at, - log.duration_ms, - log.status, - log.result, - log.error, - ); -} - -// --- Router state accessors --- - -export function getRouterState(key: string): string | undefined { - const row = db - .prepare('SELECT value FROM router_state WHERE key = ?') - .get(key) as { value: string } | undefined; - return row?.value; -} - -export function setRouterState(key: string, value: string): void { - db.prepare( - 'INSERT OR REPLACE INTO router_state (key, value) VALUES (?, ?)', - ).run(key, value); -} - -// --- Session accessors --- - -export function getSession(groupFolder: string): string | undefined { - const row = db - .prepare('SELECT session_id FROM sessions WHERE group_folder = ?') - .get(groupFolder) as { session_id: string } | undefined; - return row?.session_id; -} - -export function setSession(groupFolder: string, sessionId: string): void { - db.prepare( - 'INSERT OR REPLACE INTO sessions (group_folder, session_id) VALUES (?, ?)', - ).run(groupFolder, sessionId); -} - -export function getAllSessions(): Record { - const rows = db - .prepare('SELECT group_folder, session_id FROM sessions') - .all() as Array<{ group_folder: string; session_id: string }>; - const result: Record = {}; - for (const row of rows) { - result[row.group_folder] = row.session_id; - } - return result; -} - -// --- Registered group accessors --- - -export function getRegisteredGroup( - jid: string, -): (RegisteredGroup & { jid: string }) | undefined { - const row = db - .prepare('SELECT * FROM registered_groups WHERE jid = ?') - .get(jid) as - | { - jid: string; - name: string; - folder: string; - trigger_pattern: string; - added_at: string; - container_config: string | null; - requires_trigger: number | null; - is_main: number | null; - } - | undefined; - if (!row) return undefined; - if (!isValidGroupFolder(row.folder)) { - logger.warn( - { jid: row.jid, folder: row.folder }, - 'Skipping registered group with invalid folder', - ); - return undefined; - } - return { - jid: row.jid, - name: row.name, - folder: row.folder, - trigger: row.trigger_pattern, - added_at: row.added_at, - containerConfig: row.container_config - ? JSON.parse(row.container_config) - : undefined, - requiresTrigger: - row.requires_trigger === null ? undefined : row.requires_trigger === 1, - isMain: row.is_main === 1 ? true : undefined, - }; -} - -export function setRegisteredGroup(jid: string, group: RegisteredGroup): void { - if (!isValidGroupFolder(group.folder)) { - throw new Error(`Invalid group folder "${group.folder}" for JID ${jid}`); - } - db.prepare( - `INSERT OR REPLACE INTO registered_groups (jid, name, folder, trigger_pattern, added_at, container_config, requires_trigger, is_main) - VALUES (?, ?, ?, ?, ?, ?, ?, ?)`, - ).run( - jid, - group.name, - group.folder, - group.trigger, - group.added_at, - group.containerConfig ? JSON.stringify(group.containerConfig) : null, - group.requiresTrigger === undefined ? 1 : group.requiresTrigger ? 1 : 0, - group.isMain ? 1 : 0, - ); -} - -export function getAllRegisteredGroups(): Record { - const rows = db.prepare('SELECT * FROM registered_groups').all() as Array<{ - jid: string; - name: string; - folder: string; - trigger_pattern: string; - added_at: string; - container_config: string | null; - requires_trigger: number | null; - is_main: number | null; - }>; - const result: Record = {}; - for (const row of rows) { - if (!isValidGroupFolder(row.folder)) { - logger.warn( - { jid: row.jid, folder: row.folder }, - 'Skipping registered group with invalid folder', - ); - continue; - } - result[row.jid] = { - name: row.name, - folder: row.folder, - trigger: row.trigger_pattern, - added_at: row.added_at, - containerConfig: row.container_config - ? JSON.parse(row.container_config) - : undefined, - requiresTrigger: - row.requires_trigger === null ? undefined : row.requires_trigger === 1, - isMain: row.is_main === 1 ? true : undefined, - }; - } - return result; -} +// --- Re-exports for backwards compatibility --- +// All existing `import { ... } from './db.js'` continue to work. + +export { + storeMessage, + storeMessageDirect, + getNewMessages, + getMessagesSince, +} from './db-messages.js'; + +export { + createTask, + getTaskById, + getTasksForGroup, + getAllTasks, + updateTask, + deleteTask, + getDueTasks, + updateTaskAfterRun, + logTaskRun, +} from './db-tasks.js'; + +export { + storeChatMetadata, + updateChatName, + getAllChats, + getLastGroupSync, + setLastGroupSync, + getRouterState, + setRouterState, + getSession, + setSession, + getAllSessions, + getRegisteredGroup, + setRegisteredGroup, + getAllRegisteredGroups, +} from './db-state.js'; +export type { ChatInfo } from './db-state.js'; // --- JSON migration --- +// Uses db directly (not via getDb()) since it's called from initDatabase() +// after db is assigned, avoiding circular dependency issues. function migrateJsonState(): void { const migrateFile = (filename: string) => { @@ -656,10 +229,14 @@ function migrateJsonState(): void { } | null; if (routerState) { if (routerState.last_timestamp) { - setRouterState('last_timestamp', routerState.last_timestamp); + db.prepare( + 'INSERT OR REPLACE INTO router_state (key, value) VALUES (?, ?)', + ).run('last_timestamp', routerState.last_timestamp); } if (routerState.last_agent_timestamp) { - setRouterState( + db.prepare( + 'INSERT OR REPLACE INTO router_state (key, value) VALUES (?, ?)', + ).run( 'last_agent_timestamp', JSON.stringify(routerState.last_agent_timestamp), ); @@ -673,7 +250,9 @@ function migrateJsonState(): void { > | null; if (sessions) { for (const [folder, sessionId] of Object.entries(sessions)) { - setSession(folder, sessionId); + db.prepare( + 'INSERT OR REPLACE INTO sessions (group_folder, session_id) VALUES (?, ?)', + ).run(folder, sessionId); } } @@ -685,7 +264,30 @@ function migrateJsonState(): void { if (groups) { for (const [jid, group] of Object.entries(groups)) { try { - setRegisteredGroup(jid, group); + if (!isValidGroupFolder(group.folder)) { + throw new Error( + `Invalid group folder "${group.folder}" for JID ${jid}`, + ); + } + db.prepare( + `INSERT OR REPLACE INTO registered_groups (jid, name, folder, trigger_pattern, added_at, container_config, requires_trigger, is_main) + VALUES (?, ?, ?, ?, ?, ?, ?, ?)`, + ).run( + jid, + group.name, + group.folder, + group.trigger, + group.added_at, + group.containerConfig + ? JSON.stringify(group.containerConfig) + : null, + group.requiresTrigger === undefined + ? 1 + : group.requiresTrigger + ? 1 + : 0, + group.isMain ? 1 : 0, + ); } catch (err) { logger.warn( { jid, folder: group.folder, err }, From e64c3c9aac218f38d0a1beac21d66be8e7b40eb7 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 19 Mar 2026 10:36:51 +0000 Subject: [PATCH 06/10] Extract import CSV parser into import-parser.ts MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Column mapping (COLUMN_ALIASES, SALESFORCE_PRESET, buildColumnMap) and row validation (parseRows) extracted from import.ts (386→210 lines) into import-parser.ts (170 lines). Parser is now independently testable without DB mocks. https://claude.ai/code/session_01D3rGyrcsVT96ZepUP66rjT --- crm/src/contacts/import-parser.test.ts | 122 ++++++++++++++++ crm/src/contacts/import-parser.ts | 185 +++++++++++++++++++++++++ crm/src/contacts/import.ts | 175 +---------------------- 3 files changed, 308 insertions(+), 174 deletions(-) create mode 100644 crm/src/contacts/import-parser.test.ts create mode 100644 crm/src/contacts/import-parser.ts diff --git a/crm/src/contacts/import-parser.test.ts b/crm/src/contacts/import-parser.test.ts new file mode 100644 index 00000000000..45e8e7f6692 --- /dev/null +++ b/crm/src/contacts/import-parser.test.ts @@ -0,0 +1,122 @@ +import { describe, it, expect } from "vitest"; +import { buildColumnMap, parseRows, COLUMN_ALIASES, SALESFORCE_PRESET, VALID_FIELDS } from "./import-parser.js"; + +describe("buildColumnMap", () => { + it("auto-maps standard headers", () => { + const map = buildColumnMap(["First Name", "Last Name", "Email"], {}); + expect(map["First Name"]).toBe("firstName"); + expect(map["Last Name"]).toBe("lastName"); + expect(map["Email"]).toBe("email"); + }); + + it("handles case-insensitive matching", () => { + const map = buildColumnMap(["FIRSTNAME", "lastname", "EMAIL ADDRESS"], {}); + expect(map["FIRSTNAME"]).toBe("firstName"); + expect(map["lastname"]).toBe("lastName"); + expect(map["EMAIL ADDRESS"]).toBe("email"); + }); + + it("applies salesforce preset", () => { + const map = buildColumnMap(["Mailing Street", "Mailing City"], { preset: "salesforce" }); + expect(map["Mailing Street"]).toBe("addressLine1"); + expect(map["Mailing City"]).toBe("suburb"); + }); + + it("overrides with explicit --map args", () => { + const map = buildColumnMap(["Custom Col"], { map: ["Custom Col=firstName"] }); + expect(map["Custom Col"]).toBe("firstName"); + }); + + it("ignores invalid field mappings", () => { + const map = buildColumnMap(["Col"], { map: ["Col=invalidField"] }); + expect(map["Col"]).toBeUndefined(); + }); +}); + +describe("parseRows", () => { + it("maps CSV data to fields", () => { + const rows = parseRows( + [{ "First Name": "Jane", "Last Name": "Smith", "Email": "jane@test.org" }], + { "First Name": "firstName", "Last Name": "lastName", "Email": "email" }, + ); + expect(rows).toHaveLength(1); + expect(rows[0].data.firstName).toBe("Jane"); + expect(rows[0].data.lastName).toBe("Smith"); + expect(rows[0].errors).toHaveLength(0); + }); + + it("validates missing last name", () => { + const rows = parseRows( + [{ "First Name": "Jane" }], + { "First Name": "firstName" }, + ); + expect(rows[0].errors).toContain("Missing last name"); + }); + + it("validates email format", () => { + const rows = parseRows( + [{ "First Name": "Jane", "Last Name": "Smith", "Email": "not-an-email" }], + { "First Name": "firstName", "Last Name": "lastName", "Email": "email" }, + ); + expect(rows[0].errors[0]).toContain("Invalid email"); + }); + + it("validates state codes", () => { + const rows = parseRows( + [{ "First Name": "Jane", "Last Name": "Smith", "State": "XX" }], + { "First Name": "firstName", "Last Name": "lastName", "State": "state" }, + ); + expect(rows[0].errors[0]).toContain("Invalid state"); + }); + + it("uppercases valid state codes", () => { + const rows = parseRows( + [{ "First Name": "Jane", "Last Name": "Smith", "State": "vic" }], + { "First Name": "firstName", "Last Name": "lastName", "State": "state" }, + ); + expect(rows[0].data.state).toBe("VIC"); + expect(rows[0].errors).toHaveLength(0); + }); + + it("validates postcode format", () => { + const rows = parseRows( + [{ "First Name": "Jane", "Last Name": "Smith", "Post": "123" }], + { "First Name": "firstName", "Last Name": "lastName", "Post": "postcode" }, + ); + expect(rows[0].errors[0]).toContain("Invalid postcode"); + }); + + it("defaults contactType to other", () => { + const rows = parseRows( + [{ "First Name": "Jane", "Last Name": "Smith" }], + { "First Name": "firstName", "Last Name": "lastName" }, + ); + expect(rows[0].data.contactType).toBe("other"); + }); + + it("assigns correct row numbers (1-indexed + header)", () => { + const rows = parseRows( + [{ "Name": "A" }, { "Name": "B" }, { "Name": "C" }], + { "Name": "lastName" }, + ); + expect(rows[0].rowNumber).toBe(2); + expect(rows[1].rowNumber).toBe(3); + expect(rows[2].rowNumber).toBe(4); + }); +}); + +describe("exports", () => { + it("exports COLUMN_ALIASES", () => { + expect(COLUMN_ALIASES["firstname"]).toBe("firstName"); + expect(COLUMN_ALIASES["surname"]).toBe("lastName"); + }); + + it("exports VALID_FIELDS", () => { + expect(VALID_FIELDS.has("firstName")).toBe(true); + expect(VALID_FIELDS.has("notAField")).toBe(false); + }); + + it("exports SALESFORCE_PRESET", () => { + expect(SALESFORCE_PRESET["First Name"]).toBe("firstName"); + }); +}); diff --git a/crm/src/contacts/import-parser.ts b/crm/src/contacts/import-parser.ts new file mode 100644 index 00000000000..2052fd45801 --- /dev/null +++ b/crm/src/contacts/import-parser.ts @@ -0,0 +1,185 @@ +/** + * CSV column mapping and row parsing for contact imports. + * Extracted from import.ts for independent testability. + */ + +// Column name aliases → canonical field name +export const COLUMN_ALIASES: Record = { + // firstName + "firstname": "firstName", + "first_name": "firstName", + "first name": "firstName", + "given name": "firstName", + "givenname": "firstName", + // lastName + "lastname": "lastName", + "last_name": "lastName", + "last name": "lastName", + "surname": "lastName", + "family name": "lastName", + "familyname": "lastName", + // email + "email": "email", + "email address": "email", + "emailaddress": "email", + "e-mail": "email", + // phone + "phone": "phone", + "phone number": "phone", + "phonenumber": "phone", + "mobile": "phone", + "mobile phone": "phone", + // address + "addressline1": "addressLine1", + "address_line1": "addressLine1", + "address line 1": "addressLine1", + "street": "addressLine1", + "street address": "addressLine1", + "mailing street": "addressLine1", + "mailingstreet": "addressLine1", + "addressline2": "addressLine2", + "address_line2": "addressLine2", + "address line 2": "addressLine2", + // suburb + "suburb": "suburb", + "city": "suburb", + "mailing city": "suburb", + "mailingcity": "suburb", + // state + "state": "state", + "mailing state": "state", + "mailingstate": "state", + "state/province": "state", + // postcode + "postcode": "postcode", + "postal code": "postcode", + "postalcode": "postcode", + "zip": "postcode", + "zip code": "postcode", + "mailing zip": "postcode", + "mailingzip": "postcode", + // type + "type": "contactType", + "contact type": "contactType", + "contacttype": "contactType", + "contact_type": "contactType", + // notes + "notes": "notes", + "description": "notes", + "comment": "notes", + "comments": "notes", +}; + +// Salesforce preset mappings +export const SALESFORCE_PRESET: Record = { + "First Name": "firstName", + "Last Name": "lastName", + "Email": "email", + "Phone": "phone", + "Mailing Street": "addressLine1", + "Mailing City": "suburb", + "Mailing State/Province": "state", + "Mailing Zip/Postal Code": "postcode", + "Description": "notes", + "Contact Record Type": "contactType", +}; + +export const VALID_FIELDS = new Set([ + "firstName", "lastName", "email", "phone", + "addressLine1", "addressLine2", "suburb", "state", "postcode", + "contactType", "notes", +]); + +export interface ParsedRow { + rowNumber: number; + data: Record; + errors: string[]; +} + +export function buildColumnMap( + headers: string[], + opts: { preset?: string; map?: string[] }, +): Record { + const map: Record = {}; + + // Start with preset if provided + if (opts.preset === "salesforce") { + Object.assign(map, SALESFORCE_PRESET); + } + + // Auto-match by alias + for (const header of headers) { + if (map[header]) continue; // already mapped + const normalized = header.toLowerCase().trim(); + if (COLUMN_ALIASES[normalized]) { + map[header] = COLUMN_ALIASES[normalized]; + } + } + + // Override with explicit --map args + if (opts.map) { + for (const m of opts.map) { + const eqIdx = m.indexOf("="); + if (eqIdx === -1) continue; + const csvCol = m.slice(0, eqIdx).trim(); + const field = m.slice(eqIdx + 1).trim(); + if (VALID_FIELDS.has(field)) { + map[csvCol] = field; + } + } + } + + return map; +} + +const VALID_STATES = new Set(["VIC", "NSW", "QLD", "SA", "WA", "TAS", "NT", "ACT"]); + +export function parseRows( + records: Record[], + columnMap: Record, +): ParsedRow[] { + return records.map((record, idx) => { + const rowNumber = idx + 2; // 1-indexed, +1 for header + const data: Record = {}; + const errors: string[] = []; + + for (const [csvCol, field] of Object.entries(columnMap)) { + const value = record[csvCol]?.trim(); + if (value) { + data[field] = value; + } + } + + // Validate required fields + if (!data.firstName && !data.lastName) { + errors.push("Missing first and last name"); + } else if (!data.lastName) { + errors.push("Missing last name"); + } + + // Validate email format if present + if (data.email && !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(data.email)) { + errors.push(`Invalid email: ${data.email}`); + } + + // Validate state if present + if (data.state) { + data.state = data.state.toUpperCase(); + if (!VALID_STATES.has(data.state)) { + errors.push(`Invalid state: ${data.state}`); + } + } + + // Validate postcode if present + if (data.postcode && !/^\d{4}$/.test(data.postcode)) { + errors.push(`Invalid postcode: ${data.postcode}`); + } + + // Default contactType + if (!data.contactType) { + data.contactType = "other"; + } + + return { rowNumber, data, errors }; + }); +} diff --git a/crm/src/contacts/import.ts b/crm/src/contacts/import.ts index 9752590c232..ecb2f18ecd1 100644 --- a/crm/src/contacts/import.ts +++ b/crm/src/contacts/import.ts @@ -4,6 +4,7 @@ import { eq } from "drizzle-orm"; import { connect, audit, performer, schema } from "../db/connection.js"; import { ok, fail, type CommandResult, type CommandPlan } from "../types.js"; import { withConfirm } from "../shared/with-confirm.js"; +import { buildColumnMap, parseRows, VALID_FIELDS, type ParsedRow } from "./import-parser.js"; export interface ImportResult { imported: number; @@ -20,180 +21,6 @@ interface ImportOpts { confirm: boolean; } -// Column name aliases → canonical field name -const COLUMN_ALIASES: Record = { - // firstName - "firstname": "firstName", - "first_name": "firstName", - "first name": "firstName", - "given name": "firstName", - "givenname": "firstName", - // lastName - "lastname": "lastName", - "last_name": "lastName", - "last name": "lastName", - "surname": "lastName", - "family name": "lastName", - "familyname": "lastName", - // email - "email": "email", - "email address": "email", - "emailaddress": "email", - "e-mail": "email", - // phone - "phone": "phone", - "phone number": "phone", - "phonenumber": "phone", - "mobile": "phone", - "mobile phone": "phone", - // address - "addressline1": "addressLine1", - "address_line1": "addressLine1", - "address line 1": "addressLine1", - "street": "addressLine1", - "street address": "addressLine1", - "mailing street": "addressLine1", - "mailingstreet": "addressLine1", - "addressline2": "addressLine2", - "address_line2": "addressLine2", - "address line 2": "addressLine2", - // suburb - "suburb": "suburb", - "city": "suburb", - "mailing city": "suburb", - "mailingcity": "suburb", - // state - "state": "state", - "mailing state": "state", - "mailingstate": "state", - "state/province": "state", - // postcode - "postcode": "postcode", - "postal code": "postcode", - "postalcode": "postcode", - "zip": "postcode", - "zip code": "postcode", - "mailing zip": "postcode", - "mailingzip": "postcode", - // type - "type": "contactType", - "contact type": "contactType", - "contacttype": "contactType", - "contact_type": "contactType", - // notes - "notes": "notes", - "description": "notes", - "comment": "notes", - "comments": "notes", -}; - -// Salesforce preset mappings -const SALESFORCE_PRESET: Record = { - "First Name": "firstName", - "Last Name": "lastName", - "Email": "email", - "Phone": "phone", - "Mailing Street": "addressLine1", - "Mailing City": "suburb", - "Mailing State/Province": "state", - "Mailing Zip/Postal Code": "postcode", - "Description": "notes", - "Contact Record Type": "contactType", -}; - -const VALID_FIELDS = new Set([ - "firstName", "lastName", "email", "phone", - "addressLine1", "addressLine2", "suburb", "state", "postcode", - "contactType", "notes", -]); - -interface ParsedRow { - rowNumber: number; - data: Record; - errors: string[]; -} - -function buildColumnMap(headers: string[], opts: ImportOpts): Record { - const map: Record = {}; - - // Start with preset if provided - if (opts.preset === "salesforce") { - Object.assign(map, SALESFORCE_PRESET); - } - - // Auto-match by alias - for (const header of headers) { - if (map[header]) continue; // already mapped - const normalized = header.toLowerCase().trim(); - if (COLUMN_ALIASES[normalized]) { - map[header] = COLUMN_ALIASES[normalized]; - } - } - - // Override with explicit --map args - if (opts.map) { - for (const m of opts.map) { - const eqIdx = m.indexOf("="); - if (eqIdx === -1) continue; - const csvCol = m.slice(0, eqIdx).trim(); - const field = m.slice(eqIdx + 1).trim(); - if (VALID_FIELDS.has(field)) { - map[csvCol] = field; - } - } - } - - return map; -} - -function parseRows(records: Record[], columnMap: Record): ParsedRow[] { - return records.map((record, idx) => { - const rowNumber = idx + 2; // 1-indexed, +1 for header - const data: Record = {}; - const errors: string[] = []; - - for (const [csvCol, field] of Object.entries(columnMap)) { - const value = record[csvCol]?.trim(); - if (value) { - data[field] = value; - } - } - - // Validate required fields - if (!data.firstName && !data.lastName) { - errors.push("Missing first and last name"); - } else if (!data.lastName) { - errors.push("Missing last name"); - } - - // Validate email format if present - if (data.email && !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(data.email)) { - errors.push(`Invalid email: ${data.email}`); - } - - // Validate state if present - const validStates = new Set(["VIC", "NSW", "QLD", "SA", "WA", "TAS", "NT", "ACT"]); - if (data.state) { - data.state = data.state.toUpperCase(); - if (!validStates.has(data.state)) { - errors.push(`Invalid state: ${data.state}`); - } - } - - // Validate postcode if present - if (data.postcode && !/^\d{4}$/.test(data.postcode)) { - errors.push(`Invalid postcode: ${data.postcode}`); - } - - // Default contactType - if (!data.contactType) { - data.contactType = "other"; - } - - return { rowNumber, data, errors }; - }); -} - export async function contactsImport( file: string, opts: ImportOpts From f85ed0bf54997b4c8b67503f3569bf509f3b53b2 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 19 Mar 2026 10:37:30 +0000 Subject: [PATCH 07/10] Apply prettier formatting from pre-commit hook https://claude.ai/code/session_01D3rGyrcsVT96ZepUP66rjT --- package-lock.json | 32 ++++++++++++++++---- src/container-runner.ts | 5 +--- src/container-runtime.ts | 5 +++- src/db-state.ts | 10 ++----- src/db-tasks.ts | 4 +-- src/db.ts | 4 +-- src/formatting.test.ts | 4 ++- src/ipc-snapshots.test.ts | 61 +++++++++++++++++++++++++++++++++----- src/remote-control.test.ts | 43 ++++++++++++++++++--------- src/remote-control.ts | 8 +++-- 10 files changed, 126 insertions(+), 50 deletions(-) diff --git a/package-lock.json b/package-lock.json index fb8cb77ddf9..6ef682dcf08 100644 --- a/package-lock.json +++ b/package-lock.json @@ -146,6 +146,7 @@ "integrity": "sha512-mehfKSMWjjNol8659Z8KxEMrdSJDDot5SXMq00dM8BN4o+CLNXQ0xH2V7EchNHV4RmbZLmmPdEaXZc5H2FXmDg==", "license": "MIT", "optional": true, + "peer": true, "dependencies": { "tslib": "^2.4.0" } @@ -612,6 +613,7 @@ "resolved": "https://registry.npmjs.org/@img/colour/-/colour-1.1.0.tgz", "integrity": "sha512-Td76q7j57o/tLVdgS746cYARfSyxk8iEfRxewL9h4OMzYhbW4TAcppl0mT4eyqXddh6L/jwoM75mo7ixa/pCeQ==", "license": "MIT", + "peer": true, "engines": { "node": ">=18" } @@ -628,6 +630,7 @@ "os": [ "darwin" ], + "peer": true, "engines": { "node": "^18.17.0 || ^20.3.0 || >=21.0.0" }, @@ -650,6 +653,7 @@ "os": [ "darwin" ], + "peer": true, "engines": { "node": "^18.17.0 || ^20.3.0 || >=21.0.0" }, @@ -672,6 +676,7 @@ "os": [ "darwin" ], + "peer": true, "funding": { "url": "https://opencollective.com/libvips" } @@ -688,6 +693,7 @@ "os": [ "darwin" ], + "peer": true, "funding": { "url": "https://opencollective.com/libvips" } @@ -704,6 +710,7 @@ "os": [ "linux" ], + "peer": true, "funding": { "url": "https://opencollective.com/libvips" } @@ -720,6 +727,7 @@ "os": [ "linux" ], + "peer": true, "funding": { "url": "https://opencollective.com/libvips" } @@ -736,6 +744,7 @@ "os": [ "linux" ], + "peer": true, "funding": { "url": "https://opencollective.com/libvips" } @@ -752,6 +761,7 @@ "os": [ "linux" ], + "peer": true, "funding": { "url": "https://opencollective.com/libvips" } @@ -768,6 +778,7 @@ "os": [ "linux" ], + "peer": true, "funding": { "url": "https://opencollective.com/libvips" } @@ -784,6 +795,7 @@ "os": [ "linux" ], + "peer": true, "funding": { "url": "https://opencollective.com/libvips" } @@ -800,6 +812,7 @@ "os": [ "linux" ], + "peer": true, "funding": { "url": "https://opencollective.com/libvips" } @@ -816,6 +829,7 @@ "os": [ "linux" ], + "peer": true, "funding": { "url": "https://opencollective.com/libvips" } @@ -832,6 +846,7 @@ "os": [ "linux" ], + "peer": true, "engines": { "node": "^18.17.0 || ^20.3.0 || >=21.0.0" }, @@ -854,6 +869,7 @@ "os": [ "linux" ], + "peer": true, "engines": { "node": "^18.17.0 || ^20.3.0 || >=21.0.0" }, @@ -876,6 +892,7 @@ "os": [ "linux" ], + "peer": true, "engines": { "node": "^18.17.0 || ^20.3.0 || >=21.0.0" }, @@ -898,6 +915,7 @@ "os": [ "linux" ], + "peer": true, "engines": { "node": "^18.17.0 || ^20.3.0 || >=21.0.0" }, @@ -920,6 +938,7 @@ "os": [ "linux" ], + "peer": true, "engines": { "node": "^18.17.0 || ^20.3.0 || >=21.0.0" }, @@ -942,6 +961,7 @@ "os": [ "linux" ], + "peer": true, "engines": { "node": "^18.17.0 || ^20.3.0 || >=21.0.0" }, @@ -964,6 +984,7 @@ "os": [ "linux" ], + "peer": true, "engines": { "node": "^18.17.0 || ^20.3.0 || >=21.0.0" }, @@ -986,6 +1007,7 @@ "os": [ "linux" ], + "peer": true, "engines": { "node": "^18.17.0 || ^20.3.0 || >=21.0.0" }, @@ -1005,6 +1027,7 @@ ], "license": "Apache-2.0 AND LGPL-3.0-or-later AND MIT", "optional": true, + "peer": true, "dependencies": { "@emnapi/runtime": "^1.7.0" }, @@ -1027,6 +1050,7 @@ "os": [ "win32" ], + "peer": true, "engines": { "node": "^18.17.0 || ^20.3.0 || >=21.0.0" }, @@ -1046,6 +1070,7 @@ "os": [ "win32" ], + "peer": true, "engines": { "node": "^18.17.0 || ^20.3.0 || >=21.0.0" }, @@ -1065,6 +1090,7 @@ "os": [ "win32" ], + "peer": true, "engines": { "node": "^18.17.0 || ^20.3.0 || >=21.0.0" }, @@ -2496,7 +2522,6 @@ "resolved": "https://registry.npmjs.org/keyv/-/keyv-5.6.0.tgz", "integrity": "sha512-CYDD3SOtsHtyXeEORYRx2qBtpDJFjRTGXUtmNEMGyzYOKj1TE3tycdlho7kA1Ufx9OYWZzg52QFBGALTirzDSw==", "license": "MIT", - "peer": true, "dependencies": { "@keyv/serialize": "^1.1.1" } @@ -2855,7 +2880,6 @@ "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">=12" }, @@ -3625,7 +3649,6 @@ "integrity": "sha512-5C1sg4USs1lfG0GFb2RLXsdpXqBSEhAaA/0kPL01wxzpMqLILNxIxIOKiILz+cdg/pLnOUxFYOR5yhHU666wbw==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "esbuild": "~0.27.0", "get-tsconfig": "^4.7.5" @@ -3696,7 +3719,6 @@ "integrity": "sha512-w+N7Hifpc3gRjZ63vYBXA56dvvRlNWRczTdmCBBa+CotUzAPf5b7YMdMR/8CQoeYE5LX3W4wj6RYTgonm1b9DA==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "esbuild": "^0.27.0", "fdir": "^6.5.0", @@ -3772,7 +3794,6 @@ "integrity": "sha512-hOQuK7h0FGKgBAas7v0mSAsnvrIgAvWmRFjmzpJ7SwFHH3g1k2u37JtYwOwmEKhK6ZO3v9ggDBBm0La1LCK4uQ==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@vitest/expect": "4.0.18", "@vitest/mocker": "4.0.18", @@ -3926,7 +3947,6 @@ "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.8.2.tgz", "integrity": "sha512-mplynKqc1C2hTVYxd0PU2xQAc22TI1vShAYGksCCfxbn/dFwnHTNi1bvYsBTkhdUNtGIf5xNOg938rrSSYvS9A==", "license": "ISC", - "peer": true, "bin": { "yaml": "bin.mjs" }, diff --git a/src/container-runner.ts b/src/container-runner.ts index e907f291790..fc2d9e9230c 100644 --- a/src/container-runner.ts +++ b/src/container-runner.ts @@ -16,10 +16,7 @@ import { } from './config.js'; import { resolveGroupFolderPath } from './group-folder.js'; import { logger } from './logger.js'; -import { - CONTAINER_RUNTIME_BIN, - stopContainer, -} from './container-runtime.js'; +import { CONTAINER_RUNTIME_BIN, stopContainer } from './container-runtime.js'; import { RegisteredGroup } from './types.js'; import { buildVolumeMounts, buildContainerArgs } from './container-mounts.js'; diff --git a/src/container-runtime.ts b/src/container-runtime.ts index 01c4cff6e32..41a65227489 100644 --- a/src/container-runtime.ts +++ b/src/container-runtime.ts @@ -90,7 +90,10 @@ export function ensureContainerRuntimeRunning(): void { ); execSync(`sleep ${retryDelaySec}`); } else { - logger.error({ err }, 'Failed to reach container runtime after retries'); + logger.error( + { err }, + 'Failed to reach container runtime after retries', + ); console.error( '\n╔════════════════════════════════════════════════════════════════╗', ); diff --git a/src/db-state.ts b/src/db-state.ts index 196a1a0ced4..7b98f4c4f4d 100644 --- a/src/db-state.ts +++ b/src/db-state.ts @@ -126,9 +126,7 @@ export function getRouterState(key: string): string | undefined { export function setRouterState(key: string, value: string): void { getDb() - .prepare( - 'INSERT OR REPLACE INTO router_state (key, value) VALUES (?, ?)', - ) + .prepare('INSERT OR REPLACE INTO router_state (key, value) VALUES (?, ?)') .run(key, value); } @@ -218,11 +216,7 @@ export function setRegisteredGroup(jid: string, group: RegisteredGroup): void { group.trigger, group.added_at, group.containerConfig ? JSON.stringify(group.containerConfig) : null, - group.requiresTrigger === undefined - ? 1 - : group.requiresTrigger - ? 1 - : 0, + group.requiresTrigger === undefined ? 1 : group.requiresTrigger ? 1 : 0, group.isMain ? 1 : 0, ); } diff --git a/src/db-tasks.ts b/src/db-tasks.ts index c7288707487..0d511a99152 100644 --- a/src/db-tasks.ts +++ b/src/db-tasks.ts @@ -86,9 +86,7 @@ export function updateTask( values.push(id); getDb() - .prepare( - `UPDATE scheduled_tasks SET ${fields.join(', ')} WHERE id = ?`, - ) + .prepare(`UPDATE scheduled_tasks SET ${fields.join(', ')} WHERE id = ?`) .run(...values); } diff --git a/src/db.ts b/src/db.ts index 4a5165c29b8..2a715182da6 100644 --- a/src/db.ts +++ b/src/db.ts @@ -278,9 +278,7 @@ function migrateJsonState(): void { group.folder, group.trigger, group.added_at, - group.containerConfig - ? JSON.stringify(group.containerConfig) - : null, + group.containerConfig ? JSON.stringify(group.containerConfig) : null, group.requiresTrigger === undefined ? 1 : group.requiresTrigger diff --git a/src/formatting.test.ts b/src/formatting.test.ts index d31a3592e1b..ce9f6637c35 100644 --- a/src/formatting.test.ts +++ b/src/formatting.test.ts @@ -108,7 +108,9 @@ describe('formatMessages', () => { it('handles empty array', () => { const result = formatMessages([], TZ); expect(result).toContain(''); - expect(result).toContain('\n\n\n\n\n'); + expect(result).toContain( + '\n\n\n\n\n', + ); }); it('repeats messages block for prompt repetition', () => { diff --git a/src/ipc-snapshots.test.ts b/src/ipc-snapshots.test.ts index c319d8d754a..75b4a9103ea 100644 --- a/src/ipc-snapshots.test.ts +++ b/src/ipc-snapshots.test.ts @@ -35,8 +35,24 @@ describe('writeTasksSnapshot', () => { it('writes all tasks for main group', () => { const tasks = [ - { id: 't1', groupFolder: 'main', prompt: 'do X', schedule_type: 'once', schedule_value: '2024-01-01', status: 'active', next_run: null }, - { id: 't2', groupFolder: 'other', prompt: 'do Y', schedule_type: 'once', schedule_value: '2024-01-01', status: 'active', next_run: null }, + { + id: 't1', + groupFolder: 'main', + prompt: 'do X', + schedule_type: 'once', + schedule_value: '2024-01-01', + status: 'active', + next_run: null, + }, + { + id: 't2', + groupFolder: 'other', + prompt: 'do Y', + schedule_type: 'once', + schedule_value: '2024-01-01', + status: 'active', + next_run: null, + }, ]; writeTasksSnapshot('main', true, tasks); @@ -52,8 +68,24 @@ describe('writeTasksSnapshot', () => { it('filters tasks for non-main group', () => { const tasks = [ - { id: 't1', groupFolder: 'main', prompt: 'do X', schedule_type: 'once', schedule_value: '2024-01-01', status: 'active', next_run: null }, - { id: 't2', groupFolder: 'other', prompt: 'do Y', schedule_type: 'once', schedule_value: '2024-01-01', status: 'active', next_run: null }, + { + id: 't1', + groupFolder: 'main', + prompt: 'do X', + schedule_type: 'once', + schedule_value: '2024-01-01', + status: 'active', + next_run: null, + }, + { + id: 't2', + groupFolder: 'other', + prompt: 'do Y', + schedule_type: 'once', + schedule_value: '2024-01-01', + status: 'active', + next_run: null, + }, ]; writeTasksSnapshot('other', false, tasks); @@ -71,8 +103,18 @@ describe('writeGroupsSnapshot', () => { it('writes all groups for main', () => { const groups = [ - { jid: 'a@g.us', name: 'Group A', lastActivity: '2024-01-01', isRegistered: true }, - { jid: 'b@g.us', name: 'Group B', lastActivity: '2024-01-02', isRegistered: false }, + { + jid: 'a@g.us', + name: 'Group A', + lastActivity: '2024-01-01', + isRegistered: true, + }, + { + jid: 'b@g.us', + name: 'Group B', + lastActivity: '2024-01-02', + isRegistered: false, + }, ]; writeGroupsSnapshot('main', true, groups, new Set(['a@g.us'])); @@ -84,7 +126,12 @@ describe('writeGroupsSnapshot', () => { it('writes empty groups for non-main', () => { const groups = [ - { jid: 'a@g.us', name: 'Group A', lastActivity: '2024-01-01', isRegistered: true }, + { + jid: 'a@g.us', + name: 'Group A', + lastActivity: '2024-01-01', + isRegistered: true, + }, ]; writeGroupsSnapshot('other', false, groups, new Set(['a@g.us'])); diff --git a/src/remote-control.test.ts b/src/remote-control.test.ts index 4b5ab2fcb36..1fa434beb38 100644 --- a/src/remote-control.test.ts +++ b/src/remote-control.test.ts @@ -45,14 +45,20 @@ describe('remote-control', () => { stdoutFileContent = ''; // Default fs mocks - mkdirSyncSpy = vi.spyOn(fs, 'mkdirSync').mockImplementation(() => undefined as any); - writeFileSyncSpy = vi.spyOn(fs, 'writeFileSync').mockImplementation(() => {}); + mkdirSyncSpy = vi + .spyOn(fs, 'mkdirSync') + .mockImplementation(() => undefined as any); + writeFileSyncSpy = vi + .spyOn(fs, 'writeFileSync') + .mockImplementation(() => {}); unlinkSyncSpy = vi.spyOn(fs, 'unlinkSync').mockImplementation(() => {}); openSyncSpy = vi.spyOn(fs, 'openSync').mockReturnValue(42 as any); closeSyncSpy = vi.spyOn(fs, 'closeSync').mockImplementation(() => {}); // readFileSync: return stdoutFileContent for the stdout file, state file, etc. - readFileSyncSpy = vi.spyOn(fs, 'readFileSync').mockImplementation(((p: string) => { + readFileSyncSpy = vi.spyOn(fs, 'readFileSync').mockImplementation((( + p: string, + ) => { if (p.endsWith('remote-control.stdout')) return stdoutFileContent; if (p.endsWith('remote-control.json')) { throw Object.assign(new Error('ENOENT'), { code: 'ENOENT' }); @@ -74,7 +80,8 @@ describe('remote-control', () => { spawnMock.mockReturnValue(proc); // Simulate URL appearing in stdout file on first poll - stdoutFileContent = 'Session URL: https://claude.ai/code?bridge=env_abc123\n'; + stdoutFileContent = + 'Session URL: https://claude.ai/code?bridge=env_abc123\n'; vi.spyOn(process, 'kill').mockImplementation((() => true) as any); const result = await startRemoteControl('user1', 'tg:123', '/project'); @@ -157,7 +164,9 @@ describe('remote-control', () => { spawnMock.mockReturnValueOnce(proc1).mockReturnValueOnce(proc2); // First start: process alive, URL found - const killSpy = vi.spyOn(process, 'kill').mockImplementation((() => true) as any); + const killSpy = vi + .spyOn(process, 'kill') + .mockImplementation((() => true) as any); stdoutFileContent = 'https://claude.ai/code?bridge=env_first\n'; await startRemoteControl('user1', 'tg:123', '/project'); @@ -239,7 +248,9 @@ describe('remote-control', () => { const proc = createMockProcess(55555); spawnMock.mockReturnValue(proc); stdoutFileContent = 'https://claude.ai/code?bridge=env_stop\n'; - const killSpy = vi.spyOn(process, 'kill').mockImplementation((() => true) as any); + const killSpy = vi + .spyOn(process, 'kill') + .mockImplementation((() => true) as any); await startRemoteControl('user1', 'tg:123', '/project'); @@ -337,7 +348,9 @@ describe('remote-control', () => { if (p.endsWith('remote-control.json')) return JSON.stringify(session); return ''; }) as any); - const killSpy = vi.spyOn(process, 'kill').mockImplementation((() => true) as any); + const killSpy = vi + .spyOn(process, 'kill') + .mockImplementation((() => true) as any); restoreRemoteControl(); expect(getActiveSession()).not.toBeNull(); @@ -365,13 +378,15 @@ describe('remote-control', () => { restoreRemoteControl(); - return startRemoteControl('user2', 'tg:456', '/project').then((result) => { - expect(result).toEqual({ - ok: true, - url: 'https://claude.ai/code?bridge=env_restored', - }); - expect(spawnMock).not.toHaveBeenCalled(); - }); + return startRemoteControl('user2', 'tg:456', '/project').then( + (result) => { + expect(result).toEqual({ + ok: true, + url: 'https://claude.ai/code?bridge=env_restored', + }); + expect(spawnMock).not.toHaveBeenCalled(); + }, + ); }); }); }); diff --git a/src/remote-control.ts b/src/remote-control.ts index df8f646a5ae..015aa7f8205 100644 --- a/src/remote-control.ts +++ b/src/remote-control.ts @@ -196,9 +196,11 @@ export async function startRemoteControl( }); } -export function stopRemoteControl(): { - ok: true; -} | { ok: false; error: string } { +export function stopRemoteControl(): + | { + ok: true; + } + | { ok: false; error: string } { if (!activeSession) { return { ok: false, error: 'No active Remote Control session' }; } From 5b02bcd4a70034ca2b2eeba91f462681eda46471 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 19 Mar 2026 10:59:10 +0000 Subject: [PATCH 08/10] Add parseSortOption() to deduplicate sort logic in list commands Both contacts/list.ts and donations/list.ts had identical 4-line sort parsing blocks. New parseSortOption() in db/sort.ts returns { descending, column, orderFn } in one call. https://claude.ai/code/session_01D3rGyrcsVT96ZepUP66rjT --- crm/src/contacts/list.ts | 9 +++------ crm/src/db/sort.test.ts | 41 ++++++++++++++++++++++++++++++++++++++- crm/src/db/sort.ts | 17 +++++++++++++++- crm/src/donations/list.ts | 9 +++------ 4 files changed, 62 insertions(+), 14 deletions(-) diff --git a/crm/src/contacts/list.ts b/crm/src/contacts/list.ts index cd5cf6edb6f..1c743fa4cb1 100644 --- a/crm/src/contacts/list.ts +++ b/crm/src/contacts/list.ts @@ -1,7 +1,7 @@ -import { eq, and, sql, desc, asc, type SQL } from "drizzle-orm"; +import { eq, and, sql, type SQL } from "drizzle-orm"; import { connect, schema } from "../db/connection.js"; import { ok, type CommandResult, type ContactRow } from "../types.js"; -import { resolveSortColumn } from "../db/sort.js"; +import { parseSortOption } from "../db/sort.js"; interface ListOpts { type?: string; @@ -46,10 +46,7 @@ export async function contactsList(opts: ListOpts): Promise { @@ -33,3 +34,41 @@ describe("resolveSortColumn", () => { expect(col).toBeUndefined(); }); }); + +describe("parseSortOption", () => { + it("parses ascending sort", () => { + const result = parseSortOption("lastName", schema.contacts, schema.contacts.lastName); + expect(result.descending).toBe(false); + expect(result.column).toBe(schema.contacts.lastName); + expect(result.orderFn).toBe(asc); + }); + + it("parses descending sort with - prefix", () => { + const result = parseSortOption("-donationDate", schema.donations, schema.donations.donationDate); + expect(result.descending).toBe(true); + expect(result.column).toBe(schema.donations.donationDate); + expect(result.orderFn).toBe(desc); + }); + + it("falls back to default for unknown field", () => { + const result = parseSortOption("nonexistent", schema.contacts, schema.contacts.lastName); + expect(result.column).toBe(schema.contacts.lastName); + expect(result.descending).toBe(false); + }); + + it("falls back to default for unknown field with - prefix", () => { + const result = parseSortOption("-nonexistent", schema.contacts, schema.contacts.lastName); + expect(result.column).toBe(schema.contacts.lastName); + expect(result.descending).toBe(true); + }); + + it("resolves email column on contacts", () => { + const result = parseSortOption("email", schema.contacts, schema.contacts.lastName); + expect(result.column).toBe(schema.contacts.email); + }); + + it("resolves amount column on donations", () => { + const result = parseSortOption("amount", schema.donations, schema.donations.donationDate); + expect(result.column).toBe(schema.donations.amount); + }); +}); diff --git a/crm/src/db/sort.ts b/crm/src/db/sort.ts index 07524a04a4a..7081c876144 100644 --- a/crm/src/db/sort.ts +++ b/crm/src/db/sort.ts @@ -5,7 +5,7 @@ */ import type { PgTableWithColumns, TableConfig } from "drizzle-orm/pg-core"; -import type { Column } from "drizzle-orm"; +import { type Column, desc, asc } from "drizzle-orm"; /** * Resolve a user-provided sort field name to a Drizzle column. @@ -23,3 +23,18 @@ export function resolveSortColumn( } return fallback; } + +/** + * Parse a sort option string (e.g. "-donationDate", "lastName") into + * its components. Eliminates the repeated 4-line pattern in list commands. + */ +export function parseSortOption( + sortOpt: string, + table: PgTableWithColumns, + fallback: Column, +): { descending: boolean; column: Column; orderFn: typeof desc | typeof asc } { + const descending = sortOpt.startsWith("-"); + const field = descending ? sortOpt.slice(1) : sortOpt; + const column = resolveSortColumn(table, field, fallback)!; + return { descending, column, orderFn: descending ? desc : asc }; +} diff --git a/crm/src/donations/list.ts b/crm/src/donations/list.ts index 95bcfa16163..3729c31fea6 100644 --- a/crm/src/donations/list.ts +++ b/crm/src/donations/list.ts @@ -1,8 +1,8 @@ -import { eq, and, sql, desc, asc, type SQL } from "drizzle-orm"; +import { eq, and, sql, type SQL } from "drizzle-orm"; import { connect, schema } from "../db/connection.js"; import { ok, type CommandResult, type Donation } from "../types.js"; import { resolveContact } from "./resolve-contact.js"; -import { resolveSortColumn } from "../db/sort.js"; +import { parseSortOption } from "../db/sort.js"; interface ListOpts { contact?: string; @@ -49,10 +49,7 @@ export async function donationsList(opts: ListOpts): Promise Date: Thu, 19 Mar 2026 11:00:02 +0000 Subject: [PATCH 09/10] Type fixture overrides in test-helpers.ts Replace `any` with Partial for donation, receipt, and receiptConfig fixture factories. Catches typos in test overrides at compile time. https://claude.ai/code/session_01D3rGyrcsVT96ZepUP66rjT --- crm/src/test-helpers.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/crm/src/test-helpers.ts b/crm/src/test-helpers.ts index 3c05f5b882f..96348ff8975 100644 --- a/crm/src/test-helpers.ts +++ b/crm/src/test-helpers.ts @@ -189,7 +189,7 @@ export const fixtures = { ...overrides, }), - donation: (overrides: any = {}) => ({ + donation: (overrides: Partial = {}) => ({ id: "11111111-2222-3333-4444-555555555555", contactId: "aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee", amount: "250.00", @@ -211,7 +211,7 @@ export const fixtures = { ...overrides, }), - receipt: (overrides: any = {}) => ({ + receipt: (overrides: Partial = {}) => ({ id: "rrrrrrrr-rrrr-rrrr-rrrr-rrrrrrrrrrrr", receiptNumber: 44, donationId: "11111111-2222-3333-4444-555555555555", @@ -233,7 +233,7 @@ export const fixtures = { ...overrides, }), - receiptConfig: (overrides: any = {}) => ({ + receiptConfig: (overrides: Partial = {}) => ({ id: 1, orgName: "Our Village Inc.", dgrName: "Our Village Inc.", From 8849b24b73c702f495432b6e8905be77f2973076 Mon Sep 17 00:00:00 2001 From: youngha kim Date: Fri, 20 Mar 2026 10:26:09 +1100 Subject: [PATCH 10/10] Fix container-runtime test: mockImplementationOnce only covered first retry The test used mockImplementationOnce which only threw on the first docker info call. On the second retry, the mock returned undefined (no throw), so the function succeeded instead of exhausting retries. Changed to mockImplementation with a cmd filter so all docker info calls throw while sleep calls succeed. Co-Authored-By: Claude Opus 4.6 (1M context) --- src/container-runtime.test.ts | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/src/container-runtime.test.ts b/src/container-runtime.test.ts index 08ffd59b46f..3c94f7af38f 100644 --- a/src/container-runtime.test.ts +++ b/src/container-runtime.test.ts @@ -65,8 +65,11 @@ describe('ensureContainerRuntimeRunning', () => { }); it('throws when docker info fails', () => { - mockExecSync.mockImplementationOnce(() => { - throw new Error('Cannot connect to the Docker daemon'); + mockExecSync.mockImplementation((cmd: string) => { + if (typeof cmd === 'string' && cmd.includes('info')) { + throw new Error('Cannot connect to the Docker daemon'); + } + return ''; }); expect(() => ensureContainerRuntimeRunning()).toThrow(