Send market closed notifications every 5 days

This commit is contained in:
Ian Philips 2022-10-11 10:22:38 -06:00
parent d507c4092e
commit 274f7fa849
7 changed files with 109 additions and 70 deletions

View File

@ -10,6 +10,7 @@ import {
MANIFOLD_AVATAR_URL, MANIFOLD_AVATAR_URL,
MANIFOLD_USER_NAME, MANIFOLD_USER_NAME,
MANIFOLD_USER_USERNAME, MANIFOLD_USER_USERNAME,
PrivateUser,
User, User,
} from '../../common/user' } from '../../common/user'
import { Contract } from '../../common/contract' import { Contract } from '../../common/contract'
@ -42,21 +43,19 @@ type recipients_to_reason_texts = {
[userId: string]: { reason: notification_reason_types } [userId: string]: { reason: notification_reason_types }
} }
export const createNotification = async ( export const createFollowOrMarketSubsidizedNotification = async (
sourceId: string, sourceId: string,
sourceType: 'contract' | 'liquidity' | 'follow', sourceType: 'liquidity' | 'follow',
sourceUpdateType: 'closed' | 'created', sourceUpdateType: 'created',
sourceUser: User, sourceUser: User,
idempotencyKey: string, idempotencyKey: string,
sourceText: string, sourceText: string,
miscData?: { miscData?: {
contract?: Contract contract?: Contract
recipients?: string[] recipients?: string[]
slug?: string
title?: string
} }
) => { ) => {
const { contract: sourceContract, recipients, slug, title } = miscData ?? {} const { contract: sourceContract, recipients } = miscData ?? {}
const shouldReceiveNotification = ( const shouldReceiveNotification = (
userId: string, userId: string,
@ -100,23 +99,15 @@ export const createNotification = async (
sourceContractCreatorUsername: sourceContract?.creatorUsername, sourceContractCreatorUsername: sourceContract?.creatorUsername,
sourceContractTitle: sourceContract?.question, sourceContractTitle: sourceContract?.question,
sourceContractSlug: sourceContract?.slug, sourceContractSlug: sourceContract?.slug,
sourceSlug: slug ? slug : sourceContract?.slug, sourceSlug: sourceContract?.slug,
sourceTitle: title ? title : sourceContract?.question, sourceTitle: sourceContract?.question,
} }
await notificationRef.set(removeUndefinedProps(notification)) await notificationRef.set(removeUndefinedProps(notification))
} }
if (!sendToEmail) continue if (!sendToEmail) continue
if (reason === 'your_contract_closed' && privateUser && sourceContract) { if (reason === 'subsidized_your_market') {
// TODO: include number and names of bettors waiting for creator to resolve their market
await sendMarketCloseEmail(
reason,
sourceUser,
privateUser,
sourceContract
)
} else if (reason === 'subsidized_your_market') {
// TODO: send email to creator of market that was subsidized // TODO: send email to creator of market that was subsidized
} else if (reason === 'on_new_follow') { } else if (reason === 'on_new_follow') {
// TODO: send email to user who was followed // TODO: send email to user who was followed
@ -133,20 +124,7 @@ export const createNotification = async (
reason: 'on_new_follow', reason: 'on_new_follow',
} }
return await sendNotificationsIfSettingsPermit(userToReasonTexts) return await sendNotificationsIfSettingsPermit(userToReasonTexts)
} else if ( } else if (sourceType === 'liquidity' && sourceContract) {
sourceType === 'contract' &&
sourceUpdateType === 'closed' &&
sourceContract
) {
userToReasonTexts[sourceContract.creatorId] = {
reason: 'your_contract_closed',
}
return await sendNotificationsIfSettingsPermit(userToReasonTexts)
} else if (
sourceType === 'liquidity' &&
sourceUpdateType === 'created' &&
sourceContract
) {
if (shouldReceiveNotification(sourceContract.creatorId, userToReasonTexts)) if (shouldReceiveNotification(sourceContract.creatorId, userToReasonTexts))
userToReasonTexts[sourceContract.creatorId] = { userToReasonTexts[sourceContract.creatorId] = {
reason: 'subsidized_your_market', reason: 'subsidized_your_market',
@ -1133,3 +1111,41 @@ export const createBadgeAwardedNotification = async (
// TODO send email notification // TODO send email notification
} }
export const createMarketClosedNotification = async (
contract: Contract,
creator: User,
privateUser: PrivateUser,
idempotencyKey: string
) => {
const notificationRef = firestore
.collection(`/users/${creator.id}/notifications`)
.doc(idempotencyKey)
const notification: Notification = {
id: idempotencyKey,
userId: creator.id,
reason: 'your_contract_closed',
createdTime: Date.now(),
isSeen: false,
sourceId: contract.id,
sourceType: 'contract',
sourceUpdateType: 'closed',
sourceContractId: contract?.id,
sourceUserName: creator.name,
sourceUserUsername: creator.username,
sourceUserAvatarUrl: creator.avatarUrl,
sourceText: contract.closeTime?.toString() ?? new Date().toString(),
sourceContractCreatorUsername: creator.username,
sourceContractTitle: contract.question,
sourceContractSlug: contract.slug,
sourceSlug: contract.slug,
sourceTitle: contract.question,
}
await notificationRef.set(removeUndefinedProps(notification))
await sendMarketCloseEmail(
'your_contract_closed',
creator,
privateUser,
contract
)
}

View File

@ -301,12 +301,7 @@ export const sendMarketCloseEmail = async (
privateUser: PrivateUser, privateUser: PrivateUser,
contract: Contract contract: Contract
) => { ) => {
const { sendToEmail, unsubscribeUrl } = getNotificationDestinationsForUser( if (!privateUser.email) return
privateUser,
reason
)
if (!privateUser.email || !sendToEmail) return
const { username, name, id: userId } = user const { username, name, id: userId } = user
const firstName = name.split(' ')[0] const firstName = name.split(' ')[0]
@ -315,6 +310,7 @@ export const sendMarketCloseEmail = async (
const url = `https://${DOMAIN}/${username}/${slug}` const url = `https://${DOMAIN}/${username}/${slug}`
// We ignore if they were able to unsubscribe from market close emails, this is a necessary email
return await sendTemplateEmail( return await sendTemplateEmail(
privateUser.email, privateUser.email,
'Your market has closed', 'Your market has closed',
@ -322,7 +318,7 @@ export const sendMarketCloseEmail = async (
{ {
question, question,
url, url,
unsubscribeUrl, unsubscribeUrl: '',
userId, userId,
name: firstName, name: firstName,
volume: formatMoney(volume), volume: formatMoney(volume),

View File

@ -3,8 +3,10 @@ import * as admin from 'firebase-admin'
import { Contract } from '../../common/contract' import { Contract } from '../../common/contract'
import { getPrivateUser, getUserByUsername } from './utils' import { getPrivateUser, getUserByUsername } from './utils'
import { createNotification } from './create-notification' import { createMarketClosedNotification } from './create-notification'
import { DAY_MS } from '../../common/util/time'
const SEND_NOTIFICATIONS_EVERY_DAYS = 5
export const marketCloseNotifications = functions export const marketCloseNotifications = functions
.runWith({ secrets: ['MAILGUN_KEY'] }) .runWith({ secrets: ['MAILGUN_KEY'] })
.pubsub.schedule('every 1 hours') .pubsub.schedule('every 1 hours')
@ -14,31 +16,31 @@ export const marketCloseNotifications = functions
const firestore = admin.firestore() const firestore = admin.firestore()
async function sendMarketCloseEmails() { export async function sendMarketCloseEmails() {
const contracts = await firestore.runTransaction(async (transaction) => { const contracts = await firestore.runTransaction(async (transaction) => {
const snap = await transaction.get( const snap = await transaction.get(
firestore.collection('contracts').where('isResolved', '!=', true) firestore.collection('contracts').where('isResolved', '!=', true)
) )
const contracts = snap.docs.map((doc) => doc.data() as Contract)
return snap.docs const now = Date.now()
.map((doc) => { const closeContracts = contracts.filter(
const contract = doc.data() as Contract (contract) =>
contract.closeTime &&
if ( contract.closeTime < now &&
contract.resolution || shouldSendFirstOrFollowUpCloseNotification(contract)
(contract.closeEmailsSent ?? 0) >= 1 ||
contract.closeTime === undefined ||
(contract.closeTime ?? 0) > Date.now()
) )
return undefined
transaction.update(doc.ref, { await Promise.all(
closeEmailsSent: (contract.closeEmailsSent ?? 0) + 1, closeContracts.map(async (contract) => {
await transaction.update(
firestore.collection('contracts').doc(contract.id),
{
closeEmailsSent: admin.firestore.FieldValue.increment(1),
}
)
}) })
)
return contract return closeContracts
})
.filter((x) => !!x) as Contract[]
}) })
for (const contract of contracts) { for (const contract of contracts) {
@ -55,14 +57,38 @@ async function sendMarketCloseEmails() {
const privateUser = await getPrivateUser(user.id) const privateUser = await getPrivateUser(user.id)
if (!privateUser) continue if (!privateUser) continue
await createNotification( await createMarketClosedNotification(
contract.id, contract,
'contract',
'closed',
user, user,
contract.id + '-closed-at-' + contract.closeTime, privateUser,
contract.closeTime?.toString() ?? new Date().toString(), contract.id + '-closed-at-' + contract.closeTime
{ contract }
) )
} }
} }
function shouldSendFirstOrFollowUpCloseNotification(contract: Contract) {
if (!contract.closeEmailsSent || contract.closeEmailsSent === 0) return true
const { closedMultipleOfNDaysAgo, fullTimePeriodsSinceClose } =
marketClosedMultipleOfNDaysAgo(contract)
// Sends another notification if it's been a multiple of N days since the market closed AND
// the number of close notifications we've sent is equal to the number of time periods since the market closed
return (
contract.closeEmailsSent > 0 &&
closedMultipleOfNDaysAgo &&
contract.closeEmailsSent === fullTimePeriodsSinceClose
)
}
function marketClosedMultipleOfNDaysAgo(contract: Contract) {
const now = Date.now()
const closeTime = contract.closeTime
if (!closeTime)
return { closedMultipleOfNDaysAgo: false, fullTimePeriodsSinceClose: 0 }
const daysSinceClose = Math.floor((now - closeTime) / DAY_MS)
return {
closedMultipleOfNDaysAgo:
daysSinceClose % SEND_NOTIFICATIONS_EVERY_DAYS == 0,
fullTimePeriodsSinceClose: Math.floor(
daysSinceClose / SEND_NOTIFICATIONS_EVERY_DAYS
),
}
}

View File

@ -1,6 +1,6 @@
import * as functions from 'firebase-functions' import * as functions from 'firebase-functions'
import { getContract, getUser, log } from './utils' import { getContract, getUser, log } from './utils'
import { createNotification } from './create-notification' import { createFollowOrMarketSubsidizedNotification } from './create-notification'
import { LiquidityProvision } from '../../common/liquidity-provision' import { LiquidityProvision } from '../../common/liquidity-provision'
import { addUserToContractFollowers } from './follow-market' import { addUserToContractFollowers } from './follow-market'
import { FIXED_ANTE } from '../../common/economy' import { FIXED_ANTE } from '../../common/economy'
@ -36,7 +36,7 @@ export const onCreateLiquidityProvision = functions.firestore
if (!liquidityProvider) throw new Error('Could not find liquidity provider') if (!liquidityProvider) throw new Error('Could not find liquidity provider')
await addUserToContractFollowers(contract.id, liquidityProvider.id) await addUserToContractFollowers(contract.id, liquidityProvider.id)
await createNotification( await createFollowOrMarketSubsidizedNotification(
contract.id, contract.id,
'liquidity', 'liquidity',
'created', 'created',

View File

@ -2,7 +2,7 @@ import * as functions from 'firebase-functions'
import * as admin from 'firebase-admin' import * as admin from 'firebase-admin'
import { getUser } from './utils' import { getUser } from './utils'
import { createNotification } from './create-notification' import { createFollowOrMarketSubsidizedNotification } from './create-notification'
import { FieldValue } from 'firebase-admin/firestore' import { FieldValue } from 'firebase-admin/firestore'
export const onFollowUser = functions.firestore export const onFollowUser = functions.firestore
@ -23,7 +23,7 @@ export const onFollowUser = functions.firestore
followerCountCached: FieldValue.increment(1), followerCountCached: FieldValue.increment(1),
}) })
await createNotification( await createFollowOrMarketSubsidizedNotification(
followingUser.id, followingUser.id,
'follow', 'follow',
'created', 'created',

View File

@ -1,6 +1,6 @@
import { APIError, newEndpoint } from './api' import { APIError, newEndpoint } from './api'
import { isProd } from './utils' import { isProd } from './utils'
import { sendTrendingMarketsEmailsToAllUsers } from 'functions/src/weekly-markets-emails' import { sendMarketCloseEmails } from 'functions/src/market-close-notifications'
// Function for testing scheduled functions locally // Function for testing scheduled functions locally
export const testscheduledfunction = newEndpoint( export const testscheduledfunction = newEndpoint(
@ -10,7 +10,7 @@ export const testscheduledfunction = newEndpoint(
throw new APIError(400, 'This function is only available in dev mode') throw new APIError(400, 'This function is only available in dev mode')
// Replace your function here // Replace your function here
await sendTrendingMarketsEmailsToAllUsers() await sendMarketCloseEmails()
return { success: true } return { success: true }
} }

View File

@ -1002,6 +1002,7 @@ function MarketClosedNotification(props: {
isChildOfGroup={isChildOfGroup} isChildOfGroup={isChildOfGroup}
highlighted={highlighted} highlighted={highlighted}
subtitle={'Please resolve'} subtitle={'Please resolve'}
hideUserName={true}
> >
<Row> <Row>
<span> <span>