Skip to content
Closed
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
3 changes: 3 additions & 0 deletions .github/workflows/trigger-tasks-deploy-main.yml
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,9 @@ jobs:
- name: Install DB package dependencies
working-directory: ./packages/db
run: bun install --frozen-lockfile --ignore-scripts
- name: Install Email package dependencies
working-directory: ./packages/email
run: bun install --frozen-lockfile --ignore-scripts
- name: Generate Prisma client
working-directory: ./packages/db
run: bunx prisma generate
Expand Down
3 changes: 3 additions & 0 deletions .github/workflows/trigger-tasks-deploy-release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,9 @@ jobs:
- name: Install DB package dependencies
working-directory: ./packages/db
run: bun install --frozen-lockfile --ignore-scripts
- name: Install Email package dependencies
working-directory: ./packages/email
run: bun install --frozen-lockfile --ignore-scripts

- name: Generate Prisma client
working-directory: ./packages/db
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 @@ -16,6 +16,7 @@ import { RisksModule } from './risks/risks.module';
import { TasksModule } from './tasks/tasks.module';
import { VendorsModule } from './vendors/vendors.module';
import { ContextModule } from './context/context.module';
import { TrustPortalModule } from './trust-portal/trust-portal.module';

@Module({
imports: [
Expand All @@ -41,6 +42,7 @@ import { ContextModule } from './context/context.module';
TasksModule,
CommentsModule,
HealthModule,
TrustPortalModule,
],
controllers: [AppController],
providers: [AppService],
Expand Down
14 changes: 13 additions & 1 deletion apps/api/src/main.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import type { INestApplication } from '@nestjs/common';
import { VersioningType } from '@nestjs/common';
import { ValidationPipe, VersioningType } from '@nestjs/common';
import { NestFactory } from '@nestjs/core';
import type { OpenAPIObject } from '@nestjs/swagger';
import { DocumentBuilder, SwaggerModule } from '@nestjs/swagger';
Expand All @@ -11,6 +11,18 @@ import path from 'path';
async function bootstrap(): Promise<void> {
const app: INestApplication = await NestFactory.create(AppModule);

// Enable global validation pipe
app.useGlobalPipes(
new ValidationPipe({
whitelist: true,
forbidNonWhitelisted: true,
transform: true,
transformOptions: {
enableImplicitConversion: true,
},
}),
);

// Configure body parser limits for file uploads (base64 encoded files)
app.use(express.json({ limit: '15mb' }));
app.use(express.urlencoded({ limit: '15mb', extended: true }));
Expand Down
47 changes: 47 additions & 0 deletions apps/api/src/trust-portal/dto/domain-status.dto.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
import { ApiProperty } from '@nestjs/swagger';
import { IsNotEmpty, IsString, Matches } from 'class-validator';

export class GetDomainStatusDto {
@ApiProperty({
description: 'The domain name to check status for',
example: 'portal.example.com',
})
@IsString()
@IsNotEmpty({ message: 'domain cannot be empty' })
@Matches(/^(?!-)[A-Za-z0-9-]+([-\.]{1}[a-z0-9]+)*\.[A-Za-z]{2,6}$/, {
message: 'domain must be a valid domain format',
})
domain: string;
}

export class DomainVerificationDto {
@ApiProperty({ description: 'Verification type (e.g., TXT, CNAME)' })
type: string;

@ApiProperty({ description: 'Domain for verification' })
domain: string;

@ApiProperty({ description: 'Verification value' })
value: string;

@ApiProperty({
description: 'Reason for verification status',
required: false,
})
reason?: string;
}

export class DomainStatusResponseDto {
@ApiProperty({ description: 'The domain name' })
domain: string;

@ApiProperty({ description: 'Whether the domain is verified' })
verified: boolean;

@ApiProperty({
description: 'Verification records for the domain',
type: [DomainVerificationDto],
required: false,
})
verification?: DomainVerificationDto[];
}
68 changes: 68 additions & 0 deletions apps/api/src/trust-portal/trust-portal.controller.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
import {
Controller,
Get,
HttpCode,
HttpStatus,
Query,
UseGuards,
} from '@nestjs/common';
import {
ApiHeader,
ApiOperation,
ApiQuery,
ApiResponse,
ApiSecurity,
ApiTags,
} from '@nestjs/swagger';
import { HybridAuthGuard } from '../auth/hybrid-auth.guard';
import {
DomainStatusResponseDto,
GetDomainStatusDto,
} from './dto/domain-status.dto';
import { TrustPortalService } from './trust-portal.service';

@ApiTags('Trust Portal')
@Controller({ path: 'trust-portal', version: '1' })
@UseGuards(HybridAuthGuard)
@ApiSecurity('apikey')
@ApiHeader({
name: 'X-Organization-Id',
description:
'Organization ID (required for session auth, optional for API key auth)',
required: false,
})
export class TrustPortalController {
constructor(private readonly trustPortalService: TrustPortalService) {}

@Get('domain/status')
@HttpCode(HttpStatus.OK)
@ApiOperation({
summary: 'Get domain verification status',
description:
'Retrieve the verification status and DNS records for a custom domain configured in the Vercel trust portal project',
})
@ApiQuery({
name: 'domain',
description: 'The domain name to check status for',
example: 'portal.example.com',
required: true,
})
@ApiResponse({
status: HttpStatus.OK,
description: 'Domain status retrieved successfully',
type: DomainStatusResponseDto,
})
@ApiResponse({
status: HttpStatus.INTERNAL_SERVER_ERROR,
description: 'Failed to retrieve domain status from Vercel',
})
@ApiResponse({
status: HttpStatus.UNAUTHORIZED,
description: 'Unauthorized - Invalid or missing authentication',
})
async getDomainStatus(
@Query() dto: GetDomainStatusDto,
): Promise<DomainStatusResponseDto> {
return this.trustPortalService.getDomainStatus(dto);
}
}
12 changes: 12 additions & 0 deletions apps/api/src/trust-portal/trust-portal.module.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
import { Module } from '@nestjs/common';
import { AuthModule } from '../auth/auth.module';
import { TrustPortalController } from './trust-portal.controller';
import { TrustPortalService } from './trust-portal.service';

@Module({
imports: [AuthModule],
controllers: [TrustPortalController],
providers: [TrustPortalService],
exports: [TrustPortalService],
})
export class TrustPortalModule {}
117 changes: 117 additions & 0 deletions apps/api/src/trust-portal/trust-portal.service.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,117 @@
import {
BadRequestException,
Injectable,
InternalServerErrorException,
Logger,
} from '@nestjs/common';
import axios, { AxiosInstance } from 'axios';
import {
DomainStatusResponseDto,
DomainVerificationDto,
GetDomainStatusDto,
} from './dto/domain-status.dto';

interface VercelDomainVerification {
type: string;
domain: string;
value: string;
reason?: string;
}

interface VercelDomainResponse {
name: string;
verified: boolean;
verification?: VercelDomainVerification[];
}

@Injectable()
export class TrustPortalService {
private readonly logger = new Logger(TrustPortalService.name);
private readonly vercelApi: AxiosInstance;

constructor() {
const bearerToken = process.env.VERCEL_ACCESS_TOKEN;

if (!bearerToken) {
this.logger.warn('VERCEL_ACCESS_TOKEN is not set');
}

// Initialize axios instance for Vercel API
this.vercelApi = axios.create({
baseURL: 'https://api.vercel.com',
headers: {
Authorization: `Bearer ${bearerToken || ''}`,
'Content-Type': 'application/json',
},
});
}

async getDomainStatus(
dto: GetDomainStatusDto,
): Promise<DomainStatusResponseDto> {
const { domain } = dto;

if (!process.env.TRUST_PORTAL_PROJECT_ID) {
throw new InternalServerErrorException(
'TRUST_PORTAL_PROJECT_ID is not configured',
);
}

if (!process.env.VERCEL_TEAM_ID) {
throw new InternalServerErrorException(
'VERCEL_TEAM_ID is not configured',
);
}

if (!domain) {
throw new BadRequestException('Domain is required');
}

try {
this.logger.log(`Fetching domain status for: ${domain}`);

// Get domain information including verification status
// Vercel API endpoint: GET /v9/projects/{projectId}/domains/{domain}
const response = await this.vercelApi.get<VercelDomainResponse>(
`/v9/projects/${process.env.TRUST_PORTAL_PROJECT_ID}/domains/${domain}`,
{
params: {
teamId: process.env.VERCEL_TEAM_ID,
},
},
);

const domainInfo = response.data;

const verification: DomainVerificationDto[] | undefined =
domainInfo.verification?.map((v) => ({
type: v.type,
domain: v.domain,
value: v.value,
reason: v.reason,
}));

return {
domain: domainInfo.name,
verified: domainInfo.verified ?? false,
verification,
};
} catch (error) {
this.logger.error(
`Failed to get domain status for ${domain}:`,
error instanceof Error ? error.stack : error,
);

// Handle axios errors with more detail
if (axios.isAxiosError(error)) {
const statusCode = error.response?.status;
const message = error.response?.data?.error?.message || error.message;
this.logger.error(`Vercel API error (${statusCode}): ${message}`);
}

throw new InternalServerErrorException(
'Failed to get domain status from Vercel',
);
}
}
}
50 changes: 27 additions & 23 deletions apps/app/src/actions/policies/accept-requested-policy-changes.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
'use server';

import { sendNewPolicyEmail } from '@/jobs/tasks/email/new-policy-email';
import { db, PolicyStatus } from '@db';
import { tasks } from '@trigger.dev/sdk';
import { revalidatePath, revalidateTag } from 'next/cache';
import { z } from 'zod';
import { authActionClient } from '../safe-action';
Expand Down Expand Up @@ -92,34 +94,36 @@ export const acceptRequestedPolicyChangesAction = authActionClient
return roles.includes('employee');
});

// Call /api/send-policy-email to send emails to employees

// Prepare the events array for the API
const events = employeeMembers
.filter((employee) => employee.user.email)
.map((employee) => ({
subscriberId: `${employee.user.id}-${session.activeOrganizationId}`,
email: employee.user.email,
userName: employee.user.name || employee.user.email || 'Employee',
policyName: policy.name,
organizationName: policy.organization.name,
url: `${process.env.NEXT_PUBLIC_PORTAL_URL ?? 'https://portal.trycomp.ai'}/${session.activeOrganizationId}`,
description: `The "${policy.name}" policy has been ${isNewPolicy ? 'created' : 'updated'}.`,
}));
.map((employee) => {
let notificationType: 'new' | 're-acceptance' | 'updated';
const wasAlreadySigned = policy.signedBy.includes(employee.id);
if (isNewPolicy) {
notificationType = 'new';
} else if (wasAlreadySigned) {
notificationType = 're-acceptance';
} else {
notificationType = 'updated';
}

return {
email: employee.user.email,
userName: employee.user.name || employee.user.email || 'Employee',
policyName: policy.name,
organizationId: session.activeOrganizationId || '',
organizationName: policy.organization.name,
notificationType,
};
});

// Call the API route to send the emails
try {
await fetch(`${process.env.BETTER_AUTH_URL ?? ''}/api/send-policy-email`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify(events),
});
} catch (error) {
console.error('Failed to call /api/send-policy-email:', error);
// Don't throw, just log
}
await Promise.all(
events.map((event) =>
tasks.trigger<typeof sendNewPolicyEmail>('send-new-policy-email', event),
),
);

// If a comment was provided, create a comment
if (comment && comment.trim() !== '') {
Expand Down
Loading
Loading