Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions apps/cursor/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@
"@vercel/firewall": "^1.2.0",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"cmdk": "^1.1.1",
"date-fns": "^4.1.0",
"framer-motion": "^12.38.0",
"fuse.js": "^7.3.0",
Expand Down
101 changes: 79 additions & 22 deletions apps/cursor/src/actions/upsert-company.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,14 +5,21 @@ import { z } from "zod";
import { createClient } from "@/utils/supabase/server";
import { ActionError, authActionClient } from "./safe-action";

// Postgres unique_violation. Raised when an insert/update collides with the
// case-insensitive company name index (companies_name_key_unique).
const UNIQUE_VIOLATION = "23505";

export const upsertCompanyAction = authActionClient
.metadata({
actionName: "upsert-company",
})
.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(),
Expand Down Expand Up @@ -41,6 +48,8 @@ export const upsertCompanyAction = authActionClient
}) => {
const supabase = await createClient();

const nameKey = name.toLowerCase();
Comment thread
cursor[bot] marked this conversation as resolved.

// Only treat the request as an edit when a row with the provided id
// already exists. The form always generates a client-side nanoid for new
// companies, so the presence of `id` alone does not imply an edit.
Expand All @@ -51,36 +60,84 @@ export const upsertCompanyAction = authActionClient
.eq("id", id)
.maybeSingle();

if (existing && existing.owner_id !== userId) {
throw new ActionError(
"You don't have permission to edit this company",
);
if (existing) {
if (existing.owner_id !== userId) {
throw new ActionError(
"You don't have permission to edit this company",
);
}

const { data, error } = await supabase
.from("companies")
.update({
name,
image,
location,
bio,
website,
social_x_link,
public: is_public,
})
.eq("id", id)
.select("id, slug")
.single();

if (error) {
if (error.code === UNIQUE_VIOLATION) {
throw new ActionError("A company with this name already exists.");
}
throw new ActionError(error.message);
}

if (shouldRedirect) {
redirect(`/c/${data?.slug}`);
}

return data;
}
}

// New company. Insert directly so the case-insensitive unique index can
// reject duplicates even under concurrent/double submissions. The slug is
// assigned by the `generate_company_slug` trigger.
const { data, error } = await supabase
.from("companies")
.upsert(
{
id: id ?? undefined,
name,
image,
location,
slug: slug ?? undefined,
bio,
website,
social_x_link,
public: is_public,
owner_id: userId,
},
{
onConflict: "id",
},
)
.insert({
id: id ?? undefined,
name,
image,
location,
slug: slug ?? undefined,
bio,
website,
social_x_link,
public: is_public,
owner_id: userId,
})
.select("id, slug")
.single();

if (error) {
// A company with this name already exists. Reuse it instead of creating
// a duplicate so retries/double-clicks resolve to the same record.
if (error.code === UNIQUE_VIOLATION) {
const { data: existing } = await supabase
.from("companies")
.select("id, slug")
.eq("name_key", nameKey)
.maybeSingle();

if (existing) {
if (shouldRedirect) {
redirect(`/c/${existing.slug}`);
}

return existing;
}

throw new ActionError("A company with this name already exists.");
}

throw new ActionError(error.message);
}

Expand Down
36 changes: 31 additions & 5 deletions apps/cursor/src/components/company/company-list.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,22 +2,44 @@

import { useQueryState } from "nuqs";
import { useEffect, useState } from "react";
import { searchCompanies } from "@/data/client-queries";
import { SearchInput } from "../search-input";
import { Button } from "../ui/button";
import type { Company } from "./company-card";
import { CompanyCard } from "./company-card";

export function CompanyList({ data }: { data?: Company[] | null }) {
const [companies, setCompanies] = useState<Company[]>(data ?? []);
const [isSearching, setIsSearching] = useState(false);
const [search, setSearch] = useQueryState("q");

useEffect(() => {
const filteredCompanies = data?.filter((company) =>
company.name.toLowerCase().includes(search?.toLowerCase() ?? ""),
);
const term = search?.trim() ?? "";

setCompanies(filteredCompanies ?? []);
}, [search]);
// 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.
setIsSearching(true);
let active = true;
const handle = setTimeout(async () => {
const results = await searchCompanies(term);
if (active) {
setCompanies(results);
setIsSearching(false);
}
}, 200);

return () => {
active = false;
clearTimeout(handle);
};
Comment thread
cursor[bot] marked this conversation as resolved.
}, [search, data]);
Comment thread
cursor[bot] marked this conversation as resolved.

return (
<div className="mt-8">
Expand All @@ -29,6 +51,10 @@ export function CompanyList({ data }: { data?: Company[] | null }) {
<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">
Expand Down
Loading