import { sql } from "drizzle-orm"; import { pgTable, uuid, varchar, text, integer, timestamp, primaryKey, uniqueIndex, index, } from "drizzle-orm/pg-core"; export const users = pgTable( "users", { id: uuid("id").primaryKey().defaultRandom(), email: varchar("email", { length: 255 }).notNull().unique(), name: varchar("name", { length: 255 }), avatarUrl: text("avatar_url"), createdAt: timestamp("created_at", { withTimezone: true }).defaultNow().notNull(), updatedAt: timestamp("updated_at", { withTimezone: true }).defaultNow().notNull(), }, (table) => ({ emailIdx: index("users_email_idx").on(table.email), // Belt-and-braces: existing column-level UNIQUE on `email` plus a // case-insensitive UNIQUE on `lower(email)`. The latter prevents // accidentally storing `Alice@x.com` and `alice@x.com` as two users // and makes the case-insensitive lookups in `apps/web/lib/auth.ts` // safe even if upstream rows were created mixed-case. emailLowerUnique: uniqueIndex("users_email_lower_unique").on(sql`lower(${table.email})`), }), ); export const accounts = pgTable( "accounts", { id: uuid("id").primaryKey().defaultRandom(), userId: uuid("user_id") .notNull() .references(() => users.id, { onDelete: "cascade" }), type: varchar("type", { length: 255 }).notNull(), provider: varchar("provider", { length: 255 }).notNull(), providerAccountId: varchar("provider_account_id", { length: 255 }).notNull(), refreshToken: text("refresh_token"), accessToken: text("access_token"), expiresAt: integer("expires_at"), tokenType: varchar("token_type", { length: 255 }), scope: varchar("scope", { length: 255 }), idToken: text("id_token"), sessionState: varchar("session_state", { length: 255 }), }, (table) => ({ providerAccountUnique: uniqueIndex("accounts_provider_provider_account_id_unique").on( table.provider, table.providerAccountId, ), userIdIdx: index("accounts_user_id_idx").on(table.userId), }), ); export const sessions = pgTable( "sessions", { id: uuid("id").primaryKey().defaultRandom(), sessionToken: varchar("session_token", { length: 255 }).notNull().unique(), userId: uuid("user_id") .notNull() .references(() => users.id, { onDelete: "cascade" }), expires: timestamp("expires", { withTimezone: true }).notNull(), }, (table) => ({ userIdIdx: index("sessions_user_id_idx").on(table.userId), }), ); export const verificationTokens = pgTable( "verification_tokens", { identifier: varchar("identifier", { length: 255 }).notNull(), token: varchar("token", { length: 255 }).notNull(), expires: timestamp("expires", { withTimezone: true }).notNull(), }, (table) => ({ pk: primaryKey({ columns: [table.identifier, table.token] }), }), ); /** * Multi-email identity. One `users` row can own many verified emails — one * "primary" (mirrored from `users.email` for cheap legacy lookups) plus * any number of OAuth-claimed or manually-verified addresses. * * Why this exists: a user who signs in via GitHub (alice@personal) and * later via Google (alice@gmail) would otherwise collide as two separate * `users` rows under the old `ensureUserIdByEmail` lookup. The identity * table is the source of truth for "which `users.id` does this email * belong to," and the invite-accept flow uses `userOwnsEmail()` against * it to verify that the human accepting an invite actually controls the * invited address (under any of their linked identities, not just their * primary one). * * Source values: * - 'primary' — mirror of `users.email` for the row that * existed at user creation. * - 'oauth:github' — captured from a verified GitHub OAuth claim. * - 'oauth:google' — captured from a verified Google OAuth claim. * - 'oauth:authentik' — captured from a verified Authentik OIDC claim. * - 'manual' — added by the user via the (future) one-time- * code verification flow. * * Constraints: * - `(user_id, email)` unique: one user can't have the same email * twice across sources. (A second provider claiming an email that's * already linked just bumps `last_used_at`.) * - `email` unique WHERE `verified_at IS NOT NULL`: a verified email * can only resolve to one `users` row globally. Unverified rows * (none exist yet, but the column is in place for the manual-verify * flow) don't share the constraint. */ export const userEmailIdentities = pgTable( "user_email_identities", { id: uuid("id").primaryKey().defaultRandom(), userId: uuid("user_id") .notNull() .references(() => users.id, { onDelete: "cascade" }), /** Stored lowercased. Callers are responsible for `.toLowerCase()`. */ email: varchar("email", { length: 255 }).notNull(), verifiedAt: timestamp("verified_at", { withTimezone: true }), source: varchar("source", { length: 30 }).notNull(), createdAt: timestamp("created_at", { withTimezone: true }).defaultNow().notNull(), lastUsedAt: timestamp("last_used_at", { withTimezone: true }), }, (table) => ({ userIdx: index("user_email_identities_user_id_idx").on(table.userId), emailIdx: index("user_email_identities_email_idx").on(table.email), userEmailUnique: uniqueIndex("user_email_identities_user_id_email_unique").on( table.userId, table.email, ), verifiedEmailUnique: uniqueIndex("user_email_identities_verified_email_unique") .on(table.email) .where(sql`${table.verifiedAt} IS NOT NULL`), }), );