User.email and implementation

This commit is contained in:
2026-01-28 21:34:26 +00:00
parent d4cc50f289
commit c0e06ac8ba
12 changed files with 1220 additions and 19 deletions

View File

@@ -0,0 +1,4 @@
ALTER TABLE "User" ADD COLUMN "email" varchar(255);--> statement-breakpoint
UPDATE "User" SET "email" = 'user_' || id || '@placeholder.local' WHERE "email" IS NULL;--> statement-breakpoint
ALTER TABLE "User" ALTER COLUMN "email" SET NOT NULL;--> statement-breakpoint
ALTER TABLE "User" ADD CONSTRAINT "User_email_unique" UNIQUE("email");

File diff suppressed because it is too large Load Diff

View File

@@ -190,6 +190,13 @@
"when": 1769615487574, "when": 1769615487574,
"tag": "0026_stale_shocker", "tag": "0026_stale_shocker",
"breakpoints": true "breakpoints": true
},
{
"idx": 27,
"version": "7",
"when": 1769635016079,
"tag": "0027_volatile_otto_octavius",
"breakpoints": true
} }
] ]
} }

View File

@@ -97,15 +97,15 @@ const issueComments = [
const passwordHash = await hashPassword("a"); const passwordHash = await hashPassword("a");
const users = [ const users = [
{ name: "user 1", username: "u1", passwordHash, avatarURL: null }, { name: "user 1", username: "u1", email: "user1@example.com", passwordHash, avatarURL: null },
{ name: "user 2", username: "u2", passwordHash, avatarURL: null }, { name: "user 2", username: "u2", email: "user2@example.com", passwordHash, avatarURL: null },
// anything past here is just to have more users to assign issues to // anything past here is just to have more users to assign issues to
{ name: "user 3", username: "u3", passwordHash, avatarURL: null }, { name: "user 3", username: "u3", email: "user3@example.com", passwordHash, avatarURL: null },
{ name: "user 4", username: "u4", passwordHash, avatarURL: null }, { name: "user 4", username: "u4", email: "user4@example.com", passwordHash, avatarURL: null },
{ name: "user 5", username: "u5", passwordHash, avatarURL: null }, { name: "user 5", username: "u5", email: "user5@example.com", passwordHash, avatarURL: null },
{ name: "user 6", username: "u6", passwordHash, avatarURL: null }, { name: "user 6", username: "u6", email: "user6@example.com", passwordHash, avatarURL: null },
{ name: "user 7", username: "u7", passwordHash, avatarURL: null }, { name: "user 7", username: "u7", email: "user7@example.com", passwordHash, avatarURL: null },
{ name: "user 8", username: "u8", passwordHash, avatarURL: null }, { name: "user 8", username: "u8", email: "user8@example.com", passwordHash, avatarURL: null },
]; ];
async function seed() { async function seed() {

View File

@@ -5,10 +5,14 @@ import { db } from "../client";
export async function createUser( export async function createUser(
name: string, name: string,
username: string, username: string,
email: string,
passwordHash: string, passwordHash: string,
avatarURL?: string | null, avatarURL?: string | null,
) { ) {
const [user] = await db.insert(User).values({ name, username, passwordHash, avatarURL }).returning(); const [user] = await db
.insert(User)
.values({ name, username, email, passwordHash, avatarURL })
.returning();
return user; return user;
} }
@@ -22,6 +26,11 @@ export async function getUserByUsername(username: string) {
return user; return user;
} }
export async function getUserByEmail(email: string) {
const [user] = await db.select().from(User).where(eq(User.email, email));
return user;
}
export async function updateById( export async function updateById(
id: number, id: number,
updates: { updates: {

View File

@@ -2,6 +2,7 @@ import { RegisterRequestSchema } from "@sprint/shared";
import type { BunRequest } from "bun"; import type { BunRequest } from "bun";
import { buildAuthCookie, generateToken, hashPassword } from "../../auth/utils"; import { buildAuthCookie, generateToken, hashPassword } from "../../auth/utils";
import { createSession, createUser, getUserByUsername } from "../../db/queries"; import { createSession, createUser, getUserByUsername } from "../../db/queries";
import { getUserByEmail } from "../../db/queries/users";
import { errorResponse, parseJsonBody } from "../../validation"; import { errorResponse, parseJsonBody } from "../../validation";
export default async function register(req: BunRequest) { export default async function register(req: BunRequest) {
@@ -12,15 +13,20 @@ export default async function register(req: BunRequest) {
const parsed = await parseJsonBody(req, RegisterRequestSchema); const parsed = await parseJsonBody(req, RegisterRequestSchema);
if ("error" in parsed) return parsed.error; if ("error" in parsed) return parsed.error;
const { name, username, password, avatarURL } = parsed.data; const { name, username, email, password, avatarURL } = parsed.data;
const existing = await getUserByUsername(username); const existingUsername = await getUserByUsername(username);
if (existing) { if (existingUsername) {
return errorResponse("username already taken", "USERNAME_TAKEN", 400); return errorResponse("username already taken", "USERNAME_TAKEN", 400);
} }
const existingEmail = await getUserByEmail(email);
if (existingEmail) {
return errorResponse("email already registered", "EMAIL_TAKEN", 400);
}
const passwordHash = await hashPassword(password); const passwordHash = await hashPassword(password);
const user = await createUser(name, username, passwordHash, avatarURL); const user = await createUser(name, username, email, passwordHash, avatarURL);
if (!user) { if (!user) {
return errorResponse("failed to create user", "USER_CREATE_ERROR", 500); return errorResponse("failed to create user", "USER_CREATE_ERROR", 500);
} }

View File

@@ -39,10 +39,8 @@ async function handler(req: AuthedRequest) {
const quantity = Math.max(1, totalMembers - 5); const quantity = Math.max(1, totalMembers - 5);
const priceId = billingPeriod === "annual" ? STRIPE_PRICE_ANNUAL : STRIPE_PRICE_MONTHLY; const priceId = billingPeriod === "annual" ? STRIPE_PRICE_ANNUAL : STRIPE_PRICE_MONTHLY;
// build customer data - use username as email if no email field exists // use the user's email from the database
const customerEmail = user.username.includes("@") const customerEmail = user.email;
? user.username
: `${user.username}@localhost.local`;
const session = await stripe.checkout.sessions.create({ const session = await stripe.checkout.sessions.create({
customer_email: customerEmail, customer_email: customerEmail,

View File

@@ -1,6 +1,6 @@
/** biome-ignore-all lint/correctness/useExhaustiveDependencies: <> */ /** biome-ignore-all lint/correctness/useExhaustiveDependencies: <> */
import { USER_NAME_MAX_LENGTH, USER_USERNAME_MAX_LENGTH } from "@sprint/shared"; import { USER_EMAIL_MAX_LENGTH, USER_NAME_MAX_LENGTH, USER_USERNAME_MAX_LENGTH } from "@sprint/shared";
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { useNavigate, useSearchParams } from "react-router-dom"; import { useNavigate, useSearchParams } from "react-router-dom";
import Avatar from "@/components/avatar"; import Avatar from "@/components/avatar";
@@ -36,6 +36,7 @@ export default function LogInForm({
const [name, setName] = useState(""); const [name, setName] = useState("");
const [username, setUsername] = useState(""); const [username, setUsername] = useState("");
const [email, setEmail] = useState("");
const [password, setPassword] = useState(""); const [password, setPassword] = useState("");
const [avatarURL, setAvatarUrl] = useState<string | null>(null); const [avatarURL, setAvatarUrl] = useState<string | null>(null);
const [error, setError] = useState(""); const [error, setError] = useState("");
@@ -75,7 +76,7 @@ export default function LogInForm({
}; };
const register = () => { const register = () => {
if (name.trim() === "" || username.trim() === "" || password.trim() === "") { if (name.trim() === "" || username.trim() === "" || email.trim() === "" || password.trim() === "") {
return; return;
} }
@@ -85,6 +86,7 @@ export default function LogInForm({
body: JSON.stringify({ body: JSON.stringify({
name, name,
username, username,
email,
password, password,
avatarURL, avatarURL,
}), }),
@@ -129,6 +131,7 @@ export default function LogInForm({
setError(""); setError("");
setSubmitAttempted(false); setSubmitAttempted(false);
setAvatarUrl(null); setAvatarUrl(null);
setEmail("");
requestAnimationFrame(() => focusFirstInput()); requestAnimationFrame(() => focusFirstInput());
}; };
@@ -249,6 +252,15 @@ export default function LogInForm({
spellcheck={false} spellcheck={false}
maxLength={USER_NAME_MAX_LENGTH} maxLength={USER_NAME_MAX_LENGTH}
/> />
<Field
label="Email"
value={email}
onChange={(e) => setEmail(e.target.value)}
validate={(v) => (v.trim() === "" ? "Cannot be empty" : undefined)}
submitAttempted={submitAttempted}
spellcheck={false}
maxLength={USER_EMAIL_MAX_LENGTH}
/>
</> </>
)} )}
<Field <Field

View File

@@ -9,6 +9,7 @@ import {
ORG_NAME_MAX_LENGTH, ORG_NAME_MAX_LENGTH,
ORG_SLUG_MAX_LENGTH, ORG_SLUG_MAX_LENGTH,
PROJECT_NAME_MAX_LENGTH, PROJECT_NAME_MAX_LENGTH,
USER_EMAIL_MAX_LENGTH,
USER_NAME_MAX_LENGTH, USER_NAME_MAX_LENGTH,
USER_USERNAME_MAX_LENGTH, USER_USERNAME_MAX_LENGTH,
} from "./constants"; } from "./constants";
@@ -40,6 +41,7 @@ export const RegisterRequestSchema = z.object({
.min(1, "Username is required") .min(1, "Username is required")
.max(USER_USERNAME_MAX_LENGTH) .max(USER_USERNAME_MAX_LENGTH)
.regex(/^[a-zA-Z0-9_-]+$/, "Username can only contain letters, numbers, underscores, and hyphens"), .regex(/^[a-zA-Z0-9_-]+$/, "Username can only contain letters, numbers, underscores, and hyphens"),
email: z.string().min(1, "Email is required").email("Invalid email address").max(USER_EMAIL_MAX_LENGTH),
password: z password: z
.string() .string()
.min(8, "Password must be at least 8 characters") .min(8, "Password must be at least 8 characters")

View File

@@ -1,5 +1,6 @@
export const USER_NAME_MAX_LENGTH = 64; export const USER_NAME_MAX_LENGTH = 64;
export const USER_USERNAME_MAX_LENGTH = 32; export const USER_USERNAME_MAX_LENGTH = 32;
export const USER_EMAIL_MAX_LENGTH = 256;
export const ORG_NAME_MAX_LENGTH = 64; export const ORG_NAME_MAX_LENGTH = 64;
export const ORG_DESCRIPTION_MAX_LENGTH = 1024; export const ORG_DESCRIPTION_MAX_LENGTH = 1024;

View File

@@ -146,6 +146,7 @@ export {
PROJECT_DESCRIPTION_MAX_LENGTH, PROJECT_DESCRIPTION_MAX_LENGTH,
PROJECT_NAME_MAX_LENGTH, PROJECT_NAME_MAX_LENGTH,
PROJECT_SLUG_MAX_LENGTH, PROJECT_SLUG_MAX_LENGTH,
USER_EMAIL_MAX_LENGTH,
USER_NAME_MAX_LENGTH, USER_NAME_MAX_LENGTH,
USER_USERNAME_MAX_LENGTH, USER_USERNAME_MAX_LENGTH,
} from "./constants"; } from "./constants";

View File

@@ -11,6 +11,7 @@ import {
ORG_NAME_MAX_LENGTH, ORG_NAME_MAX_LENGTH,
ORG_SLUG_MAX_LENGTH, ORG_SLUG_MAX_LENGTH,
PROJECT_NAME_MAX_LENGTH, PROJECT_NAME_MAX_LENGTH,
USER_EMAIL_MAX_LENGTH,
USER_NAME_MAX_LENGTH, USER_NAME_MAX_LENGTH,
USER_USERNAME_MAX_LENGTH, USER_USERNAME_MAX_LENGTH,
} from "./constants"; } from "./constants";
@@ -56,6 +57,7 @@ export const User = pgTable("User", {
id: integer().primaryKey().generatedAlwaysAsIdentity(), id: integer().primaryKey().generatedAlwaysAsIdentity(),
name: varchar({ length: USER_NAME_MAX_LENGTH }).notNull(), name: varchar({ length: USER_NAME_MAX_LENGTH }).notNull(),
username: varchar({ length: USER_USERNAME_MAX_LENGTH }).notNull().unique(), username: varchar({ length: USER_USERNAME_MAX_LENGTH }).notNull().unique(),
email: varchar({ length: USER_EMAIL_MAX_LENGTH }).notNull().unique(),
passwordHash: varchar({ length: 255 }).notNull(), passwordHash: varchar({ length: 255 }).notNull(),
avatarURL: varchar({ length: 512 }), avatarURL: varchar({ length: 512 }),
iconPreference: varchar({ length: 10 }).notNull().default("pixel").$type<IconStyle>(), iconPreference: varchar({ length: 10 }).notNull().default("pixel").$type<IconStyle>(),