Skip to content
Merged
Show file tree
Hide file tree
Changes from 4 commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions apps/api/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@
"@trigger.dev/sdk": "4.0.6",
"@trycompai/db": "^1.3.20",
"@trycompai/email": "workspace:*",
"@upstash/redis": "^1.34.2",
"@upstash/vector": "^1.2.2",
"adm-zip": "^0.5.16",
"ai": "^5.0.60",
Expand Down
2 changes: 2 additions & 0 deletions apps/api/src/app.module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ import { IntegrationPlatformModule } from './integration-platform/integration-pl
import { CloudSecurityModule } from './cloud-security/cloud-security.module';
import { BrowserbaseModule } from './browserbase/browserbase.module';
import { TaskManagementModule } from './task-management/task-management.module';
import { AssistantChatModule } from './assistant-chat/assistant-chat.module';

@Module({
imports: [
Expand Down Expand Up @@ -70,6 +71,7 @@ import { TaskManagementModule } from './task-management/task-management.module';
CloudSecurityModule,
BrowserbaseModule,
TaskManagementModule,
AssistantChatModule,
],
controllers: [AppController],
providers: [
Expand Down
120 changes: 120 additions & 0 deletions apps/api/src/assistant-chat/assistant-chat.controller.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,120 @@
import {
BadRequestException,
Body,
Controller,
Delete,
Get,
Put,
UseGuards,
} from '@nestjs/common';
import {
ApiHeader,
ApiOperation,
ApiResponse,
ApiSecurity,
ApiTags,
} from '@nestjs/swagger';
import { AuthContext } from '../auth/auth-context.decorator';
import { HybridAuthGuard } from '../auth/hybrid-auth.guard';
import type { AuthContext as AuthContextType } from '../auth/types';
import { SaveAssistantChatHistoryDto } from './assistant-chat.dto';
import { AssistantChatService } from './assistant-chat.service';
import type { AssistantChatMessage } from './assistant-chat.types';

@ApiTags('Assistant Chat')
@Controller({ path: 'assistant-chat', version: '1' })
@UseGuards(HybridAuthGuard)
@ApiSecurity('apikey')
@ApiHeader({
name: 'X-Organization-Id',
description:
'Organization ID (required for JWT auth, optional for API key auth)',
required: false,
})
export class AssistantChatController {
constructor(private readonly assistantChatService: AssistantChatService) {}

private getUserScopedContext(auth: AuthContextType): { organizationId: string; userId: string } {
// Defensive checks (should already be guaranteed by HybridAuthGuard + AuthContext decorator)
if (!auth.organizationId) {
throw new BadRequestException('Organization ID is required');
}

if (auth.isApiKey) {
throw new BadRequestException(
'Assistant chat history is only available for user-authenticated requests (Bearer JWT).',
);
}

if (!auth.userId) {
throw new BadRequestException('User ID is required');
}

return { organizationId: auth.organizationId, userId: auth.userId };
}

@Get('history')
@ApiOperation({
summary: 'Get assistant chat history',
description:
'Returns the current user-scoped assistant chat history (ephemeral session context).',
})
@ApiResponse({
status: 200,
description: 'Chat history retrieved',
schema: {
type: 'object',
properties: {
messages: { type: 'array', items: { type: 'object' } },
},
},
})
async getHistory(@AuthContext() auth: AuthContextType): Promise<{ messages: AssistantChatMessage[] }> {
const { organizationId, userId } = this.getUserScopedContext(auth);

const messages = await this.assistantChatService.getHistory({
organizationId,
userId,
});

return { messages };
}

@Put('history')
@ApiOperation({
summary: 'Save assistant chat history',
description:
'Replaces the current user-scoped assistant chat history (ephemeral session context).',
})
async saveHistory(
@AuthContext() auth: AuthContextType,
@Body() dto: SaveAssistantChatHistoryDto,
): Promise<{ success: true }> {
const { organizationId, userId } = this.getUserScopedContext(auth);

await this.assistantChatService.saveHistory(
{ organizationId, userId },
dto.messages,
);

return { success: true };
}

@Delete('history')
@ApiOperation({
summary: 'Clear assistant chat history',
description: 'Deletes the current user-scoped assistant chat history.',
})
async clearHistory(@AuthContext() auth: AuthContextType): Promise<{ success: true }> {
const { organizationId, userId } = this.getUserScopedContext(auth);

await this.assistantChatService.clearHistory({
organizationId,
userId,
});

return { success: true };
}
}


31 changes: 31 additions & 0 deletions apps/api/src/assistant-chat/assistant-chat.dto.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
import { ApiProperty } from '@nestjs/swagger';
import { IsArray, IsIn, IsNumber, IsString, ValidateNested } from 'class-validator';
import { Type } from 'class-transformer';

export class AssistantChatMessageDto {
@ApiProperty({ example: 'msg_abc123' })
@IsString()
id!: string;

@ApiProperty({ enum: ['user', 'assistant'], example: 'user' })
@IsIn(['user', 'assistant'])
role!: 'user' | 'assistant';

@ApiProperty({ example: 'How do I invite a teammate?' })
@IsString()
text!: string;

@ApiProperty({ example: 1735781554000, description: 'Unix epoch millis' })
@IsNumber()
createdAt!: number;
}

export class SaveAssistantChatHistoryDto {
@ApiProperty({ type: [AssistantChatMessageDto] })
@IsArray()
@ValidateNested({ each: true })
@Type(() => AssistantChatMessageDto)
messages!: AssistantChatMessageDto[];
}


13 changes: 13 additions & 0 deletions apps/api/src/assistant-chat/assistant-chat.module.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
import { Module } from '@nestjs/common';
import { AuthModule } from '../auth/auth.module';
import { AssistantChatController } from './assistant-chat.controller';
import { AssistantChatService } from './assistant-chat.service';

@Module({
imports: [AuthModule],
controllers: [AssistantChatController],
providers: [AssistantChatService],
})
export class AssistantChatModule {}


53 changes: 53 additions & 0 deletions apps/api/src/assistant-chat/assistant-chat.service.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
import { Injectable } from '@nestjs/common';
import { z } from 'zod';
import { assistantChatRedisClient } from './upstash-redis.client';
import type { AssistantChatMessage } from './assistant-chat.types';

const StoredMessageSchema = z.object({
id: z.string(),
role: z.enum(['user', 'assistant']),
text: z.string(),
createdAt: z.number(),
});

const StoredMessagesSchema = z.array(StoredMessageSchema);

type GetAssistantChatKeyParams = {
organizationId: string;
userId: string;
};

const getAssistantChatKey = ({ organizationId, userId }: GetAssistantChatKeyParams): string => {
return `assistant-chat:v1:${organizationId}:${userId}`;
};

@Injectable()
export class AssistantChatService {
/**
* Default TTL is 7 days. This is intended to behave like "session context"
* rather than a long-term, searchable archive.
*/
private readonly ttlSeconds = Number(process.env.ASSISTANT_CHAT_TTL_SECONDS ?? 60 * 60 * 24 * 7);

async getHistory(params: GetAssistantChatKeyParams): Promise<AssistantChatMessage[]> {
const key = getAssistantChatKey(params);
const raw = await assistantChatRedisClient.get<unknown>(key);
const parsed = StoredMessagesSchema.safeParse(raw);
if (!parsed.success) return [];
return parsed.data;
}

async saveHistory(params: GetAssistantChatKeyParams, messages: AssistantChatMessage[]): Promise<void> {
const key = getAssistantChatKey(params);
// Always validate before writing to keep the cache shape stable.
const validated = StoredMessagesSchema.parse(messages);
await assistantChatRedisClient.set(key, validated, { ex: this.ttlSeconds });
}

async clearHistory(params: GetAssistantChatKeyParams): Promise<void> {
const key = getAssistantChatKey(params);
await assistantChatRedisClient.del(key);
}
}


8 changes: 8 additions & 0 deletions apps/api/src/assistant-chat/assistant-chat.types.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
export type AssistantChatMessage = {
id: string;
role: 'user' | 'assistant';
text: string;
createdAt: number;
};


45 changes: 45 additions & 0 deletions apps/api/src/assistant-chat/upstash-redis.client.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
import { Redis } from '@upstash/redis';

/**
* Upstash Redis client wrapper for the NestJS API.
*
* NOTE: We do NOT import `server-only` here because this code runs in Node (Nest),
* not Next.js Server Components.
*/
class InMemoryRedis {
private storage = new Map<string, { value: unknown; expiresAt?: number }>();

async get<T = unknown>(key: string): Promise<T | null> {
const record = this.storage.get(key);
if (!record) return null;
if (record.expiresAt && record.expiresAt <= Date.now()) {
this.storage.delete(key);
return null;
}
return record.value as T;
}

async set(key: string, value: unknown, options?: { ex?: number }): Promise<'OK'> {
const expiresAt = options?.ex ? Date.now() + options.ex * 1000 : undefined;
this.storage.set(key, { value, expiresAt });
return 'OK';
}

async del(key: string): Promise<number> {
const existed = this.storage.delete(key);
return existed ? 1 : 0;
}
}

const hasUpstashConfig =
!!process.env.UPSTASH_REDIS_REST_URL && !!process.env.UPSTASH_REDIS_REST_TOKEN;

export const assistantChatRedisClient: Pick<Redis, 'get' | 'set' | 'del'> =
hasUpstashConfig
? new Redis({
url: process.env.UPSTASH_REDIS_REST_URL!,
token: process.env.UPSTASH_REDIS_REST_TOKEN!,
})
: (new InMemoryRedis() as unknown as Pick<Redis, 'get' | 'set' | 'del'>);


8 changes: 4 additions & 4 deletions apps/api/src/task-management/task-management.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -298,8 +298,8 @@ export class TaskManagementService {
`Created task item: ${taskItem.id} for organization ${organizationId} by ${member.id}`,
);

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

// Log initial assignment in audit log
// Log initial assignment in audit log (after creation log to ensure correct order)
if (taskItem.assignee) {
void this.auditService.logTaskItemAssigned({
await this.auditService.logTaskItemAssigned({
taskItemId: taskItem.id,
organizationId,
userId: authContext.userId,
Expand Down
Loading
Loading