Create user cloud function (#31)
* createUser cloud function; change User object * initial commit * listenForLogin: avoid race condition * createUser: allow capital letters in username * remove debugging * leaderboard: empty url for undefined avatar image
This commit is contained in:
parent
e6d467764d
commit
908c8c03e6
|
@ -1,12 +1,14 @@
|
||||||
export type User = {
|
export type User = {
|
||||||
id: string
|
id: string
|
||||||
email: string
|
createdTime: number
|
||||||
|
|
||||||
name: string
|
name: string
|
||||||
username: string
|
username: string
|
||||||
avatarUrl: string
|
avatarUrl?: string
|
||||||
|
|
||||||
balance: number
|
balance: number
|
||||||
createdTime: number
|
|
||||||
lastUpdatedTime: number
|
|
||||||
totalPnLCached: number
|
totalPnLCached: number
|
||||||
creatorVolumeCached: number
|
creatorVolumeCached: number
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export const STARTING_BALANCE = 1000
|
||||||
|
|
|
@ -1,4 +1,7 @@
|
||||||
export const randomString = () => Math.random().toString(16).substr(2, 14)
|
export const randomString = (length = 12) =>
|
||||||
|
Math.random()
|
||||||
|
.toString(16)
|
||||||
|
.substring(2, length + 2)
|
||||||
|
|
||||||
export function createRNG(seed: string) {
|
export function createRNG(seed: string) {
|
||||||
// https://stackoverflow.com/a/47593316/1592933
|
// https://stackoverflow.com/a/47593316/1592933
|
||||||
|
|
62
functions/src/create-user.ts
Normal file
62
functions/src/create-user.ts
Normal file
|
@ -0,0 +1,62 @@
|
||||||
|
import * as functions from 'firebase-functions'
|
||||||
|
import * as admin from 'firebase-admin'
|
||||||
|
|
||||||
|
import { 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) => {
|
||||||
|
const userId = context?.auth?.uid
|
||||||
|
if (!userId) return { status: 'error', message: 'Not authorized' }
|
||||||
|
|
||||||
|
const preexistingUser = await getUser(userId)
|
||||||
|
if (preexistingUser)
|
||||||
|
return {
|
||||||
|
status: 'error',
|
||||||
|
message: 'User already created',
|
||||||
|
user: preexistingUser,
|
||||||
|
}
|
||||||
|
|
||||||
|
const fbUser = await admin.auth().getUser(userId)
|
||||||
|
|
||||||
|
const email = fbUser.email
|
||||||
|
const emailName = email?.replace(/@.*$/, '')
|
||||||
|
|
||||||
|
const name = fbUser.displayName || emailName || 'User' + randomString(4)
|
||||||
|
let username = cleanUsername(name)
|
||||||
|
|
||||||
|
const sameNameUser = await getUserByUsername(username)
|
||||||
|
if (sameNameUser) {
|
||||||
|
username += randomString(4)
|
||||||
|
}
|
||||||
|
|
||||||
|
const avatarUrl = fbUser.photoURL
|
||||||
|
|
||||||
|
const user: User = {
|
||||||
|
id: userId,
|
||||||
|
name,
|
||||||
|
username,
|
||||||
|
avatarUrl,
|
||||||
|
balance: STARTING_BALANCE,
|
||||||
|
createdTime: Date.now(),
|
||||||
|
totalPnLCached: 0,
|
||||||
|
creatorVolumeCached: 0,
|
||||||
|
}
|
||||||
|
|
||||||
|
await firestore.collection('users').doc(userId).create(user)
|
||||||
|
console.log('created user', username, 'firebase id:', userId)
|
||||||
|
|
||||||
|
return { status: 'success', user }
|
||||||
|
})
|
||||||
|
|
||||||
|
const cleanUsername = (name: string) => {
|
||||||
|
return name
|
||||||
|
.replace(/\s+/g, '')
|
||||||
|
.normalize('NFD') // split an accented letter in the base letter and the acent
|
||||||
|
.replace(/[\u0300-\u036f]/g, '') // remove all previously split accents
|
||||||
|
.replace(/[^A-Za-z0-9_]/g, '') // remove all chars not letters, numbers and underscores
|
||||||
|
}
|
||||||
|
|
||||||
|
const firestore = admin.firestore()
|
|
@ -1,3 +1,5 @@
|
||||||
|
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'
|
||||||
|
@ -22,6 +24,10 @@ export const sendMarketResolutionEmail = async (
|
||||||
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}`
|
||||||
|
@ -39,7 +45,7 @@ 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(user.email, subject, 'market-resolved', templateData)
|
await sendTemplateEmail(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' }
|
||||||
|
|
|
@ -9,5 +9,6 @@ export * from './resolve-market'
|
||||||
export * from './stripe'
|
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 './update-contract-metrics'
|
export * from './update-contract-metrics'
|
||||||
export * from './update-user-metrics'
|
export * from './update-user-metrics'
|
||||||
|
|
|
@ -22,6 +22,15 @@ export const getUser = (userId: string) => {
|
||||||
return getValue<User>('users', userId)
|
return getValue<User>('users', userId)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export const getUserByUsername = async (username: string) => {
|
||||||
|
const snap = await firestore
|
||||||
|
.collection('users')
|
||||||
|
.where('username', '==', username)
|
||||||
|
.get()
|
||||||
|
|
||||||
|
return snap.empty ? undefined : (snap.docs[0].data() as User)
|
||||||
|
}
|
||||||
|
|
||||||
const firestore = admin.firestore()
|
const firestore = admin.firestore()
|
||||||
|
|
||||||
const updateUserBalance = (userId: string, delta: number) => {
|
const updateUserBalance = (userId: string, delta: number) => {
|
||||||
|
|
|
@ -76,7 +76,9 @@ function ProfileSummary(props: { user: User }) {
|
||||||
return (
|
return (
|
||||||
<Col className="avatar items-center sm:flex-row gap-2 sm:gap-0">
|
<Col className="avatar items-center sm:flex-row gap-2 sm:gap-0">
|
||||||
<div className="rounded-full w-10 h-10 sm:mr-4">
|
<div className="rounded-full w-10 h-10 sm:mr-4">
|
||||||
<Image src={user.avatarUrl} width={40} height={40} />
|
{user.avatarUrl && (
|
||||||
|
<Image src={user.avatarUrl} width={40} height={40} />
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className="truncate text-left" style={{ maxWidth: 170 }}>
|
<div className="truncate text-left" style={{ maxWidth: 170 }}>
|
||||||
<div className="hidden sm:flex">{user.name}</div>
|
<div className="hidden sm:flex">{user.name}</div>
|
||||||
|
|
|
@ -43,7 +43,6 @@ function UserCard(props: { user: User; showPrivateInfo?: boolean }) {
|
||||||
|
|
||||||
{showPrivateInfo && (
|
{showPrivateInfo && (
|
||||||
<>
|
<>
|
||||||
<p>{user?.email}</p>
|
|
||||||
<p>{formatMoney(user?.balance)}</p>
|
<p>{formatMoney(user?.balance)}</p>
|
||||||
<div className="card-actions">
|
<div className="card-actions">
|
||||||
<button
|
<button
|
||||||
|
|
|
@ -1,4 +1,5 @@
|
||||||
import { getFunctions, httpsCallable } from 'firebase/functions'
|
import { getFunctions, httpsCallable } from 'firebase/functions'
|
||||||
|
import { User } from '../../../common/user'
|
||||||
|
|
||||||
const functions = getFunctions()
|
const functions = getFunctions()
|
||||||
|
|
||||||
|
@ -11,3 +12,8 @@ export const placeBet = cloudFunction('placeBet')
|
||||||
export const resolveMarket = cloudFunction('resolveMarket')
|
export const resolveMarket = cloudFunction('resolveMarket')
|
||||||
|
|
||||||
export const sellBet = cloudFunction('sellBet')
|
export const sellBet = cloudFunction('sellBet')
|
||||||
|
|
||||||
|
export const createUser: () => Promise<User | null> = () =>
|
||||||
|
cloudFunction('createUser')({})
|
||||||
|
.then((r) => (r.data as any)?.user || null)
|
||||||
|
.catch(() => null)
|
||||||
|
|
|
@ -1,4 +1,3 @@
|
||||||
import { app } from './init'
|
|
||||||
import {
|
import {
|
||||||
getFirestore,
|
getFirestore,
|
||||||
doc,
|
doc,
|
||||||
|
@ -20,12 +19,12 @@ import {
|
||||||
signInWithPopup,
|
signInWithPopup,
|
||||||
} from 'firebase/auth'
|
} from 'firebase/auth'
|
||||||
|
|
||||||
|
import { app } from './init'
|
||||||
import { User } from '../../../common/user'
|
import { User } from '../../../common/user'
|
||||||
|
import { createUser } from './api-call'
|
||||||
import { getValues, listenForValues } from './utils'
|
import { getValues, listenForValues } from './utils'
|
||||||
export type { User }
|
export type { User }
|
||||||
|
|
||||||
export const STARTING_BALANCE = 1000
|
|
||||||
|
|
||||||
const db = getFirestore(app)
|
const db = getFirestore(app)
|
||||||
export const auth = getAuth(app)
|
export const auth = getAuth(app)
|
||||||
|
|
||||||
|
@ -55,31 +54,27 @@ export function listenForUser(userId: string, setUser: (user: User) => void) {
|
||||||
}
|
}
|
||||||
|
|
||||||
const CACHED_USER_KEY = 'CACHED_USER_KEY'
|
const CACHED_USER_KEY = 'CACHED_USER_KEY'
|
||||||
|
|
||||||
|
// used to avoid weird race condition
|
||||||
|
let createUserPromise: Promise<User | null> | undefined = undefined
|
||||||
|
|
||||||
export function listenForLogin(onUser: (user: User | null) => void) {
|
export function listenForLogin(onUser: (user: User | null) => void) {
|
||||||
const cachedUser = localStorage.getItem(CACHED_USER_KEY)
|
const cachedUser = localStorage.getItem(CACHED_USER_KEY)
|
||||||
onUser(cachedUser ? JSON.parse(cachedUser) : null)
|
onUser(cachedUser ? JSON.parse(cachedUser) : null)
|
||||||
|
|
||||||
|
if (!cachedUser) createUser() // warm up cloud function
|
||||||
|
|
||||||
return onAuthStateChanged(auth, async (fbUser) => {
|
return onAuthStateChanged(auth, async (fbUser) => {
|
||||||
if (fbUser) {
|
if (fbUser) {
|
||||||
let user = await getUser(fbUser.uid)
|
let user: User | null = await getUser(fbUser.uid)
|
||||||
|
|
||||||
if (!user) {
|
if (!user) {
|
||||||
// User just created an account; save them to our database.
|
if (!createUserPromise) {
|
||||||
user = {
|
createUserPromise = createUser()
|
||||||
id: fbUser.uid,
|
|
||||||
name: fbUser.displayName || 'Default Name',
|
|
||||||
username:
|
|
||||||
fbUser.displayName?.replace(/\s+/g, '') || 'DefaultUsername',
|
|
||||||
avatarUrl: fbUser.photoURL || '',
|
|
||||||
email: fbUser.email || 'default@blah.com',
|
|
||||||
balance: STARTING_BALANCE,
|
|
||||||
// TODO: use Firestore timestamp?
|
|
||||||
createdTime: Date.now(),
|
|
||||||
lastUpdatedTime: Date.now(),
|
|
||||||
totalPnLCached: 0,
|
|
||||||
creatorVolumeCached: 0,
|
|
||||||
}
|
}
|
||||||
await setUser(fbUser.uid, user)
|
user = (await createUserPromise) || null
|
||||||
}
|
}
|
||||||
|
|
||||||
onUser(user)
|
onUser(user)
|
||||||
|
|
||||||
// Persist to local storage, to reduce login blink next time.
|
// Persist to local storage, to reduce login blink next time.
|
||||||
|
@ -89,6 +84,7 @@ export function listenForLogin(onUser: (user: User | null) => void) {
|
||||||
// User logged out; reset to null
|
// User logged out; reset to null
|
||||||
onUser(null)
|
onUser(null)
|
||||||
localStorage.removeItem(CACHED_USER_KEY)
|
localStorage.removeItem(CACHED_USER_KEY)
|
||||||
|
createUserPromise = undefined
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
|
@ -90,7 +90,7 @@ function Leaderboard(props: {
|
||||||
<Row className="items-center gap-4">
|
<Row className="items-center gap-4">
|
||||||
<Image
|
<Image
|
||||||
className="rounded-full bg-gray-400 flex-shrink-0 ring-8 ring-gray-50"
|
className="rounded-full bg-gray-400 flex-shrink-0 ring-8 ring-gray-50"
|
||||||
src={user.avatarUrl}
|
src={user.avatarUrl || ''}
|
||||||
alt=""
|
alt=""
|
||||||
width={32}
|
width={32}
|
||||||
height={32}
|
height={32}
|
||||||
|
|
Loading…
Reference in New Issue
Block a user