diff --git a/functions/src/create-notification.ts b/functions/src/create-notification.ts index a0134634..204105ac 100644 --- a/functions/src/create-notification.ts +++ b/functions/src/create-notification.ts @@ -10,6 +10,7 @@ import { MANIFOLD_AVATAR_URL, MANIFOLD_USER_NAME, MANIFOLD_USER_USERNAME, + PrivateUser, User, } from '../../common/user' import { Contract } from '../../common/contract' @@ -42,21 +43,19 @@ type recipients_to_reason_texts = { [userId: string]: { reason: notification_reason_types } } -export const createNotification = async ( +export const createFollowOrMarketSubsidizedNotification = async ( sourceId: string, - sourceType: 'contract' | 'liquidity' | 'follow', - sourceUpdateType: 'closed' | 'created', + sourceType: 'liquidity' | 'follow', + sourceUpdateType: 'created', sourceUser: User, idempotencyKey: string, sourceText: string, miscData?: { contract?: Contract recipients?: string[] - slug?: string - title?: string } ) => { - const { contract: sourceContract, recipients, slug, title } = miscData ?? {} + const { contract: sourceContract, recipients } = miscData ?? {} const shouldReceiveNotification = ( userId: string, @@ -100,23 +99,15 @@ export const createNotification = async ( sourceContractCreatorUsername: sourceContract?.creatorUsername, sourceContractTitle: sourceContract?.question, sourceContractSlug: sourceContract?.slug, - sourceSlug: slug ? slug : sourceContract?.slug, - sourceTitle: title ? title : sourceContract?.question, + sourceSlug: sourceContract?.slug, + sourceTitle: sourceContract?.question, } await notificationRef.set(removeUndefinedProps(notification)) } if (!sendToEmail) continue - if (reason === 'your_contract_closed' && privateUser && sourceContract) { - // 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') { + if (reason === 'subsidized_your_market') { // TODO: send email to creator of market that was subsidized } else if (reason === 'on_new_follow') { // TODO: send email to user who was followed @@ -133,20 +124,7 @@ export const createNotification = async ( reason: 'on_new_follow', } return await sendNotificationsIfSettingsPermit(userToReasonTexts) - } else if ( - sourceType === 'contract' && - sourceUpdateType === 'closed' && - sourceContract - ) { - userToReasonTexts[sourceContract.creatorId] = { - reason: 'your_contract_closed', - } - return await sendNotificationsIfSettingsPermit(userToReasonTexts) - } else if ( - sourceType === 'liquidity' && - sourceUpdateType === 'created' && - sourceContract - ) { + } else if (sourceType === 'liquidity' && sourceContract) { if (shouldReceiveNotification(sourceContract.creatorId, userToReasonTexts)) userToReasonTexts[sourceContract.creatorId] = { reason: 'subsidized_your_market', @@ -1133,3 +1111,41 @@ export const createBadgeAwardedNotification = async ( // 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 + ) +} diff --git a/functions/src/emails.ts b/functions/src/emails.ts index 31129b71..1b111a9b 100644 --- a/functions/src/emails.ts +++ b/functions/src/emails.ts @@ -301,12 +301,7 @@ export const sendMarketCloseEmail = async ( privateUser: PrivateUser, contract: Contract ) => { - const { sendToEmail, unsubscribeUrl } = getNotificationDestinationsForUser( - privateUser, - reason - ) - - if (!privateUser.email || !sendToEmail) return + if (!privateUser.email) return const { username, name, id: userId } = user const firstName = name.split(' ')[0] @@ -315,6 +310,7 @@ export const sendMarketCloseEmail = async ( 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( privateUser.email, 'Your market has closed', @@ -322,7 +318,7 @@ export const sendMarketCloseEmail = async ( { question, url, - unsubscribeUrl, + unsubscribeUrl: '', userId, name: firstName, volume: formatMoney(volume), diff --git a/functions/src/market-close-notifications.ts b/functions/src/market-close-notifications.ts index 21b52fbc..4e25b493 100644 --- a/functions/src/market-close-notifications.ts +++ b/functions/src/market-close-notifications.ts @@ -3,8 +3,10 @@ import * as admin from 'firebase-admin' import { Contract } from '../../common/contract' 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 .runWith({ secrets: ['MAILGUN_KEY'] }) .pubsub.schedule('every 1 hours') @@ -14,31 +16,31 @@ export const marketCloseNotifications = functions const firestore = admin.firestore() -async function sendMarketCloseEmails() { +export async function sendMarketCloseEmails() { const contracts = await firestore.runTransaction(async (transaction) => { const snap = await transaction.get( firestore.collection('contracts').where('isResolved', '!=', true) ) + const contracts = snap.docs.map((doc) => doc.data() as Contract) + const now = Date.now() + const closeContracts = contracts.filter( + (contract) => + contract.closeTime && + contract.closeTime < now && + shouldSendFirstOrFollowUpCloseNotification(contract) + ) - return snap.docs - .map((doc) => { - const contract = doc.data() as Contract - - if ( - contract.resolution || - (contract.closeEmailsSent ?? 0) >= 1 || - contract.closeTime === undefined || - (contract.closeTime ?? 0) > Date.now() + await Promise.all( + closeContracts.map(async (contract) => { + await transaction.update( + firestore.collection('contracts').doc(contract.id), + { + closeEmailsSent: admin.firestore.FieldValue.increment(1), + } ) - return undefined - - transaction.update(doc.ref, { - closeEmailsSent: (contract.closeEmailsSent ?? 0) + 1, - }) - - return contract }) - .filter((x) => !!x) as Contract[] + ) + return closeContracts }) for (const contract of contracts) { @@ -55,14 +57,38 @@ async function sendMarketCloseEmails() { const privateUser = await getPrivateUser(user.id) if (!privateUser) continue - await createNotification( - contract.id, - 'contract', - 'closed', + await createMarketClosedNotification( + contract, user, - contract.id + '-closed-at-' + contract.closeTime, - contract.closeTime?.toString() ?? new Date().toString(), - { contract } + privateUser, + contract.id + '-closed-at-' + contract.closeTime ) } } +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 + ), + } +} diff --git a/functions/src/on-create-liquidity-provision.ts b/functions/src/on-create-liquidity-provision.ts index 54da7fd9..53f61eaa 100644 --- a/functions/src/on-create-liquidity-provision.ts +++ b/functions/src/on-create-liquidity-provision.ts @@ -1,6 +1,6 @@ import * as functions from 'firebase-functions' import { getContract, getUser, log } from './utils' -import { createNotification } from './create-notification' +import { createFollowOrMarketSubsidizedNotification } from './create-notification' import { LiquidityProvision } from '../../common/liquidity-provision' import { addUserToContractFollowers } from './follow-market' 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') await addUserToContractFollowers(contract.id, liquidityProvider.id) - await createNotification( + await createFollowOrMarketSubsidizedNotification( contract.id, 'liquidity', 'created', diff --git a/functions/src/on-follow-user.ts b/functions/src/on-follow-user.ts index 52042345..2f601f6d 100644 --- a/functions/src/on-follow-user.ts +++ b/functions/src/on-follow-user.ts @@ -2,7 +2,7 @@ import * as functions from 'firebase-functions' import * as admin from 'firebase-admin' import { getUser } from './utils' -import { createNotification } from './create-notification' +import { createFollowOrMarketSubsidizedNotification } from './create-notification' import { FieldValue } from 'firebase-admin/firestore' export const onFollowUser = functions.firestore @@ -23,7 +23,7 @@ export const onFollowUser = functions.firestore followerCountCached: FieldValue.increment(1), }) - await createNotification( + await createFollowOrMarketSubsidizedNotification( followingUser.id, 'follow', 'created', diff --git a/functions/src/test-scheduled-function.ts b/functions/src/test-scheduled-function.ts index c4465703..ed51e5e9 100644 --- a/functions/src/test-scheduled-function.ts +++ b/functions/src/test-scheduled-function.ts @@ -1,6 +1,6 @@ import { APIError, newEndpoint } from './api' 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 export const testscheduledfunction = newEndpoint( @@ -10,7 +10,7 @@ export const testscheduledfunction = newEndpoint( throw new APIError(400, 'This function is only available in dev mode') // Replace your function here - await sendTrendingMarketsEmailsToAllUsers() + await sendMarketCloseEmails() return { success: true } } diff --git a/web/pages/notifications.tsx b/web/pages/notifications.tsx index dd622a72..7388986a 100644 --- a/web/pages/notifications.tsx +++ b/web/pages/notifications.tsx @@ -1002,6 +1002,7 @@ function MarketClosedNotification(props: { isChildOfGroup={isChildOfGroup} highlighted={highlighted} subtitle={'Please resolve'} + hideUserName={true} >