Skip to content

Commit 4a47764

Browse files
[dev] [tofikwest] tofik/agent-chat-history-clears-on-close (#1964)
* feat(api): add assistant chat history management endpoints * refactor(api): extract user context validation into a separate method --------- Co-authored-by: Tofik Hasanov <[email protected]>
1 parent 26f86ee commit 4a47764

File tree

12 files changed

+666
-4
lines changed

12 files changed

+666
-4
lines changed

apps/api/package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@
2828
"@trigger.dev/sdk": "4.0.6",
2929
"@trycompai/db": "^1.3.20",
3030
"@trycompai/email": "workspace:*",
31+
"@upstash/redis": "^1.34.2",
3132
"@upstash/vector": "^1.2.2",
3233
"adm-zip": "^0.5.16",
3334
"ai": "^5.0.60",

apps/api/src/app.module.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@ import { IntegrationPlatformModule } from './integration-platform/integration-pl
2929
import { CloudSecurityModule } from './cloud-security/cloud-security.module';
3030
import { BrowserbaseModule } from './browserbase/browserbase.module';
3131
import { TaskManagementModule } from './task-management/task-management.module';
32+
import { AssistantChatModule } from './assistant-chat/assistant-chat.module';
3233

3334
@Module({
3435
imports: [
@@ -70,6 +71,7 @@ import { TaskManagementModule } from './task-management/task-management.module';
7071
CloudSecurityModule,
7172
BrowserbaseModule,
7273
TaskManagementModule,
74+
AssistantChatModule,
7375
],
7476
controllers: [AppController],
7577
providers: [
Lines changed: 120 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,120 @@
1+
import {
2+
BadRequestException,
3+
Body,
4+
Controller,
5+
Delete,
6+
Get,
7+
Put,
8+
UseGuards,
9+
} from '@nestjs/common';
10+
import {
11+
ApiHeader,
12+
ApiOperation,
13+
ApiResponse,
14+
ApiSecurity,
15+
ApiTags,
16+
} from '@nestjs/swagger';
17+
import { AuthContext } from '../auth/auth-context.decorator';
18+
import { HybridAuthGuard } from '../auth/hybrid-auth.guard';
19+
import type { AuthContext as AuthContextType } from '../auth/types';
20+
import { SaveAssistantChatHistoryDto } from './assistant-chat.dto';
21+
import { AssistantChatService } from './assistant-chat.service';
22+
import type { AssistantChatMessage } from './assistant-chat.types';
23+
24+
@ApiTags('Assistant Chat')
25+
@Controller({ path: 'assistant-chat', version: '1' })
26+
@UseGuards(HybridAuthGuard)
27+
@ApiSecurity('apikey')
28+
@ApiHeader({
29+
name: 'X-Organization-Id',
30+
description:
31+
'Organization ID (required for JWT auth, optional for API key auth)',
32+
required: false,
33+
})
34+
export class AssistantChatController {
35+
constructor(private readonly assistantChatService: AssistantChatService) {}
36+
37+
private getUserScopedContext(auth: AuthContextType): { organizationId: string; userId: string } {
38+
// Defensive checks (should already be guaranteed by HybridAuthGuard + AuthContext decorator)
39+
if (!auth.organizationId) {
40+
throw new BadRequestException('Organization ID is required');
41+
}
42+
43+
if (auth.isApiKey) {
44+
throw new BadRequestException(
45+
'Assistant chat history is only available for user-authenticated requests (Bearer JWT).',
46+
);
47+
}
48+
49+
if (!auth.userId) {
50+
throw new BadRequestException('User ID is required');
51+
}
52+
53+
return { organizationId: auth.organizationId, userId: auth.userId };
54+
}
55+
56+
@Get('history')
57+
@ApiOperation({
58+
summary: 'Get assistant chat history',
59+
description:
60+
'Returns the current user-scoped assistant chat history (ephemeral session context).',
61+
})
62+
@ApiResponse({
63+
status: 200,
64+
description: 'Chat history retrieved',
65+
schema: {
66+
type: 'object',
67+
properties: {
68+
messages: { type: 'array', items: { type: 'object' } },
69+
},
70+
},
71+
})
72+
async getHistory(@AuthContext() auth: AuthContextType): Promise<{ messages: AssistantChatMessage[] }> {
73+
const { organizationId, userId } = this.getUserScopedContext(auth);
74+
75+
const messages = await this.assistantChatService.getHistory({
76+
organizationId,
77+
userId,
78+
});
79+
80+
return { messages };
81+
}
82+
83+
@Put('history')
84+
@ApiOperation({
85+
summary: 'Save assistant chat history',
86+
description:
87+
'Replaces the current user-scoped assistant chat history (ephemeral session context).',
88+
})
89+
async saveHistory(
90+
@AuthContext() auth: AuthContextType,
91+
@Body() dto: SaveAssistantChatHistoryDto,
92+
): Promise<{ success: true }> {
93+
const { organizationId, userId } = this.getUserScopedContext(auth);
94+
95+
await this.assistantChatService.saveHistory(
96+
{ organizationId, userId },
97+
dto.messages,
98+
);
99+
100+
return { success: true };
101+
}
102+
103+
@Delete('history')
104+
@ApiOperation({
105+
summary: 'Clear assistant chat history',
106+
description: 'Deletes the current user-scoped assistant chat history.',
107+
})
108+
async clearHistory(@AuthContext() auth: AuthContextType): Promise<{ success: true }> {
109+
const { organizationId, userId } = this.getUserScopedContext(auth);
110+
111+
await this.assistantChatService.clearHistory({
112+
organizationId,
113+
userId,
114+
});
115+
116+
return { success: true };
117+
}
118+
}
119+
120+
Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
import { ApiProperty } from '@nestjs/swagger';
2+
import { IsArray, IsIn, IsNumber, IsString, ValidateNested } from 'class-validator';
3+
import { Type } from 'class-transformer';
4+
5+
export class AssistantChatMessageDto {
6+
@ApiProperty({ example: 'msg_abc123' })
7+
@IsString()
8+
id!: string;
9+
10+
@ApiProperty({ enum: ['user', 'assistant'], example: 'user' })
11+
@IsIn(['user', 'assistant'])
12+
role!: 'user' | 'assistant';
13+
14+
@ApiProperty({ example: 'How do I invite a teammate?' })
15+
@IsString()
16+
text!: string;
17+
18+
@ApiProperty({ example: 1735781554000, description: 'Unix epoch millis' })
19+
@IsNumber()
20+
createdAt!: number;
21+
}
22+
23+
export class SaveAssistantChatHistoryDto {
24+
@ApiProperty({ type: [AssistantChatMessageDto] })
25+
@IsArray()
26+
@ValidateNested({ each: true })
27+
@Type(() => AssistantChatMessageDto)
28+
messages!: AssistantChatMessageDto[];
29+
}
30+
31+
Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
import { Module } from '@nestjs/common';
2+
import { AuthModule } from '../auth/auth.module';
3+
import { AssistantChatController } from './assistant-chat.controller';
4+
import { AssistantChatService } from './assistant-chat.service';
5+
6+
@Module({
7+
imports: [AuthModule],
8+
controllers: [AssistantChatController],
9+
providers: [AssistantChatService],
10+
})
11+
export class AssistantChatModule {}
12+
13+
Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
1+
import { Injectable } from '@nestjs/common';
2+
import { z } from 'zod';
3+
import { assistantChatRedisClient } from './upstash-redis.client';
4+
import type { AssistantChatMessage } from './assistant-chat.types';
5+
6+
const StoredMessageSchema = z.object({
7+
id: z.string(),
8+
role: z.enum(['user', 'assistant']),
9+
text: z.string(),
10+
createdAt: z.number(),
11+
});
12+
13+
const StoredMessagesSchema = z.array(StoredMessageSchema);
14+
15+
type GetAssistantChatKeyParams = {
16+
organizationId: string;
17+
userId: string;
18+
};
19+
20+
const getAssistantChatKey = ({ organizationId, userId }: GetAssistantChatKeyParams): string => {
21+
return `assistant-chat:v1:${organizationId}:${userId}`;
22+
};
23+
24+
@Injectable()
25+
export class AssistantChatService {
26+
/**
27+
* Default TTL is 7 days. This is intended to behave like "session context"
28+
* rather than a long-term, searchable archive.
29+
*/
30+
private readonly ttlSeconds = Number(process.env.ASSISTANT_CHAT_TTL_SECONDS ?? 60 * 60 * 24 * 7);
31+
32+
async getHistory(params: GetAssistantChatKeyParams): Promise<AssistantChatMessage[]> {
33+
const key = getAssistantChatKey(params);
34+
const raw = await assistantChatRedisClient.get<unknown>(key);
35+
const parsed = StoredMessagesSchema.safeParse(raw);
36+
if (!parsed.success) return [];
37+
return parsed.data;
38+
}
39+
40+
async saveHistory(params: GetAssistantChatKeyParams, messages: AssistantChatMessage[]): Promise<void> {
41+
const key = getAssistantChatKey(params);
42+
// Always validate before writing to keep the cache shape stable.
43+
const validated = StoredMessagesSchema.parse(messages);
44+
await assistantChatRedisClient.set(key, validated, { ex: this.ttlSeconds });
45+
}
46+
47+
async clearHistory(params: GetAssistantChatKeyParams): Promise<void> {
48+
const key = getAssistantChatKey(params);
49+
await assistantChatRedisClient.del(key);
50+
}
51+
}
52+
53+
Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
export type AssistantChatMessage = {
2+
id: string;
3+
role: 'user' | 'assistant';
4+
text: string;
5+
createdAt: number;
6+
};
7+
8+
Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
import { Redis } from '@upstash/redis';
2+
3+
/**
4+
* Upstash Redis client wrapper for the NestJS API.
5+
*
6+
* NOTE: We do NOT import `server-only` here because this code runs in Node (Nest),
7+
* not Next.js Server Components.
8+
*/
9+
class InMemoryRedis {
10+
private storage = new Map<string, { value: unknown; expiresAt?: number }>();
11+
12+
async get<T = unknown>(key: string): Promise<T | null> {
13+
const record = this.storage.get(key);
14+
if (!record) return null;
15+
if (record.expiresAt && record.expiresAt <= Date.now()) {
16+
this.storage.delete(key);
17+
return null;
18+
}
19+
return record.value as T;
20+
}
21+
22+
async set(key: string, value: unknown, options?: { ex?: number }): Promise<'OK'> {
23+
const expiresAt = options?.ex ? Date.now() + options.ex * 1000 : undefined;
24+
this.storage.set(key, { value, expiresAt });
25+
return 'OK';
26+
}
27+
28+
async del(key: string): Promise<number> {
29+
const existed = this.storage.delete(key);
30+
return existed ? 1 : 0;
31+
}
32+
}
33+
34+
const hasUpstashConfig =
35+
!!process.env.UPSTASH_REDIS_REST_URL && !!process.env.UPSTASH_REDIS_REST_TOKEN;
36+
37+
export const assistantChatRedisClient: Pick<Redis, 'get' | 'set' | 'del'> =
38+
hasUpstashConfig
39+
? new Redis({
40+
url: process.env.UPSTASH_REDIS_REST_URL!,
41+
token: process.env.UPSTASH_REDIS_REST_TOKEN!,
42+
})
43+
: (new InMemoryRedis() as unknown as Pick<Redis, 'get' | 'set' | 'del'>);
44+
45+

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

Lines changed: 37 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import { tools } from '@/data/tools';
22
import { env } from '@/env.mjs';
33
import { auth } from '@/utils/auth';
44
import { openai } from '@ai-sdk/openai';
5+
import { db } from '@db';
56
import { type UIMessage, convertToModelMessages, streamText } from 'ai';
67
import { headers } from 'next/headers';
78
import { NextResponse } from 'next/server';
@@ -19,10 +20,39 @@ export async function POST(req: Request) {
1920
headers: await headers(),
2021
});
2122

22-
if (!session?.session.activeOrganizationId) {
23+
if (!session?.user) {
2324
return new Response('Unauthorized', { status: 401 });
2425
}
2526

27+
const organizationIdFromHeader = req.headers.get('x-organization-id')?.trim();
28+
const organizationIdFromSession = session.session.activeOrganizationId;
29+
30+
// Prefer deterministic org context from URL → client header.
31+
const organizationId = organizationIdFromHeader ?? organizationIdFromSession;
32+
33+
if (!organizationId) {
34+
return NextResponse.json(
35+
{ error: 'Organization context required (missing X-Organization-Id).' },
36+
{ status: 400 },
37+
);
38+
}
39+
40+
// Validate the user is a member of the requested org.
41+
const member = await db.member.findFirst({
42+
where: {
43+
userId: session.user.id,
44+
organizationId,
45+
deactivated: false,
46+
},
47+
select: { id: true },
48+
});
49+
50+
if (!member) {
51+
return new Response('Unauthorized', { status: 401 });
52+
}
53+
54+
const nowIso = new Date().toISOString();
55+
2656
const systemPrompt = `
2757
You're an expert in GRC, and a helpful assistant in Comp AI,
2858
a platform that helps companies get compliant with frameworks
@@ -33,6 +63,12 @@ export async function POST(req: Request) {
3363
Keep responses concise and to the point.
3464
3565
If you are unsure about the answer, say "I don't know" or "I don't know the answer to that question".
66+
67+
Important:
68+
- Today's date/time is ${nowIso}.
69+
- You are assisting a user inside a live application (organizationId: ${organizationId}).
70+
- Prefer using available tools to fetch up-to-date org data (policies, risks, organization details) rather than guessing.
71+
- If the question depends on the customer's current configuration/data and you haven't retrieved it, call the relevant tool first.
3672
`;
3773

3874
const result = streamText({

0 commit comments

Comments
 (0)