Skip to content

Commit 033f846

Browse files
committed
chore: merge main into release for new releases
2 parents 1ddc9df + 80e63b8 commit 033f846

File tree

27 files changed

+662
-111
lines changed

27 files changed

+662
-111
lines changed

.github/workflows/trigger-tasks-deploy-main.yml

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,9 @@ jobs:
2121
- name: Install DB package dependencies
2222
working-directory: ./packages/db
2323
run: bun install --frozen-lockfile --ignore-scripts
24+
- name: Install Email package dependencies
25+
working-directory: ./packages/email
26+
run: bun install --frozen-lockfile --ignore-scripts
2427
- name: Generate Prisma client
2528
working-directory: ./packages/db
2629
run: bunx prisma generate

.github/workflows/trigger-tasks-deploy-release.yml

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,9 @@ jobs:
2424
- name: Install DB package dependencies
2525
working-directory: ./packages/db
2626
run: bun install --frozen-lockfile --ignore-scripts
27+
- name: Install Email package dependencies
28+
working-directory: ./packages/email
29+
run: bun install --frozen-lockfile --ignore-scripts
2730

2831
- name: Generate Prisma client
2932
working-directory: ./packages/db

apps/api/src/app.module.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ import { RisksModule } from './risks/risks.module';
1616
import { TasksModule } from './tasks/tasks.module';
1717
import { VendorsModule } from './vendors/vendors.module';
1818
import { ContextModule } from './context/context.module';
19+
import { TrustPortalModule } from './trust-portal/trust-portal.module';
1920

2021
@Module({
2122
imports: [
@@ -41,6 +42,7 @@ import { ContextModule } from './context/context.module';
4142
TasksModule,
4243
CommentsModule,
4344
HealthModule,
45+
TrustPortalModule,
4446
],
4547
controllers: [AppController],
4648
providers: [AppService],

apps/api/src/main.ts

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import type { INestApplication } from '@nestjs/common';
2-
import { VersioningType } from '@nestjs/common';
2+
import { ValidationPipe, VersioningType } from '@nestjs/common';
33
import { NestFactory } from '@nestjs/core';
44
import type { OpenAPIObject } from '@nestjs/swagger';
55
import { DocumentBuilder, SwaggerModule } from '@nestjs/swagger';
@@ -11,6 +11,18 @@ import path from 'path';
1111
async function bootstrap(): Promise<void> {
1212
const app: INestApplication = await NestFactory.create(AppModule);
1313

14+
// Enable global validation pipe
15+
app.useGlobalPipes(
16+
new ValidationPipe({
17+
whitelist: true,
18+
forbidNonWhitelisted: true,
19+
transform: true,
20+
transformOptions: {
21+
enableImplicitConversion: true,
22+
},
23+
}),
24+
);
25+
1426
// Configure body parser limits for file uploads (base64 encoded files)
1527
app.use(express.json({ limit: '15mb' }));
1628
app.use(express.urlencoded({ limit: '15mb', extended: true }));
Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
import { ApiProperty } from '@nestjs/swagger';
2+
import { IsNotEmpty, IsString, Matches } from 'class-validator';
3+
4+
export class GetDomainStatusDto {
5+
@ApiProperty({
6+
description: 'The domain name to check status for',
7+
example: 'portal.example.com',
8+
})
9+
@IsString()
10+
@IsNotEmpty({ message: 'domain cannot be empty' })
11+
@Matches(/^(?!-)[A-Za-z0-9-]+([-\.]{1}[a-z0-9]+)*\.[A-Za-z]{2,6}$/, {
12+
message: 'domain must be a valid domain format',
13+
})
14+
domain: string;
15+
}
16+
17+
export class DomainVerificationDto {
18+
@ApiProperty({ description: 'Verification type (e.g., TXT, CNAME)' })
19+
type: string;
20+
21+
@ApiProperty({ description: 'Domain for verification' })
22+
domain: string;
23+
24+
@ApiProperty({ description: 'Verification value' })
25+
value: string;
26+
27+
@ApiProperty({
28+
description: 'Reason for verification status',
29+
required: false,
30+
})
31+
reason?: string;
32+
}
33+
34+
export class DomainStatusResponseDto {
35+
@ApiProperty({ description: 'The domain name' })
36+
domain: string;
37+
38+
@ApiProperty({ description: 'Whether the domain is verified' })
39+
verified: boolean;
40+
41+
@ApiProperty({
42+
description: 'Verification records for the domain',
43+
type: [DomainVerificationDto],
44+
required: false,
45+
})
46+
verification?: DomainVerificationDto[];
47+
}
Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,68 @@
1+
import {
2+
Controller,
3+
Get,
4+
HttpCode,
5+
HttpStatus,
6+
Query,
7+
UseGuards,
8+
} from '@nestjs/common';
9+
import {
10+
ApiHeader,
11+
ApiOperation,
12+
ApiQuery,
13+
ApiResponse,
14+
ApiSecurity,
15+
ApiTags,
16+
} from '@nestjs/swagger';
17+
import { HybridAuthGuard } from '../auth/hybrid-auth.guard';
18+
import {
19+
DomainStatusResponseDto,
20+
GetDomainStatusDto,
21+
} from './dto/domain-status.dto';
22+
import { TrustPortalService } from './trust-portal.service';
23+
24+
@ApiTags('Trust Portal')
25+
@Controller({ path: 'trust-portal', version: '1' })
26+
@UseGuards(HybridAuthGuard)
27+
@ApiSecurity('apikey')
28+
@ApiHeader({
29+
name: 'X-Organization-Id',
30+
description:
31+
'Organization ID (required for session auth, optional for API key auth)',
32+
required: false,
33+
})
34+
export class TrustPortalController {
35+
constructor(private readonly trustPortalService: TrustPortalService) {}
36+
37+
@Get('domain/status')
38+
@HttpCode(HttpStatus.OK)
39+
@ApiOperation({
40+
summary: 'Get domain verification status',
41+
description:
42+
'Retrieve the verification status and DNS records for a custom domain configured in the Vercel trust portal project',
43+
})
44+
@ApiQuery({
45+
name: 'domain',
46+
description: 'The domain name to check status for',
47+
example: 'portal.example.com',
48+
required: true,
49+
})
50+
@ApiResponse({
51+
status: HttpStatus.OK,
52+
description: 'Domain status retrieved successfully',
53+
type: DomainStatusResponseDto,
54+
})
55+
@ApiResponse({
56+
status: HttpStatus.INTERNAL_SERVER_ERROR,
57+
description: 'Failed to retrieve domain status from Vercel',
58+
})
59+
@ApiResponse({
60+
status: HttpStatus.UNAUTHORIZED,
61+
description: 'Unauthorized - Invalid or missing authentication',
62+
})
63+
async getDomainStatus(
64+
@Query() dto: GetDomainStatusDto,
65+
): Promise<DomainStatusResponseDto> {
66+
return this.trustPortalService.getDomainStatus(dto);
67+
}
68+
}
Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
import { Module } from '@nestjs/common';
2+
import { AuthModule } from '../auth/auth.module';
3+
import { TrustPortalController } from './trust-portal.controller';
4+
import { TrustPortalService } from './trust-portal.service';
5+
6+
@Module({
7+
imports: [AuthModule],
8+
controllers: [TrustPortalController],
9+
providers: [TrustPortalService],
10+
exports: [TrustPortalService],
11+
})
12+
export class TrustPortalModule {}
Lines changed: 117 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,117 @@
1+
import {
2+
BadRequestException,
3+
Injectable,
4+
InternalServerErrorException,
5+
Logger,
6+
} from '@nestjs/common';
7+
import axios, { AxiosInstance } from 'axios';
8+
import {
9+
DomainStatusResponseDto,
10+
DomainVerificationDto,
11+
GetDomainStatusDto,
12+
} from './dto/domain-status.dto';
13+
14+
interface VercelDomainVerification {
15+
type: string;
16+
domain: string;
17+
value: string;
18+
reason?: string;
19+
}
20+
21+
interface VercelDomainResponse {
22+
name: string;
23+
verified: boolean;
24+
verification?: VercelDomainVerification[];
25+
}
26+
27+
@Injectable()
28+
export class TrustPortalService {
29+
private readonly logger = new Logger(TrustPortalService.name);
30+
private readonly vercelApi: AxiosInstance;
31+
32+
constructor() {
33+
const bearerToken = process.env.VERCEL_ACCESS_TOKEN;
34+
35+
if (!bearerToken) {
36+
this.logger.warn('VERCEL_ACCESS_TOKEN is not set');
37+
}
38+
39+
// Initialize axios instance for Vercel API
40+
this.vercelApi = axios.create({
41+
baseURL: 'https://api.vercel.com',
42+
headers: {
43+
Authorization: `Bearer ${bearerToken || ''}`,
44+
'Content-Type': 'application/json',
45+
},
46+
});
47+
}
48+
49+
async getDomainStatus(
50+
dto: GetDomainStatusDto,
51+
): Promise<DomainStatusResponseDto> {
52+
const { domain } = dto;
53+
54+
if (!process.env.TRUST_PORTAL_PROJECT_ID) {
55+
throw new InternalServerErrorException(
56+
'TRUST_PORTAL_PROJECT_ID is not configured',
57+
);
58+
}
59+
60+
if (!process.env.VERCEL_TEAM_ID) {
61+
throw new InternalServerErrorException(
62+
'VERCEL_TEAM_ID is not configured',
63+
);
64+
}
65+
66+
if (!domain) {
67+
throw new BadRequestException('Domain is required');
68+
}
69+
70+
try {
71+
this.logger.log(`Fetching domain status for: ${domain}`);
72+
73+
// Get domain information including verification status
74+
// Vercel API endpoint: GET /v9/projects/{projectId}/domains/{domain}
75+
const response = await this.vercelApi.get<VercelDomainResponse>(
76+
`/v9/projects/${process.env.TRUST_PORTAL_PROJECT_ID}/domains/${domain}`,
77+
{
78+
params: {
79+
teamId: process.env.VERCEL_TEAM_ID,
80+
},
81+
},
82+
);
83+
84+
const domainInfo = response.data;
85+
86+
const verification: DomainVerificationDto[] | undefined =
87+
domainInfo.verification?.map((v) => ({
88+
type: v.type,
89+
domain: v.domain,
90+
value: v.value,
91+
reason: v.reason,
92+
}));
93+
94+
return {
95+
domain: domainInfo.name,
96+
verified: domainInfo.verified ?? false,
97+
verification,
98+
};
99+
} catch (error) {
100+
this.logger.error(
101+
`Failed to get domain status for ${domain}:`,
102+
error instanceof Error ? error.stack : error,
103+
);
104+
105+
// Handle axios errors with more detail
106+
if (axios.isAxiosError(error)) {
107+
const statusCode = error.response?.status;
108+
const message = error.response?.data?.error?.message || error.message;
109+
this.logger.error(`Vercel API error (${statusCode}): ${message}`);
110+
}
111+
112+
throw new InternalServerErrorException(
113+
'Failed to get domain status from Vercel',
114+
);
115+
}
116+
}
117+
}

apps/app/src/actions/policies/accept-requested-policy-changes.ts

Lines changed: 27 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,8 @@
11
'use server';
22

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

95-
// Call /api/send-policy-email to send emails to employees
96-
9797
// Prepare the events array for the API
9898
const events = employeeMembers
9999
.filter((employee) => employee.user.email)
100-
.map((employee) => ({
101-
subscriberId: `${employee.user.id}-${session.activeOrganizationId}`,
102-
email: employee.user.email,
103-
userName: employee.user.name || employee.user.email || 'Employee',
104-
policyName: policy.name,
105-
organizationName: policy.organization.name,
106-
url: `${process.env.NEXT_PUBLIC_PORTAL_URL ?? 'https://portal.trycomp.ai'}/${session.activeOrganizationId}`,
107-
description: `The "${policy.name}" policy has been ${isNewPolicy ? 'created' : 'updated'}.`,
108-
}));
100+
.map((employee) => {
101+
let notificationType: 'new' | 're-acceptance' | 'updated';
102+
const wasAlreadySigned = policy.signedBy.includes(employee.id);
103+
if (isNewPolicy) {
104+
notificationType = 'new';
105+
} else if (wasAlreadySigned) {
106+
notificationType = 're-acceptance';
107+
} else {
108+
notificationType = 'updated';
109+
}
110+
111+
return {
112+
email: employee.user.email,
113+
userName: employee.user.name || employee.user.email || 'Employee',
114+
policyName: policy.name,
115+
organizationId: session.activeOrganizationId || '',
116+
organizationName: policy.organization.name,
117+
notificationType,
118+
};
119+
});
109120

110121
// Call the API route to send the emails
111-
try {
112-
await fetch(`${process.env.BETTER_AUTH_URL ?? ''}/api/send-policy-email`, {
113-
method: 'POST',
114-
headers: {
115-
'Content-Type': 'application/json',
116-
},
117-
body: JSON.stringify(events),
118-
});
119-
} catch (error) {
120-
console.error('Failed to call /api/send-policy-email:', error);
121-
// Don't throw, just log
122-
}
122+
await Promise.all(
123+
events.map((event) =>
124+
tasks.trigger<typeof sendNewPolicyEmail>('send-new-policy-email', event),
125+
),
126+
);
123127

124128
// If a comment was provided, create a comment
125129
if (comment && comment.trim() !== '') {

0 commit comments

Comments
 (0)