Skip to content

Commit 8a3c766

Browse files
committed
chore: merge main into release for new releases
2 parents 4d83e1d + d786d79 commit 8a3c766

File tree

55 files changed

+2911
-1000
lines changed

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

55 files changed

+2911
-1000
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/api/src/task-management/task-management.service.ts

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -298,8 +298,8 @@ export class TaskManagementService {
298298
`Created task item: ${taskItem.id} for organization ${organizationId} by ${member.id}`,
299299
);
300300

301-
// Log task creation in audit log
302-
void this.auditService.logTaskItemCreated({
301+
// Log task creation in audit log first (await to ensure it's created before assignment log)
302+
await this.auditService.logTaskItemCreated({
303303
taskItemId: taskItem.id,
304304
organizationId,
305305
userId: authContext.userId,
@@ -325,9 +325,9 @@ export class TaskManagementService {
325325
assignedByUserId: authContext.userId,
326326
});
327327

328-
// Log initial assignment in audit log
328+
// Log initial assignment in audit log (after creation log to ensure correct order)
329329
if (taskItem.assignee) {
330-
void this.auditService.logTaskItemAssigned({
330+
await this.auditService.logTaskItemAssigned({
331331
taskItemId: taskItem.id,
332332
organizationId,
333333
userId: authContext.userId,

0 commit comments

Comments
 (0)