feat: custom org domains - UI, schema, and CRUD endpoints#27797
feat: custom org domains - UI, schema, and CRUD endpoints#27797
Conversation
Add custom domain management for organizations: - CustomDomain Prisma model (1:1 with Team) - Vercel API integration for domain registration and verification - tRPC CRUD endpoints (add, get, verify, remove) - Settings page UI for managing custom domains - Migration for custom_domains table Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This stack of pull requests is managed by Graphite. Learn more about stacking. |
E2E results are ready! |
…stom-org-domains-ui
Add custom domain management for organizations: - CustomDomain Prisma model (1:1 with Team) - Vercel API integration for domain registration and verification - tRPC CRUD endpoints (add, get, verify, remove) - Settings page UI for managing custom domains - Migration for custom_domains table Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
…m/cal.com into feat/custom-org-domains-ui
|
@Amit91848 I have started the AI code review. It will take a few minutes to complete. |
There was a problem hiding this comment.
6 issues found across 23 files
Prompt for AI agents (all issues)
Check if these issues are valid — if so, understand the root cause of each and fix them.
<file name="packages/prisma/schema.prisma">
<violation number="1" location="packages/prisma/schema.prisma:3316">
P1: Rule violated: **Prevent Direct NOW() Usage in Database Queries**
Use a timezone-specified default instead of now() for new timestamp columns to avoid non-UTC inserts, per “Prevent Direct NOW() Usage in Database Queries.”</violation>
</file>
<file name="apps/web/modules/ee/organizations/custom-domain.tsx">
<violation number="1" location="apps/web/modules/ee/organizations/custom-domain.tsx:582">
P3: Localize this placeholder string with t() and add the corresponding translation key to the locale source so it can be managed by lingo.dev.</violation>
<violation number="2" location="apps/web/modules/ee/organizations/custom-domain.tsx:659">
P2: Removing the current domain before confirming the new domain is accepted can leave the org without a domain if the add mutation fails. Consider an atomic update (server-side replace) or ensure the new domain is reserved/added before removing the old one.</violation>
</file>
<file name="packages/lib/domainManager/deploymentServices/vercel.ts">
<violation number="1" location="packages/lib/domainManager/deploymentServices/vercel.ts:141">
P2: This module now validates/reads env vars directly. Prefer fetching Vercel config from the centralized config service (e.g., getEnv/ConfigService) so env parsing/validation happens once and callers receive normalized values.
(Based on your team's feedback about centralizing env-derived configuration.) [FEEDBACK_USED]</violation>
</file>
<file name="packages/trpc/server/routers/viewer/organizations/_router.tsx">
<violation number="1" location="packages/trpc/server/routers/viewer/organizations/_router.tsx:243">
P2: verifyCustomDomain is defined as a query but it updates verification state in the database (and triggers external verification). This should be a mutation to avoid side effects in query handlers and prevent incorrect caching/GET semantics.</violation>
</file>
<file name="apps/web/modules/ee/organizations/appearance.tsx">
<violation number="1" location="apps/web/modules/ee/organizations/appearance.tsx:67">
P2: Keep the toggle state in sync with the query by resetting it to false when customDomain is cleared; otherwise the UI can stay enabled after a refetch that returns no domain.</violation>
</file>
Reply with feedback, questions, or to request a fix. Tag @cubic-dev-ai to re-run a review.
| lastCheckedAt DateTime @default(now()) | ||
| createdAt DateTime @default(now()) |
There was a problem hiding this comment.
P1: Rule violated: Prevent Direct NOW() Usage in Database Queries
Use a timezone-specified default instead of now() for new timestamp columns to avoid non-UTC inserts, per “Prevent Direct NOW() Usage in Database Queries.”
Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At packages/prisma/schema.prisma, line 3316:
<comment>Use a timezone-specified default instead of now() for new timestamp columns to avoid non-UTC inserts, per “Prevent Direct NOW() Usage in Database Queries.”</comment>
<file context>
@@ -3305,3 +3307,17 @@ model RoutingTrace {
+ teamId Int @unique
+ slug String @unique
+ verified Boolean @default(false)
+ lastCheckedAt DateTime @default(now())
+ createdAt DateTime @default(now())
+ updatedAt DateTime @updatedAt
</file context>
| lastCheckedAt DateTime @default(now()) | |
| createdAt DateTime @default(now()) | |
| lastCheckedAt DateTime @default(dbgenerated("timezone('utc', now())")) | |
| createdAt DateTime @default(dbgenerated("timezone('utc', now())")) |
|
|
||
| setIsUpdating(true); | ||
| try { | ||
| await removeMutation.mutateAsync({ teamId: orgId }); |
There was a problem hiding this comment.
P2: Removing the current domain before confirming the new domain is accepted can leave the org without a domain if the add mutation fails. Consider an atomic update (server-side replace) or ensure the new domain is reserved/added before removing the old one.
Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At apps/web/modules/ee/organizations/custom-domain.tsx, line 659:
<comment>Removing the current domain before confirming the new domain is accepted can leave the org without a domain if the add mutation fails. Consider an atomic update (server-side replace) or ensure the new domain is reserved/added before removing the old one.</comment>
<file context>
@@ -0,0 +1,777 @@
+
+ setIsUpdating(true);
+ try {
+ await removeMutation.mutateAsync({ teamId: orgId });
+ await addMutation.mutateAsync({ teamId: orgId, slug: domain });
+ showToast(t("custom_domain_updated"), "success");
</file context>
| }; | ||
|
|
||
| export async function getDomain(domain: string): Promise<VercelDomainResponse> { | ||
| assertVercelEnvVars(process.env); |
There was a problem hiding this comment.
P2: This module now validates/reads env vars directly. Prefer fetching Vercel config from the centralized config service (e.g., getEnv/ConfigService) so env parsing/validation happens once and callers receive normalized values.
(Based on your team's feedback about centralizing env-derived configuration.)
Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At packages/lib/domainManager/deploymentServices/vercel.ts, line 141:
<comment>This module now validates/reads env vars directly. Prefer fetching Vercel config from the centralized config service (e.g., getEnv/ConfigService) so env parsing/validation happens once and callers receive normalized values.
(Based on your team's feedback about centralizing env-derived configuration.) </comment>
<file context>
@@ -76,6 +137,67 @@ export const deleteDomain = async (domain: string) => {
};
+export async function getDomain(domain: string): Promise<VercelDomainResponse> {
+ assertVercelEnvVars(process.env);
+ const normalizedDomain = domain.toLowerCase();
+
</file context>
| return handler(opts); | ||
| }), | ||
|
|
||
| verifyCustomDomain: authedProcedure.input(ZCustomDomainVerifyInputSchema).query(async (opts) => { |
There was a problem hiding this comment.
P2: verifyCustomDomain is defined as a query but it updates verification state in the database (and triggers external verification). This should be a mutation to avoid side effects in query handlers and prevent incorrect caching/GET semantics.
Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At packages/trpc/server/routers/viewer/organizations/_router.tsx, line 243:
<comment>verifyCustomDomain is defined as a query but it updates verification state in the database (and triggers external verification). This should be a mutation to avoid side effects in query handlers and prevent incorrect caching/GET semantics.</comment>
<file context>
@@ -220,4 +224,24 @@ export const viewerOrganizationsRouter = router({
+ return handler(opts);
+ }),
+
+ verifyCustomDomain: authedProcedure.input(ZCustomDomainVerifyInputSchema).query(async (opts) => {
+ const { default: handler } = await import("./customDomain.verify.handler");
+ return handler(opts);
</file context>
| verifyCustomDomain: authedProcedure.input(ZCustomDomainVerifyInputSchema).query(async (opts) => { | |
| verifyCustomDomain: authedProcedure.input(ZCustomDomainVerifyInputSchema).mutation(async (opts) => { |
| useEffect(() => { | ||
| if (customDomain) { | ||
| setCustomDomainEnabled(true); | ||
| } | ||
| }, [customDomain]); |
There was a problem hiding this comment.
P2: Keep the toggle state in sync with the query by resetting it to false when customDomain is cleared; otherwise the UI can stay enabled after a refetch that returns no domain.
Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At apps/web/modules/ee/organizations/appearance.tsx, line 67:
<comment>Keep the toggle state in sync with the query by resetting it to false when customDomain is cleared; otherwise the UI can stay enabled after a refetch that returns no domain.</comment>
<file context>
@@ -53,6 +52,52 @@ const OrgAppearanceView = ({
+ const [customDomainEnabled, setCustomDomainEnabled] = useState(false);
+ const [showRemoveConfirmation, setShowRemoveConfirmation] = useState(false);
+
+ useEffect(() => {
+ if (customDomain) {
+ setCustomDomainEnabled(true);
</file context>
| useEffect(() => { | |
| if (customDomain) { | |
| setCustomDomainEnabled(true); | |
| } | |
| }, [customDomain]); | |
| useEffect(() => { | |
| setCustomDomainEnabled(!!customDomain); | |
| }, [customDomain]); |
| type="text" | ||
| value={domain} | ||
| onChange={(e) => setDomain(e.target.value.toLowerCase().trim())} | ||
| placeholder="booking.yourdomain.com" |
There was a problem hiding this comment.
P3: Localize this placeholder string with t() and add the corresponding translation key to the locale source so it can be managed by lingo.dev.
Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At apps/web/modules/ee/organizations/custom-domain.tsx, line 582:
<comment>Localize this placeholder string with t() and add the corresponding translation key to the locale source so it can be managed by lingo.dev.</comment>
<file context>
@@ -0,0 +1,777 @@
+ type="text"
+ value={domain}
+ onChange={(e) => setDomain(e.target.value.toLowerCase().trim())}
+ placeholder="booking.yourdomain.com"
+ className="border-default bg-default text-default placeholder:text-muted w-full rounded-md border px-3 py-2 text-sm focus:border-neutral-300 focus:outline-none focus:ring-0"
+ autoFocus
</file context>
Devin AI is addressing Cubic AI's review feedbackA Devin session has been created to address the issues identified by Cubic AI. |
|
I reviewed all 6 Cubic AI comments and checked their confidence scores. Here are the results:
No issues met the 9/10 confidence threshold for automated fixing. All 6 issues scored between 6/10 and 8/10. These should be reviewed manually by the team to determine which are worth addressing. |

Add custom domain management for organizations:
What does this PR do?
Visual Demo (For contributors especially)
A visual demonstration is strongly recommended, for both the original and new change (video / image - any one).
Video Demo (if applicable):
Image Demo (if applicable):
Mandatory Tasks (DO NOT REMOVE)
How should this be tested?
Checklist