From f0d99916b219c72d79b9e80e93e34af581e5e869 Mon Sep 17 00:00:00 2001 From: Or Dvir Date: Sun, 9 Aug 2026 14:40:01 +0300 Subject: [PATCH 1/3] AIT-370: hookmyapp phone (set/verify/status/consents) + org profile commands --- src/commands/__tests__/phone.test.ts | 141 ++++++++++++++++++++++ src/commands/org-profile.ts | 130 ++++++++++++++++++++ src/commands/phone.ts | 172 +++++++++++++++++++++++++++ src/index.ts | 4 + 4 files changed, 447 insertions(+) create mode 100644 src/commands/__tests__/phone.test.ts create mode 100644 src/commands/org-profile.ts create mode 100644 src/commands/phone.ts diff --git a/src/commands/__tests__/phone.test.ts b/src/commands/__tests__/phone.test.ts new file mode 100644 index 0000000..7381035 --- /dev/null +++ b/src/commands/__tests__/phone.test.ts @@ -0,0 +1,141 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { Command } from 'commander'; + +vi.mock('../../api/client.js', () => ({ + apiClient: vi.fn(), +})); +vi.mock('../_helpers.js', () => ({ + getDefaultWorkspaceId: vi.fn().mockResolvedValue('ws_TEST0001'), + resolveOrgPublicIdForWorkspace: vi.fn().mockResolvedValue('org_abc12345'), +})); + +const mockConsoleLog = vi.spyOn(console, 'log').mockImplementation(() => {}); + +import { apiClient } from '../../api/client.js'; +import { registerPhoneCommand } from '../phone.js'; +import { registerOrgProfileCommand } from '../org-profile.js'; + +const mockedApi = vi.mocked(apiClient); + +const STATUS = { + phone: '+155•••4567', + verified: true, + consents: { operational: true, product: false, marketing: false }, + channelPreference: 'whatsapp', +}; + +function makeProgram(): Command { + const program = new Command(); + program.exitOverride(); + program.option('--json'); + registerPhoneCommand(program); + registerOrgProfileCommand(program); + return program; +} + +beforeEach(() => { + mockedApi.mockReset(); + mockConsoleLog.mockClear(); +}); + +describe('hookmyapp phone', () => { + it('phone status GETs /auth/phone and prints JSON with --json', async () => { + mockedApi.mockResolvedValue(STATUS); + + await makeProgram().parseAsync(['node', 'hookmyapp', 'phone', 'status', '--json']); + + expect(mockedApi).toHaveBeenCalledWith('/auth/phone'); + expect(mockConsoleLog.mock.calls[0][0]).toContain('+155•••4567'); + }); + + it('phone set normalizes the number, defaults operational on, POSTs /auth/phone', async () => { + mockedApi.mockResolvedValue({ delivery: 'sent' }); + + await makeProgram().parseAsync(['node', 'hookmyapp', 'phone', 'set', '+1 (555) 123-4567', '--product']); + + const [path, init] = mockedApi.mock.calls[0]; + expect(path).toBe('/auth/phone'); + expect(JSON.parse((init as { body: string }).body)).toEqual({ + phone: '+15551234567', + consentOperational: true, + consentProduct: true, + consentMarketing: false, + channelPreference: 'whatsapp', + }); + }); + + it('phone set rejects a non-E.164 number without calling the API', async () => { + await expect( + makeProgram().parseAsync(['node', 'hookmyapp', 'phone', 'set', 'not-a-number']), + ).rejects.toThrow(/international format/); + expect(mockedApi).not.toHaveBeenCalled(); + }); + + it('phone verify POSTs the code and rejects non-6-digit codes locally', async () => { + mockedApi.mockResolvedValue(STATUS); + await makeProgram().parseAsync(['node', 'hookmyapp', 'phone', 'verify', '123456']); + expect(mockedApi).toHaveBeenCalledWith('/auth/phone/verify', expect.objectContaining({ method: 'POST' })); + + mockedApi.mockClear(); + await expect( + makeProgram().parseAsync(['node', 'hookmyapp', 'phone', 'verify', '12']), + ).rejects.toThrow(/6 digits/); + expect(mockedApi).not.toHaveBeenCalled(); + }); + + it('phone consents PATCHes only the provided flags', async () => { + mockedApi.mockResolvedValue(STATUS); + + await makeProgram().parseAsync(['node', 'hookmyapp', 'phone', 'consents', '--marketing', 'on', '--prefer', 'both']); + + const [path, init] = mockedApi.mock.calls[0]; + expect(path).toBe('/auth/phone/consents'); + expect(JSON.parse((init as { body: string }).body)).toEqual({ marketing: true, channelPreference: 'both' }); + }); +}); + +describe('hookmyapp org profile', () => { + const PROFILE = { + publicId: 'org_abc12345', + name: 'Acme', + email: null, + phone: null, + website: 'https://acme.com', + businessCategory: null, + businessNiche: null, + primaryUseCase: null, + }; + + it('org profile show GETs the org summary', async () => { + mockedApi.mockResolvedValue(PROFILE); + + await makeProgram().parseAsync(['node', 'hookmyapp', 'org', 'profile', 'show', '--json']); + + expect(mockedApi).toHaveBeenCalledWith('/organizations/org_abc12345'); + expect(mockConsoleLog.mock.calls[0][0]).toContain('acme.com'); + }); + + it('org profile set PATCHes only the provided fields', async () => { + mockedApi.mockResolvedValue(PROFILE); + + await makeProgram().parseAsync([ + 'node', 'hookmyapp', 'org', 'profile', 'set', + '--website', 'https://acme.com', '--business-category', 'E-commerce', + ]); + + const [path, init] = mockedApi.mock.calls[0]; + expect(path).toBe('/organizations/org_abc12345/profile'); + expect((init as { method: string }).method).toBe('PATCH'); + expect(JSON.parse((init as { body: string }).body)).toEqual({ + website: 'https://acme.com', + businessCategory: 'E-commerce', + }); + }); + + it('org profile set with no flags fails locally without calling the API', async () => { + await expect( + makeProgram().parseAsync(['node', 'hookmyapp', 'org', 'profile', 'set']), + ).rejects.toThrow(/Nothing to update/); + expect(mockedApi).not.toHaveBeenCalled(); + }); +}); diff --git a/src/commands/org-profile.ts b/src/commands/org-profile.ts new file mode 100644 index 0000000..dd710a4 --- /dev/null +++ b/src/commands/org-profile.ts @@ -0,0 +1,130 @@ +import type { Command } from 'commander'; +import { apiClient } from '../api/client.js'; +import { output } from '../output/format.js'; +import { ValidationError } from '../output/error.js'; +import { addExamples } from '../output/help.js'; +import { getDefaultWorkspaceId, resolveOrgPublicIdForWorkspace } from './_helpers.js'; + +/** + * AIT-370 — `hookmyapp org profile`: read/write the organization profile + * (company info). This is COMPANY data — the `--phone` here is the company + * phone, NOT anyone's personal alert phone (that is `hookmyapp phone`). + * Ask the human for company details; never infer or invent them. Org admins only. + */ + +interface OrgProfile { + publicId: string; + name: string; + email: string | null; + phone: string | null; + website: string | null; + businessCategory: string | null; + businessNiche: string | null; + primaryUseCase: string | null; +} + +function printProfile(profile: OrgProfile, json: boolean): void { + if (json) { + console.log(JSON.stringify(profile, null, 2)); + return; + } + output( + [ + { + ORG: profile.publicId, + NAME: profile.name, + EMAIL: profile.email ?? '', + PHONE: profile.phone ?? '', + WEBSITE: profile.website ?? '', + CATEGORY: profile.businessCategory ?? '', + NICHE: profile.businessNiche ?? '', + 'USE CASE': profile.primaryUseCase ?? '', + }, + ], + { human: true }, + ); +} + +export function registerOrgProfileCommand(program: Command): void { + // `org` may already exist (other org-scoped commands); reuse it if so. + const existing = program.commands.find((c) => c.name() === 'org'); + const org = existing ?? program.command('org').description('Organization-level settings'); + if (!existing) { + addExamples( + org, + ` +EXAMPLES: + $ hookmyapp org profile + $ hookmyapp org profile set --website https://acme.com +`, + ); + } + + const profile = org + .command('profile') + .description( + 'Company profile (email, phone, website, business category/niche, use case). ' + + 'Company data — for your personal alert number use: hookmyapp phone', + ); + addExamples( + profile, + ` +EXAMPLES: + $ hookmyapp org profile + $ hookmyapp org profile set --website https://acme.com --business-category "E-commerce" +`, + ); + + const show = profile + .command('show', { isDefault: true }) + .description('Show the organization profile') + .option('--json', 'Output machine-readable JSON') + .action(async (opts: { json?: boolean }) => { + const workspaceId = await getDefaultWorkspaceId(); + const orgPublicId = await resolveOrgPublicIdForWorkspace(workspaceId); + const res = (await apiClient(`/organizations/${orgPublicId}`)) as { organization?: OrgProfile } & OrgProfile; + const data = (res.organization ?? res) as OrgProfile; + printProfile(data, Boolean(opts.json || program.opts().json)); + }); + addExamples(show, `\nEXAMPLES:\n $ hookmyapp org profile show\n $ hookmyapp org profile show --json\n`); + + const set = profile + .command('set') + .description('Update company profile fields (only provided flags change; "" clears a field)') + .option('--email ', 'Company contact email') + .option('--phone ', 'Company phone (NOT your personal alert phone)') + .option('--website ', 'Company website') + .option('--business-category ', 'e.g. E-commerce, SaaS, Agency') + .option('--business-niche ', 'e.g. Fashion retail, Dental clinics') + .option('--primary-use-case ', 'What the company uses HookMyApp for') + .option('--json', 'Output machine-readable JSON') + .action( + async (opts: { + email?: string; + phone?: string; + website?: string; + businessCategory?: string; + businessNiche?: string; + primaryUseCase?: string; + json?: boolean; + }) => { + const body: Record = {}; + for (const key of ['email', 'phone', 'website', 'businessCategory', 'businessNiche', 'primaryUseCase'] as const) { + if (opts[key] !== undefined) body[key] = opts[key] as string; + } + if (Object.keys(body).length === 0) { + throw new ValidationError( + 'Nothing to update — pass at least one of --email/--phone/--website/--business-category/--business-niche/--primary-use-case', + ); + } + const workspaceId = await getDefaultWorkspaceId(); + const orgPublicId = await resolveOrgPublicIdForWorkspace(workspaceId); + const res = (await apiClient(`/organizations/${orgPublicId}/profile`, { + method: 'PATCH', + body: JSON.stringify(body), + })) as OrgProfile; + printProfile(res, Boolean(opts.json || program.opts().json)); + }, + ); + addExamples(set, `\nEXAMPLES:\n $ hookmyapp org profile set --email hello@acme.com\n $ hookmyapp org profile set --business-niche "Dental clinics" --primary-use-case "Appointment reminders"\n`); +} diff --git a/src/commands/phone.ts b/src/commands/phone.ts new file mode 100644 index 0000000..9e61519 --- /dev/null +++ b/src/commands/phone.ts @@ -0,0 +1,172 @@ +import type { Command } from 'commander'; +import { apiClient } from '../api/client.js'; +import { output } from '../output/format.js'; +import { ValidationError } from '../output/error.js'; +import { addExamples } from '../output/help.js'; + +/** + * AIT-370 — `hookmyapp phone`: the calling user's PRIVATE alert phone + * (breakdown/product/marketing notifications over WhatsApp/SMS). Thin wrapper + * over POST/GET /auth/phone + /auth/phone/verify + PATCH /auth/phone/consents. + * + * Agent etiquette: the number and the code come from the HUMAN. Never invent, + * guess, or reuse either; a decline is a normal outcome — skip and continue. + */ + +interface PhoneStatus { + phone: string | null; + verified: boolean; + consents: { operational: boolean; product: boolean; marketing: boolean }; + channelPreference: string; +} + +const E164 = /^\+[1-9]\d{6,14}$/; + +function printStatus(status: PhoneStatus, json: boolean): void { + if (json) { + console.log(JSON.stringify(status, null, 2)); + return; + } + output( + [ + { + PHONE: status.phone ?? '(none)', + VERIFIED: status.verified ? 'yes' : 'no', + OPERATIONAL: status.consents.operational ? 'on' : 'off', + PRODUCT: status.consents.product ? 'on' : 'off', + MARKETING: status.consents.marketing ? 'on' : 'off', + PREFERENCE: status.channelPreference, + }, + ], + { human: true }, + ); +} + +export function registerPhoneCommand(program: Command): void { + const phone = program + .command('phone') + .description( + 'Your personal alert phone — HookMyApp texts it (WhatsApp/SMS) when your integration ' + + 'breaks. Ask the human for THEIR number and the code they receive; never invent either. ' + + 'Declining is fine — nothing else depends on it.', + ); + addExamples( + phone, + ` +EXAMPLES: + $ hookmyapp phone status + $ hookmyapp phone set +14155552671 --product + $ hookmyapp phone verify 123456 +`, + ); + + const status = phone + .command('status', { isDefault: true }) + .description('Show the verified alert phone (masked) and notification consents') + .option('--json', 'Output machine-readable JSON') + .action(async (opts: { json?: boolean }) => { + const res = (await apiClient('/auth/phone')) as PhoneStatus; + printStatus(res, Boolean(opts.json || program.opts().json)); + }); + addExamples(status, `\nEXAMPLES:\n $ hookmyapp phone status\n $ hookmyapp phone status --json\n`); + + const set = phone + .command('set') + .description('Start verification: sends a 6-digit code to the number (international format)') + .argument('', 'The phone number in international format, e.g. +14155552671') + .option('--no-operational', 'Opt out of breakdown alerts (on by default)') + .option('--product', 'Opt in to feature announcements') + .option('--marketing', 'Opt in to offers/promotions') + .option('--prefer ', 'Delivery channel: whatsapp | sms | both', 'whatsapp') + .option('--json', 'Output machine-readable JSON') + .action( + async ( + number: string, + opts: { operational?: boolean; product?: boolean; marketing?: boolean; prefer?: string; json?: boolean }, + ) => { + const cleaned = number.replace(/[\s\-().]/g, ''); + if (!E164.test(cleaned)) { + throw new ValidationError( + `Invalid phone "${number}" — use international format, e.g. +14155552671.`, + ); + } + if (!['whatsapp', 'sms', 'both'].includes(opts.prefer ?? 'whatsapp')) { + throw new ValidationError('--prefer must be whatsapp, sms, or both'); + } + const res = (await apiClient('/auth/phone', { + method: 'POST', + body: JSON.stringify({ + phone: cleaned, + consentOperational: opts.operational ?? true, + consentProduct: opts.product ?? false, + consentMarketing: opts.marketing ?? false, + channelPreference: opts.prefer ?? 'whatsapp', + }), + })) as { delivery: 'sent' | 'unavailable' }; + if (opts.json || program.opts().json) { + console.log(JSON.stringify(res, null, 2)); + return; + } + if (res.delivery === 'sent') { + console.log('Code sent. Ask the human for the 6-digit code, then run: hookmyapp phone verify '); + } else { + console.log('Code delivery is unavailable in this environment. Verify later from the web app.'); + } + }, + ); + addExamples(set, `\nEXAMPLES:\n $ hookmyapp phone set +14155552671\n $ hookmyapp phone set +14155552671 --product --prefer both\n`); + + const verify = phone + .command('verify') + .description('Complete verification with the 6-digit code the human received') + .argument('', 'The 6-digit code from the phone') + .option('--json', 'Output machine-readable JSON') + .action(async (code: string, opts: { json?: boolean }) => { + if (!/^\d{6}$/.test(code)) { + throw new ValidationError(`Invalid code "${code}" — expected 6 digits.`); + } + const res = (await apiClient('/auth/phone/verify', { + method: 'POST', + body: JSON.stringify({ code }), + })) as PhoneStatus; + printStatus(res, Boolean(opts.json || program.opts().json)); + }); + addExamples(verify, `\nEXAMPLES:\n $ hookmyapp phone verify 123456\n $ hookmyapp phone verify 654321 --json\n`); + + const consents = phone + .command('consents') + .description('Update notification consents / delivery preference without re-verifying') + .option('--operational ', 'Breakdown alerts') + .option('--product ', 'Feature announcements') + .option('--marketing ', 'Offers/promotions') + .option('--prefer ', 'Delivery channel: whatsapp | sms | both') + .option('--json', 'Output machine-readable JSON') + .action( + async (opts: { operational?: string; product?: string; marketing?: string; prefer?: string; json?: boolean }) => { + const toBool = (v: string | undefined, flag: string): boolean | undefined => { + if (v === undefined) return undefined; + if (v !== 'on' && v !== 'off') throw new ValidationError(`${flag} must be "on" or "off"`); + return v === 'on'; + }; + const body: Record = {}; + const operational = toBool(opts.operational, '--operational'); + const product = toBool(opts.product, '--product'); + const marketing = toBool(opts.marketing, '--marketing'); + if (operational !== undefined) body.operational = operational; + if (product !== undefined) body.product = product; + if (marketing !== undefined) body.marketing = marketing; + if (opts.prefer !== undefined) { + if (!['whatsapp', 'sms', 'both'].includes(opts.prefer)) { + throw new ValidationError('--prefer must be whatsapp, sms, or both'); + } + body.channelPreference = opts.prefer; + } + if (Object.keys(body).length === 0) { + throw new ValidationError('Nothing to update — pass at least one of --operational/--product/--marketing/--prefer'); + } + const res = (await apiClient('/auth/phone/consents', { method: 'PATCH', body: JSON.stringify(body) })) as PhoneStatus; + printStatus(res, Boolean(opts.json || program.opts().json)); + }, + ); + addExamples(consents, `\nEXAMPLES:\n $ hookmyapp phone consents --marketing on\n $ hookmyapp phone consents --operational off --prefer sms\n`); +} diff --git a/src/index.ts b/src/index.ts index 31a542d..aa99f66 100644 --- a/src/index.ts +++ b/src/index.ts @@ -16,6 +16,8 @@ import { registerWorkspaceCommand } from './commands/workspace.js'; import { registerCustomersCommand } from './commands/customers.js'; import { registerSupportCommand } from './commands/support.js'; import { registerNotificationsCommand } from './commands/notifications.js'; +import { registerPhoneCommand } from './commands/phone.js'; +import { registerOrgProfileCommand } from './commands/org-profile.js'; import { registerSandboxCommand } from './commands/sandbox/index.js'; import { registerListenCommand } from './commands/sandbox-listen/index.js'; import { registerConfigCommand } from './commands/config.js'; @@ -193,6 +195,8 @@ registerWorkspaceCommand(program); registerCustomersCommand(program); registerSupportCommand(program); registerNotificationsCommand(program); +registerPhoneCommand(program); +registerOrgProfileCommand(program); // Persistent CLI config (env profile: local | staging | production) registerConfigCommand(program); From 8dff014de614096620cf34dc7820b245d2c52040 Mon Sep 17 00:00:00 2001 From: Or Dvir Date: Sun, 9 Aug 2026 16:14:30 +0300 Subject: [PATCH 2/3] AIT-370: static message for invalid-phone ValidationError (no raw number to Sentry) --- src/commands/phone.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/commands/phone.ts b/src/commands/phone.ts index 9e61519..7a4d488 100644 --- a/src/commands/phone.ts +++ b/src/commands/phone.ts @@ -86,8 +86,10 @@ EXAMPLES: ) => { const cleaned = number.replace(/[\s\-().]/g, ''); if (!E164.test(cleaned)) { + // Static message: ValidationError reaches Sentry via captureException + // when telemetry is on — never interpolate the raw number. throw new ValidationError( - `Invalid phone "${number}" — use international format, e.g. +14155552671.`, + 'Invalid phone number — use international format, e.g. +14155552671.', ); } if (!['whatsapp', 'sms', 'both'].includes(opts.prefer ?? 'whatsapp')) { From e0ce67fa2659c955c9792e3b2325c5b00c6b961c Mon Sep 17 00:00:00 2001 From: Or Dvir Date: Sun, 9 Aug 2026 16:31:30 +0300 Subject: [PATCH 3/3] AIT-370: static message for invalid-code ValidationError (no OTP to Sentry) --- src/commands/phone.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/commands/phone.ts b/src/commands/phone.ts index 7a4d488..a14ae1f 100644 --- a/src/commands/phone.ts +++ b/src/commands/phone.ts @@ -125,7 +125,9 @@ EXAMPLES: .option('--json', 'Output machine-readable JSON') .action(async (code: string, opts: { json?: boolean }) => { if (!/^\d{6}$/.test(code)) { - throw new ValidationError(`Invalid code "${code}" — expected 6 digits.`); + // Static message: a mistyped-but-real code must never reach Sentry + // through the telemetry error path. + throw new ValidationError('Invalid code — expected exactly 6 digits.'); } const res = (await apiClient('/auth/phone/verify', { method: 'POST',