Skip to content

Fix duplicate companies: enforce unique names + searchable company picker#400

Merged
leerob merged 7 commits into
mainfrom
cursor/company-duplicate-cleanup
May 27, 2026
Merged

Fix duplicate companies: enforce unique names + searchable company picker#400
leerob merged 7 commits into
mainfrom
cursor/company-duplicate-cleanup

Conversation

@leerob
Copy link
Copy Markdown
Collaborator

@leerob leerob commented May 26, 2026

Summary

Companies were being duplicated heavily (e.g. 7 separate "Contentful" rows, ~260 duplicate rows in total) because company creation only de-duped on a freshly-minted client-side id while a BEFORE INSERT trigger silently uniquified slugs (contentful-1, contentful-2, …). This PR fixes the root cause and makes existing companies pickable so users don't need to re-create them.

Prevent duplicate company records

  • New migration supabase/migrations/20260526_companies_unique_name.sql: normalizes existing names, adds a generated name_key column, and a case-insensitive unique index so duplicates are rejected at the DB level even under concurrent/double submits.
  • upsert-company action: treats a request as an edit only when the row already exists, and reuses the existing company on a name conflict (idempotent) instead of creating a duplicate.
  • Company form: generates the id once per mount so it stops changing on every render (which also broke the logo upload path).

Searchable company picker

  • Replaces the owner-only company dropdown with a searchable combobox (shadcn Command + Popover, adds cmdk).
  • Searches across all companies (RLS allows public read), with the user's own companies listed first and "Add company" kept as the fallback.
  • This closes the gap the unique constraint would otherwise create: if a colleague already submitted the company, you can now find and select it instead of being blocked.

Test plan

  • Submitting the same company name twice (incl. rapid double-click) resolves to one company instead of duplicates.
  • Editing an existing company still works and preserves its slug.
  • In the MCP form, searching the company picker surfaces companies created by other users; selecting one attaches it.
  • "Add company" for a brand-new name creates it; for an existing name it reuses the existing record.

Notes for reviewers

  • The migration and a one-time cleanup of the 260 pre-existing duplicate rows were already applied to the production DB. The cleanup is reversible via the companies_dedup_backup_20260526 table (each row records the canonical id it was merged into).
  • OG-image/rate-limit changes that previously shared a branch with this work are not included here — those landed separately in main via Fix OG image 500s and stop install rate limits logging as errors #398. This branch is company-changes only.

Made with Cursor


Note

Low Risk
UI-only loading state plus broader read queries on public company data; no auth or write-path changes in this diff.

Overview
Improves the companies directory search UX and removes the implicit 200-result cap on full-table name search.

CompanyList now tracks isSearching during the debounced searchCompanies call, showing a "Searching..." state instead of briefly treating an empty list as no results (and no longer clearing the grid at search start).

searchCompanies takes an optional limit: when provided (e.g. form suggestions still use 5), behavior is unchanged; when omitted, it pages through Supabase in 1000-row chunks until all name matches are returned, so the companies page can show every match—not only the first 200.

Reviewed by Cursor Bugbot for commit 75f377a. Bugbot is set up for automated code reviews on this repo. Configure here.

leerob and others added 2 commits May 26, 2026 12:27
Companies could be created repeatedly (e.g. 7x "Contentful") because the
upsert only conflicted on a fresh client-side id and a BEFORE INSERT trigger
silently uniquified slugs. Enforce uniqueness instead:

- Add a normalized name_key column + case-insensitive unique index so
  duplicates are rejected at the DB level, even under concurrent submits.
- Rewrite upsert-company to edit by id only when the row exists, and reuse
  the existing company on a name conflict instead of creating a duplicate.
- Generate the company form id once per mount so it stops changing on every
  render.

Co-authored-by: Cursor <cursoragent@cursor.com>
Replace the owner-only company dropdown with a searchable combobox
(shadcn Command + Popover) so users can find and attach a company someone
else already submitted, with "Add company" kept as the fallback.

Co-authored-by: Cursor <cursoragent@cursor.com>
@vercel
Copy link
Copy Markdown

vercel Bot commented May 26, 2026

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
cursor-directory Ready Ready Preview, Comment May 27, 2026 12:01am

Request Review

- Add a shared searchCompanies helper and make the /companies search query
  the entire companies table (ilike) instead of filtering the loaded page.
- Add an existing-company typeahead to the Add Company name field: in the
  browse context it opens the company; in the MCP form context it selects
  the existing company into the form (via a pickedCompany channel) instead
  of navigating away or creating a duplicate.
- Add a pg_trgm GIN index on companies.name to back the ilike search.

Co-authored-by: Cursor <cursoragent@cursor.com>
Copy link
Copy Markdown
Contributor

@cursor cursor Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Cursor Bugbot has reviewed your changes using default effort and found 2 potential issues.

Autofix Details

Bugbot Autofix prepared fixes for both issues found in the latest run.

  • ✅ Fixed: Whitespace-only company names
    • Added Zod .trim().min(2) on the server action name field so whitespace-only input is rejected before insert/update.
  • ✅ Fixed: Stale search results shown
    • Clear companies and results immediately when the search term changes so prior matches are not shown during debounce/fetch.

Create PR

Or push these changes by commenting:

@cursor push 7e660cb4b6
Preview (7e660cb4b6)
diff --git a/apps/cursor/src/actions/upsert-company.ts b/apps/cursor/src/actions/upsert-company.ts
--- a/apps/cursor/src/actions/upsert-company.ts
+++ b/apps/cursor/src/actions/upsert-company.ts
@@ -16,7 +16,10 @@
   .schema(
     z.object({
       id: z.string().optional(),
-      name: z.string(),
+      name: z
+        .string()
+        .trim()
+        .min(2, { message: "Name must be at least 2 characters." }),
       image: z.string().url().nullable(),
       slug: z.string().optional(),
       location: z.string().nullable(),
@@ -31,7 +34,7 @@
     async ({
       parsedInput: {
         id,
-        name: rawName,
+        name,
         image,
         slug,
         location,
@@ -45,7 +48,6 @@
     }) => {
       const supabase = await createClient();
 
-      const name = rawName.trim();
       const nameKey = name.toLowerCase();
 
       // Only treat the request as an edit when a row with the provided id

diff --git a/apps/cursor/src/components/company/company-list.tsx b/apps/cursor/src/components/company/company-list.tsx
--- a/apps/cursor/src/components/company/company-list.tsx
+++ b/apps/cursor/src/components/company/company-list.tsx
@@ -23,6 +23,7 @@
 
     // With a search term, query the entire companies table rather than
     // filtering only the rows that happen to be loaded on the page.
+    setCompanies([]);
     let active = true;
     const handle = setTimeout(async () => {
       const results = await searchCompanies(term);

diff --git a/apps/cursor/src/components/company/company-select.tsx b/apps/cursor/src/components/company/company-select.tsx
--- a/apps/cursor/src/components/company/company-select.tsx
+++ b/apps/cursor/src/components/company/company-select.tsx
@@ -143,6 +143,7 @@
       return;
     }
 
+    setResults([]);
     let active = true;
     setLoading(true);

You can send follow-ups to the cloud agent here.

Comment thread apps/cursor/src/actions/upsert-company.ts
Comment thread apps/cursor/src/components/company/company-list.tsx
@leerob
Copy link
Copy Markdown
Collaborator Author

leerob commented May 26, 2026

@cursor push 7e660cb

- Validate trimmed company name with min length in upsert-company action
- Clear company list and select results when search term changes

Applied via @cursor push command
Copy link
Copy Markdown
Contributor

@cursor cursor Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Cursor Bugbot has reviewed your changes using default effort and found 2 potential issues.

Fix All in Cursor

Bugbot Autofix prepared fixes for both issues found in the latest run.

  • ✅ Fixed: Search results capped at 200
    • searchCompanies now paginates through all matching rows when no limit is passed, removing the implicit 200-row cap on the companies browse search.
  • ✅ Fixed: False empty state while searching
    • CompanyList tracks isSearching, stops clearing results at search start, and shows Searching... instead of the empty state until the debounced query finishes.

Create PR

Or push these changes by commenting:

@cursor push 43d4502320
Preview (43d4502320)
diff --git a/apps/cursor/src/components/company/company-list.tsx b/apps/cursor/src/components/company/company-list.tsx
--- a/apps/cursor/src/components/company/company-list.tsx
+++ b/apps/cursor/src/components/company/company-list.tsx
@@ -10,6 +10,7 @@
 
 export function CompanyList({ data }: { data?: Company[] | null }) {
   const [companies, setCompanies] = useState<Company[]>(data ?? []);
+  const [isSearching, setIsSearching] = useState(false);
   const [search, setSearch] = useQueryState("q");
 
   useEffect(() => {
@@ -17,18 +18,20 @@
 
     // No search: show the full server-rendered list.
     if (!term) {
+      setIsSearching(false);
       setCompanies(data ?? []);
       return;
     }
 
     // With a search term, query the entire companies table rather than
     // filtering only the rows that happen to be loaded on the page.
-    setCompanies([]);
+    setIsSearching(true);
     let active = true;
     const handle = setTimeout(async () => {
       const results = await searchCompanies(term);
       if (active) {
         setCompanies(results);
+        setIsSearching(false);
       }
     }, 200);
 
@@ -48,6 +51,10 @@
             <CompanyCard key={company.id} company={company} />
           ))}
         </div>
+      ) : isSearching ? (
+        <div className="mt-24 text-center text-sm text-muted-foreground">
+          Searching...
+        </div>
       ) : (
         <div className="mt-24 flex flex-col items-center">
           <div className="text-center text-sm text-muted-foreground">

diff --git a/apps/cursor/src/data/client-queries.ts b/apps/cursor/src/data/client-queries.ts
--- a/apps/cursor/src/data/client-queries.ts
+++ b/apps/cursor/src/data/client-queries.ts
@@ -12,7 +12,7 @@
 // filtering an already-loaded page of results.
 export async function searchCompanies(
   term: string,
-  limit = 200,
+  limit?: number,
 ): Promise<CompanySearchResult[]> {
   const trimmed = term.trim();
 
@@ -21,21 +21,45 @@
   }
 
   const supabase = createClient();
+  const baseQuery = () =>
+    supabase
+      .from("companies")
+      .select("id, name, slug, image, location")
+      .ilike("name", `%${trimmed}%`)
+      .order("name", { ascending: true });
 
-  const { data } = await supabase
-    .from("companies")
-    .select("id, name, slug, image, location")
-    .ilike("name", `%${trimmed}%`)
-    .order("name", { ascending: true })
-    .limit(limit);
+  if (limit !== undefined) {
+    const { data } = await baseQuery().limit(limit);
+    return (data ?? []).map((company) => ({
+      id: company.id,
+      name: company.name,
+      slug: company.slug,
+      image: company.image ?? "",
+      location: company.location ?? "",
+    }));
+  }
 
-  return (data ?? []).map((company) => ({
-    id: company.id,
-    name: company.name,
-    slug: company.slug,
-    image: company.image ?? "",
-    location: company.location ?? "",
-  }));
+  const PAGE_SIZE = 1000;
+  const all: CompanySearchResult[] = [];
+  let from = 0;
+
+  while (true) {
+    const { data } = await baseQuery().range(from, from + PAGE_SIZE - 1);
+    if (!data || data.length === 0) break;
+    all.push(
+      ...data.map((company) => ({
+        id: company.id,
+        name: company.name,
+        slug: company.slug,
+        image: company.image ?? "",
+        location: company.location ?? "",
+      })),
+    );
+    if (data.length < PAGE_SIZE) break;
+    from += PAGE_SIZE;
+  }
+
+  return all;
 }
 
 export async function getMCPsClient({

You can send follow-ups to the cloud agent here.

Reviewed by Cursor Bugbot for commit e34ad95. Configure here.

Comment thread apps/cursor/src/data/client-queries.ts Outdated
Comment thread apps/cursor/src/components/company/company-list.tsx
cursoragent and others added 2 commits May 26, 2026 19:23
Paginate searchCompanies when no limit is passed so browse search
returns all matches, not only the first 200. Show a Searching state
while debounced queries are in flight instead of clearing the list.
In the profile/browse Add Company flow (redirect mode), selecting an existing
company from the typeahead now fills the name input instead of navigating away.
The MCP selector flow still selects the company into the form.

Co-authored-by: Cursor <cursoragent@cursor.com>
@leerob
Copy link
Copy Markdown
Collaborator Author

leerob commented May 26, 2026

@cursor push 43d4502

Paginate searchCompanies when no limit is passed so browse search
returns all matches, not only the first 200. Show a Searching state
while debounced queries are in flight instead of clearing the list.

Applied via @cursor push command
@leerob leerob merged commit 8108ed5 into main May 27, 2026
3 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants