Skip to content

Commit

Permalink
Try breaking up defaultCreateOrUpdateUsers
Browse files Browse the repository at this point in the history
  • Loading branch information
sshader authored and thomasballinger committed Dec 30, 2024
1 parent f7d0334 commit 8461df3
Show file tree
Hide file tree
Showing 2 changed files with 205 additions and 79 deletions.
268 changes: 193 additions & 75 deletions src/server/implementation/users.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,14 @@
import { GenericId } from "convex/values";
import { Doc, MutationCtx, QueryCtx } from "./types.js";
import { AuthDataModel, Doc, MutationCtx, QueryCtx } from "./types.js";
import { AuthProviderMaterializedConfig, ConvexAuthConfig } from "../types.js";
import { LOG_LEVELS, logWithLevel } from "./utils.js";
import {
DocumentByName,
GenericDataModel,
GenericMutationCtx,
TableNamesInDataModel,
WithOptionalSystemFields,
} from "convex/server";

type CreateOrUpdateUserArgs = {
type: "oauth" | "credentials" | "email" | "phone" | "verification";
Expand Down Expand Up @@ -62,7 +69,81 @@ async function defaultCreateOrUpdateUser(
...args,
});
}
const { userId, existingUserId: existingOrLinkedUserId } =
await basicCreateOrUpdateUser(ctx, {
existingUserId,
...args,
});
await callAfterUserCreatedOrUpdated(
ctx,
userId,
existingOrLinkedUserId,
args,
config,
);
return userId;
}

export async function basicCreateOrUpdateUser(
ctx: MutationCtx,
args: CreateOrUpdateUserArgs & { existingUserId: GenericId<"users"> | null },
) {
let userId: GenericId<"users"> | null = args.existingUserId;
if (userId === null) {
// Try to link to an existing user with the same verified email or phone.
userId = await getUserByEmailOrPhone(ctx, {
...args,
usersTableName: "users",
});
}
const userData = getUserData(args.profile, args.provider);
const existingOrLinkedUserId = userId;
userId = await upsertUser(ctx, "users", userId, userData);
return { userId, existingUserId: existingOrLinkedUserId };
}

async function callAfterUserCreatedOrUpdated(
ctx: MutationCtx,
userId: GenericId<"users">,
existingUserId: GenericId<"users"> | null,
args: CreateOrUpdateUserArgs,
config: ConvexAuthConfig,
) {
const afterUserCreatedOrUpdated = config.callbacks?.afterUserCreatedOrUpdated;
if (afterUserCreatedOrUpdated !== undefined) {
logWithLevel(
LOG_LEVELS.DEBUG,
"Calling custom afterUserCreatedOrUpdated callback",
);
await afterUserCreatedOrUpdated(ctx, {
userId,
existingUserId,
...args,
});
} else {
logWithLevel(
LOG_LEVELS.DEBUG,
"No custom afterUserCreatedOrUpdated callback, skipping",
);
}
}

async function getUserByEmailOrPhone<UserTableName extends string = "users">(
ctx: QueryCtx,
args: {
existingUserId: GenericId<UserTableName> | null;
profile: {
email?: string;
phone?: string;
emailVerified?: boolean;
phoneVerified?: boolean;
};
shouldLinkViaEmail?: boolean;
shouldLinkViaPhone?: boolean;
provider: AuthProviderMaterializedConfig;
usersTableName: UserTableName;
},
) {
const {
provider,
profile: {
Expand All @@ -81,106 +162,143 @@ async function defaultCreateOrUpdateUser(
const shouldLinkViaPhone =
args.shouldLinkViaPhone || phoneVerified || provider.type === "phone";

let userId = existingUserId;
if (existingUserId === null) {
const existingUserWithVerifiedEmailId =
typeof profile.email === "string" && shouldLinkViaEmail
? (await uniqueUserWithVerifiedEmail(ctx, profile.email))?._id ?? null
: null;
let userId: GenericId<UserTableName> | null = null;
const existingUserWithVerifiedEmailId =
typeof profile.email === "string" && shouldLinkViaEmail
? (
await uniqueUserWithVerifiedEmail(
ctx,
profile.email,
args.usersTableName,
)
)?._id ?? null
: null;

const existingUserWithVerifiedPhoneId =
typeof profile.phone === "string" && shouldLinkViaPhone
? (await uniqueUserWithVerifiedPhone(ctx, profile.phone))?._id ?? null
: null;
// If there is both email and phone verified user
// already we can't link.
if (
existingUserWithVerifiedEmailId !== null &&
existingUserWithVerifiedPhoneId !== null
) {
logWithLevel(
LOG_LEVELS.DEBUG,
`Found existing email and phone verified users, so not linking: email: ${existingUserWithVerifiedEmailId}, phone: ${existingUserWithVerifiedPhoneId}`,
);
userId = null;
} else if (existingUserWithVerifiedEmailId !== null) {
logWithLevel(
LOG_LEVELS.DEBUG,
`Found existing email verified user, linking: ${existingUserWithVerifiedEmailId}`,
);
userId = existingUserWithVerifiedEmailId;
} else if (existingUserWithVerifiedPhoneId !== null) {
logWithLevel(
LOG_LEVELS.DEBUG,
`Found existing phone verified user, linking: ${existingUserWithVerifiedPhoneId}`,
);
userId = existingUserWithVerifiedPhoneId;
} else {
logWithLevel(
LOG_LEVELS.DEBUG,
"No existing verified users found, creating new user",
);
userId = null;
}
}
const userData = {
...(emailVerified ? { emailVerificationTime: Date.now() } : null),
...(phoneVerified ? { phoneVerificationTime: Date.now() } : null),
...profile,
};
const existingOrLinkedUserId = userId;
if (userId !== null) {
try {
await ctx.db.patch(userId, userData);
} catch (error) {
throw new Error(
`Could not update user document with ID \`${userId}\`, ` +
`either the user has been deleted but their account has not, ` +
`or the profile data doesn't match the \`users\` table schema: ` +
`${(error as Error).message}`,
);
}
} else {
userId = await ctx.db.insert("users", userData);
}
const afterUserCreatedOrUpdated = config.callbacks?.afterUserCreatedOrUpdated;
if (afterUserCreatedOrUpdated !== undefined) {
const existingUserWithVerifiedPhoneId =
typeof profile.phone === "string" && shouldLinkViaPhone
? (
await uniqueUserWithVerifiedPhone(
ctx,
profile.phone,
args.usersTableName,
)
)?._id ?? null
: null;
// If there is both email and phone verified user
// already we can't link.
if (
existingUserWithVerifiedEmailId !== null &&
existingUserWithVerifiedPhoneId !== null
) {
logWithLevel(
LOG_LEVELS.DEBUG,
"Calling custom afterUserCreatedOrUpdated callback",
`Found existing email and phone verified users, so not linking: email: ${existingUserWithVerifiedEmailId}, phone: ${existingUserWithVerifiedPhoneId}`,
);
await afterUserCreatedOrUpdated(ctx, {
userId,
existingUserId: existingOrLinkedUserId,
...args,
});
userId = null;
} else if (existingUserWithVerifiedEmailId !== null) {
logWithLevel(
LOG_LEVELS.DEBUG,
`Found existing email verified user, linking: ${existingUserWithVerifiedEmailId}`,
);
userId = existingUserWithVerifiedEmailId;
} else if (existingUserWithVerifiedPhoneId !== null) {
logWithLevel(
LOG_LEVELS.DEBUG,
`Found existing phone verified user, linking: ${existingUserWithVerifiedPhoneId}`,
);
userId = existingUserWithVerifiedPhoneId;
} else {
logWithLevel(
LOG_LEVELS.DEBUG,
"No custom afterUserCreatedOrUpdated callback, skipping",
"No existing verified users found, creating new user",
);
userId = null;
}
return userId;
}

async function uniqueUserWithVerifiedEmail(ctx: QueryCtx, email: string) {
function getUserData(
profile: {
email?: string;
phone?: string;
emailVerified?: boolean;
phoneVerified?: boolean;
},
provider: AuthProviderMaterializedConfig,
) {
const emailVerified =
profile.emailVerified ??
((provider.type === "oauth" || provider.type === "oidc") &&
provider.allowDangerousEmailAccountLinking !== false);
const phoneVerified = profile.phoneVerified ?? false;
const userData = {
...(emailVerified ? { emailVerificationTime: Date.now() } : null),
...(phoneVerified ? { phoneVerificationTime: Date.now() } : null),
...profile,
};
return userData;
}

async function uniqueUserWithVerifiedEmail(
ctx: QueryCtx,
email: string,
usersTableName: string,
) {
const users = await ctx.db
.query("users")
.query(usersTableName as any)
.withIndex("email", (q) => q.eq("email", email))
.filter((q) => q.neq(q.field("emailVerificationTime"), undefined))
.take(2);
return users.length === 1 ? users[0] : null;
}

async function uniqueUserWithVerifiedPhone(ctx: QueryCtx, phone: string) {
async function uniqueUserWithVerifiedPhone(
ctx: QueryCtx,
phone: string,
usersTableName: string,
) {
const users = await ctx.db
.query("users")
.query(usersTableName as any)
.withIndex("phone", (q) => q.eq("phone", phone))
.filter((q) => q.neq(q.field("phoneVerificationTime"), undefined))
.take(2);
return users.length === 1 ? users[0] : null;
}

async function upsertUser<
DM extends GenericDataModel,
T extends TableNamesInDataModel<DM>,
>(
ctx: GenericMutationCtx<DM>,
tableName: T,
userId: GenericId<T> | null,
userData: WithOptionalSystemFields<DocumentByName<DM, T>>,
): Promise<GenericId<T>> {
let insertedId: GenericId<T> | null = null;
if (userId !== null) {
const typedUserId = ctx.db.normalizeId(tableName, userId);
if (typedUserId === null) {
throw new Error(
`The provided user ID \`${userId}\` is not a valid ID for table \`${tableName}\`, so cannot update the document.`,
);
}
try {
insertedId = typedUserId;
await ctx.db.patch(typedUserId, userData);
} catch (error) {
throw new Error(
`Could not update user document with ID \`${userId}\`, ` +
`either the user has been deleted but their account has not, ` +
`or the profile data doesn't match the \`${tableName}\` table schema: ` +
`${(error as Error).message}`,
);
}
} else {
insertedId = await ctx.db.insert(tableName, userData);
}
return insertedId;
}

async function createOrUpdateAccount(
ctx: MutationCtx,
userId: GenericId<"users">,
Expand Down
16 changes: 12 additions & 4 deletions src/server/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -161,9 +161,13 @@ export type ConvexAuthConfig = {
phoneVerified?: boolean;
};
/**
* The `shouldLink` argument passed to `createAccount`.
* Whether to link the account via email.
*/
shouldLink?: boolean;
shouldLinkViaEmail?: boolean;
/**
* Whether to link the account via phone.
*/
shouldLinkViaPhone?: boolean;
},
) => Promise<GenericId<"users">>;
/**
Expand Down Expand Up @@ -216,9 +220,13 @@ export type ConvexAuthConfig = {
phoneVerified?: boolean;
};
/**
* The `shouldLink` argument passed to `createAccount`.
* Whether to link the account via email.
*/
shouldLinkViaEmail?: boolean;
/**
* Whether to link the account via phone.
*/
shouldLink?: boolean;
shouldLinkViaPhone?: boolean;
},
) => Promise<void>;
};
Expand Down

0 comments on commit 8461df3

Please sign in to comment.