Skip to content
Merged
Show file tree
Hide file tree
Changes from all 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/.env.example
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ APP_AWS_REGION=
APP_AWS_ACCESS_KEY_ID=
APP_AWS_SECRET_ACCESS_KEY=
APP_AWS_ORG_ASSETS_BUCKET=
APP_AWS_ENDPOINT="" # optional for using services like MinIO

DATABASE_URL=

Expand Down
2 changes: 1 addition & 1 deletion apps/api/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@
"@react-email/components": "^0.0.41",
"@trigger.dev/build": "4.0.6",
"@trigger.dev/sdk": "4.0.6",
"@trycompai/db": "^1.3.20",
"@trycompai/db": "1.3.21",
"@trycompai/email": "workspace:*",
"@upstash/redis": "^1.34.2",
"@upstash/vector": "^1.2.2",
Expand Down
3 changes: 3 additions & 0 deletions apps/api/src/app/s3.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ const logger = new Logger('S3');
const APP_AWS_REGION = process.env.APP_AWS_REGION;
const APP_AWS_ACCESS_KEY_ID = process.env.APP_AWS_ACCESS_KEY_ID;
const APP_AWS_SECRET_ACCESS_KEY = process.env.APP_AWS_SECRET_ACCESS_KEY;
const APP_AWS_ENDPOINT = process.env.APP_AWS_ENDPOINT;

export const BUCKET_NAME = process.env.APP_AWS_BUCKET_NAME;
export const APP_AWS_QUESTIONNAIRE_UPLOAD_BUCKET =
Expand All @@ -37,11 +38,13 @@ try {
}

s3ClientInstance = new S3Client({
endpoint: APP_AWS_ENDPOINT || undefined,
region: APP_AWS_REGION,
credentials: {
accessKeyId: APP_AWS_ACCESS_KEY_ID,
secretAccessKey: APP_AWS_SECRET_ACCESS_KEY,
},
forcePathStyle: !!APP_AWS_ENDPOINT,
});
} catch (error) {
logger.error(
Expand Down
15 changes: 10 additions & 5 deletions apps/api/src/assistant-chat/assistant-chat.controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,10 @@ import type { AssistantChatMessage } from './assistant-chat.types';
export class AssistantChatController {
constructor(private readonly assistantChatService: AssistantChatService) {}

private getUserScopedContext(auth: AuthContextType): { organizationId: string; userId: string } {
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');
Expand Down Expand Up @@ -69,7 +72,9 @@ export class AssistantChatController {
},
},
})
async getHistory(@AuthContext() auth: AuthContextType): Promise<{ messages: AssistantChatMessage[] }> {
async getHistory(
@AuthContext() auth: AuthContextType,
): Promise<{ messages: AssistantChatMessage[] }> {
const { organizationId, userId } = this.getUserScopedContext(auth);

const messages = await this.assistantChatService.getHistory({
Expand Down Expand Up @@ -105,7 +110,9 @@ export class AssistantChatController {
summary: 'Clear assistant chat history',
description: 'Deletes the current user-scoped assistant chat history.',
})
async clearHistory(@AuthContext() auth: AuthContextType): Promise<{ success: true }> {
async clearHistory(
@AuthContext() auth: AuthContextType,
): Promise<{ success: true }> {
const { organizationId, userId } = this.getUserScopedContext(auth);

await this.assistantChatService.clearHistory({
Expand All @@ -116,5 +123,3 @@ export class AssistantChatController {
return { success: true };
}
}


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

export class AssistantChatMessageDto {
Expand Down Expand Up @@ -27,5 +33,3 @@ export class SaveAssistantChatHistoryDto {
@Type(() => AssistantChatMessageDto)
messages!: AssistantChatMessageDto[];
}


2 changes: 0 additions & 2 deletions apps/api/src/assistant-chat/assistant-chat.module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,5 +9,3 @@ import { AssistantChatService } from './assistant-chat.service';
providers: [AssistantChatService],
})
export class AssistantChatModule {}


20 changes: 14 additions & 6 deletions apps/api/src/assistant-chat/assistant-chat.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,10 @@ type GetAssistantChatKeyParams = {
userId: string;
};

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

Expand All @@ -27,17 +30,24 @@ 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);
private readonly ttlSeconds = Number(
process.env.ASSISTANT_CHAT_TTL_SECONDS ?? 60 * 60 * 24 * 7,
);

async getHistory(params: GetAssistantChatKeyParams): Promise<AssistantChatMessage[]> {
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> {
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);
Expand All @@ -49,5 +59,3 @@ export class AssistantChatService {
await assistantChatRedisClient.del(key);
}
}


2 changes: 0 additions & 2 deletions apps/api/src/assistant-chat/assistant-chat.types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,5 +4,3 @@ export type AssistantChatMessage = {
text: string;
createdAt: number;
};


11 changes: 7 additions & 4 deletions apps/api/src/assistant-chat/upstash-redis.client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,11 @@ class InMemoryRedis {
return record.value as T;
}

async set(key: string, value: unknown, options?: { ex?: number }): Promise<'OK'> {
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';
Expand All @@ -32,7 +36,8 @@ class InMemoryRedis {
}

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

export const assistantChatRedisClient: Pick<Redis, 'get' | 'set' | 'del'> =
hasUpstashConfig
Expand All @@ -41,5 +46,3 @@ export const assistantChatRedisClient: Pick<Redis, 'get' | 'set' | 'del'> =
token: process.env.UPSTASH_REDIS_REST_TOKEN!,
})
: (new InMemoryRedis() as unknown as Pick<Redis, 'get' | 'set' | 'del'>);


38 changes: 26 additions & 12 deletions apps/api/src/attachments/attachments.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import {
import { randomBytes } from 'crypto';
import { AttachmentResponseDto } from '../tasks/dto/task-responses.dto';
import { UploadAttachmentDto } from './upload-attachment.dto';
import { s3Client } from '@/app/s3';

@Injectable()
export class AttachmentsService {
Expand All @@ -27,20 +28,14 @@ export class AttachmentsService {
// Safe to access environment variables directly since they're validated
this.bucketName = process.env.APP_AWS_BUCKET_NAME!;

if (
!process.env.APP_AWS_ACCESS_KEY_ID ||
!process.env.APP_AWS_SECRET_ACCESS_KEY
) {
console.warn('AWS credentials are missing, S3 client may fail');
if (!s3Client) {
console.error('S3 Client is not initialized. Check AWS S3 configuration.');
throw new Error(
'S3 Client is not initialized. Check AWS S3 configuration.',
);
}

this.s3Client = new S3Client({
region: process.env.APP_AWS_REGION || 'us-east-1',
credentials: {
accessKeyId: process.env.APP_AWS_ACCESS_KEY_ID!,
secretAccessKey: process.env.APP_AWS_SECRET_ACCESS_KEY!,
},
});
this.s3Client = s3Client;
}

/**
Expand Down Expand Up @@ -385,6 +380,25 @@ export class AttachmentsService {
return this.generateSignedUrl(s3Key);
}

/**
* Generate presigned download URL with a custom download filename
*/
async getPresignedDownloadUrlWithFilename(
s3Key: string,
downloadFilename: string,
): Promise<string> {
const sanitizedFilename = this.sanitizeHeaderValue(downloadFilename);
const getCommand = new GetObjectCommand({
Bucket: this.bucketName,
Key: s3Key,
ResponseContentDisposition: `attachment; filename="${sanitizedFilename}"`,
});

return getSignedUrl(this.s3Client, getCommand, {
expiresIn: this.SIGNED_URL_EXPIRY,
});
}

async getObjectBuffer(s3Key: string): Promise<Buffer> {
const getCommand = new GetObjectCommand({
Bucket: this.bucketName,
Expand Down
2 changes: 0 additions & 2 deletions apps/api/src/auth/internal-token.guard.ts
Original file line number Diff line number Diff line change
Expand Up @@ -42,5 +42,3 @@ export class InternalTokenGuard implements CanActivate {
return true;
}
}


10 changes: 7 additions & 3 deletions apps/api/src/comments/comment-mention-notifier.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -102,7 +102,8 @@ async function buildFallbackCommentContext(params: {
});

if (taskItem) {
const parentRoutePath = taskItem.entityType === 'vendor' ? 'vendors' : 'risk';
const parentRoutePath =
taskItem.entityType === 'vendor' ? 'vendors' : 'risk';
const url = new URL(
`${appUrl}/${organizationId}/${parentRoutePath}/${taskItem.entityId}`,
);
Expand Down Expand Up @@ -291,7 +292,11 @@ export class CommentMentionNotifierService {

// Check if user is unsubscribed from comment mention notifications
// Note: We'll use 'taskMentions' preference for now, or create a new 'commentMentions' preference
const isUnsubscribed = await isUserUnsubscribed(db, user.email, 'taskMentions');
const isUnsubscribed = await isUserUnsubscribed(
db,
user.email,
'taskMentions',
);
if (isUnsubscribed) {
this.logger.log(
`Skipping mention notification: user ${user.email} is unsubscribed from mentions`,
Expand Down Expand Up @@ -375,4 +380,3 @@ export class CommentMentionNotifierService {
}
}
}

4 changes: 3 additions & 1 deletion apps/api/src/comments/comments.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -270,7 +270,9 @@ export class CommentsService {

// Notify mentioned users
if (createCommentDto.content && userId) {
const mentionedUserIds = extractMentionedUserIds(createCommentDto.content);
const mentionedUserIds = extractMentionedUserIds(
createCommentDto.content,
);
if (mentionedUserIds.length > 0) {
// Fire-and-forget: notification failures should not block comment creation
void this.mentionNotifier.notifyMentionedUsers({
Expand Down
2 changes: 2 additions & 0 deletions apps/api/src/config/aws.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ const awsConfigSchema = z.object({
accessKeyId: z.string().min(1, 'AWS_ACCESS_KEY_ID is required'),
secretAccessKey: z.string().min(1, 'AWS_SECRET_ACCESS_KEY is required'),
bucketName: z.string().min(1, 'AWS_BUCKET_NAME is required'),
endpoint: z.string().optional(),
});

export type AwsConfig = z.infer<typeof awsConfigSchema>;
Expand All @@ -16,6 +17,7 @@ export const awsConfig = registerAs('aws', (): AwsConfig => {
accessKeyId: process.env.APP_AWS_ACCESS_KEY_ID || '',
secretAccessKey: process.env.APP_AWS_SECRET_ACCESS_KEY || '',
bucketName: process.env.APP_AWS_BUCKET_NAME || '',
endpoint: process.env.APP_AWS_ENDPOINT || '',
};

// Validate configuration at startup
Expand Down
35 changes: 35 additions & 0 deletions apps/api/src/policies/policies.controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import {
Controller,
Delete,
Get,
HttpCode,
Param,
Patch,
Post,
Expand Down Expand Up @@ -77,6 +78,40 @@ export class PoliciesController {
};
}

@Get('download-all')
@HttpCode(HttpStatus.OK)
@ApiOperation({
summary: 'Download all published policies as a single PDF',
description:
'Generates a PDF bundle containing all published policies with organization branding and returns a signed download URL',
})
@ApiResponse({
status: 200,
description: 'Signed URL for PDF bundle returned',
})
@ApiResponse({
status: 404,
description: 'No published policies found',
})
async downloadAllPolicies(
@OrganizationId() organizationId: string,
@AuthContext() authContext: AuthContextType,
) {
const result =
await this.policiesService.downloadAllPoliciesPdf(organizationId);

return {
...result,
authType: authContext.authType,
...(authContext.userId && {
authenticatedUser: {
id: authContext.userId,
email: authContext.userEmail,
},
}),
};
}

@Get(':id')
@ApiOperation(POLICY_OPERATIONS.getPolicyById)
@ApiParam(POLICY_PARAMS.policyId)
Expand Down
6 changes: 4 additions & 2 deletions apps/api/src/policies/policies.module.ts
Original file line number Diff line number Diff line change
@@ -1,12 +1,14 @@
import { Module } from '@nestjs/common';
import { AttachmentsModule } from '../attachments/attachments.module';
import { AuthModule } from '../auth/auth.module';
import { PolicyPdfRendererService } from '../trust-portal/policy-pdf-renderer.service';
import { PoliciesController } from './policies.controller';
import { PoliciesService } from './policies.service';

@Module({
imports: [AuthModule],
imports: [AuthModule, AttachmentsModule],
controllers: [PoliciesController],
providers: [PoliciesService],
providers: [PoliciesService, PolicyPdfRendererService],
exports: [PoliciesService],
})
export class PoliciesModule {}
Loading
Loading