Skip to content

Commit 09d11bc

Browse files
authored
Merge pull request #424 from trycompai/lewis/vendor-research
feat: integrate Firecrawl for vendor research and add global vendor table
2 parents 4b64d3f + f17506e commit 09d11bc

File tree

11 files changed

+220
-11
lines changed

11 files changed

+220
-11
lines changed

apps/app/package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,7 @@
3131
"@dnd-kit/sortable": "^10.0.0",
3232
"@dnd-kit/utilities": "^3.2.2",
3333
"@hookform/resolvers": "^3.10.0",
34+
"@mendable/firecrawl-js": "^1.24.0",
3435
"@nangohq/frontend": "^0.53.2",
3536
"@next/third-parties": "^15.3.0",
3637
"@novu/headless": "^2.6.6",
Lines changed: 131 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,131 @@
1+
"use server";
2+
import { type ActionResponse } from "@/app/actions/actions";
3+
import { env } from "@/env.mjs";
4+
import { db } from "@comp/db";
5+
import FirecrawlApp, { ScrapeResponse } from "@mendable/firecrawl-js";
6+
import ky from "ky";
7+
import { z } from "zod";
8+
import { authActionClient } from "./safe-action";
9+
10+
let firecrawl: FirecrawlApp | null = null;
11+
12+
if (process.env.FIRECRAWL_API_KEY) {
13+
firecrawl = new FirecrawlApp({
14+
apiKey: process.env.FIRECRAWL_API_KEY,
15+
});
16+
}
17+
18+
const schema = z.object({
19+
company_name: z.string(),
20+
legal_name: z.string(),
21+
company_description: z.string(),
22+
company_hq_address: z.string(),
23+
privacy_policy_url: z.string(),
24+
terms_of_service_url: z.string(),
25+
service_level_agreement_url: z.string(),
26+
security_overview_url: z.string(),
27+
trust_portal_url: z.string(),
28+
certified_security_frameworks: z.array(z.string()),
29+
subprocessors: z.array(z.string()),
30+
type_of_company: z.string(),
31+
});
32+
33+
export const researchVendorAction = authActionClient
34+
.schema(
35+
z.object({
36+
website: z.string().url({ message: "Invalid URL format" }),
37+
}),
38+
)
39+
.metadata({
40+
name: "research-vendor",
41+
})
42+
.action(async ({ parsedInput: { website }, ctx: { user } }) => {
43+
try {
44+
if (!firecrawl) {
45+
return {
46+
success: false,
47+
error: { message: "Firecrawl client not initialized" },
48+
};
49+
}
50+
51+
const existingVendor = await db.globalVendors.findUnique({
52+
where: {
53+
website: website,
54+
},
55+
select: { website: true },
56+
});
57+
58+
if (existingVendor) {
59+
return {
60+
success: true,
61+
data: {
62+
message: "Vendor already exists.",
63+
vendorExists: true,
64+
},
65+
};
66+
}
67+
68+
const scrapeResult = await firecrawl.extract([website], {
69+
prompt: "You're a cyber security researcher, researching a vendor.",
70+
schema: schema,
71+
enableWebSearch: true,
72+
scrapeOptions: {
73+
onlyMainContent: true,
74+
removeBase64Images: true,
75+
},
76+
});
77+
78+
if (!scrapeResult.success || !scrapeResult.data) {
79+
return {
80+
success: false,
81+
error: {
82+
message: `Failed to scrape vendor data: ${
83+
scrapeResult.error || "Unknown error"
84+
}`,
85+
},
86+
};
87+
}
88+
89+
const vendorData = scrapeResult.data as z.infer<typeof schema>;
90+
91+
await db.globalVendors.create({
92+
data: {
93+
website: website,
94+
company_name: vendorData.company_name,
95+
legal_name: vendorData.legal_name,
96+
company_description: vendorData.company_description,
97+
company_hq_address: vendorData.company_hq_address,
98+
privacy_policy_url: vendorData.privacy_policy_url,
99+
terms_of_service_url: vendorData.terms_of_service_url,
100+
service_level_agreement_url:
101+
vendorData.service_level_agreement_url,
102+
security_page_url: vendorData.security_overview_url,
103+
trust_page_url: vendorData.trust_portal_url,
104+
security_certifications:
105+
vendorData.certified_security_frameworks,
106+
subprocessors: vendorData.subprocessors,
107+
type_of_company: vendorData.type_of_company,
108+
},
109+
});
110+
111+
return {
112+
success: true,
113+
data: {
114+
message: "Vendor researched and added successfully.",
115+
vendorExists: false,
116+
},
117+
};
118+
} catch (error) {
119+
console.error("Error in researchVendorAction:", error);
120+
121+
return {
122+
success: false,
123+
error: {
124+
message:
125+
error instanceof Error
126+
? error.message
127+
: "An unexpected error occurred.",
128+
},
129+
};
130+
}
131+
});

apps/app/src/app/[locale]/(app)/(dashboard)/[orgId]/vendors/components/create-vendor-form.tsx

Lines changed: 12 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
"use client";
22

3+
import { researchVendorAction } from "@/actions/research-vendor";
34
import { SelectAssignee } from "@/components/SelectAssignee";
45
import { useI18n } from "@/locales/client";
56
import { Member, User, VendorCategory, VendorStatus } from "@comp/db/types";
@@ -28,6 +29,7 @@ import {
2829
} from "@comp/ui/select";
2930
import { Textarea } from "@comp/ui/textarea";
3031
import { zodResolver } from "@hookform/resolvers/zod";
32+
import ky from "ky";
3133
import { ArrowRightIcon } from "lucide-react";
3234
import { useAction } from "next-safe-action/hooks";
3335
import { useQueryState } from "nuqs";
@@ -52,7 +54,7 @@ export function CreateVendorForm({
5254
const [_, setCreateVendorSheet] = useQueryState("createVendorSheet");
5355

5456
const createVendor = useAction(createVendorAction, {
55-
onSuccess: async (data) => {
57+
onSuccess: async () => {
5658
toast.success(t("vendors.form.create_vendor_success"));
5759
setCreateVendorSheet(null);
5860
},
@@ -61,6 +63,8 @@ export function CreateVendorForm({
6163
},
6264
});
6365

66+
const researchVendor = useAction(researchVendorAction);
67+
6468
const form = useForm<z.infer<typeof createVendorSchema>>({
6569
resolver: zodResolver(createVendorSchema),
6670
defaultValues: {
@@ -71,8 +75,14 @@ export function CreateVendorForm({
7175
},
7276
});
7377

74-
const onSubmit = (data: z.infer<typeof createVendorSchema>) => {
78+
const onSubmit = async (data: z.infer<typeof createVendorSchema>) => {
7579
createVendor.execute(data);
80+
81+
if (data.website) {
82+
researchVendor.execute({
83+
website: data.website,
84+
});
85+
}
7686
};
7787

7888
return (

apps/app/src/app/api/chat/route.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import { tools } from "@/data/tools";
22
import { model, type modelID } from "@/hooks/ai/providers";
33
import { auth } from "@/utils/auth";
4-
import { type UIMessage, streamText } from "ai";
4+
import { type UIMessage, smoothStream, streamText } from "ai";
55
import { headers } from "next/headers";
66

77
export const maxDuration = 30;

apps/app/src/instrumentation-client.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,3 +24,5 @@ Sentry.init({
2424
// Setting this option to true will print useful information to the console while you're setting up Sentry.
2525
debug: false,
2626
});
27+
28+
export const onRouterTransitionStart = Sentry.captureRouterTransitionStart;

bun.lock

Lines changed: 5 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.
Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
-- CreateTable
2+
CREATE TABLE "GlobalVendors" (
3+
"website" TEXT NOT NULL,
4+
"company_name" TEXT,
5+
"legal_name" TEXT,
6+
"company_description" TEXT,
7+
"company_hq_address" TEXT,
8+
"privacy_policy_url" TEXT,
9+
"terms_of_service_url" TEXT,
10+
"service_level_agreement_url" TEXT,
11+
"security_page_url" TEXT,
12+
"trust_page_url" TEXT,
13+
"security_certifications" TEXT[],
14+
"subprocessors" TEXT[],
15+
"type_of_company" TEXT,
16+
"approved" BOOLEAN NOT NULL DEFAULT false,
17+
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
18+
19+
CONSTRAINT "GlobalVendors_pkey" PRIMARY KEY ("website")
20+
);
21+
22+
-- CreateIndex
23+
CREATE UNIQUE INDEX "GlobalVendors_website_key" ON "GlobalVendors"("website");
24+
25+
-- CreateIndex
26+
CREATE INDEX "GlobalVendors_website_idx" ON "GlobalVendors"("website");
Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,3 @@
11
# Please do not edit this file manually
22
# It should be added in your version-control system (e.g., Git)
3-
provider = "postgresql"
3+
provider = "postgresql"

packages/db/prisma/schema/shared.prisma

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,28 @@ model AuditLog {
2929
@@index([organizationId])
3030
}
3131

32+
model GlobalVendors {
33+
website String @id @unique
34+
company_name String?
35+
legal_name String?
36+
company_description String?
37+
company_hq_address String?
38+
privacy_policy_url String?
39+
terms_of_service_url String?
40+
service_level_agreement_url String?
41+
security_page_url String?
42+
trust_page_url String?
43+
security_certifications String[]
44+
subprocessors String[]
45+
type_of_company String?
46+
47+
approved Boolean @default(false)
48+
createdAt DateTime @default(now())
49+
50+
51+
@@index([website])
52+
}
53+
3254
enum Departments {
3355
none
3456
admin

turbo.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -42,7 +42,8 @@
4242
"REVALIDATION_SECRET",
4343
"GROQ_API_KEY",
4444
"SENTRY_AUTH_TOKEN",
45-
"RESEND_AUDIENCE_ID"
45+
"RESEND_AUDIENCE_ID",
46+
"FIRECRAWL_API_KEY"
4647
],
4748
"inputs": ["$TURBO_DEFAULT$", ".env"],
4849
"dependsOn": ["^build", "^db:generate", "^auth:build", "clean-react"],

0 commit comments

Comments
 (0)