Skip to content

Commit 41a8edc

Browse files
[dev] [Marfuen] mariano/fraud-risk (#1473)
* Update zod dependency to version 3.25.76 across multiple files - Changed zod version from 4.0.14 to 3.25.76 in package.json and bun.lock. - Updated zod version in apps/app/package.json from 4.0.17 to 3.25.76. - Refactored onboarding helpers to utilize new zod schema validation methods. These changes ensure compatibility with the updated zod version and improve the overall stability of the application. * fix: Update error handling in schema validation to use 'required_error' for consistency - Changed error messages in various schemas to use 'required_error' instead of 'error' for better clarity and consistency across the application. - Updated schemas include createRiskSchema, updateRiskSchema, createPolicySchema, createVendorTaskSchema, and others. These changes enhance the user experience by providing clearer validation messages. --------- Co-authored-by: Mariano Fuentes <marfuen98@gmail.com>
1 parent a45a4a3 commit 41a8edc

File tree

6 files changed

+156
-69
lines changed

6 files changed

+156
-69
lines changed

apps/app/package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -91,7 +91,7 @@
9191
"use-long-press": "^3.3.0",
9292
"xml2js": "^0.6.2",
9393
"zaraz-ts": "^1.2.0",
94-
"zod": "^4.0.17",
94+
"zod": "^3.25.76",
9595
"zustand": "^5.0.3"
9696
},
9797
"devDependencies": {

apps/app/src/actions/schema.ts

Lines changed: 13 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -65,7 +65,7 @@ export const organizationWebsiteSchema = z.object({
6565
export const createRiskSchema = z.object({
6666
title: z
6767
.string({
68-
error: 'Risk name is required',
68+
required_error: 'Risk name is required',
6969
})
7070
.min(1, {
7171
message: 'Risk name should be at least 1 character',
@@ -75,7 +75,7 @@ export const createRiskSchema = z.object({
7575
}),
7676
description: z
7777
.string({
78-
error: 'Risk description is required',
78+
required_error: 'Risk description is required',
7979
})
8080
.min(1, {
8181
message: 'Risk description should be at least 1 character',
@@ -84,10 +84,10 @@ export const createRiskSchema = z.object({
8484
message: 'Risk description should be at most 255 characters',
8585
}),
8686
category: z.nativeEnum(RiskCategory, {
87-
error: 'Risk category is required',
87+
required_error: 'Risk category is required',
8888
}),
8989
department: z.nativeEnum(Departments, {
90-
error: 'Risk department is required',
90+
required_error: 'Risk department is required',
9191
}),
9292
assigneeId: z.string().optional().nullable(),
9393
});
@@ -103,14 +103,14 @@ export const updateRiskSchema = z.object({
103103
message: 'Risk description is required',
104104
}),
105105
category: z.nativeEnum(RiskCategory, {
106-
error: 'Risk category is required',
106+
required_error: 'Risk category is required',
107107
}),
108108
department: z.nativeEnum(Departments, {
109-
error: 'Risk department is required',
109+
required_error: 'Risk department is required',
110110
}),
111111
assigneeId: z.string().optional().nullable(),
112112
status: z.nativeEnum(RiskStatus, {
113-
error: 'Risk status is required',
113+
required_error: 'Risk status is required',
114114
}),
115115
});
116116

@@ -162,7 +162,7 @@ export const updateTaskSchema = z.object({
162162
description: z.string().optional(),
163163
dueDate: z.date().optional(),
164164
status: z.nativeEnum(TaskStatus, {
165-
error: 'Task status is required',
165+
required_error: 'Task status is required',
166166
}),
167167
assigneeId: z.string().optional().nullable(),
168168
});
@@ -251,8 +251,10 @@ export const updateResidualRiskEnumSchema = z.object({
251251

252252
// Policies
253253
export const createPolicySchema = z.object({
254-
title: z.string({ error: 'Title is required' }).min(1, 'Title is required'),
255-
description: z.string({ error: 'Description is required' }).min(1, 'Description is required'),
254+
title: z.string({ required_error: 'Title is required' }).min(1, 'Title is required'),
255+
description: z
256+
.string({ required_error: 'Description is required' })
257+
.min(1, 'Description is required'),
256258
frameworkIds: z.array(z.string()).optional(),
257259
controlIds: z.array(z.string()).optional(),
258260
entityId: z.string().optional(),
@@ -279,7 +281,7 @@ export const createEmployeeSchema = z.object({
279281
name: z.string().min(1, 'Name is required'),
280282
email: z.string().email('Invalid email address'),
281283
department: z.nativeEnum(Departments, {
282-
error: 'Department is required',
284+
required_error: 'Department is required',
283285
}),
284286
externalEmployeeId: z.string().optional(),
285287
isActive: z.boolean().default(true),

apps/app/src/app/(app)/[orgId]/vendors/[vendorId]/actions/schema.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,7 @@ export const createVendorTaskSchema = z.object({
2424
message: 'Description is required',
2525
}),
2626
dueDate: z.date({
27-
error: 'Due date is required',
27+
required_error: 'Due date is required',
2828
}),
2929
assigneeId: z.string().nullable(),
3030
});
@@ -79,7 +79,7 @@ export const updateVendorTaskSchema = z.object({
7979
}),
8080
dueDate: z.date().optional(),
8181
status: z.nativeEnum(TaskStatus, {
82-
error: 'Task status is required',
82+
required_error: 'Task status is required',
8383
}),
8484
assigneeId: z.string().nullable(),
8585
});

apps/app/src/jobs/tasks/onboarding/onboard-organization-helpers.ts

Lines changed: 126 additions & 35 deletions
Original file line numberDiff line numberDiff line change
@@ -8,13 +8,13 @@ import {
88
Likelihood,
99
Risk,
1010
RiskCategory,
11+
RiskStatus,
1112
RiskTreatmentType,
1213
VendorCategory,
1314
} from '@db';
1415
import { logger, tasks } from '@trigger.dev/sdk';
15-
import { generateObject, generateText } from 'ai';
16+
import { generateObject, generateText, jsonSchema } from 'ai';
1617
import axios from 'axios';
17-
import z from 'zod';
1818
import type { researchVendor } from '../scrape/research';
1919
import { RISK_MITIGATION_PROMPT } from './prompts/risk-mitigation';
2020
import { VENDOR_RISK_ASSESSMENT_PROMPT } from './prompts/vendor-risk-assessment';
@@ -53,6 +53,58 @@ export type RiskData = {
5353
department: Departments;
5454
};
5555

56+
// Baseline risks that must always exist for every organization regardless of frameworks
57+
const BASELINE_RISKS: Array<{
58+
title: string;
59+
description: string;
60+
category: RiskCategory;
61+
department: Departments;
62+
status: RiskStatus;
63+
}> = [
64+
{
65+
title: 'Intentional Fraud and Misuse',
66+
description:
67+
'Intentional misrepresentation or deception by an internal actor (employee, contractor) or by the organization as a whole, for the purpose of achieving an unauthorized or improper gain.',
68+
category: RiskCategory.governance,
69+
department: Departments.gov,
70+
status: RiskStatus.closed,
71+
},
72+
];
73+
74+
/**
75+
* Ensures baseline risks are present for the organization.
76+
* Creates them if missing. Returns the list of risks that were created.
77+
*/
78+
export async function ensureBaselineRisks(organizationId: string): Promise<Risk[]> {
79+
const created: Risk[] = [];
80+
81+
for (const base of BASELINE_RISKS) {
82+
const existing = await db.risk.findFirst({
83+
where: {
84+
organizationId,
85+
title: base.title,
86+
},
87+
});
88+
89+
if (!existing) {
90+
const risk = await db.risk.create({
91+
data: {
92+
title: base.title,
93+
description: base.description,
94+
category: base.category,
95+
department: base.department,
96+
status: base.status,
97+
organizationId,
98+
},
99+
});
100+
created.push(risk);
101+
logger.info(`Created baseline risk: ${risk.id} (${risk.title})`);
102+
}
103+
}
104+
105+
return created;
106+
}
107+
56108
/**
57109
* Revalidates the organization path for cache busting
58110
*/
@@ -114,28 +166,47 @@ export async function getOrganizationContext(organizationId: string) {
114166
export async function extractVendorsFromContext(
115167
questionsAndAnswers: ContextItem[],
116168
): Promise<VendorData[]> {
117-
const result = await generateObject({
169+
const { object } = await generateObject({
118170
model: openai('gpt-4.1-mini'),
119-
schema: z.object({
120-
vendors: z.array(
121-
z.object({
122-
vendor_name: z.string(),
123-
vendor_website: z.string(),
124-
vendor_description: z.string(),
125-
category: z.enum(Object.values(VendorCategory) as [string, ...string[]]),
126-
inherent_probability: z.enum(Object.values(Likelihood) as [string, ...string[]]),
127-
inherent_impact: z.enum(Object.values(Impact) as [string, ...string[]]),
128-
residual_probability: z.enum(Object.values(Likelihood) as [string, ...string[]]),
129-
residual_impact: z.enum(Object.values(Impact) as [string, ...string[]]),
130-
}),
131-
),
171+
mode: 'json',
172+
schema: jsonSchema({
173+
type: 'object',
174+
properties: {
175+
vendors: {
176+
type: 'array',
177+
items: {
178+
type: 'object',
179+
properties: {
180+
vendor_name: { type: 'string' },
181+
vendor_website: { type: 'string' },
182+
vendor_description: { type: 'string' },
183+
category: { type: 'string', enum: Object.values(VendorCategory) },
184+
inherent_probability: { type: 'string', enum: Object.values(Likelihood) },
185+
inherent_impact: { type: 'string', enum: Object.values(Impact) },
186+
residual_probability: { type: 'string', enum: Object.values(Likelihood) },
187+
residual_impact: { type: 'string', enum: Object.values(Impact) },
188+
},
189+
required: [
190+
'vendor_name',
191+
'vendor_website',
192+
'vendor_description',
193+
'category',
194+
'inherent_probability',
195+
'inherent_impact',
196+
'residual_probability',
197+
'residual_impact',
198+
],
199+
},
200+
},
201+
},
202+
required: ['vendors'],
132203
}),
133204
system:
134205
'Extract vendor names from the following questions and answers. Return their name (grammar-correct), website, description, category, inherent probability, inherent impact, residual probability, and residual impact.',
135206
prompt: questionsAndAnswers.map((q) => `${q.question}\n${q.answer}`).join('\n'),
136207
});
137208

138-
return result.object.vendors as VendorData[];
209+
return (object as { vendors: VendorData[] }).vendors;
139210
}
140211

141212
/**
@@ -335,23 +406,40 @@ export async function extractRisksFromContext(
335406
organizationName: string,
336407
existingRisks: { title: string }[],
337408
): Promise<RiskData[]> {
338-
const result = await generateObject({
409+
const { object } = await generateObject({
339410
model: openai('gpt-4.1-mini'),
340-
schema: z.object({
341-
risks: z.array(
342-
z.object({
343-
risk_name: z.string(),
344-
risk_description: z.string(),
345-
risk_treatment_strategy: z.enum(
346-
Object.values(RiskTreatmentType) as [string, ...string[]],
347-
),
348-
risk_treatment_strategy_description: z.string(),
349-
risk_residual_probability: z.enum(Object.values(Likelihood) as [string, ...string[]]),
350-
risk_residual_impact: z.enum(Object.values(Impact) as [string, ...string[]]),
351-
category: z.enum(Object.values(RiskCategory) as [string, ...string[]]),
352-
department: z.enum(Object.values(Departments) as [string, ...string[]]),
353-
}),
354-
),
411+
mode: 'json',
412+
schema: jsonSchema({
413+
type: 'object',
414+
properties: {
415+
risks: {
416+
type: 'array',
417+
items: {
418+
type: 'object',
419+
properties: {
420+
risk_name: { type: 'string' },
421+
risk_description: { type: 'string' },
422+
risk_treatment_strategy: { type: 'string', enum: Object.values(RiskTreatmentType) },
423+
risk_treatment_strategy_description: { type: 'string' },
424+
risk_residual_probability: { type: 'string', enum: Object.values(Likelihood) },
425+
risk_residual_impact: { type: 'string', enum: Object.values(Impact) },
426+
category: { type: 'string', enum: Object.values(RiskCategory) },
427+
department: { type: 'string', enum: Object.values(Departments) },
428+
},
429+
required: [
430+
'risk_name',
431+
'risk_description',
432+
'risk_treatment_strategy',
433+
'risk_treatment_strategy_description',
434+
'risk_residual_probability',
435+
'risk_residual_impact',
436+
'category',
437+
'department',
438+
],
439+
},
440+
},
441+
},
442+
required: ['risks'],
355443
}),
356444
system: `Create a list of 8-12 risks that are relevant to the organization. Use action-oriented language, assume reviewers understand basic termilology - skip definitions.
357445
Your mandate is to propose risks that satisfy both ISO 27001:2022 clause 6.1 (risk management) and SOC 2 trust services criteria CC3 and CC4.
@@ -367,7 +455,7 @@ export async function extractRisksFromContext(
367455
`,
368456
});
369457

370-
return result.object.risks as RiskData[];
458+
return (object as { risks: RiskData[] }).risks;
371459
}
372460

373461
/**
@@ -489,7 +577,10 @@ export async function createRisks(
489577
organizationId: string,
490578
organizationName: string,
491579
): Promise<Risk[]> {
492-
// Get existing risks to avoid duplicates
580+
// Ensure baseline risks exist first so the AI doesn't recreate them
581+
await ensureBaselineRisks(organizationId);
582+
583+
// Get existing risks to avoid duplicates (includes baseline)
493584
const existingRisks = await getExistingRisks(organizationId);
494585

495586
// Extract risks using AI

0 commit comments

Comments
 (0)