Private user (#32)

* PrivateUser

* createUser: create private user; detect multiple signups

* include user properties in private user

* script: create private users

* unsubscribing from market resolution emails

* track total deposits
This commit is contained in:
mantikoros 2022-01-18 21:36:46 -06:00 committed by GitHub
parent 21949abbe1
commit 4528615863
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
9 changed files with 190 additions and 18 deletions

View File

@ -7,8 +7,20 @@ export type User = {
avatarUrl?: string avatarUrl?: string
balance: number balance: number
totalDeposits: number
totalPnLCached: number totalPnLCached: number
creatorVolumeCached: number creatorVolumeCached: number
} }
export const STARTING_BALANCE = 1000 export const STARTING_BALANCE = 1000
export const SUS_STARTING_BALANCE = 10 // for sus users, i.e. multiple sign ups for same person
export type PrivateUser = {
id: string // same as User.id
username: string // denormalized from User
email?: string
unsubscribedFromResolutionEmails?: boolean
initialDeviceToken?: string
initialIpAddress?: string
}

View File

@ -1,13 +1,18 @@
import * as functions from 'firebase-functions' import * as functions from 'firebase-functions'
import * as admin from 'firebase-admin' import * as admin from 'firebase-admin'
import { STARTING_BALANCE, User } from '../../common/user' import {
PrivateUser,
STARTING_BALANCE,
SUS_STARTING_BALANCE,
User,
} from '../../common/user'
import { getUser, getUserByUsername } from './utils' import { getUser, getUserByUsername } from './utils'
import { randomString } from '../../common/util/random' import { randomString } from '../../common/util/random'
export const createUser = functions export const createUser = functions
.runWith({ minInstances: 1 }) .runWith({ minInstances: 1 })
.https.onCall(async (_, context) => { .https.onCall(async (data: { deviceToken?: string }, context) => {
const userId = context?.auth?.uid const userId = context?.auth?.uid
if (!userId) return { status: 'error', message: 'Not authorized' } if (!userId) return { status: 'error', message: 'Not authorized' }
@ -34,12 +39,23 @@ export const createUser = functions
const avatarUrl = fbUser.photoURL const avatarUrl = fbUser.photoURL
const { deviceToken } = data
const deviceUsedBefore =
!deviceToken || (await isPrivateUserWithDeviceToken(deviceToken))
const ipAddress = context.rawRequest.ip
const ipCount = ipAddress ? await numberUsersWithIp(ipAddress) : 0
const balance =
deviceUsedBefore || ipCount > 2 ? SUS_STARTING_BALANCE : STARTING_BALANCE
const user: User = { const user: User = {
id: userId, id: userId,
name, name,
username, username,
avatarUrl, avatarUrl,
balance: STARTING_BALANCE, balance,
totalDeposits: balance,
createdTime: Date.now(), createdTime: Date.now(),
totalPnLCached: 0, totalPnLCached: 0,
creatorVolumeCached: 0, creatorVolumeCached: 0,
@ -48,6 +64,16 @@ export const createUser = functions
await firestore.collection('users').doc(userId).create(user) await firestore.collection('users').doc(userId).create(user)
console.log('created user', username, 'firebase id:', userId) console.log('created user', username, 'firebase id:', userId)
const privateUser: PrivateUser = {
id: userId,
username,
email,
initialIpAddress: ipAddress,
initialDeviceToken: deviceToken,
}
await firestore.collection('private-users').doc(userId).create(privateUser)
return { status: 'success', user } return { status: 'success', user }
}) })
@ -60,3 +86,21 @@ const cleanUsername = (name: string) => {
} }
const firestore = admin.firestore() const firestore = admin.firestore()
const isPrivateUserWithDeviceToken = async (deviceToken: string) => {
const snap = await firestore
.collection('private-users')
.where('initialDeviceToken', '==', deviceToken)
.get()
return !snap.empty
}
const numberUsersWithIp = async (ipAddress: string) => {
const snap = await firestore
.collection('private-users')
.where('initialIpAddress', '==', ipAddress)
.get()
return snap.docs.length
}

View File

@ -1,9 +1,7 @@
import * as admin from 'firebase-admin'
import { Contract } from '../../common/contract' import { Contract } from '../../common/contract'
import { User } from '../../common/user' import { User } from '../../common/user'
import { sendTemplateEmail } from './send-email' import { sendTemplateEmail } from './send-email'
import { getUser } from './utils' import { getPrivateUser, getUser } from './utils'
type market_resolved_template = { type market_resolved_template = {
name: string name: string
@ -21,13 +19,17 @@ export const sendMarketResolutionEmail = async (
contract: Contract, contract: Contract,
resolution: 'YES' | 'NO' | 'CANCEL' | 'MKT' resolution: 'YES' | 'NO' | 'CANCEL' | 'MKT'
) => { ) => {
const privateUser = await getPrivateUser(userId)
if (
!privateUser ||
privateUser.unsubscribedFromResolutionEmails ||
!privateUser.email
)
return
const user = await getUser(userId) const user = await getUser(userId)
if (!user) return if (!user) return
const fbUser = await admin.auth().getUser(userId)
const email = fbUser.email
if (!email) return
const outcome = toDisplayResolution[resolution] const outcome = toDisplayResolution[resolution]
const subject = `Resolved ${outcome}: ${contract.question}` const subject = `Resolved ${outcome}: ${contract.question}`
@ -45,7 +47,12 @@ export const sendMarketResolutionEmail = async (
// https://app.mailgun.com/app/sending/domains/mg.manifold.markets/templates/edit/market-resolved/initial // https://app.mailgun.com/app/sending/domains/mg.manifold.markets/templates/edit/market-resolved/initial
// Mailgun username: james@mantic.markets // Mailgun username: james@mantic.markets
await sendTemplateEmail(email, subject, 'market-resolved', templateData) await sendTemplateEmail(
privateUser.email,
subject,
'market-resolved',
templateData
)
} }
const toDisplayResolution = { YES: 'YES', NO: 'NO', CANCEL: 'N/A', MKT: 'MKT' } const toDisplayResolution = { YES: 'YES', NO: 'NO', CANCEL: 'N/A', MKT: 'MKT' }

View File

@ -10,5 +10,6 @@ export * from './stripe'
export * from './sell-bet' export * from './sell-bet'
export * from './create-contract' export * from './create-contract'
export * from './create-user' export * from './create-user'
export * from './unsubscribe'
export * from './update-contract-metrics' export * from './update-contract-metrics'
export * from './update-user-metrics' export * from './update-user-metrics'

View File

@ -0,0 +1,54 @@
import * as admin from 'firebase-admin'
import * as _ from 'lodash'
import { PrivateUser, STARTING_BALANCE, User } from '../../../common/user'
// Generate your own private key, and set the path below:
// https://console.firebase.google.com/u/0/project/mantic-markets/settings/serviceaccounts/adminsdk
const serviceAccount = require('../../../../../../Downloads/dev-mantic-markets-firebase-adminsdk-sir5m-b2d27f8970.json')
admin.initializeApp({
credential: admin.credential.cert(serviceAccount),
})
const firestore = admin.firestore()
async function main() {
const snap = await firestore.collection('users').get()
const users = snap.docs.map((d) => d.data() as User)
for (let user of users) {
const fbUser = await admin.auth().getUser(user.id)
const email = fbUser.email
const { username } = user
const privateUser: PrivateUser = {
id: user.id,
email,
username,
}
if (user.totalDeposits === undefined) {
await firestore
.collection('users')
.doc(user.id)
.update({ totalDeposits: STARTING_BALANCE })
console.log('set starting balance for:', user.username)
}
try {
await firestore
.collection('private-users')
.doc(user.id)
.create(privateUser)
console.log('created private user for:', user.username)
} catch (_) {
// private user already created
}
}
}
if (require.main === module) main().then(() => process.exit())

View File

@ -129,7 +129,7 @@ const issueMoneys = async (session: any) => {
await firestore.collection('stripe-transactions').add(transaction) await firestore.collection('stripe-transactions').add(transaction)
await payUser(userId, payout) await payUser(userId, payout, true)
console.log('user', userId, 'paid M$', payout) console.log('user', userId, 'paid M$', payout)
} }

View File

@ -0,0 +1,33 @@
import * as functions from 'firebase-functions'
import * as admin from 'firebase-admin'
import * as _ from 'lodash'
import { getPrivateUser } from './utils'
import { PrivateUser } from '../../common/user'
export const unsubscribe = functions
.runWith({ minInstances: 1 })
.https.onRequest(async (req, res) => {
let id = req.query.id as string
if (!id) return
let privateUser = await getPrivateUser(id)
if (privateUser) {
let { username } = privateUser
const update: Partial<PrivateUser> = {
unsubscribedFromResolutionEmails: true,
}
await firestore.collection('private-users').doc(id).update(update)
res.send(
username +
', you have been unsubscribed from market resolution emails on Manifold Markets.'
)
} else {
res.send('This user is not currently subscribed or does not exist.')
}
})
const firestore = admin.firestore()

View File

@ -1,7 +1,7 @@
import * as admin from 'firebase-admin' import * as admin from 'firebase-admin'
import { Contract } from '../../common/contract' import { Contract } from '../../common/contract'
import { User } from '../../common/user' import { PrivateUser, User } from '../../common/user'
export const getValue = async <T>(collection: string, doc: string) => { export const getValue = async <T>(collection: string, doc: string) => {
const snap = await admin.firestore().collection(collection).doc(doc).get() const snap = await admin.firestore().collection(collection).doc(doc).get()
@ -22,6 +22,10 @@ export const getUser = (userId: string) => {
return getValue<User>('users', userId) return getValue<User>('users', userId)
} }
export const getPrivateUser = (userId: string) => {
return getValue<PrivateUser>('private-users', userId)
}
export const getUserByUsername = async (username: string) => { export const getUserByUsername = async (username: string) => {
const snap = await firestore const snap = await firestore
.collection('users') .collection('users')
@ -33,7 +37,11 @@ export const getUserByUsername = async (username: string) => {
const firestore = admin.firestore() const firestore = admin.firestore()
const updateUserBalance = (userId: string, delta: number) => { const updateUserBalance = (
userId: string,
delta: number,
isDeposit = false
) => {
return firestore.runTransaction(async (transaction) => { return firestore.runTransaction(async (transaction) => {
const userDoc = firestore.doc(`users/${userId}`) const userDoc = firestore.doc(`users/${userId}`)
const userSnap = await transaction.get(userDoc) const userSnap = await transaction.get(userDoc)
@ -47,15 +55,20 @@ const updateUserBalance = (userId: string, delta: number) => {
`User (${userId}) balance cannot be negative: ${newUserBalance}` `User (${userId}) balance cannot be negative: ${newUserBalance}`
) )
if (isDeposit) {
const newTotalDeposits = (user.totalDeposits || 0) + delta
transaction.update(userDoc, { totalDeposits: newTotalDeposits })
}
transaction.update(userDoc, { balance: newUserBalance }) transaction.update(userDoc, { balance: newUserBalance })
}) })
} }
export const payUser = (userId: string, payout: number) => { export const payUser = (userId: string, payout: number, isDeposit = false) => {
if (!isFinite(payout) || payout <= 0) if (!isFinite(payout) || payout <= 0)
throw new Error('Payout is not positive: ' + payout) throw new Error('Payout is not positive: ' + payout)
return updateUserBalance(userId, payout) return updateUserBalance(userId, payout, isDeposit)
} }
export const chargeUser = (userId: string, charge: number) => { export const chargeUser = (userId: string, charge: number) => {

View File

@ -1,5 +1,6 @@
import { getFunctions, httpsCallable } from 'firebase/functions' import { getFunctions, httpsCallable } from 'firebase/functions'
import { User } from '../../../common/user' import { User } from '../../../common/user'
import { randomString } from '../../../common/util/random'
const functions = getFunctions() const functions = getFunctions()
@ -13,7 +14,14 @@ export const resolveMarket = cloudFunction('resolveMarket')
export const sellBet = cloudFunction('sellBet') export const sellBet = cloudFunction('sellBet')
export const createUser: () => Promise<User | null> = () => export const createUser: () => Promise<User | null> = () => {
cloudFunction('createUser')({}) let deviceToken = window.localStorage.getItem('device-token')
if (!deviceToken) {
deviceToken = randomString()
window.localStorage.setItem('device-token', deviceToken)
}
return cloudFunction('createUser')({ deviceToken })
.then((r) => (r.data as any)?.user || null) .then((r) => (r.data as any)?.user || null)
.catch(() => null) .catch(() => null)
}