batch_identify_to_cio.mjs 13 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465
  1. #!/usr/bin/env node
  2. /**
  3. * This script reads a CSV file (output from migrate_mailchimp_to_cio.mjs or
  4. * export_active_subscription_users_csv.mjs) and
  5. * makes batch identify requests to Customer.io using the CDP Analytics node library.
  6. *
  7. * Usage:
  8. * node scripts/batch_identify_to_cio.mjs --input INPUT-FILE [OPTIONS]
  9. *
  10. * Example:
  11. * node scripts/batch_identify_to_cio.mjs --input /tmp/customerio_import.csv
  12. * CUSTOMER_IO_API_KEY=xxx node scripts/batch_identify_to_cio.mjs --input /tmp/customerio_import.csv --commit
  13. *
  14. * Resuming after failure:
  15. * CUSTOMER_IO_API_KEY=xxx node scripts/batch_identify_to_cio.mjs --input /tmp/customerio_import.csv --commit --skip 50000
  16. *
  17. * Options:
  18. * --input, -i PATH Input CSV file (from migrate_mailchimp_to_cio.mjs) (required)
  19. * --commit Actually send to Customer.io (default is dry-run)
  20. * --skip N Skip the first N rows (for resuming after failure)
  21. * --batch-size N Maximum items per batch (default: 1000)
  22. * --help, -h Show this help message
  23. *
  24. * Environment Variables:
  25. * CUSTOMER_IO_API_KEY Customer.io CDP API key (required when using --commit)
  26. *
  27. * CSV Input Format (from migrate_mailchimp_to_cio.mjs):
  28. * - email: Subscriber email address
  29. * - overleafId: Overleaf user ID (from mongo_id)
  30. * - created_at: Unix timestamp
  31. * - cio_subscription_preferences.topics.<topic_id>: 'true' if subscribed
  32. * - labsExperiments: JSON array of experiment names
  33. *
  34. * CSV Input Format (from export_active_subscription_users_csv.mjs):
  35. * - user_id: Overleaf user ID
  36. * - email: Subscriber email address
  37. * - plan_type, display_plan_type, pre_migration_plan_type,
  38. * pre_migration_display_plan_type, plan_term, ai_plan, ai_plan_term,
  39. * next_renewal_date, expiry_date, group_ai_enabled, group_role
  40. *
  41. * Rate Limiting:
  42. * - Max 3000 requests per 3 seconds (Customer.io limit)
  43. * - Progress is logged every 10000 rows for recovery purposes
  44. *
  45. * Notes:
  46. * - Users are identified by email address (used as userId)
  47. * - The library handles batching internally via maxEventsInBatch
  48. */
  49. import fs, { createReadStream } from 'node:fs'
  50. import * as csv from 'csv'
  51. import minimist from 'minimist'
  52. import { Analytics } from '@customerio/cdp-analytics-node'
  53. import { scriptRunner } from './lib/ScriptRunner.mjs'
  54. const DEFAULT_BATCH_SIZE = 1000
  55. const RATE_LIMIT_REQUESTS = 3000
  56. const RATE_LIMIT_WINDOW_MS = 3000
  57. const PROGRESS_LOG_INTERVAL = 10000
  58. function usage() {
  59. console.error(`Usage: node scripts/batch_identify_to_cio.mjs --input INPUT-FILE [OPTIONS]
  60. Options:
  61. --input, -i PATH Input CSV file (from migrate_mailchimp_to_cio.mjs) (required)
  62. --commit Actually send to Customer.io (default is dry-run)
  63. --skip N Skip the first N rows (for resuming after failure)
  64. --batch-size N Maximum items per batch (default: ${DEFAULT_BATCH_SIZE})
  65. --help, -h Show this help message
  66. Environment Variables:
  67. CUSTOMER_IO_API_KEY Customer.io CDP API key (required when using --commit)
  68. `)
  69. process.exit(1)
  70. }
  71. /**
  72. * Simple rate limiter that enforces max requests per time window
  73. */
  74. class RateLimiter {
  75. constructor(maxRequests, windowMs) {
  76. this.maxRequests = maxRequests
  77. this.windowMs = windowMs
  78. this.requests = []
  79. }
  80. async waitIfNeeded() {
  81. const now = Date.now()
  82. // Remove requests outside the current window
  83. this.requests = this.requests.filter(t => now - t < this.windowMs)
  84. if (this.requests.length >= this.maxRequests) {
  85. // Wait until the oldest request falls outside the window
  86. const oldestRequest = this.requests[0]
  87. const waitTime = this.windowMs - (now - oldestRequest) + 10 // +10ms buffer
  88. await new Promise(resolve => setTimeout(resolve, waitTime))
  89. // Clean up again after waiting
  90. this.requests = this.requests.filter(t => Date.now() - t < this.windowMs)
  91. }
  92. this.requests.push(Date.now())
  93. }
  94. }
  95. /**
  96. * Create a Customer.io Analytics client
  97. */
  98. function createCioClient(batchSize) {
  99. const apiKey = process.env.CUSTOMER_IO_API_KEY
  100. if (!apiKey) {
  101. throw new Error(
  102. 'CUSTOMER_IO_API_KEY environment variable is required. ' +
  103. 'Set it to your Customer.io CDP API key.'
  104. )
  105. }
  106. return new Analytics({
  107. writeKey: apiKey,
  108. host: 'https://cdp.customer.io',
  109. maxEventsInBatch: batchSize,
  110. })
  111. }
  112. /**
  113. * Convert a CSV row to a Customer.io identify payload
  114. */
  115. function parseOptionalBoolean(value) {
  116. if (value == null || value === '') {
  117. return undefined
  118. }
  119. const normalized = String(value).trim().toLowerCase()
  120. if (normalized === 'true') {
  121. return true
  122. }
  123. if (normalized === 'false') {
  124. return false
  125. }
  126. return undefined
  127. }
  128. function parseOptionalInt(value) {
  129. if (value == null || value === '') {
  130. return undefined
  131. }
  132. const parsed = parseInt(value, 10)
  133. return Number.isNaN(parsed) ? undefined : parsed
  134. }
  135. function getFirstDefinedValue(row, columnNames) {
  136. for (const columnName of columnNames) {
  137. if (Object.prototype.hasOwnProperty.call(row, columnName)) {
  138. return row[columnName]
  139. }
  140. }
  141. return undefined
  142. }
  143. function rowToIdentifyPayload(row) {
  144. const email = getFirstDefinedValue(row, ['email'])
  145. if (!email) {
  146. return null
  147. }
  148. const traits = {}
  149. const overleafUserId = getFirstDefinedValue(row, [
  150. 'user_id',
  151. 'userId',
  152. 'overleafId',
  153. ])
  154. if (email) {
  155. traits.email = email
  156. }
  157. if (overleafUserId) {
  158. traits.overleaf_id = overleafUserId
  159. }
  160. // Add created_at if present (keep as unix timestamp)
  161. const createdAtValue = getFirstDefinedValue(row, ['created_at'])
  162. if (createdAtValue) {
  163. const createdAt = parseOptionalInt(createdAtValue)
  164. if (createdAt !== undefined) {
  165. traits.created_at = createdAt
  166. }
  167. }
  168. // Add subscription status fields when present (from export_active_subscription_users_csv.mjs)
  169. const stringTraitMappings = [
  170. {
  171. columnNames: ['plan_type', 'planType'],
  172. traitName: 'plan_type',
  173. },
  174. {
  175. columnNames: ['display_plan_type', 'displayPlanType'],
  176. traitName: 'display_plan_type',
  177. },
  178. {
  179. columnNames: ['pre_migration_plan_type', 'preMigrationPlanType'],
  180. traitName: 'pre_migration_plan_type',
  181. },
  182. {
  183. columnNames: [
  184. 'pre_migration_display_plan_type',
  185. 'preMigrationDisplayPlanType',
  186. ],
  187. traitName: 'pre_migration_display_plan_type',
  188. },
  189. {
  190. columnNames: ['plan_term', 'planTerm', 'plan_term_label'],
  191. traitName: 'plan_term',
  192. },
  193. {
  194. columnNames: ['ai_plan', 'aiPlan'],
  195. traitName: 'ai_plan',
  196. },
  197. {
  198. columnNames: ['ai_plan_term', 'aiPlanTerm', 'ai_plan_term_label'],
  199. traitName: 'ai_plan_term',
  200. },
  201. {
  202. columnNames: ['group_role', 'groupRole'],
  203. traitName: 'group_role',
  204. },
  205. ]
  206. for (const { columnNames, traitName } of stringTraitMappings) {
  207. const value = getFirstDefinedValue(row, columnNames)
  208. if (value) {
  209. traits[traitName] = value
  210. }
  211. }
  212. const nextRenewalDateValue = getFirstDefinedValue(row, [
  213. 'next_renewal_date',
  214. 'nextRenewalDate',
  215. ])
  216. if (nextRenewalDateValue !== undefined) {
  217. if (nextRenewalDateValue === '') {
  218. traits.next_renewal_date = ''
  219. } else {
  220. const nextRenewalDate = parseOptionalInt(nextRenewalDateValue)
  221. if (nextRenewalDate !== undefined) {
  222. traits.next_renewal_date = nextRenewalDate
  223. }
  224. }
  225. }
  226. const expiryDateValue = getFirstDefinedValue(row, [
  227. 'expiry_date',
  228. 'expiryDate',
  229. ])
  230. if (expiryDateValue !== undefined) {
  231. if (expiryDateValue === '') {
  232. traits.expiry_date = ''
  233. } else {
  234. const expiryDate = parseOptionalInt(expiryDateValue)
  235. if (expiryDate !== undefined) {
  236. traits.expiry_date = expiryDate
  237. }
  238. }
  239. }
  240. const groupAiEnabledValue = getFirstDefinedValue(row, [
  241. 'group_ai_enabled',
  242. 'groupAIEnabled',
  243. ])
  244. if (groupAiEnabledValue !== undefined) {
  245. const groupAiEnabled = parseOptionalBoolean(groupAiEnabledValue)
  246. if (groupAiEnabled !== undefined) {
  247. traits.group_ai_enabled = groupAiEnabled
  248. }
  249. }
  250. // Add subscription preferences
  251. for (const key of Object.keys(row)) {
  252. if (key.startsWith('cio_subscription_preferences.topics.')) {
  253. if (row[key] === 'true') {
  254. traits[key] = true
  255. }
  256. }
  257. }
  258. // Add labsExperiments if present
  259. if (row.labsExperiments) {
  260. try {
  261. traits.labsExperiments = JSON.parse(row.labsExperiments)
  262. } catch {
  263. // If it's not valid JSON, store as-is
  264. traits.labsExperiments = row.labsExperiments
  265. }
  266. }
  267. return {
  268. // Prefer stable Overleaf user id when available, otherwise fall back to email
  269. userId: overleafUserId || email,
  270. email,
  271. traits,
  272. }
  273. }
  274. /**
  275. * Create a CSV parser stream
  276. */
  277. function createCsvParser(inputPath) {
  278. return createReadStream(inputPath).pipe(
  279. csv.parse({
  280. columns: true,
  281. skip_empty_lines: true,
  282. relax_column_count: true,
  283. })
  284. )
  285. }
  286. /**
  287. * Main script function
  288. */
  289. function main() {
  290. const argv = minimist(process.argv.slice(2), {
  291. string: ['input', 'batch-size', 'skip'],
  292. boolean: ['commit', 'help'],
  293. alias: {
  294. i: 'input',
  295. h: 'help',
  296. },
  297. })
  298. if (argv.help) {
  299. usage()
  300. }
  301. const inputPath = argv.input
  302. const commit = argv.commit
  303. const dryRun = !commit
  304. const batchSize = parseInt(argv['batch-size'], 10) || DEFAULT_BATCH_SIZE
  305. const skipRows = parseInt(argv.skip, 10) || 0
  306. if (!inputPath) {
  307. console.error('Error: --input is required')
  308. usage()
  309. }
  310. if (!fs.existsSync(inputPath)) {
  311. console.error(`Error: Input file not found: ${inputPath}`)
  312. process.exit(1)
  313. }
  314. if (commit && !process.env.CUSTOMER_IO_API_KEY) {
  315. console.error(
  316. 'Error: CUSTOMER_IO_API_KEY environment variable is required when using --commit'
  317. )
  318. process.exit(1)
  319. }
  320. scriptRunner(
  321. async trackProgress => {
  322. await trackProgress('Starting batch identify to Customer.io...')
  323. await trackProgress(`Input: ${inputPath}`)
  324. await trackProgress(`Batch size limit: ${batchSize}`)
  325. if (skipRows > 0) {
  326. await trackProgress(`Skipping first ${skipRows} rows`)
  327. }
  328. if (dryRun) {
  329. await trackProgress('DRY RUN MODE - no requests will be sent')
  330. }
  331. const rateLimiter = new RateLimiter(
  332. RATE_LIMIT_REQUESTS,
  333. RATE_LIMIT_WINDOW_MS
  334. )
  335. const client = dryRun ? null : createCioClient(batchSize)
  336. // Listen to the 'error' event
  337. if (!dryRun) {
  338. client.on('error', err => {
  339. console.error('cdp-analytics-node error occurred:')
  340. console.error('Code:', err.code)
  341. console.error('Reason:', err.reason)
  342. if (err.ctx) {
  343. console.error('Context:', err.ctx)
  344. }
  345. })
  346. }
  347. let rowNumber = 0
  348. let processedCount = 0
  349. let skippedCount = 0
  350. let lastProgressLog = 0
  351. let loggedFirstIdentifyPayload = false
  352. const parser = createCsvParser(inputPath)
  353. try {
  354. for await (const row of parser) {
  355. rowNumber++
  356. // Skip rows if resuming
  357. if (rowNumber <= skipRows) {
  358. continue
  359. }
  360. const payload = rowToIdentifyPayload(row)
  361. if (!payload) {
  362. skippedCount++
  363. continue
  364. }
  365. if (dryRun && !loggedFirstIdentifyPayload) {
  366. await trackProgress(
  367. `First identify payload (dry run): ${JSON.stringify(payload)}`
  368. )
  369. loggedFirstIdentifyPayload = true
  370. }
  371. if (!dryRun) {
  372. await rateLimiter.waitIfNeeded()
  373. client.identify(payload)
  374. }
  375. processedCount++
  376. // Log progress periodically
  377. if (processedCount - lastProgressLog >= PROGRESS_LOG_INTERVAL) {
  378. await trackProgress(
  379. `Progress: row ${rowNumber}, sent ${processedCount} requests (${skippedCount} skipped)`
  380. )
  381. lastProgressLog = processedCount
  382. }
  383. }
  384. } catch (error) {
  385. await trackProgress(
  386. `ERROR at row ${rowNumber}: ${error.message}. Resume with --skip ${rowNumber - 1}`
  387. )
  388. throw error
  389. }
  390. if (!dryRun && client) {
  391. await trackProgress('Flushing remaining requests...')
  392. await client.closeAndFlush()
  393. }
  394. await trackProgress(
  395. `Completed: processed ${processedCount} rows (${skippedCount} skipped due to missing email)`
  396. )
  397. if (dryRun) {
  398. await trackProgress(
  399. `DRY RUN complete - would have sent ${processedCount} identify requests`
  400. )
  401. }
  402. process.exit(0)
  403. },
  404. { inputPath, dryRun, batchSize, skipRows }
  405. ).catch(err => {
  406. console.error(err)
  407. process.exit(1)
  408. })
  409. }
  410. main()