@wavyx/pdcli 0.3.0 → 0.5.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.
@@ -0,0 +1,78 @@
1
+ import { Flags } from '@oclif/core'
2
+ import BaseCommand from '../../base-command.js'
3
+ import { collectPages } from '../../lib/pagination.js'
4
+ import { computeHealth } from '../../lib/analytics.js'
5
+ import { CliError } from '../../lib/errors.js'
6
+
7
+ export default class PipelineHealthCommand extends BaseCommand {
8
+ static description =
9
+ 'Per-stage pipeline health: value, weighted value, stale deals, missing next steps'
10
+
11
+ static examples = [
12
+ '<%= config.bin %> pipeline health',
13
+ '<%= config.bin %> pipeline health --pipeline 1',
14
+ ]
15
+
16
+ static flags = {
17
+ ...BaseCommand.baseFlags,
18
+ pipeline: Flags.integer({
19
+ description: 'Pipeline ID (required when the account has several)',
20
+ }),
21
+ }
22
+
23
+ async run() {
24
+ const { flags } = await this.parse(PipelineHealthCommand)
25
+ const now = new Date()
26
+
27
+ let pipelineId = flags.pipeline
28
+ if (pipelineId == null) {
29
+ const body = await this.apiClient.get('/api/v2/pipelines')
30
+ const pipelines = body.data ?? []
31
+ if (pipelines.length > 1) {
32
+ throw new CliError(
33
+ `Account has ${pipelines.length} pipelines — pass --pipeline <id> ` +
34
+ `(${pipelines.map((p) => `${p.id}=${p.name}`).join(', ')})`,
35
+ { exitCode: 64 },
36
+ )
37
+ }
38
+ pipelineId = pipelines[0]?.id
39
+ }
40
+
41
+ const [stages, open, activities] = await Promise.all([
42
+ collectPages(
43
+ this.apiClient.pageV2('/api/v2/stages', {
44
+ pipeline_id: pipelineId,
45
+ limit: 500,
46
+ }),
47
+ ),
48
+ collectPages(
49
+ this.apiClient.pageV2('/api/v2/deals', {
50
+ pipeline_id: pipelineId,
51
+ status: 'open',
52
+ limit: 500,
53
+ }),
54
+ ),
55
+ collectPages(
56
+ this.apiClient.pageV2('/api/v2/activities', {
57
+ done: false,
58
+ limit: 500,
59
+ }),
60
+ ),
61
+ ])
62
+
63
+ const rows = computeHealth(open, stages, activities, { now })
64
+
65
+ await this.outputResults(rows, {
66
+ stage: { header: 'Stage' },
67
+ openCount: { header: 'Open' },
68
+ openValue: { header: 'Value' },
69
+ weightedValue: {
70
+ header: 'Weighted',
71
+ get: (row) => String(Math.round(row.weightedValue)),
72
+ },
73
+ staleCount: { header: 'Stale >14d' },
74
+ noNextActivityCount: { header: 'No next step' },
75
+ pastCloseCount: { header: 'Past close' },
76
+ })
77
+ }
78
+ }
@@ -0,0 +1,154 @@
1
+ const DAY_MS = 86_400_000
2
+
3
+ /**
4
+ * Sales Velocity Equation over a trailing window:
5
+ * (open opportunities × win rate × avg won value) / avg cycle days.
6
+ * Win rate uses the decided-deals variant: won / (won + lost) closed in
7
+ * the window, keyed on won_time/lost_time. Cycle = won_time − add_time.
8
+ * @param {object[]} deals open + closed deals (closed filtered to window here)
9
+ * @param {{ since: Date, now: Date }} window
10
+ */
11
+ export function computeVelocity(deals, { since }) {
12
+ const open = deals.filter((d) => d.status === 'open')
13
+ const wonInPeriod = deals.filter(
14
+ (d) => d.status === 'won' && inWindow(d.won_time, since),
15
+ )
16
+ const lostInPeriod = deals.filter(
17
+ (d) => d.status === 'lost' && inWindow(d.lost_time, since),
18
+ )
19
+
20
+ const decided = wonInPeriod.length + lostInPeriod.length
21
+ const winRate = decided > 0 ? wonInPeriod.length / decided : null
22
+
23
+ const wonValues = wonInPeriod.map((d) => d.value ?? 0)
24
+ const avgWonValue =
25
+ wonInPeriod.length > 0
26
+ ? wonValues.reduce((a, b) => a + b, 0) / wonInPeriod.length
27
+ : null
28
+
29
+ const cycles = wonInPeriod.map(
30
+ (d) => (new Date(d.won_time) - new Date(d.add_time)) / DAY_MS,
31
+ )
32
+ const avgCycleDays =
33
+ cycles.length > 0 ? cycles.reduce((a, b) => a + b, 0) / cycles.length : null
34
+
35
+ const velocityPerDay =
36
+ winRate != null && avgWonValue != null && avgCycleDays > 0
37
+ ? (open.length * winRate * avgWonValue) / avgCycleDays
38
+ : null
39
+
40
+ return {
41
+ openCount: open.length,
42
+ wonCount: wonInPeriod.length,
43
+ lostCount: lostInPeriod.length,
44
+ winRate,
45
+ avgWonValue,
46
+ avgCycleDays,
47
+ velocityPerDay,
48
+ }
49
+ }
50
+
51
+ function inWindow(time, since) {
52
+ return time != null && new Date(time) >= since
53
+ }
54
+
55
+ /**
56
+ * Stage-reach funnel approximated from closed deals' final stages: a deal
57
+ * counts as having reached every stage up to (and including) its final one;
58
+ * won deals count for every stage. Accurate per-stage history would need
59
+ * per-deal flow mining (v1) — this stays one cheap list call.
60
+ * @param {object[]} closedDeals won/lost deals (already window-filtered)
61
+ * @param {object[]} openDeals current open deals
62
+ * @param {object[]} stages v2 stages (order_nr, pipeline_id)
63
+ * @param {{ pipelineId?: number }} [options]
64
+ */
65
+ export function computeFunnel(closedDeals, openDeals, stages, options = {}) {
66
+ const ordered = stages
67
+ .filter(
68
+ (s) => options.pipelineId == null || s.pipeline_id === options.pipelineId,
69
+ )
70
+ .sort((a, b) => a.order_nr - b.order_nr)
71
+
72
+ const orderByStageId = new Map(ordered.map((s, i) => [s.id, i]))
73
+
74
+ function finalIndex(deal) {
75
+ if (deal.status === 'won') return ordered.length - 1
76
+ return orderByStageId.get(deal.stage_id) ?? -1
77
+ }
78
+
79
+ return ordered.map((stage, index) => {
80
+ const reached = closedDeals.filter((d) => finalIndex(d) >= index).length
81
+ const openHere = openDeals.filter((d) => d.stage_id === stage.id)
82
+ const prevReached =
83
+ index > 0
84
+ ? closedDeals.filter((d) => finalIndex(d) >= index - 1).length
85
+ : null
86
+
87
+ return {
88
+ stage: stage.name,
89
+ stageId: stage.id,
90
+ reached,
91
+ conversionFromPrev:
92
+ index > 0 && prevReached > 0 ? reached / prevReached : null,
93
+ openCount: openHere.length,
94
+ openValue: openHere.reduce((sum, d) => sum + (d.value ?? 0), 0),
95
+ }
96
+ })
97
+ }
98
+
99
+ const STALE_DAYS = 14
100
+
101
+ /**
102
+ * Per-stage pipeline health snapshot: open count/value, probability-weighted
103
+ * value (deal probability wins over stage default), stale deals (>14 days
104
+ * without update), deals without a future open activity, and deals past
105
+ * their expected close date.
106
+ * @param {object[]} deals open deals
107
+ * @param {object[]} stages
108
+ * @param {object[]} activities open+done activities for those deals
109
+ * @param {{ now: Date }} options
110
+ */
111
+ export function computeHealth(deals, stages, activities, { now }) {
112
+ const open = deals.filter((d) => d.status === 'open')
113
+ const today = now.toISOString().slice(0, 10)
114
+
115
+ const dealsWithFutureActivity = new Set(
116
+ activities
117
+ .filter((a) => !a.done && a.due_date >= today && a.deal_id != null)
118
+ .map((a) => a.deal_id),
119
+ )
120
+
121
+ const ordered = [...stages].sort((a, b) => a.order_nr - b.order_nr)
122
+
123
+ return ordered.map((stage) => {
124
+ const inStage = open.filter((d) => d.stage_id === stage.id)
125
+
126
+ const weightedValue = inStage.reduce((sum, d) => {
127
+ const probability = d.probability ?? stage.deal_probability ?? 100
128
+ return sum + ((d.value ?? 0) * probability) / 100
129
+ }, 0)
130
+
131
+ const staleCount = inStage.filter(
132
+ (d) => now - new Date(d.update_time) > STALE_DAYS * DAY_MS,
133
+ ).length
134
+
135
+ const noNextActivityCount = inStage.filter(
136
+ (d) => !dealsWithFutureActivity.has(d.id),
137
+ ).length
138
+
139
+ const pastCloseCount = inStage.filter(
140
+ (d) => d.expected_close_date != null && d.expected_close_date < today,
141
+ ).length
142
+
143
+ return {
144
+ stage: stage.name,
145
+ stageId: stage.id,
146
+ openCount: inStage.length,
147
+ openValue: inStage.reduce((sum, d) => sum + (d.value ?? 0), 0),
148
+ weightedValue,
149
+ staleCount,
150
+ noNextActivityCount,
151
+ pastCloseCount,
152
+ }
153
+ })
154
+ }
@@ -0,0 +1,228 @@
1
+ const DAY_MS = 86_400_000
2
+ const STALE_DAYS = 14
3
+ const ANCIENT_FALLBACK_DAYS = 168 // ~2× the median B2B cycle
4
+ const ANCIENT_CYCLE_MULTIPLIER = 2
5
+
6
+ function openDeals(data) {
7
+ return data.deals.filter((d) => d.status === 'open')
8
+ }
9
+
10
+ function today(now) {
11
+ return now.toISOString().slice(0, 10)
12
+ }
13
+
14
+ /**
15
+ * Data-quality checks. Each returns flagged items; detection rules follow
16
+ * common RevOps hygiene practice (see docs for thresholds).
17
+ * @type {{ name: string, severity: 'must' | 'should', title: string, run: (data: object, ctx: { now: Date }) => object[] }[]}
18
+ */
19
+ export const AUDIT_CHECKS = [
20
+ {
21
+ name: 'stale-deals',
22
+ severity: 'must',
23
+ title: `Open deals untouched for >${STALE_DAYS} days`,
24
+ run: (data, { now }) =>
25
+ openDeals(data)
26
+ .filter((d) => now - new Date(d.update_time) > STALE_DAYS * DAY_MS)
27
+ .map((d) => ({
28
+ id: d.id,
29
+ title: d.title,
30
+ days: Math.floor((now - new Date(d.update_time)) / DAY_MS),
31
+ })),
32
+ },
33
+ {
34
+ name: 'no-next-activity',
35
+ severity: 'must',
36
+ title: 'Open deals with no future activity scheduled',
37
+ run: (data, { now }) => {
38
+ const withFuture = new Set(
39
+ data.activities
40
+ .filter(
41
+ (a) => !a.done && a.due_date >= today(now) && a.deal_id != null,
42
+ )
43
+ .map((a) => a.deal_id),
44
+ )
45
+ return openDeals(data)
46
+ .filter((d) => !withFuture.has(d.id))
47
+ .map((d) => ({ id: d.id, title: d.title }))
48
+ },
49
+ },
50
+ {
51
+ name: 'past-close-date',
52
+ severity: 'must',
53
+ title: 'Open deals past their expected close date',
54
+ run: (data, { now }) =>
55
+ openDeals(data)
56
+ .filter(
57
+ (d) =>
58
+ d.expected_close_date != null && d.expected_close_date < today(now),
59
+ )
60
+ .map((d) => ({
61
+ id: d.id,
62
+ title: d.title,
63
+ expected_close_date: d.expected_close_date,
64
+ })),
65
+ },
66
+ {
67
+ name: 'missing-fields',
68
+ severity: 'must',
69
+ title: 'Open deals missing owner, person/org, value, or currency',
70
+ run: (data) =>
71
+ openDeals(data)
72
+ .map((d) => {
73
+ const missing = []
74
+ if (d.owner_id == null) missing.push('owner')
75
+ if (d.person_id == null && d.org_id == null)
76
+ missing.push('person/org')
77
+ if (d.value == null || d.value <= 0) missing.push('value')
78
+ else if (!d.currency) missing.push('currency')
79
+ return { id: d.id, title: d.title, missing }
80
+ })
81
+ .filter((item) => item.missing.length > 0),
82
+ },
83
+ {
84
+ name: 'ancient-deals',
85
+ severity: 'must',
86
+ title: 'Open deals far older than the typical won cycle',
87
+ run: (data, { now }) => {
88
+ const cycles = data.deals
89
+ .filter((d) => d.status === 'won' && d.won_time && d.add_time)
90
+ .map((d) => (new Date(d.won_time) - new Date(d.add_time)) / DAY_MS)
91
+ const avgCycle =
92
+ cycles.length > 0
93
+ ? cycles.reduce((a, b) => a + b, 0) / cycles.length
94
+ : null
95
+ const thresholdDays =
96
+ avgCycle != null
97
+ ? avgCycle * ANCIENT_CYCLE_MULTIPLIER
98
+ : ANCIENT_FALLBACK_DAYS
99
+ return openDeals(data)
100
+ .filter((d) => now - new Date(d.add_time) > thresholdDays * DAY_MS)
101
+ .map((d) => ({
102
+ id: d.id,
103
+ title: d.title,
104
+ ageDays: Math.floor((now - new Date(d.add_time)) / DAY_MS),
105
+ thresholdDays: Math.round(thresholdDays),
106
+ }))
107
+ },
108
+ },
109
+ {
110
+ name: 'missing-close-time',
111
+ severity: 'should',
112
+ title: 'Closed deals missing their close timestamp',
113
+ run: (data) =>
114
+ data.deals
115
+ .filter(
116
+ (d) =>
117
+ (d.status === 'won' && d.won_time == null) ||
118
+ (d.status === 'lost' && d.lost_time == null),
119
+ )
120
+ .map((d) => ({ id: d.id, title: d.title, status: d.status })),
121
+ },
122
+ {
123
+ name: 'duplicate-persons',
124
+ severity: 'must',
125
+ title: 'Persons sharing the same email',
126
+ run: (data) => {
127
+ const byEmail = new Map()
128
+ for (const person of data.persons) {
129
+ for (const entry of person.emails ?? []) {
130
+ const email = entry.value?.trim().toLowerCase()
131
+ if (!email) continue
132
+ byEmail.set(email, [...(byEmail.get(email) ?? []), person.id])
133
+ }
134
+ }
135
+ return [...byEmail.entries()]
136
+ .filter(([, ids]) => new Set(ids).size > 1)
137
+ .map(([email, ids]) => ({ email, ids: [...new Set(ids)] }))
138
+ },
139
+ },
140
+ {
141
+ name: 'uncontactable-persons',
142
+ severity: 'must',
143
+ title: 'Persons with neither email nor phone',
144
+ run: (data) =>
145
+ data.persons
146
+ .filter(
147
+ (p) =>
148
+ !(p.emails ?? []).some((e) => e.value) &&
149
+ !(p.phones ?? []).some((ph) => ph.value),
150
+ )
151
+ .map((p) => ({ id: p.id, name: p.name })),
152
+ },
153
+ {
154
+ name: 'duplicate-orgs',
155
+ severity: 'should',
156
+ title: 'Organizations with the same normalized name',
157
+ run: (data) => {
158
+ const byName = new Map()
159
+ for (const org of data.organizations) {
160
+ const key = normalizeOrgName(org.name)
161
+ if (!key) continue
162
+ byName.set(key, [...(byName.get(key) ?? []), org.id])
163
+ }
164
+ return [...byName.entries()]
165
+ .filter(([, ids]) => ids.length > 1)
166
+ .map(([name, ids]) => ({ name, ids }))
167
+ },
168
+ },
169
+ {
170
+ name: 'overdue-activities',
171
+ severity: 'should',
172
+ title: 'Overdue open activities piling up per owner',
173
+ run: (data, { now }) => {
174
+ const byOwner = new Map()
175
+ for (const activity of data.activities) {
176
+ if (activity.done || activity.due_date >= today(now)) continue
177
+ byOwner.set(
178
+ activity.owner_id,
179
+ (byOwner.get(activity.owner_id) ?? 0) + 1,
180
+ )
181
+ }
182
+ return [...byOwner.entries()].map(([owner_id, overdue]) => ({
183
+ owner_id,
184
+ overdue,
185
+ }))
186
+ },
187
+ },
188
+ {
189
+ name: 'currency-missing',
190
+ severity: 'should',
191
+ title: 'Deals with a value but no currency',
192
+ run: (data) =>
193
+ openDeals(data)
194
+ .filter((d) => d.value != null && d.value > 0 && !d.currency)
195
+ .map((d) => ({ id: d.id, title: d.title, value: d.value })),
196
+ },
197
+ ]
198
+
199
+ function normalizeOrgName(name) {
200
+ return (name ?? '')
201
+ .toLowerCase()
202
+ .replace(/\b(inc|ltd|llc|gmbh|sa|sarl|bv|corp|co)\b\.?/g, '')
203
+ .replace(/[^a-z0-9]/g, '')
204
+ }
205
+
206
+ /**
207
+ * Run all (or a subset of) hygiene checks over pre-fetched account data.
208
+ * @param {{ deals: object[], persons: object[], organizations: object[], activities: object[] }} data
209
+ * @param {{ now: Date, only?: string[] }} options
210
+ * @returns {{ name: string, severity: string, title: string, count: number, items: object[] }[]}
211
+ */
212
+ export function runChecks(data, { now, only } = {}) {
213
+ return AUDIT_CHECKS.filter((check) => !only || only.includes(check.name)).map(
214
+ (check) => {
215
+ const overdueTotal = check.name === 'overdue-activities'
216
+ const items = check.run(data, { now })
217
+ return {
218
+ name: check.name,
219
+ severity: check.severity,
220
+ title: check.title,
221
+ count: overdueTotal
222
+ ? items.reduce((sum, i) => sum + i.overdue, 0)
223
+ : items.length,
224
+ items,
225
+ }
226
+ },
227
+ )
228
+ }
@@ -0,0 +1,106 @@
1
+ import createDebug from 'debug'
2
+ import { CliError } from './errors.js'
3
+
4
+ const debug = createDebug('pd:bulk')
5
+
6
+ /**
7
+ * Resolve the target ids for a bulk operation from exactly one selector:
8
+ * --ids "1,2,3", a Pipedrive saved filter (--filter <id>), or piped stdin
9
+ * (newline-separated ids, a JSON array of ids, or JSON objects with an id).
10
+ * @param {object} selectors
11
+ * @param {string} [selectors.ids]
12
+ * @param {number} [selectors.filter]
13
+ * @param {NodeJS.ReadStream} [selectors.stdin] defaults to process.stdin
14
+ * @param {ReturnType<import('./client.js').createClient>} client
15
+ * @param {string} listPath v2 list endpoint supporting filter_id (e.g. /api/v2/deals)
16
+ * @returns {Promise<number[]>}
17
+ */
18
+ export async function resolveTargets(
19
+ { ids, filter, stdin = process.stdin },
20
+ client,
21
+ listPath,
22
+ ) {
23
+ if (ids) {
24
+ return ids.split(',').map(parseId)
25
+ }
26
+
27
+ if (filter != null) {
28
+ debug('resolving targets from filter %d', filter)
29
+ const targets = []
30
+ for await (const item of client.pageV2(listPath, {
31
+ filter_id: filter,
32
+ limit: 500,
33
+ })) {
34
+ targets.push(item.id)
35
+ }
36
+ return targets
37
+ }
38
+
39
+ if (!stdin.isTTY) {
40
+ const chunks = []
41
+ for await (const chunk of stdin) chunks.push(chunk)
42
+ const text = Buffer.concat(chunks).toString('utf8').trim()
43
+ if (text.startsWith('[')) {
44
+ const parsed = JSON.parse(text)
45
+ return parsed.map((entry) =>
46
+ typeof entry === 'object' ? entry.id : parseId(String(entry)),
47
+ )
48
+ }
49
+ return text.split('\n').map(parseId)
50
+ }
51
+
52
+ throw new CliError(
53
+ 'No targets — pass --ids, --filter, or pipe ids on stdin',
54
+ { exitCode: 64 },
55
+ )
56
+ }
57
+
58
+ function parseId(raw) {
59
+ const id = Number(raw.trim())
60
+ if (!Number.isInteger(id)) {
61
+ throw new CliError(`Invalid id "${raw.trim()}" — expected an integer`, {
62
+ exitCode: 64,
63
+ })
64
+ }
65
+ return id
66
+ }
67
+
68
+ function sleep(ms) {
69
+ return new Promise((resolve) => setTimeout(resolve, ms))
70
+ }
71
+
72
+ /**
73
+ * Run an async operation per item, sequentially with a pacing gap so bulk
74
+ * writes stay inside Pipedrive's 2-second burst window (writes cost 10
75
+ * tokens each; the client's 429 backoff covers anything that slips through).
76
+ * Per-item failures are collected, never thrown.
77
+ * @template T
78
+ * @param {T[]} items
79
+ * @param {(item: T) => Promise<unknown>} operation
80
+ * @param {object} [options]
81
+ * @param {number} [options.gapMs] delay between requests (default 200)
82
+ * @param {(done: number, total: number) => void} [options.onProgress]
83
+ * @returns {Promise<{ succeeded: { item: T, result: unknown }[], failed: { item: T, error: string }[] }>}
84
+ */
85
+ export async function bulkRun(
86
+ items,
87
+ operation,
88
+ { gapMs = 200, onProgress } = {},
89
+ ) {
90
+ const succeeded = []
91
+ const failed = []
92
+
93
+ for (const [index, item] of items.entries()) {
94
+ if (index > 0 && gapMs > 0) await sleep(gapMs)
95
+ try {
96
+ const result = await operation(item)
97
+ succeeded.push({ item, result })
98
+ } catch (err) {
99
+ debug('bulk item %o failed: %s', item, err.message)
100
+ failed.push({ item, error: err.message })
101
+ }
102
+ onProgress?.(index + 1, items.length)
103
+ }
104
+
105
+ return { succeeded, failed }
106
+ }
@@ -0,0 +1,88 @@
1
+ import { CliError } from './errors.js'
2
+
3
+ /**
4
+ * Minimal RFC 4180 CSV parser (quoted fields, escaped quotes, embedded
5
+ * commas/newlines, CRLF). First record is the header row.
6
+ * @param {string} text
7
+ * @returns {{ headers: string[], rows: string[][] }}
8
+ */
9
+ export function parseCsv(text) {
10
+ const records = []
11
+ let record = []
12
+ let field = ''
13
+ let inQuotes = false
14
+ let i = 0
15
+
16
+ while (i < text.length) {
17
+ const char = text[i]
18
+
19
+ if (inQuotes) {
20
+ if (char === '"') {
21
+ if (text[i + 1] === '"') {
22
+ field += '"'
23
+ i += 2
24
+ continue
25
+ }
26
+ inQuotes = false
27
+ i++
28
+ continue
29
+ }
30
+ field += char
31
+ i++
32
+ continue
33
+ }
34
+
35
+ if (char === '"') {
36
+ inQuotes = true
37
+ i++
38
+ continue
39
+ }
40
+ if (char === ',') {
41
+ record.push(field)
42
+ field = ''
43
+ i++
44
+ continue
45
+ }
46
+ if (char === '\n' || char === '\r') {
47
+ if (char === '\r' && text[i + 1] === '\n') i++
48
+ record.push(field)
49
+ field = ''
50
+ if (record.length > 1 || record[0] !== '') records.push(record)
51
+ record = []
52
+ i++
53
+ continue
54
+ }
55
+ field += char
56
+ i++
57
+ }
58
+
59
+ if (inQuotes) {
60
+ throw new CliError('Unterminated quoted field in CSV', { exitCode: 65 })
61
+ }
62
+ if (field !== '' || record.length > 0) {
63
+ // A tail record only reaches here when non-empty (a bare trailing
64
+ // newline never starts a record), so push unconditionally.
65
+ record.push(field)
66
+ records.push(record)
67
+ }
68
+
69
+ if (records.length === 0) {
70
+ throw new CliError('CSV file is empty', { exitCode: 65 })
71
+ }
72
+
73
+ const [headers, ...rows] = records
74
+
75
+ return {
76
+ headers,
77
+ rows: rows.map((row, index) => {
78
+ if (row.length > headers.length) {
79
+ throw new CliError(
80
+ `CSV row ${index + 2} has ${row.length} cells but the header has ${headers.length}`,
81
+ { exitCode: 65 },
82
+ )
83
+ }
84
+ while (row.length < headers.length) row.push('')
85
+ return row
86
+ }),
87
+ }
88
+ }
@@ -0,0 +1,49 @@
1
+ import { buildWriteBody } from './input.js'
2
+ import { CliError } from './errors.js'
3
+
4
+ /**
5
+ * Turn parsed CSV rows into write bodies. Special columns (matched
6
+ * case-insensitively) build typed values; every other header resolves
7
+ * through the entity's field definitions — names, hash keys, and option
8
+ * labels included. Empty cells are skipped.
9
+ * @param {object} options
10
+ * @param {string[]} options.headers
11
+ * @param {string[][]} options.rows
12
+ * @param {Record<string, (typed: object, value: string) => void>} [options.specialColumns]
13
+ * @param {object[]} [options.defs] field definitions for non-special headers
14
+ * @returns {object[]} one request body per row
15
+ */
16
+ export function prepareImportBodies({
17
+ headers,
18
+ rows,
19
+ specialColumns = {},
20
+ defs,
21
+ }) {
22
+ const specials = Object.fromEntries(
23
+ Object.entries(specialColumns).map(([k, v]) => [k.toLowerCase(), v]),
24
+ )
25
+
26
+ return rows.map((row, index) => {
27
+ const typed = {}
28
+ const fields = []
29
+
30
+ headers.forEach((header, i) => {
31
+ const value = row[i]
32
+ if (value === '') return
33
+ const special = specials[header.toLowerCase()]
34
+ if (special) {
35
+ special(typed, value)
36
+ return
37
+ }
38
+ fields.push(`${header}=${value}`)
39
+ })
40
+
41
+ try {
42
+ return buildWriteBody({ typed, fields, defs })
43
+ } catch (err) {
44
+ throw new CliError(`CSV row ${index + 2}: ${err.message}`, {
45
+ exitCode: 65,
46
+ })
47
+ }
48
+ })
49
+ }