export_active_subscription_users_csv.mjs 21 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789
  1. import fs from 'node:fs'
  2. import { Parser as CSVParser } from 'json2csv'
  3. import minimist from 'minimist'
  4. import pLimit from 'p-limit'
  5. import Settings from '@overleaf/settings'
  6. import { scriptRunner } from './lib/ScriptRunner.mjs'
  7. import {
  8. db,
  9. ObjectId,
  10. READ_PREFERENCE_SECONDARY,
  11. } from '../app/src/infrastructure/mongodb.mjs'
  12. import PaymentService from '../modules/subscriptions/app/src/PaymentService.mjs'
  13. import FeaturesHelper from '../app/src/Features/Subscription/FeaturesHelper.mjs'
  14. import CustomerIoPlanHelpers from '../app/src/Features/Subscription/CustomerIoPlanHelpers.mjs'
  15. import { isStandaloneAiAddOnPlanCode } from '../app/src/Features/Subscription/AiHelper.mjs'
  16. import InstitutionsGetter from '../app/src/Features/Institutions/InstitutionsGetter.mjs'
  17. const CSV_FIELDS = [
  18. { label: 'user_id', value: 'userId' },
  19. { label: 'email', value: 'email' },
  20. { label: 'plan_type', value: 'planType' },
  21. { label: 'display_plan_type', value: 'displayPlanType' },
  22. {
  23. label: 'pre_migration_plan_type',
  24. value: 'preMigrationPlanType',
  25. },
  26. {
  27. label: 'pre_migration_display_plan_type',
  28. value: 'preMigrationDisplayPlanType',
  29. },
  30. { label: 'plan_term', value: 'planTerm' },
  31. { label: 'ai_plan', value: 'aiPlan' },
  32. { label: 'ai_plan_term', value: 'aiPlanTerm' },
  33. { label: 'next_renewal_date', value: 'nextRenewalDate' },
  34. { label: 'expiry_date', value: 'expiryDate' },
  35. { label: 'group_ai_enabled', value: 'groupAIEnabled' },
  36. { label: 'group_role', value: 'groupRole' },
  37. ]
  38. const CSV_FIELD_NAMES = CSV_FIELDS.map(field => field.value)
  39. const ACTIVE_SUBSCRIPTION_STATES = ['active', 'trialing']
  40. const COMMONS_PLAN = getPlan(Settings.institutionPlanCode)
  41. function usage() {
  42. console.log(`
  43. Usage:
  44. node scripts/export_active_subscription_users_csv.mjs [options]
  45. Options:
  46. --outputPath <path> Output CSV path (default: /tmp/active_subscription_users.csv)
  47. --concurrency <number> Concurrent payment-provider lookups (default: 5)
  48. --batchSize <number> Number of users processed per batch (default: 500)
  49. --resumeAfterUserId <id> Resume processing strictly after this user id
  50. --checkpointInterval <n> Log resumable checkpoint every N processed users (default: 10000)
  51. --append Append to existing output file (for resume runs)
  52. --help Show this message
  53. `)
  54. }
  55. function parseArgs() {
  56. const args = minimist(process.argv.slice(2), {
  57. string: ['outputPath', 'resumeAfterUserId'],
  58. boolean: ['help', 'append'],
  59. default: {
  60. outputPath: '/tmp/active_subscription_users.csv',
  61. concurrency: 5,
  62. batchSize: 500,
  63. checkpointInterval: 10000,
  64. append: false,
  65. help: false,
  66. },
  67. })
  68. if (args.help) {
  69. usage()
  70. process.exit(0)
  71. }
  72. return args
  73. }
  74. function getPlan(planCode) {
  75. return Settings.plans.find(plan => plan.planCode === planCode) || null
  76. }
  77. function getPlanType(subscription) {
  78. if (isStandaloneAiAddOnPlanCode(subscription.planCode)) {
  79. return 'standalone-ai-add-on'
  80. }
  81. return subscription.groupPlan ? 'group' : 'individual'
  82. }
  83. function getPlanCadence(subscription, plan) {
  84. if (plan != null) {
  85. return plan.annual ? 'annual' : 'monthly'
  86. }
  87. if (isStandaloneAiAddOnPlanCode(subscription.planCode)) {
  88. return subscription.planCode.includes('annual') ? 'annual' : 'monthly'
  89. }
  90. return ''
  91. }
  92. function userHasPremiumAiFeatures(user) {
  93. return (
  94. user?.features?.aiErrorAssistant === true ||
  95. user?.features?.aiUsageQuota === Settings.aiFeatures.unlimitedQuota
  96. )
  97. }
  98. async function userHasCurrentInstitutionLicence(userId, commonsCache) {
  99. if (commonsCache.has(userId)) {
  100. return commonsCache.get(userId)
  101. }
  102. try {
  103. const institutions =
  104. await InstitutionsGetter.promises.getCurrentInstitutionsWithLicence(
  105. userId
  106. )
  107. const hasCommons = Boolean(institutions?.length)
  108. commonsCache.set(userId, hasCommons)
  109. return hasCommons
  110. } catch (error) {
  111. console.warn(
  112. `Failed to evaluate commons licence for user ${userId}: ${error.message}`
  113. )
  114. commonsCache.set(userId, false)
  115. return false
  116. }
  117. }
  118. function isMemberOfGroupSubscription(candidates) {
  119. return candidates.some(candidate => candidate.subscription?.groupPlan)
  120. }
  121. function getAiPlanForUser({
  122. bestSubscription,
  123. planType,
  124. individualSubscription,
  125. paymentRecord,
  126. user,
  127. userIsMemberOfGroupSubscription,
  128. userHasActiveOverleafSubscription,
  129. }) {
  130. const baseAiPlan = CustomerIoPlanHelpers.getAiPlanType(
  131. bestSubscription,
  132. individualSubscription,
  133. paymentRecord,
  134. user?.writefull,
  135. userIsMemberOfGroupSubscription
  136. )
  137. if (baseAiPlan !== 'none') {
  138. return baseAiPlan
  139. }
  140. if (!userHasActiveOverleafSubscription && userHasPremiumAiFeatures(user)) {
  141. return 'ai-assist'
  142. }
  143. return 'none'
  144. }
  145. function getPaymentState(subscription) {
  146. if (subscription?.recurlyStatus?.state) {
  147. return subscription.recurlyStatus.state
  148. }
  149. if (subscription?.paymentProvider?.state) {
  150. return subscription.paymentProvider.state
  151. }
  152. return null
  153. }
  154. function isActivePaidSubscription(subscription) {
  155. const hasRecurlySubscription = Boolean(subscription?.recurlySubscription_id)
  156. const hasStripeSubscription = Boolean(
  157. subscription?.paymentProvider?.subscriptionId
  158. )
  159. if (!hasRecurlySubscription && !hasStripeSubscription) {
  160. return false
  161. }
  162. return ACTIVE_SUBSCRIPTION_STATES.includes(getPaymentState(subscription))
  163. }
  164. function getGroupAiEnabled(candidates) {
  165. const groupCandidates = candidates.filter(
  166. candidate => getPlanType(candidate.subscription) === 'group'
  167. )
  168. if (groupCandidates.length === 0) {
  169. return ''
  170. }
  171. return groupCandidates.some(candidate =>
  172. CustomerIoPlanHelpers.hasPlanAiEnabled(candidate.plan)
  173. )
  174. }
  175. function getGroupRole(candidates, userId) {
  176. const groupCandidates = candidates.filter(
  177. candidate => candidate.subscription?.groupPlan
  178. )
  179. if (groupCandidates.length === 0) {
  180. return ''
  181. }
  182. const isGroupAdminOrManager = groupCandidates.some(candidate => {
  183. const subscription = candidate.subscription
  184. const adminId = subscription?.admin_id?.toString()
  185. const managerIds = (subscription?.manager_ids || []).map(id =>
  186. id?.toString()
  187. )
  188. return adminId === userId || managerIds.includes(userId)
  189. })
  190. return isGroupAdminOrManager ? 'admin' : 'member'
  191. }
  192. function chooseBestCandidate(candidates) {
  193. let best = null
  194. for (const candidate of candidates) {
  195. if (best == null) {
  196. best = candidate
  197. continue
  198. }
  199. const candidateType = getPlanType(candidate.subscription)
  200. const bestType = getPlanType(best.subscription)
  201. if (candidateType === 'standalone-ai-add-on' && bestType !== 'free') {
  202. continue
  203. }
  204. if (
  205. candidateType !== 'standalone-ai-add-on' &&
  206. bestType === 'standalone-ai-add-on'
  207. ) {
  208. best = candidate
  209. continue
  210. }
  211. if (
  212. FeaturesHelper.isFeatureSetBetter(
  213. candidate.plan?.features || {},
  214. best.plan?.features || {}
  215. )
  216. ) {
  217. best = candidate
  218. }
  219. }
  220. return best
  221. }
  222. function getSubscriptionQuery() {
  223. return {
  224. $or: [
  225. {
  226. recurlySubscription_id: { $exists: true, $nin: ['', null] },
  227. 'recurlyStatus.state': { $in: ACTIVE_SUBSCRIPTION_STATES },
  228. },
  229. {
  230. 'paymentProvider.subscriptionId': { $exists: true, $nin: ['', null] },
  231. 'paymentProvider.state': { $in: ACTIVE_SUBSCRIPTION_STATES },
  232. },
  233. ],
  234. }
  235. }
  236. function getSubscriptionProjection() {
  237. return {
  238. _id: 1,
  239. admin_id: 1,
  240. manager_ids: 1,
  241. member_ids: 1,
  242. planCode: 1,
  243. groupPlan: 1,
  244. recurlySubscription_id: 1,
  245. recurlyStatus: 1,
  246. paymentProvider: 1,
  247. addOns: 1,
  248. }
  249. }
  250. function getSupplementaryUserQuery() {
  251. return {
  252. $or: [
  253. { 'writefull.isPremium': true },
  254. { 'features.aiErrorAssistant': true },
  255. { 'features.aiUsageQuota': Settings.aiFeatures.unlimitedQuota },
  256. {
  257. emails: {
  258. $elemMatch: {
  259. confirmedAt: { $exists: true },
  260. 'affiliation.institution.confirmed': true,
  261. 'affiliation.licence': { $exists: true, $ne: 'free' },
  262. 'affiliation.pastReconfirmDate': { $ne: true },
  263. },
  264. },
  265. },
  266. ],
  267. }
  268. }
  269. function getTargetUserIdsPipeline(resumeAfterUserId) {
  270. const pipeline = [
  271. { $match: getSubscriptionQuery() },
  272. {
  273. $project: {
  274. participantIds: {
  275. $setUnion: [
  276. [{ $ifNull: ['$admin_id', null] }],
  277. { $ifNull: ['$manager_ids', []] },
  278. { $ifNull: ['$member_ids', []] },
  279. ],
  280. },
  281. },
  282. },
  283. { $unwind: '$participantIds' },
  284. { $match: { participantIds: { $ne: null } } },
  285. { $group: { _id: '$participantIds' } },
  286. {
  287. $unionWith: {
  288. coll: 'users',
  289. pipeline: [
  290. { $match: getSupplementaryUserQuery() },
  291. { $project: { _id: 1 } },
  292. ],
  293. },
  294. },
  295. { $group: { _id: '$_id' } },
  296. ]
  297. if (resumeAfterUserId) {
  298. let resumeId = resumeAfterUserId
  299. try {
  300. resumeId = new ObjectId(resumeAfterUserId)
  301. } catch {
  302. // leave as-is for non-ObjectId identifiers
  303. }
  304. pipeline.push({ $match: { _id: { $gt: resumeId } } })
  305. }
  306. pipeline.push({ $sort: { _id: 1 } })
  307. return pipeline
  308. }
  309. function getTargetUserIdsCursor(resumeAfterUserId) {
  310. return db.subscriptions.aggregate(
  311. getTargetUserIdsPipeline(resumeAfterUserId),
  312. {
  313. allowDiskUse: true,
  314. readPreference: READ_PREFERENCE_SECONDARY,
  315. }
  316. )
  317. }
  318. function isInvalidOrMissingSubscriptionError(error) {
  319. const message = String(error?.message || '').toLowerCase()
  320. return (
  321. message.includes('no such subscription') ||
  322. message.includes('invalid subscription') ||
  323. message.includes('subscription not found')
  324. )
  325. }
  326. async function getSubscriptionPaymentInfo(
  327. userId,
  328. subscription,
  329. paymentInfoCache
  330. ) {
  331. const subscriptionId = subscription._id.toString()
  332. if (paymentInfoCache.has(subscriptionId)) {
  333. return {
  334. ...paymentInfoCache.get(subscriptionId),
  335. skip: false,
  336. }
  337. }
  338. try {
  339. const paymentRecord =
  340. await PaymentService.promises.getPaymentFromRecord(subscription)
  341. const nextRenewalDate =
  342. CustomerIoPlanHelpers.getNextRenewalDateFromPaymentRecord(
  343. paymentRecord
  344. ) || ''
  345. const paymentInfo = { nextRenewalDate, paymentRecord }
  346. paymentInfoCache.set(subscriptionId, paymentInfo)
  347. return { ...paymentInfo, skip: false }
  348. } catch (error) {
  349. if (isInvalidOrMissingSubscriptionError(error)) {
  350. console.warn(
  351. `Skipping user ${userId}: invalid/missing payment-provider subscription for subscription ${subscriptionId} (${error.message})`
  352. )
  353. return { nextRenewalDate: '', paymentRecord: null, skip: true }
  354. }
  355. console.error(
  356. `Failed to get renewal date for subscription ${subscriptionId}:`,
  357. error.message
  358. )
  359. const paymentInfo = { nextRenewalDate: '', paymentRecord: null }
  360. paymentInfoCache.set(subscriptionId, paymentInfo)
  361. return { ...paymentInfo, skip: false }
  362. }
  363. }
  364. async function getUsersByIds(userIds) {
  365. const objectIds = []
  366. for (const userId of userIds) {
  367. try {
  368. objectIds.push(new ObjectId(userId))
  369. } catch {
  370. // ignore invalid ObjectId strings
  371. }
  372. }
  373. const idFilters = []
  374. if (objectIds.length > 0) {
  375. idFilters.push({ _id: { $in: objectIds } })
  376. }
  377. if (userIds.length > 0) {
  378. idFilters.push({ _id: { $in: userIds } })
  379. }
  380. if (idFilters.length === 0) {
  381. return new Map()
  382. }
  383. const users = await db.users
  384. .find(idFilters.length === 1 ? idFilters[0] : { $or: idFilters }, {
  385. projection: {
  386. _id: 1,
  387. email: 1,
  388. features: 1,
  389. writefull: 1,
  390. },
  391. readPreference: READ_PREFERENCE_SECONDARY,
  392. })
  393. .toArray()
  394. return new Map(users.map(user => [user._id.toString(), user]))
  395. }
  396. async function getActiveSubscriptionsForUserIds(userIds) {
  397. const objectIds = []
  398. for (const userId of userIds) {
  399. try {
  400. objectIds.push(new ObjectId(userId))
  401. } catch {
  402. // ignore invalid ObjectId strings
  403. }
  404. }
  405. const idValues = objectIds.length > 0 ? objectIds : userIds
  406. return await db.subscriptions
  407. .find(
  408. {
  409. ...getSubscriptionQuery(),
  410. $or: [
  411. { admin_id: { $in: idValues } },
  412. { manager_ids: { $in: idValues } },
  413. { member_ids: { $in: idValues } },
  414. ],
  415. },
  416. {
  417. projection: getSubscriptionProjection(),
  418. readPreference: READ_PREFERENCE_SECONDARY,
  419. }
  420. )
  421. .toArray()
  422. }
  423. function buildUserCandidatesMap(subscriptions, allowedUserIds) {
  424. const userCandidates = new Map()
  425. const allowedUserIdsSet = new Set(allowedUserIds)
  426. function addCandidate(userId, candidate) {
  427. if (!userId || !allowedUserIdsSet.has(userId)) {
  428. return
  429. }
  430. if (!userCandidates.has(userId)) {
  431. userCandidates.set(userId, [])
  432. }
  433. userCandidates.get(userId).push(candidate)
  434. }
  435. for (const subscription of subscriptions) {
  436. const plan = getPlan(subscription.planCode)
  437. const baseCandidate = { subscription, plan }
  438. const adminId = subscription.admin_id?.toString()
  439. addCandidate(adminId, baseCandidate)
  440. if (Array.isArray(subscription.manager_ids)) {
  441. for (const managerIdRaw of subscription.manager_ids) {
  442. addCandidate(managerIdRaw?.toString(), baseCandidate)
  443. }
  444. }
  445. if (subscription.groupPlan && Array.isArray(subscription.member_ids)) {
  446. for (const memberIdRaw of subscription.member_ids) {
  447. addCandidate(memberIdRaw?.toString(), baseCandidate)
  448. }
  449. }
  450. }
  451. return userCandidates
  452. }
  453. function writeCsvRows(writeStream, rows, includeHeader) {
  454. if (rows.length === 0) {
  455. return
  456. }
  457. const csvParser = new CSVParser({
  458. fields: includeHeader ? CSV_FIELDS : CSV_FIELD_NAMES,
  459. header: includeHeader,
  460. eol: '\n',
  461. })
  462. writeStream.write(`${csvParser.parse(rows)}\n`)
  463. }
  464. function writeCsvHeader(writeStream) {
  465. writeStream.write(`${CSV_FIELDS.map(field => field.label).join(',')}\n`)
  466. }
  467. async function processUserBatch({
  468. userIds,
  469. writeStream,
  470. paymentInfoCache,
  471. commonsCache,
  472. limit,
  473. trackProgress,
  474. totalUsersCount,
  475. globalState,
  476. checkpointInterval,
  477. }) {
  478. const usersById = await getUsersByIds(userIds)
  479. const subscriptions = await getActiveSubscriptionsForUserIds(userIds)
  480. const activeSubscriptions = subscriptions.filter(isActivePaidSubscription)
  481. const userCandidates = buildUserCandidatesMap(activeSubscriptions, userIds)
  482. const rows = await Promise.all(
  483. userIds.map(userId =>
  484. limit(async () => {
  485. const user = usersById.get(userId)
  486. if (!user?.email) {
  487. return null
  488. }
  489. const candidates = userCandidates.get(userId) || []
  490. const bestCandidate = chooseBestCandidate(candidates)
  491. const hasCommons = await userHasCurrentInstitutionLicence(
  492. userId,
  493. commonsCache
  494. )
  495. const commonsBeatsBestSubscription =
  496. CustomerIoPlanHelpers.shouldUseCommonsBestSubscription(
  497. hasCommons,
  498. bestCandidate,
  499. COMMONS_PLAN
  500. )
  501. const resolvedSubscription = commonsBeatsBestSubscription
  502. ? null
  503. : bestCandidate?.subscription
  504. const resolvedPlan = commonsBeatsBestSubscription
  505. ? COMMONS_PLAN
  506. : bestCandidate?.plan
  507. const bestSubscriptionForPlanType = commonsBeatsBestSubscription
  508. ? { type: 'commons', plan: COMMONS_PLAN }
  509. : resolvedSubscription
  510. ? {
  511. type: getPlanType(resolvedSubscription),
  512. plan: resolvedPlan,
  513. }
  514. : { type: 'free' }
  515. const planType = CustomerIoPlanHelpers.normalizePlanType(
  516. bestSubscriptionForPlanType
  517. )
  518. const displayPlanType =
  519. CustomerIoPlanHelpers.getFriendlyPlanName(planType) || ''
  520. const planTerm = resolvedSubscription
  521. ? getPlanCadence(resolvedSubscription, resolvedPlan)
  522. : ''
  523. const userIsMemberOfGroupSubscription =
  524. isMemberOfGroupSubscription(candidates)
  525. const userHasActiveOverleafSubscription = bestCandidate != null
  526. let nextRenewalDate = ''
  527. let expiryDate = ''
  528. let paymentRecord = null
  529. if (resolvedSubscription) {
  530. const paymentInfo = await getSubscriptionPaymentInfo(
  531. userId,
  532. resolvedSubscription,
  533. paymentInfoCache
  534. )
  535. if (paymentInfo.skip) {
  536. return null
  537. }
  538. nextRenewalDate = paymentInfo.nextRenewalDate
  539. paymentRecord = paymentInfo.paymentRecord
  540. expiryDate =
  541. CustomerIoPlanHelpers.getExpiryDateFromPaymentRecord(
  542. paymentRecord
  543. ) || ''
  544. }
  545. const aiPlan = getAiPlanForUser({
  546. bestSubscription: bestSubscriptionForPlanType,
  547. planType,
  548. individualSubscription: resolvedSubscription,
  549. paymentRecord,
  550. user,
  551. userIsMemberOfGroupSubscription,
  552. userHasActiveOverleafSubscription,
  553. })
  554. const aiPlanTerm = CustomerIoPlanHelpers.getAiPlanCadence(
  555. aiPlan,
  556. bestSubscriptionForPlanType,
  557. resolvedSubscription,
  558. paymentRecord
  559. )
  560. const groupAIEnabled = getGroupAiEnabled(candidates)
  561. const groupRole = getGroupRole(candidates, userId)
  562. return {
  563. userId,
  564. email: user.email,
  565. planType,
  566. displayPlanType,
  567. preMigrationPlanType: planType,
  568. preMigrationDisplayPlanType: displayPlanType,
  569. planTerm,
  570. aiPlan,
  571. aiPlanTerm: aiPlanTerm || '',
  572. nextRenewalDate,
  573. expiryDate,
  574. groupAIEnabled,
  575. groupRole,
  576. }
  577. })
  578. )
  579. )
  580. const csvRows = []
  581. for (const row of rows) {
  582. globalState.processedCount += 1
  583. if (row) {
  584. csvRows.push(row)
  585. globalState.writtenCount += 1
  586. } else {
  587. globalState.skippedCount += 1
  588. }
  589. }
  590. writeCsvRows(writeStream, csvRows, globalState.shouldWriteHeader)
  591. if (csvRows.length > 0) {
  592. globalState.shouldWriteHeader = false
  593. }
  594. globalState.lastProcessedUserId = userIds[userIds.length - 1]
  595. if (globalState.processedCount % checkpointInterval < userIds.length) {
  596. await trackProgress(
  597. `Checkpoint: processed=${globalState.processedCount}/${totalUsersCount || '?'} written=${globalState.writtenCount} skipped=${globalState.skippedCount} resumeAfterUserId=${globalState.lastProcessedUserId}`
  598. )
  599. }
  600. }
  601. async function main(trackProgress) {
  602. const {
  603. outputPath,
  604. concurrency,
  605. batchSize,
  606. resumeAfterUserId,
  607. checkpointInterval,
  608. append,
  609. } = parseArgs()
  610. await trackProgress(
  611. 'Building target user cursor (subscriptions + supplementary users)'
  612. )
  613. if (resumeAfterUserId) {
  614. await trackProgress(
  615. `Resume enabled: starting after user id ${resumeAfterUserId}`
  616. )
  617. }
  618. const paymentInfoCache = new Map()
  619. const commonsCache = new Map()
  620. const limit = pLimit(Number(concurrency) || 5)
  621. const resolvedBatchSize = Math.max(1, Number(batchSize) || 500)
  622. const resolvedCheckpointInterval = Math.max(
  623. 100,
  624. Number(checkpointInterval) || 10000
  625. )
  626. const outputFileExists = fs.existsSync(outputPath)
  627. const outputFileHasContent =
  628. outputFileExists && fs.statSync(outputPath).size > 0
  629. const writeStream = fs.createWriteStream(outputPath, {
  630. flags: append ? 'a' : 'w',
  631. })
  632. const globalState = {
  633. processedCount: 0,
  634. writtenCount: 0,
  635. skippedCount: 0,
  636. lastProcessedUserId: resumeAfterUserId || null,
  637. shouldWriteHeader: !append || !outputFileHasContent,
  638. }
  639. let pendingUserIds = []
  640. const targetUserIdsCursor = getTargetUserIdsCursor(resumeAfterUserId)
  641. for await (const doc of targetUserIdsCursor) {
  642. pendingUserIds.push(doc._id.toString())
  643. if (pendingUserIds.length >= resolvedBatchSize) {
  644. await processUserBatch({
  645. userIds: pendingUserIds,
  646. writeStream,
  647. paymentInfoCache,
  648. commonsCache,
  649. limit,
  650. trackProgress,
  651. totalUsersCount: null,
  652. globalState,
  653. checkpointInterval: resolvedCheckpointInterval,
  654. })
  655. pendingUserIds = []
  656. }
  657. }
  658. if (pendingUserIds.length > 0) {
  659. await processUserBatch({
  660. userIds: pendingUserIds,
  661. writeStream,
  662. paymentInfoCache,
  663. commonsCache,
  664. limit,
  665. trackProgress,
  666. totalUsersCount: null,
  667. globalState,
  668. checkpointInterval: resolvedCheckpointInterval,
  669. })
  670. }
  671. if (globalState.shouldWriteHeader) {
  672. writeCsvHeader(writeStream)
  673. globalState.shouldWriteHeader = false
  674. }
  675. writeStream.end()
  676. await trackProgress(
  677. `Final checkpoint: processed=${globalState.processedCount} written=${globalState.writtenCount} skipped=${globalState.skippedCount} resumeAfterUserId=${globalState.lastProcessedUserId}`
  678. )
  679. await trackProgress(`CSV generated: ${outputPath}`)
  680. console.log(`✅ Export complete: ${outputPath}`)
  681. console.log(`Rows written: ${globalState.writtenCount}`)
  682. }
  683. try {
  684. await scriptRunner(main)
  685. process.exit(0)
  686. } catch (error) {
  687. console.error(error)
  688. process.exit(1)
  689. }