Skip to content

Commit 91617b0

Browse files
committed
chore: merge main into release for new releases
2 parents 8b308c1 + 4d554a4 commit 91617b0

File tree

7 files changed

+842
-334
lines changed

7 files changed

+842
-334
lines changed

apps/app/src/app/(app)/[orgId]/vendors/actions/create-vendor-action.ts

Lines changed: 76 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -95,7 +95,10 @@ const triggerRiskAssessmentIfMissing = async (params: {
9595

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

118121
if (!session?.user?.id) {
119-
throw new Error('Unauthorized');
122+
return {
123+
success: false,
124+
error: 'Unauthorized',
125+
};
120126
}
121127

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

133139
if (!member) {
134-
throw new Error('Unauthorized');
140+
return {
141+
success: false,
142+
error: 'Unauthorized',
143+
};
144+
}
145+
146+
// Check if vendor with same name already exists for this organization
147+
const existingVendor = await db.vendor.findFirst({
148+
where: {
149+
organizationId: input.parsedInput.organizationId,
150+
name: {
151+
equals: input.parsedInput.name,
152+
mode: 'insensitive',
153+
},
154+
},
155+
select: { id: true, name: true },
156+
});
157+
158+
if (existingVendor) {
159+
return {
160+
success: false,
161+
error: `A vendor named "${existingVendor.name}" already exists in this organization.`,
162+
};
135163
}
136164

137165
const vendor = await db.vendor.create({
@@ -146,6 +174,51 @@ export const createVendorAction = createSafeActionClient()
146174
},
147175
});
148176

177+
// Create or update GlobalVendors entry immediately so vendor is searchable
178+
// This ensures the vendor appears in global vendor search suggestions right away
179+
const normalizedWebsite = normalizeWebsite(vendor.website ?? null);
180+
if (normalizedWebsite) {
181+
try {
182+
// Check if GlobalVendors entry already exists
183+
const existingGlobalVendor = await db.globalVendors.findUnique({
184+
where: { website: normalizedWebsite },
185+
select: { company_description: true },
186+
});
187+
188+
const updateData: {
189+
company_name: string;
190+
company_description?: string | null;
191+
} = {
192+
company_name: vendor.name,
193+
};
194+
195+
// Only update description if GlobalVendors doesn't have one yet
196+
if (!existingGlobalVendor?.company_description) {
197+
updateData.company_description = vendor.description || null;
198+
}
199+
200+
await db.globalVendors.upsert({
201+
where: { website: normalizedWebsite },
202+
create: {
203+
website: normalizedWebsite,
204+
company_name: vendor.name,
205+
company_description: vendor.description || null,
206+
approved: false,
207+
},
208+
update: updateData,
209+
});
210+
} catch (error) {
211+
// Non-blocking: vendor creation succeeded, GlobalVendors upsert is optional
212+
console.warn('[createVendorAction] Failed to upsert GlobalVendors (non-blocking)', {
213+
organizationId: input.parsedInput.organizationId,
214+
vendorId: vendor.id,
215+
vendorName: vendor.name,
216+
normalizedWebsite,
217+
error: error instanceof Error ? error.message : String(error),
218+
});
219+
}
220+
}
221+
149222
// If we don't already have GlobalVendors risk assessment data for this website, trigger research.
150223
// Best-effort: vendor creation should succeed even if the trigger fails.
151224
try {
Lines changed: 219 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,219 @@
1+
'use client';
2+
3+
import { useDebouncedCallback } from '@/hooks/use-debounced-callback';
4+
import { FormControl, FormField, FormItem, FormLabel, FormMessage } from '@comp/ui/form';
5+
import { Input } from '@comp/ui/input';
6+
import type { GlobalVendors } from '@db';
7+
import { useAction } from 'next-safe-action/hooks';
8+
import { useEffect, useMemo, useRef, useState } from 'react';
9+
import type { UseFormReturn } from 'react-hook-form';
10+
import { searchGlobalVendorsAction } from '../actions/search-global-vendors-action';
11+
import type { CreateVendorFormValues } from './create-vendor-form-schema';
12+
13+
const getVendorDisplayName = (vendor: GlobalVendors): string => {
14+
return vendor.company_name ?? vendor.legal_name ?? vendor.website ?? '';
15+
};
16+
17+
const normalizeVendorName = (name: string): string => {
18+
return name.toLowerCase().trim();
19+
};
20+
21+
const getVendorKey = (vendor: GlobalVendors): string => {
22+
// `website` is the primary key and should always be present.
23+
if (vendor.website) return vendor.website;
24+
25+
const name = vendor.company_name || vendor.legal_name || 'unknown';
26+
const timestamp = vendor.createdAt?.getTime() ?? 0;
27+
return `${name}-${timestamp}`;
28+
};
29+
30+
type Props = {
31+
form: UseFormReturn<CreateVendorFormValues>;
32+
isSheetOpen: boolean;
33+
};
34+
35+
export function VendorNameAutocompleteField({ form, isSheetOpen }: Props) {
36+
const [searchQuery, setSearchQuery] = useState('');
37+
const [searchResults, setSearchResults] = useState<GlobalVendors[]>([]);
38+
const [isSearching, setIsSearching] = useState(false);
39+
const [popoverOpen, setPopoverOpen] = useState(false);
40+
41+
// Used to avoid resetting on initial mount.
42+
const hasOpenedOnceRef = useRef(false);
43+
44+
const searchVendors = useAction(searchGlobalVendorsAction, {
45+
onExecute: () => setIsSearching(true),
46+
onSuccess: (result) => {
47+
if (result.data?.success && result.data.data?.vendors) {
48+
setSearchResults(result.data.data.vendors);
49+
} else {
50+
setSearchResults([]);
51+
}
52+
setIsSearching(false);
53+
},
54+
onError: () => {
55+
setSearchResults([]);
56+
setIsSearching(false);
57+
},
58+
});
59+
60+
const debouncedSearch = useDebouncedCallback((query: string) => {
61+
if (query.trim().length > 1) {
62+
searchVendors.execute({ name: query });
63+
} else {
64+
setSearchResults([]);
65+
}
66+
}, 300);
67+
68+
// Reset autocomplete state when the sheet closes.
69+
useEffect(() => {
70+
if (isSheetOpen) {
71+
hasOpenedOnceRef.current = true;
72+
return;
73+
}
74+
75+
if (!hasOpenedOnceRef.current) return;
76+
77+
setSearchQuery('');
78+
setSearchResults([]);
79+
setIsSearching(false);
80+
setPopoverOpen(false);
81+
}, [isSheetOpen]);
82+
83+
const deduplicatedSearchResults = useMemo(() => {
84+
if (searchResults.length === 0) return [];
85+
86+
const seen = new Map<string, GlobalVendors>();
87+
88+
for (const vendor of searchResults) {
89+
const displayName = getVendorDisplayName(vendor);
90+
const normalizedName = normalizeVendorName(displayName);
91+
const existing = seen.get(normalizedName);
92+
93+
if (!existing) {
94+
seen.set(normalizedName, vendor);
95+
continue;
96+
}
97+
98+
// Prefer vendor with more complete data.
99+
const existingHasCompanyName = !!existing.company_name;
100+
const currentHasCompanyName = !!vendor.company_name;
101+
102+
if (!existingHasCompanyName && currentHasCompanyName) {
103+
seen.set(normalizedName, vendor);
104+
continue;
105+
}
106+
107+
if (existingHasCompanyName === currentHasCompanyName) {
108+
if (!existing.website && vendor.website) {
109+
seen.set(normalizedName, vendor);
110+
}
111+
}
112+
}
113+
114+
return Array.from(seen.values());
115+
}, [searchResults]);
116+
117+
const handleSelectVendor = (vendor: GlobalVendors) => {
118+
// Use same fallback logic as getVendorDisplayName for consistency
119+
const name = getVendorDisplayName(vendor);
120+
121+
form.setValue('name', name, { shouldDirty: true, shouldValidate: true });
122+
form.setValue('website', vendor.website ?? '', { shouldDirty: true, shouldValidate: true });
123+
form.setValue('description', vendor.company_description ?? '', {
124+
shouldDirty: true,
125+
shouldValidate: true,
126+
});
127+
128+
setSearchQuery(name);
129+
setSearchResults([]);
130+
setPopoverOpen(false);
131+
};
132+
133+
return (
134+
<FormField
135+
control={form.control}
136+
name="name"
137+
render={({ field }) => (
138+
<FormItem className="relative flex flex-col">
139+
<FormLabel>{'Vendor Name'}</FormLabel>
140+
<FormControl>
141+
<div className="relative">
142+
<Input
143+
placeholder={'Search or enter vendor name...'}
144+
value={searchQuery}
145+
onChange={(e) => {
146+
const val = e.target.value;
147+
setSearchQuery(val);
148+
field.onChange(val);
149+
debouncedSearch(val);
150+
151+
if (val.trim().length > 1) {
152+
setPopoverOpen(true);
153+
} else {
154+
setPopoverOpen(false);
155+
setSearchResults([]);
156+
}
157+
}}
158+
onBlur={() => {
159+
setTimeout(() => setPopoverOpen(false), 150);
160+
}}
161+
onFocus={() => {
162+
// Prevent flicker on initial focus: only show if we have results or an active search.
163+
if (searchQuery.trim().length > 1 && (isSearching || searchResults.length > 0)) {
164+
setPopoverOpen(true);
165+
}
166+
}}
167+
autoFocus
168+
/>
169+
170+
{popoverOpen && (
171+
<div className="bg-background absolute top-full z-10 mt-1 w-full rounded-md border shadow-lg">
172+
<div className="max-h-[300px] overflow-y-auto p-1">
173+
{isSearching && (
174+
<div className="text-muted-foreground p-2 text-sm">Loading...</div>
175+
)}
176+
177+
{!isSearching && deduplicatedSearchResults.length > 0 && (
178+
<>
179+
<p className="text-muted-foreground px-2 py-1.5 text-xs font-medium">
180+
{'Suggestions'}
181+
</p>
182+
{deduplicatedSearchResults.map((vendor) => (
183+
<div
184+
key={getVendorKey(vendor)}
185+
className="hover:bg-accent cursor-pointer rounded-sm p-2 text-sm"
186+
onMouseDown={() => handleSelectVendor(vendor)}
187+
>
188+
{getVendorDisplayName(vendor)}
189+
</div>
190+
))}
191+
</>
192+
)}
193+
194+
{!isSearching &&
195+
searchQuery.trim().length > 1 &&
196+
deduplicatedSearchResults.length === 0 && (
197+
<div
198+
className="hover:bg-accent cursor-pointer rounded-sm p-2 text-sm italic"
199+
onMouseDown={() => {
200+
field.onChange(searchQuery);
201+
setSearchResults([]);
202+
setPopoverOpen(false);
203+
}}
204+
>
205+
{`Create "${searchQuery}"`}
206+
</div>
207+
)}
208+
</div>
209+
</div>
210+
)}
211+
</div>
212+
</FormControl>
213+
<FormMessage />
214+
</FormItem>
215+
)}
216+
/>
217+
);
218+
}
219+
Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
import { VendorCategory, VendorStatus } from '@db';
2+
import { z } from 'zod';
3+
4+
export const createVendorSchema = z.object({
5+
name: z.string().trim().min(1, 'Name is required'),
6+
// Allow empty string in the input and treat it as "not provided"
7+
website: z
8+
.union([z.string().url('URL must be valid and start with https://'), z.literal('')])
9+
.transform((value) => (value === '' ? undefined : value))
10+
.optional(),
11+
description: z.string().optional(),
12+
category: z.nativeEnum(VendorCategory),
13+
status: z.nativeEnum(VendorStatus),
14+
assigneeId: z.string().optional(),
15+
});
16+
17+
export type CreateVendorFormValues = z.infer<typeof createVendorSchema>;
18+

0 commit comments

Comments
 (0)