sync_group_subscription_memberships.mjs 8.2 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282
  1. import GoogleBigQueryHelper from './helpers/GoogleBigQueryHelper.mjs'
  2. import { Subscription } from '../../app/src/models/Subscription.js'
  3. import AnalyticsManager from '../../app/src/Features/Analytics/AnalyticsManager.js'
  4. import { DeletedSubscription } from '../../app/src/models/DeletedSubscription.js'
  5. import minimist from 'minimist'
  6. import _ from 'lodash'
  7. import mongodb from 'mongodb-legacy'
  8. import { scriptRunner } from '../lib/ScriptRunner.mjs'
  9. const { ObjectId } = mongodb
  10. let BATCH_SIZE, COMMIT, VERBOSE
  11. async function main() {
  12. console.log('## Syncing group subscription memberships...')
  13. const subscriptionsCount = await Subscription.countDocuments({
  14. groupPlan: true,
  15. })
  16. const deletedSubscriptionsCount = await DeletedSubscription.countDocuments({
  17. 'subscription.groupPlan': true,
  18. })
  19. console.log(
  20. `## Going to synchronize ${subscriptionsCount} subscriptions and ${deletedSubscriptionsCount} deleted subscriptions`
  21. )
  22. await checkActiveSubscriptions()
  23. await checkDeletedSubscriptions()
  24. }
  25. async function checkActiveSubscriptions() {
  26. let subscriptions
  27. const processedSubscriptionIds = new Set()
  28. const cursor = Subscription.find(
  29. { groupPlan: true },
  30. { recurlySubscription_id: 1, member_ids: 1 }
  31. )
  32. .sort('_id')
  33. .cursor()
  34. do {
  35. subscriptions = []
  36. while (subscriptions.length <= BATCH_SIZE) {
  37. const next = await cursor.next()
  38. if (!next) {
  39. break
  40. }
  41. subscriptions.push(next)
  42. }
  43. if (subscriptions.length) {
  44. const groupIds = subscriptions.map(sub => sub._id)
  45. const bigQueryGroupMemberships =
  46. await fetchBigQueryMembershipStatuses(groupIds)
  47. const membershipsByGroupId = _.groupBy(
  48. bigQueryGroupMemberships,
  49. 'group_id'
  50. )
  51. for (const subscription of subscriptions) {
  52. const subscriptionId = subscription._id.toString()
  53. if (!processedSubscriptionIds.has(subscriptionId)) {
  54. await checkSubscriptionMemberships(
  55. subscription,
  56. membershipsByGroupId[subscriptionId] || []
  57. )
  58. processedSubscriptionIds.add(subscriptionId)
  59. }
  60. }
  61. }
  62. } while (subscriptions.length > 0)
  63. }
  64. async function checkDeletedSubscriptions() {
  65. let deletedSubscriptions
  66. const processedSubscriptionIds = new Set()
  67. const cursor = DeletedSubscription.find(
  68. { 'subscription.groupPlan': true },
  69. { subscription: 1 }
  70. ).cursor()
  71. do {
  72. deletedSubscriptions = []
  73. while (deletedSubscriptions.length <= BATCH_SIZE) {
  74. const next = await cursor.next()
  75. if (!next) {
  76. break
  77. }
  78. deletedSubscriptions.push(next.toObject().subscription)
  79. }
  80. if (deletedSubscriptions.length) {
  81. const groupIds = deletedSubscriptions.map(sub => sub._id.toString())
  82. const bigQueryGroupMemberships =
  83. await fetchBigQueryMembershipStatuses(groupIds)
  84. const membershipsByGroupId = _.groupBy(
  85. bigQueryGroupMemberships,
  86. 'group_id'
  87. )
  88. for (const deletedSubscription of deletedSubscriptions) {
  89. const subscriptionId = deletedSubscription._id.toString()
  90. if (!processedSubscriptionIds.has(subscriptionId)) {
  91. await checkDeletedSubscriptionMemberships(
  92. deletedSubscription,
  93. membershipsByGroupId[subscriptionId] || []
  94. )
  95. processedSubscriptionIds.add(subscriptionId)
  96. }
  97. }
  98. }
  99. } while (deletedSubscriptions.length > 0)
  100. }
  101. async function checkSubscriptionMemberships(subscription, membershipStatuses) {
  102. if (VERBOSE) {
  103. console.log(
  104. '\n###########################################################################################',
  105. '\n# Subscription (mongo): ',
  106. '\n# _id: \t\t\t\t',
  107. subscription._id.toString(),
  108. '\n# member_ids: \t\t\t',
  109. subscription.member_ids.map(_id => _id.toString()),
  110. '\n# recurlySubscription_id: \t',
  111. subscription.recurlySubscription_id
  112. )
  113. console.log('#\n# Membership statuses found in BigQuery: ')
  114. console.table(membershipStatuses)
  115. }
  116. // create missing `joined` events when membership status is missing
  117. for (const memberId of subscription.member_ids) {
  118. if (
  119. !_.find(membershipStatuses, {
  120. user_id: memberId.toString(),
  121. is_member: true,
  122. })
  123. ) {
  124. await sendCorrectiveEvent(
  125. memberId,
  126. 'group-subscription-joined',
  127. subscription
  128. )
  129. }
  130. }
  131. // create missing `left` events if user is not a member of the group anymore
  132. for (const { user_id: userId, is_member: isMember } of membershipStatuses) {
  133. if (
  134. isMember &&
  135. !subscription.member_ids.some(id => id.toString() === userId)
  136. ) {
  137. await sendCorrectiveEvent(userId, 'group-subscription-left', subscription)
  138. }
  139. }
  140. }
  141. async function checkDeletedSubscriptionMemberships(
  142. subscription,
  143. membershipStatuses
  144. ) {
  145. if (VERBOSE) {
  146. console.log(
  147. '\n###########################################################################################',
  148. '\n# Deleted subscription (mongo): ',
  149. '\n# _id: \t\t\t\t',
  150. subscription._id.toString(),
  151. '\n# member_ids: \t\t\t',
  152. subscription.member_ids.map(_id => _id.toString()),
  153. '\n# recurlySubscription_id: \t',
  154. subscription.recurlySubscription_id
  155. )
  156. console.log('#\n# Membership statuses found in BigQuery: ')
  157. console.table(membershipStatuses)
  158. }
  159. const updatedUserIds = new Set()
  160. // create missing `left` events if user was a member of the group in BQ and status is not up-to-date
  161. for (const memberId of subscription.member_ids.map(id => id.toString())) {
  162. if (
  163. _.find(membershipStatuses, {
  164. user_id: memberId,
  165. is_member: true,
  166. })
  167. ) {
  168. await sendCorrectiveEvent(
  169. memberId,
  170. 'group-subscription-left',
  171. subscription
  172. )
  173. updatedUserIds.add(memberId)
  174. }
  175. }
  176. // for cases where the user has been removed from the subscription before it was deleted and status is not up-to-date
  177. for (const { user_id: userId, is_member: isMember } of membershipStatuses) {
  178. if (isMember && !updatedUserIds.has(userId)) {
  179. await sendCorrectiveEvent(userId, 'group-subscription-left', subscription)
  180. updatedUserIds.add(userId)
  181. }
  182. }
  183. }
  184. async function sendCorrectiveEvent(userId, event, subscription) {
  185. if (!ObjectId.isValid(userId)) {
  186. console.warn(`Skipping '${event}' for user ${userId}: invalid user ID`)
  187. return
  188. }
  189. const segmentation = {
  190. groupId: subscription._id.toString(),
  191. subscriptionId: subscription.recurlySubscription_id,
  192. source: 'sync',
  193. }
  194. if (COMMIT) {
  195. console.log(
  196. `Sending event '${event}' for user ${userId} with segmentation: ${JSON.stringify(
  197. segmentation
  198. )}`
  199. )
  200. await AnalyticsManager.recordEventForUser(userId, event, segmentation)
  201. } else {
  202. console.log(
  203. `Dry run - would send event '${event}' for user ${userId} with segmentation: ${JSON.stringify(
  204. segmentation
  205. )}`
  206. )
  207. }
  208. }
  209. /**
  210. * @param {Array<ObjectId>} groupIds
  211. * @return {Promise<*>}
  212. */
  213. async function fetchBigQueryMembershipStatuses(groupIds) {
  214. const query = `\
  215. WITH user_memberships AS (
  216. SELECT
  217. group_id,
  218. COALESCE(user_aliases.user_id, ugm.user_id) AS user_id,
  219. is_member,
  220. ugm.created_at
  221. FROM INT_user_group_memberships ugm
  222. LEFT JOIN INT_user_aliases user_aliases ON ugm.user_id = user_aliases.analytics_id
  223. WHERE ugm.group_id IN UNNEST(@groupIds)
  224. ),
  225. ordered_status AS (
  226. SELECT *,
  227. ROW_NUMBER() OVER(PARTITION BY group_id, user_id ORDER BY created_at DESC) AS row_number
  228. FROM user_memberships
  229. )
  230. SELECT group_id, user_id, is_member, created_at FROM ordered_status
  231. WHERE row_number = 1;
  232. `
  233. return await GoogleBigQueryHelper.query(query, {
  234. groupIds: groupIds.map(id => id.toString()),
  235. })
  236. }
  237. const setup = () => {
  238. const argv = minimist(process.argv.slice(2))
  239. BATCH_SIZE = argv.batchSize ? parseInt(argv.batchSize, 10) : 100
  240. COMMIT = argv.commit !== undefined
  241. VERBOSE = argv.debug !== undefined
  242. if (!COMMIT) {
  243. console.warn('Doing dry run without --commit')
  244. }
  245. if (VERBOSE) {
  246. console.log('Running in verbose mode')
  247. }
  248. }
  249. setup()
  250. try {
  251. await scriptRunner(main)
  252. console.error('Done.')
  253. process.exit(0)
  254. } catch (error) {
  255. console.error({ error })
  256. process.exit(1)
  257. }