Skip to content

Commit ee8de8f

Browse files
[dev] [Marfuen] mariano/smart-suggestions (#1742)
* fix(api): improve env loading and JWKS retry handling - Load .env manually before NestJS bootstrap - Add automatic JWKS retry on key mismatch - Remove redundant ConfigModule envFilePath * fix(auth): add automatic token refresh on 401 errors - Auto-refresh token and retry request on 401 - Add race condition protection and cooldown - Fix useTask hook to wait for orgId from URL params * feat(automation): add AI-generated suggestions for new automations - Generate task-specific suggestions using GPT-4o-mini - Load suggestions asynchronously for faster page load - Add loading state for automation page * feat(automation): improve suggestion prompts and error handling - Ensure suggestions match exact task topic - Exclude screenshots, require API integrations only - Add fallback for broken vendor logo images * feat(automation): add skeleton loaders for suggestion cards - Show animated skeleton cards while AI suggestions are loading - Match card structure and layout for smooth transition - Load suggestions asynchronously without blocking page render * chore(deps): update @trycompai/db to version 1.3.17 and add dotenv * refactor(automation): remove reduced limits on vendor queries for clarity * feat(automation): improve suggestion UI and add vendor diversity - add flushSync for immediate UI updates after suggestions load - change placeholder to generic text - add vendor diversity requirement to AI prompts to avoid duplicate vendors --------- Co-authored-by: Mariano Fuentes <marfuen98@gmail.com>
1 parent 6e0b968 commit ee8de8f

File tree

20 files changed

+750
-57
lines changed

20 files changed

+750
-57
lines changed

apps/api/package.json

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,22 +4,23 @@
44
"version": "0.0.1",
55
"author": "",
66
"dependencies": {
7-
"@prisma/client": "^6.13.0",
8-
"prisma": "^6.13.0",
97
"@aws-sdk/client-s3": "^3.859.0",
108
"@aws-sdk/s3-request-presigner": "^3.859.0",
119
"@nestjs/common": "^11.0.1",
1210
"@nestjs/config": "^4.0.2",
1311
"@nestjs/core": "^11.0.1",
1412
"@nestjs/platform-express": "^11.1.5",
1513
"@nestjs/swagger": "^11.2.0",
14+
"@prisma/client": "^6.13.0",
1615
"@trycompai/db": "^1.3.17",
1716
"archiver": "^7.0.1",
1817
"axios": "^1.12.2",
1918
"better-auth": "^1.3.27",
2019
"class-transformer": "^0.5.1",
2120
"class-validator": "^0.14.2",
21+
"dotenv": "^17.2.3",
2222
"jose": "^6.0.12",
23+
"prisma": "^6.13.0",
2324
"reflect-metadata": "^0.2.2",
2425
"rxjs": "^7.8.1",
2526
"swagger-ui-express": "^5.0.1",

apps/api/src/app.module.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import { PeopleModule } from './people/people.module';
99
import { DevicesModule } from './devices/devices.module';
1010
import { DeviceAgentModule } from './device-agent/device-agent.module';
1111
import { awsConfig } from './config/aws.config';
12+
import { betterAuthConfig } from './config/better-auth.config';
1213
import { HealthModule } from './health/health.module';
1314
import { OrganizationModule } from './organization/organization.module';
1415
import { PoliciesModule } from './policies/policies.module';
@@ -23,7 +24,8 @@ import { TaskTemplateModule } from './framework-editor/task-template/task-templa
2324
imports: [
2425
ConfigModule.forRoot({
2526
isGlobal: true,
26-
load: [awsConfig],
27+
// .env file is loaded manually in main.ts before NestJS starts
28+
load: [awsConfig, betterAuthConfig],
2729
validationOptions: {
2830
allowUnknown: true,
2931
abortEarly: true,

apps/api/src/auth/hybrid-auth.guard.ts

Lines changed: 113 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -4,14 +4,32 @@ import {
44
Injectable,
55
UnauthorizedException,
66
} from '@nestjs/common';
7+
import { ConfigService } from '@nestjs/config';
78
import { db } from '@trycompai/db';
89
import { createRemoteJWKSet, jwtVerify } from 'jose';
910
import { ApiKeyService } from './api-key.service';
11+
import type { BetterAuthConfig } from '../config/better-auth.config';
1012
import { AuthenticatedRequest } from './types';
1113

1214
@Injectable()
1315
export class HybridAuthGuard implements CanActivate {
14-
constructor(private readonly apiKeyService: ApiKeyService) {}
16+
private readonly betterAuthUrl: string;
17+
18+
constructor(
19+
private readonly apiKeyService: ApiKeyService,
20+
private readonly configService: ConfigService,
21+
) {
22+
const betterAuthConfig =
23+
this.configService.get<BetterAuthConfig>('betterAuth');
24+
this.betterAuthUrl =
25+
betterAuthConfig?.url || process.env.BETTER_AUTH_URL || '';
26+
27+
if (!this.betterAuthUrl) {
28+
console.warn(
29+
'[HybridAuthGuard] BETTER_AUTH_URL not configured. JWT authentication will fail.',
30+
);
31+
}
32+
}
1533

1634
async canActivate(context: ExecutionContext): Promise<boolean> {
1735
const request = context.switchToHttp().getRequest<AuthenticatedRequest>();
@@ -61,20 +79,71 @@ export class HybridAuthGuard implements CanActivate {
6179
authHeader: string,
6280
): Promise<boolean> {
6381
try {
82+
// Validate BETTER_AUTH_URL is configured
83+
if (!this.betterAuthUrl) {
84+
console.error(
85+
'[HybridAuthGuard] BETTER_AUTH_URL environment variable is not set',
86+
);
87+
throw new UnauthorizedException(
88+
'Authentication configuration error: BETTER_AUTH_URL not configured',
89+
);
90+
}
91+
6492
// Extract token from "Bearer <token>"
6593
const token = authHeader.substring(7);
6694

67-
// Create JWKS for token verification using Better Auth endpoint
68-
const JWKS = createRemoteJWKSet(
69-
new URL(`${process.env.BETTER_AUTH_URL}/api/auth/jwks`),
70-
);
95+
const jwksUrl = `${this.betterAuthUrl}/api/auth/jwks`;
7196

72-
// Verify JWT token
73-
const { payload } = await jwtVerify(token, JWKS, {
74-
issuer: process.env.BETTER_AUTH_URL,
75-
audience: process.env.BETTER_AUTH_URL,
97+
// Create JWKS for token verification using Better Auth endpoint
98+
// Use shorter cache time to handle key rotation better
99+
const JWKS = createRemoteJWKSet(new URL(jwksUrl), {
100+
cacheMaxAge: 60000, // 1 minute cache (default is 5 minutes)
101+
cooldownDuration: 10000, // 10 seconds cooldown before refetching
76102
});
77103

104+
// Verify JWT token with automatic retry on key mismatch
105+
let payload;
106+
try {
107+
payload = (
108+
await jwtVerify(token, JWKS, {
109+
issuer: this.betterAuthUrl,
110+
audience: this.betterAuthUrl,
111+
})
112+
).payload;
113+
} catch (verifyError: any) {
114+
// If we get a key mismatch error, retry with a fresh JWKS fetch
115+
if (
116+
verifyError.code === 'ERR_JWKS_NO_MATCHING_KEY' ||
117+
verifyError.message?.includes('no applicable key found') ||
118+
verifyError.message?.includes('JWKSNoMatchingKey')
119+
) {
120+
console.log(
121+
'[HybridAuthGuard] Key mismatch detected, fetching fresh JWKS and retrying...',
122+
);
123+
124+
// Create a fresh JWKS instance with no cache to force immediate fetch
125+
const freshJWKS = createRemoteJWKSet(new URL(jwksUrl), {
126+
cacheMaxAge: 0, // No cache - force fresh fetch
127+
cooldownDuration: 0, // No cooldown - allow immediate retry
128+
});
129+
130+
// Retry verification with fresh keys
131+
payload = (
132+
await jwtVerify(token, freshJWKS, {
133+
issuer: this.betterAuthUrl,
134+
audience: this.betterAuthUrl,
135+
})
136+
).payload;
137+
138+
console.log(
139+
'[HybridAuthGuard] Successfully verified token with fresh JWKS',
140+
);
141+
} else {
142+
// Re-throw if it's not a key mismatch error
143+
throw verifyError;
144+
}
145+
}
146+
78147
// Extract user information from JWT payload (user data is directly in payload for Better Auth JWT)
79148
const userId = payload.id as string;
80149
const userEmail = payload.email as string;
@@ -112,6 +181,41 @@ export class HybridAuthGuard implements CanActivate {
112181
return true;
113182
} catch (error) {
114183
console.error('JWT verification failed:', error);
184+
185+
// Provide more helpful error messages
186+
if (error instanceof Error) {
187+
// Connection errors
188+
if (
189+
error.message.includes('ECONNREFUSED') ||
190+
error.message.includes('fetch failed')
191+
) {
192+
console.error(
193+
`[HybridAuthGuard] Cannot connect to Better Auth JWKS endpoint at ${this.betterAuthUrl}/api/auth/jwks`,
194+
);
195+
console.error(
196+
'[HybridAuthGuard] Make sure BETTER_AUTH_URL is set correctly and the Better Auth server is running',
197+
);
198+
throw new UnauthorizedException(
199+
`Cannot connect to authentication service. Please check BETTER_AUTH_URL configuration.`,
200+
);
201+
}
202+
203+
// Key mismatch errors should have been handled by retry logic above
204+
// If we still get one here, it means the retry also failed (token truly invalid)
205+
if (
206+
(error as any).code === 'ERR_JWKS_NO_MATCHING_KEY' ||
207+
error.message.includes('no applicable key found') ||
208+
error.message.includes('JWKSNoMatchingKey')
209+
) {
210+
console.error(
211+
'[HybridAuthGuard] Token key not found even after fetching fresh JWKS. Token may be from a different environment or truly invalid.',
212+
);
213+
throw new UnauthorizedException(
214+
'Authentication token is invalid. Please log out and log back in to refresh your session.',
215+
);
216+
}
217+
}
218+
115219
throw new UnauthorizedException('Invalid or expired JWT token');
116220
}
117221
}
Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
import { registerAs } from '@nestjs/config';
2+
import { z } from 'zod';
3+
4+
const betterAuthConfigSchema = z.object({
5+
url: z.string().url('BETTER_AUTH_URL must be a valid URL'),
6+
});
7+
8+
export type BetterAuthConfig = z.infer<typeof betterAuthConfigSchema>;
9+
10+
export const betterAuthConfig = registerAs(
11+
'betterAuth',
12+
(): BetterAuthConfig => {
13+
const url = process.env.BETTER_AUTH_URL;
14+
15+
if (!url) {
16+
throw new Error('BETTER_AUTH_URL environment variable is required');
17+
}
18+
19+
const config = { url };
20+
21+
// Validate configuration at startup
22+
const result = betterAuthConfigSchema.safeParse(config);
23+
24+
if (!result.success) {
25+
throw new Error(
26+
`Better Auth configuration validation failed: ${result.error.issues
27+
.map((e) => `${e.path.join('.')}: ${e.message}`)
28+
.join(', ')}`,
29+
);
30+
}
31+
32+
return result.data;
33+
},
34+
);

apps/api/src/main.ts

Lines changed: 16 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,9 +4,24 @@ import { NestFactory } from '@nestjs/core';
44
import type { OpenAPIObject } from '@nestjs/swagger';
55
import { DocumentBuilder, SwaggerModule } from '@nestjs/swagger';
66
import * as express from 'express';
7+
import { config } from 'dotenv';
8+
import path from 'path';
79
import { AppModule } from './app.module';
810
import { existsSync, mkdirSync, writeFileSync } from 'fs';
9-
import path from 'path';
11+
12+
// Load .env file from apps/api directory before anything else
13+
// This ensures .env values override any shell environment variables
14+
// __dirname in compiled code is dist/src, so go up two levels to apps/api
15+
const envPath = path.join(__dirname, '..', '..', '.env');
16+
if (existsSync(envPath)) {
17+
config({ path: envPath, override: true });
18+
} else {
19+
// Fallback: try current working directory (when run from apps/api)
20+
const cwdEnvPath = path.join(process.cwd(), '.env');
21+
if (existsSync(cwdEnvPath)) {
22+
config({ path: cwdEnvPath, override: true });
23+
}
24+
}
1025

1126
async function bootstrap(): Promise<void> {
1227
const app: INestApplication = await NestFactory.create(AppModule);

apps/app/package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@
44
"dependencies": {
55
"@ai-sdk/anthropic": "^2.0.0",
66
"@ai-sdk/groq": "^2.0.0",
7-
"@ai-sdk/openai": "^2.0.0",
7+
"@ai-sdk/openai": "^2.0.65",
88
"@ai-sdk/provider": "^2.0.0",
99
"@ai-sdk/react": "^2.0.60",
1010
"@ai-sdk/rsc": "^1.0.0",
Lines changed: 100 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,100 @@
1+
'use server';
2+
3+
import { openai } from '@ai-sdk/openai';
4+
import { db } from '@db';
5+
import { generateObject } from 'ai';
6+
import { performance } from 'perf_hooks';
7+
import { z } from 'zod';
8+
import {
9+
AUTOMATION_SUGGESTIONS_SYSTEM_PROMPT,
10+
getAutomationSuggestionsPrompt,
11+
} from './prompts/automation-suggestions';
12+
13+
const SuggestionsSchema = z.object({
14+
suggestions: z.array(
15+
z.object({
16+
title: z.string(),
17+
prompt: z.string(),
18+
vendorName: z.string().optional(),
19+
vendorWebsite: z.string().optional(),
20+
}),
21+
),
22+
});
23+
24+
export async function generateAutomationSuggestions(
25+
taskDescription: string,
26+
organizationId: string,
27+
): Promise<{ title: string; prompt: string; vendorName?: string; vendorWebsite?: string }[]> {
28+
const startTime = performance.now();
29+
console.log('[generateAutomationSuggestions] Starting suggestion generation...');
30+
31+
// Get vendors from the Vendor table
32+
const vendorsStartTime = performance.now();
33+
const vendors = await db.vendor.findMany({
34+
where: {
35+
organizationId,
36+
},
37+
select: {
38+
name: true,
39+
website: true,
40+
description: true,
41+
},
42+
});
43+
const vendorsTime = performance.now() - vendorsStartTime;
44+
console.log(
45+
`[generateAutomationSuggestions] Fetched ${vendors.length} vendors in ${vendorsTime.toFixed(2)}ms`,
46+
);
47+
48+
// Get vendors from context table as well
49+
const contextStartTime = performance.now();
50+
const contextEntries = await db.context.findMany({
51+
where: {
52+
organizationId,
53+
},
54+
select: {
55+
question: true,
56+
answer: true,
57+
},
58+
});
59+
const contextTime = performance.now() - contextStartTime;
60+
console.log(
61+
`[generateAutomationSuggestions] Fetched ${contextEntries.length} context entries in ${contextTime.toFixed(2)}ms`,
62+
);
63+
64+
const vendorList =
65+
vendors.length > 0
66+
? vendors.map((v) => `${v.name}${v.website ? ` (${v.website})` : ''}`).join(', ')
67+
: 'No vendors configured yet';
68+
69+
const contextInfo =
70+
contextEntries.length > 0
71+
? contextEntries.map((c) => `Q: ${c.question}\nA: ${c.answer}`).join('\n\n')
72+
: 'No additional context available';
73+
74+
const promptLength = getAutomationSuggestionsPrompt(
75+
taskDescription,
76+
vendorList,
77+
contextInfo,
78+
).length;
79+
console.log(`[generateAutomationSuggestions] Prompt length: ${promptLength} characters`);
80+
81+
// Generate AI suggestions
82+
const aiStartTime = performance.now();
83+
const { object, usage } = await generateObject({
84+
model: openai('gpt-4.1-mini'), // Testing gpt-5-nano for suggestions
85+
schema: SuggestionsSchema,
86+
system: AUTOMATION_SUGGESTIONS_SYSTEM_PROMPT,
87+
prompt: getAutomationSuggestionsPrompt(taskDescription, vendorList, contextInfo),
88+
});
89+
const aiTime = performance.now() - aiStartTime;
90+
console.log(
91+
`[generateAutomationSuggestions] AI generation completed in ${aiTime.toFixed(2)}ms (total tokens: ${usage?.totalTokens || 'unknown'})`,
92+
);
93+
94+
const totalTime = performance.now() - startTime;
95+
console.log(
96+
`[generateAutomationSuggestions] Total time: ${totalTime.toFixed(2)}ms (vendors: ${vendorsTime.toFixed(2)}ms, context: ${contextTime.toFixed(2)}ms, AI: ${aiTime.toFixed(2)}ms)`,
97+
);
98+
99+
return object.suggestions;
100+
}

0 commit comments

Comments
 (0)