assessment-model/src/app/db/schema/users.ts
Khalim Conn-Kowlessar d042606955 Add 6-digit code sign-in as primary, magic link as fast-path fallback
Same email now contains a 6-digit code and a magic link, both backed by a
single verificationToken row. After submitting their email, the user
lands on /auth/verify-code with a single-input form (inputmode=numeric,
autocomplete=one-time-code, auto-submit on 6 digits or paste) and can
either type the code or use the link from the email. Either path
consumes the same row — single-use, replace-on-resend.

This is the structural fix for the silent-quarantine pattern observed
with Atkins and Sustainable Building UK: corporate gateways are happier
with short transactional content than long opaque token URLs, and a
code can't be broken by SafeLinks-style URL rewriting. The link path is
preserved so users whose email gets through unmangled keep one-click UX.

Security:
  - Codes are 6-digit, crypto.randomInt-generated, stored as sha256
    hashed against NEXTAUTH_SECRET on the same row as the link token
  - 5-attempt lockout per code (attempts column); 6th attempt with the
    correct code still fails
  - Per-email send rate limit: 5/hour fixed window (authRateLimits
    table); 6th send returns an error
  - Code + link share a 10-minute window (maxAge dropped from 1h)
  - Resending replaces any prior token rows for the identifier so only
    the latest send is ever live

Implementation:
  - verificationCode.ts holds generateCode + hashCode + the pure
    evaluateCodeAttempt decision tree; 9 unit tests cover every branch
    of the verification outcome (no-such-row, expired, locked-out, ok,
    wrong-with-newAttempts, locked-out-still-rejects-correct-code)
  - sendVerificationRequest now hashes the URL token the same way
    /verify/[token]/page.tsx does, applies the rate limit + records the
    code + replaces older rows in two transactions
  - CredentialsProvider (id: "email-code") calls evaluateCodeAttempt
    inside a transaction, handles all 5 outcomes, creates the user on
    first successful code (parity with the magic-link callback path)
  - oauthId backfill in the signIn callback is now guarded on
    account.type === "oauth" so the credentials flow doesn't pollute
    oauthProvider with "email-code"
  - Migration is additive: code_hash nullable, attempts default 0; new
    authRateLimits table is independent. In-flight tokens at deploy time
    keep working via the link path.

Vercel preview deployment is the test surface; a Mailpit + Cypress E2E
loop is intentionally deferred per the lean-setup plan in docs/wip/
auth-email-code-fallback-plan.md.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-27 14:16:47 +00:00

210 lines
5.5 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import {
bigint,
bigserial,
text,
timestamp,
pgTable,
primaryKey,
integer,
boolean,
json,
pgEnum,
varchar,
} from "drizzle-orm/pg-core";
import { InferModel } from "drizzle-orm";
// -------------------------
// USERS
// -------------------------
export const user = pgTable("user", {
id: bigserial("id", { mode: "bigint" }).primaryKey(),
firstName: text("firstName"),
email: text("email").notNull().unique(),
emailVerified: timestamp("emailVerified", { mode: "date" }),
oauthId: text("oauth_id"),
oauthProvider: text("oauth_provider").$type<
"google" | "credentials" | "azure-ad-b2c"
>(),
image: text("image"),
onboarded: boolean("onboarded").default(false).notNull(),
lastLogin: timestamp("last_login", { mode: "date" }),
createdAt: timestamp("created_at", { precision: 6, withTimezone: true })
.defaultNow()
.notNull(),
updatedAt: timestamp("updated_at", { precision: 6, withTimezone: true })
.defaultNow()
.notNull(),
});
// -------------------------
// ACCOUNTS (OAuth providers)
// -------------------------
export const accounts = pgTable(
"account",
{
userId: bigint("userId", { mode: "bigint" })
.notNull()
.references(() => user.id, { onDelete: "cascade" }),
type: text("type").$type<"oauth" | "email" | "credentials">().notNull(),
provider: text("provider").notNull(),
providerAccountId: text("providerAccountId").notNull(),
refresh_token: text("refresh_token"),
access_token: text("access_token"),
expires_at: integer("expires_at"),
token_type: text("token_type"),
scope: text("scope"),
id_token: text("id_token"),
session_state: text("session_state"),
},
(account) => [
primaryKey({ columns: [account.provider, account.providerAccountId] }),
]
);
export const sessions = pgTable("session", {
sessionToken: text("sessionToken").primaryKey(),
userId: bigint("userId", { mode: "bigint" })
.notNull()
.references(() => user.id, { onDelete: "cascade" }),
expires: timestamp("expires", { mode: "date" }).notNull(),
});
export const verificationTokens = pgTable(
"verificationToken",
{
identifier: text("identifier").notNull(),
token: text("token").notNull(),
expires: timestamp("expires", { mode: "date" }).notNull(),
codeHash: text("code_hash"),
attempts: integer("attempts").notNull().default(0),
},
(vt) => [primaryKey({ columns: [vt.identifier, vt.token] })]
);
export const authRateLimits = pgTable(
"authRateLimits",
{
scope: text("scope").notNull(),
key: text("key").notNull(),
count: integer("count").notNull().default(0),
windowStart: timestamp("window_start", { mode: "date" }).notNull(),
},
(rl) => [primaryKey({ columns: [rl.scope, rl.key] })]
);
export const UserType: [string, ...string[]] = [
"private_landlord",
"private_tenant",
"social_landlord",
"social_tenant",
"homeowner",
"other",
];
export const PropertyCount: [string, ...string[]] = [
// Private landlord options
"1",
"25",
"620",
"21+",
// Social landlord options
"150",
"51100",
"101300",
"3011000",
"1000+",
];
export const ReferralSource: [string, ...string[]] = [
"search",
"social_media",
"NRLA",
"partner",
"word_of_mouth",
"other",
];
export const Goal: [string, ...string[]] = [
"access_funding",
"net_zero",
"improve_condition",
"save_money",
"other",
];
export const userTypeEnum = pgEnum("user_profiles_user_type", UserType);
export const propertyCountEnum = pgEnum(
"user_profiles_property_count",
PropertyCount
);
export const referralSourceEnum = pgEnum(
"user_profiles_referral_source",
ReferralSource
);
// ----------------------------
// MAIN TABLE
// ----------------------------
export const userProfiles = pgTable("user_profiles", {
id: bigserial("id", { mode: "bigint" }).primaryKey(),
userId: bigint("user_id", { mode: "bigint" })
.notNull()
.references(() => user.id, { onDelete: "cascade" }),
// Profile
userType: userTypeEnum("user_type").notNull(),
propertyCount: propertyCountEnum("property_count"), // Nullable for homeowners / tenants
// Goals (multi-select)
goals: json("goals").$type<(typeof Goal)[number][]>(),
// Referral
referralSource: referralSourceEnum("referral_source"),
nrlaMembershipId: varchar("nrla_membership_id", { length: 255 }),
// Compliance
acceptedPrivacy: boolean("accepted_privacy").notNull().default(false),
acceptedPrivacyAt: timestamp("accepted_privacy_at", {
withTimezone: true,
precision: 6,
}),
// Marketing
marketingOptIn: boolean("marketing_opt_in").default(false),
marketingOptInAt: timestamp("marketing_opt_in_at", {
withTimezone: true,
precision: 6,
}),
// Basic user identity
firstName: text("first_name"),
lastName: text("last_name"),
// Metadata
createdAt: timestamp("created_at", {
precision: 6,
withTimezone: true,
})
.defaultNow()
.notNull(),
updatedAt: timestamp("updated_at", {
precision: 6,
withTimezone: true,
})
.defaultNow()
.notNull(),
});
// -------------------------
// Types
// -------------------------
export type User = InferModel<typeof user, "select">;
export type NewUser = InferModel<typeof user, "insert">;
export type Account = InferModel<typeof accounts, "select">;
export type Session = InferModel<typeof sessions, "select">;
export type VerificationToken = InferModel<typeof verificationTokens, "select">;
export type UserProfile = InferModel<typeof userProfiles, "select">;
export type NewUserProfile = InferModel<typeof userProfiles, "insert">;