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) {
|
function getUserByClerkId(clerkUserId: string) {
|
||||||
const row = db
|
const row = db
|
||||||
.prepare(
|
.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
|
.get(clerkUserId) as
|
||||||
| {
|
| {
|
||||||
id: string;
|
id: string;
|
||||||
clerkUserId: string;
|
clerkUserId: string;
|
||||||
email: string | null;
|
email: string | null;
|
||||||
|
emailVerified: number;
|
||||||
firstName: string | null;
|
firstName: string | null;
|
||||||
lastName: string | null;
|
lastName: string | null;
|
||||||
displayName: string | null;
|
displayName: string | null;
|
||||||
@@ -194,6 +195,7 @@ describe("Clerk user.created webhook", () => {
|
|||||||
expect(user).toBeDefined();
|
expect(user).toBeDefined();
|
||||||
expect(user!.clerkUserId).toBe("user_abc123");
|
expect(user!.clerkUserId).toBe("user_abc123");
|
||||||
expect(user!.email).toBe("jane@example.com");
|
expect(user!.email).toBe("jane@example.com");
|
||||||
|
expect(user!.emailVerified).toBe(1); // Clerk marked the email verified
|
||||||
expect(user!.firstName).toBe("Jane");
|
expect(user!.firstName).toBe("Jane");
|
||||||
expect(user!.lastName).toBe("Doe");
|
expect(user!.lastName).toBe("Doe");
|
||||||
expect(user!.displayName).toBe("Jane Doe");
|
expect(user!.displayName).toBe("Jane Doe");
|
||||||
@@ -228,6 +230,26 @@ describe("Clerk user.created webhook", () => {
|
|||||||
const user = getUserByClerkId("user_abc123");
|
const user = getUserByClerkId("user_abc123");
|
||||||
expect(user!.displayName).toBe("janedoe");
|
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", () => {
|
describe("Clerk user.updated webhook", () => {
|
||||||
@@ -245,6 +267,7 @@ describe("Clerk user.updated webhook", () => {
|
|||||||
expect(after!.id).toBe(localId); // local UUID stable
|
expect(after!.id).toBe(localId); // local UUID stable
|
||||||
expect(after!.clerkUserId).toBe("user_abc123");
|
expect(after!.clerkUserId).toBe("user_abc123");
|
||||||
expect(after!.email).toBe("jane.new@example.com");
|
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!.lastName).toBe("Smith");
|
||||||
expect(after!.displayName).toBe("Jane Smith");
|
expect(after!.displayName).toBe("Jane Smith");
|
||||||
expect(after!.avatarUrl).toBe("https://cdn.clerk.com/avatar2.png");
|
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;
|
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). */
|
/** Resolve a display name (first + last, falling back to username). */
|
||||||
function resolveDisplayName(data: ClerkUserData): string | null {
|
function resolveDisplayName(data: ClerkUserData): string | null {
|
||||||
const first = (data.first_name ?? "").trim();
|
const first = (data.first_name ?? "").trim();
|
||||||
@@ -164,6 +173,7 @@ export async function handleClerkUserWebhook(opts: {
|
|||||||
const data = evt.data;
|
const data = evt.data;
|
||||||
const clerkUserId = data.id;
|
const clerkUserId = data.id;
|
||||||
const email = resolveEmail(data);
|
const email = resolveEmail(data);
|
||||||
|
const emailVerified = resolveEmailVerified(data);
|
||||||
const firstName = data.first_name ?? null;
|
const firstName = data.first_name ?? null;
|
||||||
const lastName = data.last_name ?? null;
|
const lastName = data.last_name ?? null;
|
||||||
const displayName = resolveDisplayName(data);
|
const displayName = resolveDisplayName(data);
|
||||||
@@ -174,10 +184,11 @@ export async function handleClerkUserWebhook(opts: {
|
|||||||
// ON CONFLICT instead of erroring on a duplicate.
|
// ON CONFLICT instead of erroring on a duplicate.
|
||||||
await conn.execute({
|
await conn.execute({
|
||||||
sql: `INSERT INTO users
|
sql: `INSERT INTO users
|
||||||
(id, clerkUserId, email, firstName, lastName, displayName, avatarUrl, provider, status)
|
(id, clerkUserId, email, emailVerified, firstName, lastName, displayName, avatarUrl, provider, status)
|
||||||
VALUES (?, ?, ?, ?, ?, ?, ?, 'clerk', 'active')
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, 'clerk', 'active')
|
||||||
ON CONFLICT(clerkUserId) DO UPDATE SET
|
ON CONFLICT(clerkUserId) DO UPDATE SET
|
||||||
email = excluded.email,
|
email = excluded.email,
|
||||||
|
emailVerified = excluded.emailVerified,
|
||||||
firstName = excluded.firstName,
|
firstName = excluded.firstName,
|
||||||
lastName = excluded.lastName,
|
lastName = excluded.lastName,
|
||||||
displayName = excluded.displayName,
|
displayName = excluded.displayName,
|
||||||
@@ -187,6 +198,7 @@ export async function handleClerkUserWebhook(opts: {
|
|||||||
crypto.randomUUID(),
|
crypto.randomUUID(),
|
||||||
clerkUserId,
|
clerkUserId,
|
||||||
email,
|
email,
|
||||||
|
emailVerified,
|
||||||
firstName,
|
firstName,
|
||||||
lastName,
|
lastName,
|
||||||
displayName,
|
displayName,
|
||||||
@@ -198,13 +210,14 @@ export async function handleClerkUserWebhook(opts: {
|
|||||||
await conn.execute({
|
await conn.execute({
|
||||||
sql: `UPDATE users SET
|
sql: `UPDATE users SET
|
||||||
email = ?,
|
email = ?,
|
||||||
|
emailVerified = ?,
|
||||||
firstName = ?,
|
firstName = ?,
|
||||||
lastName = ?,
|
lastName = ?,
|
||||||
displayName = ?,
|
displayName = ?,
|
||||||
avatarUrl = ?,
|
avatarUrl = ?,
|
||||||
updatedAt = datetime('now')
|
updatedAt = datetime('now')
|
||||||
WHERE clerkUserId = ?`,
|
WHERE clerkUserId = ?`,
|
||||||
args: [email, firstName, lastName, displayName, avatarUrl, clerkUserId]
|
args: [email, emailVerified, firstName, lastName, displayName, avatarUrl, clerkUserId]
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user