-
Notifications
You must be signed in to change notification settings - Fork 338
CS-229 [Improvement] - Send notification when DNS record needs to be fixed on the Trust Portal settings #3247
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Open
github-actions
wants to merge
11
commits into
main
Choose a base branch
from
chas/dns-record-notification
base: main
Could not load branches
Branch not found: {{ refName }}
Loading
Could not load tags
Nothing to show
Loading
Are you sure you want to change the base?
Some commits from the old base branch may be removed from the timeline,
and old review comments may become outdated.
Open
Changes from all commits
Commits
Show all changes
11 commits
Select commit
Hold shift + click to select a range
bcb642d
fix(api): add daily domain health check scheduled task
chasprowebdev da65859
fix(api): correct maxDuration and continue batch on per-domain vercel…
chasprowebdev 1c3dea6
Merge branch 'main' into chas/dns-record-notification
chasprowebdev 75ce6d8
fix(api): route domain misconfigured email through trust portal channel
chasprowebdev cbc40d7
Merge branch 'main' into chas/dns-record-notification
chasprowebdev 46dfc83
fix(api): remove unused TRUST_PORTAL_PROJECT_ID requirement from verc…
chasprowebdev 83a60fc
fix(api): parallelize domain health checks and email sends
chasprowebdev a3a6d2a
fix(api): isolate per-trust domain health check failures with Promise…
chasprowebdev 6f6f9fc
fix(api): use exact role matching for domain health notifications
chasprowebdev c229007
Merge branch 'main' into chas/dns-record-notification
tofikwest 40c559b
Merge branch 'main' into chas/dns-record-notification
Marfuen File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
109 changes: 109 additions & 0 deletions
109
apps/api/src/email/templates/trust-domain-misconfigured.tsx
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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
196
apps/api/src/trigger/trust-portal/check-domain-health-schedule.ts
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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( | ||
| 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( | ||
|
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 }; | ||
| }, | ||
| }); | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.