diff --git a/app/(auth)/sign-in/[[...sign-in]]/page.tsx b/app/(auth)/sign-in/[[...sign-in]]/page.tsx index 557e85a..fb358ea 100644 --- a/app/(auth)/sign-in/[[...sign-in]]/page.tsx +++ b/app/(auth)/sign-in/[[...sign-in]]/page.tsx @@ -1,15 +1,15 @@ -import { auth } from '@clerk/nextjs/server'; +import { auth, currentUser } from '@clerk/nextjs/server'; import { SignIn } from '@clerk/nextjs'; import { redirect } from 'next/navigation'; import { getAdminSession } from '@/lib/admin'; -import { getBusinessForOwnerClerkId } from '@/lib/business-access'; import { DEFAULT_CLERK_AFTER_AUTH_URL, DEFAULT_CLERK_SIGN_IN_URL, DEFAULT_CLERK_SIGN_UP_URL, hasRequiredValidClerkEnv, } from '@/lib/clerk-config'; +import { getOwnedBusinessForClerkUser } from '@/lib/owner-linking'; import { resolveSignedInAppDestination } from '@/lib/public-auth-routing'; export default async function SignInPage() { @@ -39,7 +39,7 @@ export default async function SignInPage() { if (userId) { const [adminSession, business] = await Promise.all([ getAdminSession(), - getBusinessForOwnerClerkId(userId), + getOwnedBusinessForClerkUser(await currentUser()), ]); redirect( diff --git a/app/(auth)/sign-up/[[...sign-up]]/page.tsx b/app/(auth)/sign-up/[[...sign-up]]/page.tsx index e809dfd..d3566c7 100644 --- a/app/(auth)/sign-up/[[...sign-up]]/page.tsx +++ b/app/(auth)/sign-up/[[...sign-up]]/page.tsx @@ -1,15 +1,15 @@ -import { auth } from '@clerk/nextjs/server'; +import { auth, currentUser } from '@clerk/nextjs/server'; import { SignUp } from '@clerk/nextjs'; import { redirect } from 'next/navigation'; import { getAdminSession } from '@/lib/admin'; -import { getBusinessForOwnerClerkId } from '@/lib/business-access'; import { DEFAULT_CLERK_AFTER_AUTH_URL, DEFAULT_CLERK_SIGN_IN_URL, DEFAULT_CLERK_SIGN_UP_URL, hasRequiredValidClerkEnv, } from '@/lib/clerk-config'; +import { getOwnedBusinessForClerkUser } from '@/lib/owner-linking'; import { resolveSignedInAppDestination } from '@/lib/public-auth-routing'; function getIntentCopy(intent: string | undefined) { @@ -65,7 +65,7 @@ export default async function SignUpPage({ if (userId) { const [adminSession, business] = await Promise.all([ getAdminSession(), - getBusinessForOwnerClerkId(userId), + getOwnedBusinessForClerkUser(await currentUser()), ]); redirect( diff --git a/app/admin/[businessId]/page.tsx b/app/admin/[businessId]/page.tsx index 367c880..14ef98b 100644 --- a/app/admin/[businessId]/page.tsx +++ b/app/admin/[businessId]/page.tsx @@ -393,12 +393,12 @@ export default async function AdminBusinessDetailPage({ : ownerAction === 'invited' ? { variant: 'success' as const, - message: `Owner invitation sent to ${ownerEmail}. The owner step stays pending until they accept, then use Connect existing owner to finish linking.`, + message: `Owner invitation sent to ${ownerEmail}. CallbackCloser will auto-connect the owner after they accept and sign in, or show a one-click connect action here if review is still needed.`, } : ownerAction === 'resent' ? { variant: 'success' as const, - message: `Owner invitation resent to ${ownerEmail}. The owner step stays pending until they accept, then use Connect existing owner to finish linking.`, + message: `Owner invitation resent to ${ownerEmail}. CallbackCloser will auto-connect the owner after they accept and sign in, or show a one-click connect action here if review is still needed.`, } : provisioned ? { @@ -1122,9 +1122,9 @@ export default async function AdminBusinessDetailPage({ {ownerAction === 'connected' ? `Existing owner connected for ${ownerEmail}.` : ownerAction === 'invited' - ? `Owner invitation sent to ${ownerEmail}.` + ? `Owner invitation sent to ${ownerEmail}. CallbackCloser will auto-connect after acceptance when it is safe to do so.` : ownerAction === 'resent' - ? `Owner invitation resent to ${ownerEmail}.` + ? `Owner invitation resent to ${ownerEmail}. CallbackCloser will auto-connect after acceptance when it is safe to do so.` : 'Owner state updated.'} ) : null} diff --git a/app/api/stripe/checkout/route.ts b/app/api/stripe/checkout/route.ts index 457a09a..e3edd1c 100644 --- a/app/api/stripe/checkout/route.ts +++ b/app/api/stripe/checkout/route.ts @@ -1,10 +1,11 @@ -import { auth } from '@clerk/nextjs/server'; +import { auth, currentUser } from '@clerk/nextjs/server'; import { NextResponse } from 'next/server'; import { logAuditEvent } from '@/lib/audit-log'; import { db } from '@/lib/db'; import { getConfiguredAppBaseUrl } from '@/lib/env.server'; import { getCorrelationIdFromRequest, reportApplicationError, withCorrelationIdHeader } from '@/lib/observability'; +import { getOwnedBusinessForClerkUser } from '@/lib/owner-linking'; import { isAllowedRequestOrigin } from '@/lib/request-origin'; import { getStripe } from '@/lib/stripe'; import { absoluteUrl } from '@/lib/url'; @@ -29,7 +30,7 @@ export async function POST(request: Request) { return withCorrelation(NextResponse.redirect(absoluteUrl('/sign-in'), { status: 303 })); } - const business = await db.business.findUnique({ where: { ownerClerkId: userId } }); + const business = await getOwnedBusinessForClerkUser(await currentUser()); if (!business) { return withCorrelation(NextResponse.redirect(absoluteUrl('/app'), { status: 303 })); } diff --git a/app/api/stripe/portal/route.ts b/app/api/stripe/portal/route.ts index 044f14f..b59aee6 100644 --- a/app/api/stripe/portal/route.ts +++ b/app/api/stripe/portal/route.ts @@ -1,10 +1,10 @@ -import { auth } from '@clerk/nextjs/server'; +import { auth, currentUser } from '@clerk/nextjs/server'; import { NextResponse } from 'next/server'; import { logAuditEvent } from '@/lib/audit-log'; -import { db } from '@/lib/db'; import { getConfiguredAppBaseUrl } from '@/lib/env.server'; import { getCorrelationIdFromRequest, reportApplicationError, withCorrelationIdHeader } from '@/lib/observability'; +import { getOwnedBusinessForClerkUser } from '@/lib/owner-linking'; import { isAllowedRequestOrigin } from '@/lib/request-origin'; import { getStripe } from '@/lib/stripe'; import { absoluteUrl } from '@/lib/url'; @@ -24,7 +24,7 @@ export async function POST(request: Request) { return withCorrelation(NextResponse.redirect(absoluteUrl('/sign-in'), { status: 303 })); } - const business = await db.business.findUnique({ where: { ownerClerkId: userId } }); + const business = await getOwnedBusinessForClerkUser(await currentUser()); if (!business?.stripeCustomerId) { return withCorrelation( NextResponse.redirect(absoluteUrl('/app/billing?error=No%20Stripe%20customer%20for%20this%20business'), { status: 303 }) diff --git a/app/api/twilio/provision-number/route.ts b/app/api/twilio/provision-number/route.ts index 7220fa1..7a75cc2 100644 --- a/app/api/twilio/provision-number/route.ts +++ b/app/api/twilio/provision-number/route.ts @@ -1,9 +1,9 @@ -import { auth } from '@clerk/nextjs/server'; +import { auth, currentUser } from '@clerk/nextjs/server'; import { NextResponse } from 'next/server'; -import { db } from '@/lib/db'; import { getConfiguredAppBaseUrl } from '@/lib/env.server'; import { getCorrelationIdFromRequest, withCorrelationIdHeader } from '@/lib/observability'; +import { getOwnedBusinessForClerkUser } from '@/lib/owner-linking'; import { isAllowedRequestOrigin } from '@/lib/request-origin'; import { logTwilioError, logTwilioWarn } from '@/lib/twilio-logging'; import { getTwilioProvisioningBlockReason, provisionPhoneNumber } from '@/lib/twilio-provision'; @@ -46,16 +46,7 @@ export async function POST(request: Request) { return withCorrelation(NextResponse.json({ error: 'Unauthorized' }, { status: 401 })); } - const business = await db.business.findUnique({ - where: { ownerClerkId: userId }, - select: { - id: true, - name: true, - twilioSubaccountSid: true, - twilioPhoneNumber: true, - twilioPhoneNumberSid: true, - }, - }); + const business = await getOwnedBusinessForClerkUser(await currentUser()); if (!business) { return withCorrelation(NextResponse.json({ error: 'Business not found' }, { status: 404 })); diff --git a/app/app/layout.tsx b/app/app/layout.tsx index 141e9ed..5784ac9 100644 --- a/app/app/layout.tsx +++ b/app/app/layout.tsx @@ -8,9 +8,9 @@ import { Badge } from '@/components/ui/badge'; import { getAdminSession } from '@/lib/admin'; import { getAdminCustomerActingContext } from '@/lib/admin-customer-context'; import { buildAdminCustomerExitHref } from '@/lib/admin-customer-paths'; -import { ensurePendingBusinessForOwner } from '@/lib/customer-setup-handoff'; import { getCustomerWorkspaceNotice, shouldShowCustomerSetupWaitingPage } from '@/lib/customer-setup'; import { db } from '@/lib/db'; +import { getOwnedBusinessForClerkUser, getOrCreateOwnedBusinessForClerkUser } from '@/lib/owner-linking'; import { getPortfolioDemoBusiness, isPortfolioDemoMode } from '@/lib/portfolio-demo'; import { getCustomerSystemStatus } from '@/lib/system-status'; @@ -55,10 +55,14 @@ export default async function AppLayout({ children }: { children: React.ReactNod } const adminSession = await getAdminSession(); + const signedInUser = adminCustomerContext ? null : await currentUser(); + if (!adminCustomerContext && !signedInUser) { + redirect('/sign-in'); + } const existingBusiness = adminCustomerContext ? adminCustomerContext.business - : await db.business.findUnique({ where: { ownerClerkId: userId } }); + : await getOwnedBusinessForClerkUser(signedInUser); if (!adminCustomerContext && adminSession?.isAdmin && !existingBusiness) { redirect('/admin?intent=new-business-pilot'); } @@ -66,19 +70,7 @@ export default async function AppLayout({ children }: { children: React.ReactNod existingBusiness || (adminCustomerContext ? null - : await (async () => { - const user = await currentUser(); - const ownerEmail = - (user?.primaryEmailAddressId - ? user.emailAddresses.find((email) => email.id === user.primaryEmailAddressId)?.emailAddress - : user?.emailAddresses[0]?.emailAddress) || null; - - return ensurePendingBusinessForOwner(userId, { - businessName: typeof user?.publicMetadata?.businessName === 'string' ? user.publicMetadata.businessName : null, - ownerEmail, - ownerName: user?.fullName || [user?.firstName, user?.lastName].filter(Boolean).join(' ') || null, - }); - })()); + : await getOrCreateOwnedBusinessForClerkUser(signedInUser)); const successfulLeadCount = business ? await db.lead.count({ where: { businessId: business.id, OR: [{ ownerNotifiedAt: { not: null } }, { notifiedAt: { not: null } }] } }) : 0; diff --git a/app/app/onboarding/actions.ts b/app/app/onboarding/actions.ts index 5265de5..170cba4 100644 --- a/app/app/onboarding/actions.ts +++ b/app/app/onboarding/actions.ts @@ -6,6 +6,7 @@ import { revalidatePath } from 'next/cache'; import { deriveTwilioNumberSetupModeFromPhoneSetupPath } from '@/lib/business-phone-setup'; import { upsertBusinessForOwner } from '@/lib/business'; +import { getOwnedBusinessForClerkUser } from '@/lib/owner-linking'; import { onboardingSchema } from '@/lib/validators'; const DEFAULT_POST_ONBOARDING_REDIRECT = '/app/settings'; @@ -38,6 +39,7 @@ export async function saveOnboardingAction(formData: FormData) { } const user = await currentUser(); + await getOwnedBusinessForClerkUser(user); const ownerEmail = (user?.primaryEmailAddressId ? user.emailAddresses.find((email) => email.id === user.primaryEmailAddressId)?.emailAddress diff --git a/app/buy/page.tsx b/app/buy/page.tsx index df36848..2ada6b8 100644 --- a/app/buy/page.tsx +++ b/app/buy/page.tsx @@ -1,7 +1,7 @@ -import { auth } from '@clerk/nextjs/server'; +import { auth, currentUser } from '@clerk/nextjs/server'; import { redirect } from 'next/navigation'; -import { db } from '@/lib/db'; +import { getOwnedBusinessForClerkUser } from '@/lib/owner-linking'; type PlanParam = 'starter' | 'pro'; @@ -32,7 +32,7 @@ export default async function BuyPage({ searchParams }: { searchParams?: Record< redirect(`/sign-up?redirect_url=${encodeURIComponent(buyPath)}`); } - const business = await db.business.findUnique({ where: { ownerClerkId: userId } }); + const business = await getOwnedBusinessForClerkUser(await currentUser()); if (!business) { redirect('/app'); } diff --git a/app/start-free-pilot/page.tsx b/app/start-free-pilot/page.tsx index e8a8f82..6544209 100644 --- a/app/start-free-pilot/page.tsx +++ b/app/start-free-pilot/page.tsx @@ -1,8 +1,8 @@ -import { auth } from '@clerk/nextjs/server'; +import { auth, currentUser } from '@clerk/nextjs/server'; import { redirect } from 'next/navigation'; import { getAdminSession } from '@/lib/admin'; -import { getBusinessForOwnerClerkId } from '@/lib/business-access'; +import { getOwnedBusinessForClerkUser } from '@/lib/owner-linking'; import { resolvePublicPilotDestination } from '@/lib/public-auth-routing'; export default async function StartFreePilotPage() { @@ -14,7 +14,7 @@ export default async function StartFreePilotPage() { const [adminSession, business] = await Promise.all([ getAdminSession(), - getBusinessForOwnerClerkId(userId), + getOwnedBusinessForClerkUser(await currentUser()), ]); redirect( diff --git a/lib/admin-dashboard.ts b/lib/admin-dashboard.ts index c3c9723..21bf910 100644 --- a/lib/admin-dashboard.ts +++ b/lib/admin-dashboard.ts @@ -290,7 +290,7 @@ export function buildAdminNextStep(params: { title: business.ownerInviteSentAt ? 'Owner invitation is still pending' : 'Owner account still needs setup', detail: ownerEmail ? business.ownerInviteSentAt - ? 'The invite has been sent, but the owner account is not attached yet. Wait for acceptance or use Connect existing owner after they create the account.' + ? 'The invite has been sent, but the owner account is not attached yet. CallbackCloser should auto-connect the owner after acceptance, or this panel will expose Connect existing owner as the fallback.' : 'The business is saved, but the owner account still needs a deliberate admin action. Use Invite owner by email or Connect existing owner.' : 'Add the owner email first, then choose Invite owner by email or Connect existing owner.', tone: 'attention', @@ -590,7 +590,7 @@ export function buildAdminOnboardingConfidence(params: { ? 'A CallbackCloser owner account is linked to this business.' : ownerEmail ? business.ownerInviteSentAt - ? 'The owner invite is still pending or the accepted account still needs linking.' + ? 'The owner invite is still pending, or CallbackCloser still needs a safe owner-link confirmation.' : 'Choose Invite owner by email or Connect existing owner.' : 'Add the owner email and then choose the correct owner setup action.', }, diff --git a/lib/admin-provisioning-presenters.ts b/lib/admin-provisioning-presenters.ts index c687965..e53f382 100644 --- a/lib/admin-provisioning-presenters.ts +++ b/lib/admin-provisioning-presenters.ts @@ -177,7 +177,7 @@ export function deriveAdminOwnerState(input: { return { status: 'accepted_needs_connection', statusLabel: 'Owner account ready to connect', - detail: 'The owner now appears to have a CallbackCloser account. Finish by using Connect existing owner.', + detail: 'The owner now appears to have a verified CallbackCloser account. Use Connect existing owner only if CallbackCloser could not safely auto-link it.', badgeVariant: 'secondary', connected: false, pending: true, @@ -270,7 +270,7 @@ export function deriveAdminOwnerState(input: { return { status: 'missing', - statusLabel: 'No owner connected', + statusLabel: 'No owner invited', detail: 'Add the owner email, then either invite them or connect their existing CallbackCloser account.', badgeVariant: 'destructive', connected: false, @@ -332,7 +332,7 @@ export function buildAdminProvisioningChecklist({ detail: ownerConnected ? 'A Clerk user is linked to this business.' : ownerEmail - ? 'Owner setup is still incomplete. Send the invite or connect the existing CallbackCloser account.' + ? 'Owner setup is still incomplete. Send the invite or connect the existing CallbackCloser account, and CallbackCloser will auto-link verified invite acceptances when it is safe.' : 'Add an owner email, then choose Invite owner by email or Connect existing owner.', }, { diff --git a/lib/admin-provisioning.ts b/lib/admin-provisioning.ts index 476d27d..aa06b8c 100644 --- a/lib/admin-provisioning.ts +++ b/lib/admin-provisioning.ts @@ -12,6 +12,7 @@ import { import { ensureBusinessNotificationSettings } from '@/lib/business-notification-settings'; import { db } from '@/lib/db'; import { getConfiguredAppBaseUrl } from '@/lib/env.server'; +import { findVerifiedClerkUserByEmail } from '@/lib/owner-linking'; import { attachNumberToMessagingService, createMessagingServiceForBusiness, @@ -37,16 +38,7 @@ export { export type { AdminProvisioningChecklistItem } from '@/lib/admin-provisioning-presenters'; export async function findClerkUserByEmail(email: string) { - const normalized = email.trim().toLowerCase(); - if (!normalized) return null; - - const client = await clerkClient(); - const result = await client.users.getUserList({ - emailAddress: [normalized], - limit: 1, - }); - - return result.data[0] ?? null; + return findVerifiedClerkUserByEmail(email); } function invitationBelongsToBusiness(invitation: { publicMetadata: Record | null }, businessId: string) { diff --git a/lib/admin-setup-remediation.ts b/lib/admin-setup-remediation.ts index 23c3505..88ac360 100644 --- a/lib/admin-setup-remediation.ts +++ b/lib/admin-setup-remediation.ts @@ -143,7 +143,7 @@ export function buildAdminSetupPanels(params: { 'Confirm the owner email you want attached to this business.', 'If the owner already has a CallbackCloser login, use Connect existing owner.', 'If not, send an invite from this panel.', - 'Come back to this step after the owner appears as connected.', + 'After the owner accepts and signs in, CallbackCloser should connect the account automatically or show Connect existing owner here as the fallback.', ], verification: [ 'The step should show Owner connected.', diff --git a/lib/auth.ts b/lib/auth.ts index 12be669..35a25a0 100644 --- a/lib/auth.ts +++ b/lib/auth.ts @@ -3,8 +3,7 @@ import { redirect } from 'next/navigation'; import { getAdminCustomerActingContext } from '@/lib/admin-customer-context'; import { getAdminSession } from '@/lib/admin'; -import { getBusinessForOwnerClerkId } from '@/lib/business-access'; -import { ensurePendingBusinessForOwner } from '@/lib/customer-setup-handoff'; +import { getOwnedBusinessForClerkUser, getOrCreateOwnedBusinessForClerkUser } from '@/lib/owner-linking'; import { getPortfolioDemoAuth, getPortfolioDemoBusiness, isPortfolioDemoMode } from '@/lib/portfolio-demo'; export async function requireAuth() { @@ -32,7 +31,10 @@ export async function getCurrentBusiness() { const { userId } = await auth(); if (!userId) return null; - return getBusinessForOwnerClerkId(userId); + const user = await currentUser(); + if (!user) return null; + + return getOwnedBusinessForClerkUser(user); } export async function requireBusiness() { @@ -45,25 +47,17 @@ export async function requireBusiness() { return adminCustomerContext.business; } - const { userId } = await requireAuth(); + await requireAuth(); const adminSession = await getAdminSession(); if (adminSession?.isAdmin) { redirect('/admin?intent=new-business-pilot'); } - let business = await getBusinessForOwnerClerkId(userId); - if (!business) { - const user = await currentUser(); - const ownerEmail = - (user?.primaryEmailAddressId - ? user.emailAddresses.find((email) => email.id === user.primaryEmailAddressId)?.emailAddress - : user?.emailAddresses[0]?.emailAddress) || null; - - business = await ensurePendingBusinessForOwner(userId, { - businessName: typeof user?.publicMetadata?.businessName === 'string' ? user.publicMetadata.businessName : null, - ownerEmail, - ownerName: user?.fullName || [user?.firstName, user?.lastName].filter(Boolean).join(' ') || null, - }); + const user = await currentUser(); + if (!user) { + redirect('/sign-in'); } + + const business = await getOrCreateOwnedBusinessForClerkUser(user); return business; } diff --git a/lib/owner-linking.ts b/lib/owner-linking.ts new file mode 100644 index 0000000..ffb0114 --- /dev/null +++ b/lib/owner-linking.ts @@ -0,0 +1,219 @@ +import { clerkClient } from '@clerk/nextjs/server'; +import type { Business } from '@prisma/client'; + +import { isPendingOwnerClerkId } from '@/lib/admin-provisioning-presenters'; +import { getBusinessForOwnerClerkId } from '@/lib/business-access'; +import { ensureBusinessNotificationSettings } from '@/lib/business-notification-settings'; +import { db } from '@/lib/db'; +import { recordBusinessOperatorEvent } from '@/lib/operator-events'; + +type ClerkEmailAddressLike = { + id?: string | null; + emailAddress?: string | null; + verification?: { + status?: string | null; + } | null; +}; + +type ClerkUserLike = { + id: string; + fullName?: string | null; + firstName?: string | null; + lastName?: string | null; + primaryEmailAddressId?: string | null; + publicMetadata?: Record | null; + emailAddresses: ClerkEmailAddressLike[]; +}; + +type AutoLinkPendingOwnerResult = + | { status: 'already_connected'; business: Business } + | { status: 'linked'; business: Business; matchedEmail: string } + | { status: 'no_verified_email' | 'no_pending_invite_match' | 'multiple_pending_matches' | 'already_linked_elsewhere' }; + +function normalizeEmail(value: string | null | undefined) { + const normalized = value?.trim().toLowerCase(); + return normalized || null; +} + +export function getClerkPrimaryEmailAddress(user: ClerkUserLike | null | undefined) { + if (!user) return null; + + const primaryEmail = user.primaryEmailAddressId + ? user.emailAddresses.find((email) => email.id === user.primaryEmailAddressId)?.emailAddress + : user.emailAddresses[0]?.emailAddress; + + return normalizeEmail(primaryEmail); +} + +export function getClerkVerifiedEmailAddresses(user: ClerkUserLike | null | undefined) { + if (!user) return []; + + return Array.from( + new Set( + user.emailAddresses + .filter((email) => email.verification?.status === 'verified') + .map((email) => normalizeEmail(email.emailAddress)) + .filter((email): email is string => Boolean(email)), + ), + ); +} + +export function getClerkDisplayName(user: ClerkUserLike | null | undefined) { + if (!user) return null; + const displayName = user.fullName?.trim() || [user.firstName, user.lastName].filter(Boolean).join(' ').trim(); + return displayName || null; +} + +export async function findVerifiedClerkUserByEmail(email: string) { + const normalized = normalizeEmail(email); + if (!normalized) return null; + + const client = await clerkClient(); + const result = await client.users.getUserList({ + emailAddress: [normalized], + limit: 1, + }); + + const matchedUser = result.data[0] ?? null; + if (!matchedUser) { + return null; + } + + return getClerkVerifiedEmailAddresses(matchedUser).includes(normalized) ? matchedUser : null; +} + +export async function autoLinkPendingOwnerInviteForUser(user: ClerkUserLike | null | undefined): Promise { + if (!user?.id) { + return { status: 'no_verified_email' }; + } + + const existingBusiness = await getBusinessForOwnerClerkId(user.id); + if (existingBusiness) { + return { status: 'already_connected', business: existingBusiness }; + } + + const verifiedEmails = getClerkVerifiedEmailAddresses(user); + if (verifiedEmails.length === 0) { + return { status: 'no_verified_email' }; + } + + const pendingMatches = await db.business.findMany({ + where: { + ownerInviteSentAt: { not: null }, + notificationSettings: { + is: { + ownerEmail: { in: verifiedEmails }, + }, + }, + }, + include: { + notificationSettings: { + select: { + ownerEmail: true, + }, + }, + }, + orderBy: { updatedAt: 'desc' }, + }); + + const eligibleMatches = pendingMatches.filter((business) => isPendingOwnerClerkId(business.ownerClerkId)); + if (eligibleMatches.length === 0) { + return { status: 'no_pending_invite_match' }; + } + + if (eligibleMatches.length > 1) { + return { status: 'multiple_pending_matches' }; + } + + const matchedBusiness = eligibleMatches[0]; + const existingOwnerBusiness = await db.business.findUnique({ + where: { ownerClerkId: user.id }, + select: { id: true }, + }); + + if (existingOwnerBusiness && existingOwnerBusiness.id !== matchedBusiness.id) { + return { status: 'already_linked_elsewhere' }; + } + + const matchedEmail = + verifiedEmails.find((email) => email === normalizeEmail(matchedBusiness.notificationSettings?.ownerEmail)) || + normalizeEmail(matchedBusiness.notificationSettings?.ownerEmail); + + if (!matchedEmail) { + return { status: 'no_pending_invite_match' }; + } + + await db.business.update({ + where: { id: matchedBusiness.id }, + data: { + ownerClerkId: user.id, + ownerName: getClerkDisplayName(user) || matchedBusiness.ownerName || null, + ownerInviteSentAt: null, + }, + }); + + await ensureBusinessNotificationSettings( + { + id: matchedBusiness.id, + ownerClerkId: user.id, + notifyPhone: matchedBusiness.notifyPhone, + }, + { + ownerEmail: matchedEmail, + }, + ); + + await recordBusinessOperatorEvent({ + businessId: matchedBusiness.id, + type: 'onboarding.owner_auto_linked', + category: 'ONBOARDING', + status: 'SUCCESS', + summary: 'Invited owner linked automatically', + details: { + ownerClerkId: user.id, + ownerEmail: matchedEmail, + source: 'verified_clerk_sign_in', + }, + }); + + return { + status: 'linked', + business: await db.business.findUniqueOrThrow({ where: { id: matchedBusiness.id } }), + matchedEmail, + }; +} + +export async function getOwnedBusinessForClerkUser(user: ClerkUserLike | null | undefined) { + if (!user?.id) return null; + + const existingBusiness = await getBusinessForOwnerClerkId(user.id); + if (existingBusiness) { + return existingBusiness; + } + + const autoLinkResult = await autoLinkPendingOwnerInviteForUser(user); + if (autoLinkResult.status === 'linked' || autoLinkResult.status === 'already_connected') { + return autoLinkResult.business; + } + + return null; +} + +export async function getOrCreateOwnedBusinessForClerkUser(user: ClerkUserLike | null | undefined) { + if (!user?.id) { + throw new Error('Authenticated Clerk user context is required to create or recover a business.'); + } + + const ownedBusiness = await getOwnedBusinessForClerkUser(user); + if (ownedBusiness) { + return ownedBusiness; + } + + const { ensurePendingBusinessForOwner } = await import('@/lib/customer-setup-handoff'); + + return ensurePendingBusinessForOwner(user.id, { + businessName: user.publicMetadata && typeof user.publicMetadata.businessName === 'string' ? user.publicMetadata.businessName : null, + ownerEmail: getClerkPrimaryEmailAddress(user), + ownerName: getClerkDisplayName(user), + }); +} diff --git a/tests/admin-owner-state.test.ts b/tests/admin-owner-state.test.ts index f81393c..7e8bf7b 100644 --- a/tests/admin-owner-state.test.ts +++ b/tests/admin-owner-state.test.ts @@ -15,7 +15,7 @@ test('owner state is invite-ready when contact info exists but no account is lin assert.equal(state.statusLabel, 'Invite ready to send'); }); -test('owner state shows accepted invite waiting for manual link when a user now exists', () => { +test('owner state shows accepted invite ready for fallback connection when a verified user now exists', () => { const state = deriveAdminOwnerState({ ownerClerkId: 'pending_owner_fixture', ownerName: 'Casey Owner', @@ -31,7 +31,7 @@ test('owner state shows accepted invite waiting for manual link when a user now assert.equal(state.status, 'accepted_needs_connection'); assert.equal(state.matchedUserId, 'user_123'); - assert.match(state.detail, /Connect existing owner/i); + assert.match(state.detail, /could not safely auto-link/i); }); test('owner state shows pending invitation truthfully before acceptance', () => { @@ -75,3 +75,15 @@ test('owner state distinguishes connected owners from broken stored links', () = assert.equal(broken.status, 'connection_broken'); assert.equal(broken.badgeVariant, 'destructive'); }); + +test('owner state uses the no-owner-invited label when no email is saved yet', () => { + const state = deriveAdminOwnerState({ + ownerClerkId: null, + ownerName: null, + ownerEmail: null, + ownerInviteSentAt: null, + }); + + assert.equal(state.status, 'missing'); + assert.equal(state.statusLabel, 'No owner invited'); +}); diff --git a/tests/managed-customer-setup.test.ts b/tests/managed-customer-setup.test.ts index f438ff2..ea8cc01 100644 --- a/tests/managed-customer-setup.test.ts +++ b/tests/managed-customer-setup.test.ts @@ -41,7 +41,7 @@ test('managed setup handoff is wired through customer and admin surfaces', () => assert.match(setupHandoff, /summary: 'New customer signup is waiting for founder setup'/); assert.match(setupHandoff, /subject: 'New CallbackCloser signup needs setup'/); assert.match(setupHandoff, /subject: 'Your CallbackCloser account is ready'/); - assert.match(auth, /ensurePendingBusinessForOwner/); + assert.match(auth, /getOrCreateOwnedBusinessForClerkUser/); assert.match(appLayout, /CustomerSetupWaitingPage/); assert.match(appLayout, /getCustomerWorkspaceNotice/); assert.match(adminHome, /Pending setup/); diff --git a/tests/owner-linking.test.ts b/tests/owner-linking.test.ts new file mode 100644 index 0000000..3e5a2dd --- /dev/null +++ b/tests/owner-linking.test.ts @@ -0,0 +1,200 @@ +import assert from 'node:assert/strict'; +import { randomUUID } from 'node:crypto'; +import test from 'node:test'; + +import { buildPendingOwnerClerkId } from '../lib/admin-provisioning-presenters.ts'; +import { db } from '../lib/db.ts'; +import { + autoLinkPendingOwnerInviteForUser, + getClerkVerifiedEmailAddresses, + getOrCreateOwnedBusinessForClerkUser, + getOwnedBusinessForClerkUser, +} from '../lib/owner-linking.ts'; + +function buildUser(params: { + id: string; + verifiedEmail?: string | null; + primaryEmail?: string | null; + unverifiedEmail?: string | null; + fullName?: string | null; +}) { + const emailAddresses = []; + + if (params.verifiedEmail) { + emailAddresses.push({ + id: 'email_verified', + emailAddress: params.verifiedEmail, + verification: { status: 'verified' as const }, + }); + } + + if (params.unverifiedEmail) { + emailAddresses.push({ + id: 'email_unverified', + emailAddress: params.unverifiedEmail, + verification: { status: 'unverified' as const }, + }); + } + + return { + id: params.id, + fullName: params.fullName || 'Casey Owner', + primaryEmailAddressId: + params.primaryEmail === params.unverifiedEmail && params.unverifiedEmail + ? 'email_unverified' + : params.verifiedEmail + ? 'email_verified' + : null, + emailAddresses, + publicMetadata: {}, + }; +} + +async function createPendingInviteBusiness(seed: string, ownerEmail: string, ownerInviteSentAt: Date | null = new Date()) { + return db.business.create({ + data: { + ownerClerkId: buildPendingOwnerClerkId(), + name: `Invite Test ${seed.slice(0, 8)}`, + ownerName: 'Pending Owner', + forwardingNumber: '+15125550123', + ownerInviteSentAt, + notificationSettings: { + create: { + ownerEmail, + ownerPhone: '+15125550124', + }, + }, + }, + include: { + notificationSettings: true, + }, + }); +} + +test('verified Clerk emails are the only ones eligible for auto-link matching', () => { + const emails = getClerkVerifiedEmailAddresses( + buildUser({ + id: 'user_verified_only', + verifiedEmail: 'owner@example.com', + unverifiedEmail: 'owner+stale@example.com', + fullName: 'Casey Owner', + }), + ); + + assert.deepEqual(emails, ['owner@example.com']); +}); + +test('auto-link promotes a verified invited owner into the durable business owner link', async () => { + const seed = randomUUID(); + const ownerEmail = `owner-${seed.slice(0, 8)}@example.com`; + const business = await createPendingInviteBusiness(seed, ownerEmail); + + try { + const result = await autoLinkPendingOwnerInviteForUser( + buildUser({ + id: `user_${seed}`, + verifiedEmail: ownerEmail, + fullName: 'Casey Owner', + }), + ); + + assert.equal(result.status, 'linked'); + + const updated = await db.business.findUniqueOrThrow({ + where: { id: business.id }, + include: { notificationSettings: true }, + }); + + assert.equal(updated.ownerClerkId, `user_${seed}`); + assert.equal(updated.ownerInviteSentAt, null); + assert.equal(updated.ownerName, 'Casey Owner'); + assert.equal(updated.notificationSettings?.ownerEmail, ownerEmail); + + const operatorEvent = await db.businessOperatorEvent.findFirst({ + where: { businessId: business.id, type: 'onboarding.owner_auto_linked' }, + orderBy: { createdAt: 'desc' }, + }); + + assert.equal(operatorEvent?.summary, 'Invited owner linked automatically'); + } finally { + await db.business.delete({ where: { id: business.id } }); + } +}); + +test('auto-link refuses mismatched emails and keeps the pending invite untouched', async () => { + const seed = randomUUID(); + const business = await createPendingInviteBusiness(seed, `owner-${seed.slice(0, 8)}@example.com`); + + try { + const result = await autoLinkPendingOwnerInviteForUser( + buildUser({ + id: `user_${seed}`, + verifiedEmail: `different-${seed.slice(0, 8)}@example.com`, + }), + ); + + assert.equal(result.status, 'no_pending_invite_match'); + + const unchanged = await db.business.findUniqueOrThrow({ where: { id: business.id } }); + assert.equal(unchanged.ownerClerkId, business.ownerClerkId); + assert.notEqual(unchanged.ownerInviteSentAt, null); + } finally { + await db.business.delete({ where: { id: business.id } }); + } +}); + +test('auto-link does not bypass the separate connect-existing-owner flow when no invite is pending', async () => { + const seed = randomUUID(); + const ownerEmail = `owner-${seed.slice(0, 8)}@example.com`; + const business = await createPendingInviteBusiness(seed, ownerEmail, null); + + try { + const result = await autoLinkPendingOwnerInviteForUser( + buildUser({ + id: `user_${seed}`, + verifiedEmail: ownerEmail, + }), + ); + + assert.equal(result.status, 'no_pending_invite_match'); + + const unchanged = await db.business.findUniqueOrThrow({ where: { id: business.id } }); + assert.equal(unchanged.ownerClerkId, business.ownerClerkId); + assert.equal(unchanged.ownerInviteSentAt, null); + } finally { + await db.business.delete({ where: { id: business.id } }); + } +}); + +test('owned business lookup reuses the linked invited business instead of creating a duplicate workspace', async () => { + const seed = randomUUID(); + const ownerEmail = `owner-${seed.slice(0, 8)}@example.com`; + const business = await createPendingInviteBusiness(seed, ownerEmail); + const user = buildUser({ + id: `user_${seed}`, + verifiedEmail: ownerEmail, + fullName: 'Casey Owner', + }); + + try { + const linkedBusiness = await getOwnedBusinessForClerkUser(user); + const resolvedBusiness = await getOrCreateOwnedBusinessForClerkUser(user); + + assert.equal(linkedBusiness?.id, business.id); + assert.equal(resolvedBusiness?.id, business.id); + + const ownedBusinesses = await db.business.findMany({ + where: { + OR: [{ id: business.id }, { ownerClerkId: user.id }], + }, + select: { id: true }, + }); + + assert.deepEqual( + ownedBusinesses.map((item) => item.id).sort(), + [business.id], + ); + } finally { + await db.business.delete({ where: { id: business.id } }); + } +}); diff --git a/tests/pilot-safety.test.ts b/tests/pilot-safety.test.ts index db62316..7656b08 100644 --- a/tests/pilot-safety.test.ts +++ b/tests/pilot-safety.test.ts @@ -38,7 +38,7 @@ test('managed setup handoff replaces the old self-serve onboarding route', () => const buyPage = read('app/buy/page.tsx'); assert.match(onboardingPage, /redirect\('\/app'\)/); - assert.match(auth, /ensurePendingBusinessForOwner/); + assert.match(auth, /getOrCreateOwnedBusinessForClerkUser/); assert.match(appLayout, /CustomerSetupWaitingPage/); assert.match(waitingPage, /Your missed-call recovery system is being set up/); assert.match(waitingPage, /You do not need to configure anything right now/i); diff --git a/tests/tenant-isolation-wiring.test.ts b/tests/tenant-isolation-wiring.test.ts index 2b7a2c0..4ab028b 100644 --- a/tests/tenant-isolation-wiring.test.ts +++ b/tests/tenant-isolation-wiring.test.ts @@ -19,7 +19,8 @@ test('protected app surfaces use shared tenant-scoped access helpers', () => { const billingPage = read('app/app/billing/page.tsx'); const recordingRoute = read('app/api/leads/[leadId]/recording/route.ts'); - assert.match(auth, /getBusinessForOwnerClerkId/); + assert.match(auth, /getOwnedBusinessForClerkUser/); + assert.match(auth, /getOrCreateOwnedBusinessForClerkUser/); assert.match(appHomePage, /requireBusiness/); assert.match(appHomePage, /listAllDashboardLeadsForBusiness/); assert.match(leadsPage, /listAllDashboardLeadsForBusiness/);