export_active_subscription_users_csv.mjs 21 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796
  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 isGroupAdmin = groupCandidates.some(
  183. candidate => candidate.subscription?.admin_id?.toString() === userId
  184. )
  185. if (isGroupAdmin) {
  186. return 'admin'
  187. }
  188. const isGroupManager = groupCandidates.some(candidate => {
  189. const managerIds = (candidate.subscription?.manager_ids || []).map(id =>
  190. id?.toString()
  191. )
  192. return managerIds.includes(userId)
  193. })
  194. if (isGroupManager) {
  195. return 'manager'
  196. }
  197. return 'member'
  198. }
  199. function chooseBestCandidate(candidates) {
  200. let best = null
  201. for (const candidate of candidates) {
  202. if (best == null) {
  203. best = candidate
  204. continue
  205. }
  206. const candidateType = getPlanType(candidate.subscription)
  207. const bestType = getPlanType(best.subscription)
  208. if (candidateType === 'standalone-ai-add-on' && bestType !== 'free') {
  209. continue
  210. }
  211. if (
  212. candidateType !== 'standalone-ai-add-on' &&
  213. bestType === 'standalone-ai-add-on'
  214. ) {
  215. best = candidate
  216. continue
  217. }
  218. if (
  219. FeaturesHelper.isFeatureSetBetter(
  220. candidate.plan?.features || {},
  221. best.plan?.features || {}
  222. )
  223. ) {
  224. best = candidate
  225. }
  226. }
  227. return best
  228. }
  229. function getSubscriptionQuery() {
  230. return {
  231. $or: [
  232. {
  233. recurlySubscription_id: { $exists: true, $nin: ['', null] },
  234. 'recurlyStatus.state': { $in: ACTIVE_SUBSCRIPTION_STATES },
  235. },
  236. {
  237. 'paymentProvider.subscriptionId': { $exists: true, $nin: ['', null] },
  238. 'paymentProvider.state': { $in: ACTIVE_SUBSCRIPTION_STATES },
  239. },
  240. ],
  241. }
  242. }
  243. function getSubscriptionProjection() {
  244. return {
  245. _id: 1,
  246. admin_id: 1,
  247. manager_ids: 1,
  248. member_ids: 1,
  249. planCode: 1,
  250. groupPlan: 1,
  251. recurlySubscription_id: 1,
  252. recurlyStatus: 1,
  253. paymentProvider: 1,
  254. addOns: 1,
  255. }
  256. }
  257. function getSupplementaryUserQuery() {
  258. return {
  259. $or: [
  260. { 'writefull.isPremium': true },
  261. { 'features.aiErrorAssistant': true },
  262. { 'features.aiUsageQuota': Settings.aiFeatures.unlimitedQuota },
  263. {
  264. emails: {
  265. $elemMatch: {
  266. confirmedAt: { $exists: true },
  267. 'affiliation.institution.confirmed': true,
  268. 'affiliation.licence': { $exists: true, $ne: 'free' },
  269. 'affiliation.pastReconfirmDate': { $ne: true },
  270. },
  271. },
  272. },
  273. ],
  274. }
  275. }
  276. function getTargetUserIdsPipeline(resumeAfterUserId) {
  277. const pipeline = [
  278. { $match: getSubscriptionQuery() },
  279. {
  280. $project: {
  281. participantIds: {
  282. $setUnion: [
  283. [{ $ifNull: ['$admin_id', null] }],
  284. { $ifNull: ['$manager_ids', []] },
  285. { $ifNull: ['$member_ids', []] },
  286. ],
  287. },
  288. },
  289. },
  290. { $unwind: '$participantIds' },
  291. { $match: { participantIds: { $ne: null } } },
  292. { $group: { _id: '$participantIds' } },
  293. {
  294. $unionWith: {
  295. coll: 'users',
  296. pipeline: [
  297. { $match: getSupplementaryUserQuery() },
  298. { $project: { _id: 1 } },
  299. ],
  300. },
  301. },
  302. { $group: { _id: '$_id' } },
  303. ]
  304. if (resumeAfterUserId) {
  305. let resumeId = resumeAfterUserId
  306. try {
  307. resumeId = new ObjectId(resumeAfterUserId)
  308. } catch {
  309. // leave as-is for non-ObjectId identifiers
  310. }
  311. pipeline.push({ $match: { _id: { $gt: resumeId } } })
  312. }
  313. pipeline.push({ $sort: { _id: 1 } })
  314. return pipeline
  315. }
  316. function getTargetUserIdsCursor(resumeAfterUserId) {
  317. return db.subscriptions.aggregate(
  318. getTargetUserIdsPipeline(resumeAfterUserId),
  319. {
  320. allowDiskUse: true,
  321. readPreference: READ_PREFERENCE_SECONDARY,
  322. }
  323. )
  324. }
  325. function isInvalidOrMissingSubscriptionError(error) {
  326. const message = String(error?.message || '').toLowerCase()
  327. return (
  328. message.includes('no such subscription') ||
  329. message.includes('invalid subscription') ||
  330. message.includes('subscription not found')
  331. )
  332. }
  333. async function getSubscriptionPaymentInfo(
  334. userId,
  335. subscription,
  336. paymentInfoCache
  337. ) {
  338. const subscriptionId = subscription._id.toString()
  339. if (paymentInfoCache.has(subscriptionId)) {
  340. return {
  341. ...paymentInfoCache.get(subscriptionId),
  342. skip: false,
  343. }
  344. }
  345. try {
  346. const paymentRecord =
  347. await PaymentService.promises.getPaymentFromRecord(subscription)
  348. const nextRenewalDate =
  349. CustomerIoPlanHelpers.getNextRenewalDateFromPaymentRecord(
  350. paymentRecord
  351. ) || ''
  352. const paymentInfo = { nextRenewalDate, paymentRecord }
  353. paymentInfoCache.set(subscriptionId, paymentInfo)
  354. return { ...paymentInfo, skip: false }
  355. } catch (error) {
  356. if (isInvalidOrMissingSubscriptionError(error)) {
  357. console.warn(
  358. `Skipping user ${userId}: invalid/missing payment-provider subscription for subscription ${subscriptionId} (${error.message})`
  359. )
  360. return { nextRenewalDate: '', paymentRecord: null, skip: true }
  361. }
  362. console.error(
  363. `Failed to get renewal date for subscription ${subscriptionId}:`,
  364. error.message
  365. )
  366. const paymentInfo = { nextRenewalDate: '', paymentRecord: null }
  367. paymentInfoCache.set(subscriptionId, paymentInfo)
  368. return { ...paymentInfo, skip: false }
  369. }
  370. }
  371. async function getUsersByIds(userIds) {
  372. const objectIds = []
  373. for (const userId of userIds) {
  374. try {
  375. objectIds.push(new ObjectId(userId))
  376. } catch {
  377. // ignore invalid ObjectId strings
  378. }
  379. }
  380. const idFilters = []
  381. if (objectIds.length > 0) {
  382. idFilters.push({ _id: { $in: objectIds } })
  383. }
  384. if (userIds.length > 0) {
  385. idFilters.push({ _id: { $in: userIds } })
  386. }
  387. if (idFilters.length === 0) {
  388. return new Map()
  389. }
  390. const users = await db.users
  391. .find(idFilters.length === 1 ? idFilters[0] : { $or: idFilters }, {
  392. projection: {
  393. _id: 1,
  394. email: 1,
  395. features: 1,
  396. writefull: 1,
  397. },
  398. readPreference: READ_PREFERENCE_SECONDARY,
  399. })
  400. .toArray()
  401. return new Map(users.map(user => [user._id.toString(), user]))
  402. }
  403. async function getActiveSubscriptionsForUserIds(userIds) {
  404. const objectIds = []
  405. for (const userId of userIds) {
  406. try {
  407. objectIds.push(new ObjectId(userId))
  408. } catch {
  409. // ignore invalid ObjectId strings
  410. }
  411. }
  412. const idValues = objectIds.length > 0 ? objectIds : userIds
  413. return await db.subscriptions
  414. .find(
  415. {
  416. ...getSubscriptionQuery(),
  417. $or: [
  418. { admin_id: { $in: idValues } },
  419. { manager_ids: { $in: idValues } },
  420. { member_ids: { $in: idValues } },
  421. ],
  422. },
  423. {
  424. projection: getSubscriptionProjection(),
  425. readPreference: READ_PREFERENCE_SECONDARY,
  426. }
  427. )
  428. .toArray()
  429. }
  430. function buildUserCandidatesMap(subscriptions, allowedUserIds) {
  431. const userCandidates = new Map()
  432. const allowedUserIdsSet = new Set(allowedUserIds)
  433. function addCandidate(userId, candidate) {
  434. if (!userId || !allowedUserIdsSet.has(userId)) {
  435. return
  436. }
  437. if (!userCandidates.has(userId)) {
  438. userCandidates.set(userId, [])
  439. }
  440. userCandidates.get(userId).push(candidate)
  441. }
  442. for (const subscription of subscriptions) {
  443. const plan = getPlan(subscription.planCode)
  444. const baseCandidate = { subscription, plan }
  445. const adminId = subscription.admin_id?.toString()
  446. addCandidate(adminId, baseCandidate)
  447. if (Array.isArray(subscription.manager_ids)) {
  448. for (const managerIdRaw of subscription.manager_ids) {
  449. addCandidate(managerIdRaw?.toString(), baseCandidate)
  450. }
  451. }
  452. if (subscription.groupPlan && Array.isArray(subscription.member_ids)) {
  453. for (const memberIdRaw of subscription.member_ids) {
  454. addCandidate(memberIdRaw?.toString(), baseCandidate)
  455. }
  456. }
  457. }
  458. return userCandidates
  459. }
  460. function writeCsvRows(writeStream, rows, includeHeader) {
  461. if (rows.length === 0) {
  462. return
  463. }
  464. const csvParser = new CSVParser({
  465. fields: includeHeader ? CSV_FIELDS : CSV_FIELD_NAMES,
  466. header: includeHeader,
  467. eol: '\n',
  468. })
  469. writeStream.write(`${csvParser.parse(rows)}\n`)
  470. }
  471. function writeCsvHeader(writeStream) {
  472. writeStream.write(`${CSV_FIELDS.map(field => field.label).join(',')}\n`)
  473. }
  474. async function processUserBatch({
  475. userIds,
  476. writeStream,
  477. paymentInfoCache,
  478. commonsCache,
  479. limit,
  480. trackProgress,
  481. totalUsersCount,
  482. globalState,
  483. checkpointInterval,
  484. }) {
  485. const usersById = await getUsersByIds(userIds)
  486. const subscriptions = await getActiveSubscriptionsForUserIds(userIds)
  487. const activeSubscriptions = subscriptions.filter(isActivePaidSubscription)
  488. const userCandidates = buildUserCandidatesMap(activeSubscriptions, userIds)
  489. const rows = await Promise.all(
  490. userIds.map(userId =>
  491. limit(async () => {
  492. const user = usersById.get(userId)
  493. if (!user?.email) {
  494. return null
  495. }
  496. const candidates = userCandidates.get(userId) || []
  497. const bestCandidate = chooseBestCandidate(candidates)
  498. const hasCommons = await userHasCurrentInstitutionLicence(
  499. userId,
  500. commonsCache
  501. )
  502. const commonsBeatsBestSubscription =
  503. CustomerIoPlanHelpers.shouldUseCommonsBestSubscription(
  504. hasCommons,
  505. bestCandidate,
  506. COMMONS_PLAN
  507. )
  508. const resolvedSubscription = commonsBeatsBestSubscription
  509. ? null
  510. : bestCandidate?.subscription
  511. const resolvedPlan = commonsBeatsBestSubscription
  512. ? COMMONS_PLAN
  513. : bestCandidate?.plan
  514. const bestSubscriptionForPlanType = commonsBeatsBestSubscription
  515. ? { type: 'commons', plan: COMMONS_PLAN }
  516. : resolvedSubscription
  517. ? {
  518. type: getPlanType(resolvedSubscription),
  519. plan: resolvedPlan,
  520. }
  521. : { type: 'free' }
  522. const planType = CustomerIoPlanHelpers.normalizePlanType(
  523. bestSubscriptionForPlanType
  524. )
  525. const displayPlanType =
  526. CustomerIoPlanHelpers.getFriendlyPlanName(planType) || ''
  527. const planTerm = resolvedSubscription
  528. ? getPlanCadence(resolvedSubscription, resolvedPlan)
  529. : ''
  530. const userIsMemberOfGroupSubscription =
  531. isMemberOfGroupSubscription(candidates)
  532. const userHasActiveOverleafSubscription = bestCandidate != null
  533. let nextRenewalDate = ''
  534. let expiryDate = ''
  535. let paymentRecord = null
  536. if (resolvedSubscription) {
  537. const paymentInfo = await getSubscriptionPaymentInfo(
  538. userId,
  539. resolvedSubscription,
  540. paymentInfoCache
  541. )
  542. if (paymentInfo.skip) {
  543. return null
  544. }
  545. nextRenewalDate = paymentInfo.nextRenewalDate
  546. paymentRecord = paymentInfo.paymentRecord
  547. expiryDate =
  548. CustomerIoPlanHelpers.getExpiryDateFromPaymentRecord(
  549. paymentRecord
  550. ) || ''
  551. }
  552. const aiPlan = getAiPlanForUser({
  553. bestSubscription: bestSubscriptionForPlanType,
  554. planType,
  555. individualSubscription: resolvedSubscription,
  556. paymentRecord,
  557. user,
  558. userIsMemberOfGroupSubscription,
  559. userHasActiveOverleafSubscription,
  560. })
  561. const aiPlanTerm = CustomerIoPlanHelpers.getAiPlanCadence(
  562. aiPlan,
  563. bestSubscriptionForPlanType,
  564. resolvedSubscription,
  565. paymentRecord
  566. )
  567. const groupAIEnabled = getGroupAiEnabled(candidates)
  568. const groupRole = getGroupRole(candidates, userId)
  569. return {
  570. userId,
  571. email: user.email,
  572. planType,
  573. displayPlanType,
  574. preMigrationPlanType: planType,
  575. preMigrationDisplayPlanType: displayPlanType,
  576. planTerm,
  577. aiPlan,
  578. aiPlanTerm: aiPlanTerm || '',
  579. nextRenewalDate,
  580. expiryDate,
  581. groupAIEnabled,
  582. groupRole,
  583. }
  584. })
  585. )
  586. )
  587. const csvRows = []
  588. for (const row of rows) {
  589. globalState.processedCount += 1
  590. if (row) {
  591. csvRows.push(row)
  592. globalState.writtenCount += 1
  593. } else {
  594. globalState.skippedCount += 1
  595. }
  596. }
  597. writeCsvRows(writeStream, csvRows, globalState.shouldWriteHeader)
  598. if (csvRows.length > 0) {
  599. globalState.shouldWriteHeader = false
  600. }
  601. globalState.lastProcessedUserId = userIds[userIds.length - 1]
  602. if (globalState.processedCount % checkpointInterval < userIds.length) {
  603. await trackProgress(
  604. `Checkpoint: processed=${globalState.processedCount}/${totalUsersCount || '?'} written=${globalState.writtenCount} skipped=${globalState.skippedCount} resumeAfterUserId=${globalState.lastProcessedUserId}`
  605. )
  606. }
  607. }
  608. async function main(trackProgress) {
  609. const {
  610. outputPath,
  611. concurrency,
  612. batchSize,
  613. resumeAfterUserId,
  614. checkpointInterval,
  615. append,
  616. } = parseArgs()
  617. await trackProgress(
  618. 'Building target user cursor (subscriptions + supplementary users)'
  619. )
  620. if (resumeAfterUserId) {
  621. await trackProgress(
  622. `Resume enabled: starting after user id ${resumeAfterUserId}`
  623. )
  624. }
  625. const paymentInfoCache = new Map()
  626. const commonsCache = new Map()
  627. const limit = pLimit(Number(concurrency) || 5)
  628. const resolvedBatchSize = Math.max(1, Number(batchSize) || 500)
  629. const resolvedCheckpointInterval = Math.max(
  630. 100,
  631. Number(checkpointInterval) || 10000
  632. )
  633. const outputFileExists = fs.existsSync(outputPath)
  634. const outputFileHasContent =
  635. outputFileExists && fs.statSync(outputPath).size > 0
  636. const writeStream = fs.createWriteStream(outputPath, {
  637. flags: append ? 'a' : 'w',
  638. })
  639. const globalState = {
  640. processedCount: 0,
  641. writtenCount: 0,
  642. skippedCount: 0,
  643. lastProcessedUserId: resumeAfterUserId || null,
  644. shouldWriteHeader: !append || !outputFileHasContent,
  645. }
  646. let pendingUserIds = []
  647. const targetUserIdsCursor = getTargetUserIdsCursor(resumeAfterUserId)
  648. for await (const doc of targetUserIdsCursor) {
  649. pendingUserIds.push(doc._id.toString())
  650. if (pendingUserIds.length >= resolvedBatchSize) {
  651. await processUserBatch({
  652. userIds: pendingUserIds,
  653. writeStream,
  654. paymentInfoCache,
  655. commonsCache,
  656. limit,
  657. trackProgress,
  658. totalUsersCount: null,
  659. globalState,
  660. checkpointInterval: resolvedCheckpointInterval,
  661. })
  662. pendingUserIds = []
  663. }
  664. }
  665. if (pendingUserIds.length > 0) {
  666. await processUserBatch({
  667. userIds: pendingUserIds,
  668. writeStream,
  669. paymentInfoCache,
  670. commonsCache,
  671. limit,
  672. trackProgress,
  673. totalUsersCount: null,
  674. globalState,
  675. checkpointInterval: resolvedCheckpointInterval,
  676. })
  677. }
  678. if (globalState.shouldWriteHeader) {
  679. writeCsvHeader(writeStream)
  680. globalState.shouldWriteHeader = false
  681. }
  682. writeStream.end()
  683. await trackProgress(
  684. `Final checkpoint: processed=${globalState.processedCount} written=${globalState.writtenCount} skipped=${globalState.skippedCount} resumeAfterUserId=${globalState.lastProcessedUserId}`
  685. )
  686. await trackProgress(`CSV generated: ${outputPath}`)
  687. console.log(`✅ Export complete: ${outputPath}`)
  688. console.log(`Rows written: ${globalState.writtenCount}`)
  689. }
  690. try {
  691. await scriptRunner(main)
  692. process.exit(0)
  693. } catch (error) {
  694. console.error(error)
  695. process.exit(1)
  696. }