Allow to follow/unfollow markets, backfill as well

This commit is contained in:
Ian Philips 2022-08-23 16:29:19 -06:00
parent bea94d58c5
commit 89644502a5
16 changed files with 482 additions and 180 deletions

View File

@ -57,6 +57,7 @@ export type Contract<T extends AnyContractType = AnyContractType> = {
uniqueBettorIds?: string[] uniqueBettorIds?: string[]
uniqueBettorCount?: number uniqueBettorCount?: number
popularityScore?: number popularityScore?: number
followerCount?: number
} & T } & T
export type BinaryContract = Contract & Binary export type BinaryContract = Contract & Binary

View File

@ -70,3 +70,4 @@ export type notification_reason_types =
| 'challenge_accepted' | 'challenge_accepted'
| 'betting_streak_incremented' | 'betting_streak_incremented'
| 'loan_income' | 'loan_income'
| 'you_follow_contract'

View File

@ -44,6 +44,11 @@ service cloud.firestore {
allow read; allow read;
} }
match /contracts/{contractId}/follows/{userId} {
allow read;
allow create, delete: if userId == request.auth.uid;
}
match /contracts/{contractId}/challenges/{challengeId}{ match /contracts/{contractId}/challenges/{challengeId}{
allow read; allow read;
allow create: if request.auth.uid == request.resource.data.creatorId; allow create: if request.auth.uid == request.resource.data.creatorId;

View File

@ -13,7 +13,7 @@
"deploy": "firebase deploy --only functions", "deploy": "firebase deploy --only functions",
"logs": "firebase functions:log", "logs": "firebase functions:log",
"dev": "nodemon src/serve.ts", "dev": "nodemon src/serve.ts",
"firestore": "firebase emulators:start --only firestore --import=./firestore_export", "firestore": "firebase emulators:start --only firestore,pubsub --import=./firestore_export",
"serve": "firebase use dev && yarn build && firebase emulators:start --only functions,firestore,pubsub --import=./firestore_export", "serve": "firebase use dev && yarn build && firebase emulators:start --only functions,firestore,pubsub --import=./firestore_export",
"db:update-local-from-remote": "yarn db:backup-remote && gsutil rsync -r gs://$npm_package_config_firestore/firestore_export ./firestore_export", "db:update-local-from-remote": "yarn db:backup-remote && gsutil rsync -r gs://$npm_package_config_firestore/firestore_export ./firestore_export",
"db:backup-local": "firebase emulators:export --force ./firestore_export", "db:backup-local": "firebase emulators:export --force ./firestore_export",

View File

@ -7,7 +7,7 @@ import {
} from '../../common/notification' } from '../../common/notification'
import { User } from '../../common/user' import { User } from '../../common/user'
import { Contract } from '../../common/contract' import { Contract } from '../../common/contract'
import { getValues } from './utils' import { getValues, log } from './utils'
import { Comment } from '../../common/comment' import { Comment } from '../../common/comment'
import { uniq } from 'lodash' import { uniq } from 'lodash'
import { Bet, LimitBet } from '../../common/bet' import { Bet, LimitBet } from '../../common/bet'
@ -33,19 +33,12 @@ export const createNotification = async (
sourceText: string, sourceText: string,
miscData?: { miscData?: {
contract?: Contract contract?: Contract
relatedSourceType?: notification_source_types
recipients?: string[] recipients?: string[]
slug?: string slug?: string
title?: string title?: string
} }
) => { ) => {
const { const { contract: sourceContract, recipients, slug, title } = miscData ?? {}
contract: sourceContract,
relatedSourceType,
recipients,
slug,
title,
} = miscData ?? {}
const shouldGetNotification = ( const shouldGetNotification = (
userId: string, userId: string,
@ -90,24 +83,6 @@ export const createNotification = async (
) )
} }
const notifyLiquidityProviders = async (
userToReasonTexts: user_to_reason_texts,
contract: Contract
) => {
const liquidityProviders = await firestore
.collection(`contracts/${contract.id}/liquidity`)
.get()
const liquidityProvidersIds = uniq(
liquidityProviders.docs.map((doc) => doc.data().userId)
)
liquidityProvidersIds.forEach((userId) => {
if (!shouldGetNotification(userId, userToReasonTexts)) return
userToReasonTexts[userId] = {
reason: 'on_contract_with_users_shares_in',
}
})
}
const notifyUsersFollowers = async ( const notifyUsersFollowers = async (
userToReasonTexts: user_to_reason_texts userToReasonTexts: user_to_reason_texts
) => { ) => {
@ -129,23 +104,6 @@ export const createNotification = async (
}) })
} }
const notifyRepliedUser = (
userToReasonTexts: user_to_reason_texts,
relatedUserId: string,
relatedSourceType: notification_source_types
) => {
if (!shouldGetNotification(relatedUserId, userToReasonTexts)) return
if (relatedSourceType === 'comment') {
userToReasonTexts[relatedUserId] = {
reason: 'reply_to_users_comment',
}
} else if (relatedSourceType === 'answer') {
userToReasonTexts[relatedUserId] = {
reason: 'reply_to_users_answer',
}
}
}
const notifyFollowedUser = ( const notifyFollowedUser = (
userToReasonTexts: user_to_reason_texts, userToReasonTexts: user_to_reason_texts,
followedUserId: string followedUserId: string
@ -182,71 +140,6 @@ export const createNotification = async (
} }
} }
const notifyOtherAnswerersOnContract = async (
userToReasonTexts: user_to_reason_texts,
sourceContract: Contract
) => {
const answers = await getValues<Answer>(
firestore
.collection('contracts')
.doc(sourceContract.id)
.collection('answers')
)
const recipientUserIds = uniq(answers.map((answer) => answer.userId))
recipientUserIds.forEach((userId) => {
if (shouldGetNotification(userId, userToReasonTexts))
userToReasonTexts[userId] = {
reason: 'on_contract_with_users_answer',
}
})
}
const notifyOtherCommentersOnContract = async (
userToReasonTexts: user_to_reason_texts,
sourceContract: Contract
) => {
const comments = await getValues<Comment>(
firestore
.collection('contracts')
.doc(sourceContract.id)
.collection('comments')
)
const recipientUserIds = uniq(comments.map((comment) => comment.userId))
recipientUserIds.forEach((userId) => {
if (shouldGetNotification(userId, userToReasonTexts))
userToReasonTexts[userId] = {
reason: 'on_contract_with_users_comment',
}
})
}
const notifyBettorsOnContract = async (
userToReasonTexts: user_to_reason_texts,
sourceContract: Contract
) => {
const betsSnap = await firestore
.collection(`contracts/${sourceContract.id}/bets`)
.get()
const bets = betsSnap.docs.map((doc) => doc.data() as Bet)
// filter bets for only users that have an amount invested still
const recipientUserIds = uniq(bets.map((bet) => bet.userId)).filter(
(userId) => {
return (
getContractBetMetrics(
sourceContract,
bets.filter((bet) => bet.userId === userId)
).invested > 0
)
}
)
recipientUserIds.forEach((userId) => {
if (shouldGetNotification(userId, userToReasonTexts))
userToReasonTexts[userId] = {
reason: 'on_contract_with_users_shares_in',
}
})
}
const notifyUserAddedToGroup = ( const notifyUserAddedToGroup = (
userToReasonTexts: user_to_reason_texts, userToReasonTexts: user_to_reason_texts,
relatedUserId: string relatedUserId: string
@ -266,58 +159,289 @@ export const createNotification = async (
} }
} }
const getUsersToNotify = async () => { const userToReasonTexts: user_to_reason_texts = {}
const userToReasonTexts: user_to_reason_texts = {} // The following functions modify the userToReasonTexts object in place.
// The following functions modify the userToReasonTexts object in place.
if (sourceType === 'follow' && recipients?.[0]) {
notifyFollowedUser(userToReasonTexts, recipients[0])
} else if (
sourceType === 'group' &&
sourceUpdateType === 'created' &&
recipients
) {
recipients.forEach((r) => notifyUserAddedToGroup(userToReasonTexts, r))
}
// The following functions need sourceContract to be defined. if (sourceType === 'follow' && recipients?.[0]) {
if (!sourceContract) return userToReasonTexts notifyFollowedUser(userToReasonTexts, recipients[0])
} else if (
if ( sourceType === 'group' &&
sourceType === 'comment' || sourceUpdateType === 'created' &&
sourceType === 'answer' || recipients
(sourceType === 'contract' && ) {
(sourceUpdateType === 'updated' || sourceUpdateType === 'resolved')) recipients.forEach((r) => notifyUserAddedToGroup(userToReasonTexts, r))
) { } else if (
if (sourceType === 'comment') { sourceType === 'contract' &&
if (recipients?.[0] && relatedSourceType) sourceUpdateType === 'created' &&
notifyRepliedUser(userToReasonTexts, recipients[0], relatedSourceType) sourceContract
if (sourceText) notifyTaggedUsers(userToReasonTexts, recipients ?? []) ) {
} await notifyUsersFollowers(userToReasonTexts)
await notifyContractCreator(userToReasonTexts, sourceContract) notifyTaggedUsers(userToReasonTexts, recipients ?? [])
await notifyOtherAnswerersOnContract(userToReasonTexts, sourceContract) } else if (
await notifyLiquidityProviders(userToReasonTexts, sourceContract) sourceType === 'contract' &&
await notifyBettorsOnContract(userToReasonTexts, sourceContract) sourceUpdateType === 'closed' &&
await notifyOtherCommentersOnContract(userToReasonTexts, sourceContract) sourceContract
} else if (sourceType === 'contract' && sourceUpdateType === 'created') { ) {
await notifyUsersFollowers(userToReasonTexts) await notifyContractCreator(userToReasonTexts, sourceContract, {
notifyTaggedUsers(userToReasonTexts, recipients ?? []) force: true,
} else if (sourceType === 'contract' && sourceUpdateType === 'closed') { })
await notifyContractCreator(userToReasonTexts, sourceContract, { } else if (
force: true, sourceType === 'liquidity' &&
}) sourceUpdateType === 'created' &&
} else if (sourceType === 'liquidity' && sourceUpdateType === 'created') { sourceContract
await notifyContractCreator(userToReasonTexts, sourceContract) ) {
} else if (sourceType === 'bonus' && sourceUpdateType === 'created') { await notifyContractCreator(userToReasonTexts, sourceContract)
// Note: the daily bonus won't have a contract attached to it } else if (
await notifyContractCreatorOfUniqueBettorsBonus( sourceType === 'bonus' &&
userToReasonTexts, sourceUpdateType === 'created' &&
sourceContract.creatorId sourceContract
) ) {
} // Note: the daily bonus won't have a contract attached to it
return userToReasonTexts await notifyContractCreatorOfUniqueBettorsBonus(
userToReasonTexts,
sourceContract.creatorId
)
} }
const userToReasonTexts = await getUsersToNotify() await createUsersNotifications(userToReasonTexts)
}
export const createCommentOrAnswerOrUpdatedContractNotification = async (
sourceId: string,
sourceType: notification_source_types,
sourceUpdateType: notification_source_update_types,
sourceUser: User,
idempotencyKey: string,
sourceText: string,
sourceContract: Contract,
miscData?: {
relatedSourceType?: notification_source_types
repliedUserId?: string
taggedUserIds?: string[]
}
) => {
const { relatedSourceType, repliedUserId, taggedUserIds } = miscData ?? {}
const createUsersNotifications = async (
userToReasonTexts: user_to_reason_texts
) => {
await Promise.all(
Object.keys(userToReasonTexts).map(async (userId) => {
const notificationRef = firestore
.collection(`/users/${userId}/notifications`)
.doc(idempotencyKey)
const notification: Notification = {
id: idempotencyKey,
userId,
reason: userToReasonTexts[userId].reason,
createdTime: Date.now(),
isSeen: false,
sourceId,
sourceType,
sourceUpdateType,
sourceContractId: sourceContract.id,
sourceUserName: sourceUser.name,
sourceUserUsername: sourceUser.username,
sourceUserAvatarUrl: sourceUser.avatarUrl,
sourceText,
sourceContractCreatorUsername: sourceContract.creatorUsername,
sourceContractTitle: sourceContract.question,
sourceContractSlug: sourceContract.slug,
sourceSlug: sourceContract.slug,
sourceTitle: sourceContract.question,
}
await notificationRef.set(removeUndefinedProps(notification))
})
)
}
// get contract follower documents and check here if they're a follower
const contractFollowersSnap = await firestore
.collection(`contracts/${sourceContract.id}/follows`)
.get()
const contractFollowersIds = contractFollowersSnap.docs.map(
(doc) => doc.data().id
)
log('contractFollowerIds', contractFollowersIds)
const stillFollowingContract = (userId: string) => {
return contractFollowersIds.includes(userId)
}
const shouldGetNotification = (
userId: string,
userToReasonTexts: user_to_reason_texts
) => {
return (
sourceUser.id != userId &&
!Object.keys(userToReasonTexts).includes(userId)
)
}
const notifyContractFollowers = async (
userToReasonTexts: user_to_reason_texts
) => {
for (const userId of contractFollowersIds) {
if (shouldGetNotification(userId, userToReasonTexts))
userToReasonTexts[userId] = {
reason: 'you_follow_contract',
}
}
}
const notifyContractCreator = async (
userToReasonTexts: user_to_reason_texts
) => {
if (
shouldGetNotification(sourceContract.creatorId, userToReasonTexts) &&
stillFollowingContract(sourceContract.creatorId)
)
userToReasonTexts[sourceContract.creatorId] = {
reason: 'on_users_contract',
}
}
const notifyOtherAnswerersOnContract = async (
userToReasonTexts: user_to_reason_texts
) => {
const answers = await getValues<Answer>(
firestore
.collection('contracts')
.doc(sourceContract.id)
.collection('answers')
)
const recipientUserIds = uniq(answers.map((answer) => answer.userId))
recipientUserIds.forEach((userId) => {
if (
shouldGetNotification(userId, userToReasonTexts) &&
stillFollowingContract(userId)
)
userToReasonTexts[userId] = {
reason: 'on_contract_with_users_answer',
}
})
}
const notifyOtherCommentersOnContract = async (
userToReasonTexts: user_to_reason_texts
) => {
const comments = await getValues<Comment>(
firestore
.collection('contracts')
.doc(sourceContract.id)
.collection('comments')
)
const recipientUserIds = uniq(comments.map((comment) => comment.userId))
recipientUserIds.forEach((userId) => {
if (
shouldGetNotification(userId, userToReasonTexts) &&
stillFollowingContract(userId)
)
userToReasonTexts[userId] = {
reason: 'on_contract_with_users_comment',
}
})
}
const notifyBettorsOnContract = async (
userToReasonTexts: user_to_reason_texts
) => {
const betsSnap = await firestore
.collection(`contracts/${sourceContract.id}/bets`)
.get()
const bets = betsSnap.docs.map((doc) => doc.data() as Bet)
// filter bets for only users that have an amount invested still
const recipientUserIds = uniq(bets.map((bet) => bet.userId)).filter(
(userId) => {
return (
getContractBetMetrics(
sourceContract,
bets.filter((bet) => bet.userId === userId)
).invested > 0
)
}
)
recipientUserIds.forEach((userId) => {
if (
shouldGetNotification(userId, userToReasonTexts) &&
stillFollowingContract(userId)
)
userToReasonTexts[userId] = {
reason: 'on_contract_with_users_shares_in',
}
})
}
const notifyRepliedUser = (
userToReasonTexts: user_to_reason_texts,
relatedUserId: string,
relatedSourceType: notification_source_types
) => {
if (
shouldGetNotification(relatedUserId, userToReasonTexts) &&
stillFollowingContract(relatedUserId)
) {
if (relatedSourceType === 'comment') {
userToReasonTexts[relatedUserId] = {
reason: 'reply_to_users_comment',
}
} else if (relatedSourceType === 'answer') {
userToReasonTexts[relatedUserId] = {
reason: 'reply_to_users_answer',
}
}
}
}
const notifyTaggedUsers = (
userToReasonTexts: user_to_reason_texts,
userIds: (string | undefined)[]
) => {
userIds.forEach((id) => {
console.log('tagged user: ', id)
// Allowing non-following users to get tagged
if (id && shouldGetNotification(id, userToReasonTexts))
userToReasonTexts[id] = {
reason: 'tagged_user',
}
})
}
const notifyLiquidityProviders = async (
userToReasonTexts: user_to_reason_texts
) => {
const liquidityProviders = await firestore
.collection(`contracts/${sourceContract.id}/liquidity`)
.get()
const liquidityProvidersIds = uniq(
liquidityProviders.docs.map((doc) => doc.data().userId)
)
liquidityProvidersIds.forEach((userId) => {
if (
shouldGetNotification(userId, userToReasonTexts) &&
stillFollowingContract(userId)
) {
userToReasonTexts[userId] = {
reason: 'on_contract_with_users_shares_in',
}
}
})
}
const userToReasonTexts: user_to_reason_texts = {}
if (sourceType === 'comment') {
if (repliedUserId && relatedSourceType)
notifyRepliedUser(userToReasonTexts, repliedUserId, relatedSourceType)
if (sourceText) notifyTaggedUsers(userToReasonTexts, taggedUserIds ?? [])
}
await notifyContractCreator(userToReasonTexts)
await notifyOtherAnswerersOnContract(userToReasonTexts)
await notifyLiquidityProviders(userToReasonTexts)
await notifyBettorsOnContract(userToReasonTexts)
await notifyOtherCommentersOnContract(userToReasonTexts)
// if they weren't added previously, add them now
await notifyContractFollowers(userToReasonTexts)
await createUsersNotifications(userToReasonTexts) await createUsersNotifications(userToReasonTexts)
} }

View File

@ -0,0 +1,30 @@
import { Contract } from '../../common/lib/contract'
import { User } from '../../common/lib/user'
import * as admin from 'firebase-admin'
import { FieldValue } from 'firebase-admin/firestore'
const firestore = admin.firestore()
export const addUserToContractFollowers = async (
contract: Contract,
user: User
) => {
const followerDoc = await firestore
.collection(`contracts/${contract.id}/follows`)
.doc(user.id)
.get()
if (followerDoc.exists) return
await firestore
.collection(`contracts/${contract.id}/follows`)
.doc(user.id)
.set({
id: user.id,
createdTime: Date.now(),
})
await firestore
.collection(`contracts`)
.doc(contract.id)
.update({
followerCount: FieldValue.increment(1),
})
}

View File

@ -1,7 +1,8 @@
import * as functions from 'firebase-functions' import * as functions from 'firebase-functions'
import { getContract, getUser } from './utils' import { getContract, getUser } from './utils'
import { createNotification } from './create-notification' import { createCommentOrAnswerOrUpdatedContractNotification } from './create-notification'
import { Answer } from '../../common/answer' import { Answer } from '../../common/answer'
import { addUserToContractFollowers } from './follow-market'
export const onCreateAnswer = functions.firestore export const onCreateAnswer = functions.firestore
.document('contracts/{contractId}/answers/{answerNumber}') .document('contracts/{contractId}/answers/{answerNumber}')
@ -20,14 +21,14 @@ export const onCreateAnswer = functions.firestore
const answerCreator = await getUser(answer.userId) const answerCreator = await getUser(answer.userId)
if (!answerCreator) throw new Error('Could not find answer creator') if (!answerCreator) throw new Error('Could not find answer creator')
await addUserToContractFollowers(contract, answerCreator)
await createNotification( await createCommentOrAnswerOrUpdatedContractNotification(
answer.id, answer.id,
'answer', 'answer',
'created', 'created',
answerCreator, answerCreator,
eventId, eventId,
answer.text, answer.text,
{ contract } contract
) )
}) })

View File

@ -24,6 +24,7 @@ import {
} from '../../common/antes' } from '../../common/antes'
import { APIError } from '../../common/api' import { APIError } from '../../common/api'
import { User } from '../../common/user' import { User } from '../../common/user'
import { addUserToContractFollowers } from './follow-market'
const firestore = admin.firestore() const firestore = admin.firestore()
const BONUS_START_DATE = new Date('2022-07-13T15:30:00.000Z').getTime() const BONUS_START_DATE = new Date('2022-07-13T15:30:00.000Z').getTime()
@ -54,14 +55,13 @@ export const onCreateBet = functions.firestore
log(`Could not find contract ${contractId}`) log(`Could not find contract ${contractId}`)
return return
} }
await updateUniqueBettorsAndGiveCreatorBonus(contract, eventId, bet.userId)
const bettor = await getUser(bet.userId) const bettor = await getUser(bet.userId)
if (!bettor) return if (!bettor) return
await addUserToContractFollowers(contract, bettor)
await updateUniqueBettorsAndGiveCreatorBonus(contract, eventId, bet.userId)
await notifyFills(bet, contract, eventId, bettor) await notifyFills(bet, contract, eventId, bettor)
await updateBettingStreak(bettor, bet, contract, eventId) await updateBettingStreak(bettor, bet, contract, eventId)
await firestore.collection('users').doc(bettor.id).update({ lastBetTime }) await firestore.collection('users').doc(bettor.id).update({ lastBetTime })
}) })

View File

@ -6,8 +6,9 @@ import { ContractComment } from '../../common/comment'
import { sendNewCommentEmail } from './emails' import { sendNewCommentEmail } from './emails'
import { Bet } from '../../common/bet' import { Bet } from '../../common/bet'
import { Answer } from '../../common/answer' import { Answer } from '../../common/answer'
import { createNotification } from './create-notification' import { createCommentOrAnswerOrUpdatedContractNotification } from './create-notification'
import { parseMentions, richTextToString } from '../../common/util/parse' import { parseMentions, richTextToString } from '../../common/util/parse'
import { addUserToContractFollowers } from './follow-market'
const firestore = admin.firestore() const firestore = admin.firestore()
@ -35,6 +36,8 @@ export const onCreateCommentOnContract = functions
const commentCreator = await getUser(comment.userId) const commentCreator = await getUser(comment.userId)
if (!commentCreator) throw new Error('Could not find comment creator') if (!commentCreator) throw new Error('Could not find comment creator')
await addUserToContractFollowers(contract, commentCreator)
await firestore await firestore
.collection('contracts') .collection('contracts')
.doc(contract.id) .doc(contract.id)
@ -77,18 +80,19 @@ export const onCreateCommentOnContract = functions
? comments.find((c) => c.id === comment.replyToCommentId)?.userId ? comments.find((c) => c.id === comment.replyToCommentId)?.userId
: answer?.userId : answer?.userId
const recipients = uniq( await createCommentOrAnswerOrUpdatedContractNotification(
compact([...parseMentions(comment.content), repliedUserId])
)
await createNotification(
comment.id, comment.id,
'comment', 'comment',
'created', 'created',
commentCreator, commentCreator,
eventId, eventId,
richTextToString(comment.content), richTextToString(comment.content),
{ contract, relatedSourceType, recipients } contract,
{
relatedSourceType,
repliedUserId,
taggedUserIds: compact(parseMentions(comment.content)),
}
) )
const recipientUserIds = uniq([ const recipientUserIds = uniq([

View File

@ -5,6 +5,7 @@ import { createNotification } from './create-notification'
import { Contract } from '../../common/contract' import { Contract } from '../../common/contract'
import { parseMentions, richTextToString } from '../../common/util/parse' import { parseMentions, richTextToString } from '../../common/util/parse'
import { JSONContent } from '@tiptap/core' import { JSONContent } from '@tiptap/core'
import { addUserToContractFollowers } from './follow-market'
export const onCreateContract = functions export const onCreateContract = functions
.runWith({ secrets: ['MAILGUN_KEY'] }) .runWith({ secrets: ['MAILGUN_KEY'] })
@ -18,6 +19,7 @@ export const onCreateContract = functions
const desc = contract.description as JSONContent const desc = contract.description as JSONContent
const mentioned = parseMentions(desc) const mentioned = parseMentions(desc)
await addUserToContractFollowers(contract, contractCreator)
await createNotification( await createNotification(
contract.id, contract.id,

View File

@ -2,6 +2,7 @@ import * as functions from 'firebase-functions'
import { getContract, getUser } from './utils' import { getContract, getUser } from './utils'
import { createNotification } from './create-notification' import { createNotification } from './create-notification'
import { LiquidityProvision } from 'common/liquidity-provision' import { LiquidityProvision } from 'common/liquidity-provision'
import { addUserToContractFollowers } from './follow-market'
export const onCreateLiquidityProvision = functions.firestore export const onCreateLiquidityProvision = functions.firestore
.document('contracts/{contractId}/liquidity/{liquidityId}') .document('contracts/{contractId}/liquidity/{liquidityId}')
@ -18,6 +19,7 @@ export const onCreateLiquidityProvision = functions.firestore
const liquidityProvider = await getUser(liquidity.userId) const liquidityProvider = await getUser(liquidity.userId)
if (!liquidityProvider) throw new Error('Could not find liquidity provider') if (!liquidityProvider) throw new Error('Could not find liquidity provider')
await addUserToContractFollowers(contract, liquidityProvider)
await createNotification( await createNotification(
contract.id, contract.id,

View File

@ -1,6 +1,6 @@
import * as functions from 'firebase-functions' import * as functions from 'firebase-functions'
import { getUser } from './utils' import { getUser } from './utils'
import { createNotification } from './create-notification' import { createCommentOrAnswerOrUpdatedContractNotification } from './create-notification'
import { Contract } from '../../common/contract' import { Contract } from '../../common/contract'
export const onUpdateContract = functions.firestore export const onUpdateContract = functions.firestore
@ -29,14 +29,14 @@ export const onUpdateContract = functions.firestore
resolutionText = `${contract.resolutionValue}` resolutionText = `${contract.resolutionValue}`
} }
await createNotification( await createCommentOrAnswerOrUpdatedContractNotification(
contract.id, contract.id,
'contract', 'contract',
'resolved', 'resolved',
contractUpdater, contractUpdater,
eventId, eventId,
resolutionText, resolutionText,
{ contract } contract
) )
} else if ( } else if (
previousValue.closeTime !== contract.closeTime || previousValue.closeTime !== contract.closeTime ||
@ -52,14 +52,14 @@ export const onUpdateContract = functions.firestore
sourceText = contract.question sourceText = contract.question
} }
await createNotification( await createCommentOrAnswerOrUpdatedContractNotification(
contract.id, contract.id,
'contract', 'contract',
'updated', 'updated',
contractUpdater, contractUpdater,
eventId, eventId,
sourceText, sourceText,
{ contract } contract
) )
} }
}) })

View File

@ -0,0 +1,74 @@
import * as admin from 'firebase-admin'
import { initAdmin } from './script-init'
initAdmin()
import { getValues } from '../utils'
import { Contract } from 'common/lib/contract'
import { Comment } from 'common/lib/comment'
import { uniq } from 'lodash'
import { Bet } from 'common/lib/bet'
import {
DEV_HOUSE_LIQUIDITY_PROVIDER_ID,
HOUSE_LIQUIDITY_PROVIDER_ID,
} from 'common/lib/antes'
const firestore = admin.firestore()
async function backfillContractFollowers() {
console.log('Backfilling contract followers')
const contracts = await getValues<Contract>(
firestore.collection('contracts').where('isResolved', '==', false)
)
let count = 0
for (const contract of contracts) {
const comments = await getValues<Comment>(
firestore.collection('contracts').doc(contract.id).collection('comments')
)
const commenterIds = uniq(comments.map((comment) => comment.userId))
const betsSnap = await firestore
.collection(`contracts/${contract.id}/bets`)
.get()
const bets = betsSnap.docs.map((doc) => doc.data() as Bet)
// filter bets for only users that have an amount invested still
const bettorIds = uniq(bets.map((bet) => bet.userId))
const liquidityProviders = await firestore
.collection(`contracts/${contract.id}/liquidity`)
.get()
const liquidityProvidersIds = uniq(
liquidityProviders.docs.map((doc) => doc.data().userId)
// exclude free market liquidity provider
).filter(
(id) =>
id !== HOUSE_LIQUIDITY_PROVIDER_ID ||
id !== DEV_HOUSE_LIQUIDITY_PROVIDER_ID
)
const followerIds = uniq([
...commenterIds,
...bettorIds,
...liquidityProvidersIds,
contract.creatorId,
])
for (const followerId of followerIds) {
await firestore
.collection(`contracts/${contract.id}/follows`)
.doc(followerId)
.set({ id: followerId, createdTime: Date.now() })
}
const followerCount = followerIds.length
await firestore
.collection(`contracts`)
.doc(contract.id)
.update({ followerCount: followerCount })
count += 1
if (count % 100 === 0) {
console.log(`${count} contracts processed`)
}
}
}
if (require.main === module) {
backfillContractFollowers()
.then(() => process.exit())
.catch(console.log)
}

View File

@ -1,8 +1,8 @@
import clsx from 'clsx' import clsx from 'clsx'
import { ShareIcon } from '@heroicons/react/outline' import { EyeIcon, EyeOffIcon, ShareIcon } from '@heroicons/react/outline'
import { Row } from '../layout/row' import { Row } from '../layout/row'
import { Contract } from 'web/lib/firebase/contracts' import { Contract, contracts } from 'web/lib/firebase/contracts'
import { useState } from 'react' import { useState } from 'react'
import { Button } from 'web/components/button' import { Button } from 'web/components/button'
import { CreateChallengeModal } from '../challenges/create-challenge-modal' import { CreateChallengeModal } from '../challenges/create-challenge-modal'
@ -10,6 +10,9 @@ import { User } from 'common/user'
import { CHALLENGES_ENABLED } from 'common/challenge' import { CHALLENGES_ENABLED } from 'common/challenge'
import { ShareModal } from './share-modal' import { ShareModal } from './share-modal'
import { withTracking } from 'web/lib/service/analytics' import { withTracking } from 'web/lib/service/analytics'
import { collection, deleteDoc, doc } from 'firebase/firestore'
import { users } from 'web/lib/firebase/users'
import { useContractFollows } from 'web/hooks/use-follows'
export function ShareRow(props: { export function ShareRow(props: {
contract: Contract contract: Contract
@ -23,6 +26,7 @@ export function ShareRow(props: {
const [isOpen, setIsOpen] = useState(false) const [isOpen, setIsOpen] = useState(false)
const [isShareOpen, setShareOpen] = useState(false) const [isShareOpen, setShareOpen] = useState(false)
const followers = useContractFollows(contract.id)
return ( return (
<Row className="mt-2"> <Row className="mt-2">
@ -62,6 +66,38 @@ export function ShareRow(props: {
/> />
</Button> </Button>
)} )}
{user && (
<Button
size={'lg'}
color={'gray-white'}
onClick={async () => {
// remove user doc from contract follows collection
const followDoc = doc(
collection(contracts, contract.id, 'follows'),
user.id
)
await deleteDoc(followDoc)
}}
>
{followers?.includes(user.id) ? (
<Row>
<EyeOffIcon
className={clsx('mr-2 h-[24px] w-5')}
aria-hidden="true"
/>
Unfollow
</Row>
) : (
<Row>
<EyeIcon
className={clsx('mr-2 h-[24px] w-5')}
aria-hidden="true"
/>
Follow
</Row>
)}
</Button>
)}
</Row> </Row>
) )
} }

View File

@ -1,5 +1,6 @@
import { useEffect, useState } from 'react' import { useEffect, useState } from 'react'
import { listenForFollowers, listenForFollows } from 'web/lib/firebase/users' import { listenForFollowers, listenForFollows } from 'web/lib/firebase/users'
import { contracts, listenForContractFollows } from 'web/lib/firebase/contracts'
export const useFollows = (userId: string | null | undefined) => { export const useFollows = (userId: string | null | undefined) => {
const [followIds, setFollowIds] = useState<string[] | undefined>() const [followIds, setFollowIds] = useState<string[] | undefined>()
@ -29,3 +30,13 @@ export const useFollowers = (userId: string | undefined) => {
return followerIds return followerIds
} }
export const useContractFollows = (contractId: string) => {
const [followIds, setFollowIds] = useState<string[] | undefined>()
useEffect(() => {
return listenForContractFollows(contractId, setFollowIds)
}, [contractId])
return followIds
}

View File

@ -23,6 +23,7 @@ import { Bet } from 'common/bet'
import { Comment } from 'common/comment' import { Comment } from 'common/comment'
import { ENV_CONFIG } from 'common/envs/constants' import { ENV_CONFIG } from 'common/envs/constants'
import { getBinaryProb } from 'common/contract-details' import { getBinaryProb } from 'common/contract-details'
import { users } from 'web/lib/firebase/users'
export const contracts = coll<Contract>('contracts') export const contracts = coll<Contract>('contracts')
@ -212,6 +213,16 @@ export function listenForContract(
return listenForValue<Contract>(contractRef, setContract) return listenForValue<Contract>(contractRef, setContract)
} }
export function listenForContractFollows(
contractId: string,
setFollowIds: (followIds: string[]) => void
) {
const follows = collection(contracts, contractId, 'follows')
return listenForValues<{ id: string }>(follows, (docs) =>
setFollowIds(docs.map(({ id }) => id))
)
}
function chooseRandomSubset(contracts: Contract[], count: number) { function chooseRandomSubset(contracts: Contract[], count: number) {
const fiveMinutes = 5 * 60 * 1000 const fiveMinutes = 5 * 60 * 1000
const seed = Math.round(Date.now() / fiveMinutes).toString() const seed = Math.round(Date.now() / fiveMinutes).toString()