Commit 93f92ad6 authored by aguestuser's avatar aguestuser

Merge branch '215-admins-can-undo-recycle' into 'main'

Resolve "admins can undo recycle"

Closes #215

See merge request !384
parents 2e665303 e9d3dbbd
......@@ -69,9 +69,7 @@ const routesOf = async router => {
router.post('/phoneNumbers/recycle', async ctx => {
const { phoneNumbers } = ctx.request.body
const result = await phoneNumberService.recycle({
phoneNumbers,
})
const result = await phoneNumberService.requestToRecycle(phoneNumbers.split(','))
merge(ctx, { status: httpStatusOfMany(result), body: result })
})
......
const defaults = {
healthcheckInterval: 1000 * 60 * 15, // 15 min
hotlineMessageExpiryInMillis: 1000 * 60 * 60 * 24 * 28, // 4 weeks
inviteDeletionInterval: 1000 * 60 * 60, // 1 hour
inviteExpiryInMillis: 1000 * 60 * 60 * 24 * 14, // 2 weeks
hotlineMessageExpiryInMillis: 1000 * 60 * 60 * 24 * 28, // 4 weeks
recycleInterval: 1000 * 60 * 60, // 1 hr
recycleGracePeriod: 1000 * 60 * 60 * 24, // 1 day
signaldStartupTime: 3000 * 60, // 3 min
}
const testInterval = 50
const development = {
...defaults,
healthcheckInterval: 1000 * 60, // 60 sec
recycleInterval: 1000 * 5, // 5 secs
recycleGracePeriod: 1000 * 30, // 30 sec
}
const test = {
...defaults,
inviteDeletionInterval: 100, // 100 millis
testInterval,
healthcheckInterval: testInterval, // millis
inviteDeletionInterval: testInterval,
recycleInterval: testInterval,
signaldStartupTime: 1, // millis
inviteExpiryInMillis: 200, // 200 millis
}
module.exports = {
development: defaults,
development,
test,
production: defaults,
}
......@@ -4,7 +4,6 @@ const defaults = {
broadcastSpacing: 100, // 100 millis
defaultMessageExpiryTime: 60 * 60 * 24 * 7, // 1 week
expiryUpdateDelay: 200, // 200 millis
healtcheckInterval: 1000 * 60 * 15, // 15 min
healthcheckTimeout: 1000 * 60 * 15, // 15 min
healtcheckSpacing: 100, // 100 millis
intervalBetweenRegistrationBatches: 120000, // 2 minutes
......@@ -18,7 +17,6 @@ const defaults = {
signaldRequestTimeout: 1000 * 10, // 10 sec
signaldVerifyTimeout: 1000 * 30, // 30 sec
signaldSendTimeout: 1000 * 60 * 60, // 60 min
signaldStartupTime: 3000 * 60, // 3 min
supportPhoneNumber: (process.env.SUPPORT_CHANNEL_NUMBER || '').replace(`"`, ''),
diagnosticsPhoneNumber: (process.env.DIAGNOSTICS_CHANNEL_NUMBER || '').replace(`"`, ''),
welcomeDelay: 3000, // 3 sec
......@@ -29,7 +27,6 @@ const test = {
broadcastBatchInterval: 10, // 10 millis
broadcastBatchSize: 1,
expiryUpdateDelay: 1, // millis
healthcheckInterval: 30, // millis
healthcheckTimeout: 30, // millis
intervalBetweenRegistrationBatches: 30, // millis
intervalBetweenRegistrations: 5, // millis,
......@@ -40,7 +37,6 @@ const test = {
signaldSendTimeout: 40, // millis
signaldRequestTimeout: 10, // millis
signaldVerifyTimeout: 20, // millis
signaldStartupTime: 1, // millis
supportPhoneNumber: '+15555555555',
welcomeDelay: 0.0001, // millis
diagnosticsPhoneNumber: '+15554443333',
......@@ -48,7 +44,6 @@ const test = {
const development = {
...defaults,
healtcheckInterval: 1000 * 60, // 60 sec
healthcheckTimeout: 1000 * 60, // 60 sec
}
......
......@@ -10,6 +10,8 @@ const { membershipOf } = require('./models/membership')
const { messageCountOf } = require('./models/messageCount')
const { phoneNumberOf } = require('./models/phoneNumber')
const { smsSenderOf } = require('./models/smsSender')
const { recycleRequestOf } = require('./models/recycleRequest')
const { wait } = require('../util')
const { maxConnectionAttempts, connectionInterval } = config
......@@ -28,6 +30,7 @@ const run = async () => {
membership: membershipOf(sequelize, Sequelize),
messageCount: messageCountOf(sequelize, Sequelize),
phoneNumber: phoneNumberOf(sequelize, Sequelize),
recycleRequest: recycleRequestOf(sequelize, Sequelize),
smsSender: smsSenderOf(sequelize, Sequelize),
}
......
'use strict';
module.exports = {
up: (queryInterface, Sequelize) => {
return queryInterface.createTable('recycleablePhoneNumbers', {
channelPhoneNumber: {
type: Sequelize.STRING,
primaryKey: true,
allowNull: false,
unique: true,
},
whenEnqueued: {
type: Sequelize.DATE,
allowNull: false,
},
createdAt: {
type: Sequelize.DATE,
allowNull: false,
defaultValue: Sequelize.NOW,
},
updatedAt: {
type: Sequelize.DATE,
allowNull: false,
defaultValue: Sequelize.NOW,
},
});
},
down: (queryInterface, Sequelize) => {
return queryInterface.dropTable('recycleablePhoneNumbers');
}
};
'use strict';
module.exports = {
up: async (queryInterface, Sequelize) => {
await queryInterface.createTable('recycleRequests', {
phoneNumber: {
type: Sequelize.STRING,
primaryKey: true,
allowNull: false,
unique: true,
},
createdAt: {
type: Sequelize.DATE,
allowNull: false,
defaultValue: Sequelize.NOW,
},
updatedAt: {
type: Sequelize.DATE,
allowNull: false,
defaultValue: Sequelize.NOW,
},
})
await queryInterface.addIndex('recycleRequests', {
fields: ['createdAt']
})
return queryInterface.dropTable('recycleablePhoneNumbers')
},
down: async (queryInterface, Sequelize) => {
await queryInterface.dropTable('recycleRequests')
return queryInterface.createTable('recycleablePhoneNumbers', {
channelPhoneNumber: {
type: Sequelize.STRING,
primaryKey: true,
allowNull: false,
unique: true,
},
whenEnqueued: {
type: Sequelize.DATE,
allowNull: false,
},
createdAt: {
type: Sequelize.DATE,
allowNull: false,
defaultValue: Sequelize.NOW,
},
updatedAt: {
type: Sequelize.DATE,
allowNull: false,
defaultValue: Sequelize.NOW,
},
});
}
};
const { isPhoneNumber } = require('../validations')
const recycleRequestOf = (sequelize, Sequelize) =>
sequelize.define('recycleRequest', {
phoneNumber: {
type: Sequelize.STRING,
primaryKey: true,
allowNull: false,
unique: true,
validate: isPhoneNumber,
},
createdAt: {
type: Sequelize.DATE,
allowNull: false,
defaultValue: Sequelize.NOW,
},
updatedAt: {
type: Sequelize.DATE,
allowNull: false,
defaultValue: Sequelize.NOW,
},
})
module.exports = { recycleRequestOf }
const app = require('../../../app')
const { Op } = require('sequelize')
const { loggerOf } = require('../../util')
const { memberTypes } = require('./membership')
const { map } = require('lodash')
const {
signal: { supportPhoneNumber },
} = require('../../config')
......@@ -30,6 +32,14 @@ const update = (phoneNumber, attrs) =>
.update({ ...attrs }, { where: { phoneNumber }, returning: true })
.then(([, [pNumInstance]]) => pNumInstance)
// (string, Transaction | null) => Promise<boolean>
const destroy = async (phoneNumber, transaction) => {
const channel = await findByPhoneNumber(phoneNumber)
return channel
? channel.destroy({ ...(transaction ? { transaction } : {}) }).then(() => true)
: false
}
const findAll = () => app.db.channel.findAll()
const findAllDeep = () =>
......@@ -42,6 +52,17 @@ const findAllDeep = () =>
],
})
const findManyDeep = phoneNumbers =>
app.db.channel.findAll({
where: { phoneNumber: { [Op.in]: phoneNumbers } },
include: [
{ model: app.db.deauthorization },
{ model: app.db.invite },
{ model: app.db.membership },
{ model: app.db.messageCount },
],
})
const findByPhoneNumber = phoneNumber => app.db.channel.findOne({ where: { phoneNumber } })
const findDeep = phoneNumber =>
......@@ -72,6 +93,10 @@ const isSysadmin = async phoneNumber => {
// all selectors assume you are operating on an already deeply-fetched channel (with all nested attrs avail)
const getMemberPhoneNumbers = channel => (channel.memberships || []).map(m => m.memberPhoneNumber)
const getMembersExcept = (channel, members) => {
const phoneNumbersToExclude = new Set(map(members, 'memberPhoneNumber'))
return channel.memberships.filter(m => !phoneNumbersToExclude.has(m.memberPhoneNumber))
}
const getMemberPhoneNumbersExcept = (channel, phoneNumbers) =>
getMemberPhoneNumbers(channel).filter(pn => !phoneNumbers.includes(pn))
......@@ -89,14 +114,17 @@ const getSubscriberPhoneNumbers = channel =>
module.exports = {
create,
destroy,
findAll,
findAllDeep,
findByPhoneNumber,
findDeep,
findManyDeep,
getAllAdminsExcept,
getAdminMemberships,
getAdminPhoneNumbers,
getMemberPhoneNumbers,
getMembersExcept,
getMemberPhoneNumbersExcept,
getSubscriberMemberships,
getSubscriberPhoneNumbers,
......
......@@ -2,14 +2,12 @@ const app = require('../../../app')
const moment = require('moment')
const { Op } = require('sequelize')
const membershipRepository = require('./membership')
const { repeatEvery, loggerOf } = require('../../util')
const logger = loggerOf('db|inviteRepository')
const {
defaultLanguage,
job: { inviteExpiryInMillis, inviteDeletionInterval },
job: { inviteExpiryInMillis },
} = require('../../config')
// (Database, string, string, string) -> Promise<boolean>
// (string, string, string) -> Promise<boolean>
const issue = async (channelPhoneNumber, inviterPhoneNumber, inviteePhoneNumber) => {
// issues invite IFF invitee is not already invited
const [, wasCreated] = await app.db.invite.findOrCreate({
......@@ -18,26 +16,22 @@ const issue = async (channelPhoneNumber, inviterPhoneNumber, inviteePhoneNumber)
return wasCreated
}
// (Database, string, string) -> Promise<number>
// (string, string) -> Promise<number>
const count = (channelPhoneNumber, inviteePhoneNumber) =>
app.db.invite.count({ where: { channelPhoneNumber, inviteePhoneNumber } })
// (Database, string, string, string) -> Promise<Array<Membership,number>>
// (string, string, string) -> Promise<Array<Membership,number>>
const accept = (channelPhoneNumber, inviteePhoneNumber, language = defaultLanguage) =>
Promise.all([
membershipRepository.addSubscriber(channelPhoneNumber, inviteePhoneNumber, language),
app.db.invite.destroy({ where: { channelPhoneNumber, inviteePhoneNumber } }),
])
// (Database, string, string) -> Promise<number>
// (string, string) -> Promise<number>
const decline = async (channelPhoneNumber, inviteePhoneNumber) =>
app.db.invite.destroy({ where: { channelPhoneNumber, inviteePhoneNumber } })
// (Database, number) -> Promise<void>
const launchInviteDeletionJob = () =>
repeatEvery(() => deleteExpired().catch(logger.error), inviteDeletionInterval)
// Database -> Promise<number>
// () -> Promise<number>
const deleteExpired = async () =>
app.db.invite.destroy({
where: {
......@@ -47,4 +41,4 @@ const deleteExpired = async () =>
},
})
module.exports = { issue, count, accept, decline, deleteExpired, launchInviteDeletionJob }
module.exports = { issue, count, accept, decline, deleteExpired }
......@@ -10,6 +10,14 @@ const filters = {
const create = ({ phoneNumber, twilioSid, status }) =>
app.db.phoneNumber.create({ phoneNumber, twilioSid, status })
// (string, Transaction | null) => Promise<boolean>
const destroy = async (phoneNumber, transaction) => {
const phoneNumberRecord = await find(phoneNumber)
return phoneNumberRecord
? phoneNumber.destroy({ ...(transaction ? { transaction } : {}) }).then(() => true)
: false
}
const find = phoneNumber => app.db.phoneNumber.findOne({ where: { phoneNumber } })
const findAll = () => app.db.phoneNumber.findAll()
......@@ -35,4 +43,4 @@ const update = (phoneNumber, attrs) =>
.update({ ...attrs }, { where: { phoneNumber }, returning: true })
.then(([, [pNumInstance]]) => pNumInstance)
module.exports = { filters, create, find, findAll, findAllPurchased, list, update }
module.exports = { filters, create, destroy, find, findAll, findAllPurchased, list, update }
const { Op } = require('sequelize')
const app = require('../../../app')
const util = require('../../util')
const { map, partition } = require('lodash')
const {
job: { recycleGracePeriod },
} = require('../../config')
// (string) -> Promise<{ recycleRequest: RecycleRequest, wasCreated: boolean }>
const requestToRecycle = phoneNumber =>
app.db.recycleRequest
.findOrCreate({ where: { phoneNumber } })
.then(([recycleRequest, wasCreated]) => ({
recycleRequest,
wasCreated,
}))
// (Array<string>) -> Promise<void>
const destroyMany = phoneNumbers =>
app.db.recycleRequest.destroy({
where: { phoneNumber: { [Op.in]: phoneNumbers } },
})
const evaluateRecycleRequests = 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')
// find all the requests issued before the start of the grace period, indicating
// channels which should be considered for recycling (b/c their grace period has passed)
const matureRequests = await app.db.recycleRequest.findAll({
where: { createdAt: { [Op.lte]: gracePeriodStart } },
})
// make lists of redeemed and unredeemed channel phone numbers, where "redeemed" channels
// have been used since the start of the grace period, and thus should not be recycled
const [redeemed, toRecycle] = partition(
await app.db.messageCount.findAll({
where: { channelPhoneNumber: { [Op.in]: map(matureRequests, 'phoneNumber') } },
}),
messageCount => messageCount.updatedAt > gracePeriodStart,
)
// pluck the channel phone numbers and return them for processing!
return {
redeemed: map(redeemed, 'channelPhoneNumber'),
toRecycle: map(toRecycle, 'channelPhoneNumber'),
}
}
module.exports = { requestToRecycle, evaluateRecycleRequests, destroyMany }
......@@ -6,7 +6,7 @@ const metrics = require('./metrics')
const { zip } = require('lodash')
const { sdMessageOf } = require('./signal/constants')
const {
signal: { diagnosticsPhoneNumber, healtcheckInterval, healthcheckSpacing, signaldStartupTime },
signal: { diagnosticsPhoneNumber, healthcheckSpacing },
} = require('./config')
const logger = util.loggerOf('diagnostics')
......@@ -51,14 +51,7 @@ const respondToHealthcheck = (channelPhoneNumber, healthcheckId) =>
),
)
// () => Promise<void>
const launchHealthcheckJob = async () => {
await util.wait(signaldStartupTime)
return diagnosticsPhoneNumber && util.repeatEvery(sendHealthchecks, healtcheckInterval)
}
module.exports = {
respondToHealthcheck,
sendHealthchecks,
launchHealthcheckJob,
}
......@@ -410,9 +410,15 @@ const notifications = {
channelDestructionFailed: phoneNumber =>
`Der Kanal mit der Signal-Nummer: ${phoneNumber} konnte nicht zerstört werden`,
channelEnqueuedForRecycling:
'Hallo! Dieser Kanal wird wegen mangelnder Nutzung deaktiviert. Um zu verhindern, dass es deaktiviert wird, senden Sie innerhalb der nächsten 24 Stunden "INFO". Weitere Informationen finden Sie unter signalboost.info/how-to.',
channelRecycled:
'Der Kanal wurde wegen Inaktivität deaktiviert. Gehe auf https://signalboost.info um einen neuen Kanal zu erstellen.',
channelRedeemed:
'Dieser Kanal sollte wegen mangelnder Nutzung deaktiviert werden. Da Sie den Kanal kürzlich verwendet haben, wird er jedoch nicht mehr deaktiviert. Yay!',
channelRenamed: (oldName, newName) => `Kanal umbenannt von "${oldName}" zu "${newName}."`,
deauthorization: adminPhoneNumber => `
......
......@@ -401,9 +401,15 @@ const notifications = {
channelDestructionFailed: phoneNumber =>
`Failed to destroy channel for phone number: ${phoneNumber}`,
channelEnqueuedForRecycling:
'Hello! This channel is about to be deactivated due to lack of use. To prevent it from being deactivated, send "INFO" within the next 24 hours. For more info, visit signalboost.info/how-to.',
channelRecycled:
'Channel deactivated due to lack of use. To create a new channel, visit https://signalboost.info',
channelRedeemed:
'This channel was scheduled to be deactivated due to lack of use. However, since you used the channel recently, it will no longer be deactivated. Yay!',
channelRenamed: (oldName, newName) => `Channel renamed from "${oldName}" to "${newName}."`,
deauthorization: adminPhoneNumber => `
......
......@@ -410,12 +410,18 @@ const notifications = {
channelDestroyed: 'El canal ha sido destruido permanentemente por sus admins.',
channelEnqueuedForRecycling:
'¡Hola! Este canal está a punto de desactivarse por falta de uso. Para evitar que se desactive, envíe "INFO" en las próximas 24 horas. Para obtener más información, visite signalboost.info/how-to. ',
channelDestructionFailed: phoneNumber =>
`Error al destruir el canal para el número de teléfono: ${phoneNumber}`,
channelRecycled:
'Canal desactivado por falta de uso. Para crear un nuevo canal, visite https://signalboost.info',
channelRedeemed:
'Este canal estaba programado para desactivarse por falta de uso. Sin embargo, dado que usó el canal recientemente, ya no estará desactivado. ¡Hurra!',
channelRenamed: (oldName, newName) => `Canal renombrado de "${oldName}" a "${newName}."`,
deauthorization: adminPhoneNumber => `
......
......@@ -413,13 +413,19 @@ const notifications = {
adminLeft: 'Un-e admin vient de quitter le canal',
channelDestroyed:
'La chaîne et tous les enregistrements associés ont été définitivement détruits.',
'La canal et tous les enregistrements associés ont été définitivement détruits.',
channelEnqueuedForRecycling:
"Bonjour! Cette canal est sur le point d'être désactivée faute d'utilisation. Pour éviter qu'il ne soit désactivé, envoyez 'INFO' dans les prochaines 24 heures.Pour plus d'informations, visitez signalboost.info/how-to. ",
channelDestructionFailed: phoneNumber =>
`Impossible de détruire la chaîne pour le numéro de téléphone: ${phoneNumber}`,
`Impossible de détruire la canal pour le numéro de téléphone: ${phoneNumber}`,
channelRecycled:
"Chaîne désactivée par manque d'utilisation. Pour créer une nouvelle chaîne, visitez https://signalboost.info",
"Canal désactivée par manque d'utilisation. Pour créer une nouvelle chaîne, visitez https://signalboost.info",
channelRedeemed:
"Cette canal devait être désactivée en raison d'un manque d'utilisation. Cependant, puisque vous avez utilisé la chaîne récemment, elle ne sera plus désactivée. Yay!",
channelRenamed: (oldName, newName) => `Le canal a été renommé de "${oldName}" à "${newName}."`,
......
......@@ -7,6 +7,7 @@ const app = {
socketPool: null,
api: null,
metrics: null,
jobs: null,
}
app.run = async ({ db, socketPool, api, metrics, jobs, signal }) => {
......@@ -40,7 +41,7 @@ app.run = async ({ db, socketPool, api, metrics, jobs, signal }) => {
logger.log(`...created metrics registry!`)
logger.log('Running startup jobs...')
await jobsService.run().catch(logger.fatalError)
app.jobs = jobsService.run().catch(logger.fatalError)
logger.log('...ran startup jobs!')
logger.log('Starting signal service...')
......@@ -54,7 +55,12 @@ app.run = async ({ db, socketPool, api, metrics, jobs, signal }) => {
app.stop = async () => {
const { logger } = require('./util')
logger.log('Shutting down signalboost...')
await Promise.all([app.socketPool.stop(), app.db.stop(), app.api.stop()])
await Promise.all([
() => app.socketPool.stop(),
() => app.db.stop(),
() => app.api.stop(),
() => app.jobs.stop(),
])
logger.log('...Signalboost shut down!')
}
......
......@@ -4,10 +4,26 @@ 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 { values } = require('lodash')
const {
job: { healthcheckInterval, inviteDeletionInterval, recycleInterval, signaldStartupTime },
signal: { diagnosticsPhoneNumber },
} = require('./config')
const cancelations = {
deleteInvitesJob: null,
recycleJob: null,
healtcheckJob: null,
}
const run = async () => {
logger.log('--- Running startup jobs...')
/******************
* ONE-OFF JOBS
*****************/
if (process.env.REREGISTER_ON_STARTUP === '1') {
logger.log('----- Registering phone numbers...')
const regs = await phoneNumberRegistrar.registerAllUnregistered().catch(logger.error)
......@@ -15,24 +31,50 @@ const run = async () => {
}
logger.log('----- Deleting expired sms sender records...')
// here we rely on fact of nightly backups to ensure this task runs once every 24 hr.
// rely on fact of nightly backups to ensure this task runs at least every 24 hr.
const sendersDeleted = await smsSenderRepository.deleteExpired()
logger.log(`----- Deleted ${sendersDeleted} expired sms sender records.`)
logger.log('----- Deleting expired hotline message records...')
// here we rely on fact of nightly backups to ensure this task runs once every 24 hr.
// rely on fact of nightly backups to ensure this task runs at least every 24 hr.
const messageIdsDeleted = await hotlineMessageRepository.deleteExpired()
logger.log(`----- Deleted ${messageIdsDeleted} expired sms sender records.`)
logger.log(`----- Deleted ${messageIdsDeleted} expired hotline records.`)
/******************
* REPEATING JOBS
*****************/
logger.log('----- Launching data cleaning jobs...')
inviteRepository.launchInviteDeletionJob()
logger.log('----- Launched data cleaning jobs.')
logger.log('----- Launching invite scrubbing job...')
cancelations.deleteInvitesJob = util.repeatUntilCancelled(
() => inviteRepository.deleteExpired().catch(logger.error),
inviteDeletionInterval,
)
logger.log('----- Launched invite scrubbing job.')
logger.log('---- Launching recycle request processing job...')
cancelations.recycleJob = util.repeatUntilCancelled(
() => phoneNumberRegistrar.processRecycleRequests().catch(logger.error),
recycleInterval,
)
logger.log('---- Launched recycle request job...')
logger.log('---- Launching healthcheck job...')
diagnostics.launchHealthcheckJob()
const launchHealthchecks = async () => {
await util.wait(signaldStartupTime)
cancelations.healtcheckJob = util.repeatUntilCancelled(
() => diagnostics.sendHealthchecks().catch(logger.error),