Verified Commit 4709de67 authored by aguestuser's avatar aguestuser

[215] add `registrar.phoneNumber.processRecycleRequests`

* get (classified) mature requests
* recycle the unredeemed channels
* notify admins of redeemed channels that their channel won't be recycled
* notify admins of all results (redemption/recycle/recycle-error)
parent f89742c8
......@@ -15,13 +15,13 @@ const requestToRecycle = phoneNumber =>
wasCreated,
}))
// (Array<string>) -> Promise<any>
// (Array<string>) -> Promise<void>
const destroyMany = phoneNumbers =>
app.db.recycleRequest.destroy({
where: { phoneNumber: { [Op.in]: phoneNumbers } },
})
const classifyMatureRecycleRequests = async () => {
const getMatureRecycleRequests = async () => {
// channel admins have a 1 day grace period to redeem a channel slated for recycling
// by using it. calculate when that grace period started...
const gracePeriodStart = util.now().subtract(parseInt(recycleGracePeriod), 'ms')
......@@ -48,4 +48,4 @@ const classifyMatureRecycleRequests = async () => {
}
}
module.exports = { requestToRecycle, classifyMatureRecycleRequests, destroyMany }
module.exports = { requestToRecycle, getMatureRecycleRequests, destroyMany }
......@@ -4,6 +4,15 @@ const inviteRepository = require('./db/repositories/invite')
const smsSenderRepository = require('./db/repositories/smsSender')
const hotlineMessageRepository = require('./db/repositories/hotlineMessage')
const diagnostics = require('./diagnostics')
const util = require('./util')
const recycleRequestRepository = require('./db/repositories/recycleRequest')
const channelRepository = require('./db/repositories/channel')
const notifier = require('./notifier')
const { notificationKeys } = require('./notifier')
const { map } = require('lodash')
const {
job: { recycleInterval },
} = require('./config')
const run = async () => {
logger.log('--- Running startup jobs...')
......@@ -24,9 +33,13 @@ const run = async () => {
const messageIdsDeleted = await hotlineMessageRepository.deleteExpired()
logger.log(`----- Deleted ${messageIdsDeleted} expired sms sender records.`)
logger.log('----- Launching data cleaning jobs...')
logger.log('----- Launching invite scrubbing job...')
inviteRepository.launchInviteDeletionJob()
logger.log('----- Launched data cleaning jobs.')
logger.log('----- Launched invite scrubbing job.')
logger.log('---- Launching recycle request processing job...')
util.repeatEvery(recycleInterval, processRecycleRequests)
logger.log('---- Launched healthcheck job...')
logger.log('---- Launching healthcheck job...')
diagnostics.launchHealthcheckJob()
......
const { pick } = require('lodash')
const { statuses } = require('../../db/models/phoneNumber')
const channelRepository = require('../../db/repositories/channel')
const signal = require('../../signal')
const { getAdminMemberships } = require("../../db/repositories/channel")
const { getAdminPhoneNumbers } = require('../../db/repositories/channel')
const { messagesIn } = require('../../dispatcher/strings/messages')
const { sdMessageOf } = require('../../signal/constants')
const {
twilio: { accountSid, authToken, smsEndpoint },
api: { host },
signal: { supportPhoneNumber },
} = require('../../config')
// STRINGS
const notificationKeys = {
CHANNEL_DESTROYED: 'channelDestroyed',
CHANNEL_RECYCLED: 'channelRecycled',
}
const errors = {
searchEmpty: 'search returned empty list',
searchFailed: err => `twilio number search failed: ${err}`,
......@@ -38,40 +26,6 @@ const errorStatus = (error, phoneNumber) => ({
const extractStatus = phoneNumberInstance =>
pick(phoneNumberInstance, ['status', 'phoneNumber', 'twilioSid'])
// (Database, Socket, Channel, String, String) -> Promise<Array<string>>
const notifyMembersExcept = async (channel, message, sender) => {
if (channel == null) return
const memberPhoneNumbers = channelRepository.getMemberPhoneNumbersExcept(channel, [sender])
await signal.broadcastMessage(memberPhoneNumbers, sdMessageOf(channel, message))
}
// (string) -> Promise<Array<string>>
const notifyMaintainers = async message => {
if (!supportPhoneNumber) return Promise.resolve([])
const supportChannel = await channelRepository.findDeep(supportPhoneNumber)
const maintainerPhoneNumbers = getAdminPhoneNumbers(supportChannel)
await signal.broadcastMessage(maintainerPhoneNumbers, sdMessageOf(supportChannel, message))
}
// (string, string) -> Promise<Array<string>>
const notifyAdmins = async (channel, notificationKey) =>
_notifyMany(channel, notificationKey, getAdminMemberships(channel))
// (Channel, string) -> Promise<Array<string>>
const notifyMembers = async (channel, notificationKey) =>
_notifyMany(channel, notificationKey, channel.memberships)
// (Channel, string, Array<Member>) => Promise<Array<string>>
const _notifyMany = (channel, notificationKey, recipients) =>
Promise.all(
recipients.map(recipient =>
signal.sendMessage(
recipient.memberPhoneNumber,
sdMessageOf(channel, messagesIn(recipient.language).notifications[notificationKey]),
),
),
)
// (DB, Socket, ChannelInstance, String) -> Promise<void>
const destroyChannel = async (channel, tx) => {
if (channel == null) return
......@@ -92,11 +46,6 @@ module.exports = {
statuses,
errorStatus,
extractStatus,
notifyAdmins,
notifyMembers,
notifyMaintainers,
notifyMembersExcept,
notificationKeys,
destroyChannel,
getTwilioClient,
smsUrl,
......
......@@ -4,7 +4,7 @@ const { errors } = require('./common')
const { destroy } = require('./destroy')
const { list } = require('./present')
const { provisionN } = require('./provision')
const { requestToRecycle, recycle } = require('./recycle')
const { requestToRecycle, recycle, processRecycleRequests } = require('./recycle')
const { register, registerAllPurchased, registerAllUnregistered } = require('./register')
const { handleSms } = require('./sms')
const { purchase, purchaseN } = require('./purchase')
......@@ -22,6 +22,7 @@ module.exports = {
provisionN,
purchase,
purchaseN,
processRecycleRequests,
requestToRecycle,
recycle,
register,
......
......@@ -3,11 +3,11 @@ const eventRepository = require('../../db/repositories/event')
const phoneNumberRepository = require('../../db/repositories/phoneNumber')
const recycleRequestRepository = require('../../db/repositories/recycleRequest')
const common = require('./common')
const { notificationKeys } = require('./common')
const notifier = require('../../notifier')
const { notificationKeys } = notifier
const { statuses } = require('../../util')
const { defaultLanguage } = require('../../config')
const { eventTypes } = require('../../db/models/event')
const { messagesIn } = require('../../dispatcher/strings/messages')
const { map } = require('lodash')
// (Array<string>) -> Promise<SignalboostStatus>
const requestToRecycle = async phoneNumbers => {
......@@ -28,7 +28,7 @@ const requestToRecycle = async phoneNumbers => {
message: `${phoneNumber} has already been enqueued for recycling.`,
}
await common.notifyAdmins(channel, 'channelEnqueuedForRecycling')
await notifier.notifyAdmins(channel, 'channelEnqueuedForRecycling')
return {
status: statuses.SUCCESS,
......@@ -44,32 +44,50 @@ const requestToRecycle = async phoneNumbers => {
)
}
// () -> Promise<Array<string>>
const processRecycleRequests = async () => {
try {
const { redeemed, toRecycle } = await recycleRequestRepository.getMatureRecycleRequests()
const recycleResults = await Promise.all(toRecycle.map(recycle))
await recycleRequestRepository.destroyMany([...redeemed, ...toRecycle])
const redeemedChannels = await channelRepository.findManyDeep(redeemed)
return Promise.all([
...redeemedChannels.map(channel =>
notifier.notifyAdmins(channel, notificationKeys.CHANNEL_REDEEMED),
),
notifier.notifyMaintainers(
`${redeemed.length + toRecycle.length} recycle requests processed:\n\n` +
`${redeemed.map(r => `${r} redeemed by admins.`).join('\n')}` +
'\n' +
`${map(recycleResults, 'message').join('\n')}`,
),
])
} catch (err) {
return notifier.notifyMaintainers(`Error processing recycle job: ${err}`)
}
}
// (string) -> SignalboostStatus
const recycle = async phoneNumber => {
const channel = await channelRepository.findDeep(phoneNumber)
if (!channel) return { status: statuses.ERROR, message: `Channel not found for ${phoneNumber}` }
try {
await common.notifyMembers(channel, notificationKeys.CHANNEL_RECYCLED)
await notifier.notifyMembers(channel, notificationKeys.CHANNEL_RECYCLED)
await channelRepository.destroy(channel)
await eventRepository.log(eventTypes.CHANNEL_DESTROYED, phoneNumber)
const result = await phoneNumberRepository.update(phoneNumber, {
status: common.statuses.VERIFIED,
})
await phoneNumberRepository.update(phoneNumber, { status: common.statuses.VERIFIED })
return {
status: statuses.SUCCESS,
data: common.extractStatus(result),
message: `${phoneNumber} recycled.`,
}
} catch (err) {
await common.notifyMaintainers(
messagesIn(defaultLanguage).notifications.recycleChannelFailed(phoneNumber),
)
return {
status: 'ERROR',
message: `Failed to recycle channel for ${phoneNumber}. Error: ${err}`,
message: `${phoneNumber} failed to be recycled. Error: ${err}`,
}
}
}
module.exports = { requestToRecycle, recycle }
module.exports = { requestToRecycle, processRecycleRequests, recycle }
......@@ -131,7 +131,7 @@ describe('recycleablePhoneNumber repository', () => {
})
it('retrieves all mature recycle requests and classifies them as redeemed or toRecycle', async () => {
const res = await recycleRequestRepository.classifyMatureRecycleRequests(values(phoneNumbers))
const res = await recycleRequestRepository.getMatureRecycleRequests(values(phoneNumbers))
expect(res).to.eql({
redeemed: [phoneNumbers.redeemed],
toRecycle: [phoneNumbers.toRecycle],
......
......@@ -5,7 +5,7 @@ import phoneNumberRegistrar from '../../app/registrar/phoneNumber'
import inviteRepository from '../../app/db/repositories/invite'
import smsSenderRepository from '../../app/db/repositories/smsSender'
import hotlineMessageRepository from '../../app/db/repositories/hotlineMessage'
import registrar from '../../app/jobs'
import jobs from '../../app/jobs'
describe('jobs service', () => {
let registerAllStub, inviteDeletionStub, smsSenderDeletionStub, hotlineMessageDeletionStub
......@@ -20,7 +20,7 @@ describe('jobs service', () => {
smsSenderDeletionStub = sinon.stub(smsSenderRepository, 'deleteExpired')
hotlineMessageDeletionStub = sinon.stub(hotlineMessageRepository, 'deleteExpired')
process.env.REREGISTER_ON_STARTUP = '1'
await registrar.run()
await jobs.run()
})
after(() => {
......
......@@ -3,17 +3,19 @@ import { afterEach, beforeEach, describe, it } from 'mocha'
import phoneNumberService, {
recycle,
requestToRecycle,
processRecycleRequests,
} from '../../../../app/registrar/phoneNumber'
import sinon from 'sinon'
import common from '../../../../app/registrar/phoneNumber/common'
import channelRepository from '../../../../app/db/repositories/channel'
import eventRepository from '../../../../app/db/repositories/event'
import phoneNumberRepository from '../../../../app/db/repositories/phoneNumber'
import recycleRequestRepository from '../../../../app/db/repositories/recycleRequest'
import { eventTypes } from '../../../../app/db/models/event'
import { genPhoneNumber } from '../../../support/factories/phoneNumber'
import { channelFactory, deepChannelFactory } from '../../../support/factories/channel'
import { times } from 'lodash'
import channelRepository from '../../../../app/db/repositories/channel'
import recycleRequestRepository from '../../../../app/db/repositories/recycleRequest'
import notifier, { notificationKeys } from '../../../../app/notifier'
import { times, map, flatten } from "lodash"
import { genPhoneNumber, phoneNumberFactory } from '../../../support/factories/phoneNumber'
import { eventFactory } from '../../../support/factories/event'
describe('phone number services -- recycle module', () => {
const phoneNumber = genPhoneNumber()
......@@ -32,9 +34,9 @@ describe('phone number services -- recycle module', () => {
findChannelStub = sinon.stub(channelRepository, 'findDeep')
destroyChannelStub = sinon.stub(channelRepository, 'destroy')
logEventStub = sinon.stub(eventRepository, 'log')
notifyMaintainersStub = sinon.stub(common, 'notifyMaintainers')
notifyMembersStub = sinon.stub(common, 'notifyMembers')
notifyAdminsStub = sinon.stub(common, 'notifyAdmins')
notifyMaintainersStub = sinon.stub(notifier, 'notifyMaintainers')
notifyMembersStub = sinon.stub(notifier, 'notifyMembers')
notifyAdminsStub = sinon.stub(notifier, 'notifyAdmins')
requestToRecycleStub = sinon.stub(recycleRequestRepository, 'requestToRecycle')
})
......@@ -45,7 +47,7 @@ describe('phone number services -- recycle module', () => {
Promise.resolve({ phoneNumber, status }),
)
describe('issuing a request to recycle several phone numbers', () => {
describe('#requestToRecycle', () => {
describe('when a phone number does not belong to a valid channel', () => {
beforeEach(async () => {
findChannelStub.returns(Promise.resolve(null))
......@@ -195,7 +197,7 @@ describe('phone number services -- recycle module', () => {
})
})
describe('recycling a phone number', () => {
describe('#recycle', () => {
describe('when the phone number does not belong to a valid channel', () => {
beforeEach(async () => {
findChannelStub.returns(Promise.resolve(null))
......@@ -223,7 +225,7 @@ describe('phone number services -- recycle module', () => {
it('returns a failed status', async () => {
expect(await recycle(phoneNumber)).to.eql({
status: 'ERROR',
message: `Failed to recycle channel for ${phoneNumber}. Error: Failed to broadcast message`,
message: `${phoneNumber} failed to be recycled. Error: Failed to broadcast message`,
})
})
})
......@@ -268,7 +270,7 @@ describe('phone number services -- recycle module', () => {
const response = await recycle(phoneNumber)
expect(response).to.eql({
status: 'SUCCESS',
data: { phoneNumber: phoneNumber, status: 'VERIFIED' },
message: `${phoneNumber} recycled.`,
})
})
})
......@@ -284,7 +286,7 @@ describe('phone number services -- recycle module', () => {
const response = await recycle(phoneNumber)
expect(response).to.eql({
status: 'ERROR',
message: `Failed to recycle channel for ${phoneNumber}. Error: DB phoneNumber update failure`,
message: `${phoneNumber} failed to be recycled. Error: DB phoneNumber update failure`,
})
})
})
......@@ -293,25 +295,93 @@ describe('phone number services -- recycle module', () => {
describe('when the channel destruction fails', () => {
beforeEach(() => {
notifyMembersStub.returns(Promise.resolve())
destroyChannelStub.callsFake(() => Promise.reject('Failed to destroy channel'))
})
it('notifies the instance maintainers with a channel failure message', async () => {
await recycle(phoneNumber)
expect(notifyMaintainersStub.getCall(0).args).to.eql([
`Failed to recycle channel for phone number: ${phoneNumber}`,
])
})
it('returns a failed status', async () => {
const response = await recycle(phoneNumber)
expect(response).to.eql({
status: 'ERROR',
message: `Failed to recycle channel for ${phoneNumber}. Error: Failed to destroy channel`,
message: `${phoneNumber} failed to be recycled. Error: Failed to destroy channel`,
})
})
})
})
})
describe('#processRecycleRequests', () => {
const redeemed = times(2, genPhoneNumber)
const redeemedChannels = redeemed.map(channelPhoneNumber =>
deepChannelFactory({ channelPhoneNumber }),
)
const toRecycle = times(3, genPhoneNumber)
let getMatureRecycleRequestsStub
beforeEach(() => {
// recycle helpers that should always succeed
notifyMembersStub.returns(Promise.resolve('42'))
logEventStub.returns(Promise.resolve(eventFactory()))
updatePhoneNumberStub.returns(phoneNumberFactory())
findChannelStub.callsFake(phoneNumber => Promise.resolve(channelFactory({ phoneNumber })))
// processRecycle helpers that should always succeed
sinon.stub(channelRepository, 'findManyDeep').returns(Promise.resolve(redeemedChannels))
notifyMaintainersStub.returns(Promise.resolve(['42']))
notifyAdminsStub.returns(Promise.resolve(['42', '42']))
// if this fails, processRecycleRequests will fail
getMatureRecycleRequestsStub = sinon.stub(
recycleRequestRepository,
'getMatureRecycleRequests',
)
})
describe('when processing succeeds', () => {
beforeEach(async () => {
// recycle succeeds twice, fails once
destroyChannelStub
.onCall(0)
.returns(Promise.resolve(true))
.onCall(1)
.returns(Promise.resolve(true))
.onCall(2)
.callsFake(() => Promise.reject('BOOM!'))
// overall job succeeds
getMatureRecycleRequestsStub.returns(Promise.resolve({ redeemed, toRecycle }))
await processRecycleRequests()
})
it('recycles unredeemed channels', () => {
expect(flatten(map(destroyChannelStub.getCalls(), 'args'))).to.eql(toRecycle)
})
it('notifies admins of redeemed channels of redemption', () => {
expect(map(notifyAdminsStub.getCalls(), 'args')).to.eql([
[redeemedChannels[0], notificationKeys.CHANNEL_REDEEMED],
[redeemedChannels[1], notificationKeys.CHANNEL_REDEEMED],
])
})
it('notifies maintainers of results', () => {
expect(notifyMaintainersStub.getCall(0).args).to.eql([
'5 recycle requests processed:\n\n' +
`${redeemed[0]} redeemed by admins.\n` +
`${redeemed[1]} redeemed by admins.\n` +
`${toRecycle[0]} recycled.\n` +
`${toRecycle[1]} recycled.\n` +
`${toRecycle[2]} failed to be recycled. Error: BOOM!`,
])
})
})
describe('when job fails', () => {
beforeEach(() => getMatureRecycleRequestsStub.callsFake(() => Promise.reject('BOOM!')))
it('notifies maintainers of error', async () => {
await processRecycleRequests()
expect(notifyMaintainersStub.getCall(0).args).to.eql([
'Error processing recycle job: BOOM!',
])
})
})
})
})
Markdown is supported
0% or .
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment