fix(clerk-webhook): sync emailVerified from Clerk verification status
The webhook handler now derives emailVerified from the primary email's Clerk verification status (1 = verified, 0 = unverified) and writes it on both user.created upserts and user.updated mutations. This aligns manual test-user creation (scripts/create_test_user) with webhook-created users, which previously diverged (manual set emailVerified=1, webhook did not set it at all). - resolveEmailVerified(): new helper reads the primary email's verification.status from the Clerk payload. - INSERT/UPDATE SQL now includes emailVerified in the column list. - Tests assert emailVerified=1 for verified emails and emailVerified=0 for unverified emails.
This commit is contained in:
@@ -165,13 +165,14 @@ async function call(
|
||||
function getUserByClerkId(clerkUserId: string) {
|
||||
const row = db
|
||||
.prepare(
|
||||
"SELECT id, clerkUserId, email, firstName, lastName, displayName, avatarUrl, provider, status FROM users WHERE clerkUserId = ?"
|
||||
"SELECT id, clerkUserId, email, emailVerified, firstName, lastName, displayName, avatarUrl, provider, status FROM users WHERE clerkUserId = ?"
|
||||
)
|
||||
.get(clerkUserId) as
|
||||
| {
|
||||
id: string;
|
||||
clerkUserId: string;
|
||||
email: string | null;
|
||||
emailVerified: number;
|
||||
firstName: string | null;
|
||||
lastName: string | null;
|
||||
displayName: string | null;
|
||||
@@ -194,6 +195,7 @@ describe("Clerk user.created webhook", () => {
|
||||
expect(user).toBeDefined();
|
||||
expect(user!.clerkUserId).toBe("user_abc123");
|
||||
expect(user!.email).toBe("jane@example.com");
|
||||
expect(user!.emailVerified).toBe(1); // Clerk marked the email verified
|
||||
expect(user!.firstName).toBe("Jane");
|
||||
expect(user!.lastName).toBe("Doe");
|
||||
expect(user!.displayName).toBe("Jane Doe");
|
||||
@@ -228,6 +230,26 @@ describe("Clerk user.created webhook", () => {
|
||||
const user = getUserByClerkId("user_abc123");
|
||||
expect(user!.displayName).toBe("janedoe");
|
||||
});
|
||||
|
||||
it("records emailVerified=0 when Clerk marks the email unverified", async () => {
|
||||
const res = await call(
|
||||
sign(
|
||||
userCreatedPayload({
|
||||
email_addresses: [
|
||||
{
|
||||
id: "idn_1",
|
||||
email_address: "unverified@example.com",
|
||||
verification: { status: "unverified" }
|
||||
}
|
||||
],
|
||||
primary_email_address_id: "idn_1"
|
||||
})
|
||||
)
|
||||
);
|
||||
expect(res.status).toBe(200);
|
||||
const user = getUserByClerkId("user_abc123");
|
||||
expect(user!.emailVerified).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Clerk user.updated webhook", () => {
|
||||
@@ -245,6 +267,7 @@ describe("Clerk user.updated webhook", () => {
|
||||
expect(after!.id).toBe(localId); // local UUID stable
|
||||
expect(after!.clerkUserId).toBe("user_abc123");
|
||||
expect(after!.email).toBe("jane.new@example.com");
|
||||
expect(after!.emailVerified).toBe(1); // verification status carried through update
|
||||
expect(after!.lastName).toBe("Smith");
|
||||
expect(after!.displayName).toBe("Jane Smith");
|
||||
expect(after!.avatarUrl).toBe("https://cdn.clerk.com/avatar2.png");
|
||||
|
||||
@@ -65,6 +65,15 @@ function resolveEmail(data: ClerkUserData): string | null {
|
||||
return primary?.email_address ?? null;
|
||||
}
|
||||
|
||||
/** Resolve the primary email's verification status (1 = verified, 0 = unverified). */
|
||||
function resolveEmailVerified(data: ClerkUserData): number {
|
||||
const addresses = data.email_addresses ?? [];
|
||||
const primaryId = data.primary_email_address_id ?? null;
|
||||
const primary =
|
||||
addresses.find((e) => e.id === primaryId) ?? addresses[0] ?? null;
|
||||
return primary?.verification?.status === "verified" ? 1 : 0;
|
||||
}
|
||||
|
||||
/** Resolve a display name (first + last, falling back to username). */
|
||||
function resolveDisplayName(data: ClerkUserData): string | null {
|
||||
const first = (data.first_name ?? "").trim();
|
||||
@@ -164,6 +173,7 @@ export async function handleClerkUserWebhook(opts: {
|
||||
const data = evt.data;
|
||||
const clerkUserId = data.id;
|
||||
const email = resolveEmail(data);
|
||||
const emailVerified = resolveEmailVerified(data);
|
||||
const firstName = data.first_name ?? null;
|
||||
const lastName = data.last_name ?? null;
|
||||
const displayName = resolveDisplayName(data);
|
||||
@@ -174,10 +184,11 @@ export async function handleClerkUserWebhook(opts: {
|
||||
// ON CONFLICT instead of erroring on a duplicate.
|
||||
await conn.execute({
|
||||
sql: `INSERT INTO users
|
||||
(id, clerkUserId, email, firstName, lastName, displayName, avatarUrl, provider, status)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, 'clerk', 'active')
|
||||
(id, clerkUserId, email, emailVerified, firstName, lastName, displayName, avatarUrl, provider, status)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, 'clerk', 'active')
|
||||
ON CONFLICT(clerkUserId) DO UPDATE SET
|
||||
email = excluded.email,
|
||||
emailVerified = excluded.emailVerified,
|
||||
firstName = excluded.firstName,
|
||||
lastName = excluded.lastName,
|
||||
displayName = excluded.displayName,
|
||||
@@ -187,6 +198,7 @@ export async function handleClerkUserWebhook(opts: {
|
||||
crypto.randomUUID(),
|
||||
clerkUserId,
|
||||
email,
|
||||
emailVerified,
|
||||
firstName,
|
||||
lastName,
|
||||
displayName,
|
||||
@@ -198,13 +210,14 @@ export async function handleClerkUserWebhook(opts: {
|
||||
await conn.execute({
|
||||
sql: `UPDATE users SET
|
||||
email = ?,
|
||||
emailVerified = ?,
|
||||
firstName = ?,
|
||||
lastName = ?,
|
||||
displayName = ?,
|
||||
avatarUrl = ?,
|
||||
updatedAt = datetime('now')
|
||||
WHERE clerkUserId = ?`,
|
||||
args: [email, firstName, lastName, displayName, avatarUrl, clerkUserId]
|
||||
args: [email, emailVerified, firstName, lastName, displayName, avatarUrl, clerkUserId]
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user