diff --git a/common/user.ts b/common/user.ts index 0ba23010..ccaf2414 100644 --- a/common/user.ts +++ b/common/user.ts @@ -7,8 +7,20 @@ export type User = { avatarUrl?: string balance: number + totalDeposits: number totalPnLCached: number creatorVolumeCached: number } 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 +} diff --git a/functions/src/create-user.ts b/functions/src/create-user.ts index aba7b8e8..cd6f92b2 100644 --- a/functions/src/create-user.ts +++ b/functions/src/create-user.ts @@ -1,13 +1,18 @@ import * as functions from 'firebase-functions' 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 { randomString } from '../../common/util/random' export const createUser = functions .runWith({ minInstances: 1 }) - .https.onCall(async (_, context) => { + .https.onCall(async (data: { deviceToken?: string }, context) => { const userId = context?.auth?.uid if (!userId) return { status: 'error', message: 'Not authorized' } @@ -34,12 +39,23 @@ export const createUser = functions 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 = { id: userId, name, username, avatarUrl, - balance: STARTING_BALANCE, + balance, + totalDeposits: balance, createdTime: Date.now(), totalPnLCached: 0, creatorVolumeCached: 0, @@ -48,6 +64,16 @@ export const createUser = functions await firestore.collection('users').doc(userId).create(user) 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 } }) @@ -60,3 +86,21 @@ const cleanUsername = (name: string) => { } 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 +} diff --git a/functions/src/emails.ts b/functions/src/emails.ts index 3dd0873f..a7ca2845 100644 --- a/functions/src/emails.ts +++ b/functions/src/emails.ts @@ -1,9 +1,7 @@ -import * as admin from 'firebase-admin' - import { Contract } from '../../common/contract' import { User } from '../../common/user' import { sendTemplateEmail } from './send-email' -import { getUser } from './utils' +import { getPrivateUser, getUser } from './utils' type market_resolved_template = { name: string @@ -21,13 +19,17 @@ export const sendMarketResolutionEmail = async ( contract: Contract, resolution: 'YES' | 'NO' | 'CANCEL' | 'MKT' ) => { + const privateUser = await getPrivateUser(userId) + if ( + !privateUser || + privateUser.unsubscribedFromResolutionEmails || + !privateUser.email + ) + return + const user = await getUser(userId) if (!user) return - const fbUser = await admin.auth().getUser(userId) - const email = fbUser.email - if (!email) return - const outcome = toDisplayResolution[resolution] 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 // 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' } diff --git a/functions/src/index.ts b/functions/src/index.ts index 534e72cf..0b4ef72c 100644 --- a/functions/src/index.ts +++ b/functions/src/index.ts @@ -10,5 +10,6 @@ export * from './stripe' export * from './sell-bet' export * from './create-contract' export * from './create-user' +export * from './unsubscribe' export * from './update-contract-metrics' export * from './update-user-metrics' diff --git a/functions/src/scripts/create-private-users.ts b/functions/src/scripts/create-private-users.ts new file mode 100644 index 00000000..f9d84ba3 --- /dev/null +++ b/functions/src/scripts/create-private-users.ts @@ -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()) diff --git a/functions/src/stripe.ts b/functions/src/stripe.ts index 2f054392..4dffe541 100644 --- a/functions/src/stripe.ts +++ b/functions/src/stripe.ts @@ -129,7 +129,7 @@ const issueMoneys = async (session: any) => { await firestore.collection('stripe-transactions').add(transaction) - await payUser(userId, payout) + await payUser(userId, payout, true) console.log('user', userId, 'paid M$', payout) } diff --git a/functions/src/unsubscribe.ts b/functions/src/unsubscribe.ts new file mode 100644 index 00000000..d556f4ba --- /dev/null +++ b/functions/src/unsubscribe.ts @@ -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 = { + 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() diff --git a/functions/src/utils.ts b/functions/src/utils.ts index e6ecd7cf..9f3777e8 100644 --- a/functions/src/utils.ts +++ b/functions/src/utils.ts @@ -1,7 +1,7 @@ import * as admin from 'firebase-admin' import { Contract } from '../../common/contract' -import { User } from '../../common/user' +import { PrivateUser, User } from '../../common/user' export const getValue = async (collection: string, doc: string) => { const snap = await admin.firestore().collection(collection).doc(doc).get() @@ -22,6 +22,10 @@ export const getUser = (userId: string) => { return getValue('users', userId) } +export const getPrivateUser = (userId: string) => { + return getValue('private-users', userId) +} + export const getUserByUsername = async (username: string) => { const snap = await firestore .collection('users') @@ -33,7 +37,11 @@ export const getUserByUsername = async (username: string) => { const firestore = admin.firestore() -const updateUserBalance = (userId: string, delta: number) => { +const updateUserBalance = ( + userId: string, + delta: number, + isDeposit = false +) => { return firestore.runTransaction(async (transaction) => { const userDoc = firestore.doc(`users/${userId}`) const userSnap = await transaction.get(userDoc) @@ -47,15 +55,20 @@ const updateUserBalance = (userId: string, delta: number) => { `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 }) }) } -export const payUser = (userId: string, payout: number) => { +export const payUser = (userId: string, payout: number, isDeposit = false) => { if (!isFinite(payout) || payout <= 0) throw new Error('Payout is not positive: ' + payout) - return updateUserBalance(userId, payout) + return updateUserBalance(userId, payout, isDeposit) } export const chargeUser = (userId: string, charge: number) => { diff --git a/web/lib/firebase/api-call.ts b/web/lib/firebase/api-call.ts index a5d80200..6c5e98ab 100644 --- a/web/lib/firebase/api-call.ts +++ b/web/lib/firebase/api-call.ts @@ -1,5 +1,6 @@ import { getFunctions, httpsCallable } from 'firebase/functions' import { User } from '../../../common/user' +import { randomString } from '../../../common/util/random' const functions = getFunctions() @@ -13,7 +14,14 @@ export const resolveMarket = cloudFunction('resolveMarket') export const sellBet = cloudFunction('sellBet') -export const createUser: () => Promise = () => - cloudFunction('createUser')({}) +export const createUser: () => Promise = () => { + 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) .catch(() => null) +}