Fix duplicate companies: enforce unique names + searchable company picker#400
Merged
Conversation
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>
|
The latest updates on your projects. Learn more about Vercel for GitHub.
|
4 tasks
- 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>
Contributor
There was a problem hiding this comment.
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.
- Added Zod
- ✅ Fixed: Stale search results shown
- Clear
companiesandresultsimmediately when the search term changes so prior matches are not shown during debounce/fetch.
- Clear
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.
Collaborator
Author
- 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
Contributor
There was a problem hiding this comment.
Cursor Bugbot has reviewed your changes using default effort and found 2 potential issues.
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.
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.
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>
Collaborator
Author
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
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.


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 INSERTtrigger 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
supabase/migrations/20260526_companies_unique_name.sql: normalizes existing names, adds a generatedname_keycolumn, and a case-insensitive unique index so duplicates are rejected at the DB level even under concurrent/double submits.upsert-companyaction: 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.Searchable company picker
Command+Popover, addscmdk).Test plan
Notes for reviewers
companies_dedup_backup_20260526table (each row records the canonical id it was merged into).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.
CompanyListnow tracksisSearchingduring the debouncedsearchCompaniescall, showing a "Searching..." state instead of briefly treating an empty list as no results (and no longer clearing the grid at search start).searchCompaniestakes an optionallimit: when provided (e.g. form suggestions still use5), 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.