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
Original file line number Diff line number Diff line change
Expand Up @@ -95,7 +95,10 @@ const triggerRiskAssessmentIfMissing = async (params: {

const schema = z.object({
organizationId: z.string().min(1, 'Organization ID is required'),
name: z.string().min(1, 'Name is required'),
name: z
.string()
.trim()
.min(1, 'Name is required'),
// Treat empty string as "not provided" so the form default doesn't block submission
website: z
.union([z.string().url('Must be a valid URL (include https://)'), z.literal('')])
Expand All @@ -116,7 +119,10 @@ export const createVendorAction = createSafeActionClient()
});

if (!session?.user?.id) {
throw new Error('Unauthorized');
return {
success: false,
error: 'Unauthorized',
};
}

// Security: verify the current user is a member of the target organization.
Expand All @@ -131,7 +137,29 @@ export const createVendorAction = createSafeActionClient()
});

if (!member) {
throw new Error('Unauthorized');
return {
success: false,
error: 'Unauthorized',
};
}

// Check if vendor with same name already exists for this organization
const existingVendor = await db.vendor.findFirst({
where: {
organizationId: input.parsedInput.organizationId,
name: {
equals: input.parsedInput.name,
mode: 'insensitive',
},
},
select: { id: true, name: true },
});

if (existingVendor) {
return {
success: false,
error: `A vendor named "${existingVendor.name}" already exists in this organization.`,
};
Copy link

Choose a reason for hiding this comment

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

Race condition allows duplicate vendor names

Medium Severity

The duplicate vendor name check and vendor creation are separate database operations without transaction protection. Two concurrent requests can both pass the findFirst check before either creates the vendor, resulting in duplicate vendors with the same name in the organization. The Vendor model lacks a @@unique([organizationId, name]) constraint that would provide database-level protection against this race condition.

Fix in Cursor Fix in Web

}

const vendor = await db.vendor.create({
Expand All @@ -146,6 +174,51 @@ export const createVendorAction = createSafeActionClient()
},
});

// Create or update GlobalVendors entry immediately so vendor is searchable
// This ensures the vendor appears in global vendor search suggestions right away
const normalizedWebsite = normalizeWebsite(vendor.website ?? null);
if (normalizedWebsite) {
try {
// Check if GlobalVendors entry already exists
const existingGlobalVendor = await db.globalVendors.findUnique({
where: { website: normalizedWebsite },
select: { company_description: true },
});

const updateData: {
company_name: string;
company_description?: string | null;
} = {
company_name: vendor.name,
};

// Only update description if GlobalVendors doesn't have one yet
if (!existingGlobalVendor?.company_description) {
updateData.company_description = vendor.description || null;
}

await db.globalVendors.upsert({
where: { website: normalizedWebsite },
create: {
website: normalizedWebsite,
company_name: vendor.name,
company_description: vendor.description || null,
approved: false,
},
update: updateData,
});
} catch (error) {
// Non-blocking: vendor creation succeeded, GlobalVendors upsert is optional
console.warn('[createVendorAction] Failed to upsert GlobalVendors (non-blocking)', {
organizationId: input.parsedInput.organizationId,
vendorId: vendor.id,
vendorName: vendor.name,
normalizedWebsite,
error: error instanceof Error ? error.message : String(error),
});
}
}

// If we don't already have GlobalVendors risk assessment data for this website, trigger research.
// Best-effort: vendor creation should succeed even if the trigger fails.
try {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,219 @@
'use client';

import { useDebouncedCallback } from '@/hooks/use-debounced-callback';
import { FormControl, FormField, FormItem, FormLabel, FormMessage } from '@comp/ui/form';
import { Input } from '@comp/ui/input';
import type { GlobalVendors } from '@db';
import { useAction } from 'next-safe-action/hooks';
import { useEffect, useMemo, useRef, useState } from 'react';
import type { UseFormReturn } from 'react-hook-form';
import { searchGlobalVendorsAction } from '../actions/search-global-vendors-action';
import type { CreateVendorFormValues } from './create-vendor-form-schema';

const getVendorDisplayName = (vendor: GlobalVendors): string => {
return vendor.company_name ?? vendor.legal_name ?? vendor.website ?? '';
};

const normalizeVendorName = (name: string): string => {
return name.toLowerCase().trim();
};

const getVendorKey = (vendor: GlobalVendors): string => {
// `website` is the primary key and should always be present.
if (vendor.website) return vendor.website;

const name = vendor.company_name || vendor.legal_name || 'unknown';
const timestamp = vendor.createdAt?.getTime() ?? 0;
return `${name}-${timestamp}`;
};

type Props = {
form: UseFormReturn<CreateVendorFormValues>;
isSheetOpen: boolean;
};

export function VendorNameAutocompleteField({ form, isSheetOpen }: Props) {
const [searchQuery, setSearchQuery] = useState('');
const [searchResults, setSearchResults] = useState<GlobalVendors[]>([]);
const [isSearching, setIsSearching] = useState(false);
const [popoverOpen, setPopoverOpen] = useState(false);

// Used to avoid resetting on initial mount.
const hasOpenedOnceRef = useRef(false);

const searchVendors = useAction(searchGlobalVendorsAction, {
onExecute: () => setIsSearching(true),
onSuccess: (result) => {
if (result.data?.success && result.data.data?.vendors) {
setSearchResults(result.data.data.vendors);
} else {
setSearchResults([]);
}
setIsSearching(false);
},
onError: () => {
setSearchResults([]);
setIsSearching(false);
},
});

const debouncedSearch = useDebouncedCallback((query: string) => {
if (query.trim().length > 1) {
searchVendors.execute({ name: query });
} else {
setSearchResults([]);
}
}, 300);

// Reset autocomplete state when the sheet closes.
useEffect(() => {
if (isSheetOpen) {
hasOpenedOnceRef.current = true;
return;
}

if (!hasOpenedOnceRef.current) return;

setSearchQuery('');
setSearchResults([]);
setIsSearching(false);
setPopoverOpen(false);
}, [isSheetOpen]);
Copy link

Choose a reason for hiding this comment

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

Stale search results persist after sheet reopens

Low Severity

When a user closes the sheet while a vendor search is in flight, the cleanup effect at lines 77-80 clears searchResults. However, if the API response arrives after this cleanup, the onSuccess callback repopulates searchResults with stale data. When the sheet reopens, the effect does an early return (line 72), so stale results are not cleared. If the user then types 2+ characters, the onChange handler only calls setSearchResults([]) for inputs with length ≤ 1, so stale results briefly appear in the dropdown until the new debounced search completes.

Additional Locations (1)

Fix in Cursor Fix in Web


const deduplicatedSearchResults = useMemo(() => {
if (searchResults.length === 0) return [];

const seen = new Map<string, GlobalVendors>();

for (const vendor of searchResults) {
const displayName = getVendorDisplayName(vendor);
const normalizedName = normalizeVendorName(displayName);
const existing = seen.get(normalizedName);

if (!existing) {
seen.set(normalizedName, vendor);
continue;
}

// Prefer vendor with more complete data.
const existingHasCompanyName = !!existing.company_name;
const currentHasCompanyName = !!vendor.company_name;

if (!existingHasCompanyName && currentHasCompanyName) {
seen.set(normalizedName, vendor);
continue;
}

if (existingHasCompanyName === currentHasCompanyName) {
if (!existing.website && vendor.website) {
seen.set(normalizedName, vendor);
}
}
}

return Array.from(seen.values());
}, [searchResults]);

const handleSelectVendor = (vendor: GlobalVendors) => {
// Use same fallback logic as getVendorDisplayName for consistency
const name = getVendorDisplayName(vendor);

form.setValue('name', name, { shouldDirty: true, shouldValidate: true });
form.setValue('website', vendor.website ?? '', { shouldDirty: true, shouldValidate: true });
form.setValue('description', vendor.company_description ?? '', {
shouldDirty: true,
shouldValidate: true,
});

setSearchQuery(name);
setSearchResults([]);
setPopoverOpen(false);
};

return (
<FormField
control={form.control}
name="name"
render={({ field }) => (
<FormItem className="relative flex flex-col">
<FormLabel>{'Vendor Name'}</FormLabel>
<FormControl>
<div className="relative">
<Input
placeholder={'Search or enter vendor name...'}
value={searchQuery}
onChange={(e) => {
const val = e.target.value;
setSearchQuery(val);
field.onChange(val);
debouncedSearch(val);

if (val.trim().length > 1) {
setPopoverOpen(true);
} else {
setPopoverOpen(false);
setSearchResults([]);
}
}}
onBlur={() => {
setTimeout(() => setPopoverOpen(false), 150);
}}
onFocus={() => {
// Prevent flicker on initial focus: only show if we have results or an active search.
if (searchQuery.trim().length > 1 && (isSearching || searchResults.length > 0)) {
setPopoverOpen(true);
}
}}
autoFocus
/>

{popoverOpen && (
<div className="bg-background absolute top-full z-10 mt-1 w-full rounded-md border shadow-lg">
<div className="max-h-[300px] overflow-y-auto p-1">
{isSearching && (
<div className="text-muted-foreground p-2 text-sm">Loading...</div>
)}

{!isSearching && deduplicatedSearchResults.length > 0 && (
<>
<p className="text-muted-foreground px-2 py-1.5 text-xs font-medium">
{'Suggestions'}
</p>
{deduplicatedSearchResults.map((vendor) => (
<div
key={getVendorKey(vendor)}
className="hover:bg-accent cursor-pointer rounded-sm p-2 text-sm"
onMouseDown={() => handleSelectVendor(vendor)}
>
{getVendorDisplayName(vendor)}
</div>
))}
</>
)}

{!isSearching &&
searchQuery.trim().length > 1 &&
deduplicatedSearchResults.length === 0 && (
<div
className="hover:bg-accent cursor-pointer rounded-sm p-2 text-sm italic"
onMouseDown={() => {
field.onChange(searchQuery);
setSearchResults([]);
setPopoverOpen(false);
}}
>
{`Create "${searchQuery}"`}
</div>
)}
</div>
</div>
)}
</div>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
);
}

Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
import { VendorCategory, VendorStatus } from '@db';
import { z } from 'zod';

export const createVendorSchema = z.object({
name: z.string().trim().min(1, 'Name is required'),
// Allow empty string in the input and treat it as "not provided"
website: z
.union([z.string().url('URL must be valid and start with https://'), z.literal('')])
.transform((value) => (value === '' ? undefined : value))
.optional(),
description: z.string().optional(),
category: z.nativeEnum(VendorCategory),
status: z.nativeEnum(VendorStatus),
assigneeId: z.string().optional(),
});

export type CreateVendorFormValues = z.infer<typeof createVendorSchema>;

Loading
Loading