Skip to content
Open
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
109 changes: 109 additions & 0 deletions apps/api/src/email/templates/trust-domain-misconfigured.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,109 @@
import {
Body,
Button,
Container,
Font,
Heading,
Html,
Preview,
Section,
Tailwind,
Text,
} from '@react-email/components';
import { Footer } from '../components/footer';
import { Logo } from '../components/logo';

interface Props {
toName: string;
organizationName: string;
domain: string;
settingsUrl: string;
}

export const TrustDomainMisconfiguredEmail = ({
toName,
organizationName,
domain,
settingsUrl,
}: Props) => {
return (
<Html>
<Tailwind>
<head>
<Font
fontFamily="Geist"
fallbackFontFamily="Helvetica"
fontWeight={400}
fontStyle="normal"
/>
<Font
fontFamily="Geist"
fallbackFontFamily="Helvetica"
fontWeight={500}
fontStyle="normal"
/>
</head>
<Preview>
Action required: Trust Portal custom domain {domain} is misconfigured
</Preview>

<Body className="mx-auto my-auto bg-[#fff] font-sans">
<Container
className="mx-auto my-[40px] max-w-[600px] border-transparent p-[20px] md:border-[#E8E7E1]"
style={{ borderStyle: 'solid', borderWidth: 1 }}
>
<Logo />
<Heading className="mx-0 my-[30px] p-0 text-center text-[24px] font-normal text-[#121212]">
Trust Portal Domain Needs Attention
</Heading>

<Text className="text-[14px] leading-[24px] text-[#121212]">
Hello {toName},
</Text>

<Text className="text-[14px] leading-[24px] text-[#121212]">
We detected that the custom domain{' '}
<strong>{domain}</strong> configured for{' '}
<strong>{organizationName}</strong>'s Trust Portal is no longer
resolving correctly. Visitors using this domain may be unable to
access your Trust Portal until the DNS configuration is fixed.
</Text>

<Section
className="mt-[24px] mb-[24px] rounded-[3px] border-l-4 p-[15px]"
style={{ backgroundColor: '#fff8f0', borderColor: '#f97316' }}
>
<Text className="m-0 text-[14px] leading-[24px] text-[#121212]">
<strong>What to do:</strong>
<br />
Visit your Trust Portal settings to review the DNS records and
re-verify your domain. Ensure your CNAME record points to the
correct target and that all required verification records are in
place.
</Text>
</Section>

<Section className="mt-[32px] mb-[32px] text-center">
<Button
className="rounded-[3px] bg-[#121212] px-[20px] py-[12px] text-center text-[14px] font-semibold text-white no-underline"
href={settingsUrl}
>
Review Domain Settings
</Button>
</Section>

<Text className="text-[14px] leading-[24px] text-[#121212]">
If you need help, please contact our support team.
</Text>

<br />

<Footer />
</Container>
</Body>
</Tailwind>
</Html>
);
};

export default TrustDomainMisconfiguredEmail;
196 changes: 196 additions & 0 deletions apps/api/src/trigger/trust-portal/check-domain-health-schedule.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,196 @@
import { db } from '@db';
import { logger, schedules } from '@trigger.dev/sdk';
import { parseRoles } from '../../people/utils/role-authorization';
import { TrustEmailService } from '../../trust-portal/email.service';

const emailService = new TrustEmailService();

const NOTIFIABLE_ROLES = ['owner', 'admin'];

const APP_BASE_URL =
process.env.NEXT_PUBLIC_APP_URL ?? 'https://app.trycomp.ai';

/**
* Checks domain config via the Vercel API. Returns null when Vercel is not
* configured on this server (dev/self-host) — callers should skip the check.
*/
async function isDomainMisconfigured(
domain: string,
): Promise<boolean | null> {
const teamId = process.env.VERCEL_TEAM_ID;
const vercelToken = process.env.VERCEL_AUTH_TOKEN;

if (!teamId || !vercelToken) {
return null;
}

const url = new URL(
`https://api.vercel.com/v6/domains/${encodeURIComponent(domain)}/config`,
);
url.searchParams.set('teamId', teamId);

const res = await fetch(url.toString(), {
headers: { Authorization: `Bearer ${vercelToken}` },
});

if (!res.ok) {
logger.warn(`Vercel config check failed for ${domain}`, {
status: res.status,
});
return null;
}

const data = (await res.json()) as { misconfigured?: boolean };
return data.misconfigured === true;
}

/**
* Daily health check for Trust Portal custom domains.
*
* Iterates all orgs with a verified custom domain, re-checks Vercel's
* `misconfigured` flag, and — when a domain is broken — marks it unverified
* in the DB and emails the org's admin/owner members so they can act.
*
* Runs at 6:00 AM UTC daily.
*/
export const checkDomainHealthSchedule = schedules.task({
id: 'trust-portal-check-domain-health',
cron: '0 6 * * *',
maxDuration: 60 * 15, // 15 minutes
run: async (payload) => {
logger.info('Starting Trust Portal domain health check', {
scheduledAt: payload.timestamp,
});

const trusts = await db.trust.findMany({
where: {
domain: { not: null },
domainVerified: true,
},
select: {
organizationId: true,
domain: true,
organization: {
select: {
name: true,
members: {
where: { isActive: true },
select: {
role: true,
user: {
select: { id: true, name: true, email: true },
},
},
},
},
},
},
});

logger.info(`Found ${trusts.length} trusts with verified custom domains`);

const vercelConfigured =
!!process.env.VERCEL_TEAM_ID &&
!!process.env.VERCEL_AUTH_TOKEN;

if (!vercelConfigured) {
logger.info(
'Skipping domain health check — Vercel not configured on this server',
);
return { checked: 0, misconfigured: 0, notified: 0 };
}

const settled = await Promise.allSettled(
Comment thread
Marfuen marked this conversation as resolved.
trusts.map(async (trust) => {
const domain = trust.domain!;

const broken = await isDomainMisconfigured(domain);

if (broken === null) {
logger.warn(`Skipping domain ${domain} — Vercel API request failed`);
return { misconfigured: 0, notified: 0 };
}

if (!broken) {
return { misconfigured: 0, notified: 0 };
}

logger.warn(`Domain misconfigured: ${domain}`, {
organizationId: trust.organizationId,
});

await db.trust.update({
where: { organizationId: trust.organizationId },
data: { domainVerified: false },
});

const adminOrOwnerMembers = trust.organization.members.filter(
Comment thread
cubic-dev-ai[bot] marked this conversation as resolved.
(m) =>
parseRoles(m.role).some((role) => NOTIFIABLE_ROLES.includes(role)) &&
m.user?.email,
);

const settingsUrl = `${APP_BASE_URL}/${trust.organizationId}/trust/portal-settings`;

const emailResults = await Promise.allSettled(
adminOrOwnerMembers
.filter((m) => m.user?.email)
.map((member) =>
emailService.sendDomainMisconfiguredEmail({
toEmail: member.user!.email!,
toName: member.user!.name?.trim() || member.user!.email!,
organizationName: trust.organization.name,
domain,
settingsUrl,
}),
),
);

emailResults.forEach((result, i) => {
if (result.status === 'rejected') {
logger.error(
`Failed to send domain misconfigured email to ${adminOrOwnerMembers[i].user?.email}`,
{
error:
result.reason instanceof Error
? result.reason.message
: String(result.reason),
},
);
}
});

return {
misconfigured: 1,
notified: emailResults.filter((r) => r.status === 'fulfilled').length,
};
}),
);

const results = settled.map((s, i) => {
if (s.status === 'rejected') {
logger.error(
`Domain health check failed for trust ${trusts[i].organizationId}`,
{
error:
s.reason instanceof Error ? s.reason.message : String(s.reason),
},
);
return { misconfigured: 0, notified: 0 };
}
return s.value;
});

const checked = trusts.length;
const misconfigured = results.reduce((sum, r) => sum + r.misconfigured, 0);
const notified = results.reduce((sum, r) => sum + r.notified, 0);

logger.info('Trust Portal domain health check complete', {
checked,
misconfigured,
notified,
});

return { checked, misconfigured, notified };
},
});
27 changes: 27 additions & 0 deletions apps/api/src/trust-portal/email.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { AccessGrantedEmail } from '../email/templates/access-granted';
import { AccessReclaimEmail } from '../email/templates/access-reclaim';
import { NdaSigningEmail } from '../email/templates/nda-signing';
import { AccessRequestNotificationEmail } from '../email/templates/access-request-notification';
import { TrustDomainMisconfiguredEmail } from '../email/templates/trust-domain-misconfigured';

@Injectable()
export class TrustEmailService {
Expand Down Expand Up @@ -131,4 +132,30 @@ export class TrustEmailService {
`Access request notification sent to ${toEmail} for requester ${requesterEmail} (ID: ${id})`,
);
}

async sendDomainMisconfiguredEmail(params: {
toEmail: string;
toName: string;
organizationName: string;
domain: string;
settingsUrl: string;
}): Promise<void> {
const { toEmail, toName, organizationName, domain, settingsUrl } = params;

const { id } = await triggerEmail({
to: toEmail,
subject: `Action required: Trust Portal domain ${domain} is misconfigured`,
react: TrustDomainMisconfiguredEmail({
Comment thread
cubic-dev-ai[bot] marked this conversation as resolved.
toName,
organizationName,
domain,
settingsUrl,
}),
trustPortal: true,
});

this.logger.log(
`Domain misconfigured email sent to ${toEmail} for domain ${domain} (ID: ${id})`,
);
}
}
Loading