fix: no user ever had a username, so people search found nobody
Root cause of "still cannot find users": username was optional in better-auth's schema and no signup form or settings page ever set it, but search/profile/follow all key on username, not user id. Every account now gets a unique one auto-generated (from name/email) in the user.create.before hook — covers email/password and OAuth signups. Added a one-off db:backfill-usernames script for accounts created before this fix and ran it against the current database. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
@@ -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.
|
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
|
## 0.13.0 — 2026-07-13 15:37
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import { sendEmail, verifyEmailHtml, resetPasswordHtml, welcomeHtml } from "@/li
|
|||||||
import { isSignupsDisabled } from "@/lib/site-settings";
|
import { isSignupsDisabled } from "@/lib/site-settings";
|
||||||
import { findValidInvite, consumeInvite, INVITE_COOKIE } from "@/lib/invites";
|
import { findValidInvite, consumeInvite, INVITE_COOKIE } from "@/lib/invites";
|
||||||
import { gravatarUrl } from "@/lib/gravatar";
|
import { gravatarUrl } from "@/lib/gravatar";
|
||||||
|
import { generateUniqueUsername } from "@/lib/username";
|
||||||
|
|
||||||
export const auth = betterAuth({
|
export const auth = betterAuth({
|
||||||
trustedOrigins: [process.env["BETTER_AUTH_URL"] ?? "http://localhost:3000"],
|
trustedOrigins: [process.env["BETTER_AUTH_URL"] ?? "http://localhost:3000"],
|
||||||
@@ -94,13 +95,21 @@ export const auth = betterAuth({
|
|||||||
user: {
|
user: {
|
||||||
create: {
|
create: {
|
||||||
before: async (user, context) => {
|
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 token = context?.getCookie(INVITE_COOKIE);
|
||||||
const invite = token ? await findValidInvite(token, user.email) : null;
|
const invite = token ? await findValidInvite(token, user.email) : null;
|
||||||
if (!invite) return false;
|
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) => {
|
after: async (user, context) => {
|
||||||
// First registered user becomes admin
|
// First registered user becomes admin
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
// Mirrors CHANGELOG.md at the repo root — update both together.
|
// 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 = {
|
export type ChangelogEntry = {
|
||||||
version: string;
|
version: string;
|
||||||
@@ -11,6 +11,13 @@ export type ChangelogEntry = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export const CHANGELOG: 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",
|
version: "0.13.0",
|
||||||
date: "2026-07-13 15:37",
|
date: "2026-07-13 15:37",
|
||||||
|
|||||||
@@ -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<string> {
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@epicure/web",
|
"name": "@epicure/web",
|
||||||
"version": "0.13.0",
|
"version": "0.13.1",
|
||||||
"private": true,
|
"private": true,
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "next dev",
|
"dev": "next dev",
|
||||||
|
|||||||
+2
-1
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "epicure",
|
"name": "epicure",
|
||||||
"version": "0.13.0",
|
"version": "0.13.1",
|
||||||
"private": true,
|
"private": true,
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "pnpm --filter web dev",
|
"dev": "pnpm --filter web dev",
|
||||||
@@ -10,6 +10,7 @@
|
|||||||
"db:generate": "pnpm --filter @epicure/db generate",
|
"db:generate": "pnpm --filter @epicure/db generate",
|
||||||
"db:migrate": "pnpm --filter @epicure/db migrate",
|
"db:migrate": "pnpm --filter @epicure/db migrate",
|
||||||
"db:seed": "pnpm --filter @epicure/db seed",
|
"db:seed": "pnpm --filter @epicure/db seed",
|
||||||
|
"db:backfill-usernames": "pnpm --filter @epicure/db backfill-usernames",
|
||||||
"db:studio": "pnpm --filter @epicure/db studio"
|
"db:studio": "pnpm --filter @epicure/db studio"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
|||||||
@@ -13,6 +13,7 @@
|
|||||||
"migrate": "drizzle-kit migrate",
|
"migrate": "drizzle-kit migrate",
|
||||||
"studio": "drizzle-kit studio",
|
"studio": "drizzle-kit studio",
|
||||||
"seed": "tsx src/seed.ts",
|
"seed": "tsx src/seed.ts",
|
||||||
|
"backfill-usernames": "tsx src/backfill-usernames.ts",
|
||||||
"typecheck": "tsc --noEmit"
|
"typecheck": "tsc --noEmit"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
|||||||
@@ -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);
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user