diff --git a/CHANGELOG.md b/CHANGELOG.md index bbffb48..c4aaa45 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,11 @@ All notable changes to Epicure are documented here. This file is mirrored in-app at `/changelog` (and in the admin dashboard) via `apps/web/lib/changelog.ts` — update both together. +## 0.13.1 — 2026-07-13 17:25 + +### Fixed +- People search never found anyone, for anyone — there was no signup or settings flow that ever set a username, and search (along with profiles and follow) requires one. Every account now gets one automatically on signup; existing accounts were backfilled. + ## 0.13.0 — 2026-07-13 15:37 ### Added diff --git a/apps/web/lib/auth/server.ts b/apps/web/lib/auth/server.ts index 9a8a095..15b5be3 100644 --- a/apps/web/lib/auth/server.ts +++ b/apps/web/lib/auth/server.ts @@ -6,6 +6,7 @@ import { sendEmail, verifyEmailHtml, resetPasswordHtml, welcomeHtml } from "@/li import { isSignupsDisabled } from "@/lib/site-settings"; import { findValidInvite, consumeInvite, INVITE_COOKIE } from "@/lib/invites"; import { gravatarUrl } from "@/lib/gravatar"; +import { generateUniqueUsername } from "@/lib/username"; export const auth = betterAuth({ trustedOrigins: [process.env["BETTER_AUTH_URL"] ?? "http://localhost:3000"], @@ -94,13 +95,21 @@ export const auth = betterAuth({ user: { create: { before: async (user, context) => { - if (!(await isSignupsDisabled())) return; + // No signup form or settings page ever lets someone set a username, + // yet profiles, follows, and people-search all key on it — so every + // account needs one generated here, or those features silently see + // nobody. OAuth signups may already have one (mapped from the + // provider profile); email/password never does. + const existingUsername = (user as { username?: string | null }).username; + const username = existingUsername || (await generateUniqueUsername(user.name || user.email)); + + if (!(await isSignupsDisabled())) return { data: { ...user, username } }; const token = context?.getCookie(INVITE_COOKIE); const invite = token ? await findValidInvite(token, user.email) : null; if (!invite) return false; - return { data: { ...user, role: invite.role, tier: invite.tier } }; + return { data: { ...user, username, role: invite.role, tier: invite.tier } }; }, after: async (user, context) => { // First registered user becomes admin diff --git a/apps/web/lib/changelog.ts b/apps/web/lib/changelog.ts index 635e090..d8b2f86 100644 --- a/apps/web/lib/changelog.ts +++ b/apps/web/lib/changelog.ts @@ -1,5 +1,5 @@ // Mirrors CHANGELOG.md at the repo root — update both together. -export const APP_VERSION = "0.13.0"; +export const APP_VERSION = "0.13.1"; export type ChangelogEntry = { version: string; @@ -11,6 +11,13 @@ export type ChangelogEntry = { }; export const CHANGELOG: ChangelogEntry[] = [ + { + version: "0.13.1", + date: "2026-07-13 17:25", + fixed: [ + "People search never found anyone, for anyone — there was no signup or settings flow that ever set a username, and search (along with profiles and follow) requires one. Every account now gets one automatically on signup; existing accounts were backfilled.", + ], + }, { version: "0.13.0", date: "2026-07-13 15:37", diff --git a/apps/web/lib/username.ts b/apps/web/lib/username.ts new file mode 100644 index 0000000..c1d7316 --- /dev/null +++ b/apps/web/lib/username.ts @@ -0,0 +1,33 @@ +import { db, users, eq } from "@epicure/db"; + +/** Lowercase alnum-and-underscore slug, at least 3 chars, at most 20. */ +function slugify(seed: string): string { + const base = seed + .split("@")[0]! // if seed is an email, drop the domain + .toLowerCase() + .replace(/[^a-z0-9_]/g, "") + .slice(0, 20); + return base.length >= 3 ? base : `${base}user`.slice(0, 20); +} + +/** + * Every user needs a username — it's the only thing profile pages, follows, + * and people-search key on, but there's no signup-time or settings UI to set + * one. Called from the user.create.before hook (lib/auth/server.ts) so every + * account gets one automatically, generated from their name or email. + */ +export async function generateUniqueUsername(seed: string): Promise { + const base = slugify(seed); + let candidate = base; + let suffix = 0; + + while (true) { + const existing = await db.query.users.findFirst({ + where: eq(users.username, candidate), + columns: { id: true }, + }); + if (!existing) return candidate; + suffix += 1; + candidate = `${base}${suffix}`.slice(0, 20); + } +} diff --git a/apps/web/package.json b/apps/web/package.json index ee95459..6a0f7b9 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -1,6 +1,6 @@ { "name": "@epicure/web", - "version": "0.13.0", + "version": "0.13.1", "private": true, "scripts": { "dev": "next dev", diff --git a/package.json b/package.json index 6b9e589..02758e6 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "epicure", - "version": "0.13.0", + "version": "0.13.1", "private": true, "scripts": { "dev": "pnpm --filter web dev", @@ -10,6 +10,7 @@ "db:generate": "pnpm --filter @epicure/db generate", "db:migrate": "pnpm --filter @epicure/db migrate", "db:seed": "pnpm --filter @epicure/db seed", + "db:backfill-usernames": "pnpm --filter @epicure/db backfill-usernames", "db:studio": "pnpm --filter @epicure/db studio" }, "devDependencies": { diff --git a/packages/db/package.json b/packages/db/package.json index 2e187a9..659d204 100644 --- a/packages/db/package.json +++ b/packages/db/package.json @@ -13,6 +13,7 @@ "migrate": "drizzle-kit migrate", "studio": "drizzle-kit studio", "seed": "tsx src/seed.ts", + "backfill-usernames": "tsx src/backfill-usernames.ts", "typecheck": "tsc --noEmit" }, "dependencies": { diff --git a/packages/db/src/backfill-usernames.ts b/packages/db/src/backfill-usernames.ts new file mode 100644 index 0000000..83e4aad --- /dev/null +++ b/packages/db/src/backfill-usernames.ts @@ -0,0 +1,51 @@ +import { db } from "./client"; +import { users } from "./schema"; +import { eq, isNull } from "drizzle-orm"; + +// Mirrors apps/web/lib/username.ts's slugify — duplicated rather than shared +// since packages/db doesn't depend on apps/web. Kept intentionally simple. +function slugify(seed: string): string { + const base = seed + .split("@")[0]! + .toLowerCase() + .replace(/[^a-z0-9_]/g, "") + .slice(0, 20); + return base.length >= 3 ? base : `${base}user`.slice(0, 20); +} + +async function backfillUsernames() { + const rows = await db.query.users.findMany({ + where: isNull(users.username), + columns: { id: true, name: true, email: true }, + }); + + console.log(`${rows.length} user(s) missing a username.`); + + const taken = new Set( + (await db.query.users.findMany({ columns: { username: true } })) + .map((u) => u.username) + .filter((u): u is string => !!u) + ); + + for (const row of rows) { + const base = slugify(row.name || row.email); + let candidate = base; + let suffix = 0; + while (taken.has(candidate)) { + suffix += 1; + candidate = `${base}${suffix}`.slice(0, 20); + } + taken.add(candidate); + + await db.update(users).set({ username: candidate }).where(eq(users.id, row.id)); + console.log(` ${row.email} -> @${candidate}`); + } + + console.log("Backfill complete."); + process.exit(0); +} + +backfillUsernames().catch((err) => { + console.error(err); + process.exit(1); +});