import { and, eq, isNotNull } from "drizzle-orm"; import { userEmailIdentities } from "@tasks/database/schema"; import { db } from "@tasks/database"; /** * Identity helpers built on top of the `user_email_identities` table. * * Why this module exists: invite acceptance (and any future feature that * binds an action to "the human who owns this email address") needs to * answer the question *"does this `users.id` actually control this email?"* * without leaking the wrong answer when the user signed in via a different * provider than the invite was sent to. * * The answer is: *yes* iff the user has a row in `user_email_identities` * with the lowercased email and `verified_at IS NOT NULL`. Both the * `source='primary'` mirror of `users.email` and any OAuth-claimed or * manually-verified identity counts. */ /** * Returns `true` iff the given user owns the given (lowercased) email * as a verified identity. Case-insensitive — callers may pass any case * and this function normalizes. * * This is the single source of truth for "is this email under this * user's control?" — invite acceptance, profile-bound API access, and * any future per-email permission check should funnel through here. */ export async function userOwnsEmail( userId: string, email: string, ): Promise { const emailLower = email.trim().toLowerCase(); if (!userId || !emailLower) return false; const rows = await db .select({ id: userEmailIdentities.id }) .from(userEmailIdentities) .where( and( eq(userEmailIdentities.userId, userId), eq(userEmailIdentities.email, emailLower), isNotNull(userEmailIdentities.verifiedAt), ), ) .limit(1); return rows.length > 0; } /** * Look up the `users.id` that owns a verified email, or `null` if no * verified identity matches. Used by the sign-in callback to resolve * an OAuth provider's email claim to the canonical user — replacing * the old `ensureUserIdByEmail` lookup against `users.email`. * * Note: this only returns matches where `verified_at IS NOT NULL`. * The (future) "pending manual verification" rows from * `Task-manual-email-verification` are correctly invisible here. */ export async function findUserIdByVerifiedEmail( email: string, ): Promise { const emailLower = email.trim().toLowerCase(); if (!emailLower) return null; const rows = await db .select({ userId: userEmailIdentities.userId }) .from(userEmailIdentities) .where( and( eq(userEmailIdentities.email, emailLower), isNotNull(userEmailIdentities.verifiedAt), ), ) .limit(1); return rows[0]?.userId ?? null; }