sync_group_subscription_memberships.js 7.3 KB

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