Skip to content

Commit 0958dbf

Browse files
authored
Merge pull request #2510 from trycompai/main
[comp] Production Deploy
2 parents d990d2d + 082501f commit 0958dbf

22 files changed

Lines changed: 577 additions & 27 deletions

File tree

‎apps/api/prisma/client.ts‎

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,12 @@ function createPrismaClient(): PrismaClient {
1919
// Strip sslmode from the connection string to avoid conflicts with the explicit ssl option
2020
const url = ssl !== undefined ? stripSslMode(rawUrl) : rawUrl;
2121
const adapter = new PrismaPg({ connectionString: url, ssl });
22-
return new PrismaClient({ adapter });
22+
return new PrismaClient({
23+
adapter,
24+
transactionOptions: {
25+
timeout: 30000,
26+
},
27+
});
2328
}
2429

2530
export const db = globalForPrisma.prisma || createPrismaClient();

‎apps/api/src/email/email.module.ts‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,10 @@
11
import { Module } from '@nestjs/common';
22
import { AuthModule } from '../auth/auth.module';
33
import { EmailController } from './email.controller';
4+
import { UnsubscribeController } from './unsubscribe.controller';
45

56
@Module({
67
imports: [AuthModule],
7-
controllers: [EmailController],
8+
controllers: [EmailController, UnsubscribeController],
89
})
910
export class EmailModule {}
Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,71 @@
1+
import { Controller, Post, Body, Query, HttpCode, BadRequestException } from '@nestjs/common';
2+
import { ApiOperation, ApiTags } from '@nestjs/swagger';
3+
import { db } from '@db';
4+
import { generateUnsubscribeToken } from '@trycompai/email';
5+
import { timingSafeEqual } from 'node:crypto';
6+
7+
@ApiTags('Email - Unsubscribe')
8+
@Controller({ path: 'email/unsubscribe', version: '1' })
9+
export class UnsubscribeController {
10+
/**
11+
* RFC 8058 one-click unsubscribe endpoint.
12+
* Gmail POSTs to this URL with List-Unsubscribe=One-Click in the body.
13+
* Email and token come via query params in the URL.
14+
*/
15+
@Post()
16+
@HttpCode(200)
17+
@ApiOperation({ summary: 'One-click unsubscribe (RFC 8058)' })
18+
async unsubscribe(
19+
@Query('email') queryEmail?: string,
20+
@Query('token') queryToken?: string,
21+
@Body() body?: { email?: string; token?: string },
22+
) {
23+
// Coerce to string - query params can be arrays if repeated
24+
const rawEmail = queryEmail || body?.email;
25+
const rawToken = queryToken || body?.token;
26+
const email = typeof rawEmail === 'string' ? rawEmail : undefined;
27+
const token = typeof rawToken === 'string' ? rawToken : undefined;
28+
29+
if (!email || !token) {
30+
throw new BadRequestException('Email and token are required');
31+
}
32+
33+
// Verify HMAC token (timing-safe comparison)
34+
const expectedToken = generateUnsubscribeToken(email);
35+
const tokensMatch =
36+
expectedToken.length === token.length &&
37+
timingSafeEqual(Buffer.from(expectedToken), Buffer.from(token));
38+
if (!tokensMatch) {
39+
throw new BadRequestException('Invalid token');
40+
}
41+
42+
// Unsubscribe the user from all email notifications
43+
const user = await db.user.findUnique({
44+
where: { email },
45+
select: { id: true },
46+
});
47+
48+
if (!user) {
49+
// Don't reveal user existence - just return success
50+
return { success: true };
51+
}
52+
53+
await db.user.update({
54+
where: { id: user.id },
55+
data: {
56+
emailNotificationsUnsubscribed: true,
57+
emailPreferences: {
58+
policyNotifications: false,
59+
taskReminders: false,
60+
weeklyTaskDigest: false,
61+
unassignedItemsNotifications: false,
62+
taskMentions: false,
63+
taskAssignments: false,
64+
findingNotifications: false,
65+
},
66+
},
67+
});
68+
69+
return { success: true };
70+
}
71+
}

‎apps/api/src/trigger/email/send-email.ts‎

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,11 @@
11
import { logger, queue, schemaTask } from '@trigger.dev/sdk';
22
import { z } from 'zod';
33
import { resend } from '../../email/resend';
4+
import { generateUnsubscribeToken } from '@trycompai/email';
45

56
const emailQueue = queue({
67
name: 'send-email',
7-
concurrencyLimit: 30,
8+
concurrencyLimit: 10,
89
});
910

1011
export const sendEmailTask = schemaTask({
@@ -51,12 +52,22 @@ export const sendEmailTask = schemaTask({
5152
}
5253

5354
try {
55+
// Build List-Unsubscribe headers for Gmail/RFC 8058 one-click compliance
56+
const apiBaseUrl = process.env.NEXT_PUBLIC_API_URL || 'https://api.trycomp.ai';
57+
const token = generateUnsubscribeToken(params.to);
58+
const oneClickUrl = `${apiBaseUrl}/v1/email/unsubscribe?email=${encodeURIComponent(params.to)}&token=${encodeURIComponent(token)}`;
59+
const headers: Record<string, string> = {
60+
'List-Unsubscribe': `<${oneClickUrl}>`,
61+
'List-Unsubscribe-Post': 'List-Unsubscribe=One-Click',
62+
};
63+
5464
const { data, error } = await resend.emails.send({
5565
from: fromAddress,
5666
to: toAddress,
5767
cc: params.cc,
5868
subject: params.subject,
5969
html: params.html,
70+
headers,
6071
scheduledAt: params.scheduledAt,
6172
attachments: params.attachments?.map((att) => ({
6273
filename: att.filename,
@@ -76,6 +87,9 @@ export const sendEmailTask = schemaTask({
7687

7788
logger.info('Email sent', { to: params.to, id: data?.id });
7889

90+
// Throttle: hold the concurrency slot for 1s to space out sends
91+
await new Promise((r) => setTimeout(r, 1000));
92+
7993
return { id: data?.id };
8094
} catch (error) {
8195
logger.error('Email sending failed', {

‎apps/app/prisma/client.ts‎

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,12 @@ function createPrismaClient(): PrismaClient {
1919
// Strip sslmode from the connection string to avoid conflicts with the explicit ssl option
2020
const url = ssl !== undefined ? stripSslMode(rawUrl) : rawUrl;
2121
const adapter = new PrismaPg({ connectionString: url, ssl });
22-
return new PrismaClient({ adapter });
22+
return new PrismaClient({
23+
adapter,
24+
transactionOptions: {
25+
timeout: 30000,
26+
},
27+
});
2328
}
2429

2530
export const db = globalForPrisma.prisma || createPrismaClient();
Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,52 @@
1+
import { db } from '@db/server';
2+
3+
/**
4+
* Resolves framework IDs for an organization by:
5+
* 1. Checking for a raw frameworkIds context entry (JSON array, saved by newer code)
6+
* 2. Falling back to reverse-looking framework names from the onboarding context
7+
*/
8+
export async function resolveFrameworkIds(organizationId: string): Promise<string[]> {
9+
// Try the raw IDs context entry first (saved by newer createOrganizationMinimal)
10+
const rawIdsContext = await db.context.findFirst({
11+
where: {
12+
organizationId,
13+
question: 'frameworkIds',
14+
tags: { has: 'onboarding' },
15+
},
16+
});
17+
18+
if (rawIdsContext?.answer) {
19+
try {
20+
const ids = JSON.parse(rawIdsContext.answer);
21+
if (Array.isArray(ids) && ids.length > 0) {
22+
return ids;
23+
}
24+
} catch {
25+
// Fall through to name-based lookup
26+
}
27+
}
28+
29+
// Fall back to reverse-looking from framework names
30+
const frameworkContext = await db.context.findFirst({
31+
where: {
32+
organizationId,
33+
question: 'Which compliance frameworks do you need?',
34+
tags: { has: 'onboarding' },
35+
},
36+
});
37+
38+
if (!frameworkContext?.answer) {
39+
return [];
40+
}
41+
42+
const frameworkNames = frameworkContext.answer.split(',').map((name) => name.trim());
43+
44+
const frameworks = await db.frameworkEditorFramework.findMany({
45+
where: {
46+
name: { in: frameworkNames, mode: 'insensitive' },
47+
},
48+
select: { id: true },
49+
});
50+
51+
return frameworks.map((f) => f.id);
52+
}

‎apps/app/src/app/(app)/onboarding/[orgId]/page.tsx‎

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -109,12 +109,23 @@ export default async function OnboardingPage({ params }: OnboardingPageProps) {
109109
});
110110
}
111111

112+
// Check if user has other completed orgs (for cancel button)
113+
const otherOrgCount = await db.member.count({
114+
where: {
115+
userId: session.user.id,
116+
organizationId: { not: orgId },
117+
deactivated: false,
118+
organization: { onboardingCompleted: true, hasAccess: true },
119+
},
120+
});
121+
112122
// We'll use a modified version that starts at step 3
113123
return (
114124
<PostPaymentOnboarding
115125
organization={organization}
116126
initialData={initialData}
117127
userEmail={session.user.email}
128+
hasOtherOrgs={otherOrgCount > 0}
118129
/>
119130
);
120131
}
Lines changed: 106 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,106 @@
1+
'use server';
2+
3+
import { authActionClientWithoutOrg } from '@/actions/safe-action';
4+
import { auth } from '@/utils/auth';
5+
import { db } from '@db/server';
6+
import { headers } from 'next/headers';
7+
import { z } from 'zod';
8+
9+
const cancelSchema = z.object({
10+
organizationId: z.string().min(1),
11+
});
12+
13+
export const cancelOnboarding = authActionClientWithoutOrg
14+
.inputSchema(cancelSchema)
15+
.metadata({
16+
name: 'cancel-onboarding',
17+
track: {
18+
event: 'cancel-onboarding',
19+
channel: 'server',
20+
},
21+
})
22+
.action(async ({ parsedInput, ctx }) => {
23+
const session = await auth.api.getSession({
24+
headers: await headers(),
25+
});
26+
27+
if (!session) {
28+
return { success: false, error: 'Not authorized.' };
29+
}
30+
31+
// Verify the user owns this org and it's still incomplete
32+
const member = await db.member.findFirst({
33+
where: {
34+
userId: session.user.id,
35+
organizationId: parsedInput.organizationId,
36+
role: { contains: 'owner' },
37+
},
38+
include: { organization: { select: { onboardingCompleted: true } } },
39+
});
40+
41+
if (!member) {
42+
return { success: false, error: 'Only the owner can cancel onboarding.' };
43+
}
44+
45+
if (member.organization.onboardingCompleted) {
46+
return { success: false, error: 'Cannot cancel a completed organization.' };
47+
}
48+
49+
// Find a fallback org to switch to BEFORE deleting
50+
const fallbackOrg = await db.member.findFirst({
51+
where: {
52+
userId: session.user.id,
53+
organizationId: { not: parsedInput.organizationId },
54+
deactivated: false,
55+
organization: {
56+
onboardingCompleted: true,
57+
hasAccess: true,
58+
},
59+
},
60+
select: { organizationId: true },
61+
orderBy: { createdAt: 'desc' },
62+
});
63+
64+
// Must have a fallback org — refuse to delete if there's nowhere to go.
65+
// The UI guards this too, but a race condition could remove fallback orgs
66+
// between page render and action execution.
67+
if (!fallbackOrg) {
68+
return { success: false, error: 'No other organization to switch to.' };
69+
}
70+
71+
// Switch active org BEFORE deletion so the session never
72+
// references a deleted org (even if the client redirect is slow).
73+
try {
74+
await auth.api.setActiveOrganization({
75+
headers: await headers(),
76+
body: { organizationId: fallbackOrg.organizationId },
77+
});
78+
} catch (error) {
79+
console.error('Failed to switch to fallback org:', error);
80+
return { success: false, error: 'Failed to switch organization.' };
81+
}
82+
83+
// Delete the incomplete org (cascade handles related records).
84+
// If this fails, roll back the active org switch to keep state consistent.
85+
try {
86+
await db.organization.delete({
87+
where: { id: parsedInput.organizationId },
88+
});
89+
} catch (error) {
90+
console.error('Failed to delete organization:', error);
91+
try {
92+
await auth.api.setActiveOrganization({
93+
headers: await headers(),
94+
body: { organizationId: parsedInput.organizationId },
95+
});
96+
} catch (rollbackError) {
97+
console.error('Failed to rollback active org switch:', rollbackError);
98+
}
99+
return { success: false, error: 'Failed to cancel onboarding.' };
100+
}
101+
102+
return {
103+
success: true,
104+
fallbackOrgId: fallbackOrg?.organizationId ?? null,
105+
};
106+
});

‎apps/app/src/app/(app)/onboarding/actions/complete-onboarding.ts‎

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,7 @@
11
'use server';
22

3+
import { initializeOrganization } from '@/actions/organization/lib/initialize-organization';
4+
import { resolveFrameworkIds } from '@/actions/organization/lib/resolve-framework-ids';
35
import { authActionClientWithoutOrg } from '@/actions/safe-action';
46
import { steps } from '@/app/(app)/setup/lib/constants';
57
import { createFleetLabelForOrg } from '@/trigger/tasks/device/create-fleet-label-for-org';
@@ -155,6 +157,43 @@ export const completeOnboarding = authActionClientWithoutOrg
155157
data: { onboardingCompleted: true },
156158
});
157159

160+
// Ensure framework structure exists before triggering the onboard job.
161+
// If createOrganizationMinimal partially failed (org created but
162+
// initializeOrganization didn't run), recover by initializing now.
163+
const existingFrameworks = await db.frameworkInstance.findFirst({
164+
where: { organizationId: parsedInput.organizationId },
165+
});
166+
167+
if (!existingFrameworks) {
168+
console.warn(
169+
`[complete-onboarding] No framework instances found for org ${parsedInput.organizationId}, running initializeOrganization as recovery`,
170+
);
171+
172+
const frameworkIds = await resolveFrameworkIds(parsedInput.organizationId);
173+
174+
if (frameworkIds.length > 0) {
175+
await initializeOrganization({
176+
frameworkIds,
177+
organizationId: parsedInput.organizationId,
178+
});
179+
} else {
180+
console.error(
181+
`[complete-onboarding] Could not resolve framework IDs for org ${parsedInput.organizationId}`,
182+
);
183+
}
184+
}
185+
186+
// Ensure onboarding record exists (may be missing if createOrganizationMinimal
187+
// failed before creating it).
188+
await db.onboarding.upsert({
189+
where: { organizationId: parsedInput.organizationId },
190+
create: {
191+
organizationId: parsedInput.organizationId,
192+
triggerJobCompleted: false,
193+
},
194+
update: {},
195+
});
196+
158197
// Now trigger the jobs that were skipped during minimal creation
159198
const handle = await tasks.trigger<typeof onboardOrganizationTask>('onboard-organization', {
160199
organizationId: parsedInput.organizationId,
@@ -208,3 +247,4 @@ export const completeOnboarding = authActionClientWithoutOrg
208247
};
209248
}
210249
});
250+

0 commit comments

Comments
 (0)