Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 10 additions & 1 deletion apis/api-gateway/schema.graphql
Original file line number Diff line number Diff line change
Expand Up @@ -550,6 +550,7 @@ type Mutation @join__type(graph: API_ANALYTICS) @join__type(graph: API_JOURNEYS
videoEditionCreate(input: VideoEditionCreateInput!) : VideoEdition! @join__field(graph: API_MEDIA)
videoEditionUpdate(input: VideoEditionUpdateInput!) : VideoEdition! @join__field(graph: API_MEDIA)
videoEditionDelete(id: ID!) : VideoEdition! @join__field(graph: API_MEDIA)
mergeGuest(input: MergeGuestInput!) : User! @join__field(graph: API_USERS)
userImpersonate(email: String!) : String @join__field(graph: API_USERS)
createVerificationRequest(input: CreateVerificationRequestInput) : Boolean @join__field(graph: API_USERS)
validateEmail(email: String!, token: String!) : User @join__field(graph: API_USERS)
Expand Down Expand Up @@ -2185,9 +2186,10 @@ type User @join__type(graph: API_JOURNEYS, key: "id", extension: true) @join__t
id: ID!
languageUserRoles: [LanguageRole!]! @join__field(graph: API_LANGUAGES)
mediaUserRoles: [MediaRole!]! @join__field(graph: API_MEDIA)
userId: String! @join__field(graph: API_USERS)
firstName: String! @join__field(graph: API_USERS)
lastName: String @join__field(graph: API_USERS)
email: String! @join__field(graph: API_USERS)
email: String @join__field(graph: API_USERS)
imageUrl: String @join__field(graph: API_USERS)
superAdmin: Boolean @join__field(graph: API_USERS)
emailVerified: Boolean! @join__field(graph: API_USERS)
Expand Down Expand Up @@ -5454,5 +5456,12 @@ input CreateVerificationRequestInput @join__type(graph: API_USERS) {

input MeInput @join__type(graph: API_USERS) {
redirect: String
createGuestIfAnonymous: Boolean
}

input MergeGuestInput @join__type(graph: API_USERS) {
firstName: String!
lastName: String!
email: String!
}

Original file line number Diff line number Diff line change
Expand Up @@ -92,5 +92,16 @@ describe('MailChimpService', () => {
'Mailchimp Audience ID is undefined'
)
})

it('should no-op when user has no email (e.g. anonymous/guest)', async () => {
const userWithoutEmail: User = {
...user,
email: null
}
await expect(
mailChimpService.syncUser(userWithoutEmail)
).resolves.toBeUndefined()
expect(mailchimp.lists.setListMember).not.toHaveBeenCalled()
})
})
})
Original file line number Diff line number Diff line change
Expand Up @@ -8,14 +8,13 @@ import { User } from '../../lib/firebaseClient'
export class MailChimpService {
async syncUser(user: User): Promise<void> {
try {
if (user.email == null) return
mailchimp.setConfig({
apiKey: process.env.MAILCHIMP_MARKETING_API_KEY,
server: process.env.MAILCHIMP_MARKETING_API_SERVER_PREFIX
})
if (process.env.MAILCHIMP_AUDIENCE_ID == null)
throw new Error('Mailchimp Audience ID is undefined')
if (user.email == null)
throw new Error('User must have an email to receive marketing emails')
// upsert operation
await mailchimp.lists.setListMember(
process.env.MAILCHIMP_AUDIENCE_ID,
Expand Down
11 changes: 10 additions & 1 deletion apis/api-users/schema.graphql
Original file line number Diff line number Diff line change
Expand Up @@ -7,9 +7,17 @@ input CreateVerificationRequestInput {

input MeInput {
redirect: String
createGuestIfAnonymous: Boolean
}

input MergeGuestInput {
firstName: String!
lastName: String!
email: String!
}

type Mutation {
mergeGuest(input: MergeGuestInput!): User!
userImpersonate(email: String!): String
createVerificationRequest(input: CreateVerificationRequestInput): Boolean
validateEmail(email: String!, token: String!): User
Expand All @@ -25,9 +33,10 @@ type User
@key(fields: "id")
{
id: ID!
userId: String!
firstName: String!
lastName: String
email: String!
email: String
imageUrl: String
superAdmin: Boolean
emailVerified: Boolean!
Expand Down
74 changes: 71 additions & 3 deletions apis/api-users/src/schema/user/findOrFetchUser.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,14 +6,15 @@ import { verifyUser } from './verifyUser'

jest.mock('@core/yoga/firebaseClient', () => ({
auth: {
getUser: jest.fn().mockReturnValue({
getUser: jest.fn().mockResolvedValue({
id: '1',
userId: '1',
createdAt: new Date('2021-01-01T00:00:00.000Z'),
displayName: 'Amin One',
email: 'amin@email.com',
photoURL: 'https://bit.ly/3Gth4',
emailVerified: false
emailVerified: false,
providerData: [{ providerId: 'google.com' }]
})
}
}))
Expand Down Expand Up @@ -41,7 +42,7 @@ describe('findOrFetchUser', () => {
const data = await findOrFetchUser({}, 'userId', undefined)
expect(data).toEqual(user)
expect(prismaMock.user.update).toHaveBeenCalledWith({
where: { id: 'userId' },
where: { userId: 'userId' },
data: { emailVerified: false }
})
})
Expand All @@ -67,4 +68,71 @@ describe('findOrFetchUser', () => {
undefined
)
})

it('should return null and not create database user for anonymous Firebase user', async () => {
const { auth } = await import('@core/yoga/firebaseClient')
;(auth.getUser as jest.Mock).mockResolvedValueOnce({
id: 'anon',
userId: 'anonymousUserId',
displayName: null,
email: null,
photoURL: null,
emailVerified: false,
providerData: []
})
prismaMock.user.findUnique.mockResolvedValueOnce(null)
const data = await findOrFetchUser({}, 'anonymousUserId', undefined)
expect(data).toBeNull()
expect(prismaMock.user.create).not.toHaveBeenCalled()
})

it('should return existing user when create fails (e.g. concurrent create)', async () => {
prismaMock.user.findUnique
.mockResolvedValueOnce(null)
.mockResolvedValueOnce(user)
prismaMock.user.create.mockRejectedValueOnce(new Error('Unique constraint'))
const data = await findOrFetchUser({}, 'userId', undefined)
expect(data).toEqual(user)
expect(prismaMock.user.create).toHaveBeenCalledTimes(1)
expect(prismaMock.user.findUnique).toHaveBeenCalledTimes(2)
})

it('should create database user for anonymous Firebase user when forceCreateForAnonymous is true', async () => {
const { auth } = await import('@core/yoga/firebaseClient')
;(auth.getUser as jest.Mock).mockResolvedValueOnce({
id: 'anon',
userId: 'anonymousUserId',
displayName: null,
email: null,
photoURL: null,
emailVerified: false,
providerData: []
})
const createdUser = {
...user,
userId: 'anonymousUserId',
firstName: 'Unknown User',
lastName: '',
email: null,
emailVerified: false
}
prismaMock.user.findUnique.mockResolvedValueOnce(null)
prismaMock.user.create.mockResolvedValueOnce(
createdUser as unknown as typeof user
)
const data = await findOrFetchUser({}, 'anonymousUserId', undefined, {
forceCreateForAnonymous: true
})
expect(data).toEqual(createdUser)
expect(prismaMock.user.create).toHaveBeenCalledWith({
data: {
userId: 'anonymousUserId',
firstName: 'Unknown User',
lastName: '',
email: null,
imageUrl: null,
emailVerified: false
}
})
})
})
62 changes: 35 additions & 27 deletions apis/api-users/src/schema/user/findOrFetchUser.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,11 +3,19 @@ import { auth } from '@core/yoga/firebaseClient'

import { verifyUser } from './verifyUser'

export type FindOrFetchUserOptions = {
/** When true, create a database user even for anonymous Firebase users (e.g. "Create Guest"). */
forceCreateForAnonymous?: boolean
}

export async function findOrFetchUser(
query: { select?: Prisma.UserSelect; include?: undefined },
userId: string,
redirect: string | undefined = undefined
redirect: string | undefined = undefined,
options: FindOrFetchUserOptions = {}
): Promise<User | null> {
const { forceCreateForAnonymous = false } = options

const existingUser = await prisma.user.findUnique({
...query,
where: {
Expand All @@ -17,7 +25,7 @@ export async function findOrFetchUser(
if (existingUser != null && existingUser.emailVerified == null) {
const user = await prisma.user.update({
where: {
id: userId
userId
},
data: {
emailVerified: false
Expand All @@ -29,12 +37,17 @@ export async function findOrFetchUser(
if (existingUser != null && existingUser.emailVerified != null)
return existingUser

const {
displayName,
email,
emailVerified,
photoURL: imageUrl
} = await auth.getUser(userId)
const firebaseUser = await auth.getUser(userId)

const isAnonymous =
firebaseUser.providerData == null || firebaseUser.providerData.length === 0

// Do not create a database user for anonymous Firebase users unless explicitly requested.
if (isAnonymous && !forceCreateForAnonymous) {
return null
}

const { displayName, email, emailVerified, photoURL: imageUrl } = firebaseUser

// Extract firstName and lastName from displayName with better fallbacks
let firstName = ''
Expand All @@ -60,36 +73,31 @@ export async function findOrFetchUser(
firstName = 'Unknown User'
}

const data = {
// Schema has email String?; use assertion so nullable email compiles with any Prisma client version
const createData = {
userId,
firstName,
lastName,
email: email ?? '',
imageUrl,
email: email ?? null,
imageUrl: imageUrl ?? null,
emailVerified
}
} as Prisma.UserUncheckedCreateInput

let user: User | null = null
let retry = 0
let userCreated = false
// this function can run in parallel as such it is possible for multiple
// calls to reach this point and try to create the same user
// due to the earlier firebase async call.
// This can run in parallel; multiple calls may try to create the same user.
try {
user = await prisma.user.create({
data
data: createData
})
userCreated = true
} catch (e) {
do {
user = await prisma.user.update({
where: {
id: userId
},
data
})
retry++
} while (user == null && retry < 3)
} catch (createErr) {
// Create failed - often unique constraint (concurrent request already created). Fetch existing.
user = await prisma.user.findUnique({
...query,
where: { userId }
})
if (user == null) throw createErr
}
// after user create so it is only sent once
if (email != null && userCreated && !emailVerified)
Expand Down
2 changes: 2 additions & 0 deletions apis/api-users/src/schema/user/inputs/index.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
import './createVerificationRequestInput'
import './meInput'
import './mergeGuestInput'

export { CreateVerificationRequestInput } from './createVerificationRequestInput'
export { MeInput } from './meInput'
export { MergeGuestInput } from './mergeGuestInput'
3 changes: 2 additions & 1 deletion apis/api-users/src/schema/user/inputs/meInput.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { builder } from '../../builder'

export const MeInput = builder.inputType('MeInput', {
fields: (t) => ({
redirect: t.string({ required: false })
redirect: t.string({ required: false }),
createGuestIfAnonymous: t.boolean({ required: false })
})
})
3 changes: 2 additions & 1 deletion apis/api-users/src/schema/user/objects/user.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { builder } from '../../builder'
export const User = builder.prismaObject('User', {
fields: (t) => ({
id: t.exposeID('id', { nullable: false }),
userId: t.exposeString('userId', { nullable: false }),
firstName: t.field({
type: 'String',
nullable: false,
Expand All @@ -18,7 +19,7 @@ export const User = builder.prismaObject('User', {
}
}),
lastName: t.exposeString('lastName'),
email: t.exposeString('email', { nullable: false }),
email: t.exposeString('email'),
imageUrl: t.exposeString('imageUrl'),
superAdmin: t.exposeBoolean('superAdmin'),
emailVerified: t.exposeBoolean('emailVerified', { nullable: false })
Expand Down
7 changes: 6 additions & 1 deletion apis/api-users/src/schema/user/user.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -83,7 +83,12 @@ describe('api-users', () => {
const data = await authClient({
document: ME_QUERY
})
expect(findOrFetchUser).toHaveBeenCalledWith({}, 'testUserId', undefined)
expect(findOrFetchUser).toHaveBeenCalledWith(
{},
'testUserId',
undefined,
{ forceCreateForAnonymous: false }
)
expect(data).toHaveProperty(
'data.me',
omit(user, ['createdAt', 'userId'])
Expand Down
Loading
Loading