quoxcli 0.0.0-stage → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +35 -0
- package/README.md +142 -2
- package/bin/quox.js +292 -0
- package/bin/quoxon.js +8 -0
- package/lib/api/endpoints/admin.js +125 -0
- package/lib/api/endpoints/aee.js +38 -0
- package/lib/api/endpoints/agent-attach.js +126 -0
- package/lib/api/endpoints/agents.js +56 -0
- package/lib/api/endpoints/assistants.js +50 -0
- package/lib/api/endpoints/audit.js +14 -0
- package/lib/api/endpoints/auth.js +50 -0
- package/lib/api/endpoints/awareness.js +34 -0
- package/lib/api/endpoints/backup-schedules.js +20 -0
- package/lib/api/endpoints/backups.js +86 -0
- package/lib/api/endpoints/brain.js +140 -0
- package/lib/api/endpoints/budgets.js +62 -0
- package/lib/api/endpoints/calendarq.js +71 -0
- package/lib/api/endpoints/catalog.js +14 -0
- package/lib/api/endpoints/chat.js +54 -0
- package/lib/api/endpoints/consent.js +26 -0
- package/lib/api/endpoints/contactsq.js +66 -0
- package/lib/api/endpoints/conversations.js +32 -0
- package/lib/api/endpoints/custom-roles.js +92 -0
- package/lib/api/endpoints/divisions.js +56 -0
- package/lib/api/endpoints/domains.js +38 -0
- package/lib/api/endpoints/engagement.js +231 -0
- package/lib/api/endpoints/engine.js +11 -0
- package/lib/api/endpoints/entity.js +50 -0
- package/lib/api/endpoints/executions.js +32 -0
- package/lib/api/endpoints/file.js +20 -0
- package/lib/api/endpoints/files.js +26 -0
- package/lib/api/endpoints/github.js +21 -0
- package/lib/api/endpoints/governance.js +43 -0
- package/lib/api/endpoints/hat.js +36 -0
- package/lib/api/endpoints/inbox.js +32 -0
- package/lib/api/endpoints/inboxq.js +120 -0
- package/lib/api/endpoints/index.js +228 -0
- package/lib/api/endpoints/invites.js +19 -0
- package/lib/api/endpoints/llm-policy.js +43 -0
- package/lib/api/endpoints/lucid.js +89 -0
- package/lib/api/endpoints/members.js +32 -0
- package/lib/api/endpoints/memory.js +75 -0
- package/lib/api/endpoints/mfa.js +32 -0
- package/lib/api/endpoints/mon.js +178 -0
- package/lib/api/endpoints/notesq.js +43 -0
- package/lib/api/endpoints/objectives.js +62 -0
- package/lib/api/endpoints/orgs.js +38 -0
- package/lib/api/endpoints/plugins.js +134 -0
- package/lib/api/endpoints/powers.js +75 -0
- package/lib/api/endpoints/prompts.js +69 -0
- package/lib/api/endpoints/qlarity.js +122 -0
- package/lib/api/endpoints/quoxbox.js +55 -0
- package/lib/api/endpoints/quoxchat.js +54 -0
- package/lib/api/endpoints/quoxflow.js +32 -0
- package/lib/api/endpoints/quoxplan.js +114 -0
- package/lib/api/endpoints/quoxseo.js +108 -0
- package/lib/api/endpoints/reach.js +47 -0
- package/lib/api/endpoints/runs.js +38 -0
- package/lib/api/endpoints/safety.js +46 -0
- package/lib/api/endpoints/scim.js +46 -0
- package/lib/api/endpoints/scopes.js +14 -0
- package/lib/api/endpoints/security.js +69 -0
- package/lib/api/endpoints/setup.js +90 -0
- package/lib/api/endpoints/skills.js +180 -0
- package/lib/api/endpoints/sso.js +67 -0
- package/lib/api/endpoints/tags.js +32 -0
- package/lib/api/endpoints/teams.js +56 -0
- package/lib/api/endpoints/telephony.js +46 -0
- package/lib/api/endpoints/tg.js +56 -0
- package/lib/api/endpoints/tools.js +50 -0
- package/lib/api/endpoints/traces.js +157 -0
- package/lib/api/endpoints/triggers.js +70 -0
- package/lib/api/endpoints/user-library.js +41 -0
- package/lib/api/endpoints/users.js +20 -0
- package/lib/api/endpoints/v1.js +499 -0
- package/lib/api/endpoints/vault.js +192 -0
- package/lib/api/endpoints/voice.js +22 -0
- package/lib/api/endpoints/volt.js +84 -0
- package/lib/api/endpoints/ward.js +40 -0
- package/lib/api/endpoints/webhooks.js +46 -0
- package/lib/api/endpoints/workflows.js +162 -0
- package/lib/api/endpoints/x.js +24 -0
- package/lib/api/endpoints.js +15 -0
- package/lib/commands/admin.js +359 -0
- package/lib/commands/aee.js +99 -0
- package/lib/commands/agent.js +201 -0
- package/lib/commands/agentAttach.js +1440 -0
- package/lib/commands/approvals.js +47 -0
- package/lib/commands/assistant.js +114 -0
- package/lib/commands/auth.js +458 -0
- package/lib/commands/awareness.js +88 -0
- package/lib/commands/backup.js +373 -0
- package/lib/commands/brain.js +994 -0
- package/lib/commands/budget.js +241 -0
- package/lib/commands/build.js +156 -0
- package/lib/commands/calendar.js +216 -0
- package/lib/commands/capabilities.js +34 -0
- package/lib/commands/chat.js +240 -0
- package/lib/commands/claude.js +354 -0
- package/lib/commands/config-cmd.js +106 -0
- package/lib/commands/consent.js +84 -0
- package/lib/commands/contacts.js +967 -0
- package/lib/commands/contactsSelftest.js +132 -0
- package/lib/commands/conversation.js +74 -0
- package/lib/commands/department.js +75 -0
- package/lib/commands/dev.js +620 -0
- package/lib/commands/devResident.js +314 -0
- package/lib/commands/devSupervisor.js +1050 -0
- package/lib/commands/discord.js +96 -0
- package/lib/commands/doctor.js +181 -0
- package/lib/commands/domain.js +114 -0
- package/lib/commands/done.js +277 -0
- package/lib/commands/engagement.js +586 -0
- package/lib/commands/entity.js +119 -0
- package/lib/commands/facebook.js +212 -0
- package/lib/commands/file.js +128 -0
- package/lib/commands/fleet.js +882 -0
- package/lib/commands/governance.js +638 -0
- package/lib/commands/gripe.js +324 -0
- package/lib/commands/hat.js +205 -0
- package/lib/commands/helpers.js +79 -0
- package/lib/commands/inbox.js +122 -0
- package/lib/commands/instagram.js +264 -0
- package/lib/commands/integration.js +100 -0
- package/lib/commands/inventory.js +115 -0
- package/lib/commands/invites.js +45 -0
- package/lib/commands/licence.js +280 -0
- package/lib/commands/linkedin.js +178 -0
- package/lib/commands/llm-policy.js +117 -0
- package/lib/commands/llm.js +45 -0
- package/lib/commands/lucid.js +591 -0
- package/lib/commands/mail.js +531 -0
- package/lib/commands/memory.js +284 -0
- package/lib/commands/mon.js +514 -0
- package/lib/commands/monitoring.js +86 -0
- package/lib/commands/motion.js +22 -0
- package/lib/commands/notes.js +1246 -0
- package/lib/commands/notesBatch.js +160 -0
- package/lib/commands/notesBridges.js +56 -0
- package/lib/commands/notesLinks.js +60 -0
- package/lib/commands/notification.js +57 -0
- package/lib/commands/nucleus.js +92 -0
- package/lib/commands/objective.js +229 -0
- package/lib/commands/onboard.js +328 -0
- package/lib/commands/open.js +56 -0
- package/lib/commands/org.js +266 -0
- package/lib/commands/plan.js +906 -0
- package/lib/commands/plugin-doctor.js +1091 -0
- package/lib/commands/plugin.js +539 -0
- package/lib/commands/policy.js +78 -0
- package/lib/commands/powers.js +296 -0
- package/lib/commands/prompt.js +350 -0
- package/lib/commands/qlarity.js +690 -0
- package/lib/commands/quoxbox.js +270 -0
- package/lib/commands/quoxchat.js +266 -0
- package/lib/commands/quoxseo.js +340 -0
- package/lib/commands/reach.js +158 -0
- package/lib/commands/role.js +286 -0
- package/lib/commands/run.js +132 -0
- package/lib/commands/safety.js +107 -0
- package/lib/commands/scim.js +88 -0
- package/lib/commands/scope.js +58 -0
- package/lib/commands/security.js +157 -0
- package/lib/commands/service.js +89 -0
- package/lib/commands/settings.js +99 -0
- package/lib/commands/setup.js +543 -0
- package/lib/commands/skillManifest.js +113 -0
- package/lib/commands/skillRightsFormat.js +38 -0
- package/lib/commands/skills.js +644 -0
- package/lib/commands/slot.js +1198 -0
- package/lib/commands/sso.js +229 -0
- package/lib/commands/status.js +137 -0
- package/lib/commands/tag.js +91 -0
- package/lib/commands/team.js +140 -0
- package/lib/commands/tg.js +240 -0
- package/lib/commands/tiktok.js +218 -0
- package/lib/commands/tool.js +216 -0
- package/lib/commands/trace.js +525 -0
- package/lib/commands/trigger.js +128 -0
- package/lib/commands/vault.js +1424 -0
- package/lib/commands/voice.js +182 -0
- package/lib/commands/volt.js +528 -0
- package/lib/commands/watch.js +115 -0
- package/lib/commands/webhook.js +137 -0
- package/lib/commands/workflow.js +503 -0
- package/lib/commands/x.js +439 -0
- package/lib/core/actingAs.js +136 -0
- package/lib/core/aee.js +53 -0
- package/lib/core/agentAlias.js +163 -0
- package/lib/core/approvalContext.js +61 -0
- package/lib/core/auth.js +350 -0
- package/lib/core/banner.js +143 -0
- package/lib/core/brainPrime.js +90 -0
- package/lib/core/callerSource.js +129 -0
- package/lib/core/claudeLs.js +94 -0
- package/lib/core/client.js +584 -0
- package/lib/core/completions.js +163 -0
- package/lib/core/config.js +551 -0
- package/lib/core/confirm.js +37 -0
- package/lib/core/deeplinks.js +76 -0
- package/lib/core/doneEngineFetch.js +219 -0
- package/lib/core/errors.js +132 -0
- package/lib/core/global-flags.js +90 -0
- package/lib/core/governedSession.js +402 -0
- package/lib/core/lazyCommands.js +119 -0
- package/lib/core/liveSessionsReport.js +112 -0
- package/lib/core/org-override.js +52 -0
- package/lib/core/output.js +239 -0
- package/lib/core/prodEnv.js +7 -0
- package/lib/core/quoxglyph.js +158 -0
- package/lib/core/residentAlarm.js +282 -0
- package/lib/core/residentAuth.js +243 -0
- package/lib/core/residentPump.js +229 -0
- package/lib/core/residentSeed.js +104 -0
- package/lib/core/sessionHeartbeat.js +192 -0
- package/lib/core/sessionsOverview.js +160 -0
- package/lib/core/slotKeeper.js +282 -0
- package/lib/core/supervisorEngine.js +864 -0
- package/lib/core/systemdInstaller.js +256 -0
- package/lib/core/version.js +87 -0
- package/lib/core/websiteSession.js +148 -0
- package/lib/lucid/LucidIntro.jsx +105 -0
- package/lib/lucid/blind.js +71 -0
- package/lib/lucid/deck.js +32 -0
- package/lib/lucid/fmt.js +139 -0
- package/lib/lucid/sitrep.js +173 -0
- package/lib/plugins/loader.js +93 -0
- package/lib/quoxon/activity.js +104 -0
- package/lib/quoxon/api.js +473 -0
- package/lib/quoxon/app.js +1262 -0
- package/lib/quoxon/capabilities.js +75 -0
- package/lib/quoxon/commands.js +154 -0
- package/lib/quoxon/connect.js +467 -0
- package/lib/quoxon/connectCard.js +28 -0
- package/lib/quoxon/context.js +68 -0
- package/lib/quoxon/copy.js +33 -0
- package/lib/quoxon/demo.fixtures.js +24 -0
- package/lib/quoxon/demo.js +35 -0
- package/lib/quoxon/egg.js +36 -0
- package/lib/quoxon/events.js +458 -0
- package/lib/quoxon/gallery.js +223 -0
- package/lib/quoxon/indicator.js +193 -0
- package/lib/quoxon/intent.js +41 -0
- package/lib/quoxon/keys.js +121 -0
- package/lib/quoxon/main.js +244 -0
- package/lib/quoxon/markdown.js +317 -0
- package/lib/quoxon/model.js +205 -0
- package/lib/quoxon/motion/clock.js +69 -0
- package/lib/quoxon/motion/color.js +35 -0
- package/lib/quoxon/motion/frame.js +14 -0
- package/lib/quoxon/motion/reveal.js +54 -0
- package/lib/quoxon/motion/ring.js +79 -0
- package/lib/quoxon/objects/agent.js +61 -0
- package/lib/quoxon/objects/crystal.js +100 -0
- package/lib/quoxon/objects/fetch.js +170 -0
- package/lib/quoxon/objects/index.js +85 -0
- package/lib/quoxon/objects/ladder.js +71 -0
- package/lib/quoxon/objects/memory.js +44 -0
- package/lib/quoxon/objects/model.js +118 -0
- package/lib/quoxon/objects/proof.js +110 -0
- package/lib/quoxon/objects/skill.js +105 -0
- package/lib/quoxon/objects/view.js +76 -0
- package/lib/quoxon/objects/workflow.js +119 -0
- package/lib/quoxon/observers.js +42 -0
- package/lib/quoxon/onboarding/copy.js +235 -0
- package/lib/quoxon/onboarding/credential.js +95 -0
- package/lib/quoxon/onboarding/detect.js +22 -0
- package/lib/quoxon/onboarding/finish.js +119 -0
- package/lib/quoxon/onboarding/index.js +67 -0
- package/lib/quoxon/onboarding/installRunner.js +225 -0
- package/lib/quoxon/onboarding/intent.js +53 -0
- package/lib/quoxon/onboarding/journey.js +73 -0
- package/lib/quoxon/onboarding/preflight.js +142 -0
- package/lib/quoxon/onboarding/runner.js +58 -0
- package/lib/quoxon/onboarding/view.js +625 -0
- package/lib/quoxon/print.js +94 -0
- package/lib/quoxon/qart.js +379 -0
- package/lib/quoxon/qglyph.js +143 -0
- package/lib/quoxon/qhello.js +58 -0
- package/lib/quoxon/qmotion.js +111 -0
- package/lib/quoxon/render.js +648 -0
- package/lib/quoxon/roster.js +221 -0
- package/lib/quoxon/route.js +38 -0
- package/lib/quoxon/rules.js +130 -0
- package/lib/quoxon/session.js +68 -0
- package/lib/quoxon/skills.js +30 -0
- package/lib/quoxon/slash/compact.js +90 -0
- package/lib/quoxon/slash/ledger.js +83 -0
- package/lib/quoxon/slash/listView.js +28 -0
- package/lib/quoxon/slash/palette.js +104 -0
- package/lib/quoxon/slash/paletteController.js +59 -0
- package/lib/quoxon/slash/panels.js +101 -0
- package/lib/quoxon/slash/picker.js +38 -0
- package/lib/quoxon/slash/restart.js +144 -0
- package/lib/quoxon/slash/resume.js +59 -0
- package/lib/quoxon/slash/settings.js +50 -0
- package/lib/quoxon/sound/index.js +186 -0
- package/lib/quoxon/sound/mapping.js +119 -0
- package/lib/quoxon/sound/output.js +52 -0
- package/lib/quoxon/sound/synth.js +122 -0
- package/lib/quoxon/term.js +106 -0
- package/lib/quoxon/textBuffer.js +242 -0
- package/lib/quoxon/theme.js +253 -0
- package/lib/quoxon/transcript.js +150 -0
- package/lib/quoxon/web/qface.js +81 -0
- package/lib/quoxon/welcome.js +52 -0
- package/lib/tui/app.jsx +289 -0
- package/lib/tui/components/Breadcrumb.jsx +42 -0
- package/lib/tui/components/ChatInput.jsx +68 -0
- package/lib/tui/components/Confirm.jsx +32 -0
- package/lib/tui/components/DataTable.jsx +270 -0
- package/lib/tui/components/DetailPanel.jsx +241 -0
- package/lib/tui/components/HelpOverlay.jsx +67 -0
- package/lib/tui/components/Layout.jsx +129 -0
- package/lib/tui/components/Palette.jsx +141 -0
- package/lib/tui/components/SearchInput.jsx +125 -0
- package/lib/tui/components/Sparkline.jsx +55 -0
- package/lib/tui/components/StatusBar.jsx +133 -0
- package/lib/tui/components/Toast.jsx +52 -0
- package/lib/tui/hooks/useApi.js +44 -0
- package/lib/tui/hooks/useHealth.js +90 -0
- package/lib/tui/hooks/usePolledApi.js +86 -0
- package/lib/tui/hooks/useTerminalSize.js +46 -0
- package/lib/tui/index.js +56 -0
- package/lib/tui/intro/Intro.jsx +332 -0
- package/lib/tui/jsx-loader.js +32 -0
- package/lib/tui/keys.js +87 -0
- package/lib/tui/registry.js +49 -0
- package/lib/tui/theme.js +103 -0
- package/lib/tui/views/AdminView.jsx +398 -0
- package/lib/tui/views/AgentView.jsx +204 -0
- package/lib/tui/views/BackupView.jsx +225 -0
- package/lib/tui/views/BrainView.jsx +223 -0
- package/lib/tui/views/ChatView.jsx +468 -0
- package/lib/tui/views/FilesView.jsx +218 -0
- package/lib/tui/views/FleetView.jsx +234 -0
- package/lib/tui/views/GovernanceView.jsx +453 -0
- package/lib/tui/views/HealthView.jsx +216 -0
- package/lib/tui/views/InboxView.jsx +228 -0
- package/lib/tui/views/LucidDeck.jsx +727 -0
- package/lib/tui/views/MainMenu.jsx +123 -0
- package/lib/tui/views/MemoryView.jsx +377 -0
- package/lib/tui/views/ObjectivesView.jsx +230 -0
- package/lib/tui/views/OrgView.jsx +229 -0
- package/lib/tui/views/ToolView.jsx +221 -0
- package/lib/tui/views/VaultView.jsx +422 -0
- package/lib/tui/views/WorkflowView.jsx +329 -0
- package/package.json +72 -4
|
@@ -0,0 +1,967 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ContactsQ commands: governed contacts for people and agents.
|
|
3
|
+
* Proxied through the collector (/api/v1/contactsq/*; org resolved server-side).
|
|
4
|
+
* Cards carry MASKED identifiers only. Raw values come back solely from
|
|
5
|
+
* `show --full`, which the server restricts to human actors.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { writeFileSync, readFileSync, existsSync } from 'node:fs';
|
|
9
|
+
import { basename } from 'node:path';
|
|
10
|
+
import { Option } from 'commander';
|
|
11
|
+
import { formatOutput, formatDetail } from '../core/output.js';
|
|
12
|
+
import { QuoxApiError, QuoxError } from '../core/errors.js';
|
|
13
|
+
import { line as glyphLine, printEvent } from '../core/quoxglyph.js';
|
|
14
|
+
import { resolveEndpoint } from '../api/endpoints.js';
|
|
15
|
+
import { runSelftest } from './contactsSelftest.js';
|
|
16
|
+
import { resolveOrgId } from './helpers.js';
|
|
17
|
+
|
|
18
|
+
const KINDS = ['person', 'organisation'];
|
|
19
|
+
const REASONS = ['dnc', 'do_not_market', 'bounced'];
|
|
20
|
+
const ID_CHANNELS = ['phone', 'email', 'social', 'url'];
|
|
21
|
+
const ERASE_SCOPES = ['full', 'identifiers_only'];
|
|
22
|
+
const IMPORT_FORMATS = ['csv', 'google_csv', 'vcard'];
|
|
23
|
+
|
|
24
|
+
const collect = (v, prev) => (prev || []).concat(v);
|
|
25
|
+
const isJson = (program) => program.opts().output === 'json';
|
|
26
|
+
const printJson = (d) => console.log(JSON.stringify(d, null, 2));
|
|
27
|
+
const announce = (verb, result) => printEvent(glyphLine('contactsq', verb, { result }));
|
|
28
|
+
const channelOf = (v) => (String(v).includes('@') ? 'email' : 'phone');
|
|
29
|
+
|
|
30
|
+
function checkEnum(value, allowed, flag) {
|
|
31
|
+
if (value !== undefined && !allowed.includes(value)) {
|
|
32
|
+
throw new QuoxError(`${flag} must be one of: ${allowed.join(', ')}`, 2);
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
async function call(fn, { forbidden } = {}) {
|
|
37
|
+
try {
|
|
38
|
+
return await fn();
|
|
39
|
+
} catch (e) {
|
|
40
|
+
if (e instanceof QuoxApiError) {
|
|
41
|
+
if (e.status === 403 && forbidden) throw new QuoxError(forbidden, 1);
|
|
42
|
+
if (e.status === 404 && e.body && e.body.error === 'tombstone_not_found') {
|
|
43
|
+
throw new QuoxError('No erasure tombstone exists for that identifier in this org.', 1);
|
|
44
|
+
}
|
|
45
|
+
if (e.status === 404) throw new QuoxError('Contact not found.', 1);
|
|
46
|
+
if (e.status === 409 && e.body && e.body.error === 'legal_hold') {
|
|
47
|
+
throw new QuoxError('Blocked: this contact is on legal hold. Remove the legal_hold flag first.', 1);
|
|
48
|
+
}
|
|
49
|
+
if (e.status === 409 && e.body && e.body.error === 'already_erased') throw new QuoxError('This contact is already erased.', 1);
|
|
50
|
+
if (e.status === 409 && e.body && e.body.error === 'contact_erased') throw new QuoxError('This contact has been erased, so there is nothing to export.', 1);
|
|
51
|
+
if (e.status === 409 && e.body && e.body.error === 'blind_index_rotation_required') {
|
|
52
|
+
throw new QuoxError('Blocked: MASTER_ENCRYPTION_KEY_PREVIOUS is set. Run `quox contacts reindex` first, then rewrap.', 1);
|
|
53
|
+
}
|
|
54
|
+
if (e.status === 409 && e.body && e.body.error === 'merge_expired') throw new QuoxError('This merge is past its 30 day undo window.', 1);
|
|
55
|
+
if (e.status === 409 && e.body && e.body.error === 'merge_not_active') throw new QuoxError('This merge has already been undone or has expired.', 1);
|
|
56
|
+
if (e.status === 409 && e.body && e.body.error === 'survivor_unavailable') throw new QuoxError('The surviving contact was merged further or erased: undo that first.', 1);
|
|
57
|
+
if (e.status === 409 && e.body && e.body.error === 'not_mergeable') throw new QuoxError('One of those contacts is already merged or erased.', 1);
|
|
58
|
+
if (e.status === 400 && e.body && e.body.error === 'kind_mismatch') throw new QuoxError('A person can only be merged into a person, an organisation into an organisation.', 1);
|
|
59
|
+
if (e.status === 409 && e.body && e.body.error === 'contact_merged') throw new QuoxError('That contact was merged into another: erase the surviving contact instead.', 1);
|
|
60
|
+
if (e.status === 409 && e.body && e.body.error === 'identifier_erased') {
|
|
61
|
+
throw new QuoxError('That number or email belongs to an erased person and is blocked from coming back. If they have legitimately returned, an org admin can run `quox contacts unblock <value> --reason <code> --yes` first.', 1);
|
|
62
|
+
}
|
|
63
|
+
if (e.status === 409 && e.body && e.body.error === 'identifier_in_use') {
|
|
64
|
+
throw new QuoxError('That identifier already belongs to another contact in this org.', 1);
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
throw e;
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
const summary = (c) => (c.identifier_summary || []).map((i) => `${i.channel}:${i.masked}`).join(' ');
|
|
72
|
+
const cardRow = (c) => ({
|
|
73
|
+
id: c.id,
|
|
74
|
+
name: c.display_name || '',
|
|
75
|
+
kind: c.kind || '',
|
|
76
|
+
status: c.status || '',
|
|
77
|
+
relationship: c.relationship_primary || '',
|
|
78
|
+
identifiers: summary(c),
|
|
79
|
+
});
|
|
80
|
+
const CARD_COLS = ['id', 'name', 'kind', 'status', 'relationship', 'identifiers'];
|
|
81
|
+
const CARD_HEADS = ['ID', 'Name', 'Kind', 'Status', 'Relationship', 'Identifiers'];
|
|
82
|
+
|
|
83
|
+
// Structured person name flags (--given A --family B ...) -> the API's `name` object. '' clears a part on edit.
|
|
84
|
+
const NAME_FLAGS = [
|
|
85
|
+
['given', '--given <text>'], ['family', '--family <text>'], ['middle', '--middle <text>'], ['prefix', '--prefix <text>'],
|
|
86
|
+
['suffix', '--suffix <text>'], ['nickname', '--nickname <text>'],
|
|
87
|
+
['phonetic_given', '--phonetic-given <text>'], ['phonetic_family', '--phonetic-family <text>'],
|
|
88
|
+
];
|
|
89
|
+
const camel = (k) => k.replace(/_([a-z])/g, (_, c) => c.toUpperCase());
|
|
90
|
+
const addNameOptions = (cmd) => NAME_FLAGS.reduce((c, [k, flag]) => c.option(flag, `name part: ${k.replace('_', ' ')}`), cmd);
|
|
91
|
+
function nameFromOptions(options) {
|
|
92
|
+
const name = {};
|
|
93
|
+
for (const [k] of NAME_FLAGS) if (options[camel(k)] !== undefined) name[k] = options[camel(k)];
|
|
94
|
+
return Object.keys(name).length ? name : undefined;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
export function registerContactsCommands(program, ctx) {
|
|
98
|
+
const { client, auth } = ctx;
|
|
99
|
+
|
|
100
|
+
const contacts = program
|
|
101
|
+
.command('contacts')
|
|
102
|
+
.description('ContactsQ: governed contacts (add, find, resolve caller-ID, suppress)');
|
|
103
|
+
|
|
104
|
+
const list = async (options, textQuery) => {
|
|
105
|
+
auth.requireAuth();
|
|
106
|
+
const query = {};
|
|
107
|
+
const q = textQuery ?? options.q;
|
|
108
|
+
if (q) query.q = q;
|
|
109
|
+
if (options.status) query.status = options.status;
|
|
110
|
+
if (options.limit !== undefined) query.limit = Number(options.limit);
|
|
111
|
+
const ep = resolveEndpoint('contactsq.contacts.list');
|
|
112
|
+
const data = await call(() => client.get(ep.path, { service: ep.service, query }));
|
|
113
|
+
if (isJson(program)) return printJson(data);
|
|
114
|
+
const rows = (data.contacts || []).map(cardRow);
|
|
115
|
+
formatOutput({ columns: CARD_COLS, headers: CARD_HEADS, rows }, { format: program.opts().output });
|
|
116
|
+
announce('contacts list', `${rows.length} contacts`);
|
|
117
|
+
};
|
|
118
|
+
|
|
119
|
+
addNameOptions(contacts
|
|
120
|
+
.command('add')
|
|
121
|
+
.description('Create a contact (repeat --phone/--email for several identifiers). Name: positional <name> (split into given/family) or --given/--family/...')
|
|
122
|
+
.argument('[name]', 'display name (split into given + family); or use --given/--family'))
|
|
123
|
+
.option('--phone <number>', 'phone number (repeatable)', collect)
|
|
124
|
+
.option('--email <address>', 'email address (repeatable)', collect)
|
|
125
|
+
.option('--kind <kind>', 'person | organisation (default person)')
|
|
126
|
+
.option('--org-kind', 'shorthand for --kind organisation')
|
|
127
|
+
.option('--organisation <name>', 'their organisation (found by name or created, and linked); a contact has a name AND an organisation')
|
|
128
|
+
.option('--job-title <title>', 'their role at --organisation')
|
|
129
|
+
.option('--relationship <r>', 'primary relationship (e.g. customer, supplier)')
|
|
130
|
+
.option('--notes <text>', 'notes (stored encrypted)')
|
|
131
|
+
.action(async (name, options) => {
|
|
132
|
+
auth.requireAuth();
|
|
133
|
+
checkEnum(options.kind, KINDS, '--kind');
|
|
134
|
+
const kind = options.orgKind ? 'organisation' : options.kind;
|
|
135
|
+
const nameParts = nameFromOptions(options);
|
|
136
|
+
if (!name && !nameParts) throw new QuoxError('Give a <name>, or at least --given/--family.', 2);
|
|
137
|
+
if (nameParts && kind === 'organisation') throw new QuoxError('Organisations have a single name: pass <name>, not --given/--family.', 2);
|
|
138
|
+
if (options.organisation && kind === 'organisation') throw new QuoxError('--organisation is for a person; an organisation contact is just <name> with --org-kind.', 2);
|
|
139
|
+
if (options.jobTitle && !options.organisation) throw new QuoxError('--job-title needs --organisation.', 2);
|
|
140
|
+
const identifiers = [
|
|
141
|
+
...(options.phone || []).map((value, i) => ({ channel: 'phone', value, ...(i === 0 ? { is_primary: true } : {}) })),
|
|
142
|
+
...(options.email || []).map((value, i) => ({ channel: 'email', value, ...(i === 0 ? { is_primary: true } : {}) })),
|
|
143
|
+
];
|
|
144
|
+
const body = {};
|
|
145
|
+
if (name) body.display_name = name;
|
|
146
|
+
if (nameParts) body.name = nameParts;
|
|
147
|
+
if (kind) body.kind = kind;
|
|
148
|
+
if (options.organisation) body.organisation = { name: options.organisation, ...(options.jobTitle ? { role: options.jobTitle } : {}) };
|
|
149
|
+
if (options.relationship) body.relationship_primary = options.relationship;
|
|
150
|
+
if (options.notes) body.notes = options.notes;
|
|
151
|
+
if (identifiers.length) body.identifiers = identifiers;
|
|
152
|
+
const ep = resolveEndpoint('contactsq.contacts.create');
|
|
153
|
+
const data = await call(() => client.post(ep.path, { service: ep.service, body }));
|
|
154
|
+
if (isJson(program)) return printJson(data);
|
|
155
|
+
const c = data.contact || {};
|
|
156
|
+
formatOutput({ columns: CARD_COLS, headers: CARD_HEADS, rows: [cardRow(c)] }, { format: program.opts().output });
|
|
157
|
+
announce('contacts add', c.id || 'created');
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
addNameOptions(contacts
|
|
161
|
+
.command('edit')
|
|
162
|
+
.description('Edit a contact name (partial: only the parts you pass change; --given \'\' clears one)')
|
|
163
|
+
.argument('<id>', 'contact id')
|
|
164
|
+
.option('--display <text>', 'display name override (use \'\' to go back to the derived name)')
|
|
165
|
+
.option('--organisation <name>', 'set their organisation (\'\' clears it)')
|
|
166
|
+
.option('--job-title <title>', 'their role at the organisation (with --organisation)'))
|
|
167
|
+
.action(async (id, options) => {
|
|
168
|
+
auth.requireAuth();
|
|
169
|
+
const body = {};
|
|
170
|
+
const name = nameFromOptions(options);
|
|
171
|
+
if (name) body.name = name;
|
|
172
|
+
if (options.display !== undefined) body.display_name_override = options.display === '' ? null : options.display;
|
|
173
|
+
if (options.jobTitle !== undefined && options.organisation === undefined) throw new QuoxError('--job-title needs --organisation (pass the current one to change only the title).', 2);
|
|
174
|
+
if (options.organisation !== undefined) body.organisation = options.organisation === '' ? null : { name: options.organisation, ...(options.jobTitle ? { role: options.jobTitle } : {}) };
|
|
175
|
+
if (!Object.keys(body).length) throw new QuoxError('Nothing to change: pass --given/--family/--middle/--prefix/--suffix/--nickname, --display or --organisation.', 2);
|
|
176
|
+
const ep = resolveEndpoint('contactsq.contacts.update', { id });
|
|
177
|
+
const data = await call(() => client.put(ep.path, { service: ep.service, body }), { forbidden: 'Editing a contact needs a human user.' });
|
|
178
|
+
if (isJson(program)) return printJson(data);
|
|
179
|
+
const c = data.contact || {};
|
|
180
|
+
formatOutput({ columns: CARD_COLS, headers: CARD_HEADS, rows: [cardRow(c)] }, { format: program.opts().output });
|
|
181
|
+
announce('contacts edit', c.id || id);
|
|
182
|
+
});
|
|
183
|
+
|
|
184
|
+
contacts
|
|
185
|
+
.command('list')
|
|
186
|
+
.description('List contacts')
|
|
187
|
+
.option('--q <text>', 'name filter')
|
|
188
|
+
.option('--status <s>', 'active | candidate | archived')
|
|
189
|
+
.option('--limit <n>', 'max contacts')
|
|
190
|
+
.action((options) => list(options));
|
|
191
|
+
|
|
192
|
+
contacts
|
|
193
|
+
.command('find')
|
|
194
|
+
.description('Find contacts by name (same as list --q)')
|
|
195
|
+
.argument('<text>', 'search text')
|
|
196
|
+
.option('--status <s>', 'status filter')
|
|
197
|
+
.option('--limit <n>', 'max contacts')
|
|
198
|
+
.action((text, options) => list(options, text));
|
|
199
|
+
|
|
200
|
+
contacts
|
|
201
|
+
.command('show')
|
|
202
|
+
.description('Show a contact card (masked). --greeting: safe greeting projection. --full: raw identifiers, human only')
|
|
203
|
+
.argument('<id>', 'contact id')
|
|
204
|
+
.option('--greeting', 'greeting projection (name, language, tz; no notes)')
|
|
205
|
+
.option('--full', 'full record with raw identifiers and notes (human actors only)')
|
|
206
|
+
.action(async (id, options) => {
|
|
207
|
+
auth.requireAuth();
|
|
208
|
+
if (options.greeting && options.full) throw new QuoxError('Use --greeting or --full, not both.', 2);
|
|
209
|
+
const query = {};
|
|
210
|
+
if (options.greeting) query.projection = 'greeting';
|
|
211
|
+
if (options.full) query.projection = 'full';
|
|
212
|
+
const ep = resolveEndpoint('contactsq.contacts.get', { id });
|
|
213
|
+
const data = await call(() => client.get(ep.path, { service: ep.service, query }), {
|
|
214
|
+
forbidden: '--full is for human users only; agents and service keys get the masked card.',
|
|
215
|
+
});
|
|
216
|
+
if (isJson(program)) return printJson(data);
|
|
217
|
+
const c = data.contact || {};
|
|
218
|
+
const detail = { ...cardRow(c), flags: (c.flags || []).join(',') };
|
|
219
|
+
if (c.name) {
|
|
220
|
+
for (const [k] of NAME_FLAGS) if (c.name[k]) detail[k] = c.name[k];
|
|
221
|
+
if (c.display_name_override) detail.display_override = c.display_name_override;
|
|
222
|
+
}
|
|
223
|
+
if (options.greeting) {
|
|
224
|
+
Object.assign(detail, {
|
|
225
|
+
greeting_name: c.greeting_name ?? '', preferred_name: c.preferred_name ?? '',
|
|
226
|
+
pronunciation: c.pronunciation ?? '', language: c.language ?? '', tz: c.tz ?? '',
|
|
227
|
+
ai_disclosure_required: c.ai_disclosure_required ?? '',
|
|
228
|
+
});
|
|
229
|
+
}
|
|
230
|
+
formatDetail(detail);
|
|
231
|
+
if (options.full) {
|
|
232
|
+
const rows = (c.identifiers || []).map((i) => ({
|
|
233
|
+
id: i.id, channel: i.channel, label: i.label || '', value: i.value, verification: i.verification || '',
|
|
234
|
+
}));
|
|
235
|
+
console.log('');
|
|
236
|
+
formatOutput({
|
|
237
|
+
columns: ['id', 'channel', 'label', 'value', 'verification'],
|
|
238
|
+
headers: ['ID', 'Channel', 'Label', 'Value', 'Verification'],
|
|
239
|
+
rows,
|
|
240
|
+
}, { format: program.opts().output });
|
|
241
|
+
if (c.notes) { console.log(''); console.log(c.notes); }
|
|
242
|
+
}
|
|
243
|
+
announce('contacts show', c.id || id);
|
|
244
|
+
});
|
|
245
|
+
|
|
246
|
+
contacts
|
|
247
|
+
.command('resolve')
|
|
248
|
+
.description('Caller-ID: resolve a phone number or email to a contact (reports suppression)')
|
|
249
|
+
.argument('<value>', 'phone number or email')
|
|
250
|
+
.addOption(new Option('--channel <c>', 'phone | email | social | url (default: auto-detect)'))
|
|
251
|
+
.option('--greeting', 'greeting projection instead of the card')
|
|
252
|
+
.action(async (value, options) => {
|
|
253
|
+
auth.requireAuth();
|
|
254
|
+
const channel = options.channel || channelOf(value);
|
|
255
|
+
const body = { channel, value, projection: options.greeting ? 'greeting' : 'card' };
|
|
256
|
+
const ep = resolveEndpoint('contactsq.resolve');
|
|
257
|
+
const data = await call(() => client.post(ep.path, { service: ep.service, body }));
|
|
258
|
+
if (isJson(program)) return printJson(data);
|
|
259
|
+
const m = data.match;
|
|
260
|
+
if (m) {
|
|
261
|
+
formatOutput({ columns: CARD_COLS, headers: CARD_HEADS, rows: [cardRow(m)] }, { format: program.opts().output });
|
|
262
|
+
if (options.greeting) console.log(`greeting: ${m.greeting_name ?? '(unknown)'}`);
|
|
263
|
+
}
|
|
264
|
+
const reasons = data.suppressed && data.suppressed.reasons;
|
|
265
|
+
if (reasons && reasons.length) console.log(`suppressed: ${reasons.join(', ')}`);
|
|
266
|
+
announce('contacts resolve', m ? (m.display_name || m.id) : 'no match');
|
|
267
|
+
});
|
|
268
|
+
|
|
269
|
+
contacts
|
|
270
|
+
.command('add-id')
|
|
271
|
+
.description('Add an identifier to a contact')
|
|
272
|
+
.argument('<id>', 'contact id')
|
|
273
|
+
.option('--phone <v>', 'phone number')
|
|
274
|
+
.option('--email <v>', 'email address')
|
|
275
|
+
.option('--social <v>', 'social handle as network:handle')
|
|
276
|
+
.option('--url <v>', 'URL')
|
|
277
|
+
.option('--label <l>', 'label (work, home, ...)')
|
|
278
|
+
.action(async (id, options) => {
|
|
279
|
+
auth.requireAuth();
|
|
280
|
+
const given = ID_CHANNELS.filter((c) => options[c]);
|
|
281
|
+
if (given.length !== 1) throw new QuoxError('Give exactly one of --phone, --email, --social, --url.', 2);
|
|
282
|
+
const channel = given[0];
|
|
283
|
+
const body = { channel, value: options[channel] };
|
|
284
|
+
if (options.label) body.label = options.label;
|
|
285
|
+
const ep = resolveEndpoint('contactsq.identifiers.add', { id });
|
|
286
|
+
const data = await call(() => client.post(ep.path, { service: ep.service, body }));
|
|
287
|
+
if (isJson(program)) return printJson(data);
|
|
288
|
+
const i = data.identifier || {};
|
|
289
|
+
formatDetail({ id: i.id, channel: i.channel, label: i.label || '', masked: i.masked });
|
|
290
|
+
announce('contacts add-id', `${i.channel || channel} ${i.masked || ''}`.trim());
|
|
291
|
+
});
|
|
292
|
+
|
|
293
|
+
contacts
|
|
294
|
+
.command('archive')
|
|
295
|
+
.description('Archive a contact (not erasure)')
|
|
296
|
+
.argument('<id>', 'contact id')
|
|
297
|
+
.action(async (id) => {
|
|
298
|
+
auth.requireAuth();
|
|
299
|
+
const ep = resolveEndpoint('contactsq.contacts.archive', { id });
|
|
300
|
+
const data = await call(() => client.delete(ep.path, { service: ep.service }));
|
|
301
|
+
if (isJson(program)) return printJson(data);
|
|
302
|
+
const c = data.contact || {};
|
|
303
|
+
announce('contacts archive', c.status || 'archived');
|
|
304
|
+
});
|
|
305
|
+
|
|
306
|
+
contacts
|
|
307
|
+
.command('suppress')
|
|
308
|
+
.description('Add a number or email to the suppression list (blind index only)')
|
|
309
|
+
.argument('<value>', 'phone number or email')
|
|
310
|
+
.requiredOption('--reason <r>', 'dnc | do_not_market | bounced')
|
|
311
|
+
.option('--channel <c>', 'phone | email (default: auto-detect)')
|
|
312
|
+
.action(async (value, options) => {
|
|
313
|
+
auth.requireAuth();
|
|
314
|
+
checkEnum(options.reason, REASONS, '--reason');
|
|
315
|
+
const body = { channel: options.channel || channelOf(value), value, reason: options.reason };
|
|
316
|
+
const ep = resolveEndpoint('contactsq.suppressions.create');
|
|
317
|
+
const data = await call(() => client.post(ep.path, { service: ep.service, body }));
|
|
318
|
+
if (isJson(program)) return printJson(data);
|
|
319
|
+
const s = data.suppression || {};
|
|
320
|
+
announce('contacts suppress', `${s.channel || body.channel} ${s.reason || body.reason}`);
|
|
321
|
+
});
|
|
322
|
+
|
|
323
|
+
contacts
|
|
324
|
+
.command('suppressed')
|
|
325
|
+
.description('Check whether a number or email is suppressed')
|
|
326
|
+
.argument('<value>', 'phone number or email')
|
|
327
|
+
.option('--channel <c>', 'phone | email (default: auto-detect)')
|
|
328
|
+
.action(async (value, options) => {
|
|
329
|
+
auth.requireAuth();
|
|
330
|
+
const body = { channel: options.channel || channelOf(value), value };
|
|
331
|
+
const ep = resolveEndpoint('contactsq.suppressions.check');
|
|
332
|
+
const data = await call(() => client.post(ep.path, { service: ep.service, body }));
|
|
333
|
+
if (isJson(program)) return printJson(data);
|
|
334
|
+
const reasons = data.reasons || [];
|
|
335
|
+
console.log(data.suppressed ? `suppressed: ${reasons.join(', ')}` : 'not suppressed');
|
|
336
|
+
announce('contacts suppressed', data.suppressed ? reasons.join(',') : 'clear');
|
|
337
|
+
});
|
|
338
|
+
|
|
339
|
+
contacts
|
|
340
|
+
.command('search')
|
|
341
|
+
.description('Ranked name search (phonetic: Sean/Shawn/Shaun); flags ambiguity instead of guessing')
|
|
342
|
+
.argument('<text>', 'name, e.g. "sarah"')
|
|
343
|
+
.option('--org-hint <org>', 'prefer people at this organisation')
|
|
344
|
+
.option('--limit <n>', 'max candidates')
|
|
345
|
+
.action(async (text, options) => {
|
|
346
|
+
auth.requireAuth();
|
|
347
|
+
const query = { q: text };
|
|
348
|
+
if (options.orgHint) query.org_hint = options.orgHint;
|
|
349
|
+
if (options.limit !== undefined) query.limit = Number(options.limit);
|
|
350
|
+
const ep = resolveEndpoint('contactsq.search');
|
|
351
|
+
const data = await call(() => client.get(ep.path, { service: ep.service, query }));
|
|
352
|
+
if (isJson(program)) return printJson(data);
|
|
353
|
+
const rows = (data.candidates || []).map((c) => ({ ...cardRow(c), confidence: Number(c.confidence || 0).toFixed(2), match: c.match || '' }));
|
|
354
|
+
formatOutput({ columns: [...CARD_COLS, 'confidence', 'match'], headers: [...CARD_HEADS, 'Conf', 'Match'], rows }, { format: program.opts().output });
|
|
355
|
+
if (data.ambiguous) console.log(`ambiguous: ${data.hint || 'ask which one'}`);
|
|
356
|
+
announce('contacts search', `${rows.length} candidates${data.ambiguous ? ' (ambiguous)' : ''}`);
|
|
357
|
+
});
|
|
358
|
+
|
|
359
|
+
// ---- P8a: morning brief, selftest, handoff notes ----
|
|
360
|
+
contacts
|
|
361
|
+
.command('brief')
|
|
362
|
+
.description('Morning brief: candidates to review, consents expiring, overdue notices, duplicates, sync health, recent do-not-call additions')
|
|
363
|
+
.action(async () => {
|
|
364
|
+
auth.requireAuth();
|
|
365
|
+
const ep = resolveEndpoint('contactsq.brief');
|
|
366
|
+
const d = await call(() => client.get(ep.path, { service: ep.service }));
|
|
367
|
+
if (isJson(program)) return printJson(d);
|
|
368
|
+
formatDetail({
|
|
369
|
+
candidates_to_review: d.candidates_to_review, consents_expiring_30d: d.consents_expiring_30d, notices_overdue: d.notices_overdue,
|
|
370
|
+
duplicates: d.duplicates, dnc_added_7d: d.dnc_added_7d, generated_at: d.generated_at,
|
|
371
|
+
});
|
|
372
|
+
for (const src of d.sync || []) console.log(`sync ${src.label || src.source_id}: ${src.retired ? 'retired' : (src.last_status || 'never run')}${src.last_run_at ? ` (${src.last_run_at})` : ''}`);
|
|
373
|
+
announce('contacts brief', `${d.candidates_to_review} candidates, ${d.duplicates} duplicates`);
|
|
374
|
+
});
|
|
375
|
+
|
|
376
|
+
contacts
|
|
377
|
+
.command('selftest')
|
|
378
|
+
.description('Drive the read path (search, find, resolve, best channel, timeline, touchpoint, brief) against a demo contact. Never dials or sends. --agent runs it through the attached agent tool scope')
|
|
379
|
+
.option('--agent', 'run the checks as the attached contactsq agent (quox agent attach contactsq first)')
|
|
380
|
+
.action(async (options) => {
|
|
381
|
+
await runSelftest({ client, auth, program, options, orgId: options.agent ? resolveOrgId(auth, program) : undefined });
|
|
382
|
+
});
|
|
383
|
+
|
|
384
|
+
contacts
|
|
385
|
+
.command('handoff')
|
|
386
|
+
.description('Leave a handoff note on a contact with an open thread (stored encrypted, humans read it)')
|
|
387
|
+
.argument('<id>', 'contact id')
|
|
388
|
+
.argument('<note>', 'what is pending / what to do next (max 500 characters)')
|
|
389
|
+
.action(async (id, note) => {
|
|
390
|
+
auth.requireAuth();
|
|
391
|
+
if (!note || !note.trim()) throw new QuoxError('A handoff note cannot be empty.', 2);
|
|
392
|
+
if (note.length > 500) throw new QuoxError('A handoff note is at most 500 characters.', 2);
|
|
393
|
+
const ep = resolveEndpoint('contactsq.handoffs.add', { id });
|
|
394
|
+
const d = await call(() => client.post(ep.path, { service: ep.service, body: { note: note.trim() } }));
|
|
395
|
+
if (isJson(program)) return printJson(d);
|
|
396
|
+
announce('contacts handoff', (d.touchpoint && d.touchpoint.id) || 'recorded');
|
|
397
|
+
});
|
|
398
|
+
|
|
399
|
+
contacts
|
|
400
|
+
.command('handoffs')
|
|
401
|
+
.description('Open handoff notes on a contact, newest first')
|
|
402
|
+
.argument('<id>', 'contact id')
|
|
403
|
+
.option('--limit <n>', 'max notes (default 3)')
|
|
404
|
+
.action(async (id, options) => {
|
|
405
|
+
auth.requireAuth();
|
|
406
|
+
const ep = resolveEndpoint('contactsq.handoffs', { id });
|
|
407
|
+
const d = await call(() => client.get(ep.path, { service: ep.service, query: options.limit !== undefined ? { limit: Number(options.limit) } : {} }));
|
|
408
|
+
if (isJson(program)) return printJson(d);
|
|
409
|
+
const rows = (d.handoffs || []).map((h) => ({ when: h.occurred_at, age_hours: h.age_hours, by: h.agent_id || '', note: h.summary || '' }));
|
|
410
|
+
formatOutput({ columns: ['when', 'age_hours', 'by', 'note'], headers: ['When', 'Age (h)', 'By', 'Note'], rows }, { format: program.opts().output });
|
|
411
|
+
announce('contacts handoffs', `${rows.length} open`);
|
|
412
|
+
});
|
|
413
|
+
|
|
414
|
+
contacts
|
|
415
|
+
.command('focus')
|
|
416
|
+
.description('Show the contact currently open in the ContactsQ dashboard (the one an agent means by "this person"); expires after 30 minutes')
|
|
417
|
+
.action(async () => {
|
|
418
|
+
auth.requireAuth();
|
|
419
|
+
const ep = resolveEndpoint('contactsq.focus.get');
|
|
420
|
+
const data = await call(() => client.get(ep.path, { service: ep.service }));
|
|
421
|
+
if (isJson(program)) return printJson(data);
|
|
422
|
+
const f = data.focus;
|
|
423
|
+
if (!f) { console.log('No contact is open in the dashboard.'); return announce('contacts focus', 'none'); }
|
|
424
|
+
formatOutput({ columns: CARD_COLS, headers: CARD_HEADS, rows: [cardRow(f.contact)] }, { format: program.opts().output });
|
|
425
|
+
console.log(`set by ${f.set_by}, ${f.age_seconds}s ago`);
|
|
426
|
+
announce('contacts focus', `${f.contact.id} (${f.age_seconds}s ago)`);
|
|
427
|
+
});
|
|
428
|
+
|
|
429
|
+
contacts
|
|
430
|
+
.command('call')
|
|
431
|
+
.description('Dial a contact by id through the phone bridge; you never see or type the number')
|
|
432
|
+
.argument('<id>', 'contact id (find it with `quox contacts search`)')
|
|
433
|
+
.option('--reason <text>', 'what the call is about (the agent opens with it)')
|
|
434
|
+
.option('--purpose <p>', 'service | transactional | marketing | research', 'service')
|
|
435
|
+
.action(async (id, options) => {
|
|
436
|
+
auth.requireAuth();
|
|
437
|
+
const body = { contact_id: id, projection: 'dial', channel: 'voice', purpose: options.purpose };
|
|
438
|
+
if (options.reason) body.reason = options.reason;
|
|
439
|
+
const ep = resolveEndpoint('contactsq.leases.create');
|
|
440
|
+
const data = await call(() => client.post(ep.path, { service: ep.service, body }), {
|
|
441
|
+
forbidden: 'Blocked: the number is suppressed (do-not-call) or you are not a human user.',
|
|
442
|
+
});
|
|
443
|
+
if (isJson(program)) return printJson(data);
|
|
444
|
+
const l = data.lease || {};
|
|
445
|
+
formatDetail({ lease: l.id, status: l.status, dial: l.dial_state || 'queued', expires_at: l.expires_at });
|
|
446
|
+
announce('contacts call', `${l.id || '?'} queued for the phone bridge`);
|
|
447
|
+
});
|
|
448
|
+
|
|
449
|
+
contacts
|
|
450
|
+
.command('leases')
|
|
451
|
+
.description('Lease + use ledger for a contact (who was given access, when, why; never the values)')
|
|
452
|
+
.argument('<id>', 'contact id')
|
|
453
|
+
.action(async (id) => {
|
|
454
|
+
auth.requireAuth();
|
|
455
|
+
const ep = resolveEndpoint('contactsq.leases.list');
|
|
456
|
+
const data = await call(() => client.get(ep.path, { service: ep.service, query: { contact_id: id } }), {
|
|
457
|
+
forbidden: 'The lease ledger is for human users.',
|
|
458
|
+
});
|
|
459
|
+
if (isJson(program)) return printJson(data);
|
|
460
|
+
const rows = (data.leases || []).map((l) => ({
|
|
461
|
+
id: l.id, projection: l.projection, channel: l.channel, purpose: l.purpose, status: l.status,
|
|
462
|
+
decided_by: l.decided_by, uses: `${l.use_count}/${l.max_uses}`, expires_at: l.expires_at,
|
|
463
|
+
}));
|
|
464
|
+
formatOutput({ columns: ['id', 'projection', 'channel', 'purpose', 'status', 'decided_by', 'uses', 'expires_at'] , rows }, { format: program.opts().output });
|
|
465
|
+
announce('contacts leases', `${rows.length} leases`);
|
|
466
|
+
});
|
|
467
|
+
|
|
468
|
+
const grant = contacts.command('grant').description('Standing grants: let an agent reach contacts without asking each time (admin)');
|
|
469
|
+
grant
|
|
470
|
+
.command('list')
|
|
471
|
+
.action(async () => {
|
|
472
|
+
auth.requireAuth();
|
|
473
|
+
const ep = resolveEndpoint('contactsq.grants.list');
|
|
474
|
+
const data = await call(() => client.get(ep.path, { service: ep.service }), { forbidden: 'Grants are for human users.' });
|
|
475
|
+
if (isJson(program)) return printJson(data);
|
|
476
|
+
const rows = (data.grants || []).map((g) => ({
|
|
477
|
+
id: g.id, agent: g.subject_id, projection: g.projection, channels: (g.channels || []).join(','),
|
|
478
|
+
purposes: (g.purposes || []).join(','), per_day: g.max_uses_per_day, revoked: g.revoked_at ? 'yes' : '',
|
|
479
|
+
}));
|
|
480
|
+
formatOutput({ columns: ['id', 'agent', 'projection', 'channels', 'purposes', 'per_day', 'revoked'], rows }, { format: program.opts().output });
|
|
481
|
+
announce('contacts grant list', `${rows.length} grants`);
|
|
482
|
+
});
|
|
483
|
+
grant
|
|
484
|
+
.command('add')
|
|
485
|
+
.argument('<agent>', 'agent id, e.g. quox')
|
|
486
|
+
.option('--preset <name>', 'receptionist = dial, voice, service, 20/day')
|
|
487
|
+
.option('--projection <p>', 'dial | send')
|
|
488
|
+
.option('--channel <c>', 'voice | sms | email (repeatable)', collect)
|
|
489
|
+
.option('--purpose <p>', 'service | transactional | marketing | research (repeatable)', collect)
|
|
490
|
+
.option('--per-day <n>', 'daily cap (1-500)')
|
|
491
|
+
.action(async (agent, options) => {
|
|
492
|
+
auth.requireAuth();
|
|
493
|
+
const body = options.preset
|
|
494
|
+
? { subject_id: agent, preset: options.preset }
|
|
495
|
+
: { subject_id: agent, projection: options.projection, channels: options.channel || [], purposes: options.purpose || [], max_uses_per_day: Number(options.perDay) };
|
|
496
|
+
const ep = resolveEndpoint('contactsq.grants.create');
|
|
497
|
+
const data = await call(() => client.post(ep.path, { service: ep.service, body }), { forbidden: 'Creating a standing grant needs an org admin.' });
|
|
498
|
+
if (isJson(program)) return printJson(data);
|
|
499
|
+
const g = data.grant || {};
|
|
500
|
+
announce('contacts grant add', `${g.id || '?'} ${agent} ${g.projection || ''} ${g.max_uses_per_day || ''}/day`);
|
|
501
|
+
});
|
|
502
|
+
grant
|
|
503
|
+
.command('revoke')
|
|
504
|
+
.argument('<grant-id>')
|
|
505
|
+
.action(async (gid) => {
|
|
506
|
+
auth.requireAuth();
|
|
507
|
+
const ep = resolveEndpoint('contactsq.grants.revoke', { id: gid });
|
|
508
|
+
const data = await call(() => client.delete(ep.path, { service: ep.service }), { forbidden: 'Revoking a grant needs an org admin.' });
|
|
509
|
+
if (isJson(program)) return printJson(data);
|
|
510
|
+
announce('contacts grant revoke', gid);
|
|
511
|
+
});
|
|
512
|
+
|
|
513
|
+
contacts
|
|
514
|
+
.command('dsar')
|
|
515
|
+
.description('Data subject access request: the full record, provenance, consents, touchpoints, edges and the lease use ledger as one JSON bundle (org admin)')
|
|
516
|
+
.argument('<id>', 'contact id')
|
|
517
|
+
.option('--out <file>', 'write the bundle to this file instead of printing it')
|
|
518
|
+
.action(async (id, options) => {
|
|
519
|
+
auth.requireAuth();
|
|
520
|
+
const ep = resolveEndpoint('contactsq.contacts.dsar', { id });
|
|
521
|
+
const data = await call(() => client.get(ep.path, { service: ep.service }), {
|
|
522
|
+
forbidden: 'A DSAR export needs a signed-in org admin; agents and service keys cannot run it.',
|
|
523
|
+
});
|
|
524
|
+
if (options.out) {
|
|
525
|
+
writeFileSync(options.out, `${JSON.stringify(data, null, 2)}\n`, { mode: 0o600 });
|
|
526
|
+
announce('contacts dsar', `${options.out} (${Object.entries(data.counts || {}).map(([k, v]) => `${k}=${v}`).join(' ')})`);
|
|
527
|
+
return;
|
|
528
|
+
}
|
|
529
|
+
printJson(data);
|
|
530
|
+
announce('contacts dsar', id);
|
|
531
|
+
});
|
|
532
|
+
|
|
533
|
+
contacts
|
|
534
|
+
.command('erase')
|
|
535
|
+
.description('Erase a contact (org admin, irreversible). full = crypto-shred + tombstone; identifiers_only keeps the record')
|
|
536
|
+
.argument('<id>', 'contact id')
|
|
537
|
+
.requiredOption('--scope <scope>', 'full | identifiers_only')
|
|
538
|
+
.requiredOption('--reason <code>', 'reason code, e.g. subject_request')
|
|
539
|
+
.option('--yes', 'confirm the erasure (refused without it)')
|
|
540
|
+
.action(async (id, options) => {
|
|
541
|
+
auth.requireAuth();
|
|
542
|
+
checkEnum(options.scope, ERASE_SCOPES, '--scope');
|
|
543
|
+
if (!options.yes) throw new QuoxError('Erasure is irreversible. Re-run with --yes to confirm.', 2);
|
|
544
|
+
const ep = resolveEndpoint('contactsq.contacts.erase', { id });
|
|
545
|
+
const data = await call(() => client.post(ep.path, { service: ep.service, body: { scope: options.scope, reason_code: options.reason } }), {
|
|
546
|
+
forbidden: 'Erasure needs a signed-in org admin; agents and service keys cannot erase.',
|
|
547
|
+
});
|
|
548
|
+
if (isJson(program)) return printJson(data);
|
|
549
|
+
const c = data.certificate || {};
|
|
550
|
+
formatDetail({ erasure: c.erasure_id, contact: c.contact_id, scope: c.scope, executed_at: c.executed_at, sha256: c.sha256 });
|
|
551
|
+
console.log('');
|
|
552
|
+
formatDetail(c.propagation || {});
|
|
553
|
+
announce('contacts erase', `${c.erasure_id || '?'} ${c.scope || options.scope}`);
|
|
554
|
+
});
|
|
555
|
+
|
|
556
|
+
contacts
|
|
557
|
+
.command('certificate')
|
|
558
|
+
.description('Print an erasure certificate (org admin)')
|
|
559
|
+
.argument('<erasure-id>', 'erasure id from `contacts erase`')
|
|
560
|
+
.action(async (eid) => {
|
|
561
|
+
auth.requireAuth();
|
|
562
|
+
const ep = resolveEndpoint('contactsq.erasures.certificate', { id: eid });
|
|
563
|
+
const data = await call(() => client.get(ep.path, { service: ep.service }), { forbidden: 'Certificates are for org admins.' });
|
|
564
|
+
printJson(data);
|
|
565
|
+
announce('contacts certificate', `${eid} ${data.verified ? 'verified' : 'HASH MISMATCH'}`);
|
|
566
|
+
});
|
|
567
|
+
|
|
568
|
+
contacts
|
|
569
|
+
.command('compliance')
|
|
570
|
+
.description('Compliance bands: Article 14 notices overdue, candidates expiring, no lawful basis, consents expiring')
|
|
571
|
+
.action(async () => {
|
|
572
|
+
auth.requireAuth();
|
|
573
|
+
const ep = resolveEndpoint('contactsq.compliance');
|
|
574
|
+
const data = await call(() => client.get(ep.path, { service: ep.service }), { forbidden: 'Compliance bands are for human users.' });
|
|
575
|
+
if (isJson(program)) return printJson(data);
|
|
576
|
+
const rows = Object.entries(data.bands || {}).map(([band, b]) => ({ band, count: b.count, ids: (b.ids || []).slice(0, 5).join(',') + (b.count > 5 ? ',...' : '') }));
|
|
577
|
+
formatOutput({ columns: ['band', 'count', 'ids'], headers: ['Band', 'Count', 'IDs'], rows }, { format: program.opts().output });
|
|
578
|
+
announce('contacts compliance', rows.map((r) => `${r.band}=${r.count}`).join(' '));
|
|
579
|
+
});
|
|
580
|
+
|
|
581
|
+
contacts
|
|
582
|
+
.command('notice-sent')
|
|
583
|
+
.description('Record that the Article 14 notice was sent to a contact')
|
|
584
|
+
.argument('<id>', 'contact id')
|
|
585
|
+
.requiredOption('--evidence <ref>', 'reference to the evidence (WARD ref, message id)')
|
|
586
|
+
.action(async (id, options) => {
|
|
587
|
+
auth.requireAuth();
|
|
588
|
+
const ep = resolveEndpoint('contactsq.contacts.noticeSent', { id });
|
|
589
|
+
const data = await call(() => client.post(ep.path, { service: ep.service, body: { evidence_ref: options.evidence } }), { forbidden: 'Only human users can record a notice.' });
|
|
590
|
+
if (isJson(program)) return printJson(data);
|
|
591
|
+
announce('contacts notice-sent', (data.notice && data.notice.sent_at) || id);
|
|
592
|
+
});
|
|
593
|
+
|
|
594
|
+
contacts
|
|
595
|
+
.command('processing-record')
|
|
596
|
+
.description('Article 30 record of processing for this org (no personal data; org admin)')
|
|
597
|
+
.option('--out <file>', 'write to a file')
|
|
598
|
+
.action(async (options) => {
|
|
599
|
+
auth.requireAuth();
|
|
600
|
+
const ep = resolveEndpoint('contactsq.processingRecord');
|
|
601
|
+
const data = await call(() => client.get(ep.path, { service: ep.service }), { forbidden: 'The processing record is for org admins.' });
|
|
602
|
+
if (options.out) { writeFileSync(options.out, `${JSON.stringify(data, null, 2)}\n`); announce('contacts processing-record', options.out); return; }
|
|
603
|
+
printJson(data);
|
|
604
|
+
announce('contacts processing-record', 'ok');
|
|
605
|
+
});
|
|
606
|
+
|
|
607
|
+
const retention = contacts.command('retention').description('Retention reaper: erases candidates past their retention date (kill switch: `contacts settings --reaper off`)');
|
|
608
|
+
retention
|
|
609
|
+
.command('status')
|
|
610
|
+
.description('Reaper status: enabled, last run, next run, pending')
|
|
611
|
+
.action(async () => {
|
|
612
|
+
auth.requireAuth();
|
|
613
|
+
const ep = resolveEndpoint('contactsq.retention.status');
|
|
614
|
+
const data = await call(() => client.get(ep.path, { service: ep.service }), { forbidden: 'Retention status is for human users.' });
|
|
615
|
+
if (isJson(program)) return printJson(data);
|
|
616
|
+
formatDetail({ ...data, last_counts: data.last_counts ? JSON.stringify(data.last_counts) : '', pending: JSON.stringify(data.pending || {}) });
|
|
617
|
+
announce('contacts retention status', `${data.enabled ? 'enabled' : 'disabled'} next ${data.next_tick_at || 'never'}`);
|
|
618
|
+
});
|
|
619
|
+
retention
|
|
620
|
+
.command('run')
|
|
621
|
+
.description('Run the reaper now (org admin). --dry-run lists what would be erased and changes nothing')
|
|
622
|
+
.option('--dry-run', 'report only')
|
|
623
|
+
.action(async (options) => {
|
|
624
|
+
auth.requireAuth();
|
|
625
|
+
const ep = resolveEndpoint('contactsq.retention.run');
|
|
626
|
+
const data = await call(() => client.post(ep.path, { service: ep.service, body: { dry_run: Boolean(options.dryRun) } }), { forbidden: 'Running retention needs an org admin.' });
|
|
627
|
+
if (isJson(program)) return printJson(data);
|
|
628
|
+
const r = data.run || {};
|
|
629
|
+
formatDetail({ status: r.status, dry_run: r.dry_run, considered: r.considered, erased: r.erased, failed: r.failed, skipped_legal_hold: r.skipped_legal_hold, retired_failed: r.retired_failed });
|
|
630
|
+
announce('contacts retention run', `${r.dry_run ? 'dry run: would erase' : 'erased'} ${r.dry_run ? (r.would_erase_ids || []).length : r.erased}`);
|
|
631
|
+
});
|
|
632
|
+
|
|
633
|
+
contacts
|
|
634
|
+
.command('import')
|
|
635
|
+
.description('Import contacts from a CSV (incl. Google Contacts export) or vCard file. Dry run unless --commit. Org admin')
|
|
636
|
+
.argument('<file>', '.csv or .vcf file')
|
|
637
|
+
.option('--commit', 'write the contacts (without it nothing is written)')
|
|
638
|
+
.option('--format <f>', 'csv | google_csv | vcard (default: detected)')
|
|
639
|
+
.option('--mapping <json>', 'column mapping as JSON or a path to a JSON file: {"Header": "email|phone|name|first_name|last_name|company|title|notes|tags|ignore"}')
|
|
640
|
+
.action(async (file, options) => {
|
|
641
|
+
auth.requireAuth();
|
|
642
|
+
checkEnum(options.format, IMPORT_FORMATS, '--format');
|
|
643
|
+
let bytes;
|
|
644
|
+
try { bytes = readFileSync(file); } catch (e) { throw new QuoxError(`Cannot read ${file}: ${e.message}`, 2); }
|
|
645
|
+
let mapping;
|
|
646
|
+
if (options.mapping) {
|
|
647
|
+
const raw = existsSync(options.mapping) ? readFileSync(options.mapping, 'utf8') : options.mapping;
|
|
648
|
+
try { mapping = JSON.parse(raw); } catch { throw new QuoxError('--mapping must be valid JSON (or a path to a JSON file).', 2); }
|
|
649
|
+
}
|
|
650
|
+
const form = new FormData();
|
|
651
|
+
form.append('file', new Blob([bytes]), basename(file));
|
|
652
|
+
form.append('dry_run', options.commit ? 'false' : 'true');
|
|
653
|
+
if (options.format) form.append('format', options.format);
|
|
654
|
+
if (mapping) form.append('mapping', JSON.stringify(mapping));
|
|
655
|
+
const ep = resolveEndpoint('contactsq.import');
|
|
656
|
+
// retries: 0, a retried multipart upload would import twice.
|
|
657
|
+
const data = await call(() => client.post(ep.path, { service: ep.service, body: form, timeout: 300000, retries: 0 }), {
|
|
658
|
+
forbidden: 'Importing contacts needs a signed-in org admin; agents and service keys cannot import.',
|
|
659
|
+
});
|
|
660
|
+
if (isJson(program)) return printJson(data);
|
|
661
|
+
const r = data.report || {};
|
|
662
|
+
if (data.dry_run) {
|
|
663
|
+
formatDetail({
|
|
664
|
+
rows: r.rows, would_create: r.would_create, would_merge_into_existing: r.would_merge_into_existing,
|
|
665
|
+
organisations_would_create: r.organisations_would_create, skipped_tombstoned: r.skipped_tombstoned,
|
|
666
|
+
skipped_suppressed_marked: r.skipped_suppressed_marked, duplicates_in_file: r.duplicates_in_file, invalid: (r.invalid || []).length,
|
|
667
|
+
});
|
|
668
|
+
for (const i of r.invalid || []) console.log(`row ${i.row}: ${i.code}`);
|
|
669
|
+
console.log('Dry run: nothing was written. Re-run with --commit to import.');
|
|
670
|
+
announce('contacts import', `dry run: ${r.would_create} create, ${r.would_merge_into_existing} merge`);
|
|
671
|
+
return;
|
|
672
|
+
}
|
|
673
|
+
formatDetail({ import_id: data.import_id, ...(data.counts || {}) });
|
|
674
|
+
for (const i of r.invalid || []) console.log(`row ${i.row}: ${i.code}`);
|
|
675
|
+
for (const i of r.row_errors || []) console.log(`row ${i.row}: failed (${i.code})`);
|
|
676
|
+
announce('contacts import', `${(data.counts || {}).created || 0} created, ${(data.counts || {}).merged_into_existing || 0} merged (${data.import_id})`);
|
|
677
|
+
});
|
|
678
|
+
|
|
679
|
+
// ---- P7: CardDAV pull sync (iCloud, Fastmail, Nextcloud). One way: the address book is read, never written. ----
|
|
680
|
+
const syncCmd = contacts.command('sync').description('Link an address book (iCloud or any CardDAV) and pull it into ContactsQ, one way. Org admin');
|
|
681
|
+
const SYNC_FORBIDDEN = 'Address book sync needs a signed-in org admin; agents and service keys cannot manage it.';
|
|
682
|
+
const syncRow = (s) => ({
|
|
683
|
+
id: s.id, label: s.label, provider: s.provider, enabled: s.enabled ? 'yes' : 'no', every: s.interval_min ? `${s.interval_min}m` : 'manual',
|
|
684
|
+
last_run: s.last_run_at || 'never', status: s.retired_at ? `RETIRED (${s.last_status || ''})` : (s.last_status || '-'),
|
|
685
|
+
});
|
|
686
|
+
const printSyncCounts = (c) => formatDetail(c || {});
|
|
687
|
+
syncCmd
|
|
688
|
+
.command('list')
|
|
689
|
+
.description('List sync sources with last run and status')
|
|
690
|
+
.action(async () => {
|
|
691
|
+
auth.requireAuth();
|
|
692
|
+
const ep = resolveEndpoint('contactsq.sync.list');
|
|
693
|
+
const data = await call(() => client.get(ep.path, { service: ep.service }), { forbidden: SYNC_FORBIDDEN });
|
|
694
|
+
if (isJson(program)) return printJson(data);
|
|
695
|
+
const rows = (data.sources || []).map(syncRow);
|
|
696
|
+
formatOutput({ columns: ['id', 'label', 'provider', 'enabled', 'every', 'last_run', 'status'], headers: ['ID', 'Label', 'Provider', 'Enabled', 'Every', 'Last run', 'Status'], rows }, { format: program.opts().output });
|
|
697
|
+
announce('contacts sync list', `${rows.length} sources`);
|
|
698
|
+
});
|
|
699
|
+
syncCmd
|
|
700
|
+
.command('add')
|
|
701
|
+
.description('Add a sync source from a vault credential of kind carddav_account (create the credential first; for iCloud use an app-specific password)')
|
|
702
|
+
.requiredOption('--credential <id>', 'vault credential id (icloud_contacts or carddav)')
|
|
703
|
+
.option('--label <text>', 'a name for this source')
|
|
704
|
+
.option('--every <minutes>', 'sync on a schedule, every N minutes (15 to 10080). Omit for manual only')
|
|
705
|
+
.option('--addressbook <url>', 'pick one address book when the account has several')
|
|
706
|
+
.action(async (options) => {
|
|
707
|
+
auth.requireAuth();
|
|
708
|
+
const body = { credential_id: options.credential };
|
|
709
|
+
if (options.label) body.label = options.label;
|
|
710
|
+
if (options.addressbook) body.addressbook_url = options.addressbook;
|
|
711
|
+
if (options.every !== undefined) {
|
|
712
|
+
const n = Number(options.every);
|
|
713
|
+
if (!Number.isInteger(n) || n < 15 || n > 10080) throw new QuoxError('--every must be a whole number of minutes between 15 and 10080.', 2);
|
|
714
|
+
body.interval_min = n;
|
|
715
|
+
}
|
|
716
|
+
const ep = resolveEndpoint('contactsq.sync.add');
|
|
717
|
+
let data;
|
|
718
|
+
try {
|
|
719
|
+
data = await call(() => client.post(ep.path, { service: ep.service, body, retries: 0 }), { forbidden: SYNC_FORBIDDEN });
|
|
720
|
+
} catch (e) {
|
|
721
|
+
if (e instanceof QuoxApiError && e.body) {
|
|
722
|
+
const code = e.body.error;
|
|
723
|
+
if (code === 'credential_rejected') throw new QuoxError('The address book server rejected that login. For iCloud use an app-specific password, not your Apple ID password.', 1);
|
|
724
|
+
if (code === 'credential_wrong_kind') throw new QuoxError('That credential is not an address book login (kind carddav_account).', 1);
|
|
725
|
+
if (code === 'source_exists') throw new QuoxError('That address book is already linked.', 1);
|
|
726
|
+
if (code === 'addressbook_choice_required') {
|
|
727
|
+
const list = (e.body.addressbooks || []).map((a) => ` ${a.name || '(unnamed)'} ${a.url}`).join('\n');
|
|
728
|
+
throw new QuoxError(`This account has several address books. Re-run with --addressbook <url>:\n${list}`, 1);
|
|
729
|
+
}
|
|
730
|
+
}
|
|
731
|
+
throw e;
|
|
732
|
+
}
|
|
733
|
+
if (isJson(program)) return printJson(data);
|
|
734
|
+
const s = data.source || {};
|
|
735
|
+
formatDetail(syncRow(s));
|
|
736
|
+
announce('contacts sync add', `${s.id} (try: quox contacts sync run ${s.id} --dry-run)`);
|
|
737
|
+
});
|
|
738
|
+
syncCmd
|
|
739
|
+
.command('run')
|
|
740
|
+
.description('Pull the address book now. --dry-run reports counts and changes nothing')
|
|
741
|
+
.argument('<id>', 'sync source id')
|
|
742
|
+
.option('--dry-run', 'report only')
|
|
743
|
+
.action(async (id, options) => {
|
|
744
|
+
auth.requireAuth();
|
|
745
|
+
const ep = resolveEndpoint('contactsq.sync.run', { id });
|
|
746
|
+
const data = await call(() => client.post(ep.path, { service: ep.service, body: { dry_run: Boolean(options.dryRun) }, timeout: 600000, retries: 0 }), { forbidden: SYNC_FORBIDDEN });
|
|
747
|
+
if (isJson(program)) return printJson(data);
|
|
748
|
+
const c = data.counts || {};
|
|
749
|
+
printSyncCounts(c);
|
|
750
|
+
if (data.dry_run) console.log('Dry run: nothing was written.');
|
|
751
|
+
announce('contacts sync run', `${data.dry_run ? 'dry run: ' : ''}${c.created || 0} created, ${c.updated || 0} updated, ${c.flagged_removed || 0} flagged removed`);
|
|
752
|
+
});
|
|
753
|
+
for (const [name, on] of [['enable', true], ['disable', false]]) {
|
|
754
|
+
syncCmd
|
|
755
|
+
.command(name)
|
|
756
|
+
.description(on ? 'Enable a source for scheduled sync (also clears a retirement after repeated failures)' : 'Stop scheduled sync for a source (contacts are kept)')
|
|
757
|
+
.argument('<id>', 'sync source id')
|
|
758
|
+
.action(async (id) => {
|
|
759
|
+
auth.requireAuth();
|
|
760
|
+
const ep = resolveEndpoint('contactsq.sync.update', { id });
|
|
761
|
+
const data = await call(() => client.patch(ep.path, { service: ep.service, body: { enabled: on } }), { forbidden: SYNC_FORBIDDEN });
|
|
762
|
+
if (isJson(program)) return printJson(data);
|
|
763
|
+
announce(`contacts sync ${name}`, `${id} ${on ? 'enabled' : 'disabled'}`);
|
|
764
|
+
});
|
|
765
|
+
}
|
|
766
|
+
syncCmd
|
|
767
|
+
.command('remove')
|
|
768
|
+
.description('Remove a sync source: stops syncing, keeps every contact already pulled')
|
|
769
|
+
.argument('<id>', 'sync source id')
|
|
770
|
+
.option('--yes', 'confirm (refused without it)')
|
|
771
|
+
.action(async (id, options) => {
|
|
772
|
+
auth.requireAuth();
|
|
773
|
+
if (!options.yes) throw new QuoxError('Removing a source stops syncing and drops its links. Contacts already pulled are kept. Re-run with --yes to confirm.', 2);
|
|
774
|
+
const ep = resolveEndpoint('contactsq.sync.remove', { id });
|
|
775
|
+
const data = await call(() => client.delete(ep.path, { service: ep.service }), { forbidden: SYNC_FORBIDDEN });
|
|
776
|
+
if (isJson(program)) return printJson(data);
|
|
777
|
+
announce('contacts sync remove', `${id} removed, contacts kept`);
|
|
778
|
+
});
|
|
779
|
+
|
|
780
|
+
contacts
|
|
781
|
+
.command('merge')
|
|
782
|
+
.description('Merge LOSER into SURVIVOR (org admin, undoable for 30 days with `contacts unmerge`)')
|
|
783
|
+
.argument('<survivor>', 'contact id that stays')
|
|
784
|
+
.argument('<loser>', 'contact id that is folded in')
|
|
785
|
+
.option('--yes', 'confirm the merge (refused without it)')
|
|
786
|
+
.action(async (survivor, loser, options) => {
|
|
787
|
+
auth.requireAuth();
|
|
788
|
+
if (!options.yes) throw new QuoxError('Merging moves identifiers, history and facts onto the survivor. Re-run with --yes to confirm (undo: `quox contacts unmerge <merge-id>` within 30 days).', 2);
|
|
789
|
+
const ep = resolveEndpoint('contactsq.contacts.merge', { id: survivor });
|
|
790
|
+
const data = await call(() => client.post(ep.path, { service: ep.service, body: { from_id: loser } }), { forbidden: 'Merging needs a signed-in org admin.' });
|
|
791
|
+
if (isJson(program)) return printJson(data);
|
|
792
|
+
const m = data.merge || {};
|
|
793
|
+
formatDetail({ merge_id: m.id, survivor: m.survivor_id, merged: m.loser_id, undo_until: m.expires_at, ...(data.counts || {}) });
|
|
794
|
+
announce('contacts merge', `${m.id} (undo: quox contacts unmerge ${m.id})`);
|
|
795
|
+
});
|
|
796
|
+
|
|
797
|
+
contacts
|
|
798
|
+
.command('unmerge')
|
|
799
|
+
.description('Undo a merge (org admin, within 30 days): both contacts are restored exactly')
|
|
800
|
+
.argument('<merge-id>', 'merge id from `contacts merge`')
|
|
801
|
+
.action(async (mid) => {
|
|
802
|
+
auth.requireAuth();
|
|
803
|
+
const ep = resolveEndpoint('contactsq.merges.undo', { id: mid });
|
|
804
|
+
const data = await call(() => client.post(ep.path, { service: ep.service }), { forbidden: 'Undoing a merge needs a signed-in org admin.' });
|
|
805
|
+
if (isJson(program)) return printJson(data);
|
|
806
|
+
const m = data.merge || {};
|
|
807
|
+
formatDetail({ merge_id: m.id, status: m.status, survivor: m.survivor_id, restored: m.loser_id });
|
|
808
|
+
announce('contacts unmerge', `${m.id} restored`);
|
|
809
|
+
});
|
|
810
|
+
|
|
811
|
+
contacts
|
|
812
|
+
.command('duplicates')
|
|
813
|
+
.description('Suggest duplicate pairs (same identifier, or same name at the same organisation)')
|
|
814
|
+
.action(async () => {
|
|
815
|
+
auth.requireAuth();
|
|
816
|
+
const ep = resolveEndpoint('contactsq.duplicates');
|
|
817
|
+
const data = await call(() => client.get(ep.path, { service: ep.service }), { forbidden: 'Duplicate suggestions are for human users.' });
|
|
818
|
+
if (isJson(program)) return printJson(data);
|
|
819
|
+
const rows = (data.duplicates || []).map((d) => ({ a: `${d.a.id} ${d.a.display_name}`, b: `${d.b.id} ${d.b.display_name}`, why: d.reasons.join(','), keep: d.suggested_survivor }));
|
|
820
|
+
formatOutput({ columns: ['a', 'b', 'why', 'keep'], headers: ['A', 'B', 'Why', 'Suggested survivor'], rows }, { format: program.opts().output });
|
|
821
|
+
announce('contacts duplicates', `${rows.length} pairs`);
|
|
822
|
+
});
|
|
823
|
+
|
|
824
|
+
contacts
|
|
825
|
+
.command('facts')
|
|
826
|
+
.description('List a contact\'s facts (human). --confirm / --reject <fact-id> reviews an agent proposal')
|
|
827
|
+
.argument('<id>', 'contact id')
|
|
828
|
+
.option('--confirm <fact-id>', 'confirm a proposed fact')
|
|
829
|
+
.option('--reject <fact-id>', 'reject a proposed fact')
|
|
830
|
+
.action(async (id, options) => {
|
|
831
|
+
auth.requireAuth();
|
|
832
|
+
const forbidden = 'Facts are for human users; agents can only propose them.';
|
|
833
|
+
for (const [factId, state] of [[options.confirm, 'confirmed'], [options.reject, 'rejected']]) {
|
|
834
|
+
if (!factId) continue;
|
|
835
|
+
const uep = resolveEndpoint('contactsq.facts.update', { id, factId });
|
|
836
|
+
await call(() => client.put(uep.path, { service: uep.service, body: { review_state: state } }), { forbidden });
|
|
837
|
+
announce('contacts facts', `${factId} ${state}`);
|
|
838
|
+
}
|
|
839
|
+
const ep = resolveEndpoint('contactsq.facts.list', { id });
|
|
840
|
+
const data = await call(() => client.get(ep.path, { service: ep.service }), { forbidden });
|
|
841
|
+
if (isJson(program)) return printJson(data);
|
|
842
|
+
const rows = (data.facts || []).map((f) => ({ id: f.id, key: f.key, value: f.value || '', state: f.review_state, sensitivity: f.sensitivity, confidence: f.confidence }));
|
|
843
|
+
formatOutput({ columns: ['id', 'key', 'value', 'state', 'sensitivity', 'confidence'], headers: ['ID', 'Key', 'Value', 'State', 'Sensitivity', 'Confidence'], rows }, { format: program.opts().output });
|
|
844
|
+
announce('contacts facts', `${rows.length} facts`);
|
|
845
|
+
});
|
|
846
|
+
|
|
847
|
+
contacts
|
|
848
|
+
.command('settings')
|
|
849
|
+
.description('Show or update ContactsQ settings (updates are admin only)')
|
|
850
|
+
.option('--region <cc>', 'default phone region, e.g. GB')
|
|
851
|
+
.option('--candidate-days <n>', 'days to keep auto-created candidates')
|
|
852
|
+
.option('--reaper <on|off>', 'retention reaper kill switch')
|
|
853
|
+
.option('--reaper-time <HH:MM>', 'UTC time of day the reaper runs (default 03:15)')
|
|
854
|
+
.option('--backup-days <n>', 'days until an erased contact ages out of backups (shown on certificates)')
|
|
855
|
+
.option('--report <weekly|monthly|off>', 'recurring compliance report to the Approvals inbox (default off)')
|
|
856
|
+
.option('--report-day <n>', 'report day: weekly 1-7 (Mon=1), monthly 1-28')
|
|
857
|
+
.option('--report-time <HH:MM>', 'UTC time of day the report is delivered (default 08:00)')
|
|
858
|
+
.option('--strict-names <on|off>', 'store names encrypted (agents see a placeholder); run `contacts strict-names apply` to migrate existing contacts')
|
|
859
|
+
.action(async (options) => {
|
|
860
|
+
auth.requireAuth();
|
|
861
|
+
const gep = resolveEndpoint('contactsq.settings.get');
|
|
862
|
+
let data = await call(() => client.get(gep.path, { service: gep.service }));
|
|
863
|
+
if (options.reaper !== undefined) checkEnum(options.reaper, ['on', 'off'], '--reaper');
|
|
864
|
+
if (options.strictNames !== undefined) checkEnum(options.strictNames, ['on', 'off'], '--strict-names');
|
|
865
|
+
if (options.report !== undefined) checkEnum(options.report, ['weekly', 'monthly', 'off'], '--report');
|
|
866
|
+
const reportTouched = options.report !== undefined || options.reportDay !== undefined || options.reportTime !== undefined;
|
|
867
|
+
if (reportTouched ||
|
|
868
|
+
options.strictNames !== undefined || options.region !== undefined || options.candidateDays !== undefined || options.reaper !== undefined || options.reaperTime !== undefined || options.backupDays !== undefined) {
|
|
869
|
+
const cur = data.settings || {};
|
|
870
|
+
const body = { ...cur };
|
|
871
|
+
if (options.region !== undefined) body.default_region = String(options.region).toUpperCase();
|
|
872
|
+
if (options.candidateDays !== undefined) body.candidate_retention_days = Number(options.candidateDays);
|
|
873
|
+
if (options.reaper !== undefined) body.retention_reaper_enabled = options.reaper === 'on';
|
|
874
|
+
if (options.reaperTime !== undefined) body.retention_run_time_utc = options.reaperTime;
|
|
875
|
+
if (options.backupDays !== undefined) body.backup_retention_days = Number(options.backupDays);
|
|
876
|
+
if (options.strictNames !== undefined) body.strict_names = options.strictNames === 'on';
|
|
877
|
+
if (reportTouched) {
|
|
878
|
+
const cr = { ...(cur.compliance_report || {}) };
|
|
879
|
+
if (options.report !== undefined) {
|
|
880
|
+
cr.enabled = options.report !== 'off';
|
|
881
|
+
if (options.report !== 'off') cr.cadence = options.report;
|
|
882
|
+
}
|
|
883
|
+
if (options.reportDay !== undefined) cr.day = Number(options.reportDay);
|
|
884
|
+
if (options.reportTime !== undefined) cr.time_utc = options.reportTime;
|
|
885
|
+
body.compliance_report = cr;
|
|
886
|
+
}
|
|
887
|
+
const pep = resolveEndpoint('contactsq.settings.update');
|
|
888
|
+
data = await call(() => client.put(pep.path, { service: pep.service, body }), {
|
|
889
|
+
forbidden: 'Changing ContactsQ settings needs an org admin.',
|
|
890
|
+
});
|
|
891
|
+
}
|
|
892
|
+
if (isJson(program)) return printJson(data);
|
|
893
|
+
formatDetail(data.settings || {});
|
|
894
|
+
announce('contacts settings', 'ok');
|
|
895
|
+
});
|
|
896
|
+
contacts
|
|
897
|
+
.command('unblock')
|
|
898
|
+
.description('Lift an erasure tombstone so a returning person can be re-added (org admin; witnessed)')
|
|
899
|
+
.argument('<identifier>', 'phone number or email address that was erased')
|
|
900
|
+
.requiredOption('--reason <code>', 'reason code, e.g. subject_returned')
|
|
901
|
+
.option('--yes', 'confirm (refused without it)')
|
|
902
|
+
.action(async (identifier, options) => {
|
|
903
|
+
auth.requireAuth();
|
|
904
|
+
if (!options.yes) throw new QuoxError('Lifting a tombstone lets this identifier be recorded again. Re-run with --yes to confirm.', 2);
|
|
905
|
+
const ep = resolveEndpoint('contactsq.suppressions.tombstoneLift');
|
|
906
|
+
const data = await call(() => client.post(ep.path, { service: ep.service, body: { channel: channelOf(identifier), value: identifier, reason_code: options.reason } }), {
|
|
907
|
+
forbidden: 'Lifting a tombstone needs a signed-in org admin; agents and service keys cannot.',
|
|
908
|
+
});
|
|
909
|
+
if (isJson(program)) return printJson(data);
|
|
910
|
+
const l = data.lifted || {};
|
|
911
|
+
formatDetail({ channel: l.channel, removed: l.removed });
|
|
912
|
+
announce('contacts unblock', `${l.channel || channelOf(identifier)} tombstone lifted`);
|
|
913
|
+
});
|
|
914
|
+
|
|
915
|
+
contacts
|
|
916
|
+
.command('rewrap')
|
|
917
|
+
.description('Re-wrap every contact key under the current master key and upgrade old ciphertexts (org admin)')
|
|
918
|
+
.action(async () => {
|
|
919
|
+
auth.requireAuth();
|
|
920
|
+
const ep = resolveEndpoint('contactsq.admin.rewrap');
|
|
921
|
+
const data = await call(() => client.post(ep.path, { service: ep.service, body: {} }), {
|
|
922
|
+
forbidden: 'Rewrap needs a signed-in org admin; agents and service keys cannot run it.',
|
|
923
|
+
});
|
|
924
|
+
if (isJson(program)) return printJson(data);
|
|
925
|
+
formatDetail(data.counts || {});
|
|
926
|
+
if (data.previous_key_still_required) {
|
|
927
|
+
console.log('\nWarning: some suppressions can only be matched with MASTER_ENCRYPTION_KEY_PREVIOUS. Do not remove it yet.');
|
|
928
|
+
}
|
|
929
|
+
if ((data.failed_ids || []).length) console.log(`\nFailed contact ids: ${data.failed_ids.join(', ')}`);
|
|
930
|
+
announce('contacts rewrap', `${(data.counts || {}).rewrapped ?? 0} rewrapped, ${(data.counts || {}).failed ?? 0} failed`);
|
|
931
|
+
});
|
|
932
|
+
|
|
933
|
+
contacts
|
|
934
|
+
.command('reindex')
|
|
935
|
+
.description('Re-key every blind index under the current master key (master-key rotation step; org admin)')
|
|
936
|
+
.action(async () => {
|
|
937
|
+
auth.requireAuth();
|
|
938
|
+
const ep = resolveEndpoint('contactsq.admin.reindex');
|
|
939
|
+
const data = await call(() => client.post(ep.path, { service: ep.service, body: {} }), {
|
|
940
|
+
forbidden: 'Reindex needs a signed-in org admin; agents and service keys cannot run it.',
|
|
941
|
+
});
|
|
942
|
+
if (isJson(program)) return printJson(data);
|
|
943
|
+
formatDetail({ complete: data.complete, bi_version: data.bi_version, previous_key_removable: data.previous_key_removable, ...(data.counts || {}) });
|
|
944
|
+
if ((data.failed_ids || []).length) console.log(`\nFailed contact ids: ${data.failed_ids.join(', ')}`);
|
|
945
|
+
if (data.complete && !data.previous_key_removable) {
|
|
946
|
+
console.log('\nWarning: some suppressions could not be re-keyed (no surviving identifier). Keep MASTER_ENCRYPTION_KEY_PREVIOUS configured.');
|
|
947
|
+
}
|
|
948
|
+
announce('contacts reindex', data.complete ? `bi_version ${data.bi_version}` : 'INCOMPLETE');
|
|
949
|
+
});
|
|
950
|
+
|
|
951
|
+
contacts
|
|
952
|
+
.command('strict-names')
|
|
953
|
+
.description('Apply the org strict_names setting to existing contacts, both directions (org admin)')
|
|
954
|
+
.argument('<action>', 'apply')
|
|
955
|
+
.action(async (action) => {
|
|
956
|
+
auth.requireAuth();
|
|
957
|
+
checkEnum(action, ['apply'], 'action');
|
|
958
|
+
const ep = resolveEndpoint('contactsq.admin.strictNamesApply');
|
|
959
|
+
const data = await call(() => client.post(ep.path, { service: ep.service, body: {} }), {
|
|
960
|
+
forbidden: 'Applying strict names needs a signed-in org admin.',
|
|
961
|
+
});
|
|
962
|
+
if (isJson(program)) return printJson(data);
|
|
963
|
+
formatDetail({ strict_names: data.strict_names, ...(data.counts || {}) });
|
|
964
|
+
if ((data.failed_ids || []).length) console.log(`\nFailed contact ids: ${data.failed_ids.join(', ')}`);
|
|
965
|
+
announce('contacts strict-names apply', `${(data.counts || {}).converted ?? 0} converted`);
|
|
966
|
+
});
|
|
967
|
+
}
|