sync_group_subscription_memberships.mjs 8.1 KB

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