Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
141 changes: 141 additions & 0 deletions src/commands/__tests__/phone.test.ts
Original file line number Diff line number Diff line change
@@ -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();
});
});
130 changes: 130 additions & 0 deletions src/commands/org-profile.ts
Original file line number Diff line number Diff line change
@@ -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 <email>', 'Company contact email')
.option('--phone <phone>', 'Company phone (NOT your personal alert phone)')
.option('--website <url>', 'Company website')
.option('--business-category <text>', 'e.g. E-commerce, SaaS, Agency')
.option('--business-niche <text>', 'e.g. Fashion retail, Dental clinics')
.option('--primary-use-case <text>', '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<string, string> = {};
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`);
}
Loading
Loading