Skip to content
Open
381 changes: 4 additions & 377 deletions crm/src/cli.ts

Large diffs are not rendered by default.

86 changes: 86 additions & 0 deletions crm/src/commands/commands.test.ts
Original file line number Diff line number Diff line change
@@ -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");
});
});
18 changes: 18 additions & 0 deletions crm/src/commands/config.ts
Original file line number Diff line number Diff line change
@@ -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("<key> <value>").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());
});
}
119 changes: 119 additions & 0 deletions crm/src/commands/contacts.ts
Original file line number Diff line number Diff line change
@@ -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("<firstName>", "First name")
.argument("<lastName>", "Last name")
.option("-e, --email <email>", "Email address")
.option("-p, --phone <phone>", "Phone number")
.option("--address-line1 <addr>", "Street address")
.option("--address-line2 <addr>", "Address line 2")
.option("--suburb <suburb>", "Suburb")
.option("--state <state>", "State (VIC, NSW, QLD, SA, WA, TAS, NT, ACT)")
.option("--postcode <postcode>", "4-digit postcode")
.option("-t, --type <type>", "Type: donor, volunteer, client, board, other", "other")
.option("--tag <tag...>", "Tags (key or key=value, repeatable)")
.option("--notes <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 <type>", "Filter by type")
.option("--tag <tag...>", "Filter by tag (AND logic)")
.option("-s, --search <query>", "Full-text search")
.option("--state <state>", "Filter by state")
.option("-n, --limit <n>", "Max results", "50")
.option("--offset <n>", "Pagination offset", "0")
.option("--sort <field>", "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("<query>", "Search query")
.option("--type <type>", "contact, org, or all", "all")
.option("-n, --limit <n>", "Max results", "20")
.action(async (query, opts) => {
output(await contactsSearch(query, { type: opts.type, limit: parseInt(opts.limit) }), fmt());
});

contacts
.command("show")
.argument("<id>", "Contact UUID prefix (8+ chars)")
.description("Show full contact details (READ tier)")
.action(async (id) => {
output(await contactsShow(id), fmt());
});

contacts
.command("edit")
.argument("<id>", "Contact UUID prefix (8+ chars)")
.description("Edit a contact (WRITE tier)")
.option("--first-name <name>", "First name")
.option("--last-name <name>", "Last name")
.option("-e, --email <email>", "Email address")
.option("-p, --phone <phone>", "Phone number")
.option("--address-line1 <addr>", "Street address")
.option("--address-line2 <addr>", "Address line 2")
.option("--suburb <suburb>", "Suburb")
.option("--state <state>", "State (VIC, NSW, QLD, SA, WA, TAS, NT, ACT)")
.option("--postcode <postcode>", "4-digit postcode")
.option("-t, --type <type>", "Type: donor, volunteer, client, board, other")
.option("--notes <notes>", "Notes")
.option("--add-tag <tag...>", "Add tags (key or key=value, repeatable)")
.option("--remove-tag <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("<id>", "Contact UUID prefix (8+ chars)")
.description("Activity timeline for a contact (READ tier)")
.option("-n, --limit <n>", "Max events", "50")
.option("--from <date>", "Start date filter")
.option("--to <date>", "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("<file>", "CSV file path")
.description("Import contacts from CSV (WRITE tier)")
.option("--map <mapping...>", "Column mapping: \"CSV Column=fieldName\" (repeatable)")
.option("--preset <preset>", "Column preset: salesforce")
.option("--on-duplicate <action>", "skip, update, or error", "skip")
.option("--tag <tag...>", "Tags to apply to all imported contacts")
.option("-t, --type <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("<id>").description("Delete a contact (WRITE)").option("--confirm").action(stub("contacts.delete"));
contacts.command("export").description("Export contacts to CSV (READ)").option("-o, --output <file>").action(stub("contacts.export"));
contacts.command("dedup").description("Find duplicate contacts (READ)").action(stub("contacts.dedup"));
contacts.command("merge").arguments("<id1> <id2>").description("Merge two contacts (WRITE)").option("--confirm").action(stub("contacts.merge"));
contacts.command("link").arguments("<contact> <org>").description("Link contact to org (WRITE)").option("--role <role>").option("--confirm").action(stub("contacts.link"));
}
54 changes: 54 additions & 0 deletions crm/src/commands/donations.ts
Original file line number Diff line number Diff line change
@@ -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("<amount>").description("Record a donation (WRITE)")
.option("-c, --contact <contact>", "Contact name/email/ID")
.option("-d, --date <date>", "Donation date", today())
.option("-m, --method <method>", "cash, cheque, eft, card, in_kind, other")
.option("--fund <fund>", "Fund allocation", "general")
.option("--campaign <campaign>", "Campaign attribution")
.option("-r, --reference <ref>", "External reference")
.option("--no-dgr", "Not DGR-eligible")
.option("--notes <notes>")
.option("--confirm", "", false)
.action(async (amount, opts) => {
output(await donationsAdd(amount, opts), fmt());
});

don.command("list").description("List donations (READ)")
.option("-c, --contact <contact>")
.option("--from <date>")
.option("--to <date>")
.option("-m, --method <method>")
.option("--fund <fund>")
.option("--campaign <campaign>")
.option("--status <status>")
.option("--unreceipted", "Only unreceipted DGR-eligible")
.option("-n, --limit <n>", "", "50")
.option("--sort <field>", "", "-donationDate")
.action(async (opts) => {
output(await donationsList({ ...opts, limit: parseInt(opts.limit) }), fmt());
});

don.command("show").argument("<id>").description("Show donation (READ)")
.action(async (id) => {
output(await donationsShow(id), fmt());
});

don.command("edit").argument("<id>").description("Edit donation (WRITE)").option("--confirm").action(stub("donations.edit"));

don.command("void").argument("<id>").description("Void a donation (WRITE)")
.option("--reason <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());
});
}
54 changes: 54 additions & 0 deletions crm/src/commands/index.ts
Original file line number Diff line number Diff line change
@@ -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 <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: [] };
};
}
18 changes: 18 additions & 0 deletions crm/src/commands/jobs.ts
Original file line number Diff line number Diff line change
@@ -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 <id>", "Filter by task ID")
.option("--status <status>", "Filter: success or error")
.option("--from <date>", "Start date filter")
.option("-n, --limit <n>", "Max results", "50")
.action(async (opts) => {
output(await jobsHistory({ task: opts.task, status: opts.status, from: opts.from, limit: parseInt(opts.limit) }), fmt());
});
}
27 changes: 27 additions & 0 deletions crm/src/commands/orgs.ts
Original file line number Diff line number Diff line change
@@ -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("<name>").description("Add organisation (WRITE)")
.option("--abn <abn>", "ABN (11 digits)")
.option("--org-type <type>", "Type: charity, government, corporate, community, other", "other")
.option("--address-line1 <addr>", "Street address")
.option("--suburb <suburb>", "Suburb")
.option("--state <state>", "State (VIC, NSW, QLD, SA, WA, TAS, NT, ACT)")
.option("--postcode <postcode>", "4-digit postcode")
.option("--phone <phone>", "Phone number")
.option("--website <url>", "Website URL")
.option("--notes <notes>", "Notes")
.option("--tag <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("<id>").description("Show organisation (READ)").action(stub("orgs.show"));
}
Loading
Loading